slidemind 0.3.20 → 0.4.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +591 -15
- package/README.md +202 -7
- package/bin/slidemind.mjs +50 -2
- package/package.json +2 -2
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
// The two edit-safeguard sidecars on disk: `state/edit-operations.json` (what
|
|
2
|
+
// this run is allowed to change) and `state/slide-preservation.json` (what the
|
|
3
|
+
// slide must still contain afterwards). The SHAPES live in
|
|
4
|
+
// `edit-operations.mjs` / `slide-preservation.mjs` — pure, mirrored into the
|
|
5
|
+
// add-in, and the only judges of validity. This module is the CLI's I/O half:
|
|
6
|
+
// read, validate, refuse, write atomically, and migrate a deck that predates
|
|
7
|
+
// both records.
|
|
8
|
+
//
|
|
9
|
+
// Three rules the implementation exists to keep:
|
|
10
|
+
//
|
|
11
|
+
// • A RECORD WE CANNOT READ IS NEVER REWRITTEN. Corrupt JSON, a schema from a
|
|
12
|
+
// future build, or a record that fails its own validator all raise a coded
|
|
13
|
+
// error and leave the bytes exactly as they were found. Downgrading state
|
|
14
|
+
// is how obligations silently disappear.
|
|
15
|
+
// • IDENTITY IS RESOLVED, NEVER INFERRED. `--slides 2,7` is an INPUT
|
|
16
|
+
// grammar; what gets persisted is the slide's durable GUID
|
|
17
|
+
// (src/lib/slide-guids.mjs), minted and saved when a slide has none yet.
|
|
18
|
+
// Nothing here stores a slide number.
|
|
19
|
+
// • MIGRATION READS THE SOURCE IN FRONT OF IT. A slide that has been edited
|
|
20
|
+
// since `transcribe` stamped it is seeded from its CURRENT source, never
|
|
21
|
+
// from the concept spec it no longer resembles, and its evidence is
|
|
22
|
+
// `unknown` — which is not `passed`.
|
|
23
|
+
|
|
24
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
import { statePath } from "./deck-paths.mjs";
|
|
29
|
+
import { readRevision, slideFileOrder, writeStateJson } from "./revision.mjs";
|
|
30
|
+
import { loadSlideGuids, saveSlideGuids, slideGuidEntry, reconcileSlideGuids } from "./slide-guids.mjs";
|
|
31
|
+
import { conversionProvenance, loadConversions } from "./conversions.mjs";
|
|
32
|
+
import { mintSlideGuid } from "../pptx/tags.mjs";
|
|
33
|
+
import {
|
|
34
|
+
EDIT_OPERATIONS_FILE,
|
|
35
|
+
EDIT_OPERATION_VERSION,
|
|
36
|
+
OPERATION_KINDS,
|
|
37
|
+
operationSlideKeys,
|
|
38
|
+
validateEditOperation,
|
|
39
|
+
validateEditOperationsFile,
|
|
40
|
+
} from "./edit-operations.mjs";
|
|
41
|
+
import {
|
|
42
|
+
SLIDE_PRESERVATION_FILE,
|
|
43
|
+
SLIDE_PRESERVATION_VERSION,
|
|
44
|
+
amendForOperation,
|
|
45
|
+
validatePreservationUpdate,
|
|
46
|
+
validateSlidePreservationFile,
|
|
47
|
+
} from "./slide-preservation.mjs";
|
|
48
|
+
import { PROPERTY_SCOPES } from "./edit-operations.mjs";
|
|
49
|
+
import { isDurableId } from "./durable-ids.mjs";
|
|
50
|
+
import { bindSplitIds, readSlideElements } from "./preservation-verdict.mjs";
|
|
51
|
+
import { inlineComponents } from "./inline-components.mjs";
|
|
52
|
+
import { cleanText, readSpec } from "../builders/concept-spec.mjs";
|
|
53
|
+
|
|
54
|
+
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What a SEEDED requirement protects, by where the obligation came from.
|
|
58
|
+
*
|
|
59
|
+
* Existence is protected unconditionally — an empty `properties` list still
|
|
60
|
+
* means "this element must still be there". Beyond that, only what the evidence
|
|
61
|
+
* at hand actually establishes, because a scope nobody can justify is a block
|
|
62
|
+
* on an edit the user is entitled to make:
|
|
63
|
+
*
|
|
64
|
+
* concept the spec IS the approved content: its text is the business
|
|
65
|
+
* fact, its image/icon the asset that was chosen, its fill the
|
|
66
|
+
* design decision. GEOMETRY IS NOT PROTECTED — settling moves
|
|
67
|
+
* elements by design, and the repair budget (phase 6) is what
|
|
68
|
+
* governs that, not a permanent obligation.
|
|
69
|
+
* converted an import is a fidelity claim about somebody else's slide:
|
|
70
|
+
* its raw borders, spacing and typography are precisely the
|
|
71
|
+
* thing being preserved (E02), so the full set applies. The
|
|
72
|
+
* CLI never snaps or settles converted source, so nothing
|
|
73
|
+
* legitimate trips over it.
|
|
74
|
+
* legacy-current hand-owned or pre-record source. The bytes prove an element
|
|
75
|
+
* is there and which asset it points at; they prove nothing
|
|
76
|
+
* about which of its properties were deliberate, so nothing
|
|
77
|
+
* deeper is claimed. Stated as a limitation rather than
|
|
78
|
+
* guessed at in either direction.
|
|
79
|
+
*/
|
|
80
|
+
export const PROTECTED_BY_ORIGIN = {
|
|
81
|
+
concept: ["text", "fill", "media", "chart", "native"],
|
|
82
|
+
converted: ["text", "geom", "fill", "line", "media", "chart", "native"],
|
|
83
|
+
"legacy-current": ["media", "native"],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Candidate scopes ∩ what this origin may claim, in the vocabulary's own order
|
|
87
|
+
// so two seeds of the same slide are byte-comparable.
|
|
88
|
+
const protect = (candidates, origin) => {
|
|
89
|
+
const allowed = new Set(PROTECTED_BY_ORIGIN[origin] ?? PROTECTED_BY_ORIGIN["legacy-current"]);
|
|
90
|
+
const wanted = new Set(candidates);
|
|
91
|
+
return PROPERTY_SCOPES.filter((scope) => allowed.has(scope) && wanted.has(scope));
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Operations that still hold their slides. Anything else has let go. */
|
|
95
|
+
export const OPEN_OPERATION_STATUSES = ["prepared", "validated"];
|
|
96
|
+
|
|
97
|
+
/** The kinds `revision begin --operation` accepts: `create` is a build's inference, never a session's claim. */
|
|
98
|
+
export const SESSION_OPERATION_KINDS = OPERATION_KINDS.filter((k) => k !== "create");
|
|
99
|
+
|
|
100
|
+
const EMPTY_OPERATIONS = { version: EDIT_OPERATION_VERSION, operations: [] };
|
|
101
|
+
const EMPTY_PRESERVATION = { version: SLIDE_PRESERVATION_VERSION, slides: {} };
|
|
102
|
+
|
|
103
|
+
const stateError = (code, message, extra = {}) =>
|
|
104
|
+
Object.assign(new Error(message), { code, ...extra });
|
|
105
|
+
|
|
106
|
+
const errorLines = (errors) =>
|
|
107
|
+
errors.slice(0, 8).map((e) => ` ${e.path}: ${e.message}`).join("\n") +
|
|
108
|
+
(errors.length > 8 ? `\n … and ${errors.length - 8} more` : "");
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// read / write
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
function readDoc(workspaceDir, name, validate, empty) {
|
|
115
|
+
const file = statePath(workspaceDir, name);
|
|
116
|
+
let raw;
|
|
117
|
+
try {
|
|
118
|
+
raw = fs.readFileSync(file, "utf8");
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (err?.code === "ENOENT") return structuredClone(empty);
|
|
121
|
+
throw stateError("state_unreadable", `state/${name} cannot be read (${err.message}); nothing was changed.`);
|
|
122
|
+
}
|
|
123
|
+
let doc;
|
|
124
|
+
try {
|
|
125
|
+
doc = JSON.parse(raw);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
throw stateError(
|
|
128
|
+
"state_invalid",
|
|
129
|
+
`state/${name} is not readable JSON (${err.message}). The file was left untouched — ` +
|
|
130
|
+
`repair or remove it before editing this deck.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const { ok, errors } = validate(doc);
|
|
134
|
+
if (ok) return doc;
|
|
135
|
+
// A version this build does not know is its own outcome: the record may be
|
|
136
|
+
// perfectly valid for a newer slidemind, so it is preserved and named.
|
|
137
|
+
const future = errors.find((e) => e.code === "unsupported_version");
|
|
138
|
+
if (future) {
|
|
139
|
+
throw stateError("unsupported_version", `state/${name}: ${future.message} The file was left untouched.`, {
|
|
140
|
+
file,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
throw stateError(
|
|
144
|
+
"state_invalid",
|
|
145
|
+
`state/${name} does not match the record contract, so it was left untouched:\n${errorLines(errors)}`,
|
|
146
|
+
{ errors },
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** `{ version, operations[] }` — an absent file reads as empty, never as an error. */
|
|
151
|
+
export function readEditOperations(workspaceDir) {
|
|
152
|
+
return readDoc(workspaceDir, EDIT_OPERATIONS_FILE, (d) => validateEditOperationsFile(d), EMPTY_OPERATIONS);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** `{ version, slides{} }` — same. */
|
|
156
|
+
export function readSlidePreservation(workspaceDir) {
|
|
157
|
+
return readDoc(workspaceDir, SLIDE_PRESERVATION_FILE, (d) => validateSlidePreservationFile(d), EMPTY_PRESERVATION);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Validate, then write-then-rename. An invalid document never reaches disk. */
|
|
161
|
+
export function writeEditOperations(workspaceDir, doc) {
|
|
162
|
+
const { ok, errors } = validateEditOperationsFile(doc);
|
|
163
|
+
if (!ok) {
|
|
164
|
+
throw stateError("state_invalid", `refusing to write state/${EDIT_OPERATIONS_FILE}:\n${errorLines(errors)}`, {
|
|
165
|
+
errors,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
writeStateJson(workspaceDir, EDIT_OPERATIONS_FILE, doc);
|
|
169
|
+
return doc;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Write the preservation file. The transition rule runs FIRST: `next` may not
|
|
174
|
+
* have dropped an obligation `previous` held unless it supersedes it inside
|
|
175
|
+
* `operation`'s scope. A rejected write leaves the previous bytes in place.
|
|
176
|
+
*/
|
|
177
|
+
export function writeSlidePreservation(workspaceDir, next, { operation = null } = {}) {
|
|
178
|
+
const previous = readSlidePreservation(workspaceDir);
|
|
179
|
+
const { ok, errors } = validatePreservationUpdate(previous, next, { operation });
|
|
180
|
+
if (!ok) {
|
|
181
|
+
throw stateError("state_invalid", `refusing to write state/${SLIDE_PRESERVATION_FILE}:\n${errorLines(errors)}`, {
|
|
182
|
+
errors,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
writeStateJson(workspaceDir, SLIDE_PRESERVATION_FILE, next);
|
|
186
|
+
return next;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// operations
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
export function readOperation(workspaceDir, operationId) {
|
|
194
|
+
return readEditOperations(workspaceDir).operations.find((o) => o.operationId === operationId) ?? null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The operations that still hold slides — what a lease check compares against. */
|
|
198
|
+
export function openOperations(workspaceDir) {
|
|
199
|
+
return readEditOperations(workspaceDir).operations.filter((o) => OPEN_OPERATION_STATUSES.includes(o.status));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Persist one operation, upserting by `operationId`.
|
|
204
|
+
*
|
|
205
|
+
* The LEASE: a slide is scoped by at most one open operation. A second, different
|
|
206
|
+
* operation naming the same slide is refused rather than layered on top — two
|
|
207
|
+
* live scopes on one slide means neither one describes what may change.
|
|
208
|
+
*/
|
|
209
|
+
export function recordOperation(workspaceDir, operation) {
|
|
210
|
+
const { ok, errors } = validateEditOperation(operation);
|
|
211
|
+
if (!ok) {
|
|
212
|
+
throw stateError("operation_invalid", `this operation record is not valid:\n${errorLines(errors)}`, { errors });
|
|
213
|
+
}
|
|
214
|
+
const doc = readEditOperations(workspaceDir);
|
|
215
|
+
const mine = new Set(operationSlideKeys(operation));
|
|
216
|
+
for (const other of doc.operations) {
|
|
217
|
+
if (other.operationId === operation.operationId) continue;
|
|
218
|
+
if (!OPEN_OPERATION_STATUSES.includes(other.status)) continue;
|
|
219
|
+
const clash = operationSlideKeys(other).filter((k) => mine.has(k));
|
|
220
|
+
if (clash.length > 0) {
|
|
221
|
+
throw stateError(
|
|
222
|
+
"operation_conflict",
|
|
223
|
+
`slide ${clash[0]} is already scoped by operation ${other.operationId} (${other.kind}, ${other.status}). ` +
|
|
224
|
+
`Finish that edit session (\`slidemind revision finish <deck>\`) before opening a second scope on it.`,
|
|
225
|
+
{ operationId: other.operationId, slideKeys: clash },
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
writeEditOperations(workspaceDir, {
|
|
230
|
+
version: EDIT_OPERATION_VERSION,
|
|
231
|
+
operations: [...doc.operations.filter((o) => o.operationId !== operation.operationId), operation],
|
|
232
|
+
});
|
|
233
|
+
return operation;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Move every open operation to `status` — what the end of an edit session means. */
|
|
237
|
+
export function closeOpenOperations(workspaceDir, { status = "aborted" } = {}) {
|
|
238
|
+
const doc = readEditOperations(workspaceDir);
|
|
239
|
+
const closed = [];
|
|
240
|
+
const operations = doc.operations.map((o) => {
|
|
241
|
+
if (!OPEN_OPERATION_STATUSES.includes(o.status)) return o;
|
|
242
|
+
closed.push(o.operationId);
|
|
243
|
+
return { ...o, status };
|
|
244
|
+
});
|
|
245
|
+
if (closed.length > 0) writeEditOperations(workspaceDir, { version: EDIT_OPERATION_VERSION, operations });
|
|
246
|
+
return closed;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** sha256 of a slide's bytes as they are on disk right now, or null when it is gone. */
|
|
250
|
+
export function sourceHash(workspaceDir, jsxPath) {
|
|
251
|
+
try {
|
|
252
|
+
return sha256(fs.readFileSync(path.join(workspaceDir, jsxPath)));
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Compare-and-swap: every target's source must still be the bytes the operation
|
|
260
|
+
* was prepared against. A candidate built from source that has since moved is
|
|
261
|
+
* not this operation's candidate, whatever its ledger says.
|
|
262
|
+
*/
|
|
263
|
+
export function assertOperationFresh(workspaceDir, operation) {
|
|
264
|
+
for (const target of operation?.targets ?? []) {
|
|
265
|
+
if (!target?.baseSourceHash) continue;
|
|
266
|
+
const current = sourceHash(workspaceDir, target.jsxPath);
|
|
267
|
+
if (current === null) {
|
|
268
|
+
throw stateError(
|
|
269
|
+
"source_missing",
|
|
270
|
+
`${target.jsxPath} is gone, but operation ${operation.operationId} was prepared against it.`,
|
|
271
|
+
{ slideKey: target.slideKey, jsxPath: target.jsxPath },
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (current !== target.baseSourceHash) {
|
|
275
|
+
throw stateError(
|
|
276
|
+
"source_moved",
|
|
277
|
+
`${target.jsxPath} changed since operation ${operation.operationId} was prepared ` +
|
|
278
|
+
`(${target.baseSourceHash.slice(0, 12)} → ${current.slice(0, 12)}). ` +
|
|
279
|
+
`Re-open the scope (\`slidemind revision begin\`) against the current source.`,
|
|
280
|
+
{ slideKey: target.slideKey, jsxPath: target.jsxPath, expected: target.baseSourceHash, actual: current },
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// resolving a selection to durable identity
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* "2,7" / "2-4" / "all" / "03-market.jsx" / "03-market" → the raw selectors.
|
|
293
|
+
* Position is an input grammar only: {@link resolveSlideTargets} turns each one
|
|
294
|
+
* into a slide GUID before anything is written down.
|
|
295
|
+
*/
|
|
296
|
+
export function parseSlideSelection(raw) {
|
|
297
|
+
if (raw == null || raw === "" || raw === "all") return null;
|
|
298
|
+
const out = [];
|
|
299
|
+
for (const chunk of String(raw).split(",")) {
|
|
300
|
+
const token = chunk.trim();
|
|
301
|
+
if (!token) continue;
|
|
302
|
+
const range = /^(\d+)\s*-\s*(\d+)$/.exec(token);
|
|
303
|
+
if (range) {
|
|
304
|
+
const [from, to] = [Number(range[1]), Number(range[2])];
|
|
305
|
+
if (from < 1 || to < from) throw stateError("bad_selection", `'${token}' is not a slide range`);
|
|
306
|
+
for (let n = from; n <= to; n++) out.push(n);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (/^\d+$/.test(token)) {
|
|
310
|
+
const n = Number(token);
|
|
311
|
+
if (n < 1) throw stateError("bad_selection", `'${token}' is not a 1-based slide number`);
|
|
312
|
+
out.push(n);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
out.push(token);
|
|
316
|
+
}
|
|
317
|
+
return out.length > 0 ? out : null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Every slide file in play order with its DURABLE key. A slide with no GUID yet
|
|
322
|
+
* (a deck that has never rendered) gets one minted and persisted here, because
|
|
323
|
+
* an operation that named a position would be worthless the moment the deck is
|
|
324
|
+
* reordered.
|
|
325
|
+
*
|
|
326
|
+
* `selection` is the output of {@link parseSlideSelection}; null means all.
|
|
327
|
+
*/
|
|
328
|
+
export function resolveSlideTargets(workspaceDir, { selection = null } = {}) {
|
|
329
|
+
const known = loadSlideGuids(workspaceDir);
|
|
330
|
+
const guids = reconcileSlideGuids(workspaceDir);
|
|
331
|
+
const order = slideFileOrder(workspaceDir);
|
|
332
|
+
const fresh = {};
|
|
333
|
+
const all = order.map((file, i) => {
|
|
334
|
+
const jsxPath = `slides/${file}`;
|
|
335
|
+
let slideKey = guids[jsxPath] ?? null;
|
|
336
|
+
if (!slideKey) {
|
|
337
|
+
// `reconcileSlideGuids` tracks .jsx only, so it drops a reused native
|
|
338
|
+
// slide's entry every time it runs. `known` is the sidecar as it was
|
|
339
|
+
// before that, which is where a native's identity survives — and it is
|
|
340
|
+
// re-persisted below so the next command resolves the same key.
|
|
341
|
+
slideKey = known[jsxPath] ?? null;
|
|
342
|
+
if (!slideKey) slideKey = mintSlideGuid();
|
|
343
|
+
try {
|
|
344
|
+
fresh[jsxPath] = slideGuidEntry(slideKey, fs.readFileSync(path.join(workspaceDir, jsxPath)));
|
|
345
|
+
} catch {
|
|
346
|
+
fresh[jsxPath] = slideKey;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return { number: i + 1, file, jsxPath, slideKey, sourceHash: sourceHash(workspaceDir, jsxPath) };
|
|
350
|
+
});
|
|
351
|
+
if (Object.keys(fresh).length > 0) saveSlideGuids(workspaceDir, fresh);
|
|
352
|
+
if (!selection) return all;
|
|
353
|
+
|
|
354
|
+
const byNumber = new Map(all.map((t) => [t.number, t]));
|
|
355
|
+
const byName = new Map();
|
|
356
|
+
for (const t of all) {
|
|
357
|
+
byName.set(t.file, t);
|
|
358
|
+
byName.set(t.file.replace(/\.(jsx|pptx)$/i, ""), t);
|
|
359
|
+
byName.set(t.slideKey, t);
|
|
360
|
+
}
|
|
361
|
+
const picked = new Map();
|
|
362
|
+
for (const selector of selection) {
|
|
363
|
+
const hit = typeof selector === "number" ? byNumber.get(selector) : byName.get(selector);
|
|
364
|
+
if (!hit) {
|
|
365
|
+
throw stateError(
|
|
366
|
+
"bad_selection",
|
|
367
|
+
`--slides: this deck has no slide '${selector}' (it has ${all.length}: ${all.map((t) => t.file).join(", ") || "none"})`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
picked.set(hit.slideKey, hit);
|
|
371
|
+
}
|
|
372
|
+
return [...picked.values()].sort((a, b) => a.number - b.number);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// legacy migration
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
// `state/transcribed.json`, written by `slidemind transcribe` (src/cli/transcribe.mjs):
|
|
380
|
+
// slide file name → sha256 of the utf8 source it wrote. Read directly rather
|
|
381
|
+
// than through that module, which is a command (its helpers exit the process).
|
|
382
|
+
// `state/transcribed-delivery.json`, written beside it: per file, the ids that
|
|
383
|
+
// write left out (`dropped`) and the line id each connector went under.
|
|
384
|
+
function readTranscribeStamps(workspaceDir, name = "transcribed.json") {
|
|
385
|
+
try {
|
|
386
|
+
const parsed = JSON.parse(fs.readFileSync(statePath(workspaceDir, name), "utf8"));
|
|
387
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
388
|
+
} catch {
|
|
389
|
+
return {};
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ponytail: tag scan by regex, not a JSX parse. Slide source has no `>` inside
|
|
394
|
+
// an attribute expression (`style={{…}}`, `data={{…}}`, numeric props), so the
|
|
395
|
+
// bound holds for everything the transcriber and the converter emit. A real
|
|
396
|
+
// parser belongs with the repair validator (phase 6), which needs one anyway.
|
|
397
|
+
const TAG_RE = /<[A-Za-z][^>]*>/g;
|
|
398
|
+
// `(?<![-\w])` and not `\b`: `\bid=` also matches the tail of
|
|
399
|
+
// `data-sm-source-id=`, which would hand a converted element its source id
|
|
400
|
+
// under the wrong attribute name. A braced value counts only as a string or
|
|
401
|
+
// number literal: `id={id}` names a helper's parameter, not a value.
|
|
402
|
+
const attr = (tag, name) => {
|
|
403
|
+
const m = new RegExp(`(?<![-\\w])${name}="([^"]*)"`).exec(tag);
|
|
404
|
+
if (m) return m[1];
|
|
405
|
+
const braced = new RegExp(`(?<![-\\w])${name}=\\{\\s*(?:"([^"]*)"|'([^']*)'|\\x60([^\\x60$]*)\\x60|(\\d+))\\s*\\}`).exec(tag);
|
|
406
|
+
return braced ? (braced[1] ?? braced[2] ?? braced[3] ?? braced[4]) : null;
|
|
407
|
+
};
|
|
408
|
+
/** `<div id={id} …>`: a helper's template, given a real id at each call site. */
|
|
409
|
+
const isTemplateTag = (tag) => attr(tag, "id") == null && /(?<![-\w])id=\{/.test(tag);
|
|
410
|
+
|
|
411
|
+
/** How much of an element's text a requirement keeps, to quote in a refusal. */
|
|
412
|
+
const EXCERPT_CHARS = 32;
|
|
413
|
+
const excerptOf = (text) => (text.length > EXCERPT_CHARS ? `${text.slice(0, EXCERPT_CHARS - 1)}…` : text);
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The obligations readable from the source in front of us: one requirement per
|
|
417
|
+
* element that carries a durable id, connectors bound to their endpoints, and
|
|
418
|
+
* the native-object placeholders an import left behind.
|
|
419
|
+
*
|
|
420
|
+
* For a CONVERTED slide the ids are the converter's own shape ids
|
|
421
|
+
* (`data-sm-source-id`), never a concept's — an import has no concept. What
|
|
422
|
+
* each requirement protects comes from {@link PROTECTED_BY_ORIGIN}; an image
|
|
423
|
+
* also records the asset it points at (`mediaRef`), which is what makes a
|
|
424
|
+
* substitution detectable at the same element count (E11).
|
|
425
|
+
*/
|
|
426
|
+
export function scanSourceRequirements(jsx, { packagePart = null, origin = "legacy-current" } = {}) {
|
|
427
|
+
const requirements = [];
|
|
428
|
+
const limitations = [];
|
|
429
|
+
const ids = new Set();
|
|
430
|
+
const sourceIds = new Set();
|
|
431
|
+
const connectors = [];
|
|
432
|
+
let unidentified = 0;
|
|
433
|
+
const read = readSlideElements(jsx);
|
|
434
|
+
|
|
435
|
+
// A set component's members are scanned where they are drawn, by their
|
|
436
|
+
// literal part ids (inline-components.mjs).
|
|
437
|
+
const inlined = inlineComponents(String(jsx ?? ""));
|
|
438
|
+
for (const u of inlined.unreadable) {
|
|
439
|
+
limitations.push(`<${u.component}> on line ${u.line} is spread from an object, so its members cannot be required back`);
|
|
440
|
+
}
|
|
441
|
+
for (const tag of inlined.src.match(TAG_RE) ?? []) {
|
|
442
|
+
if (/^<\//.test(tag) || isTemplateTag(tag)) continue;
|
|
443
|
+
const srcId = attr(tag, "data-sm-source-id");
|
|
444
|
+
const elementId = attr(tag, "id") ?? (srcId ? String(srcId) : null);
|
|
445
|
+
const isConnector = /\bdata-connector\b/.test(tag);
|
|
446
|
+
const isNative = /\bdata-sm-native-object\b/.test(tag);
|
|
447
|
+
const isImage = /^<img\b/i.test(tag);
|
|
448
|
+
|
|
449
|
+
if (isConnector) {
|
|
450
|
+
// A connector is identified by what it joins, so an unlabelled one is not
|
|
451
|
+
// ambiguous the way an unlabelled box is: its id is derived below, after
|
|
452
|
+
// both endpoints have been resolved against the slide.
|
|
453
|
+
connectors.push({ elementId, from: attr(tag, "data-from"), to: attr(tag, "data-to") });
|
|
454
|
+
if (elementId) ids.add(elementId);
|
|
455
|
+
if (srcId) sourceIds.add(String(srcId));
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (!elementId) {
|
|
459
|
+
// A layout wrapper carries no identity and owes nothing; a content element
|
|
460
|
+
// without one cannot be required back. Counted, never guessed at.
|
|
461
|
+
if (isNative || isImage || /\bx=|\bleft:/.test(tag)) unidentified += 1;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (srcId) sourceIds.add(String(srcId));
|
|
465
|
+
ids.add(elementId);
|
|
466
|
+
|
|
467
|
+
if (isNative) {
|
|
468
|
+
const rel = packagePart ? [{ role: "package-part", target: packagePart }] : [];
|
|
469
|
+
if (rel.length === 0) {
|
|
470
|
+
limitations.push(
|
|
471
|
+
`${elementId} is a preserved native object whose source package is not in this deck; ` +
|
|
472
|
+
`its dependency closure cannot be required back`,
|
|
473
|
+
);
|
|
474
|
+
requirements.push({
|
|
475
|
+
requirementId: `req-element-${elementId}`,
|
|
476
|
+
elementId,
|
|
477
|
+
kind: "element",
|
|
478
|
+
// Still a native object: content, never decoration a redesign may drop.
|
|
479
|
+
properties: protect(["geom", "native"], origin),
|
|
480
|
+
});
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const slot = attr(tag, "data-sm-native-object");
|
|
484
|
+
requirements.push({
|
|
485
|
+
requirementId: `req-native-object-${elementId}`,
|
|
486
|
+
elementId,
|
|
487
|
+
kind: "native-object",
|
|
488
|
+
properties: protect(["native"], origin),
|
|
489
|
+
relationships: slot != null ? [...rel, { role: "native-slot", target: String(slot) }] : rel,
|
|
490
|
+
});
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
// What each element class can even have: an image has bytes, a chart has
|
|
494
|
+
// data, an ordinary box has styling — and text or a picture fill only
|
|
495
|
+
// where it actually carries one. A shape with no content owes none, which
|
|
496
|
+
// is what lets a redesign restyle it away (`declaredFor`,
|
|
497
|
+
// preservation-verdict.mjs). The origin then decides which of those it is
|
|
498
|
+
// entitled to claim.
|
|
499
|
+
const isChart = /^<Chart\b/.test(tag);
|
|
500
|
+
const text = read.byId.get(elementId)?.text ?? "";
|
|
501
|
+
const candidates = isImage
|
|
502
|
+
? ["media", "geom"]
|
|
503
|
+
: isChart
|
|
504
|
+
? ["chart", "geom"]
|
|
505
|
+
: ["geom", "fill", "line", ...(text ? ["text"] : []), ...(/\burl\(/.test(tag) ? ["media"] : [])];
|
|
506
|
+
const properties = protect(candidates, origin);
|
|
507
|
+
const mediaRef = isImage ? attr(tag, "src") : null;
|
|
508
|
+
requirements.push({
|
|
509
|
+
requirementId: `req-element-${elementId}`,
|
|
510
|
+
elementId,
|
|
511
|
+
kind: "element",
|
|
512
|
+
properties,
|
|
513
|
+
...(mediaRef ? { mediaRef } : {}),
|
|
514
|
+
...(properties.includes("text") ? { excerpt: excerptOf(text) } : {}),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// A connector's endpoints are the whole obligation (E11: same element count,
|
|
519
|
+
// different endpoint). The emitted anchor may be prefixed — resolve it against
|
|
520
|
+
// the ids actually on this slide, and say so when it resolves to nothing.
|
|
521
|
+
const resolveEnd = (ref) => {
|
|
522
|
+
const bare = String(ref ?? "").replace(/^#/, "");
|
|
523
|
+
if (!bare) return null;
|
|
524
|
+
if (ids.has(bare)) return bare;
|
|
525
|
+
const suffix = /^sm-(.+)$/.exec(bare)?.[1];
|
|
526
|
+
if (suffix && (ids.has(suffix) || sourceIds.has(suffix))) return suffix;
|
|
527
|
+
return bare;
|
|
528
|
+
};
|
|
529
|
+
const usedConnectorIds = new Set(ids);
|
|
530
|
+
for (const c of connectors) {
|
|
531
|
+
const start = resolveEnd(c.from);
|
|
532
|
+
const end = resolveEnd(c.to);
|
|
533
|
+
if (!start || !end) {
|
|
534
|
+
// Without both ends there is no obligation to state and no identity to
|
|
535
|
+
// derive — the only honest outcome is to say so.
|
|
536
|
+
unidentified += 1;
|
|
537
|
+
limitations.push(`a connector names no endpoints in the source; it cannot be required back`);
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
// Derived, not invented: a connector the source did not name is identified
|
|
541
|
+
// by the pair it joins, and two connectors on the same pair are numbered in
|
|
542
|
+
// source order — the order both clients read the slide in.
|
|
543
|
+
let elementId = c.elementId;
|
|
544
|
+
if (!elementId) {
|
|
545
|
+
elementId = `conn-${start}-${end}`;
|
|
546
|
+
for (let n = 2; usedConnectorIds.has(elementId); n++) elementId = `conn-${start}-${end}-${n}`;
|
|
547
|
+
}
|
|
548
|
+
usedConnectorIds.add(elementId);
|
|
549
|
+
if (!ids.has(start) && !sourceIds.has(start)) {
|
|
550
|
+
limitations.push(`connector ${elementId} starts at '${start}', which is not an element of this slide`);
|
|
551
|
+
}
|
|
552
|
+
if (!ids.has(end) && !sourceIds.has(end)) {
|
|
553
|
+
limitations.push(`connector ${elementId} ends at '${end}', which is not an element of this slide`);
|
|
554
|
+
}
|
|
555
|
+
requirements.push({
|
|
556
|
+
requirementId: `req-connection-${elementId}`,
|
|
557
|
+
elementId,
|
|
558
|
+
kind: "connection",
|
|
559
|
+
// A connector's obligation IS its endpoints; the route is style, and only
|
|
560
|
+
// an import claims that much.
|
|
561
|
+
properties: protect(["geom", "line"], origin),
|
|
562
|
+
relationships: [
|
|
563
|
+
{ role: "start", target: start },
|
|
564
|
+
{ role: "end", target: end },
|
|
565
|
+
],
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (unidentified > 0) {
|
|
570
|
+
limitations.push(
|
|
571
|
+
`${unidentified} element(s) in the current source carry no durable id, so their preservation cannot be required`,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
return { requirements, limitations };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* The obligations a TRANSCRIBED slide carries, taken from its concept spec's
|
|
579
|
+
* element ids (`state/concept-specs/concept-slide-NN.json`) rather than from
|
|
580
|
+
* the JSX those ids were written into. The spec is the approved content: it
|
|
581
|
+
* knows which text is a business fact, which asset was chosen and which element
|
|
582
|
+
* is a chart — none of which the emitted source states about itself.
|
|
583
|
+
*
|
|
584
|
+
* Connector ids are derived exactly as {@link scanSourceRequirements} derives
|
|
585
|
+
* them (`conn-<from>-<to>`), so a slide seeded from its spec and one re-read
|
|
586
|
+
* from its source name the same obligations.
|
|
587
|
+
*/
|
|
588
|
+
export function specRequirements(spec, { origin = "concept" } = {}) {
|
|
589
|
+
const requirements = [];
|
|
590
|
+
const limitations = [];
|
|
591
|
+
const ids = new Set();
|
|
592
|
+
let unidentified = 0;
|
|
593
|
+
|
|
594
|
+
for (const e of spec?.elements ?? []) {
|
|
595
|
+
if (!isDurableId(e?.id)) {
|
|
596
|
+
unidentified += 1;
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
ids.add(e.id);
|
|
600
|
+
// A bound original delegates byte and framing verification to the
|
|
601
|
+
// image-intent validator; all this record keeps is the canonical use id.
|
|
602
|
+
if (isDurableId(e.image?.assetUseId)) {
|
|
603
|
+
requirements.push({
|
|
604
|
+
requirementId: `req-image-use-${e.id}`,
|
|
605
|
+
elementId: e.id,
|
|
606
|
+
kind: "image-use",
|
|
607
|
+
useId: e.image.assetUseId,
|
|
608
|
+
properties: protect(["media"], origin),
|
|
609
|
+
});
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
const candidates = [];
|
|
613
|
+
if (cleanText(e.text) != null) candidates.push("text");
|
|
614
|
+
if (e.fillToken || e.fillHex || (e.fill && e.fill !== "none")) candidates.push("fill");
|
|
615
|
+
if (e.image || e.icon) candidates.push("media");
|
|
616
|
+
if (e.chart) candidates.push("chart");
|
|
617
|
+
requirements.push({
|
|
618
|
+
requirementId: `req-element-${e.id}`,
|
|
619
|
+
elementId: e.id,
|
|
620
|
+
kind: "element",
|
|
621
|
+
properties: protect(candidates, origin),
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const used = new Set(ids);
|
|
626
|
+
for (const c of spec?.connectors ?? []) {
|
|
627
|
+
// A free line (an end at a point) is drawn geometry, not a relationship.
|
|
628
|
+
if (c?.fromPoint || c?.toPoint) continue;
|
|
629
|
+
const start = String(c?.from ?? "");
|
|
630
|
+
const end = String(c?.to ?? "");
|
|
631
|
+
if (!isDurableId(start) || !isDurableId(end)) {
|
|
632
|
+
limitations.push("a connector in the concept spec names no endpoints; it cannot be required back");
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
let elementId = `conn-${start}-${end}`;
|
|
636
|
+
for (let n = 2; used.has(elementId); n++) elementId = `conn-${start}-${end}-${n}`;
|
|
637
|
+
used.add(elementId);
|
|
638
|
+
if (!ids.has(start)) limitations.push(`connector ${elementId} starts at '${start}', which the spec does not list`);
|
|
639
|
+
if (!ids.has(end)) limitations.push(`connector ${elementId} ends at '${end}', which the spec does not list`);
|
|
640
|
+
requirements.push({
|
|
641
|
+
requirementId: `req-connection-${elementId}`,
|
|
642
|
+
elementId,
|
|
643
|
+
kind: "connection",
|
|
644
|
+
properties: protect(["geom", "line"], origin),
|
|
645
|
+
relationships: [
|
|
646
|
+
{ role: "start", target: start },
|
|
647
|
+
{ role: "end", target: end },
|
|
648
|
+
],
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
if (unidentified > 0) {
|
|
652
|
+
limitations.push(`${unidentified} concept element(s) carry no id, so their preservation cannot be required`);
|
|
653
|
+
}
|
|
654
|
+
return { requirements, limitations };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Concept obligations bound to what `transcribe` DELIVERED — LOCKSTEP with the
|
|
658
|
+
// add-in's `bindDelivered` (transcription-state.ts). A spec is read only while
|
|
659
|
+
// its source is byte for byte transcribe's output, so an obligation that output
|
|
660
|
+
// cannot answer for fails our own gate on a slide nobody touched. `delivery` is
|
|
661
|
+
// transcribe's own record of that write: a split line or a mark's dots is
|
|
662
|
+
// `<id>-N` (`bindSplitIds`); a connector not glued as the concept says (a
|
|
663
|
+
// loop's arc, an end whose ink stops short) is owed as the line it was written
|
|
664
|
+
// under (`connectorLines`); an id it deliberately left out (`dropped`) is
|
|
665
|
+
// released. Anything else the output lacks stays owed: a silent loss is a
|
|
666
|
+
// transcriber defect and must read as one.
|
|
667
|
+
function bindDelivered(seeded, jsx, { dropped = [], connectorLines = [] } = {}) {
|
|
668
|
+
const present = readSlideElements(jsx).byId;
|
|
669
|
+
const pairOf = (r) => ["start", "end"].map((role) => r.relationships?.find((x) => x.role === role)?.target).join(" -> ");
|
|
670
|
+
const bound = bindSplitIds(seeded.requirements, jsx);
|
|
671
|
+
// A delivered connector answers for one obligation, never for an element id.
|
|
672
|
+
const claimed = new Set(bound.filter((r) => r.kind !== "connection").map((r) => r.elementId));
|
|
673
|
+
const limitations = [...seeded.limitations];
|
|
674
|
+
const requirements = [];
|
|
675
|
+
for (const req of bound) {
|
|
676
|
+
const ends = req.kind === "connection" ? pairOf(req) : null;
|
|
677
|
+
const written = ends ? connectorLines.filter((l) => `${l.from} -> ${l.to}` === ends).map((l) => l.id) : [];
|
|
678
|
+
const glued = ends && [...present].find(([id, e]) => !claimed.has(id) && pairOf(e) === ends)?.[0];
|
|
679
|
+
const line = ends && !glued && written.find((id) => present.has(id) && !claimed.has(id));
|
|
680
|
+
if (glued || line) {
|
|
681
|
+
claimed.add(glued || line);
|
|
682
|
+
if (glued) requirements.push({ ...req, requirementId: `req-connection-${glued}`, elementId: glued });
|
|
683
|
+
else {
|
|
684
|
+
requirements.push({ requirementId: `req-element-${line}`, elementId: line, kind: "element", properties: req.properties });
|
|
685
|
+
limitations.push(`${ends} is drawn as ${line} without both ends glued: the line is owed, the relation cannot be required back`);
|
|
686
|
+
}
|
|
687
|
+
} else if ((ends || !present.has(req.elementId)) && [req.elementId, ...written].some((id) => dropped.includes(id))) {
|
|
688
|
+
limitations.push(`${req.elementId} was left out by transcribe (see its notes), so it cannot be required back`);
|
|
689
|
+
} else {
|
|
690
|
+
requirements.push(req);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return { requirements, limitations };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// A slide file's number is its NN- prefix — the same rule `transcribe` uses to
|
|
697
|
+
// pair `concept-slide-NN.json` with `NN-name.jsx`. Falls back to play order for
|
|
698
|
+
// a deck that never used the prefix.
|
|
699
|
+
function slideNumberFor(target) {
|
|
700
|
+
const prefixed = /^(\d+)[-_.]/.exec(target.file);
|
|
701
|
+
return prefixed ? Number(prefixed[1]) : target.number;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function seedEntry(workspaceDir, target, { stamps, delivery, conversions, baseRevision }) {
|
|
705
|
+
let bytes;
|
|
706
|
+
try {
|
|
707
|
+
bytes = fs.readFileSync(path.join(workspaceDir, target.jsxPath));
|
|
708
|
+
} catch {
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
const baseline = { sourceHash: sha256(bytes), revision: baseRevision ?? null };
|
|
712
|
+
const limitations = [];
|
|
713
|
+
// `loadConversions` cannot say "a record was here and I could not read it" —
|
|
714
|
+
// a map has no encoding for that, so a corrupt record reaches it as absence.
|
|
715
|
+
// Read the third state directly: seeding `legacy-current` for a slide that
|
|
716
|
+
// IS an import would hand it the WEAKER protected set (media/native instead
|
|
717
|
+
// of the full raw-fidelity set), and an import is precisely the case where
|
|
718
|
+
// the raw values are the content. The record file existing is evidence about
|
|
719
|
+
// what the slide IS; failing to parse it says nothing about that, only about
|
|
720
|
+
// the details — so origin stays `converted` and the details become a stated
|
|
721
|
+
// limitation.
|
|
722
|
+
const provenance = conversionProvenance(workspaceDir, target.file);
|
|
723
|
+
const conversionUnreadable = provenance === undefined;
|
|
724
|
+
const conversion = conversions[target.file] ?? (conversionUnreadable ? {} : null);
|
|
725
|
+
const isJsx = target.file.toLowerCase().endsWith(".jsx");
|
|
726
|
+
const stem = target.file.replace(/\.(jsx|pptx)$/i, "");
|
|
727
|
+
const packagePart = fs.existsSync(path.join(workspaceDir, "conversions", `${stem}.slide.pptx`))
|
|
728
|
+
? `conversions/${stem}.slide.pptx`
|
|
729
|
+
: null;
|
|
730
|
+
|
|
731
|
+
// WHERE THE OBLIGATIONS COME FROM decides both the origin and the reader.
|
|
732
|
+
// An import is read through the converter's shape ids, a machine-owned
|
|
733
|
+
// transcription through its concept spec, and everything else through the
|
|
734
|
+
// source as it stands — which is the one rule with teeth here: source that a
|
|
735
|
+
// person has edited since it was stamped is NOT a concept's to promise.
|
|
736
|
+
let origin;
|
|
737
|
+
let seeded = null;
|
|
738
|
+
const machineOwned = Boolean(stamps[target.file]) && stamps[target.file] === sha256(bytes.toString("utf8"));
|
|
739
|
+
if (conversion) {
|
|
740
|
+
origin = "converted";
|
|
741
|
+
if (conversionUnreadable) {
|
|
742
|
+
// The natives its `preserved[]` would have named cannot be enumerated, so
|
|
743
|
+
// any native without a placeholder in the source is unrecoverable here.
|
|
744
|
+
limitations.push(
|
|
745
|
+
`conversions/${stem}.json is on disk and could not be read: this slide is treated as an import ` +
|
|
746
|
+
`(its raw values are protected), but its preserved natives could not be enumerated — repair or ` +
|
|
747
|
+
`re-adopt the record before relying on native preservation`,
|
|
748
|
+
);
|
|
749
|
+
} else if (!conversion.conversionEvidence) {
|
|
750
|
+
limitations.push("imported without render evidence; what the conversion produced was never verified here");
|
|
751
|
+
}
|
|
752
|
+
} else if (!isJsx) {
|
|
753
|
+
origin = "legacy-current";
|
|
754
|
+
limitations.push("native .pptx slide: its contents are not modelled locally, only its bytes are");
|
|
755
|
+
} else if (machineOwned) {
|
|
756
|
+
const spec = readSpec(workspaceDir, slideNumberFor(target));
|
|
757
|
+
if (spec) {
|
|
758
|
+
origin = "concept";
|
|
759
|
+
seeded = bindDelivered(specRequirements(spec, { origin }), bytes.toString("utf8"), delivery[target.file]);
|
|
760
|
+
} else {
|
|
761
|
+
origin = "legacy-current";
|
|
762
|
+
limitations.push(
|
|
763
|
+
"transcribed, but its concept spec is no longer on disk; these obligations come from the current source",
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
} else {
|
|
767
|
+
origin = "legacy-current";
|
|
768
|
+
if (stamps[target.file]) {
|
|
769
|
+
limitations.push(
|
|
770
|
+
"edited since `transcribe` stamped it: these obligations come from the CURRENT source, " +
|
|
771
|
+
"not from the concept spec it was generated from",
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const scan =
|
|
777
|
+
seeded ??
|
|
778
|
+
(isJsx
|
|
779
|
+
? scanSourceRequirements(bytes.toString("utf8"), { packagePart, origin })
|
|
780
|
+
: { requirements: [], limitations: [] });
|
|
781
|
+
const active = [...scan.requirements];
|
|
782
|
+
limitations.push(...scan.limitations);
|
|
783
|
+
|
|
784
|
+
// A converted slide whose source carries fewer identities than the original
|
|
785
|
+
// had shapes lost something in conversion; that is a stated gap, not a pass.
|
|
786
|
+
const sourceShapes = conversion?.sourceBaseline?.projection?.shapes?.length ?? null;
|
|
787
|
+
if (sourceShapes != null && active.length > 0 && sourceShapes > active.length) {
|
|
788
|
+
limitations.push(
|
|
789
|
+
`the source slide had ${sourceShapes} shape(s) but only ${active.length} carry an identity here; ` +
|
|
790
|
+
`the rest cannot be required back`,
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Natives the import preserved but the source carries no placeholder for:
|
|
795
|
+
// the package is the obligation, whatever the jsx shows.
|
|
796
|
+
const placeheld = new Set(active.filter((r) => r.kind === "native-object").map((r) => r.elementId));
|
|
797
|
+
(conversion?.preserved ?? []).forEach((p, i) => {
|
|
798
|
+
const elementId = String(p?.sourceId ?? `native-${i}`).replace(/\s+/g, "-");
|
|
799
|
+
if (placeheld.has(elementId) || active.some((r) => r.kind === "native-object" && r.relationships?.some((rel) => rel.role === "native-slot" && rel.target === String(i)))) {
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (!packagePart) {
|
|
803
|
+
limitations.push(`preserved native '${elementId}' has no source package in this deck; it cannot be required back`);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
active.push({
|
|
807
|
+
requirementId: `req-native-object-${elementId}`,
|
|
808
|
+
elementId,
|
|
809
|
+
kind: "native-object",
|
|
810
|
+
properties: protect(["native"], origin),
|
|
811
|
+
relationships: [
|
|
812
|
+
{ role: "package-part", target: packagePart },
|
|
813
|
+
{ role: "native-slot", target: String(i) },
|
|
814
|
+
],
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
return {
|
|
819
|
+
version: SLIDE_PRESERVATION_VERSION,
|
|
820
|
+
slideKey: target.slideKey,
|
|
821
|
+
origin,
|
|
822
|
+
// Migration captures obligations, never verdicts. `unknown` is what makes a
|
|
823
|
+
// final replacement refusable instead of quietly assumed good.
|
|
824
|
+
evidence: "unknown",
|
|
825
|
+
baseline,
|
|
826
|
+
active,
|
|
827
|
+
superseded: [],
|
|
828
|
+
limitations,
|
|
829
|
+
migratedAt: new Date().toISOString(),
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Seed preservation records for every slide that has none, from the evidence
|
|
835
|
+
* actually on disk. Additive and resumable: an existing entry is never touched,
|
|
836
|
+
* so a run interrupted halfway continues where it stopped, and a deck that has
|
|
837
|
+
* already migrated is a no-op.
|
|
838
|
+
*
|
|
839
|
+
* Refuses (and writes nothing) when the record on disk is corrupt or carries a
|
|
840
|
+
* schema this build does not read.
|
|
841
|
+
*/
|
|
842
|
+
export function migrateEditState(workspaceDir, { selection = null } = {}) {
|
|
843
|
+
const previous = readSlidePreservation(workspaceDir);
|
|
844
|
+
const targets = resolveSlideTargets(workspaceDir, { selection });
|
|
845
|
+
const stamps = readTranscribeStamps(workspaceDir);
|
|
846
|
+
const delivery = readTranscribeStamps(workspaceDir, "transcribed-delivery.json");
|
|
847
|
+
const conversions = loadConversions(workspaceDir, { includeSource: false });
|
|
848
|
+
const baseRevision = readRevision(workspaceDir)?.base?.sha256 ?? null;
|
|
849
|
+
|
|
850
|
+
const slides = { ...previous.slides };
|
|
851
|
+
const seeded = [];
|
|
852
|
+
const skipped = [];
|
|
853
|
+
for (const target of targets) {
|
|
854
|
+
if (slides[target.slideKey]) continue;
|
|
855
|
+
const entry = seedEntry(workspaceDir, target, { stamps, delivery, conversions, baseRevision });
|
|
856
|
+
if (!entry) {
|
|
857
|
+
skipped.push({ slideKey: target.slideKey, jsxPath: target.jsxPath, reason: "unreadable" });
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
slides[target.slideKey] = entry;
|
|
861
|
+
seeded.push({
|
|
862
|
+
slideKey: target.slideKey,
|
|
863
|
+
jsxPath: target.jsxPath,
|
|
864
|
+
origin: entry.origin,
|
|
865
|
+
requirements: entry.active.length,
|
|
866
|
+
limitations: entry.limitations,
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
if (seeded.length > 0) {
|
|
870
|
+
writeSlidePreservation(workspaceDir, { version: SLIDE_PRESERVATION_VERSION, slides });
|
|
871
|
+
}
|
|
872
|
+
return { seeded, skipped, migrated: seeded.length > 0, slideCount: Object.keys(slides).length };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ---------------------------------------------------------------------------
|
|
876
|
+
// scoped amendments — the only legal way a requirement set changes
|
|
877
|
+
// ---------------------------------------------------------------------------
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* What this operation PERMITS on a slide: `elementId → Set(property scope)`,
|
|
881
|
+
* plus `removed` for the elements it was asked to delete.
|
|
882
|
+
*
|
|
883
|
+
* Permissions live on the operation, obligations on the preservation record.
|
|
884
|
+
* Keeping them apart is deliberate: an `update` never narrows a requirement (it
|
|
885
|
+
* would never widen back), so the verdict reads both and the record stays the
|
|
886
|
+
* statement of what is owed.
|
|
887
|
+
*/
|
|
888
|
+
export function permittedChanges(operation, slideKey) {
|
|
889
|
+
const out = new Map();
|
|
890
|
+
const target = (operation?.targets ?? []).find((t) => t?.slideKey === slideKey);
|
|
891
|
+
for (const change of target?.changes ?? []) {
|
|
892
|
+
if (!isDurableId(change?.elementId)) continue;
|
|
893
|
+
const entry = out.get(change.elementId) ?? { action: change.action, properties: new Set() };
|
|
894
|
+
entry.action = change.action;
|
|
895
|
+
for (const scope of change.properties ?? []) entry.properties.add(scope);
|
|
896
|
+
out.set(change.elementId, entry);
|
|
897
|
+
}
|
|
898
|
+
return out;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Apply an operation's declared changes to the preservation record.
|
|
903
|
+
*
|
|
904
|
+
* This is the ONLY door through which an obligation ends. It is request-scoped
|
|
905
|
+
* in three ways at once: the supersession names the operation that authorized
|
|
906
|
+
* it and the base source it was authorized against; the write goes through
|
|
907
|
+
* `validatePreservationUpdate(previous, next, { operation })`, which refuses any
|
|
908
|
+
* drop this operation did not name and any slide it does not target; and the
|
|
909
|
+
* operation must still be fresh, so a scope declared against source that has
|
|
910
|
+
* since moved cannot retire anything.
|
|
911
|
+
*
|
|
912
|
+
* Three actions, three outcomes:
|
|
913
|
+
* remove supersede every requirement on that element — the intentional
|
|
914
|
+
* deletion, distinguishable from the accidental one precisely because
|
|
915
|
+
* it was asked for in advance (E09 vs E10).
|
|
916
|
+
* add a REQUESTED ADDITION becomes a required output in its own right.
|
|
917
|
+
* Absence of old content is not the only failure mode: an addition
|
|
918
|
+
* the user asked for and did not get fails too.
|
|
919
|
+
* update nothing here. See {@link permittedChanges}.
|
|
920
|
+
*
|
|
921
|
+
* Unaffected requirements are carried through untouched, and there is no
|
|
922
|
+
* encoding for "reset this slide" — a blanket clear is unrepresentable because
|
|
923
|
+
* every drop has to be named element by element.
|
|
924
|
+
*/
|
|
925
|
+
export function applyRequirementAmendment(workspaceDir, operation) {
|
|
926
|
+
assertOperationFresh(workspaceDir, operation);
|
|
927
|
+
const doc = readSlidePreservation(workspaceDir);
|
|
928
|
+
const slides = { ...doc.slides };
|
|
929
|
+
const amended = { added: [], superseded: [], slides: [] };
|
|
930
|
+
const at = new Date().toISOString();
|
|
931
|
+
|
|
932
|
+
for (const target of operation?.targets ?? []) {
|
|
933
|
+
const changes = (target.changes ?? []).filter((c) => isDurableId(c?.elementId));
|
|
934
|
+
if (changes.length === 0) continue;
|
|
935
|
+
const entry = slides[target.slideKey];
|
|
936
|
+
if (!entry) {
|
|
937
|
+
throw stateError(
|
|
938
|
+
"no_preservation_record",
|
|
939
|
+
`${target.jsxPath} has no preservation record, so there is nothing to amend. ` +
|
|
940
|
+
`Open the scope with \`slidemind revision begin\`, which captures one first.`,
|
|
941
|
+
{ slideKey: target.slideKey },
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
const next = amendForOperation(entry, target, operation, { at });
|
|
945
|
+
for (const requirementId of next.superseded) amended.superseded.push({ slideKey: target.slideKey, requirementId });
|
|
946
|
+
for (const requirementId of next.added) amended.added.push({ slideKey: target.slideKey, requirementId });
|
|
947
|
+
slides[target.slideKey] = next.entry;
|
|
948
|
+
amended.slides.push(target.slideKey);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (amended.slides.length > 0) {
|
|
952
|
+
writeSlidePreservation(workspaceDir, { version: SLIDE_PRESERVATION_VERSION, slides }, { operation });
|
|
953
|
+
}
|
|
954
|
+
return amended;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// ---------------------------------------------------------------------------
|
|
958
|
+
// checking a requirement set against evidence
|
|
959
|
+
// ---------------------------------------------------------------------------
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* The slide's obligations-facing evidence, read from its SOURCE: which element
|
|
963
|
+
* identities are present, what each connector is bound to, and which asset each
|
|
964
|
+
* image points at.
|
|
965
|
+
*
|
|
966
|
+
* Source evidence proves identity and relationships exactly; it cannot prove
|
|
967
|
+
* that a protected text, fill or geometry is unchanged — that needs the
|
|
968
|
+
* normalized render IR. {@link checkRequirements} therefore reports those
|
|
969
|
+
* scopes as UNVERIFIED rather than passing them, which is the whole reason the
|
|
970
|
+
* two are separate values.
|
|
971
|
+
*/
|
|
972
|
+
export function requirementEvidenceFromSource(jsx, options = {}) {
|
|
973
|
+
const { requirements } = scanSourceRequirements(jsx, options);
|
|
974
|
+
const ids = new Set();
|
|
975
|
+
const relationships = new Map();
|
|
976
|
+
const media = new Map();
|
|
977
|
+
for (const req of requirements) {
|
|
978
|
+
ids.add(req.elementId);
|
|
979
|
+
if (req.relationships?.length) relationships.set(req.elementId, req.relationships);
|
|
980
|
+
if (req.mediaRef) media.set(req.elementId, req.mediaRef);
|
|
981
|
+
}
|
|
982
|
+
return { ids, relationships, media };
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Which of a slide's active requirements the evidence says are met, unmet, or
|
|
987
|
+
* simply not answerable.
|
|
988
|
+
*
|
|
989
|
+
* `operation` is the permission side: an element it was asked to remove is not
|
|
990
|
+
* reported missing, and a property scope it was allowed to change is not
|
|
991
|
+
* reported unverified.
|
|
992
|
+
*/
|
|
993
|
+
export function checkRequirements(entry, evidence, { operation = null } = {}) {
|
|
994
|
+
const violations = [];
|
|
995
|
+
const unverified = [];
|
|
996
|
+
const permitted = operation ? permittedChanges(operation, entry?.slideKey) : new Map();
|
|
997
|
+
// Only these two are decidable from source; everything else needs the render.
|
|
998
|
+
const PROVABLE = new Set(["media"]);
|
|
999
|
+
|
|
1000
|
+
for (const req of entry?.active ?? []) {
|
|
1001
|
+
const allowed = permitted.get(req.elementId) ?? null;
|
|
1002
|
+
if (!evidence.ids.has(req.elementId)) {
|
|
1003
|
+
// An element the operation was asked to remove should have been
|
|
1004
|
+
// superseded by the amendment; if it still stands, it is still owed.
|
|
1005
|
+
violations.push({
|
|
1006
|
+
requirementId: req.requirementId,
|
|
1007
|
+
elementId: req.elementId,
|
|
1008
|
+
code: allowed?.action === "remove" ? "removed_without_amendment" : "missing",
|
|
1009
|
+
detail:
|
|
1010
|
+
allowed?.action === "remove"
|
|
1011
|
+
? `${req.elementId} was removed, but its requirement was never superseded by this operation`
|
|
1012
|
+
: `${req.elementId} is required on this slide and is not in the evidence`,
|
|
1013
|
+
});
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
for (const rel of req.relationships ?? []) {
|
|
1017
|
+
// A package part is evidence about the .pptx, not about the source.
|
|
1018
|
+
if (rel.role !== "start" && rel.role !== "end") continue;
|
|
1019
|
+
const actual = (evidence.relationships.get(req.elementId) ?? []).find((r) => r.role === rel.role);
|
|
1020
|
+
if (!actual) {
|
|
1021
|
+
violations.push({
|
|
1022
|
+
requirementId: req.requirementId,
|
|
1023
|
+
elementId: req.elementId,
|
|
1024
|
+
code: "relationship_missing",
|
|
1025
|
+
detail: `${req.elementId} no longer records its ${rel.role} endpoint`,
|
|
1026
|
+
});
|
|
1027
|
+
} else if (actual.target !== rel.target) {
|
|
1028
|
+
violations.push({
|
|
1029
|
+
requirementId: req.requirementId,
|
|
1030
|
+
elementId: req.elementId,
|
|
1031
|
+
code: "relationship_changed",
|
|
1032
|
+
detail: `${req.elementId}'s ${rel.role} was ${rel.target}, now ${actual.target}`,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
if (req.mediaRef && !allowed?.properties.has("media")) {
|
|
1037
|
+
const actual = evidence.media.get(req.elementId) ?? null;
|
|
1038
|
+
if (actual !== req.mediaRef) {
|
|
1039
|
+
violations.push({
|
|
1040
|
+
requirementId: req.requirementId,
|
|
1041
|
+
elementId: req.elementId,
|
|
1042
|
+
code: "media_changed",
|
|
1043
|
+
detail: `${req.elementId} pointed at ${req.mediaRef}, now ${actual ?? "nothing"}`,
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
const open = (req.properties ?? []).filter(
|
|
1048
|
+
(scope) => !PROVABLE.has(scope) && !allowed?.properties.has(scope),
|
|
1049
|
+
);
|
|
1050
|
+
const mediaUnprovable = (req.properties ?? []).includes("media") && !req.mediaRef && !allowed?.properties.has("media");
|
|
1051
|
+
if (mediaUnprovable) open.push("media");
|
|
1052
|
+
if (req.kind === "native-object" || req.kind === "image-use") open.push(req.kind);
|
|
1053
|
+
if (open.length > 0) {
|
|
1054
|
+
unverified.push({ requirementId: req.requirementId, elementId: req.elementId, properties: [...new Set(open)] });
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
return { ok: violations.length === 0, violations, unverified };
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* The verdict a check earns: a violation is `failed`, an unanswerable
|
|
1062
|
+
* obligation is `unknown`, and only a check that answered everything is
|
|
1063
|
+
* `passed`. Missing evidence never becomes a pass.
|
|
1064
|
+
*/
|
|
1065
|
+
export function evidenceStatus(result) {
|
|
1066
|
+
if (!result || result.violations.length > 0) return "failed";
|
|
1067
|
+
return result.unverified.length > 0 ? "unknown" : "passed";
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// ---------------------------------------------------------------------------
|
|
1071
|
+
// redesign — a replacement, not an addition
|
|
1072
|
+
// ---------------------------------------------------------------------------
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Whether an explicit redesign actually REPLACED what it was pointed at.
|
|
1076
|
+
*
|
|
1077
|
+
* A redesign is authorized by the user's request, so nothing here asks again
|
|
1078
|
+
* (guide section 1: "A user who already asked for redesign has authorized that
|
|
1079
|
+
* transition; no redundant approval prompt"). What it does check is the one
|
|
1080
|
+
* thing a restyle gets wrong in practice — E18 — which is producing a second
|
|
1081
|
+
* slide next to the original instead of replacing it, and then carrying neither
|
|
1082
|
+
* the original's required content nor its native objects across.
|
|
1083
|
+
*
|
|
1084
|
+
* Four questions, each answerable from what is already on disk:
|
|
1085
|
+
*
|
|
1086
|
+
* • does every target still exist under its OWN slideKey? A redesign that
|
|
1087
|
+
* minted a new identity has lost the thing it was asked to change.
|
|
1088
|
+
* • did a slide appear that nobody has a scope for? The operation records the
|
|
1089
|
+
* deck's slide identities as they stood when it was prepared; a slide
|
|
1090
|
+
* outside that set, and outside any `create`, is the duplicate E18 is
|
|
1091
|
+
* about. A slide merely LACKING a preservation record proves nothing — a
|
|
1092
|
+
* legacy or partially migrated deck is full of those — so it is never the
|
|
1093
|
+
* test, and an operation that recorded no such set reports the question as
|
|
1094
|
+
* unanswerable rather than answering it.
|
|
1095
|
+
* • did the required content survive? Delegated to {@link checkRequirements},
|
|
1096
|
+
* so "carried forward" means the same thing here as everywhere else.
|
|
1097
|
+
* • are the native objects still backed? A native's obligation is its source
|
|
1098
|
+
* package dependency closure — which survives a change of placement, and
|
|
1099
|
+
* does not survive the package being dropped.
|
|
1100
|
+
*
|
|
1101
|
+
* Reports; never writes. The caller decides whether a violation blocks —
|
|
1102
|
+
* `unverified` names what could not be decided and is never a violation.
|
|
1103
|
+
*/
|
|
1104
|
+
export function checkRedesignTransition(workspaceDir, operation) {
|
|
1105
|
+
const violations = [];
|
|
1106
|
+
const unverified = [];
|
|
1107
|
+
const { slides } = readSlidePreservation(workspaceDir);
|
|
1108
|
+
const targets = resolveSlideTargets(workspaceDir);
|
|
1109
|
+
const byKey = new Map(targets.map((t) => [t.slideKey, t]));
|
|
1110
|
+
|
|
1111
|
+
for (const target of operation?.targets ?? []) {
|
|
1112
|
+
const current = byKey.get(target.slideKey);
|
|
1113
|
+
if (!current) {
|
|
1114
|
+
violations.push({
|
|
1115
|
+
code: "target_missing",
|
|
1116
|
+
slideKey: target.slideKey,
|
|
1117
|
+
detail:
|
|
1118
|
+
`${target.jsxPath} was redesigned away rather than redesigned: no slide in this deck carries ` +
|
|
1119
|
+
`${target.slideKey} any more. A redesign replaces the slide it was pointed at, under its own identity.`,
|
|
1120
|
+
});
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
const entry = slides[target.slideKey];
|
|
1124
|
+
if (!entry) {
|
|
1125
|
+
violations.push({
|
|
1126
|
+
code: "no_preservation_record",
|
|
1127
|
+
slideKey: target.slideKey,
|
|
1128
|
+
detail: `${current.jsxPath} has no preservation record, so nothing can be said to have carried forward`,
|
|
1129
|
+
});
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
let jsx = "";
|
|
1134
|
+
try {
|
|
1135
|
+
jsx = fs.readFileSync(path.join(workspaceDir, current.jsxPath), "utf8");
|
|
1136
|
+
} catch {
|
|
1137
|
+
/* read failure surfaces below as every requirement missing */
|
|
1138
|
+
}
|
|
1139
|
+
const stem = current.file.replace(/\.(jsx|pptx)$/i, "");
|
|
1140
|
+
const packagePart = fs.existsSync(path.join(workspaceDir, "conversions", `${stem}.slide.pptx`))
|
|
1141
|
+
? `conversions/${stem}.slide.pptx`
|
|
1142
|
+
: null;
|
|
1143
|
+
const result = checkRequirements(entry, requirementEvidenceFromSource(jsx, { packagePart, origin: entry.origin }), {
|
|
1144
|
+
operation,
|
|
1145
|
+
});
|
|
1146
|
+
for (const v of result.violations) {
|
|
1147
|
+
violations.push({ code: `carry_forward_${v.code}`, slideKey: target.slideKey, requirementId: v.requirementId, detail: v.detail });
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// A native object's placement may change; its package may not go missing.
|
|
1151
|
+
for (const req of entry.active) {
|
|
1152
|
+
if (req.kind !== "native-object") continue;
|
|
1153
|
+
for (const rel of req.relationships ?? []) {
|
|
1154
|
+
if (rel.role !== "package-part") continue;
|
|
1155
|
+
if (fs.existsSync(path.join(workspaceDir, rel.target))) continue;
|
|
1156
|
+
violations.push({
|
|
1157
|
+
code: "native_package_missing",
|
|
1158
|
+
slideKey: target.slideKey,
|
|
1159
|
+
requirementId: req.requirementId,
|
|
1160
|
+
detail:
|
|
1161
|
+
`${req.elementId} is a preserved native object and ${rel.target} is gone; ` +
|
|
1162
|
+
`its dependency closure has to survive a redesign, wherever the object ends up on the slide`,
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// THE DUPLICATE. The question is "did this slide appear since the scope
|
|
1169
|
+
// opened?" — never "does this slide have a preservation record?". A deck can
|
|
1170
|
+
// legitimately hold slides with no record at all (a legacy deck, a partial
|
|
1171
|
+
// migration, an adopt from a file an older CLI baked, where
|
|
1172
|
+
// `restorePreservationFromTags` only restores what carried a payload), and
|
|
1173
|
+
// convicting those would report an innocent slide as E18. Absence of a record
|
|
1174
|
+
// is not evidence either way.
|
|
1175
|
+
//
|
|
1176
|
+
// The evidence is `preparedSlideKeys`: the deck's slide identities as they
|
|
1177
|
+
// stood when the operation was recorded. A slide in that set was there
|
|
1178
|
+
// before, whatever its record says; one outside it appeared afterwards and
|
|
1179
|
+
// belongs to a `create` operation or to nothing.
|
|
1180
|
+
const prepared = Array.isArray(operation?.preparedSlideKeys)
|
|
1181
|
+
? new Set(operation.preparedSlideKeys.filter(isDurableId))
|
|
1182
|
+
: null;
|
|
1183
|
+
if (!prepared) {
|
|
1184
|
+
// An operation recorded without that set — another client's, or one built
|
|
1185
|
+
// by hand — cannot answer the question. Say so; do not guess in either
|
|
1186
|
+
// direction.
|
|
1187
|
+
unverified.push({
|
|
1188
|
+
code: "insertion_unverifiable",
|
|
1189
|
+
detail:
|
|
1190
|
+
`operation ${operation?.operationId ?? "(unnamed)"} did not record the deck's slides as they stood when ` +
|
|
1191
|
+
`it was prepared, so a slide added since cannot be told from one that was always there`,
|
|
1192
|
+
});
|
|
1193
|
+
} else {
|
|
1194
|
+
const created = new Set(
|
|
1195
|
+
openOperations(workspaceDir)
|
|
1196
|
+
.filter((o) => o.kind === "create")
|
|
1197
|
+
.flatMap((o) => operationSlideKeys(o)),
|
|
1198
|
+
);
|
|
1199
|
+
for (const target of targets) {
|
|
1200
|
+
if (prepared.has(target.slideKey) || created.has(target.slideKey)) continue;
|
|
1201
|
+
violations.push({
|
|
1202
|
+
code: "duplicate_insertion",
|
|
1203
|
+
slideKey: target.slideKey,
|
|
1204
|
+
detail:
|
|
1205
|
+
`${target.jsxPath} appeared after this redesign was scoped and belongs to no operation. A redesign ` +
|
|
1206
|
+
`replaces its target in place; a genuinely new slide is its own \`create\`.`,
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
return { ok: violations.length === 0, violations, unverified };
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** The open redesign operations governing this deck. */
|
|
1215
|
+
export function openRedesigns(workspaceDir) {
|
|
1216
|
+
return openOperations(workspaceDir).filter((o) => o.kind === "redesign");
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Why a FINAL replacement cannot go ahead for these slides yet: no record at
|
|
1221
|
+
* all, or a record whose evidence is anything but `passed`. Reading and
|
|
1222
|
+
* drafting stay open — the caller reports the limitation; only delivery refuses.
|
|
1223
|
+
*/
|
|
1224
|
+
export function finalEvidenceBlockers(workspaceDir, slideKeys) {
|
|
1225
|
+
const { slides } = readSlidePreservation(workspaceDir);
|
|
1226
|
+
const blockers = [];
|
|
1227
|
+
for (const slideKey of slideKeys ?? []) {
|
|
1228
|
+
const entry = slides[slideKey];
|
|
1229
|
+
if (!entry) {
|
|
1230
|
+
blockers.push({ slideKey, code: "no_record", reason: "no preservation record; run an edit scope to capture one" });
|
|
1231
|
+
continue;
|
|
1232
|
+
}
|
|
1233
|
+
if (entry.evidence !== "passed") {
|
|
1234
|
+
blockers.push({
|
|
1235
|
+
slideKey,
|
|
1236
|
+
code: `evidence_${entry.evidence}`,
|
|
1237
|
+
reason:
|
|
1238
|
+
`preservation evidence is '${entry.evidence}'` +
|
|
1239
|
+
(entry.limitations?.length ? ` — ${entry.limitations.join("; ")}` : ""),
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return blockers;
|
|
1244
|
+
}
|