slidemind 0.2.4 → 0.3.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.
@@ -16,9 +16,16 @@ import { fileURLToPath } from "node:url";
16
16
 
17
17
  import {
18
18
  deriveEnvLabel,
19
+ envPhrase,
19
20
  getSessionFor,
21
+ isMultiEnv,
20
22
  readSessions,
21
23
  } from "./config.mjs";
24
+ import {
25
+ ensureSignedIn,
26
+ getGraphToken,
27
+ getSharePointToken,
28
+ } from "./graph-auth.mjs";
22
29
  import { updateCliInstruction } from "../install-channel.mjs";
23
30
 
24
31
  // This CLI's own version, for the add-in's minCliVersion floor (see
@@ -254,7 +261,7 @@ async function bridgeFetch(pathPart, init = {}, backendURL = null) {
254
261
  } catch (err) {
255
262
  if (err && /ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(String(err.message || err))) {
256
263
  throw new Error(
257
- `slidemind relay unreachable (${deriveEnvLabel(session.backendURL)}). Check your connection; ` +
264
+ `slidemind relay unreachable${envPhrase(deriveEnvLabel(session.backendURL), "on")}. Check your connection; ` +
258
265
  "if it persists, reconnect from the add-in's Connect CLI panel.",
259
266
  );
260
267
  }
@@ -470,7 +477,7 @@ async function bridgeStreamToFile(pathPart, init, destPath, backendURL = null) {
470
477
  )
471
478
  ) {
472
479
  throw new Error(
473
- `slidemind relay unreachable (${deriveEnvLabel(session.backendURL)}). Check your connection; ` +
480
+ `slidemind relay unreachable${envPhrase(deriveEnvLabel(session.backendURL), "on")}. Check your connection; ` +
474
481
  "if it persists, reconnect from the add-in's Connect CLI panel.",
475
482
  );
476
483
  }
@@ -1258,6 +1265,144 @@ export async function generateImageForBrand(
1258
1265
  throw err;
1259
1266
  }
1260
1267
 
1268
+ // Deterministic PPTX→JSX conversion is a per-slide OOXML parse + a render pass
1269
+ // per converted slide — a whole foreign deck runs in render-grade time, not
1270
+ // list-grade. Budget like the render routes.
1271
+ function convertTimeoutMs() {
1272
+ return (
1273
+ parseInt(process.env.SLIDEMIND_BRIDGE_CONVERT_TIMEOUT_MS || "300000", 10) ||
1274
+ 300000
1275
+ );
1276
+ }
1277
+
1278
+ // Minimal multipart/form-data body. The route takes an uploaded PPTX, and
1279
+ // nodeRequest writes a Buffer verbatim — no fetch/FormData involved, so the
1280
+ // envelope is assembled here (fields first, file last, boundary never appearing
1281
+ // in the parts because it carries a random suffix).
1282
+ function multipartBody(fields, file) {
1283
+ const boundary = `----slidemind${randomUUID().replace(/-/g, "")}`;
1284
+ const chunks = [];
1285
+ for (const [name, value] of Object.entries(fields)) {
1286
+ if (value === null || value === undefined) continue;
1287
+ chunks.push(
1288
+ Buffer.from(
1289
+ `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`,
1290
+ ),
1291
+ );
1292
+ }
1293
+ // The file name reaches this from a path on disk; a quote or a newline in it
1294
+ // would break out of the Content-Disposition header and forge a part.
1295
+ const safeFileName = String(file.fileName).replace(/[\r\n"\\]/g, "_");
1296
+ chunks.push(
1297
+ Buffer.from(
1298
+ `--${boundary}\r\nContent-Disposition: form-data; name="${file.name}"; ` +
1299
+ `filename="${safeFileName}"\r\nContent-Type: ${file.contentType}\r\n\r\n`,
1300
+ ),
1301
+ Buffer.from(file.bytes),
1302
+ Buffer.from(`\r\n--${boundary}--\r\n`),
1303
+ );
1304
+ return {
1305
+ body: Buffer.concat(chunks),
1306
+ contentType: `multipart/form-data; boundary=${boundary}`,
1307
+ };
1308
+ }
1309
+
1310
+ // Whether a backend deterministically converts foreign PPTX slides to jsx
1311
+ // (`POST /api/cli/brands/:brandId/convert-pptx`). Advertised only by add-ins
1312
+ // running with the reverse-conversion flag on; everywhere else `slidemind
1313
+ // adopt` folds untagged slides in as native .pptx exactly as before.
1314
+ export async function backendSupportsConvertPptx(backendURL) {
1315
+ const caps = await backendCapabilities(backendURL);
1316
+ return caps?.convertPptx === true;
1317
+ }
1318
+
1319
+ async function postConvertPptx(brandId, { bytes, fileName, slides }, backendURL) {
1320
+ const { body, contentType } = multipartBody(
1321
+ {
1322
+ // 1-based play-order slide numbers to convert. Omitted = the whole deck;
1323
+ // `adopt` always names the untagged ones so tagged slides are not re-parsed.
1324
+ slides: Array.isArray(slides) && slides.length > 0 ? slides.join(",") : null,
1325
+ },
1326
+ {
1327
+ name: "file",
1328
+ fileName: path.basename(fileName || "deck.pptx"),
1329
+ contentType:
1330
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
1331
+ bytes,
1332
+ },
1333
+ );
1334
+ const res = await bridgeFetch(
1335
+ `/api/cli/brands/${encodeURIComponent(brandId)}/convert-pptx`,
1336
+ {
1337
+ method: "POST",
1338
+ headers: { "content-type": contentType },
1339
+ body,
1340
+ timeoutMs: convertTimeoutMs(),
1341
+ },
1342
+ backendURL,
1343
+ );
1344
+ const data = (await res.json()) ?? {};
1345
+ return { ...data, backendURL };
1346
+ }
1347
+
1348
+ // Convert a FILE deck's foreign slides to converted-dialect jsx over the
1349
+ // pane-free brand route (`slidemind adopt`). Body is multipart: the .pptx plus
1350
+ // the 1-based slide numbers to convert. Returns the server's
1351
+ // `{ slides, layouts, assets, sourceBaselines, conversionEvidence, skipped,
1352
+ // warnings }` (add-in `ConvertPptxResponse`) augmented with the resolved
1353
+ // `backendURL`. Identity and evidence are keyed by each slide's jsx `path`, not
1354
+ // carried on the slide — `src/lib/adopt.mjs` `convertedByNumber()` is the one
1355
+ // place that key shape is unfolded.
1356
+ //
1357
+ // LIVE decks are NOT convertible through this route in v1: converting an open
1358
+ // presentation needs a slide export + tag read over the relay, and neither
1359
+ // event exists (design D9) — the add-in path owns that flow.
1360
+ //
1361
+ // Fan-out and degradation mirror generateConceptImagesForBrand: throws a typed
1362
+ // `convert_route_unavailable` when no paired backend serves the route (older
1363
+ // add-ins, the reverse flag off, unknown brand, missing grant) so `adopt` falls
1364
+ // back to the native fold instead of failing.
1365
+ export async function convertPptxForBrand(
1366
+ brandId,
1367
+ { bytes, fileName = "deck.pptx", slides = null, backendURL = null } = {},
1368
+ ) {
1369
+ if (!brandId) throw new Error("convertPptxForBrand: brandId is required");
1370
+ if (!bytes) throw new Error("convertPptxForBrand: bytes is required");
1371
+ if (backendURL) {
1372
+ return postConvertPptx(brandId, { bytes, fileName, slides }, backendURL);
1373
+ }
1374
+ const sessions = sessionsInScope();
1375
+ const failures = [];
1376
+ for (const s of sessions) {
1377
+ try {
1378
+ if (!(await backendSupportsConvertPptx(s.backendURL))) {
1379
+ failures.push({
1380
+ env: deriveEnvLabel(s.backendURL),
1381
+ reason: "no convert-pptx route",
1382
+ });
1383
+ continue;
1384
+ }
1385
+ return await postConvertPptx(
1386
+ brandId,
1387
+ { bytes, fileName, slides },
1388
+ s.backendURL,
1389
+ );
1390
+ } catch (err) {
1391
+ failures.push({
1392
+ env: deriveEnvLabel(s.backendURL),
1393
+ reason: err instanceof Error ? err.message : String(err),
1394
+ });
1395
+ }
1396
+ }
1397
+ const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
1398
+ const err = new Error(
1399
+ `no paired backend converts foreign slides for design '${brandId}'` +
1400
+ `${detail ? ` (${detail})` : ""}.`,
1401
+ );
1402
+ err.code = "convert_route_unavailable";
1403
+ throw err;
1404
+ }
1405
+
1261
1406
  // List the LIVE engine catalog (NOT the bundle's curated set) for the paired
1262
1407
  // backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
1263
1408
  // when several environments are paired this fans out and tags each engine with
@@ -1292,8 +1437,10 @@ async function listEnginesForBackend(backendURL, { type = null, subtype = null }
1292
1437
  const res = await bridgeFetch(
1293
1438
  `/api/cli/engines${qs ? `?${qs}` : ""}`,
1294
1439
  // Pane-relayed on the add-in side — needs the engine budget, not the 8s
1295
- // list timeout (which bailed before the pane could ever answer).
1296
- { timeoutMs: engineTimeoutMs() },
1440
+ // list timeout (which bailed before the pane could ever answer). The
1441
+ // delegated tokens authorize the pane-free catalog (login_required
1442
+ // without them); older add-ins ignore the headers.
1443
+ { timeoutMs: engineTimeoutMs(), headers: await graphHeaders(backendURL) },
1297
1444
  backendURL,
1298
1445
  );
1299
1446
  const data = await res.json();
@@ -1503,6 +1650,16 @@ export async function backendSupportsEngineRoutes(backendURL) {
1503
1650
  return caps?.engineRoutes === true;
1504
1651
  }
1505
1652
 
1653
+ // Whether a backend honours a delegated Microsoft Graph token on the pane-free
1654
+ // engine routes — the CLI's own identity for SharePoint-backed content
1655
+ // (`slidemind login`, src/bridge/graph-auth.mjs). Gates BOTH the attach and the
1656
+ // remediation text: naming `slidemind login` on an add-in that ignores the token
1657
+ // would send a user through a browser flow that changes nothing.
1658
+ export async function backendSupportsGraphDelegated(backendURL) {
1659
+ const caps = await backendCapabilities(backendURL);
1660
+ return caps?.graphDelegated === true;
1661
+ }
1662
+
1506
1663
  // The add-in's hard version floor: /api/cli/capabilities advertises the oldest
1507
1664
  // CLI it still speaks (`minCliVersion`). An older CLI must refuse to proceed —
1508
1665
  // mid-rollout skew otherwise surfaces as confusing route/contract errors deep
@@ -1698,6 +1855,9 @@ export async function renderPptx({
1698
1855
  deckAssets = null,
1699
1856
  nativeSlides = null,
1700
1857
  etag = null,
1858
+ sourceGuids = null,
1859
+ conversions = null,
1860
+ reconcile = null,
1701
1861
  backendURL = null,
1702
1862
  onProgress = null,
1703
1863
  }) {
@@ -1728,6 +1888,30 @@ export async function renderPptx({
1728
1888
  // server splices them in by numeric-aware sorted filename (contract #7).
1729
1889
  if (hasNative) body.nativeSlides = nativeSlides;
1730
1890
  if (etag) body.etag = etag;
1891
+ // Deck-embedded source: known slide GUIDs (jsx path → sm-<uuid>) so the
1892
+ // server's tag bake keeps identities stable; minted ones ride back as
1893
+ // `sourceGuids` on the response.
1894
+ if (sourceGuids && Object.keys(sourceGuids).length > 0) {
1895
+ body.sourceGuids = sourceGuids;
1896
+ }
1897
+ // Reverse-converted slides (`slidemind adopt`): identity + fidelity evidence
1898
+ // per converted jsx file, so the server can gate the first replacement of a
1899
+ // converted slide against what it originally converted, plus the verbatim
1900
+ // source package whose preserved natives (chart / OLE / SmartArt) it splices
1901
+ // back into the rendered slide. Keyed exactly like `slides`. Additive: a deck
1902
+ // with no conversions sends the same body it always did.
1903
+ if (conversions && Object.keys(conversions).length > 0) {
1904
+ body.conversions = conversions;
1905
+ }
1906
+ // Manual-edit reconcile: for every slide the user edited in PowerPoint since
1907
+ // the last build, the baked baseline projection and the current one, keyed
1908
+ // exactly like `slides`. The server diffs them, folds text edits into the jsx
1909
+ // it renders, and reports the rest — the CLI owns no diff source. Additive: a
1910
+ // deck with no pending edits sends the same body it always did, and the
1911
+ // response's `reconcile` echo is what proves the backend understood it.
1912
+ if (reconcile && Object.keys(reconcile).length > 0) {
1913
+ body.reconcile = reconcile;
1914
+ }
1731
1915
  // The brand surface has no presId to label the render with — pass the deck
1732
1916
  // slug so job records/temp dirs stay readable (server-side sanitized).
1733
1917
  if (brandId && deckName) body.deckName = deckName;
@@ -1962,12 +2146,12 @@ export async function resolveSearchBackend(searchId) {
1962
2146
  }
1963
2147
  }
1964
2148
  // The two failures that are NOT the record talking: a dead session, and a
1965
- // permission snapshot that has aged out (the record re-read is an engine
1966
- // route, so it degrades with the rest of corpus search). Reported as "that
1967
- // search expired", both send the user to re-run a search that fails the same
1968
- // way — so they outrank the expired-record answer.
2149
+ // missing Microsoft 365 sign-in (the record re-read is an engine route, so
2150
+ // it refuses with the rest of corpus search). Reported as "that search
2151
+ // expired", both send the user to re-run a search that fails the same way —
2152
+ // so they outrank the expired-record answer.
1969
2153
  const blocking = failures.find(
1970
- (e) => isSessionInvalidError(e) || e?.code === "permissions_stale",
2154
+ (e) => isSessionInvalidError(e) || e?.code === "login_required",
1971
2155
  );
1972
2156
  if (blocking) throw blocking;
1973
2157
  return null;
@@ -2072,27 +2256,91 @@ async function directEngineBackend(fn, { backendURL = null, brandId = null }) {
2072
2256
  throw err;
2073
2257
  }
2074
2258
 
2075
- // The permission SNAPSHOT behind corpus search is refreshed whenever the add-in
2076
- // runs; when it ages out the engine routes answer
2077
- // `403 { error: "permissions_stale", action: "…" }` and only corpus search
2078
- // degrades (render/bundle never do). Shape it like the connection ladder's rungs
2079
- // — `{ code, actor, message }` — so the CLI can print ONE actionable line and an
2080
- // agent knows it cannot clear this one itself. Returns null for any other error.
2081
- export function permissionsStaleBlocker(err) {
2082
- if (err?.code !== "permissions_stale") return null;
2259
+ // The user's delegated tokens for a backend, or nulls. Attached ONLY to the
2260
+ // pane-free engine ops: they are the user's own Entra identity, and render,
2261
+ // bundle, whoami and the pane relay have no business carrying them (the pane
2262
+ // signs its own requests with a browser token that is strictly fresher).
2263
+ //
2264
+ // When nothing is stored, the sign-in RUNS HERE — browser opens, the command
2265
+ // blocks until the human finishes (graph-auth.ensureSignedIn, once per
2266
+ // process). So login is not a separate ritual: the first engine command of a
2267
+ // fresh machine walks the user through it, and every later one rides the
2268
+ // stored refresh token silently. Null only when the add-in doesn't honour the
2269
+ // tokens, or the sign-in was refused/abandoned — the backend then answers its
2270
+ // typed `login_required`, which the blocker below renders.
2271
+ async function engineGraphToken(backendURL) {
2272
+ if (!backendURL) return null;
2273
+ if (!(await backendSupportsGraphDelegated(backendURL))) return null;
2274
+ let token = await getGraphToken(backendURL);
2275
+ if (!token && (await ensureSignedIn(backendURL))) {
2276
+ token = await getGraphToken(backendURL);
2277
+ }
2278
+ return token;
2279
+ }
2280
+
2281
+ // The SharePoint-resource sibling (same sign-in, same refresh token): the
2282
+ // backend resolves `sg:` site-group membership from the PAIR, and on a
2283
+ // SharePoint-configured deployment refuses corpus search without it.
2284
+ async function engineSpToken(backendURL) {
2285
+ if (!backendURL) return null;
2286
+ if (!(await backendSupportsGraphDelegated(backendURL))) return null;
2287
+ let token = await getSharePointToken(backendURL);
2288
+ if (!token && (await ensureSignedIn(backendURL))) {
2289
+ token = await getSharePointToken(backendURL);
2290
+ }
2291
+ return token;
2292
+ }
2293
+
2294
+ // Both delegated tokens as body fields, for the POST routes. Empty when there
2295
+ // is nothing to attach, so an older backend sees the body it always did.
2296
+ async function delegatedBodyFields(backendURL) {
2297
+ const [graphToken, spToken] = await Promise.all([
2298
+ engineGraphToken(backendURL),
2299
+ engineSpToken(backendURL),
2300
+ ]);
2301
+ return {
2302
+ ...(graphToken ? { graphApiToken: graphToken } : {}),
2303
+ ...(spToken ? { spApiToken: spToken } : {}),
2304
+ };
2305
+ }
2306
+
2307
+ // The byte routes are GETs, so the delegated tokens ride headers instead of
2308
+ // body fields. Empty (not absent-valued) when there is no token, so the request
2309
+ // headers are unchanged for everyone who never signed in.
2310
+ async function graphHeaders(backendURL) {
2311
+ const [graphToken, spToken] = await Promise.all([
2312
+ engineGraphToken(backendURL),
2313
+ engineSpToken(backendURL),
2314
+ ]);
2315
+ return {
2316
+ ...(graphToken ? { "x-slidemind-graph-token": graphToken } : {}),
2317
+ ...(spToken ? { "x-slidemind-sp-token": spToken } : {}),
2318
+ };
2319
+ }
2320
+
2321
+ // Corpus search runs as the USER: the pane-free engine routes resolve the
2322
+ // caller's group membership live from the delegated tokens `slidemind login`
2323
+ // minted, and answer `401 { code: "login_required", action: "…" }` without
2324
+ // them. Shape it like the connection ladder's rungs — `{ code, actor, message }`
2325
+ // — so the CLI can print ONE actionable line and an agent knows it cannot clear
2326
+ // this one itself (the sign-in needs a browser and a human). Returns null for
2327
+ // any other error.
2328
+ export function loginRequiredBlocker(err) {
2329
+ if (err?.code !== "login_required") return null;
2083
2330
  const action =
2084
2331
  typeof err?.body?.action === "string" && err.body.action.trim()
2085
2332
  ? err.body.action.trim()
2086
- : "open the slidemind add-in once to refresh access";
2333
+ : "run `slidemind login` to sign in to Microsoft 365";
2087
2334
  return {
2088
- code: "permissions_stale",
2335
+ code: "login_required",
2089
2336
  actor: "user",
2090
2337
  message:
2091
- `slidemind needs your search permissions refreshed before it can search the corpus — ${action}. ` +
2338
+ `slidemind needs your Microsoft 365 sign-in before it can search the corpus — ${action}. ` +
2092
2339
  "Everything else (render, bundle, imagery already downloaded) keeps working.",
2093
2340
  };
2094
2341
  }
2095
2342
 
2343
+
2096
2344
  // Brand-engine search through the local bridge. New add-ins run this
2097
2345
  // server-side with the relayed user token; older add-ins may fall back to a
2098
2346
  // task-pane proxy. Returns a searchId + numbered results (image thumbnails, or
@@ -2136,12 +2384,18 @@ export async function engineSearch({
2136
2384
  if (typeof top === "number") body.top = top;
2137
2385
  if (direct) {
2138
2386
  const be = await directEngineBackend("engineSearch", { backendURL, brandId });
2387
+ // The user's own identity: the backend resolves the caller's group
2388
+ // membership from these tokens per request and refuses without them
2389
+ // (login_required). Search also seeds the speculative insert cache
2390
+ // server-side, which is why it carries the Graph token as well as the
2391
+ // byte GETs.
2392
+ const delegated = await delegatedBodyFields(be);
2139
2393
  const res = await bridgeFetch(
2140
2394
  "/api/cli/searches",
2141
2395
  {
2142
2396
  method: "POST",
2143
2397
  headers: { "content-type": "application/json" },
2144
- body: JSON.stringify(body),
2398
+ body: JSON.stringify({ ...body, ...delegated }),
2145
2399
  timeoutMs: timeoutMs ?? engineTimeoutMs(),
2146
2400
  },
2147
2401
  be,
@@ -2359,7 +2613,12 @@ export async function engineDownloadToFile({
2359
2613
  if (iconColor) params.set("iconColor", iconColor);
2360
2614
  const res = await bridgeStreamToFile(
2361
2615
  resultRoute(searchId, selection, "image", params),
2362
- { method: "GET", timeoutMs: downloadTimeoutMs(), onProgress },
2616
+ {
2617
+ method: "GET",
2618
+ headers: await graphHeaders(b),
2619
+ timeoutMs: downloadTimeoutMs(),
2620
+ onProgress,
2621
+ },
2363
2622
  destPath,
2364
2623
  b,
2365
2624
  );
@@ -2425,7 +2684,12 @@ export async function extractSlideToFile({
2425
2684
  });
2426
2685
  const res = await bridgeStreamToFile(
2427
2686
  resultRoute(searchId, selection, "slide"),
2428
- { method: "GET", timeoutMs: downloadTimeoutMs(), onProgress },
2687
+ {
2688
+ method: "GET",
2689
+ headers: await graphHeaders(b),
2690
+ timeoutMs: downloadTimeoutMs(),
2691
+ onProgress,
2692
+ },
2429
2693
  destPath,
2430
2694
  b,
2431
2695
  );
@@ -2657,7 +2921,11 @@ export async function viewSlide({
2657
2921
  try {
2658
2922
  const res = await bridgeStreamToFile(
2659
2923
  resultRoute(searchId, selection, "thumbnail"),
2660
- { method: "GET", timeoutMs: engineTimeoutMs() },
2924
+ {
2925
+ method: "GET",
2926
+ headers: await graphHeaders(b),
2927
+ timeoutMs: engineTimeoutMs(),
2928
+ },
2661
2929
  tmp,
2662
2930
  b,
2663
2931
  );
@@ -2837,12 +3105,18 @@ export async function insertSlides({
2837
3105
  insertAt = null,
2838
3106
  sourceSlideIds = null,
2839
3107
  operationId = null,
3108
+ sourceTagsBySlide = null,
2840
3109
  backendURL = null,
2841
3110
  }) {
2842
3111
  if (!presId) throw new Error("insertSlides: presId is required");
2843
3112
  if (!base64) throw new Error("insertSlides: base64 is required");
2844
3113
  const be = backendURL || (await resolveBackendForPres(presId));
2845
3114
  const body = { base64, insertAt, sourceSlideIds, operationId };
3115
+ // Deck-embedded source payloads for the pane's post-insert tag write,
3116
+ // aligned with the inserted slides in document order.
3117
+ if (Array.isArray(sourceTagsBySlide) && sourceTagsBySlide.some(Boolean)) {
3118
+ body.sourceTagsBySlide = sourceTagsBySlide;
3119
+ }
2846
3120
  const res = await bridgeFetch(
2847
3121
  `/api/cli/presentations/${encodeURIComponent(presId)}/insert`,
2848
3122
  {
@@ -2858,21 +3132,65 @@ export async function insertSlides({
2858
3132
  return ensurePaneAckOk(await res.json().catch(() => ({})), "insert");
2859
3133
  }
2860
3134
 
3135
+ // Read-only sibling of the mutation ops: export the LIVE slides carrying the
3136
+ // given GUIDs, so a `--replace` push can see what the user changed on screen
3137
+ // BEFORE it wipes it (plan Phase C1). `guids` omitted ⇒ the whole deck.
3138
+ //
3139
+ // The ack's `tags` come from the Office.js tag API, NOT from the exported
3140
+ // bytes: exportAsBase64 strips custom tags (office-js #6104), so a slide's
3141
+ // drift BASELINE is recoverable only from `tags` — never from `base64`, which
3142
+ // carries the CURRENT state and nothing else. `deck.unmatched` lists the live
3143
+ // slides that are not in the requested set (what a whole-deck replace would
3144
+ // delete); `truncated` names GUIDs that matched but did not fit the relay
3145
+ // frame, to be re-requested.
3146
+ export async function exportSlides({
3147
+ presId,
3148
+ guids = null,
3149
+ presentationId = null,
3150
+ backendURL = null,
3151
+ }) {
3152
+ if (!presId) throw new Error("exportSlides: presId is required");
3153
+ const be = backendURL || (await resolveBackendForPres(presId));
3154
+ const body = {};
3155
+ if (Array.isArray(guids) && guids.length > 0) body.guids = guids;
3156
+ if (presentationId) body.presentationId = presentationId;
3157
+ const res = await bridgeFetch(
3158
+ `/api/cli/presentations/${encodeURIComponent(presId)}/export-slides`,
3159
+ {
3160
+ method: "POST",
3161
+ headers: { "content-type": "application/json" },
3162
+ body: JSON.stringify(body),
3163
+ // Same pane round trip as the mutations (a suspended pane has to wake),
3164
+ // and the payload is slide bytes.
3165
+ timeoutMs:
3166
+ parseInt(process.env.SLIDEMIND_BRIDGE_MUTATION_TIMEOUT_MS || "1805000", 10) ||
3167
+ 1805000,
3168
+ },
3169
+ be,
3170
+ );
3171
+ return ensurePaneAckOk(await res.json().catch(() => ({})), "export-slides");
3172
+ }
3173
+
2861
3174
  export async function replaceSlides({
2862
3175
  presId,
2863
3176
  base64,
2864
3177
  operationId = null,
3178
+ sourceTagsBySlide = null,
2865
3179
  backendURL = null,
2866
3180
  }) {
2867
3181
  if (!presId) throw new Error("replaceSlides: presId is required");
2868
3182
  if (!base64) throw new Error("replaceSlides: base64 is required");
2869
3183
  const be = backendURL || (await resolveBackendForPres(presId));
3184
+ const replaceBody = { base64, operationId };
3185
+ if (Array.isArray(sourceTagsBySlide) && sourceTagsBySlide.some(Boolean)) {
3186
+ replaceBody.sourceTagsBySlide = sourceTagsBySlide;
3187
+ }
2870
3188
  const res = await bridgeFetch(
2871
3189
  `/api/cli/presentations/${encodeURIComponent(presId)}/replace`,
2872
3190
  {
2873
3191
  method: "POST",
2874
3192
  headers: { "content-type": "application/json" },
2875
- body: JSON.stringify({ base64, operationId }),
3193
+ body: JSON.stringify(replaceBody),
2876
3194
  timeoutMs:
2877
3195
  parseInt(process.env.SLIDEMIND_BRIDGE_MUTATION_TIMEOUT_MS || "1805000", 10) ||
2878
3196
  1805000,
@@ -3031,7 +3349,7 @@ export function pickPresentationEntry(
3031
3349
  const list = registry
3032
3350
  .map(
3033
3351
  (p) =>
3034
- ` ${p.presId}\t${p.env ? `[${p.env}]\t` : ""}${p.title || p.fileName || "(untitled)"}` +
3352
+ ` ${p.presId}\t${p.env && isMultiEnv() ? `[${p.env}]\t` : ""}${p.title || p.fileName || "(untitled)"}` +
3035
3353
  (p.connected === false || p.suspendedAt ? "\t(suspended — PowerPoint in background)" : ""),
3036
3354
  )
3037
3355
  .join("\n");