slidemind 0.2.3 → 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.
@@ -10,6 +10,7 @@ import { randomUUID } from "node:crypto";
10
10
  import fs from "node:fs";
11
11
  import http from "node:http";
12
12
  import https from "node:https";
13
+ import os from "node:os";
13
14
  import path from "node:path";
14
15
  import { fileURLToPath } from "node:url";
15
16
 
@@ -150,6 +151,29 @@ function transientRetryDelays() {
150
151
  .filter((n) => Number.isFinite(n) && n >= 0);
151
152
  }
152
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
+
153
177
  function isTransientBridgeError(err) {
154
178
  if (!err) return false;
155
179
  // pane_suspended needs the user (foreground PowerPoint), not a blind retry —
@@ -237,12 +261,7 @@ async function bridgeFetch(pathPart, init = {}, backendURL = null) {
237
261
  throw err;
238
262
  }
239
263
  enforceVersionFloor(response);
240
- if (response.status === 401) {
241
- throw new Error(
242
- "slidemind session is invalid or expired. Run `slidemind logout --all`, then " +
243
- "reconnect with a fresh command from the add-in's Connect CLI panel.",
244
- );
245
- }
264
+ if (response.status === 401) throw sessionInvalidError();
246
265
  if (response.status < 200 || response.status >= 300) {
247
266
  let parsed = null;
248
267
  try {
@@ -272,6 +291,10 @@ async function bridgeFetch(pathPart, init = {}, backendURL = null) {
272
291
  return {
273
292
  json: async () => (response.body ? JSON.parse(response.body) : null),
274
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 ?? {},
275
298
  };
276
299
  }
277
300
 
@@ -454,12 +477,7 @@ async function bridgeStreamToFile(pathPart, init, destPath, backendURL = null) {
454
477
  throw err;
455
478
  }
456
479
  enforceVersionFloor(response);
457
- if (response.status === 401) {
458
- throw new Error(
459
- "slidemind session is invalid or expired. Run `slidemind logout --all`, then " +
460
- "reconnect with a fresh command from the add-in's Connect CLI panel.",
461
- );
462
- }
480
+ if (response.status === 401) throw sessionInvalidError();
463
481
  if (response.status < 200 || response.status >= 300) {
464
482
  let parsed = null;
465
483
  try {
@@ -797,6 +815,21 @@ export async function requirePaneHost({ timeoutMs = null } = {}) {
797
815
  return requireHost();
798
816
  }
799
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
+
800
833
  // Tie-break a multi-backend presId collision without bothering the user when
801
834
  // only one candidate can actually serve the deck:
802
835
  // (a) exactly one CONNECTED pane whose active CD matches the deck's bundle
@@ -852,17 +885,23 @@ export async function resolveBackendForPres(
852
885
  // the live registry entry (caller re-targets from it), or null on timeout.
853
886
  // `onWait` fires once with the wait budget in seconds — the caller's single
854
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.
855
892
  export async function waitForPaneReady(
856
893
  presId,
857
894
  {
858
895
  backendURL = null,
859
896
  fileName = null,
860
897
  brandId = null,
898
+ otherPresIds = null,
861
899
  timeoutMs = null,
862
900
  pollMs = null,
863
901
  onWait = null,
864
902
  } = {},
865
903
  ) {
904
+ const notTheTarget = new Set(otherPresIds ?? []);
866
905
  const waitMs =
867
906
  timeoutMs ?? envInt("SLIDEMIND_BRIDGE_PANE_READY_TIMEOUT_MS", 120000);
868
907
  const stepMs = pollMs ?? envInt("SLIDEMIND_BRIDGE_PANE_READY_POLL_MS", 3000);
@@ -889,6 +928,25 @@ export async function waitForPaneReady(
889
928
  (!brandId || p.brandId === brandId),
890
929
  );
891
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
+ }
892
950
  }
893
951
  if (Date.now() >= deadline) return null;
894
952
  if (!notified) {
@@ -908,6 +966,16 @@ function paneWaitNotice(seconds) {
908
966
  );
909
967
  }
910
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
+
911
979
  // GET the full design bundle (tokens, css, layouts, designRules, signed asset
912
980
  // URLs, engines) for the presentation's active corporate design.
913
981
  export async function getDesignBundle(
@@ -985,6 +1053,45 @@ export async function getDesignBundleForBrandAnyBackend(brandId) {
985
1053
  throw err;
986
1054
  }
987
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
+
988
1095
  // Concept-image generation is a heavy provider call (per-image cap ~180s
989
1096
  // server-side). Budget just over that so a typed server error wins over a
990
1097
  // client timeout, but keep it below the relay's ~240s ingress cap — the CLI
@@ -1156,7 +1263,26 @@ export async function generateImageForBrand(
1156
1263
  // when several environments are paired this fans out and tags each engine with
1157
1264
  // its short `env` label (never the backendURL — the no-URL rule). Filter by
1158
1265
  // engine `type` (collection|search) and/or `subtype` (image|slide|template|
1159
- // content). Returns a flat `{ engines: [...] }` like the single-backend route.
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
+
1160
1286
  async function listEnginesForBackend(backendURL, { type = null, subtype = null } = {}) {
1161
1287
  return withTransientRetry(async () => {
1162
1288
  const params = new URLSearchParams();
@@ -1173,22 +1299,60 @@ async function listEnginesForBackend(backendURL, { type = null, subtype = null }
1173
1299
  const data = await res.json();
1174
1300
  const arr = Array.isArray(data?.engines) ? data.engines : [];
1175
1301
  const env = deriveEnvLabel(backendURL);
1176
- return arr.map((e) => ({ ...e, env }));
1302
+ return {
1303
+ engines: arr.map((e) => ({ ...e, env })),
1304
+ catalog: readCatalogSummary(data),
1305
+ };
1177
1306
  });
1178
1307
  }
1179
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
+
1180
1318
  export async function listEngines({ type = null, subtype = null, backendURL = null } = {}) {
1181
1319
  const sessions = backendURL ? [sessionForBackend(backendURL)] : sessionsInScope();
1182
1320
  const settled = await Promise.allSettled(
1183
1321
  sessions.map((s) => listEnginesForBackend(s.backendURL, { type, subtype })),
1184
1322
  );
1185
1323
  const engines = [];
1324
+ const catalogs = [];
1186
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();
1187
1332
  let fulfilledCount = 0;
1188
1333
  settled.forEach((r, i) => {
1189
1334
  if (r.status === "fulfilled") {
1190
1335
  fulfilledCount += 1;
1191
- engines.push(...r.value);
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);
1192
1356
  } else {
1193
1357
  failures.push({ env: deriveEnvLabel(sessions[i].backendURL), err: r.reason });
1194
1358
  }
@@ -1201,7 +1365,24 @@ export async function listEngines({ type = null, subtype = null, backendURL = nu
1201
1365
  failures.map((f) => ` ${f.env}: ${f.err.message}`).join("\n"),
1202
1366
  );
1203
1367
  }
1204
- return { engines };
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 };
1205
1386
  }
1206
1387
 
1207
1388
  // ---------------------------------------------------------------------------
@@ -1230,7 +1411,12 @@ function sleep(ms) {
1230
1411
  // mixed versions each get their own answer.
1231
1412
  const backendCapabilitiesCache = new Map();
1232
1413
 
1233
- function backendCapabilities(backendURL) {
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) {
1234
1420
  let probe = backendCapabilitiesCache.get(backendURL);
1235
1421
  if (!probe) {
1236
1422
  probe = (async () => {
@@ -1240,11 +1426,11 @@ function backendCapabilities(backendURL) {
1240
1426
  { timeoutMs: listTimeoutMs() },
1241
1427
  backendURL,
1242
1428
  );
1243
- return (await res.json()) ?? {};
1244
- } catch {
1429
+ return { caps: (await res.json()) ?? {}, error: null };
1430
+ } catch (err) {
1245
1431
  // Older backends 404 this path (with a non-JSON body) — no
1246
1432
  // capabilities: sync render, no version floor.
1247
- return {};
1433
+ return { caps: {}, error: err };
1248
1434
  }
1249
1435
  })();
1250
1436
  backendCapabilitiesCache.set(backendURL, probe);
@@ -1252,6 +1438,10 @@ function backendCapabilities(backendURL) {
1252
1438
  return probe;
1253
1439
  }
1254
1440
 
1441
+ async function backendCapabilities(backendURL) {
1442
+ return (await backendCapabilitiesResult(backendURL)).caps;
1443
+ }
1444
+
1255
1445
  async function backendSupportsRenderJobs(backendURL) {
1256
1446
  const caps = await backendCapabilities(backendURL);
1257
1447
  return caps?.renderJobs === true;
@@ -1284,6 +1474,35 @@ export async function backendSupportsImageGeneration(backendURL) {
1284
1474
  return caps?.imageGeneration === true;
1285
1475
  }
1286
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
+
1287
1506
  // The add-in's hard version floor: /api/cli/capabilities advertises the oldest
1288
1507
  // CLI it still speaks (`minCliVersion`). An older CLI must refuse to proceed —
1289
1508
  // mid-rollout skew otherwise surfaces as confusing route/contract errors deep
@@ -1574,6 +1793,22 @@ export async function translateDeck({
1574
1793
  return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
1575
1794
  }
1576
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
+
1577
1812
  // Run a pane-relayed engine op; on `pane_suspended` (the server already waited
1578
1813
  // ~25s for a reconnect) OR `pane_timeout` (the pane looked connected but never
1579
1814
  // acked within the server's 60s window — the server marks it suspended on the
@@ -1582,22 +1817,282 @@ export async function translateDeck({
1582
1817
  // presId churn too). Engine ops are idempotent reads, and the one mutation
1583
1818
  // (live insert) rides the SAME operationId through the retry so the pane-side
1584
1819
  // dedup makes a re-send safe.
1585
- async function engineOpWithPaneWait(presId, backendURL, attempt) {
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
+ ) {
1586
1838
  try {
1587
1839
  return await attempt(presId, backendURL);
1588
1840
  } catch (err) {
1589
- if (err?.code !== "pane_suspended" && err?.code !== "pane_timeout") {
1590
- throw err;
1591
- }
1841
+ const suspended =
1842
+ err?.code === "pane_suspended" || err?.code === "pane_timeout";
1843
+ const churned = retargetOnMissing && isMissingPresentationError(err);
1844
+ if (!suspended && !churned) throw err;
1592
1845
  const live = await waitForPaneReady(presId, {
1593
1846
  backendURL,
1594
- onWait: paneWaitNotice,
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,
1595
1855
  });
1596
1856
  if (!live) throw err;
1597
1857
  return attempt(live.presId, live.backendURL ?? backendURL);
1598
1858
  }
1599
1859
  }
1600
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
+
1601
2096
  // Brand-engine search through the local bridge. New add-ins run this
1602
2097
  // server-side with the relayed user token; older add-ins may fall back to a
1603
2098
  // task-pane proxy. Returns a searchId + numbered results (image thumbnails, or
@@ -1609,8 +2104,12 @@ async function engineOpWithPaneWait(presId, backendURL, attempt) {
1609
2104
  // an unfiltered search over the whole corpus rather than one engine's saved
1610
2105
  // folder filter. A scope has no subtype to derive its index from, so it needs an
1611
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.
1612
2111
  export async function engineSearch({
1613
- presId,
2112
+ presId = null,
1614
2113
  engineId,
1615
2114
  query,
1616
2115
  subtype = null,
@@ -1618,22 +2117,42 @@ export async function engineSearch({
1618
2117
  intent = null,
1619
2118
  top = null,
1620
2119
  backendURL = null,
2120
+ brandId = null,
1621
2121
  // When the chosen pane is suspended, wait CLIENT-side for THIS window to wake
1622
2122
  // (the single-window default). engineSearchAnyPane sets this false for all but
1623
2123
  // the last candidate so it can fail over to another live window instead.
1624
2124
  waitOnSuspend = true,
1625
2125
  timeoutMs = null,
1626
2126
  }) {
1627
- if (!presId) throw new Error("engineSearch: presId is required");
1628
2127
  if (!engineId) throw new Error("engineSearch: engineId is required");
1629
2128
  if (!query) throw new Error("engineSearch: query is required");
1630
- const be = backendURL || (await resolveBackendForPres(presId));
2129
+ const direct = !presId && Boolean(backendURL || brandId);
2130
+ if (!presId && !direct) throw new Error("engineSearch: presId is required");
1631
2131
  const body = { engineId, query };
1632
2132
  if (subtype) body.subtype = subtype;
1633
2133
  // Additive: omitted unless set, so the body an older add-in sees is unchanged.
1634
2134
  if (resultType) body.resultType = resultType;
1635
2135
  if (intent) body.intent = intent;
1636
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));
1637
2156
  const run = async (pid, b) => {
1638
2157
  const res = await bridgeFetch(
1639
2158
  `/api/cli/presentations/${encodeURIComponent(pid)}/engine/search`,
@@ -1731,6 +2250,11 @@ export async function engineSearchAnyPane({
1731
2250
  // Fetch a chosen engine result's bytes through the local bridge. For image
1732
2251
  // engines this returns SVG/PNG bytes (+ optional recoloured svgMarkup) WITHOUT
1733
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.
1734
2258
  export async function engineDownload({
1735
2259
  presId,
1736
2260
  searchId,
@@ -1739,9 +2263,7 @@ export async function engineDownload({
1739
2263
  backendURL = null,
1740
2264
  }) {
1741
2265
  if (!presId) throw new Error("engineDownload: presId is required");
1742
- if (typeof searchId !== "number") {
1743
- throw new Error("engineDownload: searchId (number) is required");
1744
- }
2266
+ assertSearchId("engineDownload", searchId);
1745
2267
  if (typeof selection !== "number") {
1746
2268
  throw new Error("engineDownload: selection (number) is required");
1747
2269
  }
@@ -1778,46 +2300,32 @@ export async function engineDownload({
1778
2300
  // byte stream: it is kilobytes and its recolour happens pane-side. Callers get
1779
2301
  // `{ svgMarkup }` in that case and `{ bytesWritten, mimeType }` otherwise, so they
1780
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.
1781
2307
  export async function engineDownloadToFile({
1782
- presId,
2308
+ presId = null,
1783
2309
  searchId,
1784
2310
  selection,
1785
2311
  destPath,
1786
2312
  iconColor = null,
1787
2313
  backendURL = null,
2314
+ brandId = null,
1788
2315
  onProgress = null,
1789
2316
  }) {
1790
- if (!presId) throw new Error("engineDownloadToFile: presId is required");
1791
- if (typeof searchId !== "number") {
1792
- throw new Error("engineDownloadToFile: searchId (number) is required");
2317
+ const direct = !presId && Boolean(backendURL || brandId);
2318
+ if (!presId && !direct) {
2319
+ throw new Error("engineDownloadToFile: presId is required");
1793
2320
  }
2321
+ assertSearchId("engineDownloadToFile", searchId);
1794
2322
  if (typeof selection !== "number") {
1795
2323
  throw new Error("engineDownloadToFile: selection (number) is required");
1796
2324
  }
1797
2325
  if (!destPath) throw new Error("engineDownloadToFile: destPath is required");
1798
- const be = backendURL || (await resolveBackendForPres(presId));
1799
- const body = { searchId, selection };
1800
- if (iconColor) body.iconColor = iconColor;
1801
- return engineOpWithPaneWait(presId, be, async (pid, b) => {
1802
- const res = await bridgeStreamToFile(
1803
- `/api/cli/presentations/${encodeURIComponent(pid)}/engine/download-stream`,
1804
- {
1805
- method: "POST",
1806
- headers: { "content-type": "application/json" },
1807
- body: JSON.stringify(body),
1808
- // A large image legitimately takes longer than an engine op: the server
1809
- // is fetching from SharePoint/blob AND piping. Separate budget so raising
1810
- // it doesn't loosen every other relay call.
1811
- timeoutMs:
1812
- parseInt(
1813
- process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000",
1814
- 10,
1815
- ) || 600000,
1816
- onProgress,
1817
- },
1818
- destPath,
1819
- b,
1820
- );
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) => {
1821
2329
  if (!res.streamed) {
1822
2330
  // The inline-SVG answer (kilobytes) arrives as JSON, not as a byte stream.
1823
2331
  const parsed = res.body ? JSON.parse(res.body) : {};
@@ -1839,6 +2347,41 @@ export async function engineDownloadToFile({
1839
2347
  bytesWritten: res.bytesWritten,
1840
2348
  ...(warnings ? { warnings } : {}),
1841
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);
1842
2385
  });
1843
2386
  }
1844
2387
 
@@ -1847,22 +2390,47 @@ export async function engineDownloadToFile({
1847
2390
  // request, and the add-in server performs it and pipes the (base64-decoded) bytes
1848
2391
  // here. `extractSlide` puts the whole slide through the pane's Socket.IO ack as
1849
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`.
1850
2396
  export async function extractSlideToFile({
1851
- presId,
2397
+ presId = null,
1852
2398
  searchId,
1853
2399
  selection,
1854
2400
  destPath,
1855
2401
  backendURL = null,
2402
+ brandId = null,
1856
2403
  onProgress = null,
1857
2404
  }) {
1858
- if (!presId) throw new Error("extractSlideToFile: presId is required");
1859
- if (typeof searchId !== "number") {
1860
- throw new Error("extractSlideToFile: searchId (number) is required");
1861
- }
2405
+ const direct = !presId && Boolean(backendURL || brandId);
2406
+ if (!presId && !direct) throw new Error("extractSlideToFile: presId is required");
2407
+ assertSearchId("extractSlideToFile", searchId);
1862
2408
  if (typeof selection !== "number") {
1863
2409
  throw new Error("extractSlideToFile: selection (number) is required");
1864
2410
  }
1865
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
+ }
1866
2434
  const be = backendURL || (await resolveBackendForPres(presId));
1867
2435
  return engineOpWithPaneWait(presId, be, async (pid, b) => {
1868
2436
  const res = await bridgeStreamToFile(
@@ -1871,29 +2439,21 @@ export async function extractSlideToFile({
1871
2439
  method: "POST",
1872
2440
  headers: { "content-type": "application/json" },
1873
2441
  body: JSON.stringify({ searchId, selection }),
1874
- timeoutMs:
1875
- parseInt(
1876
- process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000",
1877
- 10,
1878
- ) || 600000,
2442
+ timeoutMs: downloadTimeoutMs(),
1879
2443
  onProgress,
1880
2444
  },
1881
2445
  destPath,
1882
2446
  b,
1883
2447
  );
1884
- const title = res.headers?.["x-slidemind-presentation-title"];
1885
- return {
1886
- ok: true,
1887
- bytesWritten: res.bytesWritten,
1888
- slideId: res.headers?.["x-slidemind-slide-id"] ?? undefined,
1889
- presentationTitle: title ? decodeURIComponent(String(title)) : undefined,
1890
- };
2448
+ return shape(res);
1891
2449
  });
1892
2450
  }
1893
2451
 
1894
2452
  // Extract a chosen slide-search result as a standalone one-slide .pptx (base64),
1895
2453
  // WITHOUT inserting it anywhere — the "fold into a deck" path. Relayed through
1896
- // 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.
1897
2457
  export async function extractSlide({
1898
2458
  presId,
1899
2459
  searchId,
@@ -1901,9 +2461,7 @@ export async function extractSlide({
1901
2461
  backendURL = null,
1902
2462
  }) {
1903
2463
  if (!presId) throw new Error("extractSlide: presId is required");
1904
- if (typeof searchId !== "number") {
1905
- throw new Error("extractSlide: searchId (number) is required");
1906
- }
2464
+ assertSearchId("extractSlide", searchId);
1907
2465
  if (typeof selection !== "number") {
1908
2466
  throw new Error("extractSlide: selection (number) is required");
1909
2467
  }
@@ -1927,17 +2485,23 @@ export async function extractSlide({
1927
2485
 
1928
2486
  // Insert one or more chosen slide-search results live into the open
1929
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.
1930
2493
  export async function insertSlideLive({
1931
2494
  presId,
1932
2495
  searchId,
1933
2496
  selections,
1934
2497
  at = null,
1935
2498
  backendURL = null,
2499
+ fileName = null,
2500
+ brandId = null,
2501
+ otherPresIds = null,
1936
2502
  }) {
1937
2503
  if (!presId) throw new Error("insertSlideLive: presId is required");
1938
- if (typeof searchId !== "number") {
1939
- throw new Error("insertSlideLive: searchId (number) is required");
1940
- }
2504
+ assertSearchId("insertSlideLive", searchId);
1941
2505
  if (!Array.isArray(selections) || selections.length === 0) {
1942
2506
  throw new Error("insertSlideLive: selections (number[]) is required");
1943
2507
  }
@@ -1951,54 +2515,279 @@ export async function insertSlideLive({
1951
2515
  const operationId = `insert-${randomUUID()}`;
1952
2516
  const body = { searchId, selections, operationId };
1953
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));
1954
2681
  return engineOpWithPaneWait(presId, be, async (pid, b) => {
1955
2682
  const res = await bridgeFetch(
1956
- `/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-insert`,
2683
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-view`,
1957
2684
  {
1958
2685
  method: "POST",
1959
2686
  headers: { "content-type": "application/json" },
1960
- body: JSON.stringify(body),
2687
+ body: JSON.stringify({ searchId, selection }),
1961
2688
  timeoutMs:
1962
2689
  parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
1963
2690
  90000,
1964
2691
  },
1965
2692
  b,
1966
2693
  );
1967
- return res.json(); // { ok, insertedCount, insertedSlideIds }
2694
+ return res.json(); // { ok, dataUri, mimeType }
1968
2695
  });
1969
2696
  }
1970
2697
 
1971
- // Fetch a chosen slide-search result's thumbnail/preview as a data URI for
1972
- // on-demand viewing (the agent doesn't have to extract the whole .pptx just to
1973
- // look at a slide). Relayed through the pane.
1974
- export async function viewSlide({
1975
- presId,
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,
1976
2717
  searchId,
1977
2718
  selection,
2719
+ fromSlide = null,
2720
+ toSlide = null,
1978
2721
  backendURL = null,
2722
+ brandId = null,
1979
2723
  }) {
1980
- if (!presId) throw new Error("viewSlide: presId is required");
1981
- if (typeof searchId !== "number") {
1982
- throw new Error("viewSlide: searchId (number) is required");
2724
+ const direct = !presId && Boolean(backendURL || brandId);
2725
+ if (!presId && !direct) {
2726
+ throw new Error("readSourcePresentation: presId is required");
1983
2727
  }
2728
+ assertSearchId("readSourcePresentation", searchId);
1984
2729
  if (typeof selection !== "number") {
1985
- throw new Error("viewSlide: selection (number) is required");
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);
1986
2764
  }
1987
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;
1988
2776
  return engineOpWithPaneWait(presId, be, async (pid, b) => {
1989
2777
  const res = await bridgeFetch(
1990
- `/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-view`,
2778
+ `/api/cli/presentations/${encodeURIComponent(pid)}/source-presentation`,
1991
2779
  {
1992
2780
  method: "POST",
1993
- headers: { "content-type": "application/json" },
1994
- body: JSON.stringify({ searchId, selection }),
1995
- timeoutMs:
1996
- parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
1997
- 90000,
2781
+ headers: {
2782
+ "content-type": "application/json",
2783
+ accept: "text/markdown, text/plain",
2784
+ },
2785
+ body: JSON.stringify(body),
2786
+ timeoutMs: engineTimeoutMs(),
1998
2787
  },
1999
2788
  b,
2000
2789
  );
2001
- return res.json(); // { ok, dataUri, mimeType }
2790
+ return shape(res);
2002
2791
  });
2003
2792
  }
2004
2793