Release / Build and publish image (push) Failing after 9m55s
**Arkivet, fordi papirkurven er det forkerte redskab.** Den siger "det her var en fejl, og om tredive dage er det væk" — så alt, der er værd at beholde, kan ikke komme i den. Et arkiv på tolv hundrede importerede noter er præcis det tilfælde, hvor de fleste er værd at beholde og næsten ingen er værd at kigge på; uden et sted at lægge dem er listen hele arkivet for evigt, og at finde ugens note betyder at læse forbi et årti. En kolonne, ikke en mappe. Noterne afviste et andet hierarki i 0018, og arkivering er ikke arkivering i den forstand: det er én oplysning om, hvorvidt en note er aktuel, og den skal kunne besvares ved siden af `deleted_at`, som er den anden af samme slags. `updated_at` røres ikke — at rydde op er ikke at skrive. **Flere ad gangen.** ⌘-klik lægger til, skift-klik tager intervallet i den rækkefølge, listen faktisk står i — ikke notens egen, som er en anden, når der er sorteret eller søgt. Trækker man en note, der er markeret, følger hele markeringen med; trækker man en, der ikke er, er det den ene, for ellers ville et træk i en tilfældig række flytte halvtreds noter, man havde glemt var markeret. Ét kald frem for et pr. note: efter en import af tolv hundrede er det at vælge halvtreds det almindelige tilfælde. Hver note kontrolleres stadig for sig — et sæt er en bekvemmelighed for den, der kalder, og aldrig en vej til noget, de ikke kunne have rørt én ad gangen. En kant *og* en tone på det markerede: `--accent-sunken` er meget lys i lyst tema, og tre markerede rækker var svære at skelne fra tre, der ikke var — en dårlig ting at være i tvivl om lige inden man trykker Slet. **Kopiér-knappen spiste næsten den kode, den skulle kopiere.** Den ligger inde i `<pre>` for at kunne stå i hjørnet af blokken, og `colourCode` læste `pre.textContent` og malede resultatet tilbage — så et klik på "Kopiér" skrev "Kopieret" ind i koden, og næste gemning lagde det i filen. Nu ét sted, der siger hvad en kodeblok indeholder, brugt af både farvelægningen og oversættelsen til Markdown: var de to uenige, ville forskellen ende i noten. Prøven klikker, læser udklipsholderen og læser filen bagefter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
435 lines
15 KiB
Go
435 lines
15 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.
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|