feat(filter): support memo location filters (#6181)
This commit is contained in:
parent
da81b6b48f
commit
57bfe0a70f
57 changed files with 264 additions and 14 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ export type FilterFactor =
|
|||
| "pinned"
|
||||
| "property.hasLink"
|
||||
| "property.hasTaskList"
|
||||
| "property.hasCode";
|
||||
| "property.hasCode"
|
||||
| "property.hasLocation";
|
||||
|
||||
export interface MemoFilter {
|
||||
factor: FilterFactor;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@
|
|||
"filters": {
|
||||
"has-code": "يحتوي على كود",
|
||||
"has-link": "يحتوي على رابط",
|
||||
"has-location": "يحتوي على موقع",
|
||||
"has-task-list": "يحتوي على قائمة مهام",
|
||||
"label": "المرشحات"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@
|
|||
"filters": {
|
||||
"has-code": "hasCode",
|
||||
"has-link": "hasLink",
|
||||
"has-location": "има местоположение",
|
||||
"has-task-list": "hasTaskList",
|
||||
"label": "Филтри"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
"filters": {
|
||||
"has-code": "téCodi",
|
||||
"has-link": "téEnllaç",
|
||||
"has-location": "té ubicació",
|
||||
"has-task-list": "téLlistaTasques",
|
||||
"label": "Filtres"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
"filters": {
|
||||
"has-code": "maKod",
|
||||
"has-link": "maOdkaz",
|
||||
"has-location": "má polohu",
|
||||
"has-task-list": "maSeznamUkolu",
|
||||
"label": "Filtry"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@
|
|||
"filters": {
|
||||
"has-code": "hasCode",
|
||||
"has-link": "harLink",
|
||||
"has-location": "har placering",
|
||||
"has-task-list": "har Opgaveliste",
|
||||
"label": "Filtre"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
"filters": {
|
||||
"has-code": "hatCode",
|
||||
"has-link": "hatLink",
|
||||
"has-location": "hat Standort",
|
||||
"has-task-list": "hatAufgabenliste",
|
||||
"label": "Filter"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@
|
|||
"filters": {
|
||||
"has-code": "έχει Κώδικα",
|
||||
"has-link": "έχει Σύνδεσμο",
|
||||
"has-location": "έχει τοποθεσία",
|
||||
"has-task-list": "hasTaskList",
|
||||
"label": "Φίλτρα"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
"filters": {
|
||||
"has-code": "hasCode",
|
||||
"has-link": "hasLink",
|
||||
"has-location": "hasLocation",
|
||||
"has-task-list": "hasTaskList",
|
||||
"label": "Filters"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -355,6 +355,7 @@
|
|||
"filters": {
|
||||
"has-code": "hasCode",
|
||||
"has-link": "hasLink",
|
||||
"has-location": "hasLocation",
|
||||
"has-task-list": "hasTaskList",
|
||||
"label": "Filters"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
"filters": {
|
||||
"has-code": "tieneCódigo",
|
||||
"has-link": "tieneEnlace",
|
||||
"has-location": "tiene ubicación",
|
||||
"has-task-list": "tieneListaTareas",
|
||||
"label": "Filtros"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@
|
|||
"filters": {
|
||||
"has-code": "hasCode",
|
||||
"has-link": "hasLink",
|
||||
"has-location": "on asukoht",
|
||||
"has-task-list": "hasTaskList",
|
||||
"label": "Filtrid"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@
|
|||
"filters": {
|
||||
"has-code": "دارای کد",
|
||||
"has-link": "دارای پیوند",
|
||||
"has-location": "دارای موقعیت",
|
||||
"has-task-list": "دارای لیست کارها",
|
||||
"label": "فیلترها"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@
|
|||
"filters": {
|
||||
"has-code": "hasCode",
|
||||
"has-link": "hasLink",
|
||||
"has-location": "on sijainti",
|
||||
"has-task-list": "hasTaskList",
|
||||
"label": "Suodattimet"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
"filters": {
|
||||
"has-code": "aCode",
|
||||
"has-link": "aLien",
|
||||
"has-location": "a une localisation",
|
||||
"has-task-list": "aListeTâches",
|
||||
"label": "Filtres"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
"filters": {
|
||||
"has-code": "hasCode",
|
||||
"has-link": "hasLink",
|
||||
"has-location": "ten localización",
|
||||
"has-task-list": "hasTaskList",
|
||||
"label": "Filtros"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@
|
|||
"filters": {
|
||||
"has-code": "कोड है",
|
||||
"has-link": "लिंक है",
|
||||
"has-location": "स्थान है",
|
||||
"has-task-list": "टास्क लिस्ट है",
|
||||
"label": "फिल्टर"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
"filters": {
|
||||
"has-code": "imaKod",
|
||||
"has-link": "imaLink",
|
||||
"has-location": "ima lokaciju",
|
||||
"has-task-list": "imaZadatke",
|
||||
"label": "Filteri"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
"filters": {
|
||||
"has-code": "vanKód",
|
||||
"has-link": "vanLink",
|
||||
"has-location": "van helyadat",
|
||||
"has-task-list": "vanFeladatLista",
|
||||
"label": "Szűrők"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
"filters": {
|
||||
"has-code": "haCodice",
|
||||
"has-link": "haLink",
|
||||
"has-location": "ha una posizione",
|
||||
"has-task-list": "haListaCompiti",
|
||||
"label": "Filtri"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -265,6 +265,7 @@
|
|||
"filters": {
|
||||
"has-code": "コードあり",
|
||||
"has-link": "リンクあり",
|
||||
"has-location": "位置情報あり",
|
||||
"has-task-list": "タスクリストあり",
|
||||
"label": "フィルター"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
"filters": {
|
||||
"has-code": "კოდიარის",
|
||||
"has-link": "ლინკიარის",
|
||||
"has-location": "აქვს მდებარეობა",
|
||||
"has-task-list": "სიისამოცანებია",
|
||||
"label": "ფილტრები"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@
|
|||
"filters": {
|
||||
"has-code": "코드있음",
|
||||
"has-link": "링크있음",
|
||||
"has-location": "위치 있음",
|
||||
"has-task-list": "할일목록있음",
|
||||
"label": "필터"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@
|
|||
"filters": {
|
||||
"has-code": "hasCode",
|
||||
"has-link": "hasLink",
|
||||
"has-location": "turi vietą",
|
||||
"has-task-list": "turiTaskList",
|
||||
"label": "Filtrai"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@
|
|||
"filters": {
|
||||
"has-code": "hasCode",
|
||||
"has-link": "hasLink",
|
||||
"has-location": "ir atrašanās vieta",
|
||||
"has-task-list": "hasTaskList",
|
||||
"label": "Filtri"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@
|
|||
"filters": {
|
||||
"has-code": "कोड आहे",
|
||||
"has-link": "लिंक आहे",
|
||||
"has-location": "स्थान आहे",
|
||||
"has-task-list": "कार्यसूची आहे",
|
||||
"label": "फिल्टर"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
"filters": {
|
||||
"has-code": "harKode",
|
||||
"has-link": "harLink",
|
||||
"has-location": "har posisjon",
|
||||
"has-task-list": "harGjøremålsListe",
|
||||
"label": "Filtre"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
"filters": {
|
||||
"has-code": "heeftCode",
|
||||
"has-link": "heeftLink",
|
||||
"has-location": "heeft locatie",
|
||||
"has-task-list": "heeftTakenlijst",
|
||||
"label": "Filters"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@
|
|||
"filters": {
|
||||
"has-code": "temCódigo",
|
||||
"has-link": "temLink",
|
||||
"has-location": "tem localização",
|
||||
"has-task-list": "temListaDeTarefas",
|
||||
"label": "Filtros"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
"filters": {
|
||||
"has-code": "temCódigo",
|
||||
"has-link": "temLink",
|
||||
"has-location": "tem localização",
|
||||
"has-task-list": "temListaDeTarefas",
|
||||
"label": "Filtros"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@
|
|||
"filters": {
|
||||
"has-code": "areCode",
|
||||
"has-link": "areLink",
|
||||
"has-location": "are locație",
|
||||
"has-task-list": "areTaskList",
|
||||
"label": "Filtre"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
"filters": {
|
||||
"has-code": "hasCode",
|
||||
"has-link": "hasLink",
|
||||
"has-location": "есть местоположение",
|
||||
"has-task-list": "hasTaskList",
|
||||
"label": "Фильтры"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@
|
|||
"filters": {
|
||||
"has-code": "hasCode",
|
||||
"has-link": "hasLink",
|
||||
"has-location": "má polohu",
|
||||
"has-task-list": "hasTaskList",
|
||||
"label": "Filtre"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
"filters": {
|
||||
"has-code": "imaKodo",
|
||||
"has-link": "imaPovezavo",
|
||||
"has-location": "imaLokacijo",
|
||||
"has-task-list": "imaSeznamOpravil",
|
||||
"label": "Filtri"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@
|
|||
"filters": {
|
||||
"has-code": "хасЦоде",
|
||||
"has-link": "хасЛинк",
|
||||
"has-location": "има локацију",
|
||||
"has-task-list": "хасТаскЛист",
|
||||
"label": "Филтери"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
"filters": {
|
||||
"has-code": "harKod",
|
||||
"has-link": "harLänk",
|
||||
"has-location": "har plats",
|
||||
"has-task-list": "harAttGöraLista",
|
||||
"label": "Filter"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@
|
|||
"filters": {
|
||||
"has-code": "มีโค้ด",
|
||||
"has-link": "มีลิงก์",
|
||||
"has-location": "มีตำแหน่ง",
|
||||
"has-task-list": "มีรายการที่ต้องทำ",
|
||||
"label": "ตัวกรอง"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@
|
|||
"filters": {
|
||||
"has-code": "єКод",
|
||||
"has-link": "єПосилання",
|
||||
"has-location": "є розташування",
|
||||
"has-task-list": "єСписокЗавдань",
|
||||
"label": "Фільтри"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -280,6 +280,7 @@
|
|||
"filters": {
|
||||
"has-code": "有代码",
|
||||
"has-link": "有链接",
|
||||
"has-location": "有位置",
|
||||
"has-task-list": "有待办",
|
||||
"label": "过滤器"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -357,6 +357,7 @@
|
|||
"filters": {
|
||||
"has-code": "有程式碼",
|
||||
"has-link": "有連結",
|
||||
"has-location": "有位置",
|
||||
"has-task-list": "有待辦事項",
|
||||
"label": "過濾器"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue