From 3a02bc83045f7c4ed8b2f0cf99c5fb55d97ea494 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Thu, 20 Aug 2026 09:58:41 +0200 Subject: [PATCH] =?UTF-8?q?fix(notes):=20s=C3=B8gningen=20svarede=20med=20?= =?UTF-8?q?det=20forkerte=20f=C3=B8rst,=20og=20et=20tr=C3=A6k=20gjorde=20i?= =?UTF-8?q?ngenting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Søgningen sorterede efter dato.** Usynligt med ti noter og ubrugeligt med tolv hundrede: de blev alle importeret samme aften, så `updated_at` siger ingenting, og loftet skar listen af ved halvtreds vilkårlige rækker. At søge "Tesla" svarede med en note om nginx først og noten, der hedder "Tesla Model Y", bagefter — hvilket læses som en søgning, der ikke virker, fordi det fra brugerens plads er præcis dét. Nu bm25, med titlen vægtet ti gange kroppen: et ord i titlen er, hvad noten *handler om*, mens det samme ord én gang i en lang krop er en henvisning. `fold` vejer som kroppen, ellers ville et dansk match slå et præcist. Sammenføjet frem for `rowid IN (…)`, fordi rangeringen hører til matchet, og en underforespørgsel smider den væk. Fladen sorterer ikke om, når der er søgt — det ville kaste netop det væk, der gør en søgning i tolv hundrede noter brugbar. **Et træk af en note hen på et projekt gjorde ingenting.** `ondragover` tog imod både opgaver og noter, så rækken lyste op — og `ondrop` sendte kun opgaver videre, mens noten faldt ned i grenen, der omarrangerer projekter, hvor den ikke betyder noget. Den værst mulige form for i stykker: den så ud, som om den ville virke, helt indtil man slap. Det står i overleveringen som "virker i koden, men ikke fastholdt i prøver, fordi prøven blev upålidelig". Den er skrevet igen, og den ser på resultatet — ligger noten i projektet bagefter — frem for på om rækken blev fremhævet, hvilket var netop det, den gamle kunne se og som blev ved med at være sandt, mens funktionen var i stykker. **Og datoen i listen var altid `updated_at`.** For tolv hundrede importerede noter er det i aftes, så en note fra 2017 stod som "i går", og den dato, man sorterede efter, var ingen steder at se. Rækken viser nu den dato, listen er sorteret efter, og har begge i sit værktøjstip. Co-Authored-By: Claude Opus 5 --- internal/store/notes.go | 43 +++++++++++++++++---- internal/store/notes_test.go | 36 ++++++++++++++++++ web/e2e/smoke.spec.js | 54 +++++++++++++++++++++++++++ web/src/lib/components/Sidebar.svelte | 16 +++++++- web/src/routes/noter/+page.svelte | 29 ++++++++++++-- 5 files changed, 165 insertions(+), 13 deletions(-) diff --git a/internal/store/notes.go b/internal/store/notes.go index b27d7dd..9a3ec87 100644 --- a/internal/store/notes.go +++ b/internal/store/notes.go @@ -35,6 +35,14 @@ type NoteLink struct { const noteColumns = `id, project_id, title, body, created_by, pinned, created_at, updated_at, deleted_at` +// The same columns, said with the table in front of them. +// +// Needed only by the search, which joins `notes_fts` — and that table carries its +// own `id` and `project_id` (UNINDEXED, so the row can be found from a match). +// Unqualified, SQLite cannot tell which one is meant and refuses the query. +const noteColumnsQualified = `notes.id, notes.project_id, notes.title, notes.body, + notes.created_by, notes.pinned, notes.created_at, notes.updated_at, notes.deleted_at` + // Reference syntax, and deliberately the same characters the quick-add parser // already uses. A `#Firma` in a note means the same project as a `#Firma` typed // into the task box — not a second kind of tag that happens to look alike. That @@ -238,12 +246,27 @@ func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note, if expr == "" { return nil, nil } - // By rowid, the way tasks do it: `id` is UNINDEXED in the table, so selecting - // on it would work and read the whole index to do it. - return db.notesWhere(ctx, ` - WHERE deleted_at IS NULL - AND rowid IN (SELECT rowid FROM notes_fts WHERE notes_fts MATCH ?) - ORDER BY updated_at DESC + // Ordered by how well it matched, not by when it was last touched. + // + // `ORDER BY updated_at DESC` was invisible while there were ten notes and + // useless at twelve hundred: every one of them was imported the same evening, + // so `updated_at` says nothing, and the limit then cut the list at fifty + // arbitrary rows. Searching "Tesla" answered with a note about nginx first and + // the note actually called "Tesla Model Y" second — which reads as a search + // that does not work, because from where the person is sitting it is one. + // + // bm25 with the title weighted ten times the body. A word in the title is what + // the note is *about*; the same word once in a long body is a mention. `fold` + // carries the same text transliterated, so it must weigh the same as the body + // or a Danish match would outrank an exact one. + // + // Joined rather than `rowid IN (…)`: the rank belongs to the match, and a + // subquery throws it away. + return db.notesJoin(ctx, noteColumnsQualified, ` + JOIN notes_fts ON notes_fts.rowid = notes.rowid + WHERE notes.deleted_at IS NULL + AND notes_fts MATCH ? + ORDER BY bm25(notes_fts, 10.0, 1.0, 1.0) LIMIT ?`, expr, limit) } @@ -295,7 +318,13 @@ func (db *DB) RestoreNote(ctx context.Context, id string) error { } func (db *DB) notesWhere(ctx context.Context, where string, args ...any) ([]Note, error) { - rows, err := db.QueryContext(ctx, `SELECT `+noteColumns+` FROM notes `+where, args...) + return db.notesJoin(ctx, noteColumns, where, args...) +} + +// notesJoin is notesWhere with the column list spelled out, for the one caller +// that joins another table and must therefore say which `id` it means. +func (db *DB) notesJoin(ctx context.Context, columns, where string, args ...any) ([]Note, error) { + rows, err := db.QueryContext(ctx, `SELECT `+columns+` FROM notes `+where, args...) if err != nil { return nil, err } diff --git a/internal/store/notes_test.go b/internal/store/notes_test.go index 8fb779a..577d743 100644 --- a/internal/store/notes_test.go +++ b/internal/store/notes_test.go @@ -333,3 +333,39 @@ func TestATagFindsItsProjectWhateverTheCase(t *testing.T) { t.Error("a note title did not match itself") } } + +// En søgning skal svare med det, der passer bedst, øverst. +// +// Den sorterede efter `updated_at`, hvilket var usynligt med ti noter og +// ubrugeligt med tolv hundrede: de blev alle importeret samme aften, så datoen +// siger ingenting, og loftet skar så listen af ved halvtreds tilfældige rækker. +// At søge "Tesla" svarede med en note om nginx først og noten, der hedder "Tesla +// Model Y", bagefter — hvilket læses som en søgning, der ikke virker, fordi det +// fra brugerens plads er præcis dét. +func TestSearchAnswersWithTheBestMatchFirst(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + + // Skrevet i den rækkefølge, der ville give det forkerte svar: den, der kun + // nævner ordet i kroppen, gemmes sidst og har derfor den nyeste dato. + for _, body := range []string{ + "# Tesla Model Y\n\nBilen selv.\n", + "# Ladestandere\n\nNoter om Tesla og andre mærker.\n", + "# nginx proxy manager backup\n\nEn konfiguration. Tesla nævnes i forbifarten.\n", + } { + if err := db.SaveNote(ctx, &Note{CreatedBy: userID, Body: body}); err != nil { + t.Fatal(err) + } + } + + found, err := db.SearchNotes(ctx, "Tesla", 10) + if err != nil { + t.Fatal(err) + } + if len(found) != 3 { + t.Fatalf("fandt %d noter, ventede 3", len(found)) + } + if found[0].Title != "Tesla Model Y" { + t.Errorf("øverst stod %q; den note, der hedder det, man søgte efter, skal stå først", found[0].Title) + } +} diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 740801d..ea8ed92 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -2740,3 +2740,57 @@ test('en note kan oprettes inde fra et projekt og hører til det med det samme', expect(trouble).toEqual([]); }); + +test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page }) => { + const trouble = watchForTrouble(page); + + const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); + await page.goto('/'); + await sidebar.getByLabel('Nyt projekt').click(); + await sidebar.getByLabel('Projektnavn').fill('Trækmål'); + await sidebar.getByLabel('Projektnavn').press('Enter'); + await expect(sidebar.getByRole('link', { name: 'Trækmål' })).toBeVisible(); + + const id = await page.evaluate(async () => { + const r = await fetch('/api/v1/notes', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' }, + body: JSON.stringify({ body: '# Løs note\n\nSkal flyttes ved at trække.\n' }) + }); + return (await r.json()).id; + }); + + await page.goto('/noter?note=' + id); + await page.evaluate((noteId) => (window.__noteId = noteId), id); + const row = page.locator('.notes button.row').filter({ hasText: 'Løs note' }).first(); + await expect(row).toBeVisible(); + + // Selve gesten, med de rigtige hændelser og den nyttelast, appen sender. + // Rækken tog imod dragover og lod slippet falde ned i grenen, der omarrangerer + // projekter — så den lyste op og gjorde ingenting. Prøven skal derfor gå hele + // vejen til slippet og se på resultatet, ikke på at rækken blev fremhævet. + await page.evaluate(() => { + const dt = new DataTransfer(); + dt.setData('application/x-verdande-note', window.__noteId); + const target = [...document.querySelectorAll('nav a')].find((a) => + a.textContent.includes('Trækmål') + ); + for (const type of ['dragover', 'drop']) { + target.dispatchEvent(new DragEvent(type, { dataTransfer: dt, bubbles: true, cancelable: true })); + } + }); + await page.waitForTimeout(1000); + + // Det, der tæller: noten ligger i projektet bagefter. + const body = await page.evaluate(async (noteId) => { + const r = await fetch('/api/v1/notes/' + noteId, { + credentials: 'include', + headers: { 'Sec-Fetch-Site': 'same-origin' } + }); + return await r.json(); + }, id); + expect(body.project_id).toBeTruthy(); + + expect(trouble).toEqual([]); +}); diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index 1f27cf8..f6451f4 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -363,6 +363,15 @@ overProject = project.id; } + /** + * Et slip på et projekt, uanset hvad der bliver sluppet. + * + * Rækken kaldte den her for opgaver og lod noter falde ned i grenen, der + * omarrangerer projekter — hvor en note ikke betyder noget. Samtidig tog + * `onTaskDragOver` imod begge, så rækken lyste op, når en note blev trukket hen + * over den. Den værst mulige form for i stykker: den så ud, som om den ville + * virke, helt indtil man slap. + */ async function onTaskDrop(event, project) { event.preventDefault(); overProject = null; @@ -569,14 +578,17 @@ ondragstart={(e) => sortable && onDragStart(e, project)} ondragend={clearDrag} ondragover={(e) => { - if (carries(e, TASK)) onTaskDragOver(e, project); + if (carries(e, TASK) || carries(e, NOTE)) onTaskDragOver(e, project); else if (sortable) onDragOver(e, project); }} ondragleave={() => { overId = null; overProject = null; }} - ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))} + ondrop={(e) => + carries(e, TASK) || carries(e, NOTE) + ? onTaskDrop(e, project) + : sortable && onDrop(e, project)} > {projectName(project)} diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte index 67a12af..21e347d 100644 --- a/web/src/routes/noter/+page.svelte +++ b/web/src/routes/noter/+page.svelte @@ -53,7 +53,16 @@ // Favoritter står øverst uanset rækkefølge. Det er hele meningen med at gøre en // note til favorit, og en sortering, der blandede dem ind igen, ville tage den. - let ordered = $derived( + let ordered = $derived.by(() => { + // En søgning har allerede en rækkefølge, og den er bedre end nogen af de + // tre herunder: serveren har sorteret efter hvor godt hver note matchede. + // Sorterede vi den om efter dato, ville vi kaste netop det væk, der gør en + // søgning i tolv hundrede noter brugbar. + if (query.trim()) return notes; + return sortedByChoice(); + }); + + const sortedByChoice = () => [...notes].sort((a, b) => { if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; if (order === 'title') { @@ -61,8 +70,7 @@ } const field = order === 'created' ? 'created_at' : 'updated_at'; return new Date(b[field]).getTime() - new Date(a[field]).getTime(); - }) - ); + }); let selected = $state(null); let query = $state(''); let status = $state('loading'); @@ -151,6 +159,14 @@ return at.toLocaleDateString(tag(), { day: '2-digit', month: '2-digit', year: 'numeric' }); } + /** Hvilken dato rækken viser: den, der sorteres efter. */ + const shownDate = (note) => (order === 'created' ? note.created_at : note.updated_at); + + /** Begge datoer, til den, der holder musen stille. */ + const stamp = (note) => + `${t('notes.sortCreated')}: ${new Date(note.created_at).toLocaleString(tag())}\n` + + `${t('notes.sortUpdated')}: ${new Date(note.updated_at).toLocaleString(tag())}`; + function projectName(id) { return app.projects.find((p) => p.id === id)?.name ?? ''; } @@ -329,7 +345,12 @@ linjer pr. note frem for tre, og datoen er dét, man skanner efter, når man leder efter noget, man skrev i tirsdags. --> - {when(note.updated_at)} + + {when(shownDate(note))} {plain(note.body)} {#if note.project_id}