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;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
121
src/index.ts
121
src/index.ts
@ -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
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