From d37d2406250cf95518b85fef39f6745e4854e124 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Mon, 17 Aug 2026 17:05:06 +0200 Subject: [PATCH] feat(search): find a task by its label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search covered task text only, so "regnskab" found nothing with the task labelled @regnskab sitting in front of you — which reads as the search box having lost it. A label is often the only thing you remember. "That thing about the accounts" is a search for @regnskab, not for any word in the title. The label side is a plain lowercase match rather than a second FTS index. Labels are short, and their names are typed by the same person now searching for them, so there is no spelling to be generous about — the diacritic folding that earns its keep on task text does not earn a second index here. The join sits inside the WHERE the visibility rules already build, so somebody else's label cannot pull their task into your results. There is a test for that, because it is the kind of thing an OR is good at breaking. Co-Authored-By: Claude Opus 5 --- internal/httpapi/views_test.go | 55 ++++++++++++++++++++++++++++++++++ internal/store/tasks.go | 19 ++++++++++-- 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 internal/httpapi/views_test.go diff --git a/internal/httpapi/views_test.go b/internal/httpapi/views_test.go new file mode 100644 index 0000000..cf94c0b --- /dev/null +++ b/internal/httpapi/views_test.go @@ -0,0 +1,55 @@ +package httpapi + +import "testing" + +// A label is how you find a task whose wording you have forgotten. Search +// covered the text only, so "regnskab" found nothing even with the task +// labelled @regnskab in front of you. +func TestSearchFindsTasksByLabel(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + ts.do(t, "POST", "/api/v1/tasks/quick-add", + map[string]any{"text": "ring til revisoren @regnskab"}) + ts.do(t, "POST", "/api/v1/tasks/quick-add", + map[string]any{"text": "køb kaffe"}) + + _, body := ts.do(t, "GET", "/api/v1/search?q=regnskab", nil) + tasks, _ := body["tasks"].([]any) + if len(tasks) != 1 { + t.Fatalf("found %d tasks searching for a label, want 1: %v", len(tasks), body) + } + if got := tasks[0].(map[string]any)["content"]; got != "ring til revisoren" { + t.Errorf("found %v", got) + } +} + +// The text search still works, and a label match does not swallow it. +func TestSearchStillFindsTasksByText(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{"text": "køb grøn maling"}) + + // Diacritic folding still applies to the text side. + _, body := ts.do(t, "GET", "/api/v1/search?q=gron", nil) + if tasks, _ := body["tasks"].([]any); len(tasks) != 1 { + t.Errorf("searching \"gron\" found %d tasks, want 1", len(tasks)) + } +} + +// Somebody else's label must not pull their task into your results — the label +// join has to stay inside the visibility the outer query already applies. +func TestSearchByLabelStaysWithinWhatYouCanSee(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + other := ts.newUser(t, "anden@example.dk", "Anden") + other.do(t, "POST", "/api/v1/tasks/quick-add", + map[string]any{"text": "deres hemmelighed @regnskab"}) + + _, body := ts.do(t, "GET", "/api/v1/search?q=regnskab", nil) + if tasks, _ := body["tasks"].([]any); len(tasks) != 0 { + t.Errorf("found somebody else's task: %v", tasks) + } +} diff --git a/internal/store/tasks.go b/internal/store/tasks.go index 6b47557..5993e6d 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -136,8 +136,23 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas args = append(args, f.DueFrom) } if expr := MatchExpr(f.Search); expr != "" { - where = append(where, `t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)`) - args = append(args, expr) + // Text or a label. A label is how you find a task whose wording you have + // forgotten — "that thing about the accounts" is a search for @regnskab — + // and leaving them out made the box look like it had lost the task. + // + // The label side is a plain lowercase match rather than another FTS index: + // labels are short, and their names are typed by the person searching for + // them, so there is no spelling to be generous about. The diacritic + // folding that matters for task text does not earn a second index here. + where = append(where, `( + t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?) + OR t.id IN ( + SELECT tl.task_id FROM task_labels tl + JOIN labels l ON l.id = tl.label_id + WHERE lower(l.name) LIKE ? + ) + )`) + args = append(args, expr, "%"+strings.ToLower(strings.TrimSpace(f.Search))+"%") } if f.FilterSQL != "" { where = append(where, "("+f.FilterSQL+")")