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,945 @@
|
|
|
1
|
+
import { readRemote, writeRemote } from './remote.mjs';
|
|
2
|
+
import { readPresentationContext } from './presentation-context.mjs';
|
|
3
|
+
import { runDeclared, materializeArtifact } from './extensions.mjs';
|
|
4
|
+
import { unzip, zip } from '../pptx/zip.mjs';
|
|
5
|
+
import { slideOrderPaths } from '../pptx/ooxml.mjs';
|
|
6
|
+
import { TAG_GUID, mintSlideGuid, updateSlideTag } from '../pptx/tags.mjs';
|
|
7
|
+
import { stageWorkspace } from './workspace-transaction.mjs';
|
|
8
|
+
import { loadSlideGuids, saveSlideGuids } from './slide-guids.mjs';
|
|
9
|
+
import { designDigest, slideContentDigests } from '../pptx/content-digest.mjs';
|
|
10
|
+
// Three-way rebase: the accepted base, the working inputs, and an incoming
|
|
11
|
+
// snapshot of the same deck saved somewhere else.
|
|
12
|
+
//
|
|
13
|
+
// The rule: a conflict is BOTH SIDES CHANGING THE SAME SLIDE — editing it
|
|
14
|
+
// twice, or one side deleting it while the other edited it. Everything else
|
|
15
|
+
// merges, the way co-authoring in PowerPoint merges it:
|
|
16
|
+
//
|
|
17
|
+
// • a slide only one side changed takes that side's version;
|
|
18
|
+
// • slides either side added or deleted land where that side put them;
|
|
19
|
+
// • both sides' reorders combine — a slide both sides moved keeps THEIR
|
|
20
|
+
// position, and says so;
|
|
21
|
+
// • a change to the shared design (theme, masters, layouts) is theirs to
|
|
22
|
+
// make — nothing local edits it — so it is taken, and reported.
|
|
23
|
+
//
|
|
24
|
+
// "Changed" is read by MEANING (src/pptx/content-digest.mjs), never by bytes:
|
|
25
|
+
// one PowerPoint save re-serializes, renumbers and re-localizes almost every
|
|
26
|
+
// part of a file nobody edited, and a byte comparison reported all of that as
|
|
27
|
+
// somebody's edit. A conflict activates NOTHING. The base, the incoming file
|
|
28
|
+
// and the local inputs all survive a conflicted rebase intact, because the
|
|
29
|
+
// point of the exercise is that nobody loses work.
|
|
30
|
+
//
|
|
31
|
+
// A remote-only slide change applies by taking the slide back to its package
|
|
32
|
+
// form: the incoming slide is folded in as a native one-slide .pptx and any
|
|
33
|
+
// stale local hydration of it is dropped. That is honest (the bytes are exactly
|
|
34
|
+
// what the other side saved) and it needs no converter — `slidemind adopt
|
|
35
|
+
// <file> --slides N` re-hydrates it to editable JSX when the user wants that.
|
|
36
|
+
|
|
37
|
+
import fs from "node:fs";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
acceptedBasePath,
|
|
42
|
+
clearConflicts,
|
|
43
|
+
computeDirty,
|
|
44
|
+
inputDigest,
|
|
45
|
+
journalBegin,
|
|
46
|
+
journalComplete,
|
|
47
|
+
journalStep,
|
|
48
|
+
materializedInputs,
|
|
49
|
+
readRevision,
|
|
50
|
+
readStaged,
|
|
51
|
+
slideFileOrder,
|
|
52
|
+
stagedBytes,
|
|
53
|
+
stagedDir,
|
|
54
|
+
writeConflicts,
|
|
55
|
+
writeRevision,
|
|
56
|
+
inventoryPackage,
|
|
57
|
+
assertCheckpoint,
|
|
58
|
+
} from "./revision.mjs";
|
|
59
|
+
import { foldPickedSlides } from "../pptx/slide-swap.mjs";
|
|
60
|
+
import { removeConversionRecord } from "./conversions.mjs";
|
|
61
|
+
|
|
62
|
+
const nowIso = () => new Date().toISOString();
|
|
63
|
+
|
|
64
|
+
// Every conflict class the design enumerates. The `kind` is the contract the
|
|
65
|
+
// resolution file and the downstream consumers key on. All four are one thing
|
|
66
|
+
// underneath — both sides changed the same slide — named by how:
|
|
67
|
+
export const CONFLICT_KINDS = [
|
|
68
|
+
"both-edited", // edited on both sides
|
|
69
|
+
"incoming-deleted", // edited here, deleted there
|
|
70
|
+
"local-deleted", // deleted here, edited there
|
|
71
|
+
"ambiguous-identity", // edited here, and their file no longer says which slide it is
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
// How one slide is identified across two saves of the same deck, in the order
|
|
75
|
+
// the evidence is trustworthy:
|
|
76
|
+
//
|
|
77
|
+
// 1. a SLIDEMIND GUID — survives reorders, renames and source-tag removal;
|
|
78
|
+
// 2. the package's own slide id (`p:sldId/@id`) — PowerPoint keeps it across
|
|
79
|
+
// an ordinary save. It is what tells PowerPoint's Duplicate Slide apart
|
|
80
|
+
// from the original: the copy carries the original's tags, GUID included,
|
|
81
|
+
// under a NEW slide id. It also matches a slide whose GUID a host dropped.
|
|
82
|
+
//
|
|
83
|
+
// Position is never identity: two saves with the same slide count prove nothing
|
|
84
|
+
// about which slide is which once someone has reordered them.
|
|
85
|
+
|
|
86
|
+
/** Each slide with a key unique within its own package, or `key: null`. */
|
|
87
|
+
function keyed(slides) {
|
|
88
|
+
const perGuid = new Map();
|
|
89
|
+
for (const s of slides ?? []) if (s.guid) perGuid.set(s.guid, (perGuid.get(s.guid) ?? 0) + 1);
|
|
90
|
+
return (slides ?? []).map((slide) => {
|
|
91
|
+
let key = null;
|
|
92
|
+
if (slide.guid && perGuid.get(slide.guid) === 1) key = `guid:${slide.guid}`;
|
|
93
|
+
else if (slide.guid && slide.sldId) key = `guid:${slide.guid}@${slide.sldId}`;
|
|
94
|
+
else if (!slide.guid && slide.sldId) key = `sldid:${slide.sldId}`;
|
|
95
|
+
return { ...slide, key };
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Pair the accepted base's slides with the incoming file's.
|
|
101
|
+
*
|
|
102
|
+
* `sameContent(base, incoming)` breaks the one tie identity cannot: a duplicated
|
|
103
|
+
* slide whose original also lost its slide id. Returns the pairs, the slides
|
|
104
|
+
* only one side has, the slides no key can name, and the base slides whose
|
|
105
|
+
* GUID survives only in copies none of which can be told to be it.
|
|
106
|
+
*/
|
|
107
|
+
function matchSlides(base, incoming, sameContent) {
|
|
108
|
+
const pairs = [];
|
|
109
|
+
const unnamed = [];
|
|
110
|
+
const usedBase = new Set();
|
|
111
|
+
const usedIncoming = new Set();
|
|
112
|
+
const pair = (b, i) => {
|
|
113
|
+
pairs.push({ base: b, incoming: i });
|
|
114
|
+
usedBase.add(b);
|
|
115
|
+
usedIncoming.add(i);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// A slide carrying neither a GUID nor a slide id, or a key two slides share
|
|
119
|
+
// (a duplicated slide id is a broken package), is matched to nothing.
|
|
120
|
+
for (const [list, label, used] of [
|
|
121
|
+
[base, "accepted", usedBase],
|
|
122
|
+
[incoming, "incoming", usedIncoming],
|
|
123
|
+
]) {
|
|
124
|
+
const holders = new Map();
|
|
125
|
+
for (const s of list) if (s.key) holders.set(s.key, [...(holders.get(s.key) ?? []), s]);
|
|
126
|
+
for (const s of list) {
|
|
127
|
+
if (!s.key) {
|
|
128
|
+
unnamed.push({ slide: s, why: `this slide of the ${label} file carries neither a GUID nor a slide id, so it cannot be matched` });
|
|
129
|
+
} else if (holders.get(s.key).length > 1) {
|
|
130
|
+
unnamed.push({ slide: s, why: `${s.key} names ${holders.get(s.key).length} slides of the ${label} file` });
|
|
131
|
+
} else {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
used.add(s);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const byGuid = (list, used) => {
|
|
139
|
+
const map = new Map();
|
|
140
|
+
for (const s of list) if (!used.has(s) && s.guid) map.set(s.guid, [...(map.get(s.guid) ?? []), s]);
|
|
141
|
+
return map;
|
|
142
|
+
};
|
|
143
|
+
const incomingByGuid = byGuid(incoming, usedIncoming);
|
|
144
|
+
const unclaimedLineage = new Set();
|
|
145
|
+
for (const [guid, bs] of byGuid(base, usedBase)) {
|
|
146
|
+
const is = incomingByGuid.get(guid);
|
|
147
|
+
if (!is) continue;
|
|
148
|
+
// One on each side: the same slide, wherever it moved.
|
|
149
|
+
if (bs.length === 1 && is.length === 1) {
|
|
150
|
+
pair(bs[0], is[0]);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// A duplicated slide: the original kept its slide id, every copy has a new one.
|
|
154
|
+
for (const b of bs) {
|
|
155
|
+
const i = is.find((x) => !usedIncoming.has(x) && x.sldId && x.sldId === b.sldId);
|
|
156
|
+
if (i) pair(b, i);
|
|
157
|
+
}
|
|
158
|
+
const restBase = bs.filter((b) => !usedBase.has(b));
|
|
159
|
+
const restIncoming = is.filter((i) => !usedIncoming.has(i));
|
|
160
|
+
if (restBase.length === 1 && restIncoming.length) {
|
|
161
|
+
const same = restIncoming.filter((i) => sameContent(restBase[0], i));
|
|
162
|
+
if (restIncoming.length === 1) pair(restBase[0], restIncoming[0]);
|
|
163
|
+
else if (same.length === 1) pair(restBase[0], same[0]);
|
|
164
|
+
}
|
|
165
|
+
// Whatever is left cannot be told apart. Read as "removed there, copies
|
|
166
|
+
// added there", their file lands exactly as saved; only a LOCAL edit to
|
|
167
|
+
// such a slide has nowhere certain to go (classifyRebase).
|
|
168
|
+
for (const b of bs) if (!usedBase.has(b) && restIncoming.some((i) => !usedIncoming.has(i))) unclaimedLineage.add(b);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// A GUID one side lost: the same slide id is the same slide. Two DIFFERENT
|
|
172
|
+
// GUIDs on one slide id are two slides, however the id came to be reused.
|
|
173
|
+
for (const b of base) {
|
|
174
|
+
if (usedBase.has(b) || !b.sldId) continue;
|
|
175
|
+
const hits = incoming.filter((i) => !usedIncoming.has(i) && i.sldId === b.sldId && (!i.guid || !b.guid));
|
|
176
|
+
if (hits.length === 1) pair(b, hits[0]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
pairs,
|
|
181
|
+
unnamed,
|
|
182
|
+
unclaimedLineage,
|
|
183
|
+
baseOnly: base.filter((b) => !usedBase.has(b)),
|
|
184
|
+
incomingOnly: incoming.filter((i) => !usedIncoming.has(i)),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The items of `side` that side MOVED relative to `base`: everything outside
|
|
189
|
+
// one longest common subsequence of the two orders (over the items both hold).
|
|
190
|
+
// Which of two swapped slides counts as "the" mover is arbitrary, and either
|
|
191
|
+
// answer reproduces the same order once the movers are placed.
|
|
192
|
+
function moved(base, side) {
|
|
193
|
+
const inSide = new Set(side);
|
|
194
|
+
const inBase = new Set(base);
|
|
195
|
+
const a = base.filter((x) => inSide.has(x));
|
|
196
|
+
const b = side.filter((x) => inBase.has(x));
|
|
197
|
+
const n = a.length;
|
|
198
|
+
const m = b.length;
|
|
199
|
+
const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
200
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
201
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
202
|
+
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const kept = new Set();
|
|
206
|
+
for (let i = 0, j = 0; i < n && j < m; ) {
|
|
207
|
+
if (a[i] === b[j]) {
|
|
208
|
+
kept.add(a[i]);
|
|
209
|
+
i++;
|
|
210
|
+
j++;
|
|
211
|
+
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) i++;
|
|
212
|
+
else j++;
|
|
213
|
+
}
|
|
214
|
+
return new Set(b.filter((x) => !kept.has(x)));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Three-way merge of slide order. Their order is the starting point (it
|
|
219
|
+
* already carries their adds, deletes and moves); every slide only the local
|
|
220
|
+
* side moved or added is then placed after the slide it follows locally. A
|
|
221
|
+
* slide both sides moved keeps their position and is reported — a move is not
|
|
222
|
+
* worth stopping anyone for.
|
|
223
|
+
*
|
|
224
|
+
* @param {string[]} base merge ids in the accepted base's order
|
|
225
|
+
* @param {string[]} incoming merge ids in their order
|
|
226
|
+
* @param {string[]} local merge ids in the working files' order (`local:` = added here)
|
|
227
|
+
*/
|
|
228
|
+
export function mergeOrder(base, incoming, local) {
|
|
229
|
+
const theirMoves = moved(base, incoming);
|
|
230
|
+
const ourMoves = moved(base, local);
|
|
231
|
+
const present = new Set(incoming);
|
|
232
|
+
const place = new Set(local.filter((x) => (ourMoves.has(x) && !theirMoves.has(x)) || x.startsWith("local:")));
|
|
233
|
+
const order = [...incoming];
|
|
234
|
+
for (let p = 0; p < local.length; p++) {
|
|
235
|
+
const x = local[p];
|
|
236
|
+
if (!place.has(x) || (!present.has(x) && !x.startsWith("local:"))) continue;
|
|
237
|
+
const at = order.indexOf(x);
|
|
238
|
+
if (at >= 0) order.splice(at, 1);
|
|
239
|
+
// The neighbour it follows here — preferring one THEY did not move, since
|
|
240
|
+
// a slide they moved no longer marks the place we meant.
|
|
241
|
+
let to = -1;
|
|
242
|
+
for (const anchored of [(y) => !theirMoves.has(y), () => true]) {
|
|
243
|
+
for (let q = p - 1; q >= 0 && to < 0; q--) {
|
|
244
|
+
const k = order.indexOf(local[q]);
|
|
245
|
+
if (k >= 0 && anchored(local[q])) to = k + 1;
|
|
246
|
+
}
|
|
247
|
+
for (let q = p + 1; q < local.length && to < 0; q++) {
|
|
248
|
+
const k = order.indexOf(local[q]);
|
|
249
|
+
if (k >= 0 && anchored(local[q])) to = k;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
order.splice(to < 0 ? order.length : to, 0, x);
|
|
253
|
+
}
|
|
254
|
+
return { order, bothMoved: [...ourMoves].filter((x) => theirMoves.has(x) && present.has(x)) };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Slide files that hold, byte for byte, what another base slide file held:
|
|
259
|
+
* old file -> new file.
|
|
260
|
+
*
|
|
261
|
+
* The NN- prefix IS the play order, so a local reorder renames files — to new
|
|
262
|
+
* names, or, when slides share a stem, onto each other's names (a swap). The
|
|
263
|
+
* dirty tracker is keyed by path and reads either as edits. For a merge that is
|
|
264
|
+
* the wrong reading: the slide is the same one in a new place, and a
|
|
265
|
+
* colleague's edit to it is not a delete/edit conflict.
|
|
266
|
+
*
|
|
267
|
+
* Only a unique byte-identical match counts. A slide moved onto a path whose
|
|
268
|
+
* own slide did not move away (it was edited or deleted there) cannot be told
|
|
269
|
+
* from an edit of that slide, so that one move is not claimed at all.
|
|
270
|
+
*/
|
|
271
|
+
function localRenames(revision, now) {
|
|
272
|
+
const baseline = revision.baseline?.inputs ?? {};
|
|
273
|
+
const bySlideHash = (inputs) => {
|
|
274
|
+
const map = new Map();
|
|
275
|
+
for (const [rel, hash] of Object.entries(inputs)) {
|
|
276
|
+
if (rel.startsWith("slides/") && /\.(jsx|pptx)$/.test(rel)) map.set(hash, [...(map.get(hash) ?? []), rel]);
|
|
277
|
+
}
|
|
278
|
+
return map;
|
|
279
|
+
};
|
|
280
|
+
const was = bySlideHash(baseline);
|
|
281
|
+
const is = bySlideHash(now);
|
|
282
|
+
const renames = {};
|
|
283
|
+
for (const [hash, olds] of was) {
|
|
284
|
+
const news = is.get(hash);
|
|
285
|
+
if (olds.length === 1 && news?.length === 1 && olds[0] !== news[0]) {
|
|
286
|
+
renames[olds[0].slice("slides/".length)] = news[0].slice("slides/".length);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for (let settled = false; !settled; ) {
|
|
290
|
+
settled = true;
|
|
291
|
+
for (const [from, to] of Object.entries(renames)) {
|
|
292
|
+
if (baseline[`slides/${to}`] !== undefined && renames[to] === undefined) {
|
|
293
|
+
delete renames[from];
|
|
294
|
+
settled = false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return renames;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Digests of a package, by its sha256. A pull classifies, then applies (which
|
|
302
|
+
// classifies again inside its transaction), and a refused rebase classifies a
|
|
303
|
+
// third time to write the resolution file — the package is the same bytes each
|
|
304
|
+
// time, and on a large deck reading it is the expensive part.
|
|
305
|
+
const digestCache = new Map();
|
|
306
|
+
function packageDigests(sha256, read) {
|
|
307
|
+
const cached = sha256 ? digestCache.get(sha256) : null;
|
|
308
|
+
if (cached) return cached;
|
|
309
|
+
const files = unzip(read());
|
|
310
|
+
const digests = { slides: slideContentDigests(files), design: designDigest(files) };
|
|
311
|
+
if (sha256) {
|
|
312
|
+
digestCache.set(sha256, digests);
|
|
313
|
+
if (digestCache.size > 4) digestCache.delete(digestCache.keys().next().value);
|
|
314
|
+
}
|
|
315
|
+
return digests;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Compare base, working inputs and a staged incoming snapshot.
|
|
320
|
+
*
|
|
321
|
+
* Returns `{ revisionId, remoteOnly, localOnly, unchanged, conflicts, orderPlan,
|
|
322
|
+
* identity, sharedChanged, notes }`. `remoteOnly` entries are what `applyRebase`
|
|
323
|
+
* will refresh, add or remove; `conflicts` is the manifest body; `orderPlan` is
|
|
324
|
+
* the merged slide order in merge ids; `identity` maps each incoming slide's key
|
|
325
|
+
* to its merge id.
|
|
326
|
+
*/
|
|
327
|
+
export function classifyRebase(workspaceDir, revisionId) {
|
|
328
|
+
const revision = readRevision(workspaceDir);
|
|
329
|
+
if (!revision) {
|
|
330
|
+
const e = new Error("this deck has no accepted base — adopt it before rebasing.");
|
|
331
|
+
e.code = "no_base";
|
|
332
|
+
throw e;
|
|
333
|
+
}
|
|
334
|
+
const incoming = readStaged(workspaceDir, revisionId);
|
|
335
|
+
const missing = (code, message) => Object.assign(new Error(message), { code });
|
|
336
|
+
if (!incoming) throw missing("no_such_revision", `no staged snapshot ${revisionId}`);
|
|
337
|
+
const theirsDigests = packageDigests(incoming.sha256, () => {
|
|
338
|
+
const bytes = stagedBytes(workspaceDir, revisionId);
|
|
339
|
+
if (!bytes) throw missing("no_such_revision", `no staged snapshot ${revisionId}`);
|
|
340
|
+
return bytes;
|
|
341
|
+
});
|
|
342
|
+
const baseDigests = packageDigests(revision.base?.sha256, () => {
|
|
343
|
+
try {
|
|
344
|
+
return fs.readFileSync(acceptedBasePath(workspaceDir));
|
|
345
|
+
} catch {
|
|
346
|
+
throw missing("no_base", "this deck's accepted base file is missing — adopt it again before rebasing.");
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
const baseContent = baseDigests.slides;
|
|
350
|
+
const incomingContent = theirsDigests.slides;
|
|
351
|
+
const sharedChanged = baseDigests.design !== theirsDigests.design;
|
|
352
|
+
|
|
353
|
+
const materialized = materializedInputs(workspaceDir);
|
|
354
|
+
const dirty = computeDirty(workspaceDir, revision, materialized);
|
|
355
|
+
const dirtyFiles = new Set(dirty.dirtySlides);
|
|
356
|
+
const localRemoved = new Set(dirty.removed);
|
|
357
|
+
const renames = localRenames(revision, materialized.inputs);
|
|
358
|
+
// Each base slide under the name its file has NOW.
|
|
359
|
+
const base = keyed(revision.base?.slides).map((b) => (b.file && renames[b.file] ? { ...b, file: renames[b.file], renamed: true } : b));
|
|
360
|
+
const theirs = keyed(incoming.slides);
|
|
361
|
+
const match = matchSlides(base, theirs, (b, i) => baseContent.get(b.part) === incomingContent.get(i.part));
|
|
362
|
+
|
|
363
|
+
const nameOf = (slide) => slide.guid ?? slide.key ?? slide.part;
|
|
364
|
+
const mine = (b) => Boolean(b.file && !b.renamed && dirtyFiles.has(b.file));
|
|
365
|
+
// A slide the user deleted locally. `computeDirty` reports it as dirty like
|
|
366
|
+
// any other change, so without this it would be labelled `both-edited` — true
|
|
367
|
+
// in spirit, wrong in the one word the user reads to decide what to do.
|
|
368
|
+
const locallyDeleted = (b) => Boolean(b.file && !b.renamed && localRemoved.has(b.file));
|
|
369
|
+
|
|
370
|
+
const conflicts = [];
|
|
371
|
+
const remoteOnly = [];
|
|
372
|
+
const localOnly = [];
|
|
373
|
+
const unchanged = [];
|
|
374
|
+
const conflict = (kind, b, i, detail, extra = {}) =>
|
|
375
|
+
conflicts.push({
|
|
376
|
+
id: `c${conflicts.length + 1}`,
|
|
377
|
+
kind,
|
|
378
|
+
guid: b ? nameOf(b) : i ? nameOf(i) : null,
|
|
379
|
+
key: b?.key ?? i?.key ?? null,
|
|
380
|
+
file: b?.file ?? null,
|
|
381
|
+
baseSha256: b?.package ?? null,
|
|
382
|
+
incomingSha256: i?.package ?? null,
|
|
383
|
+
inputSha256: dirty.inputDigest,
|
|
384
|
+
detail,
|
|
385
|
+
...extra,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Only a broken package gets here (a valid one gives every slide an id), and
|
|
389
|
+
// nothing about a broken package is guessed into place.
|
|
390
|
+
for (const { slide, why } of match.unnamed) {
|
|
391
|
+
const isBase = base.includes(slide);
|
|
392
|
+
conflict("ambiguous-identity", isBase ? slide : null, isBase ? null : slide, why, { part: slide.part });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
for (const { base: b, incoming: i } of match.pairs) {
|
|
396
|
+
const changedThere = baseContent.get(b.part) !== incomingContent.get(i.part);
|
|
397
|
+
if (changedThere && locallyDeleted(b)) {
|
|
398
|
+
conflict(
|
|
399
|
+
"local-deleted",
|
|
400
|
+
b,
|
|
401
|
+
i,
|
|
402
|
+
"this slide was deleted locally, and the incoming file changed it — " +
|
|
403
|
+
"reinstating their version and dropping it are both defensible, so neither is chosen",
|
|
404
|
+
);
|
|
405
|
+
} else if (changedThere && mine(b)) {
|
|
406
|
+
conflict("both-edited", b, i, "this slide changed on both sides");
|
|
407
|
+
} else if (changedThere) {
|
|
408
|
+
remoteOnly.push({ id: b.key, key: b.key, guid: nameOf(b), action: "refresh", file: b.file ?? null, index: i.index });
|
|
409
|
+
} else if (mine(b)) {
|
|
410
|
+
localOnly.push({ key: b.key, guid: nameOf(b), file: b.file ?? null });
|
|
411
|
+
} else {
|
|
412
|
+
unchanged.push(nameOf(b));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
for (const b of match.baseOnly) {
|
|
417
|
+
if (!mine(b) || locallyDeleted(b)) {
|
|
418
|
+
remoteOnly.push({ id: b.key, key: b.key, guid: nameOf(b), action: "delete", file: b.file ?? null });
|
|
419
|
+
} else if (match.unclaimedLineage.has(b)) {
|
|
420
|
+
conflict(
|
|
421
|
+
"ambiguous-identity",
|
|
422
|
+
b,
|
|
423
|
+
null,
|
|
424
|
+
"their file holds several copies of this slide and none of them can be told to be it, " +
|
|
425
|
+
"so there is no certain place for your edit",
|
|
426
|
+
);
|
|
427
|
+
} else {
|
|
428
|
+
conflict("incoming-deleted", b, null, "the incoming file no longer has this slide, but it has unpublished local edits");
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const identity = {};
|
|
433
|
+
for (const { base: b, incoming: i } of match.pairs) identity[i.key] = b.key;
|
|
434
|
+
for (const i of match.incomingOnly) {
|
|
435
|
+
if (!i.key) continue;
|
|
436
|
+
identity[i.key] = `in:${i.key}`;
|
|
437
|
+
// A copy PowerPoint's Duplicate Slide made carries its original's GUID.
|
|
438
|
+
const copy = Boolean(i.guid && theirs.some((s) => s !== i && s.guid === i.guid));
|
|
439
|
+
remoteOnly.push({ id: `in:${i.key}`, key: null, guid: nameOf(i), action: "add", file: null, index: i.index, copy });
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Order. Their order, plus every move and addition only this side made.
|
|
443
|
+
const fileFor = new Map(base.filter((b) => b.key && b.file).map((b) => [b.file, b.key]));
|
|
444
|
+
const { order: orderPlan, bothMoved } = mergeOrder(
|
|
445
|
+
base.map((b) => b.key).filter(Boolean),
|
|
446
|
+
theirs.map((i) => identity[i.key]).filter(Boolean),
|
|
447
|
+
slideFileOrder(workspaceDir).map((file) => fileFor.get(file) ?? `local:${file}`),
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const notes = [];
|
|
451
|
+
if (sharedChanged) {
|
|
452
|
+
notes.push(
|
|
453
|
+
"their save changed the shared design (theme, masters or layouts); it is taken as saved, and " +
|
|
454
|
+
"slides you edited will render onto it — check them in the next preview",
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
for (const id of bothMoved) {
|
|
458
|
+
const b = base.find((s) => s.key === id);
|
|
459
|
+
notes.push(`${b?.file ?? nameOf(b ?? { key: id })} was moved on both sides — it keeps their position`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
revisionId,
|
|
464
|
+
baseSha256: revision.base?.sha256 ?? null,
|
|
465
|
+
incomingSha256: incoming.sha256,
|
|
466
|
+
remoteOnly,
|
|
467
|
+
localOnly,
|
|
468
|
+
unchanged,
|
|
469
|
+
conflicts,
|
|
470
|
+
orderPlan,
|
|
471
|
+
identity,
|
|
472
|
+
renames,
|
|
473
|
+
sharedChanged,
|
|
474
|
+
notes,
|
|
475
|
+
generation: revision.generation,
|
|
476
|
+
inputDigest: dirty.inputDigest,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Write the diagnostic conflict manifest and the complete recovery evidence:
|
|
482
|
+
* the accepted base, the whole incoming file, and a snapshot of the local
|
|
483
|
+
* inputs as they stood. A per-slide preview is an extra, never the only copy.
|
|
484
|
+
*/
|
|
485
|
+
export function recordConflicts(workspaceDir, classified) {
|
|
486
|
+
const dir = stagedDir(workspaceDir, classified.revisionId);
|
|
487
|
+
const localDir = path.join(dir, "local-snapshot");
|
|
488
|
+
fs.mkdirSync(localDir, { recursive: true });
|
|
489
|
+
for (const sub of ["slides", "assets", "conversions"]) {
|
|
490
|
+
const from = path.join(workspaceDir, sub);
|
|
491
|
+
if (fs.existsSync(from)) fs.cpSync(from, path.join(localDir, sub), { recursive: true });
|
|
492
|
+
}
|
|
493
|
+
writeConflicts(workspaceDir, {
|
|
494
|
+
revisionId: classified.revisionId,
|
|
495
|
+
baseSha256: classified.baseSha256,
|
|
496
|
+
incomingSha256: classified.incomingSha256,
|
|
497
|
+
generation: classified.generation,
|
|
498
|
+
inputDigest: classified.inputDigest,
|
|
499
|
+
detectedAt: nowIso(),
|
|
500
|
+
conflicts: classified.conflicts,
|
|
501
|
+
recovery: {
|
|
502
|
+
base: "state/base.pptx",
|
|
503
|
+
incoming: `state/staged/${classified.revisionId}/incoming.pptx`,
|
|
504
|
+
local: `state/staged/${classified.revisionId}/local-snapshot/`,
|
|
505
|
+
},
|
|
506
|
+
resolveWith: `slidemind rebase <deck> --resolve <resolution.json>`,
|
|
507
|
+
});
|
|
508
|
+
return classified.conflicts;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Two-phase in-memory rename avoids filename swaps clobbering each other.
|
|
512
|
+
function applyOrder(ws, order, fileByKey) {
|
|
513
|
+
const all = slideFileOrder(ws);
|
|
514
|
+
const ordered = [...order.map(key => fileByKey.get(key)).filter(file => file && all.includes(file))];
|
|
515
|
+
ordered.push(...all.filter(file => !ordered.includes(file)));
|
|
516
|
+
const renames = new Map(ordered.map((file, i) => [file, `${String(i + 1).padStart(2, '0')}-${file.replace(/^\d+-/, '')}`]));
|
|
517
|
+
const pending = [];
|
|
518
|
+
const guids = loadSlideGuids(ws), nextGuids = {};
|
|
519
|
+
for (const [from, to] of renames) {
|
|
520
|
+
const a = from.replace(/\.(jsx|pptx)$/, ''), b = to.replace(/\.(jsx|pptx)$/, '');
|
|
521
|
+
for (const [oldRel, newRel] of [[`slides/${from}`, `slides/${to}`], [`slides/${a}.brief.md`, `slides/${b}.brief.md`], [`conversions/${a}.json`, `conversions/${b}.json`], [`conversions/${a}.slide.pptx`, `conversions/${b}.slide.pptx`]]) {
|
|
522
|
+
if (!fs.existsSync(path.join(ws, oldRel))) continue;
|
|
523
|
+
let bytes = fs.readFileSync(path.join(ws, oldRel));
|
|
524
|
+
if (oldRel.startsWith('conversions/') && oldRel.endsWith('.json')) {
|
|
525
|
+
const record = JSON.parse(bytes); record.slide = `slides/${to}`;
|
|
526
|
+
if (record.source?.package) record.source.package = `${b}.slide.pptx`;
|
|
527
|
+
bytes = Buffer.from(JSON.stringify(record, null, 2));
|
|
528
|
+
}
|
|
529
|
+
pending.push({ oldRel, newRel, bytes });
|
|
530
|
+
}
|
|
531
|
+
if (guids[`slides/${from}`]) nextGuids[`slides/${to}`] = guids[`slides/${from}`];
|
|
532
|
+
}
|
|
533
|
+
for (const entry of pending) fs.rmSync(path.join(ws, entry.oldRel), { force: true });
|
|
534
|
+
for (const entry of pending) fs.writeFileSync(path.join(ws, entry.newRel), entry.bytes);
|
|
535
|
+
fs.writeFileSync(path.join(ws, 'state', 'slide-guids.json'), JSON.stringify(Object.fromEntries(Object.entries(nextGuids).map(([file, guid]) => [file, { guid, sha256: null }]))));
|
|
536
|
+
for (const [key, file] of fileByKey) fileByKey.set(key, renames.get(file) ?? file);
|
|
537
|
+
return renames;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// An explicit `rebase --incoming` must not bypass the checks pull performs.
|
|
541
|
+
// Remote artifacts were downloaded by pull and are read from its immutable
|
|
542
|
+
// staging directory; this path has no network or publication authority.
|
|
543
|
+
function assessStagedBusiness(ws, revisionId) {
|
|
544
|
+
const decoded = readPresentationContext(unzip(stagedBytes(ws, revisionId)));
|
|
545
|
+
const remembered = readRemote(ws)?.extensions ?? [];
|
|
546
|
+
const declarations = decoded?.context?.extensions ?? [];
|
|
547
|
+
const blocked = message => { throw Object.assign(new Error(message), { code: 'validation_required' }); };
|
|
548
|
+
if (decoded?.corrupt || remembered.some(e => e.required !== false && !declarations.some(d => d.id === e.id && d.required !== false))) blocked('The staged remote generation is missing its required business context. Pull its exact artifacts before rebasing.');
|
|
549
|
+
if (!declarations.length) return;
|
|
550
|
+
const dependencies = declarations.flatMap(e => e.refs ?? []);
|
|
551
|
+
const request = { dependencies, context: decoded.context };
|
|
552
|
+
const hydrate = runDeclared(ws, declarations, 'hydrate', request);
|
|
553
|
+
for (const result of hydrate.results) {
|
|
554
|
+
for (const artifact of result.response.artifacts ?? []) {
|
|
555
|
+
const ref = (result.declaration.refs ?? []).find(r => r.artifactId === artifact.artifactId);
|
|
556
|
+
if (!ref?.sha256) { if (artifact.required !== false) blocked(`No exact ref for ${artifact.artifactId}`); else continue; }
|
|
557
|
+
const file = path.join(stagedDir(ws, revisionId), 'context', `${ref.sha256}-${ref.artifactId}`.replace(/[^A-Za-z0-9._-]/g, '-'));
|
|
558
|
+
if (!fs.existsSync(file)) { if (artifact.required !== false) blocked(`The exact ${artifact.artifactId} is not staged; pull before rebasing.`); else continue; }
|
|
559
|
+
const placement = materializeArtifact(ws, { ...artifact, extensionId: result.id, sha256: ref.sha256 }, fs.readFileSync(file));
|
|
560
|
+
if (placement.isolated) blocked(`Unsafe artifact placement for ${artifact.artifactId}`);
|
|
561
|
+
const registryPath = path.join(ws, 'state', 'artifact-materializations.json');
|
|
562
|
+
let registry = { artifacts: [] };
|
|
563
|
+
try { registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); } catch {}
|
|
564
|
+
registry.artifacts = (registry.artifacts ?? []).filter(a => a.extensionId !== result.id || a.artifactId !== ref.artifactId);
|
|
565
|
+
registry.artifacts.push({ extensionId: result.id, artifactId: ref.artifactId, localPath: path.relative(ws, placement.path), sha256: ref.sha256 });
|
|
566
|
+
fs.writeFileSync(registryPath, JSON.stringify(registry));
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const restored = runDeclared(ws, declarations, 'hydrate', request);
|
|
570
|
+
const assessed = runDeclared(ws, declarations, 'assess', request);
|
|
571
|
+
const blockers = [...restored.blockers, ...assessed.blockers];
|
|
572
|
+
if (blockers.length) blocked(blockers.map(b => b.message).join('\n'));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Take one slide of the incoming package back to its package form on disk. The
|
|
576
|
+
// local hydration of that slide (JSX plus its conversion record) is removed:
|
|
577
|
+
// keeping it would be keeping a projection of content that no longer exists.
|
|
578
|
+
// The one-slide package with its GUID tag rewritten in place.
|
|
579
|
+
function withSlideGuid(bytes, guid) {
|
|
580
|
+
const files = unzip(Buffer.from(bytes));
|
|
581
|
+
const part = slideOrderPaths(files)[0];
|
|
582
|
+
return part && updateSlideTag(files, part, TAG_GUID, guid) ? zip(files) : bytes;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function refreshSlideFromIncoming(workspaceDir, incomingBuf, entry, folds, ownGuid = null) {
|
|
586
|
+
const slidesDir = path.join(workspaceDir, "slides");
|
|
587
|
+
fs.mkdirSync(slidesDir, { recursive: true });
|
|
588
|
+
const fold = folds.get(entry.index);
|
|
589
|
+
if (!fold) return null;
|
|
590
|
+
const stem = entry.file
|
|
591
|
+
? entry.file.replace(/\.(jsx|pptx)$/, "")
|
|
592
|
+
: `${String(entry.index).padStart(2, "0")}-incoming-${String(entry.guid ?? entry.key).replace(/[^a-z0-9]/gi, "").slice(0, 8)}`;
|
|
593
|
+
if (entry.file) {
|
|
594
|
+
fs.rmSync(path.join(slidesDir, entry.file), { force: true });
|
|
595
|
+
if (entry.file.endsWith(".jsx")) removeConversionRecord(workspaceDir, entry.file);
|
|
596
|
+
}
|
|
597
|
+
const file = `${stem}.pptx`;
|
|
598
|
+
fs.writeFileSync(path.join(slidesDir, file), Buffer.from(ownGuid ? withSlideGuid(fold.bytes, ownGuid) : fold.bytes));
|
|
599
|
+
return file;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Apply a classified rebase. Remote-only changes land, local-only edits stay
|
|
604
|
+
* dirty against the NEW accepted base, and a conflict activates nothing.
|
|
605
|
+
*
|
|
606
|
+
* `resolutions` (from `rebase --resolve <file>`) is validated against the exact
|
|
607
|
+
* base/incoming/input hashes recorded when the conflict was detected — a stale
|
|
608
|
+
* resolution is refused with everything intact. There is no generic force.
|
|
609
|
+
*/
|
|
610
|
+
export function applyRebase(workspaceDir, revisionId, { owner, resolutions = null, staged = false } = {}) {
|
|
611
|
+
if (!staged) {
|
|
612
|
+
const classified = classifyRebase(workspaceDir, revisionId);
|
|
613
|
+
// Preserve the normal conflict manifest on the real workspace.
|
|
614
|
+
if (classified.conflicts.length && !resolutions) {
|
|
615
|
+
recordConflicts(workspaceDir, classified);
|
|
616
|
+
throw Object.assign(new Error(`${classified.conflicts.length} conflict(s) need a decision; nothing activated.`), { code: 'rebase_conflicts', conflicts: classified.conflicts });
|
|
617
|
+
}
|
|
618
|
+
const tx = stageWorkspace(workspaceDir);
|
|
619
|
+
try {
|
|
620
|
+
tx.copySnapshot(revisionId);
|
|
621
|
+
const result = applyRebase(tx.workspace, revisionId, { owner, resolutions, staged: true });
|
|
622
|
+
if (readStaged(workspaceDir, revisionId)?.source?.kind === 'remote') assessStagedBusiness(tx.workspace, revisionId);
|
|
623
|
+
tx.activate({ checkpoint: () => assertCheckpoint(workspaceDir, { owner, generation: classified.generation, digest: classified.inputDigest }) });
|
|
624
|
+
return result;
|
|
625
|
+
} finally { tx.dispose(); }
|
|
626
|
+
}
|
|
627
|
+
const classified = classifyRebase(workspaceDir, revisionId);
|
|
628
|
+
const revision = readRevision(workspaceDir);
|
|
629
|
+
const incoming = readStaged(workspaceDir, revisionId);
|
|
630
|
+
const incomingBuf = stagedBytes(workspaceDir, revisionId);
|
|
631
|
+
if (!incomingBuf) {
|
|
632
|
+
const e = new Error(`staged snapshot ${revisionId} has no bytes`);
|
|
633
|
+
e.code = "no_such_revision";
|
|
634
|
+
throw e;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
let outstanding = classified.conflicts;
|
|
638
|
+
let chosen = new Map();
|
|
639
|
+
if (resolutions) {
|
|
640
|
+
const { applied, rejected } = matchResolutions(classified, resolutions);
|
|
641
|
+
if (rejected.length > 0) {
|
|
642
|
+
const e = new Error(
|
|
643
|
+
`these resolutions no longer match the state they were written against:\n ` +
|
|
644
|
+
rejected.map((r) => `${r.id}: ${r.why}`).join("\n ") +
|
|
645
|
+
`\nNothing was activated. Re-read the conflict manifest and resolve again.`,
|
|
646
|
+
);
|
|
647
|
+
e.code = "resolution_stale";
|
|
648
|
+
e.rejected = rejected;
|
|
649
|
+
throw e;
|
|
650
|
+
}
|
|
651
|
+
chosen = applied;
|
|
652
|
+
outstanding = classified.conflicts.filter((c) => !chosen.has(c.id));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (outstanding.length > 0) {
|
|
656
|
+
recordConflicts(workspaceDir, { ...classified, conflicts: outstanding });
|
|
657
|
+
const e = new Error(
|
|
658
|
+
`${outstanding.length} conflict(s) need a decision — nothing was changed. ` +
|
|
659
|
+
`The base, the incoming file and a snapshot of your local slides are all kept under ` +
|
|
660
|
+
`state/staged/${revisionId}/. Resolve with \`--resolve <file>\`.`,
|
|
661
|
+
);
|
|
662
|
+
e.code = "rebase_conflicts";
|
|
663
|
+
e.conflicts = outstanding;
|
|
664
|
+
throw e;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Everything that will touch the working tree, worked out BEFORE anything is
|
|
668
|
+
// written, so a failure halfway through has a journal to resume from.
|
|
669
|
+
const refresh = classified.remoteOnly.filter((r) => r.action !== "delete");
|
|
670
|
+
const removals = classified.remoteOnly.filter((r) => r.action === "delete");
|
|
671
|
+
const incomingKeys = keyed(incoming.slides);
|
|
672
|
+
for (const [id, choice] of chosen) {
|
|
673
|
+
const conflict = classified.conflicts.find((c) => c.id === id);
|
|
674
|
+
if (!conflict?.key) continue;
|
|
675
|
+
if (choice.choice === "incoming") {
|
|
676
|
+
const inc = incomingKeys.find((s) => s.key && classified.identity[s.key] === conflict.key);
|
|
677
|
+
if (inc) {
|
|
678
|
+
refresh.push({
|
|
679
|
+
id: conflict.key,
|
|
680
|
+
key: conflict.key,
|
|
681
|
+
guid: conflict.guid,
|
|
682
|
+
action: "refresh",
|
|
683
|
+
file: conflict.file,
|
|
684
|
+
index: inc.index,
|
|
685
|
+
});
|
|
686
|
+
} else {
|
|
687
|
+
removals.push({ id: conflict.key, key: conflict.key, guid: conflict.guid, action: "delete", file: conflict.file });
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// "local" keeps the working file exactly as it is; it simply stays dirty
|
|
691
|
+
// against the new base. "merged" means the user already wrote the file.
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const journal = journalBegin(workspaceDir, {
|
|
695
|
+
op: "rebase",
|
|
696
|
+
stagingDir: `state/staged/${revisionId}`,
|
|
697
|
+
detail: {
|
|
698
|
+
revisionId,
|
|
699
|
+
from: revision.base?.sha256 ?? null,
|
|
700
|
+
to: incoming.sha256,
|
|
701
|
+
refresh: refresh.map((r) => r.guid),
|
|
702
|
+
remove: removals.map((r) => r.guid),
|
|
703
|
+
},
|
|
704
|
+
});
|
|
705
|
+
assertCheckpoint(workspaceDir, {
|
|
706
|
+
owner,
|
|
707
|
+
generation: classified.generation,
|
|
708
|
+
digest: classified.inputDigest,
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
const picks = [...new Set(refresh.map((r) => r.index).filter(Boolean))];
|
|
712
|
+
const folds = new Map(
|
|
713
|
+
(picks.length ? foldPickedSlides(incomingBuf, picks, { sourceName: "incoming" }) : []).map(
|
|
714
|
+
(f) => [f.selection, f],
|
|
715
|
+
),
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
journalStep(workspaceDir, journal, "materialize-incoming");
|
|
719
|
+
// Merge id -> working file: a base slide by its key, a slide added here by
|
|
720
|
+
// its file, a slide arriving from their file by the id `classifyRebase` gave it.
|
|
721
|
+
const fileByKey = new Map();
|
|
722
|
+
for (const slide of keyed(revision.base?.slides)) {
|
|
723
|
+
if (slide.key && slide.file) fileByKey.set(slide.key, classified.renames[slide.file] ?? slide.file);
|
|
724
|
+
}
|
|
725
|
+
for (const id of classified.orderPlan) {
|
|
726
|
+
if (id.startsWith("local:")) fileByKey.set(id, id.slice("local:".length));
|
|
727
|
+
}
|
|
728
|
+
for (const entry of refresh) {
|
|
729
|
+
// Two working files never claim one identity: a duplicated slide's copy gets
|
|
730
|
+
// its own GUID, in the slide it brings and in the sidecar — the way adopt
|
|
731
|
+
// re-mints a duplicate.
|
|
732
|
+
const ownGuid = entry.copy ? mintSlideGuid() : null;
|
|
733
|
+
const file = refreshSlideFromIncoming(workspaceDir, incomingBuf, entry, folds, ownGuid);
|
|
734
|
+
if (file) {
|
|
735
|
+
fileByKey.set(entry.id, file);
|
|
736
|
+
const previousGuid = entry.file ? loadSlideGuids(workspaceDir)[`slides/${entry.file}`] : null;
|
|
737
|
+
const guid = ownGuid ?? (incoming.slides ?? []).find((s) => s.index === entry.index)?.guid ?? previousGuid;
|
|
738
|
+
if (guid) saveSlideGuids(workspaceDir, { [`slides/${file}`]: guid });
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
for (const entry of removals) {
|
|
742
|
+
fileByKey.delete(entry.id);
|
|
743
|
+
if (!entry.file) continue;
|
|
744
|
+
fs.rmSync(path.join(workspaceDir, "slides", entry.file), { force: true });
|
|
745
|
+
if (entry.file.endsWith(".jsx")) removeConversionRecord(workspaceDir, entry.file);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const beforeOrder = materializedInputs(workspaceDir);
|
|
749
|
+
const renamed = applyOrder(workspaceDir, classified.orderPlan, fileByKey);
|
|
750
|
+
journalStep(workspaceDir, journal, "advance-base");
|
|
751
|
+
fs.writeFileSync(acceptedBasePath(workspaceDir), incomingBuf);
|
|
752
|
+
const inventory = inventoryPackage(incomingBuf);
|
|
753
|
+
const materialized = materializedInputs(workspaceDir);
|
|
754
|
+
|
|
755
|
+
// The new baseline records what the incoming side contributed. Local edits are
|
|
756
|
+
// deliberately NOT folded in: they stay dirty against the new base, which is
|
|
757
|
+
// what "unpublished local work" means.
|
|
758
|
+
const renamedKey = key => {
|
|
759
|
+
for (const [from, to] of renamed) {
|
|
760
|
+
const a = from.replace(/\.(jsx|pptx)$/, ''), b = to.replace(/\.(jsx|pptx)$/, '');
|
|
761
|
+
if (key === `slides/${from}`) return `slides/${to}`;
|
|
762
|
+
if (key === `slides/${a}.brief.md`) return `slides/${b}.brief.md`;
|
|
763
|
+
if (key.startsWith(`conversions/${a}.`)) return key.replace(`conversions/${a}.`, `conversions/${b}.`);
|
|
764
|
+
}
|
|
765
|
+
return key;
|
|
766
|
+
};
|
|
767
|
+
// A file renamed here carries its accepted bytes under its new name: it is
|
|
768
|
+
// the same slide, moved, and must not read as deleted-plus-added afterwards.
|
|
769
|
+
// Built fresh, not moved key by key: in a swap each name is the other's target.
|
|
770
|
+
const priorInputs = {};
|
|
771
|
+
for (const [rel, hash] of Object.entries(revision.baseline?.inputs ?? {})) {
|
|
772
|
+
const to = rel.startsWith("slides/") ? classified.renames[rel.slice("slides/".length)] : undefined;
|
|
773
|
+
priorInputs[to ? `slides/${to}` : rel] = hash;
|
|
774
|
+
}
|
|
775
|
+
const baseline = {};
|
|
776
|
+
for (const [key, hash] of Object.entries(priorInputs)) {
|
|
777
|
+
const moved = renamedKey(key);
|
|
778
|
+
baseline[moved] = key.startsWith('conversions/') && key.endsWith('.json') && beforeOrder.inputs[key] === hash ? materialized.inputs[moved] : hash;
|
|
779
|
+
}
|
|
780
|
+
for (const entry of [...refresh, ...removals]) {
|
|
781
|
+
if (entry.file) {
|
|
782
|
+
delete baseline[renamedKey(`slides/${entry.file}`)];
|
|
783
|
+
const stem = entry.file.replace(/\.(jsx|pptx)$/, '');
|
|
784
|
+
delete baseline[renamedKey(`conversions/${stem}.json`)];
|
|
785
|
+
delete baseline[renamedKey(`conversions/${stem}.slide.pptx`)];
|
|
786
|
+
}
|
|
787
|
+
const landed = fileByKey.get(entry.id);
|
|
788
|
+
if (landed && materialized.inputs[`slides/${landed}`]) baseline[`slides/${landed}`] = materialized.inputs[`slides/${landed}`];
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Their order, in this deck's file names: what the new base IS. Where the
|
|
792
|
+
// merged order differs (a move or an addition made here), the deck stays
|
|
793
|
+
// dirty on order until the next publish carries it.
|
|
794
|
+
const fileOfIncoming = (slide) => (slide.key ? fileByKey.get(classified.identity[slide.key]) ?? null : null);
|
|
795
|
+
const incomingInventory = keyed(inventory.slides);
|
|
796
|
+
const baseOrder = incomingInventory
|
|
797
|
+
.map(fileOfIncoming)
|
|
798
|
+
.filter((file) => file && baseline[`slides/${file}`] !== undefined);
|
|
799
|
+
|
|
800
|
+
journalStep(workspaceDir, journal, "write-revision");
|
|
801
|
+
const next = writeRevision(workspaceDir, {
|
|
802
|
+
...revision,
|
|
803
|
+
generation: revision.generation + 1,
|
|
804
|
+
base: {
|
|
805
|
+
sha256: inventory.sha256,
|
|
806
|
+
acceptedAt: nowIso(),
|
|
807
|
+
slideCount: inventory.slideCount,
|
|
808
|
+
packageSha256: inventory.packageSha256,
|
|
809
|
+
order: inventory.order,
|
|
810
|
+
slides: inventory.slides.map((s, i) => ({
|
|
811
|
+
...s,
|
|
812
|
+
file: fileOfIncoming(incomingInventory[i]),
|
|
813
|
+
})),
|
|
814
|
+
},
|
|
815
|
+
baseline: {
|
|
816
|
+
...revision.baseline,
|
|
817
|
+
inputs: baseline,
|
|
818
|
+
order: baseOrder,
|
|
819
|
+
shared: materialized.shared,
|
|
820
|
+
at: nowIso(),
|
|
821
|
+
},
|
|
822
|
+
candidate: null, // every candidate was built against the old base
|
|
823
|
+
conflicts: null,
|
|
824
|
+
rebasedFrom: { revisionId, at: nowIso(), incomingSha256: incoming.sha256 },
|
|
825
|
+
});
|
|
826
|
+
clearConflicts(workspaceDir);
|
|
827
|
+
journalComplete(workspaceDir);
|
|
828
|
+
|
|
829
|
+
// A rebase onto a save pulled from the bound item moves the accepted remote
|
|
830
|
+
// revision with the base. Without this the next publish compares the item's
|
|
831
|
+
// eTag against the one before that save and stages the same save again as
|
|
832
|
+
// "somebody else saved first", forever.
|
|
833
|
+
const remote = readRemote(workspaceDir);
|
|
834
|
+
if (remote && incoming.source?.kind === "remote" && incoming.source.itemId === remote.itemId) {
|
|
835
|
+
writeRemote(workspaceDir, {
|
|
836
|
+
...remote,
|
|
837
|
+
acceptedETag: incoming.source.eTag ?? remote.acceptedETag,
|
|
838
|
+
acceptedCTag: incoming.source.cTag ?? remote.acceptedCTag,
|
|
839
|
+
acceptedSha256: incoming.sha256,
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const after = computeDirty(workspaceDir, next);
|
|
844
|
+
return {
|
|
845
|
+
revisionId,
|
|
846
|
+
applied: refresh.length + removals.length,
|
|
847
|
+
refreshed: refresh.map((r) => r.guid),
|
|
848
|
+
removed: removals.map((r) => r.guid),
|
|
849
|
+
localOnly: classified.localOnly,
|
|
850
|
+
notes: classified.notes,
|
|
851
|
+
stillDirty: after.dirtySlides,
|
|
852
|
+
generation: next.generation,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Bind each resolution to the exact hashes the conflict was detected against.
|
|
858
|
+
* A choice written before an input moved is REFUSED — the user resolved a
|
|
859
|
+
* different situation, and applying it anyway would silently discard whatever
|
|
860
|
+
* changed since. There is deliberately no generic force flag.
|
|
861
|
+
*/
|
|
862
|
+
export function matchResolutions(classified, resolutions) {
|
|
863
|
+
const applied = new Map();
|
|
864
|
+
const rejected = [];
|
|
865
|
+
const byId = new Map(classified.conflicts.map((c) => [c.id, c]));
|
|
866
|
+
|
|
867
|
+
if (resolutions.revisionId && resolutions.revisionId !== classified.revisionId) {
|
|
868
|
+
rejected.push({
|
|
869
|
+
id: "(file)",
|
|
870
|
+
why: `it resolves ${resolutions.revisionId}, but this rebase is ${classified.revisionId}`,
|
|
871
|
+
});
|
|
872
|
+
return { applied, rejected };
|
|
873
|
+
}
|
|
874
|
+
if (resolutions.baseSha256 && resolutions.baseSha256 !== classified.baseSha256) {
|
|
875
|
+
rejected.push({ id: "(file)", why: "the accepted base moved since it was written" });
|
|
876
|
+
return { applied, rejected };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
for (const entry of resolutions.resolutions ?? []) {
|
|
880
|
+
const conflict = byId.get(entry.id);
|
|
881
|
+
if (!conflict) {
|
|
882
|
+
rejected.push({ id: entry.id, why: "there is no conflict with that id in this rebase" });
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
if (!["local", "incoming", "merged"].includes(entry.choice)) {
|
|
886
|
+
rejected.push({ id: entry.id, why: `choice must be local, incoming or merged (got ${entry.choice})` });
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
for (const field of ["baseSha256", "incomingSha256", "inputSha256"]) {
|
|
890
|
+
if (entry[field] && entry[field] !== conflict[field]) {
|
|
891
|
+
rejected.push({ id: entry.id, why: `${field} no longer matches — the deck moved since you resolved it` });
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
if (entry.choice === "merged" && !entry.mergedFile) {
|
|
895
|
+
rejected.push({ id: entry.id, why: "a merged choice must name the file you wrote" });
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (rejected.some((r) => r.id === entry.id)) continue;
|
|
899
|
+
applied.set(entry.id, entry);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
for (const conflict of classified.conflicts) {
|
|
903
|
+
if (!applied.has(conflict.id)) {
|
|
904
|
+
rejected.push({ id: conflict.id, why: "this conflict has no resolution in the file" });
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return { applied, rejected };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** The skeleton a user fills in — every hash already bound. */
|
|
911
|
+
export function resolutionTemplate(classified) {
|
|
912
|
+
return {
|
|
913
|
+
schemaVersion: 1,
|
|
914
|
+
revisionId: classified.revisionId,
|
|
915
|
+
baseSha256: classified.baseSha256,
|
|
916
|
+
incomingSha256: classified.incomingSha256,
|
|
917
|
+
resolutions: classified.conflicts.map((c) => ({
|
|
918
|
+
id: c.id,
|
|
919
|
+
kind: c.kind,
|
|
920
|
+
guid: c.guid,
|
|
921
|
+
key: c.key,
|
|
922
|
+
file: c.file,
|
|
923
|
+
detail: c.detail,
|
|
924
|
+
choice: "local | incoming | merged",
|
|
925
|
+
mergedFile: null,
|
|
926
|
+
baseSha256: c.baseSha256,
|
|
927
|
+
incomingSha256: c.incomingSha256,
|
|
928
|
+
inputSha256: c.inputSha256,
|
|
929
|
+
})),
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** Recover an interrupted rebase: the journal names the generation to finish. */
|
|
934
|
+
export function resumeRebase(workspaceDir, journal, { owner } = {}) {
|
|
935
|
+
if (journal?.op !== "rebase") return null;
|
|
936
|
+
const revisionId = journal.detail?.revisionId;
|
|
937
|
+
if (!revisionId) {
|
|
938
|
+
journalComplete(workspaceDir);
|
|
939
|
+
return { resumed: false, reason: "the journal names no snapshot — cleared" };
|
|
940
|
+
}
|
|
941
|
+
// Re-running from the staged bytes is idempotent: classification reads the
|
|
942
|
+
// CURRENT base, so a rebase that already advanced simply finds nothing to do.
|
|
943
|
+
const result = applyRebase(workspaceDir, revisionId, { owner });
|
|
944
|
+
return { resumed: true, ...result };
|
|
945
|
+
}
|