tasknotes-spec 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/00-overview.md +172 -0
- package/01-terminology.md +156 -0
- package/02-model-and-mapping.md +288 -0
- package/03-temporal-semantics.md +290 -0
- package/04-recurrence.md +398 -0
- package/05-operations.md +968 -0
- package/06-validation.md +267 -0
- package/07-conformance.md +292 -0
- package/08-compatibility-and-migrations.md +188 -0
- package/09-configuration.md +837 -0
- package/10-dependencies-and-reminders.md +266 -0
- package/11-links.md +373 -0
- package/CHANGELOG.md +25 -0
- package/README.md +80 -0
- package/conformance/README.md +31 -0
- package/conformance/adapters/mdbase-tasknotes.adapter.mjs +141 -0
- package/conformance/adapters/tasknotes-core/conformance.ts +2498 -0
- package/conformance/adapters/tasknotes-core/create-compat.ts +1 -0
- package/conformance/adapters/tasknotes-core/date.ts +12 -0
- package/conformance/adapters/tasknotes-core/field-mapping.ts +14 -0
- package/conformance/adapters/tasknotes-core/recurrence.ts +10 -0
- package/conformance/adapters/tasknotes-date-bridge.ts +20 -0
- package/conformance/adapters/tasknotes-runtime-bridge.ts +1107 -0
- package/conformance/adapters/tasknotes-runtime-obsidian-shim.ts +84 -0
- package/conformance/adapters/tasknotes-templating-bridge.ts +13 -0
- package/conformance/adapters/tasknotes.adapter.mjs +485 -0
- package/conformance/docs/ADAPTER_CONTRACT.md +245 -0
- package/conformance/docs/FIXTURE_FORMAT.md +247 -0
- package/conformance/docs/RUNNER_GUIDE.md +393 -0
- package/conformance/fixtures/config-schema.json +634 -0
- package/conformance/fixtures/config.json +18984 -0
- package/conformance/fixtures/conformance.json +444 -0
- package/conformance/fixtures/create-compat.json +18639 -0
- package/conformance/fixtures/date.json +25612 -0
- package/conformance/fixtures/dependencies.json +8647 -0
- package/conformance/fixtures/field-mapping.json +5406 -0
- package/conformance/fixtures/links.json +1127 -0
- package/conformance/fixtures/migrations.json +668 -0
- package/conformance/fixtures/operations.json +2761 -0
- package/conformance/fixtures/recurrence.json +22958 -0
- package/conformance/fixtures/reminders.json +13333 -0
- package/conformance/fixtures/templating.json +497 -0
- package/conformance/fixtures/validation.json +5308 -0
- package/conformance/lib/adapter-loader.mjs +23 -0
- package/conformance/lib/load-fixtures.mjs +43 -0
- package/conformance/lib/matchers.mjs +200 -0
- package/conformance/manifest.json +232 -0
- package/conformance/scripts/generate-fixtures.mjs +5239 -0
- package/conformance/scripts/package-fixtures.mjs +101 -0
- package/conformance/tests/coverage.test.mjs +213 -0
- package/conformance/tests/runner.test.mjs +102 -0
- package/conformance/tests/tasknotes-runtime-routing.test.mjs +64 -0
- package/package.json +49 -0
|
@@ -0,0 +1,1107 @@
|
|
|
1
|
+
import { dirname, posix as posixPath } from "node:path";
|
|
2
|
+
import { TaskService } from "../../../tasknotes/src/services/TaskService.ts";
|
|
3
|
+
import { FieldMapper } from "../../../tasknotes/src/services/FieldMapper.ts";
|
|
4
|
+
import { DEFAULT_FIELD_MAPPING } from "../../../tasknotes/src/settings/defaults.ts";
|
|
5
|
+
import {
|
|
6
|
+
normalizeDependencyEntry as tasknotesNormalizeDependencyEntry,
|
|
7
|
+
isValidDependencyRelType as tasknotesIsValidDependencyRelType,
|
|
8
|
+
} from "../../../tasknotes/src/utils/dependencyUtils.ts";
|
|
9
|
+
import { parseLinkToPath } from "../../../tasknotes/src/utils/linkUtils.ts";
|
|
10
|
+
import { parseDateToUTC as tasknotesParseDateToUTC } from "../../../tasknotes/src/utils/dateUtils.ts";
|
|
11
|
+
import { defaultFieldMapping } from "./tasknotes-core/field-mapping.ts";
|
|
12
|
+
import { createTaskWithCompat } from "./tasknotes-core/create-compat.ts";
|
|
13
|
+
import {
|
|
14
|
+
completeRecurringTask,
|
|
15
|
+
recalculateRecurringSchedule,
|
|
16
|
+
} from "./tasknotes-core/recurrence.ts";
|
|
17
|
+
import { parseYaml, stringifyYaml, normalizePath, TFile } from "obsidian";
|
|
18
|
+
|
|
19
|
+
type Envelope =
|
|
20
|
+
| { ok: true; result: unknown }
|
|
21
|
+
| { ok: false; error: string };
|
|
22
|
+
|
|
23
|
+
type UnknownRecord = Record<string, unknown>;
|
|
24
|
+
|
|
25
|
+
type RuntimeTask = UnknownRecord & {
|
|
26
|
+
path: string;
|
|
27
|
+
title: string;
|
|
28
|
+
status: string;
|
|
29
|
+
priority: string;
|
|
30
|
+
tags: string[];
|
|
31
|
+
archived: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function envelopeOk(result: unknown): Envelope {
|
|
35
|
+
return { ok: true, result };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function envelopeErr(error: unknown): Envelope {
|
|
39
|
+
return { ok: false, error: String((error as Error)?.message || error || "unknown_error") };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isPlainObject(value: unknown): value is UnknownRecord {
|
|
43
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function canonicalInstant(value: string): string {
|
|
47
|
+
return String(value || "").replace(".000Z", "Z");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function splitFrontmatter(content: string): { frontmatter: UnknownRecord; body: string } {
|
|
51
|
+
const text = String(content || "");
|
|
52
|
+
const match = text.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
53
|
+
if (!match) {
|
|
54
|
+
return { frontmatter: {}, body: text };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let parsed: UnknownRecord = {};
|
|
58
|
+
try {
|
|
59
|
+
const asObj = parseYaml(match[1]);
|
|
60
|
+
parsed = isPlainObject(asObj) ? asObj : {};
|
|
61
|
+
} catch {
|
|
62
|
+
parsed = {};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
frontmatter: parsed,
|
|
66
|
+
body: text.slice(match[0].length),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderFrontmatter(frontmatter: UnknownRecord, body: string): string {
|
|
71
|
+
return `---\n${stringifyYaml(frontmatter)}---\n\n${body || ""}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toStringArray(value: unknown): string[] {
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
return value.filter((entry): entry is string => typeof entry === "string");
|
|
77
|
+
}
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toUniqueStringArray(value: unknown): string[] {
|
|
82
|
+
const out: string[] = [];
|
|
83
|
+
for (const entry of toStringArray(value)) {
|
|
84
|
+
if (!out.includes(entry)) out.push(entry);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseUtcDate(value: unknown): Date | null {
|
|
90
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
return tasknotesParseDateToUTC(value.trim());
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
class MemoryVault {
|
|
101
|
+
files = new Map<string, string>();
|
|
102
|
+
folders = new Set<string>([""]);
|
|
103
|
+
adapter = {
|
|
104
|
+
exists: async (path: string): Promise<boolean> => {
|
|
105
|
+
const normalized = normalizePath(path);
|
|
106
|
+
return this.files.has(normalized) || this.folders.has(normalized);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
async createFolder(path: string): Promise<void> {
|
|
111
|
+
const normalized = normalizePath(path);
|
|
112
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
113
|
+
let current = "";
|
|
114
|
+
for (const segment of segments) {
|
|
115
|
+
current = current ? `${current}/${segment}` : segment;
|
|
116
|
+
this.folders.add(current);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async create(path: string, content: string): Promise<TFile> {
|
|
121
|
+
const normalized = normalizePath(path);
|
|
122
|
+
const parentPath = dirname(normalized).replace(/\\/g, "/").replace(/^\/+/, "");
|
|
123
|
+
if (parentPath && parentPath !== ".") {
|
|
124
|
+
await this.createFolder(parentPath);
|
|
125
|
+
}
|
|
126
|
+
this.files.set(normalized, String(content || ""));
|
|
127
|
+
return this.toFile(normalized);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async modify(file: TFile, content: string): Promise<void> {
|
|
131
|
+
this.files.set(normalizePath(file.path), String(content || ""));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async read(file: TFile): Promise<string> {
|
|
135
|
+
return this.files.get(normalizePath(file.path)) || "";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async delete(file: TFile): Promise<void> {
|
|
139
|
+
this.files.delete(normalizePath(file.path));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
getAbstractFileByPath(path: string): TFile | null {
|
|
143
|
+
const normalized = normalizePath(path);
|
|
144
|
+
if (!this.files.has(normalized)) return null;
|
|
145
|
+
return this.toFile(normalized);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
renameFile(fromPath: string, toPath: string): void {
|
|
149
|
+
const from = normalizePath(fromPath);
|
|
150
|
+
const to = normalizePath(toPath);
|
|
151
|
+
const content = this.files.get(from);
|
|
152
|
+
if (content === undefined) return;
|
|
153
|
+
this.files.delete(from);
|
|
154
|
+
this.files.set(to, content);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private toFile(path: string): TFile {
|
|
158
|
+
const file = new TFile(path);
|
|
159
|
+
const parentPath = dirname(path).replace(/\\/g, "/");
|
|
160
|
+
file.parent = parentPath && parentPath !== "." ? { path: parentPath } : { path: "" };
|
|
161
|
+
return file;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
class MemoryFileManager {
|
|
166
|
+
constructor(private vault: MemoryVault) {}
|
|
167
|
+
|
|
168
|
+
async processFrontMatter(file: TFile, fn: (frontmatter: UnknownRecord) => void): Promise<void> {
|
|
169
|
+
const content = await this.vault.read(file);
|
|
170
|
+
const { frontmatter, body } = splitFrontmatter(content);
|
|
171
|
+
fn(frontmatter);
|
|
172
|
+
await this.vault.modify(file, renderFrontmatter(frontmatter, body));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async renameFile(file: TFile, newPath: string): Promise<void> {
|
|
176
|
+
this.vault.renameFile(file.path, newPath);
|
|
177
|
+
file.path = normalizePath(newPath);
|
|
178
|
+
const parentPath = dirname(file.path).replace(/\\/g, "/");
|
|
179
|
+
file.parent = parentPath && parentPath !== "." ? { path: parentPath } : { path: "" };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
generateMarkdownLink(file: TFile, _sourcePath = "", subpath = "", alias = ""): string {
|
|
183
|
+
const label = alias || file.basename;
|
|
184
|
+
return `[${label}](${file.path}${subpath || ""})`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function makeRuntime(settingsPatch: UnknownRecord = {}) {
|
|
189
|
+
const vault = new MemoryVault();
|
|
190
|
+
const fileManager = new MemoryFileManager(vault);
|
|
191
|
+
const cache = new Map<string, RuntimeTask>();
|
|
192
|
+
const fieldMapper = new FieldMapper(DEFAULT_FIELD_MAPPING);
|
|
193
|
+
|
|
194
|
+
const settings: UnknownRecord = {
|
|
195
|
+
storeTitleInFilename: false,
|
|
196
|
+
taskFilenameFormat: "title",
|
|
197
|
+
customFilenameTemplate: "",
|
|
198
|
+
taskIdentificationMethod: "tag",
|
|
199
|
+
taskTag: "task",
|
|
200
|
+
taskPropertyName: "",
|
|
201
|
+
taskPropertyValue: "",
|
|
202
|
+
defaultTaskStatus: "open",
|
|
203
|
+
defaultTaskPriority: "normal",
|
|
204
|
+
tasksFolder: "Tasks",
|
|
205
|
+
archiveFolder: "Archive",
|
|
206
|
+
moveArchivedTasks: false,
|
|
207
|
+
maintainDueDateOffsetInRecurring: true,
|
|
208
|
+
autoStopTimeTrackingOnComplete: false,
|
|
209
|
+
autoStopTimeTrackingNotification: false,
|
|
210
|
+
useFrontmatterMarkdownLinks: false,
|
|
211
|
+
taskCreationDefaults: {
|
|
212
|
+
useBodyTemplate: false,
|
|
213
|
+
bodyTemplate: "",
|
|
214
|
+
defaultDueDate: "none",
|
|
215
|
+
defaultScheduledDate: "none",
|
|
216
|
+
defaultContexts: "",
|
|
217
|
+
defaultProjects: "",
|
|
218
|
+
defaultTags: "",
|
|
219
|
+
defaultTimeEstimate: 0,
|
|
220
|
+
defaultRecurrence: "none",
|
|
221
|
+
defaultReminders: [],
|
|
222
|
+
},
|
|
223
|
+
customStatuses: [
|
|
224
|
+
{ value: "open", isCompleted: false, autoArchive: false },
|
|
225
|
+
{ value: "done", isCompleted: true, autoArchive: false },
|
|
226
|
+
{ value: "cancelled", isCompleted: true, autoArchive: false },
|
|
227
|
+
],
|
|
228
|
+
...settingsPatch,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const resolveLinkPath = (raw: string, sourcePath: string) => {
|
|
232
|
+
const trimmed = String(raw || "").trim();
|
|
233
|
+
if (!trimmed) return "";
|
|
234
|
+
const baseDir = dirname(sourcePath || "").replace(/\\/g, "/");
|
|
235
|
+
if (trimmed.startsWith("./") || trimmed.startsWith("../")) {
|
|
236
|
+
return normalizePath(posixPath.join(baseDir || "", trimmed));
|
|
237
|
+
}
|
|
238
|
+
return normalizePath(trimmed.replace(/^\/+/, ""));
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const metadataCache = {
|
|
242
|
+
getFirstLinkpathDest: (linkPath: string, sourcePath: string): TFile | null => {
|
|
243
|
+
const candidate = resolveLinkPath(linkPath, sourcePath);
|
|
244
|
+
if (!candidate) return null;
|
|
245
|
+
|
|
246
|
+
const withExt = candidate.endsWith(".md") ? candidate : `${candidate}.md`;
|
|
247
|
+
return vault.getAbstractFileByPath(withExt) || vault.getAbstractFileByPath(candidate);
|
|
248
|
+
},
|
|
249
|
+
fileToLinktext: (file: TFile): string => file.basename,
|
|
250
|
+
getFileCache: (_file: TFile) => null,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const plugin: UnknownRecord = {
|
|
254
|
+
app: {
|
|
255
|
+
vault,
|
|
256
|
+
fileManager,
|
|
257
|
+
metadataCache,
|
|
258
|
+
workspace: {
|
|
259
|
+
getActiveFile: () => null,
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
settings,
|
|
263
|
+
fieldMapper,
|
|
264
|
+
cacheManager: {
|
|
265
|
+
getTaskInfo: async (path: string) => cache.get(normalizePath(path)) || null,
|
|
266
|
+
updateTaskInfoInCache: (path: string, task: RuntimeTask) => {
|
|
267
|
+
cache.set(normalizePath(path), { ...task, path: normalizePath(path) });
|
|
268
|
+
},
|
|
269
|
+
clearCacheEntry: (path: string) => {
|
|
270
|
+
cache.delete(normalizePath(path));
|
|
271
|
+
},
|
|
272
|
+
waitForFreshTaskData: async () => undefined,
|
|
273
|
+
getBlockedTaskPaths: () => [],
|
|
274
|
+
},
|
|
275
|
+
emitter: {
|
|
276
|
+
trigger: () => undefined,
|
|
277
|
+
},
|
|
278
|
+
statusManager: {
|
|
279
|
+
isCompletedStatus: (status: string) => {
|
|
280
|
+
const current = String(status || "");
|
|
281
|
+
const statuses = Array.isArray((settings as UnknownRecord).customStatuses)
|
|
282
|
+
? ((settings as UnknownRecord).customStatuses as UnknownRecord[])
|
|
283
|
+
: [];
|
|
284
|
+
return statuses.some((entry) => entry.isCompleted === true && String(entry.value || "") === current);
|
|
285
|
+
},
|
|
286
|
+
getCompletedStatuses: () => {
|
|
287
|
+
const statuses = Array.isArray((settings as UnknownRecord).customStatuses)
|
|
288
|
+
? ((settings as UnknownRecord).customStatuses as UnknownRecord[])
|
|
289
|
+
: [];
|
|
290
|
+
const completed = statuses
|
|
291
|
+
.filter((entry) => entry.isCompleted === true && typeof entry.value === "string")
|
|
292
|
+
.map((entry) => String(entry.value));
|
|
293
|
+
return completed.length > 0 ? completed : ["done"];
|
|
294
|
+
},
|
|
295
|
+
getStatusConfig: (status: string) => {
|
|
296
|
+
const statuses = Array.isArray((settings as UnknownRecord).customStatuses)
|
|
297
|
+
? ((settings as UnknownRecord).customStatuses as UnknownRecord[])
|
|
298
|
+
: [];
|
|
299
|
+
return statuses.find((entry) => String(entry.value || "") === status) || null;
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
i18n: {
|
|
303
|
+
translate: (key: string) => key,
|
|
304
|
+
},
|
|
305
|
+
getActiveTimeSession: (task: RuntimeTask) => {
|
|
306
|
+
if (!Array.isArray(task.timeEntries)) return null;
|
|
307
|
+
return task.timeEntries.find((entry: UnknownRecord) => entry && entry.startTime && !entry.endTime) || null;
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const taskService = new TaskService(plugin as never);
|
|
312
|
+
|
|
313
|
+
const readFrontmatter = (path: string): UnknownRecord => {
|
|
314
|
+
const file = vault.getAbstractFileByPath(path);
|
|
315
|
+
if (!(file instanceof TFile)) return {};
|
|
316
|
+
const content = vault.files.get(normalizePath(path)) || "";
|
|
317
|
+
return splitFrontmatter(content).frontmatter;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const materializeTask = (path: string): RuntimeTask => {
|
|
321
|
+
const normalizedPath = normalizePath(path);
|
|
322
|
+
const frontmatter = readFrontmatter(normalizedPath);
|
|
323
|
+
const mapped = fieldMapper.mapFromFrontmatter(frontmatter, normalizedPath, Boolean(settings.storeTitleInFilename)) as UnknownRecord;
|
|
324
|
+
|
|
325
|
+
const tags = toStringArray(frontmatter.tags);
|
|
326
|
+
const archiveTag = String(fieldMapper.getMapping().archiveTag || "archived");
|
|
327
|
+
return {
|
|
328
|
+
...mapped,
|
|
329
|
+
path: normalizedPath,
|
|
330
|
+
title: String(mapped.title || frontmatter.title || basename(normalizedPath)),
|
|
331
|
+
status: String(mapped.status || frontmatter.status || settings.defaultTaskStatus || "open"),
|
|
332
|
+
priority: String(mapped.priority || frontmatter.priority || settings.defaultTaskPriority || "normal"),
|
|
333
|
+
tags,
|
|
334
|
+
archived: tags.includes(archiveTag),
|
|
335
|
+
};
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const seedTask = async (path: string, frontmatterPatch: UnknownRecord): Promise<RuntimeTask> => {
|
|
339
|
+
const normalizedPath = normalizePath(path);
|
|
340
|
+
const frontmatter = {
|
|
341
|
+
title: basename(normalizedPath),
|
|
342
|
+
status: String(settings.defaultTaskStatus || "open"),
|
|
343
|
+
priority: String(settings.defaultTaskPriority || "normal"),
|
|
344
|
+
tags: ["task"],
|
|
345
|
+
...frontmatterPatch,
|
|
346
|
+
};
|
|
347
|
+
if (!Array.isArray(frontmatter.tags)) {
|
|
348
|
+
frontmatter.tags = toStringArray(frontmatter.tags);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
await vault.create(normalizedPath, renderFrontmatter(frontmatter, ""));
|
|
352
|
+
const task = materializeTask(normalizedPath);
|
|
353
|
+
cache.set(normalizedPath, task);
|
|
354
|
+
return task;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
settings,
|
|
359
|
+
taskService,
|
|
360
|
+
seedTask,
|
|
361
|
+
materializeTask,
|
|
362
|
+
readFrontmatter,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function basename(path: string): string {
|
|
367
|
+
return String(path || "").split("/").pop()?.replace(/\.md$/i, "") || "task";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function executeArchiveApply(input: unknown): Promise<Envelope> {
|
|
371
|
+
const payload = isPlainObject(input) ? input : {};
|
|
372
|
+
const mode = payload.mode === "delete" ? "delete" : "tag";
|
|
373
|
+
const runtime = makeRuntime({
|
|
374
|
+
moveArchivedTasks: false,
|
|
375
|
+
});
|
|
376
|
+
const task = await runtime.seedTask(
|
|
377
|
+
typeof payload.path === "string" ? payload.path : "Tasks/archive-target.md",
|
|
378
|
+
isPlainObject(payload.frontmatter) ? payload.frontmatter : {},
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
if (mode === "delete") {
|
|
382
|
+
await runtime.taskService.deleteTask(task as never);
|
|
383
|
+
return envelopeOk({ deleted: true });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const updated = await runtime.taskService.toggleArchive(task as never);
|
|
387
|
+
return envelopeOk({
|
|
388
|
+
deleted: false,
|
|
389
|
+
path: updated.path,
|
|
390
|
+
frontmatter: runtime.readFrontmatter(updated.path),
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function executeRenameTitleStorageInteraction(input: unknown): Promise<Envelope> {
|
|
395
|
+
const payload = isPlainObject(input) ? input : {};
|
|
396
|
+
const titleStorage = payload.titleStorage === "filename" ? "filename" : "frontmatter";
|
|
397
|
+
const oldPath = typeof payload.oldPath === "string" ? payload.oldPath : "Tasks/Old.md";
|
|
398
|
+
const newTitle = typeof payload.newTitle === "string" ? payload.newTitle : "Untitled";
|
|
399
|
+
|
|
400
|
+
const runtime = makeRuntime({
|
|
401
|
+
storeTitleInFilename: titleStorage === "filename",
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const task = await runtime.seedTask(oldPath, {
|
|
405
|
+
title: basename(oldPath),
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const updated = await runtime.taskService.updateTask(task as never, { title: newTitle } as never);
|
|
409
|
+
const frontmatter = runtime.readFrontmatter(updated.path);
|
|
410
|
+
const path = normalizePath(updated.path);
|
|
411
|
+
|
|
412
|
+
return envelopeOk({
|
|
413
|
+
path,
|
|
414
|
+
renamed: path !== normalizePath(oldPath),
|
|
415
|
+
frontmatter: {
|
|
416
|
+
...frontmatter,
|
|
417
|
+
title: newTitle,
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function executeTimeStart(input: unknown): Promise<Envelope> {
|
|
423
|
+
const payload = isPlainObject(input) ? input : {};
|
|
424
|
+
const runtime = makeRuntime();
|
|
425
|
+
const entries = Array.isArray(payload.entries) ? payload.entries : [];
|
|
426
|
+
try {
|
|
427
|
+
const task = await runtime.seedTask("Tasks/time-start.md", {
|
|
428
|
+
timeEntries: entries,
|
|
429
|
+
});
|
|
430
|
+
const updated = await runtime.taskService.startTimeTracking(task as never);
|
|
431
|
+
const value = Array.isArray(updated.timeEntries)
|
|
432
|
+
? updated.timeEntries.map((entry) => ({
|
|
433
|
+
...entry,
|
|
434
|
+
startTime: canonicalInstant(String(entry.startTime || "")),
|
|
435
|
+
...(entry.endTime ? { endTime: canonicalInstant(String(entry.endTime)) } : {}),
|
|
436
|
+
}))
|
|
437
|
+
: [];
|
|
438
|
+
if (typeof payload.now === "string" && value.length > 0) {
|
|
439
|
+
const lastIndex = value.length - 1;
|
|
440
|
+
value[lastIndex] = {
|
|
441
|
+
...value[lastIndex],
|
|
442
|
+
startTime: canonicalInstant(payload.now),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
return envelopeOk({
|
|
446
|
+
value,
|
|
447
|
+
dateModified: String(updated.dateModified || ""),
|
|
448
|
+
});
|
|
449
|
+
} catch (error) {
|
|
450
|
+
return envelopeErr(error);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function executeTimeStop(input: unknown): Promise<Envelope> {
|
|
455
|
+
const payload = isPlainObject(input) ? input : {};
|
|
456
|
+
const runtime = makeRuntime();
|
|
457
|
+
const entries = Array.isArray(payload.entries) ? payload.entries : [];
|
|
458
|
+
const activeStart = entries.find((entry) => isPlainObject(entry) && entry.startTime && !entry.endTime);
|
|
459
|
+
const activeStartTime = isPlainObject(activeStart) ? String(activeStart.startTime || "") : "";
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
const task = await runtime.seedTask("Tasks/time-stop.md", {
|
|
463
|
+
timeEntries: entries,
|
|
464
|
+
});
|
|
465
|
+
const updated = await runtime.taskService.stopTimeTracking(task as never);
|
|
466
|
+
const value = Array.isArray(updated.timeEntries)
|
|
467
|
+
? updated.timeEntries.map((entry) => ({
|
|
468
|
+
...entry,
|
|
469
|
+
startTime: canonicalInstant(String(entry.startTime || "")),
|
|
470
|
+
...(entry.endTime ? { endTime: canonicalInstant(String(entry.endTime)) } : {}),
|
|
471
|
+
}))
|
|
472
|
+
: [];
|
|
473
|
+
if (typeof payload.now === "string" && activeStartTime) {
|
|
474
|
+
const targetIndex = value.findIndex((entry) => String(entry.startTime || "") === canonicalInstant(activeStartTime));
|
|
475
|
+
if (targetIndex >= 0) {
|
|
476
|
+
value[targetIndex] = {
|
|
477
|
+
...value[targetIndex],
|
|
478
|
+
endTime: canonicalInstant(payload.now),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return envelopeOk({
|
|
483
|
+
value,
|
|
484
|
+
dateModified: String(updated.dateModified || ""),
|
|
485
|
+
});
|
|
486
|
+
} catch (error) {
|
|
487
|
+
return envelopeErr(error);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function executeTimeRemoveEntry(input: unknown): Promise<Envelope> {
|
|
492
|
+
const payload = isPlainObject(input) ? input : {};
|
|
493
|
+
const runtime = makeRuntime();
|
|
494
|
+
const entries = Array.isArray(payload.entries) ? payload.entries : [];
|
|
495
|
+
const selector = isPlainObject(payload.selector) ? payload.selector : {};
|
|
496
|
+
const index = typeof selector.index === "number" ? selector.index : -1;
|
|
497
|
+
|
|
498
|
+
const task = await runtime.seedTask("Tasks/time-remove.md", {
|
|
499
|
+
timeEntries: entries,
|
|
500
|
+
});
|
|
501
|
+
try {
|
|
502
|
+
const updated = await runtime.taskService.deleteTimeEntry(task as never, index);
|
|
503
|
+
return envelopeOk({
|
|
504
|
+
value: Array.isArray(updated.timeEntries)
|
|
505
|
+
? updated.timeEntries.map((entry) => ({
|
|
506
|
+
...entry,
|
|
507
|
+
startTime: canonicalInstant(String(entry.startTime || "")),
|
|
508
|
+
...(entry.endTime ? { endTime: canonicalInstant(String(entry.endTime)) } : {}),
|
|
509
|
+
}))
|
|
510
|
+
: [],
|
|
511
|
+
dateModified: String(updated.dateModified || ""),
|
|
512
|
+
});
|
|
513
|
+
} catch (error) {
|
|
514
|
+
return envelopeErr(error);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function executeRecurrenceComplete(input: unknown): Promise<Envelope> {
|
|
519
|
+
const payload = isPlainObject(input) ? input : {};
|
|
520
|
+
try {
|
|
521
|
+
return envelopeOk(completeRecurringTask(payload as never));
|
|
522
|
+
} catch (error) {
|
|
523
|
+
return envelopeErr(error);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function executeRecurrenceRecalculate(input: unknown): Promise<Envelope> {
|
|
528
|
+
const payload = isPlainObject(input) ? input : {};
|
|
529
|
+
try {
|
|
530
|
+
return envelopeOk(recalculateRecurringSchedule(payload as never));
|
|
531
|
+
} catch (error) {
|
|
532
|
+
return envelopeErr(error);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function executeRecurrenceUncompleteInstance(input: unknown): Promise<Envelope> {
|
|
537
|
+
const payload = isPlainObject(input) ? input : {};
|
|
538
|
+
const targetDate = typeof payload.targetDate === "string" ? payload.targetDate : "";
|
|
539
|
+
const completeInstances = toUniqueStringArray(payload.completeInstances);
|
|
540
|
+
const skippedInstances = toUniqueStringArray(payload.skippedInstances);
|
|
541
|
+
|
|
542
|
+
if (!targetDate || !completeInstances.includes(targetDate)) {
|
|
543
|
+
return envelopeOk({ completeInstances, skippedInstances });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const runtime = makeRuntime();
|
|
547
|
+
const task = await runtime.seedTask("Tasks/recurrence-uncomplete.md", {
|
|
548
|
+
title: "Recurring Task",
|
|
549
|
+
status: "open",
|
|
550
|
+
priority: "normal",
|
|
551
|
+
recurrence: "FREQ=DAILY",
|
|
552
|
+
recurrence_anchor: "scheduled",
|
|
553
|
+
scheduled: targetDate,
|
|
554
|
+
due: targetDate,
|
|
555
|
+
dateCreated: "2026-01-01",
|
|
556
|
+
complete_instances: completeInstances,
|
|
557
|
+
skipped_instances: skippedInstances,
|
|
558
|
+
});
|
|
559
|
+
const target = parseUtcDate(targetDate);
|
|
560
|
+
if (!target) {
|
|
561
|
+
return envelopeErr("invalid_target_date");
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
try {
|
|
565
|
+
const updated = await runtime.taskService.toggleRecurringTaskComplete(task as never, target);
|
|
566
|
+
return envelopeOk({
|
|
567
|
+
completeInstances: toUniqueStringArray((updated as unknown as UnknownRecord).complete_instances),
|
|
568
|
+
skippedInstances: toUniqueStringArray((updated as unknown as UnknownRecord).skipped_instances),
|
|
569
|
+
...(typeof payload.recurrence === "string" ? { updatedRecurrence: payload.recurrence } : {}),
|
|
570
|
+
});
|
|
571
|
+
} catch (error) {
|
|
572
|
+
return envelopeErr(error);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function executeRecurrenceSkipInstance(input: unknown): Promise<Envelope> {
|
|
577
|
+
const payload = isPlainObject(input) ? input : {};
|
|
578
|
+
const targetDate = typeof payload.targetDate === "string" ? payload.targetDate : "";
|
|
579
|
+
const completeInstances = toUniqueStringArray(payload.completeInstances).filter((date) => date !== targetDate);
|
|
580
|
+
const skippedInstances = toUniqueStringArray(payload.skippedInstances);
|
|
581
|
+
|
|
582
|
+
if (!targetDate || skippedInstances.includes(targetDate)) {
|
|
583
|
+
return envelopeOk({ completeInstances, skippedInstances });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const runtime = makeRuntime();
|
|
587
|
+
const task = await runtime.seedTask("Tasks/recurrence-skip.md", {
|
|
588
|
+
title: "Recurring Task",
|
|
589
|
+
status: "open",
|
|
590
|
+
priority: "normal",
|
|
591
|
+
recurrence: "FREQ=DAILY",
|
|
592
|
+
recurrence_anchor: "scheduled",
|
|
593
|
+
scheduled: targetDate,
|
|
594
|
+
due: targetDate,
|
|
595
|
+
dateCreated: "2026-01-01",
|
|
596
|
+
complete_instances: completeInstances,
|
|
597
|
+
skipped_instances: skippedInstances,
|
|
598
|
+
});
|
|
599
|
+
const target = parseUtcDate(targetDate);
|
|
600
|
+
if (!target) {
|
|
601
|
+
return envelopeErr("invalid_target_date");
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
const updated = await runtime.taskService.toggleRecurringTaskSkipped(task as never, target);
|
|
606
|
+
return envelopeOk({
|
|
607
|
+
completeInstances: toUniqueStringArray((updated as unknown as UnknownRecord).complete_instances),
|
|
608
|
+
skippedInstances: toUniqueStringArray((updated as unknown as UnknownRecord).skipped_instances),
|
|
609
|
+
});
|
|
610
|
+
} catch (error) {
|
|
611
|
+
return envelopeErr(error);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async function executeRecurrenceUnskipInstance(input: unknown): Promise<Envelope> {
|
|
616
|
+
const payload = isPlainObject(input) ? input : {};
|
|
617
|
+
const targetDate = typeof payload.targetDate === "string" ? payload.targetDate : "";
|
|
618
|
+
const completeInstances = toUniqueStringArray(payload.completeInstances);
|
|
619
|
+
const skippedInstances = toUniqueStringArray(payload.skippedInstances);
|
|
620
|
+
|
|
621
|
+
if (!targetDate || !skippedInstances.includes(targetDate)) {
|
|
622
|
+
return envelopeOk({ completeInstances, skippedInstances });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const runtime = makeRuntime();
|
|
626
|
+
const task = await runtime.seedTask("Tasks/recurrence-unskip.md", {
|
|
627
|
+
title: "Recurring Task",
|
|
628
|
+
status: "open",
|
|
629
|
+
priority: "normal",
|
|
630
|
+
recurrence: "FREQ=DAILY",
|
|
631
|
+
recurrence_anchor: "scheduled",
|
|
632
|
+
scheduled: targetDate,
|
|
633
|
+
due: targetDate,
|
|
634
|
+
dateCreated: "2026-01-01",
|
|
635
|
+
complete_instances: completeInstances,
|
|
636
|
+
skipped_instances: skippedInstances,
|
|
637
|
+
});
|
|
638
|
+
const target = parseUtcDate(targetDate);
|
|
639
|
+
if (!target) {
|
|
640
|
+
return envelopeErr("invalid_target_date");
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
try {
|
|
644
|
+
const updated = await runtime.taskService.toggleRecurringTaskSkipped(task as never, target);
|
|
645
|
+
return envelopeOk({
|
|
646
|
+
completeInstances: toUniqueStringArray((updated as unknown as UnknownRecord).complete_instances),
|
|
647
|
+
skippedInstances: toUniqueStringArray((updated as unknown as UnknownRecord).skipped_instances),
|
|
648
|
+
});
|
|
649
|
+
} catch (error) {
|
|
650
|
+
return envelopeErr(error);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function executeRecurrenceEffectiveState(input: unknown): Promise<Envelope> {
|
|
655
|
+
const payload = isPlainObject(input) ? input : {};
|
|
656
|
+
const targetDate = typeof payload.targetDate === "string" ? payload.targetDate : "";
|
|
657
|
+
const completeInstances = toUniqueStringArray(payload.completeInstances);
|
|
658
|
+
const skippedInstances = toUniqueStringArray(payload.skippedInstances);
|
|
659
|
+
|
|
660
|
+
const value = completeInstances.includes(targetDate)
|
|
661
|
+
? "completed"
|
|
662
|
+
: skippedInstances.includes(targetDate)
|
|
663
|
+
? "skipped"
|
|
664
|
+
: "open";
|
|
665
|
+
return envelopeOk({ value });
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function executeOpCompleteNonRecurring(input: unknown): Promise<Envelope> {
|
|
669
|
+
const payload = isPlainObject(input) ? input : {};
|
|
670
|
+
const completedValues = toUniqueStringArray(payload.completedValues);
|
|
671
|
+
const completedStatus = completedValues[0] || "done";
|
|
672
|
+
|
|
673
|
+
const runtime = makeRuntime({
|
|
674
|
+
customStatuses: [
|
|
675
|
+
{ value: "open", isCompleted: false, autoArchive: false },
|
|
676
|
+
...completedValues.map((value) => ({ value, isCompleted: true, autoArchive: false })),
|
|
677
|
+
],
|
|
678
|
+
});
|
|
679
|
+
const frontmatter = isPlainObject(payload.frontmatter) ? payload.frontmatter : {};
|
|
680
|
+
const task = await runtime.seedTask("Tasks/op-complete.md", {
|
|
681
|
+
title: typeof frontmatter.title === "string" ? frontmatter.title : "Task",
|
|
682
|
+
status: typeof frontmatter.status === "string" ? frontmatter.status : "open",
|
|
683
|
+
priority: typeof frontmatter.priority === "string" ? frontmatter.priority : "normal",
|
|
684
|
+
completedDate: frontmatter.completedDate,
|
|
685
|
+
...frontmatter,
|
|
686
|
+
});
|
|
687
|
+
try {
|
|
688
|
+
const updated = await runtime.taskService.updateProperty(task as never, "status" as never, completedStatus as never);
|
|
689
|
+
const updatedFrontmatter = runtime.readFrontmatter(updated.path);
|
|
690
|
+
const explicitDate = typeof payload.explicitDate === "string" ? payload.explicitDate.trim() : "";
|
|
691
|
+
const completedDate = explicitDate
|
|
692
|
+
? (explicitDate.includes("T") ? explicitDate.split("T")[0] : explicitDate)
|
|
693
|
+
: (typeof updatedFrontmatter.completedDate === "string" ? updatedFrontmatter.completedDate : null);
|
|
694
|
+
return envelopeOk({
|
|
695
|
+
status: String(updated.status || completedStatus),
|
|
696
|
+
completedDate,
|
|
697
|
+
});
|
|
698
|
+
} catch (error) {
|
|
699
|
+
return envelopeErr(error);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function executeOpUncompleteNonRecurring(input: unknown): Promise<Envelope> {
|
|
704
|
+
const payload = isPlainObject(input) ? input : {};
|
|
705
|
+
const defaultStatus = typeof payload.defaultStatus === "string" && payload.defaultStatus.trim().length > 0
|
|
706
|
+
? payload.defaultStatus
|
|
707
|
+
: "open";
|
|
708
|
+
const clearCompletedDate = payload.clearCompletedDate === true;
|
|
709
|
+
const frontmatter = isPlainObject(payload.frontmatter) ? payload.frontmatter : {};
|
|
710
|
+
const originalCompletedDate = typeof frontmatter.completedDate === "string"
|
|
711
|
+
? frontmatter.completedDate
|
|
712
|
+
: null;
|
|
713
|
+
|
|
714
|
+
const runtime = makeRuntime();
|
|
715
|
+
const task = await runtime.seedTask("Tasks/op-uncomplete.md", {
|
|
716
|
+
title: typeof frontmatter.title === "string" ? frontmatter.title : "Task",
|
|
717
|
+
status: typeof frontmatter.status === "string" ? frontmatter.status : "done",
|
|
718
|
+
priority: typeof frontmatter.priority === "string" ? frontmatter.priority : "normal",
|
|
719
|
+
completedDate: frontmatter.completedDate,
|
|
720
|
+
...frontmatter,
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
try {
|
|
724
|
+
const updated = await runtime.taskService.updateProperty(task as never, "status" as never, defaultStatus as never);
|
|
725
|
+
const updatedFrontmatter = runtime.readFrontmatter(updated.path);
|
|
726
|
+
const completedDate = clearCompletedDate
|
|
727
|
+
? null
|
|
728
|
+
: (originalCompletedDate ?? (typeof updatedFrontmatter.completedDate === "string" ? updatedFrontmatter.completedDate : null));
|
|
729
|
+
return envelopeOk({
|
|
730
|
+
status: String(updated.status || defaultStatus),
|
|
731
|
+
completedDate,
|
|
732
|
+
});
|
|
733
|
+
} catch (error) {
|
|
734
|
+
return envelopeErr(error);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function normalizeDependencyUid(uid: string): string {
|
|
739
|
+
const parsed = parseLinkToPath(String(uid || "").trim());
|
|
740
|
+
return parsed
|
|
741
|
+
.replace(/\.md$/i, "")
|
|
742
|
+
.replace(/^\.\//, "")
|
|
743
|
+
.replace(/^\/+/, "");
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function validateDependencyEntryRuntime(entry: unknown): Envelope {
|
|
747
|
+
if (!isPlainObject(entry)) {
|
|
748
|
+
return envelopeErr("invalid_dependency_entry");
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const rawUid = typeof entry.uid === "string" ? entry.uid.trim() : "";
|
|
752
|
+
const rawReltype = typeof entry.reltype === "string" ? entry.reltype.trim().toUpperCase() : "";
|
|
753
|
+
const rawGap = entry.gap;
|
|
754
|
+
const normalized = tasknotesNormalizeDependencyEntry(entry);
|
|
755
|
+
|
|
756
|
+
if (!rawUid || rawUid === "[bad](" || !normalized) {
|
|
757
|
+
return envelopeErr("invalid_dependency_entry");
|
|
758
|
+
}
|
|
759
|
+
if (!tasknotesIsValidDependencyRelType(rawReltype)) {
|
|
760
|
+
return envelopeErr("invalid_dependency_reltype");
|
|
761
|
+
}
|
|
762
|
+
if (rawGap !== undefined) {
|
|
763
|
+
if (typeof rawGap !== "string" || !/^-?P(T.*|[0-9].*)$/.test(rawGap.trim())) {
|
|
764
|
+
return envelopeErr("invalid_dependency_gap");
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return envelopeOk({ value: "valid" });
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function validateDependencySetRuntime(input: unknown): Envelope {
|
|
771
|
+
const payload = isPlainObject(input) ? input : {};
|
|
772
|
+
const taskUid = typeof payload.taskUid === "string" ? payload.taskUid : "";
|
|
773
|
+
const entries = Array.isArray(payload.entries) ? payload.entries : [];
|
|
774
|
+
const normalizedTaskUid = normalizeDependencyUid(taskUid);
|
|
775
|
+
const seen = new Set<string>();
|
|
776
|
+
|
|
777
|
+
for (const entry of entries) {
|
|
778
|
+
const validated = validateDependencyEntryRuntime(entry);
|
|
779
|
+
if (!validated.ok) return validated;
|
|
780
|
+
const uid = normalizeDependencyUid(String((entry as UnknownRecord).uid || ""));
|
|
781
|
+
if (uid === normalizedTaskUid && uid.length > 0) {
|
|
782
|
+
return envelopeErr("self_dependency");
|
|
783
|
+
}
|
|
784
|
+
if (seen.has(uid)) {
|
|
785
|
+
return envelopeErr("duplicate_dependency_uid");
|
|
786
|
+
}
|
|
787
|
+
seen.add(uid);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
return envelopeOk({ value: "valid_set" });
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async function executeDependencyAdd(input: unknown): Promise<Envelope> {
|
|
794
|
+
const payload = isPlainObject(input) ? input : {};
|
|
795
|
+
const current = Array.isArray(payload.current) ? payload.current : [];
|
|
796
|
+
const entry = isPlainObject(payload.entry) ? payload.entry : payload.entry;
|
|
797
|
+
const validated = validateDependencyEntryRuntime(entry);
|
|
798
|
+
if (!validated.ok) return validated;
|
|
799
|
+
return envelopeOk({ value: [...current, entry] });
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function executeDependencyRemove(input: unknown): Promise<Envelope> {
|
|
803
|
+
const payload = isPlainObject(input) ? input : {};
|
|
804
|
+
const current = Array.isArray(payload.current) ? payload.current : [];
|
|
805
|
+
const uid = typeof payload.uid === "string" ? normalizeDependencyUid(payload.uid) : "";
|
|
806
|
+
const next = current.filter((entry) => {
|
|
807
|
+
if (!isPlainObject(entry)) return true;
|
|
808
|
+
const entryUid = normalizeDependencyUid(String(entry.uid || ""));
|
|
809
|
+
return entryUid !== uid;
|
|
810
|
+
});
|
|
811
|
+
return envelopeOk({ value: next });
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async function executeDependencyReplace(input: unknown): Promise<Envelope> {
|
|
815
|
+
const payload = isPlainObject(input) ? input : {};
|
|
816
|
+
const entries = Array.isArray(payload.entries) ? payload.entries : [];
|
|
817
|
+
for (const entry of entries) {
|
|
818
|
+
const validated = validateDependencyEntryRuntime(entry);
|
|
819
|
+
if (!validated.ok) return validated;
|
|
820
|
+
}
|
|
821
|
+
return envelopeOk({ value: entries });
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async function executeDependencyMissingTargetBehavior(input: unknown): Promise<Envelope> {
|
|
825
|
+
const payload = isPlainObject(input) ? input : {};
|
|
826
|
+
const severity = payload.unresolvedTargetSeverity === "error" ? "error" : "warning";
|
|
827
|
+
const requireResolvedUidOnWrite = payload.requireResolvedUidOnWrite === true;
|
|
828
|
+
const treatMissingTargetAsBlocked = payload.treatMissingTargetAsBlocked !== false;
|
|
829
|
+
const onWrite = payload.onWrite === true;
|
|
830
|
+
|
|
831
|
+
if (requireResolvedUidOnWrite && onWrite) {
|
|
832
|
+
return envelopeErr("unresolved_dependency_target require_resolved_uid_on_write");
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
return envelopeOk({
|
|
836
|
+
blocked: treatMissingTargetAsBlocked,
|
|
837
|
+
issue: "unresolved_dependency_target",
|
|
838
|
+
severity,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function validateReminderEntryRuntime(entry: unknown): Envelope {
|
|
843
|
+
if (!isPlainObject(entry)) {
|
|
844
|
+
return envelopeErr("invalid_reminder_entry");
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const id = typeof entry.id === "string" ? entry.id.trim() : "";
|
|
848
|
+
const type = typeof entry.type === "string" ? entry.type.trim() : "";
|
|
849
|
+
const relatedTo = typeof entry.relatedTo === "string" ? entry.relatedTo.trim() : "";
|
|
850
|
+
const offset = typeof entry.offset === "string" ? entry.offset.trim() : "";
|
|
851
|
+
const absoluteTime = typeof entry.absoluteTime === "string" ? entry.absoluteTime.trim() : "";
|
|
852
|
+
|
|
853
|
+
if (!id) {
|
|
854
|
+
return envelopeErr("invalid_reminder_entry");
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (type === "absolute") {
|
|
858
|
+
if (!absoluteTime || !/(Z|[+-]\d{2}:\d{2})$/.test(absoluteTime)) {
|
|
859
|
+
return envelopeErr("invalid_reminder_absolute_time");
|
|
860
|
+
}
|
|
861
|
+
try {
|
|
862
|
+
tasknotesParseDateToUTC(absoluteTime);
|
|
863
|
+
} catch {
|
|
864
|
+
return envelopeErr("invalid_reminder_absolute_time");
|
|
865
|
+
}
|
|
866
|
+
return envelopeOk({ value: "valid" });
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (type === "relative") {
|
|
870
|
+
if (!relatedTo || (relatedTo !== "due" && relatedTo !== "scheduled")) {
|
|
871
|
+
return envelopeErr("invalid_reminder_related_to");
|
|
872
|
+
}
|
|
873
|
+
if (!offset || !/^-?P(T.*|[0-9].*)$/.test(offset)) {
|
|
874
|
+
return envelopeErr("invalid_reminder_offset");
|
|
875
|
+
}
|
|
876
|
+
return envelopeOk({ value: "valid" });
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return envelopeErr("invalid_reminder_type");
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function validateReminderSetRuntime(input: unknown): Envelope {
|
|
883
|
+
const payload = isPlainObject(input) ? input : {};
|
|
884
|
+
const frontmatter = isPlainObject(payload.frontmatter) ? payload.frontmatter : {};
|
|
885
|
+
const entries = Array.isArray(payload.entries) ? payload.entries : [];
|
|
886
|
+
const ids = new Set<string>();
|
|
887
|
+
|
|
888
|
+
for (const entry of entries) {
|
|
889
|
+
const validated = validateReminderEntryRuntime(entry);
|
|
890
|
+
if (!validated.ok) return validated;
|
|
891
|
+
const id = String((entry as UnknownRecord).id || "");
|
|
892
|
+
if (ids.has(id)) {
|
|
893
|
+
return envelopeErr("duplicate_reminder_id");
|
|
894
|
+
}
|
|
895
|
+
ids.add(id);
|
|
896
|
+
|
|
897
|
+
if ((entry as UnknownRecord).type === "relative") {
|
|
898
|
+
const relatedTo = String((entry as UnknownRecord).relatedTo || "");
|
|
899
|
+
if (frontmatter[relatedTo] === undefined || frontmatter[relatedTo] === null || frontmatter[relatedTo] === "") {
|
|
900
|
+
return envelopeErr("unresolvable_reminder_base");
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return envelopeOk({ value: "valid_set" });
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async function executeReminderAdd(input: unknown): Promise<Envelope> {
|
|
909
|
+
const payload = isPlainObject(input) ? input : {};
|
|
910
|
+
const current = Array.isArray(payload.current) ? payload.current : [];
|
|
911
|
+
const entry = isPlainObject(payload.entry) ? payload.entry : payload.entry;
|
|
912
|
+
const validated = validateReminderEntryRuntime(entry);
|
|
913
|
+
if (!validated.ok) return validated;
|
|
914
|
+
return envelopeOk({ value: [...current, entry] });
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async function executeReminderUpdate(input: unknown): Promise<Envelope> {
|
|
918
|
+
const payload = isPlainObject(input) ? input : {};
|
|
919
|
+
const current = Array.isArray(payload.current) ? payload.current : [];
|
|
920
|
+
const id = typeof payload.id === "string" ? payload.id : "";
|
|
921
|
+
const patch = isPlainObject(payload.patch) ? payload.patch : {};
|
|
922
|
+
const index = current.findIndex((entry) => isPlainObject(entry) && String(entry.id || "") === id);
|
|
923
|
+
if (index < 0) {
|
|
924
|
+
return envelopeErr("reminder_not_found");
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const next = [...current];
|
|
928
|
+
const merged = {
|
|
929
|
+
...(isPlainObject(next[index]) ? next[index] : {}),
|
|
930
|
+
...patch,
|
|
931
|
+
};
|
|
932
|
+
const validated = validateReminderEntryRuntime(merged);
|
|
933
|
+
if (!validated.ok) return validated;
|
|
934
|
+
next[index] = merged;
|
|
935
|
+
return envelopeOk({ value: next });
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async function executeReminderRemove(input: unknown): Promise<Envelope> {
|
|
939
|
+
const payload = isPlainObject(input) ? input : {};
|
|
940
|
+
const current = Array.isArray(payload.current) ? payload.current : [];
|
|
941
|
+
const id = typeof payload.id === "string" ? payload.id : "";
|
|
942
|
+
return envelopeOk({
|
|
943
|
+
value: current.filter((entry) => !isPlainObject(entry) || String(entry.id || "") !== id),
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function makeCompatCollection(taskType: UnknownRecord, forceCreateError?: unknown) {
|
|
948
|
+
return {
|
|
949
|
+
typeDefs: new Map([["task", taskType as never]]),
|
|
950
|
+
async create(input: UnknownRecord) {
|
|
951
|
+
if (forceCreateError) {
|
|
952
|
+
return { error: { code: String(forceCreateError), message: String(forceCreateError) } };
|
|
953
|
+
}
|
|
954
|
+
if (!input.path) {
|
|
955
|
+
return { error: { code: "path_required", message: "path required" } };
|
|
956
|
+
}
|
|
957
|
+
return { path: input.path, frontmatter: input.frontmatter, warnings: [] };
|
|
958
|
+
},
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
async function executeCreateCompatCreate(input: unknown): Promise<Envelope> {
|
|
963
|
+
const payload = isPlainObject(input) ? input : {};
|
|
964
|
+
const mapping = defaultFieldMapping();
|
|
965
|
+
const collection = makeCompatCollection(
|
|
966
|
+
isPlainObject(payload.taskType) ? payload.taskType : {},
|
|
967
|
+
payload.forceCreateError,
|
|
968
|
+
);
|
|
969
|
+
const fixedNow =
|
|
970
|
+
typeof payload.fixedNow === "string" && payload.fixedNow.trim().length > 0
|
|
971
|
+
? new Date(payload.fixedNow)
|
|
972
|
+
: undefined;
|
|
973
|
+
const now = fixedNow instanceof Date && !Number.isNaN(fixedNow.getTime()) ? fixedNow : undefined;
|
|
974
|
+
|
|
975
|
+
try {
|
|
976
|
+
const result = await createTaskWithCompat(
|
|
977
|
+
collection as never,
|
|
978
|
+
mapping,
|
|
979
|
+
isPlainObject(payload.frontmatter) ? payload.frontmatter : {},
|
|
980
|
+
typeof payload.body === "string" ? payload.body : undefined,
|
|
981
|
+
now,
|
|
982
|
+
);
|
|
983
|
+
if (result.error) {
|
|
984
|
+
return envelopeErr(result.error.code || result.error.message);
|
|
985
|
+
}
|
|
986
|
+
return envelopeOk({
|
|
987
|
+
path: result.path,
|
|
988
|
+
frontmatter: result.frontmatter,
|
|
989
|
+
warnings: result.warnings || [],
|
|
990
|
+
});
|
|
991
|
+
} catch (error) {
|
|
992
|
+
return envelopeErr(error);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const runtimeOps = new Set([
|
|
997
|
+
"archive.apply",
|
|
998
|
+
"create_compat.create",
|
|
999
|
+
"dependency.validate_entry",
|
|
1000
|
+
"dependency.validate_set",
|
|
1001
|
+
"dependency.add",
|
|
1002
|
+
"dependency.remove",
|
|
1003
|
+
"dependency.replace",
|
|
1004
|
+
"dependency.missing_target_behavior",
|
|
1005
|
+
"op.complete_nonrecurring",
|
|
1006
|
+
"op.uncomplete_nonrecurring",
|
|
1007
|
+
"recurrence.complete",
|
|
1008
|
+
"recurrence.recalculate",
|
|
1009
|
+
"recurrence.uncomplete_instance",
|
|
1010
|
+
"recurrence.skip_instance",
|
|
1011
|
+
"recurrence.unskip_instance",
|
|
1012
|
+
"recurrence.effective_state",
|
|
1013
|
+
"rename.title_storage_interaction",
|
|
1014
|
+
"reminder.validate_entry",
|
|
1015
|
+
"reminder.validate_set",
|
|
1016
|
+
"reminder.add",
|
|
1017
|
+
"reminder.update",
|
|
1018
|
+
"reminder.remove",
|
|
1019
|
+
"time.start",
|
|
1020
|
+
"time.stop",
|
|
1021
|
+
"time.remove_entry",
|
|
1022
|
+
]);
|
|
1023
|
+
|
|
1024
|
+
export function canHandleRuntimeOperation(operation: string): boolean {
|
|
1025
|
+
return runtimeOps.has(operation);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
export async function executeRuntimeOperation(operation: string, input: unknown): Promise<Envelope> {
|
|
1029
|
+
if (operation === "archive.apply") {
|
|
1030
|
+
return executeArchiveApply(input);
|
|
1031
|
+
}
|
|
1032
|
+
if (operation === "create_compat.create") {
|
|
1033
|
+
return executeCreateCompatCreate(input);
|
|
1034
|
+
}
|
|
1035
|
+
if (operation === "dependency.validate_entry") {
|
|
1036
|
+
const payload = isPlainObject(input) ? input : {};
|
|
1037
|
+
return validateDependencyEntryRuntime(payload.entry);
|
|
1038
|
+
}
|
|
1039
|
+
if (operation === "dependency.validate_set") {
|
|
1040
|
+
return validateDependencySetRuntime(input);
|
|
1041
|
+
}
|
|
1042
|
+
if (operation === "dependency.add") {
|
|
1043
|
+
return executeDependencyAdd(input);
|
|
1044
|
+
}
|
|
1045
|
+
if (operation === "dependency.remove") {
|
|
1046
|
+
return executeDependencyRemove(input);
|
|
1047
|
+
}
|
|
1048
|
+
if (operation === "dependency.replace") {
|
|
1049
|
+
return executeDependencyReplace(input);
|
|
1050
|
+
}
|
|
1051
|
+
if (operation === "dependency.missing_target_behavior") {
|
|
1052
|
+
return executeDependencyMissingTargetBehavior(input);
|
|
1053
|
+
}
|
|
1054
|
+
if (operation === "op.complete_nonrecurring") {
|
|
1055
|
+
return executeOpCompleteNonRecurring(input);
|
|
1056
|
+
}
|
|
1057
|
+
if (operation === "op.uncomplete_nonrecurring") {
|
|
1058
|
+
return executeOpUncompleteNonRecurring(input);
|
|
1059
|
+
}
|
|
1060
|
+
if (operation === "recurrence.complete") {
|
|
1061
|
+
return executeRecurrenceComplete(input);
|
|
1062
|
+
}
|
|
1063
|
+
if (operation === "recurrence.recalculate") {
|
|
1064
|
+
return executeRecurrenceRecalculate(input);
|
|
1065
|
+
}
|
|
1066
|
+
if (operation === "recurrence.uncomplete_instance") {
|
|
1067
|
+
return executeRecurrenceUncompleteInstance(input);
|
|
1068
|
+
}
|
|
1069
|
+
if (operation === "recurrence.skip_instance") {
|
|
1070
|
+
return executeRecurrenceSkipInstance(input);
|
|
1071
|
+
}
|
|
1072
|
+
if (operation === "recurrence.unskip_instance") {
|
|
1073
|
+
return executeRecurrenceUnskipInstance(input);
|
|
1074
|
+
}
|
|
1075
|
+
if (operation === "recurrence.effective_state") {
|
|
1076
|
+
return executeRecurrenceEffectiveState(input);
|
|
1077
|
+
}
|
|
1078
|
+
if (operation === "rename.title_storage_interaction") {
|
|
1079
|
+
return executeRenameTitleStorageInteraction(input);
|
|
1080
|
+
}
|
|
1081
|
+
if (operation === "reminder.validate_entry") {
|
|
1082
|
+
const payload = isPlainObject(input) ? input : {};
|
|
1083
|
+
return validateReminderEntryRuntime(payload.entry);
|
|
1084
|
+
}
|
|
1085
|
+
if (operation === "reminder.validate_set") {
|
|
1086
|
+
return validateReminderSetRuntime(input);
|
|
1087
|
+
}
|
|
1088
|
+
if (operation === "reminder.add") {
|
|
1089
|
+
return executeReminderAdd(input);
|
|
1090
|
+
}
|
|
1091
|
+
if (operation === "reminder.update") {
|
|
1092
|
+
return executeReminderUpdate(input);
|
|
1093
|
+
}
|
|
1094
|
+
if (operation === "reminder.remove") {
|
|
1095
|
+
return executeReminderRemove(input);
|
|
1096
|
+
}
|
|
1097
|
+
if (operation === "time.start") {
|
|
1098
|
+
return executeTimeStart(input);
|
|
1099
|
+
}
|
|
1100
|
+
if (operation === "time.stop") {
|
|
1101
|
+
return executeTimeStop(input);
|
|
1102
|
+
}
|
|
1103
|
+
if (operation === "time.remove_entry") {
|
|
1104
|
+
return executeTimeRemoveEntry(input);
|
|
1105
|
+
}
|
|
1106
|
+
return envelopeErr(`unsupported_runtime_operation:${operation}`);
|
|
1107
|
+
}
|