mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
5684614d0a
commit
b8b3641e6e
21 files changed
+896
-274
No files matched your search
@@ -77,7 +77,7 @@
|
||||
</button>
|
||||
{#if isTouchDevice}
|
||||
<!-- Space, for a thumb. -->
|
||||
<button class="finish" aria-label="done: {card.title}" onclick={() => workspace.guard(() => workspace.finishCard(card))}>
|
||||
<button class="finish" aria-label="done: {card.title}" onclick={() => workspace.guard(() => workspace.toggleDone(card))}>
|
||||
<Icon name="check" size={18} />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
const query = line.slice(1)
|
||||
await workspace.guard(async () => {
|
||||
if (query && workspace.query !== query) await workspace.search(query, global)
|
||||
if (query && workspace.selected) await workspace.openSelected()
|
||||
if (query && (workspace.selected || workspace.foundCard)) await workspace.openSelected()
|
||||
})
|
||||
hooks.focusList()
|
||||
return
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { workspace, type Tile } from './store.svelte'
|
||||
import Icon from './Icon.svelte'
|
||||
import {
|
||||
formatDay,
|
||||
formatStamp,
|
||||
formatWeekday,
|
||||
headingsIn,
|
||||
@@ -26,6 +27,9 @@
|
||||
const words = $derived(
|
||||
(tile.editing ? tile.draft.body : doc.body).split(/\s+/).filter(Boolean).length,
|
||||
)
|
||||
/** What is still to be done about it, and when the first of that is due. */
|
||||
const tasks = $derived(doc.cards.filter((card) => card.done_at === null))
|
||||
const due = $derived(tasks.reduce<number | null>((first, card) => (card.due_at !== null && (first === null || card.due_at < first) ? card.due_at : first), null))
|
||||
let reader = $state<HTMLElement>()
|
||||
let article = $state<HTMLElement>()
|
||||
let title = $state<HTMLInputElement>()
|
||||
@@ -162,6 +166,18 @@
|
||||
<span class="field public"><span class="ring"></span>public</span>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- The work about it, in a line: what is still to do here — gc lists it. -->
|
||||
{#if tasks.length > 0}
|
||||
<button class="meta tasks" title="cards (gc)" onclick={() => openInfo('cards')}>
|
||||
<span class="field">
|
||||
<span class="caption">{tasks.length === 1 ? 'card' : 'cards'}</span>
|
||||
{#if tasks.length === 1}<span class="what">{tasks[0].title}</span> · {tasks[0].list_name}{:else}{tasks.length} open{/if}
|
||||
</span>
|
||||
{#if due !== null}
|
||||
<span class="field" class:late={due < Date.now() / 1000}><span class="caption">due</span>{formatWeekday(due)} {formatDay(due)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if tile.editing}
|
||||
@@ -277,6 +293,8 @@
|
||||
.meta:hover .caption, .meta:focus-visible .caption { color: var(--light); }
|
||||
.caption { margin-right: 8px; color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.public { display: flex; align-items: center; gap: 6px; color: var(--light); }
|
||||
.tasks .what { color: var(--light); }
|
||||
.tasks .late { color: var(--alarm); }
|
||||
.ring { width: 7px; height: 7px; flex: none; border: 1.5px solid var(--light); border-radius: 999px; }
|
||||
|
||||
.title {
|
||||
|
||||
+53
-5
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" module>
|
||||
import type { Backlink, NodeSummary, RevisionSummary } from './api'
|
||||
import type { AgendaCard, Backlink, NodeSummary, RevisionSummary } from './api'
|
||||
import { workspace, type InfoSection } from './store.svelte'
|
||||
import { formatStamp } from './util'
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
* What the keys in actions.ts do here too, so a key and a click are the
|
||||
* same thing. The info sheet holds what is known about the open document
|
||||
* besides its text: its headings, its earlier versions, what links to it,
|
||||
* and whether it is shared.
|
||||
* the cards about it, and whether it is shared.
|
||||
*/
|
||||
export const infoSections: InfoSection[] = ['outline', 'history', 'backlinks', 'share']
|
||||
export const infoSections: InfoSection[] = ['outline', 'history', 'backlinks', 'cards', 'share']
|
||||
|
||||
/** Tab and Shift-Tab: the next part along, round the ends. */
|
||||
export function turnInfo(by: number) {
|
||||
@@ -44,6 +44,12 @@
|
||||
return workspace.guard(() => workspace.openPath(link.path))
|
||||
}
|
||||
|
||||
/** Enter on a card: the sheet away, and the card turned over on its board. */
|
||||
export function turnCard(card: AgendaCard) {
|
||||
workspace.closeSheet()
|
||||
return workspace.guard(() => workspace.turnTo(card))
|
||||
}
|
||||
|
||||
// Sharing is a key away; taking a link back from its readers asks first.
|
||||
export async function toggleSharing(node: NodeSummary | null | undefined) {
|
||||
if (!node) return workspace.say('nothing selected', 'error')
|
||||
@@ -66,14 +72,28 @@
|
||||
restore: 'replaced by a restore',
|
||||
}
|
||||
const reason = (summary: RevisionSummary) => REASONS[summary.reason] ?? summary.reason
|
||||
|
||||
/** Where a card stands, as one word: done, or the list it is on. */
|
||||
const standing = (card: AgendaCard) => (card.done_at !== null ? 'done' : card.list_name)
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Sheet from './Sheet.svelte'
|
||||
import { diffLines, folded } from './diff'
|
||||
import { relative } from './util'
|
||||
import { formatDay, relative } from './util'
|
||||
|
||||
const doc = $derived(workspace.open)
|
||||
|
||||
/** `work · doing · due 2026-10-23 · mentions it`: a card's line under its title. */
|
||||
const where = (card: AgendaCard) =>
|
||||
[
|
||||
card.space_slug,
|
||||
standing(card),
|
||||
card.due_at !== null && card.done_at === null ? `due ${formatDay(card.due_at)}` : '',
|
||||
card.node_id !== doc?.id ? 'mentions it' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
const section = $derived(workspace.infoSection)
|
||||
|
||||
// The shallowest level in the document is the left edge, so a text that
|
||||
@@ -95,11 +115,18 @@
|
||||
['outline', `outline ${workspace.outline.length}`],
|
||||
['history', `history ${workspace.history.length}`],
|
||||
['backlinks', `linked from ${workspace.backlinks.length}`],
|
||||
['cards', `cards ${doc?.cards.filter((card) => card.done_at === null).length ?? 0}`],
|
||||
['share', doc?.shared ? 'shared' : 'private'],
|
||||
])
|
||||
|
||||
const cursor = $derived(
|
||||
{ outline: workspace.outlineCursor, history: workspace.historyCursor, backlinks: workspace.backlinksCursor, share: undefined }[section],
|
||||
{
|
||||
outline: workspace.outlineCursor,
|
||||
history: workspace.historyCursor,
|
||||
backlinks: workspace.backlinksCursor,
|
||||
cards: workspace.cardsCursor,
|
||||
share: undefined,
|
||||
}[section],
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -186,6 +213,25 @@
|
||||
<li class="muted">nothing links here yet — a link is [text](/n/…) or [[title]].</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if section === 'cards'}
|
||||
<ul class="items">
|
||||
{#each doc?.cards ?? [] as card, index (card.id)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||
<li
|
||||
class="item"
|
||||
class:at={index === workspace.cardsCursor}
|
||||
class:done={card.done_at !== null}
|
||||
data-index={index}
|
||||
onpointerenter={() => (workspace.cardsCursor = index)}
|
||||
onclick={() => turnCard(card)}
|
||||
>
|
||||
<span class="name">{card.title}</span>
|
||||
<span class="muted">{where(card)}</span>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="muted">no card about this document — c makes one, and a card's [[title]] counts too.</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<div class="share">
|
||||
{#if sharePath}
|
||||
@@ -236,6 +282,8 @@
|
||||
.row.added { background: color-mix(in oklab, var(--light) 14%, transparent); }
|
||||
.row.fold { color: var(--muted); }
|
||||
|
||||
.done .name { color: var(--muted); text-decoration: line-through; }
|
||||
|
||||
.context { font-size: 12px; color: var(--subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.share { display: flex; flex-direction: column; gap: 12px; font-size: 12px; }
|
||||
|
||||
@@ -77,7 +77,10 @@
|
||||
<div class="head label rule">
|
||||
<span class="marker"></span>
|
||||
<span class="name">{workspace.space?.slug ?? 'diary'}</span>
|
||||
<span class="count">{workspace.nodes.length} {workspace.nodes.length === 1 ? 'item' : 'items'}</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}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if workspace.query}
|
||||
@@ -144,7 +147,22 @@
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{:else}
|
||||
{/each}
|
||||
<!-- The cards a search found, after the nodes: Enter turns one over on its board. -->
|
||||
{#if workspace.foundCards.length > 0}
|
||||
<li class="daybreak label rule"><span>cards</span></li>
|
||||
{/if}
|
||||
{#each workspace.foundCards as card, at (card.id)}
|
||||
{@const index = workspace.nodes.length + at}
|
||||
<li class="item">
|
||||
<button class="row card" class:active={index === workspace.cursor} class:done={card.done_at !== null} data-index={index} onclick={() => pick(index)}>
|
||||
<span class="dot"></span>
|
||||
<span class="title">{card.title}<span class="where">{card.space_slug}/{card.list_name}</span></span>
|
||||
<span class="aside">{card.done_at !== null ? 'done' : card.due_at !== null ? formatDay(card.due_at) : 'card'}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{#if workspace.rows === 0}
|
||||
<li class="empty muted">
|
||||
{#if workspace.query}
|
||||
no matches
|
||||
@@ -152,7 +170,7 @@
|
||||
nothing here yet — <span class="kbd">o</span> writes, <span class="kbd">⇧ O</span> makes a folder, files dropped here land here
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -246,6 +264,9 @@
|
||||
}
|
||||
.row.open .dot { background: var(--light); }
|
||||
.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.card.done .title { color: var(--muted); text-decoration: line-through; }
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
|
||||
+35
-14
@@ -34,7 +34,7 @@ const spaceIndex = (key: string) => {
|
||||
const reading = () => !!workspace.open && !workspace.editing && workspace.pane === 'document'
|
||||
|
||||
/** A cursor down a sheet's list, kept on it. */
|
||||
function step(cursor: 'outlineCursor' | 'mediaCursor' | 'trashCursor' | 'backlinksCursor', length: number, by: number) {
|
||||
function step(cursor: 'outlineCursor' | 'mediaCursor' | 'trashCursor' | 'backlinksCursor' | 'cardsCursor', length: number, by: number) {
|
||||
workspace[cursor] = Math.min(Math.max(workspace[cursor] + by, 0), Math.max(length - 1, 0))
|
||||
}
|
||||
|
||||
@@ -209,6 +209,33 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Its cards: j/k picks the one the card verbs act on — Space, H/L, `:due`,
|
||||
* `:label` — and Enter turns it over on its board.
|
||||
*/
|
||||
cards: {
|
||||
down: () => step('cardsCursor', workspace.open?.cards.length ?? 0, 1),
|
||||
up: () => step('cardsCursor', workspace.open?.cards.length ?? 0, -1),
|
||||
bottom: () => step('cardsCursor', workspace.open?.cards.length ?? 0, Infinity),
|
||||
turn: () => {
|
||||
const card = workspace.open?.cards[workspace.cardsCursor]
|
||||
if (!card) return
|
||||
workspace.closeSheet()
|
||||
guard(() => workspace.turnTo(card))
|
||||
},
|
||||
done: () => guard(() => workspace.toggleDone()),
|
||||
sendLeft: () => guard(() => workspace.sendCard(-1)),
|
||||
sendRight: () => guard(() => workspace.sendCard(1)),
|
||||
due: () => {
|
||||
if (workspace.focusedCard) line(':due ')
|
||||
},
|
||||
new: () => line(':card! '),
|
||||
label: () => {
|
||||
if (workspace.focusedCard) line(':label ')
|
||||
},
|
||||
line: () => line(':'),
|
||||
},
|
||||
|
||||
/** Its history: j/k through the versions, Enter brings the one shown back. */
|
||||
history: {
|
||||
down: () => moveHistory(1),
|
||||
@@ -307,7 +334,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
|
||||
down: () => void (reading() ? hooks.scrollReader(1) : workspace.move(1)),
|
||||
up: () => void (reading() ? hooks.scrollReader(-1) : workspace.move(-1)),
|
||||
top: () => void (reading() ? hooks.scrollReader('top') : (workspace.cursor = 0)),
|
||||
bottom: () => void (reading() ? hooks.scrollReader('bottom') : (workspace.cursor = Math.max(workspace.nodes.length - 1, 0))),
|
||||
bottom: () => void (reading() ? hooks.scrollReader('bottom') : (workspace.cursor = Math.max(workspace.rows - 1, 0))),
|
||||
open: (key) => {
|
||||
const enter = key === 'Enter' || key === 'Shift-Enter'
|
||||
if (enter && reading() && hooks.fileAction()) return
|
||||
@@ -341,6 +368,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
|
||||
outline: () => void runCommand('outline'),
|
||||
history: () => void runCommand('history'),
|
||||
backlinks: () => void runCommand('backlinks'),
|
||||
cards: () => void runCommand('cards'),
|
||||
inTab: () => openInTab(),
|
||||
// `f` already fills the screen, so the hints vimium puts on `f` live on
|
||||
// its other key.
|
||||
@@ -357,7 +385,8 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
|
||||
drop: () => guard(() => workspace.drop()),
|
||||
new: () => void runCommand('new'),
|
||||
// Capture: a thought goes on this space's inbox without leaving here.
|
||||
capture: () => line(':card '),
|
||||
// Reading a document, the card is about it.
|
||||
capture: () => line(workspace.focus === 'node' && workspace.open?.kind === 'document' ? ':card! ' : ':card '),
|
||||
// The name has to come from somewhere, and the command line is where
|
||||
// names are typed — this only saves typing `:mkdir `.
|
||||
mkdir: () => line(':mkdir '),
|
||||
@@ -445,16 +474,13 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
|
||||
// Beside the agenda, so `q` on it comes back here.
|
||||
else if (past) guard(() => workspace.openPath(past.path))
|
||||
},
|
||||
finish: () => {
|
||||
const card = workspace.agendaCards[workspace.agendaCursor]
|
||||
if (card) guard(() => workspace.finishCard(card))
|
||||
},
|
||||
finish: () => guard(() => workspace.toggleDone()),
|
||||
due: () => {
|
||||
if (workspace.agendaCards[workspace.agendaCursor]) line(':due ')
|
||||
},
|
||||
line: () => line(':'),
|
||||
sendLeft: () => sendAgendaCard(-1),
|
||||
sendRight: () => sendAgendaCard(1),
|
||||
sendLeft: () => guard(() => workspace.sendCard(-1)),
|
||||
sendRight: () => guard(() => workspace.sendCard(1)),
|
||||
today: () => void runCommand('today'),
|
||||
},
|
||||
|
||||
@@ -520,8 +546,3 @@ function goToSpace(index: number) {
|
||||
workspace.closeSheet()
|
||||
guard(() => workspace.enterSpace(entry.space.id))
|
||||
}
|
||||
|
||||
function sendAgendaCard(by: 1 | -1) {
|
||||
const card = workspace.agendaCards[workspace.agendaCursor]
|
||||
if (card) guard(() => workspace.sendAgendaCard(card, by))
|
||||
}
|
||||
+13
-2
@@ -53,6 +53,8 @@ export interface WorkspaceNode extends NodeSummary {
|
||||
path: Crumb[]
|
||||
/** How many documents link here. */
|
||||
backlinks: number
|
||||
/** The cards about it — its own, and those whose summary links here — open ones first. */
|
||||
cards: AgendaCard[]
|
||||
/** Where each `[[title]]` in the body leads, as an /n/ path; a title that leads nowhere is missing. */
|
||||
wikilinks: Record<string, string>
|
||||
}
|
||||
@@ -191,12 +193,16 @@ export interface Moved extends Card {
|
||||
again: Card | null
|
||||
}
|
||||
|
||||
/** An open card as the agenda lists it, with the board it lives on. */
|
||||
/**
|
||||
* A card with the board it lives on: as the agenda lists it, as a document
|
||||
* lists the cards about it, and as a search finds it.
|
||||
*/
|
||||
export interface AgendaCard extends Card {
|
||||
space_id: number
|
||||
space_slug: string
|
||||
list_name: string
|
||||
role: 'inbox' | 'doing'
|
||||
/** The agenda only ever has the first two. */
|
||||
role: 'inbox' | 'doing' | 'done'
|
||||
}
|
||||
|
||||
export interface Agenda {
|
||||
@@ -456,6 +462,11 @@ export const api = {
|
||||
},
|
||||
|
||||
getNode: (id: number) => request<WorkspaceNode>(`/nodes/${id}`),
|
||||
/** The cards about a document, as `getNode` has them. */
|
||||
nodeCards: (id: number) => request<AgendaCard[]>(`/nodes/${id}/cards`),
|
||||
/** The cards whose title, summary or steps match — in one space's board with `space`. */
|
||||
searchCards: (q: string, space?: number) =>
|
||||
request<AgendaCard[]>(`/cards?${new URLSearchParams(space === undefined ? { q } : { q, space: String(space) })}`),
|
||||
|
||||
/** `work/einarbeiten/01-was-ist-eebus` — the node behind a `/n/...` address. */
|
||||
resolvePath: (path: string) => request<WorkspaceNode>(`/resolve?path=${encodeURIComponent(path)}`),
|
||||
|
||||
+26
-31
@@ -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)
|
||||
},
|
||||
},
|
||||
|
||||
+17
-2
@@ -23,6 +23,7 @@ export type Context =
|
||||
| 'media'
|
||||
| 'trash'
|
||||
| 'backlinks'
|
||||
| 'cards'
|
||||
| 'history'
|
||||
| 'share'
|
||||
| 'card'
|
||||
@@ -89,7 +90,7 @@ export const bindings = [
|
||||
{ context: 'tree', keys: ['zh', 'zl', 'zH', 'zL'], action: 'sideways', label: 'zl / zh · zL / zH', help: 'the wide table or code block at the top of the view, sideways — a little, or half its width' },
|
||||
{ context: 'tree', keys: [']]'], action: 'nextHeading', label: ']] / [[', help: 'next / previous heading of the document — in the editor too' },
|
||||
{ context: 'tree', keys: ['[['], action: 'previousHeading' },
|
||||
{ context: 'tree', keys: ['gi'], action: 'info', help: "the info sheet: the open document's headings, earlier versions, backlinks and sharing — Tab between them" },
|
||||
{ context: 'tree', keys: ['gi'], action: 'info', help: "the info sheet: the open document's headings, earlier versions, backlinks, cards and sharing — Tab between them" },
|
||||
{ context: 'tree', keys: ['gO'], action: 'outline', help: 'the headings as a list: j/k, Enter jumps there' },
|
||||
{ context: 'tree', keys: ['F'], action: 'hints', help: 'hints over the links and buttons in view — type one to follow it' },
|
||||
{ context: 'tree', label: 'gf / gx', help: 'in the editor: follow the link under the cursor / open it in a tab' },
|
||||
@@ -112,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" },
|
||||
{ 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: ['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' },
|
||||
@@ -123,6 +124,7 @@ export const bindings = [
|
||||
{ context: 'tree', keys: ['u'], action: 'undo', help: 'undo the last delete: it comes back out of the trash — :trash has the rest' },
|
||||
{ context: 'tree', keys: ['gh'], action: 'history', help: "the open document's earlier versions, as diffs — Enter brings one back" },
|
||||
{ context: 'tree', keys: ['gb'], action: 'backlinks', help: 'the documents that link to the open one — [[title]] links by title, [[ completes one while writing' },
|
||||
{ context: 'tree', keys: ['gc'], action: 'cards', help: 'the cards about the open document — j/k picks the one :due, :label, Space and H/L act on, Enter turns it over on its board' },
|
||||
{ context: 'global', keys: [':'], action: 'line', help: 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before' },
|
||||
{ context: 'always', keys: ['Ctrl-s', 'Meta-s'], action: 'save', typing: true, label: 'Ctrl-S', help: 'save, from anywhere' },
|
||||
{ context: 'global', keys: ['?'], action: 'help', help: 'this screen, from the editor too — h/l a column, / filters it down' },
|
||||
@@ -259,6 +261,19 @@ export const bindings = [
|
||||
{ context: 'backlinks', keys: ['G'], action: 'bottom' },
|
||||
{ context: 'backlinks', keys: ['Enter'], action: 'open', hint: 'open' },
|
||||
|
||||
{ context: 'cards', keys: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'move' },
|
||||
{ context: 'cards', keys: ['k', 'ArrowUp'], action: 'up' },
|
||||
{ context: 'cards', keys: ['G'], action: 'bottom' },
|
||||
{ context: 'cards', keys: ['Enter'], action: 'turn', hint: 'turn over' },
|
||||
{ context: 'cards', keys: ['Space'], action: 'done', hint: 'done' },
|
||||
{ context: 'cards', keys: ['H'], action: 'sendLeft', caps: ['H', 'L'], hint: 'send' },
|
||||
{ context: 'cards', keys: ['L'], action: 'sendRight' },
|
||||
{ context: 'cards', keys: ['D'], action: 'due', hint: 'due' },
|
||||
{ context: 'cards', keys: ['o', 'c'], action: 'new', caps: ['o'], hint: 'new' },
|
||||
{ context: 'cards', keys: ['a'], action: 'label', hint: 'label' },
|
||||
// The sheet stays up behind the line, so `:due` and `:repeat` know which card.
|
||||
{ context: 'cards', keys: [':'], action: 'line' },
|
||||
|
||||
{ context: 'history', keys: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'earlier / later' },
|
||||
{ context: 'history', keys: ['k', 'ArrowUp'], action: 'up' },
|
||||
{ context: 'history', keys: ['G'], action: 'bottom' },
|
||||
|
||||
+153
-79
@@ -7,6 +7,7 @@ import {
|
||||
type AgendaCard,
|
||||
type Backlink,
|
||||
type Board,
|
||||
type BoardList,
|
||||
type Card,
|
||||
type Crumb,
|
||||
type DiaryEntry,
|
||||
@@ -35,7 +36,7 @@ export type View = Exclude<TileKind, 'node'>
|
||||
/** What floats over the tiles; one at a time. */
|
||||
export type Sheet = 'help' | 'media' | 'overview' | 'actions' | 'verify' | 'info' | 'trash'
|
||||
/** The info sheet's parts: what the open document holds, and what is around it. */
|
||||
export type InfoSection = 'outline' | 'history' | 'backlinks' | 'share'
|
||||
export type InfoSection = 'outline' | 'history' | 'backlinks' | 'cards' | 'share'
|
||||
export type Flash = { text: string; kind: 'info' | 'error' } | null
|
||||
|
||||
/**
|
||||
@@ -137,6 +138,8 @@ 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. */
|
||||
foundCards = $state<AgendaCard[]>([])
|
||||
cursor = $state(0)
|
||||
query = $state('')
|
||||
|
||||
@@ -407,6 +410,8 @@ class Workspace {
|
||||
/** `:backlinks`: the documents that link to the open one. */
|
||||
backlinks = $state<Backlink[]>([])
|
||||
backlinksCursor = $state(0)
|
||||
/** The info sheet's cards: where its cursor is in the open document's. */
|
||||
cardsCursor = $state(0)
|
||||
overview = $state<SpaceOverview[]>([])
|
||||
overviewCursor = $state(0)
|
||||
|
||||
@@ -434,6 +439,16 @@ class Workspace {
|
||||
return this.nodes[this.cursor]
|
||||
}
|
||||
|
||||
/** How many rows the list has to walk: the nodes, then the cards a search found. */
|
||||
get rows(): number {
|
||||
return this.nodes.length + this.foundCards.length
|
||||
}
|
||||
|
||||
/** The card a search found under the cursor, past the nodes it found. */
|
||||
get foundCard(): AgendaCard | undefined {
|
||||
return this.cursor >= this.nodes.length ? this.foundCards[this.cursor - this.nodes.length] : 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
|
||||
@@ -460,6 +475,38 @@ class Workspace {
|
||||
return this.list?.cards[this.cardCursor]
|
||||
}
|
||||
|
||||
/**
|
||||
* The card the card verbs act on, wherever it is looked at: picked in the
|
||||
* info sheet, under the board's cursor or turned over beside it,
|
||||
* highlighted in the agenda — or, reading a document, the one open card
|
||||
* about it, when there is only one.
|
||||
*/
|
||||
get focusedCard(): Card | AgendaCard | undefined {
|
||||
const about = this.open?.cards ?? []
|
||||
if (this.overlay === 'info' && this.infoSection === 'cards') return about[this.cardsCursor]
|
||||
switch (this.focus) {
|
||||
case 'board':
|
||||
case 'card':
|
||||
return this.card
|
||||
case 'agenda':
|
||||
return this.agendaCards[this.agendaCursor]
|
||||
case 'node': {
|
||||
const open = about.filter((card) => card.done_at === null)
|
||||
return open.length === 1 ? open[0] : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The focused card, or `undefined` once it has been said why there is none. */
|
||||
#needCard(): Card | AgendaCard | undefined {
|
||||
const card = this.focusedCard
|
||||
if (card) return card
|
||||
const open = this.focus === 'node' ? (this.open?.cards ?? []).filter((c) => c.done_at === null).length : 0
|
||||
if (open > 1) this.say(`${open} open cards about this — gc picks one`, 'error')
|
||||
else if (this.focus === 'node' && this.open?.kind === 'document') this.say('no open card about this document — c makes one', 'error')
|
||||
else this.say('no card in focus — :board', 'error')
|
||||
}
|
||||
|
||||
/**
|
||||
* The agenda in the order it is drawn and walked, bucketed by urgency. The
|
||||
* day is the server's, in the workspace's timezone, so a browser on a trip
|
||||
@@ -946,11 +993,20 @@ class Workspace {
|
||||
/** `keep` follows a node that just arrived, instead of holding the cursor. */
|
||||
async refresh(keep?: number) {
|
||||
const keepId = keep ?? this.selected?.id
|
||||
this.nodes = this.query
|
||||
? await api.listNodes({ q: this.query, space: this.globalSearch ? undefined : (this.spaceId ?? undefined) })
|
||||
: await api.listNodes({ parent: this.parentId ?? undefined })
|
||||
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)])
|
||||
} else {
|
||||
this.nodes = await api.listNodes({ parent: this.parentId ?? undefined })
|
||||
this.foundCards = []
|
||||
}
|
||||
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))
|
||||
this.cursor = index >= 0 ? index : Math.min(this.cursor, Math.max(this.rows - 1, 0))
|
||||
}
|
||||
|
||||
/** The space a search keeps to; none for one across every space. */
|
||||
get #searchSpace(): number | undefined {
|
||||
return this.globalSearch ? undefined : (this.spaceId ?? undefined)
|
||||
}
|
||||
|
||||
async search(query: string, global = this.globalSearch) {
|
||||
@@ -962,25 +1018,25 @@ class Workspace {
|
||||
|
||||
/**
|
||||
* `n` / `N`: the next or previous search result after the cursor, opened
|
||||
* beside the results so the next `n` goes on from there. A folder is only
|
||||
* highlighted: going into it would end the search. Past the last result it
|
||||
* starts again at the first, as vim's `wrapscan` does.
|
||||
* beside the results so the next `n` goes on from there. A folder or a card
|
||||
* is only highlighted: going to it would end the search. Past the last
|
||||
* result it starts again at the first, as vim's `wrapscan` does.
|
||||
*/
|
||||
async nextResult(delta: 1 | -1) {
|
||||
if (!this.query) return this.say('no search — / starts one', 'error')
|
||||
const count = this.nodes.length
|
||||
const count = this.rows
|
||||
if (count === 0) return this.say(`no results for "${this.query}"`, 'error')
|
||||
const next = this.cursor + delta
|
||||
if (next >= count) this.say('search hit BOTTOM, continuing at TOP')
|
||||
else if (next < 0) this.say('search hit TOP, continuing at BOTTOM')
|
||||
this.cursor = (next + count) % count
|
||||
const node = this.nodes[this.cursor]
|
||||
if (!isContainer(node)) await this.openNode(node.id)
|
||||
const node = this.selected
|
||||
if (node && !isContainer(node)) await this.openNode(node.id)
|
||||
}
|
||||
|
||||
move(delta: number) {
|
||||
if (this.nodes.length === 0) return
|
||||
this.cursor = Math.min(Math.max(this.cursor + delta, 0), this.nodes.length - 1)
|
||||
if (this.rows === 0) return
|
||||
this.cursor = Math.min(Math.max(this.cursor + delta, 0), this.rows - 1)
|
||||
}
|
||||
|
||||
async openNode(id: number, edit = false) {
|
||||
@@ -1028,6 +1084,8 @@ class Workspace {
|
||||
|
||||
/** What Enter does: descend into a folder, open a document or a file. */
|
||||
async openSelected(edit = false) {
|
||||
const found = this.foundCard
|
||||
if (found) return this.turnTo(found)
|
||||
const selected = this.selected
|
||||
if (!selected) return
|
||||
// A match from anywhere opens beside the results, as one from this space
|
||||
@@ -1470,44 +1528,13 @@ class Workspace {
|
||||
await this.showView('board')
|
||||
}
|
||||
|
||||
/** Space in the agenda: into that board's done list, from wherever it is. */
|
||||
async finishCard(card: AgendaCard) {
|
||||
const done = (await api.board(card.space_id)).lists.at(-1)
|
||||
if (!done) return
|
||||
const moved = await api.moveCard(card.id, done.id, done.cards.length)
|
||||
await this.loadAgenda()
|
||||
if (this.boardSpaceId === card.space_id) await this.reloadBoard()
|
||||
this.say(doneSaid(moved))
|
||||
}
|
||||
|
||||
/** `:due` or `:repeat` with the agenda up: the highlighted card, on its own board. */
|
||||
async patchAgendaCard(patch: Parameters<typeof api.updateCard>[1]) {
|
||||
const card = this.agendaCards[this.agendaCursor]
|
||||
if (!card) return this.say('no card highlighted', 'error')
|
||||
const updated = await api.updateCard(card.id, patch)
|
||||
await this.loadAgenda()
|
||||
// It may have changed group; the cursor goes with it.
|
||||
const at = this.agendaCards.findIndex((c) => c.id === card.id)
|
||||
if (at >= 0) this.agendaCursor = at
|
||||
if (this.boardSpaceId === card.space_id) await this.reloadBoard()
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* `H` / `L` in the agenda: the card to the list left or right of the one it
|
||||
* is in, without going to the board — `L` on the last list but one is done.
|
||||
* Enter on a card a search found, or one picked in a document's info
|
||||
* sheet: to its board, turned over beside it.
|
||||
*/
|
||||
async sendAgendaCard(card: AgendaCard, delta: number) {
|
||||
const lists = (await api.board(card.space_id)).lists
|
||||
const from = lists.findIndex((list) => list.id === card.list_id)
|
||||
const target = lists[from + delta]
|
||||
if (from < 0 || !target) return this.say(`${card.title} is already in the ${delta < 0 ? 'first' : 'last'} list`, 'error')
|
||||
const moved = await api.moveCard(card.id, target.id, target.cards.length)
|
||||
await this.loadAgenda()
|
||||
const at = this.agendaCards.findIndex((c) => c.id === card.id)
|
||||
if (at >= 0) this.agendaCursor = at
|
||||
if (this.boardSpaceId === card.space_id) await this.reloadBoard()
|
||||
this.say(from + delta === lists.length - 1 ? doneSaid(moved) : `"${card.title}" → ${target.name}`)
|
||||
async turnTo(card: AgendaCard) {
|
||||
await this.goToCard(card)
|
||||
await this.turnCard()
|
||||
}
|
||||
|
||||
async loadOverview() {
|
||||
@@ -1721,25 +1748,75 @@ class Workspace {
|
||||
await this.reloadBoard(this.boardSpaceId, card.id)
|
||||
}
|
||||
|
||||
/** `H`/`L`: the same card, one list over — the move a board is really for. */
|
||||
async sendCard(delta: number) {
|
||||
await this.sendCardTo(this.listCursor + delta)
|
||||
/**
|
||||
* `H`/`L`: the same card, one list over — the move a board is really for.
|
||||
* From the agenda or a document too, without going to the board.
|
||||
*/
|
||||
async sendCard(delta: number, card = this.#needCard()) {
|
||||
if (!card) return
|
||||
const lists = await this.#listsOf(card)
|
||||
await this.#sendTo(card, lists, lists.findIndex((list) => list.id === card.list_id) + delta)
|
||||
}
|
||||
|
||||
/** The digits on the board: the card under the cursor to that list. */
|
||||
async sendCardTo(index: number) {
|
||||
const card = this.card
|
||||
if (!card || index === this.listCursor) return
|
||||
await this.#sendTo(card, this.board?.lists ?? [], index)
|
||||
}
|
||||
|
||||
/** The lists of a card's board: the one shown, or read for it. */
|
||||
async #listsOf(card: Card | AgendaCard): Promise<BoardList[]> {
|
||||
const shown = this.board?.lists ?? []
|
||||
if (shown.some((list) => list.id === card.list_id)) return shown
|
||||
return 'space_id' in card ? (await api.board(card.space_id)).lists : []
|
||||
}
|
||||
|
||||
/*
|
||||
* The last list is "done": the server stamps a card finished as it arrives
|
||||
* there and clears the stamp as it leaves, so moving is all there is to it.
|
||||
*/
|
||||
async sendCardTo(index: number) {
|
||||
const card = this.card
|
||||
const lists = this.board?.lists ?? []
|
||||
async #sendTo(card: Card, lists: BoardList[], index: number) {
|
||||
const target = lists[index]
|
||||
if (!card || !target || index === this.listCursor) return
|
||||
const moved = await api.moveCard(card.id, target.id, target.cards.length)
|
||||
await this.reloadBoard(this.boardSpaceId, card.id)
|
||||
if (!target) return this.say(`"${card.title}" is already in the ${index < 0 ? 'first' : 'last'} list`, 'error')
|
||||
const moved = await api.moveCard(card.id, target.id)
|
||||
await this.#cardChanged(card)
|
||||
this.say(index === lists.length - 1 ? doneSaid(moved) : `"${card.title}" → ${target.name}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* After a card changed, wherever it was changed from: the board, the
|
||||
* agenda and the open documents read it again, and the cursor that was on
|
||||
* it stays on it.
|
||||
*/
|
||||
async #cardChanged(card: Card) {
|
||||
const shown = this.rawBoard?.lists.some((list) => list.cards.some((c) => c.id === card.id)) ?? false
|
||||
const here = this.focus === 'board' || this.focus === 'card'
|
||||
if (shown) await this.reloadBoard(this.boardSpaceId, here ? card.id : this.card?.id)
|
||||
if (!shown || this.focus === 'agenda') {
|
||||
await this.loadAgenda()
|
||||
// It may have changed group; the cursor goes with it.
|
||||
const at = this.agendaCards.findIndex((c) => c.id === card.id)
|
||||
if (at >= 0 && this.focus === 'agenda') this.agendaCursor = at
|
||||
}
|
||||
await this.#reloadCardsAbout()
|
||||
if (this.foundCards.some((c) => c.id === card.id)) this.foundCards = await api.searchCards(this.query, this.#searchSpace)
|
||||
const at = this.open?.cards.findIndex((c) => c.id === card.id) ?? -1
|
||||
if (at >= 0) this.cardsCursor = at
|
||||
}
|
||||
|
||||
/** The cards about each document open in a tile, read again. */
|
||||
async #reloadCardsAbout() {
|
||||
await Promise.all(
|
||||
this.tiles.map(async (tile) => {
|
||||
const id = tile.open?.kind === 'document' ? tile.open.id : null
|
||||
if (id === null) return
|
||||
const cards = await api.nodeCards(id).catch(() => null)
|
||||
if (cards && tile.open?.id === id) tile.open.cards = cards
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A card lands in the first list — the inbox — of this space's board, or of
|
||||
* the first space that has one, without leaving the view you are in: writing
|
||||
@@ -1752,6 +1829,7 @@ class Workspace {
|
||||
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.boardSpaceId, card.id)
|
||||
await this.#reloadCardsAbout()
|
||||
return this.say(nodeId === null ? `carded "${card.title}"` : `carded "${card.title}" → #${nodeId}`)
|
||||
}
|
||||
|
||||
@@ -1761,30 +1839,25 @@ class Workspace {
|
||||
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 })
|
||||
await this.loadAgenda()
|
||||
await this.#reloadCardsAbout()
|
||||
this.say(`carded "${card.title}" → ${space.slug}/${inbox.name}`)
|
||||
}
|
||||
|
||||
async patchCard(patch: Parameters<typeof api.updateCard>[1]) {
|
||||
const card = this.card
|
||||
if (!card) return this.say('no card selected', 'error')
|
||||
/** A change to the focused card — or to `card`, when it is known which. */
|
||||
async patchCard(patch: Parameters<typeof api.updateCard>[1], card = this.#needCard()) {
|
||||
if (!card) return
|
||||
const updated = await api.updateCard(card.id, patch)
|
||||
await this.reloadBoard(this.boardSpaceId, card.id)
|
||||
await this.#cardChanged(card)
|
||||
return updated
|
||||
}
|
||||
|
||||
/** Space: into the done list, or back out of it to the inbox. */
|
||||
async toggleDone() {
|
||||
const card = this.card
|
||||
const lists = this.board?.lists ?? []
|
||||
if (!card) return this.say('no card selected', 'error')
|
||||
if (lists.length < 2) return this.say('a board needs two lists for one to mean done', 'error')
|
||||
const last = lists.length - 1
|
||||
if (this.listCursor === last) {
|
||||
await this.sendCardTo(0)
|
||||
this.say(`reopened: ${card.title}`)
|
||||
} else {
|
||||
await this.sendCardTo(last)
|
||||
}
|
||||
async toggleDone(card = this.#needCard()) {
|
||||
if (!card) return
|
||||
const done = card.done_at === null
|
||||
const moved = await api.updateCard(card.id, { done })
|
||||
await this.#cardChanged(card)
|
||||
this.say(done ? doneSaid(moved) : `reopened: ${card.title}`)
|
||||
}
|
||||
|
||||
async deleteCard() {
|
||||
@@ -1972,7 +2045,8 @@ class Workspace {
|
||||
async saveDescription() {
|
||||
const card = this.card
|
||||
if (!card || !this.describing) return
|
||||
await this.patchCard({ body: this.description })
|
||||
// Named, as this also runs when the focus has just left the card.
|
||||
await this.patchCard({ body: this.description }, card)
|
||||
this.descriptionDirty = false
|
||||
this.say(`"${card.title}" described`)
|
||||
}
|
||||
@@ -2003,7 +2077,7 @@ class Workspace {
|
||||
|
||||
/** The checklist written back whole, with the cursor on step `at`. */
|
||||
async writeChecklist(checklist: Task[], at = this.taskCursor) {
|
||||
await this.patchCard({ checklist })
|
||||
await this.patchCard({ checklist }, this.card)
|
||||
this.taskCursor = Math.min(Math.max(at, 0), Math.max(checklist.length - 1, 0))
|
||||
}
|
||||
|
||||
@@ -2071,11 +2145,11 @@ class Workspace {
|
||||
/** `:label work`: on the card, or off it when it is on already. */
|
||||
toggleLabel(name: string) {
|
||||
return this.inTurn(async () => {
|
||||
const card = this.card
|
||||
if (!card) return this.say('no card selected', 'error')
|
||||
const card = this.#needCard()
|
||||
if (!card) return
|
||||
const label = name.trim().toLowerCase()
|
||||
const on = card.labels.includes(label)
|
||||
await this.patchCard({ labels: on ? card.labels.filter((l) => l !== label) : [...card.labels, label] })
|
||||
await this.patchCard({ labels: on ? card.labels.filter((l) => l !== label) : [...card.labels, label] }, card)
|
||||
this.say(on ? `${label} off` : `${label} on`)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user