Files
verdande/internal/store/notes.go
T
Kristian WindandClaude Opus 5 be581eb8ae
Release / Build and publish image (push) Failing after 9m57s
fix(search): et ord inde i et ord kunne ikke findes
Meldt ind som "søgning har ikke det store held": en note med
`gamerule keepInventory true` kunne ikke findes ved at skrive "keep inventory".

Et indeks er bygget af tokens, og et token er det, en tokenizer besluttede var et
ord. `keepInventory` er ét. Så "keep inventory" spørger efter to ord, der ikke
findes nogen steder, og søgningen svarer, at der ikke er noget — mens noten står
lige der.

Præfikssøgningen tager halvdelen af det. `keep*` rammer faktisk
`keepinventory`; `inventory*` kan ikke, for det er ikke et præfiks af noget. Og
de to led er bundet sammen med AND, så den halvdel, der virker, bliver kasseret af
den, der ikke kan.

Det er ikke et sært tilfælde i en notesapp. Ti års noter er fulde af kommandoer,
identifikatorer og filnavne, og hver eneste af dem er flere ord, indekset tror er
ét: `keepInventory`, `VACUUM INTO`, `apple-notes-til-markdown`.

Indekset svarer først. Har det ingenting, læses teksten: hvert ord skal stå et
sted i noten, hvor som helst, i hvilket som helst ord. `fold` er
`title || ' ' || body` med de danske bogstaver translittereret, så én søjle dækker
titlen, brødteksten og "gron" for "grøn" på én gang.

Opgavesøgningen har fået den samme arm. At have den i den ene af de to ville være
værre end at have den i ingen af dem: "den finder det i mine noter, men ikke i
mine opgaver" er den næste melding, og svaret på den ville være denne linje.

Hvad det koster, og hvornår formen er forkert, står skrevet ved koden: en LIKE med
et indledende % kan ikke bruge et indeks, så den læser hver note. Ved tolv hundrede
noter er det fire megabyte og et par millisekunder, og den kører kun, når den
hurtige vej allerede kom tomhændet tilbage — altså netop den søgning, der var ved
at skuffe nogen. Ved hundrede tusind er svaret et FTS5-`trigram`-indeks, ikke en
større scanning.

Prøverne er set fejle uden rettelsen, og de dækker begge veje: "inventory" alene,
som ingen præfikssøgning kan nå, og "Bedrock", som stadig skal besvares af
indekset — ellers er rangeringen, der blev bygget til tolv hundrede noter, stille
forsvundet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:33:22 +02:00

496 lines
18 KiB
Go

package store
import (
"context"
"database/sql"
"regexp"
"strings"
"time"
)
// Note is a page of Markdown belonging to a project, or to nobody.
type Note struct {
ID string `json:"id"`
ProjectID string `json:"project_id,omitempty"`
Title string `json:"title"`
Body string `json:"body"`
CreatedBy string `json:"created_by,omitempty"`
Pinned bool `json:"pinned"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
ArchivedAt *time.Time `json:"archived_at,omitempty"`
// Preview is the first of the body, for a list that shows one line of it.
//
// It exists so a list of twelve hundred notes does not have to carry twelve
// hundred whole notes: the list rendered 845 KB of JSON to show ninety
// characters of each. When it is set, Body is deliberately empty — see
// handleListNotes. That is not tidiness but a guard: an editor handed a
// truncated body would save the truncation, and the note would be cut down to
// its own preview.
Preview string `json:"preview,omitempty"`
// Links is what the body points at, read out of the text on the way in. Never
// set by a caller: it is derived, and a caller that set it would be describing
// a note whose text says something else.
Links []NoteLink `json:"links,omitempty"`
}
// NoteLink is one reference from a note to something else.
type NoteLink struct {
Kind string `json:"kind"` // "task", "note" or "project"
TargetID string `json:"target_id"`
}
const noteColumns = `id, project_id, title, body, created_by, pinned,
created_at, updated_at, deleted_at, archived_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,
notes.archived_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
// is the whole of "tag a note and see it in the project".
var (
projectRef = regexp.MustCompile(`(?:^|\s)#([\p{L}\p{N}_-]{1,64})`)
// [[a note]] by title, the spelling every note-taking program has settled on.
noteRef = regexp.MustCompile(`\[\[([^\]\n]{1,200})\]\]`)
// A task by id, which is what a link pasted out of the app carries.
taskRef = regexp.MustCompile(`(?:/opgave/|task:)([0-9a-fA-F-]{36})`)
)
// SaveNote writes a note and rebuilds what it points at.
//
// The links are torn down and rebuilt rather than diffed. A note's body is small,
// the set is small, and a diff would be a second description of the same fact —
// one that can disagree with the text, which is the one thing this must never do.
func (db *DB) SaveNote(ctx context.Context, n *Note) error {
if n.ID == "" {
n.ID = NewID()
}
now := time.Now()
if n.CreatedAt.IsZero() {
n.CreatedAt = now
}
n.UpdatedAt = now
// The title is always the first line, the way Apple Notes does it. Not "when it
// is empty": derived once and then left alone, it goes stale the moment somebody
// rewrites the opening of a note, and the list ends up calling a note by a name
// that is nowhere in it. The column is a cache of the body, not a field beside
// it, and every caller gets the same answer because none of them can set it.
n.Title = firstLine(n.Body)
return db.Tx(ctx, func(tx *sql.Tx) error {
var projectID any
if n.ProjectID != "" {
projectID = n.ProjectID
}
var createdBy any
if n.CreatedBy != "" {
createdBy = n.CreatedBy
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO notes (id, project_id, title, body, created_by, pinned, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
project_id = excluded.project_id,
title = excluded.title,
body = excluded.body,
pinned = excluded.pinned,
updated_at = excluded.updated_at`,
n.ID, projectID, n.Title, n.Body, createdBy, n.Pinned,
n.CreatedAt.Unix(), n.UpdatedAt.Unix()); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM note_links WHERE note_id = ?`, n.ID); err != nil {
return err
}
n.Links = LinksIn(n.Body)
for _, l := range n.Links {
if _, err := tx.ExecContext(ctx,
`INSERT OR IGNORE INTO note_links (note_id, kind, target_id) VALUES (?, ?, ?)`,
n.ID, l.Kind, l.TargetID); err != nil {
return err
}
}
return nil
})
}
// LinksIn reads the references out of a body.
//
// Exported because it is worth testing on its own, and because the editor wants
// the same answer while somebody is typing rather than after they have saved.
func LinksIn(body string) []NoteLink {
seen := map[NoteLink]bool{}
var out []NoteLink
add := func(kind, target string) {
l := NoteLink{Kind: kind, TargetID: target}
if target == "" || seen[l] {
return
}
seen[l] = true
out = append(out, l)
}
for _, m := range taskRef.FindAllStringSubmatch(body, -1) {
add("task", strings.ToLower(m[1]))
}
for _, m := range projectRef.FindAllStringSubmatch(body, -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]))
}
return out
}
// firstLine is the title a note gets when it has not been given one. Markdown
// heading marks are stripped: "# Møde" is titled "Møde", not "# Møde".
func firstLine(body string) string {
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "#"))
if line != "" {
if len([]rune(line)) > 120 {
return string([]rune(line)[:120])
}
return line
}
}
return ""
}
// Note returns one, or nil when it is not there or is in the trash.
func (db *DB) Note(ctx context.Context, id string) (*Note, error) {
row := db.QueryRowContext(ctx,
`SELECT `+noteColumns+` FROM notes WHERE id = ? AND deleted_at IS NULL`, id)
n, err := scanNote(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
links, err := db.linksOf(ctx, n.ID)
if err != nil {
return nil, err
}
n.Links = links
return &n, nil
}
// NotesInProject lists a project's notes, pinned first and newest after.
func (db *DB) NotesInProject(ctx context.Context, projectID string) ([]Note, error) {
return db.notesWhere(ctx,
`WHERE project_id = ? AND deleted_at IS NULL ORDER BY pinned DESC, updated_at DESC`,
projectID)
}
// LooseNotes are the ones filed under no project — a person's own, the way a task
// in the inbox is. Nobody else can see them, so there is no role to ask about.
func (db *DB) LooseNotes(ctx context.Context, userID string) ([]Note, error) {
return db.notesWhere(ctx, `
WHERE project_id IS NULL AND created_by = ? AND deleted_at IS NULL
ORDER BY pinned DESC, updated_at DESC`, userID)
}
// AllNotes is everything a person can see: their own loose ones, and the ones in
// projects they are part of.
//
// This is what the Notes page lists. Filing a note in a project is how it gets
// shared, and a list that dropped it at that moment would make sharing look like
// deleting — which is exactly how it read before this existed.
func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
return db.allNotes(ctx, userID, false)
}
// ArchivedNotes is the same list, the other way round: what has been put away.
func (db *DB) ArchivedNotes(ctx context.Context, userID string) ([]Note, error) {
return db.allNotes(ctx, userID, true)
}
func (db *DB) allNotes(ctx context.Context, userID string, archived bool) ([]Note, error) {
// Archived notes are left out of the ordinary list rather than mixed in and
// greyed: the point of putting something away is not seeing it.
state := "notes.archived_at IS NULL"
if archived {
state = "notes.archived_at IS NOT NULL"
}
return db.notesJoin(ctx, noteColumnsQualified, `
WHERE `+state+` AND deleted_at IS NULL
AND (
(project_id IS NULL AND created_by = ?)
OR project_id IN (
SELECT id FROM projects WHERE owner_id = ? AND deleted_at IS NULL
UNION
SELECT project_id FROM project_members WHERE user_id = ?
)
)
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID)
}
// NotesLinking answers the backwards question: what points at this thing.
//
// 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 = ?)
ORDER BY updated_at DESC`, kind, targetID)
}
// SearchNotes matches title and body, in either spelling of a Danish word.
func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note, error) {
if strings.TrimSpace(query) == "" {
return nil, nil
}
if limit <= 0 {
limit = 50
}
expr := MatchExprOver(query, "title", "body")
if expr == "" {
return nil, nil
}
// 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.
found, err := 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)
if err != nil || len(found) > 0 {
return found, err
}
return db.searchNotesInside(ctx, query, limit)
}
// searchNotesInside finds a word that lives *inside* another word.
//
// An index is built of tokens, and a token is what a tokenizer decided was a
// word. `gamerule keepInventory true` is three of them, and the middle one is
// `keepinventory` — so "keep inventory" asks for two words that are nowhere, and
// gets nothing. Reported as "søgning har ikke det store held", which from where
// the person is sitting is exactly what it is: the note is right there, and the
// search says there is nothing.
//
// A prefix query gets half of it. `keep*` does match `keepinventory`; `inventory*`
// cannot, because it is not a prefix of anything — and the two are joined with AND,
// so the half that works is thrown away by the half that cannot.
//
// This is not an exotic case in a notes app. A decade of notes is full of
// commands, identifiers and file names, and every one of them is several words the
// index thinks is one: `keepInventory`, `VACUUM INTO`, `apple-notes-til-markdown`.
//
// So: the index answers first, and when it has nothing, the text is read. Every
// term has to appear somewhere in the note, anywhere, in any word.
//
// # What it costs, and when to stop doing it this way
//
// A LIKE with a leading % cannot use an index; this reads every note. At twelve
// hundred notes that is four megabytes and a millisecond or two, and it only runs
// when the fast path already came back empty — which is the search that was about
// to disappoint somebody. At a hundred thousand notes it would be the wrong shape,
// and the answer then is an FTS5 `trigram` index, not a bigger scan.
//
// It does not rank. bm25 needs a match to rank, and there is no match here — that
// is the whole situation. The rows are the most recently touched first, and the
// set is small, because the index already said there was nothing in it.
func (db *DB) searchNotesInside(ctx context.Context, query string, limit int) ([]Note, error) {
terms := SearchTerms(query)
if len(terms) == 0 {
return nil, nil
}
// `fold` is `title || ' ' || body` with the Danish letters transliterated, so
// one column covers the title, the body and "gron" for "grøn" at once. LIKE is
// case-insensitive for ASCII in SQLite, which is what makes `keepInventory`
// answer to "inventory".
//
// The needle is safe to interpolate into a pattern because `tokenize` splits on
// everything that is not a letter or a digit: a term cannot contain % or _, so
// there is no wildcard for somebody to smuggle in. It is still a bound
// parameter — that is about SQL, and this is about LIKE.
where := "WHERE deleted_at IS NULL"
args := make([]any, 0, len(terms)+1)
for _, term := range terms {
where += " AND fold LIKE ?"
args = append(args, "%"+FoldDanish(term)+"%")
}
args = append(args, limit)
return db.notesWhere(ctx, where+" ORDER BY updated_at DESC LIMIT ?", args...)
}
// SetNoteTimes writes the dates a note had somewhere else.
//
// Only the import calls this, and it exists because SaveNote is right to refuse:
// `updated_at` is when this program last wrote the note, and letting any caller
// set it would make the column mean whatever the last writer felt like. A note
// brought in from Apple Notes is the one case where the dates are facts from
// before this database existed — and without them a move of twelve hundred notes
// is a pile that was all written the same evening, which throws away the order
// they were written in. That order is half of what an archive is.
func (db *DB) SetNoteTimes(ctx context.Context, id string, created, updated time.Time) error {
if created.IsZero() && updated.IsZero() {
return nil
}
// COALESCE, so a note carrying only one of the two keeps what it already had
// for the other rather than being stamped with the zero time — which would sort
// it to the year one.
var c, u any
if !created.IsZero() {
c = created.Unix()
}
if !updated.IsZero() {
u = updated.Unix()
}
_, err := db.ExecContext(ctx, `
UPDATE notes
SET created_at = COALESCE(?, created_at),
updated_at = COALESCE(?, updated_at)
WHERE id = ?`, c, u, id)
return err
}
// SetNoteArchived puts a note away, or takes it back out.
//
// Separate from DeleteNote because they mean different things and the difference
// is the whole feature: the trash says "this was a mistake, and in thirty days it
// is gone", and the archive says "this is finished, and I want to keep it". A
// twelve-hundred-note import is mostly the second, and without somewhere to put
// them the list is the whole archive forever.
//
// `updated_at` is deliberately not touched. Archiving is not editing, and a list
// sorted by when things were last written should not be reordered by somebody
// tidying up.
func (db *DB) SetNoteArchived(ctx context.Context, id string, archived bool) error {
var at any
if archived {
at = time.Now().Unix()
}
_, err := db.ExecContext(ctx,
`UPDATE notes SET archived_at = ? WHERE id = ? AND deleted_at IS NULL`, at, id)
return err
}
// DeleteNote puts it in the trash. Nothing here removes a row.
func (db *DB) DeleteNote(ctx context.Context, id string) error {
_, err := db.ExecContext(ctx,
`UPDATE notes SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
time.Now().Unix(), time.Now().Unix(), id)
return err
}
// RestoreNote takes it back out.
func (db *DB) RestoreNote(ctx context.Context, id string) error {
_, err := db.ExecContext(ctx,
`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`,
time.Now().Unix(), id)
return err
}
func (db *DB) notesWhere(ctx context.Context, where string, args ...any) ([]Note, error) {
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
}
defer rows.Close()
var out []Note
for rows.Next() {
n, err := scanNote(rows)
if err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
func (db *DB) linksOf(ctx context.Context, noteID string) ([]NoteLink, error) {
rows, err := db.QueryContext(ctx,
`SELECT kind, target_id FROM note_links WHERE note_id = ? ORDER BY kind, target_id`, noteID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NoteLink
for rows.Next() {
var l NoteLink
if err := rows.Scan(&l.Kind, &l.TargetID); err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
func scanNote(row scanner) (Note, error) {
var n Note
var projectID, createdBy sql.NullString
var created, updated int64
var deleted, archived sql.NullInt64
if err := row.Scan(&n.ID, &projectID, &n.Title, &n.Body, &createdBy,
&n.Pinned, &created, &updated, &deleted, &archived); err != nil {
return n, err
}
n.ProjectID = projectID.String
n.CreatedBy = createdBy.String
n.CreatedAt = time.Unix(created, 0)
n.UpdatedAt = time.Unix(updated, 0)
if deleted.Valid {
t := time.Unix(deleted.Int64, 0)
n.DeletedAt = &t
}
if archived.Valid {
t := time.Unix(archived.Int64, 0)
n.ArchivedAt = &t
}
return n, nil
}