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.
@@ -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) {
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 {
@@ -406,6 +686,9 @@ async function renderWithRetarget(deckDir, entry, onProgress) {
406
686
  backendURL: entry.backendURL ?? null,
407
687
  fileName: entry.fileName ?? entry.title ?? null,
408
688
  brandId: entry.brandId ?? null,
689
+ otherPresIds: registry
690
+ .map((p) => p.presId)
691
+ .filter((id) => id && id !== entry.presId),
409
692
  timeoutMs: 15000,
410
693
  onWait: (seconds) =>
411
694
  process.stderr.write(
@@ -424,6 +707,8 @@ export async function buildAndPush({
424
707
  insertAt = null,
425
708
  replace = false,
426
709
  slideIds = null,
710
+ acceptConversions = false,
711
+ force = false,
427
712
  onProgress = null,
428
713
  }) {
429
714
  if (!deckDir) throw new Error("buildAndPush: deckDir is required");
@@ -441,25 +726,96 @@ export async function buildAndPush({
441
726
  }
442
727
 
443
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
+ }
444
774
 
445
775
  // --- Render half. Pane-free for a bound deck (the window may be closed or
446
776
  // backgrounded during the render); the pane matters only for the insert. ---
447
777
  let result = null;
448
778
  let renderEntry = null;
449
779
  if (!presentationId && deckRef?.presFP) {
450
- const detached = await tryDetachedRender(deckDir, deckRef, onProgress);
780
+ const detached = await tryDetachedRender(deckDir, deckRef, onProgress, reconcile);
451
781
  if (detached) result = detached.result;
452
782
  }
453
783
  if (!result) {
454
784
  const registry = await requireRenderHost();
785
+ seenRegistry = registry;
455
786
  const entry = pickPresentationEntry(registry, presentationId, {
456
787
  preferBrandId: deckRef?.presFP ?? null,
457
788
  });
458
- const rendered = await renderWithRetarget(deckDir, entry, onProgress);
789
+ const rendered = await renderWithRetarget(
790
+ deckDir,
791
+ entry,
792
+ onProgress,
793
+ registry,
794
+ reconcile,
795
+ );
459
796
  renderEntry = rendered.entry;
460
797
  result = rendered.result;
461
798
  }
462
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
+
463
819
  const base64 = result.base64;
464
820
  const bytes = Buffer.byteLength(base64, "base64");
465
821
  const mode = replace ? "replace" : "insert";
@@ -469,7 +825,17 @@ export async function buildAndPush({
469
825
  // find one — save the .pptx next to the deck and tell the caller (bin prints
470
826
  // the recovery instructions).
471
827
  const saveFallback = (presIdForResult) => {
472
- 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
+ }
473
839
  fs.writeFileSync(fallbackFile, Buffer.from(base64, "base64"));
474
840
  return {
475
841
  presId: presIdForResult,
@@ -494,22 +860,38 @@ export async function buildAndPush({
494
860
  } catch {
495
861
  return saveFallback(null);
496
862
  }
863
+ seenRegistry = registry;
497
864
  renderEntry = pickPresentationEntry(registry, presentationId, {
498
865
  preferBrandId: deckRef?.presFP ?? null,
499
866
  });
500
867
  }
501
868
  const presId = renderEntry.presId;
869
+ const otherPresIds = seenRegistry
870
+ .map((p) => p.presId)
871
+ .filter((id) => id && id !== presId);
502
872
  const backendURL = renderEntry.backendURL ?? null;
503
873
 
504
874
  // The SAME operationId on every attempt: the pane keeps an LRU of applied
505
875
  // operationIds and drops duplicates, so a retry after an ambiguous failure
506
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);
507
888
  const doPush = (targetPresId, targetBackendURL) =>
508
889
  replace
509
890
  ? replaceSlides({
510
891
  presId: targetPresId,
511
892
  base64,
512
893
  operationId,
894
+ sourceTagsBySlide,
513
895
  backendURL: targetBackendURL,
514
896
  })
515
897
  : insertSlides({
@@ -518,6 +900,7 @@ export async function buildAndPush({
518
900
  insertAt,
519
901
  sourceSlideIds: slideIds,
520
902
  operationId,
903
+ sourceTagsBySlide,
521
904
  backendURL: targetBackendURL,
522
905
  });
523
906
 
@@ -550,6 +933,7 @@ export async function buildAndPush({
550
933
  backendURL,
551
934
  fileName: renderEntry.fileName ?? renderEntry.title ?? null,
552
935
  brandId: renderEntry.brandId ?? null,
936
+ otherPresIds,
553
937
  onWait: (seconds) =>
554
938
  process.stderr.write(
555
939
  "task pane suspended — click the slidemind pane in PowerPoint to " +
@@ -557,10 +941,10 @@ export async function buildAndPush({
557
941
  ),
558
942
  });
559
943
  if (!live) {
560
- if (err?.code !== "pane_suspended" && err?.code !== "pane_timeout") {
561
- throw err;
562
- }
563
- // The pane never woke up — same recovery as no window at all.
944
+ // Nothing came back — a pane that never woke, or a window that closed for
945
+ // good. Either way the render is done and there is no one to hand it to,
946
+ // which is the same recovery as no window at all: keep the bytes, and let
947
+ // the caller print the wake-PowerPoint blocker instead of a relay error.
564
948
  return saveFallback(presId);
565
949
  }
566
950
  livePresId = live.presId;
@@ -586,16 +970,23 @@ export async function buildPptxFile({
586
970
  deckDir,
587
971
  presentationId = null,
588
972
  outPath = null,
973
+ force = false,
589
974
  onProgress = null,
590
975
  }) {
591
976
  if (!deckDir) throw new Error("buildPptxFile: deckDir is required");
592
977
  const deckRef = readDeckBundleRef(deckDir);
593
978
  const out = outPath || path.join(deckDir, `${path.basename(deckDir)}.pptx`);
594
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
+
595
986
  // Pane-free path. `--to <presId>` names a window explicitly and therefore
596
987
  // keeps the registry path.
597
988
  if (!presentationId && deckRef?.presFP) {
598
- const detached = await tryDetachedRender(deckDir, deckRef, onProgress);
989
+ const detached = await tryDetachedRender(deckDir, deckRef, onProgress, reconcile);
599
990
  if (detached) {
600
991
  fs.writeFileSync(out, Buffer.from(detached.result.base64, "base64"));
601
992
  return {
@@ -617,6 +1008,8 @@ export async function buildPptxFile({
617
1008
  deckDir,
618
1009
  entry,
619
1010
  onProgress,
1011
+ [],
1012
+ reconcile,
620
1013
  );
621
1014
  const presId = renderEntry.presId;
622
1015
  fs.writeFileSync(out, Buffer.from(result.base64, "base64"));