slidemind 0.1.0 → 0.1.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.md +7 -1
- package/README.md +27 -0
- package/bin/slidemind.mjs +161 -37
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +4 -4
- package/src/bridge/build-and-push.mjs +25 -14
- package/src/bridge/bundle.mjs +13 -2
- package/src/bridge/client.mjs +292 -76
- package/src/bridge/config.mjs +168 -25
- package/src/builders/build.mjs +9 -5
- package/src/lib/slide-order.mjs +28 -0
- package/src/mcp/server.mjs +10 -7
package/src/bridge/client.mjs
CHANGED
|
@@ -5,67 +5,111 @@
|
|
|
5
5
|
// backend runs render/bundle in-process and relays insert/replace/engine to the
|
|
6
6
|
// open PowerPoint pane. No localhost, no shared loopback token, no cert.
|
|
7
7
|
|
|
8
|
-
import fs from "node:fs";
|
|
9
8
|
import http from "node:http";
|
|
10
|
-
import os from "node:os";
|
|
11
|
-
import path from "node:path";
|
|
12
9
|
import https from "node:https";
|
|
13
10
|
|
|
14
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
deriveEnvLabel,
|
|
13
|
+
getSessionFor,
|
|
14
|
+
readSessions,
|
|
15
|
+
} from "./config.mjs";
|
|
15
16
|
|
|
16
17
|
function isLoopbackHostname(hostname) {
|
|
17
18
|
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
// The fan-out probe (listing presentations across every paired backend) uses a
|
|
22
|
+
// shorter timeout than ordinary calls so one dead/slow backend — a local relay
|
|
23
|
+
// that isn't running — can't stall the whole sweep for the full 35s default.
|
|
24
|
+
function listTimeoutMs() {
|
|
25
|
+
return (
|
|
26
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_LIST_TIMEOUT_MS || "8000", 10) || 8000
|
|
27
|
+
);
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
const NOT_PAIRED_MESSAGE =
|
|
31
|
+
'slidemind is not paired. In the slidemind add-in, click "Connect CLI" ' +
|
|
32
|
+
"and run the shown `slidemind pair …` command, then retry.";
|
|
33
|
+
|
|
34
|
+
// One transient blip — a pane mid-reconnect after a backend deploy, a stale
|
|
35
|
+
// registration the pane is about to refresh — should not fail a whole skill
|
|
36
|
+
// step. Idempotent reads retry a couple of times; mutations never do (the
|
|
37
|
+
// request may already have been delivered).
|
|
38
|
+
function transientRetryDelays() {
|
|
39
|
+
const raw = process.env.SLIDEMIND_BRIDGE_RETRY_DELAYS_MS || "1500,4000";
|
|
40
|
+
return raw
|
|
41
|
+
.split(",")
|
|
42
|
+
.map((s) => parseInt(s.trim(), 10))
|
|
43
|
+
.filter((n) => Number.isFinite(n) && n >= 0);
|
|
31
44
|
}
|
|
32
45
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
function isTransientBridgeError(err) {
|
|
47
|
+
if (!err) return false;
|
|
48
|
+
// pane_suspended needs the user (foreground PowerPoint), not a blind retry —
|
|
49
|
+
// the relay already waited for a reconnect before returning it.
|
|
50
|
+
if (err.code === "pane_suspended") return false;
|
|
51
|
+
if (err.status === 404 || err.status === 502 || err.status === 503) return true;
|
|
52
|
+
if (err.status === 409 && err.code === "pane_disconnected") return true;
|
|
53
|
+
return /relay unreachable/i.test(String(err.message || ""));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function withTransientRetry(fn) {
|
|
57
|
+
const delays = transientRetryDelays();
|
|
58
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
59
|
+
try {
|
|
60
|
+
return await fn();
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (attempt >= delays.length || !isTransientBridgeError(err)) throw err;
|
|
63
|
+
await new Promise((resolve) => setTimeout(resolve, delays[attempt]));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The paired backends in scope for this invocation. Defaults to every paired
|
|
69
|
+
// session (fan-out); `SLIDEMIND_BACKEND` narrows it to one (the CLI sets that
|
|
70
|
+
// env from a `--backend <url|env>` flag) for speed or to disambiguate. Throws
|
|
71
|
+
// the same actionable message as a missing pairing when nothing matches.
|
|
72
|
+
function sessionsInScope() {
|
|
73
|
+
const all = readSessions();
|
|
74
|
+
if (all.length === 0) throw new Error(NOT_PAIRED_MESSAGE);
|
|
75
|
+
const scope = process.env.SLIDEMIND_BACKEND;
|
|
76
|
+
if (scope && scope.trim()) {
|
|
77
|
+
const want = scope.trim();
|
|
78
|
+
const match = all.filter(
|
|
79
|
+
(s) =>
|
|
80
|
+
s.backendURL === want ||
|
|
81
|
+
s.label === want ||
|
|
82
|
+
deriveEnvLabel(s.backendURL) === want ||
|
|
83
|
+
s.backendURL.includes(want),
|
|
84
|
+
);
|
|
85
|
+
if (match.length === 0) {
|
|
40
86
|
throw new Error(
|
|
41
|
-
`slidemind
|
|
42
|
-
`Start the slidemind PowerPoint add-in first — it generates the token on first run.`,
|
|
87
|
+
`slidemind is not paired with '${want}'. Run 'slidemind whoami' to see paired environments.`,
|
|
43
88
|
);
|
|
44
89
|
}
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
const token = raw.trim();
|
|
48
|
-
if (!token) {
|
|
49
|
-
throw new Error(`slidemind bridge token at ${file} is empty.`);
|
|
90
|
+
return match;
|
|
50
91
|
}
|
|
51
|
-
return
|
|
92
|
+
return all;
|
|
52
93
|
}
|
|
53
94
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
95
|
+
// Resolve the credential for one backend (or the default session when no
|
|
96
|
+
// backendURL is given — the back-compat single-session path).
|
|
97
|
+
function sessionForBackend(backendURL) {
|
|
98
|
+
if (backendURL) {
|
|
99
|
+
const s = getSessionFor(backendURL);
|
|
100
|
+
if (!s)
|
|
101
|
+
throw new Error(
|
|
102
|
+
`slidemind is not paired with '${deriveEnvLabel(backendURL)}'.`,
|
|
103
|
+
);
|
|
104
|
+
return s;
|
|
63
105
|
}
|
|
64
|
-
|
|
106
|
+
const all = readSessions();
|
|
107
|
+
if (all.length === 0) throw new Error(NOT_PAIRED_MESSAGE);
|
|
108
|
+
return all[0];
|
|
65
109
|
}
|
|
66
110
|
|
|
67
|
-
async function bridgeFetch(pathPart, init = {}) {
|
|
68
|
-
const session =
|
|
111
|
+
async function bridgeFetch(pathPart, init = {}, backendURL = null) {
|
|
112
|
+
const session = sessionForBackend(backendURL);
|
|
69
113
|
const url = `${session.backendURL}${pathPart}`;
|
|
70
114
|
const headers = {
|
|
71
115
|
accept: "application/json",
|
|
@@ -79,7 +123,7 @@ async function bridgeFetch(pathPart, init = {}) {
|
|
|
79
123
|
} catch (err) {
|
|
80
124
|
if (err && /ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(String(err.message || err))) {
|
|
81
125
|
throw new Error(
|
|
82
|
-
`slidemind relay unreachable
|
|
126
|
+
`slidemind relay unreachable (${deriveEnvLabel(session.backendURL)}). Check your connection; ` +
|
|
83
127
|
"if it persists, re-pair with `slidemind pair`.",
|
|
84
128
|
);
|
|
85
129
|
}
|
|
@@ -158,10 +202,11 @@ function nodeRequest(urlString, init = {}) {
|
|
|
158
202
|
});
|
|
159
203
|
},
|
|
160
204
|
);
|
|
161
|
-
// Default 35s > the
|
|
162
|
-
// typed `pane_suspended` rather than a client timeout. A
|
|
163
|
-
// tagged `bridge_timeout` so non-idempotent callers
|
|
164
|
-
// may already have been delivered and must NOT
|
|
205
|
+
// Default 35s > the relay's 25s wait-for-pane-reconnect, so a suspended
|
|
206
|
+
// pane surfaces a typed `pane_suspended` rather than a client timeout. A
|
|
207
|
+
// real timeout is tagged `bridge_timeout` so non-idempotent callers
|
|
208
|
+
// (insert) know the request may already have been delivered and must NOT
|
|
209
|
+
// auto-retry it.
|
|
165
210
|
req.setTimeout(timeoutMs, () => {
|
|
166
211
|
const err = new Error(
|
|
167
212
|
`slidemind bridge request timed out after ${timeoutMs}ms`,
|
|
@@ -175,42 +220,159 @@ function nodeRequest(urlString, init = {}) {
|
|
|
175
220
|
});
|
|
176
221
|
}
|
|
177
222
|
|
|
223
|
+
// List one backend's open presentations, tagging each entry with its origin
|
|
224
|
+
// (backendURL + a short env label) so the merged registry can be routed and
|
|
225
|
+
// displayed per environment. Transient blips retry per-backend.
|
|
226
|
+
async function listPresentationsForBackend(backendURL) {
|
|
227
|
+
return withTransientRetry(async () => {
|
|
228
|
+
const res = await bridgeFetch(
|
|
229
|
+
"/api/cli/presentations",
|
|
230
|
+
{ timeoutMs: listTimeoutMs() },
|
|
231
|
+
backendURL,
|
|
232
|
+
);
|
|
233
|
+
const data = await res.json();
|
|
234
|
+
const arr = Array.isArray(data?.presentations) ? data.presentations : [];
|
|
235
|
+
const env = deriveEnvLabel(backendURL);
|
|
236
|
+
return arr.map((p) => ({ ...p, backendURL, env }));
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Fan out across every backend in scope, concurrently. Never rejects: each
|
|
241
|
+
// backend's outcome is partitioned into merged presentations vs. a failure
|
|
242
|
+
// record, so one unreachable environment can't hide the others.
|
|
243
|
+
async function fanOutPresentations() {
|
|
244
|
+
const sessions = sessionsInScope();
|
|
245
|
+
const settled = await Promise.allSettled(
|
|
246
|
+
sessions.map((s) => listPresentationsForBackend(s.backendURL)),
|
|
247
|
+
);
|
|
248
|
+
const presentations = [];
|
|
249
|
+
const failures = [];
|
|
250
|
+
let fulfilledCount = 0;
|
|
251
|
+
settled.forEach((r, i) => {
|
|
252
|
+
const backendURL = sessions[i].backendURL;
|
|
253
|
+
if (r.status === "fulfilled") {
|
|
254
|
+
fulfilledCount += 1;
|
|
255
|
+
presentations.push(...r.value);
|
|
256
|
+
} else {
|
|
257
|
+
failures.push({
|
|
258
|
+
backendURL,
|
|
259
|
+
env: deriveEnvLabel(backendURL),
|
|
260
|
+
err: r.reason,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
return { presentations, failures, fulfilledCount };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Merged registry across all paired backends. Stays a flat array (every caller
|
|
268
|
+
// expects one). Preserves single-backend behaviour: when nothing is reachable
|
|
269
|
+
// AND no backend answered, the underlying bridge error propagates (so a typed
|
|
270
|
+
// code like `pane_suspended` or a 401 still surfaces) — for several failing
|
|
271
|
+
// backends it's an aggregate message.
|
|
178
272
|
export async function listOpenPresentations() {
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
273
|
+
const { presentations, failures, fulfilledCount } =
|
|
274
|
+
await fanOutPresentations();
|
|
275
|
+
if (presentations.length === 0 && fulfilledCount === 0 && failures.length > 0) {
|
|
276
|
+
if (failures.length === 1) throw failures[0].err;
|
|
277
|
+
const err = new Error(
|
|
278
|
+
"slidemind: every paired backend failed to list presentations:\n" +
|
|
279
|
+
failures
|
|
280
|
+
.map((f) => ` ${f.env}: ${f.err.message}`)
|
|
281
|
+
.join("\n"),
|
|
282
|
+
);
|
|
283
|
+
throw err;
|
|
284
|
+
}
|
|
285
|
+
return presentations;
|
|
182
286
|
}
|
|
183
287
|
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
288
|
+
// The user/agent-facing shape of a presentation entry. `backendURL` is internal
|
|
289
|
+
// routing state — every caller targets a presentation by `presId` (the backend
|
|
290
|
+
// is re-resolved server-side from it), so the URL must never leave the process
|
|
291
|
+
// in any printed line or MCP payload. Keeps presId, title, fileName, connected,
|
|
292
|
+
// suspendedAt, and the short `env` label (the only discriminator a user sees).
|
|
293
|
+
export function toPublicPresentation(p) {
|
|
294
|
+
const { backendURL, ...rest } = p;
|
|
295
|
+
return rest;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Like listOpenPresentations but never throws on per-backend failures — returns
|
|
299
|
+
// the merged registry plus which backends were unreachable, for callers that
|
|
300
|
+
// want to report partial results (requireHost, whoami).
|
|
301
|
+
export async function listOpenPresentationsDetailed() {
|
|
302
|
+
const { presentations, failures } = await fanOutPresentations();
|
|
303
|
+
return {
|
|
304
|
+
presentations,
|
|
305
|
+
unreachable: failures.map((f) => ({
|
|
306
|
+
backendURL: f.backendURL,
|
|
307
|
+
env: f.env,
|
|
308
|
+
error: f.err?.message ? f.err.message : String(f.err),
|
|
309
|
+
})),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Probe the bridge for an open PowerPoint host across every paired backend.
|
|
314
|
+
// Returns the merged registry, or throws a single actionable message — the
|
|
315
|
+
// shared precondition for every author/render command now that the design truth
|
|
316
|
+
// lives in the add-in. Suspended entries (task-pane socket dropped, e.g.
|
|
317
|
+
// PowerPoint backgrounded) count as registered: render + bundle work without
|
|
318
|
+
// the pane, and a push waits server-side for the pane to reconnect.
|
|
187
319
|
export async function requireHost() {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
320
|
+
const { presentations, unreachable } = await listOpenPresentationsDetailed();
|
|
321
|
+
if (Array.isArray(presentations) && presentations.length > 0) {
|
|
322
|
+
return presentations;
|
|
323
|
+
}
|
|
324
|
+
// Nothing registered. Distinguish "a backend was unreachable" from "backends
|
|
325
|
+
// answered but no window is open" so the user gets the right next step.
|
|
326
|
+
if (unreachable.length > 0) {
|
|
192
327
|
throw new Error(
|
|
193
328
|
"Open PowerPoint with the slidemind add-in and an active corporate design, then retry.\n" +
|
|
194
|
-
|
|
329
|
+
unreachable
|
|
330
|
+
.map((u) => ` (bridge: ${u.env}: ${u.error})`)
|
|
331
|
+
.join("\n"),
|
|
195
332
|
);
|
|
196
333
|
}
|
|
197
|
-
|
|
334
|
+
throw new Error(
|
|
335
|
+
"No PowerPoint window is registered with the slidemind add-in.\n" +
|
|
336
|
+
"If PowerPoint is already open, bring it to the foreground and click the slidemind pane once so it re-registers, then retry.\n" +
|
|
337
|
+
"Otherwise open PowerPoint with the slidemind add-in and an active corporate design, then retry.",
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Resolve which backend a presId lives on via a LIVE fan-out list (never a
|
|
342
|
+
// cached map — a presId can move backends on re-pair/redeploy). Throws an
|
|
343
|
+
// actionable message on a miss, or an ambiguity error when the same presId is
|
|
344
|
+
// open on more than one reachable backend.
|
|
345
|
+
export async function resolveBackendForPres(presId) {
|
|
346
|
+
if (!presId) throw new Error("resolveBackendForPres: presId is required");
|
|
347
|
+
const { presentations } = await fanOutPresentations();
|
|
348
|
+
const matches = presentations.filter((p) => p.presId === presId);
|
|
349
|
+
if (matches.length === 1) return matches[0].backendURL;
|
|
350
|
+
if (matches.length > 1) {
|
|
351
|
+
const envs = matches.map((m) => m.env).join(", ");
|
|
198
352
|
throw new Error(
|
|
199
|
-
|
|
200
|
-
"
|
|
353
|
+
`Presentation '${presId}' is open on multiple environments: ${envs}. ` +
|
|
354
|
+
"Disambiguate with --backend <env|label> (or SLIDEMIND_BACKEND).",
|
|
201
355
|
);
|
|
202
356
|
}
|
|
203
|
-
|
|
357
|
+
throw new Error(
|
|
358
|
+
`No open PowerPoint window registered with presId='${presId}' on any paired backend. ` +
|
|
359
|
+
"Run 'slidemind presentations' to see what's open.",
|
|
360
|
+
);
|
|
204
361
|
}
|
|
205
362
|
|
|
206
363
|
// GET the full design bundle (tokens, css, layouts, designRules, signed asset
|
|
207
364
|
// URLs, engines) for the presentation's active corporate design.
|
|
208
|
-
export async function getDesignBundle(presId) {
|
|
365
|
+
export async function getDesignBundle(presId, { backendURL = null } = {}) {
|
|
209
366
|
if (!presId) throw new Error("getDesignBundle: presId is required");
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
367
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
368
|
+
return withTransientRetry(async () => {
|
|
369
|
+
const res = await bridgeFetch(
|
|
370
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/design-bundle`,
|
|
371
|
+
{},
|
|
372
|
+
be,
|
|
373
|
+
);
|
|
374
|
+
return res.json();
|
|
375
|
+
});
|
|
214
376
|
}
|
|
215
377
|
|
|
216
378
|
// Server-side OOXML render: ship authored slide JSX (+ deck-local assets) and
|
|
@@ -221,11 +383,13 @@ export async function renderPptx({
|
|
|
221
383
|
slides,
|
|
222
384
|
deckAssets = null,
|
|
223
385
|
etag = null,
|
|
386
|
+
backendURL = null,
|
|
224
387
|
}) {
|
|
225
388
|
if (!presId) throw new Error("renderPptx: presId is required");
|
|
226
389
|
if (!slides || Object.keys(slides).length === 0) {
|
|
227
390
|
throw new Error("renderPptx: slides is required");
|
|
228
391
|
}
|
|
392
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
229
393
|
const body = { slides };
|
|
230
394
|
if (deckAssets && Object.keys(deckAssets).length > 0) {
|
|
231
395
|
body.deckAssets = deckAssets;
|
|
@@ -237,7 +401,14 @@ export async function renderPptx({
|
|
|
237
401
|
method: "POST",
|
|
238
402
|
headers: { "content-type": "application/json" },
|
|
239
403
|
body: JSON.stringify(body),
|
|
404
|
+
// Server-side OOXML render is a heavy operation — a full deck of richly
|
|
405
|
+
// composed slides routinely runs well past the 35s default. Treat it like
|
|
406
|
+
// a mutation: give it a generous default and let env override it.
|
|
407
|
+
timeoutMs:
|
|
408
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_RENDER_TIMEOUT_MS || "300000", 10) ||
|
|
409
|
+
300000,
|
|
240
410
|
},
|
|
411
|
+
be,
|
|
241
412
|
);
|
|
242
413
|
return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
|
|
243
414
|
}
|
|
@@ -245,17 +416,25 @@ export async function renderPptx({
|
|
|
245
416
|
// Brand-engine search through the local bridge. New add-ins run this
|
|
246
417
|
// server-side with the relayed user token; older add-ins may fall back to a
|
|
247
418
|
// task-pane proxy. Returns a searchId + numbered result thumbnails.
|
|
248
|
-
export async function engineSearch({ presId, engineId, query }) {
|
|
419
|
+
export async function engineSearch({ presId, engineId, query, backendURL = null }) {
|
|
249
420
|
if (!presId) throw new Error("engineSearch: presId is required");
|
|
250
421
|
if (!engineId) throw new Error("engineSearch: engineId is required");
|
|
251
422
|
if (!query) throw new Error("engineSearch: query is required");
|
|
423
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
252
424
|
const res = await bridgeFetch(
|
|
253
425
|
`/api/cli/presentations/${encodeURIComponent(presId)}/engine/search`,
|
|
254
426
|
{
|
|
255
427
|
method: "POST",
|
|
256
428
|
headers: { "content-type": "application/json" },
|
|
257
429
|
body: JSON.stringify({ engineId, query }),
|
|
430
|
+
// Engine ops relay through the pane: the server may wait up to ~25s for
|
|
431
|
+
// a suspended pane to reconnect, then up to 60s for the pane's ack —
|
|
432
|
+
// stay above that sum so typed errors win over client timeouts.
|
|
433
|
+
timeoutMs:
|
|
434
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
435
|
+
90000,
|
|
258
436
|
},
|
|
437
|
+
be,
|
|
259
438
|
);
|
|
260
439
|
return res.json(); // { ok, searchId, resultCount, gridDataUri, thumbnails }
|
|
261
440
|
}
|
|
@@ -268,6 +447,7 @@ export async function engineDownload({
|
|
|
268
447
|
searchId,
|
|
269
448
|
selection,
|
|
270
449
|
iconColor = null,
|
|
450
|
+
backendURL = null,
|
|
271
451
|
}) {
|
|
272
452
|
if (!presId) throw new Error("engineDownload: presId is required");
|
|
273
453
|
if (typeof searchId !== "number") {
|
|
@@ -276,6 +456,7 @@ export async function engineDownload({
|
|
|
276
456
|
if (typeof selection !== "number") {
|
|
277
457
|
throw new Error("engineDownload: selection (number) is required");
|
|
278
458
|
}
|
|
459
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
279
460
|
const body = { searchId, selection };
|
|
280
461
|
if (iconColor) body.iconColor = iconColor;
|
|
281
462
|
const res = await bridgeFetch(
|
|
@@ -284,7 +465,13 @@ export async function engineDownload({
|
|
|
284
465
|
method: "POST",
|
|
285
466
|
headers: { "content-type": "application/json" },
|
|
286
467
|
body: JSON.stringify(body),
|
|
468
|
+
// Same relay path as engine/search — outlast the server's reconnect
|
|
469
|
+
// wait + pane ack timeout.
|
|
470
|
+
timeoutMs:
|
|
471
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
472
|
+
90000,
|
|
287
473
|
},
|
|
474
|
+
be,
|
|
288
475
|
);
|
|
289
476
|
return res.json(); // { ok, base64, mimeType, svgMarkup }
|
|
290
477
|
}
|
|
@@ -295,9 +482,11 @@ export async function insertSlides({
|
|
|
295
482
|
insertAt = null,
|
|
296
483
|
sourceSlideIds = null,
|
|
297
484
|
operationId = null,
|
|
485
|
+
backendURL = null,
|
|
298
486
|
}) {
|
|
299
487
|
if (!presId) throw new Error("insertSlides: presId is required");
|
|
300
488
|
if (!base64) throw new Error("insertSlides: base64 is required");
|
|
489
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
301
490
|
const body = { base64, insertAt, sourceSlideIds, operationId };
|
|
302
491
|
const res = await bridgeFetch(
|
|
303
492
|
`/api/cli/presentations/${encodeURIComponent(presId)}/insert`,
|
|
@@ -309,13 +498,20 @@ export async function insertSlides({
|
|
|
309
498
|
parseInt(process.env.SLIDEMIND_BRIDGE_MUTATION_TIMEOUT_MS || "1805000", 10) ||
|
|
310
499
|
1805000,
|
|
311
500
|
},
|
|
501
|
+
be,
|
|
312
502
|
);
|
|
313
503
|
return res.json().catch(() => ({}));
|
|
314
504
|
}
|
|
315
505
|
|
|
316
|
-
export async function replaceSlides({
|
|
506
|
+
export async function replaceSlides({
|
|
507
|
+
presId,
|
|
508
|
+
base64,
|
|
509
|
+
operationId = null,
|
|
510
|
+
backendURL = null,
|
|
511
|
+
}) {
|
|
317
512
|
if (!presId) throw new Error("replaceSlides: presId is required");
|
|
318
513
|
if (!base64) throw new Error("replaceSlides: base64 is required");
|
|
514
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
319
515
|
const res = await bridgeFetch(
|
|
320
516
|
`/api/cli/presentations/${encodeURIComponent(presId)}/replace`,
|
|
321
517
|
{
|
|
@@ -326,23 +522,33 @@ export async function replaceSlides({ presId, base64, operationId = null }) {
|
|
|
326
522
|
parseInt(process.env.SLIDEMIND_BRIDGE_MUTATION_TIMEOUT_MS || "1805000", 10) ||
|
|
327
523
|
1805000,
|
|
328
524
|
},
|
|
525
|
+
be,
|
|
329
526
|
);
|
|
330
527
|
return res.json().catch(() => ({}));
|
|
331
528
|
}
|
|
332
529
|
|
|
333
|
-
// Pick a target presentation given the
|
|
334
|
-
// presId. Returns the
|
|
335
|
-
//
|
|
336
|
-
|
|
530
|
+
// Pick a target presentation given the (possibly multi-backend) registry and an
|
|
531
|
+
// optional explicit presId. Returns the matched registry ENTRY (which carries
|
|
532
|
+
// `backendURL`/`env` after a fan-out list), or throws with a helpful message if
|
|
533
|
+
// the choice is ambiguous. Callers that already hold the entry can pass its
|
|
534
|
+
// `backendURL` straight to the targeted ops, skipping a second fan-out.
|
|
535
|
+
export function pickPresentationEntry(registry, explicitPresId) {
|
|
337
536
|
if (explicitPresId) {
|
|
338
|
-
const
|
|
339
|
-
if (
|
|
537
|
+
const matches = registry.filter((p) => p.presId === explicitPresId);
|
|
538
|
+
if (matches.length === 0) {
|
|
340
539
|
const ids = registry.map((p) => p.presId).join(", ") || "(none registered)";
|
|
341
540
|
throw new Error(
|
|
342
541
|
`No open PowerPoint window registered with presId='${explicitPresId}'. Live: ${ids}`,
|
|
343
542
|
);
|
|
344
543
|
}
|
|
345
|
-
|
|
544
|
+
if (matches.length > 1) {
|
|
545
|
+
const envs = matches.map((m) => m.env || "?").join(", ");
|
|
546
|
+
throw new Error(
|
|
547
|
+
`Presentation '${explicitPresId}' is open on multiple environments: ${envs}. ` +
|
|
548
|
+
"Disambiguate with --backend <env|label> (or SLIDEMIND_BACKEND).",
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
return matches[0];
|
|
346
552
|
}
|
|
347
553
|
if (registry.length === 0) {
|
|
348
554
|
throw new Error(
|
|
@@ -350,11 +556,21 @@ export function pickPresentation(registry, explicitPresId) {
|
|
|
350
556
|
"Open a presentation, ensure the add-in is loaded, and try again.",
|
|
351
557
|
);
|
|
352
558
|
}
|
|
353
|
-
if (registry.length === 1) return registry[0]
|
|
559
|
+
if (registry.length === 1) return registry[0];
|
|
354
560
|
const list = registry
|
|
355
|
-
.map(
|
|
561
|
+
.map(
|
|
562
|
+
(p) =>
|
|
563
|
+
` ${p.presId}\t${p.env ? `[${p.env}]\t` : ""}${p.title || p.fileName || "(untitled)"}` +
|
|
564
|
+
(p.connected === false || p.suspendedAt ? "\t(suspended — PowerPoint in background)" : ""),
|
|
565
|
+
)
|
|
356
566
|
.join("\n");
|
|
357
567
|
throw new Error(
|
|
358
568
|
`Multiple PowerPoint windows are open. Pick one with --to <presId>:\n${list}`,
|
|
359
569
|
);
|
|
360
570
|
}
|
|
571
|
+
|
|
572
|
+
// Back-compat shim: returns just the presId string. Prefer
|
|
573
|
+
// pickPresentationEntry when you need the origin backend.
|
|
574
|
+
export function pickPresentation(registry, explicitPresId) {
|
|
575
|
+
return pickPresentationEntry(registry, explicitPresId).presId;
|
|
576
|
+
}
|