slidemind 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,18 @@ import path from "node:path";
17
17
 
18
18
  import { unzip, zip, readText } from "../zip.mjs";
19
19
  import { slideOrderPaths, relsPathFor, resolvePartPath } from "../ooxml.mjs";
20
+ import {
21
+ bakeSlideSourceTags,
22
+ encodeSourceTags,
23
+ mintSlideGuid,
24
+ referencedAssets,
25
+ } from "../tags.mjs";
26
+ import {
27
+ reconcileSlideGuids,
28
+ saveSlideGuids,
29
+ slideGuidEntry,
30
+ } from "../../lib/slide-guids.mjs";
31
+ import { readDeckBundleRef } from "../../bridge/bundle.mjs";
20
32
  import { parseSlideFile, layoutNumber } from "./parse-slides.mjs";
21
33
  import {
22
34
  setShapeText, setParagraphText, setShapeFill, deleteShapes, cloneShape,
@@ -35,69 +47,11 @@ export async function buildFillTemplateDeck({ deckDir, skill, outPath, onLog })
35
47
  const locale = skill.locale || "en";
36
48
  const fieldsByNo = pack.fields[locale] ?? {};
37
49
  const repeatsByNo = pack.repeats[locale] ?? {};
38
-
39
- if (!skill.templatePath || !fs.existsSync(skill.templatePath)) {
40
- throw new Error(
41
- `template "${skill.templateKey}" not found in pack ${skill.skillDir} — check plan.md frontmatter and skill.json templates`,
42
- );
43
- }
44
-
45
- // ---- deck reading ---------------------------------------------------------
46
- const slidesDir = path.join(deckDir, "slides");
47
- const entries = fs.readdirSync(slidesDir)
48
- .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"))
49
- .sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
50
- if (entries.length === 0) throw new Error(`no .jsx or .pptx slides in ${slidesDir}`);
51
-
52
- const problems = [];
53
- const warnings = [];
54
- const sequence = []; // { kind: "master", no, props, file } | { kind: "insert", file, buf }
55
-
56
- // In-deck freeform pairing: NN-name.jsx (create-slides source) renders to a
57
- // sibling NN-name.pptx artifact — when both exist, the .pptx carries the
58
- // slide and the .jsx is source only.
59
- const renderedStems = new Set(
60
- entries.filter((f) => f.endsWith(".pptx")).map((f) => f.replace(/\.pptx$/, "")),
61
- );
62
-
63
- for (const file of entries) {
64
- if (file.endsWith(".pptx")) {
65
- sequence.push({ kind: "insert", file, buf: fs.readFileSync(path.join(slidesDir, file)) });
66
- continue;
67
- }
68
- if (renderedStems.has(file.replace(/\.jsx$/, ""))) continue;
69
- const src = fs.readFileSync(path.join(slidesDir, file), "utf8");
70
- let parsed;
71
- try {
72
- parsed = parseSlideFile(src, file);
73
- } catch (err) {
74
- problems.push(err.message);
75
- continue;
76
- }
77
- const layout = layoutNumber(parsed.component, locale, pack.components);
78
- if (!layout) {
79
- problems.push(`${file}: <${parsed.component}> is a freeform (create-slides) slide with no ` +
80
- `rendered artifact next to it — 'slidemind build-pptx' renders slides/${file.replace(/\.jsx$/, "")}.pptx ` +
81
- `automatically when a paired add-in backend is reachable`);
82
- continue;
83
- }
84
- if (layout.warn) warnings.push(`${file}: ${layout.warn}`);
85
- const no = String(layout.no);
86
-
87
- if ((pack.policy.freeform ?? []).includes(no)) {
88
- if (Object.keys(parsed.props).length > 0) {
89
- problems.push(`${file}: slide ${no} is a freeform placeholder — it takes no props. ` +
90
- `Keep it bare to keep the master placeholder, or delete the file to drop the slide.`);
91
- continue;
92
- }
93
- warnings.push(`${file}: keeping the master's slide-${no} placeholder (freeform replacement pending)`);
94
- }
95
-
96
- validateProps(parsed.props, fieldsByNo[no] ?? {}, repeatsByNo[no] ?? {}, pack.policy, no, file, problems);
97
- sequence.push({ kind: "master", no: Number(no), props: parsed.props, file });
98
- }
99
-
100
- checkIdentities(sequence, warnings, problems);
50
+ const { sequence, warnings, problems } = inspectFillTemplateDeck({
51
+ deckDir,
52
+ skill,
53
+ includeBuffers: true,
54
+ });
101
55
  if (problems.length) {
102
56
  const err = new Error(`deck has ${problems.length} authoring error(s):\n - ${problems.join("\n - ")}`);
103
57
  err.problems = problems;
@@ -142,7 +96,7 @@ export async function buildFillTemplateDeck({ deckDir, skill, outPath, onLog })
142
96
  xml = applyDeckChrome(xml, fieldMap, skill.planMeta ?? {}, item.props, log);
143
97
  xml = applyHygiene(xml, pack.policy, item.no, log);
144
98
  files[part] = enc(xml);
145
- output.push({ part, isNew: n > 1 });
99
+ output.push({ part, isNew: n > 1, sourceFile: item.file });
146
100
  }
147
101
 
148
102
  // Hygiene also applies to untouched (clone-only) slides that made it in.
@@ -161,6 +115,13 @@ export async function buildFillTemplateDeck({ deckDir, skill, outPath, onLog })
161
115
  // sweep them up as orphans.
162
116
  pruneOrphanParts(files, log);
163
117
 
118
+ // Deck-embedded source (spec §5.4): every declarative slide carries its
119
+ // slide-file source + a durable GUID as SLIDEMIND tags, so `adopt` can
120
+ // reconstruct the deck from the file alone. Freeform/native inserts arrive
121
+ // with their own tags (server-baked) and are left untouched. GUIDs persist
122
+ // in the deck's .slide-guids.json so a re-render keeps identities stable.
123
+ bakeDeclarativeSourceTags(files, output, deckDir, log);
124
+
164
125
  const integrity = checkIntegrity(files);
165
126
  for (const issue of integrity) warnings.push(`integrity: ${issue}`);
166
127
  if (integrity.length) {
@@ -174,6 +135,162 @@ export async function buildFillTemplateDeck({ deckDir, skill, outPath, onLog })
174
135
  return { outPath: finalOut, slideCount: output.length, warnings };
175
136
  }
176
137
 
138
+ // Bake SLIDEMIND source tags for the deck's declarative slides. Best-effort:
139
+ // an unreadable slide file skips only itself (the deck still builds).
140
+ function bakeDeclarativeSourceTags(files, output, deckDir, log) {
141
+ const bundleRef = readDeckBundleRef(deckDir);
142
+ // Reconciled, not merely loaded: a renamed slide file keeps its GUID via the
143
+ // recorded content hash, and dead entries are pruned (see slide-guids.mjs).
144
+ const guids = reconcileSlideGuids(deckDir);
145
+ const minted = {};
146
+ const entries = [];
147
+ for (const { part, sourceFile } of output) {
148
+ if (!sourceFile || !sourceFile.endsWith(".jsx")) continue;
149
+ let jsx;
150
+ try {
151
+ jsx = fs.readFileSync(path.join(deckDir, "slides", sourceFile), "utf8");
152
+ } catch {
153
+ continue;
154
+ }
155
+ const key = `slides/${sourceFile}`;
156
+ const guid = guids[key] ?? minted[key]?.guid ?? mintSlideGuid();
157
+ minted[key] = slideGuidEntry(guid, jsx);
158
+ entries.push({
159
+ partName: part,
160
+ tags: encodeSourceTags({
161
+ name: sourceFile.replace(/\.jsx$/, ""),
162
+ jsx,
163
+ guid,
164
+ bundle: {
165
+ presFP: bundleRef?.presFP ?? "unknown",
166
+ etag: bundleRef?.etag ?? "",
167
+ },
168
+ renderer: "slidemind-cli-fill",
169
+ // Deck-local images the slide file references ride along (smallest-
170
+ // first within the budget, spec §2.2) so `adopt` rebuilds a deck that
171
+ // still has its pictures.
172
+ assets: referencedAssets(jsx, path.join(deckDir, "assets")),
173
+ pushedAt: new Date().toISOString(),
174
+ // Declarative slides are template fills, not freeform renders — the
175
+ // add-in's hydration treats them like any source (decoders ignore
176
+ // unknown meta properties).
177
+ metaExtra: { kind: "template-fill" },
178
+ }),
179
+ });
180
+ }
181
+ if (entries.length && bakeSlideSourceTags(files, entries)) {
182
+ saveSlideGuids(deckDir, minted);
183
+ log.slide?.(`embedded source tags on ${entries.length} slide(s)`);
184
+ }
185
+ }
186
+
187
+ // Parse and structurally validate a fill-template deck without mutating it.
188
+ // External packs use this generic inspection surface to run their own domain
189
+ // checks before invoking build-pptx. Buffers are opt-in because JSON callers
190
+ // must receive a compact, serializable sequence.
191
+ export function inspectFillTemplateDeck({
192
+ deckDir,
193
+ skill,
194
+ allowUnrenderedFreeform = false,
195
+ includeBuffers = false,
196
+ }) {
197
+ const pack = loadPack(skill);
198
+ const locale = skill.locale || "en";
199
+ const fieldsByNo = pack.fields[locale] ?? {};
200
+ const repeatsByNo = pack.repeats[locale] ?? {};
201
+ const slidesDir = path.join(deckDir, "slides");
202
+ const problems = [];
203
+ const warnings = [];
204
+ const sequence = [];
205
+
206
+ if (!skill.templatePath || !fs.existsSync(skill.templatePath)) {
207
+ problems.push(
208
+ `template "${skill.templateKey}" not found in pack ${skill.skillDir} — check the pack manifest templates`,
209
+ );
210
+ }
211
+ if (!fs.existsSync(slidesDir) || !fs.statSync(slidesDir).isDirectory()) {
212
+ problems.push(`expected 'slides/' folder in ${deckDir}`);
213
+ return { sequence, warnings, problems, locale, templateKey: skill.templateKey };
214
+ }
215
+
216
+ const entries = fs.readdirSync(slidesDir)
217
+ .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"))
218
+ .sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
219
+ if (entries.length === 0) {
220
+ problems.push(`no .jsx or .pptx slides in ${slidesDir}`);
221
+ return { sequence, warnings, problems, locale, templateKey: skill.templateKey };
222
+ }
223
+
224
+ const renderedStems = new Set(
225
+ entries.filter((f) => f.endsWith(".pptx")).map((f) => f.replace(/\.pptx$/, "")),
226
+ );
227
+
228
+ for (const file of entries) {
229
+ const full = path.join(slidesDir, file);
230
+ if (file.endsWith(".pptx")) {
231
+ sequence.push({
232
+ kind: "insert",
233
+ file,
234
+ ...(includeBuffers ? { buf: fs.readFileSync(full) } : {}),
235
+ });
236
+ continue;
237
+ }
238
+ if (renderedStems.has(file.replace(/\.jsx$/, ""))) continue;
239
+
240
+ const src = fs.readFileSync(full, "utf8");
241
+ const component = src.match(/<([A-Z][A-Za-z0-9]*)/)?.[1] ?? null;
242
+ let layout = null;
243
+ if (component) {
244
+ try {
245
+ layout = layoutNumber(component, locale, pack.components);
246
+ } catch (err) {
247
+ problems.push(err.message);
248
+ continue;
249
+ }
250
+ }
251
+
252
+ // A pack validator needs to see freeform source positions even before the
253
+ // paired add-in artifact exists. The build path still rejects such a
254
+ // source until ensureFreeformRenders has produced its .pptx sibling.
255
+ if (!layout && allowUnrenderedFreeform) {
256
+ sequence.push({ kind: "freeform", file, component });
257
+ warnings.push(`${file}: freeform source has no rendered .pptx artifact yet`);
258
+ continue;
259
+ }
260
+
261
+ let parsed;
262
+ try {
263
+ parsed = parseSlideFile(src, file);
264
+ } catch (err) {
265
+ problems.push(err.message);
266
+ continue;
267
+ }
268
+ layout = layoutNumber(parsed.component, locale, pack.components);
269
+ if (!layout) {
270
+ problems.push(`${file}: <${parsed.component}> is a freeform (create-slides) slide with no ` +
271
+ `rendered artifact next to it — render slides/${file.replace(/\.jsx$/, "")}.pptx ` +
272
+ `through the paired add-in before building`);
273
+ continue;
274
+ }
275
+ if (layout.warn) warnings.push(`${file}: ${layout.warn}`);
276
+ const no = String(layout.no);
277
+
278
+ if ((pack.policy.freeform ?? []).includes(no)) {
279
+ if (Object.keys(parsed.props).length > 0) {
280
+ problems.push(`${file}: slide ${no} is a freeform placeholder — it takes no props. ` +
281
+ `Keep it bare to keep the master placeholder, or delete the file to drop the slide.`);
282
+ continue;
283
+ }
284
+ warnings.push(`${file}: keeping the master's slide-${no} placeholder (freeform replacement pending)`);
285
+ }
286
+
287
+ validateProps(parsed.props, fieldsByNo[no] ?? {}, repeatsByNo[no] ?? {}, pack.policy, no, file, problems);
288
+ sequence.push({ kind: "master", no: Number(no), props: parsed.props, file });
289
+ }
290
+
291
+ return { sequence, warnings, problems, locale, templateKey: skill.templateKey };
292
+ }
293
+
177
294
  // ---------------------------------------------------------------------------
178
295
  // Pack
179
296
  // ---------------------------------------------------------------------------
@@ -266,77 +383,6 @@ function editDistance(a, b) {
266
383
  return dp[a.length][b.length];
267
384
  }
268
385
 
269
- // ---------------------------------------------------------------------------
270
- // Cross-slide identities (the catalog's consistency table, as code)
271
- // ---------------------------------------------------------------------------
272
-
273
- const asLines = (v) => (Array.isArray(v) ? v : String(v ?? "").split("\n")).map((s) => s.trim()).filter(Boolean);
274
-
275
- function checkIdentities(sequence, warnings, problems) {
276
- const slide = (no) => sequence.filter((s) => s.kind === "master" && s.no === no);
277
- const approach = slide(10)[0];
278
- const details = slide(11);
279
- const gantt = slide(13)[0];
280
- const calc = slide(16)[0];
281
-
282
- const wpItems = approach?.props?.repeat?.workPackages ?? null;
283
- const ganttItems = gantt?.props?.repeat?.workPackages ?? null;
284
-
285
- if (wpItems && details.length && wpItems.length !== details.length) {
286
- problems.push(`WP count mismatch: slide 10 declares ${wpItems.length} work packages but there are ${details.length} WP-detail files (slide 11)`);
287
- }
288
- // Every WP detail must carry FULL bullet lists — a section left empty (or a
289
- // one-liner) is a defect in a client deliverable, not a style choice.
290
- details.forEach((d, i) => {
291
- for (const section of ["services", "assumptions", "deliverables"]) {
292
- const lines = asLines(d.props?.[section]);
293
- if (lines.length === 0) {
294
- problems.push(`${d.file}: WP detail ${i + 1} has no "${section}" — every WP detail needs all three bullet lists filled`);
295
- } else if (lines.length < 3) {
296
- warnings.push(`${d.file}: "${section}" has only ${lines.length} bullet(s) — WP details expect full lists (3–5)`);
297
- }
298
- }
299
- });
300
- if (wpItems && ganttItems && wpItems.length !== ganttItems.length) {
301
- problems.push(`WP count mismatch: slide 10 has ${wpItems.length} work packages, the project plan (13) has ${ganttItems.length} rows`);
302
- }
303
- if (ganttItems && details.length === ganttItems.length) {
304
- ganttItems.forEach((item, i) => {
305
- const canonical = asLines(details[i]?.props?.deliverables);
306
- const row = asLines(item.deliverable);
307
- if (canonical.length && row.length && canonical.join("\n") !== row.join("\n")) {
308
- problems.push(`deliverables drift: gantt row ${i + 1} must repeat WP-${i + 1}'s deliverables verbatim (slide 11 owns them)`);
309
- }
310
- });
311
- }
312
- if (wpItems && ganttItems) {
313
- ganttItems.forEach((item, i) => {
314
- if (item.title && wpItems[i]?.title && item.title !== wpItems[i].title) {
315
- problems.push(`WP name drift: slide 10 card ${i + 1} is "${wpItems[i].title}" but gantt row ${i + 1} is "${item.title}"`);
316
- }
317
- });
318
- }
319
- for (const [frameId, rows] of Object.entries(calc?.props?.tables ?? {})) {
320
- if (!Array.isArray(rows) || !wpItems) continue;
321
- const labels = rows.slice(1, -1).map((r) => String(r[0] ?? ""));
322
- labels.forEach((label, i) => {
323
- if (wpItems[i]?.title && label && label !== wpItems[i].title) {
324
- problems.push(`WP name drift: calculation table ${frameId} row ${i + 1} is "${label}" but slide 10 card ${i + 1} is "${wpItems[i].title}"`);
325
- }
326
- });
327
- }
328
- // Price echo: the contracted total should appear in the management summary.
329
- const costRows = calc?.props?.tables?.["15"];
330
- if (Array.isArray(costRows)) {
331
- const grand = String(costRows[costRows.length - 1]?.slice(-1)[0] ?? "");
332
- const digits = grand.replace(/[^\d.,]/g, "");
333
- const summary = slide(7)[0];
334
- if (digits && summary?.props?.approach && !String(summary.props.approach).includes(digits)) {
335
- warnings.push(`slide 7's approach paragraph does not mention the contracted total ${grand} from table 15`);
336
- }
337
- }
338
- }
339
-
340
386
  // ---------------------------------------------------------------------------
341
387
  // Per-slide ops
342
388
  // ---------------------------------------------------------------------------
@@ -1,9 +1,10 @@
1
1
  import { readText } from "./zip.mjs";
2
2
  import { relsFor, relsPathFor, resolvePartPath } from "./ooxml.mjs";
3
+ import { slideHasForeignTags, slideReferencesEmbeddedWorkbook } from "./tags.mjs";
3
4
 
4
5
  const CONTENT_TYPES = "[Content_Types].xml";
5
6
 
6
- export function stripHiddenOleGraphicFrames(files) {
7
+ export function stripHiddenOleGraphicFrames(files, { keepSlideOle = null } = {}) {
7
8
  let changed = false;
8
9
  const removedTargets = new Set();
9
10
  const candidates = Object.keys(files).filter((part) =>
@@ -14,6 +15,26 @@ export function stripHiddenOleGraphicFrames(files) {
14
15
  const before = readText(files, part);
15
16
  if (!before) continue;
16
17
 
18
+ // Coexistence rule C4 (intern-slidemind docs/deck-embedded-source-spec.md
19
+ // §13): a SLIDE that carries another add-in's state — foreign tags or an
20
+ // embedded workbook/OLE part (think-cell "slide workbook" pattern) — keeps
21
+ // its hidden OLE frames untouched; stripping them would destroy that
22
+ // add-in's data. Slidemind's own rendered/template output never has
23
+ // foreign tags, so the strip stays fully active there. Masters/layouts
24
+ // (template chain) keep the old behavior. The verdict is constant per
25
+ // slide CONTENT, so a caller running the strip once per pick over the
26
+ // same package (foldPickedSlides) precomputes it and passes `keepSlideOle`
27
+ // instead of paying the rels + tag-part parse again on every pick.
28
+ if (
29
+ part.startsWith("ppt/slides/") &&
30
+ (keepSlideOle
31
+ ? keepSlideOle(part)
32
+ : slideHasForeignTags(files, part) ||
33
+ slideReferencesEmbeddedWorkbook(files, part))
34
+ ) {
35
+ continue;
36
+ }
37
+
17
38
  const removedIds = new Set();
18
39
  const after = before.replace(
19
40
  /<p:graphicFrame\b[\s\S]*?<\/p:graphicFrame>/g,
@@ -31,6 +31,10 @@ import {
31
31
  } from "./ooxml.mjs";
32
32
  import { stripHiddenOleGraphicFrames } from "./hidden-ole.mjs";
33
33
  import { stripRiskyImageExtensions } from "./image-extensions.mjs";
34
+ import {
35
+ slideHasForeignTags,
36
+ slideReferencesEmbeddedWorkbook,
37
+ } from "./tags.mjs";
34
38
 
35
39
  // The four package parts we always keep and rewrite for standalone validity.
36
40
  const SKELETON = [
@@ -63,6 +67,19 @@ export function foldPickedSlides(buffer, picks, { sourceName = "reused" } = {})
63
67
  const lowest = firstSlidePath(files);
64
68
  const lowestRels = relsPathFor(lowest);
65
69
 
70
+ // The C4 keep-OLE verdict (foreign tags / embedded workbook) is per slide
71
+ // CONTENT and constant across picks — computed inside the strip it re-parsed
72
+ // every slide's rels and tag parts once per pick, O(picks × slides) on a big
73
+ // adopt. One pass here; the per-pick lookup follows the slide swap below
74
+ // (the lowest slot holds the picked slide's content).
75
+ const keepOleByPart = new Map(
76
+ order.map((part) => [
77
+ part,
78
+ slideHasForeignTags(files, part) ||
79
+ slideReferencesEmbeddedWorkbook(files, part),
80
+ ]),
81
+ );
82
+
66
83
  return picks.map((n) => {
67
84
  const picked = order[n - 1];
68
85
  const sldId = entries[n - 1] ? entries[n - 1].sldId : null;
@@ -89,7 +106,12 @@ export function foldPickedSlides(buffer, picks, { sourceName = "reused" } = {})
89
106
  copy[lowestRels] = stripNotesRel(copy[lowestRels]);
90
107
  notesStripped = true;
91
108
  }
92
- const hiddenOleStripped = stripHiddenOleGraphicFrames(copy);
109
+ const hiddenOleStripped = stripHiddenOleGraphicFrames(copy, {
110
+ keepSlideOle: (part) =>
111
+ keepOleByPart.get(
112
+ part === lowest ? picked : part === picked ? lowest : part,
113
+ ) ?? false,
114
+ });
93
115
  const riskyImagesStripped = stripRiskyImageExtensions(copy);
94
116
 
95
117
  // Prune to the lowest slide's dependency closure + trimmed skeleton.