slidemind 0.1.3 → 0.1.5
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.md +31 -19
- package/README.md +31 -6
- package/bin/slidemind.mjs +442 -18
- package/package.json +2 -6
- package/skills/create-slides/SKILL.md +14 -8
- package/skills/search/SKILL.md +75 -0
- package/src/bridge/build-and-push.mjs +178 -34
- package/src/bridge/bundle.mjs +325 -99
- package/src/bridge/capture.mjs +187 -0
- package/src/bridge/client.mjs +719 -46
- package/src/builders/build.mjs +75 -26
- package/src/builders/lint.mjs +112 -0
- package/src/builders/resolve-skill.mjs +16 -21
- package/src/builders/review-overlay.js +821 -0
- package/src/builders/review.mjs +388 -0
- package/src/lib/reused-slides.mjs +67 -0
- package/src/lib/slide-order.mjs +4 -1
- package/src/mcp/server.mjs +375 -28
- package/src/builders/lint-slides.mjs +0 -796
- package/src/builders/primitives.json +0 -19
package/src/bridge/client.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// backend runs render/bundle in-process and relays insert/replace/engine to the
|
|
6
6
|
// open PowerPoint pane. No localhost, no shared loopback token, no cert.
|
|
7
7
|
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
8
9
|
import http from "node:http";
|
|
9
10
|
import https from "node:https";
|
|
10
11
|
|
|
@@ -27,6 +28,19 @@ function listTimeoutMs() {
|
|
|
27
28
|
);
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
// Engine ops (list / search / download / extract) are RELAYED through the task
|
|
32
|
+
// pane: the server waits up to ~25s for a suspended pane to reconnect, then up
|
|
33
|
+
// to 60s for the pane's ack. Budget above that sum so a typed pane error
|
|
34
|
+
// (pane_suspended / pane_timeout) wins over a client timeout. Note `ls engines`
|
|
35
|
+
// is pane-relayed too — using the 8s list timeout there masked the real
|
|
36
|
+
// pane_suspended as a bare "timed out after 8000ms".
|
|
37
|
+
function engineTimeoutMs() {
|
|
38
|
+
return (
|
|
39
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
40
|
+
90000
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
const NOT_PAIRED_MESSAGE =
|
|
31
45
|
'slidemind is not paired. In the slidemind add-in, click "Connect CLI" ' +
|
|
32
46
|
"and run the shown `slidemind pair …` command, then retry.";
|
|
@@ -338,19 +352,44 @@ export async function requireHost() {
|
|
|
338
352
|
);
|
|
339
353
|
}
|
|
340
354
|
|
|
355
|
+
// Tie-break a multi-backend presId collision without bothering the user when
|
|
356
|
+
// only one candidate can actually serve the deck:
|
|
357
|
+
// (a) exactly one CONNECTED pane whose active CD matches the deck's bundle
|
|
358
|
+
// ref (`preferBrandId` — the deck's presFP, which equals the pane's
|
|
359
|
+
// brandId by the bundle-cache contract);
|
|
360
|
+
// (b) else exactly one connected pane with ANY active CD (brandId set) —
|
|
361
|
+
// e.g. a local dev backend connected without a CD next to prod with one.
|
|
362
|
+
// Returns the winning entry or null when genuinely ambiguous.
|
|
363
|
+
function tieBreakMatches(matches, preferBrandId) {
|
|
364
|
+
const live = matches.filter((m) => m.connected !== false && !m.suspendedAt);
|
|
365
|
+
if (preferBrandId) {
|
|
366
|
+
const want = live.filter((m) => m.brandId === preferBrandId);
|
|
367
|
+
if (want.length === 1) return want[0];
|
|
368
|
+
}
|
|
369
|
+
const withCd = live.filter((m) => m.brandId);
|
|
370
|
+
if (withCd.length === 1) return withCd[0];
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
341
374
|
// Resolve which backend a presId lives on via a LIVE fan-out list (never a
|
|
342
375
|
// cached map — a presId can move backends on re-pair/redeploy). Throws an
|
|
343
376
|
// actionable message on a miss, or an ambiguity error when the same presId is
|
|
344
|
-
// open on more than one reachable backend.
|
|
345
|
-
export async function resolveBackendForPres(
|
|
377
|
+
// open on more than one reachable backend and no tie-break rule settles it.
|
|
378
|
+
export async function resolveBackendForPres(
|
|
379
|
+
presId,
|
|
380
|
+
{ preferBrandId = null } = {},
|
|
381
|
+
) {
|
|
346
382
|
if (!presId) throw new Error("resolveBackendForPres: presId is required");
|
|
347
383
|
const { presentations } = await fanOutPresentations();
|
|
348
384
|
const matches = presentations.filter((p) => p.presId === presId);
|
|
349
385
|
if (matches.length === 1) return matches[0].backendURL;
|
|
350
386
|
if (matches.length > 1) {
|
|
387
|
+
const winner = tieBreakMatches(matches, preferBrandId);
|
|
388
|
+
if (winner) return winner.backendURL;
|
|
351
389
|
const envs = matches.map((m) => m.env).join(", ");
|
|
352
390
|
throw new Error(
|
|
353
|
-
`Presentation '${presId}' is open on multiple environments: ${envs}
|
|
391
|
+
`Presentation '${presId}' is open on multiple environments: ${envs}, ` +
|
|
392
|
+
"and no single connected one has the deck's corporate design active. " +
|
|
354
393
|
"Disambiguate with --backend <env|label> (or SLIDEMIND_BACKEND).",
|
|
355
394
|
);
|
|
356
395
|
}
|
|
@@ -360,11 +399,79 @@ export async function resolveBackendForPres(presId) {
|
|
|
360
399
|
);
|
|
361
400
|
}
|
|
362
401
|
|
|
402
|
+
// Wait for a presentation's task pane to come back after a `pane_suspended`
|
|
403
|
+
// (PowerPoint backgrounded → webview suspended → socket dropped). Polls the
|
|
404
|
+
// live registry; succeeds on the same presId reconnecting, OR on a CONNECTED
|
|
405
|
+
// entry matching `fileName` + `brandId` — a pane reconnect can mint a NEW
|
|
406
|
+
// presId for the same window, so identity-by-content covers the churn. Returns
|
|
407
|
+
// the live registry entry (caller re-targets from it), or null on timeout.
|
|
408
|
+
// `onWait` fires once with the wait budget in seconds — the caller's single
|
|
409
|
+
// actionable "click the pane" line.
|
|
410
|
+
export async function waitForPaneReady(
|
|
411
|
+
presId,
|
|
412
|
+
{
|
|
413
|
+
backendURL = null,
|
|
414
|
+
fileName = null,
|
|
415
|
+
brandId = null,
|
|
416
|
+
timeoutMs = null,
|
|
417
|
+
pollMs = null,
|
|
418
|
+
onWait = null,
|
|
419
|
+
} = {},
|
|
420
|
+
) {
|
|
421
|
+
const waitMs =
|
|
422
|
+
timeoutMs ?? envInt("SLIDEMIND_BRIDGE_PANE_READY_TIMEOUT_MS", 120000);
|
|
423
|
+
const stepMs = pollMs ?? envInt("SLIDEMIND_BRIDGE_PANE_READY_POLL_MS", 3000);
|
|
424
|
+
const deadline = Date.now() + waitMs;
|
|
425
|
+
let notified = false;
|
|
426
|
+
for (;;) {
|
|
427
|
+
let list = [];
|
|
428
|
+
try {
|
|
429
|
+
list = backendURL
|
|
430
|
+
? await listPresentationsForBackend(backendURL)
|
|
431
|
+
: (await fanOutPresentations()).presentations;
|
|
432
|
+
} catch {
|
|
433
|
+
/* transient list failure — poll again until the deadline */
|
|
434
|
+
}
|
|
435
|
+
const connected = list.filter(
|
|
436
|
+
(p) => p.connected !== false && !p.suspendedAt,
|
|
437
|
+
);
|
|
438
|
+
const same = connected.find((p) => p.presId === presId);
|
|
439
|
+
if (same) return same;
|
|
440
|
+
if (fileName || brandId) {
|
|
441
|
+
const rematch = connected.find(
|
|
442
|
+
(p) =>
|
|
443
|
+
(!fileName || p.fileName === fileName || p.title === fileName) &&
|
|
444
|
+
(!brandId || p.brandId === brandId),
|
|
445
|
+
);
|
|
446
|
+
if (rematch) return rematch;
|
|
447
|
+
}
|
|
448
|
+
if (Date.now() >= deadline) return null;
|
|
449
|
+
if (!notified) {
|
|
450
|
+
notified = true;
|
|
451
|
+
onWait?.(Math.round(waitMs / 1000));
|
|
452
|
+
}
|
|
453
|
+
await sleep(stepMs);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// The one actionable line a human needs when a pane op is waiting. stderr so
|
|
458
|
+
// it reaches the terminal from the CLI and the MCP server log alike.
|
|
459
|
+
function paneWaitNotice(seconds) {
|
|
460
|
+
process.stderr.write(
|
|
461
|
+
"task pane suspended — click the slidemind pane in PowerPoint to wake it " +
|
|
462
|
+
`(waiting up to ${seconds}s)…\n`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
363
466
|
// GET the full design bundle (tokens, css, layouts, designRules, signed asset
|
|
364
467
|
// URLs, engines) for the presentation's active corporate design.
|
|
365
|
-
export async function getDesignBundle(
|
|
468
|
+
export async function getDesignBundle(
|
|
469
|
+
presId,
|
|
470
|
+
{ backendURL = null, preferBrandId = null } = {},
|
|
471
|
+
) {
|
|
366
472
|
if (!presId) throw new Error("getDesignBundle: presId is required");
|
|
367
|
-
const be =
|
|
473
|
+
const be =
|
|
474
|
+
backendURL || (await resolveBackendForPres(presId, { preferBrandId }));
|
|
368
475
|
return withTransientRetry(async () => {
|
|
369
476
|
const res = await bridgeFetch(
|
|
370
477
|
`/api/cli/presentations/${encodeURIComponent(presId)}/design-bundle`,
|
|
@@ -375,35 +482,335 @@ export async function getDesignBundle(presId, { backendURL = null } = {}) {
|
|
|
375
482
|
});
|
|
376
483
|
}
|
|
377
484
|
|
|
485
|
+
// List the LIVE engine catalog (NOT the bundle's curated set) for the paired
|
|
486
|
+
// backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
|
|
487
|
+
// when several environments are paired this fans out and tags each engine with
|
|
488
|
+
// its short `env` label (never the backendURL — the no-URL rule). Filter by
|
|
489
|
+
// engine `type` (collection|search) and/or `subtype` (image|slide|template|
|
|
490
|
+
// content). Returns a flat `{ engines: [...] }` like the single-backend route.
|
|
491
|
+
async function listEnginesForBackend(backendURL, { type = null, subtype = null } = {}) {
|
|
492
|
+
return withTransientRetry(async () => {
|
|
493
|
+
const params = new URLSearchParams();
|
|
494
|
+
if (type) params.set("type", type);
|
|
495
|
+
if (subtype) params.set("subtype", subtype);
|
|
496
|
+
const qs = params.toString();
|
|
497
|
+
const res = await bridgeFetch(
|
|
498
|
+
`/api/cli/engines${qs ? `?${qs}` : ""}`,
|
|
499
|
+
// Pane-relayed on the add-in side — needs the engine budget, not the 8s
|
|
500
|
+
// list timeout (which bailed before the pane could ever answer).
|
|
501
|
+
{ timeoutMs: engineTimeoutMs() },
|
|
502
|
+
backendURL,
|
|
503
|
+
);
|
|
504
|
+
const data = await res.json();
|
|
505
|
+
const arr = Array.isArray(data?.engines) ? data.engines : [];
|
|
506
|
+
const env = deriveEnvLabel(backendURL);
|
|
507
|
+
return arr.map((e) => ({ ...e, env }));
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export async function listEngines({ type = null, subtype = null } = {}) {
|
|
512
|
+
const sessions = sessionsInScope();
|
|
513
|
+
const settled = await Promise.allSettled(
|
|
514
|
+
sessions.map((s) => listEnginesForBackend(s.backendURL, { type, subtype })),
|
|
515
|
+
);
|
|
516
|
+
const engines = [];
|
|
517
|
+
const failures = [];
|
|
518
|
+
let fulfilledCount = 0;
|
|
519
|
+
settled.forEach((r, i) => {
|
|
520
|
+
if (r.status === "fulfilled") {
|
|
521
|
+
fulfilledCount += 1;
|
|
522
|
+
engines.push(...r.value);
|
|
523
|
+
} else {
|
|
524
|
+
failures.push({ env: deriveEnvLabel(sessions[i].backendURL), err: r.reason });
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
// Mirror listOpenPresentations: only surface an error when nothing answered.
|
|
528
|
+
if (engines.length === 0 && fulfilledCount === 0 && failures.length > 0) {
|
|
529
|
+
if (failures.length === 1) throw failures[0].err;
|
|
530
|
+
throw new Error(
|
|
531
|
+
"slidemind: every paired backend failed to list engines:\n" +
|
|
532
|
+
failures.map((f) => ` ${f.env}: ${f.err.message}`).join("\n"),
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
return { engines };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
// Server-side OOXML render
|
|
540
|
+
// ---------------------------------------------------------------------------
|
|
541
|
+
//
|
|
542
|
+
// Two protocols, newest first:
|
|
543
|
+
// 1. Render JOBS (preferred): POST creates a job, the CLI polls cheap status
|
|
544
|
+
// GETs and fetches the result when done. A full deck render routinely
|
|
545
|
+
// outlives the cloud ingress's ~240s request cap — only short polling
|
|
546
|
+
// requests survive that reliably, and they carry per-slide progress.
|
|
547
|
+
// 2. Sync render (fallback): one POST held open for the whole render. Kept
|
|
548
|
+
// for older backends that don't serve /api/cli/capabilities yet.
|
|
549
|
+
|
|
550
|
+
function envInt(name, fallback) {
|
|
551
|
+
const n = parseInt(process.env[name] || "", 10);
|
|
552
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function sleep(ms) {
|
|
556
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// One capability probe per backend per process. Keyed by the RESOLVED backend
|
|
560
|
+
// URL (callers always resolve before probing), so multiple paired backends on
|
|
561
|
+
// mixed versions each get their own answer.
|
|
562
|
+
const renderJobsCapability = new Map();
|
|
563
|
+
|
|
564
|
+
function backendSupportsRenderJobs(backendURL) {
|
|
565
|
+
let probe = renderJobsCapability.get(backendURL);
|
|
566
|
+
if (!probe) {
|
|
567
|
+
probe = (async () => {
|
|
568
|
+
try {
|
|
569
|
+
const res = await bridgeFetch(
|
|
570
|
+
"/api/cli/capabilities",
|
|
571
|
+
{ timeoutMs: listTimeoutMs() },
|
|
572
|
+
backendURL,
|
|
573
|
+
);
|
|
574
|
+
const data = await res.json();
|
|
575
|
+
return data?.renderJobs === true;
|
|
576
|
+
} catch {
|
|
577
|
+
// Older backends 404 this path (with a non-JSON body) — sync render.
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
})();
|
|
581
|
+
renderJobsCapability.set(backendURL, probe);
|
|
582
|
+
}
|
|
583
|
+
return probe;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Rebuild a job failure into the exact error shape bridgeFetch throws for the
|
|
587
|
+
// sync route, so callers (etag self-heal, MCP error texts) can't tell the
|
|
588
|
+
// protocols apart.
|
|
589
|
+
function jobFailureError(jobError, presId) {
|
|
590
|
+
const status = jobError?.status ?? 500;
|
|
591
|
+
const detail = jobError?.error ?? "render_failed";
|
|
592
|
+
const err = new Error(
|
|
593
|
+
`slidemind bridge ${status} render job failed for ${presId}: ${detail}`,
|
|
594
|
+
);
|
|
595
|
+
err.status = status;
|
|
596
|
+
err.code = detail;
|
|
597
|
+
err.body = { error: detail, ...(jobError?.extra ?? {}) };
|
|
598
|
+
return err;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function renderViaSync({ presId, body, backendURL }) {
|
|
602
|
+
const res = await bridgeFetch(
|
|
603
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/render-pptx`,
|
|
604
|
+
{
|
|
605
|
+
method: "POST",
|
|
606
|
+
headers: { "content-type": "application/json" },
|
|
607
|
+
body: JSON.stringify(body),
|
|
608
|
+
// Server-side OOXML render is a heavy operation — a full deck of richly
|
|
609
|
+
// composed slides routinely runs well past the 35s default. Treat it like
|
|
610
|
+
// a mutation: give it a generous default and let env override it.
|
|
611
|
+
timeoutMs:
|
|
612
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_RENDER_TIMEOUT_MS || "300000", 10) ||
|
|
613
|
+
300000,
|
|
614
|
+
},
|
|
615
|
+
backendURL,
|
|
616
|
+
);
|
|
617
|
+
return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function renderViaJob({ presId, body, backendURL, onProgress }) {
|
|
621
|
+
const pollMs = envInt("SLIDEMIND_BRIDGE_RENDER_POLL_MS", 2500);
|
|
622
|
+
const deadlineMs = envInt("SLIDEMIND_BRIDGE_RENDER_DEADLINE_MS", 900000);
|
|
623
|
+
const resultTimeoutMs = envInt(
|
|
624
|
+
"SLIDEMIND_BRIDGE_RENDER_RESULT_TIMEOUT_MS",
|
|
625
|
+
120000,
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
const createJob = async () => {
|
|
629
|
+
// Deliberately NOT wrapped in withTransientRetry: its blanket 404 retry
|
|
630
|
+
// would re-upload a multi-MB slide payload, and creation errors (stale
|
|
631
|
+
// etag, no CD, unknown presId) are definitive.
|
|
632
|
+
const res = await bridgeFetch(
|
|
633
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/render-jobs`,
|
|
634
|
+
{
|
|
635
|
+
method: "POST",
|
|
636
|
+
headers: { "content-type": "application/json" },
|
|
637
|
+
body: JSON.stringify(body),
|
|
638
|
+
timeoutMs:
|
|
639
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_TIMEOUT_MS || "35000", 10) ||
|
|
640
|
+
35000,
|
|
641
|
+
},
|
|
642
|
+
backendURL,
|
|
643
|
+
);
|
|
644
|
+
const data = await res.json();
|
|
645
|
+
if (!data?.jobId) throw new Error("render-jobs returned no jobId");
|
|
646
|
+
return data.jobId;
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
let jobId = await createJob();
|
|
650
|
+
let resubmitted = false;
|
|
651
|
+
let lastReported = "";
|
|
652
|
+
const deadline = Date.now() + deadlineMs;
|
|
653
|
+
|
|
654
|
+
for (;;) {
|
|
655
|
+
if (Date.now() > deadline) {
|
|
656
|
+
const err = new Error(
|
|
657
|
+
`slidemind render did not finish within ${deadlineMs}ms ` +
|
|
658
|
+
"(raise SLIDEMIND_BRIDGE_RENDER_DEADLINE_MS for very large decks). " +
|
|
659
|
+
"The render keeps running server-side — retrying may pick up a warm cache.",
|
|
660
|
+
);
|
|
661
|
+
err.code = "bridge_timeout";
|
|
662
|
+
throw err;
|
|
663
|
+
}
|
|
664
|
+
await sleep(pollMs);
|
|
665
|
+
|
|
666
|
+
let status;
|
|
667
|
+
try {
|
|
668
|
+
const res = await bridgeFetch(
|
|
669
|
+
`/api/cli/render-jobs/${encodeURIComponent(jobId)}`,
|
|
670
|
+
{ timeoutMs: 15000 },
|
|
671
|
+
backendURL,
|
|
672
|
+
);
|
|
673
|
+
status = await res.json();
|
|
674
|
+
} catch (err) {
|
|
675
|
+
// The job record vanished (backend Redis flush/restart) — same recovery
|
|
676
|
+
// as a lost replica: resubmit once.
|
|
677
|
+
if (err?.code === "job_not_found") {
|
|
678
|
+
if (resubmitted) throw jobFailureError({ status: 500, error: "job_lost" }, presId);
|
|
679
|
+
resubmitted = true;
|
|
680
|
+
onProgress?.({ state: "restarted" });
|
|
681
|
+
jobId = await createJob();
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
// Rate-limited poll: honor the window, then resume.
|
|
685
|
+
if (err?.status === 429) {
|
|
686
|
+
const retryAfterSec = err?.body?.retryAfterSec;
|
|
687
|
+
await sleep(Math.min((retryAfterSec || 5) * 1000, 30000));
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
// Anything else (network blip, 5xx, replica restart) — polling is
|
|
691
|
+
// idempotent; just poll again until the deadline.
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (onProgress) {
|
|
696
|
+
const snapshot = JSON.stringify([status.state, status.progress]);
|
|
697
|
+
if (snapshot !== lastReported) {
|
|
698
|
+
lastReported = snapshot;
|
|
699
|
+
onProgress({ state: status.state, ...(status.progress ?? {}) });
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (status.state === "failed") {
|
|
704
|
+
const jobError = status.error ?? { status: 500, error: "render_failed" };
|
|
705
|
+
// The rendering replica died (deploy, crash, scale-in). Renders are
|
|
706
|
+
// idempotent — resubmit once before giving up.
|
|
707
|
+
if (jobError.error === "job_lost" && !resubmitted) {
|
|
708
|
+
resubmitted = true;
|
|
709
|
+
onProgress?.({ state: "restarted" });
|
|
710
|
+
jobId = await createJob();
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
throw jobFailureError(jobError, presId);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (status.state === "done") {
|
|
717
|
+
// The result may be tens of MB — generous timeout, and a couple of
|
|
718
|
+
// retries for transient blips (the stored result is re-fetchable).
|
|
719
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
720
|
+
try {
|
|
721
|
+
const res = await bridgeFetch(
|
|
722
|
+
`/api/cli/render-jobs/${encodeURIComponent(jobId)}/result`,
|
|
723
|
+
{ timeoutMs: resultTimeoutMs },
|
|
724
|
+
backendURL,
|
|
725
|
+
);
|
|
726
|
+
return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
|
|
727
|
+
} catch (err) {
|
|
728
|
+
if (
|
|
729
|
+
attempt >= 2 ||
|
|
730
|
+
err?.code === "result_expired" ||
|
|
731
|
+
err?.code === "job_not_found"
|
|
732
|
+
) {
|
|
733
|
+
throw err;
|
|
734
|
+
}
|
|
735
|
+
await sleep(pollMs);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
378
742
|
// Server-side OOXML render: ship authored slide JSX (+ deck-local assets) and
|
|
379
743
|
// get back a native .pptx (base64) rendered against the presentation's own CD.
|
|
380
744
|
// `slides`: { "<file>.jsx": "<jsx source>" }. `deckAssets`: { "assets/<f>": "<base64>" }.
|
|
745
|
+
// `onProgress` (optional) receives `{ state, done?, total?, stage? }` ticks on
|
|
746
|
+
// the job protocol; the sync fallback emits none.
|
|
381
747
|
export async function renderPptx({
|
|
382
748
|
presId,
|
|
383
749
|
slides,
|
|
384
750
|
deckAssets = null,
|
|
751
|
+
nativeSlides = null,
|
|
385
752
|
etag = null,
|
|
386
753
|
backendURL = null,
|
|
754
|
+
onProgress = null,
|
|
387
755
|
}) {
|
|
388
756
|
if (!presId) throw new Error("renderPptx: presId is required");
|
|
389
|
-
|
|
390
|
-
|
|
757
|
+
const hasSlides = slides && Object.keys(slides).length > 0;
|
|
758
|
+
const hasNative = nativeSlides && Object.keys(nativeSlides).length > 0;
|
|
759
|
+
if (!hasSlides && !hasNative) {
|
|
760
|
+
throw new Error("renderPptx: slides or nativeSlides is required");
|
|
391
761
|
}
|
|
392
762
|
const be = backendURL || (await resolveBackendForPres(presId));
|
|
393
|
-
const body = { slides };
|
|
763
|
+
const body = { slides: slides ?? {} };
|
|
394
764
|
if (deckAssets && Object.keys(deckAssets).length > 0) {
|
|
395
765
|
body.deckAssets = deckAssets;
|
|
396
766
|
}
|
|
767
|
+
// Reused native one-slide .pptx (base64), keyed by play-order filename — the
|
|
768
|
+
// server splices them in by numeric-aware sorted filename (contract #7).
|
|
769
|
+
if (hasNative) body.nativeSlides = nativeSlides;
|
|
770
|
+
if (etag) body.etag = etag;
|
|
771
|
+
|
|
772
|
+
if (await backendSupportsRenderJobs(be)) {
|
|
773
|
+
try {
|
|
774
|
+
return await renderViaJob({ presId, body, backendURL: be, onProgress });
|
|
775
|
+
} catch (err) {
|
|
776
|
+
// The backend speaks the protocol but its job store is down — the sync
|
|
777
|
+
// route renders in-process without Redis, so fall back to it.
|
|
778
|
+
if (err?.code !== "jobs_unavailable") throw err;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return renderViaSync({ presId, body, backendURL: be });
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Translate a deck the CLI captured LOCALLY (the shipped capture engine) into a
|
|
785
|
+
// native .pptx. The slow Chrome work already ran client-side, so this is a fast
|
|
786
|
+
// single synchronous request — the server runs only the IP-sensitive boxes→OOXML
|
|
787
|
+
// half. Returns the same shape as renderPptx ({ ok, base64, slideCount, … }).
|
|
788
|
+
export async function translateDeck({
|
|
789
|
+
presId,
|
|
790
|
+
captured,
|
|
791
|
+
nativeSlides = null,
|
|
792
|
+
etag = null,
|
|
793
|
+
backendURL = null,
|
|
794
|
+
}) {
|
|
795
|
+
if (!presId) throw new Error("translateDeck: presId is required");
|
|
796
|
+
if (!captured) throw new Error("translateDeck: captured is required");
|
|
797
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
798
|
+
const body = { captured };
|
|
799
|
+
// Reused native one-slide .pptx (base64), keyed by play-order filename — the
|
|
800
|
+
// server splices them in alongside the locally-captured JSX slides (contract
|
|
801
|
+
// #7). The slow capture already ran client-side; native slides are pass-through.
|
|
802
|
+
if (nativeSlides && Object.keys(nativeSlides).length > 0) {
|
|
803
|
+
body.nativeSlides = nativeSlides;
|
|
804
|
+
}
|
|
397
805
|
if (etag) body.etag = etag;
|
|
398
806
|
const res = await bridgeFetch(
|
|
399
|
-
`/api/cli/presentations/${encodeURIComponent(presId)}/
|
|
807
|
+
`/api/cli/presentations/${encodeURIComponent(presId)}/translate`,
|
|
400
808
|
{
|
|
401
809
|
method: "POST",
|
|
402
810
|
headers: { "content-type": "application/json" },
|
|
403
811
|
body: JSON.stringify(body),
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
// a mutation: give it a generous default and let env override it.
|
|
812
|
+
// The body carries inlined image bytes and the response a base64 .pptx —
|
|
813
|
+
// bandwidth, not server compute. Give it the render-grade timeout.
|
|
407
814
|
timeoutMs:
|
|
408
815
|
parseInt(process.env.SLIDEMIND_BRIDGE_RENDER_TIMEOUT_MS || "300000", 10) ||
|
|
409
816
|
300000,
|
|
@@ -413,30 +820,142 @@ export async function renderPptx({
|
|
|
413
820
|
return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
|
|
414
821
|
}
|
|
415
822
|
|
|
823
|
+
// Run a pane-relayed engine op; on `pane_suspended` (the server already waited
|
|
824
|
+
// ~25s for a reconnect) wait CLIENT-side for the pane to wake — one actionable
|
|
825
|
+
// line, then a single retry against the live entry (covers presId churn too).
|
|
826
|
+
// Engine ops are idempotent reads, so the retry is safe.
|
|
827
|
+
async function engineOpWithPaneWait(presId, backendURL, attempt) {
|
|
828
|
+
try {
|
|
829
|
+
return await attempt(presId, backendURL);
|
|
830
|
+
} catch (err) {
|
|
831
|
+
if (err?.code !== "pane_suspended") throw err;
|
|
832
|
+
const live = await waitForPaneReady(presId, {
|
|
833
|
+
backendURL,
|
|
834
|
+
onWait: paneWaitNotice,
|
|
835
|
+
});
|
|
836
|
+
if (!live) throw err;
|
|
837
|
+
return attempt(live.presId, live.backendURL ?? backendURL);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
416
841
|
// Brand-engine search through the local bridge. New add-ins run this
|
|
417
842
|
// server-side with the relayed user token; older add-ins may fall back to a
|
|
418
|
-
// task-pane proxy. Returns a searchId + numbered
|
|
419
|
-
|
|
843
|
+
// task-pane proxy. Returns a searchId + numbered results (image thumbnails, or
|
|
844
|
+
// slide results carrying per-slide text + metadata). `subtype`/`intent`/`top`
|
|
845
|
+
// are passed through for slide engines (image engines ignore intent); omit them
|
|
846
|
+
// and the server uses its defaults (back-compat with older add-ins).
|
|
847
|
+
export async function engineSearch({
|
|
848
|
+
presId,
|
|
849
|
+
engineId,
|
|
850
|
+
query,
|
|
851
|
+
subtype = null,
|
|
852
|
+
intent = null,
|
|
853
|
+
top = null,
|
|
854
|
+
backendURL = null,
|
|
855
|
+
// When the chosen pane is suspended, wait CLIENT-side for THIS window to wake
|
|
856
|
+
// (the single-window default). engineSearchAnyPane sets this false for all but
|
|
857
|
+
// the last candidate so it can fail over to another live window instead.
|
|
858
|
+
waitOnSuspend = true,
|
|
859
|
+
timeoutMs = null,
|
|
860
|
+
}) {
|
|
420
861
|
if (!presId) throw new Error("engineSearch: presId is required");
|
|
421
862
|
if (!engineId) throw new Error("engineSearch: engineId is required");
|
|
422
863
|
if (!query) throw new Error("engineSearch: query is required");
|
|
423
864
|
const be = backendURL || (await resolveBackendForPres(presId));
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
865
|
+
const body = { engineId, query };
|
|
866
|
+
if (subtype) body.subtype = subtype;
|
|
867
|
+
if (intent) body.intent = intent;
|
|
868
|
+
if (typeof top === "number") body.top = top;
|
|
869
|
+
const run = async (pid, b) => {
|
|
870
|
+
const res = await bridgeFetch(
|
|
871
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/search`,
|
|
872
|
+
{
|
|
873
|
+
method: "POST",
|
|
874
|
+
headers: { "content-type": "application/json" },
|
|
875
|
+
body: JSON.stringify(body),
|
|
876
|
+
timeoutMs: timeoutMs ?? engineTimeoutMs(),
|
|
877
|
+
},
|
|
878
|
+
b,
|
|
879
|
+
);
|
|
880
|
+
return res.json(); // { ok, searchId, engineId, subtype, intent, resultCount, gridDataUri?, results }
|
|
881
|
+
};
|
|
882
|
+
return waitOnSuspend ? engineOpWithPaneWait(presId, be, run) : run(presId, be);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// A suspended/throttled background webview must not strand a window-AGNOSTIC
|
|
886
|
+
// engine search: the catalog is per-user, so any connected pane can answer it.
|
|
887
|
+
// Try the picked pane first, then every other connected pane on the same
|
|
888
|
+
// backend (non-suspended first), failing fast past each unresponsive one; the
|
|
889
|
+
// LAST candidate keeps the normal "click the pane to wake it" wait so a genuine
|
|
890
|
+
// single-window/all-asleep case still surfaces the actionable prompt. Returns
|
|
891
|
+
// the search result tagged with the presId/backendURL that actually answered —
|
|
892
|
+
// the searchId is bound to THAT window, so callers must thread it into
|
|
893
|
+
// follow-up view/insert/add-slide. An explicit presId pins the window (no
|
|
894
|
+
// failover — the caller asked for that specific one).
|
|
895
|
+
export async function engineSearchAnyPane({
|
|
896
|
+
registry,
|
|
897
|
+
explicitPresId = null,
|
|
898
|
+
engineId,
|
|
899
|
+
query,
|
|
900
|
+
subtype = null,
|
|
901
|
+
intent = null,
|
|
902
|
+
top = null,
|
|
903
|
+
}) {
|
|
904
|
+
const primary = pickAnyPresentationEntry(registry, explicitPresId);
|
|
905
|
+
const primaryBackend = primary.backendURL ?? null;
|
|
906
|
+
let candidates;
|
|
907
|
+
if (explicitPresId) {
|
|
908
|
+
candidates = [primary];
|
|
909
|
+
} else {
|
|
910
|
+
const others = (Array.isArray(registry) ? registry : [])
|
|
911
|
+
.filter(
|
|
912
|
+
(p) =>
|
|
913
|
+
p.presId !== primary.presId &&
|
|
914
|
+
p.connected !== false &&
|
|
915
|
+
(p.backendURL ?? null) === primaryBackend,
|
|
916
|
+
)
|
|
917
|
+
.sort((a, b) => (a.suspendedAt ? 1 : 0) - (b.suspendedAt ? 1 : 0));
|
|
918
|
+
candidates = [primary, ...others];
|
|
919
|
+
}
|
|
920
|
+
const failoverTimeout = envInt(
|
|
921
|
+
"SLIDEMIND_BRIDGE_ENGINE_FAILOVER_TIMEOUT_MS",
|
|
922
|
+
25000,
|
|
438
923
|
);
|
|
439
|
-
|
|
924
|
+
let lastErr = null;
|
|
925
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
926
|
+
const cand = candidates[i];
|
|
927
|
+
const isLast = i === candidates.length - 1;
|
|
928
|
+
try {
|
|
929
|
+
const res = await engineSearch({
|
|
930
|
+
presId: cand.presId,
|
|
931
|
+
engineId,
|
|
932
|
+
query,
|
|
933
|
+
subtype,
|
|
934
|
+
intent,
|
|
935
|
+
top,
|
|
936
|
+
backendURL: cand.backendURL ?? primaryBackend,
|
|
937
|
+
waitOnSuspend: isLast,
|
|
938
|
+
timeoutMs: isLast ? null : failoverTimeout,
|
|
939
|
+
});
|
|
940
|
+
return {
|
|
941
|
+
...res,
|
|
942
|
+
presId: cand.presId,
|
|
943
|
+
backendURL: cand.backendURL ?? primaryBackend,
|
|
944
|
+
};
|
|
945
|
+
} catch (err) {
|
|
946
|
+
lastErr = err;
|
|
947
|
+
// A suspended/frozen/slow pane is recoverable by trying the next window;
|
|
948
|
+
// anything else (bad engineId, auth, 4xx) is real — surface it now.
|
|
949
|
+
const recoverable =
|
|
950
|
+
err?.code === "pane_suspended" ||
|
|
951
|
+
err?.code === "pane_timeout" ||
|
|
952
|
+
err?.code === "bridge_timeout" ||
|
|
953
|
+
err?.status === 503 ||
|
|
954
|
+
err?.status === 504;
|
|
955
|
+
if (isLast || !recoverable) throw err;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
throw lastErr ?? new Error("engineSearchAnyPane: no candidate panes");
|
|
440
959
|
}
|
|
441
960
|
|
|
442
961
|
// Fetch a chosen engine result's bytes through the local bridge. For image
|
|
@@ -459,21 +978,134 @@ export async function engineDownload({
|
|
|
459
978
|
const be = backendURL || (await resolveBackendForPres(presId));
|
|
460
979
|
const body = { searchId, selection };
|
|
461
980
|
if (iconColor) body.iconColor = iconColor;
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
981
|
+
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
982
|
+
const res = await bridgeFetch(
|
|
983
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/download`,
|
|
984
|
+
{
|
|
985
|
+
method: "POST",
|
|
986
|
+
headers: { "content-type": "application/json" },
|
|
987
|
+
body: JSON.stringify(body),
|
|
988
|
+
// Same relay path as engine/search — outlast the server's reconnect
|
|
989
|
+
// wait + pane ack timeout.
|
|
990
|
+
timeoutMs:
|
|
991
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
992
|
+
90000,
|
|
993
|
+
},
|
|
994
|
+
b,
|
|
995
|
+
);
|
|
996
|
+
return res.json(); // { ok, base64, mimeType, svgMarkup }
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Extract a chosen slide-search result as a standalone one-slide .pptx (base64),
|
|
1001
|
+
// WITHOUT inserting it anywhere — the "fold into a deck" path. Relayed through
|
|
1002
|
+
// the pane like search/download.
|
|
1003
|
+
export async function extractSlide({
|
|
1004
|
+
presId,
|
|
1005
|
+
searchId,
|
|
1006
|
+
selection,
|
|
1007
|
+
backendURL = null,
|
|
1008
|
+
}) {
|
|
1009
|
+
if (!presId) throw new Error("extractSlide: presId is required");
|
|
1010
|
+
if (typeof searchId !== "number") {
|
|
1011
|
+
throw new Error("extractSlide: searchId (number) is required");
|
|
1012
|
+
}
|
|
1013
|
+
if (typeof selection !== "number") {
|
|
1014
|
+
throw new Error("extractSlide: selection (number) is required");
|
|
1015
|
+
}
|
|
1016
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
1017
|
+
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
1018
|
+
const res = await bridgeFetch(
|
|
1019
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-extract`,
|
|
1020
|
+
{
|
|
1021
|
+
method: "POST",
|
|
1022
|
+
headers: { "content-type": "application/json" },
|
|
1023
|
+
body: JSON.stringify({ searchId, selection }),
|
|
1024
|
+
timeoutMs:
|
|
1025
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
1026
|
+
90000,
|
|
1027
|
+
},
|
|
1028
|
+
b,
|
|
1029
|
+
);
|
|
1030
|
+
return res.json(); // { ok, base64, slideId?, presentationTitle? }
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Insert one or more chosen slide-search results live into the open
|
|
1035
|
+
// presentation (the "reuse → insert live" path). Relayed through the pane.
|
|
1036
|
+
export async function insertSlideLive({
|
|
1037
|
+
presId,
|
|
1038
|
+
searchId,
|
|
1039
|
+
selections,
|
|
1040
|
+
at = null,
|
|
1041
|
+
backendURL = null,
|
|
1042
|
+
}) {
|
|
1043
|
+
if (!presId) throw new Error("insertSlideLive: presId is required");
|
|
1044
|
+
if (typeof searchId !== "number") {
|
|
1045
|
+
throw new Error("insertSlideLive: searchId (number) is required");
|
|
1046
|
+
}
|
|
1047
|
+
if (!Array.isArray(selections) || selections.length === 0) {
|
|
1048
|
+
throw new Error("insertSlideLive: selections (number[]) is required");
|
|
1049
|
+
}
|
|
1050
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
1051
|
+
// Live insert is a MUTATION, so it carries an operationId for the add-in's
|
|
1052
|
+
// runMutationOnce dedup (the slide-insert handler keys an LRU on it). Generate
|
|
1053
|
+
// it ONCE here, before engineOpWithPaneWait — the body is built once and
|
|
1054
|
+
// reused across the wrapper's single pane_suspended retry, so the SAME id
|
|
1055
|
+
// rides both attempts and a retry can never double-insert. Mirrors how
|
|
1056
|
+
// buildAndPush makes one push operationId reused across attempts.
|
|
1057
|
+
const operationId = `insert-${randomUUID()}`;
|
|
1058
|
+
const body = { searchId, selections, operationId };
|
|
1059
|
+
if (typeof at === "number") body.at = at;
|
|
1060
|
+
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
1061
|
+
const res = await bridgeFetch(
|
|
1062
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-insert`,
|
|
1063
|
+
{
|
|
1064
|
+
method: "POST",
|
|
1065
|
+
headers: { "content-type": "application/json" },
|
|
1066
|
+
body: JSON.stringify(body),
|
|
1067
|
+
timeoutMs:
|
|
1068
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
1069
|
+
90000,
|
|
1070
|
+
},
|
|
1071
|
+
b,
|
|
1072
|
+
);
|
|
1073
|
+
return res.json(); // { ok, insertedCount, insertedSlideIds }
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// Fetch a chosen slide-search result's thumbnail/preview as a data URI for
|
|
1078
|
+
// on-demand viewing (the agent doesn't have to extract the whole .pptx just to
|
|
1079
|
+
// look at a slide). Relayed through the pane.
|
|
1080
|
+
export async function viewSlide({
|
|
1081
|
+
presId,
|
|
1082
|
+
searchId,
|
|
1083
|
+
selection,
|
|
1084
|
+
backendURL = null,
|
|
1085
|
+
}) {
|
|
1086
|
+
if (!presId) throw new Error("viewSlide: presId is required");
|
|
1087
|
+
if (typeof searchId !== "number") {
|
|
1088
|
+
throw new Error("viewSlide: searchId (number) is required");
|
|
1089
|
+
}
|
|
1090
|
+
if (typeof selection !== "number") {
|
|
1091
|
+
throw new Error("viewSlide: selection (number) is required");
|
|
1092
|
+
}
|
|
1093
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
1094
|
+
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
1095
|
+
const res = await bridgeFetch(
|
|
1096
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-view`,
|
|
1097
|
+
{
|
|
1098
|
+
method: "POST",
|
|
1099
|
+
headers: { "content-type": "application/json" },
|
|
1100
|
+
body: JSON.stringify({ searchId, selection }),
|
|
1101
|
+
timeoutMs:
|
|
1102
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
|
|
1103
|
+
90000,
|
|
1104
|
+
},
|
|
1105
|
+
b,
|
|
1106
|
+
);
|
|
1107
|
+
return res.json(); // { ok, dataUri, mimeType }
|
|
1108
|
+
});
|
|
477
1109
|
}
|
|
478
1110
|
|
|
479
1111
|
export async function insertSlides({
|
|
@@ -532,7 +1164,13 @@ export async function replaceSlides({
|
|
|
532
1164
|
// `backendURL`/`env` after a fan-out list), or throws with a helpful message if
|
|
533
1165
|
// the choice is ambiguous. Callers that already hold the entry can pass its
|
|
534
1166
|
// `backendURL` straight to the targeted ops, skipping a second fan-out.
|
|
535
|
-
|
|
1167
|
+
// `preferBrandId` (the deck's bundle-ref presFP) tie-breaks a presId open on
|
|
1168
|
+
// several backends — see tieBreakMatches.
|
|
1169
|
+
export function pickPresentationEntry(
|
|
1170
|
+
registry,
|
|
1171
|
+
explicitPresId,
|
|
1172
|
+
{ preferBrandId = null } = {},
|
|
1173
|
+
) {
|
|
536
1174
|
if (explicitPresId) {
|
|
537
1175
|
const matches = registry.filter((p) => p.presId === explicitPresId);
|
|
538
1176
|
if (matches.length === 0) {
|
|
@@ -542,9 +1180,12 @@ export function pickPresentationEntry(registry, explicitPresId) {
|
|
|
542
1180
|
);
|
|
543
1181
|
}
|
|
544
1182
|
if (matches.length > 1) {
|
|
1183
|
+
const winner = tieBreakMatches(matches, preferBrandId);
|
|
1184
|
+
if (winner) return winner;
|
|
545
1185
|
const envs = matches.map((m) => m.env || "?").join(", ");
|
|
546
1186
|
throw new Error(
|
|
547
|
-
`Presentation '${explicitPresId}' is open on multiple environments: ${envs}
|
|
1187
|
+
`Presentation '${explicitPresId}' is open on multiple environments: ${envs}, ` +
|
|
1188
|
+
"and no single connected one has the deck's corporate design active. " +
|
|
548
1189
|
"Disambiguate with --backend <env|label> (or SLIDEMIND_BACKEND).",
|
|
549
1190
|
);
|
|
550
1191
|
}
|
|
@@ -557,6 +1198,13 @@ export function pickPresentationEntry(registry, explicitPresId) {
|
|
|
557
1198
|
);
|
|
558
1199
|
}
|
|
559
1200
|
if (registry.length === 1) return registry[0];
|
|
1201
|
+
// ONE window mirrored across several paired backends is not a user choice —
|
|
1202
|
+
// apply the same tie-break as the explicit-presId path. Several DIFFERENT
|
|
1203
|
+
// windows stay a user decision.
|
|
1204
|
+
if (new Set(registry.map((p) => p.presId)).size === 1) {
|
|
1205
|
+
const winner = tieBreakMatches(registry, preferBrandId);
|
|
1206
|
+
if (winner) return winner;
|
|
1207
|
+
}
|
|
560
1208
|
const list = registry
|
|
561
1209
|
.map(
|
|
562
1210
|
(p) =>
|
|
@@ -569,6 +1217,31 @@ export function pickPresentationEntry(registry, explicitPresId) {
|
|
|
569
1217
|
);
|
|
570
1218
|
}
|
|
571
1219
|
|
|
1220
|
+
// Window-AGNOSTIC picker for engine ops (discovery / search / result view). The
|
|
1221
|
+
// engine catalog is per-user and NOT bound to any window, so any connected pane
|
|
1222
|
+
// can run the search — there is nothing for the caller to disambiguate. When no
|
|
1223
|
+
// explicit presId is given we attach to one automatically (preferring a live,
|
|
1224
|
+
// non-suspended pane so we don't have to wake a backgrounded one) instead of
|
|
1225
|
+
// throwing the "multiple windows, pick --to" error pickPresentationEntry raises.
|
|
1226
|
+
// An explicit presId still resolves exactly as pickPresentationEntry.
|
|
1227
|
+
//
|
|
1228
|
+
// CAVEAT: the searchId the add-in returns is bound to the pane that ran the
|
|
1229
|
+
// search (each window keeps its own search-context store). So follow-up
|
|
1230
|
+
// view/extract/insert must target the SAME entry this returns — surface its
|
|
1231
|
+
// presId to the caller so it can thread it back.
|
|
1232
|
+
export function pickAnyPresentationEntry(registry, explicitPresId, opts = {}) {
|
|
1233
|
+
if (explicitPresId) return pickPresentationEntry(registry, explicitPresId, opts);
|
|
1234
|
+
if (!Array.isArray(registry) || registry.length === 0) {
|
|
1235
|
+
throw new Error(
|
|
1236
|
+
"No PowerPoint windows registered with the slidemind add-in. " +
|
|
1237
|
+
"Open a presentation, ensure the add-in is loaded, and try again.",
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
return (
|
|
1241
|
+
registry.find((p) => p.connected !== false && !p.suspendedAt) ?? registry[0]
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
572
1245
|
// Back-compat shim: returns just the presId string. Prefer
|
|
573
1246
|
// pickPresentationEntry when you need the origin backend.
|
|
574
1247
|
export function pickPresentation(registry, explicitPresId) {
|