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.
@@ -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
- // ASYNC REVIEW (the drain loop, shared with the plan gate). Saving a pin sends
16
- // it AT ONCE and the tab does not veil over: the preview stays fully browsable
17
- // and pinnable in WORKING mode, and further pins queue in the tab's
18
- // sessionStorage. When the agent re-runs this command the waiting tab reloads
19
- // (heartbeat claim) and its boot script POSTs that backlog to /review/backlog.
20
- // A non-empty backlog resolves the gate immediately with those pins (a "drain
21
- // round": DECISION: comments prints and the command exits without ever
22
- // parking), so several consecutive comment rounds are the normal shape of a
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
- // Keep the review preview available for a working session, then let the
77
- // existing finally block close the local server when the decision or timeout
78
- // resolves.
79
- const DEFAULT_TIMEOUT_MS = 5 * 60 * 60 * 1000;
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 — a queued pin is delivered a round later, so the two can differ)
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 === "queued" || c.status === "addressed" ? c.status : "sent",
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, { drained = false } = {}) {
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 is load-bearing: a pin queued during a fix names the render
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 LIVE while you fix — they keep browsing the preview and
214
- // keep dropping pins — and those pins are only delivered by the next
215
- // `slidemind review` run. An agent that ends its turn here strands them.
216
- out.push("Fix exactly what the comments ask for at the cited lines — a pin may");
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). The user may queue further comments while you fix; they");
223
- out.push("are delivered on that next run, which can return immediately with them — that");
224
- out.push("is normal, not an error. Do NOT end your turn, do NOT export anything, and do");
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 — the gate waited ${mins} minutes and the user hasn't answered.`,
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
- // Both POST routes are gate-server's shared handlers: the backlog drain and
348
- // the decision + 409 guard behave identically in both gates. `gate.settled()`
349
- // guards BOTH: once one tab's drain or decision resolved this run, a late
350
- // POST from another waiting tab is refused instead of being acked and
351
- // silently dropped (every waiter reloads into a new round, so two tabs
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, drained: false });
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, { "Content-Type": MIME[".html"] });
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
- `Waiting for the user's decision — this command BLOCKS (up to ${Math.round(
498
- timeoutMs / 60000,
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, { drained: !!decision.drained }),
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
- if (blocks.length === 0) {
143
- return {
144
- data: null,
145
- warnings,
146
- error: "Storyboard needs at least one valid block.",
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
- const remaining = Array.from(
25
- { length: Math.max(0, slideCount - 1) },
26
- (_, index) => index + 2,
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. Two kinds of pick share the pass — a slide that
180
- // stays native, and a CONVERTED slide whose preserved natives (chart / OLE /
181
- // SmartArt) need their verbatim source package kept for the render-time
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) picks.push(index + 1);
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
@@ -42,8 +42,18 @@ function stemOf(slideFileName) {
42
42
  }
43
43
 
44
44
  /**
45
- * Write one converted slide's record (+ its source package when the slide
46
- * carries preserved natives). `sourcePackage` is a Uint8Array/Buffer or null.
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.preserved?.length > 0 && !c.preserveSource)
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 (JSON.stringify(current) === JSON.stringify(decoded.baseline)) return;
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,
@@ -20,7 +20,11 @@
20
20
  import fs from "node:fs";
21
21
  import path from "node:path";
22
22
 
23
- import { computeProjection, decodeSourceTags } from "../pptx/tags.mjs";
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
- const current = computeProjection(unzip(Buffer.from(slide.base64, "base64")));
202
- if (JSON.stringify(current) === JSON.stringify(baseline)) continue;
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
  };