0087: Note tags and links, pictures as files
On this page
Status: accepted
Context
Notes started as plain Markdown in Short Leash's database with a title, a site and who wrote them (0029). Making them a place to keep what you learn needs tags, links between notes, pasted screenshots, a real editor and a search that finds words, not substrings. Chat, leash notes and coding agents on leash mcp read and write the same notes as plain text, so whatever notes gain has to survive being written by something that only knows Markdown. And the app's pages can't load anything from outside: the content security policy allows images only from the app, data: and blob:, and a caught mail's preview runs in its own origin that must not reach Short Leash's data.
Decision
- Tags and links are read from the body. A
#tag(a letter, then letters, digits,_,-or/, up to 64 characters, at most 100 per note) and a[[Title]]or[[Title|label]]link are found by one scanner innotes::markup, which skips fenced code blocks and inline code. There's no tags field: a tag written by chat, the CLI or an agent is a tag the moment the note is saved, and removing the word removes the tag. Saving a note writes what it found tonote_tagsandnote_linksin the same transaction, so lists, counts and backlinks are queries. The app's preview highlights from the same rules: both sides are tested againsttests/fixtures/notes/markup.json. Indented code blocks aren't treated as code, a known gap. - Links match by title key: the title trimmed, runs of white space made one, lower-cased. A link opens the most recently changed note with that title; "Linked from" lists the notes that link to this one's title. Renaming a note rewrites
[[Old]]to[[New]]in other notes in the same transaction, only when no other note keeps the old title, never inside code, without changing when those notes last changed, and skipping a note that would grow past the 200,000-character limit. The answer says how many notes changed. Links by id would survive renames without rewriting, but would make[[…]]unreadable in every other place notes are read. - Pasted pictures are files named by their content: one folder,
db/note-images/<sha256>.<ext>(0700 folder, 0600 files, written atomically), with a row each innote_imagesand which notes show them innote_image_refs. A folder per note would copy a picture pasted into two notes and make moving text between notes break pictures. PNG, JPEG, GIF and WebP only, told apart by their first bytes, never by name; no SVG, which can carry script. Up to 10 MiB (10 MB;defaults::NOTE_IMAGE_MAX), kept as they came, never re-encoded, so their metadata stays as pasted. - A picture no note shows is removed 7 days later (
defaults::NOTE_IMAGE_UNUSED_KEPT) by the database's upkeep every ten minutes. An upload starts unused, so a picture pasted and deleted again, or pasted into a note never saved, is cleaned up too. The week leaves time to paste a picture cut from one note into another, or to bring back text undone by mistake. At start-up, files without a row and rows without a file are cleared. When a damaged database is moved aside, its pictures are linked into<moved>-note-images/beside it. - Pictures travel as base64 JSON through Short Leash's API.
POST /v1/notes/imagestakes{data}and answers with the name and the Markdown to insert;GET /v1/notes/images/{name}answers{name, mime, data}. The app turns each into ablob:URL once per session. Tauri's asset protocol or a custom URL scheme would serve files to any page in the app, including a caught mail's preview origin, which could then load a note's pictures by guessing or reading their names. Base64 costs a third more bytes on a loopback socket, which doesn't matter at 10 MiB. - The Markdown names a picture as
leash-image:<sha256>.<ext>. It's not a URL anything resolves: the notes preview looks the name up through the API and shows a picture only by that name. Any other image address, remote or local, isn't loaded, so a note pasted from elsewhere can't make Short Leash fetch from the network or a file path. Tools see pictures by name only (note_readlists them); the pictures themselves never leave the computer. - Full-text search with FTS5 and its own copy of the text.
notes_ftsusesunicode61 remove_diacritics 2, kept in step by triggers: every word must match as the start of a word, accents are ignored, and titles rank above text (bm25weights 10 and 1).#tagwords in a search filter by tag. When the index finds nothing, a substring match runs instead. An external-content table would save the copy, but it is tied to the notes table's rowids, andVACUUM INTO(the daily copy) may renumber them, silently pointing the index at the wrong notes after a restore. - Migration 0012 adds
pinned,title_key, the four tables and the index, and fills tags, links, title keys and picture references for notes already saved through a Rust hook (store::notes::reindex_all) in the same transaction, the migration list's second such hook after the one-time import. - The editor writes Markdown source, with a preview. CodeMirror's Markdown mode with Edit, Split and Preview, a toolbar and shortcuts that insert Markdown,
[[and#completion, and list continuation. A WYSIWYG editor would store its own document model, or convert to Markdown and back on every save, and chat, the CLI and agents would see a different note from the one written. Source stays exactly what everyone reads. - Notes' preview is its own Markdown variant. It adds real headings with ids for the outline, GitHub's tables, task lists and strikethrough, colored code,
#tagsand[[links]]as in-app actions, and pictures only from the note store. Chat's variant and its allowlist (0062) are unchanged, so nothing a model writes can show a picture.
Consequences
- Notes stay plain Markdown everywhere: what chat,
leash notes showandnote_readreturn is what the editor shows. - A tag can't be added without writing it into the text; Add a tag beside a note's tags does that for you, at the end of the note.
- Two notes with the same title share their incoming links; the newest one opens. Renaming one of them leaves links alone.
- A note open with changes not yet saved while another note is renamed elsewhere (by chat, an agent or another window) can save its old
[[link]]text back over the rewritten one; that link then offers to create the note it names. This is rare and costs a click, so it's acceptable for now. - A picture's EXIF data, a phone photo's location included, is kept as pasted. It stays on this computer and goes nowhere, but a note copied out with its picture would carry it.
- The full-text index keeps a second copy of every note's text in the database.
- An editor for notes means CodeMirror's Markdown packages (
@codemirror/lang-markdown,@lezer/markdown, MIT) andremark-gfm(MIT) in the app.