slidemind 0.2.4 → 0.3.1

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,407 @@
1
+ // `slidemind adopt <file.pptx>` — reconstruct a local deck workspace from a
2
+ // presentation's deck-embedded source tags (intern-slidemind
3
+ // docs/deck-embedded-source-spec.md §6.2) and, where the add-in offers it,
4
+ // from a deterministic conversion of the slides that carry no source at all.
5
+ //
6
+ // Three outcomes per slide, in order:
7
+ // 1. decodable SLIDEMIND source → slides/NN-<name>.jsx, byte-identical to
8
+ // what was authored, plus its embedded assets;
9
+ // 2. deterministically CONVERTED → slides/NN-<name>.jsx in the converted
10
+ // dialect + assets + a conversions/<stem>.json identity/evidence record
11
+ // (openspec `pptx-to-jsx-deterministic-reverse`, D9 — the parse runs
12
+ // server-side, this repo ships no parser);
13
+ // 3. everything else → native NN-reused-<id>.pptx (opaque,
14
+ // re-render-safe) exactly like engine reuse.
15
+ //
16
+ // The deck binds to the bundle recorded in the tags, and slide GUIDs persist in
17
+ // .slide-guids.json so a later `push`/`build-pptx` keeps every slide's identity.
18
+ //
19
+ // v1 is FILE decks only: converting a LIVE presentation needs a slide export
20
+ // and a tag read over the relay, and neither event exists — that flow belongs
21
+ // to the add-in (design D9).
22
+
23
+ import { createHash } from "node:crypto";
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+
27
+ import { unzip } from "../pptx/zip.mjs";
28
+ import { slideOrderPaths } from "../pptx/ooxml.mjs";
29
+ import { decodeSourceTags, mintSlideGuid, readSlideTags } from "../pptx/tags.mjs";
30
+ import { foldPickedSlides } from "../pptx/slide-swap.mjs";
31
+ import { readDeckBundleRef, writeDeckBundleRef } from "../bridge/bundle.mjs";
32
+ import { loadSlideGuids, saveSlideGuids, slideGuidEntry } from "./slide-guids.mjs";
33
+ import { writeConversionRecord } from "./conversions.mjs";
34
+
35
+ export const LIVE_DECK_UNSUPPORTED =
36
+ "conversion of live presentations requires the add-in path in v1 — " +
37
+ "`slidemind adopt` takes a .pptx FILE. Save the open deck to disk and adopt " +
38
+ "that file, or make the change in PowerPoint with the slidemind pane open.";
39
+
40
+ function pad2(n) {
41
+ return String(n).padStart(2, "0");
42
+ }
43
+
44
+ // Slide names and asset names arrive from an UNTRUSTED file's tags (anyone can
45
+ // mail a crafted .pptx) and both end up in filesystem paths, so neither may
46
+ // carry a directory component or escape the deck dir (spec §10).
47
+ function safeName(raw) {
48
+ return path
49
+ .basename(String(raw ?? "").replace(/\\/g, "/"))
50
+ // Control chars and the reserved filename set — NOT a broad range (a
51
+ // `[ -<]` style range would swallow digits and eat the NN- prefix).
52
+ .replace(/[\u0000-\u001f<>:"|?*/]/g, "-")
53
+ .replace(/^\.+/, "")
54
+ .trim();
55
+ }
56
+
57
+ function stemFor(metaName, index, taken) {
58
+ // meta.name usually already carries its NN- prefix ("03-market-overview");
59
+ // only prefix by play order when it doesn't.
60
+ const name = safeName(metaName) || `slide-${pad2(index + 1)}`;
61
+ const stem = /^\d/.test(name) ? name : `${pad2(index + 1)}-${name}`;
62
+ let candidate = stem;
63
+ let n = 2;
64
+ while (taken.has(candidate)) candidate = `${stem}-${n++}`;
65
+ taken.add(candidate);
66
+ return candidate;
67
+ }
68
+
69
+ // Deck-embedded asset name → deck-local path ("assets/…" stays deck-relative).
70
+ // Returns null for anything that would land outside the deck's assets dir.
71
+ function assetPathFor(deckDir, assetName) {
72
+ const root = path.resolve(deckDir, "assets");
73
+ const rel = String(assetName ?? "")
74
+ .replace(/\\/g, "/")
75
+ .replace(/^assets\//, "");
76
+ const target = path.resolve(root, rel);
77
+ return target.startsWith(root + path.sep) ? target : null;
78
+ }
79
+
80
+ const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
81
+
82
+ // Write one adopted asset, resolving name collisions by CONTENT: two adopts
83
+ // into the same deck may both ship an assets/images/photo.png. Identical bytes
84
+ // reuse the existing file; different bytes land under the first free -N suffix
85
+ // (and the caller rewrites the slide's references), so an adopted slide never
86
+ // silently renders another deck's picture. Returns the deck-relative name
87
+ // actually used, or null when the name escapes the assets dir.
88
+ function placeAsset(deckDir, name, bytes, sha256) {
89
+ for (let n = 1; ; n++) {
90
+ const suffixed = name.replace(/(\.[^./]+)$/, `-${n}$1`);
91
+ const candidate = n === 1 ? name : suffixed !== name ? suffixed : `${name}-${n}`;
92
+ const target = assetPathFor(deckDir, candidate);
93
+ if (!target) return null;
94
+ if (!fs.existsSync(target)) {
95
+ fs.mkdirSync(path.dirname(target), { recursive: true });
96
+ fs.writeFileSync(target, bytes);
97
+ return candidate;
98
+ }
99
+ if (sha256Hex(fs.readFileSync(target)) === sha256) return candidate;
100
+ }
101
+ }
102
+
103
+ // Read a source deck once: bytes, parts, and the decoded embedded source per
104
+ // slide (null = untagged). Shared by the sync adopt core and the async
105
+ // conversion step so a file is never tag-decoded on two different snapshots —
106
+ // `cmdAdopt` reads once and threads the result into both.
107
+ export function readSourceDeck(pptxPath) {
108
+ const buffer = fs.readFileSync(pptxPath);
109
+ const files = unzip(buffer);
110
+ const order = slideOrderPaths(files);
111
+ if (order.length === 0) throw new Error(`${pptxPath}: no slides found`);
112
+ const decodedByIndex = order.map((part) =>
113
+ decodeSourceTags(readSlideTags(files, part)),
114
+ );
115
+ return { buffer, files, order, decodedByIndex };
116
+ }
117
+
118
+ // Route result -> per-slide-number map, flattened into the per-slide shape the
119
+ // rest of this file reads.
120
+ //
121
+ // The server names the slide it converted (`index`, 1-based play order in the
122
+ // uploaded package); an entry without one is DROPPED rather than joined by
123
+ // array position — a positional join would attach one slide's source to another
124
+ // slide's identity (design R11).
125
+ //
126
+ // The wire keeps identity and evidence in TOP-LEVEL maps keyed by the slide's
127
+ // jsx path (`sourceBaselines` / `conversionEvidence`), because that is the key
128
+ // the render route joins them on. `preserved[]` rides inside the baseline. This
129
+ // is the single place that key shape is unfolded; get it wrong and a converted
130
+ // slide silently loses its preserved natives (`conv.preserved` empty => no
131
+ // `conversions/<stem>.slide.pptx` is folded, and the render has nothing to
132
+ // splice back).
133
+ function convertedByNumber(converted) {
134
+ const baselines = converted?.sourceBaselines ?? {};
135
+ const evidence = converted?.conversionEvidence ?? {};
136
+ const map = new Map();
137
+ for (const slide of converted?.slides ?? []) {
138
+ const n = Number(slide?.index);
139
+ if (!Number.isInteger(n) || n < 1 || typeof slide.jsx !== "string") continue;
140
+ if (map.has(n)) continue;
141
+ const baseline = baselines[slide.path] ?? null;
142
+ map.set(n, {
143
+ jsx: slide.jsx,
144
+ // "slides/converted-slide1.jsx" -> "converted-slide1"; stemFor() adds the
145
+ // NN- play-order prefix.
146
+ name: slide.name || path.basename(String(slide.path ?? ""), ".jsx") || null,
147
+ guid: slide.guid ?? null,
148
+ state: slide.state ?? "needsReview",
149
+ part: slide.sourcePart ?? slide.part ?? null,
150
+ sourceBaseline: baseline,
151
+ conversionEvidence: evidence[slide.path] ?? null,
152
+ preserved: Array.isArray(baseline?.preserved) ? baseline.preserved : [],
153
+ warnings: Array.isArray(slide.warnings) ? slide.warnings : [],
154
+ });
155
+ }
156
+ return map;
157
+ }
158
+
159
+ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source = null }) {
160
+ const { buffer, order, decodedByIndex } = source ?? readSourceDeck(pptxPath);
161
+ const convertedSlides = convertedByNumber(converted);
162
+
163
+ const slidesDir = path.join(deckDir, "slides");
164
+ fs.mkdirSync(slidesDir, { recursive: true });
165
+
166
+ const taken = new Set(
167
+ fs.existsSync(slidesDir)
168
+ ? fs.readdirSync(slidesDir).map((f) => f.replace(/\.(jsx|pptx)$/, ""))
169
+ : [],
170
+ );
171
+ const guids = {};
172
+ const adopted = [];
173
+ const convertedFiles = [];
174
+ const native = [];
175
+ let bundle = null;
176
+
177
+ // Fold in ONE pass: foldPickedSlides unzips and prunes the whole source
178
+ // package per call, so folding slide by slide redid that work for the entire
179
+ // deck on every native slide. Two kinds of pick share the pass — a slide that
180
+ // stays native, and a CONVERTED slide whose preserved natives (chart / OLE /
181
+ // SmartArt) need their verbatim source package kept for the render-time
182
+ // splice (design D4/D6).
183
+ const picks = [];
184
+ decodedByIndex.forEach((decoded, index) => {
185
+ if (decoded) return;
186
+ const conv = convertedSlides.get(index + 1);
187
+ if (!conv || conv.preserved?.length > 0) picks.push(index + 1);
188
+ });
189
+ const foldByPick = new Map(
190
+ (picks.length
191
+ ? foldPickedSlides(buffer, picks, { sourceName: "adopted" })
192
+ : []
193
+ ).map((fold) => [fold.selection, fold]),
194
+ );
195
+
196
+ // Spec §6.3: two slides with the same GUID resolve by NEWEST META.pushedAt
197
+ // (an ND-mode edit leaves the untouched original as a frozen archive while
198
+ // its duplicate evolves — identity must follow the evolution). Ties (a
199
+ // manual duplicate carries byte-identical tags) keep the first slide in
200
+ // deck order. Every non-owner gets a fresh GUID so the deck never pushes
201
+ // two slides claiming to be the same slide. Mirrors hydrate-from-deck.ts.
202
+ const ownerIndexByGuid = new Map();
203
+ decodedByIndex.forEach((decoded, index) => {
204
+ if (!decoded) return;
205
+ const pushedAt = decoded.meta.pushedAt ?? "";
206
+ const current = ownerIndexByGuid.get(decoded.guid);
207
+ if (!current || pushedAt > current.pushedAt) {
208
+ ownerIndexByGuid.set(decoded.guid, { index, pushedAt });
209
+ }
210
+ });
211
+ // Seeded from the deck's persisted sidecar: adopting into an EXISTING deck
212
+ // dedupes against the slides already there, or a re-adopt writes a second
213
+ // slide file claiming the first one's identity.
214
+ const seenGuids = new Set(Object.values(loadSlideGuids(deckDir)));
215
+
216
+ // Conversion-route assets (materialized media, preserved-object preview
217
+ // crops) land BEFORE the slide loop so a collision-renamed asset can be
218
+ // rewritten in the converted jsx. Names arrive from an UNTRUSTED package,
219
+ // so they pass the same escape guard as tag-embedded assets.
220
+ const convAssetRenames = new Map();
221
+ for (const [name, b64] of Object.entries(converted?.assets ?? {})) {
222
+ const bytes = Buffer.from(String(b64), "base64");
223
+ const placed = placeAsset(deckDir, name, bytes, sha256Hex(bytes));
224
+ if (placed && placed !== name) convAssetRenames.set(name, placed);
225
+ }
226
+
227
+ decodedByIndex.forEach((decoded, index) => {
228
+ if (decoded) {
229
+ const stem = stemFor(decoded.meta.name, index, taken);
230
+ let jsx = decoded.jsx;
231
+ for (const asset of decoded.assets) {
232
+ const placed = placeAsset(deckDir, asset.name, asset.bytes, asset.sha256);
233
+ if (placed && placed !== asset.name) jsx = jsx.split(asset.name).join(placed);
234
+ }
235
+ fs.writeFileSync(path.join(slidesDir, `${stem}.jsx`), jsx, "utf8");
236
+ const owns = ownerIndexByGuid.get(decoded.guid)?.index === index;
237
+ const guid =
238
+ owns && !seenGuids.has(decoded.guid) ? decoded.guid : mintSlideGuid();
239
+ seenGuids.add(guid);
240
+ guids[`slides/${stem}.jsx`] = slideGuidEntry(guid, jsx);
241
+ bundle ??= decoded.meta.bundle ?? null;
242
+ adopted.push(`${stem}.jsx`);
243
+ return;
244
+ }
245
+ const fold = foldByPick.get(index + 1);
246
+ const conv = convertedSlides.get(index + 1);
247
+ if (conv) {
248
+ // Deterministically converted: editable jsx in the converted dialect,
249
+ // plus the identity/evidence record push threads back to the server.
250
+ const stem = stemFor(conv.name ?? `${pad2(index + 1)}-converted`, index, taken);
251
+ let jsx = conv.jsx;
252
+ for (const [from, to] of convAssetRenames) jsx = jsx.split(from).join(to);
253
+ fs.writeFileSync(path.join(slidesDir, `${stem}.jsx`), jsx, "utf8");
254
+ const guid = conv.guid && !seenGuids.has(conv.guid) ? conv.guid : mintSlideGuid();
255
+ seenGuids.add(guid);
256
+ guids[`slides/${stem}.jsx`] = slideGuidEntry(guid, jsx);
257
+ writeConversionRecord(
258
+ deckDir,
259
+ `${stem}.jsx`,
260
+ {
261
+ guid,
262
+ state: conv.state ?? "needsReview",
263
+ sourceBaseline: conv.sourceBaseline ?? null,
264
+ conversionEvidence: conv.conversionEvidence ?? null,
265
+ preserved: Array.isArray(conv.preserved) ? conv.preserved : [],
266
+ warnings: Array.isArray(conv.warnings) ? conv.warnings : [],
267
+ source: {
268
+ file: path.basename(pptxPath),
269
+ index: index + 1,
270
+ part: conv.part ?? order[index],
271
+ },
272
+ convertedAt: new Date().toISOString(),
273
+ },
274
+ fold?.bytes ?? null,
275
+ );
276
+ convertedFiles.push(`${stem}.jsx`);
277
+ return;
278
+ }
279
+ // No decodable source and no conversion → keep the slide verbatim as a
280
+ // native one-slide .pptx, spliced back by filename order on every future
281
+ // render.
282
+ if (!fold) return;
283
+ const stem = stemFor(`${pad2(index + 1)}-reused-adopted`, index, taken);
284
+ fs.writeFileSync(path.join(slidesDir, `${stem}.pptx`), fold.bytes);
285
+ native.push(`${stem}.pptx`);
286
+ });
287
+
288
+ if (Object.keys(guids).length > 0) saveSlideGuids(deckDir, guids);
289
+ // A wholly foreign deck names no bundle in any tag, but conversion ran
290
+ // against a design — bind to that one so the next push renders pane-free
291
+ // (the etag self-heals on the first render).
292
+ if (!bundle && convertedFiles.length > 0 && converted?.brandId) {
293
+ bundle = { presFP: converted.brandId, etag: "" };
294
+ }
295
+ // Bind the deck to the bundle the tags name — but NEVER clobber an
296
+ // existing binding: adopting into an existing deck must not repoint every
297
+ // other slide's design at the incoming file's CD.
298
+ if (
299
+ bundle?.presFP &&
300
+ bundle.presFP !== "unknown" &&
301
+ !readDeckBundleRef(deckDir)?.presFP
302
+ ) {
303
+ writeDeckBundleRef(deckDir, { presFP: bundle.presFP, etag: bundle.etag });
304
+ }
305
+ const planPath = path.join(deckDir, "plan.md");
306
+ if (!fs.existsSync(planPath)) {
307
+ fs.writeFileSync(
308
+ planPath,
309
+ `---\nskill: create-slides\n---\n\n# ${path.basename(deckDir)}\n\nAdopted from ${path.basename(pptxPath)}.\n`,
310
+ "utf8",
311
+ );
312
+ }
313
+
314
+ return {
315
+ deckDir,
316
+ adopted,
317
+ converted: convertedFiles,
318
+ native,
319
+ bundle,
320
+ // Why a slide the server was asked to convert stayed native (addinOwned,
321
+ // nativeOnlyEnvelope, a parse failure) — the agent has to be able to say so.
322
+ skipped: Array.isArray(converted?.skipped) ? converted.skipped : [],
323
+ warnings: Array.isArray(converted?.warnings) ? converted.warnings : [],
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Deterministically convert a FILE deck's untagged slides through the add-in's
329
+ * `POST /api/cli/brands/:brandId/convert-pptx` route. Returns the route result
330
+ * (`{ slides, assets, skipped, warnings }` + the `brandId` it ran against) to
331
+ * hand to {@link adoptDeckFromPptx}, or null when conversion is not available
332
+ * here — no design to convert against, no backend serving the route, or the
333
+ * route refused. A null result is not an error: every untagged slide then folds
334
+ * in as native .pptx exactly as before.
335
+ *
336
+ * `note` receives one human line per degradation; the caller prints it.
337
+ */
338
+ export async function convertUntaggedSlides({
339
+ pptxPath,
340
+ deckDir,
341
+ brandId = null,
342
+ note = () => {},
343
+ source = null,
344
+ }) {
345
+ const { buffer, decodedByIndex } = source ?? readSourceDeck(pptxPath);
346
+ const slides = [];
347
+ decodedByIndex.forEach((decoded, i) => {
348
+ if (!decoded) slides.push(i + 1);
349
+ });
350
+ if (slides.length === 0) return null;
351
+
352
+ const client = await import("../bridge/client.mjs");
353
+ const brand =
354
+ brandId ??
355
+ readDeckBundleRef(deckDir)?.presFP ??
356
+ decodedByIndex.find((d) => d?.meta?.bundle?.presFP)?.meta.bundle.presFP ??
357
+ null;
358
+ let resolved = brand && brand !== "unknown" ? brand : null;
359
+ let backendURL = null;
360
+ if (!resolved) {
361
+ // No design anywhere: adopt one only when the answer is unambiguous.
362
+ let brands = [];
363
+ try {
364
+ brands = await client.listBrands();
365
+ } catch {
366
+ brands = [];
367
+ }
368
+ if (brands.length !== 1) {
369
+ note(
370
+ brands.length === 0
371
+ ? "no corporate design available to convert against — untagged slides stay native"
372
+ : "several corporate designs available — pass `--brand <presFP>` to convert the untagged slides",
373
+ );
374
+ return null;
375
+ }
376
+ resolved = brands[0].presFP;
377
+ backendURL = brands[0].backendURL ?? null;
378
+ // Routing the upload straight at that backend skips the fan-out's
379
+ // capability probe — do it here, so a whole .pptx is never shipped to a
380
+ // backend that will just 404 the route.
381
+ if (backendURL && !(await client.backendSupportsConvertPptx(backendURL))) {
382
+ note("this add-in does not convert foreign slides — untagged slides stay native");
383
+ return null;
384
+ }
385
+ }
386
+
387
+ try {
388
+ const result = await client.convertPptxForBrand(resolved, {
389
+ bytes: buffer,
390
+ fileName: path.basename(pptxPath),
391
+ slides,
392
+ backendURL,
393
+ });
394
+ if (result?.ok === false) {
395
+ note("the add-in declined to convert this file — untagged slides stay native");
396
+ return null;
397
+ }
398
+ return { ...result, brandId: resolved };
399
+ } catch (err) {
400
+ note(
401
+ err?.code === "convert_route_unavailable"
402
+ ? "this add-in does not convert foreign slides — untagged slides stay native"
403
+ : `slide conversion unavailable (${err?.message ?? err}) — untagged slides stay native`,
404
+ );
405
+ return null;
406
+ }
407
+ }
@@ -0,0 +1,263 @@
1
+ // Deck-dir conversion metadata — the durable half of `slidemind adopt`'s
2
+ // reverse conversion (intern-slidemind openspec change
3
+ // `pptx-to-jsx-deterministic-reverse`, design D6/D9).
4
+ //
5
+ // A slide the add-in's `POST /api/cli/brands/:brandId/convert-pptx` route
6
+ // converted lands as ordinary deck source (`slides/NN-name.jsx` + `assets/…`),
7
+ // but its IDENTITY and its FIDELITY PROOF are not derivable from that jsx:
8
+ //
9
+ // • sourceBaseline — what the source slide WAS (projection, canvas,
10
+ // fingerprint, envelope class). The drift baseline.
11
+ // • conversionEvidence — what the UNEDITED generated jsx rendered to.
12
+ // • preserved[] — the opaque natives (chart/OLE/SmartArt) that must
13
+ // be spliced back verbatim on every render.
14
+ //
15
+ // So each converted slide keeps a compact `conversions/<stem>.json` next to the
16
+ // deck, and — ONLY when it carries preserved objects — the verbatim one-slide
17
+ // source package `conversions/<stem>.slide.pptx` that the server re-parses to
18
+ // splice those natives back in. Bulky, re-derivable packages stay out of the
19
+ // record for every other slide (D6).
20
+ //
21
+ // `slidemind push` / `build-pptx` reads both back and threads them onto the
22
+ // render request; nothing here talks to the network.
23
+
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+
27
+ /** Deck-relative home of every conversion artifact. */
28
+ export const CONVERSIONS_DIR = "conversions";
29
+
30
+ /** Record format version — bumped when the on-disk shape changes. */
31
+ export const CONVERSION_RECORD_VERSION = 1;
32
+
33
+ function conversionsDir(deckDir) {
34
+ return path.join(deckDir, CONVERSIONS_DIR);
35
+ }
36
+
37
+ // Records are keyed by the slide's FILE NAME ("03-market.jsx"), the same key
38
+ // the render body's `slides` map uses, so the server joins the two without a
39
+ // second convention. basename() keeps a crafted record name inside the dir.
40
+ function stemOf(slideFileName) {
41
+ return path.basename(String(slideFileName)).replace(/\.jsx$/i, "");
42
+ }
43
+
44
+ /**
45
+ * Write one converted slide's record (+ its source package when the slide
46
+ * carries preserved natives). `sourcePackage` is a Uint8Array/Buffer or null.
47
+ */
48
+ export function writeConversionRecord(
49
+ deckDir,
50
+ slideFileName,
51
+ record,
52
+ sourcePackage = null,
53
+ ) {
54
+ const stem = stemOf(slideFileName);
55
+ const dir = conversionsDir(deckDir);
56
+ fs.mkdirSync(dir, { recursive: true });
57
+ const packageName =
58
+ sourcePackage && record.preserved?.length ? `${stem}.slide.pptx` : null;
59
+ if (packageName) {
60
+ fs.writeFileSync(path.join(dir, packageName), Buffer.from(sourcePackage));
61
+ }
62
+ const full = {
63
+ v: CONVERSION_RECORD_VERSION,
64
+ slide: `slides/${stem}.jsx`,
65
+ dialect: "converted",
66
+ ...record,
67
+ source: { ...(record.source ?? {}), package: packageName },
68
+ };
69
+ fs.writeFileSync(
70
+ path.join(dir, `${stem}.json`),
71
+ `${JSON.stringify(full, null, 2)}\n`,
72
+ "utf8",
73
+ );
74
+ return full;
75
+ }
76
+
77
+ /**
78
+ * Every conversion record of a deck, keyed by slide file name, in the shape the
79
+ * render request carries: identity + evidence + the base64 preserve source.
80
+ *
81
+ * A record whose `slides/<stem>.jsx` no longer exists is skipped — the slide was
82
+ * deleted or renamed, and shipping its baseline would bind conversion identity
83
+ * to a slide that is no longer in the deck. Unreadable/corrupt records are
84
+ * skipped too: a converted slide then renders as ordinary jsx (its preserved
85
+ * natives are lost, which the caller warns about) rather than failing the push.
86
+ *
87
+ * `includeSource: false` skips reading the preserve packages — for callers that
88
+ * only need to know WHICH slides are converted (they can be megabytes).
89
+ */
90
+ export function loadConversions(deckDir, { includeSource = true } = {}) {
91
+ const dir = conversionsDir(deckDir);
92
+ if (!fs.existsSync(dir)) return {};
93
+ const out = {};
94
+ for (const name of fs.readdirSync(dir)) {
95
+ if (!name.endsWith(".json")) continue;
96
+ const stem = name.replace(/\.json$/, "");
97
+ if (!fs.existsSync(path.join(deckDir, "slides", `${stem}.jsx`))) continue;
98
+ let record;
99
+ try {
100
+ record = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
101
+ } catch {
102
+ continue;
103
+ }
104
+ const entry = {
105
+ guid: record.guid ?? null,
106
+ state: record.state ?? "needsReview",
107
+ dialect: record.dialect ?? "converted",
108
+ sourceBaseline: record.sourceBaseline ?? null,
109
+ conversionEvidence: record.conversionEvidence ?? null,
110
+ preserved: Array.isArray(record.preserved) ? record.preserved : [],
111
+ };
112
+ // The preserve splice runs server-side against the ORIGINAL bytes: shape
113
+ // indices are re-derived from this package on every push, never persisted
114
+ // (design D4 — one replace reorders the slide's shape list).
115
+ const packageName = record.source?.package
116
+ ? path.basename(record.source.package)
117
+ : null;
118
+ if (packageName && includeSource) {
119
+ const packagePath = path.join(dir, packageName);
120
+ if (fs.existsSync(packagePath)) {
121
+ entry.preserveSource = fs.readFileSync(packagePath).toString("base64");
122
+ }
123
+ }
124
+ out[`${stem}.jsx`] = entry;
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /**
130
+ * Follow the deck's reconciled GUIDs: move every conversion record whose slide
131
+ * file was renamed (a reorder renames every slide — the NN- prefix IS the play
132
+ * order) to the file now carrying its GUID, and delete records whose GUID left
133
+ * the deck. Without this a renamed converted slide silently loses its record —
134
+ * the next render drops its preserved natives and `push --replace` sails past
135
+ * the first-replace gate — while the dead record's multi-MB preserve package
136
+ * sits on disk forever.
137
+ *
138
+ * `guidsByPath` MUST be fresh (the return of reconcileSlideGuids in the same
139
+ * flow); a stale map would read a mid-rename deck as a deletion. A record that
140
+ * cannot be parsed is left untouched — it may be a hand-edit gone wrong, and
141
+ * deleting it would take the preserve package with it.
142
+ *
143
+ * Returns one human-readable note per record moved or removed.
144
+ */
145
+ export function rehomeConversions(deckDir, guidsByPath) {
146
+ const dir = conversionsDir(deckDir);
147
+ if (!fs.existsSync(dir)) return [];
148
+ const stemByGuid = new Map();
149
+ for (const [p, guid] of Object.entries(guidsByPath ?? {})) {
150
+ stemByGuid.set(guid, stemOf(p));
151
+ }
152
+ const notes = [];
153
+ for (const name of fs.readdirSync(dir)) {
154
+ if (!name.endsWith(".json")) continue;
155
+ const stem = name.replace(/\.json$/, "");
156
+ if (fs.existsSync(path.join(deckDir, "slides", `${stem}.jsx`))) continue;
157
+ let record;
158
+ try {
159
+ record = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
160
+ } catch {
161
+ continue;
162
+ }
163
+ const nextStem = record?.guid ? stemByGuid.get(record.guid) : undefined;
164
+ if (nextStem && !fs.existsSync(path.join(dir, `${nextStem}.json`))) {
165
+ record.slide = `slides/${nextStem}.jsx`;
166
+ if (record.source?.package) {
167
+ const oldPackage = path.join(dir, path.basename(record.source.package));
168
+ record.source.package = `${nextStem}.slide.pptx`;
169
+ if (fs.existsSync(oldPackage)) {
170
+ fs.renameSync(oldPackage, path.join(dir, record.source.package));
171
+ }
172
+ }
173
+ fs.writeFileSync(
174
+ path.join(dir, `${nextStem}.json`),
175
+ `${JSON.stringify(record, null, 2)}\n`,
176
+ "utf8",
177
+ );
178
+ fs.rmSync(path.join(dir, name), { force: true });
179
+ notes.push(
180
+ `${stem}.jsx was renamed to ${nextStem}.jsx — moved its conversion record along`,
181
+ );
182
+ } else if (record?.guid && !nextStem) {
183
+ fs.rmSync(path.join(dir, name), { force: true });
184
+ if (record.source?.package) {
185
+ fs.rmSync(path.join(dir, path.basename(record.source.package)), { force: true });
186
+ }
187
+ notes.push(`${stem}.jsx left the deck — removed its conversion record`);
188
+ }
189
+ }
190
+ return notes;
191
+ }
192
+
193
+ /**
194
+ * Apply the render response's first-replace gate outcome to the deck records.
195
+ *
196
+ * A FILE deck has no live slide, so the server cannot block: `gateConversions`
197
+ * renders anyway and reports every slide that is not cleared for a silent
198
+ * replace. That makes THIS the only place the outcome can become visible or
199
+ * durable — drop `conversionGates` here and an unreviewed conversion overwrites
200
+ * the user's original slides with no prompt and no warning line.
201
+ *
202
+ * Each record is stamped with the outcome (`firstReplace: {route, reasons, at}`)
203
+ * so a later run — and the user reading the deck dir — can see why a slide is
204
+ * still unconfirmed. The stamp deliberately does NOT touch `state`: `state` is
205
+ * what the server gates ON, and rewriting it here would either forge a review
206
+ * that never happened or re-gate a slide the server already cleared.
207
+ *
208
+ * Returns one `{ file, route, reasons, note }` per GATED slide — `note` is the
209
+ * user-facing line, in the caller's `warnings` vocabulary.
210
+ */
211
+ export function applyConversionGates(deckDir, conversions, gates) {
212
+ const byFile = new Map(
213
+ (Array.isArray(gates) ? gates : [])
214
+ .filter((g) => g && typeof g.file === "string")
215
+ .map((g) => [path.basename(g.file), g]),
216
+ );
217
+ const gated = [];
218
+ for (const file of Object.keys(conversions).sort()) {
219
+ const gate = byFile.get(file) ?? null;
220
+ if (gate) {
221
+ const reasons =
222
+ Array.isArray(gate.reasons) && gate.reasons.length > 0
223
+ ? gate.reasons
224
+ : [String(gate.route ?? "gated")];
225
+ gated.push({
226
+ file,
227
+ route: String(gate.route ?? "gated"),
228
+ reasons,
229
+ note:
230
+ `${file}: this converted slide was NOT confirmed for replacement ` +
231
+ `(${gate.route}: ${reasons.join(", ")}) — it is in the rendered deck, but pushing ` +
232
+ `it over the original is unreviewed. Compare it with the source slide, then set ` +
233
+ `"state": "verified" in ${CONVERSIONS_DIR}/${stemOf(file)}.json once it is right.`,
234
+ });
235
+ }
236
+ stampFirstReplace(deckDir, file, gate);
237
+ }
238
+ return gated;
239
+ }
240
+
241
+ // Durable half of the gate outcome. Best-effort: a deck dir the user made
242
+ // read-only must not lose them a finished render.
243
+ function stampFirstReplace(deckDir, slideFileName, gate) {
244
+ const file = path.join(conversionsDir(deckDir), `${stemOf(slideFileName)}.json`);
245
+ try {
246
+ const record = JSON.parse(fs.readFileSync(file, "utf8"));
247
+ record.firstReplace = {
248
+ route: gate ? String(gate.route ?? "gated") : "autoSilent",
249
+ reasons: gate && Array.isArray(gate.reasons) ? gate.reasons : [],
250
+ at: new Date().toISOString(),
251
+ };
252
+ fs.writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, "utf8");
253
+ } catch {
254
+ /* unreadable/unwritable record — the warning line still reached the user */
255
+ }
256
+ }
257
+
258
+ /** Conversion records whose preserved natives have no source package to splice. */
259
+ export function conversionsMissingPreserveSource(conversions) {
260
+ return Object.entries(conversions)
261
+ .filter(([, c]) => c.preserved?.length > 0 && !c.preserveSource)
262
+ .map(([file]) => file);
263
+ }