split model package into standalone repo

This commit is contained in:
callumalpass 2026-05-31 20:37:19 +10:00
parent 0b1f3d5f39
commit 0029c4417f
22 changed files with 21 additions and 4485 deletions

3
.gitignore vendored
View file

@ -85,8 +85,7 @@ tasknotes-e2e-vault/*.ics
/.ops
AGENTS.md
/docs-builder/dist
/packages/model/dist/
.ops/
.ops/
.serena/
/.codex

35
package-lock.json generated
View file

@ -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"
}
}
}
}

View file

@ -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",

View file

@ -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.

View file

@ -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"
}
}

View file

@ -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`,
}),
])
);

View file

@ -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> = {}): 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<FieldRole, string>;
const fieldToRole = {} as Record<string, FieldRole>;
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<string, unknown> = {},
displayNameKey?: string
): SpecFieldMapping {
const roleToField = {} as Record<FieldRole, string>;
const fieldToRole = {} as Record<string, FieldRole>;
const roles = new Set<string>(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<string, unknown>,
mapping: SpecFieldMapping
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(raw)) {
const role = mapping.fieldToRole[key];
result[role ?? key] = value;
}
return result;
}
export function denormalizeSpecFrontmatter(
roleData: Record<string, unknown>,
mapping: SpecFieldMapping
): Record<string, unknown> {
const result: Record<string, unknown> = {};
const roles = new Set<string>(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<string, unknown>,
mapping: SpecFieldMapping,
taskPath?: string
): string | undefined {
const candidates = [mapping.displayNameKey, "title"];
const seen = new Set<string>();
for (const key of candidates) {
if (seen.has(key)) continue;
seen.add(key);
const value = frontmatter[key];
if (typeof value === "string" && value.trim().length > 0) {
return value;
}
}
if (typeof taskPath === "string" && taskPath.trim().length > 0) {
const 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<TaskIdentificationConfig> & { methods?: string[]; combine?: "and" | "or" };
frontmatter?: Record<string, unknown>;
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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
if (isRecord(source.fieldMapping)) {
const mapping: Record<string, unknown> = {};
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<string, unknown>).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<string, unknown>, 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<string, unknown>, 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<string, string> = {
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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -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<string, unknown> = {}
): Promise<ConformanceEnvelope> {
return executeConformanceOperation(operation, input);
}
export function executeConformanceOperation(
operation: string,
input: Record<string, unknown> = {}
): 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<TaskInfo>, 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<typeof detectTaskFile>[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<TaskInfo>, 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<string, unknown>): 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<string, unknown> {
return isRecord(value) ? value : {};
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>,
field: "completeInstances" | "skippedInstances",
shouldAdd: boolean
): Record<string, unknown> {
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<string, unknown>, field: "completeInstances"): Record<string, unknown> {
return addRemoveInstance(input, field, false);
}
function completeNonRecurring(input: Record<string, unknown>): Record<string, unknown> {
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<string, unknown>): Record<string, unknown> {
const task = normalizeTaskForConformance(input.task);
return {
updatedTask: {
...task,
status: String(input.openStatus || "open"),
completedDate: undefined,
},
};
}
function removeDependency(input: Record<string, unknown>): 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 };

View file

@ -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<string>();
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());
}

View file

@ -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 },
};
}

View file

@ -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<string, unknown>;
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<string, unknown>)
: {};
return {
frontmatter,
body: markdown.slice(match[0].length),
};
}
export function stringifyFrontmatter(
frontmatter: Record<string, unknown>,
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<TaskInfo>,
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<string, unknown>,
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<string, unknown>,
patch: Record<string, unknown | null>
): Record<string, unknown> {
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<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)));
}

View file

@ -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";

View file

@ -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<string>();
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<string, unknown> | undefined | null,
filePath: string,
storeTitleInFilename = false,
userFields: readonly UserMappedField[] = [],
statuses: readonly StatusConfig[] = DEFAULT_STATUSES,
priorities: readonly PriorityConfig[] = DEFAULT_PRIORITIES
): Partial<TaskInfo> {
if (!frontmatter) return {};
const mapped: Partial<TaskInfo> = {
path: filePath,
};
if (frontmatter[mapping.title] !== undefined) {
const normalized = normalizeTitleValue(frontmatter[mapping.title]);
if (normalized !== undefined && 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<string, unknown> = {};
const mappedAny = mapped as Record<string, unknown>;
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<TaskInfo>,
taskTag?: string,
storeTitleInFilename = false,
userFields: readonly UserMappedField[] = []
): Record<string, unknown> {
const frontmatter: Record<string, unknown> = {};
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<string, unknown>;
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<string, unknown>).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<string, unknown>;
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<string, string> = { 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<T extends { value: string; label: string }>(
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;
}

View file

@ -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<string, unknown>;
updatedTask: TaskInfo;
dateModified?: string;
changed: boolean;
metadata?: Record<string, unknown>;
}
export interface BuildSpecCompleteTaskUpdateInput {
frontmatter: Record<string, unknown>;
targetDate: string;
completedStatus: string;
currentTimestamp?: string;
path?: string;
}
export interface BuildSpecRecurringSkipUpdateInput {
frontmatter: Record<string, unknown>;
targetDate: string;
skip: boolean;
currentTimestamp?: string;
path?: string;
}
export interface BuildSpecTimeTrackingUpdateInput {
frontmatter: Record<string, unknown>;
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<TaskInfo> {
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<TaskInfo> {
const recurrenceUpdates: Partial<TaskInfo> = {};
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<TaskInfo>;
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<TaskInfo> {
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<string, unknown>,
patch: readonly TaskPatchOperation[]
): Record<string, unknown> {
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<string, unknown>,
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<TaskInfo>): Record<string, unknown> {
const fields: Record<string, unknown> = {};
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown>): TaskPatchOperation[] {
return Object.entries(frontmatter)
.filter(([, value]) => value !== undefined)
.map(([field, value]) => ({ op: "set", field, value }) satisfies TaskPatchOperation);
}
function applySpecFieldsToTaskInfo(task: TaskInfo, fields: Record<string, unknown>): 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<Record<keyof TaskInfo, FieldMappingKey>> = {
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<string, unknown>, 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") : [];
}

View file

@ -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<RecurringTaskLike, "recurrence" | "complete_instances" | "status">,
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<RecurringTaskLike, "recurrence" | "complete_instances">,
targetDate: Date
): string {
if (!task.recurrence) return "";
const dateStr = formatDateForStorage(targetDate);
const isCompleted = task.complete_instances?.includes(dateStr) || false;
return isCompleted ? "Completed for this date" : "Not completed for this date";
}
export function shouldUseRecurringTaskUI(task: Pick<RecurringTaskLike, "recurrence">): boolean {
return !!task.recurrence;
}
export function generateRecurringInstances(
task: Pick<RecurringTaskLike, "title" | "recurrence" | "scheduled" | "dateCreated">,
startDate: Date,
endDate: Date
): Date[] {
if (!task.recurrence || 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<RecurringTaskLike, "title" | "recurrence" | "scheduled" | "dateCreated">
): 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<RecurringTaskLike, "recurrence" | "scheduled" | "dateCreated">
): string | null {
if (!task.recurrence || typeof task.recurrence !== "string") return null;
if (task.recurrence.includes("DTSTART:")) return task.recurrence;
const sourceDateString = task.scheduled || task.dateCreated;
if (!sourceDateString) return null;
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<RecurringTaskLike, "recurrence" | "scheduled" | "dateCreated">,
draggedStart: Date,
allDay: boolean
): string | null {
if (!task.recurrence || typeof task.recurrence !== "string") return null;
if (task.recurrence.includes("DTSTART:")) return task.recurrence;
const sourceDateString = task.scheduled || task.dateCreated;
if (!sourceDateString) return null;
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<TaskInfo, "recurrence_anchor" | "scheduled">, 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<RecurringTaskLike, "recurrence" | "scheduled" | "dateCreated">
): 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<RecurringTaskLike, "recurrence" | "scheduled" | "dateCreated">
): 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;
}

View file

@ -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(),
});

View file

@ -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<TaskInfo, "timeEntries">): 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),
};
}

View file

@ -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<string, unknown>;
basesData?: unknown;
blockedBy?: TaskDependency[];
blocking?: string[];
isBlocked?: boolean;
isBlocking?: boolean;
hasSubtasks?: boolean;
details?: string;
sortOrder?: string;
}
export interface TaskCreationData extends Partial<TaskInfo> {
details?: string;
parentNote?: string;
creationContext?:
| "inline-conversion"
| "manual-creation"
| "modal-inline-creation"
| "api"
| "import"
| "ics-event";
customFrontmatter?: Record<string, unknown>;
}
export type TaskUpdateInput = Partial<TaskInfo> & {
details?: string;
customFrontmatter?: Record<string, unknown>;
};
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<string, unknown>;
body: string;
task: Partial<TaskInfo>;
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<TTask extends Partial<TaskInfo> = TaskInfo> {
kind: string;
updatedTask: TTask;
frontmatterPatch: TaskPatchOperation[];
dateModified?: string;
metadata?: Record<string, unknown>;
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<FieldRole, string>;
fieldToRole: Record<string, FieldRole>;
displayNameKey: string;
completedStatuses: string[];
}
export type ConformanceEnvelope =
| { ok: true; result: unknown }
| { ok: false; error: string; error_details?: Record<string, unknown> };

View file

@ -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<TaskInfo>): 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<TaskInfo>,
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"],
});
}
}

View file

@ -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" });
});

View file

@ -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"
]
}

View file

@ -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"