feat(filter): support memo location filters (#6181)

This commit is contained in:
Johnny 2026-08-12 00:00:37 +08:00 committed by GitHub
parent da81b6b48f
commit 57bfe0a70f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 264 additions and 14 deletions

View file

@ -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.

View file

@ -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

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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()),

View file

@ -330,7 +330,8 @@ message ListMemosRequest {
// created_ts / updated_ts (timestamp), pinned (bool),
// visibility (string: PRIVATE | PROTECTED | PUBLIC),
// tags (list<string>; 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:

View file

@ -550,7 +550,8 @@ type ListMemosRequest struct {
// created_ts / updated_ts (timestamp), pinned (bool),
// visibility (string: PRIVATE | PROTECTED | PUBLIC),
// tags (list<string>; 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.

View file

@ -664,7 +664,8 @@ paths:
created_ts / updated_ts (timestamp), pinned (bool),
visibility (string: PRIVATE | PROTECTED | PUBLIC),
tags (list<string>; 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:

View file

@ -8,6 +8,7 @@ import {
HashIcon,
LinkIcon,
LucideIcon,
MapPinIcon,
SearchIcon,
XIcon,
} from "lucide-react";
@ -54,6 +55,10 @@ const FILTER_CONFIGS: Record<FilterFactor, FilterConfig> = {
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 }) => {

View file

@ -10,7 +10,8 @@ export type FilterFactor =
| "pinned"
| "property.hasLink"
| "property.hasTaskList"
| "property.hasCode";
| "property.hasCode"
| "property.hasLocation";
export interface MemoFilter {
factor: FilterFactor;

View file

@ -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) {

View file

@ -190,6 +190,7 @@
"filters": {
"has-code": "يحتوي على كود",
"has-link": "يحتوي على رابط",
"has-location": "يحتوي على موقع",
"has-task-list": "يحتوي على قائمة مهام",
"label": "المرشحات"
},

View file

@ -242,6 +242,7 @@
"filters": {
"has-code": "hasCode",
"has-link": "hasLink",
"has-location": "има местоположение",
"has-task-list": "hasTaskList",
"label": "Филтри"
},

View file

@ -204,6 +204,7 @@
"filters": {
"has-code": "téCodi",
"has-link": "téEnllaç",
"has-location": "té ubicació",
"has-task-list": "téLlistaTasques",
"label": "Filtres"
},

View file

@ -204,6 +204,7 @@
"filters": {
"has-code": "maKod",
"has-link": "maOdkaz",
"has-location": "má polohu",
"has-task-list": "maSeznamUkolu",
"label": "Filtry"
},

View file

@ -242,6 +242,7 @@
"filters": {
"has-code": "hasCode",
"has-link": "harLink",
"has-location": "har placering",
"has-task-list": "har Opgaveliste",
"label": "Filtre"
},

View file

@ -204,6 +204,7 @@
"filters": {
"has-code": "hatCode",
"has-link": "hatLink",
"has-location": "hat Standort",
"has-task-list": "hatAufgabenliste",
"label": "Filter"
},

View file

@ -242,6 +242,7 @@
"filters": {
"has-code": "έχει Κώδικα",
"has-link": "έχει Σύνδεσμο",
"has-location": "έχει τοποθεσία",
"has-task-list": "hasTaskList",
"label": "Φίλτρα"
},

View file

@ -204,6 +204,7 @@
"filters": {
"has-code": "hasCode",
"has-link": "hasLink",
"has-location": "hasLocation",
"has-task-list": "hasTaskList",
"label": "Filters"
},

View file

@ -355,6 +355,7 @@
"filters": {
"has-code": "hasCode",
"has-link": "hasLink",
"has-location": "hasLocation",
"has-task-list": "hasTaskList",
"label": "Filters"
},

View file

@ -204,6 +204,7 @@
"filters": {
"has-code": "tieneCódigo",
"has-link": "tieneEnlace",
"has-location": "tiene ubicación",
"has-task-list": "tieneListaTareas",
"label": "Filtros"
},

View file

@ -242,6 +242,7 @@
"filters": {
"has-code": "hasCode",
"has-link": "hasLink",
"has-location": "on asukoht",
"has-task-list": "hasTaskList",
"label": "Filtrid"
},

View file

@ -190,6 +190,7 @@
"filters": {
"has-code": "دارای کد",
"has-link": "دارای پیوند",
"has-location": "دارای موقعیت",
"has-task-list": "دارای لیست کارها",
"label": "فیلترها"
},

View file

@ -242,6 +242,7 @@
"filters": {
"has-code": "hasCode",
"has-link": "hasLink",
"has-location": "on sijainti",
"has-task-list": "hasTaskList",
"label": "Suodattimet"
},

View file

@ -204,6 +204,7 @@
"filters": {
"has-code": "aCode",
"has-link": "aLien",
"has-location": "a une localisation",
"has-task-list": "aListeTâches",
"label": "Filtres"
},

View file

@ -204,6 +204,7 @@
"filters": {
"has-code": "hasCode",
"has-link": "hasLink",
"has-location": "ten localización",
"has-task-list": "hasTaskList",
"label": "Filtros"
},

View file

@ -190,6 +190,7 @@
"filters": {
"has-code": "कोड है",
"has-link": "लिंक है",
"has-location": "स्थान है",
"has-task-list": "टास्क लिस्ट है",
"label": "फिल्टर"
},

View file

@ -204,6 +204,7 @@
"filters": {
"has-code": "imaKod",
"has-link": "imaLink",
"has-location": "ima lokaciju",
"has-task-list": "imaZadatke",
"label": "Filteri"
},

View file

@ -204,6 +204,7 @@
"filters": {
"has-code": "vanKód",
"has-link": "vanLink",
"has-location": "van helyadat",
"has-task-list": "vanFeladatLista",
"label": "Szűrők"
},

View file

@ -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"
},

View file

@ -204,6 +204,7 @@
"filters": {
"has-code": "haCodice",
"has-link": "haLink",
"has-location": "ha una posizione",
"has-task-list": "haListaCompiti",
"label": "Filtri"
},

View file

@ -265,6 +265,7 @@
"filters": {
"has-code": "コードあり",
"has-link": "リンクあり",
"has-location": "位置情報あり",
"has-task-list": "タスクリストあり",
"label": "フィルター"
},

View file

@ -204,6 +204,7 @@
"filters": {
"has-code": "კოდიარის",
"has-link": "ლინკიარის",
"has-location": "აქვს მდებარეობა",
"has-task-list": "სიისამოცანებია",
"label": "ფილტრები"
},

View file

@ -190,6 +190,7 @@
"filters": {
"has-code": "코드있음",
"has-link": "링크있음",
"has-location": "위치 있음",
"has-task-list": "할일목록있음",
"label": "필터"
},

View file

@ -242,6 +242,7 @@
"filters": {
"has-code": "hasCode",
"has-link": "hasLink",
"has-location": "turi vietą",
"has-task-list": "turiTaskList",
"label": "Filtrai"
},

View file

@ -242,6 +242,7 @@
"filters": {
"has-code": "hasCode",
"has-link": "hasLink",
"has-location": "ir atrašanās vieta",
"has-task-list": "hasTaskList",
"label": "Filtri"
},

View file

@ -190,6 +190,7 @@
"filters": {
"has-code": "कोड आहे",
"has-link": "लिंक आहे",
"has-location": "स्थान आहे",
"has-task-list": "कार्यसूची आहे",
"label": "फिल्टर"
},

View file

@ -204,6 +204,7 @@
"filters": {
"has-code": "harKode",
"has-link": "harLink",
"has-location": "har posisjon",
"has-task-list": "harGjøremålsListe",
"label": "Filtre"
},

View file

@ -204,6 +204,7 @@
"filters": {
"has-code": "heeftCode",
"has-link": "heeftLink",
"has-location": "heeft locatie",
"has-task-list": "heeftTakenlijst",
"label": "Filters"
},

View file

@ -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"
},

View file

@ -190,6 +190,7 @@
"filters": {
"has-code": "temCódigo",
"has-link": "temLink",
"has-location": "tem localização",
"has-task-list": "temListaDeTarefas",
"label": "Filtros"
},

View file

@ -204,6 +204,7 @@
"filters": {
"has-code": "temCódigo",
"has-link": "temLink",
"has-location": "tem localização",
"has-task-list": "temListaDeTarefas",
"label": "Filtros"
},

View file

@ -242,6 +242,7 @@
"filters": {
"has-code": "areCode",
"has-link": "areLink",
"has-location": "are locație",
"has-task-list": "areTaskList",
"label": "Filtre"
},

View file

@ -204,6 +204,7 @@
"filters": {
"has-code": "hasCode",
"has-link": "hasLink",
"has-location": "есть местоположение",
"has-task-list": "hasTaskList",
"label": "Фильтры"
},

View file

@ -242,6 +242,7 @@
"filters": {
"has-code": "hasCode",
"has-link": "hasLink",
"has-location": "má polohu",
"has-task-list": "hasTaskList",
"label": "Filtre"
},

View file

@ -204,6 +204,7 @@
"filters": {
"has-code": "imaKodo",
"has-link": "imaPovezavo",
"has-location": "imaLokacijo",
"has-task-list": "imaSeznamOpravil",
"label": "Filtri"
},

View file

@ -242,6 +242,7 @@
"filters": {
"has-code": "хасЦоде",
"has-link": "хасЛинк",
"has-location": "има локацију",
"has-task-list": "хасТаскЛист",
"label": "Филтери"
},

View file

@ -204,6 +204,7 @@
"filters": {
"has-code": "harKod",
"has-link": "harLänk",
"has-location": "har plats",
"has-task-list": "harAttGöraLista",
"label": "Filter"
},

View file

@ -190,6 +190,7 @@
"filters": {
"has-code": "มีโค้ด",
"has-link": "มีลิงก์",
"has-location": "มีตำแหน่ง",
"has-task-list": "มีรายการที่ต้องทำ",
"label": "ตัวกรอง"
},

View file

@ -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"
},

View file

@ -204,6 +204,7 @@
"filters": {
"has-code": "єКод",
"has-link": "єПосилання",
"has-location": "є розташування",
"has-task-list": "єСписокЗавдань",
"label": "Фільтри"
},

View file

@ -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"
},

View file

@ -280,6 +280,7 @@
"filters": {
"has-code": "有代码",
"has-link": "有链接",
"has-location": "有位置",
"has-task-list": "有待办",
"label": "过滤器"
},

View file

@ -357,6 +357,7 @@
"filters": {
"has-code": "有程式碼",
"has-link": "有連結",
"has-location": "有位置",
"has-task-list": "有待辦事項",
"label": "過濾器"
},

View file

@ -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 (
<div
<button
type="button"
key={example.filter}
className="group cursor-pointer rounded-md border border-transparent p-2 text-left transition-colors hover:border-border hover:bg-muted/50"
className="group w-full cursor-pointer rounded-md border border-transparent p-2 text-left transition-colors hover:border-border hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
onClick={() => onUseExample(example)}
>
<span className="flex items-center gap-2 text-sm font-medium text-foreground">
@ -215,7 +224,7 @@ const MemoViewGuide = ({ onUseExample }: MemoViewGuideProps) => {
</span>
<span className="mt-1 block font-mono text-xs leading-5 text-muted-foreground">{example.filter}</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">{example.description}</span>
</div>
</button>
);
})}
</div>

View file

@ -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<string>; 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:

View file

@ -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({