From dfa0fda76602d49dfbb68a6683ef20b068c8d45b Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 4 Sep 2026 09:29:09 +0800 Subject: [PATCH] feat(filter): support space placement in saved views (#6267) --- docs/design/multi-spaces.md | 2 +- internal/filter/engine_test.go | 11 +++++++++-- internal/filter/parser.go | 14 +++++++++++++- proto/api/v1/memo_service.proto | 3 ++- proto/gen/api/v1/memo_service.pb.go | 3 ++- proto/gen/openapi.yaml | 3 ++- web/src/pages/MemoViews.tsx | 16 ++++++++++++++++ web/src/types/proto/api/v1/memo_service_pb.ts | 3 ++- 8 files changed, 47 insertions(+), 8 deletions(-) diff --git a/docs/design/multi-spaces.md b/docs/design/multi-spaces.md index c8b0c21a..758d8d32 100644 --- a/docs/design/multi-spaces.md +++ b/docs/design/multi-spaces.md @@ -162,7 +162,7 @@ Space responses returned through membership-authorized operations include the au Memo responses gain an optional Space resource name, and `Visibility` adds `SPACE = 4` without renumbering existing values. The domain-to-v1 mapping is Author to `PRIVATE`, Instance to `PROTECTED`, Public to `PUBLIC`, and Space to `SPACE`. `VISIBILITY_UNSPECIFIED` remains an input sentinel: create treats it as `PRIVATE`, an explicit visibility update rejects it, and responses never return it. Visibility values are named domains and must not be compared numerically. The global default memo visibility setting continues to accept only `PRIVATE`, `PROTECTED`, and `PUBLIC`, because it cannot identify a Space. -Memo listing gains explicit all-readable, Unassigned, and Space scopes. Space scope requires active membership. Existing global and Space feeds exclude comments by default, and Space identity is not added to the CEL filter schema. +Memo listing expresses placement through the CEL filter: `space == "spaces/{space}"` selects one Space, `space == null` selects Unassigned memos, and `space != null` selects every placed memo. Membership is enforced by the read policy, which the filter can only narrow. Saved views accept the same expressions. Existing global and Space feeds exclude comments by default. Placement and audience use the existing memo update mechanism so the memo author can change them atomically. The Space API does not add an operation for an `ADMIN` to move, withdraw, or otherwise mutate an individual memo. diff --git a/internal/filter/engine_test.go b/internal/filter/engine_test.go index 2f92fb9d..e7b734a1 100644 --- a/internal/filter/engine_test.go +++ b/internal/filter/engine_test.go @@ -96,8 +96,15 @@ func TestRenderSpaceFilters(t *testing.T) { require.Empty(t, unassigned.Args, schema.Name, dialect) } - _, err = engine.Compile(context.Background(), `space != null`) - require.ErrorContains(t, err, `operator != not allowed for field "space"`) + for _, dialect := range []DialectName{DialectSQLite, DialectMySQL, DialectPostgres} { + assigned, err := engine.CompileToStatement(context.Background(), `space != null`, RenderOptions{Dialect: dialect}) + require.NoError(t, err, schema.Name, dialect) + require.Contains(t, assigned.SQL, "IS NOT NULL", schema.Name, dialect) + require.Empty(t, assigned.Args, schema.Name, dialect) + } + + _, err = engine.Compile(context.Background(), `space != "spaces/team"`) + require.ErrorContains(t, err, `operator != not allowed for field "space"`, "NULL != value would silently drop unassigned memos") } } diff --git a/internal/filter/parser.go b/internal/filter/parser.go index f6a26a2f..c97a6131 100644 --- a/internal/filter/parser.go +++ b/internal/filter/parser.go @@ -162,7 +162,7 @@ func buildComparisonCondition(call *exprv1.Expr_Call, pc parseContext) (Conditio } } if def.AllowedComparisonOps != nil { - if _, allowed := def.AllowedComparisonOps[op]; !allowed { + if _, allowed := def.AllowedComparisonOps[op]; !allowed && !isNullComplement(def, op, right) { return nil, errors.Errorf("operator %s not allowed for field %q", op, field.Name) } } @@ -1018,3 +1018,15 @@ func buildContainsPredicate(call *exprv1.Expr_Call, iterVar string) (PredicateEx return &ContainsPredicate{Substring: substringStr}, nil } + +// isNullComplement reports whether op is a `!= null` test on a field that +// permits `== null`. IS NOT NULL is the exact complement of IS NULL, so it is +// safe even where `!=` against a value is rejected because SQL null semantics +// would silently drop rows. +func isNullComplement(def Field, op ComparisonOperator, right ValueExpr) bool { + if op != CompareNeq || !def.AllowedComparisonOps[CompareEq] { + return false + } + lit, ok := right.(*LiteralValue) + return ok && lit.Value == nil +} diff --git a/proto/api/v1/memo_service.proto b/proto/api/v1/memo_service.proto index 3f70995c..0353351a 100644 --- a/proto/api/v1/memo_service.proto +++ b/proto/api/v1/memo_service.proto @@ -340,7 +340,8 @@ message ListMemosRequest { // content (string), creator (string, e.g. "users/1"), // created_ts / updated_ts (timestamp), pinned (bool), // visibility (string: PRIVATE | PROTECTED | PUBLIC | SPACE), - // space (string resource name or null when the memo has no space), + // space (string resource name, or null when the memo has no space; + // supports == and comparisons against null, e.g. space != null), // tags (list; match with `"work" in tags`, not `tag == "work"`), // has_task_list / has_link / has_code / has_incomplete_tasks (bool), // has_location (bool; true when the memo has a location attached). diff --git a/proto/gen/api/v1/memo_service.pb.go b/proto/gen/api/v1/memo_service.pb.go index dac005b2..acd921e9 100644 --- a/proto/gen/api/v1/memo_service.pb.go +++ b/proto/gen/api/v1/memo_service.pb.go @@ -556,7 +556,8 @@ type ListMemosRequest struct { // content (string), creator (string, e.g. "users/1"), // created_ts / updated_ts (timestamp), pinned (bool), // visibility (string: PRIVATE | PROTECTED | PUBLIC | SPACE), - // space (string resource name or null when the memo has no space), + // space (string resource name, or null when the memo has no space; + // supports == and comparisons against null, e.g. space != null), // tags (list; match with `"work" in tags`, not `tag == "work"`), // has_task_list / has_link / has_code / has_incomplete_tasks (bool), // has_location (bool; true when the memo has a location attached). diff --git a/proto/gen/openapi.yaml b/proto/gen/openapi.yaml index 3977c1e8..613ef741 100644 --- a/proto/gen/openapi.yaml +++ b/proto/gen/openapi.yaml @@ -667,7 +667,8 @@ paths: content (string), creator (string, e.g. "users/1"), created_ts / updated_ts (timestamp), pinned (bool), visibility (string: PRIVATE | PROTECTED | PUBLIC | SPACE), - space (string resource name or null when the memo has no space), + space (string resource name, or null when the memo has no space; + supports == and comparisons against null, e.g. space != null), tags (list; match with `"work" in tags`, not `tag == "work"`), has_task_list / has_link / has_code / has_incomplete_tasks (bool), has_location (bool; true when the memo has a location attached). diff --git a/web/src/pages/MemoViews.tsx b/web/src/pages/MemoViews.tsx index 58e9c1c3..a5233823 100644 --- a/web/src/pages/MemoViews.tsx +++ b/web/src/pages/MemoViews.tsx @@ -2,6 +2,7 @@ import { create } from "@bufbuild/protobuf"; import { FieldMaskSchema } from "@bufbuild/protobuf/wkt"; import { useQueryClient } from "@tanstack/react-query"; import { + AstroidIcon, CheckCircle2Icon, ClipboardCheckIcon, Clock3Icon, @@ -77,6 +78,18 @@ const memoViewExamples = [ description: "Match hierarchical tags by prefix.", icon: TagsIcon, }, + { + title: "Unassigned", + filter: "space == null", + description: "Memos that are not placed in any space.", + icon: AstroidIcon, + }, + { + title: "In one space", + filter: 'space == "spaces/your-space-id"', + description: "Memos placed in a space. Use the space ID from its URL or settings.", + icon: AstroidIcon, + }, { title: "Open tasks", filter: "has_task_list && has_incomplete_tasks", @@ -164,6 +177,9 @@ const filterFields = [ "content.matches(...)", "visibility", "pinned", + "space == null", + "space != null", + 'space == "spaces/..."', "tag in [...]", "tags.exists(...)", "tags.all(...)", diff --git a/web/src/types/proto/api/v1/memo_service_pb.ts b/web/src/types/proto/api/v1/memo_service_pb.ts index 040c1191..81e80558 100644 --- a/web/src/types/proto/api/v1/memo_service_pb.ts +++ b/web/src/types/proto/api/v1/memo_service_pb.ts @@ -358,7 +358,8 @@ export type ListMemosRequest = Message<"memos.api.v1.ListMemosRequest"> & { * content (string), creator (string, e.g. "users/1"), * created_ts / updated_ts (timestamp), pinned (bool), * visibility (string: PRIVATE | PROTECTED | PUBLIC | SPACE), - * space (string resource name or null when the memo has no space), + * space (string resource name, or null when the memo has no space; + * supports == and comparisons against null, e.g. space != null), * tags (list; match with `"work" in tags`, not `tag == "work"`), * has_task_list / has_link / has_code / has_incomplete_tasks (bool), * has_location (bool; true when the memo has a location attached).