diff --git a/.gitignore b/.gitignore index abd9d94fcb5ee..2d8e5355562e5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ web/dist # Build artifacts build/ bin/ -memos + # Plan/design documents docs/plans/ diff --git a/cmd/memos/main.go b/cmd/memos/main.go index 48dad202d5fee..6ecf58a7ef28f 100644 --- a/cmd/memos/main.go +++ b/cmd/memos/main.go @@ -169,6 +169,7 @@ func printGreetings(profile *profile.Profile) { } func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))) if err := rootCmd.Execute(); err != nil { panic(err) } diff --git a/server/router/api/v1/memo_service.go b/server/router/api/v1/memo_service.go index f407b009e5fbb..88ccc971b74d7 100644 --- a/server/router/api/v1/memo_service.go +++ b/server/router/api/v1/memo_service.go @@ -45,6 +45,15 @@ func (s *APIV1Service) CreateMemo(ctx context.Context, request *v1pb.CreateMemoR Content: request.Memo.Content, Visibility: convertVisibilityToStore(request.Memo.Visibility), } + if request.Memo.CreateTime != nil { + create.CreatedTs = request.Memo.CreateTime.AsTime().Unix() + } + // If UpdateTime is provided, use it. Otherwise, if CreateTime is provided, use it for UpdatedTs as well. + if request.Memo.UpdateTime != nil { + create.UpdatedTs = request.Memo.UpdateTime.AsTime().Unix() + } else if request.Memo.CreateTime != nil { + create.UpdatedTs = request.Memo.CreateTime.AsTime().Unix() + } instanceMemoRelatedSetting, err := s.Store.GetInstanceMemoRelatedSetting(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get instance memo related setting") diff --git a/store/db/postgres/memo.go b/store/db/postgres/memo.go index 3fa3abd4b10f0..f8dfe1c3b4f37 100644 --- a/store/db/postgres/memo.go +++ b/store/db/postgres/memo.go @@ -14,7 +14,17 @@ import ( ) func (d *DB) CreateMemo(ctx context.Context, create *store.Memo) (*store.Memo, error) { - fields := []string{"uid", "creator_id", "content", "visibility", "payload"} + fields := []string{"uid", "creator_id", "content", "visibility"} + args := []any{create.UID, create.CreatorID, create.Content, create.Visibility} + if create.CreatedTs != 0 { + fields = append(fields, "created_ts") + args = append(args, create.CreatedTs) + } + if create.UpdatedTs != 0 { + fields = append(fields, "updated_ts") + args = append(args, create.UpdatedTs) + } + payload := "{}" if create.Payload != nil { payloadBytes, err := protojson.Marshal(create.Payload) @@ -23,13 +33,12 @@ func (d *DB) CreateMemo(ctx context.Context, create *store.Memo) (*store.Memo, e } payload = string(payloadBytes) } - args := []any{create.UID, create.CreatorID, create.Content, create.Visibility, payload} + fields = append(fields, "payload") + args = append(args, payload) - stmt := "INSERT INTO memo (" + strings.Join(fields, ", ") + ") VALUES (" + placeholders(len(args)) + ") RETURNING id, created_ts, updated_ts, row_status" + stmt := "INSERT INTO memo (" + strings.Join(fields, ", ") + ") VALUES (" + placeholders(len(args)) + ") RETURNING id, row_status" if err := d.db.QueryRowContext(ctx, stmt, args...).Scan( &create.ID, - &create.CreatedTs, - &create.UpdatedTs, &create.RowStatus, ); err != nil { return nil, err diff --git a/store/db/sqlite/memo.go b/store/db/sqlite/memo.go index f3bc2f54d17eb..8c9e6c234ae1a 100644 --- a/store/db/sqlite/memo.go +++ b/store/db/sqlite/memo.go @@ -16,6 +16,15 @@ import ( func (d *DB) CreateMemo(ctx context.Context, create *store.Memo) (*store.Memo, error) { fields := []string{"`uid`", "`creator_id`", "`content`", "`visibility`", "`payload`"} placeholder := []string{"?", "?", "?", "?", "?"} + if create.CreatedTs != 0 { + fields = append(fields, "`created_ts`") + placeholder = append(placeholder, "?") + } + if create.UpdatedTs != 0 { + fields = append(fields, "`updated_ts`") + placeholder = append(placeholder, "?") + } + payload := "{}" if create.Payload != nil { payloadBytes, err := protojson.Marshal(create.Payload) @@ -25,12 +34,16 @@ func (d *DB) CreateMemo(ctx context.Context, create *store.Memo) (*store.Memo, e payload = string(payloadBytes) } args := []any{create.UID, create.CreatorID, create.Content, create.Visibility, payload} + if create.CreatedTs != 0 { + args = append(args, create.CreatedTs) + } + if create.UpdatedTs != 0 { + args = append(args, create.UpdatedTs) + } - stmt := "INSERT INTO `memo` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholder, ", ") + ") RETURNING `id`, `created_ts`, `updated_ts`, `row_status`" + stmt := "INSERT INTO `memo` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholder, ", ") + ") RETURNING `id`, `row_status`" if err := d.db.QueryRowContext(ctx, stmt, args...).Scan( &create.ID, - &create.CreatedTs, - &create.UpdatedTs, &create.RowStatus, ); err != nil { return nil, err diff --git a/web/src/components/ActivityCalendar/CalendarCell.tsx b/web/src/components/ActivityCalendar/CalendarCell.tsx index 48026e46013e2..f237eb89a2d23 100644 --- a/web/src/components/ActivityCalendar/CalendarCell.tsx +++ b/web/src/components/ActivityCalendar/CalendarCell.tsx @@ -17,7 +17,7 @@ export const CalendarCell = memo((props: CalendarCellProps) => { const { day, maxCount, tooltipText, onClick, size = "default" } = props; const handleClick = () => { - if (day.count > 0 && onClick) { + if (onClick) { onClick(day.date); } }; @@ -31,7 +31,7 @@ export const CalendarCell = memo((props: CalendarCellProps) => { sizeConfig.borderRadius, smallExtraClasses, ); - const isInteractive = Boolean(onClick && day.count > 0); + const isInteractive = Boolean(onClick); const ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText; if (!day.isCurrentMonth) { diff --git a/web/src/components/ActivityCalendar/MonthCalendar.tsx b/web/src/components/ActivityCalendar/MonthCalendar.tsx index b1a9a973d597e..7eb041f0d73a7 100644 --- a/web/src/components/ActivityCalendar/MonthCalendar.tsx +++ b/web/src/components/ActivityCalendar/MonthCalendar.tsx @@ -1,5 +1,6 @@ -import { memo } from "react"; +import { memo, useMemo } from "react"; import { useInstance } from "@/contexts/InstanceContext"; +import { useMemoFilterContext } from "@/contexts/MemoFilterContext"; import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; import { CalendarCell } from "./CalendarCell"; @@ -13,11 +14,13 @@ export const MonthCalendar = memo((props: MonthCalendarProps) => { const { month, data, maxCount, size = "default", onClick, className } = props; const t = useTranslate(); const { generalSetting } = useInstance(); + const { getFiltersByFactor } = useMemoFilterContext(); const weekStartDayOffset = generalSetting.weekStartDayOffset; const today = useTodayDate(); const weekDays = useWeekdayLabels(); + const selectedDate = useMemo(() => getFiltersByFactor("displayTime")?.[0]?.value || "", [getFiltersByFactor]); const { weeks, weekDays: rotatedWeekDays } = useCalendarMatrix({ month, @@ -25,7 +28,7 @@ export const MonthCalendar = memo((props: MonthCalendarProps) => { weekDays, weekStartDayOffset, today, - selectedDate: "", + selectedDate: selectedDate, }); const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE; diff --git a/web/src/components/MemoEditor/components/EditorMetadata.tsx b/web/src/components/MemoEditor/components/EditorMetadata.tsx index dc9ba17bd865e..bd2e0dcecf273 100644 --- a/web/src/components/MemoEditor/components/EditorMetadata.tsx +++ b/web/src/components/MemoEditor/components/EditorMetadata.tsx @@ -1,4 +1,6 @@ import type { FC } from "react"; +import DateTimeInput from "@/components/DateTimeInput"; +import { useTranslate } from "@/utils/i18n"; import { useEditorContext } from "../state"; import type { EditorMetadataProps } from "../types"; import AttachmentList from "./AttachmentList"; @@ -6,6 +8,7 @@ import LocationDisplay from "./LocationDisplay"; import RelationList from "./RelationList"; export const EditorMetadata: FC = () => { + const t = useTranslate(); const { state, actions, dispatch } = useEditorContext(); return ( @@ -22,6 +25,15 @@ export const EditorMetadata: FC = () => { {state.metadata.location && ( dispatch(actions.setMetadata({ location: undefined }))} /> )} +
+
+ {t("editor.created-at")}: + dispatch(actions.setTimestamps({ createTime: date }))} + /> +
+
); }; diff --git a/web/src/components/MemoEditor/hooks/useMemoInit.ts b/web/src/components/MemoEditor/hooks/useMemoInit.ts index d49a487291ebf..bac204f8ae8d1 100644 --- a/web/src/components/MemoEditor/hooks/useMemoInit.ts +++ b/web/src/components/MemoEditor/hooks/useMemoInit.ts @@ -1,4 +1,6 @@ +import dayjs from "dayjs"; import { useEffect, useRef } from "react"; +import { useMemoFilterContext } from "@/contexts/MemoFilterContext"; import type { EditorRefActions } from "../Editor"; import { cacheService, memoService } from "../services"; import { useEditorContext } from "../state"; @@ -10,7 +12,8 @@ export const useMemoInit = ( username: string, autoFocus?: boolean, ) => { - const { actions, dispatch } = useEditorContext(); + const { state, actions, dispatch } = useEditorContext(); + const { getFiltersByFactor } = useMemoFilterContext(); const initializedRef = useRef(false); useEffect(() => { @@ -32,7 +35,14 @@ export const useMemoInit = ( }), ); } else { - // Load from cache for new memo + // New memo: first apply date filter if not already set, then load from cache + if (!state.timestamps.createTime) { + const displayTimeFilter = getFiltersByFactor("displayTime")?.[0]?.value; + if (displayTimeFilter) { + dispatch(actions.setTimestamps({ createTime: dayjs(displayTimeFilter).toDate() })); + } + } + const cachedContent = cacheService.load(cacheService.key(username, cacheKey)); if (cachedContent) { dispatch(actions.updateContent(cachedContent)); @@ -52,5 +62,5 @@ export const useMemoInit = ( }; init(); - }, [memoName, cacheKey, username, autoFocus, actions, dispatch, editorRef]); + }, [memoName, cacheKey, username, autoFocus, actions, dispatch, editorRef, getFiltersByFactor, state.timestamps.createTime]); }; diff --git a/web/src/components/MemoEditor/state/actions.ts b/web/src/components/MemoEditor/state/actions.ts index ec46bd05a62fa..67700b5f986a3 100644 --- a/web/src/components/MemoEditor/state/actions.ts +++ b/web/src/components/MemoEditor/state/actions.ts @@ -19,6 +19,11 @@ export const editorActions = { payload: metadata, }), + setTimestamps: (timestamps: Partial): EditorAction => ({ + type: "SET_TIMESTAMPS", + payload: timestamps, + }), + addAttachment: (attachment: Attachment): EditorAction => ({ type: "ADD_ATTACHMENT", payload: attachment, diff --git a/web/src/components/MemoEditor/state/reducer.ts b/web/src/components/MemoEditor/state/reducer.ts index cc935f2bf4979..781056b2ffc4a 100644 --- a/web/src/components/MemoEditor/state/reducer.ts +++ b/web/src/components/MemoEditor/state/reducer.ts @@ -26,6 +26,15 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS }, }; + case "SET_TIMESTAMPS": + return { + ...state, + timestamps: { + ...state.timestamps, + ...action.payload, + }, + }; + case "ADD_ATTACHMENT": return { ...state, diff --git a/web/src/components/MemoEditor/state/types.ts b/web/src/components/MemoEditor/state/types.ts index 48289b210245d..8612729579b6c 100644 --- a/web/src/components/MemoEditor/state/types.ts +++ b/web/src/components/MemoEditor/state/types.ts @@ -34,6 +34,7 @@ export type EditorAction = | { type: "INIT_MEMO"; payload: { content: string; metadata: EditorState["metadata"]; timestamps: EditorState["timestamps"] } } | { type: "UPDATE_CONTENT"; payload: string } | { type: "SET_METADATA"; payload: Partial } + | { type: "SET_TIMESTAMPS"; payload: Partial } | { type: "ADD_ATTACHMENT"; payload: Attachment } | { type: "REMOVE_ATTACHMENT"; payload: string } | { type: "ADD_RELATION"; payload: MemoRelation } diff --git a/web/src/hooks/useDateFilterNavigation.ts b/web/src/hooks/useDateFilterNavigation.ts index ce0a167b68986..e52681164a6a3 100644 --- a/web/src/hooks/useDateFilterNavigation.ts +++ b/web/src/hooks/useDateFilterNavigation.ts @@ -1,16 +1,28 @@ import { useCallback } from "react"; import { useNavigate } from "react-router-dom"; -import { stringifyFilters } from "@/contexts/MemoFilterContext"; +import { MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext"; export const useDateFilterNavigation = () => { const navigate = useNavigate(); + const { filters } = useMemoFilterContext(); const navigateToDateFilter = useCallback( (date: string) => { - const filterQuery = stringifyFilters([{ factor: "displayTime", value: date }]); - navigate(`/?filter=${filterQuery}`); + const otherFilters = filters.filter((f) => f.factor !== "displayTime"); + const newFilters: MemoFilter[] = [...otherFilters]; + const existingDateFilter = filters.find((f) => f.factor === "displayTime"); + + // If the selected date is different from the current filter, add the new filter. + // If the selected date is the same, the filter is effectively removed. + if (existingDateFilter?.value !== date) { + newFilters.push({ factor: "displayTime", value: date }); + } + + const filterQuery = stringifyFilters(newFilters); + const targetUrl = filterQuery ? `/?filter=${filterQuery}` : "/"; + navigate(targetUrl); }, - [navigate], + [filters, navigate], ); return navigateToDateFilter; diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 41d8974151428..2d46678891a6c 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -124,7 +124,8 @@ "no-changes-detected": "No changes detected", "focus-mode": "Focus Mode", "exit-focus-mode": "Exit Focus Mode", - "slash-commands": "Type `/` for commands" + "slash-commands": "Type `/` for commands", + "created-at": "Created at" }, "filters": { "has-code": "hasCode",