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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +37 -8
- package/README.md +32 -2
- package/bin/slidemind.mjs +535 -122
- package/install.ps1 +70 -0
- package/install.sh +65 -0
- package/package.json +3 -1
- package/skills/create-slides/SKILL.md +30 -6
- package/skills/search/SKILL.md +3 -1
- package/src/bridge/build-and-push.mjs +418 -38
- package/src/bridge/capture.mjs +12 -1
- package/src/bridge/client.mjs +344 -26
- package/src/bridge/config.mjs +193 -1
- package/src/bridge/graph-auth.mjs +865 -0
- package/src/bridge/http-json.mjs +15 -6
- package/src/bridge/secrets.mjs +75 -0
- package/src/bridge/setup-claim.mjs +6 -0
- package/src/builders/build.mjs +3 -1
- package/src/builders/gate-server.mjs +156 -0
- package/src/builders/gate-session.js +355 -0
- package/src/builders/plan-overlay.js +507 -225
- package/src/builders/plan.mjs +169 -98
- package/src/builders/review-overlay.js +390 -302
- package/src/builders/review.mjs +136 -50
- package/src/lib/adopt.mjs +407 -0
- package/src/lib/conversions.mjs +263 -0
- package/src/lib/overwrite-guard.mjs +91 -0
- package/src/lib/reconcile-ack.mjs +46 -0
- package/src/lib/reconcile.mjs +306 -0
- package/src/lib/slide-guids.mjs +143 -0
- package/src/pptx/fill/engine.mjs +69 -1
- package/src/pptx/hidden-ole.mjs +22 -1
- package/src/pptx/slide-swap.mjs +23 -1
- package/src/pptx/tags.mjs +676 -0
|
@@ -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
|
-
{
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
186
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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"));
|
package/src/bridge/capture.mjs
CHANGED
|
@@ -27,8 +27,19 @@ const LAUNCH_ARGS = [
|
|
|
27
27
|
"--no-default-browser-check",
|
|
28
28
|
];
|
|
29
29
|
|
|
30
|
-
/**
|
|
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
|
}
|