things-api 0.11.0 → 0.13.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 +16 -5
- package/dist/audit/schema.d.ts +7 -0
- package/dist/audit/schema.js.map +1 -1
- package/dist/cli/canonical-ref.d.ts +21 -0
- package/dist/cli/canonical-ref.js +23 -0
- package/dist/cli/canonical-ref.js.map +1 -0
- package/dist/cli/commands/area.d.ts +18 -11
- package/dist/cli/commands/area.js +81 -77
- package/dist/cli/commands/area.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts +1 -2
- package/dist/cli/commands/doctor.js +11 -1
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/install-skill.d.ts +19 -4
- package/dist/cli/commands/install-skill.js +130 -52
- package/dist/cli/commands/install-skill.js.map +1 -1
- package/dist/cli/commands/mcp.js +21 -1
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/project.d.ts +1 -0
- package/dist/cli/commands/project.js +92 -48
- package/dist/cli/commands/project.js.map +1 -1
- package/dist/cli/commands/reads.d.ts +5 -1
- package/dist/cli/commands/reads.js +110 -31
- package/dist/cli/commands/reads.js.map +1 -1
- package/dist/cli/commands/setup.js +11 -4
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/show.js +75 -36
- package/dist/cli/commands/show.js.map +1 -1
- package/dist/cli/commands/todo.js +2 -2
- package/dist/cli/commands/todo.js.map +1 -1
- package/dist/cli/commands/writes.js +795 -144
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/did-you-mean.d.ts +14 -3
- package/dist/cli/did-you-mean.js +44 -7
- package/dist/cli/did-you-mean.js.map +1 -1
- package/dist/cli/dry-run.d.ts +55 -0
- package/dist/cli/dry-run.js +92 -0
- package/dist/cli/dry-run.js.map +1 -0
- package/dist/cli/glyphs.d.ts +16 -2
- package/dist/cli/glyphs.js +60 -12
- package/dist/cli/glyphs.js.map +1 -1
- package/dist/cli/help.js +69 -13
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +8 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/move-hint.d.ts +3 -2
- package/dist/cli/move-hint.js +2 -2
- package/dist/cli/move-hint.js.map +1 -1
- package/dist/cli/read-driver.d.ts +53 -5
- package/dist/cli/read-driver.js +82 -15
- package/dist/cli/read-driver.js.map +1 -1
- package/dist/cli/ref-render.d.ts +14 -0
- package/dist/cli/ref-render.js +9 -0
- package/dist/cli/ref-render.js.map +1 -0
- package/dist/cli/render.d.ts +40 -14
- package/dist/cli/render.js +128 -55
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/resolve-invocation.d.ts +7 -5
- package/dist/cli/resolve-invocation.js +16 -7
- package/dist/cli/resolve-invocation.js.map +1 -1
- package/dist/cli/skill-check.d.ts +12 -6
- package/dist/cli/skill-check.js +31 -14
- package/dist/cli/skill-check.js.map +1 -1
- package/dist/cli/skill.d.ts +8 -0
- package/dist/cli/skill.js +10 -0
- package/dist/cli/skill.js.map +1 -1
- package/dist/cli/verb-hint.js +1 -1
- package/dist/cli/verb-hint.js.map +1 -1
- package/dist/client.d.ts +158 -26
- package/dist/client.js +254 -38
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +66 -2
- package/dist/config.js +120 -12
- package/dist/config.js.map +1 -1
- package/dist/contracts.d.ts +187 -28
- package/dist/contracts.js +22 -1
- package/dist/contracts.js.map +1 -1
- package/dist/db/baselines/db-v26.js +4 -1
- package/dist/db/baselines/db-v26.js.map +1 -1
- package/dist/diagnose.d.ts +34 -2
- package/dist/diagnose.js +36 -1
- package/dist/diagnose.js.map +1 -1
- package/dist/index.d.ts +21 -8
- package/dist/index.js +26 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.js +790 -322
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/entities.d.ts +57 -4
- package/dist/model/entities.js.map +1 -1
- package/dist/model/mappers.d.ts +2 -0
- package/dist/model/mappers.js +44 -2
- package/dist/model/mappers.js.map +1 -1
- package/dist/model/recurrence.d.ts +8 -1
- package/dist/model/recurrence.js.map +1 -1
- package/dist/model/serialize.js +15 -3
- package/dist/model/serialize.js.map +1 -1
- package/dist/read/area-filter.d.ts +76 -0
- package/dist/read/area-filter.js +58 -0
- package/dist/read/area-filter.js.map +1 -0
- package/dist/read/area-view.d.ts +22 -11
- package/dist/read/area-view.js +54 -28
- package/dist/read/area-view.js.map +1 -1
- package/dist/read/detail.js +15 -8
- package/dist/read/detail.js.map +1 -1
- package/dist/read/predicates.d.ts +18 -0
- package/dist/read/predicates.js +19 -0
- package/dist/read/predicates.js.map +1 -1
- package/dist/read/project-view.d.ts +91 -15
- package/dist/read/project-view.js +135 -37
- package/dist/read/project-view.js.map +1 -1
- package/dist/read/pseudo-area.d.ts +29 -0
- package/dist/read/pseudo-area.js +27 -0
- package/dist/read/pseudo-area.js.map +1 -0
- package/dist/read/queries.d.ts +175 -19
- package/dist/read/queries.js +376 -32
- package/dist/read/queries.js.map +1 -1
- package/dist/read/scope.d.ts +126 -0
- package/dist/read/scope.js +161 -0
- package/dist/read/scope.js.map +1 -0
- package/dist/read/search-rank.d.ts +2 -5
- package/dist/read/shape.d.ts +181 -0
- package/dist/read/shape.js +737 -0
- package/dist/read/shape.js.map +1 -0
- package/dist/read/show-target.d.ts +9 -1
- package/dist/read/show-target.js +77 -17
- package/dist/read/show-target.js.map +1 -1
- package/dist/read/stage.d.ts +199 -0
- package/dist/read/stage.js +125 -0
- package/dist/read/stage.js.map +1 -0
- package/dist/read/truncation.d.ts +7 -12
- package/dist/read/truncation.js +56 -21
- package/dist/read/truncation.js.map +1 -1
- package/dist/read/views.d.ts +68 -16
- package/dist/read/views.js +206 -52
- package/dist/read/views.js.map +1 -1
- package/dist/surface-copy.d.ts +16 -0
- package/dist/surface-copy.js +16 -0
- package/dist/surface-copy.js.map +1 -1
- package/dist/write/batch.d.ts +49 -10
- package/dist/write/batch.js +423 -71
- package/dist/write/batch.js.map +1 -1
- package/dist/write/commands.js +268 -55
- package/dist/write/commands.js.map +1 -1
- package/dist/write/guards.d.ts +1 -1
- package/dist/write/guards.js +71 -10
- package/dist/write/guards.js.map +1 -1
- package/dist/write/heading.d.ts +10 -1
- package/dist/write/heading.js +35 -5
- package/dist/write/heading.js.map +1 -1
- package/dist/write/make-repeating-project.d.ts +2 -2
- package/dist/write/make-repeating-project.js +9 -9
- package/dist/write/make-repeating-project.js.map +1 -1
- package/dist/write/move.d.ts +134 -0
- package/dist/write/move.js +1874 -0
- package/dist/write/move.js.map +1 -0
- package/dist/write/operations.d.ts +105 -19
- package/dist/write/operations.js +56 -7
- package/dist/write/operations.js.map +1 -1
- package/dist/write/opid.d.ts +31 -0
- package/dist/write/opid.js +30 -0
- package/dist/write/opid.js.map +1 -0
- package/dist/write/pipeline.d.ts +70 -3
- package/dist/write/pipeline.js +198 -33
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.d.ts +172 -8
- package/dist/write/pre-state.js +467 -25
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/reorder.d.ts +23 -3
- package/dist/write/reorder.js +2106 -86
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/reversibility.js +19 -7
- package/dist/write/reversibility.js.map +1 -1
- package/dist/write/scope-guard.d.ts +31 -0
- package/dist/write/scope-guard.js +162 -0
- package/dist/write/scope-guard.js.map +1 -0
- package/dist/write/undo.js +120 -13
- package/dist/write/undo.js.map +1 -1
- package/dist/write/vectors/applescript.js +16 -7
- package/dist/write/vectors/applescript.js.map +1 -1
- package/dist/write/vectors/shortcuts.js +1 -1
- package/dist/write/vectors/shortcuts.js.map +1 -1
- package/dist/write/vectors/simulator.js +83 -27
- package/dist/write/vectors/simulator.js.map +1 -1
- package/dist/write/vectors/types.d.ts +11 -0
- package/dist/write/vectors/ui-certification.d.ts +3 -3
- package/dist/write/vectors/ui-certification.js +41 -5
- package/dist/write/vectors/ui-certification.js.map +1 -1
- package/dist/write/vectors/ui-drag.d.ts +21 -1
- package/dist/write/vectors/ui-drag.js +138 -27
- package/dist/write/vectors/ui-drag.js.map +1 -1
- package/dist/write/vectors/ui-recipes.d.ts +21 -1
- package/dist/write/vectors/ui-recipes.js +166 -7
- package/dist/write/vectors/ui-recipes.js.map +1 -1
- package/dist/write/vectors/ui.d.ts +25 -1
- package/dist/write/vectors/ui.js +142 -33
- package/dist/write/vectors/ui.js.map +1 -1
- package/dist/write/verify/delta.d.ts +17 -1
- package/dist/write/verify/delta.js +106 -29
- package/dist/write/verify/delta.js.map +1 -1
- package/package.json +7 -1
- package/schema/envelope.schema.json +397 -0
- package/skills/things-cli/SKILL.md +36 -17
- package/skills/things-cli/references/banner.md +35 -0
- package/skills/things-cli/references/contracts.md +32 -9
- package/skills/things-cli/references/errors.md +57 -0
- package/skills/things-cli/references/gui.md +1 -1
- package/skills/things-cli/references/model.md +18 -7
- package/skills/things-cli/references/ordering.md +88 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { addRepeatRuleFlags, repeatRuleFlagsFromOpts } from "./repeat-flags.js";
|
|
3
|
-
import { aggregateExitCode, blockedCode,
|
|
3
|
+
import { aggregateExitCode, blockedCode, capabilitiesTable, ClockError, describeConfig, errorEnvelope, ExitCode, getConfigKey, mutationWireData, okEnvelope, openThings, OP_ID_RE, outcomeFailed, ReferenceResolutionError, saveConfigKey, splitWhenSugar, ThingsDbNotFoundError, ThingsDbOpenError, verifyFailedCode, } from "../../index.js";
|
|
4
4
|
import { usageError } from "../read-driver.js";
|
|
5
|
+
import { dim } from "../style.js";
|
|
5
6
|
function addWriteFlags(cmd) {
|
|
6
7
|
return cmd
|
|
7
8
|
.option("--json", "emit versioned JSON envelope on stdout")
|
|
@@ -11,14 +12,68 @@ function addWriteFlags(cmd) {
|
|
|
11
12
|
.option("--allow-disruptive", "permit changes that briefly steal window focus")
|
|
12
13
|
.option("--allow-very-disruptive", "permit changes that visibly drive the Things UI")
|
|
13
14
|
.option("--verify-timeout <ms>", "how long to wait for the change to take effect")
|
|
14
|
-
.option("--actor <name>", "author name recorded for this change (default: from config)")
|
|
15
|
+
.option("--actor <name>", "author name recorded for this change (default: from config)")
|
|
16
|
+
.option("--op-id <key>", "idempotency key: a resubmission with the same key is recognized as already applied " +
|
|
17
|
+
"and not re-run (matches [A-Za-z0-9_-], 1-64 chars)");
|
|
18
|
+
}
|
|
19
|
+
/** Validate an `--op-id`; on a malformed value emit the usage error and return false. */
|
|
20
|
+
function opIdOk(opts) {
|
|
21
|
+
if (opts.opId === undefined)
|
|
22
|
+
return true;
|
|
23
|
+
if (OP_ID_RE.test(opts.opId))
|
|
24
|
+
return true;
|
|
25
|
+
usageError(opts, "--op-id must match [A-Za-z0-9_-] and be 1-64 characters");
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Refuse `--op-id` on a multi-leg COMPOUND command. Single-op idempotency (phase
|
|
30
|
+
* 1) replays exactly ONE recorded ok result; a compound records several (or a
|
|
31
|
+
* summary) and its idempotency is the batch-shaped per-line `op_id`. Refusing is
|
|
32
|
+
* honest — dropping the flag silently would leave a resubmission un-deduped.
|
|
33
|
+
* Returns true when refused (the caller returns).
|
|
34
|
+
*/
|
|
35
|
+
function opIdCompoundRefused(opts, what) {
|
|
36
|
+
if (opts.opId === undefined)
|
|
37
|
+
return false;
|
|
38
|
+
usageError(opts, `--op-id is not available on ${what} (a multi-leg compound in phase 1) — express it as ` +
|
|
39
|
+
"`things batch` with a per-line op_id for idempotent resubmission");
|
|
40
|
+
return true;
|
|
15
41
|
}
|
|
16
42
|
/** A commander flag value when present-with-value (bare presence yields `true`). */
|
|
17
43
|
const flagVal = (v) => (typeof v === "string" ? v : undefined);
|
|
18
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Writes one batch result as a FLATTENED NDJSON line to stdout: the internal
|
|
46
|
+
* `outcome` union object is collapsed to one level. `outcome` becomes its tag
|
|
47
|
+
* as a plain string (`"ok"`, `"blocked"`, `"verify-failed"`, `"unsupported"`,
|
|
48
|
+
* `"dry-run"`, `"invalid"`, `"skipped"`, `"already-applied"`, `"bounce-aborted"`),
|
|
49
|
+
* and every variant field (uuid, detail, plan, expected, observed, considered,
|
|
50
|
+
* placed, remaining, cause, …) sits as a sibling of the line-level keys. The
|
|
51
|
+
* outcome's own `op` duplicates the line-level `op`, so it is dropped. No
|
|
52
|
+
* variant field name collides with a line-level key (index, op, outcome,
|
|
53
|
+
* tempId, boundUuid, opId).
|
|
54
|
+
*/
|
|
19
55
|
const emit = (r) => {
|
|
20
|
-
|
|
56
|
+
const { index, op, outcome, tempId, boundUuid, opId } = r;
|
|
57
|
+
const { kind, op: _outcomeOp, ...variant } = outcome;
|
|
58
|
+
const line = {
|
|
59
|
+
index,
|
|
60
|
+
op,
|
|
61
|
+
outcome: kind,
|
|
62
|
+
...variant,
|
|
63
|
+
...(tempId !== undefined && { tempId }),
|
|
64
|
+
...(boundUuid !== undefined && { boundUuid }),
|
|
65
|
+
...(opId !== undefined && { opId }),
|
|
66
|
+
};
|
|
67
|
+
process.stdout.write(`${JSON.stringify(line)}\n`);
|
|
21
68
|
};
|
|
69
|
+
/**
|
|
70
|
+
* One `config get` line: `key: value`, plus a dim provenance marker for a
|
|
71
|
+
* default, env-sourced, or derived value (stored keys render bare).
|
|
72
|
+
*/
|
|
73
|
+
function configKeyLine(entry) {
|
|
74
|
+
const marker = entry.source === "stored" ? "" : ` ${dim(`(${entry.source})`)}`;
|
|
75
|
+
return `${entry.key}: ${String(entry.value)}${marker}`;
|
|
76
|
+
}
|
|
22
77
|
function writeOptionsFrom(opts, extra = {}) {
|
|
23
78
|
const maxDisruption = opts.allowVeryDisruptive
|
|
24
79
|
? 3
|
|
@@ -32,6 +87,7 @@ function writeOptionsFrom(opts, extra = {}) {
|
|
|
32
87
|
...(opts.verifyTimeout !== undefined && { verifyTimeoutMs: Number(opts.verifyTimeout) }),
|
|
33
88
|
...(opts.actor !== undefined && { actor: opts.actor }),
|
|
34
89
|
...(opts.dangerouslyDriveGui === true && { dangerouslyDriveGui: true }),
|
|
90
|
+
...(opts.opId !== undefined && { opId: opts.opId }),
|
|
35
91
|
...extra,
|
|
36
92
|
};
|
|
37
93
|
}
|
|
@@ -47,6 +103,38 @@ function addCreateTagsFlag(cmd) {
|
|
|
47
103
|
function createTagsExtra(opts) {
|
|
48
104
|
return opts["createTags"] === true ? { createTags: true } : {};
|
|
49
105
|
}
|
|
106
|
+
/** Heading selector help string (exact title or uuid — never an ordinal). */
|
|
107
|
+
const HEADING_SEL_HELP = "heading selector: exact title or uuid";
|
|
108
|
+
/** The four heading-placement flags (spec §2), shared by add/move-heading. */
|
|
109
|
+
function addPlacementFlags(cmd) {
|
|
110
|
+
return cmd
|
|
111
|
+
.option("--first", "place it first among the project's headings")
|
|
112
|
+
.option("--last", "place it last among the project's headings")
|
|
113
|
+
.option("--before-heading <sel>", "place it immediately before this heading (title or uuid)")
|
|
114
|
+
.option("--after-heading <sel>", "place it immediately after this heading (title or uuid)");
|
|
115
|
+
}
|
|
116
|
+
function countPlacementFlags(opts) {
|
|
117
|
+
return [
|
|
118
|
+
opts["first"] === true,
|
|
119
|
+
opts["last"] === true,
|
|
120
|
+
opts["beforeHeading"] !== undefined,
|
|
121
|
+
opts["afterHeading"] !== undefined,
|
|
122
|
+
].filter(Boolean).length;
|
|
123
|
+
}
|
|
124
|
+
/** Build the resolved placement from the flags, resolving anchor selectors. */
|
|
125
|
+
function headingPlacement(c, projectUuid, opts) {
|
|
126
|
+
if (opts["first"] === true)
|
|
127
|
+
return { position: "first" };
|
|
128
|
+
if (opts["last"] === true)
|
|
129
|
+
return { position: "last" };
|
|
130
|
+
if (opts["beforeHeading"] !== undefined) {
|
|
131
|
+
return { before: c.resolve.heading(projectUuid, opts["beforeHeading"]).uuid };
|
|
132
|
+
}
|
|
133
|
+
if (opts["afterHeading"] !== undefined) {
|
|
134
|
+
return { after: c.resolve.heading(projectUuid, opts["afterHeading"]).uuid };
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
50
138
|
/** Add the mandatory GUI-drive acknowledgement to a ui-vector command. */
|
|
51
139
|
function addDriveGuiFlag(cmd) {
|
|
52
140
|
return cmd.option("--dangerously-drive-gui", "required: visibly drives the Things app to make a change it offers nowhere else; " +
|
|
@@ -87,7 +175,9 @@ function splitCsv(value) {
|
|
|
87
175
|
.map((s) => s.trim())
|
|
88
176
|
.filter((s) => s !== "");
|
|
89
177
|
}
|
|
90
|
-
async function runWrite(opts, fn) {
|
|
178
|
+
async function runWrite(opts, fn, emitFn = emitResult) {
|
|
179
|
+
if (!opIdOk(opts))
|
|
180
|
+
return;
|
|
91
181
|
const started = Date.now();
|
|
92
182
|
let client = null;
|
|
93
183
|
const meta = (client_) => {
|
|
@@ -105,7 +195,7 @@ async function runWrite(opts, fn) {
|
|
|
105
195
|
try {
|
|
106
196
|
client = openThings(opts.db ? { dbPath: opts.db } : {});
|
|
107
197
|
const result = await fn(client);
|
|
108
|
-
|
|
198
|
+
emitFn(result, opts, meta(client));
|
|
109
199
|
}
|
|
110
200
|
catch (err) {
|
|
111
201
|
// An unresolved write target (uuid/partial-uuid/name that is ambiguous or
|
|
@@ -116,7 +206,7 @@ async function runWrite(opts, fn) {
|
|
|
116
206
|
process.stdout.write(`${JSON.stringify(errorEnvelope({
|
|
117
207
|
code: err.code,
|
|
118
208
|
message: err.message,
|
|
119
|
-
|
|
209
|
+
detail: { candidates: err.candidates },
|
|
120
210
|
}, meta(client)))}\n`);
|
|
121
211
|
}
|
|
122
212
|
else {
|
|
@@ -167,11 +257,14 @@ function emitResult(result, opts, meta) {
|
|
|
167
257
|
process.stderr.write(`warning: ${warning}\n`);
|
|
168
258
|
}
|
|
169
259
|
if (opts.json) {
|
|
170
|
-
process.stdout.write(`${JSON.stringify(okEnvelope("mutation-result", result, meta))}\n`);
|
|
260
|
+
process.stdout.write(`${JSON.stringify(okEnvelope("mutation-result", mutationWireData(result), meta))}\n`);
|
|
171
261
|
}
|
|
172
262
|
else {
|
|
173
263
|
const uuid = result.uuid === null ? "" : ` uuid=${result.uuid}`;
|
|
174
|
-
|
|
264
|
+
const status = "alreadyApplied" in result && result.alreadyApplied === true
|
|
265
|
+
? "already applied — matched op-id in the change history, not re-run"
|
|
266
|
+
: `vector=${result.vector}, tier=${result.tier}, verified`;
|
|
267
|
+
process.stdout.write(`ok ${result.op}${uuid} (${status})\n`);
|
|
175
268
|
}
|
|
176
269
|
process.exitCode = ExitCode.Ok;
|
|
177
270
|
return;
|
|
@@ -233,7 +326,7 @@ function emitResult(result, opts, meta) {
|
|
|
233
326
|
process.stdout.write(`${JSON.stringify(errorEnvelope({
|
|
234
327
|
code: "unsupported",
|
|
235
328
|
message: `no validated vector supports ${result.op}`,
|
|
236
|
-
detail: result.considered,
|
|
329
|
+
detail: { considered: result.considered },
|
|
237
330
|
}, meta))}\n`);
|
|
238
331
|
}
|
|
239
332
|
else {
|
|
@@ -251,6 +344,303 @@ function emitResult(result, opts, meta) {
|
|
|
251
344
|
}
|
|
252
345
|
}
|
|
253
346
|
}
|
|
347
|
+
// -------------------------------------------------- move / reorder (spec §4)
|
|
348
|
+
/** Open the client, run a MoveResult-returning fn, and render it. */
|
|
349
|
+
async function runMoveCmd(opts, fn) {
|
|
350
|
+
// A variadic move/reorder is a multi-leg COMPOUND (the batch-shaped case), not
|
|
351
|
+
// a single recorded mutation — single-op idempotency (--op-id) does not apply
|
|
352
|
+
// in phase 1. Refuse it loudly rather than silently drop it; the compound
|
|
353
|
+
// analogue is `things batch` with a per-line op_id.
|
|
354
|
+
if (opts.opId !== undefined) {
|
|
355
|
+
usageError(opts, "--op-id is not available on a variadic move/reorder (a multi-leg compound) — express it " +
|
|
356
|
+
"as `things batch` with a per-line op_id for idempotent resubmission");
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const started = Date.now();
|
|
360
|
+
let client = null;
|
|
361
|
+
const meta = () => {
|
|
362
|
+
let dbVersion = null;
|
|
363
|
+
let fingerprint = "unknown";
|
|
364
|
+
if (client !== null) {
|
|
365
|
+
const fp = client.fingerprint();
|
|
366
|
+
dbVersion = fp.observation.databaseVersion;
|
|
367
|
+
fingerprint = fp.kind === "ok" ? "ok" : fp.kind === "drift" ? "drift" : "unknown";
|
|
368
|
+
}
|
|
369
|
+
return { dbVersion, fingerprint, elapsedMs: Date.now() - started };
|
|
370
|
+
};
|
|
371
|
+
try {
|
|
372
|
+
client = openThings(opts.db ? { dbPath: opts.db } : {});
|
|
373
|
+
emitMoveResult(await fn(client), opts, meta());
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
if (err instanceof ReferenceResolutionError) {
|
|
377
|
+
if (opts.json) {
|
|
378
|
+
process.stdout.write(`${JSON.stringify(errorEnvelope({ code: err.code, message: err.message, detail: { candidates: err.candidates } }, meta()))}\n`);
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
process.stderr.write(`error: ${err.message}\n`);
|
|
382
|
+
}
|
|
383
|
+
process.exitCode = ExitCode.Usage;
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
387
|
+
if (opts.json) {
|
|
388
|
+
process.stdout.write(`${JSON.stringify(errorEnvelope({ code: "unexpected", message }, meta()))}\n`);
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
process.stderr.write(`error: ${message}\n`);
|
|
392
|
+
}
|
|
393
|
+
process.exitCode = ExitCode.Unexpected;
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
client?.close();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
function emitMoveResult(result, opts, meta) {
|
|
400
|
+
switch (result.kind) {
|
|
401
|
+
case "move-ok": {
|
|
402
|
+
if (opts.json) {
|
|
403
|
+
process.stdout.write(`${JSON.stringify(okEnvelope("move-result", mutationWireData(result), meta))}\n`);
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
const who = result.movees.map((m) => m.title ?? m.uuid).join(", ");
|
|
407
|
+
process.stdout.write(`ok ${result.op}: moved ${result.movees.length} item(s) — ${who}\n` +
|
|
408
|
+
` placement: ${result.placementClass} — ${result.note}\n`);
|
|
409
|
+
}
|
|
410
|
+
process.exitCode = ExitCode.Ok;
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
case "move-dry-run": {
|
|
414
|
+
if (opts.json) {
|
|
415
|
+
process.stdout.write(`${JSON.stringify(okEnvelope("move-plan", result.plan, meta))}\n`);
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
process.stdout.write([
|
|
419
|
+
`DRY RUN ${result.op}`,
|
|
420
|
+
` movees: ${result.plan.movees.join(", ")}`,
|
|
421
|
+
` membership: ${result.plan.membership}`,
|
|
422
|
+
` placement: ${result.plan.placement} (${result.plan.placementClass})`,
|
|
423
|
+
` ${result.plan.note}`,
|
|
424
|
+
"",
|
|
425
|
+
].join("\n"));
|
|
426
|
+
}
|
|
427
|
+
process.exitCode = ExitCode.Ok;
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
case "move-refused": {
|
|
431
|
+
const code = result.refusal === "usage"
|
|
432
|
+
? ExitCode.Usage
|
|
433
|
+
: result.refusal === "unsupported"
|
|
434
|
+
? ExitCode.Unsupported
|
|
435
|
+
: ExitCode.Blocked;
|
|
436
|
+
// A hoisted placement block names its hazard — surface it with the canonical
|
|
437
|
+
// `blocked:<hazard>` code + `BLOCKED (<hazard>)` copy, exactly as a direct
|
|
438
|
+
// `things reorder` hazard block does (emitResult "blocked" case).
|
|
439
|
+
const jsonCode = result.hazard !== undefined
|
|
440
|
+
? blockedCode({ hazard: result.hazard, reason: "hazard" })
|
|
441
|
+
: result.refusal;
|
|
442
|
+
if (opts.json) {
|
|
443
|
+
process.stdout.write(`${JSON.stringify(errorEnvelope({
|
|
444
|
+
code: jsonCode,
|
|
445
|
+
message: result.detail,
|
|
446
|
+
...(result.remediation !== undefined && { remediation: result.remediation }),
|
|
447
|
+
...(result.candidates !== undefined && {
|
|
448
|
+
detail: { candidates: result.candidates },
|
|
449
|
+
}),
|
|
450
|
+
}, meta))}\n`);
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
const prefix = result.refusal === "usage"
|
|
454
|
+
? "error"
|
|
455
|
+
: result.hazard !== undefined
|
|
456
|
+
? `BLOCKED (${result.hazard})`
|
|
457
|
+
: result.refusal.toUpperCase();
|
|
458
|
+
process.stderr.write(`${prefix}: ${result.detail}\n` +
|
|
459
|
+
(result.remediation !== undefined ? ` remediation: ${result.remediation}\n` : ""));
|
|
460
|
+
}
|
|
461
|
+
process.exitCode = code;
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
case "move-leg-failed": {
|
|
465
|
+
if (opts.json) {
|
|
466
|
+
process.stdout.write(`${JSON.stringify(errorEnvelope({ code: "verify-failed", message: result.detail, detail: { failed: result.failed, completed: result.completed } }, meta))}\n`);
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
process.stderr.write(`MOVE FAILED: ${result.detail}\n`);
|
|
470
|
+
}
|
|
471
|
+
process.exitCode = ExitCode.VerifyFailed;
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/** Build a MovePosition from the shared --first/--last/--before/--after flags. */
|
|
477
|
+
function movePosition(opts) {
|
|
478
|
+
const chosen = [
|
|
479
|
+
opts["first"] === true,
|
|
480
|
+
opts["last"] === true,
|
|
481
|
+
opts["before"] !== undefined,
|
|
482
|
+
opts["after"] !== undefined,
|
|
483
|
+
].filter(Boolean).length;
|
|
484
|
+
if (chosen > 1)
|
|
485
|
+
return "conflict";
|
|
486
|
+
if (opts["first"] === true)
|
|
487
|
+
return { at: "first" };
|
|
488
|
+
if (opts["last"] === true)
|
|
489
|
+
return { at: "last" };
|
|
490
|
+
if (opts["before"] !== undefined)
|
|
491
|
+
return { before: opts["before"] };
|
|
492
|
+
if (opts["after"] !== undefined)
|
|
493
|
+
return { after: opts["after"] };
|
|
494
|
+
return undefined;
|
|
495
|
+
}
|
|
496
|
+
/** Build a to-do move destination; "conflict" when more than one is named. */
|
|
497
|
+
function todoDestination(opts) {
|
|
498
|
+
const dests = [];
|
|
499
|
+
const toProject = opts["toProject"];
|
|
500
|
+
const toHeading = opts["toHeading"];
|
|
501
|
+
if (toHeading !== undefined) {
|
|
502
|
+
dests.push({
|
|
503
|
+
kind: "heading",
|
|
504
|
+
sel: toHeading,
|
|
505
|
+
...(toProject !== undefined && { project: { uuid: toProject, title: toProject } }),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
else if (toProject !== undefined) {
|
|
509
|
+
dests.push({ kind: "project", ref: { uuid: toProject, title: toProject } });
|
|
510
|
+
}
|
|
511
|
+
const toArea = opts["toArea"];
|
|
512
|
+
if (toArea !== undefined)
|
|
513
|
+
dests.push({ kind: "area", ref: { uuid: toArea, title: toArea } });
|
|
514
|
+
// Commander maps `--no-heading`/`--no-area` onto the base key as `false`.
|
|
515
|
+
if (opts["heading"] === false)
|
|
516
|
+
dests.push({ kind: "no-heading" });
|
|
517
|
+
if (opts["loose"] === true)
|
|
518
|
+
dests.push({ kind: "loose" });
|
|
519
|
+
if (opts["inbox"] === true)
|
|
520
|
+
dests.push({ kind: "inbox" });
|
|
521
|
+
if (opts["area"] === false)
|
|
522
|
+
dests.push({ kind: "no-area" });
|
|
523
|
+
if (opts["detach"] === true)
|
|
524
|
+
dests.push({ kind: "detach" });
|
|
525
|
+
if (dests.length > 1)
|
|
526
|
+
return "conflict";
|
|
527
|
+
return dests[0];
|
|
528
|
+
}
|
|
529
|
+
/** Build a project move destination; "conflict" when more than one is named. */
|
|
530
|
+
function projectDestination(opts) {
|
|
531
|
+
const dests = [];
|
|
532
|
+
const toArea = opts["toArea"];
|
|
533
|
+
if (toArea !== undefined)
|
|
534
|
+
dests.push({ kind: "area", ref: { uuid: toArea, title: toArea } });
|
|
535
|
+
// Commander maps `--no-area` onto the base key `area` as `false`.
|
|
536
|
+
if (opts["area"] === false)
|
|
537
|
+
dests.push({ kind: "no-area" });
|
|
538
|
+
if (opts["loose"] === true)
|
|
539
|
+
dests.push({ kind: "loose" });
|
|
540
|
+
if (opts["detach"] === true)
|
|
541
|
+
dests.push({ kind: "detach" });
|
|
542
|
+
if (dests.length > 1)
|
|
543
|
+
return "conflict";
|
|
544
|
+
return dests[0];
|
|
545
|
+
}
|
|
546
|
+
/** The shared position flags (--first/--last/--before/--after) for move/reorder. */
|
|
547
|
+
function addPositionFlags(cmd) {
|
|
548
|
+
return cmd
|
|
549
|
+
.option("--first", "place the block at the top of its bucket")
|
|
550
|
+
.option("--last", "place the block at the bottom of its bucket")
|
|
551
|
+
.option("--before <ref>", "place the block immediately before this item (same bucket)")
|
|
552
|
+
.option("--after <ref>", "place the block immediately after this item (same bucket)");
|
|
553
|
+
}
|
|
554
|
+
/** One TTY line for a bulk-add batch item (the human, non-JSON multi rendering). */
|
|
555
|
+
function addResultLine(r) {
|
|
556
|
+
const o = r.outcome;
|
|
557
|
+
switch (o.kind) {
|
|
558
|
+
case "ok":
|
|
559
|
+
return `ok todo.add uuid=${o.uuid ?? ""} (vector=${o.vector}, tier=${o.tier}, verified)\n`;
|
|
560
|
+
case "dry-run":
|
|
561
|
+
return `DRY RUN todo.add (vector=${o.plan.vector}, tier ${o.plan.tier}) ${o.plan.invocation}\n`;
|
|
562
|
+
case "already-applied":
|
|
563
|
+
return `already-applied todo.add uuid=${o.uuid}\n`;
|
|
564
|
+
case "skipped":
|
|
565
|
+
return `skipped todo.add: ${o.detail}\n`;
|
|
566
|
+
case "invalid":
|
|
567
|
+
return `FAILED todo.add: ${o.detail}\n`;
|
|
568
|
+
case "blocked":
|
|
569
|
+
return `BLOCKED todo.add (${o.hazard ?? o.reason}): ${o.detail}\n`;
|
|
570
|
+
case "verify-failed":
|
|
571
|
+
return `VERIFY FAILED todo.add (${o.reason}): ${o.detail}\n`;
|
|
572
|
+
case "unsupported":
|
|
573
|
+
return `UNSUPPORTED todo.add\n`;
|
|
574
|
+
default:
|
|
575
|
+
return `FAILED todo.add: ${JSON.stringify(o)}\n`;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Run a bulk `todo add` as ONE batch of `todo.add` legs (shared flags already
|
|
580
|
+
* compiled into every op's params). Streams per-line results and a trailing
|
|
581
|
+
* summary carrying the single `undoToken` that removes the whole skeleton —
|
|
582
|
+
* except under `--id-only`, where output is exactly one uuid per created item,
|
|
583
|
+
* in creation order, and nothing else. Exit code is the worst leg's failure.
|
|
584
|
+
*/
|
|
585
|
+
async function runBulkAdd(opts, ops, idOnly) {
|
|
586
|
+
let client = null;
|
|
587
|
+
try {
|
|
588
|
+
client = openThings(opts.db ? { dbPath: opts.db } : {});
|
|
589
|
+
const batchResult = await client.write.batch(ops, {
|
|
590
|
+
...(opts.dryRun !== undefined && { dryRun: opts.dryRun }),
|
|
591
|
+
...(opts.actor !== undefined && { actor: opts.actor }),
|
|
592
|
+
}, (r) => {
|
|
593
|
+
if (idOnly) {
|
|
594
|
+
if (r.outcome.kind === "ok" && r.outcome.uuid !== null)
|
|
595
|
+
process.stdout.write(`${r.outcome.uuid}\n`);
|
|
596
|
+
}
|
|
597
|
+
else if (opts.json) {
|
|
598
|
+
emit(r);
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
process.stdout.write(addResultLine(r));
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
const failed = batchResult.results.filter((r) => outcomeFailed(r.outcome));
|
|
605
|
+
if (!idOnly) {
|
|
606
|
+
const total = batchResult.results.length;
|
|
607
|
+
const okCount = total - failed.length;
|
|
608
|
+
if (opts.json) {
|
|
609
|
+
const summary = {
|
|
610
|
+
summary: {
|
|
611
|
+
total,
|
|
612
|
+
ok: okCount,
|
|
613
|
+
failed: failed.filter((r) => r.outcome.kind !== "skipped").length,
|
|
614
|
+
skipped: batchResult.results.filter((r) => r.outcome.kind === "skipped").length,
|
|
615
|
+
...(batchResult.undoToken !== undefined && { undoToken: batchResult.undoToken }),
|
|
616
|
+
},
|
|
617
|
+
};
|
|
618
|
+
process.stdout.write(`${JSON.stringify(summary)}\n`);
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
const undo = batchResult.undoToken !== undefined
|
|
622
|
+
? ` (undo all: things undo --txn ${batchResult.undoToken})`
|
|
623
|
+
: "";
|
|
624
|
+
process.stdout.write(`added ${okCount}/${total} to-dos${undo}\n`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
process.exitCode = aggregateExitCode(failed.map((r) => r.outcome));
|
|
628
|
+
}
|
|
629
|
+
finally {
|
|
630
|
+
client?.close();
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/** Read newline-delimited titles from stdin; blank lines (whitespace-only) skipped. */
|
|
634
|
+
async function readStdinTitles() {
|
|
635
|
+
const chunks = [];
|
|
636
|
+
for await (const chunk of process.stdin)
|
|
637
|
+
chunks.push(chunk);
|
|
638
|
+
return Buffer.concat(chunks)
|
|
639
|
+
.toString("utf8")
|
|
640
|
+
.split("\n")
|
|
641
|
+
.map((l) => l.replace(/\r$/, ""))
|
|
642
|
+
.filter((l) => l.trim() !== "");
|
|
643
|
+
}
|
|
254
644
|
function group(program, name, description) {
|
|
255
645
|
const existing = program.commands.find((c) => c.name() === name);
|
|
256
646
|
if (existing !== undefined)
|
|
@@ -261,12 +651,15 @@ const containerRef = (value) => value === undefined ? undefined : { uuid: value,
|
|
|
261
651
|
export function registerWriteCommands(program) {
|
|
262
652
|
const todo = group(program, "todo", "To-do–scoped operations");
|
|
263
653
|
addCreateTagsFlag(addWriteFlags(todo
|
|
264
|
-
.command("add
|
|
265
|
-
.description("Create
|
|
266
|
-
"
|
|
267
|
-
"
|
|
654
|
+
.command("add [titles...]")
|
|
655
|
+
.description("Create one or more to-dos; each new uuid is printed on success. Pass several " +
|
|
656
|
+
"titles to create a quick skeleton in one call, or stream them with --stdin " +
|
|
657
|
+
"(one title per line); every shared flag below applies to each title. Projects, " +
|
|
658
|
+
"areas, and headings must name existing items — unknown or ambiguous references " +
|
|
659
|
+
"are rejected. A tag may be a name or a parent/child path, and must exist unless " +
|
|
268
660
|
"--create-tags. Adding into a completed/canceled project reopens that project — " +
|
|
269
|
-
"requires --acknowledge-project-reopen."
|
|
661
|
+
"requires --acknowledge-project-reopen. When several to-dos are created, one undo " +
|
|
662
|
+
"token removes the whole skeleton at once.")
|
|
270
663
|
.option("--notes <text>", "notes body")
|
|
271
664
|
.option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
|
|
272
665
|
.option("--reminder <HH:mm>", "time-of-day reminder (24h); requires --when today|evening|YYYY-MM-DD")
|
|
@@ -276,14 +669,33 @@ export function registerWriteCommands(program) {
|
|
|
276
669
|
.option("--project <ref>", "destination project (uuid or unique name)")
|
|
277
670
|
.option("--area <ref>", "destination area (uuid or unique name)")
|
|
278
671
|
.option("--heading <name>", "existing heading in the destination project")
|
|
279
|
-
.option("--acknowledge-project-reopen", "allow adding into a completed/canceled project")
|
|
672
|
+
.option("--acknowledge-project-reopen", "allow adding into a completed/canceled project")
|
|
673
|
+
.option("--stdin", "read newline-delimited titles from stdin (blank lines skipped); exclusive with title arguments")
|
|
674
|
+
.option("--id-only", "print only the new uuid(s), one per line in creation order, and nothing else (exclusive with --json)"))).action(async (titles, opts) => {
|
|
675
|
+
const idOnly = opts["idOnly"] === true;
|
|
676
|
+
const useStdin = opts["stdin"] === true;
|
|
677
|
+
if (idOnly && opts.json === true) {
|
|
678
|
+
usageError(opts, "--id-only and --json are mutually exclusive");
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (useStdin && titles.length > 0) {
|
|
682
|
+
usageError(opts, "--stdin is mutually exclusive with title arguments");
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const finalTitles = useStdin ? await readStdinTitles() : titles;
|
|
686
|
+
if (finalTitles.length === 0) {
|
|
687
|
+
usageError(opts, useStdin
|
|
688
|
+
? "no titles: --stdin received no non-empty lines"
|
|
689
|
+
: "provide at least one title, or pass --stdin to read titles from stdin");
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
280
692
|
const checklist = opts["checklistItem"];
|
|
281
693
|
const tags = splitCsv(opts["tags"]);
|
|
282
694
|
const project = containerRef(opts["project"]);
|
|
283
695
|
const area = containerRef(opts["area"]);
|
|
284
696
|
if (!whenSugarOk(opts))
|
|
285
697
|
return;
|
|
286
|
-
|
|
698
|
+
const buildParams = (title) => ({
|
|
287
699
|
title,
|
|
288
700
|
...(opts["notes"] !== undefined && { notes: opts["notes"] }),
|
|
289
701
|
...(opts["when"] !== undefined && { when: opts["when"] }),
|
|
@@ -294,12 +706,65 @@ export function registerWriteCommands(program) {
|
|
|
294
706
|
...(project !== undefined && { project }),
|
|
295
707
|
...(area !== undefined && { area }),
|
|
296
708
|
...(opts["heading"] !== undefined && { heading: opts["heading"] }),
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
|
|
709
|
+
});
|
|
710
|
+
const ackReopen = opts["acknowledgeProjectReopen"] !== undefined
|
|
711
|
+
? { acknowledgeProjectReopen: opts["acknowledgeProjectReopen"] }
|
|
712
|
+
: {};
|
|
713
|
+
// A multi-title add compiles onto the batch machinery (below), where
|
|
714
|
+
// idempotency is a per-LINE op_id, not one key for the whole skeleton —
|
|
715
|
+
// refuse --op-id there rather than drop it. A single-title add is a single
|
|
716
|
+
// mutation and takes --op-id normally (validated inside runWrite).
|
|
717
|
+
if (finalTitles.length > 1 && opts.opId !== undefined) {
|
|
718
|
+
usageError(opts, "--op-id applies to a single-title add — for a multi-title add use `things batch` with a " +
|
|
719
|
+
"per-line op_id");
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
// Single title (positional or a one-line stdin) keeps today's single
|
|
723
|
+
// mutation-result envelope exactly — unless --id-only, which prints just
|
|
724
|
+
// the new uuid. Multiple titles compile onto the batch machinery.
|
|
725
|
+
if (finalTitles.length === 1) {
|
|
726
|
+
const params = buildParams(finalTitles[0]);
|
|
727
|
+
const wopts = writeOptionsFrom(opts, { ...ackReopen, ...createTagsExtra(opts) });
|
|
728
|
+
if (!idOnly) {
|
|
729
|
+
await runWrite(opts, (c) => c.write.addTodo(params, wopts));
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
await runWrite(opts, (c) => c.write.addTodo(params, wopts), (result, o, meta) => {
|
|
733
|
+
if (result.kind === "ok") {
|
|
734
|
+
if (result.uuid !== null)
|
|
735
|
+
process.stdout.write(`${result.uuid}\n`);
|
|
736
|
+
process.exitCode = ExitCode.Ok;
|
|
737
|
+
}
|
|
738
|
+
else if (result.kind === "dry-run") {
|
|
739
|
+
// --id-only mints no uuid to print on a dry-run; stay silent.
|
|
740
|
+
process.exitCode = ExitCode.Ok;
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
emitResult(result, { ...o, json: false }, meta);
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
// Multi-title: one batch of todo.add legs, shared flags applied to each.
|
|
749
|
+
const perOpOptions = {
|
|
750
|
+
...ackReopen,
|
|
751
|
+
...(opts["createTags"] === true && { createTags: true }),
|
|
752
|
+
...(opts.vector !== undefined && { vector: opts.vector }),
|
|
753
|
+
...(opts.allowVeryDisruptive === true
|
|
754
|
+
? { maxDisruption: 3 }
|
|
755
|
+
: opts.allowDisruptive === true
|
|
756
|
+
? { maxDisruption: 2 }
|
|
757
|
+
: {}),
|
|
758
|
+
...(opts.verifyTimeout !== undefined && { verifyTimeoutMs: Number(opts.verifyTimeout) }),
|
|
759
|
+
};
|
|
760
|
+
const hasPerOpOptions = Object.keys(perOpOptions).length > 0;
|
|
761
|
+
const ops = finalTitles.map((title) => {
|
|
762
|
+
const op = { op: "todo.add", params: buildParams(title) };
|
|
763
|
+
if (hasPerOpOptions)
|
|
764
|
+
op.options = perOpOptions;
|
|
765
|
+
return op;
|
|
766
|
+
});
|
|
767
|
+
await runBulkAdd(opts, ops, idOnly);
|
|
303
768
|
});
|
|
304
769
|
addWriteFlags(todo
|
|
305
770
|
.command("update <uuid>")
|
|
@@ -355,35 +820,47 @@ export function registerWriteCommands(program) {
|
|
|
355
820
|
await runWrite(opts, (c) => c.write[method](uuid, writeOptionsFrom(opts)));
|
|
356
821
|
});
|
|
357
822
|
}
|
|
358
|
-
addWriteFlags(todo
|
|
359
|
-
.command("move <
|
|
360
|
-
.description("Move
|
|
361
|
-
"
|
|
362
|
-
"
|
|
363
|
-
"
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
823
|
+
addPositionFlags(addWriteFlags(todo
|
|
824
|
+
.command("move <refs...>")
|
|
825
|
+
.description("Move one or more to-dos as an ordered block (the argument order is the order they " +
|
|
826
|
+
"land — name them backwards to reverse). MOVE changes WHAT a to-do belongs to; to " +
|
|
827
|
+
"rearrange to-dos that already share a container use `things todo reorder`. Pass one " +
|
|
828
|
+
"destination: --to-project / --to-heading / --to-area, or the detach family " +
|
|
829
|
+
"--no-heading (leave the heading, stay in the project) / --loose (leave heading, " +
|
|
830
|
+
"project, AND area). --inbox files back to the Inbox. Position with " +
|
|
831
|
+
"--first/--last/--before/--after (an anchor positions but never migrates — an " +
|
|
832
|
+
"anchor-only move that would cross containers is refused). Membership always " +
|
|
833
|
+
"succeeds; placement is guaranteed for loose lists, a project's/area's members, a " +
|
|
834
|
+
"heading's children, and a container's same-day scheduled children — the result " +
|
|
835
|
+
"states the placement class, and a bounce that co-bounces unnamed siblings to honor " +
|
|
836
|
+
"an anchor lists them. Set bounce-max-items to cap how many items a bounce touches; " +
|
|
837
|
+
"bounce-enabled=false refuses bounce-dependent placements. Moving into a " +
|
|
838
|
+
"completed/canceled project reopens it — requires --acknowledge-project-reopen.")
|
|
839
|
+
.option("--to-project <ref>", "destination project (uuid or unique name)")
|
|
840
|
+
.option("--to-heading <sel>", "destination heading (exact title or uuid; within --to-project)")
|
|
841
|
+
.option("--to-area <ref>", "destination area (uuid or unique name)")
|
|
842
|
+
.option("--no-heading", "leave the heading but stay in the current project (unheaded block)")
|
|
843
|
+
.option("--loose", "detach from heading, project, AND area (keeping the schedule)")
|
|
367
844
|
.option("--inbox", "move back to the Inbox — removes any schedule")
|
|
368
|
-
.option("--
|
|
369
|
-
.option("--
|
|
370
|
-
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const dest = project !== undefined || area !== undefined || opts["heading"] !== undefined;
|
|
375
|
-
if ((inbox && (dest || detach)) || (detach && dest)) {
|
|
376
|
-
usageError(opts, "--inbox/--detach are exclusive with each other and with --project/--area/--heading");
|
|
845
|
+
.option("--no-area", "(not a to-do flag — teaches the correct spelling)")
|
|
846
|
+
.option("--detach", "(removed — teaches the replacement family)")
|
|
847
|
+
.option("--acknowledge-project-reopen", "allow moving into a completed/canceled project"))).action(async (refs, opts) => {
|
|
848
|
+
const dest = todoDestination(opts);
|
|
849
|
+
if (dest === "conflict") {
|
|
850
|
+
usageError(opts, "pass at most one destination (--to-project/--to-heading/--to-area/--no-heading/--loose/--inbox)");
|
|
377
851
|
return;
|
|
378
852
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
...(
|
|
853
|
+
const position = movePosition(opts);
|
|
854
|
+
if (position === "conflict") {
|
|
855
|
+
usageError(opts, "pass at most one of --first/--last/--before/--after");
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const request = {
|
|
859
|
+
uuids: refs,
|
|
860
|
+
...(dest !== undefined && { destination: dest }),
|
|
861
|
+
...(position !== undefined && { position }),
|
|
862
|
+
};
|
|
863
|
+
await runMoveCmd(opts, (c) => c.write.moveTodos(request, writeOptionsFrom(opts, {
|
|
387
864
|
...(opts["acknowledgeProjectReopen"] !== undefined && {
|
|
388
865
|
acknowledgeProjectReopen: opts["acknowledgeProjectReopen"],
|
|
389
866
|
}),
|
|
@@ -475,6 +952,8 @@ export function registerWriteCommands(program) {
|
|
|
475
952
|
: action === "rename"
|
|
476
953
|
? { action: "rename", ...target, title: opts["to"] }
|
|
477
954
|
: { action: "move", ...target, to: Number(opts["toPosition"]) };
|
|
955
|
+
if (opIdCompoundRefused(opts, "a granular checklist edit"))
|
|
956
|
+
return;
|
|
478
957
|
await runWrite(opts, (c) => c.write.editChecklist(uuid, edit, writeOptionsFrom(opts)));
|
|
479
958
|
return;
|
|
480
959
|
}
|
|
@@ -503,6 +982,8 @@ export function registerWriteCommands(program) {
|
|
|
503
982
|
"shortcuts installed (`things setup shortcuts`) this is in place, and is the only " +
|
|
504
983
|
"way for a repeating to-do; otherwise a date-scheduled to-do is cleared by a brief " +
|
|
505
984
|
"re-schedule through Today. Reversible with `things undo`.")).action(async (uuid, opts) => {
|
|
985
|
+
if (opIdCompoundRefused(opts, "clear-reminder"))
|
|
986
|
+
return;
|
|
506
987
|
await runWrite(opts, (c) => c.write.clearReminder(uuid, writeOptionsFrom(opts)));
|
|
507
988
|
});
|
|
508
989
|
addWriteFlags(todo
|
|
@@ -591,62 +1072,147 @@ export function registerWriteCommands(program) {
|
|
|
591
1072
|
await runWrite(opts, (c) => c.write.run(op, { uuid }, writeOptionsFrom(opts)));
|
|
592
1073
|
});
|
|
593
1074
|
}
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
1075
|
+
const project = group(program, "project", "Project-scoped operations");
|
|
1076
|
+
// --- project headings (spec §2) ------------------------------------------
|
|
1077
|
+
// A heading exists only inside a project. Each verb takes <project-ref> then
|
|
1078
|
+
// a heading selector: an exact title OR a uuid (never an ordinal — an index
|
|
1079
|
+
// silently re-targets a different heading after any reorder). An empty-string
|
|
1080
|
+
// title selects a titleless heading; duplicates fail closed with uuid
|
|
1081
|
+
// candidates.
|
|
1082
|
+
addPlacementFlags(addWriteFlags(project
|
|
1083
|
+
.command("add-heading <project> <title>")
|
|
597
1084
|
.description("Create a heading inside an existing project; its uuid is printed on success. The " +
|
|
598
|
-
"project must name an existing project (uuid or unique name).
|
|
599
|
-
"
|
|
600
|
-
|
|
1085
|
+
"project must name an existing project (uuid or unique name). Uses the Things proxy " +
|
|
1086
|
+
"shortcuts — run `things setup shortcuts` once first. By default the heading is " +
|
|
1087
|
+
"appended; a placement flag positions it among the project's headings (that leg " +
|
|
1088
|
+
"needs `things config set allow-experimental true`)."))).action(async (projectRef, title, opts) => {
|
|
1089
|
+
if (countPlacementFlags(opts) > 1) {
|
|
1090
|
+
usageError(opts, "pass at most one of --first / --last / --before-heading / --after-heading");
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (opIdCompoundRefused(opts, "add-heading"))
|
|
1094
|
+
return;
|
|
1095
|
+
await runWrite(opts, (c) => {
|
|
1096
|
+
const proj = c.resolve.project(projectRef);
|
|
1097
|
+
const placement = headingPlacement(c, proj.uuid, opts);
|
|
1098
|
+
return c.write.addHeading({ uuid: proj.uuid }, title, placement, writeOptionsFrom(opts));
|
|
1099
|
+
});
|
|
601
1100
|
});
|
|
602
|
-
addWriteFlags(
|
|
603
|
-
.command("rename <
|
|
604
|
-
.description(
|
|
605
|
-
|
|
1101
|
+
addWriteFlags(project
|
|
1102
|
+
.command("rename-heading <project> <heading>")
|
|
1103
|
+
.description(`Rename a heading in place (works on archived headings too). <heading> is a ${HEADING_SEL_HELP}.`)
|
|
1104
|
+
.requiredOption("--to <title>", "the new heading title")).action(async (projectRef, sel, opts) => {
|
|
1105
|
+
await runWrite(opts, (c) => {
|
|
1106
|
+
const proj = c.resolve.project(projectRef);
|
|
1107
|
+
const h = c.resolve.heading(proj.uuid, sel);
|
|
1108
|
+
return c.write.renameHeading(h.uuid, opts["to"], writeOptionsFrom(opts));
|
|
1109
|
+
});
|
|
606
1110
|
});
|
|
607
|
-
addWriteFlags(
|
|
608
|
-
.command("archive <
|
|
1111
|
+
addWriteFlags(project
|
|
1112
|
+
.command("archive-heading <project> <heading>")
|
|
609
1113
|
.description("Archive a heading — it leaves the active project view (reversible with " +
|
|
610
|
-
"`things
|
|
1114
|
+
"`things project unarchive-heading`). This is the preferred way to retire a heading: " +
|
|
611
1115
|
"row DELETION exists only in the app's UI and Shortcuts with a per-run consent " +
|
|
612
1116
|
"dialog, never headlessly. With open children, --children is required: " +
|
|
613
1117
|
"complete/cancel resolve them with the heading (one atomic cascade); reparent " +
|
|
614
1118
|
"moves them to the project root first, keeping them open — a compound sequence " +
|
|
615
|
-
|
|
616
|
-
.option("--children <policy>", "complete | cancel | reparent (required when children are open)")).action(async (
|
|
1119
|
+
`that \`things undo\` reverses as one unit. <heading> is a ${HEADING_SEL_HELP}.`)
|
|
1120
|
+
.option("--children <policy>", "complete | cancel | reparent (required when children are open)")).action(async (projectRef, sel, opts) => {
|
|
617
1121
|
const children = opts["children"];
|
|
1122
|
+
if (opIdCompoundRefused(opts, "archive-heading"))
|
|
1123
|
+
return;
|
|
618
1124
|
await runWrite(opts, async (c) => {
|
|
619
|
-
const
|
|
1125
|
+
const proj = c.resolve.project(projectRef);
|
|
1126
|
+
const h = c.resolve.heading(proj.uuid, sel);
|
|
1127
|
+
const outcome = await c.write.archiveHeading(h.uuid, children ? { children } : {}, writeOptionsFrom(opts));
|
|
620
1128
|
for (const leg of outcome.reparented) {
|
|
621
1129
|
process.stderr.write(`reparented: ${leg.title} (${leg.result.kind})\n`);
|
|
622
1130
|
}
|
|
623
1131
|
return outcome.heading;
|
|
624
1132
|
});
|
|
625
1133
|
});
|
|
626
|
-
addWriteFlags(
|
|
627
|
-
.command("unarchive <
|
|
1134
|
+
addWriteFlags(project
|
|
1135
|
+
.command("unarchive-heading <project> <heading>")
|
|
628
1136
|
.description("Un-archive a heading. --restore-children also reopens the children the archive " +
|
|
629
1137
|
"cascade resolved with it (identified by matching resolution timestamps; a " +
|
|
630
1138
|
"someday child comes back as someday). Children resolved at other times are " +
|
|
631
|
-
|
|
632
|
-
.option("--restore-children", "reopen cascade-resolved children too")).action(async (
|
|
1139
|
+
`never touched. <heading> is a ${HEADING_SEL_HELP}.`)
|
|
1140
|
+
.option("--restore-children", "reopen cascade-resolved children too")).action(async (projectRef, sel, opts) => {
|
|
1141
|
+
if (opIdCompoundRefused(opts, "unarchive-heading"))
|
|
1142
|
+
return;
|
|
633
1143
|
await runWrite(opts, async (c) => {
|
|
634
|
-
const
|
|
1144
|
+
const proj = c.resolve.project(projectRef);
|
|
1145
|
+
const h = c.resolve.heading(proj.uuid, sel);
|
|
1146
|
+
const outcome = await c.write.unarchiveHeading(h.uuid, opts["restoreChildren"] === true ? { restoreChildren: true } : {}, writeOptionsFrom(opts));
|
|
635
1147
|
for (const child of outcome.children) {
|
|
636
1148
|
process.stderr.write(`restored: ${child.title} (${child.result.kind})\n`);
|
|
637
1149
|
}
|
|
638
1150
|
return outcome.heading;
|
|
639
1151
|
});
|
|
640
1152
|
});
|
|
641
|
-
addDriveGuiFlag(addWriteFlags(
|
|
642
|
-
.command("
|
|
643
|
-
.description("
|
|
1153
|
+
addDriveGuiFlag(addWriteFlags(project
|
|
1154
|
+
.command("promote-heading <project> <heading>")
|
|
1155
|
+
.description("Promote a heading into a project. This REPLACES the heading with a new project — it " +
|
|
644
1156
|
"is promoted alongside its parent project (into the same area) and the heading's " +
|
|
645
1157
|
"to-dos move under the new project. The heading's identity is gone and it cannot be " +
|
|
646
|
-
|
|
647
|
-
await runWrite(opts, (c) =>
|
|
1158
|
+
`undone. The new project's uuid is printed on success. <heading> is a ${HEADING_SEL_HELP}.`))).action(async (projectRef, sel, opts) => {
|
|
1159
|
+
await runWrite(opts, (c) => {
|
|
1160
|
+
const proj = c.resolve.project(projectRef);
|
|
1161
|
+
const h = c.resolve.heading(proj.uuid, sel);
|
|
1162
|
+
return c.write.run("project.promote-heading", { uuid: h.uuid }, writeOptionsFrom(opts));
|
|
1163
|
+
});
|
|
1164
|
+
});
|
|
1165
|
+
addPlacementFlags(addWriteFlags(project
|
|
1166
|
+
.command("move-heading <project> <headings...>")
|
|
1167
|
+
.description("Reposition one or more of a project's headings as an ordered block — the selection " +
|
|
1168
|
+
"order is the resulting order, and each heading's to-dos follow it. Pass exactly one " +
|
|
1169
|
+
"placement: --first, --last, --before-heading <sel>, or --after-heading <sel>. Each " +
|
|
1170
|
+
`<heading> is a ${HEADING_SEL_HELP}. Reordering headings rides the same experimental ` +
|
|
1171
|
+
"surface as `things reorder` — enable it once with `things config set " +
|
|
1172
|
+
"allow-experimental true`."))).action(async (projectRef, sels, opts) => {
|
|
1173
|
+
if (countPlacementFlags(opts) !== 1) {
|
|
1174
|
+
usageError(opts, "pass exactly one of --first / --last / --before-heading / --after-heading");
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
await runWrite(opts, (c) => {
|
|
1178
|
+
const proj = c.resolve.project(projectRef);
|
|
1179
|
+
const headings = sels.map((s) => c.resolve.heading(proj.uuid, s).uuid);
|
|
1180
|
+
const placement = headingPlacement(c, proj.uuid, opts);
|
|
1181
|
+
return c.write.moveHeading({ uuid: proj.uuid }, headings, placement, writeOptionsFrom(opts));
|
|
1182
|
+
});
|
|
1183
|
+
});
|
|
1184
|
+
addWriteFlags(project
|
|
1185
|
+
.command("move-heading-to-project <project> <heading>")
|
|
1186
|
+
.requiredOption("--to <project>", "destination project (uuid or unique title)")
|
|
1187
|
+
.description("Move a heading — WITH its to-dos — to a DIFFERENT project. This is the cross-project " +
|
|
1188
|
+
"relocation, distinct from `move-heading` (which only reorders a heading within its own " +
|
|
1189
|
+
`project). <heading> is a ${HEADING_SEL_HELP} in the SOURCE <project>; --to names the ` +
|
|
1190
|
+
"destination. GUI-ONLY: it drives the heading row's ellipsis Move… menu, so enable it " +
|
|
1191
|
+
"once with `things config set ui.enabled true` and pass --dangerously-drive-gui. Fails " +
|
|
1192
|
+
"closed when the heading title is shared by another heading in the project, or the " +
|
|
1193
|
+
"destination title by another project (the drive addresses both by title). No undo — " +
|
|
1194
|
+
"move it back to reverse.")).action(async (projectRef, sel, opts) => {
|
|
1195
|
+
await runWrite(opts, (c) => {
|
|
1196
|
+
const proj = c.resolve.project(projectRef);
|
|
1197
|
+
const dest = c.resolve.project(String(opts["to"]));
|
|
1198
|
+
return c.write.moveHeadingToProject({ uuid: proj.uuid }, sel, { uuid: dest.uuid }, writeOptionsFrom(opts));
|
|
1199
|
+
});
|
|
1200
|
+
});
|
|
1201
|
+
addWriteFlags(project
|
|
1202
|
+
.command("dissolve-heading <project> <heading>")
|
|
1203
|
+
.description("Dissolve a heading — remove it but KEEP its to-dos, which become direct children of the " +
|
|
1204
|
+
"project (in the same order), NOT trashed. This is the opposite of deleting: nothing is " +
|
|
1205
|
+
`lost. <heading> is a ${HEADING_SEL_HELP}. Contrast \`things todo delete\` and the ` +
|
|
1206
|
+
"Shortcuts heading-delete cascade, which TRASH the children. GUI-ONLY (drives the " +
|
|
1207
|
+
"heading row's ellipsis Delete): enable it once with `things config set ui.enabled true` " +
|
|
1208
|
+
"and pass --dangerously-drive-gui. Fails closed when the heading title is shared by " +
|
|
1209
|
+
"another heading in the project. No undo — re-create the heading and move the to-dos back.")).action(async (projectRef, sel, opts) => {
|
|
1210
|
+
await runWrite(opts, (c) => {
|
|
1211
|
+
const proj = c.resolve.project(projectRef);
|
|
1212
|
+
const h = c.resolve.heading(proj.uuid, sel);
|
|
1213
|
+
return c.write.dissolveHeading(h.uuid, writeOptionsFrom(opts));
|
|
1214
|
+
});
|
|
648
1215
|
});
|
|
649
|
-
const project = group(program, "project", "Project-scoped operations");
|
|
650
1216
|
// --- ui vector: repeating-project transforms (two-key gated) -------------
|
|
651
1217
|
addDriveGuiFlag(addRepeatRuleFlags(addWriteFlags(project
|
|
652
1218
|
.command("reschedule-repeat <ref>")
|
|
@@ -690,6 +1256,8 @@ export function registerWriteCommands(program) {
|
|
|
690
1256
|
.requiredOption("--frequency <freq>", REPEAT_FREQ_HELP)
|
|
691
1257
|
.requiredOption("--interval <n>", REPEAT_INTERVAL_HELP)))).action(async (uuid, opts) => {
|
|
692
1258
|
const frequency = opts["frequency"];
|
|
1259
|
+
if (opIdCompoundRefused(opts, "project make-repeating"))
|
|
1260
|
+
return;
|
|
693
1261
|
await runWrite(opts, (c) => c.write.makeRepeatingProject(uuid, {
|
|
694
1262
|
frequency,
|
|
695
1263
|
interval: Number(opts["interval"]),
|
|
@@ -697,7 +1265,7 @@ export function registerWriteCommands(program) {
|
|
|
697
1265
|
}, writeOptionsFrom(opts)));
|
|
698
1266
|
});
|
|
699
1267
|
addDriveGuiFlag(addWriteFlags(project
|
|
700
|
-
.command("
|
|
1268
|
+
.command("add-repeating <title>")
|
|
701
1269
|
.description("Create a project and turn it into a repeating series in ONE call. Two operations: the " +
|
|
702
1270
|
"project is created first and PERSISTS even if the make-repeating step refuses; then it " +
|
|
703
1271
|
"is promoted (which drives the GUI). Give --area to place it, or omit it to create in " +
|
|
@@ -710,7 +1278,9 @@ export function registerWriteCommands(program) {
|
|
|
710
1278
|
.requiredOption("--interval <n>", REPEAT_INTERVAL_HELP))).action(async (title, opts) => {
|
|
711
1279
|
const todos = opts["todo"];
|
|
712
1280
|
const area = containerRef(opts["area"]);
|
|
713
|
-
|
|
1281
|
+
if (opIdCompoundRefused(opts, "project add-repeating"))
|
|
1282
|
+
return;
|
|
1283
|
+
await runWrite(opts, (c) => c.write.addRepeatingProject({
|
|
714
1284
|
title,
|
|
715
1285
|
...(opts["notes"] !== undefined && { notes: opts["notes"] }),
|
|
716
1286
|
...(area !== undefined && { area }),
|
|
@@ -722,12 +1292,14 @@ export function registerWriteCommands(program) {
|
|
|
722
1292
|
});
|
|
723
1293
|
addWriteFlags(project
|
|
724
1294
|
.command("add <title>")
|
|
725
|
-
.description("Create a project; its uuid is printed on success."
|
|
1295
|
+
.description("Create a project; its uuid is printed on success. Give --todo (repeatable) to " +
|
|
1296
|
+
"seed it with child to-dos in the same call — the quick way to stand up a new " +
|
|
1297
|
+
"project skeleton.")
|
|
726
1298
|
.option("--notes <text>", "notes body")
|
|
727
1299
|
.option("--area <ref>", "destination area (uuid or unique name)")
|
|
728
1300
|
.option("--when <value>", "today | evening | anytime | someday | YYYY-MM-DD")
|
|
729
1301
|
.option("--deadline <date>", "YYYY-MM-DD")
|
|
730
|
-
.option("--todo <title>", "initial child to-do (
|
|
1302
|
+
.option("--todo <title>", "initial child to-do, repeatable (seeds the new project)", collect, [])).action(async (title, opts) => {
|
|
731
1303
|
const todos = opts["todo"];
|
|
732
1304
|
const area = containerRef(opts["area"]);
|
|
733
1305
|
await runWrite(opts, (c) => c.write.addProject({
|
|
@@ -801,20 +1373,35 @@ export function registerWriteCommands(program) {
|
|
|
801
1373
|
? c.write.setProjectTags(uuid, set, writeOptionsFrom(opts, createTagsExtra(opts)))
|
|
802
1374
|
: c.write.addProjectTags(uuid, add ?? [], writeOptionsFrom(opts, createTagsExtra(opts))));
|
|
803
1375
|
});
|
|
804
|
-
addWriteFlags(project
|
|
805
|
-
.command("move <
|
|
806
|
-
.description("Move
|
|
807
|
-
"
|
|
808
|
-
"
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
1376
|
+
addPositionFlags(addWriteFlags(project
|
|
1377
|
+
.command("move <refs...>")
|
|
1378
|
+
.description("Move one or more projects as an ordered block (argument order = resulting order). " +
|
|
1379
|
+
"Pass one destination: --to-area (uuid or unique name) or --no-area (leave the " +
|
|
1380
|
+
"area — a project's complete detach). Position among siblings with " +
|
|
1381
|
+
"--first/--last/--before/--after (an anchor positions but never migrates). " +
|
|
1382
|
+
"Membership always succeeds; top-of-bucket placement is guaranteed only where a " +
|
|
1383
|
+
"reorder protocol exists (the result states the class). Status and schedule are " +
|
|
1384
|
+
"untouched.")
|
|
1385
|
+
.option("--to-area <ref>", "destination area (uuid or unique name)")
|
|
1386
|
+
.option("--no-area", "leave the current area (a project's single-level detach)")
|
|
1387
|
+
.option("--loose", "(not a project flag — teaches the correct spelling)")
|
|
1388
|
+
.option("--detach", "(removed — teaches the replacement)"))).action(async (refs, opts) => {
|
|
1389
|
+
const dest = projectDestination(opts);
|
|
1390
|
+
if (dest === "conflict") {
|
|
1391
|
+
usageError(opts, "pass at most one of --to-area / --no-area");
|
|
813
1392
|
return;
|
|
814
1393
|
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1394
|
+
const position = movePosition(opts);
|
|
1395
|
+
if (position === "conflict") {
|
|
1396
|
+
usageError(opts, "pass at most one of --first/--last/--before/--after");
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
const request = {
|
|
1400
|
+
uuids: refs,
|
|
1401
|
+
...(dest !== undefined && { destination: dest }),
|
|
1402
|
+
...(position !== undefined && { position }),
|
|
1403
|
+
};
|
|
1404
|
+
await runMoveCmd(opts, (c) => c.write.moveProjects(request, writeOptionsFrom(opts)));
|
|
818
1405
|
});
|
|
819
1406
|
addWriteFlags(project
|
|
820
1407
|
.command("cancel <ref>")
|
|
@@ -832,6 +1419,8 @@ export function registerWriteCommands(program) {
|
|
|
832
1419
|
"project — children resolved earlier are never touched. Exit 3 if any child " +
|
|
833
1420
|
"restore fails.")
|
|
834
1421
|
.option("--restore-children", "also reopen the children resolved with the project")).action(async (uuid, opts) => {
|
|
1422
|
+
if (opIdCompoundRefused(opts, "project reopen"))
|
|
1423
|
+
return;
|
|
835
1424
|
const started = Date.now();
|
|
836
1425
|
let client = null;
|
|
837
1426
|
try {
|
|
@@ -953,13 +1542,18 @@ export function registerWriteCommands(program) {
|
|
|
953
1542
|
addWriteFlags(area
|
|
954
1543
|
.command("delete <ref>")
|
|
955
1544
|
.description("Delete an area PERMANENTLY — areas do not go to the Trash, so this cannot be " +
|
|
956
|
-
"undone; requires --dangerously-permanent.
|
|
957
|
-
"
|
|
958
|
-
|
|
1545
|
+
"undone; requires --dangerously-permanent. Deleting an area moves its to-dos and " +
|
|
1546
|
+
"projects to the Trash, so a non-empty area is refused unless you pass " +
|
|
1547
|
+
"--allow-non-empty; empty the area first to keep its contents.")
|
|
1548
|
+
.option("--dangerously-permanent", "accept permanent, unrecoverable deletion")
|
|
1549
|
+
.option("--allow-non-empty", "delete the area together with its contents (its to-dos and projects move to the Trash)")).action(async (target, opts) => {
|
|
959
1550
|
await runWrite(opts, (c) => c.write.deleteArea(target, writeOptionsFrom(opts, {
|
|
960
1551
|
...(opts["dangerouslyPermanent"] !== undefined && {
|
|
961
1552
|
dangerouslyPermanent: opts["dangerouslyPermanent"],
|
|
962
1553
|
}),
|
|
1554
|
+
...(opts["allowNonEmpty"] !== undefined && {
|
|
1555
|
+
allowNonEmptyArea: opts["allowNonEmpty"],
|
|
1556
|
+
}),
|
|
963
1557
|
})));
|
|
964
1558
|
});
|
|
965
1559
|
const tag = group(program, "tag", "Tag-scoped operations");
|
|
@@ -1041,6 +1635,15 @@ export function registerWriteCommands(program) {
|
|
|
1041
1635
|
"failure does not roll back earlier ops. Per-op results stream as JSONL. Per-op " +
|
|
1042
1636
|
"options carry the confirmation flags (acknowledgeChecklistReset, " +
|
|
1043
1637
|
"acknowledgeProjectReopen, dangerouslyPermanent, acknowledgeTagSubtree). " +
|
|
1638
|
+
"CHAINING: an op that creates something may carry a `tempId` (a handle like " +
|
|
1639
|
+
'"proj1"); a LATER op references that new uuid as "$proj1" in any id/container ' +
|
|
1640
|
+
'param (dotted "$proj1.instance"/"$proj1.replaced" reach a repeating op\'s spawned ' +
|
|
1641
|
+
"instance / replaced source). A tempId is valid only on a creating op (not tag.add — " +
|
|
1642
|
+
"reference a tag by title) and unique per batch; an unresolved/forward $ref fails just " +
|
|
1643
|
+
"that line. IDEMPOTENCY: a line's `opId` makes resubmission safe — a matching earlier " +
|
|
1644
|
+
"success is reported already-applied, not re-created. The trailing summary line adds " +
|
|
1645
|
+
"`tempIdMapping` (handle → uuid) and `undoToken` — undo the WHOLE batch with " +
|
|
1646
|
+
"`things undo --txn <undoToken>`. " +
|
|
1044
1647
|
"--dry-run plans everything without executing; --fail-fast skips the rest after " +
|
|
1045
1648
|
"the first failure. Exit (worst failure wins): 0 all ok · 3 any verify-failed/invalid " +
|
|
1046
1649
|
"· 4 any blocked · 5 any drift-blocked · 6 any unsupported.")
|
|
@@ -1084,7 +1687,7 @@ export function registerWriteCommands(program) {
|
|
|
1084
1687
|
let client = null;
|
|
1085
1688
|
try {
|
|
1086
1689
|
client = openThings(opts.db ? { dbPath: opts.db } : {});
|
|
1087
|
-
const
|
|
1690
|
+
const batchResult = await client.write.batch(ops, {
|
|
1088
1691
|
...(opts.dryRun !== undefined && { dryRun: opts.dryRun }),
|
|
1089
1692
|
...(opts["failFast"] === true && { failFast: true }),
|
|
1090
1693
|
...(opts.actor !== undefined && { actor: opts.actor }),
|
|
@@ -1092,14 +1695,19 @@ export function registerWriteCommands(program) {
|
|
|
1092
1695
|
const pre = preInvalid.find((p) => p.index === r.index);
|
|
1093
1696
|
emit(pre ?? r);
|
|
1094
1697
|
});
|
|
1095
|
-
const merged = results.map((r) => preInvalid.find((p) => p.index === r.index) ?? r);
|
|
1698
|
+
const merged = batchResult.results.map((r) => preInvalid.find((p) => p.index === r.index) ?? r);
|
|
1096
1699
|
const failed = merged.filter((r) => outcomeFailed(r.outcome));
|
|
1700
|
+
const hasMapping = Object.keys(batchResult.tempIdMapping).length > 0;
|
|
1097
1701
|
const summary = {
|
|
1098
1702
|
summary: {
|
|
1099
1703
|
total: merged.length,
|
|
1100
1704
|
ok: merged.length - failed.length,
|
|
1101
1705
|
failed: failed.filter((r) => r.outcome.kind !== "skipped").length,
|
|
1102
1706
|
skipped: merged.filter((r) => r.outcome.kind === "skipped").length,
|
|
1707
|
+
// ADDITIVE: the temp-id → uuid mapping and the batch undo token
|
|
1708
|
+
// (undo the whole submission with `things undo --txn <token>`).
|
|
1709
|
+
...(hasMapping && { tempIdMapping: batchResult.tempIdMapping }),
|
|
1710
|
+
...(batchResult.undoToken !== undefined && { undoToken: batchResult.undoToken }),
|
|
1103
1711
|
},
|
|
1104
1712
|
};
|
|
1105
1713
|
process.stdout.write(`${JSON.stringify(summary)}\n`);
|
|
@@ -1209,35 +1817,41 @@ export function registerWriteCommands(program) {
|
|
|
1209
1817
|
client?.close();
|
|
1210
1818
|
}
|
|
1211
1819
|
});
|
|
1212
|
-
addWriteFlags(program
|
|
1213
|
-
.command("reorder <
|
|
1214
|
-
.description("
|
|
1215
|
-
"
|
|
1216
|
-
"
|
|
1217
|
-
"
|
|
1218
|
-
"keep
|
|
1219
|
-
"
|
|
1220
|
-
"
|
|
1221
|
-
|
|
1222
|
-
"
|
|
1223
|
-
"
|
|
1224
|
-
"
|
|
1225
|
-
"
|
|
1226
|
-
"
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
.option("--
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
}
|
|
1820
|
+
addPositionFlags(addWriteFlags(program
|
|
1821
|
+
.command("reorder <refs...>")
|
|
1822
|
+
.description("Rearrange to-dos (and the project rows the Today/Evening/day lists intermix with " +
|
|
1823
|
+
"them) IN PLACE within the container and bucket they already share — REARRANGES, " +
|
|
1824
|
+
"never changes membership (to change what an item belongs to, use `things todo " +
|
|
1825
|
+
"move` / `things project move`). Argument order is the resulting order; unmentioned " +
|
|
1826
|
+
"siblings keep theirs. Bare (no position) assembles the named items as a block at " +
|
|
1827
|
+
"the EARLIEST one's current slot (partial-selection friendly); --first/--last/" +
|
|
1828
|
+
"--before/--after position the block. Operands that span containers or buckets fail " +
|
|
1829
|
+
"closed. A Today/Evening member also has an index slot in its container, so a set " +
|
|
1830
|
+
"sharing BOTH axes is ambiguous — pass --in to say which (the refusal names both " +
|
|
1831
|
+
"spellings). A deadline-forecast set sharing one Upcoming day is dual-axis the same " +
|
|
1832
|
+
"way (the day-block vs its container order); --in upcoming or --in <YYYY-MM-DD> names " +
|
|
1833
|
+
"the day-block. Ordering uses the native re-rank where available (private surface, on " +
|
|
1834
|
+
"by default) and a verified when= bounce otherwise; bounce-max-items caps a bounce, " +
|
|
1835
|
+
"bounce-enabled=false refuses bounce-dependent placements rather than degrading. " +
|
|
1836
|
+
"For a project's HEADINGS use `things project move-heading`; for sidebar AREAS use " +
|
|
1837
|
+
"`things area reorder`.")
|
|
1838
|
+
.option("--in <target>", "name the axis to reorder on: today | evening | anytime | someday | inbox, a project/" +
|
|
1839
|
+
"area/heading ref (uuid or unique title), upcoming (the one future day the set shares), " +
|
|
1840
|
+
"or a YYYY-MM-DD day-block. A stage-list or container axis sorts one KIND at a time — " +
|
|
1841
|
+
"a mixed to-do+project set is refused (even sharing a container); only today | evening " +
|
|
1842
|
+
"| upcoming | a day-block intermix both kinds. Reorder a project's headings with " +
|
|
1843
|
+
"`things project move-heading`."))).action(async (refs, opts) => {
|
|
1844
|
+
const position = movePosition(opts);
|
|
1845
|
+
if (position === "conflict") {
|
|
1846
|
+
usageError(opts, "pass at most one of --first/--last/--before/--after");
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
const request = {
|
|
1850
|
+
uuids: refs,
|
|
1851
|
+
...(position !== undefined && { position }),
|
|
1852
|
+
...(opts["in"] !== undefined && { in: opts["in"] }),
|
|
1853
|
+
};
|
|
1854
|
+
await runMoveCmd(opts, (c) => c.write.reorderTodos(request, writeOptionsFrom(opts)));
|
|
1241
1855
|
});
|
|
1242
1856
|
program
|
|
1243
1857
|
.command("capabilities")
|
|
@@ -1250,7 +1864,7 @@ export function registerWriteCommands(program) {
|
|
|
1250
1864
|
const data = capabilitiesTable(opts.op);
|
|
1251
1865
|
if (opts.json) {
|
|
1252
1866
|
const meta = { dbVersion: null, fingerprint: "unknown", elapsedMs: 0 };
|
|
1253
|
-
process.stdout.write(`${JSON.stringify(okEnvelope("capabilities", data, meta))}\n`);
|
|
1867
|
+
process.stdout.write(`${JSON.stringify(okEnvelope("capabilities", { items: data }, meta))}\n`);
|
|
1254
1868
|
return;
|
|
1255
1869
|
}
|
|
1256
1870
|
for (const entry of data) {
|
|
@@ -1268,40 +1882,57 @@ export function registerWriteCommands(program) {
|
|
|
1268
1882
|
});
|
|
1269
1883
|
const config = group(program, "config", "things-api configuration");
|
|
1270
1884
|
config
|
|
1271
|
-
.command("
|
|
1272
|
-
.description("Show
|
|
1885
|
+
.command("get [key]")
|
|
1886
|
+
.description("Show one config key's effective value, or every effective value when no key is given " +
|
|
1887
|
+
"(including read-only derived values like host). Precedence is env > stored > default; " +
|
|
1888
|
+
"each value is marked with the layer that supplied it. Unknown key is a usage error. " +
|
|
1889
|
+
"--json emits a versioned envelope.")
|
|
1273
1890
|
.option("--json", "emit versioned JSON envelope on stdout")
|
|
1274
|
-
.
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1891
|
+
.action((key, opts) => {
|
|
1892
|
+
const meta = { dbVersion: null, fingerprint: "unknown", elapsedMs: 0 };
|
|
1893
|
+
if (key !== undefined) {
|
|
1894
|
+
const entry = getConfigKey(key);
|
|
1895
|
+
if (entry === undefined) {
|
|
1896
|
+
process.stderr.write(`error: unknown config key "${key}"\n`);
|
|
1897
|
+
process.exitCode = ExitCode.Usage;
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1278
1900
|
if (opts.json) {
|
|
1279
|
-
|
|
1280
|
-
process.stdout.write(`${JSON.stringify(okEnvelope("config", client.config, meta))}\n`);
|
|
1901
|
+
process.stdout.write(`${JSON.stringify(okEnvelope("config", entry, meta))}\n`);
|
|
1281
1902
|
}
|
|
1282
1903
|
else {
|
|
1283
|
-
|
|
1284
|
-
process.stdout.write(`${k}: ${String(v)}\n`);
|
|
1285
|
-
}
|
|
1904
|
+
process.stdout.write(`${configKeyLine(entry)}\n`);
|
|
1286
1905
|
}
|
|
1906
|
+
return;
|
|
1287
1907
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1908
|
+
const all = describeConfig();
|
|
1909
|
+
if (opts.json) {
|
|
1910
|
+
process.stdout.write(`${JSON.stringify(okEnvelope("config", { items: all }, meta))}\n`);
|
|
1911
|
+
}
|
|
1912
|
+
else {
|
|
1913
|
+
for (const entry of all) {
|
|
1914
|
+
process.stdout.write(`${configKeyLine(entry)}\n`);
|
|
1915
|
+
}
|
|
1290
1916
|
}
|
|
1291
1917
|
});
|
|
1292
1918
|
config
|
|
1293
1919
|
.command("set <key> <value>")
|
|
1294
1920
|
.description("Persist a config key: profile | maxDisruption | actor | auditEnabled | " +
|
|
1295
|
-
"accepted-fingerprint | allow-experimental |
|
|
1296
|
-
|
|
1921
|
+
"accepted-fingerprint | certified-app-version | allow-experimental | bounce-enabled | " +
|
|
1922
|
+
"bounce-max-items | ui-enabled | scope")
|
|
1923
|
+
.action((key, value, opts) => {
|
|
1297
1924
|
const map = {
|
|
1298
1925
|
profile: "profile",
|
|
1299
1926
|
maxDisruption: "maxDisruption",
|
|
1300
1927
|
actor: "actor",
|
|
1301
1928
|
auditEnabled: "auditEnabled",
|
|
1302
1929
|
"accepted-fingerprint": "acceptedFingerprint",
|
|
1930
|
+
"certified-app-version": "certifiedAppVersion",
|
|
1303
1931
|
"allow-experimental": "allowExperimental",
|
|
1932
|
+
"bounce-enabled": "bounceEnabled",
|
|
1933
|
+
"bounce-max-items": "bounceMaxItems",
|
|
1304
1934
|
"ui-enabled": "uiEnabled",
|
|
1935
|
+
scope: "scope",
|
|
1305
1936
|
};
|
|
1306
1937
|
const target = map[key];
|
|
1307
1938
|
if (target === undefined) {
|
|
@@ -1309,13 +1940,33 @@ export function registerWriteCommands(program) {
|
|
|
1309
1940
|
process.exitCode = ExitCode.Usage;
|
|
1310
1941
|
return;
|
|
1311
1942
|
}
|
|
1312
|
-
const parsed = target === "maxDisruption"
|
|
1943
|
+
const parsed = target === "maxDisruption" || target === "bounceMaxItems"
|
|
1313
1944
|
? Number(value)
|
|
1314
|
-
: target === "auditEnabled" ||
|
|
1945
|
+
: target === "auditEnabled" ||
|
|
1946
|
+
target === "allowExperimental" ||
|
|
1947
|
+
target === "bounceEnabled" ||
|
|
1948
|
+
target === "uiEnabled"
|
|
1315
1949
|
? value === "true"
|
|
1316
1950
|
: value;
|
|
1951
|
+
// Universal `--dry-run` (../dry-run.ts): `config set` writes local config
|
|
1952
|
+
// state (not the Things DB), so it honors the flag with an honest preview —
|
|
1953
|
+
// the key is still validated above, but nothing is persisted. Exit 0.
|
|
1954
|
+
if (opts.dryRun === true) {
|
|
1955
|
+
process.stdout.write(`DRY RUN would set ${key} = ${String(parsed)} (nothing written)\n`);
|
|
1956
|
+
process.exitCode = ExitCode.Ok;
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1317
1959
|
saveConfigKey(target, parsed);
|
|
1318
1960
|
process.stdout.write(`set ${key} = ${String(parsed)}\n`);
|
|
1961
|
+
// A stored scope jails EVERY process on this host — including this
|
|
1962
|
+
// terminal — until removed. Per-process scoping belongs on the
|
|
1963
|
+
// THINGS_API_SCOPE env var or the `things mcp --scope` flag instead.
|
|
1964
|
+
if (target === "scope") {
|
|
1965
|
+
process.stderr.write("warning: a stored scope limits EVERY things-api process on this machine to " +
|
|
1966
|
+
`"${value}" — including your own terminal — until you clear it with ` +
|
|
1967
|
+
'`things config set scope ""`. For per-process limits (e.g. one MCP server), ' +
|
|
1968
|
+
"prefer `things mcp --scope <ref>` or the THINGS_API_SCOPE environment variable.\n");
|
|
1969
|
+
}
|
|
1319
1970
|
});
|
|
1320
1971
|
}
|
|
1321
1972
|
//# sourceMappingURL=writes.js.map
|