Files
narl-workspace/web/src/lib/api.ts
T
nvrl 0245402ead typst: a block of it in markdown is typeset on the server
A ```typst (or ```typ) block is compiled by the typst crate into SVG and
shown as an image where it stands, so math, tables and figures can be
written in typst beside the markdown around them. It shows its source while
it is set, and the error typst gives, with the block's line, when it cannot
be.

Each block is a document of its own: its source is the only file in its
world and the fonts are typst-assets', inside the binary, so nothing is read
from disk or fetched — packages and images are refused. The page fits what
is on it, is transparent, and carries the theme's text colour. Answers are
cached by source, two blocks are laid out at a time off the async threads,
and comemo's memo is evicted after each compile.

A share page asks POST /api/share/{token}/typst, which takes the reader's
cookie and typesets only a block the shared document holds.

typst brings a palette crate whose AsRef impls on arrays made four
`kek.as_ref().into()` in the vault ambiguous; they deref instead.
2026-10-21 15:41:45 +02:00

661 lines
22 KiB
TypeScript

/** What a node is: a top-level space, a folder inside one, a document, or a file. */
export type NodeKind = 'space' | 'folder' | 'document' | 'file'
/** Whether a node can hold other nodes — the ones `l` walks into. */
export const isContainer = (node: { kind: NodeKind }) => node.kind === 'space' || node.kind === 'folder'
/** The bytes behind a file node. */
export interface FileInfo {
media_id: string
mime: string
size: number
url: string
/** Sealed in the vault: it opens only while the vault is unlocked. */
encrypted: boolean
}
export interface NodeSummary {
id: number
parent_id: number | null
kind: NodeKind
name: string
slug: string
excerpt: string
position: number
has_board: boolean
/** Nodes the app has to tell apart: `diary`, `trash`, or nothing. */
role: string | null
child_count: number
created_at: number
updated_at: number
shared: boolean
share_token: string | null
/** Folders and spaces only: whatever lands here is sealed in the vault. */
sealed: boolean
/** Files only. */
file: FileInfo | null
/** Search results only: where the match sits, `work/notes`. */
location?: string
/** Search results only: a file found by what it says; `excerpt` is the words around the match. */
in_text?: boolean
}
/** One step of a node's path, the space first and the node itself last. */
export interface Crumb {
id: number
kind: NodeKind
name: string
slug: string
}
export interface WorkspaceNode extends NodeSummary {
body: string
path: Crumb[]
/** How many documents link here. */
backlinks: number
/** Where each `[[title]]` in the body leads, as an /n/ path; a title that leads nowhere is missing. */
wikilinks: Record<string, string>
}
/** A document that links to the one asked about, and the line it does it on. */
export interface Backlink {
id: number
name: string
slug: string
path: string
context: string
}
/** A space as the overview shows it: itself, and what was last written in it. */
export interface SpaceOverview {
space: NodeSummary
recent: NodeSummary[]
}
/** A browser that is signed in. */
export interface SignedIn {
id: string
created_at: number
last_seen: number
user_agent: string
/** The session asking. */
current: boolean
}
/** A public key allowed to mount the workspace over SFTP. */
export interface SshKey {
id: number
name: string
fingerprint: string
created_at: number
last_used_at: number | null
/** May read sealed files over the mount while the vault is open. */
vault: boolean
}
/** A token that may capture from outside the browser; the token itself is shown once. */
export interface ApiToken {
id: number
name: string
created_at: number
last_used_at: number | null
}
/** Where `POST /api/capture` put the text. */
export interface Captured {
to: 'card' | 'diary'
id: number
where: string
}
/** What a document said before a save replaced it. */
export interface RevisionSummary {
id: number
name: string
saved_at: number
/** `edit`, `force` (`:w!`), `sftp`, or `restore`. */
reason: string
/** Characters of text. */
size: number
}
export interface Revision extends Omit<RevisionSummary, 'size'> {
body: string
}
/** Something deleted, waiting in the trash to be restored or purged. */
export interface TrashItem {
id: number
kind: NodeKind
name: string
slug: string
/** The folder it was deleted from, `work/notes`; empty for a space. */
from: string
deleted_at: number
/** When the sweep deletes it for good. */
purge_at: number
/** How many nodes are inside it. */
inside: number
}
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 {
/** New spaces, made by an import at the root. */
spaces: number
folders: number
documents: number
files: number
links: number
cards: 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
/** `daily`, `weekly`, `monthly`, `yearly`, `+3d`, `+2w`, `+6m` — or none. */
repeat: string | null
position: number
created_at: number
updated_at: number
}
/** A card after a move or an edit, and the copy a repeating card it finished left in the inbox. */
export interface Moved extends Card {
again: Card | null
}
/** An open card as the agenda lists it, with the board it lives on. */
export interface AgendaCard extends Card {
space_id: number
space_slug: string
list_name: string
role: 'inbox' | 'doing'
}
export interface Agenda {
cards: AgendaCard[]
/** Whether the diary has an entry today; null when there is no diary. */
diary_today: boolean | null
/** Diary entries from this day in earlier years, the latest year first. */
on_this_day: DiaryEntry[]
/** Today in the workspace's timezone, which is what due dates are counted in. */
today: { date: string; start: number; end: number; week_end: number } | null
}
/** A diary entry, by the local day it is about. */
export interface DiaryEntry {
id: number
name: string
/** `2026-10-20`, in the server's timezone. */
day: string
created_at: number
/** `diary/untitled`, to open it by. */
path: string
excerpt: string
}
/** A document in a space's `templates` folder. */
export interface Template {
id: number
name: string
slug: string
}
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
mime: string
size: number
created_at: number
url: string
encrypted: boolean
/** Every document that embeds this file — a file may be used by several. */
node_ids: number[]
/** The file node it is, when it sits in the tree; deleted from there. */
file_node: number | null
}
/** One thing a certificate, key or keystore file holds, as `/inspect` describes it. */
export type CertItem =
| {
kind: 'certificate'
subject: string
issuer: string
serial: string
not_before: number
not_after: number
/** `RSA 2048`, `EC P-256`, `Ed25519`. */
key: string
names: string[]
usage: string[]
ca: boolean
sha256: string
sha1: string
label: string | null
}
| { kind: 'request'; subject: string; key: string; names: string[] }
| {
kind: 'private_key'
/** Null while it is still encrypted. */
key: string | null
encrypted: boolean
/** The index of its certificate among the items. */
matches: number | null
label: string | null
}
| { kind: 'public_key'; key: string }
export interface Inspection {
items: CertItem[]
/** Something in the file is behind a password that was not given. */
needs_password: boolean
}
/** The vault, as `/api/vault` reports it. */
export interface VaultStatus {
configured: boolean
unlocked: boolean
/** Seconds until it locks itself if nothing uses it. */
locks_in: number | null
/** How many files are sealed: what an export needs the vault open for. */
sealed: number
/**
* Present while files sealed before the last password change are still
* being re-sealed under the new key; `total` is 0 until counted.
*/
rekey: { done: number; total: number } | null
}
/** The Proton Drive mirror, as `/api/backup` reports it. */
export interface BackupStatus {
configured: boolean
device: string | null
running: boolean
pending: boolean
last_run_at: number | null
last_success_at: number | null
last_error: string | null
last: { uploaded: number; skipped: number; pruned: number; bytes: number } | null
/** What Proton asks a third-party client to say before it takes a login. */
disclosure?: string
/** Whether a stored session is sealed under a key file off the data volume. */
sealed_session?: boolean
}
/** What a Proton login from the workspace needs next. */
export interface BackupLogin {
next: 'done' | 'totp' | 'captcha'
/** Proton's human-check page, for `captcha`. */
url?: string
status: BackupStatus
}
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message)
}
}
/** No answer at all: offline, or the server is down. Status 0, as XHR has it. */
export const unreachable = (error: unknown) =>
error instanceof ApiError && (error.status === 0 || error.status === 502 || error.status === 503 || error.status === 504)
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
let response: Response
try {
response = await fetch(`/api${path}`, {
credentials: 'same-origin',
...init,
headers:
init.body instanceof FormData
? init.headers
: { 'content-type': 'application/json', ...init.headers },
})
} catch {
// fetch says "Failed to fetch" and nothing else; this says what it means.
throw new ApiError(0, 'the server cannot be reached')
}
if (!response.ok) {
let message = response.statusText
try {
message = (await response.json()).error ?? message
} catch {
/* the body was not JSON; the status text will do */
}
throw new ApiError(response.status, message)
}
return response.status === 204 ? (undefined as T) : await response.json()
}
const body = (data: unknown) => JSON.stringify(data)
/** The size of a page of a long list, the most the server hands out at once. */
const PAGE = 2000
/** Every page of a list, read until one comes back short. */
async function pages<T>(read: (limit: number, offset: number) => Promise<T[]>): Promise<T[]> {
const all: T[] = []
for (;;) {
const page = await read(PAGE, all.length)
all.push(...page)
if (page.length < PAGE) return all
}
}
/**
* Files up as multipart, by XHR rather than fetch, because a gigabyte going
* up needs to say how far along it is.
*/
function send<T>(url: string, files: File[], progress?: (fraction: number) => void) {
return new Promise<T>((resolve, reject) => {
const form = new FormData()
for (const file of files) form.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', url)
xhr.responseType = 'json'
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) progress?.(event.loaded / event.total)
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response as T)
else reject(new ApiError(xhr.status, xhr.response?.error ?? xhr.statusText))
}
xhr.onerror = () => reject(new ApiError(0, 'the upload did not reach the server'))
xhr.send(form)
})
}
export const api = {
me: () => request<{ user: string }>('/me'),
login: (username: string, password: string) =>
request<{ user: string }>('/login', { method: 'POST', body: body({ username, password }) }),
/** `everywhere`: every device's session ends, not only this one. */
logout: (everywhere = false) =>
request<{ ok: true }>(`/logout${everywhere ? '?everywhere=true' : ''}`, { method: 'POST' }),
listSpaces: () => request<NodeSummary[]>('/spaces'),
overview: () => request<SpaceOverview[]>('/spaces/overview'),
createSpace: (name: string, has_board = true) =>
request<WorkspaceNode>('/spaces', { method: 'POST', body: body({ name, has_board }) }),
/**
* The children of a node, or — with `q` — a search across the whole tree.
* A folder is read a page at a time until a page comes back short, so one
* that grows past a page is listed whole rather than cut off; a search
* stops at its first page, the best matches.
*/
listNodes: async (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))
if (params.q) return request<NodeSummary[]>(`/nodes?${query}`)
return pages((limit, offset) => {
query.set('limit', String(limit))
query.set('offset', String(offset))
return request<NodeSummary[]>(`/nodes?${query}`)
})
},
getNode: (id: number) => request<WorkspaceNode>(`/nodes/${id}`),
/** `work/einarbeiten/01-was-ist-eebus` — the node behind a `/n/...` address. */
resolvePath: (path: string) => request<WorkspaceNode>(`/resolve?path=${encodeURIComponent(path)}`),
/**
* 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 })
},
/** An `:export!` poured back in whole: its directories become spaces. */
importRoot: (files: File[]) => {
const form = new FormData()
for (const file of files) form.append('file', file, file.webkitRelativePath || file.name)
return request<ImportReport>('/import', { method: 'POST', body: form })
},
createNode: (input: {
parent_id: number
kind?: NodeKind
name?: string
body?: string
created_at?: number
/** Start from this document, its `{{date}}` and the rest filled in. */
template_id?: number
}) => request<WorkspaceNode>('/nodes', { method: 'POST', body: body(input) }),
/** The templates of the space `id` is in. */
templates: (id: number) => request<Template[]>(`/nodes/${id}/templates`),
/** A month's diary entries: `2026-10`. */
diaryDays: (month: string) => request<DiaryEntry[]>(`/diary/days?month=${encodeURIComponent(month)}`),
/** The entries before and after a diary entry, by date. */
diaryAround: (id: number) =>
request<{ prev: DiaryEntry | null; next: DiaryEntry | null }>(`/diary/around/${id}`),
/**
* Only what is sent changes: a rename sends the name alone.
* `base_updated_at`: refused with 409 if the node was saved since then.
* `restore`: the text is a revision brought back, so what it replaces is
* kept as a revision whatever the last one was.
*/
updateNode: (
id: number,
input: {
name?: string
body?: string
created_at?: number
has_board?: boolean
base_updated_at?: number
restore?: boolean
},
) => request<WorkspaceNode>(`/nodes/${id}`, { method: 'PUT', body: body(input) }),
/** A document's earlier versions, the newest first. */
revisions: (id: number) => request<RevisionSummary[]>(`/nodes/${id}/revisions`),
revision: (id: number, rev: number) => request<Revision>(`/nodes/${id}/revisions/${rev}`),
/** The documents that link to this one, the last written first. */
backlinks: (id: number) => request<Backlink[]>(`/nodes/${id}/backlinks`),
/** Into the trash; `purged` when it was in the trash already and is now gone. */
deleteNode: (id: number) =>
request<{ ok: true; removed: number; purged: boolean }>(`/nodes/${id}`, { method: 'DELETE' }),
trash: () => request<TrashItem[]>('/trash'),
/** Back where it was; `elsewhere` when that folder is gone and it went to its space. */
restore: (id: number) =>
request<{ node: WorkspaceNode; elsewhere: boolean }>(`/trash/${id}/restore`, { method: 'POST' }),
purge: (id: number) => request<{ ok: true; removed: number }>(`/trash/${id}`, { method: 'DELETE' }),
emptyTrash: () => request<{ ok: true; removed: number }>('/trash', { method: 'DELETE' }),
moveNode: (id: number, parent_id: number, position?: number) =>
request<WorkspaceNode>(`/nodes/${id}/move`, { method: 'POST', body: body({ parent_id, position }) }),
/** All of them into `parent_id`, or none when one cannot go. */
moveNodes: (ids: number[], parent_id: number) =>
request<{ moved: number; unchanged: number }>('/nodes/move', { method: 'POST', body: body({ ids, parent_id }) }),
/** Publishes the document, or mints a fresh key for one already published. */
share: (id: number) =>
request<{ token: string; key: string; path: string; sealed: number }>(`/nodes/${id}/share`, {
method: 'POST',
}),
unshare: (id: number) => request<{ ok: true }>(`/nodes/${id}/share`, { method: 'DELETE' }),
/** Open cards across every board: due within a week, overdue, or in hand. */
agenda: () => request<Agenda>('/agenda'),
/** 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
repeat?: string | null
done?: boolean
},
) => request<Moved>(`/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<Moved>(`/cards/${id}/move`, { method: 'POST', body: body({ list_id, position }) }),
listMedia: () => pages((limit, offset) => request<MediaFile[]>(`/media?limit=${limit}&offset=${offset}`)),
/**
* Upload files into a space or folder as file nodes. XHR rather than fetch,
* because a gigabyte going up needs to say how far along it is.
*/
putFiles: (parentId: number, files: File[], encrypt: boolean, progress?: (fraction: number) => void) =>
send<WorkspaceNode[]>(`/api/nodes/${parentId}/files${encrypt ? '?encrypt=true' : ''}`, files, progress),
/** Files to embed in a document, with the same progress as `putFiles`. */
upload: (files: File[], progress?: (fraction: number) => void) =>
send<MediaFile[]>('/api/media', files, progress),
deleteMedia: (id: string) => request<{ ok: true }>(`/media/${id}`, { method: 'DELETE' }),
vaultStatus: () => request<VaultStatus>('/vault'),
setupVault: (password: string) =>
request<VaultStatus>('/vault', { method: 'POST', body: body({ password }) }),
changeVaultPassword: (current: string, password: string) =>
request<VaultStatus>('/vault', { method: 'PUT', body: body({ current, password }) }),
unlockVault: (password: string) =>
request<VaultStatus>('/vault/unlock', { method: 'POST', body: body({ password }) }),
lockVault: () => request<VaultStatus>('/vault/lock', { method: 'POST' }),
/**
* Seal a file into the vault, or a folder with everything in it and all
* that lands there later — or take it back out.
*/
seal: (id: number, encrypt: boolean) =>
request<{ files: number; unchanged: number; failed: number; shared: number; sealed: boolean }>(`/nodes/${id}/${encrypt ? 'encrypt' : 'decrypt'}`, {
method: 'POST',
}),
/** What a certificate, key or keystore holds; the password opens it for this answer only. */
inspect: (id: number, password?: string) =>
request<Inspection>(`/nodes/${id}/inspect`, { method: 'POST', body: body({ password }) }),
backupStatus: () => request<BackupStatus>('/backup'),
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
backupLogin: (username: string, password: string) =>
request<BackupLogin>('/backup/login', { method: 'POST', body: body({ username, password }) }),
backupTotp: (code: string) => request<BackupLogin>('/backup/login/totp', { method: 'POST', body: body({ code }) }),
backupCaptcha: (token: string) =>
request<BackupLogin>('/backup/login/captcha', { method: 'POST', body: body({ token }) }),
backupLogout: () => request<BackupStatus>('/backup/login', { method: 'DELETE' }),
readShared: (token: string, key: string) =>
request<SharedDocument>(`/share/${encodeURIComponent(token)}`, { method: 'POST', body: body({ key }) }),
/** A ```typst block as SVG — or, on a share page, a block of the shared document. */
typeset: (source: string, share?: string) =>
request<{ svg?: string; error?: string }>(share ? `/share/${encodeURIComponent(share)}/typst` : '/typst', {
method: 'POST',
body: body({ source }),
}),
sessions: () => request<SignedIn[]>('/sessions'),
revokeSession: (id: string) =>
request<{ ok: true }>(`/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
listSshKeys: () => request<SshKey[]>('/ssh-keys'),
addSshKey: (name: string, public_key: string) =>
request<SshKey>('/ssh-keys', { method: 'POST', body: body({ name, public_key }) }),
removeSshKey: (id: number) => request<{ ok: true }>(`/ssh-keys/${id}`, { method: 'DELETE' }),
setSshKeyVault: (id: number, vault: boolean) =>
request<{ ok: true }>(`/ssh-keys/${id}`, { method: 'PATCH', body: body({ vault }) }),
listTokens: () => request<ApiToken[]>('/tokens'),
addToken: (name: string) =>
request<ApiToken & { token: string }>('/tokens', { method: 'POST', body: body({ name }) }),
removeToken: (id: number) => request<{ ok: true }>(`/tokens/${id}`, { method: 'DELETE' }),
}