slidemind 0.1.5 → 0.1.7
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 -7
- package/CLAUDE.md +22 -14
- package/README.md +33 -15
- package/bin/slidemind.mjs +1124 -34
- package/package.json +2 -5
- package/skills/create-slides/SKILL.md +95 -47
- package/skills/search/SKILL.md +29 -26
- package/src/bridge/build-and-push.mjs +353 -66
- package/src/bridge/bundle.mjs +49 -7
- package/src/bridge/capture.mjs +2 -2
- package/src/bridge/client.mjs +572 -43
- package/src/bridge/engine-download.mjs +89 -0
- package/src/{mcp → bridge}/recolor.mjs +10 -9
- package/src/builders/build.mjs +209 -13
- package/src/builders/lint.mjs +2 -2
- package/src/builders/review-overlay.js +22 -7
- package/src/builders/review.mjs +8 -15
- package/src/installers/claude.mjs +10 -17
- package/src/installers/codex.mjs +41 -36
- package/src/installers/opencode.mjs +10 -18
- package/src/lib/reused-slides.mjs +2 -4
- package/src/mcp/server.mjs +0 -810
package/src/bridge/client.mjs
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
// open PowerPoint pane. No localhost, no shared loopback token, no cert.
|
|
7
7
|
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
|
+
import fs from "node:fs";
|
|
9
10
|
import http from "node:http";
|
|
10
11
|
import https from "node:https";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
11
14
|
|
|
12
15
|
import {
|
|
13
16
|
deriveEnvLabel,
|
|
@@ -15,6 +18,39 @@ import {
|
|
|
15
18
|
readSessions,
|
|
16
19
|
} from "./config.mjs";
|
|
17
20
|
|
|
21
|
+
// This CLI's own version, for the add-in's minCliVersion floor (see
|
|
22
|
+
// assertCliVersionSupported). Read lazily from the package manifest so the
|
|
23
|
+
// module stays importable even from an odd install layout.
|
|
24
|
+
let cliVersionCache = null;
|
|
25
|
+
export function cliVersion() {
|
|
26
|
+
if (cliVersionCache) return cliVersionCache;
|
|
27
|
+
try {
|
|
28
|
+
const pkgPath = path.join(
|
|
29
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
30
|
+
"..",
|
|
31
|
+
"..",
|
|
32
|
+
"package.json",
|
|
33
|
+
);
|
|
34
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
35
|
+
cliVersionCache = typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
36
|
+
} catch {
|
|
37
|
+
cliVersionCache = "0.0.0";
|
|
38
|
+
}
|
|
39
|
+
return cliVersionCache;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Numeric-aware dotted-version compare (no prerelease semantics — the CLI
|
|
43
|
+
// releases plain X.Y.Z). Returns <0 / 0 / >0 like a comparator.
|
|
44
|
+
export function compareCliVersions(a, b) {
|
|
45
|
+
const pa = String(a ?? "").split(".").map((n) => parseInt(n, 10) || 0);
|
|
46
|
+
const pb = String(b ?? "").split(".").map((n) => parseInt(n, 10) || 0);
|
|
47
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
|
48
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
49
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
18
54
|
function isLoopbackHostname(hostname) {
|
|
19
55
|
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
|
|
20
56
|
}
|
|
@@ -41,10 +77,30 @@ function engineTimeoutMs() {
|
|
|
41
77
|
);
|
|
42
78
|
}
|
|
43
79
|
|
|
80
|
+
function readinessTimeoutMs() {
|
|
81
|
+
return (
|
|
82
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_READY_TIMEOUT_MS || "20000", 10) ||
|
|
83
|
+
20000
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
44
87
|
const NOT_PAIRED_MESSAGE =
|
|
45
88
|
'slidemind is not paired. In the slidemind add-in, click "Connect CLI" ' +
|
|
46
89
|
"and run the shown `slidemind pair …` command, then retry.";
|
|
47
90
|
|
|
91
|
+
// The one-command remedy every host-precondition error now points at:
|
|
92
|
+
// `slidemind ready` runs the whole pairing → reachability → version → window →
|
|
93
|
+
// design ladder and materializes the bundle, so a stuck author has a single
|
|
94
|
+
// next step instead of a scavenger hunt across doctor/presentations/download.
|
|
95
|
+
// Appended (never substituted) to the existing prose so callers' assert.match
|
|
96
|
+
// on the original text keeps passing.
|
|
97
|
+
const READY_HINT =
|
|
98
|
+
"Run `slidemind ready` to check the connection and set up in one step.";
|
|
99
|
+
// pickPresentationEntry's variant: the caller already HAS a host, it's just
|
|
100
|
+
// ambiguous — `ready` resolves the window AND the design in one shot.
|
|
101
|
+
const READY_HINT_PICK =
|
|
102
|
+
"Or run `slidemind ready` first — it resolves the window and design in one step.";
|
|
103
|
+
|
|
48
104
|
// One transient blip — a pane mid-reconnect after a backend deploy, a stale
|
|
49
105
|
// registration the pane is about to refresh — should not fail a whole skill
|
|
50
106
|
// step. Idempotent reads retry a couple of times; mutations never do (the
|
|
@@ -251,6 +307,80 @@ async function listPresentationsForBackend(backendURL) {
|
|
|
251
307
|
});
|
|
252
308
|
}
|
|
253
309
|
|
|
310
|
+
async function readinessForBackend(backendURL) {
|
|
311
|
+
const env = deriveEnvLabel(backendURL);
|
|
312
|
+
try {
|
|
313
|
+
const res = await bridgeFetch(
|
|
314
|
+
"/api/cli/readiness",
|
|
315
|
+
{ timeoutMs: listTimeoutMs() },
|
|
316
|
+
backendURL,
|
|
317
|
+
);
|
|
318
|
+
const data = (await res.json()) ?? {};
|
|
319
|
+
const presentations = Array.isArray(data.presentations)
|
|
320
|
+
? data.presentations.map((p) => ({ ...p, backendURL, env }))
|
|
321
|
+
: [];
|
|
322
|
+
// Version-floor check for diagnostics (doctor / connection_status): newer
|
|
323
|
+
// add-ins advertise minCliVersion in the readiness capabilities, so the
|
|
324
|
+
// skew is visible BEFORE a render trips over it.
|
|
325
|
+
const minCliVersion =
|
|
326
|
+
typeof data.capabilities?.minCliVersion === "string"
|
|
327
|
+
? data.capabilities.minCliVersion
|
|
328
|
+
: null;
|
|
329
|
+
return {
|
|
330
|
+
env,
|
|
331
|
+
backendURL,
|
|
332
|
+
ok: true,
|
|
333
|
+
readinessV1: data.readinessV1 === true,
|
|
334
|
+
capabilities: data.capabilities ?? null,
|
|
335
|
+
minCliVersion,
|
|
336
|
+
cliUpdateRequired: Boolean(
|
|
337
|
+
minCliVersion && compareCliVersions(cliVersion(), minCliVersion) < 0,
|
|
338
|
+
),
|
|
339
|
+
presentations,
|
|
340
|
+
summary: data.summary ?? null,
|
|
341
|
+
};
|
|
342
|
+
} catch (err) {
|
|
343
|
+
if (err?.status !== 404) throw err;
|
|
344
|
+
// Older add-ins do not serve /readiness. Fall back to the legacy
|
|
345
|
+
// presentations route and mark the result as a compatibility response.
|
|
346
|
+
const presentations = await listPresentationsForBackend(backendURL);
|
|
347
|
+
return {
|
|
348
|
+
env,
|
|
349
|
+
backendURL,
|
|
350
|
+
ok: true,
|
|
351
|
+
readinessV1: false,
|
|
352
|
+
capabilities: null,
|
|
353
|
+
presentations,
|
|
354
|
+
summary: null,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export async function connectionStatus() {
|
|
360
|
+
const sessions = sessionsInScope();
|
|
361
|
+
const settled = await Promise.allSettled(
|
|
362
|
+
sessions.map((s) => readinessForBackend(s.backendURL)),
|
|
363
|
+
);
|
|
364
|
+
const backends = settled.map((r, i) => {
|
|
365
|
+
const backendURL = sessions[i].backendURL;
|
|
366
|
+
const env = deriveEnvLabel(backendURL);
|
|
367
|
+
if (r.status === "fulfilled") return r.value;
|
|
368
|
+
return {
|
|
369
|
+
env,
|
|
370
|
+
backendURL,
|
|
371
|
+
ok: false,
|
|
372
|
+
error: r.reason?.message ? r.reason.message : String(r.reason),
|
|
373
|
+
presentations: [],
|
|
374
|
+
};
|
|
375
|
+
});
|
|
376
|
+
return {
|
|
377
|
+
paired: sessions.length > 0,
|
|
378
|
+
backendCount: sessions.length,
|
|
379
|
+
backends,
|
|
380
|
+
presentations: backends.flatMap((b) => b.presentations ?? []),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
254
384
|
// Fan out across every backend in scope, concurrently. Never rejects: each
|
|
255
385
|
// backend's outcome is partitioned into merged presentations vs. a failure
|
|
256
386
|
// record, so one unreachable environment can't hide the others.
|
|
@@ -302,8 +432,10 @@ export async function listOpenPresentations() {
|
|
|
302
432
|
// The user/agent-facing shape of a presentation entry. `backendURL` is internal
|
|
303
433
|
// routing state — every caller targets a presentation by `presId` (the backend
|
|
304
434
|
// is re-resolved server-side from it), so the URL must never leave the process
|
|
305
|
-
// in any printed line or
|
|
306
|
-
// suspendedAt,
|
|
435
|
+
// in any printed line or command output. Keeps everything else — presId, title,
|
|
436
|
+
// fileName, connected, suspendedAt, the short `env` label, and the readiness
|
|
437
|
+
// fields (connectionState, designState, capabilities, nextAction) that agents
|
|
438
|
+
// parse from `presentations --json` to decide whether/how to target a pane.
|
|
307
439
|
export function toPublicPresentation(p) {
|
|
308
440
|
const { backendURL, ...rest } = p;
|
|
309
441
|
return rest;
|
|
@@ -342,16 +474,133 @@ export async function requireHost() {
|
|
|
342
474
|
"Open PowerPoint with the slidemind add-in and an active corporate design, then retry.\n" +
|
|
343
475
|
unreachable
|
|
344
476
|
.map((u) => ` (bridge: ${u.env}: ${u.error})`)
|
|
345
|
-
.join("\n")
|
|
477
|
+
.join("\n") +
|
|
478
|
+
`\n${READY_HINT}`,
|
|
346
479
|
);
|
|
347
480
|
}
|
|
348
481
|
throw new Error(
|
|
349
482
|
"No PowerPoint window is registered with the slidemind add-in.\n" +
|
|
350
483
|
"If PowerPoint is already open, bring it to the foreground and click the slidemind pane once so it re-registers, then retry.\n" +
|
|
351
|
-
"Otherwise open PowerPoint with the slidemind add-in and an active corporate design, then retry
|
|
484
|
+
"Otherwise open PowerPoint with the slidemind add-in and an active corporate design, then retry.\n" +
|
|
485
|
+
READY_HINT,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function hasReadinessFields(p) {
|
|
490
|
+
return (
|
|
491
|
+
p &&
|
|
492
|
+
(typeof p.connectionState === "string" ||
|
|
493
|
+
typeof p.designState === "string" ||
|
|
494
|
+
(p.capabilities && typeof p.capabilities === "object"))
|
|
352
495
|
);
|
|
353
496
|
}
|
|
354
497
|
|
|
498
|
+
export function isRenderReadyPresentation(p) {
|
|
499
|
+
if (!p) return false;
|
|
500
|
+
if (!hasReadinessFields(p)) return true;
|
|
501
|
+
if (p.capabilities?.render === true) return true;
|
|
502
|
+
if (p.designState === "ready" && p.capabilities?.render !== false) return true;
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export function isPaneReadyPresentation(p) {
|
|
507
|
+
if (!p) return false;
|
|
508
|
+
if (!hasReadinessFields(p)) return p.connected !== false && !p.suspendedAt;
|
|
509
|
+
if (p.capabilities?.paneOps === true) return true;
|
|
510
|
+
return (
|
|
511
|
+
p.connectionState === "connected" &&
|
|
512
|
+
p.connected !== false &&
|
|
513
|
+
!p.suspendedAt &&
|
|
514
|
+
p.connectionState !== "stale"
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function waitForReadyPresentation(predicate, { timeoutMs = null } = {}) {
|
|
519
|
+
const waitMs = timeoutMs ?? readinessTimeoutMs();
|
|
520
|
+
const pollMs = envInt("SLIDEMIND_BRIDGE_READY_POLL_MS", 1500);
|
|
521
|
+
const deadline = Date.now() + waitMs;
|
|
522
|
+
for (;;) {
|
|
523
|
+
const { presentations, unreachable } = await listOpenPresentationsDetailed();
|
|
524
|
+
if (presentations.some(predicate)) return presentations;
|
|
525
|
+
// Do not wait when nothing is registered at all. Waiting helps only for an
|
|
526
|
+
// already-open pane that is still hydrating brand/readiness metadata.
|
|
527
|
+
if (presentations.length === 0 || Date.now() >= deadline) {
|
|
528
|
+
return { presentations, unreachable, timedOut: presentations.length > 0 };
|
|
529
|
+
}
|
|
530
|
+
await sleep(pollMs);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// The bounded poll behind `slidemind presentations`. Unlike
|
|
535
|
+
// waitForReadyPresentation it PURPOSELY does NOT early-return on an empty
|
|
536
|
+
// registry: the empty window right after a pane reload / PowerPoint wake is
|
|
537
|
+
// exactly the flaky case this papers over. Polls the merged registry every
|
|
538
|
+
// SLIDEMIND_BRIDGE_READY_POLL_MS (default 1.5s) up to
|
|
539
|
+
// SLIDEMIND_BRIDGE_PRESENTATIONS_WAIT_MS (default 6s), stopping as soon as at
|
|
540
|
+
// least one pane is pane-ready. A `timeoutMs` of 0 means single-shot — one
|
|
541
|
+
// fetch, no sleep. Returns { all, ready } so the caller can list EVERY entry
|
|
542
|
+
// (suspended included, with its status) yet still know which are live. Does not
|
|
543
|
+
// touch waitForReadyPresentation / requirePaneHost — agent target selection
|
|
544
|
+
// still uses only ready panes.
|
|
545
|
+
export async function listReadyPresentationsWithWait({ timeoutMs = null } = {}) {
|
|
546
|
+
const waitMs =
|
|
547
|
+
timeoutMs ?? envInt("SLIDEMIND_BRIDGE_PRESENTATIONS_WAIT_MS", 6000);
|
|
548
|
+
const pollMs = envInt("SLIDEMIND_BRIDGE_READY_POLL_MS", 1500);
|
|
549
|
+
const deadline = Date.now() + waitMs;
|
|
550
|
+
for (;;) {
|
|
551
|
+
const all = await listOpenPresentations();
|
|
552
|
+
const ready = all.filter(isPaneReadyPresentation);
|
|
553
|
+
if (ready.length > 0 || waitMs <= 0 || Date.now() >= deadline) {
|
|
554
|
+
return { all, ready };
|
|
555
|
+
}
|
|
556
|
+
await sleep(pollMs);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export async function requireRenderHost({ timeoutMs = null } = {}) {
|
|
561
|
+
const first = await listOpenPresentationsDetailed();
|
|
562
|
+
if (first.presentations.some(isRenderReadyPresentation)) {
|
|
563
|
+
return first.presentations;
|
|
564
|
+
}
|
|
565
|
+
if (first.presentations.length > 0) {
|
|
566
|
+
const waited = await waitForReadyPresentation(isRenderReadyPresentation, {
|
|
567
|
+
timeoutMs,
|
|
568
|
+
});
|
|
569
|
+
if (Array.isArray(waited)) return waited;
|
|
570
|
+
if (waited.presentations.some(isRenderReadyPresentation)) {
|
|
571
|
+
return waited.presentations;
|
|
572
|
+
}
|
|
573
|
+
throw new Error(
|
|
574
|
+
"PowerPoint is registered with the slidemind add-in, but no window is render-ready yet. " +
|
|
575
|
+
"Wait for the corporate design to finish matching, or pick/create a corporate design in the add-in, then retry. " +
|
|
576
|
+
READY_HINT,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
return requireHost();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export async function requirePaneHost({ timeoutMs = null } = {}) {
|
|
583
|
+
const first = await listOpenPresentationsDetailed();
|
|
584
|
+
if (first.presentations.some(isPaneReadyPresentation)) {
|
|
585
|
+
return first.presentations.filter(isPaneReadyPresentation);
|
|
586
|
+
}
|
|
587
|
+
if (first.presentations.length > 0) {
|
|
588
|
+
const waited = await waitForReadyPresentation(isPaneReadyPresentation, {
|
|
589
|
+
timeoutMs,
|
|
590
|
+
});
|
|
591
|
+
if (Array.isArray(waited)) return waited.filter(isPaneReadyPresentation);
|
|
592
|
+
if (waited.presentations.some(isPaneReadyPresentation)) {
|
|
593
|
+
return waited.presentations.filter(isPaneReadyPresentation);
|
|
594
|
+
}
|
|
595
|
+
throw new Error(
|
|
596
|
+
"PowerPoint is registered with the slidemind add-in, but the task pane is not ready for pane operations. " +
|
|
597
|
+
"Bring PowerPoint forward and click or reopen the slidemind pane, then retry. " +
|
|
598
|
+
READY_HINT,
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
return requireHost();
|
|
602
|
+
}
|
|
603
|
+
|
|
355
604
|
// Tie-break a multi-backend presId collision without bothering the user when
|
|
356
605
|
// only one candidate can actually serve the deck:
|
|
357
606
|
// (a) exactly one CONNECTED pane whose active CD matches the deck's bundle
|
|
@@ -361,7 +610,7 @@ export async function requireHost() {
|
|
|
361
610
|
// e.g. a local dev backend connected without a CD next to prod with one.
|
|
362
611
|
// Returns the winning entry or null when genuinely ambiguous.
|
|
363
612
|
function tieBreakMatches(matches, preferBrandId) {
|
|
364
|
-
const live = matches.filter(
|
|
613
|
+
const live = matches.filter(isPaneReadyPresentation);
|
|
365
614
|
if (preferBrandId) {
|
|
366
615
|
const want = live.filter((m) => m.brandId === preferBrandId);
|
|
367
616
|
if (want.length === 1) return want[0];
|
|
@@ -433,7 +682,7 @@ export async function waitForPaneReady(
|
|
|
433
682
|
/* transient list failure — poll again until the deadline */
|
|
434
683
|
}
|
|
435
684
|
const connected = list.filter(
|
|
436
|
-
(p) => p
|
|
685
|
+
(p) => isPaneReadyPresentation(p),
|
|
437
686
|
);
|
|
438
687
|
const same = connected.find((p) => p.presId === presId);
|
|
439
688
|
if (same) return same;
|
|
@@ -455,7 +704,7 @@ export async function waitForPaneReady(
|
|
|
455
704
|
}
|
|
456
705
|
|
|
457
706
|
// The one actionable line a human needs when a pane op is waiting. stderr so
|
|
458
|
-
// it reaches the terminal from the CLI
|
|
707
|
+
// it reaches the terminal from the CLI.
|
|
459
708
|
function paneWaitNotice(seconds) {
|
|
460
709
|
process.stderr.write(
|
|
461
710
|
"task pane suspended — click the slidemind pane in PowerPoint to wake it " +
|
|
@@ -482,6 +731,64 @@ export async function getDesignBundle(
|
|
|
482
731
|
});
|
|
483
732
|
}
|
|
484
733
|
|
|
734
|
+
// GET the design bundle by brandId over the pane-free brand route. 404
|
|
735
|
+
// `unknown_brand` is definitive (unknown / cross-tenant / not granted to this
|
|
736
|
+
// user), so no transient retry. Requires the resolved backendURL — use
|
|
737
|
+
// getDesignBundleForBrandAnyBackend when it isn't known yet.
|
|
738
|
+
export async function getDesignBundleForBrand(brandId, { backendURL } = {}) {
|
|
739
|
+
if (!brandId) {
|
|
740
|
+
throw new Error("getDesignBundleForBrand: brandId is required");
|
|
741
|
+
}
|
|
742
|
+
if (!backendURL) {
|
|
743
|
+
throw new Error("getDesignBundleForBrand: backendURL is required");
|
|
744
|
+
}
|
|
745
|
+
const res = await bridgeFetch(
|
|
746
|
+
`/api/cli/brands/${encodeURIComponent(brandId)}/design-bundle`,
|
|
747
|
+
{},
|
|
748
|
+
backendURL,
|
|
749
|
+
);
|
|
750
|
+
return res.json();
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Resolve WHICH paired backend serves a brand by asking each one that
|
|
754
|
+
// advertises brand routes — a bound deck has no open window, so there is no
|
|
755
|
+
// registry entry to resolve a backend from. presFP/brandId is globally unique
|
|
756
|
+
// across deployments (the bundle-cache contract), so at most one backend
|
|
757
|
+
// answers 2xx. Returns { wire, backendURL }; throws a typed
|
|
758
|
+
// `brand_route_unavailable` when no backend serves the brand (old add-ins,
|
|
759
|
+
// unknown brand, missing grant) so callers fall back to the pane flow.
|
|
760
|
+
export async function getDesignBundleForBrandAnyBackend(brandId) {
|
|
761
|
+
if (!brandId) {
|
|
762
|
+
throw new Error("getDesignBundleForBrandAnyBackend: brandId is required");
|
|
763
|
+
}
|
|
764
|
+
const sessions = sessionsInScope();
|
|
765
|
+
const failures = [];
|
|
766
|
+
for (const s of sessions) {
|
|
767
|
+
try {
|
|
768
|
+
if (!(await backendSupportsBrandRoutes(s.backendURL))) {
|
|
769
|
+
failures.push({ env: deriveEnvLabel(s.backendURL), reason: "no brand routes" });
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
const wire = await getDesignBundleForBrand(brandId, {
|
|
773
|
+
backendURL: s.backendURL,
|
|
774
|
+
});
|
|
775
|
+
return { wire, backendURL: s.backendURL };
|
|
776
|
+
} catch (err) {
|
|
777
|
+
failures.push({
|
|
778
|
+
env: deriveEnvLabel(s.backendURL),
|
|
779
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
|
|
784
|
+
const err = new Error(
|
|
785
|
+
`no paired backend serves design '${brandId}' over the pane-free brand ` +
|
|
786
|
+
`route${detail ? ` (${detail})` : ""}.`,
|
|
787
|
+
);
|
|
788
|
+
err.code = "brand_route_unavailable";
|
|
789
|
+
throw err;
|
|
790
|
+
}
|
|
791
|
+
|
|
485
792
|
// List the LIVE engine catalog (NOT the bundle's curated set) for the paired
|
|
486
793
|
// backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
|
|
487
794
|
// when several environments are paired this fans out and tags each engine with
|
|
@@ -508,8 +815,8 @@ async function listEnginesForBackend(backendURL, { type = null, subtype = null }
|
|
|
508
815
|
});
|
|
509
816
|
}
|
|
510
817
|
|
|
511
|
-
export async function listEngines({ type = null, subtype = null } = {}) {
|
|
512
|
-
const sessions = sessionsInScope();
|
|
818
|
+
export async function listEngines({ type = null, subtype = null, backendURL = null } = {}) {
|
|
819
|
+
const sessions = backendURL ? [sessionForBackend(backendURL)] : sessionsInScope();
|
|
513
820
|
const settled = await Promise.allSettled(
|
|
514
821
|
sessions.map((s) => listEnginesForBackend(s.backendURL, { type, subtype })),
|
|
515
822
|
);
|
|
@@ -559,10 +866,10 @@ function sleep(ms) {
|
|
|
559
866
|
// One capability probe per backend per process. Keyed by the RESOLVED backend
|
|
560
867
|
// URL (callers always resolve before probing), so multiple paired backends on
|
|
561
868
|
// mixed versions each get their own answer.
|
|
562
|
-
const
|
|
869
|
+
const backendCapabilitiesCache = new Map();
|
|
563
870
|
|
|
564
|
-
function
|
|
565
|
-
let probe =
|
|
871
|
+
function backendCapabilities(backendURL) {
|
|
872
|
+
let probe = backendCapabilitiesCache.get(backendURL);
|
|
566
873
|
if (!probe) {
|
|
567
874
|
probe = (async () => {
|
|
568
875
|
try {
|
|
@@ -571,26 +878,59 @@ function backendSupportsRenderJobs(backendURL) {
|
|
|
571
878
|
{ timeoutMs: listTimeoutMs() },
|
|
572
879
|
backendURL,
|
|
573
880
|
);
|
|
574
|
-
|
|
575
|
-
return data?.renderJobs === true;
|
|
881
|
+
return (await res.json()) ?? {};
|
|
576
882
|
} catch {
|
|
577
|
-
// Older backends 404 this path (with a non-JSON body) —
|
|
578
|
-
|
|
883
|
+
// Older backends 404 this path (with a non-JSON body) — no
|
|
884
|
+
// capabilities: sync render, no version floor.
|
|
885
|
+
return {};
|
|
579
886
|
}
|
|
580
887
|
})();
|
|
581
|
-
|
|
888
|
+
backendCapabilitiesCache.set(backendURL, probe);
|
|
582
889
|
}
|
|
583
890
|
return probe;
|
|
584
891
|
}
|
|
585
892
|
|
|
893
|
+
async function backendSupportsRenderJobs(backendURL) {
|
|
894
|
+
const caps = await backendCapabilities(backendURL);
|
|
895
|
+
return caps?.renderJobs === true;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Pane-free brand-scoped routes (/api/cli/brands/:brandId/*): design-bundle,
|
|
899
|
+
// translate and render keyed by the brandId the deck's .bundle-ref.json already
|
|
900
|
+
// stores (presFP == brandId), instead of the pane registry — so a bound deck
|
|
901
|
+
// keeps building after every pane key aged out of the add-in's registry (or
|
|
902
|
+
// with PowerPoint closed entirely). Advertised by newer add-ins only.
|
|
903
|
+
export async function backendSupportsBrandRoutes(backendURL) {
|
|
904
|
+
const caps = await backendCapabilities(backendURL);
|
|
905
|
+
return caps?.brandRoutes === true;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// The add-in's hard version floor: /api/cli/capabilities advertises the oldest
|
|
909
|
+
// CLI it still speaks (`minCliVersion`). An older CLI must refuse to proceed —
|
|
910
|
+
// mid-rollout skew otherwise surfaces as confusing route/contract errors deep
|
|
911
|
+
// in a render — and prompt the one actionable fix. No-op when the backend
|
|
912
|
+
// doesn't advertise a floor (older add-ins) or the CLI meets it.
|
|
913
|
+
export async function assertCliVersionSupported(backendURL) {
|
|
914
|
+
const caps = await backendCapabilities(backendURL);
|
|
915
|
+
const min = typeof caps?.minCliVersion === "string" ? caps.minCliVersion : null;
|
|
916
|
+
if (!min || compareCliVersions(cliVersion(), min) >= 0) return;
|
|
917
|
+
const err = new Error(
|
|
918
|
+
`slidemind CLI ${cliVersion()} is older than the minimum this add-in ` +
|
|
919
|
+
`supports (${min}). Run \`slidemind update\` (or ` +
|
|
920
|
+
"`npm install -g slidemind@latest`), then retry.",
|
|
921
|
+
);
|
|
922
|
+
err.code = "cli_update_required";
|
|
923
|
+
throw err;
|
|
924
|
+
}
|
|
925
|
+
|
|
586
926
|
// Rebuild a job failure into the exact error shape bridgeFetch throws for the
|
|
587
|
-
// sync route, so callers (etag self-heal,
|
|
927
|
+
// sync route, so callers (etag self-heal, error texts) can't tell the
|
|
588
928
|
// protocols apart.
|
|
589
|
-
function jobFailureError(jobError,
|
|
929
|
+
function jobFailureError(jobError, label) {
|
|
590
930
|
const status = jobError?.status ?? 500;
|
|
591
931
|
const detail = jobError?.error ?? "render_failed";
|
|
592
932
|
const err = new Error(
|
|
593
|
-
`slidemind bridge ${status} render job failed for ${
|
|
933
|
+
`slidemind bridge ${status} render job failed for ${label}: ${detail}`,
|
|
594
934
|
);
|
|
595
935
|
err.status = status;
|
|
596
936
|
err.code = detail;
|
|
@@ -598,9 +938,18 @@ function jobFailureError(jobError, presId) {
|
|
|
598
938
|
return err;
|
|
599
939
|
}
|
|
600
940
|
|
|
601
|
-
|
|
941
|
+
// The render/translate routes exist on two surfaces sharing one contract:
|
|
942
|
+
// /api/cli/presentations/:presId — brandId resolved from the pane registry
|
|
943
|
+
// /api/cli/brands/:brandId — pane-free (bound deck, window optional)
|
|
944
|
+
function renderBasePath({ presId, brandId }) {
|
|
945
|
+
return brandId
|
|
946
|
+
? `/api/cli/brands/${encodeURIComponent(brandId)}`
|
|
947
|
+
: `/api/cli/presentations/${encodeURIComponent(presId)}`;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
async function renderViaSync({ basePath, body, backendURL }) {
|
|
602
951
|
const res = await bridgeFetch(
|
|
603
|
-
|
|
952
|
+
`${basePath}/render-pptx`,
|
|
604
953
|
{
|
|
605
954
|
method: "POST",
|
|
606
955
|
headers: { "content-type": "application/json" },
|
|
@@ -617,7 +966,7 @@ async function renderViaSync({ presId, body, backendURL }) {
|
|
|
617
966
|
return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
|
|
618
967
|
}
|
|
619
968
|
|
|
620
|
-
async function renderViaJob({
|
|
969
|
+
async function renderViaJob({ basePath, label, body, backendURL, onProgress }) {
|
|
621
970
|
const pollMs = envInt("SLIDEMIND_BRIDGE_RENDER_POLL_MS", 2500);
|
|
622
971
|
const deadlineMs = envInt("SLIDEMIND_BRIDGE_RENDER_DEADLINE_MS", 900000);
|
|
623
972
|
const resultTimeoutMs = envInt(
|
|
@@ -630,7 +979,7 @@ async function renderViaJob({ presId, body, backendURL, onProgress }) {
|
|
|
630
979
|
// would re-upload a multi-MB slide payload, and creation errors (stale
|
|
631
980
|
// etag, no CD, unknown presId) are definitive.
|
|
632
981
|
const res = await bridgeFetch(
|
|
633
|
-
|
|
982
|
+
`${basePath}/render-jobs`,
|
|
634
983
|
{
|
|
635
984
|
method: "POST",
|
|
636
985
|
headers: { "content-type": "application/json" },
|
|
@@ -675,7 +1024,7 @@ async function renderViaJob({ presId, body, backendURL, onProgress }) {
|
|
|
675
1024
|
// The job record vanished (backend Redis flush/restart) — same recovery
|
|
676
1025
|
// as a lost replica: resubmit once.
|
|
677
1026
|
if (err?.code === "job_not_found") {
|
|
678
|
-
if (resubmitted) throw jobFailureError({ status: 500, error: "job_lost" },
|
|
1027
|
+
if (resubmitted) throw jobFailureError({ status: 500, error: "job_lost" }, label);
|
|
679
1028
|
resubmitted = true;
|
|
680
1029
|
onProgress?.({ state: "restarted" });
|
|
681
1030
|
jobId = await createJob();
|
|
@@ -710,7 +1059,7 @@ async function renderViaJob({ presId, body, backendURL, onProgress }) {
|
|
|
710
1059
|
jobId = await createJob();
|
|
711
1060
|
continue;
|
|
712
1061
|
}
|
|
713
|
-
throw jobFailureError(jobError,
|
|
1062
|
+
throw jobFailureError(jobError, label);
|
|
714
1063
|
}
|
|
715
1064
|
|
|
716
1065
|
if (status.state === "done") {
|
|
@@ -744,8 +1093,15 @@ async function renderViaJob({ presId, body, backendURL, onProgress }) {
|
|
|
744
1093
|
// `slides`: { "<file>.jsx": "<jsx source>" }. `deckAssets`: { "assets/<f>": "<base64>" }.
|
|
745
1094
|
// `onProgress` (optional) receives `{ state, done?, total?, stage? }` ticks on
|
|
746
1095
|
// the job protocol; the sync fallback emits none.
|
|
1096
|
+
//
|
|
1097
|
+
// Target is EXACTLY ONE of:
|
|
1098
|
+
// presId — render via the pane registry (any add-in version)
|
|
1099
|
+
// brandId — render via the pane-free brand route (needs backendURL + a
|
|
1100
|
+
// backend advertising `brandRoutes`); `deckName` labels the render.
|
|
747
1101
|
export async function renderPptx({
|
|
748
|
-
presId,
|
|
1102
|
+
presId = null,
|
|
1103
|
+
brandId = null,
|
|
1104
|
+
deckName = null,
|
|
749
1105
|
slides,
|
|
750
1106
|
deckAssets = null,
|
|
751
1107
|
nativeSlides = null,
|
|
@@ -753,13 +1109,25 @@ export async function renderPptx({
|
|
|
753
1109
|
backendURL = null,
|
|
754
1110
|
onProgress = null,
|
|
755
1111
|
}) {
|
|
756
|
-
if (!presId
|
|
1112
|
+
if (!presId && !brandId) {
|
|
1113
|
+
throw new Error("renderPptx: presId or brandId is required");
|
|
1114
|
+
}
|
|
757
1115
|
const hasSlides = slides && Object.keys(slides).length > 0;
|
|
758
1116
|
const hasNative = nativeSlides && Object.keys(nativeSlides).length > 0;
|
|
759
1117
|
if (!hasSlides && !hasNative) {
|
|
760
1118
|
throw new Error("renderPptx: slides or nativeSlides is required");
|
|
761
1119
|
}
|
|
762
|
-
|
|
1120
|
+
let be = backendURL;
|
|
1121
|
+
if (!be) {
|
|
1122
|
+
if (brandId) throw new Error("renderPptx: backendURL is required with brandId");
|
|
1123
|
+
be = await resolveBackendForPres(presId);
|
|
1124
|
+
}
|
|
1125
|
+
// Version floor first: shipping a whole deck to a backend that no longer
|
|
1126
|
+
// speaks this CLI's contract fails deep and confusingly; fail here instead
|
|
1127
|
+
// with the actionable `slidemind update`.
|
|
1128
|
+
await assertCliVersionSupported(be);
|
|
1129
|
+
const basePath = renderBasePath({ presId, brandId });
|
|
1130
|
+
const label = brandId ? (deckName ?? brandId) : presId;
|
|
763
1131
|
const body = { slides: slides ?? {} };
|
|
764
1132
|
if (deckAssets && Object.keys(deckAssets).length > 0) {
|
|
765
1133
|
body.deckAssets = deckAssets;
|
|
@@ -768,17 +1136,20 @@ export async function renderPptx({
|
|
|
768
1136
|
// server splices them in by numeric-aware sorted filename (contract #7).
|
|
769
1137
|
if (hasNative) body.nativeSlides = nativeSlides;
|
|
770
1138
|
if (etag) body.etag = etag;
|
|
1139
|
+
// The brand surface has no presId to label the render with — pass the deck
|
|
1140
|
+
// slug so job records/temp dirs stay readable (server-side sanitized).
|
|
1141
|
+
if (brandId && deckName) body.deckName = deckName;
|
|
771
1142
|
|
|
772
1143
|
if (await backendSupportsRenderJobs(be)) {
|
|
773
1144
|
try {
|
|
774
|
-
return await renderViaJob({
|
|
1145
|
+
return await renderViaJob({ basePath, label, body, backendURL: be, onProgress });
|
|
775
1146
|
} catch (err) {
|
|
776
1147
|
// The backend speaks the protocol but its job store is down — the sync
|
|
777
1148
|
// route renders in-process without Redis, so fall back to it.
|
|
778
1149
|
if (err?.code !== "jobs_unavailable") throw err;
|
|
779
1150
|
}
|
|
780
1151
|
}
|
|
781
|
-
return renderViaSync({
|
|
1152
|
+
return renderViaSync({ basePath, body, backendURL: be });
|
|
782
1153
|
}
|
|
783
1154
|
|
|
784
1155
|
// Translate a deck the CLI captured LOCALLY (the shipped capture engine) into a
|
|
@@ -786,15 +1157,24 @@ export async function renderPptx({
|
|
|
786
1157
|
// single synchronous request — the server runs only the IP-sensitive boxes→OOXML
|
|
787
1158
|
// half. Returns the same shape as renderPptx ({ ok, base64, slideCount, … }).
|
|
788
1159
|
export async function translateDeck({
|
|
789
|
-
presId,
|
|
1160
|
+
presId = null,
|
|
1161
|
+
brandId = null,
|
|
1162
|
+
deckName = null,
|
|
790
1163
|
captured,
|
|
791
1164
|
nativeSlides = null,
|
|
792
1165
|
etag = null,
|
|
793
1166
|
backendURL = null,
|
|
794
1167
|
}) {
|
|
795
|
-
if (!presId
|
|
1168
|
+
if (!presId && !brandId) {
|
|
1169
|
+
throw new Error("translateDeck: presId or brandId is required");
|
|
1170
|
+
}
|
|
796
1171
|
if (!captured) throw new Error("translateDeck: captured is required");
|
|
797
|
-
|
|
1172
|
+
let be = backendURL;
|
|
1173
|
+
if (!be) {
|
|
1174
|
+
if (brandId) throw new Error("translateDeck: backendURL is required with brandId");
|
|
1175
|
+
be = await resolveBackendForPres(presId);
|
|
1176
|
+
}
|
|
1177
|
+
await assertCliVersionSupported(be);
|
|
798
1178
|
const body = { captured };
|
|
799
1179
|
// Reused native one-slide .pptx (base64), keyed by play-order filename — the
|
|
800
1180
|
// server splices them in alongside the locally-captured JSX slides (contract
|
|
@@ -803,8 +1183,9 @@ export async function translateDeck({
|
|
|
803
1183
|
body.nativeSlides = nativeSlides;
|
|
804
1184
|
}
|
|
805
1185
|
if (etag) body.etag = etag;
|
|
1186
|
+
if (brandId && deckName) body.deckName = deckName;
|
|
806
1187
|
const res = await bridgeFetch(
|
|
807
|
-
|
|
1188
|
+
`${renderBasePath({ presId, brandId })}/translate`,
|
|
808
1189
|
{
|
|
809
1190
|
method: "POST",
|
|
810
1191
|
headers: { "content-type": "application/json" },
|
|
@@ -821,14 +1202,20 @@ export async function translateDeck({
|
|
|
821
1202
|
}
|
|
822
1203
|
|
|
823
1204
|
// Run a pane-relayed engine op; on `pane_suspended` (the server already waited
|
|
824
|
-
// ~25s for a reconnect)
|
|
825
|
-
//
|
|
826
|
-
//
|
|
1205
|
+
// ~25s for a reconnect) OR `pane_timeout` (the pane looked connected but never
|
|
1206
|
+
// acked within the server's 60s window — the server marks it suspended on the
|
|
1207
|
+
// way out, so the same wake applies) wait CLIENT-side for the pane to wake —
|
|
1208
|
+
// one actionable line, then a single retry against the live entry (covers
|
|
1209
|
+
// presId churn too). Engine ops are idempotent reads, and the one mutation
|
|
1210
|
+
// (live insert) rides the SAME operationId through the retry so the pane-side
|
|
1211
|
+
// dedup makes a re-send safe.
|
|
827
1212
|
async function engineOpWithPaneWait(presId, backendURL, attempt) {
|
|
828
1213
|
try {
|
|
829
1214
|
return await attempt(presId, backendURL);
|
|
830
1215
|
} catch (err) {
|
|
831
|
-
if (err?.code !== "pane_suspended"
|
|
1216
|
+
if (err?.code !== "pane_suspended" && err?.code !== "pane_timeout") {
|
|
1217
|
+
throw err;
|
|
1218
|
+
}
|
|
832
1219
|
const live = await waitForPaneReady(presId, {
|
|
833
1220
|
backendURL,
|
|
834
1221
|
onWait: paneWaitNotice,
|
|
@@ -1108,6 +1495,46 @@ export async function viewSlide({
|
|
|
1108
1495
|
});
|
|
1109
1496
|
}
|
|
1110
1497
|
|
|
1498
|
+
// Read the CURRENT content of an open presentation (the "pull content" path
|
|
1499
|
+
// behind `slidemind read`). `mode` selects the
|
|
1500
|
+
// depth: "summary" returns a per-slide outline (index, layout, title, subtitle);
|
|
1501
|
+
// "raw" (default) returns each slide's full plain text — NOT the add-in's editing
|
|
1502
|
+
// markup. Unlike engine ops, content is WINDOW-BOUND: it reads THAT presentation,
|
|
1503
|
+
// so we resolve the origin backend from the presId and relay through that
|
|
1504
|
+
// window's pane. It's an idempotent read, so engineOpWithPaneWait's single
|
|
1505
|
+
// pane_suspended client-wait + retry against the live entry is safe (like viewSlide).
|
|
1506
|
+
export async function getPresentationContent({
|
|
1507
|
+
presId,
|
|
1508
|
+
mode = "raw",
|
|
1509
|
+
backendURL = null,
|
|
1510
|
+
}) {
|
|
1511
|
+
if (!presId) throw new Error("getPresentationContent: presId is required");
|
|
1512
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
1513
|
+
const body = { mode: mode === "summary" ? "summary" : "raw" };
|
|
1514
|
+
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
1515
|
+
const res = await bridgeFetch(
|
|
1516
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/content`,
|
|
1517
|
+
{
|
|
1518
|
+
method: "POST",
|
|
1519
|
+
headers: { "content-type": "application/json" },
|
|
1520
|
+
body: JSON.stringify(body),
|
|
1521
|
+
timeoutMs: engineTimeoutMs(),
|
|
1522
|
+
},
|
|
1523
|
+
b,
|
|
1524
|
+
);
|
|
1525
|
+
return res.json(); // { ok, mode, slideCount, slides }
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function ensurePaneAckOk(data, operation) {
|
|
1530
|
+
if (!data || data.ok !== false) return data ?? {};
|
|
1531
|
+
const detail = data.error || `${operation} failed`;
|
|
1532
|
+
const err = new Error(detail);
|
|
1533
|
+
err.code = data.code || `${operation}_failed`;
|
|
1534
|
+
err.body = data;
|
|
1535
|
+
throw err;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1111
1538
|
export async function insertSlides({
|
|
1112
1539
|
presId,
|
|
1113
1540
|
base64,
|
|
@@ -1132,7 +1559,7 @@ export async function insertSlides({
|
|
|
1132
1559
|
},
|
|
1133
1560
|
be,
|
|
1134
1561
|
);
|
|
1135
|
-
return res.json().catch(() => ({}));
|
|
1562
|
+
return ensurePaneAckOk(await res.json().catch(() => ({})), "insert");
|
|
1136
1563
|
}
|
|
1137
1564
|
|
|
1138
1565
|
export async function replaceSlides({
|
|
@@ -1156,7 +1583,107 @@ export async function replaceSlides({
|
|
|
1156
1583
|
},
|
|
1157
1584
|
be,
|
|
1158
1585
|
);
|
|
1159
|
-
return res.json().catch(() => ({}));
|
|
1586
|
+
return ensurePaneAckOk(await res.json().catch(() => ({})), "replace");
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// Non-throwing, PURE (no network) sibling of pickPresentationEntry that answers
|
|
1590
|
+
// the one question `slidemind ready` needs: which corporate design should this
|
|
1591
|
+
// session bind to? Where pickPresentationEntry PROMPTS the user by throwing,
|
|
1592
|
+
// resolveDesignSource returns a STRUCTURED verdict the CLI turns into a bound
|
|
1593
|
+
// bundle, a single blocker, or a "which design?" question — no exceptions as
|
|
1594
|
+
// control flow.
|
|
1595
|
+
//
|
|
1596
|
+
// The bar is RENDER-ready, never pane-ready: the add-in is the source of truth
|
|
1597
|
+
// for the design, and a backgrounded (pane-suspended) window still serves its
|
|
1598
|
+
// bundle and still renders — so it is a valid design source (the caller reports
|
|
1599
|
+
// `paneSuspended` but stays exit 0). Pane readiness only matters later, when a
|
|
1600
|
+
// push/search actually needs the live pane.
|
|
1601
|
+
//
|
|
1602
|
+
// The ONE user-facing ambiguity is ≥2 render-ready windows on DISTINCT designs
|
|
1603
|
+
// (distinct `brandId`). We deliberately do NOT let the deck's own bundle ref
|
|
1604
|
+
// (`deckPresFP`) break that tie — the open add-in wins; a deck never pins a
|
|
1605
|
+
// design preference. The deck ref only:
|
|
1606
|
+
// (a) tie-breaks ONE window mirrored across paired backends (same presId on
|
|
1607
|
+
// dev+prod) via tieBreakMatches — the SAME rule pickPresentationEntry
|
|
1608
|
+
// uses, and its only sanctioned use here; and
|
|
1609
|
+
// (b) surfaces the matching candidate as `suggested`, so the agent can say
|
|
1610
|
+
// "this deck was last built against X" WITHOUT the CLI silently deciding.
|
|
1611
|
+
//
|
|
1612
|
+
// Returns exactly one of:
|
|
1613
|
+
// { status: "none" } — nothing render-ready is open.
|
|
1614
|
+
// { status: "unknown_presid", presId } — an explicit --to names no
|
|
1615
|
+
// render-ready window.
|
|
1616
|
+
// { status: "resolved", entry } — the RAW registry entry (backendURL
|
|
1617
|
+
// intact; the caller strips it before
|
|
1618
|
+
// any user-facing output).
|
|
1619
|
+
// { status: "ambiguous", candidates, suggested }
|
|
1620
|
+
// — candidates deduped to ONE entry per
|
|
1621
|
+
// distinct design; suggested is the
|
|
1622
|
+
// presId of the deck-ref match, else
|
|
1623
|
+
// null.
|
|
1624
|
+
export function resolveDesignSource(
|
|
1625
|
+
registry,
|
|
1626
|
+
{ explicitPresId = null, deckPresFP = null } = {},
|
|
1627
|
+
) {
|
|
1628
|
+
const all = (Array.isArray(registry) ? registry : []).filter(
|
|
1629
|
+
isRenderReadyPresentation,
|
|
1630
|
+
);
|
|
1631
|
+
if (all.length === 0) return { status: "none" };
|
|
1632
|
+
|
|
1633
|
+
// Group by DESIGN. A render-ready pane always carries a brandId, but be
|
|
1634
|
+
// defensive: a missing brandId is treated as its OWN pseudo-design keyed by
|
|
1635
|
+
// presId so two CD-less windows never collapse into one false match.
|
|
1636
|
+
const designKey = (p) =>
|
|
1637
|
+
p.brandId != null && p.brandId !== ""
|
|
1638
|
+
? `brand:${p.brandId}`
|
|
1639
|
+
: `pres:${p.presId}`;
|
|
1640
|
+
|
|
1641
|
+
// Collapse a set of entries to one representative per distinct design,
|
|
1642
|
+
// preferring a live (pane-ready) pane over a suspended mirror, then compute
|
|
1643
|
+
// the deck-ref `suggested` presId over those representatives.
|
|
1644
|
+
const dedupeCandidates = (entries) => {
|
|
1645
|
+
const byKey = new Map();
|
|
1646
|
+
for (const p of entries) {
|
|
1647
|
+
const k = designKey(p);
|
|
1648
|
+
const cur = byKey.get(k);
|
|
1649
|
+
if (!cur || (!isPaneReadyPresentation(cur) && isPaneReadyPresentation(p))) {
|
|
1650
|
+
byKey.set(k, p);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
const candidates = [...byKey.values()];
|
|
1654
|
+
const suggested =
|
|
1655
|
+
(deckPresFP && candidates.find((c) => c.brandId === deckPresFP)?.presId) ||
|
|
1656
|
+
null;
|
|
1657
|
+
return { candidates, suggested };
|
|
1658
|
+
};
|
|
1659
|
+
|
|
1660
|
+
if (explicitPresId) {
|
|
1661
|
+
const matches = all.filter((p) => p.presId === explicitPresId);
|
|
1662
|
+
if (matches.length === 0) {
|
|
1663
|
+
return { status: "unknown_presid", presId: explicitPresId };
|
|
1664
|
+
}
|
|
1665
|
+
if (matches.length === 1) return { status: "resolved", entry: matches[0] };
|
|
1666
|
+
// >1 render-ready match == ONE window mirrored across paired backends.
|
|
1667
|
+
// Tie-break exactly as pickPresentationEntry; only a genuine tie is asked.
|
|
1668
|
+
const winner = tieBreakMatches(matches, deckPresFP);
|
|
1669
|
+
if (winner) return { status: "resolved", entry: winner };
|
|
1670
|
+
return { status: "ambiguous", ...dedupeCandidates(matches) };
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
const distinctDesigns = new Set(all.map(designKey));
|
|
1674
|
+
if (distinctDesigns.size === 1) {
|
|
1675
|
+
// One design open (possibly one window mirrored across backends, or several
|
|
1676
|
+
// windows on the SAME CD). Pick via the mirror tie-break, else a live pane,
|
|
1677
|
+
// else the first — the design is unambiguous either way.
|
|
1678
|
+
const entry =
|
|
1679
|
+
tieBreakMatches(all, deckPresFP) ??
|
|
1680
|
+
all.find(isPaneReadyPresentation) ??
|
|
1681
|
+
all[0];
|
|
1682
|
+
return { status: "resolved", entry };
|
|
1683
|
+
}
|
|
1684
|
+
// ≥2 distinct designs open — the only case that asks the user (the deck ref
|
|
1685
|
+
// does NOT auto-pick; it only annotates `suggested`).
|
|
1686
|
+
return { status: "ambiguous", ...dedupeCandidates(all) };
|
|
1160
1687
|
}
|
|
1161
1688
|
|
|
1162
1689
|
// Pick a target presentation given the (possibly multi-backend) registry and an
|
|
@@ -1213,7 +1740,7 @@ export function pickPresentationEntry(
|
|
|
1213
1740
|
)
|
|
1214
1741
|
.join("\n");
|
|
1215
1742
|
throw new Error(
|
|
1216
|
-
`Multiple PowerPoint windows are open. Pick one with --to <presId>:\n${list}`,
|
|
1743
|
+
`Multiple PowerPoint windows are open. Pick one with --to <presId>:\n${list}\n${READY_HINT_PICK}`,
|
|
1217
1744
|
);
|
|
1218
1745
|
}
|
|
1219
1746
|
|
|
@@ -1238,7 +1765,9 @@ export function pickAnyPresentationEntry(registry, explicitPresId, opts = {}) {
|
|
|
1238
1765
|
);
|
|
1239
1766
|
}
|
|
1240
1767
|
return (
|
|
1241
|
-
registry.find(
|
|
1768
|
+
registry.find(isPaneReadyPresentation) ??
|
|
1769
|
+
registry.find((p) => p.connected !== false && !p.suspendedAt) ??
|
|
1770
|
+
registry[0]
|
|
1242
1771
|
);
|
|
1243
1772
|
}
|
|
1244
1773
|
|