Release / Build and publish image (push) Failing after 9m55s
Chrome frøs på notesiden, og der var to grunde. **Nyttelasten.** Listen viser en titel og omkring halvfems tegn. Den fik hele noter for at gøre det: med tolv hundrede blev svaret 3,9 MB, hvoraf otteoghalvfems procent var brødtekst, ingen ville læse på den skærm — hentet, tolket og holdt i hukommelsen hver gang siden blev åbnet. Nu 763 KB. Kroppen tømmes frem for at blive forkortet, og det er den vigtige halvdel. Et felt, der hedder `body` og indeholder et *stykke* af kroppen, er en fælde: editoren ville åbne det, pausen ville gemme det, og noten ville være skåret ned til sit eget uddrag af ingenting andet end at være blevet kigget på. Tom er en form, klienten kan se efter — og den gør det og henter hele noten, når den åbner en. Det kostede en adskillelse mere: hvilken række der er *valgt*, og hvilken note der er *indlæst*, er nu to ting. Editoren indlæser sin tekst, når notens id skifter, så hvis den fik uddraget først og hele noten bagefter, ville id'et være det samme begge gange og den anden aldrig blive vist. Man ville sidde med en tom note, der lige havde haft en titel. **Tegningen.** Alle tolv hundrede rækker fik layout og maling, også de tusind ingen kan se: 2,1 sekunder til første tegning. `content-visibility: auto` lader browseren springe det over og tage det, når man ruller derhen — 0,95 sekunder, og uden en eneste linje JavaScript, ingen rullematematik og ingen liste, der kan komme ud af trit med sig selv. **Og søgefeltet.** Det var en pille med et forstørrelsesglas tegnet ind som et baggrundsbillede med farven skrevet i hånden — #8b918d, uanset tema — hvilket gjorde det til det ene sted på siden, der ikke fulgte resten. Nu samme form som søgefeltet i toppen, et rigtigt ikon i `currentColor`, en ryd-knap og et antal fundne. Det havde heller ingen pause: hver tast sendte en søgning gennem tolv hundrede noter, så "kaffe" var fem forespørgsler, hvoraf de fire var forældede inden de kom tilbage — og svarene kunne lande i den forkerte rækkefølge. Nu én. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
393 lines
14 KiB
Go
393 lines
14 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"`
|
|
|
|
// 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`
|
|
|
|
// 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
|
|
// 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.notesWhere(ctx, `
|
|
WHERE 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.
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 sql.NullInt64
|
|
|
|
if err := row.Scan(&n.ID, &projectID, &n.Title, &n.Body, &createdBy,
|
|
&n.Pinned, &created, &updated, &deleted); 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
|
|
}
|
|
return n, nil
|
|
}
|