slidemind 0.2.4 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +37 -8
- package/README.md +32 -2
- package/bin/slidemind.mjs +535 -122
- package/install.ps1 +70 -0
- package/install.sh +65 -0
- package/package.json +3 -1
- package/skills/create-slides/SKILL.md +30 -6
- package/skills/search/SKILL.md +3 -1
- package/src/bridge/build-and-push.mjs +418 -38
- package/src/bridge/capture.mjs +12 -1
- package/src/bridge/client.mjs +344 -26
- package/src/bridge/config.mjs +193 -1
- package/src/bridge/graph-auth.mjs +859 -0
- package/src/bridge/http-json.mjs +15 -6
- package/src/bridge/secrets.mjs +75 -0
- package/src/bridge/setup-claim.mjs +6 -0
- package/src/builders/build.mjs +3 -1
- package/src/builders/gate-server.mjs +156 -0
- package/src/builders/gate-session.js +355 -0
- package/src/builders/plan-overlay.js +507 -225
- package/src/builders/plan.mjs +169 -98
- package/src/builders/review-overlay.js +390 -302
- package/src/builders/review.mjs +136 -50
- package/src/lib/adopt.mjs +407 -0
- package/src/lib/conversions.mjs +263 -0
- package/src/lib/overwrite-guard.mjs +91 -0
- package/src/lib/reconcile-ack.mjs +46 -0
- package/src/lib/reconcile.mjs +306 -0
- package/src/lib/slide-guids.mjs +143 -0
- package/src/pptx/fill/engine.mjs +69 -1
- package/src/pptx/hidden-ole.mjs +22 -1
- package/src/pptx/slide-swap.mjs +23 -1
- package/src/pptx/tags.mjs +676 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
// `slidemind adopt <file.pptx>` — reconstruct a local deck workspace from a
|
|
2
|
+
// presentation's deck-embedded source tags (intern-slidemind
|
|
3
|
+
// docs/deck-embedded-source-spec.md §6.2) and, where the add-in offers it,
|
|
4
|
+
// from a deterministic conversion of the slides that carry no source at all.
|
|
5
|
+
//
|
|
6
|
+
// Three outcomes per slide, in order:
|
|
7
|
+
// 1. decodable SLIDEMIND source → slides/NN-<name>.jsx, byte-identical to
|
|
8
|
+
// what was authored, plus its embedded assets;
|
|
9
|
+
// 2. deterministically CONVERTED → slides/NN-<name>.jsx in the converted
|
|
10
|
+
// dialect + assets + a conversions/<stem>.json identity/evidence record
|
|
11
|
+
// (openspec `pptx-to-jsx-deterministic-reverse`, D9 — the parse runs
|
|
12
|
+
// server-side, this repo ships no parser);
|
|
13
|
+
// 3. everything else → native NN-reused-<id>.pptx (opaque,
|
|
14
|
+
// re-render-safe) exactly like engine reuse.
|
|
15
|
+
//
|
|
16
|
+
// The deck binds to the bundle recorded in the tags, and slide GUIDs persist in
|
|
17
|
+
// .slide-guids.json so a later `push`/`build-pptx` keeps every slide's identity.
|
|
18
|
+
//
|
|
19
|
+
// v1 is FILE decks only: converting a LIVE presentation needs a slide export
|
|
20
|
+
// and a tag read over the relay, and neither event exists — that flow belongs
|
|
21
|
+
// to the add-in (design D9).
|
|
22
|
+
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
|
|
27
|
+
import { unzip } from "../pptx/zip.mjs";
|
|
28
|
+
import { slideOrderPaths } from "../pptx/ooxml.mjs";
|
|
29
|
+
import { decodeSourceTags, mintSlideGuid, readSlideTags } from "../pptx/tags.mjs";
|
|
30
|
+
import { foldPickedSlides } from "../pptx/slide-swap.mjs";
|
|
31
|
+
import { readDeckBundleRef, writeDeckBundleRef } from "../bridge/bundle.mjs";
|
|
32
|
+
import { loadSlideGuids, saveSlideGuids, slideGuidEntry } from "./slide-guids.mjs";
|
|
33
|
+
import { writeConversionRecord } from "./conversions.mjs";
|
|
34
|
+
|
|
35
|
+
export const LIVE_DECK_UNSUPPORTED =
|
|
36
|
+
"conversion of live presentations requires the add-in path in v1 — " +
|
|
37
|
+
"`slidemind adopt` takes a .pptx FILE. Save the open deck to disk and adopt " +
|
|
38
|
+
"that file, or make the change in PowerPoint with the slidemind pane open.";
|
|
39
|
+
|
|
40
|
+
function pad2(n) {
|
|
41
|
+
return String(n).padStart(2, "0");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Slide names and asset names arrive from an UNTRUSTED file's tags (anyone can
|
|
45
|
+
// mail a crafted .pptx) and both end up in filesystem paths, so neither may
|
|
46
|
+
// carry a directory component or escape the deck dir (spec §10).
|
|
47
|
+
function safeName(raw) {
|
|
48
|
+
return path
|
|
49
|
+
.basename(String(raw ?? "").replace(/\\/g, "/"))
|
|
50
|
+
// Control chars and the reserved filename set — NOT a broad range (a
|
|
51
|
+
// `[ -<]` style range would swallow digits and eat the NN- prefix).
|
|
52
|
+
.replace(/[\u0000-\u001f<>:"|?*/]/g, "-")
|
|
53
|
+
.replace(/^\.+/, "")
|
|
54
|
+
.trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stemFor(metaName, index, taken) {
|
|
58
|
+
// meta.name usually already carries its NN- prefix ("03-market-overview");
|
|
59
|
+
// only prefix by play order when it doesn't.
|
|
60
|
+
const name = safeName(metaName) || `slide-${pad2(index + 1)}`;
|
|
61
|
+
const stem = /^\d/.test(name) ? name : `${pad2(index + 1)}-${name}`;
|
|
62
|
+
let candidate = stem;
|
|
63
|
+
let n = 2;
|
|
64
|
+
while (taken.has(candidate)) candidate = `${stem}-${n++}`;
|
|
65
|
+
taken.add(candidate);
|
|
66
|
+
return candidate;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Deck-embedded asset name → deck-local path ("assets/…" stays deck-relative).
|
|
70
|
+
// Returns null for anything that would land outside the deck's assets dir.
|
|
71
|
+
function assetPathFor(deckDir, assetName) {
|
|
72
|
+
const root = path.resolve(deckDir, "assets");
|
|
73
|
+
const rel = String(assetName ?? "")
|
|
74
|
+
.replace(/\\/g, "/")
|
|
75
|
+
.replace(/^assets\//, "");
|
|
76
|
+
const target = path.resolve(root, rel);
|
|
77
|
+
return target.startsWith(root + path.sep) ? target : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
81
|
+
|
|
82
|
+
// Write one adopted asset, resolving name collisions by CONTENT: two adopts
|
|
83
|
+
// into the same deck may both ship an assets/images/photo.png. Identical bytes
|
|
84
|
+
// reuse the existing file; different bytes land under the first free -N suffix
|
|
85
|
+
// (and the caller rewrites the slide's references), so an adopted slide never
|
|
86
|
+
// silently renders another deck's picture. Returns the deck-relative name
|
|
87
|
+
// actually used, or null when the name escapes the assets dir.
|
|
88
|
+
function placeAsset(deckDir, name, bytes, sha256) {
|
|
89
|
+
for (let n = 1; ; n++) {
|
|
90
|
+
const suffixed = name.replace(/(\.[^./]+)$/, `-${n}$1`);
|
|
91
|
+
const candidate = n === 1 ? name : suffixed !== name ? suffixed : `${name}-${n}`;
|
|
92
|
+
const target = assetPathFor(deckDir, candidate);
|
|
93
|
+
if (!target) return null;
|
|
94
|
+
if (!fs.existsSync(target)) {
|
|
95
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
96
|
+
fs.writeFileSync(target, bytes);
|
|
97
|
+
return candidate;
|
|
98
|
+
}
|
|
99
|
+
if (sha256Hex(fs.readFileSync(target)) === sha256) return candidate;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Read a source deck once: bytes, parts, and the decoded embedded source per
|
|
104
|
+
// slide (null = untagged). Shared by the sync adopt core and the async
|
|
105
|
+
// conversion step so a file is never tag-decoded on two different snapshots —
|
|
106
|
+
// `cmdAdopt` reads once and threads the result into both.
|
|
107
|
+
export function readSourceDeck(pptxPath) {
|
|
108
|
+
const buffer = fs.readFileSync(pptxPath);
|
|
109
|
+
const files = unzip(buffer);
|
|
110
|
+
const order = slideOrderPaths(files);
|
|
111
|
+
if (order.length === 0) throw new Error(`${pptxPath}: no slides found`);
|
|
112
|
+
const decodedByIndex = order.map((part) =>
|
|
113
|
+
decodeSourceTags(readSlideTags(files, part)),
|
|
114
|
+
);
|
|
115
|
+
return { buffer, files, order, decodedByIndex };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Route result -> per-slide-number map, flattened into the per-slide shape the
|
|
119
|
+
// rest of this file reads.
|
|
120
|
+
//
|
|
121
|
+
// The server names the slide it converted (`index`, 1-based play order in the
|
|
122
|
+
// uploaded package); an entry without one is DROPPED rather than joined by
|
|
123
|
+
// array position — a positional join would attach one slide's source to another
|
|
124
|
+
// slide's identity (design R11).
|
|
125
|
+
//
|
|
126
|
+
// The wire keeps identity and evidence in TOP-LEVEL maps keyed by the slide's
|
|
127
|
+
// jsx path (`sourceBaselines` / `conversionEvidence`), because that is the key
|
|
128
|
+
// the render route joins them on. `preserved[]` rides inside the baseline. This
|
|
129
|
+
// is the single place that key shape is unfolded; get it wrong and a converted
|
|
130
|
+
// slide silently loses its preserved natives (`conv.preserved` empty => no
|
|
131
|
+
// `conversions/<stem>.slide.pptx` is folded, and the render has nothing to
|
|
132
|
+
// splice back).
|
|
133
|
+
function convertedByNumber(converted) {
|
|
134
|
+
const baselines = converted?.sourceBaselines ?? {};
|
|
135
|
+
const evidence = converted?.conversionEvidence ?? {};
|
|
136
|
+
const map = new Map();
|
|
137
|
+
for (const slide of converted?.slides ?? []) {
|
|
138
|
+
const n = Number(slide?.index);
|
|
139
|
+
if (!Number.isInteger(n) || n < 1 || typeof slide.jsx !== "string") continue;
|
|
140
|
+
if (map.has(n)) continue;
|
|
141
|
+
const baseline = baselines[slide.path] ?? null;
|
|
142
|
+
map.set(n, {
|
|
143
|
+
jsx: slide.jsx,
|
|
144
|
+
// "slides/converted-slide1.jsx" -> "converted-slide1"; stemFor() adds the
|
|
145
|
+
// NN- play-order prefix.
|
|
146
|
+
name: slide.name || path.basename(String(slide.path ?? ""), ".jsx") || null,
|
|
147
|
+
guid: slide.guid ?? null,
|
|
148
|
+
state: slide.state ?? "needsReview",
|
|
149
|
+
part: slide.sourcePart ?? slide.part ?? null,
|
|
150
|
+
sourceBaseline: baseline,
|
|
151
|
+
conversionEvidence: evidence[slide.path] ?? null,
|
|
152
|
+
preserved: Array.isArray(baseline?.preserved) ? baseline.preserved : [],
|
|
153
|
+
warnings: Array.isArray(slide.warnings) ? slide.warnings : [],
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return map;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source = null }) {
|
|
160
|
+
const { buffer, order, decodedByIndex } = source ?? readSourceDeck(pptxPath);
|
|
161
|
+
const convertedSlides = convertedByNumber(converted);
|
|
162
|
+
|
|
163
|
+
const slidesDir = path.join(deckDir, "slides");
|
|
164
|
+
fs.mkdirSync(slidesDir, { recursive: true });
|
|
165
|
+
|
|
166
|
+
const taken = new Set(
|
|
167
|
+
fs.existsSync(slidesDir)
|
|
168
|
+
? fs.readdirSync(slidesDir).map((f) => f.replace(/\.(jsx|pptx)$/, ""))
|
|
169
|
+
: [],
|
|
170
|
+
);
|
|
171
|
+
const guids = {};
|
|
172
|
+
const adopted = [];
|
|
173
|
+
const convertedFiles = [];
|
|
174
|
+
const native = [];
|
|
175
|
+
let bundle = null;
|
|
176
|
+
|
|
177
|
+
// Fold in ONE pass: foldPickedSlides unzips and prunes the whole source
|
|
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).
|
|
183
|
+
const picks = [];
|
|
184
|
+
decodedByIndex.forEach((decoded, index) => {
|
|
185
|
+
if (decoded) return;
|
|
186
|
+
const conv = convertedSlides.get(index + 1);
|
|
187
|
+
if (!conv || conv.preserved?.length > 0) picks.push(index + 1);
|
|
188
|
+
});
|
|
189
|
+
const foldByPick = new Map(
|
|
190
|
+
(picks.length
|
|
191
|
+
? foldPickedSlides(buffer, picks, { sourceName: "adopted" })
|
|
192
|
+
: []
|
|
193
|
+
).map((fold) => [fold.selection, fold]),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
// Spec §6.3: two slides with the same GUID resolve by NEWEST META.pushedAt
|
|
197
|
+
// (an ND-mode edit leaves the untouched original as a frozen archive while
|
|
198
|
+
// its duplicate evolves — identity must follow the evolution). Ties (a
|
|
199
|
+
// manual duplicate carries byte-identical tags) keep the first slide in
|
|
200
|
+
// deck order. Every non-owner gets a fresh GUID so the deck never pushes
|
|
201
|
+
// two slides claiming to be the same slide. Mirrors hydrate-from-deck.ts.
|
|
202
|
+
const ownerIndexByGuid = new Map();
|
|
203
|
+
decodedByIndex.forEach((decoded, index) => {
|
|
204
|
+
if (!decoded) return;
|
|
205
|
+
const pushedAt = decoded.meta.pushedAt ?? "";
|
|
206
|
+
const current = ownerIndexByGuid.get(decoded.guid);
|
|
207
|
+
if (!current || pushedAt > current.pushedAt) {
|
|
208
|
+
ownerIndexByGuid.set(decoded.guid, { index, pushedAt });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
// Seeded from the deck's persisted sidecar: adopting into an EXISTING deck
|
|
212
|
+
// dedupes against the slides already there, or a re-adopt writes a second
|
|
213
|
+
// slide file claiming the first one's identity.
|
|
214
|
+
const seenGuids = new Set(Object.values(loadSlideGuids(deckDir)));
|
|
215
|
+
|
|
216
|
+
// Conversion-route assets (materialized media, preserved-object preview
|
|
217
|
+
// crops) land BEFORE the slide loop so a collision-renamed asset can be
|
|
218
|
+
// rewritten in the converted jsx. Names arrive from an UNTRUSTED package,
|
|
219
|
+
// so they pass the same escape guard as tag-embedded assets.
|
|
220
|
+
const convAssetRenames = new Map();
|
|
221
|
+
for (const [name, b64] of Object.entries(converted?.assets ?? {})) {
|
|
222
|
+
const bytes = Buffer.from(String(b64), "base64");
|
|
223
|
+
const placed = placeAsset(deckDir, name, bytes, sha256Hex(bytes));
|
|
224
|
+
if (placed && placed !== name) convAssetRenames.set(name, placed);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
decodedByIndex.forEach((decoded, index) => {
|
|
228
|
+
if (decoded) {
|
|
229
|
+
const stem = stemFor(decoded.meta.name, index, taken);
|
|
230
|
+
let jsx = decoded.jsx;
|
|
231
|
+
for (const asset of decoded.assets) {
|
|
232
|
+
const placed = placeAsset(deckDir, asset.name, asset.bytes, asset.sha256);
|
|
233
|
+
if (placed && placed !== asset.name) jsx = jsx.split(asset.name).join(placed);
|
|
234
|
+
}
|
|
235
|
+
fs.writeFileSync(path.join(slidesDir, `${stem}.jsx`), jsx, "utf8");
|
|
236
|
+
const owns = ownerIndexByGuid.get(decoded.guid)?.index === index;
|
|
237
|
+
const guid =
|
|
238
|
+
owns && !seenGuids.has(decoded.guid) ? decoded.guid : mintSlideGuid();
|
|
239
|
+
seenGuids.add(guid);
|
|
240
|
+
guids[`slides/${stem}.jsx`] = slideGuidEntry(guid, jsx);
|
|
241
|
+
bundle ??= decoded.meta.bundle ?? null;
|
|
242
|
+
adopted.push(`${stem}.jsx`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const fold = foldByPick.get(index + 1);
|
|
246
|
+
const conv = convertedSlides.get(index + 1);
|
|
247
|
+
if (conv) {
|
|
248
|
+
// Deterministically converted: editable jsx in the converted dialect,
|
|
249
|
+
// plus the identity/evidence record push threads back to the server.
|
|
250
|
+
const stem = stemFor(conv.name ?? `${pad2(index + 1)}-converted`, index, taken);
|
|
251
|
+
let jsx = conv.jsx;
|
|
252
|
+
for (const [from, to] of convAssetRenames) jsx = jsx.split(from).join(to);
|
|
253
|
+
fs.writeFileSync(path.join(slidesDir, `${stem}.jsx`), jsx, "utf8");
|
|
254
|
+
const guid = conv.guid && !seenGuids.has(conv.guid) ? conv.guid : mintSlideGuid();
|
|
255
|
+
seenGuids.add(guid);
|
|
256
|
+
guids[`slides/${stem}.jsx`] = slideGuidEntry(guid, jsx);
|
|
257
|
+
writeConversionRecord(
|
|
258
|
+
deckDir,
|
|
259
|
+
`${stem}.jsx`,
|
|
260
|
+
{
|
|
261
|
+
guid,
|
|
262
|
+
state: conv.state ?? "needsReview",
|
|
263
|
+
sourceBaseline: conv.sourceBaseline ?? null,
|
|
264
|
+
conversionEvidence: conv.conversionEvidence ?? null,
|
|
265
|
+
preserved: Array.isArray(conv.preserved) ? conv.preserved : [],
|
|
266
|
+
warnings: Array.isArray(conv.warnings) ? conv.warnings : [],
|
|
267
|
+
source: {
|
|
268
|
+
file: path.basename(pptxPath),
|
|
269
|
+
index: index + 1,
|
|
270
|
+
part: conv.part ?? order[index],
|
|
271
|
+
},
|
|
272
|
+
convertedAt: new Date().toISOString(),
|
|
273
|
+
},
|
|
274
|
+
fold?.bytes ?? null,
|
|
275
|
+
);
|
|
276
|
+
convertedFiles.push(`${stem}.jsx`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// No decodable source and no conversion → keep the slide verbatim as a
|
|
280
|
+
// native one-slide .pptx, spliced back by filename order on every future
|
|
281
|
+
// render.
|
|
282
|
+
if (!fold) return;
|
|
283
|
+
const stem = stemFor(`${pad2(index + 1)}-reused-adopted`, index, taken);
|
|
284
|
+
fs.writeFileSync(path.join(slidesDir, `${stem}.pptx`), fold.bytes);
|
|
285
|
+
native.push(`${stem}.pptx`);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (Object.keys(guids).length > 0) saveSlideGuids(deckDir, guids);
|
|
289
|
+
// A wholly foreign deck names no bundle in any tag, but conversion ran
|
|
290
|
+
// against a design — bind to that one so the next push renders pane-free
|
|
291
|
+
// (the etag self-heals on the first render).
|
|
292
|
+
if (!bundle && convertedFiles.length > 0 && converted?.brandId) {
|
|
293
|
+
bundle = { presFP: converted.brandId, etag: "" };
|
|
294
|
+
}
|
|
295
|
+
// Bind the deck to the bundle the tags name — but NEVER clobber an
|
|
296
|
+
// existing binding: adopting into an existing deck must not repoint every
|
|
297
|
+
// other slide's design at the incoming file's CD.
|
|
298
|
+
if (
|
|
299
|
+
bundle?.presFP &&
|
|
300
|
+
bundle.presFP !== "unknown" &&
|
|
301
|
+
!readDeckBundleRef(deckDir)?.presFP
|
|
302
|
+
) {
|
|
303
|
+
writeDeckBundleRef(deckDir, { presFP: bundle.presFP, etag: bundle.etag });
|
|
304
|
+
}
|
|
305
|
+
const planPath = path.join(deckDir, "plan.md");
|
|
306
|
+
if (!fs.existsSync(planPath)) {
|
|
307
|
+
fs.writeFileSync(
|
|
308
|
+
planPath,
|
|
309
|
+
`---\nskill: create-slides\n---\n\n# ${path.basename(deckDir)}\n\nAdopted from ${path.basename(pptxPath)}.\n`,
|
|
310
|
+
"utf8",
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
deckDir,
|
|
316
|
+
adopted,
|
|
317
|
+
converted: convertedFiles,
|
|
318
|
+
native,
|
|
319
|
+
bundle,
|
|
320
|
+
// Why a slide the server was asked to convert stayed native (addinOwned,
|
|
321
|
+
// nativeOnlyEnvelope, a parse failure) — the agent has to be able to say so.
|
|
322
|
+
skipped: Array.isArray(converted?.skipped) ? converted.skipped : [],
|
|
323
|
+
warnings: Array.isArray(converted?.warnings) ? converted.warnings : [],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Deterministically convert a FILE deck's untagged slides through the add-in's
|
|
329
|
+
* `POST /api/cli/brands/:brandId/convert-pptx` route. Returns the route result
|
|
330
|
+
* (`{ slides, assets, skipped, warnings }` + the `brandId` it ran against) to
|
|
331
|
+
* hand to {@link adoptDeckFromPptx}, or null when conversion is not available
|
|
332
|
+
* here — no design to convert against, no backend serving the route, or the
|
|
333
|
+
* route refused. A null result is not an error: every untagged slide then folds
|
|
334
|
+
* in as native .pptx exactly as before.
|
|
335
|
+
*
|
|
336
|
+
* `note` receives one human line per degradation; the caller prints it.
|
|
337
|
+
*/
|
|
338
|
+
export async function convertUntaggedSlides({
|
|
339
|
+
pptxPath,
|
|
340
|
+
deckDir,
|
|
341
|
+
brandId = null,
|
|
342
|
+
note = () => {},
|
|
343
|
+
source = null,
|
|
344
|
+
}) {
|
|
345
|
+
const { buffer, decodedByIndex } = source ?? readSourceDeck(pptxPath);
|
|
346
|
+
const slides = [];
|
|
347
|
+
decodedByIndex.forEach((decoded, i) => {
|
|
348
|
+
if (!decoded) slides.push(i + 1);
|
|
349
|
+
});
|
|
350
|
+
if (slides.length === 0) return null;
|
|
351
|
+
|
|
352
|
+
const client = await import("../bridge/client.mjs");
|
|
353
|
+
const brand =
|
|
354
|
+
brandId ??
|
|
355
|
+
readDeckBundleRef(deckDir)?.presFP ??
|
|
356
|
+
decodedByIndex.find((d) => d?.meta?.bundle?.presFP)?.meta.bundle.presFP ??
|
|
357
|
+
null;
|
|
358
|
+
let resolved = brand && brand !== "unknown" ? brand : null;
|
|
359
|
+
let backendURL = null;
|
|
360
|
+
if (!resolved) {
|
|
361
|
+
// No design anywhere: adopt one only when the answer is unambiguous.
|
|
362
|
+
let brands = [];
|
|
363
|
+
try {
|
|
364
|
+
brands = await client.listBrands();
|
|
365
|
+
} catch {
|
|
366
|
+
brands = [];
|
|
367
|
+
}
|
|
368
|
+
if (brands.length !== 1) {
|
|
369
|
+
note(
|
|
370
|
+
brands.length === 0
|
|
371
|
+
? "no corporate design available to convert against — untagged slides stay native"
|
|
372
|
+
: "several corporate designs available — pass `--brand <presFP>` to convert the untagged slides",
|
|
373
|
+
);
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
resolved = brands[0].presFP;
|
|
377
|
+
backendURL = brands[0].backendURL ?? null;
|
|
378
|
+
// Routing the upload straight at that backend skips the fan-out's
|
|
379
|
+
// capability probe — do it here, so a whole .pptx is never shipped to a
|
|
380
|
+
// backend that will just 404 the route.
|
|
381
|
+
if (backendURL && !(await client.backendSupportsConvertPptx(backendURL))) {
|
|
382
|
+
note("this add-in does not convert foreign slides — untagged slides stay native");
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const result = await client.convertPptxForBrand(resolved, {
|
|
389
|
+
bytes: buffer,
|
|
390
|
+
fileName: path.basename(pptxPath),
|
|
391
|
+
slides,
|
|
392
|
+
backendURL,
|
|
393
|
+
});
|
|
394
|
+
if (result?.ok === false) {
|
|
395
|
+
note("the add-in declined to convert this file — untagged slides stay native");
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
return { ...result, brandId: resolved };
|
|
399
|
+
} catch (err) {
|
|
400
|
+
note(
|
|
401
|
+
err?.code === "convert_route_unavailable"
|
|
402
|
+
? "this add-in does not convert foreign slides — untagged slides stay native"
|
|
403
|
+
: `slide conversion unavailable (${err?.message ?? err}) — untagged slides stay native`,
|
|
404
|
+
);
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Deck-dir conversion metadata — the durable half of `slidemind adopt`'s
|
|
2
|
+
// reverse conversion (intern-slidemind openspec change
|
|
3
|
+
// `pptx-to-jsx-deterministic-reverse`, design D6/D9).
|
|
4
|
+
//
|
|
5
|
+
// A slide the add-in's `POST /api/cli/brands/:brandId/convert-pptx` route
|
|
6
|
+
// converted lands as ordinary deck source (`slides/NN-name.jsx` + `assets/…`),
|
|
7
|
+
// but its IDENTITY and its FIDELITY PROOF are not derivable from that jsx:
|
|
8
|
+
//
|
|
9
|
+
// • sourceBaseline — what the source slide WAS (projection, canvas,
|
|
10
|
+
// fingerprint, envelope class). The drift baseline.
|
|
11
|
+
// • conversionEvidence — what the UNEDITED generated jsx rendered to.
|
|
12
|
+
// • preserved[] — the opaque natives (chart/OLE/SmartArt) that must
|
|
13
|
+
// be spliced back verbatim on every render.
|
|
14
|
+
//
|
|
15
|
+
// So each converted slide keeps a compact `conversions/<stem>.json` next to the
|
|
16
|
+
// deck, and — ONLY when it carries preserved objects — the verbatim one-slide
|
|
17
|
+
// source package `conversions/<stem>.slide.pptx` that the server re-parses to
|
|
18
|
+
// splice those natives back in. Bulky, re-derivable packages stay out of the
|
|
19
|
+
// record for every other slide (D6).
|
|
20
|
+
//
|
|
21
|
+
// `slidemind push` / `build-pptx` reads both back and threads them onto the
|
|
22
|
+
// render request; nothing here talks to the network.
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
|
|
27
|
+
/** Deck-relative home of every conversion artifact. */
|
|
28
|
+
export const CONVERSIONS_DIR = "conversions";
|
|
29
|
+
|
|
30
|
+
/** Record format version — bumped when the on-disk shape changes. */
|
|
31
|
+
export const CONVERSION_RECORD_VERSION = 1;
|
|
32
|
+
|
|
33
|
+
function conversionsDir(deckDir) {
|
|
34
|
+
return path.join(deckDir, CONVERSIONS_DIR);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Records are keyed by the slide's FILE NAME ("03-market.jsx"), the same key
|
|
38
|
+
// the render body's `slides` map uses, so the server joins the two without a
|
|
39
|
+
// second convention. basename() keeps a crafted record name inside the dir.
|
|
40
|
+
function stemOf(slideFileName) {
|
|
41
|
+
return path.basename(String(slideFileName)).replace(/\.jsx$/i, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
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.
|
|
47
|
+
*/
|
|
48
|
+
export function writeConversionRecord(
|
|
49
|
+
deckDir,
|
|
50
|
+
slideFileName,
|
|
51
|
+
record,
|
|
52
|
+
sourcePackage = null,
|
|
53
|
+
) {
|
|
54
|
+
const stem = stemOf(slideFileName);
|
|
55
|
+
const dir = conversionsDir(deckDir);
|
|
56
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
57
|
+
const packageName =
|
|
58
|
+
sourcePackage && record.preserved?.length ? `${stem}.slide.pptx` : null;
|
|
59
|
+
if (packageName) {
|
|
60
|
+
fs.writeFileSync(path.join(dir, packageName), Buffer.from(sourcePackage));
|
|
61
|
+
}
|
|
62
|
+
const full = {
|
|
63
|
+
v: CONVERSION_RECORD_VERSION,
|
|
64
|
+
slide: `slides/${stem}.jsx`,
|
|
65
|
+
dialect: "converted",
|
|
66
|
+
...record,
|
|
67
|
+
source: { ...(record.source ?? {}), package: packageName },
|
|
68
|
+
};
|
|
69
|
+
fs.writeFileSync(
|
|
70
|
+
path.join(dir, `${stem}.json`),
|
|
71
|
+
`${JSON.stringify(full, null, 2)}\n`,
|
|
72
|
+
"utf8",
|
|
73
|
+
);
|
|
74
|
+
return full;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every conversion record of a deck, keyed by slide file name, in the shape the
|
|
79
|
+
* render request carries: identity + evidence + the base64 preserve source.
|
|
80
|
+
*
|
|
81
|
+
* A record whose `slides/<stem>.jsx` no longer exists is skipped — the slide was
|
|
82
|
+
* deleted or renamed, and shipping its baseline would bind conversion identity
|
|
83
|
+
* to a slide that is no longer in the deck. Unreadable/corrupt records are
|
|
84
|
+
* skipped too: a converted slide then renders as ordinary jsx (its preserved
|
|
85
|
+
* natives are lost, which the caller warns about) rather than failing the push.
|
|
86
|
+
*
|
|
87
|
+
* `includeSource: false` skips reading the preserve packages — for callers that
|
|
88
|
+
* only need to know WHICH slides are converted (they can be megabytes).
|
|
89
|
+
*/
|
|
90
|
+
export function loadConversions(deckDir, { includeSource = true } = {}) {
|
|
91
|
+
const dir = conversionsDir(deckDir);
|
|
92
|
+
if (!fs.existsSync(dir)) return {};
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const name of fs.readdirSync(dir)) {
|
|
95
|
+
if (!name.endsWith(".json")) continue;
|
|
96
|
+
const stem = name.replace(/\.json$/, "");
|
|
97
|
+
if (!fs.existsSync(path.join(deckDir, "slides", `${stem}.jsx`))) continue;
|
|
98
|
+
let record;
|
|
99
|
+
try {
|
|
100
|
+
record = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
101
|
+
} catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const entry = {
|
|
105
|
+
guid: record.guid ?? null,
|
|
106
|
+
state: record.state ?? "needsReview",
|
|
107
|
+
dialect: record.dialect ?? "converted",
|
|
108
|
+
sourceBaseline: record.sourceBaseline ?? null,
|
|
109
|
+
conversionEvidence: record.conversionEvidence ?? null,
|
|
110
|
+
preserved: Array.isArray(record.preserved) ? record.preserved : [],
|
|
111
|
+
};
|
|
112
|
+
// The preserve splice runs server-side against the ORIGINAL bytes: shape
|
|
113
|
+
// indices are re-derived from this package on every push, never persisted
|
|
114
|
+
// (design D4 — one replace reorders the slide's shape list).
|
|
115
|
+
const packageName = record.source?.package
|
|
116
|
+
? path.basename(record.source.package)
|
|
117
|
+
: null;
|
|
118
|
+
if (packageName && includeSource) {
|
|
119
|
+
const packagePath = path.join(dir, packageName);
|
|
120
|
+
if (fs.existsSync(packagePath)) {
|
|
121
|
+
entry.preserveSource = fs.readFileSync(packagePath).toString("base64");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
out[`${stem}.jsx`] = entry;
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Follow the deck's reconciled GUIDs: move every conversion record whose slide
|
|
131
|
+
* file was renamed (a reorder renames every slide — the NN- prefix IS the play
|
|
132
|
+
* order) to the file now carrying its GUID, and delete records whose GUID left
|
|
133
|
+
* the deck. Without this a renamed converted slide silently loses its record —
|
|
134
|
+
* the next render drops its preserved natives and `push --replace` sails past
|
|
135
|
+
* the first-replace gate — while the dead record's multi-MB preserve package
|
|
136
|
+
* sits on disk forever.
|
|
137
|
+
*
|
|
138
|
+
* `guidsByPath` MUST be fresh (the return of reconcileSlideGuids in the same
|
|
139
|
+
* flow); a stale map would read a mid-rename deck as a deletion. A record that
|
|
140
|
+
* cannot be parsed is left untouched — it may be a hand-edit gone wrong, and
|
|
141
|
+
* deleting it would take the preserve package with it.
|
|
142
|
+
*
|
|
143
|
+
* Returns one human-readable note per record moved or removed.
|
|
144
|
+
*/
|
|
145
|
+
export function rehomeConversions(deckDir, guidsByPath) {
|
|
146
|
+
const dir = conversionsDir(deckDir);
|
|
147
|
+
if (!fs.existsSync(dir)) return [];
|
|
148
|
+
const stemByGuid = new Map();
|
|
149
|
+
for (const [p, guid] of Object.entries(guidsByPath ?? {})) {
|
|
150
|
+
stemByGuid.set(guid, stemOf(p));
|
|
151
|
+
}
|
|
152
|
+
const notes = [];
|
|
153
|
+
for (const name of fs.readdirSync(dir)) {
|
|
154
|
+
if (!name.endsWith(".json")) continue;
|
|
155
|
+
const stem = name.replace(/\.json$/, "");
|
|
156
|
+
if (fs.existsSync(path.join(deckDir, "slides", `${stem}.jsx`))) continue;
|
|
157
|
+
let record;
|
|
158
|
+
try {
|
|
159
|
+
record = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
160
|
+
} catch {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const nextStem = record?.guid ? stemByGuid.get(record.guid) : undefined;
|
|
164
|
+
if (nextStem && !fs.existsSync(path.join(dir, `${nextStem}.json`))) {
|
|
165
|
+
record.slide = `slides/${nextStem}.jsx`;
|
|
166
|
+
if (record.source?.package) {
|
|
167
|
+
const oldPackage = path.join(dir, path.basename(record.source.package));
|
|
168
|
+
record.source.package = `${nextStem}.slide.pptx`;
|
|
169
|
+
if (fs.existsSync(oldPackage)) {
|
|
170
|
+
fs.renameSync(oldPackage, path.join(dir, record.source.package));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
fs.writeFileSync(
|
|
174
|
+
path.join(dir, `${nextStem}.json`),
|
|
175
|
+
`${JSON.stringify(record, null, 2)}\n`,
|
|
176
|
+
"utf8",
|
|
177
|
+
);
|
|
178
|
+
fs.rmSync(path.join(dir, name), { force: true });
|
|
179
|
+
notes.push(
|
|
180
|
+
`${stem}.jsx was renamed to ${nextStem}.jsx — moved its conversion record along`,
|
|
181
|
+
);
|
|
182
|
+
} else if (record?.guid && !nextStem) {
|
|
183
|
+
fs.rmSync(path.join(dir, name), { force: true });
|
|
184
|
+
if (record.source?.package) {
|
|
185
|
+
fs.rmSync(path.join(dir, path.basename(record.source.package)), { force: true });
|
|
186
|
+
}
|
|
187
|
+
notes.push(`${stem}.jsx left the deck — removed its conversion record`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return notes;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Apply the render response's first-replace gate outcome to the deck records.
|
|
195
|
+
*
|
|
196
|
+
* A FILE deck has no live slide, so the server cannot block: `gateConversions`
|
|
197
|
+
* renders anyway and reports every slide that is not cleared for a silent
|
|
198
|
+
* replace. That makes THIS the only place the outcome can become visible or
|
|
199
|
+
* durable — drop `conversionGates` here and an unreviewed conversion overwrites
|
|
200
|
+
* the user's original slides with no prompt and no warning line.
|
|
201
|
+
*
|
|
202
|
+
* Each record is stamped with the outcome (`firstReplace: {route, reasons, at}`)
|
|
203
|
+
* so a later run — and the user reading the deck dir — can see why a slide is
|
|
204
|
+
* still unconfirmed. The stamp deliberately does NOT touch `state`: `state` is
|
|
205
|
+
* what the server gates ON, and rewriting it here would either forge a review
|
|
206
|
+
* that never happened or re-gate a slide the server already cleared.
|
|
207
|
+
*
|
|
208
|
+
* Returns one `{ file, route, reasons, note }` per GATED slide — `note` is the
|
|
209
|
+
* user-facing line, in the caller's `warnings` vocabulary.
|
|
210
|
+
*/
|
|
211
|
+
export function applyConversionGates(deckDir, conversions, gates) {
|
|
212
|
+
const byFile = new Map(
|
|
213
|
+
(Array.isArray(gates) ? gates : [])
|
|
214
|
+
.filter((g) => g && typeof g.file === "string")
|
|
215
|
+
.map((g) => [path.basename(g.file), g]),
|
|
216
|
+
);
|
|
217
|
+
const gated = [];
|
|
218
|
+
for (const file of Object.keys(conversions).sort()) {
|
|
219
|
+
const gate = byFile.get(file) ?? null;
|
|
220
|
+
if (gate) {
|
|
221
|
+
const reasons =
|
|
222
|
+
Array.isArray(gate.reasons) && gate.reasons.length > 0
|
|
223
|
+
? gate.reasons
|
|
224
|
+
: [String(gate.route ?? "gated")];
|
|
225
|
+
gated.push({
|
|
226
|
+
file,
|
|
227
|
+
route: String(gate.route ?? "gated"),
|
|
228
|
+
reasons,
|
|
229
|
+
note:
|
|
230
|
+
`${file}: this converted slide was NOT confirmed for replacement ` +
|
|
231
|
+
`(${gate.route}: ${reasons.join(", ")}) — it is in the rendered deck, but pushing ` +
|
|
232
|
+
`it over the original is unreviewed. Compare it with the source slide, then set ` +
|
|
233
|
+
`"state": "verified" in ${CONVERSIONS_DIR}/${stemOf(file)}.json once it is right.`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
stampFirstReplace(deckDir, file, gate);
|
|
237
|
+
}
|
|
238
|
+
return gated;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Durable half of the gate outcome. Best-effort: a deck dir the user made
|
|
242
|
+
// read-only must not lose them a finished render.
|
|
243
|
+
function stampFirstReplace(deckDir, slideFileName, gate) {
|
|
244
|
+
const file = path.join(conversionsDir(deckDir), `${stemOf(slideFileName)}.json`);
|
|
245
|
+
try {
|
|
246
|
+
const record = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
247
|
+
record.firstReplace = {
|
|
248
|
+
route: gate ? String(gate.route ?? "gated") : "autoSilent",
|
|
249
|
+
reasons: gate && Array.isArray(gate.reasons) ? gate.reasons : [],
|
|
250
|
+
at: new Date().toISOString(),
|
|
251
|
+
};
|
|
252
|
+
fs.writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
253
|
+
} catch {
|
|
254
|
+
/* unreadable/unwritable record — the warning line still reached the user */
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Conversion records whose preserved natives have no source package to splice. */
|
|
259
|
+
export function conversionsMissingPreserveSource(conversions) {
|
|
260
|
+
return Object.entries(conversions)
|
|
261
|
+
.filter(([, c]) => c.preserved?.length > 0 && !c.preserveSource)
|
|
262
|
+
.map(([file]) => file);
|
|
263
|
+
}
|