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

+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()
},