slidemind 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +65 -15
- package/README.md +36 -2
- package/bin/slidemind.mjs +1609 -225
- package/install.ps1 +70 -0
- package/install.sh +65 -0
- package/package.json +3 -1
- package/skills/create-slides/SKILL.md +47 -19
- package/skills/search/SKILL.md +26 -18
- package/src/bridge/build-and-push.mjs +431 -38
- package/src/bridge/capture.mjs +12 -1
- package/src/bridge/client.mjs +1214 -107
- package/src/bridge/config.mjs +193 -1
- package/src/bridge/engine-download.mjs +61 -8
- 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/resolve-skill.mjs +64 -2
- 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 +181 -135
- 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,91 @@
|
|
|
1
|
+
// Overwrite guard: never silently clobber manual PowerPoint edits.
|
|
2
|
+
//
|
|
3
|
+
// `slidemind build-pptx` (and push's fallback save) writes its output file
|
|
4
|
+
// wholesale. If the user opened the last build in desktop PowerPoint and moved
|
|
5
|
+
// a shape, retyped a line, or recoloured a box, that work lives ONLY in that
|
|
6
|
+
// file — the deck's jsx knows nothing about it — and a rebuild would erase it
|
|
7
|
+
// with no warning. This module answers the one question that stands between a
|
|
8
|
+
// rebuild and that loss: has anything in the file changed since we wrote it?
|
|
9
|
+
//
|
|
10
|
+
// The answer needs no server and no diff engine (the CLI ships none, by
|
|
11
|
+
// design — intern-slidemind PLAN_CLI_RECONCILE.md §3): a slidemind-authored
|
|
12
|
+
// slide carries its own drift BASELINE in its tags, the structural projection
|
|
13
|
+
// P(slide) as it stood at bake time. Recomputing P over the file on disk and
|
|
14
|
+
// comparing the two IS the detection. What to do about a difference — fold the
|
|
15
|
+
// text edits back into the jsx, report the rest — is Phase B's server round
|
|
16
|
+
// trip; here an edited slide only means "refuse, and say which slide".
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
|
|
20
|
+
import { slideOrderPaths } from "../pptx/ooxml.mjs";
|
|
21
|
+
import {
|
|
22
|
+
TAG_GUID,
|
|
23
|
+
computeProjection,
|
|
24
|
+
decodeSourceTags,
|
|
25
|
+
readSlideTags,
|
|
26
|
+
} from "../pptx/tags.mjs";
|
|
27
|
+
import { unzip } from "../pptx/zip.mjs";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Slides of the .pptx at `outPath` that were edited since the CLI last wrote
|
|
31
|
+
* it. `[]` means the file is safe to overwrite — it is missing, it is not a
|
|
32
|
+
* package we can read, or every slide we can vouch for still matches its
|
|
33
|
+
* baseline. Entries are `{ slideIndex, slidePath, guid, baseline, current }`
|
|
34
|
+
* with `slideIndex` 1-based in PLAY order (the number PowerPoint shows the
|
|
35
|
+
* user). The two projections ride along because the server-side reconcile
|
|
36
|
+
* (Phase B) diffs exactly them — computing them twice would mean unzipping and
|
|
37
|
+
* re-parsing the whole package twice.
|
|
38
|
+
*/
|
|
39
|
+
export function detectManualEdits(outPath) {
|
|
40
|
+
if (!outPath || !fs.existsSync(outPath)) return [];
|
|
41
|
+
|
|
42
|
+
let files;
|
|
43
|
+
try {
|
|
44
|
+
files = unzip(fs.readFileSync(outPath));
|
|
45
|
+
} catch {
|
|
46
|
+
// Not a readable OPC package — nothing slidemind ever baked is in there to
|
|
47
|
+
// protect, and refusing here would block a build over any stray --out file.
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const edited = [];
|
|
52
|
+
slideOrderPaths(files).forEach((slidePath, i) => {
|
|
53
|
+
const tags = readSlideTags(files, slidePath);
|
|
54
|
+
const decoded = decodeSourceTags(tags);
|
|
55
|
+
// No decodable source (foreign or native-reused slide) or no baked
|
|
56
|
+
// baseline (pre-baseline bake): nothing to compare against. Skipping is
|
|
57
|
+
// the honest answer — an unverifiable slide is not evidence of an edit.
|
|
58
|
+
if (!decoded?.baseline) return;
|
|
59
|
+
// Both sides are produced by the SAME emitter, so key order matches and a
|
|
60
|
+
// string compare is a stable structural compare.
|
|
61
|
+
const current = computeProjection(files, slidePath);
|
|
62
|
+
if (JSON.stringify(current) === JSON.stringify(decoded.baseline)) return;
|
|
63
|
+
edited.push({
|
|
64
|
+
slideIndex: i + 1,
|
|
65
|
+
slidePath,
|
|
66
|
+
guid: tags[TAG_GUID] ?? null,
|
|
67
|
+
baseline: decoded.baseline,
|
|
68
|
+
current,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
return edited;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The refusal a manually-edited output file earns when nothing can be done for
|
|
75
|
+
// it here: the user's PowerPoint edits exist in that file ALONE, so the build
|
|
76
|
+
// stops and names the slides rather than overwriting them. Reachable only for
|
|
77
|
+
// slides the deck cannot address — one carrying no jsx of ours to fold into.
|
|
78
|
+
// Everything else goes through the server-side reconcile (Phase B).
|
|
79
|
+
export function manualEditsError(outFile, edited) {
|
|
80
|
+
const numbers = edited.map((e) => e.slideIndex).join(", ");
|
|
81
|
+
const err = new Error(
|
|
82
|
+
`${outFile} was edited in PowerPoint since the CLI last wrote it — ` +
|
|
83
|
+
`slide ${numbers} (${edited.length} of them) no longer match the source ` +
|
|
84
|
+
`this deck would render.\n` +
|
|
85
|
+
`Rebuilding would discard those edits: re-run with --force to discard ` +
|
|
86
|
+
`these manual edits, or use --out <other file> to keep them.`,
|
|
87
|
+
);
|
|
88
|
+
err.code = "manual_edits";
|
|
89
|
+
err.manualEdits = edited;
|
|
90
|
+
return err;
|
|
91
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Deck-local sidecar recording the last ACKNOWLEDGED drift hash per slide file
|
|
2
|
+
// (`{ "01-cover.jsx": "<nowHash>" }`), keyed exactly like the render request's
|
|
3
|
+
// `slides` and `reconcile` maps. File handling mirrors slide-guids.mjs.
|
|
4
|
+
//
|
|
5
|
+
// The ack is what keeps the reconcile loop finite. When the server reports a
|
|
6
|
+
// manual PowerPoint edit it cannot fold on its own (a moved shape), the build
|
|
7
|
+
// refuses and the hash of that slide's CURRENT state is stored here. The user
|
|
8
|
+
// folds the change into the jsx and re-runs: the output file has not changed,
|
|
9
|
+
// so the hash still matches, the server short-circuits its diff, and the build
|
|
10
|
+
// proceeds. Any NEW manual edit changes the hash and re-arms the report.
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
const SIDECAR = ".reconcile-ack.json";
|
|
16
|
+
|
|
17
|
+
/** { [fileName]: nowHash } — `{}` when the deck has no sidecar (or a broken one). */
|
|
18
|
+
export function loadReconcileAcks(deckDir) {
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(fs.readFileSync(path.join(deckDir, SIDECAR), "utf8"));
|
|
22
|
+
} catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const [file, hash] of Object.entries(parsed)) {
|
|
28
|
+
// basename: the key is a slide FILE NAME, never a path — a crafted one can
|
|
29
|
+
// then never travel out of this map into a write.
|
|
30
|
+
if (typeof hash === "string" && hash) out[path.basename(file)] = hash;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Merge acks into the sidecar (last write wins per file). No-op when empty. */
|
|
36
|
+
export function saveReconcileAcks(deckDir, acks) {
|
|
37
|
+
if (!acks || Object.keys(acks).length === 0) return;
|
|
38
|
+
const merged = loadReconcileAcks(deckDir);
|
|
39
|
+
for (const [file, hash] of Object.entries(acks)) {
|
|
40
|
+
if (typeof hash === "string" && hash) merged[path.basename(file)] = hash;
|
|
41
|
+
}
|
|
42
|
+
fs.writeFileSync(
|
|
43
|
+
path.join(deckDir, SIDECAR),
|
|
44
|
+
`${JSON.stringify(merged, null, 2)}\n`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// Manual-edit reconcile, CLI half (intern-slidemind PLAN_CLI_RECONCILE.md,
|
|
2
|
+
// Phase B2). Two functions around ONE server round trip:
|
|
3
|
+
//
|
|
4
|
+
// collectReconcileInputs — what the render request carries: for every slide
|
|
5
|
+
// the user edited in PowerPoint since the last
|
|
6
|
+
// build, its baked baseline projection and its
|
|
7
|
+
// current one, keyed by jsx file name. Reads the
|
|
8
|
+
// deck's previous OUTPUT FILE (`build-pptx`).
|
|
9
|
+
// collectLiveReconcileInputs — the same field, read from the LIVE deck via
|
|
10
|
+
// the pane's export op. What `push --replace` must
|
|
11
|
+
// use: it wipes what is on screen, not a file.
|
|
12
|
+
// applyReconcileResponse — what comes back: text edits already folded into
|
|
13
|
+
// the jsx (write them down), a hash to acknowledge,
|
|
14
|
+
// and the edits that need human judgment (refuse).
|
|
15
|
+
//
|
|
16
|
+
// The CLI ships NO diff and NO fold logic — same rule as lint and the exporter
|
|
17
|
+
// ("served on the wire", plan §3). It supplies projections and applies results;
|
|
18
|
+
// the server decides what changed and what that means.
|
|
19
|
+
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
|
|
23
|
+
import { computeProjection, decodeSourceTags } from "../pptx/tags.mjs";
|
|
24
|
+
import { unzip } from "../pptx/zip.mjs";
|
|
25
|
+
import { loadConversions } from "./conversions.mjs";
|
|
26
|
+
import { detectManualEdits, manualEditsError } from "./overwrite-guard.mjs";
|
|
27
|
+
import { loadReconcileAcks, saveReconcileAcks } from "./reconcile-ack.mjs";
|
|
28
|
+
import { reconcileSlideGuids } from "./slide-guids.mjs";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The render request's `reconcile` field for `deckDir`'s previous output at
|
|
32
|
+
* `outFile`, or null when there is nothing to reconcile. Throws the Phase A
|
|
33
|
+
* `manual_edits` refusal for an edited slide this deck cannot address.
|
|
34
|
+
*/
|
|
35
|
+
export function collectReconcileInputs({ deckDir, outFile, force = false }) {
|
|
36
|
+
// `--force` is the explicit "discard my edits" — no scan, no field, exactly
|
|
37
|
+
// the blind build the CLI did before any of this existed.
|
|
38
|
+
if (force) return null;
|
|
39
|
+
const edits = detectManualEdits(outFile);
|
|
40
|
+
if (edits.length === 0) return null;
|
|
41
|
+
|
|
42
|
+
// GUID → slide file name. reconcileSlideGuids, not loadSlideGuids: a reorder
|
|
43
|
+
// renames every file, so the raw sidecar can still be keyed by the old names
|
|
44
|
+
// — and the reconcile map must key by the names `slides` uses. It is
|
|
45
|
+
// idempotent, so the render's own call right after finds nothing to do.
|
|
46
|
+
const fileByGuid = new Map();
|
|
47
|
+
for (const [slidePath, guid] of Object.entries(reconcileSlideGuids(deckDir))) {
|
|
48
|
+
fileByGuid.set(guid, path.basename(slidePath));
|
|
49
|
+
}
|
|
50
|
+
// B3 exclusion — CONVERTED slides (`slidemind adopt`). Their drift protection
|
|
51
|
+
// is the conversion-gate machinery, which already holds a baseline of the
|
|
52
|
+
// ORIGINAL slide; driving a second reconcile from the render bake's baseline
|
|
53
|
+
// would have two mechanisms arguing over one slide. Matched by GUID, not file
|
|
54
|
+
// name, so a reorder cannot smuggle one back in before the records rehome.
|
|
55
|
+
const convertedGuids = new Set(
|
|
56
|
+
Object.values(loadConversions(deckDir, { includeSource: false }))
|
|
57
|
+
.map((record) => record.guid)
|
|
58
|
+
.filter(Boolean),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const acks = loadReconcileAcks(deckDir);
|
|
62
|
+
const reconcile = {};
|
|
63
|
+
const refusals = [];
|
|
64
|
+
for (const edit of edits) {
|
|
65
|
+
if (edit.guid && convertedGuids.has(edit.guid)) continue;
|
|
66
|
+
const fileName = edit.guid ? fileByGuid.get(edit.guid) : null;
|
|
67
|
+
// B3 exclusion — a slide with no jsx of ours behind it: a native reused
|
|
68
|
+
// .pptx, or a slide pushed by another deck. There is nothing to fold an
|
|
69
|
+
// edit INTO, so Phase A's refusal still governs it.
|
|
70
|
+
if (!fileName) {
|
|
71
|
+
refusals.push(edit);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
reconcile[fileName] = {
|
|
75
|
+
baseline: edit.baseline,
|
|
76
|
+
current: edit.current,
|
|
77
|
+
...(acks[fileName] ? { ack: acks[fileName] } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (refusals.length > 0) throw manualEditsError(outFile, refusals);
|
|
81
|
+
return Object.keys(reconcile).length > 0 ? reconcile : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Live deck (push --replace) — plan Phase C2
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
// One export round trip, with the two failure shapes that mean "this live deck
|
|
89
|
+
// cannot be inspected at all" mapped to a fail-closed refusal: a 404 (backend
|
|
90
|
+
// predates the route) and UNSUPPORTED_API (the pane's Office build cannot
|
|
91
|
+
// export). A --replace push must never proceed on faith — it deletes slides.
|
|
92
|
+
// Everything else (unknown presentation, wrong presentation, transport) stays
|
|
93
|
+
// its own error: the push aborts and nothing on screen is touched either way.
|
|
94
|
+
async function exportRound(exportSlides, guids) {
|
|
95
|
+
try {
|
|
96
|
+
return await exportSlides(guids);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
const reason =
|
|
99
|
+
err?.status === 404 && err?.code !== "unknown_presentation"
|
|
100
|
+
? "this add-in backend does not support export-slides"
|
|
101
|
+
: err?.code === "UNSUPPORTED_API"
|
|
102
|
+
? "this PowerPoint build cannot export slides"
|
|
103
|
+
: null;
|
|
104
|
+
if (!reason) throw err;
|
|
105
|
+
const failClosed = new Error(
|
|
106
|
+
`the live deck could not be inspected for manual PowerPoint edits ` +
|
|
107
|
+
`(${reason}) — update the slidemind add-in, or re-run with --force to ` +
|
|
108
|
+
`push anyway and discard any manual edits`,
|
|
109
|
+
);
|
|
110
|
+
failClosed.code = "reconcile_unsupported";
|
|
111
|
+
throw failClosed;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A whole-deck replace deletes every live slide it does not re-push. Slides the
|
|
116
|
+
// deck does not own — added by hand, by another tool, or a manual DUPLICATE of
|
|
117
|
+
// one of ours (same GUID twice: the copy lands here) — would go with them.
|
|
118
|
+
// v1 policy is to refuse and let the user decide (plan C2 step 4).
|
|
119
|
+
function liveDeckExtraSlidesError(unmatched) {
|
|
120
|
+
const numbers = unmatched
|
|
121
|
+
.map((s) => (Number.isFinite(s?.index) ? s.index + 1 : "?"))
|
|
122
|
+
.join(", ");
|
|
123
|
+
const err = new Error(
|
|
124
|
+
`the live deck contains ${unmatched.length} slide(s) not in this deck ` +
|
|
125
|
+
`(added manually or by another tool) — pass --force to discard them.\n` +
|
|
126
|
+
`Slide ${numbers} (live deck numbering) would be deleted by a whole-deck ` +
|
|
127
|
+
`replace; push without --replace to insert instead.`,
|
|
128
|
+
);
|
|
129
|
+
err.code = "live_deck_extra_slides";
|
|
130
|
+
err.unmatched = unmatched;
|
|
131
|
+
return err;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The render request's `reconcile` field built from the LIVE presentation:
|
|
136
|
+
* export the deck's slides through the pane, decode each one's baked baseline
|
|
137
|
+
* from its TAGS and project its exported BYTES for the current state.
|
|
138
|
+
* `guids` selects a subset (a `--slides` push); null means the whole deck.
|
|
139
|
+
* `exportSlides(guids)` is the pane ack producer — injected so this stays free
|
|
140
|
+
* of the bridge client (and testable without a relay).
|
|
141
|
+
*/
|
|
142
|
+
export async function collectLiveReconcileInputs({
|
|
143
|
+
deckDir,
|
|
144
|
+
exportSlides,
|
|
145
|
+
guids = null,
|
|
146
|
+
}) {
|
|
147
|
+
const fileByGuid = new Map();
|
|
148
|
+
for (const [slidePath, guid] of Object.entries(reconcileSlideGuids(deckDir))) {
|
|
149
|
+
fileByGuid.set(guid, path.basename(slidePath));
|
|
150
|
+
}
|
|
151
|
+
const deckGuids = [...fileByGuid.keys()];
|
|
152
|
+
const requested = (guids ?? deckGuids).filter((guid) => fileByGuid.has(guid));
|
|
153
|
+
if (requested.length === 0) return null;
|
|
154
|
+
|
|
155
|
+
// `truncated` names slides that matched but did not fit the relay frame —
|
|
156
|
+
// re-request them until every one has landed. Dropping them would silently
|
|
157
|
+
// leave those slides unprotected, which is the whole failure mode here.
|
|
158
|
+
const exported = {};
|
|
159
|
+
let deck = null;
|
|
160
|
+
let pending = requested;
|
|
161
|
+
while (pending.length > 0) {
|
|
162
|
+
const before = Object.keys(exported).length;
|
|
163
|
+
const ack = await exportRound(exportSlides, pending);
|
|
164
|
+
deck ??= ack?.deck ?? null;
|
|
165
|
+
for (const [guid, slide] of Object.entries(ack?.slides ?? {})) {
|
|
166
|
+
if (slide?.base64 && slide?.tags) exported[guid] = slide;
|
|
167
|
+
}
|
|
168
|
+
const next = (Array.isArray(ack?.truncated) ? ack.truncated : []).filter(
|
|
169
|
+
(guid) => !exported[guid] && requested.includes(guid),
|
|
170
|
+
);
|
|
171
|
+
if (next.length > 0 && Object.keys(exported).length === before) {
|
|
172
|
+
const err = new Error(
|
|
173
|
+
`the pane could not export ${next.length} live slide(s) — they are too ` +
|
|
174
|
+
`large for the relay frame, so their manual edits cannot be checked. ` +
|
|
175
|
+
`Re-run with --force to push anyway and discard any manual edits.`,
|
|
176
|
+
);
|
|
177
|
+
err.code = "export_incomplete";
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
pending = next;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Only meaningful for a WHOLE-deck request: a subset replace deletes nothing
|
|
184
|
+
// outside the subset, so slides beyond it are none of its business.
|
|
185
|
+
const unmatched =
|
|
186
|
+
requested.length === deckGuids.length ? (deck?.unmatched ?? []) : [];
|
|
187
|
+
if (unmatched.length > 0) throw liveDeckExtraSlidesError(unmatched);
|
|
188
|
+
|
|
189
|
+
const acks = loadReconcileAcks(deckDir);
|
|
190
|
+
const reconcile = {};
|
|
191
|
+
for (const [guid, slide] of Object.entries(exported)) {
|
|
192
|
+
const fileName = fileByGuid.get(guid);
|
|
193
|
+
if (!fileName) continue;
|
|
194
|
+
// Same honesty rule as the file guard: a slide whose tags carry no baseline
|
|
195
|
+
// (pushed before baselines existed) cannot be compared, so it is skipped —
|
|
196
|
+
// an unverifiable slide is not evidence of an edit.
|
|
197
|
+
const baseline = decodeSourceTags(slide.tags)?.baseline ?? null;
|
|
198
|
+
if (!baseline) continue;
|
|
199
|
+
// The BYTES carry the current state; the TAGS carry the baseline. Never the
|
|
200
|
+
// other way round — exportAsBase64 strips tags (office-js #6104).
|
|
201
|
+
const current = computeProjection(unzip(Buffer.from(slide.base64, "base64")));
|
|
202
|
+
if (JSON.stringify(current) === JSON.stringify(baseline)) continue;
|
|
203
|
+
reconcile[fileName] = {
|
|
204
|
+
baseline,
|
|
205
|
+
current,
|
|
206
|
+
...(acks[fileName] ? { ack: acks[fileName] } : {}),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
return Object.keys(reconcile).length > 0 ? reconcile : null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The reconcile payload carried by a REFUSED render: the backend answers HTTP
|
|
214
|
+
* 409 when EVERY jsx slide of the request blocked (the common single-slide
|
|
215
|
+
* deck), because there is then no deck to return — the echo travels on the
|
|
216
|
+
* error body instead of the result. Both surfaces land here: the sync route's
|
|
217
|
+
* 409 (bridgeFetch attaches the parsed body) and a render job that failed with
|
|
218
|
+
* the same payload (jobFailureError rebuilds it from the job's error extra).
|
|
219
|
+
* Null for every other error, which stays a plain transport failure.
|
|
220
|
+
*/
|
|
221
|
+
export function reconcileErrorPayload(err) {
|
|
222
|
+
const echo = err?.body?.reconcile;
|
|
223
|
+
// A NON-EMPTY echo is the whole signal. An error carrying none (or an empty
|
|
224
|
+
// one) is a plain failure and must surface as itself — mapping it onto the
|
|
225
|
+
// fail-closed "backend does not support reconcile" message would blame the
|
|
226
|
+
// wrong thing for whatever actually went wrong.
|
|
227
|
+
if (!echo || typeof echo !== "object" || Object.keys(echo).length === 0) {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
return err.body;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Apply the render response's `reconcile` echo: fold text edits into the deck's
|
|
235
|
+
* jsx, store the acknowledged hashes, and refuse the build when a slide's edit
|
|
236
|
+
* needs human judgment. Returns normally only when the caller may write/push.
|
|
237
|
+
* `sent` is what {@link collectReconcileInputs} produced (null ⇒ nothing to do).
|
|
238
|
+
*/
|
|
239
|
+
export function applyReconcileResponse({ deckDir, sent, result }) {
|
|
240
|
+
if (!sent) return;
|
|
241
|
+
const echo = result?.reconcile;
|
|
242
|
+
const sentFiles = Object.keys(sent);
|
|
243
|
+
// FAIL CLOSED (plan §3). The echo IS the capability signal: a backend that
|
|
244
|
+
// understood the field answers for every key. Anything less and we do not
|
|
245
|
+
// know whether the user's edits were considered — so nothing gets written.
|
|
246
|
+
const answered = echo && typeof echo === "object" ? echo : {};
|
|
247
|
+
if (sentFiles.some((file) => !answered[file])) {
|
|
248
|
+
const err = new Error(
|
|
249
|
+
"backend does not support manual-edit reconcile — update the add-in " +
|
|
250
|
+
"backend, or re-run with --force to discard the manual edits",
|
|
251
|
+
);
|
|
252
|
+
err.code = "reconcile_unsupported";
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const acks = {};
|
|
257
|
+
const reports = [];
|
|
258
|
+
// Only the files we ASKED about: the response may not steer a write to a path
|
|
259
|
+
// of its choosing.
|
|
260
|
+
for (const fileName of sentFiles) {
|
|
261
|
+
const entry = answered[fileName];
|
|
262
|
+
const safeName = path.basename(fileName);
|
|
263
|
+
if (typeof entry.nowHash === "string" && entry.nowHash) {
|
|
264
|
+
acks[safeName] = entry.nowHash;
|
|
265
|
+
}
|
|
266
|
+
if (typeof entry.foldedJsx === "string") {
|
|
267
|
+
// The fold is now the source of truth — the render already used it. It
|
|
268
|
+
// is written for a BLOCKING slide too (the backend echoes it either
|
|
269
|
+
// way): the text half of the edit is then already safe on disk while the
|
|
270
|
+
// agent folds the geometry half by hand.
|
|
271
|
+
fs.writeFileSync(
|
|
272
|
+
path.join(deckDir, "slides", safeName),
|
|
273
|
+
entry.foldedJsx,
|
|
274
|
+
"utf8",
|
|
275
|
+
);
|
|
276
|
+
process.stderr.write(
|
|
277
|
+
`note: folded ${entry.appliedTextEdits ?? 0} manual text edit(s) from ` +
|
|
278
|
+
`PowerPoint into slides/${safeName}\n`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (entry.blocking) {
|
|
282
|
+
// The response also lists these in `skippedSlides` as
|
|
283
|
+
// `{ stem, reason: "reconcile" }`. Nothing to do with that: this throws,
|
|
284
|
+
// so the caller never gets far enough to print the skip lines — the
|
|
285
|
+
// report says it better and says it once.
|
|
286
|
+
reports.push(entry.report ?? `${safeName}: manual edit needs folding`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// BEFORE the refusal below: the ack is what lets the re-run through once the
|
|
290
|
+
// user has folded the change into the jsx. Without it the same report fires
|
|
291
|
+
// forever (the output file, and therefore its hash, never changed).
|
|
292
|
+
saveReconcileAcks(deckDir, acks);
|
|
293
|
+
|
|
294
|
+
if (reports.length > 0) {
|
|
295
|
+
const err = new Error(
|
|
296
|
+
`${reports.length} slide(s) were edited in PowerPoint in ways that cannot ` +
|
|
297
|
+
`be folded automatically — nothing was written or pushed.\n\n` +
|
|
298
|
+
`${reports.join("\n\n")}\n\n` +
|
|
299
|
+
`Fold each change into the slide's jsx as layout intent (never literal ` +
|
|
300
|
+
`offsets) and re-run, or re-run with --force to discard these manual edits.`,
|
|
301
|
+
);
|
|
302
|
+
err.code = "reconcile_required";
|
|
303
|
+
err.reports = reports;
|
|
304
|
+
throw err;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Deck-local sidecar persisting each slide's durable GUID (`sm-<uuid>`) for
|
|
2
|
+
// the deck-embedded source tags, keyed by workspace jsx path
|
|
3
|
+
// ("slides/01-cover.jsx"). Each entry also records the sha256 of the jsx it
|
|
4
|
+
// was last saved for: the NN- filename prefix IS the play order, so reordering
|
|
5
|
+
// a deck renames every file — the content hash is what lets a renamed file
|
|
6
|
+
// keep its identity (reconcileSlideGuids below).
|
|
7
|
+
//
|
|
8
|
+
// Keeps a slide's identity stable across re-renders: the compose path sends
|
|
9
|
+
// these to the server render (which mints for new slides and reports back),
|
|
10
|
+
// the fill-template path bakes them locally.
|
|
11
|
+
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
const SIDECAR = ".slide-guids.json";
|
|
17
|
+
|
|
18
|
+
const sha256Hex = (data) => createHash("sha256").update(data).digest("hex");
|
|
19
|
+
|
|
20
|
+
/** Sidecar entry for a slide: its GUID plus the hash of the jsx it maps to. */
|
|
21
|
+
export function slideGuidEntry(guid, jsx) {
|
|
22
|
+
return { guid, sha256: sha256Hex(Buffer.from(String(jsx), "utf8")) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Raw sidecar, normalized to { [path]: { guid, sha256|null } }. A v1 sidecar
|
|
26
|
+
// stored bare guid strings — read them (identity must survive the format
|
|
27
|
+
// change) but never write them back.
|
|
28
|
+
function readSidecar(deckDir) {
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(fs.readFileSync(path.join(deckDir, SIDECAR), "utf8"));
|
|
32
|
+
} catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const [p, v] of Object.entries(parsed)) {
|
|
38
|
+
if (typeof v === "string") out[p] = { guid: v, sha256: null };
|
|
39
|
+
else if (v && typeof v.guid === "string") {
|
|
40
|
+
out[p] = { guid: v.guid, sha256: typeof v.sha256 === "string" ? v.sha256 : null };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writeSidecar(deckDir, entries) {
|
|
47
|
+
fs.writeFileSync(
|
|
48
|
+
path.join(deckDir, SIDECAR),
|
|
49
|
+
`${JSON.stringify(entries, null, 2)}\n`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** { [path]: guid } — the wire shape the render routes take. */
|
|
54
|
+
export function loadSlideGuids(deckDir) {
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const [p, entry] of Object.entries(readSidecar(deckDir))) {
|
|
57
|
+
out[p] = entry.guid;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Merge entries into the sidecar. Values are either a bare guid string
|
|
64
|
+
* (content hash unknown — reconcile refreshes it on the next run) or a
|
|
65
|
+
* {@link slideGuidEntry}.
|
|
66
|
+
*/
|
|
67
|
+
export function saveSlideGuids(deckDir, guids) {
|
|
68
|
+
if (!guids || Object.keys(guids).length === 0) return;
|
|
69
|
+
const merged = readSidecar(deckDir);
|
|
70
|
+
for (const [p, v] of Object.entries(guids)) {
|
|
71
|
+
if (typeof v === "string") {
|
|
72
|
+
merged[p] = { guid: v, sha256: merged[p]?.guid === v ? (merged[p].sha256 ?? null) : null };
|
|
73
|
+
} else if (v && typeof v.guid === "string") {
|
|
74
|
+
merged[p] = { guid: v.guid, sha256: v.sha256 ?? null };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
writeSidecar(deckDir, merged);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Reconcile the sidecar with the slide files actually on disk and return the
|
|
82
|
+
* resulting { [path]: guid } map. Run this before every bake/render — it is
|
|
83
|
+
* what makes identity survive the single most common deck edit (reordering,
|
|
84
|
+
* which renames every file):
|
|
85
|
+
*
|
|
86
|
+
* • a file with a sidecar entry keeps its GUID (and gets its hash refreshed);
|
|
87
|
+
* • a file WITHOUT an entry inherits the GUID of a vanished entry whose
|
|
88
|
+
* recorded hash matches its content — a pure rename;
|
|
89
|
+
* • entries whose file is gone and whose content matched nothing are DROPPED,
|
|
90
|
+
* so a later file reusing the name can never resurrect a dead slide's
|
|
91
|
+
* identity.
|
|
92
|
+
*
|
|
93
|
+
* Files still unmapped after this are genuinely new; the caller (or the server
|
|
94
|
+
* render) mints for them.
|
|
95
|
+
*/
|
|
96
|
+
export function reconcileSlideGuids(deckDir) {
|
|
97
|
+
const sidecar = readSidecar(deckDir);
|
|
98
|
+
const slidesDir = path.join(deckDir, "slides");
|
|
99
|
+
const present = new Map(); // path -> sha256 of current content
|
|
100
|
+
let files = [];
|
|
101
|
+
try {
|
|
102
|
+
files = fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx"));
|
|
103
|
+
} catch {
|
|
104
|
+
files = [];
|
|
105
|
+
}
|
|
106
|
+
for (const f of files.sort()) {
|
|
107
|
+
try {
|
|
108
|
+
present.set(`slides/${f}`, sha256Hex(fs.readFileSync(path.join(slidesDir, f))));
|
|
109
|
+
} catch {
|
|
110
|
+
/* unreadable slide — it simply keeps no identity this round */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const next = {};
|
|
115
|
+
const orphansBySha = new Map(); // sha256 -> [guid, …] whose file is gone
|
|
116
|
+
for (const [p, entry] of Object.entries(sidecar)) {
|
|
117
|
+
if (present.has(p)) {
|
|
118
|
+
next[p] = { guid: entry.guid, sha256: present.get(p) };
|
|
119
|
+
} else if (entry.sha256) {
|
|
120
|
+
const list = orphansBySha.get(entry.sha256) ?? [];
|
|
121
|
+
list.push(entry.guid);
|
|
122
|
+
orphansBySha.set(entry.sha256, list);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const claimed = new Set(Object.values(next).map((e) => e.guid));
|
|
126
|
+
for (const [p, sha] of present) {
|
|
127
|
+
if (next[p]) continue;
|
|
128
|
+
const guids = orphansBySha.get(sha);
|
|
129
|
+
while (guids?.length) {
|
|
130
|
+
const guid = guids.shift();
|
|
131
|
+
if (!claimed.has(guid)) {
|
|
132
|
+
next[p] = { guid, sha256: sha };
|
|
133
|
+
claimed.add(guid);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (JSON.stringify(next) !== JSON.stringify(sidecar)) writeSidecar(deckDir, next);
|
|
140
|
+
const out = {};
|
|
141
|
+
for (const [p, entry] of Object.entries(next)) out[p] = entry.guid;
|
|
142
|
+
return out;
|
|
143
|
+
}
|