slidemind 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +3 -0
- package/bin/slidemind.mjs +450 -55
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +6 -5
- package/skills/search/SKILL.md +10 -4
- package/src/bridge/bundle.mjs +21 -3
- package/src/bridge/client.mjs +414 -7
- package/src/bridge/engine-download.mjs +229 -5
- package/src/bridge/image-dimensions.mjs +181 -0
- package/src/builders/resolve-skill.mjs +45 -1
- package/src/builders/review.mjs +4 -1
- package/src/pptx/fill/engine.mjs +1273 -0
- package/src/pptx/fill/freeform.mjs +156 -0
- package/src/pptx/fill/parse-slides.mjs +154 -0
- package/src/pptx/fill/xml.mjs +486 -0
- package/src/pptx/ooxml.mjs +10 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slidemind",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Connect Claude Code, Codex & opencode to the slidemind PowerPoint add-in — let your coding agent build and edit native, on-brand PowerPoint decks against your active corporate design, from the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -146,7 +146,7 @@ Keep planning about intent and content. The bundle is already materialized by `s
|
|
|
146
146
|
1. **Study brand samples (before composing).** The materialized bundle's `samples/` dir holds the brand's own reference slides. If it's non-empty, pick **3 at random** and **Read** each (the Read tool renders images) *before* writing any JSX — this is the local-harness stand-in for the in-app persona's reference thumbnails, and it is what keeps a deck looking on-brand rather than merely compliant. Study them for whitespace rhythm, shape density, hierarchy, accent use, and layering, then compose in that spirit (never copy a sample 1:1). This is `composition.md`'s samples discipline; skip only when `samples/` is empty.
|
|
147
147
|
2. **Read, then author.** Read the create-stage references together — `workflow/create.md` (the procedure), `workflow/composition.md` (the composition baseline), `workflow/layouts.md` (palette + working area), and `workflow/authoring.md` (the substrate rules) — plus the deck's `design.md` and the specific `layouts/*.jsx` the plan uses. (That is the read set `workflow/create.md` → Context loading prescribes; follow it as written — `workflow/flow.md` you already read after `ready`.) Then write `decks/<slug>/slides/NN-<topic>.jsx` — zero-padded numeric prefix from the first file on (play order is filename order here; see the slide-file row in the command mapping), one default export per slide, composing one bundle layout (Mode A) or a blank-content layout with freeform children (Mode B), exactly as those docs prescribe. The approved `plan.md` is a **content contract, not a rendering spec** — you drew its storyboards yourself, but approval covered the story and content, not the sketch's geometry; how to interpret the `storyboard-canvas` blocks at author time (design hypothesis: reinterpret freely, never transcribe coordinates, block geometry, or block labels) is owned by `workflow/create.md` → Preconditions. **Generate concept mockups first — mandatory for a deck (stage 4).** Before authoring a deck from an approved plan, run `slidemind concepts <deck>` — the local-harness equivalent of the in-app manager's *unconditional* concept pass (`generateConceptImages` fires for every `approvedPlan && !targetSlideScope` delegation). Then **Read** each `decks/<slug>/concepts/concept-slide-NN.png` as the per-slide design target and rebuild it under your constraints — the rebuild rule is owned by `workflow/create.md` → Preconditions (reproduce the mockup's composition, hierarchy, graphic mechanism, AND granularity — its organism count, badges, satellites, and annotation layers are part of the target, and restraint is a colour budget, never an element budget; texts follow the story). It never hard-fails: if it produces no mockups (exit 0 — the add-in can't generate them) author from the storyboards alone. Skip it only where the add-in's `!targetSlideScope` gate would — a single slide, a tiny in-chat set, or a targeted rework of existing slides.
|
|
148
148
|
When storyboard objects share a `visualFamily`, rebuild those mockups as one recognizable visual series. The lowest-numbered member is the anchor. Preserve its shape vocabulary, framing, hierarchy, annotation style, illustration treatment, and spatial rhythm across the family, but adapt every member's element count, sizing, arrangement, labels, emphasis, and connections to its own complete `keyMessage`, `contentNotes`, role, and structure. Never copy another member's text, claims, or data, never force its exact geometry, and never omit approved content because it does not fit the anchor composition.
|
|
149
|
-
3. **Imagery from the engine.** Discover the available image engines by listing them — `slidemind ls engines --subtype image` (the local-harness equivalent of the in-app design agent's `list_engines` tool) — judge which engine fits from its label and description, then search it and place results. (`slidemind search` without `--engine` picks an image engine the same way.) The engine search/reuse mechanics live in **`slidemind-search`** — use it to find and fetch imagery; the create-only icon
|
|
149
|
+
3. **Imagery from the engine.** Discover the available image engines by listing them — `slidemind ls engines --subtype image` (the local-harness equivalent of the in-app design agent's `list_engines` tool) — judge which engine fits from its label and description, then search it and place results. (`slidemind search` without `--engine` picks an image engine the same way.) The engine search/reuse mechanics live in **`slidemind-search`** — use it to find and fetch imagery; the create-only detail — sizing a fetched asset's box and recolouring an icon — is in *Placing engine imagery* below. To pull in an **existing whole slide** (not just an image), use `slidemind-search`'s `add-slide` — it folds the slide into `decks/<slug>/slides/` as a native `.pptx` that coexists with your JSX in play order and survives re-push. The slide can come from an engine search **or** from a local `.pptx` on disk (`add-slide --from <file.pptx> --pick <n> --deck <slug>`, host-free); both fold in identically — see `slidemind-search`.
|
|
150
150
|
4. **Look at your own draft first (you, the agent).** This is the **draft check** in `workflow/build-and-export.md` — your pre-check, *before* any human sees the deck. Run `slidemind view-draft <deck>`: it renders each authored `slides/*.jsx` to a per-slide PNG and hands the images back to you — **nothing is pushed, no `.pptx` is written, no review gate opens**. Read every image the way the audience will, fix anything visibly wrong **in the JSX** (re-express, never delete), and view again until each slide is sound. One round usually suffices. It runs locally against the materialized bundle, so it needs no awake pane and no open host once the bundle has been downloaded once. Two faithful limits to know: a slide on a **purpose-built (Mode A) layout** (cover/divider/agenda) and a **reused native `.pptx`** slide have no freeform region to capture, so they come back as a status line, not an image — judge those at the full-deck `review`/`build` step below. Do this self-check before the human review gate; don't push a draft you haven't looked at.
|
|
151
151
|
5. **The review gate — the moment the deck builds clean.** This is the **review gate** in `workflow/build-and-export.md`; the CLI's gate command is `slidemind review <deck>`. Run it the instant the authored set builds clean — before any deliver, without asking whether the user wants a review. It builds the HTML preview first (the same lint the render enforces, so a gate that *opens* is a clean build; while lint errors remain it exits non-zero — fix and re-run), then opens the browser with a spatial comment overlay and **blocks until the user decides** (host-free — no open PowerPoint needed for the gate). Announce it per *Two UX rules*: tell the user the browser opened and give the clickable gate URL. **Blocking is the point: give the shell call a generous timeout (the gate waits up to 30 min) and let it sit — a long quiet wait means the user is reviewing, not that something hung. Never cancel it; if your harness kills the call early, just re-run it (the user's open tab reconnects).** The verdict prints to stdout:
|
|
152
152
|
- `DECISION: approved` → proceed to the deliver the user chose at intake: `slidemind push <deck>` (into PowerPoint) or `slidemind build-pptx <deck>` (to a file).
|
|
@@ -157,12 +157,13 @@ Keep planning about intent and content. The bundle is already materialized by `s
|
|
|
157
157
|
|
|
158
158
|
**On a clean approve + deliver, the task is done.** Report what landed (slide count + target) and stop — no sanity pass, no re-verification. The visual QA already happened at the draft check (step 4) and the user signed off at the review gate.
|
|
159
159
|
|
|
160
|
-
##
|
|
160
|
+
## Placing engine imagery (create-authoring specifics)
|
|
161
161
|
|
|
162
|
-
Finding and fetching engine imagery is `slidemind-search`'s job (`slidemind search` → `slidemind download-result`). What follows is **create-only**: how to colour a fetched
|
|
162
|
+
Finding and fetching engine imagery is `slidemind-search`'s job (`slidemind search` → `slidemind download-result`). What follows is **create-only**: how to size and colour a fetched asset so it reads on-brand in your JSX, honoured in both the `slidemind build` preview and the render.
|
|
163
163
|
|
|
164
164
|
Engine results land as files referenced with `<img src="./assets/<file>">`. These facts are specific to the local harness (the bundle's `workflow/*.md` stay harness-neutral):
|
|
165
165
|
|
|
166
|
+
- **Size the box from the asset's own dimensions.** Both `slidemind search` (from the engine index, before any fetch) and `slidemind download-result` (measured from the delivered bytes) print each image's intrinsic `width×height`. **Use it.** A plain `<img>` is stretched to whatever box you write, so a box whose aspect ratio disagrees with the asset ships a distorted picture. Derive one side from your layout and compute the other from the printed ratio. `object-fit: contain`/`cover` is honoured in the render as well as the preview, but it is a *fallback*, not a substitute: it converts a wrong ratio into letterboxing or a crop, both of which you chose blind. When the size prints as `(size unknown)`, the format carried no readable header — give the asset a square-ish box and check it at the draft check.
|
|
166
167
|
- **Never open the SVG to find out whether it recolours.** `slidemind download-result` prints a `recolor` descriptor for each asset — `{ recolorable, mode, applied, note }`, where `mode` is `currentColor`, `mono`, `multicolor`, or `raster`. Read it; that *is* the answer.
|
|
167
168
|
- **Two ways to colour an icon, both honoured in the `slidemind build` preview *and* the render:**
|
|
168
169
|
- **Bake it in (prefer this):** pass `--icon-color <hex>` — a hex you resolve from a token in the bundle's `corporate-design.css` — to `slidemind download-result`. Works for any `recolorable` glyph and the colour is in the saved file, so preview and render always agree.
|
|
@@ -186,8 +187,8 @@ The bundle's `workflow/*.md` speak the harness-neutral verbs defined in `workflo
|
|
|
186
187
|
| **build & push** | `slidemind push <deck>` (into the open PowerPoint) · `slidemind build-pptx <deck>` (to a `.pptx` file) — deliver whichever target was locked at intake; both render server-side via the bridge, and for a bound deck the render is pane-free (`build-pptx` needs no PowerPoint window at all) |
|
|
187
188
|
| **the review gate** | `slidemind review <deck>` — builds the HTML preview (lint-clean or it doesn't open), serves it with a spatial comment overlay and **blocks until the user decides**, then prints `DECISION: approved` or `DECISION: comments` (each comment cited to `slides/<file>.jsx:line:col`). On approve → deliver (`slidemind push` / `build-pptx`); on comments → edit the cited files and re-run `slidemind review` (prior round shows as ghost pins). CLI delivers happen out of the user's sight, so **every deliver follows an approved review of what it delivers — re-run `review` after any slide change**. `push`/`build-pptx` never pause; only `review` does. |
|
|
188
189
|
| **inspect the deck** | `slidemind read [presId\|file.pptx] [--mode summary\|raw]` — one verb, two sources: pull the current content of an open presentation (a presId; host) **or** read a local `.pptx` on disk (a file path; host-free — no PowerPoint, no pairing). `summary` = per-slide outline; `raw` = each slide's plain text — to inspect what's there before editing. Plain text, not authoring markup |
|
|
189
|
-
| **discover engines** (`list_engines`) | `slidemind ls engines [--type <collection\|search>] [--subtype <image\|slide\|template\|content>]` — the live catalog is the only engine list (the bundle carries none); pick an
|
|
190
|
-
| **engine search / fetch** | **`slidemind-search`** owns the engine search + reuse mechanics (`slidemind search` → `slidemind download-result`, plus whole-slide reuse via `add-slide`). For placing a fetched
|
|
190
|
+
| **discover engines** (`list_engines`) | `slidemind ls engines [--type <collection\|search\|scope>] [--subtype <image\|slide\|template\|content>]` — the live catalog is the only engine list (the bundle carries none); pick an `id` from it, never guess one. It also lists **search scopes** for unfiltered corpus search — those belong to `slidemind-search`, not to imagery |
|
|
191
|
+
| **engine search / fetch** | **`slidemind-search`** owns the engine search + reuse mechanics (`slidemind search` → `slidemind download-result`, plus whole-slide reuse via `add-slide`). For placing a fetched asset in your JSX, size its box from the printed `width×height`, pass `--icon-color` / batch with `--pick <n,n,…>` and colour per *Placing engine imagery* below |
|
|
191
192
|
| **see** a live slide | no CLI analog — once delivered, the user is looking at the result; your visual authority runs at the draft check and the review gate instead |
|
|
192
193
|
|
|
193
194
|
Everything else in `workflow/*.md` — the composition craft, the substrate/authoring rules, the layout discovery and slot/working-area rules, the visual quality bar (applied at the draft check) — applies to you unchanged.
|
package/skills/search/SKILL.md
CHANGED
|
@@ -37,9 +37,10 @@ The pane-touching ops here (`search`, `view_slide`, `insert-slide`, `download-re
|
|
|
37
37
|
|
|
38
38
|
Follow `workflow/search.md` for the discipline; this is the CLI shape of its loop.
|
|
39
39
|
|
|
40
|
-
1. **Discover
|
|
41
|
-
2. **Choose the
|
|
42
|
-
3. **Search — no window to choose.** `slidemind search "<query>" --engine <id> --intent <auto|content|visual> [--top N]` (leave `--to` off — search attaches to any connected window itself). It returns a `searchId` plus numbered results, and prints **which window it ran on** (`searchId N ran on window <presId>`). **Thread that `presId` into every follow-up** as `--to <presId>` (view-slide / insert-slide / add-slide / download-result): the `searchId` is bound to that window, so a follow-up aimed at a different window won't find it.
|
|
40
|
+
1. **Discover targets — never guess an id.** Run `slidemind ls engines` and read the catalog before searching. It lists two kinds of target: **engines** (`--type collection|search`) and **scopes** (`--type scope` — `scope:all`, and `scope:sharepoint` once a site is connected), which search the corpus unfiltered. Filter with `--subtype <image|slide|template|content>` (e.g. `slidemind ls engines --type search --subtype slide`). The catalog is **per-user, not per-presentation** — there is no presId to scope; spanning every paired environment, each row is tagged with its `env`. (`--to` is accepted for symmetry but ignored.) Pick an **id** from the list — do not invent one. **An empty or thin engine list is not "nothing to search"** (see `workflow/search.md`): fall through to a scope.
|
|
41
|
+
2. **Choose the target + intent.** Slide engines (`subtype slide`) and scopes support two intents: **content** (the slide's text is the signal — title, body, tables, notes) and **visual** (the slide image is the signal). Image/icon engines (`subtype image`) are **visual-only**. Match the intent to the ask: "a slide that says X" / "covers topic Y" → `--intent content`; "a slide that looks like X" / brand imagery → `--intent visual`. `--intent auto` lets the engine decide.
|
|
42
|
+
3. **Search — no window to choose.** `slidemind search "<query>" --engine <id> --intent <auto|content|visual> [--top N]` (leave `--to` off — search attaches to any connected window itself). A **scope** target additionally needs `--result-type slide|image` (it has no subtype of its own; the CLI defaults it to `slide`). Omit `--engine` entirely and the CLI picks for you: a slide intent searches `scope:all` for slides, anything else resolves a live image engine. It returns a `searchId` plus numbered results, and prints **which window it ran on** (`searchId N ran on window <presId>`). **Thread that `presId` into every follow-up** as `--to <presId>` (view-slide / insert-slide / add-slide / download-result): the `searchId` is bound to that window, so a follow-up aimed at a different window won't find it.
|
|
43
|
+
- **On `0 result(s)`, read the diagnostic the CLI prints and act on it** — it lists the causes that look identical from here (nothing connected, still indexing, the engine's folder filter excludes the content) and the next moves. Reuse from a scope works exactly like reuse from an engine, so widening costs you nothing.
|
|
43
44
|
4. **Read / view, then pick.** For **content** results, read the per-slide **text + score** the search prints — that is the on-the-wire signal; you usually don't need the picture. For **visual** results, scan the numbered thumbnail list; pull a single result's image on demand with `slidemind view-slide --search <searchId> --pick <n> --to <presId>` (it writes the preview to a file you can Read), or `slidemind search … --json` for the structured payload. Pick by **selection number(s)**.
|
|
44
45
|
5. **Reuse.** Insert live or fold into a deck — see the next section.
|
|
45
46
|
|
|
@@ -65,6 +66,8 @@ This writes the **same** `decks/<slug>/slides/NN-reused-*.pptx` as the engine pa
|
|
|
65
66
|
|
|
66
67
|
For brand **imagery** (icons/images, not whole slides), the reuse step is `slidemind download-result` — it writes the asset into the deck's `assets/`. That mechanism (including the icon-recolor descriptor and `--icon-color`) is documented where it's used, in `slidemind-create-slides`.
|
|
67
68
|
|
|
69
|
+
Image results print their **intrinsic `width×height`** next to the title — the engine indexes it, so the aspect ratio is yours *before* you fetch anything. Carry it into the pick: a result whose shape doesn't suit the slot you have in mind is the wrong pick, whatever it depicts. (`download-result` re-states the size, measured from the delivered bytes.)
|
|
70
|
+
|
|
68
71
|
## Working with slidemind-create-slides
|
|
69
72
|
|
|
70
73
|
These two skills compose. When the user is **authoring** a deck (that's `slidemind-create-slides`) and wants an **existing** slide incorporated, reach for this skill's `add-slide`: it folds the existing slide into the same `decks/<slug>/slides/` directory as the authored JSX. The reused `.pptx` and the authored `.jsx` coexist in play order (numeric filename prefix) and the reused slide is **not lost on re-push**. Hand back to `slidemind-create-slides` for any new content authoring.
|
|
@@ -75,8 +78,9 @@ These two skills compose. When the user is **authoring** a deck (that's `slidemi
|
|
|
75
78
|
|
|
76
79
|
| Verb (`workflow/search.md`) | Your equivalent (local harness) |
|
|
77
80
|
|---|---|
|
|
78
|
-
| **discover engines** ("list what you can reach") | `slidemind ls engines [--type
|
|
81
|
+
| **discover engines** ("list what you can reach") | `slidemind ls engines [--type collection\|search\|scope] [--subtype …]` — returns the engine catalog **plus the search scopes** (pick an `id`; never guess one). The catalog is backend/user-scoped; `--to` is accepted but ignored (informational only). |
|
|
79
82
|
| **engine search** (with `intent`) | `slidemind search "<query>" --engine <id> --intent <auto\|content\|visual> [--top N]` — leave `--to` off (attaches to any window) → a `searchId` + numbered results, **plus the presId it ran on** (thread that in as `--to` on the follow-ups below) |
|
|
83
|
+
| **search a scope** (unfiltered corpus, no engine) | `slidemind search "<query>" --engine <scope:all\|scope:sharepoint> --result-type <slide\|image> [--intent …]` — `--result-type` is required for a scope (defaults to `slide`). Results reuse identically to engine results. |
|
|
80
84
|
| **pull an on-demand thumbnail** for a stored result | `slidemind view-slide --search <searchId> --pick <n> --to <searchWindow>` — writes one result's preview image to a file (`--to` = the window the search returned) · or `slidemind search … --json` for the structured payload. |
|
|
81
85
|
| **insert live** (reuse a slide → the open window) | `slidemind insert-slide --search <searchId> --pick <n[,n,…]> --to <searchWindow> [--at N]` — `--to` = the window the search returned; the slide lands there |
|
|
82
86
|
| **fold into a deck** (reuse a slide → deck source) | `slidemind add-slide --search <searchId> --pick <n[,n,…]> --deck <deck> --to <searchWindow> [--at N]` → writes `decks/<slug>/slides/NN-reused-*.pptx`; `--to` = the window the search returned (extraction runs there) |
|
|
@@ -89,3 +93,5 @@ Everything else in `workflow/search.md` — the engine-discovery discipline, con
|
|
|
89
93
|
## The engines and the workflow both live in the bundle, not here
|
|
90
94
|
|
|
91
95
|
Do not restate which engines exist, what they contain, or any brand rule — the engine catalog comes from `slidemind ls engines` (live discovery — the bundle carries no engine list), and the search/reuse craft is in `workflow/search.md`. Read them and follow them. If the corporate design moves mid-task, re-list engines before searching again, since the catalog may have changed.
|
|
96
|
+
|
|
97
|
+
`workflow/search.md` also owns the judgement this skill deliberately does **not** restate: what an engine actually is (a saved filter over a shared corpus, not a library of its own), when to reach for a scope instead, and why zero results are not evidence that the content doesn't exist. Read it before concluding a search came up empty.
|
package/src/bridge/bundle.mjs
CHANGED
|
@@ -648,11 +648,29 @@ export function loadEnginesForDeck(deckDir) {
|
|
|
648
648
|
return readEnginesFromDir(resolveBundleDirForDeck(deckDir));
|
|
649
649
|
}
|
|
650
650
|
|
|
651
|
+
// Reserved non-engine search targets. An engine is a saved FILTER over the
|
|
652
|
+
// add-in's shared corpus; a scope searches that corpus unfiltered. They ride in
|
|
653
|
+
// the same id slot as engines — engine ids are UUIDs and can never contain ":",
|
|
654
|
+
// so the namespace can't collide. Kept in sync with the add-in's
|
|
655
|
+
// `types/search-scope.ts` / `models/domain/search_scopes.py`.
|
|
656
|
+
export const SCOPE_PREFIX = "scope:";
|
|
657
|
+
export const SCOPE_ALL = "scope:all";
|
|
658
|
+
export const SCOPE_SHAREPOINT = "scope:sharepoint";
|
|
659
|
+
|
|
660
|
+
// True when an id names a search scope rather than an engine. Matches unknown
|
|
661
|
+
// `scope:*` values too, so a typo is reported as a bad scope by the add-in
|
|
662
|
+
// instead of being looked up as an engine and reported as "Engine not found".
|
|
663
|
+
export function isSearchScopeId(id) {
|
|
664
|
+
return typeof id === "string" && id.startsWith(SCOPE_PREFIX);
|
|
665
|
+
}
|
|
666
|
+
|
|
651
667
|
// Image-producing engine kinds — the modality "image" plus the finer image
|
|
652
668
|
// content kinds an authored bundle may carry. Excludes "slide" (a slide-search
|
|
653
|
-
// engine returns whole slides, not insertable bytes)
|
|
654
|
-
//
|
|
655
|
-
//
|
|
669
|
+
// engine returns whole slides, not insertable bytes) and scopes (whose subtype
|
|
670
|
+
// is empty — they are not a brand image source, so the imagery default must not
|
|
671
|
+
// silently resolve to one). Mirrors the add-in's local-bridge
|
|
672
|
+
// `engine_not_image` gate so the CLI never picks an engine the bridge would
|
|
673
|
+
// reject.
|
|
656
674
|
const IMAGE_ENGINE_KIND_RE = /^(image|icon|photo|illustration|vector)$/i;
|
|
657
675
|
|
|
658
676
|
// True when an engine produces insertable image bytes. Gates on the engine's
|
package/src/bridge/client.mjs
CHANGED
|
@@ -51,6 +51,42 @@ export function compareCliVersions(a, b) {
|
|
|
51
51
|
return 0;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// The one error every version-floor check raises, so the probe path
|
|
55
|
+
// (assertCliVersionSupported) and the per-response header path are
|
|
56
|
+
// indistinguishable to callers and to the skills that relay the text.
|
|
57
|
+
function cliUpdateRequiredError(min) {
|
|
58
|
+
const err = new Error(
|
|
59
|
+
`slidemind CLI ${cliVersion()} is older than the minimum this add-in ` +
|
|
60
|
+
`supports (${min}). Run \`slidemind update\` (or ` +
|
|
61
|
+
"`npm install -g slidemind@latest`), then retry.",
|
|
62
|
+
);
|
|
63
|
+
err.code = "cli_update_required";
|
|
64
|
+
return err;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// `doctor` and `ready` exist to EXPLAIN a broken connection, version skew
|
|
68
|
+
// included — they render it as a structured blocker with an `actor` and a
|
|
69
|
+
// nextAction. Throwing the raw error out of bridgeFetch would break the one
|
|
70
|
+
// command that diagnoses the problem, so they opt out of the header check
|
|
71
|
+
// (their own ladder still reports cli_update_required, terminally).
|
|
72
|
+
let diagnosticMode = false;
|
|
73
|
+
export function setDiagnosticMode(on) {
|
|
74
|
+
diagnosticMode = on !== false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Every add-in response carries the version floor in `x-slidemind-min-cli`.
|
|
78
|
+
// Checking it here covers routes no capability probe guards — the pane ops
|
|
79
|
+
// (search/read/insert) and pairing itself — for zero extra round-trips, and it
|
|
80
|
+
// fires BEFORE the status handling below so a stale CLI hitting a route that
|
|
81
|
+
// does not exist yet reads "run slidemind update" instead of a bare 404.
|
|
82
|
+
function enforceVersionFloor(response) {
|
|
83
|
+
if (diagnosticMode) return;
|
|
84
|
+
const min = response?.headers?.["x-slidemind-min-cli"];
|
|
85
|
+
if (typeof min !== "string" || !min) return;
|
|
86
|
+
if (compareCliVersions(cliVersion(), min) >= 0) return;
|
|
87
|
+
throw cliUpdateRequiredError(min);
|
|
88
|
+
}
|
|
89
|
+
|
|
54
90
|
function isLoopbackHostname(hostname) {
|
|
55
91
|
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
|
|
56
92
|
}
|
|
@@ -199,6 +235,7 @@ async function bridgeFetch(pathPart, init = {}, backendURL = null) {
|
|
|
199
235
|
}
|
|
200
236
|
throw err;
|
|
201
237
|
}
|
|
238
|
+
enforceVersionFloor(response);
|
|
202
239
|
if (response.status === 401) {
|
|
203
240
|
throw new Error(
|
|
204
241
|
"slidemind session is invalid or expired. Re-pair: open the add-in, " +
|
|
@@ -267,6 +304,7 @@ function nodeRequest(urlString, init = {}) {
|
|
|
267
304
|
resolve({
|
|
268
305
|
status: res.statusCode ?? 0,
|
|
269
306
|
statusText: res.statusMessage ?? "",
|
|
307
|
+
headers: res.headers,
|
|
270
308
|
body: Buffer.concat(chunks).toString("utf8"),
|
|
271
309
|
});
|
|
272
310
|
});
|
|
@@ -290,6 +328,163 @@ function nodeRequest(urlString, init = {}) {
|
|
|
290
328
|
});
|
|
291
329
|
}
|
|
292
330
|
|
|
331
|
+
// A streaming sibling of `nodeRequest`, for responses too large to hold in
|
|
332
|
+
// memory. `nodeRequest` concatenates the whole body and decodes it as UTF-8 —
|
|
333
|
+
// fine for JSON, but it would both balloon memory and CORRUPT binary. This pipes
|
|
334
|
+
// a success response straight to `destPath` and never materializes it.
|
|
335
|
+
//
|
|
336
|
+
// Error and JSON bodies are still buffered: both are small by construction (an
|
|
337
|
+
// error message, or the inline SVG the download route answers with), and the
|
|
338
|
+
// caller needs them whole to act on.
|
|
339
|
+
function nodeRequestToFile(urlString, init = {}, destPath) {
|
|
340
|
+
const url = new URL(urlString);
|
|
341
|
+
const isHttps = url.protocol === "https:";
|
|
342
|
+
const transport = isHttps ? https : http;
|
|
343
|
+
const body = init.body ?? null;
|
|
344
|
+
const headers = { ...(init.headers ?? {}) };
|
|
345
|
+
if (
|
|
346
|
+
body &&
|
|
347
|
+
headers["content-length"] === undefined &&
|
|
348
|
+
headers["Content-Length"] === undefined
|
|
349
|
+
) {
|
|
350
|
+
headers["content-length"] = Buffer.byteLength(body);
|
|
351
|
+
}
|
|
352
|
+
const timeoutMs =
|
|
353
|
+
init.timeoutMs ??
|
|
354
|
+
(parseInt(process.env.SLIDEMIND_BRIDGE_TIMEOUT_MS || "35000", 10) || 35000);
|
|
355
|
+
|
|
356
|
+
return new Promise((resolve, reject) => {
|
|
357
|
+
const req = transport.request(
|
|
358
|
+
url,
|
|
359
|
+
{
|
|
360
|
+
method: init.method ?? "GET",
|
|
361
|
+
headers,
|
|
362
|
+
rejectUnauthorized: isHttps
|
|
363
|
+
? !isLoopbackHostname(url.hostname)
|
|
364
|
+
: undefined,
|
|
365
|
+
},
|
|
366
|
+
(res) => {
|
|
367
|
+
const status = res.statusCode ?? 0;
|
|
368
|
+
const contentType = String(res.headers["content-type"] ?? "");
|
|
369
|
+
const buffered = status < 200 || status >= 300 || contentType.includes("application/json");
|
|
370
|
+
if (buffered) {
|
|
371
|
+
const chunks = [];
|
|
372
|
+
res.on("data", (c) => chunks.push(Buffer.from(c)));
|
|
373
|
+
res.on("end", () =>
|
|
374
|
+
resolve({
|
|
375
|
+
status,
|
|
376
|
+
statusText: res.statusMessage ?? "",
|
|
377
|
+
headers: res.headers,
|
|
378
|
+
body: Buffer.concat(chunks).toString("utf8"),
|
|
379
|
+
streamed: false,
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
res.on("error", reject);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const out = fs.createWriteStream(destPath);
|
|
386
|
+
let bytesWritten = 0;
|
|
387
|
+
const total = Number.parseInt(
|
|
388
|
+
String(res.headers["content-length"] ?? ""),
|
|
389
|
+
10,
|
|
390
|
+
);
|
|
391
|
+
res.on("data", (c) => {
|
|
392
|
+
bytesWritten += c.length;
|
|
393
|
+
init.onProgress?.(bytesWritten, Number.isFinite(total) ? total : null);
|
|
394
|
+
});
|
|
395
|
+
res.pipe(out);
|
|
396
|
+
// A stream that dies mid-flight must reject, not resolve with a truncated
|
|
397
|
+
// file — the caller deletes the partial and surfaces the failure.
|
|
398
|
+
res.on("error", (err) => {
|
|
399
|
+
out.destroy();
|
|
400
|
+
reject(err);
|
|
401
|
+
});
|
|
402
|
+
out.on("error", reject);
|
|
403
|
+
out.on("finish", () =>
|
|
404
|
+
resolve({
|
|
405
|
+
status,
|
|
406
|
+
statusText: res.statusMessage ?? "",
|
|
407
|
+
headers: res.headers,
|
|
408
|
+
bytesWritten,
|
|
409
|
+
streamed: true,
|
|
410
|
+
}),
|
|
411
|
+
);
|
|
412
|
+
},
|
|
413
|
+
);
|
|
414
|
+
req.setTimeout(timeoutMs, () => {
|
|
415
|
+
const err = new Error(
|
|
416
|
+
`slidemind bridge request timed out after ${timeoutMs}ms`,
|
|
417
|
+
);
|
|
418
|
+
err.code = "bridge_timeout";
|
|
419
|
+
req.destroy(err);
|
|
420
|
+
});
|
|
421
|
+
req.on("error", reject);
|
|
422
|
+
if (body) req.write(body);
|
|
423
|
+
req.end();
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// `bridgeFetch` for a response streamed to disk. Shares the session lookup,
|
|
428
|
+
// auth header and non-2xx error mapping so a streaming call fails exactly like
|
|
429
|
+
// every other bridge call.
|
|
430
|
+
async function bridgeStreamToFile(pathPart, init, destPath, backendURL = null) {
|
|
431
|
+
const session = sessionForBackend(backendURL);
|
|
432
|
+
const url = `${session.backendURL}${pathPart}`;
|
|
433
|
+
const headers = {
|
|
434
|
+
accept: "application/octet-stream, application/json",
|
|
435
|
+
...init.headers,
|
|
436
|
+
authorization: `Bearer ${session.sessionToken}`,
|
|
437
|
+
};
|
|
438
|
+
let response;
|
|
439
|
+
try {
|
|
440
|
+
response = await nodeRequestToFile(url, { ...init, headers }, destPath);
|
|
441
|
+
} catch (err) {
|
|
442
|
+
if (
|
|
443
|
+
err &&
|
|
444
|
+
/ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(
|
|
445
|
+
String(err.message || err),
|
|
446
|
+
)
|
|
447
|
+
) {
|
|
448
|
+
throw new Error(
|
|
449
|
+
`slidemind relay unreachable (${deriveEnvLabel(session.backendURL)}). Check your connection; ` +
|
|
450
|
+
"if it persists, re-pair with `slidemind pair`.",
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
throw err;
|
|
454
|
+
}
|
|
455
|
+
enforceVersionFloor(response);
|
|
456
|
+
if (response.status === 401) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
"slidemind session is invalid or expired. Re-pair: open the add-in, " +
|
|
459
|
+
'click "Connect CLI", and run the shown `slidemind pair …`.',
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
if (response.status < 200 || response.status >= 300) {
|
|
463
|
+
let parsed = null;
|
|
464
|
+
try {
|
|
465
|
+
parsed = response.body ? JSON.parse(response.body) : null;
|
|
466
|
+
} catch {
|
|
467
|
+
/* non-JSON error body */
|
|
468
|
+
}
|
|
469
|
+
const machineCode =
|
|
470
|
+
parsed && typeof parsed.code === "string" ? parsed.code : null;
|
|
471
|
+
const humanError =
|
|
472
|
+
parsed && typeof parsed.error === "string" ? parsed.error : null;
|
|
473
|
+
const detail =
|
|
474
|
+
humanError ?? (response.body ? response.body.slice(0, 400) : "");
|
|
475
|
+
const err = new Error(
|
|
476
|
+
`slidemind bridge ${response.status} ${response.statusText} for ${pathPart}` +
|
|
477
|
+
(detail ? `: ${detail}` : ""),
|
|
478
|
+
);
|
|
479
|
+
err.status = response.status;
|
|
480
|
+
const code = machineCode ?? humanError;
|
|
481
|
+
if (code) err.code = code;
|
|
482
|
+
if (parsed) err.body = parsed;
|
|
483
|
+
throw err;
|
|
484
|
+
}
|
|
485
|
+
return response;
|
|
486
|
+
}
|
|
487
|
+
|
|
293
488
|
// List one backend's open presentations, tagging each entry with its origin
|
|
294
489
|
// (backendURL + a short env label) so the merged registry can be routed and
|
|
295
490
|
// displayed per environment. Transient blips retry per-backend.
|
|
@@ -879,6 +1074,82 @@ export async function generateConceptImagesForBrand(
|
|
|
879
1074
|
throw err;
|
|
880
1075
|
}
|
|
881
1076
|
|
|
1077
|
+
// gpt-image calls routinely take 60-120s; budget like the concept relay so a
|
|
1078
|
+
// typed server error wins over a client timeout.
|
|
1079
|
+
function imagenTimeoutMs() {
|
|
1080
|
+
return (
|
|
1081
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_IMAGEN_TIMEOUT_MS || "200000", 10) ||
|
|
1082
|
+
200000
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
async function postGenerateImage(brandId, body, backendURL) {
|
|
1087
|
+
const res = await bridgeFetch(
|
|
1088
|
+
`/api/cli/brands/${encodeURIComponent(brandId)}/images`,
|
|
1089
|
+
{
|
|
1090
|
+
method: "POST",
|
|
1091
|
+
headers: { "content-type": "application/json" },
|
|
1092
|
+
body: JSON.stringify(body),
|
|
1093
|
+
timeoutMs: imagenTimeoutMs(),
|
|
1094
|
+
},
|
|
1095
|
+
backendURL,
|
|
1096
|
+
);
|
|
1097
|
+
const data = (await res.json()) ?? {};
|
|
1098
|
+
return { ...data, backendURL };
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Generate ONE regular image over the pane-free brand route
|
|
1102
|
+
// (`slidemind imagen`). Body `{ prompt, size?, quality?, brandStyle? }`;
|
|
1103
|
+
// returns the server's `{ ok, image, styleReferences }` augmented with the
|
|
1104
|
+
// resolved `backendURL`. Fan-out and degradation mirror
|
|
1105
|
+
// generateConceptImagesForBrand: throws a typed `imagen_route_unavailable`
|
|
1106
|
+
// when no paired backend serves the route (older add-ins, unknown brand,
|
|
1107
|
+
// missing grant) so callers can print the one actionable fix.
|
|
1108
|
+
export async function generateImageForBrand(
|
|
1109
|
+
brandId,
|
|
1110
|
+
{ prompt, size = null, quality = null, brandStyle = false, backendURL = null } = {},
|
|
1111
|
+
) {
|
|
1112
|
+
if (!brandId) {
|
|
1113
|
+
throw new Error("generateImageForBrand: brandId is required");
|
|
1114
|
+
}
|
|
1115
|
+
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
1116
|
+
throw new Error("generateImageForBrand: prompt is required");
|
|
1117
|
+
}
|
|
1118
|
+
const body = { prompt };
|
|
1119
|
+
if (size) body.size = size;
|
|
1120
|
+
if (quality) body.quality = quality;
|
|
1121
|
+
if (brandStyle) body.brandStyle = true;
|
|
1122
|
+
if (backendURL) {
|
|
1123
|
+
return postGenerateImage(brandId, body, backendURL);
|
|
1124
|
+
}
|
|
1125
|
+
const sessions = sessionsInScope();
|
|
1126
|
+
const failures = [];
|
|
1127
|
+
for (const s of sessions) {
|
|
1128
|
+
try {
|
|
1129
|
+
if (!(await backendSupportsImageGeneration(s.backendURL))) {
|
|
1130
|
+
failures.push({
|
|
1131
|
+
env: deriveEnvLabel(s.backendURL),
|
|
1132
|
+
reason: "no image-generation route (update the add-in)",
|
|
1133
|
+
});
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
return await postGenerateImage(brandId, body, s.backendURL);
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
failures.push({
|
|
1139
|
+
env: deriveEnvLabel(s.backendURL),
|
|
1140
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
|
|
1145
|
+
const err = new Error(
|
|
1146
|
+
`no paired backend generates images for design '${brandId}'` +
|
|
1147
|
+
`${detail ? ` (${detail})` : ""}.`,
|
|
1148
|
+
);
|
|
1149
|
+
err.code = "imagen_route_unavailable";
|
|
1150
|
+
throw err;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
882
1153
|
// List the LIVE engine catalog (NOT the bundle's curated set) for the paired
|
|
883
1154
|
// backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
|
|
884
1155
|
// when several environments are paired this fans out and tags each engine with
|
|
@@ -1004,6 +1275,14 @@ export async function backendSupportsConceptImages(backendURL) {
|
|
|
1004
1275
|
return caps?.conceptImages === true;
|
|
1005
1276
|
}
|
|
1006
1277
|
|
|
1278
|
+
// Whether a backend generates regular images over the pane-free
|
|
1279
|
+
// `/api/cli/brands/:brandId/images` route (see generateImageForBrand).
|
|
1280
|
+
// Advertised by newer add-ins only.
|
|
1281
|
+
export async function backendSupportsImageGeneration(backendURL) {
|
|
1282
|
+
const caps = await backendCapabilities(backendURL);
|
|
1283
|
+
return caps?.imageGeneration === true;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1007
1286
|
// The add-in's hard version floor: /api/cli/capabilities advertises the oldest
|
|
1008
1287
|
// CLI it still speaks (`minCliVersion`). An older CLI must refuse to proceed —
|
|
1009
1288
|
// mid-rollout skew otherwise surfaces as confusing route/contract errors deep
|
|
@@ -1013,13 +1292,7 @@ export async function assertCliVersionSupported(backendURL) {
|
|
|
1013
1292
|
const caps = await backendCapabilities(backendURL);
|
|
1014
1293
|
const min = typeof caps?.minCliVersion === "string" ? caps.minCliVersion : null;
|
|
1015
1294
|
if (!min || compareCliVersions(cliVersion(), min) >= 0) return;
|
|
1016
|
-
|
|
1017
|
-
`slidemind CLI ${cliVersion()} is older than the minimum this add-in ` +
|
|
1018
|
-
`supports (${min}). Run \`slidemind update\` (or ` +
|
|
1019
|
-
"`npm install -g slidemind@latest`), then retry.",
|
|
1020
|
-
);
|
|
1021
|
-
err.code = "cli_update_required";
|
|
1022
|
-
throw err;
|
|
1295
|
+
throw cliUpdateRequiredError(min);
|
|
1023
1296
|
}
|
|
1024
1297
|
|
|
1025
1298
|
// Rebuild a job failure into the exact error shape bridgeFetch throws for the
|
|
@@ -1330,11 +1603,17 @@ async function engineOpWithPaneWait(presId, backendURL, attempt) {
|
|
|
1330
1603
|
// slide results carrying per-slide text + metadata). `subtype`/`intent`/`top`
|
|
1331
1604
|
// are passed through for slide engines (image engines ignore intent); omit them
|
|
1332
1605
|
// and the server uses its defaults (back-compat with older add-ins).
|
|
1606
|
+
//
|
|
1607
|
+
// `engineId` may also be a SCOPE sentinel (`scope:all` / `scope:sharepoint`) —
|
|
1608
|
+
// an unfiltered search over the whole corpus rather than one engine's saved
|
|
1609
|
+
// folder filter. A scope has no subtype to derive its index from, so it needs an
|
|
1610
|
+
// explicit `resultType`; the add-in rejects a scope search without one.
|
|
1333
1611
|
export async function engineSearch({
|
|
1334
1612
|
presId,
|
|
1335
1613
|
engineId,
|
|
1336
1614
|
query,
|
|
1337
1615
|
subtype = null,
|
|
1616
|
+
resultType = null,
|
|
1338
1617
|
intent = null,
|
|
1339
1618
|
top = null,
|
|
1340
1619
|
backendURL = null,
|
|
@@ -1350,6 +1629,8 @@ export async function engineSearch({
|
|
|
1350
1629
|
const be = backendURL || (await resolveBackendForPres(presId));
|
|
1351
1630
|
const body = { engineId, query };
|
|
1352
1631
|
if (subtype) body.subtype = subtype;
|
|
1632
|
+
// Additive: omitted unless set, so the body an older add-in sees is unchanged.
|
|
1633
|
+
if (resultType) body.resultType = resultType;
|
|
1353
1634
|
if (intent) body.intent = intent;
|
|
1354
1635
|
if (typeof top === "number") body.top = top;
|
|
1355
1636
|
const run = async (pid, b) => {
|
|
@@ -1384,6 +1665,7 @@ export async function engineSearchAnyPane({
|
|
|
1384
1665
|
engineId,
|
|
1385
1666
|
query,
|
|
1386
1667
|
subtype = null,
|
|
1668
|
+
resultType = null,
|
|
1387
1669
|
intent = null,
|
|
1388
1670
|
top = null,
|
|
1389
1671
|
}) {
|
|
@@ -1417,6 +1699,7 @@ export async function engineSearchAnyPane({
|
|
|
1417
1699
|
engineId,
|
|
1418
1700
|
query,
|
|
1419
1701
|
subtype,
|
|
1702
|
+
resultType,
|
|
1420
1703
|
intent,
|
|
1421
1704
|
top,
|
|
1422
1705
|
backendURL: cand.backendURL ?? primaryBackend,
|
|
@@ -1483,6 +1766,130 @@ export async function engineDownload({
|
|
|
1483
1766
|
});
|
|
1484
1767
|
}
|
|
1485
1768
|
|
|
1769
|
+
// Stream a chosen engine result's bytes to `destPath`. The size-unbounded
|
|
1770
|
+
// download: the pane's Socket.IO ack carries only a signed source reference, and
|
|
1771
|
+
// the add-in server fetches it and pipes the body here — so nothing buffers the
|
|
1772
|
+
// whole image and there is no base64 4/3 inflation. `engineDownload` above sends
|
|
1773
|
+
// the bytes through the pane ack instead and is therefore capped by the relay's
|
|
1774
|
+
// 10 MB frame limit; prefer this for anything that might be large.
|
|
1775
|
+
//
|
|
1776
|
+
// An SVG result comes back as JSON (`{ ok, mimeType, svgMarkup }`) rather than a
|
|
1777
|
+
// byte stream: it is kilobytes and its recolour happens pane-side. Callers get
|
|
1778
|
+
// `{ svgMarkup }` in that case and `{ bytesWritten, mimeType }` otherwise, so they
|
|
1779
|
+
// can persist whichever arrived.
|
|
1780
|
+
export async function engineDownloadToFile({
|
|
1781
|
+
presId,
|
|
1782
|
+
searchId,
|
|
1783
|
+
selection,
|
|
1784
|
+
destPath,
|
|
1785
|
+
iconColor = null,
|
|
1786
|
+
backendURL = null,
|
|
1787
|
+
onProgress = null,
|
|
1788
|
+
}) {
|
|
1789
|
+
if (!presId) throw new Error("engineDownloadToFile: presId is required");
|
|
1790
|
+
if (typeof searchId !== "number") {
|
|
1791
|
+
throw new Error("engineDownloadToFile: searchId (number) is required");
|
|
1792
|
+
}
|
|
1793
|
+
if (typeof selection !== "number") {
|
|
1794
|
+
throw new Error("engineDownloadToFile: selection (number) is required");
|
|
1795
|
+
}
|
|
1796
|
+
if (!destPath) throw new Error("engineDownloadToFile: destPath is required");
|
|
1797
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
1798
|
+
const body = { searchId, selection };
|
|
1799
|
+
if (iconColor) body.iconColor = iconColor;
|
|
1800
|
+
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
1801
|
+
const res = await bridgeStreamToFile(
|
|
1802
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/download-stream`,
|
|
1803
|
+
{
|
|
1804
|
+
method: "POST",
|
|
1805
|
+
headers: { "content-type": "application/json" },
|
|
1806
|
+
body: JSON.stringify(body),
|
|
1807
|
+
// A large image legitimately takes longer than an engine op: the server
|
|
1808
|
+
// is fetching from SharePoint/blob AND piping. Separate budget so raising
|
|
1809
|
+
// it doesn't loosen every other relay call.
|
|
1810
|
+
timeoutMs:
|
|
1811
|
+
parseInt(
|
|
1812
|
+
process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000",
|
|
1813
|
+
10,
|
|
1814
|
+
) || 600000,
|
|
1815
|
+
onProgress,
|
|
1816
|
+
},
|
|
1817
|
+
destPath,
|
|
1818
|
+
b,
|
|
1819
|
+
);
|
|
1820
|
+
if (!res.streamed) {
|
|
1821
|
+
// The inline-SVG answer (kilobytes) arrives as JSON, not as a byte stream.
|
|
1822
|
+
const parsed = res.body ? JSON.parse(res.body) : {};
|
|
1823
|
+
return { ok: true, streamed: false, ...parsed };
|
|
1824
|
+
}
|
|
1825
|
+
const warningsHeader = res.headers?.["x-slidemind-warnings"];
|
|
1826
|
+
let warnings;
|
|
1827
|
+
if (warningsHeader) {
|
|
1828
|
+
try {
|
|
1829
|
+
warnings = JSON.parse(warningsHeader);
|
|
1830
|
+
} catch {
|
|
1831
|
+
// A malformed header must not fail a download that otherwise succeeded.
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
return {
|
|
1835
|
+
ok: true,
|
|
1836
|
+
streamed: true,
|
|
1837
|
+
mimeType: String(res.headers?.["content-type"] ?? "") || null,
|
|
1838
|
+
bytesWritten: res.bytesWritten,
|
|
1839
|
+
...(warnings ? { warnings } : {}),
|
|
1840
|
+
};
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// Stream a chosen slide-search result to `destPath` as a native one-slide .pptx.
|
|
1845
|
+
// The size-unbounded sibling of `extractSlide`: the pane ack carries only the
|
|
1846
|
+
// request, and the add-in server performs it and pipes the (base64-decoded) bytes
|
|
1847
|
+
// here. `extractSlide` puts the whole slide through the pane's Socket.IO ack as
|
|
1848
|
+
// base64, so it is capped at ~7 MB of real bytes — which real reused slides reach.
|
|
1849
|
+
export async function extractSlideToFile({
|
|
1850
|
+
presId,
|
|
1851
|
+
searchId,
|
|
1852
|
+
selection,
|
|
1853
|
+
destPath,
|
|
1854
|
+
backendURL = null,
|
|
1855
|
+
onProgress = null,
|
|
1856
|
+
}) {
|
|
1857
|
+
if (!presId) throw new Error("extractSlideToFile: presId is required");
|
|
1858
|
+
if (typeof searchId !== "number") {
|
|
1859
|
+
throw new Error("extractSlideToFile: searchId (number) is required");
|
|
1860
|
+
}
|
|
1861
|
+
if (typeof selection !== "number") {
|
|
1862
|
+
throw new Error("extractSlideToFile: selection (number) is required");
|
|
1863
|
+
}
|
|
1864
|
+
if (!destPath) throw new Error("extractSlideToFile: destPath is required");
|
|
1865
|
+
const be = backendURL || (await resolveBackendForPres(presId));
|
|
1866
|
+
return engineOpWithPaneWait(presId, be, async (pid, b) => {
|
|
1867
|
+
const res = await bridgeStreamToFile(
|
|
1868
|
+
`/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-extract-stream`,
|
|
1869
|
+
{
|
|
1870
|
+
method: "POST",
|
|
1871
|
+
headers: { "content-type": "application/json" },
|
|
1872
|
+
body: JSON.stringify({ searchId, selection }),
|
|
1873
|
+
timeoutMs:
|
|
1874
|
+
parseInt(
|
|
1875
|
+
process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000",
|
|
1876
|
+
10,
|
|
1877
|
+
) || 600000,
|
|
1878
|
+
onProgress,
|
|
1879
|
+
},
|
|
1880
|
+
destPath,
|
|
1881
|
+
b,
|
|
1882
|
+
);
|
|
1883
|
+
const title = res.headers?.["x-slidemind-presentation-title"];
|
|
1884
|
+
return {
|
|
1885
|
+
ok: true,
|
|
1886
|
+
bytesWritten: res.bytesWritten,
|
|
1887
|
+
slideId: res.headers?.["x-slidemind-slide-id"] ?? undefined,
|
|
1888
|
+
presentationTitle: title ? decodeURIComponent(String(title)) : undefined,
|
|
1889
|
+
};
|
|
1890
|
+
});
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1486
1893
|
// Extract a chosen slide-search result as a standalone one-slide .pptx (base64),
|
|
1487
1894
|
// WITHOUT inserting it anywhere — the "fold into a deck" path. Relayed through
|
|
1488
1895
|
// the pane like search/download.
|