SLOPSHOPPER

tasknotes-preview

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

newpaneguardcommandtoasttool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tasknotes-preview
│ ┃ tasks ✕ › fix the failing auth test and add an audit log call │ ┃ No tasks open. Run /taskboard, or /taskboard │ ┃ kanban, or /taskboard <file.base>. ⏺ Read(src/auth.ts) │ ┃ The board, agenda and graph are drawn in the ⎿ Read 6 lines │ ┃ Desktop Code tab. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /taskboard │ ⎿ tasknotes-preview: tasknotes-preview: "tasks" names a TaskNotes │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · tasks
No tasks open. Run /taskboard, or /taskboard kanban, or /taskboard <file.base>. The board, agenda and graph are drawn in the Desktop Code tab.
README

claude-tasknotes-renderer-mod

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.

LayoutDrawn forWhat it shows
BoardtasknotesKanban viewsColumns by the view's groupBy (default: status). Status columns follow TaskNotes' order and colours; empty ones stay unless the view hides them.
ListtasknotesTaskList, table and other viewsSections by groupBy, rows in the view's sort order. Table views also show their formula columns.
AgendatasknotesCalendar and tasknotesMiniCalendar viewsOverdue tasks first, then the next 7 days (listDayCount) by due and scheduled date. Recurring tasks are expanded from their rule, minus completed instances.
Graphany view, on requestblockedBy 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.

Use

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]
  • Or just ask Claude ("show my overdue tasks", "show the dependency graph of the kanban"). The mod registers the model tool 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.

Install

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.

  1. Clone this repository.
  2. Add it to 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" } }
  1. Start a new Code-tab session in (or below) your vault folder.

Requirements

  • Required: Claude Code with mods (function hooks), 2.1.286 or later. There are no npm packages, and the mod installs nothing.
  • Your vault: TaskNotes' settings (.obsidian/plugins/tasknotes/data.json) give the folders, statuses, priorities, field mapping and view files. Without them the mod uses TaskNotes' defaults.
  • Optional: the Advanced URI plugin in Obsidian, for the ↗ and Open in Obsidian buttons.

How faithful it is

  • Expressions. The Bases expression language is implemented, not approximated:
  • operators;
  • if, date, today, now, number, list, file, link, min, max, duration;
  • date arithmetic with durations (today() + "7 days");
  • list filter / map / reduce;
  • string, number, date, file and link methods;
  • this, and formulas that use other formulas.

Anything it does not know is skipped and named under the caption, never guessed.

  • Scope. Filters run over the notes in TaskNotes' tasks and archive folders, not the whole vault, so a base about other notes shows nothing. When a base's own filters leave the archive out (!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.
  • What a base says is what you get. TaskNotes' default bases filter on 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.

Develop

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.

Credits

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.

License

MIT

Source 10 files
hooks/register.tsx 789 lines
1import { 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}
789
hooks/agenda.ts 154 lines
1// 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}
154
hooks/bases.ts 235 lines
1// 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}
235
hooks/common.ts 179 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
179
hooks/expr.ts 879 lines
1// 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}
879
hooks/graph.ts 290 lines
1// 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}
290
hooks/model.ts 273 lines
1// 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}
273
hooks/tasknotes.ts 309 lines
1// 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}
309
hooks/yaml.ts 357 lines
1// 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}
357
types/index.d.ts 38 lines
1export 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