slidemind 0.1.9 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +25 -0
- package/.claude-plugin/plugin.json +6 -3
- package/.codex-plugin/plugin.json +29 -0
- package/CLAUDE.md +25 -5
- package/README.md +24 -0
- package/bin/slidemind +7 -0
- package/bin/slidemind.cmd +2 -0
- package/bin/slidemind.mjs +480 -60
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +6 -5
- package/skills/search/SKILL.md +10 -4
- package/src/bridge/bundle.mjs +23 -4
- package/src/bridge/client.mjs +414 -7
- package/src/bridge/engine-download.mjs +229 -5
- package/src/bridge/image-dimensions.mjs +181 -0
- package/src/builders/resolve-skill.mjs +45 -1
- package/src/builders/review.mjs +4 -1
- package/src/install-channel.mjs +61 -0
- package/src/installers/claude.mjs +22 -0
- package/src/pptx/fill/engine.mjs +1273 -0
- package/src/pptx/fill/freeform.mjs +156 -0
- package/src/pptx/fill/parse-slides.mjs +154 -0
- package/src/pptx/fill/xml.mjs +486 -0
- package/src/pptx/ooxml.mjs +10 -3
package/src/bridge/client.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
getSessionFor,
|
|
18
18
|
readSessions,
|
|
19
19
|
} from "./config.mjs";
|
|
20
|
+
import { updateCliInstruction } from "../install-channel.mjs";
|
|
20
21
|
|
|
21
22
|
// This CLI's own version, for the add-in's minCliVersion floor (see
|
|
22
23
|
// assertCliVersionSupported). Read lazily from the package manifest so the
|
|
@@ -51,6 +52,41 @@ export function compareCliVersions(a, b) {
|
|
|
51
52
|
return 0;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
// The one error every version-floor check raises, so the probe path
|
|
56
|
+
// (assertCliVersionSupported) and the per-response header path are
|
|
57
|
+
// indistinguishable to callers and to the skills that relay the text.
|
|
58
|
+
function cliUpdateRequiredError(min) {
|
|
59
|
+
const err = new Error(
|
|
60
|
+
`slidemind CLI ${cliVersion()} is older than the minimum this add-in ` +
|
|
61
|
+
`supports (${min}). ${updateCliInstruction()}`,
|
|
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.
|
|
@@ -879,6 +1074,82 @@ export async function generateConceptImagesForBrand(
|
|
|
879
1074
|
throw err;
|
|
880
1075
|
}
|
|
881
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
|
+
|
|
882
1153
|
// List the LIVE engine catalog (NOT the bundle's curated set) for the paired
|
|
883
1154
|
// backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
|
|
884
1155
|
// when several environments are paired this fans out and tags each engine with
|
|
@@ -1004,6 +1275,14 @@ export async function backendSupportsConceptImages(backendURL) {
|
|
|
1004
1275
|
return caps?.conceptImages === true;
|
|
1005
1276
|
}
|
|
1006
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
|
+
|
|
1007
1286
|
// The add-in's hard version floor: /api/cli/capabilities advertises the oldest
|
|
1008
1287
|
// CLI it still speaks (`minCliVersion`). An older CLI must refuse to proceed —
|
|
1009
1288
|
// mid-rollout skew otherwise surfaces as confusing route/contract errors deep
|
|
@@ -1013,13 +1292,7 @@ export async function assertCliVersionSupported(backendURL) {
|
|
|
1013
1292
|
const caps = await backendCapabilities(backendURL);
|
|
1014
1293
|
const min = typeof caps?.minCliVersion === "string" ? caps.minCliVersion : null;
|
|
1015
1294
|
if (!min || compareCliVersions(cliVersion(), min) >= 0) return;
|
|
1016
|
-
|
|
1017
|
-
`slidemind CLI ${cliVersion()} is older than the minimum this add-in ` +
|
|
1018
|
-
`supports (${min}). Run \`slidemind update\` (or ` +
|
|
1019
|
-
"`npm install -g slidemind@latest`), then retry.",
|
|
1020
|
-
);
|
|
1021
|
-
err.code = "cli_update_required";
|
|
1022
|
-
throw err;
|
|
1295
|
+
throw cliUpdateRequiredError(min);
|
|
1023
1296
|
}
|
|
1024
1297
|
|
|
1025
1298
|
// Rebuild a job failure into the exact error shape bridgeFetch throws for the
|
|
@@ -1330,11 +1603,17 @@ async function engineOpWithPaneWait(presId, backendURL, attempt) {
|
|
|
1330
1603
|
// slide results carrying per-slide text + metadata). `subtype`/`intent`/`top`
|
|
1331
1604
|
// are passed through for slide engines (image engines ignore intent); omit them
|
|
1332
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.
|
|
1333
1611
|
export async function engineSearch({
|
|
1334
1612
|
presId,
|
|
1335
1613
|
engineId,
|
|
1336
1614
|
query,
|
|
1337
1615
|
subtype = null,
|
|
1616
|
+
resultType = null,
|
|
1338
1617
|
intent = null,
|
|
1339
1618
|
top = null,
|
|
1340
1619
|
backendURL = null,
|
|
@@ -1350,6 +1629,8 @@ export async function engineSearch({
|
|
|
1350
1629
|
const be = backendURL || (await resolveBackendForPres(presId));
|
|
1351
1630
|
const body = { engineId, query };
|
|
1352
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;
|
|
1353
1634
|
if (intent) body.intent = intent;
|
|
1354
1635
|
if (typeof top === "number") body.top = top;
|
|
1355
1636
|
const run = async (pid, b) => {
|
|
@@ -1384,6 +1665,7 @@ export async function engineSearchAnyPane({
|
|
|
1384
1665
|
engineId,
|
|
1385
1666
|
query,
|
|
1386
1667
|
subtype = null,
|
|
1668
|
+
resultType = null,
|
|
1387
1669
|
intent = null,
|
|
1388
1670
|
top = null,
|
|
1389
1671
|
}) {
|
|
@@ -1417,6 +1699,7 @@ export async function engineSearchAnyPane({
|
|
|
1417
1699
|
engineId,
|
|
1418
1700
|
query,
|
|
1419
1701
|
subtype,
|
|
1702
|
+
resultType,
|
|
1420
1703
|
intent,
|
|
1421
1704
|
top,
|
|
1422
1705
|
backendURL: cand.backendURL ?? primaryBackend,
|
|
@@ -1483,6 +1766,130 @@ export async function engineDownload({
|
|
|
1483
1766
|
});
|
|
1484
1767
|
}
|
|
1485
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
|
+
|
|
1486
1893
|
// Extract a chosen slide-search result as a standalone one-slide .pptx (base64),
|
|
1487
1894
|
// WITHOUT inserting it anywhere — the "fold into a deck" path. Relayed through
|
|
1488
1895
|
// the pane like search/download.
|