Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
9 changes: 7 additions & 2 deletions src/mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ export function getFrontmatterTags(value: unknown): string[] {
const tags: string[] = [];
const seen = new Set<string>();
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);
Expand Down Expand Up @@ -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 {
Expand Down
44 changes: 31 additions & 13 deletions src/recurrence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand All @@ -277,7 +288,7 @@ export function addDTSTARTToRecurrenceRule(
task: Pick<RecurringTaskLike, "recurrence" | "scheduled" | "dateCreated">
): 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}`;
Expand All @@ -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}`;
}
Expand All @@ -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;

Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Expand Down Expand Up @@ -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;
}
Expand Down
17 changes: 17 additions & 0 deletions test/model.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
68 changes: 68 additions & 0 deletions test/nullable-lists.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
58 changes: 58 additions & 0 deletions test/recurrence-dtstart.test.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
});
Loading