fix(notes): søgningen svarede med det forkerte først, og et træk gjorde ingenting
Release / Build and publish image (push) Failing after 9m55s

**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 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-20 09:58:41 +02:00
co-authored by Claude Opus 5
parent 4aa8bd6a40
commit 3a02bc8304
5 changed files with 165 additions and 13 deletions
+36 -7
View File
@@ -35,6 +35,14 @@ type NoteLink struct {
const noteColumns = `id, project_id, title, body, created_by, pinned, const noteColumns = `id, project_id, title, body, created_by, pinned,
created_at, updated_at, deleted_at` 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 // 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 // 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 // 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 == "" { if expr == "" {
return nil, nil return nil, nil
} }
// By rowid, the way tasks do it: `id` is UNINDEXED in the table, so selecting // Ordered by how well it matched, not by when it was last touched.
// on it would work and read the whole index to do it. //
return db.notesWhere(ctx, ` // `ORDER BY updated_at DESC` was invisible while there were ten notes and
WHERE deleted_at IS NULL // useless at twelve hundred: every one of them was imported the same evening,
AND rowid IN (SELECT rowid FROM notes_fts WHERE notes_fts MATCH ?) // so `updated_at` says nothing, and the limit then cut the list at fifty
ORDER BY updated_at DESC // 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) 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) { 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 { if err != nil {
return nil, err return nil, err
} }
+36
View File
@@ -333,3 +333,39 @@ func TestATagFindsItsProjectWhateverTheCase(t *testing.T) {
t.Error("a note title did not match itself") 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)
}
}
+54
View File
@@ -2740,3 +2740,57 @@ test('en note kan oprettes inde fra et projekt og hører til det med det samme',
expect(trouble).toEqual([]); 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([]);
});
+14 -2
View File
@@ -363,6 +363,15 @@
overProject = project.id; 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) { async function onTaskDrop(event, project) {
event.preventDefault(); event.preventDefault();
overProject = null; overProject = null;
@@ -569,14 +578,17 @@
ondragstart={(e) => sortable && onDragStart(e, project)} ondragstart={(e) => sortable && onDragStart(e, project)}
ondragend={clearDrag} ondragend={clearDrag}
ondragover={(e) => { 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); else if (sortable) onDragOver(e, project);
}} }}
ondragleave={() => { ondragleave={() => {
overId = null; overId = null;
overProject = 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)}
> >
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span> <span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
{projectName(project)} {projectName(project)}
+25 -4
View File
@@ -53,7 +53,16 @@
// Favoritter står øverst uanset rækkefølge. Det er hele meningen med at gøre en // 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. // 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) => { [...notes].sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
if (order === 'title') { if (order === 'title') {
@@ -61,8 +70,7 @@
} }
const field = order === 'created' ? 'created_at' : 'updated_at'; const field = order === 'created' ? 'created_at' : 'updated_at';
return new Date(b[field]).getTime() - new Date(a[field]).getTime(); return new Date(b[field]).getTime() - new Date(a[field]).getTime();
}) });
);
let selected = $state(null); let selected = $state(null);
let query = $state(''); let query = $state('');
let status = $state('loading'); let status = $state('loading');
@@ -151,6 +159,14 @@
return at.toLocaleDateString(tag(), { day: '2-digit', month: '2-digit', year: 'numeric' }); 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) { function projectName(id) {
return app.projects.find((p) => p.id === id)?.name ?? ''; 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 linjer pr. note frem for tre, og datoen er dét, man skanner
efter, når man leder efter noget, man skrev i tirsdags. --> efter, når man leder efter noget, man skrev i tirsdags. -->
<span class="under"> <span class="under">
<span class="when">{when(note.updated_at)}</span> <!-- Datoen, der svarer til den rækkefølge, listen står i.
Den viste altid `updated_at`, og 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. -->
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
<span class="preview">{plain(note.body)}</span> <span class="preview">{plain(note.body)}</span>
</span> </span>
{#if note.project_id} {#if note.project_id}