slidemind 0.2.4 → 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.
@@ -19,6 +19,7 @@ import path from "node:path";
19
19
  import { randomUUID } from "node:crypto";
20
20
 
21
21
  import {
22
+ exportSlides,
22
23
  insertSlides,
23
24
  pickPresentationEntry,
24
25
  renderPptx,
@@ -36,7 +37,133 @@ import {
36
37
  } from "./bundle.mjs";
37
38
  import { bundleHasCaptureEngine, captureDeckLocally } from "./capture.mjs";
38
39
  import { slideOrderWarning, sortSlideFilenames } from "../lib/slide-order.mjs";
40
+ import {
41
+ applyConversionGates,
42
+ conversionsMissingPreserveSource,
43
+ loadConversions,
44
+ rehomeConversions,
45
+ } from "../lib/conversions.mjs";
46
+ import { detectManualEdits } from "../lib/overwrite-guard.mjs";
47
+ import {
48
+ applyReconcileResponse,
49
+ collectLiveReconcileInputs,
50
+ collectReconcileInputs,
51
+ reconcileErrorPayload,
52
+ } from "../lib/reconcile.mjs";
39
53
  import { normalizePptxAppPropertiesBase64 } from "../pptx/app-props.mjs";
54
+ import {
55
+ loadSlideGuids,
56
+ reconcileSlideGuids,
57
+ saveSlideGuids,
58
+ slideGuidEntry,
59
+ } from "../lib/slide-guids.mjs";
60
+ import {
61
+ SLIDEMIND_TAG_PREFIX,
62
+ TAG_GUID,
63
+ TAG_PROV,
64
+ bakeSlideSourceTags,
65
+ encodeSourceTags,
66
+ mintSlideGuid,
67
+ readSlideTags,
68
+ referencedAssets,
69
+ } from "../pptx/tags.mjs";
70
+ import { unzip, zip } from "../pptx/zip.mjs";
71
+ import { slideOrderPaths } from "../pptx/ooxml.mjs";
72
+
73
+ // Deck-embedded source, translate-path bake (spec §5.4): the server never
74
+ // sees the jsx on the local-capture path, so tag any UNTAGGED jsx slide in
75
+ // the returned deck locally. Deck slide order == sorted deck filename order
76
+ // (jsx + native .pptx share one namespace), which maps 1:1 onto the
77
+ // package's slide order. Returns new base64 or null when nothing changed.
78
+ function ensureDeckSourceTags(deckDir, base64) {
79
+ const slidesDir = path.join(deckDir, "slides");
80
+ if (!fs.existsSync(slidesDir)) return null;
81
+ const deckFiles = sortSlideFilenames(
82
+ fs
83
+ .readdirSync(slidesDir)
84
+ .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx")),
85
+ );
86
+ if (deckFiles.length === 0) return null;
87
+
88
+ const files = unzip(Buffer.from(base64, "base64"));
89
+ const order = slideOrderPaths(files);
90
+ if (order.length !== deckFiles.length) {
91
+ // A skipped/spliced-away slide broke the 1:1 filename↔slide mapping —
92
+ // never guess; the deck ships without locally-baked tags and the user
93
+ // should know it isn't `adopt`-able.
94
+ process.stderr.write(
95
+ `note: rendered deck has ${order.length} slide(s) but the deck folder has ` +
96
+ `${deckFiles.length} — skipping the local source-tag bake (file won't be adoptable)\n`,
97
+ );
98
+ return null;
99
+ }
100
+
101
+ const bundleRef = readDeckBundleRef(deckDir);
102
+ const guids = loadSlideGuids(deckDir);
103
+ const minted = {};
104
+ const entries = [];
105
+ for (let i = 0; i < deckFiles.length; i++) {
106
+ const fileName = deckFiles[i];
107
+ if (!fileName.endsWith(".jsx")) continue; // natives carry their own tags
108
+ if (readSlideTags(files, order[i])[TAG_GUID]) continue; // server already baked
109
+ let jsx;
110
+ try {
111
+ jsx = fs.readFileSync(path.join(slidesDir, fileName), "utf8");
112
+ } catch {
113
+ continue;
114
+ }
115
+ const key = `slides/${fileName}`;
116
+ const guid = guids[key] ?? mintSlideGuid();
117
+ minted[key] = slideGuidEntry(guid, jsx);
118
+ entries.push({
119
+ partName: order[i],
120
+ tags: encodeSourceTags({
121
+ name: fileName.replace(/\.jsx$/, ""),
122
+ jsx,
123
+ guid,
124
+ bundle: {
125
+ presFP: bundleRef?.presFP ?? "unknown",
126
+ etag: bundleRef?.etag ?? "",
127
+ },
128
+ renderer: "slidemind-cli-translate",
129
+ // The jsx's deck-local assets ride along (smallest-first within the
130
+ // budget, spec §2.2) — without them `adopt` rebuilds a deck whose
131
+ // slides point at images that were never handed over.
132
+ assets: referencedAssets(jsx, path.join(deckDir, "assets")),
133
+ pushedAt: new Date().toISOString(),
134
+ }),
135
+ });
136
+ }
137
+ if (entries.length === 0 || !bakeSlideSourceTags(files, entries)) return null;
138
+ saveSlideGuids(deckDir, minted);
139
+ return Buffer.from(zip(files)).toString("base64");
140
+ }
141
+
142
+ // Per-slide SLIDEMIND tag maps of a deck in slide order, for the relay's
143
+ // post-insert tag write (the pane upserts them onto the landed slides —
144
+ // insertSlidesFromBase64 merges rather than byte-copies). Slides without an
145
+ // embedded source yield null placeholders to keep the array aligned.
146
+ function collectSourceTagsBySlide(base64) {
147
+ try {
148
+ const files = unzip(Buffer.from(base64, "base64"));
149
+ const maps = slideOrderPaths(files).map((part) => {
150
+ const tags = readSlideTags(files, part);
151
+ // SLIDE-level keys only. readSlideTags also walks every SHAPE's
152
+ // custDataLst, and those carry SLIDEMIND_PROV — stamping that onto the
153
+ // landed slide would file one arbitrary shape's provenance as the
154
+ // slide's own.
155
+ const own = Object.fromEntries(
156
+ Object.entries(tags).filter(
157
+ ([k]) => k.startsWith(SLIDEMIND_TAG_PREFIX) && k !== TAG_PROV,
158
+ ),
159
+ );
160
+ return own[TAG_GUID] ? own : null;
161
+ });
162
+ return maps.some(Boolean) ? maps : null;
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
40
167
 
41
168
  function gatherSlides(deckDir) {
42
169
  const slidesDir = path.join(deckDir, "slides");
@@ -97,7 +224,13 @@ function gatherDeckAssets(deckDir) {
97
224
  // ref rewritten, and the render retried once against the current design.
98
225
  export async function renderDeckToBase64(
99
226
  deckDir,
100
- { presId = null, brandId = null, backendURL = null, onProgress = null },
227
+ {
228
+ presId = null,
229
+ brandId = null,
230
+ backendURL = null,
231
+ reconcile = null,
232
+ onProgress = null,
233
+ },
101
234
  ) {
102
235
  if (!presId && !brandId) {
103
236
  throw new Error("renderDeckToBase64: presId or brandId is required");
@@ -126,17 +259,62 @@ export async function renderDeckToBase64(
126
259
  }
127
260
  }
128
261
 
262
+ // Deck-embedded source identity FIRST: reconcile the GUID sidecar with the
263
+ // slide files on disk (a reorder renames every file — the content hash moves
264
+ // each GUID to its renamed file and prunes dead entries), then let the
265
+ // conversion records follow their GUIDs to the renamed files.
266
+ const sourceGuids = reconcileSlideGuids(deckDir);
267
+ for (const note of rehomeConversions(deckDir, sourceGuids)) {
268
+ process.stderr.write(`note: ${note}\n`);
269
+ }
270
+
271
+ // Reverse-converted slides (`slidemind adopt`): conversion identity, fidelity
272
+ // evidence and — where the slide kept opaque natives — the source package the
273
+ // server splices them back from. Keyed by slide file name, like `slides`.
274
+ const conversions = loadConversions(deckDir);
275
+ const hasConversions = Object.keys(conversions).length > 0;
276
+ for (const file of conversionsMissingPreserveSource(conversions)) {
277
+ process.stderr.write(
278
+ `note: ${file} has preserved native objects but no conversions/*.slide.pptx ` +
279
+ `to splice them from — re-run 'slidemind adopt' for that deck\n`,
280
+ );
281
+ }
282
+
129
283
  const bundleDir = resolveBundleDirForDeck(deckDir);
130
284
  // Local capture only applies to authored JSX slides. A native-only deck (just
131
285
  // reused .pptx) has nothing to capture, so it goes straight to the server
132
286
  // render, which splices the native slides in.
133
- let useLocalCapture = hasJsx && bundleHasCaptureEngine(bundleDir);
287
+ //
288
+ // Converted slides also go server-side: they invoke the synthetic
289
+ // `ConvertedHost` layout, which is minted per source canvas at conversion
290
+ // time and lives in NO materialized bundle — the local capture engine reads
291
+ // its layout registry from the bundle dir, so it would reject every converted
292
+ // slide as an unregistered layout.
293
+ // Pending manual edits go server-side too: the reconcile round trip rides the
294
+ // render request, and the translate route never sees the jsx it would fold.
295
+ let useLocalCapture =
296
+ hasJsx && !hasConversions && !reconcile && bundleHasCaptureEngine(bundleDir);
134
297
 
135
298
  // One attempt at the chosen etag. Local capture that can't run (no Chrome,
136
299
  // corrupt engine) falls back to the server render for this and later attempts;
137
300
  // translate/render FAILURES (etag-stale, per-slide errors) propagate. Native
138
301
  // .pptx slides ride alongside on either path so the server splices them by
139
302
  // play-order filename.
303
+ // Deck-embedded source: the reconciled slide GUIDs keep identities stable
304
+ // across re-renders (server bake on the render path, local bake on the
305
+ // translate path — the server never sees jsx sources there).
306
+ const serverRender = (target, etag) =>
307
+ renderPptx({
308
+ ...target,
309
+ slides,
310
+ deckAssets,
311
+ nativeSlides,
312
+ etag,
313
+ sourceGuids,
314
+ conversions: hasConversions ? conversions : null,
315
+ reconcile,
316
+ onProgress,
317
+ });
140
318
  const attemptRender = async (etag) => {
141
319
  const target = { presId, brandId, deckName, backendURL };
142
320
  if (useLocalCapture) {
@@ -149,18 +327,35 @@ export async function renderDeckToBase64(
149
327
  "falling back to the server render\n",
150
328
  );
151
329
  useLocalCapture = false;
152
- return renderPptx({ ...target, slides, deckAssets, nativeSlides, etag, onProgress });
330
+ return serverRender(target, etag);
153
331
  }
154
332
  const n = Array.isArray(captured?.slides) ? captured.slides.length : 0;
155
333
  onProgress?.({ done: n, total: n, stage: "assembling" });
156
334
  return translateDeck({ ...target, captured, nativeSlides, etag });
157
335
  }
158
- return renderPptx({ ...target, slides, deckAssets, nativeSlides, etag, onProgress });
336
+ return serverRender(target, etag);
337
+ };
338
+
339
+ // A render where EVERY jsx slide blocked comes back as a REFUSAL (409, or a
340
+ // failed job carrying that same payload) — there is no deck to return, so the
341
+ // echo rides the error. It is the same outcome as a partial block, so it runs
342
+ // through the same handler: folds saved, hashes acknowledged, build refused
343
+ // with the reports. Never a transport error, never the fail-closed branch.
344
+ const renderOnce = async (etag) => {
345
+ try {
346
+ return await attemptRender(etag);
347
+ } catch (err) {
348
+ const refusal = reconcileErrorPayload(err);
349
+ if (refusal) {
350
+ applyReconcileResponse({ deckDir, sent: reconcile, result: refusal });
351
+ }
352
+ throw err;
353
+ }
159
354
  };
160
355
 
161
356
  let result;
162
357
  try {
163
- result = await attemptRender(ref?.etag ?? null);
358
+ result = await renderOnce(ref?.etag ?? null);
164
359
  } catch (err) {
165
360
  if (err?.code !== "bundle_etag_stale") throw err;
166
361
  // The active corporate design moved on since the deck was authored. The
@@ -182,12 +377,40 @@ export async function renderDeckToBase64(
182
377
  writeDeckBundleRef(deckDir, { presFP: mat.presFP, etag: mat.etag });
183
378
  // The bundle dir is keyed by presFP, so a refreshed capture engine landed
184
379
  // in place — re-evaluate in case it appeared (or vanished) on this sync.
185
- useLocalCapture = bundleHasCaptureEngine(bundleDir);
186
- result = await attemptRender(mat.etag);
380
+ useLocalCapture =
381
+ !hasConversions && !reconcile && bundleHasCaptureEngine(bundleDir);
382
+ result = await renderOnce(mat.etag);
187
383
  }
188
384
  if (!result?.base64) {
189
385
  throw new Error("render returned no pptx bytes");
190
386
  }
387
+ // Persist GUIDs the server minted for new slides (render path), with the
388
+ // jsx hash alongside so a later rename can still be reconciled to them.
389
+ if (result.sourceGuids) {
390
+ const entries = {};
391
+ for (const [key, guid] of Object.entries(result.sourceGuids)) {
392
+ const jsx = slides[key.replace(/^slides\//, "")];
393
+ entries[key] = typeof jsx === "string" ? slideGuidEntry(guid, jsx) : guid;
394
+ }
395
+ saveSlideGuids(deckDir, entries);
396
+ }
397
+ // Manual-edit reconcile: fold what the server folded, acknowledge the hashes,
398
+ // and refuse the build when an edit needs human judgment. Deliberately BEFORE
399
+ // the bake/normalize below — on a refusal those bytes are thrown away anyway.
400
+ applyReconcileResponse({ deckDir, sent: reconcile, result });
401
+ // Make sure EVERY jsx slide carries source tags. Only needed when the server
402
+ // never saw the jsx: the translate path (local capture), or a backend too old
403
+ // to bake tags — either way it reports no sourceGuids. When the server DID
404
+ // bake (sourceGuids present), unzipping the whole deck here just to find
405
+ // TAG_GUID on every slide is pure waste.
406
+ if (useLocalCapture || !result.sourceGuids) {
407
+ try {
408
+ const baked = ensureDeckSourceTags(deckDir, result.base64);
409
+ if (baked) result = { ...result, base64: baked };
410
+ } catch {
411
+ /* best-effort — an unbaked deck still opens fine, it just isn't adoptable */
412
+ }
413
+ }
191
414
  const normalized = normalizePptxAppPropertiesBase64(result.base64);
192
415
  if (normalized.changed) {
193
416
  result = {
@@ -198,10 +421,36 @@ export async function renderDeckToBase64(
198
421
  } else if (normalized.slideCount !== null && result.slideCount == null) {
199
422
  result = { ...result, slideCount: normalized.slideCount };
200
423
  }
424
+ // First-replace gate (design D9). A backend that evaluated the request's
425
+ // `conversions` ALWAYS reports `conversionGates` (empty when everything is
426
+ // cleared for a silent replace). A response WITHOUT the field means the
427
+ // backend ignored the conversions body entirely — it predates the feature,
428
+ // so its render also dropped the preserved natives. Fail CLOSED there: gate
429
+ // every converted slide rather than stamping a clearance ("autoSilent") for
430
+ // a check that never ran.
431
+ const gates = Array.isArray(result.conversionGates)
432
+ ? result.conversionGates
433
+ : Object.keys(conversions).map((file) => ({
434
+ file,
435
+ route: "backendUnaware",
436
+ reasons: [
437
+ "this add-in did not evaluate converted slides (no conversion support) — the render dropped their preserved natives",
438
+ ],
439
+ }));
440
+ const gatedConversions = hasConversions
441
+ ? applyConversionGates(deckDir, conversions, gates)
442
+ : [];
443
+ if (gatedConversions.length > 0) {
444
+ result.warnings = [
445
+ ...(result.warnings ?? []),
446
+ ...gatedConversions.map((g) => g.note),
447
+ ];
448
+ }
449
+ result.gatedConversions = gatedConversions;
201
450
  if (orderWarning) {
202
451
  result.warnings = [orderWarning, ...(result.warnings ?? [])];
203
452
  }
204
- return result; // { ok, base64, slideCount, warnings, skippedSlides }
453
+ return result; // { ok, base64, slideCount, warnings, skippedSlides, gatedConversions }
205
454
  }
206
455
 
207
456
  // Map ONE captured slide to a draft-view entry. The capture engine produces a
@@ -282,11 +531,26 @@ export async function viewDraft(
282
531
  if (all.length === 0) {
283
532
  throw new Error(`No .jsx or .pptx slides under ${slidesDir}`);
284
533
  }
285
- const hasJsx = all.some((f) => f.endsWith(".jsx"));
534
+ const warnings = [];
535
+ // Same identity reconcile as the render path — a reorder renames every file,
536
+ // and a stale sidecar here would mislabel renamed converted slides (Chrome
537
+ // would then choke on their unregistered ConvertedHost layout).
538
+ warnings.push(...rehomeConversions(deckDir, reconcileSlideGuids(deckDir)));
539
+ // Converted slides (`slidemind adopt`) invoke the synthetic ConvertedHost
540
+ // layout, which no materialized bundle carries — the local capture engine
541
+ // builds its layout registry from the bundle dir, so it cannot render them.
542
+ // They are named here instead of surfacing as a cryptic unregistered-layout
543
+ // error, and a deck of nothing but converted slides never launches Chrome.
544
+ const conversions = loadConversions(deckDir, { includeSource: false });
545
+ const convertedNote =
546
+ "converted slide — renders server-side only (its ConvertedHost layout is not in the " +
547
+ "bundle); use `slidemind build-pptx <deck>` to look at it";
548
+ const hasJsx = all.some((f) => f.endsWith(".jsx") && !conversions[f]);
286
549
 
287
550
  // Resolve the materialized bundle. Local capture needs the bundle's capture
288
551
  // engine; once it's cached this whole call is offline. Only reach for a host
289
- // (to fetch the bundle) when there are JSX slides and no usable cached engine.
552
+ // (to fetch the bundle) when there are capturable JSX slides and no usable
553
+ // cached engine.
290
554
  let ref = readDeckBundleRef(deckDir);
291
555
  let bundleDir = resolveBundleDirForDeck(deckDir);
292
556
  if (hasJsx && (!bundleDir || !bundleHasCaptureEngine(bundleDir))) {
@@ -302,16 +566,19 @@ export async function viewDraft(
302
566
  bundleDir = resolveBundleDirForDeck(deckDir);
303
567
  }
304
568
 
305
- const warnings = [];
306
-
307
- // A native-only deck (just reused .pptx) has no authored JSX to draft-preview.
569
+ // Nothing left to capture — a native-only deck (just reused .pptx), a fully
570
+ // converted one, or both.
308
571
  if (!hasJsx) {
309
572
  return {
310
- slides: all.map((fileName) => ({
311
- fileName,
312
- status: "native",
313
- note: "reused native slide — not authored JSX, no draft preview rendered",
314
- })),
573
+ slides: all.map((fileName) =>
574
+ conversions[fileName]
575
+ ? { fileName, status: "converted", note: convertedNote }
576
+ : {
577
+ fileName,
578
+ status: "native",
579
+ note: "reused native slide — not authored JSX, no draft preview rendered",
580
+ },
581
+ ),
315
582
  warnings,
316
583
  };
317
584
  }
@@ -332,20 +599,25 @@ export async function viewDraft(
332
599
  throw new Error(
333
600
  `local draft capture failed (${err instanceof Error ? err.message : String(err)}). ` +
334
601
  "It runs a local headless Chrome; if Chrome can't launch here, use " +
335
- "`slidemind build <deck>` for an HTML preview instead.",
602
+ "`slidemind build <deck>` for an HTML preview instead." +
603
+ (Object.keys(conversions).length > 0 ? ` ${convertedNote}.` : ""),
336
604
  );
337
605
  }
338
606
 
339
607
  const byFile = new Map((captured?.slides ?? []).map((s) => [s.fileName, s]));
340
- const slides = all.map((fileName) =>
341
- fileName.endsWith(".pptx")
342
- ? {
343
- fileName,
344
- status: "native",
345
- note: "reused native slide — not authored JSX, no draft preview rendered",
346
- }
347
- : mapCapturedSlide(fileName, byFile.get(fileName), warnings),
348
- );
608
+ const slides = all.map((fileName) => {
609
+ if (fileName.endsWith(".pptx")) {
610
+ return {
611
+ fileName,
612
+ status: "native",
613
+ note: "reused native slide — not authored JSX, no draft preview rendered",
614
+ };
615
+ }
616
+ if (conversions[fileName]) {
617
+ return { fileName, status: "converted", note: convertedNote };
618
+ }
619
+ return mapCapturedSlide(fileName, byFile.get(fileName), warnings);
620
+ });
349
621
 
350
622
  return { slides, warnings };
351
623
  }
@@ -369,7 +641,7 @@ function isBrandRouteMiss(err) {
369
641
  // involved. Returns null when the brand route can't serve this deck (caller
370
642
  // falls back to the pane flow); render failures (lint, stale-after-retry)
371
643
  // propagate, they are real.
372
- async function tryDetachedRender(deckDir, deckRef, onProgress) {
644
+ async function tryDetachedRender(deckDir, deckRef, onProgress, reconcile = null) {
373
645
  const brandId = deckRef.presFP;
374
646
  let backendURL;
375
647
  try {
@@ -384,16 +656,24 @@ async function tryDetachedRender(deckDir, deckRef, onProgress) {
384
656
  const result = await renderDeckToBase64(deckDir, {
385
657
  brandId,
386
658
  backendURL,
659
+ reconcile,
387
660
  onProgress,
388
661
  });
389
662
  return { result, backendURL };
390
663
  }
391
664
 
392
- async function renderWithRetarget(deckDir, entry, onProgress, registry = []) {
665
+ async function renderWithRetarget(
666
+ deckDir,
667
+ entry,
668
+ onProgress,
669
+ registry = [],
670
+ reconcile = null,
671
+ ) {
393
672
  const run = (target) =>
394
673
  renderDeckToBase64(deckDir, {
395
674
  presId: target.presId,
396
675
  backendURL: target.backendURL ?? null,
676
+ reconcile,
397
677
  onProgress,
398
678
  });
399
679
  try {
@@ -427,6 +707,8 @@ export async function buildAndPush({
427
707
  insertAt = null,
428
708
  replace = false,
429
709
  slideIds = null,
710
+ acceptConversions = false,
711
+ force = false,
430
712
  onProgress = null,
431
713
  }) {
432
714
  if (!deckDir) throw new Error("buildAndPush: deckDir is required");
@@ -444,17 +726,58 @@ export async function buildAndPush({
444
726
  }
445
727
 
446
728
  const deckRef = readDeckBundleRef(deckDir);
729
+ // Every window we saw next to the target. The retarget wait treats these as
730
+ // definitely-not-the-target, so a second deck on the same corporate design
731
+ // can never inherit this push (waitForPaneReady's `otherPresIds`).
732
+ let seenRegistry = [];
733
+
734
+ // Manual-edit reconcile against the LIVE deck (plan Phase C2). `--replace`
735
+ // deletes what is on screen and re-pushes the deck, so the thing that must be
736
+ // inspected first is the presentation itself — not the deck's .pptx sibling
737
+ // (that file keeps its own guard, in saveFallback below). An INSERT push adds
738
+ // slides and destroys nothing, so it never reconciles.
739
+ //
740
+ // Needs a live window, because only the pane can export its slides. When no
741
+ // window is registered at all, nothing can be replaced either: the render
742
+ // still runs and its bytes land through saveFallback, where the output-file
743
+ // guard is the protection.
744
+ let reconcile = null;
745
+ if (replace && !force) {
746
+ let registry = null;
747
+ try {
748
+ registry = await requireHost();
749
+ } catch {
750
+ registry = null;
751
+ }
752
+ if (registry) {
753
+ seenRegistry = registry;
754
+ // May throw the "which window?" question — the same one the push itself
755
+ // would raise a moment later, just earlier and before any render work.
756
+ const target = pickPresentationEntry(registry, presentationId, {
757
+ preferBrandId: deckRef?.presFP ?? null,
758
+ });
759
+ reconcile = await collectLiveReconcileInputs({
760
+ deckDir,
761
+ // A subset push (`--slides`) reconciles only its own guids; this CLI
762
+ // rejects --replace with --slides upstream, so today this is always the
763
+ // whole deck — which is exactly when `deck.unmatched` bites.
764
+ guids: Array.isArray(slideIds) && slideIds.length > 0 ? slideIds : null,
765
+ exportSlides: (guids) =>
766
+ exportSlides({
767
+ presId: target.presId,
768
+ guids,
769
+ backendURL: target.backendURL ?? null,
770
+ }),
771
+ });
772
+ }
773
+ }
447
774
 
448
775
  // --- Render half. Pane-free for a bound deck (the window may be closed or
449
776
  // backgrounded during the render); the pane matters only for the insert. ---
450
777
  let result = null;
451
778
  let renderEntry = null;
452
- // Every window we saw next to the target. The retarget wait treats these as
453
- // definitely-not-the-target, so a second deck on the same corporate design
454
- // can never inherit this push (waitForPaneReady's `otherPresIds`).
455
- let seenRegistry = [];
456
779
  if (!presentationId && deckRef?.presFP) {
457
- const detached = await tryDetachedRender(deckDir, deckRef, onProgress);
780
+ const detached = await tryDetachedRender(deckDir, deckRef, onProgress, reconcile);
458
781
  if (detached) result = detached.result;
459
782
  }
460
783
  if (!result) {
@@ -463,11 +786,36 @@ export async function buildAndPush({
463
786
  const entry = pickPresentationEntry(registry, presentationId, {
464
787
  preferBrandId: deckRef?.presFP ?? null,
465
788
  });
466
- const rendered = await renderWithRetarget(deckDir, entry, onProgress, registry);
789
+ const rendered = await renderWithRetarget(
790
+ deckDir,
791
+ entry,
792
+ onProgress,
793
+ registry,
794
+ reconcile,
795
+ );
467
796
  renderEntry = rendered.entry;
468
797
  result = rendered.result;
469
798
  }
470
799
 
800
+ // `--replace` wipes the presentation and re-pushes the built deck: for a
801
+ // converted deck that IS the first replacement of the user's original slides.
802
+ // A slide the server gated has not been confirmed for that, so the destructive
803
+ // half stops here and asks. Insert mode leaves the originals alone, so it only
804
+ // warns (the gate lines are already on `result.warnings`).
805
+ const gatedConversions = result.gatedConversions ?? [];
806
+ if (replace && gatedConversions.length > 0 && !acceptConversions) {
807
+ const err = new Error(
808
+ `${gatedConversions.length} converted slide(s) are not confirmed for replacement — ` +
809
+ `--replace would overwrite the originals with an unreviewed conversion.\n` +
810
+ gatedConversions.map((g) => ` ${g.note}`).join("\n") +
811
+ `\nRe-run with --accept-conversions to replace anyway, or push without --replace ` +
812
+ `to insert them and leave the originals in place.`,
813
+ );
814
+ err.code = "conversions_not_confirmed";
815
+ err.gatedConversions = gatedConversions;
816
+ throw err;
817
+ }
818
+
471
819
  const base64 = result.base64;
472
820
  const bytes = Buffer.byteLength(base64, "base64");
473
821
  const mode = replace ? "replace" : "insert";
@@ -477,7 +825,17 @@ export async function buildAndPush({
477
825
  // find one — save the .pptx next to the deck and tell the caller (bin prints
478
826
  // the recovery instructions).
479
827
  const saveFallback = (presIdForResult) => {
480
- const fallbackFile = path.join(deckDir, `${path.basename(deckDir)}.pptx`);
828
+ let fallbackFile = path.join(deckDir, `${path.basename(deckDir)}.pptx`);
829
+ // This save is the ONLY copy of a render nobody could take delivery of, so
830
+ // it must land somewhere — it can never be the thing that destroys manual
831
+ // edits, and it can never refuse either. Edited sibling ⇒ write next to it.
832
+ if (detectManualEdits(fallbackFile).length > 0) {
833
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
834
+ fallbackFile = path.join(
835
+ deckDir,
836
+ `${path.basename(deckDir)}-${stamp}.pptx`,
837
+ );
838
+ }
481
839
  fs.writeFileSync(fallbackFile, Buffer.from(base64, "base64"));
482
840
  return {
483
841
  presId: presIdForResult,
@@ -516,12 +874,24 @@ export async function buildAndPush({
516
874
  // The SAME operationId on every attempt: the pane keeps an LRU of applied
517
875
  // operationIds and drops duplicates, so a retry after an ambiguous failure
518
876
  // can never double-insert.
877
+ // Deck-embedded source: per-slide tag maps for the pane's post-insert tag
878
+ // write (insertSlidesFromBase64 merges rather than byte-copies, so the
879
+ // baked tags may not survive the insert). Aligned with FULL-deck slide
880
+ // order only — a --slides subset insert reorders the landed set, so subset
881
+ // pushes rely on the baked-in tags alone.
882
+ // ponytail: subset alignment needs the id→index map of the rendered deck;
883
+ // add it if spike shows baked tags don't survive UseDestinationTheme.
884
+ const sourceTagsBySlide =
885
+ Array.isArray(slideIds) && slideIds.length > 0
886
+ ? null
887
+ : collectSourceTagsBySlide(base64);
519
888
  const doPush = (targetPresId, targetBackendURL) =>
520
889
  replace
521
890
  ? replaceSlides({
522
891
  presId: targetPresId,
523
892
  base64,
524
893
  operationId,
894
+ sourceTagsBySlide,
525
895
  backendURL: targetBackendURL,
526
896
  })
527
897
  : insertSlides({
@@ -530,6 +900,7 @@ export async function buildAndPush({
530
900
  insertAt,
531
901
  sourceSlideIds: slideIds,
532
902
  operationId,
903
+ sourceTagsBySlide,
533
904
  backendURL: targetBackendURL,
534
905
  });
535
906
 
@@ -599,16 +970,23 @@ export async function buildPptxFile({
599
970
  deckDir,
600
971
  presentationId = null,
601
972
  outPath = null,
973
+ force = false,
602
974
  onProgress = null,
603
975
  }) {
604
976
  if (!deckDir) throw new Error("buildPptxFile: deckDir is required");
605
977
  const deckRef = readDeckBundleRef(deckDir);
606
978
  const out = outPath || path.join(deckDir, `${path.basename(deckDir)}.pptx`);
607
979
 
980
+ // Before ANY render work: what did the user change by hand in the file this
981
+ // build is about to replace? An edit we cannot address refuses here and now
982
+ // (nothing renders); everything else rides the render request and comes back
983
+ // folded or reported.
984
+ const reconcile = collectReconcileInputs({ deckDir, outFile: out, force });
985
+
608
986
  // Pane-free path. `--to <presId>` names a window explicitly and therefore
609
987
  // keeps the registry path.
610
988
  if (!presentationId && deckRef?.presFP) {
611
- const detached = await tryDetachedRender(deckDir, deckRef, onProgress);
989
+ const detached = await tryDetachedRender(deckDir, deckRef, onProgress, reconcile);
612
990
  if (detached) {
613
991
  fs.writeFileSync(out, Buffer.from(detached.result.base64, "base64"));
614
992
  return {
@@ -630,6 +1008,8 @@ export async function buildPptxFile({
630
1008
  deckDir,
631
1009
  entry,
632
1010
  onProgress,
1011
+ [],
1012
+ reconcile,
633
1013
  );
634
1014
  const presId = renderEntry.presId;
635
1015
  fs.writeFileSync(out, Buffer.from(result.base64, "base64"));
@@ -27,8 +27,19 @@ const LAUNCH_ARGS = [
27
27
  "--no-default-browser-check",
28
28
  ];
29
29
 
30
- /** True when the materialized bundle carries a capture engine to run locally. */
30
+ /**
31
+ * True when the materialized bundle carries a capture engine to run locally.
32
+ *
33
+ * `SLIDEMIND_FORCE_SERVER_RENDER=1` forces the server-render fallback even
34
+ * when the bundle has one. Escape hatch for the case where the two paths
35
+ * disagree: capture runs client-side and its output crosses a JSON wire to
36
+ * `/translate`, so an engine bug can lose data that the in-process server
37
+ * render keeps (this is how the base64 background-image fix was isolated —
38
+ * same deck, same asset, images only on the server path). It costs the
39
+ * pane-free speed win, so it is a diagnostic, not a default.
40
+ */
31
41
  export function bundleHasCaptureEngine(bundleDir) {
42
+ if (process.env.SLIDEMIND_FORCE_SERVER_RENDER === "1") return false;
32
43
  if (!bundleDir) return false;
33
44
  return fs.existsSync(path.join(bundleDir, "capture-engine.mjs"));
34
45
  }