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.
No files matched your search
@@ -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.
|
# 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
|
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.
|
# Minutes the vault stays open with nothing using it, before it locks itself.
|
||||||
# It also locks on logout and whenever the server restarts.
|
# It also locks on logout and whenever the server restarts.
|
||||||
WORKSPACE_VAULT_IDLE_MIN=15
|
WORKSPACE_VAULT_IDLE_MIN=15
|
||||||
|
|||||||
@@ -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).
|
# Two terminals: `make dev-api` and `make dev-web` (http://127.0.0.1:4243).
|
||||||
dev-api:
|
dev-api:
|
||||||
@@ -20,6 +20,17 @@ check:
|
|||||||
cargo clippy --all-targets -- -D warnings
|
cargo clippy --all-targets -- -D warnings
|
||||||
cargo test
|
cargo test
|
||||||
cd web && bun run check
|
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:
|
clean:
|
||||||
cargo clean
|
cargo clean
|
||||||
|
|||||||
@@ -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
|
# ~/workspace
|
||||||
|
|
||||||
A private, terminal-themed workspace that runs in the browser: a diary, the
|
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,
|
notes for work and for uni, a board per space, and the files that go with them.
|
||||||
one binary. It was called `narl-diary` until it grew everything but the diary.
|
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),
|
**New here?** The [guide](docs/wiki/Guide.md) walks through a day with it, and
|
||||||
reaching into the text files in the tree too,
|
[getting started](docs/wiki/Getting-Started.md) has it running in five minutes.
|
||||||
and math in them set by KaTeX: `$x^2$` inline, `$$…$$` or a ```` ```math ````
|
Everything is also in the [wiki](https://github.com/narrrl/narl-workspace/wiki).
|
||||||
block on its own.
|
|
||||||
- Import a folder — or a zip of one — as a tree of documents, links and all.
|
## What it does
|
||||||
- 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.
|
- **Write.** Markdown documents in spaces (`diary`, `work`, `uni`, …) with
|
||||||
- Files of any kind — PDFs, archives, recordings — live in the tree beside the
|
folders to any depth. Real vim keys on the desktop (CodeMirror 6), plain taps
|
||||||
notes about them, streamed to disk however large — and PDFs, text, code,
|
on a phone. Math by KaTeX, `$x^2$` and `$$…$$`. `[[Title]]` links that are
|
||||||
tables and certificates open in place.
|
rewritten when what they point at is renamed or moved, and backlinks.
|
||||||
- Share any document behind an unguessable link — revocable at any time.
|
- **Plan.** A kanban board per space, due dates said the way you say them
|
||||||
- A vault: files sealed with AES-256-GCM under a password that is never stored,
|
(`fri`, `+3d`), cards that come back once done, and an agenda of what is late,
|
||||||
readable only while it is unlocked.
|
due or in hand on every board.
|
||||||
- Backs itself up to Proton Drive, end-to-end encrypted, as a registered device.
|
- **Remember.** A diary with a calendar, templates, the entries from this day
|
||||||
- Mails you — a reminder when the diary is empty, due cards, a weekly digest.
|
in earlier years, and an evening reminder when nothing was written.
|
||||||
- Real vim keybindings on the desktop (CodeMirror 6 + vim mode), plain taps on
|
- **Keep files.** PDFs, archives and recordings live in the tree beside the
|
||||||
mobile.
|
notes about them, streamed to disk however large. PDFs, text, code, tables
|
||||||
- The whole frontend is embedded in the release binary, so deploying is copying
|
and certificates open in place, and search reads the text files too.
|
||||||
one file.
|
- **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.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|  |  |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
## Quick start
|
## 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
|
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
|
## Keys
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 129 KiB |
@@ -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 |
|
||||||
@@ -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>.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **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).
|
||||||
@@ -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.
|
||||||
@@ -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 |
|
||||||
@@ -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. |
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| 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).
|
||||||
@@ -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 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
[The guide](Guide.md) goes through a whole day with the workspace and
|
||||||
|
explains each part as it comes up.
|
||||||
@@ -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`.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
`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 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 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
`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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
`:set vim` turns the vim keys on for a phone with a keyboard.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|

|
||||||
|
|
||||||
|
# ~/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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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 side by side, each in a tile of its own. | A board: the first list is the inbox, the last is done. |
|
||||||
|
|  |  |
|
||||||
|
| The agenda: what is late, due, or in hand, on every board. | The diary by month. |
|
||||||
|
|  |  |
|
||||||
|
| A search finds the text inside files too. | Certificates and keys are read on the server. |
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- `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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- `: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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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).
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/*
|
||||||
|
* The wiki, from the files it describes: `make wiki`.
|
||||||
|
*
|
||||||
|
* docs/wiki holds the pages. Three of them are not written by hand but read
|
||||||
|
* out of the code, so they cannot drift from it:
|
||||||
|
*
|
||||||
|
* - Commands.md, from the keys in Help.svelte and the commands in commands.ts,
|
||||||
|
* the same lists `:help` draws;
|
||||||
|
* - Configuration.md, from the comments and values in .env.example;
|
||||||
|
* - API.md, from the API section of README.md.
|
||||||
|
*
|
||||||
|
* `bun scripts/wiki.ts` writes those three and checks that every link between
|
||||||
|
* pages and every image resolves. `--publish <dir>` also lays the wiki out for
|
||||||
|
* GitHub: links without `.md`, images beside the pages, a footer saying where
|
||||||
|
* the pages come from. CI does that on every push to main and pushes the result
|
||||||
|
* to the wiki repository; `--check` fails when a generated page is stale.
|
||||||
|
*/
|
||||||
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
|
||||||
|
const root = resolve(import.meta.dir, '..')
|
||||||
|
const pages = join(root, 'docs/wiki')
|
||||||
|
const images = join(root, 'docs/images')
|
||||||
|
const repo = process.env.GITHUB_REPOSITORY ?? 'narrrl/narl-workspace'
|
||||||
|
const read = (path: string) => readFileSync(join(root, path), 'utf8')
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const check = args.includes('--check')
|
||||||
|
const publish = args.includes('--publish') ? args[args.indexOf('--publish') + 1] : undefined
|
||||||
|
if (args.includes('--publish') && !publish) throw new Error('--publish needs a directory')
|
||||||
|
|
||||||
|
const generated = (source: string) =>
|
||||||
|
`<!-- Generated by scripts/wiki.ts from ${source}. Edit that, then run \`make wiki\`. -->\n\n`
|
||||||
|
|
||||||
|
/** A JS string literal's value: `'it\'s'` or `"it's"`. */
|
||||||
|
function unquote(literal: string): string {
|
||||||
|
return literal.slice(1, -1).replace(/\\(.)/g, '$1')
|
||||||
|
}
|
||||||
|
const STRING = `('(?:[^'\\\\]|\\\\.)*'|"(?:[^"\\\\]|\\\\.)*")`
|
||||||
|
|
||||||
|
/** `|` inside a table cell would end it, and a GitHub wiki reads `[[x]]` as a link to page x. */
|
||||||
|
const cell = (text: string) => text.replace(/\|/g, '\\|').replace(/\[\[/g, '\\[\\[')
|
||||||
|
/** A key or command as code, safe in a table even when it holds a backtick. */
|
||||||
|
const code = (text: string) => (text.includes('`') ? `<code>${cell(text).replace(/</g, '<')}</code>` : `\`${cell(text)}\``)
|
||||||
|
|
||||||
|
// --- Commands.md ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function keyTable(source: string, name: string): string {
|
||||||
|
const start = source.indexOf(`const ${name}: [string, string][] = [`)
|
||||||
|
if (start < 0) throw new Error(`Help.svelte has no ${name}`)
|
||||||
|
const body = source.slice(start, source.indexOf('\n ]', start))
|
||||||
|
const pairs = [...body.matchAll(new RegExp(`\\[${STRING}, ${STRING}\\]`, 'g'))].map(([, key, description]) => [unquote(key), unquote(description)])
|
||||||
|
if (pairs.length === 0) throw new Error(`Help.svelte: ${name} is empty`)
|
||||||
|
// `rule` is not a key but the sentence the help column starts with.
|
||||||
|
const rule = pairs.find(([key]) => key === 'rule')
|
||||||
|
const rows = pairs.filter(([key]) => key !== 'rule').map(([key, description]) => `| ${code(key)} | ${cell(description)} |`)
|
||||||
|
const table = ['| key | what it does |', '| --- | --- |', ...rows].join('\n')
|
||||||
|
return rule ? `${rule[1][0].toUpperCase()}${rule[1].slice(1)}.\n\n${table}` : table
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Spec {
|
||||||
|
name: string
|
||||||
|
aliases: string[]
|
||||||
|
args?: string
|
||||||
|
group: string
|
||||||
|
help: string
|
||||||
|
bang?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandSpecs(source: string): Spec[] {
|
||||||
|
const start = source.indexOf('export const commands: CommandSpec[] = [')
|
||||||
|
const end = source.indexOf('\n]\n', start)
|
||||||
|
// Every command is an object literal opened on a line of its own, two in.
|
||||||
|
const blocks = source.slice(start, end).split('\n {\n').slice(1)
|
||||||
|
const field = (block: string, name: string, indent = 4) =>
|
||||||
|
new RegExp(`^${' '.repeat(indent)}${name}: ${STRING},?$`, 'm').exec(block)?.[1]
|
||||||
|
return blocks.map((block) => {
|
||||||
|
const name = field(block, 'name')
|
||||||
|
const group = field(block, 'group')
|
||||||
|
const help = field(block, 'help')
|
||||||
|
if (!name || !group || !help) throw new Error(`commands.ts: a command without a name, group or help:\n${block.slice(0, 200)}`)
|
||||||
|
const aliases = /^ aliases: \[(.*)\],$/m.exec(block)?.[1]
|
||||||
|
const bang = /^ bang: \{/m.test(block) ? field(block, 'help', 6) : undefined
|
||||||
|
const args = field(block, 'args')
|
||||||
|
return {
|
||||||
|
name: unquote(name),
|
||||||
|
group: unquote(group),
|
||||||
|
help: unquote(help),
|
||||||
|
aliases: aliases ? [...aliases.matchAll(new RegExp(STRING, 'g'))].map(([literal]) => unquote(literal)) : [],
|
||||||
|
args: args && unquote(args),
|
||||||
|
bang: bang && unquote(bang),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandsPage(): string {
|
||||||
|
const help = read('web/src/lib/Help.svelte')
|
||||||
|
const source = read('web/src/lib/commands.ts')
|
||||||
|
const groups = [.../export const groups = \[(.*)\] as const/.exec(source)![1].matchAll(new RegExp(STRING, 'g'))].map(([g]) => unquote(g))
|
||||||
|
const titles = Object.fromEntries(
|
||||||
|
[...help.matchAll(new RegExp(`^ (\\w+): ${STRING},$`, 'gm'))].map(([, group, title]) => [group, unquote(title)]),
|
||||||
|
)
|
||||||
|
const specs = commandSpecs(source)
|
||||||
|
|
||||||
|
const sections = groups.map((group) => {
|
||||||
|
const rows = specs
|
||||||
|
.filter((spec) => spec.group === group)
|
||||||
|
.flatMap((spec) => {
|
||||||
|
const names = [spec.name, ...spec.aliases].map((name) => `:${name}`).join(', ')
|
||||||
|
const usage = spec.args ? `${names} ${spec.args}` : names
|
||||||
|
const row = `| ${code(usage)} | ${cell(spec.help)} |`
|
||||||
|
return spec.bang ? [row, `| ${code(`:${spec.name}!`)} | ${cell(spec.bang)} |`] : [row]
|
||||||
|
})
|
||||||
|
const title = titles[group] ?? group
|
||||||
|
return `### ${title[0].toUpperCase()}${title.slice(1)}\n\n| command | what it does |\n| --- | --- |\n${rows.join('\n')}`
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
generated('web/src/lib/Help.svelte and web/src/lib/commands.ts') +
|
||||||
|
`# Keys and commands
|
||||||
|
|
||||||
|
Everything here is also on \`?\` (\`:help\`) inside the app, which draws the same
|
||||||
|
lists. Keys act on whatever has focus: the row under the cursor in the list, or
|
||||||
|
the document in the focused tile. Inside the editor the full vim keymap is live,
|
||||||
|
and \`:\` opens the command line, where \`Tab\` completes commands and their
|
||||||
|
arguments and \`↑\` / \`↓\` recall earlier lines.
|
||||||
|
|
||||||
|
## Keys
|
||||||
|
|
||||||
|
### Browsing and reading
|
||||||
|
|
||||||
|
${keyTable(help, 'keys')}
|
||||||
|
|
||||||
|
### On a board
|
||||||
|
|
||||||
|
${keyTable(help, 'boardKeys')}
|
||||||
|
|
||||||
|
### In the agenda
|
||||||
|
|
||||||
|
${keyTable(help, 'agendaKeys')}
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
A command takes a \`!\` for its forcing variant where one exists, the way vim's
|
||||||
|
do: \`:w!\`, \`:space! <name>\`, \`:import!\`.
|
||||||
|
|
||||||
|
${sections.join('\n\n')}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Configuration.md -------------------------------------------------------------
|
||||||
|
|
||||||
|
function configurationPage(): string {
|
||||||
|
const lines = read('.env.example').split('\n')
|
||||||
|
const out: string[] = []
|
||||||
|
let comment: string[] = []
|
||||||
|
let table: string[] = []
|
||||||
|
const flush = () => {
|
||||||
|
if (table.length) out.push('| setting | example | what it does |', '| --- | --- | --- |', ...table, '')
|
||||||
|
table = []
|
||||||
|
}
|
||||||
|
for (const line of lines) {
|
||||||
|
const section = /^# --- (.+?) -+$/.exec(line)
|
||||||
|
const setting = /^(?:# )?(WORKSPACE_\w+)=(.*)$/.exec(line)
|
||||||
|
if (section) {
|
||||||
|
flush()
|
||||||
|
out.push(`## ${section[1]}`, '')
|
||||||
|
comment = []
|
||||||
|
} else if (setting) {
|
||||||
|
const [, name, value] = setting
|
||||||
|
// A comment above two settings belongs to both: USER and PASSWORD.
|
||||||
|
const text = comment.join(' ').replace(/\s+/g, ' ').trim() || 'See the row above.'
|
||||||
|
const unset = line.startsWith('#') && !/off until/i.test(text) ? ' Unset by default.' : ''
|
||||||
|
table.push(`| \`${name}\` | ${value ? `\`${cell(value)}\`` : ''} | ${cell(text)}${unset} |`)
|
||||||
|
comment = []
|
||||||
|
} else if (line.startsWith('#')) {
|
||||||
|
comment.push(line.replace(/^# ?/, ''))
|
||||||
|
} else if (line.trim() === '') {
|
||||||
|
// A blank line ends a paragraph that belonged to no single setting.
|
||||||
|
if (comment.length && table.length === 0 && out.length) out.push(comment.join(' ').trim(), '')
|
||||||
|
comment = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return (
|
||||||
|
generated('.env.example') +
|
||||||
|
`# Configuration
|
||||||
|
|
||||||
|
Every setting is an environment variable, read at start, usually from a \`.env\`
|
||||||
|
file beside the binary (\`cp .env.example .env\`). The older \`DIARY_<NAME>\`
|
||||||
|
spelling of each is still accepted. A value that is set but cannot be read stops
|
||||||
|
the start with the reason rather than falling back to the default.
|
||||||
|
|
||||||
|
## Basics
|
||||||
|
|
||||||
|
${out.join('\n').trim()}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API.md -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function apiPage(): string {
|
||||||
|
const readme = read('README.md')
|
||||||
|
const start = readme.indexOf('\n## API\n')
|
||||||
|
const end = readme.indexOf('\n## ', start + 1)
|
||||||
|
if (start < 0) throw new Error('README.md has no API section')
|
||||||
|
return generated('the API section of README.md') + '# API\n' + readme.slice(start + '\n## API\n'.length, end).trimEnd() + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- writing, checking, publishing -----------------------------------------------
|
||||||
|
|
||||||
|
const outputs: Record<string, string> = {
|
||||||
|
'Commands.md': commandsPage(),
|
||||||
|
'Configuration.md': configurationPage(),
|
||||||
|
'API.md': apiPage(),
|
||||||
|
}
|
||||||
|
|
||||||
|
let stale = false
|
||||||
|
for (const [name, text] of Object.entries(outputs)) {
|
||||||
|
const path = join(pages, name)
|
||||||
|
const before = existsSync(path) ? readFileSync(path, 'utf8') : ''
|
||||||
|
if (before === text) continue
|
||||||
|
if (check) {
|
||||||
|
console.error(`docs/wiki/${name} is out of date: run \`make wiki\``)
|
||||||
|
stale = true
|
||||||
|
} else {
|
||||||
|
writeFileSync(path, text)
|
||||||
|
console.log(`docs/wiki/${name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = readdirSync(pages).filter((name) => name.endsWith('.md'))
|
||||||
|
const broken: string[] = []
|
||||||
|
for (const name of names) {
|
||||||
|
// Code shows links as examples; only the prose around it links.
|
||||||
|
const text = readFileSync(join(pages, name), 'utf8')
|
||||||
|
.replace(/```[\s\S]*?```/g, '')
|
||||||
|
.replace(/`[^`\n]*`/g, '')
|
||||||
|
for (const [, target] of text.matchAll(/\]\(([^)\s]+)\)/g)) {
|
||||||
|
if (/^(https?:|mailto:|#)/.test(target)) continue
|
||||||
|
const path = target.split('#')[0]
|
||||||
|
const exists = path.startsWith('../') ? existsSync(join(pages, path)) : names.includes(path)
|
||||||
|
if (!exists) broken.push(`docs/wiki/${name}: ${target}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (broken.length) {
|
||||||
|
console.error(`links that lead nowhere:\n ${broken.join('\n ')}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
if (stale) process.exit(1)
|
||||||
|
|
||||||
|
if (publish) {
|
||||||
|
const dir = resolve(publish)
|
||||||
|
// Everything but the wiki's own git directory is ours to replace.
|
||||||
|
if (existsSync(dir)) {
|
||||||
|
for (const entry of readdirSync(dir)) if (entry !== '.git') rmSync(join(dir, entry), { recursive: true })
|
||||||
|
}
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
cpSync(images, join(dir, 'images'), { recursive: true })
|
||||||
|
for (const name of names) {
|
||||||
|
const text = readFileSync(join(pages, name), 'utf8')
|
||||||
|
.replace(/\]\(\.\.\/images\//g, '](images/')
|
||||||
|
.replace(/\]\(\.\.\/\.\.\/([^)]+)\)/g, `](https://github.com/${repo}/blob/main/$1)`)
|
||||||
|
.replace(/\]\(([\w-]+)\.md(#[^)]*)?\)/g, (_, page, anchor = '') => `](${page}${anchor})`)
|
||||||
|
writeFileSync(join(dir, name), text)
|
||||||
|
}
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, '_Footer.md'),
|
||||||
|
`Written in [\`docs/wiki\`](https://github.com/${repo}/tree/main/docs/wiki) and published by CI on every push to main; an edit made here is overwritten by the next one.\n`,
|
||||||
|
)
|
||||||
|
console.log(`wiki laid out in ${dir}`)
|
||||||
|
}
|
||||||