slidemind 0.0.29 → 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 +222 -10
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +10 -11
- package/src/bridge/build-and-push.mjs +25 -14
- package/src/bridge/bundle.mjs +13 -2
- package/src/bridge/client.mjs +319 -94
- package/src/bridge/config.mjs +214 -0
- package/src/bridge/pair.mjs +67 -0
- 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
|
@@ -1,75 +1,120 @@
|
|
|
1
|
-
// Client for the slidemind
|
|
1
|
+
// Client for the slidemind cloud relay.
|
|
2
2
|
//
|
|
3
|
-
// The
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// The shared token is generated on add-in start and written to a state file.
|
|
9
|
-
// Reading it is sufficient auth; the add-in's middleware also rejects any
|
|
10
|
-
// non-loopback caller, so this is intentionally a same-machine-only surface.
|
|
3
|
+
// The CLI talks to `<backendURL>/api/cli/*` with a Bearer session token — both
|
|
4
|
+
// established once by `slidemind pair` (see config.mjs). The add-in's cloud
|
|
5
|
+
// backend runs render/bundle in-process and relays insert/replace/engine to the
|
|
6
|
+
// open PowerPoint pane. No localhost, no shared loopback token, no cert.
|
|
11
7
|
|
|
12
|
-
import fs from "node:fs";
|
|
13
8
|
import http from "node:http";
|
|
14
|
-
import os from "node:os";
|
|
15
|
-
import path from "node:path";
|
|
16
9
|
import https from "node:https";
|
|
17
10
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
import {
|
|
12
|
+
deriveEnvLabel,
|
|
13
|
+
getSessionFor,
|
|
14
|
+
readSessions,
|
|
15
|
+
} from "./config.mjs";
|
|
21
16
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const base = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
|
25
|
-
return path.join(base, "slidemind");
|
|
26
|
-
}
|
|
27
|
-
const base = process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
|
|
28
|
-
return path.join(base, "slidemind");
|
|
17
|
+
function isLoopbackHostname(hostname) {
|
|
18
|
+
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
|
|
29
19
|
}
|
|
30
20
|
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
);
|
|
33
28
|
}
|
|
34
29
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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);
|
|
44
|
+
}
|
|
45
|
+
|
|
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) {
|
|
42
86
|
throw new Error(
|
|
43
|
-
`slidemind
|
|
44
|
-
`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.`,
|
|
45
88
|
);
|
|
46
89
|
}
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
const token = raw.trim();
|
|
50
|
-
if (!token) {
|
|
51
|
-
throw new Error(`slidemind bridge token at ${file} is empty.`);
|
|
90
|
+
return match;
|
|
52
91
|
}
|
|
53
|
-
return
|
|
92
|
+
return all;
|
|
54
93
|
}
|
|
55
94
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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;
|
|
105
|
+
}
|
|
106
|
+
const all = readSessions();
|
|
107
|
+
if (all.length === 0) throw new Error(NOT_PAIRED_MESSAGE);
|
|
108
|
+
return all[0];
|
|
64
109
|
}
|
|
65
110
|
|
|
66
|
-
async function bridgeFetch(pathPart, init = {}) {
|
|
67
|
-
const
|
|
68
|
-
const
|
|
111
|
+
async function bridgeFetch(pathPart, init = {}, backendURL = null) {
|
|
112
|
+
const session = sessionForBackend(backendURL);
|
|
113
|
+
const url = `${session.backendURL}${pathPart}`;
|
|
69
114
|
const headers = {
|
|
70
115
|
accept: "application/json",
|
|
71
116
|
...init.headers,
|
|
72
|
-
|
|
117
|
+
authorization: `Bearer ${session.sessionToken}`,
|
|
73
118
|
};
|
|
74
119
|
|
|
75
120
|
let response;
|
|
@@ -78,12 +123,18 @@ async function bridgeFetch(pathPart, init = {}) {
|
|
|
78
123
|
} catch (err) {
|
|
79
124
|
if (err && /ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(String(err.message || err))) {
|
|
80
125
|
throw new Error(
|
|
81
|
-
`slidemind
|
|
82
|
-
|
|
126
|
+
`slidemind relay unreachable (${deriveEnvLabel(session.backendURL)}). Check your connection; ` +
|
|
127
|
+
"if it persists, re-pair with `slidemind pair`.",
|
|
83
128
|
);
|
|
84
129
|
}
|
|
85
130
|
throw err;
|
|
86
131
|
}
|
|
132
|
+
if (response.status === 401) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
"slidemind session is invalid or expired. Re-pair: open the add-in, " +
|
|
135
|
+
'click "Connect CLI", and run the shown `slidemind pair …`.',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
87
138
|
if (response.status < 200 || response.status >= 300) {
|
|
88
139
|
let parsed = null;
|
|
89
140
|
try {
|
|
@@ -135,7 +186,9 @@ function nodeRequest(urlString, init = {}) {
|
|
|
135
186
|
{
|
|
136
187
|
method: init.method ?? "GET",
|
|
137
188
|
headers,
|
|
138
|
-
|
|
189
|
+
// Validate the cloud's real cert; only skip for a loopback dev backend
|
|
190
|
+
// (self-signed) when pointing the CLI at a local add-in for development.
|
|
191
|
+
rejectUnauthorized: isHttps ? !isLoopbackHostname(url.hostname) : undefined,
|
|
139
192
|
},
|
|
140
193
|
(res) => {
|
|
141
194
|
const chunks = [];
|
|
@@ -149,10 +202,11 @@ function nodeRequest(urlString, init = {}) {
|
|
|
149
202
|
});
|
|
150
203
|
},
|
|
151
204
|
);
|
|
152
|
-
// Default 35s > the
|
|
153
|
-
// typed `pane_suspended` rather than a client timeout. A
|
|
154
|
-
// tagged `bridge_timeout` so non-idempotent callers
|
|
155
|
-
// 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.
|
|
156
210
|
req.setTimeout(timeoutMs, () => {
|
|
157
211
|
const err = new Error(
|
|
158
212
|
`slidemind bridge request timed out after ${timeoutMs}ms`,
|
|
@@ -166,42 +220,159 @@ function nodeRequest(urlString, init = {}) {
|
|
|
166
220
|
});
|
|
167
221
|
}
|
|
168
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.
|
|
169
272
|
export async function listOpenPresentations() {
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
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;
|
|
173
286
|
}
|
|
174
287
|
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
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.
|
|
178
319
|
export async function requireHost() {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
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) {
|
|
183
327
|
throw new Error(
|
|
184
328
|
"Open PowerPoint with the slidemind add-in and an active corporate design, then retry.\n" +
|
|
185
|
-
|
|
329
|
+
unreachable
|
|
330
|
+
.map((u) => ` (bridge: ${u.env}: ${u.error})`)
|
|
331
|
+
.join("\n"),
|
|
186
332
|
);
|
|
187
333
|
}
|
|
188
|
-
|
|
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(", ");
|
|
189
352
|
throw new Error(
|
|
190
|
-
|
|
191
|
-
"
|
|
353
|
+
`Presentation '${presId}' is open on multiple environments: ${envs}. ` +
|
|
354
|
+
"Disambiguate with --backend <env|label> (or SLIDEMIND_BACKEND).",
|
|
192
355
|
);
|
|
193
356
|
}
|
|
194
|
-
|
|
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
|
+
);
|
|
195
361
|
}
|
|
196
362
|
|
|
197
363
|
// GET the full design bundle (tokens, css, layouts, designRules, signed asset
|
|
198
364
|
// URLs, engines) for the presentation's active corporate design.
|
|
199
|
-
export async function getDesignBundle(presId) {
|
|
365
|
+
export async function getDesignBundle(presId, { backendURL = null } = {}) {
|
|
200
366
|
if (!presId) throw new Error("getDesignBundle: presId is required");
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
+
});
|
|
205
376
|
}
|
|
206
377
|
|
|
207
378
|
// Server-side OOXML render: ship authored slide JSX (+ deck-local assets) and
|
|
@@ -212,23 +383,32 @@ export async function renderPptx({
|
|
|
212
383
|
slides,
|
|
213
384
|
deckAssets = null,
|
|
214
385
|
etag = null,
|
|
386
|
+
backendURL = null,
|
|
215
387
|
}) {
|
|
216
388
|
if (!presId) throw new Error("renderPptx: presId is required");
|
|
217
389
|
if (!slides || Object.keys(slides).length === 0) {
|
|
218
390
|
throw new Error("renderPptx: slides is required");
|
|
219
391
|
}
|
|
392
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
220
393
|
const body = { slides };
|
|
221
394
|
if (deckAssets && Object.keys(deckAssets).length > 0) {
|
|
222
395
|
body.deckAssets = deckAssets;
|
|
223
396
|
}
|
|
224
397
|
if (etag) body.etag = etag;
|
|
225
398
|
const res = await bridgeFetch(
|
|
226
|
-
`/
|
|
399
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/render-pptx`,
|
|
227
400
|
{
|
|
228
401
|
method: "POST",
|
|
229
402
|
headers: { "content-type": "application/json" },
|
|
230
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,
|
|
231
410
|
},
|
|
411
|
+
be,
|
|
232
412
|
);
|
|
233
413
|
return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
|
|
234
414
|
}
|
|
@@ -236,17 +416,25 @@ export async function renderPptx({
|
|
|
236
416
|
// Brand-engine search through the local bridge. New add-ins run this
|
|
237
417
|
// server-side with the relayed user token; older add-ins may fall back to a
|
|
238
418
|
// task-pane proxy. Returns a searchId + numbered result thumbnails.
|
|
239
|
-
export async function engineSearch({ presId, engineId, query }) {
|
|
419
|
+
export async function engineSearch({ presId, engineId, query, backendURL = null }) {
|
|
240
420
|
if (!presId) throw new Error("engineSearch: presId is required");
|
|
241
421
|
if (!engineId) throw new Error("engineSearch: engineId is required");
|
|
242
422
|
if (!query) throw new Error("engineSearch: query is required");
|
|
423
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
243
424
|
const res = await bridgeFetch(
|
|
244
|
-
`/
|
|
425
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/engine/search`,
|
|
245
426
|
{
|
|
246
427
|
method: "POST",
|
|
247
428
|
headers: { "content-type": "application/json" },
|
|
248
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,
|
|
249
436
|
},
|
|
437
|
+
be,
|
|
250
438
|
);
|
|
251
439
|
return res.json(); // { ok, searchId, resultCount, gridDataUri, thumbnails }
|
|
252
440
|
}
|
|
@@ -259,6 +447,7 @@ export async function engineDownload({
|
|
|
259
447
|
searchId,
|
|
260
448
|
selection,
|
|
261
449
|
iconColor = null,
|
|
450
|
+
backendURL = null,
|
|
262
451
|
}) {
|
|
263
452
|
if (!presId) throw new Error("engineDownload: presId is required");
|
|
264
453
|
if (typeof searchId !== "number") {
|
|
@@ -267,15 +456,22 @@ export async function engineDownload({
|
|
|
267
456
|
if (typeof selection !== "number") {
|
|
268
457
|
throw new Error("engineDownload: selection (number) is required");
|
|
269
458
|
}
|
|
459
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
270
460
|
const body = { searchId, selection };
|
|
271
461
|
if (iconColor) body.iconColor = iconColor;
|
|
272
462
|
const res = await bridgeFetch(
|
|
273
|
-
`/
|
|
463
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/engine/download`,
|
|
274
464
|
{
|
|
275
465
|
method: "POST",
|
|
276
466
|
headers: { "content-type": "application/json" },
|
|
277
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,
|
|
278
473
|
},
|
|
474
|
+
be,
|
|
279
475
|
);
|
|
280
476
|
return res.json(); // { ok, base64, mimeType, svgMarkup }
|
|
281
477
|
}
|
|
@@ -286,12 +482,14 @@ export async function insertSlides({
|
|
|
286
482
|
insertAt = null,
|
|
287
483
|
sourceSlideIds = null,
|
|
288
484
|
operationId = null,
|
|
485
|
+
backendURL = null,
|
|
289
486
|
}) {
|
|
290
487
|
if (!presId) throw new Error("insertSlides: presId is required");
|
|
291
488
|
if (!base64) throw new Error("insertSlides: base64 is required");
|
|
489
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
292
490
|
const body = { base64, insertAt, sourceSlideIds, operationId };
|
|
293
491
|
const res = await bridgeFetch(
|
|
294
|
-
`/
|
|
492
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/insert`,
|
|
295
493
|
{
|
|
296
494
|
method: "POST",
|
|
297
495
|
headers: { "content-type": "application/json" },
|
|
@@ -300,15 +498,22 @@ export async function insertSlides({
|
|
|
300
498
|
parseInt(process.env.SLIDEMIND_BRIDGE_MUTATION_TIMEOUT_MS || "1805000", 10) ||
|
|
301
499
|
1805000,
|
|
302
500
|
},
|
|
501
|
+
be,
|
|
303
502
|
);
|
|
304
503
|
return res.json().catch(() => ({}));
|
|
305
504
|
}
|
|
306
505
|
|
|
307
|
-
export async function replaceSlides({
|
|
506
|
+
export async function replaceSlides({
|
|
507
|
+
presId,
|
|
508
|
+
base64,
|
|
509
|
+
operationId = null,
|
|
510
|
+
backendURL = null,
|
|
511
|
+
}) {
|
|
308
512
|
if (!presId) throw new Error("replaceSlides: presId is required");
|
|
309
513
|
if (!base64) throw new Error("replaceSlides: base64 is required");
|
|
514
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
310
515
|
const res = await bridgeFetch(
|
|
311
|
-
`/
|
|
516
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/replace`,
|
|
312
517
|
{
|
|
313
518
|
method: "POST",
|
|
314
519
|
headers: { "content-type": "application/json" },
|
|
@@ -317,23 +522,33 @@ export async function replaceSlides({ presId, base64, operationId = null }) {
|
|
|
317
522
|
parseInt(process.env.SLIDEMIND_BRIDGE_MUTATION_TIMEOUT_MS || "1805000", 10) ||
|
|
318
523
|
1805000,
|
|
319
524
|
},
|
|
525
|
+
be,
|
|
320
526
|
);
|
|
321
527
|
return res.json().catch(() => ({}));
|
|
322
528
|
}
|
|
323
529
|
|
|
324
|
-
// Pick a target presentation given the
|
|
325
|
-
// presId. Returns the
|
|
326
|
-
//
|
|
327
|
-
|
|
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) {
|
|
328
536
|
if (explicitPresId) {
|
|
329
|
-
const
|
|
330
|
-
if (
|
|
537
|
+
const matches = registry.filter((p) => p.presId === explicitPresId);
|
|
538
|
+
if (matches.length === 0) {
|
|
331
539
|
const ids = registry.map((p) => p.presId).join(", ") || "(none registered)";
|
|
332
540
|
throw new Error(
|
|
333
541
|
`No open PowerPoint window registered with presId='${explicitPresId}'. Live: ${ids}`,
|
|
334
542
|
);
|
|
335
543
|
}
|
|
336
|
-
|
|
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];
|
|
337
552
|
}
|
|
338
553
|
if (registry.length === 0) {
|
|
339
554
|
throw new Error(
|
|
@@ -341,11 +556,21 @@ export function pickPresentation(registry, explicitPresId) {
|
|
|
341
556
|
"Open a presentation, ensure the add-in is loaded, and try again.",
|
|
342
557
|
);
|
|
343
558
|
}
|
|
344
|
-
if (registry.length === 1) return registry[0]
|
|
559
|
+
if (registry.length === 1) return registry[0];
|
|
345
560
|
const list = registry
|
|
346
|
-
.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
|
+
)
|
|
347
566
|
.join("\n");
|
|
348
567
|
throw new Error(
|
|
349
568
|
`Multiple PowerPoint windows are open. Pick one with --to <presId>:\n${list}`,
|
|
350
569
|
);
|
|
351
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
|
+
}
|