slidemind 0.3.13 → 0.3.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +86 -21
- package/README.md +14 -17
- package/bin/slidemind.mjs +65 -4969
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +113 -74
- package/skills/search/SKILL.md +25 -7
- package/src/bridge/build-and-push.mjs +108 -15
- package/src/bridge/bundle.mjs +69 -0
- package/src/bridge/capture.mjs +19 -0
- package/src/bridge/client/brands.mjs +622 -0
- package/src/bridge/client/engine-routing.mjs +237 -0
- package/src/bridge/client/engine-search.mjs +310 -0
- package/src/bridge/client/engine-slides.mjs +435 -0
- package/src/bridge/client/pane-ops.mjs +335 -0
- package/src/bridge/client/presentations.mjs +587 -0
- package/src/bridge/client/render.mjs +317 -0
- package/src/bridge/client/transport.mjs +695 -0
- package/src/bridge/client.mjs +80 -3452
- package/src/bridge/config.mjs +1 -1
- package/src/bridge/engine-download.mjs +15 -2
- package/src/builders/build.mjs +11 -804
- package/src/builders/draft-check.mjs +376 -0
- package/src/builders/lint.mjs +38 -2
- package/src/builders/preview.html +799 -0
- package/src/builders/review.mjs +23 -4
- package/src/builders/visual-families.mjs +6 -8
- package/src/cli/common.mjs +179 -0
- package/src/cli/concepts.mjs +310 -0
- package/src/cli/connection.mjs +147 -0
- package/src/cli/deck.mjs +608 -0
- package/src/cli/designs.mjs +113 -0
- package/src/cli/diagnose.mjs +141 -0
- package/src/cli/download-bundle.mjs +144 -0
- package/src/cli/engine-helpers.mjs +429 -0
- package/src/cli/engine-results.mjs +320 -0
- package/src/cli/help.mjs +201 -0
- package/src/cli/install.mjs +257 -0
- package/src/cli/read.mjs +120 -0
- package/src/cli/ready.mjs +805 -0
- package/src/cli/search.mjs +608 -0
- package/src/cli/session.mjs +177 -0
- package/src/cli/setup.mjs +282 -0
- package/src/cli/slide-reuse.mjs +334 -0
- package/src/cli/status.mjs +118 -0
- package/src/cli/workspace.mjs +302 -0
- package/src/installers/codex.mjs +1 -1
- package/src/update-check.mjs +231 -0
package/src/bridge/client.mjs
CHANGED
|
@@ -5,3456 +5,84 @@
|
|
|
5
5
|
// The add-in's cloud
|
|
6
6
|
// backend runs render/bundle in-process and relays insert/replace/engine to the
|
|
7
7
|
// open PowerPoint pane. No localhost, no shared loopback token, no cert.
|
|
8
|
-
|
|
9
|
-
import { randomUUID } from "node:crypto";
|
|
10
|
-
import fs from "node:fs";
|
|
11
|
-
import http from "node:http";
|
|
12
|
-
import https from "node:https";
|
|
13
|
-
import os from "node:os";
|
|
14
|
-
import path from "node:path";
|
|
15
|
-
import { fileURLToPath } from "node:url";
|
|
16
|
-
|
|
17
|
-
import {
|
|
18
|
-
deriveEnvLabel,
|
|
19
|
-
envPhrase,
|
|
20
|
-
getSessionFor,
|
|
21
|
-
isMultiEnv,
|
|
22
|
-
readSessions,
|
|
23
|
-
} from "./config.mjs";
|
|
24
|
-
import {
|
|
25
|
-
ensureSignedIn,
|
|
26
|
-
getGraphToken,
|
|
27
|
-
getSharePointToken,
|
|
28
|
-
} from "./graph-auth.mjs";
|
|
29
|
-
import { updateCliInstruction } from "../install-channel.mjs";
|
|
30
|
-
|
|
31
|
-
// This CLI's own version, for the add-in's minCliVersion floor (see
|
|
32
|
-
// assertCliVersionSupported). Read lazily from the package manifest so the
|
|
33
|
-
// module stays importable even from an odd install layout.
|
|
34
|
-
let cliVersionCache = null;
|
|
35
|
-
export function cliVersion() {
|
|
36
|
-
if (cliVersionCache) return cliVersionCache;
|
|
37
|
-
try {
|
|
38
|
-
const pkgPath = path.join(
|
|
39
|
-
path.dirname(fileURLToPath(import.meta.url)),
|
|
40
|
-
"..",
|
|
41
|
-
"..",
|
|
42
|
-
"package.json",
|
|
43
|
-
);
|
|
44
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
45
|
-
cliVersionCache = typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
46
|
-
} catch {
|
|
47
|
-
cliVersionCache = "0.0.0";
|
|
48
|
-
}
|
|
49
|
-
return cliVersionCache;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Numeric-aware dotted-version compare (no prerelease semantics — the CLI
|
|
53
|
-
// releases plain X.Y.Z). Returns <0 / 0 / >0 like a comparator.
|
|
54
|
-
export function compareCliVersions(a, b) {
|
|
55
|
-
const pa = String(a ?? "").split(".").map((n) => parseInt(n, 10) || 0);
|
|
56
|
-
const pb = String(b ?? "").split(".").map((n) => parseInt(n, 10) || 0);
|
|
57
|
-
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
|
58
|
-
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
59
|
-
if (d !== 0) return d < 0 ? -1 : 1;
|
|
60
|
-
}
|
|
61
|
-
return 0;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// The one error every version-floor check raises, so the probe path
|
|
65
|
-
// (assertCliVersionSupported) and the per-response header path are
|
|
66
|
-
// indistinguishable to callers and to the skills that relay the text.
|
|
67
|
-
function cliUpdateRequiredError(min) {
|
|
68
|
-
const err = new Error(
|
|
69
|
-
`slidemind CLI ${cliVersion()} is older than the minimum this add-in ` +
|
|
70
|
-
`supports (${min}). ${updateCliInstruction()}`,
|
|
71
|
-
);
|
|
72
|
-
err.code = "cli_update_required";
|
|
73
|
-
return err;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// `doctor` and `ready` exist to EXPLAIN a broken connection, version skew
|
|
77
|
-
// included — they render it as a structured blocker with an `actor` and a
|
|
78
|
-
// nextAction. Throwing the raw error out of bridgeFetch would break the one
|
|
79
|
-
// command that diagnoses the problem, so they opt out of the header check
|
|
80
|
-
// (their own ladder still reports cli_update_required, terminally).
|
|
81
|
-
let diagnosticMode = false;
|
|
82
|
-
export function setDiagnosticMode(on) {
|
|
83
|
-
diagnosticMode = on !== false;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Every add-in response carries the version floor in `x-slidemind-min-cli`.
|
|
87
|
-
// Checking it here covers routes no capability probe guards — the pane ops
|
|
88
|
-
// (search/read/insert) and pairing itself — for zero extra round-trips, and it
|
|
89
|
-
// fires BEFORE the status handling below so a stale CLI hitting a route that
|
|
90
|
-
// does not exist yet reads "run slidemind update" instead of a bare 404.
|
|
91
|
-
function enforceVersionFloor(response) {
|
|
92
|
-
if (diagnosticMode) return;
|
|
93
|
-
const min = response?.headers?.["x-slidemind-min-cli"];
|
|
94
|
-
if (typeof min !== "string" || !min) return;
|
|
95
|
-
if (compareCliVersions(cliVersion(), min) >= 0) return;
|
|
96
|
-
throw cliUpdateRequiredError(min);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function isLoopbackHostname(hostname) {
|
|
100
|
-
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// The fan-out probe (listing presentations across every paired backend) uses a
|
|
104
|
-
// shorter timeout than ordinary calls so one dead/slow backend — a local relay
|
|
105
|
-
// that isn't running — can't stall the whole sweep for the full 35s default.
|
|
106
|
-
function listTimeoutMs() {
|
|
107
|
-
return (
|
|
108
|
-
parseInt(process.env.SLIDEMIND_BRIDGE_LIST_TIMEOUT_MS || "8000", 10) || 8000
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Engine ops (list / search / download / extract) are RELAYED through the task
|
|
113
|
-
// pane: the server waits up to ~25s for a suspended pane to reconnect, then up
|
|
114
|
-
// to 60s for the pane's ack. Budget above that sum so a typed pane error
|
|
115
|
-
// (pane_suspended / pane_timeout) wins over a client timeout. Note `ls engines`
|
|
116
|
-
// is pane-relayed too — using the 8s list timeout there masked the real
|
|
117
|
-
// pane_suspended as a bare "timed out after 8000ms".
|
|
118
|
-
function engineTimeoutMs() {
|
|
119
|
-
return (
|
|
120
|
-
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
121
|
-
90000
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function readinessTimeoutMs() {
|
|
126
|
-
return (
|
|
127
|
-
parseInt(process.env.SLIDEMIND_BRIDGE_READY_TIMEOUT_MS || "20000", 10) ||
|
|
128
|
-
20000
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const NOT_PAIRED_MESSAGE =
|
|
133
|
-
"slidemind is not connected. In PowerPoint open slidemind → Settings → " +
|
|
134
|
-
"General → Connect CLI, press the link button, and run the command it shows.";
|
|
135
|
-
|
|
136
|
-
// The one-command remedy every host-precondition error now points at:
|
|
137
|
-
// `slidemind ready` runs authorization → reachability → version → window →
|
|
138
|
-
// design ladder and materializes the bundle, so a stuck author has a single
|
|
139
|
-
// next step instead of a scavenger hunt across doctor/presentations/download.
|
|
140
|
-
// Appended (never substituted) to the existing prose so callers' assert.match
|
|
141
|
-
// on the original text keeps passing.
|
|
142
|
-
const READY_HINT =
|
|
143
|
-
"Run `slidemind ready` to check the connection and set up in one step.";
|
|
144
|
-
// pickPresentationEntry's variant: the caller already HAS a host, it's just
|
|
145
|
-
// ambiguous — `ready` resolves the window AND the design in one shot.
|
|
146
|
-
const READY_HINT_PICK =
|
|
147
|
-
"Or run `slidemind ready` first — it resolves the window and design in one step.";
|
|
148
|
-
|
|
149
|
-
// One transient blip — a pane mid-reconnect after a backend deploy, a stale
|
|
150
|
-
// registration the pane is about to refresh — should not fail a whole skill
|
|
151
|
-
// step. Idempotent reads retry a couple of times; mutations never do (the
|
|
152
|
-
// request may already have been delivered).
|
|
153
|
-
function transientRetryDelays() {
|
|
154
|
-
const raw = process.env.SLIDEMIND_BRIDGE_RETRY_DELAYS_MS || "1500,4000";
|
|
155
|
-
return raw
|
|
156
|
-
.split(",")
|
|
157
|
-
.map((s) => parseInt(s.trim(), 10))
|
|
158
|
-
.filter((n) => Number.isFinite(n) && n >= 0);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// A 401 from either bridge transport. Carries the remediation as its message
|
|
162
|
-
// AND a machine-readable shape, because callers that probe several backends in
|
|
163
|
-
// a row have to tell "this environment doesn't have that record" (routing
|
|
164
|
-
// information) from "this session is dead" (the user's problem) — see
|
|
165
|
-
// resolveSearchBackend.
|
|
166
|
-
function sessionInvalidError() {
|
|
167
|
-
// Deliberately does NOT say `logout --all`: a 401 means the add-in answered
|
|
168
|
-
// "no such session" (revoked in Settings → Connect CLI, or expired). Running
|
|
169
|
-
// `setup` with a fresh command replaces this backend's record in place, so
|
|
170
|
-
// nothing has to be wiped first — and a user with two environments who wipes
|
|
171
|
-
// everything on every 401 is how the 2026-08-27 reconnect loop happened.
|
|
172
|
-
const err = new Error(
|
|
173
|
-
"slidemind session is invalid or expired: the add-in no longer knows this session " +
|
|
174
|
-
"(revoked under Settings → General → Connect CLI, or expired). Reconnect with a fresh " +
|
|
175
|
-
"command from that panel; other environments and your Microsoft 365 sign-in stay as they are.",
|
|
176
|
-
);
|
|
177
|
-
err.status = 401;
|
|
178
|
-
err.code = "session_invalid";
|
|
179
|
-
return err;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// True for the 401 class above, and for anything else that arrives with a 401
|
|
183
|
-
// status (an older path, or a rethrown response error).
|
|
184
|
-
export function isSessionInvalidError(err) {
|
|
185
|
-
if (!err) return false;
|
|
186
|
-
if (err.status === 401 || err.code === "session_invalid") return true;
|
|
187
|
-
return /slidemind session is invalid or expired/i.test(String(err.message ?? ""));
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function isTransientBridgeError(err) {
|
|
191
|
-
if (!err) return false;
|
|
192
|
-
// pane_suspended needs the user (foreground PowerPoint), not a blind retry —
|
|
193
|
-
// the relay already waited for a reconnect before returning it.
|
|
194
|
-
if (err.code === "pane_suspended") return false;
|
|
195
|
-
if (err.status === 404 || err.status === 502 || err.status === 503) return true;
|
|
196
|
-
if (err.status === 409 && err.code === "pane_disconnected") return true;
|
|
197
|
-
return /relay unreachable/i.test(String(err.message || ""));
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
async function withTransientRetry(fn, isRetryable = isTransientBridgeError) {
|
|
201
|
-
const delays = transientRetryDelays();
|
|
202
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
203
|
-
try {
|
|
204
|
-
return await fn();
|
|
205
|
-
} catch (err) {
|
|
206
|
-
if (attempt >= delays.length || !isRetryable(err)) throw err;
|
|
207
|
-
await new Promise((resolve) => setTimeout(resolve, delays[attempt]));
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// The paired backends in scope for this invocation. Defaults to every paired
|
|
213
|
-
// session (fan-out); `SLIDEMIND_BACKEND` narrows it to one (the CLI sets that
|
|
214
|
-
// env from a `--backend <url|env>` flag) for speed or to disambiguate. Throws
|
|
215
|
-
// the same actionable message as a missing pairing when nothing matches.
|
|
216
|
-
function sessionsInScope() {
|
|
217
|
-
const all = readSessions();
|
|
218
|
-
if (all.length === 0) throw new Error(NOT_PAIRED_MESSAGE);
|
|
219
|
-
const scope = process.env.SLIDEMIND_BACKEND;
|
|
220
|
-
if (scope && scope.trim()) {
|
|
221
|
-
const want = scope.trim();
|
|
222
|
-
const match = all.filter(
|
|
223
|
-
(s) =>
|
|
224
|
-
s.backendURL === want ||
|
|
225
|
-
s.label === want ||
|
|
226
|
-
deriveEnvLabel(s.backendURL) === want ||
|
|
227
|
-
s.backendURL.includes(want),
|
|
228
|
-
);
|
|
229
|
-
if (match.length === 0) {
|
|
230
|
-
throw new Error(
|
|
231
|
-
`slidemind is not paired with '${want}'. Run 'slidemind whoami' to see paired environments.`,
|
|
232
|
-
);
|
|
233
|
-
}
|
|
234
|
-
return match;
|
|
235
|
-
}
|
|
236
|
-
return all;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Resolve the credential for one backend (or the default session when no
|
|
240
|
-
// backendURL is given — the back-compat single-session path).
|
|
241
|
-
function sessionForBackend(backendURL) {
|
|
242
|
-
if (backendURL) {
|
|
243
|
-
const s = getSessionFor(backendURL);
|
|
244
|
-
if (!s)
|
|
245
|
-
throw new Error(
|
|
246
|
-
`slidemind is not paired with '${deriveEnvLabel(backendURL)}'.`,
|
|
247
|
-
);
|
|
248
|
-
return s;
|
|
249
|
-
}
|
|
250
|
-
const all = readSessions();
|
|
251
|
-
if (all.length === 0) throw new Error(NOT_PAIRED_MESSAGE);
|
|
252
|
-
return all[0];
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
async function bridgeFetch(pathPart, init = {}, backendURL = null) {
|
|
256
|
-
const session = sessionForBackend(backendURL);
|
|
257
|
-
const url = `${session.backendURL}${pathPart}`;
|
|
258
|
-
const headers = {
|
|
259
|
-
accept: "application/json",
|
|
260
|
-
...init.headers,
|
|
261
|
-
authorization: `Bearer ${session.sessionToken}`,
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
let response;
|
|
265
|
-
try {
|
|
266
|
-
response = await nodeRequest(url, { ...init, headers });
|
|
267
|
-
} catch (err) {
|
|
268
|
-
if (err && /ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(String(err.message || err))) {
|
|
269
|
-
throw new Error(
|
|
270
|
-
`slidemind relay unreachable${envPhrase(deriveEnvLabel(session.backendURL), "on")}. Check your connection; ` +
|
|
271
|
-
"if it persists, reconnect from the add-in's Connect CLI panel.",
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
|
-
throw err;
|
|
275
|
-
}
|
|
276
|
-
enforceVersionFloor(response);
|
|
277
|
-
if (response.status === 401) throw sessionInvalidError();
|
|
278
|
-
if (response.status < 200 || response.status >= 300) {
|
|
279
|
-
let parsed = null;
|
|
280
|
-
try {
|
|
281
|
-
parsed = response.body ? JSON.parse(response.body) : null;
|
|
282
|
-
} catch {
|
|
283
|
-
/* non-JSON error body — fall back to the raw text */
|
|
284
|
-
}
|
|
285
|
-
// Prefer the machine-readable `code` (e.g. "pane_suspended",
|
|
286
|
-
// "engine_auth_expired"); fall back to the human `error` string for routes
|
|
287
|
-
// that don't emit a discrete code yet. Keep the human text in the message.
|
|
288
|
-
const machineCode =
|
|
289
|
-
parsed && typeof parsed.code === "string" ? parsed.code : null;
|
|
290
|
-
const humanError =
|
|
291
|
-
parsed && typeof parsed.error === "string" ? parsed.error : null;
|
|
292
|
-
const detail =
|
|
293
|
-
humanError ?? (response.body ? response.body.slice(0, 400) : "");
|
|
294
|
-
const err = new Error(
|
|
295
|
-
`slidemind bridge ${response.status} ${response.statusText} for ${pathPart}` +
|
|
296
|
-
(detail ? `: ${detail}` : ""),
|
|
297
|
-
);
|
|
298
|
-
err.status = response.status;
|
|
299
|
-
const code = machineCode ?? humanError;
|
|
300
|
-
if (code) err.code = code;
|
|
301
|
-
if (parsed) err.body = parsed;
|
|
302
|
-
throw err;
|
|
303
|
-
}
|
|
304
|
-
return {
|
|
305
|
-
json: async () => (response.body ? JSON.parse(response.body) : null),
|
|
306
|
-
text: async () => response.body,
|
|
307
|
-
// Exposed for routes that carry their answer partly in headers (the
|
|
308
|
-
// source-presentation read reports slide counts and truncation there, so a
|
|
309
|
-
// caller learns the deck was windowed without parsing the Markdown).
|
|
310
|
-
headers: response.headers ?? {},
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function nodeRequest(urlString, init = {}) {
|
|
315
|
-
const url = new URL(urlString);
|
|
316
|
-
const isHttps = url.protocol === "https:";
|
|
317
|
-
const transport = isHttps ? https : http;
|
|
318
|
-
const body = init.body ?? null;
|
|
319
|
-
const headers = { ...(init.headers ?? {}) };
|
|
320
|
-
if (body && headers["content-length"] === undefined && headers["Content-Length"] === undefined) {
|
|
321
|
-
headers["content-length"] = Buffer.byteLength(body);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const timeoutMs =
|
|
325
|
-
init.timeoutMs ??
|
|
326
|
-
(parseInt(process.env.SLIDEMIND_BRIDGE_TIMEOUT_MS || "35000", 10) || 35000);
|
|
327
|
-
return new Promise((resolve, reject) => {
|
|
328
|
-
const req = transport.request(
|
|
329
|
-
url,
|
|
330
|
-
{
|
|
331
|
-
method: init.method ?? "GET",
|
|
332
|
-
headers,
|
|
333
|
-
// Validate the cloud's real cert; only skip for a loopback dev backend
|
|
334
|
-
// (self-signed) when pointing the CLI at a local add-in for development.
|
|
335
|
-
rejectUnauthorized: isHttps ? !isLoopbackHostname(url.hostname) : undefined,
|
|
336
|
-
},
|
|
337
|
-
(res) => {
|
|
338
|
-
const chunks = [];
|
|
339
|
-
res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
340
|
-
res.on("end", () => {
|
|
341
|
-
resolve({
|
|
342
|
-
status: res.statusCode ?? 0,
|
|
343
|
-
statusText: res.statusMessage ?? "",
|
|
344
|
-
headers: res.headers,
|
|
345
|
-
body: Buffer.concat(chunks).toString("utf8"),
|
|
346
|
-
});
|
|
347
|
-
});
|
|
348
|
-
},
|
|
349
|
-
);
|
|
350
|
-
// Default 35s > the relay's 25s wait-for-pane-reconnect, so a suspended
|
|
351
|
-
// pane surfaces a typed `pane_suspended` rather than a client timeout. A
|
|
352
|
-
// real timeout is tagged `bridge_timeout` so non-idempotent callers
|
|
353
|
-
// (insert) know the request may already have been delivered and must NOT
|
|
354
|
-
// auto-retry it.
|
|
355
|
-
req.setTimeout(timeoutMs, () => {
|
|
356
|
-
const err = new Error(
|
|
357
|
-
`slidemind bridge request timed out after ${timeoutMs}ms`,
|
|
358
|
-
);
|
|
359
|
-
err.code = "bridge_timeout";
|
|
360
|
-
req.destroy(err);
|
|
361
|
-
});
|
|
362
|
-
req.on("error", reject);
|
|
363
|
-
if (body) req.write(body);
|
|
364
|
-
req.end();
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// A streaming sibling of `nodeRequest`, for responses too large to hold in
|
|
369
|
-
// memory. `nodeRequest` concatenates the whole body and decodes it as UTF-8 —
|
|
370
|
-
// fine for JSON, but it would both balloon memory and CORRUPT binary. This pipes
|
|
371
|
-
// a success response straight to `destPath` and never materializes it.
|
|
372
8
|
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
`slidemind bridge request timed out after ${timeoutMs}ms`,
|
|
454
|
-
);
|
|
455
|
-
err.code = "bridge_timeout";
|
|
456
|
-
req.destroy(err);
|
|
457
|
-
});
|
|
458
|
-
req.on("error", reject);
|
|
459
|
-
if (body) req.write(body);
|
|
460
|
-
req.end();
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// `bridgeFetch` for a response streamed to disk. Shares the session lookup,
|
|
465
|
-
// auth header and non-2xx error mapping so a streaming call fails exactly like
|
|
466
|
-
// every other bridge call.
|
|
467
|
-
async function bridgeStreamToFile(pathPart, init, destPath, backendURL = null) {
|
|
468
|
-
const session = sessionForBackend(backendURL);
|
|
469
|
-
const url = `${session.backendURL}${pathPart}`;
|
|
470
|
-
const headers = {
|
|
471
|
-
accept: "application/octet-stream, application/json",
|
|
472
|
-
...init.headers,
|
|
473
|
-
authorization: `Bearer ${session.sessionToken}`,
|
|
474
|
-
};
|
|
475
|
-
let response;
|
|
476
|
-
try {
|
|
477
|
-
response = await nodeRequestToFile(url, { ...init, headers }, destPath);
|
|
478
|
-
} catch (err) {
|
|
479
|
-
if (
|
|
480
|
-
err &&
|
|
481
|
-
/ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(
|
|
482
|
-
String(err.message || err),
|
|
483
|
-
)
|
|
484
|
-
) {
|
|
485
|
-
throw new Error(
|
|
486
|
-
`slidemind relay unreachable${envPhrase(deriveEnvLabel(session.backendURL), "on")}. Check your connection; ` +
|
|
487
|
-
"if it persists, reconnect from the add-in's Connect CLI panel.",
|
|
488
|
-
);
|
|
489
|
-
}
|
|
490
|
-
throw err;
|
|
491
|
-
}
|
|
492
|
-
enforceVersionFloor(response);
|
|
493
|
-
if (response.status === 401) throw sessionInvalidError();
|
|
494
|
-
if (response.status < 200 || response.status >= 300) {
|
|
495
|
-
let parsed = null;
|
|
496
|
-
try {
|
|
497
|
-
parsed = response.body ? JSON.parse(response.body) : null;
|
|
498
|
-
} catch {
|
|
499
|
-
/* non-JSON error body */
|
|
500
|
-
}
|
|
501
|
-
const machineCode =
|
|
502
|
-
parsed && typeof parsed.code === "string" ? parsed.code : null;
|
|
503
|
-
const humanError =
|
|
504
|
-
parsed && typeof parsed.error === "string" ? parsed.error : null;
|
|
505
|
-
const detail =
|
|
506
|
-
humanError ?? (response.body ? response.body.slice(0, 400) : "");
|
|
507
|
-
const err = new Error(
|
|
508
|
-
`slidemind bridge ${response.status} ${response.statusText} for ${pathPart}` +
|
|
509
|
-
(detail ? `: ${detail}` : ""),
|
|
510
|
-
);
|
|
511
|
-
err.status = response.status;
|
|
512
|
-
const code = machineCode ?? humanError;
|
|
513
|
-
if (code) err.code = code;
|
|
514
|
-
if (parsed) err.body = parsed;
|
|
515
|
-
throw err;
|
|
516
|
-
}
|
|
517
|
-
return response;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
// List one backend's open presentations, tagging each entry with its origin
|
|
521
|
-
// (backendURL + a short env label) so the merged registry can be routed and
|
|
522
|
-
// displayed per environment. Transient blips retry per-backend.
|
|
523
|
-
async function listPresentationsForBackend(backendURL) {
|
|
524
|
-
return withTransientRetry(async () => {
|
|
525
|
-
const res = await bridgeFetch(
|
|
526
|
-
"/api/cli/presentations",
|
|
527
|
-
{ timeoutMs: listTimeoutMs() },
|
|
528
|
-
backendURL,
|
|
529
|
-
);
|
|
530
|
-
const data = await res.json();
|
|
531
|
-
const arr = Array.isArray(data?.presentations) ? data.presentations : [];
|
|
532
|
-
const env = deriveEnvLabel(backendURL);
|
|
533
|
-
return arr.map((p) => ({ ...p, backendURL, env }));
|
|
534
|
-
});
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
async function readinessForBackend(backendURL) {
|
|
538
|
-
const env = deriveEnvLabel(backendURL);
|
|
539
|
-
try {
|
|
540
|
-
// Retry only a 503: the add-in answers that (code `backend_unavailable`)
|
|
541
|
-
// while its session store cannot respond, and a 404 here means an older
|
|
542
|
-
// add-in without the readiness route, which the fallback below handles
|
|
543
|
-
// without waiting out the retry delays.
|
|
544
|
-
const res = await withTransientRetry(
|
|
545
|
-
() =>
|
|
546
|
-
bridgeFetch(
|
|
547
|
-
"/api/cli/readiness",
|
|
548
|
-
{ timeoutMs: listTimeoutMs() },
|
|
549
|
-
backendURL,
|
|
550
|
-
),
|
|
551
|
-
(err) => err?.status === 503 && err?.code !== "pane_suspended",
|
|
552
|
-
);
|
|
553
|
-
const data = (await res.json()) ?? {};
|
|
554
|
-
const presentations = Array.isArray(data.presentations)
|
|
555
|
-
? data.presentations.map((p) => ({ ...p, backendURL, env }))
|
|
556
|
-
: [];
|
|
557
|
-
// Version-floor check for diagnostics (doctor / connection_status): newer
|
|
558
|
-
// add-ins advertise minCliVersion in the readiness capabilities, so the
|
|
559
|
-
// skew is visible BEFORE a render trips over it.
|
|
560
|
-
const minCliVersion =
|
|
561
|
-
typeof data.capabilities?.minCliVersion === "string"
|
|
562
|
-
? data.capabilities.minCliVersion
|
|
563
|
-
: null;
|
|
564
|
-
return {
|
|
565
|
-
env,
|
|
566
|
-
backendURL,
|
|
567
|
-
ok: true,
|
|
568
|
-
readinessV1: data.readinessV1 === true,
|
|
569
|
-
capabilities: data.capabilities ?? null,
|
|
570
|
-
minCliVersion,
|
|
571
|
-
cliUpdateRequired: Boolean(
|
|
572
|
-
minCliVersion && compareCliVersions(cliVersion(), minCliVersion) < 0,
|
|
573
|
-
),
|
|
574
|
-
presentations,
|
|
575
|
-
summary: data.summary ?? null,
|
|
576
|
-
};
|
|
577
|
-
} catch (err) {
|
|
578
|
-
if (err?.status !== 404) throw err;
|
|
579
|
-
// Older add-ins do not serve /readiness. Fall back to the legacy
|
|
580
|
-
// presentations route and mark the result as a compatibility response.
|
|
581
|
-
const presentations = await listPresentationsForBackend(backendURL);
|
|
582
|
-
return {
|
|
583
|
-
env,
|
|
584
|
-
backendURL,
|
|
585
|
-
ok: true,
|
|
586
|
-
readinessV1: false,
|
|
587
|
-
capabilities: null,
|
|
588
|
-
presentations,
|
|
589
|
-
summary: null,
|
|
590
|
-
};
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
export async function connectionStatus() {
|
|
595
|
-
const sessions = sessionsInScope();
|
|
596
|
-
const settled = await Promise.allSettled(
|
|
597
|
-
sessions.map((s) => readinessForBackend(s.backendURL)),
|
|
598
|
-
);
|
|
599
|
-
const backends = settled.map((r, i) => {
|
|
600
|
-
const backendURL = sessions[i].backendURL;
|
|
601
|
-
const env = deriveEnvLabel(backendURL);
|
|
602
|
-
if (r.status === "fulfilled") return r.value;
|
|
603
|
-
return {
|
|
604
|
-
env,
|
|
605
|
-
backendURL,
|
|
606
|
-
ok: false,
|
|
607
|
-
error: r.reason?.message ? r.reason.message : String(r.reason),
|
|
608
|
-
// Machine code of the failure (`backend_unavailable`, `session_invalid`,
|
|
609
|
-
// …) so the readiness ladder can tell "retry shortly" from "re-pair".
|
|
610
|
-
code: typeof r.reason?.code === "string" ? r.reason.code : null,
|
|
611
|
-
// The HTTP status too: a proxy's 503 carries no machine code (or only a
|
|
612
|
-
// prose `error`), and the ladder must still read it as "wait".
|
|
613
|
-
status: typeof r.reason?.status === "number" ? r.reason.status : null,
|
|
614
|
-
presentations: [],
|
|
615
|
-
};
|
|
616
|
-
});
|
|
617
|
-
return {
|
|
618
|
-
paired: sessions.length > 0,
|
|
619
|
-
backendCount: sessions.length,
|
|
620
|
-
backends,
|
|
621
|
-
presentations: backends.flatMap((b) => b.presentations ?? []),
|
|
622
|
-
};
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// Fan out across every backend in scope, concurrently. Never rejects: each
|
|
626
|
-
// backend's outcome is partitioned into merged presentations vs. a failure
|
|
627
|
-
// record, so one unreachable environment can't hide the others.
|
|
628
|
-
async function fanOutPresentations() {
|
|
629
|
-
const sessions = sessionsInScope();
|
|
630
|
-
const settled = await Promise.allSettled(
|
|
631
|
-
sessions.map((s) => listPresentationsForBackend(s.backendURL)),
|
|
632
|
-
);
|
|
633
|
-
const presentations = [];
|
|
634
|
-
const failures = [];
|
|
635
|
-
let fulfilledCount = 0;
|
|
636
|
-
settled.forEach((r, i) => {
|
|
637
|
-
const backendURL = sessions[i].backendURL;
|
|
638
|
-
if (r.status === "fulfilled") {
|
|
639
|
-
fulfilledCount += 1;
|
|
640
|
-
presentations.push(...r.value);
|
|
641
|
-
} else {
|
|
642
|
-
failures.push({
|
|
643
|
-
backendURL,
|
|
644
|
-
env: deriveEnvLabel(backendURL),
|
|
645
|
-
err: r.reason,
|
|
646
|
-
});
|
|
647
|
-
}
|
|
648
|
-
});
|
|
649
|
-
return { presentations, failures, fulfilledCount };
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
// Merged registry across all paired backends. Stays a flat array (every caller
|
|
653
|
-
// expects one). Preserves single-backend behaviour: when nothing is reachable
|
|
654
|
-
// AND no backend answered, the underlying bridge error propagates (so a typed
|
|
655
|
-
// code like `pane_suspended` or a 401 still surfaces) — for several failing
|
|
656
|
-
// backends it's an aggregate message.
|
|
657
|
-
export async function listOpenPresentations() {
|
|
658
|
-
const { presentations, failures, fulfilledCount } =
|
|
659
|
-
await fanOutPresentations();
|
|
660
|
-
if (presentations.length === 0 && fulfilledCount === 0 && failures.length > 0) {
|
|
661
|
-
if (failures.length === 1) throw failures[0].err;
|
|
662
|
-
const err = new Error(
|
|
663
|
-
"slidemind: every paired backend failed to list presentations:\n" +
|
|
664
|
-
failures
|
|
665
|
-
.map((f) => ` ${f.env}: ${f.err.message}`)
|
|
666
|
-
.join("\n"),
|
|
667
|
-
);
|
|
668
|
-
throw err;
|
|
669
|
-
}
|
|
670
|
-
return presentations;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
// The user/agent-facing shape of a presentation entry. `backendURL` is internal
|
|
674
|
-
// routing state — every caller targets a presentation by `presId` (the backend
|
|
675
|
-
// is re-resolved server-side from it), so the URL must never leave the process
|
|
676
|
-
// in any printed line or command output. Keeps everything else — presId, title,
|
|
677
|
-
// fileName, connected, suspendedAt, the short `env` label, and the readiness
|
|
678
|
-
// fields (connectionState, designState, capabilities, nextAction) that agents
|
|
679
|
-
// parse from `presentations --json` to decide whether/how to target a pane.
|
|
680
|
-
export function toPublicPresentation(p) {
|
|
681
|
-
const { backendURL, ...rest } = p;
|
|
682
|
-
return rest;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
// Like listOpenPresentations but never throws on per-backend failures — returns
|
|
686
|
-
// the merged registry plus which backends were unreachable, for callers that
|
|
687
|
-
// want to report partial results (requireHost, whoami).
|
|
688
|
-
export async function listOpenPresentationsDetailed() {
|
|
689
|
-
const { presentations, failures } = await fanOutPresentations();
|
|
690
|
-
return {
|
|
691
|
-
presentations,
|
|
692
|
-
unreachable: failures.map((f) => ({
|
|
693
|
-
backendURL: f.backendURL,
|
|
694
|
-
env: f.env,
|
|
695
|
-
error: f.err?.message ? f.err.message : String(f.err),
|
|
696
|
-
})),
|
|
697
|
-
};
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// Probe the bridge for an open PowerPoint host across every paired backend.
|
|
701
|
-
// Returns the merged registry, or throws a single actionable message — the
|
|
702
|
-
// shared precondition for every author/render command now that the design truth
|
|
703
|
-
// lives in the add-in. Suspended entries (task-pane socket dropped, e.g.
|
|
704
|
-
// PowerPoint backgrounded) count as registered: render + bundle work without
|
|
705
|
-
// the pane, and a push waits server-side for the pane to reconnect.
|
|
706
|
-
export async function requireHost() {
|
|
707
|
-
const { presentations, unreachable } = await listOpenPresentationsDetailed();
|
|
708
|
-
if (Array.isArray(presentations) && presentations.length > 0) {
|
|
709
|
-
return presentations;
|
|
710
|
-
}
|
|
711
|
-
// Nothing registered. Distinguish "a backend was unreachable" from "backends
|
|
712
|
-
// answered but no window is open" so the user gets the right next step.
|
|
713
|
-
if (unreachable.length > 0) {
|
|
714
|
-
throw new Error(
|
|
715
|
-
"Open PowerPoint with the slidemind add-in and an active corporate design, then retry.\n" +
|
|
716
|
-
unreachable
|
|
717
|
-
.map((u) => ` (bridge: ${u.env}: ${u.error})`)
|
|
718
|
-
.join("\n") +
|
|
719
|
-
`\n${READY_HINT}`,
|
|
720
|
-
);
|
|
721
|
-
}
|
|
722
|
-
throw new Error(
|
|
723
|
-
"No PowerPoint window is registered with the slidemind add-in.\n" +
|
|
724
|
-
"If PowerPoint is already open, bring it to the foreground and click the slidemind pane once so it re-registers, then retry.\n" +
|
|
725
|
-
"Otherwise open PowerPoint with the slidemind add-in and an active corporate design, then retry.\n" +
|
|
726
|
-
READY_HINT,
|
|
727
|
-
);
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
function hasReadinessFields(p) {
|
|
731
|
-
return (
|
|
732
|
-
p &&
|
|
733
|
-
(typeof p.connectionState === "string" ||
|
|
734
|
-
typeof p.designState === "string" ||
|
|
735
|
-
(p.capabilities && typeof p.capabilities === "object"))
|
|
736
|
-
);
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
export function isRenderReadyPresentation(p) {
|
|
740
|
-
if (!p) return false;
|
|
741
|
-
if (!hasReadinessFields(p)) return true;
|
|
742
|
-
if (p.capabilities?.render === true) return true;
|
|
743
|
-
if (p.designState === "ready" && p.capabilities?.render !== false) return true;
|
|
744
|
-
return false;
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
export function isPaneReadyPresentation(p) {
|
|
748
|
-
if (!p) return false;
|
|
749
|
-
if (!hasReadinessFields(p)) return p.connected !== false && !p.suspendedAt;
|
|
750
|
-
if (p.capabilities?.paneOps === true) return true;
|
|
751
|
-
return (
|
|
752
|
-
p.connectionState === "connected" &&
|
|
753
|
-
p.connected !== false &&
|
|
754
|
-
!p.suspendedAt &&
|
|
755
|
-
p.connectionState !== "stale"
|
|
756
|
-
);
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
async function waitForReadyPresentation(predicate, { timeoutMs = null } = {}) {
|
|
760
|
-
const waitMs = timeoutMs ?? readinessTimeoutMs();
|
|
761
|
-
const pollMs = envInt("SLIDEMIND_BRIDGE_READY_POLL_MS", 1500);
|
|
762
|
-
const deadline = Date.now() + waitMs;
|
|
763
|
-
for (;;) {
|
|
764
|
-
const { presentations, unreachable } = await listOpenPresentationsDetailed();
|
|
765
|
-
if (presentations.some(predicate)) return presentations;
|
|
766
|
-
// Do not wait when nothing is registered at all. Waiting helps only for an
|
|
767
|
-
// already-open pane that is still hydrating brand/readiness metadata.
|
|
768
|
-
if (presentations.length === 0 || Date.now() >= deadline) {
|
|
769
|
-
return { presentations, unreachable, timedOut: presentations.length > 0 };
|
|
770
|
-
}
|
|
771
|
-
await sleep(pollMs);
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
// The bounded poll behind `slidemind presentations`. Unlike
|
|
776
|
-
// waitForReadyPresentation it PURPOSELY does NOT early-return on an empty
|
|
777
|
-
// registry: the empty window right after a pane reload / PowerPoint wake is
|
|
778
|
-
// exactly the flaky case this papers over. Polls the merged registry every
|
|
779
|
-
// SLIDEMIND_BRIDGE_READY_POLL_MS (default 1.5s) up to
|
|
780
|
-
// SLIDEMIND_BRIDGE_PRESENTATIONS_WAIT_MS (default 6s), stopping as soon as at
|
|
781
|
-
// least one pane is pane-ready. A `timeoutMs` of 0 means single-shot — one
|
|
782
|
-
// fetch, no sleep. Returns { all, ready } so the caller can list EVERY entry
|
|
783
|
-
// (suspended included, with its status) yet still know which are live. Does not
|
|
784
|
-
// touch waitForReadyPresentation / requirePaneHost — agent target selection
|
|
785
|
-
// still uses only ready panes.
|
|
786
|
-
export async function listReadyPresentationsWithWait({ timeoutMs = null } = {}) {
|
|
787
|
-
const waitMs =
|
|
788
|
-
timeoutMs ?? envInt("SLIDEMIND_BRIDGE_PRESENTATIONS_WAIT_MS", 6000);
|
|
789
|
-
const pollMs = envInt("SLIDEMIND_BRIDGE_READY_POLL_MS", 1500);
|
|
790
|
-
const deadline = Date.now() + waitMs;
|
|
791
|
-
for (;;) {
|
|
792
|
-
const all = await listOpenPresentations();
|
|
793
|
-
const ready = all.filter(isPaneReadyPresentation);
|
|
794
|
-
if (ready.length > 0 || waitMs <= 0 || Date.now() >= deadline) {
|
|
795
|
-
return { all, ready };
|
|
796
|
-
}
|
|
797
|
-
await sleep(pollMs);
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
export async function requireRenderHost({ timeoutMs = null } = {}) {
|
|
802
|
-
const first = await listOpenPresentationsDetailed();
|
|
803
|
-
if (first.presentations.some(isRenderReadyPresentation)) {
|
|
804
|
-
return first.presentations;
|
|
805
|
-
}
|
|
806
|
-
if (first.presentations.length > 0) {
|
|
807
|
-
const waited = await waitForReadyPresentation(isRenderReadyPresentation, {
|
|
808
|
-
timeoutMs,
|
|
809
|
-
});
|
|
810
|
-
if (Array.isArray(waited)) return waited;
|
|
811
|
-
if (waited.presentations.some(isRenderReadyPresentation)) {
|
|
812
|
-
return waited.presentations;
|
|
813
|
-
}
|
|
814
|
-
throw new Error(
|
|
815
|
-
"PowerPoint is registered with the slidemind add-in, but no window is render-ready yet. " +
|
|
816
|
-
"Wait for the corporate design to finish matching, or pick/create a corporate design in the add-in, then retry. " +
|
|
817
|
-
READY_HINT,
|
|
818
|
-
);
|
|
819
|
-
}
|
|
820
|
-
return requireHost();
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
export async function requirePaneHost({ timeoutMs = null } = {}) {
|
|
824
|
-
const first = await listOpenPresentationsDetailed();
|
|
825
|
-
if (first.presentations.some(isPaneReadyPresentation)) {
|
|
826
|
-
return first.presentations.filter(isPaneReadyPresentation);
|
|
827
|
-
}
|
|
828
|
-
if (first.presentations.length > 0) {
|
|
829
|
-
const waited = await waitForReadyPresentation(isPaneReadyPresentation, {
|
|
830
|
-
timeoutMs,
|
|
831
|
-
});
|
|
832
|
-
if (Array.isArray(waited)) return waited.filter(isPaneReadyPresentation);
|
|
833
|
-
if (waited.presentations.some(isPaneReadyPresentation)) {
|
|
834
|
-
return waited.presentations.filter(isPaneReadyPresentation);
|
|
835
|
-
}
|
|
836
|
-
throw new Error(
|
|
837
|
-
"PowerPoint is registered with the slidemind add-in, but the task pane is not ready for pane operations. " +
|
|
838
|
-
"Bring PowerPoint forward and click or reopen the slidemind pane, then retry. " +
|
|
839
|
-
READY_HINT,
|
|
840
|
-
);
|
|
841
|
-
}
|
|
842
|
-
return requireHost();
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
// "Is a task pane available RIGHT NOW?" — the non-throwing twin of
|
|
846
|
-
// requirePaneHost, for paths where an open pane is an OPTION rather than a
|
|
847
|
-
// precondition (a stale permission snapshot can be routed around by relaying
|
|
848
|
-
// through a live pane, which signs each request with a fresh browser token).
|
|
849
|
-
// Never waits for hydration and never authors a blocker: an empty array means
|
|
850
|
-
// "no, carry on with what you were doing", not "stop".
|
|
851
|
-
export async function paneReadyPresentations() {
|
|
852
|
-
try {
|
|
853
|
-
const { presentations } = await listOpenPresentationsDetailed();
|
|
854
|
-
return presentations.filter(isPaneReadyPresentation);
|
|
855
|
-
} catch {
|
|
856
|
-
return [];
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
// Tie-break a multi-backend presId collision without bothering the user when
|
|
861
|
-
// only one candidate can actually serve the deck:
|
|
862
|
-
// (a) exactly one CONNECTED pane whose active CD matches the deck's bundle
|
|
863
|
-
// ref (`preferBrandId` — the deck's presFP, which equals the pane's
|
|
864
|
-
// brandId by the bundle-cache contract);
|
|
865
|
-
// (b) else exactly one connected pane with ANY active CD (brandId set) —
|
|
866
|
-
// e.g. a local dev backend connected without a CD next to prod with one.
|
|
867
|
-
// Returns the winning entry or null when genuinely ambiguous.
|
|
868
|
-
function tieBreakMatches(matches, preferBrandId) {
|
|
869
|
-
const live = matches.filter(isPaneReadyPresentation);
|
|
870
|
-
if (preferBrandId) {
|
|
871
|
-
const want = live.filter((m) => m.brandId === preferBrandId);
|
|
872
|
-
if (want.length === 1) return want[0];
|
|
873
|
-
}
|
|
874
|
-
const withCd = live.filter((m) => m.brandId);
|
|
875
|
-
if (withCd.length === 1) return withCd[0];
|
|
876
|
-
return null;
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
// Resolve which backend a presId lives on via a LIVE fan-out list (never a
|
|
880
|
-
// cached map — a presId can move backends on re-pair/redeploy). Throws an
|
|
881
|
-
// actionable message on a miss, or an ambiguity error when the same presId is
|
|
882
|
-
// open on more than one reachable backend and no tie-break rule settles it.
|
|
883
|
-
export async function resolveBackendForPres(
|
|
884
|
-
presId,
|
|
885
|
-
{ preferBrandId = null } = {},
|
|
886
|
-
) {
|
|
887
|
-
if (!presId) throw new Error("resolveBackendForPres: presId is required");
|
|
888
|
-
const { presentations } = await fanOutPresentations();
|
|
889
|
-
const matches = presentations.filter((p) => p.presId === presId);
|
|
890
|
-
if (matches.length === 1) return matches[0].backendURL;
|
|
891
|
-
if (matches.length > 1) {
|
|
892
|
-
const winner = tieBreakMatches(matches, preferBrandId);
|
|
893
|
-
if (winner) return winner.backendURL;
|
|
894
|
-
const envs = matches.map((m) => m.env).join(", ");
|
|
895
|
-
throw new Error(
|
|
896
|
-
`Presentation '${presId}' is open on multiple environments: ${envs}, ` +
|
|
897
|
-
"and no single connected one has the deck's corporate design active. " +
|
|
898
|
-
"Disambiguate with --backend <env|label> (or SLIDEMIND_BACKEND).",
|
|
899
|
-
);
|
|
900
|
-
}
|
|
901
|
-
throw new Error(
|
|
902
|
-
`No open PowerPoint window registered with presId='${presId}' on any paired backend. ` +
|
|
903
|
-
"Run 'slidemind presentations' to see what's open.",
|
|
904
|
-
);
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
// Wait for a presentation's task pane to come back after a `pane_suspended`
|
|
908
|
-
// (PowerPoint backgrounded → webview suspended → socket dropped). Polls the
|
|
909
|
-
// live registry; succeeds on the same presId reconnecting, OR on a CONNECTED
|
|
910
|
-
// entry matching `fileName` + `brandId` — a pane reconnect can mint a NEW
|
|
911
|
-
// presId for the same window, so identity-by-content covers the churn. Returns
|
|
912
|
-
// the live registry entry (caller re-targets from it), or null on timeout.
|
|
913
|
-
// `onWait` fires once with the wait budget in seconds — the caller's single
|
|
914
|
-
// actionable "click the pane" line.
|
|
915
|
-
//
|
|
916
|
-
// `otherPresIds` are windows the caller already knows are NOT the target (the
|
|
917
|
-
// rest of the registry it picked from). They are excluded from the last-resort
|
|
918
|
-
// design-fingerprint match — see below.
|
|
919
|
-
export async function waitForPaneReady(
|
|
920
|
-
presId,
|
|
921
|
-
{
|
|
922
|
-
backendURL = null,
|
|
923
|
-
fileName = null,
|
|
924
|
-
brandId = null,
|
|
925
|
-
otherPresIds = null,
|
|
926
|
-
timeoutMs = null,
|
|
927
|
-
pollMs = null,
|
|
928
|
-
onWait = null,
|
|
929
|
-
} = {},
|
|
930
|
-
) {
|
|
931
|
-
const notTheTarget = new Set(otherPresIds ?? []);
|
|
932
|
-
const waitMs =
|
|
933
|
-
timeoutMs ?? envInt("SLIDEMIND_BRIDGE_PANE_READY_TIMEOUT_MS", 120000);
|
|
934
|
-
const stepMs = pollMs ?? envInt("SLIDEMIND_BRIDGE_PANE_READY_POLL_MS", 3000);
|
|
935
|
-
const deadline = Date.now() + waitMs;
|
|
936
|
-
let notified = false;
|
|
937
|
-
for (;;) {
|
|
938
|
-
let list = [];
|
|
939
|
-
try {
|
|
940
|
-
list = backendURL
|
|
941
|
-
? await listPresentationsForBackend(backendURL)
|
|
942
|
-
: (await fanOutPresentations()).presentations;
|
|
943
|
-
} catch {
|
|
944
|
-
/* transient list failure — poll again until the deadline */
|
|
945
|
-
}
|
|
946
|
-
const connected = list.filter(
|
|
947
|
-
(p) => isPaneReadyPresentation(p),
|
|
948
|
-
);
|
|
949
|
-
const same = connected.find((p) => p.presId === presId);
|
|
950
|
-
if (same) return same;
|
|
951
|
-
if (fileName || brandId) {
|
|
952
|
-
const rematch = connected.find(
|
|
953
|
-
(p) =>
|
|
954
|
-
(!fileName || p.fileName === fileName || p.title === fileName) &&
|
|
955
|
-
(!brandId || p.brandId === brandId),
|
|
956
|
-
);
|
|
957
|
-
if (rematch) return rematch;
|
|
958
|
-
// Last resort: match on the design fingerprint alone (presFP == brandId).
|
|
959
|
-
// A reconnect can report a different name for the same window — the user
|
|
960
|
-
// hit Save As, or the pane reports the title where it used to report the
|
|
961
|
-
// file name — but the active corporate design does not change with it.
|
|
962
|
-
// Only when EXACTLY one connected window carries it: two windows on the
|
|
963
|
-
// same design are indistinguishable, and pushing into the wrong one is
|
|
964
|
-
// worse than waiting.
|
|
965
|
-
//
|
|
966
|
-
// A window the caller ALREADY saw next to the target is not a churned
|
|
967
|
-
// target — it is a different presentation that merely shares the design
|
|
968
|
-
// (most tenants run exactly one), and retargeting to it would append the
|
|
969
|
-
// deck's slides to an unrelated file. Most tenants run a single corporate
|
|
970
|
-
// design, so brandId alone is not window identity.
|
|
971
|
-
if (fileName && brandId) {
|
|
972
|
-
const byBrand = connected.filter(
|
|
973
|
-
(p) => p.brandId === brandId && !notTheTarget.has(p.presId),
|
|
974
|
-
);
|
|
975
|
-
if (byBrand.length === 1) return byBrand[0];
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
if (Date.now() >= deadline) return null;
|
|
979
|
-
if (!notified) {
|
|
980
|
-
notified = true;
|
|
981
|
-
onWait?.(Math.round(waitMs / 1000));
|
|
982
|
-
}
|
|
983
|
-
await sleep(stepMs);
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
// The one actionable line a human needs when a pane op is waiting. stderr so
|
|
988
|
-
// it reaches the terminal from the CLI.
|
|
989
|
-
function paneWaitNotice(seconds) {
|
|
990
|
-
process.stderr.write(
|
|
991
|
-
"task pane suspended — click the slidemind pane in PowerPoint to wake it " +
|
|
992
|
-
`(waiting up to ${seconds}s)…\n`,
|
|
993
|
-
);
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
// The window is gone from the registry but its pane may already be back under a
|
|
997
|
-
// new presId (churn on every reconnect) — nothing for the user to do here, so
|
|
998
|
-
// say what the CLI is doing rather than asking for a click.
|
|
999
|
-
function paneRetargetNotice(seconds) {
|
|
1000
|
-
process.stderr.write(
|
|
1001
|
-
"the target PowerPoint window re-registered — re-matching it by corporate " +
|
|
1002
|
-
`design (up to ${seconds}s)…\n`,
|
|
1003
|
-
);
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
// GET the full design bundle (tokens, css, layouts, designRules, signed asset
|
|
1007
|
-
// URLs, engines) for the presentation's active corporate design.
|
|
1008
|
-
export async function getDesignBundle(
|
|
1009
|
-
presId,
|
|
1010
|
-
{ backendURL = null, preferBrandId = null } = {},
|
|
1011
|
-
) {
|
|
1012
|
-
if (!presId) throw new Error("getDesignBundle: presId is required");
|
|
1013
|
-
const be =
|
|
1014
|
-
backendURL || (await resolveBackendForPres(presId, { preferBrandId }));
|
|
1015
|
-
return withTransientRetry(async () => {
|
|
1016
|
-
const res = await bridgeFetch(
|
|
1017
|
-
`/api/cli/presentations/${encodeURIComponent(presId)}/design-bundle`,
|
|
1018
|
-
{},
|
|
1019
|
-
be,
|
|
1020
|
-
);
|
|
1021
|
-
return res.json();
|
|
1022
|
-
});
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
// GET the design bundle by brandId over the pane-free brand route. 404
|
|
1026
|
-
// `unknown_brand` is definitive (unknown / cross-tenant / not granted to this
|
|
1027
|
-
// user), so no transient retry. Requires the resolved backendURL — use
|
|
1028
|
-
// getDesignBundleForBrandAnyBackend when it isn't known yet.
|
|
1029
|
-
export async function getDesignBundleForBrand(brandId, { backendURL } = {}) {
|
|
1030
|
-
if (!brandId) {
|
|
1031
|
-
throw new Error("getDesignBundleForBrand: brandId is required");
|
|
1032
|
-
}
|
|
1033
|
-
if (!backendURL) {
|
|
1034
|
-
throw new Error("getDesignBundleForBrand: backendURL is required");
|
|
1035
|
-
}
|
|
1036
|
-
const res = await bridgeFetch(
|
|
1037
|
-
`/api/cli/brands/${encodeURIComponent(brandId)}/design-bundle`,
|
|
1038
|
-
{},
|
|
1039
|
-
backendURL,
|
|
1040
|
-
);
|
|
1041
|
-
return res.json();
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
// Resolve WHICH paired backend serves a brand by asking each one that
|
|
1045
|
-
// advertises brand routes — a bound deck has no open window, so there is no
|
|
1046
|
-
// registry entry to resolve a backend from. presFP/brandId is globally unique
|
|
1047
|
-
// across deployments (the bundle-cache contract), so at most one backend
|
|
1048
|
-
// answers 2xx. Returns { wire, backendURL }; throws a typed
|
|
1049
|
-
// `brand_route_unavailable` when no backend serves the brand (old add-ins,
|
|
1050
|
-
// unknown brand, missing grant) so callers fall back to the pane flow.
|
|
1051
|
-
export async function getDesignBundleForBrandAnyBackend(brandId) {
|
|
1052
|
-
if (!brandId) {
|
|
1053
|
-
throw new Error("getDesignBundleForBrandAnyBackend: brandId is required");
|
|
1054
|
-
}
|
|
1055
|
-
const sessions = sessionsInScope();
|
|
1056
|
-
const failures = [];
|
|
1057
|
-
for (const s of sessions) {
|
|
1058
|
-
try {
|
|
1059
|
-
if (!(await backendSupportsBrandRoutes(s.backendURL))) {
|
|
1060
|
-
failures.push({ env: deriveEnvLabel(s.backendURL), reason: "no brand routes" });
|
|
1061
|
-
continue;
|
|
1062
|
-
}
|
|
1063
|
-
const wire = await getDesignBundleForBrand(brandId, {
|
|
1064
|
-
backendURL: s.backendURL,
|
|
1065
|
-
});
|
|
1066
|
-
return { wire, backendURL: s.backendURL };
|
|
1067
|
-
} catch (err) {
|
|
1068
|
-
failures.push({
|
|
1069
|
-
env: deriveEnvLabel(s.backendURL),
|
|
1070
|
-
reason: err instanceof Error ? err.message : String(err),
|
|
1071
|
-
});
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
|
|
1075
|
-
const err = new Error(
|
|
1076
|
-
`no paired backend serves design '${brandId}' over the pane-free brand ` +
|
|
1077
|
-
`route${detail ? ` (${detail})` : ""}.`,
|
|
1078
|
-
);
|
|
1079
|
-
err.code = "brand_route_unavailable";
|
|
1080
|
-
throw err;
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
// The corporate designs this user may build against, straight off the CLI
|
|
1084
|
-
// session — no open window, no pane. One entry per design: `{ presFP, name,
|
|
1085
|
-
// env }`, where presFP IS the brandId every pane-free route is keyed by. Only
|
|
1086
|
-
// backends advertising `brandsList` are asked (older ones have no such route);
|
|
1087
|
-
// a backend that fails is skipped rather than failing the whole answer, so one
|
|
1088
|
-
// unreachable environment can't hide another's designs.
|
|
1089
|
-
export async function listBrands() {
|
|
1090
|
-
const out = [];
|
|
1091
|
-
for (const s of sessionsInScope()) {
|
|
1092
|
-
if (!(await backendSupportsBrandsList(s.backendURL))) continue;
|
|
1093
|
-
const env = deriveEnvLabel(s.backendURL);
|
|
1094
|
-
try {
|
|
1095
|
-
const res = await bridgeFetch(
|
|
1096
|
-
"/api/cli/brands",
|
|
1097
|
-
{ timeoutMs: listTimeoutMs() },
|
|
1098
|
-
s.backendURL,
|
|
1099
|
-
);
|
|
1100
|
-
const data = (await res.json()) ?? {};
|
|
1101
|
-
const arr = Array.isArray(data.brands) ? data.brands : [];
|
|
1102
|
-
for (const b of arr) {
|
|
1103
|
-
// The wire may name the design by either of its two equivalent keys
|
|
1104
|
-
// (presFP on the bundle contract, brandId on the routes) — normalize to
|
|
1105
|
-
// the one the deck ref stores.
|
|
1106
|
-
const presFP = b?.presFP ?? b?.brandId ?? b?.id ?? null;
|
|
1107
|
-
if (!presFP) continue;
|
|
1108
|
-
out.push({
|
|
1109
|
-
presFP,
|
|
1110
|
-
name: b?.name ?? b?.designName ?? null,
|
|
1111
|
-
env,
|
|
1112
|
-
backendURL: s.backendURL,
|
|
1113
|
-
});
|
|
1114
|
-
}
|
|
1115
|
-
} catch {
|
|
1116
|
-
/* unreachable / not granted — that environment simply contributes none */
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
return out;
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
// Concept-image generation is a heavy provider call (per-image cap ~180s
|
|
1123
|
-
// server-side). Budget just over that so a typed server error wins over a
|
|
1124
|
-
// client timeout, but keep it below the relay's ~240s ingress cap — the CLI
|
|
1125
|
-
// batches per-slide so no single request approaches that ceiling.
|
|
1126
|
-
function conceptTimeoutMs() {
|
|
1127
|
-
return (
|
|
1128
|
-
parseInt(process.env.SLIDEMIND_BRIDGE_CONCEPT_TIMEOUT_MS || "200000", 10) ||
|
|
1129
|
-
200000
|
|
1130
|
-
);
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
async function postConceptImages(
|
|
1134
|
-
brandId,
|
|
1135
|
-
{ planMarkdown, slideNumbers, references },
|
|
1136
|
-
backendURL,
|
|
1137
|
-
) {
|
|
1138
|
-
const body = { planMarkdown };
|
|
1139
|
-
if (Array.isArray(slideNumbers) && slideNumbers.length > 0) {
|
|
1140
|
-
body.slideNumbers = slideNumbers;
|
|
1141
|
-
}
|
|
1142
|
-
// The user's own images, inline: `{ role: "concept" | "style", image:
|
|
1143
|
-
// <data URI>, slides?: number[], name? }`. A concept replaces the generated
|
|
1144
|
-
// mockup for its slides; a style reference leads the generation.
|
|
1145
|
-
if (Array.isArray(references) && references.length > 0) {
|
|
1146
|
-
body.references = references;
|
|
1147
|
-
}
|
|
1148
|
-
const res = await bridgeFetch(
|
|
1149
|
-
`/api/cli/brands/${encodeURIComponent(brandId)}/concept-images`,
|
|
1150
|
-
{
|
|
1151
|
-
method: "POST",
|
|
1152
|
-
headers: { "content-type": "application/json" },
|
|
1153
|
-
body: JSON.stringify(body),
|
|
1154
|
-
timeoutMs: conceptTimeoutMs(),
|
|
1155
|
-
},
|
|
1156
|
-
backendURL,
|
|
1157
|
-
);
|
|
1158
|
-
const data = (await res.json()) ?? {};
|
|
1159
|
-
// Tag the response with the backend that answered so a per-slide batch can
|
|
1160
|
-
// pin subsequent calls to the same one (routing state only — never printed).
|
|
1161
|
-
return { ...data, backendURL };
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
// Generate concept mockup images for a brand over the pane-free brand route.
|
|
1165
|
-
// Body `{ planMarkdown, slideNumbers?, references? }`; the optional `slideNumbers`
|
|
1166
|
-
// filters which storyboard beats are rendered (the CLI batches per-slide);
|
|
1167
|
-
// `references` carries the user's own concept/style images inline. Returns the
|
|
1168
|
-
// server's `{ ok, images, slideCount }` augmented with the resolved
|
|
1169
|
-
// `backendURL`. When `backendURL` is given it posts directly; otherwise it
|
|
1170
|
-
// fans out — like getDesignBundleForBrandAnyBackend, presFP/brandId is globally
|
|
1171
|
-
// unique so at most one paired backend answers 2xx. Throws a typed
|
|
1172
|
-
// `concept_route_unavailable` when no backend serves the brand (old add-ins,
|
|
1173
|
-
// unknown brand, missing grant) so callers can degrade to "continue without".
|
|
1174
|
-
export async function generateConceptImagesForBrand(
|
|
1175
|
-
brandId,
|
|
1176
|
-
{ planMarkdown, slideNumbers = null, references = null, backendURL = null } = {},
|
|
1177
|
-
) {
|
|
1178
|
-
if (!brandId) {
|
|
1179
|
-
throw new Error("generateConceptImagesForBrand: brandId is required");
|
|
1180
|
-
}
|
|
1181
|
-
if (typeof planMarkdown !== "string" || !planMarkdown.trim()) {
|
|
1182
|
-
throw new Error("generateConceptImagesForBrand: planMarkdown is required");
|
|
1183
|
-
}
|
|
1184
|
-
if (backendURL) {
|
|
1185
|
-
return postConceptImages(
|
|
1186
|
-
brandId,
|
|
1187
|
-
{ planMarkdown, slideNumbers, references },
|
|
1188
|
-
backendURL,
|
|
1189
|
-
);
|
|
1190
|
-
}
|
|
1191
|
-
const sessions = sessionsInScope();
|
|
1192
|
-
const failures = [];
|
|
1193
|
-
for (const s of sessions) {
|
|
1194
|
-
try {
|
|
1195
|
-
if (!(await backendSupportsConceptImages(s.backendURL))) {
|
|
1196
|
-
failures.push({
|
|
1197
|
-
env: deriveEnvLabel(s.backendURL),
|
|
1198
|
-
reason: "no concept-images route",
|
|
1199
|
-
});
|
|
1200
|
-
continue;
|
|
1201
|
-
}
|
|
1202
|
-
return await postConceptImages(
|
|
1203
|
-
brandId,
|
|
1204
|
-
{ planMarkdown, slideNumbers, references },
|
|
1205
|
-
s.backendURL,
|
|
1206
|
-
);
|
|
1207
|
-
} catch (err) {
|
|
1208
|
-
failures.push({
|
|
1209
|
-
env: deriveEnvLabel(s.backendURL),
|
|
1210
|
-
reason: err instanceof Error ? err.message : String(err),
|
|
1211
|
-
});
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
|
|
1215
|
-
const err = new Error(
|
|
1216
|
-
`no paired backend generates concept mockups for design '${brandId}'` +
|
|
1217
|
-
`${detail ? ` (${detail})` : ""}.`,
|
|
1218
|
-
);
|
|
1219
|
-
err.code = "concept_route_unavailable";
|
|
1220
|
-
throw err;
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
// gpt-image calls routinely take 60-120s; budget like the concept relay so a
|
|
1224
|
-
// typed server error wins over a client timeout.
|
|
1225
|
-
function imagenTimeoutMs() {
|
|
1226
|
-
return (
|
|
1227
|
-
parseInt(process.env.SLIDEMIND_BRIDGE_IMAGEN_TIMEOUT_MS || "200000", 10) ||
|
|
1228
|
-
200000
|
|
1229
|
-
);
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
async function postGenerateImage(brandId, body, backendURL) {
|
|
1233
|
-
const res = await bridgeFetch(
|
|
1234
|
-
`/api/cli/brands/${encodeURIComponent(brandId)}/images`,
|
|
1235
|
-
{
|
|
1236
|
-
method: "POST",
|
|
1237
|
-
headers: { "content-type": "application/json" },
|
|
1238
|
-
body: JSON.stringify(body),
|
|
1239
|
-
timeoutMs: imagenTimeoutMs(),
|
|
1240
|
-
},
|
|
1241
|
-
backendURL,
|
|
1242
|
-
);
|
|
1243
|
-
const data = (await res.json()) ?? {};
|
|
1244
|
-
return { ...data, backendURL };
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
// Generate ONE regular image over the pane-free brand route
|
|
1248
|
-
// (`slidemind imagen`). Body `{ prompt, size?, quality? }`;
|
|
1249
|
-
// returns the server's `{ ok, image }` augmented with the
|
|
1250
|
-
// resolved `backendURL`. Fan-out and degradation mirror
|
|
1251
|
-
// generateConceptImagesForBrand: throws a typed `imagen_route_unavailable`
|
|
1252
|
-
// when no paired backend serves the route (older add-ins, unknown brand,
|
|
1253
|
-
// missing grant) so callers can print the one actionable fix.
|
|
1254
|
-
export async function generateImageForBrand(
|
|
1255
|
-
brandId,
|
|
1256
|
-
{ prompt, size = null, quality = null, backendURL = null } = {},
|
|
1257
|
-
) {
|
|
1258
|
-
if (!brandId) {
|
|
1259
|
-
throw new Error("generateImageForBrand: brandId is required");
|
|
1260
|
-
}
|
|
1261
|
-
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
1262
|
-
throw new Error("generateImageForBrand: prompt is required");
|
|
1263
|
-
}
|
|
1264
|
-
const body = { prompt };
|
|
1265
|
-
if (size) body.size = size;
|
|
1266
|
-
if (quality) body.quality = quality;
|
|
1267
|
-
if (backendURL) {
|
|
1268
|
-
return postGenerateImage(brandId, body, backendURL);
|
|
1269
|
-
}
|
|
1270
|
-
const sessions = sessionsInScope();
|
|
1271
|
-
const failures = [];
|
|
1272
|
-
for (const s of sessions) {
|
|
1273
|
-
try {
|
|
1274
|
-
if (!(await backendSupportsImageGeneration(s.backendURL))) {
|
|
1275
|
-
failures.push({
|
|
1276
|
-
env: deriveEnvLabel(s.backendURL),
|
|
1277
|
-
reason: "no image-generation route (update the add-in)",
|
|
1278
|
-
});
|
|
1279
|
-
continue;
|
|
1280
|
-
}
|
|
1281
|
-
return await postGenerateImage(brandId, body, s.backendURL);
|
|
1282
|
-
} catch (err) {
|
|
1283
|
-
failures.push({
|
|
1284
|
-
env: deriveEnvLabel(s.backendURL),
|
|
1285
|
-
reason: err instanceof Error ? err.message : String(err),
|
|
1286
|
-
});
|
|
1287
|
-
}
|
|
1288
|
-
}
|
|
1289
|
-
const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
|
|
1290
|
-
const err = new Error(
|
|
1291
|
-
`no paired backend generates images for design '${brandId}'` +
|
|
1292
|
-
`${detail ? ` (${detail})` : ""}.`,
|
|
1293
|
-
);
|
|
1294
|
-
err.code = "imagen_route_unavailable";
|
|
1295
|
-
throw err;
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
// Deterministic PPTX→JSX conversion is a per-slide OOXML parse + a render pass
|
|
1299
|
-
// per converted slide — a whole foreign deck runs in render-grade time, not
|
|
1300
|
-
// list-grade. Budget like the render routes.
|
|
1301
|
-
function convertTimeoutMs() {
|
|
1302
|
-
return (
|
|
1303
|
-
parseInt(process.env.SLIDEMIND_BRIDGE_CONVERT_TIMEOUT_MS || "300000", 10) ||
|
|
1304
|
-
300000
|
|
1305
|
-
);
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
// Minimal multipart/form-data body. The route takes an uploaded PPTX, and
|
|
1309
|
-
// nodeRequest writes a Buffer verbatim — no fetch/FormData involved, so the
|
|
1310
|
-
// envelope is assembled here (fields first, file last, boundary never appearing
|
|
1311
|
-
// in the parts because it carries a random suffix).
|
|
1312
|
-
function multipartBody(fields, file) {
|
|
1313
|
-
const boundary = `----slidemind${randomUUID().replace(/-/g, "")}`;
|
|
1314
|
-
const chunks = [];
|
|
1315
|
-
for (const [name, value] of Object.entries(fields)) {
|
|
1316
|
-
if (value === null || value === undefined) continue;
|
|
1317
|
-
chunks.push(
|
|
1318
|
-
Buffer.from(
|
|
1319
|
-
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`,
|
|
1320
|
-
),
|
|
1321
|
-
);
|
|
1322
|
-
}
|
|
1323
|
-
// The file name reaches this from a path on disk; a quote or a newline in it
|
|
1324
|
-
// would break out of the Content-Disposition header and forge a part.
|
|
1325
|
-
const safeFileName = String(file.fileName).replace(/[\r\n"\\]/g, "_");
|
|
1326
|
-
chunks.push(
|
|
1327
|
-
Buffer.from(
|
|
1328
|
-
`--${boundary}\r\nContent-Disposition: form-data; name="${file.name}"; ` +
|
|
1329
|
-
`filename="${safeFileName}"\r\nContent-Type: ${file.contentType}\r\n\r\n`,
|
|
1330
|
-
),
|
|
1331
|
-
Buffer.from(file.bytes),
|
|
1332
|
-
Buffer.from(`\r\n--${boundary}--\r\n`),
|
|
1333
|
-
);
|
|
1334
|
-
return {
|
|
1335
|
-
body: Buffer.concat(chunks),
|
|
1336
|
-
contentType: `multipart/form-data; boundary=${boundary}`,
|
|
1337
|
-
};
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
// Whether a backend deterministically converts foreign PPTX slides to jsx
|
|
1341
|
-
// (`POST /api/cli/brands/:brandId/convert-pptx`). Advertised only by add-ins
|
|
1342
|
-
// running with the reverse-conversion flag on; everywhere else `slidemind
|
|
1343
|
-
// adopt` folds untagged slides in as native .pptx exactly as before.
|
|
1344
|
-
export async function backendSupportsConvertPptx(backendURL) {
|
|
1345
|
-
const caps = await backendCapabilities(backendURL);
|
|
1346
|
-
return caps?.convertPptx === true;
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
async function postConvertPptx(brandId, { bytes, fileName, slides }, backendURL) {
|
|
1350
|
-
const { body, contentType } = multipartBody(
|
|
1351
|
-
{
|
|
1352
|
-
// 1-based play-order slide numbers to convert. Omitted = the whole deck;
|
|
1353
|
-
// `adopt` always names the untagged ones so tagged slides are not re-parsed.
|
|
1354
|
-
slides: Array.isArray(slides) && slides.length > 0 ? slides.join(",") : null,
|
|
1355
|
-
},
|
|
1356
|
-
{
|
|
1357
|
-
name: "file",
|
|
1358
|
-
fileName: path.basename(fileName || "deck.pptx"),
|
|
1359
|
-
contentType:
|
|
1360
|
-
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
1361
|
-
bytes,
|
|
1362
|
-
},
|
|
1363
|
-
);
|
|
1364
|
-
const res = await bridgeFetch(
|
|
1365
|
-
`/api/cli/brands/${encodeURIComponent(brandId)}/convert-pptx`,
|
|
1366
|
-
{
|
|
1367
|
-
method: "POST",
|
|
1368
|
-
headers: { "content-type": contentType },
|
|
1369
|
-
body,
|
|
1370
|
-
timeoutMs: convertTimeoutMs(),
|
|
1371
|
-
},
|
|
1372
|
-
backendURL,
|
|
1373
|
-
);
|
|
1374
|
-
const data = (await res.json()) ?? {};
|
|
1375
|
-
return { ...data, backendURL };
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
// Convert a FILE deck's foreign slides to converted-dialect jsx over the
|
|
1379
|
-
// pane-free brand route (`slidemind adopt`). Body is multipart: the .pptx plus
|
|
1380
|
-
// the 1-based slide numbers to convert. Returns the server's
|
|
1381
|
-
// `{ slides, layouts, assets, sourceBaselines, conversionEvidence, skipped,
|
|
1382
|
-
// warnings }` (add-in `ConvertPptxResponse`) augmented with the resolved
|
|
1383
|
-
// `backendURL`. Identity and evidence are keyed by each slide's jsx `path`, not
|
|
1384
|
-
// carried on the slide — `src/lib/adopt.mjs` `convertedByNumber()` is the one
|
|
1385
|
-
// place that key shape is unfolded.
|
|
1386
|
-
//
|
|
1387
|
-
// LIVE decks are NOT convertible through this route in v1: converting an open
|
|
1388
|
-
// presentation needs a slide export + tag read over the relay, and neither
|
|
1389
|
-
// event exists (design D9) — the add-in path owns that flow.
|
|
1390
|
-
//
|
|
1391
|
-
// Fan-out and degradation mirror generateConceptImagesForBrand: throws a typed
|
|
1392
|
-
// `convert_route_unavailable` when no paired backend serves the route (older
|
|
1393
|
-
// add-ins, the reverse flag off, unknown brand, missing grant) so `adopt` falls
|
|
1394
|
-
// back to the native fold instead of failing.
|
|
1395
|
-
export async function convertPptxForBrand(
|
|
1396
|
-
brandId,
|
|
1397
|
-
{ bytes, fileName = "deck.pptx", slides = null, backendURL = null } = {},
|
|
1398
|
-
) {
|
|
1399
|
-
if (!brandId) throw new Error("convertPptxForBrand: brandId is required");
|
|
1400
|
-
if (!bytes) throw new Error("convertPptxForBrand: bytes is required");
|
|
1401
|
-
if (backendURL) {
|
|
1402
|
-
return postConvertPptx(brandId, { bytes, fileName, slides }, backendURL);
|
|
1403
|
-
}
|
|
1404
|
-
const sessions = sessionsInScope();
|
|
1405
|
-
const failures = [];
|
|
1406
|
-
for (const s of sessions) {
|
|
1407
|
-
try {
|
|
1408
|
-
if (!(await backendSupportsConvertPptx(s.backendURL))) {
|
|
1409
|
-
failures.push({
|
|
1410
|
-
env: deriveEnvLabel(s.backendURL),
|
|
1411
|
-
reason: "no convert-pptx route",
|
|
1412
|
-
});
|
|
1413
|
-
continue;
|
|
1414
|
-
}
|
|
1415
|
-
return await postConvertPptx(
|
|
1416
|
-
brandId,
|
|
1417
|
-
{ bytes, fileName, slides },
|
|
1418
|
-
s.backendURL,
|
|
1419
|
-
);
|
|
1420
|
-
} catch (err) {
|
|
1421
|
-
failures.push({
|
|
1422
|
-
env: deriveEnvLabel(s.backendURL),
|
|
1423
|
-
reason: err instanceof Error ? err.message : String(err),
|
|
1424
|
-
});
|
|
1425
|
-
}
|
|
1426
|
-
}
|
|
1427
|
-
const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
|
|
1428
|
-
const err = new Error(
|
|
1429
|
-
`no paired backend converts foreign slides for design '${brandId}'` +
|
|
1430
|
-
`${detail ? ` (${detail})` : ""}.`,
|
|
1431
|
-
);
|
|
1432
|
-
err.code = "convert_route_unavailable";
|
|
1433
|
-
throw err;
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
// List the LIVE engine catalog (NOT the bundle's curated set) for the paired
|
|
1437
|
-
// backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
|
|
1438
|
-
// when several environments are paired this fans out and tags each engine with
|
|
1439
|
-
// its short `env` label (never the backendURL — the no-URL rule). Filter by
|
|
1440
|
-
// engine `type` (collection|search) and/or `subtype` (image|slide|template|
|
|
1441
|
-
// content). Returns a flat `{ engines: [...], catalog }` like the single-backend
|
|
1442
|
-
// route.
|
|
1443
|
-
//
|
|
1444
|
-
// `catalog` is the pane-free route's summary — `{totalEngines, totalScopes,
|
|
1445
|
-
// filtered, emptyEngines}`, counted BEFORE the type/subtype filter — and it is
|
|
1446
|
-
// what separates "this filter matched nothing" from "this deployment has no
|
|
1447
|
-
// engines". Older backends (and the pane-relay path) send none; it stays null
|
|
1448
|
-
// there and every caller falls back to its pre-summary wording.
|
|
1449
|
-
function readCatalogSummary(data) {
|
|
1450
|
-
const c = data?.catalog;
|
|
1451
|
-
if (!c || typeof c !== "object" || Array.isArray(c)) return null;
|
|
1452
|
-
const num = (v) => (Number.isFinite(v) ? v : 0);
|
|
1453
|
-
return {
|
|
1454
|
-
totalEngines: num(c.totalEngines),
|
|
1455
|
-
totalScopes: num(c.totalScopes),
|
|
1456
|
-
filtered: Boolean(c.filtered),
|
|
1457
|
-
emptyEngines: num(c.emptyEngines),
|
|
1458
|
-
};
|
|
1459
|
-
}
|
|
1460
|
-
|
|
1461
|
-
async function listEnginesForBackend(backendURL, { type = null, subtype = null } = {}) {
|
|
1462
|
-
return withTransientRetry(async () => {
|
|
1463
|
-
const params = new URLSearchParams();
|
|
1464
|
-
if (type) params.set("type", type);
|
|
1465
|
-
if (subtype) params.set("subtype", subtype);
|
|
1466
|
-
const qs = params.toString();
|
|
1467
|
-
const res = await bridgeFetch(
|
|
1468
|
-
`/api/cli/engines${qs ? `?${qs}` : ""}`,
|
|
1469
|
-
// Pane-relayed on the add-in side — needs the engine budget, not the 8s
|
|
1470
|
-
// list timeout (which bailed before the pane could ever answer). The
|
|
1471
|
-
// delegated tokens authorize the pane-free catalog (login_required
|
|
1472
|
-
// without them); older add-ins ignore the headers.
|
|
1473
|
-
{ timeoutMs: engineTimeoutMs(), headers: await graphHeaders(backendURL) },
|
|
1474
|
-
backendURL,
|
|
1475
|
-
);
|
|
1476
|
-
const data = await res.json();
|
|
1477
|
-
const arr = Array.isArray(data?.engines) ? data.engines : [];
|
|
1478
|
-
const env = deriveEnvLabel(backendURL);
|
|
1479
|
-
return {
|
|
1480
|
-
engines: arr.map((e) => ({ ...e, env })),
|
|
1481
|
-
catalog: readCatalogSummary(data),
|
|
1482
|
-
};
|
|
1483
|
-
});
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
// A scope (scope:all / scope:sharepoint) is not one environment's object: it is
|
|
1487
|
-
// the same saved search target, named identically on every deployment. Pooling
|
|
1488
|
-
// two backends' catalogs therefore repeats it verbatim — same id, same label —
|
|
1489
|
-
// which reads as two different things to choose between. Real engines DO differ
|
|
1490
|
-
// per environment and keep their duplicates; their `env` tag tells them apart.
|
|
1491
|
-
function isScopeRow(e) {
|
|
1492
|
-
return e?.type === "scope" || /^scope:/.test(String(e?.id ?? ""));
|
|
1493
|
-
}
|
|
1494
|
-
|
|
1495
|
-
export async function listEngines({ type = null, subtype = null, backendURL = null } = {}) {
|
|
1496
|
-
const sessions = backendURL ? [sessionForBackend(backendURL)] : sessionsInScope();
|
|
1497
|
-
const settled = await Promise.allSettled(
|
|
1498
|
-
sessions.map((s) => listEnginesForBackend(s.backendURL, { type, subtype })),
|
|
1499
|
-
);
|
|
1500
|
-
const engines = [];
|
|
1501
|
-
const catalogs = [];
|
|
1502
|
-
const failures = [];
|
|
1503
|
-
// Which environments actually contributed. NOT derivable from the pooled rows
|
|
1504
|
-
// any more: a second environment that only repeated the scopes leaves no row
|
|
1505
|
-
// of its own, and the human renderer keys its `[env]` tags off this.
|
|
1506
|
-
const envs = [];
|
|
1507
|
-
// Scope id → the one pooled row kept for it.
|
|
1508
|
-
const scopeRows = new Map();
|
|
1509
|
-
let fulfilledCount = 0;
|
|
1510
|
-
settled.forEach((r, i) => {
|
|
1511
|
-
if (r.status === "fulfilled") {
|
|
1512
|
-
fulfilledCount += 1;
|
|
1513
|
-
envs.push(deriveEnvLabel(sessions[i].backendURL));
|
|
1514
|
-
for (const e of r.value.engines) {
|
|
1515
|
-
const id = typeof e?.id === "string" ? e.id : null;
|
|
1516
|
-
if (id && isScopeRow(e)) {
|
|
1517
|
-
const kept = scopeRows.get(id);
|
|
1518
|
-
if (kept) {
|
|
1519
|
-
// Contributed by more than one environment, so it belongs to none
|
|
1520
|
-
// of them in particular — drop the tag rather than crediting
|
|
1521
|
-
// whichever backend happened to answer first.
|
|
1522
|
-
kept.env = null;
|
|
1523
|
-
continue;
|
|
1524
|
-
}
|
|
1525
|
-
const row = { ...e };
|
|
1526
|
-
scopeRows.set(id, row);
|
|
1527
|
-
engines.push(row);
|
|
1528
|
-
continue;
|
|
1529
|
-
}
|
|
1530
|
-
engines.push(e);
|
|
1531
|
-
}
|
|
1532
|
-
if (r.value.catalog) catalogs.push(r.value.catalog);
|
|
1533
|
-
} else {
|
|
1534
|
-
failures.push({ env: deriveEnvLabel(sessions[i].backendURL), err: r.reason });
|
|
1535
|
-
}
|
|
1536
|
-
});
|
|
1537
|
-
// Mirror listOpenPresentations: only surface an error when nothing answered.
|
|
1538
|
-
if (engines.length === 0 && fulfilledCount === 0 && failures.length > 0) {
|
|
1539
|
-
if (failures.length === 1) throw failures[0].err;
|
|
1540
|
-
throw new Error(
|
|
1541
|
-
"slidemind: every paired backend failed to list engines:\n" +
|
|
1542
|
-
failures.map((f) => ` ${f.env}: ${f.err.message}`).join("\n"),
|
|
1543
|
-
);
|
|
1544
|
-
}
|
|
1545
|
-
// Fanned out, the summary is the sum of what each environment reported: the
|
|
1546
|
-
// engines are pooled into one list, so their counts pool too. SCOPES are the
|
|
1547
|
-
// exception — the same named targets everywhere, deduped above — so summing
|
|
1548
|
-
// them double-counts; the distinct total is the largest environment's count,
|
|
1549
|
-
// or the number of distinct scope rows actually pooled when that is larger.
|
|
1550
|
-
// (The catalog counts are pre-filter and the rows post-filter, which is why
|
|
1551
|
-
// both terms are needed.) `filtered` is the same question asked of every
|
|
1552
|
-
// backend, so any one of them saying yes is the answer. Null when no backend
|
|
1553
|
-
// supplied one (old add-in / pane relay).
|
|
1554
|
-
const catalog = catalogs.length
|
|
1555
|
-
? {
|
|
1556
|
-
totalEngines: catalogs.reduce((n, c) => n + c.totalEngines, 0),
|
|
1557
|
-
totalScopes: Math.max(...catalogs.map((c) => c.totalScopes), scopeRows.size),
|
|
1558
|
-
filtered: catalogs.some((c) => c.filtered),
|
|
1559
|
-
emptyEngines: catalogs.reduce((n, c) => n + c.emptyEngines, 0),
|
|
1560
|
-
}
|
|
1561
|
-
: null;
|
|
1562
|
-
return { engines, catalog, envs };
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
// ---------------------------------------------------------------------------
|
|
1566
|
-
// Server-side OOXML render
|
|
1567
|
-
// ---------------------------------------------------------------------------
|
|
1568
|
-
//
|
|
1569
|
-
// Two protocols, newest first:
|
|
1570
|
-
// 1. Render JOBS (preferred): POST creates a job, the CLI polls cheap status
|
|
1571
|
-
// GETs and fetches the result when done. A full deck render routinely
|
|
1572
|
-
// outlives the cloud ingress's ~240s request cap — only short polling
|
|
1573
|
-
// requests survive that reliably, and they carry per-slide progress.
|
|
1574
|
-
// 2. Sync render (fallback): one POST held open for the whole render. Kept
|
|
1575
|
-
// for older backends that don't serve /api/cli/capabilities yet.
|
|
1576
|
-
|
|
1577
|
-
function envInt(name, fallback) {
|
|
1578
|
-
const n = parseInt(process.env[name] || "", 10);
|
|
1579
|
-
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
|
-
function sleep(ms) {
|
|
1583
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
// One capability probe per backend per process. Keyed by the RESOLVED backend
|
|
1587
|
-
// URL (callers always resolve before probing), so multiple paired backends on
|
|
1588
|
-
// mixed versions each get their own answer.
|
|
1589
|
-
const backendCapabilitiesCache = new Map();
|
|
1590
|
-
|
|
1591
|
-
// The probe result, failure included. Every ordinary caller wants the graceful
|
|
1592
|
-
// `{}` (an older backend simply 404s this path), but a caller that is DIAGNOSING
|
|
1593
|
-
// has to see a 401 — "this session is revoked" must never be reported as "this
|
|
1594
|
-
// add-in is too old". Kept as one cached promise so the distinction costs no
|
|
1595
|
-
// extra round-trip.
|
|
1596
|
-
function backendCapabilitiesResult(backendURL) {
|
|
1597
|
-
let probe = backendCapabilitiesCache.get(backendURL);
|
|
1598
|
-
if (!probe) {
|
|
1599
|
-
probe = (async () => {
|
|
1600
|
-
try {
|
|
1601
|
-
const res = await bridgeFetch(
|
|
1602
|
-
"/api/cli/capabilities",
|
|
1603
|
-
{ timeoutMs: listTimeoutMs() },
|
|
1604
|
-
backendURL,
|
|
1605
|
-
);
|
|
1606
|
-
return { caps: (await res.json()) ?? {}, error: null };
|
|
1607
|
-
} catch (err) {
|
|
1608
|
-
// Older backends 404 this path (with a non-JSON body) — no
|
|
1609
|
-
// capabilities: sync render, no version floor.
|
|
1610
|
-
return { caps: {}, error: err };
|
|
1611
|
-
}
|
|
1612
|
-
})();
|
|
1613
|
-
backendCapabilitiesCache.set(backendURL, probe);
|
|
1614
|
-
}
|
|
1615
|
-
return probe;
|
|
1616
|
-
}
|
|
1617
|
-
|
|
1618
|
-
async function backendCapabilities(backendURL) {
|
|
1619
|
-
return (await backendCapabilitiesResult(backendURL)).caps;
|
|
1620
|
-
}
|
|
1621
|
-
|
|
1622
|
-
async function backendSupportsRenderJobs(backendURL) {
|
|
1623
|
-
const caps = await backendCapabilities(backendURL);
|
|
1624
|
-
return caps?.renderJobs === true;
|
|
1625
|
-
}
|
|
1626
|
-
|
|
1627
|
-
// Pane-free brand-scoped routes (/api/cli/brands/:brandId/*): design-bundle,
|
|
1628
|
-
// translate and render keyed by the brandId the deck's .bundle-ref.json already
|
|
1629
|
-
// stores (presFP == brandId), instead of the pane registry — so a bound deck
|
|
1630
|
-
// keeps building after every pane key aged out of the add-in's registry (or
|
|
1631
|
-
// with PowerPoint closed entirely). Advertised by newer add-ins only.
|
|
1632
|
-
export async function backendSupportsBrandRoutes(backendURL) {
|
|
1633
|
-
const caps = await backendCapabilities(backendURL);
|
|
1634
|
-
return caps?.brandRoutes === true;
|
|
1635
|
-
}
|
|
1636
|
-
|
|
1637
|
-
// Whether a backend generates concept mockup images for a brand over the
|
|
1638
|
-
// pane-free `/api/cli/brands/:brandId/concept-images` route (see
|
|
1639
|
-
// generateConceptImagesForBrand). Advertised by newer add-ins only; older ones
|
|
1640
|
-
// omit the flag and the caller degrades to "continue without".
|
|
1641
|
-
export async function backendSupportsConceptImages(backendURL) {
|
|
1642
|
-
const caps = await backendCapabilities(backendURL);
|
|
1643
|
-
return caps?.conceptImages === true;
|
|
1644
|
-
}
|
|
1645
|
-
|
|
1646
|
-
// Whether a backend generates regular images over the pane-free
|
|
1647
|
-
// `/api/cli/brands/:brandId/images` route (see generateImageForBrand).
|
|
1648
|
-
// Advertised by newer add-ins only.
|
|
1649
|
-
export async function backendSupportsImageGeneration(backendURL) {
|
|
1650
|
-
const caps = await backendCapabilities(backendURL);
|
|
1651
|
-
return caps?.imageGeneration === true;
|
|
1652
|
-
}
|
|
1653
|
-
|
|
1654
|
-
// Whether a backend can list the corporate designs a user may build against
|
|
1655
|
-
// (`GET /api/cli/brands`). It is what lets `ready` bind a deck with PowerPoint
|
|
1656
|
-
// closed — without it the only way to learn a design's identity is an open
|
|
1657
|
-
// window. Advertised by newer add-ins only.
|
|
1658
|
-
export async function backendSupportsBrandsList(backendURL) {
|
|
1659
|
-
const caps = await backendCapabilities(backendURL);
|
|
1660
|
-
return caps?.brandsList === true;
|
|
1661
|
-
}
|
|
1662
|
-
|
|
1663
|
-
// Whether a backend can read the SOURCE deck behind a chosen slide search
|
|
1664
|
-
// result (`/api/cli/presentations/:presId/source-presentation`). Advertised by
|
|
1665
|
-
// newer add-ins only; older ones omit the flag and the caller must say so
|
|
1666
|
-
// rather than surfacing a bare 404.
|
|
1667
|
-
export async function backendSupportsSourcePresentation(backendURL) {
|
|
1668
|
-
const caps = await backendCapabilities(backendURL);
|
|
1669
|
-
return caps?.sourcePresentation === true;
|
|
1670
|
-
}
|
|
1671
|
-
|
|
1672
|
-
// Pane-free ENGINE routes (`/api/cli/engines`, `/api/cli/searches/*`): engine
|
|
1673
|
-
// discovery, search and every per-result fetch answered off the CLI session
|
|
1674
|
-
// itself instead of being relayed through the task-pane webview — the one
|
|
1675
|
-
// transport that a backgrounded PowerPoint is free to suspend mid-run.
|
|
1676
|
-
// Advertised by newer add-ins only; older ones keep the pane relay, which stays
|
|
1677
|
-
// as the compat path (see the direct-route block further down).
|
|
1678
|
-
export async function backendSupportsEngineRoutes(backendURL) {
|
|
1679
|
-
const caps = await backendCapabilities(backendURL);
|
|
1680
|
-
return caps?.engineRoutes === true;
|
|
1681
|
-
}
|
|
1682
|
-
|
|
1683
|
-
// Whether a backend honours a delegated Microsoft Graph token on the pane-free
|
|
1684
|
-
// engine routes — the CLI's own identity for SharePoint-backed content
|
|
1685
|
-
// (`slidemind login`, src/bridge/graph-auth.mjs). Gates BOTH the attach and the
|
|
1686
|
-
// remediation text: naming `slidemind login` on an add-in that ignores the token
|
|
1687
|
-
// would send a user through a browser flow that changes nothing.
|
|
1688
|
-
export async function backendSupportsGraphDelegated(backendURL) {
|
|
1689
|
-
const caps = await backendCapabilities(backendURL);
|
|
1690
|
-
return caps?.graphDelegated === true;
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
// The add-in's hard version floor: /api/cli/capabilities advertises the oldest
|
|
1694
|
-
// CLI it still speaks (`minCliVersion`). An older CLI must refuse to proceed —
|
|
1695
|
-
// mid-rollout skew otherwise surfaces as confusing route/contract errors deep
|
|
1696
|
-
// in a render — and prompt the one actionable fix. No-op when the backend
|
|
1697
|
-
// doesn't advertise a floor (older add-ins) or the CLI meets it.
|
|
1698
|
-
export async function assertCliVersionSupported(backendURL) {
|
|
1699
|
-
const caps = await backendCapabilities(backendURL);
|
|
1700
|
-
const min = typeof caps?.minCliVersion === "string" ? caps.minCliVersion : null;
|
|
1701
|
-
if (!min || compareCliVersions(cliVersion(), min) >= 0) return;
|
|
1702
|
-
throw cliUpdateRequiredError(min);
|
|
1703
|
-
}
|
|
1704
|
-
|
|
1705
|
-
// Rebuild a job failure into the exact error shape bridgeFetch throws for the
|
|
1706
|
-
// sync route, so callers (etag self-heal, error texts) can't tell the
|
|
1707
|
-
// protocols apart.
|
|
1708
|
-
function jobFailureError(jobError, label) {
|
|
1709
|
-
const status = jobError?.status ?? 500;
|
|
1710
|
-
const detail = jobError?.error ?? "render_failed";
|
|
1711
|
-
const err = new Error(
|
|
1712
|
-
`slidemind bridge ${status} render job failed for ${label}: ${detail}`,
|
|
1713
|
-
);
|
|
1714
|
-
err.status = status;
|
|
1715
|
-
err.code = detail;
|
|
1716
|
-
err.body = { error: detail, ...(jobError?.extra ?? {}) };
|
|
1717
|
-
return err;
|
|
1718
|
-
}
|
|
1719
|
-
|
|
1720
|
-
// The render/translate routes exist on two surfaces sharing one contract:
|
|
1721
|
-
// /api/cli/presentations/:presId — brandId resolved from the pane registry
|
|
1722
|
-
// /api/cli/brands/:brandId — pane-free (bound deck, window optional)
|
|
1723
|
-
function renderBasePath({ presId, brandId }) {
|
|
1724
|
-
return brandId
|
|
1725
|
-
? `/api/cli/brands/${encodeURIComponent(brandId)}`
|
|
1726
|
-
: `/api/cli/presentations/${encodeURIComponent(presId)}`;
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1729
|
-
async function renderViaSync({ basePath, body, backendURL }) {
|
|
1730
|
-
const res = await bridgeFetch(
|
|
1731
|
-
`${basePath}/render-pptx`,
|
|
1732
|
-
{
|
|
1733
|
-
method: "POST",
|
|
1734
|
-
headers: { "content-type": "application/json" },
|
|
1735
|
-
body: JSON.stringify(body),
|
|
1736
|
-
// Server-side OOXML render is a heavy operation — a full deck of richly
|
|
1737
|
-
// composed slides routinely runs well past the 35s default. Treat it like
|
|
1738
|
-
// a mutation: give it a generous default and let env override it.
|
|
1739
|
-
timeoutMs:
|
|
1740
|
-
parseInt(process.env.SLIDEMIND_BRIDGE_RENDER_TIMEOUT_MS || "300000", 10) ||
|
|
1741
|
-
300000,
|
|
1742
|
-
},
|
|
1743
|
-
backendURL,
|
|
1744
|
-
);
|
|
1745
|
-
return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
|
|
1746
|
-
}
|
|
1747
|
-
|
|
1748
|
-
async function renderViaJob({ basePath, label, body, backendURL, onProgress }) {
|
|
1749
|
-
const pollMs = envInt("SLIDEMIND_BRIDGE_RENDER_POLL_MS", 2500);
|
|
1750
|
-
const deadlineMs = envInt("SLIDEMIND_BRIDGE_RENDER_DEADLINE_MS", 900000);
|
|
1751
|
-
const resultTimeoutMs = envInt(
|
|
1752
|
-
"SLIDEMIND_BRIDGE_RENDER_RESULT_TIMEOUT_MS",
|
|
1753
|
-
120000,
|
|
1754
|
-
);
|
|
1755
|
-
|
|
1756
|
-
const createJob = async () => {
|
|
1757
|
-
// Deliberately NOT wrapped in withTransientRetry: its blanket 404 retry
|
|
1758
|
-
// would re-upload a multi-MB slide payload, and creation errors (stale
|
|
1759
|
-
// etag, no CD, unknown presId) are definitive.
|
|
1760
|
-
const res = await bridgeFetch(
|
|
1761
|
-
`${basePath}/render-jobs`,
|
|
1762
|
-
{
|
|
1763
|
-
method: "POST",
|
|
1764
|
-
headers: { "content-type": "application/json" },
|
|
1765
|
-
body: JSON.stringify(body),
|
|
1766
|
-
timeoutMs:
|
|
1767
|
-
parseInt(process.env.SLIDEMIND_BRIDGE_TIMEOUT_MS || "35000", 10) ||
|
|
1768
|
-
35000,
|
|
1769
|
-
},
|
|
1770
|
-
backendURL,
|
|
1771
|
-
);
|
|
1772
|
-
const data = await res.json();
|
|
1773
|
-
if (!data?.jobId) throw new Error("render-jobs returned no jobId");
|
|
1774
|
-
return data.jobId;
|
|
1775
|
-
};
|
|
1776
|
-
|
|
1777
|
-
let jobId = await createJob();
|
|
1778
|
-
let resubmitted = false;
|
|
1779
|
-
let lastReported = "";
|
|
1780
|
-
const deadline = Date.now() + deadlineMs;
|
|
1781
|
-
|
|
1782
|
-
for (;;) {
|
|
1783
|
-
if (Date.now() > deadline) {
|
|
1784
|
-
const err = new Error(
|
|
1785
|
-
`slidemind render did not finish within ${deadlineMs}ms ` +
|
|
1786
|
-
"(raise SLIDEMIND_BRIDGE_RENDER_DEADLINE_MS for very large decks). " +
|
|
1787
|
-
"The render keeps running server-side — retrying may pick up a warm cache.",
|
|
1788
|
-
);
|
|
1789
|
-
err.code = "bridge_timeout";
|
|
1790
|
-
throw err;
|
|
1791
|
-
}
|
|
1792
|
-
await sleep(pollMs);
|
|
1793
|
-
|
|
1794
|
-
let status;
|
|
1795
|
-
try {
|
|
1796
|
-
const res = await bridgeFetch(
|
|
1797
|
-
`/api/cli/render-jobs/${encodeURIComponent(jobId)}`,
|
|
1798
|
-
{ timeoutMs: 15000 },
|
|
1799
|
-
backendURL,
|
|
1800
|
-
);
|
|
1801
|
-
status = await res.json();
|
|
1802
|
-
} catch (err) {
|
|
1803
|
-
// The job record vanished (backend Redis flush/restart) — same recovery
|
|
1804
|
-
// as a lost replica: resubmit once.
|
|
1805
|
-
if (err?.code === "job_not_found") {
|
|
1806
|
-
if (resubmitted) throw jobFailureError({ status: 500, error: "job_lost" }, label);
|
|
1807
|
-
resubmitted = true;
|
|
1808
|
-
onProgress?.({ state: "restarted" });
|
|
1809
|
-
jobId = await createJob();
|
|
1810
|
-
continue;
|
|
1811
|
-
}
|
|
1812
|
-
// Rate-limited poll: honor the window, then resume.
|
|
1813
|
-
if (err?.status === 429) {
|
|
1814
|
-
const retryAfterSec = err?.body?.retryAfterSec;
|
|
1815
|
-
await sleep(Math.min((retryAfterSec || 5) * 1000, 30000));
|
|
1816
|
-
continue;
|
|
1817
|
-
}
|
|
1818
|
-
// Anything else (network blip, 5xx, replica restart) — polling is
|
|
1819
|
-
// idempotent; just poll again until the deadline.
|
|
1820
|
-
continue;
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
if (onProgress) {
|
|
1824
|
-
const snapshot = JSON.stringify([status.state, status.progress]);
|
|
1825
|
-
if (snapshot !== lastReported) {
|
|
1826
|
-
lastReported = snapshot;
|
|
1827
|
-
onProgress({ state: status.state, ...(status.progress ?? {}) });
|
|
1828
|
-
}
|
|
1829
|
-
}
|
|
1830
|
-
|
|
1831
|
-
if (status.state === "failed") {
|
|
1832
|
-
const jobError = status.error ?? { status: 500, error: "render_failed" };
|
|
1833
|
-
// The rendering replica died (deploy, crash, scale-in). Renders are
|
|
1834
|
-
// idempotent — resubmit once before giving up.
|
|
1835
|
-
if (jobError.error === "job_lost" && !resubmitted) {
|
|
1836
|
-
resubmitted = true;
|
|
1837
|
-
onProgress?.({ state: "restarted" });
|
|
1838
|
-
jobId = await createJob();
|
|
1839
|
-
continue;
|
|
1840
|
-
}
|
|
1841
|
-
throw jobFailureError(jobError, label);
|
|
1842
|
-
}
|
|
1843
|
-
|
|
1844
|
-
if (status.state === "done") {
|
|
1845
|
-
// The result may be tens of MB — generous timeout, and a couple of
|
|
1846
|
-
// retries for transient blips (the stored result is re-fetchable).
|
|
1847
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
1848
|
-
try {
|
|
1849
|
-
const res = await bridgeFetch(
|
|
1850
|
-
`/api/cli/render-jobs/${encodeURIComponent(jobId)}/result`,
|
|
1851
|
-
{ timeoutMs: resultTimeoutMs },
|
|
1852
|
-
backendURL,
|
|
1853
|
-
);
|
|
1854
|
-
return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
|
|
1855
|
-
} catch (err) {
|
|
1856
|
-
if (
|
|
1857
|
-
attempt >= 2 ||
|
|
1858
|
-
err?.code === "result_expired" ||
|
|
1859
|
-
err?.code === "job_not_found"
|
|
1860
|
-
) {
|
|
1861
|
-
throw err;
|
|
1862
|
-
}
|
|
1863
|
-
await sleep(pollMs);
|
|
1864
|
-
}
|
|
1865
|
-
}
|
|
1866
|
-
}
|
|
1867
|
-
}
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
// Server-side OOXML render: ship authored slide JSX (+ deck-local assets) and
|
|
1871
|
-
// get back a native .pptx (base64) rendered against the presentation's own CD.
|
|
1872
|
-
// `slides`: { "<file>.jsx": "<jsx source>" }. `deckAssets`: { "assets/<f>": "<base64>" }.
|
|
1873
|
-
// `onProgress` (optional) receives `{ state, done?, total?, stage? }` ticks on
|
|
1874
|
-
// the job protocol; the sync fallback emits none.
|
|
1875
|
-
//
|
|
1876
|
-
// Target is EXACTLY ONE of:
|
|
1877
|
-
// presId — render via the pane registry (any add-in version)
|
|
1878
|
-
// brandId — render via the pane-free brand route (needs backendURL + a
|
|
1879
|
-
// backend advertising `brandRoutes`); `deckName` labels the render.
|
|
1880
|
-
export async function renderPptx({
|
|
1881
|
-
presId = null,
|
|
1882
|
-
brandId = null,
|
|
1883
|
-
deckName = null,
|
|
1884
|
-
slides,
|
|
1885
|
-
deckAssets = null,
|
|
1886
|
-
nativeSlides = null,
|
|
1887
|
-
etag = null,
|
|
1888
|
-
sourceGuids = null,
|
|
1889
|
-
conversions = null,
|
|
1890
|
-
reconcile = null,
|
|
1891
|
-
backendURL = null,
|
|
1892
|
-
onProgress = null,
|
|
1893
|
-
}) {
|
|
1894
|
-
if (!presId && !brandId) {
|
|
1895
|
-
throw new Error("renderPptx: presId or brandId is required");
|
|
1896
|
-
}
|
|
1897
|
-
const hasSlides = slides && Object.keys(slides).length > 0;
|
|
1898
|
-
const hasNative = nativeSlides && Object.keys(nativeSlides).length > 0;
|
|
1899
|
-
if (!hasSlides && !hasNative) {
|
|
1900
|
-
throw new Error("renderPptx: slides or nativeSlides is required");
|
|
1901
|
-
}
|
|
1902
|
-
let be = backendURL;
|
|
1903
|
-
if (!be) {
|
|
1904
|
-
if (brandId) throw new Error("renderPptx: backendURL is required with brandId");
|
|
1905
|
-
be = await resolveBackendForPres(presId);
|
|
1906
|
-
}
|
|
1907
|
-
// Version floor first: shipping a whole deck to a backend that no longer
|
|
1908
|
-
// speaks this CLI's contract fails deep and confusingly; fail here instead
|
|
1909
|
-
// with the actionable `slidemind update`.
|
|
1910
|
-
await assertCliVersionSupported(be);
|
|
1911
|
-
const basePath = renderBasePath({ presId, brandId });
|
|
1912
|
-
const label = brandId ? (deckName ?? brandId) : presId;
|
|
1913
|
-
const body = { slides: slides ?? {} };
|
|
1914
|
-
if (deckAssets && Object.keys(deckAssets).length > 0) {
|
|
1915
|
-
body.deckAssets = deckAssets;
|
|
1916
|
-
}
|
|
1917
|
-
// Reused native one-slide .pptx (base64), keyed by play-order filename — the
|
|
1918
|
-
// server splices them in by numeric-aware sorted filename (contract #7).
|
|
1919
|
-
if (hasNative) body.nativeSlides = nativeSlides;
|
|
1920
|
-
if (etag) body.etag = etag;
|
|
1921
|
-
// Deck-embedded source: known slide GUIDs (jsx path → sm-<uuid>) so the
|
|
1922
|
-
// server's tag bake keeps identities stable; minted ones ride back as
|
|
1923
|
-
// `sourceGuids` on the response.
|
|
1924
|
-
if (sourceGuids && Object.keys(sourceGuids).length > 0) {
|
|
1925
|
-
body.sourceGuids = sourceGuids;
|
|
1926
|
-
}
|
|
1927
|
-
// Reverse-converted slides (`slidemind adopt`): identity + fidelity evidence
|
|
1928
|
-
// per converted jsx file, so the server can gate the first replacement of a
|
|
1929
|
-
// converted slide against what it originally converted, plus the verbatim
|
|
1930
|
-
// source package whose preserved natives (chart / OLE / SmartArt) it splices
|
|
1931
|
-
// back into the rendered slide. Keyed exactly like `slides`. Additive: a deck
|
|
1932
|
-
// with no conversions sends the same body it always did.
|
|
1933
|
-
if (conversions && Object.keys(conversions).length > 0) {
|
|
1934
|
-
body.conversions = conversions;
|
|
1935
|
-
}
|
|
1936
|
-
// Manual-edit reconcile: for every slide the user edited in PowerPoint since
|
|
1937
|
-
// the last build, the baked baseline projection and the current one, keyed
|
|
1938
|
-
// exactly like `slides`. The server diffs them, folds text edits into the jsx
|
|
1939
|
-
// it renders, and reports the rest — the CLI owns no diff source. Additive: a
|
|
1940
|
-
// deck with no pending edits sends the same body it always did, and the
|
|
1941
|
-
// response's `reconcile` echo is what proves the backend understood it.
|
|
1942
|
-
if (reconcile && Object.keys(reconcile).length > 0) {
|
|
1943
|
-
body.reconcile = reconcile;
|
|
1944
|
-
}
|
|
1945
|
-
// The brand surface has no presId to label the render with — pass the deck
|
|
1946
|
-
// slug so job records/temp dirs stay readable (server-side sanitized).
|
|
1947
|
-
if (brandId && deckName) body.deckName = deckName;
|
|
1948
|
-
|
|
1949
|
-
if (await backendSupportsRenderJobs(be)) {
|
|
1950
|
-
try {
|
|
1951
|
-
return await renderViaJob({ basePath, label, body, backendURL: be, onProgress });
|
|
1952
|
-
} catch (err) {
|
|
1953
|
-
// The backend speaks the protocol but its job store is down — the sync
|
|
1954
|
-
// route renders in-process without Redis, so fall back to it.
|
|
1955
|
-
if (err?.code !== "jobs_unavailable") throw err;
|
|
1956
|
-
}
|
|
1957
|
-
}
|
|
1958
|
-
return renderViaSync({ basePath, body, backendURL: be });
|
|
1959
|
-
}
|
|
1960
|
-
|
|
1961
|
-
// Translate a deck the CLI captured LOCALLY (the shipped capture engine) into a
|
|
1962
|
-
// native .pptx. The slow Chrome work already ran client-side, so this is a fast
|
|
1963
|
-
// single synchronous request — the server runs only the IP-sensitive boxes→OOXML
|
|
1964
|
-
// half. Returns the same shape as renderPptx ({ ok, base64, slideCount, … }).
|
|
1965
|
-
export async function translateDeck({
|
|
1966
|
-
presId = null,
|
|
1967
|
-
brandId = null,
|
|
1968
|
-
deckName = null,
|
|
1969
|
-
captured,
|
|
1970
|
-
nativeSlides = null,
|
|
1971
|
-
etag = null,
|
|
1972
|
-
backendURL = null,
|
|
1973
|
-
}) {
|
|
1974
|
-
if (!presId && !brandId) {
|
|
1975
|
-
throw new Error("translateDeck: presId or brandId is required");
|
|
1976
|
-
}
|
|
1977
|
-
if (!captured) throw new Error("translateDeck: captured is required");
|
|
1978
|
-
let be = backendURL;
|
|
1979
|
-
if (!be) {
|
|
1980
|
-
if (brandId) throw new Error("translateDeck: backendURL is required with brandId");
|
|
1981
|
-
be = await resolveBackendForPres(presId);
|
|
1982
|
-
}
|
|
1983
|
-
await assertCliVersionSupported(be);
|
|
1984
|
-
const body = { captured };
|
|
1985
|
-
// Reused native one-slide .pptx (base64), keyed by play-order filename — the
|
|
1986
|
-
// server splices them in alongside the locally-captured JSX slides (contract
|
|
1987
|
-
// #7). The slow capture already ran client-side; native slides are pass-through.
|
|
1988
|
-
if (nativeSlides && Object.keys(nativeSlides).length > 0) {
|
|
1989
|
-
body.nativeSlides = nativeSlides;
|
|
1990
|
-
}
|
|
1991
|
-
if (etag) body.etag = etag;
|
|
1992
|
-
if (brandId && deckName) body.deckName = deckName;
|
|
1993
|
-
const res = await bridgeFetch(
|
|
1994
|
-
`${renderBasePath({ presId, brandId })}/translate`,
|
|
1995
|
-
{
|
|
1996
|
-
method: "POST",
|
|
1997
|
-
headers: { "content-type": "application/json" },
|
|
1998
|
-
body: JSON.stringify(body),
|
|
1999
|
-
// The body carries inlined image bytes and the response a base64 .pptx —
|
|
2000
|
-
// bandwidth, not server compute. Give it the render-grade timeout.
|
|
2001
|
-
timeoutMs:
|
|
2002
|
-
parseInt(process.env.SLIDEMIND_BRIDGE_RENDER_TIMEOUT_MS || "300000", 10) ||
|
|
2003
|
-
300000,
|
|
2004
|
-
},
|
|
2005
|
-
be,
|
|
2006
|
-
);
|
|
2007
|
-
return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
|
|
2008
|
-
}
|
|
2009
|
-
|
|
2010
|
-
// A 404 the RELAY emitted about the presId itself (the window is no longer in
|
|
2011
|
-
// the registry — a pane reconnect mints a new presId for the same window)
|
|
2012
|
-
// rather than one a ROUTE emitted about something else it was asked for (a
|
|
2013
|
-
// stale `unknown_search`, an unimplemented route). Only the former is worth
|
|
2014
|
-
// re-targeting: the same window is probably back under a new id.
|
|
2015
|
-
//
|
|
2016
|
-
// Read the PARSED code, never `err.code`: bridgeFetch falls that back to the
|
|
2017
|
-
// human error string, and the relay's vanished-presId 404 carries prose only
|
|
2018
|
-
// ("No PowerPoint window registered for presId='X'") — so testing `err.code`
|
|
2019
|
-
// saw a truthy, unmatched value and never retargeted anything.
|
|
2020
|
-
function isMissingPresentationError(err) {
|
|
2021
|
-
if (err?.status !== 404) return false;
|
|
2022
|
-
const code = err?.body?.code ?? null;
|
|
2023
|
-
return !code || code === "unknown_presentation" || code === "presentation_not_found";
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2026
|
-
// Run a pane-relayed engine op; on `pane_suspended` (the server already waited
|
|
2027
|
-
// ~25s for a reconnect) OR `pane_timeout` (the pane looked connected but never
|
|
2028
|
-
// acked within the server's 60s window — the server marks it suspended on the
|
|
2029
|
-
// way out, so the same wake applies) wait CLIENT-side for the pane to wake —
|
|
2030
|
-
// one actionable line, then a single retry against the live entry (covers
|
|
2031
|
-
// presId churn too). Engine ops are idempotent reads, and the one mutation
|
|
2032
|
-
// (live insert) rides the SAME operationId through the retry so the pane-side
|
|
2033
|
-
// dedup makes a re-send safe.
|
|
2034
|
-
//
|
|
2035
|
-
// `fileName`/`brandId` identify the WINDOW so the wait can re-match it after a
|
|
2036
|
-
// presId churn (see waitForPaneReady). `retargetOnMissing` additionally treats a
|
|
2037
|
-
// vanished presId as recoverable — opt-in, because for most engine ops a 404 is
|
|
2038
|
-
// the search record talking, not the window: it re-lists the registry, re-matches
|
|
2039
|
-
// by presFP and retries ONCE on a short budget before the caller surfaces the
|
|
2040
|
-
// wake-PowerPoint blocker.
|
|
2041
|
-
async function engineOpWithPaneWait(
|
|
2042
|
-
presId,
|
|
2043
|
-
backendURL,
|
|
2044
|
-
attempt,
|
|
2045
|
-
{
|
|
2046
|
-
fileName = null,
|
|
2047
|
-
brandId = null,
|
|
2048
|
-
otherPresIds = null,
|
|
2049
|
-
retargetOnMissing = false,
|
|
2050
|
-
} = {},
|
|
2051
|
-
) {
|
|
2052
|
-
try {
|
|
2053
|
-
return await attempt(presId, backendURL);
|
|
2054
|
-
} catch (err) {
|
|
2055
|
-
const suspended =
|
|
2056
|
-
err?.code === "pane_suspended" || err?.code === "pane_timeout";
|
|
2057
|
-
const churned = retargetOnMissing && isMissingPresentationError(err);
|
|
2058
|
-
if (!suspended && !churned) throw err;
|
|
2059
|
-
const live = await waitForPaneReady(presId, {
|
|
2060
|
-
backendURL,
|
|
2061
|
-
fileName,
|
|
2062
|
-
brandId,
|
|
2063
|
-
otherPresIds,
|
|
2064
|
-
// A churned presId needs a re-list, not a user: the window is already
|
|
2065
|
-
// there under a new id, so poll briefly instead of holding the full
|
|
2066
|
-
// click-the-pane budget. A suspended pane keeps that budget.
|
|
2067
|
-
timeoutMs: churned && !suspended ? envInt("SLIDEMIND_BRIDGE_PANE_RETARGET_TIMEOUT_MS", 15000) : null,
|
|
2068
|
-
onWait: churned && !suspended ? paneRetargetNotice : paneWaitNotice,
|
|
2069
|
-
});
|
|
2070
|
-
if (!live) throw err;
|
|
2071
|
-
return attempt(live.presId, live.backendURL ?? backendURL);
|
|
2072
|
-
}
|
|
2073
|
-
}
|
|
2074
|
-
|
|
2075
|
-
// ---------------------------------------------------------------------------
|
|
2076
|
-
// Pane-free engine routes (the DIRECT path)
|
|
2077
|
-
// ---------------------------------------------------------------------------
|
|
2078
|
-
//
|
|
2079
|
-
// Newer add-ins serve the whole engine surface off the CLI session:
|
|
2080
|
-
// GET /api/cli/engines catalog (same path,
|
|
2081
|
-
// answered pane-free)
|
|
2082
|
-
// POST /api/cli/searches → { searchId, results }
|
|
2083
|
-
// GET /api/cli/searches/:id/results/:sel/image stream
|
|
2084
|
-
// GET /api/cli/searches/:id/results/:sel/thumbnail stream
|
|
2085
|
-
// GET /api/cli/searches/:id/results/:sel/slide stream (one-slide .pptx)
|
|
2086
|
-
// GET /api/cli/searches/:id/results/:sel/source-presentation
|
|
2087
|
-
//
|
|
2088
|
-
// The searchId is SERVER-owned (per user, short TTL) rather than bound to a
|
|
2089
|
-
// window's pane, and a request carries only that searchId plus the selection
|
|
2090
|
-
// index — the server resolves the source config it stored at search time, so no
|
|
2091
|
-
// client ever names a file, site or drive.
|
|
2092
|
-
//
|
|
2093
|
-
// How a function picks its path: a `presId` means "relay through THAT window"
|
|
2094
|
-
// (the compat path old add-ins need and live insert always needs); NO presId
|
|
2095
|
-
// plus an explicit `backendURL`/`brandId` means "run this pane-free". Callers
|
|
2096
|
-
// resolve the backend once with resolveEngineBackend(), which returns null when
|
|
2097
|
-
// nothing paired advertises the routes — that null is the signal to fall back
|
|
2098
|
-
// to the pane relay, never a failure.
|
|
2099
|
-
|
|
2100
|
-
function searchRoute(searchId, suffix = "") {
|
|
2101
|
-
return `/api/cli/searches/${encodeURIComponent(String(searchId))}${suffix}`;
|
|
2102
|
-
}
|
|
2103
|
-
|
|
2104
|
-
// A searchId is a number on the pane relay (the pane mints it per window) and an
|
|
2105
|
-
// opaque server-minted id on the direct routes — accept either, reject anything
|
|
2106
|
-
// else before a call goes near the network.
|
|
2107
|
-
function assertSearchId(fn, searchId) {
|
|
2108
|
-
const ok =
|
|
2109
|
-
(typeof searchId === "number" && Number.isFinite(searchId)) ||
|
|
2110
|
-
(typeof searchId === "string" && searchId.trim().length > 0);
|
|
2111
|
-
if (!ok) throw new Error(`${fn}: searchId is required`);
|
|
2112
|
-
}
|
|
2113
|
-
|
|
2114
|
-
// A large asset legitimately takes longer than an engine op: the server is
|
|
2115
|
-
// fetching from SharePoint/blob AND piping. Separate budget so raising it
|
|
2116
|
-
// doesn't loosen every other bridge call.
|
|
2117
|
-
function downloadTimeoutMs() {
|
|
2118
|
-
return (
|
|
2119
|
-
parseInt(process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000", 10) ||
|
|
2120
|
-
600000
|
|
2121
|
-
);
|
|
2122
|
-
}
|
|
2123
|
-
|
|
2124
|
-
function resultRoute(searchId, selection, leaf, params = null) {
|
|
2125
|
-
const qs = params && [...params.keys()].length > 0 ? `?${params}` : "";
|
|
2126
|
-
return `${searchRoute(searchId, `/results/${encodeURIComponent(String(selection))}/${leaf}`)}${qs}`;
|
|
2127
|
-
}
|
|
2128
|
-
|
|
2129
|
-
// Per-process memo of which backend serves a brand's engine routes, so a
|
|
2130
|
-
// multi-environment session pays the brand probe at most once per design.
|
|
2131
|
-
const engineBackendForBrand = new Map();
|
|
2132
|
-
|
|
2133
|
-
// A searchId is opaque (server-minted, hex) on the direct routes and numeric on
|
|
2134
|
-
// the pane relay. The distinction routes: an opaque id names a record on the
|
|
2135
|
-
// backend that minted it, and the pane can NEVER serve it — its handler only
|
|
2136
|
-
// understands its own per-window numeric ids.
|
|
2137
|
-
export function isOpaqueSearchId(searchId) {
|
|
2138
|
-
return (
|
|
2139
|
-
typeof searchId === "string" &&
|
|
2140
|
-
searchId.trim() !== "" &&
|
|
2141
|
-
!/^\d+$/.test(searchId.trim())
|
|
2142
|
-
);
|
|
2143
|
-
}
|
|
2144
|
-
|
|
2145
|
-
// Which paired backend minted this server-owned search record. Search records
|
|
2146
|
-
// are oid-scoped, so a foreign environment answers the same 404 as an expired
|
|
2147
|
-
// id — probing every engine-routes candidate with the record's own GET is both
|
|
2148
|
-
// exact and cheap (2h TTL, at most a couple of environments). Returns null when
|
|
2149
|
-
// no candidate holds it: expired, or minted by an environment no longer in
|
|
2150
|
-
// scope. Never falls back to the pane — see isOpaqueSearchId.
|
|
2151
|
-
//
|
|
2152
|
-
// One failure class is NOT routing information: a 401 means the session itself
|
|
2153
|
-
// is dead (revoked in the add-in's Connect CLI panel, or aged past its hard
|
|
2154
|
-
// lifetime). Swallowing it reports a revoked session as "that search expired",
|
|
2155
|
-
// and the user then re-runs a search that 401s the same way. So per-candidate
|
|
2156
|
-
// failures are collected and a session-invalid one is rethrown instead.
|
|
2157
|
-
export async function resolveSearchBackend(searchId) {
|
|
2158
|
-
if (!isOpaqueSearchId(searchId)) return null;
|
|
2159
|
-
const failures = [];
|
|
2160
|
-
for (const s of sessionsInScope()) {
|
|
2161
|
-
// A revoked session 401s the capability probe too, and that answer is
|
|
2162
|
-
// indistinguishable from "old add-in" unless the failure is kept.
|
|
2163
|
-
const probe = await backendCapabilitiesResult(s.backendURL);
|
|
2164
|
-
if (probe.error && isSessionInvalidError(probe.error)) {
|
|
2165
|
-
failures.push(probe.error);
|
|
2166
|
-
continue;
|
|
2167
|
-
}
|
|
2168
|
-
if (probe.caps?.engineRoutes !== true) continue;
|
|
2169
|
-
try {
|
|
2170
|
-
await bridgeFetch(searchRoute(searchId), {}, s.backendURL);
|
|
2171
|
-
return s.backendURL;
|
|
2172
|
-
} catch (err) {
|
|
2173
|
-
// 404 (not this environment's record / expired) or unreachable — try the
|
|
2174
|
-
// next candidate; the distinction doesn't matter to routing.
|
|
2175
|
-
failures.push(err);
|
|
2176
|
-
}
|
|
2177
|
-
}
|
|
2178
|
-
// The two failures that are NOT the record talking: a dead session, and a
|
|
2179
|
-
// missing Microsoft 365 sign-in (the record re-read is an engine route, so
|
|
2180
|
-
// it refuses with the rest of corpus search). Reported as "that search
|
|
2181
|
-
// expired", both send the user to re-run a search that fails the same way —
|
|
2182
|
-
// so they outrank the expired-record answer.
|
|
2183
|
-
const blocking = failures.find(
|
|
2184
|
-
(e) => isSessionInvalidError(e) || e?.code === "login_required",
|
|
2185
|
-
);
|
|
2186
|
-
if (blocking) throw blocking;
|
|
2187
|
-
return null;
|
|
2188
|
-
}
|
|
2189
|
-
|
|
2190
|
-
// Which paired backend runs the pane-free engine routes for this call. An
|
|
2191
|
-
// explicit backendURL is honoured as-is (the caller already resolved a window or
|
|
2192
|
-
// a deck). Otherwise: every session in scope that advertises the routes is a
|
|
2193
|
-
// candidate — with ONE connected environment (the normal case) that IS the
|
|
2194
|
-
// answer, and no probe is worth paying for it. With several connected, the
|
|
2195
|
-
// DECK's bound design decides, never an open window's origin. presFP/brandId is
|
|
2196
|
-
// content-derived, so the SAME design onboarded in two environments carries the
|
|
2197
|
-
// SAME id — candidates are therefore probed FIRST, and only when none serves
|
|
2198
|
-
// the design does the caller fall back to the pane relay (the design lives on
|
|
2199
|
-
// an older add-in, and searching a different corpus would fold ITS slides into
|
|
2200
|
-
// this deck). Returns null for that fallback and when nothing paired serves the
|
|
2201
|
-
// routes at all.
|
|
2202
|
-
export async function resolveEngineBackend({ backendURL = null, brandId = null } = {}) {
|
|
2203
|
-
if (backendURL) {
|
|
2204
|
-
return (await backendSupportsEngineRoutes(backendURL)) ? backendURL : null;
|
|
2205
|
-
}
|
|
2206
|
-
const sessions = sessionsInScope();
|
|
2207
|
-
const candidates = [];
|
|
2208
|
-
for (const s of sessions) {
|
|
2209
|
-
if (await backendSupportsEngineRoutes(s.backendURL)) candidates.push(s.backendURL);
|
|
2210
|
-
}
|
|
2211
|
-
if (candidates.length === 0) return null;
|
|
2212
|
-
// Exactly one CONNECTED environment: there is nothing else the deck could
|
|
2213
|
-
// belong to. Note the test is on the sessions, not on the candidates — during
|
|
2214
|
-
// a rollout only one of several environments serves the routes, and taking it
|
|
2215
|
-
// regardless would have searched the wrong corpus.
|
|
2216
|
-
if (sessions.length === 1) return candidates[0];
|
|
2217
|
-
if (brandId) {
|
|
2218
|
-
// The memo only stands while that environment is still a candidate.
|
|
2219
|
-
const memo = engineBackendForBrand.get(brandId);
|
|
2220
|
-
if (memo) return candidates.includes(memo) ? memo : null;
|
|
2221
|
-
// Candidates first: an environment that serves both the design AND the
|
|
2222
|
-
// engine routes wins outright, even when an older environment also has the
|
|
2223
|
-
// (content-identical) design.
|
|
2224
|
-
for (const be of candidates) {
|
|
2225
|
-
try {
|
|
2226
|
-
await getDesignBundleForBrand(brandId, { backendURL: be });
|
|
2227
|
-
engineBackendForBrand.set(brandId, be);
|
|
2228
|
-
return be;
|
|
2229
|
-
} catch {
|
|
2230
|
-
// This candidate doesn't serve the design — try the next.
|
|
2231
|
-
}
|
|
2232
|
-
}
|
|
2233
|
-
try {
|
|
2234
|
-
const { backendURL: be } = await getDesignBundleForBrandAnyBackend(brandId);
|
|
2235
|
-
engineBackendForBrand.set(brandId, be);
|
|
2236
|
-
// Only a non-candidate can answer here: the design's environment is an
|
|
2237
|
-
// older add-in with no engine routes. null sends the caller to that
|
|
2238
|
-
// environment's pane relay instead of a different corpus.
|
|
2239
|
-
return null;
|
|
2240
|
-
} catch {
|
|
2241
|
-
// No backend owns the design (unknown brand, old add-in) — fall through
|
|
2242
|
-
// rather than guessing an environment.
|
|
2243
|
-
}
|
|
2244
|
-
}
|
|
2245
|
-
if (candidates.length === 1) return candidates[0];
|
|
2246
|
-
const err = new Error(
|
|
2247
|
-
`Several connected environments run engine search (${candidates
|
|
2248
|
-
.map((u) => deriveEnvLabel(u))
|
|
2249
|
-
.join(", ")}). Scope the command with --backend <env>, point it at a ` +
|
|
2250
|
-
"bound deck (--deck) so the deck's design picks one, or name an open " +
|
|
2251
|
-
"window with --to <presId> to relay through it.",
|
|
2252
|
-
);
|
|
2253
|
-
err.code = "engine_backend_ambiguous";
|
|
2254
|
-
throw err;
|
|
2255
|
-
}
|
|
2256
|
-
|
|
2257
|
-
// Which connected environments answer the pane-free engine routes — the
|
|
2258
|
-
// "backend authoring ready" half of the readiness picture (`doctor`, `ready`),
|
|
2259
|
-
// reported as ENV LABELS so no URL can reach a printed line. Pass
|
|
2260
|
-
// connectionStatus()'s `backends` to skip environments that are already known
|
|
2261
|
-
// unreachable; without it every session in scope is probed.
|
|
2262
|
-
export async function engineReachability({ backends = null } = {}) {
|
|
2263
|
-
const urls = Array.isArray(backends)
|
|
2264
|
-
? backends.filter((b) => b?.ok && b.backendURL).map((b) => b.backendURL)
|
|
2265
|
-
: sessionsInScope().map((s) => s.backendURL);
|
|
2266
|
-
const envs = [];
|
|
2267
|
-
for (const url of urls) {
|
|
2268
|
-
if (await backendSupportsEngineRoutes(url)) envs.push(deriveEnvLabel(url));
|
|
2269
|
-
}
|
|
2270
|
-
return { available: envs.length > 0, envs };
|
|
2271
|
-
}
|
|
2272
|
-
|
|
2273
|
-
// Resolve + verify the backend for a direct engine call. Throws a typed
|
|
2274
|
-
// `engine_routes_unavailable` when the caller asked for the pane-free path on a
|
|
2275
|
-
// backend that doesn't serve it — a programming error by then, since callers go
|
|
2276
|
-
// through resolveEngineBackend first, but it must never surface as a bare 404.
|
|
2277
|
-
async function directEngineBackend(fn, { backendURL = null, brandId = null }) {
|
|
2278
|
-
const be = backendURL ?? (await resolveEngineBackend({ brandId }));
|
|
2279
|
-
if (be && (await backendSupportsEngineRoutes(be))) return be;
|
|
2280
|
-
const err = new Error(
|
|
2281
|
-
`${fn}: no connected slidemind add-in serves pane-free engine routes` +
|
|
2282
|
-
`${be ? ` ('${deriveEnvLabel(be)}' is too old)` : ""}. ` +
|
|
2283
|
-
"Open PowerPoint with the slidemind pane so the search can be relayed, or update the add-in.",
|
|
2284
|
-
);
|
|
2285
|
-
err.code = "engine_routes_unavailable";
|
|
2286
|
-
throw err;
|
|
2287
|
-
}
|
|
2288
|
-
|
|
2289
|
-
// The user's delegated tokens for a backend, or nulls. Attached ONLY to the
|
|
2290
|
-
// pane-free engine ops: they are the user's own Entra identity, and render,
|
|
2291
|
-
// bundle, whoami and the pane relay have no business carrying them (the pane
|
|
2292
|
-
// signs its own requests with a browser token that is strictly fresher).
|
|
2293
|
-
//
|
|
2294
|
-
// When nothing is stored, the sign-in RUNS HERE — browser opens, the command
|
|
2295
|
-
// blocks until the human finishes (graph-auth.ensureSignedIn, once per
|
|
2296
|
-
// process). So login is not a separate ritual: the first engine command of a
|
|
2297
|
-
// fresh machine walks the user through it, and every later one rides the
|
|
2298
|
-
// stored refresh token silently. Null only when the add-in doesn't honour the
|
|
2299
|
-
// tokens, or the sign-in was refused/abandoned — the backend then answers its
|
|
2300
|
-
// typed `login_required`, which the blocker below renders.
|
|
2301
|
-
async function engineGraphToken(backendURL) {
|
|
2302
|
-
if (!backendURL) return null;
|
|
2303
|
-
if (!(await backendSupportsGraphDelegated(backendURL))) return null;
|
|
2304
|
-
let token = await getGraphToken(backendURL);
|
|
2305
|
-
if (!token && (await ensureSignedIn(backendURL))) {
|
|
2306
|
-
token = await getGraphToken(backendURL);
|
|
2307
|
-
}
|
|
2308
|
-
return token;
|
|
2309
|
-
}
|
|
2310
|
-
|
|
2311
|
-
// The SharePoint-resource sibling (same sign-in, same refresh token): the
|
|
2312
|
-
// backend resolves `sg:` site-group membership from the PAIR, and on a
|
|
2313
|
-
// SharePoint-configured deployment refuses corpus search without it.
|
|
2314
|
-
async function engineSpToken(backendURL) {
|
|
2315
|
-
if (!backendURL) return null;
|
|
2316
|
-
if (!(await backendSupportsGraphDelegated(backendURL))) return null;
|
|
2317
|
-
let token = await getSharePointToken(backendURL);
|
|
2318
|
-
if (!token && (await ensureSignedIn(backendURL))) {
|
|
2319
|
-
token = await getSharePointToken(backendURL);
|
|
2320
|
-
}
|
|
2321
|
-
return token;
|
|
2322
|
-
}
|
|
2323
|
-
|
|
2324
|
-
// Both delegated tokens as body fields, for the POST routes. Empty when there
|
|
2325
|
-
// is nothing to attach, so an older backend sees the body it always did.
|
|
2326
|
-
async function delegatedBodyFields(backendURL) {
|
|
2327
|
-
const [graphToken, spToken] = await Promise.all([
|
|
2328
|
-
engineGraphToken(backendURL),
|
|
2329
|
-
engineSpToken(backendURL),
|
|
2330
|
-
]);
|
|
2331
|
-
return {
|
|
2332
|
-
...(graphToken ? { graphApiToken: graphToken } : {}),
|
|
2333
|
-
...(spToken ? { spApiToken: spToken } : {}),
|
|
2334
|
-
};
|
|
2335
|
-
}
|
|
2336
|
-
|
|
2337
|
-
// The byte routes are GETs, so the delegated tokens ride headers instead of
|
|
2338
|
-
// body fields. Empty (not absent-valued) when there is no token, so the request
|
|
2339
|
-
// headers are unchanged for everyone who never signed in.
|
|
2340
|
-
async function graphHeaders(backendURL) {
|
|
2341
|
-
const [graphToken, spToken] = await Promise.all([
|
|
2342
|
-
engineGraphToken(backendURL),
|
|
2343
|
-
engineSpToken(backendURL),
|
|
2344
|
-
]);
|
|
2345
|
-
return {
|
|
2346
|
-
...(graphToken ? { "x-slidemind-graph-token": graphToken } : {}),
|
|
2347
|
-
...(spToken ? { "x-slidemind-sp-token": spToken } : {}),
|
|
2348
|
-
};
|
|
2349
|
-
}
|
|
2350
|
-
|
|
2351
|
-
// Corpus search runs as the USER: the pane-free engine routes resolve the
|
|
2352
|
-
// caller's group membership live from the delegated tokens `slidemind login`
|
|
2353
|
-
// minted, and answer `401 { code: "login_required", action: "…" }` without
|
|
2354
|
-
// them. Shape it like the connection ladder's rungs — `{ code, actor, message }`
|
|
2355
|
-
// — so the CLI can print ONE actionable line and an agent knows it cannot clear
|
|
2356
|
-
// this one itself (the sign-in needs a browser and a human). Returns null for
|
|
2357
|
-
// any other error.
|
|
2358
|
-
export function loginRequiredBlocker(err) {
|
|
2359
|
-
if (err?.code !== "login_required") return null;
|
|
2360
|
-
const action =
|
|
2361
|
-
typeof err?.body?.action === "string" && err.body.action.trim()
|
|
2362
|
-
? err.body.action.trim()
|
|
2363
|
-
: "run `slidemind login` to sign in to Microsoft 365";
|
|
2364
|
-
return {
|
|
2365
|
-
code: "login_required",
|
|
2366
|
-
actor: "user",
|
|
2367
|
-
message:
|
|
2368
|
-
`slidemind needs your Microsoft 365 sign-in before it can search the corpus — ${action}. ` +
|
|
2369
|
-
"Everything else (render, bundle, imagery already downloaded) keeps working.",
|
|
2370
|
-
};
|
|
2371
|
-
}
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
// Brand-engine search through the local bridge. New add-ins run this
|
|
2375
|
-
// server-side with the relayed user token; older add-ins may fall back to a
|
|
2376
|
-
// task-pane proxy. Returns a searchId + numbered results (image thumbnails, or
|
|
2377
|
-
// slide results carrying per-slide text + metadata). `subtype`/`intent`/`top`
|
|
2378
|
-
// are passed through for slide engines (image engines ignore intent); omit them
|
|
2379
|
-
// and the server uses its defaults (back-compat with older add-ins).
|
|
2380
|
-
//
|
|
2381
|
-
// `engineId` may also be a SCOPE sentinel (`scope:all` / `scope:sharepoint`) —
|
|
2382
|
-
// an unfiltered search over the whole corpus rather than one engine's saved
|
|
2383
|
-
// folder filter. A scope has no subtype to derive its index from, so it needs an
|
|
2384
|
-
// explicit `resultType`; the add-in rejects a scope search without one.
|
|
2385
|
-
//
|
|
2386
|
-
// With NO presId and a resolved `backendURL`/`brandId` this runs pane-free
|
|
2387
|
-
// against `POST /api/cli/searches` (same body) and tags the answer with the
|
|
2388
|
-
// backend that owns the searchId.
|
|
2389
|
-
export async function engineSearch({
|
|
2390
|
-
presId = null,
|
|
2391
|
-
engineId,
|
|
2392
|
-
query,
|
|
2393
|
-
subtype = null,
|
|
2394
|
-
resultType = null,
|
|
2395
|
-
intent = null,
|
|
2396
|
-
top = null,
|
|
2397
|
-
backendURL = null,
|
|
2398
|
-
brandId = null,
|
|
2399
|
-
// When the chosen pane is suspended, wait CLIENT-side for THIS window to wake
|
|
2400
|
-
// (the single-window default). engineSearchAnyPane sets this false for all but
|
|
2401
|
-
// the last candidate so it can fail over to another live window instead.
|
|
2402
|
-
waitOnSuspend = true,
|
|
2403
|
-
timeoutMs = null,
|
|
2404
|
-
}) {
|
|
2405
|
-
if (!engineId) throw new Error("engineSearch: engineId is required");
|
|
2406
|
-
if (!query) throw new Error("engineSearch: query is required");
|
|
2407
|
-
const direct = !presId && Boolean(backendURL || brandId);
|
|
2408
|
-
if (!presId && !direct) throw new Error("engineSearch: presId is required");
|
|
2409
|
-
const body = { engineId, query };
|
|
2410
|
-
if (subtype) body.subtype = subtype;
|
|
2411
|
-
// Additive: omitted unless set, so the body an older add-in sees is unchanged.
|
|
2412
|
-
if (resultType) body.resultType = resultType;
|
|
2413
|
-
if (intent) body.intent = intent;
|
|
2414
|
-
if (typeof top === "number") body.top = top;
|
|
2415
|
-
if (direct) {
|
|
2416
|
-
const be = await directEngineBackend("engineSearch", { backendURL, brandId });
|
|
2417
|
-
// The user's own identity: the backend resolves the caller's group
|
|
2418
|
-
// membership from these tokens per request and refuses without them
|
|
2419
|
-
// (login_required). Search also seeds the speculative insert cache
|
|
2420
|
-
// server-side, which is why it carries the Graph token as well as the
|
|
2421
|
-
// byte GETs.
|
|
2422
|
-
const delegated = await delegatedBodyFields(be);
|
|
2423
|
-
const res = await bridgeFetch(
|
|
2424
|
-
"/api/cli/searches",
|
|
2425
|
-
{
|
|
2426
|
-
method: "POST",
|
|
2427
|
-
headers: { "content-type": "application/json" },
|
|
2428
|
-
body: JSON.stringify({ ...body, ...delegated }),
|
|
2429
|
-
timeoutMs: timeoutMs ?? engineTimeoutMs(),
|
|
2430
|
-
},
|
|
2431
|
-
be,
|
|
2432
|
-
);
|
|
2433
|
-
const data = (await res.json()) ?? {};
|
|
2434
|
-
// Tag the routing state (like the brand routes do) so every follow-up pick
|
|
2435
|
-
// hits the backend that owns the searchId. `env` is the printable half —
|
|
2436
|
-
// callers must never surface backendURL.
|
|
2437
|
-
return { ...data, backendURL: be, env: deriveEnvLabel(be) };
|
|
2438
|
-
}
|
|
2439
|
-
const be = backendURL || (await resolveBackendForPres(presId));
|
|
2440
|
-
const run = async (pid, b) => {
|
|
2441
|
-
const res = await bridgeFetch(
|
|
2442
|
-
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/search`,
|
|
2443
|
-
{
|
|
2444
|
-
method: "POST",
|
|
2445
|
-
headers: { "content-type": "application/json" },
|
|
2446
|
-
body: JSON.stringify(body),
|
|
2447
|
-
timeoutMs: timeoutMs ?? engineTimeoutMs(),
|
|
2448
|
-
},
|
|
2449
|
-
b,
|
|
2450
|
-
);
|
|
2451
|
-
return res.json(); // { ok, searchId, engineId, subtype, intent, resultCount, gridDataUri?, results }
|
|
2452
|
-
};
|
|
2453
|
-
return waitOnSuspend ? engineOpWithPaneWait(presId, be, run) : run(presId, be);
|
|
2454
|
-
}
|
|
2455
|
-
|
|
2456
|
-
// A suspended/throttled background webview must not strand a window-AGNOSTIC
|
|
2457
|
-
// engine search: the catalog is per-user, so any connected pane can answer it.
|
|
2458
|
-
// Try the picked pane first, then every other connected pane on the same
|
|
2459
|
-
// backend (non-suspended first), failing fast past each unresponsive one; the
|
|
2460
|
-
// LAST candidate keeps the normal "click the pane to wake it" wait so a genuine
|
|
2461
|
-
// single-window/all-asleep case still surfaces the actionable prompt. Returns
|
|
2462
|
-
// the search result tagged with the presId/backendURL that actually answered —
|
|
2463
|
-
// the searchId is bound to THAT window, so callers must thread it into
|
|
2464
|
-
// follow-up view/insert/add-slide. An explicit presId pins the window (no
|
|
2465
|
-
// failover — the caller asked for that specific one).
|
|
2466
|
-
export async function engineSearchAnyPane({
|
|
2467
|
-
registry,
|
|
2468
|
-
explicitPresId = null,
|
|
2469
|
-
engineId,
|
|
2470
|
-
query,
|
|
2471
|
-
subtype = null,
|
|
2472
|
-
resultType = null,
|
|
2473
|
-
intent = null,
|
|
2474
|
-
top = null,
|
|
2475
|
-
}) {
|
|
2476
|
-
const primary = pickAnyPresentationEntry(registry, explicitPresId);
|
|
2477
|
-
const primaryBackend = primary.backendURL ?? null;
|
|
2478
|
-
let candidates;
|
|
2479
|
-
if (explicitPresId) {
|
|
2480
|
-
candidates = [primary];
|
|
2481
|
-
} else {
|
|
2482
|
-
const others = (Array.isArray(registry) ? registry : [])
|
|
2483
|
-
.filter(
|
|
2484
|
-
(p) =>
|
|
2485
|
-
p.presId !== primary.presId &&
|
|
2486
|
-
p.connected !== false &&
|
|
2487
|
-
(p.backendURL ?? null) === primaryBackend,
|
|
2488
|
-
)
|
|
2489
|
-
.sort((a, b) => (a.suspendedAt ? 1 : 0) - (b.suspendedAt ? 1 : 0));
|
|
2490
|
-
candidates = [primary, ...others];
|
|
2491
|
-
}
|
|
2492
|
-
const failoverTimeout = envInt(
|
|
2493
|
-
"SLIDEMIND_BRIDGE_ENGINE_FAILOVER_TIMEOUT_MS",
|
|
2494
|
-
25000,
|
|
2495
|
-
);
|
|
2496
|
-
let lastErr = null;
|
|
2497
|
-
for (let i = 0; i < candidates.length; i++) {
|
|
2498
|
-
const cand = candidates[i];
|
|
2499
|
-
const isLast = i === candidates.length - 1;
|
|
2500
|
-
try {
|
|
2501
|
-
const res = await engineSearch({
|
|
2502
|
-
presId: cand.presId,
|
|
2503
|
-
engineId,
|
|
2504
|
-
query,
|
|
2505
|
-
subtype,
|
|
2506
|
-
resultType,
|
|
2507
|
-
intent,
|
|
2508
|
-
top,
|
|
2509
|
-
backendURL: cand.backendURL ?? primaryBackend,
|
|
2510
|
-
waitOnSuspend: isLast,
|
|
2511
|
-
timeoutMs: isLast ? null : failoverTimeout,
|
|
2512
|
-
});
|
|
2513
|
-
return {
|
|
2514
|
-
...res,
|
|
2515
|
-
presId: cand.presId,
|
|
2516
|
-
backendURL: cand.backendURL ?? primaryBackend,
|
|
2517
|
-
};
|
|
2518
|
-
} catch (err) {
|
|
2519
|
-
lastErr = err;
|
|
2520
|
-
// A suspended/frozen/slow pane is recoverable by trying the next window;
|
|
2521
|
-
// anything else (bad engineId, auth, 4xx) is real — surface it now.
|
|
2522
|
-
const recoverable =
|
|
2523
|
-
err?.code === "pane_suspended" ||
|
|
2524
|
-
err?.code === "pane_timeout" ||
|
|
2525
|
-
err?.code === "bridge_timeout" ||
|
|
2526
|
-
err?.status === 503 ||
|
|
2527
|
-
err?.status === 504;
|
|
2528
|
-
if (isLast || !recoverable) throw err;
|
|
2529
|
-
}
|
|
2530
|
-
}
|
|
2531
|
-
throw lastErr ?? new Error("engineSearchAnyPane: no candidate panes");
|
|
2532
|
-
}
|
|
2533
|
-
|
|
2534
|
-
// Fetch a chosen engine result's bytes through the local bridge. For image
|
|
2535
|
-
// engines this returns SVG/PNG bytes (+ optional recoloured svgMarkup) WITHOUT
|
|
2536
|
-
// inserting onto a slide.
|
|
2537
|
-
//
|
|
2538
|
-
// Pane-only by design: the pane-free route table streams (see
|
|
2539
|
-
// engineDownloadToFile), and this base64-through-the-ack path only exists for
|
|
2540
|
-
// add-ins that predate streaming — which by definition predate the direct
|
|
2541
|
-
// routes too.
|
|
2542
|
-
export async function engineDownload({
|
|
2543
|
-
presId,
|
|
2544
|
-
searchId,
|
|
2545
|
-
selection,
|
|
2546
|
-
iconColor = null,
|
|
2547
|
-
backendURL = null,
|
|
2548
|
-
}) {
|
|
2549
|
-
if (!presId) throw new Error("engineDownload: presId is required");
|
|
2550
|
-
assertSearchId("engineDownload", searchId);
|
|
2551
|
-
if (typeof selection !== "number") {
|
|
2552
|
-
throw new Error("engineDownload: selection (number) is required");
|
|
2553
|
-
}
|
|
2554
|
-
const be = backendURL || (await resolveBackendForPres(presId));
|
|
2555
|
-
const body = { searchId, selection };
|
|
2556
|
-
if (iconColor) body.iconColor = iconColor;
|
|
2557
|
-
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
2558
|
-
const res = await bridgeFetch(
|
|
2559
|
-
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/download`,
|
|
2560
|
-
{
|
|
2561
|
-
method: "POST",
|
|
2562
|
-
headers: { "content-type": "application/json" },
|
|
2563
|
-
body: JSON.stringify(body),
|
|
2564
|
-
// Same relay path as engine/search — outlast the server's reconnect
|
|
2565
|
-
// wait + pane ack timeout.
|
|
2566
|
-
timeoutMs:
|
|
2567
|
-
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
2568
|
-
90000,
|
|
2569
|
-
},
|
|
2570
|
-
b,
|
|
2571
|
-
);
|
|
2572
|
-
return res.json(); // { ok, base64, mimeType, svgMarkup }
|
|
2573
|
-
});
|
|
2574
|
-
}
|
|
2575
|
-
|
|
2576
|
-
// Stream a chosen engine result's bytes to `destPath`. The size-unbounded
|
|
2577
|
-
// download: the pane's Socket.IO ack carries only a signed source reference, and
|
|
2578
|
-
// the add-in server fetches it and pipes the body here — so nothing buffers the
|
|
2579
|
-
// whole image and there is no base64 4/3 inflation. `engineDownload` above sends
|
|
2580
|
-
// the bytes through the pane ack instead and is therefore capped by the relay's
|
|
2581
|
-
// 10 MB frame limit; prefer this for anything that might be large.
|
|
2582
|
-
//
|
|
2583
|
-
// An SVG result comes back as JSON (`{ ok, mimeType, svgMarkup }`) rather than a
|
|
2584
|
-
// byte stream: it is kilobytes and its recolour happens pane-side. Callers get
|
|
2585
|
-
// `{ svgMarkup }` in that case and `{ bytesWritten, mimeType }` otherwise, so they
|
|
2586
|
-
// can persist whichever arrived.
|
|
2587
|
-
//
|
|
2588
|
-
// With NO presId and a resolved `backendURL`/`brandId` this streams pane-free
|
|
2589
|
-
// from `GET /api/cli/searches/:searchId/results/:selection/image` — same answer
|
|
2590
|
-
// shape, no window involved.
|
|
2591
|
-
export async function engineDownloadToFile({
|
|
2592
|
-
presId = null,
|
|
2593
|
-
searchId,
|
|
2594
|
-
selection,
|
|
2595
|
-
destPath,
|
|
2596
|
-
iconColor = null,
|
|
2597
|
-
backendURL = null,
|
|
2598
|
-
brandId = null,
|
|
2599
|
-
onProgress = null,
|
|
2600
|
-
}) {
|
|
2601
|
-
const direct = !presId && Boolean(backendURL || brandId);
|
|
2602
|
-
if (!presId && !direct) {
|
|
2603
|
-
throw new Error("engineDownloadToFile: presId is required");
|
|
2604
|
-
}
|
|
2605
|
-
assertSearchId("engineDownloadToFile", searchId);
|
|
2606
|
-
if (typeof selection !== "number") {
|
|
2607
|
-
throw new Error("engineDownloadToFile: selection (number) is required");
|
|
2608
|
-
}
|
|
2609
|
-
if (!destPath) throw new Error("engineDownloadToFile: destPath is required");
|
|
2610
|
-
// Both paths answer with the same two shapes (streamed bytes, or the inline
|
|
2611
|
-
// SVG as JSON), so the response shaping is authored once.
|
|
2612
|
-
const shape = (res) => {
|
|
2613
|
-
if (!res.streamed) {
|
|
2614
|
-
// The inline-SVG answer (kilobytes) arrives as JSON, not as a byte stream.
|
|
2615
|
-
const parsed = res.body ? JSON.parse(res.body) : {};
|
|
2616
|
-
return { ok: true, streamed: false, ...parsed };
|
|
2617
|
-
}
|
|
2618
|
-
const warningsHeader = res.headers?.["x-slidemind-warnings"];
|
|
2619
|
-
let warnings;
|
|
2620
|
-
if (warningsHeader) {
|
|
2621
|
-
try {
|
|
2622
|
-
warnings = JSON.parse(warningsHeader);
|
|
2623
|
-
} catch {
|
|
2624
|
-
// A malformed header must not fail a download that otherwise succeeded.
|
|
2625
|
-
}
|
|
2626
|
-
}
|
|
2627
|
-
return {
|
|
2628
|
-
ok: true,
|
|
2629
|
-
streamed: true,
|
|
2630
|
-
mimeType: String(res.headers?.["content-type"] ?? "") || null,
|
|
2631
|
-
bytesWritten: res.bytesWritten,
|
|
2632
|
-
...(warnings ? { warnings } : {}),
|
|
2633
|
-
};
|
|
2634
|
-
};
|
|
2635
|
-
if (direct) {
|
|
2636
|
-
const b = await directEngineBackend("engineDownloadToFile", {
|
|
2637
|
-
backendURL,
|
|
2638
|
-
brandId,
|
|
2639
|
-
});
|
|
2640
|
-
// Only the recolour rides the query string — the RESOURCE is never
|
|
2641
|
-
// client-supplied on this path; the server resolves it from searchId+selection.
|
|
2642
|
-
const params = new URLSearchParams();
|
|
2643
|
-
if (iconColor) params.set("iconColor", iconColor);
|
|
2644
|
-
const res = await bridgeStreamToFile(
|
|
2645
|
-
resultRoute(searchId, selection, "image", params),
|
|
2646
|
-
{
|
|
2647
|
-
method: "GET",
|
|
2648
|
-
headers: await graphHeaders(b),
|
|
2649
|
-
timeoutMs: downloadTimeoutMs(),
|
|
2650
|
-
onProgress,
|
|
2651
|
-
},
|
|
2652
|
-
destPath,
|
|
2653
|
-
b,
|
|
2654
|
-
);
|
|
2655
|
-
return shape(res);
|
|
2656
|
-
}
|
|
2657
|
-
const be = backendURL || (await resolveBackendForPres(presId));
|
|
2658
|
-
const body = { searchId, selection };
|
|
2659
|
-
if (iconColor) body.iconColor = iconColor;
|
|
2660
|
-
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
2661
|
-
const res = await bridgeStreamToFile(
|
|
2662
|
-
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/download-stream`,
|
|
2663
|
-
{
|
|
2664
|
-
method: "POST",
|
|
2665
|
-
headers: { "content-type": "application/json" },
|
|
2666
|
-
body: JSON.stringify(body),
|
|
2667
|
-
timeoutMs: downloadTimeoutMs(),
|
|
2668
|
-
onProgress,
|
|
2669
|
-
},
|
|
2670
|
-
destPath,
|
|
2671
|
-
b,
|
|
2672
|
-
);
|
|
2673
|
-
return shape(res);
|
|
2674
|
-
});
|
|
2675
|
-
}
|
|
2676
|
-
|
|
2677
|
-
// Stream a chosen slide-search result to `destPath` as a native one-slide .pptx.
|
|
2678
|
-
// The size-unbounded sibling of `extractSlide`: the pane ack carries only the
|
|
2679
|
-
// request, and the add-in server performs it and pipes the (base64-decoded) bytes
|
|
2680
|
-
// here. `extractSlide` puts the whole slide through the pane's Socket.IO ack as
|
|
2681
|
-
// base64, so it is capped at ~7 MB of real bytes — which real reused slides reach.
|
|
2682
|
-
//
|
|
2683
|
-
// With NO presId and a resolved `backendURL`/`brandId` this streams pane-free
|
|
2684
|
-
// from `GET /api/cli/searches/:searchId/results/:selection/slide`.
|
|
2685
|
-
export async function extractSlideToFile({
|
|
2686
|
-
presId = null,
|
|
2687
|
-
searchId,
|
|
2688
|
-
selection,
|
|
2689
|
-
destPath,
|
|
2690
|
-
backendURL = null,
|
|
2691
|
-
brandId = null,
|
|
2692
|
-
onProgress = null,
|
|
2693
|
-
}) {
|
|
2694
|
-
const direct = !presId && Boolean(backendURL || brandId);
|
|
2695
|
-
if (!presId && !direct) throw new Error("extractSlideToFile: presId is required");
|
|
2696
|
-
assertSearchId("extractSlideToFile", searchId);
|
|
2697
|
-
if (typeof selection !== "number") {
|
|
2698
|
-
throw new Error("extractSlideToFile: selection (number) is required");
|
|
2699
|
-
}
|
|
2700
|
-
if (!destPath) throw new Error("extractSlideToFile: destPath is required");
|
|
2701
|
-
const shape = (res) => {
|
|
2702
|
-
const title = res.headers?.["x-slidemind-presentation-title"];
|
|
2703
|
-
return {
|
|
2704
|
-
ok: true,
|
|
2705
|
-
bytesWritten: res.bytesWritten,
|
|
2706
|
-
slideId: res.headers?.["x-slidemind-slide-id"] ?? undefined,
|
|
2707
|
-
presentationTitle: title ? decodeURIComponent(String(title)) : undefined,
|
|
2708
|
-
};
|
|
2709
|
-
};
|
|
2710
|
-
if (direct) {
|
|
2711
|
-
const b = await directEngineBackend("extractSlideToFile", {
|
|
2712
|
-
backendURL,
|
|
2713
|
-
brandId,
|
|
2714
|
-
});
|
|
2715
|
-
const res = await bridgeStreamToFile(
|
|
2716
|
-
resultRoute(searchId, selection, "slide"),
|
|
2717
|
-
{
|
|
2718
|
-
method: "GET",
|
|
2719
|
-
headers: await graphHeaders(b),
|
|
2720
|
-
timeoutMs: downloadTimeoutMs(),
|
|
2721
|
-
onProgress,
|
|
2722
|
-
},
|
|
2723
|
-
destPath,
|
|
2724
|
-
b,
|
|
2725
|
-
);
|
|
2726
|
-
return shape(res);
|
|
2727
|
-
}
|
|
2728
|
-
const be = backendURL || (await resolveBackendForPres(presId));
|
|
2729
|
-
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
2730
|
-
const res = await bridgeStreamToFile(
|
|
2731
|
-
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-extract-stream`,
|
|
2732
|
-
{
|
|
2733
|
-
method: "POST",
|
|
2734
|
-
headers: { "content-type": "application/json" },
|
|
2735
|
-
body: JSON.stringify({ searchId, selection }),
|
|
2736
|
-
timeoutMs: downloadTimeoutMs(),
|
|
2737
|
-
onProgress,
|
|
2738
|
-
},
|
|
2739
|
-
destPath,
|
|
2740
|
-
b,
|
|
2741
|
-
);
|
|
2742
|
-
return shape(res);
|
|
2743
|
-
});
|
|
2744
|
-
}
|
|
2745
|
-
|
|
2746
|
-
// Extract a chosen slide-search result as a standalone one-slide .pptx (base64),
|
|
2747
|
-
// WITHOUT inserting it anywhere — the "fold into a deck" path. Relayed through
|
|
2748
|
-
// the pane like search/download, and pane-only for the same reason as
|
|
2749
|
-
// engineDownload: the pane-free route streams (extractSlideToFile above), and
|
|
2750
|
-
// this capped ack path exists only for add-ins that predate it.
|
|
2751
|
-
export async function extractSlide({
|
|
2752
|
-
presId,
|
|
2753
|
-
searchId,
|
|
2754
|
-
selection,
|
|
2755
|
-
backendURL = null,
|
|
2756
|
-
}) {
|
|
2757
|
-
if (!presId) throw new Error("extractSlide: presId is required");
|
|
2758
|
-
assertSearchId("extractSlide", searchId);
|
|
2759
|
-
if (typeof selection !== "number") {
|
|
2760
|
-
throw new Error("extractSlide: selection (number) is required");
|
|
2761
|
-
}
|
|
2762
|
-
const be = backendURL || (await resolveBackendForPres(presId));
|
|
2763
|
-
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
2764
|
-
const res = await bridgeFetch(
|
|
2765
|
-
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-extract`,
|
|
2766
|
-
{
|
|
2767
|
-
method: "POST",
|
|
2768
|
-
headers: { "content-type": "application/json" },
|
|
2769
|
-
body: JSON.stringify({ searchId, selection }),
|
|
2770
|
-
timeoutMs:
|
|
2771
|
-
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
2772
|
-
90000,
|
|
2773
|
-
},
|
|
2774
|
-
b,
|
|
2775
|
-
);
|
|
2776
|
-
return res.json(); // { ok, base64, slideId?, presentationTitle? }
|
|
2777
|
-
});
|
|
2778
|
-
}
|
|
2779
|
-
|
|
2780
|
-
// Insert one or more chosen slide-search results live into the open
|
|
2781
|
-
// presentation (the "reuse → insert live" path). Relayed through the pane.
|
|
2782
|
-
//
|
|
2783
|
-
// `fileName`/`brandId` identify the target WINDOW (brandId == the deck's presFP).
|
|
2784
|
-
// They cost nothing on the happy path and are what lets the single retry find
|
|
2785
|
-
// the same window again after a pane reconnect churned its presId — the same
|
|
2786
|
-
// self-healing `push` does around its insert.
|
|
2787
|
-
export async function insertSlideLive({
|
|
2788
|
-
presId,
|
|
2789
|
-
searchId,
|
|
2790
|
-
selections,
|
|
2791
|
-
at = null,
|
|
2792
|
-
backendURL = null,
|
|
2793
|
-
fileName = null,
|
|
2794
|
-
brandId = null,
|
|
2795
|
-
otherPresIds = null,
|
|
2796
|
-
}) {
|
|
2797
|
-
if (!presId) throw new Error("insertSlideLive: presId is required");
|
|
2798
|
-
assertSearchId("insertSlideLive", searchId);
|
|
2799
|
-
if (!Array.isArray(selections) || selections.length === 0) {
|
|
2800
|
-
throw new Error("insertSlideLive: selections (number[]) is required");
|
|
2801
|
-
}
|
|
2802
|
-
const be = backendURL || (await resolveBackendForPres(presId));
|
|
2803
|
-
// Live insert is a MUTATION, so it carries an operationId for the add-in's
|
|
2804
|
-
// runMutationOnce dedup (the slide-insert handler keys an LRU on it). Generate
|
|
2805
|
-
// it ONCE here, before engineOpWithPaneWait — the body is built once and
|
|
2806
|
-
// reused across the wrapper's single pane_suspended retry, so the SAME id
|
|
2807
|
-
// rides both attempts and a retry can never double-insert. Mirrors how
|
|
2808
|
-
// buildAndPush makes one push operationId reused across attempts.
|
|
2809
|
-
const operationId = `insert-${randomUUID()}`;
|
|
2810
|
-
const body = { searchId, selections, operationId };
|
|
2811
|
-
if (typeof at === "number") body.at = at;
|
|
2812
|
-
return engineOpWithPaneWait(
|
|
2813
|
-
presId,
|
|
2814
|
-
be,
|
|
2815
|
-
async (pid, b) => {
|
|
2816
|
-
const res = await bridgeFetch(
|
|
2817
|
-
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-insert`,
|
|
2818
|
-
{
|
|
2819
|
-
method: "POST",
|
|
2820
|
-
headers: { "content-type": "application/json" },
|
|
2821
|
-
body: JSON.stringify(body),
|
|
2822
|
-
timeoutMs:
|
|
2823
|
-
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
2824
|
-
90000,
|
|
2825
|
-
},
|
|
2826
|
-
b,
|
|
2827
|
-
);
|
|
2828
|
-
// Tag the window the insert actually landed in: after a retarget it is no
|
|
2829
|
-
// longer the presId the caller passed, and the caller reports it.
|
|
2830
|
-
return { ...(await res.json()), presId: pid }; // { ok, insertedCount, insertedSlideIds, presId }
|
|
2831
|
-
},
|
|
2832
|
-
// A vanished presId is recoverable HERE (unlike the engine reads, whose
|
|
2833
|
-
// 404s are about the search record): the insert never landed, so re-listing
|
|
2834
|
-
// and retrying against the re-registered window is safe — and the same
|
|
2835
|
-
// operationId rides along, so even an ambiguous first attempt can't
|
|
2836
|
-
// double-insert.
|
|
2837
|
-
{ fileName, brandId, otherPresIds, retargetOnMissing: true },
|
|
2838
|
-
);
|
|
2839
|
-
}
|
|
2840
|
-
|
|
2841
|
-
// Reuse chosen slide-search results live when the search record is the
|
|
2842
|
-
// BACKEND's rather than a pane's.
|
|
2843
|
-
//
|
|
2844
|
-
// The pane's slide-insert handler resolves a selection out of the in-browser
|
|
2845
|
-
// search context IT minted (a numeric id); a searchId from the pane-free search
|
|
2846
|
-
// is server-owned and opaque, so relaying it would answer `searchId_required`.
|
|
2847
|
-
// The two legs `add-slide --search` + `push` already run therefore do the job in
|
|
2848
|
-
// the other order: extract each pick pane-free, then hand the BYTES to the
|
|
2849
|
-
// window's own insert route. Live insert stays pane-bound (Office.js is the only
|
|
2850
|
-
// thing that can type into an open deck) — only the searchId resolution moves.
|
|
2851
|
-
export async function insertSlidesFromSearch({
|
|
2852
|
-
presId,
|
|
2853
|
-
searchId,
|
|
2854
|
-
selections,
|
|
2855
|
-
at = null,
|
|
2856
|
-
backendURL = null,
|
|
2857
|
-
engineBackendURL = null,
|
|
2858
|
-
fileName = null,
|
|
2859
|
-
brandId = null,
|
|
2860
|
-
otherPresIds = null,
|
|
2861
|
-
onProgress = null,
|
|
2862
|
-
}) {
|
|
2863
|
-
if (!presId) throw new Error("insertSlidesFromSearch: presId is required");
|
|
2864
|
-
assertSearchId("insertSlidesFromSearch", searchId);
|
|
2865
|
-
if (!Array.isArray(selections) || selections.length === 0) {
|
|
2866
|
-
throw new Error("insertSlidesFromSearch: selections (number[]) is required");
|
|
2867
|
-
}
|
|
2868
|
-
const be = backendURL || (await resolveBackendForPres(presId));
|
|
2869
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-reuse-"));
|
|
2870
|
-
try {
|
|
2871
|
-
let insertedCount = 0;
|
|
2872
|
-
let livePresId = presId;
|
|
2873
|
-
let liveBackendURL = be;
|
|
2874
|
-
const insertedSlideIds = [];
|
|
2875
|
-
for (const [i, selection] of selections.entries()) {
|
|
2876
|
-
const slidePath = path.join(dir, `pick-${selection}.pptx`);
|
|
2877
|
-
const extracted = await extractSlideToFile({
|
|
2878
|
-
searchId,
|
|
2879
|
-
selection,
|
|
2880
|
-
destPath: slidePath,
|
|
2881
|
-
backendURL: engineBackendURL,
|
|
2882
|
-
brandId,
|
|
2883
|
-
onProgress,
|
|
2884
|
-
});
|
|
2885
|
-
if (!extracted?.bytesWritten) {
|
|
2886
|
-
throw new Error(`selection ${selection} produced no slide bytes`);
|
|
2887
|
-
}
|
|
2888
|
-
// One insert per pick, each with its own dedup key, so a retry inside
|
|
2889
|
-
// engineOpWithPaneWait re-sends THAT pick and nothing else. `at` counts
|
|
2890
|
-
// forward so the picks land in the order they were named.
|
|
2891
|
-
const operationId = `insert-${randomUUID()}`;
|
|
2892
|
-
const base64 = fs.readFileSync(slidePath).toString("base64");
|
|
2893
|
-
const resp = await engineOpWithPaneWait(
|
|
2894
|
-
livePresId,
|
|
2895
|
-
liveBackendURL,
|
|
2896
|
-
async (pid, b) => {
|
|
2897
|
-
const ack = await insertSlides({
|
|
2898
|
-
presId: pid,
|
|
2899
|
-
base64,
|
|
2900
|
-
insertAt: typeof at === "number" ? at + i : null,
|
|
2901
|
-
sourceSlideIds: extracted.slideId ? [extracted.slideId] : null,
|
|
2902
|
-
operationId,
|
|
2903
|
-
backendURL: b,
|
|
2904
|
-
});
|
|
2905
|
-
return { ...ack, presId: pid, backendURL: b };
|
|
2906
|
-
},
|
|
2907
|
-
{ fileName, brandId, otherPresIds, retargetOnMissing: true },
|
|
2908
|
-
);
|
|
2909
|
-
// A retarget moves every remaining pick to the window that answered, so
|
|
2910
|
-
// a multi-pick reuse cannot end up split across two presentations.
|
|
2911
|
-
livePresId = resp.presId ?? livePresId;
|
|
2912
|
-
liveBackendURL = resp.backendURL ?? liveBackendURL;
|
|
2913
|
-
const landed = Array.isArray(resp.insertedSlideIds)
|
|
2914
|
-
? resp.insertedSlideIds
|
|
2915
|
-
: [];
|
|
2916
|
-
insertedCount += landed.length || 1;
|
|
2917
|
-
insertedSlideIds.push(...landed);
|
|
2918
|
-
bestEffortRm(slidePath);
|
|
2919
|
-
}
|
|
2920
|
-
return { ok: true, insertedCount, insertedSlideIds, presId: livePresId };
|
|
2921
|
-
} finally {
|
|
2922
|
-
bestEffortRm(dir);
|
|
2923
|
-
}
|
|
2924
|
-
}
|
|
2925
|
-
|
|
2926
|
-
// Remove a temp file/dir that bytes were JUST streamed into. Windows may still
|
|
2927
|
-
// hold a handle on it at cleanup time (antivirus scanning the fresh write), so
|
|
2928
|
-
// plain rmSync races into EBUSY/ENOTEMPTY there — retry, and swallow what's
|
|
2929
|
-
// left: cleanup failure must never mask an operation that succeeded.
|
|
2930
|
-
function bestEffortRm(p) {
|
|
2931
|
-
try {
|
|
2932
|
-
fs.rmSync(p, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
2933
|
-
} catch {}
|
|
2934
|
-
}
|
|
2935
|
-
|
|
2936
|
-
// Fetch a chosen slide-search result's thumbnail/preview as a data URI for
|
|
2937
|
-
// on-demand viewing (the agent doesn't have to extract the whole .pptx just to
|
|
2938
|
-
// look at a slide). Relayed through the pane.
|
|
2939
|
-
//
|
|
2940
|
-
// With NO presId and a resolved `backendURL`/`brandId` this reads the pane-free
|
|
2941
|
-
// `GET …/results/:selection/thumbnail`, which STREAMS: a thumbnail is small, so
|
|
2942
|
-
// it is buffered back into the same `{ dataUri }` contract rather than forcing
|
|
2943
|
-
// every caller to grow a file path.
|
|
2944
|
-
export async function viewSlide({
|
|
2945
|
-
presId = null,
|
|
2946
|
-
searchId,
|
|
2947
|
-
selection,
|
|
2948
|
-
backendURL = null,
|
|
2949
|
-
brandId = null,
|
|
2950
|
-
}) {
|
|
2951
|
-
const direct = !presId && Boolean(backendURL || brandId);
|
|
2952
|
-
if (!presId && !direct) throw new Error("viewSlide: presId is required");
|
|
2953
|
-
assertSearchId("viewSlide", searchId);
|
|
2954
|
-
if (typeof selection !== "number") {
|
|
2955
|
-
throw new Error("viewSlide: selection (number) is required");
|
|
2956
|
-
}
|
|
2957
|
-
if (direct) {
|
|
2958
|
-
const b = await directEngineBackend("viewSlide", { backendURL, brandId });
|
|
2959
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-thumb-"));
|
|
2960
|
-
const tmp = path.join(dir, "thumbnail.bin");
|
|
2961
|
-
try {
|
|
2962
|
-
const res = await bridgeStreamToFile(
|
|
2963
|
-
resultRoute(searchId, selection, "thumbnail"),
|
|
2964
|
-
{
|
|
2965
|
-
method: "GET",
|
|
2966
|
-
headers: await graphHeaders(b),
|
|
2967
|
-
timeoutMs: engineTimeoutMs(),
|
|
2968
|
-
},
|
|
2969
|
-
tmp,
|
|
2970
|
-
b,
|
|
2971
|
-
);
|
|
2972
|
-
if (!res.streamed) {
|
|
2973
|
-
// An add-in that answers with an inline preview passes straight through.
|
|
2974
|
-
return res.body ? JSON.parse(res.body) : { ok: false };
|
|
2975
|
-
}
|
|
2976
|
-
const mimeType =
|
|
2977
|
-
String(res.headers?.["content-type"] ?? "").split(";")[0].trim() ||
|
|
2978
|
-
"image/png";
|
|
2979
|
-
return {
|
|
2980
|
-
ok: true,
|
|
2981
|
-
mimeType,
|
|
2982
|
-
dataUri: `data:${mimeType};base64,${fs.readFileSync(tmp).toString("base64")}`,
|
|
2983
|
-
};
|
|
2984
|
-
} finally {
|
|
2985
|
-
bestEffortRm(dir);
|
|
2986
|
-
}
|
|
2987
|
-
}
|
|
2988
|
-
const be = backendURL || (await resolveBackendForPres(presId));
|
|
2989
|
-
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
2990
|
-
const res = await bridgeFetch(
|
|
2991
|
-
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-view`,
|
|
2992
|
-
{
|
|
2993
|
-
method: "POST",
|
|
2994
|
-
headers: { "content-type": "application/json" },
|
|
2995
|
-
body: JSON.stringify({ searchId, selection }),
|
|
2996
|
-
timeoutMs:
|
|
2997
|
-
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
2998
|
-
90000,
|
|
2999
|
-
},
|
|
3000
|
-
b,
|
|
3001
|
-
);
|
|
3002
|
-
return res.json(); // { ok, dataUri, mimeType }
|
|
3003
|
-
});
|
|
3004
|
-
}
|
|
3005
|
-
|
|
3006
|
-
// Read the ORIGINAL deck a chosen slide-search result came from — NOT the open
|
|
3007
|
-
// window (that is getPresentationContent below). Answers "what else is in the
|
|
3008
|
-
// presentation this slide belongs to?": the surrounding narrative, the slides
|
|
3009
|
-
// either side, the argument the source deck was making.
|
|
3010
|
-
//
|
|
3011
|
-
// The add-in reads it out of the slide index rather than downloading the source
|
|
3012
|
-
// file, so this is fast and works the same for blob- and SharePoint-backed
|
|
3013
|
-
// results — but the text is the last SYNCED extraction, not the live file. The
|
|
3014
|
-
// response is Markdown, and the pane never carries it: the relay route resolves
|
|
3015
|
-
// the selection through the pane and performs the backend read itself.
|
|
3016
|
-
//
|
|
3017
|
-
// searchId is presentation-scoped on the pane path, so that path relays through
|
|
3018
|
-
// the window the search ran on, like viewSlide. Idempotent, so the
|
|
3019
|
-
// pane_suspended retry is safe. With NO presId and a resolved
|
|
3020
|
-
// `backendURL`/`brandId` it reads the pane-free
|
|
3021
|
-
// `GET …/results/:selection/source-presentation` instead (the searchId is
|
|
3022
|
-
// server-owned there, so no window is involved at all).
|
|
3023
|
-
export async function readSourcePresentation({
|
|
3024
|
-
presId = null,
|
|
3025
|
-
searchId,
|
|
3026
|
-
selection,
|
|
3027
|
-
fromSlide = null,
|
|
3028
|
-
toSlide = null,
|
|
3029
|
-
backendURL = null,
|
|
3030
|
-
brandId = null,
|
|
3031
|
-
}) {
|
|
3032
|
-
const direct = !presId && Boolean(backendURL || brandId);
|
|
3033
|
-
if (!presId && !direct) {
|
|
3034
|
-
throw new Error("readSourcePresentation: presId is required");
|
|
3035
|
-
}
|
|
3036
|
-
assertSearchId("readSourcePresentation", searchId);
|
|
3037
|
-
if (typeof selection !== "number") {
|
|
3038
|
-
throw new Error("readSourcePresentation: selection (number) is required");
|
|
3039
|
-
}
|
|
3040
|
-
// Both paths answer with Markdown plus the counts/truncation in headers.
|
|
3041
|
-
const shape = async (res) => ({
|
|
3042
|
-
ok: true,
|
|
3043
|
-
markdown: await res.text(),
|
|
3044
|
-
truncated: res.headers?.["x-slidemind-truncated"] === "1",
|
|
3045
|
-
slideCount: Number(res.headers?.["x-slidemind-slide-count"] ?? 0) || 0,
|
|
3046
|
-
totalSlideCount:
|
|
3047
|
-
Number(res.headers?.["x-slidemind-total-slide-count"] ?? 0) || 0,
|
|
3048
|
-
presentationTitle: res.headers?.["x-slidemind-presentation-title"]
|
|
3049
|
-
? decodeURIComponent(String(res.headers["x-slidemind-presentation-title"]))
|
|
3050
|
-
: undefined,
|
|
3051
|
-
});
|
|
3052
|
-
if (direct) {
|
|
3053
|
-
// The direct route table carries source-presentation, so engineRoutes is the
|
|
3054
|
-
// capability that gates it here (sourcePresentation gates the pane route).
|
|
3055
|
-
const b = await directEngineBackend("readSourcePresentation", {
|
|
3056
|
-
backendURL,
|
|
3057
|
-
brandId,
|
|
3058
|
-
});
|
|
3059
|
-
const params = new URLSearchParams();
|
|
3060
|
-
if (typeof fromSlide === "number") params.set("fromSlide", String(fromSlide));
|
|
3061
|
-
if (typeof toSlide === "number") params.set("toSlide", String(toSlide));
|
|
3062
|
-
const res = await bridgeFetch(
|
|
3063
|
-
resultRoute(searchId, selection, "source-presentation", params),
|
|
3064
|
-
{
|
|
3065
|
-
method: "GET",
|
|
3066
|
-
headers: { accept: "text/markdown, text/plain" },
|
|
3067
|
-
timeoutMs: engineTimeoutMs(),
|
|
3068
|
-
},
|
|
3069
|
-
b,
|
|
3070
|
-
);
|
|
3071
|
-
return shape(res);
|
|
3072
|
-
}
|
|
3073
|
-
const be = backendURL || (await resolveBackendForPres(presId));
|
|
3074
|
-
if (!(await backendSupportsSourcePresentation(be))) {
|
|
3075
|
-
const err = new Error(
|
|
3076
|
-
"This slidemind add-in cannot read a result's source presentation yet. Update the add-in, then try again.",
|
|
3077
|
-
);
|
|
3078
|
-
err.code = "source_presentation_unsupported";
|
|
3079
|
-
throw err;
|
|
3080
|
-
}
|
|
3081
|
-
const body = { searchId, selection };
|
|
3082
|
-
if (typeof fromSlide === "number") body.fromSlide = fromSlide;
|
|
3083
|
-
if (typeof toSlide === "number") body.toSlide = toSlide;
|
|
3084
|
-
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
3085
|
-
const res = await bridgeFetch(
|
|
3086
|
-
`/api/cli/presentations/${encodeURIComponent(pid)}/source-presentation`,
|
|
3087
|
-
{
|
|
3088
|
-
method: "POST",
|
|
3089
|
-
headers: {
|
|
3090
|
-
"content-type": "application/json",
|
|
3091
|
-
accept: "text/markdown, text/plain",
|
|
3092
|
-
},
|
|
3093
|
-
body: JSON.stringify(body),
|
|
3094
|
-
timeoutMs: engineTimeoutMs(),
|
|
3095
|
-
},
|
|
3096
|
-
b,
|
|
3097
|
-
);
|
|
3098
|
-
return shape(res);
|
|
3099
|
-
});
|
|
3100
|
-
}
|
|
3101
|
-
|
|
3102
|
-
// Read the CURRENT content of an open presentation (the "pull content" path
|
|
3103
|
-
// behind `slidemind read`). `mode` selects the
|
|
3104
|
-
// depth: "summary" returns a per-slide outline (index, layout, title, subtitle);
|
|
3105
|
-
// "raw" (default) returns each slide's full plain text — NOT the add-in's editing
|
|
3106
|
-
// markup. Unlike engine ops, content is WINDOW-BOUND: it reads THAT presentation,
|
|
3107
|
-
// so we resolve the origin backend from the presId and relay through that
|
|
3108
|
-
// window's pane. It's an idempotent read, so engineOpWithPaneWait's single
|
|
3109
|
-
// pane_suspended client-wait + retry against the live entry is safe (like viewSlide).
|
|
3110
|
-
export async function getPresentationContent({
|
|
3111
|
-
presId,
|
|
3112
|
-
mode = "raw",
|
|
3113
|
-
backendURL = null,
|
|
3114
|
-
}) {
|
|
3115
|
-
if (!presId) throw new Error("getPresentationContent: presId is required");
|
|
3116
|
-
const be = backendURL || (await resolveBackendForPres(presId));
|
|
3117
|
-
const body = { mode: mode === "summary" ? "summary" : "raw" };
|
|
3118
|
-
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
3119
|
-
const res = await bridgeFetch(
|
|
3120
|
-
`/api/cli/presentations/${encodeURIComponent(pid)}/content`,
|
|
3121
|
-
{
|
|
3122
|
-
method: "POST",
|
|
3123
|
-
headers: { "content-type": "application/json" },
|
|
3124
|
-
body: JSON.stringify(body),
|
|
3125
|
-
timeoutMs: engineTimeoutMs(),
|
|
3126
|
-
},
|
|
3127
|
-
b,
|
|
3128
|
-
);
|
|
3129
|
-
return res.json(); // { ok, mode, slideCount, slides }
|
|
3130
|
-
});
|
|
3131
|
-
}
|
|
3132
|
-
|
|
3133
|
-
// Ask the pane whether its host document is actually still alive (the
|
|
3134
|
-
// `ping-document` op — one PowerPoint.run in the webview). This exists for
|
|
3135
|
-
// `ready`'s zombie check: a webview whose document was closed keeps its socket
|
|
3136
|
-
// (and thus its registry lease) alive, so only a host round-trip tells the
|
|
3137
|
-
// truth. Deliberately NOT engineOpWithPaneWait — a probe must fail fast, never
|
|
3138
|
-
// park in the 120s pane wait. Callers decide what a failure means; this just
|
|
3139
|
-
// returns the ack or throws the transport error.
|
|
3140
|
-
export async function pingPresentation({ presId, backendURL, timeoutMs = 12000 }) {
|
|
3141
|
-
if (!presId) throw new Error("pingPresentation: presId is required");
|
|
3142
|
-
const res = await bridgeFetch(
|
|
3143
|
-
`/api/cli/presentations/${encodeURIComponent(presId)}/ping`,
|
|
3144
|
-
{
|
|
3145
|
-
method: "POST",
|
|
3146
|
-
headers: { "content-type": "application/json" },
|
|
3147
|
-
body: JSON.stringify({}),
|
|
3148
|
-
timeoutMs,
|
|
3149
|
-
},
|
|
3150
|
-
backendURL,
|
|
3151
|
-
);
|
|
3152
|
-
return res.json(); // { ok, presId?, fileName? } | { ok: false, code, error }
|
|
3153
|
-
}
|
|
3154
|
-
|
|
3155
|
-
function ensurePaneAckOk(data, operation) {
|
|
3156
|
-
if (!data || data.ok !== false) return data ?? {};
|
|
3157
|
-
const detail = data.error || `${operation} failed`;
|
|
3158
|
-
const err = new Error(detail);
|
|
3159
|
-
err.code = data.code || `${operation}_failed`;
|
|
3160
|
-
err.body = data;
|
|
3161
|
-
throw err;
|
|
3162
|
-
}
|
|
3163
|
-
|
|
3164
|
-
export async function insertSlides({
|
|
3165
|
-
presId,
|
|
3166
|
-
base64,
|
|
3167
|
-
insertAt = null,
|
|
3168
|
-
sourceSlideIds = null,
|
|
3169
|
-
operationId = null,
|
|
3170
|
-
sourceTagsBySlide = null,
|
|
3171
|
-
backendURL = null,
|
|
3172
|
-
}) {
|
|
3173
|
-
if (!presId) throw new Error("insertSlides: presId is required");
|
|
3174
|
-
if (!base64) throw new Error("insertSlides: base64 is required");
|
|
3175
|
-
const be = backendURL || (await resolveBackendForPres(presId));
|
|
3176
|
-
const body = { base64, insertAt, sourceSlideIds, operationId };
|
|
3177
|
-
// Deck-embedded source payloads for the pane's post-insert tag write,
|
|
3178
|
-
// aligned with the inserted slides in document order.
|
|
3179
|
-
if (Array.isArray(sourceTagsBySlide) && sourceTagsBySlide.some(Boolean)) {
|
|
3180
|
-
body.sourceTagsBySlide = sourceTagsBySlide;
|
|
3181
|
-
}
|
|
3182
|
-
const res = await bridgeFetch(
|
|
3183
|
-
`/api/cli/presentations/${encodeURIComponent(presId)}/insert`,
|
|
3184
|
-
{
|
|
3185
|
-
method: "POST",
|
|
3186
|
-
headers: { "content-type": "application/json" },
|
|
3187
|
-
body: JSON.stringify(body),
|
|
3188
|
-
timeoutMs:
|
|
3189
|
-
parseInt(process.env.SLIDEMIND_BRIDGE_MUTATION_TIMEOUT_MS || "1805000", 10) ||
|
|
3190
|
-
1805000,
|
|
3191
|
-
},
|
|
3192
|
-
be,
|
|
3193
|
-
);
|
|
3194
|
-
return ensurePaneAckOk(await res.json().catch(() => ({})), "insert");
|
|
3195
|
-
}
|
|
3196
|
-
|
|
3197
|
-
// Read-only sibling of the mutation ops: export the LIVE slides carrying the
|
|
3198
|
-
// given GUIDs, so a `--replace` push can see what the user changed on screen
|
|
3199
|
-
// BEFORE it wipes it (plan Phase C1). `guids` omitted ⇒ the whole deck.
|
|
3200
|
-
//
|
|
3201
|
-
// The ack's `tags` come from the Office.js tag API, NOT from the exported
|
|
3202
|
-
// bytes: exportAsBase64 strips custom tags (office-js #6104), so a slide's
|
|
3203
|
-
// drift BASELINE is recoverable only from `tags` — never from `base64`, which
|
|
3204
|
-
// carries the CURRENT state and nothing else. `deck.unmatched` lists the live
|
|
3205
|
-
// slides that are not in the requested set (what a whole-deck replace would
|
|
3206
|
-
// delete); `truncated` names GUIDs that matched but did not fit the relay
|
|
3207
|
-
// frame, to be re-requested.
|
|
3208
|
-
export async function exportSlides({
|
|
3209
|
-
presId,
|
|
3210
|
-
guids = null,
|
|
3211
|
-
presentationId = null,
|
|
3212
|
-
backendURL = null,
|
|
3213
|
-
}) {
|
|
3214
|
-
if (!presId) throw new Error("exportSlides: presId is required");
|
|
3215
|
-
const be = backendURL || (await resolveBackendForPres(presId));
|
|
3216
|
-
const body = {};
|
|
3217
|
-
if (Array.isArray(guids) && guids.length > 0) body.guids = guids;
|
|
3218
|
-
if (presentationId) body.presentationId = presentationId;
|
|
3219
|
-
const res = await bridgeFetch(
|
|
3220
|
-
`/api/cli/presentations/${encodeURIComponent(presId)}/export-slides`,
|
|
3221
|
-
{
|
|
3222
|
-
method: "POST",
|
|
3223
|
-
headers: { "content-type": "application/json" },
|
|
3224
|
-
body: JSON.stringify(body),
|
|
3225
|
-
// Same pane round trip as the mutations (a suspended pane has to wake),
|
|
3226
|
-
// and the payload is slide bytes.
|
|
3227
|
-
timeoutMs:
|
|
3228
|
-
parseInt(process.env.SLIDEMIND_BRIDGE_MUTATION_TIMEOUT_MS || "1805000", 10) ||
|
|
3229
|
-
1805000,
|
|
3230
|
-
},
|
|
3231
|
-
be,
|
|
3232
|
-
);
|
|
3233
|
-
return ensurePaneAckOk(await res.json().catch(() => ({})), "export-slides");
|
|
3234
|
-
}
|
|
3235
|
-
|
|
3236
|
-
export async function replaceSlides({
|
|
3237
|
-
presId,
|
|
3238
|
-
base64,
|
|
3239
|
-
operationId = null,
|
|
3240
|
-
sourceTagsBySlide = null,
|
|
3241
|
-
backendURL = null,
|
|
3242
|
-
}) {
|
|
3243
|
-
if (!presId) throw new Error("replaceSlides: presId is required");
|
|
3244
|
-
if (!base64) throw new Error("replaceSlides: base64 is required");
|
|
3245
|
-
const be = backendURL || (await resolveBackendForPres(presId));
|
|
3246
|
-
const replaceBody = { base64, operationId };
|
|
3247
|
-
if (Array.isArray(sourceTagsBySlide) && sourceTagsBySlide.some(Boolean)) {
|
|
3248
|
-
replaceBody.sourceTagsBySlide = sourceTagsBySlide;
|
|
3249
|
-
}
|
|
3250
|
-
const res = await bridgeFetch(
|
|
3251
|
-
`/api/cli/presentations/${encodeURIComponent(presId)}/replace`,
|
|
3252
|
-
{
|
|
3253
|
-
method: "POST",
|
|
3254
|
-
headers: { "content-type": "application/json" },
|
|
3255
|
-
body: JSON.stringify(replaceBody),
|
|
3256
|
-
timeoutMs:
|
|
3257
|
-
parseInt(process.env.SLIDEMIND_BRIDGE_MUTATION_TIMEOUT_MS || "1805000", 10) ||
|
|
3258
|
-
1805000,
|
|
3259
|
-
},
|
|
3260
|
-
be,
|
|
3261
|
-
);
|
|
3262
|
-
return ensurePaneAckOk(await res.json().catch(() => ({})), "replace");
|
|
3263
|
-
}
|
|
3264
|
-
|
|
3265
|
-
// Non-throwing, PURE (no network) sibling of pickPresentationEntry that answers
|
|
3266
|
-
// the one question `slidemind ready` needs: which corporate design should this
|
|
3267
|
-
// session bind to? Where pickPresentationEntry PROMPTS the user by throwing,
|
|
3268
|
-
// resolveDesignSource returns a STRUCTURED verdict the CLI turns into a bound
|
|
3269
|
-
// bundle, a single blocker, or a "which design?" question — no exceptions as
|
|
3270
|
-
// control flow.
|
|
3271
|
-
//
|
|
3272
|
-
// The bar is RENDER-ready, never pane-ready: the add-in is the source of truth
|
|
3273
|
-
// for the design, and a backgrounded (pane-suspended) window still serves its
|
|
3274
|
-
// bundle and still renders — so it is a valid design source (the caller reports
|
|
3275
|
-
// `paneSuspended` but stays exit 0). Pane readiness only matters later, when a
|
|
3276
|
-
// push/search actually needs the live pane.
|
|
3277
|
-
//
|
|
3278
|
-
// The ONE user-facing ambiguity is ≥2 render-ready windows on DISTINCT designs
|
|
3279
|
-
// (distinct `brandId`). We deliberately do NOT let the deck's own bundle ref
|
|
3280
|
-
// (`deckPresFP`) break that tie — the open add-in wins; a deck never pins a
|
|
3281
|
-
// design preference. The deck ref only:
|
|
3282
|
-
// (a) tie-breaks ONE window mirrored across paired backends (same presId on
|
|
3283
|
-
// dev+prod) via tieBreakMatches — the SAME rule pickPresentationEntry
|
|
3284
|
-
// uses, and its only sanctioned use here; and
|
|
3285
|
-
// (b) surfaces the matching candidate as `suggested`, so the agent can say
|
|
3286
|
-
// "this deck was last built against X" WITHOUT the CLI silently deciding.
|
|
3287
|
-
//
|
|
3288
|
-
// Returns exactly one of:
|
|
3289
|
-
// { status: "none" } — nothing render-ready is open.
|
|
3290
|
-
// { status: "unknown_presid", presId } — an explicit --to names no
|
|
3291
|
-
// render-ready window.
|
|
3292
|
-
// { status: "resolved", entry } — the RAW registry entry (backendURL
|
|
3293
|
-
// intact; the caller strips it before
|
|
3294
|
-
// any user-facing output).
|
|
3295
|
-
// { status: "ambiguous", candidates, suggested }
|
|
3296
|
-
// — candidates deduped to ONE entry per
|
|
3297
|
-
// distinct design; suggested is the
|
|
3298
|
-
// presId of the deck-ref match, else
|
|
3299
|
-
// null.
|
|
3300
|
-
export function resolveDesignSource(
|
|
3301
|
-
registry,
|
|
3302
|
-
{ explicitPresId = null, deckPresFP = null } = {},
|
|
3303
|
-
) {
|
|
3304
|
-
const all = (Array.isArray(registry) ? registry : []).filter(
|
|
3305
|
-
isRenderReadyPresentation,
|
|
3306
|
-
);
|
|
3307
|
-
if (all.length === 0) return { status: "none" };
|
|
3308
|
-
|
|
3309
|
-
// Group by DESIGN. A render-ready pane always carries a brandId, but be
|
|
3310
|
-
// defensive: a missing brandId is treated as its OWN pseudo-design keyed by
|
|
3311
|
-
// presId so two CD-less windows never collapse into one false match.
|
|
3312
|
-
const designKey = (p) =>
|
|
3313
|
-
p.brandId != null && p.brandId !== ""
|
|
3314
|
-
? `brand:${p.brandId}`
|
|
3315
|
-
: `pres:${p.presId}`;
|
|
3316
|
-
|
|
3317
|
-
// Collapse a set of entries to one representative per distinct design,
|
|
3318
|
-
// preferring a live (pane-ready) pane over a suspended mirror, then compute
|
|
3319
|
-
// the deck-ref `suggested` presId over those representatives.
|
|
3320
|
-
const dedupeCandidates = (entries) => {
|
|
3321
|
-
const byKey = new Map();
|
|
3322
|
-
for (const p of entries) {
|
|
3323
|
-
const k = designKey(p);
|
|
3324
|
-
const cur = byKey.get(k);
|
|
3325
|
-
if (!cur || (!isPaneReadyPresentation(cur) && isPaneReadyPresentation(p))) {
|
|
3326
|
-
byKey.set(k, p);
|
|
3327
|
-
}
|
|
3328
|
-
}
|
|
3329
|
-
const candidates = [...byKey.values()];
|
|
3330
|
-
const suggested =
|
|
3331
|
-
(deckPresFP && candidates.find((c) => c.brandId === deckPresFP)?.presId) ||
|
|
3332
|
-
null;
|
|
3333
|
-
return { candidates, suggested };
|
|
3334
|
-
};
|
|
3335
|
-
|
|
3336
|
-
if (explicitPresId) {
|
|
3337
|
-
const matches = all.filter((p) => p.presId === explicitPresId);
|
|
3338
|
-
if (matches.length === 0) {
|
|
3339
|
-
return { status: "unknown_presid", presId: explicitPresId };
|
|
3340
|
-
}
|
|
3341
|
-
if (matches.length === 1) return { status: "resolved", entry: matches[0] };
|
|
3342
|
-
// >1 render-ready match == ONE window mirrored across paired backends.
|
|
3343
|
-
// Tie-break exactly as pickPresentationEntry; only a genuine tie is asked.
|
|
3344
|
-
const winner = tieBreakMatches(matches, deckPresFP);
|
|
3345
|
-
if (winner) return { status: "resolved", entry: winner };
|
|
3346
|
-
return { status: "ambiguous", ...dedupeCandidates(matches) };
|
|
3347
|
-
}
|
|
3348
|
-
|
|
3349
|
-
// A live pane outranks a suspended one for the DESIGN decision: a
|
|
3350
|
-
// backgrounded — or closed and not yet purged — window must not turn the one
|
|
3351
|
-
// design the user actually has in front of them into a question. Only when
|
|
3352
|
-
// nothing is live do the suspended windows decide.
|
|
3353
|
-
const live = all.filter(isPaneReadyPresentation);
|
|
3354
|
-
const pool = live.length > 0 ? live : all;
|
|
3355
|
-
const distinctDesigns = new Set(pool.map(designKey));
|
|
3356
|
-
if (distinctDesigns.size === 1) {
|
|
3357
|
-
// One design open (possibly one window mirrored across backends, or several
|
|
3358
|
-
// windows on the SAME CD). Pick via the mirror tie-break, else a live pane,
|
|
3359
|
-
// else the first — the design is unambiguous either way.
|
|
3360
|
-
const entry =
|
|
3361
|
-
tieBreakMatches(pool, deckPresFP) ??
|
|
3362
|
-
pool.find(isPaneReadyPresentation) ??
|
|
3363
|
-
pool[0];
|
|
3364
|
-
return { status: "resolved", entry };
|
|
3365
|
-
}
|
|
3366
|
-
// ≥2 distinct designs open — the only case that asks the user (the deck ref
|
|
3367
|
-
// does NOT auto-pick; it only annotates `suggested`).
|
|
3368
|
-
return { status: "ambiguous", ...dedupeCandidates(pool) };
|
|
3369
|
-
}
|
|
3370
|
-
|
|
3371
|
-
// Pick a target presentation given the (possibly multi-backend) registry and an
|
|
3372
|
-
// optional explicit presId. Returns the matched registry ENTRY (which carries
|
|
3373
|
-
// `backendURL`/`env` after a fan-out list), or throws with a helpful message if
|
|
3374
|
-
// the choice is ambiguous. Callers that already hold the entry can pass its
|
|
3375
|
-
// `backendURL` straight to the targeted ops, skipping a second fan-out.
|
|
3376
|
-
// `preferBrandId` (the deck's bundle-ref presFP) tie-breaks a presId open on
|
|
3377
|
-
// several backends — see tieBreakMatches.
|
|
3378
|
-
export function pickPresentationEntry(
|
|
3379
|
-
registry,
|
|
3380
|
-
explicitPresId,
|
|
3381
|
-
{ preferBrandId = null } = {},
|
|
3382
|
-
) {
|
|
3383
|
-
if (explicitPresId) {
|
|
3384
|
-
const matches = registry.filter((p) => p.presId === explicitPresId);
|
|
3385
|
-
if (matches.length === 0) {
|
|
3386
|
-
const ids = registry.map((p) => p.presId).join(", ") || "(none registered)";
|
|
3387
|
-
throw new Error(
|
|
3388
|
-
`No open PowerPoint window registered with presId='${explicitPresId}'. Live: ${ids}`,
|
|
3389
|
-
);
|
|
3390
|
-
}
|
|
3391
|
-
if (matches.length > 1) {
|
|
3392
|
-
const winner = tieBreakMatches(matches, preferBrandId);
|
|
3393
|
-
if (winner) return winner;
|
|
3394
|
-
const envs = matches.map((m) => m.env || "?").join(", ");
|
|
3395
|
-
throw new Error(
|
|
3396
|
-
`Presentation '${explicitPresId}' is open on multiple environments: ${envs}, ` +
|
|
3397
|
-
"and no single connected one has the deck's corporate design active. " +
|
|
3398
|
-
"Disambiguate with --backend <env|label> (or SLIDEMIND_BACKEND).",
|
|
3399
|
-
);
|
|
3400
|
-
}
|
|
3401
|
-
return matches[0];
|
|
3402
|
-
}
|
|
3403
|
-
if (registry.length === 0) {
|
|
3404
|
-
throw new Error(
|
|
3405
|
-
"No PowerPoint windows registered with the slidemind add-in. " +
|
|
3406
|
-
"Open a presentation, ensure the add-in is loaded, and try again.",
|
|
3407
|
-
);
|
|
3408
|
-
}
|
|
3409
|
-
if (registry.length === 1) return registry[0];
|
|
3410
|
-
// ONE window mirrored across several paired backends is not a user choice —
|
|
3411
|
-
// apply the same tie-break as the explicit-presId path. Several DIFFERENT
|
|
3412
|
-
// windows stay a user decision.
|
|
3413
|
-
if (new Set(registry.map((p) => p.presId)).size === 1) {
|
|
3414
|
-
const winner = tieBreakMatches(registry, preferBrandId);
|
|
3415
|
-
if (winner) return winner;
|
|
3416
|
-
}
|
|
3417
|
-
const list = registry
|
|
3418
|
-
.map(
|
|
3419
|
-
(p) =>
|
|
3420
|
-
` ${p.presId}\t${p.env && isMultiEnv() ? `[${p.env}]\t` : ""}${p.title || p.fileName || "(untitled)"}` +
|
|
3421
|
-
(p.connected === false || p.suspendedAt ? "\t(suspended — PowerPoint in background)" : ""),
|
|
3422
|
-
)
|
|
3423
|
-
.join("\n");
|
|
3424
|
-
throw new Error(
|
|
3425
|
-
`Multiple PowerPoint windows are open. Pick one with --to <presId>:\n${list}\n${READY_HINT_PICK}`,
|
|
3426
|
-
);
|
|
3427
|
-
}
|
|
3428
|
-
|
|
3429
|
-
// Window-AGNOSTIC picker for engine ops (discovery / search / result view). The
|
|
3430
|
-
// engine catalog is per-user and NOT bound to any window, so any connected pane
|
|
3431
|
-
// can run the search — there is nothing for the caller to disambiguate. When no
|
|
3432
|
-
// explicit presId is given we attach to one automatically (preferring a live,
|
|
3433
|
-
// non-suspended pane so we don't have to wake a backgrounded one) instead of
|
|
3434
|
-
// throwing the "multiple windows, pick --to" error pickPresentationEntry raises.
|
|
3435
|
-
// An explicit presId still resolves exactly as pickPresentationEntry.
|
|
3436
|
-
//
|
|
3437
|
-
// CAVEAT: the searchId the add-in returns is bound to the pane that ran the
|
|
3438
|
-
// search (each window keeps its own search-context store). So follow-up
|
|
3439
|
-
// view/extract/insert must target the SAME entry this returns — surface its
|
|
3440
|
-
// presId to the caller so it can thread it back.
|
|
3441
|
-
export function pickAnyPresentationEntry(registry, explicitPresId, opts = {}) {
|
|
3442
|
-
if (explicitPresId) return pickPresentationEntry(registry, explicitPresId, opts);
|
|
3443
|
-
if (!Array.isArray(registry) || registry.length === 0) {
|
|
3444
|
-
throw new Error(
|
|
3445
|
-
"No PowerPoint windows registered with the slidemind add-in. " +
|
|
3446
|
-
"Open a presentation, ensure the add-in is loaded, and try again.",
|
|
3447
|
-
);
|
|
3448
|
-
}
|
|
3449
|
-
return (
|
|
3450
|
-
registry.find(isPaneReadyPresentation) ??
|
|
3451
|
-
registry.find((p) => p.connected !== false && !p.suspendedAt) ??
|
|
3452
|
-
registry[0]
|
|
3453
|
-
);
|
|
3454
|
-
}
|
|
3455
|
-
|
|
3456
|
-
// Back-compat shim: returns just the presId string. Prefer
|
|
3457
|
-
// pickPresentationEntry when you need the origin backend.
|
|
3458
|
-
export function pickPresentation(registry, explicitPresId) {
|
|
3459
|
-
return pickPresentationEntry(registry, explicitPresId).presId;
|
|
3460
|
-
}
|
|
9
|
+
// This file is the public surface; the implementation lives in ./client/*.mjs
|
|
10
|
+
// (split by job). Import from here — the submodules are an internal layout.
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
getDesignBundle,
|
|
14
|
+
getDesignBundleForBrand,
|
|
15
|
+
getDesignBundleForBrandAnyBackend,
|
|
16
|
+
listBrands,
|
|
17
|
+
generateConceptImagesForBrand,
|
|
18
|
+
resolveConceptBackend,
|
|
19
|
+
generateImageForBrand,
|
|
20
|
+
backendSupportsConvertPptx,
|
|
21
|
+
convertPptxForBrand,
|
|
22
|
+
listEngines,
|
|
23
|
+
} from "./client/brands.mjs";
|
|
24
|
+
export {
|
|
25
|
+
isOpaqueSearchId,
|
|
26
|
+
resolveSearchBackend,
|
|
27
|
+
resolveEngineBackend,
|
|
28
|
+
engineReachability,
|
|
29
|
+
loginRequiredBlocker,
|
|
30
|
+
} from "./client/engine-routing.mjs";
|
|
31
|
+
export {
|
|
32
|
+
engineSearch,
|
|
33
|
+
engineSearchAnyPane,
|
|
34
|
+
engineDownload,
|
|
35
|
+
engineDownloadToFile,
|
|
36
|
+
} from "./client/engine-search.mjs";
|
|
37
|
+
export {
|
|
38
|
+
extractSlideToFile,
|
|
39
|
+
extractSlide,
|
|
40
|
+
insertSlideLive,
|
|
41
|
+
insertSlidesFromSearch,
|
|
42
|
+
viewSlide,
|
|
43
|
+
readSourcePresentation,
|
|
44
|
+
} from "./client/engine-slides.mjs";
|
|
45
|
+
export {
|
|
46
|
+
pingPresentation,
|
|
47
|
+
insertSlides,
|
|
48
|
+
exportSlides,
|
|
49
|
+
replaceSlides,
|
|
50
|
+
resolveDesignSource,
|
|
51
|
+
pickPresentationEntry,
|
|
52
|
+
pickAnyPresentationEntry,
|
|
53
|
+
pickPresentation,
|
|
54
|
+
} from "./client/pane-ops.mjs";
|
|
55
|
+
export {
|
|
56
|
+
connectionStatus,
|
|
57
|
+
listOpenPresentations,
|
|
58
|
+
toPublicPresentation,
|
|
59
|
+
listOpenPresentationsDetailed,
|
|
60
|
+
requireHost,
|
|
61
|
+
isRenderReadyPresentation,
|
|
62
|
+
isPaneReadyPresentation,
|
|
63
|
+
listReadyPresentationsWithWait,
|
|
64
|
+
requireRenderHost,
|
|
65
|
+
requirePaneHost,
|
|
66
|
+
paneReadyPresentations,
|
|
67
|
+
resolveBackendForPres,
|
|
68
|
+
waitForPaneReady,
|
|
69
|
+
getPresentationContent,
|
|
70
|
+
} from "./client/presentations.mjs";
|
|
71
|
+
export {
|
|
72
|
+
renderPptx,
|
|
73
|
+
translateDeck,
|
|
74
|
+
} from "./client/render.mjs";
|
|
75
|
+
export {
|
|
76
|
+
cliVersion,
|
|
77
|
+
compareCliVersions,
|
|
78
|
+
setDiagnosticMode,
|
|
79
|
+
isSessionInvalidError,
|
|
80
|
+
backendSupportsBrandRoutes,
|
|
81
|
+
backendSupportsConceptImages,
|
|
82
|
+
backendSupportsImageGeneration,
|
|
83
|
+
backendSupportsBrandsList,
|
|
84
|
+
backendSupportsSourcePresentation,
|
|
85
|
+
backendSupportsEngineRoutes,
|
|
86
|
+
backendSupportsGraphDelegated,
|
|
87
|
+
assertCliVersionSupported,
|
|
88
|
+
} from "./client/transport.mjs";
|