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.
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.
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.
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.
The help was a box narrower than the screen that had to be scrolled with
the mouse. It is now as big as the porthole, in newspaper columns that run
on to the right: h/l and j/k move a column, Ctrl-D/U and Space a screen,
g/G to either end. A row is never split between columns, and says what it
says before its first dash; / filters the rows by key or words, and shows
the ones left whole. The prose footer became an 'in the editor' group.
`: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.
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.
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.
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.
`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.
`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.
`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.
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.
`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.
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.
`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.
`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.
`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.
`]]` 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.
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.
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.
: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.
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.
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.
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.
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.
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
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.
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