slidemind 0.2.3 → 0.3.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.
@@ -0,0 +1,91 @@
1
+ // Overwrite guard: never silently clobber manual PowerPoint edits.
2
+ //
3
+ // `slidemind build-pptx` (and push's fallback save) writes its output file
4
+ // wholesale. If the user opened the last build in desktop PowerPoint and moved
5
+ // a shape, retyped a line, or recoloured a box, that work lives ONLY in that
6
+ // file — the deck's jsx knows nothing about it — and a rebuild would erase it
7
+ // with no warning. This module answers the one question that stands between a
8
+ // rebuild and that loss: has anything in the file changed since we wrote it?
9
+ //
10
+ // The answer needs no server and no diff engine (the CLI ships none, by
11
+ // design — intern-slidemind PLAN_CLI_RECONCILE.md §3): a slidemind-authored
12
+ // slide carries its own drift BASELINE in its tags, the structural projection
13
+ // P(slide) as it stood at bake time. Recomputing P over the file on disk and
14
+ // comparing the two IS the detection. What to do about a difference — fold the
15
+ // text edits back into the jsx, report the rest — is Phase B's server round
16
+ // trip; here an edited slide only means "refuse, and say which slide".
17
+
18
+ import fs from "node:fs";
19
+
20
+ import { slideOrderPaths } from "../pptx/ooxml.mjs";
21
+ import {
22
+ TAG_GUID,
23
+ computeProjection,
24
+ decodeSourceTags,
25
+ readSlideTags,
26
+ } from "../pptx/tags.mjs";
27
+ import { unzip } from "../pptx/zip.mjs";
28
+
29
+ /**
30
+ * Slides of the .pptx at `outPath` that were edited since the CLI last wrote
31
+ * it. `[]` means the file is safe to overwrite — it is missing, it is not a
32
+ * package we can read, or every slide we can vouch for still matches its
33
+ * baseline. Entries are `{ slideIndex, slidePath, guid, baseline, current }`
34
+ * with `slideIndex` 1-based in PLAY order (the number PowerPoint shows the
35
+ * user). The two projections ride along because the server-side reconcile
36
+ * (Phase B) diffs exactly them — computing them twice would mean unzipping and
37
+ * re-parsing the whole package twice.
38
+ */
39
+ export function detectManualEdits(outPath) {
40
+ if (!outPath || !fs.existsSync(outPath)) return [];
41
+
42
+ let files;
43
+ try {
44
+ files = unzip(fs.readFileSync(outPath));
45
+ } catch {
46
+ // Not a readable OPC package — nothing slidemind ever baked is in there to
47
+ // protect, and refusing here would block a build over any stray --out file.
48
+ return [];
49
+ }
50
+
51
+ const edited = [];
52
+ slideOrderPaths(files).forEach((slidePath, i) => {
53
+ const tags = readSlideTags(files, slidePath);
54
+ const decoded = decodeSourceTags(tags);
55
+ // No decodable source (foreign or native-reused slide) or no baked
56
+ // baseline (pre-baseline bake): nothing to compare against. Skipping is
57
+ // the honest answer — an unverifiable slide is not evidence of an edit.
58
+ if (!decoded?.baseline) return;
59
+ // Both sides are produced by the SAME emitter, so key order matches and a
60
+ // string compare is a stable structural compare.
61
+ const current = computeProjection(files, slidePath);
62
+ if (JSON.stringify(current) === JSON.stringify(decoded.baseline)) return;
63
+ edited.push({
64
+ slideIndex: i + 1,
65
+ slidePath,
66
+ guid: tags[TAG_GUID] ?? null,
67
+ baseline: decoded.baseline,
68
+ current,
69
+ });
70
+ });
71
+ return edited;
72
+ }
73
+
74
+ // The refusal a manually-edited output file earns when nothing can be done for
75
+ // it here: the user's PowerPoint edits exist in that file ALONE, so the build
76
+ // stops and names the slides rather than overwriting them. Reachable only for
77
+ // slides the deck cannot address — one carrying no jsx of ours to fold into.
78
+ // Everything else goes through the server-side reconcile (Phase B).
79
+ export function manualEditsError(outFile, edited) {
80
+ const numbers = edited.map((e) => e.slideIndex).join(", ");
81
+ const err = new Error(
82
+ `${outFile} was edited in PowerPoint since the CLI last wrote it — ` +
83
+ `slide ${numbers} (${edited.length} of them) no longer match the source ` +
84
+ `this deck would render.\n` +
85
+ `Rebuilding would discard those edits: re-run with --force to discard ` +
86
+ `these manual edits, or use --out <other file> to keep them.`,
87
+ );
88
+ err.code = "manual_edits";
89
+ err.manualEdits = edited;
90
+ return err;
91
+ }
@@ -0,0 +1,46 @@
1
+ // Deck-local sidecar recording the last ACKNOWLEDGED drift hash per slide file
2
+ // (`{ "01-cover.jsx": "<nowHash>" }`), keyed exactly like the render request's
3
+ // `slides` and `reconcile` maps. File handling mirrors slide-guids.mjs.
4
+ //
5
+ // The ack is what keeps the reconcile loop finite. When the server reports a
6
+ // manual PowerPoint edit it cannot fold on its own (a moved shape), the build
7
+ // refuses and the hash of that slide's CURRENT state is stored here. The user
8
+ // folds the change into the jsx and re-runs: the output file has not changed,
9
+ // so the hash still matches, the server short-circuits its diff, and the build
10
+ // proceeds. Any NEW manual edit changes the hash and re-arms the report.
11
+
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+
15
+ const SIDECAR = ".reconcile-ack.json";
16
+
17
+ /** { [fileName]: nowHash } — `{}` when the deck has no sidecar (or a broken one). */
18
+ export function loadReconcileAcks(deckDir) {
19
+ let parsed;
20
+ try {
21
+ parsed = JSON.parse(fs.readFileSync(path.join(deckDir, SIDECAR), "utf8"));
22
+ } catch {
23
+ return {};
24
+ }
25
+ if (!parsed || typeof parsed !== "object") return {};
26
+ const out = {};
27
+ for (const [file, hash] of Object.entries(parsed)) {
28
+ // basename: the key is a slide FILE NAME, never a path — a crafted one can
29
+ // then never travel out of this map into a write.
30
+ if (typeof hash === "string" && hash) out[path.basename(file)] = hash;
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /** Merge acks into the sidecar (last write wins per file). No-op when empty. */
36
+ export function saveReconcileAcks(deckDir, acks) {
37
+ if (!acks || Object.keys(acks).length === 0) return;
38
+ const merged = loadReconcileAcks(deckDir);
39
+ for (const [file, hash] of Object.entries(acks)) {
40
+ if (typeof hash === "string" && hash) merged[path.basename(file)] = hash;
41
+ }
42
+ fs.writeFileSync(
43
+ path.join(deckDir, SIDECAR),
44
+ `${JSON.stringify(merged, null, 2)}\n`,
45
+ );
46
+ }
@@ -0,0 +1,306 @@
1
+ // Manual-edit reconcile, CLI half (intern-slidemind PLAN_CLI_RECONCILE.md,
2
+ // Phase B2). Two functions around ONE server round trip:
3
+ //
4
+ // collectReconcileInputs — what the render request carries: for every slide
5
+ // the user edited in PowerPoint since the last
6
+ // build, its baked baseline projection and its
7
+ // current one, keyed by jsx file name. Reads the
8
+ // deck's previous OUTPUT FILE (`build-pptx`).
9
+ // collectLiveReconcileInputs — the same field, read from the LIVE deck via
10
+ // the pane's export op. What `push --replace` must
11
+ // use: it wipes what is on screen, not a file.
12
+ // applyReconcileResponse — what comes back: text edits already folded into
13
+ // the jsx (write them down), a hash to acknowledge,
14
+ // and the edits that need human judgment (refuse).
15
+ //
16
+ // The CLI ships NO diff and NO fold logic — same rule as lint and the exporter
17
+ // ("served on the wire", plan §3). It supplies projections and applies results;
18
+ // the server decides what changed and what that means.
19
+
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+
23
+ import { computeProjection, decodeSourceTags } from "../pptx/tags.mjs";
24
+ import { unzip } from "../pptx/zip.mjs";
25
+ import { loadConversions } from "./conversions.mjs";
26
+ import { detectManualEdits, manualEditsError } from "./overwrite-guard.mjs";
27
+ import { loadReconcileAcks, saveReconcileAcks } from "./reconcile-ack.mjs";
28
+ import { reconcileSlideGuids } from "./slide-guids.mjs";
29
+
30
+ /**
31
+ * The render request's `reconcile` field for `deckDir`'s previous output at
32
+ * `outFile`, or null when there is nothing to reconcile. Throws the Phase A
33
+ * `manual_edits` refusal for an edited slide this deck cannot address.
34
+ */
35
+ export function collectReconcileInputs({ deckDir, outFile, force = false }) {
36
+ // `--force` is the explicit "discard my edits" — no scan, no field, exactly
37
+ // the blind build the CLI did before any of this existed.
38
+ if (force) return null;
39
+ const edits = detectManualEdits(outFile);
40
+ if (edits.length === 0) return null;
41
+
42
+ // GUID → slide file name. reconcileSlideGuids, not loadSlideGuids: a reorder
43
+ // renames every file, so the raw sidecar can still be keyed by the old names
44
+ // — and the reconcile map must key by the names `slides` uses. It is
45
+ // idempotent, so the render's own call right after finds nothing to do.
46
+ const fileByGuid = new Map();
47
+ for (const [slidePath, guid] of Object.entries(reconcileSlideGuids(deckDir))) {
48
+ fileByGuid.set(guid, path.basename(slidePath));
49
+ }
50
+ // B3 exclusion — CONVERTED slides (`slidemind adopt`). Their drift protection
51
+ // is the conversion-gate machinery, which already holds a baseline of the
52
+ // ORIGINAL slide; driving a second reconcile from the render bake's baseline
53
+ // would have two mechanisms arguing over one slide. Matched by GUID, not file
54
+ // name, so a reorder cannot smuggle one back in before the records rehome.
55
+ const convertedGuids = new Set(
56
+ Object.values(loadConversions(deckDir, { includeSource: false }))
57
+ .map((record) => record.guid)
58
+ .filter(Boolean),
59
+ );
60
+
61
+ const acks = loadReconcileAcks(deckDir);
62
+ const reconcile = {};
63
+ const refusals = [];
64
+ for (const edit of edits) {
65
+ if (edit.guid && convertedGuids.has(edit.guid)) continue;
66
+ const fileName = edit.guid ? fileByGuid.get(edit.guid) : null;
67
+ // B3 exclusion — a slide with no jsx of ours behind it: a native reused
68
+ // .pptx, or a slide pushed by another deck. There is nothing to fold an
69
+ // edit INTO, so Phase A's refusal still governs it.
70
+ if (!fileName) {
71
+ refusals.push(edit);
72
+ continue;
73
+ }
74
+ reconcile[fileName] = {
75
+ baseline: edit.baseline,
76
+ current: edit.current,
77
+ ...(acks[fileName] ? { ack: acks[fileName] } : {}),
78
+ };
79
+ }
80
+ if (refusals.length > 0) throw manualEditsError(outFile, refusals);
81
+ return Object.keys(reconcile).length > 0 ? reconcile : null;
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Live deck (push --replace) — plan Phase C2
86
+ // ---------------------------------------------------------------------------
87
+
88
+ // One export round trip, with the two failure shapes that mean "this live deck
89
+ // cannot be inspected at all" mapped to a fail-closed refusal: a 404 (backend
90
+ // predates the route) and UNSUPPORTED_API (the pane's Office build cannot
91
+ // export). A --replace push must never proceed on faith — it deletes slides.
92
+ // Everything else (unknown presentation, wrong presentation, transport) stays
93
+ // its own error: the push aborts and nothing on screen is touched either way.
94
+ async function exportRound(exportSlides, guids) {
95
+ try {
96
+ return await exportSlides(guids);
97
+ } catch (err) {
98
+ const reason =
99
+ err?.status === 404 && err?.code !== "unknown_presentation"
100
+ ? "this add-in backend does not support export-slides"
101
+ : err?.code === "UNSUPPORTED_API"
102
+ ? "this PowerPoint build cannot export slides"
103
+ : null;
104
+ if (!reason) throw err;
105
+ const failClosed = new Error(
106
+ `the live deck could not be inspected for manual PowerPoint edits ` +
107
+ `(${reason}) — update the slidemind add-in, or re-run with --force to ` +
108
+ `push anyway and discard any manual edits`,
109
+ );
110
+ failClosed.code = "reconcile_unsupported";
111
+ throw failClosed;
112
+ }
113
+ }
114
+
115
+ // A whole-deck replace deletes every live slide it does not re-push. Slides the
116
+ // deck does not own — added by hand, by another tool, or a manual DUPLICATE of
117
+ // one of ours (same GUID twice: the copy lands here) — would go with them.
118
+ // v1 policy is to refuse and let the user decide (plan C2 step 4).
119
+ function liveDeckExtraSlidesError(unmatched) {
120
+ const numbers = unmatched
121
+ .map((s) => (Number.isFinite(s?.index) ? s.index + 1 : "?"))
122
+ .join(", ");
123
+ const err = new Error(
124
+ `the live deck contains ${unmatched.length} slide(s) not in this deck ` +
125
+ `(added manually or by another tool) — pass --force to discard them.\n` +
126
+ `Slide ${numbers} (live deck numbering) would be deleted by a whole-deck ` +
127
+ `replace; push without --replace to insert instead.`,
128
+ );
129
+ err.code = "live_deck_extra_slides";
130
+ err.unmatched = unmatched;
131
+ return err;
132
+ }
133
+
134
+ /**
135
+ * The render request's `reconcile` field built from the LIVE presentation:
136
+ * export the deck's slides through the pane, decode each one's baked baseline
137
+ * from its TAGS and project its exported BYTES for the current state.
138
+ * `guids` selects a subset (a `--slides` push); null means the whole deck.
139
+ * `exportSlides(guids)` is the pane ack producer — injected so this stays free
140
+ * of the bridge client (and testable without a relay).
141
+ */
142
+ export async function collectLiveReconcileInputs({
143
+ deckDir,
144
+ exportSlides,
145
+ guids = null,
146
+ }) {
147
+ const fileByGuid = new Map();
148
+ for (const [slidePath, guid] of Object.entries(reconcileSlideGuids(deckDir))) {
149
+ fileByGuid.set(guid, path.basename(slidePath));
150
+ }
151
+ const deckGuids = [...fileByGuid.keys()];
152
+ const requested = (guids ?? deckGuids).filter((guid) => fileByGuid.has(guid));
153
+ if (requested.length === 0) return null;
154
+
155
+ // `truncated` names slides that matched but did not fit the relay frame —
156
+ // re-request them until every one has landed. Dropping them would silently
157
+ // leave those slides unprotected, which is the whole failure mode here.
158
+ const exported = {};
159
+ let deck = null;
160
+ let pending = requested;
161
+ while (pending.length > 0) {
162
+ const before = Object.keys(exported).length;
163
+ const ack = await exportRound(exportSlides, pending);
164
+ deck ??= ack?.deck ?? null;
165
+ for (const [guid, slide] of Object.entries(ack?.slides ?? {})) {
166
+ if (slide?.base64 && slide?.tags) exported[guid] = slide;
167
+ }
168
+ const next = (Array.isArray(ack?.truncated) ? ack.truncated : []).filter(
169
+ (guid) => !exported[guid] && requested.includes(guid),
170
+ );
171
+ if (next.length > 0 && Object.keys(exported).length === before) {
172
+ const err = new Error(
173
+ `the pane could not export ${next.length} live slide(s) — they are too ` +
174
+ `large for the relay frame, so their manual edits cannot be checked. ` +
175
+ `Re-run with --force to push anyway and discard any manual edits.`,
176
+ );
177
+ err.code = "export_incomplete";
178
+ throw err;
179
+ }
180
+ pending = next;
181
+ }
182
+
183
+ // Only meaningful for a WHOLE-deck request: a subset replace deletes nothing
184
+ // outside the subset, so slides beyond it are none of its business.
185
+ const unmatched =
186
+ requested.length === deckGuids.length ? (deck?.unmatched ?? []) : [];
187
+ if (unmatched.length > 0) throw liveDeckExtraSlidesError(unmatched);
188
+
189
+ const acks = loadReconcileAcks(deckDir);
190
+ const reconcile = {};
191
+ for (const [guid, slide] of Object.entries(exported)) {
192
+ const fileName = fileByGuid.get(guid);
193
+ if (!fileName) continue;
194
+ // Same honesty rule as the file guard: a slide whose tags carry no baseline
195
+ // (pushed before baselines existed) cannot be compared, so it is skipped —
196
+ // an unverifiable slide is not evidence of an edit.
197
+ const baseline = decodeSourceTags(slide.tags)?.baseline ?? null;
198
+ if (!baseline) continue;
199
+ // The BYTES carry the current state; the TAGS carry the baseline. Never the
200
+ // other way round — exportAsBase64 strips tags (office-js #6104).
201
+ const current = computeProjection(unzip(Buffer.from(slide.base64, "base64")));
202
+ if (JSON.stringify(current) === JSON.stringify(baseline)) continue;
203
+ reconcile[fileName] = {
204
+ baseline,
205
+ current,
206
+ ...(acks[fileName] ? { ack: acks[fileName] } : {}),
207
+ };
208
+ }
209
+ return Object.keys(reconcile).length > 0 ? reconcile : null;
210
+ }
211
+
212
+ /**
213
+ * The reconcile payload carried by a REFUSED render: the backend answers HTTP
214
+ * 409 when EVERY jsx slide of the request blocked (the common single-slide
215
+ * deck), because there is then no deck to return — the echo travels on the
216
+ * error body instead of the result. Both surfaces land here: the sync route's
217
+ * 409 (bridgeFetch attaches the parsed body) and a render job that failed with
218
+ * the same payload (jobFailureError rebuilds it from the job's error extra).
219
+ * Null for every other error, which stays a plain transport failure.
220
+ */
221
+ export function reconcileErrorPayload(err) {
222
+ const echo = err?.body?.reconcile;
223
+ // A NON-EMPTY echo is the whole signal. An error carrying none (or an empty
224
+ // one) is a plain failure and must surface as itself — mapping it onto the
225
+ // fail-closed "backend does not support reconcile" message would blame the
226
+ // wrong thing for whatever actually went wrong.
227
+ if (!echo || typeof echo !== "object" || Object.keys(echo).length === 0) {
228
+ return null;
229
+ }
230
+ return err.body;
231
+ }
232
+
233
+ /**
234
+ * Apply the render response's `reconcile` echo: fold text edits into the deck's
235
+ * jsx, store the acknowledged hashes, and refuse the build when a slide's edit
236
+ * needs human judgment. Returns normally only when the caller may write/push.
237
+ * `sent` is what {@link collectReconcileInputs} produced (null ⇒ nothing to do).
238
+ */
239
+ export function applyReconcileResponse({ deckDir, sent, result }) {
240
+ if (!sent) return;
241
+ const echo = result?.reconcile;
242
+ const sentFiles = Object.keys(sent);
243
+ // FAIL CLOSED (plan §3). The echo IS the capability signal: a backend that
244
+ // understood the field answers for every key. Anything less and we do not
245
+ // know whether the user's edits were considered — so nothing gets written.
246
+ const answered = echo && typeof echo === "object" ? echo : {};
247
+ if (sentFiles.some((file) => !answered[file])) {
248
+ const err = new Error(
249
+ "backend does not support manual-edit reconcile — update the add-in " +
250
+ "backend, or re-run with --force to discard the manual edits",
251
+ );
252
+ err.code = "reconcile_unsupported";
253
+ throw err;
254
+ }
255
+
256
+ const acks = {};
257
+ const reports = [];
258
+ // Only the files we ASKED about: the response may not steer a write to a path
259
+ // of its choosing.
260
+ for (const fileName of sentFiles) {
261
+ const entry = answered[fileName];
262
+ const safeName = path.basename(fileName);
263
+ if (typeof entry.nowHash === "string" && entry.nowHash) {
264
+ acks[safeName] = entry.nowHash;
265
+ }
266
+ if (typeof entry.foldedJsx === "string") {
267
+ // The fold is now the source of truth — the render already used it. It
268
+ // is written for a BLOCKING slide too (the backend echoes it either
269
+ // way): the text half of the edit is then already safe on disk while the
270
+ // agent folds the geometry half by hand.
271
+ fs.writeFileSync(
272
+ path.join(deckDir, "slides", safeName),
273
+ entry.foldedJsx,
274
+ "utf8",
275
+ );
276
+ process.stderr.write(
277
+ `note: folded ${entry.appliedTextEdits ?? 0} manual text edit(s) from ` +
278
+ `PowerPoint into slides/${safeName}\n`,
279
+ );
280
+ }
281
+ if (entry.blocking) {
282
+ // The response also lists these in `skippedSlides` as
283
+ // `{ stem, reason: "reconcile" }`. Nothing to do with that: this throws,
284
+ // so the caller never gets far enough to print the skip lines — the
285
+ // report says it better and says it once.
286
+ reports.push(entry.report ?? `${safeName}: manual edit needs folding`);
287
+ }
288
+ }
289
+ // BEFORE the refusal below: the ack is what lets the re-run through once the
290
+ // user has folded the change into the jsx. Without it the same report fires
291
+ // forever (the output file, and therefore its hash, never changed).
292
+ saveReconcileAcks(deckDir, acks);
293
+
294
+ if (reports.length > 0) {
295
+ const err = new Error(
296
+ `${reports.length} slide(s) were edited in PowerPoint in ways that cannot ` +
297
+ `be folded automatically — nothing was written or pushed.\n\n` +
298
+ `${reports.join("\n\n")}\n\n` +
299
+ `Fold each change into the slide's jsx as layout intent (never literal ` +
300
+ `offsets) and re-run, or re-run with --force to discard these manual edits.`,
301
+ );
302
+ err.code = "reconcile_required";
303
+ err.reports = reports;
304
+ throw err;
305
+ }
306
+ }
@@ -0,0 +1,143 @@
1
+ // Deck-local sidecar persisting each slide's durable GUID (`sm-<uuid>`) for
2
+ // the deck-embedded source tags, keyed by workspace jsx path
3
+ // ("slides/01-cover.jsx"). Each entry also records the sha256 of the jsx it
4
+ // was last saved for: the NN- filename prefix IS the play order, so reordering
5
+ // a deck renames every file — the content hash is what lets a renamed file
6
+ // keep its identity (reconcileSlideGuids below).
7
+ //
8
+ // Keeps a slide's identity stable across re-renders: the compose path sends
9
+ // these to the server render (which mints for new slides and reports back),
10
+ // the fill-template path bakes them locally.
11
+
12
+ import { createHash } from "node:crypto";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+
16
+ const SIDECAR = ".slide-guids.json";
17
+
18
+ const sha256Hex = (data) => createHash("sha256").update(data).digest("hex");
19
+
20
+ /** Sidecar entry for a slide: its GUID plus the hash of the jsx it maps to. */
21
+ export function slideGuidEntry(guid, jsx) {
22
+ return { guid, sha256: sha256Hex(Buffer.from(String(jsx), "utf8")) };
23
+ }
24
+
25
+ // Raw sidecar, normalized to { [path]: { guid, sha256|null } }. A v1 sidecar
26
+ // stored bare guid strings — read them (identity must survive the format
27
+ // change) but never write them back.
28
+ function readSidecar(deckDir) {
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(fs.readFileSync(path.join(deckDir, SIDECAR), "utf8"));
32
+ } catch {
33
+ return {};
34
+ }
35
+ if (!parsed || typeof parsed !== "object") return {};
36
+ const out = {};
37
+ for (const [p, v] of Object.entries(parsed)) {
38
+ if (typeof v === "string") out[p] = { guid: v, sha256: null };
39
+ else if (v && typeof v.guid === "string") {
40
+ out[p] = { guid: v.guid, sha256: typeof v.sha256 === "string" ? v.sha256 : null };
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ function writeSidecar(deckDir, entries) {
47
+ fs.writeFileSync(
48
+ path.join(deckDir, SIDECAR),
49
+ `${JSON.stringify(entries, null, 2)}\n`,
50
+ );
51
+ }
52
+
53
+ /** { [path]: guid } — the wire shape the render routes take. */
54
+ export function loadSlideGuids(deckDir) {
55
+ const out = {};
56
+ for (const [p, entry] of Object.entries(readSidecar(deckDir))) {
57
+ out[p] = entry.guid;
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * Merge entries into the sidecar. Values are either a bare guid string
64
+ * (content hash unknown — reconcile refreshes it on the next run) or a
65
+ * {@link slideGuidEntry}.
66
+ */
67
+ export function saveSlideGuids(deckDir, guids) {
68
+ if (!guids || Object.keys(guids).length === 0) return;
69
+ const merged = readSidecar(deckDir);
70
+ for (const [p, v] of Object.entries(guids)) {
71
+ if (typeof v === "string") {
72
+ merged[p] = { guid: v, sha256: merged[p]?.guid === v ? (merged[p].sha256 ?? null) : null };
73
+ } else if (v && typeof v.guid === "string") {
74
+ merged[p] = { guid: v.guid, sha256: v.sha256 ?? null };
75
+ }
76
+ }
77
+ writeSidecar(deckDir, merged);
78
+ }
79
+
80
+ /**
81
+ * Reconcile the sidecar with the slide files actually on disk and return the
82
+ * resulting { [path]: guid } map. Run this before every bake/render — it is
83
+ * what makes identity survive the single most common deck edit (reordering,
84
+ * which renames every file):
85
+ *
86
+ * • a file with a sidecar entry keeps its GUID (and gets its hash refreshed);
87
+ * • a file WITHOUT an entry inherits the GUID of a vanished entry whose
88
+ * recorded hash matches its content — a pure rename;
89
+ * • entries whose file is gone and whose content matched nothing are DROPPED,
90
+ * so a later file reusing the name can never resurrect a dead slide's
91
+ * identity.
92
+ *
93
+ * Files still unmapped after this are genuinely new; the caller (or the server
94
+ * render) mints for them.
95
+ */
96
+ export function reconcileSlideGuids(deckDir) {
97
+ const sidecar = readSidecar(deckDir);
98
+ const slidesDir = path.join(deckDir, "slides");
99
+ const present = new Map(); // path -> sha256 of current content
100
+ let files = [];
101
+ try {
102
+ files = fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx"));
103
+ } catch {
104
+ files = [];
105
+ }
106
+ for (const f of files.sort()) {
107
+ try {
108
+ present.set(`slides/${f}`, sha256Hex(fs.readFileSync(path.join(slidesDir, f))));
109
+ } catch {
110
+ /* unreadable slide — it simply keeps no identity this round */
111
+ }
112
+ }
113
+
114
+ const next = {};
115
+ const orphansBySha = new Map(); // sha256 -> [guid, …] whose file is gone
116
+ for (const [p, entry] of Object.entries(sidecar)) {
117
+ if (present.has(p)) {
118
+ next[p] = { guid: entry.guid, sha256: present.get(p) };
119
+ } else if (entry.sha256) {
120
+ const list = orphansBySha.get(entry.sha256) ?? [];
121
+ list.push(entry.guid);
122
+ orphansBySha.set(entry.sha256, list);
123
+ }
124
+ }
125
+ const claimed = new Set(Object.values(next).map((e) => e.guid));
126
+ for (const [p, sha] of present) {
127
+ if (next[p]) continue;
128
+ const guids = orphansBySha.get(sha);
129
+ while (guids?.length) {
130
+ const guid = guids.shift();
131
+ if (!claimed.has(guid)) {
132
+ next[p] = { guid, sha256: sha };
133
+ claimed.add(guid);
134
+ break;
135
+ }
136
+ }
137
+ }
138
+
139
+ if (JSON.stringify(next) !== JSON.stringify(sidecar)) writeSidecar(deckDir, next);
140
+ const out = {};
141
+ for (const [p, entry] of Object.entries(next)) out[p] = entry.guid;
142
+ return out;
143
+ }