feat: spaces, folder import, boards and mail; rename to narl-workspace

The diary grows into a workspace: a flat list of dated entries becomes a
tree of spaces, and the app is renamed to match what it now is.

Spaces and nodes
  One recursive `nodes` table replaces `entries`: a space is a root node,
  and folders and documents nest under it without limit. Entries keep
  their ids through the migration, so share tokens, media links and the
  drafts in localStorage all still resolve.

Folder import
  A folder or a zip is uploaded as a document tree. Markdown becomes
  documents titled from their first heading, everything else becomes
  media, and relative links between the imported files are rewritten to
  the paths they landed at, so a set of cross-linked notes stays
  navigable after the upload.

Boards
  A kanban board per space, with the space as the board: lists, cards,
  due dates and an optional link to a document. `diary` has none.

Mail
  SMTP notifications through an outbox. A mail is enqueued under a key
  naming the occurrence it belongs to, so a restart cannot resend and a
  relay that refuses is retried with a widening backoff. Four kinds: the
  diary reminder, cards due and overdue, a weekly digest, and logins
  from a browser that has not been seen before or a burst of failures.
  All of it is dormant until WORKSPACE_SMTP_URL is set.

Rename
  Everything user-visible says workspace; everything that already exists
  outside this repo keeps its name, because renaming it would break a
  running deployment for tidiness alone: the database file, the docker
  volume, the session cookie, the Proton device, the keyring entry and
  the localStorage keys. Configuration reads WORKSPACE_* first and falls
  back to DIARY_*, so the deployed .env keeps working untouched.
This commit is contained in:
nvrl committed 2026-09-18 14:53:22 +02:00
1 parent 76f0887ac5
commit 49b2056215
50 files changed
+5482 -967

No files matched your search

+48 -13
View File
@@ -1,41 +1,76 @@
# Every setting is read as WORKSPACE_<NAME>. The old DIARY_<NAME> spelling is
# still accepted, so a deployment configured before the rename keeps running.
# The one and only account. Both are checked in constant time on login.
DIARY_USER=narl
DIARY_PASSWORD=change-me
WORKSPACE_USER=narl
WORKSPACE_PASSWORD=change-me
# Signs session cookies. Rotate it to log every device out. 32+ random chars.
# Generate one with: openssl rand -base64 32
DIARY_SECRET=replace-with-32-random-characters
WORKSPACE_SECRET=replace-with-32-random-characters
# Where the SQLite database and uploaded media live.
DIARY_DATA_DIR=./data
WORKSPACE_DATA_DIR=./data
# Address to listen on. Put a TLS terminating reverse proxy in front of this.
DIARY_BIND=127.0.0.1:4242
WORKSPACE_BIND=127.0.0.1:4242
# How long a login lasts, in days.
DIARY_SESSION_DAYS=30
WORKSPACE_SESSION_DAYS=30
# Largest single upload, in megabytes.
DIARY_MAX_UPLOAD_MB=64
WORKSPACE_MAX_UPLOAD_MB=64
# Set to 1 once the app is served over HTTPS, so the cookie gets `Secure`.
DIARY_SECURE_COOKIE=0
WORKSPACE_SECURE_COOKIE=0
# --- Proton Drive backup -----------------------------------------------------
# Off until `narl-diary proton-login` stores a session under DIARY_DATA_DIR.
# Off until `narl-workspace proton-login` stores a session under WORKSPACE_DATA_DIR.
# These only shape how often the mirror runs.
# The device the server registers in Proton Drive; its sync root is the folder
# the diary is mirrored into.
DIARY_PROTON_DEVICE=narl-diary
WORKSPACE_PROTON_DEVICE=narl-workspace
# Backstop between runs, in minutes. 0 leaves it change-driven only.
DIARY_BACKUP_INTERVAL_MIN=60
WORKSPACE_BACKUP_INTERVAL_MIN=60
# 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.
DIARY_BACKUP_DEBOUNCE_SEC=300
WORKSPACE_BACKUP_DEBOUNCE_SEC=300
# 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.
DIARY_BACKUP_PRUNE=0
WORKSPACE_BACKUP_PRUNE=0
# --- Notification mail -------------------------------------------------------
# Off until WORKSPACE_SMTP_URL is set. Nothing is even queued while it is unset.
# 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.
# WORKSPACE_SMTP_URL=smtps://mail.example.org:465
# WORKSPACE_SMTP_USER=
# WORKSPACE_SMTP_PASSWORD=
# Where the mail comes from and goes to. FROM defaults to TO.
# WORKSPACE_MAIL_FROM=workspace@example.org
# WORKSPACE_MAIL_TO=you@example.org
# "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_TIMEZONE=Europe/Berlin
# When to ask about the diary, if nothing was written in it that day. `off`
# turns it into no reminder at all.
WORKSPACE_REMINDER_AT=20:00
# When to mail about cards that are due today or already overdue.
WORKSPACE_CARD_DUE_AT=08:00
# Weekday and time of the weekly digest.
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
Generated
+101 -1
View File
@@ -653,6 +653,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chrono-tz"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -1275,6 +1285,22 @@ dependencies = [
"zeroize",
]
[[package]]
name = "email-encoding"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd"
dependencies = [
"base64 0.23.1",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1730,6 +1756,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "hostname"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
dependencies = [
"cfg-if",
"libc",
"windows-link",
]
[[package]]
name = "http"
version = "1.5.0"
@@ -2137,6 +2174,34 @@ dependencies = [
"spin",
]
[[package]]
name = "lettre"
version = "0.11.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae"
dependencies = [
"async-trait",
"base64 0.23.1",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"hostname",
"httpdate",
"idna",
"mime",
"nom",
"percent-encoding",
"quoted_printable",
"rustls",
"socket2",
"tokio",
"tokio-rustls",
"url",
"webpki-roots",
]
[[package]]
name = "libbz2-rs-sys"
version = "0.2.5"
@@ -2324,16 +2389,19 @@ dependencies = [
]
[[package]]
name = "narl-diary"
name = "narl-workspace"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"base64 0.22.1",
"chrono",
"chrono-tz",
"dotenvy",
"hex",
"hmac 0.12.1",
"keyring",
"lettre",
"mime_guess",
"proton-drive-rs",
"proton-sdk",
@@ -2718,6 +2786,24 @@ dependencies = [
"zeroize",
]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -2976,6 +3062,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "quoted_printable"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
[[package]]
name = "r-efi"
version = "6.0.0"
@@ -3296,7 +3388,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@@ -3675,6 +3769,12 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "slab"
version = "0.4.12"
+9 -1
View File
@@ -1,5 +1,5 @@
[package]
name = "narl-diary"
name = "narl-workspace"
version = "0.1.0"
edition = "2021"
description = "A terminal-themed personal diary for the browser"
@@ -32,6 +32,14 @@ tempfile = "3.27.0"
# container deployment has no session bus and falls back to the session file.
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"], optional = true }
# Mail. rustls rather than native-tls: the container has no OpenSSL, and the
# Proton client already pulls rustls in.
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "pool", "builder", "hostname", "tokio1-rustls-tls"] }
# "did I write today?" and "is this due today?" are local-day questions, and DST
# rules out doing them with a fixed offset.
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
chrono-tz = "0.10"
proton-drive-rs = "0.6.2"
proton-sdk = "0.6.2"
rpassword = "7"
+5 -5
View File
@@ -19,7 +19,7 @@ COPY --from=web /web/dist/ ./web/dist/
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release --locked \
&& cp target/release/narl-diary /usr/local/bin/narl-diary
&& cp target/release/narl-workspace /usr/local/bin/narl-workspace
# 3. Runtime. SQLite is compiled into the binary; the only thing the image has
# to supply is a trust store. The Proton Drive client verifies TLS against
@@ -32,11 +32,11 @@ RUN apt-get update \
&& useradd --system --uid 10001 --create-home --home-dir /home/diary diary \
&& mkdir -p /data \
&& chown diary:diary /data
COPY --from=build /usr/local/bin/narl-diary /usr/local/bin/narl-diary
COPY --from=build /usr/local/bin/narl-workspace /usr/local/bin/narl-workspace
USER diary
WORKDIR /home/diary
VOLUME ["/data"]
ENV DIARY_BIND=0.0.0.0:4242 \
DIARY_DATA_DIR=/data
ENV WORKSPACE_BIND=0.0.0.0:4242 \
WORKSPACE_DATA_DIR=/data
EXPOSE 4242
ENTRYPOINT ["/usr/local/bin/narl-diary"]
ENTRYPOINT ["/usr/local/bin/narl-workspace"]
+2 -2
View File
@@ -7,13 +7,13 @@ dev-api:
dev-web:
cd web && bun run dev
# Everything, as one self-contained release binary at target/release/narl-diary.
# Everything, as one self-contained release binary at target/release/narl-workspace.
build:
cd web && bun install && bun run build
cargo build --release
run: build
./target/release/narl-diary
./target/release/narl-workspace
check:
cargo clippy --all-targets -- -D warnings
+161 -48
View File
@@ -1,13 +1,18 @@
# ~/diary
# ~/workspace
A private, terminal-themed diary that runs in the browser. One user, one SQLite
file, one binary.
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.
- **Rust + axum** backend, **SQLite** storage, **Svelte 5** frontend.
- Markdown entries with full-text search (SQLite FTS5).
- Spaces (`diary`, `work`, `uni`, …) with folders nested to any depth inside them.
- Markdown documents with full-text search across a whole space (SQLite FTS5).
- 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.
- Share any entry behind an unguessable link — revocable at any time.
- Share any document behind an unguessable link — revocable at any time.
- 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
@@ -16,16 +21,20 @@ file, one binary.
## Quick start
```sh
cp .env.example .env # set DIARY_USER, DIARY_PASSWORD and DIARY_SECRET
cp .env.example .env # set WORKSPACE_USER, WORKSPACE_PASSWORD and WORKSPACE_SECRET
make build # builds the frontend, then the release binary
./target/release/narl-diary
./target/release/narl-workspace
```
Then open <http://127.0.0.1:4242>.
`DIARY_SECRET` signs the session cookie; generate one with
Every setting is read as `WORKSPACE_<NAME>`, with the older `DIARY_<NAME>`
spelling still accepted behind it, so a server configured before the rename
keeps running untouched.
`WORKSPACE_SECRET` signs the session cookie; generate one with
`openssl rand -base64 32`. Changing it logs every device out. The database and
uploads live under `DIARY_DATA_DIR` (`./data` by default) — that directory is
uploads live under `WORKSPACE_DATA_DIR` (`./data` by default) — that directory is
the entire backup, and it is exactly what the Proton Drive mirror copies.
### Development
@@ -47,9 +56,11 @@ The desktop UI is modal. In the browse pane:
| `j` / `k` | move down / up |
| `gg` / `G` | first / last entry |
| `Enter`, `l` | open the highlighted entry |
| `o` | new entry, straight into insert mode |
| `o` | a new document here, straight into insert mode |
| `O` | a new folder here |
| `i`, `a` | edit the open entry |
| `Esc`, `h`, `q` | back out to the list |
| `Esc`, `q` | back out to the document |
| `h` | up one level in the tree |
| `/` | full-text search (`n` clears it) |
| `s` / `y` | toggle sharing / mint a new share link |
| `x`, `dd` | delete the entry |
@@ -59,46 +70,95 @@ The desktop UI is modal. In the browse pane:
Inside the editor the full vim keymap is live — `w`/`b`, `dd`, `ciw`, visual
mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are
`:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:title`, `:share`, `:link`,
`:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:name`, `:share`, `:link`,
`:upload`, `:media`, `:search`, `:backup`, `:set theme=mocha|green|amber|ice` and
`:set novim`.
`:set novim`. The tree has its own: `:space [name]` switches (`:space! <name>`
makes one), `:mkdir <name>` makes a folder, `:cd <name|..|/>` walks it and
`:mv <folder|..>` moves what is selected, and `:import` uploads a folder into
the one being browsed (`:import!` takes a `.zip` instead). The board has
`:board`, `:card <title>` (`:card!` links the open document to it),
`:due <yyyy-mm-dd|->`, `:done` and `:list <name>` (`:list!` deletes one).
On a touch device modal editing is turned off and the same actions are buttons.
## Boards
`:board` swaps the tree for the board of the space being browsed, and swaps it
back. Every space has one except the diary — a journal has no backlog — and
`:set board` / `:set noboard` changes that for the space you are in. A new
board starts with `backlog`, `doing` and `done`; `:list <name>` adds columns.
| key | action |
| --- | --- |
| `h` / `l` | to the list on the left / right |
| `j` / `k` | down / up the cards of that list |
| `J` / `K` | move the card down / up its list |
| `H` / `L` | move the card to the list on the left / right |
| `Enter` | open the document the card links to |
| `t`, `Space` | tick the card off, or un-tick it |
| `o` | a new card on this list |
| `x` | delete the card |
| `q`, `Esc` | back to the tree |
A card can point at a document: `:card!` while reading one makes a card for it,
and `Enter` on that card opens it again. The link is deliberately loose —
deleting the document leaves the card standing, because the work is still open
once its notes are gone.
## Not losing things
Every change is written to `localStorage` as it is typed and to the server a
couple of seconds after typing stops, so `:w` is a habit rather than a
necessity. If a tab dies mid-entry, opening that entry again restores the draft
and says so — `:e!` throws it away, the way vim handles a swap file.
necessity. If a tab dies mid-document, opening it again restores the draft and
says so — `:e!` throws it away, the way vim handles a swap file.
`:export` downloads the open entry as markdown. `:export!` downloads the whole
diary as a zip: one markdown file per entry named by its day, every embedded
file under its real name, and relative links between the two, so the archive
reads in any markdown viewer without this application.
`:export` downloads the open document as markdown. `:export!` downloads the
whole workspace as a zip: the tree as real directories, one markdown file per
document, every embedded file under its real name, and relative links between
them, so the archive reads in any markdown viewer without this application.
## Importing a folder
`:import` picks a folder and uploads it whole — every file carries the path it
had inside, and the tree is rebuilt on the other side: directories become
folders, `.md` and `.markdown` files become documents (titled by their first
heading), anything else becomes an attached file. `:import!` does the same with
a zip, which is the shape `:export!` writes, so a workspace can be poured back
into another one.
Links between the imported files are rewritten as they land: a link to a
markdown file becomes the document's address, a link to an image becomes its
media URL, and anything pointing outside the upload — a URL, a file that was not
uploaded — is left exactly as it was written. Absolute paths, `..` segments and
anything a zip smuggles in are refused, and the whole upload is bounded by
`WORKSPACE_MAX_UPLOAD_MB`.
A document has a real address, `/n/<space>/<folder>/<slug>`, which is what those
rewritten links point at and what the address bar shows, so any document can be
linked to from outside — behind the login, unlike a share link.
## Backing up to Proton Drive
The server can mirror itself into Proton Drive, where it appears as a device —
its own sync root, alongside the desktop clients — rather than as a folder
dropped in *My Files*. Everything is encrypted client-side before it leaves the
machine, by the same Rust SDK the Linux client uses, so Proton stores a diary it
cannot read.
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 identifies itself to Proton as
`external-drive-narl_diary@<version>-alpha` and says so before it asks for
`external-drive-narl_workspace@<version>-alpha` and says so before it asks for
account details. It carries no Proton branding and is not supported by Proton.
Set it up once, interactively, because SRP and 2FA need a human:
```sh
narl-diary proton-login # or, in Docker:
docker compose exec -it diary narl-diary proton-login
narl-workspace proton-login # or, in Docker:
docker compose exec -it workspace narl-workspace proton-login
```
That stores a session — tokens, the mailbox password needed to rebuild the key
chain, and the account key salts — as a `0600` file next to the database. It
sits on the same volume as the diary it protects, and it is enough to read the
sits on the same volume as the workspace it protects, and it is enough to read the
account, so the volume is the thing to keep private. Built with
`--features keyring` on a host that has a Secret Service, the session goes to
the OS keyring instead and the file is only a fallback; the container has no
@@ -108,16 +168,17 @@ Afterwards the server resumes on its own and nothing prompts again. Refresh
tokens are single-use, so every rotation is written back immediately — which is
also why two servers must not share one session file.
The mirror is one-way and change-driven: a write marks the diary dirty, and once
it has been quiet for `DIARY_BACKUP_DEBOUNCE_SEC` the mirror runs, with
`DIARY_BACKUP_INTERVAL_MIN` as a backstop. An hour of writing is one backup, not
The mirror is one-way and change-driven: a write marks the workspace dirty, and once
it has been quiet for `WORKSPACE_BACKUP_DEBOUNCE_SEC` the mirror runs, with
`WORKSPACE_BACKUP_INTERVAL_MIN` as a backstop. An hour of writing is one backup, not
sixty. The device folder ends up as a copy of the data directory:
```text
narl-diary/ the device — Proton allows only folders in a device root
data/ the diary's data directory, copied
narl-workspace/ the device — Proton allows only folders in a device root
data/ the data directory, copied
RESTORE.txt what this is, and how to put it back
diary.db a VACUUM INTO snapshot — consistent, no write-ahead log
(the file keeps the name it was created with)
uploads/<uuid> every uploaded file, under the name the database knows
```
@@ -128,19 +189,58 @@ back into an empty data directory — no tool in between, which is the point.
| command | what it does |
| --- | --- |
| `narl-diary proton-login` | log in and enable backups |
| `narl-diary proton-status` | account, device, schedule, how much is mirrored |
| `narl-diary backup-now` | mirror once and exit — for cron, or for nerves |
| `narl-diary proton-logout` | forget the session; the mirror stays where it is |
| `narl-workspace proton-login` | log in and enable backups |
| `narl-workspace proton-status` | account, device, schedule, how much is mirrored |
| `narl-workspace backup-now` | mirror once and exit — for cron, or for nerves |
| `narl-workspace proton-logout` | forget the session; the mirror stays where it is |
From inside the diary, `:backup` says when the last one finished and `:backup!`
From inside the workspace, `:backup` says when the last one finished and `:backup!`
runs one now. A failed backup is loud: it is reported by `:backup`, and the next
tick retries it.
Deleting an entry does not delete it from the mirror unless `DIARY_BACKUP_PRUNE`
Deleting an entry does not delete it from the mirror unless `WORKSPACE_BACKUP_PRUNE`
is on. A backup that forgets on command is one accident away from being no
backup at all.
## Mail
Off until `WORKSPACE_SMTP_URL` is set, and then four kinds of mail, all plain
text and all addressed to `WORKSPACE_MAIL_TO`:
| kind | when |
| --- | --- |
| `reminder` | at `WORKSPACE_REMINDER_AT`, if nothing was written in the diary space 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 per space, what was finished, what is still open |
| `login` | a browser signed in that has not signed in before, or a run of wrong passwords |
`WORKSPACE_MAIL_KINDS` is the list of kinds that may be sent; drop one to switch
it off. Every "today" is asked in `WORKSPACE_TIMEZONE`, because whether an entry
was written today depends on where you are, and a day is not always 24 hours
long where the clocks change.
Nothing is sent straight from the code that decides a mail is due. It goes into
an outbox table under a key that names the occurrence — `reminder:2026-09-18`,
`card_due:41:2026-09-20:overdue` — and a tick a minute drains it. A restart
cannot resend yesterday's reminder, because the row is already there; a relay
that refuses cannot lose one, because the row is still there and is retried with
a widening gap, up to an hour apart.
The login mail says *a new browser*, not *a new person*: the device is
remembered as a SHA-256 of its `User-Agent`, which is honest about what it can
tell and needs no proxy header to be trusted for it.
```sh
narl-workspace mail-test # one mail, straight out, bypassing the outbox
narl-workspace mail-status # what is configured, what is queued, what failed
```
SMTP settings are wrong the first time, always. `compose.override.yml.example`
carries a [mailpit](https://mailpit.axllent.org/) service for exactly that:
point `WORKSPACE_SMTP_URL` at `smtp://mailpit:1025` and read the
reminders at <http://127.0.0.1:8025> before involving a real relay.
## Sharing
`:share` mints two 192-bit random secrets and copies
@@ -171,20 +271,30 @@ Everything except the two share routes requires the session cookie.
| --- | --- | --- |
| `POST` | `/api/login`, `/api/logout` | session |
| `GET` | `/api/me` | current user |
| `GET`/`POST` | `/api/entries` | list (`?q=` searches) / create |
| `GET`/`PUT`/`DELETE` | `/api/entries/{id}` | read / update / delete |
| `GET` | `/api/export` | every entry and file, as a zip |
| `GET`/`POST` | `/api/spaces` | the spaces / create one |
| `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`) / create |
| `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / delete (a delete takes the subtree) |
| `GET` | `/api/spaces/{id}/board` | the lists and cards of one space |
| `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, done, link) / delete |
| `POST` | `/api/cards/{id}/move` | to another list and place in it |
| `POST` | `/api/nodes/{id}/move` | reparent, and place among the new siblings |
| `POST` | `/api/nodes/{id}/import` | import a folder or zip (multipart) under this node |
| `GET` | `/api/resolve?path=` | the node behind a slug path, `work/notes/monday` |
| `POST`/`DELETE` | `/api/nodes/{id}/share` | mint or rotate / revoke a share link |
| `GET` | `/api/export` | every document and file, as a zip |
| `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now |
| `POST`/`DELETE` | `/api/entries/{id}/share` | mint or rotate / revoke a share link |
| `GET`/`POST` | `/api/media` | list / upload (multipart) |
| `GET`/`DELETE` | `/api/media/{id}` | serve / delete a file |
| `GET` | `/api/share/{token}/{key}` | public: read a shared entry |
| `GET` | `/api/share/{token}/{key}/media/{id}` | public: media inside a shared entry |
| `GET` | `/api/share/{token}/{key}` | public: read a shared document |
| `GET` | `/api/share/{token}/{key}/media/{id}` | public: media inside a shared document |
## Deploying
Put a TLS-terminating reverse proxy in front of it, point it at `DIARY_BIND`,
and set `DIARY_SECURE_COOKIE=1`. Nothing else is needed: no database server, no
Put a TLS-terminating reverse proxy in front of it, point it at `WORKSPACE_BIND`,
and set `WORKSPACE_SECURE_COOKIE=1`. Nothing else is needed: no database server, no
runtime dependencies, no build tools on the host.
The app sends its own `Content-Security-Policy`, `Referrer-Policy: no-referrer`,
@@ -199,18 +309,21 @@ is handed back as an opaque download.
### Docker
```sh
cp .env.example .env # set DIARY_USER, DIARY_PASSWORD, DIARY_SECRET
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` (~86 MB), running as an unprivileged
user. `DIARY_BIND` and `DIARY_DATA_DIR` are forced in `compose.yml`, so the
user. `WORKSPACE_BIND` and `WORKSPACE_DATA_DIR` are forced in `compose.yml`, so the
values in `.env` do not have to change; everything else is read from `.env`.
Database and uploads live in the `diary-data` volume and survive recreation.
Database and uploads live in the `diary-data` volume — named for what it held
when it was made — and survive recreation. Keep the project name if you rename
the directory (`COMPOSE_PROJECT_NAME=narl-diary`), or compose will look for the
volume under a new name and find an empty one.
By default the port is published on `127.0.0.1:4242` only. To serve it through
traefik instead, copy `compose.override.yml.example` to `compose.override.yml`
and adjust the host — it joins the external `narl` network, drops the published
port, and routes to container port 4242. Set `DIARY_SECURE_COOKIE=1` in `.env`
port, and routes to container port 4242. Set `WORKSPACE_SECURE_COOKIE=1` in `.env`
once it is served over HTTPS.
+20 -5
View File
@@ -1,6 +1,6 @@
# Copy to compose.override.yml on the host that runs traefik.
services:
diary:
workspace:
ports: !override []
networks:
- narl
@@ -8,10 +8,25 @@ services:
labels:
- "traefik.enable=true"
- "traefik.docker.network=narl"
- "traefik.http.routers.diary.rule=Host(`diary.narl.io`)"
- "traefik.http.routers.diary.entrypoints=websecure"
- "traefik.http.routers.diary.tls.certresolver=https_resolver"
- "traefik.http.services.diary.loadbalancer.server.port=4242"
- "traefik.http.routers.workspace.rule=Host(`workspace.narl.io`)"
- "traefik.http.routers.workspace.entrypoints=websecure"
- "traefik.http.routers.workspace.tls.certresolver=https_resolver"
- "traefik.http.services.workspace.loadbalancer.server.port=4242"
# Mail, locally. Mailpit accepts everything on 1025 and shows it on 8025, so
# the reminders can be read before a real relay is ever involved:
#
# WORKSPACE_SMTP_URL=smtp://mailpit:1025
# WORKSPACE_MAIL_TO=you@example.org
#
# Nothing it receives leaves the host, which is the point of testing here.
mailpit:
image: axllent/mailpit:latest
restart: unless-stopped
networks:
- internal_net
ports:
- "127.0.0.1:8025:8025"
networks:
narl:
+6 -4
View File
@@ -1,16 +1,18 @@
services:
diary:
workspace:
build: .
image: narl-diary:latest
image: narl-workspace:latest
restart: unless-stopped
env_file:
- .env
environment:
# These win over anything in .env: inside the container the app must
# listen on all interfaces and keep its state on the volume.
DIARY_BIND: 0.0.0.0:4242
DIARY_DATA_DIR: /data
WORKSPACE_BIND: 0.0.0.0:4242
WORKSPACE_DATA_DIR: /data
volumes:
# Named for what it held when it was made. Renaming it would mean moving
# the database into a new volume by hand, which is no kind of upgrade.
- diary-data:/data
networks:
- internal_net
+103
View File
@@ -0,0 +1,103 @@
-- The diary grows sub-structures. A flat list of dated entries becomes a tree:
-- top-level spaces (diary, work, uni, ...), folders inside them to any depth,
-- and documents as the leaves. One recursive table holds all three, so a space
-- is simply a node without a parent and nesting costs nothing extra.
--
-- Ids are carried over unchanged. Share tokens already handed out, the
-- entry/media links and the `diary:draft:<id>` keys browsers hold in
-- localStorage all key on the entry id, and none of them can be rewritten from
-- here. The `diary` space therefore takes an id above every existing entry.
CREATE TABLE nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER REFERENCES nodes (id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('space', 'folder', 'document')),
-- A document's title, a folder's or space's name.
name TEXT NOT NULL DEFAULT '',
-- URL-safe, unique among siblings: the path segment in /n/<space>/<slug>.
slug TEXT NOT NULL,
-- Documents only; folders and spaces keep it empty.
body TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL DEFAULT 0,
-- Spaces only. The diary is a journal, not a project, so it has no board.
has_board INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
share_token TEXT UNIQUE,
share_key_hash TEXT
);
CREATE UNIQUE INDEX idx_nodes_parent_slug ON nodes (parent_id, slug);
CREATE INDEX idx_nodes_parent ON nodes (parent_id, position, created_at DESC);
CREATE INDEX idx_nodes_share_token ON nodes (share_token);
-- Same shape and same reasoning as entry_media in 0002: a file may be embedded
-- by more than one document, and deleting one of them must not take the file
-- away from the others.
CREATE TABLE node_media (
node_id INTEGER NOT NULL REFERENCES nodes (id) ON DELETE CASCADE,
media_id TEXT NOT NULL REFERENCES media (id) ON DELETE CASCADE,
PRIMARY KEY (node_id, media_id)
) WITHOUT ROWID;
CREATE INDEX idx_node_media_media_id ON node_media (media_id);
-- The three spaces to start from. Their ids sit above every entry id so the
-- documents below can keep theirs.
INSERT INTO nodes (id, parent_id, kind, name, slug, position, has_board, created_at, updated_at)
VALUES
((SELECT COALESCE(MAX(id), 0) FROM entries) + 1, NULL, 'space', 'diary', 'diary', 0, 0,
strftime('%s', 'now'), strftime('%s', 'now')),
((SELECT COALESCE(MAX(id), 0) FROM entries) + 2, NULL, 'space', 'work', 'work', 1, 1,
strftime('%s', 'now'), strftime('%s', 'now')),
((SELECT COALESCE(MAX(id), 0) FROM entries) + 3, NULL, 'space', 'uni', 'uni', 2, 1,
strftime('%s', 'now'), strftime('%s', 'now'));
-- Every entry becomes a document in the diary space, id intact. The slug
-- carries the day the entry is about and its id, which reads well in a URL and
-- cannot collide with a second entry written on the same day.
INSERT INTO nodes (id, parent_id, kind, name, slug, body, position,
created_at, updated_at, share_token, share_key_hash)
SELECT e.id,
(SELECT id FROM nodes WHERE parent_id IS NULL AND slug = 'diary'),
'document',
e.title,
date(e.created_at, 'unixepoch') || '-' || e.id,
e.body,
0,
e.created_at,
e.updated_at,
e.share_token,
e.share_key_hash
FROM entries e;
INSERT INTO node_media (node_id, media_id)
SELECT entry_id, media_id FROM entry_media;
-- Search follows. Folder and space names are indexed alongside document bodies,
-- because "where did I put that folder" is the same question as "where did I
-- write that sentence".
CREATE VIRTUAL TABLE nodes_fts USING fts5 (
name, body, content = 'nodes', content_rowid = 'id', tokenize = 'unicode61'
);
CREATE TRIGGER nodes_ai AFTER INSERT ON nodes BEGIN
INSERT INTO nodes_fts (rowid, name, body) VALUES (new.id, new.name, new.body);
END;
CREATE TRIGGER nodes_ad AFTER DELETE ON nodes BEGIN
INSERT INTO nodes_fts (nodes_fts, rowid, name, body) VALUES ('delete', old.id, old.name, old.body);
END;
CREATE TRIGGER nodes_au AFTER UPDATE ON nodes BEGIN
INSERT INTO nodes_fts (nodes_fts, rowid, name, body) VALUES ('delete', old.id, old.name, old.body);
INSERT INTO nodes_fts (rowid, name, body) VALUES (new.id, new.name, new.body);
END;
-- The rows above were inserted before the triggers existed.
INSERT INTO nodes_fts (nodes_fts) VALUES ('rebuild');
DROP TRIGGER entries_ai;
DROP TRIGGER entries_ad;
DROP TRIGGER entries_au;
DROP TABLE entries_fts;
DROP TABLE entry_media;
DROP TABLE entries;
+45
View File
@@ -0,0 +1,45 @@
-- A board per space, so "what am I working on" can be asked per space instead
-- of across everything at once. There is no board table: a space *is* its
-- board, and `nodes.has_board` decides whether the view exists. The diary is a
-- journal rather than a project and keeps that flag off.
CREATE TABLE board_lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- A space. Dropping the space drops its board with it.
space_id INTEGER NOT NULL REFERENCES nodes (id) ON DELETE CASCADE,
name TEXT NOT NULL,
position INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX idx_board_lists_space ON board_lists (space_id, position);
CREATE TABLE cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER NOT NULL REFERENCES board_lists (id) ON DELETE CASCADE,
-- The document this card is about, if any. Deleting that document must not
-- delete the card: the work is still open, only its notes are gone.
node_id INTEGER REFERENCES nodes (id) ON DELETE SET NULL,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
due_at INTEGER,
done_at INTEGER,
position INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_cards_list ON cards (list_id, position);
CREATE INDEX idx_cards_node ON cards (node_id);
-- The reminder mails in the next phase ask "what is due", which is a question
-- about every board at once.
CREATE INDEX idx_cards_due ON cards (due_at) WHERE due_at IS NOT NULL AND done_at IS NULL;
-- The three columns every board starts with, for the spaces that already exist.
INSERT INTO board_lists (space_id, name, position, created_at)
SELECT n.id, l.name, l.position, strftime('%s', 'now')
FROM nodes n
CROSS JOIN (SELECT 'backlog' AS name, 0 AS position
UNION ALL SELECT 'doing', 1
UNION ALL SELECT 'done', 2) l
WHERE n.parent_id IS NULL AND n.kind = 'space' AND n.has_board <> 0;
+37
View File
@@ -0,0 +1,37 @@
-- Mail is sent through an outbox rather than straight from the code that
-- decides a mail is due. Two reasons, both of which have bitten every reminder
-- that skips this step: a restart must not send yesterday's reminder again, and
-- a relay that is down for an hour must not lose the mail it refused.
--
-- `dedupe_key` carries the whole idea. It spells out *which* occurrence this is
-- ("reminder:2026-09-18", "card_due:41:2026-09-20:overdue"), so deciding
-- whether a mail was already sent is an insert that either takes or does not.
CREATE TABLE notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
dedupe_key TEXT NOT NULL UNIQUE,
subject TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER NOT NULL,
sent_at INTEGER,
attempts INTEGER NOT NULL DEFAULT 0,
-- When the last attempt was made, which is what the backoff is measured
-- from; a count alone cannot say whether it is time to try again.
last_attempt_at INTEGER,
last_error TEXT
);
-- The sweep asks one question every minute: what is still unsent?
CREATE INDEX idx_notifications_pending ON notifications (created_at) WHERE sent_at IS NULL;
-- Which browsers have logged in. The hash is over the User-Agent, so this can
-- honestly say "a browser you have not used before", not "someone else" — and
-- it needs no proxy header to be trusted for it.
CREATE TABLE login_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_hash TEXT NOT NULL UNIQUE,
user_agent TEXT NOT NULL,
first_seen_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
+14 -2
View File
@@ -9,6 +9,8 @@ use subtle::ConstantTimeEq;
use crate::{config::Config, error::AppError, state::AppState};
// Kept from before the rename: the name is never seen, and changing it would
// sign every browser out to no purpose.
pub const COOKIE_NAME: &str = "diary_session";
type HmacSha256 = Hmac<Sha256>;
@@ -20,7 +22,7 @@ fn sign(secret: &[u8], payload: &str) -> String {
}
/// A session token is `base64(expiry_unix_seconds).base64(hmac)`. There is no
/// server-side session store: rotating `DIARY_SECRET` invalidates every session.
/// server-side session store: rotating `WORKSPACE_SECRET` invalidates every session.
pub fn issue_token(config: &Config) -> String {
let expires = crate::now() + config.session_days * 86_400;
let payload = B64.encode(expires.to_string());
@@ -98,7 +100,7 @@ impl FromRequestParts<AppState> for Session {
#[cfg(test)]
mod tests {
use super::{credentials_match, issue_token, verify_token};
use crate::config::{BackupConfig, Config};
use crate::config::{BackupConfig, Config, MailConfig};
fn config(session_days: i64) -> Config {
Config {
@@ -116,6 +118,16 @@ mod tests {
debounce: std::time::Duration::from_secs(0),
prune: false,
},
mail: MailConfig {
url: None,
from: String::new(),
to: String::new(),
timezone: chrono_tz::UTC,
reminder_at: None,
card_due_at: None,
digest_at: None,
kinds: Vec::new(),
},
}
}
+10 -10
View File
@@ -1,16 +1,16 @@
//! Automatic backup of the whole data directory to Proton Drive.
//!
//! The server registers itself as a Drive *device* — a sync root with its own
//! share — and keeps that folder equal to `DIARY_DATA_DIR`. It is deliberately
//! share — and keeps that folder equal to `WORKSPACE_DATA_DIR`. It is deliberately
//! one-way: this is a backup, not a two-master sync, so nothing that happens in
//! Proton Drive can ever reach back and change the diary.
//! Proton Drive can ever reach back and change the workspace.
//!
//! It is dormant until someone runs `narl-diary proton-login` once. Nothing
//! It is dormant until someone runs `narl-workspace proton-login` once. Nothing
//! else in the application depends on it, and a Proton outage costs log lines,
//! never a write.
//!
//! Runs are change-driven with a timer as a backstop: entry and media routes
//! call [`Backup::signal`], the loop waits for the diary to fall quiet, and
//! call [`Backup::signal`], the loop waits for the workspace to fall quiet, and
//! mirrors once. An hour of writing is one backup, not sixty.
pub mod proton;
@@ -72,7 +72,7 @@ impl Backup {
config,
store,
wake: Notify::new(),
// The diary may have been written to while this process was down,
// The workspace may have been written to while this process was down,
// and a restart is the cheapest moment to find out.
dirty: AtomicBool::new(true),
status: Mutex::new(Status {
@@ -168,7 +168,7 @@ impl Backup {
/// Nothing is written to the device's own root, because Proton refuses it —
/// a device root holds folders, and a file there is answered with 422
/// "Cannot create file at the root of a device". `data/` is that folder,
/// and it is also the honest name for what it holds: the diary's data
/// and it is also the honest name for what it holds: the workspace's data
/// directory, copied.
///
/// Remembering them is the point. Resolving either by name means listing a
@@ -278,7 +278,7 @@ impl Backup {
pub fn spawn(backup: Arc<Backup>) {
if backup.store.load().ok().flatten().is_none() {
tracing::info!(
"Proton Drive backups are not configured — run `narl-diary proton-login` to enable them"
"Proton Drive backups are not configured — run `narl-workspace proton-login` to enable them"
);
return;
}
@@ -301,7 +301,7 @@ pub fn spawn(backup: Arc<Backup>) {
}
}
// Let the diary fall quiet. Every further change restarts the wait,
// Let the workspace fall quiet. Every further change restarts the wait,
// so a writing session is mirrored once, when it is over.
loop {
tokio::select! {
@@ -315,11 +315,11 @@ pub fn spawn(backup: Arc<Backup>) {
});
}
/// One-shot mirror for `narl-diary backup-now`, without starting a server.
/// One-shot mirror for `narl-workspace backup-now`, without starting a server.
pub async fn run_once(db: SqlitePool, config: Arc<Config>) -> Result<Summary> {
let backup = Backup::new(db, config);
backup
.run_now()
.await?
.context("no Proton session stored — run `narl-diary proton-login` first")
.context("no Proton session stored — run `narl-workspace proton-login` first")
}
+9 -12
View File
@@ -8,7 +8,7 @@ use proton_sdk::session::ProtonApiSession;
use super::session::{SessionStore, StoredSession};
/// What Proton asks a third-party client to tell anyone it takes credentials
/// from. Shown by `narl-diary proton-login` before it asks for anything.
/// from. Shown by `narl-workspace proton-login` before it asks for anything.
pub const DISCLOSURE: &str =
"This is a third-party application not officially supported by Proton.";
@@ -20,10 +20,10 @@ pub const DISCLOSURE: &str =
/// application — a header that misrepresents its client, or imitates a
/// first-party Proton one, is a rule violation, not a cosmetic detail, and a
/// malformed one is answered with a 422 "unusual activity" that no login
/// survives. So: `narl_diary` is the application, the version is the crate's
/// survives. So: `narl_workspace` is the application, the version is the crate's
/// own, and the channel follows it — a `0.x` release is not a stable one, and
/// saying otherwise would be the same misrepresentation in miniature.
const APP_NAME: &str = "narl_diary";
const APP_NAME: &str = "narl_workspace";
fn app_version() -> String {
let version = env!("CARGO_PKG_VERSION");
@@ -34,11 +34,8 @@ fn app_version() -> String {
/// The identification headers. Overridable only for the case the shape itself
/// changes on Proton's side; the honest default is the one above.
fn client_config() -> ProtonClientConfiguration {
let version = std::env::var("DIARY_PROTON_APP_VERSION")
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(app_version);
let agent = format!("narl-diary/{}", env!("CARGO_PKG_VERSION"));
let version = crate::config::var("PROTON_APP_VERSION").unwrap_or_else(app_version);
let agent = format!("narl-workspace/{}", env!("CARGO_PKG_VERSION"));
ProtonClientConfiguration::new(version).with_user_agent(agent)
}
@@ -95,7 +92,7 @@ pub async fn resume(store: &SessionStore) -> Result<Option<ProtonDriveClient>> {
if stored.key_salts.is_empty() {
return Err(anyhow!(
"the stored Proton session has no key salts — run `narl-diary proton-login` again"
"the stored Proton session has no key salts — run `narl-workspace proton-login` again"
));
}
@@ -119,7 +116,7 @@ pub async fn resume(store: &SessionStore) -> Result<Option<ProtonDriveClient>> {
stored.mailbox_password.clone().into_bytes(),
stored.key_salts.clone(),
)
// A diary is mostly small files; one atomic request beats the
// A workspace is mostly small files; one atomic request beats the
// draft/block/commit dance for anything that fits in a single block.
.with_small_file_upload(true);
@@ -128,7 +125,7 @@ pub async fn resume(store: &SessionStore) -> Result<Option<ProtonDriveClient>> {
/// The device this server owns, registered on first use.
///
/// A device is a sync root with its own share, which is what makes the diary
/// A device is a sync root with its own share, which is what makes the workspace
/// appear in Proton Drive as a machine rather than as a folder someone dropped
/// in My Files — and keeps it out of the way of everything else in the account.
pub async fn ensure_device(client: &ProtonDriveClient, name: &str) -> Result<Device> {
@@ -138,7 +135,7 @@ pub async fn ensure_device(client: &ProtonDriveClient, name: &str) -> Result<Dev
.context("could not list the account's devices")?;
// A device whose name failed to decrypt is not ours to claim: adopting it
// would mirror the diary into a stranger of a folder.
// would mirror the workspace into a stranger of a folder.
if let Some(device) = devices
.into_iter()
.find(|d| d.name.as_deref().map(|n| n == name).unwrap_or(false))
+4 -2
View File
@@ -85,9 +85,9 @@ impl StoredSession {
/// Where a session is kept.
///
/// The OS keyring is the right home for this and is what the desktop client
/// uses — but it is a Secret Service on D-Bus, and the diary's supported
/// uses — but it is a Secret Service on D-Bus, and the workspace's supported
/// deployment is a container, where there is neither. So the default is a
/// `0600` file next to the database: on the same volume as the diary it is
/// `0600` file next to the database: on the same volume as the workspace it is
/// protecting, and no weaker than the thing it sits beside. Build with
/// `--features keyring` on a host that has a session bus to use the keyring
/// instead, with the file as the fallback when it cannot be reached.
@@ -174,6 +174,8 @@ fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
}
#[cfg(feature = "keyring")]
// Names an entry that may already exist in the OS keyring; renaming it would
// lose the session it points at.
const KEYRING_SERVICE: &str = "narl-diary";
#[cfg(feature = "keyring")]
const KEYRING_USER: &str = "proton-session";
+11 -10
View File
@@ -1,17 +1,17 @@
//! The mirror itself: make the device folder look like `DIARY_DATA_DIR`.
//! The mirror itself: make the device folder look like `WORKSPACE_DATA_DIR`.
//!
//! The remote layout is deliberately the local one, not a rendering of it:
//!
//! ```text
//! narl-diary/ the device's sync root — folders only, per Proton
//! data/ the diary's data directory, copied
//! narl-workspace/ the device's sync root — folders only, per Proton
//! data/ the workspace's data directory, copied
//! RESTORE.txt what this is and how to put it back
//! diary.db a consistent snapshot, one revision per change
//! uploads/<uuid> every uploaded file, under the name the database knows
//! ```
//!
//! so restoring is copying two things into an empty data directory, with no
//! tool in between. The readable-anywhere view of the diary is what `:export!`
//! tool in between. The readable-anywhere view of the workspace is what `:export!`
//! is for; this is the copy that can be *restored*.
use std::path::{Path, PathBuf};
@@ -34,17 +34,18 @@ pub struct Summary {
}
const RESTORE: &str = "\
This is a Proton Drive mirror of a ~/diary server. It is the contents of the
This is a Proton Drive mirror of a ~/workspace server. It is the contents of the
server's data directory, one folder down from the device root because Proton
does not allow files directly in it.
diary.db the whole diary: entries, media metadata, share tokens
diary.db everything written: documents, boards, media metadata,
share tokens (the file keeps its original name)
uploads/<uuid> every uploaded file; diary.db gives each one its real name
To restore, stop the diary, empty its data directory, and copy both back:
To restore, stop the workspace, empty its data directory, and copy both back:
DIARY_DATA_DIR/diary.db
DIARY_DATA_DIR/uploads/
WORKSPACE_DATA_DIR/diary.db
WORKSPACE_DATA_DIR/uploads/
The database is a snapshot taken with `VACUUM INTO`, so it is internally
consistent and carries no write-ahead log: it can be opened directly.
@@ -357,7 +358,7 @@ pub(crate) fn parse_uid(text: &str) -> Option<NodeUid> {
}
/// Streaming sha256 of a file, on the blocking pool: the database snapshot is
/// as large as the diary, and photographs are larger.
/// as large as the workspace, and photographs are larger.
async fn hash_file(path: PathBuf) -> Result<String> {
tokio::task::spawn_blocking(move || -> Result<String> {
let mut file = std::fs::File::open(&path)
+102 -12
View File
@@ -5,24 +5,29 @@
//! stored session, so the server itself never prompts for anything. In Docker:
//!
//! ```sh
//! docker compose exec -it diary narl-diary proton-login
//! docker compose exec -it workspace narl-workspace proton-login
//! ```
use std::io::{IsTerminal, Write};
use std::{
io::{IsTerminal, Write},
sync::Arc,
};
use anyhow::{bail, Context, Result};
use sqlx::SqlitePool;
use sqlx::{Row, SqlitePool};
use crate::{backup, config::Config};
use crate::{backup, config::Config, notify};
pub const USAGE: &str = "\
narl-diary — a terminal-themed personal diary
narl-workspace — a terminal-themed personal workspace
narl-diary serve the diary (the default)
narl-diary proton-login log in to Proton Drive and enable backups
narl-diary proton-status show whether backups are configured
narl-diary proton-logout forget the stored Proton session
narl-diary backup-now mirror to Proton Drive once and exit
narl-workspace serve the workspace (the default)
narl-workspace proton-login log in to Proton Drive and enable backups
narl-workspace proton-status show whether backups are configured
narl-workspace proton-logout forget the stored Proton session
narl-workspace backup-now mirror to Proton Drive once and exit
narl-workspace mail-test send one mail through the configured relay
narl-workspace mail-status show what mail is configured and what is queued
";
pub async fn proton_login(config: &Config) -> Result<()> {
@@ -58,7 +63,7 @@ pub async fn proton_login(config: &Config) -> Result<()> {
.await?;
println!("Logged in. The session is stored at {}.", store.path().display());
println!("Backups start with the next server start, or run `narl-diary backup-now`.");
println!("Backups start with the next server start, or run `narl-workspace backup-now`.");
Ok(())
}
@@ -76,7 +81,7 @@ pub async fn proton_status(db: &SqlitePool, config: &Config) -> Result<()> {
let store = backup::session::SessionStore::new(config.proton_session_path());
let Some(stored) = store.load()? else {
println!("Proton Drive backups are not configured.");
println!("Run `narl-diary proton-login` to enable them.");
println!("Run `narl-workspace proton-login` to enable them.");
return Ok(());
};
@@ -127,3 +132,88 @@ fn prompt(label: &str) -> Result<String> {
}
Ok(line)
}
/// One mail, straight out. SMTP credentials are wrong the first time, always,
/// and finding that out from a reminder that quietly never arrived is worse
/// than finding it out here.
pub async fn mail_test(db: SqlitePool, config: Arc<Config>) -> Result<()> {
let notifier = notify::Notifier::new(db, config)?;
notifier
.send_test()
.await
.context("the test mail could not be sent")?;
println!("Sent. If it does not arrive, the relay accepted it and something after that did not.");
Ok(())
}
pub async fn mail_status(db: &SqlitePool, config: &Config) -> Result<()> {
let mail = &config.mail;
let Some(url) = mail.url.as_deref() else {
println!("Mail is off. Set WORKSPACE_SMTP_URL to enable notifications.");
return Ok(());
};
// The URL may carry a password; only the part that identifies the relay is
// worth printing, and printing the rest into a terminal log is not.
let relay = url.split('@').next_back().unwrap_or(url);
println!("Relay: {relay}");
println!("From: {}", mail.from);
println!("To: {}", mail.to);
println!("Timezone: {}", mail.timezone);
match mail.reminder_at {
Some(at) if mail.sends("reminder") => {
println!("Reminder: {:02}:{:02} when nothing was written", at.hour, at.minute)
}
_ => println!("Reminder: off"),
}
match mail.card_due_at {
Some(at) if mail.sends("card_due") => {
println!("Cards: {:02}:{:02} for due and overdue cards", at.hour, at.minute)
}
_ => println!("Cards: off"),
}
match mail.digest_at {
Some((day, at)) if mail.sends("digest") => println!(
"Digest: {} {:02}:{:02}",
notify::weekday_name(day),
at.hour,
at.minute
),
_ => println!("Digest: off"),
}
println!("Logins: {}", if mail.sends("login") { "on" } else { "off" });
let row = sqlx::query(
"SELECT count(*) AS queued,
sum(CASE WHEN sent_at IS NULL THEN 1 ELSE 0 END) AS pending,
max(sent_at) AS last_sent
FROM notifications",
)
.fetch_one(db)
.await?;
let queued: i64 = row.get("queued");
let pending: Option<i64> = row.get("pending");
let last_sent: Option<i64> = row.get("last_sent");
println!("Outbox: {queued} mails, {} unsent", pending.unwrap_or(0));
match last_sent {
Some(at) => println!(
"Last out: {}",
crate::notify::local_stamp(config.mail.timezone, at)
),
None => println!("Last out: never"),
}
let failed = sqlx::query(
"SELECT dedupe_key, attempts, last_error FROM notifications
WHERE sent_at IS NULL AND last_error IS NOT NULL ORDER BY created_at LIMIT 5",
)
.fetch_all(db)
.await?;
for row in failed {
let key: String = row.get("dedupe_key");
let attempts: i64 = row.get("attempts");
let error: String = row.get("last_error");
println!(" {key}: {attempts} attempt(s), {error}");
}
Ok(())
}
+225 -25
View File
@@ -1,6 +1,8 @@
use std::{net::SocketAddr, path::PathBuf, time::Duration};
use anyhow::{Context, Result};
use chrono::Weekday;
use chrono_tz::Tz;
/// Runtime configuration. Everything comes from the environment (`.env` is
/// loaded on startup), because this is a deliberately single-user application.
@@ -15,10 +17,100 @@ pub struct Config {
pub max_upload_bytes: usize,
pub secure_cookie: bool,
pub backup: BackupConfig,
pub mail: MailConfig,
}
/// Time of day in the configured timezone, as `HH:MM`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimeOfDay {
pub hour: u32,
pub minute: u32,
}
/// Notification mail. Dormant until `WORKSPACE_SMTP_URL` is set — the same
/// dormant-until-configured shape the Proton mirror has, because a workspace
/// that refuses to start over an unconfigured mail relay would be a bad trade.
#[derive(Debug, Clone)]
pub struct MailConfig {
/// `smtp://host:587` or `smtps://user:pass@host:465`. `None` turns every
/// notification off, including the queue that would otherwise pile up.
pub url: Option<String>,
pub from: String,
pub to: String,
/// The timezone every "today" in the notifier is asked in.
pub timezone: Tz,
/// When to ask about the diary, if no document was written that day.
pub reminder_at: Option<TimeOfDay>,
/// When to mail about cards that are due or overdue.
pub card_due_at: Option<TimeOfDay>,
/// Weekday and time of the digest.
pub digest_at: Option<(Weekday, TimeOfDay)>,
/// The kinds that are allowed to be sent: `reminder`, `card_due`, `digest`,
/// `login`. Listing them explicitly is how a single kind is switched off.
pub kinds: Vec<String>,
}
impl MailConfig {
/// Whether mail is configured at all.
pub fn enabled(&self) -> bool {
self.url.is_some()
}
pub fn sends(&self, kind: &str) -> bool {
self.enabled() && self.kinds.iter().any(|k| k == kind)
}
}
/// `20:00`, or `off` for nothing at all.
fn parse_time(key: &str, raw: &str) -> Result<Option<TimeOfDay>> {
if raw.eq_ignore_ascii_case("off") || raw.eq_ignore_ascii_case("none") {
return Ok(None);
}
let (hour, minute) = raw
.split_once(':')
.with_context(|| format!("{key} must look like 20:00, or be `off`"))?;
let time = TimeOfDay {
hour: hour.trim().parse().with_context(|| format!("{key}: {hour:?} is not an hour"))?,
minute: minute
.trim()
.parse()
.with_context(|| format!("{key}: {minute:?} is not a minute"))?,
};
if time.hour > 23 || time.minute > 59 {
anyhow::bail!("{key} must be between 00:00 and 23:59");
}
Ok(Some(time))
}
fn parse_weekday(raw: &str) -> Option<Weekday> {
match raw.to_ascii_lowercase().as_str() {
"mon" | "monday" => Some(Weekday::Mon),
"tue" | "tuesday" => Some(Weekday::Tue),
"wed" | "wednesday" => Some(Weekday::Wed),
"thu" | "thursday" => Some(Weekday::Thu),
"fri" | "friday" => Some(Weekday::Fri),
"sat" | "saturday" => Some(Weekday::Sat),
"sun" | "sunday" => Some(Weekday::Sun),
_ => None,
}
}
/// `sun 18:00`, or `off`.
fn parse_digest(raw: &str) -> Result<Option<(Weekday, TimeOfDay)>> {
let key = "WORKSPACE_DIGEST_AT";
let Some((day, time)) = raw.split_once(char::is_whitespace) else {
if raw.eq_ignore_ascii_case("off") || raw.eq_ignore_ascii_case("none") {
return Ok(None);
}
anyhow::bail!("{key} must look like `sun 18:00`, or be `off`");
};
let day = parse_weekday(day.trim())
.with_context(|| format!("{key}: {day:?} is not a weekday like `sun`"))?;
Ok(parse_time(key, time.trim())?.map(|time| (day, time)))
}
/// The Proton Drive mirror. Dormant until a session has been stored by
/// `narl-diary proton-login`; these values only shape how often it runs.
/// `narl-workspace proton-login`; these values only shape how often it runs.
#[derive(Debug, Clone)]
pub struct BackupConfig {
/// Name of the Drive device (its sync-root folder) this server owns.
@@ -26,7 +118,7 @@ pub struct BackupConfig {
/// Backstop between runs. `None` disables the timer, leaving the mirror to
/// whatever `POST /api/backup` asks for.
pub interval: Option<Duration>,
/// How long the diary must be quiet after a change before it is mirrored,
/// How long the workspace must be quiet after a change before it is mirrored,
/// so a writing session is uploaded once rather than after every keystroke.
pub debounce: Duration,
/// Whether a file deleted here is trashed there. Off by default: a backup
@@ -34,51 +126,60 @@ pub struct BackupConfig {
pub prune: bool,
}
fn var(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|v| !v.trim().is_empty())
/// Look a setting up in the environment.
///
/// `WORKSPACE_*` is the name to use. `DIARY_*` is still read behind it, because
/// this application was called narl-diary until it grew spaces and the running
/// deployment's `.env` still says so; a rename that logs the owner out of their
/// own server would be a poor trade for a tidier prefix.
pub fn var(key: &str) -> Option<String> {
std::env::var(format!("WORKSPACE_{key}"))
.or_else(|_| std::env::var(format!("DIARY_{key}")))
.ok()
.filter(|v| !v.trim().is_empty())
}
impl Config {
pub fn from_env() -> Result<Self> {
let username = var("DIARY_USER").context("DIARY_USER must be set (see .env.example)")?;
let username = var("USER").context("WORKSPACE_USER must be set (see .env.example)")?;
let password =
var("DIARY_PASSWORD").context("DIARY_PASSWORD must be set (see .env.example)")?;
let secret = var("DIARY_SECRET")
.context("DIARY_SECRET must be set (see .env.example)")?
var("PASSWORD").context("WORKSPACE_PASSWORD must be set (see .env.example)")?;
let secret = var("SECRET")
.context("WORKSPACE_SECRET must be set (see .env.example)")?
.into_bytes();
if secret.len() < 16 {
anyhow::bail!("DIARY_SECRET must be at least 16 characters");
anyhow::bail!("WORKSPACE_SECRET must be at least 16 characters");
}
if password == "change-me" {
tracing::warn!(
"DIARY_PASSWORD is still the example value from .env.example — \
anyone who can reach this server can read the diary"
"WORKSPACE_PASSWORD is still the example value from .env.example — \
anyone who can reach this server can read the workspace"
);
}
let bind = var("DIARY_BIND")
let bind = var("BIND")
.unwrap_or_else(|| "127.0.0.1:4242".to_string())
.parse()
.context("DIARY_BIND must look like 127.0.0.1:4242")?;
.context("WORKSPACE_BIND must look like 127.0.0.1:4242")?;
let data_dir = PathBuf::from(var("DIARY_DATA_DIR").unwrap_or_else(|| "./data".into()));
let data_dir = PathBuf::from(var("DATA_DIR").unwrap_or_else(|| "./data".into()));
let session_days = var("DIARY_SESSION_DAYS")
let session_days = var("SESSION_DAYS")
.map(|v| v.parse())
.transpose()
.context("DIARY_SESSION_DAYS must be a number")?
.context("WORKSPACE_SESSION_DAYS must be a number")?
.unwrap_or(30);
let max_upload_bytes = var("DIARY_MAX_UPLOAD_MB")
let max_upload_bytes = var("MAX_UPLOAD_MB")
.map(|v| v.parse::<usize>())
.transpose()
.context("DIARY_MAX_UPLOAD_MB must be a number")?
.context("WORKSPACE_MAX_UPLOAD_MB must be a number")?
.unwrap_or(64)
* 1024
* 1024;
let secure_cookie = var("DIARY_SECURE_COOKIE")
let secure_cookie = var("SECURE_COOKIE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
@@ -92,6 +193,7 @@ impl Config {
max_upload_bytes,
secure_cookie,
backup: BackupConfig::from_env()?,
mail: MailConfig::from_env()?,
})
}
@@ -106,26 +208,83 @@ impl Config {
}
}
impl MailConfig {
fn from_env() -> Result<Self> {
let url = var("SMTP_URL");
// Both addresses are the owner's: this mails the workspace to its writer.
let to = var("MAIL_TO").unwrap_or_default();
let from = var("MAIL_FROM").unwrap_or_else(|| to.clone());
if url.is_some() && (from.is_empty() || to.is_empty()) {
anyhow::bail!("WORKSPACE_SMTP_URL is set, so WORKSPACE_MAIL_TO must be set too");
}
let timezone = var("TIMEZONE")
.unwrap_or_else(|| "Europe/Berlin".to_string())
.parse::<Tz>()
.map_err(|e| anyhow::anyhow!("WORKSPACE_TIMEZONE: {e}"))?;
let reminder_at = parse_time(
"WORKSPACE_REMINDER_AT",
&var("REMINDER_AT").unwrap_or_else(|| "20:00".into()),
)?;
let card_due_at = parse_time(
"WORKSPACE_CARD_DUE_AT",
&var("CARD_DUE_AT").unwrap_or_else(|| "08:00".into()),
)?;
let digest_at = parse_digest(&var("DIGEST_AT").unwrap_or_else(|| "sun 18:00".into()))?;
let kinds = var("MAIL_KINDS")
.unwrap_or_else(|| "reminder,card_due,digest,login".into())
.split(',')
.map(|k| k.trim().to_ascii_lowercase())
.filter(|k| !k.is_empty() && k != "off" && k != "none")
.collect::<Vec<_>>();
if let Some(unknown) = kinds
.iter()
.find(|k| !["reminder", "card_due", "digest", "login"].contains(&k.as_str()))
{
anyhow::bail!(
"WORKSPACE_MAIL_KINDS: {unknown:?} is not one of reminder, card_due, digest, login"
);
}
Ok(Self {
url,
from,
to,
timezone,
reminder_at,
card_due_at,
digest_at,
kinds,
})
}
}
impl BackupConfig {
fn from_env() -> Result<Self> {
let device_name = var("DIARY_PROTON_DEVICE").unwrap_or_else(|| "narl-diary".to_string());
// The default keeps the old name on purpose: it names a device that
// already exists in Proton Drive, and a new name there would mean a
// second device and a fresh upload of everything in it.
let device_name = var("PROTON_DEVICE").unwrap_or_else(|| "narl-diary".to_string());
let minutes = var("DIARY_BACKUP_INTERVAL_MIN")
let minutes = var("BACKUP_INTERVAL_MIN")
.map(|v| v.parse::<u64>())
.transpose()
.context("DIARY_BACKUP_INTERVAL_MIN must be a number of minutes")?
.context("WORKSPACE_BACKUP_INTERVAL_MIN must be a number of minutes")?
.unwrap_or(60);
let interval = (minutes > 0).then(|| Duration::from_secs(minutes * 60));
let debounce = Duration::from_secs(
var("DIARY_BACKUP_DEBOUNCE_SEC")
var("BACKUP_DEBOUNCE_SEC")
.map(|v| v.parse::<u64>())
.transpose()
.context("DIARY_BACKUP_DEBOUNCE_SEC must be a number of seconds")?
.context("WORKSPACE_BACKUP_DEBOUNCE_SEC must be a number of seconds")?
.unwrap_or(300),
);
let prune = var("DIARY_BACKUP_PRUNE")
let prune = var("BACKUP_PRUNE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
@@ -137,3 +296,44 @@ impl BackupConfig {
})
}
}
#[cfg(test)]
mod tests {
use super::{parse_digest, parse_time, TimeOfDay};
use chrono::Weekday;
#[test]
fn a_time_of_day_is_read_as_written() {
assert_eq!(
parse_time("T", "20:00").unwrap(),
Some(TimeOfDay { hour: 20, minute: 0 })
);
assert_eq!(
parse_time("T", "07:45").unwrap(),
Some(TimeOfDay { hour: 7, minute: 45 })
);
assert_eq!(parse_time("T", "off").unwrap(), None);
}
#[test]
fn a_time_that_does_not_exist_is_refused_rather_than_rounded() {
for raw in ["24:00", "20:60", "8", "eight", "20:00:00"] {
assert!(parse_time("T", raw).is_err(), "{raw:?}");
}
}
#[test]
fn a_digest_is_a_weekday_and_a_time() {
assert_eq!(
parse_digest("sun 18:00").unwrap(),
Some((Weekday::Sun, TimeOfDay { hour: 18, minute: 0 }))
);
assert_eq!(
parse_digest("Monday 09:30").unwrap(),
Some((Weekday::Mon, TimeOfDay { hour: 9, minute: 30 }))
);
assert_eq!(parse_digest("off").unwrap(), None);
assert!(parse_digest("someday 18:00").is_err());
assert!(parse_digest("sun").is_err());
}
}
+234
View File
@@ -0,0 +1,234 @@
//! The outbox and the SMTP transport.
//!
//! Nothing in the application sends a mail directly. A mail is *enqueued* with
//! a `dedupe_key` that names the occurrence it belongs to, and the sweep in
//! [`crate::notify`] hands whatever is unsent to the relay. The split buys the
//! two properties a reminder needs: a restart cannot resend, because the row is
//! already there, and a relay that refuses cannot lose, because the row is
//! still there.
//!
//! Mail is dormant until `WORKSPACE_SMTP_URL` is set. Unconfigured, nothing is even
//! enqueued — an outbox nobody drains would only grow.
use anyhow::{Context, Result};
use lettre::{
message::Mailbox, transport::smtp::authentication::Credentials, AsyncSmtpTransport,
AsyncTransport, Message, Tokio1Executor,
};
use sqlx::{Row, SqlitePool};
use crate::config::{self, MailConfig};
/// Mails are plain text and short. Nothing here is worth an HTML part, and a
/// diary reminder that renders as a newsletter would be faintly absurd.
pub struct Mailer {
transport: AsyncSmtpTransport<Tokio1Executor>,
from: Mailbox,
to: Mailbox,
}
/// How often a refused mail is retried before it is left alone: roughly a day
/// of doubling. A relay that has said no ten times is misconfigured, not busy,
/// and the row keeps its last error for whoever goes looking.
const MAX_ATTEMPTS: i64 = 10;
const FIRST_RETRY_SECS: i64 = 60;
const MAX_RETRY_SECS: i64 = 3600;
impl Mailer {
/// `Ok(None)` when no SMTP URL is configured — that is a state, not a fault.
pub fn new(config: &MailConfig) -> Result<Option<Self>> {
let Some(url) = config.url.as_deref() else {
return Ok(None);
};
// lettre reads the scheme, the port and any credentials out of the URL:
// `smtps://` is implicit TLS, `smtp://host?tls=required` is STARTTLS,
// and a bare `smtp://` is plain, for a local catcher and nothing else.
let transport = AsyncSmtpTransport::<Tokio1Executor>::from_url(url)
.context("WORKSPACE_SMTP_URL is not a usable SMTP URL")?;
// A URL with credentials has already set them; this only covers the
// relays that want them and a URL that keeps them out of the string.
let transport = match (config::var("SMTP_USER"), config::var("SMTP_PASSWORD")) {
(Some(user), Some(password)) => {
transport.credentials(Credentials::new(user, password))
}
_ => transport,
}
.build();
Ok(Some(Self {
transport,
from: config
.from
.parse()
.context("WORKSPACE_MAIL_FROM is not an email address")?,
to: config
.to
.parse()
.context("WORKSPACE_MAIL_TO is not an email address")?,
}))
}
pub async fn send(&self, subject: &str, body: &str) -> Result<()> {
let message = Message::builder()
.from(self.from.clone())
.to(self.to.clone())
.subject(subject)
.header(lettre::message::header::ContentType::TEXT_PLAIN)
.body(body.to_string())
.context("could not build the message")?;
self.transport
.send(message)
.await
.context("the SMTP relay refused the message")?;
Ok(())
}
}
/// Put a mail in the outbox. Returns whether this call is the one that created
/// it: a `dedupe_key` that is already there means the occurrence was handled,
/// whether or not the mail has left yet.
pub async fn enqueue(
db: &SqlitePool,
kind: &str,
dedupe_key: &str,
subject: &str,
body: &str,
) -> Result<bool> {
let result = sqlx::query(
"INSERT OR IGNORE INTO notifications (kind, dedupe_key, subject, body, created_at)
VALUES (?, ?, ?, ?, ?)",
)
.bind(kind)
.bind(dedupe_key)
.bind(subject)
.bind(body)
.bind(crate::now())
.execute(db)
.await?;
Ok(result.rows_affected() > 0)
}
/// Whether a mail that has already failed `attempts` times may be tried again.
/// 1m, 2m, 4m… up to an hour, measured from the last attempt rather than from
/// the count, because the count alone cannot say how long ago that was.
fn retry_due(attempts: i64, last_attempt_at: Option<i64>, now: i64) -> bool {
let Some(last) = last_attempt_at else {
return true;
};
if attempts >= MAX_ATTEMPTS {
return false;
}
let wait = FIRST_RETRY_SECS
.checked_shl(attempts.max(1) as u32 - 1)
.unwrap_or(MAX_RETRY_SECS)
.min(MAX_RETRY_SECS);
now - last >= wait
}
/// Send everything the outbox is holding. Failures are recorded on the row and
/// retried later; one bad message does not block the ones behind it.
pub async fn send_pending(db: &SqlitePool, mailer: &Mailer) -> Result<usize> {
let rows = sqlx::query(
"SELECT id, subject, body, attempts, last_attempt_at FROM notifications
WHERE sent_at IS NULL ORDER BY created_at LIMIT 50",
)
.fetch_all(db)
.await?;
let now = crate::now();
let mut sent = 0;
for row in rows {
let id: i64 = row.get("id");
let attempts: i64 = row.get("attempts");
if !retry_due(attempts, row.get("last_attempt_at"), now) {
continue;
}
let subject: String = row.get("subject");
let body: String = row.get("body");
match mailer.send(&subject, &body).await {
Ok(()) => {
sqlx::query(
"UPDATE notifications
SET sent_at = ?, attempts = attempts + 1, last_attempt_at = ?, last_error = NULL
WHERE id = ?",
)
.bind(crate::now())
.bind(crate::now())
.bind(id)
.execute(db)
.await?;
sent += 1;
}
Err(e) => {
let message = format!("{e:#}");
tracing::warn!(id, error = %message, "could not send a notification");
sqlx::query(
"UPDATE notifications
SET attempts = attempts + 1, last_attempt_at = ?, last_error = ?
WHERE id = ?",
)
.bind(crate::now())
.bind(&message)
.bind(id)
.execute(db)
.await?;
}
}
}
Ok(sent)
}
#[cfg(test)]
mod tests {
use super::{retry_due, MAX_ATTEMPTS};
#[test]
fn a_mail_that_has_never_been_tried_goes_out_at_once() {
assert!(retry_due(0, None, 1_000));
}
#[test]
fn a_refused_mail_waits_longer_every_time() {
assert!(!retry_due(1, Some(1_000), 1_030));
assert!(retry_due(1, Some(1_000), 1_060));
// Four failures in, the wait is eight minutes.
assert!(!retry_due(4, Some(1_000), 1_000 + 7 * 60));
assert!(retry_due(4, Some(1_000), 1_000 + 8 * 60));
// And it never grows past an hour.
assert!(retry_due(9, Some(1_000), 1_000 + 3_600));
}
#[test]
fn a_mail_the_relay_keeps_refusing_is_eventually_left_alone() {
assert!(!retry_due(MAX_ATTEMPTS, Some(1_000), 1_000 + 86_400));
}
/// The property the whole outbox rests on: the tick asks about the same
/// occurrence once a minute, all day, and only the first ask queues a mail.
#[tokio::test]
async fn an_occurrence_is_only_queued_once() {
let db = sqlx::SqlitePool::connect("sqlite::memory:")
.await
.expect("an in-memory database");
sqlx::migrate!("./migrations")
.run(&db)
.await
.expect("migrations apply");
let queue = || super::enqueue(&db, "reminder", "reminder:2026-09-18", "subject", "body");
assert!(queue().await.expect("the first ask queues"));
assert!(!queue().await.expect("the second does not"));
let queued: i64 = sqlx::query_scalar("SELECT count(*) FROM notifications")
.fetch_one(&db)
.await
.expect("counting works");
assert_eq!(queued, 1);
}
}
+20 -5
View File
@@ -3,6 +3,8 @@ mod backup;
mod cli;
mod config;
mod error;
mod mail;
mod notify;
mod routes;
mod state;
mod static_files;
@@ -38,10 +40,11 @@ async fn main() -> Result<()> {
tracing_subscriber::registry()
// rPGP warns on every key it re-serialises differently to how Proton
// wrote it — a cosmetic packet-header difference, once per unlock, that
// otherwise buries the backup's own log lines. `DIARY_LOG` overrides
// otherwise buries the backup's own log lines. `WORKSPACE_LOG` overrides
// all of this when something needs looking at.
.with(
EnvFilter::try_from_env("DIARY_LOG")
EnvFilter::try_from_env("WORKSPACE_LOG")
.or_else(|_| EnvFilter::try_from_env("DIARY_LOG"))
.unwrap_or_else(|_| EnvFilter::new("info,pgp=error")),
)
.with(tracing_subscriber::fmt::layer())
@@ -72,6 +75,14 @@ async fn main() -> Result<()> {
let db = open_db(&config).await?;
return cli::proton_status(&db, &config).await;
}
Some("mail-test") => {
let db = open_db(&config).await?;
return cli::mail_test(db, Arc::new(config)).await;
}
Some("mail-status") => {
let db = open_db(&config).await?;
return cli::mail_status(&db, &config).await;
}
Some("backup-now") => {
let db = open_db(&config).await?;
let summary = backup::run_once(db, Arc::new(config)).await?;
@@ -94,16 +105,20 @@ async fn main() -> Result<()> {
let backup = backup::Backup::new(db.clone(), Arc::clone(&config));
backup::spawn(Arc::clone(&backup));
let notify = notify::Notifier::new(db.clone(), Arc::clone(&config))?;
notify::spawn(Arc::clone(&notify));
let state = AppState {
db,
config,
login_throttle: Arc::default(),
backup,
notify,
};
serve(bind, max_upload, state).await
}
/// Open the diary database and bring it up to date. Both the server and the
/// Open the workspace database and bring it up to date. Both the server and the
/// subcommands need it, and both need the same pragmas.
async fn open_db(config: &Config) -> Result<sqlx::SqlitePool> {
let db = SqlitePoolOptions::new()
@@ -121,7 +136,7 @@ async fn open_db(config: &Config) -> Result<sqlx::SqlitePool> {
.foreign_keys(true),
)
.await
.context("could not open the diary database")?;
.context("could not open the workspace database")?;
sqlx::migrate!("./migrations")
.run(&db)
@@ -145,7 +160,7 @@ async fn serve(bind: std::net::SocketAddr, max_upload: usize, state: AppState) -
.await
.with_context(|| format!("could not bind {bind}"))?;
tracing::info!("narl-diary listening on http://{bind}");
tracing::info!("narl-workspace listening on http://{bind}");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
+546
View File
@@ -0,0 +1,546 @@
//! What is worth a mail, and when.
//!
//! One task, one tick a minute, four questions asked on it: was anything
//! written in the diary today, is a card due or overdue, is it time for the
//! weekly digest, and — from the login route rather than the tick — has a
//! browser signed in that has not signed in before.
//!
//! Every question is answered in the configured timezone. "Today" is a local
//! day, and DST means a local day is not always 86400 seconds long, so the
//! boundaries are computed rather than rounded to.
//!
//! Nothing is sent from here. Everything found is enqueued into the outbox in
//! [`crate::mail`] under a key that names the occurrence, so asking twice —
//! which the tick does, once a minute, all day — costs one insert that does
//! nothing.
use std::sync::Arc;
use anyhow::{Context, Result};
use chrono::{Datelike, Duration as ChronoDuration, NaiveDate, TimeZone, Utc, Weekday};
use chrono_tz::Tz;
use sha2::{Digest, Sha256};
use sqlx::{Row, SqlitePool};
use crate::{
config::{Config, TimeOfDay},
mail::{self, Mailer},
};
pub struct Notifier {
db: SqlitePool,
config: Arc<Config>,
/// `None` when no SMTP URL is configured; then nothing is enqueued either.
mailer: Option<Mailer>,
}
impl Notifier {
pub fn new(db: SqlitePool, config: Arc<Config>) -> Result<Arc<Self>> {
let mailer = Mailer::new(&config.mail)?;
Ok(Arc::new(Self { db, config, mailer }))
}
pub fn enabled(&self) -> bool {
self.mailer.is_some()
}
/// One pass: find what is due, then drain the outbox.
pub async fn tick(&self) -> Result<()> {
let Some(mailer) = self.mailer.as_ref() else {
return Ok(());
};
if let Err(e) = self.sweep(crate::now()).await {
// A failing sweep must not stop the outbox from draining: the mails
// already in it are the ones that matter most.
tracing::error!(error = %format!("{e:#}"), "the notification sweep failed");
}
mail::send_pending(&self.db, mailer).await?;
Ok(())
}
/// Everything the clock decides, at the instant `now`.
pub async fn sweep(&self, now: i64) -> Result<()> {
let tz = self.config.mail.timezone;
let today = local_day(tz, now);
if self.config.mail.sends("reminder") {
if let Some(at) = self.config.mail.reminder_at {
if time_reached(tz, now, today, at) {
self.sweep_reminder(today).await?;
}
}
}
if self.config.mail.sends("card_due") {
if let Some(at) = self.config.mail.card_due_at {
if time_reached(tz, now, today, at) {
self.sweep_cards(today).await?;
}
}
}
if self.config.mail.sends("digest") {
if let Some((day, at)) = self.config.mail.digest_at {
if today.weekday() == day && time_reached(tz, now, today, at) {
self.sweep_digest(today).await?;
}
}
}
Ok(())
}
/// "You have not written today." Asked once the reminder time has passed,
/// and only if the diary space gained no document that local day.
async fn sweep_reminder(&self, today: NaiveDate) -> Result<()> {
let (start, end) = day_bounds(self.config.mail.timezone, today);
let written: i64 = sqlx::query_scalar(
"WITH RECURSIVE tree (id) AS (
SELECT id FROM nodes WHERE parent_id IS NULL AND slug = 'diary'
UNION ALL
SELECT n.id FROM nodes n JOIN tree t ON n.parent_id = t.id
)
SELECT count(*) FROM nodes
WHERE kind = 'document' AND id IN (SELECT id FROM tree)
AND created_at >= ? AND created_at < ?",
)
.bind(start)
.bind(end)
.fetch_one(&self.db)
.await?;
if written > 0 {
return Ok(());
}
let subject = format!("workspace — nothing written on {today}");
let body = format!(
"No entry in the diary for {today}.\n\n\
Open the diary, press o, and write the day down while it is still today.\n"
);
self.enqueue("reminder", &format!("reminder:{today}"), &subject, &body)
.await
}
/// Cards whose due date has arrived, and cards that have slipped past it.
/// The due date is part of the key, so moving a card's date asks again.
async fn sweep_cards(&self, today: NaiveDate) -> Result<()> {
let tz = self.config.mail.timezone;
let (_, end_of_today) = day_bounds(tz, today);
let rows = sqlx::query(
"SELECT c.id, c.title, c.due_at, l.name AS list_name, s.name AS space_name
FROM cards c
JOIN board_lists l ON l.id = c.list_id
JOIN nodes s ON s.id = l.space_id
WHERE c.done_at IS NULL AND c.due_at IS NOT NULL AND c.due_at < ?
ORDER BY c.due_at",
)
.bind(end_of_today)
.fetch_all(&self.db)
.await?;
for row in rows {
let id: i64 = row.get("id");
let title: String = row.get("title");
let space: String = row.get("space_name");
let list: String = row.get("list_name");
let due = local_day(tz, row.get("due_at"));
let (stage, subject, opening) = if due < today {
let days = (today - due).num_days();
(
"overdue",
format!("{space} — overdue: {title}"),
format!("\"{title}\" was due on {due}, {days} day(s) ago."),
)
} else {
(
"due",
format!("{space} — due today: {title}"),
format!("\"{title}\" is due today, {due}."),
)
};
let body = format!("{opening}\n\nSpace: {space}\nList: {list}\n");
self.enqueue(
"card_due",
&format!("card_due:{id}:{due}:{stage}"),
&subject,
&body,
)
.await?;
}
Ok(())
}
/// The week just gone: what was written, what was finished, what is open.
async fn sweep_digest(&self, today: NaiveDate) -> Result<()> {
let tz = self.config.mail.timezone;
let (_, end) = day_bounds(tz, today);
let (start, _) = day_bounds(tz, today - ChronoDuration::days(6));
let written = sqlx::query(
"WITH RECURSIVE tree (space_id, id) AS (
SELECT id, id FROM nodes WHERE parent_id IS NULL AND kind = 'space'
UNION ALL
SELECT t.space_id, n.id FROM nodes n JOIN tree t ON n.parent_id = t.id
)
SELECT s.name AS space_name, count(*) AS written
FROM nodes n
JOIN tree t ON t.id = n.id
JOIN nodes s ON s.id = t.space_id
WHERE n.kind = 'document' AND n.created_at >= ? AND n.created_at < ?
GROUP BY s.id ORDER BY s.position",
)
.bind(start)
.bind(end)
.fetch_all(&self.db)
.await?;
let finished = sqlx::query(
"SELECT s.name AS space_name, c.title AS title
FROM cards c
JOIN board_lists l ON l.id = c.list_id
JOIN nodes s ON s.id = l.space_id
WHERE c.done_at >= ? AND c.done_at < ?
ORDER BY s.position, c.done_at",
)
.bind(start)
.bind(end)
.fetch_all(&self.db)
.await?;
let open = sqlx::query(
"SELECT s.name AS space_name, c.title AS title, c.due_at AS due_at
FROM cards c
JOIN board_lists l ON l.id = c.list_id
JOIN nodes s ON s.id = l.space_id
WHERE c.done_at IS NULL AND c.due_at IS NOT NULL AND c.due_at < ?
ORDER BY c.due_at",
)
.bind(end)
.fetch_all(&self.db)
.await?;
let mut body = format!("The week to {today}.\n\nWritten\n");
if written.is_empty() {
body.push_str(" nothing\n");
}
for row in &written {
let space: String = row.get("space_name");
let count: i64 = row.get("written");
body.push_str(&format!(" {space}: {count} document(s)\n"));
}
body.push_str("\nFinished\n");
if finished.is_empty() {
body.push_str(" nothing\n");
}
for row in &finished {
let space: String = row.get("space_name");
let title: String = row.get("title");
body.push_str(&format!(" {space}: {title}\n"));
}
body.push_str("\nStill open, due or overdue\n");
if open.is_empty() {
body.push_str(" nothing\n");
}
for row in &open {
let space: String = row.get("space_name");
let title: String = row.get("title");
let due = local_day(tz, row.get("due_at"));
body.push_str(&format!(" {space}: {title} (due {due})\n"));
}
let week = iso_week_key(today);
self.enqueue(
"digest",
&format!("digest:{week}"),
&format!("workspace — week {week}"),
&body,
)
.await
}
/// A login happened. The browser is remembered by a hash of its User-Agent,
/// and one that has not been seen before is worth a mail.
///
/// That is all the hash can honestly claim: a browser, not a person. It
/// needs no proxy header to be trusted, which is the reason to prefer it
/// over an address that a reverse proxy could be lying about.
pub async fn note_login(&self, user_agent: &str) {
if !self.config.mail.sends("login") {
return;
}
if let Err(e) = self.record_login(user_agent).await {
tracing::error!(error = %format!("{e:#}"), "could not record a login");
}
}
async fn record_login(&self, user_agent: &str) -> Result<()> {
let hash = device_hash(user_agent);
let now = crate::now();
let inserted = sqlx::query(
"INSERT INTO login_events (device_hash, user_agent, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (device_hash) DO UPDATE SET last_seen_at = excluded.last_seen_at",
)
.bind(&hash)
.bind(user_agent)
.bind(now)
.bind(now)
.execute(&self.db)
.await?
.rows_affected();
// The upsert touches a row either way, so "new" is asked separately.
let first_seen: i64 = sqlx::query_scalar(
"SELECT first_seen_at FROM login_events WHERE device_hash = ?",
)
.bind(&hash)
.fetch_one(&self.db)
.await?;
if inserted == 0 || first_seen != now {
return Ok(());
}
let when = local_stamp(self.config.mail.timezone, now);
self.enqueue(
"login",
&format!("login:{hash}"),
"workspace — signed in from a new browser",
&format!(
"A browser that has not signed in before signed in at {when}.\n\n\
User-Agent: {user_agent}\n\n\
If that was not you, change WORKSPACE_PASSWORD and rotate WORKSPACE_SECRET — \
rotating the secret signs every device out.\n"
),
)
.await
}
/// A run of wrong passwords. Enqueued at most once an hour, because the
/// point is to notice an attempt, not to be mailed by the attacker.
pub async fn note_failed_logins(&self, failures: u32) {
if !self.config.mail.sends("login") {
return;
}
let hour = local_stamp(self.config.mail.timezone, crate::now());
let hour = &hour[..13.min(hour.len())];
let outcome = self
.enqueue(
"login",
&format!("login_failed:{hour}"),
"workspace — repeated failed logins",
&format!(
"{failures} failed login attempts. The login is being answered more \
slowly with every one of them.\n"
),
)
.await;
if let Err(e) = outcome {
tracing::error!(error = %format!("{e:#}"), "could not record failed logins");
}
}
async fn enqueue(&self, kind: &str, key: &str, subject: &str, body: &str) -> Result<()> {
if mail::enqueue(&self.db, kind, key, subject, body).await? {
tracing::info!(kind, key, "queued a notification");
}
Ok(())
}
/// One mail, sent straight out, for `narl-workspace mail-test`. It skips the
/// outbox on purpose: the question it answers is whether the relay works,
/// and a queued row would answer it later or not at all.
pub async fn send_test(&self) -> Result<()> {
let mailer = self
.mailer
.as_ref()
.context("WORKSPACE_SMTP_URL is not set — mail is off")?;
let when = local_stamp(self.config.mail.timezone, crate::now());
mailer
.send(
"workspace — test",
&format!("This is narl-workspace checking its SMTP settings at {when}.\n"),
)
.await
}
}
/// Run the notifier for as long as the server lives.
pub fn spawn(notifier: Arc<Notifier>) {
if !notifier.enabled() {
tracing::info!("mail is off — set WORKSPACE_SMTP_URL to enable notifications");
return;
}
tokio::spawn(async move {
// A minute is fine-grained enough for a configuration written as HH:MM
// and coarse enough to be free.
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
if let Err(e) = notifier.tick().await {
tracing::error!(error = %format!("{e:#}"), "the notification tick failed");
}
}
});
}
/// SHA-256 of the User-Agent, hex. Not a secret — a stable name for a browser.
fn device_hash(user_agent: &str) -> String {
let digest = Sha256::digest(user_agent.as_bytes());
digest.iter().map(|b| format!("{b:02x}")).collect()
}
/// The local date an instant falls on.
fn local_day(tz: Tz, at: i64) -> NaiveDate {
Utc.timestamp_opt(at, 0)
.single()
.unwrap_or_else(Utc::now)
.with_timezone(&tz)
.date_naive()
}
/// The instant a local wall-clock time happens at.
///
/// DST makes this a question with two awkward answers: the hour that occurs
/// twice, where the earlier one is meant, and the hour that does not occur at
/// all, where the clock skipping past it counts as the time having passed.
fn local_instant(tz: Tz, day: NaiveDate, hour: u32, minute: u32) -> i64 {
let Some(naive) = day.and_hms_opt(hour, minute, 0) else {
return i64::MAX;
};
match tz.from_local_datetime(&naive).earliest() {
Some(at) => at.timestamp(),
// The wall clock never showed this time; the moment it jumped over it
// is the honest answer.
None => tz
.from_local_datetime(&(naive + ChronoDuration::hours(1)))
.earliest()
.map(|at| at.timestamp())
.unwrap_or(i64::MAX),
}
}
/// Start (inclusive) and end (exclusive) of a local day, in Unix seconds.
fn day_bounds(tz: Tz, day: NaiveDate) -> (i64, i64) {
(
local_instant(tz, day, 0, 0),
local_instant(tz, day + ChronoDuration::days(1), 0, 0),
)
}
/// Whether `at` has already come round on the local day `day`.
fn time_reached(tz: Tz, now: i64, day: NaiveDate, at: TimeOfDay) -> bool {
now >= local_instant(tz, day, at.hour, at.minute)
}
/// `2026-W38`. The digest belongs to a week, and the ISO week is the one that
/// does not change its mind about which year the first days of January are in.
fn iso_week_key(day: NaiveDate) -> String {
let week = day.iso_week();
format!("{}-W{:02}", week.year(), week.week())
}
/// `2026-09-18 20:00`, in the configured timezone — for the body of a mail,
/// where a Unix timestamp would be no use to a reader.
pub fn local_stamp(tz: Tz, at: i64) -> String {
Utc.timestamp_opt(at, 0)
.single()
.unwrap_or_else(Utc::now)
.with_timezone(&tz)
.format("%Y-%m-%d %H:%M %Z")
.to_string()
}
/// The weekday name the digest configuration uses, for the status output.
pub fn weekday_name(day: Weekday) -> &'static str {
match day {
Weekday::Mon => "Monday",
Weekday::Tue => "Tuesday",
Weekday::Wed => "Wednesday",
Weekday::Thu => "Thursday",
Weekday::Fri => "Friday",
Weekday::Sat => "Saturday",
Weekday::Sun => "Sunday",
}
}
#[cfg(test)]
mod tests {
use super::{day_bounds, device_hash, iso_week_key, local_day, time_reached};
use crate::config::TimeOfDay;
use chrono::NaiveDate;
use chrono_tz::Europe::Berlin;
fn day(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).expect("a real date")
}
const AT: TimeOfDay = TimeOfDay {
hour: 20,
minute: 0,
};
#[test]
fn a_local_day_is_not_the_utc_day_it_overlaps() {
// 22:30 UTC on the 17th is already the 18th in Berlin.
let at = day(2026, 9, 17)
.and_hms_opt(22, 30, 0)
.unwrap()
.and_utc()
.timestamp();
assert_eq!(local_day(Berlin, at), day(2026, 9, 18));
}
#[test]
fn the_reminder_waits_for_the_local_evening() {
let today = day(2026, 9, 18);
let (start, end) = day_bounds(Berlin, today);
assert_eq!(end - start, 86_400);
// 19:59 local is 17:59 UTC in summer time.
assert!(!time_reached(Berlin, start + 19 * 3600 + 59 * 60, today, AT));
assert!(time_reached(Berlin, start + 20 * 3600, today, AT));
}
#[test]
fn the_day_the_clocks_go_forward_is_an_hour_short() {
// 29 March 2026: 02:00 never happens in Berlin.
let (start, end) = day_bounds(Berlin, day(2026, 3, 29));
assert_eq!(end - start, 82_800);
// And the evening still arrives, an hour earlier in UTC than usual.
assert!(time_reached(
Berlin,
start + 19 * 3600,
day(2026, 3, 29),
AT
));
}
#[test]
fn the_day_the_clocks_go_back_is_an_hour_long() {
let (start, end) = day_bounds(Berlin, day(2026, 10, 25));
assert_eq!(end - start, 90_000);
}
#[test]
fn a_week_key_names_the_iso_week() {
assert_eq!(iso_week_key(day(2026, 9, 18)), "2026-W38");
// The first days of January belong to the week that holds most of them.
assert_eq!(iso_week_key(day(2027, 1, 1)), "2026-W53");
}
#[test]
fn a_device_hash_is_stable_and_says_nothing_by_itself() {
let hash = device_hash("Mozilla/5.0");
assert_eq!(hash.len(), 64);
assert_eq!(hash, device_hash("Mozilla/5.0"));
assert_ne!(hash, device_hash("Mozilla/5.1"));
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ pub async fn status(_: Session, State(state): State<AppState>) -> AppResult<Json
Ok(Json(state.backup.status()))
}
/// Mirror now rather than when the diary next falls quiet. Answers with the
/// Mirror now rather than when the workspace next falls quiet. Answers with the
/// status the run produced, so `:backup` can report what it did.
pub async fn run(_: Session, State(state): State<AppState>) -> AppResult<Json<Status>> {
state.backup.run_now().await?;
+553
View File
@@ -0,0 +1,553 @@
//! Kanban boards, one per space.
//!
//! The question a board answers here is "what am I working on in *this* part of
//! my life", so there is exactly one board per space and no board table: a
//! space with `has_board` set has a board, and the lists hang off the space
//! node directly. That keeps the deployment one binary and one SQLite file, and
//! the Proton mirror keeps covering everything without a second service.
//!
//! Cards may point at a document. The link is deliberately weak — deleting the
//! document sets `node_id` to NULL rather than deleting the card, because the
//! work is still open once its notes are gone.
use axum::{
extract::{Path, State},
Json,
};
use serde::{Deserialize, Serialize};
use sqlx::{Row, SqlitePool};
use crate::{
auth::Session,
error::{AppError, AppResult},
routes::nodes::SPACE,
state::AppState,
};
/// What a board starts with. Three columns is the smallest arrangement that
/// still says something: not started, in hand, finished.
const DEFAULT_LISTS: [&str; 3] = ["backlog", "doing", "done"];
#[derive(Serialize)]
pub struct Board {
pub space_id: i64,
pub space_name: String,
pub lists: Vec<BoardList>,
}
#[derive(Serialize)]
pub struct BoardList {
pub id: i64,
pub name: String,
pub position: i64,
pub cards: Vec<Card>,
}
#[derive(Serialize)]
pub struct Card {
pub id: i64,
pub list_id: i64,
pub title: String,
pub body: String,
pub node_id: Option<i64>,
/// The linked document's name, so a card can say what it points at without
/// the client reading every document on the board.
pub node_name: Option<String>,
pub due_at: Option<i64>,
pub done_at: Option<i64>,
pub position: i64,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Deserialize)]
pub struct ListInput {
pub name: String,
}
#[derive(Deserialize)]
pub struct CardInput {
pub title: String,
#[serde(default)]
pub body: String,
/// The document the card is about, if it has one.
#[serde(default)]
pub node_id: Option<i64>,
#[serde(default)]
pub due_at: Option<i64>,
}
/// Every field is optional: the client sends what changed. `null` and "absent"
/// are different for the two that can be cleared, so they are doubly wrapped —
/// `"due_at": null` drops the date, leaving it out keeps it.
#[derive(Deserialize)]
pub struct CardPatch {
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub body: Option<String>,
#[serde(default, deserialize_with = "double_option")]
pub node_id: Option<Option<i64>>,
#[serde(default, deserialize_with = "double_option")]
pub due_at: Option<Option<i64>>,
pub done: Option<bool>,
}
fn double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::Deserialize<'de>,
{
Option::deserialize(deserializer).map(Some)
}
#[derive(Deserialize)]
pub struct MoveInput {
pub list_id: i64,
/// Where among the cards of that list it lands. Past the end means last.
#[serde(default)]
pub position: Option<i64>,
}
/// `ids` with `card` taken out and put back at `to`. Pulled out of the move
/// handler because off-by-one in a reorder is silent: the card lands next to
/// where it was asked to go and nobody notices until the board reads wrong.
fn reordered(ids: &[i64], card: i64, to: usize) -> Vec<i64> {
let mut rest: Vec<i64> = ids.iter().copied().filter(|id| *id != card).collect();
rest.insert(to.min(rest.len()), card);
rest
}
fn row_to_card(row: &sqlx::sqlite::SqliteRow) -> Card {
Card {
id: row.get("id"),
list_id: row.get("list_id"),
title: row.get("title"),
body: row.get("body"),
node_id: row.get("node_id"),
node_name: row.get("node_name"),
due_at: row.get("due_at"),
done_at: row.get("done_at"),
position: row.get("position"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
}
}
/// The space behind an id, refusing anything that is not a space with a board.
async fn board_space(db: &SqlitePool, space_id: i64) -> AppResult<String> {
let row = sqlx::query("SELECT kind, name, has_board FROM nodes WHERE id = ?1")
.bind(space_id)
.fetch_optional(db)
.await?
.ok_or(AppError::NotFound)?;
if row.get::<String, _>("kind") != SPACE {
return Err(AppError::BadRequest("only a space has a board".into()));
}
if row.get::<i64, _>("has_board") == 0 {
// The diary is the case this exists for: a journal has no backlog.
return Err(AppError::BadRequest(
"this space has no board — :set board turns one on".into(),
));
}
Ok(row.get("name"))
}
/// The space a list belongs to, and the same board check along the way.
async fn list_space(db: &SqlitePool, list_id: i64) -> AppResult<i64> {
let space_id: i64 = sqlx::query("SELECT space_id FROM board_lists WHERE id = ?1")
.bind(list_id)
.fetch_optional(db)
.await?
.ok_or(AppError::NotFound)?
.get("space_id");
board_space(db, space_id).await?;
Ok(space_id)
}
/// Seed a board that has no lists yet. A space created before this feature
/// existed — or one whose board was switched on later — gets its columns the
/// first time the board is looked at, which is the only moment it matters.
async fn ensure_lists(db: &SqlitePool, space_id: i64) -> AppResult<()> {
let existing: i64 = sqlx::query("SELECT COUNT(*) AS n FROM board_lists WHERE space_id = ?1")
.bind(space_id)
.fetch_one(db)
.await?
.get("n");
if existing > 0 {
return Ok(());
}
let now = crate::now();
for (position, name) in DEFAULT_LISTS.iter().enumerate() {
sqlx::query(
"INSERT INTO board_lists (space_id, name, position, created_at) VALUES (?1, ?2, ?3, ?4)",
)
.bind(space_id)
.bind(name)
.bind(position as i64)
.bind(now)
.execute(db)
.await?;
}
Ok(())
}
pub async fn board(
_: Session,
State(state): State<AppState>,
Path(space_id): Path<i64>,
) -> AppResult<Json<Board>> {
let space_name = board_space(&state.db, space_id).await?;
ensure_lists(&state.db, space_id).await?;
load(&state, space_id, space_name).await.map(Json)
}
async fn load(state: &AppState, space_id: i64, space_name: String) -> AppResult<Board> {
let list_rows = sqlx::query(
"SELECT id, name, position FROM board_lists WHERE space_id = ?1 ORDER BY position, id",
)
.bind(space_id)
.fetch_all(&state.db)
.await?;
// One query for every card on the board rather than one per list: a board
// is small, and the round trips are what would be felt.
let card_rows = sqlx::query(
"SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at,
c.position, c.created_at, c.updated_at,
(SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name
FROM cards c
JOIN board_lists l ON l.id = c.list_id
WHERE l.space_id = ?1
ORDER BY c.position, c.id",
)
.bind(space_id)
.fetch_all(&state.db)
.await?;
let lists = list_rows
.iter()
.map(|row| {
let id: i64 = row.get("id");
BoardList {
id,
name: row.get("name"),
position: row.get("position"),
cards: card_rows
.iter()
.filter(|card| card.get::<i64, _>("list_id") == id)
.map(row_to_card)
.collect(),
}
})
.collect();
Ok(Board {
space_id,
space_name,
lists,
})
}
pub async fn create_list(
_: Session,
State(state): State<AppState>,
Path(space_id): Path<i64>,
Json(input): Json<ListInput>,
) -> AppResult<Json<Board>> {
let space_name = board_space(&state.db, space_id).await?;
let name = input.name.trim();
if name.is_empty() {
return Err(AppError::BadRequest("a list needs a name".into()));
}
let position: i64 = sqlx::query(
"SELECT COALESCE(MAX(position), -1) + 1 AS next FROM board_lists WHERE space_id = ?1",
)
.bind(space_id)
.fetch_one(&state.db)
.await?
.get("next");
sqlx::query(
"INSERT INTO board_lists (space_id, name, position, created_at) VALUES (?1, ?2, ?3, ?4)",
)
.bind(space_id)
.bind(name)
.bind(position)
.bind(crate::now())
.execute(&state.db)
.await?;
state.backup.signal();
load(&state, space_id, space_name).await.map(Json)
}
pub async fn rename_list(
_: Session,
State(state): State<AppState>,
Path(list_id): Path<i64>,
Json(input): Json<ListInput>,
) -> AppResult<Json<Board>> {
let space_id = list_space(&state.db, list_id).await?;
let name = input.name.trim();
if name.is_empty() {
return Err(AppError::BadRequest("a list needs a name".into()));
}
sqlx::query("UPDATE board_lists SET name = ?1 WHERE id = ?2")
.bind(name)
.bind(list_id)
.execute(&state.db)
.await?;
state.backup.signal();
let space_name = board_space(&state.db, space_id).await?;
load(&state, space_id, space_name).await.map(Json)
}
/// Delete a list and the cards on it. A board must keep one list, or there is
/// nowhere left to put a card.
pub async fn remove_list(
_: Session,
State(state): State<AppState>,
Path(list_id): Path<i64>,
) -> AppResult<Json<Board>> {
let space_id = list_space(&state.db, list_id).await?;
let remaining: i64 = sqlx::query("SELECT COUNT(*) AS n FROM board_lists WHERE space_id = ?1")
.bind(space_id)
.fetch_one(&state.db)
.await?
.get("n");
if remaining <= 1 {
return Err(AppError::BadRequest(
"a board needs at least one list".into(),
));
}
sqlx::query("DELETE FROM board_lists WHERE id = ?1")
.bind(list_id)
.execute(&state.db)
.await?;
state.backup.signal();
let space_name = board_space(&state.db, space_id).await?;
load(&state, space_id, space_name).await.map(Json)
}
pub async fn create_card(
_: Session,
State(state): State<AppState>,
Path(list_id): Path<i64>,
Json(input): Json<CardInput>,
) -> AppResult<Json<Card>> {
list_space(&state.db, list_id).await?;
let title = input.title.trim();
if title.is_empty() {
return Err(AppError::BadRequest("a card needs a title".into()));
}
let position: i64 = sqlx::query(
"SELECT COALESCE(MAX(position), -1) + 1 AS next FROM cards WHERE list_id = ?1",
)
.bind(list_id)
.fetch_one(&state.db)
.await?
.get("next");
let now = crate::now();
let id: i64 = sqlx::query(
"INSERT INTO cards (list_id, node_id, title, body, due_at, position, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)
RETURNING id",
)
.bind(list_id)
.bind(input.node_id)
.bind(title)
.bind(&input.body)
.bind(input.due_at)
.bind(position)
.bind(now)
.fetch_one(&state.db)
.await?
.get("id");
state.backup.signal();
card(&state.db, id).await.map(Json)
}
pub async fn update_card(
_: Session,
State(state): State<AppState>,
Path(card_id): Path<i64>,
Json(patch): Json<CardPatch>,
) -> AppResult<Json<Card>> {
let current = card(&state.db, card_id).await?;
let now = crate::now();
let title = match patch.title {
Some(title) if title.trim().is_empty() => {
return Err(AppError::BadRequest("a card needs a title".into()))
}
Some(title) => title.trim().to_string(),
None => current.title,
};
// `done` is a flag on the way in and a timestamp in the table: when it was
// finished is worth more than that it was, and the weekly digest needs it.
let done_at = match patch.done {
Some(true) => current.done_at.or(Some(now)),
Some(false) => None,
None => current.done_at,
};
sqlx::query(
"UPDATE cards SET title = ?1, body = ?2, node_id = ?3, due_at = ?4, done_at = ?5,
updated_at = ?6
WHERE id = ?7",
)
.bind(&title)
.bind(patch.body.unwrap_or(current.body))
.bind(patch.node_id.unwrap_or(current.node_id))
.bind(patch.due_at.unwrap_or(current.due_at))
.bind(done_at)
.bind(now)
.bind(card_id)
.execute(&state.db)
.await?;
state.backup.signal();
card(&state.db, card_id).await.map(Json)
}
pub async fn remove_card(
_: Session,
State(state): State<AppState>,
Path(card_id): Path<i64>,
) -> AppResult<Json<serde_json::Value>> {
let affected = sqlx::query("DELETE FROM cards WHERE id = ?1")
.bind(card_id)
.execute(&state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound);
}
state.backup.signal();
Ok(Json(serde_json::json!({ "ok": true })))
}
/// Move a card to a list and a place in it. Positions are rewritten densely
/// afterwards, so they stay the small numbers the board reads them as instead
/// of drifting into gaps nobody can reason about.
pub async fn move_card(
_: Session,
State(state): State<AppState>,
Path(card_id): Path<i64>,
Json(input): Json<MoveInput>,
) -> AppResult<Json<Card>> {
let current = card(&state.db, card_id).await?;
let from = list_space(&state.db, current.list_id).await?;
let to = list_space(&state.db, input.list_id).await?;
if from != to {
// Boards are per space on purpose; a card that could cross would make
// "what am I working on in work" a lie.
return Err(AppError::BadRequest(
"a card cannot move to another space's board".into(),
));
}
let mut tx = state.db.begin().await?;
sqlx::query("UPDATE cards SET list_id = ?1, updated_at = ?2 WHERE id = ?3")
.bind(input.list_id)
.bind(crate::now())
.bind(card_id)
.execute(&mut *tx)
.await?;
let ids: Vec<i64> = sqlx::query("SELECT id FROM cards WHERE list_id = ?1 ORDER BY position, id")
.bind(input.list_id)
.fetch_all(&mut *tx)
.await?
.iter()
.map(|row| row.get("id"))
.collect();
let target = input.position.unwrap_or(i64::MAX).max(0) as usize;
for (position, id) in reordered(&ids, card_id, target).iter().enumerate() {
sqlx::query("UPDATE cards SET position = ?1 WHERE id = ?2")
.bind(position as i64)
.bind(id)
.execute(&mut *tx)
.await?;
}
// The list it came from keeps a hole where the card was. Harmless for
// ordering, but it would grow with every move.
let left: Vec<i64> = sqlx::query("SELECT id FROM cards WHERE list_id = ?1 ORDER BY position, id")
.bind(current.list_id)
.fetch_all(&mut *tx)
.await?
.iter()
.map(|row| row.get("id"))
.collect();
for (position, id) in left.iter().enumerate() {
sqlx::query("UPDATE cards SET position = ?1 WHERE id = ?2")
.bind(position as i64)
.bind(id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
state.backup.signal();
card(&state.db, card_id).await.map(Json)
}
async fn card(db: &SqlitePool, id: i64) -> AppResult<Card> {
let row = sqlx::query(
"SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at,
c.position, c.created_at, c.updated_at,
(SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name
FROM cards c WHERE c.id = ?1",
)
.bind(id)
.fetch_optional(db)
.await?
.ok_or(AppError::NotFound)?;
Ok(row_to_card(&row))
}
#[cfg(test)]
mod tests {
use super::reordered;
#[test]
fn a_card_lands_where_it_was_asked_to() {
let ids = [1, 2, 3, 4];
assert_eq!(reordered(&ids, 4, 0), vec![4, 1, 2, 3]);
assert_eq!(reordered(&ids, 1, 2), vec![2, 3, 1, 4]);
// Past the end is the end, not an error: dragging below the last card
// is how a card is sent to the bottom.
assert_eq!(reordered(&ids, 1, 99), vec![2, 3, 4, 1]);
}
#[test]
fn a_card_from_another_list_is_inserted_rather_than_moved() {
// The card is not in `ids` yet — the usual case when it comes from the
// list next door.
assert_eq!(reordered(&[1, 2], 9, 1), vec![1, 9, 2]);
assert_eq!(reordered(&[], 9, 0), vec![9]);
}
#[test]
fn a_card_kept_in_place_does_not_drift() {
let ids = [1, 2, 3];
assert_eq!(reordered(&ids, 2, 1), vec![1, 2, 3]);
}
}
-307
View File
@@ -1,307 +0,0 @@
use axum::{
extract::{Path, Query, State},
Json,
};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use crate::{
auth::Session,
error::{AppError, AppResult},
routes::media,
state::AppState,
};
#[derive(Serialize)]
pub struct EntrySummary {
pub id: i64,
pub title: String,
pub excerpt: String,
pub created_at: i64,
pub updated_at: i64,
pub shared: bool,
pub share_token: Option<String>,
}
#[derive(Serialize)]
pub struct Entry {
pub id: i64,
pub title: String,
pub body: String,
/// The same preview the list shows, so a client that has just saved can
/// patch its list row without re-reading the whole list.
pub excerpt: String,
pub created_at: i64,
pub updated_at: i64,
pub shared: bool,
pub share_token: Option<String>,
}
#[derive(Deserialize)]
pub struct ListQuery {
#[serde(default)]
pub q: Option<String>,
#[serde(default)]
pub limit: Option<i64>,
#[serde(default)]
pub offset: Option<i64>,
}
#[derive(Deserialize)]
pub struct EntryInput {
#[serde(default)]
pub title: String,
#[serde(default)]
pub body: String,
/// Optional override so an entry can be backdated to the day it is about.
#[serde(default)]
pub created_at: Option<i64>,
}
/// How much of a body the list query reads. Comfortably more than an excerpt
/// needs, and a rounding error next to entries that run to thousands of words.
const HEAD_CHARS: usize = 1024;
const EXCERPT_CHARS: usize = 180;
/// A short, plain-ish preview of the body for the entry list. `head` is only
/// the leading `HEAD_CHARS` of the entry, so truncation is inferred from its
/// length rather than measured against the whole body.
fn excerpt(head: &str) -> String {
let flat: String = head
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with("![") && !line.starts_with("```"))
.map(|line| line.trim_start_matches(['#', '>', '-', '*', ' ']))
.collect::<Vec<_>>()
.join(" ");
let mut out: String = flat.chars().take(EXCERPT_CHARS).collect();
if flat.chars().count() > EXCERPT_CHARS || head.chars().count() >= HEAD_CHARS {
out.push('…');
}
out
}
/// Turn free text into an FTS5 prefix query, discarding anything that could be
/// read as FTS syntax.
fn fts_query(raw: &str) -> Option<String> {
let terms: Vec<String> = raw
.split_whitespace()
.map(|term| term.replace(|c: char| !c.is_alphanumeric(), " "))
.flat_map(|term| {
term.split_whitespace()
.map(str::to_string)
.collect::<Vec<_>>()
})
.map(|term| format!("\"{term}\"*"))
.collect();
(!terms.is_empty()).then(|| terms.join(" AND "))
}
pub async fn list(
_: Session,
State(state): State<AppState>,
Query(params): Query<ListQuery>,
) -> AppResult<Json<Vec<EntrySummary>>> {
let limit = params.limit.unwrap_or(200).clamp(1, 1000);
let offset = params.offset.unwrap_or(0).max(0);
let search = params.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
let rows = match search.and_then(fts_query) {
Some(query) => {
sqlx::query(&format!(
"SELECT e.id, e.title, substr(e.body, 1, {HEAD_CHARS}) AS body,
e.created_at, e.updated_at, e.share_token
FROM entries_fts
JOIN entries e ON e.id = entries_fts.rowid
WHERE entries_fts MATCH ?1
ORDER BY rank
LIMIT ?2 OFFSET ?3"
))
.bind(query)
.bind(limit)
.bind(offset)
.fetch_all(&state.db)
.await?
}
None => {
sqlx::query(&format!(
"SELECT id, title, substr(body, 1, {HEAD_CHARS}) AS body,
created_at, updated_at, share_token
FROM entries
ORDER BY created_at DESC, id DESC
LIMIT ?1 OFFSET ?2"
))
.bind(limit)
.bind(offset)
.fetch_all(&state.db)
.await?
}
};
let entries = rows
.into_iter()
.map(|row| {
let head: String = row.get("body");
let share_token: Option<String> = row.get("share_token");
EntrySummary {
id: row.get("id"),
title: row.get("title"),
excerpt: excerpt(&head),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
shared: share_token.is_some(),
share_token,
}
})
.collect();
Ok(Json(entries))
}
pub async fn get(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Entry>> {
load(&state, id).await.map(Json)
}
pub async fn create(
_: Session,
State(state): State<AppState>,
Json(input): Json<EntryInput>,
) -> AppResult<Json<Entry>> {
let now = crate::now();
let created_at = input.created_at.unwrap_or(now);
let id: i64 = sqlx::query(
"INSERT INTO entries (title, body, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4)
RETURNING id",
)
.bind(&input.title)
.bind(&input.body)
.bind(created_at)
.bind(now)
.fetch_one(&state.db)
.await?
.get("id");
media::link_to_entry(&state.db, id, &input.body).await?;
state.backup.signal();
load(&state, id).await.map(Json)
}
pub async fn update(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(input): Json<EntryInput>,
) -> AppResult<Json<Entry>> {
let affected = sqlx::query(
"UPDATE entries
SET title = ?1, body = ?2, updated_at = ?3, created_at = COALESCE(?4, created_at)
WHERE id = ?5",
)
.bind(&input.title)
.bind(&input.body)
.bind(crate::now())
.bind(input.created_at)
.bind(id)
.execute(&state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound);
}
media::link_to_entry(&state.db, id, &input.body).await?;
state.backup.signal();
load(&state, id).await.map(Json)
}
pub async fn remove(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<serde_json::Value>> {
// Collected before the delete, which takes the entry_media rows with it.
// Files another entry also embeds are left alone.
let orphans = media::exclusive_media(&state.db, id).await?;
let affected = sqlx::query("DELETE FROM entries WHERE id = ?1")
.bind(id)
.execute(&state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound);
}
for media_id in orphans {
media::delete_media(&state, &media_id).await?;
}
state.backup.signal();
Ok(Json(serde_json::json!({ "ok": true })))
}
async fn load(state: &AppState, id: i64) -> AppResult<Entry> {
let row = sqlx::query(
"SELECT id, title, body, created_at, updated_at, share_token FROM entries WHERE id = ?1",
)
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
let share_token: Option<String> = row.get("share_token");
let body: String = row.get("body");
Ok(Entry {
id: row.get("id"),
title: row.get("title"),
excerpt: excerpt(&body.chars().take(HEAD_CHARS).collect::<String>()),
body,
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
shared: share_token.is_some(),
share_token,
})
}
#[cfg(test)]
mod tests {
use super::{excerpt, fts_query, EXCERPT_CHARS, HEAD_CHARS};
#[test]
fn strips_markdown_furniture() {
let body = "# Title\n\n![pic](/api/media/x)\n\n> a quote\n- a point";
assert_eq!(excerpt(body), "Title a quote a point");
}
#[test]
fn marks_a_body_that_was_cut_short() {
assert!(!excerpt("short").ends_with('…'));
assert!(excerpt(&"word ".repeat(60)).ends_with('…'));
// A head at the read limit is truncated even when it flattens small.
assert!(excerpt(&"![x](/api/media/y)\n".repeat(HEAD_CHARS / 18)).ends_with('…'));
}
#[test]
fn excerpt_never_exceeds_its_budget() {
let out = excerpt(&"a".repeat(HEAD_CHARS));
assert_eq!(out.chars().count(), EXCERPT_CHARS + 1);
}
#[test]
fn fts_query_discards_operators() {
// Anything that could be read as FTS syntax must not survive as syntax.
assert_eq!(fts_query("hello"), Some("\"hello\"*".into()));
assert_eq!(fts_query("a OR b"), Some("\"a\"* AND \"OR\"* AND \"b\"*".into()));
assert_eq!(fts_query("foo\"NEAR/2\"bar"), Some("\"foo\"* AND \"NEAR\"* AND \"2\"* AND \"bar\"*".into()));
assert_eq!(fts_query(" "), None);
assert_eq!(fts_query("***"), None);
}
}
+137 -49
View File
@@ -1,4 +1,7 @@
use std::io::{Seek, SeekFrom, Write};
use std::{
collections::HashMap,
io::{Seek, SeekFrom, Write},
};
use axum::{
body::Body,
@@ -12,31 +15,50 @@ use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter};
use crate::{auth::Session, error::AppResult, state::AppState};
/// One entry, flattened out of the database ahead of the blocking zip work.
struct ExportEntry {
/// One node, flattened out of the database ahead of the blocking zip work.
struct ExportNode {
id: i64,
title: String,
parent_id: Option<i64>,
kind: String,
name: String,
slug: String,
body: String,
created_at: i64,
}
/// `2026-09-05-first-light-12.md`. The id is always on the end, so two entries
/// written on the same day under the same title cannot collide.
fn entry_filename(entry: &ExportEntry) -> String {
let slug: String = entry
.title
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let slug = slug.trim_matches('-').split('-').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("-");
let slug: String = slug.chars().take(60).collect();
let day = day_string(entry.created_at);
if slug.is_empty() {
format!("{day}-entry-{}.md", entry.id)
} else {
format!("{day}-{slug}-{}.md", entry.id)
/// Where a document lands in the archive: the slugs of its ancestors as
/// directories, its own slug as the file. Slugs are already unique among
/// siblings, so the path is unique without adding the id to it.
fn document_path(nodes: &HashMap<i64, ExportNode>, node: &ExportNode) -> String {
let mut segments = vec![format!("{}.md", node.slug)];
let mut parent = node.parent_id;
// Bounded by the number of nodes, so a parent chain that somehow loops
// cannot spin here.
for _ in 0..nodes.len() {
let Some(id) = parent else { break };
let Some(ancestor) = nodes.get(&id) else { break };
segments.push(ancestor.slug.clone());
parent = ancestor.parent_id;
}
segments.reverse();
segments.join("/")
}
/// A link from one archived document to another, relative to the first. The
/// application addresses documents as `/n/<space>/<slug>/...`, which means
/// nothing outside the application, so the archive turns those links back into
/// file paths — the shape they had before they were ever imported.
fn relative_link(from: &str, to: &str) -> String {
let from: Vec<&str> = from.split('/').collect();
let to: Vec<&str> = to.split('/').collect();
// The last segment of `from` is the file itself, not a directory.
let shared = from[..from.len() - 1]
.iter()
.zip(to[..to.len() - 1].iter())
.take_while(|(a, b)| a == b)
.count();
let up = "../".repeat(from.len() - 1 - shared);
format!("{up}{}", to[shared..].join("/"))
}
/// `YYYY-MM-DD` in UTC, from a Unix timestamp, without pulling in a date crate.
@@ -73,30 +95,36 @@ fn media_filename(id: &str, original: &str) -> String {
}
const README: &str = "\
This is a complete copy of a ~/diary.
This is a complete copy of a ~/workspace.
entries/ one markdown file per entry, named by the day it is about
media/ every file the entries embed
<space>/ one directory per space, holding the same tree of folders and
documents that the workspace shows; one markdown file per document
media/ every file the documents embed
Embedded media is linked relatively, so the entries render correctly in any
markdown reader as long as the two folders stay next to each other. Nothing
here needs the diary application to read it.
Embedded media, and links from one document to another, are relative — so the
documents render and cross-link correctly in any markdown reader as long as the
tree stays intact. Nothing here needs the workspace application to read it.
";
/// Everything, as one zip. A diary you cannot get out of is not a diary you
/// Everything, as one zip. A workspace you cannot get out of is not one you
/// can trust, so this is deliberately a plain archive of plain files.
pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Response> {
let entries: Vec<ExportEntry> = sqlx::query(
"SELECT id, title, body, created_at FROM entries ORDER BY created_at, id",
let nodes: HashMap<i64, ExportNode> = sqlx::query(
"SELECT id, parent_id, kind, name, slug, body FROM nodes ORDER BY created_at, id",
)
.fetch_all(&state.db)
.await?
.into_iter()
.map(|row| ExportEntry {
.map(|row| {
let node = ExportNode {
id: row.get("id"),
title: row.get("title"),
parent_id: row.get("parent_id"),
kind: row.get("kind"),
name: row.get("name"),
slug: row.get("slug"),
body: row.get("body"),
created_at: row.get("created_at"),
};
(node.id, node)
})
.collect();
@@ -127,18 +155,40 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
.map(|(id, filename)| (id.clone(), media_filename(id, filename)))
.collect();
for entry in &entries {
let mut body = entry.body.clone();
// Every document by the address the application knows it as, so a link
// between two of them can be found and pointed at the file instead.
let addresses: HashMap<String, String> = nodes
.values()
.filter(|n| n.kind == "document")
.map(|n| {
let path = document_path(&nodes, n);
(format!("/n/{}", path.trim_end_matches(".md")), path)
})
.collect();
for node in nodes.values().filter(|n| n.kind == "document") {
let path = document_path(&nodes, node);
// A document three directories deep needs three steps back up to
// reach the media folder next to the spaces.
let up = "../".repeat(path.matches('/').count());
let mut body = node.body.clone();
for (id, name) in &names {
body = body.replace(&format!("/api/media/{id}"), &format!("../media/{name}"));
body = body.replace(&format!("/api/media/{id}"), &format!("{up}media/{name}"));
}
let document = if entry.title.trim().is_empty() {
for (address, target) in &addresses {
// Matching the `)` or `#` that ends a markdown link is what
// keeps `/n/work/a` from matching inside `/n/work/ab`.
body = body.replace(&format!("]({address})"), &format!("]({})", relative_link(&path, target)));
body = body.replace(&format!("]({address}#"), &format!("]({}#", relative_link(&path, target)));
}
let document = if node.name.trim().is_empty() {
body
} else {
format!("# {}\n\n{body}", entry.title.trim())
format!("# {}\n\n{body}", node.name.trim())
};
zip.start_file(format!("entries/{}", entry_filename(entry)), text)?;
zip.start_file(path, text)?;
zip.write_all(document.as_bytes())?;
}
@@ -158,7 +208,7 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
.await??;
let size = file.metadata()?.len();
let name = format!("diary-{}.zip", day_string(crate::now()));
let name = format!("workspace-{}.zip", day_string(crate::now()));
Ok((
[
@@ -177,7 +227,9 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
#[cfg(test)]
mod tests {
use super::{day_string, entry_filename, media_filename, ExportEntry};
use std::collections::HashMap;
use super::{day_string, document_path, media_filename, relative_link, ExportNode};
#[test]
fn renders_days_without_a_date_crate() {
@@ -187,17 +239,45 @@ mod tests {
assert_eq!(day_string(-1), "1969-12-31");
}
fn entry(title: &str, id: i64) -> ExportEntry {
ExportEntry { id, title: title.into(), body: String::new(), created_at: 0 }
fn tree(rows: &[(i64, Option<i64>, &str, &str)]) -> HashMap<i64, ExportNode> {
rows.iter()
.map(|(id, parent_id, kind, slug)| {
(
*id,
ExportNode {
id: *id,
parent_id: *parent_id,
kind: (*kind).into(),
name: (*slug).into(),
slug: (*slug).into(),
body: String::new(),
},
)
})
.collect()
}
#[test]
fn entry_names_are_readable_and_unique() {
assert_eq!(entry_filename(&entry("First Light", 12)), "1970-01-01-first-light-12.md");
assert_eq!(entry_filename(&entry("", 3)), "1970-01-01-entry-3.md");
assert_eq!(entry_filename(&entry("!!! ???", 4)), "1970-01-01-entry-4.md");
// Same day, same title, different entries.
assert_ne!(entry_filename(&entry("a", 1)), entry_filename(&entry("a", 2)));
fn a_document_keeps_its_place_in_the_tree() {
let nodes = tree(&[
(1, None, "space", "work"),
(2, Some(1), "folder", "einarbeiten"),
(3, Some(2), "document", "01-was-ist-eebus"),
(4, Some(1), "document", "notes"),
]);
assert_eq!(document_path(&nodes, &nodes[&3]), "work/einarbeiten/01-was-ist-eebus.md");
assert_eq!(document_path(&nodes, &nodes[&4]), "work/notes.md");
}
#[test]
fn a_broken_parent_chain_still_terminates() {
// A parent that is not in the map, and a cycle: neither may hang the
// export or walk off the end of the archive.
let orphan = tree(&[(3, Some(99), "document", "loose")]);
assert_eq!(document_path(&orphan, &orphan[&3]), "loose.md");
let cycle = tree(&[(1, Some(2), "folder", "a"), (2, Some(1), "document", "b")]);
assert!(document_path(&cycle, &cycle[&2]).ends_with("b.md"));
}
#[test]
@@ -208,4 +288,12 @@ mod tests {
assert_eq!(media_filename("abc", "photo.jpg"), "abc-photo.jpg");
assert_eq!(media_filename("abc", "..."), "abc");
}
#[test]
fn links_between_documents_become_file_paths() {
assert_eq!(relative_link("uni/proj/readme.md", "uni/proj/sub/deep.md"), "sub/deep.md");
assert_eq!(relative_link("uni/proj/sub/deep.md", "uni/proj/readme.md"), "../readme.md");
assert_eq!(relative_link("work/a.md", "uni/b.md"), "../uni/b.md");
assert_eq!(relative_link("work/a.md", "work/b.md"), "b.md");
}
}
+652
View File
@@ -0,0 +1,652 @@
//! Importing a folder as a tree of documents.
//!
//! A directory of markdown notes is the format writing arrives in — a repo's
//! `docs/`, a folder of lecture notes, an export of this very diary. Storing it
//! as opaque files would make it unsearchable and uneditable, so the importer
//! reproduces the directory as nodes: directories become folders, markdown
//! becomes documents, everything else becomes media. The links between the
//! files are rewritten as they land, because a set of documents that can no
//! longer reach each other is a worse copy of what was uploaded.
use std::collections::{BTreeSet, HashMap};
use axum::{
extract::{Multipart, Path, State},
Json,
};
use regex::Regex;
use serde::Serialize;
use sqlx::Row;
use std::sync::LazyLock;
use uuid::Uuid;
use crate::{
auth::Session,
error::{AppError, AppResult},
routes::{
media,
nodes::{self, next_slug, slugify, DOCUMENT, FOLDER},
},
state::AppState,
};
/// Markdown inline links and images: `[text](target)`, `![alt](target "title")`,
/// `[text](<target with spaces>)`. Reference-style definitions are left alone —
/// they are rare in the wild and rewriting them wrongly is worse than not.
static LINK: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(!?)\[([^\]\n]*)\]\(\s*<?([^)\s<>]*)>?([^)\n]*)\)").expect("static regex is valid")
});
/// What the client is told happened. Counts rather than a list of ids: the
/// status line has one line, and the tree is right there to look at.
#[derive(Serialize)]
pub struct ImportReport {
pub folders: usize,
pub documents: usize,
pub files: usize,
pub links: usize,
/// Paths that were refused, so a silently missing file is never a surprise.
pub skipped: Vec<String>,
}
/// One file of the upload, with the path it had inside the folder.
struct Upload {
path: String,
data: Vec<u8>,
mime: String,
}
/// Where a path inside the import ended up, as a URL a document body can use.
#[derive(Clone)]
enum Landed {
Node(String),
Media(String),
}
/// A path that is safe to build a tree from: relative, no `.` or `..` segment,
/// no empty segment, no backslash separators smuggled in from Windows.
fn normalize_path(raw: &str) -> Option<String> {
let raw = raw.trim().replace('\\', "/");
if raw.is_empty() || raw.starts_with('/') || raw.contains('\0') {
return None;
}
// A drive letter is an absolute path wearing a disguise.
if raw.len() > 1 && raw.as_bytes()[1] == b':' {
return None;
}
let mut parts = Vec::new();
for part in raw.split('/') {
match part {
"" | "." => continue,
".." => return None,
_ => parts.push(part),
}
}
(!parts.is_empty()).then(|| parts.join("/"))
}
/// The directory a path sits in, `""` for the top level.
fn parent_of(path: &str) -> &str {
path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("")
}
fn file_name(path: &str) -> &str {
path.rsplit_once('/').map(|(_, name)| name).unwrap_or(path)
}
fn is_markdown(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
lower.ends_with(".md") || lower.ends_with(".markdown")
}
/// The title of a document and the body left over. A markdown file usually
/// opens with the heading that names it; a node keeps its name in its own
/// column, so carrying the heading in the body too would show it twice and
/// export it twice. Without a heading the file name has to do.
fn split_title(body: &str, path: &str) -> (String, String) {
let mut lines = body.lines();
let heading = lines
.next()
.and_then(|line| line.trim().strip_prefix("# "))
.map(str::trim)
.filter(|title| !title.is_empty())
.map(str::to_string);
match heading {
Some(title) => (title, lines.collect::<Vec<_>>().join("\n").trim_start().to_string()),
None => {
let name = file_name(path);
let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem);
(stem.to_string(), body.to_string())
}
}
}
/// `%20` and friends, because a link to a file with a space in its name is
/// written escaped but the file arrives named with the space.
fn percent_decode(value: &str) -> String {
let bytes = value.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(byte) = u8::from_str_radix(&value[i + 1..i + 3], 16) {
out.push(byte);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_else(|_| value.to_string())
}
/// Split a link target into the part that names a file and the `#fragment` or
/// `?query` that follows it. `None` for anything that does not point inside the
/// import: a URL, an absolute path, a bare anchor, a mail address.
fn split_target(raw: &str) -> Option<(String, &str)> {
let raw = raw.trim();
if raw.is_empty() || raw.starts_with('#') || raw.starts_with('/') || raw.contains("://") {
return None;
}
if raw.starts_with("mailto:") || raw.starts_with("tel:") || raw.starts_with("data:") {
return None;
}
let cut = raw.find(['#', '?']).unwrap_or(raw.len());
let (path, suffix) = raw.split_at(cut);
(!path.is_empty()).then(|| (percent_decode(path), suffix))
}
/// Resolve a relative link against the directory its document sits in. `None`
/// when it climbs out of the import — that file was not uploaded, so there is
/// nothing to point at and the link is better left as it was written.
fn join_relative(dir: &str, target: &str) -> Option<String> {
let mut parts: Vec<&str> = dir.split('/').filter(|p| !p.is_empty()).collect();
for part in target.split('/') {
match part {
"" | "." => continue,
".." => {
parts.pop()?;
}
_ => parts.push(part),
}
}
(!parts.is_empty()).then(|| parts.join("/"))
}
/// Point every link that names an imported file at where that file now lives.
/// Returns the new body and how many links moved; anything unresolvable is left
/// exactly as written, so a link to the wider internet survives untouched.
fn rewrite_links(body: &str, dir: &str, landed: &HashMap<String, Landed>) -> (String, usize) {
let mut count = 0;
let out = LINK.replace_all(body, |caps: &regex::Captures| {
let whole = caps[0].to_string();
let Some((path, suffix)) = split_target(&caps[3]) else {
return whole;
};
let Some(key) = join_relative(dir, &path) else {
return whole;
};
// A link written without the extension (`see [notes](notes)`) still
// names the markdown file next to it.
let target = landed
.get(&key)
.or_else(|| landed.get(&format!("{key}.md")))
.or_else(|| landed.get(&format!("{key}.markdown")));
match target {
Some(Landed::Node(url)) => {
count += 1;
format!("{}[{}]({url}{suffix})", &caps[1], &caps[2])
}
// A fragment means nothing to a file download, so it is dropped.
Some(Landed::Media(url)) => {
count += 1;
format!("{}[{}]({url})", &caps[1], &caps[2])
}
None => whole,
}
});
(out.into_owned(), count)
}
/// Read the multipart body into uploads. A field whose file name ends in `.zip`
/// is expanded, so dragging in an archive and picking a folder arrive the same
/// way — the exporter writes one of those archives.
async fn collect(
state: &AppState,
mut multipart: Multipart,
skipped: &mut Vec<String>,
) -> AppResult<Vec<Upload>> {
let mut uploads: Vec<Upload> = Vec::new();
let mut total = 0usize;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| AppError::BadRequest(format!("malformed upload: {e}")))?
{
let name = field
.file_name()
.map(str::to_string)
.unwrap_or_else(|| "upload".to_string());
let content_type = field.content_type().map(str::to_string);
let data = field
.bytes()
.await
.map_err(|e| AppError::BadRequest(format!("could not read upload: {e}")))?;
total += data.len();
if total > state.config.max_upload_bytes {
return Err(AppError::BadRequest(format!(
"the upload is larger than the {} MB limit",
state.config.max_upload_bytes / 1024 / 1024
)));
}
if name.to_ascii_lowercase().ends_with(".zip") {
expand_zip(&data, &mut uploads, skipped)?;
continue;
}
let Some(path) = normalize_path(&name) else {
skipped.push(name);
continue;
};
if data.is_empty() {
skipped.push(path);
continue;
}
uploads.push(Upload {
mime: mime_of(&path, content_type),
path,
data: data.to_vec(),
});
}
Ok(uploads)
}
fn mime_of(path: &str, declared: Option<String>) -> String {
let raw = declared.filter(|m| !m.trim().is_empty()).unwrap_or_else(|| {
mime_guess::from_path(path)
.first_or_octet_stream()
.to_string()
});
media::sanitize_mime(&raw)
}
fn expand_zip(data: &[u8], uploads: &mut Vec<Upload>, skipped: &mut Vec<String>) -> AppResult<()> {
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(data))
.map_err(|e| AppError::BadRequest(format!("not a readable zip: {e}")))?;
for index in 0..archive.len() {
let mut file = archive
.by_index(index)
.map_err(|e| AppError::BadRequest(format!("damaged zip entry: {e}")))?;
if file.is_dir() {
continue;
}
let raw = file.name().to_string();
// `enclosed_name` is the zip crate's own refusal of absolute paths,
// `..` and symlink-style escapes; `normalize_path` then re-checks what
// the tree builder relies on.
let name = file
.enclosed_name()
.map(|path| path.to_string_lossy().to_string())
.and_then(|path| normalize_path(&path));
let Some(path) = name else {
skipped.push(raw);
continue;
};
let mut data = Vec::new();
std::io::copy(&mut file, &mut data)?;
if data.is_empty() {
skipped.push(path);
continue;
}
uploads.push(Upload {
mime: mime_of(&path, None),
path,
data,
});
}
Ok(())
}
/// `POST /api/nodes/{id}/import` — a folder, or a zip of one, becomes a tree
/// under the given space or folder.
pub async fn import(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
multipart: Multipart,
) -> AppResult<Json<ImportReport>> {
let target = nodes::load(&state, id).await?;
if target.kind == DOCUMENT {
return Err(AppError::BadRequest(
"a folder can only be imported into a space or a folder".into(),
));
}
let mut skipped = Vec::new();
let uploads = collect(&state, multipart, &mut skipped).await?;
if uploads.is_empty() {
return Err(AppError::BadRequest(
"nothing importable in that upload".into(),
));
}
// The files land on disk before the transaction so a failed import writes
// nothing to the database; the orphans are swept up on the way out.
let mut written: Vec<String> = Vec::new();
let result = build(&state, &target, uploads, skipped, &mut written).await;
if result.is_err() {
for media_id in &written {
let _ = tokio::fs::remove_file(state.config.uploads_dir().join(media_id)).await;
}
}
let report = result?;
state.backup.signal();
Ok(Json(report))
}
async fn build(
state: &AppState,
target: &nodes::Node,
mut uploads: Vec<Upload>,
skipped: Vec<String>,
written: &mut Vec<String>,
) -> AppResult<ImportReport> {
// The path of the target in slugs, which every rewritten link starts with.
let prefix = target
.path
.iter()
.map(|crumb| crumb.slug.as_str())
.collect::<Vec<_>>()
.join("/");
// Sorted so a parent directory is always created before its children, and
// so two runs of the same folder produce the same slugs.
uploads.sort_by(|a, b| a.path.cmp(&b.path));
uploads.dedup_by(|a, b| a.path == b.path);
let mut directories: BTreeSet<String> = BTreeSet::new();
for upload in &uploads {
let mut dir = parent_of(&upload.path);
while !dir.is_empty() {
directories.insert(dir.to_string());
dir = parent_of(dir);
}
}
let now = crate::now();
let mut tx = state.db.begin().await?;
// Slugs already taken under each parent, extended as the tree is built —
// the nodes being created are not visible to a query until the commit.
let mut siblings: HashMap<i64, BTreeSet<String>> = HashMap::new();
let existing = sqlx::query("SELECT slug FROM nodes WHERE parent_id IS ?1")
.bind(target.id)
.fetch_all(&mut *tx)
.await?;
siblings.insert(
target.id,
existing.iter().map(|row| row.get("slug")).collect(),
);
let mut dir_ids: HashMap<String, i64> = HashMap::from([(String::new(), target.id)]);
let mut dir_paths: HashMap<String, String> = HashMap::from([(String::new(), prefix)]);
let mut landed: HashMap<String, Landed> = HashMap::new();
for dir in &directories {
let parent = dir_ids[parent_of(dir)];
let name = file_name(dir);
let taken = siblings.entry(parent).or_default();
let slug = next_slug(taken, &slugify(name))?;
taken.insert(slug.clone());
let id: i64 = sqlx::query(
"INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?5)
RETURNING id",
)
.bind(parent)
.bind(FOLDER)
.bind(name)
.bind(&slug)
.bind(now)
.fetch_one(&mut *tx)
.await?
.get("id");
let path = format!("{}/{slug}", dir_paths[parent_of(dir)]);
dir_ids.insert(dir.clone(), id);
dir_paths.insert(dir.clone(), path.clone());
landed.insert(dir.clone(), Landed::Node(format!("/n/{path}")));
}
// Documents first, with their bodies as uploaded: the rewriting below needs
// to know where every document landed before it can point anything at one.
let mut documents: Vec<(i64, String, String)> = Vec::new();
let mut files = 0usize;
for upload in &uploads {
let dir = parent_of(&upload.path);
let parent = dir_ids[dir];
if is_markdown(&upload.path) {
let raw = String::from_utf8_lossy(&upload.data).to_string();
let (name, body) = split_title(&raw, &upload.path);
// The slug follows the file name rather than the title, so a link
// someone wrote to `01-was-ist-eebus.md` keeps reading that way.
let stem = file_name(&upload.path);
let stem = stem.rsplit_once('.').map_or(stem, |(stem, _)| stem);
let taken = siblings.entry(parent).or_default();
let slug = next_slug(taken, &slugify(stem))?;
taken.insert(slug.clone());
let id: i64 = sqlx::query(
"INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)
RETURNING id",
)
.bind(parent)
.bind(DOCUMENT)
.bind(&name)
.bind(&slug)
.bind(&body)
.bind(now)
.fetch_one(&mut *tx)
.await?
.get("id");
let path = format!("{}/{slug}", dir_paths[dir]);
landed.insert(upload.path.clone(), Landed::Node(format!("/n/{path}")));
documents.push((id, upload.path.clone(), body));
} else {
let media_id = Uuid::new_v4().to_string();
tokio::fs::write(state.config.uploads_dir().join(&media_id), &upload.data).await?;
written.push(media_id.clone());
sqlx::query(
"INSERT INTO media (id, filename, mime, size, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
)
.bind(&media_id)
.bind(file_name(&upload.path))
.bind(&upload.mime)
.bind(upload.data.len() as i64)
.bind(now)
.execute(&mut *tx)
.await?;
landed.insert(
upload.path.clone(),
Landed::Media(format!("/api/media/{media_id}")),
);
files += 1;
}
}
// Second pass: point the links at where things landed, and attach every
// file a document embeds to it the way a hand-written document does.
let mut links = 0usize;
let mut attached: BTreeSet<String> = BTreeSet::new();
for (id, path, body) in &documents {
let (rewritten, count) = rewrite_links(body, parent_of(path), &landed);
links += count;
if rewritten != *body {
sqlx::query("UPDATE nodes SET body = ?1 WHERE id = ?2")
.bind(&rewritten)
.bind(id)
.execute(&mut *tx)
.await?;
}
for capture in media::MEDIA_REF.captures_iter(&rewritten) {
let media_id = capture[1].to_lowercase();
sqlx::query(
"INSERT OR IGNORE INTO node_media (node_id, media_id)
SELECT ?1, id FROM media WHERE id = ?2",
)
.bind(id)
.bind(&media_id)
.execute(&mut *tx)
.await?;
attached.insert(media_id);
}
}
// A file nobody links to still belongs to the folder it was uploaded in,
// or deleting that folder would leave it behind with no way back to it.
for upload in &uploads {
if let Some(Landed::Media(url)) = landed.get(&upload.path) {
let media_id = url.trim_start_matches("/api/media/").to_string();
if attached.contains(&media_id) {
continue;
}
sqlx::query("INSERT OR IGNORE INTO node_media (node_id, media_id) VALUES (?1, ?2)")
.bind(dir_ids[parent_of(&upload.path)])
.bind(&media_id)
.execute(&mut *tx)
.await?;
}
}
tx.commit().await?;
Ok(ImportReport {
folders: directories.len(),
documents: documents.len(),
files,
links,
skipped,
})
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::{
is_markdown, join_relative, normalize_path, rewrite_links, split_target, split_title, Landed,
};
#[test]
fn refuses_paths_that_escape_the_import() {
assert_eq!(normalize_path("docs/readme.md").as_deref(), Some("docs/readme.md"));
assert_eq!(normalize_path("./docs//readme.md").as_deref(), Some("docs/readme.md"));
assert_eq!(normalize_path("docs\\readme.md").as_deref(), Some("docs/readme.md"));
assert_eq!(normalize_path("../secrets"), None);
assert_eq!(normalize_path("docs/../../etc/passwd"), None);
assert_eq!(normalize_path("/etc/passwd"), None);
assert_eq!(normalize_path("C:/windows/system32"), None);
assert_eq!(normalize_path(" "), None);
}
#[test]
fn a_title_comes_from_the_heading_then_the_file_name() {
assert_eq!(
split_title("# Was ist EEBUS?\n\ntext\n", "a/01.md"),
("Was ist EEBUS?".into(), "text".into())
);
// No heading, or one further down: the body is left exactly as it came.
assert_eq!(
split_title("no heading", "a/01-intro.md"),
("01-intro".into(), "no heading".into())
);
assert_eq!(
split_title("#nope\n\n# Real\n", "x.md"),
("x".into(), "#nope\n\n# Real\n".into())
);
}
#[test]
fn markdown_is_recognised_by_extension() {
assert!(is_markdown("a/README.MD"));
assert!(is_markdown("notes.markdown"));
assert!(!is_markdown("diagram.png"));
}
#[test]
fn relative_links_resolve_against_their_document() {
assert_eq!(join_relative("docs", "img/a.png").as_deref(), Some("docs/img/a.png"));
assert_eq!(join_relative("docs/deep", "../a.md").as_deref(), Some("docs/a.md"));
assert_eq!(join_relative("", "a.md").as_deref(), Some("a.md"));
assert_eq!(join_relative("docs", "../../outside.md"), None);
}
#[test]
fn external_targets_are_not_touched() {
assert!(split_target("https://example.org/x").is_none());
assert!(split_target("#section").is_none());
assert!(split_target("/api/media/x").is_none());
assert!(split_target("mailto:someone@example.org").is_none());
assert_eq!(split_target("a%20b.md#top"), Some(("a b.md".into(), "#top")));
}
#[test]
fn links_point_at_where_the_files_landed() {
let landed = HashMap::from([
("einarbeiten/01.md".to_string(), Landed::Node("/n/work/einarbeiten/01".into())),
("einarbeiten/img/a.png".to_string(), Landed::Media("/api/media/uuid".into())),
]);
let body = "see [one](01.md#ziele) and ![pic](img/a.png), \
plus [the web](https://example.org) and [gone](missing.md)";
let (out, count) = rewrite_links(body, "einarbeiten", &landed);
assert_eq!(count, 2);
assert!(out.contains("[one](/n/work/einarbeiten/01#ziele)"), "{out}");
assert!(out.contains("![pic](/api/media/uuid)"), "{out}");
assert!(out.contains("[the web](https://example.org)"), "{out}");
assert!(out.contains("[gone](missing.md)"), "{out}");
}
#[test]
fn a_link_from_a_sibling_directory_still_resolves() {
let landed = HashMap::from([(
"a/one.md".to_string(),
Landed::Node("/n/work/a/one".into()),
)]);
let (out, count) = rewrite_links("[x](../a/one.md)", "b", &landed);
assert_eq!(count, 1);
assert_eq!(out, "[x](/n/work/a/one)");
}
}
+45 -32
View File
@@ -19,9 +19,9 @@ use crate::{
state::AppState,
};
/// Media referenced from an entry body always looks like `/api/media/<uuid>`,
/// which is how an entry claims ownership of the files it embeds.
static MEDIA_REF: LazyLock<Regex> = LazyLock::new(|| {
/// Media referenced from a document body always looks like `/api/media/<uuid>`,
/// which is how a document claims ownership of the files it embeds.
pub(crate) static MEDIA_REF: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"/api/media/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})")
.expect("static regex is valid")
});
@@ -30,7 +30,7 @@ static MEDIA_REF: LazyLock<Regex> = LazyLock::new(|| {
/// browser is only ever told it is a type that cannot execute script. Anything
/// else — `text/html`, `image/svg+xml`, an unrecognised type — is stored and
/// served as an opaque download instead.
fn sanitize_mime(raw: &str) -> String {
pub(crate) fn sanitize_mime(raw: &str) -> String {
let base = raw
.split(';')
.next()
@@ -67,16 +67,16 @@ pub struct Media {
pub size: i64,
pub created_at: i64,
pub url: String,
/// Every entry that embeds this file, which may be none or several.
pub entry_ids: Vec<i64>,
/// Every document that embeds this file, which may be none or several.
pub node_ids: Vec<i64>,
}
fn row_to_media(row: &sqlx::sqlite::SqliteRow) -> Media {
let id: String = row.get("id");
// `group_concat` gives "3,7" or NULL; queries that do not ask for it at all
// (a fresh upload) get an empty list.
let entry_ids = row
.try_get::<Option<String>, _>("entry_ids")
let node_ids = row
.try_get::<Option<String>, _>("node_ids")
.ok()
.flatten()
.map(|joined| joined.split(',').filter_map(|n| n.parse().ok()).collect())
@@ -89,7 +89,7 @@ fn row_to_media(row: &sqlx::sqlite::SqliteRow) -> Media {
mime: row.get("mime"),
size: row.get("size"),
created_at: row.get("created_at"),
entry_ids,
node_ids,
}
}
@@ -164,8 +164,8 @@ pub async fn upload(
pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<Media>>> {
let rows = sqlx::query(
"SELECT m.id, m.filename, m.mime, m.size, m.created_at,
(SELECT group_concat(em.entry_id)
FROM entry_media em WHERE em.media_id = m.id) AS entry_ids
(SELECT group_concat(nm.node_id)
FROM node_media nm WHERE nm.media_id = m.id) AS node_ids
FROM media m ORDER BY m.created_at DESC LIMIT 500",
)
.fetch_all(&state.db)
@@ -249,21 +249,21 @@ pub async fn delete_media(state: &AppState, id: &str) -> AppResult<()> {
Ok(())
}
/// Attach every media file the body embeds to this entry, and detach the ones
/// it no longer mentions so they stop being reachable through its share link.
/// Other entries keep whatever they embed; this only rewrites one entry's row.
pub async fn link_to_entry(db: &SqlitePool, entry_id: i64, body: &str) -> AppResult<()> {
/// Attach every media file the body embeds to this document, and detach the
/// ones it no longer mentions so they stop being reachable through its share
/// link. Other documents keep whatever they embed; this rewrites one node's rows.
pub async fn link_to_node(db: &SqlitePool, node_id: i64, body: &str) -> AppResult<()> {
let referenced: BTreeSet<String> = MEDIA_REF
.captures_iter(body)
.map(|c| c[1].to_lowercase())
.collect();
// One transaction, so an entry is never momentarily attached to nothing —
// One transaction, so a document is never momentarily attached to nothing —
// which would blank its images for anyone reading its share link.
let mut tx = db.begin().await?;
sqlx::query("DELETE FROM entry_media WHERE entry_id = ?1")
.bind(entry_id)
sqlx::query("DELETE FROM node_media WHERE node_id = ?1")
.bind(node_id)
.execute(&mut *tx)
.await?;
@@ -271,10 +271,10 @@ pub async fn link_to_entry(db: &SqlitePool, entry_id: i64, body: &str) -> AppRes
// Selecting from `media` rather than binding the id directly means a
// body that still mentions a since-deleted file saves fine.
sqlx::query(
"INSERT OR IGNORE INTO entry_media (entry_id, media_id)
"INSERT OR IGNORE INTO node_media (node_id, media_id)
SELECT ?1, id FROM media WHERE id = ?2",
)
.bind(entry_id)
.bind(node_id)
.bind(id)
.execute(&mut *tx)
.await?;
@@ -284,19 +284,32 @@ pub async fn link_to_entry(db: &SqlitePool, entry_id: i64, body: &str) -> AppRes
Ok(())
}
/// The files this entry embeds that no other entry does — the ones that become
/// unreachable once it is gone.
pub async fn exclusive_media(db: &SqlitePool, entry_id: i64) -> AppResult<Vec<String>> {
let rows = sqlx::query(
"SELECT em.media_id AS id
FROM entry_media em
WHERE em.entry_id = ?1
/// The files embedded somewhere in `node_ids` and nowhere outside it — the ones
/// that become unreachable once those nodes are gone. A whole subtree is asked
/// about at once, because deleting a folder deletes everything under it and a
/// file shared between two of its documents is still exclusive to the subtree.
pub async fn exclusive_media(db: &SqlitePool, node_ids: &[i64]) -> AppResult<Vec<String>> {
if node_ids.is_empty() {
return Ok(Vec::new());
}
// Ids are i64 straight out of the database, so there is nothing to bind:
// the list is built as literals to keep it one statement of any length.
let list = node_ids
.iter()
.map(i64::to_string)
.collect::<Vec<_>>()
.join(",");
let rows = sqlx::query(&format!(
"SELECT DISTINCT nm.media_id AS id
FROM node_media nm
WHERE nm.node_id IN ({list})
AND NOT EXISTS (
SELECT 1 FROM entry_media other
WHERE other.media_id = em.media_id AND other.entry_id <> ?1
)",
)
.bind(entry_id)
SELECT 1 FROM node_media other
WHERE other.media_id = nm.media_id AND other.node_id NOT IN ({list})
)"
))
.fetch_all(db)
.await?;
+19 -7
View File
@@ -1,6 +1,8 @@
pub mod backup;
pub mod entries;
pub mod boards;
pub mod nodes;
pub mod export;
pub mod import;
pub mod media;
pub mod session;
pub mod share;
@@ -17,14 +19,24 @@ pub fn api_router() -> Router<AppState> {
.route("/me", get(session::me))
.route("/login", post(session::login))
.route("/logout", post(session::logout))
.route("/entries", get(entries::list).post(entries::create))
.route("/export", get(export::export))
.route("/backup", get(backup::status).post(backup::run))
.route("/entries/{id}", get(entries::get))
.route("/entries/{id}", put(entries::update))
.route("/entries/{id}", delete(entries::remove))
.route("/entries/{id}/share", post(share::enable))
.route("/entries/{id}/share", delete(share::disable))
.route("/spaces", get(nodes::spaces).post(nodes::create_space))
.route("/spaces/{id}/board", get(boards::board))
.route("/spaces/{id}/lists", post(boards::create_list))
.route("/lists/{id}", put(boards::rename_list).delete(boards::remove_list))
.route("/lists/{id}/cards", post(boards::create_card))
.route("/cards/{id}", put(boards::update_card).delete(boards::remove_card))
.route("/cards/{id}/move", post(boards::move_card))
.route("/nodes", get(nodes::list).post(nodes::create))
.route("/resolve", get(nodes::resolve))
.route("/nodes/{id}", get(nodes::get))
.route("/nodes/{id}", put(nodes::update))
.route("/nodes/{id}", delete(nodes::remove))
.route("/nodes/{id}/move", post(nodes::move_node))
.route("/nodes/{id}/import", post(import::import))
.route("/nodes/{id}/share", post(share::enable))
.route("/nodes/{id}/share", delete(share::disable))
.route("/media", post(media::upload).get(media::list))
.route("/media/{id}", get(media::serve).delete(media::remove))
.route("/share/{token}/{key}", get(share::read))
+775
View File
@@ -0,0 +1,775 @@
use std::collections::BTreeSet;
use axum::{
extract::{Path, Query, State},
Json,
};
use serde::{Deserialize, Serialize};
use sqlx::{Row, SqlitePool};
use crate::{
auth::Session,
error::{AppError, AppResult},
routes::media,
state::AppState,
};
/// What a node is. Spaces are the roots (`diary`, `work`, `uni`, ...), folders
/// group things inside them to any depth, documents are the leaves that hold
/// text. One table, one recursion, so a space can nest exactly like a folder.
pub const SPACE: &str = "space";
pub const FOLDER: &str = "folder";
pub const DOCUMENT: &str = "document";
#[derive(Serialize)]
pub struct NodeSummary {
pub id: i64,
pub parent_id: Option<i64>,
pub kind: String,
pub name: String,
pub slug: String,
pub excerpt: String,
pub position: i64,
pub has_board: bool,
pub child_count: i64,
pub created_at: i64,
pub updated_at: i64,
pub shared: bool,
pub share_token: Option<String>,
}
#[derive(Serialize)]
pub struct Node {
pub id: i64,
pub parent_id: Option<i64>,
pub kind: String,
pub name: String,
pub slug: String,
pub body: String,
/// The same preview the list shows, so a client that has just saved can
/// patch its list row without re-reading the whole list.
pub excerpt: String,
pub position: i64,
pub has_board: bool,
pub child_count: i64,
pub created_at: i64,
pub updated_at: i64,
pub shared: bool,
pub share_token: Option<String>,
/// This node's ancestors, the space first and the node itself last — the
/// breadcrumb, and everything needed to build its /n/... path.
pub path: Vec<Crumb>,
}
#[derive(Serialize)]
pub struct Crumb {
pub id: i64,
pub kind: String,
pub name: String,
pub slug: String,
}
#[derive(Deserialize)]
pub struct ListQuery {
/// Children of this node. Omitted together with `q` means the spaces.
#[serde(default)]
pub parent: Option<i64>,
#[serde(default)]
pub q: Option<String>,
/// Confine a search to one space.
#[serde(default)]
pub space: Option<i64>,
#[serde(default)]
pub limit: Option<i64>,
#[serde(default)]
pub offset: Option<i64>,
}
#[derive(Deserialize)]
pub struct CreateInput {
pub parent_id: i64,
/// `document` (the default) or `folder`.
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub name: String,
#[serde(default)]
pub body: String,
/// Optional override so a document can be backdated to the day it is about.
#[serde(default)]
pub created_at: Option<i64>,
}
#[derive(Deserialize)]
pub struct UpdateInput {
#[serde(default)]
pub name: String,
#[serde(default)]
pub body: String,
#[serde(default)]
pub created_at: Option<i64>,
/// Spaces only, and only when it is sent at all.
#[serde(default)]
pub has_board: Option<bool>,
}
#[derive(Deserialize)]
pub struct SpaceInput {
pub name: String,
/// Defaults to on: a space is usually a project, and the one place a board
/// makes no sense — the diary — already exists.
#[serde(default)]
pub has_board: Option<bool>,
}
#[derive(Deserialize)]
pub struct MoveInput {
pub parent_id: i64,
#[serde(default)]
pub position: Option<i64>,
}
/// How much of a body the list query reads. Comfortably more than an excerpt
/// needs, and a rounding error next to documents that run to thousands of words.
const HEAD_CHARS: usize = 1024;
const EXCERPT_CHARS: usize = 180;
/// The columns every list query selects, so a row always converts the same way.
const SUMMARY_COLUMNS: &str = "n.id, n.parent_id, n.kind, n.name, n.slug, n.position, n.has_board,
n.created_at, n.updated_at, n.share_token,
(SELECT COUNT(*) FROM nodes c WHERE c.parent_id = n.id) AS child_count";
/// A short, plain-ish preview of the body for the list pane. `head` is only the
/// leading `HEAD_CHARS` of the document, so truncation is inferred from its
/// length rather than measured against the whole body.
fn excerpt(head: &str) -> String {
let flat: String = head
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with("![") && !line.starts_with("```"))
.map(|line| line.trim_start_matches(['#', '>', '-', '*', ' ']))
.collect::<Vec<_>>()
.join(" ");
let mut out: String = flat.chars().take(EXCERPT_CHARS).collect();
if flat.chars().count() > EXCERPT_CHARS || head.chars().count() >= HEAD_CHARS {
out.push('…');
}
out
}
/// Turn free text into an FTS5 prefix query, discarding anything that could be
/// read as FTS syntax.
fn fts_query(raw: &str) -> Option<String> {
let terms: Vec<String> = raw
.split_whitespace()
.map(|term| term.replace(|c: char| !c.is_alphanumeric(), " "))
.flat_map(|term| {
term.split_whitespace()
.map(str::to_string)
.collect::<Vec<_>>()
})
.map(|term| format!("\"{term}\"*"))
.collect();
(!terms.is_empty()).then(|| terms.join(" AND "))
}
/// A name as a path segment: lowercase, ASCII-ish, no surprises in a URL. An
/// empty result becomes `untitled`, because every node needs a slug and a
/// document usually gets its name minutes after it is created.
pub fn slugify(name: &str) -> String {
let mut slug = String::new();
let mut dash = false;
for ch in name.trim().chars().flat_map(|c| c.to_lowercase()) {
if ch.is_ascii_alphanumeric() {
slug.push(ch);
dash = false;
} else if matches!(ch, 'ä' | 'ö' | 'ü' | 'ß') {
// The diary is written in two languages; transliterating rather
// than dropping keeps a German heading readable in its URL.
slug.push_str(match ch {
'ä' => "ae",
'ö' => "oe",
'ü' => "ue",
_ => "ss",
});
dash = false;
} else if !slug.is_empty() && !dash {
slug.push('-');
dash = true;
}
}
let slug = slug.trim_end_matches('-').to_string();
let slug: String = slug.chars().take(80).collect();
let slug = slug.trim_end_matches('-').to_string();
if slug.is_empty() {
"untitled".to_string()
} else {
slug
}
}
/// `base`, or `base-2`, `base-3`… until one is free. Kept separate from the
/// database so the importer, which creates a whole tree of siblings before any
/// of them is queryable, can allocate against the set it is building.
pub fn next_slug(taken: &BTreeSet<String>, base: &str) -> AppResult<String> {
for suffix in 1..1000 {
let candidate = if suffix == 1 {
base.to_string()
} else {
format!("{base}-{suffix}")
};
if !taken.contains(&candidate) {
return Ok(candidate);
}
}
Err(AppError::BadRequest(
"too many siblings with that name".into(),
))
}
/// The slugs already spoken for under `parent_id`. `exclude` is the node being
/// renamed, which must not collide with itself.
pub async fn sibling_slugs(
db: &SqlitePool,
parent_id: Option<i64>,
exclude: Option<i64>,
) -> AppResult<BTreeSet<String>> {
let rows = sqlx::query(
"SELECT slug FROM nodes
WHERE parent_id IS ?1 AND (?2 IS NULL OR id <> ?2)",
)
.bind(parent_id)
.bind(exclude)
.fetch_all(db)
.await?;
Ok(rows.iter().map(|row| row.get("slug")).collect())
}
/// `base`, or `base-2`, `base-3`… until no sibling holds it.
pub async fn unique_slug(
db: &SqlitePool,
parent_id: Option<i64>,
base: &str,
exclude: Option<i64>,
) -> AppResult<String> {
next_slug(&sibling_slugs(db, parent_id, exclude).await?, base)
}
fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary {
let share_token: Option<String> = row.get("share_token");
NodeSummary {
id: row.get("id"),
parent_id: row.get("parent_id"),
kind: row.get("kind"),
name: row.get("name"),
slug: row.get("slug"),
excerpt: excerpt(head),
position: row.get("position"),
has_board: row.get::<i64, _>("has_board") != 0,
child_count: row.get("child_count"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
shared: share_token.is_some(),
share_token,
}
}
/// The spaces, in display order. Their child counts are what the switcher shows.
pub async fn spaces(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<NodeSummary>>> {
let rows = sqlx::query(&format!(
"SELECT {SUMMARY_COLUMNS} FROM nodes n
WHERE n.parent_id IS NULL AND n.kind = 'space'
ORDER BY n.position, n.name"
))
.fetch_all(&state.db)
.await?;
Ok(Json(
rows.iter().map(|row| row_to_summary(row, "")).collect(),
))
}
pub async fn create_space(
_: Session,
State(state): State<AppState>,
Json(input): Json<SpaceInput>,
) -> AppResult<Json<Node>> {
let name = input.name.trim();
if name.is_empty() {
return Err(AppError::BadRequest("a space needs a name".into()));
}
let slug = unique_slug(&state.db, None, &slugify(name), None).await?;
let now = crate::now();
let position: i64 = sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 AS next FROM nodes WHERE parent_id IS NULL")
.fetch_one(&state.db)
.await?
.get("next");
let id: i64 = sqlx::query(
"INSERT INTO nodes (parent_id, kind, name, slug, position, has_board, created_at, updated_at)
VALUES (NULL, 'space', ?1, ?2, ?3, ?4, ?5, ?5)
RETURNING id",
)
.bind(name)
.bind(&slug)
.bind(position)
.bind(i64::from(input.has_board.unwrap_or(true)))
.bind(now)
.fetch_one(&state.db)
.await?
.get("id");
state.backup.signal();
load(&state, id).await.map(Json)
}
pub async fn list(
_: Session,
State(state): State<AppState>,
Query(params): Query<ListQuery>,
) -> AppResult<Json<Vec<NodeSummary>>> {
let limit = params.limit.unwrap_or(500).clamp(1, 2000);
let offset = params.offset.unwrap_or(0).max(0);
let search = params.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
let rows = match search.and_then(fts_query) {
// A search reaches through the whole tree, optionally confined to one
// space: the recursive term collects that space and everything under it.
Some(query) => {
sqlx::query(&format!(
"WITH RECURSIVE sub (id) AS (
SELECT id FROM nodes WHERE id = ?4
UNION ALL
SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id
)
SELECT {SUMMARY_COLUMNS}, substr(n.body, 1, {HEAD_CHARS}) AS head
FROM nodes_fts
JOIN nodes n ON n.id = nodes_fts.rowid
WHERE nodes_fts MATCH ?1
AND n.kind <> 'space'
AND (?4 IS NULL OR n.id IN (SELECT id FROM sub))
ORDER BY rank
LIMIT ?2 OFFSET ?3"
))
.bind(query)
.bind(limit)
.bind(offset)
.bind(params.space)
.fetch_all(&state.db)
.await?
}
// Browsing: the children of one node, folders before documents.
None => {
let parent = params.parent;
sqlx::query(&format!(
"SELECT {SUMMARY_COLUMNS}, substr(n.body, 1, {HEAD_CHARS}) AS head
FROM nodes n
WHERE n.parent_id IS ?1
ORDER BY CASE n.kind WHEN 'folder' THEN 0 ELSE 1 END,
n.position, n.created_at DESC, n.id DESC
LIMIT ?2 OFFSET ?3"
))
.bind(parent)
.bind(limit)
.bind(offset)
.fetch_all(&state.db)
.await?
}
};
Ok(Json(
rows.iter()
.map(|row| {
let head: String = row.try_get("head").unwrap_or_default();
row_to_summary(row, &head)
})
.collect(),
))
}
pub async fn get(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Node>> {
load(&state, id).await.map(Json)
}
#[derive(Deserialize)]
pub struct ResolveQuery {
/// Slugs from a space downwards, `work/einarbeiten/01-was-ist-eebus`.
pub path: String,
}
/// Resolve a slug path to the node it names. This is the other half of the
/// `/n/<space>/<slug>/...` addresses the client puts in the URL bar and the
/// importer writes into rewritten links: they stay readable because they are
/// resolved here rather than carrying ids around.
pub async fn resolve(
_: Session,
State(state): State<AppState>,
Query(params): Query<ResolveQuery>,
) -> AppResult<Json<Node>> {
let mut current: Option<i64> = None;
for segment in params.path.split('/').filter(|s| !s.is_empty()) {
let row = sqlx::query("SELECT id FROM nodes WHERE parent_id IS ?1 AND slug = ?2")
.bind(current)
.bind(segment)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
current = Some(row.get("id"));
}
load(&state, current.ok_or(AppError::NotFound)?).await.map(Json)
}
pub async fn create(
_: Session,
State(state): State<AppState>,
Json(input): Json<CreateInput>,
) -> AppResult<Json<Node>> {
let kind = input.kind.as_deref().unwrap_or(DOCUMENT);
if kind != DOCUMENT && kind != FOLDER {
return Err(AppError::BadRequest(format!(
"a node created here is a document or a folder, not a {kind}"
)));
}
let parent_kind: String = sqlx::query("SELECT kind FROM nodes WHERE id = ?1")
.bind(input.parent_id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?
.get("kind");
if parent_kind == DOCUMENT {
return Err(AppError::BadRequest(
"a document cannot contain other nodes".into(),
));
}
let now = crate::now();
let created_at = input.created_at.unwrap_or(now);
let slug = unique_slug(
&state.db,
Some(input.parent_id),
&slugify(&input.name),
None,
)
.await?;
let id: i64 = sqlx::query(
"INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
RETURNING id",
)
.bind(input.parent_id)
.bind(kind)
.bind(input.name.trim())
.bind(&slug)
.bind(&input.body)
.bind(created_at)
.bind(now)
.fetch_one(&state.db)
.await?
.get("id");
media::link_to_node(&state.db, id, &input.body).await?;
state.backup.signal();
load(&state, id).await.map(Json)
}
pub async fn update(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(input): Json<UpdateInput>,
) -> AppResult<Json<Node>> {
let row = sqlx::query("SELECT parent_id, kind, name, slug FROM nodes WHERE id = ?1")
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
let parent_id: Option<i64> = row.get("parent_id");
let kind: String = row.get("kind");
let previous_name: String = row.get("name");
let name = input.name.trim();
// The slug follows the name, because a path nobody can read is no better
// than an id. It only moves when the name does, so a link to an unrenamed
// document keeps working.
let slug = if name == previous_name {
row.get::<String, _>("slug")
} else {
unique_slug(&state.db, parent_id, &slugify(name), Some(id)).await?
};
if input.has_board.is_some() && kind != SPACE {
return Err(AppError::BadRequest("only a space has a board".into()));
}
let affected = sqlx::query(
"UPDATE nodes
SET name = ?1, body = ?2, slug = ?3, updated_at = ?4,
created_at = COALESCE(?5, created_at),
has_board = COALESCE(?6, has_board)
WHERE id = ?7",
)
.bind(name)
.bind(&input.body)
.bind(&slug)
.bind(crate::now())
.bind(input.created_at)
.bind(input.has_board.map(i64::from))
.bind(id)
.execute(&state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound);
}
media::link_to_node(&state.db, id, &input.body).await?;
state.backup.signal();
load(&state, id).await.map(Json)
}
pub async fn remove(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<serde_json::Value>> {
// Everything under a folder goes with it, so the files to clean up are
// those exclusive to the whole subtree. Collected before the delete, which
// takes the node_media rows with it.
let subtree = descendants(&state.db, id).await?;
if subtree.is_empty() {
return Err(AppError::NotFound);
}
let orphans = media::exclusive_media(&state.db, &subtree).await?;
let affected = sqlx::query("DELETE FROM nodes WHERE id = ?1")
.bind(id)
.execute(&state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound);
}
for media_id in orphans {
media::delete_media(&state, &media_id).await?;
}
state.backup.signal();
Ok(Json(serde_json::json!({ "ok": true, "removed": subtree.len() })))
}
/// Reparent a node, and optionally place it among its new siblings.
pub async fn move_node(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(input): Json<MoveInput>,
) -> AppResult<Json<Node>> {
if input.parent_id == id {
return Err(AppError::BadRequest("a node cannot contain itself".into()));
}
let target_kind: String = sqlx::query("SELECT kind FROM nodes WHERE id = ?1")
.bind(input.parent_id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?
.get("kind");
if target_kind == DOCUMENT {
return Err(AppError::BadRequest(
"a document cannot contain other nodes".into(),
));
}
// Moving a folder into its own subtree would cut that subtree loose from
// every root, where nothing could reach it again.
if descendants(&state.db, id).await?.contains(&input.parent_id) {
return Err(AppError::BadRequest(
"a folder cannot be moved into itself".into(),
));
}
let name: String = sqlx::query("SELECT name FROM nodes WHERE id = ?1")
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?
.get("name");
// The slug has to be free where it lands, not where it came from.
let slug = unique_slug(
&state.db,
Some(input.parent_id),
&slugify(&name),
Some(id),
)
.await?;
sqlx::query(
"UPDATE nodes
SET parent_id = ?1, slug = ?2, position = COALESCE(?3, position), updated_at = ?4
WHERE id = ?5",
)
.bind(input.parent_id)
.bind(&slug)
.bind(input.position)
.bind(crate::now())
.bind(id)
.execute(&state.db)
.await?;
state.backup.signal();
load(&state, id).await.map(Json)
}
/// A node and everything beneath it, the node itself first.
pub async fn descendants(db: &SqlitePool, id: i64) -> AppResult<Vec<i64>> {
let rows = sqlx::query(
"WITH RECURSIVE sub (id) AS (
SELECT id FROM nodes WHERE id = ?1
UNION ALL
SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id
)
SELECT id FROM sub",
)
.bind(id)
.fetch_all(db)
.await?;
Ok(rows.iter().map(|row| row.get("id")).collect())
}
/// The ancestors of a node, the space first and the node itself last.
pub async fn path_of(db: &SqlitePool, id: i64) -> AppResult<Vec<Crumb>> {
let rows = sqlx::query(
"WITH RECURSIVE up (id, parent_id, kind, name, slug, depth) AS (
SELECT id, parent_id, kind, name, slug, 0 FROM nodes WHERE id = ?1
UNION ALL
SELECT n.id, n.parent_id, n.kind, n.name, n.slug, up.depth + 1
FROM nodes n JOIN up ON n.id = up.parent_id
)
SELECT id, kind, name, slug FROM up ORDER BY depth DESC",
)
.bind(id)
.fetch_all(db)
.await?;
Ok(rows
.iter()
.map(|row| Crumb {
id: row.get("id"),
kind: row.get("kind"),
name: row.get("name"),
slug: row.get("slug"),
})
.collect())
}
pub async fn load(state: &AppState, id: i64) -> AppResult<Node> {
let row = sqlx::query(&format!(
"SELECT {SUMMARY_COLUMNS}, n.body FROM nodes n WHERE n.id = ?1"
))
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
let body: String = row.get("body");
let summary = row_to_summary(&row, &body.chars().take(HEAD_CHARS).collect::<String>());
Ok(Node {
id: summary.id,
parent_id: summary.parent_id,
kind: summary.kind,
name: summary.name,
slug: summary.slug,
body,
excerpt: summary.excerpt,
position: summary.position,
has_board: summary.has_board,
child_count: summary.child_count,
created_at: summary.created_at,
updated_at: summary.updated_at,
shared: summary.shared,
share_token: summary.share_token,
path: path_of(&state.db, id).await?,
})
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::{excerpt, fts_query, next_slug, slugify, EXCERPT_CHARS, HEAD_CHARS};
#[test]
fn strips_markdown_furniture() {
let body = "# Title\n\n![pic](/api/media/x)\n\n> a quote\n- a point";
assert_eq!(excerpt(body), "Title a quote a point");
}
#[test]
fn marks_a_body_that_was_cut_short() {
assert!(!excerpt("short").ends_with('…'));
assert!(excerpt(&"word ".repeat(60)).ends_with('…'));
// A head at the read limit is truncated even when it flattens small.
assert!(excerpt(&"![x](/api/media/y)\n".repeat(HEAD_CHARS / 18)).ends_with('…'));
}
#[test]
fn excerpt_never_exceeds_its_budget() {
let out = excerpt(&"a".repeat(HEAD_CHARS));
assert_eq!(out.chars().count(), EXCERPT_CHARS + 1);
}
#[test]
fn fts_query_discards_operators() {
// Anything that could be read as FTS syntax must not survive as syntax.
assert_eq!(fts_query("hello"), Some("\"hello\"*".into()));
assert_eq!(fts_query("a OR b"), Some("\"a\"* AND \"OR\"* AND \"b\"*".into()));
assert_eq!(fts_query("foo\"NEAR/2\"bar"), Some("\"foo\"* AND \"NEAR\"* AND \"2\"* AND \"bar\"*".into()));
assert_eq!(fts_query(" "), None);
assert_eq!(fts_query("***"), None);
}
#[test]
fn slugs_are_url_safe() {
assert_eq!(slugify("01 — Was ist EEBUS?"), "01-was-ist-eebus");
assert_eq!(slugify(" Trailing and inner "), "trailing-and-inner");
assert_eq!(slugify("Übergrößen"), "uebergroessen");
assert_eq!(slugify("README.md"), "readme-md");
}
#[test]
fn a_nameless_node_still_gets_a_slug() {
assert_eq!(slugify(""), "untitled");
assert_eq!(slugify("***"), "untitled");
assert_eq!(slugify(" "), "untitled");
}
#[test]
fn slugs_stay_short_and_never_end_in_a_dash() {
let slug = slugify(&"word ".repeat(40));
assert!(slug.len() <= 80, "{slug}");
assert!(!slug.ends_with('-'), "{slug}");
}
#[test]
fn a_taken_slug_gets_a_number() {
let taken: BTreeSet<String> = ["readme", "readme-2"].iter().map(|s| s.to_string()).collect();
assert_eq!(next_slug(&taken, "readme").unwrap(), "readme-3");
assert_eq!(next_slug(&taken, "notes").unwrap(), "notes");
assert_eq!(next_slug(&BTreeSet::new(), "readme").unwrap(), "readme");
}
}
+14 -2
View File
@@ -1,6 +1,6 @@
use axum::{
extract::State,
http::{header, StatusCode},
http::{header, HeaderMap, StatusCode},
response::IntoResponse,
Json,
};
@@ -9,6 +9,7 @@ use serde_json::json;
use crate::{
auth::{self, Session},
throttle::FREE_ATTEMPTS,
error::{AppError, AppResult},
state::AppState,
};
@@ -21,6 +22,7 @@ pub struct LoginRequest {
pub async fn login(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<LoginRequest>,
) -> AppResult<impl IntoResponse> {
state.login_throttle.check().map_err(|wait| {
@@ -29,11 +31,21 @@ pub async fn login(
})?;
if !auth::credentials_match(&state.config, &body.username, &body.password) {
state.login_throttle.record_failure();
let failures = state.login_throttle.record_failure();
// Worth a mail once someone is past guessing and into trying.
if failures > FREE_ATTEMPTS {
state.notify.note_failed_logins(failures).await;
}
return Err(AppError::Unauthorized);
}
state.login_throttle.record_success();
let user_agent = headers
.get(header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown");
state.notify.note_login(user_agent).await;
let token = auth::issue_token(&state.config);
Ok((
StatusCode::OK,
+27 -23
View File
@@ -19,8 +19,8 @@ use crate::{
};
#[derive(Serialize)]
pub struct SharedEntry {
pub title: String,
pub struct SharedDocument {
pub name: String,
pub body: String,
pub created_at: i64,
pub updated_at: i64,
@@ -45,7 +45,7 @@ fn key_matches(stored: Option<&str>, presented: &str) -> bool {
bool::from(hash_key(presented).as_bytes().ct_eq(stored.as_bytes()))
}
/// Publish an entry, or mint a fresh key for one that is already published.
/// Publish a document, or mint a fresh key for one that is already published.
///
/// The plaintext key is returned exactly once, here. Nothing but its hash is
/// stored, so a link that the author loses cannot be recovered — only replaced,
@@ -57,21 +57,25 @@ pub async fn enable(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<serde_json::Value>> {
let existing: Option<Option<String>> =
sqlx::query("SELECT share_token FROM entries WHERE id = ?1")
let row = sqlx::query("SELECT kind, share_token FROM nodes WHERE id = ?1")
.bind(id)
.fetch_optional(&state.db)
.await?
.map(|row| row.get("share_token"));
.ok_or(AppError::NotFound)?;
let token = match existing {
None => return Err(AppError::NotFound),
Some(Some(token)) => token,
Some(None) => random_secret(),
// A share link serves one body. A folder has none, and publishing a whole
// subtree behind one key is a different feature with different consequences.
if row.get::<String, _>("kind") != super::nodes::DOCUMENT {
return Err(AppError::BadRequest("only a document can be shared".into()));
}
let token = match row.get::<Option<String>, _>("share_token") {
Some(token) => token,
None => random_secret(),
};
let key = random_secret();
sqlx::query("UPDATE entries SET share_token = ?1, share_key_hash = ?2 WHERE id = ?3")
sqlx::query("UPDATE nodes SET share_token = ?1, share_key_hash = ?2 WHERE id = ?3")
.bind(&token)
.bind(hash_key(&key))
.bind(id)
@@ -92,7 +96,7 @@ pub async fn disable(
Path(id): Path<i64>,
) -> AppResult<Json<serde_json::Value>> {
let affected =
sqlx::query("UPDATE entries SET share_token = NULL, share_key_hash = NULL WHERE id = ?1")
sqlx::query("UPDATE nodes SET share_token = NULL, share_key_hash = NULL WHERE id = ?1")
.bind(id)
.execute(&state.db)
.await?
@@ -109,10 +113,10 @@ pub async fn disable(
pub async fn read(
State(state): State<AppState>,
Path((token, key)): Path<(String, String)>,
) -> AppResult<Json<SharedEntry>> {
) -> AppResult<Json<SharedDocument>> {
let row = sqlx::query(
"SELECT title, body, created_at, updated_at, share_key_hash
FROM entries WHERE share_token = ?1",
"SELECT name, body, created_at, updated_at, share_key_hash
FROM nodes WHERE share_token = ?1",
)
.bind(&token)
.fetch_optional(&state.db)
@@ -126,8 +130,8 @@ pub async fn read(
return Err(AppError::NotFound);
}
Ok(Json(SharedEntry {
title: row.get("title"),
Ok(Json(SharedDocument {
name: row.get("name"),
body: row.get("body"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
@@ -135,17 +139,17 @@ pub async fn read(
}))
}
/// Public: serves a media file only while it is embedded in a shared entry, and
/// only to a caller holding that entry's key.
/// Public: serves a media file only while it is embedded in a shared document,
/// and only to a caller holding that document's key.
pub async fn serve_media(
State(state): State<AppState>,
Path((token, key, id)): Path<(String, String, String)>,
) -> AppResult<Response> {
let stored: Option<String> = sqlx::query(
"SELECT e.share_key_hash
FROM entry_media em
JOIN entries e ON e.id = em.entry_id
WHERE em.media_id = ?1 AND e.share_token = ?2",
"SELECT n.share_key_hash
FROM node_media nm
JOIN nodes n ON n.id = nm.node_id
WHERE nm.media_id = ?1 AND n.share_token = ?2",
)
.bind(&id)
.bind(&token)
+3 -1
View File
@@ -2,7 +2,7 @@ use std::sync::Arc;
use sqlx::SqlitePool;
use crate::{backup::Backup, config::Config, throttle::LoginThrottle};
use crate::{backup::Backup, config::Config, notify::Notifier, throttle::LoginThrottle};
#[derive(Clone)]
pub struct AppState {
@@ -11,4 +11,6 @@ pub struct AppState {
pub login_throttle: Arc<LoginThrottle>,
/// The Proton Drive mirror. Always present; dormant until it has a session.
pub backup: Arc<Backup>,
/// Notification mail. Always present; dormant until WORKSPACE_SMTP_URL is set.
pub notify: Arc<Notifier>,
}
+6 -3
View File
@@ -2,7 +2,7 @@ use std::sync::Mutex;
/// Failed logins get progressively slower to answer.
///
/// The diary has exactly one account, so this is one counter rather than a map
/// The workspace has exactly one account, so this is one counter rather than a map
/// keyed by address: an attacker cannot step around it by changing source IP,
/// and there is no per-client table to grow. The cost of that choice is that
/// someone hammering the login can slow the owner's own attempt down — which is
@@ -19,7 +19,7 @@ struct State {
}
/// Wrong passwords happen; the first few are answered at full speed.
const FREE_ATTEMPTS: u32 = 3;
pub const FREE_ATTEMPTS: u32 = 3;
const MAX_DELAY: i64 = 30;
/// 1s, 2s, 4s, 8s… up to `MAX_DELAY`, starting once the free attempts are used.
@@ -40,10 +40,13 @@ impl LoginThrottle {
}
}
pub fn record_failure(&self) {
/// Returns how many failures have piled up, so the caller can decide
/// whether this one is worth telling anybody about.
pub fn record_failure(&self) -> u32 {
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
state.failures = state.failures.saturating_add(1);
state.next_allowed = crate::now() + delay_after(state.failures);
state.failures
}
pub fn record_success(&self) {
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0b0e0d" />
<title>~/diary</title>
<title>~/workspace</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230b0e0d'/%3E%3Ctext x='16' y='22' font-family='monospace' font-size='16' fill='%2354f2a1' text-anchor='middle'%3E~%3C/text%3E%3C/svg%3E" />
</head>
<body>
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "narl-diary-web",
"name": "narl-workspace-web",
"private": true,
"type": "module",
"scripts": {
+160 -33
View File
@@ -1,15 +1,16 @@
<script lang="ts">
import { hooks, runCommand } from './lib/commands'
import { embedSnippet } from './lib/markdown'
import Board from './lib/Board.svelte'
import CommandLine from './lib/CommandLine.svelte'
import EntryPane from './lib/EntryPane.svelte'
import DocumentPane from './lib/DocumentPane.svelte'
import Help from './lib/Help.svelte'
import Login from './lib/Login.svelte'
import MediaBrowser from './lib/MediaBrowser.svelte'
import Shared from './lib/Shared.svelte'
import Sidebar from './lib/Sidebar.svelte'
import StatusBar from './lib/StatusBar.svelte'
import { diary } from './lib/store.svelte'
import { workspace } from './lib/store.svelte'
/*
* A share link is /s/<token>#<key>. The fragment never leaves the browser, so
@@ -22,18 +23,26 @@
let cmdline = $state<string | null>(null)
let fileInput = $state<HTMLInputElement>()
let folderInput = $state<HTMLInputElement>()
let archiveInput = $state<HTMLInputElement>()
let shell = $state<HTMLDivElement>()
let overlayEl = $state<HTMLDivElement>()
let pending = $state('')
if (!shareToken) void diary.boot()
if (!shareToken) void workspace.boot()
$effect(() => {
document.documentElement.dataset.theme = diary.theme
document.documentElement.dataset.theme = workspace.theme
})
$effect(() => {
folderInput?.setAttribute('webkitdirectory', '')
})
$effect(() => {
hooks.pickFiles = () => fileInput?.click()
hooks.pickFolder = () => folderInput?.click()
hooks.pickArchive = () => archiveInput?.click()
hooks.focusList = () => shell?.focus()
hooks.openCommandLine = (initial) => (cmdline = initial)
})
@@ -43,15 +52,15 @@
* window handler instead of being swallowed by the editor underneath.
*/
$effect(() => {
if (diary.overlay !== 'none') {
if (workspace.overlay !== 'none') {
overlayEl?.focus()
return () => (diary.editing ? hooks.focusEditor() : shell?.focus())
return () => (workspace.editing ? hooks.focusEditor() : shell?.focus())
}
})
// Guard against losing a half-written entry to a stray refresh.
$effect(() => {
if (!diary.dirty) return
if (!workspace.dirty) return
const warn = (event: BeforeUnloadEvent) => event.preventDefault()
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
@@ -62,12 +71,89 @@
const files = Array.from(input.files ?? [])
input.value = ''
if (files.length === 0) return
await diary.guard(async () => {
const uploaded = await diary.upload(files)
await workspace.guard(async () => {
const uploaded = await workspace.upload(files)
hooks.insertText(uploaded.map(embedSnippet).join('\n\n'))
})
}
/*
* A folder upload is the same multipart body as any other, except that each
* file carries the path it had inside the folder — which is what makes the
* tree on the other side possible.
*/
async function importFolder(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
input.value = ''
if (files.length === 0) return
await workspace.guard(() => workspace.importFolder(files))
}
/*
* The board reads the same keys as the list, one dimension wider: h/l walk
* the lists, j/k the cards, and the shifted pair takes the card along. Only
* `:` and `?` fall through to the keys below, because everything else would
* act on a tree that is not on screen.
*/
function boardKey(key: string): boolean {
switch (key) {
case 'h':
case 'ArrowLeft':
workspace.moveList(-1)
return true
case 'l':
case 'ArrowRight':
workspace.moveList(1)
return true
case 'j':
case 'ArrowDown':
workspace.moveCard(1)
return true
case 'k':
case 'ArrowUp':
workspace.moveCard(-1)
return true
case 'J':
void workspace.guard(() => workspace.nudgeCard(1))
return true
case 'K':
void workspace.guard(() => workspace.nudgeCard(-1))
return true
case 'H':
void workspace.guard(() => workspace.sendCard(-1))
return true
case 'L':
void workspace.guard(() => workspace.sendCard(1))
return true
case 'G':
workspace.cardCursor = Math.max((workspace.list?.cards.length ?? 0) - 1, 0)
return true
case 'Enter':
void workspace.guard(() => workspace.openCard())
return true
case 'o':
cmdline = ':card '
return true
case 't':
case ' ':
void workspace.guard(() => workspace.toggleDone())
return true
case 'x':
void runCommand('delete')
return true
case 'r':
void workspace.guard(() => workspace.reloadBoard())
return true
case 'q':
case 'Escape':
workspace.closeBoard()
return true
default:
return false
}
}
function isTypingTarget(target: EventTarget | null) {
const node = target as HTMLElement | null
return (
@@ -82,13 +168,13 @@
function onkeydown(event: KeyboardEvent) {
if (event.key === 's' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
if (diary.open) void diary.guard(() => diary.save())
if (workspace.open) void workspace.guard(() => workspace.save())
return
}
if (event.key === 'Escape') {
if (diary.overlay !== 'none') {
diary.overlay = 'none'
if (workspace.overlay !== 'none') {
workspace.overlay = 'none'
return
}
if (cmdline !== null) {
@@ -98,10 +184,10 @@
}
}
if (diary.overlay !== 'none') {
if (workspace.overlay !== 'none') {
if (event.key === 'q' || event.key === '?') {
event.preventDefault()
diary.overlay = 'none'
workspace.overlay = 'none'
}
return
}
@@ -113,10 +199,26 @@
const previous = pending
pending = ''
if (workspace.view === 'board') {
if (previous === 'g' && key === 'g') {
event.preventDefault()
workspace.cardCursor = 0
return
}
if (key === 'g') {
pending = key
return
}
if (boardKey(key)) {
event.preventDefault()
return
}
}
// Two-key sequences: gg and dd.
if (previous === 'g' && key === 'g') {
event.preventDefault()
diary.cursor = 0
workspace.cursor = 0
return
}
if (previous === 'd' && key === 'd') {
@@ -133,27 +235,33 @@
case 'j':
case 'ArrowDown':
event.preventDefault()
diary.move(1)
workspace.move(1)
break
case 'k':
case 'ArrowUp':
event.preventDefault()
diary.move(-1)
workspace.move(-1)
break
case 'G':
event.preventDefault()
diary.cursor = Math.max(diary.entries.length - 1, 0)
workspace.cursor = Math.max(workspace.nodes.length - 1, 0)
break
case 'Enter':
case 'l':
case 'ArrowRight':
event.preventDefault()
void diary.guard(() => diary.openSelected())
void workspace.guard(() => workspace.openSelected())
break
case 'o':
event.preventDefault()
void runCommand('new')
break
case 'O':
// The name has to come from somewhere, and the command line is where
// names are typed — this only saves typing `:mkdir `.
event.preventDefault()
cmdline = ':mkdir '
break
case 'i':
case 'a':
event.preventDefault()
@@ -161,10 +269,16 @@
break
case 'h':
case 'ArrowLeft':
// Out of the document if one is open, otherwise up a level — the same
// key means "leftwards" at both depths.
event.preventDefault()
if (workspace.open) void runCommand('q')
else void workspace.guard(() => workspace.up())
break
case 'q':
case 'Escape':
event.preventDefault()
if (diary.open) void runCommand('q')
if (workspace.open) void runCommand('q')
break
case 'x':
event.preventDefault()
@@ -172,7 +286,7 @@
break
case 's':
event.preventDefault()
void runCommand(diary.selected?.shared ? 'unshare' : 'share')
void runCommand(workspace.selected?.shared ? 'unshare' : 'share')
break
case 'y':
event.preventDefault()
@@ -180,7 +294,7 @@
break
case 'n':
event.preventDefault()
if (diary.query) void diary.guard(() => diary.search(''))
if (workspace.query) void workspace.guard(() => workspace.search(''))
break
case '/':
event.preventDefault()
@@ -202,32 +316,39 @@
{#if shareToken}
<Shared token={shareToken} shareKey={shareKey} />
{:else if diary.booting}
<div class="boot faint">booting ~/diary…</div>
{:else if !diary.user}
{:else if workspace.booting}
<div class="boot faint">booting ~/workspace…</div>
{:else if !workspace.user}
<Login />
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="shell" bind:this={shell} tabindex="-1">
<div class="panes" class:reading={!!diary.open}>
{#if workspace.view === 'board'}
<!-- The board takes the whole width: its columns are the two panes. -->
<div class="panes board"><Board /></div>
{:else}
<div class="panes" class:reading={!!workspace.open}>
<div class="list"><Sidebar /></div>
<div class="entry">
{#if diary.open}
<EntryPane />
{#if workspace.open}
<DocumentPane />
{:else}
<div class="placeholder faint">
<pre>{` ┌─────────────────────────────┐
│ nothing open │
│ │
│ o write a new entry │
│ o write a new document │
│ O make a folder │
│ j/k browse, Enter opens │
│ / search everything │
│ h up one level │
│ / search this space │
│ ? help │
└─────────────────────────────┘`}</pre>
</div>
{/if}
</div>
</div>
{/if}
<StatusBar />
@@ -235,17 +356,21 @@
<CommandLine initial={cmdline} close={() => (cmdline = null)} />
{/if}
{#if diary.overlay !== 'none'}
{#if workspace.overlay !== 'none'}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (diary.overlay = 'none')}
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
>
{#if diary.overlay === 'help'}<Help />{:else}<MediaBrowser />{/if}
{#if workspace.overlay === 'help'}<Help />{:else}<MediaBrowser />{/if}
</div>
{/if}
</div>
{/if}
<input class="hidden" type="file" multiple bind:this={fileInput} onchange={attach} />
<!-- `webkitdirectory` is set from script: it is the one attribute browsers
agree on for picking a folder, and no typed attribute list carries it. -->
<input class="hidden" type="file" multiple bind:this={folderInput} onchange={importFolder} />
<input class="hidden" type="file" accept=".zip" bind:this={archiveInput} onchange={importFolder} />
<style>
.boot { display: grid; place-items: center; height: 100%; }
@@ -265,6 +390,8 @@
grid-template-columns: minmax(28ch, 34ch) 1fr;
}
.panes.board { grid-template-columns: 1fr; }
.list, .entry { min-width: 0; overflow: hidden; }
.placeholder { display: grid; place-items: center; height: 100%; }
+199
View File
@@ -0,0 +1,199 @@
<script lang="ts">
/*
* The board of the current space: columns of cards, in the same terminal
* idiom as the list. The keys live in App.svelte with every other key, so
* this file only draws and — for a pointer — moves the two cursors.
*/
import type { Card } from './api'
import { hooks, runCommand } from './commands'
import SpaceBar from './SpaceBar.svelte'
import { workspace } from './store.svelte'
import { formatDay } from './util'
let columns = $state<HTMLDivElement>()
// Keep the selected card on screen while walking the board with the keyboard.
$effect(() => {
const at = `${workspace.listCursor}:${workspace.cardCursor}`
columns?.querySelector<HTMLElement>(`[data-at="${at}"]`)?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
})
const midnight = () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return Math.floor(today.getTime() / 1000)
}
/** `overdue`, `today` or nothing — a date only earns colour when it bites. */
function urgency(card: Card): '' | 'today' | 'overdue' {
if (card.due_at === null || card.done_at !== null) return ''
const start = midnight()
if (card.due_at < start) return 'overdue'
return card.due_at < start + 86400 ? 'today' : ''
}
function pick(list: number, card: number) {
workspace.listCursor = list
workspace.cardCursor = card
}
</script>
<div class="board">
<div class="head">
<span class="accent">~/{workspace.space?.slug ?? ''}/board</span>
<span class="faint count">
{workspace.board?.lists.reduce((n, list) => n + list.cards.length, 0) ?? 0} cards
</span>
<span class="tools">
<button title="new card (o)" onclick={() => hooks.openCommandLine(':card ')}>+</button>
<button title="new list" onclick={() => hooks.openCommandLine(':list ')}>[+]</button>
<button title="back to the tree (:board)" onclick={() => runCommand('board')}>tree</button>
<button title="command line (:)" onclick={() => hooks.openCommandLine(':')}>:</button>
<button title="help (?)" onclick={() => runCommand('help')}>?</button>
</span>
</div>
<SpaceBar />
<div class="columns" bind:this={columns}>
{#each workspace.board?.lists ?? [] as list, listIndex (list.id)}
<section class="column" class:on={listIndex === workspace.listCursor}>
<header>
<span class="name">{list.name}</span>
<span class="faint">{list.cards.length}</span>
</header>
<ul>
{#each list.cards as card, cardIndex (card.id)}
<li>
<button
class="card"
class:active={listIndex === workspace.listCursor && cardIndex === workspace.cardCursor}
class:done={card.done_at !== null}
data-at="{listIndex}:{cardIndex}"
onclick={() => pick(listIndex, cardIndex)}
ondblclick={() => workspace.guard(() => workspace.openCard())}
>
<span class="title">
<span class="tick">{card.done_at === null ? '[ ]' : '[x]'}</span>
{card.title}
</span>
{#if card.due_at !== null || card.node_id !== null}
<span class="meta faint">
{#if card.due_at !== null}
<span class={urgency(card)}>due {formatDay(card.due_at)}</span>
{/if}
{#if card.node_id !== null}
<span class="doc" title="Enter opens it">→ {card.node_name ?? `#${card.node_id}`}</span>
{/if}
</span>
{/if}
</button>
</li>
{:else}
<li class="empty faint">—</li>
{/each}
</ul>
</section>
{:else}
<div class="empty faint">no lists — :list &lt;name&gt; makes one</div>
{/each}
</div>
</div>
<style>
.board {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.head {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-bottom: 1px solid var(--line);
flex: none;
}
.count { flex: 1; text-align: right; white-space: nowrap; }
.tools { display: flex; gap: 2px; flex: none; }
.tools button { padding: 0 6px; line-height: 18px; }
.columns {
flex: 1;
min-height: 0;
display: flex;
align-items: stretch;
gap: 0;
overflow: auto;
}
.column {
display: flex;
flex-direction: column;
min-width: 26ch;
flex: 1 1 0;
max-width: 44ch;
border-right: 1px solid var(--line);
min-height: 0;
}
.column header {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border-bottom: 1px solid var(--line);
color: var(--fg-dim);
position: sticky;
top: 0;
background: var(--bg);
}
/* The list the cursor is on, marked the way the sidebar marks its space. */
.column.on header .name { color: var(--accent); font-weight: 700; }
.column.on { background: var(--bg-alt); }
ul {
list-style: none;
margin: 0;
padding: 4px 0 24px;
overflow-y: auto;
flex: 1;
}
.card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
width: 100%;
border: 0;
border-radius: 0;
padding: 4px 10px;
text-align: left;
color: var(--fg-dim);
white-space: normal;
}
.card:hover { background: var(--bg-lift); color: var(--fg); }
.card.active { background: var(--select); color: var(--fg); }
.card.done .title { text-decoration: line-through; color: var(--fg-faint); }
.tick { color: var(--accent); }
.title { display: block; }
.meta { display: flex; gap: 8px; font-size: 12px; padding-left: 4ch; }
.meta .today { color: var(--warn); }
.meta .overdue { color: var(--error); }
.doc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 24ch; }
.empty { padding: 12px 10px; }
@media (pointer: coarse) {
.card { padding: 8px 10px; }
.tools button { padding: 4px 10px; }
}
</style>
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte'
import { completions, runCommand, hooks } from './commands'
import { diary } from './store.svelte'
import { workspace } from './store.svelte'
interface Props {
initial: string
@@ -23,7 +23,7 @@
$effect(() => {
if (!isSearch) return
const query = value.slice(1)
const timer = setTimeout(() => void diary.guard(() => diary.search(query)), 120)
const timer = setTimeout(() => void workspace.guard(() => workspace.search(query)), 120)
return () => clearTimeout(timer)
})
@@ -1,47 +1,61 @@
<script lang="ts">
import { runCommand } from './commands'
import { renderMarkdown } from './markdown'
import { diary } from './store.svelte'
import { workspace } from './store.svelte'
import { formatStamp, formatWeekday, isTouchDevice, relative } from './util'
const entry = $derived(diary.open!)
const html = $derived(renderMarkdown(diary.editing ? diary.draft.body : entry.body))
const doc = $derived(workspace.open!)
const html = $derived(renderMarkdown(workspace.editing ? workspace.draft.body : doc.body))
const words = $derived(
(diary.editing ? diary.draft.body : entry.body).split(/\s+/).filter(Boolean).length,
(workspace.editing ? workspace.draft.body : doc.body).split(/\s+/).filter(Boolean).length,
)
/*
* Only the token half of a share link is recoverable — the key lives in the
* fragment, and the server kept nothing but its hash — so the bar shows the
* link's shape and offers a fresh one rather than a copy of the old.
*/
const sharePath = $derived(entry.share_token ? `${location.origin}/s/${entry.share_token}#…` : null)
const sharePath = $derived(doc.share_token ? `${location.origin}/s/${doc.share_token}#…` : null)
/**
* `/n/<space>/<slug>` links — the ones the importer writes between imported
* documents — are followed in place rather than reloading the application.
*/
function follow(event: MouseEvent) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return
const anchor = (event.target as HTMLElement | null)?.closest('a')
const href = anchor?.getAttribute('href') ?? ''
if (!href.startsWith('/n/')) return
event.preventDefault()
const path = href.slice(3).split('#')[0]
void workspace.guard(() => workspace.openPath(decodeURIComponent(path)))
}
</script>
<section class="pane">
<header>
<div class="meta">
<span class="accent">entry:{entry.id}</span>
<span class="faint">{formatStamp(diary.draft.created_at)} {formatWeekday(diary.draft.created_at)}</span>
<span class="accent">{workspace.here}/{doc.slug}</span>
<span class="faint">{formatStamp(workspace.draft.created_at)} {formatWeekday(workspace.draft.created_at)}</span>
<span class="faint">·</span>
<span class="faint">edited {relative(entry.updated_at)}</span>
<span class="faint">edited {relative(doc.updated_at)}</span>
<span class="faint">·</span>
<span class="faint">{words} words</span>
{#if diary.dirty}<span class="warn">[+]</span>{/if}
{#if workspace.dirty}<span class="warn">[+]</span>{/if}
</div>
<div class="actions">
{#if isTouchDevice}
<button onclick={() => runCommand('q')}>← list</button>
{/if}
{#if diary.editing}
{#if workspace.editing}
<button onclick={() => runCommand('w')}>:w</button>
<button onclick={() => runCommand('upload')}>attach</button>
<button onclick={() => runCommand('wq')}>done</button>
{:else}
<button onclick={() => runCommand('e')}>edit</button>
{/if}
<button class:on={entry.shared} onclick={() => runCommand(entry.shared ? 'unshare' : 'share')}>
{entry.shared ? 'unshare' : 'share'}
<button class:on={doc.shared} onclick={() => runCommand(doc.shared ? 'unshare' : 'share')}>
{doc.shared ? 'unshare' : 'share'}
</button>
<button onclick={() => runCommand('d')}>delete</button>
<button onclick={() => runCommand('q')}>close</button>
@@ -56,12 +70,12 @@
</div>
{/if}
{#if diary.editing}
{#if workspace.editing}
<input
class="title"
placeholder="title (optional)"
bind:value={diary.draft.title}
oninput={() => diary.touch()}
placeholder="name (optional)"
bind:value={workspace.draft.name}
oninput={() => workspace.touch()}
onkeydown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
@@ -70,7 +84,7 @@
}}
/>
<div class="body">
{#key entry.id}
{#key doc.id}
{#await import('./Editor.svelte')}
<div class="loading faint">loading editor…</div>
{:then module}
@@ -88,12 +102,13 @@
{/if}
{:else}
<div class="body reading">
<article class="md">
{#if entry.title.trim()}<h1 class="entrytitle">{entry.title}</h1>{/if}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<article class="md" onclick={follow}>
{#if doc.name.trim()}<h1 class="entrytitle">{doc.name}</h1>{/if}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
{@html html}
{#if !entry.body.trim()}
<p class="faint">this entry is empty — press <span class="accent">i</span> to write.</p>
{#if !doc.body.trim()}
<p class="faint">this document is empty — press <span class="accent">i</span> to write.</p>
{/if}
</article>
</div>
+19 -19
View File
@@ -21,7 +21,7 @@
import { onDestroy, onMount } from 'svelte'
import { commands, hooks, runCommand } from './commands'
import { embedSnippet } from './markdown'
import { diary } from './store.svelte'
import { workspace } from './store.svelte'
let host = $state<HTMLDivElement>()
let view: EditorView | undefined
@@ -110,18 +110,18 @@
const push = registers.pushText.bind(registers)
registers.pushText = (name, operator, text, linewise, blockwise) => {
push(name, operator, text, linewise, blockwise)
if (!name && text && diary.clipboard) void diary.copy(text)
if (!name && text && workspace.clipboard) void workspace.copy(text)
}
}
/**
* `?` is vim's search-backwards, but the diary advertises it as the help key
* `?` is vim's search-backwards, but the workspace advertises it as the help key
* everywhere else, so it opens the reference here too. `/` still searches.
*/
function bindHelpKey() {
Vim.defineAction('diaryHelp', () => void runCommand('help'))
Vim.defineAction('workspaceHelp', () => void runCommand('help'))
for (const context of ['normal', 'visual'] as const) {
Vim.mapCommand('?', 'action', 'diaryHelp', {}, { context })
Vim.mapCommand('?', 'action', 'workspaceHelp', {}, { context })
}
}
@@ -145,8 +145,8 @@
async function uploadFiles(files: File[]) {
if (files.length === 0) return
await diary.guard(async () => {
const uploaded = await diary.upload(files)
await workspace.guard(async () => {
const uploaded = await workspace.upload(files)
insertAtCursor(uploaded.map(embedSnippet).join('\n\n'))
})
}
@@ -157,7 +157,7 @@
mirrorUnnamedRegisterToClipboard()
const extensions = [
...(diary.vimEnabled ? [vim({ status: false })] : []),
...(workspace.vimEnabled ? [vim({ status: false })] : []),
history(),
drawSelection(),
dropCursor(),
@@ -167,13 +167,13 @@
markdown({ base: markdownLanguage, codeLanguages: [] }),
syntaxHighlighting(markdownHighlight),
EditorView.lineWrapping,
placeholder('dear diary…'),
placeholder('write…'),
editorTheme,
keymap.of([
{
key: 'Mod-s',
preventDefault: true,
run: () => (void diary.guard(() => diary.save()), true),
run: () => (void workspace.guard(() => workspace.save()), true),
},
...defaultKeymap,
...historyKeymap,
@@ -181,8 +181,8 @@
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
diary.draft.body = update.state.doc.toString()
diary.touch()
workspace.draft.body = update.state.doc.toString()
workspace.touch()
}
}),
EditorView.domEventHandlers({
@@ -204,7 +204,7 @@
]
view = new EditorView({
state: EditorState.create({ doc: diary.draft.body, extensions }),
state: EditorState.create({ doc: workspace.draft.body, extensions }),
parent: host!,
})
@@ -212,21 +212,21 @@
hooks.insertText = insertAtCursor
view.focus()
const cm = diary.vimEnabled ? getCM(view) : null
const cm = workspace.vimEnabled ? getCM(view) : null
if (cm) {
// Keep the status line's mode indicator in step with the editor.
const emitter = cm as unknown as {
on?: (event: string, handler: (e: { mode: string }) => void) => void
}
emitter.on?.('vim-mode-change', (event) => {
diary.vimMode = event.mode
workspace.vimMode = event.mode
})
// A fresh entry starts in insert mode; an existing one starts in normal
// mode, the way opening a file in vim does.
if (diary.enterInsert) {
diary.enterInsert = false
Vim.handleKey(cm, 'i', 'diary')
if (workspace.enterInsert) {
workspace.enterInsert = false
Vim.handleKey(cm, 'i', 'workspace')
}
}
})
@@ -236,7 +236,7 @@
view = undefined
hooks.focusEditor = () => {}
hooks.insertText = () => {}
diary.vimMode = 'normal'
workspace.vimMode = 'normal'
})
</script>
+40 -12
View File
@@ -1,26 +1,44 @@
<script lang="ts">
import { commands, groups, type CommandSpec, type Group } from './commands'
import { diary } from './store.svelte'
import { workspace } from './store.svelte'
const keys: [string, string][] = [
['j / k · ↓ ↑', 'move down / up the entry list'],
['gg / G', 'first / last entry'],
['Enter · l', 'open the highlighted entry'],
['o', 'new entry, straight into insert mode'],
['i · a', 'edit the open entry'],
['Esc · h · q', 'back out to the list'],
['/', 'full-text search'],
['j / k · ↓ ↑', 'move down / up the list'],
['gg / G', 'first / last row'],
['Enter · l', 'open a document, or enter a folder'],
['h', 'up one level, or back out of a document'],
['o', 'new document here, straight into insert mode'],
['O', 'new folder here'],
['i · a', 'edit the open document'],
['Esc · q', 'back out to the list'],
['/', 'full-text search, inside the current space'],
['n', 'clear the search'],
['s', 'toggle sharing for the entry'],
['s', 'toggle sharing for the document'],
['y', 'mint a new share link and copy it'],
['x · dd', 'delete the entry (asks first)'],
['x · dd', 'delete what is selected (asks first)'],
[':', 'command line'],
['Ctrl-S', 'save, from anywhere'],
['?', 'this screen — in the editor too'],
]
/* The board rebinds most keys, so it gets its own column rather than
footnotes on the list keys. */
const boardKeys: [string, string][] = [
['h / l', 'to the list 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 left / right'],
['Enter', 'open the document the card links to'],
['t · Space', 'tick the card off, or un-tick it'],
['o', 'new card on this list'],
['x', 'delete the card (asks first)'],
['q · Esc', 'back to the tree'],
]
const titles: Record<Group, string> = {
entries: 'entries',
tree: 'spaces & folders',
board: 'boards',
documents: 'documents',
editing: 'writing',
sharing: 'sharing & export',
view: 'session',
@@ -39,7 +57,7 @@
<header>
<span class="accent">:help</span>
<span class="faint">everything is a key or a `:` command</span>
<button onclick={() => (diary.overlay = 'none')}>esc</button>
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
<div class="cols">
@@ -53,6 +71,16 @@
</dl>
</section>
<section class="keys">
<h2>board keys</h2>
<dl>
{#each boardKeys as [key, description] (key)}
<dt>{key}</dt>
<dd>{description}</dd>
{/each}
</dl>
</section>
{#each grouped as [group, specs] (group)}
<section>
<h2>{titles[group]}</h2>
+4 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { ApiError } from './api'
import { diary } from './store.svelte'
import { workspace } from './store.svelte'
let username = $state('')
let password = $state('')
@@ -17,7 +17,7 @@
busy = true
error = ''
try {
await diary.login(username, password)
await workspace.login(username, password)
} catch (e) {
// The server answers a wrong password with a bare 401; the throttle's
// "wait Ns" message is worth showing verbatim.
@@ -37,10 +37,10 @@
<main>
<form onsubmit={submit}>
<pre class="banner">{`╭──────────────────────────────────╮
│ ~/diary │
│ ~/workspace │
╰──────────────────────────────────╯`}</pre>
<p class="faint">a private log. one user. one machine.</p>
<p class="faint">a private workspace. one user. one machine.</p>
<label>
<span class="accent">login:</span>
+16 -16
View File
@@ -2,19 +2,19 @@
import { api } from './api'
import { hooks } from './commands'
import { embedSnippet } from './markdown'
import { diary } from './store.svelte'
import { workspace } from './store.svelte'
import { formatBytes, formatStamp } from './util'
const usage = (entryIds: number[]) =>
entryIds.length === 0 ? 'unattached' : entryIds.map((id) => `entry:${id}`).join(' ')
const usage = (nodeIds: number[]) =>
nodeIds.length === 0 ? 'unattached' : nodeIds.map((id) => `#${id}`).join(' ')
async function remove(id: string, filename: string, entryIds: number[]) {
const used = entryIds.length === 1 ? '1 entry' : `${entryIds.length} entries`
const warning = entryIds.length > 0 ? ` it is embedded in ${used}, which will break.` : ''
async function remove(id: string, filename: string, nodeIds: number[]) {
const used = nodeIds.length === 1 ? '1 document' : `${nodeIds.length} documents`
const warning = nodeIds.length > 0 ? ` it is embedded in ${used}, which will break.` : ''
if (!confirm(`delete ${filename}?${warning}`)) return
await diary.guard(async () => {
await workspace.guard(async () => {
await api.deleteMedia(id)
await diary.loadMedia()
await workspace.loadMedia()
})
}
</script>
@@ -22,12 +22,12 @@
<div class="sheet">
<header>
<span class="accent">:media</span>
<span class="faint">{diary.media.length} files</span>
<button onclick={() => (diary.overlay = 'none')}>esc</button>
<span class="faint">{workspace.media.length} files</span>
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
<ul>
{#each diary.media as file (file.id)}
{#each workspace.media as file (file.id)}
<li>
<div class="thumb">
{#if file.mime.startsWith('image/')}
@@ -41,21 +41,21 @@
<span class="name">{file.filename}</span>
<span class="faint">
{formatBytes(file.size)} · {formatStamp(file.created_at)} ·
{usage(file.entry_ids)}
{usage(file.node_ids)}
</span>
</div>
<div class="actions">
{#if diary.editing}
{#if workspace.editing}
<button
onclick={() => {
hooks.insertText(embedSnippet(file))
diary.overlay = 'none'
workspace.overlay = 'none'
}}>insert</button
>
{/if}
<button onclick={() => diary.copy(location.origin + file.url)}>copy url</button>
<button onclick={() => remove(file.id, file.filename, file.entry_ids)}>rm</button>
<button onclick={() => workspace.copy(location.origin + file.url)}>copy url</button>
<button onclick={() => remove(file.id, file.filename, file.node_ids)}>rm</button>
</div>
</li>
{:else}
+11 -11
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import { api, type SharedEntry } from './api'
import { api, type SharedDocument } from './api'
import { renderMarkdown } from './markdown'
import { formatStamp, formatWeekday } from './util'
const { token, shareKey }: { token: string; shareKey: string } = $props()
let entry = $state<SharedEntry | null>(null)
let doc = $state<SharedDocument | null>(null)
let error = $state('')
$effect(() => {
@@ -16,27 +16,27 @@
api
.readShared(token, shareKey)
.then((data) => {
entry = data
document.title = data.title.trim() || `~/diary ${formatStamp(data.created_at)}`
doc = data
document.title = data.name.trim() || `~/workspace ${formatStamp(data.created_at)}`
})
.catch(() => (error = 'this link is not valid, or the entry is no longer shared.'))
.catch(() => (error = 'this link is not valid, or the document is no longer shared.'))
})
</script>
<main>
{#if error}
<p class="error">{error}</p>
{:else if entry}
{:else if doc}
<article class="md">
<div class="meta faint">
{formatStamp(entry.created_at)} {formatWeekday(entry.created_at)}
<span class="tag">shared entry</span>
{formatStamp(doc.created_at)} {formatWeekday(doc.created_at)}
<span class="tag">shared document</span>
</div>
{#if entry.title.trim()}<h1>{entry.title}</h1>{/if}
{#if doc.name.trim()}<h1>{doc.name}</h1>{/if}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
{@html renderMarkdown(entry.body, { token, key: shareKey })}
{@html renderMarkdown(doc.body, { token, key: shareKey })}
</article>
<footer class="faint">— written in ~/diary</footer>
<footer class="faint">— written in ~/workspace</footer>
{:else}
<p class="faint">loading…</p>
{/if}
+35 -25
View File
@@ -1,75 +1,85 @@
<script lang="ts">
import type { EntrySummary } from './api'
import type { NodeSummary } from './api'
import { hooks, runCommand } from './commands'
import { diary } from './store.svelte'
import SpaceBar from './SpaceBar.svelte'
import { workspace } from './store.svelte'
import { formatDay, formatTime, formatWeekday } from './util'
let list = $state<HTMLUListElement>()
// Keep the cursor line visible while navigating with j/k.
$effect(() => {
const index = diary.cursor
const index = workspace.cursor
const node = list?.querySelector<HTMLElement>(`[data-index="${index}"]`)
node?.scrollIntoView({ block: 'nearest' })
})
function label(entry: EntrySummary) {
return entry.title.trim() || entry.excerpt.trim() || '(empty)'
function label(node: NodeSummary) {
return node.name.trim() || node.excerpt.trim() || node.slug
}
/* A folder is listed by what is in it; a document by when it was written. */
const aside = (node: NodeSummary) =>
node.kind === 'document' ? formatTime(node.created_at) : `${node.child_count}`
function pick(index: number) {
diary.cursor = index
void diary.guard(() => diary.openSelected())
workspace.cursor = index
void workspace.guard(() => workspace.openSelected())
}
</script>
<div class="sidebar">
<div class="head">
<span class="accent">~/diary</span>
<span class="accent">~/{workspace.space?.slug ?? 'diary'}</span>
<span class="faint count">
{diary.entries.length} {diary.entries.length === 1 ? 'entry' : 'entries'}
{workspace.nodes.length} {workspace.nodes.length === 1 ? 'item' : 'items'}
</span>
<span class="tools">
<button title="new entry (o)" onclick={() => runCommand('new')}>+</button>
<button title="new document (o)" onclick={() => runCommand('new')}>+</button>
<button title="new folder (O)" onclick={() => hooks.openCommandLine(':mkdir ')}>[+]</button>
<button title="search (/)" onclick={() => hooks.openCommandLine('/')}>/</button>
<button title="command line (:)" onclick={() => hooks.openCommandLine(':')}>:</button>
<button title="help (?)" onclick={() => runCommand('help')}>?</button>
</span>
</div>
{#if diary.query}
<SpaceBar />
{#if workspace.query}
<div class="filter">
<span class="faint">/</span>{diary.query}
<button title="clear search" onclick={() => diary.guard(() => diary.search(''))}>esc</button>
<span class="faint">/</span>{workspace.query}
<button title="clear search" onclick={() => workspace.guard(() => workspace.search(''))}>esc</button>
</div>
{/if}
<ul bind:this={list}>
{#each diary.entries as entry, index (entry.id)}
{@const previous = diary.entries[index - 1]}
{#if !diary.query && (!previous || formatDay(previous.created_at) !== formatDay(entry.created_at))}
{#each workspace.nodes as node, index (node.id)}
{@const previous = workspace.nodes[index - 1]}
<!-- Day headings only make sense in a list that is a journal: documents,
in date order, not a search and not a folder listing. -->
{#if !workspace.query && node.kind === 'document' && (!previous || previous.kind !== 'document' || formatDay(previous.created_at) !== formatDay(node.created_at))}
<li class="daybreak">
<span>{formatDay(entry.created_at)}</span>
<span class="faint">{formatWeekday(entry.created_at)}</span>
<span>{formatDay(node.created_at)}</span>
<span class="faint">{formatWeekday(node.created_at)}</span>
</li>
{/if}
<li>
<button
class="row"
class:active={index === diary.cursor}
class:open={diary.open?.id === entry.id}
class:active={index === workspace.cursor}
class:open={workspace.open?.id === node.id}
data-index={index}
onclick={() => pick(index)}
>
<span class="caret">{index === diary.cursor ? '>' : ' '}</span>
<span class="time">{formatTime(entry.created_at)}</span>
<span class="title">{label(entry)}</span>
{#if entry.shared}<span class="shared" title="shared">◉</span>{/if}
<span class="caret">{index === workspace.cursor ? '>' : ' '}</span>
<span class="time">{aside(node)}</span>
<span class="title">{node.kind === 'document' ? '' : '/'}{label(node)}</span>
{#if node.shared}<span class="shared" title="shared">◉</span>{/if}
</button>
</li>
{:else}
<li class="empty faint">
{diary.query ? 'no matches' : 'no entries yet — press o to write one'}
{workspace.query ? 'no matches' : 'nothing here yet — o writes, O makes a folder'}
</li>
{/each}
</ul>
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
/*
* The two lines above the list: which space is open, and where inside it the
* list currently is. Both are clickable, because a tree is the one thing that
* is genuinely faster to navigate with a pointer than with `:cd`.
*/
import { hooks } from './commands'
import { workspace } from './store.svelte'
// The space is the first crumb, and it is already named by the bar above.
const crumbs = $derived(workspace.path.slice(1))
</script>
<div class="spaces">
{#each workspace.spaces as space (space.id)}
<button
class="space"
class:on={space.id === workspace.spaceId}
onclick={() => workspace.guard(() => workspace.enterSpace(space.id))}
>
{space.slug}
</button>
{/each}
<button class="space add" title="new space (:space! <name>)" onclick={() => hooks.openCommandLine(':space! ')}>
+
</button>
</div>
{#if crumbs.length > 0}
<div class="crumbs">
<button onclick={() => workspace.spaceId !== null && workspace.guard(() => workspace.goTo(workspace.spaceId!))}>
/
</button>
{#each crumbs as crumb, index (crumb.id)}
<span class="faint">/</span>
<button
class:last={index === crumbs.length - 1}
onclick={() => workspace.guard(() => workspace.goTo(crumb.id))}
>
{crumb.slug}
</button>
{/each}
</div>
{/if}
<style>
.spaces,
.crumbs {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 8px;
border-bottom: 1px solid var(--line);
flex: none;
overflow-x: auto;
white-space: nowrap;
}
.crumbs { font-size: 12px; }
button {
border: 0;
border-radius: 0;
background: transparent;
color: var(--fg-dim);
padding: 0 6px;
line-height: 18px;
}
button:hover { color: var(--fg); background: var(--bg-lift); }
.space.on { color: var(--bg); background: var(--accent-dim); font-weight: 700; }
.add { color: var(--fg-faint); }
.crumbs .last { color: var(--accent); }
@media (pointer: coarse) {
button { padding: 4px 10px; }
}
</style>
+10 -7
View File
@@ -1,16 +1,19 @@
<script lang="ts">
import { diary } from './store.svelte'
import { workspace } from './store.svelte'
import { isTouchDevice } from './util'
const mode = $derived(
diary.editing ? (diary.vimEnabled ? diary.vimMode.toUpperCase() : 'EDIT') : 'BROWSE',
workspace.editing ? (workspace.vimEnabled ? workspace.vimMode.toUpperCase() : 'EDIT') : 'BROWSE',
)
// The path is the file name here: work/einarbeiten/01-was-ist-eebus.
const file = $derived(
diary.open ? `entry:${diary.open.id}${diary.dirty ? ' [+]' : ''}` : '[no entry]',
workspace.open
? `${workspace.here}/${workspace.open.slug}${workspace.dirty ? ' [+]' : ''}`
: `${workspace.here || '~'}/`,
)
const lines = $derived(diary.editing ? diary.draft.body.split('\n').length : null)
const lines = $derived(workspace.editing ? workspace.draft.body.split('\n').length : null)
</script>
<footer>
@@ -19,15 +22,15 @@
</span>
<span class="file">{file}</span>
<span class="msg" class:error={diary.flash?.kind === 'error'}>
{diary.flash?.text ?? ''}
<span class="msg" class:error={workspace.flash?.kind === 'error'}>
{workspace.flash?.text ?? ''}
</span>
{#if lines !== null}<span class="faint">{lines}L</span>{/if}
<span class="faint hint">
{isTouchDevice ? 'tap : for commands' : '? help · : command · / search'}
</span>
<span class="user accent">{diary.user}</span>
<span class="user accent">{workspace.user}</span>
</footer>
<style>
+146 -25
View File
@@ -1,32 +1,81 @@
export interface EntrySummary {
/** What a node is: a top-level space, a folder inside one, or a document. */
export type NodeKind = 'space' | 'folder' | 'document'
export interface NodeSummary {
id: number
title: string
parent_id: number | null
kind: NodeKind
name: string
slug: string
excerpt: string
position: number
has_board: boolean
child_count: number
created_at: number
updated_at: number
shared: boolean
share_token: string | null
}
export interface Entry {
/** One step of a node's path, the space first and the node itself last. */
export interface Crumb {
id: number
title: string
kind: NodeKind
name: string
slug: string
}
export interface WorkspaceNode extends NodeSummary {
body: string
excerpt: string
created_at: number
updated_at: number
shared: boolean
share_token: string | null
path: Crumb[]
}
export interface SharedEntry {
title: string
export interface SharedDocument {
name: string
body: string
created_at: number
updated_at: number
token: string
}
/** What an import did, as `POST /api/nodes/{id}/import` reports it. */
export interface ImportReport {
folders: number
documents: number
files: number
links: number
skipped: string[]
}
/** A card on a space's board. `node_id` is the document it is about, if any. */
export interface Card {
id: number
list_id: number
title: string
body: string
node_id: number | null
node_name: string | null
due_at: number | null
done_at: number | null
position: number
created_at: number
updated_at: number
}
export interface BoardList {
id: number
name: string
position: number
cards: Card[]
}
/** One space's board. There is one per space; the diary has none. */
export interface Board {
space_id: number
space_name: string
lists: BoardList[]
}
export interface MediaFile {
id: string
filename: string
@@ -34,8 +83,8 @@ export interface MediaFile {
size: number
created_at: number
url: string
/** Every entry that embeds this file — a file may be used by several. */
entry_ids: number[]
/** Every document that embeds this file — a file may be used by several. */
node_ids: number[]
}
/** The Proton Drive mirror, as `/api/backup` reports it. */
@@ -92,24 +141,96 @@ export const api = {
logout: () => request<{ ok: true }>('/logout', { method: 'POST' }),
listEntries: (q = '') =>
request<EntrySummary[]>(`/entries${q ? `?q=${encodeURIComponent(q)}` : ''}`),
listSpaces: () => request<NodeSummary[]>('/spaces'),
getEntry: (id: number) => request<Entry>(`/entries/${id}`),
createSpace: (name: string, has_board = true) =>
request<WorkspaceNode>('/spaces', { method: 'POST', body: body({ name, has_board }) }),
createEntry: (input: { title: string; body: string; created_at?: number }) =>
request<Entry>('/entries', { method: 'POST', body: body(input) }),
/** The children of a node, or — with `q` — a search across the whole tree. */
listNodes: (params: { parent?: number; q?: string; space?: number }) => {
const query = new URLSearchParams()
if (params.q) query.set('q', params.q)
else if (params.parent !== undefined) query.set('parent', String(params.parent))
if (params.q && params.space !== undefined) query.set('space', String(params.space))
return request<NodeSummary[]>(`/nodes?${query}`)
},
updateEntry: (id: number, input: { title: string; body: string; created_at?: number }) =>
request<Entry>(`/entries/${id}`, { method: 'PUT', body: body(input) }),
getNode: (id: number) => request<WorkspaceNode>(`/nodes/${id}`),
deleteEntry: (id: number) => request<{ ok: true }>(`/entries/${id}`, { method: 'DELETE' }),
/** `work/einarbeiten/01-was-ist-eebus` — the node behind a `/n/...` address. */
resolvePath: (path: string) => request<WorkspaceNode>(`/resolve?path=${encodeURIComponent(path)}`),
/** Publishes the entry, or mints a fresh key for one already published. */
/**
* Upload a folder into a node. The relative path is passed as each part's
* file name, which is the only place a multipart body has to put it, and is
* what the server rebuilds the directory tree from.
*/
importFolder: (id: number, files: File[]) => {
const form = new FormData()
for (const file of files) form.append('file', file, file.webkitRelativePath || file.name)
return request<ImportReport>(`/nodes/${id}/import`, { method: 'POST', body: form })
},
createNode: (input: {
parent_id: number
kind?: NodeKind
name?: string
body?: string
created_at?: number
}) => request<WorkspaceNode>('/nodes', { method: 'POST', body: body(input) }),
updateNode: (
id: number,
input: { name: string; body: string; created_at?: number; has_board?: boolean },
) => request<WorkspaceNode>(`/nodes/${id}`, { method: 'PUT', body: body(input) }),
deleteNode: (id: number) =>
request<{ ok: true; removed: number }>(`/nodes/${id}`, { method: 'DELETE' }),
moveNode: (id: number, parent_id: number, position?: number) =>
request<WorkspaceNode>(`/nodes/${id}/move`, { method: 'POST', body: body({ parent_id, position }) }),
/** Publishes the document, or mints a fresh key for one already published. */
share: (id: number) =>
request<{ token: string; key: string; path: string }>(`/entries/${id}/share`, { method: 'POST' }),
request<{ token: string; key: string; path: string }>(`/nodes/${id}/share`, { method: 'POST' }),
unshare: (id: number) => request<{ ok: true }>(`/entries/${id}/share`, { method: 'DELETE' }),
unshare: (id: number) => request<{ ok: true }>(`/nodes/${id}/share`, { method: 'DELETE' }),
/** The board of a space. Fails for a space that has none, such as the diary. */
board: (spaceId: number) => request<Board>(`/spaces/${spaceId}/board`),
createList: (spaceId: number, name: string) =>
request<Board>(`/spaces/${spaceId}/lists`, { method: 'POST', body: body({ name }) }),
renameList: (listId: number, name: string) =>
request<Board>(`/lists/${listId}`, { method: 'PUT', body: body({ name }) }),
deleteList: (listId: number) => request<Board>(`/lists/${listId}`, { method: 'DELETE' }),
createCard: (
listId: number,
input: { title: string; body?: string; node_id?: number | null; due_at?: number | null },
) => request<Card>(`/lists/${listId}/cards`, { method: 'POST', body: body(input) }),
/*
* Only what changed is sent. `due_at: null` clears the date while leaving it
* out keeps it, which is why the server reads those fields as double options.
*/
updateCard: (
id: number,
patch: {
title?: string
body?: string
node_id?: number | null
due_at?: number | null
done?: boolean
},
) => request<Card>(`/cards/${id}`, { method: 'PUT', body: body(patch) }),
deleteCard: (id: number) => request<{ ok: true }>(`/cards/${id}`, { method: 'DELETE' }),
moveCard: (id: number, list_id: number, position?: number) =>
request<Card>(`/cards/${id}/move`, { method: 'POST', body: body({ list_id, position }) }),
listMedia: () => request<MediaFile[]>('/media'),
@@ -126,5 +247,5 @@ export const api = {
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
readShared: (token: string, key: string) =>
request<SharedEntry>(`/share/${encodeURIComponent(token)}/${encodeURIComponent(key)}`),
request<SharedDocument>(`/share/${encodeURIComponent(token)}/${encodeURIComponent(key)}`),
}
+364 -156
View File
@@ -1,10 +1,12 @@
import { api, type BackupStatus } from './api'
import { diary, type Theme } from './store.svelte'
import { workspace, type Theme } from './store.svelte'
import { formatDay, parseDay } from './util'
/** Filled in by App.svelte so ex-commands can reach the DOM-bound bits. */
export const hooks = {
pickFiles: () => {},
pickFolder: () => {},
pickArchive: () => {},
focusEditor: () => {},
focusList: () => {},
insertText: (_text: string) => {},
@@ -12,7 +14,7 @@ export const hooks = {
}
/** Help groups the command list under these headings, in this order. */
export const groups = ['entries', 'editing', 'sharing', 'view'] as const
export const groups = ['tree', 'board', 'documents', 'editing', 'sharing', 'view'] as const
export type Group = (typeof groups)[number]
export interface CommandSpec {
@@ -27,28 +29,37 @@ export interface CommandSpec {
}
const requireOpen = () => {
if (!diary.open) {
diary.say('no entry open', 'error')
if (!workspace.open) {
workspace.say('no document open', 'error')
return null
}
return diary.open
return workspace.open
}
/** Move the cursor and follow it, so `:next` reads as well as `j` then `Enter`. */
const jumpTo = async (index: number) => {
if (diary.entries.length === 0) return diary.say('no entries', 'error')
diary.cursor = Math.min(Math.max(index, 0), diary.entries.length - 1)
await diary.guard(() => diary.openSelected())
if (workspace.nodes.length === 0) return workspace.say('nothing here', 'error')
workspace.cursor = Math.min(Math.max(index, 0), workspace.nodes.length - 1)
await workspace.guard(() => workspace.openSelected())
}
/** A folder among the children currently listed, by slug or by name. */
const findFolder = (target: string) => {
const wanted = target.toLowerCase()
return workspace.nodes.find(
(node) =>
node.kind !== 'document' && (node.slug === wanted || node.name.toLowerCase() === wanted),
)
}
const themes: Theme[] = ['mocha', 'green', 'amber', 'ice']
const setTheme = (name: string) => {
if (!themes.includes(name as Theme)) {
return diary.say(`unknown theme: ${name} — try ${themes.join(', ')}`, 'error')
return workspace.say(`unknown theme: ${name} — try ${themes.join(', ')}`, 'error')
}
diary.setTheme(name as Theme)
diary.say(`theme ${name}`)
workspace.setTheme(name as Theme)
workspace.say(`theme ${name}`)
}
/** `3 minutes ago`, or `never`. Backups are read at a glance, not to the second. */
@@ -63,14 +74,14 @@ const ago = (at: number | null) => {
const describeBackup = (status: BackupStatus) => {
if (!status.configured) {
return diary.say('proton drive backups are off — run `narl-diary proton-login` on the server', 'error')
return workspace.say('proton drive backups are off — run `narl-workspace proton-login` on the server', 'error')
}
if (status.last_error) {
return diary.say(`backup failed: ${status.last_error}`, 'error')
return workspace.say(`backup failed: ${status.last_error}`, 'error')
}
const where = status.device ? `${status.device} · ` : ''
const state = status.running ? 'running now' : status.pending ? 'changes waiting' : 'up to date'
diary.say(`${where}${state} · last ${ago(status.last_success_at)}`)
workspace.say(`${where}${state} · last ${ago(status.last_success_at)}`)
}
/** A filename that survives a download folder: `2026-09-05-first-light.md`. */
@@ -84,9 +95,9 @@ export const commands: CommandSpec[] = [
name: 'write',
aliases: ['w'],
group: 'editing',
help: 'save the open entry',
run: () => diary.guard(() => diary.save()),
bang: { help: 'same as :w — the bang is accepted out of habit', run: () => diary.guard(() => diary.save()) },
help: 'save the open document',
run: () => workspace.guard(() => workspace.save()),
bang: { help: 'same as :w — the bang is accepted out of habit', run: () => workspace.guard(() => workspace.save()) },
},
{
name: 'wq',
@@ -94,15 +105,15 @@ export const commands: CommandSpec[] = [
group: 'editing',
help: 'save and leave insert/editor mode',
run: async () => {
await diary.guard(() => diary.save())
diary.editing = false
await workspace.guard(() => workspace.save())
workspace.editing = false
hooks.focusList()
},
bang: {
help: 'same as :wq — the bang is accepted out of habit',
run: async () => {
await diary.guard(() => diary.save())
diary.editing = false
await workspace.guard(() => workspace.save())
workspace.editing = false
hooks.focusList()
},
},
@@ -111,48 +122,212 @@ export const commands: CommandSpec[] = [
name: 'quit',
aliases: ['q'],
group: 'editing',
help: 'close the editor, or the entry when already reading',
help: 'close the editor, or the document when already reading',
run: () => {
if (diary.dirty) {
diary.say('unsaved changes — :w to write, :q! to discard', 'error')
if (workspace.dirty) {
workspace.say('unsaved changes — :w to write, :q! to discard', 'error')
return
}
if (diary.editing) diary.editing = false
if (workspace.editing) workspace.editing = false
else {
diary.open = null
diary.pane = 'list'
workspace.open = null
workspace.pane = 'list'
}
hooks.focusList()
},
bang: {
help: 'discard changes and close the editor',
run: async () => {
const open = diary.open
diary.editing = false
diary.dirty = false
const open = workspace.open
workspace.editing = false
workspace.dirty = false
if (open) {
diary.dropStash(open.id)
await diary.guard(() => diary.openEntry(open.id))
workspace.dropStash(open.id)
await workspace.guard(() => workspace.openNode(open.id))
}
hooks.focusList()
},
},
},
{
name: 'space',
aliases: ['sp'],
args: '[name]',
group: 'tree',
help: 'switch to a space, or list them',
run: async (arg) => {
const wanted = arg.trim().toLowerCase()
if (!wanted) {
return workspace.say(workspace.spaces.map((space) => space.slug).join(' · ') || 'no spaces')
}
const space = workspace.spaces.find(
(candidate) => candidate.slug === wanted || candidate.name.toLowerCase() === wanted,
)
if (!space) return workspace.say(`no such space: ${wanted} — :space! ${wanted} makes it`, 'error')
await workspace.guard(() => workspace.enterSpace(space.id))
},
bang: {
help: 'create a space with that name and switch to it',
run: async (arg) => {
if (!arg.trim()) return workspace.say('usage: :space! <name>', 'error')
await workspace.guard(() => workspace.createSpace(arg.trim()))
},
},
},
{
name: 'mkdir',
args: '<name>',
group: 'tree',
help: 'make a folder here',
run: async (arg) => {
if (!arg.trim()) return workspace.say('usage: :mkdir <name>', 'error')
await workspace.guard(() => workspace.createFolder(arg.trim()))
},
},
{
name: 'cd',
args: '<name|..|/>',
group: 'tree',
help: 'enter a folder, .. goes up, / back to the space',
run: async (arg) => {
const target = arg.trim()
await workspace.guard(async () => {
if (!target || target === '..') return workspace.up()
if (target === '/') {
const space = workspace.spaceId
return space === null ? undefined : workspace.goTo(space)
}
const folder = findFolder(target)
if (!folder) return workspace.say(`no such folder here: ${target}`, 'error')
await workspace.goTo(folder.id)
})
},
},
{
name: 'import',
aliases: ['imp'],
group: 'tree',
help: 'upload a folder into this one — markdown becomes documents, links follow',
run: () => hooks.pickFolder(),
bang: {
help: 'import a .zip of a folder instead of the folder itself',
run: () => hooks.pickArchive(),
},
},
{
name: 'board',
aliases: ['b'],
group: 'board',
help: "show this space's board, or go back to the tree",
run: () => workspace.guard(() => workspace.toggleBoardView()),
},
{
name: 'card',
aliases: ['c'],
args: '<title>',
group: 'board',
help: 'add a card to the list under the cursor',
run: async (arg) => {
if (!arg.trim()) return workspace.say('usage: :card <title>', 'error')
if (workspace.view !== 'board') await workspace.guard(() => workspace.openBoard())
if (workspace.view !== 'board') return
await workspace.guard(() => workspace.createCard(arg.trim()))
},
bang: {
help: 'add a card linked to the open document — Enter on it opens the document',
run: async (arg) => {
const node = workspace.open ?? workspace.selected
if (!node || node.kind !== 'document') {
return workspace.say('no document to link — open one first', 'error')
}
const title = arg.trim() || node.name.trim() || node.slug
if (workspace.view !== 'board') await workspace.guard(() => workspace.openBoard())
if (workspace.view !== 'board') return
await workspace.guard(() => workspace.createCard(title, node.id))
},
},
},
{
name: 'due',
args: '<yyyy-mm-dd|->',
group: 'board',
help: 'set the due date of the selected card, - clears it',
run: async (arg) => {
if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error')
const target = arg.trim()
if (target === '-' || target === '') {
return workspace.guard(() => workspace.patchCard({ due_at: null }))
}
const at = parseDay(target)
if (at === null) return workspace.say('usage: :due 2026-09-30', 'error')
await workspace.guard(() => workspace.patchCard({ due_at: at }))
workspace.say(`due ${formatDay(at)}`)
},
},
{
name: 'done',
group: 'board',
help: 'tick the selected card off, or un-tick it',
run: () => {
if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error')
return workspace.guard(() => workspace.toggleDone())
},
},
{
name: 'list',
args: '<name>',
group: 'board',
help: 'add a column to the board',
run: async (arg) => {
if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error')
if (!arg.trim()) return workspace.say('usage: :list <name>', 'error')
await workspace.guard(() => workspace.createList(arg.trim()))
},
bang: {
help: 'delete the column under the cursor and every card on it',
run: async () => {
if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error')
const list = workspace.list
if (!list) return workspace.say('no list selected', 'error')
if (!confirm(`delete the list "${list.name}" and its ${list.cards.length} cards?`)) return
await workspace.guard(() => workspace.deleteList())
},
},
},
{
name: 'move',
aliases: ['mv'],
args: '<folder|..>',
group: 'tree',
help: 'move the selected node into a folder here, or up one level',
run: async (arg) => {
const node = workspace.open ?? workspace.selected
if (!node) return workspace.say('nothing selected', 'error')
const target = arg.trim()
if (!target) return workspace.say('usage: :mv <folder|..>', 'error')
const destination =
target === '..' ? workspace.path.at(-2)?.id : findFolder(target)?.id
if (destination === undefined) return workspace.say(`no such folder: ${target}`, 'error')
if (destination === node.id) return workspace.say('a folder cannot hold itself', 'error')
await workspace.guard(() => workspace.moveNode(node.id, destination))
},
},
{
name: 'new',
aliases: ['n', 'o'],
args: '[yyyy-mm-dd]',
group: 'entries',
help: 'start a new entry, optionally dated',
group: 'documents',
help: 'start a new document here, optionally dated',
run: async (arg) => {
await diary.guard(async () => {
await diary.createEntry()
await workspace.guard(async () => {
await workspace.createDocument()
if (arg) {
const at = parseDay(arg)
if (at === null) return diary.say(`not a date: ${arg}`, 'error')
diary.draft.created_at = at
diary.dirty = true
await diary.save()
if (at === null) return workspace.say(`not a date: ${arg}`, 'error')
workspace.draft.created_at = at
workspace.dirty = true
await workspace.save()
}
})
hooks.focusEditor()
@@ -161,17 +336,25 @@ export const commands: CommandSpec[] = [
{
name: 'today',
aliases: ['t'],
group: 'entries',
help: "open today's entry, starting one if there is none yet",
group: 'documents',
help: "open today's diary entry, starting one if there is none yet",
run: async () => {
await diary.guard(async () => {
if (diary.query) await diary.search('')
await workspace.guard(async () => {
// The diary is one space among several now, so :today goes there first
// rather than writing the day into whatever folder was being browsed.
const home = workspace.spaces.find((space) => space.slug === 'diary')
if (home && workspace.parentId !== home.id) await workspace.enterSpace(home.id)
else if (workspace.query) await workspace.search('')
const midnight = new Date()
midnight.setHours(0, 0, 0, 0)
const from = Math.floor(midnight.getTime() / 1000)
const entry = diary.entries.find((e) => e.created_at >= from && e.created_at < from + 86400)
if (entry) await diary.openEntry(entry.id, true)
else await diary.createEntry()
const today = workspace.nodes.find(
(node) =>
node.kind === 'document' && node.created_at >= from && node.created_at < from + 86400,
)
if (today) await workspace.openNode(today.id, true)
else await workspace.createDocument()
})
hooks.focusEditor()
},
@@ -181,106 +364,123 @@ export const commands: CommandSpec[] = [
aliases: ['e'],
args: '[id]',
group: 'editing',
help: 'edit the open entry, or open an entry by id',
help: 'edit the open document, or open a node by id',
run: async (arg) => {
const id = arg ? Number(arg) : diary.open?.id ?? diary.selected?.id
if (!id || Number.isNaN(id)) return diary.say('usage: :e <id>', 'error')
await diary.guard(() => diary.openEntry(id, true))
const id = arg ? Number(arg) : workspace.open?.id ?? workspace.selected?.id
if (!id || Number.isNaN(id)) return workspace.say('usage: :e <id>', 'error')
await workspace.guard(() => workspace.openNode(id, true))
hooks.focusEditor()
},
bang: {
help: 'throw away the draft and re-read the entry from the server',
help: 'throw away the draft and re-read the document from the server',
run: async () => {
const open = requireOpen()
if (!open) return
diary.dirty = false
diary.dropStash(open.id)
await diary.guard(() => diary.openEntry(open.id, diary.editing))
diary.say(`entry:${open.id} reloaded`)
workspace.dirty = false
workspace.dropStash(open.id)
await workspace.guard(() => workspace.openNode(open.id, workspace.editing))
workspace.say(`${open.slug} reloaded`)
},
},
},
{
name: 'next',
aliases: ['bn'],
group: 'entries',
help: 'open the next entry down the list',
run: () => jumpTo(diary.cursor + 1),
group: 'documents',
help: 'open the next node down the list',
run: () => jumpTo(workspace.cursor + 1),
},
{
name: 'prev',
aliases: ['bp'],
group: 'entries',
help: 'open the previous entry',
run: () => jumpTo(diary.cursor - 1),
group: 'documents',
help: 'open the previous node',
run: () => jumpTo(workspace.cursor - 1),
},
{
name: 'first',
group: 'entries',
help: 'open the newest entry',
group: 'documents',
help: 'open the first node in the list',
run: () => jumpTo(0),
},
{
name: 'last',
group: 'entries',
help: 'open the oldest entry',
run: () => jumpTo(diary.entries.length - 1),
group: 'documents',
help: 'open the last node in the list',
run: () => jumpTo(workspace.nodes.length - 1),
},
{
name: 'random',
group: 'entries',
help: 'open an entry at random — good for re-reading',
run: () => jumpTo(Math.floor(Math.random() * diary.entries.length)),
group: 'documents',
help: 'open something here at random — good for re-reading',
run: () => jumpTo(Math.floor(Math.random() * workspace.nodes.length)),
},
{
name: 'delete',
aliases: ['d', 'rm'],
args: '[id]',
group: 'entries',
help: 'delete an entry (asks first)',
group: 'documents',
help: 'delete a node, and everything under it (asks first)',
run: async (arg) => {
const id = arg ? Number(arg) : diary.open?.id ?? diary.selected?.id
if (!id || Number.isNaN(id)) return diary.say('nothing to delete', 'error')
if (!confirm(`delete entry #${id}? this cannot be undone.`)) return
await diary.guard(() => diary.deleteEntry(id))
// On the board the thing under the cursor is a card, not a node.
if (workspace.view === 'board') {
const card = workspace.card
if (!card) return workspace.say('no card selected', 'error')
if (!confirm(`delete the card "${card.title}"?`)) return
return workspace.guard(() => workspace.deleteCard())
}
const id = arg ? Number(arg) : workspace.open?.id ?? workspace.selected?.id
if (!id || Number.isNaN(id)) return workspace.say('nothing to delete', 'error')
const node = workspace.open?.id === id ? workspace.open : workspace.nodes.find((n) => n.id === id)
const what =
node && node.kind !== 'document'
? `delete ${node.kind} "${node.name}" and everything in it?`
: `delete #${id}?`
if (!confirm(`${what} this cannot be undone.`)) return
await workspace.guard(() => workspace.deleteNode(id))
},
},
{
name: 'title',
name: 'name',
aliases: ['title'],
args: '<text>',
group: 'editing',
help: 'set the title of the open entry',
help: 'rename the open document, or the selected card on a board',
run: async (arg) => {
if (workspace.view === 'board') {
if (!arg.trim()) return workspace.say('usage: :name <text>', 'error')
return workspace.guard(() => workspace.patchCard({ title: arg.trim() }))
}
if (!requireOpen()) return
diary.draft.title = arg
diary.dirty = true
await diary.guard(() => diary.save())
workspace.draft.name = arg
workspace.dirty = true
await workspace.guard(() => workspace.save())
},
},
{
name: 'date',
args: '<yyyy-mm-dd>',
group: 'editing',
help: 're-date the open entry',
help: 're-date the open document',
run: async (arg) => {
if (!requireOpen()) return
const at = parseDay(arg)
if (at === null) return diary.say('usage: :date 2026-09-05', 'error')
diary.draft.created_at = at
diary.dirty = true
await diary.guard(() => diary.save())
if (at === null) return workspace.say('usage: :date 2026-09-05', 'error')
workspace.draft.created_at = at
workspace.dirty = true
await workspace.guard(() => workspace.save())
},
},
{
name: 'share',
group: 'sharing',
help: 'publish the entry behind an unguessable link and copy it',
help: 'publish the document behind an unguessable link and copy it',
run: async () => {
const id = diary.open?.id ?? diary.selected?.id
if (!id) return diary.say('no entry selected', 'error')
const entry = diary.open?.id === id ? diary.open : diary.selected
if (entry?.shared) return diary.say('already shared — :link for a new link, :unshare to revoke')
await diary.guard(() => diary.toggleShare(id))
const id = workspace.open?.id ?? workspace.selected?.id
if (!id) return workspace.say('nothing selected', 'error')
const node = workspace.open?.id === id ? workspace.open : workspace.selected
if (node?.shared) return workspace.say('already shared — :link for a new link, :unshare to revoke')
await workspace.guard(() => workspace.toggleShare(id))
},
},
{
@@ -288,25 +488,25 @@ export const commands: CommandSpec[] = [
group: 'sharing',
help: 'revoke the share link',
run: async () => {
const id = diary.open?.id ?? diary.selected?.id
if (!id) return diary.say('no entry selected', 'error')
const entry = diary.open?.id === id ? diary.open : diary.selected
if (!entry?.shared) return diary.say('entry is not shared')
await diary.guard(() => diary.toggleShare(id))
const id = workspace.open?.id ?? workspace.selected?.id
if (!id) return workspace.say('nothing selected', 'error')
const node = workspace.open?.id === id ? workspace.open : workspace.selected
if (!node?.shared) return workspace.say('not shared')
await workspace.guard(() => workspace.toggleShare(id))
},
},
{
name: 'link',
group: 'sharing',
help: 'mint a new share link for the entry and copy it',
help: 'mint a new share link for the document and copy it',
run: async () => {
const id = diary.open?.id ?? diary.selected?.id
if (!id) return diary.say('no entry selected', 'error')
const entry = diary.open?.id === id ? diary.open : diary.selected
if (!entry?.shared) return diary.say('entry is not shared — :share first', 'error')
await diary.guard(async () => {
const url = await diary.mintShareLink(id)
diary.say(`new link → ${url} (copied) — the previous one is dead`)
const id = workspace.open?.id ?? workspace.selected?.id
if (!id) return workspace.say('nothing selected', 'error')
const node = workspace.open?.id === id ? workspace.open : workspace.selected
if (!node?.shared) return workspace.say('not shared — :share first', 'error')
await workspace.guard(async () => {
const url = await workspace.mintShareLink(id)
workspace.say(`new link → ${url} (copied) — the previous one is dead`)
})
},
},
@@ -314,38 +514,38 @@ export const commands: CommandSpec[] = [
name: 'copy',
aliases: ['yank'],
group: 'sharing',
help: 'copy the whole entry to the clipboard as markdown',
help: 'copy the whole document to the clipboard as markdown',
run: async () => {
if (!requireOpen()) return
const { title, body } = diary.draft
const text = title.trim() ? `# ${title.trim()}\n\n${body}` : body
diary.say((await diary.copy(text)) ? 'entry copied' : 'the clipboard said no', 'info')
const { name, body } = workspace.draft
const text = name.trim() ? `# ${name.trim()}\n\n${body}` : body
workspace.say((await workspace.copy(text)) ? 'document copied' : 'the clipboard said no', 'info')
},
},
{
name: 'export',
aliases: ['exp'],
group: 'sharing',
help: 'download the open entry as a .md file',
help: 'download the open document as a .md file',
run: () => {
if (!requireOpen()) return
const { title, body, created_at } = diary.draft
const text = title.trim() ? `# ${title.trim()}\n\n${body}` : body
const { name, body, created_at } = workspace.draft
const text = name.trim() ? `# ${name.trim()}\n\n${body}` : body
const url = URL.createObjectURL(new Blob([text], { type: 'text/markdown' }))
const anchor = document.createElement('a')
anchor.href = url
anchor.download = exportName(title.trim(), created_at)
anchor.download = exportName(name.trim(), created_at)
anchor.click()
URL.revokeObjectURL(url)
diary.say(`wrote ${anchor.download}`)
workspace.say(`wrote ${anchor.download}`)
},
bang: {
help: 'download the whole diary — every entry and every file — as a zip',
help: 'download the whole workspace — every space, document and file — as a zip',
run: async () => {
// A plain navigation, so the browser streams the archive straight to
// disk instead of the tab holding all of it in memory first.
await diary.flush()
diary.say('building the archive — the download starts on its own')
await workspace.flush()
workspace.say('building the archive — the download starts on its own')
location.href = '/api/export'
},
},
@@ -361,46 +561,50 @@ export const commands: CommandSpec[] = [
name: 'media',
group: 'editing',
help: 'browse everything you have uploaded',
run: () => diary.guard(async () => {
await diary.loadMedia()
diary.overlay = 'media'
run: () => workspace.guard(async () => {
await workspace.loadMedia()
workspace.overlay = 'media'
}),
},
{
name: 'search',
aliases: ['se'],
args: '[text]',
group: 'entries',
group: 'documents',
help: 'full-text search (empty clears)',
run: (arg) => diary.guard(() => diary.search(arg)),
run: (arg) => workspace.guard(() => workspace.search(arg)),
},
{
name: 'clear',
aliases: ['noh'],
group: 'entries',
group: 'documents',
help: 'clear the search filter',
run: () => diary.guard(() => diary.search('')),
run: () => workspace.guard(() => workspace.search('')),
},
{
name: 'reload',
aliases: ['r'],
group: 'entries',
help: 're-read the entry list from the server',
group: 'documents',
help: 're-read the current folder from the server',
run: () =>
diary.guard(async () => {
await diary.refresh()
diary.say(`${diary.entries.length} entries`)
workspace.guard(async () => {
await workspace.refresh()
workspace.say(`${workspace.nodes.length} here`)
}),
},
{
name: 'stats',
group: 'view',
help: 'word, line and entry counts',
help: 'word, line and node counts',
run: () => {
const body = diary.open ? diary.draft.body : ''
const body = workspace.open ? workspace.draft.body : ''
const words = body.split(/\s+/).filter(Boolean).length
const here = diary.open ? `entry:${diary.open.id} ${words}w ${body.split('\n').length}L · ` : ''
diary.say(`${here}${diary.entries.length} entries${diary.query ? ` matching "${diary.query}"` : ''}`)
const open = workspace.open ? `${workspace.open.slug} ${words}w ${body.split('\n').length}L · ` : ''
workspace.say(
`${open}${workspace.here || '/'} · ${workspace.nodes.length} node${workspace.nodes.length === 1 ? '' : 's'}${
workspace.query ? ` matching "${workspace.query}"` : ''
}`,
)
},
},
{
@@ -408,46 +612,50 @@ export const commands: CommandSpec[] = [
args: '<mocha|green|amber|ice>',
group: 'view',
help: 'switch the colour scheme',
run: (arg) => setTheme(arg.trim() || diary.theme),
run: (arg) => setTheme(arg.trim() || workspace.theme),
},
{
name: 'set',
args: '<option>',
group: 'view',
help: 'theme=mocha|green|amber|ice, vim, novim, clipboard, noclipboard',
help: 'theme=mocha|green|amber|ice, vim, novim, clipboard, noclipboard, board, noboard',
run: (arg) => {
const option = arg.trim()
if (option === 'vim') return diary.setVim(true), diary.say('vim keys on')
if (option === 'novim') return diary.setVim(false), diary.say('vim keys off')
if (option === 'vim') return workspace.setVim(true), workspace.say('vim keys on')
if (option === 'novim') return workspace.setVim(false), workspace.say('vim keys off')
if (option === 'clipboard')
return diary.setClipboard(true), diary.say('yanks go to the system clipboard')
return workspace.setClipboard(true), workspace.say('yanks go to the system clipboard')
if (option === 'noclipboard')
return diary.setClipboard(false), diary.say('yanks stay in vim registers')
return workspace.setClipboard(false), workspace.say('yanks stay in vim registers')
// A board is a property of the space, not of this browser, so unlike the
// options above this one goes to the server.
if (option === 'board') return workspace.guard(() => workspace.setBoard(true))
if (option === 'noboard') return workspace.guard(() => workspace.setBoard(false))
const theme = /^theme=(\w+)$/.exec(option)?.[1]
if (theme) return setTheme(theme)
diary.say(`unknown option: ${option}`, 'error')
workspace.say(`unknown option: ${option}`, 'error')
},
},
{
name: 'backup',
group: 'view',
help: 'when the diary was last mirrored to proton drive',
run: () => diary.guard(async () => describeBackup(await api.backupStatus())),
help: 'when the workspace was last mirrored to proton drive',
run: () => workspace.guard(async () => describeBackup(await api.backupStatus())),
bang: {
help: 'back up to proton drive now, rather than when the diary falls quiet',
help: 'back up to proton drive now, rather than when the workspace falls quiet',
run: () =>
diary.guard(async () => {
workspace.guard(async () => {
// Whatever is in the editor belongs in the backup that was just
// asked for, so it goes to the server before the mirror runs.
await diary.flush()
diary.say('backing up …')
await workspace.flush()
workspace.say('backing up …')
const status = await api.backupNow()
if (status.last_error) return diary.say(`backup failed: ${status.last_error}`, 'error')
if (status.last_error) return workspace.say(`backup failed: ${status.last_error}`, 'error')
const last = status.last
diary.say(
workspace.say(
last
? `backed up · ${last.uploaded} file${last.uploaded === 1 ? '' : 's'} uploaded, ${last.skipped} unchanged`
: 'proton drive backups are off — run `narl-diary proton-login` on the server',
: 'proton drive backups are off — run `narl-workspace proton-login` on the server',
last ? 'info' : 'error',
)
}),
@@ -459,14 +667,14 @@ export const commands: CommandSpec[] = [
group: 'view',
help: 'show the key and command reference',
run: () => {
diary.overlay = diary.overlay === 'help' ? 'none' : 'help'
workspace.overlay = workspace.overlay === 'help' ? 'none' : 'help'
},
},
{
name: 'logout',
group: 'view',
help: 'end the session',
run: () => diary.guard(() => diary.logout()),
run: () => workspace.guard(() => workspace.logout()),
},
]
@@ -482,16 +690,16 @@ export async function runCommand(line: string): Promise<void> {
if (!input) return
if (input.startsWith('/')) {
await diary.guard(() => diary.search(input.slice(1)))
await workspace.guard(() => workspace.search(input.slice(1)))
return
}
const [head, ...rest] = input.split(/\s+/)
const arg = rest.join(' ')
// `:12` jumps to an entry by id, the way `:12` jumps to a line in vim.
// `:12` jumps to a node by id, the way `:12` jumps to a line in vim.
if (/^\d+$/.test(head)) {
await diary.guard(() => diary.openEntry(Number(head)))
await workspace.guard(() => workspace.openNode(Number(head)))
return
}
@@ -499,11 +707,11 @@ export async function runCommand(line: string): Promise<void> {
const forced = head.endsWith('!')
const spec = lookup.get(forced ? head.slice(0, -1) : head)
if (!spec) {
diary.say(`E492: not an editor command: ${head}`, 'error')
workspace.say(`E492: not an editor command: ${head}`, 'error')
return
}
if (forced) {
if (!spec.bang) return diary.say(`E477: no ! allowed: ${head}`, 'error')
if (!spec.bang) return workspace.say(`E477: no ! allowed: ${head}`, 'error')
await spec.bang.run(arg)
return
}
+416 -52
View File
@@ -1,6 +1,17 @@
import { api, ApiError, type Entry, type EntrySummary, type MediaFile } from './api'
import {
api,
ApiError,
type Board,
type Card,
type Crumb,
type WorkspaceNode,
type MediaFile,
type NodeSummary,
} from './api'
export type Pane = 'list' | 'entry'
export type Pane = 'list' | 'document'
/** The tree of documents, or the board of the space being browsed. */
export type View = 'tree' | 'board'
export type Flash = { text: string; kind: 'info' | 'error' } | null
export type Theme = 'mocha' | 'green' | 'amber' | 'ice'
@@ -8,21 +19,36 @@ export type Theme = 'mocha' | 'green' | 'amber' | 'ice'
* The whole application state. It is deliberately one object: the keyboard
* layer needs to see everything at once to decide what a key means.
*/
class Diary {
class Workspace {
user = $state<string | null>(null)
booting = $state(true)
entries = $state<EntrySummary[]>([])
/** The top level: diary, work, uni, whatever else gets made. */
spaces = $state<NodeSummary[]>([])
/** The space being browsed, and the folder inside it the list is showing. */
spaceId = $state<number | null>(null)
parentId = $state<number | null>(null)
/** Ancestors of the current folder, the space first and the folder last. */
path = $state<Crumb[]>([])
/** Children of the current folder, or the results of a search. */
nodes = $state<NodeSummary[]>([])
cursor = $state(0)
query = $state('')
open = $state<Entry | null>(null)
draft = $state({ title: '', body: '', created_at: 0 })
open = $state<WorkspaceNode | null>(null)
draft = $state({ name: '', body: '', created_at: 0 })
editing = $state(false)
/** Set when the editor should open in insert mode rather than normal mode. */
enterInsert = $state(false)
dirty = $state(false)
/** The board of the current space, loaded only while it is being looked at. */
view = $state<View>('tree')
board = $state<Board | null>(null)
listCursor = $state(0)
cardCursor = $state(0)
pane = $state<Pane>('list')
vimMode = $state('normal')
flash = $state<Flash>(null)
@@ -40,8 +66,25 @@ class Diary {
/** Pending debounced write, if the draft has changed since the last save. */
#autosave: ReturnType<typeof setTimeout> | null = null
get selected(): EntrySummary | undefined {
return this.entries[this.cursor]
get selected(): NodeSummary | undefined {
return this.nodes[this.cursor]
}
get space(): NodeSummary | undefined {
return this.spaces.find((s) => s.id === this.spaceId)
}
/** `work/einarbeiten` — what the sidebar and the status line show. */
get here(): string {
return this.path.map((crumb) => crumb.slug).join('/')
}
get list() {
return this.board?.lists[this.listCursor]
}
get card(): Card | undefined {
return this.list?.cards[this.cardCursor]
}
say(text: string, kind: 'info' | 'error' = 'info') {
@@ -71,7 +114,7 @@ class Diary {
this.dirty = true
this.stash()
if (this.#autosave) clearTimeout(this.#autosave)
this.#autosave = setTimeout(() => void this.#writeBack(), Diary.AUTOSAVE_MS)
this.#autosave = setTimeout(() => void this.#writeBack(), Workspace.AUTOSAVE_MS)
}
stash() {
@@ -94,7 +137,10 @@ class Diary {
#readStash(id: number): typeof this.draft | null {
try {
const raw = localStorage.getItem(this.#stashKey(id))
return raw ? JSON.parse(raw) : null
if (!raw) return null
const stashed = JSON.parse(raw)
// Drafts stashed before documents had names carry a title instead.
return { name: stashed.name ?? stashed.title ?? '', body: stashed.body, created_at: stashed.created_at }
} catch {
return null
}
@@ -108,7 +154,7 @@ class Diary {
/**
* Write out a pending draft before doing something that would replace it.
* Without this, clicking another entry in the list discarded unsaved work
* Without this, clicking another document in the list discarded unsaved work
* without a word.
*/
async flush() {
@@ -123,7 +169,7 @@ class Diary {
try {
const { user } = await api.me()
this.user = user
await this.refresh()
await this.loadSpaces(Workspace.addressedPath())
} catch {
this.user = null
} finally {
@@ -134,22 +180,112 @@ class Diary {
async login(username: string, password: string) {
const { user } = await api.login(username, password)
this.user = user
await this.refresh()
await this.loadSpaces(Workspace.addressedPath())
}
async logout() {
await api.logout()
this.user = null
this.entries = []
this.spaces = []
this.nodes = []
this.open = null
this.editing = false
}
/*
* Navigation. The list pane always shows one folder's children, so browsing
* is: pick a space, descend into folders, come back up. A search steps
* outside that and shows matches from anywhere in the current space.
*/
/** The slug path in the address bar, if the page was opened on a `/n/...` URL. */
static addressedPath(): string | null {
return location.pathname.startsWith('/n/')
? decodeURIComponent(location.pathname.slice(3)).replace(/\/$/, '')
: null
}
/** Keep the address bar on the node being looked at, so it can be linked to. */
#syncUrl(path: Crumb[]) {
const slugs = path.map((crumb) => crumb.slug).join('/')
if (slugs) history.replaceState(null, '', `/n/${slugs}`)
}
/** Open whatever `work/einarbeiten/01-was-ist-eebus` names. */
async openPath(path: string) {
const node = await api.resolvePath(path)
if (node.kind === 'document') {
if (node.parent_id !== null) await this.goTo(node.parent_id)
await this.openNode(node.id)
} else {
await this.goTo(node.id)
}
}
async loadSpaces(startPath: string | null = null) {
this.spaces = await api.listSpaces()
if (startPath) {
try {
await this.openPath(startPath)
return
} catch {
// A stale or hand-typed address should not leave the app on nothing.
this.say(`no such path: ${startPath}`, 'error')
}
}
const remembered = Number(localStorage.getItem('diary:space'))
const start =
this.spaces.find((s) => s.id === remembered) ??
this.spaces.find((s) => s.slug === 'diary') ??
this.spaces[0]
if (start) await this.enterSpace(start.id)
}
async enterSpace(id: number) {
this.spaceId = id
localStorage.setItem('diary:space', String(id))
this.query = ''
await this.goTo(id)
// The board belongs to the space, so switching space while looking at one
// shows the new space's board — or drops back to the tree for a space that
// has none, rather than leaving the previous board on screen.
if (this.view === 'board') {
this.listCursor = 0
this.cardCursor = 0
if (this.space?.has_board) await this.reloadBoard(id, undefined)
else this.closeBoard()
}
}
/** Show the children of `id`, which must be a space or a folder. */
async goTo(id: number, keep?: number) {
await this.flush()
this.parentId = id
this.query = ''
const folder = await api.getNode(id)
this.path = folder.path
this.spaceId = folder.path[0]?.id ?? id
this.nodes = await api.listNodes({ parent: id })
const index = this.nodes.findIndex((n) => n.id === keep)
this.cursor = index >= 0 ? index : 0
this.#syncUrl(folder.path)
}
/** Up one level, selecting the folder just left so `h` then `l` is a no-op. */
async up() {
const from = this.path.at(-1)
const parent = this.path.at(-2)
if (!parent || !from) return
await this.goTo(parent.id, from.id)
}
async refresh() {
const keepId = this.selected?.id
this.entries = await api.listEntries(this.query)
const index = this.entries.findIndex((e) => e.id === keepId)
this.cursor = index >= 0 ? index : Math.min(this.cursor, Math.max(this.entries.length - 1, 0))
this.nodes = this.query
? await api.listNodes({ q: this.query, space: this.spaceId ?? undefined })
: await api.listNodes({ parent: this.parentId ?? undefined })
const index = this.nodes.findIndex((n) => n.id === keepId)
this.cursor = index >= 0 ? index : Math.min(this.cursor, Math.max(this.nodes.length - 1, 0))
}
async search(query: string) {
@@ -159,26 +295,32 @@ class Diary {
}
move(delta: number) {
if (this.entries.length === 0) return
this.cursor = Math.min(Math.max(this.cursor + delta, 0), this.entries.length - 1)
if (this.nodes.length === 0) return
this.cursor = Math.min(Math.max(this.cursor + delta, 0), this.nodes.length - 1)
}
async openEntry(id: number, edit = false) {
async openNode(id: number, edit = false) {
if (this.open?.id !== id) await this.flush()
const entry = await api.getEntry(id)
this.open = entry
this.draft = { title: entry.title, body: entry.body, created_at: entry.created_at }
const node = await api.getNode(id)
if (node.kind !== 'document') {
await this.goTo(node.id)
return
}
this.open = node
this.draft = { name: node.name, body: node.body, created_at: node.created_at }
this.dirty = false
this.editing = edit
this.pane = 'entry'
const index = this.entries.findIndex((e) => e.id === id)
this.pane = 'document'
this.#syncUrl(node.path)
const index = this.nodes.findIndex((n) => n.id === id)
if (index >= 0) this.cursor = index
// A stash that differs from what the server has is work the last session
// did not manage to write. Vim would call this a swap file.
const stashed = this.#readStash(id)
if (stashed && (stashed.body !== entry.body || stashed.title !== entry.title)) {
if (stashed && (stashed.body !== node.body || stashed.name !== node.name)) {
this.draft = stashed
this.dirty = true
this.say('recovered an unsaved draft — :w to keep it, :e! to discard', 'error')
@@ -187,80 +329,279 @@ class Diary {
}
}
/** What Enter does: descend into a folder, open a document. */
async openSelected(edit = false) {
const selected = this.selected
if (selected) await this.openEntry(selected.id, edit)
if (!selected) return
if (selected.kind === 'document') await this.openNode(selected.id, edit)
else await this.goTo(selected.id)
}
/** A new entry is created immediately so uploads have something to attach to. */
async createEntry() {
const entry = await api.createEntry({ title: '', body: '' })
/** A new document is created immediately so uploads have something to attach to. */
async createDocument(name = '') {
const parent = this.parentId
if (parent === null) return
const node = await api.createNode({ parent_id: parent, name, body: '' })
this.query = ''
await this.refresh()
await this.openEntry(entry.id, true)
await this.openNode(node.id, true)
this.enterInsert = true
this.say(`new entry #${entry.id}`)
this.say(`new document ${this.here}/${node.slug}`)
}
async createFolder(name: string) {
const parent = this.parentId
if (parent === null) return
const node = await api.createNode({ parent_id: parent, kind: 'folder', name })
await this.refresh()
const index = this.nodes.findIndex((n) => n.id === node.id)
if (index >= 0) this.cursor = index
this.say(`new folder ${this.here}/${node.slug}`)
}
async createSpace(name: string) {
const space = await api.createSpace(name)
await this.loadSpaces()
await this.enterSpace(space.id)
this.say(`new space ${space.slug}`)
}
async save({ quiet = false } = {}) {
if (!this.open) return
// Snapshot what goes over the wire: an autosave races the typing that
// triggered it, and anything typed since must stay marked unsaved.
const sent = { ...this.draft, title: this.draft.title.trim() }
const saved = await api.updateEntry(this.open.id, sent)
const sent = { ...this.draft, name: this.draft.name.trim() }
const saved = await api.updateNode(this.open.id, sent)
this.open = saved
this.dirty =
this.draft.body !== sent.body ||
this.draft.title.trim() !== sent.title ||
this.draft.name.trim() !== sent.name ||
this.draft.created_at !== sent.created_at
if (this.dirty) this.touch()
else this.dropStash(saved.id)
// The response carries the same excerpt the list query builds, so the row
// can usually be patched in place rather than re-reading the whole list.
if (!this.patch(saved)) await this.refresh()
if (!quiet) this.say(`"entry:${saved.id}" ${saved.body.split('\n').length}L written`)
if (!quiet) this.say(`"${saved.slug}" ${saved.body.split('\n').length}L written`)
}
/**
* Fold a freshly saved entry back into its list row. Returns false when the
* row cannot be patched in place — a new entry, or one whose date moved it
* somewhere else in the order — and the caller should re-read the list.
* Fold a freshly saved document back into its list row. Returns false when
* the row cannot be patched in place — a new document, or one whose date or
* slug moved it somewhere else — and the caller should re-read the list.
*/
patch(entry: Entry): boolean {
const index = this.entries.findIndex((e) => e.id === entry.id)
if (index < 0 || this.entries[index].created_at !== entry.created_at) return false
const { id, title, excerpt, created_at, updated_at, shared, share_token } = entry
this.entries[index] = { id, title, excerpt, created_at, updated_at, shared, share_token }
patch(node: WorkspaceNode): boolean {
const index = this.nodes.findIndex((n) => n.id === node.id)
if (index < 0) return false
const row = this.nodes[index]
if (row.created_at !== node.created_at || row.slug !== node.slug) return false
const { body: _body, path: _path, ...summary } = node
this.nodes[index] = summary
return true
}
async deleteEntry(id: number) {
async deleteNode(id: number) {
if (this.#autosave) {
clearTimeout(this.#autosave)
this.#autosave = null
}
this.dropStash(id)
await api.deleteEntry(id)
const { removed } = await api.deleteNode(id)
if (this.open?.id === id) {
this.open = null
this.editing = false
this.pane = 'list'
}
await this.refresh()
this.say(`entry #${id} deleted`)
this.say(removed > 1 ? `deleted ${removed} nodes` : `deleted #${id}`)
}
/** Reparent the node under the cursor, for `:mv`. */
async moveNode(id: number, parentId: number) {
await api.moveNode(id, parentId)
await this.refresh()
this.say(`moved #${id}`)
}
/*
* Boards. One per space, which is what makes "what am I working on" a
* question with an answer: work and uni are asked separately, and the diary
* is not asked at all. The board is reloaded whole after every change — it
* is a few dozen rows, and a board that disagrees with the server about
* where a card is would be worse than a round trip.
*/
async openBoard() {
const space = this.space
if (!space) return
if (!space.has_board) {
return this.say(`${space.slug} has no board — :set board turns one on`, 'error')
}
await this.reloadBoard(space.id)
this.view = 'board'
}
closeBoard() {
this.view = 'tree'
}
async toggleBoardView() {
if (this.view === 'board') this.closeBoard()
else await this.openBoard()
}
async reloadBoard(spaceId = this.spaceId, keep = this.card?.id) {
if (spaceId === null) return
this.board = await api.board(spaceId)
// Follow the card that was just moved rather than whatever now sits under
// the cursor, so J J J walks one card down instead of shuffling three.
if (keep !== undefined) {
const at = this.locate(keep)
if (at) {
this.listCursor = at.list
this.cardCursor = at.card
}
}
this.clampBoard()
}
/** Where a card sits now, as list and card indices. */
locate(cardId: number): { list: number; card: number } | null {
const lists = this.board?.lists ?? []
for (let list = 0; list < lists.length; list++) {
const card = lists[list].cards.findIndex((c) => c.id === cardId)
if (card >= 0) return { list, card }
}
return null
}
clampBoard() {
const lists = this.board?.lists ?? []
this.listCursor = Math.min(Math.max(this.listCursor, 0), Math.max(lists.length - 1, 0))
const cards = lists[this.listCursor]?.cards ?? []
this.cardCursor = Math.min(Math.max(this.cardCursor, 0), Math.max(cards.length - 1, 0))
}
/** `h`/`l`: to the list next door, keeping the cursor as near as it can. */
moveList(delta: number) {
const lists = this.board?.lists ?? []
if (lists.length === 0) return
this.listCursor = Math.min(Math.max(this.listCursor + delta, 0), lists.length - 1)
this.clampBoard()
}
/** `j`/`k`: down and up the cards of the current list. */
moveCard(delta: number) {
const cards = this.list?.cards ?? []
if (cards.length === 0) return
this.cardCursor = Math.min(Math.max(this.cardCursor + delta, 0), cards.length - 1)
}
/** `J`/`K`: take the card with you instead of leaving it behind. */
async nudgeCard(delta: number) {
const card = this.card
const list = this.list
if (!card || !list) return
const to = this.cardCursor + delta
if (to < 0 || to >= list.cards.length) return
await api.moveCard(card.id, list.id, to)
await this.reloadBoard(this.spaceId, card.id)
}
/** `H`/`L`: the same card, one list over — the move a board is really for. */
async sendCard(delta: number) {
const card = this.card
const lists = this.board?.lists ?? []
const target = lists[this.listCursor + delta]
if (!card || !target) return
await api.moveCard(card.id, target.id, target.cards.length)
await this.reloadBoard(this.spaceId, card.id)
this.say(`"${card.title}" → ${target.name}`)
}
async createCard(title: string, nodeId: number | null = null) {
const list = this.list
if (!list) return this.say('no list to put it on', 'error')
const card = await api.createCard(list.id, { title, node_id: nodeId })
await this.reloadBoard(this.spaceId, card.id)
this.say(nodeId === null ? `carded "${card.title}"` : `carded "${card.title}" → #${nodeId}`)
}
async patchCard(patch: Parameters<typeof api.updateCard>[1]) {
const card = this.card
if (!card) return this.say('no card selected', 'error')
await api.updateCard(card.id, patch)
await this.reloadBoard(this.spaceId, card.id)
}
async toggleDone() {
const card = this.card
if (!card) return this.say('no card selected', 'error')
await api.updateCard(card.id, { done: card.done_at === null })
await this.reloadBoard(this.spaceId, card.id)
this.say(card.done_at === null ? `done: ${card.title}` : `reopened: ${card.title}`)
}
async deleteCard() {
const card = this.card
if (!card) return this.say('no card selected', 'error')
await api.deleteCard(card.id)
await this.reloadBoard(this.spaceId, undefined)
this.say(`deleted "${card.title}"`)
}
async createList(name: string) {
if (this.spaceId === null) return
this.board = await api.createList(this.spaceId, name)
this.listCursor = this.board.lists.length - 1
this.cardCursor = 0
this.say(`new list ${name}`)
}
async deleteList() {
const list = this.list
if (!list) return this.say('no list selected', 'error')
this.board = await api.deleteList(list.id)
this.clampBoard()
this.say(`deleted list ${list.name}`)
}
/** Open the document a card points at, leaving the board behind. */
async openCard() {
const card = this.card
if (!card) return
if (card.node_id === null) {
return this.say(`"${card.title}" is not linked to a document — :card! links one`, 'error')
}
this.view = 'tree'
await this.openNode(card.node_id)
}
/** Turn a space's board on or off. The diary ships with it off. */
async setBoard(on: boolean) {
const space = this.space
if (!space) return
await api.updateNode(space.id, { name: space.name, body: '', has_board: on })
this.spaces = await api.listSpaces()
if (on) await this.openBoard()
else {
this.closeBoard()
this.board = null
}
this.say(on ? `${space.slug} has a board` : `${space.slug} has no board any more`)
}
async toggleShare(id: number) {
const entry = this.open?.id === id ? this.open : await api.getEntry(id)
if (entry.shared) {
const node = this.open?.id === id ? this.open : await api.getNode(id)
if (node.shared) {
await api.unshare(id)
this.say(`entry #${id} is private again`)
this.say(`#${id} is private again`)
} else {
const url = await this.mintShareLink(id)
this.say(`shared → ${url} (copied)`)
}
if (this.open?.id === id) this.open = await api.getEntry(id)
if (this.open?.id === id) this.open = await api.getNode(id)
await this.refresh()
}
@@ -286,6 +627,29 @@ class Diary {
}
}
/**
* Import a picked folder (or a zip of one) into the folder being browsed.
* The report is worth a line of its own: an import that quietly skipped half
* its files would only be noticed much later.
*/
async importFolder(files: File[]) {
const parent = this.parentId
if (parent === null) return
this.say(`importing ${files.length} file${files.length === 1 ? '' : 's'} …`)
const report = await api.importFolder(parent, files)
await this.refresh()
const parts = [
`${report.documents} document${report.documents === 1 ? '' : 's'}`,
`${report.folders} folder${report.folders === 1 ? '' : 's'}`,
`${report.files} file${report.files === 1 ? '' : 's'}`,
`${report.links} link${report.links === 1 ? '' : 's'} rewritten`,
]
if (report.skipped.length > 0) parts.push(`${report.skipped.length} skipped`)
this.say(`imported ${parts.join(', ')}`)
}
async upload(files: File[]): Promise<MediaFile[]> {
const uploaded = await api.upload(files)
this.say(`uploaded ${uploaded.length} file${uploaded.length === 1 ? '' : 's'}`)
@@ -326,4 +690,4 @@ class Diary {
}
}
export const diary = new Diary()
export const workspace = new Workspace()