mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
ccc91028e7
commit
2233fa41a6
20 files changed
+561
-84
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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() ?? ''
|
||||
}
|
||||
Reference in new issue
Block a user