slidemind 0.1.5 → 0.1.7
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 -7
- package/CLAUDE.md +22 -14
- package/README.md +33 -15
- package/bin/slidemind.mjs +1124 -34
- package/package.json +2 -5
- package/skills/create-slides/SKILL.md +95 -47
- package/skills/search/SKILL.md +29 -26
- package/src/bridge/build-and-push.mjs +353 -66
- package/src/bridge/bundle.mjs +49 -7
- package/src/bridge/capture.mjs +2 -2
- package/src/bridge/client.mjs +572 -43
- package/src/bridge/engine-download.mjs +89 -0
- package/src/{mcp → bridge}/recolor.mjs +10 -9
- package/src/builders/build.mjs +209 -13
- package/src/builders/lint.mjs +2 -2
- package/src/builders/review-overlay.js +22 -7
- package/src/builders/review.mjs +8 -15
- package/src/installers/claude.mjs +10 -17
- package/src/installers/codex.mjs +41 -36
- package/src/installers/opencode.mjs +10 -18
- package/src/lib/reused-slides.mjs +2 -4
- package/src/mcp/server.mjs +0 -810
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
// Render a deck server-side via the bridge, then push the bytes into a running
|
|
2
|
-
// PowerPoint window (or write them to a file).
|
|
2
|
+
// PowerPoint window (or write them to a file). Also hosts `viewDraft` — the
|
|
3
|
+
// push-free, .pptx-free draft preview (the CLI analog of the persona's
|
|
4
|
+
// `see_draft`): it runs the shipped capture engine locally and returns the
|
|
5
|
+
// per-slide preview PNGs it already produces, so the agent can look before the
|
|
6
|
+
// `review` gate.
|
|
3
7
|
//
|
|
4
8
|
// The CLI ships ZERO exporter code: the authored slide JSX + deck-local assets
|
|
5
|
-
// are POSTed to the add-in's
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
9
|
+
// are POSTed to the add-in's render routes, which render them against the
|
|
10
|
+
// deck's corporate design using the SAME OOXML exporter the in-app design
|
|
11
|
+
// persona uses. The returned base64 .pptx is then inserted / replaced (push)
|
|
12
|
+
// or written to disk (build-pptx). A BOUND deck renders pane-free over the
|
|
13
|
+
// brand routes (PowerPoint backgrounded or closed); only the INSERT half of a
|
|
14
|
+
// push, and unbound decks (whose brandId must come from the pane registry),
|
|
15
|
+
// still need a registered PowerPoint window.
|
|
10
16
|
|
|
11
17
|
import fs from "node:fs";
|
|
12
18
|
import path from "node:path";
|
|
@@ -18,6 +24,7 @@ import {
|
|
|
18
24
|
renderPptx,
|
|
19
25
|
replaceSlides,
|
|
20
26
|
requireHost,
|
|
27
|
+
requireRenderHost,
|
|
21
28
|
translateDeck,
|
|
22
29
|
waitForPaneReady,
|
|
23
30
|
} from "./client.mjs";
|
|
@@ -89,8 +96,12 @@ function gatherDeckAssets(deckDir) {
|
|
|
89
96
|
// ref rewritten, and the render retried once against the current design.
|
|
90
97
|
export async function renderDeckToBase64(
|
|
91
98
|
deckDir,
|
|
92
|
-
{ presId, backendURL = null, onProgress = null },
|
|
99
|
+
{ presId = null, brandId = null, backendURL = null, onProgress = null },
|
|
93
100
|
) {
|
|
101
|
+
if (!presId && !brandId) {
|
|
102
|
+
throw new Error("renderDeckToBase64: presId or brandId is required");
|
|
103
|
+
}
|
|
104
|
+
const deckName = path.basename(deckDir);
|
|
94
105
|
const { slides, nativeSlides, orderWarning } = gatherSlides(deckDir);
|
|
95
106
|
const hasJsx = Object.keys(slides).length > 0;
|
|
96
107
|
const deckAssets = gatherDeckAssets(deckDir);
|
|
@@ -99,9 +110,10 @@ export async function renderDeckToBase64(
|
|
|
99
110
|
// silently no-ops), the local HTML/editor preview can't resolve the deck's
|
|
100
111
|
// design files, AND local capture has no engine/bundle to read. `download-
|
|
101
112
|
// bundle <deck>` normally writes this; here we self-heal so `push`/`build-pptx`
|
|
102
|
-
// work even when it wasn't run explicitly.
|
|
113
|
+
// work even when it wasn't run explicitly. (The brand path implies a bound
|
|
114
|
+
// deck — its brandId came from the ref — so this only fires with a presId.)
|
|
103
115
|
let ref = readDeckBundleRef(deckDir);
|
|
104
|
-
if (!ref?.etag) {
|
|
116
|
+
if (!ref?.etag && presId) {
|
|
105
117
|
try {
|
|
106
118
|
const mat = await materializeBundle(presId, { backendURL });
|
|
107
119
|
writeDeckBundleRef(deckDir, { presFP: mat.presFP, etag: mat.etag });
|
|
@@ -125,6 +137,7 @@ export async function renderDeckToBase64(
|
|
|
125
137
|
// .pptx slides ride alongside on either path so the server splices them by
|
|
126
138
|
// play-order filename.
|
|
127
139
|
const attemptRender = async (etag) => {
|
|
140
|
+
const target = { presId, brandId, deckName, backendURL };
|
|
128
141
|
if (useLocalCapture) {
|
|
129
142
|
let captured;
|
|
130
143
|
try {
|
|
@@ -135,13 +148,13 @@ export async function renderDeckToBase64(
|
|
|
135
148
|
"falling back to the server render\n",
|
|
136
149
|
);
|
|
137
150
|
useLocalCapture = false;
|
|
138
|
-
return renderPptx({
|
|
151
|
+
return renderPptx({ ...target, slides, deckAssets, nativeSlides, etag, onProgress });
|
|
139
152
|
}
|
|
140
153
|
const n = Array.isArray(captured?.slides) ? captured.slides.length : 0;
|
|
141
154
|
onProgress?.({ done: n, total: n, stage: "assembling" });
|
|
142
|
-
return translateDeck({
|
|
155
|
+
return translateDeck({ ...target, captured, nativeSlides, etag });
|
|
143
156
|
}
|
|
144
|
-
return renderPptx({
|
|
157
|
+
return renderPptx({ ...target, slides, deckAssets, nativeSlides, etag, onProgress });
|
|
145
158
|
};
|
|
146
159
|
|
|
147
160
|
let result;
|
|
@@ -153,15 +166,18 @@ export async function renderDeckToBase64(
|
|
|
153
166
|
// design truth lives in the add-in, so the right move is forward: refresh
|
|
154
167
|
// the local bundle (so previews/lint/capture-engine match), re-bind the
|
|
155
168
|
// deck, and render once against the CURRENT design. A second stale (CD
|
|
156
|
-
// changed again mid-flight) propagates.
|
|
169
|
+
// changed again mid-flight) propagates. The refresh rides the SAME target
|
|
170
|
+
// the render used — the brand route stays pane-free end to end.
|
|
157
171
|
process.stderr.write(
|
|
158
172
|
"corporate design changed since this deck was authored — refreshing the " +
|
|
159
173
|
"bundle and retrying the render against the current design…\n",
|
|
160
174
|
);
|
|
161
|
-
const mat =
|
|
162
|
-
backendURL,
|
|
163
|
-
|
|
164
|
-
|
|
175
|
+
const mat = brandId
|
|
176
|
+
? await materializeBundle(null, { backendURL, brandId })
|
|
177
|
+
: await materializeBundle(presId, {
|
|
178
|
+
backendURL,
|
|
179
|
+
preferBrandId: ref?.presFP ?? null,
|
|
180
|
+
});
|
|
165
181
|
writeDeckBundleRef(deckDir, { presFP: mat.presFP, etag: mat.etag });
|
|
166
182
|
// The bundle dir is keyed by presFP, so a refreshed capture engine landed
|
|
167
183
|
// in place — re-evaluate in case it appeared (or vanished) on this sync.
|
|
@@ -177,6 +193,220 @@ export async function renderDeckToBase64(
|
|
|
177
193
|
return result; // { ok, base64, slideCount, warnings, skippedSlides }
|
|
178
194
|
}
|
|
179
195
|
|
|
196
|
+
// Map ONE captured slide to a draft-view entry. The capture engine produces a
|
|
197
|
+
// `previewDataUrl` (the 1280×720 stage PNG) only for FREEFORM slides that go
|
|
198
|
+
// through the Chrome box capture — purpose-built (Mode A) layouts have no
|
|
199
|
+
// freeform region and so carry no preview, exactly like the add-in's see_draft.
|
|
200
|
+
// Degraded renders still yield a flat raster the agent can look at; lint/parse
|
|
201
|
+
// failures surface as a status line so the agent is never blind.
|
|
202
|
+
function mapCapturedSlide(fileName, cap, warnings) {
|
|
203
|
+
if (!cap) {
|
|
204
|
+
return {
|
|
205
|
+
fileName,
|
|
206
|
+
status: "missing",
|
|
207
|
+
note: "the capture engine produced no result for this slide",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
for (const w of cap.lint?.warnLines ?? []) warnings.push(`${fileName}: ${w}`);
|
|
211
|
+
for (const w of cap.render?.warnings ?? []) warnings.push(`${fileName}: ${w}`);
|
|
212
|
+
|
|
213
|
+
if (cap.previewDataUrl) {
|
|
214
|
+
return { fileName, status: "ok", dataUri: cap.previewDataUrl };
|
|
215
|
+
}
|
|
216
|
+
// A degraded render is a non-editable raster; push/build-pptx will REJECT it.
|
|
217
|
+
// Attach it anyway (so the agent isn't blind) but flag the named cause.
|
|
218
|
+
if (cap.render?.degraded && cap.render?.fallbackPngBase64) {
|
|
219
|
+
return {
|
|
220
|
+
fileName,
|
|
221
|
+
status: "degraded",
|
|
222
|
+
dataUri: `data:image/png;base64,${cap.render.fallbackPngBase64}`,
|
|
223
|
+
note:
|
|
224
|
+
`did NOT render cleanly (degraded ${cap.render.degraded} raster, not editable shapes) — ` +
|
|
225
|
+
`push/build-pptx will reject it as-is. Fix the named cause in the JSX: ` +
|
|
226
|
+
(cap.render.errorText ?? "unknown render failure"),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (cap.lint?.errorMessage) {
|
|
230
|
+
return { fileName, status: "failed", note: cap.lint.errorMessage };
|
|
231
|
+
}
|
|
232
|
+
if (cap.captureError) {
|
|
233
|
+
return { fileName, status: "failed", note: cap.captureError };
|
|
234
|
+
}
|
|
235
|
+
// No preview and no error: a purpose-built (Mode A) layout has no freeform
|
|
236
|
+
// region to capture — there is simply nothing to draft-preview for it.
|
|
237
|
+
return {
|
|
238
|
+
fileName,
|
|
239
|
+
status: "no-preview",
|
|
240
|
+
note: "no preview rendered — a purpose-built layout has no freeform region to capture (use slidemind review for a full-deck look)",
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Render a deck's authored slides to per-slide preview PNGs WITHOUT pushing,
|
|
245
|
+
// translating, or assembling a .pptx — the CLI analog of the add-in persona's
|
|
246
|
+
// `see_draft`. It runs the add-in's shipped capture engine on THIS machine's
|
|
247
|
+
// Chrome (the same engine the local-capture render path uses) and returns the
|
|
248
|
+
// `previewDataUrl` it already produces, so the agent can LOOK at its slides and
|
|
249
|
+
// self-correct before the user ever sees them at the `review` gate.
|
|
250
|
+
//
|
|
251
|
+
// Host-free once the bundle is materialized: the capture runs locally against
|
|
252
|
+
// the cached bundle, so no open PowerPoint is needed. A host is required ONLY
|
|
253
|
+
// when the bundle (and its capture engine) isn't cached yet — then we fetch it
|
|
254
|
+
// like `download-bundle` does. Returns { slides: [{fileName, dataUri?, status,
|
|
255
|
+
// note?}], warnings: [] }.
|
|
256
|
+
export async function viewDraft(
|
|
257
|
+
deckDir,
|
|
258
|
+
{ presentationId = null, onProgress = null } = {},
|
|
259
|
+
) {
|
|
260
|
+
if (!deckDir) throw new Error("viewDraft: deckDir is required");
|
|
261
|
+
const slidesDir = path.join(deckDir, "slides");
|
|
262
|
+
if (!fs.existsSync(slidesDir) || !fs.statSync(slidesDir).isDirectory()) {
|
|
263
|
+
throw new Error(`Expected 'slides/' folder in ${deckDir}`);
|
|
264
|
+
}
|
|
265
|
+
// Authored JSX + reused native .pptx share one play-order namespace, exactly
|
|
266
|
+
// as the render path sees them — walk them in that order so the draft view
|
|
267
|
+
// lines up with the deck the user will get.
|
|
268
|
+
const all = sortSlideFilenames(
|
|
269
|
+
fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx")),
|
|
270
|
+
);
|
|
271
|
+
if (all.length === 0) {
|
|
272
|
+
throw new Error(`No .jsx or .pptx slides under ${slidesDir}`);
|
|
273
|
+
}
|
|
274
|
+
const hasJsx = all.some((f) => f.endsWith(".jsx"));
|
|
275
|
+
|
|
276
|
+
// Resolve the materialized bundle. Local capture needs the bundle's capture
|
|
277
|
+
// engine; once it's cached this whole call is offline. Only reach for a host
|
|
278
|
+
// (to fetch the bundle) when there are JSX slides and no usable cached engine.
|
|
279
|
+
let ref = readDeckBundleRef(deckDir);
|
|
280
|
+
let bundleDir = resolveBundleDirForDeck(deckDir);
|
|
281
|
+
if (hasJsx && (!bundleDir || !bundleHasCaptureEngine(bundleDir))) {
|
|
282
|
+
const registry = await requireRenderHost();
|
|
283
|
+
const entry = pickPresentationEntry(registry, presentationId, {
|
|
284
|
+
preferBrandId: ref?.presFP ?? null,
|
|
285
|
+
});
|
|
286
|
+
const mat = await materializeBundle(entry.presId, {
|
|
287
|
+
backendURL: entry.backendURL ?? null,
|
|
288
|
+
preferBrandId: ref?.presFP ?? null,
|
|
289
|
+
});
|
|
290
|
+
writeDeckBundleRef(deckDir, { presFP: mat.presFP, etag: mat.etag });
|
|
291
|
+
bundleDir = resolveBundleDirForDeck(deckDir);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const warnings = [];
|
|
295
|
+
|
|
296
|
+
// A native-only deck (just reused .pptx) has no authored JSX to draft-preview.
|
|
297
|
+
if (!hasJsx) {
|
|
298
|
+
return {
|
|
299
|
+
slides: all.map((fileName) => ({
|
|
300
|
+
fileName,
|
|
301
|
+
status: "native",
|
|
302
|
+
note: "reused native slide — not authored JSX, no draft preview rendered",
|
|
303
|
+
})),
|
|
304
|
+
warnings,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (!bundleDir || !bundleHasCaptureEngine(bundleDir)) {
|
|
309
|
+
throw new Error(
|
|
310
|
+
"draft preview needs the corporate design's capture engine, which ships in the " +
|
|
311
|
+
"materialized bundle. Run `slidemind download-bundle <deck>` against an open " +
|
|
312
|
+
"PowerPoint host once, then retry — or use `slidemind build <deck>` for a " +
|
|
313
|
+
"host-free HTML preview.",
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let captured;
|
|
318
|
+
try {
|
|
319
|
+
captured = await captureDeckLocally({ deckDir, bundleDir, onProgress });
|
|
320
|
+
} catch (err) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`local draft capture failed (${err instanceof Error ? err.message : String(err)}). ` +
|
|
323
|
+
"It runs a local headless Chrome; if Chrome can't launch here, use " +
|
|
324
|
+
"`slidemind build <deck>` for an HTML preview instead.",
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const byFile = new Map((captured?.slides ?? []).map((s) => [s.fileName, s]));
|
|
329
|
+
const slides = all.map((fileName) =>
|
|
330
|
+
fileName.endsWith(".pptx")
|
|
331
|
+
? {
|
|
332
|
+
fileName,
|
|
333
|
+
status: "native",
|
|
334
|
+
note: "reused native slide — not authored JSX, no draft preview rendered",
|
|
335
|
+
}
|
|
336
|
+
: mapCapturedSlide(fileName, byFile.get(fileName), warnings),
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
return { slides, warnings };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// A brand-route miss is definitive for the pane-free path but NOT for the
|
|
343
|
+
// deck: old add-in (no brandRoutes), legacy non-brand presFP in the ref,
|
|
344
|
+
// grant/tenant miss, or a brand with no bundle — every one of these still
|
|
345
|
+
// works through the pane flow, which needs no grant and re-binds the ref.
|
|
346
|
+
function isBrandRouteMiss(err) {
|
|
347
|
+
return (
|
|
348
|
+
err?.code === "brand_route_unavailable" ||
|
|
349
|
+
err?.code === "unknown_brand" ||
|
|
350
|
+
err?.code === "no_active_cd" ||
|
|
351
|
+
err?.status === 404
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Pane-free render for a BOUND deck: sync the bundle over the brand route
|
|
356
|
+
// (proves the backend + grant and refreshes the capture engine/etag exactly
|
|
357
|
+
// like `ready`), re-bind, then render by brandId — no PowerPoint window
|
|
358
|
+
// involved. Returns null when the brand route can't serve this deck (caller
|
|
359
|
+
// falls back to the pane flow); render failures (lint, stale-after-retry)
|
|
360
|
+
// propagate, they are real.
|
|
361
|
+
async function tryDetachedRender(deckDir, deckRef, onProgress) {
|
|
362
|
+
const brandId = deckRef.presFP;
|
|
363
|
+
let backendURL;
|
|
364
|
+
try {
|
|
365
|
+
const mat = await materializeBundle(null, { brandId });
|
|
366
|
+
writeDeckBundleRef(deckDir, { presFP: mat.presFP, etag: mat.etag });
|
|
367
|
+
backendURL = mat.backendURL ?? null;
|
|
368
|
+
if (!backendURL) return null;
|
|
369
|
+
} catch (err) {
|
|
370
|
+
if (isBrandRouteMiss(err)) return null;
|
|
371
|
+
throw err;
|
|
372
|
+
}
|
|
373
|
+
const result = await renderDeckToBase64(deckDir, {
|
|
374
|
+
brandId,
|
|
375
|
+
backendURL,
|
|
376
|
+
onProgress,
|
|
377
|
+
});
|
|
378
|
+
return { result, backendURL };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function renderWithRetarget(deckDir, entry, onProgress) {
|
|
382
|
+
const run = (target) =>
|
|
383
|
+
renderDeckToBase64(deckDir, {
|
|
384
|
+
presId: target.presId,
|
|
385
|
+
backendURL: target.backendURL ?? null,
|
|
386
|
+
onProgress,
|
|
387
|
+
});
|
|
388
|
+
try {
|
|
389
|
+
return { entry, result: await run(entry) };
|
|
390
|
+
} catch (err) {
|
|
391
|
+
const recoverable =
|
|
392
|
+
err?.status === 404 || err?.code === "unknown_presentation";
|
|
393
|
+
if (!recoverable) throw err;
|
|
394
|
+
const live = await waitForPaneReady(entry.presId, {
|
|
395
|
+
backendURL: entry.backendURL ?? null,
|
|
396
|
+
fileName: entry.fileName ?? entry.title ?? null,
|
|
397
|
+
brandId: entry.brandId ?? null,
|
|
398
|
+
timeoutMs: 15000,
|
|
399
|
+
onWait: (seconds) =>
|
|
400
|
+
process.stderr.write(
|
|
401
|
+
"render target changed — waiting for the slidemind pane to " +
|
|
402
|
+
`re-register (up to ${seconds}s)…\n`,
|
|
403
|
+
),
|
|
404
|
+
});
|
|
405
|
+
if (!live) throw err;
|
|
406
|
+
return { entry: live, result: await run(live) };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
180
410
|
export async function buildAndPush({
|
|
181
411
|
deckDir,
|
|
182
412
|
presentationId = null,
|
|
@@ -200,23 +430,66 @@ export async function buildAndPush({
|
|
|
200
430
|
}
|
|
201
431
|
|
|
202
432
|
const deckRef = readDeckBundleRef(deckDir);
|
|
203
|
-
const registry = await requireHost();
|
|
204
|
-
const entry = pickPresentationEntry(registry, presentationId, {
|
|
205
|
-
preferBrandId: deckRef?.presFP ?? null,
|
|
206
|
-
});
|
|
207
|
-
const presId = entry.presId;
|
|
208
|
-
const backendURL = entry.backendURL ?? null;
|
|
209
433
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
434
|
+
// --- Render half. Pane-free for a bound deck (the window may be closed or
|
|
435
|
+
// backgrounded during the render); the pane matters only for the insert. ---
|
|
436
|
+
let result = null;
|
|
437
|
+
let renderEntry = null;
|
|
438
|
+
if (!presentationId && deckRef?.presFP) {
|
|
439
|
+
const detached = await tryDetachedRender(deckDir, deckRef, onProgress);
|
|
440
|
+
if (detached) result = detached.result;
|
|
441
|
+
}
|
|
442
|
+
if (!result) {
|
|
443
|
+
const registry = await requireRenderHost();
|
|
444
|
+
const entry = pickPresentationEntry(registry, presentationId, {
|
|
445
|
+
preferBrandId: deckRef?.presFP ?? null,
|
|
446
|
+
});
|
|
447
|
+
const rendered = await renderWithRetarget(deckDir, entry, onProgress);
|
|
448
|
+
renderEntry = rendered.entry;
|
|
449
|
+
result = rendered.result;
|
|
450
|
+
}
|
|
451
|
+
|
|
215
452
|
const base64 = result.base64;
|
|
216
453
|
const bytes = Buffer.byteLength(base64, "base64");
|
|
217
454
|
const mode = replace ? "replace" : "insert";
|
|
218
455
|
const operationId = `push-${randomUUID()}`;
|
|
219
456
|
|
|
457
|
+
// The render survived without a window; don't lose it when the insert can't
|
|
458
|
+
// find one — save the .pptx next to the deck and tell the caller (bin prints
|
|
459
|
+
// the recovery instructions).
|
|
460
|
+
const saveFallback = (presIdForResult) => {
|
|
461
|
+
const fallbackFile = path.join(deckDir, `${path.basename(deckDir)}.pptx`);
|
|
462
|
+
fs.writeFileSync(fallbackFile, Buffer.from(base64, "base64"));
|
|
463
|
+
return {
|
|
464
|
+
presId: presIdForResult,
|
|
465
|
+
mode,
|
|
466
|
+
bytes,
|
|
467
|
+
operationId,
|
|
468
|
+
response: null,
|
|
469
|
+
warnings: result.warnings ?? [],
|
|
470
|
+
skippedSlides: result.skippedSlides ?? [],
|
|
471
|
+
paneSuspended: true,
|
|
472
|
+
fallbackFile,
|
|
473
|
+
};
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
// --- Insert half: needs a registered window. On the pane-free render path
|
|
477
|
+
// none was resolved yet; if none exists at all, the deck is saved instead of
|
|
478
|
+
// thrown away. ---
|
|
479
|
+
if (!renderEntry) {
|
|
480
|
+
let registry;
|
|
481
|
+
try {
|
|
482
|
+
registry = await requireHost();
|
|
483
|
+
} catch {
|
|
484
|
+
return saveFallback(null);
|
|
485
|
+
}
|
|
486
|
+
renderEntry = pickPresentationEntry(registry, presentationId, {
|
|
487
|
+
preferBrandId: deckRef?.presFP ?? null,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
const presId = renderEntry.presId;
|
|
491
|
+
const backendURL = renderEntry.backendURL ?? null;
|
|
492
|
+
|
|
220
493
|
// The SAME operationId on every attempt: the pane keeps an LRU of applied
|
|
221
494
|
// operationIds and drops duplicates, so a retry after an ambiguous failure
|
|
222
495
|
// can never double-insert.
|
|
@@ -242,21 +515,30 @@ export async function buildAndPush({
|
|
|
242
515
|
try {
|
|
243
516
|
resp = await doPush(presId, backendURL);
|
|
244
517
|
} catch (err) {
|
|
245
|
-
//
|
|
518
|
+
// Three recoverable shapes:
|
|
246
519
|
// - pane_suspended (503): the server waited ~25s for a reconnect and gave
|
|
247
|
-
// up — the pane needs the user (foreground
|
|
520
|
+
// up — nothing was delivered; the pane needs the user (foreground
|
|
521
|
+
// PowerPoint / click the pane).
|
|
522
|
+
// - pane_timeout (504): the pane looked connected but never acked within
|
|
523
|
+
// the server's 60s window (just-frozen webview). Delivery is ambiguous,
|
|
524
|
+
// but the retry below re-sends the SAME operationId, so the pane-side
|
|
525
|
+
// dedup LRU makes it safe either way. The server marked the pane
|
|
526
|
+
// suspended on the way out, so the same client-side wake wait applies.
|
|
248
527
|
// - 404: the presId vanished from the registry — a pane reconnect mints a
|
|
249
528
|
// NEW presId for the same window (presId churn).
|
|
250
529
|
// Wait client-side for the pane (rematching by fileName+brandId covers the
|
|
251
|
-
// churn), then retry ONCE. A `bridge_timeout` is NOT caught:
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
const recoverable =
|
|
530
|
+
// churn), then retry ONCE. A client-side `bridge_timeout` is NOT caught:
|
|
531
|
+
// there the server-side outcome is entirely unknown (the request may still
|
|
532
|
+
// be executing), so a re-send is deliberately left to the user.
|
|
533
|
+
const recoverable =
|
|
534
|
+
err?.code === "pane_suspended" ||
|
|
535
|
+
err?.code === "pane_timeout" ||
|
|
536
|
+
err?.status === 404;
|
|
255
537
|
if (!recoverable) throw err;
|
|
256
538
|
const live = await waitForPaneReady(presId, {
|
|
257
539
|
backendURL,
|
|
258
|
-
fileName:
|
|
259
|
-
brandId:
|
|
540
|
+
fileName: renderEntry.fileName ?? renderEntry.title ?? null,
|
|
541
|
+
brandId: renderEntry.brandId ?? null,
|
|
260
542
|
onWait: (seconds) =>
|
|
261
543
|
process.stderr.write(
|
|
262
544
|
"task pane suspended — click the slidemind pane in PowerPoint to " +
|
|
@@ -264,25 +546,11 @@ export async function buildAndPush({
|
|
|
264
546
|
),
|
|
265
547
|
});
|
|
266
548
|
if (!live) {
|
|
267
|
-
if (err?.code !== "pane_suspended"
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
`${path.basename(deckDir)}.pptx`,
|
|
273
|
-
);
|
|
274
|
-
fs.writeFileSync(fallbackFile, Buffer.from(base64, "base64"));
|
|
275
|
-
return {
|
|
276
|
-
presId,
|
|
277
|
-
mode,
|
|
278
|
-
bytes,
|
|
279
|
-
operationId,
|
|
280
|
-
response: null,
|
|
281
|
-
warnings: result.warnings ?? [],
|
|
282
|
-
skippedSlides: result.skippedSlides ?? [],
|
|
283
|
-
paneSuspended: true,
|
|
284
|
-
fallbackFile,
|
|
285
|
-
};
|
|
549
|
+
if (err?.code !== "pane_suspended" && err?.code !== "pane_timeout") {
|
|
550
|
+
throw err;
|
|
551
|
+
}
|
|
552
|
+
// The pane never woke up — same recovery as no window at all.
|
|
553
|
+
return saveFallback(presId);
|
|
286
554
|
}
|
|
287
555
|
livePresId = live.presId;
|
|
288
556
|
resp = await doPush(live.presId, live.backendURL ?? backendURL);
|
|
@@ -298,9 +566,11 @@ export async function buildAndPush({
|
|
|
298
566
|
};
|
|
299
567
|
}
|
|
300
568
|
|
|
301
|
-
// Render a deck to a .pptx file (the `slidemind build-pptx` path).
|
|
302
|
-
//
|
|
303
|
-
//
|
|
569
|
+
// Render a deck to a .pptx file (the `slidemind build-pptx` path). For a BOUND
|
|
570
|
+
// deck against a backend serving brand routes this is fully pane-free — no
|
|
571
|
+
// PowerPoint window needed at all (backgrounded or closed). Otherwise it needs
|
|
572
|
+
// an open host because the brandId must come from the pane registry — but
|
|
573
|
+
// still not an awake pane: the render runs in the add-in's cloud backend.
|
|
304
574
|
export async function buildPptxFile({
|
|
305
575
|
deckDir,
|
|
306
576
|
presentationId = null,
|
|
@@ -309,18 +579,35 @@ export async function buildPptxFile({
|
|
|
309
579
|
}) {
|
|
310
580
|
if (!deckDir) throw new Error("buildPptxFile: deckDir is required");
|
|
311
581
|
const deckRef = readDeckBundleRef(deckDir);
|
|
312
|
-
const
|
|
582
|
+
const out = outPath || path.join(deckDir, `${path.basename(deckDir)}.pptx`);
|
|
583
|
+
|
|
584
|
+
// Pane-free path. `--to <presId>` names a window explicitly and therefore
|
|
585
|
+
// keeps the registry path.
|
|
586
|
+
if (!presentationId && deckRef?.presFP) {
|
|
587
|
+
const detached = await tryDetachedRender(deckDir, deckRef, onProgress);
|
|
588
|
+
if (detached) {
|
|
589
|
+
fs.writeFileSync(out, Buffer.from(detached.result.base64, "base64"));
|
|
590
|
+
return {
|
|
591
|
+
outPath: out,
|
|
592
|
+
presId: null,
|
|
593
|
+
windowless: true,
|
|
594
|
+
slideCount: detached.result.slideCount ?? null,
|
|
595
|
+
warnings: detached.result.warnings ?? [],
|
|
596
|
+
skippedSlides: detached.result.skippedSlides ?? [],
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const registry = await requireRenderHost();
|
|
313
602
|
const entry = pickPresentationEntry(registry, presentationId, {
|
|
314
603
|
preferBrandId: deckRef?.presFP ?? null,
|
|
315
604
|
});
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
backendURL: entry.backendURL ?? null,
|
|
605
|
+
const { entry: renderEntry, result } = await renderWithRetarget(
|
|
606
|
+
deckDir,
|
|
607
|
+
entry,
|
|
320
608
|
onProgress,
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
outPath || path.join(deckDir, `${path.basename(deckDir)}.pptx`);
|
|
609
|
+
);
|
|
610
|
+
const presId = renderEntry.presId;
|
|
324
611
|
fs.writeFileSync(out, Buffer.from(result.base64, "base64"));
|
|
325
612
|
return {
|
|
326
613
|
outPath: out,
|
package/src/bridge/bundle.mjs
CHANGED
|
@@ -45,7 +45,11 @@ import path from "node:path";
|
|
|
45
45
|
import http from "node:http";
|
|
46
46
|
import https from "node:https";
|
|
47
47
|
|
|
48
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
getDesignBundle,
|
|
50
|
+
getDesignBundleForBrand,
|
|
51
|
+
getDesignBundleForBrandAnyBackend,
|
|
52
|
+
} from "./client.mjs";
|
|
49
53
|
|
|
50
54
|
// Highest bundle wire-contract version this CLI understands. The add-in stamps
|
|
51
55
|
// `schemaVersion` on every bundle; a higher value means the CLI is too old.
|
|
@@ -65,6 +69,21 @@ export function bundleDirForPresFP(presFP) {
|
|
|
65
69
|
return path.join(bundleCacheRoot(), sanitizeKey(presFP));
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
// Zero-GET lookup of a materialized bundle's meta by presFP: reads the on-disk
|
|
73
|
+
// bundle.json materializeBundle wrote and returns the parsed record, or null
|
|
74
|
+
// when that CD was never materialized here / the file is unreadable. `new`'s
|
|
75
|
+
// stateless auto-bind uses this to confirm a resolved design's bundle already
|
|
76
|
+
// exists locally BEFORE writing a deck ref — no network, no throw.
|
|
77
|
+
export function readMaterializedBundleMeta(presFP) {
|
|
78
|
+
if (!presFP) return null;
|
|
79
|
+
try {
|
|
80
|
+
const metaPath = path.join(bundleDirForPresFP(presFP), "bundle.json");
|
|
81
|
+
return JSON.parse(fs.readFileSync(metaPath, "utf8"));
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
68
87
|
// Wipe the whole materialized bundle cache so the next build/push/download
|
|
69
88
|
// re-fetches every design bundle fresh from the add-in. `slidemind init` calls
|
|
70
89
|
// this: init is the deliberate (re)setup point — and `slidemind update`
|
|
@@ -220,13 +239,31 @@ function buildReusableImagesManifest(extractedImages) {
|
|
|
220
239
|
// a bundle re-produce (engines, image-source guidelines, sample curation) and
|
|
221
240
|
// deployment-owned content (workflow refs, the lint engine) can never go
|
|
222
241
|
// stale here. `force` re-downloads binaries even when their etags match.
|
|
223
|
-
//
|
|
224
|
-
//
|
|
242
|
+
// Target: `presId` (pane registry), or `brandId` in options for the pane-free
|
|
243
|
+
// brand route (presId may then be null; backendURL null = fan-out).
|
|
244
|
+
// Returns { dir, presFP, etag, version, backendURL, downloaded, pruned,
|
|
245
|
+
// engineUpdated, warnings }.
|
|
225
246
|
export async function materializeBundle(
|
|
226
247
|
presId,
|
|
227
|
-
{ force = false, backendURL = null, preferBrandId = null } = {},
|
|
248
|
+
{ force = false, backendURL = null, preferBrandId = null, brandId = null } = {},
|
|
228
249
|
) {
|
|
229
|
-
|
|
250
|
+
// Two fetch paths for the same wire: by presId through the pane registry
|
|
251
|
+
// (needs a registered window), or pane-free by brandId through the brand
|
|
252
|
+
// route (bound deck, PowerPoint optional). Everything below operates on the
|
|
253
|
+
// wire and is identical for both.
|
|
254
|
+
let wire;
|
|
255
|
+
let wireBackendURL = backendURL;
|
|
256
|
+
if (brandId) {
|
|
257
|
+
if (backendURL) {
|
|
258
|
+
wire = await getDesignBundleForBrand(brandId, { backendURL });
|
|
259
|
+
} else {
|
|
260
|
+
const resolved = await getDesignBundleForBrandAnyBackend(brandId);
|
|
261
|
+
wire = resolved.wire;
|
|
262
|
+
wireBackendURL = resolved.backendURL;
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
wire = await getDesignBundle(presId, { backendURL, preferBrandId });
|
|
266
|
+
}
|
|
230
267
|
if (!wire || !wire.presFP) {
|
|
231
268
|
throw new Error("design-bundle response is missing presFP");
|
|
232
269
|
}
|
|
@@ -445,6 +482,9 @@ export async function materializeBundle(
|
|
|
445
482
|
|
|
446
483
|
const meta = {
|
|
447
484
|
presFP,
|
|
485
|
+
// In the brand model presFP == brandId; recorded explicitly so consumers
|
|
486
|
+
// (and humans reading the cache) don't need to know that equivalence.
|
|
487
|
+
brandId: presFP,
|
|
448
488
|
version: wire.version ?? null,
|
|
449
489
|
etag: wire.etag ?? null,
|
|
450
490
|
scope: wire.scope ?? null,
|
|
@@ -483,6 +523,9 @@ export async function materializeBundle(
|
|
|
483
523
|
presFP,
|
|
484
524
|
etag: wire.etag ?? null,
|
|
485
525
|
version: wire.version ?? null,
|
|
526
|
+
// Which backend actually served the wire — meaningful on the brandId
|
|
527
|
+
// fan-out path, where the caller didn't know it up front.
|
|
528
|
+
backendURL: wireBackendURL,
|
|
486
529
|
downloaded,
|
|
487
530
|
pruned,
|
|
488
531
|
engineUpdated,
|
|
@@ -613,8 +656,7 @@ export function isImageEngine(engine) {
|
|
|
613
656
|
// `kind`. Returns null when the CD exposes no image engine (e.g. only a
|
|
614
657
|
// slide-search engine) — callers surface a "wire an image engine" message
|
|
615
658
|
// rather than falling back to an engine the bridge can't insert. Single source
|
|
616
|
-
// of truth for the heuristic
|
|
617
|
-
// and the MCP `slidemind_search_engine` tool.
|
|
659
|
+
// of truth for the heuristic used by the CLI dispatcher (`slidemind search`).
|
|
618
660
|
export function pickImageEngine(engines) {
|
|
619
661
|
const list = Array.isArray(engines) ? engines : [];
|
|
620
662
|
return list.find(isImageEngine) ?? null;
|
package/src/bridge/capture.mjs
CHANGED
|
@@ -171,8 +171,8 @@ export async function captureDeckLocally({ deckDir, bundleDir, onProgress }) {
|
|
|
171
171
|
}
|
|
172
172
|
const meta = readBundleMeta(bundleDir);
|
|
173
173
|
const version = meta.captureEngineVersion ?? "0";
|
|
174
|
-
// `?v=` busts Node's module cache so a
|
|
175
|
-
//
|
|
174
|
+
// `?v=` busts Node's module cache so a re-import picks up a refreshed engine
|
|
175
|
+
// at the same path after a bundle sync.
|
|
176
176
|
const engine = await import(
|
|
177
177
|
`${pathToFileURL(enginePath).href}?v=${encodeURIComponent(version)}`
|
|
178
178
|
);
|