mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
`x`, `dd`, `:delete` and `:rmspace` no longer end anything. What they delete is moved, whole, under one hidden root, and `u` puts the last of it back where it was. `:trash` lists everything deleted, the last first: Enter or `r` restores, `x` deletes one for good and `:trash purge` empties it, each asking first. Something whose folder has gone since comes back to the root of its space; something whose space is in the trash too waits for the space. The daily sweep purges what has waited WORKSPACE_TRASH_DAYS (30), and only then are its files deleted. While it waits, a trashed node is out of the tree, the search, the agenda, the mails, the SFTP mount, the export and the readable files/ view, and a shared document answers 404 until it comes back with the same link. `rm` over the mount trashes too; a rename onto a taken name still replaces what was there, so editor saves do not fill the trash.
539 lines
18 KiB
TypeScript
539 lines
18 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[]
|
|
}
|
|
|
|
/** 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
|
|
}
|
|
|
|
/** 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
|
|
position: number
|
|
created_at: number
|
|
updated_at: number
|
|
}
|
|
|
|
/** 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
|
|
}
|
|
|
|
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
|
|
}) => request<WorkspaceNode>('/nodes', { method: 'POST', body: body(input) }),
|
|
|
|
/** `base_updated_at`: refused with 409 if the node was saved since then. */
|
|
updateNode: (
|
|
id: number,
|
|
input: { name: string; body: string; created_at?: number; has_board?: boolean; base_updated_at?: number },
|
|
) => request<WorkspaceNode>(`/nodes/${id}`, { method: 'PUT', body: body(input) }),
|
|
|
|
/** 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
|
|
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: () => 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 }) }),
|
|
}
|