slidemind 0.1.8 → 0.2.0

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.
@@ -51,6 +51,42 @@ export function compareCliVersions(a, b) {
51
51
  return 0;
52
52
  }
53
53
 
54
+ // The one error every version-floor check raises, so the probe path
55
+ // (assertCliVersionSupported) and the per-response header path are
56
+ // indistinguishable to callers and to the skills that relay the text.
57
+ function cliUpdateRequiredError(min) {
58
+ const err = new Error(
59
+ `slidemind CLI ${cliVersion()} is older than the minimum this add-in ` +
60
+ `supports (${min}). Run \`slidemind update\` (or ` +
61
+ "`npm install -g slidemind@latest`), then retry.",
62
+ );
63
+ err.code = "cli_update_required";
64
+ return err;
65
+ }
66
+
67
+ // `doctor` and `ready` exist to EXPLAIN a broken connection, version skew
68
+ // included — they render it as a structured blocker with an `actor` and a
69
+ // nextAction. Throwing the raw error out of bridgeFetch would break the one
70
+ // command that diagnoses the problem, so they opt out of the header check
71
+ // (their own ladder still reports cli_update_required, terminally).
72
+ let diagnosticMode = false;
73
+ export function setDiagnosticMode(on) {
74
+ diagnosticMode = on !== false;
75
+ }
76
+
77
+ // Every add-in response carries the version floor in `x-slidemind-min-cli`.
78
+ // Checking it here covers routes no capability probe guards — the pane ops
79
+ // (search/read/insert) and pairing itself — for zero extra round-trips, and it
80
+ // fires BEFORE the status handling below so a stale CLI hitting a route that
81
+ // does not exist yet reads "run slidemind update" instead of a bare 404.
82
+ function enforceVersionFloor(response) {
83
+ if (diagnosticMode) return;
84
+ const min = response?.headers?.["x-slidemind-min-cli"];
85
+ if (typeof min !== "string" || !min) return;
86
+ if (compareCliVersions(cliVersion(), min) >= 0) return;
87
+ throw cliUpdateRequiredError(min);
88
+ }
89
+
54
90
  function isLoopbackHostname(hostname) {
55
91
  return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
56
92
  }
@@ -199,6 +235,7 @@ async function bridgeFetch(pathPart, init = {}, backendURL = null) {
199
235
  }
200
236
  throw err;
201
237
  }
238
+ enforceVersionFloor(response);
202
239
  if (response.status === 401) {
203
240
  throw new Error(
204
241
  "slidemind session is invalid or expired. Re-pair: open the add-in, " +
@@ -267,6 +304,7 @@ function nodeRequest(urlString, init = {}) {
267
304
  resolve({
268
305
  status: res.statusCode ?? 0,
269
306
  statusText: res.statusMessage ?? "",
307
+ headers: res.headers,
270
308
  body: Buffer.concat(chunks).toString("utf8"),
271
309
  });
272
310
  });
@@ -290,6 +328,163 @@ function nodeRequest(urlString, init = {}) {
290
328
  });
291
329
  }
292
330
 
331
+ // A streaming sibling of `nodeRequest`, for responses too large to hold in
332
+ // memory. `nodeRequest` concatenates the whole body and decodes it as UTF-8 —
333
+ // fine for JSON, but it would both balloon memory and CORRUPT binary. This pipes
334
+ // a success response straight to `destPath` and never materializes it.
335
+ //
336
+ // Error and JSON bodies are still buffered: both are small by construction (an
337
+ // error message, or the inline SVG the download route answers with), and the
338
+ // caller needs them whole to act on.
339
+ function nodeRequestToFile(urlString, init = {}, destPath) {
340
+ const url = new URL(urlString);
341
+ const isHttps = url.protocol === "https:";
342
+ const transport = isHttps ? https : http;
343
+ const body = init.body ?? null;
344
+ const headers = { ...(init.headers ?? {}) };
345
+ if (
346
+ body &&
347
+ headers["content-length"] === undefined &&
348
+ headers["Content-Length"] === undefined
349
+ ) {
350
+ headers["content-length"] = Buffer.byteLength(body);
351
+ }
352
+ const timeoutMs =
353
+ init.timeoutMs ??
354
+ (parseInt(process.env.SLIDEMIND_BRIDGE_TIMEOUT_MS || "35000", 10) || 35000);
355
+
356
+ return new Promise((resolve, reject) => {
357
+ const req = transport.request(
358
+ url,
359
+ {
360
+ method: init.method ?? "GET",
361
+ headers,
362
+ rejectUnauthorized: isHttps
363
+ ? !isLoopbackHostname(url.hostname)
364
+ : undefined,
365
+ },
366
+ (res) => {
367
+ const status = res.statusCode ?? 0;
368
+ const contentType = String(res.headers["content-type"] ?? "");
369
+ const buffered = status < 200 || status >= 300 || contentType.includes("application/json");
370
+ if (buffered) {
371
+ const chunks = [];
372
+ res.on("data", (c) => chunks.push(Buffer.from(c)));
373
+ res.on("end", () =>
374
+ resolve({
375
+ status,
376
+ statusText: res.statusMessage ?? "",
377
+ headers: res.headers,
378
+ body: Buffer.concat(chunks).toString("utf8"),
379
+ streamed: false,
380
+ }),
381
+ );
382
+ res.on("error", reject);
383
+ return;
384
+ }
385
+ const out = fs.createWriteStream(destPath);
386
+ let bytesWritten = 0;
387
+ const total = Number.parseInt(
388
+ String(res.headers["content-length"] ?? ""),
389
+ 10,
390
+ );
391
+ res.on("data", (c) => {
392
+ bytesWritten += c.length;
393
+ init.onProgress?.(bytesWritten, Number.isFinite(total) ? total : null);
394
+ });
395
+ res.pipe(out);
396
+ // A stream that dies mid-flight must reject, not resolve with a truncated
397
+ // file — the caller deletes the partial and surfaces the failure.
398
+ res.on("error", (err) => {
399
+ out.destroy();
400
+ reject(err);
401
+ });
402
+ out.on("error", reject);
403
+ out.on("finish", () =>
404
+ resolve({
405
+ status,
406
+ statusText: res.statusMessage ?? "",
407
+ headers: res.headers,
408
+ bytesWritten,
409
+ streamed: true,
410
+ }),
411
+ );
412
+ },
413
+ );
414
+ req.setTimeout(timeoutMs, () => {
415
+ const err = new Error(
416
+ `slidemind bridge request timed out after ${timeoutMs}ms`,
417
+ );
418
+ err.code = "bridge_timeout";
419
+ req.destroy(err);
420
+ });
421
+ req.on("error", reject);
422
+ if (body) req.write(body);
423
+ req.end();
424
+ });
425
+ }
426
+
427
+ // `bridgeFetch` for a response streamed to disk. Shares the session lookup,
428
+ // auth header and non-2xx error mapping so a streaming call fails exactly like
429
+ // every other bridge call.
430
+ async function bridgeStreamToFile(pathPart, init, destPath, backendURL = null) {
431
+ const session = sessionForBackend(backendURL);
432
+ const url = `${session.backendURL}${pathPart}`;
433
+ const headers = {
434
+ accept: "application/octet-stream, application/json",
435
+ ...init.headers,
436
+ authorization: `Bearer ${session.sessionToken}`,
437
+ };
438
+ let response;
439
+ try {
440
+ response = await nodeRequestToFile(url, { ...init, headers }, destPath);
441
+ } catch (err) {
442
+ if (
443
+ err &&
444
+ /ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(
445
+ String(err.message || err),
446
+ )
447
+ ) {
448
+ throw new Error(
449
+ `slidemind relay unreachable (${deriveEnvLabel(session.backendURL)}). Check your connection; ` +
450
+ "if it persists, re-pair with `slidemind pair`.",
451
+ );
452
+ }
453
+ throw err;
454
+ }
455
+ 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
+ }
462
+ if (response.status < 200 || response.status >= 300) {
463
+ let parsed = null;
464
+ try {
465
+ parsed = response.body ? JSON.parse(response.body) : null;
466
+ } catch {
467
+ /* non-JSON error body */
468
+ }
469
+ const machineCode =
470
+ parsed && typeof parsed.code === "string" ? parsed.code : null;
471
+ const humanError =
472
+ parsed && typeof parsed.error === "string" ? parsed.error : null;
473
+ const detail =
474
+ humanError ?? (response.body ? response.body.slice(0, 400) : "");
475
+ const err = new Error(
476
+ `slidemind bridge ${response.status} ${response.statusText} for ${pathPart}` +
477
+ (detail ? `: ${detail}` : ""),
478
+ );
479
+ err.status = response.status;
480
+ const code = machineCode ?? humanError;
481
+ if (code) err.code = code;
482
+ if (parsed) err.body = parsed;
483
+ throw err;
484
+ }
485
+ return response;
486
+ }
487
+
293
488
  // List one backend's open presentations, tagging each entry with its origin
294
489
  // (backendURL + a short env label) so the merged registry can be routed and
295
490
  // displayed per environment. Transient blips retry per-backend.
@@ -789,6 +984,172 @@ export async function getDesignBundleForBrandAnyBackend(brandId) {
789
984
  throw err;
790
985
  }
791
986
 
987
+ // Concept-image generation is a heavy provider call (per-image cap ~180s
988
+ // server-side). Budget just over that so a typed server error wins over a
989
+ // client timeout, but keep it below the relay's ~240s ingress cap — the CLI
990
+ // batches per-slide so no single request approaches that ceiling.
991
+ function conceptTimeoutMs() {
992
+ return (
993
+ parseInt(process.env.SLIDEMIND_BRIDGE_CONCEPT_TIMEOUT_MS || "200000", 10) ||
994
+ 200000
995
+ );
996
+ }
997
+
998
+ async function postConceptImages(
999
+ brandId,
1000
+ { planMarkdown, slideNumbers },
1001
+ backendURL,
1002
+ ) {
1003
+ const body = { planMarkdown };
1004
+ if (Array.isArray(slideNumbers) && slideNumbers.length > 0) {
1005
+ body.slideNumbers = slideNumbers;
1006
+ }
1007
+ const res = await bridgeFetch(
1008
+ `/api/cli/brands/${encodeURIComponent(brandId)}/concept-images`,
1009
+ {
1010
+ method: "POST",
1011
+ headers: { "content-type": "application/json" },
1012
+ body: JSON.stringify(body),
1013
+ timeoutMs: conceptTimeoutMs(),
1014
+ },
1015
+ backendURL,
1016
+ );
1017
+ const data = (await res.json()) ?? {};
1018
+ // Tag the response with the backend that answered so a per-slide batch can
1019
+ // pin subsequent calls to the same one (routing state only — never printed).
1020
+ return { ...data, backendURL };
1021
+ }
1022
+
1023
+ // Generate concept mockup images for a brand over the pane-free brand route.
1024
+ // Body `{ planMarkdown, slideNumbers? }`; the optional `slideNumbers` filters
1025
+ // which storyboard beats are rendered (the CLI batches per-slide). Returns the
1026
+ // server's `{ ok, images, slideCount }` augmented with the resolved
1027
+ // `backendURL`. When `backendURL` is given it posts directly; otherwise it
1028
+ // fans out — like getDesignBundleForBrandAnyBackend, presFP/brandId is globally
1029
+ // unique so at most one paired backend answers 2xx. Throws a typed
1030
+ // `concept_route_unavailable` when no backend serves the brand (old add-ins,
1031
+ // unknown brand, missing grant) so callers can degrade to "continue without".
1032
+ export async function generateConceptImagesForBrand(
1033
+ brandId,
1034
+ { planMarkdown, slideNumbers = null, backendURL = null } = {},
1035
+ ) {
1036
+ if (!brandId) {
1037
+ throw new Error("generateConceptImagesForBrand: brandId is required");
1038
+ }
1039
+ if (typeof planMarkdown !== "string" || !planMarkdown.trim()) {
1040
+ throw new Error("generateConceptImagesForBrand: planMarkdown is required");
1041
+ }
1042
+ if (backendURL) {
1043
+ return postConceptImages(brandId, { planMarkdown, slideNumbers }, backendURL);
1044
+ }
1045
+ const sessions = sessionsInScope();
1046
+ const failures = [];
1047
+ for (const s of sessions) {
1048
+ try {
1049
+ if (!(await backendSupportsConceptImages(s.backendURL))) {
1050
+ failures.push({
1051
+ env: deriveEnvLabel(s.backendURL),
1052
+ reason: "no concept-images route",
1053
+ });
1054
+ continue;
1055
+ }
1056
+ return await postConceptImages(
1057
+ brandId,
1058
+ { planMarkdown, slideNumbers },
1059
+ s.backendURL,
1060
+ );
1061
+ } catch (err) {
1062
+ failures.push({
1063
+ env: deriveEnvLabel(s.backendURL),
1064
+ reason: err instanceof Error ? err.message : String(err),
1065
+ });
1066
+ }
1067
+ }
1068
+ const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
1069
+ const err = new Error(
1070
+ `no paired backend generates concept mockups for design '${brandId}'` +
1071
+ `${detail ? ` (${detail})` : ""}.`,
1072
+ );
1073
+ err.code = "concept_route_unavailable";
1074
+ throw err;
1075
+ }
1076
+
1077
+ // gpt-image calls routinely take 60-120s; budget like the concept relay so a
1078
+ // typed server error wins over a client timeout.
1079
+ function imagenTimeoutMs() {
1080
+ return (
1081
+ parseInt(process.env.SLIDEMIND_BRIDGE_IMAGEN_TIMEOUT_MS || "200000", 10) ||
1082
+ 200000
1083
+ );
1084
+ }
1085
+
1086
+ async function postGenerateImage(brandId, body, backendURL) {
1087
+ const res = await bridgeFetch(
1088
+ `/api/cli/brands/${encodeURIComponent(brandId)}/images`,
1089
+ {
1090
+ method: "POST",
1091
+ headers: { "content-type": "application/json" },
1092
+ body: JSON.stringify(body),
1093
+ timeoutMs: imagenTimeoutMs(),
1094
+ },
1095
+ backendURL,
1096
+ );
1097
+ const data = (await res.json()) ?? {};
1098
+ return { ...data, backendURL };
1099
+ }
1100
+
1101
+ // Generate ONE regular image over the pane-free brand route
1102
+ // (`slidemind imagen`). Body `{ prompt, size?, quality?, brandStyle? }`;
1103
+ // returns the server's `{ ok, image, styleReferences }` augmented with the
1104
+ // resolved `backendURL`. Fan-out and degradation mirror
1105
+ // generateConceptImagesForBrand: throws a typed `imagen_route_unavailable`
1106
+ // when no paired backend serves the route (older add-ins, unknown brand,
1107
+ // missing grant) so callers can print the one actionable fix.
1108
+ export async function generateImageForBrand(
1109
+ brandId,
1110
+ { prompt, size = null, quality = null, brandStyle = false, backendURL = null } = {},
1111
+ ) {
1112
+ if (!brandId) {
1113
+ throw new Error("generateImageForBrand: brandId is required");
1114
+ }
1115
+ if (typeof prompt !== "string" || !prompt.trim()) {
1116
+ throw new Error("generateImageForBrand: prompt is required");
1117
+ }
1118
+ const body = { prompt };
1119
+ if (size) body.size = size;
1120
+ if (quality) body.quality = quality;
1121
+ if (brandStyle) body.brandStyle = true;
1122
+ if (backendURL) {
1123
+ return postGenerateImage(brandId, body, backendURL);
1124
+ }
1125
+ const sessions = sessionsInScope();
1126
+ const failures = [];
1127
+ for (const s of sessions) {
1128
+ try {
1129
+ if (!(await backendSupportsImageGeneration(s.backendURL))) {
1130
+ failures.push({
1131
+ env: deriveEnvLabel(s.backendURL),
1132
+ reason: "no image-generation route (update the add-in)",
1133
+ });
1134
+ continue;
1135
+ }
1136
+ return await postGenerateImage(brandId, body, s.backendURL);
1137
+ } catch (err) {
1138
+ failures.push({
1139
+ env: deriveEnvLabel(s.backendURL),
1140
+ reason: err instanceof Error ? err.message : String(err),
1141
+ });
1142
+ }
1143
+ }
1144
+ const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
1145
+ const err = new Error(
1146
+ `no paired backend generates images for design '${brandId}'` +
1147
+ `${detail ? ` (${detail})` : ""}.`,
1148
+ );
1149
+ err.code = "imagen_route_unavailable";
1150
+ throw err;
1151
+ }
1152
+
792
1153
  // List the LIVE engine catalog (NOT the bundle's curated set) for the paired
793
1154
  // backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
794
1155
  // when several environments are paired this fans out and tags each engine with
@@ -905,6 +1266,23 @@ export async function backendSupportsBrandRoutes(backendURL) {
905
1266
  return caps?.brandRoutes === true;
906
1267
  }
907
1268
 
1269
+ // Whether a backend generates concept mockup images for a brand over the
1270
+ // pane-free `/api/cli/brands/:brandId/concept-images` route (see
1271
+ // generateConceptImagesForBrand). Advertised by newer add-ins only; older ones
1272
+ // omit the flag and the caller degrades to "continue without".
1273
+ export async function backendSupportsConceptImages(backendURL) {
1274
+ const caps = await backendCapabilities(backendURL);
1275
+ return caps?.conceptImages === true;
1276
+ }
1277
+
1278
+ // Whether a backend generates regular images over the pane-free
1279
+ // `/api/cli/brands/:brandId/images` route (see generateImageForBrand).
1280
+ // Advertised by newer add-ins only.
1281
+ export async function backendSupportsImageGeneration(backendURL) {
1282
+ const caps = await backendCapabilities(backendURL);
1283
+ return caps?.imageGeneration === true;
1284
+ }
1285
+
908
1286
  // The add-in's hard version floor: /api/cli/capabilities advertises the oldest
909
1287
  // CLI it still speaks (`minCliVersion`). An older CLI must refuse to proceed —
910
1288
  // mid-rollout skew otherwise surfaces as confusing route/contract errors deep
@@ -914,13 +1292,7 @@ export async function assertCliVersionSupported(backendURL) {
914
1292
  const caps = await backendCapabilities(backendURL);
915
1293
  const min = typeof caps?.minCliVersion === "string" ? caps.minCliVersion : null;
916
1294
  if (!min || compareCliVersions(cliVersion(), min) >= 0) return;
917
- const err = new Error(
918
- `slidemind CLI ${cliVersion()} is older than the minimum this add-in ` +
919
- `supports (${min}). Run \`slidemind update\` (or ` +
920
- "`npm install -g slidemind@latest`), then retry.",
921
- );
922
- err.code = "cli_update_required";
923
- throw err;
1295
+ throw cliUpdateRequiredError(min);
924
1296
  }
925
1297
 
926
1298
  // Rebuild a job failure into the exact error shape bridgeFetch throws for the
@@ -1231,11 +1603,17 @@ async function engineOpWithPaneWait(presId, backendURL, attempt) {
1231
1603
  // slide results carrying per-slide text + metadata). `subtype`/`intent`/`top`
1232
1604
  // are passed through for slide engines (image engines ignore intent); omit them
1233
1605
  // and the server uses its defaults (back-compat with older add-ins).
1606
+ //
1607
+ // `engineId` may also be a SCOPE sentinel (`scope:all` / `scope:sharepoint`) —
1608
+ // an unfiltered search over the whole corpus rather than one engine's saved
1609
+ // folder filter. A scope has no subtype to derive its index from, so it needs an
1610
+ // explicit `resultType`; the add-in rejects a scope search without one.
1234
1611
  export async function engineSearch({
1235
1612
  presId,
1236
1613
  engineId,
1237
1614
  query,
1238
1615
  subtype = null,
1616
+ resultType = null,
1239
1617
  intent = null,
1240
1618
  top = null,
1241
1619
  backendURL = null,
@@ -1251,6 +1629,8 @@ export async function engineSearch({
1251
1629
  const be = backendURL || (await resolveBackendForPres(presId));
1252
1630
  const body = { engineId, query };
1253
1631
  if (subtype) body.subtype = subtype;
1632
+ // Additive: omitted unless set, so the body an older add-in sees is unchanged.
1633
+ if (resultType) body.resultType = resultType;
1254
1634
  if (intent) body.intent = intent;
1255
1635
  if (typeof top === "number") body.top = top;
1256
1636
  const run = async (pid, b) => {
@@ -1285,6 +1665,7 @@ export async function engineSearchAnyPane({
1285
1665
  engineId,
1286
1666
  query,
1287
1667
  subtype = null,
1668
+ resultType = null,
1288
1669
  intent = null,
1289
1670
  top = null,
1290
1671
  }) {
@@ -1318,6 +1699,7 @@ export async function engineSearchAnyPane({
1318
1699
  engineId,
1319
1700
  query,
1320
1701
  subtype,
1702
+ resultType,
1321
1703
  intent,
1322
1704
  top,
1323
1705
  backendURL: cand.backendURL ?? primaryBackend,
@@ -1384,6 +1766,130 @@ export async function engineDownload({
1384
1766
  });
1385
1767
  }
1386
1768
 
1769
+ // Stream a chosen engine result's bytes to `destPath`. The size-unbounded
1770
+ // download: the pane's Socket.IO ack carries only a signed source reference, and
1771
+ // the add-in server fetches it and pipes the body here — so nothing buffers the
1772
+ // whole image and there is no base64 4/3 inflation. `engineDownload` above sends
1773
+ // the bytes through the pane ack instead and is therefore capped by the relay's
1774
+ // 10 MB frame limit; prefer this for anything that might be large.
1775
+ //
1776
+ // An SVG result comes back as JSON (`{ ok, mimeType, svgMarkup }`) rather than a
1777
+ // byte stream: it is kilobytes and its recolour happens pane-side. Callers get
1778
+ // `{ svgMarkup }` in that case and `{ bytesWritten, mimeType }` otherwise, so they
1779
+ // can persist whichever arrived.
1780
+ export async function engineDownloadToFile({
1781
+ presId,
1782
+ searchId,
1783
+ selection,
1784
+ destPath,
1785
+ iconColor = null,
1786
+ backendURL = null,
1787
+ onProgress = null,
1788
+ }) {
1789
+ if (!presId) throw new Error("engineDownloadToFile: presId is required");
1790
+ if (typeof searchId !== "number") {
1791
+ throw new Error("engineDownloadToFile: searchId (number) is required");
1792
+ }
1793
+ if (typeof selection !== "number") {
1794
+ throw new Error("engineDownloadToFile: selection (number) is required");
1795
+ }
1796
+ if (!destPath) throw new Error("engineDownloadToFile: destPath is required");
1797
+ const be = backendURL || (await resolveBackendForPres(presId));
1798
+ const body = { searchId, selection };
1799
+ if (iconColor) body.iconColor = iconColor;
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
+ );
1820
+ if (!res.streamed) {
1821
+ // The inline-SVG answer (kilobytes) arrives as JSON, not as a byte stream.
1822
+ const parsed = res.body ? JSON.parse(res.body) : {};
1823
+ return { ok: true, streamed: false, ...parsed };
1824
+ }
1825
+ const warningsHeader = res.headers?.["x-slidemind-warnings"];
1826
+ let warnings;
1827
+ if (warningsHeader) {
1828
+ try {
1829
+ warnings = JSON.parse(warningsHeader);
1830
+ } catch {
1831
+ // A malformed header must not fail a download that otherwise succeeded.
1832
+ }
1833
+ }
1834
+ return {
1835
+ ok: true,
1836
+ streamed: true,
1837
+ mimeType: String(res.headers?.["content-type"] ?? "") || null,
1838
+ bytesWritten: res.bytesWritten,
1839
+ ...(warnings ? { warnings } : {}),
1840
+ };
1841
+ });
1842
+ }
1843
+
1844
+ // Stream a chosen slide-search result to `destPath` as a native one-slide .pptx.
1845
+ // The size-unbounded sibling of `extractSlide`: the pane ack carries only the
1846
+ // request, and the add-in server performs it and pipes the (base64-decoded) bytes
1847
+ // here. `extractSlide` puts the whole slide through the pane's Socket.IO ack as
1848
+ // base64, so it is capped at ~7 MB of real bytes — which real reused slides reach.
1849
+ export async function extractSlideToFile({
1850
+ presId,
1851
+ searchId,
1852
+ selection,
1853
+ destPath,
1854
+ backendURL = null,
1855
+ onProgress = null,
1856
+ }) {
1857
+ if (!presId) throw new Error("extractSlideToFile: presId is required");
1858
+ if (typeof searchId !== "number") {
1859
+ throw new Error("extractSlideToFile: searchId (number) is required");
1860
+ }
1861
+ if (typeof selection !== "number") {
1862
+ throw new Error("extractSlideToFile: selection (number) is required");
1863
+ }
1864
+ if (!destPath) throw new Error("extractSlideToFile: destPath is required");
1865
+ const be = backendURL || (await resolveBackendForPres(presId));
1866
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
1867
+ const res = await bridgeStreamToFile(
1868
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-extract-stream`,
1869
+ {
1870
+ method: "POST",
1871
+ headers: { "content-type": "application/json" },
1872
+ body: JSON.stringify({ searchId, selection }),
1873
+ timeoutMs:
1874
+ parseInt(
1875
+ process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000",
1876
+ 10,
1877
+ ) || 600000,
1878
+ onProgress,
1879
+ },
1880
+ destPath,
1881
+ b,
1882
+ );
1883
+ const title = res.headers?.["x-slidemind-presentation-title"];
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
+ };
1890
+ });
1891
+ }
1892
+
1387
1893
  // Extract a chosen slide-search result as a standalone one-slide .pptx (base64),
1388
1894
  // WITHOUT inserting it anywhere — the "fold into a deck" path. Relayed through
1389
1895
  // the pane like search/download.