slidemind 0.3.19 → 0.4.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 +591 -15
- package/README.md +202 -7
- package/bin/slidemind.mjs +50 -2
- package/package.json +2 -2
- 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/engine-slides.mjs +1 -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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// What a source image needs before a renderer can ship it — recorded as data,
|
|
2
|
+
// never performed here.
|
|
3
|
+
//
|
|
4
|
+
// The CLI owns no decoder and no converter (design decision: "do not add an
|
|
5
|
+
// independent image-processing service", and this package has no image
|
|
6
|
+
// dependency at all). What it CAN do is read a header and say, deterministically,
|
|
7
|
+
// what the picture pipeline will have to do with those bytes:
|
|
8
|
+
//
|
|
9
|
+
// * a JPEG whose EXIF says it is rotated is presented rotated — a RENDERING
|
|
10
|
+
// transform, so the stored digest still hashes the raw imported bytes;
|
|
11
|
+
// * an animated source is presented as its first frame, with a static-output
|
|
12
|
+
// note, because a PPTX picture promises no animation;
|
|
13
|
+
// * a format the OOXML adapter cannot embed as-is (WebP, SVG) ships as a
|
|
14
|
+
// derivative while the source stays in the workspace, untouched.
|
|
15
|
+
//
|
|
16
|
+
// Each of those produces a DERIVATIVE RECORD — parent hash, format, transform
|
|
17
|
+
// version — which is the contract's evidence that the thing embedded in the
|
|
18
|
+
// .pptx descends from the registered source. The bytes of the derivative are
|
|
19
|
+
// produced at capture by the image decoration both clients run (the add-in's
|
|
20
|
+
// build-pptx/image-decorate.ts, served to the CLI in the capture engine): it
|
|
21
|
+
// asks `renderTransforms` below about the same bytes, so the declaration here
|
|
22
|
+
// and the picture that ships cannot disagree, and the export check accepts the
|
|
23
|
+
// derivative only by the parent digest the capture recorded for it.
|
|
24
|
+
//
|
|
25
|
+
// Mirrored byte-identically into the add-in (shared/plan-contract/lib), which
|
|
26
|
+
// renders with it and words the same notes from it.
|
|
27
|
+
//
|
|
28
|
+
// Every derivative is PNG. One rule instead of three: it is lossless (a JPEG
|
|
29
|
+
// re-encode for a rotation is not), it keeps alpha (a transparent PNG must never
|
|
30
|
+
// come back flattened), and every renderer on both sides embeds it natively.
|
|
31
|
+
|
|
32
|
+
/** Bumped when a transform's OUTPUT changes, so old derivatives are recognised as stale. */
|
|
33
|
+
export const IMAGE_TRANSFORM_VERSION = 1;
|
|
34
|
+
|
|
35
|
+
// What the OOXML adapter embeds without conversion; the add-in's image
|
|
36
|
+
// decoration and export check take their list from here. Anything else the
|
|
37
|
+
// contract supports — WebP — needs a derivative. An SVG ships as itself: the
|
|
38
|
+
// file rides in the picture's `asvg:svgBlip`, with the capture's raster as the
|
|
39
|
+
// fallback blip, so it needs none.
|
|
40
|
+
export const NATIVE_RASTER_MIME_TYPES = ["image/png", "image/jpeg", "image/gif"];
|
|
41
|
+
const SHIPS_AS_ITSELF = new Set([...NATIVE_RASTER_MIME_TYPES, "image/svg+xml"]);
|
|
42
|
+
|
|
43
|
+
export const DERIVATIVE_FORMAT = "image/png";
|
|
44
|
+
|
|
45
|
+
const startsWith = (buf, offset, bytes) =>
|
|
46
|
+
buf.length >= offset + bytes.length && bytes.every((b, i) => buf[offset + i] === b);
|
|
47
|
+
|
|
48
|
+
const ascii = (s) => [...s].map((c) => c.charCodeAt(0));
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// EXIF orientation
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
// The Orientation tag out of a TIFF header that starts at `base` (just past
|
|
55
|
+
// "Exif\0\0"). Values 1..8; 5..8 are the ones that swap width and height.
|
|
56
|
+
function orientationFromTiff(buf, base) {
|
|
57
|
+
if (base + 8 > buf.length) return null;
|
|
58
|
+
const byteOrder = String.fromCharCode(buf[base], buf[base + 1]);
|
|
59
|
+
const le = byteOrder === "II";
|
|
60
|
+
if (!le && byteOrder !== "MM") return null;
|
|
61
|
+
const u16 = (o) => (le ? buf.readUInt16LE(o) : buf.readUInt16BE(o));
|
|
62
|
+
const u32 = (o) => (le ? buf.readUInt32LE(o) : buf.readUInt32BE(o));
|
|
63
|
+
if (u16(base + 2) !== 42) return null;
|
|
64
|
+
const ifd = base + u32(base + 4);
|
|
65
|
+
if (ifd + 2 > buf.length) return null;
|
|
66
|
+
const entries = u16(ifd);
|
|
67
|
+
for (let i = 0; i < entries; i++) {
|
|
68
|
+
const at = ifd + 2 + i * 12;
|
|
69
|
+
if (at + 12 > buf.length) return null;
|
|
70
|
+
if (u16(at) !== 0x0112) continue;
|
|
71
|
+
const value = u16(at + 8); // SHORT, so the value sits inline
|
|
72
|
+
return value >= 1 && value <= 8 ? value : null;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A JPEG's EXIF orientation (1..8), or null when it carries none.
|
|
79
|
+
*
|
|
80
|
+
* Walks the marker segments the same way `imageSizeFromBuffer` does, and stops
|
|
81
|
+
* at the start of scan data — EXIF never appears after it.
|
|
82
|
+
*/
|
|
83
|
+
export function exifOrientation(buf) {
|
|
84
|
+
if (!buf || !startsWith(buf, 0, [0xff, 0xd8, 0xff])) return null;
|
|
85
|
+
let pos = 2;
|
|
86
|
+
while (pos + 4 <= buf.length) {
|
|
87
|
+
if (buf[pos] !== 0xff) {
|
|
88
|
+
pos++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const marker = buf[pos + 1];
|
|
92
|
+
if (marker === 0xff) {
|
|
93
|
+
pos++; // fill byte
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
97
|
+
pos += 2; // standalone, no payload
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (marker === 0xda || marker === 0xd9) return null; // scan data / end
|
|
101
|
+
const length = buf.readUInt16BE(pos + 2);
|
|
102
|
+
if (length < 2) return null;
|
|
103
|
+
if (marker === 0xe1 && buf.length >= pos + 10 && buf.toString("latin1", pos + 4, pos + 10) === "Exif\0\0") {
|
|
104
|
+
return orientationFromTiff(buf, pos + 10);
|
|
105
|
+
}
|
|
106
|
+
pos += 2 + length;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// animation
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Does this GIF carry more than one frame?
|
|
117
|
+
*
|
|
118
|
+
* Walks the block structure and stops at the second image descriptor — no
|
|
119
|
+
* decoding, so an unparseable stream answers "not animated" and is embedded as
|
|
120
|
+
* it stands rather than being declared a derivative on a guess.
|
|
121
|
+
*/
|
|
122
|
+
export function gifIsAnimated(buf) {
|
|
123
|
+
if (!buf || !startsWith(buf, 0, ascii("GIF8")) || buf.length < 14) return false;
|
|
124
|
+
const flags = buf[10];
|
|
125
|
+
let pos = 13;
|
|
126
|
+
if (flags & 0x80) pos += 3 * 2 ** ((flags & 0x07) + 1); // global colour table
|
|
127
|
+
const skipSubBlocks = () => {
|
|
128
|
+
while (pos < buf.length) {
|
|
129
|
+
const size = buf[pos++];
|
|
130
|
+
if (size === 0) return true;
|
|
131
|
+
pos += size;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
};
|
|
135
|
+
let frames = 0;
|
|
136
|
+
while (pos < buf.length) {
|
|
137
|
+
const block = buf[pos];
|
|
138
|
+
if (block === 0x3b) return false; // trailer
|
|
139
|
+
if (block === 0x21) {
|
|
140
|
+
pos += 2; // introducer + label
|
|
141
|
+
if (!skipSubBlocks()) return false;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (block !== 0x2c) return false; // not a structure we can walk
|
|
145
|
+
if (++frames > 1) return true;
|
|
146
|
+
if (pos + 10 > buf.length) return false;
|
|
147
|
+
const local = buf[pos + 9];
|
|
148
|
+
pos += 10;
|
|
149
|
+
if (local & 0x80) pos += 3 * 2 ** ((local & 0x07) + 1); // local colour table
|
|
150
|
+
pos += 1; // LZW minimum code size
|
|
151
|
+
if (!skipSubBlocks()) return false;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// the plan
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The transforms a renderer must apply to ship these bytes, in the order the
|
|
162
|
+
* plan names them: `exif-orientation` (a rotated JPEG ships upright),
|
|
163
|
+
* `first-frame` (an animated GIF ships its first frame), `format-conversion`
|
|
164
|
+
* (a format the adapter cannot embed). Empty when the source ships as itself.
|
|
165
|
+
* The one decision both the plan's notes and the capture's image decoration
|
|
166
|
+
* read.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} mimeType the sniffed type of `bytes`
|
|
169
|
+
* @param {Buffer} bytes
|
|
170
|
+
*/
|
|
171
|
+
export function renderTransforms(mimeType, bytes) {
|
|
172
|
+
const transforms = [];
|
|
173
|
+
if (mimeType === "image/jpeg") {
|
|
174
|
+
const orientation = exifOrientation(bytes);
|
|
175
|
+
if (orientation && orientation !== 1) transforms.push("exif-orientation");
|
|
176
|
+
} else if (mimeType === "image/gif" && gifIsAnimated(bytes)) {
|
|
177
|
+
transforms.push("first-frame");
|
|
178
|
+
}
|
|
179
|
+
if (!SHIPS_AS_ITSELF.has(mimeType)) transforms.push("format-conversion");
|
|
180
|
+
return transforms;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* How `asset`'s bytes reach a slide: as themselves, or through a declared
|
|
185
|
+
* derivative.
|
|
186
|
+
*
|
|
187
|
+
* Returns `{native, presented, transforms, derivative, notes}`. `presented` is
|
|
188
|
+
* the asset's own size: the registry on both clients records the
|
|
189
|
+
* orientation-corrected size a viewer sees — the one a crop fraction and an
|
|
190
|
+
* `<img>` box are expressed in — while the digest hashes the raw bytes.
|
|
191
|
+
* `derivative` is null when the source ships untouched.
|
|
192
|
+
*
|
|
193
|
+
* @param {{sha256: string, mimeType: string, width: number, height: number}} asset
|
|
194
|
+
* @param {Buffer} bytes the asset's raw imported bytes
|
|
195
|
+
*/
|
|
196
|
+
export function imageRenderPlan(asset, bytes) {
|
|
197
|
+
const transforms = renderTransforms(asset.mimeType, bytes);
|
|
198
|
+
const notes = [];
|
|
199
|
+
const presented = { width: asset.width, height: asset.height };
|
|
200
|
+
|
|
201
|
+
if (transforms.includes("exif-orientation")) {
|
|
202
|
+
const orientation = exifOrientation(bytes);
|
|
203
|
+
notes.push(
|
|
204
|
+
`EXIF orientation ${orientation}: the render ships the picture upright, as a PNG derivative. ` +
|
|
205
|
+
`The stored digest hashes the raw imported bytes, so it does not change.`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (transforms.includes("first-frame")) {
|
|
209
|
+
notes.push(
|
|
210
|
+
"Animated source: the slide shows its first frame, shipped as a PNG derivative. A PowerPoint picture " +
|
|
211
|
+
"is static here — the animation is not promised, and the full source stays in the workspace.",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (transforms.includes("format-conversion")) {
|
|
215
|
+
notes.push(
|
|
216
|
+
`${asset.mimeType} cannot be embedded as it stands: the render ships a PNG derivative of it, ` +
|
|
217
|
+
`and the source is kept unchanged.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const derivative = transforms.length
|
|
222
|
+
? {
|
|
223
|
+
parentSha256: asset.sha256,
|
|
224
|
+
parentMimeType: asset.mimeType,
|
|
225
|
+
format: DERIVATIVE_FORMAT,
|
|
226
|
+
transformVersion: IMAGE_TRANSFORM_VERSION,
|
|
227
|
+
transforms: [...transforms],
|
|
228
|
+
width: presented.width,
|
|
229
|
+
height: presented.height,
|
|
230
|
+
}
|
|
231
|
+
: null;
|
|
232
|
+
|
|
233
|
+
return { native: derivative === null, presented, transforms, derivative, notes };
|
|
234
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The SHA-256 half of the image-intent contract (design decision 6).
|
|
2
|
+
//
|
|
3
|
+
// Split out of `image-intent.mjs` for one reason: that module is concatenated
|
|
4
|
+
// into the plan gate's browser bundle and may not touch `node:crypto`. The
|
|
5
|
+
// projection — which fields count, in what order — lives there, in
|
|
6
|
+
// `imageIntentFingerprintInput()`; only the hashing lives here.
|
|
7
|
+
//
|
|
8
|
+
// `canonicalJson` is a line-for-line port of the add-in's
|
|
9
|
+
// `src/ui/src/node/agent/storage/canonical-hash.ts`, which the add-in side
|
|
10
|
+
// reuses as-is: recursively sorted object keys, array order preserved, UTF-8
|
|
11
|
+
// JSON, `undefined` object properties dropped, non-finite numbers and
|
|
12
|
+
// `undefined` array entries as `null`. The two implementations are held
|
|
13
|
+
// together by `test/fixtures/image-intent/contract.json`, which carries the
|
|
14
|
+
// expected canonical string AND digest for each case — not by shared code,
|
|
15
|
+
// since the CLI cannot import from the sibling repository.
|
|
16
|
+
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
|
|
19
|
+
import { imageIntentFingerprintInput } from "./image-intent.mjs";
|
|
20
|
+
|
|
21
|
+
function normalize(value) {
|
|
22
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
if (typeof value === "number") {
|
|
26
|
+
return Number.isFinite(value) ? value : null;
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
return value.map((entry) =>
|
|
30
|
+
entry === undefined || typeof entry === "function" ? null : normalize(entry),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (typeof value === "object") {
|
|
34
|
+
const normalized = {};
|
|
35
|
+
for (const key of Object.keys(value).sort()) {
|
|
36
|
+
const entry = value[key];
|
|
37
|
+
if (entry === undefined || typeof entry === "function" || typeof entry === "symbol") {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
normalized[key] = normalize(entry);
|
|
41
|
+
}
|
|
42
|
+
return normalized;
|
|
43
|
+
}
|
|
44
|
+
throw new TypeError("Value is not JSON-compatible");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Canonical JSON: sorted keys, preserved array order. */
|
|
48
|
+
export function canonicalJson(value) {
|
|
49
|
+
return JSON.stringify(normalize(value));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function sha256Hex(value) {
|
|
53
|
+
return createHash("sha256").update(value).digest("hex");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function canonicalSha256(value) {
|
|
57
|
+
return sha256Hex(canonicalJson(value));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The fingerprint a concept, spec or validation record is keyed by.
|
|
62
|
+
*
|
|
63
|
+
* Changing an instruction, a source's bytes, a crop or the set of uses changes
|
|
64
|
+
* it; renaming the file or moving the workspace does not.
|
|
65
|
+
*
|
|
66
|
+
* @param {{uses?: Array, assets?: Array, context?: unknown}} args
|
|
67
|
+
*/
|
|
68
|
+
export function imageIntentFingerprint(args = {}) {
|
|
69
|
+
return canonicalSha256(imageIntentFingerprintInput(args));
|
|
70
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Image provenance through the deck-embedded source tags — one slide's
|
|
2
|
+
// approved image obligations, baked into the .pptx it was rendered into and
|
|
3
|
+
// read back out when that file is adopted again (design decision 8, A27).
|
|
4
|
+
//
|
|
5
|
+
// Why it rides the slide's own tags rather than a sidecar: the .pptx is the
|
|
6
|
+
// only thing that travels. A rendered deck is mailed, opened, saved and handed
|
|
7
|
+
// back, and `slidemind adopt` rebuilds a workspace from it alone. Everything
|
|
8
|
+
// else about a slide already survives that round trip — its jsx, its assets,
|
|
9
|
+
// its identity, its drift baseline. An obligation that did not would come back
|
|
10
|
+
// as a picture nobody could prove was the user's own.
|
|
11
|
+
//
|
|
12
|
+
// Two halves, both narrow:
|
|
13
|
+
//
|
|
14
|
+
// BAKE `imageIntentTagMeta` projects the slide's effective uses and the
|
|
15
|
+
// registry entries they name into `SRC_META.imageIntent`. The BYTES
|
|
16
|
+
// need no arrangement: the jsx references `./assets/local/<id>.<ext>`,
|
|
17
|
+
// so `referencedAssets` already embeds them within the tag budget.
|
|
18
|
+
// RESTORE `restoreImageIntentFromTags` puts both back — registry entries
|
|
19
|
+
// through `registerImageAssets` (identity preserved, bytes proved),
|
|
20
|
+
// uses through `setSlideImageUses` keyed by the slide's own GUID.
|
|
21
|
+
//
|
|
22
|
+
// The restore is deliberately conservative in three ways:
|
|
23
|
+
//
|
|
24
|
+
// * **A file with no `imageIntent` gains nothing.** A plain imported .pptx —
|
|
25
|
+
// one slidemind never rendered, or one from before this contract — is
|
|
26
|
+
// legacy and stays legacy. An obligation is never manufactured from a
|
|
27
|
+
// picture that happens to be on a slide (design decision 8, A27).
|
|
28
|
+
// * **Only a slide restored from its own proved-current source is restored at
|
|
29
|
+
// all.** A slide that had to be re-converted from its edited self is
|
|
30
|
+
// rebuilt content — adopt itself judged the embedded source stale — and its
|
|
31
|
+
// picture may have been moved, replaced or deleted in PowerPoint. Asserting
|
|
32
|
+
// the old obligation over it would refuse every later build for an edit
|
|
33
|
+
// nobody made to the picture. The bytes are still in the file; rebinding is
|
|
34
|
+
// a deliberate act, through the plan.
|
|
35
|
+
// * **Nothing is overwritten.** A known assetId with different bytes, a
|
|
36
|
+
// colliding useId, an asset whose bytes did not survive — each is reported
|
|
37
|
+
// and skipped. A partial restore says what it dropped.
|
|
38
|
+
//
|
|
39
|
+
// One known property, not a bug (confirmed with the add-in 2026-09-21): the
|
|
40
|
+
// add-in's own bake builds a FRESH meta from its own input (spec §5.3 write
|
|
41
|
+
// point 2 — a live insert or replace), so a slide re-pushed from the add-in
|
|
42
|
+
// comes back without this key. Its obligations are not lost — the add-in keeps
|
|
43
|
+
// its own record in its workspace, and the deck's registry and plan here are
|
|
44
|
+
// untouched — so the next CLI build re-writes the key from the registry rather
|
|
45
|
+
// than from the tags. What does not survive is that one slide's provenance
|
|
46
|
+
// INSIDE a .pptx pushed from the add-in and adopted without a CLI build in
|
|
47
|
+
// between; it adopts as a slide with a picture and no obligation, which is the
|
|
48
|
+
// legacy case and refuses nothing.
|
|
49
|
+
|
|
50
|
+
import {
|
|
51
|
+
findAssetById,
|
|
52
|
+
readImageAssetManifest,
|
|
53
|
+
registerImageAssets,
|
|
54
|
+
} from "./image-assets.mjs";
|
|
55
|
+
import { setSlideImageUses } from "./image-use-overrides.mjs";
|
|
56
|
+
import { IMAGE_INTENT_VERSION, validateSlideUses } from "./image-intent.mjs";
|
|
57
|
+
import { effectiveUsesForSlides } from "../bridge/image-intent-envelope.mjs";
|
|
58
|
+
|
|
59
|
+
export const IMAGE_INTENT_META_KEY = "imageIntent";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The `SRC_META.imageIntent` payload for each of `slides`, as
|
|
63
|
+
* `Map<slide, meta>` — a slide with no image use is absent, which keeps its
|
|
64
|
+
* tags byte-identical to what they were before this contract existed.
|
|
65
|
+
*
|
|
66
|
+
* Resolved for the WHOLE bake in one call on purpose: the effective-use lookup
|
|
67
|
+
* re-reads and re-hashes every slide file to map overrides onto slide numbers,
|
|
68
|
+
* so asking it per slide makes a deck pay for that once per slide.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} deckDir the deck workspace
|
|
71
|
+
* @param {number[]} slides 1-based slide numbers (each file's NN- prefix)
|
|
72
|
+
*/
|
|
73
|
+
export function imageIntentTagMetaForSlides(deckDir, slides, { planMarkdown = null } = {}) {
|
|
74
|
+
const out = new Map();
|
|
75
|
+
const wanted = (slides ?? []).filter((n) => Number.isInteger(n) && n > 0);
|
|
76
|
+
if (!wanted.length) return out;
|
|
77
|
+
let bySlide;
|
|
78
|
+
let manifest;
|
|
79
|
+
try {
|
|
80
|
+
bySlide = effectiveUsesForSlides(deckDir, wanted, { planMarkdown });
|
|
81
|
+
manifest = readImageAssetManifest(deckDir);
|
|
82
|
+
} catch {
|
|
83
|
+
// An unreadable registry is reported by the commands that need it (the
|
|
84
|
+
// draft check, the envelope build). A bake is bookkeeping: it must not be
|
|
85
|
+
// the thing that fails a render.
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
for (const [slide, uses] of bySlide) {
|
|
89
|
+
if (!uses.length) continue;
|
|
90
|
+
const assets = [];
|
|
91
|
+
for (const assetId of [...new Set(uses.map((u) => u.assetId))].sort()) {
|
|
92
|
+
const asset = findAssetById(manifest, assetId);
|
|
93
|
+
if (asset) assets.push(asset);
|
|
94
|
+
}
|
|
95
|
+
if (assets.length) out.set(slide, { v: IMAGE_INTENT_VERSION, uses, assets });
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One slide's payload, or null. */
|
|
101
|
+
export function imageIntentTagMeta(deckDir, slide, opts = {}) {
|
|
102
|
+
return imageIntentTagMetaForSlides(deckDir, [slide], opts).get(slide) ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Restore the image provenance a set of adopted slides carried.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} deckDir the deck being adopted into
|
|
109
|
+
* @param {Array<{slideId: string|null, intent: object|null, restoreUses: boolean,
|
|
110
|
+
* renames?: Map<string,string>}>} entries
|
|
111
|
+
* one per adopted slide. `renames` is adopt's asset-collision map
|
|
112
|
+
* (`assets/local/x.png` → the name actually written), so a restored entry
|
|
113
|
+
* points at the file that really landed.
|
|
114
|
+
* @returns {{assets: number, slides: number, notes: string[]}}
|
|
115
|
+
*/
|
|
116
|
+
export function restoreImageIntentFromTags(deckDir, entries) {
|
|
117
|
+
const notes = [];
|
|
118
|
+
const wanted = (entries ?? []).filter((e) => e?.intent && Array.isArray(e.intent.assets));
|
|
119
|
+
if (!wanted.length) return { assets: 0, slides: 0, notes };
|
|
120
|
+
|
|
121
|
+
// Every slide's assets in one manifest write — a re-adopted deck has one
|
|
122
|
+
// registry, not one per slide.
|
|
123
|
+
const descriptors = new Map();
|
|
124
|
+
for (const entry of wanted) {
|
|
125
|
+
if (entry.intent.v !== IMAGE_INTENT_VERSION) {
|
|
126
|
+
notes.push(
|
|
127
|
+
`a slide records image intent version ${entry.intent.v} — this CLI speaks version ${IMAGE_INTENT_VERSION}; its obligations were not restored`,
|
|
128
|
+
);
|
|
129
|
+
entry.unsupported = true;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
for (const asset of entry.intent.assets) {
|
|
133
|
+
if (!asset?.assetId) continue;
|
|
134
|
+
const placed = entry.renames?.get(asset.workspacePath);
|
|
135
|
+
descriptors.set(asset.assetId, placed ? { ...asset, workspacePath: placed } : asset);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const { added, reused, rejected } = registerImageAssets(deckDir, [...descriptors.values()]);
|
|
139
|
+
for (const bad of rejected) {
|
|
140
|
+
notes.push(`image source ${bad.assetId ?? "(unnamed)"} was not restored: ${bad.reason}`);
|
|
141
|
+
}
|
|
142
|
+
const usable = new Set([...added, ...reused]);
|
|
143
|
+
|
|
144
|
+
let slides = 0;
|
|
145
|
+
for (const entry of wanted) {
|
|
146
|
+
if (entry.unsupported || !entry.restoreUses) continue;
|
|
147
|
+
if (!entry.slideId) {
|
|
148
|
+
notes.push("a slide with recorded image obligations has no durable identity — its uses were not restored");
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const uses = Array.isArray(entry.intent.uses) ? entry.intent.uses : [];
|
|
152
|
+
if (!uses.length) continue;
|
|
153
|
+
const missing = uses.filter((use) => !usable.has(use?.assetId));
|
|
154
|
+
if (missing.length) {
|
|
155
|
+
notes.push(
|
|
156
|
+
`slide ${entry.slideId}: its image obligation names ${missing[0]?.assetId ?? "an asset"}, which this deck does not have — the use was not restored`,
|
|
157
|
+
);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
// Shape-check before writing: the record came out of an untrusted file.
|
|
161
|
+
const parsed = validateSlideUses(uses, {
|
|
162
|
+
assetIds: readImageAssetManifest(deckDir).assets.map((a) => a.assetId),
|
|
163
|
+
});
|
|
164
|
+
if (!parsed.value) {
|
|
165
|
+
notes.push(`slide ${entry.slideId}: ${parsed.issues[0]?.message ?? "invalid image uses"} — not restored`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
setSlideImageUses(deckDir, entry.slideId, parsed.value);
|
|
170
|
+
slides += 1;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
notes.push(`slide ${entry.slideId}: ${err?.message ?? err} — its image uses were not restored`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return { assets: added.length, slides, notes };
|
|
176
|
+
}
|