A file node points at one media row and owns it the way a document owns
what it embeds, so folder deletes, the media browser and exports keep
working. POST /api/nodes/{id}/files streams uploads into a folder;
imports turn non-markdown files into file nodes; exports put them at
their tree path. The browser previews images, video, audio and text,
offers the rest as a download, and takes files dropped on the list.
Migration 0010 widens the kind CHECK by editing the stored schema, as
SQLite documents for loosening a constraint: sqlx runs migrations in a
transaction, where foreign keys cannot be switched off for a rebuild.
GET /api/agenda gathers unfinished cards from every board that are late,
due within a week, or in hand, plus whether the diary has today; the
reminder mail and the agenda share one diary check. t opens it (and it
opens itself once a day when something is late or due), T writes today's
entry, the bar counts late and due cards, Ctrl-P searches every space,
and a document o made that is left empty is removed on the way out.
Space sends a card to the done list or back to the inbox instead of
ticking it in place. Enter on a card with no document starts one named
after it, and q comes back to the board on the same card. :card captures
into the inbox without leaving the current view (c anywhere), :due takes
fri, tm, +3d and the like, :name! renames a list, done cards older than a
week fold away behind z, and the board spells out its keys. The phone dock
gains send, done, due and notes doors.
dd, x, i, R, s, y and :mv used the open document whenever one was open,
so walking the list and pressing dd deleted the document beside it. They
now act on the row under the cursor while the list has focus. Deletes ask
an in-app y/N naming the node instead of confirm(), and the board no
longer lets tree keys through to the hidden list.
- shared view uses the bar, porthole and status band; outline gets its own
window on wide screens; j/k, gg/G, Ctrl-d/u scroll with a vim-style ruler
- rail doors are line icons in groups with hover tooltips and keycaps
- phone: number-only space pills, no nested rims, icon dock, toast messages,
back button and one-line meta in the document header
- keycaps (.kbd) wherever keys are shown, styled scrollbars in Chrome,
outline tracks position on jumps, breakpoints unified at 767px
Add an in-process SFTP server (russh) backed directly by the nodes
table, so a space can be edited from nvim or synced with sshfs without
a separate filesystem mirror. Access is authenticated by public key
against a new ssh_keys table, managed via :sshkey in the app; the
feature stays off until WORKSPACE_SFTP_BIND is set.
With a document open and not being edited, j/k scroll the reading pane,
gg/G jump to its top and bottom, and Ctrl-D/Ctrl-U page it. h still backs
out to the list, where the same keys walk the rows again.
The import pickers offered a folder or a zip and nothing else, so a single
file could not be imported at all. The second picker now takes any files;
the server already handled them.
The diary grows into a workspace: a flat list of dated entries becomes a
tree of spaces, and the app is renamed to match what it now is.
Spaces and nodes
One recursive `nodes` table replaces `entries`: a space is a root node,
and folders and documents nest under it without limit. Entries keep
their ids through the migration, so share tokens, media links and the
drafts in localStorage all still resolve.
Folder import
A folder or a zip is uploaded as a document tree. Markdown becomes
documents titled from their first heading, everything else becomes
media, and relative links between the imported files are rewritten to
the paths they landed at, so a set of cross-linked notes stays
navigable after the upload.
Boards
A kanban board per space, with the space as the board: lists, cards,
due dates and an optional link to a document. `diary` has none.
Mail
SMTP notifications through an outbox. A mail is enqueued under a key
naming the occurrence it belongs to, so a restart cannot resend and a
relay that refuses is retried with a widening backoff. Four kinds: the
diary reminder, cards due and overdue, a weekly digest, and logins
from a browser that has not been seen before or a burst of failures.
All of it is dormant until WORKSPACE_SMTP_URL is set.
Rename
Everything user-visible says workspace; everything that already exists
outside this repo keeps its name, because renaming it would break a
running deployment for tidiness alone: the database file, the docker
volume, the session cookie, the Proton device, the keyring entry and
the localStorage keys. Configuration reads WORKSPACE_* first and falls
back to DIARY_*, so the deployed .env keeps working untouched.
The data directory is the whole diary, and until now keeping a copy of it
was someone's manual job. The server can now register itself in Proton
Drive as a device — its own sync root, next to the desktop clients — and
keep that folder equal to DIARY_DATA_DIR, encrypted client-side by the
same Rust SDK the Linux client uses.
One interactive `narl-diary proton-login`, then nothing prompts again.
The stored session carries the tokens, the mailbox password (the key
chain is rebuilt from it on every start) and the account key salts, which
have to be captured at login: `core/v4/keys/salts` needs the `locked`
scope, and only a password-login token has it. Refresh tokens are
single-use, so every rotation is written back before it can be needed.
It lives as a 0600 file next to the database, on the same volume as the
data it protects; `--features keyring` puts it in the OS keyring instead,
for hosts that have a Secret Service.
The mirror is one-way and change-driven: writes mark the diary dirty and
it is mirrored once the writing stops, with an interval as the backstop —
an hour of writing is one backup, not sixty. `diary.db` goes up as a
VACUUM INTO snapshot, so it is a real point in time rather than a file
copied out from under a writer, and as a new revision each time, so
Proton keeps the older ones. Uploads are immutable, so they are written
once and skipped by size afterwards. Deletions do not propagate unless
asked: a backup that forgets on command is one accident away from being
no backup at all.
Identification follows Proton's third-party rules — an honest
`external-drive-narl_diary@<version>-alpha`, the disclosure printed
before any credential is asked for, and remembered folder uids instead of
walking the tree every run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5Gk8FPkRXu1e6Nj2BCRE1
The login endpoint answered guesses as fast as they arrived, against a
single static password.
Wrong attempts now back off — 1s, 2s, 4s, up to 30s — and a correct one
clears the count. It is a single counter rather than a table keyed by
address: there is only one account, so an attacker gains nothing by
rotating source IPs, and there is no per-client map to grow. The cap is
there so that someone hammering the login delays the owner by half a
minute rather than locking them out.
Wrong credentials now answer 401 rather than 400. The server also warns
at startup if DIARY_PASSWORD is still the value from .env.example.
There was no way to get everything out — only one entry at a time, by
hand. A diary you cannot leave is not a diary you can trust.
The archive is entries/ as one markdown file per entry named by its day,
media/ with every embedded file under its real name, and a README. Media
links are rewritten to relative paths, so the entries render in any
markdown reader with no help from this application. Names are restricted
to characters that need no escaping inside a markdown link.
It is built on the blocking pool into a temporary file and streamed, so a
diary with photographs in it never has to fit in memory.
Only Ctrl-S and :w ever wrote. A closed laptop, a crashed tab or an
expired session took the draft with it, and clicking another entry in the
list silently discarded unsaved work without so much as a prompt.
Every change now goes to localStorage immediately and to the server two
and a half seconds after typing stops. Opening an entry that has a stash
newer than the server's copy restores it and says so, the way vim reports
a swap file; :e! and :q! discard it. Switching entries flushes the
outgoing draft first.
The save response carries the entry's excerpt, so a save patches its list
row in place instead of re-reading the whole list — which autosave would
otherwise have done every few seconds.
media.entry_id modelled ownership as one entry per file, but link_to_entry
reassigns it on every save. Embedding the same image in a second entry
therefore moved the claim, and deleting that second entry deleted the file
out from under the first — a broken image in the entry and a 404 on its
share link.
A join table replaces the column. Deleting an entry now removes only the
files no other entry embeds, and link_to_entry runs in a transaction so an
entry is never briefly attached to nothing while its share link is being
read.
Adds :today, :next/:prev, :first/:last, :random, :clear, :reload, :copy,
:export, :stats and :theme, plus bangs on :w, :wq and :e. Every command now
declares a group so :help can lay them out under headings.
The help sheet flowed its columns wider than the sheet and clipped the
descriptions; it is newspaper columns now, and it lists the ! variants.
CodeMirror's base theme styles panels for a light editor with selectors the
app stylesheet cannot outrank, so the vim `:` prompt rendered black on black.
Those rules move into the editor's own theme, which does win, and the theme
declares itself dark so the light defaults stop applying at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01476ZKdB4k3rPDwvVhXcnDg
CodeMirror injects its own `.cm-cursor` rule after our stylesheet, so the
equally specific override lost and the caret painted black on a dark
background. Qualify the selector, same as `.cm-activeLine`.
`:q!` reached the command table as the literal name `quit!`, which nothing
matched. Commands now carry an optional `bang` variant, and `runCommand`
splits a trailing `!` off any head before looking it up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01476ZKdB4k3rPDwvVhXcnDg
Mocha with the palette's red as the accent is now the default; the old
green scheme moves to `:set theme=green` alongside amber and ice.
Two readability fixes in the editor:
- The vim block cursor paints the character inside itself in that
character's own syntax colour, so an accent-coloured glyph — a
heading's `#`, a link — vanished against the accent-coloured block.
The glyph is now forced to the background colour.
- CodeMirror injects its own `.cm-activeLine` rule after this stylesheet,
so the equally specific override never applied and the current line
rendered as a bright blue box. Qualified the selector and toned the
band down to a faint tint of the foreground.
Insert mode also gets a 2px bar instead of the browser hairline caret.
Single-user diary served as one Rust binary with the Svelte frontend
embedded.
- axum 0.8 + SQLite (sqlx), migrations run at startup
- stateless HMAC session cookie; credentials from .env, compared in
constant time
- FTS5 full-text search, user input rewritten into quoted prefix terms
- media uploads linked to entries by scanning bodies for /api/media/<id>,
which scopes share-token media access and entry deletion
- unguessable share links (192-bit token) serving a public read-only page
- vim keybindings via CodeMirror plus a modal browse layer and : command
line; disabled on touch devices