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:
parent
16242a2f44
commit
fc1b2fcabf
112
src/Gtd/collectEvents.ts
Normal file
112
src/Gtd/collectEvents.ts
Normal 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
36
src/Gtd/folderScope.ts
Normal 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
75
src/Gtd/gtdBlock.ts
Normal 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);
|
||||
}
|
||||
109
src/Gtd/parseCalendarConfig.ts
Normal file
109
src/Gtd/parseCalendarConfig.ts
Normal 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
74
src/Gtd/resolveDate.ts
Normal 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
69
src/Gtd/types.ts
Normal 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[]>;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
121
src/index.ts
121
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<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({
|
||||
@ -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 {
|
||||
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:
|
||||
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,
|
||||
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,
|
||||
events: result.events,
|
||||
stats: {
|
||||
scannedFolders: result.scannedFolders,
|
||||
scannedNotes: result.scannedNotes,
|
||||
},
|
||||
{
|
||||
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
350
src/tests/Gtd/gtd.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user