From 594ee77f67f577c4456efb374bc2e77581efada0 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 31 Mar 2026 05:41:55 +1100 Subject: [PATCH] Extract core logic and add conformance waivers --- conformance/README.md | 33 + conformance/waivers.json | 10 + scripts/run-tasknotes-spec-conformance.mjs | 134 +++- src/core/createCompat.ts | 383 ++++++++++ src/core/date.ts | 88 +++ src/core/fieldMapping.ts | 335 +++++++++ src/core/recurrence.ts | 706 ++++++++++++++++++ src/core/specFieldMapping.ts | 205 +++++ src/services/FieldMapper.ts | 331 +------- src/services/TaskService.ts | 6 +- .../task-service/TaskCreationService.ts | 3 +- .../task-service/TaskUpdateService.ts | 4 +- src/utils/helpers.ts | 608 +-------------- tests/unit/core/fieldMapping.test.ts | 69 ++ tests/unit/core/recurrence.test.ts | 54 ++ 15 files changed, 2059 insertions(+), 910 deletions(-) create mode 100644 conformance/README.md create mode 100644 conformance/waivers.json create mode 100644 src/core/createCompat.ts create mode 100644 src/core/date.ts create mode 100644 src/core/fieldMapping.ts create mode 100644 src/core/recurrence.ts create mode 100644 src/core/specFieldMapping.ts create mode 100644 tests/unit/core/fieldMapping.test.ts create mode 100644 tests/unit/core/recurrence.test.ts diff --git a/conformance/README.md b/conformance/README.md new file mode 100644 index 00000000..fc3517d2 --- /dev/null +++ b/conformance/README.md @@ -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. diff --git a/conformance/waivers.json b/conformance/waivers.json new file mode 100644 index 00000000..77a7fe02 --- /dev/null +++ b/conformance/waivers.json @@ -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." + } + ] +} diff --git a/scripts/run-tasknotes-spec-conformance.mjs b/scripts/run-tasknotes-spec-conformance.mjs index e5999a8a..aa79297d 100644 --- a/scripts/run-tasknotes-spec-conformance.mjs +++ b/scripts/run-tasknotes-spec-conformance.mjs @@ -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); +} diff --git a/src/core/createCompat.ts b/src/core/createCompat.ts new file mode 100644 index 00000000..69ad4040 --- /dev/null +++ b/src/core/createCompat.ts @@ -0,0 +1,383 @@ +import { format } from "date-fns"; +import type { SpecFieldMapping } from "./specFieldMapping"; +import { denormalizeFrontmatter, resolveField } from "./specFieldMapping"; + +type UnknownRecord = Record; + +interface TaskTypeDefLike { + path_pattern?: string; + match?: { + where?: Record; + }; + fields?: Record; +} + +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; + typeDefs?: Map; +}; + +export async function createTaskWithCompat( + collection: CreateCollectionLike, + mapping: SpecFieldMapping, + roleFrontmatter: UnknownRecord, + body?: string, + now?: Date +): Promise { + 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 }; + 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; + 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 +): { path?: string; missingKeys: string[] } { + const missingKeys = new Set(); + + 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 { + const values: Record = {}; + + 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; +} diff --git a/src/core/date.ts b/src/core/date.ts new file mode 100644 index 00000000..4974f1ee --- /dev/null +++ b/src/core/date.ts @@ -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); +} diff --git a/src/core/fieldMapping.ts b/src/core/fieldMapping.ts new file mode 100644 index 00000000..3e93c3a5 --- /dev/null +++ b/src/core/fieldMapping.ts @@ -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 | undefined | null, + filePath: string, + storeTitleInFilename?: boolean +): Partial { + if (!frontmatter) return {}; + + const mapped: Partial = { + 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, + taskTag?: string, + storeTitleInFilename?: boolean +): Record { + const frontmatter: Record = {}; + + 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> => !!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, + }; +} diff --git a/src/core/recurrence.ts b/src/core/recurrence.ts new file mode 100644 index 00000000..f0ef1851 --- /dev/null +++ b/src/core/recurrence.ts @@ -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): 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): 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, + 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, + 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): boolean { + return !!task.recurrence; +} + +export function generateRecurringInstances( + task: Pick, + 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 +): 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, + 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 | 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( + 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); +} diff --git a/src/core/specFieldMapping.ts b/src/core/specFieldMapping.ts new file mode 100644 index 00000000..549d3689 --- /dev/null +++ b/src/core/specFieldMapping.ts @@ -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; + fieldToRole: Record; + displayNameKey: string; + completedStatuses: string[]; +} + +export function defaultFieldMapping(): SpecFieldMapping { + const roleToField = {} as Record; + const fieldToRole = {} as Record; + + 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, + displayNameKey?: string +): SpecFieldMapping { + const roleToField = {} as Record; + const fieldToRole = {} as Record; + const rolesSet = new Set(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, 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, + mapping: SpecFieldMapping +): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(raw)) { + const role = mapping.fieldToRole[key]; + result[role ?? key] = value; + } + return result; +} + +export function denormalizeFrontmatter( + roleData: Record, + mapping: SpecFieldMapping +): Record { + const result: Record = {}; + const rolesSet = new Set(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, + mapping: SpecFieldMapping, + taskPath?: string +): string | undefined { + const candidates = [mapping.displayNameKey, "title"]; + const seen = new Set(); + 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; +} diff --git a/src/services/FieldMapper.ts b/src/services/FieldMapper.ts index 8240c6ee..4fc3f7af 100644 --- a/src/services/FieldMapper.ts +++ b/src/services/FieldMapper.ts @@ -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 { - if (!frontmatter) return {}; - - const mapped: Partial = { - 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> => !!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); } } diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index d851767e..c34e6c71 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -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"; diff --git a/src/services/task-service/TaskCreationService.ts b/src/services/task-service/TaskCreationService.ts index 2804d39c..c9d359ae 100644 --- a/src/services/task-service/TaskCreationService.ts +++ b/src/services/task-service/TaskCreationService.ts @@ -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"; diff --git a/src/services/task-service/TaskUpdateService.ts b/src/services/task-service/TaskUpdateService.ts index c1665b3d..6b1263ef 100644 --- a/src/services/task-service/TaskUpdateService.ts +++ b/src/services/task-service/TaskUpdateService.ts @@ -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"; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index c1f0a58c..9fb9876a 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -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); } /** diff --git a/tests/unit/core/fieldMapping.test.ts b/tests/unit/core/fieldMapping.test.ts new file mode 100644 index 00000000..f07bebe6 --- /dev/null +++ b/tests/unit/core/fieldMapping.test.ts @@ -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"); + }); +}); diff --git a/tests/unit/core/recurrence.test.ts b/tests/unit/core/recurrence.test.ts new file mode 100644 index 00000000..71584e8b --- /dev/null +++ b/tests/unit/core/recurrence.test.ts @@ -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"); + }); +});