cards: a document and its cards, seen from both ends

A document now lists the cards about it: the ones whose notes it is and
the ones whose description links to it. GET /nodes/{id}/cards answers
that, and the node itself carries it as `cards`. A line under the
document's meta line says what is still open and when it is first due;
it and `gc` open a new cards part of the info sheet, where j/k picks a
card, Space, H/L, D, a and `:` act on it, and Enter turns it over on its
board. `c` while reading a document makes a card about it.

Card verbs act on the card in focus wherever it is looked at: the
board's cursor, the card turned over, the agenda's highlight, the card
picked in the info sheet, or a document's only open card. patchCard,
sendCard and toggleDone take that card, so patchAgendaCard,
sendAgendaCard and finishCard are gone, and :due, :repeat, :label and
:done no longer refuse to run away from the board.

Migration 0027 adds card_links, links for a card's description: indexed
on every write, backfilled on start, followed and rewritten on a rename
or move like a document's, and claimed when a title they named appears.
It also adds cards_fts over title, description and checklist steps;
GET /cards?q= searches it, and / lists the cards found after the nodes,
Enter turning one over on its board.
This commit is contained in:
nvrl committed 2026-10-22 10:45:20 +02:00
1 parent 5684614d0a
commit b8b3641e6e
21 files changed
+896 -274

No files matched your search

+26 -31
View File
@@ -78,7 +78,8 @@ const closeFocused = () => {
/**
* The info sheet over the open document, at `section` or where it was last
* left. Every part is read now, so each one's count is in its tab: the
* headings from the pane, the versions and backlinks from the server.
* headings from the pane, the versions and backlinks from the server, and
* the cards with the document.
*/
export async function openInfo(section?: InfoSection) {
if (!requireOpen()) return
@@ -90,6 +91,8 @@ export async function openInfo(section?: InfoSection) {
// of the same text at once would race.
await workspace.loadHistory()
await workspace.loadBacklinks()
// Picked afresh each time, from the first: the open card leads.
workspace.cardsCursor = 0
if (section) workspace.infoSection = section
workspace.openSheet('info')
})
@@ -97,8 +100,6 @@ export async function openInfo(section?: InfoSection) {
/** The board's cursor, or the card turned over beside it, has the keys. */
const onBoard = () => workspace.focus === 'board' || workspace.focus === 'card'
/** A card has the keys: on the board, its back, or highlighted in the agenda. */
const cardFocused = () => onBoard() || workspace.focus === 'agenda'
const requireOpen = () => {
if (!workspace.open) {
@@ -162,8 +163,8 @@ const describeTile = (index: number) => {
/** Move the cursor and follow it, so `:next` reads as well as `j` then `Enter`. */
const jumpTo = async (index: number) => {
if (workspace.nodes.length === 0) return workspace.say('nothing here', 'error')
workspace.cursor = Math.min(Math.max(index, 0), workspace.nodes.length - 1)
if (workspace.rows === 0) return workspace.say('nothing here', 'error')
workspace.cursor = Math.min(Math.max(index, 0), workspace.rows - 1)
await workspace.guard(() => workspace.openSelected())
}
@@ -509,19 +510,16 @@ export const commands: CommandSpec[] = [
name: 'due',
args: '<when|->',
group: 'board',
help: 'due date of the card — on the board, its back, or highlighted in the agenda: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears',
help: "due date of the card in focus — on the board, its back, in the agenda, or the open document's: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears",
complete: () => ['today', 'tm', 'fri', '+3d'],
run: async (arg) => {
const agenda = workspace.focus === 'agenda'
if (!cardFocused()) return workspace.say('no card in focus — :board', 'error')
const set = (due_at: number | null) =>
agenda ? workspace.patchAgendaCard({ due_at }) : workspace.patchCard({ due_at })
const target = arg.trim()
if (target === '-' || target === '') return workspace.guard(() => set(null))
if (target === '-' || target === '') return workspace.guard(() => workspace.patchCard({ due_at: null }))
const at = parseDue(target)
if (at === null) return workspace.say(`not a date: ${target} — try fri, +3d or 2026-09-30`, 'error')
await workspace.guard(() => set(at))
workspace.say(`due ${formatDay(at)}`)
await workspace.guard(async () => {
if (await workspace.patchCard({ due_at: at })) workspace.say(`due ${formatDay(at)}`)
})
},
},
{
@@ -531,12 +529,10 @@ export const commands: CommandSpec[] = [
help: 'once the card is done, a copy comes back in the inbox: daily, weekly, monthly, yearly, +3d, +2w, +6m — - stops it',
complete: () => ['daily', 'weekly', 'monthly', 'yearly', '+2w', '-'],
run: async (arg) => {
const agenda = workspace.focus === 'agenda'
if (!cardFocused()) return workspace.say('no card in focus — :board', 'error')
const rule = arg.trim()
const repeat = rule === '-' || rule === '' ? null : rule
await workspace.guard(async () => {
const card = await (agenda ? workspace.patchAgendaCard({ repeat }) : workspace.patchCard({ repeat }))
const card = await workspace.patchCard({ repeat })
// The server's spelling of the rule: `+14d` is kept as `+2w`.
if (card) workspace.say(card.repeat === null ? 'repeats no more' : `repeats ${card.repeat} once done`)
})
@@ -545,15 +541,8 @@ export const commands: CommandSpec[] = [
{
name: 'done',
group: 'board',
help: 'move the selected card into the done list, or back out to the inbox',
run: () => {
if (workspace.focus === 'agenda') {
const card = workspace.agendaCards[workspace.agendaCursor]
return card ? workspace.guard(() => workspace.finishCard(card)) : workspace.say('no card highlighted', 'error')
}
if (!onBoard()) return workspace.say('no card in focus — :board', 'error')
return workspace.guard(() => workspace.toggleDone())
},
help: 'move the card in focus into the done list, or back out to the inbox',
run: () => workspace.guard(() => workspace.toggleDone()),
},
{
name: 'scratch',
@@ -603,8 +592,8 @@ export const commands: CommandSpec[] = [
help: 'put a label on the card, or take it off when it has it — each name keeps its colour everywhere',
complete: () => workspace.boardLabels,
run: async (arg) => {
if (!onBoard()) return workspace.say('no card in focus — :board', 'error')
if (!arg.trim()) return workspace.say(workspace.card?.labels.join(' · ') || 'no labels — :label <name> adds one')
const card = workspace.focusedCard
if (!arg.trim() && card) return workspace.say(card.labels.join(' · ') || 'no labels — :label <name> adds one')
await workspace.guard(() => workspace.toggleLabel(arg))
},
},
@@ -840,7 +829,7 @@ export const commands: CommandSpec[] = [
name: 'last',
group: 'documents',
help: 'open the last node in the list',
run: () => jumpTo(workspace.nodes.length - 1),
run: () => jumpTo(workspace.rows - 1),
},
{
name: 'random',
@@ -953,6 +942,12 @@ export const commands: CommandSpec[] = [
await openInfo('backlinks')
},
},
{
name: 'cards',
group: 'documents',
help: 'the cards about the open document — its own, and the ones whose summary links here: j/k picks the one :due, :label, Space and H/L act on, Enter turns it over on its board (gc)',
run: () => openInfo('cards'),
},
{
name: 'trash',
args: '[purge]',
@@ -1126,13 +1121,13 @@ export const commands: CommandSpec[] = [
},
{
name: 'info',
args: '[outline|history|backlinks|share]',
args: '[outline|history|backlinks|cards|share]',
group: 'documents',
help: 'what is known about the open document: its headings, earlier versions, backlinks and sharing — Tab between them (gi)',
help: 'what is known about the open document: its headings, earlier versions, backlinks, cards and sharing — Tab between them (gi)',
complete: () => infoSections,
run: (arg) => {
const section = arg.trim()
if (section && !infoSections.includes(section as InfoSection)) return workspace.say('usage: :info [outline|history|backlinks|share]', 'error')
if (section && !infoSections.includes(section as InfoSection)) return workspace.say('usage: :info [outline|history|backlinks|cards|share]', 'error')
return openInfo((section || undefined) as InfoSection | undefined)
},
},