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,531 @@
|
|
|
1
|
+
// `SLIDEMIND_CONTEXT_V1` — the portable context a .pptx carries about itself.
|
|
2
|
+
//
|
|
3
|
+
// A colleague opens a link on a machine that has never seen this deck. Nothing
|
|
4
|
+
// local can tell them what it is, which design it was built on, which business
|
|
5
|
+
// rules apply to it, or which external documents its content was approved
|
|
6
|
+
// against. The presentation-level tag is the only thing that travels WITH the
|
|
7
|
+
// file, so that is where those four facts live:
|
|
8
|
+
//
|
|
9
|
+
// { v, deckId, documentInstance, brand, intent, extensions, operationId }
|
|
10
|
+
//
|
|
11
|
+
// deckId LOGICAL identity — survives a copy, and is REMINTED when a
|
|
12
|
+
// copy becomes its own document (see the fork rule below).
|
|
13
|
+
// documentInstance PHYSICAL identity — provider:tenant:driveId:itemId of the
|
|
14
|
+
// item these bytes were published to. A cold open compares
|
|
15
|
+
// it with the item the user's own link resolved to; a
|
|
16
|
+
// mismatch means this is a copy, and a copy may not mutate
|
|
17
|
+
// the original's context.
|
|
18
|
+
// brand { presFP, etag } — which corporate design to materialize.
|
|
19
|
+
// intent bounded, human-scale purpose for an ORDINARY deck.
|
|
20
|
+
// extensions [{ id, protocolVersion, dataVersion, required, refs }] —
|
|
21
|
+
// which installed extension owns this deck's business rules
|
|
22
|
+
// and which exact artifacts its content was approved against.
|
|
23
|
+
// operationId the publish that wrote these bytes, so an unknown outcome
|
|
24
|
+
// can be resolved by reading the file back.
|
|
25
|
+
//
|
|
26
|
+
// Three rules this module enforces rather than documents:
|
|
27
|
+
//
|
|
28
|
+
// 1. **A ref is portable or it is not a ref.** Storage identity plus a content
|
|
29
|
+
// hash, never a local path and never a command. A deck that could name an
|
|
30
|
+
// executable would be a deck that could run one.
|
|
31
|
+
// 2. **A mutable ref pins an exact version or it is not paired.** An eTag is a
|
|
32
|
+
// precondition value, not a content address — it says "unchanged since",
|
|
33
|
+
// never "this content". A mutable artifact without a retrievable versionId
|
|
34
|
+
// needs an immutable snapshot ref beside it.
|
|
35
|
+
// 3. **Overflow is explicit.** The payload is capped; a deck that needs more
|
|
36
|
+
// context points at an immutable context ARTIFACT instead of being
|
|
37
|
+
// truncated into something that parses but means less than it says.
|
|
38
|
+
|
|
39
|
+
import { createHash } from "node:crypto";
|
|
40
|
+
|
|
41
|
+
import { readText } from "../pptx/zip.mjs";
|
|
42
|
+
import { relsFor, relsPathFor, resolvePartPath, decodeXmlEntities, TAG_ELEMENT, tagAttr, tagElements } from "../pptx/ooxml.mjs";
|
|
43
|
+
|
|
44
|
+
export const CONTEXT_VERSION = 1;
|
|
45
|
+
export const TAG_CONTEXT_META = "SLIDEMIND_CONTEXT_V1";
|
|
46
|
+
export const TAG_CONTEXT_CHUNK = "SLIDEMIND_CONTEXT_";
|
|
47
|
+
|
|
48
|
+
// What actually rides in the tag values, so this is the number PowerPoint sees.
|
|
49
|
+
// A deck past it publishes an immutable context artifact and points at it.
|
|
50
|
+
export const CONTEXT_MAX_ENCODED_BYTES = 32 * 1024;
|
|
51
|
+
|
|
52
|
+
// One tag value stays well under any host's attribute limit; the same size the
|
|
53
|
+
// source-tag encoder uses, for the same reason.
|
|
54
|
+
const CHUNK_BYTES = 12288;
|
|
55
|
+
|
|
56
|
+
const TAGS_REL_TYPE =
|
|
57
|
+
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags";
|
|
58
|
+
const TAGS_CONTENT_TYPE =
|
|
59
|
+
"application/vnd.openxmlformats-officedocument.presentationml.tags+xml";
|
|
60
|
+
const TAG_XML_NS =
|
|
61
|
+
'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
|
|
62
|
+
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
|
|
63
|
+
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"';
|
|
64
|
+
|
|
65
|
+
const PRESENTATION_PART = "ppt/presentation.xml";
|
|
66
|
+
const enc = (s) => new TextEncoder().encode(s);
|
|
67
|
+
const sha256 = (data) =>
|
|
68
|
+
createHash("sha256")
|
|
69
|
+
.update(typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data))
|
|
70
|
+
.digest("hex");
|
|
71
|
+
|
|
72
|
+
function xmlEsc(s) {
|
|
73
|
+
return String(s).replace(/[<>&"']/g, (c) =>
|
|
74
|
+
c === "<" ? "<" : c === ">" ? ">" : c === "&" ? "&" : c === '"' ? """ : "'",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// portable refs
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
export const MUTABILITY = ["immutable", "mutable"];
|
|
83
|
+
|
|
84
|
+
// Nothing outside this set may appear on a ref. The list is a WHITELIST rather
|
|
85
|
+
// than a blacklist of dangerous keys on purpose: the next dangerous key is the
|
|
86
|
+
// one nobody thought to ban.
|
|
87
|
+
const REF_KEYS = new Set([
|
|
88
|
+
"artifactId",
|
|
89
|
+
"purpose",
|
|
90
|
+
"required",
|
|
91
|
+
"mutability",
|
|
92
|
+
"provider",
|
|
93
|
+
"tenant",
|
|
94
|
+
"host",
|
|
95
|
+
"driveId",
|
|
96
|
+
"itemId",
|
|
97
|
+
"versionId",
|
|
98
|
+
"sha256",
|
|
99
|
+
"bytes",
|
|
100
|
+
"contentType",
|
|
101
|
+
"snapshotOf",
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Why a ref cannot travel, or null.
|
|
106
|
+
*
|
|
107
|
+
* The three refusals that matter: a local path (it means nothing on the machine
|
|
108
|
+
* that opens the file), an executable/command key (a deck must never be able to
|
|
109
|
+
* say what to run), and a mutable ref with no exact version and no immutable
|
|
110
|
+
* snapshot beside it (it would pair the deck with "whatever that file says
|
|
111
|
+
* today", which is not what anyone approved).
|
|
112
|
+
*/
|
|
113
|
+
export function refProblem(ref, { snapshots = new Set() } = {}) {
|
|
114
|
+
if (!ref || typeof ref !== "object") return "a ref must be an object";
|
|
115
|
+
for (const key of Object.keys(ref)) {
|
|
116
|
+
if (!REF_KEYS.has(key)) return `\`${key}\` is not a portable ref field`;
|
|
117
|
+
}
|
|
118
|
+
for (const required of ["artifactId", "purpose", "mutability", "sha256"]) {
|
|
119
|
+
if (!ref[required]) return `a ref needs ${required}`;
|
|
120
|
+
}
|
|
121
|
+
if (!MUTABILITY.includes(ref.mutability)) {
|
|
122
|
+
return `mutability must be immutable or mutable (got ${ref.mutability})`;
|
|
123
|
+
}
|
|
124
|
+
if (!/^[0-9a-f]{64}$/.test(String(ref.sha256))) return "sha256 must be a hex digest";
|
|
125
|
+
for (const key of ["provider", "tenant", "host", "driveId", "itemId", "versionId"]) {
|
|
126
|
+
const value = ref[key];
|
|
127
|
+
if (value === undefined || value === null) continue;
|
|
128
|
+
if (typeof value !== "string") return `${key} must be a string`;
|
|
129
|
+
// A Windows drive letter, a POSIX absolute path, a relative walk, or a
|
|
130
|
+
// file: URL — every shape a local path arrives in.
|
|
131
|
+
if (/^[a-zA-Z]:[\\/]/.test(value) || value.startsWith("/") || value.startsWith("\\")) {
|
|
132
|
+
return `${key} looks like a local path; portable refs carry storage identity only`;
|
|
133
|
+
}
|
|
134
|
+
if (value.includes("..")) return `${key} must not contain a path traversal`;
|
|
135
|
+
if (/^file:/i.test(value)) return `${key} must not be a file: URL`;
|
|
136
|
+
}
|
|
137
|
+
if (ref.mutability === "mutable" && !ref.versionId && !snapshots.has(ref.artifactId)) {
|
|
138
|
+
return (
|
|
139
|
+
"a mutable ref must pin an exact versionId, or be paired with an immutable " +
|
|
140
|
+
"snapshot ref whose snapshotOf names it"
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Validate a whole ref list together — snapshots are resolved across it. */
|
|
147
|
+
export function validateRefs(refs) {
|
|
148
|
+
const list = Array.isArray(refs) ? refs : [];
|
|
149
|
+
const snapshots = new Set(
|
|
150
|
+
list.filter((r) => r?.mutability === "immutable" && r?.snapshotOf).map((r) => r.snapshotOf),
|
|
151
|
+
);
|
|
152
|
+
const errors = [];
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
for (const ref of list) {
|
|
155
|
+
const why = refProblem(ref, { snapshots });
|
|
156
|
+
if (why) errors.push(`${ref?.artifactId ?? "(unnamed)"}: ${why}`);
|
|
157
|
+
if (ref?.artifactId) {
|
|
158
|
+
if (seen.has(ref.artifactId)) errors.push(`${ref.artifactId}: declared twice`);
|
|
159
|
+
seen.add(ref.artifactId);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return errors;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// the context record
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
// Ordinary-deck intent is BOUNDED: it is a handful of human sentences about who
|
|
170
|
+
// the deck is for and what it has to do, not a place to park a plan. The caps
|
|
171
|
+
// are what keep the tag inside its budget without truncating anything silently —
|
|
172
|
+
// over the cap is an error the caller resolves, never a quiet trim.
|
|
173
|
+
const INTENT_FIELDS = {
|
|
174
|
+
audience: { max: 400 },
|
|
175
|
+
objective: { max: 400 },
|
|
176
|
+
constraints: { max: 1200, list: true },
|
|
177
|
+
decisions: { max: 1200, list: true },
|
|
178
|
+
roles: { max: 400, list: true },
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export function intentProblems(intent) {
|
|
182
|
+
if (intent === null || intent === undefined) return [];
|
|
183
|
+
if (typeof intent !== "object" || Array.isArray(intent)) return ["intent must be an object"];
|
|
184
|
+
const errors = [];
|
|
185
|
+
for (const [key, value] of Object.entries(intent)) {
|
|
186
|
+
const spec = INTENT_FIELDS[key];
|
|
187
|
+
if (!spec) {
|
|
188
|
+
errors.push(`\`${key}\` is not a bounded intent field (${Object.keys(INTENT_FIELDS).join(", ")})`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const text = spec.list
|
|
192
|
+
? (Array.isArray(value) ? value : [value]).join("\n")
|
|
193
|
+
: String(value ?? "");
|
|
194
|
+
if (spec.list && !Array.isArray(value)) errors.push(`${key} must be an array`);
|
|
195
|
+
if (Buffer.byteLength(text, "utf8") > spec.max) {
|
|
196
|
+
errors.push(`${key} is longer than ${spec.max} bytes — put it in a context artifact instead`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return errors;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Check a whole context record. Returns the list of problems; empty means it may
|
|
204
|
+
* be encoded. Nothing here mutates or trims — a context that does not fit is a
|
|
205
|
+
* decision for the caller (publish an overflow artifact), not a silent edit.
|
|
206
|
+
*/
|
|
207
|
+
export function contextProblems(context) {
|
|
208
|
+
const errors = [];
|
|
209
|
+
if (!context || typeof context !== "object") return ["context must be an object"];
|
|
210
|
+
if (context.v !== CONTEXT_VERSION) errors.push(`v must be ${CONTEXT_VERSION}`);
|
|
211
|
+
if (!context.deckId) errors.push("deckId is required");
|
|
212
|
+
errors.push(...intentProblems(context.intent));
|
|
213
|
+
for (const ext of context.extensions ?? []) {
|
|
214
|
+
if (!ext?.id) {
|
|
215
|
+
errors.push("an extension declaration needs an id");
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (ext.protocolVersion !== 1) {
|
|
219
|
+
errors.push(`${ext.id}: protocolVersion must be 1 in this schema`);
|
|
220
|
+
}
|
|
221
|
+
if (ext.command || ext.exec || ext.path) {
|
|
222
|
+
// The one rule a deck must never be able to bend: metadata SELECTS an
|
|
223
|
+
// installed extension by id, it never supplies what to run.
|
|
224
|
+
errors.push(`${ext.id}: a context may name an extension, never a command to run`);
|
|
225
|
+
}
|
|
226
|
+
errors.push(...validateRefs(ext.refs).map((e) => `${ext.id}: ${e}`));
|
|
227
|
+
}
|
|
228
|
+
if (context.overflowRef) {
|
|
229
|
+
errors.push(...validateRefs([context.overflowRef]).map((e) => `overflowRef: ${e}`));
|
|
230
|
+
}
|
|
231
|
+
return errors;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The canonical JSON a digest is taken over — key order must not change a digest. */
|
|
235
|
+
export function canonicalContext(context) {
|
|
236
|
+
const sortKeys = (value) => {
|
|
237
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
238
|
+
if (value && typeof value === "object") {
|
|
239
|
+
return Object.fromEntries(
|
|
240
|
+
Object.keys(value)
|
|
241
|
+
.sort()
|
|
242
|
+
.map((k) => [k, sortKeys(value[k])]),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return value;
|
|
246
|
+
};
|
|
247
|
+
return JSON.stringify(sortKeys(context));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The digest an extension's evidence binds to. */
|
|
251
|
+
export function contextDigest(context) {
|
|
252
|
+
return sha256(canonicalContext(context));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Encode a context into its tag map, or throw with the reason.
|
|
257
|
+
*
|
|
258
|
+
* `context_too_large` is thrown rather than truncated: the caller's answer is an
|
|
259
|
+
* immutable context artifact plus an `overflowRef`, and a half-written intent
|
|
260
|
+
* that still parses is exactly the silent loss this whole change exists to stop.
|
|
261
|
+
*/
|
|
262
|
+
export function encodeContextTags(context) {
|
|
263
|
+
const problems = contextProblems(context);
|
|
264
|
+
if (problems.length) {
|
|
265
|
+
const e = new Error(`this presentation context cannot be written:\n ${problems.join("\n ")}`);
|
|
266
|
+
e.code = "context_invalid";
|
|
267
|
+
e.problems = problems;
|
|
268
|
+
throw e;
|
|
269
|
+
}
|
|
270
|
+
const json = canonicalContext(context);
|
|
271
|
+
const payload = Buffer.from(json, "utf8").toString("base64");
|
|
272
|
+
if (Buffer.byteLength(payload, "utf8") > CONTEXT_MAX_ENCODED_BYTES) {
|
|
273
|
+
const e = new Error(
|
|
274
|
+
`this presentation context encodes to ${Buffer.byteLength(payload, "utf8")} bytes, over the ` +
|
|
275
|
+
`${CONTEXT_MAX_ENCODED_BYTES}-byte tag budget. Publish the detail as an immutable context ` +
|
|
276
|
+
`artifact and point at it with overflowRef.`,
|
|
277
|
+
);
|
|
278
|
+
e.code = "context_too_large";
|
|
279
|
+
e.encodedBytes = Buffer.byteLength(payload, "utf8");
|
|
280
|
+
throw e;
|
|
281
|
+
}
|
|
282
|
+
const chunks = [];
|
|
283
|
+
for (let i = 0; i < payload.length; i += CHUNK_BYTES) {
|
|
284
|
+
chunks.push(payload.slice(i, i + CHUNK_BYTES));
|
|
285
|
+
}
|
|
286
|
+
const tags = {
|
|
287
|
+
[TAG_CONTEXT_META]: JSON.stringify({
|
|
288
|
+
v: CONTEXT_VERSION,
|
|
289
|
+
chunks: chunks.length,
|
|
290
|
+
sha256: sha256(json),
|
|
291
|
+
bytes: Buffer.byteLength(json, "utf8"),
|
|
292
|
+
}),
|
|
293
|
+
};
|
|
294
|
+
chunks.forEach((c, i) => {
|
|
295
|
+
tags[`${TAG_CONTEXT_CHUNK}${i}`] = c;
|
|
296
|
+
});
|
|
297
|
+
return tags;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Decode a context out of a tag map.
|
|
302
|
+
*
|
|
303
|
+
* Returns `{ context, digest }`, or null when there is no context at all, or
|
|
304
|
+
* `{ corrupt: <why> }` when there is one and it does not hold together. The
|
|
305
|
+
* three answers are deliberately distinct: "no context" is a normal ordinary
|
|
306
|
+
* deck, "corrupt context" is a deck whose business gates cannot be assumed gone.
|
|
307
|
+
*/
|
|
308
|
+
export function decodeContextTags(tags) {
|
|
309
|
+
const raw = tags?.[TAG_CONTEXT_META];
|
|
310
|
+
if (!raw) return null;
|
|
311
|
+
let meta;
|
|
312
|
+
try {
|
|
313
|
+
meta = JSON.parse(raw);
|
|
314
|
+
} catch {
|
|
315
|
+
return { corrupt: "the context header is not readable JSON" };
|
|
316
|
+
}
|
|
317
|
+
if (meta?.v !== CONTEXT_VERSION) {
|
|
318
|
+
return { corrupt: `context schema v${meta?.v} is not readable by this CLI (expected v${CONTEXT_VERSION})` };
|
|
319
|
+
}
|
|
320
|
+
const parts = [];
|
|
321
|
+
for (let i = 0; i < Number(meta.chunks ?? 0); i++) {
|
|
322
|
+
const part = tags[`${TAG_CONTEXT_CHUNK}${i}`];
|
|
323
|
+
if (part === undefined) return { corrupt: `context chunk ${i} is missing` };
|
|
324
|
+
parts.push(part);
|
|
325
|
+
}
|
|
326
|
+
let json;
|
|
327
|
+
try {
|
|
328
|
+
json = Buffer.from(parts.join(""), "base64").toString("utf8");
|
|
329
|
+
} catch {
|
|
330
|
+
return { corrupt: "the context payload is not decodable" };
|
|
331
|
+
}
|
|
332
|
+
if (sha256(json) !== meta.sha256) {
|
|
333
|
+
return { corrupt: "the context payload does not match its recorded digest" };
|
|
334
|
+
}
|
|
335
|
+
let context;
|
|
336
|
+
try {
|
|
337
|
+
context = JSON.parse(json);
|
|
338
|
+
} catch {
|
|
339
|
+
return { corrupt: "the context payload is not readable JSON" };
|
|
340
|
+
}
|
|
341
|
+
const problems = contextProblems(context);
|
|
342
|
+
if (problems.length) return { corrupt: problems.join("; "), context };
|
|
343
|
+
return { context, digest: sha256(json) };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// package side
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
/** Every tag reachable from `ppt/presentation.xml`'s own custDataLst. */
|
|
351
|
+
export function readPresentationTags(files) {
|
|
352
|
+
const xml = readText(files, PRESENTATION_PART);
|
|
353
|
+
if (!xml) return {};
|
|
354
|
+
const tagRels = {};
|
|
355
|
+
for (const rel of relsFor(files, PRESENTATION_PART)) {
|
|
356
|
+
if (rel.id && rel.type === TAGS_REL_TYPE) {
|
|
357
|
+
tagRels[rel.id] = resolvePartPath("ppt", rel.target);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const out = {};
|
|
361
|
+
for (const cust of xml.matchAll(/<p:custDataLst\b[\s\S]*?<\/p:custDataLst>/g)) {
|
|
362
|
+
for (const ref of cust[0].matchAll(/<p:tags\b[^>]*\br:id="([^"]+)"/g)) {
|
|
363
|
+
const partPath = tagRels[ref[1]];
|
|
364
|
+
const tagXml = partPath ? readText(files, partPath) : null;
|
|
365
|
+
if (!tagXml) continue;
|
|
366
|
+
for (const t of tagElements(tagXml)) {
|
|
367
|
+
const name = tagAttr(t[1], "name");
|
|
368
|
+
const val = tagAttr(t[1], "val");
|
|
369
|
+
if (name) out[decodeXmlEntities(name)] = decodeXmlEntities(val ?? "");
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return out;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** The context a package carries, in the three-answer shape of {@link decodeContextTags}. */
|
|
377
|
+
export function readPresentationContext(files) {
|
|
378
|
+
return decodeContextTags(readPresentationTags(files));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function nextTagIndex(files) {
|
|
382
|
+
let max = 0;
|
|
383
|
+
for (const name of Object.keys(files)) {
|
|
384
|
+
const m = name.match(/^ppt\/tags\/tag(\d+)\.xml$/);
|
|
385
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
386
|
+
}
|
|
387
|
+
return max + 1;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function nextRid(relsXml) {
|
|
391
|
+
let max = 0;
|
|
392
|
+
for (const m of relsXml.matchAll(/\bId="rId(\d+)"/g)) max = Math.max(max, Number(m[1]));
|
|
393
|
+
return max + 1;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Drop the tag part this module previously wrote, with its relationship, its
|
|
397
|
+
// content-type override and its custDataLst reference. Republishing without
|
|
398
|
+
// this would stack a second context on every save, and the reader would join
|
|
399
|
+
// two headers into one unreadable payload.
|
|
400
|
+
function removeExistingContext(files) {
|
|
401
|
+
const xml = readText(files, PRESENTATION_PART);
|
|
402
|
+
if (!xml) return { xml: null, changed: false };
|
|
403
|
+
const relsPath = relsPathFor(PRESENTATION_PART);
|
|
404
|
+
let relsXml = readText(files, relsPath);
|
|
405
|
+
if (!relsXml) return { xml, changed: false };
|
|
406
|
+
let nextXml = xml;
|
|
407
|
+
let changed = false;
|
|
408
|
+
for (const rel of relsFor(files, PRESENTATION_PART)) {
|
|
409
|
+
if (rel.type !== TAGS_REL_TYPE || !rel.id) continue;
|
|
410
|
+
const partPath = resolvePartPath("ppt", rel.target);
|
|
411
|
+
const tagXml = readText(files, partPath);
|
|
412
|
+
if (!tagXml || ![...tagElements(tagXml)].some((t) => tagAttr(t[1], "name") === TAG_CONTEXT_META)) continue;
|
|
413
|
+
// A part that also holds another add-in's tags is shared: strip only ours
|
|
414
|
+
// and keep the part, its relationship and its reference in place.
|
|
415
|
+
const withoutOurs = tagXml.replace(new RegExp(TAG_ELEMENT, "g"), (tag) => (/\bname=["']SLIDEMIND_CONTEXT/.test(tag) ? "" : tag));
|
|
416
|
+
if (/<p:tag\b/.test(withoutOurs)) {
|
|
417
|
+
files[partPath] = enc(withoutOurs);
|
|
418
|
+
changed = true;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
delete files[partPath];
|
|
422
|
+
relsXml = relsXml.replace(
|
|
423
|
+
new RegExp(`<Relationship\\b[^>]*\\bId="${rel.id}"[^>]*/>`, "g"),
|
|
424
|
+
"",
|
|
425
|
+
);
|
|
426
|
+
nextXml = nextXml.replace(
|
|
427
|
+
new RegExp(`<p:tags\\b[^>]*\\br:id="${rel.id}"\\s*/>`, "g"),
|
|
428
|
+
"",
|
|
429
|
+
);
|
|
430
|
+
const ct = readText(files, "[Content_Types].xml");
|
|
431
|
+
if (ct) {
|
|
432
|
+
files["[Content_Types].xml"] = enc(
|
|
433
|
+
ct.replace(new RegExp(`<Override\\b[^>]*PartName="/${partPath}"[^>]*/>`, "g"), ""),
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
changed = true;
|
|
437
|
+
}
|
|
438
|
+
if (changed) {
|
|
439
|
+
// An emptied list is removed rather than left behind: PowerPoint accepts an
|
|
440
|
+
// empty p:custDataLst, but leaving one on every save is litter nobody reads.
|
|
441
|
+
nextXml = nextXml.replace(/<p:custDataLst>\s*<\/p:custDataLst>/g, "");
|
|
442
|
+
files[relsPath] = enc(relsXml);
|
|
443
|
+
}
|
|
444
|
+
return { xml: nextXml, changed };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// CT_Presentation fixes the child order, and PowerPoint repairs a file that
|
|
448
|
+
// breaks it. custDataLst sits after photoAlbum and before kinsoku — so the
|
|
449
|
+
// insertion point is the first of the four elements that may follow it.
|
|
450
|
+
const AFTER_CUST_DATA = ["p:kinsoku", "p:defaultTextStyle", "p:modifyVerifier", "p:extLst"];
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Write a context into a package IN PLACE, replacing any context this module
|
|
454
|
+
* wrote before. Additive to everything else: another add-in's presentation tags,
|
|
455
|
+
* the slide-level source tags and every other part are untouched.
|
|
456
|
+
*/
|
|
457
|
+
export function bakePresentationContext(files, context) {
|
|
458
|
+
const tags = encodeContextTags(context);
|
|
459
|
+
const { xml: stripped } = removeExistingContext(files);
|
|
460
|
+
const xml = stripped ?? readText(files, PRESENTATION_PART);
|
|
461
|
+
if (!xml) return false;
|
|
462
|
+
|
|
463
|
+
// CT_CustomerDataList allows ONE p:tags. A presentation that already carries
|
|
464
|
+
// one (a pack's master may bring think-cell's) gets our tags merged into
|
|
465
|
+
// that part; a second p:tags element is a PowerPoint repair prompt
|
|
466
|
+
// (observed 2026-09-17: PowerPoint kept the other add-in's and dropped ours).
|
|
467
|
+
const shared = xml.match(/<p:custDataLst\b[^>]*>[\s\S]*?<p:tags\b[^>]*\br:id="([^"]+)"/);
|
|
468
|
+
if (shared) {
|
|
469
|
+
const rel = relsFor(files, PRESENTATION_PART).find((r) => r.id === shared[1] && r.type === TAGS_REL_TYPE);
|
|
470
|
+
const partPath = rel ? resolvePartPath("ppt", rel.target) : null;
|
|
471
|
+
const tagXml = partPath ? readText(files, partPath) : null;
|
|
472
|
+
if (tagXml && tagXml.includes("</p:tagLst>")) {
|
|
473
|
+
const ours = Object.entries(tags)
|
|
474
|
+
.map(([name, val]) => `<p:tag name="${xmlEsc(name)}" val="${xmlEsc(val)}"/>`)
|
|
475
|
+
.join("");
|
|
476
|
+
files[partPath] = enc(tagXml.replace("</p:tagLst>", `${ours}</p:tagLst>`));
|
|
477
|
+
files[PRESENTATION_PART] = enc(xml);
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const tagIndex = nextTagIndex(files);
|
|
483
|
+
const partName = `ppt/tags/tag${tagIndex}.xml`;
|
|
484
|
+
files[partName] = enc(
|
|
485
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n<p:tagLst ${TAG_XML_NS}>` +
|
|
486
|
+
Object.entries(tags)
|
|
487
|
+
.map(([name, val]) => `<p:tag name="${xmlEsc(name)}" val="${xmlEsc(val)}"/>`)
|
|
488
|
+
.join("") +
|
|
489
|
+
`</p:tagLst>`,
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
const contentTypes = readText(files, "[Content_Types].xml") ?? "";
|
|
493
|
+
if (contentTypes && !contentTypes.includes(`PartName="/${partName}"`)) {
|
|
494
|
+
files["[Content_Types].xml"] = enc(
|
|
495
|
+
contentTypes.replace(
|
|
496
|
+
"</Types>",
|
|
497
|
+
`<Override PartName="/${partName}" ContentType="${TAGS_CONTENT_TYPE}"/></Types>`,
|
|
498
|
+
),
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const relsPath = relsPathFor(PRESENTATION_PART);
|
|
503
|
+
const relsXml =
|
|
504
|
+
readText(files, relsPath) ??
|
|
505
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`;
|
|
506
|
+
const rId = `rId${nextRid(relsXml)}`;
|
|
507
|
+
files[relsPath] = enc(
|
|
508
|
+
relsXml.replace(
|
|
509
|
+
"</Relationships>",
|
|
510
|
+
`<Relationship Id="${rId}" Type="${TAGS_REL_TYPE}" Target="tags/tag${tagIndex}.xml"/></Relationships>`,
|
|
511
|
+
),
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
const existing = xml.match(/<p:custDataLst\b[^>]*?(\/?)>/);
|
|
515
|
+
let nextXml;
|
|
516
|
+
if (existing && existing[1] === "/") {
|
|
517
|
+
nextXml = xml.replace(
|
|
518
|
+
existing[0],
|
|
519
|
+
`${existing[0].replace(/\/>$/, ">")}<p:tags r:id="${rId}"/></p:custDataLst>`,
|
|
520
|
+
);
|
|
521
|
+
} else if (existing) {
|
|
522
|
+
nextXml = xml.replace(existing[0], `${existing[0]}<p:tags r:id="${rId}"/>`);
|
|
523
|
+
} else {
|
|
524
|
+
const block = `<p:custDataLst><p:tags r:id="${rId}"/></p:custDataLst>`;
|
|
525
|
+
const anchor = AFTER_CUST_DATA.map((tag) => xml.indexOf(`<${tag}`)).filter((i) => i >= 0);
|
|
526
|
+
const at = anchor.length ? Math.min(...anchor) : xml.lastIndexOf("</p:presentation>");
|
|
527
|
+
nextXml = at >= 0 ? xml.slice(0, at) + block + xml.slice(at) : xml;
|
|
528
|
+
}
|
|
529
|
+
files[PRESENTATION_PART] = enc(nextXml);
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Preservation provenance through the deck-embedded source tags — what a slide
|
|
2
|
+
// must still contain, baked into the .pptx it was rendered into and read back
|
|
3
|
+
// out when that file is adopted again (openspec `safeguard-slide-editing`,
|
|
4
|
+
// task 5.7 / E19–E20).
|
|
5
|
+
//
|
|
6
|
+
// Why it rides the slide's own tags rather than only `state/`: the .pptx is the
|
|
7
|
+
// only thing that travels. A rendered deck is mailed, opened, saved and handed
|
|
8
|
+
// back, and `slidemind adopt` rebuilds a workspace from it alone. The slide's
|
|
9
|
+
// jsx, assets, identity, drift baseline and image obligations all survive that
|
|
10
|
+
// round trip already (src/lib/image-intent-tags.mjs is the same shape, one
|
|
11
|
+
// contract over). An obligation that did not would come back as a slide nobody
|
|
12
|
+
// could prove had kept its connector, its native object or its chart.
|
|
13
|
+
//
|
|
14
|
+
// Two halves, both narrow:
|
|
15
|
+
//
|
|
16
|
+
// BAKE `preservationTagMeta` returns the slide's entry out of
|
|
17
|
+
// `state/slide-preservation.json`, keyed by the slide's own GUID.
|
|
18
|
+
// RESTORE `restorePreservationFromTags` puts entries back for the slides
|
|
19
|
+
// adopt restored from their own proved-current source.
|
|
20
|
+
//
|
|
21
|
+
// Four rules the restore exists to keep — the deletion ones are the subtle
|
|
22
|
+
// half, and they run in both directions:
|
|
23
|
+
//
|
|
24
|
+
// * **A file with no `preservation` carries no obligations.** A plain
|
|
25
|
+
// imported .pptx, or one rendered before this contract, is legacy and stays
|
|
26
|
+
// legacy; `migrateEditState` seeds it from CURRENT evidence instead. An
|
|
27
|
+
// obligation is never manufactured from a shape that happens to be there.
|
|
28
|
+
// * **Only a slide restored from its own proved-current source restores.**
|
|
29
|
+
// A slide that had to be re-converted is rebuilt content — adopt itself
|
|
30
|
+
// judged the embedded payload stale (`sourceMatchesSlide`) — so the element
|
|
31
|
+
// the record names may be the one the user just deleted in PowerPoint.
|
|
32
|
+
// A stale-but-valid payload never wins over current truth, here as
|
|
33
|
+
// everywhere else in adoption.
|
|
34
|
+
// * **An entry already on disk is never overwritten.** The workspace's record
|
|
35
|
+
// is the current statement of what is owed; the file's is history. That is
|
|
36
|
+
// what stops an OLDER .pptx from resurrecting a requirement this deck
|
|
37
|
+
// superseded — and what makes the restore idempotent, so a resumed or
|
|
38
|
+
// repeated adopt re-seeds nothing.
|
|
39
|
+
// * **`superseded` travels with `active`.** An intentional deletion is part
|
|
40
|
+
// of the record, so a deck re-adopted into a FRESH workspace comes back
|
|
41
|
+
// with the obligation still ended rather than silently owed again.
|
|
42
|
+
//
|
|
43
|
+
// Evidence is deliberately NOT carried across: a re-adoption is a new base, and
|
|
44
|
+
// a workspace that has verified nothing must say `unknown` rather than inherit
|
|
45
|
+
// someone else's `passed` (guide §5 — missing evidence is distinct from a
|
|
46
|
+
// passed check, and any intervening change invalidates a verdict).
|
|
47
|
+
|
|
48
|
+
import {
|
|
49
|
+
SLIDE_PRESERVATION_VERSION,
|
|
50
|
+
validateSlidePreservation,
|
|
51
|
+
} from "./slide-preservation.mjs";
|
|
52
|
+
import { readSlidePreservation, writeSlidePreservation } from "./edit-state.mjs";
|
|
53
|
+
|
|
54
|
+
export const PRESERVATION_META_KEY = "preservation";
|
|
55
|
+
|
|
56
|
+
// ponytail: a flat size cap, not a chunked channel. SRC_META is one tag value
|
|
57
|
+
// and already carries the asset list; the source and the drift baseline get
|
|
58
|
+
// their own chunk streams (CHUNK_BYTES = 12288) precisely because they can be
|
|
59
|
+
// large. An entry is a few hundred bytes per requirement, so this only bites on
|
|
60
|
+
// a slide with ~50 of them — and the upgrade path is the same chunking the
|
|
61
|
+
// baseline uses (TAG_BASELINE_CHUNK), not a bigger number here.
|
|
62
|
+
const META_BUDGET = 8192;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The deck's preservation document for a whole bake, or null when it cannot be
|
|
66
|
+
* read. Read once and handed to {@link preservationTagMeta} per slide: the bake
|
|
67
|
+
* already avoids re-reading per-slide state, and an unreadable or future-schema
|
|
68
|
+
* record must not be the thing that fails a render — the commands that actually
|
|
69
|
+
* need it refuse on their own.
|
|
70
|
+
*/
|
|
71
|
+
export function preservationDocForBake(deckDir) {
|
|
72
|
+
try {
|
|
73
|
+
return readSlidePreservation(deckDir);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The `SRC_META.preservation` payload for one slide, or null when the slide has
|
|
81
|
+
* no record — which keeps its tags byte-identical to what they were before this
|
|
82
|
+
* contract existed.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} deckDir the deck workspace
|
|
85
|
+
* @param {string} slideKey the slide's durable identity (its GUID)
|
|
86
|
+
* @param {{ preservation?: object }} [opts] a pre-read document, so a whole-deck
|
|
87
|
+
* bake reads `state/slide-preservation.json` once instead of once per slide
|
|
88
|
+
*/
|
|
89
|
+
export function preservationTagMeta(deckDir, slideKey, { preservation = null } = {}) {
|
|
90
|
+
if (!slideKey) return null;
|
|
91
|
+
const doc = preservation ?? preservationDocForBake(deckDir);
|
|
92
|
+
const entry = doc?.slides?.[slideKey];
|
|
93
|
+
if (!entry) return null;
|
|
94
|
+
if (JSON.stringify(entry).length > META_BUDGET) return null;
|
|
95
|
+
return entry;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Restore the obligations a set of adopted slides carried.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} deckDir the deck being adopted into
|
|
102
|
+
* @param {Array<{slideKey: string|null, entry: object|null}>} entries one per
|
|
103
|
+
* slide adopted from its own proved-current source. `slideKey` is the GUID
|
|
104
|
+
* this adoption assigned, which is NOT the embedded one when the slide turned
|
|
105
|
+
* out to be a duplicate — the record follows the identity the slide has here.
|
|
106
|
+
* @returns {{slides: number, notes: string[]}}
|
|
107
|
+
*/
|
|
108
|
+
export function restorePreservationFromTags(deckDir, entries) {
|
|
109
|
+
const notes = [];
|
|
110
|
+
const wanted = (entries ?? []).filter((e) => e?.entry && e.slideKey);
|
|
111
|
+
if (!wanted.length) return { slides: 0, notes };
|
|
112
|
+
|
|
113
|
+
let previous;
|
|
114
|
+
try {
|
|
115
|
+
previous = readSlidePreservation(deckDir);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
notes.push(`recorded obligations were not restored: ${err?.message ?? err}`);
|
|
118
|
+
return { slides: 0, notes };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const slides = { ...previous.slides };
|
|
122
|
+
let restored = 0;
|
|
123
|
+
for (const { slideKey, entry } of wanted) {
|
|
124
|
+
if (slides[slideKey]) continue; // never overwrite a live record with history
|
|
125
|
+
if (entry.version !== SLIDE_PRESERVATION_VERSION) {
|
|
126
|
+
notes.push(
|
|
127
|
+
`a slide records preservation version ${JSON.stringify(entry.version ?? null)} — ` +
|
|
128
|
+
`this CLI reads version ${SLIDE_PRESERVATION_VERSION}; its obligations were not restored`,
|
|
129
|
+
);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// Bound to the identity this deck gave the slide, and to no verdict: the
|
|
133
|
+
// requirements come back, the claim that they were met does not.
|
|
134
|
+
const rebound = { ...entry, slideKey, evidence: "unknown" };
|
|
135
|
+
// The payload came out of an untrusted file — shape-check before it becomes
|
|
136
|
+
// this deck's statement of what is owed.
|
|
137
|
+
const { ok, errors } = validateSlidePreservation(rebound, { slideKey });
|
|
138
|
+
if (!ok) {
|
|
139
|
+
notes.push(
|
|
140
|
+
`slide ${slideKey}: its recorded obligations are not a valid preservation record ` +
|
|
141
|
+
`(${errors[0]?.path}: ${errors[0]?.message}) — they were not restored`,
|
|
142
|
+
);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
slides[slideKey] = rebound;
|
|
146
|
+
restored += 1;
|
|
147
|
+
}
|
|
148
|
+
if (restored === 0) return { slides: 0, notes };
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
writeSlidePreservation(deckDir, { version: SLIDE_PRESERVATION_VERSION, slides });
|
|
152
|
+
} catch (err) {
|
|
153
|
+
notes.push(`recorded obligations were not restored: ${err?.message ?? err}`);
|
|
154
|
+
return { slides: 0, notes };
|
|
155
|
+
}
|
|
156
|
+
return { slides: restored, notes };
|
|
157
|
+
}
|