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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +86 -21
- package/README.md +14 -17
- package/bin/slidemind.mjs +65 -4969
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +113 -74
- package/skills/search/SKILL.md +25 -7
- package/src/bridge/build-and-push.mjs +108 -15
- package/src/bridge/bundle.mjs +69 -0
- package/src/bridge/capture.mjs +19 -0
- package/src/bridge/client/brands.mjs +622 -0
- package/src/bridge/client/engine-routing.mjs +237 -0
- package/src/bridge/client/engine-search.mjs +310 -0
- package/src/bridge/client/engine-slides.mjs +435 -0
- package/src/bridge/client/pane-ops.mjs +335 -0
- package/src/bridge/client/presentations.mjs +587 -0
- package/src/bridge/client/render.mjs +317 -0
- package/src/bridge/client/transport.mjs +695 -0
- package/src/bridge/client.mjs +80 -3452
- package/src/bridge/config.mjs +1 -1
- package/src/bridge/engine-download.mjs +15 -2
- package/src/builders/build.mjs +11 -804
- package/src/builders/draft-check.mjs +376 -0
- package/src/builders/lint.mjs +38 -2
- package/src/builders/preview.html +799 -0
- package/src/builders/review.mjs +23 -4
- package/src/builders/visual-families.mjs +6 -8
- package/src/cli/common.mjs +179 -0
- package/src/cli/concepts.mjs +310 -0
- package/src/cli/connection.mjs +147 -0
- package/src/cli/deck.mjs +608 -0
- package/src/cli/designs.mjs +113 -0
- package/src/cli/diagnose.mjs +141 -0
- package/src/cli/download-bundle.mjs +144 -0
- package/src/cli/engine-helpers.mjs +429 -0
- package/src/cli/engine-results.mjs +320 -0
- package/src/cli/help.mjs +201 -0
- package/src/cli/install.mjs +257 -0
- package/src/cli/read.mjs +120 -0
- package/src/cli/ready.mjs +805 -0
- package/src/cli/search.mjs +608 -0
- package/src/cli/session.mjs +177 -0
- package/src/cli/setup.mjs +282 -0
- package/src/cli/slide-reuse.mjs +334 -0
- package/src/cli/status.mjs +118 -0
- package/src/cli/workspace.mjs +302 -0
- package/src/installers/codex.mjs +1 -1
- package/src/update-check.mjs +231 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
// Brand routes: design bundles, brand listing, concept images, image generation,
|
|
2
|
+
// convert-pptx, and the engine catalog.
|
|
3
|
+
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { deriveEnvLabel } from "../config.mjs";
|
|
7
|
+
import { resolveBackendForPres } from "./presentations.mjs";
|
|
8
|
+
import { listTimeoutMs, engineTimeoutMs, withTransientRetry, sessionsInScope, sessionForBackend, bridgeFetch, backendCapabilities, backendSupportsBrandRoutes, backendSupportsConceptImages, backendSupportsImageGeneration, backendSupportsBrandsList, graphHeaders } from "./transport.mjs";
|
|
9
|
+
|
|
10
|
+
// GET the full design bundle (tokens, css, layouts, designRules, signed asset
|
|
11
|
+
// URLs, engines) for the presentation's active corporate design.
|
|
12
|
+
export async function getDesignBundle(
|
|
13
|
+
presId,
|
|
14
|
+
{ backendURL = null, preferBrandId = null } = {},
|
|
15
|
+
) {
|
|
16
|
+
if (!presId) throw new Error("getDesignBundle: presId is required");
|
|
17
|
+
const be =
|
|
18
|
+
backendURL || (await resolveBackendForPres(presId, { preferBrandId }));
|
|
19
|
+
return withTransientRetry(async () => {
|
|
20
|
+
const res = await bridgeFetch(
|
|
21
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/design-bundle`,
|
|
22
|
+
{},
|
|
23
|
+
be,
|
|
24
|
+
);
|
|
25
|
+
return res.json();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// GET the design bundle by brandId over the pane-free brand route. 404
|
|
30
|
+
// `unknown_brand` is definitive (unknown / cross-tenant / not granted to this
|
|
31
|
+
// user), so no transient retry. Requires the resolved backendURL — use
|
|
32
|
+
// getDesignBundleForBrandAnyBackend when it isn't known yet.
|
|
33
|
+
export async function getDesignBundleForBrand(brandId, { backendURL } = {}) {
|
|
34
|
+
if (!brandId) {
|
|
35
|
+
throw new Error("getDesignBundleForBrand: brandId is required");
|
|
36
|
+
}
|
|
37
|
+
if (!backendURL) {
|
|
38
|
+
throw new Error("getDesignBundleForBrand: backendURL is required");
|
|
39
|
+
}
|
|
40
|
+
const res = await bridgeFetch(
|
|
41
|
+
`/api/cli/brands/${encodeURIComponent(brandId)}/design-bundle`,
|
|
42
|
+
{},
|
|
43
|
+
backendURL,
|
|
44
|
+
);
|
|
45
|
+
return res.json();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Resolve WHICH paired backend serves a brand by asking each one that
|
|
49
|
+
// advertises brand routes — a bound deck has no open window, so there is no
|
|
50
|
+
// registry entry to resolve a backend from. presFP/brandId is globally unique
|
|
51
|
+
// across deployments (the bundle-cache contract), so at most one backend
|
|
52
|
+
// answers 2xx. Returns { wire, backendURL }; throws a typed
|
|
53
|
+
// `brand_route_unavailable` when no backend serves the brand (old add-ins,
|
|
54
|
+
// unknown brand, missing grant) so callers fall back to the pane flow.
|
|
55
|
+
export async function getDesignBundleForBrandAnyBackend(brandId) {
|
|
56
|
+
if (!brandId) {
|
|
57
|
+
throw new Error("getDesignBundleForBrandAnyBackend: brandId is required");
|
|
58
|
+
}
|
|
59
|
+
const sessions = sessionsInScope();
|
|
60
|
+
const failures = [];
|
|
61
|
+
for (const s of sessions) {
|
|
62
|
+
try {
|
|
63
|
+
if (!(await backendSupportsBrandRoutes(s.backendURL))) {
|
|
64
|
+
failures.push({ env: deriveEnvLabel(s.backendURL), reason: "no brand routes" });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const wire = await getDesignBundleForBrand(brandId, {
|
|
68
|
+
backendURL: s.backendURL,
|
|
69
|
+
});
|
|
70
|
+
return { wire, backendURL: s.backendURL };
|
|
71
|
+
} catch (err) {
|
|
72
|
+
failures.push({
|
|
73
|
+
env: deriveEnvLabel(s.backendURL),
|
|
74
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
|
|
79
|
+
const err = new Error(
|
|
80
|
+
`no paired backend serves design '${brandId}' over the pane-free brand ` +
|
|
81
|
+
`route${detail ? ` (${detail})` : ""}.`,
|
|
82
|
+
);
|
|
83
|
+
err.code = "brand_route_unavailable";
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The corporate designs this user may build against, straight off the CLI
|
|
88
|
+
// session — no open window, no pane. One entry per design: `{ presFP, name,
|
|
89
|
+
// env }`, where presFP IS the brandId every pane-free route is keyed by. Only
|
|
90
|
+
// backends advertising `brandsList` are asked (older ones have no such route);
|
|
91
|
+
// a backend that fails is skipped rather than failing the whole answer, so one
|
|
92
|
+
// unreachable environment can't hide another's designs.
|
|
93
|
+
export async function listBrands() {
|
|
94
|
+
const out = [];
|
|
95
|
+
for (const s of sessionsInScope()) {
|
|
96
|
+
if (!(await backendSupportsBrandsList(s.backendURL))) continue;
|
|
97
|
+
const env = deriveEnvLabel(s.backendURL);
|
|
98
|
+
try {
|
|
99
|
+
const res = await bridgeFetch(
|
|
100
|
+
"/api/cli/brands",
|
|
101
|
+
{ timeoutMs: listTimeoutMs() },
|
|
102
|
+
s.backendURL,
|
|
103
|
+
);
|
|
104
|
+
const data = (await res.json()) ?? {};
|
|
105
|
+
const arr = Array.isArray(data.brands) ? data.brands : [];
|
|
106
|
+
for (const b of arr) {
|
|
107
|
+
// The wire may name the design by either of its two equivalent keys
|
|
108
|
+
// (presFP on the bundle contract, brandId on the routes) — normalize to
|
|
109
|
+
// the one the deck ref stores.
|
|
110
|
+
const presFP = b?.presFP ?? b?.brandId ?? b?.id ?? null;
|
|
111
|
+
if (!presFP) continue;
|
|
112
|
+
out.push({
|
|
113
|
+
presFP,
|
|
114
|
+
name: b?.name ?? b?.designName ?? null,
|
|
115
|
+
env,
|
|
116
|
+
backendURL: s.backendURL,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
/* unreachable / not granted — that environment simply contributes none */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Concept-image generation is a heavy provider call (per-image cap ~180s
|
|
127
|
+
// server-side). Budget just over that so a typed server error wins over a
|
|
128
|
+
// client timeout, but keep it below the relay's ~240s ingress cap — the CLI
|
|
129
|
+
// batches per-slide so no single request approaches that ceiling.
|
|
130
|
+
export function conceptTimeoutMs() {
|
|
131
|
+
return (
|
|
132
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_CONCEPT_TIMEOUT_MS || "200000", 10) ||
|
|
133
|
+
200000
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function postConceptImages(
|
|
138
|
+
brandId,
|
|
139
|
+
{ planMarkdown, slideNumbers, references, force },
|
|
140
|
+
backendURL,
|
|
141
|
+
) {
|
|
142
|
+
const body = { planMarkdown };
|
|
143
|
+
// `--force`: the server keeps a per-plan mockup cache; an unchanged plan
|
|
144
|
+
// would otherwise hand back last time's images no matter what changed
|
|
145
|
+
// server-side (prompt, chrome, model).
|
|
146
|
+
if (force) body.force = true;
|
|
147
|
+
if (Array.isArray(slideNumbers) && slideNumbers.length > 0) {
|
|
148
|
+
body.slideNumbers = slideNumbers;
|
|
149
|
+
}
|
|
150
|
+
// The user's own images, inline: `{ role: "concept" | "style", image:
|
|
151
|
+
// <data URI>, slides?: number[], name? }`. A concept replaces the generated
|
|
152
|
+
// mockup for its slides; a style reference leads the generation.
|
|
153
|
+
if (Array.isArray(references) && references.length > 0) {
|
|
154
|
+
body.references = references;
|
|
155
|
+
}
|
|
156
|
+
const res = await bridgeFetch(
|
|
157
|
+
`/api/cli/brands/${encodeURIComponent(brandId)}/concept-images`,
|
|
158
|
+
{
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: { "content-type": "application/json" },
|
|
161
|
+
body: JSON.stringify(body),
|
|
162
|
+
timeoutMs: conceptTimeoutMs(),
|
|
163
|
+
},
|
|
164
|
+
backendURL,
|
|
165
|
+
);
|
|
166
|
+
const data = (await res.json()) ?? {};
|
|
167
|
+
// Tag the response with the backend that answered so a per-slide batch can
|
|
168
|
+
// pin subsequent calls to the same one (routing state only — never printed).
|
|
169
|
+
return { ...data, backendURL };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Generate concept mockup images for a brand over the pane-free brand route.
|
|
173
|
+
// Body `{ planMarkdown, slideNumbers?, references? }`; the optional `slideNumbers`
|
|
174
|
+
// filters which storyboard beats are rendered (the CLI batches per-slide);
|
|
175
|
+
// `references` carries the user's own concept/style images inline. Returns the
|
|
176
|
+
// server's `{ ok, images, slideCount }` augmented with the resolved
|
|
177
|
+
// `backendURL`. When `backendURL` is given it posts directly; otherwise it
|
|
178
|
+
// fans out — like getDesignBundleForBrandAnyBackend, presFP/brandId is globally
|
|
179
|
+
// unique so at most one paired backend answers 2xx. Throws a typed
|
|
180
|
+
// `concept_route_unavailable` when no backend serves the brand (old add-ins,
|
|
181
|
+
// unknown brand, missing grant) so callers can degrade to "continue without".
|
|
182
|
+
export async function generateConceptImagesForBrand(
|
|
183
|
+
brandId,
|
|
184
|
+
{
|
|
185
|
+
planMarkdown,
|
|
186
|
+
slideNumbers = null,
|
|
187
|
+
references = null,
|
|
188
|
+
backendURL = null,
|
|
189
|
+
force = false,
|
|
190
|
+
} = {},
|
|
191
|
+
) {
|
|
192
|
+
if (!brandId) {
|
|
193
|
+
throw new Error("generateConceptImagesForBrand: brandId is required");
|
|
194
|
+
}
|
|
195
|
+
if (typeof planMarkdown !== "string" || !planMarkdown.trim()) {
|
|
196
|
+
throw new Error("generateConceptImagesForBrand: planMarkdown is required");
|
|
197
|
+
}
|
|
198
|
+
if (backendURL) {
|
|
199
|
+
return postConceptImages(
|
|
200
|
+
brandId,
|
|
201
|
+
{ planMarkdown, slideNumbers, references, force },
|
|
202
|
+
backendURL,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const sessions = sessionsInScope();
|
|
206
|
+
const failures = [];
|
|
207
|
+
for (const s of sessions) {
|
|
208
|
+
try {
|
|
209
|
+
if (!(await backendSupportsConceptImages(s.backendURL))) {
|
|
210
|
+
failures.push({
|
|
211
|
+
env: deriveEnvLabel(s.backendURL),
|
|
212
|
+
reason: "no concept-images route",
|
|
213
|
+
});
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
return await postConceptImages(
|
|
217
|
+
brandId,
|
|
218
|
+
{ planMarkdown, slideNumbers, references, force },
|
|
219
|
+
s.backendURL,
|
|
220
|
+
);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
failures.push({
|
|
223
|
+
env: deriveEnvLabel(s.backendURL),
|
|
224
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
|
|
229
|
+
const err = new Error(
|
|
230
|
+
`no paired backend generates concept mockups for design '${brandId}'` +
|
|
231
|
+
`${detail ? ` (${detail})` : ""}.`,
|
|
232
|
+
);
|
|
233
|
+
err.code = "concept_route_unavailable";
|
|
234
|
+
throw err;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Which connected backend generates concept mockups. One CAPABILITIES probe per
|
|
238
|
+
// session answers it — cached, no image generated — which is why `concepts` can
|
|
239
|
+
// go straight to its waves instead of spending a throwaway single-slide request
|
|
240
|
+
// to learn who answers. Returns the one backend to pin a per-slide batch to, or
|
|
241
|
+
// null when several environments qualify (developer machines only; each request
|
|
242
|
+
// then fans out as before). Throws the typed `concept_route_unavailable` when
|
|
243
|
+
// none does, so the caller degrades to "continue without".
|
|
244
|
+
export async function resolveConceptBackend() {
|
|
245
|
+
const sessions = sessionsInScope();
|
|
246
|
+
const capable = [];
|
|
247
|
+
const failures = [];
|
|
248
|
+
for (const s of sessions) {
|
|
249
|
+
if (await backendSupportsConceptImages(s.backendURL)) {
|
|
250
|
+
capable.push(s.backendURL);
|
|
251
|
+
} else {
|
|
252
|
+
failures.push(`${deriveEnvLabel(s.backendURL)}: no concept-images route`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (capable.length === 0) {
|
|
256
|
+
const detail = failures.join("; ");
|
|
257
|
+
const err = new Error(
|
|
258
|
+
"no paired backend generates concept mockups" +
|
|
259
|
+
`${detail ? ` (${detail})` : ""}.`,
|
|
260
|
+
);
|
|
261
|
+
err.code = "concept_route_unavailable";
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
264
|
+
return capable.length === 1 ? capable[0] : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// gpt-image calls routinely take 60-120s; budget like the concept relay so a
|
|
268
|
+
// typed server error wins over a client timeout.
|
|
269
|
+
export function imagenTimeoutMs() {
|
|
270
|
+
return (
|
|
271
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_IMAGEN_TIMEOUT_MS || "200000", 10) ||
|
|
272
|
+
200000
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function postGenerateImage(brandId, body, backendURL) {
|
|
277
|
+
const res = await bridgeFetch(
|
|
278
|
+
`/api/cli/brands/${encodeURIComponent(brandId)}/images`,
|
|
279
|
+
{
|
|
280
|
+
method: "POST",
|
|
281
|
+
headers: { "content-type": "application/json" },
|
|
282
|
+
body: JSON.stringify(body),
|
|
283
|
+
timeoutMs: imagenTimeoutMs(),
|
|
284
|
+
},
|
|
285
|
+
backendURL,
|
|
286
|
+
);
|
|
287
|
+
const data = (await res.json()) ?? {};
|
|
288
|
+
return { ...data, backendURL };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Generate ONE regular image over the pane-free brand route
|
|
292
|
+
// (`slidemind imagen`). Body `{ prompt, size?, quality? }`;
|
|
293
|
+
// returns the server's `{ ok, image }` augmented with the
|
|
294
|
+
// resolved `backendURL`. Fan-out and degradation mirror
|
|
295
|
+
// generateConceptImagesForBrand: throws a typed `imagen_route_unavailable`
|
|
296
|
+
// when no paired backend serves the route (older add-ins, unknown brand,
|
|
297
|
+
// missing grant) so callers can print the one actionable fix.
|
|
298
|
+
export async function generateImageForBrand(
|
|
299
|
+
brandId,
|
|
300
|
+
{ prompt, size = null, quality = null, backendURL = null } = {},
|
|
301
|
+
) {
|
|
302
|
+
if (!brandId) {
|
|
303
|
+
throw new Error("generateImageForBrand: brandId is required");
|
|
304
|
+
}
|
|
305
|
+
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
306
|
+
throw new Error("generateImageForBrand: prompt is required");
|
|
307
|
+
}
|
|
308
|
+
const body = { prompt };
|
|
309
|
+
if (size) body.size = size;
|
|
310
|
+
if (quality) body.quality = quality;
|
|
311
|
+
if (backendURL) {
|
|
312
|
+
return postGenerateImage(brandId, body, backendURL);
|
|
313
|
+
}
|
|
314
|
+
const sessions = sessionsInScope();
|
|
315
|
+
const failures = [];
|
|
316
|
+
for (const s of sessions) {
|
|
317
|
+
try {
|
|
318
|
+
if (!(await backendSupportsImageGeneration(s.backendURL))) {
|
|
319
|
+
failures.push({
|
|
320
|
+
env: deriveEnvLabel(s.backendURL),
|
|
321
|
+
reason: "no image-generation route (update the add-in)",
|
|
322
|
+
});
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
return await postGenerateImage(brandId, body, s.backendURL);
|
|
326
|
+
} catch (err) {
|
|
327
|
+
failures.push({
|
|
328
|
+
env: deriveEnvLabel(s.backendURL),
|
|
329
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
|
|
334
|
+
const err = new Error(
|
|
335
|
+
`no paired backend generates images for design '${brandId}'` +
|
|
336
|
+
`${detail ? ` (${detail})` : ""}.`,
|
|
337
|
+
);
|
|
338
|
+
err.code = "imagen_route_unavailable";
|
|
339
|
+
throw err;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Deterministic PPTX→JSX conversion is a per-slide OOXML parse + a render pass
|
|
343
|
+
// per converted slide — a whole foreign deck runs in render-grade time, not
|
|
344
|
+
// list-grade. Budget like the render routes.
|
|
345
|
+
export function convertTimeoutMs() {
|
|
346
|
+
return (
|
|
347
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_CONVERT_TIMEOUT_MS || "300000", 10) ||
|
|
348
|
+
300000
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Minimal multipart/form-data body. The route takes an uploaded PPTX, and
|
|
353
|
+
// nodeRequest writes a Buffer verbatim — no fetch/FormData involved, so the
|
|
354
|
+
// envelope is assembled here (fields first, file last, boundary never appearing
|
|
355
|
+
// in the parts because it carries a random suffix).
|
|
356
|
+
export function multipartBody(fields, file) {
|
|
357
|
+
const boundary = `----slidemind${randomUUID().replace(/-/g, "")}`;
|
|
358
|
+
const chunks = [];
|
|
359
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
360
|
+
if (value === null || value === undefined) continue;
|
|
361
|
+
chunks.push(
|
|
362
|
+
Buffer.from(
|
|
363
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`,
|
|
364
|
+
),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
// The file name reaches this from a path on disk; a quote or a newline in it
|
|
368
|
+
// would break out of the Content-Disposition header and forge a part.
|
|
369
|
+
const safeFileName = String(file.fileName).replace(/[\r\n"\\]/g, "_");
|
|
370
|
+
chunks.push(
|
|
371
|
+
Buffer.from(
|
|
372
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="${file.name}"; ` +
|
|
373
|
+
`filename="${safeFileName}"\r\nContent-Type: ${file.contentType}\r\n\r\n`,
|
|
374
|
+
),
|
|
375
|
+
Buffer.from(file.bytes),
|
|
376
|
+
Buffer.from(`\r\n--${boundary}--\r\n`),
|
|
377
|
+
);
|
|
378
|
+
return {
|
|
379
|
+
body: Buffer.concat(chunks),
|
|
380
|
+
contentType: `multipart/form-data; boundary=${boundary}`,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Whether a backend deterministically converts foreign PPTX slides to jsx
|
|
385
|
+
// (`POST /api/cli/brands/:brandId/convert-pptx`). Advertised only by add-ins
|
|
386
|
+
// running with the reverse-conversion flag on; everywhere else `slidemind
|
|
387
|
+
// adopt` folds untagged slides in as native .pptx exactly as before.
|
|
388
|
+
export async function backendSupportsConvertPptx(backendURL) {
|
|
389
|
+
const caps = await backendCapabilities(backendURL);
|
|
390
|
+
return caps?.convertPptx === true;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export async function postConvertPptx(brandId, { bytes, fileName, slides }, backendURL) {
|
|
394
|
+
const { body, contentType } = multipartBody(
|
|
395
|
+
{
|
|
396
|
+
// 1-based play-order slide numbers to convert. Omitted = the whole deck;
|
|
397
|
+
// `adopt` always names the untagged ones so tagged slides are not re-parsed.
|
|
398
|
+
slides: Array.isArray(slides) && slides.length > 0 ? slides.join(",") : null,
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
name: "file",
|
|
402
|
+
fileName: path.basename(fileName || "deck.pptx"),
|
|
403
|
+
contentType:
|
|
404
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
405
|
+
bytes,
|
|
406
|
+
},
|
|
407
|
+
);
|
|
408
|
+
const res = await bridgeFetch(
|
|
409
|
+
`/api/cli/brands/${encodeURIComponent(brandId)}/convert-pptx`,
|
|
410
|
+
{
|
|
411
|
+
method: "POST",
|
|
412
|
+
headers: { "content-type": contentType },
|
|
413
|
+
body,
|
|
414
|
+
timeoutMs: convertTimeoutMs(),
|
|
415
|
+
},
|
|
416
|
+
backendURL,
|
|
417
|
+
);
|
|
418
|
+
const data = (await res.json()) ?? {};
|
|
419
|
+
return { ...data, backendURL };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Convert a FILE deck's foreign slides to converted-dialect jsx over the
|
|
423
|
+
// pane-free brand route (`slidemind adopt`). Body is multipart: the .pptx plus
|
|
424
|
+
// the 1-based slide numbers to convert. Returns the server's
|
|
425
|
+
// `{ slides, layouts, assets, sourceBaselines, conversionEvidence, skipped,
|
|
426
|
+
// warnings }` (add-in `ConvertPptxResponse`) augmented with the resolved
|
|
427
|
+
// `backendURL`. Identity and evidence are keyed by each slide's jsx `path`, not
|
|
428
|
+
// carried on the slide — `src/lib/adopt.mjs` `convertedByNumber()` is the one
|
|
429
|
+
// place that key shape is unfolded.
|
|
430
|
+
//
|
|
431
|
+
// LIVE decks are NOT convertible through this route in v1: converting an open
|
|
432
|
+
// presentation needs a slide export + tag read over the relay, and neither
|
|
433
|
+
// event exists (design D9) — the add-in path owns that flow.
|
|
434
|
+
//
|
|
435
|
+
// Fan-out and degradation mirror generateConceptImagesForBrand: throws a typed
|
|
436
|
+
// `convert_route_unavailable` when no paired backend serves the route (older
|
|
437
|
+
// add-ins, the reverse flag off, unknown brand, missing grant) so `adopt` falls
|
|
438
|
+
// back to the native fold instead of failing.
|
|
439
|
+
export async function convertPptxForBrand(
|
|
440
|
+
brandId,
|
|
441
|
+
{ bytes, fileName = "deck.pptx", slides = null, backendURL = null } = {},
|
|
442
|
+
) {
|
|
443
|
+
if (!brandId) throw new Error("convertPptxForBrand: brandId is required");
|
|
444
|
+
if (!bytes) throw new Error("convertPptxForBrand: bytes is required");
|
|
445
|
+
if (backendURL) {
|
|
446
|
+
return postConvertPptx(brandId, { bytes, fileName, slides }, backendURL);
|
|
447
|
+
}
|
|
448
|
+
const sessions = sessionsInScope();
|
|
449
|
+
const failures = [];
|
|
450
|
+
for (const s of sessions) {
|
|
451
|
+
try {
|
|
452
|
+
if (!(await backendSupportsConvertPptx(s.backendURL))) {
|
|
453
|
+
failures.push({
|
|
454
|
+
env: deriveEnvLabel(s.backendURL),
|
|
455
|
+
reason: "no convert-pptx route",
|
|
456
|
+
});
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
return await postConvertPptx(
|
|
460
|
+
brandId,
|
|
461
|
+
{ bytes, fileName, slides },
|
|
462
|
+
s.backendURL,
|
|
463
|
+
);
|
|
464
|
+
} catch (err) {
|
|
465
|
+
failures.push({
|
|
466
|
+
env: deriveEnvLabel(s.backendURL),
|
|
467
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
|
|
472
|
+
const err = new Error(
|
|
473
|
+
`no paired backend converts foreign slides for design '${brandId}'` +
|
|
474
|
+
`${detail ? ` (${detail})` : ""}.`,
|
|
475
|
+
);
|
|
476
|
+
err.code = "convert_route_unavailable";
|
|
477
|
+
throw err;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// List the LIVE engine catalog (NOT the bundle's curated set) for the paired
|
|
481
|
+
// backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
|
|
482
|
+
// when several environments are paired this fans out and tags each engine with
|
|
483
|
+
// its short `env` label (never the backendURL — the no-URL rule). Filter by
|
|
484
|
+
// engine `type` (collection|search) and/or `subtype` (image|slide|template|
|
|
485
|
+
// content). Returns a flat `{ engines: [...], catalog }` like the single-backend
|
|
486
|
+
// route.
|
|
487
|
+
//
|
|
488
|
+
// `catalog` is the pane-free route's summary — `{totalEngines, totalScopes,
|
|
489
|
+
// filtered, emptyEngines}`, counted BEFORE the type/subtype filter — and it is
|
|
490
|
+
// what separates "this filter matched nothing" from "this deployment has no
|
|
491
|
+
// engines". Older backends (and the pane-relay path) send none; it stays null
|
|
492
|
+
// there and every caller falls back to its pre-summary wording.
|
|
493
|
+
export function readCatalogSummary(data) {
|
|
494
|
+
const c = data?.catalog;
|
|
495
|
+
if (!c || typeof c !== "object" || Array.isArray(c)) return null;
|
|
496
|
+
const num = (v) => (Number.isFinite(v) ? v : 0);
|
|
497
|
+
return {
|
|
498
|
+
totalEngines: num(c.totalEngines),
|
|
499
|
+
totalScopes: num(c.totalScopes),
|
|
500
|
+
filtered: Boolean(c.filtered),
|
|
501
|
+
emptyEngines: num(c.emptyEngines),
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export async function listEnginesForBackend(backendURL, { type = null, subtype = null, signIn = true } = {}) {
|
|
506
|
+
return withTransientRetry(async () => {
|
|
507
|
+
const params = new URLSearchParams();
|
|
508
|
+
if (type) params.set("type", type);
|
|
509
|
+
if (subtype) params.set("subtype", subtype);
|
|
510
|
+
const qs = params.toString();
|
|
511
|
+
const res = await bridgeFetch(
|
|
512
|
+
`/api/cli/engines${qs ? `?${qs}` : ""}`,
|
|
513
|
+
// Pane-relayed on the add-in side — needs the engine budget, not the 8s
|
|
514
|
+
// list timeout (which bailed before the pane could ever answer). The
|
|
515
|
+
// delegated tokens authorize the pane-free catalog (login_required
|
|
516
|
+
// without them); older add-ins ignore the headers.
|
|
517
|
+
{ timeoutMs: engineTimeoutMs(), headers: await graphHeaders(backendURL, { signIn }) },
|
|
518
|
+
backendURL,
|
|
519
|
+
);
|
|
520
|
+
const data = await res.json();
|
|
521
|
+
const arr = Array.isArray(data?.engines) ? data.engines : [];
|
|
522
|
+
const env = deriveEnvLabel(backendURL);
|
|
523
|
+
return {
|
|
524
|
+
engines: arr.map((e) => ({ ...e, env })),
|
|
525
|
+
catalog: readCatalogSummary(data),
|
|
526
|
+
};
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// A scope (scope:all / scope:sharepoint) is not one environment's object: it is
|
|
531
|
+
// the same saved search target, named identically on every deployment. Pooling
|
|
532
|
+
// two backends' catalogs therefore repeats it verbatim — same id, same label —
|
|
533
|
+
// which reads as two different things to choose between. Real engines DO differ
|
|
534
|
+
// per environment and keep their duplicates; their `env` tag tells them apart.
|
|
535
|
+
export function isScopeRow(e) {
|
|
536
|
+
return e?.type === "scope" || /^scope:/.test(String(e?.id ?? ""));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export async function listEngines({ type = null, subtype = null, backendURL = null } = {}) {
|
|
540
|
+
const sessions = backendURL ? [sessionForBackend(backendURL)] : sessionsInScope();
|
|
541
|
+
// Fanned out over several environments, the listing must not block on a
|
|
542
|
+
// browser sign-in for one of them — the user asked for a catalog, not for
|
|
543
|
+
// `local`'s Microsoft 365 login. One environment in scope keeps the
|
|
544
|
+
// automatic sign-in (that IS the environment being used).
|
|
545
|
+
const signIn = sessions.length === 1;
|
|
546
|
+
const settled = await Promise.allSettled(
|
|
547
|
+
sessions.map((s) => listEnginesForBackend(s.backendURL, { type, subtype, signIn })),
|
|
548
|
+
);
|
|
549
|
+
const engines = [];
|
|
550
|
+
const catalogs = [];
|
|
551
|
+
const failures = [];
|
|
552
|
+
// Which environments actually contributed. NOT derivable from the pooled rows
|
|
553
|
+
// any more: a second environment that only repeated the scopes leaves no row
|
|
554
|
+
// of its own, and the human renderer keys its `[env]` tags off this.
|
|
555
|
+
const envs = [];
|
|
556
|
+
// Scope id → the one pooled row kept for it.
|
|
557
|
+
const scopeRows = new Map();
|
|
558
|
+
let fulfilledCount = 0;
|
|
559
|
+
settled.forEach((r, i) => {
|
|
560
|
+
if (r.status === "fulfilled") {
|
|
561
|
+
fulfilledCount += 1;
|
|
562
|
+
envs.push(deriveEnvLabel(sessions[i].backendURL));
|
|
563
|
+
for (const e of r.value.engines) {
|
|
564
|
+
const id = typeof e?.id === "string" ? e.id : null;
|
|
565
|
+
if (id && isScopeRow(e)) {
|
|
566
|
+
const kept = scopeRows.get(id);
|
|
567
|
+
if (kept) {
|
|
568
|
+
// Contributed by more than one environment, so it belongs to none
|
|
569
|
+
// of them in particular — drop the tag rather than crediting
|
|
570
|
+
// whichever backend happened to answer first.
|
|
571
|
+
kept.env = null;
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
const row = { ...e };
|
|
575
|
+
scopeRows.set(id, row);
|
|
576
|
+
engines.push(row);
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
engines.push(e);
|
|
580
|
+
}
|
|
581
|
+
if (r.value.catalog) catalogs.push(r.value.catalog);
|
|
582
|
+
} else {
|
|
583
|
+
failures.push({ env: deriveEnvLabel(sessions[i].backendURL), err: r.reason });
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
// Mirror listOpenPresentations: only surface an error when nothing answered.
|
|
587
|
+
if (engines.length === 0 && fulfilledCount === 0 && failures.length > 0) {
|
|
588
|
+
if (failures.length === 1) throw failures[0].err;
|
|
589
|
+
throw new Error(
|
|
590
|
+
"slidemind: every paired backend failed to list engines:\n" +
|
|
591
|
+
failures.map((f) => ` ${f.env}: ${f.err.message}`).join("\n"),
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
// Fanned out, the summary is the sum of what each environment reported: the
|
|
595
|
+
// engines are pooled into one list, so their counts pool too. SCOPES are the
|
|
596
|
+
// exception — the same named targets everywhere, deduped above — so summing
|
|
597
|
+
// them double-counts; the distinct total is the largest environment's count,
|
|
598
|
+
// or the number of distinct scope rows actually pooled when that is larger.
|
|
599
|
+
// (The catalog counts are pre-filter and the rows post-filter, which is why
|
|
600
|
+
// both terms are needed.) `filtered` is the same question asked of every
|
|
601
|
+
// backend, so any one of them saying yes is the answer. Null when no backend
|
|
602
|
+
// supplied one (old add-in / pane relay).
|
|
603
|
+
const catalog = catalogs.length
|
|
604
|
+
? {
|
|
605
|
+
totalEngines: catalogs.reduce((n, c) => n + c.totalEngines, 0),
|
|
606
|
+
totalScopes: Math.max(...catalogs.map((c) => c.totalScopes), scopeRows.size),
|
|
607
|
+
filtered: catalogs.some((c) => c.filtered),
|
|
608
|
+
emptyEngines: catalogs.reduce((n, c) => n + c.emptyEngines, 0),
|
|
609
|
+
}
|
|
610
|
+
: null;
|
|
611
|
+
return {
|
|
612
|
+
engines,
|
|
613
|
+
catalog,
|
|
614
|
+
envs,
|
|
615
|
+
failures: failures.map((f) => ({
|
|
616
|
+
env: f.env,
|
|
617
|
+
message: /login_required/.test(f.err?.message ?? "") || f.err?.code === "login_required"
|
|
618
|
+
? `Microsoft 365 sign-in needed (run 'slidemind login --backend ${f.env}')`
|
|
619
|
+
: f.err?.message ?? String(f.err),
|
|
620
|
+
})),
|
|
621
|
+
};
|
|
622
|
+
}
|