Phase 2: real data layer — folder scope, gtd blocks, inclusion matrix

- 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)
This commit is contained in:
Victor Wiebe 2026-06-12 22:01:13 +00:00 committed by vwiebe
parent 16242a2f44
commit fc1b2fcabf
10 changed files with 939 additions and 56 deletions

112
src/Gtd/collectEvents.ts Normal file
View File

@ -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<CollectResult> {
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;
});
}

36
src/Gtd/folderScope.ts Normal file
View File

@ -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<string, string[]>();
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;
}

75
src/Gtd/gtdBlock.ts Normal file
View File

@ -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);
}

View File

@ -0,0 +1,109 @@
import { CalendarConfig, InclusionMode } from "./types";
const VIEW_ALIASES: Record<string, CalendarConfig["view"]> = {
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";
}

74
src/Gtd/resolveDate.ts Normal file
View File

@ -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;
}

69
src/Gtd/types.ts Normal file
View File

@ -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<RawFolder[]>;
getNotesInFolder(folderId: string): Promise<RawNote[]>;
}

View File

@ -161,3 +161,14 @@
cursor: pointer; cursor: pointer;
text-decoration: underline; 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;
}

View File

@ -70,22 +70,34 @@
box.className = "gtd-calendar-debug"; box.className = "gtd-calendar-debug";
const heading = document.createElement("h3"); const heading = document.createElement("h3");
heading.textContent = heading.textContent = payload.title || "GTD Calendar";
(payload.title || "GTD Calendar") + " — Phase 1 plumbing test";
box.appendChild(heading); box.appendChild(heading);
const meta = document.createElement("p"); const meta = document.createElement("p");
meta.className = "gtd-calendar-debug-meta"; meta.className = "gtd-calendar-debug-meta";
const noteInfo = payload.sourceNote const stats = payload.stats
? '"' + payload.sourceNote.title + '" (' + payload.sourceNote.id + ")" ? " · scanned " +
: "unknown"; payload.stats.scannedNotes +
" notes in " +
payload.stats.scannedFolders +
" folder(s)"
: "";
meta.textContent = meta.textContent =
"view: " + "view: " +
(payload.view || "?") + (payload.view || "?") +
" · rendered in note: " + stats +
noteInfo; " · " +
(payload.events || []).length +
" event(s) · debug view (Phase 3 brings the calendar grid)";
box.appendChild(meta); 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) { if (payload.configError) {
const warn = document.createElement("p"); const warn = document.createElement("p");
warn.className = "gtd-calendar-error"; warn.className = "gtd-calendar-error";
@ -101,7 +113,15 @@
const glyph = event.isTodo ? (event.completed ? "☑ " : "☐ ") : "📄 "; const glyph = event.isTodo ? (event.completed ? "☑ " : "☐ ") : "📄 ";
const dateLabel = event.date ? event.date : "unscheduled"; 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) { if (event.id) {
item.classList.add("gtd-calendar-clickable"); item.classList.add("gtd-calendar-clickable");

View File

@ -3,10 +3,48 @@ import { ContentScriptType } from "api/types";
const YAML = require("yaml"); 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"; const CONTENT_SCRIPT_ID = "gtd-calendar-renderer";
function todayISO(): string { const NOTE_FIELDS = [
return new Date().toISOString().slice(0, 10); "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<RawFolder[]> {
return fetchAllPages<RawFolder>(["folders"], {
fields: ["id", "parent_id"],
});
},
async getNotesInFolder(folderId: string): Promise<RawNote[]> {
return fetchAllPages<RawNote>(["folders", folderId, "notes"], {
fields: NOTE_FIELDS,
});
},
};
async function fetchAllPages<T>(path: string[], query: any): Promise<T[]> {
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({ 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 }) { async function handleGetEvents(message: { rawConfig?: string }) {
let config: any = {}; // 1. Parse the gtd-calendar config.
let rawParsed: any = {};
let configError: string | null = null; let configError: string | null = null;
try { try {
config = YAML.parse(message.rawConfig || "") || {}; rawParsed = YAML.parse(message.rawConfig || "") || {};
} catch (error) { } catch (error) {
configError = String(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 = let sourceNote: { id: string; title: string; parentId: string } | null =
null; null;
try { try {
@ -65,47 +97,42 @@ async function handleGetEvents(message: { rawConfig?: string }) {
}; };
} }
} catch (error) { } catch (error) {
// Non-fatal in Phase 1; the debug view reports "unknown".
console.warn("gtd-calendar: could not resolve selected note", error); console.warn("gtd-calendar: could not resolve selected note", error);
} }
if (!sourceNote) {
return { return {
phase: 1, title: config.title,
title: typeof config.title === "string" ? config.title : "GTD Calendar", view: config.view,
view: typeof config.view === "string" ? config.view : "day", configError:
config, 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 {
title: config.title,
view: config.view,
configError, configError,
warnings: [...config.warnings, ...result.warnings],
sourceNote, sourceNote,
events: [ events: result.events,
{ stats: {
id: sourceNote ? sourceNote.id : null, scannedFolders: result.scannedFolders,
title: "Drilldown test — opens this very note", scannedNotes: result.scannedNotes,
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,
},
],
}; };
} }

350
src/tests/Gtd/gtd.test.ts Normal file
View File

@ -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>): 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"]);
});
});