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
+5487 -972

No files matched your search

+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
}