feat(filters): saved filters with a query language, and label management

"today & p1", "#Firma & @regnskab", "overdue | (today & assigned to: me)".
The grammar is Todoist's, so the import path from it does not require
relearning anything, and both languages' keywords are accepted.

It compiles to parameterised SQL rather than filtering in Go: one
indexed query instead of ten thousand rows crossing the boundary. No
typed text is ever concatenated into the statement — this is a language
people write by hand, which makes it exactly where string-building would
become an injection.

An expression is compiled before it is saved. A filter that cannot run
is worse than none: it is discovered later, as a list that is empty for
a reason nobody can see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 06:57:43 +02:00
co-authored by Claude Opus 5
parent a96aae57ec
commit 4d142b2fa7
7 changed files with 1460 additions and 0 deletions
+10
View File
@@ -62,6 +62,12 @@ type TaskFilter struct {
CompletedOnly bool
Search string
// FilterSQL is a compiled saved-filter expression: a WHERE fragment over `t`
// with its own bound arguments. It is produced by the filterquery package and
// never assembled from user text here.
FilterSQL string
FilterArgs []any
Limit int
Offset int
}
@@ -133,6 +139,10 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
where = append(where, `t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)`)
args = append(args, expr)
}
if f.FilterSQL != "" {
where = append(where, "("+f.FilterSQL+")")
args = append(args, f.FilterArgs...)
}
query := `
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,