diff --git a/internal/filterquery/filterquery.go b/internal/filterquery/filterquery.go new file mode 100644 index 0000000..f7c23b3 --- /dev/null +++ b/internal/filterquery/filterquery.go @@ -0,0 +1,385 @@ +// Package filterquery parses the saved-filter language: the small expression +// somebody types to describe a slice of their tasks. +// +// today & p1 +// #Firma & @regnskab +// overdue | (today & assigned to: me) +// 7 days & !@venter +// +// It compiles to SQL rather than filtering in Go, so a filter over ten thousand +// tasks is one indexed query instead of ten thousand rows crossing the boundary. +// +// The grammar is deliberately Todoist's, because that is the one people already +// know and the import path from it should not require relearning anything: +// +// expression := term (('&' | '|' | ',') term)* +// term := '!' term | '(' expression ')' | atom +// atom := #project | @label | pN | date-word | 'assigned to:' name | text +// +// '&' binds tighter than '|', and ',' is Todoist's spelling of '|'. +package filterquery + +import ( + "fmt" + "strconv" + "strings" + "time" + "unicode" +) + +// Compiled is a WHERE fragment and its arguments, ready to be dropped into the +// task query. The fragment references the task table as `t`. +type Compiled struct { + SQL string + Args []any +} + +// Context is what the query needs to know that is not in the text: who is asking, +// and what "today" means where they are. +type Context struct { + UserID string + Now time.Time + Location *time.Location +} + +func (c Context) today() time.Time { + loc := c.Location + if loc == nil { + loc = time.UTC + } + now := c.Now.In(loc) + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) +} + +// Parse compiles a filter expression. +// +// An error names what went wrong in the same terms the person typed, because this +// is a language they are writing by hand in a small box with no autocomplete. +func Parse(query string, ctx Context) (Compiled, error) { + p := &parser{tokens: tokenize(query), ctx: ctx} + if len(p.tokens) == 0 { + return Compiled{}, fmt.Errorf("filteret er tomt") + } + + node, err := p.parseExpression() + if err != nil { + return Compiled{}, err + } + if !p.done() { + return Compiled{}, fmt.Errorf("kunne ikke læse %q", p.peek().text) + } + return node, nil +} + +// --- tokens ------------------------------------------------------------------- + +type tokenKind int + +const ( + tokWord tokenKind = iota + tokAnd + tokOr + tokNot + tokLParen + tokRParen +) + +type token struct { + kind tokenKind + text string +} + +func tokenize(query string) []token { + var out []token + runes := []rune(query) + + for i := 0; i < len(runes); { + r := runes[i] + switch { + case unicode.IsSpace(r): + i++ + case r == '&': + out = append(out, token{kind: tokAnd, text: "&"}) + i++ + case r == '|', r == ',': + out = append(out, token{kind: tokOr, text: string(r)}) + i++ + case r == '!': + out = append(out, token{kind: tokNot, text: "!"}) + i++ + case r == '(': + out = append(out, token{kind: tokLParen, text: "("}) + i++ + case r == ')': + out = append(out, token{kind: tokRParen, text: ")"}) + i++ + default: + // A word runs to the next operator. That is what lets "assigned to: + // anders" and "7 days" arrive as single atoms rather than as three + // fragments the parser would then have to reassemble. + start := i + for i < len(runes) && !isOperator(runes[i]) { + i++ + } + text := strings.TrimSpace(string(runes[start:i])) + if text != "" { + out = append(out, token{kind: tokWord, text: text}) + } + } + } + return out +} + +func isOperator(r rune) bool { + switch r { + case '&', '|', ',', '!', '(', ')': + return true + } + return false +} + +// --- parser ------------------------------------------------------------------- + +type parser struct { + tokens []token + pos int + ctx Context +} + +func (p *parser) done() bool { return p.pos >= len(p.tokens) } +func (p *parser) peek() token { return p.tokens[p.pos] } +func (p *parser) next() token { t := p.tokens[p.pos]; p.pos++; return t } + +// parseExpression handles '|' — the loosest binding, so it is the outermost level. +func (p *parser) parseExpression() (Compiled, error) { + left, err := p.parseAnd() + if err != nil { + return Compiled{}, err + } + for !p.done() && p.peek().kind == tokOr { + p.next() + right, err := p.parseAnd() + if err != nil { + return Compiled{}, err + } + left = combine("OR", left, right) + } + return left, nil +} + +func (p *parser) parseAnd() (Compiled, error) { + left, err := p.parseTerm() + if err != nil { + return Compiled{}, err + } + for !p.done() && p.peek().kind == tokAnd { + p.next() + right, err := p.parseTerm() + if err != nil { + return Compiled{}, err + } + left = combine("AND", left, right) + } + return left, nil +} + +func (p *parser) parseTerm() (Compiled, error) { + if p.done() { + return Compiled{}, fmt.Errorf("filteret slutter midt i et udtryk") + } + + switch t := p.peek(); t.kind { + case tokNot: + p.next() + inner, err := p.parseTerm() + if err != nil { + return Compiled{}, err + } + return Compiled{SQL: "NOT (" + inner.SQL + ")", Args: inner.Args}, nil + + case tokLParen: + p.next() + inner, err := p.parseExpression() + if err != nil { + return Compiled{}, err + } + if p.done() || p.peek().kind != tokRParen { + return Compiled{}, fmt.Errorf("der mangler en afsluttende parentes") + } + p.next() + return Compiled{SQL: "(" + inner.SQL + ")", Args: inner.Args}, nil + + case tokRParen: + return Compiled{}, fmt.Errorf("uventet )") + + case tokAnd, tokOr: + return Compiled{}, fmt.Errorf("%q står hvor der skulle stå et søgeord", t.text) + + default: + p.next() + return p.atom(t.text) + } +} + +func combine(op string, left, right Compiled) Compiled { + return Compiled{ + SQL: "(" + left.SQL + " " + op + " " + right.SQL + ")", + Args: append(append([]any{}, left.Args...), right.Args...), + } +} + +// --- atoms -------------------------------------------------------------------- + +// atom compiles one search term. +// +// Every branch produces parameterised SQL. No user text is ever concatenated into +// the statement — this is a language people type, so it is exactly the place where +// string-building would become an injection. +func (p *parser) atom(text string) (Compiled, error) { + lower := strings.ToLower(strings.TrimSpace(text)) + if lower == "" { + return Compiled{}, fmt.Errorf("tomt søgeord") + } + + switch { + case strings.HasPrefix(lower, "#"): + return p.project(strings.TrimPrefix(text, "#")) + + case strings.HasPrefix(lower, "@"): + return p.label(strings.TrimPrefix(text, "@")) + + case strings.HasPrefix(lower, "assigned to:"), strings.HasPrefix(lower, "tildelt:"): + _, value, _ := strings.Cut(text, ":") + return p.assignee(strings.TrimSpace(value)) + + case lower == "assigned", lower == "tildelt": + return Compiled{SQL: "t.assignee_id IS NOT NULL"}, nil + + case lower == "no date", lower == "ingen dato", lower == "no due date": + return Compiled{SQL: "t.due_date IS NULL"}, nil + + case lower == "overdue", lower == "forsinket", lower == "over due": + return Compiled{ + SQL: "(t.due_date IS NOT NULL AND t.due_date < ?)", + Args: []any{p.day(0)}, + }, nil + + case lower == "today", lower == "i dag", lower == "idag": + return Compiled{SQL: "t.due_date = ?", Args: []any{p.day(0)}}, nil + + case lower == "tomorrow", lower == "i morgen", lower == "imorgen": + return Compiled{SQL: "t.due_date = ?", Args: []any{p.day(1)}}, nil + + case lower == "yesterday", lower == "i går", lower == "igar": + return Compiled{SQL: "t.due_date = ?", Args: []any{p.day(-1)}}, nil + + case lower == "recurring", lower == "gentagen", lower == "gentagne": + return Compiled{SQL: "t.recurrence_rule IS NOT NULL"}, nil + + case lower == "completed", lower == "færdig", lower == "afsluttet": + return Compiled{SQL: "t.completed_at IS NOT NULL"}, nil + + case lower == "subtask", lower == "underopgave": + return Compiled{SQL: "t.parent_id IS NOT NULL"}, nil + } + + // pN — priority. Todoist numbers them the way the interface does, p1 highest. + if len(lower) == 2 && lower[0] == 'p' && lower[1] >= '1' && lower[1] <= '4' { + return Compiled{SQL: "t.priority = ?", Args: []any{int(lower[1] - '0')}}, nil + } + + // "7 days" / "7 dage" — everything due between now and then, which is how + // Todoist reads it: a window, not a single day. + if n, unit, ok := cutNumberWord(lower); ok { + switch unit { + case "days", "dage", "day", "dag": + return Compiled{ + SQL: "(t.due_date IS NOT NULL AND t.due_date >= ? AND t.due_date <= ?)", + Args: []any{p.day(0), p.day(n)}, + }, nil + case "weeks", "uger", "week", "uge": + return Compiled{ + SQL: "(t.due_date IS NOT NULL AND t.due_date >= ? AND t.due_date <= ?)", + Args: []any{p.day(0), p.day(n * 7)}, + }, nil + } + } + + // An explicit date. + if _, err := time.Parse("2006-01-02", lower); err == nil { + return Compiled{SQL: "t.due_date = ?", Args: []any{lower}}, nil + } + + // Anything left is free text, matched against the task's own words. Falling + // back to a search rather than an error is what keeps the box forgiving: a + // filter that finds too much is fixable, one that refuses to run is not. + return Compiled{ + SQL: "(t.content LIKE ? OR t.description LIKE ?)", + Args: []any{"%" + text + "%", "%" + text + "%"}, + }, nil +} + +func (p *parser) day(offset int) string { + return p.ctx.today().AddDate(0, 0, offset).Format("2006-01-02") +} + +// project matches by name, case-insensitively, and only among projects the asking +// user can see — the outer query restricts that, but naming a project here must not +// widen it. +func (p *parser) project(name string) (Compiled, error) { + name = strings.TrimSpace(strings.Trim(name, `"`)) + if name == "" { + return Compiled{}, fmt.Errorf("#: der mangler et projektnavn") + } + return Compiled{ + SQL: `t.project_id IN ( + SELECT id FROM projects WHERE lower(name) = lower(?) AND deleted_at IS NULL)`, + Args: []any{name}, + }, nil +} + +// label matches the asking user's own labels. Labels are personal, so the same word +// on somebody else's task is a different label and must not match. +func (p *parser) label(name string) (Compiled, error) { + name = strings.TrimSpace(strings.Trim(name, `"`)) + if name == "" { + return Compiled{}, fmt.Errorf("@: der mangler et etiketnavn") + } + return Compiled{ + SQL: `EXISTS ( + SELECT 1 FROM task_labels tl JOIN labels l ON l.id = tl.label_id + WHERE tl.task_id = t.id AND l.user_id = ? AND lower(l.name) = lower(?))`, + Args: []any{p.ctx.UserID, name}, + }, nil +} + +// assignee resolves "me" to the asking user and anything else to a name or address. +func (p *parser) assignee(who string) (Compiled, error) { + who = strings.TrimSpace(strings.Trim(who, `"`)) + switch strings.ToLower(who) { + case "": + return Compiled{}, fmt.Errorf("assigned to: der mangler et navn") + case "me", "mig": + return Compiled{SQL: "t.assignee_id = ?", Args: []any{p.ctx.UserID}}, nil + case "none", "ingen", "nobody": + return Compiled{SQL: "t.assignee_id IS NULL"}, nil + } + return Compiled{ + SQL: `t.assignee_id IN ( + SELECT id FROM users WHERE lower(email) = lower(?) OR lower(name) = lower(?))`, + Args: []any{who, who}, + }, nil +} + +// cutNumberWord reads "7 days" into (7, "days"). +func cutNumberWord(s string) (int, string, bool) { + fields := strings.Fields(s) + if len(fields) != 2 { + return 0, "", false + } + n, err := strconv.Atoi(fields[0]) + if err != nil || n < 0 || n > 3650 { + return 0, "", false + } + return n, fields[1], true +} diff --git a/internal/filterquery/filterquery_test.go b/internal/filterquery/filterquery_test.go new file mode 100644 index 0000000..b3dcb73 --- /dev/null +++ b/internal/filterquery/filterquery_test.go @@ -0,0 +1,373 @@ +package filterquery + +import ( + "database/sql" + "path/filepath" + "strings" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +func ctx(t *testing.T) Context { + t.Helper() + loc, err := time.LoadLocation("Europe/Copenhagen") + if err != nil { + t.Fatalf("load timezone: %v", err) + } + return Context{ + UserID: "u1", + Now: time.Date(2026, 3, 10, 10, 0, 0, 0, loc), + Location: loc, + } +} + +func TestParseProducesRunnableSQL(t *testing.T) { + // Every expression is executed against a real SQLite schema, not merely + // string-compared. A filter language that compiles to SQL which does not run + // is worse than no filter language. + db := schema(t) + c := ctx(t) + + queries := []string{ + "today", + "i dag", + "overdue", + "forsinket", + "tomorrow", + "no date", + "p1", + "p4", + "#Firma", + "@regnskab", + "assigned to: me", + "assigned to: anders@example.dk", + "tildelt: mig", + "assigned to: none", + "assigned", + "recurring", + "completed", + "subtask", + "7 days", + "14 dage", + "2 weeks", + "2026-12-24", + "today & p1", + "today | tomorrow", + "today, tomorrow", + "#Firma & @regnskab", + "!@venter", + "today & !p4", + "(today | overdue) & p1", + "overdue | (today & assigned to: me)", + "#Firma & (p1 | p2) & !completed", + "rapport", + "7 days & #Firma & @vigtig", + } + + for _, q := range queries { + t.Run(q, func(t *testing.T) { + compiled, err := Parse(q, c) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if compiled.SQL == "" { + t.Fatal("compiled to an empty condition") + } + // The fragment has to slot into the real task query. + stmt := `SELECT count(*) FROM tasks t + JOIN projects p ON p.id = t.project_id + WHERE ` + compiled.SQL + var n int + if err := db.QueryRow(stmt, compiled.Args...).Scan(&n); err != nil { + t.Fatalf("query failed: %v\nSQL: %s", err, stmt) + } + }) + } +} + +// The compiled fragment must actually select the right rows, not merely run. +func TestFiltersSelectTheRightTasks(t *testing.T) { + db := schema(t) + c := ctx(t) + seed(t, db) + + cases := []struct { + query string + want []string + }{ + {"today", []string{"i-dag-p1"}}, + {"overdue", []string{"forsinket"}}, + {"tomorrow", []string{"i-morgen"}}, + // firma-p3 has no due date either, so it belongs here. + {"no date", []string{"ingen-dato", "underopgave", "firma-p3"}}, + {"p1", []string{"i-dag-p1"}}, + {"#Firma", []string{"i-dag-p1", "firma-p3"}}, + {"@regnskab", []string{"i-dag-p1"}}, + {"assigned to: me", []string{"i-dag-p1"}}, + {"assigned to: none", []string{"forsinket", "i-morgen", "ingen-dato", "firma-p3", "underopgave"}}, + {"recurring", []string{"i-morgen"}}, + {"subtask", []string{"underopgave"}}, + + // Combinations + {"today & p1", []string{"i-dag-p1"}}, + {"today | overdue", []string{"forsinket", "i-dag-p1"}}, + {"#Firma & p1", []string{"i-dag-p1"}}, + {"#Firma & !p1", []string{"firma-p3"}}, + // u1's "regnskab" is on i-dag-p1 only, so the other Firma task survives. + {"!@regnskab & #Firma", []string{"firma-p3"}}, + {"(today | tomorrow) & !p1", []string{"i-morgen"}}, + + // A window, not a single day. + {"7 days", []string{"i-dag-p1", "i-morgen"}}, + + // Free text falls through to a content search. + {"moms", []string{"i-dag-p1"}}, + } + + for _, tc := range cases { + t.Run(tc.query, func(t *testing.T) { + compiled, err := Parse(tc.query, c) + if err != nil { + t.Fatalf("Parse: %v", err) + } + got := run(t, db, compiled) + if !sameSet(got, tc.want) { + t.Errorf("matched %v, want %v", got, tc.want) + } + }) + } +} + +// '&' binds tighter than '|', so "a | b & c" is "a | (b & c)". +func TestAndBindsTighterThanOr(t *testing.T) { + db := schema(t) + c := ctx(t) + seed(t, db) + + loose, err := Parse("overdue | today & p1", c) + if err != nil { + t.Fatal(err) + } + explicit, err := Parse("overdue | (today & p1)", c) + if err != nil { + t.Fatal(err) + } + if !sameSet(run(t, db, loose), run(t, db, explicit)) { + t.Error("'a | b & c' does not mean 'a | (b & c)'") + } + + // And the other grouping really is different, or the test above proves nothing. + other, err := Parse("(overdue | today) & p1", c) + if err != nil { + t.Fatal(err) + } + if sameSet(run(t, db, loose), run(t, db, other)) { + t.Error("both groupings select the same rows; the precedence test is vacuous") + } +} + +func TestErrors(t *testing.T) { + c := ctx(t) + for _, q := range []string{ + "", + " ", + "&", + "today &", + "| today", + "(today", + "today)", + "()", + "#", + "@", + "assigned to:", + } { + t.Run(q, func(t *testing.T) { + if _, err := Parse(q, c); err == nil { + t.Error("accepted an expression that is not valid") + } + }) + } +} + +// The filter box takes free text from a person. None of it may reach the database +// as SQL — every value has to arrive as a bound parameter. +func TestNoUserTextReachesTheSQL(t *testing.T) { + db := schema(t) + c := ctx(t) + seed(t, db) + + hostile := []string{ + `#'; DROP TABLE tasks; --`, + `@' OR 1=1 --`, + `assigned to: ' OR '1'='1`, + `'; DELETE FROM projects; --`, + `x' UNION SELECT password_hash FROM users --`, + } + + for _, q := range hostile { + t.Run(q, func(t *testing.T) { + compiled, err := Parse(q, c) + if err != nil { + return // refusing it outright is also a correct answer + } + // The property is that the typed text appears only among the bound + // arguments — never in the statement. Scanning the SQL for words like + // DELETE would instead flag the column name `deleted_at`, which proves + // nothing about injection. + for _, needle := range []string{"DROP TABLE", "DELETE FROM", "UNION SELECT", "OR 1=1", "'1'='1"} { + if strings.Contains(strings.ToUpper(compiled.SQL), needle) { + t.Fatalf("%q was built into the SQL: %s", needle, compiled.SQL) + } + } + // And it still executes, so the expression is not merely inert text — + // it reaches the database as a real query with the hostile part bound. + run(t, db, compiled) + }) + } + + // And everything is still there. + var tasks, projects int + if err := db.QueryRow(`SELECT count(*) FROM tasks`).Scan(&tasks); err != nil { + t.Fatalf("the tasks table did not survive: %v", err) + } + if err := db.QueryRow(`SELECT count(*) FROM projects`).Scan(&projects); err != nil { + t.Fatalf("the projects table did not survive: %v", err) + } + if tasks == 0 || projects == 0 { + t.Error("rows were deleted by a filter expression") + } +} + +// Labels are personal: the same word on another person's task is a different label. +func TestLabelsAreScopedToTheAskingUser(t *testing.T) { + db := schema(t) + seed(t, db) + + // Both users have a label called "regnskab", on different tasks. Each must see + // only their own — that is what "labels are personal" has to mean in practice. + mine, err := Parse("@regnskab", ctx(t)) + if err != nil { + t.Fatal(err) + } + if got := run(t, db, mine); !sameSet(got, []string{"i-dag-p1"}) { + t.Errorf("u1 matched %v, want only their own labelled task", got) + } + + other := ctx(t) + other.UserID = "u2" + theirs, err := Parse("@regnskab", other) + if err != nil { + t.Fatal(err) + } + if got := run(t, db, theirs); !sameSet(got, []string{"firma-p3"}) { + t.Errorf("u2 matched %v, want only their own labelled task", got) + } +} + +// --- helpers ------------------------------------------------------------------ + +func schema(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "f.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + + stmts := []string{ + `CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, name TEXT, password_hash TEXT)`, + `CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT, deleted_at INTEGER)`, + `CREATE TABLE labels (id TEXT PRIMARY KEY, user_id TEXT, name TEXT)`, + `CREATE TABLE task_labels (task_id TEXT, label_id TEXT)`, + `CREATE TABLE tasks ( + id TEXT PRIMARY KEY, project_id TEXT, parent_id TEXT, content TEXT, + description TEXT DEFAULT '', priority INTEGER DEFAULT 4, + due_date TEXT, recurrence_rule TEXT, assignee_id TEXT, + completed_at INTEGER, deleted_at INTEGER)`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("schema: %v", err) + } + } + return db +} + +func seed(t *testing.T, db *sql.DB) { + t.Helper() + stmts := []string{ + `INSERT INTO users (id, email, name) VALUES + ('u1','kristian@example.dk','Kristian'), ('u2','anders@example.dk','Anders')`, + `INSERT INTO projects (id, name) VALUES ('p-firma','Firma'), ('p-inbox','Indbakke')`, + `INSERT INTO labels (id, user_id, name) VALUES + ('l-regnskab','u1','regnskab'), ('l-andens','u2','regnskab')`, + + // 10 March 2026 is "today" for these tests. + `INSERT INTO tasks (id, project_id, content, priority, due_date, assignee_id) VALUES + ('i-dag-p1','p-firma','betal moms',1,'2026-03-10','u1')`, + `INSERT INTO tasks (id, project_id, content, priority, due_date) VALUES + ('forsinket','p-inbox','var forsinket',3,'2026-03-01')`, + `INSERT INTO tasks (id, project_id, content, priority, due_date, recurrence_rule) VALUES + ('i-morgen','p-inbox','i morgen',3,'2026-03-11','FREQ=DAILY')`, + `INSERT INTO tasks (id, project_id, content, priority) VALUES + ('ingen-dato','p-inbox','ingen dato',4)`, + `INSERT INTO tasks (id, project_id, content, priority) VALUES + ('firma-p3','p-firma','noget andet',3)`, + `INSERT INTO tasks (id, project_id, parent_id, content, priority) VALUES + ('underopgave','p-inbox','ingen-dato','en underopgave',4)`, + + `INSERT INTO task_labels (task_id, label_id) VALUES ('i-dag-p1','l-regnskab')`, + // The other user's identically-named label, on a task of theirs. + `INSERT INTO task_labels (task_id, label_id) VALUES ('firma-p3','l-andens')`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("seed: %v", err) + } + } +} + +func run(t *testing.T, db *sql.DB, c Compiled) []string { + t.Helper() + rows, err := db.Query(`SELECT t.id FROM tasks t + JOIN projects p ON p.id = t.project_id + WHERE `+c.SQL+` ORDER BY t.id`, c.Args...) + if err != nil { + t.Fatalf("query: %v\nSQL: %s", err, c.SQL) + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + t.Fatal(err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return ids +} + +func sameSet(a, b []string) bool { + if len(a) != len(b) { + return false + } + seen := map[string]int{} + for _, v := range a { + seen[v]++ + } + for _, v := range b { + seen[v]-- + } + for _, n := range seen { + if n != 0 { + return false + } + } + return true +} diff --git a/internal/httpapi/filters_handlers.go b/internal/httpapi/filters_handlers.go new file mode 100644 index 0000000..1c4bc8e --- /dev/null +++ b/internal/httpapi/filters_handlers.go @@ -0,0 +1,270 @@ +package httpapi + +import ( + "errors" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/kristianwind/verdande/internal/filterquery" + "github.com/kristianwind/verdande/internal/store" +) + +type filterJSON struct { + ID string `json:"id"` + Name string `json:"name"` + Query string `json:"query"` + Color string `json:"color"` + SortOrder float64 `json:"sort_order"` +} + +func toFilterJSON(f store.Filter) filterJSON { + return filterJSON{ID: f.ID, Name: f.Name, Query: f.Query, Color: f.Color, SortOrder: f.SortOrder} +} + +func (s *Server) handleListFilters(w http.ResponseWriter, r *http.Request) { + filters, err := s.db.ListFilters(r.Context(), userFrom(r.Context()).ID) + if err != nil { + s.internal(w, "list filters", err) + return + } + out := make([]filterJSON, 0, len(filters)) + for _, f := range filters { + out = append(out, toFilterJSON(f)) + } + writeJSON(w, http.StatusOK, map[string]any{"filters": out}) +} + +type filterRequest struct { + Name *string `json:"name"` + Query *string `json:"query"` + Color *string `json:"color"` + SortOrder *float64 `json:"sort_order"` +} + +func (s *Server) handleCreateFilter(w http.ResponseWriter, r *http.Request) { + var req filterRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + user := userFrom(r.Context()) + + name := strings.TrimSpace(valueOr(req.Name, "")) + query := strings.TrimSpace(valueOr(req.Query, "")) + + fields := map[string]string{} + if name == "" { + fields["name"] = "required" + } + // The expression is compiled before it is saved. A filter that cannot run is + // worse than no filter: it is discovered later, from a list that is empty for + // a reason nobody can see. + if msg := checkFilterQuery(query, user); msg != "" { + fields["query"] = msg + } + if len(fields) > 0 { + writeFieldErrors(w, fields) + return + } + + f := &store.Filter{UserID: user.ID, Name: name, Query: query, Color: valueOr(req.Color, "")} + if req.SortOrder != nil { + f.SortOrder = *req.SortOrder + } + if err := s.db.CreateFilter(r.Context(), f); err != nil { + s.internal(w, "create filter", err) + return + } + writeJSON(w, http.StatusCreated, toFilterJSON(*f)) +} + +func (s *Server) handleUpdateFilter(w http.ResponseWriter, r *http.Request) { + var req filterRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + user := userFrom(r.Context()) + + if req.Query != nil { + query := strings.TrimSpace(*req.Query) + if msg := checkFilterQuery(query, user); msg != "" { + writeFieldErrors(w, map[string]string{"query": msg}) + return + } + req.Query = &query + } + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + writeFieldErrors(w, map[string]string{"name": "required"}) + return + } + req.Name = &name + } + + err := s.db.UpdateFilter(r.Context(), user.ID, chi.URLParam(r, "filterID"), store.FilterUpdate{ + Name: req.Name, Query: req.Query, Color: req.Color, SortOrder: req.SortOrder, + }) + if err != nil { + s.storeError(w, "update filter", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleDeleteFilter(w http.ResponseWriter, r *http.Request) { + err := s.db.DeleteFilter(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "filterID")) + if err != nil { + s.storeError(w, "delete filter", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleRunFilter executes a saved filter and returns the tasks it selects. +func (s *Server) handleRunFilter(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + + f, err := s.db.GetFilter(r.Context(), user.ID, chi.URLParam(r, "filterID")) + if err != nil { + s.storeError(w, "get filter", err) + return + } + s.runQuery(w, r, f.Query) +} + +// handlePreviewFilter runs an expression without saving it, so the filter editor +// can show what it selects while it is being written. +func (s *Server) handlePreviewFilter(w http.ResponseWriter, r *http.Request) { + s.runQuery(w, r, r.URL.Query().Get("query")) +} + +func (s *Server) runQuery(w http.ResponseWriter, r *http.Request, query string) { + user := userFrom(r.Context()) + + compiled, err := filterquery.Parse(query, filterContext(user)) + if err != nil { + // The message is the parser's own, in Danish, naming what it could not + // read — this is a language somebody is writing by hand in a small box. + writeJSON(w, http.StatusUnprocessableEntity, APIError{ + Code: CodeValidation, + Message: err.Error(), + Fields: map[string]string{"query": err.Error()}, + }) + return + } + + tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{ + FilterSQL: compiled.SQL, + FilterArgs: compiled.Args, + // A filter that names "completed" has to be able to find completed tasks, + // so the query decides rather than the default. + IncludeCompleted: strings.Contains(strings.ToLower(query), "completed") || + strings.Contains(strings.ToLower(query), "færdig") || + strings.Contains(strings.ToLower(query), "afsluttet"), + Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500), + }) + if err != nil { + s.internal(w, "run filter", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)}) +} + +func filterContext(user *store.User) filterquery.Context { + return filterquery.Context{ + UserID: user.ID, + Now: time.Now(), + Location: userLocation(user.Timezone), + } +} + +func checkFilterQuery(query string, user *store.User) string { + if strings.TrimSpace(query) == "" { + return "required" + } + if _, err := filterquery.Parse(query, filterContext(user)); err != nil { + return err.Error() + } + return "" +} + +// --- labels --------------------------------------------------------------------- + +type labelJSON struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + SortOrder float64 `json:"sort_order"` + TaskCount int `json:"task_count"` +} + +func (s *Server) handleListLabels(w http.ResponseWriter, r *http.Request) { + labels, err := s.db.ListLabels(r.Context(), userFrom(r.Context()).ID) + if err != nil { + s.internal(w, "list labels", err) + return + } + out := make([]labelJSON, 0, len(labels)) + for _, l := range labels { + out = append(out, labelJSON{l.ID, l.Name, l.Color, l.SortOrder, l.TaskCount}) + } + writeJSON(w, http.StatusOK, map[string]any{"labels": out}) +} + +type labelRequest struct { + Name *string `json:"name"` + Color *string `json:"color"` +} + +func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) { + var req labelRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + name := strings.TrimSpace(valueOr(req.Name, "")) + if name == "" { + writeFieldErrors(w, map[string]string{"name": "required"}) + return + } + + l := &store.Label{UserID: userFrom(r.Context()).ID, Name: name, Color: valueOr(req.Color, "")} + if err := s.db.CreateLabel(r.Context(), l); err != nil { + if errors.Is(err, store.ErrLabelExists) { + writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name") + return + } + s.internal(w, "create label", err) + return + } + writeJSON(w, http.StatusCreated, labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0}) +} + +func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) { + var req labelRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + err := s.db.UpdateLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID"), + strings.TrimSpace(valueOr(req.Name, "")), valueOr(req.Color, "")) + if err != nil { + if errors.Is(err, store.ErrLabelExists) { + writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name") + return + } + s.storeError(w, "update label", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) { + err := s.db.DeleteLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID")) + if err != nil { + s.storeError(w, "delete label", err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 86e3b27..a2f6e82 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -116,6 +116,22 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Get("/ws", s.handleWebSocket) r.Get("/today", s.handleToday) + r.Get("/filters/preview", s.handlePreviewFilter) + + r.Route("/filters", func(r chi.Router) { + r.Get("/", s.handleListFilters) + r.Post("/", s.handleCreateFilter) + r.Get("/{filterID}/tasks", s.handleRunFilter) + r.Patch("/{filterID}", s.handleUpdateFilter) + r.Delete("/{filterID}", s.handleDeleteFilter) + }) + + r.Route("/labels", func(r chi.Router) { + r.Get("/", s.handleListLabels) + r.Post("/", s.handleCreateLabel) + r.Patch("/{labelID}", s.handleUpdateLabel) + r.Delete("/{labelID}", s.handleDeleteLabel) + }) r.Get("/upcoming", s.handleUpcoming) r.Get("/search", s.handleSearch) diff --git a/internal/httpapi/tasks_test.go b/internal/httpapi/tasks_test.go index 8c3eec6..f9c2449 100644 --- a/internal/httpapi/tasks_test.go +++ b/internal/httpapi/tasks_test.go @@ -839,3 +839,170 @@ func TestCompletingAPlainTaskStillCloses(t *testing.T) { t.Error("a plain task reported as recurring") } } + +// --- saved filters ---------------------------------------------------------------- + +func TestSavedFilters(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + _, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"}) + projectID := project["id"].(string) + + ts.do(t, "POST", "/api/v1/tasks", map[string]any{ + "content": "betal moms", "project_id": projectID, + "priority": 1, "due_date": userDate(t, 0), "labels": []string{"regnskab"}, + }) + ts.do(t, "POST", "/api/v1/tasks", map[string]any{ + "content": "ryd op", "project_id": projectID, "priority": 4, + }) + ts.do(t, "POST", "/api/v1/tasks", map[string]any{ + "content": "forsinket ting", "due_date": userDate(t, -3), "priority": 2, + }) + + resp, filter := ts.do(t, "POST", "/api/v1/filters", map[string]any{ + "name": "Haster i dag", "query": "today & p1", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create filter: %d %v", resp.StatusCode, filter) + } + + resp, result := ts.do(t, "GET", "/api/v1/filters/"+filter["id"].(string)+"/tasks", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("run filter: %d %v", resp.StatusCode, result) + } + tasks, _ := result["tasks"].([]any) + if len(tasks) != 1 { + t.Fatalf("the filter matched %d tasks, want 1", len(tasks)) + } + if tasks[0].(map[string]any)["content"] != "betal moms" { + t.Errorf("matched the wrong task: %v", tasks[0]) + } +} + +func TestFilterPreviewRunsWithoutSaving(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + ts.do(t, "POST", "/api/v1/tasks", map[string]any{ + "content": "forsinket", "due_date": userDate(t, -2), + }) + ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "ingen dato"}) + + resp, body := ts.do(t, "GET", "/api/v1/filters/preview?query=overdue", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("preview: %d %v", resp.StatusCode, body) + } + tasks, _ := body["tasks"].([]any) + if len(tasks) != 1 { + t.Errorf("overdue matched %d tasks, want 1", len(tasks)) + } + + _, list := ts.do(t, "GET", "/api/v1/filters", nil) + if saved, _ := list["filters"].([]any); len(saved) != 0 { + t.Errorf("the preview saved %d filters", len(saved)) + } +} + +// A filter that cannot be compiled must be refused when it is saved, not when it +// is next opened and silently returns nothing. +func TestInvalidFilterIsRefusedOnSave(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + resp, body := ts.do(t, "POST", "/api/v1/filters", map[string]any{ + "name": "Ødelagt", "query": "today & (p1", + }) + if resp.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("status %d, want 422", resp.StatusCode) + } + fields, _ := body["fields"].(map[string]any) + if fields["query"] == nil { + t.Errorf("the error does not name the query field: %v", body) + } +} + +// A filter belongs to one person and must not reach anybody else's tasks. +func TestFiltersAreScopedToTheirOwner(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + other := ts.newUser(t, "anden@example.dk", "Anden") + + ts.do(t, "POST", "/api/v1/tasks", map[string]any{ + "content": "min private opgave", "priority": 1, "due_date": userDate(t, 0), + }) + _, filter := ts.do(t, "POST", "/api/v1/filters", map[string]any{ + "name": "Mit filter", "query": "today & p1", + }) + filterID := filter["id"].(string) + + // Somebody else cannot run it. + resp, _ := other.do(t, "GET", "/api/v1/filters/"+filterID+"/tasks", nil) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("another user ran the filter: status %d", resp.StatusCode) + } + + // And writing the same expression themselves finds none of the owner's tasks. + _, result := other.do(t, "GET", "/api/v1/filters/preview?query=today+%26+p1", nil) + if tasks, _ := result["tasks"].([]any); len(tasks) != 0 { + t.Errorf("an identical filter reached another user's tasks: %v", tasks) + } +} + +func TestLabelsCRUD(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + // Labels appear by being used, without being created first. + ts.do(t, "POST", "/api/v1/tasks", map[string]any{ + "content": "med etiket", "labels": []string{"venter"}, + }) + _, list := ts.do(t, "GET", "/api/v1/labels", nil) + labels, _ := list["labels"].([]any) + if len(labels) != 1 { + t.Fatalf("got %d labels, want the one created by using it", len(labels)) + } + if labels[0].(map[string]any)["task_count"] != float64(1) { + t.Errorf("task_count = %v, want 1", labels[0].(map[string]any)["task_count"]) + } + + resp, created := ts.do(t, "POST", "/api/v1/labels", map[string]any{"name": "haster"}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create label: %d %v", resp.StatusCode, created) + } + // The same name twice is a conflict, not a second label. + resp, _ = ts.do(t, "POST", "/api/v1/labels", map[string]any{"name": "HASTER"}) + if resp.StatusCode != http.StatusConflict { + t.Errorf("duplicate label: status %d, want 409", resp.StatusCode) + } + + resp, _ = ts.do(t, "DELETE", "/api/v1/labels/"+created["id"].(string), nil) + if resp.StatusCode != http.StatusNoContent { + t.Errorf("delete label: status %d", resp.StatusCode) + } +} + +// Deleting a label is tidying, not a request to delete the work filed under it. +func TestDeletingALabelKeepsItsTasks(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + _, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{ + "content": "vigtig opgave", "labels": []string{"venter"}, + }) + taskID := task["id"].(string) + + _, list := ts.do(t, "GET", "/api/v1/labels", nil) + labels, _ := list["labels"].([]any) + labelID := labels[0].(map[string]any)["id"].(string) + + ts.do(t, "DELETE", "/api/v1/labels/"+labelID, nil) + + resp, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("the task went with its label: %d", resp.StatusCode) + } + if got, _ := after["labels"].([]any); len(got) != 0 { + t.Errorf("labels = %v, want empty", got) + } +} diff --git a/internal/store/filters.go b/internal/store/filters.go new file mode 100644 index 0000000..df436c7 --- /dev/null +++ b/internal/store/filters.go @@ -0,0 +1,239 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "time" +) + +type Filter struct { + ID string + UserID string + Name string + Query string + Color string + SortOrder float64 + CreatedAt time.Time + UpdatedAt time.Time +} + +func (db *DB) ListFilters(ctx context.Context, userID string) ([]Filter, error) { + rows, err := db.QueryContext(ctx, + `SELECT id, user_id, name, query, color, sort_order, created_at, updated_at + FROM filters WHERE user_id = ? ORDER BY sort_order, created_at`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []Filter{} + for rows.Next() { + var f Filter + var created, updated int64 + if err := rows.Scan(&f.ID, &f.UserID, &f.Name, &f.Query, &f.Color, + &f.SortOrder, &created, &updated); err != nil { + return nil, err + } + f.CreatedAt = time.Unix(created, 0).UTC() + f.UpdatedAt = time.Unix(updated, 0).UTC() + out = append(out, f) + } + return out, rows.Err() +} + +// GetFilter is scoped by user: a filter id from somewhere else finds nothing, +// rather than running somebody else's saved query. +func (db *DB) GetFilter(ctx context.Context, userID, filterID string) (*Filter, error) { + var f Filter + var created, updated int64 + err := db.QueryRowContext(ctx, + `SELECT id, user_id, name, query, color, sort_order, created_at, updated_at + FROM filters WHERE id = ? AND user_id = ?`, filterID, userID). + Scan(&f.ID, &f.UserID, &f.Name, &f.Query, &f.Color, &f.SortOrder, &created, &updated) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + f.CreatedAt = time.Unix(created, 0).UTC() + f.UpdatedAt = time.Unix(updated, 0).UTC() + return &f, nil +} + +func (db *DB) CreateFilter(ctx context.Context, f *Filter) error { + if f.ID == "" { + f.ID = NewID() + } + if f.Color == "" { + f.Color = "graphite" + } + now := time.Now().UTC() + f.CreatedAt, f.UpdatedAt = now, now + + if f.SortOrder == 0 { + var max sql.NullFloat64 + if err := db.QueryRowContext(ctx, + `SELECT max(sort_order) FROM filters WHERE user_id = ?`, f.UserID).Scan(&max); err != nil { + return err + } + f.SortOrder = max.Float64 + 1024 + } + + _, err := db.ExecContext(ctx, + `INSERT INTO filters (id, user_id, name, query, color, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + f.ID, f.UserID, f.Name, f.Query, f.Color, f.SortOrder, now.Unix(), now.Unix()) + return err +} + +type FilterUpdate struct { + Name *string + Query *string + Color *string + SortOrder *float64 +} + +func (db *DB) UpdateFilter(ctx context.Context, userID, filterID string, u FilterUpdate) error { + set, args := buildUpdate(map[string]any{ + "name": u.Name, + "query": u.Query, + "color": u.Color, + "sort_order": u.SortOrder, + }) + if len(set) == 0 { + return nil + } + set = append(set, "updated_at = ?") + args = append(args, time.Now().Unix(), filterID, userID) + + res, err := db.ExecContext(ctx, + `UPDATE filters SET `+joinComma(set)+` WHERE id = ? AND user_id = ?`, args...) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +func (db *DB) DeleteFilter(ctx context.Context, userID, filterID string) error { + res, err := db.ExecContext(ctx, + `DELETE FROM filters WHERE id = ? AND user_id = ?`, filterID, userID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// --- labels -------------------------------------------------------------------- + +type Label struct { + ID string + UserID string + Name string + Color string + SortOrder float64 + // TaskCount is how many open tasks carry it, which is what the sidebar shows + // and what makes an unused label obvious enough to delete. + TaskCount int + CreatedAt time.Time +} + +func (db *DB) ListLabels(ctx context.Context, userID string) ([]Label, error) { + rows, err := db.QueryContext(ctx, ` + SELECT l.id, l.user_id, l.name, l.color, l.sort_order, l.created_at, + (SELECT count(*) FROM task_labels tl + JOIN tasks t ON t.id = tl.task_id + WHERE tl.label_id = l.id AND t.deleted_at IS NULL AND t.completed_at IS NULL) + FROM labels l WHERE l.user_id = ? + ORDER BY l.sort_order, l.name`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []Label{} + for rows.Next() { + var l Label + var created int64 + if err := rows.Scan(&l.ID, &l.UserID, &l.Name, &l.Color, &l.SortOrder, + &created, &l.TaskCount); err != nil { + return nil, err + } + l.CreatedAt = time.Unix(created, 0).UTC() + out = append(out, l) + } + return out, rows.Err() +} + +func (db *DB) CreateLabel(ctx context.Context, l *Label) error { + if l.ID == "" { + l.ID = NewID() + } + if l.Color == "" { + l.Color = "graphite" + } + l.CreatedAt = time.Now().UTC() + + _, err := db.ExecContext(ctx, + `INSERT INTO labels (id, user_id, name, color, sort_order, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + l.ID, l.UserID, l.Name, l.Color, l.SortOrder, l.CreatedAt.Unix()) + if isUniqueViolation(err) { + return ErrLabelExists + } + return err +} + +var ErrLabelExists = errors.New("store: that label already exists") + +func (db *DB) UpdateLabel(ctx context.Context, userID, labelID, name, color string) error { + set, args := buildUpdate(map[string]any{ + "name": nonEmpty(name), + "color": nonEmpty(color), + }) + if len(set) == 0 { + return nil + } + args = append(args, labelID, userID) + + res, err := db.ExecContext(ctx, + `UPDATE labels SET `+joinComma(set)+` WHERE id = ? AND user_id = ?`, args...) + if isUniqueViolation(err) { + return ErrLabelExists + } + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// DeleteLabel removes the label and its attachments. The tasks stay: deleting a +// label is tidying, not a request to delete the work it was filed under. +func (db *DB) DeleteLabel(ctx context.Context, userID, labelID string) error { + res, err := db.ExecContext(ctx, + `DELETE FROM labels WHERE id = ? AND user_id = ?`, labelID, userID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +func nonEmpty(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/internal/store/tasks.go b/internal/store/tasks.go index f00a10b..6b47557 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -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,