slidemind 0.3.13 → 0.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +86 -21
  4. package/README.md +14 -17
  5. package/bin/slidemind.mjs +65 -4969
  6. package/package.json +1 -1
  7. package/skills/create-slides/SKILL.md +113 -74
  8. package/skills/search/SKILL.md +25 -7
  9. package/src/bridge/build-and-push.mjs +108 -15
  10. package/src/bridge/bundle.mjs +69 -0
  11. package/src/bridge/capture.mjs +19 -0
  12. package/src/bridge/client/brands.mjs +622 -0
  13. package/src/bridge/client/engine-routing.mjs +237 -0
  14. package/src/bridge/client/engine-search.mjs +310 -0
  15. package/src/bridge/client/engine-slides.mjs +435 -0
  16. package/src/bridge/client/pane-ops.mjs +335 -0
  17. package/src/bridge/client/presentations.mjs +587 -0
  18. package/src/bridge/client/render.mjs +317 -0
  19. package/src/bridge/client/transport.mjs +695 -0
  20. package/src/bridge/client.mjs +80 -3452
  21. package/src/bridge/config.mjs +1 -1
  22. package/src/bridge/engine-download.mjs +15 -2
  23. package/src/builders/build.mjs +11 -804
  24. package/src/builders/draft-check.mjs +376 -0
  25. package/src/builders/lint.mjs +38 -2
  26. package/src/builders/preview.html +799 -0
  27. package/src/builders/review.mjs +23 -4
  28. package/src/builders/visual-families.mjs +6 -8
  29. package/src/cli/common.mjs +179 -0
  30. package/src/cli/concepts.mjs +310 -0
  31. package/src/cli/connection.mjs +147 -0
  32. package/src/cli/deck.mjs +608 -0
  33. package/src/cli/designs.mjs +113 -0
  34. package/src/cli/diagnose.mjs +141 -0
  35. package/src/cli/download-bundle.mjs +144 -0
  36. package/src/cli/engine-helpers.mjs +429 -0
  37. package/src/cli/engine-results.mjs +320 -0
  38. package/src/cli/help.mjs +201 -0
  39. package/src/cli/install.mjs +257 -0
  40. package/src/cli/read.mjs +120 -0
  41. package/src/cli/ready.mjs +805 -0
  42. package/src/cli/search.mjs +608 -0
  43. package/src/cli/session.mjs +177 -0
  44. package/src/cli/setup.mjs +282 -0
  45. package/src/cli/slide-reuse.mjs +334 -0
  46. package/src/cli/status.mjs +118 -0
  47. package/src/cli/workspace.mjs +302 -0
  48. package/src/installers/codex.mjs +1 -1
  49. package/src/update-check.mjs +231 -0
@@ -0,0 +1,237 @@
1
+ // Pane-free engine routes (the DIRECT path)
2
+ //
3
+ // Newer add-ins serve the whole engine surface off the CLI session:
4
+ // GET /api/cli/engines catalog (same path,
5
+ // answered pane-free)
6
+ // POST /api/cli/searches → { searchId, results }
7
+ // GET /api/cli/searches/:id/results/:sel/image stream
8
+ // GET /api/cli/searches/:id/results/:sel/thumbnail stream
9
+ // GET /api/cli/searches/:id/results/:sel/slide stream (one-slide .pptx)
10
+ // GET /api/cli/searches/:id/results/:sel/source-presentation
11
+ //
12
+ // The searchId is SERVER-owned (per user, short TTL) rather than bound to a
13
+ // window's pane, and a request carries only that searchId plus the selection
14
+ // index — the server resolves the source config it stored at search time, so no
15
+ // client ever names a file, site or drive.
16
+ //
17
+ // How a function picks its path: a `presId` means "relay through THAT window"
18
+ // (the compat path old add-ins need and live insert always needs); NO presId
19
+ // plus an explicit `backendURL`/`brandId` means "run this pane-free". Callers
20
+ // resolve the backend once with resolveEngineBackend(), which returns null when
21
+ // nothing paired advertises the routes — that null is the signal to fall back
22
+ // to the pane relay, never a failure.
23
+
24
+ import { deriveEnvLabel } from "../config.mjs";
25
+ import { getDesignBundleForBrand, getDesignBundleForBrandAnyBackend } from "./brands.mjs";
26
+ import { isSessionInvalidError, sessionsInScope, bridgeFetch, backendCapabilitiesResult, backendSupportsEngineRoutes } from "./transport.mjs";
27
+
28
+ export function searchRoute(searchId, suffix = "") {
29
+ return `/api/cli/searches/${encodeURIComponent(String(searchId))}${suffix}`;
30
+ }
31
+
32
+ // A searchId is a number on the pane relay (the pane mints it per window) and an
33
+ // opaque server-minted id on the direct routes — accept either, reject anything
34
+ // else before a call goes near the network.
35
+ export function assertSearchId(fn, searchId) {
36
+ const ok =
37
+ (typeof searchId === "number" && Number.isFinite(searchId)) ||
38
+ (typeof searchId === "string" && searchId.trim().length > 0);
39
+ if (!ok) throw new Error(`${fn}: searchId is required`);
40
+ }
41
+
42
+ // A large asset legitimately takes longer than an engine op: the server is
43
+ // fetching from SharePoint/blob AND piping. Separate budget so raising it
44
+ // doesn't loosen every other bridge call.
45
+ export function downloadTimeoutMs() {
46
+ return (
47
+ parseInt(process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000", 10) ||
48
+ 600000
49
+ );
50
+ }
51
+
52
+ export function resultRoute(searchId, selection, leaf, params = null) {
53
+ const qs = params && [...params.keys()].length > 0 ? `?${params}` : "";
54
+ return `${searchRoute(searchId, `/results/${encodeURIComponent(String(selection))}/${leaf}`)}${qs}`;
55
+ }
56
+
57
+ // Per-process memo of which backend serves a brand's engine routes, so a
58
+ // multi-environment session pays the brand probe at most once per design.
59
+ export const engineBackendForBrand = new Map();
60
+
61
+ // A searchId is opaque (server-minted, hex) on the direct routes and numeric on
62
+ // the pane relay. The distinction routes: an opaque id names a record on the
63
+ // backend that minted it, and the pane can NEVER serve it — its handler only
64
+ // understands its own per-window numeric ids.
65
+ export function isOpaqueSearchId(searchId) {
66
+ return (
67
+ typeof searchId === "string" &&
68
+ searchId.trim() !== "" &&
69
+ !/^\d+$/.test(searchId.trim())
70
+ );
71
+ }
72
+
73
+ // Which paired backend minted this server-owned search record. Search records
74
+ // are oid-scoped, so a foreign environment answers the same 404 as an expired
75
+ // id — probing every engine-routes candidate with the record's own GET is both
76
+ // exact and cheap (2h TTL, at most a couple of environments). Returns null when
77
+ // no candidate holds it: expired, or minted by an environment no longer in
78
+ // scope. Never falls back to the pane — see isOpaqueSearchId.
79
+ //
80
+ // One failure class is NOT routing information: a 401 means the session itself
81
+ // is dead (revoked in the add-in's Connect CLI panel, or aged past its hard
82
+ // lifetime). Swallowing it reports a revoked session as "that search expired",
83
+ // and the user then re-runs a search that 401s the same way. So per-candidate
84
+ // failures are collected and a session-invalid one is rethrown instead.
85
+ export async function resolveSearchBackend(searchId) {
86
+ if (!isOpaqueSearchId(searchId)) return null;
87
+ const failures = [];
88
+ for (const s of sessionsInScope()) {
89
+ // A revoked session 401s the capability probe too, and that answer is
90
+ // indistinguishable from "old add-in" unless the failure is kept.
91
+ const probe = await backendCapabilitiesResult(s.backendURL);
92
+ if (probe.error && isSessionInvalidError(probe.error)) {
93
+ failures.push(probe.error);
94
+ continue;
95
+ }
96
+ if (probe.caps?.engineRoutes !== true) continue;
97
+ try {
98
+ await bridgeFetch(searchRoute(searchId), {}, s.backendURL);
99
+ return s.backendURL;
100
+ } catch (err) {
101
+ // 404 (not this environment's record / expired) or unreachable — try the
102
+ // next candidate; the distinction doesn't matter to routing.
103
+ failures.push(err);
104
+ }
105
+ }
106
+ // The two failures that are NOT the record talking: a dead session, and a
107
+ // missing Microsoft 365 sign-in (the record re-read is an engine route, so
108
+ // it refuses with the rest of corpus search). Reported as "that search
109
+ // expired", both send the user to re-run a search that fails the same way —
110
+ // so they outrank the expired-record answer.
111
+ const blocking = failures.find(
112
+ (e) => isSessionInvalidError(e) || e?.code === "login_required",
113
+ );
114
+ if (blocking) throw blocking;
115
+ return null;
116
+ }
117
+
118
+ // Which paired backend runs the pane-free engine routes for this call. An
119
+ // explicit backendURL is honoured as-is (the caller already resolved a window or
120
+ // a deck). Otherwise: every session in scope that advertises the routes is a
121
+ // candidate — with ONE connected environment (the normal case) that IS the
122
+ // answer, and no probe is worth paying for it. With several connected, the
123
+ // DECK's bound design decides, never an open window's origin. presFP/brandId is
124
+ // content-derived, so the SAME design onboarded in two environments carries the
125
+ // SAME id — candidates are therefore probed FIRST, and only when none serves
126
+ // the design does the caller fall back to the pane relay (the design lives on
127
+ // an older add-in, and searching a different corpus would fold ITS slides into
128
+ // this deck). Returns null for that fallback and when nothing paired serves the
129
+ // routes at all.
130
+ export async function resolveEngineBackend({ backendURL = null, brandId = null } = {}) {
131
+ if (backendURL) {
132
+ return (await backendSupportsEngineRoutes(backendURL)) ? backendURL : null;
133
+ }
134
+ const sessions = sessionsInScope();
135
+ const candidates = [];
136
+ for (const s of sessions) {
137
+ if (await backendSupportsEngineRoutes(s.backendURL)) candidates.push(s.backendURL);
138
+ }
139
+ if (candidates.length === 0) return null;
140
+ // Exactly one CONNECTED environment: there is nothing else the deck could
141
+ // belong to. Note the test is on the sessions, not on the candidates — during
142
+ // a rollout only one of several environments serves the routes, and taking it
143
+ // regardless would have searched the wrong corpus.
144
+ if (sessions.length === 1) return candidates[0];
145
+ if (brandId) {
146
+ // The memo only stands while that environment is still a candidate.
147
+ const memo = engineBackendForBrand.get(brandId);
148
+ if (memo) return candidates.includes(memo) ? memo : null;
149
+ // Candidates first: an environment that serves both the design AND the
150
+ // engine routes wins outright, even when an older environment also has the
151
+ // (content-identical) design.
152
+ for (const be of candidates) {
153
+ try {
154
+ await getDesignBundleForBrand(brandId, { backendURL: be });
155
+ engineBackendForBrand.set(brandId, be);
156
+ return be;
157
+ } catch {
158
+ // This candidate doesn't serve the design — try the next.
159
+ }
160
+ }
161
+ try {
162
+ const { backendURL: be } = await getDesignBundleForBrandAnyBackend(brandId);
163
+ engineBackendForBrand.set(brandId, be);
164
+ // Only a non-candidate can answer here: the design's environment is an
165
+ // older add-in with no engine routes. null sends the caller to that
166
+ // environment's pane relay instead of a different corpus.
167
+ return null;
168
+ } catch {
169
+ // No backend owns the design (unknown brand, old add-in) — fall through
170
+ // rather than guessing an environment.
171
+ }
172
+ }
173
+ if (candidates.length === 1) return candidates[0];
174
+ const err = new Error(
175
+ `Several connected environments run engine search (${candidates
176
+ .map((u) => deriveEnvLabel(u))
177
+ .join(", ")}). Scope the command with --backend <env>, point it at a ` +
178
+ "bound deck (--deck) so the deck's design picks one, or name an open " +
179
+ "window with --to <presId> to relay through it.",
180
+ );
181
+ err.code = "engine_backend_ambiguous";
182
+ throw err;
183
+ }
184
+
185
+ // Which connected environments answer the pane-free engine routes — the
186
+ // "backend authoring ready" half of the readiness picture (`doctor`, `ready`),
187
+ // reported as ENV LABELS so no URL can reach a printed line. Pass
188
+ // connectionStatus()'s `backends` to skip environments that are already known
189
+ // unreachable; without it every session in scope is probed.
190
+ export async function engineReachability({ backends = null } = {}) {
191
+ const urls = Array.isArray(backends)
192
+ ? backends.filter((b) => b?.ok && b.backendURL).map((b) => b.backendURL)
193
+ : sessionsInScope().map((s) => s.backendURL);
194
+ const envs = [];
195
+ for (const url of urls) {
196
+ if (await backendSupportsEngineRoutes(url)) envs.push(deriveEnvLabel(url));
197
+ }
198
+ return { available: envs.length > 0, envs };
199
+ }
200
+
201
+ // Resolve + verify the backend for a direct engine call. Throws a typed
202
+ // `engine_routes_unavailable` when the caller asked for the pane-free path on a
203
+ // backend that doesn't serve it — a programming error by then, since callers go
204
+ // through resolveEngineBackend first, but it must never surface as a bare 404.
205
+ export async function directEngineBackend(fn, { backendURL = null, brandId = null }) {
206
+ const be = backendURL ?? (await resolveEngineBackend({ brandId }));
207
+ if (be && (await backendSupportsEngineRoutes(be))) return be;
208
+ const err = new Error(
209
+ `${fn}: no connected slidemind add-in serves pane-free engine routes` +
210
+ `${be ? ` ('${deriveEnvLabel(be)}' is too old)` : ""}. ` +
211
+ "Open PowerPoint with the slidemind pane so the search can be relayed, or update the add-in.",
212
+ );
213
+ err.code = "engine_routes_unavailable";
214
+ throw err;
215
+ }
216
+
217
+ // Corpus search runs as the USER: the pane-free engine routes resolve the
218
+ // caller's group membership live from the delegated tokens `slidemind login`
219
+ // minted, and answer `401 { code: "login_required", action: "…" }` without
220
+ // them. Shape it like the connection ladder's rungs — `{ code, actor, message }`
221
+ // — so the CLI can print ONE actionable line and an agent knows it cannot clear
222
+ // this one itself (the sign-in needs a browser and a human). Returns null for
223
+ // any other error.
224
+ export function loginRequiredBlocker(err) {
225
+ if (err?.code !== "login_required") return null;
226
+ const action =
227
+ typeof err?.body?.action === "string" && err.body.action.trim()
228
+ ? err.body.action.trim()
229
+ : "run `slidemind login` to sign in to Microsoft 365";
230
+ return {
231
+ code: "login_required",
232
+ actor: "user",
233
+ message:
234
+ `slidemind needs your Microsoft 365 sign-in before it can search the corpus — ${action}. ` +
235
+ "Everything else (render, bundle, imagery already downloaded) keeps working.",
236
+ };
237
+ }
@@ -0,0 +1,310 @@
1
+ // Engine search + result download (pane-free direct path, pane relay fallback).
2
+
3
+ import { deriveEnvLabel } from "../config.mjs";
4
+ import { assertSearchId, downloadTimeoutMs, resultRoute, directEngineBackend } from "./engine-routing.mjs";
5
+ import { pickAnyPresentationEntry } from "./pane-ops.mjs";
6
+ import { resolveBackendForPres, engineOpWithPaneWait } from "./presentations.mjs";
7
+ import { engineTimeoutMs, bridgeFetch, bridgeStreamToFile, envInt, delegatedBodyFields, graphHeaders } from "./transport.mjs";
8
+
9
+ // Brand-engine search through the local bridge. New add-ins run this
10
+ // server-side with the relayed user token; older add-ins may fall back to a
11
+ // task-pane proxy. Returns a searchId + numbered results (image thumbnails, or
12
+ // slide results carrying per-slide text + metadata). `subtype`/`intent`/`top`
13
+ // are passed through for slide engines (image engines ignore intent); omit them
14
+ // and the server uses its defaults (back-compat with older add-ins).
15
+ //
16
+ // `engineId` may also be a SCOPE sentinel (`scope:all` / `scope:sharepoint`) —
17
+ // an unfiltered search over the whole corpus rather than one engine's saved
18
+ // folder filter. A scope has no subtype to derive its index from, so it needs an
19
+ // explicit `resultType`; the add-in rejects a scope search without one.
20
+ //
21
+ // With NO presId and a resolved `backendURL`/`brandId` this runs pane-free
22
+ // against `POST /api/cli/searches` (same body) and tags the answer with the
23
+ // backend that owns the searchId.
24
+ export async function engineSearch({
25
+ presId = null,
26
+ engineId,
27
+ query,
28
+ subtype = null,
29
+ resultType = null,
30
+ intent = null,
31
+ top = null,
32
+ backendURL = null,
33
+ brandId = null,
34
+ // When the chosen pane is suspended, wait CLIENT-side for THIS window to wake
35
+ // (the single-window default). engineSearchAnyPane sets this false for all but
36
+ // the last candidate so it can fail over to another live window instead.
37
+ waitOnSuspend = true,
38
+ timeoutMs = null,
39
+ }) {
40
+ if (!engineId) throw new Error("engineSearch: engineId is required");
41
+ if (!query) throw new Error("engineSearch: query is required");
42
+ const direct = !presId && Boolean(backendURL || brandId);
43
+ if (!presId && !direct) throw new Error("engineSearch: presId is required");
44
+ const body = { engineId, query };
45
+ if (subtype) body.subtype = subtype;
46
+ // Additive: omitted unless set, so the body an older add-in sees is unchanged.
47
+ if (resultType) body.resultType = resultType;
48
+ if (intent) body.intent = intent;
49
+ if (typeof top === "number") body.top = top;
50
+ if (direct) {
51
+ const be = await directEngineBackend("engineSearch", { backendURL, brandId });
52
+ // The user's own identity: the backend resolves the caller's group
53
+ // membership from these tokens per request and refuses without them
54
+ // (login_required). Search also seeds the speculative insert cache
55
+ // server-side, which is why it carries the Graph token as well as the
56
+ // byte GETs.
57
+ const delegated = await delegatedBodyFields(be);
58
+ const res = await bridgeFetch(
59
+ "/api/cli/searches",
60
+ {
61
+ method: "POST",
62
+ headers: { "content-type": "application/json" },
63
+ body: JSON.stringify({ ...body, ...delegated }),
64
+ timeoutMs: timeoutMs ?? engineTimeoutMs(),
65
+ },
66
+ be,
67
+ );
68
+ const data = (await res.json()) ?? {};
69
+ // Tag the routing state (like the brand routes do) so every follow-up pick
70
+ // hits the backend that owns the searchId. `env` is the printable half —
71
+ // callers must never surface backendURL.
72
+ return { ...data, backendURL: be, env: deriveEnvLabel(be) };
73
+ }
74
+ const be = backendURL || (await resolveBackendForPres(presId));
75
+ const run = async (pid, b) => {
76
+ const res = await bridgeFetch(
77
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/search`,
78
+ {
79
+ method: "POST",
80
+ headers: { "content-type": "application/json" },
81
+ body: JSON.stringify(body),
82
+ timeoutMs: timeoutMs ?? engineTimeoutMs(),
83
+ },
84
+ b,
85
+ );
86
+ return res.json(); // { ok, searchId, engineId, subtype, intent, resultCount, gridDataUri?, results }
87
+ };
88
+ return waitOnSuspend ? engineOpWithPaneWait(presId, be, run) : run(presId, be);
89
+ }
90
+
91
+ // A suspended/throttled background webview must not strand a window-AGNOSTIC
92
+ // engine search: the catalog is per-user, so any connected pane can answer it.
93
+ // Try the picked pane first, then every other connected pane on the same
94
+ // backend (non-suspended first), failing fast past each unresponsive one; the
95
+ // LAST candidate keeps the normal "click the pane to wake it" wait so a genuine
96
+ // single-window/all-asleep case still surfaces the actionable prompt. Returns
97
+ // the search result tagged with the presId/backendURL that actually answered —
98
+ // the searchId is bound to THAT window, so callers must thread it into
99
+ // follow-up view/insert/add-slide. An explicit presId pins the window (no
100
+ // failover — the caller asked for that specific one).
101
+ export async function engineSearchAnyPane({
102
+ registry,
103
+ explicitPresId = null,
104
+ engineId,
105
+ query,
106
+ subtype = null,
107
+ resultType = null,
108
+ intent = null,
109
+ top = null,
110
+ }) {
111
+ const primary = pickAnyPresentationEntry(registry, explicitPresId);
112
+ const primaryBackend = primary.backendURL ?? null;
113
+ let candidates;
114
+ if (explicitPresId) {
115
+ candidates = [primary];
116
+ } else {
117
+ const others = (Array.isArray(registry) ? registry : [])
118
+ .filter(
119
+ (p) =>
120
+ p.presId !== primary.presId &&
121
+ p.connected !== false &&
122
+ (p.backendURL ?? null) === primaryBackend,
123
+ )
124
+ .sort((a, b) => (a.suspendedAt ? 1 : 0) - (b.suspendedAt ? 1 : 0));
125
+ candidates = [primary, ...others];
126
+ }
127
+ const failoverTimeout = envInt(
128
+ "SLIDEMIND_BRIDGE_ENGINE_FAILOVER_TIMEOUT_MS",
129
+ 25000,
130
+ );
131
+ let lastErr = null;
132
+ for (let i = 0; i < candidates.length; i++) {
133
+ const cand = candidates[i];
134
+ const isLast = i === candidates.length - 1;
135
+ try {
136
+ const res = await engineSearch({
137
+ presId: cand.presId,
138
+ engineId,
139
+ query,
140
+ subtype,
141
+ resultType,
142
+ intent,
143
+ top,
144
+ backendURL: cand.backendURL ?? primaryBackend,
145
+ waitOnSuspend: isLast,
146
+ timeoutMs: isLast ? null : failoverTimeout,
147
+ });
148
+ return {
149
+ ...res,
150
+ presId: cand.presId,
151
+ backendURL: cand.backendURL ?? primaryBackend,
152
+ };
153
+ } catch (err) {
154
+ lastErr = err;
155
+ // A suspended/frozen/slow pane is recoverable by trying the next window;
156
+ // anything else (bad engineId, auth, 4xx) is real — surface it now.
157
+ const recoverable =
158
+ err?.code === "pane_suspended" ||
159
+ err?.code === "pane_timeout" ||
160
+ err?.code === "bridge_timeout" ||
161
+ err?.status === 503 ||
162
+ err?.status === 504;
163
+ if (isLast || !recoverable) throw err;
164
+ }
165
+ }
166
+ throw lastErr ?? new Error("engineSearchAnyPane: no candidate panes");
167
+ }
168
+
169
+ // Fetch a chosen engine result's bytes through the local bridge. For image
170
+ // engines this returns SVG/PNG bytes (+ optional recoloured svgMarkup) WITHOUT
171
+ // inserting onto a slide.
172
+ //
173
+ // Pane-only by design: the pane-free route table streams (see
174
+ // engineDownloadToFile), and this base64-through-the-ack path only exists for
175
+ // add-ins that predate streaming — which by definition predate the direct
176
+ // routes too.
177
+ export async function engineDownload({
178
+ presId,
179
+ searchId,
180
+ selection,
181
+ iconColor = null,
182
+ backendURL = null,
183
+ }) {
184
+ if (!presId) throw new Error("engineDownload: presId is required");
185
+ assertSearchId("engineDownload", searchId);
186
+ if (typeof selection !== "number") {
187
+ throw new Error("engineDownload: selection (number) is required");
188
+ }
189
+ const be = backendURL || (await resolveBackendForPres(presId));
190
+ const body = { searchId, selection };
191
+ if (iconColor) body.iconColor = iconColor;
192
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
193
+ const res = await bridgeFetch(
194
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/download`,
195
+ {
196
+ method: "POST",
197
+ headers: { "content-type": "application/json" },
198
+ body: JSON.stringify(body),
199
+ // Same relay path as engine/search — outlast the server's reconnect
200
+ // wait + pane ack timeout.
201
+ timeoutMs:
202
+ parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
203
+ 90000,
204
+ },
205
+ b,
206
+ );
207
+ return res.json(); // { ok, base64, mimeType, svgMarkup }
208
+ });
209
+ }
210
+
211
+ // Stream a chosen engine result's bytes to `destPath`. The size-unbounded
212
+ // download: the pane's Socket.IO ack carries only a signed source reference, and
213
+ // the add-in server fetches it and pipes the body here — so nothing buffers the
214
+ // whole image and there is no base64 4/3 inflation. `engineDownload` above sends
215
+ // the bytes through the pane ack instead and is therefore capped by the relay's
216
+ // 10 MB frame limit; prefer this for anything that might be large.
217
+ //
218
+ // An SVG result comes back as JSON (`{ ok, mimeType, svgMarkup }`) rather than a
219
+ // byte stream: it is kilobytes and its recolour happens pane-side. Callers get
220
+ // `{ svgMarkup }` in that case and `{ bytesWritten, mimeType }` otherwise, so they
221
+ // can persist whichever arrived.
222
+ //
223
+ // With NO presId and a resolved `backendURL`/`brandId` this streams pane-free
224
+ // from `GET /api/cli/searches/:searchId/results/:selection/image` — same answer
225
+ // shape, no window involved.
226
+ export async function engineDownloadToFile({
227
+ presId = null,
228
+ searchId,
229
+ selection,
230
+ destPath,
231
+ iconColor = null,
232
+ backendURL = null,
233
+ brandId = null,
234
+ onProgress = null,
235
+ }) {
236
+ const direct = !presId && Boolean(backendURL || brandId);
237
+ if (!presId && !direct) {
238
+ throw new Error("engineDownloadToFile: presId is required");
239
+ }
240
+ assertSearchId("engineDownloadToFile", searchId);
241
+ if (typeof selection !== "number") {
242
+ throw new Error("engineDownloadToFile: selection (number) is required");
243
+ }
244
+ if (!destPath) throw new Error("engineDownloadToFile: destPath is required");
245
+ // Both paths answer with the same two shapes (streamed bytes, or the inline
246
+ // SVG as JSON), so the response shaping is authored once.
247
+ const shape = (res) => {
248
+ if (!res.streamed) {
249
+ // The inline-SVG answer (kilobytes) arrives as JSON, not as a byte stream.
250
+ const parsed = res.body ? JSON.parse(res.body) : {};
251
+ return { ok: true, streamed: false, ...parsed };
252
+ }
253
+ const warningsHeader = res.headers?.["x-slidemind-warnings"];
254
+ let warnings;
255
+ if (warningsHeader) {
256
+ try {
257
+ warnings = JSON.parse(warningsHeader);
258
+ } catch {
259
+ // A malformed header must not fail a download that otherwise succeeded.
260
+ }
261
+ }
262
+ return {
263
+ ok: true,
264
+ streamed: true,
265
+ mimeType: String(res.headers?.["content-type"] ?? "") || null,
266
+ bytesWritten: res.bytesWritten,
267
+ ...(warnings ? { warnings } : {}),
268
+ };
269
+ };
270
+ if (direct) {
271
+ const b = await directEngineBackend("engineDownloadToFile", {
272
+ backendURL,
273
+ brandId,
274
+ });
275
+ // Only the recolour rides the query string — the RESOURCE is never
276
+ // client-supplied on this path; the server resolves it from searchId+selection.
277
+ const params = new URLSearchParams();
278
+ if (iconColor) params.set("iconColor", iconColor);
279
+ const res = await bridgeStreamToFile(
280
+ resultRoute(searchId, selection, "image", params),
281
+ {
282
+ method: "GET",
283
+ headers: await graphHeaders(b),
284
+ timeoutMs: downloadTimeoutMs(),
285
+ onProgress,
286
+ },
287
+ destPath,
288
+ b,
289
+ );
290
+ return shape(res);
291
+ }
292
+ const be = backendURL || (await resolveBackendForPres(presId));
293
+ const body = { searchId, selection };
294
+ if (iconColor) body.iconColor = iconColor;
295
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
296
+ const res = await bridgeStreamToFile(
297
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/download-stream`,
298
+ {
299
+ method: "POST",
300
+ headers: { "content-type": "application/json" },
301
+ body: JSON.stringify(body),
302
+ timeoutMs: downloadTimeoutMs(),
303
+ onProgress,
304
+ },
305
+ destPath,
306
+ b,
307
+ );
308
+ return shape(res);
309
+ });
310
+ }