slidemind 0.3.5 → 0.3.12
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 +5 -5
- package/bin/slidemind.mjs +520 -182
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +263 -104
- package/skills/search/SKILL.md +9 -5
- package/src/bridge/build-and-push.mjs +3 -3
- package/src/bridge/bundle.mjs +91 -36
- package/src/bridge/capture.mjs +34 -0
- package/src/bridge/client.mjs +59 -23
- package/src/bridge/config.mjs +21 -2
- package/src/bridge/engine-download.mjs +26 -7
- package/src/bridge/recolor.mjs +46 -0
- package/src/bridge/setup-claim.mjs +7 -1
- package/src/builders/build.mjs +179 -5
- package/src/builders/gate-server.mjs +25 -36
- package/src/builders/gate-session.js +28 -48
- package/src/builders/lint-fix.mjs +88 -0
- package/src/builders/plan-overlay.js +507 -136
- package/src/builders/plan.mjs +337 -60
- package/src/builders/review-overlay.js +126 -109
- package/src/builders/review.mjs +118 -64
- package/src/builders/storyboard-markdown.mjs +15 -0
- package/src/builders/storyboard-schema.mjs +23 -6
- package/src/builders/visual-families.mjs +20 -4
- package/src/lib/adopt.mjs +10 -6
- package/src/lib/conversions.mjs +26 -6
- package/src/lib/overwrite-guard.mjs +14 -1
- package/src/lib/reconcile.mjs +15 -4
- package/src/pptx/tags.mjs +49 -0
package/skills/search/SKILL.md
CHANGED
|
@@ -142,10 +142,14 @@ Follow `workflow/search.md` for the discipline; this is the CLI shape of its loo
|
|
|
142
142
|
4. **Read / view, then pick.** For **content** results, read the per-slide **text + score** the
|
|
143
143
|
search prints — that is the on-the-wire signal; you usually don't need the picture. For
|
|
144
144
|
**visual** results, scan the numbered thumbnail list; when the search prints a **contact sheet**
|
|
145
|
-
path, Read that ONE file — every candidate is on it with its selection number burned in
|
|
146
|
-
|
|
147
|
-
--search <searchId> --pick <n>` (
|
|
148
|
-
|
|
145
|
+
path, Read that ONE file — every candidate is on it with its selection number burned in. With no
|
|
146
|
+
contact sheet, previews depend on the result type: **slide-library results** support `slidemind
|
|
147
|
+
view-slide --search <searchId> --pick <n>` (writes one result's preview to a file you can Read);
|
|
148
|
+
**image/icon results have NO preview on this path** — `view-slide` answers "no image returned"
|
|
149
|
+
for them, so never call it on an image search, and never retry it hoping for a different answer.
|
|
150
|
+
For icons, pick by title + score, `download-result` the pick (downloads are cheap), Read the
|
|
151
|
+
saved file, and re-search once if it was wrong — two failed downloads for the same need means
|
|
152
|
+
mark it unresolved and move on. Pick by **selection number(s)**.
|
|
149
153
|
5. **Read the source deck when the story matters.** A found slide came out of a real presentation,
|
|
150
154
|
and often the answer the user needs is in the *rest* of it — how the slide was framed, what came
|
|
151
155
|
before and after, whether that deck already covers more of the ask. `slidemind read-source
|
|
@@ -237,7 +241,7 @@ insert live, fold into a deck, engine fetch). You are the local harness — this
|
|
|
237
241
|
| **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 never needed. No window needed where the add-in serves the pane-free routes. |
|
|
238
242
|
| **engine search** (with `intent`) | `slidemind search "<query>" --engine <id> --intent <auto\|content\|visual> [--top N] [--deck <deck>]` — leave `--to` off → a `searchId` + numbered results, plus the exact follow-up commands for the path it took (`--deck` routes by that deck's design when several environments are connected) |
|
|
239
243
|
| **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. |
|
|
240
|
-
| **pull an on-demand thumbnail** for a stored result | `slidemind view-slide --search <searchId> --pick <n>` —
|
|
244
|
+
| **pull an on-demand thumbnail** for a stored result | `slidemind view-slide --search <searchId> --pick <n>` — SLIDE-library results only; image/icon results have no preview ("no image returned") — download and Read the file instead (add `--to <searchWindow>` only on the relayed path) · or `slidemind search … --json` for the structured payload. |
|
|
241
245
|
| **read a result's SOURCE deck** (the presentation it came from) | `slidemind read-source --search <searchId> --pick <n> [--from-slide N] [--to-slide N] [--out <file.md>]` — Markdown of the whole source presentation, one section per slide (add `--to <searchWindow>` only on the relayed path). Indexed text, not the live file. Slide results only. |
|
|
242
246
|
| **insert live** (reuse a slide → the open window) | `slidemind insert-slide --search <searchId> --pick <n[,n,…]> --to <presId> [--at N]` — **host-required**: the slide lands in that open window |
|
|
243
247
|
| **fold into a deck** (reuse a slide → deck source) | `slidemind add-slide --search <searchId> --pick <n[,n,…]> --deck <deck> [--at N] [--json]` → writes `decks/<slug>/slides/NN-reused-*.pptx` (add `--to <searchWindow>` only on the relayed path, where extraction runs on the search's window) |
|
|
@@ -273,10 +273,10 @@ export async function renderDeckToBase64(
|
|
|
273
273
|
// server splices them back from. Keyed by slide file name, like `slides`.
|
|
274
274
|
const conversions = loadConversions(deckDir);
|
|
275
275
|
const hasConversions = Object.keys(conversions).length > 0;
|
|
276
|
-
for (const file of conversionsMissingPreserveSource(conversions)) {
|
|
276
|
+
for (const file of conversionsMissingPreserveSource(conversions, deckDir)) {
|
|
277
277
|
process.stderr.write(
|
|
278
|
-
`note: ${file} has preserved native objects but no
|
|
279
|
-
`to
|
|
278
|
+
`note: ${file} has preserved native objects or a sourced chart but no ` +
|
|
279
|
+
`conversions/*.slide.pptx to read them from — re-run 'slidemind adopt' for that deck\n`,
|
|
280
280
|
);
|
|
281
281
|
}
|
|
282
282
|
|
package/src/bridge/bundle.mjs
CHANGED
|
@@ -30,7 +30,6 @@
|
|
|
30
30
|
// ├── assets/brand-images.md (on-demand manifest of the reusable master images,
|
|
31
31
|
// │ built from wire.extractedImages — design.md only
|
|
32
32
|
// │ carries a sparse pointer + per-kind counts)
|
|
33
|
-
// ├── samples/* (downloaded from wire.samples[].url — brand reference slides)
|
|
34
33
|
// ├── fonts/* (downloaded from wire.fonts[].url, optional)
|
|
35
34
|
// ├── master/empty-layout.pptx (downloaded from wire.master.url, optional)
|
|
36
35
|
// ├── engines.json (wire.engines[] — legacy: new backends dropped the
|
|
@@ -87,6 +86,33 @@ export function readMaterializedBundleMeta(presFP) {
|
|
|
87
86
|
}
|
|
88
87
|
}
|
|
89
88
|
|
|
89
|
+
// Every design materialized on this machine: one entry per cache dir that
|
|
90
|
+
// carries a readable bundle.json. `ready` reports these so the agent can ask
|
|
91
|
+
// the user WHICH design to build against when more than one is at hand —
|
|
92
|
+
// no network, never throws.
|
|
93
|
+
export function listCachedBundles() {
|
|
94
|
+
const root = bundleCacheRoot();
|
|
95
|
+
let entries = [];
|
|
96
|
+
try {
|
|
97
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
98
|
+
} catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
if (!entry.isDirectory()) continue;
|
|
104
|
+
const dir = path.join(root, entry.name);
|
|
105
|
+
try {
|
|
106
|
+
const meta = JSON.parse(fs.readFileSync(path.join(dir, "bundle.json"), "utf8"));
|
|
107
|
+
const presFP = meta?.presFP ?? entry.name;
|
|
108
|
+
out.push({ presFP, dir, meta });
|
|
109
|
+
} catch {
|
|
110
|
+
/* half-written or foreign dir — not a design */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
90
116
|
// Wipe the whole materialized bundle cache so the next build/push/download
|
|
91
117
|
// re-fetches every design bundle fresh from the add-in. `slidemind init` calls
|
|
92
118
|
// this: init is the deliberate (re)setup point — and `slidemind update`
|
|
@@ -164,22 +190,6 @@ async function downloadInto(targetPath, url) {
|
|
|
164
190
|
fs.writeFileSync(targetPath, buf);
|
|
165
191
|
}
|
|
166
192
|
|
|
167
|
-
// File extension for a sample image, derived from its wire `contentType`. The
|
|
168
|
-
// sample's stable `id` is the basename; samples carry no `filename` on the wire.
|
|
169
|
-
function sampleExt(contentType) {
|
|
170
|
-
switch (contentType) {
|
|
171
|
-
case "image/jpeg":
|
|
172
|
-
return ".jpg";
|
|
173
|
-
case "image/webp":
|
|
174
|
-
return ".webp";
|
|
175
|
-
case "image/svg+xml":
|
|
176
|
-
return ".svg";
|
|
177
|
-
case "image/png":
|
|
178
|
-
default:
|
|
179
|
-
return ".png";
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
193
|
const REUSABLE_KIND_ORDER = [
|
|
184
194
|
"logo",
|
|
185
195
|
"icon",
|
|
@@ -235,11 +245,11 @@ function buildReusableImagesManifest(extractedImages) {
|
|
|
235
245
|
|
|
236
246
|
// Sync the bundle for `presId`. Idempotent: every call rewrites all inline
|
|
237
247
|
// text from the freshly fetched wire (the wire is fetched on every call
|
|
238
|
-
// anyway), downloads only binaries whose per-item blob etag changed
|
|
239
|
-
//
|
|
248
|
+
// anyway), downloads only binaries whose per-item blob etag changed, and
|
|
249
|
+
// prunes files the wire no longer serves. There is
|
|
240
250
|
// deliberately NO staleness token to compare — the materialized dir is always
|
|
241
251
|
// a per-item projection of the last wire, so authored-layer edits that bypass
|
|
242
|
-
// a bundle re-produce (engines, image-source guidelines
|
|
252
|
+
// a bundle re-produce (engines, image-source guidelines) and
|
|
243
253
|
// deployment-owned content (workflow refs, the lint engine) can never go
|
|
244
254
|
// stale here. `force` re-downloads binaries even when their etags match.
|
|
245
255
|
// Target: `presId` (pane registry), or `brandId` in options for the pane-free
|
|
@@ -376,7 +386,7 @@ export async function materializeBundle(
|
|
|
376
386
|
return;
|
|
377
387
|
}
|
|
378
388
|
if (!force && exists && !etag) {
|
|
379
|
-
return; // write-once binary
|
|
389
|
+
return; // write-once binary without an etag — presence is currency
|
|
380
390
|
}
|
|
381
391
|
downloads.push({ rel, target, url, etag: etag ?? null, label });
|
|
382
392
|
};
|
|
@@ -408,21 +418,6 @@ export async function materializeBundle(
|
|
|
408
418
|
"master empty-layout.pptx",
|
|
409
419
|
);
|
|
410
420
|
}
|
|
411
|
-
// Per-CD sample slides (the brand's own reference slides) — write-once
|
|
412
|
-
// captures keyed by the sample's stable `id`; extension derived from
|
|
413
|
-
// `contentType`.
|
|
414
|
-
for (const sample of wire.samples ?? []) {
|
|
415
|
-
if (!sample?.url || !sample?.id) continue;
|
|
416
|
-
queueBinary(
|
|
417
|
-
path.join(
|
|
418
|
-
"samples",
|
|
419
|
-
`${path.basename(sample.id)}${sampleExt(sample.contentType)}`,
|
|
420
|
-
),
|
|
421
|
-
sample.url,
|
|
422
|
-
null,
|
|
423
|
-
`sample ${sample.id}`,
|
|
424
|
-
);
|
|
425
|
-
}
|
|
426
421
|
// Reusable master images (logos, decorations, illustrations the add-in's
|
|
427
422
|
// vision pass lifted from the brand's own master and flagged `reusable`).
|
|
428
423
|
// Each carries a `workspacePath` (e.g. `assets/master-<sha8>.<ext>`) that the
|
|
@@ -522,6 +517,21 @@ export async function materializeBundle(
|
|
|
522
517
|
role: f.role,
|
|
523
518
|
family: f.family,
|
|
524
519
|
filename: path.basename(f.filename),
|
|
520
|
+
// Face descriptors (multi-face families: Regular/Bold/Italic/…).
|
|
521
|
+
// Without them every face of a family registers descriptor-less and
|
|
522
|
+
// the last one wins for ALL weights — bold measured with the regular
|
|
523
|
+
// face. Older backends omit them; default to a plain 400/normal face.
|
|
524
|
+
weight:
|
|
525
|
+
typeof f.weight === "number" && f.weight >= 100 && f.weight <= 900
|
|
526
|
+
? f.weight
|
|
527
|
+
: 400,
|
|
528
|
+
style: f.style === "italic" ? "italic" : "normal",
|
|
529
|
+
variable:
|
|
530
|
+
f.variable &&
|
|
531
|
+
typeof f.variable.minWeight === "number" &&
|
|
532
|
+
typeof f.variable.maxWeight === "number"
|
|
533
|
+
? { minWeight: f.variable.minWeight, maxWeight: f.variable.maxWeight }
|
|
534
|
+
: null,
|
|
525
535
|
})),
|
|
526
536
|
binaries,
|
|
527
537
|
fetchedAt: new Date().toISOString(),
|
|
@@ -529,9 +539,13 @@ export async function materializeBundle(
|
|
|
529
539
|
};
|
|
530
540
|
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf8");
|
|
531
541
|
|
|
542
|
+
// Last, after every write AND the prune — the overlay must win over the wire.
|
|
543
|
+
const overlaid = applyBundleOverlay(dir);
|
|
544
|
+
|
|
532
545
|
return {
|
|
533
546
|
dir,
|
|
534
547
|
presFP,
|
|
548
|
+
overlaid,
|
|
535
549
|
etag: wire.etag ?? null,
|
|
536
550
|
version: wire.version ?? null,
|
|
537
551
|
// Which backend actually served the wire — meaningful on the brandId
|
|
@@ -545,6 +559,47 @@ export async function materializeBundle(
|
|
|
545
559
|
};
|
|
546
560
|
}
|
|
547
561
|
|
|
562
|
+
// Copy `$SLIDEMIND_BUNDLE_OVERLAY/**` over a materialized bundle. Experiment-only
|
|
563
|
+
// hook for the skill-optimization loop (LOOP_PLAN.md): it lets a run vary the
|
|
564
|
+
// add-in-owned workflow/design docs without a deploy. Deliberately undocumented
|
|
565
|
+
// in user-facing help. The wire re-syncs every text file on EVERY materialize
|
|
566
|
+
// (and the prune deletes anything it doesn't own), so this re-applies each time.
|
|
567
|
+
export function applyBundleOverlay(dir) {
|
|
568
|
+
const overlay = process.env.SLIDEMIND_BUNDLE_OVERLAY;
|
|
569
|
+
if (!overlay) return 0;
|
|
570
|
+
let copied = 0;
|
|
571
|
+
try {
|
|
572
|
+
if (!fs.statSync(overlay).isDirectory()) return 0;
|
|
573
|
+
fs.cpSync(overlay, dir, {
|
|
574
|
+
recursive: true,
|
|
575
|
+
force: true,
|
|
576
|
+
// Docs only (.md/.css): the bundle dir also holds lint-engine.mjs and
|
|
577
|
+
// capture-engine.mjs, which the CLI dynamic-imports — an overlay must
|
|
578
|
+
// never be able to swap executable code, and text files re-sync from the
|
|
579
|
+
// wire on every materialize so a doc overlay can't persist past the
|
|
580
|
+
// experiment (binaries would).
|
|
581
|
+
filter: (src) => {
|
|
582
|
+
let st;
|
|
583
|
+
try {
|
|
584
|
+
st = fs.lstatSync(src);
|
|
585
|
+
} catch {
|
|
586
|
+
return false; // dangling symlink — skip, never fail the materialize
|
|
587
|
+
}
|
|
588
|
+
if (st.isDirectory()) return true;
|
|
589
|
+
if (!/\.(md|css)$/i.test(src)) return false;
|
|
590
|
+
copied += 1;
|
|
591
|
+
return true;
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
} catch (err) {
|
|
595
|
+
// The overlay is an experiment convenience — a bad overlay must not break
|
|
596
|
+
// ready/build/push after an otherwise successful sync.
|
|
597
|
+
process.stderr.write(`bundle overlay skipped: ${err.message}\n`);
|
|
598
|
+
return 0;
|
|
599
|
+
}
|
|
600
|
+
return copied;
|
|
601
|
+
}
|
|
602
|
+
|
|
548
603
|
// Delete files the current sync doesn't own and collapse empty directories.
|
|
549
604
|
// `expected` holds dir-relative paths exactly as `path.join` built them, so
|
|
550
605
|
// the comparison is platform-consistent.
|
package/src/bridge/capture.mjs
CHANGED
|
@@ -114,6 +114,12 @@ export function buildCaptureInput(deckDir, bundleDir) {
|
|
|
114
114
|
family: f.family,
|
|
115
115
|
filename: path.basename(f.filename),
|
|
116
116
|
bytesB64: fs.readFileSync(fontPath).toString("base64"),
|
|
117
|
+
// Face descriptors — the capture engine emits per-face @font-face
|
|
118
|
+
// weight/style rules from these (engine defaults 400/normal when a
|
|
119
|
+
// pre-descriptor meta.json omits them).
|
|
120
|
+
weight: f.weight,
|
|
121
|
+
style: f.style,
|
|
122
|
+
variable: f.variable ?? null,
|
|
117
123
|
});
|
|
118
124
|
}
|
|
119
125
|
|
|
@@ -214,3 +220,31 @@ export async function captureDeckLocally({ deckDir, bundleDir, onProgress }) {
|
|
|
214
220
|
const input = buildCaptureInput(deckDir, bundleDir);
|
|
215
221
|
return withBrowser((browser) => engine.captureDeck(browser, input, { onProgress }));
|
|
216
222
|
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The `<Chart>` stand-in runtime the add-in exports from the SAME artifact
|
|
226
|
+
* (`chartPreview: { scripts: [{ filename, source }], tokensCss }`), for the
|
|
227
|
+
* host-free HTML preview (`slidemind build`, and the review gate that serves
|
|
228
|
+
* it). The CLI ships no chart source: the page inlines these before any slide
|
|
229
|
+
* is evaluated, exactly where the add-in's own page loads them.
|
|
230
|
+
*
|
|
231
|
+
* null when the bundle has no engine or the engine predates charts. That is
|
|
232
|
+
* never a silent degradation: such a bundle's lint engine (same deployment)
|
|
233
|
+
* rejects `<Chart>` as an unknown component before a page is ever built.
|
|
234
|
+
*/
|
|
235
|
+
export async function loadChartPreview(bundleDir) {
|
|
236
|
+
if (!bundleDir || !bundleHasCaptureEngine(bundleDir)) return null;
|
|
237
|
+
const enginePath = path.join(bundleDir, "capture-engine.mjs");
|
|
238
|
+
const version = readBundleMeta(bundleDir).captureEngineVersion ?? "0";
|
|
239
|
+
const engine = await import(
|
|
240
|
+
`${pathToFileURL(enginePath).href}?v=${encodeURIComponent(version)}`
|
|
241
|
+
);
|
|
242
|
+
const preview = engine.chartPreview;
|
|
243
|
+
if (!preview || !Array.isArray(preview.scripts)) return null;
|
|
244
|
+
return {
|
|
245
|
+
scripts: preview.scripts.filter(
|
|
246
|
+
(s) => s && typeof s.filename === "string" && typeof s.source === "string",
|
|
247
|
+
),
|
|
248
|
+
tokensCss: typeof preview.tokensCss === "string" ? preview.tokensCss : "",
|
|
249
|
+
};
|
|
250
|
+
}
|
package/src/bridge/client.mjs
CHANGED
|
@@ -164,9 +164,15 @@ function transientRetryDelays() {
|
|
|
164
164
|
// information) from "this session is dead" (the user's problem) — see
|
|
165
165
|
// resolveSearchBackend.
|
|
166
166
|
function sessionInvalidError() {
|
|
167
|
+
// Deliberately does NOT say `logout --all`: a 401 means the add-in answered
|
|
168
|
+
// "no such session" (revoked in Settings → Connect CLI, or expired). Running
|
|
169
|
+
// `setup` with a fresh command replaces this backend's record in place, so
|
|
170
|
+
// nothing has to be wiped first — and a user with two environments who wipes
|
|
171
|
+
// everything on every 401 is how the 2026-08-27 reconnect loop happened.
|
|
167
172
|
const err = new Error(
|
|
168
|
-
"slidemind session is invalid or expired
|
|
169
|
-
"
|
|
173
|
+
"slidemind session is invalid or expired: the add-in no longer knows this session " +
|
|
174
|
+
"(revoked under Settings → General → Connect CLI, or expired). Reconnect with a fresh " +
|
|
175
|
+
"command from that panel; other environments and your Microsoft 365 sign-in stay as they are.",
|
|
170
176
|
);
|
|
171
177
|
err.status = 401;
|
|
172
178
|
err.code = "session_invalid";
|
|
@@ -191,13 +197,13 @@ function isTransientBridgeError(err) {
|
|
|
191
197
|
return /relay unreachable/i.test(String(err.message || ""));
|
|
192
198
|
}
|
|
193
199
|
|
|
194
|
-
async function withTransientRetry(fn) {
|
|
200
|
+
async function withTransientRetry(fn, isRetryable = isTransientBridgeError) {
|
|
195
201
|
const delays = transientRetryDelays();
|
|
196
202
|
for (let attempt = 0; ; attempt += 1) {
|
|
197
203
|
try {
|
|
198
204
|
return await fn();
|
|
199
205
|
} catch (err) {
|
|
200
|
-
if (attempt >= delays.length || !
|
|
206
|
+
if (attempt >= delays.length || !isRetryable(err)) throw err;
|
|
201
207
|
await new Promise((resolve) => setTimeout(resolve, delays[attempt]));
|
|
202
208
|
}
|
|
203
209
|
}
|
|
@@ -531,10 +537,18 @@ async function listPresentationsForBackend(backendURL) {
|
|
|
531
537
|
async function readinessForBackend(backendURL) {
|
|
532
538
|
const env = deriveEnvLabel(backendURL);
|
|
533
539
|
try {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
540
|
+
// Retry only a 503: the add-in answers that (code `backend_unavailable`)
|
|
541
|
+
// while its session store cannot respond, and a 404 here means an older
|
|
542
|
+
// add-in without the readiness route, which the fallback below handles
|
|
543
|
+
// without waiting out the retry delays.
|
|
544
|
+
const res = await withTransientRetry(
|
|
545
|
+
() =>
|
|
546
|
+
bridgeFetch(
|
|
547
|
+
"/api/cli/readiness",
|
|
548
|
+
{ timeoutMs: listTimeoutMs() },
|
|
549
|
+
backendURL,
|
|
550
|
+
),
|
|
551
|
+
(err) => err?.status === 503 && err?.code !== "pane_suspended",
|
|
538
552
|
);
|
|
539
553
|
const data = (await res.json()) ?? {};
|
|
540
554
|
const presentations = Array.isArray(data.presentations)
|
|
@@ -591,6 +605,12 @@ export async function connectionStatus() {
|
|
|
591
605
|
backendURL,
|
|
592
606
|
ok: false,
|
|
593
607
|
error: r.reason?.message ? r.reason.message : String(r.reason),
|
|
608
|
+
// Machine code of the failure (`backend_unavailable`, `session_invalid`,
|
|
609
|
+
// …) so the readiness ladder can tell "retry shortly" from "re-pair".
|
|
610
|
+
code: typeof r.reason?.code === "string" ? r.reason.code : null,
|
|
611
|
+
// The HTTP status too: a proxy's 503 carries no machine code (or only a
|
|
612
|
+
// prose `error`), and the ladder must still read it as "wait".
|
|
613
|
+
status: typeof r.reason?.status === "number" ? r.reason.status : null,
|
|
594
614
|
presentations: [],
|
|
595
615
|
};
|
|
596
616
|
});
|
|
@@ -1112,13 +1132,19 @@ function conceptTimeoutMs() {
|
|
|
1112
1132
|
|
|
1113
1133
|
async function postConceptImages(
|
|
1114
1134
|
brandId,
|
|
1115
|
-
{ planMarkdown, slideNumbers },
|
|
1135
|
+
{ planMarkdown, slideNumbers, references },
|
|
1116
1136
|
backendURL,
|
|
1117
1137
|
) {
|
|
1118
1138
|
const body = { planMarkdown };
|
|
1119
1139
|
if (Array.isArray(slideNumbers) && slideNumbers.length > 0) {
|
|
1120
1140
|
body.slideNumbers = slideNumbers;
|
|
1121
1141
|
}
|
|
1142
|
+
// The user's own images, inline: `{ role: "concept" | "style", image:
|
|
1143
|
+
// <data URI>, slides?: number[], name? }`. A concept replaces the generated
|
|
1144
|
+
// mockup for its slides; a style reference leads the generation.
|
|
1145
|
+
if (Array.isArray(references) && references.length > 0) {
|
|
1146
|
+
body.references = references;
|
|
1147
|
+
}
|
|
1122
1148
|
const res = await bridgeFetch(
|
|
1123
1149
|
`/api/cli/brands/${encodeURIComponent(brandId)}/concept-images`,
|
|
1124
1150
|
{
|
|
@@ -1136,8 +1162,9 @@ async function postConceptImages(
|
|
|
1136
1162
|
}
|
|
1137
1163
|
|
|
1138
1164
|
// Generate concept mockup images for a brand over the pane-free brand route.
|
|
1139
|
-
// Body `{ planMarkdown, slideNumbers? }`; the optional `slideNumbers`
|
|
1140
|
-
// which storyboard beats are rendered (the CLI batches per-slide)
|
|
1165
|
+
// Body `{ planMarkdown, slideNumbers?, references? }`; the optional `slideNumbers`
|
|
1166
|
+
// filters which storyboard beats are rendered (the CLI batches per-slide);
|
|
1167
|
+
// `references` carries the user's own concept/style images inline. Returns the
|
|
1141
1168
|
// server's `{ ok, images, slideCount }` augmented with the resolved
|
|
1142
1169
|
// `backendURL`. When `backendURL` is given it posts directly; otherwise it
|
|
1143
1170
|
// fans out — like getDesignBundleForBrandAnyBackend, presFP/brandId is globally
|
|
@@ -1146,7 +1173,7 @@ async function postConceptImages(
|
|
|
1146
1173
|
// unknown brand, missing grant) so callers can degrade to "continue without".
|
|
1147
1174
|
export async function generateConceptImagesForBrand(
|
|
1148
1175
|
brandId,
|
|
1149
|
-
{ planMarkdown, slideNumbers = null, backendURL = null } = {},
|
|
1176
|
+
{ planMarkdown, slideNumbers = null, references = null, backendURL = null } = {},
|
|
1150
1177
|
) {
|
|
1151
1178
|
if (!brandId) {
|
|
1152
1179
|
throw new Error("generateConceptImagesForBrand: brandId is required");
|
|
@@ -1155,7 +1182,11 @@ export async function generateConceptImagesForBrand(
|
|
|
1155
1182
|
throw new Error("generateConceptImagesForBrand: planMarkdown is required");
|
|
1156
1183
|
}
|
|
1157
1184
|
if (backendURL) {
|
|
1158
|
-
return postConceptImages(
|
|
1185
|
+
return postConceptImages(
|
|
1186
|
+
brandId,
|
|
1187
|
+
{ planMarkdown, slideNumbers, references },
|
|
1188
|
+
backendURL,
|
|
1189
|
+
);
|
|
1159
1190
|
}
|
|
1160
1191
|
const sessions = sessionsInScope();
|
|
1161
1192
|
const failures = [];
|
|
@@ -1170,7 +1201,7 @@ export async function generateConceptImagesForBrand(
|
|
|
1170
1201
|
}
|
|
1171
1202
|
return await postConceptImages(
|
|
1172
1203
|
brandId,
|
|
1173
|
-
{ planMarkdown, slideNumbers },
|
|
1204
|
+
{ planMarkdown, slideNumbers, references },
|
|
1174
1205
|
s.backendURL,
|
|
1175
1206
|
);
|
|
1176
1207
|
} catch (err) {
|
|
@@ -1214,15 +1245,15 @@ async function postGenerateImage(brandId, body, backendURL) {
|
|
|
1214
1245
|
}
|
|
1215
1246
|
|
|
1216
1247
|
// Generate ONE regular image over the pane-free brand route
|
|
1217
|
-
// (`slidemind imagen`). Body `{ prompt, size?, quality
|
|
1218
|
-
// returns the server's `{ ok, image
|
|
1248
|
+
// (`slidemind imagen`). Body `{ prompt, size?, quality? }`;
|
|
1249
|
+
// returns the server's `{ ok, image }` augmented with the
|
|
1219
1250
|
// resolved `backendURL`. Fan-out and degradation mirror
|
|
1220
1251
|
// generateConceptImagesForBrand: throws a typed `imagen_route_unavailable`
|
|
1221
1252
|
// when no paired backend serves the route (older add-ins, unknown brand,
|
|
1222
1253
|
// missing grant) so callers can print the one actionable fix.
|
|
1223
1254
|
export async function generateImageForBrand(
|
|
1224
1255
|
brandId,
|
|
1225
|
-
{ prompt, size = null, quality = null,
|
|
1256
|
+
{ prompt, size = null, quality = null, backendURL = null } = {},
|
|
1226
1257
|
) {
|
|
1227
1258
|
if (!brandId) {
|
|
1228
1259
|
throw new Error("generateImageForBrand: brandId is required");
|
|
@@ -1233,7 +1264,6 @@ export async function generateImageForBrand(
|
|
|
1233
1264
|
const body = { prompt };
|
|
1234
1265
|
if (size) body.size = size;
|
|
1235
1266
|
if (quality) body.quality = quality;
|
|
1236
|
-
if (brandStyle) body.brandStyle = true;
|
|
1237
1267
|
if (backendURL) {
|
|
1238
1268
|
return postGenerateImage(brandId, body, backendURL);
|
|
1239
1269
|
}
|
|
@@ -3316,20 +3346,26 @@ export function resolveDesignSource(
|
|
|
3316
3346
|
return { status: "ambiguous", ...dedupeCandidates(matches) };
|
|
3317
3347
|
}
|
|
3318
3348
|
|
|
3319
|
-
|
|
3349
|
+
// A live pane outranks a suspended one for the DESIGN decision: a
|
|
3350
|
+
// backgrounded — or closed and not yet purged — window must not turn the one
|
|
3351
|
+
// design the user actually has in front of them into a question. Only when
|
|
3352
|
+
// nothing is live do the suspended windows decide.
|
|
3353
|
+
const live = all.filter(isPaneReadyPresentation);
|
|
3354
|
+
const pool = live.length > 0 ? live : all;
|
|
3355
|
+
const distinctDesigns = new Set(pool.map(designKey));
|
|
3320
3356
|
if (distinctDesigns.size === 1) {
|
|
3321
3357
|
// One design open (possibly one window mirrored across backends, or several
|
|
3322
3358
|
// windows on the SAME CD). Pick via the mirror tie-break, else a live pane,
|
|
3323
3359
|
// else the first — the design is unambiguous either way.
|
|
3324
3360
|
const entry =
|
|
3325
|
-
tieBreakMatches(
|
|
3326
|
-
|
|
3327
|
-
|
|
3361
|
+
tieBreakMatches(pool, deckPresFP) ??
|
|
3362
|
+
pool.find(isPaneReadyPresentation) ??
|
|
3363
|
+
pool[0];
|
|
3328
3364
|
return { status: "resolved", entry };
|
|
3329
3365
|
}
|
|
3330
3366
|
// ≥2 distinct designs open — the only case that asks the user (the deck ref
|
|
3331
3367
|
// does NOT auto-pick; it only annotates `suggested`).
|
|
3332
|
-
return { status: "ambiguous", ...dedupeCandidates(
|
|
3368
|
+
return { status: "ambiguous", ...dedupeCandidates(pool) };
|
|
3333
3369
|
}
|
|
3334
3370
|
|
|
3335
3371
|
// Pick a target presentation given the (possibly multi-backend) registry and an
|
package/src/bridge/config.mjs
CHANGED
|
@@ -160,6 +160,7 @@ export function readSessions() {
|
|
|
160
160
|
backendURL: s.backendURL,
|
|
161
161
|
sessionToken: s.sessionToken,
|
|
162
162
|
label: typeof s.label === "string" && s.label ? s.label : deriveEnvLabel(s.backendURL),
|
|
163
|
+
...(typeof s.oid === "string" && s.oid ? { oid: s.oid } : {}),
|
|
163
164
|
...(typeof s.msGraphRefreshToken === "string" && s.msGraphRefreshToken
|
|
164
165
|
? { msGraphRefreshToken: s.msGraphRefreshToken }
|
|
165
166
|
: {}),
|
|
@@ -199,6 +200,7 @@ function writeAll(sessions, defaultBackend) {
|
|
|
199
200
|
backendURL: s.backendURL,
|
|
200
201
|
sessionToken: s.sessionToken,
|
|
201
202
|
label: s.label || deriveEnvLabel(s.backendURL),
|
|
203
|
+
...(s.oid ? { oid: s.oid } : {}),
|
|
202
204
|
...(s.msGraphRefreshToken ? { msGraphRefreshToken: s.msGraphRefreshToken } : {}),
|
|
203
205
|
})),
|
|
204
206
|
};
|
|
@@ -220,15 +222,32 @@ function writeAll(sessions, defaultBackend) {
|
|
|
220
222
|
// pairing that session with the previous user's delegated Graph token would
|
|
221
223
|
// fetch one person's content under another's session. `slidemind login` again
|
|
222
224
|
// is one command; a mismatched identity is not recoverable by the user.
|
|
223
|
-
export function upsertSession({ backendURL, sessionToken, label = null }) {
|
|
225
|
+
export function upsertSession({ backendURL, sessionToken, label = null, oid = null }) {
|
|
224
226
|
if (!backendURL || !sessionToken) {
|
|
225
227
|
throw new Error("upsertSession: backendURL and sessionToken are required");
|
|
226
228
|
}
|
|
227
|
-
const
|
|
229
|
+
const all = readSessions();
|
|
230
|
+
const previous = all.find((s) => s.backendURL === backendURL);
|
|
231
|
+
const sessions = all.filter((s) => s.backendURL !== backendURL);
|
|
232
|
+
// A re-pair replaces the add-in session, not the Microsoft 365 sign-in — but
|
|
233
|
+
// only when the SAME person re-paired: a claim can hand a shared machine to
|
|
234
|
+
// someone else, who must not inherit the previous user's delegated token.
|
|
235
|
+
// Identity is the add-in-reported `oid`; unknown on either side means drop.
|
|
236
|
+
const samePerson = Boolean(oid) && previous?.oid === oid;
|
|
237
|
+
// Dropping the field detaches the record from a keychain-stored secret; the
|
|
238
|
+
// secret itself has to go too, or the previous user's refresh token stays
|
|
239
|
+
// readable at rest for whoever shares this login keychain.
|
|
240
|
+
if (!samePerson && previous?.msGraphRefreshToken === KEYCHAIN_MARKER && keychainUsable()) {
|
|
241
|
+
keychainDelete(backendURL);
|
|
242
|
+
}
|
|
228
243
|
sessions.push({
|
|
229
244
|
backendURL,
|
|
230
245
|
sessionToken,
|
|
231
246
|
label: label || deriveEnvLabel(backendURL),
|
|
247
|
+
...(oid ? { oid } : {}),
|
|
248
|
+
...(samePerson && previous?.msGraphRefreshToken
|
|
249
|
+
? { msGraphRefreshToken: previous.msGraphRefreshToken }
|
|
250
|
+
: {}),
|
|
232
251
|
});
|
|
233
252
|
const def = getDefaultBackend() || backendURL;
|
|
234
253
|
writeAll(sessions, def);
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
imageSizeFromBuffer,
|
|
15
15
|
svgSizeFromMarkup,
|
|
16
16
|
} from "./image-dimensions.mjs";
|
|
17
|
-
import { describeRecolor } from "./recolor.mjs";
|
|
17
|
+
import { applyLocalRecolor, describeRecolor } from "./recolor.mjs";
|
|
18
18
|
|
|
19
19
|
// Every image format an engine accepts, plus the alias spellings seen in the
|
|
20
20
|
// wild. Mirrors the add-in's `searchImage` set (lib/image-formats.ts there);
|
|
@@ -151,12 +151,18 @@ function describeRecolorWithRefusal(markup, mimeType, iconColor, refusedColor) {
|
|
|
151
151
|
refusedColor ? null : (iconColor ?? null),
|
|
152
152
|
);
|
|
153
153
|
if (!refusedColor) return described;
|
|
154
|
+
// With the local bake in place (applyLocalRecolor), a surviving refusal
|
|
155
|
+
// means the glyph itself declined the colour — say why instead of sending
|
|
156
|
+
// the agent on a pane hunt.
|
|
157
|
+
const why =
|
|
158
|
+
described.mode === "multicolor"
|
|
159
|
+
? "it is a multi-tone glyph and a single colour would flatten it — left as designed."
|
|
160
|
+
: described.mode === "raster"
|
|
161
|
+
? "raster images carry their own colours."
|
|
162
|
+
: "the glyph's paint structure could not be rewritten safely.";
|
|
154
163
|
return {
|
|
155
164
|
...described,
|
|
156
|
-
note:
|
|
157
|
-
`Saved in its ORIGINAL colours — recolouring to ${refusedColor} runs in the slidemind add-in. ` +
|
|
158
|
-
"Re-run this download with --to <presId> (a PowerPoint window with the slidemind pane open) " +
|
|
159
|
-
"to bake the colour in.",
|
|
165
|
+
note: `Saved in its ORIGINAL colours — recolouring to ${refusedColor} was not applied: ${why}`,
|
|
160
166
|
};
|
|
161
167
|
}
|
|
162
168
|
|
|
@@ -218,16 +224,29 @@ async function streamOneResult({
|
|
|
218
224
|
if (res?.svgMarkup) {
|
|
219
225
|
// SVG arrives inline, not as a stream.
|
|
220
226
|
fs.rmSync(tempPath, { force: true });
|
|
227
|
+
// Window-free tint fallback: the pane-side refusal is about WHERE the
|
|
228
|
+
// rewrite runs, not whether it's possible — for currentColor/mono SVGs
|
|
229
|
+
// the bake is a deterministic local string transform. Measured impact:
|
|
230
|
+
// one sim batch carried 235 "saved in ORIGINAL colours" notes, every
|
|
231
|
+
// one an off-brand icon on the rendered slide.
|
|
232
|
+
let markup = res.svgMarkup;
|
|
233
|
+
if (recolorRefused) {
|
|
234
|
+
const local = applyLocalRecolor(markup, recolorRefused);
|
|
235
|
+
if (local.applied) {
|
|
236
|
+
markup = local.markup;
|
|
237
|
+
recolorRefused = null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
221
240
|
const filename = `engine-${searchId}-${selection}.svg`;
|
|
222
241
|
const target = path.join(assetsDir, filename);
|
|
223
|
-
fs.writeFileSync(target,
|
|
242
|
+
fs.writeFileSync(target, markup, "utf8");
|
|
224
243
|
return {
|
|
225
244
|
selection,
|
|
226
245
|
path: target,
|
|
227
246
|
reference: `./assets/${filename}`,
|
|
228
247
|
mimeType: res.mimeType ?? "image/svg+xml",
|
|
229
248
|
recolor: describeRecolorWithRefusal(
|
|
230
|
-
|
|
249
|
+
markup,
|
|
231
250
|
res.mimeType,
|
|
232
251
|
iconColor,
|
|
233
252
|
recolorRefused,
|
package/src/bridge/recolor.mjs
CHANGED
|
@@ -110,3 +110,49 @@ export function describeRecolor(svgMarkup, mimeType, appliedColor = null) {
|
|
|
110
110
|
note,
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
// Bake a colour into an SVG LOCALLY — the fallback for the window-free path,
|
|
115
|
+
// where the add-in refuses the tint (`recolor_needs_host`) because its
|
|
116
|
+
// DOM-based classifier lives pane-side. For the two recolourable modes the
|
|
117
|
+
// rewrite is a deterministic string transform this module can do itself:
|
|
118
|
+
// currentColor — replace every `currentColor` paint with the hex;
|
|
119
|
+
// mono — replace every concrete fill/stroke paint with the hex.
|
|
120
|
+
// Multicolor and raster are refused (flattening a multi-tone glyph or touching
|
|
121
|
+
// raster bytes is never right). Returns { markup, applied }.
|
|
122
|
+
export function applyLocalRecolor(svgMarkup, hex) {
|
|
123
|
+
if (typeof svgMarkup !== "string" || !hex || !isSvg(null, svgMarkup)) {
|
|
124
|
+
return { markup: svgMarkup, applied: false };
|
|
125
|
+
}
|
|
126
|
+
if (hasCurrentColor(svgMarkup)) {
|
|
127
|
+
return {
|
|
128
|
+
markup: svgMarkup.replace(
|
|
129
|
+
/((?:fill|stroke)\s*[:=]\s*["']?\s*)currentColor/gi,
|
|
130
|
+
`$1${hex}`,
|
|
131
|
+
),
|
|
132
|
+
applied: true,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const paints = distinctPaints(svgMarkup);
|
|
136
|
+
if (paints.size >= 2 || paints.size === 0) {
|
|
137
|
+
// Multicolor: never flatten. Zero declared paints: the glyph renders in
|
|
138
|
+
// the SVG default (black) via inheritance — inject a root fill instead of
|
|
139
|
+
// rewriting nothing.
|
|
140
|
+
if (paints.size === 0) {
|
|
141
|
+
return {
|
|
142
|
+
markup: svgMarkup.replace(/<svg\b/i, `<svg fill="${hex}"`),
|
|
143
|
+
applied: true,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return { markup: svgMarkup, applied: false };
|
|
147
|
+
}
|
|
148
|
+
const markup = svgMarkup.replace(
|
|
149
|
+
/((?:fill|stroke)\s*[:=]\s*["']?\s*)(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\)|[a-zA-Z]+)/g,
|
|
150
|
+
(m, prefix, value) => {
|
|
151
|
+
const v = value.trim().toLowerCase();
|
|
152
|
+
if (v === "none" || v === "transparent") return m;
|
|
153
|
+
if (!/^#|^rgb|^hsl/.test(v) && !NAMED_COLORS.has(v)) return m;
|
|
154
|
+
return `${prefix}${hex}`;
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
return { markup, applied: true };
|
|
158
|
+
}
|
|
@@ -114,7 +114,13 @@ export async function redeemSetupClaim({ backendURL, secret, cliVersion }) {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
if (result.status === 200 && result.json?.sessionToken) {
|
|
117
|
-
|
|
117
|
+
// `oid` (the user's directory id) arrives from add-ins that report it; an
|
|
118
|
+
// older add-in leaves it null, which the session store treats as "unknown
|
|
119
|
+
// person" — i.e. never inherit the previous Microsoft 365 sign-in.
|
|
120
|
+
return {
|
|
121
|
+
sessionToken: String(result.json.sessionToken),
|
|
122
|
+
oid: typeof result.json.oid === "string" && result.json.oid ? result.json.oid : null,
|
|
123
|
+
};
|
|
118
124
|
}
|
|
119
125
|
// Discriminate on the error CODE, not the status. A deployment that predates
|
|
120
126
|
// one-command setup has no such route, so the request falls through to that
|