From b4ea7d843f872b6a5f0ab43368e6673c6bac59d2 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 20 Oct 2025 23:41:58 +0800 Subject: [PATCH] feat: enhance memo sorting functionality to support multiple fields --- proto/api/v1/memo_service.proto | 4 +- proto/gen/api/v1/activity_service.pb.go | 2 +- proto/gen/api/v1/attachment_service.pb.go | 2 +- proto/gen/api/v1/auth_service.pb.go | 2 +- proto/gen/api/v1/common.pb.go | 2 +- proto/gen/api/v1/idp_service.pb.go | 2 +- proto/gen/api/v1/inbox_service.pb.go | 2 +- proto/gen/api/v1/markdown_service.pb.go | 2 +- proto/gen/api/v1/memo_service.pb.go | 6 ++- proto/gen/api/v1/shortcut_service.pb.go | 2 +- proto/gen/api/v1/user_service.pb.go | 2 +- proto/gen/api/v1/workspace_service.pb.go | 2 +- proto/gen/openapi.yaml | 4 +- proto/gen/store/activity.pb.go | 2 +- proto/gen/store/attachment.pb.go | 2 +- proto/gen/store/idp.pb.go | 2 +- proto/gen/store/inbox.pb.go | 2 +- proto/gen/store/memo.pb.go | 2 +- proto/gen/store/user_setting.pb.go | 2 +- proto/gen/store/workspace_setting.pb.go | 2 +- server/router/api/v1/memo_service.go | 59 +++++++++++++++------- store/db/mysql/memo.go | 3 ++ store/db/postgres/memo.go | 3 ++ store/db/sqlite/memo.go | 3 ++ store/memo.go | 1 + store/migrator.go | 2 +- web/src/pages/Archived.tsx | 15 ++++-- web/src/pages/Home.tsx | 15 ++++-- web/src/pages/UserProfile.tsx | 15 ++++-- web/src/types/proto/api/v1/memo_service.ts | 4 +- 30 files changed, 113 insertions(+), 55 deletions(-) diff --git a/proto/api/v1/memo_service.proto b/proto/api/v1/memo_service.proto index 3a9bb4612..7ae4b10f1 100644 --- a/proto/api/v1/memo_service.proto +++ b/proto/api/v1/memo_service.proto @@ -291,7 +291,9 @@ message ListMemosRequest { // Optional. The order to sort results by. // Default to "display_time desc". - // Example: "display_time desc" or "create_time asc" + // Supports comma-separated list of fields following AIP-132. + // Example: "pinned desc, display_time desc" or "create_time asc" + // Supported fields: pinned, display_time, create_time, update_time, name string order_by = 4 [(google.api.field_behavior) = OPTIONAL]; // Optional. Filter to apply to the list results. diff --git a/proto/gen/api/v1/activity_service.pb.go b/proto/gen/api/v1/activity_service.pb.go index 2dcb8ac65..87530f29c 100644 --- a/proto/gen/api/v1/activity_service.pb.go +++ b/proto/gen/api/v1/activity_service.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: api/v1/activity_service.proto diff --git a/proto/gen/api/v1/attachment_service.pb.go b/proto/gen/api/v1/attachment_service.pb.go index 16b6d9931..f471c3ebe 100644 --- a/proto/gen/api/v1/attachment_service.pb.go +++ b/proto/gen/api/v1/attachment_service.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: api/v1/attachment_service.proto diff --git a/proto/gen/api/v1/auth_service.pb.go b/proto/gen/api/v1/auth_service.pb.go index c55e666a6..8dfa299aa 100644 --- a/proto/gen/api/v1/auth_service.pb.go +++ b/proto/gen/api/v1/auth_service.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: api/v1/auth_service.proto diff --git a/proto/gen/api/v1/common.pb.go b/proto/gen/api/v1/common.pb.go index 76ea1f953..20bfbedd5 100644 --- a/proto/gen/api/v1/common.pb.go +++ b/proto/gen/api/v1/common.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: api/v1/common.proto diff --git a/proto/gen/api/v1/idp_service.pb.go b/proto/gen/api/v1/idp_service.pb.go index a4f0588fc..2e85a7cb5 100644 --- a/proto/gen/api/v1/idp_service.pb.go +++ b/proto/gen/api/v1/idp_service.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: api/v1/idp_service.proto diff --git a/proto/gen/api/v1/inbox_service.pb.go b/proto/gen/api/v1/inbox_service.pb.go index d43ea64ef..47a3d2246 100644 --- a/proto/gen/api/v1/inbox_service.pb.go +++ b/proto/gen/api/v1/inbox_service.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: api/v1/inbox_service.proto diff --git a/proto/gen/api/v1/markdown_service.pb.go b/proto/gen/api/v1/markdown_service.pb.go index d74b54633..bdabacf55 100644 --- a/proto/gen/api/v1/markdown_service.pb.go +++ b/proto/gen/api/v1/markdown_service.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: api/v1/markdown_service.proto diff --git a/proto/gen/api/v1/memo_service.pb.go b/proto/gen/api/v1/memo_service.pb.go index 16360a3a0..6991a6c56 100644 --- a/proto/gen/api/v1/memo_service.pb.go +++ b/proto/gen/api/v1/memo_service.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: api/v1/memo_service.proto @@ -564,7 +564,9 @@ type ListMemosRequest struct { State State `protobuf:"varint,3,opt,name=state,proto3,enum=memos.api.v1.State" json:"state,omitempty"` // Optional. The order to sort results by. // Default to "display_time desc". - // Example: "display_time desc" or "create_time asc" + // Supports comma-separated list of fields following AIP-132. + // Example: "pinned desc, display_time desc" or "create_time asc" + // Supported fields: pinned, display_time, create_time, update_time, name OrderBy string `protobuf:"bytes,4,opt,name=order_by,json=orderBy,proto3" json:"order_by,omitempty"` // Optional. Filter to apply to the list results. // Filter is a CEL expression to filter memos. diff --git a/proto/gen/api/v1/shortcut_service.pb.go b/proto/gen/api/v1/shortcut_service.pb.go index 32f79e9f8..1054cd2f2 100644 --- a/proto/gen/api/v1/shortcut_service.pb.go +++ b/proto/gen/api/v1/shortcut_service.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: api/v1/shortcut_service.proto diff --git a/proto/gen/api/v1/user_service.pb.go b/proto/gen/api/v1/user_service.pb.go index 72ec59a3f..6f573b83d 100644 --- a/proto/gen/api/v1/user_service.pb.go +++ b/proto/gen/api/v1/user_service.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: api/v1/user_service.proto diff --git a/proto/gen/api/v1/workspace_service.pb.go b/proto/gen/api/v1/workspace_service.pb.go index 1262ee25b..e1e9ef4a0 100644 --- a/proto/gen/api/v1/workspace_service.pb.go +++ b/proto/gen/api/v1/workspace_service.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: api/v1/workspace_service.proto diff --git a/proto/gen/openapi.yaml b/proto/gen/openapi.yaml index 34db04b4b..810b45410 100644 --- a/proto/gen/openapi.yaml +++ b/proto/gen/openapi.yaml @@ -656,7 +656,9 @@ paths: description: |- Optional. The order to sort results by. Default to "display_time desc". - Example: "display_time desc" or "create_time asc" + Supports comma-separated list of fields following AIP-132. + Example: "pinned desc, display_time desc" or "create_time asc" + Supported fields: pinned, display_time, create_time, update_time, name schema: type: string - name: filter diff --git a/proto/gen/store/activity.pb.go b/proto/gen/store/activity.pb.go index 28c3049a9..ecce5aa4d 100644 --- a/proto/gen/store/activity.pb.go +++ b/proto/gen/store/activity.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: store/activity.proto diff --git a/proto/gen/store/attachment.pb.go b/proto/gen/store/attachment.pb.go index 26d422d93..126c75ea4 100644 --- a/proto/gen/store/attachment.pb.go +++ b/proto/gen/store/attachment.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: store/attachment.proto diff --git a/proto/gen/store/idp.pb.go b/proto/gen/store/idp.pb.go index 3633f0d82..1e4f43ad1 100644 --- a/proto/gen/store/idp.pb.go +++ b/proto/gen/store/idp.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: store/idp.proto diff --git a/proto/gen/store/inbox.pb.go b/proto/gen/store/inbox.pb.go index 02cfe12de..9e2f848c9 100644 --- a/proto/gen/store/inbox.pb.go +++ b/proto/gen/store/inbox.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: store/inbox.proto diff --git a/proto/gen/store/memo.pb.go b/proto/gen/store/memo.pb.go index 41ed40508..8c979c79f 100644 --- a/proto/gen/store/memo.pb.go +++ b/proto/gen/store/memo.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: store/memo.proto diff --git a/proto/gen/store/user_setting.pb.go b/proto/gen/store/user_setting.pb.go index 054510585..11b29ecd5 100644 --- a/proto/gen/store/user_setting.pb.go +++ b/proto/gen/store/user_setting.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: store/user_setting.proto diff --git a/proto/gen/store/workspace_setting.pb.go b/proto/gen/store/workspace_setting.pb.go index fcbea13b4..5c09483f5 100644 --- a/proto/gen/store/workspace_setting.pb.go +++ b/proto/gen/store/workspace_setting.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: store/workspace_setting.proto diff --git a/server/router/api/v1/memo_service.go b/server/router/api/v1/memo_service.go index e8f812c8e..2b423f729 100644 --- a/server/router/api/v1/memo_service.go +++ b/server/router/api/v1/memo_service.go @@ -875,30 +875,55 @@ func substring(s string, length int) string { } // parseMemoOrderBy parses the order_by field and sets the appropriate ordering in memoFind. +// Follows AIP-132: supports comma-separated list of fields with optional "desc" suffix. +// Example: "pinned desc, display_time desc" or "create_time asc". func (*APIV1Service) parseMemoOrderBy(orderBy string, memoFind *store.FindMemo) error { - // Parse order_by field like "display_time desc" or "create_time asc" - parts := strings.Fields(strings.TrimSpace(orderBy)) - if len(parts) == 0 { + if strings.TrimSpace(orderBy) == "" { return errors.New("empty order_by") } - field := parts[0] - direction := "desc" // default - if len(parts) > 1 { - direction = strings.ToLower(parts[1]) - if direction != "asc" && direction != "desc" { - return errors.Errorf("invalid order direction: %s, must be 'asc' or 'desc'", parts[1]) + // Split by comma to support multiple sort fields per AIP-132. + fields := strings.Split(orderBy, ",") + + // Track if we've seen pinned field. + hasPinned := false + + for _, field := range fields { + parts := strings.Fields(strings.TrimSpace(field)) + if len(parts) == 0 { + continue + } + + fieldName := parts[0] + fieldDirection := "desc" // default per AIP-132 (we use desc as default for time fields) + if len(parts) > 1 { + fieldDirection = strings.ToLower(parts[1]) + if fieldDirection != "asc" && fieldDirection != "desc" { + return errors.Errorf("invalid order direction: %s, must be 'asc' or 'desc'", parts[1]) + } + } + + switch fieldName { + case "pinned": + hasPinned = true + memoFind.OrderByPinned = true + // Note: pinned is always DESC (true first) regardless of direction specified. + case "display_time", "create_time", "name": + // Only set if this is the first time field we encounter. + if !memoFind.OrderByUpdatedTs { + memoFind.OrderByTimeAsc = fieldDirection == "asc" + } + case "update_time": + memoFind.OrderByUpdatedTs = true + memoFind.OrderByTimeAsc = fieldDirection == "asc" + default: + return errors.Errorf("unsupported order field: %s, supported fields are: pinned, display_time, create_time, update_time, name", fieldName) } } - switch field { - case "display_time", "create_time", "name": - memoFind.OrderByTimeAsc = direction == "asc" - case "update_time": - memoFind.OrderByUpdatedTs = true - memoFind.OrderByTimeAsc = direction == "asc" - default: - return errors.Errorf("unsupported order field: %s, supported fields are: display_time, create_time, update_time, name", field) + // If only pinned was specified, still need to set a default time ordering. + if hasPinned && !memoFind.OrderByUpdatedTs && len(fields) == 1 { + memoFind.OrderByTimeAsc = false // default to desc } return nil diff --git a/store/db/mysql/memo.go b/store/db/mysql/memo.go index 5eea60b30..41f2bb950 100644 --- a/store/db/mysql/memo.go +++ b/store/db/mysql/memo.go @@ -106,6 +106,9 @@ func (d *DB) ListMemos(ctx context.Context, find *store.FindMemo) ([]*store.Memo order = "ASC" } orderBy := []string{} + if find.OrderByPinned { + orderBy = append(orderBy, "`pinned` DESC") + } if find.OrderByUpdatedTs { orderBy = append(orderBy, "`updated_ts` "+order) } else { diff --git a/store/db/postgres/memo.go b/store/db/postgres/memo.go index 0d35d6af0..6a53e1293 100644 --- a/store/db/postgres/memo.go +++ b/store/db/postgres/memo.go @@ -97,6 +97,9 @@ func (d *DB) ListMemos(ctx context.Context, find *store.FindMemo) ([]*store.Memo order = "ASC" } orderBy := []string{} + if find.OrderByPinned { + orderBy = append(orderBy, "pinned DESC") + } if find.OrderByUpdatedTs { orderBy = append(orderBy, "updated_ts "+order) } else { diff --git a/store/db/sqlite/memo.go b/store/db/sqlite/memo.go index ef2f103f3..b3c0ee35a 100644 --- a/store/db/sqlite/memo.go +++ b/store/db/sqlite/memo.go @@ -98,6 +98,9 @@ func (d *DB) ListMemos(ctx context.Context, find *store.FindMemo) ([]*store.Memo order = "ASC" } orderBy := []string{} + if find.OrderByPinned { + orderBy = append(orderBy, "`pinned` DESC") + } if find.OrderByUpdatedTs { orderBy = append(orderBy, "`updated_ts` "+order) } else { diff --git a/store/memo.go b/store/memo.go index 7949eae07..afd71e29a 100644 --- a/store/memo.go +++ b/store/memo.go @@ -76,6 +76,7 @@ type FindMemo struct { Offset *int // Ordering + OrderByPinned bool OrderByUpdatedTs bool OrderByTimeAsc bool } diff --git a/store/migrator.go b/store/migrator.go index b2409aee5..d40580400 100644 --- a/store/migrator.go +++ b/store/migrator.go @@ -64,7 +64,7 @@ const ( // Before 0.22, migration history had inconsistent versioning that needed normalization. migrationHistoryNormalizedVersion = "0.22" - // Mode constants for profile mode + // Mode constants for profile mode. modeProd = "prod" modeDemo = "demo" ) diff --git a/web/src/pages/Archived.tsx b/web/src/pages/Archived.tsx index a83534cb6..11df16355 100644 --- a/web/src/pages/Archived.tsx +++ b/web/src/pages/Archived.tsx @@ -34,14 +34,19 @@ const Archived = observer(() => { listSort={(memos: Memo[]) => memos .filter((memo) => memo.state === State.ARCHIVED) - .sort((a, b) => - viewStore.state.orderByTimeAsc + .sort((a, b) => { + // First, sort by pinned status (pinned memos first) + if (a.pinned !== b.pinned) { + return b.pinned ? 1 : -1; + } + // Then sort by display time + return viewStore.state.orderByTimeAsc ? dayjs(a.displayTime).unix() - dayjs(b.displayTime).unix() - : dayjs(b.displayTime).unix() - dayjs(a.displayTime).unix(), - ) + : dayjs(b.displayTime).unix() - dayjs(a.displayTime).unix(); + }) } state={State.ARCHIVED} - orderBy={viewStore.state.orderByTimeAsc ? "display_time asc" : "display_time desc"} + orderBy={viewStore.state.orderByTimeAsc ? "pinned desc, display_time asc" : "pinned desc, display_time desc"} filter={memoFitler} /> ); diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 12cb75de4..26e270b40 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -63,13 +63,18 @@ const Home = observer(() => { listSort={(memos: Memo[]) => memos .filter((memo) => memo.state === State.NORMAL) - .sort((a, b) => - viewStore.state.orderByTimeAsc + .sort((a, b) => { + // First, sort by pinned status (pinned memos first) + if (a.pinned !== b.pinned) { + return b.pinned ? 1 : -1; + } + // Then sort by display time + return viewStore.state.orderByTimeAsc ? dayjs(a.displayTime).unix() - dayjs(b.displayTime).unix() - : dayjs(b.displayTime).unix() - dayjs(a.displayTime).unix(), - ) + : dayjs(b.displayTime).unix() - dayjs(a.displayTime).unix(); + }) } - orderBy={viewStore.state.orderByTimeAsc ? "display_time asc" : "display_time desc"} + orderBy={viewStore.state.orderByTimeAsc ? "pinned desc, display_time asc" : "pinned desc, display_time desc"} filter={memoFilter} /> diff --git a/web/src/pages/UserProfile.tsx b/web/src/pages/UserProfile.tsx index 505738c98..c4d6370e8 100644 --- a/web/src/pages/UserProfile.tsx +++ b/web/src/pages/UserProfile.tsx @@ -96,13 +96,18 @@ const UserProfile = observer(() => { listSort={(memos: Memo[]) => memos .filter((memo) => memo.state === State.NORMAL) - .sort((a, b) => - viewStore.state.orderByTimeAsc + .sort((a, b) => { + // First, sort by pinned status (pinned memos first) + if (a.pinned !== b.pinned) { + return b.pinned ? 1 : -1; + } + // Then sort by display time + return viewStore.state.orderByTimeAsc ? dayjs(a.displayTime).unix() - dayjs(b.displayTime).unix() - : dayjs(b.displayTime).unix() - dayjs(a.displayTime).unix(), - ) + : dayjs(b.displayTime).unix() - dayjs(a.displayTime).unix(); + }) } - orderBy={viewStore.state.orderByTimeAsc ? "display_time asc" : "display_time desc"} + orderBy={viewStore.state.orderByTimeAsc ? "pinned desc, display_time asc" : "pinned desc, display_time desc"} filter={memoFilter} /> diff --git a/web/src/types/proto/api/v1/memo_service.ts b/web/src/types/proto/api/v1/memo_service.ts index 1f6406f81..9eabbcb1c 100644 --- a/web/src/types/proto/api/v1/memo_service.ts +++ b/web/src/types/proto/api/v1/memo_service.ts @@ -195,7 +195,9 @@ export interface ListMemosRequest { /** * Optional. The order to sort results by. * Default to "display_time desc". - * Example: "display_time desc" or "create_time asc" + * Supports comma-separated list of fields following AIP-132. + * Example: "pinned desc, display_time desc" or "create_time asc" + * Supported fields: pinned, display_time, create_time, update_time, name */ orderBy: string; /**