diff --git a/internal/store/migrations/0020_fold_note_tags.sql b/internal/store/migrations/0020_fold_note_tags.sql new file mode 100644 index 0000000..b64b950 --- /dev/null +++ b/internal/store/migrations/0020_fold_note_tags.sql @@ -0,0 +1,29 @@ +-- #garageristeriet and #GarageRisteriet are the same project. +-- +-- They were not, because the key was stored exactly as typed and looked up the +-- same way — so a note that named a project in lower case simply did not appear +-- on it, silently and with nothing to suggest why. +-- +-- Folded on the way in from now on; this brings the rows already written into +-- line. Only projects: a [[note title]] is a title, and two notes called "Møde" +-- and "møde" are two notes. +-- +-- The primary key is (note_id, kind, target_id), so folding can collide with a row +-- that is already there — a note that wrote both spellings. INSERT OR IGNORE into +-- a fresh set and swap, which keeps one of each rather than failing on the pair. +CREATE TABLE note_links_folded ( + note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('task', 'note', 'project')), + target_id TEXT NOT NULL, + PRIMARY KEY (note_id, kind, target_id) +); + +INSERT OR IGNORE INTO note_links_folded (note_id, kind, target_id) +SELECT note_id, kind, + CASE WHEN kind = 'project' THEN lower(target_id) ELSE target_id END +FROM note_links; + +DROP TABLE note_links; +ALTER TABLE note_links_folded RENAME TO note_links; + +CREATE INDEX note_links_backwards ON note_links (kind, target_id); diff --git a/internal/store/notes.go b/internal/store/notes.go index de20bd2..f387219 100644 --- a/internal/store/notes.go +++ b/internal/store/notes.go @@ -128,7 +128,14 @@ func LinksIn(body string) []NoteLink { add("task", strings.ToLower(m[1])) } for _, m := range projectRef.FindAllStringSubmatch(body, -1) { - add("project", m[1]) + // Folded, because #garageristeriet and #GarageRisteriet are the same project + // to everybody except a database. The key is stored folded and looked up + // folded, so the comparison stays an exact match and keeps using its index — + // a comparison that lowered the column instead would read the whole table. + // + // The project's real spelling is not lost: it is on the project, which is + // what the interface shows. This column is a key, not a label. + add("project", strings.ToLower(m[1])) } for _, m := range noteRef.FindAllStringSubmatch(body, -1) { add("note", strings.TrimSpace(m[1])) @@ -210,6 +217,9 @@ func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) { // The reason note_links exists. Without it this would mean reading every note's // prose, and a panel that slow is one nobody leaves open. func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note, error) { + if kind == "project" { + targetID = strings.ToLower(targetID) + } return db.notesWhere(ctx, ` WHERE deleted_at IS NULL AND id IN (SELECT note_id FROM note_links WHERE kind = ? AND target_id = ?) diff --git a/internal/store/notes_test.go b/internal/store/notes_test.go index 510930e..8fb779a 100644 --- a/internal/store/notes_test.go +++ b/internal/store/notes_test.go @@ -29,7 +29,10 @@ func TestATagInANoteIsTheSameTagAsInATask(t *testing.T) { if len(projects) != 2 { t.Fatalf("read %v out of the note, want the two projects", n.Links) } - if projects[0] != "Firma" || projects[1] != "Regnskab" { + // Folded: the key is a key, not a label. #firma and #Firma are the same project + // to everybody except a database, and the project's own spelling is on the + // project, which is what the interface shows. + if projects[0] != "firma" || projects[1] != "regnskab" { t.Errorf("got %v", projects) } } @@ -173,7 +176,9 @@ func TestWhatIsReadOutOfABody(t *testing.T) { {"a hash inside a word is not a tag", "C#Sharp og x#y", nil}, {"a note by title", "se [[Møde med Anders]]", []NoteLink{{"note", "Møde med Anders"}}}, {"the same tag twice is one link", "#Firma og #Firma igen", - []NoteLink{{"project", "Firma"}}}, + []NoteLink{{"project", "firma"}}}, + {"two spellings are one tag", "#Firma og #firma", + []NoteLink{{"project", "firma"}}}, {"a heading is not a tag", "# Overskrift", nil}, } { t.Run(c.name, func(t *testing.T) { @@ -289,3 +294,42 @@ func TestTheTitleFollowsTheFirstLine(t *testing.T) { t.Errorf("a title set by hand survived as %q", n.Title) } } + +// A note that names a project in the other case must still turn up on it. +// +// It did not, and nothing said why: the key was stored exactly as typed and +// looked up the same way, so #garageristeriet and #GarageRisteriet were two +// different things that looked identical to a person. +func TestATagFindsItsProjectWhateverTheCase(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + + if err := db.SaveNote(ctx, &Note{ + CreatedBy: userID, + Body: "Bønnerne kommer fredag #garageristeriet", + }); err != nil { + t.Fatal(err) + } + + for _, spelling := range []string{"GarageRisteriet", "garageristeriet", "GARAGERISTERIET"} { + found, err := db.NotesLinking(ctx, "project", spelling) + if err != nil { + t.Fatal(err) + } + if len(found) != 1 { + t.Errorf("looking up %q found %d notes, want 1", spelling, len(found)) + } + } + + // A note title is a title, not a key: two notes called "Møde" and "møde" are + // two notes, and folding them together would merge things somebody kept apart. + if err := db.SaveNote(ctx, &Note{CreatedBy: userID, Body: "se [[Møde]]"}); err != nil { + t.Fatal(err) + } + if found, _ := db.NotesLinking(ctx, "note", "møde"); len(found) != 0 { + t.Error("a note title was folded") + } + if found, _ := db.NotesLinking(ctx, "note", "Møde"); len(found) != 1 { + t.Error("a note title did not match itself") + } +} diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 22893a9..97cac17 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -2177,3 +2177,37 @@ test('# foreslår et projekt, og titlen følger den første linje', async ({ pag expect(trouble).toEqual([]); }); + +test('et tag finder sit projekt uanset store og små bogstaver', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/'); + + const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); + await sidebar.getByRole('button', { name: 'Nyt projekt' }).click(); + await sidebar.getByLabel('Projektnavn').fill('StoreBogstaver'); + await sidebar.getByLabel('Projektnavn').press('Enter'); + await expect(sidebar.getByRole('link', { name: 'StoreBogstaver' })).toBeVisible(); + + await sidebar.getByRole('link', { name: 'Noter', exact: true }).click(); + await page.getByRole('button', { name: 'Ny note' }).click(); + const ed = page.getByRole('textbox', { name: 'Notens tekst' }); + await ed.click(); + + // Written in lower case, the way somebody types when they are not thinking + // about it. It used to mean the note simply never appeared on the project, with + // nothing anywhere to say why. + await page.keyboard.type('Aftale om levering'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Handler om #storebogstaver'); + await page.keyboard.press('Escape'); + await ed.blur(); + + // The panel shows the project's own spelling, not the folded key: a label + // nobody wrote and that is nowhere in the note would be worse than none. + await expect(page.locator('.link', { hasText: '#StoreBogstaver' })).toBeVisible(); + + await sidebar.getByRole('link', { name: 'StoreBogstaver' }).click(); + await expect(page.locator('.notes').getByRole('link', { name: /Aftale om levering/ })).toBeVisible(); + + expect(trouble).toEqual([]); +}); diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte index fc1ee7a..15309ec 100644 --- a/web/src/routes/noter/+page.svelte +++ b/web/src/routes/noter/+page.svelte @@ -160,6 +160,22 @@ } } + /** + * What a link is called on screen. + * + * A project tag is stored folded, because it is a key — but showing the folded + * key would mean a note that says #garageristeriet is labelled with a spelling + * that appears nowhere, neither in the note nor on the project. The project's + * own name is the right answer, and the fold is what makes it findable. + */ + function label(link) { + if (link.kind !== 'project') return link.target_id; + const project = app.projects.find( + (p) => p.name.toLowerCase() === link.target_id.toLowerCase() + ); + return '#' + (project?.name ?? link.target_id); + } + // What the note points at, for the panel under the editor. let links = $derived(selected?.links ?? []); @@ -229,7 +245,7 @@ {#if links.length} {#each links as link} - {link.kind === 'project' ? '#' : ''}{link.target_id} + {label(link)} {/each} {/if}