things-api 0.9.0 → 0.11.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/README.md +57 -12
- package/dist/audit/log.js +56 -2
- package/dist/audit/log.js.map +1 -1
- package/dist/audit/schema.d.ts +26 -8
- package/dist/audit/schema.js +42 -1
- package/dist/audit/schema.js.map +1 -1
- package/dist/cli/clock.d.ts +8 -0
- package/dist/cli/clock.js +24 -0
- package/dist/cli/clock.js.map +1 -0
- package/dist/cli/commands/area.d.ts +26 -3
- package/dist/cli/commands/area.js +129 -75
- package/dist/cli/commands/area.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts +1 -1
- package/dist/cli/commands/doctor.js +44 -5
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/install-skill.d.ts +45 -0
- package/dist/cli/commands/install-skill.js +207 -0
- package/dist/cli/commands/install-skill.js.map +1 -0
- package/dist/cli/commands/mcp.js +23 -4
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/project.d.ts +18 -1
- package/dist/cli/commands/project.js +74 -35
- package/dist/cli/commands/project.js.map +1 -1
- package/dist/cli/commands/reads.d.ts +15 -2
- package/dist/cli/commands/reads.js +459 -165
- package/dist/cli/commands/reads.js.map +1 -1
- package/dist/cli/commands/repeat-flags.d.ts +20 -0
- package/dist/cli/commands/repeat-flags.js +76 -0
- package/dist/cli/commands/repeat-flags.js.map +1 -0
- package/dist/cli/commands/setup.js +8 -4
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/show.js +53 -30
- package/dist/cli/commands/show.js.map +1 -1
- package/dist/cli/commands/todo.d.ts +1 -1
- package/dist/cli/commands/todo.js +20 -13
- package/dist/cli/commands/todo.js.map +1 -1
- package/dist/cli/commands/writes.js +388 -161
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/did-you-mean.d.ts +1 -1
- package/dist/cli/excess-args.d.ts +15 -0
- package/dist/cli/excess-args.js +51 -0
- package/dist/cli/excess-args.js.map +1 -0
- package/dist/cli/glyphs.d.ts +55 -2
- package/dist/cli/glyphs.js +114 -21
- package/dist/cli/glyphs.js.map +1 -1
- package/dist/cli/help.d.ts +54 -0
- package/dist/cli/help.js +405 -0
- package/dist/cli/help.js.map +1 -0
- package/dist/cli/main.js +64 -26
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/move-hint.d.ts +45 -0
- package/dist/cli/move-hint.js +196 -0
- package/dist/cli/move-hint.js.map +1 -0
- package/dist/cli/period.d.ts +9 -8
- package/dist/cli/period.js +56 -30
- package/dist/cli/period.js.map +1 -1
- package/dist/cli/read-driver.d.ts +26 -19
- package/dist/cli/read-driver.js +77 -33
- package/dist/cli/read-driver.js.map +1 -1
- package/dist/cli/render.d.ts +92 -28
- package/dist/cli/render.js +333 -103
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/resolve-invocation.d.ts +42 -4
- package/dist/cli/resolve-invocation.js +97 -14
- package/dist/cli/resolve-invocation.js.map +1 -1
- package/dist/cli/skill-check.d.ts +21 -0
- package/dist/cli/skill-check.js +82 -0
- package/dist/cli/skill-check.js.map +1 -0
- package/dist/cli/skill.d.ts +62 -0
- package/dist/cli/skill.js +142 -0
- package/dist/cli/skill.js.map +1 -0
- package/dist/cli/tag-filters.d.ts +63 -0
- package/dist/cli/tag-filters.js +59 -0
- package/dist/cli/tag-filters.js.map +1 -0
- package/dist/cli/verb-hint.d.ts +25 -0
- package/dist/cli/verb-hint.js +163 -0
- package/dist/cli/verb-hint.js.map +1 -0
- package/dist/cli/version.d.ts +4 -0
- package/dist/cli/version.js +18 -0
- package/dist/cli/version.js.map +1 -0
- package/dist/cli/width.d.ts +135 -0
- package/dist/cli/width.js +313 -0
- package/dist/cli/width.js.map +1 -0
- package/dist/client.d.ts +160 -24
- package/dist/client.js +132 -19
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -1
- package/dist/contracts.d.ts +97 -17
- package/dist/contracts.js +44 -1
- package/dist/contracts.js.map +1 -1
- package/dist/db/fingerprint.d.ts +12 -0
- package/dist/db/fingerprint.js +15 -1
- package/dist/db/fingerprint.js.map +1 -1
- package/dist/db/locate.js +1 -1
- package/dist/db/locate.js.map +1 -1
- package/dist/diagnose.d.ts +63 -0
- package/dist/diagnose.js +38 -1
- package/dist/diagnose.js.map +1 -1
- package/dist/index.d.ts +36 -5
- package/dist/index.js +38 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +9 -1
- package/dist/mcp/server.js +1038 -436
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/clock.d.ts +61 -0
- package/dist/model/clock.js +71 -0
- package/dist/model/clock.js.map +1 -0
- package/dist/model/dates.d.ts +26 -3
- package/dist/model/dates.js +84 -3
- package/dist/model/dates.js.map +1 -1
- package/dist/model/entities.d.ts +48 -10
- package/dist/model/entities.js.map +1 -1
- package/dist/model/mappers.d.ts +9 -2
- package/dist/model/mappers.js +32 -9
- package/dist/model/mappers.js.map +1 -1
- package/dist/model/serialize.d.ts +28 -0
- package/dist/model/serialize.js +80 -0
- package/dist/model/serialize.js.map +1 -0
- package/dist/model/when-sugar.d.ts +47 -0
- package/dist/model/when-sugar.js +45 -0
- package/dist/model/when-sugar.js.map +1 -0
- package/dist/read/area-view.d.ts +8 -1
- package/dist/read/area-view.js +55 -17
- package/dist/read/area-view.js.map +1 -1
- package/dist/read/detail.d.ts +1 -1
- package/dist/read/detail.js +11 -4
- package/dist/read/detail.js.map +1 -1
- package/dist/read/filter-contract.d.ts +117 -0
- package/dist/read/filter-contract.js +78 -0
- package/dist/read/filter-contract.js.map +1 -0
- package/dist/read/log-boundary.d.ts +1 -1
- package/dist/read/log-boundary.js +13 -3
- package/dist/read/log-boundary.js.map +1 -1
- package/dist/read/predicates.d.ts +30 -0
- package/dist/read/predicates.js +30 -0
- package/dist/read/predicates.js.map +1 -1
- package/dist/read/project-view.d.ts +12 -1
- package/dist/read/project-view.js +45 -12
- package/dist/read/project-view.js.map +1 -1
- package/dist/read/queries.d.ts +152 -7
- package/dist/read/queries.js +277 -36
- package/dist/read/queries.js.map +1 -1
- package/dist/read/sections.d.ts +51 -0
- package/dist/read/sections.js +37 -0
- package/dist/read/sections.js.map +1 -0
- package/dist/read/sidebar-order.js +2 -1
- package/dist/read/sidebar-order.js.map +1 -1
- package/dist/read/snapshot.d.ts +1 -1
- package/dist/read/snapshot.js +9 -2
- package/dist/read/snapshot.js.map +1 -1
- package/dist/read/tags.d.ts +27 -3
- package/dist/read/tags.js +83 -12
- package/dist/read/tags.js.map +1 -1
- package/dist/read/truncation.d.ts +71 -0
- package/dist/read/{pagination.js → truncation.js} +103 -94
- package/dist/read/truncation.js.map +1 -0
- package/dist/read/views.d.ts +147 -22
- package/dist/read/views.js +282 -80
- package/dist/read/views.js.map +1 -1
- package/dist/surface-copy.d.ts +19 -0
- package/dist/surface-copy.js +32 -0
- package/dist/surface-copy.js.map +1 -1
- package/dist/sync-health.d.ts +78 -0
- package/dist/sync-health.js +312 -0
- package/dist/sync-health.js.map +1 -0
- package/dist/write/accessibility-probe.d.ts +12 -0
- package/dist/write/accessibility-probe.js +71 -0
- package/dist/write/accessibility-probe.js.map +1 -0
- package/dist/write/automation-probe.d.ts +8 -1
- package/dist/write/automation-probe.js +16 -1
- package/dist/write/automation-probe.js.map +1 -1
- package/dist/write/batch.js +5 -2
- package/dist/write/batch.js.map +1 -1
- package/dist/write/capabilities.d.ts +8 -0
- package/dist/write/capabilities.js +13 -6
- package/dist/write/capabilities.js.map +1 -1
- package/dist/write/commands.d.ts +3 -1
- package/dist/write/commands.js +602 -49
- package/dist/write/commands.js.map +1 -1
- package/dist/write/edit-checklist.js +3 -2
- package/dist/write/edit-checklist.js.map +1 -1
- package/dist/write/guards.d.ts +2 -7
- package/dist/write/guards.js +87 -6
- package/dist/write/guards.js.map +1 -1
- package/dist/write/heading.js +2 -0
- package/dist/write/heading.js.map +1 -1
- package/dist/write/lock.d.ts +40 -2
- package/dist/write/lock.js +91 -14
- package/dist/write/lock.js.map +1 -1
- package/dist/write/make-repeating-project.d.ts +4 -0
- package/dist/write/make-repeating-project.js +253 -0
- package/dist/write/make-repeating-project.js.map +1 -0
- package/dist/write/operations.d.ts +145 -1
- package/dist/write/operations.js +48 -0
- package/dist/write/operations.js.map +1 -1
- package/dist/write/pipeline.d.ts +73 -2
- package/dist/write/pipeline.js +218 -43
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/planner.js +8 -1
- package/dist/write/planner.js.map +1 -1
- package/dist/write/pre-state.d.ts +105 -3
- package/dist/write/pre-state.js +144 -3
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/recurrence-rule-blob.d.ts +65 -0
- package/dist/write/recurrence-rule-blob.js +150 -0
- package/dist/write/recurrence-rule-blob.js.map +1 -0
- package/dist/write/reopen.js +3 -2
- package/dist/write/reopen.js.map +1 -1
- package/dist/write/reorder.js +27 -17
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/repeat-rule.d.ts +22 -0
- package/dist/write/repeat-rule.js +258 -0
- package/dist/write/repeat-rule.js.map +1 -0
- package/dist/write/reversibility.js +49 -0
- package/dist/write/reversibility.js.map +1 -1
- package/dist/write/tag-refs.d.ts +47 -0
- package/dist/write/tag-refs.js +126 -0
- package/dist/write/tag-refs.js.map +1 -0
- package/dist/write/undo.d.ts +74 -5
- package/dist/write/undo.js +493 -80
- package/dist/write/undo.js.map +1 -1
- package/dist/write/vectors/registry.d.ts +15 -1
- package/dist/write/vectors/registry.js +65 -2
- package/dist/write/vectors/registry.js.map +1 -1
- package/dist/write/vectors/simulator.d.ts +44 -0
- package/dist/write/vectors/simulator.js +1136 -0
- package/dist/write/vectors/simulator.js.map +1 -0
- package/dist/write/vectors/types.d.ts +154 -3
- package/dist/write/vectors/ui-certification.d.ts +54 -0
- package/dist/write/vectors/ui-certification.js +43 -0
- package/dist/write/vectors/ui-certification.js.map +1 -0
- package/dist/write/vectors/ui-drag.d.ts +188 -0
- package/dist/write/vectors/ui-drag.js +1095 -0
- package/dist/write/vectors/ui-drag.js.map +1 -0
- package/dist/write/vectors/ui-recipes.d.ts +83 -0
- package/dist/write/vectors/ui-recipes.js +610 -0
- package/dist/write/vectors/ui-recipes.js.map +1 -0
- package/dist/write/vectors/ui.d.ts +152 -0
- package/dist/write/vectors/ui.js +704 -0
- package/dist/write/vectors/ui.js.map +1 -0
- package/dist/write/verify/delta.d.ts +141 -4
- package/dist/write/verify/delta.js +199 -21
- package/dist/write/verify/delta.js.map +1 -1
- package/dist/write/verify/poller.d.ts +7 -1
- package/dist/write/verify/poller.js +21 -1
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +18 -3
- package/skills/things-cli/SKILL.md +53 -0
- package/skills/things-cli/references/contracts.md +41 -0
- package/skills/things-cli/references/gui.md +11 -0
- package/skills/things-cli/references/model.md +51 -0
- package/dist/cli/exit-codes.d.ts +0 -26
- package/dist/cli/exit-codes.js +0 -26
- package/dist/cli/exit-codes.js.map +0 -1
- package/dist/cli/output.d.ts +0 -42
- package/dist/cli/output.js +0 -16
- package/dist/cli/output.js.map +0 -1
- package/dist/read/pagination.d.ts +0 -104
- package/dist/read/pagination.js.map +0 -1
|
@@ -0,0 +1,1136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SIMULATOR write vector (bench harness, Phase 0). It presents to the
|
|
3
|
+
* pipeline exactly like the real `url-scheme` transport but, instead of handing
|
|
4
|
+
* a payload to the OS, applies the mutation with SQL against a SYNTHETIC fixture
|
|
5
|
+
* database — so the whole write pipeline (guards → plan → execute → verified
|
|
6
|
+
* read-after-write → audit) can run end-to-end with no Things app installed.
|
|
7
|
+
*
|
|
8
|
+
* It NEVER parses the compiled payload: every applier reads the STRUCTURED
|
|
9
|
+
* `invocation.op` / `invocation.opParams` the pipeline attaches after compile.
|
|
10
|
+
* Its own read-write `node:sqlite` connection is separate from the pipeline's
|
|
11
|
+
* (which stays read-only); both point at the same WAL file, so the pipeline's
|
|
12
|
+
* verification poller observes each committed applier write.
|
|
13
|
+
*
|
|
14
|
+
* SAFETY — a triple fence (checked at creation AND on first execute) keeps this
|
|
15
|
+
* pointed only at a disposable bench DB, never at a real Things database:
|
|
16
|
+
* 1. env `THINGS_SIM_WRITES=1`,
|
|
17
|
+
* 2. env `THINGS_DB` set and byte-equal to the dbPath in use (never a
|
|
18
|
+
* container-glob-located production path),
|
|
19
|
+
* 3. the DB's `Meta` table carries `benchFixture` = "1".
|
|
20
|
+
* When any check fails, {@link defaultVectors} omits the simulator and
|
|
21
|
+
* {@link createSimulatorVector} refuses (throws at creation; execute refuses
|
|
22
|
+
* defensively should the environment change underneath a live instance).
|
|
23
|
+
*/
|
|
24
|
+
import { randomBytes } from "node:crypto";
|
|
25
|
+
import { DatabaseSync } from "node:sqlite";
|
|
26
|
+
import { readDatabaseVersion } from "../../db/fingerprint.js";
|
|
27
|
+
import { addDaysIso, decodePackedDate, encodePackedDate, encodeReminderTime, localToday, } from "../../model/dates.js";
|
|
28
|
+
import { decodeRecurrenceRule } from "../../model/recurrence.js";
|
|
29
|
+
import { resolveArea, resolveHeading, resolveProject, resolveTag } from "../pre-state.js";
|
|
30
|
+
import { composeRepeatRuleSpec, ruleXml } from "../recurrence-rule-blob.js";
|
|
31
|
+
import { resolveTagRefs } from "../tag-refs.js";
|
|
32
|
+
// Enum encodings mirror test/fixtures/seed.ts / docs/atlas/schema-v26.md.
|
|
33
|
+
const STATUS = { open: 0, canceled: 2, completed: 3 };
|
|
34
|
+
const START = { inbox: 0, active: 1, someday: 2 };
|
|
35
|
+
/** Substring signature of the production group container — never a bench path. */
|
|
36
|
+
const PROD_CONTAINER = "Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac";
|
|
37
|
+
// --------------------------------------------------------------- fence
|
|
38
|
+
/**
|
|
39
|
+
* The fence reason, or null when the simulator may run against `dbPath`. Pure
|
|
40
|
+
* with respect to `env`; opens `dbPath` READ-ONLY only to read the marker.
|
|
41
|
+
*/
|
|
42
|
+
export function simulatorFenceReason(dbPath, env = process.env) {
|
|
43
|
+
if (env["THINGS_SIM_WRITES"] !== "1") {
|
|
44
|
+
return "THINGS_SIM_WRITES is not set to 1";
|
|
45
|
+
}
|
|
46
|
+
const thingsDb = env["THINGS_DB"];
|
|
47
|
+
if (thingsDb === undefined || thingsDb.trim() === "") {
|
|
48
|
+
return "THINGS_DB is not set";
|
|
49
|
+
}
|
|
50
|
+
if (thingsDb !== dbPath) {
|
|
51
|
+
return `THINGS_DB (${thingsDb}) does not equal the simulator dbPath (${dbPath})`;
|
|
52
|
+
}
|
|
53
|
+
if (dbPath.includes(PROD_CONTAINER)) {
|
|
54
|
+
return "the dbPath points at the production Things group container";
|
|
55
|
+
}
|
|
56
|
+
if (!hasBenchMarker(dbPath)) {
|
|
57
|
+
return `the database at ${dbPath} carries no Meta.benchFixture marker`;
|
|
58
|
+
}
|
|
59
|
+
// Scratch state/config dirs are part of the fence (2026-07-17 incident): a
|
|
60
|
+
// simulated run without them appends bench audit records into the operator's
|
|
61
|
+
// real audit trail and reads their real config profile.
|
|
62
|
+
for (const key of ["THINGS_API_STATE_DIR", "THINGS_API_CONFIG_DIR"]) {
|
|
63
|
+
const dir = env[key];
|
|
64
|
+
if (dir === undefined || dir.trim() === "") {
|
|
65
|
+
return `${key} is not set — simulated runs must use scratch state/config dirs, never the operator's real ones`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const version = fixtureDatabaseVersion(dbPath);
|
|
69
|
+
if (version !== SIMULATED_DATABASE_VERSION) {
|
|
70
|
+
return (`the fixture reports databaseVersion ${version ?? "unknown"} but the simulator's ` +
|
|
71
|
+
`appliers model version ${SIMULATED_DATABASE_VERSION} — a Things schema change must be ` +
|
|
72
|
+
`re-modeled in lockstep (atlas → seed builders → simulator appliers → bench world/corpus; ` +
|
|
73
|
+
`see docs/lab/drift-runbook.md) before simulated writes may resume`);
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The Things schema generation the appliers are written against. When a
|
|
79
|
+
* Things update bumps the real database version, the fence refuses to
|
|
80
|
+
* simulate until the whole modeling chain is consciously re-verified —
|
|
81
|
+
* a schema tripwire, not a compatibility claim.
|
|
82
|
+
*/
|
|
83
|
+
export const SIMULATED_DATABASE_VERSION = 26;
|
|
84
|
+
function fixtureDatabaseVersion(dbPath) {
|
|
85
|
+
let db;
|
|
86
|
+
try {
|
|
87
|
+
db = new DatabaseSync(dbPath, { readOnly: true, timeout: 2000 });
|
|
88
|
+
return readDatabaseVersion(db);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
db?.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Whether the simulator's triple fence is satisfied for `dbPath`. */
|
|
98
|
+
export function simulatorFenceActive(dbPath, env = process.env) {
|
|
99
|
+
return simulatorFenceReason(dbPath, env) === null;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Whether the simulator fence is active for the ambient environment — the
|
|
103
|
+
* single signal every host-escaping code path (reveal `open`, setup install
|
|
104
|
+
* sheets, the live-app doctor probes, the pipeline's app-launch) consults so a
|
|
105
|
+
* bench run never touches the real Things/Shortcuts app. True only when
|
|
106
|
+
* THINGS_SIM_WRITES=1 AND THINGS_DB names a fenced bench fixture (marker
|
|
107
|
+
* present, not the production container); false — the ordinary path — otherwise.
|
|
108
|
+
*/
|
|
109
|
+
export function simFenceActive(env = process.env) {
|
|
110
|
+
const dbPath = env["THINGS_DB"];
|
|
111
|
+
if (dbPath === undefined || dbPath.trim() === "")
|
|
112
|
+
return false;
|
|
113
|
+
return simulatorFenceActive(dbPath, env);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Whether the database at `dbPath` carries the bench-fixture marker — i.e. it
|
|
117
|
+
* is a synthetic bench DB by construction. Consulted by defaultVectors as a
|
|
118
|
+
* fail-closed backstop (a marked DB must never be paired with real write
|
|
119
|
+
* transports) and by the reveal gate. False on any read error.
|
|
120
|
+
*/
|
|
121
|
+
export function dbCarriesBenchMarker(dbPath) {
|
|
122
|
+
return hasBenchMarker(dbPath);
|
|
123
|
+
}
|
|
124
|
+
function hasBenchMarker(dbPath) {
|
|
125
|
+
let db;
|
|
126
|
+
try {
|
|
127
|
+
db = new DatabaseSync(dbPath, { readOnly: true, timeout: 2000 });
|
|
128
|
+
const row = db.prepare("SELECT value FROM Meta WHERE key = 'benchFixture'").get();
|
|
129
|
+
return row?.value === "1";
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
db?.close();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// ------------------------------------------------------------- uuids
|
|
139
|
+
const BASE62 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
140
|
+
/** A fresh 22-char base62 id — the shape of a real Things object uuid. */
|
|
141
|
+
function genUuid() {
|
|
142
|
+
const bytes = randomBytes(22);
|
|
143
|
+
let out = "";
|
|
144
|
+
for (let i = 0; i < 22; i++)
|
|
145
|
+
out += BASE62[(bytes[i] ?? 0) % 62];
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
// ---------------------------------------------------- shared appliers
|
|
149
|
+
/** Full TMTask row insert, mirroring test/fixtures/seed.ts insertTask columns. */
|
|
150
|
+
function insertTask(sim, type, ctx, opts) {
|
|
151
|
+
sim
|
|
152
|
+
.prepare(`INSERT INTO TMTask (
|
|
153
|
+
uuid, type, status, stopDate, trashed, title, notes,
|
|
154
|
+
creationDate, userModificationDate,
|
|
155
|
+
start, startDate, startBucket, reminderTime, deadline, deadlineSuppressionDate,
|
|
156
|
+
"index", todayIndex, todayIndexReferenceDate, area, project, heading,
|
|
157
|
+
untrashedLeafActionsCount, openUntrashedLeafActionsCount,
|
|
158
|
+
checklistItemsCount, openChecklistItemsCount,
|
|
159
|
+
rt1_repeatingTemplate, rt1_recurrenceRule,
|
|
160
|
+
rt1_nextInstanceStartDate, rt1_instanceCreationPaused, repeater
|
|
161
|
+
) VALUES (?, ?, ?, NULL, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, 0, NULL, ?, ?, ?, 0, 0, ?, ?, NULL, NULL, NULL, 0, NULL)`)
|
|
162
|
+
.run(opts.uuid, type, STATUS[opts.status ?? "open"], opts.title, opts.notes ?? "", ctx.nowEpoch, ctx.nowEpoch, opts.start ?? START.active, opts.startDate ?? null, opts.startBucket ?? 0, opts.reminderTime ?? null, opts.deadline ?? null, opts.area ?? null, opts.project ?? null, opts.heading ?? null, opts.checklistItemsCount ?? 0, opts.openChecklistItemsCount ?? 0);
|
|
163
|
+
}
|
|
164
|
+
/** The (start, startDate, startBucket) triple a `when` value materializes. */
|
|
165
|
+
function scheduleColumns(when, todayIso, hasContainer) {
|
|
166
|
+
const today = encodePackedDate(todayIso);
|
|
167
|
+
switch (when) {
|
|
168
|
+
case undefined:
|
|
169
|
+
// No schedule requested — Inbox for a bare add, the container's Anytime
|
|
170
|
+
// otherwise (only startDate is ever asserted, so this is plausibility).
|
|
171
|
+
return { start: hasContainer ? START.active : START.inbox, startDate: null, startBucket: 0 };
|
|
172
|
+
case "today":
|
|
173
|
+
return { start: START.active, startDate: today, startBucket: 0 };
|
|
174
|
+
case "evening":
|
|
175
|
+
return { start: START.active, startDate: today, startBucket: 1 };
|
|
176
|
+
case "anytime":
|
|
177
|
+
return { start: START.active, startDate: null, startBucket: 0 };
|
|
178
|
+
case "someday":
|
|
179
|
+
return { start: START.someday, startDate: null, startBucket: 0 };
|
|
180
|
+
default:
|
|
181
|
+
// A concrete YYYY-MM-DD: only the date is invariant (start-state semantics
|
|
182
|
+
// differ for past/future dates and are not asserted).
|
|
183
|
+
return { start: START.active, startDate: encodePackedDate(when), startBucket: 0 };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function containerUuid(sim, ref, kind) {
|
|
187
|
+
if (ref === undefined || (ref.uuid === undefined && ref.title === undefined))
|
|
188
|
+
return null;
|
|
189
|
+
const res = kind === "project" ? resolveProject(sim, ref) : resolveArea(sim, ref);
|
|
190
|
+
if (res.resolved === null)
|
|
191
|
+
throw new Error(`simulator: unresolved ${kind} reference`);
|
|
192
|
+
return res.resolved.uuid;
|
|
193
|
+
}
|
|
194
|
+
/** Resolve each tag ref to a TMTag uuid (leaf title match), skipping unknowns. */
|
|
195
|
+
function tagUuids(sim, refs) {
|
|
196
|
+
const { titles } = resolveTagRefs(sim, refs);
|
|
197
|
+
const uuids = [];
|
|
198
|
+
for (const title of titles) {
|
|
199
|
+
const row = sim
|
|
200
|
+
.prepare("SELECT uuid FROM TMTag WHERE title = ? COLLATE NOCASE LIMIT 1")
|
|
201
|
+
.get(title);
|
|
202
|
+
if (row !== undefined)
|
|
203
|
+
uuids.push(row.uuid);
|
|
204
|
+
}
|
|
205
|
+
return uuids;
|
|
206
|
+
}
|
|
207
|
+
function setTaskTags(sim, taskUuid, refs) {
|
|
208
|
+
sim.prepare("DELETE FROM TMTaskTag WHERE tasks = ?").run(taskUuid);
|
|
209
|
+
for (const tagUuid of tagUuids(sim, refs)) {
|
|
210
|
+
sim.prepare("INSERT INTO TMTaskTag (tasks, tags) VALUES (?, ?)").run(taskUuid, tagUuid);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Recreate a to-do's checklist wholesale (all items open unless flagged). */
|
|
214
|
+
function replaceChecklist(sim, taskUuid, items, ctx) {
|
|
215
|
+
sim.prepare("DELETE FROM TMChecklistItem WHERE task = ?").run(taskUuid);
|
|
216
|
+
let open = 0;
|
|
217
|
+
items.forEach((item, i) => {
|
|
218
|
+
const title = typeof item === "string" ? item : item.title;
|
|
219
|
+
const completed = typeof item !== "string" && item.completed === true;
|
|
220
|
+
if (!completed)
|
|
221
|
+
open++;
|
|
222
|
+
sim
|
|
223
|
+
.prepare(`INSERT INTO TMChecklistItem (uuid, userModificationDate, creationDate, title, status, stopDate, "index", task, leavesTombstone)
|
|
224
|
+
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, 0)`)
|
|
225
|
+
.run(genUuid(), ctx.nowEpoch, ctx.nowEpoch, title, completed ? STATUS.completed : STATUS.open, i, taskUuid);
|
|
226
|
+
});
|
|
227
|
+
sim
|
|
228
|
+
.prepare("UPDATE TMTask SET checklistItemsCount = ?, openChecklistItemsCount = ?, userModificationDate = ? WHERE uuid = ?")
|
|
229
|
+
.run(items.length, open, ctx.nowEpoch, taskUuid);
|
|
230
|
+
}
|
|
231
|
+
/** The joined notes an append/prepend update should leave (mirrors expectedNotes). */
|
|
232
|
+
function joinedNotes(sim, uuid, params) {
|
|
233
|
+
if (params.appendNotes === undefined && params.prependNotes === undefined)
|
|
234
|
+
return undefined;
|
|
235
|
+
const row = sim.prepare("SELECT notes FROM TMTask WHERE uuid = ?").get(uuid);
|
|
236
|
+
const current = row?.notes ?? "";
|
|
237
|
+
if (params.appendNotes !== undefined) {
|
|
238
|
+
return current === "" ? params.appendNotes : `${current}\n${params.appendNotes}`;
|
|
239
|
+
}
|
|
240
|
+
const prepend = params.prependNotes ?? "";
|
|
241
|
+
return current === "" ? prepend : `${prepend}\n${current}`;
|
|
242
|
+
}
|
|
243
|
+
/** The reminderTime packed value an update leaves (mirrors effectiveReminder). */
|
|
244
|
+
function effectiveReminderValue(sim, uuid, params) {
|
|
245
|
+
if (params.reminder !== undefined) {
|
|
246
|
+
return params.reminder === null ? null : encodeReminderTime(params.reminder);
|
|
247
|
+
}
|
|
248
|
+
const when = params.when;
|
|
249
|
+
const schedulable = when === "today" ||
|
|
250
|
+
when === "evening" ||
|
|
251
|
+
(typeof when === "string" && /^\d{4}-\d{2}-\d{2}$/.test(when));
|
|
252
|
+
if (!schedulable)
|
|
253
|
+
return null;
|
|
254
|
+
// Preserve the existing reminder across a bare re-schedule.
|
|
255
|
+
const row = sim.prepare("SELECT reminderTime FROM TMTask WHERE uuid = ?").get(uuid);
|
|
256
|
+
return row?.reminderTime ?? null;
|
|
257
|
+
}
|
|
258
|
+
/** Shared to-do/project field update (title/notes/when+reminder/deadline). */
|
|
259
|
+
function applyEntityUpdate(sim, params, ctx) {
|
|
260
|
+
const sets = [];
|
|
261
|
+
const binds = [];
|
|
262
|
+
if (params.title !== undefined) {
|
|
263
|
+
sets.push("title = ?");
|
|
264
|
+
binds.push(params.title);
|
|
265
|
+
}
|
|
266
|
+
if (params.notes !== undefined) {
|
|
267
|
+
sets.push("notes = ?");
|
|
268
|
+
binds.push(params.notes);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
const joined = joinedNotes(sim, params.uuid, params);
|
|
272
|
+
if (joined !== undefined) {
|
|
273
|
+
sets.push("notes = ?");
|
|
274
|
+
binds.push(joined);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (params.when !== undefined) {
|
|
278
|
+
const s = scheduleColumns(params.when, ctx.todayIso, true);
|
|
279
|
+
sets.push("start = ?", "startDate = ?", "startBucket = ?");
|
|
280
|
+
binds.push(s.start, s.startDate, s.startBucket);
|
|
281
|
+
sets.push("reminderTime = ?");
|
|
282
|
+
binds.push(effectiveReminderValue(sim, params.uuid, params));
|
|
283
|
+
}
|
|
284
|
+
else if (params.reminder !== undefined) {
|
|
285
|
+
sets.push("reminderTime = ?");
|
|
286
|
+
binds.push(params.reminder === null ? null : encodeReminderTime(params.reminder));
|
|
287
|
+
}
|
|
288
|
+
if (params.deadline !== undefined) {
|
|
289
|
+
sets.push("deadline = ?");
|
|
290
|
+
binds.push(params.deadline === null ? null : encodePackedDate(params.deadline));
|
|
291
|
+
}
|
|
292
|
+
sets.push("userModificationDate = ?");
|
|
293
|
+
binds.push(ctx.nowEpoch);
|
|
294
|
+
sim.prepare(`UPDATE TMTask SET ${sets.join(", ")} WHERE uuid = ?`).run(...binds, params.uuid);
|
|
295
|
+
}
|
|
296
|
+
function setStatus(sim, uuid, status, stopDate, ctx) {
|
|
297
|
+
sim
|
|
298
|
+
.prepare("UPDATE TMTask SET status = ?, stopDate = ?, userModificationDate = ? WHERE uuid = ?")
|
|
299
|
+
.run(status, stopDate, ctx.nowEpoch, uuid);
|
|
300
|
+
}
|
|
301
|
+
// ------------------------------------------------- recurrence appliers
|
|
302
|
+
//
|
|
303
|
+
// The row-level shapes below reproduce the RSIM campaign verdicts
|
|
304
|
+
// (docs/lab/rsim-results.md, RSIM1–6): making a to-do/project repeat, the
|
|
305
|
+
// fixed-vs-after-completion identity asymmetry, in-place reschedule, and the
|
|
306
|
+
// completion-side stamping that schedules the next after-completion occurrence
|
|
307
|
+
// without materializing it. Rule blobs come from the SHARED composer
|
|
308
|
+
// (recurrence-rule-blob.ts), so every emitted template decodes with the real
|
|
309
|
+
// read-path decoder.
|
|
310
|
+
/** A deadlined template's own `deadline` column carries this far-future sentinel (§8a). */
|
|
311
|
+
const DEADLINE_SENTINEL_ISO = "4001-01-01";
|
|
312
|
+
/** Add whole recurrence units to an ISO date (day/week/month/year). */
|
|
313
|
+
function addUnitsIso(iso, frequency, interval) {
|
|
314
|
+
switch (frequency) {
|
|
315
|
+
case "daily":
|
|
316
|
+
return addDaysIso(iso, interval);
|
|
317
|
+
case "weekly":
|
|
318
|
+
return addDaysIso(iso, 7 * interval);
|
|
319
|
+
case "monthly":
|
|
320
|
+
return addMonthsIso(iso, interval);
|
|
321
|
+
case "yearly":
|
|
322
|
+
return addMonthsIso(iso, 12 * interval);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/** Add whole months to an ISO date, clamping the day to the target month's length. */
|
|
326
|
+
function addMonthsIso(iso, months) {
|
|
327
|
+
const [y, m, d] = iso.split("-").map(Number);
|
|
328
|
+
const anchor = new Date(Date.UTC(y, m - 1 + months, 1));
|
|
329
|
+
const year = anchor.getUTCFullYear();
|
|
330
|
+
const month0 = anchor.getUTCMonth();
|
|
331
|
+
const lastDay = new Date(Date.UTC(year, month0 + 1, 0)).getUTCDate();
|
|
332
|
+
const day = Math.min(d, lastDay);
|
|
333
|
+
return (`${String(year).padStart(4, "0")}-` +
|
|
334
|
+
`${String(month0 + 1).padStart(2, "0")}-` +
|
|
335
|
+
`${String(day).padStart(2, "0")}`);
|
|
336
|
+
}
|
|
337
|
+
/** Epoch seconds at UTC-noon of an ISO date — the (decoder-ignored) rule anchor. */
|
|
338
|
+
function epochOfIso(iso) {
|
|
339
|
+
return Math.floor(Date.parse(`${iso}T12:00:00Z`) / 1000);
|
|
340
|
+
}
|
|
341
|
+
/** Insert a template or instance row, covering the full rt1_* recurrence column set. */
|
|
342
|
+
function insertRecurrenceRow(sim, ctx, o) {
|
|
343
|
+
sim
|
|
344
|
+
.prepare(`INSERT INTO TMTask (
|
|
345
|
+
uuid, type, status, stopDate, trashed, title, notes,
|
|
346
|
+
creationDate, userModificationDate,
|
|
347
|
+
start, startDate, startBucket, reminderTime, deadline, deadlineSuppressionDate,
|
|
348
|
+
"index", todayIndex, todayIndexReferenceDate, area, project, heading,
|
|
349
|
+
untrashedLeafActionsCount, openUntrashedLeafActionsCount,
|
|
350
|
+
checklistItemsCount, openChecklistItemsCount,
|
|
351
|
+
rt1_repeatingTemplate, rt1_recurrenceRule, rt1_instanceCreationStartDate,
|
|
352
|
+
rt1_instanceCreationPaused, rt1_instanceCreationCount,
|
|
353
|
+
rt1_afterCompletionReferenceDate, rt1_nextInstanceStartDate, repeater
|
|
354
|
+
) VALUES (?, ?, 0, NULL, 0, ?, ?, ?, ?, ?, ?, 0, NULL, ?, NULL, 0, 0, NULL, ?, NULL, NULL,
|
|
355
|
+
0, 0, 0, 0, ?, ?, ?, 0, ?, ?, ?, NULL)`)
|
|
356
|
+
.run(o.uuid, o.type, o.title, o.notes, ctx.nowEpoch, ctx.nowEpoch, o.start, o.startDate, o.deadline, o.area, o.repeatingTemplate ?? null, o.recurrenceRuleXml !== undefined ? new TextEncoder().encode(o.recurrenceRuleXml) : null, o.instanceCreationStartDate ?? null, o.instanceCreationCount ?? 0, o.afterCompletionReferenceDate ?? null, o.nextInstanceStartDate ?? null);
|
|
357
|
+
}
|
|
358
|
+
/** Copy a task's direct tag links onto another task (title/notes/tags/area copy from source). */
|
|
359
|
+
function copyTaskTags(sim, fromUuid, toUuid) {
|
|
360
|
+
const rows = sim.prepare("SELECT tags FROM TMTaskTag WHERE tasks = ?").all(fromUuid);
|
|
361
|
+
for (const r of rows) {
|
|
362
|
+
sim.prepare("INSERT INTO TMTaskTag (tasks, tags) VALUES (?, ?)").run(toUuid, r.tags);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function loadRepeatSource(sim, uuid) {
|
|
366
|
+
const src = sim
|
|
367
|
+
.prepare("SELECT title, notes, area, startDate FROM TMTask WHERE uuid = ?")
|
|
368
|
+
.get(uuid);
|
|
369
|
+
if (src === undefined)
|
|
370
|
+
throw new Error("simulator: make-repeating target not found");
|
|
371
|
+
return {
|
|
372
|
+
title: src.title ?? "",
|
|
373
|
+
notes: src.notes ?? "",
|
|
374
|
+
area: src.area,
|
|
375
|
+
startDate: src.startDate,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Read a project's full containment subtree (headings + direct/headed to-dos,
|
|
380
|
+
* with tags + checklist) before any mutation.
|
|
381
|
+
*
|
|
382
|
+
* RSIM-S (§RSIM-S S-R1): trashed rows are INCLUDED (each tagged `trashed`) so a
|
|
383
|
+
* source-DELETE conversion can hard-delete the WHOLE subtree — reality destroys
|
|
384
|
+
* a pre-trashed child along with the source, leaving no orphan. Copy paths
|
|
385
|
+
* (`materializeSubtreeCopy`) filter these back out — a spawned/template copy
|
|
386
|
+
* never carries trashed rows (RSIM-P2 A3, probe-verified).
|
|
387
|
+
*/
|
|
388
|
+
function readProjectSubtree(sim, projectUuid) {
|
|
389
|
+
const headingRows = sim
|
|
390
|
+
.prepare(`SELECT uuid, title, notes, status, stopDate, trashed, "index" AS idx
|
|
391
|
+
FROM TMTask WHERE type = 2 AND project = ? ORDER BY "index"`)
|
|
392
|
+
.all(projectUuid);
|
|
393
|
+
const headings = headingRows.map((h) => ({
|
|
394
|
+
uuid: h.uuid,
|
|
395
|
+
title: h.title ?? "",
|
|
396
|
+
notes: h.notes ?? "",
|
|
397
|
+
status: h.status,
|
|
398
|
+
stopDate: h.stopDate,
|
|
399
|
+
index: h.idx,
|
|
400
|
+
trashed: h.trashed === 1,
|
|
401
|
+
}));
|
|
402
|
+
const headingUuids = headings.map((h) => h.uuid);
|
|
403
|
+
// A direct child has project=<projectUuid>; a headed child has project=NULL
|
|
404
|
+
// and heading=<one of this project's headings> — this union captures both.
|
|
405
|
+
const placeholders = headingUuids.map(() => "?").join(", ");
|
|
406
|
+
const todoRows = sim
|
|
407
|
+
.prepare(`SELECT uuid, title, notes, status, stopDate, deadline, heading, trashed,
|
|
408
|
+
rt1_recurrenceRule AS recurrenceRule, rt1_repeatingTemplate AS repeatingTemplate,
|
|
409
|
+
"index" AS idx
|
|
410
|
+
FROM TMTask WHERE type = 0
|
|
411
|
+
AND (project = ?${headingUuids.length > 0 ? ` OR heading IN (${placeholders})` : ""})
|
|
412
|
+
ORDER BY "index"`)
|
|
413
|
+
.all(projectUuid, ...headingUuids);
|
|
414
|
+
const todos = todoRows.map((t) => ({
|
|
415
|
+
uuid: t.uuid,
|
|
416
|
+
title: t.title ?? "",
|
|
417
|
+
notes: t.notes ?? "",
|
|
418
|
+
status: t.status,
|
|
419
|
+
stopDate: t.stopDate,
|
|
420
|
+
deadline: t.deadline,
|
|
421
|
+
index: t.idx,
|
|
422
|
+
trashed: t.trashed === 1,
|
|
423
|
+
headingUuid: t.heading,
|
|
424
|
+
recurrenceRule: t.recurrenceRule,
|
|
425
|
+
repeatingTemplate: t.repeatingTemplate,
|
|
426
|
+
tags: sim.prepare("SELECT tags FROM TMTaskTag WHERE tasks = ?").all(t.uuid).map((r) => r.tags),
|
|
427
|
+
checklist: sim
|
|
428
|
+
.prepare(`SELECT title, status, "index" AS idx FROM TMChecklistItem WHERE task = ? ORDER BY "index"`)
|
|
429
|
+
.all(t.uuid).map((c) => ({ title: c.title ?? "", status: c.status, index: c.idx })),
|
|
430
|
+
}));
|
|
431
|
+
return { headings, todos };
|
|
432
|
+
}
|
|
433
|
+
/** UPDATE a row's list index (insertTask hardcodes 0; the copy preserves source order). */
|
|
434
|
+
function setRowIndex(sim, uuid, index) {
|
|
435
|
+
sim.prepare(`UPDATE TMTask SET "index" = ? WHERE uuid = ?`).run(index, uuid);
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Materialize a PLAIN copy of `subtree` under `newProjectUuid` (fresh uuids for
|
|
439
|
+
* every heading/to-do; tags + checklist duplicated; index order + heading
|
|
440
|
+
* membership preserved; children `start=1`, `rt1_recurrenceRule`/
|
|
441
|
+
* `rt1_repeatingTemplate` NULL).
|
|
442
|
+
*
|
|
443
|
+
* RSIM-R: copied children are ALWAYS plain — both fixed and after-completion
|
|
444
|
+
* instance/template sides. (RSIM-P P4's per-child instance→template links are a
|
|
445
|
+
* non-reproducible anomaly; A5/R7/R8 = 3/3 plain — so no linkMap parameter.)
|
|
446
|
+
*/
|
|
447
|
+
function materializeSubtreeCopy(sim, ctx, subtree, newProjectUuid) {
|
|
448
|
+
const headingIdMap = new Map();
|
|
449
|
+
for (const h of subtree.headings) {
|
|
450
|
+
if (h.trashed)
|
|
451
|
+
continue; // RSIM-S S-R1: copies exclude trashed rows (RSIM-P2 A3).
|
|
452
|
+
const newUuid = genUuid();
|
|
453
|
+
insertTask(sim, 2, ctx, {
|
|
454
|
+
uuid: newUuid,
|
|
455
|
+
title: h.title,
|
|
456
|
+
notes: h.notes,
|
|
457
|
+
start: START.active, // RSIM-P P1: copied children are start=1
|
|
458
|
+
project: newProjectUuid,
|
|
459
|
+
});
|
|
460
|
+
setRowIndex(sim, newUuid, h.index);
|
|
461
|
+
if (h.status !== STATUS.open)
|
|
462
|
+
setStatus(sim, newUuid, h.status, h.stopDate ?? ctx.nowEpoch, ctx);
|
|
463
|
+
headingIdMap.set(h.uuid, newUuid);
|
|
464
|
+
}
|
|
465
|
+
for (const t of subtree.todos) {
|
|
466
|
+
if (t.trashed)
|
|
467
|
+
continue; // RSIM-S S-R1: copies exclude trashed rows (RSIM-P2 A3).
|
|
468
|
+
const newUuid = genUuid();
|
|
469
|
+
// Preserve the containment invariant: a headed child points at the COPIED
|
|
470
|
+
// heading with project NULL; a direct child points at the new project.
|
|
471
|
+
const heading = t.headingUuid !== null ? (headingIdMap.get(t.headingUuid) ?? null) : null;
|
|
472
|
+
const project = heading !== null ? null : newProjectUuid;
|
|
473
|
+
const openChecklist = t.checklist.filter((c) => c.status === STATUS.open).length;
|
|
474
|
+
insertTask(sim, 0, ctx, {
|
|
475
|
+
uuid: newUuid,
|
|
476
|
+
title: t.title,
|
|
477
|
+
notes: t.notes,
|
|
478
|
+
start: START.active, // RSIM-P P1: start=1
|
|
479
|
+
startDate: null,
|
|
480
|
+
deadline: t.deadline,
|
|
481
|
+
project,
|
|
482
|
+
heading,
|
|
483
|
+
checklistItemsCount: t.checklist.length,
|
|
484
|
+
openChecklistItemsCount: openChecklist,
|
|
485
|
+
});
|
|
486
|
+
setRowIndex(sim, newUuid, t.index);
|
|
487
|
+
if (t.status !== STATUS.open)
|
|
488
|
+
setStatus(sim, newUuid, t.status, t.stopDate ?? ctx.nowEpoch, ctx);
|
|
489
|
+
for (const tag of t.tags) {
|
|
490
|
+
sim.prepare("INSERT INTO TMTaskTag (tasks, tags) VALUES (?, ?)").run(newUuid, tag);
|
|
491
|
+
}
|
|
492
|
+
for (const c of t.checklist) {
|
|
493
|
+
sim
|
|
494
|
+
.prepare(`INSERT INTO TMChecklistItem (uuid, userModificationDate, creationDate, title, status, stopDate, "index", task, leavesTombstone)
|
|
495
|
+
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, 0)`)
|
|
496
|
+
.run(genUuid(), ctx.nowEpoch, ctx.nowEpoch, c.title, c.status, c.index, newUuid);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Hard-DELETE a source subtree: each to-do's tags + checklist + row, then the
|
|
502
|
+
* heading rows.
|
|
503
|
+
*
|
|
504
|
+
* RSIM-S (§RSIM-S S-R1): the subtree includes already-trashed rows, and they are
|
|
505
|
+
* destroyed here too — a source-DELETE conversion hard-deletes the WHOLE source
|
|
506
|
+
* subtree, so a pre-trashed child does NOT survive as a dangling row pointing at
|
|
507
|
+
* the deleted source (it is neither copied nor left in the Trash).
|
|
508
|
+
*/
|
|
509
|
+
function deleteProjectSubtree(sim, subtree) {
|
|
510
|
+
for (const t of subtree.todos) {
|
|
511
|
+
sim.prepare("DELETE FROM TMTaskTag WHERE tasks = ?").run(t.uuid);
|
|
512
|
+
sim.prepare("DELETE FROM TMChecklistItem WHERE task = ?").run(t.uuid);
|
|
513
|
+
sim.prepare("DELETE FROM TMTask WHERE uuid = ?").run(t.uuid);
|
|
514
|
+
}
|
|
515
|
+
for (const h of subtree.headings) {
|
|
516
|
+
sim.prepare("DELETE FROM TMTaskTag WHERE tasks = ?").run(h.uuid);
|
|
517
|
+
sim.prepare("DELETE FROM TMTask WHERE uuid = ?").run(h.uuid);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* RSIM-R: a NESTED repeater in a project's subtree is a to-do carrying either
|
|
522
|
+
* `rt1_recurrenceRule` (the nested hidden template) or `rt1_repeatingTemplate`
|
|
523
|
+
* (the nested repeater's instance). Its presence flips the fixed project
|
|
524
|
+
* make-repeating from delete-and-mint-both to the source-preserving flatten path.
|
|
525
|
+
*/
|
|
526
|
+
function subtreeHasNestedRepeater(subtree) {
|
|
527
|
+
// RSIM-S S-R1: consider only live rows — a trashed nested repeater does not
|
|
528
|
+
// flip source-fate (a trashed child alone leaves the delete-default intact).
|
|
529
|
+
return subtree.todos.some((t) => !t.trashed && (t.recurrenceRule !== null || t.repeatingTemplate !== null));
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* FIXED make-repeating (RSIM1 to-do / RSIM6 project / RSIM3 create leg): the
|
|
533
|
+
* source is DESTROYED (identity replacement) and replaced by a hidden template
|
|
534
|
+
* (start=someday, rule tp=0, next-occurrence dates) plus EXACTLY ONE instance
|
|
535
|
+
* dated at the current occurrence. Area/title/notes/tags copy to both. For a
|
|
536
|
+
* PROJECT (type=1) with children, the source subtree is hard-deleted and a plain
|
|
537
|
+
* copy is minted under BOTH new projects (RSIM-P P1).
|
|
538
|
+
*
|
|
539
|
+
* RSIM-R exception (fixed PROJECT only): if the source project's subtree contains
|
|
540
|
+
* a NESTED repeater, the source is instead PRESERVED and relinked as the current-
|
|
541
|
+
* occurrence instance, the nested repeater is FLATTENED in place, and ONLY the
|
|
542
|
+
* hidden template project is minted (no separate instance project). See
|
|
543
|
+
* `applyMakeRepeatingFixedProjectFlatten`. Area/When are irrelevant; the sole
|
|
544
|
+
* discriminator is the nested repeater. The to-do fixed-preserve anomaly (a
|
|
545
|
+
* content-rich to-do; §RSIM-R parked, unisolated trigger) is deliberately
|
|
546
|
+
* UNMODELED — to-dos always delete here.
|
|
547
|
+
*/
|
|
548
|
+
function applyMakeRepeatingFixed(sim, type, params, ctx) {
|
|
549
|
+
const src = loadRepeatSource(sim, params.uuid);
|
|
550
|
+
// RSIM-P P1: a project's child subtree is captured BEFORE any mutation so the
|
|
551
|
+
// hidden template and the instance each receive a plain copy. To-dos have none.
|
|
552
|
+
const subtree = type === 1 ? readProjectSubtree(sim, params.uuid) : null;
|
|
553
|
+
const todayIso = ctx.todayIso;
|
|
554
|
+
const nextIso = addUnitsIso(todayIso, params.frequency, params.interval);
|
|
555
|
+
const deadlined = params.deadline === true || (params.startDaysEarlier ?? 0) > 0;
|
|
556
|
+
const startEarlier = params.startDaysEarlier ?? 0;
|
|
557
|
+
const spec = composeRepeatRuleSpec(params, todayIso, epochOfIso(nextIso));
|
|
558
|
+
const templateUuid = genUuid();
|
|
559
|
+
// The hidden template row is identical in both fixed-project fates.
|
|
560
|
+
insertRecurrenceRow(sim, ctx, {
|
|
561
|
+
uuid: templateUuid,
|
|
562
|
+
type,
|
|
563
|
+
title: src.title,
|
|
564
|
+
notes: src.notes,
|
|
565
|
+
area: src.area,
|
|
566
|
+
start: START.someday,
|
|
567
|
+
startDate: null,
|
|
568
|
+
deadline: deadlined ? encodePackedDate(DEADLINE_SENTINEL_ISO) : null,
|
|
569
|
+
recurrenceRuleXml: ruleXml(spec),
|
|
570
|
+
instanceCreationCount: 1,
|
|
571
|
+
instanceCreationStartDate: encodePackedDate(nextIso),
|
|
572
|
+
nextInstanceStartDate: encodePackedDate(nextIso),
|
|
573
|
+
});
|
|
574
|
+
copyTaskTags(sim, params.uuid, templateUuid);
|
|
575
|
+
// The current occurrence's start (deadlined series back off `startDaysEarlier`;
|
|
576
|
+
// decode identity deadline = startDate − ts; deadlined creation is not itself
|
|
577
|
+
// RSIM-drive-proven).
|
|
578
|
+
const instStartIso = deadlined ? addDaysIso(todayIso, -startEarlier) : todayIso;
|
|
579
|
+
// RSIM-R: fixed PROJECT with a nested repeater PRESERVES the source (flatten path).
|
|
580
|
+
if (subtree !== null && subtreeHasNestedRepeater(subtree)) {
|
|
581
|
+
applyMakeRepeatingFixedProjectFlatten(sim, ctx, params.uuid, subtree, templateUuid, instStartIso);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
// RSIM-P P1: template-side children are completely PLAIN (no per-child link).
|
|
585
|
+
if (subtree !== null)
|
|
586
|
+
materializeSubtreeCopy(sim, ctx, subtree, templateUuid);
|
|
587
|
+
// ONE instance at the current occurrence (start=someday pending maintenance
|
|
588
|
+
// promotion). A deadlined series dates the instance's deadline at the occurrence.
|
|
589
|
+
const instanceUuid = genUuid();
|
|
590
|
+
insertRecurrenceRow(sim, ctx, {
|
|
591
|
+
uuid: instanceUuid,
|
|
592
|
+
type,
|
|
593
|
+
title: src.title,
|
|
594
|
+
notes: src.notes,
|
|
595
|
+
area: src.area,
|
|
596
|
+
start: START.someday,
|
|
597
|
+
startDate: encodePackedDate(instStartIso),
|
|
598
|
+
deadline: deadlined ? encodePackedDate(todayIso) : null,
|
|
599
|
+
repeatingTemplate: templateUuid,
|
|
600
|
+
});
|
|
601
|
+
copyTaskTags(sim, params.uuid, instanceUuid);
|
|
602
|
+
// RSIM-P P1: FIXED instance-side children are ALSO plain — no per-child link.
|
|
603
|
+
if (subtree !== null)
|
|
604
|
+
materializeSubtreeCopy(sim, ctx, subtree, instanceUuid);
|
|
605
|
+
// RSIM-P P1: hard-delete the whole source subtree, then the source project.
|
|
606
|
+
if (subtree !== null)
|
|
607
|
+
deleteProjectSubtree(sim, subtree);
|
|
608
|
+
sim.prepare("DELETE FROM TMTaskTag WHERE tasks = ?").run(params.uuid);
|
|
609
|
+
sim.prepare("DELETE FROM TMTask WHERE uuid = ?").run(params.uuid);
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* RSIM-R (fixed PROJECT whose subtree contains a nested repeater — A1/A2/A3/S2/
|
|
613
|
+
* S2b): the source project is PRESERVED and relinked as the current-occurrence
|
|
614
|
+
* instance rather than deleted; the nested repeater is FLATTENED in place; and
|
|
615
|
+
* only the (already-minted) hidden template project is populated — there is NO
|
|
616
|
+
* separate instance project. Row-level shape (S2, re-derived uuid-by-uuid):
|
|
617
|
+
* - source project CHANGED: `start → 2` (someday), `startDate → currentOccurrence`,
|
|
618
|
+
* `rt1_repeatingTemplate → template`. All other source columns (title, notes,
|
|
619
|
+
* area, deadline) are left untouched.
|
|
620
|
+
* - nested template row hard-deleted; nested instance demoted to PLAIN
|
|
621
|
+
* (`rt1_repeatingTemplate → NULL`).
|
|
622
|
+
* - the template project receives a PLAIN copy of the FLATTENED subtree (the
|
|
623
|
+
* nested template rows excluded; the demoted instance copied as a plain to-do).
|
|
624
|
+
*/
|
|
625
|
+
function applyMakeRepeatingFixedProjectFlatten(sim, ctx, sourceUuid, subtree, templateUuid, instStartIso) {
|
|
626
|
+
// The template project gets a plain copy of the FLATTENED subtree — nested
|
|
627
|
+
// template rows are dropped; every surviving to-do copies plain via
|
|
628
|
+
// materializeSubtreeCopy (start=1, no recurrence/template links).
|
|
629
|
+
// RSIM-S S-R1: the copy excludes trashed rows; materializeSubtreeCopy also
|
|
630
|
+
// skips them, but keep the flattened set clean for clarity.
|
|
631
|
+
const flattened = {
|
|
632
|
+
headings: subtree.headings.filter((h) => !h.trashed),
|
|
633
|
+
todos: subtree.todos.filter((t) => t.recurrenceRule === null && !t.trashed),
|
|
634
|
+
};
|
|
635
|
+
materializeSubtreeCopy(sim, ctx, flattened, templateUuid);
|
|
636
|
+
// Flatten the nested repeater IN the preserved source subtree. Trashed rows
|
|
637
|
+
// ride along untouched under the preserved source (RSIM-S S-R2, inferred).
|
|
638
|
+
for (const t of subtree.todos) {
|
|
639
|
+
if (t.trashed)
|
|
640
|
+
continue;
|
|
641
|
+
if (t.recurrenceRule !== null) {
|
|
642
|
+
// Nested hidden template: hard-delete its tags + checklist + row.
|
|
643
|
+
sim.prepare("DELETE FROM TMTaskTag WHERE tasks = ?").run(t.uuid);
|
|
644
|
+
sim.prepare("DELETE FROM TMChecklistItem WHERE task = ?").run(t.uuid);
|
|
645
|
+
sim.prepare("DELETE FROM TMTask WHERE uuid = ?").run(t.uuid);
|
|
646
|
+
}
|
|
647
|
+
else if (t.repeatingTemplate !== null) {
|
|
648
|
+
// Nested instance: demote to a plain to-do.
|
|
649
|
+
sim.prepare("UPDATE TMTask SET rt1_repeatingTemplate = NULL WHERE uuid = ?").run(t.uuid);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// Preserve the source project AS the current-occurrence instance.
|
|
653
|
+
sim
|
|
654
|
+
.prepare(`UPDATE TMTask SET start = ?, startDate = ?, rt1_repeatingTemplate = ?, userModificationDate = ? WHERE uuid = ?`)
|
|
655
|
+
.run(START.someday, encodePackedDate(instStartIso), templateUuid, ctx.nowEpoch, sourceUuid);
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* AFTER-COMPLETION make-repeating.
|
|
659
|
+
*
|
|
660
|
+
* TO-DO (type=0, RSIM2): the source is PRESERVED and relinked as the sole first
|
|
661
|
+
* instance (identity kept — §8g: identity replacement is fixed-only). A new tp=1
|
|
662
|
+
* template is created with NO next/reference dates (unknown until a completion).
|
|
663
|
+
* No fresh instance row is minted.
|
|
664
|
+
*
|
|
665
|
+
* PROJECT (type=1, RSIM-P P4): the type-0 path does NOT hold — the source is
|
|
666
|
+
* DELETED (like the fixed case) and BOTH a template and a fresh instance are
|
|
667
|
+
* minted, each with a full plain child copy, and each instance-side to-do child
|
|
668
|
+
* carries a per-child rt1_repeatingTemplate. Split out to a dedicated applier.
|
|
669
|
+
*/
|
|
670
|
+
function applyMakeRepeatingAfterCompletion(sim, type, params, ctx) {
|
|
671
|
+
// RSIM-P P4: the after-completion PROJECT path is NOT the to-do path.
|
|
672
|
+
if (type === 1) {
|
|
673
|
+
applyMakeRepeatingAfterCompletionProject(sim, params, ctx);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const src = loadRepeatSource(sim, params.uuid);
|
|
677
|
+
const refIso = decodePackedDate(src.startDate) ?? ctx.todayIso;
|
|
678
|
+
const deadlined = params.deadline === true || (params.startDaysEarlier ?? 0) > 0;
|
|
679
|
+
const spec = composeRepeatRuleSpec(params, refIso, epochOfIso(refIso));
|
|
680
|
+
const templateUuid = genUuid();
|
|
681
|
+
insertRecurrenceRow(sim, ctx, {
|
|
682
|
+
uuid: templateUuid,
|
|
683
|
+
type,
|
|
684
|
+
title: src.title,
|
|
685
|
+
notes: src.notes,
|
|
686
|
+
area: src.area,
|
|
687
|
+
start: START.someday,
|
|
688
|
+
startDate: null,
|
|
689
|
+
deadline: deadlined ? encodePackedDate(DEADLINE_SENTINEL_ISO) : null,
|
|
690
|
+
recurrenceRuleXml: ruleXml(spec),
|
|
691
|
+
instanceCreationCount: 0,
|
|
692
|
+
});
|
|
693
|
+
copyTaskTags(sim, params.uuid, templateUuid);
|
|
694
|
+
// Relink the preserved source as the instance; startDate/start unchanged.
|
|
695
|
+
sim
|
|
696
|
+
.prepare("UPDATE TMTask SET rt1_repeatingTemplate = ?, userModificationDate = ? WHERE uuid = ?")
|
|
697
|
+
.run(templateUuid, ctx.nowEpoch, params.uuid);
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* AFTER-COMPLETION make-repeating for a PROJECT (RSIM-P P4). Unlike the to-do
|
|
701
|
+
* path (RSIM2, source preserved), the source project is DELETED and both a
|
|
702
|
+
* template and a fresh instance are minted — the same delete+duplicate shape as
|
|
703
|
+
* the fixed project case, but with:
|
|
704
|
+
* - a tp=1 (after-completion) rule and NO next/reference dates
|
|
705
|
+
* (`rt1_nextInstanceStartDate` / `rt1_afterCompletionReferenceDate` NULL);
|
|
706
|
+
* - the template's `rt1_instanceCreationCount` = 1 (P4 observed, vs 0 for the
|
|
707
|
+
* after-completion TO-DO template);
|
|
708
|
+
* - the instance's `startDate` = the SOURCE project's startDate (not the
|
|
709
|
+
* current occurrence used by the fixed case).
|
|
710
|
+
*
|
|
711
|
+
* RSIM-R: instance-side children (to-dos AND headings) are PLAIN — no per-child
|
|
712
|
+
* `rt1_repeatingTemplate` link. RSIM-P P4's per-child links are a NON-reproducible
|
|
713
|
+
* anomaly (A5/R7/R8 = 3/3 plain); only the project row carries the instance→
|
|
714
|
+
* template link.
|
|
715
|
+
*/
|
|
716
|
+
function applyMakeRepeatingAfterCompletionProject(sim, params, ctx) {
|
|
717
|
+
const src = loadRepeatSource(sim, params.uuid);
|
|
718
|
+
const subtree = readProjectSubtree(sim, params.uuid);
|
|
719
|
+
const refIso = decodePackedDate(src.startDate) ?? ctx.todayIso;
|
|
720
|
+
const deadlined = params.deadline === true || (params.startDaysEarlier ?? 0) > 0;
|
|
721
|
+
const spec = composeRepeatRuleSpec(params, refIso, epochOfIso(refIso));
|
|
722
|
+
const templateUuid = genUuid();
|
|
723
|
+
const instanceUuid = genUuid();
|
|
724
|
+
// Template: tp=1 rule, hidden, icCount=1, NO next/reference dates (P4).
|
|
725
|
+
insertRecurrenceRow(sim, ctx, {
|
|
726
|
+
uuid: templateUuid,
|
|
727
|
+
type: 1,
|
|
728
|
+
title: src.title,
|
|
729
|
+
notes: src.notes,
|
|
730
|
+
area: src.area,
|
|
731
|
+
start: START.someday,
|
|
732
|
+
startDate: null,
|
|
733
|
+
deadline: deadlined ? encodePackedDate(DEADLINE_SENTINEL_ISO) : null,
|
|
734
|
+
recurrenceRuleXml: ruleXml(spec),
|
|
735
|
+
instanceCreationCount: 1, // RSIM-P P4
|
|
736
|
+
});
|
|
737
|
+
copyTaskTags(sim, params.uuid, templateUuid);
|
|
738
|
+
// Template-side children are PLAIN.
|
|
739
|
+
materializeSubtreeCopy(sim, ctx, subtree, templateUuid);
|
|
740
|
+
// Instance: fresh row (source is deleted, unlike the to-do path), tmpl link,
|
|
741
|
+
// startDate = the SOURCE project's startDate (P4).
|
|
742
|
+
insertRecurrenceRow(sim, ctx, {
|
|
743
|
+
uuid: instanceUuid,
|
|
744
|
+
type: 1,
|
|
745
|
+
title: src.title,
|
|
746
|
+
notes: src.notes,
|
|
747
|
+
area: src.area,
|
|
748
|
+
start: START.someday,
|
|
749
|
+
startDate: src.startDate,
|
|
750
|
+
deadline: null,
|
|
751
|
+
repeatingTemplate: templateUuid,
|
|
752
|
+
});
|
|
753
|
+
copyTaskTags(sim, params.uuid, instanceUuid);
|
|
754
|
+
// RSIM-R: instance-side children are PLAIN — no per-child template link
|
|
755
|
+
// (P4's links do not reproduce; pass null exactly as the fixed case does).
|
|
756
|
+
materializeSubtreeCopy(sim, ctx, subtree, instanceUuid);
|
|
757
|
+
// Hard-delete the source subtree + the source project (P4 deletes the source).
|
|
758
|
+
deleteProjectSubtree(sim, subtree);
|
|
759
|
+
sim.prepare("DELETE FROM TMTaskTag WHERE tasks = ?").run(params.uuid);
|
|
760
|
+
sim.prepare("DELETE FROM TMTask WHERE uuid = ?").run(params.uuid);
|
|
761
|
+
}
|
|
762
|
+
/** Route a make-repeating to the fixed or after-completion applier by rule type. */
|
|
763
|
+
function applyMakeRepeating(sim, type, params, ctx) {
|
|
764
|
+
if (params.afterCompletion === true)
|
|
765
|
+
applyMakeRepeatingAfterCompletion(sim, type, params, ctx);
|
|
766
|
+
else
|
|
767
|
+
applyMakeRepeatingFixed(sim, type, params, ctx);
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* reschedule-repeat (RSIM5, to-do or project): identity PRESERVED, the rule
|
|
771
|
+
* rewritten in place to the target `{frequency, interval, anchors}` with the
|
|
772
|
+
* instance-creation date advanced to the new next occurrence. `tp` and the
|
|
773
|
+
* deadline-ness (ts + the template's deadline column) are preserved unless the
|
|
774
|
+
* reschedule explicitly changes them. (The shipped op's interval-entry app bug
|
|
775
|
+
* — RSIM5 caveat — is NOT modeled here: the simulator applies the TARGET rule.)
|
|
776
|
+
*/
|
|
777
|
+
function applyReschedule(sim, params, ctx) {
|
|
778
|
+
const row = sim
|
|
779
|
+
.prepare("SELECT rt1_recurrenceRule AS rule FROM TMTask WHERE uuid = ?")
|
|
780
|
+
.get(params.uuid);
|
|
781
|
+
if (row === undefined || row.rule === null) {
|
|
782
|
+
throw new Error("simulator: reschedule-repeat target is not a repeating template");
|
|
783
|
+
}
|
|
784
|
+
const existing = decodeRecurrenceRule(row.rule);
|
|
785
|
+
const paramsHasAnchor = params.weekdays !== undefined || params.monthly !== undefined || params.yearly !== undefined;
|
|
786
|
+
const preserveAfterCompletion = params.afterCompletion === undefined &&
|
|
787
|
+
!paramsHasAnchor &&
|
|
788
|
+
existing.type === "after-completion";
|
|
789
|
+
const effective = { ...params };
|
|
790
|
+
if (preserveAfterCompletion)
|
|
791
|
+
effective.afterCompletion = true;
|
|
792
|
+
const todayIso = ctx.todayIso;
|
|
793
|
+
const nextIso = addUnitsIso(todayIso, params.frequency, params.interval);
|
|
794
|
+
const spec = composeRepeatRuleSpec(effective, todayIso, epochOfIso(nextIso));
|
|
795
|
+
const setsDeadline = params.deadline !== undefined || params.startDaysEarlier !== undefined;
|
|
796
|
+
if (!setsDeadline)
|
|
797
|
+
spec.ts = existing.startOffsetDays; // preserve the prior start offset
|
|
798
|
+
const sets = [
|
|
799
|
+
"rt1_recurrenceRule = ?",
|
|
800
|
+
"rt1_instanceCreationStartDate = ?",
|
|
801
|
+
"rt1_nextInstanceStartDate = ?",
|
|
802
|
+
"userModificationDate = ?",
|
|
803
|
+
];
|
|
804
|
+
const binds = [
|
|
805
|
+
new TextEncoder().encode(ruleXml(spec)),
|
|
806
|
+
encodePackedDate(nextIso),
|
|
807
|
+
encodePackedDate(nextIso),
|
|
808
|
+
ctx.nowEpoch,
|
|
809
|
+
];
|
|
810
|
+
if (setsDeadline) {
|
|
811
|
+
const deadlined = params.deadline === true || (params.startDaysEarlier ?? 0) > 0;
|
|
812
|
+
sets.push("deadline = ?");
|
|
813
|
+
binds.push(deadlined ? encodePackedDate(DEADLINE_SENTINEL_ISO) : null);
|
|
814
|
+
}
|
|
815
|
+
sim.prepare(`UPDATE TMTask SET ${sets.join(", ")} WHERE uuid = ?`).run(...binds, params.uuid);
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Completion-side scheduling (RSIM4): completing an INSTANCE of an
|
|
819
|
+
* after-completion (tp=1) template stamps the template's reference + next-start
|
|
820
|
+
* dates (completion date, completion date + interval) WITHOUT materializing the
|
|
821
|
+
* next instance — it stays pending until its future start date arrives. A
|
|
822
|
+
* fixed-template instance (or a non-instance) is untouched here.
|
|
823
|
+
*/
|
|
824
|
+
function stampAfterCompletionTemplate(sim, instanceUuid, ctx) {
|
|
825
|
+
const inst = sim
|
|
826
|
+
.prepare("SELECT rt1_repeatingTemplate AS tmpl FROM TMTask WHERE uuid = ?")
|
|
827
|
+
.get(instanceUuid);
|
|
828
|
+
const templateUuid = inst?.tmpl;
|
|
829
|
+
if (templateUuid === null || templateUuid === undefined)
|
|
830
|
+
return;
|
|
831
|
+
const tpl = sim
|
|
832
|
+
.prepare("SELECT rt1_recurrenceRule AS rule FROM TMTask WHERE uuid = ?")
|
|
833
|
+
.get(templateUuid);
|
|
834
|
+
if (tpl === undefined || tpl.rule === null)
|
|
835
|
+
return;
|
|
836
|
+
let rule;
|
|
837
|
+
try {
|
|
838
|
+
rule = decodeRecurrenceRule(tpl.rule);
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
return; // undecodable template — leave it untouched
|
|
842
|
+
}
|
|
843
|
+
if (rule.type !== "after-completion")
|
|
844
|
+
return;
|
|
845
|
+
const completionIso = ctx.todayIso;
|
|
846
|
+
const nextIso = addUnitsIso(completionIso, rule.unit, rule.interval);
|
|
847
|
+
sim
|
|
848
|
+
.prepare("UPDATE TMTask SET rt1_afterCompletionReferenceDate = ?, rt1_nextInstanceStartDate = ?, " +
|
|
849
|
+
"userModificationDate = ? WHERE uuid = ?")
|
|
850
|
+
.run(encodePackedDate(completionIso), encodePackedDate(nextIso), ctx.nowEpoch, templateUuid);
|
|
851
|
+
}
|
|
852
|
+
/** Typed applier helper — narrows the params for one operation kind. */
|
|
853
|
+
function op(fn) {
|
|
854
|
+
return (sim, params, ctx) => fn(sim, params, ctx);
|
|
855
|
+
}
|
|
856
|
+
const APPLIERS = {
|
|
857
|
+
"todo.add": op((sim, params, ctx) => {
|
|
858
|
+
let project = null;
|
|
859
|
+
let heading = null;
|
|
860
|
+
let area = null;
|
|
861
|
+
if (params.heading !== undefined && params.project !== undefined) {
|
|
862
|
+
const projUuid = containerUuid(sim, params.project, "project");
|
|
863
|
+
if (projUuid === null)
|
|
864
|
+
throw new Error("simulator: to-do heading needs a project");
|
|
865
|
+
const h = resolveHeading(sim, projUuid, params.heading);
|
|
866
|
+
if (h.resolved === null)
|
|
867
|
+
throw new Error("simulator: unresolved heading");
|
|
868
|
+
heading = h.resolved.uuid; // project reached via the heading (project col NULL)
|
|
869
|
+
}
|
|
870
|
+
else if (params.project !== undefined) {
|
|
871
|
+
project = containerUuid(sim, params.project, "project");
|
|
872
|
+
}
|
|
873
|
+
else if (params.area !== undefined) {
|
|
874
|
+
area = containerUuid(sim, params.area, "area");
|
|
875
|
+
}
|
|
876
|
+
const hasContainer = project !== null || heading !== null || area !== null;
|
|
877
|
+
const s = scheduleColumns(params.when, ctx.todayIso, hasContainer);
|
|
878
|
+
const uuid = genUuid();
|
|
879
|
+
insertTask(sim, 0, ctx, {
|
|
880
|
+
uuid,
|
|
881
|
+
title: params.title,
|
|
882
|
+
notes: params.notes ?? "",
|
|
883
|
+
start: s.start,
|
|
884
|
+
startDate: s.startDate,
|
|
885
|
+
startBucket: s.startBucket,
|
|
886
|
+
reminderTime: params.reminder !== undefined ? encodeReminderTime(params.reminder) : null,
|
|
887
|
+
deadline: params.deadline !== undefined ? encodePackedDate(params.deadline) : null,
|
|
888
|
+
area,
|
|
889
|
+
project,
|
|
890
|
+
heading,
|
|
891
|
+
});
|
|
892
|
+
if (params.tags !== undefined)
|
|
893
|
+
setTaskTags(sim, uuid, params.tags);
|
|
894
|
+
if (params.checklistItems !== undefined)
|
|
895
|
+
replaceChecklist(sim, uuid, params.checklistItems, ctx);
|
|
896
|
+
}),
|
|
897
|
+
"todo.update": op((sim, params, ctx) => applyEntityUpdate(sim, params, ctx)),
|
|
898
|
+
"todo.complete": op((sim, params, ctx) => {
|
|
899
|
+
setStatus(sim, params.uuid, STATUS.completed, ctx.nowEpoch, ctx);
|
|
900
|
+
// RSIM4: completing an after-completion instance schedules the next
|
|
901
|
+
// occurrence on its template without materializing it.
|
|
902
|
+
stampAfterCompletionTemplate(sim, params.uuid, ctx);
|
|
903
|
+
}),
|
|
904
|
+
"todo.cancel": op((sim, params, ctx) => setStatus(sim, params.uuid, STATUS.canceled, ctx.nowEpoch, ctx)),
|
|
905
|
+
"todo.reopen": op((sim, params, ctx) => setStatus(sim, params.uuid, STATUS.open, null, ctx)),
|
|
906
|
+
"todo.delete": op((sim, params, ctx) => sim
|
|
907
|
+
.prepare("UPDATE TMTask SET trashed = 1, userModificationDate = ? WHERE uuid = ?")
|
|
908
|
+
.run(ctx.nowEpoch, params.uuid)),
|
|
909
|
+
"todo.restore": op((sim, params, ctx) =>
|
|
910
|
+
// Put Back → un-trash into the Inbox, de-scheduled (E15).
|
|
911
|
+
sim
|
|
912
|
+
.prepare("UPDATE TMTask SET trashed = 0, start = 0, startDate = NULL, startBucket = 0, userModificationDate = ? WHERE uuid = ?")
|
|
913
|
+
.run(ctx.nowEpoch, params.uuid)),
|
|
914
|
+
"todo.move": op((sim, params, ctx) => {
|
|
915
|
+
if (params.inbox === true) {
|
|
916
|
+
sim
|
|
917
|
+
.prepare("UPDATE TMTask SET start = 0, startDate = NULL, startBucket = 0, project = NULL, area = NULL, heading = NULL, userModificationDate = ? WHERE uuid = ?")
|
|
918
|
+
.run(ctx.nowEpoch, params.uuid);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
if (params.detach === true) {
|
|
922
|
+
sim
|
|
923
|
+
.prepare("UPDATE TMTask SET project = NULL, area = NULL, heading = NULL, userModificationDate = ? WHERE uuid = ?")
|
|
924
|
+
.run(ctx.nowEpoch, params.uuid);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
if (params.heading !== undefined && params.project !== undefined) {
|
|
928
|
+
const projUuid = containerUuid(sim, params.project, "project");
|
|
929
|
+
if (projUuid === null)
|
|
930
|
+
throw new Error("simulator: move heading needs a project");
|
|
931
|
+
const h = resolveHeading(sim, projUuid, params.heading);
|
|
932
|
+
if (h.resolved === null)
|
|
933
|
+
throw new Error("simulator: unresolved heading");
|
|
934
|
+
sim
|
|
935
|
+
.prepare("UPDATE TMTask SET heading = ?, project = NULL, area = NULL, start = CASE WHEN start = 0 THEN 1 ELSE start END, userModificationDate = ? WHERE uuid = ?")
|
|
936
|
+
.run(h.resolved.uuid, ctx.nowEpoch, params.uuid);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
if (params.project !== undefined) {
|
|
940
|
+
const projUuid = containerUuid(sim, params.project, "project");
|
|
941
|
+
sim
|
|
942
|
+
.prepare("UPDATE TMTask SET project = ?, heading = NULL, area = NULL, start = CASE WHEN start = 0 THEN 1 ELSE start END, userModificationDate = ? WHERE uuid = ?")
|
|
943
|
+
.run(projUuid, ctx.nowEpoch, params.uuid);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
if (params.area !== undefined) {
|
|
947
|
+
const areaUuid = containerUuid(sim, params.area, "area");
|
|
948
|
+
// Filing an inbox item into a container promotes it to Anytime (start
|
|
949
|
+
// 0→1), matching the app; someday (2) and already-active items keep
|
|
950
|
+
// their start. Same promotion on the project/heading branches above.
|
|
951
|
+
sim
|
|
952
|
+
.prepare("UPDATE TMTask SET area = ?, project = NULL, heading = NULL, start = CASE WHEN start = 0 THEN 1 ELSE start END, userModificationDate = ? WHERE uuid = ?")
|
|
953
|
+
.run(areaUuid, ctx.nowEpoch, params.uuid);
|
|
954
|
+
}
|
|
955
|
+
}),
|
|
956
|
+
"todo.set-tags": op((sim, params, ctx) => {
|
|
957
|
+
setTaskTags(sim, params.uuid, params.tags);
|
|
958
|
+
sim
|
|
959
|
+
.prepare("UPDATE TMTask SET userModificationDate = ? WHERE uuid = ?")
|
|
960
|
+
.run(ctx.nowEpoch, params.uuid);
|
|
961
|
+
}),
|
|
962
|
+
"todo.replace-checklist": op((sim, params, ctx) => replaceChecklist(sim, params.uuid, params.items, ctx)),
|
|
963
|
+
"project.add": op((sim, params, ctx) => {
|
|
964
|
+
const area = containerUuid(sim, params.area, "area");
|
|
965
|
+
const s = scheduleColumns(params.when, ctx.todayIso, area !== null);
|
|
966
|
+
const uuid = genUuid();
|
|
967
|
+
insertTask(sim, 1, ctx, {
|
|
968
|
+
uuid,
|
|
969
|
+
title: params.title,
|
|
970
|
+
notes: params.notes ?? "",
|
|
971
|
+
start: s.start,
|
|
972
|
+
startDate: s.startDate,
|
|
973
|
+
startBucket: s.startBucket,
|
|
974
|
+
deadline: params.deadline !== undefined ? encodePackedDate(params.deadline) : null,
|
|
975
|
+
area,
|
|
976
|
+
});
|
|
977
|
+
// `todos` seed children are not asserted by the delta and are omitted.
|
|
978
|
+
}),
|
|
979
|
+
"project.update": op((sim, params, ctx) => applyEntityUpdate(sim, params, ctx)),
|
|
980
|
+
"project.complete": op((sim, params, ctx) => {
|
|
981
|
+
// Cascade open children to completed; canceled children are untouched (T08).
|
|
982
|
+
// RSIM-P P2: completion also flips the containing HEADING rows (type=2)
|
|
983
|
+
// status 0→3, not just the to-dos (type=0).
|
|
984
|
+
const children = sim
|
|
985
|
+
.prepare(`SELECT uuid FROM TMTask WHERE trashed = 0 AND status = 0
|
|
986
|
+
AND (
|
|
987
|
+
(type = 0 AND (project = ? OR heading IN (SELECT uuid FROM TMTask WHERE type = 2 AND project = ?)))
|
|
988
|
+
OR (type = 2 AND project = ?)
|
|
989
|
+
)`)
|
|
990
|
+
.all(params.uuid, params.uuid, params.uuid);
|
|
991
|
+
for (const c of children)
|
|
992
|
+
setStatus(sim, c.uuid, STATUS.completed, ctx.nowEpoch, ctx);
|
|
993
|
+
setStatus(sim, params.uuid, STATUS.completed, ctx.nowEpoch, ctx);
|
|
994
|
+
// RSIM-P P2: completing an INSTANCE project also promotes its own start 2→1
|
|
995
|
+
// (observed only for instance projects — a plain project is left untouched).
|
|
996
|
+
sim
|
|
997
|
+
.prepare("UPDATE TMTask SET start = 1 WHERE uuid = ? AND rt1_repeatingTemplate IS NOT NULL")
|
|
998
|
+
.run(params.uuid);
|
|
999
|
+
}),
|
|
1000
|
+
"area.add": op((sim, params) => {
|
|
1001
|
+
const uuid = genUuid();
|
|
1002
|
+
sim
|
|
1003
|
+
.prepare(`INSERT INTO TMArea (uuid, title, visible, "index") VALUES (?, ?, 1, 0)`)
|
|
1004
|
+
.run(uuid, params.title);
|
|
1005
|
+
if (params.tags !== undefined) {
|
|
1006
|
+
for (const tagUuid of tagUuids(sim, params.tags)) {
|
|
1007
|
+
sim.prepare("INSERT INTO TMAreaTag (areas, tags) VALUES (?, ?)").run(uuid, tagUuid);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}),
|
|
1011
|
+
"area.update": op((sim, params) => {
|
|
1012
|
+
const res = resolveArea(sim, { uuid: params.target, title: params.target });
|
|
1013
|
+
if (res.resolved === null)
|
|
1014
|
+
throw new Error("simulator: unresolved area target");
|
|
1015
|
+
const uuid = res.resolved.uuid;
|
|
1016
|
+
if (params.title !== undefined) {
|
|
1017
|
+
sim.prepare("UPDATE TMArea SET title = ? WHERE uuid = ?").run(params.title, uuid);
|
|
1018
|
+
}
|
|
1019
|
+
if (params.tags !== undefined) {
|
|
1020
|
+
sim.prepare("DELETE FROM TMAreaTag WHERE areas = ?").run(uuid);
|
|
1021
|
+
for (const tagUuid of tagUuids(sim, params.tags)) {
|
|
1022
|
+
sim.prepare("INSERT INTO TMAreaTag (areas, tags) VALUES (?, ?)").run(uuid, tagUuid);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}),
|
|
1026
|
+
"tag.add": op((sim, params) => {
|
|
1027
|
+
let parent = null;
|
|
1028
|
+
if (params.parent !== undefined) {
|
|
1029
|
+
const res = resolveTag(sim, params.parent);
|
|
1030
|
+
if (res.resolved === null)
|
|
1031
|
+
throw new Error("simulator: unresolved parent tag");
|
|
1032
|
+
parent = res.resolved.uuid;
|
|
1033
|
+
}
|
|
1034
|
+
sim
|
|
1035
|
+
.prepare(`INSERT INTO TMTag (uuid, title, shortcut, usedDate, parent, "index") VALUES (?, ?, NULL, NULL, ?, 0)`)
|
|
1036
|
+
.run(genUuid(), params.title, parent);
|
|
1037
|
+
}),
|
|
1038
|
+
"heading.create": op((sim, params, ctx) => {
|
|
1039
|
+
const projUuid = containerUuid(sim, params.project, "project");
|
|
1040
|
+
if (projUuid === null)
|
|
1041
|
+
throw new Error("simulator: heading.create needs a project");
|
|
1042
|
+
insertTask(sim, 2, ctx, { uuid: genUuid(), title: params.title, project: projUuid });
|
|
1043
|
+
}),
|
|
1044
|
+
// Recurrence ops (RSIM1–6). project.create-repeating is delivered by the
|
|
1045
|
+
// runCreateRepeatingProject orchestrator over project.add + project.make-repeating
|
|
1046
|
+
// (both covered here), so it needs no direct applier. pause/resume/stop-repeat
|
|
1047
|
+
// are deliberately OMITTED — no RSIM shape proves their delta, and unsupported
|
|
1048
|
+
// beats guessed.
|
|
1049
|
+
"todo.make-repeating": op((sim, params, ctx) => applyMakeRepeating(sim, 0, params, ctx)),
|
|
1050
|
+
"todo.reschedule-repeat": op((sim, params, ctx) => applyReschedule(sim, params, ctx)),
|
|
1051
|
+
"project.make-repeating": op((sim, params, ctx) => applyMakeRepeating(sim, 1, params, ctx)),
|
|
1052
|
+
"project.reschedule-repeat": op((sim, params, ctx) => applyReschedule(sim, params, ctx)),
|
|
1053
|
+
};
|
|
1054
|
+
/** The ops this simulator can apply — the ONLY entries in its honest matrix. */
|
|
1055
|
+
export const SIMULATOR_COVERAGE = Object.keys(APPLIERS);
|
|
1056
|
+
function simulatorMatrix() {
|
|
1057
|
+
const support = {
|
|
1058
|
+
support: "yes",
|
|
1059
|
+
disruption: 0,
|
|
1060
|
+
validation: "validated",
|
|
1061
|
+
notes: "simulated",
|
|
1062
|
+
};
|
|
1063
|
+
const matrix = {};
|
|
1064
|
+
for (const kind of SIMULATOR_COVERAGE)
|
|
1065
|
+
matrix[kind] = { ...support };
|
|
1066
|
+
return matrix;
|
|
1067
|
+
}
|
|
1068
|
+
// ------------------------------------------------------------- vector
|
|
1069
|
+
/**
|
|
1070
|
+
* Build the simulator vector for `dbPath`. Throws at creation if the fence is
|
|
1071
|
+
* not satisfied (so a misconfigured harness fails loud, never silently writing
|
|
1072
|
+
* to a wrong DB). `now` is injectable for tests; production reads the effective
|
|
1073
|
+
* clock from the environment (THINGS_NOW) so applier timestamps align with the
|
|
1074
|
+
* pipeline's, which the create-probe verification depends on.
|
|
1075
|
+
*/
|
|
1076
|
+
export function createSimulatorVector(dbPath, opts = {}) {
|
|
1077
|
+
const reason = simulatorFenceReason(dbPath);
|
|
1078
|
+
if (reason !== null) {
|
|
1079
|
+
throw new Error(`simulator write vector refused: ${reason}`);
|
|
1080
|
+
}
|
|
1081
|
+
const now = opts.now ?? resolveEnvNow();
|
|
1082
|
+
let sim;
|
|
1083
|
+
return {
|
|
1084
|
+
id: "url-scheme",
|
|
1085
|
+
matrix: simulatorMatrix(),
|
|
1086
|
+
simulates: true,
|
|
1087
|
+
async execute(invocation) {
|
|
1088
|
+
// Defensive re-check: the environment could have changed underneath a
|
|
1089
|
+
// live instance. Refuse rather than write to a now-unfenced DB.
|
|
1090
|
+
const liveReason = simulatorFenceReason(dbPath);
|
|
1091
|
+
if (liveReason !== null) {
|
|
1092
|
+
return { exitCode: 1, stdout: "", stderr: `simulator fence inactive: ${liveReason}` };
|
|
1093
|
+
}
|
|
1094
|
+
const kind = invocation.op;
|
|
1095
|
+
if (kind === undefined) {
|
|
1096
|
+
return {
|
|
1097
|
+
exitCode: 1,
|
|
1098
|
+
stdout: "",
|
|
1099
|
+
stderr: "simulator: invocation carries no structured op",
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
const applier = APPLIERS[kind];
|
|
1103
|
+
if (applier === undefined) {
|
|
1104
|
+
return { exitCode: 1, stdout: "", stderr: `simulator: no applier for op ${kind}` };
|
|
1105
|
+
}
|
|
1106
|
+
sim ??= new DatabaseSync(dbPath);
|
|
1107
|
+
const when = now();
|
|
1108
|
+
const ctx = {
|
|
1109
|
+
nowEpoch: Math.floor(when.getTime() / 1000),
|
|
1110
|
+
todayIso: localToday(when),
|
|
1111
|
+
};
|
|
1112
|
+
try {
|
|
1113
|
+
applier(sim, invocation.opParams, ctx);
|
|
1114
|
+
}
|
|
1115
|
+
catch (err) {
|
|
1116
|
+
return {
|
|
1117
|
+
exitCode: 1,
|
|
1118
|
+
stdout: "",
|
|
1119
|
+
stderr: `simulator applier for ${kind} failed: ${err.message}`,
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
1123
|
+
},
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
/** Read the pinned THINGS_NOW instant, if any; else real time per call. */
|
|
1127
|
+
function resolveEnvNow() {
|
|
1128
|
+
const raw = process.env["THINGS_NOW"];
|
|
1129
|
+
if (raw !== undefined && raw.trim() !== "") {
|
|
1130
|
+
const ms = new Date(raw).getTime();
|
|
1131
|
+
if (!Number.isNaN(ms))
|
|
1132
|
+
return () => new Date(ms);
|
|
1133
|
+
}
|
|
1134
|
+
return () => new Date();
|
|
1135
|
+
}
|
|
1136
|
+
//# sourceMappingURL=simulator.js.map
|