From 57bfe0a70fd2375a8be140833399a663a08581d6 Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 12 Aug 2026 00:00:37 +0800 Subject: [PATCH] feat(filter): support memo location filters (#6181) --- internal/filter/MAINTENANCE.md | 5 +- internal/filter/README.md | 5 + internal/filter/engine_test.go | 103 ++++++++++++++++++ internal/filter/render.go | 51 +++++++++ internal/filter/schema.go | 20 +++- proto/api/v1/memo_service.proto | 3 +- proto/gen/api/v1/memo_service.pb.go | 3 +- proto/gen/openapi.yaml | 3 +- web/src/components/MemoFilters.tsx | 5 + web/src/contexts/MemoFilterContext.tsx | 3 +- web/src/hooks/useMemoFilters.ts | 2 + web/src/locales/ar.json | 1 + web/src/locales/bg.json | 1 + web/src/locales/ca.json | 1 + web/src/locales/cs.json | 1 + web/src/locales/da.json | 1 + web/src/locales/de.json | 1 + web/src/locales/el.json | 1 + web/src/locales/en-GB.json | 1 + web/src/locales/en.json | 1 + web/src/locales/es.json | 1 + web/src/locales/et.json | 1 + web/src/locales/fa.json | 1 + web/src/locales/fi.json | 1 + web/src/locales/fr.json | 1 + web/src/locales/gl.json | 1 + web/src/locales/hi.json | 1 + web/src/locales/hr.json | 1 + web/src/locales/hu.json | 1 + web/src/locales/id.json | 1 + web/src/locales/it.json | 1 + web/src/locales/ja.json | 1 + web/src/locales/ka-GE.json | 1 + web/src/locales/ko.json | 1 + web/src/locales/lt.json | 1 + web/src/locales/lv.json | 1 + web/src/locales/mr.json | 1 + web/src/locales/nb.json | 1 + web/src/locales/nl.json | 1 + web/src/locales/pl.json | 1 + web/src/locales/pt-BR.json | 1 + web/src/locales/pt-PT.json | 1 + web/src/locales/ro.json | 1 + web/src/locales/ru.json | 1 + web/src/locales/sk.json | 1 + web/src/locales/sl.json | 1 + web/src/locales/sr.json | 1 + web/src/locales/sv.json | 1 + web/src/locales/th.json | 1 + web/src/locales/tr.json | 1 + web/src/locales/uk.json | 1 + web/src/locales/vi.json | 1 + web/src/locales/zh-Hans.json | 1 + web/src/locales/zh-Hant.json | 1 + web/src/pages/MemoViews.tsx | 15 ++- web/src/types/proto/api/v1/memo_service_pb.ts | 3 +- web/tests/memo-views.test.ts | 14 +++ 57 files changed, 264 insertions(+), 14 deletions(-) diff --git a/internal/filter/MAINTENANCE.md b/internal/filter/MAINTENANCE.md index f37a9f98..7c8140eb 100644 --- a/internal/filter/MAINTENANCE.md +++ b/internal/filter/MAINTENANCE.md @@ -16,9 +16,8 @@ extending or debugging the package. `parser.go` or extend the renderer helpers. - Keep validation in the parser (e.g., reject unsupported operators). 3. **Write a golden test** - - Extend the dialect-specific memo filter tests under - `store/db/{sqlite,mysql,postgres}/memo_filter_test.go` with a case that - exercises the new field. + - Extend `engine_test.go` with per-dialect rendering assertions for the new + field, and a SQLite behavioral test when the semantics warrant it. 4. **Run `go test ./...`** to ensure the SQL output matches expectations across all dialects. diff --git a/internal/filter/README.md b/internal/filter/README.md index f7444f23..cc05072d 100644 --- a/internal/filter/README.md +++ b/internal/filter/README.md @@ -61,6 +61,11 @@ stmt, _ := engine.CompileToStatement(ctx, `has_task_list && visibility == "PUBLI present in the memo tag set, not through prefix matching. - **Boolean Flags** — Fields such as `has_task_list` render as `IS TRUE` equality checks, or comparisons against `CAST('true' AS JSON)` depending on the dialect. +- **Presence Flags** — `has_location` renders as a JSON key-existence check on + `memo.payload` (`$.location`). A missing key and an explicit JSON null both + count as absent on every dialect; any other value — including an empty object — + counts as present. Only `==`/`!=` against a boolean literal (or bare/negated + use) is allowed. - **String Matching** — `content.contains(x)`, `content.startsWith(x)`, and `content.endsWith(x)` render as case-insensitive `LIKE`/`ILIKE` with LIKE metacharacters (`%`, `_`, `\`) escaped. Available on scalar string fields whose diff --git a/internal/filter/engine_test.go b/internal/filter/engine_test.go index 42c03858..34356966 100644 --- a/internal/filter/engine_test.go +++ b/internal/filter/engine_test.go @@ -513,3 +513,106 @@ func TestTagComprehensionAvoidsMySQLExistsSemiJoin(t *testing.T) { require.Contains(t, stmt.SQL, "EXISTS (SELECT 1", "dialect %s", dialect) } } + +func TestRenderHasLocationPerDialect(t *testing.T) { + t.Parallel() + + engine, err := NewEngine(NewSchema()) + require.NoError(t, err) + + cases := []struct { + dialect DialectName + sql string + }{ + {DialectSQLite, "JSON_EXTRACT(`memo`.`payload`, '$.location') IS NOT NULL"}, + {DialectMySQL, "COALESCE(JSON_TYPE(JSON_EXTRACT(`memo`.`payload`, '$.location')), 'NULL') != 'NULL'"}, + {DialectPostgres, "memo.payload->>'location' IS NOT NULL"}, + } + for _, tc := range cases { + stmt, err := engine.CompileToStatement(context.Background(), `has_location`, RenderOptions{Dialect: tc.dialect}) + require.NoError(t, err, tc.dialect) + require.Equal(t, tc.sql, stmt.SQL, tc.dialect) + require.Empty(t, stmt.Args, tc.dialect) + } +} + +func TestRenderHasLocationNegationAndComparisons(t *testing.T) { + t.Parallel() + + engine, err := NewEngine(NewSchema()) + require.NoError(t, err) + + const exists = "JSON_EXTRACT(`memo`.`payload`, '$.location') IS NOT NULL" + cases := []struct { + expr string + sql string + }{ + {`has_location`, exists}, + {`!has_location`, "NOT (" + exists + ")"}, + {`has_location == true`, exists}, + {`has_location == false`, "NOT (" + exists + ")"}, + {`has_location != true`, "NOT (" + exists + ")"}, + {`has_location != false`, exists}, + } + for _, tc := range cases { + stmt, err := engine.CompileToStatement(context.Background(), tc.expr, RenderOptions{Dialect: DialectSQLite}) + require.NoError(t, err, tc.expr) + require.Equal(t, tc.sql, stmt.SQL, tc.expr) + require.Empty(t, stmt.Args, tc.expr) + } +} + +func TestCompileRejectsOrderingOnHasLocation(t *testing.T) { + t.Parallel() + + engine, err := NewEngine(NewSchema()) + require.NoError(t, err) + + // Only ==/!= are meaningful for a presence flag. + _, err = engine.Compile(context.Background(), `has_location < true`) + require.Error(t, err) +} + +// TestHasLocationSQLiteBehavior pins the presence semantics against a real +// database: a missing key, an explicit JSON null, and a NULL payload all count +// as absent, while any location object — even an empty one — counts as present. +func TestHasLocationSQLiteBehavior(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + require.NoError(t, err) + db.SetMaxOpenConns(1) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + _, err = db.Exec(`CREATE TABLE memo (id INTEGER PRIMARY KEY, payload TEXT)`) + require.NoError(t, err) + for _, fixture := range []struct { + id int + payload any + }{ + {1, `{}`}, + {2, `{"location":{"placeholder":"Tokyo","latitude":35.6,"longitude":139.7}}`}, + {3, `{"location":{}}`}, + {4, `{"location":null}`}, + {5, nil}, + } { + _, err = db.Exec(`INSERT INTO memo (id, payload) VALUES (?, ?)`, fixture.id, fixture.payload) + require.NoError(t, err) + } + + engine, err := NewEngine(NewSchema()) + require.NoError(t, err) + + cases := []struct { + expr string + want []int + }{ + {`has_location`, []int{2, 3}}, + {`!has_location`, []int{1, 4, 5}}, + {`has_location == false`, []int{1, 4, 5}}, + {`has_location != false`, []int{2, 3}}, + } + for _, tc := range cases { + stmt, err := engine.CompileToStatement(context.Background(), tc.expr, RenderOptions{Dialect: DialectSQLite}) + require.NoError(t, err, tc.expr) + require.Equal(t, tc.want, selectMemoIDs(t, db, stmt), tc.expr) + } +} diff --git a/internal/filter/render.go b/internal/filter/render.go index 305bf4be..1a488089 100644 --- a/internal/filter/render.go +++ b/internal/filter/render.go @@ -143,6 +143,12 @@ func (r *renderer) renderFieldPredicate(cond *FieldPredicateCondition) (renderRe return renderResult{}, err } return renderResult{sql: sql}, nil + case FieldKindJSONExists: + sql, err := r.jsonExistsSQL(field) + if err != nil { + return renderResult{}, errors.Wrap(err, "failed to render JSON existence predicate") + } + return renderResult{sql: sql}, nil default: return renderResult{}, errors.Errorf("field %q cannot be used as a predicate", cond.Field) } @@ -160,6 +166,8 @@ func (r *renderer) renderComparison(cond *ComparisonCondition) (renderResult, er return r.renderBoolColumnComparison(field, cond.Operator, cond.Right) case FieldKindJSONBool: return r.renderJSONBoolComparison(field, cond.Operator, cond.Right) + case FieldKindJSONExists: + return r.renderJSONExistsComparison(field, cond.Operator, cond.Right) case FieldKindScalar: return r.renderScalarComparison(field, cond.Operator, cond.Right) default: @@ -725,6 +733,49 @@ func tagLikePattern(mode TextMatchMode, value string) string { } } +// jsonExistsSQL renders a predicate that is true when the JSON key at the +// field's path holds a non-null value. A missing key and an explicit JSON null +// both count as absent on every dialect. +func (r *renderer) jsonExistsSQL(field Field) (string, error) { + expr := jsonExtractExpr(r.dialect, field) + switch r.dialect { + case DialectSQLite, DialectPostgres: + // SQLite's JSON_EXTRACT and Postgres' terminal ->> fold both a missing + // key and a JSON null to SQL NULL. + return fmt.Sprintf("%s IS NOT NULL", expr), nil + case DialectMySQL: + // MySQL's JSON_EXTRACT returns SQL NULL for a missing key but a JSON + // null literal for an explicit null; JSON_TYPE reports 'NULL' only for + // the latter, so both collapse to 'NULL' here. + return fmt.Sprintf("COALESCE(JSON_TYPE(%s), 'NULL') != 'NULL'", expr), nil + default: + return "", errors.Errorf("unsupported dialect %s", r.dialect) + } +} + +func (r *renderer) renderJSONExistsComparison(field Field, op ComparisonOperator, right ValueExpr) (renderResult, error) { + value, err := expectBool(right) + if err != nil { + return renderResult{}, errors.Wrap(err, "json existence comparison requires a boolean value") + } + existsSQL, err := r.jsonExistsSQL(field) + if err != nil { + return renderResult{}, errors.Wrap(err, "failed to render JSON existence comparison") + } + want := value + switch op { + case CompareEq: + case CompareNeq: + want = !want + default: + return renderResult{}, errors.Errorf("operator %s not supported for field %q", op, field.Name) + } + if want { + return renderResult{sql: existsSQL}, nil + } + return renderResult{sql: fmt.Sprintf("NOT (%s)", existsSQL)}, nil +} + func (r *renderer) jsonBoolPredicate(field Field) (string, error) { expr := jsonExtractExpr(r.dialect, field) switch r.dialect { diff --git a/internal/filter/schema.go b/internal/filter/schema.go index d9fdd9c3..6cf2838d 100644 --- a/internal/filter/schema.go +++ b/internal/filter/schema.go @@ -30,9 +30,11 @@ const ( type FieldKind string const ( - FieldKindScalar FieldKind = "scalar" - FieldKindBoolColumn FieldKind = "bool_column" - FieldKindJSONBool FieldKind = "json_bool" + FieldKindScalar FieldKind = "scalar" + FieldKindBoolColumn FieldKind = "bool_column" + FieldKindJSONBool FieldKind = "json_bool" + // FieldKindJSONExists represents a boolean derived from the presence of a non-null JSON value. + FieldKindJSONExists FieldKind = "json_exists" FieldKindJSONList FieldKind = "json_list" FieldKindVirtualAlias FieldKind = "virtual_alias" ) @@ -227,6 +229,17 @@ func NewSchema() Schema { CompareNeq: true, }, }, + "has_location": { + Name: "has_location", + Kind: FieldKindJSONExists, + Type: FieldTypeBool, + Column: Column{Table: "memo", Name: "payload"}, + JSONPath: []string{"location"}, + AllowedComparisonOps: map[ComparisonOperator]bool{ + CompareEq: true, + CompareNeq: true, + }, + }, } envOptions := []cel.EnvOption{ @@ -243,6 +256,7 @@ func NewSchema() Schema { cel.Variable("has_link", cel.BoolType), cel.Variable("has_code", cel.BoolType), cel.Variable("has_incomplete_tasks", cel.BoolType), + cel.Variable("has_location", cel.BoolType), cel.Variable("now", cel.TimestampType), ext.Sets(), cel.ASTValidators(cel.ValidateRegexLiterals()), diff --git a/proto/api/v1/memo_service.proto b/proto/api/v1/memo_service.proto index 81c54b0e..3575aec3 100644 --- a/proto/api/v1/memo_service.proto +++ b/proto/api/v1/memo_service.proto @@ -330,7 +330,8 @@ message ListMemosRequest { // created_ts / updated_ts (timestamp), pinned (bool), // visibility (string: PRIVATE | PROTECTED | PUBLIC), // tags (list; match with `"work" in tags`, not `tag == "work"`), - // has_task_list / has_link / has_code / has_incomplete_tasks (bool). + // has_task_list / has_link / has_code / has_incomplete_tasks (bool), + // has_location (bool; true when the memo has a location attached). // Note: the time fields here are created_ts / updated_ts, which differ from // the create_time / update_time names used by order_by. // Examples: diff --git a/proto/gen/api/v1/memo_service.pb.go b/proto/gen/api/v1/memo_service.pb.go index aecc9e5b..1543acfb 100644 --- a/proto/gen/api/v1/memo_service.pb.go +++ b/proto/gen/api/v1/memo_service.pb.go @@ -550,7 +550,8 @@ type ListMemosRequest struct { // created_ts / updated_ts (timestamp), pinned (bool), // visibility (string: PRIVATE | PROTECTED | PUBLIC), // tags (list; match with `"work" in tags`, not `tag == "work"`), - // has_task_list / has_link / has_code / has_incomplete_tasks (bool). + // has_task_list / has_link / has_code / has_incomplete_tasks (bool), + // has_location (bool; true when the memo has a location attached). // // Note: the time fields here are created_ts / updated_ts, which differ from // the create_time / update_time names used by order_by. diff --git a/proto/gen/openapi.yaml b/proto/gen/openapi.yaml index 6fb5439c..4989504d 100644 --- a/proto/gen/openapi.yaml +++ b/proto/gen/openapi.yaml @@ -664,7 +664,8 @@ paths: created_ts / updated_ts (timestamp), pinned (bool), visibility (string: PRIVATE | PROTECTED | PUBLIC), tags (list; match with `"work" in tags`, not `tag == "work"`), - has_task_list / has_link / has_code / has_incomplete_tasks (bool). + has_task_list / has_link / has_code / has_incomplete_tasks (bool), + has_location (bool; true when the memo has a location attached). Note: the time fields here are created_ts / updated_ts, which differ from the create_time / update_time names used by order_by. Examples: diff --git a/web/src/components/MemoFilters.tsx b/web/src/components/MemoFilters.tsx index 492fcf33..53ae3573 100644 --- a/web/src/components/MemoFilters.tsx +++ b/web/src/components/MemoFilters.tsx @@ -8,6 +8,7 @@ import { HashIcon, LinkIcon, LucideIcon, + MapPinIcon, SearchIcon, XIcon, } from "lucide-react"; @@ -54,6 +55,10 @@ const FILTER_CONFIGS: Record = { icon: CodeIcon, getLabel: (_, t) => t("memo.filters.has-code"), }, + "property.hasLocation": { + icon: MapPinIcon, + getLabel: (_, t) => t("memo.filters.has-location"), + }, }; const MemoFilters = ({ className }: { className?: string }) => { diff --git a/web/src/contexts/MemoFilterContext.tsx b/web/src/contexts/MemoFilterContext.tsx index 3a282b66..872edf26 100644 --- a/web/src/contexts/MemoFilterContext.tsx +++ b/web/src/contexts/MemoFilterContext.tsx @@ -10,7 +10,8 @@ export type FilterFactor = | "pinned" | "property.hasLink" | "property.hasTaskList" - | "property.hasCode"; + | "property.hasCode" + | "property.hasLocation"; export interface MemoFilter { factor: FilterFactor; diff --git a/web/src/hooks/useMemoFilters.ts b/web/src/hooks/useMemoFilters.ts index 9ef65724..134dbf85 100644 --- a/web/src/hooks/useMemoFilters.ts +++ b/web/src/hooks/useMemoFilters.ts @@ -93,6 +93,8 @@ export const buildMemoFilter = ({ conditions.push(`has_task_list`); } else if (filter.factor === "property.hasCode") { conditions.push(`has_code`); + } else if (filter.factor === "property.hasLocation") { + conditions.push(`has_location`); } else if (filter.factor === "displayTime") { const range = getLocalDayTimestampRange(filter.value); if (range) { diff --git a/web/src/locales/ar.json b/web/src/locales/ar.json index f549f084..e69eba9b 100644 --- a/web/src/locales/ar.json +++ b/web/src/locales/ar.json @@ -190,6 +190,7 @@ "filters": { "has-code": "يحتوي على كود", "has-link": "يحتوي على رابط", + "has-location": "يحتوي على موقع", "has-task-list": "يحتوي على قائمة مهام", "label": "المرشحات" }, diff --git a/web/src/locales/bg.json b/web/src/locales/bg.json index 36d78187..b5ec0647 100644 --- a/web/src/locales/bg.json +++ b/web/src/locales/bg.json @@ -242,6 +242,7 @@ "filters": { "has-code": "hasCode", "has-link": "hasLink", + "has-location": "има местоположение", "has-task-list": "hasTaskList", "label": "Филтри" }, diff --git a/web/src/locales/ca.json b/web/src/locales/ca.json index 7b50acea..ddd9be0e 100644 --- a/web/src/locales/ca.json +++ b/web/src/locales/ca.json @@ -204,6 +204,7 @@ "filters": { "has-code": "téCodi", "has-link": "téEnllaç", + "has-location": "té ubicació", "has-task-list": "téLlistaTasques", "label": "Filtres" }, diff --git a/web/src/locales/cs.json b/web/src/locales/cs.json index 9acb5ee9..cbb2efe7 100644 --- a/web/src/locales/cs.json +++ b/web/src/locales/cs.json @@ -204,6 +204,7 @@ "filters": { "has-code": "maKod", "has-link": "maOdkaz", + "has-location": "má polohu", "has-task-list": "maSeznamUkolu", "label": "Filtry" }, diff --git a/web/src/locales/da.json b/web/src/locales/da.json index 53442708..bf252035 100644 --- a/web/src/locales/da.json +++ b/web/src/locales/da.json @@ -242,6 +242,7 @@ "filters": { "has-code": "hasCode", "has-link": "harLink", + "has-location": "har placering", "has-task-list": "har Opgaveliste", "label": "Filtre" }, diff --git a/web/src/locales/de.json b/web/src/locales/de.json index e3f1efcc..1e1f735c 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -204,6 +204,7 @@ "filters": { "has-code": "hatCode", "has-link": "hatLink", + "has-location": "hat Standort", "has-task-list": "hatAufgabenliste", "label": "Filter" }, diff --git a/web/src/locales/el.json b/web/src/locales/el.json index 8c80054c..a59ad529 100644 --- a/web/src/locales/el.json +++ b/web/src/locales/el.json @@ -242,6 +242,7 @@ "filters": { "has-code": "έχει Κώδικα", "has-link": "έχει Σύνδεσμο", + "has-location": "έχει τοποθεσία", "has-task-list": "hasTaskList", "label": "Φίλτρα" }, diff --git a/web/src/locales/en-GB.json b/web/src/locales/en-GB.json index d69a3bcc..993dc6cb 100644 --- a/web/src/locales/en-GB.json +++ b/web/src/locales/en-GB.json @@ -204,6 +204,7 @@ "filters": { "has-code": "hasCode", "has-link": "hasLink", + "has-location": "hasLocation", "has-task-list": "hasTaskList", "label": "Filters" }, diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 797b9c5b..6ca6eaf1 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -355,6 +355,7 @@ "filters": { "has-code": "hasCode", "has-link": "hasLink", + "has-location": "hasLocation", "has-task-list": "hasTaskList", "label": "Filters" }, diff --git a/web/src/locales/es.json b/web/src/locales/es.json index 09c4edd5..2336cda6 100644 --- a/web/src/locales/es.json +++ b/web/src/locales/es.json @@ -204,6 +204,7 @@ "filters": { "has-code": "tieneCódigo", "has-link": "tieneEnlace", + "has-location": "tiene ubicación", "has-task-list": "tieneListaTareas", "label": "Filtros" }, diff --git a/web/src/locales/et.json b/web/src/locales/et.json index e7df4d0e..7f95201d 100644 --- a/web/src/locales/et.json +++ b/web/src/locales/et.json @@ -242,6 +242,7 @@ "filters": { "has-code": "hasCode", "has-link": "hasLink", + "has-location": "on asukoht", "has-task-list": "hasTaskList", "label": "Filtrid" }, diff --git a/web/src/locales/fa.json b/web/src/locales/fa.json index e81bf002..81cdb9dc 100644 --- a/web/src/locales/fa.json +++ b/web/src/locales/fa.json @@ -190,6 +190,7 @@ "filters": { "has-code": "دارای کد", "has-link": "دارای پیوند", + "has-location": "دارای موقعیت", "has-task-list": "دارای لیست کارها", "label": "فیلترها" }, diff --git a/web/src/locales/fi.json b/web/src/locales/fi.json index 1e846b62..35320330 100644 --- a/web/src/locales/fi.json +++ b/web/src/locales/fi.json @@ -242,6 +242,7 @@ "filters": { "has-code": "hasCode", "has-link": "hasLink", + "has-location": "on sijainti", "has-task-list": "hasTaskList", "label": "Suodattimet" }, diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json index a72703a4..95d834fe 100644 --- a/web/src/locales/fr.json +++ b/web/src/locales/fr.json @@ -204,6 +204,7 @@ "filters": { "has-code": "aCode", "has-link": "aLien", + "has-location": "a une localisation", "has-task-list": "aListeTâches", "label": "Filtres" }, diff --git a/web/src/locales/gl.json b/web/src/locales/gl.json index f407c53d..3ab37eec 100644 --- a/web/src/locales/gl.json +++ b/web/src/locales/gl.json @@ -204,6 +204,7 @@ "filters": { "has-code": "hasCode", "has-link": "hasLink", + "has-location": "ten localización", "has-task-list": "hasTaskList", "label": "Filtros" }, diff --git a/web/src/locales/hi.json b/web/src/locales/hi.json index 01e34e18..a24d8dd3 100644 --- a/web/src/locales/hi.json +++ b/web/src/locales/hi.json @@ -190,6 +190,7 @@ "filters": { "has-code": "कोड है", "has-link": "लिंक है", + "has-location": "स्थान है", "has-task-list": "टास्क लिस्ट है", "label": "फिल्टर" }, diff --git a/web/src/locales/hr.json b/web/src/locales/hr.json index 072bc8f0..47f43a12 100644 --- a/web/src/locales/hr.json +++ b/web/src/locales/hr.json @@ -204,6 +204,7 @@ "filters": { "has-code": "imaKod", "has-link": "imaLink", + "has-location": "ima lokaciju", "has-task-list": "imaZadatke", "label": "Filteri" }, diff --git a/web/src/locales/hu.json b/web/src/locales/hu.json index debe1a58..106f8653 100644 --- a/web/src/locales/hu.json +++ b/web/src/locales/hu.json @@ -204,6 +204,7 @@ "filters": { "has-code": "vanKód", "has-link": "vanLink", + "has-location": "van helyadat", "has-task-list": "vanFeladatLista", "label": "Szűrők" }, diff --git a/web/src/locales/id.json b/web/src/locales/id.json index 8fefeba5..880ce328 100644 --- a/web/src/locales/id.json +++ b/web/src/locales/id.json @@ -190,6 +190,7 @@ "filters": { "has-code": "Memiliki kode", "has-link": "Memiliki tautan", + "has-location": "Memiliki lokasi", "has-task-list": "Memiliki daftar tugas", "label": "Filter" }, diff --git a/web/src/locales/it.json b/web/src/locales/it.json index 92e51370..af207caa 100644 --- a/web/src/locales/it.json +++ b/web/src/locales/it.json @@ -204,6 +204,7 @@ "filters": { "has-code": "haCodice", "has-link": "haLink", + "has-location": "ha una posizione", "has-task-list": "haListaCompiti", "label": "Filtri" }, diff --git a/web/src/locales/ja.json b/web/src/locales/ja.json index 19d8c8de..dfa4a225 100644 --- a/web/src/locales/ja.json +++ b/web/src/locales/ja.json @@ -265,6 +265,7 @@ "filters": { "has-code": "コードあり", "has-link": "リンクあり", + "has-location": "位置情報あり", "has-task-list": "タスクリストあり", "label": "フィルター" }, diff --git a/web/src/locales/ka-GE.json b/web/src/locales/ka-GE.json index 7fcdbdd2..fd2b46b7 100644 --- a/web/src/locales/ka-GE.json +++ b/web/src/locales/ka-GE.json @@ -204,6 +204,7 @@ "filters": { "has-code": "კოდიარის", "has-link": "ლინკიარის", + "has-location": "აქვს მდებარეობა", "has-task-list": "სიისამოცანებია", "label": "ფილტრები" }, diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json index 055369b7..007a2f49 100644 --- a/web/src/locales/ko.json +++ b/web/src/locales/ko.json @@ -190,6 +190,7 @@ "filters": { "has-code": "코드있음", "has-link": "링크있음", + "has-location": "위치 있음", "has-task-list": "할일목록있음", "label": "필터" }, diff --git a/web/src/locales/lt.json b/web/src/locales/lt.json index 7e8f7b0a..0b3cf32e 100644 --- a/web/src/locales/lt.json +++ b/web/src/locales/lt.json @@ -242,6 +242,7 @@ "filters": { "has-code": "hasCode", "has-link": "hasLink", + "has-location": "turi vietą", "has-task-list": "turiTaskList", "label": "Filtrai" }, diff --git a/web/src/locales/lv.json b/web/src/locales/lv.json index fb1ae8b6..725a56d3 100644 --- a/web/src/locales/lv.json +++ b/web/src/locales/lv.json @@ -242,6 +242,7 @@ "filters": { "has-code": "hasCode", "has-link": "hasLink", + "has-location": "ir atrašanās vieta", "has-task-list": "hasTaskList", "label": "Filtri" }, diff --git a/web/src/locales/mr.json b/web/src/locales/mr.json index 5d1f63c5..1848a91d 100644 --- a/web/src/locales/mr.json +++ b/web/src/locales/mr.json @@ -190,6 +190,7 @@ "filters": { "has-code": "कोड आहे", "has-link": "लिंक आहे", + "has-location": "स्थान आहे", "has-task-list": "कार्यसूची आहे", "label": "फिल्टर" }, diff --git a/web/src/locales/nb.json b/web/src/locales/nb.json index 70c30930..698bf972 100644 --- a/web/src/locales/nb.json +++ b/web/src/locales/nb.json @@ -204,6 +204,7 @@ "filters": { "has-code": "harKode", "has-link": "harLink", + "has-location": "har posisjon", "has-task-list": "harGjøremålsListe", "label": "Filtre" }, diff --git a/web/src/locales/nl.json b/web/src/locales/nl.json index 8d2228ac..554ad1c6 100644 --- a/web/src/locales/nl.json +++ b/web/src/locales/nl.json @@ -204,6 +204,7 @@ "filters": { "has-code": "heeftCode", "has-link": "heeftLink", + "has-location": "heeft locatie", "has-task-list": "heeftTakenlijst", "label": "Filters" }, diff --git a/web/src/locales/pl.json b/web/src/locales/pl.json index ac093a18..f6f4c4d5 100644 --- a/web/src/locales/pl.json +++ b/web/src/locales/pl.json @@ -222,6 +222,7 @@ "filters": { "has-code": "ma kod", "has-link": "ma link", + "has-location": "ma lokalizację", "has-task-list": "ma listę zadań", "label": "Filtry" }, diff --git a/web/src/locales/pt-BR.json b/web/src/locales/pt-BR.json index a00ea375..ef43cc0e 100644 --- a/web/src/locales/pt-BR.json +++ b/web/src/locales/pt-BR.json @@ -190,6 +190,7 @@ "filters": { "has-code": "temCódigo", "has-link": "temLink", + "has-location": "tem localização", "has-task-list": "temListaDeTarefas", "label": "Filtros" }, diff --git a/web/src/locales/pt-PT.json b/web/src/locales/pt-PT.json index 17e7fbf4..cbb21f8b 100644 --- a/web/src/locales/pt-PT.json +++ b/web/src/locales/pt-PT.json @@ -204,6 +204,7 @@ "filters": { "has-code": "temCódigo", "has-link": "temLink", + "has-location": "tem localização", "has-task-list": "temListaDeTarefas", "label": "Filtros" }, diff --git a/web/src/locales/ro.json b/web/src/locales/ro.json index e03678f8..1538a1bb 100644 --- a/web/src/locales/ro.json +++ b/web/src/locales/ro.json @@ -242,6 +242,7 @@ "filters": { "has-code": "areCode", "has-link": "areLink", + "has-location": "are locație", "has-task-list": "areTaskList", "label": "Filtre" }, diff --git a/web/src/locales/ru.json b/web/src/locales/ru.json index 9409bb5e..fb0a40e0 100644 --- a/web/src/locales/ru.json +++ b/web/src/locales/ru.json @@ -204,6 +204,7 @@ "filters": { "has-code": "hasCode", "has-link": "hasLink", + "has-location": "есть местоположение", "has-task-list": "hasTaskList", "label": "Фильтры" }, diff --git a/web/src/locales/sk.json b/web/src/locales/sk.json index 2555ecf1..261a3b6b 100644 --- a/web/src/locales/sk.json +++ b/web/src/locales/sk.json @@ -242,6 +242,7 @@ "filters": { "has-code": "hasCode", "has-link": "hasLink", + "has-location": "má polohu", "has-task-list": "hasTaskList", "label": "Filtre" }, diff --git a/web/src/locales/sl.json b/web/src/locales/sl.json index bd1109fe..954cbeea 100644 --- a/web/src/locales/sl.json +++ b/web/src/locales/sl.json @@ -204,6 +204,7 @@ "filters": { "has-code": "imaKodo", "has-link": "imaPovezavo", + "has-location": "imaLokacijo", "has-task-list": "imaSeznamOpravil", "label": "Filtri" }, diff --git a/web/src/locales/sr.json b/web/src/locales/sr.json index ff2b65d0..86269e70 100644 --- a/web/src/locales/sr.json +++ b/web/src/locales/sr.json @@ -242,6 +242,7 @@ "filters": { "has-code": "хасЦоде", "has-link": "хасЛинк", + "has-location": "има локацију", "has-task-list": "хасТаскЛист", "label": "Филтери" }, diff --git a/web/src/locales/sv.json b/web/src/locales/sv.json index cd41541c..41cc9b3a 100644 --- a/web/src/locales/sv.json +++ b/web/src/locales/sv.json @@ -204,6 +204,7 @@ "filters": { "has-code": "harKod", "has-link": "harLänk", + "has-location": "har plats", "has-task-list": "harAttGöraLista", "label": "Filter" }, diff --git a/web/src/locales/th.json b/web/src/locales/th.json index 25bf82ce..bb57c202 100644 --- a/web/src/locales/th.json +++ b/web/src/locales/th.json @@ -190,6 +190,7 @@ "filters": { "has-code": "มีโค้ด", "has-link": "มีลิงก์", + "has-location": "มีตำแหน่ง", "has-task-list": "มีรายการที่ต้องทำ", "label": "ตัวกรอง" }, diff --git a/web/src/locales/tr.json b/web/src/locales/tr.json index 473f12ea..157b50fd 100644 --- a/web/src/locales/tr.json +++ b/web/src/locales/tr.json @@ -204,6 +204,7 @@ "filters": { "has-code": "Kod içerir", "has-link": "Bağlantı içerir", + "has-location": "Konum içerir", "has-task-list": "Yapılacaklar listesi içerir", "label": "Filtreler" }, diff --git a/web/src/locales/uk.json b/web/src/locales/uk.json index 4156ab89..4a872dde 100644 --- a/web/src/locales/uk.json +++ b/web/src/locales/uk.json @@ -204,6 +204,7 @@ "filters": { "has-code": "єКод", "has-link": "єПосилання", + "has-location": "є розташування", "has-task-list": "єСписокЗавдань", "label": "Фільтри" }, diff --git a/web/src/locales/vi.json b/web/src/locales/vi.json index f74ec4e0..9e224b11 100644 --- a/web/src/locales/vi.json +++ b/web/src/locales/vi.json @@ -190,6 +190,7 @@ "filters": { "has-code": "cóMã", "has-link": "cóLiênKết", + "has-location": "có vị trí", "has-task-list": "cóDanhSáchViệc", "label": "Bộ lọc" }, diff --git a/web/src/locales/zh-Hans.json b/web/src/locales/zh-Hans.json index 3be375c4..c5d2f4f7 100644 --- a/web/src/locales/zh-Hans.json +++ b/web/src/locales/zh-Hans.json @@ -280,6 +280,7 @@ "filters": { "has-code": "有代码", "has-link": "有链接", + "has-location": "有位置", "has-task-list": "有待办", "label": "过滤器" }, diff --git a/web/src/locales/zh-Hant.json b/web/src/locales/zh-Hant.json index c67706b0..e852e504 100644 --- a/web/src/locales/zh-Hant.json +++ b/web/src/locales/zh-Hant.json @@ -357,6 +357,7 @@ "filters": { "has-code": "有程式碼", "has-link": "有連結", + "has-location": "有位置", "has-task-list": "有待辦事項", "label": "過濾器" }, diff --git a/web/src/pages/MemoViews.tsx b/web/src/pages/MemoViews.tsx index 37a3e587..58e9c1c3 100644 --- a/web/src/pages/MemoViews.tsx +++ b/web/src/pages/MemoViews.tsx @@ -7,6 +7,7 @@ import { Clock3Icon, ExternalLinkIcon, FilterIcon, + MapPinIcon, MoreVerticalIcon, PencilIcon, PinIcon, @@ -88,6 +89,12 @@ const memoViewExamples = [ description: "Memos containing links or code blocks.", icon: FilterIcon, }, + { + title: "No location", + filter: "!has_location", + description: "Memos without an attached location.", + icon: MapPinIcon, + }, { title: "Content search", filter: 'content.contains("TODO")', @@ -170,6 +177,7 @@ const filterFields = [ "has_incomplete_tasks", "has_link", "has_code", + "has_location", 'created_ts >= now - duration("24h")', "created_ts.getFullYear() == ...", "created_ts.getMonth() == ... (0 = Jan)", @@ -204,9 +212,10 @@ const MemoViewGuide = ({ onUseExample }: MemoViewGuideProps) => { {memoViewExamples.map((example) => { const Icon = example.icon; return ( -
onUseExample(example)} > @@ -215,7 +224,7 @@ const MemoViewGuide = ({ onUseExample }: MemoViewGuideProps) => { {example.filter} {example.description} -
+ ); })} 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 71c7aa15..297132aa 100644 --- a/web/src/types/proto/api/v1/memo_service_pb.ts +++ b/web/src/types/proto/api/v1/memo_service_pb.ts @@ -356,7 +356,8 @@ export type ListMemosRequest = Message<"memos.api.v1.ListMemosRequest"> & { * created_ts / updated_ts (timestamp), pinned (bool), * visibility (string: PRIVATE | PROTECTED | PUBLIC), * tags (list; match with `"work" in tags`, not `tag == "work"`), - * has_task_list / has_link / has_code / has_incomplete_tasks (bool). + * has_task_list / has_link / has_code / has_incomplete_tasks (bool), + * has_location (bool; true when the memo has a location attached). * Note: the time fields here are created_ts / updated_ts, which differ from * the create_time / update_time names used by order_by. * Examples: diff --git a/web/tests/memo-views.test.ts b/web/tests/memo-views.test.ts index 4a605207..807c9974 100644 --- a/web/tests/memo-views.test.ts +++ b/web/tests/memo-views.test.ts @@ -56,6 +56,20 @@ describe("memo views", () => { ); }); + it("maps property filter factors to their CEL flags", () => { + expect( + buildMemoFilter({ + filters: [ + { factor: "property.hasLink", value: "" }, + { factor: "property.hasTaskList", value: "" }, + { factor: "property.hasCode", value: "" }, + { factor: "property.hasLocation", value: "" }, + ], + includePinned: false, + }), + ).toBe("has_link && has_task_list && has_code && has_location"); + }); + it("uses a custom memo view filter when Tasks is not selected", () => { expect( buildMemoFilter({