things-api 0.5.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/LICENSE +21 -0
- package/README.md +77 -0
- package/bin/things.js +9 -0
- package/dist/audit/log.d.ts +10 -0
- package/dist/audit/log.js +31 -0
- package/dist/audit/log.js.map +1 -0
- package/dist/audit/schema.d.ts +37 -0
- package/dist/audit/schema.js +9 -0
- package/dist/audit/schema.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +9 -0
- package/dist/cli/commands/doctor.js +60 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/mcp.d.ts +7 -0
- package/dist/cli/commands/mcp.js +26 -0
- package/dist/cli/commands/mcp.js.map +1 -0
- package/dist/cli/commands/project.d.ts +5 -0
- package/dist/cli/commands/project.js +50 -0
- package/dist/cli/commands/project.js.map +1 -0
- package/dist/cli/commands/reads.d.ts +21 -0
- package/dist/cli/commands/reads.js +295 -0
- package/dist/cli/commands/reads.js.map +1 -0
- package/dist/cli/commands/snapshot.d.ts +7 -0
- package/dist/cli/commands/snapshot.js +22 -0
- package/dist/cli/commands/snapshot.js.map +1 -0
- package/dist/cli/commands/todo.d.ts +6 -0
- package/dist/cli/commands/todo.js +43 -0
- package/dist/cli/commands/todo.js.map +1 -0
- package/dist/cli/commands/writes.d.ts +9 -0
- package/dist/cli/commands/writes.js +994 -0
- package/dist/cli/commands/writes.js.map +1 -0
- package/dist/cli/exit-codes.d.ts +26 -0
- package/dist/cli/exit-codes.js +26 -0
- package/dist/cli/exit-codes.js.map +1 -0
- package/dist/cli/main.d.ts +4 -0
- package/dist/cli/main.js +73 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/output.d.ts +42 -0
- package/dist/cli/output.js +16 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/cli/style.d.ts +6 -0
- package/dist/cli/style.js +25 -0
- package/dist/cli/style.js.map +1 -0
- package/dist/client.d.ts +184 -0
- package/dist/client.js +187 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +76 -0
- package/dist/config.js.map +1 -0
- package/dist/contracts.d.ts +79 -0
- package/dist/contracts.js +46 -0
- package/dist/contracts.js.map +1 -0
- package/dist/db/baselines/db-v26.d.ts +8 -0
- package/dist/db/baselines/db-v26.js +16 -0
- package/dist/db/baselines/db-v26.js.map +1 -0
- package/dist/db/baselines/index.d.ts +3 -0
- package/dist/db/baselines/index.js +4 -0
- package/dist/db/baselines/index.js.map +1 -0
- package/dist/db/connection.d.ts +22 -0
- package/dist/db/connection.js +40 -0
- package/dist/db/connection.js.map +1 -0
- package/dist/db/fingerprint.d.ts +42 -0
- package/dist/db/fingerprint.js +82 -0
- package/dist/db/fingerprint.js.map +1 -0
- package/dist/db/locate.d.ts +13 -0
- package/dist/db/locate.js +44 -0
- package/dist/db/locate.js.map +1 -0
- package/dist/db/schema.d.ts +37 -0
- package/dist/db/schema.js +81 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/diagnose.d.ts +67 -0
- package/dist/diagnose.js +143 -0
- package/dist/diagnose.js.map +1 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/server.d.ts +8 -0
- package/dist/mcp/server.js +1002 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/model/dates.d.ts +38 -0
- package/dist/model/dates.js +104 -0
- package/dist/model/dates.js.map +1 -0
- package/dist/model/entities.d.ts +117 -0
- package/dist/model/entities.js +21 -0
- package/dist/model/entities.js.map +1 -0
- package/dist/model/mappers.d.ts +55 -0
- package/dist/model/mappers.js +105 -0
- package/dist/model/mappers.js.map +1 -0
- package/dist/model/occurrences.d.ts +46 -0
- package/dist/model/occurrences.js +164 -0
- package/dist/model/occurrences.js.map +1 -0
- package/dist/model/recurrence.d.ts +52 -0
- package/dist/model/recurrence.js +164 -0
- package/dist/model/recurrence.js.map +1 -0
- package/dist/paths.d.ts +6 -0
- package/dist/paths.js +30 -0
- package/dist/paths.js.map +1 -0
- package/dist/read/detail.d.ts +8 -0
- package/dist/read/detail.js +34 -0
- package/dist/read/detail.js.map +1 -0
- package/dist/read/project-view.d.ts +34 -0
- package/dist/read/project-view.js +87 -0
- package/dist/read/project-view.js.map +1 -0
- package/dist/read/queries.d.ts +39 -0
- package/dist/read/queries.js +134 -0
- package/dist/read/queries.js.map +1 -0
- package/dist/read/snapshot.d.ts +25 -0
- package/dist/read/snapshot.js +39 -0
- package/dist/read/snapshot.js.map +1 -0
- package/dist/read/tags.d.ts +17 -0
- package/dist/read/tags.js +64 -0
- package/dist/read/tags.js.map +1 -0
- package/dist/read/views.d.ts +152 -0
- package/dist/read/views.js +322 -0
- package/dist/read/views.js.map +1 -0
- package/dist/surface-copy.d.ts +15 -0
- package/dist/surface-copy.js +16 -0
- package/dist/surface-copy.js.map +1 -0
- package/dist/write/automation-probe.d.ts +12 -0
- package/dist/write/automation-probe.js +59 -0
- package/dist/write/automation-probe.js.map +1 -0
- package/dist/write/batch.d.ts +51 -0
- package/dist/write/batch.js +97 -0
- package/dist/write/batch.js.map +1 -0
- package/dist/write/capabilities.d.ts +16 -0
- package/dist/write/capabilities.js +16 -0
- package/dist/write/capabilities.js.map +1 -0
- package/dist/write/commands.d.ts +32 -0
- package/dist/write/commands.js +1274 -0
- package/dist/write/commands.js.map +1 -0
- package/dist/write/environment.d.ts +29 -0
- package/dist/write/environment.js +105 -0
- package/dist/write/environment.js.map +1 -0
- package/dist/write/experimental.d.ts +7 -0
- package/dist/write/experimental.js +39 -0
- package/dist/write/experimental.js.map +1 -0
- package/dist/write/failure-hints.d.ts +32 -0
- package/dist/write/failure-hints.js +71 -0
- package/dist/write/failure-hints.js.map +1 -0
- package/dist/write/guards.d.ts +21 -0
- package/dist/write/guards.js +312 -0
- package/dist/write/guards.js.map +1 -0
- package/dist/write/lock.d.ts +10 -0
- package/dist/write/lock.js +70 -0
- package/dist/write/lock.js.map +1 -0
- package/dist/write/operations.d.ts +262 -0
- package/dist/write/operations.js +34 -0
- package/dist/write/operations.js.map +1 -0
- package/dist/write/pipeline.d.ts +90 -0
- package/dist/write/pipeline.js +375 -0
- package/dist/write/pipeline.js.map +1 -0
- package/dist/write/planner.d.ts +40 -0
- package/dist/write/planner.js +57 -0
- package/dist/write/planner.js.map +1 -0
- package/dist/write/pre-state.d.ts +125 -0
- package/dist/write/pre-state.js +299 -0
- package/dist/write/pre-state.js.map +1 -0
- package/dist/write/reopen.d.ts +29 -0
- package/dist/write/reopen.js +63 -0
- package/dist/write/reopen.js.map +1 -0
- package/dist/write/reorder.d.ts +19 -0
- package/dist/write/reorder.js +285 -0
- package/dist/write/reorder.js.map +1 -0
- package/dist/write/undo.d.ts +53 -0
- package/dist/write/undo.js +703 -0
- package/dist/write/undo.js.map +1 -0
- package/dist/write/vectors/applescript.d.ts +5 -0
- package/dist/write/vectors/applescript.js +216 -0
- package/dist/write/vectors/applescript.js.map +1 -0
- package/dist/write/vectors/registry.d.ts +2 -0
- package/dist/write/vectors/registry.js +11 -0
- package/dist/write/vectors/registry.js.map +1 -0
- package/dist/write/vectors/types.d.ts +48 -0
- package/dist/write/vectors/types.js +2 -0
- package/dist/write/vectors/types.js.map +1 -0
- package/dist/write/vectors/url-scheme.d.ts +3 -0
- package/dist/write/vectors/url-scheme.js +176 -0
- package/dist/write/vectors/url-scheme.js.map +1 -0
- package/dist/write/verify/delta.d.ts +126 -0
- package/dist/write/verify/delta.js +281 -0
- package/dist/write/verify/delta.js.map +1 -0
- package/dist/write/verify/poller.d.ts +24 -0
- package/dist/write/verify/poller.js +28 -0
- package/dist/write/verify/poller.js.map +1 -0
- package/package.json +52 -0
|
@@ -0,0 +1,1274 @@
|
|
|
1
|
+
import { decodeReminderTime, encodeReminderTime, reminderUrlToken, } from "../model/dates.js";
|
|
2
|
+
import { childTagTitles, computeReorderPre, emptyPreState, loadTarget, missingTagTitles, projectChildren, projectStatus, resolveArea, resolveHeading, resolveProject, resolveTag, trashedCount, } from "./pre-state.js";
|
|
3
|
+
import { PRIVATE_REORDER_COMMAND } from "./experimental.js";
|
|
4
|
+
import { escapeAppleScript } from "./vectors/applescript.js";
|
|
5
|
+
// ------------------------------------------------------------------ helpers
|
|
6
|
+
function thingsUrl(command, params, token) {
|
|
7
|
+
const parts = [];
|
|
8
|
+
const redactedParts = [];
|
|
9
|
+
for (const [key, value] of Object.entries(params)) {
|
|
10
|
+
if (value === undefined)
|
|
11
|
+
continue;
|
|
12
|
+
parts.push(`${key}=${encodeURIComponent(value)}`);
|
|
13
|
+
redactedParts.push(`${key}=${encodeURIComponent(value)}`);
|
|
14
|
+
}
|
|
15
|
+
if (token !== null) {
|
|
16
|
+
parts.push(`auth-token=${encodeURIComponent(token)}`);
|
|
17
|
+
redactedParts.push("auth-token=REDACTED");
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
vector: "url-scheme",
|
|
21
|
+
kind: "open-url",
|
|
22
|
+
payload: `things:///${command}?${parts.join("&")}`,
|
|
23
|
+
redactedPayload: `things:///${command}?${redactedParts.join("&")}`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function osa(script) {
|
|
27
|
+
const payload = `tell application "Things3" to ${script}`;
|
|
28
|
+
return { vector: "applescript", kind: "osascript", payload, redactedPayload: payload };
|
|
29
|
+
}
|
|
30
|
+
/** Multi-statement `tell` block (one osascript invocation, several events). */
|
|
31
|
+
function osaBlock(statements) {
|
|
32
|
+
const payload = `tell application "Things3"\n ${statements.join("\n ")}\nend tell`;
|
|
33
|
+
return { vector: "applescript", kind: "osascript", payload, redactedPayload: payload };
|
|
34
|
+
}
|
|
35
|
+
function q(value) {
|
|
36
|
+
return `"${escapeAppleScript(value)}"`;
|
|
37
|
+
}
|
|
38
|
+
function unsupportedVector(op, vector) {
|
|
39
|
+
throw new Error(`${op} cannot be compiled for vector ${vector} (planner bug)`);
|
|
40
|
+
}
|
|
41
|
+
function whenAssertions(when, todayIso) {
|
|
42
|
+
switch (when) {
|
|
43
|
+
case "today":
|
|
44
|
+
return [
|
|
45
|
+
{ field: "start", equals: "active" },
|
|
46
|
+
{ field: "startDate", equals: todayIso },
|
|
47
|
+
{ field: "todaySection", equals: "today" },
|
|
48
|
+
];
|
|
49
|
+
case "evening":
|
|
50
|
+
return [
|
|
51
|
+
{ field: "start", equals: "active" },
|
|
52
|
+
{ field: "startDate", equals: todayIso },
|
|
53
|
+
{ field: "todaySection", equals: "evening" },
|
|
54
|
+
];
|
|
55
|
+
case "anytime":
|
|
56
|
+
return [
|
|
57
|
+
{ field: "start", equals: "active" },
|
|
58
|
+
{ field: "startDate", equals: null },
|
|
59
|
+
];
|
|
60
|
+
case "someday":
|
|
61
|
+
return [{ field: "start", equals: "someday" }];
|
|
62
|
+
default:
|
|
63
|
+
// Concrete date: assert only the date — start-state semantics differ
|
|
64
|
+
// for past/today/future dates (only the date itself is invariant).
|
|
65
|
+
return [{ field: "startDate", equals: when }];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function sortedTags(tags) {
|
|
69
|
+
return [...tags].toSorted();
|
|
70
|
+
}
|
|
71
|
+
/** Round-trip normalization: "6:5"-style inputs → canonical "06:05". */
|
|
72
|
+
function normalizeReminder(time) {
|
|
73
|
+
return decodeReminderTime(encodeReminderTime(time)) ?? time;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The URL `when` value with an optional reminder token appended through the
|
|
77
|
+
* deterministic emitter (never a bare 1–11 hour — oddity 2d).
|
|
78
|
+
*/
|
|
79
|
+
function whenWithReminder(when, reminder) {
|
|
80
|
+
if (reminder === undefined || reminder === null)
|
|
81
|
+
return when;
|
|
82
|
+
return `${when}@${reminderUrlToken(reminder)}`;
|
|
83
|
+
}
|
|
84
|
+
function containerGiven(ref) {
|
|
85
|
+
return ref !== undefined && (ref.uuid !== undefined || ref.title !== undefined);
|
|
86
|
+
}
|
|
87
|
+
// ----------------------------------------------------------------- commands
|
|
88
|
+
const todoAdd = {
|
|
89
|
+
op: "todo.add",
|
|
90
|
+
hazards: [
|
|
91
|
+
"H-UNKNOWN-TAG",
|
|
92
|
+
"H-UNKNOWN-DESTINATION",
|
|
93
|
+
"H-AMBIGUOUS-HEADING",
|
|
94
|
+
"H-REOPEN-RESOLVED-PROJECT",
|
|
95
|
+
"H-REMINDER-SCOPE",
|
|
96
|
+
],
|
|
97
|
+
preRead(db, params) {
|
|
98
|
+
const pre = emptyPreState();
|
|
99
|
+
if (containerGiven(params.project)) {
|
|
100
|
+
pre.destProject = resolveProject(db, params.project);
|
|
101
|
+
if (pre.destProject.resolved !== null) {
|
|
102
|
+
pre.destProjectStatus = projectStatus(db, pre.destProject.resolved.uuid);
|
|
103
|
+
if (params.heading !== undefined) {
|
|
104
|
+
pre.destHeading = resolveHeading(db, pre.destProject.resolved.uuid, params.heading);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (containerGiven(params.area))
|
|
109
|
+
pre.destArea = resolveArea(db, params.area);
|
|
110
|
+
if (params.tags !== undefined)
|
|
111
|
+
pre.missingTags = missingTagTitles(db, params.tags);
|
|
112
|
+
return pre;
|
|
113
|
+
},
|
|
114
|
+
expectedDelta(pre, params, ctx) {
|
|
115
|
+
const assert = [];
|
|
116
|
+
if (params.notes !== undefined)
|
|
117
|
+
assert.push({ field: "notes", equals: params.notes });
|
|
118
|
+
if (params.when !== undefined)
|
|
119
|
+
assert.push(...whenAssertions(params.when, ctx.todayIso));
|
|
120
|
+
if (params.reminder !== undefined) {
|
|
121
|
+
assert.push({ field: "reminder", equals: normalizeReminder(params.reminder) });
|
|
122
|
+
}
|
|
123
|
+
if (params.deadline !== undefined)
|
|
124
|
+
assert.push({ field: "deadline", equals: params.deadline });
|
|
125
|
+
if (params.tags !== undefined)
|
|
126
|
+
assert.push({ field: "tags", equals: sortedTags(params.tags) });
|
|
127
|
+
if (params.checklistItems !== undefined) {
|
|
128
|
+
assert.push({ field: "checklistTitles", equals: params.checklistItems });
|
|
129
|
+
}
|
|
130
|
+
const heading = pre.destHeading?.resolved;
|
|
131
|
+
const project = pre.destProject?.resolved;
|
|
132
|
+
if (heading !== undefined && heading !== null) {
|
|
133
|
+
assert.push({ field: "heading.uuid", equals: heading.uuid });
|
|
134
|
+
}
|
|
135
|
+
else if (project !== undefined && project !== null) {
|
|
136
|
+
assert.push({ field: "project.uuid", equals: project.uuid });
|
|
137
|
+
}
|
|
138
|
+
const area = pre.destArea?.resolved;
|
|
139
|
+
if (area !== undefined && area !== null)
|
|
140
|
+
assert.push({ field: "area.uuid", equals: area.uuid });
|
|
141
|
+
return {
|
|
142
|
+
mode: "create",
|
|
143
|
+
probe: { title: params.title, type: "to-do", sinceEpoch: ctx.nowEpoch - 2 },
|
|
144
|
+
assert,
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
compile(params, vector, pre, ctx) {
|
|
148
|
+
if (vector !== "url-scheme")
|
|
149
|
+
unsupportedVector(this.op, vector);
|
|
150
|
+
const container = pre.destProject?.resolved ?? pre.destArea?.resolved;
|
|
151
|
+
return thingsUrl("add", {
|
|
152
|
+
title: params.title,
|
|
153
|
+
notes: params.notes,
|
|
154
|
+
when: params.when === undefined ? undefined : whenWithReminder(params.when, params.reminder),
|
|
155
|
+
deadline: params.deadline,
|
|
156
|
+
tags: params.tags?.join(","),
|
|
157
|
+
"checklist-items": params.checklistItems?.join("\n"),
|
|
158
|
+
"list-id": container?.uuid,
|
|
159
|
+
heading: pre.destHeading?.resolved?.title,
|
|
160
|
+
}, ctx.token);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* The effective reminder a when-bearing update should leave behind. A bare
|
|
165
|
+
* `when=` CLEARS an existing reminder (R07/R20), so when the caller
|
|
166
|
+
* re-schedules to today/evening/a date without addressing the reminder we
|
|
167
|
+
* auto-preserve the current one; an explicit null is the intentional clear.
|
|
168
|
+
*/
|
|
169
|
+
function effectiveReminder(pre, params) {
|
|
170
|
+
if (params.reminder !== undefined)
|
|
171
|
+
return params.reminder;
|
|
172
|
+
const when = params.when;
|
|
173
|
+
const schedulable = when === "today" ||
|
|
174
|
+
when === "evening" ||
|
|
175
|
+
(typeof when === "string" && /^\d{4}-\d{2}-\d{2}$/.test(when));
|
|
176
|
+
if (!schedulable)
|
|
177
|
+
return null;
|
|
178
|
+
const target = pre.target;
|
|
179
|
+
if (target === null || target.type === "heading")
|
|
180
|
+
return null;
|
|
181
|
+
return target.reminder;
|
|
182
|
+
}
|
|
183
|
+
function assertNotesModesExclusive(params) {
|
|
184
|
+
if (params.notes !== undefined &&
|
|
185
|
+
(params.appendNotes !== undefined || params.prependNotes !== undefined)) {
|
|
186
|
+
throw new RangeError("notes (replace) is exclusive with appendNotes/prependNotes");
|
|
187
|
+
}
|
|
188
|
+
if (params.appendNotes !== undefined && params.prependNotes !== undefined) {
|
|
189
|
+
throw new RangeError("appendNotes and prependNotes cannot be combined in one update");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function expectedNotes(pre, params) {
|
|
193
|
+
const current = pre.target !== null && pre.target.type !== "heading" ? pre.target.notes : "";
|
|
194
|
+
// Separator semantics probed: newline-joined, no stray newline against an
|
|
195
|
+
// empty note (E04/E05/E11/E12).
|
|
196
|
+
if (params.appendNotes !== undefined) {
|
|
197
|
+
return current === "" ? params.appendNotes : `${current}\n${params.appendNotes}`;
|
|
198
|
+
}
|
|
199
|
+
if (params.prependNotes !== undefined) {
|
|
200
|
+
return current === "" ? params.prependNotes : `${params.prependNotes}\n${current}`;
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
const todoUpdate = {
|
|
205
|
+
op: "todo.update",
|
|
206
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-REPEAT-SCHEDULE", "H-REMINDER-SCOPE"],
|
|
207
|
+
preRead(db, params) {
|
|
208
|
+
assertNotesModesExclusive(params);
|
|
209
|
+
const pre = emptyPreState();
|
|
210
|
+
pre.target = loadTarget(db, params.uuid);
|
|
211
|
+
return pre;
|
|
212
|
+
},
|
|
213
|
+
expectedDelta(pre, params, ctx) {
|
|
214
|
+
const assert = [];
|
|
215
|
+
if (params.title !== undefined)
|
|
216
|
+
assert.push({ field: "title", equals: params.title });
|
|
217
|
+
if (params.notes !== undefined)
|
|
218
|
+
assert.push({ field: "notes", equals: params.notes });
|
|
219
|
+
const joined = expectedNotes(pre, params);
|
|
220
|
+
if (joined !== undefined)
|
|
221
|
+
assert.push({ field: "notes", equals: joined });
|
|
222
|
+
if (params.when !== undefined) {
|
|
223
|
+
assert.push(...whenAssertions(params.when, ctx.todayIso));
|
|
224
|
+
const reminder = effectiveReminder(pre, params);
|
|
225
|
+
assert.push({
|
|
226
|
+
field: "reminder",
|
|
227
|
+
equals: reminder === null ? null : normalizeReminder(reminder),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (params.deadline !== undefined)
|
|
231
|
+
assert.push({ field: "deadline", equals: params.deadline });
|
|
232
|
+
return { mode: "update", uuid: params.uuid, assert };
|
|
233
|
+
},
|
|
234
|
+
compile(params, vector, pre, ctx) {
|
|
235
|
+
if (vector !== "url-scheme")
|
|
236
|
+
unsupportedVector(this.op, vector);
|
|
237
|
+
return thingsUrl("update", {
|
|
238
|
+
id: params.uuid,
|
|
239
|
+
title: params.title,
|
|
240
|
+
notes: params.notes,
|
|
241
|
+
"append-notes": params.appendNotes,
|
|
242
|
+
"prepend-notes": params.prependNotes,
|
|
243
|
+
when: params.when === undefined
|
|
244
|
+
? undefined
|
|
245
|
+
: whenWithReminder(params.when, effectiveReminder(pre, params)),
|
|
246
|
+
deadline: params.deadline === null ? "" : params.deadline,
|
|
247
|
+
}, ctx.token);
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
function statusSpec(op, urlParam, asStatus, scriptStatus) {
|
|
251
|
+
return {
|
|
252
|
+
op,
|
|
253
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-REPEAT-SCHEDULE"],
|
|
254
|
+
preRead(db, params) {
|
|
255
|
+
const pre = emptyPreState();
|
|
256
|
+
pre.target = loadTarget(db, params.uuid);
|
|
257
|
+
return pre;
|
|
258
|
+
},
|
|
259
|
+
expectedDelta(_pre, params) {
|
|
260
|
+
return { mode: "state", uuid: params.uuid, assert: [{ field: "status", equals: asStatus }] };
|
|
261
|
+
},
|
|
262
|
+
compile(params, vector, _pre, ctx) {
|
|
263
|
+
if (vector === "url-scheme") {
|
|
264
|
+
return thingsUrl("update", { id: params.uuid, ...urlParam }, ctx.token);
|
|
265
|
+
}
|
|
266
|
+
return osa(`set status of to do id ${q(params.uuid)} to ${scriptStatus}`);
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const todoComplete = statusSpec("todo.complete", { completed: "true" }, "completed", "completed");
|
|
271
|
+
const todoCancel = statusSpec("todo.cancel", { canceled: "true" }, "canceled", "canceled");
|
|
272
|
+
const todoReopen = statusSpec("todo.reopen", { completed: "false" }, "open", "open");
|
|
273
|
+
const todoMove = {
|
|
274
|
+
op: "todo.move",
|
|
275
|
+
hazards: [
|
|
276
|
+
"H-UNKNOWN-DESTINATION",
|
|
277
|
+
"H-AMBIGUOUS-HEADING",
|
|
278
|
+
"H-REOPEN-RESOLVED-PROJECT",
|
|
279
|
+
"H-REPEAT-SCHEDULE",
|
|
280
|
+
],
|
|
281
|
+
preRead(db, params) {
|
|
282
|
+
const container = containerGiven(params.project) || containerGiven(params.area) || params.heading !== undefined;
|
|
283
|
+
if (params.inbox === true && (container || params.detach === true)) {
|
|
284
|
+
throw new RangeError("inbox is exclusive with project/area/heading/detach");
|
|
285
|
+
}
|
|
286
|
+
if (params.detach === true && container) {
|
|
287
|
+
throw new RangeError("detach is exclusive with project/area/heading destinations");
|
|
288
|
+
}
|
|
289
|
+
const pre = emptyPreState();
|
|
290
|
+
pre.target = loadTarget(db, params.uuid);
|
|
291
|
+
if (containerGiven(params.project)) {
|
|
292
|
+
pre.destProject = resolveProject(db, params.project);
|
|
293
|
+
if (pre.destProject.resolved !== null) {
|
|
294
|
+
pre.destProjectStatus = projectStatus(db, pre.destProject.resolved.uuid);
|
|
295
|
+
if (params.heading !== undefined) {
|
|
296
|
+
pre.destHeading = resolveHeading(db, pre.destProject.resolved.uuid, params.heading);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (containerGiven(params.area))
|
|
301
|
+
pre.destArea = resolveArea(db, params.area);
|
|
302
|
+
return pre;
|
|
303
|
+
},
|
|
304
|
+
expectedDelta(pre, params) {
|
|
305
|
+
const assert = [];
|
|
306
|
+
if (params.inbox === true) {
|
|
307
|
+
// De-schedules cleanly (E06): back to the Inbox bucket, no start date.
|
|
308
|
+
assert.push({ field: "start", equals: "inbox" }, { field: "startDate", equals: null });
|
|
309
|
+
return { mode: "update", uuid: params.uuid, assert };
|
|
310
|
+
}
|
|
311
|
+
if (params.detach === true) {
|
|
312
|
+
// P21/P22: empty list-id strips every container link; the schedule is
|
|
313
|
+
// untouched (pin it — a silent de-schedule would be a contrary write).
|
|
314
|
+
const target = pre.target;
|
|
315
|
+
const startDate = target !== null && target.type !== "heading" ? (target.startDate ?? null) : null;
|
|
316
|
+
assert.push({ field: "project", equals: null }, { field: "area", equals: null }, { field: "heading", equals: null }, { field: "startDate", equals: startDate });
|
|
317
|
+
return { mode: "update", uuid: params.uuid, assert };
|
|
318
|
+
}
|
|
319
|
+
const heading = pre.destHeading?.resolved;
|
|
320
|
+
const project = pre.destProject?.resolved;
|
|
321
|
+
const area = pre.destArea?.resolved;
|
|
322
|
+
if (heading !== undefined && heading !== null) {
|
|
323
|
+
assert.push({ field: "heading.uuid", equals: heading.uuid });
|
|
324
|
+
}
|
|
325
|
+
else if (project !== undefined && project !== null) {
|
|
326
|
+
assert.push({ field: "project.uuid", equals: project.uuid });
|
|
327
|
+
}
|
|
328
|
+
if (area !== undefined && area !== null) {
|
|
329
|
+
assert.push({ field: "area.uuid", equals: area.uuid });
|
|
330
|
+
// Validated (A22B): assigning an area clears any project link.
|
|
331
|
+
assert.push({ field: "project", equals: null });
|
|
332
|
+
}
|
|
333
|
+
return { mode: "update", uuid: params.uuid, assert };
|
|
334
|
+
},
|
|
335
|
+
compile(params, vector, pre, ctx) {
|
|
336
|
+
if (params.inbox === true) {
|
|
337
|
+
if (vector !== "applescript")
|
|
338
|
+
unsupportedVector(this.op, vector);
|
|
339
|
+
return osa(`move to do id ${q(params.uuid)} to list "Inbox"`);
|
|
340
|
+
}
|
|
341
|
+
if (params.detach === true) {
|
|
342
|
+
// Empty list-id = clear the container (P21/P22) — URL only; the other
|
|
343
|
+
// vectors reject or silently ignore container removal (P10/P11, P26).
|
|
344
|
+
if (vector !== "url-scheme")
|
|
345
|
+
unsupportedVector(this.op, vector);
|
|
346
|
+
return thingsUrl("update", { id: params.uuid, "list-id": "" }, ctx.token);
|
|
347
|
+
}
|
|
348
|
+
const project = pre.destProject?.resolved;
|
|
349
|
+
const area = pre.destArea?.resolved;
|
|
350
|
+
if (vector === "url-scheme") {
|
|
351
|
+
return thingsUrl("update", {
|
|
352
|
+
id: params.uuid,
|
|
353
|
+
"list-id": (project ?? area)?.uuid,
|
|
354
|
+
heading: pre.destHeading?.resolved?.title,
|
|
355
|
+
}, ctx.token);
|
|
356
|
+
}
|
|
357
|
+
if (project !== undefined && project !== null) {
|
|
358
|
+
return osa(`set project of to do id ${q(params.uuid)} to project id ${q(project.uuid)}`);
|
|
359
|
+
}
|
|
360
|
+
if (area !== undefined && area !== null) {
|
|
361
|
+
return osa(`set area of to do id ${q(params.uuid)} to area id ${q(area.uuid)}`);
|
|
362
|
+
}
|
|
363
|
+
unsupportedVector(this.op, vector);
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
const todoSetTags = {
|
|
367
|
+
op: "todo.set-tags",
|
|
368
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-UNKNOWN-TAG"],
|
|
369
|
+
preRead(db, params) {
|
|
370
|
+
const pre = emptyPreState();
|
|
371
|
+
pre.target = loadTarget(db, params.uuid);
|
|
372
|
+
pre.missingTags = missingTagTitles(db, params.tags);
|
|
373
|
+
return pre;
|
|
374
|
+
},
|
|
375
|
+
expectedDelta(_pre, params) {
|
|
376
|
+
return {
|
|
377
|
+
mode: "update",
|
|
378
|
+
uuid: params.uuid,
|
|
379
|
+
assert: [{ field: "tags", equals: sortedTags(params.tags) }],
|
|
380
|
+
};
|
|
381
|
+
},
|
|
382
|
+
compile(params, vector, _pre, ctx) {
|
|
383
|
+
if (vector === "url-scheme") {
|
|
384
|
+
return thingsUrl("update", { id: params.uuid, tags: params.tags.join(",") }, ctx.token);
|
|
385
|
+
}
|
|
386
|
+
return osa(`set tag names of to do id ${q(params.uuid)} to ${q(params.tags.join(", "))}`);
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
/** Normalize the string | spec union; decide whether states force the json form. */
|
|
390
|
+
function checklistSpecs(items) {
|
|
391
|
+
const specs = items.map((i) => typeof i === "string"
|
|
392
|
+
? { title: i, completed: false }
|
|
393
|
+
: { title: i.title, completed: i.completed === true });
|
|
394
|
+
const needsJson = items.some((i) => typeof i !== "string" && i.completed !== undefined);
|
|
395
|
+
return { specs, needsJson };
|
|
396
|
+
}
|
|
397
|
+
const todoReplaceChecklist = {
|
|
398
|
+
op: "todo.replace-checklist",
|
|
399
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-CHECKLIST-REPLACE"],
|
|
400
|
+
preRead(db, params) {
|
|
401
|
+
const pre = emptyPreState();
|
|
402
|
+
pre.target = loadTarget(db, params.uuid);
|
|
403
|
+
if (pre.target !== null && pre.target.type === "to-do") {
|
|
404
|
+
pre.checklistCount = pre.target.checklist?.length ?? 0;
|
|
405
|
+
}
|
|
406
|
+
return pre;
|
|
407
|
+
},
|
|
408
|
+
expectedDelta(_pre, params) {
|
|
409
|
+
const { specs, needsJson } = checklistSpecs(params.items);
|
|
410
|
+
const assert = [
|
|
411
|
+
{ field: "checklistTitles", equals: specs.map((s) => s.title) },
|
|
412
|
+
];
|
|
413
|
+
if (needsJson) {
|
|
414
|
+
// P18: the json form applies per-item completed states — verify them.
|
|
415
|
+
assert.push({
|
|
416
|
+
field: "checklistStates",
|
|
417
|
+
equals: specs.map((s) => (s.completed ? "completed" : "open")),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return { mode: "update", uuid: params.uuid, assert };
|
|
421
|
+
},
|
|
422
|
+
compile(params, vector, _pre, ctx) {
|
|
423
|
+
if (vector !== "url-scheme")
|
|
424
|
+
unsupportedVector(this.op, vector);
|
|
425
|
+
const { specs, needsJson } = checklistSpecs(params.items);
|
|
426
|
+
if (!needsJson) {
|
|
427
|
+
return thingsUrl("update", { id: params.uuid, "checklist-items": specs.map((s) => s.title).join("\n") }, ctx.token);
|
|
428
|
+
}
|
|
429
|
+
// things:///json — the only surface that recreates items PRE-CHECKED
|
|
430
|
+
// (P18). Items are replaced wholesale; their uuids are not stable.
|
|
431
|
+
const payload = JSON.stringify([
|
|
432
|
+
{
|
|
433
|
+
type: "to-do",
|
|
434
|
+
operation: "update",
|
|
435
|
+
id: params.uuid,
|
|
436
|
+
attributes: {
|
|
437
|
+
"checklist-items": specs.map((s) => ({
|
|
438
|
+
type: "checklist-item",
|
|
439
|
+
attributes: { title: s.title, completed: s.completed },
|
|
440
|
+
})),
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
]);
|
|
444
|
+
return thingsUrl("json", { data: payload }, ctx.token);
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
const todoDelete = {
|
|
448
|
+
op: "todo.delete",
|
|
449
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-REPEAT-SCHEDULE"],
|
|
450
|
+
preRead(db, params) {
|
|
451
|
+
const pre = emptyPreState();
|
|
452
|
+
pre.target = loadTarget(db, params.uuid);
|
|
453
|
+
return pre;
|
|
454
|
+
},
|
|
455
|
+
expectedDelta(_pre, params) {
|
|
456
|
+
return { mode: "update", uuid: params.uuid, assert: [{ field: "trashed", equals: true }] };
|
|
457
|
+
},
|
|
458
|
+
compile(params, vector) {
|
|
459
|
+
if (vector !== "applescript")
|
|
460
|
+
unsupportedVector(this.op, vector);
|
|
461
|
+
return osa(`delete to do id ${q(params.uuid)}`);
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
const projectAdd = {
|
|
465
|
+
op: "project.add",
|
|
466
|
+
hazards: ["H-UNKNOWN-DESTINATION"],
|
|
467
|
+
preRead(db, params) {
|
|
468
|
+
const pre = emptyPreState();
|
|
469
|
+
if (containerGiven(params.area))
|
|
470
|
+
pre.destArea = resolveArea(db, params.area);
|
|
471
|
+
return pre;
|
|
472
|
+
},
|
|
473
|
+
expectedDelta(pre, params, ctx) {
|
|
474
|
+
const assert = [];
|
|
475
|
+
if (params.notes !== undefined)
|
|
476
|
+
assert.push({ field: "notes", equals: params.notes });
|
|
477
|
+
if (params.when !== undefined)
|
|
478
|
+
assert.push(...whenAssertions(params.when, ctx.todayIso));
|
|
479
|
+
if (params.deadline !== undefined)
|
|
480
|
+
assert.push({ field: "deadline", equals: params.deadline });
|
|
481
|
+
const area = pre.destArea?.resolved;
|
|
482
|
+
if (area !== undefined && area !== null)
|
|
483
|
+
assert.push({ field: "area.uuid", equals: area.uuid });
|
|
484
|
+
return {
|
|
485
|
+
mode: "create",
|
|
486
|
+
probe: { title: params.title, type: "project", sinceEpoch: ctx.nowEpoch - 2 },
|
|
487
|
+
assert,
|
|
488
|
+
};
|
|
489
|
+
},
|
|
490
|
+
compile(params, vector, pre, ctx) {
|
|
491
|
+
if (vector !== "url-scheme")
|
|
492
|
+
unsupportedVector(this.op, vector);
|
|
493
|
+
return thingsUrl("add-project", {
|
|
494
|
+
title: params.title,
|
|
495
|
+
notes: params.notes,
|
|
496
|
+
when: params.when,
|
|
497
|
+
deadline: params.deadline,
|
|
498
|
+
"area-id": pre.destArea?.resolved?.uuid,
|
|
499
|
+
"to-dos": params.todos?.join("\n"),
|
|
500
|
+
}, ctx.token);
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
const projectUpdate = {
|
|
504
|
+
op: "project.update",
|
|
505
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-REPEAT-SCHEDULE", "H-REMINDER-SCOPE"],
|
|
506
|
+
preRead(db, params) {
|
|
507
|
+
assertNotesModesExclusive(params);
|
|
508
|
+
const pre = emptyPreState();
|
|
509
|
+
pre.target = loadTarget(db, params.uuid);
|
|
510
|
+
return pre;
|
|
511
|
+
},
|
|
512
|
+
expectedDelta(pre, params, ctx) {
|
|
513
|
+
const assert = [];
|
|
514
|
+
if (params.title !== undefined)
|
|
515
|
+
assert.push({ field: "title", equals: params.title });
|
|
516
|
+
if (params.notes !== undefined)
|
|
517
|
+
assert.push({ field: "notes", equals: params.notes });
|
|
518
|
+
const joined = expectedNotes(pre, params);
|
|
519
|
+
if (joined !== undefined)
|
|
520
|
+
assert.push({ field: "notes", equals: joined });
|
|
521
|
+
if (params.when !== undefined) {
|
|
522
|
+
assert.push(...whenAssertions(params.when, ctx.todayIso));
|
|
523
|
+
// Projects carry the same reminderTime codec as to-dos (A3); a bare
|
|
524
|
+
// when= clears an existing reminder unless auto-preserved.
|
|
525
|
+
const reminder = effectiveReminder(pre, params);
|
|
526
|
+
assert.push({
|
|
527
|
+
field: "reminder",
|
|
528
|
+
equals: reminder === null ? null : normalizeReminder(reminder),
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
if (params.deadline !== undefined)
|
|
532
|
+
assert.push({ field: "deadline", equals: params.deadline });
|
|
533
|
+
return { mode: "update", uuid: params.uuid, assert };
|
|
534
|
+
},
|
|
535
|
+
compile(params, vector, pre, ctx) {
|
|
536
|
+
if (vector !== "url-scheme")
|
|
537
|
+
unsupportedVector(this.op, vector);
|
|
538
|
+
return thingsUrl("update-project", {
|
|
539
|
+
id: params.uuid,
|
|
540
|
+
title: params.title,
|
|
541
|
+
notes: params.notes,
|
|
542
|
+
"append-notes": params.appendNotes,
|
|
543
|
+
"prepend-notes": params.prependNotes,
|
|
544
|
+
when: params.when === undefined
|
|
545
|
+
? undefined
|
|
546
|
+
: whenWithReminder(params.when, effectiveReminder(pre, params)),
|
|
547
|
+
deadline: params.deadline === null ? "" : params.deadline,
|
|
548
|
+
}, ctx.token);
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
const projectSetTags = {
|
|
552
|
+
op: "project.set-tags",
|
|
553
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-UNKNOWN-TAG"],
|
|
554
|
+
preRead(db, params) {
|
|
555
|
+
const pre = emptyPreState();
|
|
556
|
+
pre.target = loadTarget(db, params.uuid);
|
|
557
|
+
pre.missingTags = missingTagTitles(db, params.tags);
|
|
558
|
+
return pre;
|
|
559
|
+
},
|
|
560
|
+
expectedDelta(_pre, params) {
|
|
561
|
+
return {
|
|
562
|
+
mode: "update",
|
|
563
|
+
uuid: params.uuid,
|
|
564
|
+
assert: [{ field: "tags", equals: sortedTags(params.tags) }],
|
|
565
|
+
};
|
|
566
|
+
},
|
|
567
|
+
compile(params, vector, _pre, ctx) {
|
|
568
|
+
// Both vectors validated on projects (A1 URL, A2 AppleScript). Full
|
|
569
|
+
// replacement semantics mirror todo.set-tags; unknown tags are guarded
|
|
570
|
+
// pre-write (the app silently drops them).
|
|
571
|
+
if (vector === "url-scheme") {
|
|
572
|
+
return thingsUrl("update-project", { id: params.uuid, tags: params.tags.join(",") }, ctx.token);
|
|
573
|
+
}
|
|
574
|
+
return osa(`set tag names of project id ${q(params.uuid)} to ${q(params.tags.join(", "))}`);
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
const projectMove = {
|
|
578
|
+
op: "project.move",
|
|
579
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-REPEAT-SCHEDULE"],
|
|
580
|
+
preRead(db, params) {
|
|
581
|
+
if ((params.detach === true) === containerGiven(params.area)) {
|
|
582
|
+
throw new RangeError("project.move needs exactly one of area / detach");
|
|
583
|
+
}
|
|
584
|
+
const pre = emptyPreState();
|
|
585
|
+
pre.target = loadTarget(db, params.uuid);
|
|
586
|
+
if (containerGiven(params.area))
|
|
587
|
+
pre.destArea = resolveArea(db, params.area);
|
|
588
|
+
return pre;
|
|
589
|
+
},
|
|
590
|
+
expectedDelta(pre, params) {
|
|
591
|
+
// Area (re/un-)assignment only (E14/P23/P24): status/start/schedule
|
|
592
|
+
// untouched by the app — the delta pins the new area link.
|
|
593
|
+
return {
|
|
594
|
+
mode: "update",
|
|
595
|
+
uuid: params.uuid,
|
|
596
|
+
assert: [
|
|
597
|
+
params.detach === true
|
|
598
|
+
? { field: "area", equals: null }
|
|
599
|
+
: { field: "area.uuid", equals: pre.destArea?.resolved?.uuid ?? "" },
|
|
600
|
+
],
|
|
601
|
+
};
|
|
602
|
+
},
|
|
603
|
+
compile(params, vector, pre, ctx) {
|
|
604
|
+
if (vector === "url-scheme") {
|
|
605
|
+
// P23 (move) / P24 (empty area-id = detach — URL is the ONLY detach
|
|
606
|
+
// surface: AppleScript rejects missing value/"" and json-null no-ops).
|
|
607
|
+
return thingsUrl("update-project", {
|
|
608
|
+
id: params.uuid,
|
|
609
|
+
"area-id": params.detach === true ? "" : (pre.destArea?.resolved?.uuid ?? ""),
|
|
610
|
+
}, ctx.token);
|
|
611
|
+
}
|
|
612
|
+
if (params.detach === true)
|
|
613
|
+
unsupportedVector(this.op, vector);
|
|
614
|
+
return osa(`set area of project id ${q(params.uuid)} to area id ` +
|
|
615
|
+
q(pre.destArea?.resolved?.uuid ?? ""));
|
|
616
|
+
},
|
|
617
|
+
};
|
|
618
|
+
const todoRestore = {
|
|
619
|
+
op: "todo.restore",
|
|
620
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-REPEAT-SCHEDULE"],
|
|
621
|
+
preRead(db, params) {
|
|
622
|
+
const pre = emptyPreState();
|
|
623
|
+
pre.target = loadTarget(db, params.uuid);
|
|
624
|
+
return pre;
|
|
625
|
+
},
|
|
626
|
+
expectedDelta(_pre, params) {
|
|
627
|
+
// The UI's "Put Back", scripted (E15): the item un-trashes into the
|
|
628
|
+
// Inbox, de-scheduled. Prior list/schedule are NOT restored.
|
|
629
|
+
return {
|
|
630
|
+
mode: "update",
|
|
631
|
+
uuid: params.uuid,
|
|
632
|
+
assert: [
|
|
633
|
+
{ field: "trashed", equals: false },
|
|
634
|
+
{ field: "start", equals: "inbox" },
|
|
635
|
+
],
|
|
636
|
+
};
|
|
637
|
+
},
|
|
638
|
+
compile(params, vector) {
|
|
639
|
+
if (vector !== "applescript")
|
|
640
|
+
unsupportedVector(this.op, vector);
|
|
641
|
+
return osa(`move to do id ${q(params.uuid)} to list "Inbox"`);
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
const projectDuplicate = {
|
|
645
|
+
op: "project.duplicate",
|
|
646
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-REPEAT-SCHEDULE"],
|
|
647
|
+
preRead(db, params) {
|
|
648
|
+
const pre = emptyPreState();
|
|
649
|
+
pre.target = loadTarget(db, params.uuid);
|
|
650
|
+
return pre;
|
|
651
|
+
},
|
|
652
|
+
expectedDelta(pre, _params, ctx) {
|
|
653
|
+
// E17: the copy carries the title, notes AND children; discover it with
|
|
654
|
+
// the create probe (fresh creationDate, same as the to-do path E07).
|
|
655
|
+
const target = pre.target;
|
|
656
|
+
const title = target !== null && target.type !== "heading" ? target.title : "";
|
|
657
|
+
const notes = target !== null && target.type !== "heading" ? target.notes : "";
|
|
658
|
+
return {
|
|
659
|
+
mode: "create",
|
|
660
|
+
probe: { title, type: "project", sinceEpoch: ctx.nowEpoch - 2 },
|
|
661
|
+
assert: [{ field: "notes", equals: notes }],
|
|
662
|
+
};
|
|
663
|
+
},
|
|
664
|
+
compile(params, vector, _pre, ctx) {
|
|
665
|
+
if (vector !== "url-scheme")
|
|
666
|
+
unsupportedVector(this.op, vector);
|
|
667
|
+
return thingsUrl("update-project", { id: params.uuid, duplicate: "true" }, ctx.token);
|
|
668
|
+
},
|
|
669
|
+
};
|
|
670
|
+
const projectComplete = {
|
|
671
|
+
op: "project.complete",
|
|
672
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-PROJECT-COMPLETE-CHILDREN"],
|
|
673
|
+
preRead(db, params) {
|
|
674
|
+
const pre = emptyPreState();
|
|
675
|
+
pre.target = loadTarget(db, params.uuid);
|
|
676
|
+
if (pre.target !== null && pre.target.type === "project") {
|
|
677
|
+
const children = projectChildren(db, params.uuid);
|
|
678
|
+
pre.openChildren = children.filter((c) => c.status === "open");
|
|
679
|
+
pre.canceledChildren = children.filter((c) => c.status === "canceled");
|
|
680
|
+
}
|
|
681
|
+
return pre;
|
|
682
|
+
},
|
|
683
|
+
expectedDelta(pre, params) {
|
|
684
|
+
// Cascade semantics validated by T08/U08: open children auto-complete,
|
|
685
|
+
// canceled children stay canceled — verified, not assumed.
|
|
686
|
+
const cascade = [
|
|
687
|
+
...pre.openChildren.map((c) => ({
|
|
688
|
+
uuid: c.uuid,
|
|
689
|
+
assert: [{ field: "status", equals: "completed" }],
|
|
690
|
+
})),
|
|
691
|
+
...pre.canceledChildren.map((c) => ({
|
|
692
|
+
uuid: c.uuid,
|
|
693
|
+
assert: [{ field: "status", equals: "canceled" }],
|
|
694
|
+
})),
|
|
695
|
+
];
|
|
696
|
+
return {
|
|
697
|
+
mode: "state",
|
|
698
|
+
uuid: params.uuid,
|
|
699
|
+
assert: [{ field: "status", equals: "completed" }],
|
|
700
|
+
...(cascade.length > 0 && { cascade }),
|
|
701
|
+
};
|
|
702
|
+
},
|
|
703
|
+
compile(params, vector, _pre, ctx) {
|
|
704
|
+
if (vector !== "url-scheme")
|
|
705
|
+
unsupportedVector(this.op, vector);
|
|
706
|
+
return thingsUrl("update-project", { id: params.uuid, completed: "true" }, ctx.token);
|
|
707
|
+
},
|
|
708
|
+
};
|
|
709
|
+
const projectCancel = {
|
|
710
|
+
op: "project.cancel",
|
|
711
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-PROJECT-COMPLETE-CHILDREN"],
|
|
712
|
+
preRead(db, params) {
|
|
713
|
+
const pre = emptyPreState();
|
|
714
|
+
pre.target = loadTarget(db, params.uuid);
|
|
715
|
+
if (pre.target !== null && pre.target.type === "project") {
|
|
716
|
+
const children = projectChildren(db, params.uuid);
|
|
717
|
+
pre.openChildren = children.filter((c) => c.status === "open");
|
|
718
|
+
pre.completedChildren = children.filter((c) => c.status === "completed");
|
|
719
|
+
}
|
|
720
|
+
return pre;
|
|
721
|
+
},
|
|
722
|
+
expectedDelta(pre, params) {
|
|
723
|
+
// Cancel cascade validated by P01: open children auto-cancel, completed
|
|
724
|
+
// children keep their status AND stopDate — verified, not assumed.
|
|
725
|
+
const cascade = [
|
|
726
|
+
...pre.openChildren.map((c) => ({
|
|
727
|
+
uuid: c.uuid,
|
|
728
|
+
assert: [{ field: "status", equals: "canceled" }],
|
|
729
|
+
})),
|
|
730
|
+
...pre.completedChildren.map((c) => ({
|
|
731
|
+
uuid: c.uuid,
|
|
732
|
+
assert: [{ field: "status", equals: "completed" }],
|
|
733
|
+
})),
|
|
734
|
+
];
|
|
735
|
+
return {
|
|
736
|
+
mode: "state",
|
|
737
|
+
uuid: params.uuid,
|
|
738
|
+
assert: [{ field: "status", equals: "canceled" }],
|
|
739
|
+
...(cascade.length > 0 && { cascade }),
|
|
740
|
+
};
|
|
741
|
+
},
|
|
742
|
+
compile(params, vector, _pre, ctx) {
|
|
743
|
+
if (vector !== "url-scheme")
|
|
744
|
+
unsupportedVector(this.op, vector);
|
|
745
|
+
return thingsUrl("update-project", { id: params.uuid, canceled: "true" }, ctx.token);
|
|
746
|
+
},
|
|
747
|
+
};
|
|
748
|
+
const projectReopen = {
|
|
749
|
+
op: "project.reopen",
|
|
750
|
+
hazards: ["H-UNKNOWN-DESTINATION"],
|
|
751
|
+
preRead(db, params) {
|
|
752
|
+
const pre = emptyPreState();
|
|
753
|
+
pre.target = loadTarget(db, params.uuid);
|
|
754
|
+
return pre;
|
|
755
|
+
},
|
|
756
|
+
expectedDelta(_pre, params) {
|
|
757
|
+
// Reopens ONLY the project row (P02/P05): cascade-resolved children
|
|
758
|
+
// stay resolved — restoring them is a separate, explicit concern
|
|
759
|
+
// (client restoreChildren / undo's audit-exact replay).
|
|
760
|
+
return { mode: "state", uuid: params.uuid, assert: [{ field: "status", equals: "open" }] };
|
|
761
|
+
},
|
|
762
|
+
compile(params, vector, pre, ctx) {
|
|
763
|
+
if (vector !== "url-scheme")
|
|
764
|
+
unsupportedVector(this.op, vector);
|
|
765
|
+
// The pre status picks the parameter: completed=false vs canceled=false
|
|
766
|
+
// (each only reverses its own status — P02/P05).
|
|
767
|
+
const wasCanceled = pre.target !== null && pre.target.type !== "heading" && pre.target.status === "canceled";
|
|
768
|
+
return thingsUrl("update-project", { id: params.uuid, ...(wasCanceled ? { canceled: "false" } : { completed: "false" }) }, ctx.token);
|
|
769
|
+
},
|
|
770
|
+
};
|
|
771
|
+
const projectRestore = {
|
|
772
|
+
op: "project.restore",
|
|
773
|
+
hazards: ["H-UNKNOWN-DESTINATION"],
|
|
774
|
+
preRead(db, params) {
|
|
775
|
+
const pre = emptyPreState();
|
|
776
|
+
pre.target = loadTarget(db, params.uuid);
|
|
777
|
+
return pre;
|
|
778
|
+
},
|
|
779
|
+
expectedDelta(_pre, params) {
|
|
780
|
+
// P06: the Anytime list-move on a trashed project flips trashed IN
|
|
781
|
+
// PLACE — schedule, area link, and children all keep their state
|
|
782
|
+
// (better than the to-do restore, which relocates to the Inbox).
|
|
783
|
+
return { mode: "update", uuid: params.uuid, assert: [{ field: "trashed", equals: false }] };
|
|
784
|
+
},
|
|
785
|
+
compile(params, vector) {
|
|
786
|
+
if (vector !== "applescript")
|
|
787
|
+
unsupportedVector(this.op, vector);
|
|
788
|
+
return osa(`move project id ${q(params.uuid)} to list "Anytime"`);
|
|
789
|
+
},
|
|
790
|
+
};
|
|
791
|
+
const projectDelete = {
|
|
792
|
+
op: "project.delete",
|
|
793
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-REPEAT-SCHEDULE"],
|
|
794
|
+
preRead(db, params) {
|
|
795
|
+
const pre = emptyPreState();
|
|
796
|
+
pre.target = loadTarget(db, params.uuid);
|
|
797
|
+
return pre;
|
|
798
|
+
},
|
|
799
|
+
expectedDelta(_pre, params) {
|
|
800
|
+
// Shallow by design (A24B): only the project row flips trashed=1;
|
|
801
|
+
// children keep their links (derived Trash membership).
|
|
802
|
+
return { mode: "update", uuid: params.uuid, assert: [{ field: "trashed", equals: true }] };
|
|
803
|
+
},
|
|
804
|
+
compile(params, vector) {
|
|
805
|
+
if (vector !== "applescript")
|
|
806
|
+
unsupportedVector(this.op, vector);
|
|
807
|
+
return osa(`delete project id ${q(params.uuid)}`);
|
|
808
|
+
},
|
|
809
|
+
};
|
|
810
|
+
const areaAdd = {
|
|
811
|
+
op: "area.add",
|
|
812
|
+
hazards: ["H-UNKNOWN-TAG"],
|
|
813
|
+
preRead(db, params) {
|
|
814
|
+
const pre = emptyPreState();
|
|
815
|
+
if (params.tags !== undefined)
|
|
816
|
+
pre.missingTags = missingTagTitles(db, params.tags);
|
|
817
|
+
pre.existingEntityUuids = db.prepare("SELECT uuid FROM TMArea WHERE title = ? COLLATE NOCASE").all(params.title).map((r) => r.uuid);
|
|
818
|
+
return pre;
|
|
819
|
+
},
|
|
820
|
+
expectedDelta(pre, params) {
|
|
821
|
+
return {
|
|
822
|
+
mode: "entity-created",
|
|
823
|
+
entity: "area",
|
|
824
|
+
title: params.title,
|
|
825
|
+
excludeUuids: pre.existingEntityUuids,
|
|
826
|
+
};
|
|
827
|
+
},
|
|
828
|
+
compile(params, vector) {
|
|
829
|
+
if (vector !== "applescript")
|
|
830
|
+
unsupportedVector(this.op, vector);
|
|
831
|
+
const make = `make new area with properties {name:${q(params.title)}}`;
|
|
832
|
+
if (params.tags === undefined || params.tags.length === 0)
|
|
833
|
+
return osa(make);
|
|
834
|
+
const payload = `tell application "Things3"\n` +
|
|
835
|
+
` ${make}\n` +
|
|
836
|
+
` set tag names of area ${q(params.title)} to ${q(params.tags.join(", "))}\n` +
|
|
837
|
+
`end tell`;
|
|
838
|
+
return { vector: "applescript", kind: "osascript", payload, redactedPayload: payload };
|
|
839
|
+
},
|
|
840
|
+
};
|
|
841
|
+
const areaDelete = {
|
|
842
|
+
op: "area.delete",
|
|
843
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-PERMANENT-DELETE"],
|
|
844
|
+
preRead(db, params) {
|
|
845
|
+
const pre = emptyPreState();
|
|
846
|
+
pre.entityTarget = resolveArea(db, { title: params.target, uuid: params.target });
|
|
847
|
+
return pre;
|
|
848
|
+
},
|
|
849
|
+
expectedDelta(pre) {
|
|
850
|
+
const uuid = pre.entityTarget?.resolved?.uuid ?? "";
|
|
851
|
+
return { mode: "gone", entity: "area", uuid };
|
|
852
|
+
},
|
|
853
|
+
compile(_params, vector, pre) {
|
|
854
|
+
if (vector !== "applescript")
|
|
855
|
+
unsupportedVector(this.op, vector);
|
|
856
|
+
return osa(`delete area id ${q(pre.entityTarget?.resolved?.uuid ?? "")}`);
|
|
857
|
+
},
|
|
858
|
+
};
|
|
859
|
+
const tagAdd = {
|
|
860
|
+
op: "tag.add",
|
|
861
|
+
hazards: ["H-UNKNOWN-TAG"],
|
|
862
|
+
preRead(db, params) {
|
|
863
|
+
const pre = emptyPreState();
|
|
864
|
+
if (params.parent !== undefined) {
|
|
865
|
+
pre.parentTag = resolveTag(db, params.parent);
|
|
866
|
+
if (pre.parentTag.resolved === null)
|
|
867
|
+
pre.missingTags = [params.parent];
|
|
868
|
+
}
|
|
869
|
+
pre.existingEntityUuids = db.prepare("SELECT uuid FROM TMTag WHERE title = ? COLLATE NOCASE").all(params.title).map((r) => r.uuid);
|
|
870
|
+
return pre;
|
|
871
|
+
},
|
|
872
|
+
expectedDelta(pre, params) {
|
|
873
|
+
return {
|
|
874
|
+
mode: "entity-created",
|
|
875
|
+
entity: "tag",
|
|
876
|
+
title: params.title,
|
|
877
|
+
excludeUuids: pre.existingEntityUuids,
|
|
878
|
+
parentUuid: pre.parentTag?.resolved?.uuid ?? null,
|
|
879
|
+
};
|
|
880
|
+
},
|
|
881
|
+
compile(params, vector, pre) {
|
|
882
|
+
if (vector !== "applescript")
|
|
883
|
+
unsupportedVector(this.op, vector);
|
|
884
|
+
const make = `make new tag with properties {name:${q(params.title)}}`;
|
|
885
|
+
const parent = pre.parentTag?.resolved;
|
|
886
|
+
if (parent === undefined || parent === null)
|
|
887
|
+
return osa(make);
|
|
888
|
+
const payload = `tell application "Things3"\n` +
|
|
889
|
+
` ${make}\n` +
|
|
890
|
+
` set parent tag of tag ${q(params.title)} to tag ${q(parent.title)}\n` +
|
|
891
|
+
`end tell`;
|
|
892
|
+
return { vector: "applescript", kind: "osascript", payload, redactedPayload: payload };
|
|
893
|
+
},
|
|
894
|
+
};
|
|
895
|
+
const tagDelete = {
|
|
896
|
+
op: "tag.delete",
|
|
897
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-PERMANENT-DELETE", "H-TAG-SUBTREE-DELETE"],
|
|
898
|
+
preRead(db, params) {
|
|
899
|
+
const pre = emptyPreState();
|
|
900
|
+
pre.entityTarget = resolveTag(db, params.target);
|
|
901
|
+
if (pre.entityTarget.resolved !== null) {
|
|
902
|
+
pre.childTags = childTagTitles(db, pre.entityTarget.resolved.uuid);
|
|
903
|
+
}
|
|
904
|
+
return pre;
|
|
905
|
+
},
|
|
906
|
+
expectedDelta(pre) {
|
|
907
|
+
return { mode: "gone", entity: "tag", uuid: pre.entityTarget?.resolved?.uuid ?? "" };
|
|
908
|
+
},
|
|
909
|
+
compile(_params, vector, pre) {
|
|
910
|
+
if (vector !== "applescript")
|
|
911
|
+
unsupportedVector(this.op, vector);
|
|
912
|
+
return osa(`delete tag id ${q(pre.entityTarget?.resolved?.uuid ?? "")}`);
|
|
913
|
+
},
|
|
914
|
+
};
|
|
915
|
+
const todoDuplicate = {
|
|
916
|
+
op: "todo.duplicate",
|
|
917
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-REPEAT-SCHEDULE"],
|
|
918
|
+
preRead(db, params) {
|
|
919
|
+
const pre = emptyPreState();
|
|
920
|
+
pre.target = loadTarget(db, params.uuid);
|
|
921
|
+
return pre;
|
|
922
|
+
},
|
|
923
|
+
expectedDelta(pre, _params, ctx) {
|
|
924
|
+
// The copy carries the same title/notes with a fresh uuid + creationDate
|
|
925
|
+
// (E07) — discover it with the create probe, assert copy fidelity.
|
|
926
|
+
const target = pre.target;
|
|
927
|
+
const title = target !== null && target.type !== "heading" ? target.title : "";
|
|
928
|
+
const notes = target !== null && target.type !== "heading" ? target.notes : "";
|
|
929
|
+
return {
|
|
930
|
+
mode: "create",
|
|
931
|
+
probe: { title, type: "to-do", sinceEpoch: ctx.nowEpoch - 2 },
|
|
932
|
+
assert: [{ field: "notes", equals: notes }],
|
|
933
|
+
};
|
|
934
|
+
},
|
|
935
|
+
compile(params, vector, _pre, ctx) {
|
|
936
|
+
// AppleScript refuses duplication outright ("can not be copied", E08).
|
|
937
|
+
if (vector !== "url-scheme")
|
|
938
|
+
unsupportedVector(this.op, vector);
|
|
939
|
+
return thingsUrl("update", { id: params.uuid, duplicate: "true" }, ctx.token);
|
|
940
|
+
},
|
|
941
|
+
};
|
|
942
|
+
const areaUpdate = {
|
|
943
|
+
op: "area.update",
|
|
944
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-UNKNOWN-TAG"],
|
|
945
|
+
preRead(db, params) {
|
|
946
|
+
if (params.title === undefined && params.tags === undefined) {
|
|
947
|
+
throw new RangeError("area.update needs title and/or tags");
|
|
948
|
+
}
|
|
949
|
+
const pre = emptyPreState();
|
|
950
|
+
pre.entityTarget = resolveArea(db, { title: params.target, uuid: params.target });
|
|
951
|
+
if (params.tags !== undefined)
|
|
952
|
+
pre.missingTags = missingTagTitles(db, params.tags);
|
|
953
|
+
return pre;
|
|
954
|
+
},
|
|
955
|
+
expectedDelta(pre, params) {
|
|
956
|
+
const assert = [];
|
|
957
|
+
if (params.title !== undefined)
|
|
958
|
+
assert.push({ field: "title", equals: params.title });
|
|
959
|
+
if (params.tags !== undefined)
|
|
960
|
+
assert.push({ field: "tags", equals: sortedTags(params.tags) });
|
|
961
|
+
return {
|
|
962
|
+
mode: "entity-updated",
|
|
963
|
+
entity: "area",
|
|
964
|
+
uuid: pre.entityTarget?.resolved?.uuid ?? "",
|
|
965
|
+
assert,
|
|
966
|
+
};
|
|
967
|
+
},
|
|
968
|
+
compile(params, vector, pre) {
|
|
969
|
+
if (vector !== "applescript")
|
|
970
|
+
unsupportedVector(this.op, vector);
|
|
971
|
+
const id = q(pre.entityTarget?.resolved?.uuid ?? "");
|
|
972
|
+
const lines = [];
|
|
973
|
+
if (params.title !== undefined)
|
|
974
|
+
lines.push(`set name of area id ${id} to ${q(params.title)}`);
|
|
975
|
+
if (params.tags !== undefined) {
|
|
976
|
+
lines.push(`set tag names of area id ${id} to ${q(params.tags.join(", "))}`);
|
|
977
|
+
}
|
|
978
|
+
if (lines.length === 1)
|
|
979
|
+
return osa(lines[0]);
|
|
980
|
+
const payload = `tell application "Things3"\n${lines.map((l) => ` ${l}`).join("\n")}\nend tell`;
|
|
981
|
+
return { vector: "applescript", kind: "osascript", payload, redactedPayload: payload };
|
|
982
|
+
},
|
|
983
|
+
};
|
|
984
|
+
const tagUpdate = {
|
|
985
|
+
op: "tag.update",
|
|
986
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-UNKNOWN-TAG"],
|
|
987
|
+
preRead(db, params) {
|
|
988
|
+
if (params.title === undefined &&
|
|
989
|
+
params.parent === undefined &&
|
|
990
|
+
params.unnest === undefined &&
|
|
991
|
+
params.shortcut === undefined &&
|
|
992
|
+
params.clearShortcut === undefined) {
|
|
993
|
+
throw new RangeError("tag.update needs title, parent, unnest, shortcut, and/or clearShortcut");
|
|
994
|
+
}
|
|
995
|
+
if (params.parent !== undefined && params.unnest === true) {
|
|
996
|
+
throw new RangeError("parent and unnest are exclusive");
|
|
997
|
+
}
|
|
998
|
+
if (params.shortcut !== undefined && params.clearShortcut === true) {
|
|
999
|
+
throw new RangeError("shortcut and clearShortcut are exclusive");
|
|
1000
|
+
}
|
|
1001
|
+
const pre = emptyPreState();
|
|
1002
|
+
pre.entityTarget = resolveTag(db, params.target);
|
|
1003
|
+
if (params.parent !== undefined) {
|
|
1004
|
+
pre.parentTag = resolveTag(db, params.parent);
|
|
1005
|
+
if (pre.parentTag.resolved === null)
|
|
1006
|
+
pre.missingTags = [params.parent];
|
|
1007
|
+
}
|
|
1008
|
+
return pre;
|
|
1009
|
+
},
|
|
1010
|
+
expectedDelta(pre, params) {
|
|
1011
|
+
const assert = [];
|
|
1012
|
+
if (params.title !== undefined)
|
|
1013
|
+
assert.push({ field: "title", equals: params.title });
|
|
1014
|
+
if (params.parent !== undefined) {
|
|
1015
|
+
assert.push({ field: "parent", equals: pre.parentTag?.resolved?.uuid ?? "" });
|
|
1016
|
+
}
|
|
1017
|
+
if (params.unnest === true)
|
|
1018
|
+
assert.push({ field: "parent", equals: null });
|
|
1019
|
+
if (params.shortcut !== undefined)
|
|
1020
|
+
assert.push({ field: "shortcut", equals: params.shortcut });
|
|
1021
|
+
if (params.clearShortcut === true)
|
|
1022
|
+
assert.push({ field: "shortcut", equals: null });
|
|
1023
|
+
return {
|
|
1024
|
+
mode: "entity-updated",
|
|
1025
|
+
entity: "tag",
|
|
1026
|
+
uuid: pre.entityTarget?.resolved?.uuid ?? "",
|
|
1027
|
+
assert,
|
|
1028
|
+
};
|
|
1029
|
+
},
|
|
1030
|
+
compile(params, vector, pre) {
|
|
1031
|
+
if (vector !== "applescript")
|
|
1032
|
+
unsupportedVector(this.op, vector);
|
|
1033
|
+
const id = q(pre.entityTarget?.resolved?.uuid ?? "");
|
|
1034
|
+
const lines = [];
|
|
1035
|
+
if (params.title !== undefined)
|
|
1036
|
+
lines.push(`set name of tag id ${id} to ${q(params.title)}`);
|
|
1037
|
+
if (params.parent !== undefined) {
|
|
1038
|
+
lines.push(`set parent tag of tag id ${id} to tag id ${q(pre.parentTag?.resolved?.uuid ?? "")}`);
|
|
1039
|
+
}
|
|
1040
|
+
if (params.unnest === true) {
|
|
1041
|
+
// The property-DELETE form is the only working un-nest spelling (P29):
|
|
1042
|
+
// `set parent tag … to missing value` errors (E19). By NAME, exactly
|
|
1043
|
+
// as probed — resolveTag already guaranteed the title is unique.
|
|
1044
|
+
lines.push(`delete parent tag of tag ${q(pre.entityTarget?.resolved?.title ?? "")}`);
|
|
1045
|
+
}
|
|
1046
|
+
if (params.shortcut !== undefined) {
|
|
1047
|
+
lines.push(`set keyboard shortcut of tag id ${id} to ${q(params.shortcut)}`);
|
|
1048
|
+
}
|
|
1049
|
+
if (params.clearShortcut === true) {
|
|
1050
|
+
// The property-DELETE form clears the shortcut (A4 — the P29 un-nest
|
|
1051
|
+
// spelling generalizes to `shortcut`; `set … to ""`/missing value has
|
|
1052
|
+
// no validated clear path). By NAME, exactly as probed.
|
|
1053
|
+
lines.push(`delete keyboard shortcut of tag ${q(pre.entityTarget?.resolved?.title ?? "")}`);
|
|
1054
|
+
}
|
|
1055
|
+
if (lines.length === 1)
|
|
1056
|
+
return osa(lines[0]);
|
|
1057
|
+
const payload = `tell application "Things3"\n${lines.map((l) => ` ${l}`).join("\n")}\nend tell`;
|
|
1058
|
+
return { vector: "applescript", kind: "osascript", payload, redactedPayload: payload };
|
|
1059
|
+
},
|
|
1060
|
+
};
|
|
1061
|
+
const reorder = {
|
|
1062
|
+
op: "reorder",
|
|
1063
|
+
hazards: ["H-UNKNOWN-DESTINATION", "H-REORDER-SCOPE"],
|
|
1064
|
+
preRead(db, params, now) {
|
|
1065
|
+
const pre = emptyPreState();
|
|
1066
|
+
let containerUuid = null;
|
|
1067
|
+
if (params.scope === "project" || params.scope === "headings") {
|
|
1068
|
+
pre.destProject = resolveProject(db, params.container ?? {});
|
|
1069
|
+
containerUuid = pre.destProject.resolved?.uuid ?? null;
|
|
1070
|
+
}
|
|
1071
|
+
if (params.scope === "area") {
|
|
1072
|
+
pre.destArea = resolveArea(db, params.container ?? {});
|
|
1073
|
+
containerUuid = pre.destArea.resolved?.uuid ?? null;
|
|
1074
|
+
}
|
|
1075
|
+
pre.reorder = computeReorderPre(db, params, containerUuid, now);
|
|
1076
|
+
return pre;
|
|
1077
|
+
},
|
|
1078
|
+
expectedDelta(pre, params) {
|
|
1079
|
+
// Verify the REQUESTED sequence (strictly ascending ranks). The wire
|
|
1080
|
+
// list pins the unrequested tail too, but the caller's contract is the
|
|
1081
|
+
// requested prefix; tail members are covered by pre-rank tripwires.
|
|
1082
|
+
return {
|
|
1083
|
+
mode: "ordering",
|
|
1084
|
+
key: pre.reorder?.key ??
|
|
1085
|
+
(params.scope === "today" || params.scope === "evening" ? "todayIndex" : "index"),
|
|
1086
|
+
sequence: params.uuids,
|
|
1087
|
+
};
|
|
1088
|
+
},
|
|
1089
|
+
compile(params, vector, pre) {
|
|
1090
|
+
if (vector !== "applescript")
|
|
1091
|
+
unsupportedVector(this.op, vector);
|
|
1092
|
+
const specifier = params.scope === "project" || params.scope === "headings"
|
|
1093
|
+
? `project id ${q(pre.destProject?.resolved?.uuid ?? "")}`
|
|
1094
|
+
: params.scope === "area"
|
|
1095
|
+
? `area id ${q(pre.destArea?.resolved?.uuid ?? "")}`
|
|
1096
|
+
: params.scope === "inbox"
|
|
1097
|
+
? `list "Inbox"`
|
|
1098
|
+
: params.scope === "someday"
|
|
1099
|
+
? `list "Someday"`
|
|
1100
|
+
: `list "Today"`;
|
|
1101
|
+
const wire = pre.reorder?.wireList ?? params.uuids;
|
|
1102
|
+
if (params.scope === "someday") {
|
|
1103
|
+
// The Someday handler STACKS each sent id above the list's current top
|
|
1104
|
+
// (the current top itself never moves) — P6h/P7e/P8b. The validated
|
|
1105
|
+
// two-call protocol realizes an exact order: (1) push the desired
|
|
1106
|
+
// BOTTOM item to the top, making it the anchor; (2) send the reversed
|
|
1107
|
+
// wire list — the anchor (first id) stays put and the rest stack above
|
|
1108
|
+
// it in desired order (P8b: exact).
|
|
1109
|
+
const bottom = wire.at(-1) ?? "";
|
|
1110
|
+
const reversed = wire.toReversed().join(",");
|
|
1111
|
+
return osaBlock([
|
|
1112
|
+
`${PRIVATE_REORDER_COMMAND} ${specifier} with ids ${q(bottom)}`,
|
|
1113
|
+
`${PRIVATE_REORDER_COMMAND} ${specifier} with ids ${q(reversed)}`,
|
|
1114
|
+
]);
|
|
1115
|
+
}
|
|
1116
|
+
return osa(`${PRIVATE_REORDER_COMMAND} ${specifier} with ids ${q(wire.join(","))}`);
|
|
1117
|
+
},
|
|
1118
|
+
};
|
|
1119
|
+
/** Locale-proof AppleScript date literal: local noon on an ISO date. */
|
|
1120
|
+
function asDateBlock(varName, iso) {
|
|
1121
|
+
const [y, m, d] = iso.split("-").map(Number);
|
|
1122
|
+
return [
|
|
1123
|
+
`set ${varName} to current date`,
|
|
1124
|
+
`set time of ${varName} to 12 * hours`,
|
|
1125
|
+
`set day of ${varName} to 1`,
|
|
1126
|
+
`set year of ${varName} to ${y}`,
|
|
1127
|
+
`set month of ${varName} to ${m}`,
|
|
1128
|
+
`set day of ${varName} to ${d}`,
|
|
1129
|
+
];
|
|
1130
|
+
}
|
|
1131
|
+
const todoBackdate = {
|
|
1132
|
+
op: "todo.backdate",
|
|
1133
|
+
hazards: ["H-BACKDATE-OPEN"],
|
|
1134
|
+
preRead(db, params) {
|
|
1135
|
+
if (params.completionDate === undefined && params.creationDate === undefined) {
|
|
1136
|
+
throw new RangeError("nothing to backdate: give completionDate and/or creationDate");
|
|
1137
|
+
}
|
|
1138
|
+
const pre = emptyPreState();
|
|
1139
|
+
pre.target = loadTarget(db, params.uuid);
|
|
1140
|
+
return pre;
|
|
1141
|
+
},
|
|
1142
|
+
expectedDelta(_pre, params) {
|
|
1143
|
+
const assert = [];
|
|
1144
|
+
if (params.completionDate !== undefined) {
|
|
1145
|
+
assert.push({ field: "stoppedDate", equals: params.completionDate });
|
|
1146
|
+
}
|
|
1147
|
+
if (params.creationDate !== undefined) {
|
|
1148
|
+
assert.push({ field: "createdDate", equals: params.creationDate });
|
|
1149
|
+
}
|
|
1150
|
+
return { mode: "update", uuid: params.uuid, assert };
|
|
1151
|
+
},
|
|
1152
|
+
compile(params, vector) {
|
|
1153
|
+
if (vector !== "applescript")
|
|
1154
|
+
unsupportedVector(this.op, vector);
|
|
1155
|
+
const statements = [];
|
|
1156
|
+
if (params.completionDate !== undefined) {
|
|
1157
|
+
statements.push(...asDateBlock("compDate", params.completionDate), `set completion date of to do id ${q(params.uuid)} to compDate`);
|
|
1158
|
+
}
|
|
1159
|
+
if (params.creationDate !== undefined) {
|
|
1160
|
+
statements.push(...asDateBlock("createDate", params.creationDate), `set creation date of to do id ${q(params.uuid)} to createDate`);
|
|
1161
|
+
}
|
|
1162
|
+
return osaBlock(statements);
|
|
1163
|
+
},
|
|
1164
|
+
};
|
|
1165
|
+
const todoAddLogged = {
|
|
1166
|
+
op: "todo.add-logged",
|
|
1167
|
+
hazards: [],
|
|
1168
|
+
preRead(db, params) {
|
|
1169
|
+
if (params.creationDate !== undefined &&
|
|
1170
|
+
params.creationDate.localeCompare(params.completionDate) > 0) {
|
|
1171
|
+
throw new RangeError("creationDate must not be after completionDate");
|
|
1172
|
+
}
|
|
1173
|
+
const pre = emptyPreState();
|
|
1174
|
+
pre.sameTitleUuids = db.prepare("SELECT uuid FROM TMTask WHERE title = ? AND type = 0").all(params.title).map((r) => r.uuid);
|
|
1175
|
+
return pre;
|
|
1176
|
+
},
|
|
1177
|
+
expectedDelta(pre, params) {
|
|
1178
|
+
const assert = [
|
|
1179
|
+
{ field: "status", equals: "completed" },
|
|
1180
|
+
{ field: "stoppedDate", equals: params.completionDate },
|
|
1181
|
+
];
|
|
1182
|
+
if (params.creationDate !== undefined) {
|
|
1183
|
+
assert.push({ field: "createdDate", equals: params.creationDate });
|
|
1184
|
+
}
|
|
1185
|
+
if (params.notes !== undefined)
|
|
1186
|
+
assert.push({ field: "notes", equals: params.notes });
|
|
1187
|
+
return {
|
|
1188
|
+
mode: "create",
|
|
1189
|
+
probe: {
|
|
1190
|
+
title: params.title,
|
|
1191
|
+
type: "to-do",
|
|
1192
|
+
sinceEpoch: 0,
|
|
1193
|
+
excludeUuids: pre.sameTitleUuids,
|
|
1194
|
+
},
|
|
1195
|
+
assert,
|
|
1196
|
+
};
|
|
1197
|
+
},
|
|
1198
|
+
compile(params, vector, _pre, ctx) {
|
|
1199
|
+
if (vector !== "url-scheme")
|
|
1200
|
+
unsupportedVector(this.op, vector);
|
|
1201
|
+
// Local noon -> UTC instant, so the stored timestamp decodes back to the
|
|
1202
|
+
// requested local DATE in every timezone (P4d: json attrs honored exactly).
|
|
1203
|
+
const utcNoon = (iso) => {
|
|
1204
|
+
const [y, m, d] = iso.split("-").map(Number);
|
|
1205
|
+
return new Date(y ?? 0, (m ?? 1) - 1, d ?? 1, 12, 0, 0).toISOString();
|
|
1206
|
+
};
|
|
1207
|
+
const payload = JSON.stringify([
|
|
1208
|
+
{
|
|
1209
|
+
type: "to-do",
|
|
1210
|
+
attributes: {
|
|
1211
|
+
title: params.title,
|
|
1212
|
+
...(params.notes !== undefined && { notes: params.notes }),
|
|
1213
|
+
completed: true,
|
|
1214
|
+
"completion-date": utcNoon(params.completionDate),
|
|
1215
|
+
...(params.creationDate !== undefined && {
|
|
1216
|
+
"creation-date": utcNoon(params.creationDate),
|
|
1217
|
+
}),
|
|
1218
|
+
},
|
|
1219
|
+
},
|
|
1220
|
+
]);
|
|
1221
|
+
return thingsUrl("json", { data: payload }, ctx.token);
|
|
1222
|
+
},
|
|
1223
|
+
};
|
|
1224
|
+
const trashEmpty = {
|
|
1225
|
+
op: "trash.empty",
|
|
1226
|
+
hazards: ["H-PERMANENT-DELETE"],
|
|
1227
|
+
preRead(db) {
|
|
1228
|
+
const pre = emptyPreState();
|
|
1229
|
+
pre.trashedCount = trashedCount(db);
|
|
1230
|
+
return pre;
|
|
1231
|
+
},
|
|
1232
|
+
expectedDelta() {
|
|
1233
|
+
return { mode: "trash-emptied" };
|
|
1234
|
+
},
|
|
1235
|
+
compile(_params, vector) {
|
|
1236
|
+
if (vector !== "applescript")
|
|
1237
|
+
unsupportedVector(this.op, vector);
|
|
1238
|
+
return osa("empty trash");
|
|
1239
|
+
},
|
|
1240
|
+
};
|
|
1241
|
+
export const COMMANDS = {
|
|
1242
|
+
"todo.add": todoAdd,
|
|
1243
|
+
"todo.update": todoUpdate,
|
|
1244
|
+
"todo.complete": todoComplete,
|
|
1245
|
+
"todo.cancel": todoCancel,
|
|
1246
|
+
"todo.reopen": todoReopen,
|
|
1247
|
+
"todo.move": todoMove,
|
|
1248
|
+
"todo.set-tags": todoSetTags,
|
|
1249
|
+
"todo.replace-checklist": todoReplaceChecklist,
|
|
1250
|
+
"todo.delete": todoDelete,
|
|
1251
|
+
"project.add": projectAdd,
|
|
1252
|
+
"project.update": projectUpdate,
|
|
1253
|
+
"project.complete": projectComplete,
|
|
1254
|
+
"project.delete": projectDelete,
|
|
1255
|
+
"area.add": areaAdd,
|
|
1256
|
+
"area.delete": areaDelete,
|
|
1257
|
+
"tag.add": tagAdd,
|
|
1258
|
+
"tag.delete": tagDelete,
|
|
1259
|
+
"trash.empty": trashEmpty,
|
|
1260
|
+
reorder,
|
|
1261
|
+
"todo.duplicate": todoDuplicate,
|
|
1262
|
+
"area.update": areaUpdate,
|
|
1263
|
+
"tag.update": tagUpdate,
|
|
1264
|
+
"project.move": projectMove,
|
|
1265
|
+
"todo.restore": todoRestore,
|
|
1266
|
+
"project.duplicate": projectDuplicate,
|
|
1267
|
+
"project.cancel": projectCancel,
|
|
1268
|
+
"project.reopen": projectReopen,
|
|
1269
|
+
"project.restore": projectRestore,
|
|
1270
|
+
"project.set-tags": projectSetTags,
|
|
1271
|
+
"todo.backdate": todoBackdate,
|
|
1272
|
+
"todo.add-logged": todoAddLogged,
|
|
1273
|
+
};
|
|
1274
|
+
//# sourceMappingURL=commands.js.map
|