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}