From fc1b2fcabf5493df65e6a2e19aab59acdc8ef52f Mon Sep 17 00:00:00 2001 From: Victor Wiebe Date: Fri, 12 Jun 2026 22:01:13 +0000 Subject: [PATCH] =?UTF-8?q?Phase=202:=20real=20data=20layer=20=E2=80=94=20?= =?UTF-8?q?folder=20scope,=20gtd=20blocks,=20inclusion=20matrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/Gtd/: pure, unit-tested modules - parseCalendarConfig: SPEC defaults + warnings for invalid options - folderScope: BFS scope resolution (this-folder / children / depth N) - gtdBlock: extract + YAML-parse the first ```gtd fence (empty block is a valid opt-in; malformed YAML opts in with a surfaced warning) - resolveDate: gtd date (iso + mm-dd-yyyy) overrides todo_due; nothing resolvable -> unscheduled (null date) - collectEvents: orchestrates scope -> fetch -> inclusion matrix -> date resolution -> sort; always excludes the calendar note - index.ts: JoplinDataAdapter with pagination; getEvents now returns real events plus warnings and scan stats - Webview debug view shows real data: counts, warnings, colours, icons, strikethrough for completed todos, tooltip from text: - 24 new unit tests (33 total) --- src/Gtd/collectEvents.ts | 112 +++++++++++ src/Gtd/folderScope.ts | 36 ++++ src/Gtd/gtdBlock.ts | 75 +++++++ src/Gtd/parseCalendarConfig.ts | 109 ++++++++++ src/Gtd/resolveDate.ts | 74 +++++++ src/Gtd/types.ts | 69 +++++++ src/event-calendar.css | 11 ++ src/gtd-calendar-webview.js | 36 +++- src/index.ts | 123 +++++++----- src/tests/Gtd/gtd.test.ts | 350 +++++++++++++++++++++++++++++++++ 10 files changed, 939 insertions(+), 56 deletions(-) create mode 100644 src/Gtd/collectEvents.ts create mode 100644 src/Gtd/folderScope.ts create mode 100644 src/Gtd/gtdBlock.ts create mode 100644 src/Gtd/parseCalendarConfig.ts create mode 100644 src/Gtd/resolveDate.ts create mode 100644 src/Gtd/types.ts create mode 100644 src/tests/Gtd/gtd.test.ts diff --git a/src/Gtd/collectEvents.ts b/src/Gtd/collectEvents.ts new file mode 100644 index 0000000..9135732 --- /dev/null +++ b/src/Gtd/collectEvents.ts @@ -0,0 +1,112 @@ +import { CalendarConfig, DataAdapter, GtdEvent, RawNote } from "./types"; +import resolveScopedFolderIds from "./folderScope"; +import extractGtdBlock from "./gtdBlock"; +import { resolveEventDate } from "./resolveDate"; + +export interface CollectResult { + events: GtdEvent[]; + warnings: string[]; + scannedFolders: number; + scannedNotes: number; +} + +/** + * Collect calendar events per SPEC.md: + * - resolve folder scope from the calendar note's folder + * - fetch notes/todos in scope + * - apply the inclusion matrix (§3.2) + * - resolve dates (§3.1) + * - sort within the flat list (grouping happens at render time) + * + * The calendar note itself is always excluded. + */ +export default async function collectEvents( + adapter: DataAdapter, + calendarNoteId: string, + calendarFolderId: string, + config: CalendarConfig +): Promise { + const warnings: string[] = []; + + const folders = await adapter.getFolders(); + const folderIds = resolveScopedFolderIds( + folders, + calendarFolderId, + config.scopeDepth + ); + + let scannedNotes = 0; + const events: GtdEvent[] = []; + + for (const folderId of folderIds) { + const notes = await adapter.getNotesInFolder(folderId); + for (const note of notes) { + scannedNotes += 1; + if (note.id === calendarNoteId) continue; + + const event = noteToEvent(note, config, warnings); + if (event) events.push(event); + } + } + + sortEvents(events, config); + + return { + events, + warnings, + scannedFolders: folderIds.length, + scannedNotes, + }; +} + +function noteToEvent( + note: RawNote, + config: CalendarConfig, + warnings: string[] +): GtdEvent | null { + const mode = note.is_todo ? config.todos : config.notes; + if (mode === "none") return null; + + const result = extractGtdBlock(note.body); + if (mode === "gtd-only" && !result.found) return null; + + if (result.error) { + warnings.push(`"${note.title}": gtd block problem — ${result.error}`); + } + + const block = result.block; + const date = resolveEventDate(note, block); + + if (block && block.date && !date) { + warnings.push(`"${note.title}": unparseable date "${block.date}"`); + } + + return { + id: note.id, + title: block && block.title ? block.title : note.title, + date, + isTodo: Boolean(note.is_todo), + completed: Boolean(note.is_todo && note.todo_completed > 0), + bgColour: block ? block.bgColour : null, + fgColour: block ? block.fgColour : null, + icon: block ? block.icon : null, + text: block ? block.text : null, + updatedTime: note.updated_time, + }; +} + +function sortEvents(events: GtdEvent[], config: CalendarConfig): void { + const direction = config.sort === "desc" ? -1 : 1; + + events.sort((a, b) => { + let comparison: number; + if (config.sortType === "modified_date") { + comparison = a.updatedTime - b.updatedTime; + } else { + comparison = a.title.localeCompare(b.title, undefined, { + sensitivity: "base", + }); + } + return comparison * direction; + }); +} diff --git a/src/Gtd/folderScope.ts b/src/Gtd/folderScope.ts new file mode 100644 index 0000000..3fd8c41 --- /dev/null +++ b/src/Gtd/folderScope.ts @@ -0,0 +1,36 @@ +import { RawFolder } from "./types"; + +/** + * Resolve which folder ids are in scope: the root folder plus descendants + * down to `depth` levels (0 = root only, Infinity = all descendants). + */ +export default function resolveScopedFolderIds( + folders: RawFolder[], + rootFolderId: string, + depth: number +): string[] { + const childrenByParent = new Map(); + for (const folder of folders) { + const siblings = childrenByParent.get(folder.parent_id) || []; + siblings.push(folder.id); + childrenByParent.set(folder.parent_id, siblings); + } + + const inScope: string[] = [rootFolderId]; + let frontier = [rootFolderId]; + let level = 0; + + while (frontier.length > 0 && level < depth) { + const next: string[] = []; + for (const folderId of frontier) { + for (const childId of childrenByParent.get(folderId) || []) { + inScope.push(childId); + next.push(childId); + } + } + frontier = next; + level += 1; + } + + return inScope; +} diff --git a/src/Gtd/gtdBlock.ts b/src/Gtd/gtdBlock.ts new file mode 100644 index 0000000..f5809e9 --- /dev/null +++ b/src/Gtd/gtdBlock.ts @@ -0,0 +1,75 @@ +const YAML = require("yaml"); + +import { GtdBlockResult, GtdBlock } from "./types"; + +// Matches a fenced code block whose info string is exactly "gtd". +// Tolerates leading indentation, ``` or ~~~ fences, and trailing spaces +// after the info string. Captures the block body. +const GTD_FENCE_REGEX = + /^[ \t]*(```|~~~)gtd[ \t]*\r?\n((?:[\s\S]*?\r?\n)?)[ \t]*\1[ \t]*$/m; + +/** + * Find the first ```gtd block in a note body and parse its YAML. + * + * - No block → { found: false } + * - Empty block → { found: true } with all-null properties + * (a valid opt-in signal per SPEC.md §2.2) + * - Malformed YAML → { found: true, error } — the item still opts in, + * but the problem is surfaced. + */ +export default function extractGtdBlock(body: string): GtdBlockResult { + const match = GTD_FENCE_REGEX.exec(body || ""); + if (!match) return { found: false, block: null, error: null }; + + const content = match[2]; + + let parsed: any = {}; + try { + parsed = YAML.parse(content); + } catch (error) { + return { found: true, block: emptyBlock(), error: String(error) }; + } + + if (parsed === null || parsed === undefined) { + return { found: true, block: emptyBlock(), error: null }; + } + + if (typeof parsed !== "object" || Array.isArray(parsed)) { + return { + found: true, + block: emptyBlock(), + error: "gtd block must contain key: value pairs", + }; + } + + const block: GtdBlock = { + date: stringOrNull(parsed.date), + bgColour: stringOrNull(parsed["bg-colour"] ?? parsed.bgColor), + fgColour: stringOrNull(parsed["fg-colour"]), + icon: stringOrNull(parsed.icon), + title: stringOrNull(parsed.title), + text: stringOrNull(parsed.text), + }; + + return { found: true, block, error: null }; +} + +function emptyBlock(): GtdBlock { + return { + date: null, + bgColour: null, + fgColour: null, + icon: null, + title: null, + text: null, + }; +} + +function stringOrNull(value: any): string | null { + if (value === undefined || value === null) return null; + if (value instanceof Date) { + // The yaml library parses unquoted yyyy-mm-dd as a Date. + return value.toISOString().slice(0, 10); + } + return String(value); +} diff --git a/src/Gtd/parseCalendarConfig.ts b/src/Gtd/parseCalendarConfig.ts new file mode 100644 index 0000000..8597d84 --- /dev/null +++ b/src/Gtd/parseCalendarConfig.ts @@ -0,0 +1,109 @@ +import { CalendarConfig, InclusionMode } from "./types"; + +const VIEW_ALIASES: Record = { + day: "day", + d: "day", + week: "week", + w: "week", + month: "month", + m: "month", +}; + +const INCLUSION_MODES: InclusionMode[] = ["gtd-only", "all", "none"]; + +/** + * Normalise a parsed gtd-calendar YAML object into a CalendarConfig, + * applying SPEC.md defaults. Unknown keys and invalid values produce + * warnings rather than failures. + */ +export default function parseCalendarConfig(raw: any): CalendarConfig { + const warnings: string[] = []; + const input = raw && typeof raw === "object" ? raw : {}; + + const knownKeys = [ + "view", + "title", + "scope", + "notes", + "todos", + "sort", + "sort-type", + ]; + for (const key of Object.keys(input)) { + if (!knownKeys.includes(key)) warnings.push(`Unknown option "${key}"`); + } + + // view + let view: CalendarConfig["view"] = "day"; + if (input.view !== undefined) { + const candidate = String(input.view).toLowerCase(); + if (VIEW_ALIASES[candidate]) { + view = VIEW_ALIASES[candidate]; + } else { + warnings.push(`Invalid view "${input.view}" (using "day")`); + } + } + + // title + const title = + input.title !== undefined && input.title !== null + ? String(input.title) + : null; + + // scope + let scopeDepth = 0; + if (input.scope !== undefined) { + const value = input.scope; + if (value === "this-folder") { + scopeDepth = 0; + } else if (value === "children") { + scopeDepth = Infinity; + } else if (Number.isInteger(Number(value)) && Number(value) >= 0) { + scopeDepth = Number(value); + } else { + warnings.push(`Invalid scope "${value}" (using "this-folder")`); + } + } + + // notes / todos + const notes = parseInclusion(input.notes, "notes", warnings); + const todos = parseInclusion(input.todos, "todos", warnings); + + // sort + let sort: CalendarConfig["sort"] = "asc"; + if (input.sort !== undefined) { + const candidate = String(input.sort).toLowerCase(); + if (candidate === "asc" || candidate === "desc") { + sort = candidate; + } else { + warnings.push(`Invalid sort "${input.sort}" (using "asc")`); + } + } + + // sort-type + let sortType: CalendarConfig["sortType"] = "title"; + if (input["sort-type"] !== undefined) { + const candidate = String(input["sort-type"]).toLowerCase(); + if (candidate === "title" || candidate === "modified_date") { + sortType = candidate as CalendarConfig["sortType"]; + } else { + warnings.push( + `Invalid sort-type "${input["sort-type"]}" (using "title")` + ); + } + } + + return { view, title, scopeDepth, notes, todos, sort, sortType, warnings }; +} + +function parseInclusion( + value: any, + optionName: string, + warnings: string[] +): InclusionMode { + if (value === undefined) return "gtd-only"; + const candidate = String(value).toLowerCase() as InclusionMode; + if (INCLUSION_MODES.includes(candidate)) return candidate; + warnings.push(`Invalid ${optionName} "${value}" (using "gtd-only")`); + return "gtd-only"; +} diff --git a/src/Gtd/resolveDate.ts b/src/Gtd/resolveDate.ts new file mode 100644 index 0000000..7b047d5 --- /dev/null +++ b/src/Gtd/resolveDate.ts @@ -0,0 +1,74 @@ +import { GtdBlock, RawNote } from "./types"; + +// yyyy-mm-dd +const ISO_DATE = /^(\d{4})-(\d{1,2})-(\d{1,2})$/; +// mm-dd-yyyy (upstream Event Calendar parity) +const US_DATE = /^(\d{1,2})-(\d{1,2})-(\d{4})$/; + +/** + * Parse a gtd-block date string into canonical yyyy-mm-dd, or null if + * unparseable/absent. + */ +export function parseGtdDate(value: string | null): string | null { + if (!value) return null; + const trimmed = value.trim(); + + let year: number, month: number, day: number; + + const iso = ISO_DATE.exec(trimmed); + const us = US_DATE.exec(trimmed); + + if (iso) { + year = Number(iso[1]); + month = Number(iso[2]); + day = Number(iso[3]); + } else if (us) { + month = Number(us[1]); + day = Number(us[2]); + year = Number(us[3]); + } else { + return null; + } + + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + + return ( + String(year).padStart(4, "0") + + "-" + + String(month).padStart(2, "0") + + "-" + + String(day).padStart(2, "0") + ); +} + +/** Format a millisecond timestamp as local yyyy-mm-dd. */ +export function timestampToLocalDate(ms: number): string { + const d = new Date(ms); + return ( + String(d.getFullYear()).padStart(4, "0") + + "-" + + String(d.getMonth() + 1).padStart(2, "0") + + "-" + + String(d.getDate()).padStart(2, "0") + ); +} + +/** + * SPEC.md §3.1 date resolution: + * - Todos: todo_due, unless the gtd block provides a date (explicit wins). + * - Notes: gtd block date only. + * - Nothing resolvable → null (item is Unscheduled). + */ +export function resolveEventDate( + note: RawNote, + block: GtdBlock | null +): string | null { + const blockDate = parseGtdDate(block ? block.date : null); + if (blockDate) return blockDate; + + if (note.is_todo && note.todo_due > 0) { + return timestampToLocalDate(note.todo_due); + } + + return null; +} diff --git a/src/Gtd/types.ts b/src/Gtd/types.ts new file mode 100644 index 0000000..601be65 --- /dev/null +++ b/src/Gtd/types.ts @@ -0,0 +1,69 @@ +/** Parsed and normalised ```gtd-calendar config. */ +export interface CalendarConfig { + view: "day" | "week" | "month"; + title: string | null; + /** Folder recursion depth: 0 = this folder only, Infinity = all children. */ + scopeDepth: number; + notes: InclusionMode; + todos: InclusionMode; + sort: "asc" | "desc"; + sortType: "title" | "modified_date"; + /** Non-fatal problems found while parsing, surfaced in the UI. */ + warnings: string[]; +} + +export type InclusionMode = "gtd-only" | "all" | "none"; + +/** A note/todo as fetched from the Joplin data API. */ +export interface RawNote { + id: string; + title: string; + parent_id: string; + is_todo: number; + todo_due: number; + todo_completed: number; + updated_time: number; + body: string; +} + +/** Properties parsed from a ```gtd block. */ +export interface GtdBlock { + date: string | null; + bgColour: string | null; + fgColour: string | null; + icon: string | null; + title: string | null; + text: string | null; +} + +export interface GtdBlockResult { + found: boolean; + block: GtdBlock | null; + error: string | null; +} + +/** Event payload sent to the webview. */ +export interface GtdEvent { + id: string; + title: string; + /** ISO yyyy-mm-dd, or null for unscheduled. */ + date: string | null; + isTodo: boolean; + completed: boolean; + bgColour: string | null; + fgColour: string | null; + icon: string | null; + text: string | null; + updatedTime: number; +} + +export interface RawFolder { + id: string; + parent_id: string; +} + +/** Thin abstraction over the Joplin data API so collectEvents is testable. */ +export interface DataAdapter { + getFolders(): Promise; + getNotesInFolder(folderId: string): Promise; +} diff --git a/src/event-calendar.css b/src/event-calendar.css index 67603cb..48f5bb7 100644 --- a/src/event-calendar.css +++ b/src/event-calendar.css @@ -161,3 +161,14 @@ cursor: pointer; text-decoration: underline; } + +.gtd-calendar-warning { + font-size: 0.85em; + color: #b9770e; + margin: 0.15em 0; +} + +.gtd-calendar-debug-events li { + border-radius: 4px; + padding: 0.1em 0.4em; +} diff --git a/src/gtd-calendar-webview.js b/src/gtd-calendar-webview.js index 0cb90ac..3684803 100644 --- a/src/gtd-calendar-webview.js +++ b/src/gtd-calendar-webview.js @@ -70,22 +70,34 @@ box.className = "gtd-calendar-debug"; const heading = document.createElement("h3"); - heading.textContent = - (payload.title || "GTD Calendar") + " — Phase 1 plumbing test"; + heading.textContent = payload.title || "GTD Calendar"; box.appendChild(heading); const meta = document.createElement("p"); meta.className = "gtd-calendar-debug-meta"; - const noteInfo = payload.sourceNote - ? '"' + payload.sourceNote.title + '" (' + payload.sourceNote.id + ")" - : "unknown"; + const stats = payload.stats + ? " · scanned " + + payload.stats.scannedNotes + + " notes in " + + payload.stats.scannedFolders + + " folder(s)" + : ""; meta.textContent = "view: " + (payload.view || "?") + - " · rendered in note: " + - noteInfo; + stats + + " · " + + (payload.events || []).length + + " event(s) · debug view (Phase 3 brings the calendar grid)"; box.appendChild(meta); + (payload.warnings || []).forEach(function (warning) { + const warn = document.createElement("p"); + warn.className = "gtd-calendar-warning"; + warn.textContent = "⚠ " + warning; + box.appendChild(warn); + }); + if (payload.configError) { const warn = document.createElement("p"); warn.className = "gtd-calendar-error"; @@ -101,7 +113,15 @@ const glyph = event.isTodo ? (event.completed ? "☑ " : "☐ ") : "📄 "; const dateLabel = event.date ? event.date : "unscheduled"; - item.textContent = glyph + event.title + " — " + dateLabel; + item.textContent = + (event.icon ? event.icon + " " : glyph) + + event.title + + " — " + + dateLabel; + if (event.bgColour) item.style.backgroundColor = event.bgColour; + if (event.fgColour) item.style.color = event.fgColour; + if (event.completed) item.style.textDecoration = "line-through"; + if (event.text) item.title = event.text; if (event.id) { item.classList.add("gtd-calendar-clickable"); diff --git a/src/index.ts b/src/index.ts index 8cb7b57..1f0a41c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,10 +3,48 @@ import { ContentScriptType } from "api/types"; const YAML = require("yaml"); +import parseCalendarConfig from "./Gtd/parseCalendarConfig"; +import collectEvents from "./Gtd/collectEvents"; +import { DataAdapter, RawFolder, RawNote } from "./Gtd/types"; + const CONTENT_SCRIPT_ID = "gtd-calendar-renderer"; -function todayISO(): string { - return new Date().toISOString().slice(0, 10); +const NOTE_FIELDS = [ + "id", + "title", + "parent_id", + "is_todo", + "todo_due", + "todo_completed", + "updated_time", + "body", +]; + +/** DataAdapter backed by the Joplin data API, with pagination. */ +const joplinAdapter: DataAdapter = { + async getFolders(): Promise { + return fetchAllPages(["folders"], { + fields: ["id", "parent_id"], + }); + }, + + async getNotesInFolder(folderId: string): Promise { + return fetchAllPages(["folders", folderId, "notes"], { + fields: NOTE_FIELDS, + }); + }, +}; + +async function fetchAllPages(path: string[], query: any): Promise { + const items: T[] = []; + let page = 1; + while (true) { + const response = await joplin.data.get(path, { ...query, page }); + items.push(...(response.items || [])); + if (!response.has_more) break; + page += 1; + } + return items; } joplin.plugins.register({ @@ -35,24 +73,18 @@ joplin.plugins.register({ }, }); -/** - * Phase 1: parse the gtd-calendar config for real, but return hardcoded - * events. Proves: config transit, YAML parsing in the main process, data - * API access (selectedNote), and the response path back to the webview. - * - * Phase 2 replaces the hardcoded events with folder-scoped note/todo - * collection per SPEC.md. - */ async function handleGetEvents(message: { rawConfig?: string }) { - let config: any = {}; + // 1. Parse the gtd-calendar config. + let rawParsed: any = {}; let configError: string | null = null; - try { - config = YAML.parse(message.rawConfig || "") || {}; + rawParsed = YAML.parse(message.rawConfig || "") || {}; } catch (error) { configError = String(error); } + const config = parseCalendarConfig(rawParsed); + // 2. Resolve the calendar note (the note being rendered). let sourceNote: { id: string; title: string; parentId: string } | null = null; try { @@ -65,47 +97,42 @@ async function handleGetEvents(message: { rawConfig?: string }) { }; } } catch (error) { - // Non-fatal in Phase 1; the debug view reports "unknown". console.warn("gtd-calendar: could not resolve selected note", error); } + if (!sourceNote) { + return { + title: config.title, + view: config.view, + configError: + configError || + "Could not determine which note contains this calendar.", + warnings: config.warnings, + sourceNote: null, + events: [], + stats: null, + }; + } + + // 3. Collect events from the folder tree. + const result = await collectEvents( + joplinAdapter, + sourceNote.id, + sourceNote.parentId, + config + ); + return { - phase: 1, - title: typeof config.title === "string" ? config.title : "GTD Calendar", - view: typeof config.view === "string" ? config.view : "day", - config, + title: config.title, + view: config.view, configError, + warnings: [...config.warnings, ...result.warnings], sourceNote, - events: [ - { - id: sourceNote ? sourceNote.id : null, - title: "Drilldown test — opens this very note", - date: todayISO(), - isTodo: false, - completed: false, - }, - { - id: null, - title: "Hardcoded todo", - date: todayISO(), - isTodo: true, - completed: false, - }, - { - id: null, - title: "Hardcoded completed todo", - date: todayISO(), - isTodo: true, - completed: true, - }, - { - id: null, - title: "Hardcoded unscheduled note", - date: null, - isTodo: false, - completed: false, - }, - ], + events: result.events, + stats: { + scannedFolders: result.scannedFolders, + scannedNotes: result.scannedNotes, + }, }; } diff --git a/src/tests/Gtd/gtd.test.ts b/src/tests/Gtd/gtd.test.ts new file mode 100644 index 0000000..049da9a --- /dev/null +++ b/src/tests/Gtd/gtd.test.ts @@ -0,0 +1,350 @@ +import parseCalendarConfig from "../../Gtd/parseCalendarConfig"; +import resolveScopedFolderIds from "../../Gtd/folderScope"; +import extractGtdBlock from "../../Gtd/gtdBlock"; +import { parseGtdDate, resolveEventDate } from "../../Gtd/resolveDate"; +import collectEvents from "../../Gtd/collectEvents"; +import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types"; + +// --------------------------------------------------------------------------- +// parseCalendarConfig +// --------------------------------------------------------------------------- + +describe("parseCalendarConfig", () => { + test("applies SPEC defaults to an empty config", () => { + const config = parseCalendarConfig({}); + expect(config).toMatchObject({ + view: "day", + title: null, + scopeDepth: 0, + notes: "gtd-only", + todos: "gtd-only", + sort: "asc", + sortType: "title", + }); + expect(config.warnings).toHaveLength(0); + }); + + test("parses a full config including scope aliases", () => { + const config = parseCalendarConfig({ + view: "w", + title: "Editorial Schedule", + scope: "children", + notes: "all", + todos: "none", + sort: "desc", + "sort-type": "modified_date", + }); + expect(config.view).toBe("week"); + expect(config.title).toBe("Editorial Schedule"); + expect(config.scopeDepth).toBe(Infinity); + expect(config.notes).toBe("all"); + expect(config.todos).toBe("none"); + expect(config.sort).toBe("desc"); + expect(config.sortType).toBe("modified_date"); + }); + + test("accepts integer scope depth", () => { + expect(parseCalendarConfig({ scope: 2 }).scopeDepth).toBe(2); + expect(parseCalendarConfig({ scope: "3" }).scopeDepth).toBe(3); + expect(parseCalendarConfig({ scope: 0 }).scopeDepth).toBe(0); + }); + + test("warns on invalid values and falls back to defaults", () => { + const config = parseCalendarConfig({ + view: "year", + scope: "everything", + notes: "some", + sort: "sideways", + bogus: true, + }); + expect(config.view).toBe("day"); + expect(config.scopeDepth).toBe(0); + expect(config.notes).toBe("gtd-only"); + expect(config.sort).toBe("asc"); + expect(config.warnings.length).toBeGreaterThanOrEqual(5); + }); +}); + +// --------------------------------------------------------------------------- +// resolveScopedFolderIds +// --------------------------------------------------------------------------- + +const FOLDER_TREE: RawFolder[] = [ + { id: "root", parent_id: "" }, + { id: "blogs", parent_id: "root" }, + { id: "blog-a", parent_id: "blogs" }, + { id: "blog-b", parent_id: "blogs" }, + { id: "blog-a-drafts", parent_id: "blog-a" }, + { id: "unrelated", parent_id: "root" }, +]; + +describe("resolveScopedFolderIds", () => { + test("depth 0 returns only the root folder", () => { + expect(resolveScopedFolderIds(FOLDER_TREE, "blogs", 0)).toEqual([ + "blogs", + ]); + }); + + test("depth 1 includes direct children only", () => { + const ids = resolveScopedFolderIds(FOLDER_TREE, "blogs", 1); + expect(ids.sort()).toEqual(["blog-a", "blog-b", "blogs"]); + }); + + test("Infinity includes all descendants but not siblings", () => { + const ids = resolveScopedFolderIds(FOLDER_TREE, "blogs", Infinity); + expect(ids.sort()).toEqual([ + "blog-a", + "blog-a-drafts", + "blog-b", + "blogs", + ]); + expect(ids).not.toContain("unrelated"); + }); +}); + +// --------------------------------------------------------------------------- +// extractGtdBlock +// --------------------------------------------------------------------------- + +describe("extractGtdBlock", () => { + test("returns found: false when there is no gtd block", () => { + const result = extractGtdBlock("# Just a note\n\n```js\ncode\n```\n"); + expect(result.found).toBe(false); + }); + + test("an empty gtd block is a valid opt-in", () => { + const result = extractGtdBlock("Before\n\n```gtd\n```\n\nAfter"); + expect(result.found).toBe(true); + expect(result.error).toBeNull(); + expect(result.block).toMatchObject({ date: null, bgColour: null }); + }); + + test("parses properties including colour spellings and date", () => { + const body = [ + "# Post idea", + "", + "```gtd", + "date: 2026-07-04", + "bg-colour: teal", + "fg-colour: white", + "icon: 🦴", + "title: Override", + "```", + ].join("\n"); + const result = extractGtdBlock(body); + expect(result.found).toBe(true); + expect(result.block).toMatchObject({ + date: "2026-07-04", + bgColour: "teal", + fgColour: "white", + icon: "🦴", + title: "Override", + }); + }); + + test("accepts upstream bgColor spelling", () => { + const result = extractGtdBlock("```gtd\nbgColor: red\n```"); + expect(result.block!.bgColour).toBe("red"); + }); + + test("does not match a gtd-calendar block", () => { + const result = extractGtdBlock("```gtd-calendar\nview: week\n```"); + expect(result.found).toBe(false); + }); + + test("surfaces malformed YAML as an error while still opting in", () => { + const result = extractGtdBlock("```gtd\n[: not yaml\n```"); + expect(result.found).toBe(true); + expect(result.error).not.toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// date resolution +// --------------------------------------------------------------------------- + +function makeNote(overrides: Partial): RawNote { + return { + id: "note-id", + title: "A note", + parent_id: "root", + is_todo: 0, + todo_due: 0, + todo_completed: 0, + updated_time: 0, + body: "", + ...overrides, + }; +} + +describe("date resolution", () => { + test("parseGtdDate handles iso and us formats", () => { + expect(parseGtdDate("2026-07-04")).toBe("2026-07-04"); + expect(parseGtdDate("7-4-2026")).toBe("2026-07-04"); + expect(parseGtdDate("07-04-2026")).toBe("2026-07-04"); + expect(parseGtdDate("not a date")).toBeNull(); + expect(parseGtdDate("2026-13-01")).toBeNull(); + expect(parseGtdDate(null)).toBeNull(); + }); + + test("todo uses todo_due when no gtd date", () => { + const due = new Date(2026, 6, 4, 12, 0).getTime(); // local Jul 4 2026 + const note = makeNote({ is_todo: 1, todo_due: due }); + expect(resolveEventDate(note, null)).toBe("2026-07-04"); + }); + + test("gtd block date overrides todo_due", () => { + const due = new Date(2026, 6, 4).getTime(); + const note = makeNote({ is_todo: 1, todo_due: due }); + const block = { + date: "2026-08-01", + bgColour: null, + fgColour: null, + icon: null, + title: null, + text: null, + }; + expect(resolveEventDate(note, block)).toBe("2026-08-01"); + }); + + test("note without gtd date is unscheduled", () => { + const note = makeNote({}); + expect(resolveEventDate(note, null)).toBeNull(); + }); + + test("todo without due date is unscheduled", () => { + const note = makeNote({ is_todo: 1, todo_due: 0 }); + expect(resolveEventDate(note, null)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// collectEvents (inclusion matrix + sorting, via a mock adapter) +// --------------------------------------------------------------------------- + +function makeAdapter(folders: RawFolder[], notes: RawNote[]): DataAdapter { + return { + getFolders: async () => folders, + getNotesInFolder: async (folderId: string) => + notes.filter((n) => n.parent_id === folderId), + }; +} + +const GTD_BODY = "```gtd\ndate: 2026-07-10\n```"; + +describe("collectEvents", () => { + const folders: RawFolder[] = [ + { id: "blogs", parent_id: "root" }, + { id: "blog-a", parent_id: "blogs" }, + ]; + + const notes: RawNote[] = [ + makeNote({ + id: "calendar-note", + title: "The Calendar", + parent_id: "blogs", + }), + makeNote({ + id: "n1", + title: "Gtd note", + parent_id: "blogs", + body: GTD_BODY, + updated_time: 300, + }), + makeNote({ + id: "n2", + title: "Plain note", + parent_id: "blogs", + updated_time: 100, + }), + makeNote({ + id: "t1", + title: "Plain todo", + parent_id: "blogs", + is_todo: 1, + todo_due: new Date(2026, 6, 11).getTime(), + updated_time: 200, + }), + makeNote({ + id: "n3", + title: "Child-folder gtd note", + parent_id: "blog-a", + body: GTD_BODY, + }), + ]; + + function defaults(overrides: any = {}) { + return parseCalendarConfig(overrides); + } + + test("defaults: gtd-only for both, this-folder scope, calendar note excluded", async () => { + const result = await collectEvents( + makeAdapter(folders, notes), + "calendar-note", + "blogs", + defaults() + ); + expect(result.events.map((e) => e.id)).toEqual(["n1"]); + }); + + test("notes: all admits plain notes (unscheduled)", async () => { + const result = await collectEvents( + makeAdapter(folders, notes), + "calendar-note", + "blogs", + defaults({ notes: "all" }) + ); + const ids = result.events.map((e) => e.id).sort(); + expect(ids).toEqual(["n1", "n2"]); + const plain = result.events.find((e) => e.id === "n2")!; + expect(plain.date).toBeNull(); + }); + + test("todos: all admits plain todos with their due dates", async () => { + const result = await collectEvents( + makeAdapter(folders, notes), + "calendar-note", + "blogs", + defaults({ todos: "all" }) + ); + const todo = result.events.find((e) => e.id === "t1")!; + expect(todo.isTodo).toBe(true); + expect(todo.date).toBe("2026-07-11"); + }); + + test("notes: none excludes even gtd notes", async () => { + const result = await collectEvents( + makeAdapter(folders, notes), + "calendar-note", + "blogs", + defaults({ notes: "none", todos: "all" }) + ); + expect(result.events.map((e) => e.id)).toEqual(["t1"]); + }); + + test("scope: children reaches child folders", async () => { + const result = await collectEvents( + makeAdapter(folders, notes), + "calendar-note", + "blogs", + defaults({ scope: "children" }) + ); + const ids = result.events.map((e) => e.id).sort(); + expect(ids).toEqual(["n1", "n3"]); + }); + + test("sorting: desc by modified_date", async () => { + const result = await collectEvents( + makeAdapter(folders, notes), + "calendar-note", + "blogs", + defaults({ + notes: "all", + todos: "all", + sort: "desc", + "sort-type": "modified_date", + }) + ); + expect(result.events.map((e) => e.id)).toEqual(["n1", "t1", "n2"]); + }); +});