mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
diary: templates, a calendar, the days either side and this day before
A folder called templates at the root of any space holds templates. `:new
<template> [yyyy-mm-dd] [name]` starts a document from one, with {{date}},
{{weekday}}, {{time}} and {{title}} filled in on the server, in
WORKSPACE_TIMEZONE, for the date the document is for. A new diary entry starts
from templates/day when the diary has one, whether it comes from T, o, the
calendar or a capture. Templates are not entries: the calendar, the reminder
and "written today" leave them out.
C (or :cal) shows the diary by month: h/l a day, j/k a week, H/L a month, .
today, and Enter opens the day's entry or starts one dated that day. [d and
]d step to the entry before or after the open one by date, backdated ones
included. The agenda ends with what the diary holds from this day in earlier
years; a 29 February is remembered on the 28th in years without one.
New routes: GET /api/diary/days?month=, GET /api/diary/around/{id} and
GET /api/nodes/{id}/templates. POST /api/nodes takes template_id.
This commit is contained in:
1 parent
f629f5af97
commit
824f2bb945
14 files changed
+1048
-50
No files matched your search
+59
-4
@@ -2,6 +2,7 @@
|
||||
import { cycleTile, focusTile, followLink, hooks, runCommand } from './lib/commands'
|
||||
import { embedSnippet } from './lib/markdown'
|
||||
import Agenda from './lib/Agenda.svelte'
|
||||
import Calendar from './lib/Calendar.svelte'
|
||||
import Bar from './lib/Bar.svelte'
|
||||
import Board from './lib/Board.svelte'
|
||||
import CommandLine from './lib/CommandLine.svelte'
|
||||
@@ -25,7 +26,7 @@
|
||||
import Sidebar from './lib/Sidebar.svelte'
|
||||
import StatusBar from './lib/StatusBar.svelte'
|
||||
import { workspace } from './lib/store.svelte'
|
||||
import { dwindle, neighbour, type Direction } from './lib/util'
|
||||
import { dwindle, formatDay, neighbour, type Direction } from './lib/util'
|
||||
|
||||
/*
|
||||
* A share link is /s/<token>#<key>. The fragment never leaves the browser, so
|
||||
@@ -292,14 +293,18 @@
|
||||
return false
|
||||
}
|
||||
|
||||
/** The agenda's keys: j/k down the list, Enter to the card, Space finishes it. */
|
||||
/**
|
||||
* The agenda's keys: j/k down the list, Enter to the card, Space finishes it.
|
||||
* Past the cards the cursor walks on into what was written on this day.
|
||||
*/
|
||||
function agendaKey(event: KeyboardEvent): boolean {
|
||||
const cards = workspace.agendaCards
|
||||
const card = cards[workspace.agendaCursor]
|
||||
const past = workspace.agenda.on_this_day[workspace.agendaCursor - cards.length]
|
||||
switch (event.key) {
|
||||
case 'j':
|
||||
case 'ArrowDown':
|
||||
workspace.agendaCursor = Math.min(workspace.agendaCursor + 1, Math.max(cards.length - 1, 0))
|
||||
workspace.agendaCursor = Math.min(workspace.agendaCursor + 1, Math.max(cards.length + workspace.agenda.on_this_day.length - 1, 0))
|
||||
return true
|
||||
case 'k':
|
||||
case 'ArrowUp':
|
||||
@@ -307,6 +312,10 @@
|
||||
return true
|
||||
case 'Enter':
|
||||
if (card) void workspace.guard(() => workspace.goToCard(card))
|
||||
else if (past) {
|
||||
workspace.overlay = 'none'
|
||||
void workspace.guard(() => workspace.openPath(past.path))
|
||||
}
|
||||
return true
|
||||
case ' ':
|
||||
if (card) void workspace.guard(() => workspace.finishCard(card))
|
||||
@@ -335,6 +344,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** The calendar's keys: h/l a day, j/k a week, H/L a month, Enter to the day. */
|
||||
function calendarKey(event: KeyboardEvent): boolean {
|
||||
const step: Record<string, [number, number]> = {
|
||||
h: [-1, 0], ArrowLeft: [-1, 0], l: [1, 0], ArrowRight: [1, 0],
|
||||
k: [-7, 0], ArrowUp: [-7, 0], j: [7, 0], ArrowDown: [7, 0],
|
||||
H: [0, -1], L: [0, 1],
|
||||
}
|
||||
if (event.key in step) {
|
||||
const [days, months] = step[event.key]
|
||||
void workspace.guard(() => workspace.moveCalendar(days, months))
|
||||
return true
|
||||
}
|
||||
switch (event.key) {
|
||||
case '.':
|
||||
workspace.calendarDay = formatDay(Math.floor(Date.now() / 1000))
|
||||
void workspace.guard(() => workspace.loadCalendar())
|
||||
return true
|
||||
case 'Enter':
|
||||
void workspace.guard(() => workspace.openCalendarDay()).then(() => hooks.focusEditor())
|
||||
return true
|
||||
case 'C':
|
||||
case 'q':
|
||||
workspace.overlay = 'none'
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** `gO`'s list: j/k down the headings, Enter to the one picked. */
|
||||
function outlineKey(event: KeyboardEvent): boolean {
|
||||
const last = Math.max(workspace.outline.length - 1, 0)
|
||||
@@ -675,6 +713,12 @@
|
||||
return
|
||||
}
|
||||
}
|
||||
if (workspace.overlay === 'calendar' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
if (calendarKey(event)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (workspace.overlay === 'outline' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
if (outlineKey(event)) {
|
||||
event.preventDefault()
|
||||
@@ -769,6 +813,11 @@
|
||||
void runCommand('today')
|
||||
return
|
||||
}
|
||||
if (key === 'C') {
|
||||
event.preventDefault()
|
||||
void runCommand('cal')
|
||||
return
|
||||
}
|
||||
|
||||
if (workspace.view === 'board') {
|
||||
if (previous === 'g' && key === 'g') {
|
||||
@@ -847,6 +896,12 @@
|
||||
if (!reading || !hooks.scrollWide(amount)) workspace.say('nothing wider than the column in view', 'error')
|
||||
return
|
||||
}
|
||||
// `[d` / `]d`: the diary entry of the day before or after, by its date.
|
||||
if ((previous === '[' || previous === ']') && key === 'd') {
|
||||
event.preventDefault()
|
||||
void workspace.guard(() => workspace.stepEntry(previous === '[' ? -1 : 1))
|
||||
return
|
||||
}
|
||||
if ((previous === ']' && key === ']') || (previous === '[' && key === '[')) {
|
||||
event.preventDefault()
|
||||
// Past the last heading there is still the end, as in vim.
|
||||
@@ -1112,7 +1167,7 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
|
||||
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
|
||||
>
|
||||
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else if workspace.overlay === 'trash'}<Trash />{:else if workspace.overlay === 'history'}<History />{:else if workspace.overlay === 'backlinks'}<Backlinks />{:else}<MediaBrowser />{/if}
|
||||
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else if workspace.overlay === 'trash'}<Trash />{:else if workspace.overlay === 'history'}<History />{:else if workspace.overlay === 'backlinks'}<Backlinks />{:else if workspace.overlay === 'calendar'}<Calendar />{:else}<MediaBrowser />{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* had today yet. The keys live in App.svelte with the others; this draws
|
||||
* and lets a pointer do the same.
|
||||
*/
|
||||
import type { AgendaCard } from './api'
|
||||
import type { AgendaCard, DiaryEntry } from './api'
|
||||
import { workspace } from './store.svelte'
|
||||
import { runCommand } from './commands'
|
||||
import Icon from './Icon.svelte'
|
||||
@@ -23,6 +23,12 @@
|
||||
sheet?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
|
||||
/** An entry from an earlier year, in place in the diary. */
|
||||
function open(entry: DiaryEntry) {
|
||||
workspace.overlay = 'none'
|
||||
void workspace.guard(() => workspace.openPath(entry.path))
|
||||
}
|
||||
|
||||
/** Where a card sits in the walked order, across the groups. */
|
||||
function indexOf(card: AgendaCard) {
|
||||
return workspace.agendaCards.findIndex((c) => c.id === card.id)
|
||||
@@ -80,10 +86,33 @@
|
||||
<p class="clear">nothing late, nothing due this week, nothing in hand. <span class="kbd">c</span> captures the next thing.</p>
|
||||
{/each}
|
||||
|
||||
{#if workspace.agenda.on_this_day.length}
|
||||
<section class="past">
|
||||
<h2>on this day <span class="n">{workspace.agenda.on_this_day.length}</span></h2>
|
||||
<ul>
|
||||
{#each workspace.agenda.on_this_day as entry, at (entry.id)}
|
||||
{@const index = total + at}
|
||||
<li>
|
||||
<button
|
||||
class="row"
|
||||
class:at={index === workspace.agendaCursor}
|
||||
data-index={index}
|
||||
onclick={() => open(entry)}
|
||||
onpointerenter={() => (workspace.agendaCursor = index)}
|
||||
>
|
||||
<span class="text">{entry.name.trim() || entry.excerpt || 'untitled'}</span>
|
||||
<span class="due">{entry.day.slice(0, 4)}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<footer class="label">
|
||||
<span class="kbd">j</span><span class="kbd">k</span> move
|
||||
<span class="gap"></span>
|
||||
<span class="kbd">↵</span> to its board
|
||||
<span class="kbd">↵</span> open
|
||||
<span class="gap"></span>
|
||||
<span class="kbd">␣</span> done
|
||||
<span class="gap"></span>
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* `C`: the diary by month, the way a paper one is leafed through — the days
|
||||
* written in marked, the day under the cursor shown below. The keys live in
|
||||
* App.svelte with the others; this draws and lets a pointer do the same.
|
||||
*/
|
||||
import { workspace } from './store.svelte'
|
||||
import { formatDay } from './util'
|
||||
|
||||
const WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||
|
||||
const month = $derived(workspace.calendarDay.slice(0, 7))
|
||||
const title = $derived.by(() => {
|
||||
const [y, m] = month.split('-').map(Number)
|
||||
return new Date(y, m - 1, 1).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }).toLowerCase()
|
||||
})
|
||||
|
||||
/** The month's days in weeks from Monday, with blanks before the first. */
|
||||
const cells = $derived.by(() => {
|
||||
const [y, m] = month.split('-').map(Number)
|
||||
const lead = (new Date(y, m - 1, 1).getDay() + 6) % 7
|
||||
const length = new Date(y, m, 0).getDate()
|
||||
const days: (string | null)[] = Array(lead).fill(null)
|
||||
for (let d = 1; d <= length; d++) days.push(`${month}-${String(d).padStart(2, '0')}`)
|
||||
return days
|
||||
})
|
||||
|
||||
const today = $derived(formatDay(Math.floor(Date.now() / 1000)))
|
||||
const counts = $derived(
|
||||
workspace.calendarEntries.reduce((all, entry) => all.set(entry.day, (all.get(entry.day) ?? 0) + 1), new Map<string, number>()),
|
||||
)
|
||||
const chosen = $derived(workspace.calendarEntries.filter((entry) => entry.day === workspace.calendarDay))
|
||||
|
||||
function pick(day: string) {
|
||||
workspace.calendarDay = day
|
||||
void workspace.guard(() => workspace.openCalendarDay())
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sheet">
|
||||
<header>
|
||||
<span class="label rule"><span class="marker"></span><span class="title">{title}</span> {workspace.calendarEntries.length} entries</span>
|
||||
<button onclick={() => workspace.guard(() => workspace.moveCalendar(0, -1))} aria-label="previous month">‹</button>
|
||||
<button onclick={() => workspace.guard(() => workspace.moveCalendar(0, 1))} aria-label="next month">›</button>
|
||||
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
|
||||
</header>
|
||||
|
||||
<ol class="grid">
|
||||
{#each WEEKDAYS as weekday (weekday)}
|
||||
<li class="weekday label">{weekday}</li>
|
||||
{/each}
|
||||
{#each cells as day, index (day ?? `blank-${index}`)}
|
||||
{#if day}
|
||||
<li>
|
||||
<button
|
||||
class="day"
|
||||
class:at={day === workspace.calendarDay}
|
||||
class:today={day === today}
|
||||
class:written={counts.has(day)}
|
||||
onclick={() => pick(day)}
|
||||
>
|
||||
<span class="n">{Number(day.slice(8))}</span>
|
||||
{#if (counts.get(day) ?? 0) > 1}<span class="many">{counts.get(day)}</span>{:else if counts.has(day)}<span class="dot"></span>{/if}
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li></li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<section class="chosen">
|
||||
<h2>{workspace.calendarDay}</h2>
|
||||
{#each chosen as entry (entry.id)}
|
||||
<p class="entry"><span class="name">{entry.name.trim() || entry.excerpt || 'untitled'}</span></p>
|
||||
{:else}
|
||||
<p class="none">nothing written — <span class="kbd">↵</span> starts it</p>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<footer class="label">
|
||||
<span class="kbd">h</span><span class="kbd">l</span> day
|
||||
<span class="gap"></span>
|
||||
<span class="kbd">j</span><span class="kbd">k</span> week
|
||||
<span class="gap"></span>
|
||||
<span class="kbd">H</span><span class="kbd">L</span> month
|
||||
<span class="gap"></span>
|
||||
<span class="kbd">.</span> today
|
||||
<span class="gap"></span>
|
||||
<span class="kbd">↵</span> open
|
||||
<span class="gap"></span>
|
||||
<span class="kbd">C</span><span class="kbd">esc</span> close
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: min(100%, 60ch);
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
background: color-mix(in oklab, var(--hull) 97%, transparent);
|
||||
border: 1px solid var(--surface-alt);
|
||||
border-radius: var(--frame-radius);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
header { display: flex; align-items: center; gap: 12px; }
|
||||
header .label { flex: 1; }
|
||||
header .marker { background: var(--light); }
|
||||
header .title { color: var(--light); }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.weekday { text-align: center; font-size: 10px; color: var(--muted); }
|
||||
|
||||
.day {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
aspect-ratio: 1.4;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-s);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0;
|
||||
color: var(--subtle);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.day:active { transform: none; }
|
||||
.day.written { color: var(--fg); }
|
||||
.day.today { border-color: var(--rim); }
|
||||
.day.at { background: var(--light); color: var(--ink); }
|
||||
.dot, .many { position: absolute; bottom: 4px; }
|
||||
.dot { width: 5px; height: 5px; border-radius: 999px; background: var(--light); }
|
||||
.many { font-size: 9px; color: var(--light); }
|
||||
.day.at :is(.dot) { background: var(--ink); }
|
||||
.day.at .many { color: var(--ink); }
|
||||
|
||||
.chosen h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.22em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.chosen h2::after { content: ''; height: 1px; flex: 1; background: var(--overlay); }
|
||||
.entry, .none { margin: 2px 0; font-size: 13px; }
|
||||
.entry .name { font-family: var(--font-sans); font-size: 14px; color: var(--fg); }
|
||||
.none { color: var(--subtle); }
|
||||
|
||||
footer { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 10px; }
|
||||
footer .kbd { margin-right: 2px; }
|
||||
footer .gap { width: 14px; }
|
||||
|
||||
@media (max-width: 767px), (pointer: coarse) {
|
||||
footer { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -32,7 +32,9 @@
|
||||
['M', 'pick up the whole folder — again puts it all back'],
|
||||
['Ctrl / Shift-click', 'pick a row up · every row up to it — drag them onto a folder'],
|
||||
['t', 'agenda — late, due this week, in hand, on every board'],
|
||||
['T', "today's diary entry, started if there is none"],
|
||||
['T', "today's diary entry, started if there is none — from templates/day when the diary has one"],
|
||||
['C', 'the diary by month: 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'],
|
||||
['b', "open this space's board"],
|
||||
['c', "capture a card into this space's inbox, without leaving here"],
|
||||
@@ -73,8 +75,8 @@
|
||||
]
|
||||
|
||||
const agendaKeys: [string, string][] = [
|
||||
['j / k', 'down / up the cards'],
|
||||
['Enter', 'to the card, on its own board'],
|
||||
['j / k', 'down / up the cards, then what was written on this day in earlier years'],
|
||||
['Enter', 'to the card, on its own board — or to the entry'],
|
||||
['Space', 'done, where it stands'],
|
||||
['D', 'due date: fri, tm, +3d … — :due on the line'],
|
||||
['H / L', 'to the list left / right on its board'],
|
||||
|
||||
@@ -183,6 +183,27 @@ export interface Agenda {
|
||||
cards: AgendaCard[]
|
||||
/** Whether the diary has an entry today; null when there is no diary. */
|
||||
diary_today: boolean | null
|
||||
/** Diary entries from this day in earlier years, the latest year first. */
|
||||
on_this_day: DiaryEntry[]
|
||||
}
|
||||
|
||||
/** A diary entry, by the local day it is about. */
|
||||
export interface DiaryEntry {
|
||||
id: number
|
||||
name: string
|
||||
/** `2026-10-20`, in the server's timezone. */
|
||||
day: string
|
||||
created_at: number
|
||||
/** `diary/untitled`, to open it by. */
|
||||
path: string
|
||||
excerpt: string
|
||||
}
|
||||
|
||||
/** A document in a space's `templates` folder. */
|
||||
export interface Template {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface BoardList {
|
||||
@@ -433,8 +454,20 @@ export const api = {
|
||||
name?: string
|
||||
body?: string
|
||||
created_at?: number
|
||||
/** Start from this document, its `{{date}}` and the rest filled in. */
|
||||
template_id?: number
|
||||
}) => request<WorkspaceNode>('/nodes', { method: 'POST', body: body(input) }),
|
||||
|
||||
/** The templates of the space `id` is in. */
|
||||
templates: (id: number) => request<Template[]>(`/nodes/${id}/templates`),
|
||||
|
||||
/** A month's diary entries: `2026-10`. */
|
||||
diaryDays: (month: string) => request<DiaryEntry[]>(`/diary/days?month=${encodeURIComponent(month)}`),
|
||||
|
||||
/** The entries before and after a diary entry, by date. */
|
||||
diaryAround: (id: number) =>
|
||||
request<{ prev: DiaryEntry | null; next: DiaryEntry | null }>(`/diary/around/${id}`),
|
||||
|
||||
/**
|
||||
* `base_updated_at`: refused with 409 if the node was saved since then.
|
||||
* `restore`: the text is a revision brought back, so what it replaces is
|
||||
|
||||
+45
-9
@@ -156,6 +156,23 @@ 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 templates of the space last asked about, for `:new` to complete. */
|
||||
let templateCache: { space: number; names: string[] } | null = null
|
||||
|
||||
/** Tab cannot wait for the server: the names come on the press after the first. */
|
||||
function templateNames(): string[] {
|
||||
const here = workspace.spaceId
|
||||
if (here === null) return []
|
||||
if (templateCache?.space !== here) {
|
||||
templateCache = { space: here, names: [] }
|
||||
void api.templates(here).then(
|
||||
(templates) => (templateCache = { space: here, names: templates.map((t) => t.slug) }),
|
||||
() => (templateCache = null),
|
||||
)
|
||||
}
|
||||
return templateCache.names
|
||||
}
|
||||
|
||||
/** `:encrypt` and `:decrypt`: a file, or everything under a folder. */
|
||||
const sealTarget = (encrypt: boolean) =>
|
||||
workspace.guard(async () => {
|
||||
@@ -526,23 +543,42 @@ export const commands: CommandSpec[] = [
|
||||
{
|
||||
name: 'new',
|
||||
aliases: ['n', 'o'],
|
||||
args: '[yyyy-mm-dd]',
|
||||
args: '[template] [yyyy-mm-dd] [name]',
|
||||
group: 'documents',
|
||||
help: 'start a new document here, optionally dated',
|
||||
help: "start a new document here — from one of the space's templates, dated, named; in the diary from templates/day",
|
||||
complete: (arg) => (arg.includes(' ') ? [] : templateNames()),
|
||||
run: async (arg) => {
|
||||
await workspace.guard(async () => {
|
||||
await workspace.createDocument()
|
||||
if (arg) {
|
||||
const at = parseDay(arg)
|
||||
if (at === null) return workspace.say(`not a date: ${arg}`, 'error')
|
||||
workspace.draft.created_at = at
|
||||
workspace.dirty = true
|
||||
await workspace.save()
|
||||
const words = arg.trim().split(/\s+/).filter(Boolean)
|
||||
const here = workspace.spaceId
|
||||
const templates = words.length && here !== null ? await api.templates(here) : []
|
||||
if (words.length && here !== null) templateCache = { space: here, names: templates.map((t) => t.slug) }
|
||||
const wanted = words[0]?.toLowerCase()
|
||||
const template = templates.find((t) => t.slug === wanted || t.name.toLowerCase() === wanted)
|
||||
if (template) words.shift()
|
||||
let created_at: number | undefined
|
||||
if (words.length && /^\d{4}-\d{2}-\d{2}$/.test(words[0])) {
|
||||
const at = parseDay(words[0])
|
||||
if (at === null) return workspace.say(`not a date: ${words[0]}`, 'error')
|
||||
created_at = at
|
||||
words.shift()
|
||||
}
|
||||
await workspace.createDocument(words.join(' '), { created_at, template_id: template?.id })
|
||||
})
|
||||
hooks.focusEditor()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'cal',
|
||||
aliases: ['calendar'],
|
||||
group: 'documents',
|
||||
help: 'the diary by month — h/l a day, j/k a week, H/L a month, Enter opens the day or starts it',
|
||||
run: () =>
|
||||
workspace.guard(async () => {
|
||||
if (workspace.overlay === 'calendar') return void (workspace.overlay = 'none')
|
||||
await workspace.openCalendar()
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'agenda',
|
||||
aliases: ['ag'],
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type Board,
|
||||
type Card,
|
||||
type Crumb,
|
||||
type DiaryEntry,
|
||||
type WorkspaceNode,
|
||||
type MediaFile,
|
||||
type NodeSummary,
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
type TrashItem,
|
||||
type VaultStatus,
|
||||
} from './api'
|
||||
import { formatBytes, formatStamp, isTouchDevice, sealedNote, type Heading } from './util'
|
||||
import { formatBytes, formatDay, formatStamp, isTouchDevice, parseDay, sealedNote, type Heading } from './util'
|
||||
|
||||
export type Pane = 'list' | 'document'
|
||||
/** The tree of documents, or the board of the space being browsed. */
|
||||
@@ -282,7 +283,7 @@ class Workspace {
|
||||
pane = $state<Pane>('list')
|
||||
flash = $state<Flash>(null)
|
||||
|
||||
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline' | 'trash' | 'history' | 'backlinks'>('none')
|
||||
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline' | 'trash' | 'history' | 'backlinks' | 'calendar'>('none')
|
||||
/** `gO`: the headings of what is on screen, to jump between from the keyboard. */
|
||||
outline = $state.raw<Heading[]>([])
|
||||
outlineCursor = $state(0)
|
||||
@@ -291,9 +292,13 @@ class Workspace {
|
||||
* What is due or in hand on every board, kept loaded all the time: the bar
|
||||
* counts from it, so what is late is in sight without going to look.
|
||||
*/
|
||||
agenda = $state<Agenda>({ cards: [], diary_today: null })
|
||||
agenda = $state<Agenda>({ cards: [], diary_today: null, on_this_day: [] })
|
||||
agendaCursor = $state(0)
|
||||
|
||||
/** `C`: the day the calendar's cursor is on, `2026-10-20`, and its month's entries. */
|
||||
calendarDay = $state('')
|
||||
calendarEntries = $state.raw<DiaryEntry[]>([])
|
||||
|
||||
/** A search across every space rather than the one being browsed — Ctrl-P. */
|
||||
globalSearch = $state(false)
|
||||
|
||||
@@ -939,11 +944,15 @@ class Workspace {
|
||||
else await this.openNode(selected.id, edit)
|
||||
}
|
||||
|
||||
/** A new document is created immediately so uploads have something to attach to. */
|
||||
async createDocument(name = '') {
|
||||
/**
|
||||
* A new document is created immediately so uploads have something to attach
|
||||
* to. In the diary it starts from `templates/day` when there is one; the
|
||||
* server sees to that, so capture does the same.
|
||||
*/
|
||||
async createDocument(name = '', from: { created_at?: number; template_id?: number } = {}) {
|
||||
const parent = this.parentId
|
||||
if (parent === null) return
|
||||
const node = await api.createNode({ parent_id: parent, name, body: '' })
|
||||
const node = await api.createNode({ parent_id: parent, name, body: '', ...from })
|
||||
this.query = ''
|
||||
this.globalSearch = false
|
||||
await this.refresh()
|
||||
@@ -1285,7 +1294,7 @@ class Workspace {
|
||||
} catch {
|
||||
/* the counter is a convenience; a failed refresh leaves the last one up */
|
||||
}
|
||||
this.agendaCursor = Math.min(this.agendaCursor, Math.max(this.agendaCards.length - 1, 0))
|
||||
this.agendaCursor = Math.min(this.agendaCursor, Math.max(this.agendaCards.length + this.agenda.on_this_day.length - 1, 0))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1302,6 +1311,52 @@ class Workspace {
|
||||
this.overlay = 'agenda'
|
||||
}
|
||||
|
||||
/** `C`: the calendar, on the day of the entry being read or else today. */
|
||||
async openCalendar() {
|
||||
const open = this.open
|
||||
const reading = open && this.spaces.find((space) => space.id === open.path[0]?.id)?.role === 'diary'
|
||||
this.calendarDay = formatDay(reading ? open.created_at : Math.floor(Date.now() / 1000))
|
||||
await this.loadCalendar()
|
||||
this.overlay = 'calendar'
|
||||
}
|
||||
|
||||
async loadCalendar() {
|
||||
this.calendarEntries = await api.diaryDays(this.calendarDay.slice(0, 7))
|
||||
}
|
||||
|
||||
/** The calendar's cursor, `days` along or `months` over; a month keeps the day where it can. */
|
||||
async moveCalendar(days: number, months = 0) {
|
||||
const [y, m, d] = this.calendarDay.split('-').map(Number)
|
||||
const last = new Date(y, m - 1 + months + 1, 0).getDate()
|
||||
const date = new Date(y, m - 1 + months, Math.min(d, last) + days, 12)
|
||||
const before = this.calendarDay.slice(0, 7)
|
||||
this.calendarDay = formatDay(Math.floor(date.getTime() / 1000))
|
||||
if (this.calendarDay.slice(0, 7) !== before) await this.loadCalendar()
|
||||
}
|
||||
|
||||
/** Enter in the calendar: that day's entry, or a new one dated that day. */
|
||||
async openCalendarDay() {
|
||||
const day = this.calendarDay
|
||||
const entry = this.calendarEntries.find((e) => e.day === day)
|
||||
this.overlay = 'none'
|
||||
if (entry) return this.openPath(entry.path)
|
||||
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 })
|
||||
}
|
||||
|
||||
/** `[d` / `]d`: the diary entry before or after the one being read. */
|
||||
async stepEntry(by: -1 | 1) {
|
||||
const open = this.open
|
||||
if (!open) return this.say('nothing open', 'error')
|
||||
const { prev, next } = await api.diaryAround(open.id)
|
||||
const entry = by < 0 ? prev : next
|
||||
if (!entry) return this.say(by < 0 ? 'no entry before this one' : 'no entry after this one')
|
||||
await this.openPath(entry.path)
|
||||
this.say(entry.day)
|
||||
}
|
||||
|
||||
/** Enter in the agenda: to the card, on its own board. */
|
||||
async goToCard(card: AgendaCard) {
|
||||
this.overlay = 'none'
|
||||
|
||||
Reference in new issue
Block a user