mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
76f0887ac5
commit
49b2056215
50 files changed
+5487
-972
No files matched your search
+1
-1
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#0b0e0d" />
|
||||
<title>~/diary</title>
|
||||
<title>~/workspace</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230b0e0d'/%3E%3Ctext x='16' y='22' font-family='monospace' font-size='16' fill='%2354f2a1' text-anchor='middle'%3E~%3C/text%3E%3C/svg%3E" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "narl-diary-web",
|
||||
"name": "narl-workspace-web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+160
-33
@@ -1,15 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { hooks, runCommand } from './lib/commands'
|
||||
import { embedSnippet } from './lib/markdown'
|
||||
import Board from './lib/Board.svelte'
|
||||
import CommandLine from './lib/CommandLine.svelte'
|
||||
import EntryPane from './lib/EntryPane.svelte'
|
||||
import DocumentPane from './lib/DocumentPane.svelte'
|
||||
import Help from './lib/Help.svelte'
|
||||
import Login from './lib/Login.svelte'
|
||||
import MediaBrowser from './lib/MediaBrowser.svelte'
|
||||
import Shared from './lib/Shared.svelte'
|
||||
import Sidebar from './lib/Sidebar.svelte'
|
||||
import StatusBar from './lib/StatusBar.svelte'
|
||||
import { diary } from './lib/store.svelte'
|
||||
import { workspace } from './lib/store.svelte'
|
||||
|
||||
/*
|
||||
* A share link is /s/<token>#<key>. The fragment never leaves the browser, so
|
||||
@@ -22,18 +23,26 @@
|
||||
|
||||
let cmdline = $state<string | null>(null)
|
||||
let fileInput = $state<HTMLInputElement>()
|
||||
let folderInput = $state<HTMLInputElement>()
|
||||
let archiveInput = $state<HTMLInputElement>()
|
||||
let shell = $state<HTMLDivElement>()
|
||||
let overlayEl = $state<HTMLDivElement>()
|
||||
let pending = $state('')
|
||||
|
||||
if (!shareToken) void diary.boot()
|
||||
if (!shareToken) void workspace.boot()
|
||||
|
||||
$effect(() => {
|
||||
document.documentElement.dataset.theme = diary.theme
|
||||
document.documentElement.dataset.theme = workspace.theme
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
folderInput?.setAttribute('webkitdirectory', '')
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
hooks.pickFiles = () => fileInput?.click()
|
||||
hooks.pickFolder = () => folderInput?.click()
|
||||
hooks.pickArchive = () => archiveInput?.click()
|
||||
hooks.focusList = () => shell?.focus()
|
||||
hooks.openCommandLine = (initial) => (cmdline = initial)
|
||||
})
|
||||
@@ -43,15 +52,15 @@
|
||||
* window handler instead of being swallowed by the editor underneath.
|
||||
*/
|
||||
$effect(() => {
|
||||
if (diary.overlay !== 'none') {
|
||||
if (workspace.overlay !== 'none') {
|
||||
overlayEl?.focus()
|
||||
return () => (diary.editing ? hooks.focusEditor() : shell?.focus())
|
||||
return () => (workspace.editing ? hooks.focusEditor() : shell?.focus())
|
||||
}
|
||||
})
|
||||
|
||||
// Guard against losing a half-written entry to a stray refresh.
|
||||
$effect(() => {
|
||||
if (!diary.dirty) return
|
||||
if (!workspace.dirty) return
|
||||
const warn = (event: BeforeUnloadEvent) => event.preventDefault()
|
||||
window.addEventListener('beforeunload', warn)
|
||||
return () => window.removeEventListener('beforeunload', warn)
|
||||
@@ -62,12 +71,89 @@
|
||||
const files = Array.from(input.files ?? [])
|
||||
input.value = ''
|
||||
if (files.length === 0) return
|
||||
await diary.guard(async () => {
|
||||
const uploaded = await diary.upload(files)
|
||||
await workspace.guard(async () => {
|
||||
const uploaded = await workspace.upload(files)
|
||||
hooks.insertText(uploaded.map(embedSnippet).join('\n\n'))
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* A folder upload is the same multipart body as any other, except that each
|
||||
* file carries the path it had inside the folder — which is what makes the
|
||||
* tree on the other side possible.
|
||||
*/
|
||||
async function importFolder(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = Array.from(input.files ?? [])
|
||||
input.value = ''
|
||||
if (files.length === 0) return
|
||||
await workspace.guard(() => workspace.importFolder(files))
|
||||
}
|
||||
|
||||
/*
|
||||
* The board reads the same keys as the list, one dimension wider: h/l walk
|
||||
* the lists, j/k the cards, and the shifted pair takes the card along. Only
|
||||
* `:` and `?` fall through to the keys below, because everything else would
|
||||
* act on a tree that is not on screen.
|
||||
*/
|
||||
function boardKey(key: string): boolean {
|
||||
switch (key) {
|
||||
case 'h':
|
||||
case 'ArrowLeft':
|
||||
workspace.moveList(-1)
|
||||
return true
|
||||
case 'l':
|
||||
case 'ArrowRight':
|
||||
workspace.moveList(1)
|
||||
return true
|
||||
case 'j':
|
||||
case 'ArrowDown':
|
||||
workspace.moveCard(1)
|
||||
return true
|
||||
case 'k':
|
||||
case 'ArrowUp':
|
||||
workspace.moveCard(-1)
|
||||
return true
|
||||
case 'J':
|
||||
void workspace.guard(() => workspace.nudgeCard(1))
|
||||
return true
|
||||
case 'K':
|
||||
void workspace.guard(() => workspace.nudgeCard(-1))
|
||||
return true
|
||||
case 'H':
|
||||
void workspace.guard(() => workspace.sendCard(-1))
|
||||
return true
|
||||
case 'L':
|
||||
void workspace.guard(() => workspace.sendCard(1))
|
||||
return true
|
||||
case 'G':
|
||||
workspace.cardCursor = Math.max((workspace.list?.cards.length ?? 0) - 1, 0)
|
||||
return true
|
||||
case 'Enter':
|
||||
void workspace.guard(() => workspace.openCard())
|
||||
return true
|
||||
case 'o':
|
||||
cmdline = ':card '
|
||||
return true
|
||||
case 't':
|
||||
case ' ':
|
||||
void workspace.guard(() => workspace.toggleDone())
|
||||
return true
|
||||
case 'x':
|
||||
void runCommand('delete')
|
||||
return true
|
||||
case 'r':
|
||||
void workspace.guard(() => workspace.reloadBoard())
|
||||
return true
|
||||
case 'q':
|
||||
case 'Escape':
|
||||
workspace.closeBoard()
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isTypingTarget(target: EventTarget | null) {
|
||||
const node = target as HTMLElement | null
|
||||
return (
|
||||
@@ -82,13 +168,13 @@
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
if (event.key === 's' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
if (diary.open) void diary.guard(() => diary.save())
|
||||
if (workspace.open) void workspace.guard(() => workspace.save())
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
if (diary.overlay !== 'none') {
|
||||
diary.overlay = 'none'
|
||||
if (workspace.overlay !== 'none') {
|
||||
workspace.overlay = 'none'
|
||||
return
|
||||
}
|
||||
if (cmdline !== null) {
|
||||
@@ -98,10 +184,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (diary.overlay !== 'none') {
|
||||
if (workspace.overlay !== 'none') {
|
||||
if (event.key === 'q' || event.key === '?') {
|
||||
event.preventDefault()
|
||||
diary.overlay = 'none'
|
||||
workspace.overlay = 'none'
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -113,10 +199,26 @@
|
||||
const previous = pending
|
||||
pending = ''
|
||||
|
||||
if (workspace.view === 'board') {
|
||||
if (previous === 'g' && key === 'g') {
|
||||
event.preventDefault()
|
||||
workspace.cardCursor = 0
|
||||
return
|
||||
}
|
||||
if (key === 'g') {
|
||||
pending = key
|
||||
return
|
||||
}
|
||||
if (boardKey(key)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Two-key sequences: gg and dd.
|
||||
if (previous === 'g' && key === 'g') {
|
||||
event.preventDefault()
|
||||
diary.cursor = 0
|
||||
workspace.cursor = 0
|
||||
return
|
||||
}
|
||||
if (previous === 'd' && key === 'd') {
|
||||
@@ -133,27 +235,33 @@
|
||||
case 'j':
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
diary.move(1)
|
||||
workspace.move(1)
|
||||
break
|
||||
case 'k':
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
diary.move(-1)
|
||||
workspace.move(-1)
|
||||
break
|
||||
case 'G':
|
||||
event.preventDefault()
|
||||
diary.cursor = Math.max(diary.entries.length - 1, 0)
|
||||
workspace.cursor = Math.max(workspace.nodes.length - 1, 0)
|
||||
break
|
||||
case 'Enter':
|
||||
case 'l':
|
||||
case 'ArrowRight':
|
||||
event.preventDefault()
|
||||
void diary.guard(() => diary.openSelected())
|
||||
void workspace.guard(() => workspace.openSelected())
|
||||
break
|
||||
case 'o':
|
||||
event.preventDefault()
|
||||
void runCommand('new')
|
||||
break
|
||||
case 'O':
|
||||
// The name has to come from somewhere, and the command line is where
|
||||
// names are typed — this only saves typing `:mkdir `.
|
||||
event.preventDefault()
|
||||
cmdline = ':mkdir '
|
||||
break
|
||||
case 'i':
|
||||
case 'a':
|
||||
event.preventDefault()
|
||||
@@ -161,10 +269,16 @@
|
||||
break
|
||||
case 'h':
|
||||
case 'ArrowLeft':
|
||||
// Out of the document if one is open, otherwise up a level — the same
|
||||
// key means "leftwards" at both depths.
|
||||
event.preventDefault()
|
||||
if (workspace.open) void runCommand('q')
|
||||
else void workspace.guard(() => workspace.up())
|
||||
break
|
||||
case 'q':
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
if (diary.open) void runCommand('q')
|
||||
if (workspace.open) void runCommand('q')
|
||||
break
|
||||
case 'x':
|
||||
event.preventDefault()
|
||||
@@ -172,7 +286,7 @@
|
||||
break
|
||||
case 's':
|
||||
event.preventDefault()
|
||||
void runCommand(diary.selected?.shared ? 'unshare' : 'share')
|
||||
void runCommand(workspace.selected?.shared ? 'unshare' : 'share')
|
||||
break
|
||||
case 'y':
|
||||
event.preventDefault()
|
||||
@@ -180,7 +294,7 @@
|
||||
break
|
||||
case 'n':
|
||||
event.preventDefault()
|
||||
if (diary.query) void diary.guard(() => diary.search(''))
|
||||
if (workspace.query) void workspace.guard(() => workspace.search(''))
|
||||
break
|
||||
case '/':
|
||||
event.preventDefault()
|
||||
@@ -202,32 +316,39 @@
|
||||
|
||||
{#if shareToken}
|
||||
<Shared token={shareToken} shareKey={shareKey} />
|
||||
{:else if diary.booting}
|
||||
<div class="boot faint">booting ~/diary…</div>
|
||||
{:else if !diary.user}
|
||||
{:else if workspace.booting}
|
||||
<div class="boot faint">booting ~/workspace…</div>
|
||||
{:else if !workspace.user}
|
||||
<Login />
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div class="shell" bind:this={shell} tabindex="-1">
|
||||
<div class="panes" class:reading={!!diary.open}>
|
||||
{#if workspace.view === 'board'}
|
||||
<!-- The board takes the whole width: its columns are the two panes. -->
|
||||
<div class="panes board"><Board /></div>
|
||||
{:else}
|
||||
<div class="panes" class:reading={!!workspace.open}>
|
||||
<div class="list"><Sidebar /></div>
|
||||
<div class="entry">
|
||||
{#if diary.open}
|
||||
<EntryPane />
|
||||
{#if workspace.open}
|
||||
<DocumentPane />
|
||||
{:else}
|
||||
<div class="placeholder faint">
|
||||
<pre>{` ┌─────────────────────────────┐
|
||||
│ nothing open │
|
||||
│ │
|
||||
│ o write a new entry │
|
||||
│ o write a new document │
|
||||
│ O make a folder │
|
||||
│ j/k browse, Enter opens │
|
||||
│ / search everything │
|
||||
│ h up one level │
|
||||
│ / search this space │
|
||||
│ ? help │
|
||||
└─────────────────────────────┘`}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<StatusBar />
|
||||
|
||||
@@ -235,17 +356,21 @@
|
||||
<CommandLine initial={cmdline} close={() => (cmdline = null)} />
|
||||
{/if}
|
||||
|
||||
{#if diary.overlay !== 'none'}
|
||||
{#if workspace.overlay !== 'none'}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
|
||||
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (diary.overlay = 'none')}
|
||||
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
|
||||
>
|
||||
{#if diary.overlay === 'help'}<Help />{:else}<MediaBrowser />{/if}
|
||||
{#if workspace.overlay === 'help'}<Help />{:else}<MediaBrowser />{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<input class="hidden" type="file" multiple bind:this={fileInput} onchange={attach} />
|
||||
<!-- `webkitdirectory` is set from script: it is the one attribute browsers
|
||||
agree on for picking a folder, and no typed attribute list carries it. -->
|
||||
<input class="hidden" type="file" multiple bind:this={folderInput} onchange={importFolder} />
|
||||
<input class="hidden" type="file" accept=".zip" bind:this={archiveInput} onchange={importFolder} />
|
||||
|
||||
<style>
|
||||
.boot { display: grid; place-items: center; height: 100%; }
|
||||
@@ -265,6 +390,8 @@
|
||||
grid-template-columns: minmax(28ch, 34ch) 1fr;
|
||||
}
|
||||
|
||||
.panes.board { grid-template-columns: 1fr; }
|
||||
|
||||
.list, .entry { min-width: 0; overflow: hidden; }
|
||||
|
||||
.placeholder { display: grid; place-items: center; height: 100%; }
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* The board of the current space: columns of cards, in the same terminal
|
||||
* idiom as the list. The keys live in App.svelte with every other key, so
|
||||
* this file only draws and — for a pointer — moves the two cursors.
|
||||
*/
|
||||
import type { Card } from './api'
|
||||
import { hooks, runCommand } from './commands'
|
||||
import SpaceBar from './SpaceBar.svelte'
|
||||
import { workspace } from './store.svelte'
|
||||
import { formatDay } from './util'
|
||||
|
||||
let columns = $state<HTMLDivElement>()
|
||||
|
||||
// Keep the selected card on screen while walking the board with the keyboard.
|
||||
$effect(() => {
|
||||
const at = `${workspace.listCursor}:${workspace.cardCursor}`
|
||||
columns?.querySelector<HTMLElement>(`[data-at="${at}"]`)?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
})
|
||||
|
||||
const midnight = () => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
return Math.floor(today.getTime() / 1000)
|
||||
}
|
||||
|
||||
/** `overdue`, `today` or nothing — a date only earns colour when it bites. */
|
||||
function urgency(card: Card): '' | 'today' | 'overdue' {
|
||||
if (card.due_at === null || card.done_at !== null) return ''
|
||||
const start = midnight()
|
||||
if (card.due_at < start) return 'overdue'
|
||||
return card.due_at < start + 86400 ? 'today' : ''
|
||||
}
|
||||
|
||||
function pick(list: number, card: number) {
|
||||
workspace.listCursor = list
|
||||
workspace.cardCursor = card
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="board">
|
||||
<div class="head">
|
||||
<span class="accent">~/{workspace.space?.slug ?? ''}/board</span>
|
||||
<span class="faint count">
|
||||
{workspace.board?.lists.reduce((n, list) => n + list.cards.length, 0) ?? 0} cards
|
||||
</span>
|
||||
<span class="tools">
|
||||
<button title="new card (o)" onclick={() => hooks.openCommandLine(':card ')}>+</button>
|
||||
<button title="new list" onclick={() => hooks.openCommandLine(':list ')}>[+]</button>
|
||||
<button title="back to the tree (:board)" onclick={() => runCommand('board')}>tree</button>
|
||||
<button title="command line (:)" onclick={() => hooks.openCommandLine(':')}>:</button>
|
||||
<button title="help (?)" onclick={() => runCommand('help')}>?</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<SpaceBar />
|
||||
|
||||
<div class="columns" bind:this={columns}>
|
||||
{#each workspace.board?.lists ?? [] as list, listIndex (list.id)}
|
||||
<section class="column" class:on={listIndex === workspace.listCursor}>
|
||||
<header>
|
||||
<span class="name">{list.name}</span>
|
||||
<span class="faint">{list.cards.length}</span>
|
||||
</header>
|
||||
|
||||
<ul>
|
||||
{#each list.cards as card, cardIndex (card.id)}
|
||||
<li>
|
||||
<button
|
||||
class="card"
|
||||
class:active={listIndex === workspace.listCursor && cardIndex === workspace.cardCursor}
|
||||
class:done={card.done_at !== null}
|
||||
data-at="{listIndex}:{cardIndex}"
|
||||
onclick={() => pick(listIndex, cardIndex)}
|
||||
ondblclick={() => workspace.guard(() => workspace.openCard())}
|
||||
>
|
||||
<span class="title">
|
||||
<span class="tick">{card.done_at === null ? '[ ]' : '[x]'}</span>
|
||||
{card.title}
|
||||
</span>
|
||||
{#if card.due_at !== null || card.node_id !== null}
|
||||
<span class="meta faint">
|
||||
{#if card.due_at !== null}
|
||||
<span class={urgency(card)}>due {formatDay(card.due_at)}</span>
|
||||
{/if}
|
||||
{#if card.node_id !== null}
|
||||
<span class="doc" title="Enter opens it">→ {card.node_name ?? `#${card.node_id}`}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="empty faint">—</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{:else}
|
||||
<div class="empty faint">no lists — :list <name> makes one</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.count { flex: 1; text-align: right; white-space: nowrap; }
|
||||
.tools { display: flex; gap: 2px; flex: none; }
|
||||
.tools button { padding: 0 6px; line-height: 18px; }
|
||||
|
||||
.columns {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 26ch;
|
||||
flex: 1 1 0;
|
||||
max-width: 44ch;
|
||||
border-right: 1px solid var(--line);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.column header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--fg-dim);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* The list the cursor is on, marked the way the sidebar marks its space. */
|
||||
.column.on header .name { color: var(--accent); font-weight: 700; }
|
||||
.column.on { background: var(--bg-alt); }
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px 0 24px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 4px 10px;
|
||||
text-align: left;
|
||||
color: var(--fg-dim);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.card:hover { background: var(--bg-lift); color: var(--fg); }
|
||||
.card.active { background: var(--select); color: var(--fg); }
|
||||
.card.done .title { text-decoration: line-through; color: var(--fg-faint); }
|
||||
|
||||
.tick { color: var(--accent); }
|
||||
.title { display: block; }
|
||||
.meta { display: flex; gap: 8px; font-size: 12px; padding-left: 4ch; }
|
||||
.meta .today { color: var(--warn); }
|
||||
.meta .overdue { color: var(--error); }
|
||||
.doc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 24ch; }
|
||||
|
||||
.empty { padding: 12px 10px; }
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.card { padding: 8px 10px; }
|
||||
.tools button { padding: 4px 10px; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { completions, runCommand, hooks } from './commands'
|
||||
import { diary } from './store.svelte'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
interface Props {
|
||||
initial: string
|
||||
@@ -23,7 +23,7 @@
|
||||
$effect(() => {
|
||||
if (!isSearch) return
|
||||
const query = value.slice(1)
|
||||
const timer = setTimeout(() => void diary.guard(() => diary.search(query)), 120)
|
||||
const timer = setTimeout(() => void workspace.guard(() => workspace.search(query)), 120)
|
||||
return () => clearTimeout(timer)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,47 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { runCommand } from './commands'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import { diary } from './store.svelte'
|
||||
import { workspace } from './store.svelte'
|
||||
import { formatStamp, formatWeekday, isTouchDevice, relative } from './util'
|
||||
|
||||
const entry = $derived(diary.open!)
|
||||
const html = $derived(renderMarkdown(diary.editing ? diary.draft.body : entry.body))
|
||||
const doc = $derived(workspace.open!)
|
||||
const html = $derived(renderMarkdown(workspace.editing ? workspace.draft.body : doc.body))
|
||||
const words = $derived(
|
||||
(diary.editing ? diary.draft.body : entry.body).split(/\s+/).filter(Boolean).length,
|
||||
(workspace.editing ? workspace.draft.body : doc.body).split(/\s+/).filter(Boolean).length,
|
||||
)
|
||||
/*
|
||||
* Only the token half of a share link is recoverable — the key lives in the
|
||||
* fragment, and the server kept nothing but its hash — so the bar shows the
|
||||
* link's shape and offers a fresh one rather than a copy of the old.
|
||||
*/
|
||||
const sharePath = $derived(entry.share_token ? `${location.origin}/s/${entry.share_token}#…` : null)
|
||||
const sharePath = $derived(doc.share_token ? `${location.origin}/s/${doc.share_token}#…` : null)
|
||||
|
||||
/**
|
||||
* `/n/<space>/<slug>` links — the ones the importer writes between imported
|
||||
* documents — are followed in place rather than reloading the application.
|
||||
*/
|
||||
function follow(event: MouseEvent) {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return
|
||||
const anchor = (event.target as HTMLElement | null)?.closest('a')
|
||||
const href = anchor?.getAttribute('href') ?? ''
|
||||
if (!href.startsWith('/n/')) return
|
||||
event.preventDefault()
|
||||
const path = href.slice(3).split('#')[0]
|
||||
void workspace.guard(() => workspace.openPath(decodeURIComponent(path)))
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="pane">
|
||||
<header>
|
||||
<div class="meta">
|
||||
<span class="accent">entry:{entry.id}</span>
|
||||
<span class="faint">{formatStamp(diary.draft.created_at)} {formatWeekday(diary.draft.created_at)}</span>
|
||||
<span class="accent">{workspace.here}/{doc.slug}</span>
|
||||
<span class="faint">{formatStamp(workspace.draft.created_at)} {formatWeekday(workspace.draft.created_at)}</span>
|
||||
<span class="faint">·</span>
|
||||
<span class="faint">edited {relative(entry.updated_at)}</span>
|
||||
<span class="faint">edited {relative(doc.updated_at)}</span>
|
||||
<span class="faint">·</span>
|
||||
<span class="faint">{words} words</span>
|
||||
{#if diary.dirty}<span class="warn">[+]</span>{/if}
|
||||
{#if workspace.dirty}<span class="warn">[+]</span>{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{#if isTouchDevice}
|
||||
<button onclick={() => runCommand('q')}>← list</button>
|
||||
{/if}
|
||||
{#if diary.editing}
|
||||
{#if workspace.editing}
|
||||
<button onclick={() => runCommand('w')}>:w</button>
|
||||
<button onclick={() => runCommand('upload')}>attach</button>
|
||||
<button onclick={() => runCommand('wq')}>done</button>
|
||||
{:else}
|
||||
<button onclick={() => runCommand('e')}>edit</button>
|
||||
{/if}
|
||||
<button class:on={entry.shared} onclick={() => runCommand(entry.shared ? 'unshare' : 'share')}>
|
||||
{entry.shared ? 'unshare' : 'share'}
|
||||
<button class:on={doc.shared} onclick={() => runCommand(doc.shared ? 'unshare' : 'share')}>
|
||||
{doc.shared ? 'unshare' : 'share'}
|
||||
</button>
|
||||
<button onclick={() => runCommand('d')}>delete</button>
|
||||
<button onclick={() => runCommand('q')}>close</button>
|
||||
@@ -56,12 +70,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if diary.editing}
|
||||
{#if workspace.editing}
|
||||
<input
|
||||
class="title"
|
||||
placeholder="title (optional)"
|
||||
bind:value={diary.draft.title}
|
||||
oninput={() => diary.touch()}
|
||||
placeholder="name (optional)"
|
||||
bind:value={workspace.draft.name}
|
||||
oninput={() => workspace.touch()}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
@@ -70,7 +84,7 @@
|
||||
}}
|
||||
/>
|
||||
<div class="body">
|
||||
{#key entry.id}
|
||||
{#key doc.id}
|
||||
{#await import('./Editor.svelte')}
|
||||
<div class="loading faint">loading editor…</div>
|
||||
{:then module}
|
||||
@@ -88,12 +102,13 @@
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="body reading">
|
||||
<article class="md">
|
||||
{#if entry.title.trim()}<h1 class="entrytitle">{entry.title}</h1>{/if}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||
<article class="md" onclick={follow}>
|
||||
{#if doc.name.trim()}<h1 class="entrytitle">{doc.name}</h1>{/if}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
|
||||
{@html html}
|
||||
{#if !entry.body.trim()}
|
||||
<p class="faint">this entry is empty — press <span class="accent">i</span> to write.</p>
|
||||
{#if !doc.body.trim()}
|
||||
<p class="faint">this document is empty — press <span class="accent">i</span> to write.</p>
|
||||
{/if}
|
||||
</article>
|
||||
</div>
|
||||
+19
-19
@@ -21,7 +21,7 @@
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { commands, hooks, runCommand } from './commands'
|
||||
import { embedSnippet } from './markdown'
|
||||
import { diary } from './store.svelte'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
let host = $state<HTMLDivElement>()
|
||||
let view: EditorView | undefined
|
||||
@@ -110,18 +110,18 @@
|
||||
const push = registers.pushText.bind(registers)
|
||||
registers.pushText = (name, operator, text, linewise, blockwise) => {
|
||||
push(name, operator, text, linewise, blockwise)
|
||||
if (!name && text && diary.clipboard) void diary.copy(text)
|
||||
if (!name && text && workspace.clipboard) void workspace.copy(text)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `?` is vim's search-backwards, but the diary advertises it as the help key
|
||||
* `?` is vim's search-backwards, but the workspace advertises it as the help key
|
||||
* everywhere else, so it opens the reference here too. `/` still searches.
|
||||
*/
|
||||
function bindHelpKey() {
|
||||
Vim.defineAction('diaryHelp', () => void runCommand('help'))
|
||||
Vim.defineAction('workspaceHelp', () => void runCommand('help'))
|
||||
for (const context of ['normal', 'visual'] as const) {
|
||||
Vim.mapCommand('?', 'action', 'diaryHelp', {}, { context })
|
||||
Vim.mapCommand('?', 'action', 'workspaceHelp', {}, { context })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,8 +145,8 @@
|
||||
|
||||
async function uploadFiles(files: File[]) {
|
||||
if (files.length === 0) return
|
||||
await diary.guard(async () => {
|
||||
const uploaded = await diary.upload(files)
|
||||
await workspace.guard(async () => {
|
||||
const uploaded = await workspace.upload(files)
|
||||
insertAtCursor(uploaded.map(embedSnippet).join('\n\n'))
|
||||
})
|
||||
}
|
||||
@@ -157,7 +157,7 @@
|
||||
mirrorUnnamedRegisterToClipboard()
|
||||
|
||||
const extensions = [
|
||||
...(diary.vimEnabled ? [vim({ status: false })] : []),
|
||||
...(workspace.vimEnabled ? [vim({ status: false })] : []),
|
||||
history(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
@@ -167,13 +167,13 @@
|
||||
markdown({ base: markdownLanguage, codeLanguages: [] }),
|
||||
syntaxHighlighting(markdownHighlight),
|
||||
EditorView.lineWrapping,
|
||||
placeholder('dear diary…'),
|
||||
placeholder('write…'),
|
||||
editorTheme,
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Mod-s',
|
||||
preventDefault: true,
|
||||
run: () => (void diary.guard(() => diary.save()), true),
|
||||
run: () => (void workspace.guard(() => workspace.save()), true),
|
||||
},
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
@@ -181,8 +181,8 @@
|
||||
]),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
diary.draft.body = update.state.doc.toString()
|
||||
diary.touch()
|
||||
workspace.draft.body = update.state.doc.toString()
|
||||
workspace.touch()
|
||||
}
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
@@ -204,7 +204,7 @@
|
||||
]
|
||||
|
||||
view = new EditorView({
|
||||
state: EditorState.create({ doc: diary.draft.body, extensions }),
|
||||
state: EditorState.create({ doc: workspace.draft.body, extensions }),
|
||||
parent: host!,
|
||||
})
|
||||
|
||||
@@ -212,21 +212,21 @@
|
||||
hooks.insertText = insertAtCursor
|
||||
view.focus()
|
||||
|
||||
const cm = diary.vimEnabled ? getCM(view) : null
|
||||
const cm = workspace.vimEnabled ? getCM(view) : null
|
||||
if (cm) {
|
||||
// Keep the status line's mode indicator in step with the editor.
|
||||
const emitter = cm as unknown as {
|
||||
on?: (event: string, handler: (e: { mode: string }) => void) => void
|
||||
}
|
||||
emitter.on?.('vim-mode-change', (event) => {
|
||||
diary.vimMode = event.mode
|
||||
workspace.vimMode = event.mode
|
||||
})
|
||||
|
||||
// A fresh entry starts in insert mode; an existing one starts in normal
|
||||
// mode, the way opening a file in vim does.
|
||||
if (diary.enterInsert) {
|
||||
diary.enterInsert = false
|
||||
Vim.handleKey(cm, 'i', 'diary')
|
||||
if (workspace.enterInsert) {
|
||||
workspace.enterInsert = false
|
||||
Vim.handleKey(cm, 'i', 'workspace')
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -236,7 +236,7 @@
|
||||
view = undefined
|
||||
hooks.focusEditor = () => {}
|
||||
hooks.insertText = () => {}
|
||||
diary.vimMode = 'normal'
|
||||
workspace.vimMode = 'normal'
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
+40
-12
@@ -1,26 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { commands, groups, type CommandSpec, type Group } from './commands'
|
||||
import { diary } from './store.svelte'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
const keys: [string, string][] = [
|
||||
['j / k · ↓ ↑', 'move down / up the entry list'],
|
||||
['gg / G', 'first / last entry'],
|
||||
['Enter · l', 'open the highlighted entry'],
|
||||
['o', 'new entry, straight into insert mode'],
|
||||
['i · a', 'edit the open entry'],
|
||||
['Esc · h · q', 'back out to the list'],
|
||||
['/', 'full-text search'],
|
||||
['j / k · ↓ ↑', 'move down / up the list'],
|
||||
['gg / G', 'first / last row'],
|
||||
['Enter · l', 'open a document, or enter a folder'],
|
||||
['h', 'up one level, or back out of a document'],
|
||||
['o', 'new document here, straight into insert mode'],
|
||||
['O', 'new folder here'],
|
||||
['i · a', 'edit the open document'],
|
||||
['Esc · q', 'back out to the list'],
|
||||
['/', 'full-text search, inside the current space'],
|
||||
['n', 'clear the search'],
|
||||
['s', 'toggle sharing for the entry'],
|
||||
['s', 'toggle sharing for the document'],
|
||||
['y', 'mint a new share link and copy it'],
|
||||
['x · dd', 'delete the entry (asks first)'],
|
||||
['x · dd', 'delete what is selected (asks first)'],
|
||||
[':', 'command line'],
|
||||
['Ctrl-S', 'save, from anywhere'],
|
||||
['?', 'this screen — in the editor too'],
|
||||
]
|
||||
|
||||
/* The board rebinds most keys, so it gets its own column rather than
|
||||
footnotes on the list keys. */
|
||||
const boardKeys: [string, string][] = [
|
||||
['h / l', 'to the list left / right'],
|
||||
['j / k', 'down / up the cards'],
|
||||
['J / K', 'move the card down / up its list'],
|
||||
['H / L', 'move the card to the list left / right'],
|
||||
['Enter', 'open the document the card links to'],
|
||||
['t · Space', 'tick the card off, or un-tick it'],
|
||||
['o', 'new card on this list'],
|
||||
['x', 'delete the card (asks first)'],
|
||||
['q · Esc', 'back to the tree'],
|
||||
]
|
||||
|
||||
const titles: Record<Group, string> = {
|
||||
entries: 'entries',
|
||||
tree: 'spaces & folders',
|
||||
board: 'boards',
|
||||
documents: 'documents',
|
||||
editing: 'writing',
|
||||
sharing: 'sharing & export',
|
||||
view: 'session',
|
||||
@@ -39,7 +57,7 @@
|
||||
<header>
|
||||
<span class="accent">:help</span>
|
||||
<span class="faint">everything is a key or a `:` command</span>
|
||||
<button onclick={() => (diary.overlay = 'none')}>esc</button>
|
||||
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
|
||||
</header>
|
||||
|
||||
<div class="cols">
|
||||
@@ -53,6 +71,16 @@
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="keys">
|
||||
<h2>board keys</h2>
|
||||
<dl>
|
||||
{#each boardKeys as [key, description] (key)}
|
||||
<dt>{key}</dt>
|
||||
<dd>{description}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{#each grouped as [group, specs] (group)}
|
||||
<section>
|
||||
<h2>{titles[group]}</h2>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ApiError } from './api'
|
||||
import { diary } from './store.svelte'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
let username = $state('')
|
||||
let password = $state('')
|
||||
@@ -17,7 +17,7 @@
|
||||
busy = true
|
||||
error = ''
|
||||
try {
|
||||
await diary.login(username, password)
|
||||
await workspace.login(username, password)
|
||||
} catch (e) {
|
||||
// The server answers a wrong password with a bare 401; the throttle's
|
||||
// "wait Ns" message is worth showing verbatim.
|
||||
@@ -37,10 +37,10 @@
|
||||
<main>
|
||||
<form onsubmit={submit}>
|
||||
<pre class="banner">{`╭──────────────────────────────────╮
|
||||
│ ~/diary │
|
||||
│ ~/workspace │
|
||||
╰──────────────────────────────────╯`}</pre>
|
||||
|
||||
<p class="faint">a private log. one user. one machine.</p>
|
||||
<p class="faint">a private workspace. one user. one machine.</p>
|
||||
|
||||
<label>
|
||||
<span class="accent">login:</span>
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
import { api } from './api'
|
||||
import { hooks } from './commands'
|
||||
import { embedSnippet } from './markdown'
|
||||
import { diary } from './store.svelte'
|
||||
import { workspace } from './store.svelte'
|
||||
import { formatBytes, formatStamp } from './util'
|
||||
|
||||
const usage = (entryIds: number[]) =>
|
||||
entryIds.length === 0 ? 'unattached' : entryIds.map((id) => `entry:${id}`).join(' ')
|
||||
const usage = (nodeIds: number[]) =>
|
||||
nodeIds.length === 0 ? 'unattached' : nodeIds.map((id) => `#${id}`).join(' ')
|
||||
|
||||
async function remove(id: string, filename: string, entryIds: number[]) {
|
||||
const used = entryIds.length === 1 ? '1 entry' : `${entryIds.length} entries`
|
||||
const warning = entryIds.length > 0 ? ` it is embedded in ${used}, which will break.` : ''
|
||||
async function remove(id: string, filename: string, nodeIds: number[]) {
|
||||
const used = nodeIds.length === 1 ? '1 document' : `${nodeIds.length} documents`
|
||||
const warning = nodeIds.length > 0 ? ` it is embedded in ${used}, which will break.` : ''
|
||||
if (!confirm(`delete ${filename}?${warning}`)) return
|
||||
await diary.guard(async () => {
|
||||
await workspace.guard(async () => {
|
||||
await api.deleteMedia(id)
|
||||
await diary.loadMedia()
|
||||
await workspace.loadMedia()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -22,12 +22,12 @@
|
||||
<div class="sheet">
|
||||
<header>
|
||||
<span class="accent">:media</span>
|
||||
<span class="faint">{diary.media.length} files</span>
|
||||
<button onclick={() => (diary.overlay = 'none')}>esc</button>
|
||||
<span class="faint">{workspace.media.length} files</span>
|
||||
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
|
||||
</header>
|
||||
|
||||
<ul>
|
||||
{#each diary.media as file (file.id)}
|
||||
{#each workspace.media as file (file.id)}
|
||||
<li>
|
||||
<div class="thumb">
|
||||
{#if file.mime.startsWith('image/')}
|
||||
@@ -41,21 +41,21 @@
|
||||
<span class="name">{file.filename}</span>
|
||||
<span class="faint">
|
||||
{formatBytes(file.size)} · {formatStamp(file.created_at)} ·
|
||||
{usage(file.entry_ids)}
|
||||
{usage(file.node_ids)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{#if diary.editing}
|
||||
{#if workspace.editing}
|
||||
<button
|
||||
onclick={() => {
|
||||
hooks.insertText(embedSnippet(file))
|
||||
diary.overlay = 'none'
|
||||
workspace.overlay = 'none'
|
||||
}}>insert</button
|
||||
>
|
||||
{/if}
|
||||
<button onclick={() => diary.copy(location.origin + file.url)}>copy url</button>
|
||||
<button onclick={() => remove(file.id, file.filename, file.entry_ids)}>rm</button>
|
||||
<button onclick={() => workspace.copy(location.origin + file.url)}>copy url</button>
|
||||
<button onclick={() => remove(file.id, file.filename, file.node_ids)}>rm</button>
|
||||
</div>
|
||||
</li>
|
||||
{:else}
|
||||
|
||||
+11
-11
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { api, type SharedEntry } from './api'
|
||||
import { api, type SharedDocument } from './api'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import { formatStamp, formatWeekday } from './util'
|
||||
|
||||
const { token, shareKey }: { token: string; shareKey: string } = $props()
|
||||
|
||||
let entry = $state<SharedEntry | null>(null)
|
||||
let doc = $state<SharedDocument | null>(null)
|
||||
let error = $state('')
|
||||
|
||||
$effect(() => {
|
||||
@@ -16,27 +16,27 @@
|
||||
api
|
||||
.readShared(token, shareKey)
|
||||
.then((data) => {
|
||||
entry = data
|
||||
document.title = data.title.trim() || `~/diary ${formatStamp(data.created_at)}`
|
||||
doc = data
|
||||
document.title = data.name.trim() || `~/workspace ${formatStamp(data.created_at)}`
|
||||
})
|
||||
.catch(() => (error = 'this link is not valid, or the entry is no longer shared.'))
|
||||
.catch(() => (error = 'this link is not valid, or the document is no longer shared.'))
|
||||
})
|
||||
</script>
|
||||
|
||||
<main>
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{:else if entry}
|
||||
{:else if doc}
|
||||
<article class="md">
|
||||
<div class="meta faint">
|
||||
{formatStamp(entry.created_at)} {formatWeekday(entry.created_at)}
|
||||
<span class="tag">shared entry</span>
|
||||
{formatStamp(doc.created_at)} {formatWeekday(doc.created_at)}
|
||||
<span class="tag">shared document</span>
|
||||
</div>
|
||||
{#if entry.title.trim()}<h1>{entry.title}</h1>{/if}
|
||||
{#if doc.name.trim()}<h1>{doc.name}</h1>{/if}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
|
||||
{@html renderMarkdown(entry.body, { token, key: shareKey })}
|
||||
{@html renderMarkdown(doc.body, { token, key: shareKey })}
|
||||
</article>
|
||||
<footer class="faint">— written in ~/diary</footer>
|
||||
<footer class="faint">— written in ~/workspace</footer>
|
||||
{:else}
|
||||
<p class="faint">loading…</p>
|
||||
{/if}
|
||||
|
||||
+35
-25
@@ -1,75 +1,85 @@
|
||||
<script lang="ts">
|
||||
import type { EntrySummary } from './api'
|
||||
import type { NodeSummary } from './api'
|
||||
import { hooks, runCommand } from './commands'
|
||||
import { diary } from './store.svelte'
|
||||
import SpaceBar from './SpaceBar.svelte'
|
||||
import { workspace } from './store.svelte'
|
||||
import { formatDay, formatTime, formatWeekday } from './util'
|
||||
|
||||
let list = $state<HTMLUListElement>()
|
||||
|
||||
// Keep the cursor line visible while navigating with j/k.
|
||||
$effect(() => {
|
||||
const index = diary.cursor
|
||||
const index = workspace.cursor
|
||||
const node = list?.querySelector<HTMLElement>(`[data-index="${index}"]`)
|
||||
node?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
|
||||
function label(entry: EntrySummary) {
|
||||
return entry.title.trim() || entry.excerpt.trim() || '(empty)'
|
||||
function label(node: NodeSummary) {
|
||||
return node.name.trim() || node.excerpt.trim() || node.slug
|
||||
}
|
||||
|
||||
/* A folder is listed by what is in it; a document by when it was written. */
|
||||
const aside = (node: NodeSummary) =>
|
||||
node.kind === 'document' ? formatTime(node.created_at) : `${node.child_count}`
|
||||
|
||||
function pick(index: number) {
|
||||
diary.cursor = index
|
||||
void diary.guard(() => diary.openSelected())
|
||||
workspace.cursor = index
|
||||
void workspace.guard(() => workspace.openSelected())
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="head">
|
||||
<span class="accent">~/diary</span>
|
||||
<span class="accent">~/{workspace.space?.slug ?? 'diary'}</span>
|
||||
<span class="faint count">
|
||||
{diary.entries.length} {diary.entries.length === 1 ? 'entry' : 'entries'}
|
||||
{workspace.nodes.length} {workspace.nodes.length === 1 ? 'item' : 'items'}
|
||||
</span>
|
||||
<span class="tools">
|
||||
<button title="new entry (o)" onclick={() => runCommand('new')}>+</button>
|
||||
<button title="new document (o)" onclick={() => runCommand('new')}>+</button>
|
||||
<button title="new folder (O)" onclick={() => hooks.openCommandLine(':mkdir ')}>[+]</button>
|
||||
<button title="search (/)" onclick={() => hooks.openCommandLine('/')}>/</button>
|
||||
<button title="command line (:)" onclick={() => hooks.openCommandLine(':')}>:</button>
|
||||
<button title="help (?)" onclick={() => runCommand('help')}>?</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if diary.query}
|
||||
<SpaceBar />
|
||||
|
||||
{#if workspace.query}
|
||||
<div class="filter">
|
||||
<span class="faint">/</span>{diary.query}
|
||||
<button title="clear search" onclick={() => diary.guard(() => diary.search(''))}>esc</button>
|
||||
<span class="faint">/</span>{workspace.query}
|
||||
<button title="clear search" onclick={() => workspace.guard(() => workspace.search(''))}>esc</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ul bind:this={list}>
|
||||
{#each diary.entries as entry, index (entry.id)}
|
||||
{@const previous = diary.entries[index - 1]}
|
||||
{#if !diary.query && (!previous || formatDay(previous.created_at) !== formatDay(entry.created_at))}
|
||||
{#each workspace.nodes as node, index (node.id)}
|
||||
{@const previous = workspace.nodes[index - 1]}
|
||||
<!-- Day headings only make sense in a list that is a journal: documents,
|
||||
in date order, not a search and not a folder listing. -->
|
||||
{#if !workspace.query && node.kind === 'document' && (!previous || previous.kind !== 'document' || formatDay(previous.created_at) !== formatDay(node.created_at))}
|
||||
<li class="daybreak">
|
||||
<span>{formatDay(entry.created_at)}</span>
|
||||
<span class="faint">{formatWeekday(entry.created_at)}</span>
|
||||
<span>{formatDay(node.created_at)}</span>
|
||||
<span class="faint">{formatWeekday(node.created_at)}</span>
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
<button
|
||||
class="row"
|
||||
class:active={index === diary.cursor}
|
||||
class:open={diary.open?.id === entry.id}
|
||||
class:active={index === workspace.cursor}
|
||||
class:open={workspace.open?.id === node.id}
|
||||
data-index={index}
|
||||
onclick={() => pick(index)}
|
||||
>
|
||||
<span class="caret">{index === diary.cursor ? '>' : ' '}</span>
|
||||
<span class="time">{formatTime(entry.created_at)}</span>
|
||||
<span class="title">{label(entry)}</span>
|
||||
{#if entry.shared}<span class="shared" title="shared">◉</span>{/if}
|
||||
<span class="caret">{index === workspace.cursor ? '>' : ' '}</span>
|
||||
<span class="time">{aside(node)}</span>
|
||||
<span class="title">{node.kind === 'document' ? '' : '/'}{label(node)}</span>
|
||||
{#if node.shared}<span class="shared" title="shared">◉</span>{/if}
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="empty faint">
|
||||
{diary.query ? 'no matches' : 'no entries yet — press o to write one'}
|
||||
{workspace.query ? 'no matches' : 'nothing here yet — o writes, O makes a folder'}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* The two lines above the list: which space is open, and where inside it the
|
||||
* list currently is. Both are clickable, because a tree is the one thing that
|
||||
* is genuinely faster to navigate with a pointer than with `:cd`.
|
||||
*/
|
||||
import { hooks } from './commands'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
// The space is the first crumb, and it is already named by the bar above.
|
||||
const crumbs = $derived(workspace.path.slice(1))
|
||||
</script>
|
||||
|
||||
<div class="spaces">
|
||||
{#each workspace.spaces as space (space.id)}
|
||||
<button
|
||||
class="space"
|
||||
class:on={space.id === workspace.spaceId}
|
||||
onclick={() => workspace.guard(() => workspace.enterSpace(space.id))}
|
||||
>
|
||||
{space.slug}
|
||||
</button>
|
||||
{/each}
|
||||
<button class="space add" title="new space (:space! <name>)" onclick={() => hooks.openCommandLine(':space! ')}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if crumbs.length > 0}
|
||||
<div class="crumbs">
|
||||
<button onclick={() => workspace.spaceId !== null && workspace.guard(() => workspace.goTo(workspace.spaceId!))}>
|
||||
/
|
||||
</button>
|
||||
{#each crumbs as crumb, index (crumb.id)}
|
||||
<span class="faint">/</span>
|
||||
<button
|
||||
class:last={index === crumbs.length - 1}
|
||||
onclick={() => workspace.guard(() => workspace.goTo(crumb.id))}
|
||||
>
|
||||
{crumb.slug}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.spaces,
|
||||
.crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex: none;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.crumbs { font-size: 12px; }
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--fg-dim);
|
||||
padding: 0 6px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
button:hover { color: var(--fg); background: var(--bg-lift); }
|
||||
.space.on { color: var(--bg); background: var(--accent-dim); font-weight: 700; }
|
||||
.add { color: var(--fg-faint); }
|
||||
.crumbs .last { color: var(--accent); }
|
||||
|
||||
@media (pointer: coarse) {
|
||||
button { padding: 4px 10px; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { diary } from './store.svelte'
|
||||
import { workspace } from './store.svelte'
|
||||
import { isTouchDevice } from './util'
|
||||
|
||||
const mode = $derived(
|
||||
diary.editing ? (diary.vimEnabled ? diary.vimMode.toUpperCase() : 'EDIT') : 'BROWSE',
|
||||
workspace.editing ? (workspace.vimEnabled ? workspace.vimMode.toUpperCase() : 'EDIT') : 'BROWSE',
|
||||
)
|
||||
|
||||
// The path is the file name here: work/einarbeiten/01-was-ist-eebus.
|
||||
const file = $derived(
|
||||
diary.open ? `entry:${diary.open.id}${diary.dirty ? ' [+]' : ''}` : '[no entry]',
|
||||
workspace.open
|
||||
? `${workspace.here}/${workspace.open.slug}${workspace.dirty ? ' [+]' : ''}`
|
||||
: `${workspace.here || '~'}/`,
|
||||
)
|
||||
|
||||
const lines = $derived(diary.editing ? diary.draft.body.split('\n').length : null)
|
||||
const lines = $derived(workspace.editing ? workspace.draft.body.split('\n').length : null)
|
||||
</script>
|
||||
|
||||
<footer>
|
||||
@@ -19,15 +22,15 @@
|
||||
</span>
|
||||
<span class="file">{file}</span>
|
||||
|
||||
<span class="msg" class:error={diary.flash?.kind === 'error'}>
|
||||
{diary.flash?.text ?? ''}
|
||||
<span class="msg" class:error={workspace.flash?.kind === 'error'}>
|
||||
{workspace.flash?.text ?? ''}
|
||||
</span>
|
||||
|
||||
{#if lines !== null}<span class="faint">{lines}L</span>{/if}
|
||||
<span class="faint hint">
|
||||
{isTouchDevice ? 'tap : for commands' : '? help · : command · / search'}
|
||||
</span>
|
||||
<span class="user accent">{diary.user}</span>
|
||||
<span class="user accent">{workspace.user}</span>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
|
||||
+146
-25
@@ -1,32 +1,81 @@
|
||||
export interface EntrySummary {
|
||||
/** What a node is: a top-level space, a folder inside one, or a document. */
|
||||
export type NodeKind = 'space' | 'folder' | 'document'
|
||||
|
||||
export interface NodeSummary {
|
||||
id: number
|
||||
title: string
|
||||
parent_id: number | null
|
||||
kind: NodeKind
|
||||
name: string
|
||||
slug: string
|
||||
excerpt: string
|
||||
position: number
|
||||
has_board: boolean
|
||||
child_count: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
shared: boolean
|
||||
share_token: string | null
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
/** One step of a node's path, the space first and the node itself last. */
|
||||
export interface Crumb {
|
||||
id: number
|
||||
title: string
|
||||
kind: NodeKind
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface WorkspaceNode extends NodeSummary {
|
||||
body: string
|
||||
excerpt: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
shared: boolean
|
||||
share_token: string | null
|
||||
path: Crumb[]
|
||||
}
|
||||
|
||||
export interface SharedEntry {
|
||||
title: string
|
||||
export interface SharedDocument {
|
||||
name: string
|
||||
body: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
token: string
|
||||
}
|
||||
|
||||
/** What an import did, as `POST /api/nodes/{id}/import` reports it. */
|
||||
export interface ImportReport {
|
||||
folders: number
|
||||
documents: number
|
||||
files: number
|
||||
links: number
|
||||
skipped: string[]
|
||||
}
|
||||
|
||||
/** A card on a space's board. `node_id` is the document it is about, if any. */
|
||||
export interface Card {
|
||||
id: number
|
||||
list_id: number
|
||||
title: string
|
||||
body: string
|
||||
node_id: number | null
|
||||
node_name: string | null
|
||||
due_at: number | null
|
||||
done_at: number | null
|
||||
position: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export interface BoardList {
|
||||
id: number
|
||||
name: string
|
||||
position: number
|
||||
cards: Card[]
|
||||
}
|
||||
|
||||
/** One space's board. There is one per space; the diary has none. */
|
||||
export interface Board {
|
||||
space_id: number
|
||||
space_name: string
|
||||
lists: BoardList[]
|
||||
}
|
||||
|
||||
export interface MediaFile {
|
||||
id: string
|
||||
filename: string
|
||||
@@ -34,8 +83,8 @@ export interface MediaFile {
|
||||
size: number
|
||||
created_at: number
|
||||
url: string
|
||||
/** Every entry that embeds this file — a file may be used by several. */
|
||||
entry_ids: number[]
|
||||
/** Every document that embeds this file — a file may be used by several. */
|
||||
node_ids: number[]
|
||||
}
|
||||
|
||||
/** The Proton Drive mirror, as `/api/backup` reports it. */
|
||||
@@ -92,24 +141,96 @@ export const api = {
|
||||
|
||||
logout: () => request<{ ok: true }>('/logout', { method: 'POST' }),
|
||||
|
||||
listEntries: (q = '') =>
|
||||
request<EntrySummary[]>(`/entries${q ? `?q=${encodeURIComponent(q)}` : ''}`),
|
||||
listSpaces: () => request<NodeSummary[]>('/spaces'),
|
||||
|
||||
getEntry: (id: number) => request<Entry>(`/entries/${id}`),
|
||||
createSpace: (name: string, has_board = true) =>
|
||||
request<WorkspaceNode>('/spaces', { method: 'POST', body: body({ name, has_board }) }),
|
||||
|
||||
createEntry: (input: { title: string; body: string; created_at?: number }) =>
|
||||
request<Entry>('/entries', { method: 'POST', body: body(input) }),
|
||||
/** The children of a node, or — with `q` — a search across the whole tree. */
|
||||
listNodes: (params: { parent?: number; q?: string; space?: number }) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params.q) query.set('q', params.q)
|
||||
else if (params.parent !== undefined) query.set('parent', String(params.parent))
|
||||
if (params.q && params.space !== undefined) query.set('space', String(params.space))
|
||||
return request<NodeSummary[]>(`/nodes?${query}`)
|
||||
},
|
||||
|
||||
updateEntry: (id: number, input: { title: string; body: string; created_at?: number }) =>
|
||||
request<Entry>(`/entries/${id}`, { method: 'PUT', body: body(input) }),
|
||||
getNode: (id: number) => request<WorkspaceNode>(`/nodes/${id}`),
|
||||
|
||||
deleteEntry: (id: number) => request<{ ok: true }>(`/entries/${id}`, { method: 'DELETE' }),
|
||||
/** `work/einarbeiten/01-was-ist-eebus` — the node behind a `/n/...` address. */
|
||||
resolvePath: (path: string) => request<WorkspaceNode>(`/resolve?path=${encodeURIComponent(path)}`),
|
||||
|
||||
/** Publishes the entry, or mints a fresh key for one already published. */
|
||||
/**
|
||||
* Upload a folder into a node. The relative path is passed as each part's
|
||||
* file name, which is the only place a multipart body has to put it, and is
|
||||
* what the server rebuilds the directory tree from.
|
||||
*/
|
||||
importFolder: (id: number, files: File[]) => {
|
||||
const form = new FormData()
|
||||
for (const file of files) form.append('file', file, file.webkitRelativePath || file.name)
|
||||
return request<ImportReport>(`/nodes/${id}/import`, { method: 'POST', body: form })
|
||||
},
|
||||
|
||||
createNode: (input: {
|
||||
parent_id: number
|
||||
kind?: NodeKind
|
||||
name?: string
|
||||
body?: string
|
||||
created_at?: number
|
||||
}) => request<WorkspaceNode>('/nodes', { method: 'POST', body: body(input) }),
|
||||
|
||||
updateNode: (
|
||||
id: number,
|
||||
input: { name: string; body: string; created_at?: number; has_board?: boolean },
|
||||
) => request<WorkspaceNode>(`/nodes/${id}`, { method: 'PUT', body: body(input) }),
|
||||
|
||||
deleteNode: (id: number) =>
|
||||
request<{ ok: true; removed: number }>(`/nodes/${id}`, { method: 'DELETE' }),
|
||||
|
||||
moveNode: (id: number, parent_id: number, position?: number) =>
|
||||
request<WorkspaceNode>(`/nodes/${id}/move`, { method: 'POST', body: body({ parent_id, position }) }),
|
||||
|
||||
/** Publishes the document, or mints a fresh key for one already published. */
|
||||
share: (id: number) =>
|
||||
request<{ token: string; key: string; path: string }>(`/entries/${id}/share`, { method: 'POST' }),
|
||||
request<{ token: string; key: string; path: string }>(`/nodes/${id}/share`, { method: 'POST' }),
|
||||
|
||||
unshare: (id: number) => request<{ ok: true }>(`/entries/${id}/share`, { method: 'DELETE' }),
|
||||
unshare: (id: number) => request<{ ok: true }>(`/nodes/${id}/share`, { method: 'DELETE' }),
|
||||
|
||||
/** The board of a space. Fails for a space that has none, such as the diary. */
|
||||
board: (spaceId: number) => request<Board>(`/spaces/${spaceId}/board`),
|
||||
|
||||
createList: (spaceId: number, name: string) =>
|
||||
request<Board>(`/spaces/${spaceId}/lists`, { method: 'POST', body: body({ name }) }),
|
||||
|
||||
renameList: (listId: number, name: string) =>
|
||||
request<Board>(`/lists/${listId}`, { method: 'PUT', body: body({ name }) }),
|
||||
|
||||
deleteList: (listId: number) => request<Board>(`/lists/${listId}`, { method: 'DELETE' }),
|
||||
|
||||
createCard: (
|
||||
listId: number,
|
||||
input: { title: string; body?: string; node_id?: number | null; due_at?: number | null },
|
||||
) => request<Card>(`/lists/${listId}/cards`, { method: 'POST', body: body(input) }),
|
||||
|
||||
/*
|
||||
* Only what changed is sent. `due_at: null` clears the date while leaving it
|
||||
* out keeps it, which is why the server reads those fields as double options.
|
||||
*/
|
||||
updateCard: (
|
||||
id: number,
|
||||
patch: {
|
||||
title?: string
|
||||
body?: string
|
||||
node_id?: number | null
|
||||
due_at?: number | null
|
||||
done?: boolean
|
||||
},
|
||||
) => request<Card>(`/cards/${id}`, { method: 'PUT', body: body(patch) }),
|
||||
|
||||
deleteCard: (id: number) => request<{ ok: true }>(`/cards/${id}`, { method: 'DELETE' }),
|
||||
|
||||
moveCard: (id: number, list_id: number, position?: number) =>
|
||||
request<Card>(`/cards/${id}/move`, { method: 'POST', body: body({ list_id, position }) }),
|
||||
|
||||
listMedia: () => request<MediaFile[]>('/media'),
|
||||
|
||||
@@ -126,5 +247,5 @@ export const api = {
|
||||
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
|
||||
|
||||
readShared: (token: string, key: string) =>
|
||||
request<SharedEntry>(`/share/${encodeURIComponent(token)}/${encodeURIComponent(key)}`),
|
||||
request<SharedDocument>(`/share/${encodeURIComponent(token)}/${encodeURIComponent(key)}`),
|
||||
}
|
||||
+364
-156
@@ -1,10 +1,12 @@
|
||||
import { api, type BackupStatus } from './api'
|
||||
import { diary, type Theme } from './store.svelte'
|
||||
import { workspace, type Theme } from './store.svelte'
|
||||
import { formatDay, parseDay } from './util'
|
||||
|
||||
/** Filled in by App.svelte so ex-commands can reach the DOM-bound bits. */
|
||||
export const hooks = {
|
||||
pickFiles: () => {},
|
||||
pickFolder: () => {},
|
||||
pickArchive: () => {},
|
||||
focusEditor: () => {},
|
||||
focusList: () => {},
|
||||
insertText: (_text: string) => {},
|
||||
@@ -12,7 +14,7 @@ export const hooks = {
|
||||
}
|
||||
|
||||
/** Help groups the command list under these headings, in this order. */
|
||||
export const groups = ['entries', 'editing', 'sharing', 'view'] as const
|
||||
export const groups = ['tree', 'board', 'documents', 'editing', 'sharing', 'view'] as const
|
||||
export type Group = (typeof groups)[number]
|
||||
|
||||
export interface CommandSpec {
|
||||
@@ -27,28 +29,37 @@ export interface CommandSpec {
|
||||
}
|
||||
|
||||
const requireOpen = () => {
|
||||
if (!diary.open) {
|
||||
diary.say('no entry open', 'error')
|
||||
if (!workspace.open) {
|
||||
workspace.say('no document open', 'error')
|
||||
return null
|
||||
}
|
||||
return diary.open
|
||||
return workspace.open
|
||||
}
|
||||
|
||||
/** Move the cursor and follow it, so `:next` reads as well as `j` then `Enter`. */
|
||||
const jumpTo = async (index: number) => {
|
||||
if (diary.entries.length === 0) return diary.say('no entries', 'error')
|
||||
diary.cursor = Math.min(Math.max(index, 0), diary.entries.length - 1)
|
||||
await diary.guard(() => diary.openSelected())
|
||||
if (workspace.nodes.length === 0) return workspace.say('nothing here', 'error')
|
||||
workspace.cursor = Math.min(Math.max(index, 0), workspace.nodes.length - 1)
|
||||
await workspace.guard(() => workspace.openSelected())
|
||||
}
|
||||
|
||||
/** A folder among the children currently listed, by slug or by name. */
|
||||
const findFolder = (target: string) => {
|
||||
const wanted = target.toLowerCase()
|
||||
return workspace.nodes.find(
|
||||
(node) =>
|
||||
node.kind !== 'document' && (node.slug === wanted || node.name.toLowerCase() === wanted),
|
||||
)
|
||||
}
|
||||
|
||||
const themes: Theme[] = ['mocha', 'green', 'amber', 'ice']
|
||||
|
||||
const setTheme = (name: string) => {
|
||||
if (!themes.includes(name as Theme)) {
|
||||
return diary.say(`unknown theme: ${name} — try ${themes.join(', ')}`, 'error')
|
||||
return workspace.say(`unknown theme: ${name} — try ${themes.join(', ')}`, 'error')
|
||||
}
|
||||
diary.setTheme(name as Theme)
|
||||
diary.say(`theme ${name}`)
|
||||
workspace.setTheme(name as Theme)
|
||||
workspace.say(`theme ${name}`)
|
||||
}
|
||||
|
||||
/** `3 minutes ago`, or `never`. Backups are read at a glance, not to the second. */
|
||||
@@ -63,14 +74,14 @@ const ago = (at: number | null) => {
|
||||
|
||||
const describeBackup = (status: BackupStatus) => {
|
||||
if (!status.configured) {
|
||||
return diary.say('proton drive backups are off — run `narl-diary proton-login` on the server', 'error')
|
||||
return workspace.say('proton drive backups are off — run `narl-workspace proton-login` on the server', 'error')
|
||||
}
|
||||
if (status.last_error) {
|
||||
return diary.say(`backup failed: ${status.last_error}`, 'error')
|
||||
return workspace.say(`backup failed: ${status.last_error}`, 'error')
|
||||
}
|
||||
const where = status.device ? `${status.device} · ` : ''
|
||||
const state = status.running ? 'running now' : status.pending ? 'changes waiting' : 'up to date'
|
||||
diary.say(`${where}${state} · last ${ago(status.last_success_at)}`)
|
||||
workspace.say(`${where}${state} · last ${ago(status.last_success_at)}`)
|
||||
}
|
||||
|
||||
/** A filename that survives a download folder: `2026-09-05-first-light.md`. */
|
||||
@@ -84,9 +95,9 @@ export const commands: CommandSpec[] = [
|
||||
name: 'write',
|
||||
aliases: ['w'],
|
||||
group: 'editing',
|
||||
help: 'save the open entry',
|
||||
run: () => diary.guard(() => diary.save()),
|
||||
bang: { help: 'same as :w — the bang is accepted out of habit', run: () => diary.guard(() => diary.save()) },
|
||||
help: 'save the open document',
|
||||
run: () => workspace.guard(() => workspace.save()),
|
||||
bang: { help: 'same as :w — the bang is accepted out of habit', run: () => workspace.guard(() => workspace.save()) },
|
||||
},
|
||||
{
|
||||
name: 'wq',
|
||||
@@ -94,15 +105,15 @@ export const commands: CommandSpec[] = [
|
||||
group: 'editing',
|
||||
help: 'save and leave insert/editor mode',
|
||||
run: async () => {
|
||||
await diary.guard(() => diary.save())
|
||||
diary.editing = false
|
||||
await workspace.guard(() => workspace.save())
|
||||
workspace.editing = false
|
||||
hooks.focusList()
|
||||
},
|
||||
bang: {
|
||||
help: 'same as :wq — the bang is accepted out of habit',
|
||||
run: async () => {
|
||||
await diary.guard(() => diary.save())
|
||||
diary.editing = false
|
||||
await workspace.guard(() => workspace.save())
|
||||
workspace.editing = false
|
||||
hooks.focusList()
|
||||
},
|
||||
},
|
||||
@@ -111,48 +122,212 @@ export const commands: CommandSpec[] = [
|
||||
name: 'quit',
|
||||
aliases: ['q'],
|
||||
group: 'editing',
|
||||
help: 'close the editor, or the entry when already reading',
|
||||
help: 'close the editor, or the document when already reading',
|
||||
run: () => {
|
||||
if (diary.dirty) {
|
||||
diary.say('unsaved changes — :w to write, :q! to discard', 'error')
|
||||
if (workspace.dirty) {
|
||||
workspace.say('unsaved changes — :w to write, :q! to discard', 'error')
|
||||
return
|
||||
}
|
||||
if (diary.editing) diary.editing = false
|
||||
if (workspace.editing) workspace.editing = false
|
||||
else {
|
||||
diary.open = null
|
||||
diary.pane = 'list'
|
||||
workspace.open = null
|
||||
workspace.pane = 'list'
|
||||
}
|
||||
hooks.focusList()
|
||||
},
|
||||
bang: {
|
||||
help: 'discard changes and close the editor',
|
||||
run: async () => {
|
||||
const open = diary.open
|
||||
diary.editing = false
|
||||
diary.dirty = false
|
||||
const open = workspace.open
|
||||
workspace.editing = false
|
||||
workspace.dirty = false
|
||||
if (open) {
|
||||
diary.dropStash(open.id)
|
||||
await diary.guard(() => diary.openEntry(open.id))
|
||||
workspace.dropStash(open.id)
|
||||
await workspace.guard(() => workspace.openNode(open.id))
|
||||
}
|
||||
hooks.focusList()
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'space',
|
||||
aliases: ['sp'],
|
||||
args: '[name]',
|
||||
group: 'tree',
|
||||
help: 'switch to a space, or list them',
|
||||
run: async (arg) => {
|
||||
const wanted = arg.trim().toLowerCase()
|
||||
if (!wanted) {
|
||||
return workspace.say(workspace.spaces.map((space) => space.slug).join(' · ') || 'no spaces')
|
||||
}
|
||||
const space = workspace.spaces.find(
|
||||
(candidate) => candidate.slug === wanted || candidate.name.toLowerCase() === wanted,
|
||||
)
|
||||
if (!space) return workspace.say(`no such space: ${wanted} — :space! ${wanted} makes it`, 'error')
|
||||
await workspace.guard(() => workspace.enterSpace(space.id))
|
||||
},
|
||||
bang: {
|
||||
help: 'create a space with that name and switch to it',
|
||||
run: async (arg) => {
|
||||
if (!arg.trim()) return workspace.say('usage: :space! <name>', 'error')
|
||||
await workspace.guard(() => workspace.createSpace(arg.trim()))
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mkdir',
|
||||
args: '<name>',
|
||||
group: 'tree',
|
||||
help: 'make a folder here',
|
||||
run: async (arg) => {
|
||||
if (!arg.trim()) return workspace.say('usage: :mkdir <name>', 'error')
|
||||
await workspace.guard(() => workspace.createFolder(arg.trim()))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'cd',
|
||||
args: '<name|..|/>',
|
||||
group: 'tree',
|
||||
help: 'enter a folder, .. goes up, / back to the space',
|
||||
run: async (arg) => {
|
||||
const target = arg.trim()
|
||||
await workspace.guard(async () => {
|
||||
if (!target || target === '..') return workspace.up()
|
||||
if (target === '/') {
|
||||
const space = workspace.spaceId
|
||||
return space === null ? undefined : workspace.goTo(space)
|
||||
}
|
||||
const folder = findFolder(target)
|
||||
if (!folder) return workspace.say(`no such folder here: ${target}`, 'error')
|
||||
await workspace.goTo(folder.id)
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'import',
|
||||
aliases: ['imp'],
|
||||
group: 'tree',
|
||||
help: 'upload a folder into this one — markdown becomes documents, links follow',
|
||||
run: () => hooks.pickFolder(),
|
||||
bang: {
|
||||
help: 'import a .zip of a folder instead of the folder itself',
|
||||
run: () => hooks.pickArchive(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'board',
|
||||
aliases: ['b'],
|
||||
group: 'board',
|
||||
help: "show this space's board, or go back to the tree",
|
||||
run: () => workspace.guard(() => workspace.toggleBoardView()),
|
||||
},
|
||||
{
|
||||
name: 'card',
|
||||
aliases: ['c'],
|
||||
args: '<title>',
|
||||
group: 'board',
|
||||
help: 'add a card to the list under the cursor',
|
||||
run: async (arg) => {
|
||||
if (!arg.trim()) return workspace.say('usage: :card <title>', 'error')
|
||||
if (workspace.view !== 'board') await workspace.guard(() => workspace.openBoard())
|
||||
if (workspace.view !== 'board') return
|
||||
await workspace.guard(() => workspace.createCard(arg.trim()))
|
||||
},
|
||||
bang: {
|
||||
help: 'add a card linked to the open document — Enter on it opens the document',
|
||||
run: async (arg) => {
|
||||
const node = workspace.open ?? workspace.selected
|
||||
if (!node || node.kind !== 'document') {
|
||||
return workspace.say('no document to link — open one first', 'error')
|
||||
}
|
||||
const title = arg.trim() || node.name.trim() || node.slug
|
||||
if (workspace.view !== 'board') await workspace.guard(() => workspace.openBoard())
|
||||
if (workspace.view !== 'board') return
|
||||
await workspace.guard(() => workspace.createCard(title, node.id))
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'due',
|
||||
args: '<yyyy-mm-dd|->',
|
||||
group: 'board',
|
||||
help: 'set the due date of the selected card, - clears it',
|
||||
run: async (arg) => {
|
||||
if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error')
|
||||
const target = arg.trim()
|
||||
if (target === '-' || target === '') {
|
||||
return workspace.guard(() => workspace.patchCard({ due_at: null }))
|
||||
}
|
||||
const at = parseDay(target)
|
||||
if (at === null) return workspace.say('usage: :due 2026-09-30', 'error')
|
||||
await workspace.guard(() => workspace.patchCard({ due_at: at }))
|
||||
workspace.say(`due ${formatDay(at)}`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'done',
|
||||
group: 'board',
|
||||
help: 'tick the selected card off, or un-tick it',
|
||||
run: () => {
|
||||
if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error')
|
||||
return workspace.guard(() => workspace.toggleDone())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
args: '<name>',
|
||||
group: 'board',
|
||||
help: 'add a column to the board',
|
||||
run: async (arg) => {
|
||||
if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error')
|
||||
if (!arg.trim()) return workspace.say('usage: :list <name>', 'error')
|
||||
await workspace.guard(() => workspace.createList(arg.trim()))
|
||||
},
|
||||
bang: {
|
||||
help: 'delete the column under the cursor and every card on it',
|
||||
run: async () => {
|
||||
if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error')
|
||||
const list = workspace.list
|
||||
if (!list) return workspace.say('no list selected', 'error')
|
||||
if (!confirm(`delete the list "${list.name}" and its ${list.cards.length} cards?`)) return
|
||||
await workspace.guard(() => workspace.deleteList())
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'move',
|
||||
aliases: ['mv'],
|
||||
args: '<folder|..>',
|
||||
group: 'tree',
|
||||
help: 'move the selected node into a folder here, or up one level',
|
||||
run: async (arg) => {
|
||||
const node = workspace.open ?? workspace.selected
|
||||
if (!node) return workspace.say('nothing selected', 'error')
|
||||
const target = arg.trim()
|
||||
if (!target) return workspace.say('usage: :mv <folder|..>', 'error')
|
||||
|
||||
const destination =
|
||||
target === '..' ? workspace.path.at(-2)?.id : findFolder(target)?.id
|
||||
if (destination === undefined) return workspace.say(`no such folder: ${target}`, 'error')
|
||||
if (destination === node.id) return workspace.say('a folder cannot hold itself', 'error')
|
||||
await workspace.guard(() => workspace.moveNode(node.id, destination))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'new',
|
||||
aliases: ['n', 'o'],
|
||||
args: '[yyyy-mm-dd]',
|
||||
group: 'entries',
|
||||
help: 'start a new entry, optionally dated',
|
||||
group: 'documents',
|
||||
help: 'start a new document here, optionally dated',
|
||||
run: async (arg) => {
|
||||
await diary.guard(async () => {
|
||||
await diary.createEntry()
|
||||
await workspace.guard(async () => {
|
||||
await workspace.createDocument()
|
||||
if (arg) {
|
||||
const at = parseDay(arg)
|
||||
if (at === null) return diary.say(`not a date: ${arg}`, 'error')
|
||||
diary.draft.created_at = at
|
||||
diary.dirty = true
|
||||
await diary.save()
|
||||
if (at === null) return workspace.say(`not a date: ${arg}`, 'error')
|
||||
workspace.draft.created_at = at
|
||||
workspace.dirty = true
|
||||
await workspace.save()
|
||||
}
|
||||
})
|
||||
hooks.focusEditor()
|
||||
@@ -161,17 +336,25 @@ export const commands: CommandSpec[] = [
|
||||
{
|
||||
name: 'today',
|
||||
aliases: ['t'],
|
||||
group: 'entries',
|
||||
help: "open today's entry, starting one if there is none yet",
|
||||
group: 'documents',
|
||||
help: "open today's diary entry, starting one if there is none yet",
|
||||
run: async () => {
|
||||
await diary.guard(async () => {
|
||||
if (diary.query) await diary.search('')
|
||||
await workspace.guard(async () => {
|
||||
// The diary is one space among several now, so :today goes there first
|
||||
// rather than writing the day into whatever folder was being browsed.
|
||||
const home = workspace.spaces.find((space) => space.slug === 'diary')
|
||||
if (home && workspace.parentId !== home.id) await workspace.enterSpace(home.id)
|
||||
else if (workspace.query) await workspace.search('')
|
||||
|
||||
const midnight = new Date()
|
||||
midnight.setHours(0, 0, 0, 0)
|
||||
const from = Math.floor(midnight.getTime() / 1000)
|
||||
const entry = diary.entries.find((e) => e.created_at >= from && e.created_at < from + 86400)
|
||||
if (entry) await diary.openEntry(entry.id, true)
|
||||
else await diary.createEntry()
|
||||
const today = workspace.nodes.find(
|
||||
(node) =>
|
||||
node.kind === 'document' && node.created_at >= from && node.created_at < from + 86400,
|
||||
)
|
||||
if (today) await workspace.openNode(today.id, true)
|
||||
else await workspace.createDocument()
|
||||
})
|
||||
hooks.focusEditor()
|
||||
},
|
||||
@@ -181,106 +364,123 @@ export const commands: CommandSpec[] = [
|
||||
aliases: ['e'],
|
||||
args: '[id]',
|
||||
group: 'editing',
|
||||
help: 'edit the open entry, or open an entry by id',
|
||||
help: 'edit the open document, or open a node by id',
|
||||
run: async (arg) => {
|
||||
const id = arg ? Number(arg) : diary.open?.id ?? diary.selected?.id
|
||||
if (!id || Number.isNaN(id)) return diary.say('usage: :e <id>', 'error')
|
||||
await diary.guard(() => diary.openEntry(id, true))
|
||||
const id = arg ? Number(arg) : workspace.open?.id ?? workspace.selected?.id
|
||||
if (!id || Number.isNaN(id)) return workspace.say('usage: :e <id>', 'error')
|
||||
await workspace.guard(() => workspace.openNode(id, true))
|
||||
hooks.focusEditor()
|
||||
},
|
||||
bang: {
|
||||
help: 'throw away the draft and re-read the entry from the server',
|
||||
help: 'throw away the draft and re-read the document from the server',
|
||||
run: async () => {
|
||||
const open = requireOpen()
|
||||
if (!open) return
|
||||
diary.dirty = false
|
||||
diary.dropStash(open.id)
|
||||
await diary.guard(() => diary.openEntry(open.id, diary.editing))
|
||||
diary.say(`entry:${open.id} reloaded`)
|
||||
workspace.dirty = false
|
||||
workspace.dropStash(open.id)
|
||||
await workspace.guard(() => workspace.openNode(open.id, workspace.editing))
|
||||
workspace.say(`${open.slug} reloaded`)
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'next',
|
||||
aliases: ['bn'],
|
||||
group: 'entries',
|
||||
help: 'open the next entry down the list',
|
||||
run: () => jumpTo(diary.cursor + 1),
|
||||
group: 'documents',
|
||||
help: 'open the next node down the list',
|
||||
run: () => jumpTo(workspace.cursor + 1),
|
||||
},
|
||||
{
|
||||
name: 'prev',
|
||||
aliases: ['bp'],
|
||||
group: 'entries',
|
||||
help: 'open the previous entry',
|
||||
run: () => jumpTo(diary.cursor - 1),
|
||||
group: 'documents',
|
||||
help: 'open the previous node',
|
||||
run: () => jumpTo(workspace.cursor - 1),
|
||||
},
|
||||
{
|
||||
name: 'first',
|
||||
group: 'entries',
|
||||
help: 'open the newest entry',
|
||||
group: 'documents',
|
||||
help: 'open the first node in the list',
|
||||
run: () => jumpTo(0),
|
||||
},
|
||||
{
|
||||
name: 'last',
|
||||
group: 'entries',
|
||||
help: 'open the oldest entry',
|
||||
run: () => jumpTo(diary.entries.length - 1),
|
||||
group: 'documents',
|
||||
help: 'open the last node in the list',
|
||||
run: () => jumpTo(workspace.nodes.length - 1),
|
||||
},
|
||||
{
|
||||
name: 'random',
|
||||
group: 'entries',
|
||||
help: 'open an entry at random — good for re-reading',
|
||||
run: () => jumpTo(Math.floor(Math.random() * diary.entries.length)),
|
||||
group: 'documents',
|
||||
help: 'open something here at random — good for re-reading',
|
||||
run: () => jumpTo(Math.floor(Math.random() * workspace.nodes.length)),
|
||||
},
|
||||
{
|
||||
name: 'delete',
|
||||
aliases: ['d', 'rm'],
|
||||
args: '[id]',
|
||||
group: 'entries',
|
||||
help: 'delete an entry (asks first)',
|
||||
group: 'documents',
|
||||
help: 'delete a node, and everything under it (asks first)',
|
||||
run: async (arg) => {
|
||||
const id = arg ? Number(arg) : diary.open?.id ?? diary.selected?.id
|
||||
if (!id || Number.isNaN(id)) return diary.say('nothing to delete', 'error')
|
||||
if (!confirm(`delete entry #${id}? this cannot be undone.`)) return
|
||||
await diary.guard(() => diary.deleteEntry(id))
|
||||
// On the board the thing under the cursor is a card, not a node.
|
||||
if (workspace.view === 'board') {
|
||||
const card = workspace.card
|
||||
if (!card) return workspace.say('no card selected', 'error')
|
||||
if (!confirm(`delete the card "${card.title}"?`)) return
|
||||
return workspace.guard(() => workspace.deleteCard())
|
||||
}
|
||||
const id = arg ? Number(arg) : workspace.open?.id ?? workspace.selected?.id
|
||||
if (!id || Number.isNaN(id)) return workspace.say('nothing to delete', 'error')
|
||||
const node = workspace.open?.id === id ? workspace.open : workspace.nodes.find((n) => n.id === id)
|
||||
const what =
|
||||
node && node.kind !== 'document'
|
||||
? `delete ${node.kind} "${node.name}" and everything in it?`
|
||||
: `delete #${id}?`
|
||||
if (!confirm(`${what} this cannot be undone.`)) return
|
||||
await workspace.guard(() => workspace.deleteNode(id))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
name: 'name',
|
||||
aliases: ['title'],
|
||||
args: '<text>',
|
||||
group: 'editing',
|
||||
help: 'set the title of the open entry',
|
||||
help: 'rename the open document, or the selected card on a board',
|
||||
run: async (arg) => {
|
||||
if (workspace.view === 'board') {
|
||||
if (!arg.trim()) return workspace.say('usage: :name <text>', 'error')
|
||||
return workspace.guard(() => workspace.patchCard({ title: arg.trim() }))
|
||||
}
|
||||
if (!requireOpen()) return
|
||||
diary.draft.title = arg
|
||||
diary.dirty = true
|
||||
await diary.guard(() => diary.save())
|
||||
workspace.draft.name = arg
|
||||
workspace.dirty = true
|
||||
await workspace.guard(() => workspace.save())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
args: '<yyyy-mm-dd>',
|
||||
group: 'editing',
|
||||
help: 're-date the open entry',
|
||||
help: 're-date the open document',
|
||||
run: async (arg) => {
|
||||
if (!requireOpen()) return
|
||||
const at = parseDay(arg)
|
||||
if (at === null) return diary.say('usage: :date 2026-09-05', 'error')
|
||||
diary.draft.created_at = at
|
||||
diary.dirty = true
|
||||
await diary.guard(() => diary.save())
|
||||
if (at === null) return workspace.say('usage: :date 2026-09-05', 'error')
|
||||
workspace.draft.created_at = at
|
||||
workspace.dirty = true
|
||||
await workspace.guard(() => workspace.save())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'share',
|
||||
group: 'sharing',
|
||||
help: 'publish the entry behind an unguessable link and copy it',
|
||||
help: 'publish the document behind an unguessable link and copy it',
|
||||
run: async () => {
|
||||
const id = diary.open?.id ?? diary.selected?.id
|
||||
if (!id) return diary.say('no entry selected', 'error')
|
||||
const entry = diary.open?.id === id ? diary.open : diary.selected
|
||||
if (entry?.shared) return diary.say('already shared — :link for a new link, :unshare to revoke')
|
||||
await diary.guard(() => diary.toggleShare(id))
|
||||
const id = workspace.open?.id ?? workspace.selected?.id
|
||||
if (!id) return workspace.say('nothing selected', 'error')
|
||||
const node = workspace.open?.id === id ? workspace.open : workspace.selected
|
||||
if (node?.shared) return workspace.say('already shared — :link for a new link, :unshare to revoke')
|
||||
await workspace.guard(() => workspace.toggleShare(id))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -288,25 +488,25 @@ export const commands: CommandSpec[] = [
|
||||
group: 'sharing',
|
||||
help: 'revoke the share link',
|
||||
run: async () => {
|
||||
const id = diary.open?.id ?? diary.selected?.id
|
||||
if (!id) return diary.say('no entry selected', 'error')
|
||||
const entry = diary.open?.id === id ? diary.open : diary.selected
|
||||
if (!entry?.shared) return diary.say('entry is not shared')
|
||||
await diary.guard(() => diary.toggleShare(id))
|
||||
const id = workspace.open?.id ?? workspace.selected?.id
|
||||
if (!id) return workspace.say('nothing selected', 'error')
|
||||
const node = workspace.open?.id === id ? workspace.open : workspace.selected
|
||||
if (!node?.shared) return workspace.say('not shared')
|
||||
await workspace.guard(() => workspace.toggleShare(id))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'link',
|
||||
group: 'sharing',
|
||||
help: 'mint a new share link for the entry and copy it',
|
||||
help: 'mint a new share link for the document and copy it',
|
||||
run: async () => {
|
||||
const id = diary.open?.id ?? diary.selected?.id
|
||||
if (!id) return diary.say('no entry selected', 'error')
|
||||
const entry = diary.open?.id === id ? diary.open : diary.selected
|
||||
if (!entry?.shared) return diary.say('entry is not shared — :share first', 'error')
|
||||
await diary.guard(async () => {
|
||||
const url = await diary.mintShareLink(id)
|
||||
diary.say(`new link → ${url} (copied) — the previous one is dead`)
|
||||
const id = workspace.open?.id ?? workspace.selected?.id
|
||||
if (!id) return workspace.say('nothing selected', 'error')
|
||||
const node = workspace.open?.id === id ? workspace.open : workspace.selected
|
||||
if (!node?.shared) return workspace.say('not shared — :share first', 'error')
|
||||
await workspace.guard(async () => {
|
||||
const url = await workspace.mintShareLink(id)
|
||||
workspace.say(`new link → ${url} (copied) — the previous one is dead`)
|
||||
})
|
||||
},
|
||||
},
|
||||
@@ -314,38 +514,38 @@ export const commands: CommandSpec[] = [
|
||||
name: 'copy',
|
||||
aliases: ['yank'],
|
||||
group: 'sharing',
|
||||
help: 'copy the whole entry to the clipboard as markdown',
|
||||
help: 'copy the whole document to the clipboard as markdown',
|
||||
run: async () => {
|
||||
if (!requireOpen()) return
|
||||
const { title, body } = diary.draft
|
||||
const text = title.trim() ? `# ${title.trim()}\n\n${body}` : body
|
||||
diary.say((await diary.copy(text)) ? 'entry copied' : 'the clipboard said no', 'info')
|
||||
const { name, body } = workspace.draft
|
||||
const text = name.trim() ? `# ${name.trim()}\n\n${body}` : body
|
||||
workspace.say((await workspace.copy(text)) ? 'document copied' : 'the clipboard said no', 'info')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'export',
|
||||
aliases: ['exp'],
|
||||
group: 'sharing',
|
||||
help: 'download the open entry as a .md file',
|
||||
help: 'download the open document as a .md file',
|
||||
run: () => {
|
||||
if (!requireOpen()) return
|
||||
const { title, body, created_at } = diary.draft
|
||||
const text = title.trim() ? `# ${title.trim()}\n\n${body}` : body
|
||||
const { name, body, created_at } = workspace.draft
|
||||
const text = name.trim() ? `# ${name.trim()}\n\n${body}` : body
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'text/markdown' }))
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = exportName(title.trim(), created_at)
|
||||
anchor.download = exportName(name.trim(), created_at)
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
diary.say(`wrote ${anchor.download}`)
|
||||
workspace.say(`wrote ${anchor.download}`)
|
||||
},
|
||||
bang: {
|
||||
help: 'download the whole diary — every entry and every file — as a zip',
|
||||
help: 'download the whole workspace — every space, document and file — as a zip',
|
||||
run: async () => {
|
||||
// A plain navigation, so the browser streams the archive straight to
|
||||
// disk instead of the tab holding all of it in memory first.
|
||||
await diary.flush()
|
||||
diary.say('building the archive — the download starts on its own')
|
||||
await workspace.flush()
|
||||
workspace.say('building the archive — the download starts on its own')
|
||||
location.href = '/api/export'
|
||||
},
|
||||
},
|
||||
@@ -361,46 +561,50 @@ export const commands: CommandSpec[] = [
|
||||
name: 'media',
|
||||
group: 'editing',
|
||||
help: 'browse everything you have uploaded',
|
||||
run: () => diary.guard(async () => {
|
||||
await diary.loadMedia()
|
||||
diary.overlay = 'media'
|
||||
run: () => workspace.guard(async () => {
|
||||
await workspace.loadMedia()
|
||||
workspace.overlay = 'media'
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'search',
|
||||
aliases: ['se'],
|
||||
args: '[text]',
|
||||
group: 'entries',
|
||||
group: 'documents',
|
||||
help: 'full-text search (empty clears)',
|
||||
run: (arg) => diary.guard(() => diary.search(arg)),
|
||||
run: (arg) => workspace.guard(() => workspace.search(arg)),
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
aliases: ['noh'],
|
||||
group: 'entries',
|
||||
group: 'documents',
|
||||
help: 'clear the search filter',
|
||||
run: () => diary.guard(() => diary.search('')),
|
||||
run: () => workspace.guard(() => workspace.search('')),
|
||||
},
|
||||
{
|
||||
name: 'reload',
|
||||
aliases: ['r'],
|
||||
group: 'entries',
|
||||
help: 're-read the entry list from the server',
|
||||
group: 'documents',
|
||||
help: 're-read the current folder from the server',
|
||||
run: () =>
|
||||
diary.guard(async () => {
|
||||
await diary.refresh()
|
||||
diary.say(`${diary.entries.length} entries`)
|
||||
workspace.guard(async () => {
|
||||
await workspace.refresh()
|
||||
workspace.say(`${workspace.nodes.length} here`)
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'stats',
|
||||
group: 'view',
|
||||
help: 'word, line and entry counts',
|
||||
help: 'word, line and node counts',
|
||||
run: () => {
|
||||
const body = diary.open ? diary.draft.body : ''
|
||||
const body = workspace.open ? workspace.draft.body : ''
|
||||
const words = body.split(/\s+/).filter(Boolean).length
|
||||
const here = diary.open ? `entry:${diary.open.id} ${words}w ${body.split('\n').length}L · ` : ''
|
||||
diary.say(`${here}${diary.entries.length} entries${diary.query ? ` matching "${diary.query}"` : ''}`)
|
||||
const open = workspace.open ? `${workspace.open.slug} ${words}w ${body.split('\n').length}L · ` : ''
|
||||
workspace.say(
|
||||
`${open}${workspace.here || '/'} · ${workspace.nodes.length} node${workspace.nodes.length === 1 ? '' : 's'}${
|
||||
workspace.query ? ` matching "${workspace.query}"` : ''
|
||||
}`,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -408,46 +612,50 @@ export const commands: CommandSpec[] = [
|
||||
args: '<mocha|green|amber|ice>',
|
||||
group: 'view',
|
||||
help: 'switch the colour scheme',
|
||||
run: (arg) => setTheme(arg.trim() || diary.theme),
|
||||
run: (arg) => setTheme(arg.trim() || workspace.theme),
|
||||
},
|
||||
{
|
||||
name: 'set',
|
||||
args: '<option>',
|
||||
group: 'view',
|
||||
help: 'theme=mocha|green|amber|ice, vim, novim, clipboard, noclipboard',
|
||||
help: 'theme=mocha|green|amber|ice, vim, novim, clipboard, noclipboard, board, noboard',
|
||||
run: (arg) => {
|
||||
const option = arg.trim()
|
||||
if (option === 'vim') return diary.setVim(true), diary.say('vim keys on')
|
||||
if (option === 'novim') return diary.setVim(false), diary.say('vim keys off')
|
||||
if (option === 'vim') return workspace.setVim(true), workspace.say('vim keys on')
|
||||
if (option === 'novim') return workspace.setVim(false), workspace.say('vim keys off')
|
||||
if (option === 'clipboard')
|
||||
return diary.setClipboard(true), diary.say('yanks go to the system clipboard')
|
||||
return workspace.setClipboard(true), workspace.say('yanks go to the system clipboard')
|
||||
if (option === 'noclipboard')
|
||||
return diary.setClipboard(false), diary.say('yanks stay in vim registers')
|
||||
return workspace.setClipboard(false), workspace.say('yanks stay in vim registers')
|
||||
// A board is a property of the space, not of this browser, so unlike the
|
||||
// options above this one goes to the server.
|
||||
if (option === 'board') return workspace.guard(() => workspace.setBoard(true))
|
||||
if (option === 'noboard') return workspace.guard(() => workspace.setBoard(false))
|
||||
const theme = /^theme=(\w+)$/.exec(option)?.[1]
|
||||
if (theme) return setTheme(theme)
|
||||
diary.say(`unknown option: ${option}`, 'error')
|
||||
workspace.say(`unknown option: ${option}`, 'error')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'backup',
|
||||
group: 'view',
|
||||
help: 'when the diary was last mirrored to proton drive',
|
||||
run: () => diary.guard(async () => describeBackup(await api.backupStatus())),
|
||||
help: 'when the workspace was last mirrored to proton drive',
|
||||
run: () => workspace.guard(async () => describeBackup(await api.backupStatus())),
|
||||
bang: {
|
||||
help: 'back up to proton drive now, rather than when the diary falls quiet',
|
||||
help: 'back up to proton drive now, rather than when the workspace falls quiet',
|
||||
run: () =>
|
||||
diary.guard(async () => {
|
||||
workspace.guard(async () => {
|
||||
// Whatever is in the editor belongs in the backup that was just
|
||||
// asked for, so it goes to the server before the mirror runs.
|
||||
await diary.flush()
|
||||
diary.say('backing up …')
|
||||
await workspace.flush()
|
||||
workspace.say('backing up …')
|
||||
const status = await api.backupNow()
|
||||
if (status.last_error) return diary.say(`backup failed: ${status.last_error}`, 'error')
|
||||
if (status.last_error) return workspace.say(`backup failed: ${status.last_error}`, 'error')
|
||||
const last = status.last
|
||||
diary.say(
|
||||
workspace.say(
|
||||
last
|
||||
? `backed up · ${last.uploaded} file${last.uploaded === 1 ? '' : 's'} uploaded, ${last.skipped} unchanged`
|
||||
: 'proton drive backups are off — run `narl-diary proton-login` on the server',
|
||||
: 'proton drive backups are off — run `narl-workspace proton-login` on the server',
|
||||
last ? 'info' : 'error',
|
||||
)
|
||||
}),
|
||||
@@ -459,14 +667,14 @@ export const commands: CommandSpec[] = [
|
||||
group: 'view',
|
||||
help: 'show the key and command reference',
|
||||
run: () => {
|
||||
diary.overlay = diary.overlay === 'help' ? 'none' : 'help'
|
||||
workspace.overlay = workspace.overlay === 'help' ? 'none' : 'help'
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'logout',
|
||||
group: 'view',
|
||||
help: 'end the session',
|
||||
run: () => diary.guard(() => diary.logout()),
|
||||
run: () => workspace.guard(() => workspace.logout()),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -482,16 +690,16 @@ export async function runCommand(line: string): Promise<void> {
|
||||
if (!input) return
|
||||
|
||||
if (input.startsWith('/')) {
|
||||
await diary.guard(() => diary.search(input.slice(1)))
|
||||
await workspace.guard(() => workspace.search(input.slice(1)))
|
||||
return
|
||||
}
|
||||
|
||||
const [head, ...rest] = input.split(/\s+/)
|
||||
const arg = rest.join(' ')
|
||||
|
||||
// `:12` jumps to an entry by id, the way `:12` jumps to a line in vim.
|
||||
// `:12` jumps to a node by id, the way `:12` jumps to a line in vim.
|
||||
if (/^\d+$/.test(head)) {
|
||||
await diary.guard(() => diary.openEntry(Number(head)))
|
||||
await workspace.guard(() => workspace.openNode(Number(head)))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -499,11 +707,11 @@ export async function runCommand(line: string): Promise<void> {
|
||||
const forced = head.endsWith('!')
|
||||
const spec = lookup.get(forced ? head.slice(0, -1) : head)
|
||||
if (!spec) {
|
||||
diary.say(`E492: not an editor command: ${head}`, 'error')
|
||||
workspace.say(`E492: not an editor command: ${head}`, 'error')
|
||||
return
|
||||
}
|
||||
if (forced) {
|
||||
if (!spec.bang) return diary.say(`E477: no ! allowed: ${head}`, 'error')
|
||||
if (!spec.bang) return workspace.say(`E477: no ! allowed: ${head}`, 'error')
|
||||
await spec.bang.run(arg)
|
||||
return
|
||||
}
|
||||
|
||||
+416
-52
@@ -1,6 +1,17 @@
|
||||
import { api, ApiError, type Entry, type EntrySummary, type MediaFile } from './api'
|
||||
import {
|
||||
api,
|
||||
ApiError,
|
||||
type Board,
|
||||
type Card,
|
||||
type Crumb,
|
||||
type WorkspaceNode,
|
||||
type MediaFile,
|
||||
type NodeSummary,
|
||||
} from './api'
|
||||
|
||||
export type Pane = 'list' | 'entry'
|
||||
export type Pane = 'list' | 'document'
|
||||
/** The tree of documents, or the board of the space being browsed. */
|
||||
export type View = 'tree' | 'board'
|
||||
export type Flash = { text: string; kind: 'info' | 'error' } | null
|
||||
export type Theme = 'mocha' | 'green' | 'amber' | 'ice'
|
||||
|
||||
@@ -8,21 +19,36 @@ export type Theme = 'mocha' | 'green' | 'amber' | 'ice'
|
||||
* The whole application state. It is deliberately one object: the keyboard
|
||||
* layer needs to see everything at once to decide what a key means.
|
||||
*/
|
||||
class Diary {
|
||||
class Workspace {
|
||||
user = $state<string | null>(null)
|
||||
booting = $state(true)
|
||||
|
||||
entries = $state<EntrySummary[]>([])
|
||||
/** The top level: diary, work, uni, whatever else gets made. */
|
||||
spaces = $state<NodeSummary[]>([])
|
||||
/** The space being browsed, and the folder inside it the list is showing. */
|
||||
spaceId = $state<number | null>(null)
|
||||
parentId = $state<number | null>(null)
|
||||
/** Ancestors of the current folder, the space first and the folder last. */
|
||||
path = $state<Crumb[]>([])
|
||||
|
||||
/** Children of the current folder, or the results of a search. */
|
||||
nodes = $state<NodeSummary[]>([])
|
||||
cursor = $state(0)
|
||||
query = $state('')
|
||||
|
||||
open = $state<Entry | null>(null)
|
||||
draft = $state({ title: '', body: '', created_at: 0 })
|
||||
open = $state<WorkspaceNode | null>(null)
|
||||
draft = $state({ name: '', body: '', created_at: 0 })
|
||||
editing = $state(false)
|
||||
/** Set when the editor should open in insert mode rather than normal mode. */
|
||||
enterInsert = $state(false)
|
||||
dirty = $state(false)
|
||||
|
||||
/** The board of the current space, loaded only while it is being looked at. */
|
||||
view = $state<View>('tree')
|
||||
board = $state<Board | null>(null)
|
||||
listCursor = $state(0)
|
||||
cardCursor = $state(0)
|
||||
|
||||
pane = $state<Pane>('list')
|
||||
vimMode = $state('normal')
|
||||
flash = $state<Flash>(null)
|
||||
@@ -40,8 +66,25 @@ class Diary {
|
||||
/** Pending debounced write, if the draft has changed since the last save. */
|
||||
#autosave: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
get selected(): EntrySummary | undefined {
|
||||
return this.entries[this.cursor]
|
||||
get selected(): NodeSummary | undefined {
|
||||
return this.nodes[this.cursor]
|
||||
}
|
||||
|
||||
get space(): NodeSummary | undefined {
|
||||
return this.spaces.find((s) => s.id === this.spaceId)
|
||||
}
|
||||
|
||||
/** `work/einarbeiten` — what the sidebar and the status line show. */
|
||||
get here(): string {
|
||||
return this.path.map((crumb) => crumb.slug).join('/')
|
||||
}
|
||||
|
||||
get list() {
|
||||
return this.board?.lists[this.listCursor]
|
||||
}
|
||||
|
||||
get card(): Card | undefined {
|
||||
return this.list?.cards[this.cardCursor]
|
||||
}
|
||||
|
||||
say(text: string, kind: 'info' | 'error' = 'info') {
|
||||
@@ -71,7 +114,7 @@ class Diary {
|
||||
this.dirty = true
|
||||
this.stash()
|
||||
if (this.#autosave) clearTimeout(this.#autosave)
|
||||
this.#autosave = setTimeout(() => void this.#writeBack(), Diary.AUTOSAVE_MS)
|
||||
this.#autosave = setTimeout(() => void this.#writeBack(), Workspace.AUTOSAVE_MS)
|
||||
}
|
||||
|
||||
stash() {
|
||||
@@ -94,7 +137,10 @@ class Diary {
|
||||
#readStash(id: number): typeof this.draft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.#stashKey(id))
|
||||
return raw ? JSON.parse(raw) : null
|
||||
if (!raw) return null
|
||||
const stashed = JSON.parse(raw)
|
||||
// Drafts stashed before documents had names carry a title instead.
|
||||
return { name: stashed.name ?? stashed.title ?? '', body: stashed.body, created_at: stashed.created_at }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -108,7 +154,7 @@ class Diary {
|
||||
|
||||
/**
|
||||
* Write out a pending draft before doing something that would replace it.
|
||||
* Without this, clicking another entry in the list discarded unsaved work
|
||||
* Without this, clicking another document in the list discarded unsaved work
|
||||
* without a word.
|
||||
*/
|
||||
async flush() {
|
||||
@@ -123,7 +169,7 @@ class Diary {
|
||||
try {
|
||||
const { user } = await api.me()
|
||||
this.user = user
|
||||
await this.refresh()
|
||||
await this.loadSpaces(Workspace.addressedPath())
|
||||
} catch {
|
||||
this.user = null
|
||||
} finally {
|
||||
@@ -134,22 +180,112 @@ class Diary {
|
||||
async login(username: string, password: string) {
|
||||
const { user } = await api.login(username, password)
|
||||
this.user = user
|
||||
await this.refresh()
|
||||
await this.loadSpaces(Workspace.addressedPath())
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await api.logout()
|
||||
this.user = null
|
||||
this.entries = []
|
||||
this.spaces = []
|
||||
this.nodes = []
|
||||
this.open = null
|
||||
this.editing = false
|
||||
}
|
||||
|
||||
/*
|
||||
* Navigation. The list pane always shows one folder's children, so browsing
|
||||
* is: pick a space, descend into folders, come back up. A search steps
|
||||
* outside that and shows matches from anywhere in the current space.
|
||||
*/
|
||||
|
||||
/** The slug path in the address bar, if the page was opened on a `/n/...` URL. */
|
||||
static addressedPath(): string | null {
|
||||
return location.pathname.startsWith('/n/')
|
||||
? decodeURIComponent(location.pathname.slice(3)).replace(/\/$/, '')
|
||||
: null
|
||||
}
|
||||
|
||||
/** Keep the address bar on the node being looked at, so it can be linked to. */
|
||||
#syncUrl(path: Crumb[]) {
|
||||
const slugs = path.map((crumb) => crumb.slug).join('/')
|
||||
if (slugs) history.replaceState(null, '', `/n/${slugs}`)
|
||||
}
|
||||
|
||||
/** Open whatever `work/einarbeiten/01-was-ist-eebus` names. */
|
||||
async openPath(path: string) {
|
||||
const node = await api.resolvePath(path)
|
||||
if (node.kind === 'document') {
|
||||
if (node.parent_id !== null) await this.goTo(node.parent_id)
|
||||
await this.openNode(node.id)
|
||||
} else {
|
||||
await this.goTo(node.id)
|
||||
}
|
||||
}
|
||||
|
||||
async loadSpaces(startPath: string | null = null) {
|
||||
this.spaces = await api.listSpaces()
|
||||
if (startPath) {
|
||||
try {
|
||||
await this.openPath(startPath)
|
||||
return
|
||||
} catch {
|
||||
// A stale or hand-typed address should not leave the app on nothing.
|
||||
this.say(`no such path: ${startPath}`, 'error')
|
||||
}
|
||||
}
|
||||
const remembered = Number(localStorage.getItem('diary:space'))
|
||||
const start =
|
||||
this.spaces.find((s) => s.id === remembered) ??
|
||||
this.spaces.find((s) => s.slug === 'diary') ??
|
||||
this.spaces[0]
|
||||
if (start) await this.enterSpace(start.id)
|
||||
}
|
||||
|
||||
async enterSpace(id: number) {
|
||||
this.spaceId = id
|
||||
localStorage.setItem('diary:space', String(id))
|
||||
this.query = ''
|
||||
await this.goTo(id)
|
||||
// The board belongs to the space, so switching space while looking at one
|
||||
// shows the new space's board — or drops back to the tree for a space that
|
||||
// has none, rather than leaving the previous board on screen.
|
||||
if (this.view === 'board') {
|
||||
this.listCursor = 0
|
||||
this.cardCursor = 0
|
||||
if (this.space?.has_board) await this.reloadBoard(id, undefined)
|
||||
else this.closeBoard()
|
||||
}
|
||||
}
|
||||
|
||||
/** Show the children of `id`, which must be a space or a folder. */
|
||||
async goTo(id: number, keep?: number) {
|
||||
await this.flush()
|
||||
this.parentId = id
|
||||
this.query = ''
|
||||
const folder = await api.getNode(id)
|
||||
this.path = folder.path
|
||||
this.spaceId = folder.path[0]?.id ?? id
|
||||
this.nodes = await api.listNodes({ parent: id })
|
||||
const index = this.nodes.findIndex((n) => n.id === keep)
|
||||
this.cursor = index >= 0 ? index : 0
|
||||
this.#syncUrl(folder.path)
|
||||
}
|
||||
|
||||
/** Up one level, selecting the folder just left so `h` then `l` is a no-op. */
|
||||
async up() {
|
||||
const from = this.path.at(-1)
|
||||
const parent = this.path.at(-2)
|
||||
if (!parent || !from) return
|
||||
await this.goTo(parent.id, from.id)
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const keepId = this.selected?.id
|
||||
this.entries = await api.listEntries(this.query)
|
||||
const index = this.entries.findIndex((e) => e.id === keepId)
|
||||
this.cursor = index >= 0 ? index : Math.min(this.cursor, Math.max(this.entries.length - 1, 0))
|
||||
this.nodes = this.query
|
||||
? await api.listNodes({ q: this.query, space: this.spaceId ?? undefined })
|
||||
: await api.listNodes({ parent: this.parentId ?? undefined })
|
||||
const index = this.nodes.findIndex((n) => n.id === keepId)
|
||||
this.cursor = index >= 0 ? index : Math.min(this.cursor, Math.max(this.nodes.length - 1, 0))
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
@@ -159,26 +295,32 @@ class Diary {
|
||||
}
|
||||
|
||||
move(delta: number) {
|
||||
if (this.entries.length === 0) return
|
||||
this.cursor = Math.min(Math.max(this.cursor + delta, 0), this.entries.length - 1)
|
||||
if (this.nodes.length === 0) return
|
||||
this.cursor = Math.min(Math.max(this.cursor + delta, 0), this.nodes.length - 1)
|
||||
}
|
||||
|
||||
async openEntry(id: number, edit = false) {
|
||||
async openNode(id: number, edit = false) {
|
||||
if (this.open?.id !== id) await this.flush()
|
||||
|
||||
const entry = await api.getEntry(id)
|
||||
this.open = entry
|
||||
this.draft = { title: entry.title, body: entry.body, created_at: entry.created_at }
|
||||
const node = await api.getNode(id)
|
||||
if (node.kind !== 'document') {
|
||||
await this.goTo(node.id)
|
||||
return
|
||||
}
|
||||
|
||||
this.open = node
|
||||
this.draft = { name: node.name, body: node.body, created_at: node.created_at }
|
||||
this.dirty = false
|
||||
this.editing = edit
|
||||
this.pane = 'entry'
|
||||
const index = this.entries.findIndex((e) => e.id === id)
|
||||
this.pane = 'document'
|
||||
this.#syncUrl(node.path)
|
||||
const index = this.nodes.findIndex((n) => n.id === id)
|
||||
if (index >= 0) this.cursor = index
|
||||
|
||||
// A stash that differs from what the server has is work the last session
|
||||
// did not manage to write. Vim would call this a swap file.
|
||||
const stashed = this.#readStash(id)
|
||||
if (stashed && (stashed.body !== entry.body || stashed.title !== entry.title)) {
|
||||
if (stashed && (stashed.body !== node.body || stashed.name !== node.name)) {
|
||||
this.draft = stashed
|
||||
this.dirty = true
|
||||
this.say('recovered an unsaved draft — :w to keep it, :e! to discard', 'error')
|
||||
@@ -187,80 +329,279 @@ class Diary {
|
||||
}
|
||||
}
|
||||
|
||||
/** What Enter does: descend into a folder, open a document. */
|
||||
async openSelected(edit = false) {
|
||||
const selected = this.selected
|
||||
if (selected) await this.openEntry(selected.id, edit)
|
||||
if (!selected) return
|
||||
if (selected.kind === 'document') await this.openNode(selected.id, edit)
|
||||
else await this.goTo(selected.id)
|
||||
}
|
||||
|
||||
/** A new entry is created immediately so uploads have something to attach to. */
|
||||
async createEntry() {
|
||||
const entry = await api.createEntry({ title: '', body: '' })
|
||||
/** A new document is created immediately so uploads have something to attach to. */
|
||||
async createDocument(name = '') {
|
||||
const parent = this.parentId
|
||||
if (parent === null) return
|
||||
const node = await api.createNode({ parent_id: parent, name, body: '' })
|
||||
this.query = ''
|
||||
await this.refresh()
|
||||
await this.openEntry(entry.id, true)
|
||||
await this.openNode(node.id, true)
|
||||
this.enterInsert = true
|
||||
this.say(`new entry #${entry.id}`)
|
||||
this.say(`new document ${this.here}/${node.slug}`)
|
||||
}
|
||||
|
||||
async createFolder(name: string) {
|
||||
const parent = this.parentId
|
||||
if (parent === null) return
|
||||
const node = await api.createNode({ parent_id: parent, kind: 'folder', name })
|
||||
await this.refresh()
|
||||
const index = this.nodes.findIndex((n) => n.id === node.id)
|
||||
if (index >= 0) this.cursor = index
|
||||
this.say(`new folder ${this.here}/${node.slug}`)
|
||||
}
|
||||
|
||||
async createSpace(name: string) {
|
||||
const space = await api.createSpace(name)
|
||||
await this.loadSpaces()
|
||||
await this.enterSpace(space.id)
|
||||
this.say(`new space ${space.slug}`)
|
||||
}
|
||||
|
||||
async save({ quiet = false } = {}) {
|
||||
if (!this.open) return
|
||||
// Snapshot what goes over the wire: an autosave races the typing that
|
||||
// triggered it, and anything typed since must stay marked unsaved.
|
||||
const sent = { ...this.draft, title: this.draft.title.trim() }
|
||||
const saved = await api.updateEntry(this.open.id, sent)
|
||||
const sent = { ...this.draft, name: this.draft.name.trim() }
|
||||
const saved = await api.updateNode(this.open.id, sent)
|
||||
|
||||
this.open = saved
|
||||
this.dirty =
|
||||
this.draft.body !== sent.body ||
|
||||
this.draft.title.trim() !== sent.title ||
|
||||
this.draft.name.trim() !== sent.name ||
|
||||
this.draft.created_at !== sent.created_at
|
||||
if (this.dirty) this.touch()
|
||||
else this.dropStash(saved.id)
|
||||
// The response carries the same excerpt the list query builds, so the row
|
||||
// can usually be patched in place rather than re-reading the whole list.
|
||||
if (!this.patch(saved)) await this.refresh()
|
||||
if (!quiet) this.say(`"entry:${saved.id}" ${saved.body.split('\n').length}L written`)
|
||||
if (!quiet) this.say(`"${saved.slug}" ${saved.body.split('\n').length}L written`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a freshly saved entry back into its list row. Returns false when the
|
||||
* row cannot be patched in place — a new entry, or one whose date moved it
|
||||
* somewhere else in the order — and the caller should re-read the list.
|
||||
* Fold a freshly saved document back into its list row. Returns false when
|
||||
* the row cannot be patched in place — a new document, or one whose date or
|
||||
* slug moved it somewhere else — and the caller should re-read the list.
|
||||
*/
|
||||
patch(entry: Entry): boolean {
|
||||
const index = this.entries.findIndex((e) => e.id === entry.id)
|
||||
if (index < 0 || this.entries[index].created_at !== entry.created_at) return false
|
||||
const { id, title, excerpt, created_at, updated_at, shared, share_token } = entry
|
||||
this.entries[index] = { id, title, excerpt, created_at, updated_at, shared, share_token }
|
||||
patch(node: WorkspaceNode): boolean {
|
||||
const index = this.nodes.findIndex((n) => n.id === node.id)
|
||||
if (index < 0) return false
|
||||
const row = this.nodes[index]
|
||||
if (row.created_at !== node.created_at || row.slug !== node.slug) return false
|
||||
const { body: _body, path: _path, ...summary } = node
|
||||
this.nodes[index] = summary
|
||||
return true
|
||||
}
|
||||
|
||||
async deleteEntry(id: number) {
|
||||
async deleteNode(id: number) {
|
||||
if (this.#autosave) {
|
||||
clearTimeout(this.#autosave)
|
||||
this.#autosave = null
|
||||
}
|
||||
this.dropStash(id)
|
||||
await api.deleteEntry(id)
|
||||
const { removed } = await api.deleteNode(id)
|
||||
if (this.open?.id === id) {
|
||||
this.open = null
|
||||
this.editing = false
|
||||
this.pane = 'list'
|
||||
}
|
||||
await this.refresh()
|
||||
this.say(`entry #${id} deleted`)
|
||||
this.say(removed > 1 ? `deleted ${removed} nodes` : `deleted #${id}`)
|
||||
}
|
||||
|
||||
/** Reparent the node under the cursor, for `:mv`. */
|
||||
async moveNode(id: number, parentId: number) {
|
||||
await api.moveNode(id, parentId)
|
||||
await this.refresh()
|
||||
this.say(`moved #${id}`)
|
||||
}
|
||||
|
||||
/*
|
||||
* Boards. One per space, which is what makes "what am I working on" a
|
||||
* question with an answer: work and uni are asked separately, and the diary
|
||||
* is not asked at all. The board is reloaded whole after every change — it
|
||||
* is a few dozen rows, and a board that disagrees with the server about
|
||||
* where a card is would be worse than a round trip.
|
||||
*/
|
||||
|
||||
async openBoard() {
|
||||
const space = this.space
|
||||
if (!space) return
|
||||
if (!space.has_board) {
|
||||
return this.say(`${space.slug} has no board — :set board turns one on`, 'error')
|
||||
}
|
||||
await this.reloadBoard(space.id)
|
||||
this.view = 'board'
|
||||
}
|
||||
|
||||
closeBoard() {
|
||||
this.view = 'tree'
|
||||
}
|
||||
|
||||
async toggleBoardView() {
|
||||
if (this.view === 'board') this.closeBoard()
|
||||
else await this.openBoard()
|
||||
}
|
||||
|
||||
async reloadBoard(spaceId = this.spaceId, keep = this.card?.id) {
|
||||
if (spaceId === null) return
|
||||
this.board = await api.board(spaceId)
|
||||
// Follow the card that was just moved rather than whatever now sits under
|
||||
// the cursor, so J J J walks one card down instead of shuffling three.
|
||||
if (keep !== undefined) {
|
||||
const at = this.locate(keep)
|
||||
if (at) {
|
||||
this.listCursor = at.list
|
||||
this.cardCursor = at.card
|
||||
}
|
||||
}
|
||||
this.clampBoard()
|
||||
}
|
||||
|
||||
/** Where a card sits now, as list and card indices. */
|
||||
locate(cardId: number): { list: number; card: number } | null {
|
||||
const lists = this.board?.lists ?? []
|
||||
for (let list = 0; list < lists.length; list++) {
|
||||
const card = lists[list].cards.findIndex((c) => c.id === cardId)
|
||||
if (card >= 0) return { list, card }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
clampBoard() {
|
||||
const lists = this.board?.lists ?? []
|
||||
this.listCursor = Math.min(Math.max(this.listCursor, 0), Math.max(lists.length - 1, 0))
|
||||
const cards = lists[this.listCursor]?.cards ?? []
|
||||
this.cardCursor = Math.min(Math.max(this.cardCursor, 0), Math.max(cards.length - 1, 0))
|
||||
}
|
||||
|
||||
/** `h`/`l`: to the list next door, keeping the cursor as near as it can. */
|
||||
moveList(delta: number) {
|
||||
const lists = this.board?.lists ?? []
|
||||
if (lists.length === 0) return
|
||||
this.listCursor = Math.min(Math.max(this.listCursor + delta, 0), lists.length - 1)
|
||||
this.clampBoard()
|
||||
}
|
||||
|
||||
/** `j`/`k`: down and up the cards of the current list. */
|
||||
moveCard(delta: number) {
|
||||
const cards = this.list?.cards ?? []
|
||||
if (cards.length === 0) return
|
||||
this.cardCursor = Math.min(Math.max(this.cardCursor + delta, 0), cards.length - 1)
|
||||
}
|
||||
|
||||
/** `J`/`K`: take the card with you instead of leaving it behind. */
|
||||
async nudgeCard(delta: number) {
|
||||
const card = this.card
|
||||
const list = this.list
|
||||
if (!card || !list) return
|
||||
const to = this.cardCursor + delta
|
||||
if (to < 0 || to >= list.cards.length) return
|
||||
await api.moveCard(card.id, list.id, to)
|
||||
await this.reloadBoard(this.spaceId, card.id)
|
||||
}
|
||||
|
||||
/** `H`/`L`: the same card, one list over — the move a board is really for. */
|
||||
async sendCard(delta: number) {
|
||||
const card = this.card
|
||||
const lists = this.board?.lists ?? []
|
||||
const target = lists[this.listCursor + delta]
|
||||
if (!card || !target) return
|
||||
await api.moveCard(card.id, target.id, target.cards.length)
|
||||
await this.reloadBoard(this.spaceId, card.id)
|
||||
this.say(`"${card.title}" → ${target.name}`)
|
||||
}
|
||||
|
||||
async createCard(title: string, nodeId: number | null = null) {
|
||||
const list = this.list
|
||||
if (!list) return this.say('no list to put it on', 'error')
|
||||
const card = await api.createCard(list.id, { title, node_id: nodeId })
|
||||
await this.reloadBoard(this.spaceId, card.id)
|
||||
this.say(nodeId === null ? `carded "${card.title}"` : `carded "${card.title}" → #${nodeId}`)
|
||||
}
|
||||
|
||||
async patchCard(patch: Parameters<typeof api.updateCard>[1]) {
|
||||
const card = this.card
|
||||
if (!card) return this.say('no card selected', 'error')
|
||||
await api.updateCard(card.id, patch)
|
||||
await this.reloadBoard(this.spaceId, card.id)
|
||||
}
|
||||
|
||||
async toggleDone() {
|
||||
const card = this.card
|
||||
if (!card) return this.say('no card selected', 'error')
|
||||
await api.updateCard(card.id, { done: card.done_at === null })
|
||||
await this.reloadBoard(this.spaceId, card.id)
|
||||
this.say(card.done_at === null ? `done: ${card.title}` : `reopened: ${card.title}`)
|
||||
}
|
||||
|
||||
async deleteCard() {
|
||||
const card = this.card
|
||||
if (!card) return this.say('no card selected', 'error')
|
||||
await api.deleteCard(card.id)
|
||||
await this.reloadBoard(this.spaceId, undefined)
|
||||
this.say(`deleted "${card.title}"`)
|
||||
}
|
||||
|
||||
async createList(name: string) {
|
||||
if (this.spaceId === null) return
|
||||
this.board = await api.createList(this.spaceId, name)
|
||||
this.listCursor = this.board.lists.length - 1
|
||||
this.cardCursor = 0
|
||||
this.say(`new list ${name}`)
|
||||
}
|
||||
|
||||
async deleteList() {
|
||||
const list = this.list
|
||||
if (!list) return this.say('no list selected', 'error')
|
||||
this.board = await api.deleteList(list.id)
|
||||
this.clampBoard()
|
||||
this.say(`deleted list ${list.name}`)
|
||||
}
|
||||
|
||||
/** Open the document a card points at, leaving the board behind. */
|
||||
async openCard() {
|
||||
const card = this.card
|
||||
if (!card) return
|
||||
if (card.node_id === null) {
|
||||
return this.say(`"${card.title}" is not linked to a document — :card! links one`, 'error')
|
||||
}
|
||||
this.view = 'tree'
|
||||
await this.openNode(card.node_id)
|
||||
}
|
||||
|
||||
/** Turn a space's board on or off. The diary ships with it off. */
|
||||
async setBoard(on: boolean) {
|
||||
const space = this.space
|
||||
if (!space) return
|
||||
await api.updateNode(space.id, { name: space.name, body: '', has_board: on })
|
||||
this.spaces = await api.listSpaces()
|
||||
if (on) await this.openBoard()
|
||||
else {
|
||||
this.closeBoard()
|
||||
this.board = null
|
||||
}
|
||||
this.say(on ? `${space.slug} has a board` : `${space.slug} has no board any more`)
|
||||
}
|
||||
|
||||
async toggleShare(id: number) {
|
||||
const entry = this.open?.id === id ? this.open : await api.getEntry(id)
|
||||
if (entry.shared) {
|
||||
const node = this.open?.id === id ? this.open : await api.getNode(id)
|
||||
if (node.shared) {
|
||||
await api.unshare(id)
|
||||
this.say(`entry #${id} is private again`)
|
||||
this.say(`#${id} is private again`)
|
||||
} else {
|
||||
const url = await this.mintShareLink(id)
|
||||
this.say(`shared → ${url} (copied)`)
|
||||
}
|
||||
if (this.open?.id === id) this.open = await api.getEntry(id)
|
||||
if (this.open?.id === id) this.open = await api.getNode(id)
|
||||
await this.refresh()
|
||||
}
|
||||
|
||||
@@ -286,6 +627,29 @@ class Diary {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a picked folder (or a zip of one) into the folder being browsed.
|
||||
* The report is worth a line of its own: an import that quietly skipped half
|
||||
* its files would only be noticed much later.
|
||||
*/
|
||||
async importFolder(files: File[]) {
|
||||
const parent = this.parentId
|
||||
if (parent === null) return
|
||||
this.say(`importing ${files.length} file${files.length === 1 ? '' : 's'} …`)
|
||||
|
||||
const report = await api.importFolder(parent, files)
|
||||
await this.refresh()
|
||||
|
||||
const parts = [
|
||||
`${report.documents} document${report.documents === 1 ? '' : 's'}`,
|
||||
`${report.folders} folder${report.folders === 1 ? '' : 's'}`,
|
||||
`${report.files} file${report.files === 1 ? '' : 's'}`,
|
||||
`${report.links} link${report.links === 1 ? '' : 's'} rewritten`,
|
||||
]
|
||||
if (report.skipped.length > 0) parts.push(`${report.skipped.length} skipped`)
|
||||
this.say(`imported ${parts.join(', ')}`)
|
||||
}
|
||||
|
||||
async upload(files: File[]): Promise<MediaFile[]> {
|
||||
const uploaded = await api.upload(files)
|
||||
this.say(`uploaded ${uploaded.length} file${uploaded.length === 1 ? '' : 's'}`)
|
||||
@@ -326,4 +690,4 @@ class Diary {
|
||||
}
|
||||
}
|
||||
|
||||
export const diary = new Diary()
|
||||
export const workspace = new Workspace()
|
||||
Reference in new issue
Block a user