slidemind 0.2.2 → 0.2.4
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 +8 -1
- package/AGENTS.md +88 -21
- package/README.md +82 -34
- package/assets/app-icon.png +0 -0
- package/bin/slidemind.mjs +1497 -298
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +32 -28
- package/skills/search/SKILL.md +28 -22
- package/src/bridge/build-and-push.mjs +19 -6
- package/src/bridge/client.mjs +897 -107
- package/src/bridge/config.mjs +2 -2
- package/src/bridge/engine-download.mjs +61 -8
- package/src/bridge/{pair.mjs → http-json.mjs} +9 -24
- package/src/bridge/setup-claim.mjs +127 -0
- package/src/builders/resolve-skill.mjs +64 -2
- package/src/installers/claude.mjs +24 -0
- package/src/installers/codex.mjs +60 -4
- package/src/installers/lib.mjs +49 -19
- package/src/installers/opencode.mjs +16 -0
- package/src/installers/tui.mjs +21 -10
- package/src/pptx/fill/engine.mjs +112 -134
package/src/bridge/client.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Client for the slidemind cloud relay.
|
|
2
2
|
//
|
|
3
3
|
// The CLI talks to `<backendURL>/api/cli/*` with a Bearer session token — both
|
|
4
|
-
// established
|
|
4
|
+
// established by redeeming a one-time setup claim minted in the authenticated add-in.
|
|
5
|
+
// The add-in's cloud
|
|
5
6
|
// backend runs render/bundle in-process and relays insert/replace/engine to the
|
|
6
7
|
// open PowerPoint pane. No localhost, no shared loopback token, no cert.
|
|
7
8
|
|
|
@@ -9,6 +10,7 @@ import { randomUUID } from "node:crypto";
|
|
|
9
10
|
import fs from "node:fs";
|
|
10
11
|
import http from "node:http";
|
|
11
12
|
import https from "node:https";
|
|
13
|
+
import os from "node:os";
|
|
12
14
|
import path from "node:path";
|
|
13
15
|
import { fileURLToPath } from "node:url";
|
|
14
16
|
|
|
@@ -121,11 +123,11 @@ function readinessTimeoutMs() {
|
|
|
121
123
|
}
|
|
122
124
|
|
|
123
125
|
const NOT_PAIRED_MESSAGE =
|
|
124
|
-
|
|
125
|
-
"
|
|
126
|
+
"slidemind is not connected. In PowerPoint open slidemind → Settings → " +
|
|
127
|
+
"General → Connect CLI, press the link button, and run the command it shows.";
|
|
126
128
|
|
|
127
129
|
// The one-command remedy every host-precondition error now points at:
|
|
128
|
-
// `slidemind ready` runs
|
|
130
|
+
// `slidemind ready` runs authorization → reachability → version → window →
|
|
129
131
|
// design ladder and materializes the bundle, so a stuck author has a single
|
|
130
132
|
// next step instead of a scavenger hunt across doctor/presentations/download.
|
|
131
133
|
// Appended (never substituted) to the existing prose so callers' assert.match
|
|
@@ -149,6 +151,29 @@ function transientRetryDelays() {
|
|
|
149
151
|
.filter((n) => Number.isFinite(n) && n >= 0);
|
|
150
152
|
}
|
|
151
153
|
|
|
154
|
+
// A 401 from either bridge transport. Carries the remediation as its message
|
|
155
|
+
// AND a machine-readable shape, because callers that probe several backends in
|
|
156
|
+
// a row have to tell "this environment doesn't have that record" (routing
|
|
157
|
+
// information) from "this session is dead" (the user's problem) — see
|
|
158
|
+
// resolveSearchBackend.
|
|
159
|
+
function sessionInvalidError() {
|
|
160
|
+
const err = new Error(
|
|
161
|
+
"slidemind session is invalid or expired. Run `slidemind logout --all`, then " +
|
|
162
|
+
"reconnect with a fresh command from the add-in's Connect CLI panel.",
|
|
163
|
+
);
|
|
164
|
+
err.status = 401;
|
|
165
|
+
err.code = "session_invalid";
|
|
166
|
+
return err;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// True for the 401 class above, and for anything else that arrives with a 401
|
|
170
|
+
// status (an older path, or a rethrown response error).
|
|
171
|
+
export function isSessionInvalidError(err) {
|
|
172
|
+
if (!err) return false;
|
|
173
|
+
if (err.status === 401 || err.code === "session_invalid") return true;
|
|
174
|
+
return /slidemind session is invalid or expired/i.test(String(err.message ?? ""));
|
|
175
|
+
}
|
|
176
|
+
|
|
152
177
|
function isTransientBridgeError(err) {
|
|
153
178
|
if (!err) return false;
|
|
154
179
|
// pane_suspended needs the user (foreground PowerPoint), not a blind retry —
|
|
@@ -230,18 +255,13 @@ async function bridgeFetch(pathPart, init = {}, backendURL = null) {
|
|
|
230
255
|
if (err && /ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(String(err.message || err))) {
|
|
231
256
|
throw new Error(
|
|
232
257
|
`slidemind relay unreachable (${deriveEnvLabel(session.backendURL)}). Check your connection; ` +
|
|
233
|
-
"if it persists,
|
|
258
|
+
"if it persists, reconnect from the add-in's Connect CLI panel.",
|
|
234
259
|
);
|
|
235
260
|
}
|
|
236
261
|
throw err;
|
|
237
262
|
}
|
|
238
263
|
enforceVersionFloor(response);
|
|
239
|
-
if (response.status === 401)
|
|
240
|
-
throw new Error(
|
|
241
|
-
"slidemind session is invalid or expired. Re-pair: open the add-in, " +
|
|
242
|
-
'click "Connect CLI", and run the shown `slidemind pair …`.',
|
|
243
|
-
);
|
|
244
|
-
}
|
|
264
|
+
if (response.status === 401) throw sessionInvalidError();
|
|
245
265
|
if (response.status < 200 || response.status >= 300) {
|
|
246
266
|
let parsed = null;
|
|
247
267
|
try {
|
|
@@ -271,6 +291,10 @@ async function bridgeFetch(pathPart, init = {}, backendURL = null) {
|
|
|
271
291
|
return {
|
|
272
292
|
json: async () => (response.body ? JSON.parse(response.body) : null),
|
|
273
293
|
text: async () => response.body,
|
|
294
|
+
// Exposed for routes that carry their answer partly in headers (the
|
|
295
|
+
// source-presentation read reports slide counts and truncation there, so a
|
|
296
|
+
// caller learns the deck was windowed without parsing the Markdown).
|
|
297
|
+
headers: response.headers ?? {},
|
|
274
298
|
};
|
|
275
299
|
}
|
|
276
300
|
|
|
@@ -447,18 +471,13 @@ async function bridgeStreamToFile(pathPart, init, destPath, backendURL = null) {
|
|
|
447
471
|
) {
|
|
448
472
|
throw new Error(
|
|
449
473
|
`slidemind relay unreachable (${deriveEnvLabel(session.backendURL)}). Check your connection; ` +
|
|
450
|
-
"if it persists,
|
|
474
|
+
"if it persists, reconnect from the add-in's Connect CLI panel.",
|
|
451
475
|
);
|
|
452
476
|
}
|
|
453
477
|
throw err;
|
|
454
478
|
}
|
|
455
479
|
enforceVersionFloor(response);
|
|
456
|
-
if (response.status === 401)
|
|
457
|
-
throw new Error(
|
|
458
|
-
"slidemind session is invalid or expired. Re-pair: open the add-in, " +
|
|
459
|
-
'click "Connect CLI", and run the shown `slidemind pair …`.',
|
|
460
|
-
);
|
|
461
|
-
}
|
|
480
|
+
if (response.status === 401) throw sessionInvalidError();
|
|
462
481
|
if (response.status < 200 || response.status >= 300) {
|
|
463
482
|
let parsed = null;
|
|
464
483
|
try {
|
|
@@ -796,6 +815,21 @@ export async function requirePaneHost({ timeoutMs = null } = {}) {
|
|
|
796
815
|
return requireHost();
|
|
797
816
|
}
|
|
798
817
|
|
|
818
|
+
// "Is a task pane available RIGHT NOW?" — the non-throwing twin of
|
|
819
|
+
// requirePaneHost, for paths where an open pane is an OPTION rather than a
|
|
820
|
+
// precondition (a stale permission snapshot can be routed around by relaying
|
|
821
|
+
// through a live pane, which signs each request with a fresh browser token).
|
|
822
|
+
// Never waits for hydration and never authors a blocker: an empty array means
|
|
823
|
+
// "no, carry on with what you were doing", not "stop".
|
|
824
|
+
export async function paneReadyPresentations() {
|
|
825
|
+
try {
|
|
826
|
+
const { presentations } = await listOpenPresentationsDetailed();
|
|
827
|
+
return presentations.filter(isPaneReadyPresentation);
|
|
828
|
+
} catch {
|
|
829
|
+
return [];
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
799
833
|
// Tie-break a multi-backend presId collision without bothering the user when
|
|
800
834
|
// only one candidate can actually serve the deck:
|
|
801
835
|
// (a) exactly one CONNECTED pane whose active CD matches the deck's bundle
|
|
@@ -851,17 +885,23 @@ export async function resolveBackendForPres(
|
|
|
851
885
|
// the live registry entry (caller re-targets from it), or null on timeout.
|
|
852
886
|
// `onWait` fires once with the wait budget in seconds — the caller's single
|
|
853
887
|
// actionable "click the pane" line.
|
|
888
|
+
//
|
|
889
|
+
// `otherPresIds` are windows the caller already knows are NOT the target (the
|
|
890
|
+
// rest of the registry it picked from). They are excluded from the last-resort
|
|
891
|
+
// design-fingerprint match — see below.
|
|
854
892
|
export async function waitForPaneReady(
|
|
855
893
|
presId,
|
|
856
894
|
{
|
|
857
895
|
backendURL = null,
|
|
858
896
|
fileName = null,
|
|
859
897
|
brandId = null,
|
|
898
|
+
otherPresIds = null,
|
|
860
899
|
timeoutMs = null,
|
|
861
900
|
pollMs = null,
|
|
862
901
|
onWait = null,
|
|
863
902
|
} = {},
|
|
864
903
|
) {
|
|
904
|
+
const notTheTarget = new Set(otherPresIds ?? []);
|
|
865
905
|
const waitMs =
|
|
866
906
|
timeoutMs ?? envInt("SLIDEMIND_BRIDGE_PANE_READY_TIMEOUT_MS", 120000);
|
|
867
907
|
const stepMs = pollMs ?? envInt("SLIDEMIND_BRIDGE_PANE_READY_POLL_MS", 3000);
|
|
@@ -888,6 +928,25 @@ export async function waitForPaneReady(
|
|
|
888
928
|
(!brandId || p.brandId === brandId),
|
|
889
929
|
);
|
|
890
930
|
if (rematch) return rematch;
|
|
931
|
+
// Last resort: match on the design fingerprint alone (presFP == brandId).
|
|
932
|
+
// A reconnect can report a different name for the same window — the user
|
|
933
|
+
// hit Save As, or the pane reports the title where it used to report the
|
|
934
|
+
// file name — but the active corporate design does not change with it.
|
|
935
|
+
// Only when EXACTLY one connected window carries it: two windows on the
|
|
936
|
+
// same design are indistinguishable, and pushing into the wrong one is
|
|
937
|
+
// worse than waiting.
|
|
938
|
+
//
|
|
939
|
+
// A window the caller ALREADY saw next to the target is not a churned
|
|
940
|
+
// target — it is a different presentation that merely shares the design
|
|
941
|
+
// (most tenants run exactly one), and retargeting to it would append the
|
|
942
|
+
// deck's slides to an unrelated file. Most tenants run a single corporate
|
|
943
|
+
// design, so brandId alone is not window identity.
|
|
944
|
+
if (fileName && brandId) {
|
|
945
|
+
const byBrand = connected.filter(
|
|
946
|
+
(p) => p.brandId === brandId && !notTheTarget.has(p.presId),
|
|
947
|
+
);
|
|
948
|
+
if (byBrand.length === 1) return byBrand[0];
|
|
949
|
+
}
|
|
891
950
|
}
|
|
892
951
|
if (Date.now() >= deadline) return null;
|
|
893
952
|
if (!notified) {
|
|
@@ -907,6 +966,16 @@ function paneWaitNotice(seconds) {
|
|
|
907
966
|
);
|
|
908
967
|
}
|
|
909
968
|
|
|
969
|
+
// The window is gone from the registry but its pane may already be back under a
|
|
970
|
+
// new presId (churn on every reconnect) — nothing for the user to do here, so
|
|
971
|
+
// say what the CLI is doing rather than asking for a click.
|
|
972
|
+
function paneRetargetNotice(seconds) {
|
|
973
|
+
process.stderr.write(
|
|
974
|
+
"the target PowerPoint window re-registered — re-matching it by corporate " +
|
|
975
|
+
`design (up to ${seconds}s)…\n`,
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
|
|
910
979
|
// GET the full design bundle (tokens, css, layouts, designRules, signed asset
|
|
911
980
|
// URLs, engines) for the presentation's active corporate design.
|
|
912
981
|
export async function getDesignBundle(
|
|
@@ -984,6 +1053,45 @@ export async function getDesignBundleForBrandAnyBackend(brandId) {
|
|
|
984
1053
|
throw err;
|
|
985
1054
|
}
|
|
986
1055
|
|
|
1056
|
+
// The corporate designs this user may build against, straight off the CLI
|
|
1057
|
+
// session — no open window, no pane. One entry per design: `{ presFP, name,
|
|
1058
|
+
// env }`, where presFP IS the brandId every pane-free route is keyed by. Only
|
|
1059
|
+
// backends advertising `brandsList` are asked (older ones have no such route);
|
|
1060
|
+
// a backend that fails is skipped rather than failing the whole answer, so one
|
|
1061
|
+
// unreachable environment can't hide another's designs.
|
|
1062
|
+
export async function listBrands() {
|
|
1063
|
+
const out = [];
|
|
1064
|
+
for (const s of sessionsInScope()) {
|
|
1065
|
+
if (!(await backendSupportsBrandsList(s.backendURL))) continue;
|
|
1066
|
+
const env = deriveEnvLabel(s.backendURL);
|
|
1067
|
+
try {
|
|
1068
|
+
const res = await bridgeFetch(
|
|
1069
|
+
"/api/cli/brands",
|
|
1070
|
+
{ timeoutMs: listTimeoutMs() },
|
|
1071
|
+
s.backendURL,
|
|
1072
|
+
);
|
|
1073
|
+
const data = (await res.json()) ?? {};
|
|
1074
|
+
const arr = Array.isArray(data.brands) ? data.brands : [];
|
|
1075
|
+
for (const b of arr) {
|
|
1076
|
+
// The wire may name the design by either of its two equivalent keys
|
|
1077
|
+
// (presFP on the bundle contract, brandId on the routes) — normalize to
|
|
1078
|
+
// the one the deck ref stores.
|
|
1079
|
+
const presFP = b?.presFP ?? b?.brandId ?? b?.id ?? null;
|
|
1080
|
+
if (!presFP) continue;
|
|
1081
|
+
out.push({
|
|
1082
|
+
presFP,
|
|
1083
|
+
name: b?.name ?? b?.designName ?? null,
|
|
1084
|
+
env,
|
|
1085
|
+
backendURL: s.backendURL,
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
} catch {
|
|
1089
|
+
/* unreachable / not granted — that environment simply contributes none */
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return out;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
987
1095
|
// Concept-image generation is a heavy provider call (per-image cap ~180s
|
|
988
1096
|
// server-side). Budget just over that so a typed server error wins over a
|
|
989
1097
|
// client timeout, but keep it below the relay's ~240s ingress cap — the CLI
|
|
@@ -1155,7 +1263,26 @@ export async function generateImageForBrand(
|
|
|
1155
1263
|
// when several environments are paired this fans out and tags each engine with
|
|
1156
1264
|
// its short `env` label (never the backendURL — the no-URL rule). Filter by
|
|
1157
1265
|
// engine `type` (collection|search) and/or `subtype` (image|slide|template|
|
|
1158
|
-
// content). Returns a flat `{ engines: [...] }` like the single-backend
|
|
1266
|
+
// content). Returns a flat `{ engines: [...], catalog }` like the single-backend
|
|
1267
|
+
// route.
|
|
1268
|
+
//
|
|
1269
|
+
// `catalog` is the pane-free route's summary — `{totalEngines, totalScopes,
|
|
1270
|
+
// filtered, emptyEngines}`, counted BEFORE the type/subtype filter — and it is
|
|
1271
|
+
// what separates "this filter matched nothing" from "this deployment has no
|
|
1272
|
+
// engines". Older backends (and the pane-relay path) send none; it stays null
|
|
1273
|
+
// there and every caller falls back to its pre-summary wording.
|
|
1274
|
+
function readCatalogSummary(data) {
|
|
1275
|
+
const c = data?.catalog;
|
|
1276
|
+
if (!c || typeof c !== "object" || Array.isArray(c)) return null;
|
|
1277
|
+
const num = (v) => (Number.isFinite(v) ? v : 0);
|
|
1278
|
+
return {
|
|
1279
|
+
totalEngines: num(c.totalEngines),
|
|
1280
|
+
totalScopes: num(c.totalScopes),
|
|
1281
|
+
filtered: Boolean(c.filtered),
|
|
1282
|
+
emptyEngines: num(c.emptyEngines),
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1159
1286
|
async function listEnginesForBackend(backendURL, { type = null, subtype = null } = {}) {
|
|
1160
1287
|
return withTransientRetry(async () => {
|
|
1161
1288
|
const params = new URLSearchParams();
|
|
@@ -1172,22 +1299,60 @@ async function listEnginesForBackend(backendURL, { type = null, subtype = null }
|
|
|
1172
1299
|
const data = await res.json();
|
|
1173
1300
|
const arr = Array.isArray(data?.engines) ? data.engines : [];
|
|
1174
1301
|
const env = deriveEnvLabel(backendURL);
|
|
1175
|
-
return
|
|
1302
|
+
return {
|
|
1303
|
+
engines: arr.map((e) => ({ ...e, env })),
|
|
1304
|
+
catalog: readCatalogSummary(data),
|
|
1305
|
+
};
|
|
1176
1306
|
});
|
|
1177
1307
|
}
|
|
1178
1308
|
|
|
1309
|
+
// A scope (scope:all / scope:sharepoint) is not one environment's object: it is
|
|
1310
|
+
// the same saved search target, named identically on every deployment. Pooling
|
|
1311
|
+
// two backends' catalogs therefore repeats it verbatim — same id, same label —
|
|
1312
|
+
// which reads as two different things to choose between. Real engines DO differ
|
|
1313
|
+
// per environment and keep their duplicates; their `env` tag tells them apart.
|
|
1314
|
+
function isScopeRow(e) {
|
|
1315
|
+
return e?.type === "scope" || /^scope:/.test(String(e?.id ?? ""));
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1179
1318
|
export async function listEngines({ type = null, subtype = null, backendURL = null } = {}) {
|
|
1180
1319
|
const sessions = backendURL ? [sessionForBackend(backendURL)] : sessionsInScope();
|
|
1181
1320
|
const settled = await Promise.allSettled(
|
|
1182
1321
|
sessions.map((s) => listEnginesForBackend(s.backendURL, { type, subtype })),
|
|
1183
1322
|
);
|
|
1184
1323
|
const engines = [];
|
|
1324
|
+
const catalogs = [];
|
|
1185
1325
|
const failures = [];
|
|
1326
|
+
// Which environments actually contributed. NOT derivable from the pooled rows
|
|
1327
|
+
// any more: a second environment that only repeated the scopes leaves no row
|
|
1328
|
+
// of its own, and the human renderer keys its `[env]` tags off this.
|
|
1329
|
+
const envs = [];
|
|
1330
|
+
// Scope id → the one pooled row kept for it.
|
|
1331
|
+
const scopeRows = new Map();
|
|
1186
1332
|
let fulfilledCount = 0;
|
|
1187
1333
|
settled.forEach((r, i) => {
|
|
1188
1334
|
if (r.status === "fulfilled") {
|
|
1189
1335
|
fulfilledCount += 1;
|
|
1190
|
-
|
|
1336
|
+
envs.push(deriveEnvLabel(sessions[i].backendURL));
|
|
1337
|
+
for (const e of r.value.engines) {
|
|
1338
|
+
const id = typeof e?.id === "string" ? e.id : null;
|
|
1339
|
+
if (id && isScopeRow(e)) {
|
|
1340
|
+
const kept = scopeRows.get(id);
|
|
1341
|
+
if (kept) {
|
|
1342
|
+
// Contributed by more than one environment, so it belongs to none
|
|
1343
|
+
// of them in particular — drop the tag rather than crediting
|
|
1344
|
+
// whichever backend happened to answer first.
|
|
1345
|
+
kept.env = null;
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
const row = { ...e };
|
|
1349
|
+
scopeRows.set(id, row);
|
|
1350
|
+
engines.push(row);
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1353
|
+
engines.push(e);
|
|
1354
|
+
}
|
|
1355
|
+
if (r.value.catalog) catalogs.push(r.value.catalog);
|
|
1191
1356
|
} else {
|
|
1192
1357
|
failures.push({ env: deriveEnvLabel(sessions[i].backendURL), err: r.reason });
|
|
1193
1358
|
}
|
|
@@ -1200,7 +1365,24 @@ export async function listEngines({ type = null, subtype = null, backendURL = nu
|
|
|
1200
1365
|
failures.map((f) => ` ${f.env}: ${f.err.message}`).join("\n"),
|
|
1201
1366
|
);
|
|
1202
1367
|
}
|
|
1203
|
-
|
|
1368
|
+
// Fanned out, the summary is the sum of what each environment reported: the
|
|
1369
|
+
// engines are pooled into one list, so their counts pool too. SCOPES are the
|
|
1370
|
+
// exception — the same named targets everywhere, deduped above — so summing
|
|
1371
|
+
// them double-counts; the distinct total is the largest environment's count,
|
|
1372
|
+
// or the number of distinct scope rows actually pooled when that is larger.
|
|
1373
|
+
// (The catalog counts are pre-filter and the rows post-filter, which is why
|
|
1374
|
+
// both terms are needed.) `filtered` is the same question asked of every
|
|
1375
|
+
// backend, so any one of them saying yes is the answer. Null when no backend
|
|
1376
|
+
// supplied one (old add-in / pane relay).
|
|
1377
|
+
const catalog = catalogs.length
|
|
1378
|
+
? {
|
|
1379
|
+
totalEngines: catalogs.reduce((n, c) => n + c.totalEngines, 0),
|
|
1380
|
+
totalScopes: Math.max(...catalogs.map((c) => c.totalScopes), scopeRows.size),
|
|
1381
|
+
filtered: catalogs.some((c) => c.filtered),
|
|
1382
|
+
emptyEngines: catalogs.reduce((n, c) => n + c.emptyEngines, 0),
|
|
1383
|
+
}
|
|
1384
|
+
: null;
|
|
1385
|
+
return { engines, catalog, envs };
|
|
1204
1386
|
}
|
|
1205
1387
|
|
|
1206
1388
|
// ---------------------------------------------------------------------------
|
|
@@ -1229,7 +1411,12 @@ function sleep(ms) {
|
|
|
1229
1411
|
// mixed versions each get their own answer.
|
|
1230
1412
|
const backendCapabilitiesCache = new Map();
|
|
1231
1413
|
|
|
1232
|
-
|
|
1414
|
+
// The probe result, failure included. Every ordinary caller wants the graceful
|
|
1415
|
+
// `{}` (an older backend simply 404s this path), but a caller that is DIAGNOSING
|
|
1416
|
+
// has to see a 401 — "this session is revoked" must never be reported as "this
|
|
1417
|
+
// add-in is too old". Kept as one cached promise so the distinction costs no
|
|
1418
|
+
// extra round-trip.
|
|
1419
|
+
function backendCapabilitiesResult(backendURL) {
|
|
1233
1420
|
let probe = backendCapabilitiesCache.get(backendURL);
|
|
1234
1421
|
if (!probe) {
|
|
1235
1422
|
probe = (async () => {
|
|
@@ -1239,11 +1426,11 @@ function backendCapabilities(backendURL) {
|
|
|
1239
1426
|
{ timeoutMs: listTimeoutMs() },
|
|
1240
1427
|
backendURL,
|
|
1241
1428
|
);
|
|
1242
|
-
return (await res.json()) ?? {};
|
|
1243
|
-
} catch {
|
|
1429
|
+
return { caps: (await res.json()) ?? {}, error: null };
|
|
1430
|
+
} catch (err) {
|
|
1244
1431
|
// Older backends 404 this path (with a non-JSON body) — no
|
|
1245
1432
|
// capabilities: sync render, no version floor.
|
|
1246
|
-
return {};
|
|
1433
|
+
return { caps: {}, error: err };
|
|
1247
1434
|
}
|
|
1248
1435
|
})();
|
|
1249
1436
|
backendCapabilitiesCache.set(backendURL, probe);
|
|
@@ -1251,6 +1438,10 @@ function backendCapabilities(backendURL) {
|
|
|
1251
1438
|
return probe;
|
|
1252
1439
|
}
|
|
1253
1440
|
|
|
1441
|
+
async function backendCapabilities(backendURL) {
|
|
1442
|
+
return (await backendCapabilitiesResult(backendURL)).caps;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1254
1445
|
async function backendSupportsRenderJobs(backendURL) {
|
|
1255
1446
|
const caps = await backendCapabilities(backendURL);
|
|
1256
1447
|
return caps?.renderJobs === true;
|
|
@@ -1283,6 +1474,35 @@ export async function backendSupportsImageGeneration(backendURL) {
|
|
|
1283
1474
|
return caps?.imageGeneration === true;
|
|
1284
1475
|
}
|
|
1285
1476
|
|
|
1477
|
+
// Whether a backend can list the corporate designs a user may build against
|
|
1478
|
+
// (`GET /api/cli/brands`). It is what lets `ready` bind a deck with PowerPoint
|
|
1479
|
+
// closed — without it the only way to learn a design's identity is an open
|
|
1480
|
+
// window. Advertised by newer add-ins only.
|
|
1481
|
+
export async function backendSupportsBrandsList(backendURL) {
|
|
1482
|
+
const caps = await backendCapabilities(backendURL);
|
|
1483
|
+
return caps?.brandsList === true;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// Whether a backend can read the SOURCE deck behind a chosen slide search
|
|
1487
|
+
// result (`/api/cli/presentations/:presId/source-presentation`). Advertised by
|
|
1488
|
+
// newer add-ins only; older ones omit the flag and the caller must say so
|
|
1489
|
+
// rather than surfacing a bare 404.
|
|
1490
|
+
export async function backendSupportsSourcePresentation(backendURL) {
|
|
1491
|
+
const caps = await backendCapabilities(backendURL);
|
|
1492
|
+
return caps?.sourcePresentation === true;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// Pane-free ENGINE routes (`/api/cli/engines`, `/api/cli/searches/*`): engine
|
|
1496
|
+
// discovery, search and every per-result fetch answered off the CLI session
|
|
1497
|
+
// itself instead of being relayed through the task-pane webview — the one
|
|
1498
|
+
// transport that a backgrounded PowerPoint is free to suspend mid-run.
|
|
1499
|
+
// Advertised by newer add-ins only; older ones keep the pane relay, which stays
|
|
1500
|
+
// as the compat path (see the direct-route block further down).
|
|
1501
|
+
export async function backendSupportsEngineRoutes(backendURL) {
|
|
1502
|
+
const caps = await backendCapabilities(backendURL);
|
|
1503
|
+
return caps?.engineRoutes === true;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1286
1506
|
// The add-in's hard version floor: /api/cli/capabilities advertises the oldest
|
|
1287
1507
|
// CLI it still speaks (`minCliVersion`). An older CLI must refuse to proceed —
|
|
1288
1508
|
// mid-rollout skew otherwise surfaces as confusing route/contract errors deep
|
|
@@ -1573,6 +1793,22 @@ export async function translateDeck({
|
|
|
1573
1793
|
return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
|
|
1574
1794
|
}
|
|
1575
1795
|
|
|
1796
|
+
// A 404 the RELAY emitted about the presId itself (the window is no longer in
|
|
1797
|
+
// the registry — a pane reconnect mints a new presId for the same window)
|
|
1798
|
+
// rather than one a ROUTE emitted about something else it was asked for (a
|
|
1799
|
+
// stale `unknown_search`, an unimplemented route). Only the former is worth
|
|
1800
|
+
// re-targeting: the same window is probably back under a new id.
|
|
1801
|
+
//
|
|
1802
|
+
// Read the PARSED code, never `err.code`: bridgeFetch falls that back to the
|
|
1803
|
+
// human error string, and the relay's vanished-presId 404 carries prose only
|
|
1804
|
+
// ("No PowerPoint window registered for presId='X'") — so testing `err.code`
|
|
1805
|
+
// saw a truthy, unmatched value and never retargeted anything.
|
|
1806
|
+
function isMissingPresentationError(err) {
|
|
1807
|
+
if (err?.status !== 404) return false;
|
|
1808
|
+
const code = err?.body?.code ?? null;
|
|
1809
|
+
return !code || code === "unknown_presentation" || code === "presentation_not_found";
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1576
1812
|
// Run a pane-relayed engine op; on `pane_suspended` (the server already waited
|
|
1577
1813
|
// ~25s for a reconnect) OR `pane_timeout` (the pane looked connected but never
|
|
1578
1814
|
// acked within the server's 60s window — the server marks it suspended on the
|
|
@@ -1581,22 +1817,282 @@ export async function translateDeck({
|
|
|
1581
1817
|
// presId churn too). Engine ops are idempotent reads, and the one mutation
|
|
1582
1818
|
// (live insert) rides the SAME operationId through the retry so the pane-side
|
|
1583
1819
|
// dedup makes a re-send safe.
|
|
1584
|
-
|
|
1820
|
+
//
|
|
1821
|
+
// `fileName`/`brandId` identify the WINDOW so the wait can re-match it after a
|
|
1822
|
+
// presId churn (see waitForPaneReady). `retargetOnMissing` additionally treats a
|
|
1823
|
+
// vanished presId as recoverable — opt-in, because for most engine ops a 404 is
|
|
1824
|
+
// the search record talking, not the window: it re-lists the registry, re-matches
|
|
1825
|
+
// by presFP and retries ONCE on a short budget before the caller surfaces the
|
|
1826
|
+
// wake-PowerPoint blocker.
|
|
1827
|
+
async function engineOpWithPaneWait(
|
|
1828
|
+
presId,
|
|
1829
|
+
backendURL,
|
|
1830
|
+
attempt,
|
|
1831
|
+
{
|
|
1832
|
+
fileName = null,
|
|
1833
|
+
brandId = null,
|
|
1834
|
+
otherPresIds = null,
|
|
1835
|
+
retargetOnMissing = false,
|
|
1836
|
+
} = {},
|
|
1837
|
+
) {
|
|
1585
1838
|
try {
|
|
1586
1839
|
return await attempt(presId, backendURL);
|
|
1587
1840
|
} catch (err) {
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1841
|
+
const suspended =
|
|
1842
|
+
err?.code === "pane_suspended" || err?.code === "pane_timeout";
|
|
1843
|
+
const churned = retargetOnMissing && isMissingPresentationError(err);
|
|
1844
|
+
if (!suspended && !churned) throw err;
|
|
1591
1845
|
const live = await waitForPaneReady(presId, {
|
|
1592
1846
|
backendURL,
|
|
1593
|
-
|
|
1847
|
+
fileName,
|
|
1848
|
+
brandId,
|
|
1849
|
+
otherPresIds,
|
|
1850
|
+
// A churned presId needs a re-list, not a user: the window is already
|
|
1851
|
+
// there under a new id, so poll briefly instead of holding the full
|
|
1852
|
+
// click-the-pane budget. A suspended pane keeps that budget.
|
|
1853
|
+
timeoutMs: churned && !suspended ? envInt("SLIDEMIND_BRIDGE_PANE_RETARGET_TIMEOUT_MS", 15000) : null,
|
|
1854
|
+
onWait: churned && !suspended ? paneRetargetNotice : paneWaitNotice,
|
|
1594
1855
|
});
|
|
1595
1856
|
if (!live) throw err;
|
|
1596
1857
|
return attempt(live.presId, live.backendURL ?? backendURL);
|
|
1597
1858
|
}
|
|
1598
1859
|
}
|
|
1599
1860
|
|
|
1861
|
+
// ---------------------------------------------------------------------------
|
|
1862
|
+
// Pane-free engine routes (the DIRECT path)
|
|
1863
|
+
// ---------------------------------------------------------------------------
|
|
1864
|
+
//
|
|
1865
|
+
// Newer add-ins serve the whole engine surface off the CLI session:
|
|
1866
|
+
// GET /api/cli/engines catalog (same path,
|
|
1867
|
+
// answered pane-free)
|
|
1868
|
+
// POST /api/cli/searches → { searchId, results }
|
|
1869
|
+
// GET /api/cli/searches/:id/results/:sel/image stream
|
|
1870
|
+
// GET /api/cli/searches/:id/results/:sel/thumbnail stream
|
|
1871
|
+
// GET /api/cli/searches/:id/results/:sel/slide stream (one-slide .pptx)
|
|
1872
|
+
// GET /api/cli/searches/:id/results/:sel/source-presentation
|
|
1873
|
+
//
|
|
1874
|
+
// The searchId is SERVER-owned (per user, short TTL) rather than bound to a
|
|
1875
|
+
// window's pane, and a request carries only that searchId plus the selection
|
|
1876
|
+
// index — the server resolves the source config it stored at search time, so no
|
|
1877
|
+
// client ever names a file, site or drive.
|
|
1878
|
+
//
|
|
1879
|
+
// How a function picks its path: a `presId` means "relay through THAT window"
|
|
1880
|
+
// (the compat path old add-ins need and live insert always needs); NO presId
|
|
1881
|
+
// plus an explicit `backendURL`/`brandId` means "run this pane-free". Callers
|
|
1882
|
+
// resolve the backend once with resolveEngineBackend(), which returns null when
|
|
1883
|
+
// nothing paired advertises the routes — that null is the signal to fall back
|
|
1884
|
+
// to the pane relay, never a failure.
|
|
1885
|
+
|
|
1886
|
+
function searchRoute(searchId, suffix = "") {
|
|
1887
|
+
return `/api/cli/searches/${encodeURIComponent(String(searchId))}${suffix}`;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// A searchId is a number on the pane relay (the pane mints it per window) and an
|
|
1891
|
+
// opaque server-minted id on the direct routes — accept either, reject anything
|
|
1892
|
+
// else before a call goes near the network.
|
|
1893
|
+
function assertSearchId(fn, searchId) {
|
|
1894
|
+
const ok =
|
|
1895
|
+
(typeof searchId === "number" && Number.isFinite(searchId)) ||
|
|
1896
|
+
(typeof searchId === "string" && searchId.trim().length > 0);
|
|
1897
|
+
if (!ok) throw new Error(`${fn}: searchId is required`);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// A large asset legitimately takes longer than an engine op: the server is
|
|
1901
|
+
// fetching from SharePoint/blob AND piping. Separate budget so raising it
|
|
1902
|
+
// doesn't loosen every other bridge call.
|
|
1903
|
+
function downloadTimeoutMs() {
|
|
1904
|
+
return (
|
|
1905
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000", 10) ||
|
|
1906
|
+
600000
|
|
1907
|
+
);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
function resultRoute(searchId, selection, leaf, params = null) {
|
|
1911
|
+
const qs = params && [...params.keys()].length > 0 ? `?${params}` : "";
|
|
1912
|
+
return `${searchRoute(searchId, `/results/${encodeURIComponent(String(selection))}/${leaf}`)}${qs}`;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
// Per-process memo of which backend serves a brand's engine routes, so a
|
|
1916
|
+
// multi-environment session pays the brand probe at most once per design.
|
|
1917
|
+
const engineBackendForBrand = new Map();
|
|
1918
|
+
|
|
1919
|
+
// A searchId is opaque (server-minted, hex) on the direct routes and numeric on
|
|
1920
|
+
// the pane relay. The distinction routes: an opaque id names a record on the
|
|
1921
|
+
// backend that minted it, and the pane can NEVER serve it — its handler only
|
|
1922
|
+
// understands its own per-window numeric ids.
|
|
1923
|
+
export function isOpaqueSearchId(searchId) {
|
|
1924
|
+
return (
|
|
1925
|
+
typeof searchId === "string" &&
|
|
1926
|
+
searchId.trim() !== "" &&
|
|
1927
|
+
!/^\d+$/.test(searchId.trim())
|
|
1928
|
+
);
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// Which paired backend minted this server-owned search record. Search records
|
|
1932
|
+
// are oid-scoped, so a foreign environment answers the same 404 as an expired
|
|
1933
|
+
// id — probing every engine-routes candidate with the record's own GET is both
|
|
1934
|
+
// exact and cheap (2h TTL, at most a couple of environments). Returns null when
|
|
1935
|
+
// no candidate holds it: expired, or minted by an environment no longer in
|
|
1936
|
+
// scope. Never falls back to the pane — see isOpaqueSearchId.
|
|
1937
|
+
//
|
|
1938
|
+
// One failure class is NOT routing information: a 401 means the session itself
|
|
1939
|
+
// is dead (revoked in the add-in's Connect CLI panel, or aged past its hard
|
|
1940
|
+
// lifetime). Swallowing it reports a revoked session as "that search expired",
|
|
1941
|
+
// and the user then re-runs a search that 401s the same way. So per-candidate
|
|
1942
|
+
// failures are collected and a session-invalid one is rethrown instead.
|
|
1943
|
+
export async function resolveSearchBackend(searchId) {
|
|
1944
|
+
if (!isOpaqueSearchId(searchId)) return null;
|
|
1945
|
+
const failures = [];
|
|
1946
|
+
for (const s of sessionsInScope()) {
|
|
1947
|
+
// A revoked session 401s the capability probe too, and that answer is
|
|
1948
|
+
// indistinguishable from "old add-in" unless the failure is kept.
|
|
1949
|
+
const probe = await backendCapabilitiesResult(s.backendURL);
|
|
1950
|
+
if (probe.error && isSessionInvalidError(probe.error)) {
|
|
1951
|
+
failures.push(probe.error);
|
|
1952
|
+
continue;
|
|
1953
|
+
}
|
|
1954
|
+
if (probe.caps?.engineRoutes !== true) continue;
|
|
1955
|
+
try {
|
|
1956
|
+
await bridgeFetch(searchRoute(searchId), {}, s.backendURL);
|
|
1957
|
+
return s.backendURL;
|
|
1958
|
+
} catch (err) {
|
|
1959
|
+
// 404 (not this environment's record / expired) or unreachable — try the
|
|
1960
|
+
// next candidate; the distinction doesn't matter to routing.
|
|
1961
|
+
failures.push(err);
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
// The two failures that are NOT the record talking: a dead session, and a
|
|
1965
|
+
// permission snapshot that has aged out (the record re-read is an engine
|
|
1966
|
+
// route, so it degrades with the rest of corpus search). Reported as "that
|
|
1967
|
+
// search expired", both send the user to re-run a search that fails the same
|
|
1968
|
+
// way — so they outrank the expired-record answer.
|
|
1969
|
+
const blocking = failures.find(
|
|
1970
|
+
(e) => isSessionInvalidError(e) || e?.code === "permissions_stale",
|
|
1971
|
+
);
|
|
1972
|
+
if (blocking) throw blocking;
|
|
1973
|
+
return null;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// Which paired backend runs the pane-free engine routes for this call. An
|
|
1977
|
+
// explicit backendURL is honoured as-is (the caller already resolved a window or
|
|
1978
|
+
// a deck). Otherwise: every session in scope that advertises the routes is a
|
|
1979
|
+
// candidate — with ONE connected environment (the normal case) that IS the
|
|
1980
|
+
// answer, and no probe is worth paying for it. With several connected, the
|
|
1981
|
+
// DECK's bound design decides, never an open window's origin. presFP/brandId is
|
|
1982
|
+
// content-derived, so the SAME design onboarded in two environments carries the
|
|
1983
|
+
// SAME id — candidates are therefore probed FIRST, and only when none serves
|
|
1984
|
+
// the design does the caller fall back to the pane relay (the design lives on
|
|
1985
|
+
// an older add-in, and searching a different corpus would fold ITS slides into
|
|
1986
|
+
// this deck). Returns null for that fallback and when nothing paired serves the
|
|
1987
|
+
// routes at all.
|
|
1988
|
+
export async function resolveEngineBackend({ backendURL = null, brandId = null } = {}) {
|
|
1989
|
+
if (backendURL) {
|
|
1990
|
+
return (await backendSupportsEngineRoutes(backendURL)) ? backendURL : null;
|
|
1991
|
+
}
|
|
1992
|
+
const sessions = sessionsInScope();
|
|
1993
|
+
const candidates = [];
|
|
1994
|
+
for (const s of sessions) {
|
|
1995
|
+
if (await backendSupportsEngineRoutes(s.backendURL)) candidates.push(s.backendURL);
|
|
1996
|
+
}
|
|
1997
|
+
if (candidates.length === 0) return null;
|
|
1998
|
+
// Exactly one CONNECTED environment: there is nothing else the deck could
|
|
1999
|
+
// belong to. Note the test is on the sessions, not on the candidates — during
|
|
2000
|
+
// a rollout only one of several environments serves the routes, and taking it
|
|
2001
|
+
// regardless would have searched the wrong corpus.
|
|
2002
|
+
if (sessions.length === 1) return candidates[0];
|
|
2003
|
+
if (brandId) {
|
|
2004
|
+
// The memo only stands while that environment is still a candidate.
|
|
2005
|
+
const memo = engineBackendForBrand.get(brandId);
|
|
2006
|
+
if (memo) return candidates.includes(memo) ? memo : null;
|
|
2007
|
+
// Candidates first: an environment that serves both the design AND the
|
|
2008
|
+
// engine routes wins outright, even when an older environment also has the
|
|
2009
|
+
// (content-identical) design.
|
|
2010
|
+
for (const be of candidates) {
|
|
2011
|
+
try {
|
|
2012
|
+
await getDesignBundleForBrand(brandId, { backendURL: be });
|
|
2013
|
+
engineBackendForBrand.set(brandId, be);
|
|
2014
|
+
return be;
|
|
2015
|
+
} catch {
|
|
2016
|
+
// This candidate doesn't serve the design — try the next.
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
try {
|
|
2020
|
+
const { backendURL: be } = await getDesignBundleForBrandAnyBackend(brandId);
|
|
2021
|
+
engineBackendForBrand.set(brandId, be);
|
|
2022
|
+
// Only a non-candidate can answer here: the design's environment is an
|
|
2023
|
+
// older add-in with no engine routes. null sends the caller to that
|
|
2024
|
+
// environment's pane relay instead of a different corpus.
|
|
2025
|
+
return null;
|
|
2026
|
+
} catch {
|
|
2027
|
+
// No backend owns the design (unknown brand, old add-in) — fall through
|
|
2028
|
+
// rather than guessing an environment.
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
if (candidates.length === 1) return candidates[0];
|
|
2032
|
+
const err = new Error(
|
|
2033
|
+
`Several connected environments run engine search (${candidates
|
|
2034
|
+
.map((u) => deriveEnvLabel(u))
|
|
2035
|
+
.join(", ")}). Scope the command with --backend <env>, point it at a ` +
|
|
2036
|
+
"bound deck (--deck) so the deck's design picks one, or name an open " +
|
|
2037
|
+
"window with --to <presId> to relay through it.",
|
|
2038
|
+
);
|
|
2039
|
+
err.code = "engine_backend_ambiguous";
|
|
2040
|
+
throw err;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
// Which connected environments answer the pane-free engine routes — the
|
|
2044
|
+
// "backend authoring ready" half of the readiness picture (`doctor`, `ready`),
|
|
2045
|
+
// reported as ENV LABELS so no URL can reach a printed line. Pass
|
|
2046
|
+
// connectionStatus()'s `backends` to skip environments that are already known
|
|
2047
|
+
// unreachable; without it every session in scope is probed.
|
|
2048
|
+
export async function engineReachability({ backends = null } = {}) {
|
|
2049
|
+
const urls = Array.isArray(backends)
|
|
2050
|
+
? backends.filter((b) => b?.ok && b.backendURL).map((b) => b.backendURL)
|
|
2051
|
+
: sessionsInScope().map((s) => s.backendURL);
|
|
2052
|
+
const envs = [];
|
|
2053
|
+
for (const url of urls) {
|
|
2054
|
+
if (await backendSupportsEngineRoutes(url)) envs.push(deriveEnvLabel(url));
|
|
2055
|
+
}
|
|
2056
|
+
return { available: envs.length > 0, envs };
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// Resolve + verify the backend for a direct engine call. Throws a typed
|
|
2060
|
+
// `engine_routes_unavailable` when the caller asked for the pane-free path on a
|
|
2061
|
+
// backend that doesn't serve it — a programming error by then, since callers go
|
|
2062
|
+
// through resolveEngineBackend first, but it must never surface as a bare 404.
|
|
2063
|
+
async function directEngineBackend(fn, { backendURL = null, brandId = null }) {
|
|
2064
|
+
const be = backendURL ?? (await resolveEngineBackend({ brandId }));
|
|
2065
|
+
if (be && (await backendSupportsEngineRoutes(be))) return be;
|
|
2066
|
+
const err = new Error(
|
|
2067
|
+
`${fn}: no connected slidemind add-in serves pane-free engine routes` +
|
|
2068
|
+
`${be ? ` ('${deriveEnvLabel(be)}' is too old)` : ""}. ` +
|
|
2069
|
+
"Open PowerPoint with the slidemind pane so the search can be relayed, or update the add-in.",
|
|
2070
|
+
);
|
|
2071
|
+
err.code = "engine_routes_unavailable";
|
|
2072
|
+
throw err;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
// The permission SNAPSHOT behind corpus search is refreshed whenever the add-in
|
|
2076
|
+
// runs; when it ages out the engine routes answer
|
|
2077
|
+
// `403 { error: "permissions_stale", action: "…" }` and only corpus search
|
|
2078
|
+
// degrades (render/bundle never do). Shape it like the connection ladder's rungs
|
|
2079
|
+
// — `{ code, actor, message }` — so the CLI can print ONE actionable line and an
|
|
2080
|
+
// agent knows it cannot clear this one itself. Returns null for any other error.
|
|
2081
|
+
export function permissionsStaleBlocker(err) {
|
|
2082
|
+
if (err?.code !== "permissions_stale") return null;
|
|
2083
|
+
const action =
|
|
2084
|
+
typeof err?.body?.action === "string" && err.body.action.trim()
|
|
2085
|
+
? err.body.action.trim()
|
|
2086
|
+
: "open the slidemind add-in once to refresh access";
|
|
2087
|
+
return {
|
|
2088
|
+
code: "permissions_stale",
|
|
2089
|
+
actor: "user",
|
|
2090
|
+
message:
|
|
2091
|
+
`slidemind needs your search permissions refreshed before it can search the corpus — ${action}. ` +
|
|
2092
|
+
"Everything else (render, bundle, imagery already downloaded) keeps working.",
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
|
|
1600
2096
|
// Brand-engine search through the local bridge. New add-ins run this
|
|
1601
2097
|
// server-side with the relayed user token; older add-ins may fall back to a
|
|
1602
2098
|
// task-pane proxy. Returns a searchId + numbered results (image thumbnails, or
|
|
@@ -1608,8 +2104,12 @@ async function engineOpWithPaneWait(presId, backendURL, attempt) {
|
|
|
1608
2104
|
// an unfiltered search over the whole corpus rather than one engine's saved
|
|
1609
2105
|
// folder filter. A scope has no subtype to derive its index from, so it needs an
|
|
1610
2106
|
// explicit `resultType`; the add-in rejects a scope search without one.
|
|
2107
|
+
//
|
|
2108
|
+
// With NO presId and a resolved `backendURL`/`brandId` this runs pane-free
|
|
2109
|
+
// against `POST /api/cli/searches` (same body) and tags the answer with the
|
|
2110
|
+
// backend that owns the searchId.
|
|
1611
2111
|
export async function engineSearch({
|
|
1612
|
-
presId,
|
|
2112
|
+
presId = null,
|
|
1613
2113
|
engineId,
|
|
1614
2114
|
query,
|
|
1615
2115
|
subtype = null,
|
|
@@ -1617,22 +2117,42 @@ export async function engineSearch({
|
|
|
1617
2117
|
intent = null,
|
|
1618
2118
|
top = null,
|
|
1619
2119
|
backendURL = null,
|
|
2120
|
+
brandId = null,
|
|
1620
2121
|
// When the chosen pane is suspended, wait CLIENT-side for THIS window to wake
|
|
1621
2122
|
// (the single-window default). engineSearchAnyPane sets this false for all but
|
|
1622
2123
|
// the last candidate so it can fail over to another live window instead.
|
|
1623
2124
|
waitOnSuspend = true,
|
|
1624
2125
|
timeoutMs = null,
|
|
1625
2126
|
}) {
|
|
1626
|
-
if (!presId) throw new Error("engineSearch: presId is required");
|
|
1627
2127
|
if (!engineId) throw new Error("engineSearch: engineId is required");
|
|
1628
2128
|
if (!query) throw new Error("engineSearch: query is required");
|
|
1629
|
-
const
|
|
2129
|
+
const direct = !presId && Boolean(backendURL || brandId);
|
|
2130
|
+
if (!presId && !direct) throw new Error("engineSearch: presId is required");
|
|
1630
2131
|
const body = { engineId, query };
|
|
1631
2132
|
if (subtype) body.subtype = subtype;
|
|
1632
2133
|
// Additive: omitted unless set, so the body an older add-in sees is unchanged.
|
|
1633
2134
|
if (resultType) body.resultType = resultType;
|
|
1634
2135
|
if (intent) body.intent = intent;
|
|
1635
2136
|
if (typeof top === "number") body.top = top;
|
|
2137
|
+
if (direct) {
|
|
2138
|
+
const be = await directEngineBackend("engineSearch", { backendURL, brandId });
|
|
2139
|
+
const res = await bridgeFetch(
|
|
2140
|
+
"/api/cli/searches",
|
|
2141
|
+
{
|
|
2142
|
+
method: "POST",
|
|
2143
|
+
headers: { "content-type": "application/json" },
|
|
2144
|
+
body: JSON.stringify(body),
|
|
2145
|
+
timeoutMs: timeoutMs ?? engineTimeoutMs(),
|
|
2146
|
+
},
|
|
2147
|
+
be,
|
|
2148
|
+
);
|
|
2149
|
+
const data = (await res.json()) ?? {};
|
|
2150
|
+
// Tag the routing state (like the brand routes do) so every follow-up pick
|
|
2151
|
+
// hits the backend that owns the searchId. `env` is the printable half —
|
|
2152
|
+
// callers must never surface backendURL.
|
|
2153
|
+
return { ...data, backendURL: be, env: deriveEnvLabel(be) };
|
|
2154
|
+
}
|
|
2155
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
1636
2156
|
const run = async (pid, b) => {
|
|
1637
2157
|
const res = await bridgeFetch(
|
|
1638
2158
|
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/search`,
|
|
@@ -1730,6 +2250,11 @@ export async function engineSearchAnyPane({
|
|
|
1730
2250
|
// Fetch a chosen engine result's bytes through the local bridge. For image
|
|
1731
2251
|
// engines this returns SVG/PNG bytes (+ optional recoloured svgMarkup) WITHOUT
|
|
1732
2252
|
// inserting onto a slide.
|
|
2253
|
+
//
|
|
2254
|
+
// Pane-only by design: the pane-free route table streams (see
|
|
2255
|
+
// engineDownloadToFile), and this base64-through-the-ack path only exists for
|
|
2256
|
+
// add-ins that predate streaming — which by definition predate the direct
|
|
2257
|
+
// routes too.
|
|
1733
2258
|
export async function engineDownload({
|
|
1734
2259
|
presId,
|
|
1735
2260
|
searchId,
|
|
@@ -1738,9 +2263,7 @@ export async function engineDownload({
|
|
|
1738
2263
|
backendURL = null,
|
|
1739
2264
|
}) {
|
|
1740
2265
|
if (!presId) throw new Error("engineDownload: presId is required");
|
|
1741
|
-
|
|
1742
|
-
throw new Error("engineDownload: searchId (number) is required");
|
|
1743
|
-
}
|
|
2266
|
+
assertSearchId("engineDownload", searchId);
|
|
1744
2267
|
if (typeof selection !== "number") {
|
|
1745
2268
|
throw new Error("engineDownload: selection (number) is required");
|
|
1746
2269
|
}
|
|
@@ -1777,46 +2300,32 @@ export async function engineDownload({
|
|
|
1777
2300
|
// byte stream: it is kilobytes and its recolour happens pane-side. Callers get
|
|
1778
2301
|
// `{ svgMarkup }` in that case and `{ bytesWritten, mimeType }` otherwise, so they
|
|
1779
2302
|
// can persist whichever arrived.
|
|
2303
|
+
//
|
|
2304
|
+
// With NO presId and a resolved `backendURL`/`brandId` this streams pane-free
|
|
2305
|
+
// from `GET /api/cli/searches/:searchId/results/:selection/image` — same answer
|
|
2306
|
+
// shape, no window involved.
|
|
1780
2307
|
export async function engineDownloadToFile({
|
|
1781
|
-
presId,
|
|
2308
|
+
presId = null,
|
|
1782
2309
|
searchId,
|
|
1783
2310
|
selection,
|
|
1784
2311
|
destPath,
|
|
1785
2312
|
iconColor = null,
|
|
1786
2313
|
backendURL = null,
|
|
2314
|
+
brandId = null,
|
|
1787
2315
|
onProgress = null,
|
|
1788
2316
|
}) {
|
|
1789
|
-
|
|
1790
|
-
if (
|
|
1791
|
-
throw new Error("engineDownloadToFile:
|
|
2317
|
+
const direct = !presId && Boolean(backendURL || brandId);
|
|
2318
|
+
if (!presId && !direct) {
|
|
2319
|
+
throw new Error("engineDownloadToFile: presId is required");
|
|
1792
2320
|
}
|
|
2321
|
+
assertSearchId("engineDownloadToFile", searchId);
|
|
1793
2322
|
if (typeof selection !== "number") {
|
|
1794
2323
|
throw new Error("engineDownloadToFile: selection (number) is required");
|
|
1795
2324
|
}
|
|
1796
2325
|
if (!destPath) throw new Error("engineDownloadToFile: destPath is required");
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
1801
|
-
const res = await bridgeStreamToFile(
|
|
1802
|
-
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/download-stream`,
|
|
1803
|
-
{
|
|
1804
|
-
method: "POST",
|
|
1805
|
-
headers: { "content-type": "application/json" },
|
|
1806
|
-
body: JSON.stringify(body),
|
|
1807
|
-
// A large image legitimately takes longer than an engine op: the server
|
|
1808
|
-
// is fetching from SharePoint/blob AND piping. Separate budget so raising
|
|
1809
|
-
// it doesn't loosen every other relay call.
|
|
1810
|
-
timeoutMs:
|
|
1811
|
-
parseInt(
|
|
1812
|
-
process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000",
|
|
1813
|
-
10,
|
|
1814
|
-
) || 600000,
|
|
1815
|
-
onProgress,
|
|
1816
|
-
},
|
|
1817
|
-
destPath,
|
|
1818
|
-
b,
|
|
1819
|
-
);
|
|
2326
|
+
// Both paths answer with the same two shapes (streamed bytes, or the inline
|
|
2327
|
+
// SVG as JSON), so the response shaping is authored once.
|
|
2328
|
+
const shape = (res) => {
|
|
1820
2329
|
if (!res.streamed) {
|
|
1821
2330
|
// The inline-SVG answer (kilobytes) arrives as JSON, not as a byte stream.
|
|
1822
2331
|
const parsed = res.body ? JSON.parse(res.body) : {};
|
|
@@ -1838,6 +2347,41 @@ export async function engineDownloadToFile({
|
|
|
1838
2347
|
bytesWritten: res.bytesWritten,
|
|
1839
2348
|
...(warnings ? { warnings } : {}),
|
|
1840
2349
|
};
|
|
2350
|
+
};
|
|
2351
|
+
if (direct) {
|
|
2352
|
+
const b = await directEngineBackend("engineDownloadToFile", {
|
|
2353
|
+
backendURL,
|
|
2354
|
+
brandId,
|
|
2355
|
+
});
|
|
2356
|
+
// Only the recolour rides the query string — the RESOURCE is never
|
|
2357
|
+
// client-supplied on this path; the server resolves it from searchId+selection.
|
|
2358
|
+
const params = new URLSearchParams();
|
|
2359
|
+
if (iconColor) params.set("iconColor", iconColor);
|
|
2360
|
+
const res = await bridgeStreamToFile(
|
|
2361
|
+
resultRoute(searchId, selection, "image", params),
|
|
2362
|
+
{ method: "GET", timeoutMs: downloadTimeoutMs(), onProgress },
|
|
2363
|
+
destPath,
|
|
2364
|
+
b,
|
|
2365
|
+
);
|
|
2366
|
+
return shape(res);
|
|
2367
|
+
}
|
|
2368
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
2369
|
+
const body = { searchId, selection };
|
|
2370
|
+
if (iconColor) body.iconColor = iconColor;
|
|
2371
|
+
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
2372
|
+
const res = await bridgeStreamToFile(
|
|
2373
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/download-stream`,
|
|
2374
|
+
{
|
|
2375
|
+
method: "POST",
|
|
2376
|
+
headers: { "content-type": "application/json" },
|
|
2377
|
+
body: JSON.stringify(body),
|
|
2378
|
+
timeoutMs: downloadTimeoutMs(),
|
|
2379
|
+
onProgress,
|
|
2380
|
+
},
|
|
2381
|
+
destPath,
|
|
2382
|
+
b,
|
|
2383
|
+
);
|
|
2384
|
+
return shape(res);
|
|
1841
2385
|
});
|
|
1842
2386
|
}
|
|
1843
2387
|
|
|
@@ -1846,22 +2390,47 @@ export async function engineDownloadToFile({
|
|
|
1846
2390
|
// request, and the add-in server performs it and pipes the (base64-decoded) bytes
|
|
1847
2391
|
// here. `extractSlide` puts the whole slide through the pane's Socket.IO ack as
|
|
1848
2392
|
// base64, so it is capped at ~7 MB of real bytes — which real reused slides reach.
|
|
2393
|
+
//
|
|
2394
|
+
// With NO presId and a resolved `backendURL`/`brandId` this streams pane-free
|
|
2395
|
+
// from `GET /api/cli/searches/:searchId/results/:selection/slide`.
|
|
1849
2396
|
export async function extractSlideToFile({
|
|
1850
|
-
presId,
|
|
2397
|
+
presId = null,
|
|
1851
2398
|
searchId,
|
|
1852
2399
|
selection,
|
|
1853
2400
|
destPath,
|
|
1854
2401
|
backendURL = null,
|
|
2402
|
+
brandId = null,
|
|
1855
2403
|
onProgress = null,
|
|
1856
2404
|
}) {
|
|
1857
|
-
|
|
1858
|
-
if (
|
|
1859
|
-
|
|
1860
|
-
}
|
|
2405
|
+
const direct = !presId && Boolean(backendURL || brandId);
|
|
2406
|
+
if (!presId && !direct) throw new Error("extractSlideToFile: presId is required");
|
|
2407
|
+
assertSearchId("extractSlideToFile", searchId);
|
|
1861
2408
|
if (typeof selection !== "number") {
|
|
1862
2409
|
throw new Error("extractSlideToFile: selection (number) is required");
|
|
1863
2410
|
}
|
|
1864
2411
|
if (!destPath) throw new Error("extractSlideToFile: destPath is required");
|
|
2412
|
+
const shape = (res) => {
|
|
2413
|
+
const title = res.headers?.["x-slidemind-presentation-title"];
|
|
2414
|
+
return {
|
|
2415
|
+
ok: true,
|
|
2416
|
+
bytesWritten: res.bytesWritten,
|
|
2417
|
+
slideId: res.headers?.["x-slidemind-slide-id"] ?? undefined,
|
|
2418
|
+
presentationTitle: title ? decodeURIComponent(String(title)) : undefined,
|
|
2419
|
+
};
|
|
2420
|
+
};
|
|
2421
|
+
if (direct) {
|
|
2422
|
+
const b = await directEngineBackend("extractSlideToFile", {
|
|
2423
|
+
backendURL,
|
|
2424
|
+
brandId,
|
|
2425
|
+
});
|
|
2426
|
+
const res = await bridgeStreamToFile(
|
|
2427
|
+
resultRoute(searchId, selection, "slide"),
|
|
2428
|
+
{ method: "GET", timeoutMs: downloadTimeoutMs(), onProgress },
|
|
2429
|
+
destPath,
|
|
2430
|
+
b,
|
|
2431
|
+
);
|
|
2432
|
+
return shape(res);
|
|
2433
|
+
}
|
|
1865
2434
|
const be = backendURL || (await resolveBackendForPres(presId));
|
|
1866
2435
|
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
1867
2436
|
const res = await bridgeStreamToFile(
|
|
@@ -1870,29 +2439,21 @@ export async function extractSlideToFile({
|
|
|
1870
2439
|
method: "POST",
|
|
1871
2440
|
headers: { "content-type": "application/json" },
|
|
1872
2441
|
body: JSON.stringify({ searchId, selection }),
|
|
1873
|
-
timeoutMs:
|
|
1874
|
-
parseInt(
|
|
1875
|
-
process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000",
|
|
1876
|
-
10,
|
|
1877
|
-
) || 600000,
|
|
2442
|
+
timeoutMs: downloadTimeoutMs(),
|
|
1878
2443
|
onProgress,
|
|
1879
2444
|
},
|
|
1880
2445
|
destPath,
|
|
1881
2446
|
b,
|
|
1882
2447
|
);
|
|
1883
|
-
|
|
1884
|
-
return {
|
|
1885
|
-
ok: true,
|
|
1886
|
-
bytesWritten: res.bytesWritten,
|
|
1887
|
-
slideId: res.headers?.["x-slidemind-slide-id"] ?? undefined,
|
|
1888
|
-
presentationTitle: title ? decodeURIComponent(String(title)) : undefined,
|
|
1889
|
-
};
|
|
2448
|
+
return shape(res);
|
|
1890
2449
|
});
|
|
1891
2450
|
}
|
|
1892
2451
|
|
|
1893
2452
|
// Extract a chosen slide-search result as a standalone one-slide .pptx (base64),
|
|
1894
2453
|
// WITHOUT inserting it anywhere — the "fold into a deck" path. Relayed through
|
|
1895
|
-
// the pane like search/download
|
|
2454
|
+
// the pane like search/download, and pane-only for the same reason as
|
|
2455
|
+
// engineDownload: the pane-free route streams (extractSlideToFile above), and
|
|
2456
|
+
// this capped ack path exists only for add-ins that predate it.
|
|
1896
2457
|
export async function extractSlide({
|
|
1897
2458
|
presId,
|
|
1898
2459
|
searchId,
|
|
@@ -1900,9 +2461,7 @@ export async function extractSlide({
|
|
|
1900
2461
|
backendURL = null,
|
|
1901
2462
|
}) {
|
|
1902
2463
|
if (!presId) throw new Error("extractSlide: presId is required");
|
|
1903
|
-
|
|
1904
|
-
throw new Error("extractSlide: searchId (number) is required");
|
|
1905
|
-
}
|
|
2464
|
+
assertSearchId("extractSlide", searchId);
|
|
1906
2465
|
if (typeof selection !== "number") {
|
|
1907
2466
|
throw new Error("extractSlide: selection (number) is required");
|
|
1908
2467
|
}
|
|
@@ -1926,17 +2485,23 @@ export async function extractSlide({
|
|
|
1926
2485
|
|
|
1927
2486
|
// Insert one or more chosen slide-search results live into the open
|
|
1928
2487
|
// presentation (the "reuse → insert live" path). Relayed through the pane.
|
|
2488
|
+
//
|
|
2489
|
+
// `fileName`/`brandId` identify the target WINDOW (brandId == the deck's presFP).
|
|
2490
|
+
// They cost nothing on the happy path and are what lets the single retry find
|
|
2491
|
+
// the same window again after a pane reconnect churned its presId — the same
|
|
2492
|
+
// self-healing `push` does around its insert.
|
|
1929
2493
|
export async function insertSlideLive({
|
|
1930
2494
|
presId,
|
|
1931
2495
|
searchId,
|
|
1932
2496
|
selections,
|
|
1933
2497
|
at = null,
|
|
1934
2498
|
backendURL = null,
|
|
2499
|
+
fileName = null,
|
|
2500
|
+
brandId = null,
|
|
2501
|
+
otherPresIds = null,
|
|
1935
2502
|
}) {
|
|
1936
2503
|
if (!presId) throw new Error("insertSlideLive: presId is required");
|
|
1937
|
-
|
|
1938
|
-
throw new Error("insertSlideLive: searchId (number) is required");
|
|
1939
|
-
}
|
|
2504
|
+
assertSearchId("insertSlideLive", searchId);
|
|
1940
2505
|
if (!Array.isArray(selections) || selections.length === 0) {
|
|
1941
2506
|
throw new Error("insertSlideLive: selections (number[]) is required");
|
|
1942
2507
|
}
|
|
@@ -1950,54 +2515,279 @@ export async function insertSlideLive({
|
|
|
1950
2515
|
const operationId = `insert-${randomUUID()}`;
|
|
1951
2516
|
const body = { searchId, selections, operationId };
|
|
1952
2517
|
if (typeof at === "number") body.at = at;
|
|
2518
|
+
return engineOpWithPaneWait(
|
|
2519
|
+
presId,
|
|
2520
|
+
be,
|
|
2521
|
+
async (pid, b) => {
|
|
2522
|
+
const res = await bridgeFetch(
|
|
2523
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-insert`,
|
|
2524
|
+
{
|
|
2525
|
+
method: "POST",
|
|
2526
|
+
headers: { "content-type": "application/json" },
|
|
2527
|
+
body: JSON.stringify(body),
|
|
2528
|
+
timeoutMs:
|
|
2529
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
2530
|
+
90000,
|
|
2531
|
+
},
|
|
2532
|
+
b,
|
|
2533
|
+
);
|
|
2534
|
+
// Tag the window the insert actually landed in: after a retarget it is no
|
|
2535
|
+
// longer the presId the caller passed, and the caller reports it.
|
|
2536
|
+
return { ...(await res.json()), presId: pid }; // { ok, insertedCount, insertedSlideIds, presId }
|
|
2537
|
+
},
|
|
2538
|
+
// A vanished presId is recoverable HERE (unlike the engine reads, whose
|
|
2539
|
+
// 404s are about the search record): the insert never landed, so re-listing
|
|
2540
|
+
// and retrying against the re-registered window is safe — and the same
|
|
2541
|
+
// operationId rides along, so even an ambiguous first attempt can't
|
|
2542
|
+
// double-insert.
|
|
2543
|
+
{ fileName, brandId, otherPresIds, retargetOnMissing: true },
|
|
2544
|
+
);
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
// Reuse chosen slide-search results live when the search record is the
|
|
2548
|
+
// BACKEND's rather than a pane's.
|
|
2549
|
+
//
|
|
2550
|
+
// The pane's slide-insert handler resolves a selection out of the in-browser
|
|
2551
|
+
// search context IT minted (a numeric id); a searchId from the pane-free search
|
|
2552
|
+
// is server-owned and opaque, so relaying it would answer `searchId_required`.
|
|
2553
|
+
// The two legs `add-slide --search` + `push` already run therefore do the job in
|
|
2554
|
+
// the other order: extract each pick pane-free, then hand the BYTES to the
|
|
2555
|
+
// window's own insert route. Live insert stays pane-bound (Office.js is the only
|
|
2556
|
+
// thing that can type into an open deck) — only the searchId resolution moves.
|
|
2557
|
+
export async function insertSlidesFromSearch({
|
|
2558
|
+
presId,
|
|
2559
|
+
searchId,
|
|
2560
|
+
selections,
|
|
2561
|
+
at = null,
|
|
2562
|
+
backendURL = null,
|
|
2563
|
+
engineBackendURL = null,
|
|
2564
|
+
fileName = null,
|
|
2565
|
+
brandId = null,
|
|
2566
|
+
otherPresIds = null,
|
|
2567
|
+
onProgress = null,
|
|
2568
|
+
}) {
|
|
2569
|
+
if (!presId) throw new Error("insertSlidesFromSearch: presId is required");
|
|
2570
|
+
assertSearchId("insertSlidesFromSearch", searchId);
|
|
2571
|
+
if (!Array.isArray(selections) || selections.length === 0) {
|
|
2572
|
+
throw new Error("insertSlidesFromSearch: selections (number[]) is required");
|
|
2573
|
+
}
|
|
2574
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
2575
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-reuse-"));
|
|
2576
|
+
try {
|
|
2577
|
+
let insertedCount = 0;
|
|
2578
|
+
let livePresId = presId;
|
|
2579
|
+
let liveBackendURL = be;
|
|
2580
|
+
const insertedSlideIds = [];
|
|
2581
|
+
for (const [i, selection] of selections.entries()) {
|
|
2582
|
+
const slidePath = path.join(dir, `pick-${selection}.pptx`);
|
|
2583
|
+
const extracted = await extractSlideToFile({
|
|
2584
|
+
searchId,
|
|
2585
|
+
selection,
|
|
2586
|
+
destPath: slidePath,
|
|
2587
|
+
backendURL: engineBackendURL,
|
|
2588
|
+
brandId,
|
|
2589
|
+
onProgress,
|
|
2590
|
+
});
|
|
2591
|
+
if (!extracted?.bytesWritten) {
|
|
2592
|
+
throw new Error(`selection ${selection} produced no slide bytes`);
|
|
2593
|
+
}
|
|
2594
|
+
// One insert per pick, each with its own dedup key, so a retry inside
|
|
2595
|
+
// engineOpWithPaneWait re-sends THAT pick and nothing else. `at` counts
|
|
2596
|
+
// forward so the picks land in the order they were named.
|
|
2597
|
+
const operationId = `insert-${randomUUID()}`;
|
|
2598
|
+
const base64 = fs.readFileSync(slidePath).toString("base64");
|
|
2599
|
+
const resp = await engineOpWithPaneWait(
|
|
2600
|
+
livePresId,
|
|
2601
|
+
liveBackendURL,
|
|
2602
|
+
async (pid, b) => {
|
|
2603
|
+
const ack = await insertSlides({
|
|
2604
|
+
presId: pid,
|
|
2605
|
+
base64,
|
|
2606
|
+
insertAt: typeof at === "number" ? at + i : null,
|
|
2607
|
+
sourceSlideIds: extracted.slideId ? [extracted.slideId] : null,
|
|
2608
|
+
operationId,
|
|
2609
|
+
backendURL: b,
|
|
2610
|
+
});
|
|
2611
|
+
return { ...ack, presId: pid, backendURL: b };
|
|
2612
|
+
},
|
|
2613
|
+
{ fileName, brandId, otherPresIds, retargetOnMissing: true },
|
|
2614
|
+
);
|
|
2615
|
+
// A retarget moves every remaining pick to the window that answered, so
|
|
2616
|
+
// a multi-pick reuse cannot end up split across two presentations.
|
|
2617
|
+
livePresId = resp.presId ?? livePresId;
|
|
2618
|
+
liveBackendURL = resp.backendURL ?? liveBackendURL;
|
|
2619
|
+
const landed = Array.isArray(resp.insertedSlideIds)
|
|
2620
|
+
? resp.insertedSlideIds
|
|
2621
|
+
: [];
|
|
2622
|
+
insertedCount += landed.length || 1;
|
|
2623
|
+
insertedSlideIds.push(...landed);
|
|
2624
|
+
fs.rmSync(slidePath, { force: true });
|
|
2625
|
+
}
|
|
2626
|
+
return { ok: true, insertedCount, insertedSlideIds, presId: livePresId };
|
|
2627
|
+
} finally {
|
|
2628
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
// Fetch a chosen slide-search result's thumbnail/preview as a data URI for
|
|
2633
|
+
// on-demand viewing (the agent doesn't have to extract the whole .pptx just to
|
|
2634
|
+
// look at a slide). Relayed through the pane.
|
|
2635
|
+
//
|
|
2636
|
+
// With NO presId and a resolved `backendURL`/`brandId` this reads the pane-free
|
|
2637
|
+
// `GET …/results/:selection/thumbnail`, which STREAMS: a thumbnail is small, so
|
|
2638
|
+
// it is buffered back into the same `{ dataUri }` contract rather than forcing
|
|
2639
|
+
// every caller to grow a file path.
|
|
2640
|
+
export async function viewSlide({
|
|
2641
|
+
presId = null,
|
|
2642
|
+
searchId,
|
|
2643
|
+
selection,
|
|
2644
|
+
backendURL = null,
|
|
2645
|
+
brandId = null,
|
|
2646
|
+
}) {
|
|
2647
|
+
const direct = !presId && Boolean(backendURL || brandId);
|
|
2648
|
+
if (!presId && !direct) throw new Error("viewSlide: presId is required");
|
|
2649
|
+
assertSearchId("viewSlide", searchId);
|
|
2650
|
+
if (typeof selection !== "number") {
|
|
2651
|
+
throw new Error("viewSlide: selection (number) is required");
|
|
2652
|
+
}
|
|
2653
|
+
if (direct) {
|
|
2654
|
+
const b = await directEngineBackend("viewSlide", { backendURL, brandId });
|
|
2655
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-thumb-"));
|
|
2656
|
+
const tmp = path.join(dir, "thumbnail.bin");
|
|
2657
|
+
try {
|
|
2658
|
+
const res = await bridgeStreamToFile(
|
|
2659
|
+
resultRoute(searchId, selection, "thumbnail"),
|
|
2660
|
+
{ method: "GET", timeoutMs: engineTimeoutMs() },
|
|
2661
|
+
tmp,
|
|
2662
|
+
b,
|
|
2663
|
+
);
|
|
2664
|
+
if (!res.streamed) {
|
|
2665
|
+
// An add-in that answers with an inline preview passes straight through.
|
|
2666
|
+
return res.body ? JSON.parse(res.body) : { ok: false };
|
|
2667
|
+
}
|
|
2668
|
+
const mimeType =
|
|
2669
|
+
String(res.headers?.["content-type"] ?? "").split(";")[0].trim() ||
|
|
2670
|
+
"image/png";
|
|
2671
|
+
return {
|
|
2672
|
+
ok: true,
|
|
2673
|
+
mimeType,
|
|
2674
|
+
dataUri: `data:${mimeType};base64,${fs.readFileSync(tmp).toString("base64")}`,
|
|
2675
|
+
};
|
|
2676
|
+
} finally {
|
|
2677
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
1953
2681
|
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
1954
2682
|
const res = await bridgeFetch(
|
|
1955
|
-
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-
|
|
2683
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-view`,
|
|
1956
2684
|
{
|
|
1957
2685
|
method: "POST",
|
|
1958
2686
|
headers: { "content-type": "application/json" },
|
|
1959
|
-
body: JSON.stringify(
|
|
2687
|
+
body: JSON.stringify({ searchId, selection }),
|
|
1960
2688
|
timeoutMs:
|
|
1961
2689
|
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
1962
2690
|
90000,
|
|
1963
2691
|
},
|
|
1964
2692
|
b,
|
|
1965
2693
|
);
|
|
1966
|
-
return res.json(); // { ok,
|
|
2694
|
+
return res.json(); // { ok, dataUri, mimeType }
|
|
1967
2695
|
});
|
|
1968
2696
|
}
|
|
1969
2697
|
|
|
1970
|
-
//
|
|
1971
|
-
//
|
|
1972
|
-
//
|
|
1973
|
-
|
|
1974
|
-
|
|
2698
|
+
// Read the ORIGINAL deck a chosen slide-search result came from — NOT the open
|
|
2699
|
+
// window (that is getPresentationContent below). Answers "what else is in the
|
|
2700
|
+
// presentation this slide belongs to?": the surrounding narrative, the slides
|
|
2701
|
+
// either side, the argument the source deck was making.
|
|
2702
|
+
//
|
|
2703
|
+
// The add-in reads it out of the slide index rather than downloading the source
|
|
2704
|
+
// file, so this is fast and works the same for blob- and SharePoint-backed
|
|
2705
|
+
// results — but the text is the last SYNCED extraction, not the live file. The
|
|
2706
|
+
// response is Markdown, and the pane never carries it: the relay route resolves
|
|
2707
|
+
// the selection through the pane and performs the backend read itself.
|
|
2708
|
+
//
|
|
2709
|
+
// searchId is presentation-scoped on the pane path, so that path relays through
|
|
2710
|
+
// the window the search ran on, like viewSlide. Idempotent, so the
|
|
2711
|
+
// pane_suspended retry is safe. With NO presId and a resolved
|
|
2712
|
+
// `backendURL`/`brandId` it reads the pane-free
|
|
2713
|
+
// `GET …/results/:selection/source-presentation` instead (the searchId is
|
|
2714
|
+
// server-owned there, so no window is involved at all).
|
|
2715
|
+
export async function readSourcePresentation({
|
|
2716
|
+
presId = null,
|
|
1975
2717
|
searchId,
|
|
1976
2718
|
selection,
|
|
2719
|
+
fromSlide = null,
|
|
2720
|
+
toSlide = null,
|
|
1977
2721
|
backendURL = null,
|
|
2722
|
+
brandId = null,
|
|
1978
2723
|
}) {
|
|
1979
|
-
|
|
1980
|
-
if (
|
|
1981
|
-
throw new Error("
|
|
2724
|
+
const direct = !presId && Boolean(backendURL || brandId);
|
|
2725
|
+
if (!presId && !direct) {
|
|
2726
|
+
throw new Error("readSourcePresentation: presId is required");
|
|
1982
2727
|
}
|
|
2728
|
+
assertSearchId("readSourcePresentation", searchId);
|
|
1983
2729
|
if (typeof selection !== "number") {
|
|
1984
|
-
throw new Error("
|
|
2730
|
+
throw new Error("readSourcePresentation: selection (number) is required");
|
|
2731
|
+
}
|
|
2732
|
+
// Both paths answer with Markdown plus the counts/truncation in headers.
|
|
2733
|
+
const shape = async (res) => ({
|
|
2734
|
+
ok: true,
|
|
2735
|
+
markdown: await res.text(),
|
|
2736
|
+
truncated: res.headers?.["x-slidemind-truncated"] === "1",
|
|
2737
|
+
slideCount: Number(res.headers?.["x-slidemind-slide-count"] ?? 0) || 0,
|
|
2738
|
+
totalSlideCount:
|
|
2739
|
+
Number(res.headers?.["x-slidemind-total-slide-count"] ?? 0) || 0,
|
|
2740
|
+
presentationTitle: res.headers?.["x-slidemind-presentation-title"]
|
|
2741
|
+
? decodeURIComponent(String(res.headers["x-slidemind-presentation-title"]))
|
|
2742
|
+
: undefined,
|
|
2743
|
+
});
|
|
2744
|
+
if (direct) {
|
|
2745
|
+
// The direct route table carries source-presentation, so engineRoutes is the
|
|
2746
|
+
// capability that gates it here (sourcePresentation gates the pane route).
|
|
2747
|
+
const b = await directEngineBackend("readSourcePresentation", {
|
|
2748
|
+
backendURL,
|
|
2749
|
+
brandId,
|
|
2750
|
+
});
|
|
2751
|
+
const params = new URLSearchParams();
|
|
2752
|
+
if (typeof fromSlide === "number") params.set("fromSlide", String(fromSlide));
|
|
2753
|
+
if (typeof toSlide === "number") params.set("toSlide", String(toSlide));
|
|
2754
|
+
const res = await bridgeFetch(
|
|
2755
|
+
resultRoute(searchId, selection, "source-presentation", params),
|
|
2756
|
+
{
|
|
2757
|
+
method: "GET",
|
|
2758
|
+
headers: { accept: "text/markdown, text/plain" },
|
|
2759
|
+
timeoutMs: engineTimeoutMs(),
|
|
2760
|
+
},
|
|
2761
|
+
b,
|
|
2762
|
+
);
|
|
2763
|
+
return shape(res);
|
|
1985
2764
|
}
|
|
1986
2765
|
const be = backendURL || (await resolveBackendForPres(presId));
|
|
2766
|
+
if (!(await backendSupportsSourcePresentation(be))) {
|
|
2767
|
+
const err = new Error(
|
|
2768
|
+
"This slidemind add-in cannot read a result's source presentation yet. Update the add-in, then try again.",
|
|
2769
|
+
);
|
|
2770
|
+
err.code = "source_presentation_unsupported";
|
|
2771
|
+
throw err;
|
|
2772
|
+
}
|
|
2773
|
+
const body = { searchId, selection };
|
|
2774
|
+
if (typeof fromSlide === "number") body.fromSlide = fromSlide;
|
|
2775
|
+
if (typeof toSlide === "number") body.toSlide = toSlide;
|
|
1987
2776
|
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
1988
2777
|
const res = await bridgeFetch(
|
|
1989
|
-
`/api/cli/presentations/${encodeURIComponent(pid)}/
|
|
2778
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/source-presentation`,
|
|
1990
2779
|
{
|
|
1991
2780
|
method: "POST",
|
|
1992
|
-
headers: {
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
2781
|
+
headers: {
|
|
2782
|
+
"content-type": "application/json",
|
|
2783
|
+
accept: "text/markdown, text/plain",
|
|
2784
|
+
},
|
|
2785
|
+
body: JSON.stringify(body),
|
|
2786
|
+
timeoutMs: engineTimeoutMs(),
|
|
1997
2787
|
},
|
|
1998
2788
|
b,
|
|
1999
2789
|
);
|
|
2000
|
-
return res
|
|
2790
|
+
return shape(res);
|
|
2001
2791
|
});
|
|
2002
2792
|
}
|
|
2003
2793
|
|