scratch: the scratchpad is the inbox, with ways out and a search

A note now leaves the scratchpad by becoming something else: `K` keeps it
as a document, as before; `C` makes it a card where `:card` would put one,
the first line its title; `A` opens `:append` with the document on screen
filled in, Tab completing any other title, and puts the note at its end.

`/api/capture` takes `to: "note"` and `to: "document"` (named as a link
names it), and `note`, the scratch note the text came from, dropped once
the text has landed. Appending keeps the revision before it and indexes
links and media, as a save does.

Migration 0028 adds `scratch_fts`; `GET /api/scratch?q=` searches it, and
`/` lists note hits under "notes" after the cards, Enter lowering the
drawer at the note.

The `c` capture line cycles its target with Tab: a card, a note, or a
timed line in today's entry, which `:today <text>` now writes.
This commit is contained in:
nvrl committed 2026-10-22 11:22:55 +02:00
1 parent ccc91028e7
commit 2233fa41a6
20 files changed
+561 -84

No files matched your search

+22 -2
View File
@@ -52,6 +52,25 @@
let input = $state<HTMLInputElement>()
let list = $state<HTMLUListElement>()
const isSearch = $derived(value.startsWith('/'))
/*
* A capture line — `c` opens one — writes a thought down somewhere without
* going there: a card, a note, a line in today's entry. Tab walks the three
* with the text kept. A card stays linked to the document being read when
* the line began as one that was.
*/
const card = untrack(() => (initial.startsWith(':card! ') ? 'card!' : 'card'))
const targets = [card, 'scratch', 'today']
const targetNames: Record<string, string> = { card: 'card', 'card!': 'card about this', scratch: 'note', today: 'today' }
const capture = $derived(/^:(card!?|scratch|today)\s/.exec(value)?.[1])
function retarget(delta: 1 | -1): boolean {
if (!capture) return false
const at = targets.indexOf(capture === 'card!' || capture === 'card' ? card : capture)
const next = targets[(at + delta + targets.length) % targets.length]
value = value.replace(/^:\S+/, `:${next}`)
return true
}
const historyKey = $derived(isSearch ? 'diary:history:search' : 'diary:history:cmd')
/*
@@ -129,7 +148,7 @@
const query = line.slice(1)
await workspace.guard(async () => {
if (query && workspace.query !== query) await workspace.search(query, global)
if (query && (workspace.selected || workspace.foundCard)) await workspace.openSelected()
if (query && (workspace.selected || workspace.foundCard || workspace.foundNote)) await workspace.openSelected()
})
hooks.focusList()
return
@@ -187,7 +206,7 @@
workspace.move(event.shiftKey ? -1 : 1)
} else if (event.key === 'Tab') {
event.preventDefault()
complete(event.shiftKey ? -1 : 1)
if (!retarget(event.shiftKey ? -1 : 1)) complete(event.shiftKey ? -1 : 1)
} else if (ctrl && isSearch && (event.key === 'n' || event.key === 'p')) {
// The results, walked without leaving the line; Enter opens the one lit.
event.preventDefault()
@@ -227,6 +246,7 @@
<form onsubmit={submit}>
{#if isSearch}<span class="scope label">{global ? 'all spaces' : (workspace.space?.slug ?? '')}</span>{/if}
{#if capture}<span class="scope label" title="Tab: card · note · today">→ {targetNames[capture]} <span class="kbd">tab</span></span>{/if}
<input bind:this={input} bind:value {onkeydown} {oninput} spellcheck="false" autocomplete="off" />
</form>
</div>
+1 -6
View File
@@ -9,7 +9,7 @@
import Keys from './Keys.svelte'
import { renderMarkdown } from './markdown'
import { workspace } from './store.svelte'
import { relative } from './util'
import { headline, relative } from './util'
let list = $state<HTMLUListElement>()
@@ -21,11 +21,6 @@
list?.querySelector<HTMLElement>(`[data-index="${at}"]`)?.scrollIntoView({ block: 'nearest' })
})
/** What a note is called in the list: its first line that says something. */
function headline(body: string): string {
return body.split('\n').find((line) => line.trim())?.replace(/^#+\s*/, '').trim() ?? ''
}
function pick(index: number) {
if (workspace.scratchEditing) return
workspace.scratchCursor = index
+18 -2
View File
@@ -3,7 +3,7 @@
import { carry, into, release } from './carry'
import Icon from './Icon.svelte'
import { workspace } from './store.svelte'
import { formatBytes, formatDay, formatTime, formatWeekday, hitParts, isTouchDevice } from './util'
import { formatBytes, formatDay, formatTime, formatWeekday, headline, hitParts, isTouchDevice, relative } from './util'
let list = $state<HTMLUListElement>()
/** Files are being dragged over the list, which takes them into this folder. */
@@ -79,7 +79,8 @@
<span class="name">{workspace.space?.slug ?? 'diary'}</span>
<span class="count">
{workspace.nodes.length} {workspace.nodes.length === 1 ? 'item' : 'items'}{#if workspace.foundCards.length > 0}
· {workspace.foundCards.length} {workspace.foundCards.length === 1 ? 'card' : 'cards'}{/if}
· {workspace.foundCards.length} {workspace.foundCards.length === 1 ? 'card' : 'cards'}{/if}{#if workspace.foundNotes.length > 0}
· {workspace.foundNotes.length} {workspace.foundNotes.length === 1 ? 'note' : 'notes'}{/if}
</span>
</div>
@@ -162,6 +163,20 @@
</button>
</li>
{/each}
<!-- Then the notes it found on the scratchpad: Enter lowers it, at the note. -->
{#if workspace.foundNotes.length > 0}
<li class="daybreak label rule"><span>notes</span></li>
{/if}
{#each workspace.foundNotes as note, at (note.id)}
{@const index = workspace.nodes.length + workspace.foundCards.length + at}
<li class="item">
<button class="row note" class:active={index === workspace.cursor} data-index={index} onclick={() => pick(index)}>
<span class="dot"></span>
<span class="title">{headline(note.body) || '—'}<span class="where">scratch</span></span>
<span class="aside">{relative(note.updated_at)}</span>
</button>
</li>
{/each}
{#if workspace.rows === 0}
<li class="empty muted">
{#if workspace.query}
@@ -266,6 +281,7 @@
.row.open.active .dot { background: var(--ink); }
/* A card is told from a node by its square. */
.row.card .dot { border: 1px solid currentColor; border-radius: 1px; }
.row.note .dot { border: 1px dashed currentColor; background: none; }
.row.card.done .title { color: var(--muted); text-decoration: line-through; }
.title {
+5
View File
@@ -292,6 +292,11 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
new: () => workspace.newScratchNote(),
write: () => workspace.editScratchNote(),
keep: () => guard(() => workspace.keepScratchNote()),
card: () => guard(() => workspace.cardScratchNote()),
append: () => {
const open = [workspace.tile, ...workspace.tiles].find((tile) => tile.open?.kind === 'document')?.open
line(`:append ${open ? open.name.trim() || open.slug : ''}`)
},
drop: () => {
if (!workspace.scratchNote) return
void (async () => {
+10 -3
View File
@@ -105,7 +105,7 @@ export interface ApiToken {
/** Where `POST /api/capture` put the text. */
export interface Captured {
to: 'card' | 'diary'
to: 'card' | 'diary' | 'note' | 'document'
id: number
where: string
}
@@ -608,8 +608,15 @@ export const api = {
},
) => request<Moved>(`/cards/${id}`, { method: 'PUT', body: body(patch) }),
/** The scratchpad's notes, the one touched last first. */
scratch: () => request<ScratchNote[]>('/scratch'),
/** The scratchpad's notes, the one touched last first — or those a search finds, the best first. */
scratch: (q?: string) => request<ScratchNote[]>(`/scratch${q === undefined ? '' : `?q=${encodeURIComponent(q)}`}`),
/**
* Text written down somewhere: a card in an inbox, a line in today's entry,
* a note, the end of a document. With `note`, the text is that note's, and
* it leaves the scratchpad once the text has landed.
*/
capture: (input: { text: string; to: Captured['to']; space?: string; document?: string; note?: number }) =>
request<Captured>('/capture', { method: 'POST', body: body(input) }),
createScratch: (text: string) => request<ScratchNote>('/scratch', { method: 'POST', body: body({ body: text }) }),
updateScratch: (id: number, text: string) =>
request<ScratchNote>(`/scratch/${id}`, { method: 'PUT', body: body({ body: text }) }),
+45 -2
View File
@@ -189,6 +189,34 @@ const spaceNames = () => workspace.spaces.map((space) => space.slug)
/** The capture tokens' names, for Tab after `:token rm`; null until fetched. */
let tokenNames: string[] | null = null
/*
* The titles of the documents a search finds for what is typed after
* `:append`, for Tab — fetched as the typing goes, so by the time Tab is
* pressed they are usually there.
*/
let titlesFor = ''
let titles: string[] = []
function documentTitles(arg: string): string[] {
const wanted = arg.trim()
if (wanted && wanted !== titlesFor) {
titlesFor = wanted
void api.listNodes({ q: wanted }).then(
(found) => {
if (titlesFor !== wanted) return
titles = found.filter((node) => node.kind === 'document').map((node) => node.name.trim() || node.slug)
},
() => {},
)
}
return titles
}
/** The document in a tile, the focused one first: what a note goes to the end of unless told otherwise. */
function documentOnScreen(): string | undefined {
const tile = [workspace.tile, ...workspace.tiles].find((t) => t.open?.kind === 'document')
return tile?.open ? `/n/${tile.open.path.map((crumb) => crumb.slug).join('/')}` : undefined
}
/** The templates of the space last asked about, for `:new` to complete. */
let templateCache: { space: number; names: string[] } | null = null
@@ -547,6 +575,19 @@ export const commands: CommandSpec[] = [
await workspace.guard(() => workspace.scratch(arg.trim()))
},
},
{
name: 'append',
args: '[document]',
group: 'documents',
help: 'put the scratch note at the end of a document, by its title — Tab completes it — or the one on screen, and take it off the scratchpad',
complete: documentTitles,
run: async (arg) => {
if (!workspace.scratchOpen) return workspace.say('no scratch note — Q lowers the scratchpad', 'error')
const target = arg.trim() || documentOnScreen()
if (!target) return workspace.say('usage: :append <document> — or open the one it goes to', 'error')
await workspace.guard(() => workspace.appendScratchNote(target))
},
},
{
name: 'keep',
args: '[title]',
@@ -697,9 +738,11 @@ export const commands: CommandSpec[] = [
},
{
name: 'today',
args: '[text]',
group: 'documents',
help: "open today's diary entry, starting one if there is none yet",
run: async () => {
help: "open today's diary entry, starting one if there is none yet; with text, a timed line at its end without going there",
run: async (arg) => {
if (arg.trim()) return workspace.guard(() => workspace.logToday(arg.trim()))
await workspace.guard(() => workspace.openDay())
hooks.focusEditor()
},
+3 -1
View File
@@ -113,7 +113,7 @@ export const bindings = [
{ context: 'tree', keys: [']d'], action: 'nextEntry' },
{ context: 'global', keys: ['Ctrl-p'], action: 'find', label: 'Ctrl-P', help: 'find anything, in every space — Enter opens the result lit beside them, n goes on' },
{ context: 'global', keys: ['b'], action: 'board', help: "the board, in a tile: this space's, or the last one shown — 1 … 9 there shows another's" },
{ context: 'tree', keys: ['c'], action: 'capture', help: "capture a card into this space's inbox, without leaving here — reading a document, a card about it" },
{ context: 'tree', keys: ['c'], action: 'capture', help: "capture a card into this space's inbox, without leaving here — reading a document, a card about it; Tab on the line makes it a note, or a line in today's entry" },
{ context: 'tree', keys: ['q', 'Escape'], action: 'close', label: 'Esc · q', help: 'close the document, and its tile when there are several' },
{ context: 'tree', keys: ['/'], action: 'search', help: 'full-text search, inside the current space and its text files — Tab or Ctrl-N/P walk the results, Enter opens one' },
{ context: 'tree', keys: ['n'], action: 'next', label: 'n / N', help: 'next / previous search result, opened beside the results — :noh clears the search' },
@@ -190,6 +190,8 @@ export const bindings = [
{ context: 'drawer', keys: ['o', 'O', 'c'], action: 'new', label: 'o', caps: ['o'], hint: 'new', help: 'a new note, written straight away — it saves itself' },
{ context: 'drawer', keys: ['Enter', 'e', 'i', 'a'], action: 'write', label: 'Enter · e · i', caps: ['↵'], hint: 'write', help: 'write the note — Esc in normal mode, or :q, comes back to the notes' },
{ context: 'drawer', keys: ['K'], action: 'keep', hint: 'keep as document', help: 'keep the note: a document in the folder being browsed, titled by its first line — :keep <title> names it' },
{ context: 'drawer', keys: ['C'], action: 'card', hint: 'card', help: 'make the note a card where :card puts one — the first line its title, the rest its description' },
{ context: 'drawer', keys: ['A'], action: 'append', hint: 'append', help: 'put the note at the end of a document — :append, with the one on screen filled in; Tab completes a title' },
{ context: 'drawer', keys: ['dd'], action: 'drop', hint: 'drop', help: 'drop the note (asks y/N)' },
{ context: 'drawer', keys: [':'], action: 'line' },
{ context: 'drawer', keys: ['?'], action: 'help' },
+109 -19
View File
@@ -139,8 +139,9 @@ class Workspace {
/** Children of the current folder, or the results of a search. */
nodes = $state<NodeSummary[]>([])
/** The cards a search found, listed after the nodes. */
/** The cards a search found, listed after the nodes, and the notes after them. */
foundCards = $state<AgendaCard[]>([])
foundNotes = $state<ScratchNote[]>([])
cursor = $state(0)
query = $state('')
@@ -446,9 +447,9 @@ class Workspace {
return this.nodes[this.cursor]
}
/** How many rows the list has to walk: the nodes, then the cards a search found. */
/** How many rows the list has to walk: the nodes, then the cards and the notes a search found. */
get rows(): number {
return this.nodes.length + this.foundCards.length
return this.nodes.length + this.foundCards.length + this.foundNotes.length
}
/** The card a search found under the cursor, past the nodes it found. */
@@ -456,6 +457,12 @@ class Workspace {
return this.cursor >= this.nodes.length ? this.foundCards[this.cursor - this.nodes.length] : undefined
}
/** The note a search found under the cursor, past the cards. */
get foundNote(): ScratchNote | undefined {
const at = this.cursor - this.nodes.length - this.foundCards.length
return at >= 0 ? this.foundNotes[at] : undefined
}
/**
* What a verb acts on: the open document while the document pane has focus,
* the row under the cursor while the list does. Walking the list with a
@@ -1003,10 +1010,16 @@ class Workspace {
const keepId = keep ?? this.selected?.id
if (this.query) {
const space = this.#searchSpace
;[this.nodes, this.foundCards] = await Promise.all([api.listNodes({ q: this.query, space }), api.searchCards(this.query, space)])
// A note is in no space, so every search looks through them all.
;[this.nodes, this.foundCards, this.foundNotes] = await Promise.all([
api.listNodes({ q: this.query, space }),
api.searchCards(this.query, space),
api.scratch(this.query),
])
} else {
this.nodes = await api.listNodes({ parent: this.parentId ?? undefined })
this.foundCards = []
this.foundNotes = []
}
const index = this.nodes.findIndex((n) => n.id === keepId)
this.cursor = index >= 0 ? index : Math.min(this.cursor, Math.max(this.rows - 1, 0))
@@ -1094,6 +1107,8 @@ class Workspace {
async openSelected(edit = false) {
const found = this.foundCard
if (found) return this.turnTo(found)
const note = this.foundNote
if (note) return this.showNote(note.id)
const selected = this.selected
if (!selected) return
// A match from anywhere opens beside the results, as one from this space
@@ -1831,11 +1846,11 @@ class Workspace {
* a thought down must not cost the place you were at. On the board itself
* it goes on the list under the cursor instead.
*/
async createCard(title: string, nodeId: number | null = null) {
async createCard(title: string, nodeId: number | null = null, body = '') {
if (this.focus === 'board' || this.focus === 'card') {
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 })
const card = await api.createCard(list.id, { title, node_id: nodeId, body })
await this.reloadBoard(this.boardSpaceId, card.id)
await this.#reloadCardsAbout()
return this.say(nodeId === null ? `carded "${card.title}"` : `carded "${card.title}" → #${nodeId}`)
@@ -1845,7 +1860,7 @@ class Workspace {
if (!space) return this.say('no space has a board — :set board turns one on', 'error')
const inbox = (await api.board(space.id)).lists[0]
if (!inbox) return this.say(`${space.slug} has no lists — :board, then :list <name>`, 'error')
const card = await api.createCard(inbox.id, { title, node_id: nodeId })
const card = await api.createCard(inbox.id, { title, node_id: nodeId, body })
await this.loadAgenda()
await this.#reloadCardsAbout()
this.say(`carded "${card.title}" → ${space.slug}/${inbox.name}`)
@@ -1993,11 +2008,91 @@ class Workspace {
async dropScratchNote(say = true, note = this.scratchNote) {
if (!note) return
await api.deleteScratch(note.id)
this.scratchNotes = this.scratchNotes.filter((n) => n.id !== note.id)
this.scratchCursor = Math.min(this.scratchCursor, Math.max(this.scratchNotes.length - 1, 0))
this.#forgetNote(note.id)
if (say) this.say('scratch note dropped')
}
/** A note gone from the drawer and from the search results, the cursors kept in range. */
#forgetNote(id: number) {
this.scratchNotes = this.scratchNotes.filter((n) => n.id !== id)
this.scratchCursor = Math.min(this.scratchCursor, Math.max(this.scratchNotes.length - 1, 0))
if (!this.foundNotes.some((n) => n.id === id)) return
this.foundNotes = this.foundNotes.filter((n) => n.id !== id)
this.cursor = Math.min(this.cursor, Math.max(this.rows - 1, 0))
}
/** A note found by a search: the drawer down, with it under the cursor. */
async showNote(id: number) {
if (!this.scratchOpen) await this.toggleScratch()
else this.scratchNotes = await api.scratch()
const at = this.scratchNotes.findIndex((note) => note.id === id)
if (at < 0) return this.say('that note is gone', 'error')
this.scratchCursor = at
}
/** The note under the cursor, saved and closed if it was being written, for it to leave. */
async #leavingNote(): Promise<ScratchNote | undefined> {
if (this.scratchEditing && !(await this.leaveWriting())) return
const note = this.scratchNote
if (!note) this.say('no scratch note under the cursor', 'error')
return note
}
/** A note split as a card or a document takes it: the first line, a `# heading` or not, and the rest. */
#split(note: ScratchNote): { title: string; rest: string } {
const [first, ...rest] = note.body.replace(/^\s+/, '').split('\n')
return { title: first.replace(/^#+\s*/, '').trim().slice(0, 200), rest: rest.join('\n').replace(/^\n+/, '') }
}
/**
* `C`: the note under the cursor made a card, where `:card` would put one
* — the list under the board's cursor, or this space's inbox. The first
* line is its title, and the rest its description.
*/
async cardScratchNote() {
const note = await this.#leavingNote()
if (!note) return
const { title, rest } = this.#split(note)
if (!title) return this.say('the note has nothing to call a card by', 'error')
// Made first, dropped after: a failure in between leaves two copies, never none.
await this.createCard(title, null, rest)
await this.dropScratchNote(false, note)
}
/**
* `:append <document>`: the note under the cursor put at the end of a
* document, named as a link names it, and taken off the scratchpad. Open
* drafts are written first, and a tile showing the document reads it again.
*/
async appendScratchNote(document: string) {
const note = await this.#leavingNote()
if (!note) return
await this.flush()
const space = this.space?.role === 'diary' ? undefined : this.space?.slug
const landed = await api.capture({ text: note.body, to: 'document', document, space, note: note.id })
this.#forgetNote(note.id)
await this.#reread(landed.id)
this.say(`appended to ${landed.where}`)
}
/** `:today <text>`: a line at the end of today's diary entry, without going there. */
async logToday(text: string) {
await this.flush()
const landed = await api.capture({ text, to: 'diary' })
await this.#reread(landed.id)
this.say(`logged in ${landed.where}`)
}
/** Every tile showing `id` reads it again, after it was written somewhere else. */
async #reread(id: number) {
for (const tile of this.tiles) {
if (tile.open?.id !== id || tile.dirty) continue
const node = await api.getNode(id)
tile.open = node
tile.draft = { name: node.name, body: node.body, created_at: node.created_at }
}
}
/** `:scratch <text>`: a note from the command line, without the drawer. */
async scratch(text: string) {
const note = await api.createScratch(text)
@@ -2011,19 +2106,14 @@ class Workspace {
* line is the title — a `# heading` or not — and the rest the body.
*/
async keepScratchNote(title = '') {
if (this.scratchEditing && !(await this.leaveWriting())) return
const note = this.scratchNote
if (!note) return this.say('no scratch note to keep', 'error')
const note = await this.#leavingNote()
if (!note) return
const parent = this.parentId ?? this.spaceId ?? this.spaces.find((space) => space.role !== 'diary')?.id
if (parent === undefined || parent === null) return this.say('no space to keep it in', 'error')
let name = title.trim()
let body = note.body
if (!name) {
const [first, ...rest] = note.body.replace(/^\s+/, '').split('\n')
name = first.replace(/^#+\s*/, '').trim().slice(0, 200)
body = rest.join('\n').replace(/^\n+/, '')
}
const split = this.#split(note)
const name = title.trim() || split.title
const body = title.trim() ? note.body : split.rest
const node = await api.createNode({ parent_id: parent, name, body })
// Made first, dropped after: a failure in between leaves two copies, never none.
await this.dropScratchNote(false, note)
+5
View File
@@ -387,3 +387,8 @@ export function urgency(card: { due_at: number | null; done_at: number | null })
if (card.due_at < start) return 'overdue'
return card.due_at < start + 86400 ? 'today' : ''
}
/** What a note is called in a list: its first line that says something. */
export function headline(body: string): string {
return body.split('\n').find((line) => line.trim())?.replace(/^#+\s*/, '').trim() ?? ''
}