fixes: what the audit turned up

- POST /api/diary/day finds a day's entry, or starts it at the root of the
  diary, by the server's clock. T, the calendar and a capture all use it, so
  they can no longer write two entries for one day: before, T counted days by
  the browser's midnight and wrote into whichever diary folder was last open.
- A Ctrl-P result opens beside the results, as a / result does, so n and N
  go on from it rather than finding no search.
- A card's description resolves its [[links]] the way a document does, and
  following one leaves the card for what it leads to.
- gf on the board opens a card's notes. Enter was documented for that, but
  Enter turns the card over.
- The pane header and the status line show where the open node is, rather
  than where the list is, so a node from another space shows its own path.
This commit is contained in:
nvrl committed 2026-10-22 03:10:49 +02:00
1 parent ac3ad166e5
commit 7f8a3816d3
22 files changed
+321 -114

No files matched your search

+6
View File
@@ -1106,6 +1106,12 @@
workspace.cardCursor = 0
return
}
// The card's notes, without turning it over first: `gf` as on its back.
if (previous === 'g' && key === 'f') {
event.preventDefault()
void workspace.guard(() => workspace.openCard())
return
}
if (previous === 'd' && key === 'd') {
event.preventDefault()
void runCommand('delete')
+14 -3
View File
@@ -5,6 +5,7 @@
* description, the checklist. The keys live in App.svelte with the others;
* this draws and lets a pointer do the same.
*/
import { followLink } from './commands'
import { renderMarkdown } from './markdown'
import { workspace } from './store.svelte'
import { formatDay, labelHue, urgency } from './util'
@@ -16,7 +17,7 @@
const lists = $derived(workspace.board?.lists.length ?? 0)
const role = $derived(lists < 2 ? '' : workspace.listCursor === 0 ? 'inbox' : workspace.listCursor === lists - 1 ? 'done' : '')
const ticked = $derived(card?.checklist.filter((task) => task.done).length ?? 0)
const html = $derived(card?.body.trim() ? renderMarkdown(card.body) : '')
const html = $derived(card?.body.trim() ? renderMarkdown(card.body, { wikilinks: card.wikilinks }) : '')
// A card deleted or moved out of sight under the sheet takes the sheet with it.
$effect(() => {
@@ -28,6 +29,16 @@
steps?.querySelector<HTMLElement>(`[data-index="${at}"]`)?.scrollIntoView({ block: 'nearest' })
})
/** A link in the description leaves the card for what it leads to, as one in a document does. */
function follow(event: MouseEvent) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return
const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href') ?? ''
if (!href.startsWith('/n/')) return
event.preventDefault()
workspace.closeCardSheet()
followLink(href)
}
function pick(index: number) {
workspace.taskCursor = index
}
@@ -81,8 +92,8 @@
/>
{/await}
{:else if html}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<article class="md description" ondblclick={() => workspace.describe()}>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_element_interactions -->
<article class="md description" onclick={follow} ondblclick={() => workspace.describe()}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
{@html html}
</article>
+3 -2
View File
@@ -14,6 +14,7 @@
relative,
scrollPage,
scrollWide,
slugPath,
} from './util'
/** The tile this pane draws; the keys only reach it while it has focus. */
@@ -143,13 +144,13 @@
<section class="pane">
<header>
<div class="crumb label rule" title="{workspace.here}/{doc.slug}">
<div class="crumb label rule" title="{slugPath(doc.path)}">
<!-- A phone has no crumbs on the bar and no h key: this is the way back. -->
<button class="back" aria-label="back to the list" onclick={() => runCommand('q')}>
<Icon name="back" size={16} />
</button>
<span class="marker" class:live={tile.editing}></span>
<span class="path">{workspace.here}/{doc.slug}</span>
<span class="path">{slugPath(doc.path)}</span>
{#if tile.dirty}<span class="dirty">modified</span>{/if}
</div>
+3 -3
View File
@@ -5,7 +5,7 @@
import { renderMarkdown } from './markdown'
import { looksLikeText, parseTable, previewKind, separatorOf } from './preview'
import { workspace, type Tile } from './store.svelte'
import { formatBytes, formatStamp, headingsIn, jumpHeading, readerHeadings, relative, scrollPage, scrollWide } from './util'
import { formatBytes, formatStamp, headingsIn, jumpHeading, readerHeadings, relative, scrollPage, scrollWide, slugPath } from './util'
/** The tile this pane draws; the keys only reach it while it has focus. */
const { tile }: { tile: Tile } = $props()
@@ -158,12 +158,12 @@
<section class="pane">
<header>
<div class="crumb label rule" title="{workspace.here}/{node.slug}">
<div class="crumb label rule" title="{slugPath(node.path)}">
<button class="back" aria-label="back to the list" onclick={() => runCommand('q')}>
<Icon name="back" size={16} />
</button>
<span class="marker"></span>
<span class="path">{workspace.here}/{node.slug}</span>
<span class="path">{slugPath(node.path)}</span>
</div>
<div class="row">
+2 -1
View File
@@ -78,7 +78,7 @@
['T', "today's diary entry, started if there is none — from templates/day when the diary has one"],
['C', 'the diary by month, in the special workspace: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day'],
['[d / ]d', 'the diary entry before / after the one open, by date'],
['Ctrl-P', 'find anything, in every space — Enter goes to the result lit'],
['Ctrl-P', 'find anything, in every space — Enter opens the result lit beside them, n goes on'],
['b', "the board, in the special workspace: this space's, or the last one shown — 1 … 9 there shows another's"],
['c', "capture a card into this space's inbox, without leaving here"],
['Esc · q', 'close the document, and its tile when there are several'],
@@ -107,6 +107,7 @@
['H / L', 'move the card to the list left / right'],
['Space', 'into the done list, or back out to the inbox'],
['Enter', 'turn the card over: its labels, description and checklist'],
['gf', "the card's notes — a document, made on first use; q comes back"],
['o · c', 'new card on this list'],
['D', 'due date: fri, tm, +3d, 10-31 …'],
['R', 'rename the card, or the list when it is empty'],
+1 -1
View File
@@ -55,7 +55,7 @@
{ icon: 'right', label: 'Send the card right', keys: ['L'], run: () => workspace.guard(() => workspace.sendCard(1)), dim: !card },
{ icon: 'calendar', label: 'Due date', keys: ['D'], run: () => hooks.openCommandLine(':due '), dim: !card },
{ icon: 'repeat', label: 'Repeat once done', keys: [':repeat'], run: () => hooks.openCommandLine(':repeat '), dim: !card },
{ icon: 'doc', label: "The card's notes", keys: ['Enter'], run: () => workspace.guard(() => workspace.openCard()), dim: !card },
{ icon: 'doc', label: "The card's notes", keys: ['gf'], run: () => workspace.guard(() => workspace.openCard()), dim: !card },
],
[
{ icon: 'up', label: 'Card up its list', keys: ['K'], run: () => workspace.guard(() => workspace.nudgeCard(-1)), dim: !card },
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import Rail from './Rail.svelte'
import { workspace } from './store.svelte'
import { isTouchDevice } from './util'
import { isTouchDevice, slugPath } from './util'
/** A key waiting for its second half, like vim's `showcmd`. */
const { pending = '' }: { pending?: string } = $props()
@@ -12,7 +12,7 @@
// The path is the file name here: work/einarbeiten/01-was-ist-eebus.
const file = $derived(
workspace.open ? `${workspace.here}/${workspace.open.slug}` : `${workspace.here || '~'}/`,
workspace.open ? slugPath(workspace.open.path) : `${workspace.here || '~'}/`,
)
const lines = $derived(workspace.editing ? workspace.draft.body.split('\n').length : null)
+9
View File
@@ -176,6 +176,8 @@ export interface Card {
position: number
created_at: number
updated_at: number
/** Where each `[[title]]` in the description leads, as a document's do. */
wikilinks: Record<string, string>
}
/** A step on a card's checklist. */
@@ -492,6 +494,13 @@ export const api = {
/** A month's diary entries: `2026-10`. */
diaryDays: (month: string) => request<DiaryEntry[]>(`/diary/days?month=${encodeURIComponent(month)}`),
/**
* The entry of a day, `2026-10-22`, or of today on the server's clock;
* started when there is none, which `created` says.
*/
diaryDay: (day?: string) =>
request<{ id: number; created: boolean }>('/diary/day', { method: 'POST', body: body({ day }) }),
/** The entries before and after a diary entry, by date. */
diaryAround: (id: number) =>
request<{ prev: DiaryEntry | null; next: DiaryEntry | null }>(`/diary/around/${id}`),
+2 -18
View File
@@ -465,7 +465,7 @@ export const commands: CommandSpec[] = [
await workspace.guard(() => workspace.createCard(arg.trim()))
},
bang: {
help: 'add a card linked to the open document — Enter on it opens the document',
help: 'add a card linked to the open document — gf on it opens the document',
run: async (arg) => {
// On the board the tree is not on screen, so only the open document
// can be meant; elsewhere it is whatever has focus.
@@ -688,23 +688,7 @@ export const commands: CommandSpec[] = [
group: 'documents',
help: "open today's diary entry, starting one if there is none yet",
run: async () => {
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.role === '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 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()
})
await workspace.guard(() => workspace.openDay())
hooks.focusEditor()
},
},
+21 -7
View File
@@ -981,11 +981,8 @@ class Workspace {
async openSelected(edit = false) {
const selected = this.selected
if (!selected) return
// A match from anywhere is gone to, not opened beside a list it is not in.
if (this.globalSearch) {
const node = await api.getNode(selected.id)
return this.openPath(node.path.map((crumb) => crumb.slug).join('/'))
}
// A match from anywhere opens beside the results, as one from this space
// does, so `n` goes on from it; only a folder ends the search, by going in.
if (isContainer(selected)) await this.goTo(selected.id)
else await this.openNode(selected.id, edit)
}
@@ -1383,10 +1380,27 @@ class Workspace {
const entry = this.calendarEntries.find((e) => e.day === day)
// Opened from the special workspace, so `q` on the entry comes back here.
if (entry) return this.openPath(entry.path)
await this.openDay(day)
}
/**
* `T`, and a day in the calendar without an entry: the server finds the
* day's entry or starts it, by its own clock and at the root of the diary,
* as a capture does, so the three never write two entries for one day.
*/
async openDay(day?: string) {
const home = this.spaces.find((space) => space.role === 'diary')
if (!home) return this.say('there is no diary to write in', 'error')
if (this.parentId !== home.id) await this.enterSpace(home.id)
await this.createDocument('', { created_at: parseDay(day) ?? undefined })
const entry = await api.diaryDay(day)
if (this.spaceId !== home.id) await this.enterSpace(home.id)
else if (this.query) await this.search('')
if (entry.created) await this.refresh()
await this.openNode(entry.id, true)
if (entry.created) {
// Left as it was started, it goes again, as a new document from `o` does.
this.tile.fresh = entry.id
this.enterInsert = true
}
}
/** `[d` / `]d`: the diary entry before or after the one being read. */
+3
View File
@@ -258,6 +258,9 @@ export function linkAt(line: string, column: number): string | null {
* Where a `[[title]]` points when the page cannot say where it leads: the
* title itself, for following it to look it up, or to offer to write it.
*/
/** `work/notes/plan`: where a node is, by its crumbs. */
export const slugPath = (path: { slug: string }[]) => path.map((crumb) => crumb.slug).join('/')
export const titleHref = (title: string) => `/n/?title=${encodeURIComponent(title)}`
/** What a share link's reader will miss: a share never serves a sealed file. */