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
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:
co-authored by
Claude Opus 5
parent
4aa8bd6a40
commit
3a02bc8304
+36
-7
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
||||
{projectName(project)}
|
||||
|
||||
@@ -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. -->
|
||||
<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>
|
||||
{#if note.project_id}
|
||||
|
||||
Reference in New Issue
Block a user