slidemind 0.3.4 → 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/.agents/plugins/marketplace.json +2 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +5 -5
- package/bin/slidemind.mjs +535 -134
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +718 -127
- package/skills/search/SKILL.md +217 -57
- 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 +81 -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/src/builders/review.mjs
CHANGED
|
@@ -12,17 +12,14 @@
|
|
|
12
12
|
// the click position, and a readonly-layout note where relevant
|
|
13
13
|
// (the agent edits the cited files and re-runs review).
|
|
14
14
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// review. An empty backlog never POSTs and the run parks and blocks as before.
|
|
24
|
-
// Approve is refused once a backlog arrived in this run — that render is
|
|
25
|
-
// already superseded by pins the agent has been told to fix.
|
|
15
|
+
// ROUNDS ARE SYNCHRONOUS (add-in parity). The reviewer drops as many pins as
|
|
16
|
+
// they like — those are unsent DRAFTS in the tab — then clicks "Send comments",
|
|
17
|
+
// which resolves the gate with the whole batch and the command exits with
|
|
18
|
+
// DECISION: comments. Approve is refused while unsent drafts exist. While the
|
|
19
|
+
// agent works the tab is in WORKING mode: browsable, no new pins, no approve;
|
|
20
|
+
// the next `slidemind review` run reloads that same tab with the new render and
|
|
21
|
+
// the delivered pins as read-only ghosts. Nothing is queued behind the agent's
|
|
22
|
+
// back and no run ever returns without the reviewer acting on it.
|
|
26
23
|
//
|
|
27
24
|
// Host-free: it reviews the HTML preview BEFORE any .pptx render, so it needs a
|
|
28
25
|
// previously-materialized bundle (like `slidemind build`) but no open host.
|
|
@@ -42,6 +39,7 @@
|
|
|
42
39
|
// ALWAYS opened and the stale tab retires itself; the review must never sit
|
|
43
40
|
// invisible while the user thinks nothing happened.
|
|
44
41
|
|
|
42
|
+
import crypto from "node:crypto";
|
|
45
43
|
import fs from "node:fs";
|
|
46
44
|
import http from "node:http";
|
|
47
45
|
import path from "node:path";
|
|
@@ -57,7 +55,6 @@ import {
|
|
|
57
55
|
createHeartbeat,
|
|
58
56
|
bundleGateSession,
|
|
59
57
|
normalizeGateComments,
|
|
60
|
-
serveBacklogPost,
|
|
61
58
|
serveDecisionPost,
|
|
62
59
|
listenWithFallback,
|
|
63
60
|
preferredGatePort,
|
|
@@ -73,10 +70,12 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
73
70
|
const __dirname = path.dirname(__filename);
|
|
74
71
|
const OVERLAY_PATH = path.join(__dirname, "review-overlay.js");
|
|
75
72
|
const REVIEW_STATE_FILE = "review.json";
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
73
|
+
// No decision timeout: the gate returns only on the user's decision (or when
|
|
74
|
+
// no browser could be opened at all — see openFailedGraceMs). An agent cannot
|
|
75
|
+
// deliver a preview a human has not approved, so the process waits as long as
|
|
76
|
+
// the human does; a harness that backgrounds the call gets the decision when it
|
|
77
|
+
// lands. Pass timeoutMs explicitly to cap the wait (tests).
|
|
78
|
+
const DEFAULT_TIMEOUT_MS = null;
|
|
80
79
|
|
|
81
80
|
function clamp01(n) {
|
|
82
81
|
return n < 0 ? 0 : n > 1 ? 1 : n;
|
|
@@ -125,8 +124,7 @@ function readSourceLine(deckDir, parsed) {
|
|
|
125
124
|
// Every row keeps its spatial anchor (jsxPath + resolvedTarget.loc + the click
|
|
126
125
|
// position as FULL-PRECISION 0..1 fractions of the slide); the shared core adds
|
|
127
126
|
// identity (`id` dedupes a client retry, `round` says which render the pin was
|
|
128
|
-
// dropped on
|
|
129
|
-
// and drops text-less rows.
|
|
127
|
+
// dropped on) and drops text-less rows.
|
|
130
128
|
//
|
|
131
129
|
// x/y are deliberately NOT quantized. Whole percent was ±6px of ghost-pin drift
|
|
132
130
|
// on a 1280px stage and collapsed every sub-1% offset; percent is a DISPLAY
|
|
@@ -141,13 +139,13 @@ export function normalizeReviewComments(raw) {
|
|
|
141
139
|
// The status a delivered pin carries: the agent is about to work it. A
|
|
142
140
|
// later run serves these back as the ledger seed, where they stay "sent" —
|
|
143
141
|
// the agent has them, but nothing has answered them yet.
|
|
144
|
-
status: c.status === "
|
|
142
|
+
status: c.status === "addressed" ? "addressed" : "sent",
|
|
145
143
|
}));
|
|
146
144
|
}
|
|
147
145
|
|
|
148
146
|
// Render the comment round for the harness — ports the add-in's
|
|
149
147
|
// formatPreviewCommentsForLlm, adapted to CLI commands.
|
|
150
|
-
export function formatCommentsForHarness(deckDir, comments
|
|
148
|
+
export function formatCommentsForHarness(deckDir, comments) {
|
|
151
149
|
const rows = normalizeReviewComments(comments);
|
|
152
150
|
const n = rows.length;
|
|
153
151
|
const out = [];
|
|
@@ -155,18 +153,11 @@ export function formatCommentsForHarness(deckDir, comments, { drained = false }
|
|
|
155
153
|
out.push(
|
|
156
154
|
`The user left ${n} comment${n === 1 ? "" : "s"} on the preview. Nothing was exported.`,
|
|
157
155
|
);
|
|
158
|
-
if (drained) {
|
|
159
|
-
out.push(
|
|
160
|
-
"(These were queued while you were fixing and arrived with this run — that is " +
|
|
161
|
-
"why the gate returned immediately instead of waiting.)",
|
|
162
|
-
);
|
|
163
|
-
}
|
|
164
156
|
out.push("");
|
|
165
157
|
rows.forEach((c, i) => {
|
|
166
158
|
const region = c.resolvedTarget || null;
|
|
167
159
|
const parsed = region ? parseLoc(region.loc) : null;
|
|
168
|
-
// The round tag
|
|
169
|
-
// it was dropped on, which may be one behind the files you are looking at.
|
|
160
|
+
// The round tag names the render the pin was dropped on.
|
|
170
161
|
const head = `[${i + 1}, round ${c.round}] ${c.jsxPath} — `;
|
|
171
162
|
// Percent is the unit the AGENT reads; the stored anchor stays full precision.
|
|
172
163
|
const x = clamp01(c.x);
|
|
@@ -210,19 +201,16 @@ export function formatCommentsForHarness(deckDir, comments, { drained = false }
|
|
|
210
201
|
);
|
|
211
202
|
out.push("");
|
|
212
203
|
// Imperative, stateful tail (mirrors plan.mjs's formatPlanCommentsForHarness):
|
|
213
|
-
// the reviewer's tab is
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
out.push("
|
|
217
|
-
out.push("reference the PREVIOUS render (see its round tag), so read the file before");
|
|
218
|
-
out.push("you edit. Then re-run the gate in the same turn:");
|
|
204
|
+
// the reviewer's tab is waiting on the next `slidemind review` run — nothing
|
|
205
|
+
// reaches them until it runs. An agent that ends its turn here strands them.
|
|
206
|
+
out.push("Fix exactly what the comments ask for at the cited lines — read the file");
|
|
207
|
+
out.push("before you edit. Then re-run the gate in the same turn:");
|
|
219
208
|
out.push(" 1. Edit the cited slides/*.jsx file(s) at the given lines.");
|
|
220
209
|
out.push(` 2. Re-run: slidemind review ${path.basename(deckDir)}`);
|
|
221
210
|
out.push("The re-run rebuilds the preview and reopens their same tab (addressed pins");
|
|
222
|
-
out.push("show as ghost pins)
|
|
223
|
-
out.push("
|
|
224
|
-
out.push("
|
|
225
|
-
out.push("NOT send the user back to the browser before step 2.");
|
|
211
|
+
out.push("show as ghost pins), and the user reviews again — several rounds are normal.");
|
|
212
|
+
out.push("Do NOT end your turn, do NOT export anything, and do NOT send the user back");
|
|
213
|
+
out.push("to the browser before step 2.");
|
|
226
214
|
return out.join("\n") + "\n";
|
|
227
215
|
}
|
|
228
216
|
|
|
@@ -230,11 +218,13 @@ export function formatCommentsForHarness(deckDir, comments, { drained = false }
|
|
|
230
218
|
// formatPlanPendingForHarness: an expected state with resume instructions, not
|
|
231
219
|
// a failure and never an implicit approval.
|
|
232
220
|
export function formatReviewPendingForHarness(deckDir, { timeoutMs, pageLoaded, url } = {}) {
|
|
233
|
-
const mins = Math.max(1, Math.round((timeoutMs ?? DEFAULT_TIMEOUT_MS) / 60000));
|
|
234
221
|
const base = path.basename(deckDir);
|
|
222
|
+
const waited = timeoutMs
|
|
223
|
+
? `the gate waited ${Math.max(1, Math.round(timeoutMs / 60000))} minutes and the user hasn't answered.`
|
|
224
|
+
: "the gate closed without an answer from the user.";
|
|
235
225
|
return [
|
|
236
226
|
"DECISION: pending",
|
|
237
|
-
`No decision yet —
|
|
227
|
+
`No decision yet — ${waited}`,
|
|
238
228
|
...pendingBrowserLines({ pageLoaded, url }),
|
|
239
229
|
"This is NOT an approval and NOT a rejection. Do not push or export anything.",
|
|
240
230
|
"Close out your turn gracefully:",
|
|
@@ -249,7 +239,6 @@ export function formatReviewPendingForHarness(deckDir, { timeoutMs, pageLoaded,
|
|
|
249
239
|
function injectReview(html, cfgExtra, overlayJs, logoMark = null, round = null) {
|
|
250
240
|
const cfg = safeInlineJson({
|
|
251
241
|
endpoint: "/review",
|
|
252
|
-
backlogEndpoint: "/review/backlog",
|
|
253
242
|
heartbeat: "/__review/heartbeat",
|
|
254
243
|
round,
|
|
255
244
|
pollMs: POLL_MS,
|
|
@@ -283,6 +272,7 @@ export function loadReviewState(deckDir) {
|
|
|
283
272
|
version: 2,
|
|
284
273
|
decision: data.decision ?? null,
|
|
285
274
|
comments: normalizeReviewComments(data.comments),
|
|
275
|
+
deckHash: data.deckHash ?? null,
|
|
286
276
|
at: data.at ?? null,
|
|
287
277
|
};
|
|
288
278
|
} catch {
|
|
@@ -290,6 +280,56 @@ export function loadReviewState(deckDir) {
|
|
|
290
280
|
}
|
|
291
281
|
}
|
|
292
282
|
|
|
283
|
+
// What the user reviewed: every file under slides/ (jsx + reused native .pptx)
|
|
284
|
+
// and assets/ (downloaded imagery), hashed by path + bytes. Recorded with the
|
|
285
|
+
// decision and re-checked by push/build-pptx, so an approval binds to exactly
|
|
286
|
+
// the content the user saw — any edit after the checkmark sends the deck back
|
|
287
|
+
// through the gate.
|
|
288
|
+
export function hashDeckContent(deckDir) {
|
|
289
|
+
const h = crypto.createHash("sha256");
|
|
290
|
+
const walk = (dir) => {
|
|
291
|
+
let entries = [];
|
|
292
|
+
try {
|
|
293
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
294
|
+
} catch {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
298
|
+
const p = path.join(dir, e.name);
|
|
299
|
+
if (e.isDirectory()) walk(p);
|
|
300
|
+
else if (e.isFile()) {
|
|
301
|
+
h.update(path.relative(deckDir, p).split(path.sep).join("/"));
|
|
302
|
+
h.update("\0");
|
|
303
|
+
h.update(fs.readFileSync(p));
|
|
304
|
+
h.update("\0");
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
for (const sub of ["slides", "assets"]) walk(path.join(deckDir, sub));
|
|
309
|
+
return h.digest("hex");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// The human checkmark push/build-pptx require — enforced by the CLI, not by the
|
|
313
|
+
// skill's prose. null = approved and unchanged since; otherwise the agent-facing
|
|
314
|
+
// reason plus the command that clears it.
|
|
315
|
+
export function requireReviewApproved(deckDir) {
|
|
316
|
+
const base = path.basename(deckDir);
|
|
317
|
+
const state = loadReviewState(deckDir);
|
|
318
|
+
if (state.decision !== "approve") {
|
|
319
|
+
return (
|
|
320
|
+
`${base} has not passed the preview review — a human must approve it first: ` +
|
|
321
|
+
`run \`slidemind review ${base}\` and wait for DECISION: approved.`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
if (state.deckHash !== hashDeckContent(deckDir)) {
|
|
325
|
+
return (
|
|
326
|
+
`${base}'s slides changed since the user approved the preview — ` +
|
|
327
|
+
`re-run \`slidemind review ${base}\` for a fresh sign-off.`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
293
333
|
function saveReviewState(deckDir, state) {
|
|
294
334
|
fs.writeFileSync(
|
|
295
335
|
path.join(deckDir, REVIEW_STATE_FILE),
|
|
@@ -344,29 +384,17 @@ export async function createReviewServer({
|
|
|
344
384
|
const u = new URL(req.url, "http://127.0.0.1");
|
|
345
385
|
const pathname = decodeURIComponent(u.pathname);
|
|
346
386
|
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
// posting into one run is the normal multi-window case, not an anomaly).
|
|
353
|
-
if (req.method === "POST" && pathname === "/review/backlog") {
|
|
354
|
-
serveBacklogPost(req, res, {
|
|
355
|
-
normalize: normalizeReviewComments,
|
|
356
|
-
isSuperseded: () => gate.settled(),
|
|
357
|
-
onDrained: (comments) => {
|
|
358
|
-
gate.resolve({ decision: "comments", comments, drained: true });
|
|
359
|
-
},
|
|
360
|
-
});
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
|
|
387
|
+
// The decision POST is gate-server's shared handler: the 409 guard behaves
|
|
388
|
+
// identically in both gates. `gate.settled()` refuses a late POST from
|
|
389
|
+
// another waiting tab instead of acking and silently dropping it (every
|
|
390
|
+
// waiter reloads into a new round, so two tabs posting into one run is the
|
|
391
|
+
// normal multi-window case, not an anomaly).
|
|
364
392
|
if (req.method === "POST" && pathname === "/review") {
|
|
365
393
|
serveDecisionPost(req, res, {
|
|
366
394
|
normalize: normalizeReviewComments,
|
|
367
395
|
isSuperseded: () => gate.settled(),
|
|
368
396
|
onDecision: (decision, _payload, comments) => {
|
|
369
|
-
gate.resolve({ decision, comments
|
|
397
|
+
gate.resolve({ decision, comments });
|
|
370
398
|
},
|
|
371
399
|
});
|
|
372
400
|
return;
|
|
@@ -400,7 +428,10 @@ export async function createReviewServer({
|
|
|
400
428
|
// actually opened in front of the user (see gate-server's page-load
|
|
401
429
|
// tracker; serveAndOpen and the pending contract report from it).
|
|
402
430
|
hb.notePageLoad();
|
|
403
|
-
res.writeHead(200, {
|
|
431
|
+
res.writeHead(200, {
|
|
432
|
+
"Content-Type": MIME[".html"],
|
|
433
|
+
"Cache-Control": "no-store, max-age=0",
|
|
434
|
+
});
|
|
404
435
|
res.end(indexHtml);
|
|
405
436
|
return;
|
|
406
437
|
}
|
|
@@ -473,7 +504,30 @@ export async function runReview({
|
|
|
473
504
|
openFailedGraceMs = OPEN_FAILED_GRACE_MS,
|
|
474
505
|
} = {}) {
|
|
475
506
|
await buildDeck(deckDir);
|
|
507
|
+
|
|
508
|
+
// Headless simulation runs (scripts/codex-sim.mjs): approve without a browser.
|
|
509
|
+
// buildDeck still ran above, so lint/build errors surface before the approve.
|
|
510
|
+
if (process.env.SLIDEMIND_GATE_AUTOAPPROVE === "1") {
|
|
511
|
+
saveReviewState(deckDir, {
|
|
512
|
+
version: 2,
|
|
513
|
+
decision: "approve",
|
|
514
|
+
comments: [],
|
|
515
|
+
deckHash: hashDeckContent(deckDir),
|
|
516
|
+
at: new Date().toISOString(),
|
|
517
|
+
});
|
|
518
|
+
process.stdout.write(
|
|
519
|
+
"DECISION: approved\n" +
|
|
520
|
+
"Auto-approved via SLIDEMIND_GATE_AUTOAPPROVE — no human reviewed this preview. " +
|
|
521
|
+
"Proceed to produce the output (slidemind push / slidemind build-pptx).\n",
|
|
522
|
+
);
|
|
523
|
+
return { decision: "approve", comments: [] };
|
|
524
|
+
}
|
|
525
|
+
|
|
476
526
|
const prev = loadReviewState(deckDir);
|
|
527
|
+
// Hashed after buildDeck (it copies brand assets into assets/) and before the
|
|
528
|
+
// gate opens: the agent is blocked while the user reviews, so this IS the
|
|
529
|
+
// content the decision refers to.
|
|
530
|
+
const deckHash = hashDeckContent(deckDir);
|
|
477
531
|
// The review window's logo is the add-in's genuine per-brand orb: a PNG
|
|
478
532
|
// shipped on the bundle as a role-keyed asset (copied into the deck by
|
|
479
533
|
// buildDeck). The review UI is dark, so prefer the dark variant. When absent
|
|
@@ -494,9 +548,8 @@ export async function runReview({
|
|
|
494
548
|
open,
|
|
495
549
|
graceMs: prev.decision === "comments" ? GRACE_MS : 0,
|
|
496
550
|
waitLine:
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
)} min) while they review. A long quiet wait is normal; do not cancel it.`,
|
|
551
|
+
"Waiting for the user's decision — this command BLOCKS until they decide; there is no " +
|
|
552
|
+
"timeout. A long quiet wait is normal; do not cancel it, and do nothing else meanwhile.",
|
|
500
553
|
});
|
|
501
554
|
|
|
502
555
|
let decision;
|
|
@@ -539,6 +592,7 @@ export async function runReview({
|
|
|
539
592
|
version: 2,
|
|
540
593
|
decision: decision.decision,
|
|
541
594
|
comments,
|
|
595
|
+
deckHash,
|
|
542
596
|
at: new Date().toISOString(),
|
|
543
597
|
});
|
|
544
598
|
|
|
@@ -550,7 +604,7 @@ export async function runReview({
|
|
|
550
604
|
);
|
|
551
605
|
} else {
|
|
552
606
|
process.stdout.write(
|
|
553
|
-
formatCommentsForHarness(deckDir, comments
|
|
607
|
+
formatCommentsForHarness(deckDir, comments),
|
|
554
608
|
);
|
|
555
609
|
}
|
|
556
610
|
return decision;
|
|
@@ -116,3 +116,18 @@ export function replaceStoryboardSegment(markdown, segment, nextData) {
|
|
|
116
116
|
segment.end,
|
|
117
117
|
)}`;
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
// Deck-level layout knob: binds EVERY valid storyboard fence to `layout`.
|
|
121
|
+
// Fences are spliced last-to-first so earlier offsets stay valid; malformed
|
|
122
|
+
// fences are left untouched. Mirrors the add-in's setStoryboardLayoutForAll.
|
|
123
|
+
export function setStoryboardLayoutForAll(markdown, layout) {
|
|
124
|
+
let next = markdown;
|
|
125
|
+
const boards = splitPlanMarkdownIntoSegments(markdown).filter(
|
|
126
|
+
(segment) => segment.type === "storyboard" && segment.data !== null,
|
|
127
|
+
);
|
|
128
|
+
for (const segment of boards.reverse()) {
|
|
129
|
+
if (segment.data.layout === layout) continue;
|
|
130
|
+
next = replaceStoryboardSegment(next, segment, { ...segment.data, layout });
|
|
131
|
+
}
|
|
132
|
+
return next;
|
|
133
|
+
}
|
|
@@ -25,8 +25,12 @@ export const MAX_DESIGN_ELEMENT_LENGTH = 56;
|
|
|
25
25
|
export const MAX_CONTENT_LENGTH = 28;
|
|
26
26
|
const SB_MAX_KEY_MESSAGE_LENGTH = 180;
|
|
27
27
|
const SB_MAX_VISUAL_FAMILY_LENGTH = 64;
|
|
28
|
+
const SB_MAX_LAYOUT_NAME_LENGTH = 64;
|
|
28
29
|
const SB_MAX_DEPTH_NOTES = 6;
|
|
29
30
|
const SB_MAX_DEPTH_NOTE_LENGTH = 220;
|
|
31
|
+
export const MAX_IMAGE_PROMPT_LENGTH = 240;
|
|
32
|
+
const SB_MAX_IMAGE_PROMPTS = 4;
|
|
33
|
+
const SB_IMAGE_PROMPT_KEY = /^image\d{0,2}$/;
|
|
30
34
|
|
|
31
35
|
function sbIsRecord(value) {
|
|
32
36
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -139,12 +143,12 @@ export function parseStoryboardCanvas(raw) {
|
|
|
139
143
|
warnings.push(`Only the first ${MAX_BLOCKS} storyboard blocks are shown.`);
|
|
140
144
|
}
|
|
141
145
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
146
|
+
// An empty block list is a valid storyboard: a cover or closing slide whose
|
|
147
|
+
// layout carries the text itself has no content blocks. Rejecting it dropped
|
|
148
|
+
// the slide from concept generation — and shifted the server's slideCount so
|
|
149
|
+
// the LAST slide fell off the request list too.
|
|
150
|
+
if (rawBlocks.length > 0 && blocks.length === 0) {
|
|
151
|
+
warnings.push("No storyboard block was valid; the canvas is empty.");
|
|
148
152
|
}
|
|
149
153
|
|
|
150
154
|
const keyMessage = sbAsTrimmedString(parsed.keyMessage, SB_MAX_KEY_MESSAGE_LENGTH);
|
|
@@ -152,6 +156,17 @@ export function parseStoryboardCanvas(raw) {
|
|
|
152
156
|
parsed.visualFamily,
|
|
153
157
|
SB_MAX_VISUAL_FAMILY_LENGTH,
|
|
154
158
|
);
|
|
159
|
+
const layout = sbAsTrimmedString(parsed.layout, SB_MAX_LAYOUT_NAME_LENGTH);
|
|
160
|
+
const imagePrompts = {};
|
|
161
|
+
if (sbIsRecord(parsed.imagePrompts)) {
|
|
162
|
+
for (const [key, value] of Object.entries(parsed.imagePrompts)) {
|
|
163
|
+
if (!SB_IMAGE_PROMPT_KEY.test(key)) continue;
|
|
164
|
+
const prompt = sbAsTrimmedString(value, MAX_IMAGE_PROMPT_LENGTH);
|
|
165
|
+
if (!prompt) continue;
|
|
166
|
+
imagePrompts[key] = prompt;
|
|
167
|
+
if (Object.keys(imagePrompts).length >= SB_MAX_IMAGE_PROMPTS) break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
155
170
|
const rawContentNotes = Array.isArray(parsed.contentNotes)
|
|
156
171
|
? parsed.contentNotes
|
|
157
172
|
: parsed.depthNotes;
|
|
@@ -168,8 +183,10 @@ export function parseStoryboardCanvas(raw) {
|
|
|
168
183
|
slide,
|
|
169
184
|
blocks,
|
|
170
185
|
...(visualFamily ? { visualFamily } : {}),
|
|
186
|
+
...(layout ? { layout } : {}),
|
|
171
187
|
...(keyMessage ? { keyMessage } : {}),
|
|
172
188
|
...(contentNotes?.length ? { contentNotes } : {}),
|
|
189
|
+
...(Object.keys(imagePrompts).length > 0 ? { imagePrompts } : {}),
|
|
173
190
|
},
|
|
174
191
|
warnings,
|
|
175
192
|
};
|
|
@@ -4,6 +4,20 @@ import { splitPlanMarkdownIntoSegments } from "./storyboard-markdown.mjs";
|
|
|
4
4
|
* Split a per-slide concept batch into the dependency waves expected by the
|
|
5
5
|
* add-in: independent slides and family anchors first, family variants second.
|
|
6
6
|
*/
|
|
7
|
+
// The slide numbers the plan's storyboards actually carry, ascending. This is
|
|
8
|
+
// the set concept generation must cover — the server's slideCount is only the
|
|
9
|
+
// number of beats IT parsed, and one beat it drops (an older schema rejecting
|
|
10
|
+
// a cover's empty block list) would otherwise also drop the LAST slide from a
|
|
11
|
+
// 2..slideCount request range.
|
|
12
|
+
export function plannedSlideNumbers(planMarkdown) {
|
|
13
|
+
const slides = new Set();
|
|
14
|
+
for (const segment of splitPlanMarkdownIntoSegments(planMarkdown)) {
|
|
15
|
+
if (segment.type !== "storyboard" || !segment.data) continue;
|
|
16
|
+
if (Number.isFinite(segment.data.slide)) slides.add(segment.data.slide);
|
|
17
|
+
}
|
|
18
|
+
return [...slides].sort((a, b) => a - b);
|
|
19
|
+
}
|
|
20
|
+
|
|
7
21
|
export function conceptGenerationWaves(planMarkdown, slideCount) {
|
|
8
22
|
const groups = new Map();
|
|
9
23
|
for (const segment of splitPlanMarkdownIntoSegments(planMarkdown)) {
|
|
@@ -21,10 +35,12 @@ export function conceptGenerationWaves(planMarkdown, slideCount) {
|
|
|
21
35
|
for (const slide of members.slice(1)) variants.add(slide);
|
|
22
36
|
}
|
|
23
37
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
38
|
+
// Slide 1 is the caller's probe request; everything else the plan names
|
|
39
|
+
// follows. A plan without parseable storyboards falls back to the count.
|
|
40
|
+
const planned = plannedSlideNumbers(planMarkdown);
|
|
41
|
+
const remaining = planned.length
|
|
42
|
+
? planned.filter((slide) => slide !== 1)
|
|
43
|
+
: Array.from({ length: Math.max(0, slideCount - 1) }, (_, i) => i + 2);
|
|
28
44
|
return {
|
|
29
45
|
firstWave: remaining.filter((slide) => !variants.has(slide)),
|
|
30
46
|
variants: remaining.filter((slide) => variants.has(slide)),
|
package/src/lib/adopt.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { decodeSourceTags, mintSlideGuid, readSlideTags } from "../pptx/tags.mjs
|
|
|
30
30
|
import { foldPickedSlides } from "../pptx/slide-swap.mjs";
|
|
31
31
|
import { readDeckBundleRef, writeDeckBundleRef } from "../bridge/bundle.mjs";
|
|
32
32
|
import { loadSlideGuids, saveSlideGuids, slideGuidEntry } from "./slide-guids.mjs";
|
|
33
|
-
import { writeConversionRecord } from "./conversions.mjs";
|
|
33
|
+
import { hasSourcedChart, writeConversionRecord } from "./conversions.mjs";
|
|
34
34
|
|
|
35
35
|
export const LIVE_DECK_UNSUPPORTED =
|
|
36
36
|
"conversion of live presentations requires the add-in path in v1 — " +
|
|
@@ -176,15 +176,19 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
|
|
|
176
176
|
|
|
177
177
|
// Fold in ONE pass: foldPickedSlides unzips and prunes the whole source
|
|
178
178
|
// package per call, so folding slide by slide redid that work for the entire
|
|
179
|
-
// deck on every native slide.
|
|
180
|
-
// stays native,
|
|
181
|
-
// SmartArt) need their verbatim source package kept
|
|
182
|
-
// splice (design D4/D6)
|
|
179
|
+
// deck on every native slide. Three kinds of pick share the pass — a slide
|
|
180
|
+
// that stays native, a CONVERTED slide whose preserved natives (OLE /
|
|
181
|
+
// SmartArt / unsupported chart kinds) need their verbatim source package kept
|
|
182
|
+
// for the render-time splice (design D4/D6), and a converted slide carrying
|
|
183
|
+
// the deck's own chart as `<Chart data-sm-source-id>`, which the render
|
|
184
|
+
// patches against that same package (native-charts 5.4a).
|
|
183
185
|
const picks = [];
|
|
184
186
|
decodedByIndex.forEach((decoded, index) => {
|
|
185
187
|
if (decoded) return;
|
|
186
188
|
const conv = convertedSlides.get(index + 1);
|
|
187
|
-
if (!conv || conv.preserved?.length > 0
|
|
189
|
+
if (!conv || conv.preserved?.length > 0 || hasSourcedChart(conv.jsx)) {
|
|
190
|
+
picks.push(index + 1);
|
|
191
|
+
}
|
|
188
192
|
});
|
|
189
193
|
const foldByPick = new Map(
|
|
190
194
|
(picks.length
|
package/src/lib/conversions.mjs
CHANGED
|
@@ -42,8 +42,18 @@ function stemOf(slideFileName) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
45
|
+
* Does this converted jsx carry the deck's own chart (`<Chart data-sm-source-id>`)?
|
|
46
|
+
* Such a chart is PATCHED against its original part at push, so the slide needs
|
|
47
|
+
* its source package exactly like a slide with preserved natives does.
|
|
48
|
+
*/
|
|
49
|
+
export function hasSourcedChart(jsx) {
|
|
50
|
+
return /<Chart\b[^>]*\bdata-sm-source-id=/.test(String(jsx ?? ""));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Write one converted slide's record (+ its source package when the caller
|
|
55
|
+
* folded one — adopt does so for slides with preserved natives or a sourced
|
|
56
|
+
* chart). `sourcePackage` is a Uint8Array/Buffer or null.
|
|
47
57
|
*/
|
|
48
58
|
export function writeConversionRecord(
|
|
49
59
|
deckDir,
|
|
@@ -54,8 +64,7 @@ export function writeConversionRecord(
|
|
|
54
64
|
const stem = stemOf(slideFileName);
|
|
55
65
|
const dir = conversionsDir(deckDir);
|
|
56
66
|
fs.mkdirSync(dir, { recursive: true });
|
|
57
|
-
const packageName =
|
|
58
|
-
sourcePackage && record.preserved?.length ? `${stem}.slide.pptx` : null;
|
|
67
|
+
const packageName = sourcePackage ? `${stem}.slide.pptx` : null;
|
|
59
68
|
if (packageName) {
|
|
60
69
|
fs.writeFileSync(path.join(dir, packageName), Buffer.from(sourcePackage));
|
|
61
70
|
}
|
|
@@ -256,8 +265,19 @@ function stampFirstReplace(deckDir, slideFileName, gate) {
|
|
|
256
265
|
}
|
|
257
266
|
|
|
258
267
|
/** Conversion records whose preserved natives have no source package to splice. */
|
|
259
|
-
export function conversionsMissingPreserveSource(conversions) {
|
|
268
|
+
export function conversionsMissingPreserveSource(conversions, deckDir = null) {
|
|
269
|
+
const needsPackage = (file, c) => {
|
|
270
|
+
if (c.preserved?.length > 0) return true;
|
|
271
|
+
if (!deckDir) return false;
|
|
272
|
+
try {
|
|
273
|
+
return hasSourcedChart(
|
|
274
|
+
fs.readFileSync(path.join(deckDir, "slides", file), "utf8"),
|
|
275
|
+
);
|
|
276
|
+
} catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
260
280
|
return Object.entries(conversions)
|
|
261
|
-
.filter(([, c]) => c
|
|
281
|
+
.filter(([file, c]) => needsPackage(file, c) && !c.preserveSource)
|
|
262
282
|
.map(([file]) => file);
|
|
263
283
|
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
computeProjection,
|
|
24
24
|
decodeSourceTags,
|
|
25
25
|
readSlideTags,
|
|
26
|
+
withoutChartHashes,
|
|
26
27
|
} from "../pptx/tags.mjs";
|
|
27
28
|
import { unzip } from "../pptx/zip.mjs";
|
|
28
29
|
|
|
@@ -58,8 +59,20 @@ export function detectManualEdits(outPath) {
|
|
|
58
59
|
if (!decoded?.baseline) return;
|
|
59
60
|
// Both sides are produced by the SAME emitter, so key order matches and a
|
|
60
61
|
// string compare is a stable structural compare.
|
|
62
|
+
//
|
|
63
|
+
// …with the chart hash dropped from BOTH sides: this repo has no chart
|
|
64
|
+
// reader, so it always projects `chart: null` (see the CHART HASH note in
|
|
65
|
+
// pptx/tags.mjs). Comparing an add-in baseline's real hash against that null
|
|
66
|
+
// would refuse EVERY rebuild of a deck that holds a chart, which is a
|
|
67
|
+
// certain harm; missing a chart-ONLY manual edit is the bounded one, and the
|
|
68
|
+
// add-in's own drift check still catches it on the next push.
|
|
61
69
|
const current = computeProjection(files, slidePath);
|
|
62
|
-
if (
|
|
70
|
+
if (
|
|
71
|
+
JSON.stringify(withoutChartHashes(current)) ===
|
|
72
|
+
JSON.stringify(withoutChartHashes(decoded.baseline))
|
|
73
|
+
) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
63
76
|
edited.push({
|
|
64
77
|
slideIndex: i + 1,
|
|
65
78
|
slidePath,
|
package/src/lib/reconcile.mjs
CHANGED
|
@@ -20,7 +20,11 @@
|
|
|
20
20
|
import fs from "node:fs";
|
|
21
21
|
import path from "node:path";
|
|
22
22
|
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
computeProjection,
|
|
25
|
+
decodeSourceTags,
|
|
26
|
+
withoutChartHashes,
|
|
27
|
+
} from "../pptx/tags.mjs";
|
|
24
28
|
import { unzip } from "../pptx/zip.mjs";
|
|
25
29
|
import { loadConversions } from "./conversions.mjs";
|
|
26
30
|
import { detectManualEdits, manualEditsError } from "./overwrite-guard.mjs";
|
|
@@ -198,10 +202,17 @@ export async function collectLiveReconcileInputs({
|
|
|
198
202
|
if (!baseline) continue;
|
|
199
203
|
// The BYTES carry the current state; the TAGS carry the baseline. Never the
|
|
200
204
|
// other way round — exportAsBase64 strips tags (office-js #6104).
|
|
201
|
-
|
|
202
|
-
|
|
205
|
+
// Chart hashes are dropped from BOTH sides — this repo cannot produce one
|
|
206
|
+
// (CHART HASH note in pptx/tags.mjs), and sending a null against the
|
|
207
|
+
// baseline's real hash would make the SERVER report a chart edit that never
|
|
208
|
+
// happened.
|
|
209
|
+
const current = withoutChartHashes(
|
|
210
|
+
computeProjection(unzip(Buffer.from(slide.base64, "base64"))),
|
|
211
|
+
);
|
|
212
|
+
const comparable = withoutChartHashes(baseline);
|
|
213
|
+
if (JSON.stringify(current) === JSON.stringify(comparable)) continue;
|
|
203
214
|
reconcile[fileName] = {
|
|
204
|
-
baseline,
|
|
215
|
+
baseline: comparable,
|
|
205
216
|
current,
|
|
206
217
|
...(acks[fileName] ? { ack: acks[fileName] } : {}),
|
|
207
218
|
};
|