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.
|
||||
WORKSPACE_MAIL_KINDS=reminder,card_due,digest,login
|
||||
|
||||
# --- Vault, trash and history ------------------------------------------------
|
||||
|
||||
# Minutes the vault stays open with nothing using it, before it locks itself.
|
||||
# It also locks on logout and whenever the server restarts.
|
||||
WORKSPACE_VAULT_IDLE_MIN=15
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: dev dev-api dev-web build run check clean
|
||||
.PHONY: dev dev-api dev-web build run check wiki screenshots clean
|
||||
|
||||
# Two terminals: `make dev-api` and `make dev-web` (http://127.0.0.1:4243).
|
||||
dev-api:
|
||||
@@ -20,6 +20,17 @@ check:
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test
|
||||
cd web && bun run check
|
||||
bun scripts/wiki.ts --check
|
||||
|
||||
# The generated wiki pages, from the code they describe: see docs/wiki/Development.md.
|
||||
wiki:
|
||||
bun scripts/wiki.ts
|
||||
|
||||
# docs/images, taken again from a throwaway workspace in a headless Chromium.
|
||||
screenshots:
|
||||
cd web && bun install --frozen-lockfile && bun run build
|
||||
cargo build
|
||||
bun scripts/screenshots.ts
|
||||
|
||||
clean:
|
||||
cargo clean
|
||||
|
||||
@@ -1,30 +1,62 @@
|
||||
<p align="center">
|
||||
<img src="docs/images/banner.png" alt="~/workspace" width="820" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/narrrl/narl-workspace/actions/workflows/ci.yml"><img src="https://github.com/narrrl/narl-workspace/actions/workflows/ci.yml/badge.svg" alt="ci" /></a>
|
||||
<a href="https://github.com/narrrl/narl-workspace/wiki"><img src="https://img.shields.io/badge/docs-wiki-c8684f" alt="wiki" /></a>
|
||||
<img src="https://img.shields.io/badge/rust-1.96%2B-eef1ea" alt="rust 1.96+" />
|
||||
<img src="https://img.shields.io/badge/svelte-5-eef1ea" alt="svelte 5" />
|
||||
</p>
|
||||
|
||||
# ~/workspace
|
||||
|
||||
A private, terminal-themed workspace that runs in the browser: a diary, the
|
||||
notes for work and for uni, and a board per space. One user, one SQLite file,
|
||||
one binary. It was called `narl-diary` until it grew everything but the diary.
|
||||
notes for work and for uni, a board per space, and the files that go with them.
|
||||
One user, one SQLite file, one binary. It was called `narl-diary` until it grew
|
||||
everything but the diary.
|
||||
|
||||
- **Rust + axum** backend, **SQLite** storage, **Svelte 5** frontend.
|
||||
- Spaces (`diary`, `work`, `uni`, …) with folders nested to any depth inside them.
|
||||
- Markdown documents with full-text search across a whole space (SQLite FTS5),
|
||||
reaching into the text files in the tree too,
|
||||
and math in them set by KaTeX: `$x^2$` inline, `$$…$$` or a ```` ```math ````
|
||||
block on its own.
|
||||
- Import a folder — or a zip of one — as a tree of documents, links and all.
|
||||
- A kanban board per space, so what is open at work is not mixed up with uni.
|
||||
- Drag, drop or paste images, video and audio straight into an entry.
|
||||
- Files of any kind — PDFs, archives, recordings — live in the tree beside the
|
||||
notes about them, streamed to disk however large — and PDFs, text, code,
|
||||
tables and certificates open in place.
|
||||
- Share any document behind an unguessable link — revocable at any time.
|
||||
- A vault: files sealed with AES-256-GCM under a password that is never stored,
|
||||
readable only while it is unlocked.
|
||||
- Backs itself up to Proton Drive, end-to-end encrypted, as a registered device.
|
||||
- Mails you — a reminder when the diary is empty, due cards, a weekly digest.
|
||||
- Real vim keybindings on the desktop (CodeMirror 6 + vim mode), plain taps on
|
||||
mobile.
|
||||
- The whole frontend is embedded in the release binary, so deploying is copying
|
||||
one file.
|
||||

|
||||
|
||||
**New here?** The [guide](docs/wiki/Guide.md) walks through a day with it, and
|
||||
[getting started](docs/wiki/Getting-Started.md) has it running in five minutes.
|
||||
Everything is also in the [wiki](https://github.com/narrrl/narl-workspace/wiki).
|
||||
|
||||
## What it does
|
||||
|
||||
- **Write.** Markdown documents in spaces (`diary`, `work`, `uni`, …) with
|
||||
folders to any depth. Real vim keys on the desktop (CodeMirror 6), plain taps
|
||||
on a phone. Math by KaTeX, `$x^2$` and `$$…$$`. `[[Title]]` links that are
|
||||
rewritten when what they point at is renamed or moved, and backlinks.
|
||||
- **Plan.** A kanban board per space, due dates said the way you say them
|
||||
(`fri`, `+3d`), cards that come back once done, and an agenda of what is late,
|
||||
due or in hand on every board.
|
||||
- **Remember.** A diary with a calendar, templates, the entries from this day
|
||||
in earlier years, and an evening reminder when nothing was written.
|
||||
- **Keep files.** PDFs, archives and recordings live in the tree beside the
|
||||
notes about them, streamed to disk however large. PDFs, text, code, tables
|
||||
and certificates open in place, and search reads the text files too.
|
||||
- **Find.** Full-text search across a space or all of them (SQLite FTS5).
|
||||
- **Lose nothing.** Autosave that survives a dropped network, every earlier
|
||||
version of a document as a diff, a trash with undo, an export that reads
|
||||
without this app, and an import that takes it back.
|
||||
- **Seal.** A vault: files sealed with AES-256-GCM under a password that is
|
||||
never stored.
|
||||
- **Capture.** A token per phone or script that puts a card in an inbox or a
|
||||
line in today's entry, and nothing else.
|
||||
- **Share.** Any document behind an unguessable, revocable read-only link.
|
||||
- **Mount.** The whole workspace over SFTP, for an editor on another machine.
|
||||
- **Back up.** A mirror to Proton Drive, end-to-end encrypted, as a registered
|
||||
device, and mail for reminders, due cards and a weekly digest.
|
||||
- **Deploy.** Rust and axum on the server, Svelte 5 in the browser, and the
|
||||
whole frontend embedded in the release binary, so deploying is copying one
|
||||
file.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -59,7 +91,15 @@ make dev-web # vite dev server on :4243, proxying /api to :4242
|
||||
```
|
||||
|
||||
In debug builds the binary reads `web/dist` from disk, so `cargo run` alone also
|
||||
works once the frontend has been built at least once.
|
||||
works once the frontend has been built at least once — and not before: the
|
||||
binary embeds `web/dist`, so a fresh clone builds the frontend first.
|
||||
|
||||
`make check` runs what CI runs: format, clippy, the tests, svelte-check, and a
|
||||
check that the generated wiki pages are current. The wiki is written in
|
||||
`docs/wiki` and published by CI on every push to `main`; `make wiki`
|
||||
regenerates the pages read out of the code, and `make screenshots` takes the
|
||||
pictures in `docs/images` again. See
|
||||
[Development](docs/wiki/Development.md).
|
||||
|
||||
## Keys
|
||||
|
||||
|
||||
|
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}`)
|
||||
}
|
||||