Show Obsidian TaskNotes tasks from a .base view as a board, list, agenda or dependency graph in a Claude Desktop side pane

A Claude Code mod that shows the tasks of an Obsidian vault that uses the TaskNotes plugin in a side pane of the Claude Desktop Code tab: a kanban board, a grouped list, an agenda of the coming days, or a dependency graph with the critical path.
It reads the same .base files TaskNotes' views are made of (Obsidian Bases), and evaluates their filters, formulas, sort and grouping itself. A view in Desktop therefore shows the tasks the same view shows in Obsidian, without Obsidian running.
| Layout | Drawn for | What it shows |
|---|---|---|
| Board | tasknotesKanban views | Columns by the view's groupBy (default: status). Status columns follow TaskNotes' order and colours; empty ones stay unless the view hides them. |
| List | tasknotesTaskList, table and other views | Sections by groupBy, rows in the view's sort order. Table views also show their formula columns. |
| Agenda | tasknotesCalendar and tasknotesMiniCalendar views | Overdue tasks first, then the next 7 days (listDayCount) by due and scheduled date. Recurring tasks are expanded from their rule, minus completed instances. |
| Graph | any view, on request | blockedBy arrows laid out in layers. The critical path is the longest chain of unfinished work, by timeEstimate or else size, drawn in red. Blockers and dependents outside the view are drawn dashed; broken links and cycles are flagged. Finished tasks shrink to a dot, or hide them with one press. Under the graph its tasks are listed, critical path first, each with the hover details and ↗. |
Every card shows the properties the view's order lists that TaskNotes knows how to show: status, priority, due (red when overdue), scheduled, projects, contexts, a "blocked by N" badge, recurrence, assignee and size. It also shows Done When checkbox progress when the note has that section. The ↗ button on a card opens the note in Obsidian.
Hovering a card, an agenda row or a row of the graph's task list pops up every property of the task, with an Open in Obsidian button. The graph itself is a picture, which the pane draws without hover or presses.
The pane is read-only: it never writes a task. Claude still edits tasks when you ask, and the pane redraws after every Write or Edit to a note, the base or TaskNotes' settings.
The command is /taskboard: /tasks is Claude Code's own background-tasks command, and /tasknotes is usually taken by a TaskNotes skill.
/taskboard: the file TaskNotes opens for its tasks view./taskboard kanban (or tasks, agenda, calendar, mini-calendar, relationships): the .base file TaskNotes opens for that command (its commandFileMapping setting)./taskboard <file.base> [view name or number] [--board|--list|--agenda|--graph] [--project=A,B] [--context=X]mcp__tasknotes-preview__open with base, view, layout, project and context. Its answer includes counts per column, so Claude knows what you are looking at.The toolbar has a view picker, the four layouts (one press each), Refresh and Open in Obsidian. Under it, pills for the projects and contexts of the view's tasks (most used first) narrow every layout. Pills in one row widen each other (any of them), and the two rows narrow each other (both must hold). Clear filters resets them. The graph adds the same controls as claude-diagrams-renderer-mod: a one-press landscape/portrait switch and zoom (− · Fit · +) with ◀ ▶ pan, plus Hide done / Show done.
This is a mod: a plugin of Claude Code function hooks (early access; written against Claude Code 2.1.286). The plugin is named tasknotes-preview.
CLAUDE_CODE_PLUGIN_DIRS in the env block of your user settings (~/.claude/settings.json; project settings cannot set it). Separate several mods with ; on Windows and : elsewhere: { "env": { "CLAUDE_CODE_PLUGIN_DIRS": "\\\\wsl.localhost\\Ubuntu\\home\\you\\projects\\dev\\claude-tasknotes-renderer-mod" } }
.obsidian/plugins/tasknotes/data.json) give the folders, statuses, priorities, field mapping and view files. Without them the mod uses TaskNotes' defaults.if, date, today, now, number, list, file, link, min, max, duration;today() + "7 days");filter / map / reduce;this, and formulas that use other formulas.Anything it does not know is skipped and named under the caption, never guessed.
!file.inFolder("…/Archive")), the archive is not read at all, apart from the archived notes that open tasks name as blockers, so a finished blocker still shows as finished. file.ctime is the note's dateCreated (the pane cannot read creation times), and file.tags are the frontmatter tags.file.hasTag("task"). If your tasks are identified by a property instead (type: task), those views show only the tagged tasks, in Obsidian as here. Change the filter to type == "task" to see all of them, and exclude the archive folder if your archived tasks do not all carry the archived tag.claude plugin validate .
claude plugin test .
hooks/register.tsx: the hooks module (command, model tool, loading the vault, the pane).hooks/yaml.ts: a YAML reader for frontmatter and .base files.hooks/expr.ts: the Bases expression language (tokenizer, parser, evaluator).hooks/bases.ts: .base files: views, filters, sort, groups.hooks/tasknotes.ts: TaskNotes settings and the task model.hooks/model.ts, hooks/agenda.ts, hooks/graph.ts: what each layout shows.tests/: 32 tests run by claude plugin test. There is no real filesystem; the tests' hooks serve a small vault.Every hook has a 10-second budget of its own time, and a pane redraws on every press and resize. So the vault's task model is built once per set of file changes, a view is evaluated once per day, and each layout's result is kept until something it reads changes. tests/perf.test.tsx holds a 1,600-note vault to those limits.
The engine only follows $ (the engine interface) inside the hooks module itself, so everything that reads files lives in register.tsx; everything else is pure.
The sort order and task-field normalisation follow obsidian-bob-workspace (MIT, same author). TaskNotes is by Callum Alpass; this mod is not affiliated with it.
hooks/register.tsx 789 lines1import { memberOf, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Layout, TasksTarget } from '../types'
5import { buildAgenda, dayLabel } from './agenda'
6import type { AgendaItem } from './agenda'
7import { parseBase } from './bases'
8import type { BaseConfig } from './bases'
9import {
10 SVG_LIMIT,
11 asOrientation,
12 cleanArg,
13 errorText,
14 isInside,
15 joinPath,
16 launchers,
17 obsidianUri,
18 panStep,
19 parentOf,
20 samePath,
21 sepOf,
22 stepZoom,
23 targetIn,
24 zoomLabel,
25 zoomView,
26} from './common'
27import type { Target } from './common'
28import { FileV, linkKey, toDate } from './expr'
29import { buildGraph, graphSvg } from './graph'
30import type { GraphNode } from './graph'
31import { buildModel, columnsOf, detailOf, facets, narrow } from './model'
32import type { Card, Column, Facet, Filters, Model } from './model'
33import { VIEW_ALIASES, fieldKey, parseSettings, taskIndex, toTask } from './tasknotes'
34import type { Task, TaskNotesSettings } from './tasknotes'
35import { splitFrontmatter } from './yaml'
36
37const TARGET = { plugin: 'tasknotes-preview', key: 'target' } as const
38const PANE = 'tasks'
39const LAYOUTS: Layout[] = ['board', 'list', 'agenda', 'graph']
40const LAYOUT_LABEL: Record<Layout, string> = { board: 'Board', list: 'List', agenda: 'Agenda', graph: 'Graph' }
41/** Cards per column (or rows per section) before a "show all" button. */
42const CAP = 12
43
44// ── the vault ────────────────────────────────────────────────────────────
45
46/** A parsed note and its task, made once per modification time (and TaskNotes settings). */
47type Entry = { mtime: number; settings: string; file: FileV; task: Task }
48
49/**
50 * Parsed notes by absolute path and modification time. The first draw reads every task
51 * note; after that only changed files are read again. A module variable: a reload costs
52 * one full read.
53 */
54const notes = new Map<string, Entry>()
55
56type Vault = {
57 /** Changes when any note, or TaskNotes' settings, changes. */
58 signature: string
59 settings: TaskNotesSettings
60 /** False when the base's filters leave the archive out, so it was not read. */
61 archiveIncluded: boolean
62 /** Every note in the TaskNotes tasks and archive folders: what base filters run over. */
63 files: FileV[]
64 /** The task notes among them (TaskNotes' own identification), for resolving links. */
65 tasks: Task[]
66 taskOf: (file: FileV) => Task
67 resolve: (target: string) => FileV | undefined
68}
69
70async function findVaultRoot($: EngineInterface, from: string): Promise<string | undefined> {
71 for (let dir: string | undefined = from; dir; dir = parentOf(dir)) {
72 if (await $.fs.exists(`${dir}${sepOf(dir)}.obsidian`).catch(() => false)) return dir
73 }
74 return undefined
75}
76
77async function listNotes($: EngineInterface, dir: string, out: { real: string; mtime: number; size: number }[], depth = 0): Promise<void> {
78 if (depth > 8) return
79 const entries = await $.fs.list(dir).catch(() => [])
80 const sep = sepOf(dir)
81 const subdirs: string[] = []
82 for (const e of entries) {
83 if (e.name.startsWith('.')) continue
84 const real = `${dir}${sep}${e.name}`
85 if (e.kind === 'dir') subdirs.push(real)
86 else if (e.kind === 'file' && e.name.toLowerCase().endsWith('.md')) out.push({ real, mtime: e.mtimeMs, size: e.size })
87 }
88 for (const sub of subdirs) await listNotes($, sub, out, depth + 1)
89}
90
91function tagsOf(data: Record<string, unknown>): string[] {
92 const raw = data.tags ?? data.tag
93 const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(/[\s,]+/) : []
94 return list.map(t => String(t).trim().replace(/^#/, '')).filter(Boolean)
95}
96
97function linksOf(text: string): string[] {
98 const out = new Set<string>()
99 for (const m of text.matchAll(/\[\[([^\]|#]+)/g)) out.add((m[1] ?? '').trim())
100 return [...out]
101}
102
103type Listed = { real: string; mtime: number; size: number }
104
105/** Whether a base's global filters leave `folder` out (`!file.inFolder("<folder>")`). */
106function excludesFolder(config: BaseConfig, folder: string): boolean {
107 const norm = (f: string) => f.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '').toLowerCase()
108 const leaves = typeof config.filters === 'string' ? [config.filters] : config.filters && 'and' in config.filters ? config.filters.and : []
109 return leaves.some(leaf => {
110 const m = typeof leaf === 'string' ? /^!\s*file\.inFolder\(\s*["']([^"']+)["']\s*\)\s*$/.exec(leaf.trim()) : null
111 return !!m && (norm(folder) === norm(m[1] ?? '') || norm(folder).startsWith(`${norm(m[1] ?? '')}/`))
112 })
113}
114
115/**
116 * The notes a base runs over: the TaskNotes tasks folder, and the archive unless the base's
117 * own filters leave it out (then only the archived notes an open task names as a blocker
118 * are read, so a finished blocker still shows as finished). Parsing every archived note
119 * would spend most of a redraw's 10-second budget on notes the view never shows.
120 */
121async function loadVault($: EngineInterface, root: string, config: BaseConfig): Promise<Vault> {
122 const settingsText = await $.fs.read(joinPath(root, '.obsidian/plugins/tasknotes/data.json')).catch(() => undefined)
123 const settings = parseSettings(settingsText)
124 const tasksDir = joinPath(root, settings.tasksFolder)
125 const archiveDir = joinPath(root, settings.archiveFolder)
126 const main: Listed[] = []
127 const archived: Listed[] = []
128 await listNotes($, tasksDir, main)
129 if (!isInside(archiveDir, tasksDir)) await listNotes($, archiveDir, archived)
130 const archiveIncluded = !excludesFolder(config, settings.archiveFolder)
131 const settingsKey = String(hash(settingsText ?? ''))
132 const sig = (list: Listed[]) => `${list.length}|${list.reduce((h, l) => (Math.imul(h, 31) + l.mtime + l.real.length) | 0, 7)}`
133 const signature = `${root}|${settingsKey}|${archiveIncluded}|${sig(main)}|${sig(archived)}`
134 if (vaultCache?.signature === signature) return vaultCache.vault
135 const parse = (list: Listed[]) => parseNotes($, root, list, settings, settingsKey)
136 await parse(archiveIncluded ? [...main, ...archived] : main)
137 const inScope = (archiveIncluded ? [...main, ...archived] : main).map(l => notes.get(l.real)).filter((e): e is Entry => !!e)
138 let extra: Entry[] = []
139 if (!archiveIncluded) {
140 // Blockers that live in the archive: read just those.
141 const known = new Set(inScope.map(e => linkKey(e.file)))
142 const wanted = new Set(inScope.flatMap(e => e.task.blockedBy.map(linkKey)).filter(k => !known.has(k)))
143 const needed = archived.filter(l => wanted.has(linkKey(l.real.slice(l.real.lastIndexOf(sepOf(l.real)) + 1))))
144 await parse(needed)
145 extra = needed.map(l => notes.get(l.real)).filter((e): e is Entry => !!e)
146 }
147 const entries = [...inScope, ...extra]
148 const taskByFile = new Map(entries.map(e => [e.file, e.task]))
149 const taskOf = (file: FileV) => taskByFile.get(file) ?? toTask(file, '', settings)
150 const files = inScope.map(e => e.file)
151 const byKey = new Map<string, FileV>()
152 for (const f of entries.map(e => e.file)) {
153 byKey.set(f.path.replace(/\.md$/i, '').toLowerCase(), f)
154 if (!byKey.has(linkKey(f))) byKey.set(linkKey(f), f)
155 }
156 const resolve = (target: string) => byKey.get(target.replace(/\\/g, '/').replace(/\.md$/i, '').toLowerCase()) ?? byKey.get(linkKey(target))
157 const vault: Vault = { signature, settings, files, archiveIncluded, tasks: entries.map(e => e.task), taskOf, resolve }
158 vaultCache = { signature, vault }
159 return vault
160}
161
162async function parseNotes($: EngineInterface, root: string, list: Listed[], settings: TaskNotesSettings, settingsKey: string): Promise<void> {
163 const stale = list.filter(l => {
164 const e = notes.get(l.real)
165 return e?.mtime !== l.mtime || e.settings !== settingsKey
166 })
167 for (let k = 0; k < stale.length; k += 32) {
168 await Promise.all(stale.slice(k, k + 32).map(async l => {
169 const text = await $.fs.read(l.real).catch(() => '')
170 const { data, body } = splitFrontmatter(text)
171 const rel = l.real.slice(root.length + 1).split(sepOf(l.real)).join('/')
172 const created = toDate(data[fieldKey(settings, 'dateCreated')])?.ms ?? l.mtime
173 const file = new FileV(rel, data, l.mtime, created, l.size, tagsOf(data), linksOf(text))
174 // The task (Done When progress needs the body) is made here, once; the body is not kept.
175 notes.set(l.real, { mtime: l.mtime, settings: settingsKey, file, task: toTask(file, body, settings) })
176 }))
177 }
178}
179
180/**
181 * The vault as last built, by its notes' paths and modification times and TaskNotes'
182 * settings. A pane redraws on every press and resize, and every redraw has a 10-second
183 * budget of its own time: rebuilding the model of every note each time used most of it.
184 */
185let vaultCache: { signature: string; vault: Vault } | undefined
186
187function hash(text: string): number {
188 let h = 7
189 for (let k = 0; k < text.length; k++) h = (Math.imul(h, 31) + text.charCodeAt(k)) | 0
190 return h
191}
192
193// ── targets ──────────────────────────────────────────────────────────────
194
195type Parsed = { what: string; view?: string; layout?: Layout; projects?: string[]; contexts?: string[] }
196
197/** A comma-separated filter value: `Platform, Website` → ['Platform', 'Website']. */
198function names(value: unknown): string[] {
199 return String(value ?? '').split(',').map(s => cleanArg(s).replace(/^\[\[|\]\]$/g, '')).filter(Boolean)
200}
201
202/** `/taskboard [base|alias] [view] [--board|--list|--agenda|--graph] [--project=A,B] [--context=X]`. */
203function parseArgs(args: string): Parsed {
204 let rest = args.trim()
205 let layout: Layout | undefined
206 const picked: { projects: string[]; contexts: string[] } = { projects: [], contexts: [] }
207 for (const m of [...rest.matchAll(/\s*--(project|context)=("[^"]*"|\S+)/gi)]) {
208 picked[(m[1] ?? '').toLowerCase() === 'project' ? 'projects' : 'contexts'].push(...names(m[2]))
209 }
210 rest = rest.replace(/\s*--(project|context)=("[^"]*"|\S+)/gi, '').trim()
211 const flag = /\s*--(board|list|agenda|graph)\b/i.exec(rest)
212 if (flag) {
213 layout = (flag[1] ?? '').toLowerCase() as Layout
214 rest = (rest.slice(0, flag.index) + rest.slice(flag.index + flag[0].length)).trim()
215 }
216 let what = ''
217 const quoted = /^"([^"]+)"\s*(.*)$/.exec(rest)
218 if (quoted) {
219 what = quoted[1] ?? ''
220 rest = quoted[2] ?? ''
221 } else {
222 const at = rest.toLowerCase().indexOf('.base')
223 if (at >= 0) {
224 what = rest.slice(0, at + 5)
225 rest = rest.slice(at + 5)
226 } else {
227 const word = /^(\S+)\s*(.*)$/.exec(rest)
228 if (word && VIEW_ALIASES[(word[1] ?? '').toLowerCase()]) {
229 what = word[1] ?? ''
230 rest = word[2] ?? ''
231 }
232 }
233 }
234 const parsed: Parsed = { what: cleanArg(what) || 'tasks' }
235 if (rest.trim()) parsed.view = cleanArg(rest)
236 if (layout) parsed.layout = layout
237 if (picked.projects.length) parsed.projects = picked.projects
238 if (picked.contexts.length) parsed.contexts = picked.contexts
239 return parsed
240}
241
242type Located = Target & { root: string }
243
244async function locate($: EngineInterface, what: string): Promise<Located | { error: string }> {
245 const alias = VIEW_ALIASES[what.toLowerCase()]
246 if (alias) {
247 const root = (await findVaultRoot($, await $.session.cwd())) ?? (await findVaultRoot($, await $.session.root()))
248 if (!root) return { error: `"${what}" names a TaskNotes view, but this session is not inside an Obsidian vault; give a .base path` }
249 const settings = parseSettings(await $.fs.read(joinPath(root, '.obsidian/plugins/tasknotes/data.json')).catch(() => undefined))
250 const rel = settings.viewFiles[alias]
251 if (!rel) return { error: `TaskNotes has no file for "${what}"` }
252 const real = joinPath(root, rel)
253 if (!(await $.fs.exists(real).catch(() => false))) return { error: `TaskNotes opens ${rel} for "${what}", but it does not exist` }
254 return { ...targetIn(root, real), root }
255 }
256 if (!what.toLowerCase().endsWith('.base')) return { error: `${what} is not a .base file or a view name (${Object.keys(VIEW_ALIASES).join(', ')})` }
257 const stat = await $.fs.stat(what, { resolve: true }).catch(() => undefined)
258 let real = stat?.kind === 'file' ? stat.realPath : undefined
259 if (!real) {
260 // A vault-relative path from a session started elsewhere in the vault.
261 const root = await findVaultRoot($, await $.session.cwd())
262 const candidate = root ? joinPath(root, what) : undefined
263 if (candidate && (await $.fs.exists(candidate).catch(() => false))) real = candidate
264 }
265 if (!real) return { error: `${what} not found` }
266 const root = await findVaultRoot($, parentOf(real) ?? real)
267 if (!root) return { error: `${what} is not inside an Obsidian vault (no .obsidian folder above it)` }
268 return { ...targetIn(root, real), root }
269}
270
271function pickView(config: BaseConfig, wanted?: string): number | { error: string } {
272 if (!wanted) return 0
273 const n = Number(wanted)
274 if (Number.isInteger(n) && n >= 1 && n <= config.views.length) return n - 1
275 const i = config.views.findIndex(v => v.name.toLowerCase() === wanted.toLowerCase())
276 if (i >= 0) return i
277 return { error: `no view "${wanted}"; there are: ${config.views.map((v, k) => `${k + 1}. ${v.name}`).join(', ')}` }
278}
279
280async function openInObsidian($: EngineInterface, target: Target): Promise<string> {
281 const uri = obsidianUri(target)
282 if (!uri) throw new Error('the file is not inside an Obsidian vault')
283 let last = 'no launcher available'
284 for (const argv of launchers(uri)) {
285 try {
286 const run = await $.process.run(argv, { timeoutMs: 15000 })
287 if (run.exitCode === 0) return argv[0] ?? 'launcher'
288 last = `${argv[0]} exited ${run.exitCode}: ${run.stderr.trim().slice(0, 200)}`
289 } catch (err) {
290 last = `${argv[0]}: ${errorText(err)}`
291 }
292 }
293 throw new Error(last)
294}
295
296// ── evaluation ───────────────────────────────────────────────────────────
297
298type Drawn = {
299 /** Identifies the evaluation: base, view, notes, settings and day. */
300 key: string
301 config: BaseConfig
302 vault: Vault
303 /** The whole view, as the base defines it. */
304 all: Model
305 /** The view narrowed by the pills: what the pane draws. */
306 model: Model
307 layout: Layout
308 now: number
309}
310
311function filtersOf(t: TasksTarget): Filters {
312 return { projects: t.projects ?? [], contexts: t.contexts ?? [] }
313}
314
315/**
316 * The last evaluations, by base, view, the notes' modification times and the minute. A
317 * layout, expand or zoom press redraws the pane; it should not run every filter again.
318 */
319const evaluations = new Map<string, Omit<Drawn, 'layout' | 'model'>>()
320
321/**
322 * What a layout made of an evaluation (board columns, the agenda, the graph and its SVG,
323 * pill counts), by the evaluation and what else it read. A press that changes none of it
324 * (zoom, pan, Fit, a resize) draws from here.
325 */
326const derived = new Map<string, unknown>()
327
328function memo<T>(key: string, make: () => T): T {
329 if (derived.has(key)) return derived.get(key) as T
330 if (derived.size > 32) derived.clear()
331 const value = make()
332 derived.set(key, value)
333 return value
334}
335
336function filterKey(t: TasksTarget): string {
337 const f = filtersOf(t)
338 return `${f.projects.join('\u0001')}|${f.contexts.join('\u0001')}`
339}
340
341async function evaluate($: EngineInterface, t: TasksTarget): Promise<Drawn> {
342 const source = await $.fs.read(t.real)
343 const parsedBase = parseBase(source)
344 const vault = await loadVault($, t.root, parsedBase)
345 const now = Date.now()
346 // today() moves once a day; that is the clock a view's filters read.
347 const key = `${t.real}|${t.view}|${hash(source)}|${vault.signature}|${new Date(now).toDateString()}`
348 const cached = evaluations.get(key)
349 if (cached) return { ...cached, model: memo(`${key}|narrow|${filterKey(t)}`, () => narrow(cached.all, filtersOf(t))), layout: t.layout ?? cached.all.natural }
350 const config = parsedBase
351 const view = config.views[Math.min(t.view, config.views.length - 1)] ?? config.views[0]
352 if (!view) throw new Error('the base has no views')
353 const thisFile = new FileV(t.rel, {}, now, now, source.length, [], [])
354 const all = buildModel({ config, view, files: vault.files, taskOf: vault.taskOf, ctx: { now, thisFile, resolve: vault.resolve } })
355 evaluations.clear()
356 evaluations.set(key, { key, config, vault, all, now })
357 return { key, config, vault, all, model: memo(`${key}|narrow|${filterKey(t)}`, () => narrow(all, filtersOf(t))), layout: t.layout ?? all.natural, now }
358}
359
360/** A short text account of what the pane shows: the model tool's answer and the terminal's pane. */
361function summarize(d: Drawn, t: TasksTarget): string {
362 const { model, all, config, vault, now } = d
363 const f = filtersOf(t)
364 const filter = [...f.projects.map(p => `project ${p}`), ...f.contexts.map(c => `context @${c}`)].join(', ')
365 const head = filter
366 ? `${model.view.name} (${model.view.type}), filtered to ${filter}: ${model.matched} of ${all.matched} tasks`
367 : `${model.view.name} (${model.view.type}): ${model.matched} task${model.matched === 1 ? '' : 's'}`
368 if (d.layout === 'agenda') {
369 const count = Number(model.view.options.listDayCount) || 7
370 const agenda = memo(`${d.key}|agenda|${filterKey(t)}|${count}`, () => buildAgenda(model.tasks, now, count))
371 const days = agenda.days.filter(x => x.items.length).map(x => `${dayLabel(x.date, now)} ${x.items.length}`)
372 return `${head}; overdue ${agenda.overdue.length}${days.length ? `; ${days.join(', ')}` : ''}`
373 }
374 if (d.layout === 'graph') {
375 const g = memo(`${d.key}|graph|${filterKey(t)}|${!!t.hideDone}`, () => buildGraph(model.tasks, vault.tasks, { hideDone: !!t.hideDone }))
376 return `${head}; ${g.edges.length} dependencies among ${g.nodes.length} tasks; critical path ${Math.round(g.criticalDays * 10) / 10} days`
377 }
378 const kind = d.layout === 'board' ? 'board' : 'list'
379 const columns = memo(`${d.key}|columns|${kind}|${filterKey(t)}`, () => columnsOf(model, config, vault.settings, vault.tasks, now, kind))
380 if (columns.length === 1 && columns[0]?.key === 'all') return head
381 return `${head}; ${columns.map(c => `${c.label} ${c.cards.length}`).join(', ')}`
382}
383
384async function show($: EngineInterface, args: Parsed): Promise<string> {
385 const located = await locate($, args.what)
386 if ('error' in located) return `tasknotes-preview: ${located.error}`
387 const config = parseBase(await $.fs.read(located.real))
388 const view = pickView(config, args.view)
389 if (typeof view !== 'number') return `tasknotes-preview: ${located.rel}: ${view.error}`
390 const value: TasksTarget = { ...located, view, expanded: [], orientation: 'landscape', zoom: 1, pan: 0, rev: Date.now() }
391 if (args.layout) value.layout = args.layout
392 if (args.projects?.length) value.projects = args.projects
393 if (args.contexts?.length) value.contexts = args.contexts
394 await $.state.set({ ...TARGET, id: PANE }, value)
395 const isOpen = await $.ui.panes().then(ps => ps.some(p => p.id === PANE)).catch(() => false)
396 if (isOpen) await $.ui.close({ id: PANE }).catch(() => undefined)
397 const viewName = config.views[view]?.name ?? 'view'
398 const opened = await $.ui.open({ id: PANE, title: `Tasks · ${viewName}`, focus: true })
399 let account = ''
400 try {
401 account = summarize(await evaluate($, value), value)
402 } catch (err) {
403 account = `could not evaluate it: ${errorText(err)}`
404 }
405 return opened.isPlaced
406 ? `Showing ${located.rel} in the Tasks pane. ${account}`
407 : `The Tasks pane holds ${located.rel} but is not placed yet (${opened.reason}). ${account}`
408}
409
410// ── hooks ────────────────────────────────────────────────────────────────
411
412export const register: Register = on => {
413 on('session.start', async ($, e, next) => {
414 await $.command.register({
415 name: 'taskboard',
416 description: 'Show TaskNotes tasks from a .base view: board, list, agenda or dependency graph',
417 argumentHint: '[kanban|tasks|agenda|calendar|<file.base>] [view] [--board|--list|--agenda|--graph] [--project=A,B] [--context=X]',
418 })
419 await $.tool.register({
420 name: 'open',
421 description:
422 "Show the person's TaskNotes tasks in a side pane, evaluated from an Obsidian Bases (.base) view the way " +
423 'Obsidian shows it: as a kanban board, a grouped list, an agenda of the coming days, or a dependency graph ' +
424 '(blockedBy arrows with the critical path). Returns a short account of what it shows (counts per column). ' +
425 'base: a .base path, or one of kanban, tasks, agenda, calendar, relationships (the files TaskNotes opens ' +
426 'for those commands); default tasks. view: a view name or 1-based number. layout: board, list, agenda or graph. ' +
427 'project / context: narrow the view to tasks in these projects or contexts (comma-separated names).',
428 inputSchema: {
429 type: 'object',
430 properties: {
431 base: { type: 'string', description: 'A .base path, or kanban | tasks | agenda | calendar | relationships' },
432 view: { type: 'string', description: 'Optional view name or 1-based number' },
433 layout: { type: 'string', enum: LAYOUTS, description: 'Optional: board, list, agenda or graph' },
434 project: { type: 'string', description: 'Optional: only tasks in these projects (comma-separated)' },
435 context: { type: 'string', description: 'Optional: only tasks with these contexts (comma-separated)' },
436 },
437 },
438 })
439 return next(e)
440 })
441
442 on('command.run', { command: 'taskboard' }, async ($, e) => ({ text: await show($, parseArgs(e.args)) }))
443
444 on('tool.call', { tool: 'mcp__tasknotes-preview__open' }, async ($, e) => {
445 const parsed: Parsed = { what: cleanArg(String(e.base ?? '')) || 'tasks' }
446 if (e.view !== undefined && String(e.view).trim()) parsed.view = String(e.view).trim()
447 if (typeof e.layout === 'string' && (LAYOUTS as string[]).includes(e.layout)) parsed.layout = e.layout as Layout
448 if (names(e.project).length) parsed.projects = names(e.project)
449 if (names(e.context).length) parsed.contexts = names(e.context)
450 return { result: await show($, parsed) }
451 })
452
453 // Redraw after the agent writes a task note, the base, or TaskNotes' settings.
454 for (const tool of ['Write', 'Edit'] as const) {
455 on('tool.call', { tool }, async ($, e, next) => {
456 const ran = await next(e)
457 if (ran.deny !== undefined || ran.isError) return ran
458 const path = String(e.file_path ?? '')
459 const ref = { ...TARGET, id: PANE }
460 const { value } = await $.state.get(ref)
461 if (value && (samePath(path, value.real) || (isInside(path, value.root) && /\.(md|json|base)$/i.test(path)))) {
462 await update($, ref, cur => (cur ? { ...cur, rev: cur.rev + 1 } : cur))
463 }
464 return ran
465 })
466 }
467
468 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
469 const ref = memberOf(TARGET, e)
470 const { value: t } = await $.state.get(ref)
471 const patch = (change: Partial<TasksTarget>) => update($, ref, cur => (cur ? { ...cur, ...change, rev: cur.rev + 1 } : cur))
472 const open = async (rel: string) => {
473 if (!t) return
474 const target: Target = { rel, real: joinPath(t.root, rel), ...(t.vault ? { vault: t.vault } : {}) }
475 try {
476 $.ui.toast(`Opened ${rel} in Obsidian (${await openInObsidian($, target)})`)
477 } catch (err) {
478 $.ui.toast(`Could not open Obsidian: ${errorText(err)}`)
479 }
480 }
481 const empty = 'No tasks open. Run /taskboard, or /taskboard kanban, or /taskboard <file.base>.'
482
483 let drawn: Drawn | undefined
484 let failure: string | undefined
485 if (t) {
486 try {
487 drawn = await evaluate($, t)
488 } catch (err) {
489 failure = errorText(err)
490 }
491 }
492
493 if (e.surface === 'terminal') {
494 const { Box, Text } = $.ui.resolve(e)
495 return (
496 <Box flexDirection="column">
497 <Text>{t ? t.rel : empty}</Text>
498 {drawn && <Text>{summarize(drawn, t as TasksTarget)}</Text>}
499 {failure && <Text color="red">{failure}</Text>}
500 <Text dimColor>The board, agenda and graph are drawn in the Desktop Code tab.</Text>
501 </Box>
502 )
503 }
504
505 const elements = $.ui.resolve(e)
506 const { Box, Text, Button, Svg } = elements
507 const Select = 'Select' in elements ? elements.Select : undefined
508 if (!t) return <Text dimColor>{empty}</Text>
509 if (!drawn) return <Text color="red">Cannot show {t.rel}: {failure}</Text>
510
511 const { model, all, config, vault, layout, now } = drawn
512 const filters = filtersOf(t)
513 const filtering = filters.projects.length + filters.contexts.length > 0
514 const settings = vault.settings
515 const isExpanded = (key: string) => t.expanded.includes(`${layout}:${key}`)
516 const expand = (key: string) => patch({ expanded: [...t.expanded, `${layout}:${key}`] })
517 const collapse = (key: string) => patch({ expanded: t.expanded.filter(k => k !== `${layout}:${key}`) })
518
519 // The popup a card or agenda row shows while the pointer is on it: every property of the
520 // task and an Open in Obsidian button. A hover reveal runs no hook, so it costs no redraw;
521 // it is drawn display:none, absolute (moves nothing) and lit by the keyed Box around it.
522 const taskIndexOf = memo(`${drawn.key}|index`, () => taskIndex(vault.tasks))
523 const taskByPath = memo(`${drawn.key}|byPath`, () => new Map(vault.tasks.map(task => [task.file.path, task])))
524 const popup = (path: string, place: { top: number; left: number }) => {
525 const task = taskByPath.get(path)
526 if (!task) return undefined
527 return (
528 <Box
529 position="absolute"
530 top={place.top}
531 left={place.left}
532 right={0}
533 display="none"
534 hover={{ display: 'flex' }}
535 flexDirection="column"
536 borderStyle="round"
537 borderColor={task.statusDef?.color ?? 'gray'}
538 backgroundColor="#1e1e1e"
539 paddingX={1}
540 >
541 <Text bold color="white" wrap="wrap">{task.title}</Text>
542 {detailOf(task, taskIndexOf, now).map((m, k) => (
543 <Text key={`pop:${path}:${k}`} color={m.color ?? 'white'} {...(k === 0 || m.color ? {} : { dimColor: true })} wrap="wrap">{m.text}</Text>
544 ))}
545 {t.vault && <Button key={`pop-open:${path}`} label="Open in Obsidian" variant="primary" onPress={() => open(path)} />}
546 </Box>
547 )
548 }
549
550 const cardView = (card: Card, compact: boolean) => (
551 <Box key={`card:${card.key}`} flexDirection="column" borderStyle="round" borderColor={card.color ?? 'gray'} paddingX={1} marginBottom={compact ? 0 : 1}>
552 {popup(card.path, { top: 0, left: 0 })}
553 <Box flexDirection="row" gap={1} alignItems="flex-start">
554 <Text color={card.color ?? 'gray'}>●</Text>
555 <Box flexGrow={1} flexShrink={1}>
556 <Text bold={!card.done} strikethrough={card.done} dimColor={card.done} wrap="wrap">{card.title}</Text>
557 </Box>
558 <Button key={`open:${card.path}`} label="↗" plain dimColor onPress={() => open(card.path)} />
559 </Box>
560 {card.meta.length > 0 && (
561 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
562 {card.meta.map((m, k) => (
563 <Text key={`${card.key}:m${k}`} {...(m.color ? { color: m.color } : { dimColor: true })}>{m.text}</Text>
564 ))}
565 </Box>
566 )}
567 </Box>
568 )
569
570 const more = (key: string, total: number) =>
571 total > CAP && (isExpanded(key)
572 ? <Button key={`less:${key}`} label="Show fewer" dimColor onPress={() => collapse(key)} />
573 : <Button key={`more:${key}`} label={`Show all ${total}`} dimColor onPress={() => expand(key)} />)
574
575 const columnView = (col: Column, board: boolean) => {
576 const shown = isExpanded(col.key) ? col.cards : col.cards.slice(0, CAP)
577 return (
578 <Box key={`col:${col.key}`} flexDirection="column" flexGrow={1} flexShrink={1} {...(board ? { minWidth: 30, width: 34 } : {})}>
579 <Box flexDirection="row" gap={1}>
580 <Text color={col.color ?? 'gray'}>●</Text>
581 <Text bold>{col.label}</Text>
582 <Text dimColor>{col.cards.length}</Text>
583 </Box>
584 {col.cards.length === 0 && <Text dimColor italic>No tasks</Text>}
585 {shown.map(card => cardView(card, false))}
586 {more(col.key, col.cards.length)}
587 </Box>
588 )
589 }
590
591 const agendaRow = (item: AgendaItem, k: number, overdue: boolean) => {
592 const status = item.task.statusDef
593 const kind = item.kind === 'recurring' ? '↻' : item.kind === 'due' ? 'due' : 'sched'
594 return (
595 <Box key={`ag:${item.task.file.path}:${item.date}:${k}`} flexDirection="row" gap={1}>
596 {popup(item.task.file.path, { top: 1, left: 2 })}
597 <Text color={status?.color ?? 'gray'}>●</Text>
598 <Text {...(overdue ? { color: '#d62728' } : { dimColor: true })}>{overdue ? dayLabel(item.date, now) : kind}</Text>
599 <Box flexGrow={1} flexShrink={1}>
600 <Text wrap="wrap">{item.task.title}</Text>
601 </Box>
602 {item.task.priorityDef && item.task.priority !== 'none' && <Text color={item.task.priorityDef.color}>{item.task.priorityDef.label}</Text>}
603 <Button key={`open:${item.task.file.path}:${item.date}`} label="↗" plain dimColor onPress={() => open(item.task.file.path)} />
604 </Box>
605 )
606 }
607
608 // Pills: the projects and contexts of the whole view, most used first. A press toggles one;
609 // several in a row widen (any of them), the two rows narrow each other (both must hold).
610 const facetsOf = memo(`${drawn.key}|facets`, () => facets(all.tasks))
611 const PILLS = 10
612 const pillRow = (kind: 'project' | 'context', label: string, list: Facet[], selected: string[], prefix: string) => {
613 if (!list.length) return undefined
614 const chosen = selected.map(s => s.toLowerCase())
615 const field = kind === 'project' ? 'projects' : 'contexts'
616 const toggle = (name: string) =>
617 patch({ [field]: chosen.includes(name.toLowerCase()) ? selected.filter(s => s.toLowerCase() !== name.toLowerCase()) : [...selected, name] } as Partial<TasksTarget>)
618 // Selected pills always show, even beyond the cap.
619 const visible = t.allPills ? list : list.filter((f, i) => i < PILLS || chosen.includes(f.name.toLowerCase()))
620 return (
621 <Box key={`pills-${kind}`} flexDirection="row" flexWrap="wrap" gap={1} alignItems="center">
622 <Text dimColor>{label}</Text>
623 {visible.map(f => (
624 <Button
625 key={`pill-${kind}:${f.name}`}
626 label={`${prefix}${f.name} ${f.count}`}
627 {...(chosen.includes(f.name.toLowerCase()) ? { variant: 'primary' as const } : { dimColor: true })}
628 onPress={() => toggle(f.name)}
629 />
630 ))}
631 {list.length > PILLS && (
632 <Button key={`pills-more-${kind}`} label={t.allPills ? 'Fewer' : `+${list.length - visible.length} more`} dimColor onPress={() => patch({ allPills: !t.allPills })} />
633 )}
634 </Box>
635 )
636 }
637
638 let body: ReturnType<typeof Text>
639 let caption = filtering
640 ? `${model.matched} of ${all.matched} tasks`
641 : `${model.matched} task${model.matched === 1 ? '' : 's'}${model.shown < model.matched ? ` (first ${model.shown})` : ''}`
642 if (layout === 'board' || layout === 'list') {
643 const columns = memo(`${drawn.key}|columns|${layout}|${filterKey(t)}`, () => columnsOf(model, config, settings, vault.tasks, now, layout))
644 body = layout === 'board'
645 ? <Box flexDirection="row" flexWrap="wrap" gap={2}>{columns.map(c => columnView(c, true))}</Box>
646 : <Box flexDirection="column" gap={1}>{columns.map(c => columnView(c, false))}</Box>
647 } else if (layout === 'agenda') {
648 const days = Number(model.view.options.listDayCount) || 7
649 const agenda = memo(`${drawn.key}|agenda|${filterKey(t)}|${days}`, () => buildAgenda(model.tasks, now, days))
650 const overdueShown = isExpanded('overdue') ? agenda.overdue : agenda.overdue.slice(0, CAP)
651 caption += ` · next ${agenda.days.length} days`
652 body = (
653 <Box flexDirection="column" gap={1}>
654 {agenda.overdue.length > 0 && (
655 <Box key="overdue" flexDirection="column">
656 <Text bold color="#d62728">Overdue · {agenda.overdue.length}</Text>
657 {overdueShown.map((item, k) => agendaRow(item, k, true))}
658 {more('overdue', agenda.overdue.length)}
659 </Box>
660 )}
661 {agenda.days.map(day => (
662 <Box key={`day:${day.date}`} flexDirection="column">
663 <Box flexDirection="row" gap={1}>
664 <Text bold>{dayLabel(day.date, now)}</Text>
665 <Text dimColor>{day.items.length}</Text>
666 </Box>
667 {day.items.length === 0 && <Text dimColor italic>Nothing due or scheduled</Text>}
668 {day.items.map((item, k) => agendaRow(item, k, false))}
669 </Box>
670 ))}
671 </Box>
672 )
673 } else {
674 const graph = memo(`${drawn.key}|graph|${filterKey(t)}|${!!t.hideDone}`, () => buildGraph(model.tasks, vault.tasks, { hideDone: !!t.hideDone }))
675 const dots = graph.nodes.filter(n => n.task?.done && !n.missing).length
676 const zoom = t.zoom > 0 ? t.zoom : 1
677 const svg = graph.nodes.length ? memo(`${drawn.key}|svg|${filterKey(t)}|${!!t.hideDone}|${t.orientation}`, () => graphSvg(graph, settings, t.orientation)) : undefined
678 const view = svg ? zoomView(svg, zoom, t.pan) : undefined
679 caption += ` · ${graph.edges.length} dependenc${graph.edges.length === 1 ? 'y' : 'ies'}` +
680 (graph.criticalDays ? ` · critical path ${Math.round(graph.criticalDays * 10) / 10} days (red)` : '') +
681 (graph.isolated ? ` · ${graph.isolated} without dependencies not drawn` : '') +
682 (graph.cycles ? ` · ${graph.cycles} in a cycle` : '') +
683 (dots ? ` · ${dots} done shown as dots` : '') +
684 (view ? ` · ${t.orientation} · ${zoomLabel(zoom, view.from, view.to)}` : '')
685 const panBy = (direction: 1 | -1) => patch({ pan: Math.min(1, Math.max(0, t.pan + direction * panStep(zoom))) })
686 // The drawing is a picture: hover and presses happen on these rows instead, in the
687 // critical path's order, then open work before finished, then by layer.
688 const listed = graph.nodes
689 .filter(n => n.task)
690 .sort((a, b) => Number(b.critical) - Number(a.critical) || Number(!!a.task?.done) - Number(!!b.task?.done) || a.level - b.level || a.title.localeCompare(b.title))
691 const nodeRow = (n: GraphNode) => {
692 const task = n.task as Task
693 const path = task.file.path
694 const meta = [task.statusDef?.label ?? task.status, task.size, !task.done ? `${Math.round(n.days * 10) / 10}d` : '', n.inView ? '' : 'outside view', n.cycle ? 'in a cycle' : '']
695 .filter(Boolean)
696 .join(' · ')
697 return (
698 <Box key={`gn:${path}`} flexDirection="row" gap={1}>
699 {popup(path, { top: 1, left: 2 })}
700 <Text color={n.critical ? '#d62728' : task.statusDef?.color ?? 'gray'}>{n.critical ? '◆' : '●'}</Text>
701 <Box flexGrow={1} flexShrink={1}>
702 <Text wrap="wrap" strikethrough={task.done} dimColor={task.done}>{task.title}</Text>
703 </Box>
704 <Text dimColor>{meta}</Text>
705 <Button key={`open:${path}:graph`} label="↗" plain dimColor onPress={() => open(path)} />
706 </Box>
707 )
708 }
709 body = (
710 <Box flexDirection="column" gap={1}>
711 <Box flexDirection="row" gap={2} flexWrap="wrap" alignItems="center">
712 <Button
713 key="orientation"
714 label={t.orientation === 'landscape' ? 'Switch to portrait' : 'Switch to landscape'}
715 onPress={() => patch({ orientation: t.orientation === 'landscape' ? 'portrait' : 'landscape' })}
716 />
717 <Button key="zoom-out" label="−" onPress={() => patch({ zoom: stepZoom(zoom, -1) })} />
718 <Button key="zoom-fit" label="Fit" onPress={() => patch({ zoom: 1, pan: 0 })} />
719 <Button key="zoom-in" label="+" onPress={() => patch({ zoom: stepZoom(zoom, 1) })} />
720 {zoom > 1 && <Button key="pan-left" label="◀" onPress={() => panBy(-1)} />}
721 {zoom > 1 && <Button key="pan-right" label="▶" onPress={() => panBy(1)} />}
722 {/* One press: finished tasks as dots, or not at all. */}
723 <Button key="done-toggle" label={t.hideDone ? 'Show done' : 'Hide done'} onPress={() => patch({ hideDone: !t.hideDone })} />
724 </Box>
725 {!view && <Text dimColor italic>No task in this view has a blockedBy dependency.</Text>}
726 {view && (view.svg.length > SVG_LIMIT
727 ? <Text>The graph is too large for the pane ({view.svg.length.toLocaleString('en')} characters); pick a narrower view.</Text>
728 : <Svg source={view.svg} alt={`Dependency graph of ${model.view.name}: ${graph.nodes.length} tasks, ${graph.edges.length} dependencies`} />)}
729 {listed.length > 0 && (
730 <Box key="graph-nodes" flexDirection="column">
731 <Box flexDirection="row" gap={1}>
732 <Text bold>Tasks in the graph</Text>
733 <Text dimColor>{listed.length} · critical path first · hover a row for details</Text>
734 </Box>
735 {(isExpanded('graph-nodes') ? listed : listed.slice(0, CAP)).map(nodeRow)}
736 {more('graph-nodes', listed.length)}
737 </Box>
738 )}
739 </Box>
740 )
741 }
742
743 return (
744 <Box flexDirection="column" gap={1}>
745 <Text bold wrap="truncate-middle">{t.rel}</Text>
746 <Box flexDirection="row" gap={2} alignItems="center" flexWrap="wrap">
747 {Select && config.views.length > 1 && (
748 <Select
749 key="view"
750 label="View"
751 value={String(Math.min(t.view, config.views.length - 1))}
752 options={config.views.map((v, k) => ({ value: String(k), label: v.name }))}
753 onSelect={v =>
754 update($, ref, cur => {
755 if (!cur) return cur
756 // A new view is drawn its own way again.
757 const { layout: _drop, ...rest } = cur
758 return { ...rest, view: Number(v), expanded: [], zoom: 1, pan: 0, rev: cur.rev + 1 }
759 })
760 }
761 />
762 )}
763 {/* Four ways to draw the same rows: one press each, the current one marked. */}
764 {LAYOUTS.map(l => (
765 <Button key={`layout-${l}`} label={LAYOUT_LABEL[l]} {...(l === layout ? { variant: 'primary' as const } : {})} onPress={() => patch({ layout: l })} />
766 ))}
767 <Button key="refresh" label="Refresh" onPress={() => patch({})} />
768 {t.vault && <Button key="open-base" label="Open in Obsidian" onPress={() => open(t.rel)} />}
769 </Box>
770 <Text dimColor>{model.view.name} · {caption}{settings.found ? '' : ' · TaskNotes settings not found, using defaults'}</Text>
771 <Text dimColor>
772 {vault.archiveIncluded
773 ? `Filters run over the ${vault.files.length.toLocaleString('en')} notes in ${settings.tasksFolder} and ${settings.archiveFolder}, not the whole vault.`
774 : `Filters run over the ${vault.files.length.toLocaleString('en')} notes in ${settings.tasksFolder}; this base leaves ${settings.archiveFolder} out.`}
775 </Text>
776 {pillRow('project', 'Projects', facetsOf.projects, filters.projects, '')}
777 {pillRow('context', 'Contexts', facetsOf.contexts, filters.contexts, '@')}
778 {filtering && <Button key="pills-clear" label="Clear filters" dimColor onPress={() => patch({ projects: [], contexts: [] })} />}
779 {model.warnings.length > 0 && (
780 <Text dimColor italic>
781 Note: skipped what this renderer cannot read ({model.warnings.length}): {model.warnings.slice(0, 3).join('; ')}{model.warnings.length > 3 ? '; …' : ''}
782 </Text>
783 )}
784 {body}
785 </Box>
786 )
787 })
788}
789hooks/agenda.ts 154 lines1// The agenda: overdue tasks, then the next N days by due and scheduled date, with recurring
2// tasks expanded from their recurrence rule (TaskNotes writes `DTSTART:20260406;monthly`
3// as well as RFC 5545 `FREQ=MONTHLY;INTERVAL=3`). Instances already completed are left out.
4
5import { formatDate, toDate } from './expr'
6import type { Task } from './tasknotes'
7
8const DAY = 86400000
9const WEEKDAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']
10
11export type Rule = {
12 start: number
13 freq: 'daily' | 'weekly' | 'monthly' | 'yearly'
14 interval: number
15 byDay?: number[]
16 byMonthDay?: number[]
17 until?: number
18}
19
20export function midnight(ms: number): number {
21 const d = new Date(ms)
22 return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
23}
24
25/** Whole calendar days from `a` to `b` (both local), robust to DST changes. */
26function daysBetween(a: number, b: number): number {
27 return Math.round((midnight(b) - midnight(a)) / DAY)
28}
29
30export function parseRecurrence(text: string, fallbackStart?: number): Rule | undefined {
31 let start = fallbackStart
32 let freq: Rule['freq'] | undefined
33 let interval = 1
34 let byDay: number[] | undefined
35 let byMonthDay: number[] | undefined
36 let until: number | undefined
37 for (const rawPart of text.replace(/^RRULE:/i, '').split(/[;\n]/)) {
38 const part = rawPart.trim().replace(/^RRULE:/i, '')
39 if (!part) continue
40 const dt = /^DTSTART(?:;[^:]*)?:(\d{8})/i.exec(part)
41 if (dt) {
42 start = toDate(dt[1] ?? '')?.ms ?? start
43 continue
44 }
45 const kv = /^([A-Z]+)=(.*)$/i.exec(part)
46 if (kv) {
47 const key = (kv[1] ?? '').toUpperCase()
48 const val = (kv[2] ?? '').trim()
49 if (key === 'FREQ') freq = val.toLowerCase() as Rule['freq']
50 else if (key === 'INTERVAL') interval = Math.max(1, Number(val) || 1)
51 else if (key === 'BYDAY') byDay = val.split(',').map(d => WEEKDAYS.indexOf(d.replace(/^[-+\d]+/, '').toUpperCase())).filter(d => d >= 0)
52 else if (key === 'BYMONTHDAY') byMonthDay = val.split(',').map(Number).filter(n => Number.isFinite(n) && n !== 0)
53 else if (key === 'UNTIL') until = toDate(val.slice(0, 8))?.ms
54 continue
55 }
56 const word = part.toLowerCase()
57 if (word === 'daily' || word === 'weekly' || word === 'monthly' || word === 'yearly') freq = word
58 }
59 if (!freq || !['daily', 'weekly', 'monthly', 'yearly'].includes(freq) || start === undefined) return undefined
60 const rule: Rule = { start: midnight(start), freq, interval }
61 if (byDay?.length) rule.byDay = byDay
62 if (byMonthDay?.length) rule.byMonthDay = byMonthDay
63 if (until !== undefined) rule.until = until
64 return rule
65}
66
67export function occursOn(rule: Rule, day: number): boolean {
68 const d = midnight(day)
69 if (d < rule.start || (rule.until !== undefined && d > rule.until)) return false
70 const date = new Date(d)
71 const first = new Date(rule.start)
72 switch (rule.freq) {
73 case 'daily':
74 return daysBetween(rule.start, d) % rule.interval === 0
75 case 'weekly': {
76 const days = rule.byDay ?? [first.getDay()]
77 if (!days.includes(date.getDay())) return false
78 const weekStart = (ms: number) => midnight(ms) - new Date(ms).getDay() * DAY
79 const weeks = Math.round((weekStart(d) - weekStart(rule.start)) / (7 * DAY))
80 return weeks % rule.interval === 0
81 }
82 case 'monthly': {
83 const months = (date.getFullYear() - first.getFullYear()) * 12 + date.getMonth() - first.getMonth()
84 if (months % rule.interval !== 0) return false
85 const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
86 const wanted = (rule.byMonthDay ?? [first.getDate()]).map(n => (n < 0 ? lastDay + 1 + n : Math.min(n, lastDay)))
87 return wanted.includes(date.getDate())
88 }
89 case 'yearly':
90 return (date.getFullYear() - first.getFullYear()) % rule.interval === 0 &&
91 date.getMonth() === first.getMonth() && date.getDate() === first.getDate()
92 }
93}
94
95export type AgendaItem = { task: Task; kind: 'due' | 'scheduled' | 'recurring'; date: number }
96export type AgendaDay = { date: number; items: AgendaItem[] }
97export type Agenda = { overdue: AgendaItem[]; days: AgendaDay[] }
98
99function weightOf(task: Task): number {
100 return task.priorityDef?.weight ?? 0
101}
102
103export function buildAgenda(tasks: Task[], now: number, dayCount: number): Agenda {
104 const today = midnight(now)
105 const count = Math.max(1, Math.min(31, Math.floor(dayCount) || 7))
106 const days: AgendaDay[] = Array.from({ length: count }, (_, k) => {
107 const d = new Date(today)
108 d.setDate(d.getDate() + k)
109 return { date: d.getTime(), items: [] }
110 })
111 const overdue: AgendaItem[] = []
112 for (const task of tasks) {
113 if (task.done) continue
114 if (task.recurrence) {
115 const fallback = task.scheduled?.ms ?? task.due?.ms ?? toDate(task.file.properties.dateCreated)?.ms ?? task.file.ctime
116 const rule = parseRecurrence(task.recurrence, fallback)
117 if (rule) {
118 for (const day of days) {
119 if (occursOn(rule, day.date) && !task.completeInstances.includes(formatDate(day.date, 'YYYY-MM-DD'))) {
120 day.items.push({ task, kind: 'recurring', date: day.date })
121 }
122 }
123 continue
124 }
125 }
126 const due = task.due ? midnight(task.due.ms) : undefined
127 const scheduled = task.scheduled ? midnight(task.scheduled.ms) : undefined
128 if (due !== undefined && due < today) {
129 overdue.push({ task, kind: 'due', date: due })
130 continue
131 }
132 if (due === undefined && scheduled !== undefined && scheduled < today) {
133 overdue.push({ task, kind: 'scheduled', date: scheduled })
134 continue
135 }
136 for (const day of days) {
137 if (due === day.date) day.items.push({ task, kind: 'due', date: day.date })
138 else if (scheduled === day.date) day.items.push({ task, kind: 'scheduled', date: day.date })
139 }
140 }
141 const order = (a: AgendaItem, b: AgendaItem) => weightOf(b.task) - weightOf(a.task) || a.task.title.localeCompare(b.task.title)
142 overdue.sort((a, b) => a.date - b.date || order(a, b))
143 for (const day of days) day.items.sort(order)
144 return { overdue, days }
145}
146
147export function dayLabel(date: number, now: number): string {
148 const diff = daysBetween(now, date)
149 const name = formatDate(date, 'ddd D MMM')
150 if (diff === 0) return `Today · ${name}`
151 if (diff === 1) return `Tomorrow · ${name}`
152 return name
153}
154hooks/bases.ts 235 lines1// An Obsidian `.base` file: its views, and evaluating one over a set of files the way
2// Obsidian does: global filters AND the view's filters, formulas, sort, limit, groupBy.
3// Sort order follows bob-workspace's comparator (empty values last, then numbers, dates,
4// numeric-aware text), by the same author, MIT.
5
6import { Evaluator, FileV, Unsupported, compare, toDate, toText } from './expr'
7import type { Env, Val } from './expr'
8import { isRecord, parseYaml } from './yaml'
9
10export type FilterNode = string | { and: FilterNode[] } | { or: FilterNode[] } | { not: FilterNode[] }
11export type SortSpec = { property: string; direction: 'ASC' | 'DESC' }
12
13export type BaseView = {
14 type: string
15 name: string
16 filters?: FilterNode
17 groupBy?: SortSpec
18 sort: SortSpec[]
19 order: string[]
20 limit?: number
21 /** Everything else on the view (`options:` merged with top-level extras). */
22 options: Record<string, unknown>
23}
24
25export type BaseConfig = {
26 filters?: FilterNode
27 formulas: Record<string, string>
28 displayNames: Record<string, string>
29 views: BaseView[]
30}
31
32const VIEW_KEYS = new Set(['type', 'name', 'filters', 'groupBy', 'sort', 'order', 'limit', 'options'])
33
34function asFilter(raw: unknown): FilterNode | undefined {
35 if (raw === null || raw === undefined) return undefined
36 if (typeof raw === 'string') return raw
37 if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw)
38 if (isRecord(raw)) {
39 for (const key of ['and', 'or', 'not'] as const) {
40 const list = raw[key]
41 if (Array.isArray(list)) {
42 const items = list.map(asFilter).filter((f): f is FilterNode => f !== undefined)
43 return key === 'and' ? { and: items } : key === 'or' ? { or: items } : { not: items }
44 }
45 }
46 }
47 return undefined
48}
49
50function asSort(raw: unknown): SortSpec | undefined {
51 if (typeof raw === 'string') return { property: raw, direction: 'ASC' }
52 if (!isRecord(raw) || typeof raw.property !== 'string') return undefined
53 return { property: raw.property, direction: String(raw.direction ?? 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC' }
54}
55
56export function parseBase(src: string): BaseConfig {
57 const doc = parseYaml(src)
58 const root = isRecord(doc) ? doc : {}
59 const formulas: Record<string, string> = {}
60 if (isRecord(root.formulas)) {
61 for (const [k, v] of Object.entries(root.formulas)) if (v !== null && v !== undefined) formulas[k] = String(v)
62 }
63 const displayNames: Record<string, string> = {}
64 if (isRecord(root.properties)) {
65 for (const [k, v] of Object.entries(root.properties)) {
66 if (isRecord(v) && typeof v.displayName === 'string') displayNames[k] = v.displayName
67 }
68 }
69 const views: BaseView[] = []
70 const rawViews = Array.isArray(root.views) ? root.views : []
71 rawViews.forEach((raw, k) => {
72 if (!isRecord(raw)) return
73 const options: Record<string, unknown> = isRecord(raw.options) ? { ...raw.options } : {}
74 for (const [key, val] of Object.entries(raw)) if (!VIEW_KEYS.has(key)) options[key] = val
75 const view: BaseView = {
76 type: String(raw.type ?? 'table'),
77 name: String(raw.name ?? `View ${k + 1}`),
78 sort: (Array.isArray(raw.sort) ? raw.sort : []).map(asSort).filter((s): s is SortSpec => !!s),
79 order: (Array.isArray(raw.order) ? raw.order : []).map(String),
80 options,
81 }
82 const filters = asFilter(raw.filters)
83 if (filters) view.filters = filters
84 const groupBy = asSort(raw.groupBy)
85 if (groupBy) view.groupBy = groupBy
86 if (typeof raw.limit === 'number') view.limit = raw.limit
87 views.push(view)
88 })
89 if (!views.length) views.push({ type: 'table', name: 'Table', sort: [], order: [], options: {} })
90 const config: BaseConfig = { formulas, displayNames, views }
91 const filters = asFilter(root.filters)
92 if (filters) config.filters = filters
93 return config
94}
95
96/** A column's heading: the base's displayName, else the property's own name. */
97export function displayName(config: BaseConfig, prop: string): string {
98 const named = config.displayNames[prop] ?? config.displayNames[prop.replace(/^note\./, '')] ?? config.displayNames[`note.${prop}`]
99 if (named) return named
100 if (prop === 'file.name' || prop === 'file.basename') return 'Name'
101 return prop.replace(/^(note|formula|file)\./, '')
102}
103
104// ── evaluation ───────────────────────────────────────────────────────────
105
106export type Row = {
107 file: FileV
108 get: (prop: string) => Val
109}
110
111export type ViewResult = {
112 rows: Row[]
113 /** How many files passed the filters before the view's limit. */
114 matched: number
115 /** Filter parts and properties this evaluator could not read (each skipped, not guessed). */
116 warnings: string[]
117}
118
119export type EvalContext = Omit<Env, 'file' | 'formulas'>
120
121function makeRow(file: FileV, ev: Evaluator, warn: (msg: string) => void): Row {
122 const cache = new Map<string, Val>()
123 return {
124 file,
125 get(prop: string): Val {
126 if (cache.has(prop)) return cache.get(prop) ?? null
127 let v: Val = null
128 try {
129 v = ev.eval(prop)
130 } catch (err) {
131 warn(`${prop}: ${err instanceof Error ? err.message : String(err)}`)
132 }
133 cache.set(prop, v)
134 return v
135 },
136 }
137}
138
139function test(node: FilterNode, row: Row, ev: (expr: string) => boolean): boolean {
140 if (typeof node === 'string') return ev(node)
141 if ('and' in node) return node.and.every(n => test(n, row, ev))
142 if ('or' in node) return node.or.length === 0 || node.or.some(n => test(n, row, ev))
143 return !node.not.some(n => test(n, row, ev))
144}
145
146/** Bases sort order: empty values last in either direction, then numbers, dates, text. */
147export function sortCompare(a: Val, b: Val, direction: 'ASC' | 'DESC' = 'ASC'): number {
148 const empty = (v: Val) => v === null || v === '' || (Array.isArray(v) && v.length === 0)
149 if (empty(a) && empty(b)) return 0
150 if (empty(a)) return 1
151 if (empty(b)) return -1
152 let c = compare(a, b)
153 if (Number.isNaN(c)) {
154 const da = typeof a === 'string' ? toDate(a) : null
155 const db = typeof b === 'string' ? toDate(b) : null
156 c = da && db ? da.ms - db.ms : toText(a).localeCompare(toText(b), undefined, { numeric: true, sensitivity: 'base' })
157 }
158 return direction === 'DESC' ? -c : c
159}
160
161export function evaluateView(config: BaseConfig, view: BaseView, files: FileV[], ctx: EvalContext): ViewResult {
162 const warnings = new Set<string>()
163 const warn = (msg: string) => warnings.add(msg)
164 const filters = [config.filters, view.filters].filter((f): f is FilterNode => !!f)
165 const rows: Row[] = []
166 for (const file of files) {
167 const ev = new Evaluator({ ...ctx, file, formulas: config.formulas })
168 const row = makeRow(file, ev, warn)
169 const passes = filters.every(f =>
170 test(f, row, expr => {
171 try {
172 const v = ev.eval(expr)
173 return v !== null && v !== false && v !== '' && v !== 0 && !(Array.isArray(v) && v.length === 0)
174 } catch (err) {
175 // Skip what cannot be read (treated as passing) and say so in the pane.
176 warn(`filter ${expr}: ${err instanceof Unsupported || err instanceof Error ? err.message : String(err)}`)
177 return true
178 }
179 }),
180 )
181 if (passes) rows.push(row)
182 }
183 const specs = view.sort.length ? view.sort : []
184 rows.sort((a, b) => {
185 for (const spec of specs) {
186 const c = sortCompare(a.get(spec.property), b.get(spec.property), spec.direction)
187 if (c) return c
188 }
189 return a.file.basename.localeCompare(b.file.basename, undefined, { numeric: true, sensitivity: 'base' })
190 })
191 const matched = rows.length
192 const limited = view.limit && view.limit > 0 ? rows.slice(0, view.limit) : rows
193 return { rows: limited, matched, warnings: [...warnings] }
194}
195
196export type Group = {
197 key: string
198 label: string
199 value: Val
200 rows: Row[]
201}
202
203/**
204 * Rows grouped by a property. A list value puts the row in one group per item (a task in
205 * two projects shows under both); an empty value goes to "(none)". `order` may give the
206 * groups' order (TaskNotes' status order); otherwise values sort by Bases order.
207 */
208export function groupRows(rows: Row[], spec: SortSpec, order?: (keys: string[]) => string[] | undefined): Group[] {
209 const groups = new Map<string, Group>()
210 for (const row of rows) {
211 const v = row.get(spec.property)
212 const items: Val[] = Array.isArray(v) ? (v.length ? v : [null]) : [v]
213 for (const item of items) {
214 const label = toText(item) || '(none)'
215 const key = label.toLowerCase()
216 let g = groups.get(key)
217 if (!g) {
218 g = { key, label, value: item, rows: [] }
219 groups.set(key, g)
220 }
221 if (!g.rows.includes(row)) g.rows.push(row)
222 }
223 }
224 const list = [...groups.values()]
225 const custom = order?.(list.map(g => g.key))
226 if (custom) {
227 const rank = new Map(custom.map((k, i) => [k, i]))
228 list.sort((a, b) => (rank.get(a.key) ?? 1e9) - (rank.get(b.key) ?? 1e9) || a.label.localeCompare(b.label))
229 if (spec.direction === 'DESC') list.reverse()
230 return list
231 }
232 list.sort((a, b) => sortCompare(a.value, b.value, spec.direction))
233 return list
234}
235hooks/common.ts 179 lines1// Pure helpers (no `$` here: the engine only follows `$` within the hooks module itself).
2// Paths, the Obsidian URI, and the zoom/orientation helpers shared with
3// claude-diagrams-renderer-mod, so the dependency graph has the same controls.
4
5/** SvgProps.source is bounded at this many characters. */
6export const SVG_LIMIT = 131072
7
8/** Approximate glyph width of a sans-serif font, for wrapping without a layout engine. */
9export const CHAR_WIDTH = 0.56
10
11export type Target = {
12 /** Vault-relative path with forward slashes (what Obsidian's URI wants). */
13 rel: string
14 /** Resolved absolute path on this machine. */
15 real: string
16 /** Obsidian vault name (the vault folder's name); absent outside a vault. */
17 vault?: string
18}
19
20export function esc(text: string): string {
21 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
22}
23
24/** Greedy word wrap to at most `maxChars` per line; long words are cut. */
25export function wrapLines(text: string, maxChars: number): string[] {
26 const width = Math.max(4, Math.floor(maxChars))
27 const out: string[] = []
28 for (const raw of text.replace(/\r/g, '').split('\n')) {
29 let line = ''
30 for (const word of raw.split(/\s+/).filter(Boolean)) {
31 let piece = word
32 while (piece.length > width) {
33 if (line) {
34 out.push(line)
35 line = ''
36 }
37 out.push(piece.slice(0, width))
38 piece = piece.slice(width)
39 }
40 if (!line) line = piece
41 else if (line.length + 1 + piece.length <= width) line += ' ' + piece
42 else {
43 out.push(line)
44 line = piece
45 }
46 }
47 out.push(line)
48 }
49 return out
50}
51
52export function sepOf(path: string): string {
53 return path.includes('\\') ? '\\' : '/'
54}
55
56/** The folder above `path`, or undefined at a drive or filesystem root. */
57export function parentOf(path: string): string | undefined {
58 const trimmed = path.replace(/[\\/]+$/, '')
59 const cut = trimmed.lastIndexOf(sepOf(trimmed))
60 if (cut <= 0) return undefined
61 const parent = trimmed.slice(0, cut)
62 return /^[A-Za-z]:$/.test(parent) ? undefined : parent
63}
64
65export function samePath(a: string, b: string): boolean {
66 const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
67 return norm(a) === norm(b)
68}
69
70/** Whether `path` is `folder` or inside it (any separator, any case). */
71export function isInside(path: string, folder: string): boolean {
72 const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
73 const p = norm(path)
74 const f = norm(folder)
75 return p === f || p.startsWith(`${f}/`)
76}
77
78/** Strip surrounding quotes and a leading "./" from a typed argument. */
79export function cleanArg(arg: string): string {
80 return arg.trim().replace(/^["']|["']$/g, '').replace(/^\.[\\/]/, '')
81}
82
83/** Join a vault root and a vault-relative path with the root's separator. */
84export function joinPath(root: string, rel: string): string {
85 const sep = sepOf(root)
86 return `${root.replace(/[\\/]+$/, '')}${sep}${rel.replace(/^[\\/]+/, '').split(/[\\/]/).join(sep)}`
87}
88
89/** A Target for `real` inside the vault rooted at `root`. */
90export function targetIn(root: string, real: string): Target {
91 const sep = sepOf(real)
92 return {
93 rel: real.slice(root.length + 1).split(sep).join('/'),
94 real,
95 vault: root.slice(root.lastIndexOf(sep) + 1),
96 }
97}
98
99export function obsidianUri(target: Target): string | undefined {
100 if (!target.vault) return undefined
101 return `obsidian://adv-uri?vault=${encodeURIComponent(target.vault)}` +
102 `&filepath=${encodeURIComponent(target.rel)}&openmode=tab`
103}
104
105/**
106 * Ways to hand an obsidian:// URI to the OS, tried in order. A `Link` element only
107 * takes https:, so the URI goes through a process: rundll32's URL handler on Windows
108 * (no shell, so the `&` in the URI is safe), `open` on macOS, `xdg-open` elsewhere.
109 */
110export function launchers(uri: string): string[][] {
111 return [
112 ['rundll32.exe', 'url.dll,FileProtocolHandler', uri],
113 ['open', uri],
114 ['xdg-open', uri],
115 ]
116}
117
118export function errorText(err: unknown): string {
119 return err instanceof Error ? err.message : String(err)
120}
121
122// ── zoom ─────────────────────────────────────────────────────────────────
123// The pane always draws an Svg at its own width, so zoom changes which part of the drawing
124// the image shows: above 1 a slice of the width, panned left/right; below 1 a wider view.
125
126export const ZOOM_LEVELS = [0.25, 0.33, 0.5, 0.67, 0.75, 1, 1.25, 1.5, 2, 3, 4, 6, 8] as const
127
128export function stepZoom(current: number, direction: 1 | -1): number {
129 const from = current > 0 ? current : 1
130 const next = direction > 0
131 ? ZOOM_LEVELS.find(z => z > from * 1.01)
132 : [...ZOOM_LEVELS].reverse().find(z => z < from * 0.99)
133 return next ?? (direction > 0 ? ZOOM_LEVELS[ZOOM_LEVELS.length - 1] : ZOOM_LEVELS[0]) ?? 1
134}
135
136type ViewBox = { x: number; y: number; w: number; h: number }
137
138export function viewBoxOf(svg: string): ViewBox | undefined {
139 const root = /<svg\b[^>]*>/.exec(svg)?.[0]
140 if (!root) return undefined
141 const vb = /\sviewBox="\s*([-\d.]+)[\s,]+([-\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*"/.exec(root)
142 if (vb) return { x: Number(vb[1]), y: Number(vb[2]), w: Number(vb[3]), h: Number(vb[4]) }
143 const w = /\swidth="([\d.]+)(?:px)?"/.exec(root)
144 const h = /\sheight="([\d.]+)(?:px)?"/.exec(root)
145 return w && h ? { x: 0, y: 0, w: Number(w[1]), h: Number(h[1]) } : undefined
146}
147
148export function panStep(zoom: number): number {
149 return zoom > 1 ? Math.min(1, 0.5 / (zoom - 1)) : 1
150}
151
152export function zoomView(svg: string, zoom: number, pan = 0): { svg: string; from: number; to: number } {
153 const z = zoom > 0 ? zoom : 1
154 const vb = viewBoxOf(svg)
155 const root = /<svg\b[^>]*>/.exec(svg)?.[0]
156 if (!vb || !root || z === 1) return { svg, from: 0, to: 1 }
157 const vw = vb.w / z
158 const p = Math.min(1, Math.max(0, pan))
159 const x = z < 1 ? vb.x - (vw - vb.w) / 2 : vb.x + p * (vb.w - vw)
160 const stripped = root.replace(/\s(viewBox|width|height|preserveAspectRatio)="[^"]*"/g, '').replace(/\s*\/?>$/, '')
161 const rounded = (v: number) => Math.round(v * 100) / 100
162 const newRoot = `${stripped} width="${rounded(vw)}" height="${rounded(vb.h)}" viewBox="${rounded(x)} ${rounded(vb.y)} ${rounded(vw)} ${rounded(vb.h)}" preserveAspectRatio="xMinYMin meet">`
163 const from = z < 1 ? 0 : (x - vb.x) / vb.w
164 return { svg: svg.replace(root, newRoot), from, to: z < 1 ? 1 : from + vw / vb.w }
165}
166
167export function zoomLabel(zoom: number, from = 0, to = 1): string {
168 const z = zoom > 0 ? zoom : 1
169 if (z === 1) return 'fit width'
170 const span = z > 1 ? ` · showing ${Math.round(from * 100)}–${Math.round(to * 100)}%` : ''
171 return `zoom ${Math.round(z * 100)}%${span}`
172}
173
174export type Orientation = 'landscape' | 'portrait'
175
176export function asOrientation(value: unknown): Orientation | undefined {
177 return value === 'landscape' || value === 'portrait' ? value : undefined
178}
179hooks/expr.ts 879 lines1// The Obsidian Bases expression language: what `.base` filters, formulas, sort and group
2// properties are written in. A tokenizer, a Pratt parser and an evaluator over plain values.
3// Anything this does not know raises `Unsupported`, so a caller can say which part of a
4// view it skipped instead of silently showing the wrong tasks.
5
6// ── values ───────────────────────────────────────────────────────────────
7
8export class DateV {
9 constructor(readonly ms: number, readonly hasTime: boolean) {}
10}
11
12export class DurationV {
13 constructor(readonly ms: number, readonly months: number) {}
14}
15
16export class LinkV {
17 constructor(readonly target: string, readonly display?: string) {}
18}
19
20/** A vault file as Bases sees it: `file.*` plus its frontmatter as `properties`. */
21export class FileV {
22 constructor(
23 readonly path: string,
24 readonly properties: Record<string, unknown>,
25 readonly mtime: number,
26 readonly ctime: number,
27 readonly size: number,
28 readonly tags: string[],
29 readonly links: string[],
30 ) {}
31 get name(): string {
32 return this.path.slice(this.path.lastIndexOf('/') + 1)
33 }
34 get basename(): string {
35 return this.name.replace(/\.[^.]+$/, '')
36 }
37 get ext(): string {
38 const m = /\.([^.\/]+)$/.exec(this.path)
39 return m ? (m[1] ?? '') : ''
40 }
41 get folder(): string {
42 const cut = this.path.lastIndexOf('/')
43 return cut < 0 ? '' : this.path.slice(0, cut)
44 }
45}
46
47/** `this`: the file a base is shown for, with its properties reachable directly. */
48class ThisV {
49 constructor(readonly file: FileV) {}
50}
51
52export type Val = null | boolean | number | string | DateV | DurationV | LinkV | FileV | ThisV | Val[] | { [k: string]: unknown }
53
54export class Unsupported extends Error {}
55
56// ── tokens and syntax ────────────────────────────────────────────────────
57
58type Tok = { k: 'num'; v: number } | { k: 'str'; v: string } | { k: 'id'; v: string } | { k: 'op'; v: string } | { k: 'end' }
59
60const OPS = ['&&', '||', '==', '!=', '<=', '>=', '<', '>', '+', '-', '*', '/', '%', '!', '(', ')', '[', ']', ',', '.']
61
62function tokenize(src: string): Tok[] {
63 const out: Tok[] = []
64 let k = 0
65 while (k < src.length) {
66 const c = src[k] ?? ''
67 if (/\s/.test(c)) {
68 k++
69 continue
70 }
71 if (/[0-9]/.test(c) || (c === '.' && /[0-9]/.test(src[k + 1] ?? ''))) {
72 const m = /^(\d+(\.\d+)?|\.\d+)([eE][-+]?\d+)?/.exec(src.slice(k))
73 if (!m) throw new Unsupported(`bad number at ${src.slice(k, k + 10)}`)
74 out.push({ k: 'num', v: Number(m[0]) })
75 k += m[0].length
76 continue
77 }
78 if (c === '"' || c === "'") {
79 let s = ''
80 let j = k + 1
81 for (; j < src.length && src[j] !== c; j++) {
82 if (src[j] === '\\') {
83 j++
84 const e = src[j] ?? ''
85 s += ({ n: '\n', t: '\t', r: '\r' } as Record<string, string>)[e] ?? e
86 } else s += src[j]
87 }
88 if (j >= src.length) throw new Unsupported(`unclosed string in ${src}`)
89 out.push({ k: 'str', v: s })
90 k = j + 1
91 continue
92 }
93 if (/[A-Za-z_$]/.test(c)) {
94 const m = /^[A-Za-z_$][\w$]*/.exec(src.slice(k))
95 const id = m?.[0] ?? c
96 out.push({ k: 'id', v: id })
97 k += id.length
98 continue
99 }
100 const op = OPS.find(o => src.startsWith(o, k))
101 if (!op) throw new Unsupported(`unexpected "${c}" in ${src}`)
102 out.push({ k: 'op', v: op })
103 k += op.length
104 }
105 out.push({ k: 'end' })
106 return out
107}
108
109export type Node =
110 | { t: 'lit'; v: Val }
111 | { t: 'id'; name: string }
112 | { t: 'member'; obj: Node; name: string }
113 | { t: 'index'; obj: Node; idx: Node }
114 | { t: 'call'; fn: Node; args: Node[] }
115 | { t: 'un'; op: string; a: Node }
116 | { t: 'bin'; op: string; a: Node; b: Node }
117 | { t: 'list'; items: Node[] }
118
119const BINARY: Record<string, number> = { '||': 1, '&&': 2, '==': 3, '!=': 3, '<': 4, '<=': 4, '>': 4, '>=': 4, '+': 5, '-': 5, '*': 6, '/': 6, '%': 6 }
120
121class SyntaxParser {
122 private k = 0
123 constructor(private toks: Tok[], private src: string) {}
124
125 parse(): Node {
126 const node = this.expr(0)
127 if (this.peek().k !== 'end') throw new Unsupported(`unexpected ${this.show(this.peek())} in ${this.src}`)
128 return node
129 }
130
131 private peek(): Tok {
132 return this.toks[this.k] ?? { k: 'end' }
133 }
134
135 private show(t: Tok): string {
136 return t.k === 'end' ? 'end' : `"${String(t.v)}"`
137 }
138
139 private isOp(v: string): boolean {
140 const t = this.peek()
141 return t.k === 'op' && t.v === v
142 }
143
144 private expect(v: string): void {
145 if (!this.isOp(v)) throw new Unsupported(`expected "${v}" but found ${this.show(this.peek())} in ${this.src}`)
146 this.k++
147 }
148
149 private expr(minPrec: number): Node {
150 let left = this.unary()
151 for (;;) {
152 const t = this.peek()
153 if (t.k !== 'op') break
154 const prec = BINARY[t.v]
155 if (prec === undefined || prec <= minPrec) break
156 this.k++
157 const right = this.expr(prec)
158 left = { t: 'bin', op: t.v, a: left, b: right }
159 }
160 return left
161 }
162
163 private unary(): Node {
164 if (this.isOp('!') || this.isOp('-') || this.isOp('+')) {
165 const t = this.peek() as { v: string }
166 this.k++
167 return { t: 'un', op: t.v, a: this.unary() }
168 }
169 return this.postfix(this.primary())
170 }
171
172 private primary(): Node {
173 const t = this.peek()
174 this.k++
175 if (t.k === 'num' || t.k === 'str') return { t: 'lit', v: t.v }
176 if (t.k === 'id') {
177 if (t.v === 'true') return { t: 'lit', v: true }
178 if (t.v === 'false') return { t: 'lit', v: false }
179 if (t.v === 'null') return { t: 'lit', v: null }
180 return { t: 'id', name: t.v }
181 }
182 if (t.k === 'op' && t.v === '(') {
183 const inner = this.expr(0)
184 this.expect(')')
185 return inner
186 }
187 if (t.k === 'op' && t.v === '[') {
188 const items: Node[] = []
189 while (!this.isOp(']')) {
190 items.push(this.expr(0))
191 if (!this.isOp(',')) break
192 this.k++
193 }
194 this.expect(']')
195 return { t: 'list', items }
196 }
197 throw new Unsupported(`unexpected ${this.show(t)} in ${this.src}`)
198 }
199
200 private postfix(node: Node): Node {
201 for (;;) {
202 if (this.isOp('.')) {
203 this.k++
204 const t = this.peek()
205 if (t.k !== 'id') throw new Unsupported(`expected a name after "." in ${this.src}`)
206 this.k++
207 node = { t: 'member', obj: node, name: t.v }
208 } else if (this.isOp('(')) {
209 this.k++
210 const args: Node[] = []
211 while (!this.isOp(')')) {
212 args.push(this.expr(0))
213 if (!this.isOp(',')) break
214 this.k++
215 }
216 this.expect(')')
217 node = { t: 'call', fn: node, args }
218 } else if (this.isOp('[')) {
219 this.k++
220 const idx = this.expr(0)
221 this.expect(']')
222 node = { t: 'index', obj: node, idx }
223 } else return node
224 }
225 }
226}
227
228const parsed = new Map<string, Node>()
229
230/** Parse an expression; results are kept, since every row evaluates the same few. */
231export function parseExpr(src: string): Node {
232 let node = parsed.get(src)
233 if (!node) {
234 node = new SyntaxParser(tokenize(src), src).parse()
235 parsed.set(src, node)
236 }
237 return node
238}
239
240// ── conversions ──────────────────────────────────────────────────────────
241
242const WIKILINK = /^\[\[([^\]|#]*)(?:#[^\]|]*)?(?:\|([^\]]*))?\]\]$/
243
244/** A raw frontmatter value as a Bases value: wikilink strings become links. */
245export function wrap(raw: unknown): Val {
246 if (raw === undefined || raw === null) return null
247 if (typeof raw === 'string') {
248 const m = WIKILINK.exec(raw.trim())
249 return m ? new LinkV((m[1] ?? '').trim(), m[2]?.trim()) : raw
250 }
251 if (Array.isArray(raw)) return raw.map(wrap)
252 if (typeof raw === 'number' || typeof raw === 'boolean') return raw
253 if (raw instanceof DateV || raw instanceof LinkV || raw instanceof FileV || raw instanceof DurationV || raw instanceof ThisV) return raw
254 if (typeof raw === 'object') return raw as { [k: string]: unknown }
255 return String(raw)
256}
257
258/** A link's identity: its note name, without folders, `.md`, heading or alias, lowercased. */
259export function linkKey(v: unknown): string {
260 if (v instanceof FileV) return v.basename.toLowerCase()
261 if (v instanceof LinkV) return linkKey(v.target)
262 if (v instanceof ThisV) return v.file.basename.toLowerCase()
263 if (typeof v !== 'string') return ''
264 const m = WIKILINK.exec(v.trim())
265 const target = m ? (m[1] ?? '') : v
266 return target.trim().replace(/\\/g, '/').replace(/^.*\//, '').replace(/\.md$/i, '').toLowerCase()
267}
268
269const DATE_RE = /^(\d{4})-?(\d{2})-?(\d{2})(?:[T ](\d{1,2}):(\d{2})(?::(\d{2})(?:[.,](\d+))?)?\s*(Z|[+-]\d{2}:?\d{2})?)?$/
270
271export function toDate(v: unknown): DateV | null {
272 if (v instanceof DateV) return v
273 if (typeof v === 'number' && Number.isFinite(v)) return new DateV(v, true)
274 if (typeof v !== 'string') return null
275 const m = DATE_RE.exec(v.trim())
276 if (!m) return null
277 const [y, mo, d] = [Number(m[1]), Number(m[2]) - 1, Number(m[3])]
278 if (m[4] === undefined) return new DateV(new Date(y, mo, d).getTime(), false)
279 const h = Number(m[4])
280 const mi = Number(m[5])
281 const s = Number(m[6] ?? 0)
282 const msPart = m[7] ? Number(`0.${m[7]}`) * 1000 : 0
283 const tz = m[8]
284 if (!tz) return new DateV(new Date(y, mo, d, h, mi, s, msPart).getTime(), true)
285 let offset = 0
286 if (tz !== 'Z') {
287 const sign = tz.startsWith('-') ? -1 : 1
288 const digits = tz.slice(1).replace(':', '')
289 offset = sign * (Number(digits.slice(0, 2)) * 60 + Number(digits.slice(2, 4)))
290 }
291 return new DateV(Date.UTC(y, mo, d, h, mi, s, msPart) - offset * 60000, true)
292}
293
294const UNIT_MS: Record<string, number> = { ms: 1, s: 1000, m: 60000, h: 3600000, d: 86400000, w: 604800000 }
295
296function unitOf(word: string): { ms?: number; months?: number } | undefined {
297 if (word === 'M' || /^mon(th)?s?$/i.test(word) || /^mo$/i.test(word)) return { months: 1 }
298 if (/^(y|yrs?|years?)$/i.test(word)) return { months: 12 }
299 const w = word.toLowerCase()
300 if (/^(ms|millis(econds?)?)$/.test(w)) return { ms: UNIT_MS.ms }
301 if (/^(s|secs?|seconds?)$/.test(w)) return { ms: UNIT_MS.s }
302 if (word === 'm' || /^(mins?|minutes?)$/.test(w)) return { ms: UNIT_MS.m }
303 if (/^(h|hrs?|hours?)$/.test(w)) return { ms: UNIT_MS.h }
304 if (/^(d|days?)$/.test(w)) return { ms: UNIT_MS.d }
305 if (/^(w|wks?|weeks?)$/.test(w)) return { ms: UNIT_MS.w }
306 return undefined
307}
308
309/** "7d", "7 days", "1 week 2 days", "-1d". */
310export function toDuration(v: unknown): DurationV | null {
311 if (v instanceof DurationV) return v
312 if (typeof v !== 'string') return null
313 const re = /(-?\d+(?:\.\d+)?)\s*([A-Za-z]+)/g
314 let ms = 0
315 let months = 0
316 let matched = ''
317 for (let m = re.exec(v); m; m = re.exec(v)) {
318 const unit = unitOf(m[2] ?? '')
319 if (!unit) return null
320 const n = Number(m[1])
321 ms += n * (unit.ms ?? 0)
322 months += n * (unit.months ?? 0)
323 matched += m[0]
324 }
325 return matched.replace(/\s/g, '') === v.replace(/\s/g, '') && matched ? new DurationV(ms, months) : null
326}
327
328function shift(d: DateV, dur: DurationV, sign: 1 | -1): DateV {
329 const date = new Date(d.ms)
330 if (dur.months) date.setMonth(date.getMonth() + sign * dur.months)
331 const ms = date.getTime() + sign * dur.ms
332 // Whole days keep a date-only value date-only (and across a DST change, at midnight).
333 if (!d.hasTime && dur.ms % 86400000 === 0) {
334 const days = new Date(date.getTime())
335 days.setDate(days.getDate() + (sign * dur.ms) / 86400000)
336 return new DateV(days.getTime(), false)
337 }
338 return new DateV(ms, d.hasTime || dur.ms % 86400000 !== 0)
339}
340
341export function truthy(v: Val | undefined): boolean {
342 if (v === null || v === undefined || v === false || v === '' || v === 0) return false
343 if (typeof v === 'number') return !Number.isNaN(v)
344 if (Array.isArray(v)) return v.length > 0
345 return true
346}
347
348function typeOf(v: Val): string {
349 if (v === null) return 'null'
350 if (typeof v === 'boolean') return 'boolean'
351 if (typeof v === 'number') return 'number'
352 if (typeof v === 'string') return 'string'
353 if (v instanceof DateV) return 'date'
354 if (v instanceof DurationV) return 'duration'
355 if (v instanceof LinkV) return 'link'
356 if (v instanceof FileV || v instanceof ThisV) return 'file'
357 if (Array.isArray(v)) return 'list'
358 return 'object'
359}
360
361const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
362const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
363
364function isoWeek(d: Date): { week: number; year: number } {
365 const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
366 const day = t.getUTCDay() || 7
367 t.setUTCDate(t.getUTCDate() + 4 - day)
368 const yearStart = Date.UTC(t.getUTCFullYear(), 0, 1)
369 return { week: Math.ceil(((t.getTime() - yearStart) / 86400000 + 1) / 7), year: t.getUTCFullYear() }
370}
371
372const pad = (n: number, w = 2) => String(n).padStart(w, '0')
373
374/** Moment-style format tokens, the ones Bases formulas use. */
375export function formatDate(ms: number, fmt: string): string {
376 const d = new Date(ms)
377 return fmt.replace(/\[([^\]]*)\]|YYYY|GGGG|YY|MMMM|MMM|MM|M|Do|DD|D|dddd|ddd|dd|d|HH|H|hh|h|mm|m|ss|s|A|a|WW|W/g, (tok, lit: string | undefined) => {
378 if (lit !== undefined) return lit
379 switch (tok) {
380 case 'YYYY': return String(d.getFullYear())
381 case 'GGGG': return String(isoWeek(d).year)
382 case 'YY': return pad(d.getFullYear() % 100)
383 case 'MMMM': return MONTHS[d.getMonth()] ?? ''
384 case 'MMM': return (MONTHS[d.getMonth()] ?? '').slice(0, 3)
385 case 'MM': return pad(d.getMonth() + 1)
386 case 'M': return String(d.getMonth() + 1)
387 case 'Do': {
388 const n = d.getDate()
389 const sfx = n % 10 === 1 && n !== 11 ? 'st' : n % 10 === 2 && n !== 12 ? 'nd' : n % 10 === 3 && n !== 13 ? 'rd' : 'th'
390 return `${n}${sfx}`
391 }
392 case 'DD': return pad(d.getDate())
393 case 'D': return String(d.getDate())
394 case 'dddd': return DAYS[d.getDay()] ?? ''
395 case 'ddd': return (DAYS[d.getDay()] ?? '').slice(0, 3)
396 case 'dd': return (DAYS[d.getDay()] ?? '').slice(0, 2)
397 case 'd': return String(d.getDay())
398 case 'HH': return pad(d.getHours())
399 case 'H': return String(d.getHours())
400 case 'hh': return pad(d.getHours() % 12 || 12)
401 case 'h': return String(d.getHours() % 12 || 12)
402 case 'mm': return pad(d.getMinutes())
403 case 'm': return String(d.getMinutes())
404 case 'ss': return pad(d.getSeconds())
405 case 's': return String(d.getSeconds())
406 case 'A': return d.getHours() < 12 ? 'AM' : 'PM'
407 case 'a': return d.getHours() < 12 ? 'am' : 'pm'
408 case 'WW': return pad(isoWeek(d).week)
409 case 'W': return String(isoWeek(d).week)
410 default: return tok
411 }
412 })
413}
414
415/** How a value reads in a cell. */
416export function toText(v: Val | undefined): string {
417 if (v === null || v === undefined) return ''
418 if (typeof v === 'string') return v
419 if (typeof v === 'number') return Number.isInteger(v) ? String(v) : String(Math.round(v * 100) / 100)
420 if (typeof v === 'boolean') return v ? 'true' : 'false'
421 if (v instanceof DateV) return formatDate(v.ms, v.hasTime ? 'YYYY-MM-DD HH:mm' : 'YYYY-MM-DD')
422 if (v instanceof DurationV) return `${Math.round(v.ms / 86400000)}d`
423 if (v instanceof LinkV) return v.display || v.target
424 if (v instanceof FileV) return v.basename
425 if (v instanceof ThisV) return v.file.basename
426 if (Array.isArray(v)) return v.map(toText).filter(Boolean).join(', ')
427 try {
428 return JSON.stringify(v)
429 } catch {
430 return String(v)
431 }
432}
433
434export function equals(a: Val, b: Val): boolean {
435 if (a === null || b === null) return a === b || (a === null && b === '') || (b === null && a === '')
436 if (a instanceof LinkV || b instanceof LinkV || a instanceof FileV || b instanceof FileV) {
437 const ka = linkKey(a)
438 return ka !== '' && ka === linkKey(b)
439 }
440 if (a instanceof DateV || b instanceof DateV) {
441 const da = toDate(a)
442 const db = toDate(b)
443 return !!da && !!db && da.ms === db.ms
444 }
445 if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((x, k) => equals(x, b[k] ?? null))
446 if (typeof a === 'number' && typeof b === 'string') return String(a) === b
447 if (typeof b === 'number' && typeof a === 'string') return String(b) === a
448 return a === b
449}
450
451/** <0, 0, >0, or NaN when the two cannot be ordered (so every comparison is false). */
452export function compare(a: Val, b: Val): number {
453 if (a === null || b === null) return NaN
454 if (a instanceof DateV || b instanceof DateV) {
455 const da = toDate(a)
456 const db = toDate(b)
457 return da && db ? da.ms - db.ms : NaN
458 }
459 if (typeof a === 'number' && typeof b === 'number') return a - b
460 if (typeof a === 'boolean' && typeof b === 'boolean') return Number(a) - Number(b)
461 if (typeof a === 'number' && typeof b === 'string' && b.trim() !== '' && !Number.isNaN(Number(b))) return a - Number(b)
462 if (typeof b === 'number' && typeof a === 'string' && a.trim() !== '' && !Number.isNaN(Number(a))) return Number(a) - b
463 return toText(a).localeCompare(toText(b), undefined, { numeric: true, sensitivity: 'base' })
464}
465
466function containsValue(list: Val[], needle: Val): boolean {
467 return list.some(item => {
468 if (equals(item, needle)) return true
469 // Tags compare without their '#'; like Obsidian, case counts.
470 return typeof item === 'string' && typeof needle === 'string' && item.replace(/^#/, '') === needle.replace(/^#/, '')
471 })
472}
473
474// ── evaluation ───────────────────────────────────────────────────────────
475
476export type Env = {
477 file: FileV
478 /** The file the base is shown for (`this`); the base file itself when opened directly. */
479 thisFile?: FileV
480 formulas: Record<string, string>
481 /** A link or path to a vault file, as `file()` resolves it. */
482 resolve: (target: string) => FileV | undefined
483 /** Milliseconds; the clock `today()` and `now()` read. */
484 now: number
485}
486
487type Locals = { value?: Val; index?: Val; acc?: Val }
488
489/** Evaluates expressions for one file, keeping its formula values. */
490export class Evaluator {
491 private formulaValues = new Map<string, Val>()
492 private computing = new Set<string>()
493
494 constructor(readonly env: Env) {}
495
496 eval(src: string): Val {
497 return this.run(parseExpr(src), {})
498 }
499
500 formula(name: string): Val {
501 if (this.formulaValues.has(name)) return this.formulaValues.get(name) ?? null
502 const src = this.env.formulas[name]
503 if (src === undefined) return null
504 if (this.computing.has(name)) throw new Unsupported(`formula ${name} refers to itself`)
505 this.computing.add(name)
506 try {
507 const v = this.run(parseExpr(String(src)), {})
508 this.formulaValues.set(name, v)
509 return v
510 } finally {
511 this.computing.delete(name)
512 }
513 }
514
515 private today(): DateV {
516 const d = new Date(this.env.now)
517 return new DateV(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(), false)
518 }
519
520 private run(n: Node, loc: Locals): Val {
521 switch (n.t) {
522 case 'lit':
523 return n.v
524 case 'list':
525 return n.items.map(item => this.run(item, loc))
526 case 'id':
527 return this.ident(n.name, loc)
528 case 'member':
529 return this.member(n.obj, n.name, loc)
530 case 'index': {
531 const obj = this.run(n.obj, loc)
532 const idx = this.run(n.idx, loc)
533 if (Array.isArray(obj) && typeof idx === 'number') return obj[idx < 0 ? obj.length + idx : idx] ?? null
534 if (typeof obj === 'string' && typeof idx === 'number') return obj[idx] ?? null
535 return this.prop(obj, toText(idx))
536 }
537 case 'un': {
538 const a = this.run(n.a, loc)
539 if (n.op === '!') return !truthy(a)
540 const num = typeof a === 'number' ? a : Number(a)
541 if (a === null || Number.isNaN(num)) return null
542 return n.op === '-' ? -num : num
543 }
544 case 'bin':
545 return this.binary(n.op, n.a, n.b, loc)
546 case 'call':
547 return this.call(n, loc)
548 }
549 }
550
551 private ident(name: string, loc: Locals): Val {
552 if (name === 'value' && 'value' in loc) return loc.value ?? null
553 if (name === 'index' && 'index' in loc) return loc.index ?? null
554 if (name === 'acc' && 'acc' in loc) return loc.acc ?? null
555 if (name === 'file') return this.env.file
556 if (name === 'note') return this.env.file.properties as { [k: string]: unknown }
557 if (name === 'this') return this.env.thisFile ? new ThisV(this.env.thisFile) : null
558 if (name === 'formula') throw new Unsupported('"formula" needs a name: formula.<name>')
559 return wrap(this.env.file.properties[name])
560 }
561
562 private member(objNode: Node, name: string, loc: Locals): Val {
563 if (objNode.t === 'id' && objNode.name === 'formula') return this.formula(name)
564 return this.prop(this.run(objNode, loc), name)
565 }
566
567 private prop(obj: Val, name: string): Val {
568 if (obj === null) return null
569 if (obj instanceof ThisV) {
570 if (name === 'file') return obj.file
571 if (name === 'note') return obj.file.properties as { [k: string]: unknown }
572 return wrap(obj.file.properties[name])
573 }
574 if (obj instanceof LinkV) {
575 const f = this.env.resolve(obj.target)
576 return f ? this.prop(f, name) : null
577 }
578 if (obj instanceof FileV) {
579 switch (name) {
580 case 'name': return obj.name
581 case 'basename': return obj.basename
582 case 'path': return obj.path
583 case 'folder': return obj.folder
584 case 'ext': return obj.ext
585 case 'size': return obj.size
586 case 'mtime': return new DateV(obj.mtime, true)
587 case 'ctime': return new DateV(obj.ctime, true)
588 case 'tags': return [...obj.tags]
589 case 'links': return obj.links.map(l => new LinkV(l))
590 case 'properties': return obj.properties as { [k: string]: unknown }
591 case 'file': return obj
592 default: throw new Unsupported(`file.${name} is not supported`)
593 }
594 }
595 if (obj instanceof DateV) {
596 const d = new Date(obj.ms)
597 switch (name) {
598 case 'year': return d.getFullYear()
599 case 'month': return d.getMonth() + 1
600 case 'day': return d.getDate()
601 case 'hour': return d.getHours()
602 case 'minute': return d.getMinutes()
603 case 'second': return d.getSeconds()
604 case 'millisecond': return d.getMilliseconds()
605 default: throw new Unsupported(`date.${name} is not supported`)
606 }
607 }
608 if (typeof obj === 'string' || Array.isArray(obj)) {
609 if (name === 'length') return obj.length
610 return null
611 }
612 if (typeof obj === 'object' && !(obj instanceof DurationV)) return wrap((obj as Record<string, unknown>)[name])
613 return null
614 }
615
616 private binary(op: string, an: Node, bn: Node, loc: Locals): Val {
617 if (op === '&&') return truthy(this.run(an, loc)) && truthy(this.run(bn, loc))
618 if (op === '||') return truthy(this.run(an, loc)) || truthy(this.run(bn, loc))
619 const a = this.run(an, loc)
620 const b = this.run(bn, loc)
621 switch (op) {
622 case '==': return equals(a, b)
623 case '!=': return !equals(a, b)
624 case '<': return compare(a, b) < 0
625 case '<=': return compare(a, b) <= 0
626 case '>': return compare(a, b) > 0
627 case '>=': return compare(a, b) >= 0
628 }
629 if (a instanceof DateV && (op === '+' || op === '-')) {
630 const dur = toDuration(b)
631 if (dur) return shift(a, dur, op === '+' ? 1 : -1)
632 const other = toDate(b)
633 if (op === '-' && other) return a.ms - other.ms
634 }
635 if (op === '+') {
636 if (typeof a === 'number' && typeof b === 'number') return a + b
637 if (typeof a === 'string' || typeof b === 'string') return toText(a) + toText(b)
638 if (Array.isArray(a) && Array.isArray(b)) return [...a, ...b]
639 if (a === null || b === null) return null
640 }
641 const x = typeof a === 'number' ? a : a === null ? NaN : Number(a)
642 const y = typeof b === 'number' ? b : b === null ? NaN : Number(b)
643 if (Number.isNaN(x) || Number.isNaN(y)) return null
644 switch (op) {
645 case '+': return x + y
646 case '-': return x - y
647 case '*': return x * y
648 case '/': return y === 0 ? null : x / y
649 case '%': return y === 0 ? null : x % y
650 }
651 throw new Unsupported(`operator ${op}`)
652 }
653
654 private call(n: { fn: Node; args: Node[] }, loc: Locals): Val {
655 if (n.fn.t === 'id') return this.global(n.fn.name, n.args, loc)
656 if (n.fn.t === 'member') {
657 const target = this.run(n.fn.obj, loc)
658 return this.method(target, n.fn.name, n.args, loc)
659 }
660 throw new Unsupported('only functions and methods can be called')
661 }
662
663 private global(name: string, args: Node[], loc: Locals): Val {
664 const arg = (k: number) => (args[k] ? this.run(args[k] as Node, loc) : null)
665 switch (name) {
666 case 'if':
667 return truthy(arg(0)) ? arg(1) : args.length > 2 ? arg(2) : null
668 case 'today':
669 return this.today()
670 case 'now':
671 return new DateV(this.env.now, true)
672 case 'date':
673 return toDate(arg(0))
674 case 'duration':
675 return toDuration(arg(0))
676 case 'number': {
677 const v = arg(0)
678 if (v === null) return null
679 if (v instanceof DateV) return v.ms
680 if (v instanceof DurationV) return v.ms
681 if (typeof v === 'boolean') return v ? 1 : 0
682 if (typeof v === 'number') return v
683 const num = Number(toText(v))
684 return Number.isNaN(num) ? null : num
685 }
686 case 'string':
687 return toText(arg(0))
688 case 'list': {
689 const v = arg(0)
690 return v === null ? [] : Array.isArray(v) ? v : [v]
691 }
692 case 'file': {
693 const v = arg(0)
694 if (v instanceof FileV) return v
695 if (v instanceof ThisV) return v.file
696 if (v === null) return null
697 return this.env.resolve(v instanceof LinkV ? v.target : toText(v)) ?? null
698 }
699 case 'link': {
700 const v = arg(0)
701 const display = args.length > 1 ? toText(arg(1)) : undefined
702 if (v instanceof FileV) return new LinkV(v.basename, display)
703 return v === null ? null : new LinkV(v instanceof LinkV ? v.target : toText(v), display)
704 }
705 case 'max':
706 case 'min': {
707 const nums = args.map(a => this.run(a, loc)).flat().filter((v): v is number => typeof v === 'number' && !Number.isNaN(v))
708 if (!nums.length) return null
709 return name === 'max' ? Math.max(...nums) : Math.min(...nums)
710 }
711 default:
712 throw new Unsupported(`function ${name}() is not supported`)
713 }
714 }
715
716 private method(target: Val, name: string, args: Node[], loc: Locals): Val {
717 const arg = (k: number) => (args[k] ? this.run(args[k] as Node, loc) : null)
718 const all = () => args.map(a => this.run(a, loc))
719
720 // Every type
721 switch (name) {
722 case 'isEmpty':
723 if (target === null || target === '') return true
724 if (Array.isArray(target)) return target.length === 0
725 if (typeof target === 'object' && !(target instanceof DateV || target instanceof LinkV || target instanceof FileV || target instanceof ThisV || target instanceof DurationV)) {
726 return Object.keys(target).length === 0
727 }
728 return false
729 case 'isType':
730 return typeOf(target) === toText(arg(0)).toLowerCase()
731 case 'isTruthy':
732 return truthy(target)
733 case 'toString':
734 return toText(target)
735 }
736 if (target === null) return null
737
738 if (target instanceof ThisV) return this.method(target.file, name, args, loc)
739 if (target instanceof LinkV) {
740 if (name === 'asFile') return this.env.resolve(target.target) ?? null
741 if (name === 'linksTo') return equals(target, arg(0))
742 const f = this.env.resolve(target.target)
743 return f ? this.method(f, name, args, loc) : null
744 }
745
746 if (target instanceof FileV) {
747 switch (name) {
748 case 'hasTag': {
749 const own = target.tags.map(t => t.replace(/^#/, '').toLowerCase())
750 return all().some(tag => {
751 const want = toText(tag).replace(/^#/, '').toLowerCase()
752 return own.some(t => t === want || t.startsWith(`${want}/`))
753 })
754 }
755 case 'inFolder': {
756 const folder = toText(arg(0)).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '').toLowerCase()
757 const path = target.path.toLowerCase()
758 return folder === '' || path.startsWith(`${folder}/`)
759 }
760 case 'hasLink': {
761 const key = linkKey(arg(0))
762 return target.links.some(l => linkKey(l) === key)
763 }
764 case 'hasProperty':
765 return Object.prototype.hasOwnProperty.call(target.properties, toText(arg(0)))
766 case 'asLink':
767 return new LinkV(target.basename, args.length ? toText(arg(0)) : undefined)
768 case 'linksTo':
769 return target.links.some(l => linkKey(l) === linkKey(arg(0)))
770 default:
771 throw new Unsupported(`file.${name}() is not supported`)
772 }
773 }
774
775 if (target instanceof DateV) {
776 switch (name) {
777 case 'date': {
778 const d = new Date(target.ms)
779 return new DateV(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(), false)
780 }
781 case 'format':
782 return formatDate(target.ms, args.length ? toText(arg(0)) : 'YYYY-MM-DD')
783 case 'time':
784 return formatDate(target.ms, 'HH:mm:ss')
785 case 'relative': {
786 const days = Math.round((target.ms - this.today().ms) / 86400000)
787 if (days === 0) return 'today'
788 return days > 0 ? `in ${days} day${days === 1 ? '' : 's'}` : `${-days} day${days === -1 ? '' : 's'} ago`
789 }
790 default:
791 throw new Unsupported(`date.${name}() is not supported`)
792 }
793 }
794
795 if (typeof target === 'number') {
796 switch (name) {
797 case 'floor': return Math.floor(target)
798 case 'ceil': return Math.ceil(target)
799 case 'abs': return Math.abs(target)
800 case 'round': {
801 const digits = args.length ? Number(arg(0)) : 0
802 const f = 10 ** (Number.isFinite(digits) ? digits : 0)
803 return Math.round(target * f) / f
804 }
805 case 'toFixed': return target.toFixed(Number(arg(0) ?? 0))
806 default: throw new Unsupported(`number.${name}() is not supported`)
807 }
808 }
809
810 if (typeof target === 'string') {
811 const s = target
812 switch (name) {
813 case 'contains': return s.toLowerCase().includes(toText(arg(0)).toLowerCase())
814 case 'containsAll': return all().every(x => s.toLowerCase().includes(toText(x).toLowerCase()))
815 case 'containsAny': return all().some(x => s.toLowerCase().includes(toText(x).toLowerCase()))
816 case 'startsWith': return s.startsWith(toText(arg(0)))
817 case 'endsWith': return s.endsWith(toText(arg(0)))
818 case 'lower': return s.toLowerCase()
819 case 'upper': return s.toUpperCase()
820 case 'title': return s.replace(/\b\w/g, c => c.toUpperCase())
821 case 'trim': return s.trim()
822 case 'reverse': return Array.from(s).reverse().join('')
823 case 'repeat': return s.repeat(Math.max(0, Number(arg(0)) || 0))
824 case 'slice': return s.slice(Number(arg(0)) || 0, args.length > 1 ? Number(arg(1)) : undefined)
825 case 'split': return s.split(toText(arg(0)))
826 case 'replace': return s.split(toText(arg(0))).join(toText(arg(1)))
827 default: throw new Unsupported(`string.${name}() is not supported`)
828 }
829 }
830
831 if (Array.isArray(target)) {
832 const lambda = (body: Node | undefined, item: Val, index: number, acc?: Val): Val => {
833 if (!body) throw new Unsupported(`${name}() needs an expression`)
834 const locals: Locals = { value: item, index }
835 if (acc !== undefined) locals.acc = acc
836 return this.run(body, locals)
837 }
838 switch (name) {
839 case 'contains': return containsValue(target, arg(0))
840 case 'hasTag': {
841 // A tags list asked like file.hasTag: any of the tags, nested tags included.
842 const own = target.map(t => toText(t).replace(/^#/, '').toLowerCase())
843 return all().some(tag => {
844 const want = toText(tag).replace(/^#/, '').toLowerCase()
845 return own.some(t => t === want || t.startsWith(`${want}/`))
846 })
847 }
848 case 'containsAll': return all().every(x => containsValue(target, x))
849 case 'containsAny': return all().some(x => containsValue(target, x))
850 case 'filter': return target.filter((item, k) => truthy(lambda(args[0], item, k)))
851 case 'map': return target.map((item, k) => lambda(args[0], item, k))
852 case 'reduce': {
853 let acc: Val = args.length > 1 ? this.run(args[1] as Node, loc) : null
854 target.forEach((item, k) => {
855 acc = lambda(args[0], item, k, acc)
856 })
857 return acc
858 }
859 case 'any': return target.some((item, k) => truthy(lambda(args[0], item, k)))
860 case 'all': return target.every((item, k) => truthy(lambda(args[0], item, k)))
861 case 'join': return target.map(toText).join(args.length ? toText(arg(0)) : ', ')
862 case 'sort': return [...target].sort((a, b) => compare(a, b) || 0)
863 case 'reverse': return [...target].reverse()
864 case 'unique': return target.filter((item, k) => target.findIndex(o => equals(o, item)) === k)
865 case 'flat': return target.flat()
866 case 'slice': return target.slice(Number(arg(0)) || 0, args.length > 1 ? Number(arg(1)) : undefined)
867 default: throw new Unsupported(`list.${name}() is not supported`)
868 }
869 }
870
871 if (typeof target === 'object' && !(target instanceof DurationV)) {
872 const obj = target as Record<string, unknown>
873 if (name === 'keys') return Object.keys(obj)
874 if (name === 'values') return Object.values(obj).map(wrap)
875 }
876 throw new Unsupported(`${typeOf(target)}.${name}() is not supported`)
877 }
878}
879hooks/graph.ts 290 lines1// The dependency graph: blockedBy arrows between tasks, laid out in layers (a task sits one
2// layer after its latest blocker), with the critical path, the longest chain of unfinished
3// work by duration, drawn in red. Landscape runs left to right, portrait top to bottom.
4
5import { CHAR_WIDTH, esc, wrapLines } from './common'
6import type { Orientation } from './common'
7import { durationDays, findTask, taskIndex } from './tasknotes'
8import type { Task, TaskNotesSettings } from './tasknotes'
9import { linkKey } from './expr'
10
11export type GraphNode = {
12 key: string
13 title: string
14 task?: Task
15 /** In the view (false: a blocker or dependent outside it, drawn dashed as context). */
16 inView: boolean
17 /** A blockedBy link that names no task. */
18 missing: boolean
19 level: number
20 order: number
21 critical: boolean
22 cycle: boolean
23 days: number
24}
25
26export type GraphEdge = { from: string; to: string; critical: boolean }
27
28export type Graph = {
29 nodes: GraphNode[]
30 edges: GraphEdge[]
31 /** View tasks with no blockedBy relation, left out of the drawing. */
32 isolated: number
33 /** Days of unfinished work on the critical path (0 when there is no chain). */
34 criticalDays: number
35 cycles: number
36}
37
38export type GraphOptions = {
39 /** Leave finished tasks out altogether (their arrows go with them). */
40 hideDone?: boolean
41}
42
43export function buildGraph(allViewTasks: Task[], allTasks: Task[], opts: GraphOptions = {}): Graph {
44 const index = taskIndex(allTasks)
45 const shown = (t: Task | undefined) => !(opts.hideDone && t?.done)
46 const viewTasks = allViewTasks.filter(shown)
47 const nodes = new Map<string, GraphNode>()
48 const edges: GraphEdge[] = []
49 const viewKeys = new Set(viewTasks.map(t => linkKey(t.file)))
50 const node = (key: string, task: Task | undefined, title: string): GraphNode => {
51 let n = nodes.get(key)
52 if (!n) {
53 n = { key, title, inView: viewKeys.has(key), missing: !task, level: 0, order: 0, critical: false, cycle: false, days: task ? durationDays(task) : 0 }
54 if (task) n.task = task
55 nodes.set(key, n)
56 }
57 return n
58 }
59 const addEdge = (from: string, to: string) => {
60 if (from !== to && !edges.some(e => e.from === from && e.to === to)) edges.push({ from, to, critical: false })
61 }
62 for (const task of viewTasks) {
63 const key = linkKey(task.file)
64 for (const target of task.blockedBy) {
65 const blocker = findTask(index, target)
66 if (!shown(blocker)) continue
67 const bkey = blocker ? linkKey(blocker.file) : `missing:${linkKey(target)}`
68 node(key, task, task.title)
69 node(bkey, blocker, blocker ? blocker.title : target)
70 addEdge(bkey, key)
71 }
72 }
73 // Dependents outside the view, one hop, as context.
74 for (const task of allTasks) {
75 const key = linkKey(task.file)
76 if (viewKeys.has(key) || !shown(task)) continue
77 for (const target of task.blockedBy) {
78 const blocker = findTask(index, target)
79 if (blocker && viewKeys.has(linkKey(blocker.file))) {
80 node(linkKey(blocker.file), blocker, blocker.title)
81 node(key, task, task.title)
82 addEdge(linkKey(blocker.file), key)
83 }
84 }
85 }
86
87 // Layers: Kahn's order; what is left is in a cycle.
88 const preds = new Map<string, string[]>()
89 const succs = new Map<string, string[]>()
90 for (const n of nodes.keys()) {
91 preds.set(n, [])
92 succs.set(n, [])
93 }
94 for (const e of edges) {
95 preds.get(e.to)?.push(e.from)
96 succs.get(e.from)?.push(e.to)
97 }
98 const indeg = new Map([...nodes.keys()].map(k => [k, preds.get(k)?.length ?? 0]))
99 const queue = [...nodes.keys()].filter(k => indeg.get(k) === 0)
100 const topo: string[] = []
101 while (queue.length) {
102 const k = queue.shift() as string
103 topo.push(k)
104 for (const s of succs.get(k) ?? []) {
105 const d = (indeg.get(s) ?? 0) - 1
106 indeg.set(s, d)
107 if (d === 0) queue.push(s)
108 }
109 }
110 for (const k of topo) {
111 const n = nodes.get(k) as GraphNode
112 n.level = Math.max(0, ...(preds.get(k) ?? []).map(p => (nodes.get(p)?.level ?? 0) + 1))
113 }
114 const inCycle = [...nodes.keys()].filter(k => !topo.includes(k))
115 const maxLevel = Math.max(0, ...[...nodes.values()].map(n => n.level))
116 for (const k of inCycle) {
117 const n = nodes.get(k) as GraphNode
118 n.cycle = true
119 n.level = maxLevel + 1
120 }
121
122 // Critical path over unfinished work.
123 const dist = new Map<string, number>()
124 const via = new Map<string, string>()
125 for (const k of topo) {
126 const n = nodes.get(k) as GraphNode
127 const own = n.task && !n.task.done ? n.days : 0
128 let best = 0
129 for (const p of preds.get(k) ?? []) {
130 const d = dist.get(p) ?? 0
131 if (d > best) {
132 best = d
133 via.set(k, p)
134 }
135 }
136 dist.set(k, best + own)
137 }
138 let end: string | undefined
139 for (const [k, d] of dist) if (end === undefined || d > (dist.get(end) ?? 0)) end = k
140 let criticalDays = 0
141 if (end && via.has(end)) {
142 criticalDays = dist.get(end) ?? 0
143 for (let k: string | undefined = end; k; k = via.get(k)) {
144 const n = nodes.get(k) as GraphNode
145 if (n.task?.done) break
146 n.critical = true
147 const p = via.get(k)
148 const e = p ? edges.find(x => x.from === p && x.to === k) : undefined
149 if (e && !nodes.get(p as string)?.task?.done) e.critical = true
150 }
151 }
152
153 // Order within layers: barycentre sweeps to cut crossings.
154 const levels = new Map<number, GraphNode[]>()
155 for (const n of nodes.values()) levels.set(n.level, [...(levels.get(n.level) ?? []), n])
156 for (const list of levels.values()) list.sort((a, b) => a.title.localeCompare(b.title)).forEach((n, i) => (n.order = i))
157 const sorted = [...levels.keys()].sort((a, b) => a - b)
158 for (let sweep = 0; sweep < 4; sweep++) {
159 const pass = sweep % 2 === 0 ? sorted : [...sorted].reverse()
160 for (const lv of pass) {
161 const list = levels.get(lv) ?? []
162 const centre = (n: GraphNode) => {
163 const near = (sweep % 2 === 0 ? preds : succs).get(n.key) ?? []
164 if (!near.length) return n.order
165 return near.reduce((s, k) => s + (nodes.get(k)?.order ?? 0), 0) / near.length
166 }
167 list.sort((a, b) => centre(a) - centre(b) || a.order - b.order).forEach((n, i) => (n.order = i))
168 }
169 }
170
171 const related = new Set(edges.flatMap(e => [e.from, e.to]))
172 const isolated = viewTasks.filter(t => !related.has(linkKey(t.file))).length
173 return { nodes: [...nodes.values()], edges, isolated, criticalDays, cycles: inCycle.length }
174}
175
176// ── drawing ──────────────────────────────────────────────────────────────
177
178const W = 230
179const H = 66
180const FONT = 12
181const CRITICAL = '#d62728'
182
183/** A finished task is a dot; open work, blockers outside the view and broken links are cards. */
184const DOT = 16
185
186function isDot(n: GraphNode): boolean {
187 return !!n.task?.done && !n.missing
188}
189
190function cardMarkup(n: GraphNode, x: number, y: number, settings: TaskNotesSettings): string[] {
191 const parts: string[] = []
192 const status = n.task?.status ?? ''
193 const color = n.missing ? CRITICAL : settings.statuses.find(s => s.value.toLowerCase() === status.toLowerCase())?.color ?? '#808080'
194 const done = !!n.task?.done
195 const stroke = n.critical ? CRITICAL : n.missing ? CRITICAL : n.inView ? '#b8b8b8' : '#c8c8c8'
196 const dash = n.inView && !n.missing ? '' : ' stroke-dasharray="5 4"'
197 parts.push(`<rect x="${x}" y="${y}" width="${W}" height="${H}" rx="7" fill="${n.inView ? '#ffffff' : '#f6f6f6'}" stroke="${stroke}" stroke-width="${n.critical ? 2.5 : 1.2}"${dash}/>`)
198 parts.push(`<rect x="${x}" y="${y}" width="6" height="${H}" rx="3" fill="${esc(color)}"/>`)
199 const maxChars = Math.floor((W - 22) / (FONT * CHAR_WIDTH))
200 const lines = wrapLines(n.missing ? `not found: ${n.title}` : n.title, maxChars)
201 const shown = lines.slice(0, 2)
202 if (lines.length > 2) shown[1] = `${(shown[1] ?? '').slice(0, Math.max(0, maxChars - 1))}…`
203 shown.forEach((line, i) => {
204 parts.push(`<text x="${x + 14}" y="${y + 19 + i * 15}" font-size="${FONT}" fill="#1f1f1f"${done ? ' text-decoration="line-through"' : ''}>${esc(line)}</text>`)
205 })
206 const meta = n.missing
207 ? 'blockedBy link with no task'
208 : [n.task?.statusDef?.label ?? status, n.task?.size, n.task && !done ? `${Math.round(n.days * 10) / 10}d` : '', n.inView ? '' : 'outside view', n.cycle ? 'in a cycle' : '']
209 .filter(Boolean)
210 .join(' · ')
211 parts.push(`<text x="${x + 14}" y="${y + H - 10}" font-size="10" fill="${n.cycle ? CRITICAL : '#666666'}">${esc(meta)}</text>`)
212 return parts
213}
214
215/**
216 * The graph as SVG. Layers run left to right (landscape) or top to bottom (portrait); each
217 * layer is as deep as its biggest node, so a layer of finished dots takes little room.
218 * The pane draws it as a picture: a finished task is a plain dot (its title is in the
219 * task list the pane shows under the graph, where hover and presses work).
220 */
221export function graphSvg(graph: Graph, settings: TaskNotesSettings, orientation: Orientation): string {
222 const landscape = orientation === 'landscape'
223 const pad = 20
224 const size = (n: GraphNode) => (isDot(n) ? { w: DOT, h: DOT } : { w: W, h: H })
225 const layerGap = landscape ? 80 : 56
226 const siblingGap = (a: GraphNode, b: GraphNode) => (isDot(a) && isDot(b) ? 10 : 22)
227
228 const levels = [...new Set(graph.nodes.map(n => n.level))].sort((a, b) => a - b)
229 const pos = new Map<string, { x: number; y: number; w: number; h: number }>()
230 let along = pad
231 for (const lv of levels) {
232 const layer = graph.nodes.filter(n => n.level === lv).sort((a, b) => a.order - b.order)
233 const depth = Math.max(...layer.map(n => (landscape ? size(n).w : size(n).h)))
234 let across = pad
235 layer.forEach((n, i) => {
236 const s = size(n)
237 if (i > 0) across += siblingGap(layer[i - 1] as GraphNode, n)
238 // Centre a dot in its layer's depth, so arrows into it stay level.
239 const offset = (depth - (landscape ? s.w : s.h)) / 2
240 pos.set(n.key, landscape ? { x: along + offset, y: across, ...s } : { x: across, y: along + offset, ...s })
241 across += landscape ? s.h : s.w
242 })
243 along += depth + layerGap
244 }
245 const extent = [...pos.values()]
246 const width = Math.max(W + 2 * pad, ...extent.map(p => p.x + p.w + pad))
247 const height = Math.max(H + 2 * pad, ...extent.map(p => p.y + p.h + pad))
248 const parts: string[] = [
249 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="Segoe UI, Helvetica, Arial, sans-serif">`,
250 '<defs>',
251 '<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#888"/></marker>',
252 `<marker id="arrow-critical" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="${CRITICAL}"/></marker>`,
253 '</defs>',
254 `<rect x="0" y="0" width="${width}" height="${height}" fill="#ffffff"/>`,
255 ]
256 const order = [...graph.edges].sort((a, b) => Number(a.critical) - Number(b.critical))
257 for (const e of order) {
258 const from = pos.get(e.from)
259 const to = pos.get(e.to)
260 if (!from || !to) continue
261 const [x1, y1, x2, y2] = landscape
262 ? [from.x + from.w, from.y + from.h / 2, to.x, to.y + to.h / 2]
263 : [from.x + from.w / 2, from.y + from.h, to.x + to.w / 2, to.y]
264 const bend = landscape ? Math.max(30, (x2 - x1) / 2) : Math.max(24, (y2 - y1) / 2)
265 const d = landscape
266 ? `M${x1},${y1} C${x1 + bend},${y1} ${x2 - bend},${y2} ${x2},${y2}`
267 : `M${x1},${y1} C${x1},${y1 + bend} ${x2},${y2 - bend} ${x2},${y2}`
268 const fromDone = graph.nodes.find(n => n.key === e.from)?.task?.done
269 const color = e.critical ? CRITICAL : fromDone ? '#c4c4c4' : '#9a9a9a'
270 parts.push(`<path d="${d}" fill="none" stroke="${color}" stroke-width="${e.critical ? 2.5 : 1.4}" marker-end="url(#${e.critical ? 'arrow-critical' : 'arrow'})"/>`)
271 }
272 for (const n of graph.nodes.filter(m => !isDot(m))) {
273 const p = pos.get(n.key)
274 if (p) parts.push('<g>', ...cardMarkup(n, p.x, p.y, settings), '</g>')
275 }
276 for (const n of graph.nodes.filter(isDot)) {
277 const p = pos.get(n.key)
278 if (!p) continue
279 const color = n.task?.statusDef?.color ?? '#00aa00'
280 parts.push(
281 '<g class="dot">',
282 `<title>${esc(`${n.title} · ${n.task?.statusDef?.label ?? n.task?.status ?? 'done'}`)}</title>`,
283 `<circle cx="${p.x + DOT / 2}" cy="${p.y + DOT / 2}" r="${DOT / 2 - 1}" fill="${esc(color)}" stroke="#ffffff" stroke-width="2"/>`,
284 '</g>',
285 )
286 }
287 parts.push('</svg>')
288 return parts.join('')
289}
290hooks/model.ts 273 lines1// What a pane shows for one view of a base: its rows as TaskNotes tasks, grouped into board
2// columns or list sections, each card with the properties the view's `order` names.
3// Pure: register.tsx loads the files and draws what this returns.
4
5import type { Layout } from '../types'
6import { displayName, evaluateView, groupRows } from './bases'
7import type { BaseConfig, BaseView, EvalContext, Row } from './bases'
8import { FileV, formatDate, toText } from './expr'
9import { findTask, fieldKey, priorityOrder, statusLabel, statusOrder, taskIndex } from './tasknotes'
10import type { Task, TaskNotesSettings } from './tasknotes'
11
12export type MetaItem = { text: string; color?: string }
13
14export type Card = {
15 key: string
16 path: string
17 title: string
18 status: string
19 color?: string
20 done: boolean
21 meta: MetaItem[]
22}
23
24export type Column = { key: string; label: string; color?: string; cards: Card[] }
25
26export type Model = {
27 view: BaseView
28 /** The layout the view's own type asks for. */
29 natural: Exclude<Layout, 'graph'>
30 matched: number
31 shown: number
32 warnings: string[]
33 rows: Row[]
34 /** The rows' tasks, in the view's sort order. */
35 tasks: Task[]
36}
37
38// ── pill filters ─────────────────────────────────────────────────────────
39
40export type Filters = { projects: string[]; contexts: string[] }
41export type Facet = { name: string; count: number }
42
43const lower = (s: string) => s.toLowerCase()
44
45/**
46 * The view narrowed by the pills: a task stays when it is in one of the selected projects
47 * (if any are selected) and has one of the selected contexts (if any are selected).
48 */
49export function narrow(model: Model, filters: Filters): Model {
50 const projects = filters.projects.map(lower)
51 const contexts = filters.contexts.map(lower)
52 if (!projects.length && !contexts.length) return model
53 const keep = model.tasks.map(t =>
54 (!projects.length || t.projects.some(p => projects.includes(lower(p)))) &&
55 (!contexts.length || t.contexts.some(c => contexts.includes(lower(c.replace(/^@/, ''))))),
56 )
57 const rows = model.rows.filter((_, k) => keep[k])
58 return { ...model, rows, tasks: model.tasks.filter((_, k) => keep[k]), matched: rows.length, shown: rows.length }
59}
60
61/** The projects and contexts of a view's tasks, most used first: what the pills offer. */
62export function facets(tasks: Task[]): { projects: Facet[]; contexts: Facet[] } {
63 const count = (names: string[][]) => {
64 const seen = new Map<string, Facet>()
65 for (const list of names) {
66 for (const name of new Set(list)) {
67 const f = seen.get(lower(name))
68 if (f) f.count++
69 else seen.set(lower(name), { name, count: 1 })
70 }
71 }
72 return [...seen.values()].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
73 }
74 return {
75 projects: count(tasks.map(t => t.projects)),
76 contexts: count(tasks.map(t => t.contexts.map(c => c.replace(/^@/, '')))),
77 }
78}
79
80export function naturalLayout(type: string): Exclude<Layout, 'graph'> {
81 const t = type.toLowerCase()
82 if (t.includes('kanban') || t === 'board') return 'board'
83 if (t.includes('calendar') || t.includes('agenda')) return 'agenda'
84 return 'list'
85}
86
87export type ModelInput = {
88 config: BaseConfig
89 view: BaseView
90 files: FileV[]
91 taskOf: (file: FileV) => Task
92 ctx: EvalContext
93}
94
95export function buildModel({ config, view, files, taskOf, ctx }: ModelInput): Model {
96 const result = evaluateView(config, view, files, ctx)
97 return {
98 view,
99 natural: naturalLayout(view.type),
100 matched: result.matched,
101 shown: result.rows.length,
102 warnings: result.warnings,
103 rows: result.rows,
104 tasks: result.rows.map(r => taskOf(r.file)),
105 }
106}
107
108function isProp(prop: string, settings: TaskNotesSettings, field: string): boolean {
109 const key = fieldKey(settings, field)
110 return prop === key || prop === `note.${key}`
111}
112
113function shortDate(ms: number, now: number): string {
114 const sameYear = new Date(ms).getFullYear() === new Date(now).getFullYear()
115 return formatDate(ms, sameYear ? 'ddd D MMM' : 'D MMM YYYY')
116}
117
118function startOfDay(ms: number): number {
119 const d = new Date(ms)
120 return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
121}
122
123const SKIP = new Set(['file.name', 'file.basename', 'title', 'note.title', 'file.tags', 'tags', 'note.tags', 'complete_instances', 'note.complete_instances'])
124
125/**
126 * A card: the title, then the view's `order` properties TaskNotes knows how to show
127 * (status, priority, due, …), then Done When progress. `extras` adds the other properties
128 * the view lists (formulas, custom fields) as `name: value`, as a table view shows them.
129 */
130export function cardOf(
131 row: Row,
132 task: Task,
133 config: BaseConfig,
134 settings: TaskNotesSettings,
135 index: Map<string, Task>,
136 now: number,
137 opts: { order: string[]; groupProp?: string; extras?: boolean },
138): Card {
139 const meta: MetaItem[] = []
140 const props = [...new Set(opts.order.length ? opts.order : DEFAULT_ORDER)]
141 const today = startOfDay(now)
142 for (const prop of props) {
143 if (SKIP.has(prop) || prop === opts.groupProp) continue
144 if (isProp(prop, settings, 'status')) {
145 meta.push({ text: task.statusDef?.label ?? task.status, ...(task.statusDef ? { color: task.statusDef.color } : {}) })
146 } else if (isProp(prop, settings, 'priority')) {
147 if (task.priority !== 'none') meta.push({ text: `${task.priorityDef?.label ?? task.priority} priority`, ...(task.priorityDef ? { color: task.priorityDef.color } : {}) })
148 } else if (isProp(prop, settings, 'due')) {
149 if (task.due) {
150 const late = !task.done && startOfDay(task.due.ms) < today
151 meta.push({ text: `due ${shortDate(task.due.ms, now)}${late ? ' (overdue)' : ''}`, ...(late ? { color: '#d62728' } : {}) })
152 }
153 } else if (isProp(prop, settings, 'scheduled')) {
154 if (task.scheduled) meta.push({ text: `scheduled ${shortDate(task.scheduled.ms, now)}` })
155 } else if (isProp(prop, settings, 'projects')) {
156 if (task.projects.length) meta.push({ text: `▸ ${task.projects.join(', ')}` })
157 } else if (isProp(prop, settings, 'contexts')) {
158 if (task.contexts.length) meta.push({ text: task.contexts.map(c => `@${c.replace(/^@/, '')}`).join(' ') })
159 } else if (isProp(prop, settings, 'blockedBy')) {
160 const open = task.blockedBy.filter(t => !findTask(index, t)?.done)
161 if (open.length) meta.push({ text: `⛔ blocked by ${open.length}`, color: '#d62728' })
162 } else if (isProp(prop, settings, 'recurrence')) {
163 if (task.recurrence) meta.push({ text: `↻ ${recurrenceWord(task.recurrence)}` })
164 } else if (prop === 'assignee' || prop === 'note.assignee') {
165 if (task.assignee.length) meta.push({ text: `→ ${task.assignee.join(', ')}` })
166 } else if (prop === 'size' || prop === 'note.size') {
167 if (task.size) meta.push({ text: `size ${task.size}` })
168 } else if (opts.extras) {
169 const text = toText(row.get(prop))
170 if (text) meta.push({ text: `${displayName(config, prop)}: ${text}` })
171 }
172 }
173 if (task.doneWhen) meta.push({ text: `done when ${task.doneWhen.done}/${task.doneWhen.total}`, ...(task.doneWhen.done === task.doneWhen.total ? { color: '#00aa00' } : {}) })
174 const card: Card = { key: task.file.path, path: task.file.path, title: task.title, status: task.status, done: task.done, meta }
175 if (task.statusDef) card.color = task.statusDef.color
176 return card
177}
178
179/**
180 * Everything a task has, one line each, whatever the view lists: what the hover popup of a
181 * card shows. Properties a task lacks leave no line.
182 */
183export function detailOf(task: Task, index: Map<string, Task>, now: number): MetaItem[] {
184 const out: MetaItem[] = []
185 const today = startOfDay(now)
186 out.push({ text: `Status: ${task.statusDef?.label ?? task.status}`, ...(task.statusDef ? { color: task.statusDef.color } : {}) })
187 if (task.priority !== 'none') out.push({ text: `Priority: ${task.priorityDef?.label ?? task.priority}`, ...(task.priorityDef ? { color: task.priorityDef.color } : {}) })
188 if (task.due) {
189 const late = !task.done && startOfDay(task.due.ms) < today
190 out.push({ text: `Due: ${shortDate(task.due.ms, now)}${late ? ' (overdue)' : ''}`, ...(late ? { color: '#d62728' } : {}) })
191 }
192 if (task.scheduled) out.push({ text: `Scheduled: ${shortDate(task.scheduled.ms, now)}` })
193 if (task.completed) out.push({ text: `Completed: ${shortDate(task.completed.ms, now)}` })
194 if (task.recurrence) out.push({ text: `Repeats: ${recurrenceWord(task.recurrence)}` })
195 if (task.projects.length) out.push({ text: `Projects: ${task.projects.join(', ')}` })
196 if (task.contexts.length) out.push({ text: `Contexts: ${task.contexts.map(c => `@${c.replace(/^@/, '')}`).join(' ')}` })
197 if (task.assignee.length) out.push({ text: `Assignee: ${task.assignee.join(', ')}` })
198 if (task.size) out.push({ text: `Size: ${task.size}` })
199 if (task.estimateMinutes) out.push({ text: `Estimate: ${task.estimateMinutes} min` })
200 const open = task.blockedBy.filter(t => !findTask(index, t)?.done)
201 if (open.length) out.push({ text: `Blocked by: ${open.map(t => findTask(index, t)?.title ?? t).join(', ')}`, color: '#d62728' })
202 if (task.awaiting) out.push({ text: `Awaiting: ${task.awaiting}` })
203 if (task.doneWhen) out.push({ text: `Done when: ${task.doneWhen.done}/${task.doneWhen.total}`, ...(task.doneWhen.done === task.doneWhen.total ? { color: '#00aa00' } : {}) })
204 if (task.readyWhen) out.push({ text: `Ready when: ${task.readyWhen.done}/${task.readyWhen.total}` })
205 if (task.file.tags.length) out.push({ text: task.file.tags.map(t => (t.startsWith('#') ? t : `#${t}`)).join(' ') })
206 out.push({ text: task.file.path })
207 return out
208}
209
210/** Card properties when a view lists none: TaskNotes' usual ones. */
211const DEFAULT_ORDER = ['status', 'priority', 'due', 'scheduled', 'projects', 'contexts', 'blockedBy', 'recurrence']
212
213/** Cards for the rows of `model`, with the view's own property order. */
214export function cardsOf(model: Model, config: BaseConfig, settings: TaskNotesSettings, allTasks: Task[], now: number, groupProp?: string): Card[] {
215 const index = taskIndex(allTasks)
216 const extras = model.view.type.toLowerCase().includes('table')
217 return model.rows.map((row, k) =>
218 cardOf(row, model.tasks[k] as Task, config, settings, index, now, { order: model.view.order, extras, ...(groupProp ? { groupProp } : {}) }),
219 )
220}
221
222function recurrenceWord(rule: string): string {
223 const freq = /FREQ=(\w+)/i.exec(rule)?.[1] ?? /\b(daily|weekly|monthly|yearly)\b/i.exec(rule)?.[1] ?? 'recurring'
224 const interval = Number(/INTERVAL=(\d+)/i.exec(rule)?.[1] ?? 1)
225 const word = freq.toLowerCase()
226 if (interval <= 1) return word
227 const unit = { daily: 'days', weekly: 'weeks', monthly: 'months', yearly: 'years' }[word] ?? word
228 return `every ${interval} ${unit}`
229}
230
231/**
232 * Columns (board) or sections (list). The board groups by the view's groupBy, else status;
233 * status columns follow TaskNotes' order and include empty ones unless the view hides them.
234 * A list without groupBy is one section.
235 */
236export function columnsOf(
237 model: Model,
238 config: BaseConfig,
239 settings: TaskNotesSettings,
240 allTasks: Task[],
241 now: number,
242 layout: 'board' | 'list',
243): Column[] {
244 const spec = model.view.groupBy ?? (layout === 'board' ? { property: fieldKey(settings, 'status'), direction: 'ASC' as const } : undefined)
245 if (!spec) {
246 return [{ key: 'all', label: model.view.name, cards: cardsOf(model, config, settings, allTasks, now) }]
247 }
248 const byStatus = isProp(spec.property, settings, 'status')
249 const byPriority = isProp(spec.property, settings, 'priority')
250 const cards = cardsOf(model, config, settings, allTasks, now, spec.property)
251 const cardByRow = new Map(model.rows.map((r, k) => [r, cards[k] as Card]))
252 const groups = groupRows(model.rows, spec, keys => {
253 if (byStatus) return statusOrder(settings, keys)
254 if (byPriority) return priorityOrder(settings, keys)
255 return undefined
256 })
257 const columns: Column[] = groups.map(g => {
258 const col: Column = { key: g.key, label: byStatus ? statusLabel(settings, g.label) : g.label, cards: g.rows.map(r => cardByRow.get(r) as Card) }
259 const color = byStatus ? settings.statuses.find(s => s.value.toLowerCase() === g.key)?.color : byPriority ? settings.priorities.find(p => p.value.toLowerCase() === g.key)?.color : undefined
260 if (color) col.color = color
261 return col
262 })
263 if (layout === 'board' && byStatus && model.view.options.hideEmptyColumns !== true) {
264 for (const s of settings.statuses) {
265 if (!columns.some(c => c.key === s.value.toLowerCase())) columns.push({ key: s.value.toLowerCase(), label: s.label, color: s.color, cards: [] })
266 }
267 const ordered = statusOrder(settings, columns.map(c => c.key))
268 columns.sort((a, b) => ordered.indexOf(a.key) - ordered.indexOf(b.key))
269 if (spec.direction === 'DESC') columns.reverse()
270 }
271 return columns
272}
273hooks/tasknotes.ts 309 lines1// TaskNotes: its settings (data.json) and a task as the panes draw it. Field names go
2// through TaskNotes' field mapping, so a vault that renamed `due` to `deadline` still works.
3// The field normalisation follows bob-workspace's task-notes.ts (same author, MIT).
4
5import { DateV, FileV, LinkV, linkKey, toDate, toText, wrap } from './expr'
6import { isRecord } from './yaml'
7
8export type StatusDef = { value: string; label: string; color: string; isCompleted: boolean; order: number }
9export type PriorityDef = { value: string; label: string; color: string; weight: number }
10
11export type TaskNotesSettings = {
12 /** False when the vault has no TaskNotes data.json (defaults are used). */
13 found: boolean
14 tasksFolder: string
15 archiveFolder: string
16 excludedFolders: string[]
17 identify: { method: 'property' | 'tag'; property: string; value: string; tag: string }
18 statuses: StatusDef[]
19 priorities: PriorityDef[]
20 fieldMapping: Record<string, string>
21 /** TaskNotes command id (`open-kanban-view`, …) → the .base file it opens. */
22 viewFiles: Record<string, string>
23}
24
25const DEFAULT_STATUSES: StatusDef[] = [
26 { value: 'open', label: 'Open', color: '#808080', isCompleted: false, order: 1 },
27 { value: 'in-progress', label: 'In progress', color: '#0066cc', isCompleted: false, order: 2 },
28 { value: 'done', label: 'Done', color: '#00aa00', isCompleted: true, order: 3 },
29]
30
31const DEFAULT_PRIORITIES: PriorityDef[] = [
32 { value: 'low', label: 'Low', color: '#00aa00', weight: 1 },
33 { value: 'normal', label: 'Normal', color: '#ffaa00', weight: 2 },
34 { value: 'high', label: 'High', color: '#ff0000', weight: 3 },
35]
36
37const DEFAULT_VIEW_FILES: Record<string, string> = {
38 'open-kanban-view': 'TaskNotes/Views/kanban-default.base',
39 'open-tasks-view': 'TaskNotes/Views/tasks-default.base',
40 'open-agenda-view': 'TaskNotes/Views/agenda-default.base',
41 'open-advanced-calendar-view': 'TaskNotes/Views/calendar-default.base',
42 'open-calendar-view': 'TaskNotes/Views/mini-calendar-default.base',
43 relationships: 'TaskNotes/Views/relationships.base',
44}
45
46/** Short names for `/taskboard <name>`, each a TaskNotes command whose base file it opens. */
47export const VIEW_ALIASES: Record<string, string> = {
48 kanban: 'open-kanban-view',
49 board: 'open-kanban-view',
50 tasks: 'open-tasks-view',
51 list: 'open-tasks-view',
52 agenda: 'open-agenda-view',
53 calendar: 'open-advanced-calendar-view',
54 'mini-calendar': 'open-calendar-view',
55 relationships: 'relationships',
56}
57
58const str = (v: unknown, fallback: string) => (typeof v === 'string' && v.trim() ? v.trim() : fallback)
59
60export function parseSettings(json: string | undefined): TaskNotesSettings {
61 let data: Record<string, unknown> = {}
62 let found = false
63 if (json) {
64 try {
65 const parsed: unknown = JSON.parse(json)
66 if (isRecord(parsed)) {
67 data = parsed
68 found = true
69 }
70 } catch {
71 // A broken data.json reads as defaults.
72 }
73 }
74 const statuses = Array.isArray(data.customStatuses)
75 ? data.customStatuses.filter(isRecord).map((s, k) => ({
76 value: str(s.value, `status-${k}`),
77 label: str(s.label, str(s.value, `Status ${k}`)),
78 color: str(s.color, '#808080'),
79 isCompleted: s.isCompleted === true,
80 order: typeof s.order === 'number' ? s.order : k,
81 }))
82 : DEFAULT_STATUSES
83 const priorities = Array.isArray(data.customPriorities)
84 ? data.customPriorities.filter(isRecord).map((p, k) => ({
85 value: str(p.value, `priority-${k}`),
86 label: str(p.label, str(p.value, `Priority ${k}`)),
87 color: str(p.color, '#808080'),
88 weight: typeof p.weight === 'number' ? p.weight : k,
89 }))
90 : DEFAULT_PRIORITIES
91 const fieldMapping: Record<string, string> = {}
92 if (isRecord(data.fieldMapping)) {
93 for (const [k, v] of Object.entries(data.fieldMapping)) if (typeof v === 'string' && v) fieldMapping[k] = v
94 }
95 const viewFiles: Record<string, string> = { ...DEFAULT_VIEW_FILES }
96 if (isRecord(data.commandFileMapping)) {
97 for (const [k, v] of Object.entries(data.commandFileMapping)) if (typeof v === 'string' && v) viewFiles[k] = v
98 }
99 const excluded = data.excludedFolders
100 return {
101 found,
102 tasksFolder: str(data.tasksFolder, 'TaskNotes/Tasks').replace(/\/+$/, ''),
103 archiveFolder: str(data.archiveFolder, 'TaskNotes/Archive').replace(/\/+$/, ''),
104 excludedFolders: (Array.isArray(excluded) ? excluded.map(String) : typeof excluded === 'string' ? excluded.split(',') : [])
105 .map(s => s.trim().replace(/\/+$/, ''))
106 .filter(Boolean),
107 identify: {
108 method: data.taskIdentificationMethod === 'property' ? 'property' : 'tag',
109 property: str(data.taskPropertyName, 'type'),
110 value: str(data.taskPropertyValue, 'task'),
111 tag: str(data.taskTag, 'task').replace(/^#/, ''),
112 },
113 statuses: [...statuses].sort((a, b) => a.order - b.order),
114 priorities: [...priorities].sort((a, b) => b.weight - a.weight),
115 fieldMapping,
116 viewFiles,
117 }
118}
119
120/** The frontmatter key TaskNotes stores a field under. */
121export function fieldKey(settings: TaskNotesSettings, field: string): string {
122 return settings.fieldMapping[field] ?? field
123}
124
125export function isTaskFile(file: FileV, settings: TaskNotesSettings): boolean {
126 if (settings.excludedFolders.some(f => file.path.toLowerCase().startsWith(`${f.toLowerCase()}/`))) return false
127 const { method, property, value, tag } = settings.identify
128 if (method === 'tag') return file.tags.some(t => t.toLowerCase() === tag.toLowerCase() || t.toLowerCase().startsWith(`${tag.toLowerCase()}/`))
129 const raw = file.properties[property]
130 const values = Array.isArray(raw) ? raw : [raw]
131 return values.some(v => typeof v === 'string' && v.toLowerCase() === value.toLowerCase())
132}
133
134// ── a task ───────────────────────────────────────────────────────────────
135
136export type Progress = { done: number; total: number }
137
138export type Task = {
139 file: FileV
140 title: string
141 status: string
142 statusDef?: StatusDef
143 done: boolean
144 priority: string
145 priorityDef?: PriorityDef
146 due: DateV | null
147 scheduled: DateV | null
148 completed: DateV | null
149 projects: string[]
150 contexts: string[]
151 /** Link targets of the tasks this one waits for. */
152 blockedBy: string[]
153 recurrence?: string
154 completeInstances: string[]
155 size?: string
156 estimateMinutes?: number
157 assignee: string[]
158 /** `## Done When` checkboxes; `## Ready When` ones. */
159 doneWhen?: Progress
160 readyWhen?: Progress
161 awaiting?: string
162}
163
164function list(v: unknown): unknown[] {
165 if (v === null || v === undefined || v === '') return []
166 if (Array.isArray(v)) return v
167 if (typeof v === 'string' && v.includes(',') && !v.includes('[[')) return v.split(',').map(s => s.trim()).filter(Boolean)
168 return [v]
169}
170
171function names(v: unknown): string[] {
172 return list(v)
173 .map(item => {
174 const w = wrap(item)
175 return w instanceof LinkV ? w.display || w.target : toText(w)
176 })
177 .filter(Boolean)
178}
179
180/** A blockedBy entry is a wikilink, or `{ uid: wikilink, reltype }`. */
181export function blockerTargets(v: unknown): string[] {
182 return list(v)
183 .map(item => {
184 const raw = isRecord(item) ? item.uid : item
185 const w = wrap(raw)
186 return w instanceof LinkV ? w.target : typeof w === 'string' ? w : ''
187 })
188 .filter(Boolean)
189}
190
191/** Checkbox counts under the first heading named like one of `titles`. */
192export function sectionProgress(body: string, titles: string[]): Progress | undefined {
193 const lines = body.split(/\r?\n/)
194 const want = titles.map(t => t.toLowerCase())
195 let level = 0
196 let inSection = false
197 let done = 0
198 let total = 0
199 let fence = false
200 for (const line of lines) {
201 if (/^\s*(```|~~~)/.test(line)) fence = !fence
202 if (fence) continue
203 const h = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line)
204 if (h) {
205 const depth = (h[1] ?? '').length
206 if (inSection && depth <= level) break
207 if (!inSection && want.includes((h[2] ?? '').toLowerCase())) {
208 inSection = true
209 level = depth
210 }
211 continue
212 }
213 if (!inSection) continue
214 const box = /^\s*[-*+]\s+\[(.)\]/.exec(line)
215 if (box) {
216 total++
217 if ((box[1] ?? ' ') !== ' ') done++
218 }
219 }
220 return inSection && total > 0 ? { done, total } : undefined
221}
222
223export function toTask(file: FileV, body: string, settings: TaskNotesSettings): Task {
224 const p = file.properties
225 const get = (field: string) => p[fieldKey(settings, field)]
226 const status = toText(wrap(get('status'))).trim() || 'none'
227 const priority = toText(wrap(get('priority'))).trim() || 'none'
228 const statusDef = settings.statuses.find(s => s.value.toLowerCase() === status.toLowerCase())
229 const priorityDef = settings.priorities.find(s => s.value.toLowerCase() === priority.toLowerCase())
230 const title = toText(wrap(get('title'))).trim() || file.basename
231 const estimate = Number(get('timeEstimate'))
232 const task: Task = {
233 file,
234 title,
235 status,
236 done: statusDef ? statusDef.isCompleted : /^(done|completed|cancel+ed|archived)$/i.test(status),
237 priority,
238 due: toDate(get('due')),
239 scheduled: toDate(get('scheduled')),
240 completed: toDate(get('completedDate')),
241 projects: names(get('projects')),
242 contexts: names(get('contexts')),
243 blockedBy: blockerTargets(get('blockedBy')),
244 completeInstances: list(get('completeInstances')).map(v => toText(wrap(v))).filter(Boolean),
245 assignee: names(p.assignee),
246 }
247 if (statusDef) task.statusDef = statusDef
248 if (priorityDef) task.priorityDef = priorityDef
249 const recurrence = toText(wrap(get('recurrence'))).trim()
250 if (recurrence) task.recurrence = recurrence
251 const size = toText(wrap(p.size)).trim()
252 if (size) task.size = size
253 if (Number.isFinite(estimate) && estimate > 0) task.estimateMinutes = estimate
254 const doneWhen = sectionProgress(body, ['Done When', 'Acceptance Criteria'])
255 if (doneWhen) task.doneWhen = doneWhen
256 const readyWhen = sectionProgress(body, ['Ready When'])
257 if (readyWhen) task.readyWhen = readyWhen
258 const awaiting = toText(wrap(p.awaiting)).trim()
259 if (awaiting) task.awaiting = awaiting
260 return task
261}
262
263export function statusLabel(settings: TaskNotesSettings, value: string): string {
264 return settings.statuses.find(s => s.value.toLowerCase() === value.toLowerCase())?.label ?? value
265}
266
267export function statusColor(settings: TaskNotesSettings, value: string): string | undefined {
268 return settings.statuses.find(s => s.value.toLowerCase() === value.toLowerCase())?.color
269}
270
271/** Group keys in TaskNotes' status order (unknown values after them). */
272export function statusOrder(settings: TaskNotesSettings, keys: string[]): string[] {
273 const rank = (k: string) => {
274 const i = settings.statuses.findIndex(s => s.value.toLowerCase() === k)
275 return i < 0 ? settings.statuses.length : i
276 }
277 return [...keys].sort((a, b) => rank(a) - rank(b))
278}
279
280/** Group keys by priority weight, highest first. */
281export function priorityOrder(settings: TaskNotesSettings, keys: string[]): string[] {
282 const weight = (k: string) => settings.priorities.find(p => p.value.toLowerCase() === k)?.weight ?? -1
283 return [...keys].sort((a, b) => weight(b) - weight(a))
284}
285
286const SIZE_DAYS: Record<string, number> = { xs: 0.5, s: 1, m: 2, l: 4, xl: 8 }
287
288/** A task's duration in days for the critical path: timeEstimate (minutes, 8h days), else size, else 1. */
289export function durationDays(task: Task): number {
290 if (task.estimateMinutes) return task.estimateMinutes / 480
291 const bySize = task.size ? SIZE_DAYS[task.size.toLowerCase()] : undefined
292 return bySize ?? 1
293}
294
295/** Index tasks by link identity (note name), for resolving blockedBy. */
296export function taskIndex(tasks: Task[]): Map<string, Task> {
297 const index = new Map<string, Task>()
298 for (const t of tasks) {
299 index.set(linkKey(t.file), t)
300 index.set(t.file.path.replace(/\.md$/i, '').toLowerCase(), t)
301 }
302 return index
303}
304
305export function findTask(index: Map<string, Task>, target: string): Task | undefined {
306 const clean = target.replace(/\\/g, '/').replace(/\.md$/i, '').toLowerCase()
307 return index.get(clean) ?? index.get(linkKey(target))
308}
309hooks/yaml.ts 357 lines1// A small YAML reader for note frontmatter and Obsidian `.base` files. A plugin has no npm
2// packages, so this covers the subset Obsidian and TaskNotes write: block mappings and
3// sequences (a sequence may sit at its key's own indent), sequences of mappings, flow
4// `[...]` / `{...}`, plain, single- and double-quoted scalars (over several lines too),
5// block scalars (`|`, `>`, with `-`/`+`), and comments. Dates stay strings: Bases converts
6// them with `date()`, as Obsidian does.
7
8type Line = { indent: number; text: string }
9
10export class YamlError extends Error {}
11
12export function parseYaml(src: string): unknown {
13 const lines: (Line | undefined)[] = src.replace(/\r/g, '').split('\n').map(raw => {
14 const text = raw.replace(/\t/g, ' ')
15 const trimmed = text.trimStart()
16 return { indent: text.length - trimmed.length, text: trimmed.trimEnd() }
17 })
18 const p = new Parser(lines)
19 return p.document()
20}
21
22/** Split a markdown note into its frontmatter object and body. */
23export function splitFrontmatter(text: string): { data: Record<string, unknown>; body: string; error?: string } {
24 const m = /^---[ \t]*\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/.exec(text)
25 if (!m) return { data: {}, body: text }
26 const body = text.slice(m[0].length)
27 try {
28 const parsed = parseYaml(m[1] ?? '')
29 return { data: isRecord(parsed) ? parsed : {}, body }
30 } catch (err) {
31 return { data: {}, body, error: err instanceof Error ? err.message : String(err) }
32 }
33}
34
35export function isRecord(v: unknown): v is Record<string, unknown> {
36 return typeof v === 'object' && v !== null && !Array.isArray(v)
37}
38
39const KEY = /^(?:"((?:[^"\\]|\\.)*)"|'((?:[^']|'')*)'|([^\s'"#[\]{},&*!|>%@`-][^:]*?|-[^\s:][^:]*?))[ \t]*:(?:[ \t]+(.*)|$)/
40
41function isSeqItem(text: string): boolean {
42 return text === '-' || text.startsWith('- ')
43}
44
45function isBlank(line: Line | undefined): boolean {
46 return !line || line.text === '' || line.text.startsWith('#')
47}
48
49class Parser {
50 i = 0
51 constructor(private lines: (Line | undefined)[]) {}
52
53 document(): unknown {
54 this.skip()
55 const first = this.lines[this.i]
56 if (!first) return null
57 if (first.text === '---') {
58 this.i++
59 this.skip()
60 }
61 const line = this.lines[this.i]
62 if (!line) return null
63 return this.node(line.indent)
64 }
65
66 private skip(): void {
67 while (this.i < this.lines.length && isBlank(this.lines[this.i])) this.i++
68 }
69
70 private peek(): Line | undefined {
71 this.skip()
72 return this.lines[this.i]
73 }
74
75 private node(indent: number): unknown {
76 const line = this.peek()
77 if (!line || line.indent < indent) return null
78 if (isSeqItem(line.text)) return this.seq(line.indent)
79 if (KEY.test(line.text)) return this.map(line.indent)
80 this.i++
81 return this.inline(line.text, line.indent - 1)
82 }
83
84 private map(indent: number): Record<string, unknown> {
85 const out: Record<string, unknown> = {}
86 for (;;) {
87 const line = this.peek()
88 if (!line || line.indent !== indent || isSeqItem(line.text)) break
89 const m = KEY.exec(line.text)
90 if (!m) throw new YamlError(`expected "key: value" at "${line.text.slice(0, 60)}"`)
91 const key = m[1] !== undefined ? unescapeDouble(m[1]) : m[2] !== undefined ? m[2].replace(/''/g, "'") : (m[3] ?? '').trim()
92 const rest = (m[4] ?? '').trim()
93 this.i++
94 out[key] = this.value(rest, indent)
95 }
96 return out
97 }
98
99 /** The value after `key:` (or `- `), with `indent` the column of its owner. */
100 private value(rest: string, indent: number): unknown {
101 if (rest === '' || rest.startsWith('#')) {
102 const next = this.peek()
103 if (!next) return null
104 if (next.indent > indent) return this.node(next.indent)
105 // A sequence may sit at its key's own indent.
106 if (next.indent === indent && isSeqItem(next.text)) return this.seq(indent)
107 return null
108 }
109 if (/^[|>][+-]?\d*$/.test(rest.replace(/\s+#.*$/, ''))) return this.blockScalar(rest, indent)
110 return this.inline(rest, indent)
111 }
112
113 private seq(indent: number): unknown[] {
114 const out: unknown[] = []
115 for (;;) {
116 const line = this.peek()
117 if (!line || line.indent !== indent || !isSeqItem(line.text)) break
118 const content = line.text === '-' ? '' : line.text.slice(2).trimStart()
119 const offset = line.indent + (line.text.length - content.length)
120 if (content === '') {
121 this.i++
122 out.push(this.value('', indent))
123 } else if (isSeqItem(content) || startsAsKey(content)) {
124 // `- key: v` (a mapping) or `- - x` (a nested sequence): re-read this line as the
125 // first line of that node, at the column its content starts.
126 this.lines[this.i] = { indent: offset, text: content }
127 out.push(this.node(offset))
128 } else {
129 this.i++
130 out.push(this.inline(content, indent))
131 }
132 }
133 return out
134 }
135
136 private blockScalar(header: string, indent: number): string {
137 const fold = header.startsWith('>')
138 const chomp = header.includes('-') ? 'strip' : header.includes('+') ? 'keep' : 'clip'
139 const raw: string[] = []
140 let blockIndent = -1
141 while (this.i < this.lines.length) {
142 const line = this.lines[this.i]
143 if (!line) break
144 if (line.text === '') {
145 raw.push('')
146 this.i++
147 continue
148 }
149 if (line.indent <= indent) break
150 if (blockIndent < 0) blockIndent = line.indent
151 if (line.indent < blockIndent) break
152 raw.push(' '.repeat(line.indent - blockIndent) + line.text)
153 this.i++
154 }
155 let text: string
156 if (fold) {
157 text = raw.reduce((acc, l, k) => {
158 if (k === 0) return l
159 const prev = raw[k - 1] ?? ''
160 if (l === '' || prev === '' || l.startsWith(' ')) return `${acc}\n${l}`
161 return `${acc} ${l}`
162 }, '')
163 } else {
164 text = raw.join('\n')
165 }
166 if (chomp === 'strip') return text.replace(/\n+$/, '')
167 if (chomp === 'clip') return text.replace(/\n+$/, '') + '\n'
168 return text + '\n'
169 }
170
171 /**
172 * A scalar or flow collection starting on this line; quoted, flow and plain scalars may
173 * continue on following lines indented deeper than `indent`.
174 */
175 private inline(first: string, indent: number): unknown {
176 let text = first
177 const continues = () => {
178 const next = this.lines[this.i]
179 return !!next && next.text !== '' && next.indent > indent && !next.text.startsWith('#')
180 }
181 if (text.startsWith('"') || text.startsWith("'")) {
182 const q = text[0] as '"' | "'"
183 while (!closedQuote(text, q) && this.i < this.lines.length) {
184 const next = this.lines[this.i]
185 this.i++
186 text += next && next.text !== '' ? ` ${next.text}` : '\n'
187 }
188 const end = closingQuoteAt(text, q)
189 const body = text.slice(1, end)
190 return q === '"' ? unescapeDouble(body) : body.replace(/''/g, "'")
191 }
192 if (text.startsWith('[') || text.startsWith('{')) {
193 while (!balanced(text) && this.i < this.lines.length) {
194 const next = this.lines[this.i]
195 this.i++
196 if (next) text += ` ${next.text}`
197 }
198 return new Flow(text).value()
199 }
200 const plain = stripComment(text)
201 const parts = [plain]
202 while (continues() && !KEY.test(this.lines[this.i]?.text ?? '') && !isSeqItem(this.lines[this.i]?.text ?? '')) {
203 parts.push(stripComment(this.lines[this.i]?.text ?? ''))
204 this.i++
205 }
206 return parts.length > 1 ? parts.join(' ') : scalar(plain)
207 }
208}
209
210/**
211 * `- foo: bar` reads as a mapping only when the part before `:` looks like a key, so a
212 * filter expression such as `- file.name == "a: b"` stays one string.
213 */
214function startsAsKey(content: string): boolean {
215 const m = KEY.exec(content)
216 if (!m) return false
217 return m[3] === undefined || !/[()"'=<>!&|]/.test(m[3])
218}
219
220function closingQuoteAt(text: string, q: string): number {
221 for (let k = 1; k < text.length; k++) {
222 const c = text[k]
223 if (q === '"' && c === '\\') {
224 k++
225 continue
226 }
227 if (c === q) {
228 if (q === "'" && text[k + 1] === "'") {
229 k++
230 continue
231 }
232 return k
233 }
234 }
235 return -1
236}
237
238function closedQuote(text: string, q: string): boolean {
239 return closingQuoteAt(text, q) > 0
240}
241
242function balanced(text: string): boolean {
243 let depth = 0
244 let quote: string | undefined
245 for (let k = 0; k < text.length; k++) {
246 const c = text[k]
247 if (quote) {
248 if (quote === '"' && c === '\\') k++
249 else if (c === quote) quote = undefined
250 } else if (c === '"' || c === "'") quote = c
251 else if (c === '[' || c === '{') depth++
252 else if (c === ']' || c === '}') depth--
253 }
254 return depth <= 0
255}
256
257function stripComment(text: string): string {
258 const m = /\s#/.exec(text)
259 return (m ? text.slice(0, m.index) : text).trim()
260}
261
262function unescapeDouble(s: string): string {
263 return s.replace(/\\(u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)/g, (_, e: string) => {
264 if (e[0] === 'u' || e[0] === 'x') return String.fromCharCode(parseInt(e.slice(1), 16))
265 return ({ n: '\n', t: '\t', r: '\r', '0': '\0', '"': '"', '\\': '\\', '/': '/', ' ': ' ' } as Record<string, string>)[e] ?? e
266 })
267}
268
269/** A plain scalar: null, booleans and numbers become values; everything else stays text. */
270export function scalar(text: string): unknown {
271 const t = text.trim()
272 if (t === '' || t === '~' || t === 'null' || t === 'Null' || t === 'NULL') return null
273 if (/^(true|True|TRUE)$/.test(t)) return true
274 if (/^(false|False|FALSE)$/.test(t)) return false
275 if (/^[-+]?(\d+|\d*\.\d+)([eE][-+]?\d+)?$/.test(t)) return Number(t)
276 if (/^0x[0-9a-fA-F]+$/.test(t)) return parseInt(t, 16)
277 return t
278}
279
280class Flow {
281 k = 0
282 constructor(private s: string) {}
283
284 value(): unknown {
285 this.ws()
286 const c = this.s[this.k]
287 if (c === '[') return this.list()
288 if (c === '{') return this.object()
289 return this.atom(false)
290 }
291
292 private ws(): void {
293 while (this.k < this.s.length && /\s/.test(this.s[this.k] ?? '')) this.k++
294 }
295
296 private list(): unknown[] {
297 const out: unknown[] = []
298 this.k++ // [
299 for (;;) {
300 this.ws()
301 if (this.s[this.k] === ']') {
302 this.k++
303 return out
304 }
305 if (this.k >= this.s.length) throw new YamlError('unclosed [')
306 out.push(this.value())
307 this.ws()
308 if (this.s[this.k] === ',') this.k++
309 }
310 }
311
312 private object(): Record<string, unknown> {
313 const out: Record<string, unknown> = {}
314 this.k++ // {
315 for (;;) {
316 this.ws()
317 if (this.s[this.k] === '}') {
318 this.k++
319 return out
320 }
321 if (this.k >= this.s.length) throw new YamlError('unclosed {')
322 const key = String(this.atom(true) ?? '')
323 this.ws()
324 let val: unknown = null
325 if (this.s[this.k] === ':') {
326 this.k++
327 val = this.value()
328 }
329 out[key] = val
330 this.ws()
331 if (this.s[this.k] === ',') this.k++
332 }
333 }
334
335 private atom(isKey: boolean): unknown {
336 this.ws()
337 const c = this.s[this.k]
338 if (c === '"' || c === "'") {
339 const rest = this.s.slice(this.k)
340 const end = closingQuoteAt(rest, c)
341 if (end < 0) throw new YamlError('unclosed quote')
342 this.k += end + 1
343 const body = rest.slice(1, end)
344 return c === '"' ? unescapeDouble(body) : body.replace(/''/g, "'")
345 }
346 const start = this.k
347 while (this.k < this.s.length) {
348 const ch = this.s[this.k]
349 if (ch === ',' || ch === ']' || ch === '}') break
350 if (isKey && ch === ':') break
351 if (ch === ':' && /\s/.test(this.s[this.k + 1] ?? ' ')) break
352 this.k++
353 }
354 return scalar(this.s.slice(start, this.k))
355 }
356}
357types/index.d.ts 38 lines1export type Layout = 'board' | 'list' | 'agenda' | 'graph'
2
3export type TasksTarget = {
4 /** The .base file, vault-relative with forward slashes. */
5 rel: string
6 /** The .base file, resolved absolute path. */
7 real: string
8 /** Obsidian vault name. */
9 vault?: string
10 /** The vault folder (holds `.obsidian`), absolute. */
11 root: string
12 /** Which of the base's views is shown, 0-based. */
13 view: number
14 /** How it is drawn; absent means the view's own type (kanban → board, calendar → agenda, else list). */
15 layout?: Layout
16 /** Groups shown in full instead of their first cards, as `<layout>:<group key>`. */
17 expanded: string[]
18 /** Pill filters on top of the view: a task in any selected project, AND any selected context. */
19 projects?: string[]
20 contexts?: string[]
21 /** Every pill shown, instead of the most used ones. */
22 allPills?: boolean
23 /** Dependency graph: leave finished tasks out (else they are drawn as dots). */
24 hideDone?: boolean
25 /** Dependency graph only. */
26 orientation: 'landscape' | 'portrait'
27 zoom: number
28 pan: number
29 /** Bumped on every refresh so the pane redraws. */
30 rev: number
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'tasknotes-preview': { target: StateFamily<TasksTarget> }
36 }
37}
38