diff --git a/proto/api/v1/memo_service.proto b/proto/api/v1/memo_service.proto index 3a9bb461..7ae4b10f 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 2dcb8ac6..87530f29 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 16b6d993..f471c3eb 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 c55e666a..8dfa299a 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 76ea1f95..20bfbedd 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 a4f0588f..2e85a7cb 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 d43ea64e..47a3d224 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 d74b5463..bdabacf5 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 16360a3a..6991a6c5 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 32f79e9f..1054cd2f 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 72ec59a3..6f573b83 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 1262ee25..e1e9ef4a 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 34db04b4..810b4541 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 28c3049a..ecce5aa4 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 26d422d9..126c75ea 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 3633f0d8..1e4f43ad 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 02cfe12d..9e2f848c 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 41ed4050..8c979c79 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 05451058..11b29ecd 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 fcbea13b..5c09483f 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 e8f812c8..2b423f72 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 5eea60b3..41f2bb95 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 0d35d6af..6a53e129 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 ef2f103f..b3c0ee35 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 7949eae0..afd71e29 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 b2409aee..d4058040 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 a83534cb..11df1635 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 12cb75de..26e270b4 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 505738c9..c4d6370e 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 1f6406f8..9eabbcb1 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; /**