slidemind 0.3.20 → 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/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
package/src/install-channel.mjs
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// cannot put a bin on PATH), so a Codex-plugin session always runs the npm CLI
|
|
18
18
|
// and the npm remediation is already correct there.
|
|
19
19
|
|
|
20
|
+
import fs from "node:fs";
|
|
20
21
|
import path from "node:path";
|
|
21
22
|
import { fileURLToPath } from "node:url";
|
|
22
23
|
|
|
@@ -50,12 +51,68 @@ export function isClaudePluginInstall(root = PACKAGE_ROOT) {
|
|
|
50
51
|
return false;
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
// Which sandbox is confining this process, if any? Codex exports CODEX_SANDBOX
|
|
55
|
+
// (`seatbelt` on macOS, `landlock` on Linux) for every command it runs under
|
|
56
|
+
// the sandbox, and leaves it unset for a full-access run — so its presence is
|
|
57
|
+
// a reliable "my writes are confined to the workspace" signal. Returned as the
|
|
58
|
+
// name so a caller can word a message, not just a boolean.
|
|
59
|
+
export function sandboxName() {
|
|
60
|
+
const s = process.env.CODEX_SANDBOX;
|
|
61
|
+
return s && s !== "none" ? s : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Can a global `npm install -g slidemind@latest` launched from THIS process
|
|
65
|
+
// actually replace the running binary? npm renames the package directory in
|
|
66
|
+
// place, so the node_modules that holds slidemind must be writable. Two things
|
|
67
|
+
// break that, and the fix for each is the same escalation:
|
|
68
|
+
// "sandbox" — a Codex sandbox confines writes to the workspace (and the
|
|
69
|
+
// config dir too, so the "we already tried" record can never
|
|
70
|
+
// persist and a doomed install would respawn every command)
|
|
71
|
+
// "permission" — a root-owned global prefix
|
|
72
|
+
// Returns the reason, or null when the install would work. `root` is overridable
|
|
73
|
+
// for tests, mirroring isClaudePluginInstall(root).
|
|
74
|
+
export function globalInstallBlock(root = PACKAGE_ROOT) {
|
|
75
|
+
const dir = path.dirname(path.resolve(root));
|
|
76
|
+
const probe = path.join(
|
|
77
|
+
dir,
|
|
78
|
+
`.slidemind-wp-${process.pid}-${Math.random().toString(36).slice(2)}`,
|
|
79
|
+
);
|
|
80
|
+
try {
|
|
81
|
+
fs.writeFileSync(probe, "");
|
|
82
|
+
fs.rmSync(probe, { force: true });
|
|
83
|
+
return null;
|
|
84
|
+
} catch {
|
|
85
|
+
return sandboxName() ? "sandbox" : "permission";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
53
89
|
// The one sentence every version-floor / too-old-CLI message ends with, so the
|
|
54
|
-
// remediation always names the updater that actually replaces THIS binary
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
90
|
+
// remediation always names the updater that actually replaces THIS binary —
|
|
91
|
+
// and asks for escalated rights when the running process cannot write the
|
|
92
|
+
// global install itself (a Codex sandbox, or a root-owned prefix).
|
|
93
|
+
// `block` lets a caller that already resolved globalInstallBlock() pass it in
|
|
94
|
+
// (so the message matches the decision exactly); omitted, it self-detects — the
|
|
95
|
+
// shape the hard-block callers (connection.mjs, transport.mjs) use.
|
|
96
|
+
export function updateCliInstruction(block = globalInstallBlock()) {
|
|
97
|
+
if (isClaudePluginInstall()) {
|
|
98
|
+
return (
|
|
99
|
+
"This CLI came from the slidemind Claude Code plugin, so update it there: " +
|
|
100
|
+
"`/plugin` → slidemind → update (a global `npm install -g slidemind@latest` " +
|
|
101
|
+
"installs a separate copy and will NOT change the `slidemind` this session runs)."
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (block === "sandbox") {
|
|
105
|
+
return (
|
|
106
|
+
"This session is sandboxed and cannot replace the global install itself — " +
|
|
107
|
+
"run `slidemind update` with escalated permissions (approve running it " +
|
|
108
|
+
"outside the sandbox) to update the CLI, then retry."
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (block === "permission") {
|
|
112
|
+
return (
|
|
113
|
+
"The global install is not writable from here — run `slidemind update` " +
|
|
114
|
+
"with escalated permissions (e.g. `sudo`) to update the CLI, then retry."
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return "Run `slidemind update` (or `npm install -g slidemind@latest`), then retry.";
|
|
61
118
|
}
|
package/src/lib/adopt.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stageWorkspace } from './workspace-transaction.mjs';
|
|
1
2
|
// `slidemind adopt <file.pptx>` — reconstruct a local deck workspace from a
|
|
2
3
|
// presentation's deck-embedded source tags (intern-slidemind
|
|
3
4
|
// docs/deck-embedded-source-spec.md §6.2) and, where the add-in offers it,
|
|
@@ -26,11 +27,21 @@ import path from "node:path";
|
|
|
26
27
|
|
|
27
28
|
import { unzip } from "../pptx/zip.mjs";
|
|
28
29
|
import { slideOrderPaths } from "../pptx/ooxml.mjs";
|
|
29
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
TAG_GUID,
|
|
32
|
+
computeProjection,
|
|
33
|
+
decodeSourceTags,
|
|
34
|
+
mintSlideGuid,
|
|
35
|
+
readSlideTags,
|
|
36
|
+
withoutChartHashes,
|
|
37
|
+
} from "../pptx/tags.mjs";
|
|
30
38
|
import { foldPickedSlides } from "../pptx/slide-swap.mjs";
|
|
31
39
|
import { readDeckBundleRef, writeDeckBundleRef } from "../bridge/bundle.mjs";
|
|
40
|
+
import { IMAGE_INTENT_META_KEY, restoreImageIntentFromTags } from "./image-intent-tags.mjs";
|
|
41
|
+
import { PRESERVATION_META_KEY, restorePreservationFromTags } from "./preservation-tags.mjs";
|
|
32
42
|
import { loadSlideGuids, saveSlideGuids, slideGuidEntry } from "./slide-guids.mjs";
|
|
33
43
|
import { writeConversionRecord } from "./conversions.mjs";
|
|
44
|
+
import { deckSlug } from "./deck-paths.mjs";
|
|
34
45
|
|
|
35
46
|
// Converted JSX needs a fresh baseline against the imported PPTX. Source tags
|
|
36
47
|
// alone cannot recover the original conversion evidence or preserved natives.
|
|
@@ -39,6 +50,52 @@ export function needsConversion(decoded) {
|
|
|
39
50
|
/^\s*\/\/\s*SLIDEMIND_DIALECT:\s*converted\b/m.test(decoded.jsx);
|
|
40
51
|
}
|
|
41
52
|
|
|
53
|
+
/**
|
|
54
|
+
* May the embedded JSX be written back verbatim?
|
|
55
|
+
*
|
|
56
|
+
* `decodeSourceTags` proves the PAYLOAD is intact. That is a different question
|
|
57
|
+
* from whether it still describes the slide: a normal PowerPoint text, geometry
|
|
58
|
+
* or style edit leaves the payload perfectly valid and perfectly stale. The
|
|
59
|
+
* second half of the proof is the drift baseline the bake stored alongside it —
|
|
60
|
+
* the structural projection as it stood when the slide was written. Recompute
|
|
61
|
+
* that projection over the slide as it is NOW and the two must agree.
|
|
62
|
+
*
|
|
63
|
+
* A slide with no baseline cannot be proved either way, so it is not reused.
|
|
64
|
+
* Chart hashes drop from both sides for the reason stated in tags.mjs: this repo
|
|
65
|
+
* projects `chart: null`, and comparing that against a real hash would send
|
|
66
|
+
* every chart-bearing slide through the converter forever.
|
|
67
|
+
*/
|
|
68
|
+
export function sourceMatchesSlide(files, slidePath, decoded) {
|
|
69
|
+
if (!decoded?.baseline) return false;
|
|
70
|
+
return (
|
|
71
|
+
JSON.stringify(withoutChartHashes(computeProjection(files, slidePath))) ===
|
|
72
|
+
JSON.stringify(withoutChartHashes(decoded.baseline))
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* What each slide of a package becomes, decided from the PACKAGE rather than
|
|
78
|
+
* from the source decoder alone:
|
|
79
|
+
*
|
|
80
|
+
* "source" the embedded JSX is intact AND proved to still describe the slide
|
|
81
|
+
* "convert" hydrate from the CURRENT content through the add-in's converter
|
|
82
|
+
* "native" keep the slide verbatim as a lossless one-slide .pptx
|
|
83
|
+
*
|
|
84
|
+
* `slides` (1-based play-order numbers) narrows what is HYDRATED — everything
|
|
85
|
+
* outside it stays native. Restoring a proved-current source costs no backend
|
|
86
|
+
* call, so it happens whether or not the slide was requested.
|
|
87
|
+
*/
|
|
88
|
+
export function hydrationRoutes({ files, order, decodedByIndex }, { slides = null } = {}) {
|
|
89
|
+
const wanted = slides && slides !== "all" ? new Set(slides.map(Number)) : null;
|
|
90
|
+
return decodedByIndex.map((decoded, i) => {
|
|
91
|
+
if (decoded && !needsConversion(decoded) && sourceMatchesSlide(files, order[i], decoded)) {
|
|
92
|
+
return "source";
|
|
93
|
+
}
|
|
94
|
+
if (!wanted || wanted.has(i + 1)) return "convert";
|
|
95
|
+
return "native";
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
42
99
|
export const LIVE_DECK_UNSUPPORTED =
|
|
43
100
|
"conversion of live presentations requires the add-in path in v1 — " +
|
|
44
101
|
"`slidemind adopt` takes a .pptx FILE. Save the open deck to disk and adopt " +
|
|
@@ -163,8 +220,25 @@ function convertedByNumber(converted) {
|
|
|
163
220
|
return map;
|
|
164
221
|
}
|
|
165
222
|
|
|
166
|
-
export function adoptDeckFromPptx({
|
|
167
|
-
|
|
223
|
+
export function adoptDeckFromPptx({
|
|
224
|
+
pptxPath,
|
|
225
|
+
deckDir,
|
|
226
|
+
converted = null,
|
|
227
|
+
source = null,
|
|
228
|
+
slides = null,
|
|
229
|
+
only = null,
|
|
230
|
+
// 1-based play order -> file stem, for a slide that must keep the name it
|
|
231
|
+
// already has in the deck (a folded reused slide converted in place).
|
|
232
|
+
stems = null,
|
|
233
|
+
}) {
|
|
234
|
+
const deck = source ?? readSourceDeck(pptxPath);
|
|
235
|
+
const { buffer, order, decodedByIndex } = deck;
|
|
236
|
+
const routes = hydrationRoutes(deck, { slides });
|
|
237
|
+
// `only` materializes a SUBSET of the package into the workspace and leaves
|
|
238
|
+
// every other slide file exactly where it is — the later-hydration path, where
|
|
239
|
+
// one slide is being brought forward while another carries pending edits.
|
|
240
|
+
const materialize = only ? new Set([...only].map(Number)) : null;
|
|
241
|
+
const wanted = (index) => !materialize || materialize.has(index + 1);
|
|
168
242
|
const convertedSlides = convertedByNumber(converted);
|
|
169
243
|
|
|
170
244
|
const slidesDir = path.join(deckDir, "slides");
|
|
@@ -179,15 +253,19 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
|
|
|
179
253
|
const adopted = [];
|
|
180
254
|
const convertedFiles = [];
|
|
181
255
|
const native = [];
|
|
256
|
+
// Which workspace file materializes each slide of the package, by 1-based play
|
|
257
|
+
// order. The revision record keys its per-slide evidence on this — without it
|
|
258
|
+
// a rebase cannot tell which local file a remote change belongs to.
|
|
259
|
+
const fileByIndex = new Map();
|
|
182
260
|
let bundle = null;
|
|
183
261
|
|
|
184
|
-
// Fold every
|
|
185
|
-
// natives AND the original envelope
|
|
186
|
-
// relationships). Tagged converted JSX
|
|
262
|
+
// Fold every slide that is NOT restored from a proved-current source in one
|
|
263
|
+
// pass. The package supplies preserved natives AND the original envelope
|
|
264
|
+
// (layouts, passive third-party tags, relationships). Tagged converted JSX
|
|
265
|
+
// alone cannot restore either safely, and an unhydrated slide IS its package.
|
|
187
266
|
const picks = [];
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
picks.push(index + 1);
|
|
267
|
+
routes.forEach((route, index) => {
|
|
268
|
+
if (route !== "source" && wanted(index)) picks.push(index + 1);
|
|
191
269
|
});
|
|
192
270
|
const foldByPick = new Map(
|
|
193
271
|
(picks.length
|
|
@@ -220,6 +298,14 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
|
|
|
220
298
|
// crops) land BEFORE the slide loop so a collision-renamed asset can be
|
|
221
299
|
// rewritten in the converted jsx. Names arrive from an UNTRUSTED package,
|
|
222
300
|
// so they pass the same escape guard as tag-embedded assets.
|
|
301
|
+
// Slides whose embedded source carried image obligations, restored in one
|
|
302
|
+
// pass after the loop so the deck ends with one registry write, not one per
|
|
303
|
+
// slide (src/lib/image-intent-tags.mjs).
|
|
304
|
+
const intentEntries = [];
|
|
305
|
+
// …and the obligations they carried (src/lib/preservation-tags.mjs), keyed by
|
|
306
|
+
// the identity THIS deck gave the slide — a duplicate gets a fresh GUID below,
|
|
307
|
+
// and its record has to follow that, not the one baked into the file.
|
|
308
|
+
const preservationEntries = [];
|
|
223
309
|
const convAssetRenames = new Map();
|
|
224
310
|
for (const [name, b64] of Object.entries(converted?.assets ?? {})) {
|
|
225
311
|
const bytes = Buffer.from(String(b64), "base64");
|
|
@@ -228,12 +314,17 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
|
|
|
228
314
|
}
|
|
229
315
|
|
|
230
316
|
decodedByIndex.forEach((decoded, index) => {
|
|
231
|
-
if (
|
|
232
|
-
|
|
317
|
+
if (!wanted(index)) return;
|
|
318
|
+
if (routes[index] === "source") {
|
|
319
|
+
const stem = stems?.get(index + 1) ?? stemFor(decoded.meta.name, index, taken);
|
|
233
320
|
let jsx = decoded.jsx;
|
|
321
|
+
const renames = new Map();
|
|
234
322
|
for (const asset of decoded.assets) {
|
|
235
323
|
const placed = placeAsset(deckDir, asset.name, asset.bytes, asset.sha256);
|
|
236
|
-
if (placed && placed !== asset.name)
|
|
324
|
+
if (placed && placed !== asset.name) {
|
|
325
|
+
renames.set(asset.name, placed);
|
|
326
|
+
jsx = jsx.split(asset.name).join(placed);
|
|
327
|
+
}
|
|
237
328
|
}
|
|
238
329
|
fs.writeFileSync(path.join(slidesDir, `${stem}.jsx`), jsx, "utf8");
|
|
239
330
|
const owns = ownerIndexByGuid.get(decoded.guid)?.index === index;
|
|
@@ -241,8 +332,26 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
|
|
|
241
332
|
owns && !seenGuids.has(decoded.guid) ? decoded.guid : mintSlideGuid();
|
|
242
333
|
seenGuids.add(guid);
|
|
243
334
|
guids[`slides/${stem}.jsx`] = slideGuidEntry(guid, jsx);
|
|
335
|
+
// The slide's image obligations, if it recorded any. Only this route
|
|
336
|
+
// restores them: its embedded source was proved to still describe the
|
|
337
|
+
// slide, so the picture it names is the picture that is there.
|
|
338
|
+
if (decoded.meta[IMAGE_INTENT_META_KEY]) {
|
|
339
|
+
intentEntries.push({
|
|
340
|
+
slideId: guid,
|
|
341
|
+
intent: decoded.meta[IMAGE_INTENT_META_KEY],
|
|
342
|
+
restoreUses: true,
|
|
343
|
+
renames,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
// Its preservation record, on the same terms and for the same reason:
|
|
347
|
+
// this route, and only this route, proved the payload still describes the
|
|
348
|
+
// slide, so the elements it requires are the elements that are there.
|
|
349
|
+
if (decoded.meta[PRESERVATION_META_KEY]) {
|
|
350
|
+
preservationEntries.push({ slideKey: guid, entry: decoded.meta[PRESERVATION_META_KEY] });
|
|
351
|
+
}
|
|
244
352
|
bundle ??= decoded.meta.bundle ?? null;
|
|
245
353
|
adopted.push(`${stem}.jsx`);
|
|
354
|
+
fileByIndex.set(index + 1, { file: `${stem}.jsx`, guid, route: "source" });
|
|
246
355
|
return;
|
|
247
356
|
}
|
|
248
357
|
const fold = foldByPick.get(index + 1);
|
|
@@ -250,11 +359,16 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
|
|
|
250
359
|
if (conv) {
|
|
251
360
|
// Deterministically converted: editable jsx in the converted dialect,
|
|
252
361
|
// plus the identity/evidence record push threads back to the server.
|
|
253
|
-
const stem =
|
|
362
|
+
const stem =
|
|
363
|
+
stems?.get(index + 1) ??
|
|
364
|
+
stemFor(decoded?.meta.name ?? conv.name ?? `${pad2(index + 1)}-converted`, index, taken);
|
|
254
365
|
let jsx = conv.jsx;
|
|
255
366
|
for (const [from, to] of convAssetRenames) jsx = jsx.split(from).join(to);
|
|
256
367
|
fs.writeFileSync(path.join(slidesDir, `${stem}.jsx`), jsx, "utf8");
|
|
257
|
-
|
|
368
|
+
// The route sends no guid; a slide whose package carries one (a native
|
|
369
|
+
// slide that was already pushed) keeps it, as the native branch does.
|
|
370
|
+
const candidateGuid =
|
|
371
|
+
decoded?.guid ?? conv.guid ?? readSlideTags(deck.files, order[index])[TAG_GUID] ?? null;
|
|
258
372
|
const owns = !decoded || ownerIndexByGuid.get(decoded.guid)?.index === index;
|
|
259
373
|
const guid = owns && candidateGuid && !seenGuids.has(candidateGuid)
|
|
260
374
|
? candidateGuid : mintSlideGuid();
|
|
@@ -281,18 +395,34 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
|
|
|
281
395
|
);
|
|
282
396
|
bundle ??= decoded?.meta.bundle ?? null;
|
|
283
397
|
convertedFiles.push(`${stem}.jsx`);
|
|
398
|
+
fileByIndex.set(index + 1, { file: `${stem}.jsx`, guid, route: "convert" });
|
|
284
399
|
return;
|
|
285
400
|
}
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
// render
|
|
401
|
+
// Nothing hydrated this slide — either it was outside `--slides` or the
|
|
402
|
+
// converter declined it. It stays verbatim as a lossless one-slide .pptx,
|
|
403
|
+
// spliced back by filename order on every future render, carrying its own
|
|
404
|
+
// notes, comments, animations and embeddings.
|
|
289
405
|
if (!fold) return;
|
|
290
406
|
const stem = stemFor(`${pad2(index + 1)}-reused-adopted`, index, taken);
|
|
291
407
|
fs.writeFileSync(path.join(slidesDir, `${stem}.pptx`), fold.bytes);
|
|
292
408
|
native.push(`${stem}.pptx`);
|
|
409
|
+
// A native slide keeps the identity the PACKAGE gives it — read straight
|
|
410
|
+
// off the GUID tag, never through the source decoder, so a slide whose
|
|
411
|
+
// source tags were stripped still has one.
|
|
412
|
+
const guid = decoded?.guid ?? readSlideTags(deck.files, order[index])[TAG_GUID] ?? null;
|
|
413
|
+
fileByIndex.set(index + 1, { file: `${stem}.pptx`, guid, route: "native" });
|
|
293
414
|
});
|
|
294
415
|
|
|
295
416
|
if (Object.keys(guids).length > 0) saveSlideGuids(deckDir, guids);
|
|
417
|
+
// Image identity, after the GUIDs are persisted: an override is keyed by the
|
|
418
|
+
// slide's durable ID, so the ID has to exist first. A file that recorded none
|
|
419
|
+
// — a plain imported .pptx, or one rendered before this contract — reaches
|
|
420
|
+
// this with nothing to restore and gains no obligation (A27).
|
|
421
|
+
const intentNotes = restoreImageIntentFromTags(deckDir, intentEntries).notes;
|
|
422
|
+
// Same ordering rule, same additive contract: an entry already on disk stays,
|
|
423
|
+
// so a repeated or resumed adoption restores nothing a second time and an
|
|
424
|
+
// older file cannot resurrect a requirement this deck superseded.
|
|
425
|
+
intentNotes.push(...restorePreservationFromTags(deckDir, preservationEntries).notes);
|
|
296
426
|
// A wholly foreign deck names no bundle in any tag, but conversion ran
|
|
297
427
|
// against a design — bind to that one so the next push renders pane-free
|
|
298
428
|
// (the etag self-heals on the first render).
|
|
@@ -313,7 +443,7 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
|
|
|
313
443
|
if (!fs.existsSync(planPath)) {
|
|
314
444
|
fs.writeFileSync(
|
|
315
445
|
planPath,
|
|
316
|
-
`---\nskill: create-slides\n---\n\n# ${
|
|
446
|
+
`---\nskill: create-slides\n---\n\n# ${deckSlug(deckDir)}\n\nAdopted from ${path.basename(pptxPath)}.\n`,
|
|
317
447
|
"utf8",
|
|
318
448
|
);
|
|
319
449
|
}
|
|
@@ -324,10 +454,17 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
|
|
|
324
454
|
converted: convertedFiles,
|
|
325
455
|
native,
|
|
326
456
|
bundle,
|
|
457
|
+
routes,
|
|
458
|
+
// 1-based play order -> { file, guid, route }: which workspace file carries
|
|
459
|
+
// each slide of the adopted package.
|
|
460
|
+
slideFiles: fileByIndex,
|
|
327
461
|
// Why a slide the server was asked to convert stayed native (addinOwned,
|
|
328
462
|
// nativeOnlyEnvelope, a parse failure) — the agent has to be able to say so.
|
|
329
463
|
skipped: Array.isArray(converted?.skipped) ? converted.skipped : [],
|
|
330
|
-
warnings:
|
|
464
|
+
warnings: [
|
|
465
|
+
...(Array.isArray(converted?.warnings) ? converted.warnings : []),
|
|
466
|
+
...intentNotes,
|
|
467
|
+
],
|
|
331
468
|
};
|
|
332
469
|
}
|
|
333
470
|
|
|
@@ -351,19 +488,23 @@ export async function convertUntaggedSlides({
|
|
|
351
488
|
brandId = null,
|
|
352
489
|
note = () => {},
|
|
353
490
|
source = null,
|
|
491
|
+
slides: requested = null,
|
|
354
492
|
}) {
|
|
355
|
-
const
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
493
|
+
const deck = source ?? readSourceDeck(pptxPath);
|
|
494
|
+
const { buffer } = deck;
|
|
495
|
+
// Ask for exactly the slides that will be hydrated — no more. A slide whose
|
|
496
|
+
// embedded source is proved current needs no conversion, and one outside
|
|
497
|
+
// `--slides` is staying native, so uploading either wastes a round trip and
|
|
498
|
+
// (on a big deck) minutes of wall time.
|
|
499
|
+
const routes = hydrationRoutes(deck, { slides: requested });
|
|
500
|
+
const slides = routes.flatMap((route, i) => (route === "convert" ? [i + 1] : []));
|
|
360
501
|
if (slides.length === 0) return null;
|
|
361
502
|
|
|
362
503
|
const client = await import("../bridge/client.mjs");
|
|
363
504
|
const brand =
|
|
364
505
|
brandId ??
|
|
365
506
|
readDeckBundleRef(deckDir)?.presFP ??
|
|
366
|
-
decodedByIndex.find((d) => d?.meta?.bundle?.presFP)?.meta.bundle.presFP ??
|
|
507
|
+
deck.decodedByIndex.find((d) => d?.meta?.bundle?.presFP)?.meta.bundle.presFP ??
|
|
367
508
|
null;
|
|
368
509
|
let resolved = brand && brand !== "unknown" ? brand : null;
|
|
369
510
|
let backendURL = null;
|
|
@@ -415,3 +556,183 @@ export async function convertUntaggedSlides({
|
|
|
415
556
|
return null;
|
|
416
557
|
}
|
|
417
558
|
}
|
|
559
|
+
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
// update mode — adoption that can be run twice
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Adopt a .pptx into a workspace, or bring an already-adopted workspace up to
|
|
566
|
+
* that file. One entry point, because the difference between the two is a
|
|
567
|
+
* question about state the caller should not have to answer.
|
|
568
|
+
*
|
|
569
|
+
* Five outcomes:
|
|
570
|
+
*
|
|
571
|
+
* "adopted" first adoption — slides materialized and the accepted base
|
|
572
|
+
* recorded, the whole thing journaled so a crash resolves to a
|
|
573
|
+
* complete generation rather than a half-built deck;
|
|
574
|
+
* "hydrated" the same file, but slides that were native are now requested as
|
|
575
|
+
* editable JSX. Only those are re-materialized; every other
|
|
576
|
+
* slide file — including one with pending local edits — is left
|
|
577
|
+
* exactly as it is, and the new material joins the BASELINE
|
|
578
|
+
* rather than reading as an edit;
|
|
579
|
+
* "unchanged" the same file, nothing new asked for. Nothing is written: not
|
|
580
|
+
* the slides, not the assets, not the GUIDs, not the adoption
|
|
581
|
+
* time;
|
|
582
|
+
* "rebased" a different file over a clean workspace — the remote changes
|
|
583
|
+
* apply and the accepted base advances;
|
|
584
|
+
* needs_reconcile (thrown) — a different file over a workspace with
|
|
585
|
+
* unpublished edits. The incoming snapshot is STAGED and nothing
|
|
586
|
+
* local is touched; `slidemind rebase` decides what happens.
|
|
587
|
+
*/
|
|
588
|
+
export async function adoptWorkspace({
|
|
589
|
+
pptxPath,
|
|
590
|
+
deckDir,
|
|
591
|
+
brandId = null,
|
|
592
|
+
slides = null,
|
|
593
|
+
convert = true,
|
|
594
|
+
note = () => {},
|
|
595
|
+
owner = null,
|
|
596
|
+
}) {
|
|
597
|
+
const revisionLib = await import("./revision.mjs");
|
|
598
|
+
if (!owner) {
|
|
599
|
+
const held = revisionLib.acquireOwner(deckDir, { command: 'adopt' });
|
|
600
|
+
try { return await adoptWorkspace({ pptxPath, deckDir, brandId, slides, convert, note, owner: held.token }); }
|
|
601
|
+
finally { held.release(); }
|
|
602
|
+
}
|
|
603
|
+
const source = readSourceDeck(pptxPath);
|
|
604
|
+
const incoming = revisionLib.inventoryPackage(source.buffer);
|
|
605
|
+
const revision = revisionLib.readRevision(deckDir);
|
|
606
|
+
|
|
607
|
+
const runConvert = async (only) =>
|
|
608
|
+
convert
|
|
609
|
+
? await convertUntaggedSlides({
|
|
610
|
+
pptxPath,
|
|
611
|
+
deckDir,
|
|
612
|
+
brandId,
|
|
613
|
+
note,
|
|
614
|
+
source,
|
|
615
|
+
slides: only,
|
|
616
|
+
})
|
|
617
|
+
: null;
|
|
618
|
+
|
|
619
|
+
// --- already adopted from THIS file ------------------------------------
|
|
620
|
+
if (revision && revision.base?.sha256 === incoming.sha256) {
|
|
621
|
+
const byIndex = new Map(
|
|
622
|
+
(revision.base.slides ?? []).map((s) => [s.index, s]),
|
|
623
|
+
);
|
|
624
|
+
const requested = slides === "all" ? [...byIndex.keys()] : slides ? slides.map(Number) : [];
|
|
625
|
+
const toHydrate = requested.filter((n) => {
|
|
626
|
+
const entry = byIndex.get(n);
|
|
627
|
+
return entry && (!entry.file || entry.file.endsWith(".pptx"));
|
|
628
|
+
});
|
|
629
|
+
if (toHydrate.length === 0) {
|
|
630
|
+
return { mode: "unchanged", revision, slideCount: incoming.slideCount };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Withdraw the native materialization of exactly those slides, then build
|
|
634
|
+
// their JSX in its place. Nothing else in slides/ is read or written.
|
|
635
|
+
const dirty = revisionLib.computeDirty(deckDir, revision);
|
|
636
|
+
if (toHydrate.some((n) => dirty.dirtySlides.includes(byIndex.get(n)?.file))) {
|
|
637
|
+
const error = new Error("Cannot hydrate an edited native slide; preserve or reconcile its changes first.");
|
|
638
|
+
error.code = "local_dirty";
|
|
639
|
+
throw error;
|
|
640
|
+
}
|
|
641
|
+
const activeDir = deckDir;
|
|
642
|
+
const tx = stageWorkspace(activeDir);
|
|
643
|
+
try {
|
|
644
|
+
const deckDir = tx.workspace;
|
|
645
|
+
const converted = convert ? await convertUntaggedSlides({ pptxPath, deckDir, brandId, note, source, slides: toHydrate }) : null;
|
|
646
|
+
const replaced = [];
|
|
647
|
+
for (const n of toHydrate) {
|
|
648
|
+
const entry = byIndex.get(n);
|
|
649
|
+
if (!entry?.file) continue;
|
|
650
|
+
fs.rmSync(path.join(deckDir, "slides", entry.file), { force: true });
|
|
651
|
+
replaced.push(entry.file);
|
|
652
|
+
}
|
|
653
|
+
const result = adoptDeckFromPptx({
|
|
654
|
+
pptxPath,
|
|
655
|
+
deckDir,
|
|
656
|
+
converted,
|
|
657
|
+
source,
|
|
658
|
+
slides: toHydrate,
|
|
659
|
+
only: toHydrate,
|
|
660
|
+
});
|
|
661
|
+
const slideFiles = new Map(
|
|
662
|
+
(revision.base.slides ?? [])
|
|
663
|
+
.filter((s) => s.file)
|
|
664
|
+
.map((s) => [s.index, { file: s.file, guid: s.guid }]),
|
|
665
|
+
);
|
|
666
|
+
for (const [index, entry] of result.slideFiles) slideFiles.set(index, entry);
|
|
667
|
+
revisionLib.writeRevision(deckDir, {
|
|
668
|
+
...revision,
|
|
669
|
+
base: {
|
|
670
|
+
...revision.base,
|
|
671
|
+
slides: (revision.base.slides ?? []).map((s) => ({
|
|
672
|
+
...s,
|
|
673
|
+
file: slideFiles.get(s.index)?.file ?? s.file ?? null,
|
|
674
|
+
})),
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
const hydrated = [...result.adopted, ...result.converted];
|
|
678
|
+
revisionLib.noteHydration(deckDir, hydrated, replaced);
|
|
679
|
+
tx.activate({ checkpoint: () => revisionLib.assertCheckpoint(activeDir, { owner, generation: revision.generation, digest: dirty.inputDigest }) });
|
|
680
|
+
return { mode: "hydrated", result, hydrated, revision: revisionLib.readRevision(activeDir) };
|
|
681
|
+
} finally { tx.dispose(); }
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// --- a DIFFERENT file over an adopted workspace ------------------------
|
|
685
|
+
if (revision) {
|
|
686
|
+
const staged = revisionLib.stageIncoming(deckDir, {
|
|
687
|
+
bytes: source.buffer,
|
|
688
|
+
source: { kind: "file", path: path.resolve(pptxPath), name: path.basename(pptxPath) },
|
|
689
|
+
});
|
|
690
|
+
const dirty = revisionLib.computeDirty(deckDir, revision);
|
|
691
|
+
if (dirty.localDirty) {
|
|
692
|
+
const err = new Error(
|
|
693
|
+
`${deckSlug(deckDir)} has unpublished local changes (${dirty.dirtySlides.length} slide(s)), ` +
|
|
694
|
+
`so adopting a newer file would overwrite them. The incoming file is kept as ` +
|
|
695
|
+
`revision ${staged.revisionId}; nothing local was touched.\n` +
|
|
696
|
+
`Reconcile it: \`slidemind rebase ${deckSlug(deckDir)} --incoming ${staged.revisionId}\``,
|
|
697
|
+
);
|
|
698
|
+
err.code = "needs_reconcile";
|
|
699
|
+
err.revisionId = staged.revisionId;
|
|
700
|
+
err.dirtySlides = dirty.dirtySlides;
|
|
701
|
+
throw err;
|
|
702
|
+
}
|
|
703
|
+
const { applyRebase } = await import("./rebase.mjs");
|
|
704
|
+
const rebase = applyRebase(deckDir, staged.revisionId, { owner });
|
|
705
|
+
return { mode: "rebased", rebase, revisionId: staged.revisionId };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// --- first adoption ----------------------------------------------------
|
|
709
|
+
const journal = revisionLib.journalBegin(deckDir, {
|
|
710
|
+
op: "adopt",
|
|
711
|
+
detail: { file: path.basename(pptxPath), sha256: incoming.sha256 },
|
|
712
|
+
});
|
|
713
|
+
const converted = await runConvert(slides);
|
|
714
|
+
revisionLib.journalStep(deckDir, journal, "materialize");
|
|
715
|
+
const result = adoptDeckFromPptx({ pptxPath, deckDir, converted, source, slides });
|
|
716
|
+
revisionLib.journalStep(deckDir, journal, "record-base");
|
|
717
|
+
revisionLib.recordAcceptedBase(deckDir, {
|
|
718
|
+
bytes: source.buffer,
|
|
719
|
+
origin: { kind: "adopted", file: path.basename(pptxPath), path: path.resolve(pptxPath) },
|
|
720
|
+
});
|
|
721
|
+
// The per-slide file mapping: which workspace file carries which slide of the
|
|
722
|
+
// accepted package. A rebase without it cannot tell whose edit a remote
|
|
723
|
+
// change collides with.
|
|
724
|
+
const recorded = revisionLib.readRevision(deckDir);
|
|
725
|
+
revisionLib.writeRevision(deckDir, {
|
|
726
|
+
...recorded,
|
|
727
|
+
base: {
|
|
728
|
+
...recorded.base,
|
|
729
|
+
slides: recorded.base.slides.map((s) => ({
|
|
730
|
+
...s,
|
|
731
|
+
file: result.slideFiles.get(s.index)?.file ?? null,
|
|
732
|
+
hydrated: result.routes[s.index - 1] !== "native",
|
|
733
|
+
})),
|
|
734
|
+
},
|
|
735
|
+
});
|
|
736
|
+
revisionLib.journalComplete(deckDir);
|
|
737
|
+
return { mode: "adopted", result, revision: revisionLib.readRevision(deckDir) };
|
|
738
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// A concept spec is the reader's inventory of ONE mockup: the bytes it was read
|
|
2
|
+
// from, not "slide N". `concepts` records their SHA-256 in the spec
|
|
3
|
+
// (`imageSha256`), and every consumer here takes a spec only while the mockup
|
|
4
|
+
// on disk still hashes to it.
|
|
5
|
+
//
|
|
6
|
+
// x112 GTM: a run that found 3 of 4 mockups on disk regenerated all four, then
|
|
7
|
+
// reused the three specs already on disk because their slide numbers and image
|
|
8
|
+
// intent matched. Slides 1–3 were transcribed from pictures that no longer
|
|
9
|
+
// existed, and the deck's concepts disagreed with its JSX (forms 0.55). Bound to
|
|
10
|
+
// the bytes, a replaced mockup has no spec until it is read again, and a spec
|
|
11
|
+
// with no binding (written before this contract) is simply read again.
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
import { listSpecSlides, readSpec } from "../builders/concept-spec.mjs";
|
|
17
|
+
|
|
18
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
19
|
+
|
|
20
|
+
export function mockupFileFor(deckDir, slideNumber) {
|
|
21
|
+
return path.join(deckDir, "concepts", `concept-slide-${pad(slideNumber)}.png`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** SHA-256 (hex) of a mockup's bytes, or null when there is no mockup. */
|
|
25
|
+
export function imageDigest(bytesOrFile) {
|
|
26
|
+
let bytes = bytesOrFile;
|
|
27
|
+
if (typeof bytesOrFile === "string") {
|
|
28
|
+
try {
|
|
29
|
+
bytes = fs.readFileSync(bytesOrFile);
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return crypto.createHash("sha256").update(bytes).digest("hex");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where slide N's spec stands against its mockup on disk:
|
|
39
|
+
* `current` (read from exactly these bytes), `stale` (read from other bytes),
|
|
40
|
+
* `unbound` (no binding recorded), `no-mockup` (a spec with no picture beside
|
|
41
|
+
* it) or `absent` (no spec).
|
|
42
|
+
*/
|
|
43
|
+
export function specState(deckDir, slideNumber) {
|
|
44
|
+
const spec = readSpec(deckDir, slideNumber);
|
|
45
|
+
if (!spec) return { status: "absent", spec: null };
|
|
46
|
+
const digest = imageDigest(mockupFileFor(deckDir, slideNumber));
|
|
47
|
+
if (!digest) return { status: "no-mockup", spec };
|
|
48
|
+
if (typeof spec.imageSha256 !== "string" || !spec.imageSha256) return { status: "unbound", spec };
|
|
49
|
+
return { status: spec.imageSha256 === digest ? "current" : "stale", spec };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Slide N's spec while it describes the mockup on disk, else null. */
|
|
53
|
+
export function currentSpec(deckDir, slideNumber) {
|
|
54
|
+
const { status, spec } = specState(deckDir, slideNumber);
|
|
55
|
+
return status === "current" ? spec : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The slides whose spec is current, and those whose spec on disk is not. */
|
|
59
|
+
export function specSlidesByState(deckDir) {
|
|
60
|
+
const current = [];
|
|
61
|
+
const outdated = [];
|
|
62
|
+
for (const n of listSpecSlides(deckDir)) {
|
|
63
|
+
(specState(deckDir, n).status === "current" ? current : outdated).push(n);
|
|
64
|
+
}
|
|
65
|
+
return { current, outdated };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Remove slide N's spec — the mapped one, the reader's raw one and the legacy
|
|
70
|
+
* file next to the PNG. Called in the same step a mockup is replaced, so no
|
|
71
|
+
* reader of the deck can pair the new picture with the old inventory.
|
|
72
|
+
*/
|
|
73
|
+
export function dropSpec(deckDir, slideNumber) {
|
|
74
|
+
const n = pad(slideNumber);
|
|
75
|
+
for (const f of [
|
|
76
|
+
path.join(deckDir, "state", "concept-specs", `concept-slide-${n}.json`),
|
|
77
|
+
path.join(deckDir, "state", "reader-specs", `concept-slide-${n}.json`),
|
|
78
|
+
path.join(deckDir, "concepts", `concept-slide-${n}.spec.json`),
|
|
79
|
+
]) {
|
|
80
|
+
fs.rmSync(f, { force: true });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Replace slide N's mockup atomically (temp file + rename in the same
|
|
86
|
+
* directory) and drop its spec in the same step.
|
|
87
|
+
*/
|
|
88
|
+
export function replaceMockup(deckDir, slideNumber, bytes) {
|
|
89
|
+
const file = mockupFileFor(deckDir, slideNumber);
|
|
90
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
91
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
92
|
+
fs.writeFileSync(tmp, bytes);
|
|
93
|
+
dropSpec(deckDir, slideNumber);
|
|
94
|
+
fs.renameSync(tmp, file);
|
|
95
|
+
return file;
|
|
96
|
+
}
|