Commit Graph
54 Commits
Author SHA1 Message Date
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 e0de88938a store: what belongs to one document lives in a Tile
The open node, its draft, whether it is being written and whether that is
saved, the autosave timer and its back-off, the fresh-document marker,
the board to go back to and the editor's vim mode move out of Workspace
into a Tile. The workspace holds `tiles` and `focused`, always at least
one tile, and the old names are getters on the focused tile, so the call
sites read as before. Nothing visible changes yet: there is one tile.

`flush()` writes every dirty tile, deleting, moving, renaming, sharing or
sealing a node updates every tile that shows it, and the page warns before
unloading when any tile is dirty.

The panes and the editor are handed their tile, and fill the shared hooks
only while that tile has focus; the others stand by. Vim's actions are
defined once for the page and act on the editor the key was pressed in,
rather than on whichever editor happened to be mounted last.
2026-10-20 15:22:43 +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 4bf8634991 focus: keys go where the screen says they go
A password prompt now hands the focus back to whatever had it before it
opened, or to the editor or the list when that is gone. Until now the
focus was left on nothing once the field went away, so after an :unlock
typed in the editor the status line still said NORMAL while j walked the
list and dd asked to delete the document. A key that lands outside the
editor while it is open is handed back to it for the same reason.

A document or folder asked for from the board — T, :12, :e, a search —
is now shown instead of being opened in a tree hidden behind the board,
and closing such a document goes back to the board. A space switch that
reopens a remembered document still keeps the board on screen.

Unlocking the vault says so while the key is stretched.
2026-10-20 13:40:40 +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 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 6c82264f57 help: one long signature no longer squeezes its whole group
The :sshkey arguments made the key column of the session group as wide
as the newspaper column, and every description beside it collapsed to a
letter a line. The key column is now capped and arguments wrap under the
command name. Text is a size larger, descriptions at body contrast, and
rows have more air.
2026-10-20 12:07:35 +02:00
nvrl 7f15206ffb markdown: links open nothing they can reach back from
Every link with a target now gets rel="noopener noreferrer", and only
_blank is kept as a target. A named target such as target="foo" left
the page it opened a handle on this one — browsers imply noopener for
_blank alone — which it could use to send the tab to a lookalike login.

The share page turned links into the private workspace into plain text
with a regex over the sanitised HTML. A pattern over markup can end
inside an attribute, and on an engine that does not escape < and > in
attribute values a crafted link title became markup. The rewrite now
happens in renderMarkdown, on the parsed page.
2026-10-20 11:45:11 +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 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 b19fb21fc4 vault prompt: appears at once and stays put between questions
The password box rose into place like the [y/N] prompt, a 380 ms slide
and fade, and was taken down and put up again for every question: twice
for :vault, three times for :passwd, and right after the command line
closed for :unlock. Whatever was typed in that time went into a field
still moving and half transparent.

It now appears without an entrance, and an answer puts it away a turn
later, so a question that follows straight on takes over the same box.
Password managers are asked to leave the field alone; it is not the
login.
2026-10-20 11:00:08 +02:00
nvrl fc593e6452 markdown: math set by KaTeX, fetched only when a page has some
$x^2$ inline, $$…$$ for display, and a ```math block, the way GitHub
writes them, in documents, shared pages and .md files alike. KaTeX loads
on first use; until then the math shows as its source. Only the woff2
fonts are bundled.
2026-10-20 10:25:26 +02:00
nvrl 889f99f8d6 vault: a sealed folder stays sealed
`:encrypt` on a folder sealed the files in it once; whatever arrived
afterwards was plaintext. A folder or space that is encrypted now
carries `sealed` (0015), and so does every folder under it, so "is
this place sealed?" is one column of the parent. What lands there is
sealed on the way in, or refused with 423 while the vault is shut:

- uploads, with or without `?encrypt`, encrypted as they stream
- imports, sealed where they lie before any row names them
- SFTP writes, sealed out of their `.part`, and refused at the open
  when the vault is shut
- files and folders moved in, through the web or SFTP: seal::enter
  seals them first and the move happens only if that worked

Triggers make it hold for any path: a folder made inside a sealed one
is sealed, and a file node whose media is not encrypted cannot be
inserted into or moved into a sealed folder (a 400, not a 500).
Decrypting the sealed folder lifts the flag on its subtree and opens
its files; a file or folder inside it cannot be decrypted on its own.
An empty folder can be sealed. Documents are not files and stay as
they are.

The list shows a lock on sealed folders, the touch menu offers to
unseal one, and an upload or import into one asks for the vault
password first. Tests cover sealing, inheritance, the trigger, moves
with the vault open and shut, unsealing, an SFTP write and an import.
2026-10-20 10:14:36 +02:00
nvrl 1ae0665206 files: PDFs, text, code, tables and certificates open in place
The file pane decided what to show by MIME type, and uploads keep a
type only when it cannot execute, so a .md, a .json or a .pem arrived
as application/octet-stream and got "no preview". It now goes by name
first (preview.ts), and a file with a name it does not know has its
first 8 KiB looked at: no NUL bytes and valid UTF-8 means text.

- text and code: the first 256 KiB with line numbers, as before
- markdown: rendered through the same sanitiser as documents, with a
  source toggle
- CSV/TSV: a table of the first 500 rows, quotes respected
- PDF: the browser's own viewer in a frame. Only a PDF is served
  without the sandbox CSP (Chrome's viewer will not start under it),
  with frame-ancestors 'self' and X-Frame-Options SAMEORIGIN; the app
  CSP allows same-origin frames.
- certificates, keys, keystores: POST /api/nodes/{id}/inspect reads
  PEM (chains, keys, requests, public keys), DER and PKCS#12 on the
  server, in pure Rust, including the RC2/3DES keystores that
  openssl -legacy and older keychains write. It answers with subject,
  issuer, validity, key, names, usage and fingerprints, and pairs each
  private key with its certificate by public key. A password is asked
  for only when needed, used for that one answer and never stored;
  no key material is ever sent back. Sealed files are read through the
  vault, and only while it is open.

The tests read throwaway fixtures under src/routes/testdata, made with
the openssl commands listed beside them; .gitignore lets their .pem
files past a global *.pem rule.
2026-10-20 09:57:09 +02:00
nvrl 9749c500cf docs, help, checks: the README and the help say what the app does
README: the API table lists every route, twelve of which were missing;
an SFTP section says how to turn the mount on and register a key; the
keys table and the command list cover what was only in the code
(digits, Tab, w, f, Alt-h/l, Ctrl-D/U, m/p, :today, :edit, :next and
the rest, :search!, :set clipboard, :logout!). Three claims that were
wrong are fixed: there is no board button above the list, R does not
rename a space, and Esc/q close the document.

Help gains the agenda keys and the board's gg/G and r. :t no longer
means :today, which read as the agenda's key.

make check runs cargo fmt --check, after the one formatting commit
before this, which .git-blame-ignore-revs names (git config
blame.ignoreRevsFile .git-blame-ignore-revs). Cargo.toml states
rust-version 1.96, the floor the Proton crates set, and the build is
checked on it. make build installs the frontend from the lockfile.

A test reads the route table out of routes/mod.rs and asks every route
without a cookie: all but the login, the health check and the share
links must answer 401, including any route added later.
2026-10-20 06:02:16 +02:00
nvrl 2733ac1a3a ui: touch parity, saves that survive outages, edits that do not collide
PUT /api/nodes/{id} takes base_updated_at and answers 409 when the node
was saved since; the editor sends it, so a save from another tab, the
phone or the SFTP mount is no longer overwritten in silence. :w! and
:wq! now force the write.

An autosave the server could not take is kept and retried with backoff,
and at once on the online event; network failures say "the server
cannot be reached" rather than "Failed to fetch". At boot that is no
longer shown as a login screen. A session that expires mid-edit shows
the login with a note, and logging back in resumes the open draft.

Folder listings and the media list are read page by page, so more than
500 children are no longer cut off. Search results carry the space and
folder they sit in, and the overview's recent list includes files.
Embedding uploads report progress, like uploads into a folder.

On touch devices vim is off by default. Each row has an actions sheet
(rename, move, share, seal, delete), folders start with a .. row, and
the dock gains today's entry, upload, search all spaces, drop for a
marked node, help, and the board's list, reorder, rename and delete.
The agenda gets a done button per card and a button for today's entry.

Sharing a folder or a file is refused in the client. :mv .. in a search
moves up from the match's own folder. Files dropped outside the list or
editor no longer navigate the tab away. The public share view renders
/n/ links as plain text, and tells a network error from a dead link.
2026-10-20 05:53:34 +02:00
nvrl ee13ae44fa runtime: ranges, health, logs, supervised loops, strict config
Media answers Range requests with 206 and Content-Range, so videos seek
and Safari plays them. Sealed files too: the vault's opener now decrypts
by record position (STREAM BE32 through StreamPrimitive, the same
construction and the same files), so a range starts at the record that
holds its first byte. Compression is limited to text, because a gzipped
body no longer matches the byte ranges a player asks for.

GET /api/health answers without a session once the database does, and
the compose healthcheck asks it instead of only opening the port.
Requests are logged at info with share tokens and keys left out of the
path and no query strings. A request that is not answered in 30 seconds
gets a 503, except uploads, imports, the export, re-sealing and backups.

The backup, mail, sweep and SFTP loops run under a supervisor that logs
a panic and starts the loop again. The backup loop starts without a
Proton session and picks up a later proton-login without a restart. A
lock file keeps backup-now and the server from mirroring at once, and an
unreadable session key file turns backups off instead of failing boot.

Numbers in the configuration are checked against bounds at startup, so
a session length or an upload limit can no longer overflow later, and a
switch that is neither on nor off is an error rather than off. Config
and MailConfig print with the password, secret and SMTP URL redacted.

A session epoch is signed into each cookie (migration 0014). :logout!
advances it, which ends every session, and a changed WORKSPACE_PASSWORD
advances it on the next start. Cookies issued before this keep working
in the first epoch.
2026-10-20 02:01:50 +02:00
nvrl 336df03ee2 vault: a lock that lets go, and seals that say what they broke
The idle timeout was checked only when the key was next asked for, so a
vault nobody touched kept its key in memory indefinitely while status
reported it locked. A ticker now drops the key once it is idle, and
status() expires it too. Vault passwords are Zeroizing from the request
body to Argon2.

Re-sealing a file rewrote links with a case-sensitive replace, and only
in documents linked through node_media. Links are now matched by regex,
whatever their case, in every document and card body that names the id.
A folder re-seal carries on past a file that fails and reports how many
failed. Two re-seals of one file no longer both commit.

:share reports how many sealed files the document embeds, since a share
link never serves them, and :encrypt reports how many shared documents
embed what it just sealed. Vault status counts sealed files, so :export!
asks for the password first rather than navigating into a 423 page. A
sealed embed that fails to load while the vault is shut becomes a button
that unlocks it and puts the embed back.
2026-10-20 01:43:45 +02:00
nvrl aa34be8663 export, import: an export that pours back into a workspace whole
An export used to lose what plain files cannot carry: dates, names as
typed, the boards. It now writes .workspace.json beside the tree (format
1), and an import that finds one restores them. The markdown stays
plain, so the archive still reads without the application.

POST /api/import (:import! / in the UI) imports at the root: each
top-level directory becomes a space, or merges into the space already
there by that name, with its board and cards and the diary role. With a
manifest, media/ comes back as embedded files rather than a folder, a
file whose name ends in .md stays a file, and empty folders come back.

Export claims every path in one set, folders, then documents, then
files, so a file named notes.md beside a document notes no longer makes
a duplicate entry that failed the whole zip. Links to folders are
rewritten like links to documents, in one regex pass per kind instead
of a replace per file and document. A sealed file that will not open
now fails the export instead of being left out silently. The temporary
zip is made in the data directory, not in /tmp.
2026-10-20 01:38:42 +02:00
nvrl e1d696701f integrity: one write, one transaction, and nothing left behind
Node update, delete and move each ran as separate statements, with the
slug chosen outside the write that took it. They are now one transaction
each, opened with BEGIN IMMEDIATE so a read-then-write cannot fail with
SQLITE_BUSY halfway. Creating a node, a space or a file does the same.

A delete is one function, delete_subtree, used by the API and the SFTP
mount alike: SFTP deletes used to skip the media cleanup and orphan the
files a document embedded. Rows go in the transaction, bytes after the
commit. Deleting a media row that backs a file in the tree is refused;
the media browser marks those files and stops counting them as
documents that embed them.

A space can no longer be moved into a folder. The diary is found by a
role column (migration 0013) instead of its slug, so renaming it no
longer breaks the reminder or the agenda, and it cannot be deleted. The
same migration adds the unique index root slugs never had.

A card is done exactly when it is in the last list: a done patch now
moves the card, a card made in the done list is stamped, and adding or
deleting lists settles the board. Seeding a fresh board is atomic, and
a card links only to a document.

Uploads are fsynced before the rename, and zip entries are written aside
and renamed like any other upload. A sweep at start and once a day
removes leftover .part files, files no row knows, and uploads nothing
has embedded for a week.

Before a new build migrates the database it writes a VACUUM INTO copy,
diary.db.pre-<version>, since migrations only go forward.
2026-10-20 01:26:47 +02:00
nvrl a641d9b6ba vault: files sealed under a password that is never stored
Argon2id turns the vault password into a key that seals a random master
key; only the sealed copy is kept. Each file gets its own key, sealed
under the master key in its header with its media id, and its bytes are
AES-256-GCM STREAM records, so a swapped, cut or altered file refuses
to open. Uploads with ?encrypt=true are sealed as they stream in.

:vault, :unlock, :lock, :passwd, :encrypt, :decrypt and :upload! drive
it. The vault locks after WORKSPACE_VAULT_IDLE_MIN idle minutes, on
logout and on restart; while locked, sealed files answer 423, share
links never serve them, and :export! refuses rather than leave them out.

Sealing an existing file rewrites it under a new id and retires the old
one: the mirror deletes the plaintext from Proton Drive outright,
trash included, whatever WORKSPACE_BACKUP_PRUNE says, and drops it from
the readable files/ view. The mirror now compares on-disk sizes, so
sealed files are not re-hashed every run.
2026-10-19 16:03:59 +02:00
nvrl 2103edc299 files: a node kind of their own, in the tree beside the notes
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.
2026-10-19 15:44:30 +02:00
nvrl 9255d36b61 agenda, capture and finder: what needs doing comes to you
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.
2026-10-19 14:51:52 +02:00
nvrl e1c8e22ea5 board: one model — first list inbox, last list done
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.
2026-10-19 14:45:35 +02:00
nvrl d9b78170d8 keys act on the focused pane, deletes ask y/N in place
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.
2026-10-19 14:39:38 +02:00
nvrl ecfbab0eaa ui: share view in the shell, icon rail, phone polish
- 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
2026-10-17 17:27:59 +02:00
nvrl 9391b10af1 hyprland 2026-10-17 14:25:32 +02:00
nvrl 8a37ca5dfe redesign 2026-10-17 13:44:05 +02:00
nvrl 747332153c feat: mount spaces/folders over SFTP with SSH-key auth
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.
2026-09-21 15:36:36 +02:00
nvrl 25411ca115 ui fixes 2026-09-21 15:10:23 +02:00
nvrl 1a37420461 added document move 2026-09-21 00:05:24 +02:00
nvrl 714a84089a feat: j/k scroll the open document, and import loose files
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.
2026-09-20 20:42:20 +02:00
nvrl d8ebf4b850 rename and stuff, better navigation 2026-09-19 20:46:05 +02:00
nvrl 49b2056215 feat: spaces, folder import, boards and mail; rename to narl-workspace
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.
2026-09-18 14:53:22 +02:00
nvrl 76f0887ac5 feature(SHARE) added secret token to share to prevent getting crawled 2026-09-09 14:37:16 +02:00
nvrlandClaude Opus 5 bac8a4c1d4 feat: mirror the diary to Proton Drive as a device
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
2026-09-05 11:38:11 +02:00
nvrl 41d2d7ef77 feat: slow down repeated failed logins
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.
2026-09-05 09:47:27 +02:00
nvrl f0135d34ac feat: :export! writes the whole diary out as a zip
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.
2026-09-05 09:45:44 +02:00
nvrl 54743346fe feat: autosave drafts, and stop losing them on a switch
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.
2026-09-05 09:10:04 +02:00
nvrl 1e0f2f8792 fix: let a media file belong to more than one entry
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.
2026-09-05 09:06:22 +02:00
nvrl 515a82dfc2 yank clipboard + ? fix 2026-09-05 08:13:18 +02:00