Files
narl-workspace/web/src/lib/api.ts
T
nvrl 6673298cb2 boards: a card can come back once it is done
`:repeat weekly` gives a card a rule: daily, weekly, monthly, yearly, or
every +Nd, +Nw, +Nm, +Ny. Finishing it, by a move into the last list or by
the API's done, leaves a copy at the bottom of the inbox with the same
title, notes, document and rule, due on the next date. The finished card
gives its rule up in the same transaction, so reopening it and finishing it
again makes no second copy.

The next date is counted on the calendar in WORKSPACE_TIMEZONE, so noon
stays noon across a change of the clocks, and months are counted from the
old due date rather than step by step, so a card due on the 31st comes back
on the 31st wherever a month has one. A card finished late skips the dates
already past; one without a date counts from the moment it was finished.

A move and an edit answer with `again`, the copy, so the status line can
say when the card comes back. The board and the agenda show the rule as
`↻ weekly`, the rail has a button for it, and `:` in the agenda now opens
the line with the agenda kept up, so `:repeat` knows which card.

The rule travels in `.workspace.json` without a format bump: an older build
ignores the field and imports the card as one that does not repeat. An
import drops a rule it cannot read.
2026-10-21 00:02:42 +02:00

649 lines
21 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
}
/** 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[]
}
/** 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}`),
/**
* `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 }) }),
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' }),
}