diff --git a/.env.example b/.env.example index da2c84c..ace8a75 100644 --- a/.env.example +++ b/.env.example @@ -101,6 +101,8 @@ WORKSPACE_DIGEST_AT=sun 18:00 # Which of the four kinds may be sent. Drop one from the list to switch it off. WORKSPACE_MAIL_KINDS=reminder,card_due,digest,login +# --- Vault, trash and history ------------------------------------------------ + # Minutes the vault stays open with nothing using it, before it locks itself. # It also locks on logout and whenever the server restarts. WORKSPACE_VAULT_IDLE_MIN=15 diff --git a/Makefile b/Makefile index 06a0562..f031bf3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev dev-api dev-web build run check clean +.PHONY: dev dev-api dev-web build run check wiki screenshots clean # Two terminals: `make dev-api` and `make dev-web` (http://127.0.0.1:4243). dev-api: @@ -20,6 +20,17 @@ check: cargo clippy --all-targets -- -D warnings cargo test cd web && bun run check + bun scripts/wiki.ts --check + +# The generated wiki pages, from the code they describe: see docs/wiki/Development.md. +wiki: + bun scripts/wiki.ts + +# docs/images, taken again from a throwaway workspace in a headless Chromium. +screenshots: + cd web && bun install --frozen-lockfile && bun run build + cargo build + bun scripts/screenshots.ts clean: cargo clean diff --git a/README.md b/README.md index a69107a..5b39c48 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,62 @@ +

+ ~/workspace +

+ +

+ ci + wiki + rust 1.96+ + svelte 5 +

+ # ~/workspace A private, terminal-themed workspace that runs in the browser: a diary, the -notes for work and for uni, and a board per space. One user, one SQLite file, -one binary. It was called `narl-diary` until it grew everything but the diary. +notes for work and for uni, a board per space, and the files that go with them. +One user, one SQLite file, one binary. It was called `narl-diary` until it grew +everything but the diary. -- **Rust + axum** backend, **SQLite** storage, **Svelte 5** frontend. -- Spaces (`diary`, `work`, `uni`, …) with folders nested to any depth inside them. -- Markdown documents with full-text search across a whole space (SQLite FTS5), - reaching into the text files in the tree too, - and math in them set by KaTeX: `$x^2$` inline, `$$…$$` or a ```` ```math ```` - block on its own. -- Import a folder — or a zip of one — as a tree of documents, links and all. -- A kanban board per space, so what is open at work is not mixed up with uni. -- Drag, drop or paste images, video and audio straight into an entry. -- Files of any kind — PDFs, archives, recordings — live in the tree beside the - notes about them, streamed to disk however large — and PDFs, text, code, - tables and certificates open in place. -- Share any document behind an unguessable link — revocable at any time. -- A vault: files sealed with AES-256-GCM under a password that is never stored, - readable only while it is unlocked. -- Backs itself up to Proton Drive, end-to-end encrypted, as a registered device. -- Mails you — a reminder when the diary is empty, due cards, a weekly digest. -- Real vim keybindings on the desktop (CodeMirror 6 + vim mode), plain taps on - mobile. -- The whole frontend is embedded in the release binary, so deploying is copying - one file. +![A document open in the reader, the tree beside it](docs/images/document.png) + +**New here?** The [guide](docs/wiki/Guide.md) walks through a day with it, and +[getting started](docs/wiki/Getting-Started.md) has it running in five minutes. +Everything is also in the [wiki](https://github.com/narrrl/narl-workspace/wiki). + +## What it does + +- **Write.** Markdown documents in spaces (`diary`, `work`, `uni`, …) with + folders to any depth. Real vim keys on the desktop (CodeMirror 6), plain taps + on a phone. Math by KaTeX, `$x^2$` and `$$…$$`. `[[Title]]` links that are + rewritten when what they point at is renamed or moved, and backlinks. +- **Plan.** A kanban board per space, due dates said the way you say them + (`fri`, `+3d`), cards that come back once done, and an agenda of what is late, + due or in hand on every board. +- **Remember.** A diary with a calendar, templates, the entries from this day + in earlier years, and an evening reminder when nothing was written. +- **Keep files.** PDFs, archives and recordings live in the tree beside the + notes about them, streamed to disk however large. PDFs, text, code, tables + and certificates open in place, and search reads the text files too. +- **Find.** Full-text search across a space or all of them (SQLite FTS5). +- **Lose nothing.** Autosave that survives a dropped network, every earlier + version of a document as a diff, a trash with undo, an export that reads + without this app, and an import that takes it back. +- **Seal.** A vault: files sealed with AES-256-GCM under a password that is + never stored. +- **Capture.** A token per phone or script that puts a card in an inbox or a + line in today's entry, and nothing else. +- **Share.** Any document behind an unguessable, revocable read-only link. +- **Mount.** The whole workspace over SFTP, for an editor on another machine. +- **Back up.** A mirror to Proton Drive, end-to-end encrypted, as a registered + device, and mail for reminders, due cards and a weekly digest. +- **Deploy.** Rust and axum on the server, Svelte 5 in the browser, and the + whole frontend embedded in the release binary, so deploying is copying one + file. + +| | | +| --- | --- | +| ![Two documents side by side in tiles](docs/images/tiles.png) | ![The board of a space](docs/images/board.png) | +| ![The agenda across every board](docs/images/agenda.png) | ![The diary by month](docs/images/calendar.png) | +| ![Search finding a line inside a log file](docs/images/search.png) | ![A document's earlier versions as diffs](docs/images/history.png) | ## Quick start @@ -59,7 +91,15 @@ make dev-web # vite dev server on :4243, proxying /api to :4242 ``` In debug builds the binary reads `web/dist` from disk, so `cargo run` alone also -works once the frontend has been built at least once. +works once the frontend has been built at least once — and not before: the +binary embeds `web/dist`, so a fresh clone builds the frontend first. + +`make check` runs what CI runs: format, clippy, the tests, svelte-check, and a +check that the generated wiki pages are current. The wiki is written in +`docs/wiki` and published by CI on every push to `main`; `make wiki` +regenerates the pages read out of the code, and `make screenshots` takes the +pictures in `docs/images` again. See +[Development](docs/wiki/Development.md). ## Keys diff --git a/docs/images/agenda.png b/docs/images/agenda.png new file mode 100644 index 0000000..99680de Binary files /dev/null and b/docs/images/agenda.png differ diff --git a/docs/images/board.png b/docs/images/board.png new file mode 100644 index 0000000..6437be1 Binary files /dev/null and b/docs/images/board.png differ diff --git a/docs/images/calendar.png b/docs/images/calendar.png new file mode 100644 index 0000000..8bd439d Binary files /dev/null and b/docs/images/calendar.png differ diff --git a/docs/images/document.png b/docs/images/document.png new file mode 100644 index 0000000..72d1ce2 Binary files /dev/null and b/docs/images/document.png differ diff --git a/docs/images/editor.png b/docs/images/editor.png new file mode 100644 index 0000000..2d4fc07 Binary files /dev/null and b/docs/images/editor.png differ diff --git a/docs/images/file-certificate.png b/docs/images/file-certificate.png new file mode 100644 index 0000000..cd8b8a3 Binary files /dev/null and b/docs/images/file-certificate.png differ diff --git a/docs/images/file-table.png b/docs/images/file-table.png new file mode 100644 index 0000000..c3841ec Binary files /dev/null and b/docs/images/file-table.png differ diff --git a/docs/images/help.png b/docs/images/help.png new file mode 100644 index 0000000..24d0ac2 Binary files /dev/null and b/docs/images/help.png differ diff --git a/docs/images/history.png b/docs/images/history.png new file mode 100644 index 0000000..d1e4816 Binary files /dev/null and b/docs/images/history.png differ diff --git a/docs/images/login.png b/docs/images/login.png new file mode 100644 index 0000000..37a42d3 Binary files /dev/null and b/docs/images/login.png differ diff --git a/docs/images/math.png b/docs/images/math.png new file mode 100644 index 0000000..f6b84bd Binary files /dev/null and b/docs/images/math.png differ diff --git a/docs/images/mobile-document.png b/docs/images/mobile-document.png new file mode 100644 index 0000000..6739841 Binary files /dev/null and b/docs/images/mobile-document.png differ diff --git a/docs/images/mobile-tree.png b/docs/images/mobile-tree.png new file mode 100644 index 0000000..1244cdb Binary files /dev/null and b/docs/images/mobile-tree.png differ diff --git a/docs/images/overview.png b/docs/images/overview.png new file mode 100644 index 0000000..6330f8c Binary files /dev/null and b/docs/images/overview.png differ diff --git a/docs/images/search.png b/docs/images/search.png new file mode 100644 index 0000000..aa851d9 Binary files /dev/null and b/docs/images/search.png differ diff --git a/docs/images/tiles.png b/docs/images/tiles.png new file mode 100644 index 0000000..9a0c5f4 Binary files /dev/null and b/docs/images/tiles.png differ diff --git a/docs/wiki/API.md b/docs/wiki/API.md new file mode 100644 index 0000000..b28c148 --- /dev/null +++ b/docs/wiki/API.md @@ -0,0 +1,59 @@ + + +# API + +Everything except the login, the health check and the two share routes +requires the session cookie; `/api/capture` takes a token instead. Media answers `Range` requests, so a video seeks, +sealed or not. + +| method | path | purpose | +| --- | --- | --- | +| `GET` | `/api/health` | public: up, and the database answers — for a supervisor | +| `POST` | `/api/login`, `/api/logout` | session; `/api/logout?everywhere=true` ends every one | +| `GET` | `/api/me` | current user | +| `GET` | `/api/sessions` | every signed-in browser, the one seen last first | +| `DELETE` | `/api/sessions/{id}` | sign another browser out, and lock the vault | +| `GET`/`POST` | `/api/spaces` | the spaces / create one | +| `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`; a file matched by its text has `in_text` and the words around the match as `excerpt`) / create, from a template with `template_id` | +| `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / delete into the trash, subtree and all — or for good, for something already in it | +| `GET`/`DELETE` | `/api/trash` | what was deleted, the last first / empty it | +| `DELETE` | `/api/trash/{id}` | delete one thing in the trash for good | +| `POST` | `/api/trash/{id}/restore` | back where it was deleted from, or to its space when that folder is gone | +| `GET` | `/api/spaces/{id}/board` | the lists and cards of one space | +| `GET` | `/api/agenda` | open cards due within a week or in hand, on every board, whether the diary has today, and its entries `on_this_day` in earlier years | +| `GET` | `/api/diary/days?month=2026-10` | the diary's entries in a month, each with its local `day` | +| `GET` | `/api/diary/around/{id}` | the entries before and after a diary entry, by date | +| `GET` | `/api/nodes/{id}/templates` | the templates of the space the node is in | +| `POST` | `/api/capture` | `{ text, to: "card" \| "diary", space? }`, with a session or `Authorization: Bearer` a token | +| `GET`/`POST` | `/api/tokens` | the capture tokens / make one, `{ name }`, answered with the token this once | +| `DELETE` | `/api/tokens/{id}` | remove a capture token | +| `POST` | `/api/spaces/{id}/lists` | add a list | +| `PUT`/`DELETE` | `/api/lists/{id}` | rename / delete a list and its cards | +| `POST` | `/api/lists/{id}/cards` | add a card | +| `PUT`/`DELETE` | `/api/cards/{id}` | update (title, body, due, repeat, done, link) / delete; finishing a repeating card answers its copy as `again` | +| `POST` | `/api/cards/{id}/move` | to another list and place in it; `again` as above | +| `POST` | `/api/nodes/{id}/move` | reparent, and place among the new siblings | +| `POST` | `/api/nodes/move` | `{ ids, parent_id }`: several into one folder, all or none | +| `POST` | `/api/nodes/{id}/import` | import a folder or zip (multipart) under this node | +| `POST` | `/api/import` | import an export at the root: directories become spaces | +| `GET` | `/api/resolve?path=` | the node behind a slug path, `work/notes/monday` | +| `GET` | `/api/nodes/{id}/revisions` | a document's earlier versions, the newest first | +| `GET` | `/api/nodes/{id}/revisions/{rev}` | one earlier version, with its text | +| `GET` | `/api/nodes/{id}/backlinks` | the documents that link to this one, with the line each does it on | +| `POST`/`DELETE` | `/api/nodes/{id}/share` | mint or rotate / revoke a share link | +| `GET` | `/api/export` | the whole workspace, boards and dates included, as a zip | +| `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now | +| `POST`/`DELETE` | `/api/backup/login` | log the mirror in to Proton, body `{"username","password"}` / forget its session | +| `POST` | `/api/backup/login/totp`, `/api/backup/login/captcha` | the two-factor code `{"code"}`, or the solved human check `{"token"}`, a waiting login asked for | +| `GET`/`POST` | `/api/media` | list / upload (multipart) | +| `GET`/`DELETE` | `/api/media/{id}` | serve (with `Range`) / delete a file nothing in the tree owns | +| `GET` | `/api/spaces/overview` | every space with what was last written in it | +| `POST` | `/api/nodes/{id}/files` | upload files into this folder as file nodes (`?encrypt=true` seals them) | +| `POST` | `/api/nodes/{id}/inspect` | what a certificate, key or keystore file holds (`{password}` opens a protected one) | +| `POST` | `/api/nodes/{id}/encrypt`, `/decrypt` | seal a file, or a folder for good — what is in it and what lands later — or take it out | +| `GET`/`POST`/`PUT` | `/api/vault` | status / set the password once / change it | +| `POST` | `/api/vault/unlock`, `/api/vault/lock` | open / close the vault | +| `GET`/`POST` | `/api/ssh-keys` | the keys that may mount over SFTP / add one | +| `DELETE` | `/api/ssh-keys/{id}` | remove a key, which ends its mounts | +| `POST` | `/api/share/{token}` | public: read a shared document, body `{"key"}`; sets the share's media cookie | +| `GET` | `/api/share/{token}/media/{id}` | public: media inside a shared document, with that cookie | diff --git a/docs/wiki/Backup-and-Mail.md b/docs/wiki/Backup-and-Mail.md new file mode 100644 index 0000000..f072f38 --- /dev/null +++ b/docs/wiki/Backup-and-Mail.md @@ -0,0 +1,97 @@ +# Backup and mail + +## Backing up to Proton Drive + +The server can mirror its data directory into Proton Drive, where it appears as +a device of its own, beside the desktop clients. Everything is encrypted before +it leaves the machine, by the same Rust SDK the Linux client uses, so Proton +stores a workspace it cannot read. + +It is a third-party client. It names itself to Proton as +`external-drive-narl_workspace@-alpha`, carries no Proton branding, +and is not supported by Proton. + +### Logging in + +Once, from the workspace: + +```text +:backup login +``` + +It shows the disclosure above and asks before anything else, then asks for the +password and, when the account has one, the two-factor code. If Proton wants a +human check first, its own page opens in a sheet. An account whose only second +factor is a security key cannot log in from here; add an authenticator app to +it. The mirror starts within a minute; no restart is needed. + +The same login runs from the server's terminal: + +```sh +narl-workspace proton-login +docker compose exec -it workspace narl-workspace proton-login # in Docker +``` + +The password itself is never stored, only the session and the key passphrases +derived at login. They are kept as a `0600` file next to the database, or in +the OS keyring with `--features keyring`. In a container, +`WORKSPACE_SESSION_KEY_FILE` seals that file under a key kept off the data +volume, such as a Docker secret. + +### What it does + +The mirror is one-way and runs after changes: once the workspace has been +quiet for `WORKSPACE_BACKUP_DEBOUNCE_SEC`, with `WORKSPACE_BACKUP_INTERVAL_MIN` +as a backstop. An hour of writing is one backup, not sixty. + +```text +narl-workspace/ the device + data/ the data directory, copied + RESTORE.txt what this is, and how to put it back + diary.db a consistent snapshot + uploads/ every uploaded file + files/ every file in the tree again, as // +``` + +- `diary.db` becomes a new revision on Drive each time, so Proton keeps the + older ones and a mistake that was mirrored can still be undone. +- `files/` is for reading on Drive by name. It follows renames and moves. +- Restoring is copying the contents of `data/` into an empty data directory. +- A delete here does not delete on Drive unless `WORKSPACE_BACKUP_PRUNE` is on. + +| command | what it does | +| --- | --- | +| `:backup` | when the last one finished, and whether it failed | +| `:backup!` | run one now | +| `:backup logout` | forget the session; the mirror stays where it is | +| `narl-workspace proton-status` | account, device, schedule, how much is mirrored | +| `narl-workspace backup-now` | mirror once and exit, for cron | + +## Mail + +Mail is off until `WORKSPACE_SMTP_URL` is set. Then four kinds of plain-text +mail go to `WORKSPACE_MAIL_TO`: + +| kind | when | +| --- | --- | +| `reminder` | at `WORKSPACE_REMINDER_AT`, when nothing was written in the diary that day | +| `card_due` | at `WORKSPACE_CARD_DUE_AT`, for every card due that day, and again once it is overdue | +| `digest` | at `WORKSPACE_DIGEST_AT`: what was written in each space, what was finished, what is still open | +| `login` | a browser that has not signed in before signed in, or a run of wrong passwords | + +`WORKSPACE_MAIL_KINDS` lists the kinds that may be sent. Every "today" is +counted in `WORKSPACE_TIMEZONE`. + +Mail goes through an outbox: a restart cannot send yesterday's reminder twice, +and a relay that refuses cannot lose one. It is retried with a widening gap, up +to an hour apart, and given up after ten refusals. A card mail is checked +against its card before it goes, so a card finished since is not mailed. + +```sh +narl-workspace mail-test # one mail, straight out +narl-workspace mail-status # what is configured, queued and failed +``` + +`compose.override.yml.example` has a [mailpit](https://mailpit.axllent.org/) +service for trying the settings out: point `WORKSPACE_SMTP_URL` at +`smtp://mailpit:1025` and read the mail at . diff --git a/docs/wiki/Boards-and-Agenda.md b/docs/wiki/Boards-and-Agenda.md new file mode 100644 index 0000000..20c979e --- /dev/null +++ b/docs/wiki/Boards-and-Agenda.md @@ -0,0 +1,113 @@ +# Boards and the agenda + +## The board + +Every space except the diary has a board. `b`, the board button on the rail, or +`:board` swaps the tree for it, and `q` swaps back. + +![The board of a space](../images/board.png) + +A new board has three lists: `backlog`, `doing` and `done`. The lists mean +something by where they stand, not by their names: + +- the **first** list is the inbox, where new cards land; +- the **last** list is done. A card is finished exactly when it is in the last + list: moving it there stamps it, moving it out reopens it; +- the lists between are **work in hand**, which the agenda shows. + +`:list ` adds a list in front of the last one, `:name!` renames the list +under the cursor, and `:list!` deletes it with its cards. Done cards older than +a week fold away until `z` shows them. + +| key | what it does | +| --- | --- | +| `h` / `l` | to the list on the left / right | +| `j` / `k` | down / up the cards | +| `J` / `K` | move the card down / up its list | +| `H` / `L` | move the card to the list on the left / right | +| `Space` | into the done list, or back out to the inbox | +| `o`, `c` | a new card on this list | +| `Enter` | the card's notes | +| `D` | due date | +| `R` | rename the card | +| `z` | show done cards older than a week | +| `F` | a letter over every card; type it to jump there | +| `x`, `dd` | delete the card | +| `q`, `Esc` | back to the tree | + +### Capturing a card + +`c` anywhere outside the board opens `:card ` on the command line. The card goes +into the inbox of the space you are in, or of the first space with a board, and +you stay where you were. From outside the browser, see [Capture](Capture.md). + +### Notes + +`Enter` on a card opens its notes: a document named after the card, made in the +root of the space the first time. Closing it goes back to the board, on the +same card. `:card!` while reading a document makes a card that points at it. + +The link is loose on purpose: deleting the document leaves the card, because the +work is still open when its notes are gone. + +### Due dates + +`D` on a card (or `:due `) takes a date the way it is said: + +| you type | it means | +| --- | --- | +| `today`, `td` | today | +| `tm`, `tomorrow` | tomorrow | +| `fri` | the next Friday, never today | +| `+3d`, `+2w` | in three days, in two weeks | +| `10-31` | the next 31 October | +| `2026-10-31` | that day | +| `-` | no due date | + +### Cards that come back + +`:repeat weekly`, or the ↻ button on the rail, makes a card come back once it +is done. The rules are `daily`, `weekly`, `monthly`, `yearly`, and any `+3d`, +`+2w`, `+6m` or `+1y`. `:repeat -` stops it. + +Finishing such a card leaves a copy at the bottom of the inbox, with the same +title, notes and rule, due on the next date. The next date counts on from the +old due date, in `WORKSPACE_TIMEZONE`: + +- a card due at noon stays due at noon across a change of the clocks; +- a card due on the 31st comes back on the last day of a shorter month, and on + the 31st again where there is one; +- a card finished late skips the dates that have passed; +- a card without a due date counts from the moment it was finished. + +The finished card gives up its rule, so reopening it and finishing it again +does not make a second copy. The board and the agenda show the rule as +`↻ weekly` beside the date. + +## The agenda + +`t` (or `:agenda`) gathers what needs you from every board at once. + +![The agenda](../images/agenda.png) + +- **late**, **today** and **this week**: unfinished cards by due date; +- **in hand**: cards in a list between the inbox and done; +- whether the diary has an entry for today; +- **on this day**: diary entries from the same day in earlier years. A + 29 February is remembered on the 28th in the years that have none. + +| key | what it does | +| --- | --- | +| `j` / `k` | down / up, on into the diary entries | +| `Enter` | to the card on its own board, or open the entry | +| `Space` | finish the card where it stands | +| `D` | due date | +| `H` / `L` | to the list left / right on its board | +| `:` | the command line with the agenda kept up, so `:repeat` acts on the card | +| `T` | today's diary entry | +| `t`, `q`, `Esc` | close | + +The agenda opens by itself once a day when something is late or due. The bar +shows the `late` and `today` counts whenever either is above zero. With mail +set up, cards due today are mailed in the morning too; see +[Backup and mail](Backup-and-Mail.md#mail). diff --git a/docs/wiki/Capture.md b/docs/wiki/Capture.md new file mode 100644 index 0000000..87995ee --- /dev/null +++ b/docs/wiki/Capture.md @@ -0,0 +1,56 @@ +# Capture + +A thought arrives when no tab is open. `POST /api/capture` writes it down from +anything that can send a request: a phone shortcut, a script, `curl`. It takes a +token in place of a login. + +## Tokens + +```text +:token add phone makes one, shows it once and copies it +:token lists them, with when each was last used +:token rm phone and that one captures nothing from the next request on +``` + +A token opens this one route and nothing else. It cannot read anything, not even +the list of spaces. Only its SHA-256 is kept on the server, so it is shown once, +when it is made. Wrong tokens slow down the way wrong passwords do, on a line of +their own, so guessing at them does not hold up the login. + +## Sending + +```sh +curl -H "Authorization: Bearer nwc_…" -H 'content-type: application/json' \ + -d '{"text":"call the bank"}' https://your-host/api/capture +``` + +| field | meaning | +| --- | --- | +| `text` | what to write down; for a card, the first line is the title and the rest its notes | +| `to` | `"card"` (the default) or `"diary"` | +| `space` | for a card, the slug of the space whose inbox it goes in | + +- `"to": "card"` puts a card in the inbox of the space named by `space`, or else + of the first space that has a board. +- `"to": "diary"` adds `- 14:05 call the bank` to the end of today's entry, in + `WORKSPACE_TIMEZONE`, and starts the entry from `templates/day` when there is + none yet. + +The answer says where it went: `{"to":"card","id":7,"where":"work/backlog"}`. + +## From a phone + +**iPhone**: in Shortcuts, make a shortcut with *Ask for Input*, then *Get +Contents of URL*: + +- URL: `https://your-host/api/capture` +- Method: `POST` +- Headers: `Authorization` = `Bearer nwc_…` +- Request body: JSON, with `text` set to the *Provided Input*, and `to` set to + `diary` if that is where it should go. + +**Android**: in [HTTP Shortcuts](https://http-shortcuts.rmy.ch/), make a shortcut +with a text-input variable, method `POST`, the same header, and a JSON body +`{"text": "{{input}}"}`. + +Either one can sit on the home screen, or behind the share sheet. diff --git a/docs/wiki/Commands.md b/docs/wiki/Commands.md new file mode 100644 index 0000000..75bb9ce --- /dev/null +++ b/docs/wiki/Commands.md @@ -0,0 +1,222 @@ + + +# Keys and commands + +Everything here is also on `?` (`:help`) inside the app, which draws the same +lists. Keys act on whatever has focus: the row under the cursor in the list, or +the document in the focused tile. Inside the editor the full vim keymap is live, +and `:` opens the command line, where `Tab` completes commands and their +arguments and `↑` / `↓` recall earlier lines. + +## Keys + +### Browsing and reading + +| key | what it does | +| --- | --- | +| `1 … 9 · 0` | switch to that space, like a workspace | +| `Shift 1 … 9` | send the marked nodes, or the selected one, to that space | +| `Tab · Shift-Tab` | next / previous space | +| `w` | overview of every space — hjkl, then Enter | +| `f` | fullscreen the focused pane or tile; again or Esc to leave | +| `Alt-h/j/k/l` | focus the tile that way, from the editor too — left of the leftmost is the list | +| `Alt-Shift-h/j/k/l` | move the focused tile that way | +| `L · Shift-Enter` | open the row in a new tile beside the others | +| `gt / gT` | next / previous tile — in the editor too | +| `Alt-q` | close the focused tile — :close; :only closes the others | +| `j / k · ↓ ↑` | move down / up the list — scroll the document when it has focus | +| `gg / G` | first / last row, or top / bottom of the document — a first g, d, ] or [ waits a second in the status line | +| `Ctrl-D / Ctrl-U` | half a page down / up the open document | +| `zl / zh · zL / zH` | the wide table or code block at the top of the view, sideways — a little, or half its width | +| `]] / \[\[` | next / previous heading of the document — in the editor too | +| `gO` | the headings as a list: j/k, Enter jumps there | +| `F` | hints over the links and buttons in view — type one to follow it | +| `gf / gx` | in the editor: follow the link under the cursor / open it in a tab | +| `gx` | open the file under the cursor in a tab of its own | +| `Enter · l` | open a document, or enter a folder — on an open keystore or sealed file, Enter asks for its password | +| `h` | from the document to the list, from the list up a level | +| `o` | new document here, straight into insert mode | +| `O` | new folder here | +| `i · a` | edit the open document | +| `R` | rename what is under the cursor — document, folder or card — starting from its name | +| `m / p` | pick nodes up, then drop them all into the folder you walk to | +| `M` | pick up the whole folder — again puts it all back | +| `Ctrl / Shift-click` | pick a row up · every row up to it — drag them onto a folder | +| `t` | agenda — late, due this week, in hand, on every board | +| `T` | today's diary entry, started if there is none — from templates/day when the diary has one | +| `C` | the diary by month: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day | +| `[d / ]d` | the diary entry before / after the one open, by date | +| `Ctrl-P` | find anything, in every space — Enter goes to the result lit | +| `b` | open this space's board | +| `c` | capture a card into this space's inbox, without leaving here | +| `Esc · q` | close the document, and its tile when there are several | +| `/` | full-text search, inside the current space and its text files — Tab or Ctrl-N/P walk the results, Enter opens one | +| `n / N` | next / previous search result, opened beside the results — :noh clears the search | +| `s` | toggle sharing for the document — asks before taking it back | +| `y` | mint a new share link and copy it — asks, the old one stops working | +| `x · dd` | delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N) | +| `u` | undo the last delete: it comes back out of the trash — :trash has the rest | +| `gh` | the open document's earlier versions, as diffs — Enter brings one back | +| `gb` | the documents that link to the open one — \[\[title]] links by title, \[\[ completes one while writing | +| `:` | command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before | +| `Ctrl-S` | save, from anywhere | +| `?` | this screen — in the editor too | + +### On a board + +The first list is the inbox, the last list is done. + +| key | what it does | +| --- | --- | +| `h / l` | to the list left / right | +| `j / k` | down / up the cards | +| `gg / G` | first / last card of the list | +| `J / K` | move the card down / up its list | +| `H / L` | move the card to the list left / right | +| `Space` | into the done list, or back out to the inbox | +| `Enter` | the card's notes — a document, made on first use; q comes back | +| `o · c` | new card on this list | +| `D` | due date: fri, tm, +3d, 10-31 … | +| `R` | rename the card, or the list when it is empty | +| `z` | show done cards older than a week | +| `F` | a letter over every card in view — type it to put the cursor there | +| `r` | read the board again, for changes made elsewhere | +| `x · dd` | delete the card (asks y/N) | +| `q · Esc` | back to the tree | + +### In the agenda + +| key | what it does | +| --- | --- | +| `j / k` | down / up the cards, then what was written on this day in earlier years | +| `Enter` | to the card, on its own board — or to the entry | +| `Space` | done, where it stands | +| `D` | due date: fri, tm, +3d … — :due on the line | +| `:` | the line, with the agenda kept up — :repeat weekly acts on the card | +| `H / L` | to the list left / right on its board | +| `T` | today's diary entry | +| `t · q · Esc` | close the agenda | + +## Commands + +A command takes a `!` for its forcing variant where one exists, the way vim's +do: `:w!`, `:space! `, `:import!`. + +### Spaces & folders + +| command | what it does | +| --- | --- | +| `:space, :sp [name]` | switch to a space, or list them | +| `:space!` | create a space with that name and switch to it | +| `:mkdir ` | make a folder here | +| `:cd ` | enter a folder, .. goes up, / back to the space | +| `:import, :imp` | upload a folder into this one — markdown becomes documents, links follow | +| `:import!` | import loose files, or a .zip of a folder; :import! / pours an :export! back in, spaces and boards and all | +| `:move, :mv ` | move the marked nodes, or else the selected one, into a folder here, up one level, or to the root of the space | +| `:unmark` | put down everything picked up with m | +| `:rmspace, :rms [name]` | delete a space and everything in it, into the trash (asks first) | +| `:trash [purge]` | what was deleted — j/k, Enter brings it back, x deletes it for good; :trash purge empties it | + +### Boards + +| command | what it does | +| --- | --- | +| `:board` | show this space's board, or go back to the tree | +| `:card, :c ` | add a card — on the board to the list under the cursor, elsewhere to this space's inbox | +| `:card!` | add a card linked to the open document — Enter on it opens the document | +| `:due <when\|->` | due date of the card — on the board, or highlighted in the agenda: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears | +| `:repeat <rule\|->` | once the card is done, a copy comes back in the inbox: daily, weekly, monthly, yearly, +3d, +2w, +6m — - stops it | +| `:done` | move the selected card into the done list, or back out to the inbox | +| `:list <name>` | add a column to the board | +| `:list!` | delete the column under the cursor and every card on it | +| `:agenda, :ag` | what is late, due this week, or in hand — across every board | + +### Documents + +| command | what it does | +| --- | --- | +| `:new, :n, :o [template] [yyyy-mm-dd] [name]` | start a new document here — from one of the space's templates, dated, named; in the diary from templates/day | +| `:cal, :calendar` | the diary by month — h/l a day, j/k a week, H/L a month, Enter opens the day or starts it | +| `:today` | open today's diary entry, starting one if there is none yet | +| `:next` | open the next node down the list | +| `:prev` | open the previous node | +| `:first` | open the first node in the list | +| `:last` | open the last node in the list | +| `:random` | open something here at random — good for re-reading | +| `:delete, :d, :rm [id]` | delete a node, and everything under it, into the trash (asks first) — u brings it back | +| `:history, :hist` | the open document's earlier versions as diffs — j/k, Enter brings one back (gh) | +| `:backlinks, :bl` | the documents that link to the open one — j/k, Enter opens one (gb) | +| `:source` | show an open markdown file as written, or rendered again | +| `:outline, :toc` | the open document's headings to pick from — gO, in the editor too | +| `:search, :se [text]` | full-text search in this space (empty clears) | +| `:search!` | full-text search across every space — Ctrl-P | +| `:clear, :noh` | clear the search filter | +| `:reload, :r` | re-read the current folder from the server | + +### Tiles + +| command | what it does | +| --- | --- | +| `:close, :clo` | close the focused tile — Alt-q; asks when its draft could not be written | +| `:only, :on` | close every tile but the focused one | +| `:ls, :buffers, :files` | list the open tiles, % on the focused one | +| `:buffer, :b <n\|name>` | focus a tile by its number in :ls, or by the start of its name | +| `:bnext, :bn` | the next tile — gt | +| `:bprevious, :bp, :bN` | the previous tile — gT | + +### Writing + +| command | what it does | +| --- | --- | +| `:write, :w` | save the open document | +| `:write!` | save even over a newer version saved somewhere else since this one was opened | +| `:wq, :x` | save and leave insert/editor mode | +| `:wq!` | save over a newer version, as :w! does, and leave the editor | +| `:quit, :q` | close the editor, or the document when already reading | +| `:quit!` | discard changes and close the editor | +| `:edit, :e [id]` | edit the open document, or open a node by id | +| `:edit!` | throw away the draft and re-read the document from the server | +| `:name, :title, :rename <text>` | rename what is under the cursor — document, folder, or card on a board; bare, while writing, into the title | +| `:name!` | rename the space you are in — on the board, the list under the cursor | +| `:date <yyyy-mm-dd>` | re-date the open document | +| `:upload, :up` | while writing, attach files at the cursor — otherwise put files into this folder | +| `:upload!` | put files into this folder sealed in the vault — encrypted before they touch the disk | +| `:media` | browse everything you have uploaded — j/k, i inserts it while writing, y copies its address, x deletes | + +### Sharing & export + +| command | what it does | +| --- | --- | +| `:share` | publish the document behind an unguessable link and copy it | +| `:unshare` | revoke the share link | +| `:link` | mint a new share link for the document and copy it | +| `:copy, :yank` | copy the whole document to the clipboard as markdown | +| `:export, :exp` | download the open document as a .md file | +| `:export!` | download the whole workspace — every space, document and file — as a zip | +| `:download, :dl` | download the file under the cursor, or the open one — the open document goes as :export writes it | + +### Vault + +| command | what it does | +| --- | --- | +| `:vault` | whether the vault is open — or, the first time, set its password | +| `:unlock` | open the vault with its password, so sealed files can be read | +| `:lock` | close the vault now — it also closes itself when left alone, and on logout | +| `:passwd` | change the vault's password — every sealed file is then re-sealed under a new key, while the vault is open | +| `:encrypt, :seal` | seal the file under the cursor in the vault — a folder stays sealed: every file in it, and every file added later | +| `:decrypt, :unseal` | take the file under the cursor back out of the vault — on a sealed folder, unseal it and every file inside | + +### Session + +| command | what it does | +| --- | --- | +| `:stats` | word, line and node counts | +| `:set <option>` | vim, novim, clipboard, noclipboard, board, noboard | +| `:backup [login <proton-username> \| logout]` | when the workspace was last mirrored to proton drive — login sets the mirror up, logout forgets its session | +| `:backup!` | back up to proton drive now, rather than when the workspace falls quiet | +| `:sessions [rm <n>]` | every browser signed in, numbered — rm signs one of them out, and locks the vault | +| `:sshkey [add <name> <key> \| rm <name> \| vault <name> on\|off]` | list, add or remove keys allowed to mount the workspace over sftp, and say which may read sealed files | +| `:token [add <name> \| rm <name>]` | list, add or remove tokens that capture into the workspace from outside it — add shows the token once and copies it | +| `:help, :h` | show the key and command reference | +| `:logout` | end the session | +| `:logout!` | end every session, on every device — for a lost phone or a shared computer | diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md new file mode 100644 index 0000000..701969a --- /dev/null +++ b/docs/wiki/Configuration.md @@ -0,0 +1,64 @@ +<!-- Generated by scripts/wiki.ts from .env.example. Edit that, then run `make wiki`. --> + +# Configuration + +Every setting is an environment variable, read at start, usually from a `.env` +file beside the binary (`cp .env.example .env`). The older `DIARY_<NAME>` +spelling of each is still accepted. A value that is set but cannot be read stops +the start with the reason rather than falling back to the default. + +## Basics + +| setting | example | what it does | +| --- | --- | --- | +| `WORKSPACE_USER` | `narl` | The one and only account. Both are checked in constant time on login. | +| `WORKSPACE_PASSWORD` | `change-me` | See the row above. | +| `WORKSPACE_SECRET` | `replace-with-32-random-characters` | Signs session cookies. Rotate it to log every device out. 32+ random chars. Generate one with: openssl rand -base64 32 | +| `WORKSPACE_DATA_DIR` | `./data` | Where the SQLite database and uploaded media live. | +| `WORKSPACE_BIND` | `127.0.0.1:4242` | Address to listen on. Put a TLS terminating reverse proxy in front of this. | +| `WORKSPACE_SESSION_DAYS` | `30` | How long a login lasts, in days. | +| `WORKSPACE_MAX_UPLOAD_MB` | `1024` | Largest upload, in megabytes: one request, or what an imported zip unpacks to. Uploads stream to disk, so this bounds the disk, not the memory. | +| `WORKSPACE_SECURE_COOKIE` | `0` | Set to 1 once the app is served over HTTPS: the cookie gets `Secure` and the `__Host-` prefix (everyone is logged out once), and browsers are told to use HTTPS only (HSTS). The server refuses to start with the example secret or password above still in place. | + +## SFTP mount + +| setting | example | what it does | +| --- | --- | --- | +| `WORKSPACE_SFTP_BIND` | `127.0.0.1:2222` | Off until this is set. Once it's up, register a key with `:sshkey add <name> <ssh-ed25519 ...>` in the app, then `sshfs -P <port> anything@host:/ mnt/` (the username is ignored — only the key decides who gets in). On the mount, `mkdir` at the root makes a space and anywhere else a folder; a new `.md` file is a new document. A leading `# Heading` is the document's title. Editor temp, swap and backup files are kept in memory for the session only, and renaming one onto a `.md` name saves it into that document. Under Docker, bind 0.0.0.0 (not 127.0.0.1) as the app listens inside the container — the host-side address in compose.yml's `ports:` is what actually restricts who can reach it. | + +## Proton Drive backup + +Off until `:backup login <username>` in the workspace (or `narl-workspace proton-login`) stores a session under WORKSPACE_DATA_DIR. These only shape how often the mirror runs. + +| setting | example | what it does | +| --- | --- | --- | +| `WORKSPACE_PROTON_DEVICE` | `narl-workspace` | The device the server registers in Proton Drive; its sync root is the folder the diary is mirrored into. | +| `WORKSPACE_BACKUP_INTERVAL_MIN` | `60` | Backstop between runs, in minutes. 0 leaves it change-driven only. | +| `WORKSPACE_BACKUP_DEBOUNCE_SEC` | `300` | How long the diary must be quiet after a change before it is mirrored, so a writing session uploads once rather than after every save. | +| `WORKSPACE_BACKUP_PRUNE` | `0` | Set to 1 to let a deletion here trash the mirrored file there. Off by default: a backup that forgets on command is one accident from being none. | +| `WORKSPACE_SESSION_KEY_FILE` | `/run/secrets/session_key` | Seal the stored Proton session under a key kept off the data volume, so a copy of the volume alone carries no usable session. Point it at a Docker secret (see compose.override.yml.example); `openssl rand -base64 32` makes the key. Losing the key costs a new `proton-login`, nothing more. Unset by default. | + +## Notification mail + +Off until WORKSPACE_SMTP_URL is set. Nothing is even queued while it is unset. + +| setting | example | what it does | +| --- | --- | --- | +| `WORKSPACE_SMTP_URL` | `smtps://mail.example.org:465` | The relay. `smtps://host:465` is implicit TLS, `smtp://host:587?tls=required` is STARTTLS, and a bare `smtp://host:1025` is plain — which only belongs on a local mail catcher. Credentials may live in the URL or in the two variables below. Unset by default. | +| `WORKSPACE_SMTP_USER` | | See the row above. Unset by default. | +| `WORKSPACE_SMTP_PASSWORD` | | See the row above. Unset by default. | +| `WORKSPACE_MAIL_FROM` | `workspace@example.org` | Where the mail comes from and goes to. FROM defaults to TO. Unset by default. | +| `WORKSPACE_MAIL_TO` | `you@example.org` | See the row above. Unset by default. | +| `WORKSPACE_TIMEZONE` | `Europe/Berlin` | "Did I write today?" and "is this due today?" are local-day questions, and the answer changes with the timezone rather than with a fixed offset. | +| `WORKSPACE_REMINDER_AT` | `20:00` | When to ask about the diary, if nothing was written in it that day. `off` turns it into no reminder at all. | +| `WORKSPACE_CARD_DUE_AT` | `08:00` | When to mail about cards that are due today or already overdue. | +| `WORKSPACE_DIGEST_AT` | `sun 18:00` | Weekday and time of the weekly digest. | +| `WORKSPACE_MAIL_KINDS` | `reminder,card_due,digest,login` | Which of the four kinds may be sent. Drop one from the list to switch it off. | + +## Vault, trash and history + +| setting | example | what it does | +| --- | --- | --- | +| `WORKSPACE_VAULT_IDLE_MIN` | `15` | Minutes the vault stays open with nothing using it, before it locks itself. It also locks on logout and whenever the server restarts. | +| `WORKSPACE_TRASH_DAYS` | `30` | Days a deleted document, folder, file or space waits in the trash, where :trash can bring it back, before it and its files are deleted for good. | +| `WORKSPACE_REVISION_DAYS` | `90` | Days a document's earlier versions are kept for :history. The newest 20 of every document are kept however old they are. | diff --git a/docs/wiki/Deploying.md b/docs/wiki/Deploying.md new file mode 100644 index 0000000..4a1a354 --- /dev/null +++ b/docs/wiki/Deploying.md @@ -0,0 +1,58 @@ +# Deploying + +The workspace is one binary with the frontend inside, and one data directory. +It needs no database server and no runtime on the host. + +## On a server + +1. Build it: `make build` makes `target/release/narl-workspace`. +2. Copy the binary and a `.env` to the server. Set `WORKSPACE_USER`, + `WORKSPACE_PASSWORD` and `WORKSPACE_SECRET`; the server will not start with + the examples from `.env.example`. +3. Put a reverse proxy that terminates TLS in front of `WORKSPACE_BIND`, and + set `WORKSPACE_SECURE_COOKIE=1`. +4. Keep `WORKSPACE_DATA_DIR` safe. It is the whole state: the database and the + uploads. + +The proxy must: + +- pass the `Host` header through unchanged, because a write that a browser says + came from another origin is refused; +- allow request bodies as large as `WORKSPACE_MAX_UPLOAD_MB`; +- not weaken the security headers the app sends (`Content-Security-Policy`, + `Referrer-Policy: no-referrer` and the rest). + +`GET /api/health` answers without a login, for a supervisor. `SIGTERM` gives +requests in flight eight seconds to finish. + +## Docker + +```sh +cp .env.example .env # set WORKSPACE_USER, WORKSPACE_PASSWORD, WORKSPACE_SECRET +docker compose up -d --build +``` + +The image builds the frontend with bun and the binary with cargo, then ships +only the binary on `debian:trixie-slim`, running as an unprivileged user. The +database and uploads live in the `diary-data` volume. Keep +`COMPOSE_PROJECT_NAME=narl-diary` if you rename the directory, or compose looks +for the volume under a new name and finds an empty one. + +The port is published on `127.0.0.1:4242` only. To serve it through traefik, +copy `compose.override.yml.example` to `compose.override.yml` and set the host. +It joins the external `narl` network, drops the published port, and sets +`WORKSPACE_SECURE_COOKIE=1`. + +## Sessions and security + +- Every signed-in browser is a row on the server. `:sessions` lists them, + `:sessions rm <n>` signs one out and locks the vault, and `:logout!` signs + out every one. +- Changing `WORKSPACE_SECRET` or `WORKSPACE_PASSWORD` also signs everyone out. +- Wrong passwords are answered more slowly each time, up to 30 seconds. A + browser that has logged in before is slowed only by its own mistakes. +- With `WORKSPACE_SECURE_COOKIE=1` the cookie is `Secure` and `__Host-` + prefixed, and every answer carries HSTS for a year. +- Uploads are only served as types that cannot run in the browser. + +See [Configuration](Configuration.md) for every setting. diff --git a/docs/wiki/Development.md b/docs/wiki/Development.md new file mode 100644 index 0000000..098b82a --- /dev/null +++ b/docs/wiki/Development.md @@ -0,0 +1,107 @@ +# Development + +## Running it from source + +```sh +make dev-api # cargo run, serving the API on :4242 +make dev-web # vite on :4243, proxying /api to :4242 +``` + +Open <http://127.0.0.1:4243>. The Rust build embeds `web/dist`, so build the +frontend once (`cd web && bun install && bun run build`) before the first +`cargo build`; a debug build reads `web/dist` from disk after that. + +Point `WORKSPACE_DATA_DIR` at a throwaway directory while trying things, so the +real data stays out of it. + +## The code + +| where | what | +| --- | --- | +| `src/main.rs` | start-up, the router, security headers, background loops | +| `src/routes/` | one module per area of the API: `nodes`, `boards`, `trash`, `revisions`, `links`, `capture`, `diary`, `media`, `file_text`, `seal`, `share`, `import`, `export` … | +| `src/sftp/` | the SFTP mount | +| `src/backup/` | the Proton Drive mirror | +| `src/mail/`, `src/notify/` | the outbox, and what is worth a mail | +| `src/vault.rs` | the vault's keys and file format | +| `src/sweep.rs` | the daily clean-up: trash, old versions, unreferenced uploads | +| `migrations/` | the SQLite schema, one numbered file per change | +| `web/src/App.svelte` | the keys | +| `web/src/lib/store.svelte.ts` | the client's state, and every call to the API | +| `web/src/lib/commands.ts` | the `:` commands | +| `web/src/lib/Help.svelte` | the `?` screen | + +The database is one SQLite file with FTS5 for search. Every migration is +applied at start, after a copy of the database is kept as +`diary.db.pre-<version>`. Many rules are enforced by triggers as well as in the +code, so no path can break them: a sealed file has no indexed text, a file in a +sealed folder is sealed, the diary cannot be deleted. + +## Checks + +```sh +make check +``` + +runs what CI runs: + +- `cargo fmt --check` +- `cargo clippy --all-targets -- -D warnings` +- `cargo test` +- `bun run check` in `web/` (svelte-check) + +The Rust tests run against an in-memory database; most modules have a test +block at the bottom. + +## CI + +`.github/workflows/ci.yml` runs on every push and pull request: + +- **rust**: builds the frontend (the binary embeds it), then format, clippy + and the tests; +- **web**: svelte-check; +- **docs**: checks that the generated wiki pages are up to date and that every + link between the pages resolves. + +`.github/workflows/wiki.yml` publishes `docs/wiki` to the GitHub wiki on every +push to `main` that changes it, or changes a file the generated pages are read +from. + +## The wiki + +The pages live in `docs/wiki` and the pictures in `docs/images`. Edit them +there; an edit on GitHub's wiki page is overwritten by the next publish. + +Three pages are generated by `scripts/wiki.ts`: + +| page | read from | +| --- | --- | +| [Keys and commands](Commands.md) | `web/src/lib/Help.svelte` and `web/src/lib/commands.ts` | +| [Configuration](Configuration.md) | `.env.example` | +| [API](API.md) | the API section of `README.md` | + +After changing one of those, run `make wiki` and commit the result. CI fails +when they are stale. + +Links between pages are written as `Page-Name.md` so that they work when the +files are read in the repository; the publish step drops the `.md` for the +wiki. + +## Screenshots + +```sh +make screenshots +``` + +builds the app, starts it on a temporary data directory with a password made +up for the run, fills it with an example workspace, and drives a headless +Chromium through the views the docs show. The pictures are written to +`docs/images`. It needs `chromium` on the path (or `CHROMIUM=/path/to/chrome`), +and shrinks the pictures with ImageMagick when it is installed. + +## Conventions + +- One finished feature per commit, with the README, `:help` and the tests in + the same commit. +- Commit messages are `area: a sentence`, followed by prose about why. +- Comments explain why, not what. diff --git a/docs/wiki/Diary.md b/docs/wiki/Diary.md new file mode 100644 index 0000000..b8eec0e --- /dev/null +++ b/docs/wiki/Diary.md @@ -0,0 +1,58 @@ +# The diary + +The space with the diary role is a journal. Each document in it is an entry, +filed under the day it is about. + +- `T` (or `:today`) opens today's entry, and starts it when there is none. +- `[d` and `]d` step to the entry before or after the one open, by date. +- `:date 2026-10-01` files the open entry under another day. +- `:new 2026-10-01` starts an entry for a day that has passed. + +Days are counted in `WORKSPACE_TIMEZONE`, the same as the evening reminder, so +an entry written after midnight on a trip still lands on the right day. + +## The calendar + +`C` (or `:cal`) shows the diary a month at a time. + +![The diary by month](../images/calendar.png) + +| key | what it does | +| --- | --- | +| `h` / `l` | a day back / forward | +| `j` / `k` | a week forward / back | +| `H` / `L` | a month back / forward | +| `.` | today | +| `Enter` | open the day's entry, or start one dated that day | +| `C`, `Esc` | close | + +A dot marks a day with an entry. A number says how many when there are more +than one. + +## The day template + +When the diary has a `templates` folder with a document called `day` in it, +every new entry starts from it: from `T`, `o`, the calendar, or a +[capture](Capture.md) to the diary. For example: + +```markdown +## {{weekday}} + +### What happened + +### What I am thankful for +``` + +See [Writing](Writing.md#templates) for the placeholders. The templates are not +entries: they are not on the calendar and do not count as having written today. + +## On this day + +The [agenda](Boards-and-Agenda.md#the-agenda) ends with what you wrote on the +same day in earlier years. `j` walks into them and `Enter` opens one. + +## The reminder + +With mail set up, an evening reminder arrives at `WORKSPACE_REMINDER_AT` +(20:00 by default) when nothing was written in the diary that day. See +[Backup and mail](Backup-and-Mail.md#mail). diff --git a/docs/wiki/Files-and-Search.md b/docs/wiki/Files-and-Search.md new file mode 100644 index 0000000..9831fe7 --- /dev/null +++ b/docs/wiki/Files-and-Search.md @@ -0,0 +1,77 @@ +# Files and search + +## Files + +A file is a node like a document, only with bytes instead of text. It sits in a +folder beside the notes about it, shows its size in the list, and `Enter` opens +it. + +Put files in: + +- drop them onto the list; +- `:upload` picks files for the folder you are in; +- `:import` uploads a whole folder, and `:import!` a zip + (see [Not losing things](Not-Losing-Things.md#import)); +- copy them in over the [SFTP mount](SFTP.md). + +Each upload is written to disk as it arrives, never held in memory, so the only +limit is `WORKSPACE_MAX_UPLOAD_MB` (1024 by default) per request. + +### What opens in place + +- images, video and audio; +- PDFs, in the browser's own viewer; +- text and source code of any kind, with line numbers, up to the first + 256 KiB; +- markdown, rendered, with `:source` to see it as written; +- CSV and TSV, as a table of the first 500 rows; +- certificates, keys and keystores (`.pem`, `.crt`, `.cer`, `.der`, `.csr`, + `.key`, `.p8`, `.p12`, `.pfx`), read on the server. + +![A CSV file as a table](../images/file-table.png) + +A certificate shows its subject, issuer, validity, key, names, usage and +fingerprints, and which key belongs to which certificate. A `.p12` or an +encrypted key asks for its password, which opens it for that one look and is +not stored. Nothing of a private key is sent back but its algorithm and size. + +![A certificate read on the server](../images/file-certificate.png) + +Anything else is a download: `:download` (`:dl`) fetches the file under the +cursor, and `gx` opens it in a browser tab of its own. Files are renamed with +`:name`, moved like documents, and deleted into the trash with `x`. + +Uploads are only ever served as types that cannot run in the browser. An HTML +file or an SVG, for example, downloads instead of opening. + +## Search + +| key | searches | +| --- | --- | +| `/` | the space you are in | +| `Ctrl-P` | every space | + +Results appear as you type, ranked by relevance, each with the space and +folder it is in. `Tab` or `Ctrl-N` / `Ctrl-P` walk them without leaving the +line, and `Enter` opens the one lit. Afterwards `n` and `N` open the next and +previous result beside the list, and `:noh` clears the search. + +![A search that found a line in a log file](../images/search.png) + +Each word you type matches the start of a word, so `inge` finds "ingest". All +the words must be there. Punctuation is ignored. + +### Inside files + +A search reads the text files in the tree as well as the documents. A file +counts as text by its type, or when its first 8 KiB read as UTF-8 with no NUL +byte, which covers `.log` files and Makefiles. The first 256 KiB of each is +indexed, the same part the viewer shows. A file found by its text shows the +words around the match under its name. + +The text is read when a file arrives, by upload, import or the SFTP mount, +again when its bytes change, and at start for any file still missing it. PDFs, +office documents and images are not read. + +A sealed file is never indexed; see [The vault](Vault.md). Sealing a file drops +its text in the same step, and decrypting it reads it again. diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md new file mode 100644 index 0000000..d90e368 --- /dev/null +++ b/docs/wiki/Getting-Started.md @@ -0,0 +1,88 @@ +# Getting started + +## Run it + +You need Rust (1.96 or newer) and [bun](https://bun.sh) to build it. The result +is one binary with the whole frontend inside, so the machine it runs on needs +neither. + +```sh +git clone https://github.com/narrrl/narl-workspace +cd narl-workspace +cp .env.example .env +``` + +Open `.env` and set three things: + +- `WORKSPACE_USER`: the name you log in with. +- `WORKSPACE_PASSWORD`: the password. The server will not start while it is + still `change-me`. +- `WORKSPACE_SECRET`: 32 random characters that sign the login cookie. Make + one with `openssl rand -base64 32`. + +Then build and start it: + +```sh +make build +./target/release/narl-workspace +``` + +and open <http://127.0.0.1:4242>. + +With Docker instead, `docker compose up -d --build` does the same; see +[Deploying](Deploying.md). Every other setting has a default that works on +your own machine. [Configuration](Configuration.md) lists them all. + +## Log in + +![The login](../images/login.png) + +The workspace has one account, the one in `.env`. Wrong passwords are answered +more slowly each time. A browser that has logged in before is slowed only by +its own mistakes, so someone guessing from elsewhere cannot lock you out. + +## Find your way around + +A new workspace has three spaces: `diary`, `work` and `uni`. The bar along +the top shows them as numbered pills. `1`, `2` and `3` switch between them, +and `Tab` steps to the next one. + +The screen has four parts: + +- **the bar**, at the top: the spaces, where you are in the tree, how many + cards are late or due today, and the clock; +- **the list**, on the left: what is in the folder you are in; +- **the tiles**, on the right: the documents and files you have open; +- **the status line**, at the bottom: the mode you are in, and what the last + key or command did. + +The rail of buttons on the right does the same as the keys, for a mouse. + +## The first ten minutes + +1. Press `2` to go to `work`. +2. Press `o`. A new document opens in the editor, in insert mode. Write + something. +3. Press `Esc` to leave insert mode, then type `:name Plans for the week` and + `Enter` to give it a name. There is no need to save: the document is saved + a couple of seconds after you stop typing. `:w` saves at once anyway. +4. Type `:q` to close it. It is in the list now, under today's date. +5. Press `O` (capital). The command line opens with `:mkdir ` already typed; + add a name and press `Enter`. `l` or `Enter` on the folder goes into it, and + `h` comes back up. +6. Press `c`, type `try the board`, and press `Enter`. That is a card in the + inbox of this space's board. Press `b` to see the board, and `b` again to go + back. +7. Press `T`. That is today's diary entry, started for you. +8. Press `?` for the list of every key and command, and `Esc` to close it. + +On a phone the same things are buttons: the dock at the bottom has a new +document, a new folder, the board, today's entry, an upload and a search, and +the `⋯` on a row (or a long press on it) has what can be done to that row. + +![The workspace on a phone](../images/mobile-tree.png) + +## Next + +[The guide](Guide.md) goes through a whole day with the workspace and +explains each part as it comes up. diff --git a/docs/wiki/Guide.md b/docs/wiki/Guide.md new file mode 100644 index 0000000..fa1db4c --- /dev/null +++ b/docs/wiki/Guide.md @@ -0,0 +1,183 @@ +# The guide: a day with the workspace + +This page follows one ordinary day: the agenda in the morning, notes and cards +at work, a lecture in the afternoon, a thought on the way home, and the diary in +the evening. Each part of the workspace is explained where it first comes up, +with a link to the page that has the rest of it. + +The keys are the desktop's. On a phone, every one of these has a button; see +[On a phone](#on-a-phone) at the end. + +## Morning: what needs you + +The first time you open the workspace each day, the **agenda** opens by itself +when anything is late or due. Any other time, press `t`. + +![The agenda](../images/agenda.png) + +It gathers the unfinished cards of every board into four groups: + +- **late**: past their due date; +- **today**: due today; +- **this week**: due in the next seven days; +- **in hand**: in a list between the inbox and done, which means work has + started on them. + +Under the cards it says whether the diary has an entry for today, and lists +what you wrote on this day in earlier years. + +Walk it with `j` and `k`. On a card: + +- `Space` finishes it, where it stands; +- `D` gives it a due date: `fri`, `tm`, `+3d`, `10-31`; +- `H` and `L` move it to the list left or right on its board; +- `Enter` goes to the card on its own board. + +`Esc` or `t` closes the agenda. The bar keeps showing the `late` and `today` +counts while either is above zero. More in [Boards and the agenda](Boards-and-Agenda.md). + +## At work: spaces, documents and tiles + +Press `2` for the `work` space. A **space** is one area of your life, with its +own folders, its own board and its own search. Make more with +`:space! <name>`; `w` shows all of them at once, with what was last written in +each. + +![Every space at a glance](../images/overview.png) + +The list on the left shows the folder you are in. `j` and `k` move, `l` or +`Enter` opens what is under the cursor, `h` goes up a level. Folders come first, +then documents grouped by the day they were written. + +Open the project notes with `Enter`. To read a second document beside it, move +to it and press `L` instead: it opens in a **tile** of its own. + +![Two documents in tiles](../images/tiles.png) + +`Alt-h` and `Alt-l` move between the tiles and back to the list, `Alt-q` +closes one, and `f` makes the focused one fill the screen. Each space remembers +its tiles, so switching away and back finds them as they were. More in +[Spaces and documents](Spaces-and-Documents.md). + +### Writing + +`i` edits the open document. The editor is CodeMirror with real vim keys: +`ciw`, `dd`, visual mode, macros, `.` and the rest. `Esc` leaves insert mode, +and `:q` leaves the editor. + +![The editor](../images/editor.png) + +The document is markdown. Tables, code blocks, task lists and +math (`$x^2$`, or `$$…$$` on its own) are drawn when you read it. Link to +another document by its title, `[[Runbook]]`; typing `[[` offers the titles to +pick from. When the other document is renamed or moved, the link is rewritten +to follow it. + +You do not need to save. Every keystroke goes to the browser's storage at +once, and to the server a couple of seconds after you stop typing. If the +network is gone, the draft waits in the browser and is sent when it comes back. +More in [Writing](Writing.md). + +### Cards + +In a meeting someone asks you to renew a certificate. Press `c`, type +`Renew the gateway certificate`, and `Enter`. The card is in this space's +inbox, and you have not left the document you were reading. + +Press `b` to see the **board**. + +![The board](../images/board.png) + +The first list is the inbox and the last is done; the ones between are work in +hand. `h`/`l` move between lists and `j`/`k` between cards. `H`/`L` move a +card across, `Space` finishes it, `D` sets its due date, and `Enter` opens its +notes: a document made for the card the first time you ask. `:repeat weekly` +makes a card come back in the inbox each time it is finished. `q` goes back to +the tree. + +### Finding things + +`/` searches the space you are in, as you type. `Ctrl-P` searches every space. +The search reads the documents and the text files in the tree: a log file is +found by a line inside it, and the result shows the words around the match. + +![A search that found a log line](../images/search.png) + +`Tab` walks the results without leaving the line, `Enter` opens one, and +`n`/`N` step through them afterwards. More in [Files and search](Files-and-Search.md). + +## Afternoon: a lecture + +Press `3` for `uni`. The lecture notes have formulas in them, written as +LaTeX between dollar signs and drawn by KaTeX: + +![Math in a document](../images/math.png) + +The slides came as a PDF. Drop it onto the list, or `:upload`, and it is a +file in the folder, beside the notes about it. `Enter` opens a PDF, an image, a +video, a CSV table, a text or source file, or a certificate in place. Anything +else downloads with `:dl`. + +![A CSV file as a table](../images/file-table.png) + +A scan of something private, like an ID or a contract, goes in with +`:upload!` instead. That seals it in the **vault**, under a password that is +never stored. See [The vault](Vault.md). + +## On the way home: capture + +A thought arrives when no tab is open. `:token add phone` makes a token. With +it, an iPhone Shortcut, an Android HTTP Shortcut or a single `curl` puts a card +in an inbox, or a line in today's diary entry: + +```sh +curl -H "Authorization: Bearer nwc_…" -H 'content-type: application/json' \ + -d '{"text":"call the bank"}' https://your-host/api/capture +``` + +The token can do this and nothing else. See [Capture](Capture.md). + +## Evening: the diary + +Press `T` for today's entry. If there is none yet it is started, from the +diary's `templates/day` document when there is one, and opens ready to write. + +`C` shows the diary by month. A dot marks a day with an entry; `h`/`l`, +`j`/`k` and `H`/`L` move by day, week and month, and `Enter` opens the day, +or starts an entry for it. In an entry, `[d` and `]d` step to the one before +and after. + +![The diary by month](../images/calendar.png) + +If nothing was written in the diary by the evening and mail is set up, a +reminder arrives at `WORKSPACE_REMINDER_AT`. More in [The diary](Diary.md). + +## When something goes wrong + +- **Deleted the wrong thing?** `u` brings the last delete back. `:trash` lists + everything deleted in the last 30 days. +- **Overwrote a paragraph?** `gh` lists the document's earlier versions as diffs + against the text as it is now, and `Enter` brings one back. + + ![Earlier versions of a document](../images/history.png) + +- **Saved in two places?** A document saved somewhere else since you opened it + is not overwritten: `:w` says so, `:e!` takes the other version, and `:w!` + writes yours anyway. + +More in [Not losing things](Not-Losing-Things.md). + +## On a phone + +On a touch screen the workspace drops the modal keys, and under 768 pixels +wide the list and the open document take the whole screen in turn. A dock along +the bottom has a new document, a new folder, the board, today's entry, an +upload, the trash and a search. The `⋯` on a row, or a long press, has +everything that can be done to it: rename, move, share, seal, delete. The agenda +has a done button on each card. + +| | | +| --- | --- | +| ![The tree on a phone](../images/mobile-tree.png) | ![A document on a phone](../images/mobile-document.png) | + +`:set vim` turns the vim keys on for a phone with a keyboard. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md new file mode 100644 index 0000000..14c1c59 --- /dev/null +++ b/docs/wiki/Home.md @@ -0,0 +1,49 @@ +![~/workspace](../images/banner.png) + +# ~/workspace + +A private, terminal-themed workspace that runs in the browser: a diary, the +notes for work and for uni, a board per space, and the files that go with them. +One user, one SQLite file, one binary. Everything is a key or a `:` command on +the desktop, and a button on the phone. + +![A document open in the reader](../images/document.png) + +## Where to start + +- **[Getting started](Getting-Started.md)**: run it, log in, find your way + around in the first ten minutes. +- **[The guide](Guide.md)**: a day with the workspace, from the morning's + agenda to the evening's diary entry, with every part explained on the way. +- **[Keys and commands](Commands.md)**: the full list, the same one `?` shows. + +## The parts + +| page | what it covers | +| --- | --- | +| [Spaces and documents](Spaces-and-Documents.md) | spaces, folders, the list, tiles, moving things | +| [Writing](Writing.md) | the editor, vim, markdown, math, links between documents, templates | +| [Boards and the agenda](Boards-and-Agenda.md) | cards, due dates, cards that come back, the agenda | +| [The diary](Diary.md) | entries, today, the calendar, on this day | +| [Files and search](Files-and-Search.md) | uploads, what opens in place, full-text search | +| [Not losing things](Not-Losing-Things.md) | autosave, history, the trash, export and import | +| [The vault](Vault.md) | files sealed under a password of their own | +| [Capture](Capture.md) | writing things down from a phone or a script | +| [Sharing](Sharing.md) | read-only links to one document | +| [The SFTP mount](SFTP.md) | the workspace as a filesystem | +| [Backup and mail](Backup-and-Mail.md) | Proton Drive mirror, reminders and digests | +| [Deploying](Deploying.md) | running it on a server, behind a proxy, in Docker | +| [Configuration](Configuration.md) | every setting | +| [API](API.md) | every route | +| [Development](Development.md) | the code, the checks, the screenshots and this wiki | + +## Screens + +| | | +| --- | --- | +| ![Two documents in tiles](../images/tiles.png) | ![The board of a space](../images/board.png) | +| Two documents side by side, each in a tile of its own. | A board: the first list is the inbox, the last is done. | +| ![The agenda](../images/agenda.png) | ![The diary by month](../images/calendar.png) | +| The agenda: what is late, due, or in hand, on every board. | The diary by month. | +| ![Search reaching into a log file](../images/search.png) | ![A certificate read in place](../images/file-certificate.png) | +| A search finds the text inside files too. | Certificates and keys are read on the server. | diff --git a/docs/wiki/Not-Losing-Things.md b/docs/wiki/Not-Losing-Things.md new file mode 100644 index 0000000..5e6c923 --- /dev/null +++ b/docs/wiki/Not-Losing-Things.md @@ -0,0 +1,93 @@ +# Not losing things + +The workspace is meant to be the one place things are kept, so it tries hard +not to lose any of them. This page covers what protects your work, and how to +get it back. + +## Saving + +Each change is written to the browser's storage as it is typed and to the +server a couple of seconds later. A dropped network, an expired session or a +crashed tab does not lose a draft, and a save from elsewhere is not overwritten +without asking. See [Writing](Writing.md#saving). + +## History + +A save keeps the text it replaces. `gh` or `:history` lists the open document's +earlier versions, the newest first. + +![Earlier versions of a document](../images/history.png) + +The version under the cursor is shown as a diff against the text as it is now: +`-` is what bringing it back would take out, `+` what it would put back. `Enter` +brings it back, and the text it replaces becomes a version of its own, so a +restore can be undone the same way. + +- Autosave writes every few seconds, so an ordinary save keeps a version at + most every ten minutes. +- `:w!`, a save over the SFTP mount, a link rewritten by a rename, and a + restore always keep one, because each of them may undo something. +- A rename alone keeps nothing. +- The daily sweep forgets versions older than `WORKSPACE_REVISION_DAYS` + (90 by default), but always keeps the newest 20 of each document. +- An upload is kept as long as a version still embeds it. A file that has been + sealed since is gone from the old version, because sealing deletes the + plaintext on purpose. + +## The trash + +A delete is not final. `x`, `dd`, `:delete`, `:rmspace` and an `rm` over the +mount put what they delete in the trash, with everything inside it. + +- `u` puts the last delete back where it was: the same folder, the same name, + unless a new one took the name meanwhile. +- `:trash` lists everything in it, the last deleted first. `j`/`k` walk it, + `Enter` or `r` restores, `x` deletes one for good, and `:trash purge` + empties it. Each of these asks first. +- Something whose folder has gone since comes back to the root of its space. + Something whose space is in the trash too waits until the space is restored. +- After `WORKSPACE_TRASH_DAYS` (30 by default) the daily sweep deletes it for + good, files and all. + +While it is in the trash, a node is out of sight: not in the tree, the search, +the agenda, the mails, the SFTP mount, the export or the readable view on +Proton Drive. A card whose notes are in the trash finds them again after a +restore, and a shared document answers like an unknown link until it is +restored, with the same link its readers already have. + +## Export + +- `:export` downloads the open document as markdown. +- `:export!` downloads the whole workspace as a zip. + +The zip needs nothing from this application to be read: the tree as real +directories, one markdown file per document, every file where it sits, every +embedded upload in `media/`, all under their real names, with relative links +between them. What plain files have no place for (when each entry was written, +names as they were typed, which space is the diary, the boards with their cards +and rules) goes into one `.workspace.json` beside them. + +`:export!` refuses while the vault is shut, because the archive has to hold the +sealed files opened. + +## Import + +- `:import` picks a folder and uploads it into the one you are in. Directories + become folders, `.md` and `.markdown` files become documents (named by their + first heading), and anything else becomes a file. +- `:import!` does the same with a zip. +- `:import! /` pours an `:export!` back in at the root. Each directory becomes a + space again, or goes into the space already there by that name, and with + `.workspace.json` the dates, names, boards and cards come back as they were. + +Links between imported files are rewritten as they land: a link to a markdown +file becomes the document's address, a link to an image its upload. Anything +pointing outside the upload is left as it was. Absolute paths, `..` and +anything else a zip might smuggle in are refused. + +## Backups + +The data directory, `WORKSPACE_DATA_DIR`, is the whole backup: `diary.db` +and `uploads/`. Before a new build changes the database it copies it to +`diary.db.pre-<version>` next to it. The server can also mirror itself to +Proton Drive, end-to-end encrypted; see [Backup and mail](Backup-and-Mail.md). diff --git a/docs/wiki/SFTP.md b/docs/wiki/SFTP.md new file mode 100644 index 0000000..79b02f5 --- /dev/null +++ b/docs/wiki/SFTP.md @@ -0,0 +1,69 @@ +# The SFTP mount + +The workspace can be mounted as a filesystem over SFTP, so an editor or a file +manager on another machine works on it directly. + +## Turning it on + +It is off until `WORKSPACE_SFTP_BIND` is set, for example `127.0.0.1:2222`, or +`0.0.0.0:2222` in a container with the port published. Only public keys get +in, and only the ones registered from inside the workspace: + +```text +:sshkey add laptop ssh-ed25519 AAAA… you@laptop +:sshkey lists them, with their fingerprints +:sshkey rm laptop and this one is out, mounted or not +:sshkey vault laptop on this key may read sealed files while the vault is open +``` + +Then, on the other machine: + +```sh +sshfs -p 2222 me@your-host:/ ~/workspace # the user name is ignored +``` + +The server makes its host key on first start, as `sftp_host_key` in the data +directory. + +## What it looks like + +- The root holds the spaces; `mkdir` there makes a space, anywhere else a + folder. +- A document is `<slug>.md`, read as `# Title` followed by its body. Writing a + different first heading back renames it. +- Files sit beside the documents under their own names. A later file with the + same name is told apart by its id: `slides (42).pdf`. +- Anything copied in that is not a `.md` becomes a file, and its text is + indexed for search. +- `rm` and `rmdir` put things in the trash, as a delete in the browser does. + +## Editors + +Editors and `rsync` save by writing a temporary file and renaming it over the +real one. The mount understands that: + +- swap files, `~` backups and dotted temporary files stay in memory for the + session and never appear in the workspace; +- a temporary file renamed onto a real name saves into that document or file; +- a rename onto a name that is taken replaces what was there, as `rename(2)` + does, without filling the trash. + +A save over the mount keeps a version of what it replaces, see +[history](Not-Losing-Things.md#history). If the document was saved in the +browser while the SFTP client had it open, the client's save does not +overwrite it: it lands beside it as `<name>-conflict.md`, and both are kept. + +## Sealed files + +A file copied into a sealed folder is sealed on the way in, while the vault is +open in the browser. Reading one back needs more: a key is a standing +credential on another machine, so it reads sealed files only after +`:sshkey vault <name> on`, and only while the vault is open. Shutting the vault +or taking the access away cuts off a read already under way. Nothing done over +the mount keeps the vault open; only the browser does. + +## Limits + +A session holds at most 256 handles and 64 MB in memory, the server at most 32 +connections, and a client that stops answering is dropped within a few minutes. +Removing a key in the browser ends what that key has mounted. diff --git a/docs/wiki/Sharing.md b/docs/wiki/Sharing.md new file mode 100644 index 0000000..052ae00 --- /dev/null +++ b/docs/wiki/Sharing.md @@ -0,0 +1,33 @@ +# Sharing + +Any document can be shared read-only behind a link that cannot be guessed. The +reader needs no account. + +| key or command | what it does | +| --- | --- | +| `s`, `:share` | share the document and copy its link; on a shared one, `s` asks before taking it back | +| `y`, `:link` | make a new link for the same document; the old one stops working | +| `:unshare` | stop sharing | + +The link looks like `https://your-host/s/<token>#<key>`. The page renders the +document read-only and serves only the pictures and videos it embeds right now: +take a picture out of the document and it is gone from the link too. Links to +other documents in it are turned into plain text, since the reader could not +follow them. Sealed files are never shown; `:share` says how many the document +holds. + +## Why the link has two halves + +The token names the document; the key proves the reader was given the whole +link. A browser never sends the part after `#` to the server, so the key stays +out of access logs, referrers and link previews. A crawler or chat client that +kept only the path holds a link that answers `404`. + +The server keeps only a SHA-256 of the key and compares it in constant time, and +a wrong key is answered exactly like an unknown token. That also means the full +link exists only once, when `:share` copies it. It cannot be read back from the +document later; `:link` makes a new one instead, which is also the way to cut a +reader off without unpublishing. + +A document in the trash answers like an unknown link, and works again with the +same link when it is restored. diff --git a/docs/wiki/Spaces-and-Documents.md b/docs/wiki/Spaces-and-Documents.md new file mode 100644 index 0000000..1d74cd7 --- /dev/null +++ b/docs/wiki/Spaces-and-Documents.md @@ -0,0 +1,109 @@ +# Spaces and documents + +## Spaces + +A space is a top-level area: `diary`, `work`, `uni`, or whatever you make. +Each has its own tree of folders, its own board and its own search, so work and +uni do not get mixed up. + +- `1` … `9` and `0` switch to a space by its place in the bar; `Tab` and + `Shift-Tab` step through them. +- `w` shows every space at once, with what was last written in each. `hjkl` + move, `Enter` goes there. +- `:space! <name>` makes a space, `:name! <name>` renames the one you are in, + and `:rmspace` puts it in the trash with everything in it. +- `:set noboard` turns the board off for a space, `:set board` back on. + +![Every space at a glance](../images/overview.png) + +One space has the **diary** role. It is the diary by role, not by name: rename +it and the reminder and the agenda still know which space it is. It cannot be +deleted. See [The diary](Diary.md). + +## The tree + +Inside a space, folders nest to any depth. The list shows the folder you are +in: folders first, then documents and files, grouped by the day they were +written. + +| key | what it does | +| --- | --- | +| `j` / `k`, `gg` / `G` | move, first, last | +| `l`, `Enter` | open the document or file, or go into the folder | +| `h` | up one level | +| `o` | a new document here, straight into insert mode | +| `O` | a new folder here | +| `R` | rename what is under the cursor | +| `x`, `dd` | delete it, into the trash | +| `u` | bring the last delete back | + +`:cd <folder>` walks the tree from the command line (`:cd ..` goes up, `:cd /` +to the root of the space), and `:mkdir <name>` makes a folder. + +### Moving things + +- `m` picks up the row under the cursor; `m` again puts it back. `M` picks up + the whole folder you are in. +- Walk to another folder, in any space, and press `p` to drop everything picked + up there. +- `Shift-1` … `Shift-9` sends what is picked up, or the row under the cursor, + straight to the root of that space. +- `:mv <folder>` moves it into a folder here, `:mv ..` up one level, `:mv /` + to the root. +- With a mouse, `Ctrl`-click picks a row up and `Shift`-click every row from + the cursor to it; drag them onto a folder, a crumb in the bar, or a space. + +A move never breaks a link: every document that links to what moved is +rewritten to point at its new place. See [Writing](Writing.md#links). + +## Documents + +A document is markdown with a name. It has a real address, +`/n/<space>/<folder>/<slug>`, which the address bar shows and which can be +linked to from anywhere behind the login. + +![A document in the reader](../images/document.png) + +The line under the title says when it was written and last edited, how many +words it has, and how many documents link to it. In the reader: + +| key | what it does | +| --- | --- | +| `j` / `k`, `Ctrl-D` / `Ctrl-U` | scroll a line, or half a page | +| `]]` / `[[` | the next / previous heading | +| `gO` | the headings as a list to jump to | +| `zl` / `zh` | scroll a wide table or code block sideways | +| `F` | a letter over every link and button in view; type it to follow | +| `gh` | earlier versions | +| `gb` | the documents that link here | +| `i`, `a` | edit | +| `q`, `Esc` | close | + +`:stats` counts words and lines, `:copy` puts the whole text on the clipboard, +`:export` downloads it as a `.md` file, and `:date 2026-10-01` changes the day +it is filed under. + +A document made with `o` and left with neither a name nor a word in it is +removed again when you leave it, so abandoned starts do not pile up. + +## Tiles + +Several documents can be open at once, each in a tile of its own. + +![Two documents in tiles](../images/tiles.png) + +- `L` or `Shift-Enter` on a row opens it in a new tile; `Enter` and `l` open + it in the focused one. +- Tiles share the space the way a tiling window manager does: two side by side, + a third splits the newer one, and so on. +- `Alt-h/j/k/l` moves the focus between tiles, and `Alt-h` past the leftmost + goes back to the list. `Alt-Shift-h/j/k/l` moves the tile itself. +- `gt` / `gT` (or `:bn` / `:bp`) cycle through them, `:ls` lists them, and + `:b <n or name>` focuses one. +- `Alt-q` (or `:close`) closes the focused tile, `:only` every other one, and + `f` makes the focused tile fill the screen. + +Opening a document that is already in a tile focuses that tile rather than +opening it twice. Each tile keeps its own scroll position and editor, and each +space remembers its tiles. Under 768 pixels wide only the focused tile is +shown. diff --git a/docs/wiki/Vault.md b/docs/wiki/Vault.md new file mode 100644 index 0000000..b52cfdd --- /dev/null +++ b/docs/wiki/Vault.md @@ -0,0 +1,73 @@ +# The vault + +Some files deserve more than the login: scans of documents, contracts, health +records. The vault encrypts them at rest under a password of their own. They +stay unreadable to anyone without it: someone with a copy of the data directory +or the Proton account the mirror writes to, and even a logged-in browser while +the vault is shut. + +> **There is no recovery.** Forget the vault password and every sealed file is +> gone, from the server and from every backup. The password is not in `.env`, +> not in `diary.db` and not in the mirror. + +## Using it + +| command | what it does | +| --- | --- | +| `:vault` | set the password the first time (ten characters at least); afterwards, say whether the vault is open | +| `:unlock` / `:lock` | open / close the vault | +| `:upload!` | upload files sealed, encrypted as they stream in, so the plaintext never touches the disk | +| `:encrypt` | seal the file under the cursor; on a folder or space, seal it for good | +| `:decrypt` | take the file, or the sealed folder, back out | +| `:passwd` | change the password | + +A sealed file carries a lock in the list, and the bar says `vault open` while it +is. While the vault is shut, `Enter` on a sealed file asks for the password. + +## Sealed folders + +`:encrypt` on a folder or a space seals it for good: every file in it now, and +every file that lands in it later. An upload, an import, an SFTP write and a +file moved in are all sealed on the way in. While the vault is shut they are +refused rather than stored readable. Folders made inside are sealed too. +`:decrypt` on the folder unseals it with everything in it. + +Documents are not files and stay readable. Only the bytes of files are sealed; +the names of sealed files, and the documents around them, are as readable in +`diary.db` as everything else. + +## When it closes + +The vault closes itself after `WORKSPACE_VAULT_IDLE_MIN` minutes with nobody +using it (15 by default), on logout, and whenever the server restarts. The key +is dropped from memory then. While it is shut: + +- a sealed file shows as sealed and nothing more; +- a share link never serves one, open or not; +- `:export!` refuses. + +A sealed file is served with `Cache-Control: no-store`, so the browser keeps no +copy once the vault shuts. A sealed file is never indexed for search. + +## Changing the password + +`:passwd` makes a new master key as well as a new password. The old password +stops opening anything at once. Every sealed file is then sealed again under the +new key in the background while the vault is open; `:vault` shows how far it +has come. After that, an old copy of `diary.db` together with the old password +opens nothing still kept anywhere. + +## How it works + +Argon2id turns the password into a key that seals a random master key; only +that sealed copy is stored. Each file has a random key of its own, sealed under +the master key in the file's header together with the file's id. Its bytes are +AES-256-GCM in 64 KiB records, the last one marked as last, so a file that is +swapped, cut short or changed refuses to open. + +Sealing an existing file writes it again under a new id, rewrites the documents +that embed it, and deletes the plaintext from the disk and from Proton Drive. + +Over the [SFTP mount](SFTP.md), a key reads sealed files only once it has been +given vault access with `:sshkey vault <name> on`, and only while the vault is +open in a browser. diff --git a/docs/wiki/Writing.md b/docs/wiki/Writing.md new file mode 100644 index 0000000..6522f54 --- /dev/null +++ b/docs/wiki/Writing.md @@ -0,0 +1,104 @@ +# Writing + +## The editor + +`i` or `a` on an open document edits it. The editor is CodeMirror 6 with the +vim keymap: motions, operators, text objects, visual mode, registers, macros +and `.` all work, and `:w`, `:wq` and `:q!` do what they do in vim. + +![The editor](../images/editor.png) + +- `:name` on its own moves up into the title; `Enter`, `Esc` or `↓` goes back + down into the text. `:name <text>` renames without leaving the text. +- `]]`, `[[` and `gO` walk the headings, the same as in the reader. +- `gf` follows the link under the cursor, saving first, and `gx` opens it in + a new browser tab. +- `Ctrl-S` saves from anywhere, and `?` shows the help. +- Yanks and deletes go to the system clipboard too. `:set noclipboard` keeps + them in vim's registers only. +- `:set novim` turns the vim keys off for a plain editor; `:set vim` turns them + back on. On a touch screen they start off. + +Drag, drop or paste an image, a video or a sound file into the text and it is +uploaded and embedded where the cursor is. `:upload` picks files to embed, and +`:media` lists every upload: `Enter` embeds the one under the cursor, `y` +copies its address. + +## Saving + +There is nothing to remember. Each change goes to the browser's storage as it is +typed, and to the server a couple of seconds after typing stops. + +- When the server cannot be reached, the draft stays in the browser and the + save is tried again, less often the longer it fails, and at once when the + browser says it is back online. +- When a session runs out in the middle of a sentence, the login appears, and + logging in again carries on where it stopped, draft and all. +- When a tab dies in the middle of a document, opening the document again + restores the draft and says so. `:e!` throws the draft away. +- When the document was saved somewhere else since you opened it (another tab, + the phone, the SFTP mount), `:w` says so instead of overwriting it. `:e!` + takes the other version, and `:w!` writes yours anyway. + +Each save keeps the text it replaces, so nothing written is lost. See +[Not losing things](Not-Losing-Things.md#history). + +## Markdown + +Documents are GitHub-flavoured markdown: headings, lists, task lists, tables, +quotes, code blocks and links. A single line break is kept as a line break. + +Math is set by KaTeX: + +- `$x^2$` inline; +- `$$ … $$` on lines of its own, or a ` ```math ` block, for a display formula. + +![Math in a document](../images/math.png) + +## Links + +A document links to another in two ways: + +- by its address: `[the plan](/n/work/notes/plan)`; +- by its title: `[[Plan]]`, or `[[Plan|the plan]]` with a label of its own. + +A title is looked for in the space the link is written in first, then in every +other. It matches a name in any case, or the slug of that name. Typing `[[` in +the editor offers the titles of documents as the search finds them. A title +that no document has yet is drawn dimmed, and following it offers to write that +document. + +Links keep working when what they point at moves. Renaming a document, or a +folder above it, or moving either, rewrites the links to it in every document +that has one, in the same save: the path to the new one, a `[[title]]` to the +new name, the label left as it was. A link to something in the trash follows it +when it is restored; once it is deleted for good the link text stays and leads +nowhere. + +`gb` or `:backlinks` lists the documents that link to the open one, each with +the line it does so on. + +A link written inside a card, or a bare address in prose rather than a markdown +link, is not counted or rewritten. + +## Templates + +Any space can have a folder called `templates` at its root. Every document in +it is a template. + +- `:new meeting` starts a document from `templates/meeting`. `Tab` completes the + template names. +- `:new meeting 2026-10-20 Budget` dates it and names it as well. + +In a template these placeholders are filled in, on the clock of +`WORKSPACE_TIMEZONE` at the date the document is for: + +| placeholder | becomes | +| --- | --- | +| `{{date}}` | `2026-10-20` | +| `{{weekday}}` | `Tuesday` | +| `{{time}}` | `14:05` | +| `{{title}}` | the new document's name | + +A new diary entry starts from the diary's `templates/day` when there is one. +See [The diary](Diary.md). diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md new file mode 100644 index 0000000..29fc345 --- /dev/null +++ b/docs/wiki/_Sidebar.md @@ -0,0 +1,29 @@ +**[~/workspace](Home.md)** + +- [Getting started](Getting-Started.md) +- [The guide](Guide.md) + +**Using it** + +- [Spaces and documents](Spaces-and-Documents.md) +- [Writing](Writing.md) +- [Boards and the agenda](Boards-and-Agenda.md) +- [The diary](Diary.md) +- [Files and search](Files-and-Search.md) +- [Not losing things](Not-Losing-Things.md) +- [The vault](Vault.md) +- [Capture](Capture.md) +- [Sharing](Sharing.md) +- [The SFTP mount](SFTP.md) +- [Backup and mail](Backup-and-Mail.md) + +**Reference** + +- [Keys and commands](Commands.md) +- [Configuration](Configuration.md) +- [API](API.md) + +**Running it** + +- [Deploying](Deploying.md) +- [Development](Development.md) diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts new file mode 100644 index 0000000..1882269 --- /dev/null +++ b/scripts/screenshots.ts @@ -0,0 +1,430 @@ +/* + * The screenshots in docs/images, taken again: `make screenshots`. + * + * Starts the debug binary on a data directory of its own, with a password made + * up for the run, fills it with a workspace that has something in every + * corner, and drives a headless Chromium over the DevTools protocol through + * the views the docs talk about. Nothing outside the temporary directory is + * read or written except docs/images. Needs `bun run build` in web/ and + * `cargo build` first, which the make target does. + */ +import { spawn, spawnSync, which, type Subprocess } from 'bun' +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const root = resolve(import.meta.dir, '..') +const out = join(root, 'docs/images') +const port = 4299 +const base = `http://127.0.0.1:${port}` +const user = 'demo' +const password = crypto.randomUUID() +const dir = mkdtempSync(join(tmpdir(), 'workspace-shots-')) +const chromium = process.env.CHROMIUM ?? 'chromium' + +const children: Subprocess[] = [] +function cleanup() { + for (const child of children) child.kill() + rmSync(dir, { recursive: true, force: true }) +} +process.on('exit', cleanup) + +const sleep = (ms: number) => new Promise((done) => setTimeout(done, ms)) + +async function waitFor(url: string) { + for (let i = 0; i < 100; i++) { + try { + if ((await fetch(url)).ok) return + } catch { + /* not up yet */ + } + await sleep(200) + } + throw new Error(`${url} never answered`) +} + +// --- the server -------------------------------------------------------------- + +children.push( + spawn([join(root, 'target/debug/narl-workspace')], { + cwd: root, + env: { + ...process.env, + WORKSPACE_DATA_DIR: join(dir, 'data'), + WORKSPACE_BIND: `127.0.0.1:${port}`, + WORKSPACE_USER: user, + WORKSPACE_PASSWORD: password, + WORKSPACE_SECRET: crypto.randomUUID() + crypto.randomUUID(), + WORKSPACE_TIMEZONE: 'Europe/Berlin', + RUST_LOG: 'warn', + }, + stdout: 'ignore', + stderr: 'inherit', + }), +) +await waitFor(`${base}/api/health`) + +const login = await fetch(`${base}/api/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: user, password }), +}) +const cookie = (login.headers.get('set-cookie') ?? '').split(';')[0] +if (!login.ok || !cookie) throw new Error(`login: ${login.status}`) +const [cookieName, cookieValue] = cookie.split('=') + +async function api<T = any>(method: string, path: string, body?: unknown): Promise<T> { + const response = await fetch(`${base}/api${path}`, { + method, + headers: { cookie, 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + if (!response.ok) throw new Error(`${method} ${path}: ${response.status} ${await response.text()}`) + return response.json() as Promise<T> +} + +async function upload(parent: number, name: string, bytes: string | Uint8Array, type: string) { + const form = new FormData() + form.append('file', new File([bytes], name, { type })) + const response = await fetch(`${base}/api/nodes/${parent}/files`, { method: 'POST', headers: { cookie }, body: form }) + if (!response.ok) throw new Error(`upload ${name}: ${response.status}`) +} + +// --- the workspace ------------------------------------------------------------- + +const day = 24 * 3600 +const midnight = new Date() +midnight.setHours(0, 0, 0, 0) +const today = Math.floor(midnight.getTime() / 1000) +const at = (days: number, hour: number) => today + days * day + hour * 3600 + +const doc = (parent_id: number, name: string, body: string, created_at?: number) => + api('POST', '/nodes', { parent_id, name, body, created_at }) +const folder = (parent_id: number, name: string) => api('POST', '/nodes', { parent_id, name, kind: 'folder' }) + +// A new database starts with diary, work and uni. +const spaces = await api<{ id: number; slug: string }[]>('GET', '/spaces') +const space = async (slug: string) => + spaces.find((space) => space.slug === slug)?.id ?? (await api('POST', '/spaces', { name: slug })).id +const diary = await space('diary') +const work = await space('work') +const uni = await space('uni') +const home = await space('home') + +// work +const projects = await folder(work, 'projects') +const pipeline = await doc( + projects.id, + 'Ingest pipeline', + `Moving the nightly import off cron and onto a queue, so a slow source no longer holds up the rest. See [[Runbook]] for how it is operated today. + +## Why + +- the cron job runs **every source in series**; one slow API delays all of them +- a failed run is retried by hand, the next morning +- nobody can tell from the outside how far a run has come + +## Shape + +| stage | owner | state | +| --- | --- | --- | +| fetch | worker pool | done | +| parse | worker pool | in review | +| store | one writer | open | + +\`\`\`rust +async fn drain(queue: &Queue, store: &Store) -> anyhow::Result<()> { + while let Some(job) = queue.next().await? { + let rows = parse(&job.payload)?; + store.write(job.source, rows).await?; + queue.ack(job.id).await?; + } + Ok(()) +} +\`\`\` + +## Throughput + +With $n$ sources of mean latency $\\bar t$, the serial run takes about $n \\bar t$; with $w$ workers it is closer to + +$$ +T \\approx \\frac{n \\bar t}{w} + t_{\\max} +$$ + +## Open questions + +1. Does the writer need a lock per source, or is one queue enough? +2. Where does the dead-letter queue live? +`, +) +await doc(projects.id, 'Search rewrite', `A sketch of the new search API.\n\nDepends on the [[Ingest pipeline]] landing first.\n`) +const runbook = await doc( + work, + 'Runbook', + `How the nightly import is operated until the [[Ingest pipeline]] replaces it. + +## When the import fails + +1. read the log of the run: \`journalctl -u import --since today\` +2. re-run the one source that failed: \`import --source <name>\` +3. write down what happened in the incident notes +`, +) +// Two saves over the first text, so the runbook has a history to show. +await api('PUT', `/nodes/${runbook.id}`, { + name: 'Runbook', + body: `How the nightly import is operated until the [[Ingest pipeline]] replaces it. + +## When the import fails + +1. read the log of the run: \`journalctl -u import --since today\` +2. re-run the one source that failed: \`import --source <name> --resume\` +3. if it fails twice, page the owner of that source +4. write down what happened in the incident notes +`, +}) +const data = await folder(work, 'data') +await upload( + data.id, + 'throughput.csv', + 'day,sources,rows,duration_s,failures\n' + + Array.from({ length: 14 }, (_, i) => `2026-10-${String(i + 1).padStart(2, '0')},${38 + (i % 3)},${120000 + i * 3150},${2400 - i * 61},${i % 5 === 0 ? 1 : 0}`).join('\n') + + '\n', + 'text/csv', +) +await upload( + data.id, + 'import-2026-10-14.log', + `2026-10-14T02:00:01Z INFO import: 41 sources queued +2026-10-14T02:00:03Z INFO fetch: weather-api 200 in 812ms +2026-10-14T02:03:47Z WARN fetch: tariffs-api slow, 9.4s +2026-10-14T02:04:12Z ERROR parse: tariffs-api: unexpected field "valid_from" +2026-10-14T02:41:55Z INFO import: 40 of 41 sources stored +`, + 'text/plain', +) +const certs = await folder(work, 'certificates') +await upload(certs.id, 'gateway.pem', readFileSync(join(root, 'src/routes/testdata/cert.pem')), 'application/x-pem-file') +const templates = await folder(work, 'templates') +await doc(templates.id, 'meeting', `# {{title}}\n\n{{weekday}}, {{date}} at {{time}}\n\n## Agenda\n\n- \n\n## Decisions\n\n- \n`) + +const lists = (await api('GET', `/spaces/${work}/board`)).lists as { id: number; name: string }[] +const [backlog, doing, done] = lists +await api('POST', `/lists/${backlog.id}/cards`, { title: 'Renew the gateway certificate', due_at: at(-1, 12) }) +await api('POST', `/lists/${backlog.id}/cards`, { title: 'Write the dead-letter queue proposal', due_at: at(3, 12) }) +await api('POST', `/lists/${backlog.id}/cards`, { title: 'Ask ops about a staging queue' }) +const weekly = await api('POST', `/lists/${backlog.id}/cards`, { title: 'Weekly import report', due_at: at(0, 17) }) +await api('PUT', `/cards/${weekly.id}`, { repeat: 'weekly' }) +await api('POST', `/lists/${doing.id}/cards`, { title: 'Parse stage review', node_id: pipeline.id, due_at: at(1, 12) }) +await api('POST', `/lists/${doing.id}/cards`, { title: 'Benchmark the worker pool' }) +const finished = await api('POST', `/lists/${backlog.id}/cards`, { title: 'Move fetch onto the queue' }) +await api('POST', `/cards/${finished.id}/move`, { list_id: done.id }) + +// uni +const algebra = await folder(uni, 'linear algebra') +await doc( + algebra.id, + 'Eigenvalues', + `For a square matrix $A$, a scalar $\\lambda$ is an **eigenvalue** when there is a vector $v \\neq 0$ with $Av = \\lambda v$. They are the roots of + +$$ +\\det(A - \\lambda I) = 0 +$$ + +## Example + +$$ +A = \\begin{pmatrix} 2 & 1 \\\\ 1 & 2 \\end{pmatrix} +\\quad\\Rightarrow\\quad +\\lambda_1 = 3,\\; \\lambda_2 = 1 +$$ + +The trace is the sum of the eigenvalues, $\\operatorname{tr} A = 4$, and the determinant their product, $\\det A = 3$. +`, +) +await doc(algebra.id, 'Exercise sheet 4', `- [x] 4.1 characteristic polynomial\n- [x] 4.2 diagonalise\n- [ ] 4.3 Jordan form\n`) +const thesis = await folder(uni, 'thesis') +await doc(thesis.id, 'Outline', `## Chapters\n\n1. Introduction\n2. Related work\n3. Method\n4. Evaluation\n`) +const uniLists = (await api('GET', `/spaces/${uni}/board`)).lists as { id: number }[] +await api('POST', `/lists/${uniLists[0].id}/cards`, { title: 'Hand in exercise sheet 4', due_at: at(2, 10) }) + +// home +await doc(home, 'Groceries', `- oat milk\n- coffee\n- lemons\n`) +await api('PUT', `/nodes/${home}`, { name: 'home', has_board: false }) + +// diary +const entries: [number, string, string][] = [ + [-1, 'Queue day', 'Fetch runs on the queue now. The tariffs source still fails on a new field.'], + [-2, 'Long run', 'Ten kilometres along the river, slower than last week and better for it.'], + [-3, 'Sheet 4', 'Diagonalised everything in sight. Jordan form tomorrow.'], + [-5, 'Rain', 'Stayed in and read.'], + [-8, 'Planning', 'Wrote down why the nightly import should move to a queue.'], + [-12, 'Market', 'Lemons, coffee, a plant that will not survive the winter.'], + [-365, 'A year ago', 'First entry in the new workspace.'], +] +for (const [offset, name, body] of entries) await doc(diary, name, body + '\n', at(offset, 20)) + +// --- the browser ---------------------------------------------------------------- + +const profile = join(dir, 'chromium') +children.push( + spawn( + [chromium, '--headless=new', '--remote-debugging-port=9333', `--user-data-dir=${profile}`, '--hide-scrollbars', '--no-first-run', 'about:blank'], + { stdout: 'ignore', stderr: 'ignore' }, + ), +) +await waitFor('http://127.0.0.1:9333/json/version') +const targets = (await (await fetch('http://127.0.0.1:9333/json/list')).json()) as { type: string; webSocketDebuggerUrl: string }[] +const socket = new WebSocket(targets.find((target) => target.type === 'page')!.webSocketDebuggerUrl) +await new Promise((ready) => socket.addEventListener('open', ready, { once: true })) + +let next = 0 +const pending = new Map<number, (value: any) => void>() +socket.addEventListener('message', (event) => { + const message = JSON.parse(String(event.data)) + if (message.id !== undefined) pending.get(message.id)?.(message.result ?? message.error) +}) +function send(method: string, params: object = {}): Promise<any> { + const id = ++next + socket.send(JSON.stringify({ id, method, params })) + return new Promise((done) => pending.set(id, done)) +} + +async function viewport(width: number, height: number, mobile = false) { + await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: mobile ? 3 : 2, mobile }) + await send('Emulation.setTouchEmulationEnabled', { enabled: mobile, maxTouchPoints: mobile ? 5 : 0 }) + await send('Emulation.setEmitTouchEventsForMouse', { enabled: mobile }) +} + +async function open(path: string, signedIn = true) { + await send('Network.clearBrowserCookies') + if (signedIn) await send('Network.setCookie', { name: cookieName, value: cookieValue, url: base }) + await send('Page.navigate', { url: base + path }) + await sleep(1500) +} + +const named: Record<string, { code: string; keyCode: number }> = { + Escape: { code: 'Escape', keyCode: 27 }, + Enter: { code: 'Enter', keyCode: 13 }, + Tab: { code: 'Tab', keyCode: 9 }, +} + +/** Keys as typed: `gh`, `?`, `<Escape>` for a named one, `<A-h>` with Alt. */ +async function keys(sequence: string, pause = 250) { + for (const token of sequence.match(/<A-\w>|<\w+>|./g) ?? []) { + if (token.startsWith('<A-')) { + const key = token[3] + const params = { key, code: `Key${key.toUpperCase()}`, modifiers: 1 } + await send('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...params }) + await send('Input.dispatchKeyEvent', { type: 'keyUp', ...params }) + } else if (token.startsWith('<')) { + const key = token.slice(1, -1) + const { code, keyCode } = named[key] + const text = key === 'Enter' ? '\r' : undefined + await send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, windowsVirtualKeyCode: keyCode, text }) + await send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, windowsVirtualKeyCode: keyCode }) + } else { + const upper = token !== token.toLowerCase() + const code = /[a-z]/i.test(token) ? `Key${token.toUpperCase()}` : undefined + const params = { key: token, code, text: token, modifiers: upper ? 8 : 0 } + await send('Input.dispatchKeyEvent', { type: 'keyDown', ...params }) + await send('Input.dispatchKeyEvent', { type: 'keyUp', ...params }) + } + await sleep(pause) + } +} + +async function shot(name: string) { + await sleep(600) + const { data } = await send('Page.captureScreenshot', { format: 'png' }) + const file = join(out, `${name}.png`) + writeFileSync(file, Buffer.from(data, 'base64')) + // A dark UI fits in 256 colours at a third of the size; skipped without ImageMagick. + if (which('magick')) spawnSync(['magick', file, '-dither', 'None', '-colors', '256', '-define', 'png:compression-level=9', `PNG8:${file}`]) + console.log(`docs/images/${name}.png`) +} + +await send('Page.enable') +await send('Network.enable') +// The agenda opens itself once a day; here it opens when asked. +await send('Page.addScriptToEvaluateOnNewDocument', { + source: `localStorage.setItem('diary:agenda-seen', new Date().toDateString())`, +}) + +await viewport(1440, 900) + +await open('/', false) +await shot('login') + +await open('/n/work/projects/ingest-pipeline') +await shot('document') + +// From the folder: the pipeline in the focused tile, the rewrite beside it. +await open('/n/work/projects') +await keys('jl') +await sleep(800) +await keys('<A-h>kL') +await sleep(800) +await shot('tiles') + +await open('/n/work/runbook') +await keys('gh', 400) +await sleep(800) +await shot('history') + +await open('/n/work/runbook') +await keys('i') +await sleep(500) +await shot('editor') + +await open('/n/uni/linear-algebra/eigenvalues') +await shot('math') + +await open('/n/work') +await keys('b') +await sleep(800) +await shot('board') + +await keys('t') +await sleep(800) +await shot('agenda') +await keys('<Escape>') + +await open('/n/diary') +await keys('C') +await sleep(800) +await shot('calendar') +await keys('<Escape>') + +await open('/n/work') +await keys('/') +await send('Input.insertText', { text: 'tariffs' }) +await sleep(300) +await keys('<Enter>') +await sleep(800) +await shot('search') + +await open('/n/work/data/throughput-csv') +await shot('file-table') + +await open('/n/work/certificates/gateway-pem') +await shot('file-certificate') + +await open('/n/work') +await keys('w') +await sleep(800) +await shot('overview') +await keys('<Escape>') + +await keys('?') +await sleep(800) +await shot('help') + +await viewport(390, 844, true) +await open('/n/work/projects/ingest-pipeline') +await shot('mobile-document') +await open('/n/work') +await shot('mobile-tree') + +socket.close() +process.exit(0) diff --git a/scripts/wiki.ts b/scripts/wiki.ts new file mode 100644 index 0000000..f5713b1 --- /dev/null +++ b/scripts/wiki.ts @@ -0,0 +1,274 @@ +/* + * The wiki, from the files it describes: `make wiki`. + * + * docs/wiki holds the pages. Three of them are not written by hand but read + * out of the code, so they cannot drift from it: + * + * - Commands.md, from the keys in Help.svelte and the commands in commands.ts, + * the same lists `:help` draws; + * - Configuration.md, from the comments and values in .env.example; + * - API.md, from the API section of README.md. + * + * `bun scripts/wiki.ts` writes those three and checks that every link between + * pages and every image resolves. `--publish <dir>` also lays the wiki out for + * GitHub: links without `.md`, images beside the pages, a footer saying where + * the pages come from. CI does that on every push to main and pushes the result + * to the wiki repository; `--check` fails when a generated page is stale. + */ +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' + +const root = resolve(import.meta.dir, '..') +const pages = join(root, 'docs/wiki') +const images = join(root, 'docs/images') +const repo = process.env.GITHUB_REPOSITORY ?? 'narrrl/narl-workspace' +const read = (path: string) => readFileSync(join(root, path), 'utf8') + +const args = process.argv.slice(2) +const check = args.includes('--check') +const publish = args.includes('--publish') ? args[args.indexOf('--publish') + 1] : undefined +if (args.includes('--publish') && !publish) throw new Error('--publish needs a directory') + +const generated = (source: string) => + `<!-- Generated by scripts/wiki.ts from ${source}. Edit that, then run \`make wiki\`. -->\n\n` + +/** A JS string literal's value: `'it\'s'` or `"it's"`. */ +function unquote(literal: string): string { + return literal.slice(1, -1).replace(/\\(.)/g, '$1') +} +const STRING = `('(?:[^'\\\\]|\\\\.)*'|"(?:[^"\\\\]|\\\\.)*")` + +/** `|` inside a table cell would end it, and a GitHub wiki reads `[[x]]` as a link to page x. */ +const cell = (text: string) => text.replace(/\|/g, '\\|').replace(/\[\[/g, '\\[\\[') +/** A key or command as code, safe in a table even when it holds a backtick. */ +const code = (text: string) => (text.includes('`') ? `<code>${cell(text).replace(/</g, '<')}</code>` : `\`${cell(text)}\``) + +// --- Commands.md --------------------------------------------------------------- + +function keyTable(source: string, name: string): string { + const start = source.indexOf(`const ${name}: [string, string][] = [`) + if (start < 0) throw new Error(`Help.svelte has no ${name}`) + const body = source.slice(start, source.indexOf('\n ]', start)) + const pairs = [...body.matchAll(new RegExp(`\\[${STRING}, ${STRING}\\]`, 'g'))].map(([, key, description]) => [unquote(key), unquote(description)]) + if (pairs.length === 0) throw new Error(`Help.svelte: ${name} is empty`) + // `rule` is not a key but the sentence the help column starts with. + const rule = pairs.find(([key]) => key === 'rule') + const rows = pairs.filter(([key]) => key !== 'rule').map(([key, description]) => `| ${code(key)} | ${cell(description)} |`) + const table = ['| key | what it does |', '| --- | --- |', ...rows].join('\n') + return rule ? `${rule[1][0].toUpperCase()}${rule[1].slice(1)}.\n\n${table}` : table +} + +interface Spec { + name: string + aliases: string[] + args?: string + group: string + help: string + bang?: string +} + +function commandSpecs(source: string): Spec[] { + const start = source.indexOf('export const commands: CommandSpec[] = [') + const end = source.indexOf('\n]\n', start) + // Every command is an object literal opened on a line of its own, two in. + const blocks = source.slice(start, end).split('\n {\n').slice(1) + const field = (block: string, name: string, indent = 4) => + new RegExp(`^${' '.repeat(indent)}${name}: ${STRING},?$`, 'm').exec(block)?.[1] + return blocks.map((block) => { + const name = field(block, 'name') + const group = field(block, 'group') + const help = field(block, 'help') + if (!name || !group || !help) throw new Error(`commands.ts: a command without a name, group or help:\n${block.slice(0, 200)}`) + const aliases = /^ aliases: \[(.*)\],$/m.exec(block)?.[1] + const bang = /^ bang: \{/m.test(block) ? field(block, 'help', 6) : undefined + const args = field(block, 'args') + return { + name: unquote(name), + group: unquote(group), + help: unquote(help), + aliases: aliases ? [...aliases.matchAll(new RegExp(STRING, 'g'))].map(([literal]) => unquote(literal)) : [], + args: args && unquote(args), + bang: bang && unquote(bang), + } + }) +} + +function commandsPage(): string { + const help = read('web/src/lib/Help.svelte') + const source = read('web/src/lib/commands.ts') + const groups = [.../export const groups = \[(.*)\] as const/.exec(source)![1].matchAll(new RegExp(STRING, 'g'))].map(([g]) => unquote(g)) + const titles = Object.fromEntries( + [...help.matchAll(new RegExp(`^ (\\w+): ${STRING},$`, 'gm'))].map(([, group, title]) => [group, unquote(title)]), + ) + const specs = commandSpecs(source) + + const sections = groups.map((group) => { + const rows = specs + .filter((spec) => spec.group === group) + .flatMap((spec) => { + const names = [spec.name, ...spec.aliases].map((name) => `:${name}`).join(', ') + const usage = spec.args ? `${names} ${spec.args}` : names + const row = `| ${code(usage)} | ${cell(spec.help)} |` + return spec.bang ? [row, `| ${code(`:${spec.name}!`)} | ${cell(spec.bang)} |`] : [row] + }) + const title = titles[group] ?? group + return `### ${title[0].toUpperCase()}${title.slice(1)}\n\n| command | what it does |\n| --- | --- |\n${rows.join('\n')}` + }) + + return ( + generated('web/src/lib/Help.svelte and web/src/lib/commands.ts') + + `# Keys and commands + +Everything here is also on \`?\` (\`:help\`) inside the app, which draws the same +lists. Keys act on whatever has focus: the row under the cursor in the list, or +the document in the focused tile. Inside the editor the full vim keymap is live, +and \`:\` opens the command line, where \`Tab\` completes commands and their +arguments and \`↑\` / \`↓\` recall earlier lines. + +## Keys + +### Browsing and reading + +${keyTable(help, 'keys')} + +### On a board + +${keyTable(help, 'boardKeys')} + +### In the agenda + +${keyTable(help, 'agendaKeys')} + +## Commands + +A command takes a \`!\` for its forcing variant where one exists, the way vim's +do: \`:w!\`, \`:space! <name>\`, \`:import!\`. + +${sections.join('\n\n')} +` + ) +} + +// --- Configuration.md ------------------------------------------------------------- + +function configurationPage(): string { + const lines = read('.env.example').split('\n') + const out: string[] = [] + let comment: string[] = [] + let table: string[] = [] + const flush = () => { + if (table.length) out.push('| setting | example | what it does |', '| --- | --- | --- |', ...table, '') + table = [] + } + for (const line of lines) { + const section = /^# --- (.+?) -+$/.exec(line) + const setting = /^(?:# )?(WORKSPACE_\w+)=(.*)$/.exec(line) + if (section) { + flush() + out.push(`## ${section[1]}`, '') + comment = [] + } else if (setting) { + const [, name, value] = setting + // A comment above two settings belongs to both: USER and PASSWORD. + const text = comment.join(' ').replace(/\s+/g, ' ').trim() || 'See the row above.' + const unset = line.startsWith('#') && !/off until/i.test(text) ? ' Unset by default.' : '' + table.push(`| \`${name}\` | ${value ? `\`${cell(value)}\`` : ''} | ${cell(text)}${unset} |`) + comment = [] + } else if (line.startsWith('#')) { + comment.push(line.replace(/^# ?/, '')) + } else if (line.trim() === '') { + // A blank line ends a paragraph that belonged to no single setting. + if (comment.length && table.length === 0 && out.length) out.push(comment.join(' ').trim(), '') + comment = [] + } + } + flush() + return ( + generated('.env.example') + + `# Configuration + +Every setting is an environment variable, read at start, usually from a \`.env\` +file beside the binary (\`cp .env.example .env\`). The older \`DIARY_<NAME>\` +spelling of each is still accepted. A value that is set but cannot be read stops +the start with the reason rather than falling back to the default. + +## Basics + +${out.join('\n').trim()} +` + ) +} + +// --- API.md ----------------------------------------------------------------------- + +function apiPage(): string { + const readme = read('README.md') + const start = readme.indexOf('\n## API\n') + const end = readme.indexOf('\n## ', start + 1) + if (start < 0) throw new Error('README.md has no API section') + return generated('the API section of README.md') + '# API\n' + readme.slice(start + '\n## API\n'.length, end).trimEnd() + '\n' +} + +// --- writing, checking, publishing ----------------------------------------------- + +const outputs: Record<string, string> = { + 'Commands.md': commandsPage(), + 'Configuration.md': configurationPage(), + 'API.md': apiPage(), +} + +let stale = false +for (const [name, text] of Object.entries(outputs)) { + const path = join(pages, name) + const before = existsSync(path) ? readFileSync(path, 'utf8') : '' + if (before === text) continue + if (check) { + console.error(`docs/wiki/${name} is out of date: run \`make wiki\``) + stale = true + } else { + writeFileSync(path, text) + console.log(`docs/wiki/${name}`) + } +} + +const names = readdirSync(pages).filter((name) => name.endsWith('.md')) +const broken: string[] = [] +for (const name of names) { + // Code shows links as examples; only the prose around it links. + const text = readFileSync(join(pages, name), 'utf8') + .replace(/```[\s\S]*?```/g, '') + .replace(/`[^`\n]*`/g, '') + for (const [, target] of text.matchAll(/\]\(([^)\s]+)\)/g)) { + if (/^(https?:|mailto:|#)/.test(target)) continue + const path = target.split('#')[0] + const exists = path.startsWith('../') ? existsSync(join(pages, path)) : names.includes(path) + if (!exists) broken.push(`docs/wiki/${name}: ${target}`) + } +} +if (broken.length) { + console.error(`links that lead nowhere:\n ${broken.join('\n ')}`) + process.exit(1) +} +if (stale) process.exit(1) + +if (publish) { + const dir = resolve(publish) + // Everything but the wiki's own git directory is ours to replace. + if (existsSync(dir)) { + for (const entry of readdirSync(dir)) if (entry !== '.git') rmSync(join(dir, entry), { recursive: true }) + } + mkdirSync(dir, { recursive: true }) + cpSync(images, join(dir, 'images'), { recursive: true }) + for (const name of names) { + const text = readFileSync(join(pages, name), 'utf8') + .replace(/\]\(\.\.\/images\//g, '](images/') + .replace(/\]\(\.\.\/\.\.\/([^)]+)\)/g, `](https://github.com/${repo}/blob/main/$1)`) + .replace(/\]\(([\w-]+)\.md(#[^)]*)?\)/g, (_, page, anchor = '') => `](${page}${anchor})`) + writeFileSync(join(dir, name), text) + } + writeFileSync( + join(dir, '_Footer.md'), + `Written in [\`docs/wiki\`](https://github.com/${repo}/tree/main/docs/wiki) and published by CI on every push to main; an edit made here is overwritten by the next one.\n`, + ) + console.log(`wiki laid out in ${dir}`) +}