slidemind 0.3.20 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +592 -16
- package/LICENSE +4 -0
- package/README.md +203 -8
- package/THIRD-PARTY-NOTICES.md +201 -0
- package/bin/slidemind.mjs +50 -2
- package/package.json +5 -3
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
// The deck's image registry: immutable bytes, durably stored, identified by
|
|
2
|
+
// their own digest.
|
|
3
|
+
//
|
|
4
|
+
// <workspace>/assets/local/<assetId>.<ext> the copied source, never rewritten
|
|
5
|
+
// <workspace>/state/image-assets.json {version: 1, assets: [ImageAsset]}
|
|
6
|
+
//
|
|
7
|
+
// An entry says what bytes ARE (digest, sniffed MIME, intrinsic size, where
|
|
8
|
+
// they came from). It says nothing about what they are FOR — that is an
|
|
9
|
+
// AssetUse in the plan (src/lib/image-intent.mjs). Importing an image creates
|
|
10
|
+
// no obligation to place it anywhere; that separation is the whole point of
|
|
11
|
+
// having two records.
|
|
12
|
+
//
|
|
13
|
+
// Three rules the implementation exists to keep:
|
|
14
|
+
//
|
|
15
|
+
// * **The bytes decide.** The MIME type and the dimensions are read from the
|
|
16
|
+
// header, never from the extension or a caller's claim — the same stance
|
|
17
|
+
// `extFromBytes` already takes for engine downloads, and for the same
|
|
18
|
+
// reason: a mislabelled asset renders wrong or not at all.
|
|
19
|
+
// * **Bytes first, manifest last.** The copy is written and its digest
|
|
20
|
+
// verified BEFORE the manifest is swapped in. A kill in between leaves an
|
|
21
|
+
// unreferenced file (harmless, nothing reads it) — never a committed entry
|
|
22
|
+
// pointing at bytes that are not there (design decision 8).
|
|
23
|
+
// * **Entries are immutable.** The same bytes keep the identity they were
|
|
24
|
+
// first given, so a use bound to an assetId can never come to mean a
|
|
25
|
+
// different picture. A replacement is a new import with a new ID.
|
|
26
|
+
|
|
27
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
|
|
31
|
+
import { extFromBytes } from "../bridge/engine-download.mjs";
|
|
32
|
+
import { imageSizeFromBuffer, svgSizeFromMarkup } from "../bridge/image-dimensions.mjs";
|
|
33
|
+
import { statePath } from "./deck-paths.mjs";
|
|
34
|
+
import { exifOrientation, imageRenderPlan } from "./image-derivatives.mjs";
|
|
35
|
+
import {
|
|
36
|
+
IMAGE_INTENT_VERSION,
|
|
37
|
+
SUPPORTED_IMAGE_MIME_TYPES,
|
|
38
|
+
parseImageAsset,
|
|
39
|
+
parseImageAssetManifest,
|
|
40
|
+
} from "./image-intent.mjs";
|
|
41
|
+
|
|
42
|
+
export const MANIFEST_FILE = "image-assets.json";
|
|
43
|
+
/** Workspace-relative, POSIX, and the only shape `workspacePath` may take. */
|
|
44
|
+
export const LOCAL_ASSET_DIR = "assets/local";
|
|
45
|
+
|
|
46
|
+
// The same ceiling the add-in's image pipeline enforces (sharp's default pixel
|
|
47
|
+
// limit, 0x3FFF²). A header declaring more is refused HERE, before any of it is
|
|
48
|
+
// copied — `test/fixtures/image-intent/oversized-header.png` is 69 bytes
|
|
49
|
+
// claiming 60000x60000.
|
|
50
|
+
export const MAX_IMAGE_PIXELS = 268402689;
|
|
51
|
+
|
|
52
|
+
// Sniffed extension → the MIME the contract speaks. bmp/tiff are listed on
|
|
53
|
+
// purpose: `extFromBytes` recognises them, and naming them produces "this
|
|
54
|
+
// pipeline cannot ship a BMP" instead of "unrecognised file".
|
|
55
|
+
const EXT_MIME = {
|
|
56
|
+
png: "image/png",
|
|
57
|
+
jpg: "image/jpeg",
|
|
58
|
+
gif: "image/gif",
|
|
59
|
+
webp: "image/webp",
|
|
60
|
+
svg: "image/svg+xml",
|
|
61
|
+
bmp: "image/bmp",
|
|
62
|
+
tiff: "image/tiff",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// The recovery line for each code this module raises. The KEYS are drawn from
|
|
66
|
+
// the contract's `IMAGE_INTENT_ISSUE_CODES` — this module declares no code of
|
|
67
|
+
// its own, and `test/image-assets.test.mjs` fails if one appears here that the
|
|
68
|
+
// shared vocabulary does not have.
|
|
69
|
+
export const IMAGE_ASSET_ACTIONS = {
|
|
70
|
+
image_asset_unsupported: "Supply a source in a supported image format (PNG, JPEG, GIF, WebP or SVG).",
|
|
71
|
+
image_asset_too_large: "Supply an appropriately sized source; the original is never silently downsampled.",
|
|
72
|
+
image_asset_missing: "Restore or re-import the source file, then rebind anything that referenced it.",
|
|
73
|
+
image_asset_changed: "Re-import the new bytes as their own asset and update the use through the plan.",
|
|
74
|
+
image_intent_invalid: "Correct the named field and re-record the entry.",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** A typed failure: `err.code` for machines, `err.issue` for the contract's issue list. */
|
|
78
|
+
export function imageAssetError(code, message, extra = {}) {
|
|
79
|
+
const err = new Error(message);
|
|
80
|
+
err.code = code;
|
|
81
|
+
const action = IMAGE_ASSET_ACTIONS[code] ?? IMAGE_ASSET_ACTIONS.image_intent_invalid;
|
|
82
|
+
err.issue = { code, message, action, ...extra };
|
|
83
|
+
return err;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// inspection — the bytes decide
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
let crcTable = null;
|
|
93
|
+
function crc32(bytes) {
|
|
94
|
+
if (!crcTable) {
|
|
95
|
+
crcTable = new Int32Array(256);
|
|
96
|
+
for (let n = 0; n < 256; n++) {
|
|
97
|
+
let c = n;
|
|
98
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
99
|
+
crcTable[n] = c;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
let c = -1;
|
|
103
|
+
for (let i = 0; i < bytes.length; i++) c = crcTable[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
|
|
104
|
+
return (c ^ -1) >>> 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A PNG signature is eight bytes anyone can write; the IHDR's CRC is what says
|
|
108
|
+
// the header is really a header. Without this check a file whose "header" is
|
|
109
|
+
// the word "nonsense" registers happily and then renders as nothing anywhere.
|
|
110
|
+
function pngHeaderIntact(bytes) {
|
|
111
|
+
if (bytes.length < 33) return false;
|
|
112
|
+
if (bytes.readUInt32BE(8) !== 13 || bytes.toString("latin1", 12, 16) !== "IHDR") return false;
|
|
113
|
+
return crc32(bytes.subarray(12, 29)) === bytes.readUInt32BE(29);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// SVG has no magic number — it is XML — so it is identified the way the rest of
|
|
117
|
+
// this CLI identifies it: the markup has to actually open an <svg> element.
|
|
118
|
+
function svgMarkup(bytes) {
|
|
119
|
+
if (bytes.length > 4 * 1024 * 1024) return null; // a text scan has to stay bounded
|
|
120
|
+
const text = bytes.toString("utf8");
|
|
121
|
+
return /<svg[\s>]/i.test(text) ? text : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What these bytes are: `{mimeType, ext, width, height}`. The size is the one a
|
|
126
|
+
* viewer sees: a JPEG whose EXIF turns it a quarter turn reports its sides
|
|
127
|
+
* swapped (the add-in's registry reads the same), because crops, `<img>` boxes
|
|
128
|
+
* and the concept's image room are all expressed in it.
|
|
129
|
+
*
|
|
130
|
+
* Throws `image_asset_unsupported` for anything the pipeline cannot decode or
|
|
131
|
+
* ship, and `image_asset_too_large` for a header declaring more pixels than the
|
|
132
|
+
* renderer accepts. Pure — no filesystem, no manifest.
|
|
133
|
+
*/
|
|
134
|
+
export function inspectImageBytes(bytes, { displayName = "the file" } = {}) {
|
|
135
|
+
const unsupported = (why) =>
|
|
136
|
+
imageAssetError("image_asset_unsupported", `${displayName}: ${why}`);
|
|
137
|
+
|
|
138
|
+
if (!bytes || bytes.length === 0) throw unsupported("it is empty");
|
|
139
|
+
|
|
140
|
+
const markup = svgMarkup(bytes);
|
|
141
|
+
const ext = extFromBytes(bytes) ?? (markup ? "svg" : null);
|
|
142
|
+
const mimeType = ext ? (EXT_MIME[ext] ?? null) : null;
|
|
143
|
+
if (!mimeType) {
|
|
144
|
+
throw unsupported("its bytes are not a recognisable image (they decode as no supported format)");
|
|
145
|
+
}
|
|
146
|
+
if (!SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType)) {
|
|
147
|
+
throw unsupported(
|
|
148
|
+
`${mimeType} is not one of the formats this pipeline can ship (${SUPPORTED_IMAGE_MIME_TYPES.join(", ")})`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (mimeType === "image/png" && !pngHeaderIntact(bytes)) {
|
|
152
|
+
throw unsupported("its PNG header is corrupt (the IHDR checksum does not match)");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const size = mimeType === "image/svg+xml" ? svgSizeFromMarkup(markup) : imageSizeFromBuffer(bytes);
|
|
156
|
+
if (!size) throw unsupported("its intrinsic dimensions could not be read from the header");
|
|
157
|
+
|
|
158
|
+
// An SVG states its size in user units, which a viewBox may write as
|
|
159
|
+
// fractions; the contract wants positive integers.
|
|
160
|
+
const turned = mimeType === "image/jpeg" && (exifOrientation(bytes) ?? 1) >= 5;
|
|
161
|
+
const width = Math.max(1, Math.round(turned ? size.height : size.width));
|
|
162
|
+
const height = Math.max(1, Math.round(turned ? size.width : size.height));
|
|
163
|
+
if (width * height > MAX_IMAGE_PIXELS) {
|
|
164
|
+
throw imageAssetError(
|
|
165
|
+
"image_asset_too_large",
|
|
166
|
+
`${displayName}: ${width}x${height} is past the ${MAX_IMAGE_PIXELS}-pixel limit the renderer accepts`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return { mimeType, ext, width, height };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// the manifest
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
export function manifestPath(workspaceDir) {
|
|
177
|
+
return statePath(workspaceDir, MANIFEST_FILE);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const EMPTY_MANIFEST = () => ({ version: IMAGE_INTENT_VERSION, assets: [] });
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The deck's registry. An absent file is an empty registry; a present one that
|
|
184
|
+
* does not parse is an error, never an empty one — silently starting over
|
|
185
|
+
* would orphan every use already bound to it.
|
|
186
|
+
*/
|
|
187
|
+
export function readImageAssetManifest(workspaceDir) {
|
|
188
|
+
const file = manifestPath(workspaceDir);
|
|
189
|
+
let raw;
|
|
190
|
+
try {
|
|
191
|
+
raw = fs.readFileSync(file, "utf8");
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (err?.code === "ENOENT") return EMPTY_MANIFEST();
|
|
194
|
+
throw imageAssetError("image_asset_missing", `${file} could not be read: ${err.message}`);
|
|
195
|
+
}
|
|
196
|
+
let parsed;
|
|
197
|
+
try {
|
|
198
|
+
parsed = JSON.parse(raw);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
throw imageAssetError("image_intent_invalid", `${file} is not valid JSON: ${err.message}`);
|
|
201
|
+
}
|
|
202
|
+
const result = parseImageAssetManifest(parsed);
|
|
203
|
+
if (!result.value) {
|
|
204
|
+
const first = result.issues[0];
|
|
205
|
+
throw imageAssetError(first?.code ?? "image_intent_invalid", `${file}: ${first?.message ?? "invalid manifest"}`);
|
|
206
|
+
}
|
|
207
|
+
return result.value;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Absolute path of an entry's stored bytes, from its portable workspace path. */
|
|
211
|
+
export function assetFilePath(workspaceDir, asset) {
|
|
212
|
+
return path.join(workspaceDir, ...asset.workspacePath.split("/"));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function findAssetById(manifest, assetId) {
|
|
216
|
+
return manifest.assets.find((a) => a.assetId === assetId) ?? null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* An entry plus its verified bytes.
|
|
221
|
+
*
|
|
222
|
+
* `image_asset_missing` when the file is gone, `image_asset_changed` when what
|
|
223
|
+
* is on disk no longer hashes to the registered digest — the two failures the
|
|
224
|
+
* contract's error table distinguishes, because their repairs differ.
|
|
225
|
+
*/
|
|
226
|
+
export function resolveImageAsset(workspaceDir, assetId, { verify = true } = {}) {
|
|
227
|
+
const manifest = readImageAssetManifest(workspaceDir);
|
|
228
|
+
const asset = findAssetById(manifest, assetId);
|
|
229
|
+
if (!asset) {
|
|
230
|
+
throw imageAssetError("image_asset_missing", `no image asset ${assetId} in this deck`, { assetId });
|
|
231
|
+
}
|
|
232
|
+
const file = assetFilePath(workspaceDir, asset);
|
|
233
|
+
let bytes;
|
|
234
|
+
try {
|
|
235
|
+
bytes = fs.readFileSync(file);
|
|
236
|
+
} catch {
|
|
237
|
+
throw imageAssetError("image_asset_missing", `${asset.displayName}: ${asset.workspacePath} is missing`, {
|
|
238
|
+
assetId,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
if (verify && sha256(bytes) !== asset.sha256) {
|
|
242
|
+
throw imageAssetError(
|
|
243
|
+
"image_asset_changed",
|
|
244
|
+
`${asset.displayName}: ${asset.workspacePath} no longer matches the digest it was registered with`,
|
|
245
|
+
{ assetId },
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return { asset, file, bytes };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function publishManifest(workspaceDir, manifest) {
|
|
252
|
+
const file = manifestPath(workspaceDir);
|
|
253
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
254
|
+
// Write-then-rename, the same swap `revision.mjs` uses for the accepted base:
|
|
255
|
+
// a reader sees the old registry or the new one, never half of either.
|
|
256
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
257
|
+
fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2) + "\n");
|
|
258
|
+
fs.renameSync(tmp, file);
|
|
259
|
+
return file;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Copy bytes to their workspace path and prove the copy landed. A verification
|
|
263
|
+
// failure removes the half-written file: nothing has been committed yet, so the
|
|
264
|
+
// deck is left exactly as it was.
|
|
265
|
+
function writeAssetBytes(workspaceDir, asset, bytes) {
|
|
266
|
+
const file = assetFilePath(workspaceDir, asset);
|
|
267
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
268
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
269
|
+
const fd = fs.openSync(tmp, "w");
|
|
270
|
+
try {
|
|
271
|
+
fs.writeFileSync(fd, bytes);
|
|
272
|
+
fs.fsyncSync(fd);
|
|
273
|
+
} finally {
|
|
274
|
+
fs.closeSync(fd);
|
|
275
|
+
}
|
|
276
|
+
fs.renameSync(tmp, file);
|
|
277
|
+
if (sha256(fs.readFileSync(file)) !== asset.sha256) {
|
|
278
|
+
fs.rmSync(file, { force: true });
|
|
279
|
+
throw imageAssetError(
|
|
280
|
+
"image_asset_changed",
|
|
281
|
+
`${asset.displayName}: the copy written to ${asset.workspacePath} did not match the source digest; nothing was recorded`,
|
|
282
|
+
{ assetId: asset.assetId },
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return file;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function mintAssetId(manifest) {
|
|
289
|
+
for (;;) {
|
|
290
|
+
const id = `asset_${randomBytes(8).toString("hex")}`;
|
|
291
|
+
if (!findAssetById(manifest, id)) return id;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Adopt registry entries whose identity was minted ELSEWHERE — the only way an
|
|
297
|
+
* assetId this deck did not allocate may enter its manifest.
|
|
298
|
+
*
|
|
299
|
+
* It exists for one caller: re-adopting a .pptx that slidemind rendered, whose
|
|
300
|
+
* slides carry their own image provenance (src/lib/image-intent-tags.mjs). The
|
|
301
|
+
* uses baked into that file name their assets by ID, so re-minting one would
|
|
302
|
+
* leave every obligation dangling — the identity has to survive the round trip
|
|
303
|
+
* or nothing does.
|
|
304
|
+
*
|
|
305
|
+
* Every entry is proved before it is committed: the record parses against the
|
|
306
|
+
* contract, and the bytes already on disk at its `workspacePath` hash to the
|
|
307
|
+
* digest it claims. An assetId this deck already has is only accepted when it
|
|
308
|
+
* names the same bytes; a different digest behind a known ID is refused, never
|
|
309
|
+
* overwritten (entries are immutable). Refusals come back as `rejected`, not as
|
|
310
|
+
* a throw: a partial restore reports what it could not take.
|
|
311
|
+
*
|
|
312
|
+
* @returns {{added: string[], reused: string[], rejected: Array<{assetId, reason}>}}
|
|
313
|
+
*/
|
|
314
|
+
export function registerImageAssets(workspaceDir, entries) {
|
|
315
|
+
const manifest = readImageAssetManifest(workspaceDir);
|
|
316
|
+
const byId = new Map(manifest.assets.map((a) => [a.assetId, a]));
|
|
317
|
+
const added = [];
|
|
318
|
+
const reused = [];
|
|
319
|
+
const rejected = [];
|
|
320
|
+
const fresh = [];
|
|
321
|
+
for (const raw of entries ?? []) {
|
|
322
|
+
const parsed = parseImageAsset(raw);
|
|
323
|
+
if (!parsed.value) {
|
|
324
|
+
rejected.push({
|
|
325
|
+
assetId: raw?.assetId ?? null,
|
|
326
|
+
reason: parsed.issues[0]?.message ?? "invalid registry entry",
|
|
327
|
+
});
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const entry = parsed.value;
|
|
331
|
+
const existing = byId.get(entry.assetId);
|
|
332
|
+
if (existing) {
|
|
333
|
+
if (existing.sha256 === entry.sha256) reused.push(entry.assetId);
|
|
334
|
+
else {
|
|
335
|
+
rejected.push({
|
|
336
|
+
assetId: entry.assetId,
|
|
337
|
+
reason: `this deck already registers ${entry.assetId} for different bytes; a registry entry is never rewritten`,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
let bytes;
|
|
343
|
+
try {
|
|
344
|
+
bytes = fs.readFileSync(assetFilePath(workspaceDir, entry));
|
|
345
|
+
} catch {
|
|
346
|
+
rejected.push({ assetId: entry.assetId, reason: `${entry.workspacePath} is not in the deck` });
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (sha256(bytes) !== entry.sha256) {
|
|
350
|
+
rejected.push({
|
|
351
|
+
assetId: entry.assetId,
|
|
352
|
+
reason: `${entry.workspacePath} does not hash to the digest the record claims`,
|
|
353
|
+
});
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
byId.set(entry.assetId, entry);
|
|
357
|
+
fresh.push(entry);
|
|
358
|
+
added.push(entry.assetId);
|
|
359
|
+
}
|
|
360
|
+
if (fresh.length) {
|
|
361
|
+
publishManifest(workspaceDir, {
|
|
362
|
+
version: IMAGE_INTENT_VERSION,
|
|
363
|
+
assets: [...manifest.assets, ...fresh],
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return { added, reused, rejected };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// import
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Register a file's bytes with this deck.
|
|
375
|
+
*
|
|
376
|
+
* Filesystem paths only — acquiring bytes (an engine download, the harness's
|
|
377
|
+
* attachment access, the user's own browser) happens before this call, and this
|
|
378
|
+
* command never fetches. It infers no intent either: the returned entry obliges
|
|
379
|
+
* nothing until a plan gives it a use.
|
|
380
|
+
*
|
|
381
|
+
* Deduplicates by digest WITHIN the deck: re-importing the same bytes returns
|
|
382
|
+
* the entry they already have (`reused: true`), which is what makes a repeated
|
|
383
|
+
* flag or a re-run idempotent.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} workspaceDir the deck workspace
|
|
386
|
+
* @param {string} sourcePath a readable file
|
|
387
|
+
* @param {{displayName?: string, provenance?: {kind: string, sourceId?: string}, fault?: Function}} [opts]
|
|
388
|
+
* `fault` is the interruption seam: it runs after the bytes are on disk and
|
|
389
|
+
* before the manifest is swapped, so a test can prove that order holds.
|
|
390
|
+
* (Same idiom as `stageWorkspace().activate({fault})`.)
|
|
391
|
+
* @returns {Promise<{asset: object, reused: boolean, plan: object}>}
|
|
392
|
+
*/
|
|
393
|
+
export async function importImageAsset(
|
|
394
|
+
workspaceDir,
|
|
395
|
+
sourcePath,
|
|
396
|
+
{ displayName = null, provenance = { kind: "local" }, fault = null } = {},
|
|
397
|
+
) {
|
|
398
|
+
const file = path.resolve(sourcePath);
|
|
399
|
+
let bytes;
|
|
400
|
+
try {
|
|
401
|
+
bytes = fs.readFileSync(file);
|
|
402
|
+
} catch (err) {
|
|
403
|
+
if (err?.code === "EISDIR") {
|
|
404
|
+
throw imageAssetError("image_asset_unsupported", `${file} is a directory, not an image`);
|
|
405
|
+
}
|
|
406
|
+
throw imageAssetError("image_asset_missing", `no file to import at ${file}`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const name = String(displayName ?? path.basename(file)).trim().slice(0, 200) || path.basename(file);
|
|
410
|
+
// Inspection first: an undecodable source is refused before anything is
|
|
411
|
+
// copied, let alone committed.
|
|
412
|
+
const probe = inspectImageBytes(bytes, { displayName: name });
|
|
413
|
+
const digest = sha256(bytes);
|
|
414
|
+
|
|
415
|
+
// The deck's own lock, so two imports cannot both read the registry, append
|
|
416
|
+
// to it and write it back. Re-enters under an existing SLIDEMIND_OWNER token.
|
|
417
|
+
const { acquireOwner } = await import("./revision.mjs");
|
|
418
|
+
const held = acquireOwner(workspaceDir, { command: "assets import" });
|
|
419
|
+
try {
|
|
420
|
+
const manifest = readImageAssetManifest(workspaceDir);
|
|
421
|
+
|
|
422
|
+
const existing = manifest.assets.find((a) => a.sha256 === digest);
|
|
423
|
+
if (existing) {
|
|
424
|
+
// Immutable: the first identity stands, display name and all. Only the
|
|
425
|
+
// stored copy is restored, in case it was deleted since.
|
|
426
|
+
if (!fs.existsSync(assetFilePath(workspaceDir, existing))) {
|
|
427
|
+
writeAssetBytes(workspaceDir, existing, bytes);
|
|
428
|
+
}
|
|
429
|
+
return { asset: existing, reused: true, plan: imageRenderPlan(existing, bytes) };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const assetId = mintAssetId(manifest);
|
|
433
|
+
const entry = {
|
|
434
|
+
assetId,
|
|
435
|
+
sha256: digest,
|
|
436
|
+
mimeType: probe.mimeType,
|
|
437
|
+
width: probe.width,
|
|
438
|
+
height: probe.height,
|
|
439
|
+
byteLength: bytes.length,
|
|
440
|
+
displayName: name,
|
|
441
|
+
workspacePath: `${LOCAL_ASSET_DIR}/${assetId}.${probe.ext}`,
|
|
442
|
+
provenance: {
|
|
443
|
+
kind: provenance?.kind ?? "local",
|
|
444
|
+
...(provenance?.sourceId ? { sourceId: String(provenance.sourceId) } : {}),
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
// Our own record goes through the contract's parser: one validator, so the
|
|
448
|
+
// registry can never hold something the plan side would reject.
|
|
449
|
+
const parsed = parseImageAsset(entry);
|
|
450
|
+
if (!parsed.value) {
|
|
451
|
+
const first = parsed.issues[0];
|
|
452
|
+
throw imageAssetError(first?.code ?? "image_intent_invalid", `${name}: ${first?.message ?? "invalid entry"}`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
writeAssetBytes(workspaceDir, parsed.value, bytes);
|
|
456
|
+
if (fault) fault(parsed.value);
|
|
457
|
+
publishManifest(workspaceDir, {
|
|
458
|
+
version: IMAGE_INTENT_VERSION,
|
|
459
|
+
assets: [...manifest.assets, parsed.value],
|
|
460
|
+
});
|
|
461
|
+
return { asset: parsed.value, reused: false, plan: imageRenderPlan(parsed.value, bytes) };
|
|
462
|
+
} finally {
|
|
463
|
+
held.release();
|
|
464
|
+
}
|
|
465
|
+
}
|