slidemind 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +31 -10
- package/README.md +4 -0
- package/bin/slidemind.mjs +1170 -199
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +18 -14
- package/skills/search/SKILL.md +24 -18
- package/src/bridge/build-and-push.mjs +19 -6
- package/src/bridge/client.mjs +890 -101
- package/src/bridge/engine-download.mjs +61 -8
- package/src/builders/resolve-skill.mjs +64 -2
- package/src/pptx/fill/engine.mjs +112 -134
package/bin/slidemind.mjs
CHANGED
|
@@ -68,7 +68,9 @@ function printHelp() {
|
|
|
68
68
|
` Verifies authorization/connection/CLI version, resolves the open\n` +
|
|
69
69
|
` design, materializes its bundle, and (with a deck) binds it.\n` +
|
|
70
70
|
` Exit 0 = ready; non-zero prints one actionable blocker.\n` +
|
|
71
|
+
` A deck already bound to a design readies with PowerPoint closed.\n` +
|
|
71
72
|
` --to <presId> bind to a specific open window\n` +
|
|
73
|
+
` --brand <id> bind to a design by id (presFP) — no window needed\n` +
|
|
72
74
|
` --force re-download the bundle even if current\n` +
|
|
73
75
|
` --connect-only check the connection only (no design/bundle)\n` +
|
|
74
76
|
` --json machine-readable readiness payload\n` +
|
|
@@ -80,7 +82,8 @@ function printHelp() {
|
|
|
80
82
|
` blocks until the user decides, prints DECISION: approved | comments\n` +
|
|
81
83
|
` --port <n> bind a fixed port (default: ephemeral)\n` +
|
|
82
84
|
` slidemind concepts <deck> After plan sign-off, generate one concept mockup PNG per planned\n` +
|
|
83
|
-
` slide into <deck>/concepts/concept-slide-NN.png (
|
|
85
|
+
` slide into <deck>/concepts/concept-slide-NN.png (bound deck,\n` +
|
|
86
|
+
` pane-free — no PowerPoint window needed).\n` +
|
|
84
87
|
` Read each as a design target, then rebuild it as slide JSX.\n` +
|
|
85
88
|
` Optional aid — skips cleanly (exit 0) if unavailable.\n` +
|
|
86
89
|
` slidemind build <deck> Build self-contained HTML\n` +
|
|
@@ -88,31 +91,46 @@ function printHelp() {
|
|
|
88
91
|
` approves or leaves comments (host-free)\n` +
|
|
89
92
|
` --port <n> bind a fixed port (default: ephemeral)\n` +
|
|
90
93
|
` slidemind build-pptx <deck> Export native .pptx — via the add-in for compose decks (host), LOCALLY for template packs (host-free)\n` +
|
|
94
|
+
` --pack <dir> explicit template-pack directory (plugin integrations)\n` +
|
|
91
95
|
` --to <presId> target a specific window (required if >1 open)\n` +
|
|
92
96
|
` --out <path> output path (default <deck>/<deck>.pptx)\n` +
|
|
97
|
+
` slidemind inspect-template-deck <deck> Inspect a template deck as JSON for pack-owned validation\n` +
|
|
98
|
+
` --pack <dir> explicit template-pack directory (required)\n` +
|
|
99
|
+
` --allow-unrendered-freeform include JSX inserts without .pptx artifacts\n` +
|
|
100
|
+
` --json print the normalized deck model\n` +
|
|
93
101
|
` slidemind view-draft <deck> Render the deck's slides to per-slide PNGs (no push, no .pptx)\n` +
|
|
94
102
|
` so the agent can look before review; host-free once the\n` +
|
|
95
103
|
` bundle is materialized\n` +
|
|
96
104
|
` --to <presId> window to fetch the bundle from (only if not cached)\n` +
|
|
97
105
|
` --out <dir> output dir (default <deck>/.draft-preview/)\n` +
|
|
98
106
|
` --open open each rendered PNG\n` +
|
|
99
|
-
` slidemind search "<query>" Search an engine or scope for images or slides (
|
|
107
|
+
` slidemind search "<query>" Search an engine or scope for images or slides (no window needed on\n` +
|
|
108
|
+
` add-ins that serve the pane-free engine routes; older ones relay\n` +
|
|
109
|
+
` through an open host)\n` +
|
|
100
110
|
` --engine <id> engine, or scope:all | scope:sharepoint to search unfiltered\n` +
|
|
101
111
|
` (default: first image engine; scope:all for slide intents)\n` +
|
|
102
112
|
` --result-type <t> slide | image — required for a scope:* target\n` +
|
|
103
113
|
` --intent <i> auto | content | visual (slide engines)\n` +
|
|
104
114
|
` --top <N> max results\n` +
|
|
105
|
-
` --
|
|
106
|
-
` --
|
|
107
|
-
`
|
|
115
|
+
` --deck <deck> route by this deck's bound design (multi-environment)\n` +
|
|
116
|
+
` --to <presId> pin a window on the relayed path (ignored pane-free)\n` +
|
|
117
|
+
` --json print the raw result payload (carries the searchId)\n` +
|
|
118
|
+
` slidemind view-slide [opts] Fetch a slide search result's preview image to a file\n` +
|
|
108
119
|
` --search <id> searchId from 'slidemind search'\n` +
|
|
109
120
|
` --pick <n> 1-based selection number to view\n` +
|
|
110
|
-
` --to <presId> the window the search ran on (
|
|
121
|
+
` --to <presId> the window the search ran on (relayed path only)\n` +
|
|
111
122
|
` --out <dir> output dir (default: a slidemind temp dir)\n` +
|
|
112
|
-
` slidemind
|
|
123
|
+
` slidemind read-source [opts] Read the ORIGINAL deck a slide search result came from\n` +
|
|
124
|
+
` --search <id> searchId from 'slidemind search'\n` +
|
|
125
|
+
` --pick <n> 1-based selection number\n` +
|
|
126
|
+
` --from-slide <n> first slide to read (1-based)\n` +
|
|
127
|
+
` --to-slide <n> last slide to read (1-based)\n` +
|
|
128
|
+
` --to <presId> the window the search ran on (relayed path only)\n` +
|
|
129
|
+
` --out <file> write the Markdown to a file instead of stdout\n` +
|
|
130
|
+
` slidemind ls engines List the live engine catalog + search scopes\n` +
|
|
113
131
|
` --type <t> collection | search | scope\n` +
|
|
114
132
|
` --subtype <s> image | slide | template | content\n` +
|
|
115
|
-
` --to <presId> target a specific window/backend\n` +
|
|
133
|
+
` --to <presId> target a specific window/backend (relayed path only)\n` +
|
|
116
134
|
` --all include every paired backend\n` +
|
|
117
135
|
` --json print the raw engine list\n` +
|
|
118
136
|
` slidemind insert-slide [opts] Insert reused slide search results live into a window (requires a host)\n` +
|
|
@@ -121,17 +139,18 @@ function printHelp() {
|
|
|
121
139
|
` --to <presId> target window (required if >1 open)\n` +
|
|
122
140
|
` --at <N> insert after slide N (0-based); default = append\n` +
|
|
123
141
|
` slidemind add-slide [opts] Fold reused slide(s) into a deck as native .pptx\n` +
|
|
124
|
-
` --search <id> searchId from 'slidemind search'
|
|
142
|
+
` --search <id> searchId from 'slidemind search'\n` +
|
|
125
143
|
` --from <file.pptx> fold slide(s) from a local .pptx instead (host-free)\n` +
|
|
126
144
|
` --pick <n,..> 1-based selection number(s)\n` +
|
|
127
145
|
` --deck <deck> deck to add the slides to\n` +
|
|
128
|
-
` --to <presId>
|
|
146
|
+
` --to <presId> the window the search ran on (relayed path only; ignored with --from)\n` +
|
|
129
147
|
` --at <N> numeric prefix to start at (default: append)\n` +
|
|
130
|
-
`
|
|
148
|
+
` --json machine-readable result (carries the searchId)\n` +
|
|
149
|
+
` slidemind download-result [opts] Download image/icon search result(s) into a deck's assets/\n` +
|
|
131
150
|
` --search <id> searchId from 'slidemind search'\n` +
|
|
132
151
|
` --pick <n,..> 1-based selection number(s)\n` +
|
|
133
152
|
` --deck <deck> deck to write the asset(s) into\n` +
|
|
134
|
-
` --to <presId> the window the search ran on (
|
|
153
|
+
` --to <presId> the window the search ran on (relayed path only)\n` +
|
|
135
154
|
` --icon-color <hex[,hex,..]> bake a colour into SVG result(s) (one for all, or one per --pick)\n` +
|
|
136
155
|
` slidemind imagen "<prompt>" Generate ONE image with the add-in's image model into a deck's assets/ (paired backend, pane-free)\n` +
|
|
137
156
|
` --deck <deck> bound deck to write the image into (required)\n` +
|
|
@@ -144,7 +163,9 @@ function printHelp() {
|
|
|
144
163
|
` --force re-download even if the cached bundle is current\n` +
|
|
145
164
|
` slidemind presentations List active PowerPoint panes registered with the add-in\n` +
|
|
146
165
|
` --all include retained suspended entries\n` +
|
|
147
|
-
` slidemind doctor Diagnose
|
|
166
|
+
` slidemind doctor Diagnose two independent ladders: backend authoring readiness\n` +
|
|
167
|
+
` (session, backend, CLI version, engine routes) and live\n` +
|
|
168
|
+
` PowerPoint readiness (windows, pane)\n` +
|
|
148
169
|
` --json print machine-readable status\n` +
|
|
149
170
|
` slidemind read [presId|file.pptx] Pull the content of an open presentation (requires a host),\n` +
|
|
150
171
|
` or of a local .pptx path (read host-free from disk)\n` +
|
|
@@ -429,15 +450,86 @@ async function cmdImagen(rest) {
|
|
|
429
450
|
);
|
|
430
451
|
}
|
|
431
452
|
|
|
453
|
+
async function cmdInspectTemplateDeck(rest) {
|
|
454
|
+
const { deck, passthrough } = splitDeckArg(rest, "inspect-template-deck");
|
|
455
|
+
let packDir = null;
|
|
456
|
+
let json = false;
|
|
457
|
+
let allowUnrenderedFreeform = false;
|
|
458
|
+
for (let i = 0; i < passthrough.length; i++) {
|
|
459
|
+
const a = passthrough[i];
|
|
460
|
+
if (a === "--pack") {
|
|
461
|
+
packDir = passthrough[++i];
|
|
462
|
+
if (!packDir) fail("--pack expects a directory");
|
|
463
|
+
} else if (a === "--json") {
|
|
464
|
+
json = true;
|
|
465
|
+
} else if (a === "--allow-unrendered-freeform") {
|
|
466
|
+
allowUnrenderedFreeform = true;
|
|
467
|
+
} else if (a.startsWith("-")) {
|
|
468
|
+
fail(`unknown flag: ${a}`);
|
|
469
|
+
} else {
|
|
470
|
+
fail(`unexpected argument: ${a}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (!packDir) fail("inspect-template-deck requires --pack <dir>");
|
|
474
|
+
|
|
475
|
+
const deckDir = resolveDeckArg(deck);
|
|
476
|
+
const resolveMod = await import(
|
|
477
|
+
pathToFileURL(path.join(SRC_DIR, "builders", "resolve-skill.mjs")).href
|
|
478
|
+
);
|
|
479
|
+
const skill = resolveMod.resolveSkillForDeck(deckDir, {
|
|
480
|
+
packDir: path.resolve(packDir),
|
|
481
|
+
});
|
|
482
|
+
if (skill.mode !== "fill-template") {
|
|
483
|
+
fail(`pack ${skill.skillDir} is mode ${skill.mode}, not fill-template`);
|
|
484
|
+
}
|
|
485
|
+
const engine = await import(
|
|
486
|
+
pathToFileURL(path.join(SRC_DIR, "pptx", "fill", "engine.mjs")).href
|
|
487
|
+
);
|
|
488
|
+
const result = engine.inspectFillTemplateDeck({
|
|
489
|
+
deckDir,
|
|
490
|
+
skill,
|
|
491
|
+
allowUnrenderedFreeform,
|
|
492
|
+
});
|
|
493
|
+
const payload = {
|
|
494
|
+
version: 1,
|
|
495
|
+
pack: {
|
|
496
|
+
name: skill.skillName,
|
|
497
|
+
mode: skill.mode,
|
|
498
|
+
directory: skill.skillDir,
|
|
499
|
+
template: result.templateKey,
|
|
500
|
+
locale: result.locale,
|
|
501
|
+
},
|
|
502
|
+
slides: result.sequence,
|
|
503
|
+
warnings: result.warnings,
|
|
504
|
+
problems: result.problems,
|
|
505
|
+
};
|
|
506
|
+
if (json) {
|
|
507
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
508
|
+
} else {
|
|
509
|
+
for (const item of result.sequence) {
|
|
510
|
+
const suffix = item.kind === "master" ? ` → template ${item.no}` : "";
|
|
511
|
+
process.stdout.write(`${item.file}: ${item.kind}${suffix}\n`);
|
|
512
|
+
}
|
|
513
|
+
for (const warning of result.warnings) process.stderr.write(` warning: ${warning}\n`);
|
|
514
|
+
}
|
|
515
|
+
if (result.problems.length) {
|
|
516
|
+
fail(`inspection failed:\n - ${result.problems.join("\n - ")}`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
432
520
|
async function cmdBuildPptx(rest) {
|
|
433
521
|
const { deck, passthrough } = splitDeckArg(rest, "build-pptx");
|
|
434
522
|
let to = null;
|
|
435
523
|
let outPath = null;
|
|
524
|
+
let packDir = null;
|
|
436
525
|
for (let i = 0; i < passthrough.length; i++) {
|
|
437
526
|
const a = passthrough[i];
|
|
438
527
|
if (a === "--to") {
|
|
439
528
|
to = passthrough[++i];
|
|
440
529
|
if (!to) fail("--to expects a presId");
|
|
530
|
+
} else if (a === "--pack") {
|
|
531
|
+
packDir = passthrough[++i];
|
|
532
|
+
if (!packDir) fail("--pack expects a directory");
|
|
441
533
|
} else if (a === "--out" || a === "-o") {
|
|
442
534
|
outPath = passthrough[++i];
|
|
443
535
|
if (!outPath) fail("--out expects a path");
|
|
@@ -460,8 +552,13 @@ async function cmdBuildPptx(rest) {
|
|
|
460
552
|
const resolveMod = await import(
|
|
461
553
|
pathToFileURL(path.join(SRC_DIR, "builders", "resolve-skill.mjs")).href
|
|
462
554
|
);
|
|
463
|
-
|
|
464
|
-
|
|
555
|
+
const resolveOpts = packDir ? { packDir: path.resolve(packDir) } : {};
|
|
556
|
+
const deckMode = resolveMod.resolveDeckMode(deckDir, resolveOpts);
|
|
557
|
+
if (packDir && deckMode.mode !== "fill-template") {
|
|
558
|
+
fail(`explicit --pack ${path.resolve(packDir)} is mode ${deckMode.mode}, not fill-template`);
|
|
559
|
+
}
|
|
560
|
+
if (deckMode.mode === "fill-template") {
|
|
561
|
+
const skill = resolveMod.resolveSkillForDeck(deckDir, resolveOpts);
|
|
465
562
|
if (to) {
|
|
466
563
|
fail(
|
|
467
564
|
"--to targets a PowerPoint window, but fill-template decks build " +
|
|
@@ -699,9 +796,169 @@ function writeSearchGrid(gridDataUri, searchId, resolveExt) {
|
|
|
699
796
|
}
|
|
700
797
|
}
|
|
701
798
|
|
|
799
|
+
// Where an engine op runs. Newer add-ins serve the engine surface off the CLI
|
|
800
|
+
// session itself (`/api/cli/engines`, `/api/cli/searches/*`), so discovery,
|
|
801
|
+
// search, download and extraction need no PowerPoint window at all; older ones
|
|
802
|
+
// relay every one of them through a task-pane webview, which is what
|
|
803
|
+
// requirePaneHost gates. The DIRECT path is preferred whenever it exists and the
|
|
804
|
+
// pane path stays as the compat fallback, so both add-in generations work.
|
|
805
|
+
//
|
|
806
|
+
// `--to <presId>` stops being a REQUIREMENT — the searchId is server-owned
|
|
807
|
+
// rather than bound to a window, so nothing has to be targeted — but it is still
|
|
808
|
+
// honoured, and naming a window means "relay through THAT pane". That is the
|
|
809
|
+
// escape hatch for the two things the pane-free routes refuse (`asset_needs_host`
|
|
810
|
+
// for SharePoint-hosted bytes, `recolor_needs_host` for an icon tint), both of
|
|
811
|
+
// which the pane can do; ignoring --to made their own remediation unreachable.
|
|
812
|
+
// `brandId` is the deck's bound design — the only thing that routes an engine op
|
|
813
|
+
// in a multi-environment session when no window is involved.
|
|
814
|
+
// No connected environment holds a server-owned search record. Authored once
|
|
815
|
+
// because every consumer of an opaque searchId reaches the same dead end and
|
|
816
|
+
// the same way out — the record's own environment is the only thing that can
|
|
817
|
+
// serve it, so a re-search is the fix regardless of who asked.
|
|
818
|
+
function expiredSearchMessage(searchId) {
|
|
819
|
+
return (
|
|
820
|
+
`search ${searchId} was not found on any connected environment — server-side ` +
|
|
821
|
+
"search records expire after ~2 hours. Re-run 'slidemind search' and pick again."
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async function resolveEngineRouting(client, { to = null, brandId = null, pick = "one", searchId = null } = {}) {
|
|
826
|
+
// An opaque (server-minted) searchId pins the route by itself: the record
|
|
827
|
+
// lives on the environment that minted it, and the pane relay can never
|
|
828
|
+
// serve it — its handler only knows its own per-window numeric ids. So the
|
|
829
|
+
// record's origin outranks the deck's design AND --to, and when no connected
|
|
830
|
+
// environment holds the record the only honest answer is "search again",
|
|
831
|
+
// not a pane fallback that is guaranteed to 4xx.
|
|
832
|
+
if (client.isOpaqueSearchId?.(searchId)) {
|
|
833
|
+
if (to) {
|
|
834
|
+
fail(
|
|
835
|
+
`searchId ${searchId} is a pane-free search — it cannot be relayed through a window. ` +
|
|
836
|
+
`To go through the pane, re-run 'slidemind search "<query>" --to ${to}' and use ITS searchId.`,
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
let origin = null;
|
|
840
|
+
try {
|
|
841
|
+
origin = await client.resolveSearchBackend(searchId);
|
|
842
|
+
} catch (err) {
|
|
843
|
+
// The probe distinguishes "no environment holds this record" from the
|
|
844
|
+
// failures that only LOOK like it — a dead session, a stale snapshot —
|
|
845
|
+
// and hands the latter back for their own remediation.
|
|
846
|
+
await failOnEngineBlocker(client, err, { to });
|
|
847
|
+
}
|
|
848
|
+
if (origin) {
|
|
849
|
+
return { direct: true, backendURL: origin, presId: null, registry: null, entry: null };
|
|
850
|
+
}
|
|
851
|
+
fail(expiredSearchMessage(searchId));
|
|
852
|
+
}
|
|
853
|
+
let backendURL = null;
|
|
854
|
+
if (!to) {
|
|
855
|
+
backendURL = await client.resolveEngineBackend({ brandId });
|
|
856
|
+
}
|
|
857
|
+
if (backendURL) {
|
|
858
|
+
return { direct: true, backendURL, presId: null, registry: null, entry: null };
|
|
859
|
+
}
|
|
860
|
+
const registry = await client.requirePaneHost();
|
|
861
|
+
const entry =
|
|
862
|
+
pick === "any"
|
|
863
|
+
? client.pickAnyPresentationEntry(registry, to)
|
|
864
|
+
: client.pickPresentationEntry(registry, to);
|
|
865
|
+
return {
|
|
866
|
+
direct: false,
|
|
867
|
+
backendURL: entry.backendURL ?? null,
|
|
868
|
+
presId: entry.presId,
|
|
869
|
+
registry,
|
|
870
|
+
entry,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// The design a deck is bound to (presFP == brandId), or null when it isn't bound
|
|
875
|
+
// / isn't a deck. Routing state only — never printed.
|
|
876
|
+
async function deckBrandId(deckDir) {
|
|
877
|
+
if (!deckDir) return null;
|
|
878
|
+
const bundleMod = await import(
|
|
879
|
+
pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
|
|
880
|
+
);
|
|
881
|
+
return bundleMod.readDeckBundleRef(deckDir)?.presFP ?? null;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// A pane-ready window, or null — never a throw, and never a wait. Used by the
|
|
885
|
+
// stale-snapshot paths below, where an open pane is an OPTION rather than a
|
|
886
|
+
// precondition. `to` is honoured only when it names a window that is actually
|
|
887
|
+
// registered; a stale --to must not cost the caller its fallback.
|
|
888
|
+
async function openPaneEntry(client, to = null) {
|
|
889
|
+
const registry = (await client.paneReadyPresentations?.()) ?? [];
|
|
890
|
+
if (registry.length === 0) return null;
|
|
891
|
+
const explicit = registry.some((p) => p.presId === to) ? to : null;
|
|
892
|
+
try {
|
|
893
|
+
return { registry, entry: client.pickAnyPresentationEntry(registry, explicit) };
|
|
894
|
+
} catch {
|
|
895
|
+
// Several windows and no way to choose — no automatic fallback from here.
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// The ONE rendering of a stale permission snapshot. The snapshot is the
|
|
901
|
+
// backend's cached copy of the user's group memberships and it backs the
|
|
902
|
+
// PANE-FREE path only: an open task pane signs every request with a live
|
|
903
|
+
// browser token, which is strictly fresher than any snapshot. So when a
|
|
904
|
+
// pane-ready window is already open, "open the slidemind add-in once" is wrong
|
|
905
|
+
// twice over — it IS open, and that window can serve a fresh search right now.
|
|
906
|
+
// Returns the message, or null when this isn't a stale-snapshot failure.
|
|
907
|
+
async function staleSnapshotMessage(client, err, { to = null } = {}) {
|
|
908
|
+
const blocker = client.permissionsStaleBlocker?.(err);
|
|
909
|
+
if (!blocker) return null;
|
|
910
|
+
const pane = await openPaneEntry(client, to);
|
|
911
|
+
if (!pane) return blocker.message;
|
|
912
|
+
const env = pane.entry.env ? ` [${pane.entry.env}]` : "";
|
|
913
|
+
return (
|
|
914
|
+
`${blocker.message} A slidemind pane is already open${env} and signs each request live, so a fresh ` +
|
|
915
|
+
`search through that window works now: slidemind search "<query>" --to ${pane.entry.presId}.`
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Engine-route failures the USER has to clear get one actionable line instead of
|
|
920
|
+
// a relay error. `permissions_stale` is the search permission snapshot having
|
|
921
|
+
// aged out — the server hands back the exact remediation ("open the slidemind
|
|
922
|
+
// add-in once…"), and only corpus search degrades; render and bundle don't.
|
|
923
|
+
// Anything else is re-thrown for the top-level handler.
|
|
924
|
+
async function failOnEngineBlocker(client, err, { to = null } = {}) {
|
|
925
|
+
const message = await staleSnapshotMessage(client, err, { to });
|
|
926
|
+
if (message) fail(message);
|
|
927
|
+
throw err;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// A stale snapshot that an OPEN PANE can simply route around. Only an operation
|
|
931
|
+
// that can be replayed pane-side qualifies — creating a NEW search, whose id the
|
|
932
|
+
// pane mints itself. A record-bound op on an opaque searchId cannot: the pane's
|
|
933
|
+
// handler only understands its own per-window numeric ids, so those get
|
|
934
|
+
// staleSnapshotMessage's extended blocker instead. Returns { registry, entry }
|
|
935
|
+
// or null.
|
|
936
|
+
async function paneRelayForStaleSnapshot(client, err, { to = null } = {}) {
|
|
937
|
+
if (!client.permissionsStaleBlocker?.(err)) return null;
|
|
938
|
+
return openPaneEntry(client, to);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// The pane-free routes refuse exactly two things outright: bytes that live in
|
|
942
|
+
// SharePoint (`asset_needs_host`, which needs a delegated token only a browser
|
|
943
|
+
// can mint) and an icon tint (`recolor_needs_host`, whose classifier is
|
|
944
|
+
// DOM-based). An open pane does both, so the remediation is the RELAY — name it,
|
|
945
|
+
// rather than echoing a 409 whose "retry" would take the identical route.
|
|
946
|
+
// Returns null for anything else.
|
|
947
|
+
function describeHostOnlyEngineFailure(err) {
|
|
948
|
+
const code = err?.code ?? err?.body?.code ?? null;
|
|
949
|
+
if (code !== "asset_needs_host" && code !== "recolor_needs_host") return null;
|
|
950
|
+
const detail =
|
|
951
|
+
typeof err?.body?.error === "string" ? err.body.error : (err?.message ?? "");
|
|
952
|
+
return (
|
|
953
|
+
`${detail} Run 'slidemind search "<query>" --to <presId>' against an open window and ` +
|
|
954
|
+
"re-pick, then repeat this download with the same --to — that relays it through the pane."
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
702
958
|
async function cmdSearch(rest) {
|
|
703
959
|
let asJson = false;
|
|
704
960
|
let to = null;
|
|
961
|
+
let deck = null;
|
|
705
962
|
let engineId = null;
|
|
706
963
|
let resultType = null;
|
|
707
964
|
let intent = null;
|
|
@@ -714,6 +971,9 @@ async function cmdSearch(rest) {
|
|
|
714
971
|
} else if (a === "--to") {
|
|
715
972
|
to = rest[++i];
|
|
716
973
|
if (!to) fail("--to expects a presId");
|
|
974
|
+
} else if (a === "--deck") {
|
|
975
|
+
deck = rest[++i];
|
|
976
|
+
if (!deck) fail("--deck expects a deck name or path");
|
|
717
977
|
} else if (a === "--engine" || a === "-e" || a === "--engineId") {
|
|
718
978
|
engineId = rest[++i];
|
|
719
979
|
if (!engineId) fail("--engine expects an engineId");
|
|
@@ -745,12 +1005,13 @@ async function cmdSearch(rest) {
|
|
|
745
1005
|
const query = queryParts.join(" ").trim();
|
|
746
1006
|
if (!query) {
|
|
747
1007
|
fail(
|
|
748
|
-
`usage: slidemind search "<query>" [--engine <id|scope:all|scope:sharepoint>] [--result-type slide|image] [--intent auto|content|visual] [--top <N>] [--to <presId>] [--json]`,
|
|
1008
|
+
`usage: slidemind search "<query>" [--engine <id|scope:all|scope:sharepoint>] [--result-type slide|image] [--intent auto|content|visual] [--top <N>] [--deck <deck>] [--to <presId>] [--json]`,
|
|
749
1009
|
);
|
|
750
1010
|
}
|
|
751
1011
|
|
|
752
|
-
// Engine search runs against the add-in's live engine over the bridge —
|
|
753
|
-
//
|
|
1012
|
+
// Engine search runs against the add-in's live engine over the bridge —
|
|
1013
|
+
// pane-free where the add-in serves the direct routes, relayed through an open
|
|
1014
|
+
// window (host required) where it doesn't.
|
|
754
1015
|
const client = await import(
|
|
755
1016
|
pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
|
|
756
1017
|
);
|
|
@@ -760,14 +1021,23 @@ async function cmdSearch(rest) {
|
|
|
760
1021
|
const { resolveAssetExt } = await import(
|
|
761
1022
|
pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
|
|
762
1023
|
);
|
|
763
|
-
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
1024
|
+
// The engine catalog is per-user, not window-bound. On the pane path search
|
|
1025
|
+
// attaches to any connected window (preferring a live one) and the chosen
|
|
1026
|
+
// window is printed below, because the searchId lives on that pane; on the
|
|
1027
|
+
// direct path the searchId is the backend's and no window is involved.
|
|
1028
|
+
const routing = await resolveEngineRouting(client, {
|
|
1029
|
+
to,
|
|
1030
|
+
brandId: await deckBrandId(deck ? resolveDeckArg(deck) : null),
|
|
1031
|
+
pick: "any",
|
|
1032
|
+
});
|
|
1033
|
+
const presId = routing.presId;
|
|
1034
|
+
const backendURL = routing.backendURL;
|
|
1035
|
+
// Mutable, because a stale permission snapshot can push a direct search onto
|
|
1036
|
+
// the pane relay below — and once it does, the searchId belongs to a WINDOW
|
|
1037
|
+
// and every next-step hint has to name it.
|
|
1038
|
+
let direct = routing.direct;
|
|
1039
|
+
let registry = routing.registry;
|
|
1040
|
+
let entry = routing.entry;
|
|
771
1041
|
|
|
772
1042
|
// An explicit content/visual intent means "search slides", not imagery.
|
|
773
1043
|
const slideIntent = intent === "content" || intent === "visual";
|
|
@@ -800,9 +1070,18 @@ async function cmdSearch(rest) {
|
|
|
800
1070
|
// Best-effort: a bundle that can't be materialized (e.g. an incomplete
|
|
801
1071
|
// one) must not replace the actionable "wire an image engine" message
|
|
802
1072
|
// below with a bundle error — the live catalog is the real answer here.
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
1073
|
+
// Pane path: fetch by the window's presId. Direct path: there is no
|
|
1074
|
+
// window, so only a bound deck (--deck) can name the design; without one
|
|
1075
|
+
// there is nothing to fall back to and the live catalog's answer stands.
|
|
1076
|
+
const brandId = presId
|
|
1077
|
+
? null
|
|
1078
|
+
: await deckBrandId(deck ? resolveDeckArg(deck) : null);
|
|
1079
|
+
const mat =
|
|
1080
|
+
presId || brandId
|
|
1081
|
+
? await bundle
|
|
1082
|
+
.materializeBundle(presId, { backendURL, brandId })
|
|
1083
|
+
.catch(() => null)
|
|
1084
|
+
: null;
|
|
806
1085
|
if (mat) pick = bundle.pickImageEngine(bundle.readEnginesFromDir(mat.dir));
|
|
807
1086
|
}
|
|
808
1087
|
if (!pick) {
|
|
@@ -829,23 +1108,65 @@ async function cmdSearch(rest) {
|
|
|
829
1108
|
resultType = null;
|
|
830
1109
|
}
|
|
831
1110
|
|
|
832
|
-
//
|
|
833
|
-
// window
|
|
834
|
-
//
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1111
|
+
// Direct: one pane-free call against the backend that owns the searchId.
|
|
1112
|
+
// Pane: window-agnostic — tries the picked pane, then fails over to any other
|
|
1113
|
+
// live window on the same backend if it's suspended/frozen. The answering
|
|
1114
|
+
// window (res.presId) is what reuse must target there, since the searchId is
|
|
1115
|
+
// bound to it.
|
|
1116
|
+
const relaySearch = (reg) =>
|
|
1117
|
+
client.engineSearchAnyPane({
|
|
1118
|
+
registry: reg,
|
|
1119
|
+
explicitPresId: to,
|
|
1120
|
+
engineId,
|
|
1121
|
+
query,
|
|
1122
|
+
subtype,
|
|
1123
|
+
resultType,
|
|
1124
|
+
intent: intent ?? null,
|
|
1125
|
+
top: top ?? null,
|
|
1126
|
+
});
|
|
1127
|
+
let res;
|
|
1128
|
+
try {
|
|
1129
|
+
res = direct
|
|
1130
|
+
? await client.engineSearch({
|
|
1131
|
+
engineId,
|
|
1132
|
+
query,
|
|
1133
|
+
subtype,
|
|
1134
|
+
resultType,
|
|
1135
|
+
intent: intent ?? null,
|
|
1136
|
+
top: top ?? null,
|
|
1137
|
+
backendURL,
|
|
1138
|
+
})
|
|
1139
|
+
: await relaySearch(routing.registry);
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
// A stale permission snapshot degrades the PANE-FREE path only — an open
|
|
1142
|
+
// pane signs each request live, which is strictly fresher than the
|
|
1143
|
+
// snapshot. Creating a new search is exactly the operation the pane can
|
|
1144
|
+
// replay (it mints its own searchId), so relay it rather than telling the
|
|
1145
|
+
// user to open an add-in that is already open.
|
|
1146
|
+
const relay = direct ? await paneRelayForStaleSnapshot(client, err, { to }) : null;
|
|
1147
|
+
if (!relay) await failOnEngineBlocker(client, err, { to });
|
|
1148
|
+
process.stderr.write(
|
|
1149
|
+
`note: this environment's search permission snapshot has aged out, so the search ran through the open ` +
|
|
1150
|
+
`slidemind pane${relay.entry.env ? ` [${relay.entry.env}]` : ""} instead (it signs each request live).\n`,
|
|
1151
|
+
);
|
|
1152
|
+
direct = false;
|
|
1153
|
+
registry = relay.registry;
|
|
1154
|
+
entry = relay.entry;
|
|
1155
|
+
try {
|
|
1156
|
+
res = await relaySearch(registry);
|
|
1157
|
+
} catch (relayErr) {
|
|
1158
|
+
await failOnEngineBlocker(client, relayErr, { to });
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
const ranPresId = res?.presId ?? entry?.presId ?? presId;
|
|
1162
|
+
const ranEntry = direct
|
|
1163
|
+
? { env: res?.env ?? null }
|
|
1164
|
+
: (registry.find((p) => p.presId === ranPresId) ?? entry);
|
|
847
1165
|
if (asJson) {
|
|
848
|
-
|
|
1166
|
+
// The searchId rides the JSON so an interrupted agent can resume its picks
|
|
1167
|
+
// without re-searching. The backendURL never does — env label only.
|
|
1168
|
+
const { backendURL: _routingOnly, ...pub } = res ?? {};
|
|
1169
|
+
process.stdout.write(`${JSON.stringify(pub, null, 2)}\n`);
|
|
849
1170
|
return;
|
|
850
1171
|
}
|
|
851
1172
|
if (!res?.ok) {
|
|
@@ -902,6 +1223,16 @@ async function cmdSearch(rest) {
|
|
|
902
1223
|
`Contact sheet of all ${count} result(s) — LOOK AT THIS FILE to choose, ` +
|
|
903
1224
|
`don't fetch previews one by one: ${gridPath}\n`,
|
|
904
1225
|
);
|
|
1226
|
+
} else if (direct) {
|
|
1227
|
+
// The pane composes the collage in a DOM; the pane-free route may not
|
|
1228
|
+
// have one to hand back (`gridUnavailable`, or simply no grid field on an
|
|
1229
|
+
// add-in that hasn't grown it yet). Say where previews come from instead
|
|
1230
|
+
// — otherwise an agent that has learned to expect a contact sheet quietly
|
|
1231
|
+
// issues N thumbnail fetches to rebuild it.
|
|
1232
|
+
process.stdout.write(
|
|
1233
|
+
`No contact sheet on this path — preview candidates one at a time instead: ` +
|
|
1234
|
+
`slidemind view-slide --search ${res.searchId} --pick <n>\n`,
|
|
1235
|
+
);
|
|
905
1236
|
}
|
|
906
1237
|
}
|
|
907
1238
|
// Tailor the next-step hint to the result kind: slide verbs (insert-slide /
|
|
@@ -910,8 +1241,24 @@ async function cmdSearch(rest) {
|
|
|
910
1241
|
// add-ins that don't echo a top-level subtype.
|
|
911
1242
|
const isSlideResults =
|
|
912
1243
|
res.subtype === "slide" || results.some((r) => r.kind === "slide");
|
|
913
|
-
const envTag = ranEntry
|
|
914
|
-
if (
|
|
1244
|
+
const envTag = ranEntry?.env ? ` [${ranEntry.env}]` : "";
|
|
1245
|
+
if (direct) {
|
|
1246
|
+
// The searchId belongs to the BACKEND here, not to a window: picks work with
|
|
1247
|
+
// PowerPoint closed, so the follow-up commands carry no --to. Live insert is
|
|
1248
|
+
// the one step that still names a window (it types into an open deck).
|
|
1249
|
+
if (isSlideResults) {
|
|
1250
|
+
process.stdout.write(
|
|
1251
|
+
`searchId ${res.searchId}${envTag} is held by the add-in — reuse it with no PowerPoint window:\n` +
|
|
1252
|
+
` slidemind add-slide --search ${res.searchId} --pick <n> --deck <deck>\n` +
|
|
1253
|
+
` slidemind insert-slide --search ${res.searchId} --pick <n> --to <presId> (live insert needs an open window)\n`,
|
|
1254
|
+
);
|
|
1255
|
+
} else {
|
|
1256
|
+
process.stdout.write(
|
|
1257
|
+
`searchId ${res.searchId}${envTag} is held by the add-in — download image result(s) into a deck (no window needed):\n` +
|
|
1258
|
+
` slidemind download-result --search ${res.searchId} --pick <n[,n,...]> --deck <deck> [--icon-color <hex>]\n`,
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
} else if (isSlideResults) {
|
|
915
1262
|
// The searchId is bound to the window the search ran on — reuse must target
|
|
916
1263
|
// that same window, so spell out its presId in the next-step commands.
|
|
917
1264
|
process.stdout.write(
|
|
@@ -973,16 +1320,58 @@ async function cmdLs(rest) {
|
|
|
973
1320
|
if (all && to) fail("--all cannot be combined with --to");
|
|
974
1321
|
let backendURL = null;
|
|
975
1322
|
if (!all) {
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1323
|
+
// Pane-free where the add-in serves the direct routes; otherwise the catalog
|
|
1324
|
+
// is relayed through a window, so a live pane is still the precondition.
|
|
1325
|
+
try {
|
|
1326
|
+
backendURL = (await resolveEngineRouting(client, { to, pick: "any" }))
|
|
1327
|
+
.backendURL;
|
|
1328
|
+
} catch (err) {
|
|
1329
|
+
// Listing is the one engine op that can simply show EVERY environment
|
|
1330
|
+
// (each engine is tagged with its env), so an ambiguous multi-environment
|
|
1331
|
+
// session fans out here rather than demanding --backend.
|
|
1332
|
+
if (err?.code !== "engine_backend_ambiguous") throw err;
|
|
1333
|
+
backendURL = null;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
let engines;
|
|
1337
|
+
// The pane-free route's summary of the catalog BEFORE the type/subtype
|
|
1338
|
+
// filter; null on older add-ins and on the pane-relay path.
|
|
1339
|
+
let catalog = null;
|
|
1340
|
+
// Which environments answered. The catalog is POOLED, and pooling dedupes the
|
|
1341
|
+
// scopes every environment repeats — so a second environment can contribute
|
|
1342
|
+
// no row of its own, and the surviving rows can no longer be counted to learn
|
|
1343
|
+
// whether more than one answered.
|
|
1344
|
+
let envs = [];
|
|
1345
|
+
try {
|
|
1346
|
+
({ engines, catalog, envs = [] } = await client.listEngines({
|
|
1347
|
+
type,
|
|
1348
|
+
subtype,
|
|
1349
|
+
backendURL,
|
|
1350
|
+
}));
|
|
1351
|
+
} catch (err) {
|
|
1352
|
+
// No pane fallback here: `GET /api/cli/engines` is the catalog route on both
|
|
1353
|
+
// add-in generations (newer ones answer it directly, older ones relay it
|
|
1354
|
+
// themselves), so there is no second route to retry on. The message does
|
|
1355
|
+
// name the open pane, because searching THROUGH it still works.
|
|
1356
|
+
await failOnEngineBlocker(client, err, { to });
|
|
979
1357
|
}
|
|
980
|
-
const { engines } = await client.listEngines({ type, subtype, backendURL });
|
|
981
1358
|
if (asJson) {
|
|
982
|
-
process.stdout.write(`${JSON.stringify(engines, null, 2)}\n`);
|
|
1359
|
+
process.stdout.write(`${JSON.stringify({ engines, catalog, envs }, null, 2)}\n`);
|
|
983
1360
|
return;
|
|
984
1361
|
}
|
|
985
1362
|
if (engines.length === 0) {
|
|
1363
|
+
// A filter that matched nothing is NOT an empty deployment — the summary is
|
|
1364
|
+
// the only thing that tells the two apart, so say which one this is and the
|
|
1365
|
+
// next move follows (drop the filter vs. search the corpus unfiltered).
|
|
1366
|
+
const unfiltered = catalog ? catalog.totalEngines + catalog.totalScopes : 0;
|
|
1367
|
+
if (catalog?.filtered && unfiltered > 0) {
|
|
1368
|
+
process.stderr.write(
|
|
1369
|
+
`No engine or scope matched this filter, but ${catalog.totalEngines} engine(s) ` +
|
|
1370
|
+
`and ${catalog.totalScopes} scope(s) exist. Re-run 'slidemind ls engines' ` +
|
|
1371
|
+
"without --type/--subtype to see them.\n",
|
|
1372
|
+
);
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
986
1375
|
// Deliberately not "nothing to search": an engine is a saved folder filter
|
|
987
1376
|
// over the add-in's shared corpus, so an empty catalog says only that nobody
|
|
988
1377
|
// saved a filter. An older add-in that predates scopes lands here too.
|
|
@@ -997,8 +1386,11 @@ async function cmdLs(rest) {
|
|
|
997
1386
|
);
|
|
998
1387
|
return;
|
|
999
1388
|
}
|
|
1000
|
-
// Show the env tag only when more than one environment contributed
|
|
1001
|
-
|
|
1389
|
+
// Show the env tag only when more than one environment contributed. Two
|
|
1390
|
+
// environments can carry identically-named engines, and without the tag the
|
|
1391
|
+
// pooled list gives no way to tell them apart. Deduped scope rows carry no env
|
|
1392
|
+
// of their own (they exist on every environment), so they stay untagged.
|
|
1393
|
+
const multiEnv = envs.length > 1 || new Set(engines.map((e) => e.env)).size > 1;
|
|
1002
1394
|
for (const e of engines) {
|
|
1003
1395
|
const kind = [e.type, e.subtype].filter(Boolean).join("/");
|
|
1004
1396
|
const env = multiEnv && e.env ? `[${e.env}] ` : "";
|
|
@@ -1009,12 +1401,28 @@ async function cmdLs(rest) {
|
|
|
1009
1401
|
e.type === "scope"
|
|
1010
1402
|
? ` (needs --result-type ${(e.capabilities?.resultTypes ?? ["slide", "image"]).join("|")})`
|
|
1011
1403
|
: "";
|
|
1404
|
+
// An engine that exists but has nothing indexed yet returns zero hits for
|
|
1405
|
+
// every query; say so here so an empty search isn't read as a bad engine id.
|
|
1406
|
+
const state =
|
|
1407
|
+
e.content?.state === "empty"
|
|
1408
|
+
? " (nothing indexed yet)"
|
|
1409
|
+
: e.content?.state === "indexing"
|
|
1410
|
+
? " (indexing)"
|
|
1411
|
+
: "";
|
|
1012
1412
|
process.stdout.write(
|
|
1013
|
-
`${env}${e.id} · ${e.label ?? e.id} · ${kind}${hint}${desc}\n`,
|
|
1413
|
+
`${env}${e.id} · ${e.label ?? e.id} · ${kind}${hint}${state}${desc}\n`,
|
|
1014
1414
|
);
|
|
1015
1415
|
}
|
|
1016
1416
|
}
|
|
1017
1417
|
|
|
1418
|
+
// A searchId is a number when the pane minted it (the relay path) and an opaque
|
|
1419
|
+
// server-minted id on the pane-free engine routes, where the search record lives
|
|
1420
|
+
// on the backend. Accept both so a pick round-trips whichever search produced it.
|
|
1421
|
+
function parseSearchIdArg(raw, flag = "--search") {
|
|
1422
|
+
if (!raw || raw.startsWith("-")) fail(`${flag} expects a searchId, got '${raw}'`);
|
|
1423
|
+
return /^\d+$/.test(raw) ? parseInt(raw, 10) : raw;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1018
1426
|
function parsePicks(raw, flag = "--pick") {
|
|
1019
1427
|
if (!raw) fail(`${flag} expects a comma-separated list of selection numbers`);
|
|
1020
1428
|
// Tolerate a trailing/empty segment ("1," or "1,,2") by dropping blank parts
|
|
@@ -1034,6 +1442,45 @@ function parsePicks(raw, flag = "--pick") {
|
|
|
1034
1442
|
return picks;
|
|
1035
1443
|
}
|
|
1036
1444
|
|
|
1445
|
+
/**
|
|
1446
|
+
* Turn a live-insert failure into the action that actually fixes it. The client
|
|
1447
|
+
* has already re-listed the registry and retried ONCE against the live window
|
|
1448
|
+
* (a pane reconnect churns the presId), so a pane error that reaches here is one
|
|
1449
|
+
* only the user can clear — and the way around it is the window-free fold.
|
|
1450
|
+
*
|
|
1451
|
+
* A stale permission snapshot is NOT among them: it is authored once, in
|
|
1452
|
+
* staleSnapshotMessage, which the caller consults first.
|
|
1453
|
+
*/
|
|
1454
|
+
function describeInsertSlideFailure(err, searchId) {
|
|
1455
|
+
const code = err?.code ?? err?.body?.code ?? null;
|
|
1456
|
+
if (code === "unknown_search") {
|
|
1457
|
+
return `searchId ${searchId} is no longer available. Run 'slidemind search' again, then re-pick.`;
|
|
1458
|
+
}
|
|
1459
|
+
if (code === "searchId_required") {
|
|
1460
|
+
// The pane was handed an id it cannot resolve — its search context is
|
|
1461
|
+
// per-window and numeric, and a server-minted id was never in it.
|
|
1462
|
+
return (
|
|
1463
|
+
`searchId ${searchId} belongs to the add-in, not to that PowerPoint window. ` +
|
|
1464
|
+
`Re-run 'slidemind search' (the CLI picks the reuse route from where the search ran), then re-pick.`
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
const paneAsleep =
|
|
1468
|
+
code === "pane_suspended" ||
|
|
1469
|
+
code === "pane_timeout" ||
|
|
1470
|
+
// A relay 404 about the presId itself carries prose and no machine code,
|
|
1471
|
+
// and bridgeFetch falls `code` back to that prose — so read the PARSED one.
|
|
1472
|
+
(err?.status === 404 &&
|
|
1473
|
+
(!err?.body?.code || err.body.code === "unknown_presentation"));
|
|
1474
|
+
if (paneAsleep) {
|
|
1475
|
+
return (
|
|
1476
|
+
"the slidemind task pane in that PowerPoint window is not responding (already retried once). " +
|
|
1477
|
+
"Foreground PowerPoint and click the slidemind pane to wake it, then re-run — or reuse the same " +
|
|
1478
|
+
`result with no window at all: slidemind add-slide --search ${searchId} --pick <n> --deck <deck>.`
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1481
|
+
return `insert-slide failed: ${err?.message ?? "unknown error"}`;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1037
1484
|
async function cmdInsertSlide(rest) {
|
|
1038
1485
|
let searchId = null;
|
|
1039
1486
|
let picks = null;
|
|
@@ -1042,10 +1489,7 @@ async function cmdInsertSlide(rest) {
|
|
|
1042
1489
|
for (let i = 0; i < rest.length; i++) {
|
|
1043
1490
|
const a = rest[i];
|
|
1044
1491
|
if (a === "--search") {
|
|
1045
|
-
|
|
1046
|
-
const n = parseInt(next, 10);
|
|
1047
|
-
if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
|
|
1048
|
-
searchId = n;
|
|
1492
|
+
searchId = parseSearchIdArg(rest[++i]);
|
|
1049
1493
|
} else if (a === "--pick") {
|
|
1050
1494
|
picks = parsePicks(rest[++i]);
|
|
1051
1495
|
} else if (a === "--to") {
|
|
@@ -1070,20 +1514,97 @@ async function cmdInsertSlide(rest) {
|
|
|
1070
1514
|
const client = await import(
|
|
1071
1515
|
pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
|
|
1072
1516
|
);
|
|
1517
|
+
// Live insert types into an OPEN presentation, so it stays window- and
|
|
1518
|
+
// pane-bound even where the rest of the engine surface has moved off the pane.
|
|
1073
1519
|
const registry = await client.requirePaneHost();
|
|
1074
1520
|
const entry = client.pickPresentationEntry(registry, to);
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1521
|
+
// Window identity for the client's single retry: a pane reconnect mints a NEW
|
|
1522
|
+
// presId for the same window, and the design fingerprint re-matches it — but
|
|
1523
|
+
// only against windows we did NOT already see next to the target, or a second
|
|
1524
|
+
// deck on the same corporate design would take the insert.
|
|
1525
|
+
const identity = {
|
|
1526
|
+
fileName: entry.fileName ?? entry.title ?? null,
|
|
1527
|
+
brandId: entry.brandId ?? null,
|
|
1528
|
+
otherPresIds: registry
|
|
1529
|
+
.map((p) => p.presId)
|
|
1530
|
+
.filter((id) => id && id !== entry.presId),
|
|
1531
|
+
};
|
|
1532
|
+
// WHERE the searchId lives decides how the reuse runs. A number came from
|
|
1533
|
+
// that window's own pane search and the pane resolves it; an opaque id is the
|
|
1534
|
+
// backend's (the pane-free search) and the pane has never seen it — so those
|
|
1535
|
+
// picks are extracted pane-free and inserted as bytes instead.
|
|
1536
|
+
//
|
|
1537
|
+
// Which backend extracts them is the RECORD's, not the window's. Those are
|
|
1538
|
+
// different environments as soon as two are connected, and spending a
|
|
1539
|
+
// server-owned id against the window's origin 404s as `unknown_search` — read
|
|
1540
|
+
// as "the search expired", which sends the agent round a re-search loop that
|
|
1541
|
+
// can never resolve. The window stays the INSERT target either way.
|
|
1542
|
+
let engineBackendURL = null;
|
|
1543
|
+
if (client.isOpaqueSearchId?.(searchId)) {
|
|
1544
|
+
try {
|
|
1545
|
+
engineBackendURL = await client.resolveSearchBackend(searchId);
|
|
1546
|
+
} catch (err) {
|
|
1547
|
+
await failOnEngineBlocker(client, err, { to: entry.presId });
|
|
1548
|
+
}
|
|
1549
|
+
if (!engineBackendURL) fail(expiredSearchMessage(searchId));
|
|
1550
|
+
}
|
|
1551
|
+
let res;
|
|
1552
|
+
try {
|
|
1553
|
+
res = engineBackendURL
|
|
1554
|
+
? await client.insertSlidesFromSearch({
|
|
1555
|
+
presId: entry.presId,
|
|
1556
|
+
searchId,
|
|
1557
|
+
selections: picks,
|
|
1558
|
+
at,
|
|
1559
|
+
backendURL: entry.backendURL ?? null,
|
|
1560
|
+
engineBackendURL,
|
|
1561
|
+
...identity,
|
|
1562
|
+
})
|
|
1563
|
+
: await client.insertSlideLive({
|
|
1564
|
+
presId: entry.presId,
|
|
1565
|
+
searchId,
|
|
1566
|
+
selections: picks,
|
|
1567
|
+
at,
|
|
1568
|
+
backendURL: entry.backendURL ?? null,
|
|
1569
|
+
...identity,
|
|
1570
|
+
});
|
|
1571
|
+
} catch (err) {
|
|
1572
|
+
fail(
|
|
1573
|
+
(await staleSnapshotMessage(client, err, { to: entry.presId })) ??
|
|
1574
|
+
describeInsertSlideFailure(err, searchId),
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1082
1577
|
if (!res?.ok) {
|
|
1083
1578
|
fail(`insert-slide failed: ${res?.error ?? "unknown error"}`);
|
|
1084
1579
|
}
|
|
1085
1580
|
process.stdout.write(
|
|
1086
|
-
`Inserted ${res.insertedCount ?? picks.length} slide(s) into presentation ${entry.presId}\n`,
|
|
1581
|
+
`Inserted ${res.insertedCount ?? picks.length} slide(s) into presentation ${res.presId ?? entry.presId}\n`,
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// One line per written slide, plus the machine-readable twin. The searchId is
|
|
1586
|
+
// echoed on purpose: an agent interrupted between picks can resume from the JSON
|
|
1587
|
+
// without re-running the search (the record lives on the backend now).
|
|
1588
|
+
function writeAddSlideResult({ asJson, searchId, deckDir, deck, written, slides }) {
|
|
1589
|
+
if (asJson) {
|
|
1590
|
+
process.stdout.write(
|
|
1591
|
+
`${JSON.stringify(
|
|
1592
|
+
{
|
|
1593
|
+
ok: true,
|
|
1594
|
+
searchId,
|
|
1595
|
+
deck: path.relative(process.cwd(), deckDir),
|
|
1596
|
+
slides,
|
|
1597
|
+
},
|
|
1598
|
+
null,
|
|
1599
|
+
2,
|
|
1600
|
+
)}\n`,
|
|
1601
|
+
);
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
process.stdout.write(
|
|
1605
|
+
`Added ${written.length} reused slide(s) to ${path.relative(process.cwd(), deckDir)}:\n` +
|
|
1606
|
+
written.map((w) => ` ${w}`).join("\n") +
|
|
1607
|
+
`\nThey are native .pptx in slides/ — re-run 'slidemind build-pptx ${deck}' or 'slidemind push ${deck}' to render the deck with them spliced in.\n`,
|
|
1087
1608
|
);
|
|
1088
1609
|
}
|
|
1089
1610
|
|
|
@@ -1094,13 +1615,13 @@ async function cmdAddSlide(rest) {
|
|
|
1094
1615
|
let at = null;
|
|
1095
1616
|
let to = null;
|
|
1096
1617
|
let from = null;
|
|
1618
|
+
let asJson = false;
|
|
1097
1619
|
for (let i = 0; i < rest.length; i++) {
|
|
1098
1620
|
const a = rest[i];
|
|
1099
|
-
if (a === "--
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
searchId = n;
|
|
1621
|
+
if (a === "--json") {
|
|
1622
|
+
asJson = true;
|
|
1623
|
+
} else if (a === "--search") {
|
|
1624
|
+
searchId = parseSearchIdArg(rest[++i]);
|
|
1104
1625
|
} else if (a === "--from") {
|
|
1105
1626
|
from = rest[++i];
|
|
1106
1627
|
if (!from) fail("--from expects a path to a .pptx file");
|
|
@@ -1173,21 +1694,30 @@ async function cmdAddSlide(rest) {
|
|
|
1173
1694
|
.filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"));
|
|
1174
1695
|
const targets = reused.reusedSlideTargets(existing, results, at);
|
|
1175
1696
|
const written = [];
|
|
1697
|
+
const slides = [];
|
|
1176
1698
|
results.forEach((r, i) => {
|
|
1177
1699
|
const target = path.join(slidesDir, targets[i].filename);
|
|
1178
1700
|
fs.writeFileSync(target, Buffer.from(r.bytes));
|
|
1179
1701
|
written.push(path.relative(process.cwd(), target));
|
|
1702
|
+
slides.push({
|
|
1703
|
+
selection: picks[i] ?? null,
|
|
1704
|
+
slideId: r.slideId ?? null,
|
|
1705
|
+
file: path.relative(process.cwd(), target),
|
|
1706
|
+
});
|
|
1707
|
+
});
|
|
1708
|
+
writeAddSlideResult({
|
|
1709
|
+
asJson,
|
|
1710
|
+
searchId: null,
|
|
1711
|
+
deckDir,
|
|
1712
|
+
deck,
|
|
1713
|
+
written,
|
|
1714
|
+
slides,
|
|
1180
1715
|
});
|
|
1181
|
-
process.stdout.write(
|
|
1182
|
-
`Added ${written.length} reused slide(s) to ${path.relative(process.cwd(), deckDir)}:\n` +
|
|
1183
|
-
written.map((w) => ` ${w}`).join("\n") +
|
|
1184
|
-
`\nThey are native .pptx in slides/ — re-run 'slidemind build-pptx ${deck}' or 'slidemind push ${deck}' to render the deck with them spliced in.\n`,
|
|
1185
|
-
);
|
|
1186
1716
|
return;
|
|
1187
1717
|
}
|
|
1188
1718
|
|
|
1189
1719
|
if (searchId === null || !picks || !deck) {
|
|
1190
|
-
fail("usage: slidemind add-slide (--search <id> | --from <file.pptx>) --pick <n[,n,...]> --deck <deck> [--to <presId>] [--at <N>]");
|
|
1720
|
+
fail("usage: slidemind add-slide (--search <id> | --from <file.pptx>) --pick <n[,n,...]> --deck <deck> [--to <presId>] [--at <N>] [--json]");
|
|
1191
1721
|
}
|
|
1192
1722
|
const deckDir = resolveDeckArg(deck);
|
|
1193
1723
|
const slidesDir = path.join(deckDir, "slides");
|
|
@@ -1196,12 +1726,17 @@ async function cmdAddSlide(rest) {
|
|
|
1196
1726
|
const client = await import(
|
|
1197
1727
|
pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
|
|
1198
1728
|
);
|
|
1199
|
-
|
|
1200
|
-
//
|
|
1201
|
-
//
|
|
1202
|
-
|
|
1203
|
-
const
|
|
1204
|
-
|
|
1729
|
+
// Direct path: an opaque searchId routes to the environment that minted the
|
|
1730
|
+
// record; the deck's bound design only decides when the id can't. Pane path:
|
|
1731
|
+
// the searchId is presentation-scoped, so --to targets the window the search
|
|
1732
|
+
// ran on (without it, a registry with >1 open window is a dead-end).
|
|
1733
|
+
const routing = await resolveEngineRouting(client, {
|
|
1734
|
+
to,
|
|
1735
|
+
brandId: await deckBrandId(deckDir),
|
|
1736
|
+
searchId,
|
|
1737
|
+
});
|
|
1738
|
+
const presId = routing.presId;
|
|
1739
|
+
const backendURL = routing.backendURL;
|
|
1205
1740
|
|
|
1206
1741
|
// Extract each picked slide's bytes over the bridge, then let the shared
|
|
1207
1742
|
// helper compute the play-order filenames (NN-reused-<shortid>.pptx). The
|
|
@@ -1222,9 +1757,12 @@ async function cmdAddSlide(rest) {
|
|
|
1222
1757
|
if (s.tempPath) fs.rmSync(s.tempPath, { force: true });
|
|
1223
1758
|
}
|
|
1224
1759
|
};
|
|
1760
|
+
// A searchId can be an opaque server id on the direct path — keep it usable as
|
|
1761
|
+
// a file name component.
|
|
1762
|
+
const searchKey = String(searchId).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
1225
1763
|
try {
|
|
1226
1764
|
for (const pick of picks) {
|
|
1227
|
-
const tempPath = path.join(slidesDir, `.reused-${
|
|
1765
|
+
const tempPath = path.join(slidesDir, `.reused-${searchKey}-${pick}.part`);
|
|
1228
1766
|
if (typeof client.extractSlideToFile === "function") {
|
|
1229
1767
|
try {
|
|
1230
1768
|
const res = await client.extractSlideToFile({
|
|
@@ -1240,11 +1778,17 @@ async function cmdAddSlide(rest) {
|
|
|
1240
1778
|
continue;
|
|
1241
1779
|
} catch (err) {
|
|
1242
1780
|
fs.rmSync(tempPath, { force: true });
|
|
1781
|
+
const stale = await staleSnapshotMessage(client, err, { to });
|
|
1782
|
+
if (stale) {
|
|
1783
|
+
cleanupTemps();
|
|
1784
|
+
fail(stale);
|
|
1785
|
+
}
|
|
1243
1786
|
// Route absent (bodyless 404 / 501) → this add-in predates the streaming
|
|
1244
|
-
// route; fall through to the capped base64 ack path below.
|
|
1787
|
+
// route; fall through to the capped base64 ack path below. On the
|
|
1788
|
+
// pane-free path there is no ack path to fall back to.
|
|
1245
1789
|
const routeMissing =
|
|
1246
1790
|
(err?.status === 404 && !err?.code) || err?.status === 501;
|
|
1247
|
-
if (!routeMissing) {
|
|
1791
|
+
if (!routeMissing || !presId) {
|
|
1248
1792
|
cleanupTemps();
|
|
1249
1793
|
fail(`add-slide failed for selection ${pick}: ${err.message}`);
|
|
1250
1794
|
}
|
|
@@ -1271,6 +1815,7 @@ async function cmdAddSlide(rest) {
|
|
|
1271
1815
|
.filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"));
|
|
1272
1816
|
const targets = reused.reusedSlideTargets(existing, extracted, at);
|
|
1273
1817
|
const written = [];
|
|
1818
|
+
const slides = [];
|
|
1274
1819
|
extracted.forEach((slide, i) => {
|
|
1275
1820
|
const target = path.join(slidesDir, targets[i].filename);
|
|
1276
1821
|
if (slide.tempPath) {
|
|
@@ -1279,12 +1824,13 @@ async function cmdAddSlide(rest) {
|
|
|
1279
1824
|
fs.writeFileSync(target, Buffer.from(slide.base64, "base64"));
|
|
1280
1825
|
}
|
|
1281
1826
|
written.push(path.relative(process.cwd(), target));
|
|
1827
|
+
slides.push({
|
|
1828
|
+
selection: slide.selection,
|
|
1829
|
+
slideId: slide.slideId ?? null,
|
|
1830
|
+
file: path.relative(process.cwd(), target),
|
|
1831
|
+
});
|
|
1282
1832
|
});
|
|
1283
|
-
|
|
1284
|
-
`Added ${written.length} reused slide(s) to ${path.relative(process.cwd(), deckDir)}:\n` +
|
|
1285
|
-
written.map((w) => ` ${w}`).join("\n") +
|
|
1286
|
-
`\nThey are native .pptx in slides/ — re-run 'slidemind build-pptx ${deck}' or 'slidemind push ${deck}' to render the deck with them spliced in.\n`,
|
|
1287
|
-
);
|
|
1833
|
+
writeAddSlideResult({ asJson, searchId, deckDir, deck, written, slides });
|
|
1288
1834
|
}
|
|
1289
1835
|
|
|
1290
1836
|
// Map a `--icon-color` value onto the `--pick` list. Accepts either one colour
|
|
@@ -1318,10 +1864,7 @@ async function cmdViewSlide(rest) {
|
|
|
1318
1864
|
for (let i = 0; i < rest.length; i++) {
|
|
1319
1865
|
const a = rest[i];
|
|
1320
1866
|
if (a === "--search") {
|
|
1321
|
-
|
|
1322
|
-
const n = parseInt(next, 10);
|
|
1323
|
-
if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
|
|
1324
|
-
searchId = n;
|
|
1867
|
+
searchId = parseSearchIdArg(rest[++i]);
|
|
1325
1868
|
} else if (a === "--pick") {
|
|
1326
1869
|
const next = rest[++i];
|
|
1327
1870
|
const n = parseInt(next, 10);
|
|
@@ -1342,7 +1885,7 @@ async function cmdViewSlide(rest) {
|
|
|
1342
1885
|
}
|
|
1343
1886
|
}
|
|
1344
1887
|
if (searchId === null || pick === null) {
|
|
1345
|
-
fail("usage: slidemind view-slide --search <id> --pick <n> --to <presId> [--out <dir>]");
|
|
1888
|
+
fail("usage: slidemind view-slide --search <id> --pick <n> [--to <presId>] [--out <dir>]");
|
|
1346
1889
|
}
|
|
1347
1890
|
const client = await import(
|
|
1348
1891
|
pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
|
|
@@ -1350,16 +1893,18 @@ async function cmdViewSlide(rest) {
|
|
|
1350
1893
|
const { resolveAssetExt } = await import(
|
|
1351
1894
|
pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
|
|
1352
1895
|
);
|
|
1353
|
-
const
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
})
|
|
1896
|
+
const routing = await resolveEngineRouting(client, { to, searchId });
|
|
1897
|
+
let res;
|
|
1898
|
+
try {
|
|
1899
|
+
res = await client.viewSlide({
|
|
1900
|
+
presId: routing.presId,
|
|
1901
|
+
searchId,
|
|
1902
|
+
selection: pick,
|
|
1903
|
+
backendURL: routing.backendURL,
|
|
1904
|
+
});
|
|
1905
|
+
} catch (err) {
|
|
1906
|
+
await failOnEngineBlocker(client, err, { to });
|
|
1907
|
+
}
|
|
1363
1908
|
if (!res?.ok || !res.dataUri) {
|
|
1364
1909
|
fail(`view-slide failed: ${res?.error ?? "no image returned"}`);
|
|
1365
1910
|
}
|
|
@@ -1383,6 +1928,135 @@ async function cmdViewSlide(rest) {
|
|
|
1383
1928
|
);
|
|
1384
1929
|
}
|
|
1385
1930
|
|
|
1931
|
+
async function cmdReadSource(rest) {
|
|
1932
|
+
let searchId = null;
|
|
1933
|
+
let pick = null;
|
|
1934
|
+
let fromSlide = null;
|
|
1935
|
+
let toSlide = null;
|
|
1936
|
+
let to = null;
|
|
1937
|
+
let outFile = null;
|
|
1938
|
+
for (let i = 0; i < rest.length; i++) {
|
|
1939
|
+
const a = rest[i];
|
|
1940
|
+
if (a === "--search") {
|
|
1941
|
+
searchId = parseSearchIdArg(rest[++i]);
|
|
1942
|
+
} else if (a === "--pick") {
|
|
1943
|
+
const next = rest[++i];
|
|
1944
|
+
const n = parseInt(next, 10);
|
|
1945
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
1946
|
+
fail(`--pick expects a positive selection number, got '${next}'`);
|
|
1947
|
+
}
|
|
1948
|
+
pick = n;
|
|
1949
|
+
} else if (a === "--from-slide") {
|
|
1950
|
+
const next = rest[++i];
|
|
1951
|
+
const n = parseInt(next, 10);
|
|
1952
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
1953
|
+
fail(`--from-slide expects a positive slide number, got '${next}'`);
|
|
1954
|
+
}
|
|
1955
|
+
fromSlide = n;
|
|
1956
|
+
} else if (a === "--to-slide") {
|
|
1957
|
+
const next = rest[++i];
|
|
1958
|
+
const n = parseInt(next, 10);
|
|
1959
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
1960
|
+
fail(`--to-slide expects a positive slide number, got '${next}'`);
|
|
1961
|
+
}
|
|
1962
|
+
toSlide = n;
|
|
1963
|
+
} else if (a === "--to") {
|
|
1964
|
+
// Deliberately the WINDOW, matching every other search-scoped command.
|
|
1965
|
+
// The slide window is --from-slide/--to-slide.
|
|
1966
|
+
to = rest[++i];
|
|
1967
|
+
if (!to) fail("--to expects a presId");
|
|
1968
|
+
} else if (a === "--out" || a === "-o") {
|
|
1969
|
+
outFile = rest[++i];
|
|
1970
|
+
if (!outFile) fail("--out expects a file path");
|
|
1971
|
+
} else if (a.startsWith("-")) {
|
|
1972
|
+
fail(`unknown flag: ${a}`);
|
|
1973
|
+
} else {
|
|
1974
|
+
fail(`unexpected argument: ${a}`);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
if (searchId === null || pick === null) {
|
|
1978
|
+
fail(
|
|
1979
|
+
"usage: slidemind read-source --search <id> --pick <n> [--from-slide <n>] [--to-slide <n>] [--to <presId>] [--out <file>]",
|
|
1980
|
+
);
|
|
1981
|
+
}
|
|
1982
|
+
const client = await import(
|
|
1983
|
+
pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
|
|
1984
|
+
);
|
|
1985
|
+
// Pane path: the searchId is presentation-scoped, so --to targets the window
|
|
1986
|
+
// the search ran on — same rule as view-slide. Direct path: the record is the
|
|
1987
|
+
// backend's, so no window is involved.
|
|
1988
|
+
const routing = await resolveEngineRouting(client, { to, searchId });
|
|
1989
|
+
let res;
|
|
1990
|
+
try {
|
|
1991
|
+
res = await client.readSourcePresentation({
|
|
1992
|
+
presId: routing.presId,
|
|
1993
|
+
searchId,
|
|
1994
|
+
selection: pick,
|
|
1995
|
+
fromSlide,
|
|
1996
|
+
toSlide,
|
|
1997
|
+
backendURL: routing.backendURL,
|
|
1998
|
+
});
|
|
1999
|
+
} catch (err) {
|
|
2000
|
+
// The stale-snapshot line is authored once and knows about an open pane;
|
|
2001
|
+
// everything else is this command's own vocabulary.
|
|
2002
|
+
fail(
|
|
2003
|
+
(await staleSnapshotMessage(client, err, { to })) ??
|
|
2004
|
+
describeReadSourceFailure(err, searchId, pick),
|
|
2005
|
+
);
|
|
2006
|
+
}
|
|
2007
|
+
if (!res?.markdown) {
|
|
2008
|
+
fail("read-source returned no content. Try the search again, then re-pick.");
|
|
2009
|
+
}
|
|
2010
|
+
if (outFile) {
|
|
2011
|
+
const target = path.resolve(process.cwd(), outFile);
|
|
2012
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
2013
|
+
fs.writeFileSync(target, res.markdown, "utf8");
|
|
2014
|
+
process.stdout.write(
|
|
2015
|
+
`Wrote the source presentation of result ${pick} (searchId ${searchId}) to ${target}\n`,
|
|
2016
|
+
);
|
|
2017
|
+
} else {
|
|
2018
|
+
process.stdout.write(res.markdown.endsWith("\n") ? res.markdown : `${res.markdown}\n`);
|
|
2019
|
+
}
|
|
2020
|
+
// Truncation is the one thing a reader must not miss: the body they just got
|
|
2021
|
+
// is a slide map, not the deck.
|
|
2022
|
+
if (res.truncated) {
|
|
2023
|
+
process.stderr.write(
|
|
2024
|
+
`note: this deck was too long to return in full — the output is its slide map. ` +
|
|
2025
|
+
`Re-run with --from-slide/--to-slide to read a range.\n`,
|
|
2026
|
+
);
|
|
2027
|
+
} else if (res.totalSlideCount && res.slideCount < res.totalSlideCount) {
|
|
2028
|
+
process.stderr.write(
|
|
2029
|
+
`note: showing ${res.slideCount} of ${res.totalSlideCount} slides.\n`,
|
|
2030
|
+
);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/**
|
|
2035
|
+
* Turn a source-presentation failure into the action that actually fixes it.
|
|
2036
|
+
* A stale permission snapshot is handled one level up, in staleSnapshotMessage
|
|
2037
|
+
* — it is the one blocker only the USER can clear and it reads identically
|
|
2038
|
+
* across every command.
|
|
2039
|
+
*/
|
|
2040
|
+
function describeReadSourceFailure(err, searchId, pick) {
|
|
2041
|
+
const code = err?.code ?? err?.body?.code ?? null;
|
|
2042
|
+
switch (code) {
|
|
2043
|
+
case "unknown_search":
|
|
2044
|
+
return `searchId ${searchId} is no longer available on that window. Run 'slidemind search' again, then re-pick.`;
|
|
2045
|
+
case "wrong_result_type":
|
|
2046
|
+
return `result ${pick} has no source presentation — only slide search results come from a deck (image and collection results do not).`;
|
|
2047
|
+
case "access_denied":
|
|
2048
|
+
return "you do not have access to that result's source presentation.";
|
|
2049
|
+
case "no_indexed_content":
|
|
2050
|
+
return "that presentation has no indexed slides yet. It may still be syncing — try again later.";
|
|
2051
|
+
case "invalid_source":
|
|
2052
|
+
return `result ${pick} carries no source file reference. Re-run the search, or pick a different result.`;
|
|
2053
|
+
case "source_presentation_unsupported":
|
|
2054
|
+
return err.message;
|
|
2055
|
+
default:
|
|
2056
|
+
return `read-source failed: ${err?.message ?? "unknown error"}`;
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
|
|
1386
2060
|
async function cmdDownloadResult(rest) {
|
|
1387
2061
|
let searchId = null;
|
|
1388
2062
|
let picks = null;
|
|
@@ -1392,10 +2066,7 @@ async function cmdDownloadResult(rest) {
|
|
|
1392
2066
|
for (let i = 0; i < rest.length; i++) {
|
|
1393
2067
|
const a = rest[i];
|
|
1394
2068
|
if (a === "--search") {
|
|
1395
|
-
|
|
1396
|
-
const n = parseInt(next, 10);
|
|
1397
|
-
if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
|
|
1398
|
-
searchId = n;
|
|
2069
|
+
searchId = parseSearchIdArg(rest[++i]);
|
|
1399
2070
|
} else if (a === "--pick") {
|
|
1400
2071
|
picks = parsePicks(rest[++i]);
|
|
1401
2072
|
} else if (a === "--deck") {
|
|
@@ -1415,7 +2086,7 @@ async function cmdDownloadResult(rest) {
|
|
|
1415
2086
|
}
|
|
1416
2087
|
if (searchId === null || !picks || !deck) {
|
|
1417
2088
|
fail(
|
|
1418
|
-
"usage: slidemind download-result --search <id> --pick <n[,n,...]> --deck <deck> --to <presId> [--icon-color <hex[,hex,...]>]",
|
|
2089
|
+
"usage: slidemind download-result --search <id> --pick <n[,n,...]> --deck <deck> [--to <presId>] [--icon-color <hex[,hex,...]>]",
|
|
1419
2090
|
);
|
|
1420
2091
|
}
|
|
1421
2092
|
const iconColors = parseIconColors(iconColorsRaw, picks.length);
|
|
@@ -1430,10 +2101,15 @@ async function cmdDownloadResult(rest) {
|
|
|
1430
2101
|
const { downloadOneResult } = await import(
|
|
1431
2102
|
pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
|
|
1432
2103
|
);
|
|
1433
|
-
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
|
|
2104
|
+
// Direct path: an opaque searchId routes to the record's own environment,
|
|
2105
|
+
// else the deck's bound design decides; no window needed. Pane path: the
|
|
2106
|
+
// searchId is presentation-scoped, so --to targets the window the search ran
|
|
2107
|
+
// on (without it, >1 open window is a dead-end).
|
|
2108
|
+
const routing = await resolveEngineRouting(client, {
|
|
2109
|
+
to,
|
|
2110
|
+
brandId: await deckBrandId(deckDir),
|
|
2111
|
+
searchId,
|
|
2112
|
+
});
|
|
1437
2113
|
|
|
1438
2114
|
// Concurrent — each download is its own bridge round-trip; allSettled so one
|
|
1439
2115
|
// bad selection doesn't sink the rest of the batch.
|
|
@@ -1441,8 +2117,8 @@ async function cmdDownloadResult(rest) {
|
|
|
1441
2117
|
picks.map((selection, i) =>
|
|
1442
2118
|
downloadOneResult({
|
|
1443
2119
|
client,
|
|
1444
|
-
presId:
|
|
1445
|
-
backendURL:
|
|
2120
|
+
presId: routing.presId,
|
|
2121
|
+
backendURL: routing.backendURL,
|
|
1446
2122
|
searchId,
|
|
1447
2123
|
assetsDir,
|
|
1448
2124
|
selection,
|
|
@@ -1450,10 +2126,22 @@ async function cmdDownloadResult(rest) {
|
|
|
1450
2126
|
}),
|
|
1451
2127
|
),
|
|
1452
2128
|
);
|
|
2129
|
+
// One user-clearable blocker sinks the whole batch identically — every pick
|
|
2130
|
+
// would fail the same way, and the fix is one action, not N error lines.
|
|
2131
|
+
const stale = settled.find(
|
|
2132
|
+
(r) => r.status === "rejected" && client.permissionsStaleBlocker?.(r.reason),
|
|
2133
|
+
);
|
|
2134
|
+
if (stale) fail(await staleSnapshotMessage(client, stale.reason, { to }));
|
|
1453
2135
|
const results = settled.map((r, i) =>
|
|
1454
2136
|
r.status === "fulfilled"
|
|
1455
2137
|
? r.value
|
|
1456
|
-
: {
|
|
2138
|
+
: {
|
|
2139
|
+
selection: picks[i],
|
|
2140
|
+
error:
|
|
2141
|
+
describeHostOnlyEngineFailure(r.reason) ??
|
|
2142
|
+
r.reason?.message ??
|
|
2143
|
+
String(r.reason),
|
|
2144
|
+
},
|
|
1457
2145
|
);
|
|
1458
2146
|
const { formatSize } = await import(
|
|
1459
2147
|
pathToFileURL(path.join(SRC_DIR, "bridge", "image-dimensions.mjs")).href
|
|
@@ -1798,20 +2486,22 @@ function publicConnectionStatus(status, client) {
|
|
|
1798
2486
|
};
|
|
1799
2487
|
}
|
|
1800
2488
|
|
|
1801
|
-
// The
|
|
1802
|
-
//
|
|
1803
|
-
//
|
|
1804
|
-
//
|
|
1805
|
-
//
|
|
1806
|
-
//
|
|
1807
|
-
//
|
|
1808
|
-
//
|
|
1809
|
-
//
|
|
1810
|
-
//
|
|
1811
|
-
//
|
|
1812
|
-
//
|
|
1813
|
-
//
|
|
1814
|
-
|
|
2489
|
+
// The readiness ladder splits in two, because the two halves fail
|
|
2490
|
+
// independently: BACKEND AUTHORING readiness (a session, a reachable add-in
|
|
2491
|
+
// backend, a supported CLI — everything render, bundle and the pane-free engine
|
|
2492
|
+
// routes need) and LIVE POWERPOINT readiness (a registered window, render-ready
|
|
2493
|
+
// metadata, an awake pane — what push/insert-slide and reading an open deck
|
|
2494
|
+
// need). Keeping them apart is the whole point of doc 13: a dead pane is no
|
|
2495
|
+
// longer "cannot work", it only defers the last mile.
|
|
2496
|
+
//
|
|
2497
|
+
// Each rung is a STRUCTURED blocker: `code` is the machine-copeable reason an
|
|
2498
|
+
// agent switches on; `actor` says who can clear it — only `cli_update_required`
|
|
2499
|
+
// is the AGENT's to fix (it can run `slidemind update`), every other rung needs
|
|
2500
|
+
// a human at the add-in. The one exception is a plugin-installed CLI:
|
|
2501
|
+
// `/plugin` is the only updater that replaces the binary on the session's PATH,
|
|
2502
|
+
// and the agent cannot drive it, so that rung hands back to the user (see
|
|
2503
|
+
// src/install-channel.mjs).
|
|
2504
|
+
function backendBlocker(pub) {
|
|
1815
2505
|
if (!pub.paired) {
|
|
1816
2506
|
return {
|
|
1817
2507
|
code: "not_paired",
|
|
@@ -1841,6 +2531,15 @@ function connectionBlocker(pub) {
|
|
|
1841
2531
|
updateCliInstruction(),
|
|
1842
2532
|
};
|
|
1843
2533
|
}
|
|
2534
|
+
return null;
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
// The live-PowerPoint half. Never gates render, bundle or the pane-free engine
|
|
2538
|
+
// routes — it only says whether the ops that genuinely type into an open deck
|
|
2539
|
+
// (push's insert, insert-slide, reading an unsaved presentation) can run now.
|
|
2540
|
+
// The `message` strings are BYTE-IDENTICAL to the pre-split ladder so doctor's
|
|
2541
|
+
// printed output never churns.
|
|
2542
|
+
function paneBlocker(pub) {
|
|
1844
2543
|
if ((pub.presentations ?? []).length === 0) {
|
|
1845
2544
|
return {
|
|
1846
2545
|
code: "no_window",
|
|
@@ -1875,18 +2574,53 @@ function connectionBlocker(pub) {
|
|
|
1875
2574
|
code: "pane_suspended",
|
|
1876
2575
|
actor: "user",
|
|
1877
2576
|
message:
|
|
1878
|
-
"Rendering is available, but pane operations are suspended. Bring PowerPoint forward and click/reopen the slidemind pane before push
|
|
2577
|
+
"Rendering is available, but pane operations are suspended. Bring PowerPoint forward and click/reopen the slidemind pane before push, insert-slide, or reading this window.",
|
|
1879
2578
|
};
|
|
1880
2579
|
}
|
|
1881
2580
|
return null;
|
|
1882
2581
|
}
|
|
1883
2582
|
|
|
2583
|
+
// The two ladders end-to-end, in the order a user hits them: nothing about a
|
|
2584
|
+
// window matters while the backend half is unmet. `slidemind ready` layers
|
|
2585
|
+
// `ambiguous_design` on top and treats `pane_suspended` as non-blocking (a
|
|
2586
|
+
// render-ready-but-suspended window is still a design source).
|
|
2587
|
+
function connectionBlocker(pub) {
|
|
2588
|
+
return backendBlocker(pub) ?? paneBlocker(pub);
|
|
2589
|
+
}
|
|
2590
|
+
|
|
1884
2591
|
// Back-compat projection for doctor: just the blocker's message, or "Ready."
|
|
1885
2592
|
// when the ladder is clear. Keeps the ladder authored in ONE place.
|
|
1886
2593
|
function connectionNextAction(pub) {
|
|
1887
2594
|
return connectionBlocker(pub)?.message ?? "Ready.";
|
|
1888
2595
|
}
|
|
1889
2596
|
|
|
2597
|
+
// The permission SNAPSHOT behind corpus search ages out when nobody opens the
|
|
2598
|
+
// add-in for a while. It is a DEGRADATION, never a blocker: render, bundle and
|
|
2599
|
+
// every already-materialized asset keep working, only corpus search stops — so
|
|
2600
|
+
// it must not sit on either ladder. Newer add-ins report it on the readiness
|
|
2601
|
+
// capabilities; the message is authored once, in the client, next to the live
|
|
2602
|
+
// 403 shape the engine routes return (client.permissionsStaleBlocker).
|
|
2603
|
+
// Which connected environments report an aged-out snapshot on
|
|
2604
|
+
// `/api/cli/readiness`. Newer add-ins emit the flag; older ones never do, and
|
|
2605
|
+
// there the condition only surfaces as a live 403 on a search.
|
|
2606
|
+
function permissionsStaleEnvs(pub) {
|
|
2607
|
+
return (pub.backends ?? [])
|
|
2608
|
+
.filter((b) => b.ok && b.capabilities?.permissionsStale === true)
|
|
2609
|
+
.map((b) => b.env);
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
function permissionsStaleDegradation(pub, client) {
|
|
2613
|
+
const stale = (pub.backends ?? []).find(
|
|
2614
|
+
(b) => b.ok && b.capabilities?.permissionsStale === true,
|
|
2615
|
+
);
|
|
2616
|
+
if (!stale) return null;
|
|
2617
|
+
const action = stale.capabilities?.permissionsStaleAction;
|
|
2618
|
+
return client.permissionsStaleBlocker({
|
|
2619
|
+
code: "permissions_stale",
|
|
2620
|
+
body: typeof action === "string" ? { action } : {},
|
|
2621
|
+
});
|
|
2622
|
+
}
|
|
2623
|
+
|
|
1890
2624
|
// ---------- one-command setup (npx bootstrap) ----------
|
|
1891
2625
|
//
|
|
1892
2626
|
// `npx --yes slidemind@latest setup <claim>` is the whole onboarding: the user
|
|
@@ -2121,6 +2855,7 @@ async function cmdReady(rest) {
|
|
|
2121
2855
|
let asJson = false;
|
|
2122
2856
|
let force = false;
|
|
2123
2857
|
let connectOnly = false;
|
|
2858
|
+
let brand = null;
|
|
2124
2859
|
const positional = [];
|
|
2125
2860
|
for (let i = 0; i < rest.length; i++) {
|
|
2126
2861
|
const a = rest[i];
|
|
@@ -2133,12 +2868,18 @@ async function cmdReady(rest) {
|
|
|
2133
2868
|
force = true;
|
|
2134
2869
|
} else if (a === "--connect-only") {
|
|
2135
2870
|
connectOnly = true;
|
|
2871
|
+
} else if (a === "--brand") {
|
|
2872
|
+
brand = rest[++i];
|
|
2873
|
+
if (!brand) fail("--brand expects a design id (presFP)");
|
|
2136
2874
|
} else if (a.startsWith("-")) {
|
|
2137
2875
|
fail(`unknown flag: ${a}`);
|
|
2138
2876
|
} else {
|
|
2139
2877
|
positional.push(a);
|
|
2140
2878
|
}
|
|
2141
2879
|
}
|
|
2880
|
+
if (brand && to) {
|
|
2881
|
+
fail("--brand and --to are mutually exclusive (a design id or a window, not both)");
|
|
2882
|
+
}
|
|
2142
2883
|
|
|
2143
2884
|
// --- Local: resolve the deck dir (never create it) and read its existing
|
|
2144
2885
|
// bundle ref. deckPresFP feeds ONLY the mirror tie-break + `suggested`; it
|
|
@@ -2228,10 +2969,162 @@ async function cmdReady(rest) {
|
|
|
2228
2969
|
return;
|
|
2229
2970
|
}
|
|
2230
2971
|
|
|
2972
|
+
// --- Backend authoring readiness beyond the connection itself: which
|
|
2973
|
+
// environments serve the pane-free engine routes. Probed at most once (and
|
|
2974
|
+
// only when a payload actually reports it), and never fatal — an add-in
|
|
2975
|
+
// without them still authors, it just relays search through a window. ---
|
|
2976
|
+
let engineInfo = null;
|
|
2977
|
+
const engines = async () => {
|
|
2978
|
+
if (!engineInfo) {
|
|
2979
|
+
try {
|
|
2980
|
+
engineInfo = await client.engineReachability({
|
|
2981
|
+
backends: statusRaw.backends,
|
|
2982
|
+
});
|
|
2983
|
+
} catch {
|
|
2984
|
+
engineInfo = { available: false, envs: [] };
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
return engineInfo;
|
|
2988
|
+
};
|
|
2989
|
+
|
|
2990
|
+
// --- A design resolved WITHOUT a window — the deck's own binding, an
|
|
2991
|
+
// explicit --brand, or the backend's brand list. Same payload either way:
|
|
2992
|
+
// ready, `windowless: true`, and a pane status that is INFORMATIONAL. What
|
|
2993
|
+
// the missing window actually costs depends on the engine routes: with them
|
|
2994
|
+
// only push/insert-slide and reading an open presentation need PowerPoint.
|
|
2995
|
+
//
|
|
2996
|
+
// "Windowless" is about how the DESIGN was resolved, not about the registry:
|
|
2997
|
+
// --brand runs before the registry is consulted at all, and the later rungs
|
|
2998
|
+
// fire on "nothing render-ready", which a connected-but-still-matching window
|
|
2999
|
+
// satisfies. So the pane status is DERIVED from the registry as it stands at
|
|
3000
|
+
// call time (the hydration wait reassigns it), and the prose only claims
|
|
3001
|
+
// "no PowerPoint window" when there genuinely is none. ---
|
|
3002
|
+
const finishWindowless = async (result) => {
|
|
3003
|
+
if (deckDir) {
|
|
3004
|
+
bundle.writeDeckBundleRef(deckDir, {
|
|
3005
|
+
presFP: result.presFP,
|
|
3006
|
+
etag: result.etag,
|
|
3007
|
+
});
|
|
3008
|
+
}
|
|
3009
|
+
const designName = readDesignName(result.dir);
|
|
3010
|
+
const env = result.backendURL ? cfg.deriveEnvLabel(result.backendURL) : null;
|
|
3011
|
+
const eng = await engines();
|
|
3012
|
+
const paneReady = registry.some(client.isPaneReadyPresentation);
|
|
3013
|
+
const noWindow = registry.length === 0;
|
|
3014
|
+
const nextAction = noWindow
|
|
3015
|
+
? eng.available
|
|
3016
|
+
? "Ready (no PowerPoint window — authoring, engine search and build-pptx all work; only push/insert-slide and reading an open presentation need one)."
|
|
3017
|
+
: "Ready (no PowerPoint window — build-pptx/build/review/view-draft work; push/search/read need a pane)."
|
|
3018
|
+
: paneReady
|
|
3019
|
+
? "Ready (design resolved without targeting a window; a registered PowerPoint window is available for push/insert-slide)."
|
|
3020
|
+
: eng.available
|
|
3021
|
+
? "Ready (design resolved without targeting a window; pane operations — push/insert-slide/read of a window — will wait for a pane click; engine search does not)."
|
|
3022
|
+
: "Ready (design resolved without targeting a window; pane operations — push/search/read — will wait for a pane click).";
|
|
3023
|
+
if (asJson) {
|
|
3024
|
+
process.stdout.write(
|
|
3025
|
+
JSON.stringify(
|
|
3026
|
+
{
|
|
3027
|
+
ready: true,
|
|
3028
|
+
windowless: true,
|
|
3029
|
+
deck: deckSlug,
|
|
3030
|
+
bound: Boolean(deckDir),
|
|
3031
|
+
env,
|
|
3032
|
+
presId: null,
|
|
3033
|
+
presFP: result.presFP,
|
|
3034
|
+
designName,
|
|
3035
|
+
bundleDir: result.dir,
|
|
3036
|
+
bundleEtag: result.etag ?? null,
|
|
3037
|
+
paneSuspended: !paneReady,
|
|
3038
|
+
engines: eng,
|
|
3039
|
+
warnings: result.warnings ?? [],
|
|
3040
|
+
nextAction,
|
|
3041
|
+
},
|
|
3042
|
+
null,
|
|
3043
|
+
2,
|
|
3044
|
+
) + "\n",
|
|
3045
|
+
);
|
|
3046
|
+
return;
|
|
3047
|
+
}
|
|
3048
|
+
const designLabel = designName
|
|
3049
|
+
? `${designName} (${result.presFP})`
|
|
3050
|
+
: result.presFP;
|
|
3051
|
+
process.stdout.write(
|
|
3052
|
+
`Ready — design ${designLabel}${env ? ` [${env}]` : ""}${noWindow ? " (no PowerPoint window)" : ""}\n`,
|
|
3053
|
+
);
|
|
3054
|
+
process.stdout.write(` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`);
|
|
3055
|
+
if (deckDir) process.stdout.write(` deck ${deckSlug} bound\n`);
|
|
3056
|
+
process.stdout.write(
|
|
3057
|
+
noWindow
|
|
3058
|
+
? eng.available
|
|
3059
|
+
? " Note: authoring, engine search and build-pptx need no window; push/insert-slide and reading an open presentation do.\n"
|
|
3060
|
+
: " Note: build-pptx/build/review/view-draft work without a window; push/search/read need a pane.\n"
|
|
3061
|
+
: paneReady
|
|
3062
|
+
? " Note: the design was resolved without targeting a window; a registered PowerPoint window is available for push/insert-slide.\n"
|
|
3063
|
+
: eng.available
|
|
3064
|
+
? " Note: pane operations (push/insert-slide/read of a window) will wait for a pane click; engine search does not.\n"
|
|
3065
|
+
: " Note: pane operations (push/search/read) will wait for a pane click.\n",
|
|
3066
|
+
);
|
|
3067
|
+
for (const w of result.warnings ?? []) {
|
|
3068
|
+
process.stderr.write(` warning: ${w}\n`);
|
|
3069
|
+
}
|
|
3070
|
+
};
|
|
3071
|
+
|
|
3072
|
+
// Materialize a design by id over the pane-free brand route. Returns null on
|
|
3073
|
+
// any miss (old add-in, unknown/revoked design) so the caller can fall
|
|
3074
|
+
// through to the window ladder instead of dead-ending.
|
|
3075
|
+
const materializeBrand = async (brandId) => {
|
|
3076
|
+
try {
|
|
3077
|
+
return await bundle.materializeBundle(null, { force, brandId });
|
|
3078
|
+
} catch {
|
|
3079
|
+
return null;
|
|
3080
|
+
}
|
|
3081
|
+
};
|
|
3082
|
+
|
|
3083
|
+
// --- --brand <presFP>: the user (or the agent, after an ambiguous brand
|
|
3084
|
+
// list) named the design outright. It resolves with or without a window, so
|
|
3085
|
+
// it runs before the registry is consulted at all. ---
|
|
3086
|
+
if (brand) {
|
|
3087
|
+
const result = await materializeBrand(brand);
|
|
3088
|
+
if (!result) {
|
|
3089
|
+
finishBlocker({
|
|
3090
|
+
blocker: {
|
|
3091
|
+
code: "unknown_brand",
|
|
3092
|
+
actor: "user",
|
|
3093
|
+
message: `No connected slidemind add-in serves design '${brand}'. Run 'slidemind ready' with no --brand to see which designs are available.`,
|
|
3094
|
+
},
|
|
3095
|
+
});
|
|
3096
|
+
return;
|
|
3097
|
+
}
|
|
3098
|
+
await finishWindowless(result);
|
|
3099
|
+
return;
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
const isRR = client.isRenderReadyPresentation;
|
|
3103
|
+
|
|
3104
|
+
// --- Bound-deck ladder: session → bundle freshness, no pane precondition.
|
|
3105
|
+
// The design identity (presFP == brandId) lives in the deck's
|
|
3106
|
+
// .bundle-ref.json and the bundle syncs over the pane-free brand route, so
|
|
3107
|
+
// with PowerPoint CLOSED (an empty registry) a bound deck readies straight
|
|
3108
|
+
// away instead of sitting out the hydration wait for a window that isn't
|
|
3109
|
+
// coming. A registry that holds windows still hydrates first — the open
|
|
3110
|
+
// add-in stays the source of truth whenever there IS one. Skipped for
|
|
3111
|
+
// --connect-only (which resolves no design at all) and for --to (an explicit
|
|
3112
|
+
// window must resolve through the registry); a miss (old add-in, legacy ref,
|
|
3113
|
+
// revoked grant) falls through to the window ladder. ---
|
|
3114
|
+
const boundDeckLadder = deckDir && deckPresFP && !to && !connectOnly;
|
|
3115
|
+
let boundRungTried = false;
|
|
3116
|
+
if (boundDeckLadder && registry.length === 0) {
|
|
3117
|
+
boundRungTried = true;
|
|
3118
|
+
const result = await materializeBrand(deckPresFP);
|
|
3119
|
+
if (result) {
|
|
3120
|
+
await finishWindowless(result);
|
|
3121
|
+
return;
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
|
|
2231
3125
|
// --- Bounded hydration wait when nothing is render-ready yet: covers the
|
|
2232
3126
|
// just-clicked / just-woke pane still hydrating brand metadata. Polls even on
|
|
2233
3127
|
// an empty registry (that's the flaky window this papers over). ---
|
|
2234
|
-
const isRR = client.isRenderReadyPresentation;
|
|
2235
3128
|
if (!registry.some(isRR)) {
|
|
2236
3129
|
const waitMs =
|
|
2237
3130
|
parseInt(process.env.SLIDEMIND_BRIDGE_PRESENTATIONS_WAIT_MS || "6000", 10) ||
|
|
@@ -2245,74 +3138,89 @@ async function cmdReady(rest) {
|
|
|
2245
3138
|
}
|
|
2246
3139
|
}
|
|
2247
3140
|
if (!registry.some(isRR)) {
|
|
2248
|
-
//
|
|
2249
|
-
//
|
|
2250
|
-
//
|
|
2251
|
-
//
|
|
2252
|
-
//
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
3141
|
+
// --connect-only asks one question — "can I work?" — and since the engine
|
|
3142
|
+
// routes answer off the session, a backend that serves them IS the answer:
|
|
3143
|
+
// discovery, search and every per-result fetch run with PowerPoint closed.
|
|
3144
|
+
// Without them search is still relayed through a pane, so the window rung
|
|
3145
|
+
// below stands.
|
|
3146
|
+
if (connectOnly && (await engines()).available) {
|
|
3147
|
+
const eng = await engines();
|
|
3148
|
+
if (asJson) {
|
|
3149
|
+
process.stdout.write(
|
|
3150
|
+
JSON.stringify(
|
|
3151
|
+
{
|
|
3152
|
+
ready: true,
|
|
3153
|
+
connectOnly: true,
|
|
3154
|
+
windowless: true,
|
|
3155
|
+
engines: eng,
|
|
3156
|
+
nextAction:
|
|
3157
|
+
"Ready (no PowerPoint window — engine search runs off the connection; only insert-slide needs one).",
|
|
3158
|
+
backends: (pub.backends ?? []).map((b) => ({ env: b.env, ok: b.ok })),
|
|
3159
|
+
},
|
|
3160
|
+
null,
|
|
3161
|
+
2,
|
|
3162
|
+
) + "\n",
|
|
3163
|
+
);
|
|
3164
|
+
} else {
|
|
3165
|
+
process.stdout.write(
|
|
3166
|
+
`Connected. ${pub.backendCount} backend(s), 0 window(s) registered — ` +
|
|
3167
|
+
`engine search runs without one [${eng.envs.join(", ")}].\n`,
|
|
3168
|
+
);
|
|
3169
|
+
}
|
|
3170
|
+
return;
|
|
3171
|
+
}
|
|
3172
|
+
// The bound-deck ladder again, for the windows-registered-but-never-ready
|
|
3173
|
+
// case the pre-poll rung deliberately left to the hydration wait.
|
|
3174
|
+
if (boundDeckLadder && !boundRungTried) {
|
|
3175
|
+
boundRungTried = true;
|
|
3176
|
+
const result = await materializeBrand(deckPresFP);
|
|
3177
|
+
if (result) {
|
|
3178
|
+
await finishWindowless(result);
|
|
3179
|
+
return;
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
// --- Unbound + no window: ask the backend which designs this user may
|
|
3183
|
+
// build against. One is the answer; several genuinely need a human choice,
|
|
3184
|
+
// which --brand records. A backend without the route (or a deck already
|
|
3185
|
+
// bound to a design the brand route just refused) falls through unchanged. ---
|
|
3186
|
+
if (!connectOnly && !to && !deckPresFP && !boundRungTried) {
|
|
3187
|
+
let brands = [];
|
|
2257
3188
|
try {
|
|
2258
|
-
|
|
2259
|
-
force,
|
|
2260
|
-
brandId: deckPresFP,
|
|
2261
|
-
});
|
|
3189
|
+
brands = await client.listBrands();
|
|
2262
3190
|
} catch {
|
|
2263
|
-
/*
|
|
3191
|
+
/* no brand list — the window ladder below is the answer */
|
|
2264
3192
|
}
|
|
2265
|
-
if (
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
});
|
|
2270
|
-
const designName = readDesignName(result.dir);
|
|
2271
|
-
const env = result.backendURL
|
|
2272
|
-
? cfg.deriveEnvLabel(result.backendURL)
|
|
2273
|
-
: null;
|
|
2274
|
-
const nextAction =
|
|
2275
|
-
"Ready (no PowerPoint window — build-pptx/build/review/view-draft work; push/search/read need a pane).";
|
|
2276
|
-
if (asJson) {
|
|
2277
|
-
process.stdout.write(
|
|
2278
|
-
JSON.stringify(
|
|
2279
|
-
{
|
|
2280
|
-
ready: true,
|
|
2281
|
-
windowless: true,
|
|
2282
|
-
deck: deckSlug,
|
|
2283
|
-
bound: true,
|
|
2284
|
-
env,
|
|
2285
|
-
presId: null,
|
|
2286
|
-
presFP: result.presFP,
|
|
2287
|
-
designName,
|
|
2288
|
-
bundleDir: result.dir,
|
|
2289
|
-
bundleEtag: result.etag ?? null,
|
|
2290
|
-
paneSuspended: true,
|
|
2291
|
-
warnings: result.warnings ?? [],
|
|
2292
|
-
nextAction,
|
|
2293
|
-
},
|
|
2294
|
-
null,
|
|
2295
|
-
2,
|
|
2296
|
-
) + "\n",
|
|
2297
|
-
);
|
|
3193
|
+
if (brands.length === 1) {
|
|
3194
|
+
const result = await materializeBrand(brands[0].presFP);
|
|
3195
|
+
if (result) {
|
|
3196
|
+
await finishWindowless(result);
|
|
2298
3197
|
return;
|
|
2299
3198
|
}
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
:
|
|
2303
|
-
process.stdout.write(
|
|
2304
|
-
`Ready — design ${designLabel}${env ? ` [${env}]` : ""} (no PowerPoint window)\n`,
|
|
2305
|
-
);
|
|
2306
|
-
process.stdout.write(
|
|
2307
|
-
` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`,
|
|
2308
|
-
);
|
|
2309
|
-
process.stdout.write(` deck ${deckSlug} bound\n`);
|
|
2310
|
-
process.stdout.write(
|
|
2311
|
-
" Note: build-pptx/build/review/view-draft work without a window; push/search/read need a pane.\n",
|
|
3199
|
+
} else if (brands.length > 1) {
|
|
3200
|
+
const rows = brands.map(
|
|
3201
|
+
(b) => ` ${b.presFP}${b.env ? ` [${b.env}]` : ""} ${b.name ?? ""}`.trimEnd(),
|
|
2312
3202
|
);
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
3203
|
+
const blocker = {
|
|
3204
|
+
code: "ambiguous_brand",
|
|
3205
|
+
actor: "user",
|
|
3206
|
+
message:
|
|
3207
|
+
"Several corporate designs are available and no PowerPoint window says which — ask the user which to build against, then re-run: slidemind ready" +
|
|
3208
|
+
`${deckSlug ? ` ${deckSlug}` : ""} --brand <presFP>.`,
|
|
3209
|
+
};
|
|
3210
|
+
finishBlocker({
|
|
3211
|
+
blocker,
|
|
3212
|
+
extras: {
|
|
3213
|
+
brands: brands.map((b) => ({
|
|
3214
|
+
presFP: b.presFP,
|
|
3215
|
+
name: b.name ?? null,
|
|
3216
|
+
env: b.env ?? null,
|
|
3217
|
+
})),
|
|
3218
|
+
},
|
|
3219
|
+
humanLines:
|
|
3220
|
+
"slidemind: several corporate designs are available — ask the user which to build against:\n" +
|
|
3221
|
+
`${rows.join("\n")}\n` +
|
|
3222
|
+
`Then re-run: slidemind ready${deckSlug ? ` ${deckSlug}` : ""} --brand <presFP>`,
|
|
3223
|
+
});
|
|
2316
3224
|
return;
|
|
2317
3225
|
}
|
|
2318
3226
|
}
|
|
@@ -2345,6 +3253,7 @@ async function cmdReady(rest) {
|
|
|
2345
3253
|
{
|
|
2346
3254
|
ready: true,
|
|
2347
3255
|
connectOnly: true,
|
|
3256
|
+
engines: await engines(),
|
|
2348
3257
|
nextAction: "Ready.",
|
|
2349
3258
|
backends: (pub.backends ?? []).map((b) => ({ env: b.env, ok: b.ok })),
|
|
2350
3259
|
},
|
|
@@ -2468,6 +3377,7 @@ async function cmdReady(rest) {
|
|
|
2468
3377
|
|
|
2469
3378
|
const designName = readDesignName(result.dir);
|
|
2470
3379
|
const paneSuspended = !client.isPaneReadyPresentation(entry);
|
|
3380
|
+
const engineInfoForWindow = await engines();
|
|
2471
3381
|
|
|
2472
3382
|
if (asJson) {
|
|
2473
3383
|
process.stdout.write(
|
|
@@ -2487,6 +3397,7 @@ async function cmdReady(rest) {
|
|
|
2487
3397
|
fileName: entry.fileName ?? null,
|
|
2488
3398
|
},
|
|
2489
3399
|
paneSuspended,
|
|
3400
|
+
engines: engineInfoForWindow,
|
|
2490
3401
|
warnings: result.warnings ?? [],
|
|
2491
3402
|
nextAction: "Ready.",
|
|
2492
3403
|
},
|
|
@@ -2511,7 +3422,9 @@ async function cmdReady(rest) {
|
|
|
2511
3422
|
if (bound) process.stdout.write(` deck ${deckSlug} bound\n`);
|
|
2512
3423
|
if (paneSuspended) {
|
|
2513
3424
|
process.stdout.write(
|
|
2514
|
-
|
|
3425
|
+
engineInfoForWindow.available
|
|
3426
|
+
? " Note: pane operations (push/insert-slide/read of this window) will wait for a pane click; engine search does not.\n"
|
|
3427
|
+
: " Note: pane operations (push/search/read) will wait for a pane click.\n",
|
|
2515
3428
|
);
|
|
2516
3429
|
}
|
|
2517
3430
|
for (const w of result.warnings ?? []) {
|
|
@@ -2530,11 +3443,14 @@ async function cmdDoctor(rest) {
|
|
|
2530
3443
|
// still reaches the reader, as the `cli_update_required` rung of the ladder.
|
|
2531
3444
|
client.setDiagnosticMode(true);
|
|
2532
3445
|
if (sessions.length === 0) {
|
|
3446
|
+
const blocker = backendBlocker({ paired: false });
|
|
2533
3447
|
const pub = {
|
|
2534
3448
|
paired: false,
|
|
2535
3449
|
backendCount: 0,
|
|
2536
3450
|
backends: [],
|
|
2537
3451
|
presentations: [],
|
|
3452
|
+
authoring: { ready: false, blocker, degraded: null, engines: null },
|
|
3453
|
+
host: { ready: false, blocker: paneBlocker({ presentations: [] }) },
|
|
2538
3454
|
nextAction: connectionNextAction({ paired: false }),
|
|
2539
3455
|
};
|
|
2540
3456
|
process[asJson ? "stdout" : "stderr"].write(
|
|
@@ -2542,7 +3458,30 @@ async function cmdDoctor(rest) {
|
|
|
2542
3458
|
);
|
|
2543
3459
|
return;
|
|
2544
3460
|
}
|
|
2545
|
-
const
|
|
3461
|
+
const statusRaw = await client.connectionStatus();
|
|
3462
|
+
const status = publicConnectionStatus(statusRaw, client);
|
|
3463
|
+
// The two ladders are INDEPENDENT: authoring (session → reachable backend →
|
|
3464
|
+
// CLI version → engine reachability) is what render, bundle and the pane-free
|
|
3465
|
+
// engine routes ride on, while the live-PowerPoint ladder only governs
|
|
3466
|
+
// push/insert-slide and reading an open presentation. Reporting them
|
|
3467
|
+
// separately is the point — a dead pane is not "cannot work".
|
|
3468
|
+
const authoringBlocker = backendBlocker(status);
|
|
3469
|
+
let engines = { available: false, envs: [] };
|
|
3470
|
+
if (!authoringBlocker) {
|
|
3471
|
+
try {
|
|
3472
|
+
engines = await client.engineReachability({ backends: statusRaw.backends });
|
|
3473
|
+
} catch {
|
|
3474
|
+
/* leave it as unavailable — never fail a diagnosis on a probe */
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
status.authoring = {
|
|
3478
|
+
ready: !authoringBlocker,
|
|
3479
|
+
blocker: authoringBlocker,
|
|
3480
|
+
degraded: permissionsStaleDegradation(status, client),
|
|
3481
|
+
engines,
|
|
3482
|
+
};
|
|
3483
|
+
const hostBlocker = paneBlocker(status);
|
|
3484
|
+
status.host = { ready: !hostBlocker, blocker: hostBlocker };
|
|
2546
3485
|
status.nextAction = connectionNextAction(status);
|
|
2547
3486
|
if (asJson) {
|
|
2548
3487
|
process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
|
|
@@ -2556,6 +3495,32 @@ async function cmdDoctor(rest) {
|
|
|
2556
3495
|
`\n`,
|
|
2557
3496
|
);
|
|
2558
3497
|
}
|
|
3498
|
+
process.stdout.write(
|
|
3499
|
+
`Backend authoring ready: ${status.authoring.ready ? "yes" : "no"}` +
|
|
3500
|
+
`${status.authoring.ready ? "" : ` — ${authoringBlocker.message}`}\n`,
|
|
3501
|
+
);
|
|
3502
|
+
// One condition, one cause, named the same way everywhere: when the readiness
|
|
3503
|
+
// capabilities already say the permission snapshot has aged out, this line
|
|
3504
|
+
// says so too, instead of reporting a healthy pane-free surface that a corpus
|
|
3505
|
+
// search is about to 403 on for a reason the user learns only then.
|
|
3506
|
+
const staleEnvs = permissionsStaleEnvs(status);
|
|
3507
|
+
process.stdout.write(
|
|
3508
|
+
` engine search (no window needed): ${
|
|
3509
|
+
engines.available
|
|
3510
|
+
? `yes [${engines.envs.join(", ")}]` +
|
|
3511
|
+
(staleEnvs.length
|
|
3512
|
+
? ` — corpus search degraded on ${staleEnvs.join(", ")}: stale permission snapshot`
|
|
3513
|
+
: "")
|
|
3514
|
+
: "no — relayed through an open pane"
|
|
3515
|
+
}\n`,
|
|
3516
|
+
);
|
|
3517
|
+
if (status.authoring.degraded) {
|
|
3518
|
+
process.stdout.write(` degraded: ${status.authoring.degraded.message}\n`);
|
|
3519
|
+
}
|
|
3520
|
+
process.stdout.write(
|
|
3521
|
+
`Live PowerPoint ready: ${status.host.ready ? "yes" : "no"}` +
|
|
3522
|
+
`${status.host.ready ? "" : ` — ${hostBlocker.message}`}\n`,
|
|
3523
|
+
);
|
|
2559
3524
|
if (status.presentations.length === 0) {
|
|
2560
3525
|
process.stdout.write("Open presentations: none\n");
|
|
2561
3526
|
} else {
|
|
@@ -3268,6 +4233,9 @@ async function main(rawArgv) {
|
|
|
3268
4233
|
case "build-pptx":
|
|
3269
4234
|
await cmdBuildPptx(rest);
|
|
3270
4235
|
return 0;
|
|
4236
|
+
case "inspect-template-deck":
|
|
4237
|
+
await cmdInspectTemplateDeck(rest);
|
|
4238
|
+
return 0;
|
|
3271
4239
|
case "view-draft":
|
|
3272
4240
|
await cmdViewDraft(rest);
|
|
3273
4241
|
return 0;
|
|
@@ -3280,6 +4248,9 @@ async function main(rawArgv) {
|
|
|
3280
4248
|
case "view-slide":
|
|
3281
4249
|
await cmdViewSlide(rest);
|
|
3282
4250
|
return 0;
|
|
4251
|
+
case "read-source":
|
|
4252
|
+
await cmdReadSource(rest);
|
|
4253
|
+
return 0;
|
|
3283
4254
|
case "insert-slide":
|
|
3284
4255
|
await cmdInsertSlide(rest);
|
|
3285
4256
|
return 0;
|