slidemind 0.1.5 → 0.1.6
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.md +6 -2
- package/README.md +13 -5
- package/bin/slidemind.mjs +311 -6
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +28 -4
- package/skills/search/SKILL.md +6 -3
- package/src/bridge/build-and-push.mjs +195 -19
- package/src/bridge/client.mjs +204 -5
- package/src/installers/codex.mjs +96 -28
- package/src/mcp/server.mjs +153 -5
package/skills/search/SKILL.md
CHANGED
|
@@ -14,9 +14,10 @@ So this file owns only what is genuinely CLI-specific: the **host precondition**
|
|
|
14
14
|
This skill drives the `slidemind` CLI + its MCP server (`slidemind_list_open_presentations`, `slidemind_list_engines`, `slidemind_search_engine`, `slidemind_view_slide`, `slidemind_insert_slide`, `slidemind_add_slide_to_deck`, `slidemind_download_result`). The CLI reaches PowerPoint through the add-in's **cloud relay** — it doesn't run any local server. Patch what's missing yourself; the **pairing** step needs the user once (they hold the add-in's identity):
|
|
15
15
|
|
|
16
16
|
- **CLI missing** (`slidemind` not on `PATH`, or the `slidemind_*` MCP tools absent): `npm install -g slidemind`, then `slidemind init --for <claude|codex|opencode|all>` (registers the MCP server + (re)deploys this skill). The harness may need a restart for the `slidemind_*` tools to appear.
|
|
17
|
-
- **Not paired** (a command says *slidemind is not paired*): the one step you can't do for the user. Tell them: in the slidemind add-in
|
|
17
|
+
- **Not paired** (a command says *slidemind is not paired*): the one step you can't do for the user. Tell them: in the slidemind add-in open **Settings → General → Connect CLI**, press **Pair**, and run the shown one-time `slidemind pair <code>` command in this terminal (the code expires in ~10 min — re-press **Pair** for a fresh one). It's one-time per environment; `slidemind whoami` lists every paired environment.
|
|
18
|
+
- **Restart the add-in once after pairing** — the wizard never says this. If, right after pairing, `slidemind presentations` still doesn't list the window even though PowerPoint is open with the add-in and a corporate design active, ask the user to **restart the add-in** (close + reopen the slidemind task pane; if it still doesn't appear, reopen PowerPoint) so the freshly-paired pane registers on the relay. Then retry.
|
|
18
19
|
|
|
19
|
-
This is the same bootstrap as `slidemind-create-slides
|
|
20
|
+
This is the same bootstrap as `slidemind-create-slides` (whose **Pairing the CLI with the add-in** section has the full step-by-step + triage table); if you set it up there it's already set up here.
|
|
20
21
|
|
|
21
22
|
## Precondition gate (check this first, every time)
|
|
22
23
|
|
|
@@ -26,7 +27,7 @@ Engine search and slide reuse are **pane-touching, host-required** operations
|
|
|
26
27
|
2. **≥1 registered → you're set.** The engine catalog is **per-user, not bound to a window**, so search and discovery attach to **any** connected window automatically. **Never ask the user which window to search with**, and don't pass `--to`/`presentation_id` to search just to disambiguate — leave it off and let the CLI grab one. (A window only matters when you *insert a slide live*, and even then it's simply the window the search already ran on — see Reuse.)
|
|
27
28
|
3. **None registered → stop.** Tell the user: *open PowerPoint, load the slidemind add-in, and pick or create a corporate design, then retry.*
|
|
28
29
|
|
|
29
|
-
> **Empty list vs. not paired — different fixes.** An *empty* registry
|
|
30
|
+
> **Empty list vs. not paired — different fixes.** An *empty* registry with no PowerPoint connected means open it / enable Connect CLI. A *not paired* error means this machine has no session — pair once via **"Connect CLI"** (see Bootstrap). An *empty* registry **while** PowerPoint is open with the add-in **and** this machine is already paired usually means a just-paired pane that never registered — ask the user to **restart the add-in** (see Bootstrap), then retry. Three different problems; don't conflate them.
|
|
30
31
|
|
|
31
32
|
The pane-touching ops here (`search`, `view_slide`, `insert-slide`, `download-result`) wait for the pane themselves: the CLI waits up to ~2 min, prints one *"click the slidemind pane"* line when user help is genuinely needed, and retries safely. Relay that line once and keep waiting — don't re-plan or babysit.
|
|
32
33
|
|
|
@@ -49,6 +50,8 @@ A found slide can be reused two ways. Both extract the picked result(s) as nativ
|
|
|
49
50
|
|
|
50
51
|
> **The durable rule:** a slide folded into a deck with `add-slide` is **deck source-of-truth**. The render pipeline splices native `slides/*.pptx` into the deck **by play-order filename**, alongside any authored `.jsx`, so a reused slide **survives every `build-pptx` and `push` re-render** — it is never lost on re-push. Reorder it the same way you reorder JSX: rename its numeric prefix.
|
|
51
52
|
|
|
53
|
+
> **Building a `.pptx` file from found slides → one combined deck, always.** When the user wants the reused slides as a **`.pptx` file** (rather than a live insert), collect **every** picked slide into **one** deck and render **one** file — never one `.pptx` per slide and never a deck per slide. Fold all picks into the same deck — pass every selection in a single `add-slide` call (`--pick n1,n2,…`), repeating only to cover picks that came from different `searchId`s (always the same `--deck`) — then run `slidemind build-pptx <deck>` once. The render splices every native `slides/*.pptx` into a **single `decks/<slug>/<slug>.pptx`** in play-order, so all the found slides land in one file. That combined `.pptx` is the deliverable; hand back its path. (Need new authored slides interleaved with the reused ones in that same file? That's still one deck — hand off to `slidemind-create-slides` to add the JSX, then `build-pptx` the whole deck once.)
|
|
54
|
+
|
|
52
55
|
For brand **imagery** (icons/images, not whole slides), the reuse step is `slidemind_download_result` — it writes the asset into the deck's `assets/`. That mechanism (including the icon-recolor descriptor) is documented where it's used, in `slidemind-create-slides`.
|
|
53
56
|
|
|
54
57
|
## Working with slidemind-create-slides
|
|
@@ -1,5 +1,9 @@
|
|
|
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
9
|
// are POSTed to the add-in's `render-pptx` route, which renders them against
|
|
@@ -17,7 +21,7 @@ import {
|
|
|
17
21
|
pickPresentationEntry,
|
|
18
22
|
renderPptx,
|
|
19
23
|
replaceSlides,
|
|
20
|
-
|
|
24
|
+
requireRenderHost,
|
|
21
25
|
translateDeck,
|
|
22
26
|
waitForPaneReady,
|
|
23
27
|
} from "./client.mjs";
|
|
@@ -177,6 +181,181 @@ export async function renderDeckToBase64(
|
|
|
177
181
|
return result; // { ok, base64, slideCount, warnings, skippedSlides }
|
|
178
182
|
}
|
|
179
183
|
|
|
184
|
+
// Map ONE captured slide to a draft-view entry. The capture engine produces a
|
|
185
|
+
// `previewDataUrl` (the 1280×720 stage PNG) only for FREEFORM slides that go
|
|
186
|
+
// through the Chrome box capture — purpose-built (Mode A) layouts have no
|
|
187
|
+
// freeform region and so carry no preview, exactly like the add-in's see_draft.
|
|
188
|
+
// Degraded renders still yield a flat raster the agent can look at; lint/parse
|
|
189
|
+
// failures surface as a status line so the agent is never blind.
|
|
190
|
+
function mapCapturedSlide(fileName, cap, warnings) {
|
|
191
|
+
if (!cap) {
|
|
192
|
+
return {
|
|
193
|
+
fileName,
|
|
194
|
+
status: "missing",
|
|
195
|
+
note: "the capture engine produced no result for this slide",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
for (const w of cap.lint?.warnLines ?? []) warnings.push(`${fileName}: ${w}`);
|
|
199
|
+
for (const w of cap.render?.warnings ?? []) warnings.push(`${fileName}: ${w}`);
|
|
200
|
+
|
|
201
|
+
if (cap.previewDataUrl) {
|
|
202
|
+
return { fileName, status: "ok", dataUri: cap.previewDataUrl };
|
|
203
|
+
}
|
|
204
|
+
// A degraded render is a non-editable raster; push/build-pptx will REJECT it.
|
|
205
|
+
// Attach it anyway (so the agent isn't blind) but flag the named cause.
|
|
206
|
+
if (cap.render?.degraded && cap.render?.fallbackPngBase64) {
|
|
207
|
+
return {
|
|
208
|
+
fileName,
|
|
209
|
+
status: "degraded",
|
|
210
|
+
dataUri: `data:image/png;base64,${cap.render.fallbackPngBase64}`,
|
|
211
|
+
note:
|
|
212
|
+
`did NOT render cleanly (degraded ${cap.render.degraded} raster, not editable shapes) — ` +
|
|
213
|
+
`push/build-pptx will reject it as-is. Fix the named cause in the JSX: ` +
|
|
214
|
+
(cap.render.errorText ?? "unknown render failure"),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
if (cap.lint?.errorMessage) {
|
|
218
|
+
return { fileName, status: "failed", note: cap.lint.errorMessage };
|
|
219
|
+
}
|
|
220
|
+
if (cap.captureError) {
|
|
221
|
+
return { fileName, status: "failed", note: cap.captureError };
|
|
222
|
+
}
|
|
223
|
+
// No preview and no error: a purpose-built (Mode A) layout has no freeform
|
|
224
|
+
// region to capture — there is simply nothing to draft-preview for it.
|
|
225
|
+
return {
|
|
226
|
+
fileName,
|
|
227
|
+
status: "no-preview",
|
|
228
|
+
note: "no preview rendered — a purpose-built layout has no freeform region to capture (use slidemind review for a full-deck look)",
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Render a deck's authored slides to per-slide preview PNGs WITHOUT pushing,
|
|
233
|
+
// translating, or assembling a .pptx — the CLI analog of the add-in persona's
|
|
234
|
+
// `see_draft`. It runs the add-in's shipped capture engine on THIS machine's
|
|
235
|
+
// Chrome (the same engine the local-capture render path uses) and returns the
|
|
236
|
+
// `previewDataUrl` it already produces, so the agent can LOOK at its slides and
|
|
237
|
+
// self-correct before the user ever sees them at the `review` gate.
|
|
238
|
+
//
|
|
239
|
+
// Host-free once the bundle is materialized: the capture runs locally against
|
|
240
|
+
// the cached bundle, so no open PowerPoint is needed. A host is required ONLY
|
|
241
|
+
// when the bundle (and its capture engine) isn't cached yet — then we fetch it
|
|
242
|
+
// like `download-bundle` does. Returns { slides: [{fileName, dataUri?, status,
|
|
243
|
+
// note?}], warnings: [] }.
|
|
244
|
+
export async function viewDraft(
|
|
245
|
+
deckDir,
|
|
246
|
+
{ presentationId = null, onProgress = null } = {},
|
|
247
|
+
) {
|
|
248
|
+
if (!deckDir) throw new Error("viewDraft: deckDir is required");
|
|
249
|
+
const slidesDir = path.join(deckDir, "slides");
|
|
250
|
+
if (!fs.existsSync(slidesDir) || !fs.statSync(slidesDir).isDirectory()) {
|
|
251
|
+
throw new Error(`Expected 'slides/' folder in ${deckDir}`);
|
|
252
|
+
}
|
|
253
|
+
// Authored JSX + reused native .pptx share one play-order namespace, exactly
|
|
254
|
+
// as the render path sees them — walk them in that order so the draft view
|
|
255
|
+
// lines up with the deck the user will get.
|
|
256
|
+
const all = sortSlideFilenames(
|
|
257
|
+
fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx")),
|
|
258
|
+
);
|
|
259
|
+
if (all.length === 0) {
|
|
260
|
+
throw new Error(`No .jsx or .pptx slides under ${slidesDir}`);
|
|
261
|
+
}
|
|
262
|
+
const hasJsx = all.some((f) => f.endsWith(".jsx"));
|
|
263
|
+
|
|
264
|
+
// Resolve the materialized bundle. Local capture needs the bundle's capture
|
|
265
|
+
// engine; once it's cached this whole call is offline. Only reach for a host
|
|
266
|
+
// (to fetch the bundle) when there are JSX slides and no usable cached engine.
|
|
267
|
+
let ref = readDeckBundleRef(deckDir);
|
|
268
|
+
let bundleDir = resolveBundleDirForDeck(deckDir);
|
|
269
|
+
if (hasJsx && (!bundleDir || !bundleHasCaptureEngine(bundleDir))) {
|
|
270
|
+
const registry = await requireRenderHost();
|
|
271
|
+
const entry = pickPresentationEntry(registry, presentationId, {
|
|
272
|
+
preferBrandId: ref?.presFP ?? null,
|
|
273
|
+
});
|
|
274
|
+
const mat = await materializeBundle(entry.presId, {
|
|
275
|
+
backendURL: entry.backendURL ?? null,
|
|
276
|
+
preferBrandId: ref?.presFP ?? null,
|
|
277
|
+
});
|
|
278
|
+
writeDeckBundleRef(deckDir, { presFP: mat.presFP, etag: mat.etag });
|
|
279
|
+
bundleDir = resolveBundleDirForDeck(deckDir);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const warnings = [];
|
|
283
|
+
|
|
284
|
+
// A native-only deck (just reused .pptx) has no authored JSX to draft-preview.
|
|
285
|
+
if (!hasJsx) {
|
|
286
|
+
return {
|
|
287
|
+
slides: all.map((fileName) => ({
|
|
288
|
+
fileName,
|
|
289
|
+
status: "native",
|
|
290
|
+
note: "reused native slide — not authored JSX, no draft preview rendered",
|
|
291
|
+
})),
|
|
292
|
+
warnings,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!bundleDir || !bundleHasCaptureEngine(bundleDir)) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
"draft preview needs the corporate design's capture engine, which ships in the " +
|
|
299
|
+
"materialized bundle. Run `slidemind download-bundle <deck>` against an open " +
|
|
300
|
+
"PowerPoint host once, then retry — or use `slidemind build <deck>` for a " +
|
|
301
|
+
"host-free HTML preview.",
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let captured;
|
|
306
|
+
try {
|
|
307
|
+
captured = await captureDeckLocally({ deckDir, bundleDir, onProgress });
|
|
308
|
+
} catch (err) {
|
|
309
|
+
throw new Error(
|
|
310
|
+
`local draft capture failed (${err instanceof Error ? err.message : String(err)}). ` +
|
|
311
|
+
"It runs a local headless Chrome; if Chrome can't launch here, use " +
|
|
312
|
+
"`slidemind build <deck>` for an HTML preview instead.",
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const byFile = new Map((captured?.slides ?? []).map((s) => [s.fileName, s]));
|
|
317
|
+
const slides = all.map((fileName) =>
|
|
318
|
+
fileName.endsWith(".pptx")
|
|
319
|
+
? {
|
|
320
|
+
fileName,
|
|
321
|
+
status: "native",
|
|
322
|
+
note: "reused native slide — not authored JSX, no draft preview rendered",
|
|
323
|
+
}
|
|
324
|
+
: mapCapturedSlide(fileName, byFile.get(fileName), warnings),
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
return { slides, warnings };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function renderWithRetarget(deckDir, entry, onProgress) {
|
|
331
|
+
const run = (target) =>
|
|
332
|
+
renderDeckToBase64(deckDir, {
|
|
333
|
+
presId: target.presId,
|
|
334
|
+
backendURL: target.backendURL ?? null,
|
|
335
|
+
onProgress,
|
|
336
|
+
});
|
|
337
|
+
try {
|
|
338
|
+
return { entry, result: await run(entry) };
|
|
339
|
+
} catch (err) {
|
|
340
|
+
const recoverable =
|
|
341
|
+
err?.status === 404 || err?.code === "unknown_presentation";
|
|
342
|
+
if (!recoverable) throw err;
|
|
343
|
+
const live = await waitForPaneReady(entry.presId, {
|
|
344
|
+
backendURL: entry.backendURL ?? null,
|
|
345
|
+
fileName: entry.fileName ?? entry.title ?? null,
|
|
346
|
+
brandId: entry.brandId ?? null,
|
|
347
|
+
timeoutMs: 15000,
|
|
348
|
+
onWait: (seconds) =>
|
|
349
|
+
process.stderr.write(
|
|
350
|
+
"render target changed — waiting for the slidemind pane to " +
|
|
351
|
+
`re-register (up to ${seconds}s)…\n`,
|
|
352
|
+
),
|
|
353
|
+
});
|
|
354
|
+
if (!live) throw err;
|
|
355
|
+
return { entry: live, result: await run(live) };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
180
359
|
export async function buildAndPush({
|
|
181
360
|
deckDir,
|
|
182
361
|
presentationId = null,
|
|
@@ -200,18 +379,15 @@ export async function buildAndPush({
|
|
|
200
379
|
}
|
|
201
380
|
|
|
202
381
|
const deckRef = readDeckBundleRef(deckDir);
|
|
203
|
-
const registry = await
|
|
382
|
+
const registry = await requireRenderHost();
|
|
204
383
|
const entry = pickPresentationEntry(registry, presentationId, {
|
|
205
384
|
preferBrandId: deckRef?.presFP ?? null,
|
|
206
385
|
});
|
|
207
|
-
const
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
backendURL,
|
|
213
|
-
onProgress,
|
|
214
|
-
});
|
|
386
|
+
const rendered = await renderWithRetarget(deckDir, entry, onProgress);
|
|
387
|
+
const renderEntry = rendered.entry;
|
|
388
|
+
const presId = renderEntry.presId;
|
|
389
|
+
const backendURL = renderEntry.backendURL ?? null;
|
|
390
|
+
const result = rendered.result;
|
|
215
391
|
const base64 = result.base64;
|
|
216
392
|
const bytes = Buffer.byteLength(base64, "base64");
|
|
217
393
|
const mode = replace ? "replace" : "insert";
|
|
@@ -255,8 +431,8 @@ export async function buildAndPush({
|
|
|
255
431
|
if (!recoverable) throw err;
|
|
256
432
|
const live = await waitForPaneReady(presId, {
|
|
257
433
|
backendURL,
|
|
258
|
-
fileName:
|
|
259
|
-
brandId:
|
|
434
|
+
fileName: renderEntry.fileName ?? renderEntry.title ?? null,
|
|
435
|
+
brandId: renderEntry.brandId ?? null,
|
|
260
436
|
onWait: (seconds) =>
|
|
261
437
|
process.stderr.write(
|
|
262
438
|
"task pane suspended — click the slidemind pane in PowerPoint to " +
|
|
@@ -309,16 +485,16 @@ export async function buildPptxFile({
|
|
|
309
485
|
}) {
|
|
310
486
|
if (!deckDir) throw new Error("buildPptxFile: deckDir is required");
|
|
311
487
|
const deckRef = readDeckBundleRef(deckDir);
|
|
312
|
-
const registry = await
|
|
488
|
+
const registry = await requireRenderHost();
|
|
313
489
|
const entry = pickPresentationEntry(registry, presentationId, {
|
|
314
490
|
preferBrandId: deckRef?.presFP ?? null,
|
|
315
491
|
});
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
backendURL: entry.backendURL ?? null,
|
|
492
|
+
const { entry: renderEntry, result } = await renderWithRetarget(
|
|
493
|
+
deckDir,
|
|
494
|
+
entry,
|
|
320
495
|
onProgress,
|
|
321
|
-
|
|
496
|
+
);
|
|
497
|
+
const presId = renderEntry.presId;
|
|
322
498
|
const out =
|
|
323
499
|
outPath || path.join(deckDir, `${path.basename(deckDir)}.pptx`);
|
|
324
500
|
fs.writeFileSync(out, Buffer.from(result.base64, "base64"));
|
package/src/bridge/client.mjs
CHANGED
|
@@ -41,6 +41,13 @@ function engineTimeoutMs() {
|
|
|
41
41
|
);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function readinessTimeoutMs() {
|
|
45
|
+
return (
|
|
46
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_READY_TIMEOUT_MS || "20000", 10) ||
|
|
47
|
+
20000
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
44
51
|
const NOT_PAIRED_MESSAGE =
|
|
45
52
|
'slidemind is not paired. In the slidemind add-in, click "Connect CLI" ' +
|
|
46
53
|
"and run the shown `slidemind pair …` command, then retry.";
|
|
@@ -251,6 +258,69 @@ async function listPresentationsForBackend(backendURL) {
|
|
|
251
258
|
});
|
|
252
259
|
}
|
|
253
260
|
|
|
261
|
+
async function readinessForBackend(backendURL) {
|
|
262
|
+
const env = deriveEnvLabel(backendURL);
|
|
263
|
+
try {
|
|
264
|
+
const res = await bridgeFetch(
|
|
265
|
+
"/api/cli/readiness",
|
|
266
|
+
{ timeoutMs: listTimeoutMs() },
|
|
267
|
+
backendURL,
|
|
268
|
+
);
|
|
269
|
+
const data = (await res.json()) ?? {};
|
|
270
|
+
const presentations = Array.isArray(data.presentations)
|
|
271
|
+
? data.presentations.map((p) => ({ ...p, backendURL, env }))
|
|
272
|
+
: [];
|
|
273
|
+
return {
|
|
274
|
+
env,
|
|
275
|
+
backendURL,
|
|
276
|
+
ok: true,
|
|
277
|
+
readinessV1: data.readinessV1 === true,
|
|
278
|
+
capabilities: data.capabilities ?? null,
|
|
279
|
+
presentations,
|
|
280
|
+
summary: data.summary ?? null,
|
|
281
|
+
};
|
|
282
|
+
} catch (err) {
|
|
283
|
+
if (err?.status !== 404) throw err;
|
|
284
|
+
// Older add-ins do not serve /readiness. Fall back to the legacy
|
|
285
|
+
// presentations route and mark the result as a compatibility response.
|
|
286
|
+
const presentations = await listPresentationsForBackend(backendURL);
|
|
287
|
+
return {
|
|
288
|
+
env,
|
|
289
|
+
backendURL,
|
|
290
|
+
ok: true,
|
|
291
|
+
readinessV1: false,
|
|
292
|
+
capabilities: null,
|
|
293
|
+
presentations,
|
|
294
|
+
summary: null,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function connectionStatus() {
|
|
300
|
+
const sessions = sessionsInScope();
|
|
301
|
+
const settled = await Promise.allSettled(
|
|
302
|
+
sessions.map((s) => readinessForBackend(s.backendURL)),
|
|
303
|
+
);
|
|
304
|
+
const backends = settled.map((r, i) => {
|
|
305
|
+
const backendURL = sessions[i].backendURL;
|
|
306
|
+
const env = deriveEnvLabel(backendURL);
|
|
307
|
+
if (r.status === "fulfilled") return r.value;
|
|
308
|
+
return {
|
|
309
|
+
env,
|
|
310
|
+
backendURL,
|
|
311
|
+
ok: false,
|
|
312
|
+
error: r.reason?.message ? r.reason.message : String(r.reason),
|
|
313
|
+
presentations: [],
|
|
314
|
+
};
|
|
315
|
+
});
|
|
316
|
+
return {
|
|
317
|
+
paired: sessions.length > 0,
|
|
318
|
+
backendCount: sessions.length,
|
|
319
|
+
backends,
|
|
320
|
+
presentations: backends.flatMap((b) => b.presentations ?? []),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
254
324
|
// Fan out across every backend in scope, concurrently. Never rejects: each
|
|
255
325
|
// backend's outcome is partitioned into merged presentations vs. a failure
|
|
256
326
|
// record, so one unreachable environment can't hide the others.
|
|
@@ -352,6 +422,93 @@ export async function requireHost() {
|
|
|
352
422
|
);
|
|
353
423
|
}
|
|
354
424
|
|
|
425
|
+
function hasReadinessFields(p) {
|
|
426
|
+
return (
|
|
427
|
+
p &&
|
|
428
|
+
(typeof p.connectionState === "string" ||
|
|
429
|
+
typeof p.designState === "string" ||
|
|
430
|
+
(p.capabilities && typeof p.capabilities === "object"))
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function isRenderReadyPresentation(p) {
|
|
435
|
+
if (!p) return false;
|
|
436
|
+
if (!hasReadinessFields(p)) return true;
|
|
437
|
+
if (p.capabilities?.render === true) return true;
|
|
438
|
+
if (p.designState === "ready" && p.capabilities?.render !== false) return true;
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function isPaneReadyPresentation(p) {
|
|
443
|
+
if (!p) return false;
|
|
444
|
+
if (!hasReadinessFields(p)) return p.connected !== false && !p.suspendedAt;
|
|
445
|
+
if (p.capabilities?.paneOps === true) return true;
|
|
446
|
+
return (
|
|
447
|
+
p.connectionState === "connected" &&
|
|
448
|
+
p.connected !== false &&
|
|
449
|
+
!p.suspendedAt &&
|
|
450
|
+
p.connectionState !== "stale"
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function waitForReadyPresentation(predicate, { timeoutMs = null } = {}) {
|
|
455
|
+
const waitMs = timeoutMs ?? readinessTimeoutMs();
|
|
456
|
+
const pollMs = envInt("SLIDEMIND_BRIDGE_READY_POLL_MS", 1500);
|
|
457
|
+
const deadline = Date.now() + waitMs;
|
|
458
|
+
for (;;) {
|
|
459
|
+
const { presentations, unreachable } = await listOpenPresentationsDetailed();
|
|
460
|
+
if (presentations.some(predicate)) return presentations;
|
|
461
|
+
// Do not wait when nothing is registered at all. Waiting helps only for an
|
|
462
|
+
// already-open pane that is still hydrating brand/readiness metadata.
|
|
463
|
+
if (presentations.length === 0 || Date.now() >= deadline) {
|
|
464
|
+
return { presentations, unreachable, timedOut: presentations.length > 0 };
|
|
465
|
+
}
|
|
466
|
+
await sleep(pollMs);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export async function requireRenderHost({ timeoutMs = null } = {}) {
|
|
471
|
+
const first = await listOpenPresentationsDetailed();
|
|
472
|
+
if (first.presentations.some(isRenderReadyPresentation)) {
|
|
473
|
+
return first.presentations;
|
|
474
|
+
}
|
|
475
|
+
if (first.presentations.length > 0) {
|
|
476
|
+
const waited = await waitForReadyPresentation(isRenderReadyPresentation, {
|
|
477
|
+
timeoutMs,
|
|
478
|
+
});
|
|
479
|
+
if (Array.isArray(waited)) return waited;
|
|
480
|
+
if (waited.presentations.some(isRenderReadyPresentation)) {
|
|
481
|
+
return waited.presentations;
|
|
482
|
+
}
|
|
483
|
+
throw new Error(
|
|
484
|
+
"PowerPoint is registered with the slidemind add-in, but no window is render-ready yet. " +
|
|
485
|
+
"Wait for the corporate design to finish matching, or pick/create a corporate design in the add-in, then retry.",
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
return requireHost();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export async function requirePaneHost({ timeoutMs = null } = {}) {
|
|
492
|
+
const first = await listOpenPresentationsDetailed();
|
|
493
|
+
if (first.presentations.some(isPaneReadyPresentation)) {
|
|
494
|
+
return first.presentations;
|
|
495
|
+
}
|
|
496
|
+
if (first.presentations.length > 0) {
|
|
497
|
+
const waited = await waitForReadyPresentation(isPaneReadyPresentation, {
|
|
498
|
+
timeoutMs,
|
|
499
|
+
});
|
|
500
|
+
if (Array.isArray(waited)) return waited;
|
|
501
|
+
if (waited.presentations.some(isPaneReadyPresentation)) {
|
|
502
|
+
return waited.presentations;
|
|
503
|
+
}
|
|
504
|
+
throw new Error(
|
|
505
|
+
"PowerPoint is registered with the slidemind add-in, but the task pane is not ready for pane operations. " +
|
|
506
|
+
"Bring PowerPoint forward and click or reopen the slidemind pane, then retry.",
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
return requireHost();
|
|
510
|
+
}
|
|
511
|
+
|
|
355
512
|
// Tie-break a multi-backend presId collision without bothering the user when
|
|
356
513
|
// only one candidate can actually serve the deck:
|
|
357
514
|
// (a) exactly one CONNECTED pane whose active CD matches the deck's bundle
|
|
@@ -361,7 +518,7 @@ export async function requireHost() {
|
|
|
361
518
|
// e.g. a local dev backend connected without a CD next to prod with one.
|
|
362
519
|
// Returns the winning entry or null when genuinely ambiguous.
|
|
363
520
|
function tieBreakMatches(matches, preferBrandId) {
|
|
364
|
-
const live = matches.filter(
|
|
521
|
+
const live = matches.filter(isPaneReadyPresentation);
|
|
365
522
|
if (preferBrandId) {
|
|
366
523
|
const want = live.filter((m) => m.brandId === preferBrandId);
|
|
367
524
|
if (want.length === 1) return want[0];
|
|
@@ -433,7 +590,7 @@ export async function waitForPaneReady(
|
|
|
433
590
|
/* transient list failure — poll again until the deadline */
|
|
434
591
|
}
|
|
435
592
|
const connected = list.filter(
|
|
436
|
-
(p) => p
|
|
593
|
+
(p) => isPaneReadyPresentation(p),
|
|
437
594
|
);
|
|
438
595
|
const same = connected.find((p) => p.presId === presId);
|
|
439
596
|
if (same) return same;
|
|
@@ -1108,6 +1265,46 @@ export async function viewSlide({
|
|
|
1108
1265
|
});
|
|
1109
1266
|
}
|
|
1110
1267
|
|
|
1268
|
+
// Read the CURRENT content of an open presentation (the "pull content" path
|
|
1269
|
+
// behind `slidemind read` / slidemind_read_presentation). `mode` selects the
|
|
1270
|
+
// depth: "summary" returns a per-slide outline (index, layout, title, subtitle);
|
|
1271
|
+
// "raw" (default) returns each slide's full plain text — NOT the add-in's editing
|
|
1272
|
+
// markup. Unlike engine ops, content is WINDOW-BOUND: it reads THAT presentation,
|
|
1273
|
+
// so we resolve the origin backend from the presId and relay through that
|
|
1274
|
+
// window's pane. It's an idempotent read, so engineOpWithPaneWait's single
|
|
1275
|
+
// pane_suspended client-wait + retry against the live entry is safe (like viewSlide).
|
|
1276
|
+
export async function getPresentationContent({
|
|
1277
|
+
presId,
|
|
1278
|
+
mode = "raw",
|
|
1279
|
+
backendURL = null,
|
|
1280
|
+
}) {
|
|
1281
|
+
if (!presId) throw new Error("getPresentationContent: presId is required");
|
|
1282
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
1283
|
+
const body = { mode: mode === "summary" ? "summary" : "raw" };
|
|
1284
|
+
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
1285
|
+
const res = await bridgeFetch(
|
|
1286
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/content`,
|
|
1287
|
+
{
|
|
1288
|
+
method: "POST",
|
|
1289
|
+
headers: { "content-type": "application/json" },
|
|
1290
|
+
body: JSON.stringify(body),
|
|
1291
|
+
timeoutMs: engineTimeoutMs(),
|
|
1292
|
+
},
|
|
1293
|
+
b,
|
|
1294
|
+
);
|
|
1295
|
+
return res.json(); // { ok, mode, slideCount, slides }
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function ensurePaneAckOk(data, operation) {
|
|
1300
|
+
if (!data || data.ok !== false) return data ?? {};
|
|
1301
|
+
const detail = data.error || `${operation} failed`;
|
|
1302
|
+
const err = new Error(detail);
|
|
1303
|
+
err.code = data.code || `${operation}_failed`;
|
|
1304
|
+
err.body = data;
|
|
1305
|
+
throw err;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1111
1308
|
export async function insertSlides({
|
|
1112
1309
|
presId,
|
|
1113
1310
|
base64,
|
|
@@ -1132,7 +1329,7 @@ export async function insertSlides({
|
|
|
1132
1329
|
},
|
|
1133
1330
|
be,
|
|
1134
1331
|
);
|
|
1135
|
-
return res.json().catch(() => ({}));
|
|
1332
|
+
return ensurePaneAckOk(await res.json().catch(() => ({})), "insert");
|
|
1136
1333
|
}
|
|
1137
1334
|
|
|
1138
1335
|
export async function replaceSlides({
|
|
@@ -1156,7 +1353,7 @@ export async function replaceSlides({
|
|
|
1156
1353
|
},
|
|
1157
1354
|
be,
|
|
1158
1355
|
);
|
|
1159
|
-
return res.json().catch(() => ({}));
|
|
1356
|
+
return ensurePaneAckOk(await res.json().catch(() => ({})), "replace");
|
|
1160
1357
|
}
|
|
1161
1358
|
|
|
1162
1359
|
// Pick a target presentation given the (possibly multi-backend) registry and an
|
|
@@ -1238,7 +1435,9 @@ export function pickAnyPresentationEntry(registry, explicitPresId, opts = {}) {
|
|
|
1238
1435
|
);
|
|
1239
1436
|
}
|
|
1240
1437
|
return (
|
|
1241
|
-
registry.find(
|
|
1438
|
+
registry.find(isPaneReadyPresentation) ??
|
|
1439
|
+
registry.find((p) => p.connected !== false && !p.suspendedAt) ??
|
|
1440
|
+
registry[0]
|
|
1242
1441
|
);
|
|
1243
1442
|
}
|
|
1244
1443
|
|