From 814845ef24f1fe1e5622ec30ff68f58d13fa7b66 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 26 Jul 2026 14:46:59 +1000 Subject: [PATCH 1/4] fix empty frontmatter list normalization --- src/mapping.ts | 9 +++++++-- test/model.test.mjs | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/mapping.ts b/src/mapping.ts index 38fa60f..05f0365 100644 --- a/src/mapping.ts +++ b/src/mapping.ts @@ -54,7 +54,9 @@ export function getFrontmatterTags(value: unknown): string[] { const tags: string[] = []; const seen = new Set(); const addTag = (entry: unknown): void => { - const normalized = normalizeFrontmatterTag(String(entry)); + const normalizedValue = normalizeStringValue(entry); + if (!normalizedValue) return; + const normalized = normalizeFrontmatterTag(normalizedValue); if (!normalized || seen.has(normalized)) return; seen.add(normalized); tags.push(normalized); @@ -518,7 +520,10 @@ export function readAssigneeIds(value: unknown): string[] | undefined { } function normalizeStringArrayValue(value: unknown): string[] { - return Array.isArray(value) ? value.map(String) : [String(value)]; + const values = Array.isArray(value) ? value : [value]; + return values + .map(normalizeStringValue) + .filter((entry): entry is string => entry !== undefined); } function normalizeNumberValue(value: unknown): number | undefined { diff --git a/test/model.test.mjs b/test/model.test.mjs index a7ce3f8..520c662 100644 --- a/test/model.test.mjs +++ b/test/model.test.mjs @@ -70,6 +70,23 @@ test("maps TaskNotes frontmatter to normalized task data", () => { assert.deepEqual(task.attachments, ["[[Attachments/receipt.jpg]]"]); }); +test("maps empty list-like frontmatter values to empty arrays", () => { + const task = mapTaskFromFrontmatter( + DEFAULT_FIELD_MAPPING, + { + title: "Empty list fields", + contexts: null, + projects: null, + tags: ["", null, "task"], + }, + "Tasks/Empty list fields.md" + ); + + assert.deepEqual(task.contexts, []); + assert.deepEqual(task.projects, []); + assert.deepEqual(task.tags, ["task"]); +}); + test("denormalizes task data to configured frontmatter", () => { const frontmatter = mapTaskToFrontmatter(DEFAULT_FIELD_MAPPING, { title: "Ship model", From 947ccfdc6a0b9bb0372fb9ffce9c98a68a7443da Mon Sep 17 00:00:00 2001 From: callumalpass Date: Fri, 2 Oct 2026 23:47:34 +1000 Subject: [PATCH 2/4] test: cover nullable list normalization across callers --- test/nullable-lists.test.mjs | 68 ++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 test/nullable-lists.test.mjs diff --git a/test/nullable-lists.test.mjs b/test/nullable-lists.test.mjs new file mode 100644 index 0000000..056de5b --- /dev/null +++ b/test/nullable-lists.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +const { DEFAULT_FIELD_MAPPING, mapTaskFromFrontmatter, mapTaskToFrontmatter, getFrontmatterTags } = await import("../dist/esm/index.js"); + +// Independent coverage of every list normalizer caller and explicit user values. +test("maps empty list-like frontmatter values to empty arrays", () => { + const task = mapTaskFromFrontmatter( + DEFAULT_FIELD_MAPPING, + { + title: "Empty list fields", + contexts: null, + projects: null, + tags: ["", null, "task"], + }, + "Tasks/Empty list fields.md" + ); + assert.deepEqual(task.contexts, []); + assert.deepEqual(task.projects, []); + assert.deepEqual(task.tags, ["task"]); +}); + +for (const value of [null, "", " ", [], [null, undefined, "", "\t"]]) { + test(`nullable contexts/projects/ICS IDs/moved dates: ${JSON.stringify(value)}`, () => { + const task = mapTaskFromFrontmatter(DEFAULT_FIELD_MAPPING, { + contexts: value, projects: value, + [DEFAULT_FIELD_MAPPING.icsEventId]: value, + [DEFAULT_FIELD_MAPPING.googleCalendarMovedOriginalDates]: value, + }, "Tasks/Empty.md"); + assert.deepEqual(task.contexts, []); + assert.deepEqual(task.projects, []); + assert.deepEqual(task.icsEventId, []); + assert.deepEqual(task.googleCalendarMovedOriginalDates, []); + const saved = mapTaskToFrontmatter(DEFAULT_FIELD_MAPPING, task); + assert.ok(!JSON.stringify(saved).includes('"null"')); + }); +} + +test("retains explicit literal null/undefined strings, numbers/booleans, links and Unicode", () => { + const values = ["null", "undefined", "Deep Work 🧠", "[[Joe Smith]]", 0, false, 42]; + const task = mapTaskFromFrontmatter(DEFAULT_FIELD_MAPPING, { contexts: values, projects: values }, "Tasks/Mixed.md"); + assert.deepEqual(task.contexts, values.map(String)); + assert.deepEqual(task.projects, values.map(String)); +}); + +test("removes only invalid/empty entries in mixed lists", () => { + const task = mapTaskFromFrontmatter(DEFAULT_FIELD_MAPPING, { + contexts: [null, undefined, "", " ", {}, "work", "Deep Work 🧠"], + projects: [null, "[[Project]]", false, 0], + }, "Tasks/Mixed.md"); + assert.deepEqual(task.contexts, ["work", "Deep Work 🧠"]); + assert.deepEqual(task.projects, ["[[Project]]", "false", "0"]); +}); + +test("tags drop nullable entries but preserve normalization, deduplication and archive detection", () => { + assert.deepEqual(getFrontmatterTags([null, undefined, {}, "", " ", "#task", "task", 0, false, "null", "Deep Work 🧠"]), + ["task", "0", "false", "null", "Deep-Work-🧠"]); + const task = mapTaskFromFrontmatter(DEFAULT_FIELD_MAPPING, { tags: [null, "#archived"] }, "Tasks/Archived.md"); + assert.equal(task.archived, true); + assert.deepEqual(task.tags, ["archived"]); +}); + +test("uses configured list property names and leaves custom properties alone", () => { + const mapping = { ...DEFAULT_FIELD_MAPPING, contexts: "my_contexts", projects: "my_projects" }; + const task = mapTaskFromFrontmatter(mapping, { my_contexts: null, my_projects: null, custom: null }, "Tasks/Custom.md", false, [{ key: "custom" }]); + assert.deepEqual(task.contexts, []); + assert.deepEqual(task.projects, []); + assert.equal(task.customProperties.custom, null); +}); From 6f805b3396ac7b8e84da66ea2987c31aa3b7c956 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Fri, 2 Oct 2026 23:47:36 +1000 Subject: [PATCH 3/4] fix: preserve RFC recurrence lines and clocks when updating DTSTART --- src/recurrence.ts | 44 +++++++++++++++++------- test/recurrence-dtstart.test.mjs | 58 ++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 test/recurrence-dtstart.test.mjs diff --git a/src/recurrence.ts b/src/recurrence.ts index 653e8a0..8c05c34 100644 --- a/src/recurrence.ts +++ b/src/recurrence.ts @@ -17,8 +17,19 @@ const rruleDefault = Reflect.get(rrulePackage, "default") as | undefined; const RRule = ((rrulePackage as typeof import("rrule")).RRule ?? rruleDefault?.RRule) as typeof import("rrule").RRule; +const rrulestr = ((rrulePackage as typeof import("rrule")).rrulestr ?? + rruleDefault?.rrulestr) as typeof import("rrule").rrulestr; const MAX_FINITE_INSTANCE_COUNT = 10000; +// DTSTART's value ends at a semicolon or line break, never in the RRULE. +const DTSTART_PATTERN = /DTSTART(?:;[^:\r\n]+)?:([^;\r\n]+)/; + +function recurrenceRuleText(recurrence: string): string { + const withoutStart = recurrence.replace(DTSTART_PATTERN, "").replace(/^;/, "").trim(); + const ruleLine = withoutStart.split(/\r?\n/).find((line) => line.startsWith("RRULE:")); + return ruleLine ? ruleLine.slice("RRULE:".length) : withoutStart; +} + type RRuleInstance = { between(start: Date, end: Date, inclusive?: boolean): Date[]; after(date: Date, inclusive?: boolean): Date | null; @@ -266,7 +277,7 @@ export function getRecurrenceDisplayText(recurrence: string): string { if (!recurrence.includes("FREQ=")) { return "rrule"; } - const rruleString = recurrence.replace(/DTSTART:[^;]+;?/, ""); + const rruleString = recurrenceRuleText(recurrence); return RRule.fromString(rruleString).toText(); } catch { return "rrule"; @@ -277,7 +288,7 @@ export function addDTSTARTToRecurrenceRule( task: Pick ): string | null { if (!task.recurrence || typeof task.recurrence !== "string") return null; - if (task.recurrence.includes("DTSTART:")) return task.recurrence; + if (DTSTART_PATTERN.test(task.recurrence)) return task.recurrence; const sourceDateString = task.scheduled || task.dateCreated; if (!sourceDateString) return null; return `DTSTART:${formatDtstartValue(sourceDateString)};${task.recurrence}`; @@ -286,8 +297,13 @@ export function addDTSTARTToRecurrenceRule( export function updateDTSTARTInRecurrenceRule(recurrence: string, dateStr: string): string | null { if (!recurrence || typeof recurrence !== "string") return null; const dtstartValue = formatDtstartValue(dateStr); - if (recurrence.includes("DTSTART:")) { - return recurrence.replace(/DTSTART:[^;]+;?/, `DTSTART:${dtstartValue};`); + if (DTSTART_PATTERN.test(recurrence)) { + return recurrence.replace(DTSTART_PATTERN, (property, previousValue: string) => { + // Completion supplies a date, not a new clock. Retain the original + // floating/UTC time and property parameters (TZID or VALUE=DATE). + const clock = !hasTimeComponent(dateStr) ? previousValue.match(/T\d{6}Z?$/)?.[0] ?? "" : ""; + return property.slice(0, property.length - previousValue.length) + dtstartValue + clock; + }); } return `DTSTART:${dtstartValue};${recurrence}`; } @@ -298,7 +314,7 @@ export function addDTSTARTToRecurrenceRuleWithDraggedTime( allDay: boolean ): string | null { if (!task.recurrence || typeof task.recurrence !== "string") return null; - if (task.recurrence.includes("DTSTART:")) return task.recurrence; + if (DTSTART_PATTERN.test(task.recurrence)) return task.recurrence; const sourceDateString = task.scheduled || task.dateCreated; if (!sourceDateString) return null; @@ -370,7 +386,13 @@ function createRRule( if (!task.recurrence || typeof task.recurrence !== "string") return null; const dtstart = getRRuleDtstart(task); if (!dtstart) return null; - const rruleString = task.recurrence.replace(/DTSTART:[^;]+;?/, "").replace(/^;/, "").trim(); + if (/\r?\n/.test(task.recurrence)) { + // rrulestr does not recognize VALUE=DATE on DTSTART; normalize only + // its parser input, leaving the stored RFC property unchanged. + const parserInput = task.recurrence.replace(/DTSTART;VALUE=DATE:/, "DTSTART:"); + return rrulestr(parserInput, { dtstart }); + } + const rruleString = recurrenceRuleText(task.recurrence); const rruleOptions = RRule.parseString(rruleString); rruleOptions.dtstart = dtstart; return new RRule(rruleOptions); @@ -388,7 +410,7 @@ function getRRuleDtstart( } function parseDtstartFromRecurrence(recurrence: string): Date | null { - const match = recurrence.match(/DTSTART:(\d{8}(?:T\d{6}Z?)?)/); + const match = recurrence.match(/DTSTART(?:;[^:\r\n]+)?:(\d{8}(?:T\d{6}Z?)?)/); if (!match) return null; return parseRRuleDateValue(match[1], false); } @@ -574,12 +596,8 @@ function getNextOccurrenceDate( function buildRRuleFromRecurrence(recurrence: string, sourceDate: string): RRuleInstance | null { try { - const rruleString = recurrence.replace(/DTSTART:[^;]+;?/, "").replace(/^;/, "").trim(); - if (!rruleString.includes("FREQ=")) return null; - const options = RRule.parseString(rruleString); - const dtstart = parseDtstartFromRecurrence(recurrence) || parseDateString(sourceDate); - if (dtstart) options.dtstart = dtstart; - return new RRule(options); + if (!recurrence.includes("FREQ=")) return null; + return createRRule({ recurrence, scheduled: sourceDate }); } catch { return null; } diff --git a/test/recurrence-dtstart.test.mjs b/test/recurrence-dtstart.test.mjs new file mode 100644 index 0000000..096d449 --- /dev/null +++ b/test/recurrence-dtstart.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + completeRecurringTask, + generateRecurringInstances, + getRecurrenceDisplayText, + updateDTSTARTInRecurrenceRule, +} from "../dist/esm/index.js"; + +const rule = "FREQ=WEEKLY;BYDAY=MO,WE,FR"; +const date = (value) => new Date(`${value}T00:00:00Z`); +const expand = (recurrence) => generateRecurringInstances( + { recurrence }, date("2026-01-01"), date("2026-01-16") +).map((value) => value.toISOString().slice(0, 10)); + +for (const separator of [";", "\nRRULE:", "\r\nRRULE:"]) { + test(`DTSTART replacement preserves frequency, delimiter and time (${JSON.stringify(separator)})`, () => { + const original = `DTSTART:20260101T090000Z${separator}${rule}`; + const updated = updateDTSTARTInRecurrenceRule(original, "2026-01-05"); + assert.equal(updated, `DTSTART:20260105T090000Z${separator}${rule}`); + assert.equal(getRecurrenceDisplayText(original), "every week on Monday, Wednesday, Friday"); + assert.equal(getRecurrenceDisplayText(updated), getRecurrenceDisplayText(original)); + assert.deepEqual(expand(original), ["2026-01-02", "2026-01-05", "2026-01-07", "2026-01-09", "2026-01-12", "2026-01-14", "2026-01-16"]); + assert.deepEqual(expand(updated), expand(original).slice(1)); + }); +} + +test("completion-anchor round trip retains the RFC rule and clock", () => { + const recurrence = `DTSTART:20260101T090000Z\nRRULE:${rule}`; + const completed = completeRecurringTask({ + recurrence, recurrenceAnchor: "completion", completionDate: "2026-01-05", + scheduled: "2026-01-05T09:00:00", completeInstances: [], + }); + assert.equal(completed.updatedRecurrence, `DTSTART:20260105T090000Z\nRRULE:${rule}`); + assert.equal(completed.nextScheduled, "2026-01-07T09:00:00"); + assert.deepEqual(expand(completed.updatedRecurrence), expand(recurrence).slice(1)); +}); + +test("date-only DTSTART remains date-only and keeps RDATE/EXDATE lines", () => { + const tail = `\nRRULE:${rule}\nRDATE:20260106\nEXDATE:20260107`; + const updated = updateDTSTARTInRecurrenceRule(`DTSTART;VALUE=DATE:20260101${tail}`, "2026-01-05"); + assert.equal(updated, `DTSTART;VALUE=DATE:20260105${tail}`); + assert.equal(getRecurrenceDisplayText(updated), "every week on Monday, Wednesday, Friday"); + assert.deepEqual(expand(updated), ["2026-01-05", "2026-01-06", "2026-01-09", "2026-01-12", "2026-01-14", "2026-01-16"]); +}); + +test("preserves TZID, floating clock and all additional recurrence lines", () => { + const original = `DTSTART;TZID=Europe/London:20260101T093015\nRRULE:${rule}\nRDATE;TZID=Europe/London:20260106T093015\nEXDATE;TZID=Europe/London:20260107T093015`; + const updated = updateDTSTARTInRecurrenceRule(original, "2026-01-05"); + assert.equal(updated, original.replace("20260101", "20260105")); + assert.deepEqual(expand(updated), ["2026-01-05", "2026-01-06", "2026-01-09", "2026-01-12", "2026-01-14", "2026-01-16"]); +}); + +test("explicit timestamp updates replace the clock but never the surrounding rule", () => { + const recurrence = `DTSTART:20260101T090000Z\nRRULE:${rule}`; + assert.equal(updateDTSTARTInRecurrenceRule(recurrence, "2026-01-05T14:30:00"), + `DTSTART:20260105T143000Z\nRRULE:${rule}`); +}); From 96adde9f143f6a6dd258dc2788a826c01afa531a Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sat, 3 Oct 2026 00:07:23 +1000 Subject: [PATCH 4/4] release 0.3.0-rc.16 --- CHANGELOG.md | 8 ++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ef7f698 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## 0.3.0-rc.16 + +### Fixed + +- (TaskNotes #2148) Empty or null list properties no longer become literal `null` entries in contexts, projects or tags. Explicitly authored `"null"` strings remain unchanged. Thanks to @minchinweb for reporting this. +- Updating a completion-anchored DTSTART preserves RFC multiline recurrence rules, inclusion/exclusion date lines, timezone parameters and the original clock when only the date changes. Weekly rules retain their frequency and display label after completion; legacy semicolon rules remain supported. diff --git a/package-lock.json b/package-lock.json index 274a6ed..b407de3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.15", + "version": "0.3.0-rc.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tasknotes/model", - "version": "0.3.0-rc.15", + "version": "0.3.0-rc.16", "license": "MIT", "dependencies": { "rrule": "^2.8.1", diff --git a/package.json b/package.json index 9fd3579..a08caab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.15", + "version": "0.3.0-rc.16", "description": "TaskNotes model, mapping, validation, recurrence, and operation-planning reference implementation.", "license": "MIT", "type": "module",