Extract core logic and add conformance waivers

This commit is contained in:
callumalpass 2026-03-31 05:41:55 +11:00
parent 6c3c7ba4bb
commit 594ee77f67
15 changed files with 2059 additions and 910 deletions

33
conformance/README.md Normal file
View file

@ -0,0 +1,33 @@
# Conformance Waivers
`npm run conformance:test` in `tasknotes` can skip specific `tasknotes-spec` fixture IDs without editing the shared spec repo.
Configure waivers in [waivers.json](/home/calluma/projects/tasknotes/conformance/waivers.json).
Each waiver must include:
- `id`: fixture id such as `link.0028`
- `scope`: short category such as `host-controlled`, `known-deviation`, or `spec-gap`
- `reason`: short human-readable summary
- `justification`: why the fixture is not currently actionable for TaskNotes
Example:
```json
{
"waivers": [
{
"id": "link.0028",
"scope": "host-controlled",
"reason": "Ambiguous simple-name wikilink resolution is delegated to Obsidian",
"justification": "TaskNotes passes link paths to metadataCache.getFirstLinkpathDest and does not own the final candidate tie-break policy."
}
]
}
```
Behavior:
- The local runner filters waived fixtures after generation and restores the spec fixtures afterward.
- The waiver summary is printed before the test run.
- Waiver ids must match generated fixture ids, and every waiver must include a justification.

10
conformance/waivers.json Normal file
View file

@ -0,0 +1,10 @@
{
"waivers": [
{
"id": "link.0028",
"scope": "host-controlled",
"reason": "Ambiguous simple-name wikilink resolution is delegated to Obsidian",
"justification": "TaskNotes parses link text and passes the target to metadataCache.getFirstLinkpathDest, but it does not own Obsidian's final candidate tie-break behavior for ambiguous basename matches."
}
]
}

View file

@ -1,4 +1,4 @@
import { existsSync } from "node:fs";
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
@ -7,6 +7,7 @@ const scriptDir = dirname(fileURLToPath(import.meta.url));
const tasknotesRoot = resolve(scriptDir, "..");
const specRoot = resolve(tasknotesRoot, "../tasknotes-spec");
const adapterPath = resolve(specRoot, "conformance/adapters/tasknotes.adapter.mjs");
const defaultWaiverPath = resolve(tasknotesRoot, "conformance/waivers.json");
function requirePath(path, label) {
if (!existsSync(path)) {
@ -16,22 +17,135 @@ function requirePath(path, label) {
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
return spawnSync(command, args, {
cwd: options.cwd,
env: options.env ? { ...process.env, ...options.env } : process.env,
stdio: "inherit",
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
function loadWaivers(path) {
if (!existsSync(path)) {
return [];
}
const parsed = JSON.parse(readFileSync(path, "utf8"));
const waivers = Array.isArray(parsed?.waivers) ? parsed.waivers : [];
return waivers.map((entry, index) => {
const id = typeof entry?.id === "string" ? entry.id.trim() : "";
const reason = typeof entry?.reason === "string" ? entry.reason.trim() : "";
const justification = typeof entry?.justification === "string" ? entry.justification.trim() : "";
const scope = typeof entry?.scope === "string" ? entry.scope.trim() : "unspecified";
if (!id) {
throw new Error(`Invalid waiver at index ${index}: missing id`);
}
if (!reason) {
throw new Error(`Invalid waiver ${id}: missing reason`);
}
if (!justification) {
throw new Error(`Invalid waiver ${id}: missing justification`);
}
return { id, reason, justification, scope };
});
}
function applyFixtureWaivers(root, waivers) {
if (waivers.length === 0) {
return { restore() {}, removedCount: 0 };
}
const fixturesDir = resolve(root, "conformance/fixtures");
const waiverIds = new Map(waivers.map((waiver) => [waiver.id, waiver]));
const unmatched = new Set(waiverIds.keys());
const backups = [];
let removedCount = 0;
for (const file of readdirSync(fixturesDir)) {
if (!file.endsWith(".json")) continue;
const path = resolve(fixturesDir, file);
const original = readFileSync(path, "utf8");
backups.push({ path, original });
const fixtures = JSON.parse(original);
if (!Array.isArray(fixtures)) {
continue;
}
const filtered = fixtures.filter((fixture) => {
const id = typeof fixture?.id === "string" ? fixture.id : "";
const shouldKeep = !waiverIds.has(id);
if (!shouldKeep) {
unmatched.delete(id);
removedCount += 1;
}
return shouldKeep;
});
if (filtered.length !== fixtures.length) {
writeFileSync(path, `${JSON.stringify(filtered, null, 2)}\n`);
}
}
if (unmatched.size > 0) {
for (const backup of backups) {
writeFileSync(backup.path, backup.original);
}
throw new Error(
`Waiver ids not found in generated fixtures: ${[...unmatched].sort().join(", ")}`,
);
}
return {
removedCount,
restore() {
for (const backup of backups) {
writeFileSync(backup.path, backup.original);
}
},
};
}
requirePath(specRoot, "tasknotes-spec repo");
requirePath(resolve(specRoot, "package.json"), "tasknotes-spec package.json");
run("npm", ["run", "conformance:generate"], { cwd: specRoot });
run("npm", ["run", "conformance:build:tasknotes-bridge"], { cwd: specRoot });
run("npm", ["run", "conformance:test"], {
cwd: specRoot,
env: { TASKNOTES_ADAPTER: adapterPath },
});
const waiverPath = process.env.TASKNOTES_CONFORMANCE_WAIVERS
? resolve(tasknotesRoot, process.env.TASKNOTES_CONFORMANCE_WAIVERS)
: defaultWaiverPath;
const waivers = loadWaivers(waiverPath);
let result = run("npm", ["run", "conformance:generate"], { cwd: specRoot });
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
result = run("npm", ["run", "conformance:build:tasknotes-bridge"], { cwd: specRoot });
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
const waiverSession = applyFixtureWaivers(specRoot, waivers);
if (waivers.length > 0) {
console.warn(
`Applying ${waivers.length} TaskNotes conformance waiver(s) from ${waiverPath} (${waiverSession.removedCount} fixture(s) skipped):`,
);
for (const waiver of waivers) {
console.warn(`- ${waiver.id} [${waiver.scope}] ${waiver.reason}`);
console.warn(` justification: ${waiver.justification}`);
}
}
try {
result = run("npm", ["run", "conformance:test"], {
cwd: specRoot,
env: { TASKNOTES_ADAPTER: adapterPath },
});
} finally {
waiverSession.restore();
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}

383
src/core/createCompat.ts Normal file
View file

@ -0,0 +1,383 @@
import { format } from "date-fns";
import type { SpecFieldMapping } from "./specFieldMapping";
import { denormalizeFrontmatter, resolveField } from "./specFieldMapping";
type UnknownRecord = Record<string, unknown>;
interface TaskTypeDefLike {
path_pattern?: string;
match?: {
where?: Record<string, unknown>;
};
fields?: Record<string, { type?: string; default?: unknown }>;
}
interface CreateInputLike {
type: string;
frontmatter: UnknownRecord;
body?: string;
path?: string;
}
interface CreateResultLike {
path?: string;
frontmatter?: UnknownRecord;
error?: {
code?: string;
message: string;
};
warnings?: string[];
}
type CreateCollectionLike = {
create: (input: CreateInputLike) => Promise<CreateResultLike>;
typeDefs?: Map<string, TaskTypeDefLike>;
};
export async function createTaskWithCompat(
collection: CreateCollectionLike,
mapping: SpecFieldMapping,
roleFrontmatter: UnknownRecord,
body?: string,
now?: Date
): Promise<CreateResultLike> {
const taskType = getTaskTypeDef(collection);
const denormalized = denormalizeFrontmatter(roleFrontmatter, mapping);
const effectiveNow = now instanceof Date && !Number.isNaN(now.getTime()) ? now : new Date();
applyFieldDefaults(denormalized, taskType);
applyTimestampDefaults(denormalized, mapping, taskType, effectiveNow);
applyMatchDefaults(denormalized, taskType);
const input: CreateInputLike = {
type: "task",
frontmatter: denormalized,
body,
};
const firstAttempt = await collection.create(input);
if (!firstAttempt.error || firstAttempt.error.code !== "path_required") {
return firstAttempt;
}
const pathResolution = derivePathFromType(taskType, denormalized, mapping, effectiveNow);
if (!pathResolution.path) {
if (pathResolution.missingKeys && pathResolution.missingKeys.length > 0) {
const missing = pathResolution.missingKeys.join(", ");
return {
...firstAttempt,
warnings: [
`Cannot resolve path_pattern "${pathResolution.template}": missing template values for ${missing}.`,
],
};
}
return firstAttempt;
}
return await collection.create({
...input,
path: pathResolution.path,
});
}
function getTaskTypeDef(collection: CreateCollectionLike): TaskTypeDefLike | undefined {
const maybeCollection = collection as unknown as { typeDefs?: Map<string, TaskTypeDefLike> };
if (!maybeCollection.typeDefs || typeof maybeCollection.typeDefs.get !== "function") {
return undefined;
}
return maybeCollection.typeDefs.get("task");
}
function applyTimestampDefaults(
frontmatter: UnknownRecord,
mapping: SpecFieldMapping,
taskType: TaskTypeDefLike | undefined,
now: Date
): void {
const fields = taskType?.fields;
if (!fields) return;
const nowIso = now.toISOString();
const createdField = resolveField(mapping, "dateCreated");
if (fields[createdField] && !hasValue(frontmatter[createdField])) {
frontmatter[createdField] = nowIso;
}
const modifiedField = resolveField(mapping, "dateModified");
if (fields[modifiedField] && !hasValue(frontmatter[modifiedField])) {
frontmatter[modifiedField] = nowIso;
}
}
function applyFieldDefaults(frontmatter: UnknownRecord, taskType: TaskTypeDefLike | undefined): void {
const fields = taskType?.fields;
if (!fields) return;
for (const [fieldName, fieldDef] of Object.entries(fields)) {
if (fieldDef.default !== undefined && !hasValue(frontmatter[fieldName])) {
frontmatter[fieldName] = fieldDef.default;
}
}
}
function applyMatchDefaults(frontmatter: UnknownRecord, taskType: TaskTypeDefLike | undefined): void {
const where = taskType?.match?.where;
if (!where || typeof where !== "object") return;
for (const [field, condition] of Object.entries(where)) {
if (condition === null || condition === undefined) continue;
if (typeof condition !== "object" || Array.isArray(condition)) {
if (!hasValue(frontmatter[field])) {
frontmatter[field] = condition;
}
continue;
}
const ops = condition as Record<string, unknown>;
if ("eq" in ops && !hasValue(frontmatter[field])) {
frontmatter[field] = ops.eq;
continue;
}
if ("contains" in ops) {
const expected = ops.contains;
const current = frontmatter[field];
if (Array.isArray(current)) {
if (!current.some((v) => String(v) === String(expected))) {
current.push(expected);
frontmatter[field] = current;
}
continue;
}
if (typeof current === "string") {
if (!current.includes(String(expected))) {
frontmatter[field] = `${current} ${String(expected)}`.trim();
}
continue;
}
if (!hasValue(current)) {
frontmatter[field] = [expected];
}
continue;
}
if ("exists" in ops && ops.exists === true && !hasValue(frontmatter[field])) {
frontmatter[field] = true;
}
}
}
function derivePathFromType(
taskType: TaskTypeDefLike | undefined,
frontmatter: UnknownRecord,
mapping: SpecFieldMapping,
now: Date
): { path?: string; missingKeys?: string[]; template?: string } {
if (!taskType || typeof taskType.path_pattern !== "string" || taskType.path_pattern.trim().length === 0) {
return {};
}
const values = buildTemplateValues(frontmatter, mapping, now);
const renderedPattern = renderTemplate(taskType.path_pattern, values);
if (renderedPattern.path) {
return { path: ensureMarkdownExt(renderedPattern.path), template: taskType.path_pattern };
}
return {
template: taskType.path_pattern,
missingKeys: renderedPattern.missingKeys,
};
}
function renderTemplate(
template: string,
values: Record<string, string>
): { path?: string; missingKeys: string[] } {
const missingKeys = new Set<string>();
const rendered = template.replace(/\{\{(\w+)\}\}|\{(\w+)\}/g, (_, a: string, b: string) => {
const key = a ?? b;
const value = values[key];
if (value === undefined || value === null || String(value).trim().length === 0) {
missingKeys.add(key);
return "";
}
return String(value);
});
if (missingKeys.size > 0) {
return { missingKeys: Array.from(missingKeys).sort() };
}
const normalized = normalizeRelativePath(rendered);
if (!normalized || normalized.includes("..") || normalized.includes("\0")) {
return { missingKeys: [] };
}
return { path: normalized, missingKeys: [] };
}
function buildTemplateValues(
frontmatter: UnknownRecord,
mapping: SpecFieldMapping,
now: Date
): Record<string, string> {
const values: Record<string, string> = {};
const titleField = resolveField(mapping, "title");
const priorityField = resolveField(mapping, "priority");
const statusField = resolveField(mapping, "status");
const dueField = resolveField(mapping, "due");
const scheduledField = resolveField(mapping, "scheduled");
const contextsField = resolveField(mapping, "contexts");
const projectsField = resolveField(mapping, "projects");
const tagsField = resolveField(mapping, "tags");
const estimateField = resolveField(mapping, "timeEstimate");
const rawTitle = readString(frontmatter[titleField]) || readString(frontmatter.title) || "task";
const title = sanitizeForPathSegment(rawTitle);
const priority = sanitizeForPathSegment(
readString(frontmatter[priorityField]) || readString(frontmatter.priority) || "normal"
);
const status = sanitizeForPathSegment(
readString(frontmatter[statusField]) || readString(frontmatter.status) || "open"
);
const dueDate = readString(frontmatter[dueField]) || readString(frontmatter.due) || "";
const scheduledDate =
readString(frontmatter[scheduledField]) || readString(frontmatter.scheduled) || "";
const titleWords = splitWords(rawTitle);
const contexts = readStringList(frontmatter[contextsField] ?? frontmatter.contexts)
.map(sanitizeForPathSegment)
.join("-");
const projects = readStringList(frontmatter[projectsField] ?? frontmatter.projects)
.map(sanitizeForPathSegment)
.join("-");
const tags = readStringList(frontmatter[tagsField] ?? frontmatter.tags)
.map(sanitizeForPathSegment)
.join("-");
const timeEstimate = readString(frontmatter[estimateField] ?? frontmatter.timeEstimate) || "";
values.title = title;
values.priority = priority;
values.priorityShort = priority ? priority.slice(0, 3).toLowerCase() : "";
values.status = status;
values.statusShort = status ? status.slice(0, 3).toLowerCase() : "";
values.due = dueDate;
values.dueDate = dueDate;
values.scheduled = scheduledDate;
values.scheduledDate = scheduledDate;
values.titleKebab = toKebabCase(titleWords);
values.titleSnake = toSnakeCase(titleWords);
values.titleCamel = toCamelCase(titleWords);
values.titlePascal = toPascalCase(titleWords);
values.titleUpper = rawTitle.toUpperCase();
values.titleLower = rawTitle.toLowerCase();
values.contexts = contexts;
values.projects = projects;
values.tags = tags;
values.timeEstimate = timeEstimate;
values.year = format(now, "yyyy");
values.month = format(now, "MM");
values.monthName = sanitizeForPathSegment(format(now, "MMMM"));
values.monthNameShort = sanitizeForPathSegment(format(now, "MMM"));
values.day = format(now, "dd");
values.date = format(now, "yyyy-MM-dd");
values.shortDate = format(now, "yyyyMMdd");
values.time = format(now, "HHmmss");
values.timestamp = format(now, "yyyyMMddHHmmss");
values.week = format(now, "II");
values.zettel = format(now, "yyyyMMddHHmmss");
for (const [key, value] of Object.entries(frontmatter)) {
if (values[key] !== undefined) continue;
if (typeof value === "string") {
values[key] = sanitizeForPathSegment(value);
}
}
return values;
}
function readString(value: unknown): string | undefined {
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return undefined;
}
function readStringList(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.map((entry) => readString(entry))
.filter((entry): entry is string => typeof entry === "string");
}
const single = readString(value);
return single ? [single] : [];
}
function sanitizeForPathSegment(value: string): string {
return value
.trim()
.replace(/[<>:"|?*\u0000-\u001f]/g, "")
.replace(/[\\/]+/g, "-")
.replace(/\s+/g, " ")
.trim();
}
function normalizeRelativePath(value: string): string {
return value
.replace(/\\/g, "/")
.split("/")
.map((part) => part.trim())
.filter((part) => part.length > 0 && part !== ".")
.join("/");
}
function splitWords(value: string): string[] {
return value
.trim()
.split(/[^A-Za-z0-9]+/)
.map((part) => part.trim())
.filter((part) => part.length > 0);
}
function capitalize(word: string): string {
if (!word) return "";
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}
function toKebabCase(words: string[]): string {
return words.map((word) => word.toLowerCase()).join("-");
}
function toSnakeCase(words: string[]): string {
return words.map((word) => word.toLowerCase()).join("_");
}
function toCamelCase(words: string[]): string {
if (words.length === 0) return "";
return [
words[0].toLowerCase(),
...words.slice(1).map((word) => capitalize(word)),
].join("");
}
function toPascalCase(words: string[]): string {
return words.map((word) => capitalize(word)).join("");
}
function ensureMarkdownExt(path: string): string {
return path.endsWith(".md") ? path : `${path}.md`;
}
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) return false;
if (typeof value === "string") return value.trim().length > 0;
if (Array.isArray(value)) return value.length > 0;
return true;
}

88
src/core/date.ts Normal file
View file

@ -0,0 +1,88 @@
import {
formatDateForStorage as formatDateForStorageFromUtils,
getCurrentDateString as getCurrentDateStringFromUtils,
getDatePart as getDatePartFromUtils,
hasTimeComponent as hasTimeComponentFromUtils,
isBeforeDateSafe as isBeforeDateSafeFromUtils,
isSameDateSafe as isSameDateSafeFromUtils,
parseDateToLocal as parseDateToLocalFromUtils,
parseDateToUTC as parseDateToUTCFromUtils,
} from "../utils/dateUtils";
export function parseDateToUTC(dateString: string): Date {
return parseDateToUTCFromUtils(dateString);
}
export function parseDateToLocal(dateString: string): Date {
return parseDateToLocalFromUtils(dateString);
}
export function formatDateForStorage(date: Date): string {
return formatDateForStorageFromUtils(date);
}
export function getCurrentDateString(): string {
return getCurrentDateStringFromUtils();
}
export function validateDateString(date: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new Error(`Invalid date "${date}". Expected YYYY-MM-DD.`);
}
parseDateToUTC(date);
return date;
}
export function hasTimeComponent(dateString: string | undefined): boolean {
if (!dateString) return false;
return hasTimeComponentFromUtils(dateString);
}
export function getDatePart(dateString: string): string {
if (!dateString) return "";
return getDatePartFromUtils(dateString);
}
function extractValidDatePartOrUndefined(dateString: string | undefined): string | undefined {
if (!dateString || dateString.trim().length === 0) {
return undefined;
}
try {
const datePart = getDatePart(dateString.trim());
return validateDateString(datePart);
} catch {
return undefined;
}
}
export function resolveOperationTargetDate(
explicitDate: string | undefined,
scheduled: string | undefined,
due: string | undefined
): string {
if (explicitDate) {
return validateDateString(explicitDate);
}
const scheduledDatePart = extractValidDatePartOrUndefined(scheduled);
if (scheduledDatePart) {
return scheduledDatePart;
}
const dueDatePart = extractValidDatePartOrUndefined(due);
if (dueDatePart) {
return dueDatePart;
}
return getCurrentDateString();
}
export function isSameDateSafe(date1: string, date2: string): boolean {
return isSameDateSafeFromUtils(date1, date2);
}
export function isBeforeDateSafe(date1: string, date2: string): boolean {
return isBeforeDateSafeFromUtils(date1, date2);
}

335
src/core/fieldMapping.ts Normal file
View file

@ -0,0 +1,335 @@
/* eslint-disable no-console */
import { FieldMapping, TaskInfo } from "../types";
import {
normalizeDependencyEntry,
normalizeDependencyList,
serializeDependencies,
} from "../utils/dependencyUtils";
import { validateCompleteInstances } from "../utils/dateUtils";
export function toUserField(mapping: FieldMapping, internalName: keyof FieldMapping): string {
return mapping[internalName];
}
export function normalizeTitleValue(val: unknown): string | undefined {
if (typeof val === "string") return val;
if (Array.isArray(val)) return val.map((v) => String(v)).join(", ");
if (val === null || val === undefined) return undefined;
if (typeof val === "object") return "";
return String(val);
}
export function mapTaskFromFrontmatter(
mapping: FieldMapping,
frontmatter: Record<string, any> | undefined | null,
filePath: string,
storeTitleInFilename?: boolean
): Partial<TaskInfo> {
if (!frontmatter) return {};
const mapped: Partial<TaskInfo> = {
path: filePath,
};
if (frontmatter[mapping.title] !== undefined) {
const normalized = normalizeTitleValue(frontmatter[mapping.title]);
if (normalized !== undefined) {
mapped.title = normalized;
}
} else if (storeTitleInFilename) {
const filename = filePath.split("/").pop()?.replace(".md", "");
if (filename) {
mapped.title = filename;
}
}
if (frontmatter[mapping.status] !== undefined) {
const statusValue = frontmatter[mapping.status];
mapped.status = typeof statusValue === "boolean" ? (statusValue ? "true" : "false") : statusValue;
}
if (frontmatter[mapping.priority] !== undefined) {
mapped.priority = frontmatter[mapping.priority];
}
if (frontmatter[mapping.due] !== undefined) {
mapped.due = frontmatter[mapping.due];
}
if (frontmatter[mapping.scheduled] !== undefined) {
mapped.scheduled = frontmatter[mapping.scheduled];
}
if (frontmatter[mapping.contexts] !== undefined) {
const contexts = frontmatter[mapping.contexts];
mapped.contexts = Array.isArray(contexts) ? contexts : [contexts];
}
if (frontmatter[mapping.projects] !== undefined) {
const projects = frontmatter[mapping.projects];
mapped.projects = Array.isArray(projects) ? projects : [projects];
}
if (frontmatter[mapping.timeEstimate] !== undefined) {
mapped.timeEstimate = frontmatter[mapping.timeEstimate];
}
if (frontmatter[mapping.completedDate] !== undefined) {
mapped.completedDate = frontmatter[mapping.completedDate];
}
if (frontmatter[mapping.recurrence] !== undefined) {
mapped.recurrence = frontmatter[mapping.recurrence];
}
if (frontmatter[mapping.recurrenceAnchor] !== undefined) {
const anchorValue = frontmatter[mapping.recurrenceAnchor];
if (anchorValue === "scheduled" || anchorValue === "completion") {
mapped.recurrence_anchor = anchorValue;
} else {
console.warn(`Invalid recurrence_anchor value: ${anchorValue}, defaulting to 'scheduled'`);
mapped.recurrence_anchor = "scheduled";
}
}
if (frontmatter[mapping.dateCreated] !== undefined) {
mapped.dateCreated = frontmatter[mapping.dateCreated];
}
if (frontmatter[mapping.dateModified] !== undefined) {
mapped.dateModified = frontmatter[mapping.dateModified];
}
if (frontmatter[mapping.timeEntries] !== undefined) {
const timeEntriesValue = frontmatter[mapping.timeEntries];
mapped.timeEntries = Array.isArray(timeEntriesValue) ? timeEntriesValue : [];
}
if (frontmatter[mapping.completeInstances] !== undefined) {
mapped.complete_instances = validateCompleteInstances(frontmatter[mapping.completeInstances]);
}
if (frontmatter[mapping.skippedInstances] !== undefined) {
mapped.skipped_instances = validateCompleteInstances(frontmatter[mapping.skippedInstances]);
}
if (mapping.blockedBy && frontmatter[mapping.blockedBy] !== undefined) {
const dependencies = normalizeDependencyList(frontmatter[mapping.blockedBy]);
if (dependencies) {
mapped.blockedBy = dependencies;
}
}
if (frontmatter[mapping.icsEventId] !== undefined) {
const icsEventId = frontmatter[mapping.icsEventId];
mapped.icsEventId = Array.isArray(icsEventId) ? icsEventId : [icsEventId];
}
if (frontmatter[mapping.googleCalendarEventId] !== undefined) {
mapped.googleCalendarEventId = frontmatter[mapping.googleCalendarEventId];
}
if (frontmatter[mapping.reminders] !== undefined) {
const reminders = frontmatter[mapping.reminders];
if (Array.isArray(reminders)) {
const filteredReminders = reminders.filter((r) => r != null);
if (filteredReminders.length > 0) {
mapped.reminders = filteredReminders;
}
} else if (reminders != null) {
mapped.reminders = [reminders];
}
}
if (frontmatter[mapping.sortOrder] !== undefined) {
const val = frontmatter[mapping.sortOrder];
mapped.sortOrder = typeof val === "string" ? val : String(val);
}
if (frontmatter.tags && Array.isArray(frontmatter.tags)) {
mapped.tags = frontmatter.tags;
mapped.archived = frontmatter.tags.includes(mapping.archiveTag);
}
return mapped;
}
export function mapTaskToFrontmatter(
mapping: FieldMapping,
taskData: Partial<TaskInfo>,
taskTag?: string,
storeTitleInFilename?: boolean
): Record<string, any> {
const frontmatter: Record<string, any> = {};
if (taskData.title !== undefined) {
frontmatter[mapping.title] = taskData.title;
}
if (taskData.status !== undefined) {
const lower = taskData.status.toLowerCase();
const coercedValue =
lower === "true" || lower === "false" ? lower === "true" : taskData.status;
frontmatter[mapping.status] = coercedValue;
}
if (taskData.priority !== undefined) {
frontmatter[mapping.priority] = taskData.priority;
}
if (taskData.due !== undefined) {
frontmatter[mapping.due] = taskData.due;
}
if (taskData.scheduled !== undefined) {
frontmatter[mapping.scheduled] = taskData.scheduled;
}
if (
taskData.contexts !== undefined &&
(!Array.isArray(taskData.contexts) || taskData.contexts.length > 0)
) {
frontmatter[mapping.contexts] = taskData.contexts;
}
if (
taskData.projects !== undefined &&
(!Array.isArray(taskData.projects) || taskData.projects.length > 0)
) {
frontmatter[mapping.projects] = taskData.projects;
}
if (taskData.timeEstimate !== undefined) {
frontmatter[mapping.timeEstimate] = taskData.timeEstimate;
}
if (taskData.completedDate !== undefined) {
frontmatter[mapping.completedDate] = taskData.completedDate;
}
if (taskData.recurrence !== undefined) {
frontmatter[mapping.recurrence] = taskData.recurrence;
}
if (taskData.recurrence_anchor !== undefined) {
frontmatter[mapping.recurrenceAnchor] = taskData.recurrence_anchor;
}
if (taskData.dateCreated !== undefined) {
frontmatter[mapping.dateCreated] = taskData.dateCreated;
}
if (taskData.dateModified !== undefined) {
frontmatter[mapping.dateModified] = taskData.dateModified;
}
if (taskData.sortOrder !== undefined) {
frontmatter[mapping.sortOrder] = taskData.sortOrder;
}
if (taskData.timeEntries !== undefined) {
frontmatter[mapping.timeEntries] = taskData.timeEntries;
}
if (taskData.complete_instances !== undefined) {
frontmatter[mapping.completeInstances] = taskData.complete_instances;
}
if (taskData.skipped_instances !== undefined && taskData.skipped_instances.length > 0) {
frontmatter[mapping.skippedInstances] = taskData.skipped_instances;
}
if (taskData.blockedBy !== undefined) {
if (Array.isArray(taskData.blockedBy)) {
const normalized = taskData.blockedBy
.map((item) => normalizeDependencyEntry(item))
.filter((item): item is NonNullable<ReturnType<typeof normalizeDependencyEntry>> => !!item);
if (normalized.length > 0) {
frontmatter[mapping.blockedBy] = serializeDependencies(normalized);
}
} else {
frontmatter[mapping.blockedBy] = taskData.blockedBy;
}
}
if (taskData.icsEventId !== undefined && taskData.icsEventId.length > 0) {
frontmatter[mapping.icsEventId] = taskData.icsEventId;
}
if (taskData.reminders !== undefined && taskData.reminders.length > 0) {
frontmatter[mapping.reminders] = taskData.reminders;
}
let tags = taskData.tags ? [...taskData.tags] : [];
if (taskTag && !tags.includes(taskTag)) {
tags.push(taskTag);
}
if (taskData.archived === true && !tags.includes(mapping.archiveTag)) {
tags.push(mapping.archiveTag);
} else if (taskData.archived === false) {
tags = tags.filter((tag) => tag !== mapping.archiveTag);
}
if (tags.length > 0) {
frontmatter.tags = tags;
}
void storeTitleInFilename;
return frontmatter;
}
export function lookupMappingKey(
mapping: FieldMapping,
frontmatterPropertyName: string
): keyof FieldMapping | null {
for (const [mappingKey, propertyName] of Object.entries(mapping)) {
if (propertyName === frontmatterPropertyName) {
return mappingKey as keyof FieldMapping;
}
}
return null;
}
export function isRecognizedProperty(mapping: FieldMapping, frontmatterPropertyName: string): boolean {
return lookupMappingKey(mapping, frontmatterPropertyName) !== null;
}
export function isPropertyForField(
mapping: FieldMapping,
propertyName: string,
internalField: keyof FieldMapping
): boolean {
return mapping[internalField] === propertyName;
}
export function toUserFields(
mapping: FieldMapping,
internalFields: (keyof FieldMapping)[]
): string[] {
return internalFields.map((field) => mapping[field]);
}
export function validateFieldMapping(mapping: FieldMapping): { valid: boolean; errors: string[] } {
const errors: string[] = [];
const fields = Object.keys(mapping) as (keyof FieldMapping)[];
for (const field of fields) {
if (!mapping[field] || mapping[field].trim() === "") {
errors.push(`Field "${field}" cannot be empty`);
}
}
const values = Object.values(mapping);
const uniqueValues = new Set(values);
if (values.length !== uniqueValues.size) {
errors.push("Field mappings must have unique property names");
}
return {
valid: errors.length === 0,
errors,
};
}

706
src/core/recurrence.ts Normal file
View file

@ -0,0 +1,706 @@
import { RRule } from "rrule";
import { TaskInfo } from "../types";
import {
createUTCDateForRRule,
formatDateAsUTCString,
formatDateForStorage,
getTodayString,
hasTimeComponent,
parseDateToLocal,
parseDateToUTC,
} from "../utils/dateUtils";
export type RecurrenceAnchor = "scheduled" | "completion";
export interface RecurringTaskLike {
title?: string;
recurrence?: string;
scheduled?: string;
due?: string;
dateCreated?: string;
recurrence_anchor?: RecurrenceAnchor;
complete_instances?: string[];
skipped_instances?: string[];
status?: string;
}
export interface RecurrenceCompletionInput {
recurrence: string;
recurrenceAnchor?: string;
scheduled?: string;
due?: string;
dateCreated?: string;
completionDate: string;
completeInstances?: string[];
skippedInstances?: string[];
}
export interface RecurrenceCompletionResult {
updatedRecurrence: string;
nextScheduled: string | null;
nextDue: string | null;
completeInstances: string[];
skippedInstances: string[];
}
export interface RecurrenceScheduleInput {
recurrence: string;
recurrenceAnchor?: string;
scheduled?: string;
due?: string;
dateCreated?: string;
completeInstances?: string[];
skippedInstances?: string[];
referenceDate: string;
}
export interface RecurrenceScheduleResult {
updatedRecurrence: string;
nextScheduled: string | null;
nextDue: string | null;
}
function parseDtstartFromRecurrence(recurrence: string): Date | null {
const dtstartMatch = recurrence.match(/DTSTART:(\d{8}(?:T\d{6}Z?)?)/);
if (!dtstartMatch) {
return null;
}
const dtstartStr = dtstartMatch[1];
if (dtstartStr.length === 8) {
const year = parseInt(dtstartStr.slice(0, 4));
const month = parseInt(dtstartStr.slice(4, 6)) - 1;
const day = parseInt(dtstartStr.slice(6, 8));
return new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
}
const year = parseInt(dtstartStr.slice(0, 4));
const month = parseInt(dtstartStr.slice(4, 6)) - 1;
const day = parseInt(dtstartStr.slice(6, 8));
const hour = parseInt(dtstartStr.slice(9, 11)) || 0;
const minute = parseInt(dtstartStr.slice(11, 13)) || 0;
const second = parseInt(dtstartStr.slice(13, 15)) || 0;
return new Date(Date.UTC(year, month, day, hour, minute, second, 0));
}
function getRRuleDtstart(task: Pick<RecurringTaskLike, "recurrence" | "scheduled" | "dateCreated">): Date | null {
if (!task.recurrence) {
return null;
}
const parsedDtstart = parseDtstartFromRecurrence(task.recurrence);
if (parsedDtstart) {
return parsedDtstart;
}
if (task.scheduled) {
return createUTCDateForRRule(task.scheduled);
}
if (task.dateCreated) {
return createUTCDateForRRule(task.dateCreated);
}
return null;
}
function createRRule(task: Pick<RecurringTaskLike, "recurrence" | "scheduled" | "dateCreated">): RRule | null {
if (!task.recurrence || typeof task.recurrence !== "string") {
return null;
}
const dtstart = getRRuleDtstart(task);
if (!dtstart) {
return null;
}
const rruleString = task.recurrence.replace(/DTSTART:[^;]+;?/, "");
const rruleOptions = RRule.parseString(rruleString);
rruleOptions.dtstart = dtstart;
return new RRule(rruleOptions);
}
function parseIntervalFromRecurrence(recurrence: string): number {
const match = recurrence.match(/INTERVAL=(\d+)/);
return match ? parseInt(match[1], 10) : 1;
}
function getLookAheadDays(recurrence: string): number {
const interval = parseIntervalFromRecurrence(recurrence);
if (recurrence.includes("FREQ=DAILY")) {
return Math.max(30, interval * 2);
}
if (recurrence.includes("FREQ=WEEKLY")) {
return Math.max(90, interval * 7 * 2);
}
if (recurrence.includes("FREQ=MONTHLY")) {
return Math.max(400, interval * 31 * 2);
}
if (recurrence.includes("FREQ=YEARLY")) {
return Math.max(800, interval * 366 * 2);
}
return 365;
}
function formatDtstartValue(dateStr: string): string {
if (hasTimeComponent(dateStr)) {
const dateTime = parseDateToLocal(dateStr);
const year = dateTime.getFullYear();
const month = String(dateTime.getMonth() + 1).padStart(2, "0");
const day = String(dateTime.getDate()).padStart(2, "0");
const hours = String(dateTime.getHours()).padStart(2, "0");
const minutes = String(dateTime.getMinutes()).padStart(2, "0");
const seconds = String(dateTime.getSeconds()).padStart(2, "0");
return `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
}
const date = parseDateToUTC(dateStr);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
return `${year}${month}${day}`;
}
export function isDueByRRule(task: RecurringTaskLike, date: Date): boolean {
if (!task.recurrence) {
return true;
}
if (typeof task.recurrence === "string") {
try {
const rrule = createRRule(task);
if (!rrule) {
return false;
}
const targetDateStart = createUTCDateForRRule(formatDateAsUTCString(date));
const occurrences = rrule.between(
targetDateStart,
new Date(targetDateStart.getTime() + 24 * 60 * 60 * 1000 - 1),
true
);
return occurrences.length > 0;
} catch (error) {
console.error("Error evaluating rrule:", error, {
task: task.title,
recurrence: task.recurrence,
});
return true;
}
}
return true;
}
export function getEffectiveTaskStatus(
task: Pick<RecurringTaskLike, "recurrence" | "complete_instances" | "status">,
date: Date,
completedStatus?: string
): string {
if (!task.recurrence) {
return task.status || "open";
}
const dateStr = formatDateForStorage(date);
const completedDates = Array.isArray(task.complete_instances) ? task.complete_instances : [];
return completedDates.includes(dateStr) ? (completedStatus || "done") : (task.status || "open");
}
export function shouldShowRecurringTaskOnDate(task: RecurringTaskLike, targetDate: Date): boolean {
if (!task.recurrence) return true;
return isDueByRRule(task, targetDate);
}
export function getRecurringTaskCompletionText(
task: Pick<RecurringTaskLike, "recurrence" | "complete_instances">,
targetDate: Date
): string {
if (!task.recurrence) return "";
const dateStr = formatDateForStorage(targetDate);
const isCompleted = task.complete_instances?.includes(dateStr) || false;
return isCompleted ? "Completed for this date" : "Not completed for this date";
}
export function shouldUseRecurringTaskUI(task: Pick<RecurringTaskLike, "recurrence">): boolean {
return !!task.recurrence;
}
export function generateRecurringInstances(
task: Pick<RecurringTaskLike, "title" | "recurrence" | "scheduled" | "dateCreated">,
startDate: Date,
endDate: Date
): Date[] {
if (!task.recurrence) {
return [];
}
if (typeof task.recurrence === "string") {
try {
const rrule = createRRule(task);
if (!rrule) {
return [];
}
const utcStartDate = new Date(
Date.UTC(
startDate.getUTCFullYear(),
startDate.getUTCMonth(),
startDate.getUTCDate(),
0,
0,
0,
0
)
);
const utcEndDate = new Date(
Date.UTC(
endDate.getUTCFullYear(),
endDate.getUTCMonth(),
endDate.getUTCDate(),
23,
59,
59,
999
)
);
return rrule.between(utcStartDate, utcEndDate, true);
} catch (error) {
console.error("Error generating recurring instances:", error, {
task: task.title,
recurrence: task.recurrence,
});
}
}
const instances: Date[] = [];
const current = new Date(startDate);
while (current <= endDate) {
if (isDueByRRule(task, current)) {
instances.push(new Date(current));
}
current.setUTCDate(current.getUTCDate() + 1);
}
return instances;
}
function getNextScheduledBasedOccurrence(task: RecurringTaskLike): Date | null {
if (!task.recurrence) {
return null;
}
try {
const today = parseDateToUTC(getTodayString());
const lookAheadDays = getLookAheadDays(task.recurrence);
let startDate = today;
const dtstart = parseDtstartFromRecurrence(task.recurrence);
if (dtstart && dtstart < today) {
startDate = dtstart;
}
const endDate = new Date(today.getTime() + lookAheadDays * 24 * 60 * 60 * 1000);
const occurrences = generateRecurringInstances(task, startDate, endDate);
const processedInstances = new Set([
...(task.complete_instances || []),
...(task.skipped_instances || []),
]);
for (const occurrence of occurrences) {
const occurrenceStr = formatDateForStorage(occurrence);
if (!processedInstances.has(occurrenceStr) && occurrence >= today) {
return occurrence;
}
}
return null;
} catch (error) {
console.error("Error calculating next scheduled-based occurrence:", error, {
task: task.title,
});
return null;
}
}
function getNextCompletionBasedOccurrence(task: RecurringTaskLike): Date | null {
if (!task.recurrence || typeof task.recurrence !== "string") {
return null;
}
try {
const today = parseDateToUTC(getTodayString());
const lookAheadDays = getLookAheadDays(task.recurrence);
const dtstartDate = parseDtstartFromRecurrence(task.recurrence);
const startDate = dtstartDate || today;
const endDate = new Date(startDate.getTime() + lookAheadDays * 24 * 60 * 60 * 1000);
const occurrences = generateRecurringInstances(task, startDate, endDate);
const skippedInstances = new Set(task.skipped_instances || []);
const dtstartTime = dtstartDate ? dtstartDate.getTime() : 0;
for (const occurrence of occurrences) {
const occurrenceStr = formatDateForStorage(occurrence);
if (
occurrence.getTime() > dtstartTime &&
occurrence >= today &&
!skippedInstances.has(occurrenceStr)
) {
return occurrence;
}
}
return null;
} catch (error) {
console.error("Error calculating completion-based recurrence:", error, {
task: task.title,
});
return null;
}
}
export function getNextUncompletedOccurrence(task: RecurringTaskLike): Date | null {
if (!task.recurrence) {
return null;
}
const anchor = task.recurrence_anchor || "scheduled";
return anchor === "completion"
? getNextCompletionBasedOccurrence(task)
: getNextScheduledBasedOccurrence(task);
}
export function updateToNextScheduledOccurrence(
task: Pick<
RecurringTaskLike,
"title" | "recurrence" | "scheduled" | "due" | "dateCreated" | "recurrence_anchor" | "complete_instances" | "skipped_instances"
>,
maintainDueOffset = true
): { scheduled: string | null; due: string | null } {
const nextOccurrence = getNextUncompletedOccurrence(task);
let nextScheduleStr: string | null = null;
let nextDueStr: string | null = null;
let nextDueDate: Date | null = null;
if (nextOccurrence) {
if (maintainDueOffset) {
try {
const originalScheduled = task.scheduled ? parseDateToUTC(task.scheduled) : null;
const originalDue = task.due ? parseDateToUTC(task.due) : null;
if (originalScheduled && originalDue) {
const offsetMs = originalDue.getTime() - originalScheduled.getTime();
nextDueDate = new Date(nextOccurrence.getTime() + offsetMs);
}
} catch (error) {
console.error("Error calculating next due date with offset:", error);
}
}
if (task.scheduled && task.scheduled.includes("T")) {
const timePart = task.scheduled.split("T")[1];
nextScheduleStr = `${formatDateForStorage(nextOccurrence)}T${timePart}`;
} else {
nextScheduleStr = formatDateForStorage(nextOccurrence);
}
if (nextDueDate && task.due && task.due.includes("T")) {
const timePart = task.due.split("T")[1];
nextDueStr = `${formatDateForStorage(nextDueDate)}T${timePart}`;
} else if (nextDueDate) {
nextDueStr = formatDateForStorage(nextDueDate);
}
}
return {
scheduled: nextScheduleStr,
due: nextDueStr,
};
}
export function getRecurrenceDisplayText(recurrence: string): string {
if (!recurrence) {
return "";
}
try {
if (recurrence.includes("FREQ=")) {
const rruleString = recurrence.replace(/DTSTART:[^;]+;?/, "");
const rrule = RRule.fromString(rruleString);
return rrule.toText();
}
return "rrule";
} catch (error) {
console.error("Error converting recurrence to display text:", error, { recurrence });
return "rrule";
}
}
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;
}
const sourceDateString = task.scheduled || task.dateCreated;
if (!sourceDateString) {
return null;
}
try {
return `DTSTART:${formatDtstartValue(sourceDateString)};${task.recurrence}`;
} catch (error) {
console.error("Error parsing date for DTSTART:", error, { sourceDateString });
return null;
}
}
export function updateDTSTARTInRecurrenceRule(recurrence: string, dateStr: string): string | null {
if (!recurrence || typeof recurrence !== "string") {
return null;
}
try {
const dtstartValue = formatDtstartValue(dateStr);
if (recurrence.includes("DTSTART:")) {
return recurrence.replace(/DTSTART:[^;]+;?/, `DTSTART:${dtstartValue};`);
}
return `DTSTART:${dtstartValue};${recurrence}`;
} catch (error) {
console.error("Error updating DTSTART in recurrence rule:", error, { dateStr });
return null;
}
}
export function addDTSTARTToRecurrenceRuleWithDraggedTime(
task: Pick<RecurringTaskLike, "recurrence" | "scheduled" | "dateCreated">,
draggedStart: Date,
allDay: boolean
): string | null {
if (!task.recurrence || typeof task.recurrence !== "string") {
return null;
}
if (task.recurrence.includes("DTSTART:")) {
return task.recurrence;
}
const sourceDateString = task.scheduled || task.dateCreated;
if (!sourceDateString) {
return null;
}
try {
let dtstartValue: string;
if (allDay) {
const sourceDate = parseDateToUTC(sourceDateString);
const year = sourceDate.getUTCFullYear();
const month = String(sourceDate.getUTCMonth() + 1).padStart(2, "0");
const day = String(sourceDate.getUTCDate()).padStart(2, "0");
dtstartValue = `${year}${month}${day}`;
} else {
const sourceDate = parseDateToUTC(sourceDateString);
const year = sourceDate.getUTCFullYear();
const month = String(sourceDate.getUTCMonth() + 1).padStart(2, "0");
const day = String(sourceDate.getUTCDate()).padStart(2, "0");
const hours = String(draggedStart.getHours()).padStart(2, "0");
const minutes = String(draggedStart.getMinutes()).padStart(2, "0");
dtstartValue = `${year}${month}${day}T${hours}${minutes}00Z`;
}
return `DTSTART:${dtstartValue};${task.recurrence}`;
} catch (error) {
console.error("Error parsing date for DTSTART with dragged time:", error, {
sourceDateString,
draggedStart,
allDay,
});
return null;
}
}
export type { TaskInfo };
interface RecurrenceScheduleInternalInput extends RecurrenceScheduleInput {
completionDateForAnchor?: string;
}
function parseDateString(dateStr: string | undefined): Date | null {
if (!dateStr) return null;
try {
return parseDateToUTC(dateStr);
} catch {
return null;
}
}
function formatLikeExisting(existingValue: string | undefined, date: Date): string {
const datePart = formatDateForStorage(date);
if (existingValue && existingValue.includes("T")) {
return `${datePart}T${existingValue.split("T")[1]}`;
}
return datePart;
}
function computeNextDue(
input: { due?: string; scheduled?: string },
nextScheduledDate: Date
): string | null {
if (!input.due || !input.scheduled) {
return null;
}
const originalDue = parseDateString(input.due);
const originalScheduled = parseDateString(input.scheduled);
if (!originalDue || !originalScheduled) {
return null;
}
const offsetMs = originalDue.getTime() - originalScheduled.getTime();
const nextDueDate = new Date(nextScheduledDate.getTime() + offsetMs);
return formatLikeExisting(input.due, nextDueDate);
}
function buildRRuleFromRecurrence(
recurrence: string,
sourceDate: string
): InstanceType<typeof RRule> | 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);
} catch {
return null;
}
}
function getNextOccurrenceDate(
recurrence: string,
sourceDate: string,
afterDate: Date,
inclusive: boolean
): Date | null {
const rule = buildRRuleFromRecurrence(recurrence, sourceDate);
if (!rule) return null;
return rule.after(afterDate, inclusive);
}
function recalculateRecurringScheduleInternal(
input: RecurrenceScheduleInternalInput
): RecurrenceScheduleResult {
const anchor = input.recurrenceAnchor === "completion" ? "completion" : "scheduled";
const sourceDate = input.scheduled || input.dateCreated || input.referenceDate;
let updatedRecurrence = input.recurrence;
if (anchor === "completion") {
if (input.completionDateForAnchor) {
updatedRecurrence =
updateDTSTARTInRecurrenceRule(updatedRecurrence, input.completionDateForAnchor) ||
updatedRecurrence;
}
} else {
updatedRecurrence =
addDTSTARTToRecurrenceRule(
{
recurrence: updatedRecurrence,
scheduled: sourceDate,
}
) || updatedRecurrence;
}
const referenceDate =
(anchor === "scheduled" ? parseDateString(input.scheduled) : null) ||
parseDateString(input.referenceDate);
if (!referenceDate) {
return { updatedRecurrence, nextScheduled: null, nextDue: null };
}
const completionDay = parseDateString(input.referenceDate);
const completeInstances = Array.isArray(input.completeInstances) ? input.completeInstances : [];
const skippedInstances = Array.isArray(input.skippedInstances) ? input.skippedInstances : [];
const processedDates = new Set<string>(
anchor === "completion" ? skippedInstances : [...completeInstances, ...skippedInstances]
);
let nextOccurrence = getNextOccurrenceDate(updatedRecurrence, sourceDate, referenceDate, true);
if (completionDay) {
let guard = 0;
while (nextOccurrence && nextOccurrence.getTime() < completionDay.getTime() && guard < 1000) {
nextOccurrence = getNextOccurrenceDate(updatedRecurrence, sourceDate, nextOccurrence, false);
guard++;
}
}
let processedGuard = 0;
while (nextOccurrence && processedGuard < 1000) {
const dateStr = formatDateForStorage(nextOccurrence);
if (!processedDates.has(dateStr)) break;
nextOccurrence = getNextOccurrenceDate(updatedRecurrence, sourceDate, nextOccurrence, false);
processedGuard++;
}
if (!nextOccurrence) {
return { updatedRecurrence, nextScheduled: null, nextDue: null };
}
const nextScheduled = formatLikeExisting(input.scheduled, nextOccurrence);
const nextDue = computeNextDue(input, nextOccurrence);
return { updatedRecurrence, nextScheduled, nextDue };
}
export function completeRecurringTask(
input: RecurrenceCompletionInput
): RecurrenceCompletionResult {
const completionDate = input.completionDate;
const completeInstances = Array.isArray(input.completeInstances) ? [...input.completeInstances] : [];
const skippedInstances = Array.isArray(input.skippedInstances) ? [...input.skippedInstances] : [];
if (!completeInstances.includes(completionDate)) {
completeInstances.push(completionDate);
}
const nextSkippedInstances = skippedInstances.filter((d) => d !== completionDate);
const schedule = recalculateRecurringScheduleInternal({
recurrence: input.recurrence,
recurrenceAnchor: input.recurrenceAnchor,
scheduled: input.scheduled,
due: input.due,
dateCreated: input.dateCreated,
completeInstances,
skippedInstances: nextSkippedInstances,
referenceDate: completionDate,
completionDateForAnchor: completionDate,
});
return {
updatedRecurrence: schedule.updatedRecurrence,
nextScheduled: schedule.nextScheduled,
nextDue: schedule.nextDue,
completeInstances,
skippedInstances: nextSkippedInstances,
};
}
export function recalculateRecurringSchedule(
input: RecurrenceScheduleInput
): RecurrenceScheduleResult {
return recalculateRecurringScheduleInternal(input);
}

View file

@ -0,0 +1,205 @@
import { basename } from "node:path";
export type FieldRole =
| "title"
| "status"
| "priority"
| "due"
| "scheduled"
| "completedDate"
| "tags"
| "contexts"
| "projects"
| "timeEstimate"
| "dateCreated"
| "dateModified"
| "recurrence"
| "recurrenceAnchor"
| "completeInstances"
| "skippedInstances"
| "timeEntries";
const ALL_ROLES: FieldRole[] = [
"title",
"status",
"priority",
"due",
"scheduled",
"completedDate",
"tags",
"contexts",
"projects",
"timeEstimate",
"dateCreated",
"dateModified",
"recurrence",
"recurrenceAnchor",
"completeInstances",
"skippedInstances",
"timeEntries",
];
export interface SpecFieldMapping {
roleToField: Record<FieldRole, string>;
fieldToRole: Record<string, FieldRole>;
displayNameKey: string;
completedStatuses: string[];
}
export function defaultFieldMapping(): SpecFieldMapping {
const roleToField = {} as Record<FieldRole, string>;
const fieldToRole = {} as Record<string, FieldRole>;
for (const role of ALL_ROLES) {
roleToField[role] = role;
fieldToRole[role] = role;
}
return {
roleToField,
fieldToRole,
displayNameKey: "title",
completedStatuses: ["done", "cancelled"],
};
}
export function buildFieldMapping(
fields: Record<string, any>,
displayNameKey?: string
): SpecFieldMapping {
const roleToField = {} as Record<FieldRole, string>;
const fieldToRole = {} as Record<string, FieldRole>;
const rolesSet = new Set<string>(ALL_ROLES);
for (const [fieldName, def] of Object.entries(fields)) {
if (def && typeof def === "object" && typeof def.tn_role === "string") {
const role = def.tn_role as FieldRole;
if (!rolesSet.has(role)) continue;
if (roleToField[role] !== undefined) {
console.warn(`[mtn] Duplicate tn_role "${role}" on field "${fieldName}", ignoring.`);
continue;
}
roleToField[role] = fieldName;
fieldToRole[fieldName] = role;
}
}
for (const role of ALL_ROLES) {
if (roleToField[role] === undefined) {
if (fields[role] !== undefined) {
roleToField[role] = role;
if (fieldToRole[role] === undefined) {
fieldToRole[role] = role;
}
} else {
roleToField[role] = role;
}
}
}
const completedStatuses = inferCompletedStatuses(fields, roleToField.status);
return {
roleToField,
fieldToRole,
displayNameKey:
displayNameKey && typeof displayNameKey === "string" && displayNameKey.trim().length > 0
? displayNameKey
: roleToField.title,
completedStatuses,
};
}
function inferCompletedStatuses(fields: Record<string, any>, statusFieldName: string): string[] {
const statusDef = fields[statusFieldName];
if (!statusDef || typeof statusDef !== "object") {
return ["done", "cancelled"];
}
if (Array.isArray(statusDef.tn_completed_values)) {
const explicit = statusDef.tn_completed_values
.filter((v: unknown): v is string => typeof v === "string")
.map((v: string) => v.trim())
.filter((v: string) => v.length > 0);
if (explicit.length > 0) return explicit;
}
if (Array.isArray(statusDef.values)) {
const inferred = statusDef.values
.filter((v: unknown): v is string => typeof v === "string")
.filter((v: string) => {
const lower = v.toLowerCase();
return lower.includes("done") || lower.includes("complete") || lower.includes("cancel");
});
if (inferred.length > 0) return inferred;
}
return ["done", "cancelled"];
}
export function isCompletedStatus(mapping: SpecFieldMapping, status: string | undefined): boolean {
if (!status) return false;
return mapping.completedStatuses.includes(status);
}
export function getDefaultCompletedStatus(mapping: SpecFieldMapping): string {
return mapping.completedStatuses[0] || "done";
}
export function normalizeFrontmatter(
raw: Record<string, unknown>,
mapping: SpecFieldMapping
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(raw)) {
const role = mapping.fieldToRole[key];
result[role ?? key] = value;
}
return result;
}
export function denormalizeFrontmatter(
roleData: Record<string, unknown>,
mapping: SpecFieldMapping
): Record<string, unknown> {
const result: Record<string, unknown> = {};
const rolesSet = new Set<string>(ALL_ROLES);
for (const [key, value] of Object.entries(roleData)) {
if (rolesSet.has(key)) {
result[mapping.roleToField[key as FieldRole]] = value;
} else {
result[key] = value;
}
}
return result;
}
export function resolveField(mapping: SpecFieldMapping, role: FieldRole): string {
return mapping.roleToField[role];
}
export function resolveDisplayTitle(
frontmatter: Record<string, unknown>,
mapping: SpecFieldMapping,
taskPath?: string
): string | undefined {
const candidates = [mapping.displayNameKey, "title"];
const seen = new Set<string>();
for (const key of candidates) {
if (seen.has(key)) continue;
seen.add(key);
const value = frontmatter[key];
if (typeof value === "string" && value.trim().length > 0) {
return value;
}
}
if (typeof taskPath === "string" && taskPath.trim().length > 0) {
const fromPath = basename(taskPath, ".md").trim();
if (fromPath.length > 0) {
return fromPath;
}
}
return undefined;
}

View file

@ -1,11 +1,15 @@
/* eslint-disable no-console */
import { FieldMapping, TaskInfo } from "../types";
import {
normalizeDependencyEntry,
normalizeDependencyList,
serializeDependencies,
} from "../utils/dependencyUtils";
import { validateCompleteInstances } from "../utils/dateUtils";
isPropertyForField,
isRecognizedProperty,
lookupMappingKey,
mapTaskFromFrontmatter,
mapTaskToFrontmatter,
toUserField,
toUserFields,
validateFieldMapping,
} from "../core/fieldMapping";
/**
* Service for mapping between internal field names and user-configured property names
@ -17,21 +21,7 @@ export class FieldMapper {
* Convert internal field name to user's property name
*/
toUserField(internalName: keyof FieldMapping): string {
return this.mapping[internalName];
}
/**
* Normalize arbitrary title-like values to a string.
* - string: return as-is
* - number/boolean: String(value)
* - array: join elements stringified with ', '
* - object: return empty string (unsupported edge case)
*/
private normalizeTitle(val: unknown): string | undefined {
if (typeof val === "string") return val;
if (Array.isArray(val)) return val.map((v) => String(v)).join(", ");
if (val === null || val === undefined) return undefined;
if (typeof val === "object") return "";
return String(val);
return toUserField(this.mapping, internalName);
}
/**
@ -42,153 +32,7 @@ export class FieldMapper {
filePath: string,
storeTitleInFilename?: boolean
): Partial<TaskInfo> {
if (!frontmatter) return {};
const mapped: Partial<TaskInfo> = {
path: filePath,
};
// Map each field if it exists in frontmatter
if (frontmatter[this.mapping.title] !== undefined) {
const rawTitle = frontmatter[this.mapping.title];
const normalized = this.normalizeTitle(rawTitle);
if (normalized !== undefined) {
mapped.title = normalized;
}
} else if (storeTitleInFilename) {
const filename = filePath.split("/").pop()?.replace(".md", "");
if (filename) {
mapped.title = filename;
}
}
if (frontmatter[this.mapping.status] !== undefined) {
const statusValue = frontmatter[this.mapping.status];
// Handle boolean status values (convert back to string for internal use)
if (typeof statusValue === "boolean") {
mapped.status = statusValue ? "true" : "false";
} else {
mapped.status = statusValue;
}
}
if (frontmatter[this.mapping.priority] !== undefined) {
mapped.priority = frontmatter[this.mapping.priority];
}
if (frontmatter[this.mapping.due] !== undefined) {
mapped.due = frontmatter[this.mapping.due];
}
if (frontmatter[this.mapping.scheduled] !== undefined) {
mapped.scheduled = frontmatter[this.mapping.scheduled];
}
if (frontmatter[this.mapping.contexts] !== undefined) {
const contexts = frontmatter[this.mapping.contexts];
// Ensure contexts is always an array
mapped.contexts = Array.isArray(contexts) ? contexts : [contexts];
}
if (frontmatter[this.mapping.projects] !== undefined) {
const projects = frontmatter[this.mapping.projects];
// Ensure projects is always an array
mapped.projects = Array.isArray(projects) ? projects : [projects];
}
if (frontmatter[this.mapping.timeEstimate] !== undefined) {
mapped.timeEstimate = frontmatter[this.mapping.timeEstimate];
}
if (frontmatter[this.mapping.completedDate] !== undefined) {
mapped.completedDate = frontmatter[this.mapping.completedDate];
}
if (frontmatter[this.mapping.recurrence] !== undefined) {
mapped.recurrence = frontmatter[this.mapping.recurrence];
}
if (frontmatter[this.mapping.recurrenceAnchor] !== undefined) {
const anchorValue = frontmatter[this.mapping.recurrenceAnchor];
// Validate value
if (anchorValue === 'scheduled' || anchorValue === 'completion') {
mapped.recurrence_anchor = anchorValue;
} else {
console.warn(`Invalid recurrence_anchor value: ${anchorValue}, defaulting to 'scheduled'`);
mapped.recurrence_anchor = 'scheduled';
}
}
if (frontmatter[this.mapping.dateCreated] !== undefined) {
mapped.dateCreated = frontmatter[this.mapping.dateCreated];
}
if (frontmatter[this.mapping.dateModified] !== undefined) {
mapped.dateModified = frontmatter[this.mapping.dateModified];
}
if (frontmatter[this.mapping.timeEntries] !== undefined) {
// Ensure timeEntries is always an array
const timeEntriesValue = frontmatter[this.mapping.timeEntries];
mapped.timeEntries = Array.isArray(timeEntriesValue) ? timeEntriesValue : [];
}
if (frontmatter[this.mapping.completeInstances] !== undefined) {
// Validate and clean the complete_instances array
mapped.complete_instances = validateCompleteInstances(
frontmatter[this.mapping.completeInstances]
);
}
if (frontmatter[this.mapping.skippedInstances] !== undefined) {
// Validate and clean the skipped_instances array
mapped.skipped_instances = validateCompleteInstances(
frontmatter[this.mapping.skippedInstances]
);
}
if (this.mapping.blockedBy && frontmatter[this.mapping.blockedBy] !== undefined) {
const dependencies = normalizeDependencyList(frontmatter[this.mapping.blockedBy]);
if (dependencies) {
mapped.blockedBy = dependencies;
}
}
if (frontmatter[this.mapping.icsEventId] !== undefined) {
const icsEventId = frontmatter[this.mapping.icsEventId];
// Ensure icsEventId is always an array
mapped.icsEventId = Array.isArray(icsEventId) ? icsEventId : [icsEventId];
}
if (frontmatter[this.mapping.googleCalendarEventId] !== undefined) {
mapped.googleCalendarEventId = frontmatter[this.mapping.googleCalendarEventId];
}
if (frontmatter[this.mapping.reminders] !== undefined) {
const reminders = frontmatter[this.mapping.reminders];
// Ensure reminders is always an array and filter out null/undefined values
if (Array.isArray(reminders)) {
const filteredReminders = reminders.filter((r) => r != null);
if (filteredReminders.length > 0) {
mapped.reminders = filteredReminders;
}
} else if (reminders != null) {
mapped.reminders = [reminders];
}
}
if (frontmatter[this.mapping.sortOrder] !== undefined) {
const val = frontmatter[this.mapping.sortOrder];
mapped.sortOrder = typeof val === "string" ? val : String(val);
}
// Handle tags array (includes archive tag)
if (frontmatter.tags && Array.isArray(frontmatter.tags)) {
mapped.tags = frontmatter.tags;
mapped.archived = frontmatter.tags.includes(this.mapping.archiveTag);
}
return mapped;
return mapTaskFromFrontmatter(this.mapping, frontmatter, filePath, storeTitleInFilename);
}
/**
@ -199,124 +43,7 @@ export class FieldMapper {
taskTag?: string,
storeTitleInFilename?: boolean
): any {
const frontmatter: any = {};
// Map each field if it exists in task data
if (taskData.title !== undefined) {
frontmatter[this.mapping.title] = taskData.title;
}
// Note: title is always kept in frontmatter for CLI/API compatibility,
// even when storeTitleInFilename is true (filename is derived from title)
if (taskData.status !== undefined) {
// Coerce boolean-like status strings to actual booleans for compatibility with Obsidian checkbox properties
const lower = taskData.status.toLowerCase();
const coercedValue =
lower === "true" || lower === "false" ? lower === "true" : taskData.status;
frontmatter[this.mapping.status] = coercedValue;
}
if (taskData.priority !== undefined) {
frontmatter[this.mapping.priority] = taskData.priority;
}
if (taskData.due !== undefined) {
frontmatter[this.mapping.due] = taskData.due;
}
if (taskData.scheduled !== undefined) {
frontmatter[this.mapping.scheduled] = taskData.scheduled;
}
if (taskData.contexts !== undefined && (!Array.isArray(taskData.contexts) || taskData.contexts.length > 0)) {
frontmatter[this.mapping.contexts] = taskData.contexts;
}
if (taskData.projects !== undefined && (!Array.isArray(taskData.projects) || taskData.projects.length > 0)) {
frontmatter[this.mapping.projects] = taskData.projects;
}
if (taskData.timeEstimate !== undefined) {
frontmatter[this.mapping.timeEstimate] = taskData.timeEstimate;
}
if (taskData.completedDate !== undefined) {
frontmatter[this.mapping.completedDate] = taskData.completedDate;
}
if (taskData.recurrence !== undefined) {
frontmatter[this.mapping.recurrence] = taskData.recurrence;
}
if (taskData.recurrence_anchor !== undefined) {
frontmatter[this.mapping.recurrenceAnchor] = taskData.recurrence_anchor;
}
if (taskData.dateCreated !== undefined) {
frontmatter[this.mapping.dateCreated] = taskData.dateCreated;
}
if (taskData.dateModified !== undefined) {
frontmatter[this.mapping.dateModified] = taskData.dateModified;
}
if (taskData.sortOrder !== undefined) {
frontmatter[this.mapping.sortOrder] = taskData.sortOrder;
}
if (taskData.timeEntries !== undefined) {
frontmatter[this.mapping.timeEntries] = taskData.timeEntries;
}
if (taskData.complete_instances !== undefined) {
frontmatter[this.mapping.completeInstances] = taskData.complete_instances;
}
if (taskData.skipped_instances !== undefined && taskData.skipped_instances.length > 0) {
frontmatter[this.mapping.skippedInstances] = taskData.skipped_instances;
}
if (taskData.blockedBy !== undefined) {
if (Array.isArray(taskData.blockedBy)) {
const normalized = taskData.blockedBy
.map((item) => normalizeDependencyEntry(item))
.filter((item): item is NonNullable<ReturnType<typeof normalizeDependencyEntry>> => !!item);
if (normalized.length > 0) {
frontmatter[this.mapping.blockedBy] = serializeDependencies(normalized);
}
} else {
frontmatter[this.mapping.blockedBy] = taskData.blockedBy;
}
}
if (taskData.icsEventId !== undefined && taskData.icsEventId.length > 0) {
frontmatter[this.mapping.icsEventId] = taskData.icsEventId;
}
if (taskData.reminders !== undefined && taskData.reminders.length > 0) {
frontmatter[this.mapping.reminders] = taskData.reminders;
}
// Handle tags (merge archive status into tags array)
let tags = taskData.tags ? [...taskData.tags] : [];
// Ensure task tag is always preserved if provided
if (taskTag && !tags.includes(taskTag)) {
tags.push(taskTag);
}
if (taskData.archived === true && !tags.includes(this.mapping.archiveTag)) {
tags.push(this.mapping.archiveTag);
} else if (taskData.archived === false) {
tags = tags.filter((tag) => tag !== this.mapping.archiveTag);
}
if (tags.length > 0) {
frontmatter.tags = tags;
}
return frontmatter;
return mapTaskToFrontmatter(this.mapping, taskData, taskTag, storeTitleInFilename);
}
/**
@ -351,12 +78,7 @@ export class FieldMapper {
* lookupMappingKey("unknown_field") // Returns: null
*/
lookupMappingKey(frontmatterPropertyName: string): keyof FieldMapping | null {
for (const [mappingKey, propertyName] of Object.entries(this.mapping)) {
if (propertyName === frontmatterPropertyName) {
return mappingKey as keyof FieldMapping;
}
}
return null;
return lookupMappingKey(this.mapping, frontmatterPropertyName);
}
/**
@ -367,7 +89,7 @@ export class FieldMapper {
* @returns true if the property is recognized, false otherwise
*/
isRecognizedProperty(frontmatterPropertyName: string): boolean {
return this.lookupMappingKey(frontmatterPropertyName) !== null;
return isRecognizedProperty(this.mapping, frontmatterPropertyName);
}
/**
@ -387,7 +109,7 @@ export class FieldMapper {
* isPropertyForField("status", "status") // true
*/
isPropertyForField(propertyName: string, internalField: keyof FieldMapping): boolean {
return this.mapping[internalField] === propertyName;
return isPropertyForField(this.mapping, propertyName, internalField);
}
/**
@ -402,7 +124,7 @@ export class FieldMapper {
* // Returns: ["task-status", "deadline", "priority"]
*/
toUserFields(internalFields: (keyof FieldMapping)[]): string[] {
return internalFields.map((field) => this.mapping[field]);
return toUserFields(this.mapping, internalFields);
}
/**
@ -418,25 +140,6 @@ export class FieldMapper {
* Validate that a mapping has no empty field names
*/
static validateMapping(mapping: FieldMapping): { valid: boolean; errors: string[] } {
const errors: string[] = [];
const fields = Object.keys(mapping) as (keyof FieldMapping)[];
for (const field of fields) {
if (!mapping[field] || mapping[field].trim() === "") {
errors.push(`Field "${field}" cannot be empty`);
}
}
// Check for duplicate values
const values = Object.values(mapping);
const uniqueValues = new Set(values);
if (values.length !== uniqueValues.size) {
errors.push("Field mappings must have unique property names");
}
return {
valid: errors.length === 0,
errors,
};
return validateFieldMapping(mapping);
}
}

View file

@ -21,10 +21,12 @@ import {
} from "../utils/templateProcessor";
import {
addDTSTARTToRecurrenceRule,
calculateDefaultDate,
updateDTSTARTInRecurrenceRule,
ensureFolderExists,
updateToNextScheduledOccurrence,
} from "../core/recurrence";
import {
calculateDefaultDate,
ensureFolderExists,
splitFrontmatterAndBody,
resetMarkdownCheckboxes,
} from "../utils/helpers";

View file

@ -5,8 +5,9 @@ import {
TaskCreationData,
TaskInfo,
} from "../../types";
import { addDTSTARTToRecurrenceRule } from "../../core/recurrence";
import { FilenameContext, generateTaskFilename, generateUniqueFilename } from "../../utils/filenameGenerator";
import { addDTSTARTToRecurrenceRule, ensureFolderExists } from "../../utils/helpers";
import { ensureFolderExists } from "../../utils/helpers";
import { getCurrentTimestamp } from "../../utils/dateUtils";
import { mergeTemplateFrontmatter } from "../../utils/templateProcessor";
import type TaskNotesPlugin from "../../main";

View file

@ -4,8 +4,10 @@ import type TaskNotesPlugin from "../../main";
import { EVENT_TASK_UPDATED, IWebhookNotifier, TaskInfo } from "../../types";
import {
addDTSTARTToRecurrenceRule,
splitFrontmatterAndBody,
updateToNextScheduledOccurrence,
} from "../../core/recurrence";
import {
splitFrontmatterAndBody,
} from "../../utils/helpers";
import { generateUniqueFilename } from "../../utils/filenameGenerator";
import { getCurrentDateString, getCurrentTimestamp } from "../../utils/dateUtils";

View file

@ -1,17 +1,26 @@
import { normalizePath, TFile, Vault, App, parseYaml, stringifyYaml } from "obsidian";
import { format } from "date-fns";
import { RRule } from "rrule";
import { TimeInfo, TaskInfo, TimeEntry, TimeBlock, DailyNoteFrontmatter } from "../types";
import { FieldMapper } from "../services/FieldMapper";
import { DEFAULT_FIELD_MAPPING } from "../settings/defaults";
import {
addDTSTARTToRecurrenceRule as addDTSTARTToRecurrenceRuleCore,
addDTSTARTToRecurrenceRuleWithDraggedTime as addDTSTARTToRecurrenceRuleWithDraggedTimeCore,
generateRecurringInstances as generateRecurringInstancesCore,
getEffectiveTaskStatus as getEffectiveTaskStatusCore,
getNextUncompletedOccurrence as getNextUncompletedOccurrenceCore,
getRecurrenceDisplayText as getRecurrenceDisplayTextCore,
getRecurringTaskCompletionText as getRecurringTaskCompletionTextCore,
isDueByRRule as isDueByRRuleCore,
shouldShowRecurringTaskOnDate as shouldShowRecurringTaskOnDateCore,
shouldUseRecurringTaskUI as shouldUseRecurringTaskUICore,
updateDTSTARTInRecurrenceRule as updateDTSTARTInRecurrenceRuleCore,
updateToNextScheduledOccurrence as updateToNextScheduledOccurrenceCore,
} from "../core/recurrence";
import {
getTodayString,
parseDateToLocal,
createUTCDateForRRule,
formatDateForStorage,
formatDateAsUTCString,
hasTimeComponent,
parseDateToUTC,
isBeforeDateSafe as _isBeforeDateSafe,
getTodayLocal as _getTodayLocal,
} from "./dateUtils";
@ -371,230 +380,42 @@ export function resetMarkdownCheckboxes(content: string): {
* Checks if a recurring task is due on a specific date using RFC 5545 rrule
*/
export function isDueByRRule(task: TaskInfo, date: Date): boolean {
// If no recurrence, non-recurring task is always shown
if (!task.recurrence) {
return true;
}
// If recurrence is a string (rrule format), process it
if (typeof task.recurrence === "string") {
try {
// Parse DTSTART from RRULE string if present, otherwise use fallback logic
let dtstart: Date;
const dtstartMatch = task.recurrence.match(/DTSTART:(\d{8}(?:T\d{6}Z?)?)/);
if (dtstartMatch) {
// Extract DTSTART from RRULE string (supports both YYYYMMDD and YYYYMMDDTHHMMSSZ formats)
const dtstartStr = dtstartMatch[1];
if (dtstartStr.length === 8) {
// YYYYMMDD format
const year = parseInt(dtstartStr.slice(0, 4));
const month = parseInt(dtstartStr.slice(4, 6)) - 1; // JavaScript months are 0-indexed
const day = parseInt(dtstartStr.slice(6, 8));
dtstart = new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
} else {
// YYYYMMDDTHHMMSSZ format - parse manually since createUTCDateForRRule expects YYYY-MM-DD
const year = parseInt(dtstartStr.slice(0, 4));
const month = parseInt(dtstartStr.slice(4, 6)) - 1; // JavaScript months are 0-indexed
const day = parseInt(dtstartStr.slice(6, 8));
const hour = parseInt(dtstartStr.slice(9, 11)) || 0;
const minute = parseInt(dtstartStr.slice(11, 13)) || 0;
const second = parseInt(dtstartStr.slice(13, 15)) || 0;
dtstart = new Date(Date.UTC(year, month, day, hour, minute, second, 0));
}
} else {
// Fallback to original logic for backward compatibility
if (task.scheduled) {
dtstart = createUTCDateForRRule(task.scheduled);
} else if (task.dateCreated) {
dtstart = createUTCDateForRRule(task.dateCreated);
} else {
// If no anchor date available, task cannot generate recurring instances
return false;
}
}
// Parse the rrule string and create RRule object
// Remove DTSTART from the string before parsing since we set it manually
const rruleString = task.recurrence.replace(/DTSTART:[^;]+;?/, "");
const rruleOptions = RRule.parseString(rruleString);
rruleOptions.dtstart = dtstart;
const rrule = new RRule(rruleOptions);
// Check if the target date is an occurrence
// Use UTC date to match the dtstart timezone
const targetDateStart = createUTCDateForRRule(formatDateAsUTCString(date));
const occurrences = rrule.between(
targetDateStart,
new Date(targetDateStart.getTime() + 24 * 60 * 60 * 1000 - 1),
true
);
return occurrences.length > 0;
} catch (error) {
console.error("Error evaluating rrule:", error, {
task: task.title,
recurrence: task.recurrence,
});
// Fall back to treating as non-recurring on error
return true;
}
}
// If we get here, it's a non-rrule recurrence string - treat as always due
return true;
return isDueByRRuleCore(task, date);
}
/**
* Gets the effective status of a task, considering recurrence
*/
export function getEffectiveTaskStatus(task: any, date: Date, completedStatus?: string): string {
if (!task.recurrence) {
return task.status || "open";
}
// If it has recurrence, check if it's completed for the specified date
const dateStr = formatDateForStorage(date);
const completedDates = Array.isArray(task.complete_instances) ? task.complete_instances : [];
return completedDates.includes(dateStr)
? (completedStatus || "done")
: (task.status || "open");
return getEffectiveTaskStatusCore(task, date, completedStatus);
}
/**
* Checks if a recurring task should be due on the current target date
*/
export function shouldShowRecurringTaskOnDate(task: TaskInfo, targetDate: Date): boolean {
if (!task.recurrence) return true; // Non-recurring tasks are always shown
return isDueByRRule(task, targetDate);
return shouldShowRecurringTaskOnDateCore(task, targetDate);
}
/**
* Gets the completion state text for a recurring task on a specific date
*/
export function getRecurringTaskCompletionText(task: TaskInfo, targetDate: Date): string {
if (!task.recurrence) return "";
const dateStr = formatDateForStorage(targetDate);
const isCompleted = task.complete_instances?.includes(dateStr) || false;
return isCompleted ? "Completed for this date" : "Not completed for this date";
return getRecurringTaskCompletionTextCore(task, targetDate);
}
/**
* Checks if a task should use recurring task UI behavior
*/
export function shouldUseRecurringTaskUI(task: TaskInfo): boolean {
return !!task.recurrence;
return shouldUseRecurringTaskUICore(task);
}
/**
* Generates recurring task instances within a date range using rrule
*/
export function generateRecurringInstances(task: TaskInfo, startDate: Date, endDate: Date): Date[] {
// If no recurrence, return empty array
if (!task.recurrence) {
return [];
}
// If recurrence is a string (rrule format), use rrule
if (typeof task.recurrence === "string") {
try {
// Parse DTSTART from RRULE string if present, otherwise use fallback logic
let dtstart: Date;
const dtstartMatch = task.recurrence.match(/DTSTART:(\d{8}(?:T\d{6}Z?)?)/);
if (dtstartMatch) {
// Extract DTSTART from RRULE string (supports both YYYYMMDD and YYYYMMDDTHHMMSSZ formats)
const dtstartStr = dtstartMatch[1];
if (dtstartStr.length === 8) {
// YYYYMMDD format
const year = parseInt(dtstartStr.slice(0, 4));
const month = parseInt(dtstartStr.slice(4, 6)) - 1; // JavaScript months are 0-indexed
const day = parseInt(dtstartStr.slice(6, 8));
dtstart = new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
} else {
// YYYYMMDDTHHMMSSZ format - parse manually since createUTCDateForRRule expects YYYY-MM-DD
const year = parseInt(dtstartStr.slice(0, 4));
const month = parseInt(dtstartStr.slice(4, 6)) - 1; // JavaScript months are 0-indexed
const day = parseInt(dtstartStr.slice(6, 8));
const hour = parseInt(dtstartStr.slice(9, 11)) || 0;
const minute = parseInt(dtstartStr.slice(11, 13)) || 0;
const second = parseInt(dtstartStr.slice(13, 15)) || 0;
dtstart = new Date(Date.UTC(year, month, day, hour, minute, second, 0));
}
} else {
// Fallback to original logic for backward compatibility
if (task.scheduled) {
dtstart = createUTCDateForRRule(task.scheduled);
} else if (task.dateCreated) {
dtstart = createUTCDateForRRule(task.dateCreated);
} else {
// If no anchor date available, task cannot generate recurring instances
return [];
}
}
// Parse the rrule string and create RRule object
// Remove DTSTART from the string before parsing since we set it manually
const rruleString = task.recurrence.replace(/DTSTART:[^;]+;?/, "");
const rruleOptions = RRule.parseString(rruleString);
rruleOptions.dtstart = dtstart;
const rrule = new RRule(rruleOptions);
// Convert start and end dates to UTC to match dtstart
// Use getUTC* methods to extract UTC date components, not local timezone components
// This prevents off-by-one day errors for users in non-UTC timezones (issue #1582)
const utcStartDate = new Date(
Date.UTC(
startDate.getUTCFullYear(),
startDate.getUTCMonth(),
startDate.getUTCDate(),
0,
0,
0,
0
)
);
const utcEndDate = new Date(
Date.UTC(
endDate.getUTCFullYear(),
endDate.getUTCMonth(),
endDate.getUTCDate(),
23,
59,
59,
999
)
);
// Generate occurrences within the date range
return rrule.between(utcStartDate, utcEndDate, true);
} catch (error) {
console.error("Error generating recurring instances:", error, {
task: task.title,
recurrence: task.recurrence,
});
// Fall back to legacy method on error
}
}
// Fall back to legacy method (for object recurrence or errors)
const instances: Date[] = [];
const current = new Date(startDate);
while (current <= endDate) {
if (isDueByRRule(task, current)) {
instances.push(new Date(current));
}
current.setUTCDate(current.getUTCDate() + 1);
}
return instances;
return generateRecurringInstancesCore(task, startDate, endDate);
}
/**
@ -602,184 +423,7 @@ export function generateRecurringInstances(task: TaskInfo, startDate: Date, endD
* Returns null if no future occurrences exist
*/
export function getNextUncompletedOccurrence(task: TaskInfo): Date | null {
// If no recurrence, return null
if (!task.recurrence) {
return null;
}
const anchor = task.recurrence_anchor || 'scheduled'; // Default to scheduled
if (anchor === 'completion') {
// For completion-based recurrence, DTSTART is updated on each completion
// So we just need to get the next occurrence from the RRULE
// We don't filter by complete_instances because the DTSTART has already been shifted
return getNextCompletionBasedOccurrence(task);
} else {
// For scheduled-based recurrence, DTSTART is fixed
// We need to generate occurrences and filter out completed ones
return getNextScheduledBasedOccurrence(task);
}
}
function parseIntervalFromRecurrence(recurrence: string): number {
const match = recurrence.match(/INTERVAL=(\d+)/);
return match ? parseInt(match[1], 10) : 1;
}
/**
* Gets next occurrence for scheduled-based (fixed) recurrence
*/
function getNextScheduledBasedOccurrence(task: TaskInfo): Date | null {
if (!task.recurrence) {
return null;
}
try {
// Get current date as starting point using UTC anchor principle
const todayStr = getTodayString(); // YYYY-MM-DD format
const today = parseDateToUTC(todayStr); // UTC anchored for consistent logic
// Determine look-ahead period based on recurrence frequency
// This ensures we can find at least one future occurrence
// Scale with INTERVAL to handle large intervals (e.g. DAILY;INTERVAL=60)
const interval = parseIntervalFromRecurrence(task.recurrence);
let lookAheadDays = 365; // Default: 1 year
if (task.recurrence.includes("FREQ=DAILY")) {
lookAheadDays = Math.max(30, interval * 1 * 2);
} else if (task.recurrence.includes("FREQ=WEEKLY")) {
lookAheadDays = Math.max(90, interval * 7 * 2);
} else if (task.recurrence.includes("FREQ=MONTHLY")) {
lookAheadDays = Math.max(400, interval * 31 * 2);
} else if (task.recurrence.includes("FREQ=YEARLY")) {
lookAheadDays = Math.max(800, interval * 366 * 2);
}
// Start from the DTSTART (or earlier) to ensure we catch all occurrences
// This handles cases where DTSTART is in the past
let startDate = today;
if (task.recurrence.includes("DTSTART:")) {
const dtstartMatch = task.recurrence.match(/DTSTART:(\d{8}(?:T\d{6}Z?)?)/);
if (dtstartMatch) {
const dtstartStr = dtstartMatch[1];
if (dtstartStr.length === 8) {
// YYYYMMDD format
const year = parseInt(dtstartStr.slice(0, 4));
const month = parseInt(dtstartStr.slice(4, 6)) - 1;
const day = parseInt(dtstartStr.slice(6, 8));
const dtstart = new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
// Use the earlier of today or dtstart
startDate = dtstart < today ? dtstart : today;
}
}
}
const endDate = new Date(today.getTime() + lookAheadDays * 24 * 60 * 60 * 1000);
// Generate all occurrences from startDate onwards
const occurrences = generateRecurringInstances(task, startDate, endDate);
// Combine completed AND skipped instances for faster lookup
const processedInstances = new Set([
...(task.complete_instances || []),
...(task.skipped_instances || []),
]);
// Find the first occurrence that hasn't been completed OR skipped AND is today or in the future
for (const occurrence of occurrences) {
const occurrenceStr = formatDateForStorage(occurrence);
if (!processedInstances.has(occurrenceStr) && occurrence >= today) {
return occurrence;
}
}
return null; // No future occurrences or all completed/skipped
} catch (error) {
console.error("Error calculating next scheduled-based occurrence:", error, {
task: task.title,
});
return null;
}
}
/**
* Gets next occurrence for completion-based (flexible) recurrence
* For completion-based recurrence, the DTSTART in the RRULE is updated on each completion
* to the completion date, so we simply get the next occurrence from the current DTSTART
*/
function getNextCompletionBasedOccurrence(task: TaskInfo): Date | null {
if (!task.recurrence || typeof task.recurrence !== 'string') {
return null;
}
try {
// Get current date as starting point using UTC anchor principle
const todayStr = getTodayString(); // YYYY-MM-DD format
const today = parseDateToUTC(todayStr); // UTC anchored for consistent logic
// Determine look-ahead period based on recurrence frequency
// This ensures we can find at least one future occurrence
// Scale with INTERVAL to handle large intervals (e.g. DAILY;INTERVAL=60)
const interval = parseIntervalFromRecurrence(task.recurrence);
let lookAheadDays = 365; // Default: 1 year
if (task.recurrence.includes("FREQ=DAILY")) {
lookAheadDays = Math.max(30, interval * 1 * 2);
} else if (task.recurrence.includes("FREQ=WEEKLY")) {
lookAheadDays = Math.max(90, interval * 7 * 2);
} else if (task.recurrence.includes("FREQ=MONTHLY")) {
lookAheadDays = Math.max(400, interval * 31 * 2);
} else if (task.recurrence.includes("FREQ=YEARLY")) {
lookAheadDays = Math.max(800, interval * 366 * 2);
}
// Extract DTSTART date from the RRULE
let dtstartDate: Date | null = null;
if (task.recurrence.includes("DTSTART:")) {
const dtstartMatch = task.recurrence.match(/DTSTART:(\d{8}(?:T\d{6}Z?)?)/);
if (dtstartMatch) {
const dtstartStr = dtstartMatch[1];
if (dtstartStr.length === 8) {
// YYYYMMDD format
const year = parseInt(dtstartStr.slice(0, 4));
const month = parseInt(dtstartStr.slice(4, 6)) - 1;
const day = parseInt(dtstartStr.slice(6, 8));
dtstartDate = new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
}
}
}
// For completion-based recurrence, we want the NEXT occurrence AFTER the DTSTART (completion date)
// Start from DTSTART (if available) and look forward
const startDate = dtstartDate || today;
const endDate = new Date(startDate.getTime() + lookAheadDays * 24 * 60 * 60 * 1000);
// Generate occurrences from the RRULE
const occurrences = generateRecurringInstances(task, startDate, endDate);
// For completion-based recurrence, we DON'T filter by complete_instances
// because the DTSTART has already been shifted to the latest completion date
// However, we DO filter out skipped instances
const skippedInstances = new Set(task.skipped_instances || []);
// Find the first occurrence that is AFTER the DTSTART (not equal to it) and not skipped
const dtstartTime = dtstartDate ? dtstartDate.getTime() : 0;
for (const occurrence of occurrences) {
const occurrenceStr = formatDateForStorage(occurrence);
if (
occurrence.getTime() > dtstartTime &&
occurrence >= today &&
!skippedInstances.has(occurrenceStr)
) {
return occurrence;
}
}
return null; // No future occurrences
} catch (error) {
console.error("Error calculating completion-based recurrence:", error, {
task: task.title,
});
return null;
}
return getNextUncompletedOccurrenceCore(task);
}
/**
@ -792,75 +436,14 @@ export function updateToNextScheduledOccurrence(
task: TaskInfo,
maintainDueOffset = true
): { scheduled: string | null; due: string | null } {
const nextOccurrence = getNextUncompletedOccurrence(task);
let nextScheduleStr: string | null = null;
let nextDueStr: string | null = null;
let nextDueDate: Date | null = null;
if (nextOccurrence) {
// Calculate the offset between original scheduled and due dates (only if setting is enabled)
if (maintainDueOffset) {
try {
const originalScheduled = task.scheduled ? parseDateToUTC(task.scheduled) : null;
const originalDue = task.due ? parseDateToUTC(task.due) : null;
if (originalScheduled && originalDue) {
// Calculate the time difference
const offsetMs = originalDue.getTime() - originalScheduled.getTime();
if (nextOccurrence) {
// Apply the same offset to get the new due date
nextDueDate = new Date(nextOccurrence.getTime() + offsetMs);
}
}
} catch (error) {
console.error("Error calculating next due date with offset:", error);
}
}
// Preserve time component if original scheduled date had time
if (task.scheduled && task.scheduled.includes("T")) {
const timePart = task.scheduled.split("T")[1];
nextScheduleStr = `${formatDateForStorage(nextOccurrence)}T${timePart}`;
} else {
nextScheduleStr = formatDateForStorage(nextOccurrence);
}
if (nextDueDate && task.due && task.due.includes("T")) {
const timePart = task.due.split("T")[1];
nextDueStr = `${formatDateForStorage(nextDueDate)}T${timePart}`;
} else if (nextDueDate) {
nextDueStr = formatDateForStorage(nextDueDate);
}
}
return {
scheduled: nextScheduleStr,
due: nextDueStr,
};
return updateToNextScheduledOccurrenceCore(task, maintainDueOffset);
}
/**
* Converts rrule string to human-readable text
*/
export function getRecurrenceDisplayText(recurrence: string): string {
if (!recurrence) {
return "";
}
try {
// Handle rrule string format
if (recurrence.includes("FREQ=")) {
// Remove DTSTART from the string before parsing since RRule.fromString() expects only RRULE parameters
const rruleString = recurrence.replace(/DTSTART:[^;]+;?/, "");
const rrule = RRule.fromString(rruleString);
return rrule.toText();
}
// Fallback for unknown format
return "rrule";
} catch (error) {
console.error("Error converting recurrence to display text:", error, { recurrence });
return "rrule";
}
return getRecurrenceDisplayTextCore(recurrence);
}
/**
@ -1311,55 +894,7 @@ export function filterEmptyProjects(projects: string[]): string[] {
* Follows the UTC Anchor principle for consistent date handling
*/
export function addDTSTARTToRecurrenceRule(task: TaskInfo): string | null {
if (!task.recurrence || typeof task.recurrence !== "string") {
return null;
}
// Check if DTSTART is already present
if (task.recurrence.includes("DTSTART:")) {
return task.recurrence; // Already has DTSTART, return as-is
}
// Determine the source date string using the same fallback logic as isDueByRRule
let sourceDateString: string;
if (task.scheduled) {
sourceDateString = task.scheduled;
} else if (task.dateCreated) {
sourceDateString = task.dateCreated;
} else {
// No anchor date available, cannot add DTSTART
return null;
}
try {
// Use the plugin's established date handling approach
let dtstartValue: string;
if (hasTimeComponent(sourceDateString)) {
// Has time component - parse as local time for accuracy, then format for DTSTART
const dateTime = parseDateToLocal(sourceDateString);
const year = dateTime.getFullYear();
const month = String(dateTime.getMonth() + 1).padStart(2, "0");
const day = String(dateTime.getDate()).padStart(2, "0");
const hours = String(dateTime.getHours()).padStart(2, "0");
const minutes = String(dateTime.getMinutes()).padStart(2, "0");
const seconds = String(dateTime.getSeconds()).padStart(2, "0");
dtstartValue = `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
} else {
// Date-only - use UTC anchor principle for consistency
const date = parseDateToUTC(sourceDateString);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
dtstartValue = `${year}${month}${day}`;
}
// Add DTSTART at the beginning of the recurrence rule
return `DTSTART:${dtstartValue};${task.recurrence}`;
} catch (error) {
console.error("Error parsing date for DTSTART:", error, { sourceDateString });
return null; // Return null on parsing errors
}
return addDTSTARTToRecurrenceRuleCore(task);
}
/**
@ -1373,45 +908,7 @@ export function updateDTSTARTInRecurrenceRule(
recurrence: string,
dateStr: string
): string | null {
if (!recurrence || typeof recurrence !== "string") {
return null;
}
try {
// Format the new DTSTART value
let dtstartValue: string;
if (hasTimeComponent(dateStr)) {
// Has time component - parse as local time for accuracy, then format for DTSTART
const dateTime = parseDateToLocal(dateStr);
const year = dateTime.getFullYear();
const month = String(dateTime.getMonth() + 1).padStart(2, "0");
const day = String(dateTime.getDate()).padStart(2, "0");
const hours = String(dateTime.getHours()).padStart(2, "0");
const minutes = String(dateTime.getMinutes()).padStart(2, "0");
const seconds = String(dateTime.getSeconds()).padStart(2, "0");
dtstartValue = `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
} else {
// Date-only - use UTC anchor principle for consistency
const date = parseDateToUTC(dateStr);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
dtstartValue = `${year}${month}${day}`;
}
// Check if DTSTART is already present
if (recurrence.includes("DTSTART:")) {
// Replace existing DTSTART
return recurrence.replace(/DTSTART:[^;]+;?/, `DTSTART:${dtstartValue};`);
} else {
// Add DTSTART at the beginning
return `DTSTART:${dtstartValue};${recurrence}`;
}
} catch (error) {
console.error("Error updating DTSTART in recurrence rule:", error, { dateStr });
return null;
}
return updateDTSTARTInRecurrenceRuleCore(recurrence, dateStr);
}
/**
@ -1424,60 +921,7 @@ export function addDTSTARTToRecurrenceRuleWithDraggedTime(
draggedStart: Date,
allDay: boolean
): string | null {
if (!task.recurrence || typeof task.recurrence !== "string") {
return null;
}
// Check if DTSTART is already present
if (task.recurrence.includes("DTSTART:")) {
return task.recurrence; // Already has DTSTART, return as-is
}
// Determine the source date string using the same fallback logic as isDueByRRule
let sourceDateString: string;
if (task.scheduled) {
sourceDateString = task.scheduled;
} else if (task.dateCreated) {
sourceDateString = task.dateCreated;
} else {
// No anchor date available, cannot add DTSTART
return null;
}
try {
// Use the plugin's established date handling approach
let dtstartValue: string;
if (allDay) {
// All-day event - use date-only format from the source date (not draggedStart)
const date = parseDateToUTC(sourceDateString);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
dtstartValue = `${year}${month}${day}`;
} else {
// Timed event - use date from source, time from draggedStart
const sourceDate = parseDateToUTC(sourceDateString);
const year = sourceDate.getUTCFullYear();
const month = String(sourceDate.getUTCMonth() + 1).padStart(2, "0");
const day = String(sourceDate.getUTCDate()).padStart(2, "0");
// Use the time from the dragged position
const hours = String(draggedStart.getHours()).padStart(2, "0");
const minutes = String(draggedStart.getMinutes()).padStart(2, "0");
dtstartValue = `${year}${month}${day}T${hours}${minutes}00Z`;
}
// Add DTSTART at the beginning of the recurrence rule
return `DTSTART:${dtstartValue};${task.recurrence}`;
} catch (error) {
console.error("Error parsing date for DTSTART with dragged time:", error, {
sourceDateString,
draggedStart,
allDay,
});
return null; // Return null on parsing errors
}
return addDTSTARTToRecurrenceRuleWithDraggedTimeCore(task, draggedStart, allDay);
}
/**

View file

@ -0,0 +1,69 @@
import {
mapTaskFromFrontmatter,
mapTaskToFrontmatter,
validateFieldMapping,
} from "../../../src/core/fieldMapping";
import { DEFAULT_FIELD_MAPPING } from "../../../src/settings/defaults";
describe("core/fieldMapping", () => {
it("maps frontmatter into task fields without a FieldMapper instance", () => {
const mapped = mapTaskFromFrontmatter(
DEFAULT_FIELD_MAPPING,
{
title: "Mapped title",
status: true,
contexts: "work",
projects: ["alpha"],
recurrence_anchor: "completion",
complete_instances: ["2026-03-01", 123, "2026-03-02"],
tags: ["task", "archived"],
},
"Tasks/Mapped title.md",
true
);
expect(mapped).toMatchObject({
title: "Mapped title",
status: "true",
contexts: ["work"],
projects: ["alpha"],
recurrence_anchor: "completion",
complete_instances: ["2026-03-01", "2026-03-02"],
tags: ["task", "archived"],
archived: true,
path: "Tasks/Mapped title.md",
});
});
it("maps task fields back to frontmatter with serialized dependency data", () => {
const frontmatter = mapTaskToFrontmatter(
DEFAULT_FIELD_MAPPING,
{
title: "Task",
status: "open",
blockedBy: [{ uid: "[[Other Task]]", reltype: "FINISHTOSTART" }],
recurrence_anchor: "scheduled",
tags: ["alpha"],
archived: true,
},
"task"
);
expect(frontmatter.title).toBe("Task");
expect(frontmatter.status).toBe("open");
expect(frontmatter.recurrence_anchor).toBe("scheduled");
expect(frontmatter.blockedBy).toEqual([{ uid: "[[Other Task]]", reltype: "FINISHTOSTART" }]);
expect(frontmatter.tags).toEqual(expect.arrayContaining(["alpha", "task", "archived"]));
});
it("validates duplicate mapping values as invalid", () => {
const duplicateMapping = {
...DEFAULT_FIELD_MAPPING,
status: "title",
};
const result = validateFieldMapping(duplicateMapping);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Field mappings must have unique property names");
});
});

View file

@ -0,0 +1,54 @@
import {
addDTSTARTToRecurrenceRule,
getNextUncompletedOccurrence,
updateToNextScheduledOccurrence,
} from "../../../src/core/recurrence";
import type { TaskInfo } from "../../../src/types";
import * as dateUtils from "../../../src/utils/dateUtils";
jest.mock("../../../src/utils/dateUtils", () => {
const actual = jest.requireActual("../../../src/utils/dateUtils");
return {
...actual,
getTodayString: jest.fn(() => "2026-03-29"),
};
});
describe("core/recurrence", () => {
it("calculates the next scheduled-based occurrence from a narrow task shape", () => {
const next = getNextUncompletedOccurrence({
title: "Recurring task",
recurrence: "DTSTART:20260301;FREQ=WEEKLY;INTERVAL=1",
recurrence_anchor: "scheduled",
scheduled: "2026-03-01",
complete_instances: ["2026-03-01", "2026-03-08", "2026-03-15", "2026-03-22"],
});
expect(dateUtils.formatDateForStorage(next!)).toBe("2026-03-29");
});
it("preserves due offset when advancing to the next occurrence", () => {
const result = updateToNextScheduledOccurrence({
title: "Task with due offset",
recurrence: "DTSTART:20260301;FREQ=WEEKLY;INTERVAL=1",
recurrence_anchor: "scheduled",
scheduled: "2026-03-22",
due: "2026-03-24",
complete_instances: ["2026-03-01", "2026-03-08", "2026-03-15", "2026-03-22"],
});
expect(result).toEqual({
scheduled: "2026-03-29",
due: "2026-03-31",
});
});
it("adds DTSTART without depending on a full TaskInfo object", () => {
const recurrence = addDTSTARTToRecurrenceRule({
recurrence: "FREQ=DAILY;INTERVAL=2",
scheduled: "2026-03-29",
});
expect(recurrence).toBe("DTSTART:20260329;FREQ=DAILY;INTERVAL=2");
});
});