slidemind 0.3.20 → 0.4.1
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 +592 -16
- package/LICENSE +4 -0
- package/README.md +203 -8
- package/THIRD-PARTY-NOTICES.md +201 -0
- package/bin/slidemind.mjs +50 -2
- package/package.json +5 -3
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import fs from "node:fs";
|
|
18
18
|
import path from "node:path";
|
|
19
|
-
import { randomUUID } from "node:crypto";
|
|
19
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
20
20
|
|
|
21
21
|
import {
|
|
22
22
|
exportSlides,
|
|
@@ -37,11 +37,29 @@ import {
|
|
|
37
37
|
writeDeckBundleRef,
|
|
38
38
|
} from "./bundle.mjs";
|
|
39
39
|
import { bundleHasCaptureEngine, captureDeckLocally } from "./capture.mjs";
|
|
40
|
+
import { buildImageIntentEnvelope } from "./image-intent-envelope.mjs";
|
|
41
|
+
import { writePackagePreview } from "./package-preview.mjs";
|
|
42
|
+
import {
|
|
43
|
+
assertImageIntentSatisfied,
|
|
44
|
+
draftImageIntentLines,
|
|
45
|
+
formatIssue,
|
|
46
|
+
imageIntentVerdict,
|
|
47
|
+
slideNumberOf,
|
|
48
|
+
slideNumbersOf,
|
|
49
|
+
} from "../builders/image-intent-check.mjs";
|
|
50
|
+
import { IMAGE_INTENT_META_KEY, imageIntentTagMetaForSlides } from "../lib/image-intent-tags.mjs";
|
|
51
|
+
import {
|
|
52
|
+
PRESERVATION_META_KEY,
|
|
53
|
+
preservationDocForBake,
|
|
54
|
+
preservationTagMeta,
|
|
55
|
+
} from "../lib/preservation-tags.mjs";
|
|
40
56
|
import { lintConvertedOrThrow } from "../lib/lint-converted.mjs";
|
|
41
57
|
import { slideOrderWarning, sortSlideFilenames } from "../lib/slide-order.mjs";
|
|
42
58
|
import {
|
|
43
59
|
applyConversionGates,
|
|
60
|
+
CONVERSIONS_DIR,
|
|
44
61
|
conversionIntegrityErrors,
|
|
62
|
+
conversionProvenance,
|
|
45
63
|
loadConversions,
|
|
46
64
|
rehomeConversions,
|
|
47
65
|
} from "../lib/conversions.mjs";
|
|
@@ -63,7 +81,9 @@ import {
|
|
|
63
81
|
SLIDEMIND_TAG_PREFIX,
|
|
64
82
|
TAG_GUID,
|
|
65
83
|
TAG_PROV,
|
|
84
|
+
augmentSourceMeta,
|
|
66
85
|
bakeSlideSourceTags,
|
|
86
|
+
computeProjection,
|
|
67
87
|
encodeSourceTags,
|
|
68
88
|
mintSlideGuid,
|
|
69
89
|
readSlideTags,
|
|
@@ -71,19 +91,72 @@ import {
|
|
|
71
91
|
} from "../pptx/tags.mjs";
|
|
72
92
|
import { unzip, zip } from "../pptx/zip.mjs";
|
|
73
93
|
import { slideOrderPaths } from "../pptx/ooxml.mjs";
|
|
94
|
+
import { assembleDeck, normalizeMasters, planFromDeck, validatePackage } from "../pptx/deck-assemble.mjs";
|
|
95
|
+
import {
|
|
96
|
+
acceptedBasePath,
|
|
97
|
+
acquireOwner,
|
|
98
|
+
computeDirty,
|
|
99
|
+
materializedInputs,
|
|
100
|
+
inputDigest,
|
|
101
|
+
readAcceptedBase,
|
|
102
|
+
readRevision,
|
|
103
|
+
saveCandidate,
|
|
104
|
+
slideFileOrder,
|
|
105
|
+
} from "../lib/revision.mjs";
|
|
106
|
+
import {
|
|
107
|
+
bundleOwnedFiles,
|
|
108
|
+
deckPptxPath,
|
|
109
|
+
deckRoot,
|
|
110
|
+
deckSlug,
|
|
111
|
+
previewPath,
|
|
112
|
+
} from "../lib/deck-paths.mjs";
|
|
74
113
|
|
|
75
114
|
// Deck-embedded source, translate-path bake (spec §5.4): the server never
|
|
76
115
|
// sees the jsx on the local-capture path, so tag any UNTAGGED jsx slide in
|
|
77
116
|
// the returned deck locally. Deck slide order == sorted deck filename order
|
|
78
117
|
// (jsx + native .pptx share one namespace), which maps 1:1 onto the
|
|
79
118
|
// package's slide order. Returns new base64 or null when nothing changed.
|
|
80
|
-
|
|
119
|
+
// `metaExtra` per slide file: the slide's `SRC_META.imageIntent`, or undefined
|
|
120
|
+
// so a slide with no image use adds nothing at all to its tags. Resolved for
|
|
121
|
+
// the whole bake in one pass — the lookup re-hashes every slide file to map
|
|
122
|
+
// overrides onto slide numbers, and doing that per slide is quadratic.
|
|
123
|
+
function intentMetaByFile(deckDir, fileNames) {
|
|
124
|
+
const bySlide = imageIntentTagMetaForSlides(deckDir, slideNumbersOf(fileNames));
|
|
125
|
+
const out = new Map();
|
|
126
|
+
if (!bySlide.size) return out;
|
|
127
|
+
for (const fileName of fileNames) {
|
|
128
|
+
const intent = bySlide.get(slideNumberOf(fileName));
|
|
129
|
+
if (intent) out.set(fileName, { [IMAGE_INTENT_META_KEY]: intent });
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Merge the optional SRC_META extras of one slide, dropping the ones it has
|
|
135
|
+
// none of. `undefined` when there are none at all, so a slide that carries
|
|
136
|
+
// neither an image obligation nor a preservation record bakes exactly the tags
|
|
137
|
+
// it baked before either contract existed.
|
|
138
|
+
function slideMetaExtra(...parts) {
|
|
139
|
+
const out = {};
|
|
140
|
+
for (const part of parts) {
|
|
141
|
+
for (const [key, value] of Object.entries(part ?? {})) {
|
|
142
|
+
if (value != null) out[key] = value;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Exported for the round-trip tests: bake → adopt → edit → bake is the contract
|
|
149
|
+
// the deck-embedded records live or die by (task 5.7 / E19), and imitating the
|
|
150
|
+
// bake in a test would prove only that the imitation works.
|
|
151
|
+
export function ensureDeckSourceTags(deckDir, base64, only = null) {
|
|
81
152
|
const slidesDir = path.join(deckDir, "slides");
|
|
82
153
|
if (!fs.existsSync(slidesDir)) return null;
|
|
154
|
+
const wanted = only ? new Set(only) : null;
|
|
83
155
|
const deckFiles = sortSlideFilenames(
|
|
84
156
|
fs
|
|
85
157
|
.readdirSync(slidesDir)
|
|
86
|
-
.filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"))
|
|
158
|
+
.filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"))
|
|
159
|
+
.filter((f) => !wanted || wanted.has(f)),
|
|
87
160
|
);
|
|
88
161
|
if (deckFiles.length === 0) return null;
|
|
89
162
|
|
|
@@ -101,13 +174,36 @@ function ensureDeckSourceTags(deckDir, base64) {
|
|
|
101
174
|
}
|
|
102
175
|
|
|
103
176
|
const bundleRef = readDeckBundleRef(deckDir);
|
|
177
|
+
const intentMeta = intentMetaByFile(deckDir, deckFiles);
|
|
104
178
|
const guids = loadSlideGuids(deckDir);
|
|
179
|
+
// The deck's preservation records, read once for the whole bake and looked up
|
|
180
|
+
// per slide by its GUID below (src/lib/preservation-tags.mjs).
|
|
181
|
+
const preservationDoc = preservationDocForBake(deckDir);
|
|
105
182
|
const minted = {};
|
|
106
183
|
const entries = [];
|
|
184
|
+
// What the bake adds beyond the source itself, per slide: the approved image
|
|
185
|
+
// obligations and the slide's preservation record.
|
|
186
|
+
const extrasFor = (fileName, guid) =>
|
|
187
|
+
slideMetaExtra(intentMeta.get(fileName), {
|
|
188
|
+
[PRESERVATION_META_KEY]: preservationTagMeta(deckDir, guid, {
|
|
189
|
+
preservation: preservationDoc,
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
let augmented = false;
|
|
107
193
|
for (let i = 0; i < deckFiles.length; i++) {
|
|
108
194
|
const fileName = deckFiles[i];
|
|
109
195
|
if (!fileName.endsWith(".jsx")) continue; // natives carry their own tags
|
|
110
|
-
|
|
196
|
+
const serverGuid = readSlideTags(files, order[i])[TAG_GUID];
|
|
197
|
+
if (serverGuid) {
|
|
198
|
+
// The server baked this slide's source. It could not have written the
|
|
199
|
+
// extras — `imageIntent` needs the deck's registry, which only this side
|
|
200
|
+
// holds — so they are added to ITS meta rather than baked a second time
|
|
201
|
+
// under the same key (two writers, one key, two answers).
|
|
202
|
+
if (augmentSourceMeta(files, order[i], extrasFor(fileName, serverGuid))) {
|
|
203
|
+
augmented = true;
|
|
204
|
+
}
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
111
207
|
let jsx;
|
|
112
208
|
try {
|
|
113
209
|
jsx = fs.readFileSync(path.join(slidesDir, fileName), "utf8");
|
|
@@ -132,12 +228,31 @@ function ensureDeckSourceTags(deckDir, base64) {
|
|
|
132
228
|
// budget, spec §2.2) — without them `adopt` rebuilds a deck whose
|
|
133
229
|
// slides point at images that were never handed over.
|
|
134
230
|
assets: referencedAssets(jsx, path.join(deckDir, "assets")),
|
|
231
|
+
// The DRIFT BASELINE: the slide's structural projection as it stands in
|
|
232
|
+
// the package being baked. Without it the payload can later be proved
|
|
233
|
+
// intact but never proved CURRENT, so `adopt` has to convert the slide
|
|
234
|
+
// instead of restoring its jsx, and `detectManualEdits` has nothing to
|
|
235
|
+
// compare an Office edit against. The bake only appends a custDataLst
|
|
236
|
+
// after </p:spTree>, which the projection does not read, so computing it
|
|
237
|
+
// here and baking it are the same picture.
|
|
238
|
+
baseline: computeProjection(files, order[i]),
|
|
135
239
|
pushedAt: new Date().toISOString(),
|
|
240
|
+
// The slide's approved image obligations, so a .pptx that travels can
|
|
241
|
+
// be adopted back with its picture identities intact instead of as a
|
|
242
|
+
// slide that merely HAS a picture (design decision 8). Absent — and the
|
|
243
|
+
// tags unchanged — for every slide with no image use.
|
|
244
|
+
//
|
|
245
|
+
// Next to them, what the slide must still CONTAIN: its preservation
|
|
246
|
+
// record, so a deck adopted into a fresh workspace comes back owing what
|
|
247
|
+
// it owed — including the requirements it intentionally superseded, so
|
|
248
|
+
// a deletion is not silently owed again (task 5.7 / E19–E20).
|
|
249
|
+
metaExtra: extrasFor(fileName, guid),
|
|
136
250
|
}),
|
|
137
251
|
});
|
|
138
252
|
}
|
|
139
|
-
|
|
140
|
-
|
|
253
|
+
const baked = entries.length > 0 && bakeSlideSourceTags(files, entries);
|
|
254
|
+
if (!baked && !augmented) return null;
|
|
255
|
+
if (baked) saveSlideGuids(deckDir, minted);
|
|
141
256
|
return Buffer.from(zip(files)).toString("base64");
|
|
142
257
|
}
|
|
143
258
|
|
|
@@ -167,7 +282,7 @@ function collectSourceTagsBySlide(base64) {
|
|
|
167
282
|
}
|
|
168
283
|
}
|
|
169
284
|
|
|
170
|
-
function gatherSlides(deckDir) {
|
|
285
|
+
function gatherSlides(deckDir, only = null) {
|
|
171
286
|
const slidesDir = path.join(deckDir, "slides");
|
|
172
287
|
if (!fs.existsSync(slidesDir) || !fs.statSync(slidesDir).isDirectory()) {
|
|
173
288
|
throw new Error(`Expected 'slides/' folder in ${deckDir}`);
|
|
@@ -176,12 +291,22 @@ function gatherSlides(deckDir) {
|
|
|
176
291
|
// namespace (filename order, numeric-aware). The server splices the native
|
|
177
292
|
// .pptx in by the SAME sorted-filename key, so the two kinds interleave by
|
|
178
293
|
// their numeric prefix regardless of extension.
|
|
179
|
-
|
|
294
|
+
//
|
|
295
|
+
// `only` narrows the request to the slides that actually changed — the
|
|
296
|
+
// targeted-assembly path, where the rest of the deck comes from the accepted
|
|
297
|
+
// base instead of being re-rendered. The SUBSET keeps its relative order, so
|
|
298
|
+
// the returned package's slide N is the Nth entry of `only`.
|
|
299
|
+
let all = sortSlideFilenames(
|
|
180
300
|
fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx")),
|
|
181
301
|
);
|
|
182
302
|
if (all.length === 0) {
|
|
183
303
|
throw new Error(`No .jsx or .pptx slides under ${slidesDir}`);
|
|
184
304
|
}
|
|
305
|
+
if (only) {
|
|
306
|
+
const wanted = new Set(only);
|
|
307
|
+
all = all.filter((f) => wanted.has(f));
|
|
308
|
+
if (all.length === 0) throw new Error(`none of the requested slides exist under ${slidesDir}`);
|
|
309
|
+
}
|
|
185
310
|
const slides = {};
|
|
186
311
|
const nativeSlides = {};
|
|
187
312
|
for (const f of all) {
|
|
@@ -194,20 +319,27 @@ function gatherSlides(deckDir) {
|
|
|
194
319
|
return { slides, nativeSlides, orderWarning: slideOrderWarning(all) };
|
|
195
320
|
}
|
|
196
321
|
|
|
197
|
-
// Collect
|
|
322
|
+
// Collect DECK-OWNED assets (icons the agent downloaded, deck photos) as
|
|
198
323
|
// base64, keyed by their `assets/<rel>` path so the server-side renderer mounts
|
|
199
324
|
// them where the slide JSX references them (`<img src="./assets/<rel>">`).
|
|
325
|
+
//
|
|
326
|
+
// Copies of the design bundle a pre-workspace deck still carries are never
|
|
327
|
+
// uploaded: the server resolves brand assets from the design itself ("brand asset
|
|
328
|
+
// wins"), and a deck carrying them would ship megabytes on every render. A copy is
|
|
329
|
+
// one the bundle owns AND whose bytes match it — a user's own `master-data.png`
|
|
330
|
+
// merely shares a name and is uploaded like any other deck media.
|
|
200
331
|
function gatherDeckAssets(deckDir) {
|
|
201
332
|
const assetsDir = path.join(deckDir, "assets");
|
|
202
333
|
const out = {};
|
|
203
334
|
if (!fs.existsSync(assetsDir)) return out;
|
|
335
|
+
const bundleOwned = bundleOwnedFiles(deckDir, resolveBundleDirForDeck(deckDir));
|
|
204
336
|
const walk = (dir, prefix) => {
|
|
205
337
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
206
338
|
const abs = path.join(dir, entry.name);
|
|
207
339
|
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
208
340
|
if (entry.isDirectory()) {
|
|
209
341
|
walk(abs, rel);
|
|
210
|
-
} else {
|
|
342
|
+
} else if (!bundleOwned.has(`assets/${rel}`)) {
|
|
211
343
|
out[`assets/${rel}`] = fs.readFileSync(abs).toString("base64");
|
|
212
344
|
}
|
|
213
345
|
}
|
|
@@ -232,15 +364,30 @@ export async function renderDeckToBase64(
|
|
|
232
364
|
backendURL = null,
|
|
233
365
|
reconcile = null,
|
|
234
366
|
onProgress = null,
|
|
367
|
+
only = null,
|
|
235
368
|
},
|
|
236
369
|
) {
|
|
237
370
|
if (!presId && !brandId) {
|
|
238
371
|
throw new Error("renderDeckToBase64: presId or brandId is required");
|
|
239
372
|
}
|
|
240
|
-
const deckName =
|
|
241
|
-
const { slides, nativeSlides, orderWarning } = gatherSlides(deckDir);
|
|
373
|
+
const deckName = deckSlug(deckDir);
|
|
374
|
+
const { slides, nativeSlides, orderWarning } = gatherSlides(deckDir, only);
|
|
242
375
|
const hasJsx = Object.keys(slides).length > 0;
|
|
243
376
|
const deckAssets = gatherDeckAssets(deckDir);
|
|
377
|
+
// The deck's approved image obligations for exactly the slides this request
|
|
378
|
+
// carries. Built BEFORE anything is rendered, because the builder resolves
|
|
379
|
+
// every registered source on the way: a deleted or overwritten original
|
|
380
|
+
// raises `image_asset_missing` / `image_asset_changed` here, with no .pptx
|
|
381
|
+
// written and nothing silently substituted (A14). `null` for every deck that
|
|
382
|
+
// has no image uses — which is every deck that predates the contract — so the
|
|
383
|
+
// request body is byte-for-byte what it always was.
|
|
384
|
+
const imageIntent = buildImageIntentEnvelope(deckDir, {
|
|
385
|
+
slides: slideNumbersOf([...Object.keys(slides), ...Object.keys(nativeSlides)]),
|
|
386
|
+
// Render/translate resolve the descriptors against the byte map the request
|
|
387
|
+
// already ships (`deckAssets`, keyed `assets/local/<id>.<ext>` — the asset's
|
|
388
|
+
// own workspacePath), instead of inlining every screenshot a second time.
|
|
389
|
+
inline: false,
|
|
390
|
+
});
|
|
244
391
|
// Bind the deck to a materialized bundle if it isn't already. Without a
|
|
245
392
|
// `.bundle-ref.json` the etag below is null (server-side skew detection
|
|
246
393
|
// silently no-ops), the local HTML/editor preview can't resolve the deck's
|
|
@@ -279,7 +426,29 @@ export async function renderDeckToBase64(
|
|
|
279
426
|
// file it was renamed away from, used to be a note AFTER the render — by
|
|
280
427
|
// which time the slide had already come back on a fallback layout. It is a
|
|
281
428
|
// hard stop: the render request would carry a lie about the source.
|
|
282
|
-
|
|
429
|
+
//
|
|
430
|
+
// A record that exists but will not PARSE is the same lie with nobody left
|
|
431
|
+
// to tell it: `loadConversions` skips it, so the slide is simply absent from
|
|
432
|
+
// `conversions` — which reads as "not converted". It would then render as
|
|
433
|
+
// ordinary jsx with its preserved natives (SmartArt, OLE, an unmodeled
|
|
434
|
+
// chart) silently gone, and `conversionIntegrityErrors` /
|
|
435
|
+
// `conversionsMissingPreserveSource` cannot warn, because both iterate that
|
|
436
|
+
// same map. So ask per slide, where absence and unreadable are different
|
|
437
|
+
// answers, and stop on unreadable exactly as on a missing package: at
|
|
438
|
+
// delivery, unknown provenance is refused, never rendered past.
|
|
439
|
+
const integrity = [
|
|
440
|
+
...Object.keys(slides)
|
|
441
|
+
.filter((f) => conversionProvenance(deckDir, f) === undefined)
|
|
442
|
+
.sort()
|
|
443
|
+
.map(
|
|
444
|
+
(f) =>
|
|
445
|
+
`${f}: its ${CONVERSIONS_DIR}/${f.replace(/\.jsx$/, "")}.json exists but could not be read, ` +
|
|
446
|
+
`so whether this slide is converted is unknown — it would render as ordinary jsx and lose ` +
|
|
447
|
+
`any preserved native objects. Repair or delete that record, or re-run 'slidemind adopt' ` +
|
|
448
|
+
`for this deck.`,
|
|
449
|
+
),
|
|
450
|
+
...conversionIntegrityErrors(deckDir, conversions),
|
|
451
|
+
];
|
|
283
452
|
if (integrity.length > 0) {
|
|
284
453
|
throw new Error(
|
|
285
454
|
`conversion records are out of sync with the deck:\n ${integrity.join("\n ")}`,
|
|
@@ -328,6 +497,7 @@ export async function renderDeckToBase64(
|
|
|
328
497
|
sourceGuids,
|
|
329
498
|
conversions: hasConversions ? conversions : null,
|
|
330
499
|
reconcile,
|
|
500
|
+
imageIntent,
|
|
331
501
|
onProgress,
|
|
332
502
|
});
|
|
333
503
|
const attemptRender = async (etag) => {
|
|
@@ -346,7 +516,7 @@ export async function renderDeckToBase64(
|
|
|
346
516
|
}
|
|
347
517
|
const n = Array.isArray(captured?.slides) ? captured.slides.length : 0;
|
|
348
518
|
onProgress?.({ done: n, total: n, stage: "assembling" });
|
|
349
|
-
return translateDeck({ ...target, captured, nativeSlides, etag });
|
|
519
|
+
return translateDeck({ ...target, captured, nativeSlides, etag, imageIntent });
|
|
350
520
|
}
|
|
351
521
|
return serverRender(target, etag);
|
|
352
522
|
};
|
|
@@ -399,6 +569,13 @@ export async function renderDeckToBase64(
|
|
|
399
569
|
if (!result?.base64) {
|
|
400
570
|
throw new Error("render returned no pptx bytes");
|
|
401
571
|
}
|
|
572
|
+
// The image-obligation gate, on the ONE path every deliverable .pptx passes
|
|
573
|
+
// through and before a single byte is written or inserted. Two refusals, one
|
|
574
|
+
// rule: a verdict that failed, and a verdict that never came. The export gate
|
|
575
|
+
// next door may degrade to a note when the backend cannot answer; this one may
|
|
576
|
+
// not — "the picture check was skipped" and "the picture is on the slide" are
|
|
577
|
+
// not the same sentence, and only the second one is a built deck.
|
|
578
|
+
assertImageIntentSatisfied(imageIntent, result);
|
|
402
579
|
// Persist GUIDs the server minted for new slides (render path), with the
|
|
403
580
|
// jsx hash alongside so a later rename can still be reconciled to them.
|
|
404
581
|
if (result.sourceGuids) {
|
|
@@ -413,14 +590,17 @@ export async function renderDeckToBase64(
|
|
|
413
590
|
// and refuse the build when an edit needs human judgment. Deliberately BEFORE
|
|
414
591
|
// the bake/normalize below — on a refusal those bytes are thrown away anyway.
|
|
415
592
|
applyReconcileResponse({ deckDir, sent: reconcile, result });
|
|
416
|
-
// Make sure EVERY jsx slide carries source tags.
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
|
|
593
|
+
// Make sure EVERY jsx slide carries source tags. Needed when the server never
|
|
594
|
+
// saw the jsx: the translate path (local capture), or a backend too old to
|
|
595
|
+
// bake tags — either way it reports no sourceGuids. When the server DID bake
|
|
596
|
+
// (sourceGuids present), unzipping the whole deck here just to find TAG_GUID
|
|
597
|
+
// on every slide would be pure waste — EXCEPT for a deck carrying image
|
|
598
|
+
// obligations, whose provenance the server cannot write (the transport
|
|
599
|
+
// descriptor it holds carries no `provenance`, only this side's registry
|
|
600
|
+
// does). There the pass runs to top its meta up in place.
|
|
601
|
+
if (useLocalCapture || !result.sourceGuids || imageIntent) {
|
|
422
602
|
try {
|
|
423
|
-
const baked = ensureDeckSourceTags(deckDir, result.base64);
|
|
603
|
+
const baked = ensureDeckSourceTags(deckDir, result.base64, only);
|
|
424
604
|
if (baked) result = { ...result, base64: baked };
|
|
425
605
|
} catch {
|
|
426
606
|
/* best-effort — an unbaked deck still opens fine, it just isn't adoptable */
|
|
@@ -487,9 +667,12 @@ function mapCapturedSlide(fileName, cap, warnings) {
|
|
|
487
667
|
|
|
488
668
|
if (cap.previewDataUrl) {
|
|
489
669
|
// The serialised boxes ride along so view-draft can run the measured
|
|
490
|
-
// checks (fill band, content grid) the add-in's see_draft runs
|
|
491
|
-
|
|
492
|
-
|
|
670
|
+
// checks (fill band, content grid) the add-in's see_draft runs; the
|
|
671
|
+
// chrome text feeds its chrome rule. That rule quotes the text into a
|
|
672
|
+
// one-line verdict, so a title set on two lines reads as one.
|
|
673
|
+
const { root = null, stage = null, boxes = null, chromeText = null } = cap.render ?? {};
|
|
674
|
+
const chrome = chromeText?.map((f) => ({ ...f, text: String(f.text ?? "").replace(/\s+/g, " ") })) ?? null;
|
|
675
|
+
return { fileName, status: "ok", dataUri: cap.previewDataUrl, render: { root, stage, boxes, chromeText: chrome } };
|
|
493
676
|
}
|
|
494
677
|
// A degraded render is a non-editable raster; push/build-pptx will REJECT it.
|
|
495
678
|
// Attach it anyway (so the agent isn't blind) but flag the named cause.
|
|
@@ -647,12 +830,17 @@ export async function viewDraft(
|
|
|
647
830
|
};
|
|
648
831
|
}
|
|
649
832
|
const slide = mapCapturedSlide(fileName, byFile.get(fileName), warnings);
|
|
650
|
-
if (conversions[fileName]
|
|
833
|
+
if (conversions[fileName]) {
|
|
834
|
+
// The measured draft checks are authored-slide only (the add-in's
|
|
835
|
+
// translate-deck exempts the converted dialect the same way).
|
|
836
|
+
slide.converted = true;
|
|
837
|
+
if (!slide.note) slide.note = convertedNote;
|
|
838
|
+
}
|
|
651
839
|
return slide;
|
|
652
840
|
});
|
|
653
841
|
|
|
654
842
|
// --translate: run the captured slides through the server's translate gate
|
|
655
|
-
// (slot-fit, diagram-geometry,
|
|
843
|
+
// (slot-fit, diagram-geometry, text escape — the checks the local
|
|
656
844
|
// capture cannot make) and hand its verdict back per slide. The returned
|
|
657
845
|
// .pptx is discarded: this is validation, not delivery, so no review gate
|
|
658
846
|
// stands in front of it and a per-slide builder can prove its ONE slide
|
|
@@ -671,7 +859,22 @@ export async function viewDraft(
|
|
|
671
859
|
let translateErrors = null;
|
|
672
860
|
let translateWarnings = [];
|
|
673
861
|
let translateRan = false;
|
|
862
|
+
let gateResult = null;
|
|
863
|
+
let draftIntent = null;
|
|
674
864
|
if (translate && gateDeck.slides.length) {
|
|
865
|
+
// The deck's image obligations, so the gate validates against what was
|
|
866
|
+
// approved and not against whatever the JSX happens to say. A draft never
|
|
867
|
+
// dies of one: a source that cannot be resolved comes back as a repair line
|
|
868
|
+
// below (that is what the draft is FOR), where the final build refuses on
|
|
869
|
+
// the same fact.
|
|
870
|
+
try {
|
|
871
|
+
draftIntent = buildImageIntentEnvelope(deckDir, {
|
|
872
|
+
slides: slideNumbersOf(gateDeck.slides.map((s) => s.fileName)),
|
|
873
|
+
inline: false,
|
|
874
|
+
});
|
|
875
|
+
} catch {
|
|
876
|
+
draftIntent = null;
|
|
877
|
+
}
|
|
675
878
|
const skip = (why) => {
|
|
676
879
|
if (translate !== "auto") throw new Error(why);
|
|
677
880
|
warnings.push(`export gate skipped: ${why}`);
|
|
@@ -693,10 +896,12 @@ export async function viewDraft(
|
|
|
693
896
|
const res = await translateDeck({
|
|
694
897
|
brandId: ref.presFP,
|
|
695
898
|
backendURL: mat.backendURL,
|
|
696
|
-
deckName:
|
|
899
|
+
deckName: deckSlug(deckDir),
|
|
697
900
|
captured: gateDeck,
|
|
698
901
|
etag: mat.etag,
|
|
902
|
+
imageIntent: draftIntent,
|
|
699
903
|
});
|
|
904
|
+
gateResult = res;
|
|
700
905
|
translateErrors = new Map();
|
|
701
906
|
translateWarnings = Array.isArray(res?.warnings) ? res.warnings : [];
|
|
702
907
|
translateRan = true;
|
|
@@ -719,6 +924,26 @@ export async function viewDraft(
|
|
|
719
924
|
}
|
|
720
925
|
if (translateErrors?.has("")) warnings.push(...translateErrors.get(""));
|
|
721
926
|
|
|
927
|
+
// Image obligations, draft flavour: the local half always (is the source
|
|
928
|
+
// still there, did the captured render actually place it), plus the server's
|
|
929
|
+
// own verdict when the export gate ran and carried the expectations. Reported
|
|
930
|
+
// per slide so the agent can repair from the preview it is looking at — the
|
|
931
|
+
// refusal lives in the build path, not here.
|
|
932
|
+
const intentLines = draftImageIntentLines(deckDir, slides);
|
|
933
|
+
if (draftIntent && translateRan && gateResult) {
|
|
934
|
+
const verdict = imageIntentVerdict(draftIntent, gateResult);
|
|
935
|
+
for (const issue of verdict.issues) {
|
|
936
|
+
const key = issue.file ?? "";
|
|
937
|
+
if (!intentLines.has(key)) intentLines.set(key, []);
|
|
938
|
+
intentLines.get(key).push(formatIssue(issue));
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
for (const slide of slides) {
|
|
942
|
+
const lines = intentLines.get(slide.fileName);
|
|
943
|
+
if (lines?.length) slide.imageIntentIssues = lines;
|
|
944
|
+
}
|
|
945
|
+
if (intentLines.has("")) warnings.push(...intentLines.get(""));
|
|
946
|
+
|
|
722
947
|
return { slides, warnings, translateWarnings, translateRan };
|
|
723
948
|
}
|
|
724
949
|
|
|
@@ -743,7 +968,7 @@ function isBrandRouteMiss(err) {
|
|
|
743
968
|
// involved. Returns null when the brand route can't serve this deck (caller
|
|
744
969
|
// falls back to the pane flow); render failures (lint, stale-after-retry)
|
|
745
970
|
// propagate, they are real.
|
|
746
|
-
async function tryDetachedRender(deckDir, deckRef, onProgress, reconcile = null) {
|
|
971
|
+
async function tryDetachedRender(deckDir, deckRef, onProgress, reconcile = null, only = null) {
|
|
747
972
|
const brandId = deckRef.presFP;
|
|
748
973
|
let backendURL;
|
|
749
974
|
try {
|
|
@@ -760,6 +985,7 @@ async function tryDetachedRender(deckDir, deckRef, onProgress, reconcile = null)
|
|
|
760
985
|
backendURL,
|
|
761
986
|
reconcile,
|
|
762
987
|
onProgress,
|
|
988
|
+
only,
|
|
763
989
|
});
|
|
764
990
|
return { result, backendURL };
|
|
765
991
|
}
|
|
@@ -770,6 +996,7 @@ async function renderWithRetarget(
|
|
|
770
996
|
onProgress,
|
|
771
997
|
registry = [],
|
|
772
998
|
reconcile = null,
|
|
999
|
+
only = null,
|
|
773
1000
|
) {
|
|
774
1001
|
const run = (target) =>
|
|
775
1002
|
renderDeckToBase64(deckDir, {
|
|
@@ -777,6 +1004,7 @@ async function renderWithRetarget(
|
|
|
777
1004
|
backendURL: target.backendURL ?? null,
|
|
778
1005
|
reconcile,
|
|
779
1006
|
onProgress,
|
|
1007
|
+
only,
|
|
780
1008
|
});
|
|
781
1009
|
try {
|
|
782
1010
|
return { entry, result: await run(entry) };
|
|
@@ -903,16 +1131,20 @@ export async function buildAndPush({
|
|
|
903
1131
|
// `--replace` wipes the presentation and re-pushes the built deck: for a
|
|
904
1132
|
// converted deck that IS the first replacement of the user's original slides.
|
|
905
1133
|
// A slide the server gated has not been confirmed for that, so the destructive
|
|
906
|
-
// half stops here and asks.
|
|
907
|
-
//
|
|
1134
|
+
// half stops here and asks. The confirmation is the USER's, as in the add-in,
|
|
1135
|
+
// where the same gated route opens the preview review: the agent asks and
|
|
1136
|
+
// passes --accept-conversions only on their go-ahead. Insert mode leaves the
|
|
1137
|
+
// originals alone, so it only warns (the gate lines are already on
|
|
1138
|
+
// `result.warnings`).
|
|
908
1139
|
const gatedConversions = result.gatedConversions ?? [];
|
|
909
1140
|
if (replace && gatedConversions.length > 0 && !acceptConversions) {
|
|
910
1141
|
const err = new Error(
|
|
911
1142
|
`${gatedConversions.length} converted slide(s) are not confirmed for replacement — ` +
|
|
912
|
-
`--replace would overwrite the originals with
|
|
1143
|
+
`--replace would overwrite the originals with a conversion nobody reviewed.\n` +
|
|
913
1144
|
gatedConversions.map((g) => ` ${g.note}`).join("\n") +
|
|
914
|
-
`\
|
|
915
|
-
|
|
1145
|
+
`\nThis is the user's call, not a retry: name these slides to the user and re-run with ` +
|
|
1146
|
+
`--accept-conversions only after they agree, or push without --replace to insert them ` +
|
|
1147
|
+
`and leave the originals in place.`,
|
|
916
1148
|
);
|
|
917
1149
|
err.code = "conversions_not_confirmed";
|
|
918
1150
|
err.gatedConversions = gatedConversions;
|
|
@@ -928,15 +1160,15 @@ export async function buildAndPush({
|
|
|
928
1160
|
// find one — save the .pptx next to the deck and tell the caller (bin prints
|
|
929
1161
|
// the recovery instructions).
|
|
930
1162
|
const saveFallback = (presIdForResult) => {
|
|
931
|
-
let fallbackFile =
|
|
1163
|
+
let fallbackFile = deckPptxPath(deckDir);
|
|
932
1164
|
// This save is the ONLY copy of a render nobody could take delivery of, so
|
|
933
1165
|
// it must land somewhere — it can never be the thing that destroys manual
|
|
934
1166
|
// edits, and it can never refuse either. Edited sibling ⇒ write next to it.
|
|
935
1167
|
if (detectManualEdits(fallbackFile).length > 0) {
|
|
936
1168
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
937
1169
|
fallbackFile = path.join(
|
|
938
|
-
deckDir,
|
|
939
|
-
`${
|
|
1170
|
+
deckRoot(deckDir),
|
|
1171
|
+
`${deckSlug(deckDir)}-${stamp}.pptx`,
|
|
940
1172
|
);
|
|
941
1173
|
}
|
|
942
1174
|
fs.writeFileSync(fallbackFile, Buffer.from(base64, "base64"));
|
|
@@ -1064,9 +1296,9 @@ export async function buildAndPush({
|
|
|
1064
1296
|
};
|
|
1065
1297
|
}
|
|
1066
1298
|
|
|
1067
|
-
//
|
|
1068
|
-
//
|
|
1069
|
-
//
|
|
1299
|
+
// Browser source previews after build-pptx. These are NOT a render of the
|
|
1300
|
+
// emitted package; writePackagePreview separately inspects those exact bytes.
|
|
1301
|
+
// Best-effort — a
|
|
1070
1302
|
// missing engine or a Chrome that won't launch returns a note, never a failure:
|
|
1071
1303
|
// the .pptx is already written.
|
|
1072
1304
|
export async function writeBuildPreview(deckDir, onProgress = null) {
|
|
@@ -1077,7 +1309,7 @@ export async function writeBuildPreview(deckDir, onProgress = null) {
|
|
|
1077
1309
|
files: [],
|
|
1078
1310
|
note:
|
|
1079
1311
|
"visual check skipped — the deck's bundle carries no capture engine " +
|
|
1080
|
-
`(run 'slidemind download-bundle ${
|
|
1312
|
+
`(run 'slidemind download-bundle ${deckSlug(deckDir)}')`,
|
|
1081
1313
|
};
|
|
1082
1314
|
}
|
|
1083
1315
|
let captured;
|
|
@@ -1090,7 +1322,7 @@ export async function writeBuildPreview(deckDir, onProgress = null) {
|
|
|
1090
1322
|
note: `visual check skipped — local capture failed (${err instanceof Error ? err.message : String(err)})`,
|
|
1091
1323
|
};
|
|
1092
1324
|
}
|
|
1093
|
-
const dir =
|
|
1325
|
+
const dir = previewPath(deckDir, "build");
|
|
1094
1326
|
fs.mkdirSync(dir, { recursive: true });
|
|
1095
1327
|
const files = [];
|
|
1096
1328
|
for (const slide of captured?.slides ?? []) {
|
|
@@ -1103,7 +1335,161 @@ export async function writeBuildPreview(deckDir, onProgress = null) {
|
|
|
1103
1335
|
fs.writeFileSync(file, Buffer.from(b64, "base64"));
|
|
1104
1336
|
files.push(file);
|
|
1105
1337
|
}
|
|
1106
|
-
return { dir, files, note:
|
|
1338
|
+
return { kind: "browser", status: "source-only", dir, files, note: "Browser source preview; not a render of the exported PPTX." };
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
// ---------------------------------------------------------------------------
|
|
1343
|
+
// targeted assembly — a deck that HAS an accepted base
|
|
1344
|
+
// ---------------------------------------------------------------------------
|
|
1345
|
+
|
|
1346
|
+
// GUID per slide FILE. An adopted deck's authority is its revision record (it
|
|
1347
|
+
// carries the native slides too, which the jsx-only GUID sidecar cannot); the
|
|
1348
|
+
// sidecar fills in for slides authored since.
|
|
1349
|
+
function guidsByFile(deckDir, revision) {
|
|
1350
|
+
const byFile = new Map();
|
|
1351
|
+
for (const [key, guid] of Object.entries(reconcileSlideGuids(deckDir))) {
|
|
1352
|
+
byFile.set(key.replace(/^slides\//, ""), guid);
|
|
1353
|
+
}
|
|
1354
|
+
for (const slide of revision?.base?.slides ?? []) {
|
|
1355
|
+
if (slide.file && slide.guid && !byFile.has(slide.file)) byFile.set(slide.file, slide.guid);
|
|
1356
|
+
}
|
|
1357
|
+
return byFile;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Build the next deck by applying only what changed to the accepted base.
|
|
1362
|
+
*
|
|
1363
|
+
* Returns null when this deck has no base (nothing to be targeted about — the
|
|
1364
|
+
* caller renders the whole deck as before). Otherwise `{ base64, report,
|
|
1365
|
+
* candidate, rendered }`, where `rendered` is the slide files that actually
|
|
1366
|
+
* went to the renderer.
|
|
1367
|
+
*
|
|
1368
|
+
* Three properties this exists for, none of which whole-deck rendering has:
|
|
1369
|
+
* • two changed slides in a forty-slide deck cost two slide renders;
|
|
1370
|
+
* • an order-only change costs NONE — nothing is re-rendered at all;
|
|
1371
|
+
* • every part the change does not touch keeps its exact bytes, which is the
|
|
1372
|
+
* only way notes, comments, animations, embeddings and native objects
|
|
1373
|
+
* survive a deck slidemind did not author.
|
|
1374
|
+
*/
|
|
1375
|
+
function renderSetOf(dirty, files, refreshDesign) {
|
|
1376
|
+
const changed = new Set([...dirty.dirtySlides, ...dirty.added]);
|
|
1377
|
+
if (refreshDesign) for (const f of files) changed.add(f);
|
|
1378
|
+
return changed;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* The slide files `build-pptx` will render: every slide of a deck without an
|
|
1383
|
+
* accepted base, otherwise exactly `buildAgainstBase`'s render set. The
|
|
1384
|
+
* delivery draft gate asks each of them for a current draft check.
|
|
1385
|
+
*/
|
|
1386
|
+
export function deliveredSlideFiles(deckDir, { refreshDesign = false } = {}) {
|
|
1387
|
+
const revision = readRevision(deckDir);
|
|
1388
|
+
const files = slideFileOrder(deckDir);
|
|
1389
|
+
if (!revision?.base || !fs.existsSync(acceptedBasePath(deckDir))) return files;
|
|
1390
|
+
return [...renderSetOf(computeDirty(deckDir, revision), files, refreshDesign)];
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
async function buildAgainstBase(deckDir, { render, onProgress, refreshDesign = false }) {
|
|
1394
|
+
const revision = readRevision(deckDir);
|
|
1395
|
+
const base = readAcceptedBase(deckDir);
|
|
1396
|
+
if (!revision?.base || !base) return null;
|
|
1397
|
+
|
|
1398
|
+
const dirty = computeDirty(deckDir, revision);
|
|
1399
|
+
const files = slideFileOrder(deckDir);
|
|
1400
|
+
const byFile = guidsByFile(deckDir, revision);
|
|
1401
|
+
// The render set is what the user changed — never more. A design that moved
|
|
1402
|
+
// under the deck is announced, and re-rendered only on --refresh-design.
|
|
1403
|
+
const changed = renderSetOf(dirty, files, refreshDesign);
|
|
1404
|
+
const designStale = dirty.sharedChanged && !refreshDesign ? files.filter((f) => !changed.has(f)) : [];
|
|
1405
|
+
process.stderr.write(
|
|
1406
|
+
` rendering ${changed.size} of ${files.length} slide(s)` +
|
|
1407
|
+
(changed.size ? `: ${[...changed].sort().join(", ")}` : " — nothing changed since the accepted base") +
|
|
1408
|
+
"\n",
|
|
1409
|
+
);
|
|
1410
|
+
if (designStale.length) {
|
|
1411
|
+
process.stderr.write(
|
|
1412
|
+
` note: the corporate design moved since the accepted base — ${designStale.length} untouched slide(s) ` +
|
|
1413
|
+
`keep the design they were last rendered with; \`slidemind build-pptx --refresh-design\` re-renders them\n`,
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
const { plan, render: toRender } = planFromDeck({
|
|
1417
|
+
slides: files,
|
|
1418
|
+
guidFor: (f) => byFile.get(f) ?? null,
|
|
1419
|
+
partFor: (f) => revision.base.slides.find((s) => s.file === f)?.part ?? null,
|
|
1420
|
+
dirty: changed,
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
let rendered = null;
|
|
1424
|
+
if (toRender.length > 0) {
|
|
1425
|
+
const result = await render(toRender);
|
|
1426
|
+
rendered = Buffer.from(result.base64, "base64");
|
|
1427
|
+
} else if (onProgress) {
|
|
1428
|
+
onProgress({ stage: "assembling", done: 0, total: 0 });
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
const { bytes, report } = assembleDeck({ base, rendered, plan });
|
|
1432
|
+
if (!bytes) {
|
|
1433
|
+
const err = new Error(
|
|
1434
|
+
`the deck could not be assembled onto the file it was adopted from:\n ` +
|
|
1435
|
+
report.errors.join("\n ") +
|
|
1436
|
+
(report.blocked.length
|
|
1437
|
+
? `\nThese slides carry something a re-render cannot reproduce. Either leave them ` +
|
|
1438
|
+
`alone, or delete the slide and add a new one in its place.`
|
|
1439
|
+
: ""),
|
|
1440
|
+
);
|
|
1441
|
+
err.code = "assembly_blocked";
|
|
1442
|
+
err.report = report;
|
|
1443
|
+
throw err;
|
|
1444
|
+
}
|
|
1445
|
+
return {
|
|
1446
|
+
base64: Buffer.from(bytes).toString("base64"),
|
|
1447
|
+
report,
|
|
1448
|
+
rendered: toRender,
|
|
1449
|
+
plan,
|
|
1450
|
+
designStale,
|
|
1451
|
+
renderedAll: changed.size === files.length,
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function renderInputs(deckDir) {
|
|
1456
|
+
const inputs = materializedInputs(deckDir);
|
|
1457
|
+
for (const file of Object.keys(inputs.inputs)) {
|
|
1458
|
+
if (!file.startsWith("conversions/") || !file.endsWith(".json")) continue;
|
|
1459
|
+
// An unreadable record is NOT fatal here: the conversion gate below owns
|
|
1460
|
+
// that case and says something actionable about it. Throwing a raw
|
|
1461
|
+
// SyntaxError from the etag inputs would pre-empt it with a stack trace.
|
|
1462
|
+
let record;
|
|
1463
|
+
try {
|
|
1464
|
+
record = JSON.parse(fs.readFileSync(path.join(deckDir, file), "utf8"));
|
|
1465
|
+
} catch {
|
|
1466
|
+
continue;
|
|
1467
|
+
}
|
|
1468
|
+
// This is a render RESULT, stamped by this very request, not an input.
|
|
1469
|
+
delete record.firstReplace;
|
|
1470
|
+
inputs.inputs[file] = JSON.stringify(record);
|
|
1471
|
+
}
|
|
1472
|
+
return inputs;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function renderInputDigest(deckDir) {
|
|
1476
|
+
return inputDigest(renderInputs(deckDir));
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// Which inputs moved between two snapshots — printed when a render restarts,
|
|
1480
|
+
// because "inputs changed" without a name is a full re-render nobody can act on.
|
|
1481
|
+
function renderInputChanges(before, after) {
|
|
1482
|
+
const hash = (v) => createHash("sha256").update(typeof v === "string" ? v : Buffer.from(v)).digest("hex");
|
|
1483
|
+
const changed = [];
|
|
1484
|
+
for (const key of new Set([...Object.keys(before.inputs), ...Object.keys(after.inputs)])) {
|
|
1485
|
+
const a = before.inputs[key], b = after.inputs[key];
|
|
1486
|
+
if (a === undefined) changed.push(`${key} (added)`);
|
|
1487
|
+
else if (b === undefined) changed.push(`${key} (removed)`);
|
|
1488
|
+
else if (hash(a) !== hash(b)) changed.push(key);
|
|
1489
|
+
}
|
|
1490
|
+
if (JSON.stringify(before.order) !== JSON.stringify(after.order)) changed.push("slide order");
|
|
1491
|
+
if (before.shared?.design !== after.shared?.design) changed.push(`design bundle (${before.shared?.design} -> ${after.shared?.design})`);
|
|
1492
|
+
return changed;
|
|
1107
1493
|
}
|
|
1108
1494
|
|
|
1109
1495
|
// Render a deck to a .pptx file (the `slidemind build-pptx` path). For a BOUND
|
|
@@ -1116,11 +1502,12 @@ export async function buildPptxFile({
|
|
|
1116
1502
|
presentationId = null,
|
|
1117
1503
|
outPath = null,
|
|
1118
1504
|
force = false,
|
|
1505
|
+
refreshDesign = false,
|
|
1119
1506
|
onProgress = null,
|
|
1120
1507
|
}) {
|
|
1121
1508
|
if (!deckDir) throw new Error("buildPptxFile: deckDir is required");
|
|
1122
1509
|
const deckRef = readDeckBundleRef(deckDir);
|
|
1123
|
-
const out = outPath ||
|
|
1510
|
+
const out = outPath || deckPptxPath(deckDir);
|
|
1124
1511
|
|
|
1125
1512
|
// Before ANY render work: what did the user change by hand in the file this
|
|
1126
1513
|
// build is about to replace? An edit we cannot address refuses here and now
|
|
@@ -1133,41 +1520,129 @@ export async function buildPptxFile({
|
|
|
1133
1520
|
// the registry path — reading the design off an open presentation is the one
|
|
1134
1521
|
// thing a render legitimately needs a window for.
|
|
1135
1522
|
const brandId = presentationId ? null : await resolveWindowlessBrand(deckDir);
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1523
|
+
|
|
1524
|
+
// One render closure, whichever route answers — so the targeted path below
|
|
1525
|
+
// does not have to know which of the two it is riding on.
|
|
1526
|
+
let presId = null;
|
|
1527
|
+
let renderedWindowless = false;
|
|
1528
|
+
let lastResult = null;
|
|
1529
|
+
let previewTarget = null;
|
|
1530
|
+
const renderSubset = async (only) => {
|
|
1531
|
+
if (brandId) {
|
|
1532
|
+
const detached = await tryDetachedRender(
|
|
1533
|
+
deckDir,
|
|
1534
|
+
{ presFP: brandId },
|
|
1535
|
+
onProgress,
|
|
1536
|
+
reconcile,
|
|
1537
|
+
only,
|
|
1538
|
+
);
|
|
1539
|
+
if (detached) {
|
|
1540
|
+
renderedWindowless = true;
|
|
1541
|
+
lastResult = detached.result;
|
|
1542
|
+
previewTarget = { brandId, backendURL: detached.backendURL };
|
|
1543
|
+
return detached.result;
|
|
1544
|
+
}
|
|
1149
1545
|
}
|
|
1546
|
+
const registry = await requireRenderHost();
|
|
1547
|
+
const entry = pickPresentationEntry(registry, presentationId, {
|
|
1548
|
+
preferBrandId: deckRef?.presFP ?? null,
|
|
1549
|
+
});
|
|
1550
|
+
const { entry: renderEntry, result } = await renderWithRetarget(
|
|
1551
|
+
deckDir,
|
|
1552
|
+
entry,
|
|
1553
|
+
onProgress,
|
|
1554
|
+
[],
|
|
1555
|
+
reconcile,
|
|
1556
|
+
only,
|
|
1557
|
+
);
|
|
1558
|
+
presId = renderEntry.presId;
|
|
1559
|
+
previewTarget = { brandId: renderEntry.brandId ?? deckRef?.presFP, backendURL: renderEntry.backendURL };
|
|
1560
|
+
lastResult = result;
|
|
1561
|
+
return result;
|
|
1562
|
+
};
|
|
1563
|
+
|
|
1564
|
+
// A deck with an accepted base assembles onto it: only the changed slides
|
|
1565
|
+
// render, and everything the change does not touch keeps its exact bytes.
|
|
1566
|
+
// That whole stretch — read the base, work out the dirty set, render, assemble,
|
|
1567
|
+
// capture the candidate — runs under the deck's ownership, so an `adopt` or a
|
|
1568
|
+
// `rebase` cannot move the base out from under it halfway through. Under an
|
|
1569
|
+
// open edit session (SLIDEMIND_OWNER) it re-enters that session. Fresh decks
|
|
1570
|
+
// take the same lock and persist an unaccepted candidate for first publish.
|
|
1571
|
+
const held = acquireOwner(deckDir, { command: "build-pptx" });
|
|
1572
|
+
let targeted;
|
|
1573
|
+
let candidate = null;
|
|
1574
|
+
let base64;
|
|
1575
|
+
let result;
|
|
1576
|
+
let preview;
|
|
1577
|
+
let packagePreview;
|
|
1578
|
+
try {
|
|
1579
|
+
// A bundle refresh or source edit during rendering invalidates those bytes.
|
|
1580
|
+
// Restart once from current inputs; persistent churn refuses certification.
|
|
1581
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1582
|
+
const startingInputs = renderInputs(deckDir);
|
|
1583
|
+
targeted = await buildAgainstBase(deckDir, { render: renderSubset, onProgress, refreshDesign });
|
|
1584
|
+
result = targeted ?? (await renderSubset(null));
|
|
1585
|
+
const changed = renderInputChanges(startingInputs, renderInputs(deckDir));
|
|
1586
|
+
if (!changed.length) break;
|
|
1587
|
+
if (attempt === 1) throw new Error(`Deck inputs changed during rendering (${changed.join(", ")}); no candidate was certified. Build again from stable inputs.`);
|
|
1588
|
+
process.stderr.write(` inputs changed during the render (${changed.slice(0, 5).join(", ")}${changed.length > 5 ? `, +${changed.length - 5} more` : ""}); rendering again from the current inputs…\n`);
|
|
1589
|
+
}
|
|
1590
|
+
base64 = targeted ? targeted.base64 : result.base64;
|
|
1591
|
+
// The build proposes; it does not decide. The bytes are kept as an immutable
|
|
1592
|
+
// candidate with the evidence they were built from, the accepted base does
|
|
1593
|
+
// not move and localDirty does not clear — so edit → build → build keeps the
|
|
1594
|
+
// first edit and nothing is accepted that a human did not accept.
|
|
1595
|
+
{
|
|
1596
|
+
// The render's own package is normalized the way an assembled one is:
|
|
1597
|
+
// its design master lists layouts twice, which PowerPoint repairs.
|
|
1598
|
+
const files = unzip(Buffer.from(base64, "base64"));
|
|
1599
|
+
const fixed = normalizeMasters(files);
|
|
1600
|
+
if (Object.values(fixed).some(Boolean)) base64 = Buffer.from(zip(files)).toString("base64");
|
|
1601
|
+
const packageValidation = validatePackage(files);
|
|
1602
|
+
if (!packageValidation.ok) throw new Error(`Rendered package is invalid: ${packageValidation.errors.join('; ')}`);
|
|
1603
|
+
candidate = saveCandidate(deckDir, {
|
|
1604
|
+
bytes: Buffer.from(base64, "base64"),
|
|
1605
|
+
validation: {
|
|
1606
|
+
ok: true,
|
|
1607
|
+
errors: [],
|
|
1608
|
+
checks: ["opc-relationships", "content-types", "slide-identity"],
|
|
1609
|
+
},
|
|
1610
|
+
renderedAll: targeted ? targeted.renderedAll : true,
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
// The delivered file and its package render are one unit, so they stay
|
|
1614
|
+
// under the lock: a second build that wrote its bytes between them left a
|
|
1615
|
+
// file no render had verified (x97).
|
|
1616
|
+
fs.writeFileSync(out, Buffer.from(base64, "base64"));
|
|
1617
|
+
preview = await writeBuildPreview(deckDir, onProgress);
|
|
1618
|
+
packagePreview = await writePackagePreview({deckDir, bytes: Buffer.from(base64, "base64"), target: previewTarget, onProgress});
|
|
1619
|
+
} finally {
|
|
1620
|
+
held?.release();
|
|
1150
1621
|
}
|
|
1151
1622
|
|
|
1152
|
-
const registry = await requireRenderHost();
|
|
1153
|
-
const entry = pickPresentationEntry(registry, presentationId, {
|
|
1154
|
-
preferBrandId: deckRef?.presFP ?? null,
|
|
1155
|
-
});
|
|
1156
|
-
const { entry: renderEntry, result } = await renderWithRetarget(
|
|
1157
|
-
deckDir,
|
|
1158
|
-
entry,
|
|
1159
|
-
onProgress,
|
|
1160
|
-
[],
|
|
1161
|
-
reconcile,
|
|
1162
|
-
);
|
|
1163
|
-
const presId = renderEntry.presId;
|
|
1164
|
-
fs.writeFileSync(out, Buffer.from(result.base64, "base64"));
|
|
1165
1623
|
return {
|
|
1166
1624
|
outPath: out,
|
|
1167
1625
|
presId,
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1626
|
+
windowless: renderedWindowless && !presId ? true : undefined,
|
|
1627
|
+
slideCount: lastResult?.slideCount ?? null,
|
|
1628
|
+
warnings: [
|
|
1629
|
+
...(lastResult?.warnings ?? []),
|
|
1630
|
+
...(targeted?.report.warnings ?? []),
|
|
1631
|
+
],
|
|
1632
|
+
skippedSlides: lastResult?.skippedSlides ?? [],
|
|
1633
|
+
targeted: targeted
|
|
1634
|
+
? {
|
|
1635
|
+
rendered: targeted.rendered,
|
|
1636
|
+
designStale: targeted.designStale,
|
|
1637
|
+
kept: targeted.report.kept.length,
|
|
1638
|
+
replaced: targeted.report.replaced.length,
|
|
1639
|
+
inserted: targeted.report.inserted.length,
|
|
1640
|
+
deleted: targeted.report.deleted.length,
|
|
1641
|
+
reusedLayouts: targeted.report.reusedLayouts,
|
|
1642
|
+
}
|
|
1643
|
+
: null,
|
|
1644
|
+
candidate: candidate ? { candidateId: candidate.candidateId, outputSha256: candidate.outputSha256 } : null,
|
|
1645
|
+
preview,
|
|
1646
|
+
packagePreview,
|
|
1172
1647
|
};
|
|
1173
1648
|
}
|