Files
verdande/NOTES-SPEC.md
T
Kristian WindandClaude Opus 5 e29fd04abd docs: noternes opbygning, skrevet til at blive løftet ud
Hvordan noter er bygget her: modellen, syntaksen, søgningens tre lag,
editorkontrakten, vedhæftninger, ind- og udførsel — og begrundelserne, som er den
del, der ikke kan læses ud af skemaet.

Den begynder med fjorten spørgsmål, der skal besvares før der skrives kode, og
siger det til den, der læser den, med rene ord: gæt ikke en standard. Grunden
står i filen — omtrent en tredjedel af designet her er arvet fra de omgivelser,
noterne bor i, og løftet ind i et program med andre omgivelser uden at afgøre
hvad der skal med, giver noget, der virker en uge og derefter slås med én.

Den siger også, hvad der ikke virker: notekroppe i klartekst i hver
sikkerhedskopi, import der aldrig fletter, ingen sync. En specifikation, der kun
beskriver det, der virker, lyver ved fortielse.

I repoet frem for uden for det, som HANDOFF.md og NOTES-PLAN.md er. De to er
arbejdspapirer om *dette* program; den her er beskrivelsen af en del af det, og
den bliver kun forkert, hvis koden flytter sig uden at den følger med — hvilket
er lettere at opdage, når den ligger ved siden af.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 20:02:59 +02:00

438 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Notes, as verdande builds them
A description of one working notes system: the data model, the syntax, the search,
the rendering contract, the import and export formats, and — more useful than any
of it — the decisions that look arbitrary until you know what they cost.
It is written to be lifted into another program. Nothing here depends on Go,
SQLite or Svelte except where it says so; where an answer was forced by the tool,
that is called out so you can pick a different one on purpose rather than by
accident.
---
## Read this part first: what to ask before you build
**This document is not a drop-in. It describes a notes system that lives inside a
task app with projects, roles, a trash and a search box, and about a third of its
design is inherited from that.** Lifting it into a program with different
surroundings without deciding which parts to keep produces something that works
for a week and then fights you.
**If you are an assistant working from this file: ask these questions and get
answers before writing code.** Do not guess a default. Each one changes what you
build, not just how you build it.
### About the surroundings
1. **Does the host app already have a place to file things** — projects, folders,
notebooks, tags? verdande deliberately gave notes no hierarchy of their own and
filed them under the projects that already existed. If your app has nothing,
you need an answer for where a note lives, and "a folder tree" is the expensive
one.
2. **Is there more than one person?** Sharing, roles and "who wrote this" run
through the whole model below. Single-user changes the schema and removes about
half the permission surface.
3. **Is there already a trash, and does it expire?** Notes lean on one. Without
it, deleting is either permanent or you are building a trash.
4. **Is there an existing full-text index?** The search here is FTS5-shaped. On
Postgres it is `tsvector`, on a client-side app it is probably a scan, and the
ranking section below changes accordingly.
### About the notes themselves
5. **Markdown, rich text, or blocks?** verdande stores Markdown and renders it.
That decision is defended below; it is also the one that most constrains
everything else.
6. **Is the first line the title, or is there a title field?** Both work. Deriving
it means one less thing to keep in step and one less thing a person can leave
blank, and it forces the rule "the first line is a heading" on the editor.
7. **What links to what?** `#project`, `[[note]]` and task references exist here
because the app has projects and tasks. Yours may have none of them, or others.
8. **Do notes carry files?** Attachments bring content-type handling, a store, an
authorisation path and an XSS surface. Skipping them is a legitimate answer.
### About scale and language
9. **How many notes, realistically?** Several choices below flip at around ten
thousand: the list payload, the search fallback, rendering the whole list.
10. **Which languages?** The folding described under *Search* is Danish. Every
language has its own version of that problem and the generic answer (strip
diacritics) is wrong for several of them.
11. **Where does the text end up besides the screen?** Search indexes, AI
features, exports and backups each read it. Plaintext makes all of them cheap
and makes a stolen backup expensive — see *What is still open*.
### About integration, specifically
12. **Who owns the note's identity — you or the other system?** If notes will sync
with something else, ids and timestamps are the whole problem, and the
`updated_at` rule below becomes the first thing you have to change.
13. **Is this a read of somebody else's notes, or the store of record?** A copy
needs conflict rules; a store of record needs export.
14. **What happens to a note whose project, task or link target is deleted?**
Answered here as "the link goes dead and the note is untouched". If your host
app cascades instead, say so before the first delete.
---
## The shape of a note
```
id an opaque string, unique
project_id nullable — where it is filed, or loose
title derived, never set by a caller
body Markdown, the whole note
created_by nullable — survives the account being deleted
pinned favourite; the list puts these first
archived_at nullable timestamp — put away, not thrown away
deleted_at nullable timestamp — the trash
created_at
updated_at
```
### Markdown in a column
Not rich text, and not a format of our own.
Export was a requirement before a line was written, and **if the stored form is
the exported form there is no conversion left to lose anything in.** A note stays
readable by any editor on the day the program is gone. It is also what makes
search and anything an AI does over notes cheap: both read text.
The cost is real and you should know it before choosing: everything the editor can
do has to survive a round trip through Markdown, and anything Markdown cannot say,
the editor must not offer. There is no place to hide a feature.
### The title is the first line
Always, and re-derived on every save — not "when it is empty".
Derived once and then left alone, a title 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 no
caller can set it, so every caller gets the same answer.
This forces one editor rule: a new note opens on its title, and the first line is
styled as a heading. Otherwise a note whose first line is a sentence is a note
named by its first sentence.
### Archive and trash are two different bits
The trash says *this was a mistake, and in thirty days it is gone*. The archive
says *this is finished, and I do not want to read past it.*
A twelve-hundred-note import is exactly the case where most notes are worth
keeping and almost none are worth looking at. Without an archive, the list is the
whole archive forever, and finding this week's note means reading past a decade.
Both are nullable timestamps rather than booleans: *when was this put away* answers
questions a flag cannot, and it costs the same byte.
---
## Filing: no hierarchy of its own
A note belongs to a project, or to nobody.
The reasoning is worth repeating because it is easy to talk yourself out of:
projects already exist, are already shared, already have roles, already have a
trash. **A second hierarchy to file things in is one too many**, and a note filed
under a project inherits every one of those answers instead of needing its own.
- A note with a project: everybody who can read the project can read the note; an
editor can change it.
- A note without one: the author's, and nobody else's.
Sharing a note *is* filing it in a project. There is no access list of its own —
a second way to give somebody access is a second place to get it wrong, and the
two would disagree the day one of them changed.
---
## Links: three kinds, parsed out of the text
The body is the truth. Links are an index over it, rebuilt on every save.
| Syntax | Means | Notes |
|---|---|---|
| `#project` | a project | Same characters the task quick-add parser uses, on purpose |
| `[[a note]]` | another note, by title | The spelling every note app has settled on |
| `/opgave/<uuid>` or `task:<uuid>` | a task | What a link pasted out of the app carries |
Patterns as used here:
```
project (?:^|\s)#([\p{L}\p{N}_-]{1,64})
note \[\[([^\]\n]{1,200})\]\]
task (?:/opgave/|task:)([0-9a-fA-F-]{36})
```
Three decisions inside that table:
- **`#Firma` in a note means the same project as `#Firma` in 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".
- **The link table is torn down and rebuilt on save, not diffed.** A body is small
and the set is small; a diff is a second description of the same fact, and it
can disagree with the text. That is the one thing this must never do.
- **The target is not a foreign key.** A link to something deleted is a fact about
the note. It should read as a dead link rather than vanish, and it must not
block the delete.
The index exists so the question can be asked **backwards**: *what has been written
about this task?* Answering that by searching every note's prose would be too slow
to leave a panel open on.
---
## Search
Three layers, in the order they answer.
### 1. Full-text, ranked
FTS5 over `title`, `body` and a folded copy, with `unicode61 remove_diacritics 2`.
Ranking is 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.
This matters more than it sounds. Ordering by "last touched" is invisible with ten
notes and useless with twelve hundred imported the same evening: 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.
### 2. Folding, for the language you are in
`remove_diacritics` does not touch `ø`, `æ` or `å` — Unicode considers them
letters in their own right, not accented forms. So a Dane cannot find "grøn" by
typing "gron", which is how people type when the keyboard is not theirs.
The answer here is a generated column carrying a transliterated copy:
```
ø,Ø → o æ,Æ → ae å,Å → aa
```
indexed alongside the body and **weighted the same as the body** — otherwise a
folded match outranks an exact one.
> **Ask, do not copy.** This mapping is Danish. German wants `ü → ue`, Swedish
> disagrees with Danish about `ä`, and Turkish has a dotless `ı` that breaks
> naive lowercasing entirely. Get the mapping from somebody who types the
> language.
### 3. A word inside a word
An index is built of tokens, and a token is whatever the tokenizer decided was a
word. `gamerule keepInventory true` is three of them, and the middle one is
`keepinventory` — so a search for "keep inventory" asks for two words that are
nowhere and gets nothing, while the note sits right there.
A prefix query gets half of it: `keep*` matches, `inventory*` cannot, and the two
are ANDed, so the half that works is thrown away by the half that cannot.
**This is not exotic 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.
So: the index answers first; when it has nothing, the text is read, and every term
must appear somewhere in the note, in any word. A `LIKE '%term%'` over the folded
column covers title, body and language folding at once.
What it costs: a leading `%` cannot use an index, so it scans. At twelve hundred
notes that is a few megabytes and a millisecond or two, and it only runs when the
fast path already came back empty — which is exactly the search that was about to
disappoint somebody. **At a hundred thousand notes it is the wrong shape**, and the
answer then is a trigram index, not a bigger scan.
It does not rank. There is no match to rank — that is the whole situation.
---
## The list, and why the body is empty in it
A list showing a title and ninety characters of preview must not fetch whole
notes. Twelve hundred notes came to 3.9 MB, of which 98 % was body nobody could
see.
The important half is *how* it is trimmed: **the body is emptied, not shortened.**
A field called `body` holding a *piece* of the body is a trap. The editor opens
it, the autosave pause writes it back, and the note is now cut down to its own
excerpt — destroyed by nothing more than having been looked at. Send `preview` as
its own field and leave `body` empty; the client sees the empty body and fetches
the whole note.
That costs one more separation, and it is not optional:
> **Which row is selected and which note is loaded are two different things.**
The editor loads its text when the note's id changes. Hand it the excerpt first
and the whole note second and the id is the same both times — so the second one
never renders, and somebody sits looking at a note that had a title a moment ago.
Highlight the row immediately from the list; give the editor the note only when it
is whole.
Rendering cost, separately: with twelve hundred rows, first paint took 2.1 s. CSS
`content-visibility: auto` with an intrinsic-size guess took it to 0.95 s with no
virtual list, no scroll maths, and no list that can fall out of step with itself.
Try that before building windowing.
---
## The editor contract
Rich text on top, Markdown underneath, and a source view that shows the file.
The supported subset — deliberately small, because everything in it has to survive
the round trip:
| Block | Markdown |
|---|---|
| Title | `# ` |
| Heading | `## ` |
| Subheading | `### ` |
| Quote | `> ` |
| Code block | ```` ``` ```` with an optional language |
| Bullet and numbered lists | `- `, `1. `, nested |
Inline: `**bold**`, `*italic*`, `~~strikethrough~~`, `` `code` ``, `<u>underline</u>`,
and images as `![alt](url)`.
Five rules that were each learned the hard way:
1. **Every line becomes exactly one block.** Markdown normally joins consecutive
lines into a paragraph. Here it must not: the person typed a line and expects
to get it back, and an editor that silently reflows what somebody wrote is an
editor they stop trusting.
2. **Load on id change, never while typing.** Writing to the editor's HTML puts
the caret back at the start, which mid-sentence is unusable.
3. **One definition of what a code block contains.** A copy button inside the
`<pre>` and a syntax highlighter reading `textContent` will disagree, and the
difference ends up in the file — a click on "Copy" wrote the word *Copied* into
the code and the next save put it on disk. Have one function that says what a
block holds, used by the highlighter and the Markdown serialiser both.
4. **A note that cannot be rendered must not take the pane with it.** One note
arriving in an unexpected shape threw inside the editor's reactive effect, and
from then on *no* note would open — the previous one just stayed on screen. Put
a guard around the render, fall back to showing the raw text, and let the rest
of the pane keep working.
5. **Escaping between tags and escaping inside an attribute are different jobs.**
An escaper written for text between two tags handles `&`, `<`, `>` — and not
quotes, because between tags they are harmless. Inside an attribute they close
it. `![" onerror="alert(1)](…)` is the whole exploit.
---
## Attachments
Images pasted or dragged into a note, stored content-addressed by hash, referenced
from the body as ordinary Markdown images.
The security shape, which is the part worth copying:
- **Served as `application/octet-stream` with `nosniff` by default.** Attachments
are the one place a user supplies content another user opens. An uploaded SVG or
HTML file rendered inline runs its own script on your origin with the session
cookie attached.
- **One exception, as a named allowlist:** a short list of raster image types
served inline as their exact type — `png`, `jpeg`, `gif`, `webp`, `avif`, `heic`.
A note carries its pictures in the text, and served as octet-stream every one of
them is a broken image.
- **The list is named one type at a time, never matched on an `image/` prefix.**
The prefix lets `image/svg+xml` through, and an SVG is a document that can run
script.
- **The stored path is checked against the files directory again at read time.** A
traversal that somehow got into the database must not become one that reads
`/etc/passwd`.
- **Reaching an attachment follows the note, which follows its project.** Same
chain as everything else. Do not let a client hand you the parent id and take it
on trust.
---
## Import and export
The same format both ways: **a zip of Markdown files**, one per note, with a YAML
front matter block.
```markdown
---
created: 2019-04-02T10:15:00Z
modified: 2024-11-30T21:04:00Z
---
# Møde med Anders
Han vil gerne have kaffe hver uge.
```
- `created` / `created_at` / `date` and `modified` / `updated` / `updated_at` are
all accepted on the way in. Obsidian and Bear write this block; so should you.
- Images travel in the zip beside the notes, and the links in the text are
relative — **a note and its pictures must end up in the same archive**, which
matters the moment an export is split into parts.
- File names collide: two notes called "Møde" are two notes, and a zip with one
entry twice is a zip that loses one.
Limits that a real import taught, all of them cheap to add and expensive to omit:
- A ceiling on the upload, on the number of entries, and on the **unpacked** size.
64 MB of zip expanded to 66 GB is a normal shape for an attack.
- A per-file limit as well as a whole-archive budget: one enormous photograph and
ten thousand small ones are different attacks with the same ending.
- The entry name is never used as a path. Store by hash; the name is only a key
for rewriting links.
- **Count what you skip, and say so.** A note whose name contained an ellipsis was
dropped silently by a filter looking for `..` — one out of 1141, found only by
counting at both ends.
### Timestamps on the way in
An importer needs to write `created_at` and `updated_at` from the file, and the
normal save path must refuse to let it.
`updated_at` means *when did this program last write this note*. Let any caller set
it and the column means whatever the last writer felt like. Give the importer its
own narrow entry point instead, and say in the comment why it exists.
Without those dates, a move of twelve hundred notes is a pile that was all written
the same evening — and the order they were written in is half of what an archive
is.
---
## What is still open here
Written down because a specification that only lists what works is a specification
that lies by omission.
- **Note bodies are plaintext at rest.** They hold API keys and passwords, and
they are in every backup anybody can download. Encrypting them is bigger than it
sounds: the body is in the full-text index and the folded column is generated
from it, so both are a second plaintext copy in the same database. Encrypt and
both have to go, and search has to be rewritten to decrypt in application code —
fine at a thousand notes, a full scan per keystroke at ten thousand, at which
point the answer is a keyed index (an HMAC per word).
- **Import never merges.** Every run creates new notes. Say so out loud in the UI.
- **There is no sync.** Export and the API are the way out.
---
## A short list of the traps, for the person in a hurry
1. A `body` field holding an excerpt will be saved over the real one.
2. Selected row and loaded note are two different pieces of state.
3. The title must be re-derived on every save or it goes stale.
4. Diacritic stripping is not folding, and folding is language-specific.
5. A tokenizer cannot see a word inside a word; decide what you do about it.
6. An attachment served inline is a script on your origin unless you allowlist by
exact type and send `nosniff`.
7. Escaping for text and escaping for attributes are different functions.
8. One render failure must not be allowed to freeze the whole pane.
9. An unpacked-size limit is not optional on an archive import.
10. Let the importer set timestamps; let nobody else.