docs: a guide, a wiki and screenshots

The README grew a section per feature and is a good reference, but nothing
said where to begin. docs/wiki now has a getting-started page, a guide that
walks through one day with the workspace and explains each part where it comes
up, and a page per part of the app, each with screenshots of what it
describes.

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

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

The README opens with the banner, a CI badge, the guide, the features grouped
by what they are for, and six of the screenshots.
This commit is contained in:
nvrl committed 2026-10-21 12:07:32 +02:00
1 parent 7b2fd5579c
commit 4eb663e2d7
41 files changed
+2523 -25

No files matched your search

+2
View File
@@ -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
+12 -1
View File
@@ -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
+64 -24
View File
@@ -1,30 +1,62 @@
<p align="center">
<img src="docs/images/banner.png" alt="~/workspace" width="820" />
</p>
<p align="center">
<a href="https://github.com/narrrl/narl-workspace/actions/workflows/ci.yml"><img src="https://github.com/narrrl/narl-workspace/actions/workflows/ci.yml/badge.svg" alt="ci" /></a>
<a href="https://github.com/narrrl/narl-workspace/wiki"><img src="https://img.shields.io/badge/docs-wiki-c8684f" alt="wiki" /></a>
<img src="https://img.shields.io/badge/rust-1.96%2B-eef1ea" alt="rust 1.96+" />
<img src="https://img.shields.io/badge/svelte-5-eef1ea" alt="svelte 5" />
</p>
# ~/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
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

+59
View File
@@ -0,0 +1,59 @@
<!-- Generated by scripts/wiki.ts from the API section of README.md. Edit that, then run `make wiki`. -->
# 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 |
+97
View File
@@ -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@<version>-alpha`, carries no Proton branding,
and is not supported by Proton.
### Logging in
Once, from the workspace:
```text
:backup login <proton-username>
```
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/<uuid> every uploaded file
files/ every file in the tree again, as <space>/<folder>/<name>
```
- `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 <http://127.0.0.1:8025>.
+113
View File
@@ -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 <name>` 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 <when>`) 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).
+56
View File
@@ -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.
+222
View File
@@ -0,0 +1,222 @@
<!-- Generated by scripts/wiki.ts from web/src/lib/Help.svelte and web/src/lib/commands.ts. Edit that, then run `make wiki`. -->
# 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! <name>`, `: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 <name>` | make a folder here |
| `:cd <name\|..\|/>` | 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 <folder\|..\|/>` | 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 <title>` | 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 |
+64
View File
@@ -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. |
+58
View File
@@ -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.
+107
View File
@@ -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.
+58
View File
@@ -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).
+77
View File
@@ -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.
+88
View File
@@ -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.
+183
View File
@@ -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.
+49
View File
@@ -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. |
+93
View File
@@ -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).
+69
View File
@@ -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.
+33
View File
@@ -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.
+109
View File
@@ -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.
+73
View File
@@ -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.
+104
View File
@@ -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).
+29
View File
@@ -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)
+430
View File
@@ -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)
+274
View File
@@ -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, '&lt;')}</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}`)
}