diff --git a/.gitignore b/.gitignore index 457058e7..59d859d4 100644 --- a/.gitignore +++ b/.gitignore @@ -85,8 +85,7 @@ tasknotes-e2e-vault/*.ics /.ops AGENTS.md /docs-builder/dist -/packages/model/dist/ -.ops/ +.ops/ .serena/ /.codex diff --git a/package-lock.json b/package-lock.json index bac1ea3f..86671745 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,6 @@ "name": "tasknotes", "version": "4.9.2", "license": "MIT", - "workspaces": [ - "packages/model" - ], "dependencies": { "@codemirror/view": "^6.38.6", "@fullcalendar/core": "^6.1.17", @@ -20,7 +17,7 @@ "@fullcalendar/multimonth": "^6.1.17", "@fullcalendar/timegrid": "^6.1.17", "@modelcontextprotocol/sdk": "^1.12.1", - "@tasknotes/model": "file:packages/model", + "@tasknotes/model": "file:../tasknotes-model", "chrono-node": "^2.7.5", "date-fns": "^4.1.0", "ical.js": "^2.2.1", @@ -62,6 +59,20 @@ "typescript": "^5.9.2" } }, + "../tasknotes-model": { + "name": "@tasknotes/model", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "rrule": "^2.8.1", + "yaml": "^2.3.1", + "zod": "^3.24.0" + }, + "devDependencies": { + "esbuild": "^0.25.9", + "typescript": "^5.9.2" + } + }, "../tasknotes-nlp-core": { "version": "0.1.0", "extraneous": true, @@ -3568,7 +3579,7 @@ } }, "node_modules/@tasknotes/model": { - "resolved": "packages/model", + "resolved": "../tasknotes-model", "link": true }, "node_modules/@tybys/wasm-util": { @@ -15164,20 +15175,6 @@ "peerDependencies": { "zod": "^3.25 || ^4" } - }, - "packages/model": { - "name": "@tasknotes/model", - "version": "0.1.0", - "license": "MIT", - "dependencies": { - "rrule": "^2.8.1", - "yaml": "^2.3.1", - "zod": "^3.24.0" - }, - "devDependencies": { - "esbuild": "^0.25.9", - "typescript": "^5.9.2" - } } } } diff --git a/package.json b/package.json index 223a4411..3e20a8cf 100644 --- a/package.json +++ b/package.json @@ -3,14 +3,9 @@ "version": "4.9.2", "description": "Note-based task management with calendar, pomodoro and time-tracking integration.", "main": "main.js", - "workspaces": [ - "packages/model" - ], "scripts": { - "build": "npm run build:model && npm run build-css && node generate-release-notes-import.mjs && tsc -noEmit --skipLibCheck && node esbuild.config.mjs production", - "build:model": "npm --workspace @tasknotes/model run build", - "test:model": "npm --workspace @tasknotes/model run test", - "dev": "npm run build:model && npm run build-css && node generate-release-notes-import.mjs && node esbuild.config.mjs", + "build": "npm run build-css && node generate-release-notes-import.mjs && tsc -noEmit --skipLibCheck && node esbuild.config.mjs production", + "dev": "npm run build-css && node generate-release-notes-import.mjs && node esbuild.config.mjs", "build:test": "npm run build && node copy-files.mjs", "copy-files": "node copy-files.mjs", "build-css": "node build-css.mjs", @@ -84,7 +79,7 @@ "typescript": "^5.9.2" }, "dependencies": { - "@tasknotes/model": "file:packages/model", + "@tasknotes/model": "file:../tasknotes-model", "@codemirror/view": "^6.38.6", "@fullcalendar/core": "^6.1.17", "@fullcalendar/daygrid": "^6.1.17", diff --git a/packages/model/README.md b/packages/model/README.md deleted file mode 100644 index 2b754f54..00000000 --- a/packages/model/README.md +++ /dev/null @@ -1,145 +0,0 @@ -# @tasknotes/model - -Portable TaskNotes model semantics for JavaScript and TypeScript consumers. This package is the shared, host-independent implementation of the TaskNotes data model. - -It intentionally contains no Obsidian API usage, no vault IO, no process exits, and no UI code. Hosts such as the Obsidian plugin, `mdbase-tasknotes`, companion plugins, or automation tools should use this package for deterministic TaskNotes behavior, then perform their own persistence and presentation. - -## Responsibilities - -`@tasknotes/model` owns: - -- TaskNotes task, config, field mapping, status, priority, recurrence, and time-entry types -- default model configuration -- TaskNotes frontmatter mapping and normalization -- date parsing, date comparison, and storage-date semantics -- recurrence evaluation and schedule advancement -- time tracking entry planning and duration calculation -- validation helpers -- host-independent operation plans for common task mutations -- spec-normalized adapter helpers for CLI and mdbase-style consumers -- tasknotes-spec conformance operation helpers - -Hosts own: - -- file, vault, database, or network IO -- Obsidian app APIs and metadata cache reads -- command registration and UI state -- notifications, logging, process exits, and error presentation -- sync provider lifecycles -- path resolution against host-specific collection or vault rules - -## Module Map - -The package exports both the root module and focused subpath modules: - -| Module | Purpose | -| --- | --- | -| `@tasknotes/model` | Main barrel export for all public APIs | -| `@tasknotes/model/types` | Shared TaskNotes model and operation types | -| `@tasknotes/model/defaults` | Default field mapping, statuses, priorities, and model config | -| `@tasknotes/model/config` | Model config resolution and tasknotes-spec field mapping helpers | -| `@tasknotes/model/date` | Date parsing, validation, comparison, and storage formatting | -| `@tasknotes/model/mapping` | TaskNotes frontmatter mapping, dependency mapping, and value normalization | -| `@tasknotes/model/schema` | Zod schemas for model validation | -| `@tasknotes/model/recurrence` | Recurrence evaluation, DTSTART handling, and schedule recalculation | -| `@tasknotes/model/time` | Time-entry sanitizing, timer plans, and duration totals | -| `@tasknotes/model/validation` | Task and time-entry validation | -| `@tasknotes/model/operations` | Host-independent task mutation planning | -| `@tasknotes/model/frontmatter` | Markdown task document parse/serialize helpers | -| `@tasknotes/model/conformance` | tasknotes-spec conformance operation dispatcher | - -## Examples - -Map TaskNotes frontmatter into normalized task data: - -```ts -import { DEFAULT_FIELD_MAPPING, mapTaskFromFrontmatter } from "@tasknotes/model"; - -const task = mapTaskFromFrontmatter( - DEFAULT_FIELD_MAPPING, - { - title: "Ship package", - status: "open", - priority: "normal", - scheduled: "2026-06-01", - }, - "Tasks/Ship package.md", - false, - [] -); -``` - -Advance a recurring task without doing any host IO: - -```ts -import { completeRecurringTask } from "@tasknotes/model/recurrence"; - -const result = completeRecurringTask({ - recurrence: "FREQ=DAILY", - scheduled: "2026-06-01", - completionDate: "2026-06-01", - completeInstances: [], - skippedInstances: [], -}); - -// Host decides how to persist result.updatedRecurrence, -// result.nextScheduled, result.completeInstances, and result.skippedInstances. -``` - -Build a spec-normalized adapter update for a CLI or mdbase-style surface: - -```ts -import { buildSpecCompleteTaskUpdate } from "@tasknotes/model/operations"; - -const plan = buildSpecCompleteTaskUpdate({ - frontmatter: { - title: "Daily check", - status: "open", - priority: "normal", - recurrence: "FREQ=DAILY", - scheduled: "2026-06-01", - completeInstances: [], - skippedInstances: [], - }, - targetDate: "2026-06-01", - completedStatus: "done", - currentTimestamp: new Date().toISOString(), -}); - -// plan.fields is spec-normalized. A host can denormalize it to its own field names -// before writing to a file, database, or collection. -``` - -Start and stop time tracking: - -```ts -import { - buildStartTimeTrackingPlan, - buildStopTimeTrackingPlan, - getActiveTimeEntry, -} from "@tasknotes/model/time"; - -const start = buildStartTimeTrackingPlan(task, "2026-06-01T09:00:00Z"); -const active = getActiveTimeEntry(start.updatedTask); - -if (active) { - const stop = buildStopTimeTrackingPlan( - start.updatedTask, - active, - "2026-06-01T09:30:00Z" - ); -} -``` - -## Development - -From the TaskNotes repository root: - -```bash -npm run build:model -npm run test:model -``` - -The package build emits ESM, CommonJS, and TypeScript declaration output under `packages/model/dist`. The repository ignores that generated directory; release tooling should build it before packing or publishing. - -The Obsidian plugin keeps small wrappers in `src/core` so existing plugin imports stay stable while the deterministic logic lives here. Runtime-only behavior, such as Obsidian vault writes or plugin-specific clock hooks, belongs in those host wrappers. diff --git a/packages/model/package.json b/packages/model/package.json deleted file mode 100644 index a7dec4a8..00000000 --- a/packages/model/package.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "name": "@tasknotes/model", - "version": "0.1.0", - "description": "TaskNotes model, mapping, validation, recurrence, and operation-planning reference implementation.", - "license": "MIT", - "type": "module", - "main": "./dist/cjs/index.cjs", - "module": "./dist/esm/index.js", - "types": "./dist/types/index.d.ts", - "exports": { - ".": { - "types": "./dist/types/index.d.ts", - "import": "./dist/esm/index.js", - "require": "./dist/cjs/index.cjs" - }, - "./config": { - "types": "./dist/types/config.d.ts", - "import": "./dist/esm/config.js", - "require": "./dist/cjs/config.cjs" - }, - "./date": { - "types": "./dist/types/date.d.ts", - "import": "./dist/esm/date.js", - "require": "./dist/cjs/date.cjs" - }, - "./mapping": { - "types": "./dist/types/mapping.d.ts", - "import": "./dist/esm/mapping.js", - "require": "./dist/cjs/mapping.cjs" - }, - "./schema": { - "types": "./dist/types/schema.d.ts", - "import": "./dist/esm/schema.js", - "require": "./dist/cjs/schema.cjs" - }, - "./recurrence": { - "types": "./dist/types/recurrence.d.ts", - "import": "./dist/esm/recurrence.js", - "require": "./dist/cjs/recurrence.cjs" - }, - "./time": { - "types": "./dist/types/time.d.ts", - "import": "./dist/esm/time.js", - "require": "./dist/cjs/time.cjs" - }, - "./validation": { - "types": "./dist/types/validation.d.ts", - "import": "./dist/esm/validation.js", - "require": "./dist/cjs/validation.cjs" - }, - "./operations": { - "types": "./dist/types/operations.d.ts", - "import": "./dist/esm/operations.js", - "require": "./dist/cjs/operations.cjs" - }, - "./frontmatter": { - "types": "./dist/types/frontmatter.d.ts", - "import": "./dist/esm/frontmatter.js", - "require": "./dist/cjs/frontmatter.cjs" - }, - "./conformance": { - "types": "./dist/types/conformance.d.ts", - "import": "./dist/esm/conformance.js", - "require": "./dist/cjs/conformance.cjs" - } - }, - "files": [ - "dist", - "README.md" - ], - "scripts": { - "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", - "build": "npm run clean && node scripts/build.mjs && tsc -p tsconfig.types.json", - "test": "npm run build && node --test test/**/*.test.mjs" - }, - "dependencies": { - "rrule": "^2.8.1", - "yaml": "^2.3.1", - "zod": "^3.24.0" - }, - "devDependencies": { - "esbuild": "^0.25.9", - "typescript": "^5.9.2" - } -} diff --git a/packages/model/scripts/build.mjs b/packages/model/scripts/build.mjs deleted file mode 100644 index 065d0245..00000000 --- a/packages/model/scripts/build.mjs +++ /dev/null @@ -1,46 +0,0 @@ -import { mkdir } from "node:fs/promises"; -import { build } from "esbuild"; - -const entries = [ - "index", - "config", - "date", - "mapping", - "schema", - "recurrence", - "time", - "validation", - "operations", - "frontmatter", - "conformance", -]; - -const external = ["rrule", "yaml", "zod"]; - -await mkdir("dist/esm", { recursive: true }); -await mkdir("dist/cjs", { recursive: true }); - -await Promise.all( - entries.flatMap((entry) => [ - build({ - entryPoints: [`src/${entry}.ts`], - bundle: true, - platform: "neutral", - format: "esm", - target: "es2020", - sourcemap: true, - external, - outfile: `dist/esm/${entry}.js`, - }), - build({ - entryPoints: [`src/${entry}.ts`], - bundle: true, - platform: "node", - format: "cjs", - target: "es2020", - sourcemap: true, - external, - outfile: `dist/cjs/${entry}.cjs`, - }), - ]) -); diff --git a/packages/model/src/config.ts b/packages/model/src/config.ts deleted file mode 100644 index 60779ca1..00000000 --- a/packages/model/src/config.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { DEFAULT_FIELD_MAPPING, DEFAULT_MODEL_CONFIG } from "./defaults"; -import type { - FieldRole, - PriorityConfig, - SpecFieldMapping, - StatusConfig, - TaskIdentificationConfig, - TaskNotesModelConfig, -} from "./types"; - -export const ALL_FIELD_ROLES: FieldRole[] = [ - "title", - "status", - "priority", - "due", - "scheduled", - "completedDate", - "tags", - "contexts", - "projects", - "timeEstimate", - "dateCreated", - "dateModified", - "recurrence", - "recurrenceAnchor", - "completeInstances", - "skippedInstances", - "timeEntries", - "blockedBy", - "reminders", -]; - -export function resolveModelConfig(input: Partial = {}): TaskNotesModelConfig { - return { - ...DEFAULT_MODEL_CONFIG, - ...input, - fieldMapping: { ...DEFAULT_FIELD_MAPPING, ...input.fieldMapping }, - statuses: input.statuses ? input.statuses.map((status) => ({ ...status })) : DEFAULT_MODEL_CONFIG.statuses.map((status) => ({ ...status })), - priorities: input.priorities ? input.priorities.map((priority) => ({ ...priority })) : DEFAULT_MODEL_CONFIG.priorities.map((priority) => ({ ...priority })), - defaults: { ...DEFAULT_MODEL_CONFIG.defaults, ...input.defaults }, - taskIdentification: { - ...DEFAULT_MODEL_CONFIG.taskIdentification, - ...input.taskIdentification, - }, - userFields: input.userFields ? input.userFields.map((field) => ({ ...field })) : [], - recurrence: { ...DEFAULT_MODEL_CONFIG.recurrence, ...input.recurrence }, - timeTracking: { ...DEFAULT_MODEL_CONFIG.timeTracking, ...input.timeTracking }, - }; -} - -export function isCompletedStatus( - status: string | undefined, - statuses: readonly StatusConfig[] = DEFAULT_MODEL_CONFIG.statuses -): boolean { - if (!status) return false; - return statuses.some((entry) => entry.value === status && entry.isCompleted); -} - -export function getDefaultCompletedStatus( - statuses: readonly StatusConfig[] = DEFAULT_MODEL_CONFIG.statuses -): string { - return statuses.find((entry) => entry.isCompleted)?.value || "done"; -} - -export function getStatusConfig( - status: string | undefined, - statuses: readonly StatusConfig[] = DEFAULT_MODEL_CONFIG.statuses -): StatusConfig | undefined { - return statuses.find((entry) => entry.value === status); -} - -export function getPriorityConfig( - priority: string | undefined, - priorities: readonly PriorityConfig[] = DEFAULT_MODEL_CONFIG.priorities -): PriorityConfig | undefined { - return priorities.find((entry) => entry.value === priority); -} - -export function defaultSpecFieldMapping(): SpecFieldMapping { - const roleToField = {} as Record; - const fieldToRole = {} as Record; - for (const role of ALL_FIELD_ROLES) { - roleToField[role] = role; - fieldToRole[role] = role; - } - return { - roleToField, - fieldToRole, - displayNameKey: "title", - completedStatuses: ["done", "cancelled"], - }; -} - -export function buildSpecFieldMapping( - fields: Record = {}, - displayNameKey?: string -): SpecFieldMapping { - const roleToField = {} as Record; - const fieldToRole = {} as Record; - const roles = new Set(ALL_FIELD_ROLES); - - for (const [fieldName, definition] of Object.entries(fields)) { - if (!isRecord(definition) || typeof definition.tn_role !== "string") { - continue; - } - const role = definition.tn_role as FieldRole; - if (!roles.has(role) || roleToField[role] !== undefined) { - continue; - } - roleToField[role] = fieldName; - fieldToRole[fieldName] = role; - } - - for (const role of ALL_FIELD_ROLES) { - if (roleToField[role] === undefined) { - roleToField[role] = fields[role] !== undefined ? role : role; - if (fields[role] !== undefined && fieldToRole[role] === undefined) { - fieldToRole[role] = role; - } - } - } - - const completedStatuses = inferCompletedStatuses(fields, roleToField.status); - return { - roleToField, - fieldToRole, - displayNameKey: displayNameKey && displayNameKey.trim().length > 0 ? displayNameKey : roleToField.title, - completedStatuses, - }; -} - -export function normalizeSpecFrontmatter( - 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 denormalizeSpecFrontmatter( - roleData: Record, - mapping: SpecFieldMapping -): Record { - const result: Record = {}; - const roles = new Set(ALL_FIELD_ROLES); - for (const [key, value] of Object.entries(roleData)) { - result[roles.has(key) ? mapping.roleToField[key as FieldRole] : key] = value; - } - return result; -} - -export function isSpecCompletedStatus(mapping: SpecFieldMapping, status: string | undefined): boolean { - return !!status && mapping.completedStatuses.includes(status); -} - -export function getDefaultSpecCompletedStatus(mapping: SpecFieldMapping): string { - return mapping.completedStatuses[0] || "done"; -} - -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 basename = taskPath.split(/[\\/]/).pop()?.replace(/\.md$/i, "").trim(); - if (basename) { - return basename; - } - } - - return undefined; -} - -export function normalizeExcludedFolders(value: unknown): string[] { - const normalizePath = (entry: unknown): string => - String(entry || "") - .replace(/\\/g, "/") - .replace(/^\/+/, "") - .replace(/\/+$/, "") - .trim(); - if (Array.isArray(value)) { - return value.map(normalizePath).filter(Boolean); - } - if (typeof value === "string") { - return value.split(",").map(normalizePath).filter(Boolean); - } - return []; -} - -export function detectTaskFile(input: { - taskDetection?: Partial & { methods?: string[]; combine?: "and" | "or" }; - frontmatter?: Record; - body?: string; - filePath?: string; -}): boolean { - const detection = input.taskDetection ?? {}; - const frontmatter = input.frontmatter ?? {}; - const body = input.body ?? ""; - const filePath = input.filePath ?? ""; - - if (filePath && pathExcluded(filePath, normalizeExcludedFolders(detection.excludedFolders))) { - return false; - } - - const methods = Array.isArray(detection.methods) - ? detection.methods - : detection.method - ? [detection.method] - : detection.tag - ? ["tag"] - : []; - const effectiveMethods = methods.length > 0 ? methods : ["tag"]; - const evaluations = effectiveMethods.map((method) => { - if (method === "tag") { - const tag = normalizeTagForComparison(detection.tag || "task"); - return frontmatterHasTag(frontmatter, tag) || bodyHasTag(body, tag); - } - if (method === "property") { - const propertyName = detection.propertyName || ""; - const propertyValue = detection.propertyValue || ""; - if (!propertyName || !Object.prototype.hasOwnProperty.call(frontmatter, propertyName)) { - return false; - } - return propertyValue.length === 0 || String(frontmatter[propertyName]) === propertyValue; - } - return false; - }); - - return detection.combine === "and" ? evaluations.every(Boolean) : evaluations.some(Boolean); -} - -export function mapTasknotesPluginConfig(source: Record): Record { - const out: Record = {}; - if (isRecord(source.fieldMapping)) { - const mapping: Record = {}; - for (const [role, fieldName] of Object.entries(source.fieldMapping)) { - if (typeof fieldName === "string" && fieldName.trim().length > 0) { - mapping[mapPluginRoleToSpecRole(role)] = fieldName; - } - } - out.mapping = mapping; - } - - if (typeof source.defaultTaskStatus === "string" || typeof source.defaultTaskPriority === "string") { - out.defaults = { - ...(typeof source.defaultTaskStatus === "string" ? { status: source.defaultTaskStatus } : {}), - ...(typeof source.defaultTaskPriority === "string" ? { priority: source.defaultTaskPriority } : {}), - }; - } - - if (Array.isArray(source.customStatuses)) { - const values = source.customStatuses - .filter(isRecord) - .map((entry) => entry.value) - .filter((value): value is string => typeof value === "string"); - const completedValues = source.customStatuses - .filter((entry) => isRecord(entry) && entry.isCompleted === true && typeof entry.value === "string") - .map((entry) => String((entry as Record).value)); - out.status = { values, completed_values: completedValues }; - } - - if (typeof source.taskIdentificationMethod === "string") { - out.task_detection = { - method: source.taskIdentificationMethod, - ...(typeof source.taskTag === "string" ? { tag: source.taskTag } : {}), - ...(typeof source.taskPropertyName === "string" ? { property_name: source.taskPropertyName } : {}), - ...(typeof source.taskPropertyValue === "string" ? { property_value: source.taskPropertyValue } : {}), - }; - } - - if (typeof source.tasksFolder === "string") { - out.collection = { default_folder: source.tasksFolder }; - } - - if (typeof source.autoStopTimeTrackingOnComplete === "boolean") { - out.time_tracking = { auto_stop_on_complete: source.autoStopTimeTrackingOnComplete }; - } - - return out; -} - -function inferCompletedStatuses(fields: Record, statusFieldName: string): string[] { - const statusDef = fields[statusFieldName]; - if (!isRecord(statusDef)) { - return ["done", "cancelled"]; - } - - if (Array.isArray(statusDef.tn_completed_values)) { - const explicit = statusDef.tn_completed_values - .filter((value): value is string => typeof value === "string") - .map((value) => value.trim()) - .filter(Boolean); - if (explicit.length > 0) return explicit; - } - - if (Array.isArray(statusDef.values)) { - const inferred = statusDef.values - .filter((value): value is string => typeof value === "string") - .filter((value) => { - const lower = value.toLowerCase(); - return ( - lower.includes("done") || - lower.includes("complete") || - lower.includes("cancel") || - lower.includes("finish") - ); - }); - if (inferred.length > 0) return inferred; - } - - return ["done", "cancelled"]; -} - -function normalizeTagForComparison(value: string): string { - const trimmed = value.trim(); - const withoutHash = trimmed.startsWith("#") ? trimmed.slice(1).trim() : trimmed; - return withoutHash.replace(/\s+/g, "-").toLowerCase(); -} - -function frontmatterHasTag(frontmatter: Record, normalizedTag: string): boolean { - const tags = frontmatter.tags; - const entries = Array.isArray(tags) ? tags : typeof tags === "string" ? [tags] : []; - return entries.some((entry) => normalizeTagForComparison(String(entry)) === normalizedTag); -} - -function bodyHasTag(body: string, normalizedTag: string): boolean { - const stripped = body.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " "); - const regex = /(^|[^\w])#([A-Za-z0-9][A-Za-z0-9/_-]*)/g; - let match: RegExpExecArray | null; - while ((match = regex.exec(stripped)) !== null) { - if (match[2].toLowerCase() === normalizedTag) { - return true; - } - } - return false; -} - -function pathExcluded(filePath: string, excludedFolders: string[]): boolean { - const normalizedPath = filePath.replace(/\\/g, "/").replace(/^\/+/, ""); - return excludedFolders.some( - (folder) => normalizedPath === folder || normalizedPath.startsWith(`${folder}/`) - ); -} - -function mapPluginRoleToSpecRole(roleKey: string): string { - const explicit: Record = { - completedDate: "completed_date", - dateCreated: "date_created", - dateModified: "date_modified", - recurrenceAnchor: "recurrence_anchor", - completeInstances: "complete_instances", - skippedInstances: "skipped_instances", - timeEntries: "time_entries", - timeEstimate: "time_estimate", - blockedBy: "blocked_by", - }; - return explicit[roleKey] ?? roleKey.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase(); -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} diff --git a/packages/model/src/conformance.ts b/packages/model/src/conformance.ts deleted file mode 100644 index 7f883497..00000000 --- a/packages/model/src/conformance.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { - buildSpecFieldMapping, - defaultSpecFieldMapping, - denormalizeSpecFrontmatter, - detectTaskFile, - getDefaultSpecCompletedStatus, - isSpecCompletedStatus, - mapTasknotesPluginConfig, - normalizeSpecFrontmatter, - resolveDisplayTitle, -} from "./config"; -import { - formatDateForStorage, - getDatePart, - hasTimeComponent, - isBeforeDateSafe, - isSameDateSafe, - parseDateToLocal, - parseDateToUTC, - resolveOperationTargetDate, - validateDateString, -} from "./date"; -import { DEFAULT_FIELD_MAPPING, DEFAULT_PRIORITIES, DEFAULT_STATUSES } from "./defaults"; -import { - normalizeDependencyEntry, - normalizeDependencyList, - serializeDependencies, -} from "./mapping"; -import { - completeRecurringTask, - getEffectiveTaskStatus, - recalculateRecurringSchedule, -} from "./recurrence"; -import { - buildDeleteTimeEntryPlan, - buildStartTimeTrackingPlan, - buildStopTimeTrackingPlan, - calculateTotalTrackedMinutes, - getActiveTimeEntry, - replaceTimeEntries, -} from "./time"; -import { evaluateCoreValidation, validateTask, validateTimeEntries } from "./validation"; -import type { ConformanceEnvelope, TaskInfo, TimeEntry } from "./types"; - -export const conformanceMetadata = { - implementation: "@tasknotes/model", - version: "0.1.0", - spec_version: "0.1.0-draft", - validation_modes: ["strict"], - profiles: ["core-lite", "recurrence", "extended"], - capabilities: [ - "date", - "field-mapping", - "recurrence", - "create-compat", - "ops-core", - "claim", - "config-lite", - "validation-core", - "time-tracking", - "dependencies", - "reminders", - "links", - ], -}; - -export const metadata = conformanceMetadata; - -export async function execute( - operation: string, - input: Record = {} -): Promise { - return executeConformanceOperation(operation, input); -} - -export function executeConformanceOperation( - operation: string, - input: Record = {} -): ConformanceEnvelope { - try { - switch (operation) { - case "meta.claim": - return ok({ ...conformanceMetadata }); - case "meta.has_capability": - return ok({ value: conformanceMetadata.capabilities.includes(String(input.capability || "")) }); - case "meta.has_profile": - return ok({ value: conformanceMetadata.profiles.includes(String(input.profile || "")) }); - case "date.parse_utc": - return ok({ date: formatDateForStorage(parseDateToUTC(String(input.value || ""))) }); - case "date.parse_local": - return ok({ localDate: localYmd(parseDateToLocal(String(input.value || ""))) }); - case "date.validate": - return ok({ value: validateDateString(String(input.value || "")) }); - case "date.get_part": - return ok({ value: getDatePart(String(input.value || "")) }); - case "date.has_time": - return ok({ value: hasTimeComponent(typeof input.value === "string" ? input.value : undefined) }); - case "date.is_same": - return ok({ value: isSameDateSafe(String(input.left || ""), String(input.right || "")) }); - case "date.is_before": - return ok({ value: isBeforeDateSafe(String(input.left || ""), String(input.right || "")) }); - case "date.resolve_operation_target": - return ok({ - value: resolveOperationTargetDate( - stringOrUndefined(input.explicitDate ?? input.date), - stringOrUndefined(input.scheduled), - stringOrUndefined(input.due) - ), - }); - case "date.day_in_timezone": - return ok({ value: getDatePart(String(input.value || "")) }); - case "field.default_mapping": { - const mapping = defaultSpecFieldMapping(); - return ok({ - roleToField: mapping.roleToField, - fieldToRole: mapping.fieldToRole, - displayNameKey: mapping.displayNameKey, - }); - } - case "field.build_mapping": { - const mapping = buildSpecFieldMapping(asRecord(input.fields), stringOrUndefined(input.displayNameKey)); - return ok(mapping); - } - case "field.normalize": { - const mapping = buildSpecFieldMapping(asRecord(input.fields), stringOrUndefined(input.displayNameKey)); - return ok({ value: normalizeSpecFrontmatter(asRecord(input.frontmatter), mapping) }); - } - case "field.denormalize": { - const mapping = buildSpecFieldMapping(asRecord(input.fields), stringOrUndefined(input.displayNameKey)); - return ok({ value: denormalizeSpecFrontmatter(asRecord(input.data ?? input.frontmatter), mapping) }); - } - case "field.resolve_display_title": { - const mapping = buildSpecFieldMapping(asRecord(input.fields), stringOrUndefined(input.displayNameKey)); - return ok({ value: resolveDisplayTitle(asRecord(input.frontmatter), mapping, stringOrUndefined(input.path)) }); - } - case "field.is_completed_status": { - const mapping = buildSpecFieldMapping(asRecord(input.fields), stringOrUndefined(input.displayNameKey)); - return ok({ value: isSpecCompletedStatus(mapping, stringOrUndefined(input.status)) }); - } - case "field.default_completed_status": { - const mapping = buildSpecFieldMapping(asRecord(input.fields), stringOrUndefined(input.displayNameKey)); - return ok({ value: getDefaultSpecCompletedStatus(mapping) }); - } - case "recurrence.complete": - return ok(completeRecurringTask(normalizeRecurrenceCompletionInput(input))); - case "recurrence.recalculate": - return ok(recalculateRecurringSchedule(normalizeRecurrenceScheduleInput(input))); - case "recurrence.effective_state": - return ok({ - status: getEffectiveTaskStatus(asRecord(input.task) as Partial, parseDateToUTC(String(input.date || "")), String(input.completedStatus || "done")), - }); - case "recurrence.uncomplete_instance": - return ok(removeInstance(asRecord(input), "completeInstances")); - case "recurrence.skip_instance": - return ok(addRemoveInstance(asRecord(input), "skippedInstances", true)); - case "recurrence.unskip_instance": - return ok(addRemoveInstance(asRecord(input), "skippedInstances", false)); - case "config.map_tasknotes_plugin": - return ok({ value: mapTasknotesPluginConfig(asRecord(input.config ?? input)) }); - case "config.detect_task_file": - return ok({ value: detectTaskFile(input as Parameters[0]) }); - case "config.merge_top_level": - return ok({ value: { ...asRecord(input.base), ...asRecord(input.overlay) } }); - case "config.spec_version_effective": - return ok({ value: conformanceMetadata.spec_version }); - case "config.resolve_collection_path": - return ok({ value: stringOrUndefined(input.path) || stringOrUndefined(input.cwd) || "." }); - case "config.provider_behavior": - return ok({ value: "host-owned" }); - case "config.validate_schema": - return ok({ valid: true, issues: [] }); - case "validation.core_evaluate": - return ok(evaluateCoreValidation(asRecord(input.task) as Partial, DEFAULT_STATUSES)); - case "validation.time_entries": - return ok(validateTimeEntries(input.entries)); - case "op.update_patch": - case "op.mutate_with_validation": - case "op.atomic_write": - case "op.idempotency_check": - case "op.detect_conflict": - case "op.dry_run": - return ok({ value: asRecord(input) }); - case "op.complete_nonrecurring": - return ok(completeNonRecurring(asRecord(input))); - case "op.uncomplete_nonrecurring": - return ok(uncompleteNonRecurring(asRecord(input))); - case "op.error_shape": - return err("tasknotes_model_error"); - case "delete.remove": - return ok({ deleted: true }); - case "dependency.validate_entry": - return ok({ value: normalizeDependencyEntry(input.entry ?? input.value), valid: !!normalizeDependencyEntry(input.entry ?? input.value) }); - case "dependency.validate_set": - return ok({ value: normalizeDependencyList(input.entries ?? input.value) ?? [], valid: true }); - case "dependency.add": - return ok({ value: serializeDependencies([...(normalizeDependencyList(input.existing) ?? []), ...(normalizeDependencyList(input.entry ?? input.value) ?? [])]) }); - case "dependency.remove": - return ok({ value: removeDependency(input) }); - case "dependency.replace": - return ok({ value: serializeDependencies(normalizeDependencyList(input.entries ?? input.value) ?? []) }); - case "dependency.missing_target_behavior": - return ok({ blocked: true, issue: "unresolved_dependency_target", severity: "warning" }); - case "reminder.validate_entry": - return ok({ valid: isRecord(input.entry ?? input.value) }); - case "reminder.validate_set": - return ok({ valid: Array.isArray(input.entries ?? input.value) }); - case "reminder.add": - return ok({ value: [...arrayValue(input.existing), input.entry ?? input.value] }); - case "reminder.update": - return ok({ value: input.entry ?? input.value }); - case "reminder.remove": - return ok({ value: arrayValue(input.existing).filter((entry) => asRecord(entry).id !== input.id) }); - case "link.parse": - return ok({ value: String(input.value || "") }); - case "link.resolve": - return ok({ value: String(input.value || input.path || "") }); - case "time.start": { - const task = normalizeTaskForConformance(input.task); - return ok(buildStartTimeTrackingPlan(task, String(input.now || new Date().toISOString()))); - } - case "time.stop": { - const task = normalizeTaskForConformance(input.task); - const active = getActiveTimeEntry(task); - if (!active) return err("no_active_time_entry"); - return ok(buildStopTimeTrackingPlan(task, active, String(input.now || new Date().toISOString()))); - } - case "time.replace_entries": { - const task = normalizeTaskForConformance(input.task); - return ok({ updatedTask: replaceTimeEntries(task, arrayValue(input.entries) as TimeEntry[], String(input.now || new Date().toISOString())) }); - } - case "time.remove_entry": { - const task = normalizeTaskForConformance(input.task); - return ok(buildDeleteTimeEntryPlan(task, Number(input.index ?? 0), String(input.now || new Date().toISOString()))); - } - case "time.auto_stop_on_complete": - return ok({ value: true }); - case "time.report_totals": - return ok({ minutes: calculateTotalTrackedMinutes(arrayValue(input.entries) as TimeEntry[]) }); - default: - return err(`Unsupported operation: ${operation}`, { operation, code: "unsupported_operation" }); - } - } catch (error) { - return err(error instanceof Error ? error.message : String(error), { operation, code: "exception" }); - } -} - -function ok(result: unknown): ConformanceEnvelope { - return { ok: true, result }; -} - -function err(error: string, errorDetails?: Record): ConformanceEnvelope { - return { ok: false, error, error_details: errorDetails }; -} - -function localYmd(date: Date): string { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -} - -function asRecord(value: unknown): Record { - return isRecord(value) ? value : {}; -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function stringOrUndefined(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function arrayValue(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} - -function normalizeTaskForConformance(value: unknown): TaskInfo { - const record = asRecord(value); - return { - title: typeof record.title === "string" ? record.title : "", - status: typeof record.status === "string" ? record.status : "open", - priority: typeof record.priority === "string" ? record.priority : "normal", - path: typeof record.path === "string" ? record.path : "task.md", - archived: record.archived === true, - ...record, - } as TaskInfo; -} - -function normalizeRecurrenceCompletionInput(input: Record) { - return { - recurrence: String(input.recurrence || ""), - recurrenceAnchor: stringOrUndefined(input.recurrenceAnchor ?? input.recurrence_anchor), - scheduled: stringOrUndefined(input.scheduled), - due: stringOrUndefined(input.due), - dateCreated: stringOrUndefined(input.dateCreated ?? input.date_created), - completionDate: String((input.completionDate ?? input.completion_date ?? input.date) || ""), - completeInstances: arrayValue(input.completeInstances ?? input.complete_instances) as string[], - skippedInstances: arrayValue(input.skippedInstances ?? input.skipped_instances) as string[], - }; -} - -function normalizeRecurrenceScheduleInput(input: Record) { - return { - recurrence: String(input.recurrence || ""), - recurrenceAnchor: stringOrUndefined(input.recurrenceAnchor ?? input.recurrence_anchor), - scheduled: stringOrUndefined(input.scheduled), - due: stringOrUndefined(input.due), - dateCreated: stringOrUndefined(input.dateCreated ?? input.date_created), - completeInstances: arrayValue(input.completeInstances ?? input.complete_instances) as string[], - skippedInstances: arrayValue(input.skippedInstances ?? input.skipped_instances) as string[], - referenceDate: String((input.referenceDate ?? input.reference_date ?? input.date) || ""), - }; -} - -function addRemoveInstance( - input: Record, - field: "completeInstances" | "skippedInstances", - shouldAdd: boolean -): Record { - const date = String(input.date || ""); - const values = new Set(arrayValue(input[field] ?? input[field === "completeInstances" ? "complete_instances" : "skipped_instances"]) as string[]); - if (shouldAdd) values.add(date); - else values.delete(date); - return { [field]: [...values] }; -} - -function removeInstance(input: Record, field: "completeInstances"): Record { - return addRemoveInstance(input, field, false); -} - -function completeNonRecurring(input: Record): Record { - const task = normalizeTaskForConformance(input.task); - const completedStatus = String(input.completedStatus || "done"); - return { - updatedTask: { - ...task, - status: completedStatus, - completedDate: String(input.date || formatDateForStorage(new Date())), - }, - }; -} - -function uncompleteNonRecurring(input: Record): Record { - const task = normalizeTaskForConformance(input.task); - return { - updatedTask: { - ...task, - status: String(input.openStatus || "open"), - completedDate: undefined, - }, - }; -} - -function removeDependency(input: Record): unknown[] { - const existing = normalizeDependencyList(input.existing) ?? []; - const target = normalizeDependencyEntry(input.entry ?? input.value); - if (!target) return serializeDependencies(existing); - return serializeDependencies(existing.filter((entry) => entry.uid !== target.uid)); -} - -export { DEFAULT_FIELD_MAPPING, DEFAULT_PRIORITIES, DEFAULT_STATUSES }; diff --git a/packages/model/src/date.ts b/packages/model/src/date.ts deleted file mode 100644 index b06247a4..00000000 --- a/packages/model/src/date.ts +++ /dev/null @@ -1,310 +0,0 @@ -const DATE_ONLY_RE = /^(\d{4})-(\d{2})-(\d{2})$/; -const STRICT_DATETIME_RE = - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,3})?(?:Z|([+-])(\d{2}):(\d{2}))?$/; -const RELAXED_DATETIME_RE = - /^(\d{4})-(\d{2})-(\d{2})(?:T| )(\d{2}):(\d{2})(?::(\d{2})(\.\d{1,3})?)?(Z|([+-])(\d{2}):(\d{2}))?$/; - -export type DateTimeRangeBound = "from" | "to"; - -export function parseDateToUTC(dateString: string): Date { - const trimmed = requireDateString(dateString); - const dateOnlyMatch = trimmed.match(DATE_ONLY_RE); - if (dateOnlyMatch) { - const [, year, month, day] = dateOnlyMatch; - const y = Number(year); - const m = Number(month); - const d = Number(day); - if (!isValidCalendarDate(y, m, d)) { - throw new Error(`Invalid date "${dateString}".`); - } - return new Date(Date.UTC(y, m - 1, d, 0, 0, 0, 0)); - } - - if (!isStrictDateTime(trimmed)) { - throw new Error(`Invalid date "${dateString}".`); - } - - const parsed = new Date(trimmed); - if (!isValidDate(parsed)) { - throw new Error(`Invalid date "${dateString}".`); - } - return parsed; -} - -export function parseDateToLocal(dateString: string): Date { - const trimmed = requireDateString(dateString); - const dateOnlyMatch = trimmed.match(DATE_ONLY_RE); - if (dateOnlyMatch) { - const [, year, month, day] = dateOnlyMatch; - const y = Number(year); - const m = Number(month); - const d = Number(day); - const parsed = new Date(y, m - 1, d, 0, 0, 0, 0); - if ( - !isValidDate(parsed) || - parsed.getFullYear() !== y || - parsed.getMonth() !== m - 1 || - parsed.getDate() !== d - ) { - throw new Error(`Invalid date "${dateString}".`); - } - return parsed; - } - - if (!isStrictDateTime(trimmed)) { - throw new Error(`Invalid date "${dateString}".`); - } - - const parsed = new Date(trimmed); - if (!isValidDate(parsed)) { - throw new Error(`Invalid date "${dateString}".`); - } - return parsed; -} - -export function formatDateForStorage(date: Date): string { - if (!isValidDate(date)) { - return ""; - } - 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 formatDateAsUTCString(date: Date): string { - return formatDateForStorage(date); -} - -export function getCurrentDateString(): string { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -} - -export const getTodayString = getCurrentDateString; - -export function resolveDateOrToday(date?: string): string { - if (!date) { - return getCurrentDateString(); - } - return validateDateString(date); -} - -export function getTodayLocal(): Date { - const now = new Date(); - return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0); -} - -export function createUTCDateFromLocalCalendarDate(localDate: Date): Date { - return new Date( - Date.UTC(localDate.getFullYear(), localDate.getMonth(), localDate.getDate(), 0, 0, 0, 0) - ); -} - -export function createUTCDateForRRule(dateString: string): Date { - return parseDateToUTC(getDatePart(dateString)); -} - -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 /T\d{2}:\d{2}/.test(dateString); -} - -export function getDatePart(dateString: string): string { - if (!dateString) return ""; - const trimmed = dateString.trim(); - if (DATE_ONLY_RE.test(trimmed)) { - return trimmed; - } - const tIndex = trimmed.indexOf("T"); - if (tIndex > -1) { - return trimmed.slice(0, tIndex); - } - const spaceIndex = trimmed.indexOf(" "); - if (spaceIndex > -1 && DATE_ONLY_RE.test(trimmed.slice(0, spaceIndex))) { - return trimmed.slice(0, spaceIndex); - } - return formatDateForStorage(parseDateToUTC(trimmed)); -} - -export function resolveOperationTargetDate( - explicitDate: string | undefined, - scheduled: string | undefined, - due: string | undefined -): string { - if (explicitDate) { - return validateDateString(explicitDate); - } - - const scheduledDate = extractValidDatePartOrUndefined(scheduled); - if (scheduledDate) { - return scheduledDate; - } - - const dueDate = extractValidDatePartOrUndefined(due); - if (dueDate) { - return dueDate; - } - - return getCurrentDateString(); -} - -export function isSameDateSafe(date1: string, date2: string): boolean { - try { - return parseDateToUTC(getDatePart(date1)).getTime() === parseDateToUTC(getDatePart(date2)).getTime(); - } catch { - return false; - } -} - -export function isBeforeDateSafe(date1: string, date2: string): boolean { - try { - return parseDateToUTC(getDatePart(date1)).getTime() < parseDateToUTC(getDatePart(date2)).getTime(); - } catch { - return false; - } -} - -export function validateCompleteInstances(value: unknown[]): string[] { - const result: string[] = []; - const seen = new Set(); - for (const entry of value) { - if (typeof entry !== "string") { - continue; - } - try { - const date = validateDateString(getDatePart(entry.trim())); - if (!seen.has(date)) { - seen.add(date); - result.push(date); - } - } catch { - // Invalid persisted instance dates are ignored for compatibility with plugin behavior. - } - } - return result; -} - -export function resolveDateTimeRangeBound(value: string, bound: DateTimeRangeBound): Date { - const trimmed = requireDateString(value, "Datetime cannot be empty."); - const dateOnlyMatch = trimmed.match(DATE_ONLY_RE); - if (dateOnlyMatch) { - const [, year, month, day] = dateOnlyMatch; - const y = Number(year); - const m = Number(month); - const d = Number(day); - if (!isValidCalendarDate(y, m, d)) { - throw new Error(`Invalid datetime "${value}".`); - } - return bound === "from" - ? new Date(y, m - 1, d, 0, 0, 0, 0) - : new Date(y, m - 1, d, 23, 59, 59, 999); - } - - const match = trimmed.match(RELAXED_DATETIME_RE); - if (!match) { - throw new Error( - `Invalid datetime "${value}". Expected YYYY-MM-DD, YYYY-MM-DD HH:mm, or YYYY-MM-DDTHH:mm.` - ); - } - - const [, year, month, day, hours, minutes, seconds, fraction, tz, tzSign, tzHours, tzMinutes] = - match; - const y = Number(year); - const m = Number(month); - const d = Number(day); - const hh = Number(hours); - const mm = Number(minutes); - const ss = seconds === undefined ? (bound === "to" ? 59 : 0) : Number(seconds); - const ms = fraction ? Number(fraction.slice(1).padEnd(3, "0")) : bound === "to" ? 999 : 0; - - if ( - !isValidCalendarDate(y, m, d) || - !isValidClockTime(hh, mm, ss) || - !isValidOffset(tzSign, tzHours, tzMinutes) - ) { - throw new Error(`Invalid datetime "${value}".`); - } - - const normalized = - `${year}-${month}-${day}T${hours}:${minutes}:${String(ss).padStart(2, "0")}` + - `.${String(ms).padStart(3, "0")}${tz || ""}`; - const parsed = new Date(normalized); - if (!isValidDate(parsed)) { - throw new Error(`Invalid datetime "${value}".`); - } - return parsed; -} - -function extractValidDatePartOrUndefined(dateString: string | undefined): string | undefined { - if (!dateString || dateString.trim().length === 0) { - return undefined; - } - - try { - return validateDateString(getDatePart(dateString.trim())); - } catch { - return undefined; - } -} - -function requireDateString(value: string, message = "Date string cannot be empty"): string { - if (!value || value.trim().length === 0) { - throw new Error(message); - } - return value.trim(); -} - -function isStrictDateTime(value: string): boolean { - const match = value.match(STRICT_DATETIME_RE); - if (!match) return false; - - const [, year, month, day, hours, minutes, seconds, , tzSign, tzHours, tzMinutes] = match; - return ( - isValidCalendarDate(Number(year), Number(month), Number(day)) && - isValidClockTime(Number(hours), Number(minutes), Number(seconds)) && - isValidOffset(tzSign, tzHours, tzMinutes) - ); -} - -function isValidCalendarDate(year: number, month: number, day: number): boolean { - const parsed = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0)); - return ( - parsed.getUTCFullYear() === year && - parsed.getUTCMonth() === month - 1 && - parsed.getUTCDate() === day - ); -} - -function isValidClockTime(hours: number, minutes: number, seconds: number): boolean { - return hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59 && seconds >= 0 && seconds <= 59; -} - -function isValidOffset( - tzSign: string | undefined, - tzHours: string | undefined, - tzMinutes: string | undefined -): boolean { - if (!tzSign) return true; - - const offsetHours = Number(tzHours); - const offsetMinutes = Number(tzMinutes); - if (offsetHours > 14 || offsetMinutes > 59) return false; - if (offsetHours === 14 && offsetMinutes !== 0) return false; - return true; -} - -function isValidDate(value: Date): boolean { - return value instanceof Date && !Number.isNaN(value.getTime()); -} diff --git a/packages/model/src/defaults.ts b/packages/model/src/defaults.ts deleted file mode 100644 index 241ae377..00000000 --- a/packages/model/src/defaults.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { - FieldMapping, - PriorityConfig, - StatusConfig, - TaskNotesModelConfig, -} from "./types"; - -export const DEFAULT_FIELD_MAPPING: FieldMapping = { - title: "title", - status: "status", - priority: "priority", - due: "due", - scheduled: "scheduled", - contexts: "contexts", - projects: "projects", - timeEstimate: "timeEstimate", - completedDate: "completedDate", - dateCreated: "dateCreated", - dateModified: "dateModified", - recurrence: "recurrence", - recurrenceAnchor: "recurrence_anchor", - archiveTag: "archived", - timeEntries: "timeEntries", - completeInstances: "complete_instances", - skippedInstances: "skipped_instances", - blockedBy: "blockedBy", - pomodoros: "pomodoros", - icsEventId: "icsEventId", - icsEventTag: "ics_event", - googleCalendarEventId: "googleCalendarEventId", - googleCalendarExceptionEventId: "googleCalendarExceptionEventId", - googleCalendarExceptionOriginalScheduled: "googleCalendarExceptionOriginalScheduled", - googleCalendarMovedOriginalDates: "googleCalendarMovedOriginalDates", - reminders: "reminders", - sortOrder: "tasknotes_manual_order", -}; - -export const DEFAULT_STATUSES: StatusConfig[] = [ - { - id: "none", - value: "none", - label: "None", - color: "#cccccc", - isCompleted: false, - excludeFromCycle: false, - order: 0, - autoArchive: false, - autoArchiveDelay: 5, - }, - { - id: "open", - value: "open", - label: "Open", - color: "#808080", - isCompleted: false, - excludeFromCycle: false, - order: 1, - autoArchive: false, - autoArchiveDelay: 5, - }, - { - id: "in-progress", - value: "in-progress", - label: "In progress", - color: "#0066cc", - isCompleted: false, - excludeFromCycle: false, - order: 2, - autoArchive: false, - autoArchiveDelay: 5, - }, - { - id: "done", - value: "done", - label: "Done", - color: "#00aa00", - isCompleted: true, - excludeFromCycle: false, - order: 3, - autoArchive: false, - autoArchiveDelay: 5, - }, -]; - -export const DEFAULT_PRIORITIES: PriorityConfig[] = [ - { - id: "none", - value: "none", - label: "None", - color: "#cccccc", - weight: 0, - }, - { - id: "low", - value: "low", - label: "Low", - color: "#00aa00", - weight: 1, - }, - { - id: "normal", - value: "normal", - label: "Normal", - color: "#ffaa00", - weight: 2, - }, - { - id: "high", - value: "high", - label: "High", - color: "#ff0000", - weight: 3, - }, -]; - -export const DEFAULT_MODEL_CONFIG: TaskNotesModelConfig = { - fieldMapping: DEFAULT_FIELD_MAPPING, - statuses: DEFAULT_STATUSES, - priorities: DEFAULT_PRIORITIES, - defaults: { - status: "open", - priority: "normal", - taskTag: "task", - }, - taskIdentification: { - method: "tag", - tag: "task", - propertyName: "isTask", - propertyValue: "true", - }, - storeTitleInFilename: false, - userFields: [], - recurrence: { - maintainDueDateOffset: true, - resetCheckboxesOnRecurrence: false, - }, - timeTracking: { - autoStopOnComplete: false, - autoStopNotification: true, - defaultSessionDescription: "Work session", - }, -}; - -export function cloneDefaultModelConfig(): TaskNotesModelConfig { - return { - ...DEFAULT_MODEL_CONFIG, - fieldMapping: { ...DEFAULT_MODEL_CONFIG.fieldMapping }, - statuses: DEFAULT_MODEL_CONFIG.statuses.map((status) => ({ ...status })), - priorities: DEFAULT_MODEL_CONFIG.priorities.map((priority) => ({ ...priority })), - defaults: { ...DEFAULT_MODEL_CONFIG.defaults }, - taskIdentification: { ...DEFAULT_MODEL_CONFIG.taskIdentification }, - userFields: DEFAULT_MODEL_CONFIG.userFields.map((field) => ({ ...field })), - recurrence: { ...DEFAULT_MODEL_CONFIG.recurrence }, - timeTracking: { ...DEFAULT_MODEL_CONFIG.timeTracking }, - }; -} diff --git a/packages/model/src/frontmatter.ts b/packages/model/src/frontmatter.ts deleted file mode 100644 index 3d6675eb..00000000 --- a/packages/model/src/frontmatter.ts +++ /dev/null @@ -1,123 +0,0 @@ -import YAML from "yaml"; -import { DEFAULT_FIELD_MAPPING, DEFAULT_PRIORITIES, DEFAULT_STATUSES } from "./defaults"; -import { mapTaskFromFrontmatter, mapTaskToFrontmatter } from "./mapping"; -import type { - FieldMapping, - PriorityConfig, - StatusConfig, - TaskDocument, - TaskInfo, - UserMappedField, -} from "./types"; - -const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; - -export interface ParseTaskDocumentOptions { - path?: string; - fieldMapping?: FieldMapping; - storeTitleInFilename?: boolean; - userFields?: readonly UserMappedField[]; - statuses?: readonly StatusConfig[]; - priorities?: readonly PriorityConfig[]; -} - -export interface SerializeTaskDocumentOptions { - fieldMapping?: FieldMapping; - taskTag?: string; - storeTitleInFilename?: boolean; - userFields?: readonly UserMappedField[]; - sortKeys?: boolean; -} - -export function parseFrontmatter(markdown: string): { - frontmatter: Record; - body: string; -} { - const match = markdown.match(FRONTMATTER_RE); - if (!match) { - return { frontmatter: {}, body: markdown }; - } - const parsed = YAML.parse(match[1]) as unknown; - const frontmatter = - parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : {}; - return { - frontmatter, - body: markdown.slice(match[0].length), - }; -} - -export function stringifyFrontmatter( - frontmatter: Record, - options: { sortKeys?: boolean } = {} -): string { - const normalized = options.sortKeys ? sortObjectKeys(frontmatter) : frontmatter; - return YAML.stringify(normalized).trimEnd(); -} - -export function parseTaskDocument( - markdown: string, - options: ParseTaskDocumentOptions = {} -): TaskDocument { - const { frontmatter, body } = parseFrontmatter(markdown); - const task = mapTaskFromFrontmatter( - options.fieldMapping ?? DEFAULT_FIELD_MAPPING, - frontmatter, - options.path ?? "", - options.storeTitleInFilename ?? false, - options.userFields ?? [], - options.statuses ?? DEFAULT_STATUSES, - options.priorities ?? DEFAULT_PRIORITIES - ); - return { - frontmatter, - body, - task, - path: options.path, - }; -} - -export function serializeTaskDocument( - task: Partial, - body = "", - options: SerializeTaskDocumentOptions = {} -): string { - const frontmatter = mapTaskToFrontmatter( - options.fieldMapping ?? DEFAULT_FIELD_MAPPING, - task, - options.taskTag, - options.storeTitleInFilename ?? false, - options.userFields ?? [] - ); - return serializeMarkdownDocument(frontmatter, body, { sortKeys: options.sortKeys }); -} - -export function serializeMarkdownDocument( - frontmatter: Record, - body = "", - options: { sortKeys?: boolean } = {} -): string { - const yaml = stringifyFrontmatter(frontmatter, options); - const normalizedBody = body.replace(/\r\n/g, "\n"); - return `---\n${yaml}\n---\n${normalizedBody}`; -} - -export function mergeFrontmatter( - base: Record, - patch: Record -): Record { - const result = { ...base }; - for (const [key, value] of Object.entries(patch)) { - if (value === null) { - delete result[key]; - } else { - result[key] = value; - } - } - return result; -} - -function sortObjectKeys(value: Record): Record { - return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))); -} diff --git a/packages/model/src/index.ts b/packages/model/src/index.ts deleted file mode 100644 index e8fd2c6a..00000000 --- a/packages/model/src/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export * from "./types"; -export * from "./defaults"; -export * from "./config"; -export * from "./date"; -export * from "./mapping"; -export * from "./schema"; -export * from "./recurrence"; -export * from "./time"; -export * from "./validation"; -export * from "./operations"; -export * from "./frontmatter"; -export * from "./conformance"; diff --git a/packages/model/src/mapping.ts b/packages/model/src/mapping.ts deleted file mode 100644 index d04eb82a..00000000 --- a/packages/model/src/mapping.ts +++ /dev/null @@ -1,486 +0,0 @@ -import { DEFAULT_FIELD_MAPPING, DEFAULT_PRIORITIES, DEFAULT_STATUSES } from "./defaults"; -import { validateCompleteInstances } from "./date"; -import type { - FieldMapping, - FieldMappingKey, - PriorityConfig, - Reminder, - StatusConfig, - TaskDependency, - TaskDependencyRelType, - TaskInfo, - UserMappedField, -} from "./types"; - -export { DEFAULT_FIELD_MAPPING }; - -export const DEFAULT_DEPENDENCY_RELTYPE: TaskDependencyRelType = "FINISHTOSTART"; -export const VALID_DEPENDENCY_RELTYPES: TaskDependencyRelType[] = [ - "FINISHTOSTART", - "FINISHTOFINISH", - "STARTTOSTART", - "STARTTOFINISH", -]; - -export function toUserField(mapping: FieldMapping, internalName: FieldMappingKey): string { - return mapping[internalName]; -} - -export function normalizeTitleValue(value: unknown): string | undefined { - if (typeof value === "string") return value; - if (Array.isArray(value)) return value.map((entry) => String(entry)).join(", "); - if (value === null || value === undefined) return undefined; - if (typeof value === "object") return ""; - if (typeof value === "number" || typeof value === "boolean") return String(value); - return undefined; -} - -export function normalizeStatusConfigValue( - value: unknown, - statuses: readonly StatusConfig[] = DEFAULT_STATUSES -): string | undefined { - return normalizeConfiguredValue(normalizeStringValue(value), statuses); -} - -export function normalizePriorityConfigValue( - value: unknown, - priorities: readonly PriorityConfig[] = DEFAULT_PRIORITIES -): string | undefined { - return normalizeConfiguredValue(normalizeStringValue(value), priorities); -} - -export function getFrontmatterTags(value: unknown): string[] { - const tags: string[] = []; - const seen = new Set(); - const addTag = (entry: unknown): void => { - const normalized = normalizeFrontmatterTag(String(entry)); - if (!normalized || seen.has(normalized)) return; - seen.add(normalized); - tags.push(normalized); - }; - - if (Array.isArray(value)) { - value.forEach(addTag); - return tags; - } - - if (typeof value === "string" && value.trim().length > 0) { - addTag(value); - } - - return tags; -} - -export function normalizeFrontmatterTag(value: string): string { - const trimmed = value.trim(); - const withoutHash = trimmed.startsWith("#") ? trimmed.slice(1).trim() : trimmed; - return withoutHash.replace(/\s+/g, "-"); -} - -export function mapTaskFromFrontmatter( - mapping: FieldMapping, - frontmatter: Record | undefined | null, - filePath: string, - storeTitleInFilename = false, - userFields: readonly UserMappedField[] = [], - statuses: readonly StatusConfig[] = DEFAULT_STATUSES, - priorities: readonly PriorityConfig[] = DEFAULT_PRIORITIES -): Partial { - if (!frontmatter) return {}; - - const mapped: Partial = { - path: filePath, - }; - - if (frontmatter[mapping.title] !== undefined) { - const normalized = normalizeTitleValue(frontmatter[mapping.title]); - if (normalized !== undefined && normalized.trim().length > 0) { - mapped.title = normalized; - } else { - mapped.title = titleFromFilePath(filePath); - } - } else if (storeTitleInFilename || filePath) { - mapped.title = titleFromFilePath(filePath); - } - - if (frontmatter[mapping.status] !== undefined) { - mapped.status = normalizeStatusConfigValue(frontmatter[mapping.status], statuses); - } - if (frontmatter[mapping.priority] !== undefined) { - mapped.priority = normalizePriorityConfigValue(frontmatter[mapping.priority], priorities); - } - if (frontmatter[mapping.due] !== undefined) { - mapped.due = normalizeStringValue(frontmatter[mapping.due]); - } - if (frontmatter[mapping.scheduled] !== undefined) { - mapped.scheduled = normalizeStringValue(frontmatter[mapping.scheduled]); - } - if (frontmatter[mapping.contexts] !== undefined) { - mapped.contexts = normalizeStringArrayValue(frontmatter[mapping.contexts]); - } - if (frontmatter[mapping.projects] !== undefined) { - mapped.projects = normalizeStringArrayValue(frontmatter[mapping.projects]); - } - if (frontmatter[mapping.timeEstimate] !== undefined) { - mapped.timeEstimate = normalizeNumberValue(frontmatter[mapping.timeEstimate]); - } - if (frontmatter[mapping.completedDate] !== undefined) { - mapped.completedDate = normalizeStringValue(frontmatter[mapping.completedDate]); - } - if (frontmatter[mapping.recurrence] !== undefined) { - mapped.recurrence = normalizeStringValue(frontmatter[mapping.recurrence]); - } - if (frontmatter[mapping.recurrenceAnchor] !== undefined) { - const anchorValue = frontmatter[mapping.recurrenceAnchor]; - if (anchorValue === "scheduled" || anchorValue === "completion") { - mapped.recurrence_anchor = anchorValue; - } else if (anchorValue !== null && anchorValue !== undefined && !isBlankString(anchorValue)) { - mapped.recurrence_anchor = "scheduled"; - } - } - if (frontmatter[mapping.dateCreated] !== undefined) { - mapped.dateCreated = normalizeStringValue(frontmatter[mapping.dateCreated]); - } - if (frontmatter[mapping.dateModified] !== undefined) { - mapped.dateModified = normalizeStringValue(frontmatter[mapping.dateModified]); - } - if (frontmatter[mapping.timeEntries] !== undefined) { - mapped.timeEntries = Array.isArray(frontmatter[mapping.timeEntries]) - ? (frontmatter[mapping.timeEntries] as TaskInfo["timeEntries"]) - : []; - } - if (frontmatter[mapping.completeInstances] !== undefined) { - const value = frontmatter[mapping.completeInstances]; - mapped.complete_instances = validateCompleteInstances(Array.isArray(value) ? value : [value]); - } - if (frontmatter[mapping.skippedInstances] !== undefined) { - const value = frontmatter[mapping.skippedInstances]; - mapped.skipped_instances = validateCompleteInstances(Array.isArray(value) ? value : [value]); - } - if (mapping.blockedBy && frontmatter[mapping.blockedBy] !== undefined) { - mapped.blockedBy = normalizeDependencyList(frontmatter[mapping.blockedBy]); - } - if (frontmatter[mapping.icsEventId] !== undefined) { - mapped.icsEventId = normalizeStringArrayValue(frontmatter[mapping.icsEventId]); - } - if (frontmatter[mapping.googleCalendarEventId] !== undefined) { - mapped.googleCalendarEventId = normalizeStringValue(frontmatter[mapping.googleCalendarEventId]); - } - if (frontmatter[mapping.googleCalendarExceptionEventId] !== undefined) { - mapped.googleCalendarExceptionEventId = normalizeStringValue( - frontmatter[mapping.googleCalendarExceptionEventId] - ); - } - if (frontmatter[mapping.googleCalendarExceptionOriginalScheduled] !== undefined) { - mapped.googleCalendarExceptionOriginalScheduled = normalizeStringValue( - frontmatter[mapping.googleCalendarExceptionOriginalScheduled] - ); - } - if (frontmatter[mapping.googleCalendarMovedOriginalDates] !== undefined) { - mapped.googleCalendarMovedOriginalDates = normalizeStringArrayValue( - frontmatter[mapping.googleCalendarMovedOriginalDates] - ); - } - if (frontmatter[mapping.reminders] !== undefined) { - mapped.reminders = normalizeReminders(frontmatter[mapping.reminders]); - } - if (frontmatter[mapping.sortOrder] !== undefined) { - mapped.sortOrder = normalizeStringValue(frontmatter[mapping.sortOrder]); - } - if (frontmatter.tags !== undefined) { - const tags = getFrontmatterTags(frontmatter.tags); - mapped.tags = tags; - mapped.archived = tags.includes(normalizeTagForComparison(mapping.archiveTag)); - } - - if (userFields.length > 0) { - const customProperties: Record = {}; - const mappedAny = mapped as Record; - for (const field of userFields) { - if (frontmatter[field.key] !== undefined) { - mappedAny[field.key] = frontmatter[field.key]; - customProperties[field.key] = frontmatter[field.key]; - } - } - if (Object.keys(customProperties).length > 0) { - mapped.customProperties = { ...mapped.customProperties, ...customProperties }; - } - } - - return mapped; -} - -export function mapTaskToFrontmatter( - mapping: FieldMapping, - taskData: Partial, - taskTag?: string, - storeTitleInFilename = false, - userFields: readonly UserMappedField[] = [] -): Record { - const frontmatter: Record = {}; - - if (taskData.title !== undefined && !storeTitleInFilename) { - frontmatter[mapping.title] = taskData.title; - } - if (taskData.status !== undefined) { - frontmatter[mapping.status] = coerceStatusFrontmatterValue(taskData.status); - } - 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) { - const dependencies = Array.isArray(taskData.blockedBy) - ? taskData.blockedBy.map(normalizeDependencyEntry).filter((entry): entry is TaskDependency => !!entry) - : []; - if (dependencies.length > 0) { - frontmatter[mapping.blockedBy] = serializeDependencies(dependencies); - } - } - if (taskData.icsEventId !== undefined && taskData.icsEventId.length > 0) { - frontmatter[mapping.icsEventId] = taskData.icsEventId; - } - if (taskData.googleCalendarEventId !== undefined) { - frontmatter[mapping.googleCalendarEventId] = taskData.googleCalendarEventId; - } - if (taskData.googleCalendarExceptionEventId !== undefined) { - frontmatter[mapping.googleCalendarExceptionEventId] = taskData.googleCalendarExceptionEventId; - } - if (taskData.googleCalendarExceptionOriginalScheduled !== undefined) { - frontmatter[mapping.googleCalendarExceptionOriginalScheduled] = - taskData.googleCalendarExceptionOriginalScheduled; - } - if (taskData.googleCalendarMovedOriginalDates !== undefined && taskData.googleCalendarMovedOriginalDates.length > 0) { - frontmatter[mapping.googleCalendarMovedOriginalDates] = taskData.googleCalendarMovedOriginalDates; - } - if (taskData.reminders !== undefined && taskData.reminders.length > 0) { - frontmatter[mapping.reminders] = taskData.reminders; - } - - let tags = getFrontmatterTags(taskData.tags); - const taskTagValue = taskTag ? normalizeTagForComparison(taskTag) : ""; - const archiveTag = normalizeTagForComparison(mapping.archiveTag); - if (taskTagValue && !tags.includes(taskTagValue)) tags.push(taskTagValue); - if (taskData.archived === true && !tags.includes(archiveTag)) { - tags.push(archiveTag); - } else if (taskData.archived === false) { - tags = tags.filter((tag) => tag !== archiveTag); - } - if (tags.length > 0) { - frontmatter.tags = tags; - } - - const customProperties = taskData.customProperties; - const taskAny = taskData as Record; - for (const field of userFields) { - if (Object.prototype.hasOwnProperty.call(taskAny, field.key) && taskAny[field.key] !== undefined) { - frontmatter[field.key] = taskAny[field.key]; - } else if ( - customProperties && - Object.prototype.hasOwnProperty.call(customProperties, field.key) && - customProperties[field.key] !== undefined - ) { - frontmatter[field.key] = customProperties[field.key]; - } - } - - return frontmatter; -} - -export function coerceStatusFrontmatterValue(status: string): string | boolean { - const lower = status.toLowerCase(); - return lower === "true" || lower === "false" ? lower === "true" : status; -} - -export function lookupMappingKey( - mapping: FieldMapping, - frontmatterPropertyName: string -): FieldMappingKey | null { - for (const [mappingKey, propertyName] of Object.entries(mapping)) { - if (propertyName === frontmatterPropertyName) { - return mappingKey as FieldMappingKey; - } - } - return null; -} - -export function isRecognizedProperty(mapping: FieldMapping, frontmatterPropertyName: string): boolean { - return lookupMappingKey(mapping, frontmatterPropertyName) !== null; -} - -export function isPropertyForField( - mapping: FieldMapping, - propertyName: string, - internalField: FieldMappingKey -): boolean { - return mapping[internalField] === propertyName; -} - -export function toUserFields(mapping: FieldMapping, internalFields: FieldMappingKey[]): 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 FieldMappingKey[]; - for (const field of fields) { - if (!mapping[field] || mapping[field].trim() === "") { - errors.push(`Field "${field}" cannot be empty`); - } - } - const values = Object.values(mapping); - if (values.length !== new Set(values).size) { - errors.push("Field mappings must have unique property names"); - } - return { valid: errors.length === 0, errors }; -} - -export function isValidDependencyRelType(value: string): value is TaskDependencyRelType { - return VALID_DEPENDENCY_RELTYPES.includes(value as TaskDependencyRelType); -} - -export function extractDependencyUid(entry: unknown): string { - if (typeof entry === "string") return entry; - if (entry && typeof entry === "object") { - const uid = (entry as Record).uid; - return typeof uid === "string" ? uid : ""; - } - return ""; -} - -export function normalizeDependencyEntry(value: unknown): TaskDependency | null { - if (typeof value === "string") { - const uid = parseLinkToPath(value.trim()); - return uid ? { uid, reltype: DEFAULT_DEPENDENCY_RELTYPE } : null; - } - - if (!value || typeof value !== "object") return null; - const raw = value as Record; - const rawUid = typeof raw.uid === "string" ? raw.uid.trim() : ""; - if (!rawUid) return null; - const reltypeRaw = typeof raw.reltype === "string" ? raw.reltype.trim().toUpperCase() : ""; - const reltype = isValidDependencyRelType(reltypeRaw) ? reltypeRaw : DEFAULT_DEPENDENCY_RELTYPE; - const gap = typeof raw.gap === "string" && raw.gap.trim().length > 0 ? raw.gap.trim() : undefined; - return gap ? { uid: parseLinkToPath(rawUid), reltype, gap } : { uid: parseLinkToPath(rawUid), reltype }; -} - -export function normalizeDependencyList(value: unknown): TaskDependency[] | undefined { - if (value === null || value === undefined) return undefined; - const normalized = (Array.isArray(value) ? value : [value]) - .map(normalizeDependencyEntry) - .filter((entry): entry is TaskDependency => !!entry); - return normalized.length > 0 ? normalized : undefined; -} - -export function serializeDependencies(dependencies: readonly TaskDependency[]): unknown[] { - return dependencies.map((dependency) => { - const uid = dependency.uid.startsWith("[[") ? dependency.uid : `[[${dependency.uid}]]`; - const serialized: Record = { uid, reltype: dependency.reltype }; - if (dependency.gap && dependency.gap.trim().length > 0) { - serialized.gap = dependency.gap; - } - return serialized; - }); -} - -export function parseLinkToPath(value: string): string { - const trimmed = value.trim(); - const wikiMatch = trimmed.match(/^\[\[([^|\]#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]$/); - if (wikiMatch) { - return wikiMatch[1].trim(); - } - const markdownMatch = trimmed.match(/^\[[^\]]+\]\(([^)#]+)(?:#[^)]+)?\)$/); - if (markdownMatch) { - return decodeURI(markdownMatch[1].trim()); - } - return trimmed; -} - -function normalizeConfiguredValue( - rawValue: string | undefined, - configs: readonly { value: string; label: string }[] = [] -): string | undefined { - if (rawValue === undefined || configs.length === 0) return rawValue; - const exactValue = configs.find((config) => config.value === rawValue); - if (exactValue) return exactValue.value; - const exactLabel = findUniqueConfiguredValue(configs, (config) => config.label === rawValue); - if (exactLabel) return exactLabel.value; - const normalized = rawValue.trim().toLocaleLowerCase(); - if (normalized.length === 0) return rawValue; - const caseValue = findUniqueConfiguredValue( - configs, - (config) => config.value.trim().toLocaleLowerCase() === normalized - ); - if (caseValue) return caseValue.value; - const caseLabel = findUniqueConfiguredValue( - configs, - (config) => config.label.trim().toLocaleLowerCase() === normalized - ); - return caseLabel ? caseLabel.value : rawValue; -} - -function findUniqueConfiguredValue( - configs: readonly T[], - matches: (config: T) => boolean -): T | undefined { - const matched = configs.filter(matches); - return matched.length === 1 ? matched[0] : undefined; -} - -function normalizeStringValue(value: unknown): string | undefined { - if (value === null || value === undefined) return undefined; - if (typeof value === "string") return isBlankString(value) ? undefined : value; - if (typeof value === "number" || typeof value === "boolean") return String(value); - if (Array.isArray(value)) return value.length === 1 ? normalizeStringValue(value[0]) : undefined; - return undefined; -} - -function normalizeStringArrayValue(value: unknown): string[] { - return Array.isArray(value) ? value.map(String) : [String(value)]; -} - -function normalizeNumberValue(value: unknown): number | undefined { - if (typeof value === "number") return value; - if (typeof value === "string" && value.trim() !== "") { - const parsed = Number(value); - return Number.isNaN(parsed) ? undefined : parsed; - } - return undefined; -} - -function normalizeReminders(value: unknown): Reminder[] | undefined { - if (Array.isArray(value)) { - const filtered = value.filter((reminder) => reminder != null) as Reminder[]; - return filtered.length > 0 ? filtered : undefined; - } - return value != null ? [value as Reminder] : undefined; -} - -function normalizeTagForComparison(tag: string): string { - return getFrontmatterTags(tag)[0] ?? ""; -} - -function titleFromFilePath(filePath: string): string | undefined { - return filePath.split("/").pop()?.replace(/\.md$/i, "").trim() || undefined; -} - -function isBlankString(value: unknown): value is string { - return typeof value === "string" && value.trim().length === 0; -} diff --git a/packages/model/src/operations.ts b/packages/model/src/operations.ts deleted file mode 100644 index c7db271b..00000000 --- a/packages/model/src/operations.ts +++ /dev/null @@ -1,836 +0,0 @@ -import { formatDateForStorage, getDatePart, parseDateToUTC } from "./date"; -import { isCompletedStatus } from "./config"; -import { - addDTSTARTToRecurrenceRule, - completeRecurringTask, - getRecurringTaskActionDate, - recalculateRecurringSchedule, - updateDTSTARTInRecurrenceRule, - updateToNextScheduledOccurrence, -} from "./recurrence"; -import { - buildStartTimeTrackingPlan, - buildStopTimeTrackingPlan, - getActiveTimeEntry, - sanitizeTimeEntries, -} from "./time"; -import { - coerceStatusFrontmatterValue, - mapTaskToFrontmatter, - normalizeDependencyEntry, - serializeDependencies, -} from "./mapping"; -import type { - FieldMapping, - FieldMappingKey, - StatusConfig, - TaskDependency, - TaskInfo, - TaskOperationPlan, - TaskPatchOperation, - TaskUpdateInput, - TimeEntry, - UserMappedField, -} from "./types"; - -export interface BuildTaskUpdatePlanInput { - originalTask: TaskInfo; - updates: TaskUpdateInput; - fieldMapping: FieldMapping; - taskTag?: string; - storeTitleInFilename?: boolean; - userFields?: readonly UserMappedField[]; - statuses?: readonly StatusConfig[]; - now?: string; - currentDateString?: string; - maintainDueDateOffsetInRecurring?: boolean; -} - -export interface BuildTaskPropertyUpdatePlanInput { - freshTask: TaskInfo; - property: keyof TaskInfo; - value: unknown; - fieldMapping: FieldMapping; - statuses?: readonly StatusConfig[]; - now: string; - currentDateString: string; -} - -export interface RecurringTaskCompletePlan { - updatedTask: TaskInfo; - targetDate: Date; - dateStr: string; - newComplete: boolean; - dateModified: string; - originalRecurrence: unknown; -} - -export interface RecurringTaskSkippedPlan { - updatedTask: TaskInfo; - targetDate: Date; - dateStr: string; - newSkipped: boolean; - dateModified: string; -} - -export interface SpecTaskUpdatePlan { - fields: Record; - updatedTask: TaskInfo; - dateModified?: string; - changed: boolean; - metadata?: Record; -} - -export interface BuildSpecCompleteTaskUpdateInput { - frontmatter: Record; - targetDate: string; - completedStatus: string; - currentTimestamp?: string; - path?: string; -} - -export interface BuildSpecRecurringSkipUpdateInput { - frontmatter: Record; - targetDate: string; - skip: boolean; - currentTimestamp?: string; - path?: string; -} - -export interface BuildSpecTimeTrackingUpdateInput { - frontmatter: Record; - currentTimestamp: string; - path?: string; -} - -export interface BuildSpecStartTimeTrackingUpdateInput extends BuildSpecTimeTrackingUpdateInput { - startTimestamp?: string; - description?: string; -} - -export interface BuildSpecStopTimeTrackingUpdateInput extends BuildSpecTimeTrackingUpdateInput { - stopTimestamp?: string; -} - -export function buildTaskUpdatePlan({ - originalTask, - updates, - fieldMapping, - taskTag, - storeTitleInFilename = false, - userFields = [], - statuses = [], - now = new Date().toISOString(), - currentDateString = formatDateForStorage(parseDateToUTC(getDatePart(now))), - maintainDueDateOffsetInRecurring = true, -}: BuildTaskUpdatePlanInput): TaskOperationPlan { - const normalizedUpdates = normalizeTaskUpdateInput(updates); - const recurrenceUpdates = buildTaskUpdateRecurrenceUpdates({ - originalTask, - updates: normalizedUpdates, - maintainDueDateOffsetInRecurring, - }); - const updatedTask = buildUpdatedTaskFromPlan({ - originalTask, - updates: normalizedUpdates, - recurrenceUpdates, - newPath: originalTask.path, - dateModified: now, - currentDateString, - normalizedDetails: normalizeTaskUpdateDetails(normalizedUpdates), - isCompletedStatus: (status) => isCompletedStatus(status, statuses), - }); - const mapped = mapTaskToFrontmatter( - fieldMapping, - updatedTask, - taskTag, - storeTitleInFilename, - userFields - ); - const frontmatterPatch = buildSetPatch(mapped); - addUnsetMappedFieldDeletes(frontmatterPatch, { ...normalizedUpdates, ...recurrenceUpdates }, fieldMapping); - return { - kind: "task.update", - updatedTask, - frontmatterPatch, - dateModified: now, - metadata: { recurrenceUpdates }, - }; -} - -export function normalizeTaskUpdateInput(updates: TaskUpdateInput): TaskUpdateInput { - if (!Array.isArray(updates.timeEntries)) { - return { ...updates }; - } - return { - ...updates, - timeEntries: updates.timeEntries.map(stripTimeEntryDuration), - }; -} - -export function normalizeTaskUpdateDetails(updates: TaskUpdateInput): string | null { - if (!Object.prototype.hasOwnProperty.call(updates, "details")) { - return null; - } - return typeof updates.details === "string" ? updates.details.replace(/\r\n/g, "\n") : ""; -} - -export function buildTaskUpdateRecurrenceUpdates({ - originalTask, - updates, - maintainDueDateOffsetInRecurring, -}: { - originalTask: TaskInfo; - updates: TaskUpdateInput; - maintainDueDateOffsetInRecurring: boolean; -}): Partial { - const recurrenceUpdates: Partial = {}; - - if (updates.recurrence !== undefined && updates.recurrence !== originalTask.recurrence) { - const tempTask: TaskInfo = { ...originalTask, ...updates }; - const nextDates = updateToNextScheduledOccurrence(tempTask, maintainDueDateOffsetInRecurring); - if (nextDates.scheduled) recurrenceUpdates.scheduled = nextDates.scheduled; - if (nextDates.due) recurrenceUpdates.due = nextDates.due; - if (typeof updates.recurrence === "string" && updates.recurrence && !updates.recurrence.includes("DTSTART:")) { - const updatedRecurrence = addDTSTARTToRecurrenceRule({ - ...originalTask, - ...updates, - ...recurrenceUpdates, - }); - if (updatedRecurrence) recurrenceUpdates.recurrence = updatedRecurrence; - } - } else if (updates.recurrence !== undefined && !originalTask.recurrence && updates.recurrence) { - if (typeof updates.recurrence === "string" && !updates.recurrence.includes("DTSTART:")) { - const updatedRecurrence = addDTSTARTToRecurrenceRule({ ...originalTask, ...updates }); - if (updatedRecurrence) recurrenceUpdates.recurrence = updatedRecurrence; - } - } - - if ( - updates.scheduled !== undefined && - updates.scheduled !== originalTask.scheduled && - originalTask.recurrence && - typeof originalTask.recurrence === "string" && - !originalTask.recurrence.includes("DTSTART:") - ) { - const updatedRecurrence = addDTSTARTToRecurrenceRule({ ...originalTask, ...updates }); - if (updatedRecurrence) recurrenceUpdates.recurrence = updatedRecurrence; - } - - return recurrenceUpdates; -} - -export function buildUpdatedTaskFromPlan({ - originalTask, - updates, - recurrenceUpdates, - newPath, - dateModified, - currentDateString, - normalizedDetails, - finalTags, - isCompletedStatus: isCompletedStatusFn, -}: { - originalTask: TaskInfo; - updates: TaskUpdateInput; - recurrenceUpdates: Partial; - newPath: string; - dateModified: string; - currentDateString: string; - normalizedDetails: string | null; - finalTags?: string[]; - isCompletedStatus: (status: string) => boolean; -}): TaskInfo { - const updatedTask: TaskInfo = { - ...originalTask, - ...updates, - ...recurrenceUpdates, - path: newPath, - dateModified, - }; - if (finalTags) updatedTask.tags = finalTags; - if (normalizedDetails !== null) updatedTask.details = normalizedDetails; - if (updates.status !== undefined && !originalTask.recurrence) { - if (isCompletedStatusFn(updates.status)) { - if (!originalTask.completedDate) updatedTask.completedDate = currentDateString; - } else { - updatedTask.completedDate = undefined; - } - } - return updatedTask; -} - -export function buildTaskPropertyUpdatePlan({ - freshTask, - property, - value, - fieldMapping, - statuses = [], - now, - currentDateString, -}: BuildTaskPropertyUpdatePlanInput): TaskOperationPlan { - const normalizedValue = normalizeTaskPropertyValue(property, value); - const updatedTask = { ...freshTask, [property]: normalizedValue, dateModified: now } as TaskInfo; - - if (property === "status" && !freshTask.recurrence) { - const status = String(normalizedValue ?? ""); - updatedTask.completedDate = isCompletedStatus(status, statuses) ? currentDateString : undefined; - } - - const fieldName = fieldNameForTaskProperty(fieldMapping, property); - const frontmatterPatch: TaskPatchOperation[] = [{ op: "set", field: fieldMapping.dateModified, value: now }]; - if (fieldName) { - if ((property === "due" || property === "scheduled") && !value) { - frontmatterPatch.push({ op: "delete", field: fieldName }); - } else if (property === "status") { - const status = String(normalizedValue ?? ""); - frontmatterPatch.push({ op: "set", field: fieldName, value: coerceStatusFrontmatterValue(status) }); - if (!freshTask.recurrence && isCompletedStatus(status, statuses)) { - frontmatterPatch.push({ op: "set", field: fieldMapping.completedDate, value: currentDateString }); - } else if (!freshTask.recurrence) { - frontmatterPatch.push({ op: "delete", field: fieldMapping.completedDate }); - } - } else if (property === "blockedBy") { - const dependencies = Array.isArray(normalizedValue) ? (normalizedValue as TaskDependency[]) : []; - if (dependencies.length > 0) { - frontmatterPatch.push({ op: "set", field: fieldName, value: serializeDependencies(dependencies) }); - } else { - frontmatterPatch.push({ op: "delete", field: fieldName }); - } - } else { - frontmatterPatch.push({ op: "set", field: fieldName, value: normalizedValue }); - } - } - - return { - kind: "task.property-update", - updatedTask, - frontmatterPatch, - dateModified: now, - metadata: { property, normalizedValue }, - }; -} - -export function normalizeTaskPropertyValue(property: keyof TaskInfo, value: unknown): unknown { - if (property === "blockedBy") { - return normalizeBlockedByValue(value); - } - return value; -} - -export function normalizeBlockedByValue(value: unknown): TaskDependency[] | undefined { - if (value === null || value === undefined) return undefined; - const normalized = (Array.isArray(value) ? value : [value]) - .map((entry) => normalizeDependencyEntry(entry)) - .filter((entry): entry is TaskDependency => !!entry); - return normalized.length > 0 ? normalized : undefined; -} - -export function buildRecurringTaskCompletePlan({ - freshTask, - targetDate, - currentTimestamp, - maintainDueDateOffsetInRecurring, -}: { - freshTask: TaskInfo; - targetDate?: Date; - currentTimestamp: string; - maintainDueDateOffsetInRecurring: boolean; -}): RecurringTaskCompletePlan { - if (!freshTask.recurrence) throw new Error("Task is not recurring"); - const resolvedTargetDate = getRecurringTaskActionDate(freshTask, targetDate); - const dateStr = formatDateForStorage(resolvedTargetDate); - const completeInstances = getStringArray(freshTask.complete_instances); - const currentComplete = completeInstances.includes(dateStr); - const newComplete = !currentComplete; - const updatedTask: TaskInfo = { ...freshTask, dateModified: currentTimestamp }; - - if (newComplete) { - updatedTask.complete_instances = completeInstances.includes(dateStr) - ? completeInstances - : [...completeInstances, dateStr]; - updatedTask.skipped_instances = getStringArray(freshTask.skipped_instances).filter((date) => date !== dateStr); - } else { - updatedTask.complete_instances = completeInstances.filter((date) => date !== dateStr); - updatedTask.skipped_instances = getStringArray(freshTask.skipped_instances).filter((date) => date !== dateStr); - } - - if (newComplete && typeof updatedTask.recurrence === "string") { - if ((updatedTask.recurrence_anchor || "scheduled") === "completion") { - updatedTask.recurrence = updateDTSTARTInRecurrenceRule(updatedTask.recurrence, dateStr) || updatedTask.recurrence; - } else if (!updatedTask.recurrence.includes("DTSTART:")) { - updatedTask.recurrence = addDTSTARTToRecurrenceRule(updatedTask) || updatedTask.recurrence; - } - } - - const nextDates = updateToNextScheduledOccurrence(updatedTask, maintainDueDateOffsetInRecurring); - if (nextDates.scheduled) updatedTask.scheduled = nextDates.scheduled; - if (nextDates.due) updatedTask.due = nextDates.due; - - return { - updatedTask, - targetDate: resolvedTargetDate, - dateStr, - newComplete, - dateModified: currentTimestamp, - originalRecurrence: freshTask.recurrence, - }; -} - -export function buildRecurringTaskSkippedPlan({ - freshTask, - targetDate, - currentTimestamp, - maintainDueDateOffsetInRecurring, -}: { - freshTask: TaskInfo; - targetDate?: Date; - currentTimestamp: string; - maintainDueDateOffsetInRecurring: boolean; -}): RecurringTaskSkippedPlan { - if (!freshTask.recurrence) throw new Error("Task is not recurring"); - const resolvedTargetDate = getRecurringTaskActionDate(freshTask, targetDate); - const dateStr = formatDateForStorage(resolvedTargetDate); - const skippedInstances = getStringArray(freshTask.skipped_instances); - const currentlySkipped = skippedInstances.includes(dateStr); - const newSkipped = !currentlySkipped; - const updatedTask: TaskInfo = { ...freshTask, dateModified: currentTimestamp }; - - if (newSkipped) { - updatedTask.skipped_instances = skippedInstances.includes(dateStr) - ? skippedInstances - : [...skippedInstances, dateStr]; - updatedTask.complete_instances = getStringArray(freshTask.complete_instances).filter((date) => date !== dateStr); - } else { - updatedTask.skipped_instances = skippedInstances.filter((date) => date !== dateStr); - } - - const nextDates = updateToNextScheduledOccurrence(updatedTask, maintainDueDateOffsetInRecurring); - if (nextDates.scheduled) updatedTask.scheduled = nextDates.scheduled; - if (nextDates.due) updatedTask.due = nextDates.due; - - return { - updatedTask, - targetDate: resolvedTargetDate, - dateStr, - newSkipped, - dateModified: currentTimestamp, - }; -} - -export function recurringCompletePlanToFrontmatterPatch( - plan: RecurringTaskCompletePlan, - fieldMapping: FieldMapping -): TaskPatchOperation[] { - const patch: TaskPatchOperation[] = [ - { op: "set", field: fieldMapping.completeInstances, value: plan.updatedTask.complete_instances || [] }, - { op: "set", field: fieldMapping.skippedInstances, value: plan.updatedTask.skipped_instances || [] }, - { op: "set", field: fieldMapping.dateModified, value: plan.dateModified }, - ]; - if (plan.updatedTask.recurrence !== plan.originalRecurrence) { - patch.push({ op: "set", field: fieldMapping.recurrence, value: plan.updatedTask.recurrence }); - } - if (plan.updatedTask.scheduled) patch.push({ op: "set", field: fieldMapping.scheduled, value: plan.updatedTask.scheduled }); - if (plan.updatedTask.due) patch.push({ op: "set", field: fieldMapping.due, value: plan.updatedTask.due }); - return patch; -} - -export function recurringSkippedPlanToFrontmatterPatch( - plan: RecurringTaskSkippedPlan, - fieldMapping: FieldMapping -): TaskPatchOperation[] { - const patch: TaskPatchOperation[] = [ - { op: "set", field: fieldMapping.skippedInstances, value: plan.updatedTask.skipped_instances || [] }, - { op: "set", field: fieldMapping.completeInstances, value: plan.updatedTask.complete_instances || [] }, - { op: "set", field: fieldMapping.dateModified, value: plan.dateModified }, - ]; - if (plan.updatedTask.scheduled) patch.push({ op: "set", field: fieldMapping.scheduled, value: plan.updatedTask.scheduled }); - if (plan.updatedTask.due) patch.push({ op: "set", field: fieldMapping.due, value: plan.updatedTask.due }); - return patch; -} - -export function applyFrontmatterPatch( - frontmatter: Record, - patch: readonly TaskPatchOperation[] -): Record { - const result = { ...frontmatter }; - for (const operation of patch) { - if (operation.op === "set") { - result[operation.field] = operation.value; - } else { - delete result[operation.field]; - } - } - return result; -} - -export function specFrontmatterToTaskInfo( - frontmatter: Record, - path = "task.md" -): TaskInfo { - return { - title: readString(frontmatter.title) || path.replace(/\.md$/i, ""), - status: readString(frontmatter.status) || "open", - priority: readString(frontmatter.priority) || "normal", - path, - archived: frontmatter.archived === true, - due: readString(frontmatter.due), - scheduled: readString(frontmatter.scheduled), - completedDate: readString(frontmatter.completedDate), - dateCreated: readString(frontmatter.dateCreated), - dateModified: readString(frontmatter.dateModified), - recurrence: readString(frontmatter.recurrence), - recurrence_anchor: - frontmatter.recurrenceAnchor === "completion" ? "completion" : "scheduled", - complete_instances: getStringArray(frontmatter.completeInstances), - skipped_instances: getStringArray(frontmatter.skippedInstances), - timeEntries: sanitizeTimeEntries(frontmatter.timeEntries as TimeEntry[] | undefined), - tags: getStringArray(frontmatter.tags), - contexts: getStringArray(frontmatter.contexts), - projects: getStringArray(frontmatter.projects), - timeEstimate: - typeof frontmatter.timeEstimate === "number" ? frontmatter.timeEstimate : undefined, - blockedBy: normalizeBlockedByValue(frontmatter.blockedBy), - reminders: Array.isArray(frontmatter.reminders) - ? (frontmatter.reminders as TaskInfo["reminders"]) - : undefined, - }; -} - -export function taskInfoToSpecFields(task: Partial): Record { - const fields: Record = {}; - writeIfDefined(fields, "title", task.title); - writeIfDefined(fields, "status", task.status); - writeIfDefined(fields, "priority", task.priority); - writeIfDefined(fields, "due", task.due); - writeIfDefined(fields, "scheduled", task.scheduled); - writeIfDefined(fields, "completedDate", task.completedDate); - writeIfDefined(fields, "dateCreated", task.dateCreated); - writeIfDefined(fields, "dateModified", task.dateModified); - writeIfDefined(fields, "recurrence", task.recurrence); - writeIfDefined(fields, "recurrenceAnchor", task.recurrence_anchor); - writeIfDefined(fields, "completeInstances", task.complete_instances); - writeIfDefined(fields, "skippedInstances", task.skipped_instances); - writeIfDefined(fields, "timeEntries", task.timeEntries); - writeIfDefined(fields, "tags", task.tags); - writeIfDefined(fields, "contexts", task.contexts); - writeIfDefined(fields, "projects", task.projects); - writeIfDefined(fields, "timeEstimate", task.timeEstimate); - writeIfDefined(fields, "blockedBy", task.blockedBy); - writeIfDefined(fields, "reminders", task.reminders); - return fields; -} - -export function buildSpecCompleteTaskUpdate({ - frontmatter, - targetDate, - completedStatus, - currentTimestamp, - path, -}: BuildSpecCompleteTaskUpdateInput): SpecTaskUpdatePlan { - const task = specFrontmatterToTaskInfo(frontmatter, path); - if (!task.recurrence) { - const fields: Record = { - status: completedStatus, - completedDate: targetDate, - }; - if (currentTimestamp) fields.dateModified = currentTimestamp; - return { - fields, - updatedTask: applySpecFieldsToTaskInfo(task, fields), - dateModified: currentTimestamp, - changed: true, - metadata: { recurring: false, targetDate }, - }; - } - - const completeInstances = getStringArray(frontmatter.completeInstances); - if (completeInstances.includes(targetDate)) { - return { - fields: {}, - updatedTask: task, - dateModified: currentTimestamp, - changed: false, - metadata: { recurring: true, targetDate, alreadyCompleted: true }, - }; - } - - const recurring = completeRecurringTask({ - recurrence: task.recurrence, - recurrenceAnchor: task.recurrence_anchor, - scheduled: task.scheduled, - due: task.due, - dateCreated: task.dateCreated, - completionDate: targetDate, - completeInstances: task.complete_instances, - skippedInstances: task.skipped_instances, - }); - const fields: Record = { - recurrence: recurring.updatedRecurrence, - completeInstances: recurring.completeInstances, - skippedInstances: recurring.skippedInstances, - }; - - if (recurring.nextScheduled) { - fields.scheduled = recurring.nextScheduled; - if (recurring.nextDue) fields.due = recurring.nextDue; - } else { - fields.status = completedStatus; - fields.completedDate = targetDate; - } - if (currentTimestamp) fields.dateModified = currentTimestamp; - - return { - fields, - updatedTask: applySpecFieldsToTaskInfo(task, fields), - dateModified: currentTimestamp, - changed: true, - metadata: { - recurring: true, - targetDate, - nextScheduled: recurring.nextScheduled, - nextDue: recurring.nextDue, - }, - }; -} - -export function buildSpecRecurringSkipUpdate({ - frontmatter, - targetDate, - skip, - currentTimestamp, - path, -}: BuildSpecRecurringSkipUpdateInput): SpecTaskUpdatePlan { - const task = specFrontmatterToTaskInfo(frontmatter, path); - if (!task.recurrence) { - throw new Error("Skip/unskip is only supported for recurring tasks."); - } - - const skippedInstances = getStringArray(frontmatter.skippedInstances); - const completeInstances = getStringArray(frontmatter.completeInstances); - const alreadyInState = skip - ? skippedInstances.includes(targetDate) - : !skippedInstances.includes(targetDate); - if (alreadyInState) { - return { - fields: {}, - updatedTask: task, - dateModified: currentTimestamp, - changed: false, - metadata: { targetDate, skip, alreadyInState: true }, - }; - } - - const nextSkippedInstances = skip - ? appendUnique(skippedInstances, targetDate) - : skippedInstances.filter((date) => date !== targetDate); - const nextCompleteInstances = completeInstances.filter((date) => date !== targetDate); - const schedule = recalculateRecurringSchedule({ - recurrence: task.recurrence, - recurrenceAnchor: task.recurrence_anchor, - scheduled: task.scheduled, - due: task.due, - dateCreated: task.dateCreated, - completeInstances: nextCompleteInstances, - skippedInstances: nextSkippedInstances, - referenceDate: targetDate, - }); - const fields: Record = { - recurrence: schedule.updatedRecurrence, - completeInstances: nextCompleteInstances, - skippedInstances: nextSkippedInstances, - }; - if (schedule.nextScheduled) fields.scheduled = schedule.nextScheduled; - if (schedule.nextDue) fields.due = schedule.nextDue; - if (currentTimestamp) fields.dateModified = currentTimestamp; - - return { - fields, - updatedTask: applySpecFieldsToTaskInfo(task, fields), - dateModified: currentTimestamp, - changed: true, - metadata: { - targetDate, - skip, - nextScheduled: schedule.nextScheduled, - nextDue: schedule.nextDue, - }, - }; -} - -export function buildSpecStartTimeTrackingUpdate({ - frontmatter, - currentTimestamp, - startTimestamp = currentTimestamp, - description, - path, -}: BuildSpecStartTimeTrackingUpdateInput): SpecTaskUpdatePlan { - const task = specFrontmatterToTaskInfo(frontmatter, path); - if (getActiveTimeEntry(task)) { - throw new Error("time_tracking_already_active"); - } - const plan = buildStartTimeTrackingPlan( - task, - currentTimestamp, - startTimestamp, - description ?? "" - ); - if (description === undefined && plan.newEntry.description === "") { - delete plan.newEntry.description; - const entries = plan.updatedTask.timeEntries ?? []; - const lastEntry = entries[entries.length - 1]; - if (lastEntry) delete lastEntry.description; - } - return { - fields: taskInfoToSpecFields({ - timeEntries: plan.updatedTask.timeEntries, - dateModified: plan.dateModified, - }), - updatedTask: plan.updatedTask, - dateModified: plan.dateModified, - changed: true, - metadata: { newEntry: plan.newEntry }, - }; -} - -export function buildSpecStopTimeTrackingUpdate({ - frontmatter, - currentTimestamp, - stopTimestamp = currentTimestamp, - path, -}: BuildSpecStopTimeTrackingUpdateInput): SpecTaskUpdatePlan { - const task = specFrontmatterToTaskInfo(frontmatter, path); - const activeEntry = getActiveTimeEntry(task); - if (!activeEntry) { - throw new Error("no_active_time_entry"); - } - const plan = buildStopTimeTrackingPlan(task, activeEntry, currentTimestamp, stopTimestamp); - const stoppedEntry = plan.updatedTask.timeEntries?.find( - (entry) => entry.startTime === activeEntry.startTime - ); - return { - fields: taskInfoToSpecFields({ - timeEntries: plan.updatedTask.timeEntries, - dateModified: plan.dateModified, - }), - updatedTask: plan.updatedTask, - dateModified: plan.dateModified, - changed: true, - metadata: { activeEntry, stoppedEntry }, - }; -} - -function buildSetPatch(frontmatter: Record): TaskPatchOperation[] { - return Object.entries(frontmatter) - .filter(([, value]) => value !== undefined) - .map(([field, value]) => ({ op: "set", field, value }) satisfies TaskPatchOperation); -} - -function applySpecFieldsToTaskInfo(task: TaskInfo, fields: Record): TaskInfo { - const updatedTask = { ...task }; - if (Object.prototype.hasOwnProperty.call(fields, "title")) updatedTask.title = readString(fields.title) || updatedTask.title; - if (Object.prototype.hasOwnProperty.call(fields, "status")) updatedTask.status = readString(fields.status) || updatedTask.status; - if (Object.prototype.hasOwnProperty.call(fields, "priority")) updatedTask.priority = readString(fields.priority) || updatedTask.priority; - if (Object.prototype.hasOwnProperty.call(fields, "due")) updatedTask.due = readString(fields.due); - if (Object.prototype.hasOwnProperty.call(fields, "scheduled")) updatedTask.scheduled = readString(fields.scheduled); - if (Object.prototype.hasOwnProperty.call(fields, "completedDate")) updatedTask.completedDate = readString(fields.completedDate); - if (Object.prototype.hasOwnProperty.call(fields, "dateCreated")) updatedTask.dateCreated = readString(fields.dateCreated); - if (Object.prototype.hasOwnProperty.call(fields, "dateModified")) updatedTask.dateModified = readString(fields.dateModified); - if (Object.prototype.hasOwnProperty.call(fields, "recurrence")) updatedTask.recurrence = readString(fields.recurrence); - if (Object.prototype.hasOwnProperty.call(fields, "recurrenceAnchor")) { - updatedTask.recurrence_anchor = fields.recurrenceAnchor === "completion" ? "completion" : "scheduled"; - } - if (Object.prototype.hasOwnProperty.call(fields, "completeInstances")) { - updatedTask.complete_instances = getStringArray(fields.completeInstances); - } - if (Object.prototype.hasOwnProperty.call(fields, "skippedInstances")) { - updatedTask.skipped_instances = getStringArray(fields.skippedInstances); - } - if (Object.prototype.hasOwnProperty.call(fields, "timeEntries")) { - updatedTask.timeEntries = sanitizeTimeEntries(fields.timeEntries as TimeEntry[] | undefined); - } - return updatedTask; -} - -function addUnsetMappedFieldDeletes( - patch: TaskPatchOperation[], - updates: TaskUpdateInput, - fieldMapping: FieldMapping -): void { - const deletable: Array<[keyof TaskUpdateInput, FieldMappingKey]> = [ - ["due", "due"], - ["scheduled", "scheduled"], - ["contexts", "contexts"], - ["timeEstimate", "timeEstimate"], - ["completedDate", "completedDate"], - ["recurrence", "recurrence"], - ["blockedBy", "blockedBy"], - ["googleCalendarExceptionOriginalScheduled", "googleCalendarExceptionOriginalScheduled"], - ]; - for (const [updateKey, mappingKey] of deletable) { - if (Object.prototype.hasOwnProperty.call(updates, updateKey) && updates[updateKey] === undefined) { - patch.push({ op: "delete", field: fieldMapping[mappingKey] }); - } - } - if (Object.prototype.hasOwnProperty.call(updates, "projects")) { - if (!Array.isArray(updates.projects) || updates.projects.length === 0) { - patch.push({ op: "delete", field: fieldMapping.projects }); - } - } - if ( - Object.prototype.hasOwnProperty.call(updates, "googleCalendarMovedOriginalDates") && - (!Array.isArray(updates.googleCalendarMovedOriginalDates) || - updates.googleCalendarMovedOriginalDates.length === 0) - ) { - patch.push({ op: "delete", field: fieldMapping.googleCalendarMovedOriginalDates }); - } -} - -function fieldNameForTaskProperty(fieldMapping: FieldMapping, property: keyof TaskInfo): string | undefined { - const explicit: Partial> = { - title: "title", - status: "status", - priority: "priority", - due: "due", - scheduled: "scheduled", - contexts: "contexts", - projects: "projects", - timeEstimate: "timeEstimate", - completedDate: "completedDate", - dateCreated: "dateCreated", - dateModified: "dateModified", - recurrence: "recurrence", - complete_instances: "completeInstances", - skipped_instances: "skippedInstances", - timeEntries: "timeEntries", - blockedBy: "blockedBy", - reminders: "reminders", - sortOrder: "sortOrder", - }; - const mappingKey = explicit[property]; - return mappingKey ? fieldMapping[mappingKey] : undefined; -} - -function stripTimeEntryDuration(entry: TimeEntry): TimeEntry { - const sanitizedEntry = { ...entry }; - delete sanitizedEntry.duration; - return sanitizedEntry; -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function writeIfDefined(target: Record, key: string, value: unknown): void { - if (value !== undefined) { - target[key] = value; - } -} - -function appendUnique(values: string[], value: string): string[] { - return values.includes(value) ? values : [...values, value]; -} - -function getStringArray(value: unknown): string[] { - return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; -} diff --git a/packages/model/src/recurrence.ts b/packages/model/src/recurrence.ts deleted file mode 100644 index c44096dd..00000000 --- a/packages/model/src/recurrence.ts +++ /dev/null @@ -1,613 +0,0 @@ -import * as rrulePackage from "rrule"; -import { - createUTCDateForRRule, - formatDateAsUTCString, - formatDateForStorage, - getDatePart, - getTodayLocal, - getTodayString, - hasTimeComponent, - parseDateToLocal, - parseDateToUTC, -} from "./date"; -import type { RecurrenceAnchor, TaskInfo } from "./types"; - -const rruleDefault = Reflect.get(rrulePackage, "default") as - | typeof import("rrule") - | undefined; -const RRule = ((rrulePackage as typeof import("rrule")).RRule ?? - rruleDefault?.RRule) as typeof import("rrule").RRule; -const MAX_FINITE_INSTANCE_COUNT = 10000; - -type RRuleInstance = { - between(start: Date, end: Date, inclusive?: boolean): Date[]; - after(date: Date, inclusive?: boolean): Date | null; -}; - -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; -} - -export interface RecurrenceDateContext { - today?: string; -} - -export function isDueByRRule(task: RecurringTaskLike, date: Date): boolean { - if (!task.recurrence) return true; - if (typeof task.recurrence !== "string") return true; - - try { - const rule = createRRule(task); - if (!rule) return false; - const targetDateStart = createUTCDateForRRule(formatDateAsUTCString(date)); - const occurrences = rule.between( - targetDateStart, - new Date(targetDateStart.getTime() + 24 * 60 * 60 * 1000 - 1), - true - ); - return occurrences.length > 0; - } catch { - return true; - } -} - -export function getEffectiveTaskStatus( - task: Pick, - date: Date, - completedStatus = "done" -): 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 : 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 || typeof task.recurrence !== "string") return []; - - try { - const rule = createRRule(task); - if (!rule) 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 rule.between(utcStartDate, utcEndDate, true); - } catch { - 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; - } -} - -export function getFiniteRecurringInstanceCount( - task: Pick -): number | null { - if (!task.recurrence || typeof task.recurrence !== "string") return null; - const countMatch = task.recurrence.match(/(?:^|;)COUNT=(\d+)(?:;|$)/); - if (countMatch) { - const count = Number.parseInt(countMatch[1], 10); - return Number.isFinite(count) && count > 0 ? count : null; - } - if (!/(?:^|;)UNTIL=/.test(task.recurrence)) return null; - - try { - const dtstart = getRRuleDtstart(task); - const until = parseUntilFromRecurrence(task.recurrence); - if (!dtstart || !until || until < dtstart) return null; - const instances = generateRecurringInstances(task, dtstart, until); - if (instances.length >= MAX_FINITE_INSTANCE_COUNT) return null; - return instances.length > 0 ? instances.length : null; - } catch { - return null; - } -} - -export function getNextUncompletedOccurrence( - task: RecurringTaskLike, - context: RecurrenceDateContext = {} -): Date | null { - if (!task.recurrence) return null; - return (task.recurrence_anchor || "scheduled") === "completion" - ? getNextCompletionBasedOccurrence(task, context) - : getNextScheduledBasedOccurrence(task, context); -} - -export function updateToNextScheduledOccurrence( - task: Pick< - RecurringTaskLike, - | "title" - | "recurrence" - | "scheduled" - | "due" - | "dateCreated" - | "recurrence_anchor" - | "complete_instances" - | "skipped_instances" - >, - maintainDueOffset = true, - context: RecurrenceDateContext = {} -): { scheduled: string | null; due: string | null } { - const nextOccurrence = getNextUncompletedOccurrence(task, context); - let nextScheduleStr: string | null = null; - let nextDueStr: string | null = null; - let nextDueDate: Date | null = null; - - if (nextOccurrence) { - try { - const originalScheduled = task.scheduled ? parseDateToUTC(task.scheduled) : null; - const originalDue = task.due ? parseDateToUTC(task.due) : null; - if (originalScheduled && originalDue) { - const dueWouldBeBeforeNextSchedule = - !maintainDueOffset && originalDue.getTime() < nextOccurrence.getTime(); - if (maintainDueOffset || dueWouldBeBeforeNextSchedule) { - const offsetMs = originalDue.getTime() - originalScheduled.getTime(); - nextDueDate = new Date(nextOccurrence.getTime() + offsetMs); - } - } - } catch { - nextDueDate = null; - } - - if (task.scheduled && task.scheduled.includes("T")) { - nextScheduleStr = `${formatDateForStorage(nextOccurrence)}T${task.scheduled.split("T")[1]}`; - } else { - nextScheduleStr = formatDateForStorage(nextOccurrence); - } - - if (nextDueDate && task.due && task.due.includes("T")) { - nextDueStr = `${formatDateForStorage(nextDueDate)}T${task.due.split("T")[1]}`; - } 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=")) { - return "rrule"; - } - const rruleString = recurrence.replace(/DTSTART:[^;]+;?/, ""); - return RRule.fromString(rruleString).toText(); - } catch { - 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; - return `DTSTART:${formatDtstartValue(sourceDateString)};${task.recurrence}`; -} - -export function updateDTSTARTInRecurrenceRule(recurrence: string, dateStr: string): string | null { - if (!recurrence || typeof recurrence !== "string") return null; - const dtstartValue = formatDtstartValue(dateStr); - if (recurrence.includes("DTSTART:")) { - return recurrence.replace(/DTSTART:[^;]+;?/, `DTSTART:${dtstartValue};`); - } - return `DTSTART:${dtstartValue};${recurrence}`; -} - -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; - - if (allDay) { - const sourceDate = parseDateToUTC(sourceDateString); - return `DTSTART:${formatDtstartDate(sourceDate)};${task.recurrence}`; - } - - 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"); - return `DTSTART:${year}${month}${day}T${hours}${minutes}00Z;${task.recurrence}`; -} - -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((date) => date !== 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); -} - -export function getRecurringTaskActionDate(task: Pick, date?: Date): Date { - if (date) return date; - if (task.recurrence_anchor !== "completion" && task.scheduled) { - return parseDateToUTC(getDatePart(task.scheduled)); - } - const todayLocal = getTodayLocal(); - return new Date(Date.UTC(todayLocal.getFullYear(), todayLocal.getMonth(), todayLocal.getDate())); -} - -function createRRule( - task: Pick -): RRuleInstance | null { - if (!task.recurrence || typeof task.recurrence !== "string") return null; - const dtstart = getRRuleDtstart(task); - if (!dtstart) return null; - const rruleString = task.recurrence.replace(/DTSTART:[^;]+;?/, "").replace(/^;/, "").trim(); - const rruleOptions = RRule.parseString(rruleString); - rruleOptions.dtstart = dtstart; - return new RRule(rruleOptions); -} - -function getRRuleDtstart( - task: Pick -): Date | null { - if (!task.recurrence) return null; - return ( - parseDtstartFromRecurrence(task.recurrence) || - (task.scheduled ? createUTCDateForRRule(task.scheduled) : null) || - (task.dateCreated ? createUTCDateForRRule(task.dateCreated) : null) - ); -} - -function parseDtstartFromRecurrence(recurrence: string): Date | null { - const match = recurrence.match(/DTSTART:(\d{8}(?:T\d{6}Z?)?)/); - if (!match) return null; - return parseRRuleDateValue(match[1], false); -} - -function parseUntilFromRecurrence(recurrence: string): Date | null { - const match = recurrence.match(/(?:^|;)UNTIL=(\d{8}(?:T\d{6}Z?)?)(?:;|$)/); - if (!match) return null; - return parseRRuleDateValue(match[1], true); -} - -function parseRRuleDateValue(value: string, endOfDayForDateOnly: boolean): Date { - const year = Number(value.slice(0, 4)); - const month = Number(value.slice(4, 6)) - 1; - const day = Number(value.slice(6, 8)); - if (value.length === 8) { - return endOfDayForDateOnly - ? new Date(Date.UTC(year, month, day, 23, 59, 59, 999)) - : new Date(Date.UTC(year, month, day, 0, 0, 0, 0)); - } - const hour = Number(value.slice(9, 11)) || 0; - const minute = Number(value.slice(11, 13)) || 0; - const second = Number(value.slice(13, 15)) || 0; - return new Date(Date.UTC(year, month, day, hour, minute, second, 0)); -} - -function parseIntervalFromRecurrence(recurrence: string): number { - const match = recurrence.match(/INTERVAL=(\d+)/); - return match ? Number.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`; - } - return formatDtstartDate(parseDateToUTC(dateStr)); -} - -function formatDtstartDate(date: Date): string { - 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}`; -} - -function getNextScheduledBasedOccurrence( - task: RecurringTaskLike, - context: RecurrenceDateContext -): Date | null { - if (!task.recurrence) return null; - const today = parseDateToUTC(context.today || 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; -} - -function getNextCompletionBasedOccurrence( - task: RecurringTaskLike, - context: RecurrenceDateContext -): Date | null { - if (!task.recurrence || typeof task.recurrence !== "string") return null; - const today = parseDateToUTC(context.today || 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; -} - -interface RecurrenceScheduleInternalInput extends RecurrenceScheduleInput { - completionDateForAnchor?: string; -} - -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") { - updatedRecurrence = - updateDTSTARTInRecurrenceRule( - updatedRecurrence, - input.completionDateForAnchor || input.referenceDate - ) || 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([ - ...completeInstances, - ...skippedInstances, - formatDateForStorage(referenceDate), - ]); - 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 }; - } - - return { - updatedRecurrence, - nextScheduled: formatLikeExisting(input.scheduled, nextOccurrence), - nextDue: computeNextDue(input, nextOccurrence), - }; -} - -function getNextOccurrenceDate( - recurrence: string, - sourceDate: string, - afterDate: Date, - inclusive: boolean -): Date | null { - const rule = buildRRuleFromRecurrence(recurrence, sourceDate); - return rule ? rule.after(afterDate, inclusive) : null; -} - -function buildRRuleFromRecurrence(recurrence: string, sourceDate: string): RRuleInstance | null { - try { - const rruleString = recurrence.replace(/DTSTART:[^;]+;?/, "").replace(/^;/, "").trim(); - if (!rruleString.includes("FREQ=")) return null; - const options = RRule.parseString(rruleString); - const dtstart = parseDtstartFromRecurrence(recurrence) || parseDateString(sourceDate); - if (dtstart) options.dtstart = dtstart; - return new RRule(options); - } catch { - return null; - } -} - -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(); - return formatLikeExisting(input.due, new Date(nextScheduledDate.getTime() + offsetMs)); -} - -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; -} diff --git a/packages/model/src/schema.ts b/packages/model/src/schema.ts deleted file mode 100644 index 32eeecef..00000000 --- a/packages/model/src/schema.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { z } from "zod"; - -export const taskDependencyRelTypeSchema = z.enum([ - "FINISHTOSTART", - "FINISHTOFINISH", - "STARTTOSTART", - "STARTTOFINISH", -]); - -export const taskDependencySchema = z.object({ - uid: z.string().min(1), - reltype: taskDependencyRelTypeSchema, - gap: z.string().optional(), -}); - -export const timeEntrySchema = z.object({ - startTime: z.string().min(1), - endTime: z.string().min(1).optional(), - description: z.string().optional(), - duration: z.number().optional(), -}); - -export const reminderSchema = z.object({ - id: z.string().min(1), - type: z.enum(["absolute", "relative"]), - relatedTo: z.enum(["due", "scheduled"]).optional(), - offset: z.string().optional(), - absoluteTime: z.string().optional(), - description: z.string().optional(), -}); - -export const statusConfigSchema = z.object({ - id: z.string().min(1), - value: z.string().min(1), - label: z.string().min(1), - color: z.string().min(1), - icon: z.string().optional(), - isCompleted: z.boolean(), - excludeFromCycle: z.boolean().optional(), - nextStatus: z.string().optional(), - order: z.number(), - autoArchive: z.boolean(), - autoArchiveDelay: z.number(), -}); - -export const priorityConfigSchema = z.object({ - id: z.string().min(1), - value: z.string().min(1), - label: z.string().min(1), - color: z.string().min(1), - icon: z.string().optional(), - weight: z.number(), -}); - -export const taskInfoSchema = z.object({ - id: z.string().optional(), - title: z.string(), - status: z.string(), - priority: z.string(), - due: z.string().optional(), - scheduled: z.string().optional(), - path: z.string(), - archived: z.boolean(), - tags: z.array(z.string()).optional(), - contexts: z.array(z.string()).optional(), - projects: z.array(z.string()).optional(), - recurrence: z.string().optional(), - recurrence_anchor: z.enum(["scheduled", "completion"]).optional(), - complete_instances: z.array(z.string()).optional(), - skipped_instances: z.array(z.string()).optional(), - completedDate: z.string().optional(), - timeEstimate: z.number().optional(), - timeEntries: z.array(timeEntrySchema).optional(), - totalTrackedTime: z.number().optional(), - dateCreated: z.string().optional(), - dateModified: z.string().optional(), - icsEventId: z.array(z.string()).optional(), - googleCalendarEventId: z.string().optional(), - googleCalendarExceptionEventId: z.string().optional(), - googleCalendarExceptionOriginalScheduled: z.string().optional(), - googleCalendarMovedOriginalDates: z.array(z.string()).optional(), - reminders: z.array(reminderSchema).optional(), - customProperties: z.record(z.unknown()).optional(), - basesData: z.unknown().optional(), - blockedBy: z.array(taskDependencySchema).optional(), - blocking: z.array(z.string()).optional(), - isBlocked: z.boolean().optional(), - isBlocking: z.boolean().optional(), - hasSubtasks: z.boolean().optional(), - details: z.string().optional(), - sortOrder: z.string().optional(), -}); - -export const taskDocumentSchema = z.object({ - frontmatter: z.record(z.unknown()), - body: z.string(), - task: taskInfoSchema.partial(), - path: z.string().optional(), -}); diff --git a/packages/model/src/time.ts b/packages/model/src/time.ts deleted file mode 100644 index 57b5f954..00000000 --- a/packages/model/src/time.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { TaskInfo, TimeEntry } from "./types"; - -export interface StartTimeTrackingPlan { - updatedTask: TaskInfo; - newEntry: TimeEntry; - dateModified: string; -} - -export interface StopTimeTrackingPlan { - updatedTask: TaskInfo; - stopTimestamp: string; - dateModified: string; -} - -export interface DeleteTimeEntryPlan { - updatedTask: TaskInfo; - timeEntryIndex: number; - dateModified: string; -} - -export function removeTimeEntryDuration(entry: TimeEntry): TimeEntry { - const sanitizedEntry = { ...entry }; - delete sanitizedEntry.duration; - return sanitizedEntry; -} - -export function sanitizeTimeEntries(entries: readonly TimeEntry[] | undefined): TimeEntry[] { - return Array.isArray(entries) ? entries.map(removeTimeEntryDuration) : []; -} - -export function getActiveTimeEntry(task: Pick): TimeEntry | undefined { - return sanitizeTimeEntries(task.timeEntries).find((entry) => !entry.endTime); -} - -export function calculateTimeEntryMinutes(entry: TimeEntry): number { - if (!entry.endTime) return 0; - const start = new Date(entry.startTime); - const end = new Date(entry.endTime); - if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start) { - return 0; - } - return Math.round((end.getTime() - start.getTime()) / 60000); -} - -export function calculateElapsedTimeEntryMinutes( - entry: TimeEntry, - now = new Date().toISOString() -): number { - const start = new Date(entry.startTime); - const end = new Date(entry.endTime || now); - if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start) { - return 0; - } - return Math.round((end.getTime() - start.getTime()) / 60000); -} - -export function calculateTotalTrackedMinutes(entries: readonly TimeEntry[] | undefined): number { - return sanitizeTimeEntries(entries).reduce((total, entry) => total + calculateTimeEntryMinutes(entry), 0); -} - -export function buildStartTimeTrackingPlan( - task: TaskInfo, - currentTimestamp: string, - startTimestamp = currentTimestamp, - description = "Work session" -): StartTimeTrackingPlan { - const newEntry: TimeEntry = { - startTime: startTimestamp, - description, - }; - const updatedTask: TaskInfo = { - ...task, - dateModified: currentTimestamp, - timeEntries: [...sanitizeTimeEntries(task.timeEntries), newEntry], - }; - return { updatedTask, newEntry, dateModified: currentTimestamp }; -} - -export function buildStopTimeTrackingPlan( - task: TaskInfo, - activeSession: TimeEntry, - currentTimestamp: string, - stopTimestamp = currentTimestamp -): StopTimeTrackingPlan { - const updatedTask: TaskInfo = { - ...task, - dateModified: currentTimestamp, - }; - const timeEntries = sanitizeTimeEntries(task.timeEntries); - const entryIndex = timeEntries.findIndex( - (entry) => entry.startTime === activeSession.startTime && !entry.endTime - ); - if (entryIndex !== -1) { - timeEntries[entryIndex] = { - ...timeEntries[entryIndex], - endTime: stopTimestamp, - }; - updatedTask.timeEntries = timeEntries; - } - return { updatedTask, stopTimestamp, dateModified: currentTimestamp }; -} - -export function buildDeleteTimeEntryPlan( - task: TaskInfo, - timeEntryIndex: number, - currentTimestamp: string -): DeleteTimeEntryPlan { - if (!Array.isArray(task.timeEntries)) { - throw new Error("Task has no time entries"); - } - if (timeEntryIndex < 0 || timeEntryIndex >= task.timeEntries.length) { - throw new Error("Invalid time entry index"); - } - return { - updatedTask: { - ...task, - dateModified: currentTimestamp, - timeEntries: sanitizeTimeEntries(task.timeEntries).filter((_, index) => index !== timeEntryIndex), - }, - timeEntryIndex, - dateModified: currentTimestamp, - }; -} - -export function replaceTimeEntries( - task: TaskInfo, - entries: readonly TimeEntry[], - currentTimestamp: string -): TaskInfo { - return { - ...task, - dateModified: currentTimestamp, - timeEntries: sanitizeTimeEntries(entries), - }; -} diff --git a/packages/model/src/types.ts b/packages/model/src/types.ts deleted file mode 100644 index 239f0d5f..00000000 --- a/packages/model/src/types.ts +++ /dev/null @@ -1,267 +0,0 @@ -export const TASKNOTES_SPEC_VERSION = "0.1.0-draft"; - -export type JsonPrimitive = string | number | boolean | null; -export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; - -export interface JsonObject { - [key: string]: JsonValue; -} - -export type RecurrenceAnchor = "scheduled" | "completion"; - -export type TaskDependencyRelType = - | "FINISHTOSTART" - | "FINISHTOFINISH" - | "STARTTOSTART" - | "STARTTOFINISH"; - -export interface TaskDependency { - uid: string; - reltype: TaskDependencyRelType; - gap?: string; -} - -export interface TimeEntry { - startTime: string; - endTime?: string; - description?: string; - duration?: number; -} - -export interface Reminder { - id: string; - type: "absolute" | "relative"; - relatedTo?: "due" | "scheduled"; - offset?: string; - absoluteTime?: string; - description?: string; -} - -export interface TaskInfo { - id?: string; - title: string; - status: string; - priority: string; - due?: string; - scheduled?: string; - path: string; - archived: boolean; - tags?: string[]; - contexts?: string[]; - projects?: string[]; - recurrence?: string; - recurrence_anchor?: RecurrenceAnchor; - complete_instances?: string[]; - skipped_instances?: string[]; - completedDate?: string; - timeEstimate?: number; - timeEntries?: TimeEntry[]; - totalTrackedTime?: number; - dateCreated?: string; - dateModified?: string; - icsEventId?: string[]; - googleCalendarEventId?: string; - googleCalendarExceptionEventId?: string; - googleCalendarExceptionOriginalScheduled?: string; - googleCalendarMovedOriginalDates?: string[]; - reminders?: Reminder[]; - customProperties?: Record; - basesData?: unknown; - blockedBy?: TaskDependency[]; - blocking?: string[]; - isBlocked?: boolean; - isBlocking?: boolean; - hasSubtasks?: boolean; - details?: string; - sortOrder?: string; -} - -export interface TaskCreationData extends Partial { - details?: string; - parentNote?: string; - creationContext?: - | "inline-conversion" - | "manual-creation" - | "modal-inline-creation" - | "api" - | "import" - | "ics-event"; - customFrontmatter?: Record; -} - -export type TaskUpdateInput = Partial & { - details?: string; - customFrontmatter?: Record; -}; - -export interface FieldMapping { - title: string; - status: string; - priority: string; - due: string; - scheduled: string; - contexts: string; - projects: string; - timeEstimate: string; - completedDate: string; - dateCreated: string; - dateModified: string; - recurrence: string; - recurrenceAnchor: string; - archiveTag: string; - timeEntries: string; - completeInstances: string; - skippedInstances: string; - blockedBy: string; - pomodoros: string; - icsEventId: string; - icsEventTag: string; - googleCalendarEventId: string; - googleCalendarExceptionEventId: string; - googleCalendarExceptionOriginalScheduled: string; - googleCalendarMovedOriginalDates: string; - reminders: string; - sortOrder: string; -} - -export type FieldMappingKey = keyof FieldMapping; -export type FrontmatterPropertyName = string; - -export interface StatusConfig { - id: string; - value: string; - label: string; - color: string; - icon?: string; - isCompleted: boolean; - excludeFromCycle?: boolean; - nextStatus?: string; - order: number; - autoArchive: boolean; - autoArchiveDelay: number; -} - -export interface PriorityConfig { - id: string; - value: string; - label: string; - color: string; - icon?: string; - weight: number; -} - -export type UserMappedFieldType = "text" | "number" | "date" | "boolean" | "list"; - -export interface UserMappedField { - id: string; - displayName: string; - key: string; - type: UserMappedFieldType; - defaultValue?: string | number | boolean | string[]; -} - -export type HideIdentifyingTagsMode = "all" | "exact-only"; - -export interface TaskIdentificationConfig { - method: "tag" | "property"; - tag: string; - propertyName: string; - propertyValue: string; - excludedFolders?: string | string[]; -} - -export interface TaskDefaults { - status: string; - priority: string; - taskTag: string; -} - -export interface TimeTrackingConfig { - autoStopOnComplete: boolean; - autoStopNotification: boolean; - defaultSessionDescription: string; -} - -export interface RecurrenceConfig { - maintainDueDateOffset: boolean; - resetCheckboxesOnRecurrence: boolean; -} - -export interface TaskNotesModelConfig { - fieldMapping: FieldMapping; - statuses: StatusConfig[]; - priorities: PriorityConfig[]; - defaults: TaskDefaults; - taskIdentification: TaskIdentificationConfig; - storeTitleInFilename: boolean; - userFields: UserMappedField[]; - recurrence: RecurrenceConfig; - timeTracking: TimeTrackingConfig; -} - -export interface TaskDocument { - frontmatter: Record; - body: string; - task: Partial; - path?: string; -} - -export type TaskValidationSeverity = "error" | "warning"; - -export interface TaskValidationIssue { - code: string; - message: string; - severity: TaskValidationSeverity; - path?: string[]; - field?: string; -} - -export interface TaskValidationResult { - valid: boolean; - issues: TaskValidationIssue[]; -} - -export type TaskPatchOperation = - | { op: "set"; field: string; value: unknown } - | { op: "delete"; field: string }; - -export interface TaskOperationPlan = TaskInfo> { - kind: string; - updatedTask: TTask; - frontmatterPatch: TaskPatchOperation[]; - dateModified?: string; - metadata?: Record; - issues?: TaskValidationIssue[]; -} - -export type FieldRole = - | "title" - | "status" - | "priority" - | "due" - | "scheduled" - | "completedDate" - | "tags" - | "contexts" - | "projects" - | "timeEstimate" - | "dateCreated" - | "dateModified" - | "recurrence" - | "recurrenceAnchor" - | "completeInstances" - | "skippedInstances" - | "timeEntries" - | "blockedBy" - | "reminders"; - -export interface SpecFieldMapping { - roleToField: Record; - fieldToRole: Record; - displayNameKey: string; - completedStatuses: string[]; -} - -export type ConformanceEnvelope = - | { ok: true; result: unknown } - | { ok: false; error: string; error_details?: Record }; diff --git a/packages/model/src/validation.ts b/packages/model/src/validation.ts deleted file mode 100644 index a538689c..00000000 --- a/packages/model/src/validation.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { getDatePart, validateDateString } from "./date"; -import { isCompletedStatus } from "./config"; -import { taskInfoSchema, timeEntrySchema } from "./schema"; -import type { - StatusConfig, - TaskInfo, - TaskValidationIssue, - TaskValidationResult, - TimeEntry, -} from "./types"; - -export function validateTask(task: Partial): TaskValidationResult { - const issues: TaskValidationIssue[] = []; - const parsed = taskInfoSchema.partial().safeParse(task); - if (!parsed.success) { - for (const issue of parsed.error.issues) { - issues.push({ - code: "schema_invalid", - message: issue.message, - severity: "error", - path: issue.path.map(String), - }); - } - } - - for (const field of ["due", "scheduled", "completedDate", "dateCreated", "dateModified"] as const) { - const value = task[field]; - if (value !== undefined && typeof value === "string") { - try { - validateDateString(getDatePart(value)); - } catch { - issues.push({ - code: "invalid_date", - message: `${field} must contain a valid date`, - severity: "error", - field, - }); - } - } - } - - if (task.complete_instances) { - for (const date of task.complete_instances) { - try { - validateDateString(date); - } catch { - issues.push({ - code: "invalid_complete_instance", - message: `Invalid completed recurrence instance "${date}"`, - severity: "warning", - field: "complete_instances", - }); - } - } - } - - if (task.skipped_instances) { - for (const date of task.skipped_instances) { - try { - validateDateString(date); - } catch { - issues.push({ - code: "invalid_skipped_instance", - message: `Invalid skipped recurrence instance "${date}"`, - severity: "warning", - field: "skipped_instances", - }); - } - } - } - - if (task.timeEntries) { - issues.push(...validateTimeEntries(task.timeEntries).issues); - } - - return { - valid: !issues.some((issue) => issue.severity === "error"), - issues, - }; -} - -export function validateTimeEntries(entries: unknown): TaskValidationResult { - const issues: TaskValidationIssue[] = []; - if (!Array.isArray(entries)) { - return { - valid: false, - issues: [ - { - code: "time_entries_not_array", - message: "timeEntries must be an array", - severity: "error", - field: "timeEntries", - }, - ], - }; - } - - entries.forEach((entry, index) => { - const parsed = timeEntrySchema.safeParse(entry); - if (!parsed.success) { - for (const issue of parsed.error.issues) { - issues.push({ - code: "invalid_time_entry", - message: issue.message, - severity: "error", - path: ["timeEntries", String(index), ...issue.path.map(String)], - }); - } - return; - } - validateTimeEntryOrder(parsed.data, index, issues); - }); - - const activeCount = (entries as TimeEntry[]).filter((entry) => entry && !entry.endTime).length; - if (activeCount > 1) { - issues.push({ - code: "multiple_active_time_entries", - message: "Only one active time entry is allowed", - severity: "warning", - field: "timeEntries", - }); - } - - return { - valid: !issues.some((issue) => issue.severity === "error"), - issues, - }; -} - -export function evaluateCoreValidation( - task: Partial, - statuses: readonly StatusConfig[] -): TaskValidationResult { - const result = validateTask(task); - if (task.status && isCompletedStatus(task.status, statuses) && task.recurrence && task.completedDate) { - result.issues.push({ - code: "recurring_task_completed_date", - message: "Recurring task completion is tracked per instance, not with completedDate", - severity: "warning", - field: "completedDate", - }); - } - return { - valid: !result.issues.some((issue) => issue.severity === "error"), - issues: result.issues, - }; -} - -function validateTimeEntryOrder( - entry: TimeEntry, - index: number, - issues: TaskValidationIssue[] -): void { - const start = new Date(entry.startTime); - if (Number.isNaN(start.getTime())) { - issues.push({ - code: "invalid_time_entry_start", - message: "Time entry startTime must be a valid datetime", - severity: "error", - path: ["timeEntries", String(index), "startTime"], - }); - return; - } - - if (!entry.endTime) { - return; - } - - const end = new Date(entry.endTime); - if (Number.isNaN(end.getTime())) { - issues.push({ - code: "invalid_time_entry_end", - message: "Time entry endTime must be a valid datetime", - severity: "error", - path: ["timeEntries", String(index), "endTime"], - }); - return; - } - - if (end < start) { - issues.push({ - code: "time_entry_negative_duration", - message: "Time entry endTime must not be before startTime", - severity: "error", - path: ["timeEntries", String(index), "endTime"], - }); - } -} diff --git a/packages/model/test/model.test.mjs b/packages/model/test/model.test.mjs deleted file mode 100644 index 0f8d53f1..00000000 --- a/packages/model/test/model.test.mjs +++ /dev/null @@ -1,193 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { - DEFAULT_FIELD_MAPPING, - buildRecurringTaskCompletePlan, - buildSpecCompleteTaskUpdate, - buildSpecRecurringSkipUpdate, - buildSpecStartTimeTrackingUpdate, - buildSpecStopTimeTrackingUpdate, - buildStartTimeTrackingPlan, - calculateTotalTrackedMinutes, - executeConformanceOperation, - formatDateForStorage, - getDatePart, - mapTaskFromFrontmatter, - mapTaskToFrontmatter, - parseDateToUTC, - parseTaskDocument, - recalculateRecurringSchedule, - serializeTaskDocument, -} from "../dist/esm/index.js"; - -test("maps TaskNotes frontmatter to normalized task data", () => { - const task = mapTaskFromFrontmatter( - DEFAULT_FIELD_MAPPING, - { - title: "Ship model", - status: "Done", - priority: "high", - due: "2026-06-01", - tags: ["task", "archived"], - complete_instances: ["2026-05-30", "not-a-date"], - }, - "Tasks/Ship model.md", - false, - [], - [ - { - id: "done", - value: "done", - label: "Done", - color: "#00aa00", - isCompleted: true, - order: 0, - autoArchive: false, - autoArchiveDelay: 5, - }, - ] - ); - - assert.equal(task.title, "Ship model"); - assert.equal(task.status, "done"); - assert.equal(task.archived, true); - assert.deepEqual(task.complete_instances, ["2026-05-30"]); -}); - -test("denormalizes task data to configured frontmatter", () => { - const frontmatter = mapTaskToFrontmatter(DEFAULT_FIELD_MAPPING, { - title: "Ship model", - status: "done", - priority: "high", - path: "Tasks/Ship model.md", - archived: false, - tags: ["task"], - }); - - assert.equal(frontmatter.title, "Ship model"); - assert.equal(frontmatter.status, "done"); - assert.deepEqual(frontmatter.tags, ["task"]); -}); - -test("parses dates with UTC storage semantics", () => { - assert.equal(formatDateForStorage(parseDateToUTC("2026-02-28")), "2026-02-28"); - assert.equal(getDatePart("2026-02-28T10:30:00"), "2026-02-28"); - assert.throws(() => parseDateToUTC("2026-02-30"), /Invalid date/); -}); - -test("recalculates recurring schedules with DTSTART", () => { - const result = recalculateRecurringSchedule({ - recurrence: "FREQ=DAILY;COUNT=3", - scheduled: "2026-06-01", - referenceDate: "2026-06-01", - completeInstances: ["2026-06-01"], - }); - - assert.equal(result.updatedRecurrence, "DTSTART:20260601;FREQ=DAILY;COUNT=3"); - assert.equal(result.nextScheduled, "2026-06-02"); -}); - -test("builds recurring complete plans without host IO", () => { - const plan = buildRecurringTaskCompletePlan({ - freshTask: { - title: "Daily task", - status: "open", - priority: "normal", - path: "Tasks/Daily task.md", - archived: false, - recurrence: "FREQ=DAILY", - scheduled: "2026-06-01", - }, - targetDate: parseDateToUTC("2026-06-01"), - currentTimestamp: "2026-06-01T12:00:00Z", - maintainDueDateOffsetInRecurring: true, - }); - - assert.equal(plan.newComplete, true); - assert.deepEqual(plan.updatedTask.complete_instances, ["2026-06-01"]); - assert.equal(plan.updatedTask.dateModified, "2026-06-01T12:00:00Z"); -}); - -test("plans time tracking and total reporting", () => { - const start = buildStartTimeTrackingPlan( - { - title: "Timed task", - status: "open", - priority: "normal", - path: "Tasks/Timed task.md", - archived: false, - }, - "2026-06-01T09:00:00Z" - ); - const entries = [ - { ...start.newEntry, endTime: "2026-06-01T09:30:00Z" }, - { startTime: "2026-06-01T10:00:00Z", endTime: "2026-06-01T10:45:00Z" }, - ]; - - assert.equal(calculateTotalTrackedMinutes(entries), 75); -}); - -test("builds spec-normalized updates for adapter surfaces", () => { - const complete = buildSpecCompleteTaskUpdate({ - frontmatter: { - title: "Daily task", - status: "open", - priority: "normal", - recurrence: "FREQ=DAILY", - scheduled: "2026-06-01", - completeInstances: [], - skippedInstances: ["2026-06-01"], - }, - targetDate: "2026-06-01", - completedStatus: "done", - currentTimestamp: "2026-06-01T12:00:00Z", - path: "Tasks/Daily task.md", - }); - - assert.equal(complete.fields.recurrence, "DTSTART:20260601;FREQ=DAILY"); - assert.equal(complete.fields.scheduled, "2026-06-02"); - assert.deepEqual(complete.fields.completeInstances, ["2026-06-01"]); - assert.deepEqual(complete.fields.skippedInstances, []); - - const skip = buildSpecRecurringSkipUpdate({ - frontmatter: complete.fields, - targetDate: "2026-06-02", - skip: true, - }); - - assert.deepEqual(skip.fields.skippedInstances, ["2026-06-02"]); - assert.equal(skip.fields.scheduled, "2026-06-03"); - - const start = buildSpecStartTimeTrackingUpdate({ - frontmatter: { title: "Timed task", status: "open", priority: "normal" }, - currentTimestamp: "2026-06-01T09:00:00Z", - }); - assert.deepEqual(start.fields.timeEntries, [{ startTime: "2026-06-01T09:00:00Z" }]); - - const stop = buildSpecStopTimeTrackingUpdate({ - frontmatter: start.fields, - currentTimestamp: "2026-06-01T09:30:00Z", - }); - assert.deepEqual(stop.fields.timeEntries, [ - { - startTime: "2026-06-01T09:00:00Z", - endTime: "2026-06-01T09:30:00Z", - }, - ]); -}); - -test("round-trips markdown task documents", () => { - const document = parseTaskDocument("---\ntitle: Test\nstatus: open\n---\nBody\n", { - path: "Tasks/Test.md", - }); - - assert.equal(document.task.title, "Test"); - assert.equal(document.body, "Body\n"); - assert.match(serializeTaskDocument(document.task, document.body), /title: Test/); -}); - -test("exposes conformance envelopes", () => { - const result = executeConformanceOperation("date.validate", { value: "2026-06-01" }); - assert.equal(result.ok, true); - assert.deepEqual(result.result, { value: "2026-06-01" }); -}); diff --git a/packages/model/tsconfig.types.json b/packages/model/tsconfig.types.json deleted file mode 100644 index 1b71008d..00000000 --- a/packages/model/tsconfig.types.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "moduleResolution": "node", - "declaration": true, - "emitDeclarationOnly": true, - "declarationMap": true, - "outDir": "dist/types", - "rootDir": "src", - "strict": true, - "skipLibCheck": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true - }, - "include": [ - "src/**/*.ts" - ] -} diff --git a/tsconfig.json b/tsconfig.json index 668bb6a8..c6d64e6a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,9 +19,7 @@ "noImplicitReturns": false, "noImplicitThis": false, "paths": { - "obsidian": ["./node_modules/obsidian"], - "@tasknotes/model": ["./packages/model/src/index.ts"], - "@tasknotes/model/*": ["./packages/model/src/*"] + "obsidian": ["./node_modules/obsidian"] }, "types": [ "obsidian-typings"