Commit Graph
81 Commits
Author SHA1 Message Date
nvrl a27d5de9c7 phone app: installable, opens offline, takes the share sheet onto the scratchpad, back lets go of one thing
A manifest with a 192, a 512 and a maskable icon, and a hand-written service
worker built beside the app by a small vite plugin: it keeps index.html, the
logo, the entry with its imports and the latin fonts, keeps each hashed asset
once fetched, asks the server for pages first and answers from the cache when
it is down, and never touches /api. The CSP names worker-src and manifest-src.

The share target posts to /share, which the worker catches: the text waits in
a cache and becomes a scratchpad note once someone is logged in, with the
drawer down on it. On a touch screen one guard entry in the history makes back
let go of one thing at a time — a prompt, the line, a sheet, the drawer, the
editor, a tile, then a folder up — and leave only at a space's root. The bar
and the band grow by the safe-area insets. The login no longer says the server
is unreachable once it answers again with the session gone.
2026-10-22 20:10:13 +02:00
nvrl 9363a0024d navigation: the list by name outside the diary, Ctrl-P goes to anything by its name, new things land beside what they belong to
The list is folders first and then by name in every space but the diary,
which stays a journal by day; a document's row elsewhere shows when it
was last edited. Ctrl-P is a jump by name across every space, served by
GET /api/jump: the last word is looked for in the name, the ones before
along the path. / is the one full-text search, and Tab on its line takes
it to every space and back. A card's notes are made in the folder being
browsed when that is in the card's space, and a missing [[link]]'s
document beside the one linking to it. o in a space with templates opens
the line as :new with them listed.
2026-10-22 17:39:24 +02:00
nvrl c70bfdd7be docs: the guide around four things and two screens, new screenshots, the plan closed
The guide opens with documents, cards, notes and diary entries, and the
documents and plan screens, then walks the day with the scratchpad, the
info sheet and cards linked both ways. The README and the wiki's home say
the same in short, and show the scratchpad and the info sheet. The
screenshot script drops its Alt keys and takes both. PLAN.md records the
simplification's slices, what was left of its navigation fixes, and the
backlog fitted to the model.
2026-10-22 17:09:25 +02:00
nvrl 650fc40fa7 keys: Ctrl moves between tiles, Ctrl-Q jots from anywhere, the plan gets a screen of its own
Alt never reached the app, so the tile keys are Ctrl-h/j/k/l and
Ctrl-Shift-h/j/k/l, taken before vim sees them, and h/l walk the tiles
while reading. Ctrl-Q lowers the scratchpad from the editor straight
into the last note, and raises it back to the document, still writing.
The drawer sits in the porthole's opening with a full rim. The board,
the agenda, the calendar and a card's back live on a plan screen that
S goes to and comes back from, so documents and views never share the
screen. The calendar fits its tile instead of scrolling.
2026-10-22 16:51:50 +02:00
nvrl 7f8d625a8d commands: one grammar for !, merged commands, a settings sheet, two-level help
A `!` now means force or everything and nothing else (`:w!`, `:search!`,
`:export!`, `:logout!`). A command that did a second thing behind its bang
takes a word for it instead: `:space new|name|rm`, `:list name|rm`,
`:task [add]|edit`, `:upload seal`, `:import zip [/]`. `:card` while reading
a document makes a card about it, so `:card!` is gone.

Merged: `:export` downloads what has focus, a file as it is or a document as
markdown (`:download` folded in); `:share [off|new]` replaces `:unshare` and
`:link`, and the list's `s` and `y` give way to it and the info sheet;
`:noh` replaces `:clear`; `:vault [unlock|lock|passwd]` replaces `:unlock`,
`:lock` and `:passwd`; Esc in the list puts down what `m` picked up before it
closes the document, replacing `:unmark`.

The old spellings run as their new lines for one release through a table in
commands.ts; help and Tab leave them out, and the wiki lists them.

A settings sheet (`,` or `:settings`) holds the options, the vault, the
backup, the signed-in browsers, ssh keys and capture tokens. Each row runs
the command a line would. Bare `:set`, `:vault`, `:backup`, `:sessions`,
`:sshkey` and `:token` open it at their part.

`?` shows the keys and commands to start with, flagged in the keymap and the
command table; a second `?` (or `:help all`) shows all of them, and a filter
always searches all of them. Help rows are keyed by key and description, as
two contexts can share a label.
2026-10-22 15:35:36 +02:00
nvrl d8e72c688d files: a file belongs to a node, and a document holds its own
An upload while writing is now a file node of the document, through
POST /nodes/{id}/files, instead of a loose row of media that only the media
browser listed. It moves, seals, goes to the trash and comes back with the
document. Migration 0029 gives every embedded upload that had no node a
place under the document that embeds it (a living one before a trashed
one), keeping its media id, so no body changes; the sealed-folder triggers
now ask a document's folder for the files under it.

- nodes: `holds` and `place_for` say what may go where; a document holds
  files and nothing else, in moves, uploads and restores from the trash.
  A loaded document carries its files and whether its folder seals them.
- seal: a document moving into a sealed folder seals its files first.
- media: POST /media, GET /media and DELETE /media/{id} are gone, and with
  them MediaBrowser and :media.
- import: `plan/` beside `plan.md` comes back as the document's files, the
  shape export writes; an older export's media/ goes to the first document
  that embeds each file.
- sftp: a document's files are `<slug>/` beside its `.md`, listed once it
  has any, holding files only, moving with the `.md`.
- web: paste, drop and :upload while reading go to the document; the info
  sheet has a files section (Enter opens, i embeds, y copies how); the list
  shows +N on a document, za folds its files open, p drops marked files in.
2026-10-22 11:57:52 +02:00
nvrl 2233fa41a6 scratch: the scratchpad is the inbox, with ways out and a search
A note now leaves the scratchpad by becoming something else: `K` keeps it
as a document, as before; `C` makes it a card where `:card` would put one,
the first line its title; `A` opens `:append` with the document on screen
filled in, Tab completing any other title, and puts the note at its end.

`/api/capture` takes `to: "note"` and `to: "document"` (named as a link
names it), and `note`, the scratch note the text came from, dropped once
the text has landed. Appending keeps the revision before it and indexes
links and media, as a save does.

Migration 0028 adds `scratch_fts`; `GET /api/scratch?q=` searches it, and
`/` lists note hits under "notes" after the cards, Enter lowering the
drawer at the note.

The `c` capture line cycles its target with Tab: a card, a note, or a
timed line in today's entry, which `:today <text>` now writes.
2026-10-22 11:22:55 +02:00
nvrl b8b3641e6e cards: a document and its cards, seen from both ends
A document now lists the cards about it: the ones whose notes it is and
the ones whose description links to it. GET /nodes/{id}/cards answers
that, and the node itself carries it as `cards`. A line under the
document's meta line says what is still open and when it is first due;
it and `gc` open a new cards part of the info sheet, where j/k picks a
card, Space, H/L, D, a and `:` act on it, and Enter turns it over on its
board. `c` while reading a document makes a card about it.

Card verbs act on the card in focus wherever it is looked at: the
board's cursor, the card turned over, the agenda's highlight, the card
picked in the info sheet, or a document's only open card. patchCard,
sendCard and toggleDone take that card, so patchAgendaCard,
sendAgendaCard and finishCard are gone, and :due, :repeat, :label and
:done no longer refuse to run away from the board.

Migration 0027 adds card_links, links for a card's description: indexed
on every write, backfilled on start, followed and rewritten on a rename
or move like a document's, and claimed when a title they named appears.
It also adds cards_fts over title, description and checklist steps;
GET /cards?q= searches it, and / lists the cards found after the nodes,
Enter turning one over on its board.
2026-10-22 10:45:20 +02:00
nvrl 5684614d0a tiles: the board, the agenda, the calendar and a card's back are tiles
The special workspace was a second layout beside the tiles, with its own
focus, its own Tab, Esc and q, and a returnTo flag to find the way back.
The views are now tile kinds instead: opened beside what is on screen,
walked with Alt-h/j/k/l and gt/gT, closed with q.

- Tile gets a kind: node, board, agenda, calendar or card. view,
  specialFocus, returnTo and the special context are gone.
- A trail of focused tiles: closing one goes back to the one before, so
  q on a card's notes or a calendar day returns to the view.
- A view is never replaced by what is opened from it; that opens beside.
- S tiles the board, agenda and calendar ahead of the documents, or puts
  every view away. :plan, with :special kept as an alias.
- The card sheet is a card tile beside the board, following its cursor;
  leaving it while writing the description saves what is written.
- Spaces remember their views; with a view focused, a space switch takes
  the views along instead of leaving copies behind.
- Card commands check what has the focus rather than the special view.
2026-10-22 10:18:50 +02:00
nvrl 26b3c686d2 sheets: one Sheet component, and the info sheet over a document
Every overlay is drawn in Sheet.svelte: the header, the close button, the
footer of keys and the list cursor kept on screen, written once instead of
in ten copies. Overlays open and close through openSheet and closeSheet.

The outline, history and backlinks sheets and the share bar are one info
sheet with a part for each, Tab between them. gi opens it, gO, gh and gb open
it at their part, and so does a click on the line under the title. The
buttons in the document header are gone; the meta line says when the
document is public, and a phone gets edit and info under the text.
2026-10-22 04:07:42 +02:00
nvrl 7f8a3816d3 fixes: what the audit turned up
- POST /api/diary/day finds a day's entry, or starts it at the root of the
  diary, by the server's clock. T, the calendar and a capture all use it, so
  they can no longer write two entries for one day: before, T counted days by
  the browser's midnight and wrote into whichever diary folder was last open.
- A Ctrl-P result opens beside the results, as a / result does, so n and N
  go on from it rather than finding no search.
- A card's description resolves its [[links]] the way a document does, and
  following one leaves the card for what it leads to.
- gf on the board opens a card's notes. Enter was documented for that, but
  Enter turns the card over.
- The pane header and the status line show where the open node is, rather
  than where the list is, so a node from another space shows its own path.
2026-10-22 03:10:49 +02:00
nvrl ac3ad166e5 scratch: a drawer of notes taken in passing, kept as documents later
Q lowers the scratchpad from under the bar over whatever is on screen,
as the desktop lowers its own. Its notes have no name or place; each is
written in the vim editor and saves itself. K (:keep [title]) makes the
note under the cursor a document in the folder being browsed, titled by
its first line, and takes it off the pad. :scratch <text> jots one from
the command line. The notes ride along in backups and whole-workspace
exports.

The card description's editor is now TextEditor, shared by both.
2026-10-22 00:07:23 +02:00
nvrl 3c4c383f72 board: a card turns over to a checklist, labels and a description
Enter on a card floats a sheet over the board: its labels, a markdown
description written in the vim editor (:w writes it to the card), and a
checklist walked with j/k, ticked with Space, grown with o. Labels take
their colour from their name. A repeating card comes back with its
checklist reopened.

Code in the editor and in rendered fences is coloured from nvim's
yorha.lua, one class per highlight group, for every language
@codemirror/language-data knows; the languages load on first use.
2026-10-21 23:53:24 +02:00
nvrl 176aad8c6f special: keys go to a tile, and Esc only ever leaves
Inside the special workspace the agenda and the calendar could only be
reached with Alt-h/j/k/l, which a desktop that binds Alt to its own windows
never lets through, and the same keys meant two things: t on the agenda put
the whole workspace away, t elsewhere went to it. Three esc buttons, one per
tile, all closed everything.

Now b, t and C only ever go to their tile, from anywhere. Tab and Shift-Tab
walk the tiles, as they walk the spaces elsewhere; l past the board's last
list goes on into the agenda, h in the agenda back to the board, j past its
end into the calendar. S toggles the workspace, and q and Esc leave it from
whichever tile — after leaving fullscreen first. The tile buttons are gone;
each tile's head shows its key instead, lit on the one with focus, and the
bar has a special pill beside the spaces.

A document opened from the workspace — a day of the calendar, an entry of
the agenda, today's entry, a card's notes — comes back to the tile it was
opened from on q.

The board tile shows the board of the space being browsed, or else the last
one shown, so coming from the diary, which has none, no longer leaves it
empty. Card and list actions follow the board on screen, not the browsed
space.
2026-10-21 15:53:24 +02:00
nvrl 0245402ead typst: a block of it in markdown is typeset on the server
A ```typst (or ```typ) block is compiled by the typst crate into SVG and
shown as an image where it stands, so math, tables and figures can be
written in typst beside the markdown around them. It shows its source while
it is set, and the error typst gives, with the block's line, when it cannot
be.

Each block is a document of its own: its source is the only file in its
world and the fonts are typst-assets', inside the binary, so nothing is read
from disk or fetched — packages and images are refused. The page fits what
is on it, is transparent, and carries the theme's text colour. Answers are
cached by source, two blocks are laid out at a time off the async threads,
and comemo's memo is evicted after each compile.

A share page asks POST /api/share/{token}/typst, which takes the reader's
cookie and typesets only a block the shared document holds.

typst brings a palette crate whose AsRef impls on arrays made four
`kek.as_ref().into()` in the vault ambiguous; they deref instead.
2026-10-21 15:41:45 +02:00
nvrl 73ac961751 special: the board, the agenda and the calendar in a workspace of their own
The board swapped out the tree, and the agenda and the calendar were
overlays over it, each with a key to open and a key to close. They are now
three tiles of one special workspace, as the desktop keeps its scratchpad:
the board on the left, the agenda over the calendar on the right.

S puts it up and away from anywhere. b, t and C go straight to their tile,
and put the workspace away when that tile already has focus. Alt-h/j/k/l
move the focus between the tiles, f gives the focused one the porthole,
and the tiles without focus keep their cursors, dimmed. A digit switches
space as everywhere, and the board follows; the board's head names every
space that has one. Enter on an agenda card goes to it on the board beside.
A space without a board says so in the board's tile instead of refusing.

The rail offers the three tiles as doors, so a phone, which shows one at a
time, can walk between them. The help, the README, the wiki and the
screenshots say how it works now.
2026-10-21 15:04:58 +02:00
nvrl c72b65fc38 search: a write reads only its folder, and the match is lit
`file_text::catch_up` read every file without a row, which after a write meant
walking every file node there is to find the one or two just put in. It now
takes the part of the tree to look in: an upload asks about the folder it went
into, an import about the folder it was poured into, an SFTP save about the
folder it landed in, a decrypt about what was decrypted. Only an import at the
root and the pass at start look at everything.

The excerpt of a file found by its text marks each matched word between
U+0002 and U+0003, which no text file has a use for, and the list draws them
lit. A cut through a match leaves an opening without its close, and the rest
is lit to the end rather than the marker shown.

The screenshots are taken again, so the search one shows it.
2026-10-21 12:53:37 +02:00
nvrl 767dd076dd markdown: a paragraph an editor wrapped reads as running text
`breaks` is on because the diary is written a line at a time and a line typed
in the browser is meant. Markdown written elsewhere is usually wrapped at a
width instead — an import, a save over the SFTP mount from vim with
`textwidth`, a pasted README — and kept its breaks, one in the middle of every
sentence, worst on a phone.

`hardWrapped` tells the two apart by what a wrapping editor leaves behind:
every line but the last is long, each break comes where the next word would
not have fitted on the line before, and the lines seldom end where a sentence
does. A paragraph that looks like that has its soft breaks read as spaces; a
break asked for outright, two spaces or a backslash at the end of the line,
stays. Lines broken by hand — a short sentence and then another, a list of
things that happened, a verse — keep their breaks as before.
2026-10-21 12:38:26 +02:00
nvrl a59ba4af1b build: a clone compiles before the frontend is built
rust-embed refused to derive without `web/dist`, so on a fresh clone
`cargo check`, `cargo test` and rust-analyzer failed with a derive error that
did not say what was missing. With `allow_missing` the embed is empty until
the frontend is built, and the handler gives the answer it already had for
that case, "frontend not built". The release build is unchanged once
`web/dist` is there, which `make build`, the Dockerfile and CI all see to.
2026-10-21 12:35:59 +02:00
nvrl 0e0d21506e agenda: today is the workspace's day, not the browser's
The browser grouped the agenda by its own midnight and remembered that it had
opened it by its own date, while the server counts due dates, mails and the
diary in `WORKSPACE_TIMEZONE`. A laptop on a trip, or a browser set to UTC,
showed a card as late that the morning mail called due today, and opened the
agenda a second time on one workspace day or not at all. A day was also taken
to be 86400 seconds, which it is not when the clocks change.

The agenda answer now carries `today`: the date in the configured timezone,
when it starts and ends, and when the seventh day after it ends, all from the
same `day_bounds` the mails use. The browser groups by those and keys the
once-a-day opening on that date. The screenshot run marks the agenda as seen
under the same date.
2026-10-21 12:24:37 +02:00
nvrl a1f6f6025f nodes: names that cannot be confused, and a search of nothing
Three small things the docs pass turned up.

A search of nothing but punctuation — `!!!`, `--` — gave the FTS query no
terms, and the list handler then fell through to browsing with no parent,
which answered with the spaces as if they were matches. It now finds nothing.

Two spaces could have the same name: the slug was made unique (`work-2`) but
the name kept, so the bar showed two pills that read alike and `:space work`
picked the first. A new space, a space renamed, and a directory made at the
root of the mount are refused when another space has that name in any case or
that slug. Folders and documents may still share names; they are told apart by
where they sit.

A name had no ceiling. A file is called by its name on the mount, where most
filesystems stop at 255 bytes, so a document, folder, file or space is now at
most 200 characters, checked where one is made or renamed.
2026-10-21 12:21:54 +02:00
nvrl 3a6ad9453e nodes: a write changes only what it sends
`PUT /api/nodes/{id}` wrote the whole node, and a field left out was taken as
empty: a rename sent without the body erased the text. The browser always sent
it, which was the other half of the trouble. To rename a document it sent the
body it had — the draft for one open here, a copy fetched a moment earlier for
any other — and without the version it had seen, so a save made in another
tab, on the phone or over the mount in between was written over, kept only as
a revision nobody was told about.

Now `name` and `body`, like the rest, change only when they are sent. A rename
sends the name alone, and for a document open in a tile the `updated_at` that
tile last saw, so a rename of a document saved elsewhere since is refused the
way `:w` is, rather than stamping a new version over it that the next autosave
would then write the draft past. Toggling a space's board sends only that.
2026-10-21 12:18:26 +02:00
nvrl 4eb663e2d7 docs: a guide, a wiki and screenshots
The README grew a section per feature and is a good reference, but nothing
said where to begin. docs/wiki now has a getting-started page, a guide that
walks through one day with the workspace and explains each part where it comes
up, and a page per part of the app, each with screenshots of what it
describes.

Three pages are read out of the code rather than written, so they cannot drift
from it: the keys and commands from Help.svelte and commands.ts, the same lists
`:help` draws; the settings from .env.example, which gains a heading for the
vault, trash and history settings so they no longer sit under mail; and the
API from the README's table. `make wiki` writes them, and `--check` fails when
one is stale or a link between pages leads nowhere. `--publish` lays the pages
out for a GitHub wiki: links without `.md`, images beside them, `[[` escaped so
the wiki does not take a command's help text for a link to a page.

The screenshots are taken by `make screenshots`: the debug binary on a
temporary data directory, with a password made up for the run, filled with an
example workspace over the API, and a headless Chromium driven over the
DevTools protocol through the views the docs show. Nothing outside the
temporary directory is touched but docs/images, and the pictures are cut to
256 colours, a third of their size, which a dark UI does not show.

The README opens with the banner, a CI badge, the guide, the features grouped
by what they are for, and six of the screenshots.
2026-10-21 12:07:32 +02:00
nvrl 909abf5fcb search: a file is found by what it says
The text of every file in the tree that is text, by its type or by its
first 8 KiB reading as UTF-8 without a NUL, now goes into `file_text` and a
second FTS5 index beside the documents', up to its first 256 KiB, the same
part the viewer shows. A search asks both, lists a node found in both once,
and marks a file found by its text with `in_text`; the list shows the words
around the match under its name.

There is one way in, `file_text::catch_up`, which reads every unsealed file
that has no row yet. An upload, an import, an SFTP save and a decrypt call
it once their own transaction is done, and the server runs it at start for
anything that slipped past. A file that is not text gets an empty row, so it
is read once and not again.

The triggers keep it honest the other way. New bytes drop the old text, so
an SFTP save is read again, and `:encrypt` writes a file under a new media
id, so its plaintext is gone in the same statement that seals it. The text
of a sealed file is refused outright, whatever path tries to store it;
otherwise it would sit in `diary.db` and its mirror beside the sealed bytes.

PDFs and office documents are not read: their text needs a parser this
binary does not carry.
2026-10-21 01:12:42 +02:00
nvrl 6673298cb2 boards: a card can come back once it is done
`:repeat weekly` gives a card a rule: daily, weekly, monthly, yearly, or
every +Nd, +Nw, +Nm, +Ny. Finishing it, by a move into the last list or by
the API's done, leaves a copy at the bottom of the inbox with the same
title, notes, document and rule, due on the next date. The finished card
gives its rule up in the same transaction, so reopening it and finishing it
again makes no second copy.

The next date is counted on the calendar in WORKSPACE_TIMEZONE, so noon
stays noon across a change of the clocks, and months are counted from the
old due date rather than step by step, so a card due on the 31st comes back
on the 31st wherever a month has one. A card finished late skips the dates
already past; one without a date counts from the moment it was finished.

A move and an edit answer with `again`, the copy, so the status line can
say when the card comes back. The board and the agenda show the rule as
`↻ weekly`, the rail has a button for it, and `:` in the agenda now opens
the line with the agenda kept up, so `:repeat` knows which card.

The rule travels in `.workspace.json` without a format bump: an older build
ignores the field and imports the card as one that does not repeat. An
import drops a rule it cannot read.
2026-10-21 00:02:42 +02:00
nvrl 824f2bb945 diary: templates, a calendar, the days either side and this day before
A folder called templates at the root of any space holds templates. `:new
<template> [yyyy-mm-dd] [name]` starts a document from one, with {{date}},
{{weekday}}, {{time}} and {{title}} filled in on the server, in
WORKSPACE_TIMEZONE, for the date the document is for. A new diary entry starts
from templates/day when the diary has one, whether it comes from T, o, the
calendar or a capture. Templates are not entries: the calendar, the reminder
and "written today" leave them out.

C (or :cal) shows the diary by month: h/l a day, j/k a week, H/L a month, .
today, and Enter opens the day's entry or starts one dated that day. [d and
]d step to the entry before or after the open one by date, backdated ones
included. The agenda ends with what the diary holds from this day in earlier
years; a 29 February is remembered on the 28th in years without one.

New routes: GET /api/diary/days?month=, GET /api/diary/around/{id} and
GET /api/nodes/{id}/templates. POST /api/nodes takes template_id.
2026-10-20 23:41:04 +02:00
nvrl f629f5af97 capture: a token writes down from outside the browser
POST /api/capture takes a line of text and puts it on a card in an inbox,
or at the end of today's diary entry, starting the entry when there is none
yet. It answers a logged-in browser, and also a request that carries an
Authorization: Bearer token from :token add, so a phone shortcut, a script
or curl can capture with no tab open.

A token is nwc_ and 32 random bytes. It is shown once, copied, and stored
only as its SHA-256, the way a share key is. Only the capture route reads
the header. The Session extractor never does, so a token opens nothing
else. Wrong tokens go through the login's backoff on a throttle of their
own. A removed token is refused from the next request on, because each
capture looks the hash up again.

A card lands where :card puts one outside the board: the inbox of the space
named by "space", or else of the first space with a board. That rule now
lives server-side as boards::inbox_of. The first line of the text becomes
the title and the rest becomes the notes. Diary text becomes "- HH:MM text"
in WORKSPACE_TIMEZONE, appended to the first document written today at the
top of the diary. That is the entry :today opens. The append keeps a
revision and indexes links like any other save.

:token lists, adds and removes tokens, and completes their names after rm.
The message that shows a new token stays in the status bar instead of
fading, because it cannot be shown again.
2026-10-20 23:05:17 +02:00
nvrl a42f9074a2 links: a rename or a move no longer breaks them
A document links to another by its path, `[the plan](/n/work/plan)`, or
now by its title, `[[Plan]]` or `[[Plan|the plan]]`. What each document
links to is kept in a new `links` table, rebuilt whenever its text is
written: from the browser, over the mount, or by an import. A title is
looked for in the linking document's space first, then in every other.

Renaming or moving a document, or a folder above it, rewrites the links
to it in every document that has one, in the same transaction: the path
to the new one, a title to the new name, with the label left as it was.
Each rewritten document keeps a revision of what it said before. A link
to something in the trash keeps its row, so it follows the thing when it
is restored somewhere else. A document created, or renamed, under a title
that a link was waiting for is found by that link from then on.

The reader draws `[[titles]]` as links, and a title no document has yet
as a dimmed one that offers to write that document. `gf` follows either
from the editor, and `[[` there completes titles from the search. `gb`
and `:backlinks` list the documents that link to the open one, with the
line each does it on; the line under the title counts them. A share page
shows these links as plain text, as it does every /n/ link.

The documents written before this are indexed once, when the server
starts and finds the table empty.
2026-10-20 20:20:40 +02:00
nvrl fc2be66b0c revisions: a save keeps what it replaces
Every write of a document's text keeps the text it replaces, in the same
transaction. Autosave writes every few seconds, so an ordinary save keeps
one at most every ten minutes; `:w!`, a save over the SFTP mount and a
restore always keep one, because each may undo something. A rename alone,
or a document that was empty, keeps nothing.

`gh` or `:history` lists the open document's earlier versions, the newest
first, and shows the highlighted one as a line diff against the text as it
is now. Enter brings it back, and what it replaces becomes a version of
its own, so a restore is undone the same way.

The daily sweep forgets versions older than WORKSPACE_REVISION_DAYS (90)
but never the newest 20 of a document, and keeps an upload as long as a
version still embeds it. Versions stay out of the export, the mount and
the readable mirror; a document in the trash shows none.
2026-10-20 18:40:50 +02:00
nvrl 8ffad6350f trash: a delete can be undone
`x`, `dd`, `:delete` and `:rmspace` no longer end anything. What they
delete is moved, whole, under one hidden root, and `u` puts the last of it
back where it was. `:trash` lists everything deleted, the last first:
Enter or `r` restores, `x` deletes one for good and `:trash purge` empties
it, each asking first. Something whose folder has gone since comes back to
the root of its space; something whose space is in the trash too waits for
the space. The daily sweep purges what has waited WORKSPACE_TRASH_DAYS (30),
and only then are its files deleted.

While it waits, a trashed node is out of the tree, the search, the agenda,
the mails, the SFTP mount, the export and the readable files/ view, and a
shared document answers 404 until it comes back with the same link. `rm`
over the mount trashes too; a rename onto a taken name still replaces what
was there, so editor saves do not fill the trash.
2026-10-20 18:15:22 +02:00
nvrl 439d5b083b board: F jumps to a card by letter
`F` on the board puts a letter or two over every card in view, as it does
over links in the reader, and typing one puts the cursor on that card;
Enter then opens its notes as before.

Hints are pinned where their targets are when they appear, so `F` pressed
while a pane was still sliding in — the board just opened, a space just
switched to — put them where the targets were going to be. They now wait
for the pane to settle.
2026-10-20 15:42:12 +02:00
nvrl e728d468cb reader: wide tables scroll sideways from the keys
`zl` / `zh` scroll the table, code block or formula at the top of the
view a few columns right or left, and `zL` / `zH` by half its width, as
vim scrolls a long line. It is the first one too wide for the column that
reaches down past the top of the reading view, so it is the one being
read. A CSV's table in the file pane answers the same keys. Before, those
only scrolled with the pointer or a trackpad.
2026-10-20 15:35:29 +02:00
nvrl 811e09416f agenda: a card's date and list without going to its board
In the agenda, `D` asks for the highlighted card's due date on the command
line (`:due` now works there as well as on the board), and `H` / `L` send
the card to the list left or right of the one it is in, on its own board.
`L` into the last list is done, as on the board.

The command line now rises above an open overlay, and while it is up the
overlay's own keys stay out of its way, so typing a date does not walk the
agenda.
2026-10-20 15:34:07 +02:00
nvrl cb76aaf43a tiles: several documents open at once
`L` or Shift-Enter on a row opens it in a tile of its own; Enter and `l`
still open it in the focused tile. Right of the list the tiles share the
space as hyprland's dwindle does: two side by side, a third splits the
newer one, and so on. Each keeps its own scroll position and its own
editor, and the keys act on the focused one. A document already open in a
tile is focused there rather than opened twice, so two drafts of it can
never overwrite each other.

Alt-h/j/k/l moves the focus to the tile that way, out of the editor too,
and Alt-h past the leftmost tile focuses the list as before. With Shift
the tile itself moves, swapping places with its neighbour. `gt` / `gT` and
`:bn` / `:bp` cycle the tiles, `:ls` lists them, `:b <n or name>` focuses
one, `:close` (Alt-q) closes one, asking first when its draft could not be
written, and `:only` closes the rest. `:b` was the board's alias; `:board`
is spelled out now. `:next` / `:prev` still walk the list. `q` on a
document closes its tile when there are several. `f` fullscreens the
focused tile.

Each space remembers its tiles, as it remembered its one document. A
card's notes open in a tile beside whatever was being read, and `q` there
takes the tile away again on the way back to the board. Under 768px only
the focused tile is shown. The status line says which tile has focus.
2026-10-20 15:32:32 +02:00
nvrl c72eccf86b keys: n walks the search results, and a half-typed key shows
`n` / `N` move to the next / previous search result and open it beside the
results, so the next `n` goes on from there; a folder is only highlighted,
as going into it would end the search. Past the last result it starts
again at the first and says so, as vim's `wrapscan` does. Clearing the
search is left to `:noh` / `:clear`, as in vim, rather than to `n`.

After `g`, `d`, `]` or `[` the status line shows the key waiting for its
second half, like vim's `showcmd`, and forgets it after a second.
2026-10-20 15:17:31 +02:00
nvrl b3a60aa361 keys: the command line remembers and completes
`↑` / `↓` (and `Ctrl-p` / `Ctrl-n`) walk the lines run before, newest
first, narrowed by what is already typed: `:se` then `↑` finds the last
`:set …`. Commands and `/` searches keep separate histories in
localStorage, a hundred lines each. Passwords never get here: they are
typed into the vault prompt, not the line.

Tab moves to the next match and Shift-Tab to the one before, with the match
marked in the list. Once a name is complete, Tab completes the argument:
the folders listed here plus `..` and `/` for `:cd` and `:mv`, space names
for `:space` and `:rmspace`, the options of `:set`, `:due`'s usual dates and
`:backup`'s subcommands. `:mv /` now moves to the root of the space, as
`:cd /` goes there.

While a search is live, `Ctrl-n` / `Ctrl-p` (and Tab) move the list cursor
through the results without leaving the line, and Enter opens the one lit
rather than only closing the line. Ctrl-P's search does the same.

After a command the focus goes back where it was, or to the editor while
writing, instead of being left on <body> for the window handler to catch.
2026-10-20 15:16:00 +02:00
nvrl 37443cf083 keys: files and uploads answer the keyboard
`gx` opens the file under the cursor in a tab of its own, as the browser
shows it. `:download` (`:dl`) downloads it, after asking for the vault when
the file is sealed; on the open document it is `:export`. `:source` turns a
markdown file between rendered and as written, which before had only a
button. A sealed file asks for the vault before `gx` instead of after, so
the browser does not block the late tab as a popup.

The media list gets a cursor: `j`/`k` walk it, `i` or `Enter` embeds the
highlighted upload at the cursor while writing, `y` copies its address and
`x` deletes it. Deleting asks through the `[y/N]` prompt, which names what
would break, in place of the browser's confirm(): that one was the last
modal on the page and needed the pointer to dismiss. A footer lists the
keys, as the agenda's does.
2026-10-20 13:59:09 +02:00
nvrl 54bc333be2 keys: links are followed without the pointer
`F` puts a letter or two over every link and button in view, vimium-style,
and typing one does what a click on it would. `f` was already fullscreen, so
the hints live on the other key. The labels start from the home row and are
all one length, so a label is done the moment it is typed. Esc, a key that
matches no label, or anything that scrolls the page puts them away. In the
file pane they reach the unlock and keystore buttons too.

A workspace link (`/n/…`) opens in place, as a click on it already did. Any
other link gets a tab of its own instead of taking the workspace with it.

In the editor, `gf` follows the link under the cursor, or the next one on
the line. A workspace link saves the text and opens that document. `gx`
opens the link in a new tab, as vim hands it to the browser. Both read
markdown links, embeds, `<…>` autolinks and bare addresses.
2026-10-20 13:55:48 +02:00
nvrl 6a8ffdf408 keys: headings are a key away, read or written
`]]` and `[[` jump to the next and previous heading of the open document,
in the reader and in the editor, where vim's section motions mean nothing
useful in markdown. Past the last heading they go on to the end, as vim's
do. A heading a jump has just parked at the top counts as the one being
read, so pressing again moves on.

`gO` (or `:outline`, `:toc`) lists the headings in an overlay that opens on
the section being read or the one the cursor is in: `j`/`k` walk it,
`Enter` jumps there. It is the margin's outline for when the pane is too
narrow to show one, and for hands that stay on the keys. In the editor the
list comes from the `#` lines of the text, code fences left out, and a pick
moves the cursor; in the reader it comes from the rendered headings,
markdown files included.
2026-10-20 13:52:24 +02:00
nvrl 007fb1d3e3 keys: passwords, titles and names without the pointer
A keystore or encrypted key asks for its password as soon as it is
opened, asks again after a wrong one, and Enter on the file pane asks
once more after Escape — the button was the only way in before. Enter on
a sealed file opens the vault the same way.

`:name` on its own, while writing, moves up into the title, and Enter,
Escape, Down or Tab there goes back into the text; the title could not be
reached from the editor at all. `R` starts the command line from the
current name — of the row, the open document, the card or the list —
instead of an empty one.

`s` on a shared document and `y` both ask y/N before they cut readers
off: y is what a vim hand presses to yank, and it retired the live link
without a word.
2026-10-20 13:44:37 +02:00
nvrl e0a4c01654 backup: the Proton login runs from the workspace
:backup login <username> shows Proton's third-party disclosure (and a
warning when no session key file seals the stored session), asks for
the password, then for a two-factor code when the account has one. If
Proton answers with a human check, its own verify.proton.me page opens
in a sheet; the token it posts back resumes the login. :backup logout
forgets the session and ends it on Proton's side where Proton answers.
The mirror picks up a new session within a minute, without a restart.

The login is split into steps in backup::proton (start, second_factor,
verified), and a half-done login waits in memory for five minutes at
most. Every step goes through its own throttle, so the workspace cannot
hammer Proton into locking the account. FIDO2-only accounts are told to
add an authenticator app. The CLI runs over the same steps and points
to :backup login when Proton wants a human check. The CSP lets
verify.proton.me into a frame and nothing else.
2026-10-20 12:54:03 +02:00
nvrl 3387db1c4d vault: a password change brings a new master key
:passwd used to wrap the same master key under the new password, so an
old diary.db (a pre-* snapshot, an old revision on Proton Drive) and the
old password still opened every sealed file. It now makes a new master
key. The old password opens nothing from that moment; the old master
key is kept, sealed under the new one, in two new vault columns, only
until every sealed file has been re-sealed.

A background worker does that while the vault is open, without holding
it open. Each file gets a new id with its file key wrapped under the new
master key; its records are copied as they are. The move goes through
the same repointing as :encrypt, so the old id is put on backup_retired
and the mirror purges it from Drive. Which files are left is read off
their headers, so a lock or a restart halfway only pauses the work until
the next unlock. Once none is left, the old key is dropped.

Readers take a Keyring that tries the current key, then the retired
one. A second :passwd is refused until the first has finished. :vault
shows the progress. Every sealed file is uploaded to Proton once more.
2026-10-20 12:46:10 +02:00
nvrl 947cc2ba96 share: the key never travels in a URL
The share page used to put the key in the API path, and every embedded
picture or video carried it in its src, so a reverse proxy's access log
held whole links. The page now sends the key in the body of
POST /api/share/{token}. The answer sets a diary_share cookie
(__Secure- over HTTPS) scoped to /api/share/{token}/, HttpOnly,
SameSite=Strict, good for twelve hours, and signed over the key's hash.
Media is served from /api/share/{token}/media/{id} only to a browser
holding that cookie, so minting a new key or unsharing voids it.

The old /api/share/{token}/{key} routes are gone; links themselves are
unchanged, since the key was always in their fragment.
2026-10-20 12:33:59 +02:00
nvrl fe1b12042d login: guessing from elsewhere no longer slows a browser already in use
The login throttle was one counter for everyone, so a stranger failing
on purpose every thirty seconds kept the owner on 429s too. A browser
that logs in now gets a signed diary_device cookie (__Host- over HTTPS,
SameSite=Strict, 400 days), and a login carrying a valid one is slowed
only by its own throttle. Everything else shares the anonymous one, as
before. At most 64 known browsers are tracked; the least recently used
is forgotten first. The failed-login mail says which kind of browser
the failures came from.
2026-10-20 12:25:36 +02:00
nvrl 165561f6a2 sessions: every signed-in browser is a row, and one can be signed out
A session cookie now carries the id of a row in the new sessions table,
and is refused once that row is gone. :sessions lists the browsers
signed in, newest first, and :sessions rm <n> signs one out without
touching the rest; it locks the vault too, since the vault is open for
the whole workspace. Logging out deletes the row, :logout! and a login
password change delete them all, and the sweep drops expired rows.
last_seen is kept in memory and written at most every five minutes.

Cookies issued before this carry no id and are refused: every browser
logs in once more after the upgrade.
2026-10-20 12:19:26 +02:00
nvrl cbbb804bfc docs: what a vault password change does not take back
:passwd re-wraps the same master key, so a database copy from before the
change — a pre-migration snapshot, a Proton Drive revision — still opens
every sealed file with the old password. Say so, until a real rekey
exists.
2026-10-20 11:45:32 +02:00
nvrl a1bc0a9995 vault, sftp: a key reads sealed files only when allowed to
Since e4e3e2b every registered SSH key could read sealed files over the
mount while the vault was open in the browser. A key is a standing
credential on another machine, so it now needs vault access, given and
taken per key with :sshkey vault <name> on|off (PATCH /api/ssh-keys/{id},
migration 0016). Existing keys start without it. The access is asked on
every read of a sealed file, so taking it away cuts off a read under way,
as locking does. That includes a write handle opened over a sealed file:
its .part holds the decrypted bytes and could be read back through it.

A mount no longer holds the vault open: it takes the key through the new
Vault::peek_key, which does not count as use. Opening sealed files over
and over from an indexer kept the idle lock from ever firing.

The rest of the audit's findings on this side:
- An unfinished upload's .part is removed when it is dropped, so a sealed
  file's plaintext does not sit in uploads/ after a client goes away.
- A deleted key can no longer write, setstat or close-and-save through a
  session that was already open.
- A session holds at most 256 handles and 64 MiB in memory, the server at
  most 32 connections; keepalives drop a peer that stopped answering.
- A sealed download in the browser ends when the vault locks, the way a
  read over the mount already did.
- Certificate inspection refuses a key or keystore that asks for more
  than ten million rounds, or scrypt beyond 2^20 and r = 8, before running
  the derivation: the file chooses those numbers, and the work could not
  be stopped once begun.
2026-10-20 11:43:39 +02:00
nvrl f450883ac0 auth, headers: the examples are refused, HTTPS is insisted on
The server no longer starts with WORKSPACE_SECRET or WORKSPACE_PASSWORD
left at the value .env.example ships with. The secret was only checked
for length, and the public placeholder passed: anyone who had read the
repository could sign a session cookie for themselves. A secret under
32 characters is warned about.

With WORKSPACE_SECURE_COOKIE=1 the cookie is named __Host-diary_session,
which the browser keeps to this host and to HTTPS and which no sibling
subdomain can plant; turning it on logs everyone out once. Responses
then carry Strict-Transport-Security for a year. The traefik example
turns it on.

The password is compared as an HMAC under the secret, so the time a
comparison takes no longer depends on the configured password's length.

Every response also carries Cross-Origin-Opener-Policy and
Cross-Origin-Resource-Policy same-origin and a Permissions-Policy that
turns camera, microphone, location, payment and USB off; API answers
are no-store unless they say otherwise.
2026-10-20 11:25:16 +02:00
nvrl 6bf88e7ffd tree: several nodes picked up and moved at once
m now adds the row under the cursor to a set of marks and steps on, so
mmm picks up three; M takes the whole folder, Esc or :unmark puts it all
back. Marks stay while the list walks, so they can be gathered from
several folders. p, :mv and Shift+digit move the whole set.

With a pointer, Ctrl- or Cmd-click marks a row and Shift-click every row
from the cursor to it. Rows drag onto a folder row, a crumb or a space
pill; a marked row carries every marked node with it.

POST /api/nodes/move takes { ids, parent_id } and moves them all or
none: every node is checked before any moves, sealing into a sealed
folder happens before the one transaction that moves them, and two of a
name get slugs of their own. The single move goes the same way.
2026-10-20 11:13:52 +02:00
nvrl e4e3e2b5aa sftp: a sealed file opens while the vault is open
Decrypted a record at a time as it is read, from any offset, and sealed
again when it is written into or over, in a sealed folder or not. Shut,
the vault refuses the open and cuts off reads already under way. Reads
over the mount do not count as use, so a mount alone does not keep the
vault open.
2026-10-20 10:34:29 +02:00