mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
split model package into standalone repo
This commit is contained in:
parent
0b1f3d5f39
commit
0029c4417f
22 changed files with 21 additions and 4485 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
35
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -3,14 +3,9 @@
|
|||
"version": "4.9.2",
|
||||
"description": "Note-based task management with calendar, pomodoro and time-tracking integration.",
|
||||
"main": "main.js",
|
||||
"workspaces": [
|
||||
"packages/model"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build:model && npm run build-css && node generate-release-notes-import.mjs && tsc -noEmit --skipLibCheck && node esbuild.config.mjs production",
|
||||
"build:model": "npm --workspace @tasknotes/model run build",
|
||||
"test:model": "npm --workspace @tasknotes/model run test",
|
||||
"dev": "npm run build:model && npm run build-css && node generate-release-notes-import.mjs && node esbuild.config.mjs",
|
||||
"build": "npm run build-css && node generate-release-notes-import.mjs && tsc -noEmit --skipLibCheck && node esbuild.config.mjs production",
|
||||
"dev": "npm run build-css && node generate-release-notes-import.mjs && node esbuild.config.mjs",
|
||||
"build:test": "npm run build && node copy-files.mjs",
|
||||
"copy-files": "node copy-files.mjs",
|
||||
"build-css": "node build-css.mjs",
|
||||
|
|
@ -84,7 +79,7 @@
|
|||
"typescript": "^5.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tasknotes/model": "file:packages/model",
|
||||
"@tasknotes/model": "file:../tasknotes-model",
|
||||
"@codemirror/view": "^6.38.6",
|
||||
"@fullcalendar/core": "^6.1.17",
|
||||
"@fullcalendar/daygrid": "^6.1.17",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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`,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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") : [];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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> };
|
||||
|
|
@ -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"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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" });
|
||||
});
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue