rapier-markdown-kit 1.1.98 → 1.1.101
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/LICENSE +10 -2
- package/README.md +42 -8
- package/dist/kit/export-fonts.mjs +403 -0
- package/dist/kit/ledger/data.mjs +1 -1
- package/dist/kit/ledger/format.mjs +12 -5
- package/dist/kit/ledger/hash.mjs +16 -22
- package/dist/kit/ledger/history.mjs +59 -276
- package/dist/kit/ledger/inverse-source.mjs +316 -0
- package/dist/kit/ledger/journal-records.mjs +116 -12
- package/dist/kit/ledger/merge.mjs +7 -4
- package/dist/kit/ledger/transport.mjs +123 -19
- package/dist/kit/pdf-glyphs.mjs +159 -0
- package/dist/kit/pdf-vectors.mjs +204 -0
- package/dist/kit/print-capitals.mjs +85 -0
- package/dist/kit/rapier-ledger.mjs +2 -2
- package/dist/kit/render-entry.mjs +1 -0
- package/dist/kit/render-ink.mjs +132 -0
- package/dist/kit/render-markdown.mjs +71 -17
- package/dist/kit/render-print.mjs +208 -60
- package/dist/kit/render-sanitize.mjs +23 -7
- package/dist/kit/render-styles.mjs +8 -12
- package/dist/kit/render.mjs +365 -133
- package/dist/layout/model.mjs +26 -20
- package/dist/layout/typography.mjs +364 -0
- package/dist/packages/rapier-diff/LICENSE +21 -0
- package/dist/packages/rapier-diff/index.mjs +610 -0
- package/dist/spec/document-fonts.mjs +244 -0
- package/dist/spec/document-settings.mjs +494 -0
- package/dist/spec/frontmatter.mjs +30 -0
- package/dist/spec/html-blocks.mjs +143 -0
- package/dist/spec/md-assets.mjs +77 -5
- package/dist/spec/md-layout.mjs +7 -3
- package/dist/spec/md-marks.mjs +114 -8
- package/package.json +1 -1
- package/style.css +110 -52
- package/dist/agent/diff.mjs +0 -52
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Exact source projection shared by canonical history and causal transport.
|
|
3
|
+
// Callers admit and order the records first; this projection retains no state.
|
|
4
|
+
import {_rapierTransformSplices, _rapierHistoryEffects} from './journal-records.mjs';
|
|
5
|
+
import {diff} from '../../packages/rapier-diff/index.mjs';
|
|
6
|
+
|
|
7
|
+
const historyFailure = reason => ({ok: false, reason});
|
|
8
|
+
const historyBoundary = (source, at) => Number.isSafeInteger(at) && at >= 0 && !Object.is(at, -0) && at <= source.length &&
|
|
9
|
+
!(at > 0 && at < source.length && (source.charCodeAt(at - 1) & 0xFC00) === 0xD800 &&
|
|
10
|
+
(source.charCodeAt(at) & 0xFC00) === 0xDC00);
|
|
11
|
+
|
|
12
|
+
function minimalHistorySplice(before, after, pos = 0) {
|
|
13
|
+
let first = 0, a = before.length, b = after.length;
|
|
14
|
+
while (first < a && first < b && before[first] === after[first]) first++;
|
|
15
|
+
if (!historyBoundary(before, first) || !historyBoundary(after, first)) first--;
|
|
16
|
+
while (a > first && b > first && before[a - 1] === after[b - 1]) { a--; b--; }
|
|
17
|
+
if (!historyBoundary(before, a) || !historyBoundary(after, b)) { a++; b++; }
|
|
18
|
+
return {pos: pos + first, removed: before.slice(first, a), inserted: after.slice(first, b)};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Split recorded replacements only at exact unchanged word or line tokens.
|
|
22
|
+
// Shared letters inside changed words do not establish surviving ownership.
|
|
23
|
+
function historyHunks(row) {
|
|
24
|
+
if (row.removed === row.inserted) return [];
|
|
25
|
+
if (!row.removed || !row.inserted) return [{...row}];
|
|
26
|
+
const size = row.removed.length + row.inserted.length;
|
|
27
|
+
const edits = size <= 1024 * 1024 ? diff(row.removed, row.inserted,
|
|
28
|
+
{_unit: size <= 16384 ? 'word' : 'line', limit: 1000000}).edits : null;
|
|
29
|
+
if (!edits || edits.length > 64 || edits.some(edit => edit.coarse))
|
|
30
|
+
return [minimalHistorySplice(row.removed, row.inserted, row.pos)];
|
|
31
|
+
return edits.map(edit => ({pos: row.pos + edit.before[0], removed: row.removed.slice(...edit.before),
|
|
32
|
+
inserted: row.inserted.slice(...edit.after)})).reverse();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
// Rows are already proved source placements, optionally carrying sourceIndices from
|
|
37
|
+
// causal projection. Multiple inverse blockers can change history with no source edit.
|
|
38
|
+
export function projectInverseSource(prepared, selected, projectedEffects, options = {}) {
|
|
39
|
+
const records = prepared.rows.map(({record, splices}) => ({...record, splices}));
|
|
40
|
+
if (!records.some(record => !prepared.effects.references.get(record.transaction.id)?.length &&
|
|
41
|
+
prepared.effects.active.has(record.transaction.id) !== projectedEffects.active.has(record.transaction.id)))
|
|
42
|
+
return {ok: true, splices: [], after: prepared.source, superseded: [], overlaps: []};
|
|
43
|
+
try {
|
|
44
|
+
let pieces = prepared.initialSource ? [{text: prepared.initialSource, owner: null, lineage: []}] : [];
|
|
45
|
+
const all = [], byId = new Map(), byAct = new Map(), processed = [], keptInverseAncestors = new Set();
|
|
46
|
+
const textOf = rows => rows.map(row => row.text || '').join('');
|
|
47
|
+
const boundary = pos => {
|
|
48
|
+
let offset = 0;
|
|
49
|
+
for (let index = 0; index < pieces.length; index++) {
|
|
50
|
+
const row = pieces[index];
|
|
51
|
+
if (!row.text) continue;
|
|
52
|
+
const end = offset + row.text.length;
|
|
53
|
+
if (pos > offset && pos < end) {
|
|
54
|
+
const at = pos - offset;
|
|
55
|
+
pieces.splice(index, 1, {...row, text: row.text.slice(0, at)}, {...row, text: row.text.slice(at)});
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
offset = end;
|
|
59
|
+
}
|
|
60
|
+
offset = 0;
|
|
61
|
+
let first = pieces.length, last = pieces.length;
|
|
62
|
+
for (let index = 0; index < pieces.length; index++) {
|
|
63
|
+
if (offset === pos) { first = index; last = index; while (last < pieces.length && !pieces[last].text) last++; return {first, last}; }
|
|
64
|
+
offset += pieces[index].text?.length || 0;
|
|
65
|
+
}
|
|
66
|
+
if (offset !== pos) throw Error('range');
|
|
67
|
+
return {first, last};
|
|
68
|
+
};
|
|
69
|
+
const owns = (piece, hunk) => piece === hunk.marker || piece.owner === hunk.id ||
|
|
70
|
+
piece.restoredBy?.includes(hunk.id) || hunk.restoredMarkers?.has(piece);
|
|
71
|
+
const effectiveBefore = (hunk, effects, visiting = new Set()) => {
|
|
72
|
+
if (visiting.has(hunk.id)) return null;
|
|
73
|
+
visiting.add(hunk.id); let text = '';
|
|
74
|
+
for (const piece of hunk.before) {
|
|
75
|
+
if (!piece.text) continue;
|
|
76
|
+
const owner = byId.get(piece.owner);
|
|
77
|
+
if (owner && !effects.active.has(owner.actId) && !owner.kept) {
|
|
78
|
+
if (piece.text !== owner.inserted) return null;
|
|
79
|
+
const prior = effectiveBefore(owner, effects, new Set(visiting));
|
|
80
|
+
if (prior === null) return null;
|
|
81
|
+
text += prior;
|
|
82
|
+
} else text += piece.text;
|
|
83
|
+
}
|
|
84
|
+
return text;
|
|
85
|
+
};
|
|
86
|
+
const restoreHunk = hunk => {
|
|
87
|
+
if (!pieces.includes(hunk.marker)) throw Error('reactivation');
|
|
88
|
+
for (let at = pieces.length - 1; at >= 0; at--)
|
|
89
|
+
if (pieces[at] !== hunk.marker && owns(pieces[at], hunk)) pieces.splice(at, 1);
|
|
90
|
+
pieces.splice(pieces.indexOf(hunk.marker), 1, ...hunk.before);
|
|
91
|
+
for (const piece of hunk.before) if (piece.mark) { piece.mark.active = true; delete piece.mark.undoneBy; }
|
|
92
|
+
};
|
|
93
|
+
const laterSource = (hunk, effects) => [...hunk.supersededBy].some(id => {
|
|
94
|
+
const next = byId.get(id);
|
|
95
|
+
return next && effects.active.has(next.actId) && !prepared.effects.references.get(next.actId)?.length;
|
|
96
|
+
});
|
|
97
|
+
const reactivate = (actIds, effects) => {
|
|
98
|
+
for (const hunk of all) {
|
|
99
|
+
if (!actIds.has(hunk.actId) || hunk.derived || prepared.effects.references.get(hunk.actId)?.length) continue;
|
|
100
|
+
if (keptInverseAncestors.has(hunk.id)) continue;
|
|
101
|
+
if (laterSource(hunk, effects) || pieces.some(piece => piece.text && piece.owner === hunk.id) ||
|
|
102
|
+
(!hunk.inserted && hunk.active)) continue;
|
|
103
|
+
const marker = pieces.indexOf(hunk.marker);
|
|
104
|
+
if (marker < 0) {
|
|
105
|
+
// A zero-row withdrawal can release source still carried by another
|
|
106
|
+
// inverse's marker. Its exact ancestry also preserves later inserted runs.
|
|
107
|
+
const inverse = all.findLast(prior => prior.reversedHunk === hunk.id && pieces.includes(prior.marker));
|
|
108
|
+
if (!inverse) throw Error('reactivation');
|
|
109
|
+
if (laterSource(inverse, effects)) continue;
|
|
110
|
+
restoreHunk(inverse); inverse.active = false;
|
|
111
|
+
hunk.active = true; delete hunk.undoneBy;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const pos = textOf(pieces.slice(0, marker)).length, current = textOf(pieces);
|
|
115
|
+
const removed = effectiveBefore(hunk, effects);
|
|
116
|
+
if (removed === null || current.slice(pos, pos + removed.length) !== removed) throw Error('reactivation');
|
|
117
|
+
boundary(pos + removed.length);
|
|
118
|
+
const start = boundary(pos), end = boundary(pos + removed.length);
|
|
119
|
+
const at = start.last, count = Math.max(0, end.first - at);
|
|
120
|
+
if (textOf(pieces.slice(at, at + count)) !== removed) throw Error('reactivation');
|
|
121
|
+
const replacedOwners = new Set(pieces.slice(at, at + count).map(piece => piece.owner).filter(Boolean));
|
|
122
|
+
pieces.splice(at, count, ...(hunk.inserted ? [{text: hunk.inserted, owner: hunk.id, lineage: hunk.lineage.slice()}] : []));
|
|
123
|
+
for (const owner of replacedOwners) if (owner !== hunk.id && !pieces.some(piece => piece.text && piece.owner === owner)) {
|
|
124
|
+
const prior = byId.get(owner); if (prior) prior.active = false;
|
|
125
|
+
}
|
|
126
|
+
hunk.active = true; delete hunk.undoneBy;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const pictureInsertions = new Map(records.filter(record => prepared.effects.pictures.has(record.transaction.id))
|
|
130
|
+
.map(record => [prepared.effects.pictures.get(record.transaction.id), record.splices.map(row => row.removed)]));
|
|
131
|
+
for (const {record, splices, sourceIndices} of prepared.rows) {
|
|
132
|
+
const tx = record.transaction, references = new Set(prepared.effects.references.get(tx.id) || []);
|
|
133
|
+
const actSource = options.keepHunk && selected.has(tx.id) ? textOf(pieces) : null;
|
|
134
|
+
processed.push(record);
|
|
135
|
+
const picture = prepared.effects.pictures.get(tx.id);
|
|
136
|
+
if (picture) {
|
|
137
|
+
// The new codec bytes keep the exact inserted run's ownership. Do not
|
|
138
|
+
// cross a later edit, combine authors, or create another reversible act.
|
|
139
|
+
for (const row of splices) {
|
|
140
|
+
boundary(row.pos + row.removed.length);
|
|
141
|
+
const start = boundary(row.pos), end = boundary(row.pos + row.removed.length);
|
|
142
|
+
const removed = pieces.slice(start.last, end.first), owner = removed[0]?.owner, hunk = byId.get(owner);
|
|
143
|
+
if (!hunk || hunk.actId !== picture || removed.some(piece => !piece.text || piece.owner !== owner) || textOf(removed) !== row.removed)
|
|
144
|
+
throw Error('picture_ownership');
|
|
145
|
+
const at = hunk.inserted.indexOf(row.removed);
|
|
146
|
+
if (at < 0 || hunk.inserted.indexOf(row.removed, at + 1) >= 0) throw Error('picture_ownership');
|
|
147
|
+
hunk.inserted = hunk.inserted.slice(0, at) + row.inserted + hunk.inserted.slice(at + row.removed.length);
|
|
148
|
+
pieces.splice(start.last, end.first - start.last, {text: row.inserted, owner, lineage: hunk.lineage.slice()});
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (references.size) {
|
|
153
|
+
// Exact inverse rows restore the previous run identities. Retain the inverse's
|
|
154
|
+
// own transient marker too, so reversing it restores that same ancestry again.
|
|
155
|
+
const targets = all.filter(hunk => hunk.active && references.has(hunk.actId));
|
|
156
|
+
let restored = targets.length ? pieces.slice() : null;
|
|
157
|
+
const inverses = [], priorSplices = options.keepHunk ? [] : null;
|
|
158
|
+
for (let index = targets.length - 1; restored && index >= 0; index--) {
|
|
159
|
+
const target = targets[index], first = restored.indexOf(target.marker);
|
|
160
|
+
if (first < 0) { restored = null; break; }
|
|
161
|
+
let last = first;
|
|
162
|
+
for (let at = first; at < restored.length; at++) if (owns(restored[at], target)) last = at;
|
|
163
|
+
const pos = textOf(restored.slice(0, first)).length;
|
|
164
|
+
const before = restored.slice(first, last + 1), replacement = [];
|
|
165
|
+
for (const piece of before) {
|
|
166
|
+
if (piece === target.marker) replacement.push(...target.before);
|
|
167
|
+
else if (!owns(piece, target)) replacement.push(piece);
|
|
168
|
+
}
|
|
169
|
+
const id = tx.id + '#inverse:' + index, lineage = new Set([id]);
|
|
170
|
+
for (const piece of before) for (const parent of piece.lineage || piece.mark?.lineage || []) lineage.add(parent);
|
|
171
|
+
const hunk = {id, actId: tx.id, removed: textOf(before), inserted: textOf(replacement), before, reversedHunk: target.id,
|
|
172
|
+
chosen: selected.has(tx.id), lineage: [...lineage], supersededBy: new Set(), active: true,
|
|
173
|
+
restoredMarkers: new Set(replacement.filter(piece => !piece.text))};
|
|
174
|
+
if (hunk.chosen && options.keepHunk) {
|
|
175
|
+
const spliceIndex = splices.findIndex(row => row.pos <= pos && row.pos + row.removed.length >= pos);
|
|
176
|
+
const keep = options.keepHunk({actId: tx.id, spliceIndex: Math.max(0, spliceIndex), hunkIndex: index,
|
|
177
|
+
splice: {pos, removed: hunk.removed, inserted: hunk.inserted}, beforeSource: textOf(restored), actSource, priorSplices});
|
|
178
|
+
if (typeof keep !== 'boolean') throw Error('hunk_selection');
|
|
179
|
+
hunk.chosen = !keep;
|
|
180
|
+
}
|
|
181
|
+
hunk.kept = selected.has(tx.id) && !hunk.chosen;
|
|
182
|
+
// A semantic owner retaining this inverse also retains the exact ancestors
|
|
183
|
+
// it restored; effect reactivation must not change them behind that owner.
|
|
184
|
+
if (hunk.kept) for (let prior = target; prior; prior = byId.get(prior.reversedHunk)) keptInverseAncestors.add(prior.id);
|
|
185
|
+
hunk.marker = {mark: hunk};
|
|
186
|
+
const insertion = replacement.map(piece => piece.text ? {...piece,
|
|
187
|
+
lineage: [...new Set([...(piece.lineage || []), id])],
|
|
188
|
+
restoredBy: [...(piece.restoredBy || []), id]} : piece);
|
|
189
|
+
restored.splice(first, last + 1 - first, hunk.marker, ...insertion);
|
|
190
|
+
inverses.push(hunk); priorSplices?.push({pos, removed: hunk.removed, inserted: hunk.inserted});
|
|
191
|
+
}
|
|
192
|
+
if (restored && textOf(restored) === _rapierTransformSplices(textOf(pieces), splices)) {
|
|
193
|
+
pieces = restored;
|
|
194
|
+
for (const target of targets) { target.active = false; target.undoneBy = tx.id; }
|
|
195
|
+
for (const piece of pieces) if (piece.mark) {
|
|
196
|
+
piece.mark.active = true; delete piece.mark.undoneBy;
|
|
197
|
+
}
|
|
198
|
+
for (const hunk of inverses) { all.push(hunk); byId.set(hunk.id, hunk); }
|
|
199
|
+
byAct.set(tx.id, inverses);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (references.size) {
|
|
205
|
+
const previous = _rapierHistoryEffects(processed.slice(0, -1)), current = _rapierHistoryEffects(processed);
|
|
206
|
+
const activated = new Set(current.effectiveActIds.filter(id => !previous.active.has(id) &&
|
|
207
|
+
id !== tx.id && !current.references.get(id)?.length));
|
|
208
|
+
if (activated.size) {
|
|
209
|
+
const before = pieces, priorStates = all.map(hunk => [hunk, hunk.active, hunk.undoneBy]); pieces = pieces.slice();
|
|
210
|
+
try {
|
|
211
|
+
reactivate(activated, current);
|
|
212
|
+
if (textOf(pieces) === _rapierTransformSplices(textOf(before), splices)) continue;
|
|
213
|
+
} catch (_) {}
|
|
214
|
+
pieces = before;
|
|
215
|
+
for (const [hunk, active, undoneBy] of priorStates) { hunk.active = active; hunk.undoneBy = undoneBy; }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const priorSplices = options.keepHunk ? [] : null;
|
|
219
|
+
// An admitted codec finish replaces the complete embedded definition/reference.
|
|
220
|
+
// Keep its inserted run whole, including byte-identical prefixes of a re-edit;
|
|
221
|
+
// the previous picture stays in `before` for Undo, not as mixed live ownership.
|
|
222
|
+
const hunks = row => pictureInsertions.get(tx.id)?.some(text => row.inserted.includes(text)) ? [{...row}] : historyHunks(row);
|
|
223
|
+
for (let rowIndex = 0; rowIndex < splices.length; rowIndex++) {
|
|
224
|
+
const original = splices[rowIndex];
|
|
225
|
+
let rows;
|
|
226
|
+
if (options.splitSplice) {
|
|
227
|
+
const beforeSource = textOf(pieces), parts = options.splitSplice({actId: tx.id, beforeSource, splice: original});
|
|
228
|
+
// Semantic boundaries only refine this act's exact bytes, never its history.
|
|
229
|
+
if (!Array.isArray(parts) || _rapierTransformSplices(beforeSource, parts) !== _rapierTransformSplices(beforeSource, [original]))
|
|
230
|
+
throw Error('hunk_projection');
|
|
231
|
+
rows = parts.flatMap(hunks);
|
|
232
|
+
} else rows = hunks(original);
|
|
233
|
+
for (let index = 0; index < rows.length; index++) {
|
|
234
|
+
const row = rows[index];
|
|
235
|
+
const derived = (sourceIndices?.[rowIndex] ?? rowIndex) === record.derivedCommentIndex;
|
|
236
|
+
let chosen = selected.has(tx.id) && !derived;
|
|
237
|
+
boundary(row.pos + row.removed.length);
|
|
238
|
+
const start = boundary(row.pos), end = boundary(row.pos + row.removed.length);
|
|
239
|
+
let at = start.last, count = Math.max(0, end.first - at);
|
|
240
|
+
if (!row.removed.length) {
|
|
241
|
+
// A new insertion at a replacement's left boundary stays before that replacement.
|
|
242
|
+
const marker = pieces.slice(start.first, start.last).findIndex(item => item.mark?.inserted.length);
|
|
243
|
+
at = marker < 0 ? start.last : start.first + marker; count = 0;
|
|
244
|
+
}
|
|
245
|
+
const removed = pieces.slice(at, at + count);
|
|
246
|
+
if (textOf(removed) !== row.removed) throw Error('replay');
|
|
247
|
+
// A semantic owner may retain a drawing hunk while prose in that same act undoes.
|
|
248
|
+
// The predicate only selects transient hunks; every original row still replays.
|
|
249
|
+
if (chosen && options.keepHunk) {
|
|
250
|
+
const keep = options.keepHunk({actId: tx.id, spliceIndex: rowIndex, hunkIndex: index,
|
|
251
|
+
splice: {pos: row.pos, removed: row.removed, inserted: row.inserted}, beforeSource: textOf(pieces), actSource, priorSplices});
|
|
252
|
+
if (typeof keep !== 'boolean') throw Error('hunk_selection');
|
|
253
|
+
chosen = !keep;
|
|
254
|
+
}
|
|
255
|
+
const id = tx.id + '#' + rowIndex + ':' + index;
|
|
256
|
+
const lineage = new Set([id]);
|
|
257
|
+
for (const piece of removed) for (const parent of piece.lineage || piece.mark?.lineage || []) lineage.add(parent);
|
|
258
|
+
const hunk = {id, actId: tx.id, removed: row.removed, inserted: row.inserted, before: removed, derived,
|
|
259
|
+
chosen, kept: selected.has(tx.id) && !chosen, lineage: [...lineage], supersededBy: new Set(), active: true};
|
|
260
|
+
hunk.marker = {mark: hunk};
|
|
261
|
+
all.push(hunk); byId.set(id, hunk);
|
|
262
|
+
if (!byAct.has(tx.id)) byAct.set(tx.id, []);
|
|
263
|
+
byAct.get(tx.id).push(hunk);
|
|
264
|
+
for (const parent of lineage) {
|
|
265
|
+
const earlier = byId.get(parent);
|
|
266
|
+
if (earlier && earlier !== hunk) earlier.supersededBy.add(id);
|
|
267
|
+
}
|
|
268
|
+
pieces.splice(at, count, hunk.marker, ...(row.inserted ? [{text: row.inserted, owner: id, lineage: [...lineage]}] : []));
|
|
269
|
+
priorSplices?.push({pos: row.pos, removed: row.removed, inserted: row.inserted});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (textOf(pieces) !== prepared.source) throw Error('head');
|
|
274
|
+
const origins = new Map();
|
|
275
|
+
let offset = 0;
|
|
276
|
+
for (const piece of pieces) if (piece.text) { origins.set(piece, offset); offset += piece.text.length; }
|
|
277
|
+
const superseded = [];
|
|
278
|
+
for (let index = all.length - 1; index >= 0; index--) {
|
|
279
|
+
const hunk = all[index];
|
|
280
|
+
const inheritedInactive = !hunk.derived && !hunk.kept && !keptInverseAncestors.has(hunk.id) && !projectedEffects.active.has(hunk.actId) &&
|
|
281
|
+
!prepared.effects.references.get(hunk.actId)?.length;
|
|
282
|
+
if (!hunk.chosen && !inheritedInactive) continue;
|
|
283
|
+
hunk.active = prepared.effects.active.has(hunk.actId);
|
|
284
|
+
const laterActIds = [...new Set([...hunk.supersededBy].filter(id =>
|
|
285
|
+
(byId.get(id)?.kept || byId.get(id)?.active && projectedEffects.active.has(byId.get(id)?.actId))).map(id => byId.get(id).actId))];
|
|
286
|
+
const marker = pieces.indexOf(hunk.marker);
|
|
287
|
+
if ((!hunk.active && !inheritedInactive) || laterActIds.length || marker < 0) {
|
|
288
|
+
if (hunk.chosen) superseded.push({actId: hunk.actId, hunk: hunk.id, laterActIds: hunk.undoneBy ? [hunk.undoneBy] : laterActIds,
|
|
289
|
+
reason: hunk.active ? 'later_edit' : 'already_undone'});
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
// Restoring an inverse can restore a deletion marker without any owned text.
|
|
293
|
+
restoreHunk(hunk);
|
|
294
|
+
}
|
|
295
|
+
// A hidden withdrawal can have no splice of its own. Reversing that withdrawal
|
|
296
|
+
// reactivates the original hunk at its retained marker, never at a text search.
|
|
297
|
+
reactivate(new Set(records.filter(record => !prepared.effects.active.has(record.transaction.id) &&
|
|
298
|
+
projectedEffects.active.has(record.transaction.id)).map(record => record.transaction.id)), projectedEffects);
|
|
299
|
+
const after = textOf(pieces), changes = [];
|
|
300
|
+
let cursor = 0, inserted = '';
|
|
301
|
+
for (const piece of pieces) {
|
|
302
|
+
if (!piece.text) continue;
|
|
303
|
+
const origin = origins.get(piece);
|
|
304
|
+
if (origin == null) { inserted += piece.text; continue; }
|
|
305
|
+
if (origin < cursor) throw Error('source_order');
|
|
306
|
+
const removed = prepared.source.slice(cursor, origin);
|
|
307
|
+
if (removed !== inserted) changes.push(minimalHistorySplice(removed, inserted, cursor));
|
|
308
|
+
inserted = ''; cursor = origin + piece.text.length;
|
|
309
|
+
}
|
|
310
|
+
const removed = prepared.source.slice(cursor);
|
|
311
|
+
if (removed !== inserted) changes.push(minimalHistorySplice(removed, inserted, cursor));
|
|
312
|
+
const splices = changes.length <= 64 ? changes.reverse() : [minimalHistorySplice(prepared.source, after)];
|
|
313
|
+
if (_rapierTransformSplices(prepared.source, splices) !== after) throw Error('plan');
|
|
314
|
+
return {ok: true, splices, after, superseded, overlaps: superseded.filter(row => row.reason === 'later_edit')};
|
|
315
|
+
} catch (_) { return historyFailure('history_invalid'); }
|
|
316
|
+
}
|
|
@@ -124,6 +124,22 @@ function _rapierRecordSplices(entry, ledger) {
|
|
|
124
124
|
} catch (_) { return null; }
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// Source identifies a retained checkpoint even when host revision numbers differ.
|
|
128
|
+
function journalOperations(before, after, journal) {
|
|
129
|
+
if (typeof before !== 'string' || typeof after !== 'string' || !Array.isArray(journal)) return;
|
|
130
|
+
if (before === after) return [];
|
|
131
|
+
let text = after;
|
|
132
|
+
const groups = [];
|
|
133
|
+
for (let index = journal.length - 1; index >= 0; index--) {
|
|
134
|
+
const rows = _rapierRecordSplices(journal[index], journal);
|
|
135
|
+
const prior = _rapierTransformSplices(text, rows, true);
|
|
136
|
+
if (prior === null) return;
|
|
137
|
+
groups.push(rows);
|
|
138
|
+
if (prior === before) return groups.reverse().flat().map(({pos, removed, inserted}) => ({pos, removed, inserted}));
|
|
139
|
+
text = prior;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
127
143
|
function _rapierRecordMetadata(entry, ledger) {
|
|
128
144
|
try {
|
|
129
145
|
if (Object.hasOwn(entry, 'metadata')) return _rapierValidMetadataEffect(entry.metadata) ? entry.metadata : undefined;
|
|
@@ -140,12 +156,61 @@ function _rapierRecordMetadata(entry, ledger) {
|
|
|
140
156
|
} catch (_) { return undefined; }
|
|
141
157
|
}
|
|
142
158
|
|
|
143
|
-
//
|
|
144
|
-
//
|
|
159
|
+
// A codec replacement owns only one inserted definition and its reference. Its
|
|
160
|
+
// source act remains the picture's author; an arbitrary edit cannot claim upkeep.
|
|
161
|
+
function _rapierPictureFinish(record, original, earlier) {
|
|
162
|
+
const tx = record?.transaction, rows = record?.splices;
|
|
163
|
+
if (tx?.operation !== 'document.finish-picture' || !tx.sourceTransactionId || tx.sourceTransactionIds ||
|
|
164
|
+
tx.reverts || tx.reapplies || record.metadata || !Array.isArray(rows) || rows.length !== 2 ||
|
|
165
|
+
original?.transaction?.operation !== 'document.embed-image' || !Array.isArray(original.splices)) return false;
|
|
166
|
+
const reference = rows.find(row => /^\]\[[a-z0-9-]+\]$/i.test(row.removed));
|
|
167
|
+
if (!reference || !/^\]\[[a-z0-9-]+\]$/i.test(reference.inserted) || reference.removed === reference.inserted) return false;
|
|
168
|
+
const definition = rows.find(row => row !== reference), oldLabel = reference.removed.slice(2, -1), newLabel = reference.inserted.slice(2, -1);
|
|
169
|
+
const body = (text, label) => {
|
|
170
|
+
const prefix = '[' + label + ']: data:image/svg+xml;base64,';
|
|
171
|
+
if (typeof text !== 'string' || !text.startsWith(prefix)) return null;
|
|
172
|
+
const value = text.slice(prefix.length), end = value.indexOf(' '), bytes = end < 0 ? value : value.slice(0, end), title = end < 0 ? '' : value.slice(end);
|
|
173
|
+
return /^[A-Za-z0-9+/]+={0,2}$/.test(bytes) && !/[\r\n]/.test(title) ? title : null;
|
|
174
|
+
};
|
|
175
|
+
const title = body(definition.removed, oldLabel);
|
|
176
|
+
if (title === null || body(definition.inserted, newLabel) !== title) return false;
|
|
177
|
+
if (![definition.removed, reference.removed].every(text => {
|
|
178
|
+
let count = 0;
|
|
179
|
+
for (const row of original.splices) for (let at = row.inserted.indexOf(text); at >= 0; at = row.inserted.indexOf(text, at + 1)) count++;
|
|
180
|
+
return count === 1;
|
|
181
|
+
})) return false;
|
|
182
|
+
return rows.every((row, index) => {
|
|
183
|
+
let start = row.pos, end = start + row.removed.length;
|
|
184
|
+
const cross = (splice, owner) => {
|
|
185
|
+
const stop = splice.pos + splice.inserted.length;
|
|
186
|
+
if (splice.pos < end && stop > start || !splice.inserted.length && splice.pos > start && splice.pos < end) {
|
|
187
|
+
if (owner && start >= splice.pos && end <= stop && splice.inserted.slice(start - splice.pos, end - splice.pos) === row.removed) return 'owned';
|
|
188
|
+
return 'changed';
|
|
189
|
+
}
|
|
190
|
+
if (stop <= start) { const shift = splice.removed.length - splice.inserted.length; start += shift; end += shift; }
|
|
191
|
+
return null;
|
|
192
|
+
};
|
|
193
|
+
for (const prior of rows.slice(0, index).reverse()) if (cross(prior, false)) return false;
|
|
194
|
+
for (let at = earlier.length - 1; at >= 0; at--) {
|
|
195
|
+
const prior = earlier[at], source = _rapierRecordSplices(prior, earlier);
|
|
196
|
+
if (!source) return false;
|
|
197
|
+
const owner = prior === original || prior.transaction?.reapplies === original.transaction.id;
|
|
198
|
+
for (const splice of source.slice().reverse()) {
|
|
199
|
+
const result = cross(splice, owner);
|
|
200
|
+
if (result) return result === 'owned';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Every active inverse keeps its original targets suppressed. Independent inverses
|
|
208
|
+
// can share a target; withdrawing one restores it only after the last blocker leaves.
|
|
209
|
+
// This includes assignments hidden by later choices. No second history is retained.
|
|
145
210
|
function _rapierHistoryEffects(records, visit = null) {
|
|
146
211
|
try {
|
|
147
212
|
if (!Array.isArray(records)) return {ok: false};
|
|
148
|
-
const nodes = new Map(), references = new Map();
|
|
213
|
+
const nodes = new Map(), references = new Map(), pictures = new Map(), byId = new Map(), finished = new Set(), earlier = [];
|
|
149
214
|
const update = (target, delta) => {
|
|
150
215
|
const pending = [[target, delta]];
|
|
151
216
|
while (pending.length) {
|
|
@@ -156,7 +221,32 @@ function _rapierHistoryEffects(records, visit = null) {
|
|
|
156
221
|
if (was !== node.active) for (const ref of node.references) pending.push([ref, node.active ? 1 : -1]);
|
|
157
222
|
}
|
|
158
223
|
};
|
|
159
|
-
const lastUndo = new Map();
|
|
224
|
+
const lastUndo = new Map(), contexts = new Map(), order = [];
|
|
225
|
+
// Native Redo names an original act. Its inverse target is the last Undo
|
|
226
|
+
// in the author's proved causal view, not an unrelated later arrival.
|
|
227
|
+
const contextOf = origin => {
|
|
228
|
+
if (!origin) return null;
|
|
229
|
+
if (!_rapierValidAuthored(origin)) throw Error('origin');
|
|
230
|
+
const known = new Set(), pending = origin.basis.slice();
|
|
231
|
+
while (pending.length) {
|
|
232
|
+
const id = pending.pop(), context = contexts.get(id);
|
|
233
|
+
if (!context) throw Error('origin');
|
|
234
|
+
if (known.has(id)) continue;
|
|
235
|
+
known.add(id);
|
|
236
|
+
if (context.basis) pending.push(...context.basis);
|
|
237
|
+
else for (let index = 0; index < context.index; index++) known.add(order[index]);
|
|
238
|
+
}
|
|
239
|
+
const inactive = new Set();
|
|
240
|
+
for (let index = order.length - 1; index >= 0; index--) {
|
|
241
|
+
const id = order[index];
|
|
242
|
+
if (!known.has(id) || inactive.has(id)) continue;
|
|
243
|
+
for (const ref of references.get(id)) {
|
|
244
|
+
if (!known.has(ref)) throw Error('origin');
|
|
245
|
+
inactive.add(ref);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return {known, inactive};
|
|
249
|
+
};
|
|
160
250
|
for (const record of records) {
|
|
161
251
|
const tx = record?.transaction;
|
|
162
252
|
if (!tx || typeof tx.id !== 'string' || !tx.id || nodes.has(tx.id) || (tx.reverts && tx.reapplies)) return {ok: false};
|
|
@@ -164,24 +254,38 @@ function _rapierHistoryEffects(records, visit = null) {
|
|
|
164
254
|
if (!Array.isArray(supplied) || new Set(supplied).size !== supplied.length ||
|
|
165
255
|
(tx.sourceTransactionIds && tx.sourceTransactionId && (supplied.length !== 1 || supplied[0] !== tx.sourceTransactionId))) return {ok: false};
|
|
166
256
|
let refs = supplied;
|
|
257
|
+
if (tx.operation === 'document.finish-picture' && tx.sourceTransactionId) {
|
|
258
|
+
const owner = tx.sourceTransactionId;
|
|
259
|
+
if (!nodes.get(owner)?.active || finished.has(owner) || !_rapierPictureFinish(record, byId.get(owner), earlier)) return {ok: false};
|
|
260
|
+
pictures.set(tx.id, owner); finished.add(owner); refs = [];
|
|
261
|
+
}
|
|
167
262
|
if (tx.reverts || tx.reapplies) {
|
|
168
263
|
const target = tx.reverts || tx.reapplies;
|
|
169
264
|
if (!nodes.has(target) || (supplied.length && (supplied.length !== 1 || supplied[0] !== target))) return {ok: false};
|
|
170
265
|
if (tx.reverts) refs = [target];
|
|
171
266
|
else {
|
|
172
|
-
const
|
|
173
|
-
|
|
267
|
+
const context = contextOf(record.authored || tx.authored);
|
|
268
|
+
const undo = (lastUndo.get(target) || []).findLast(id => context
|
|
269
|
+
? context.known.has(id) && !context.inactive.has(id) : nodes.get(id).active);
|
|
270
|
+
if (!undo) return {ok: false};
|
|
174
271
|
refs = [undo];
|
|
175
272
|
}
|
|
176
273
|
}
|
|
177
|
-
if (refs.some(id => typeof id !== 'string' || !nodes.
|
|
274
|
+
if (refs.some(id => typeof id !== 'string' || !nodes.has(id) || pictures.has(id))) return {ok: false};
|
|
178
275
|
nodes.set(tx.id, {active: true, blockers: 0, references: refs}); references.set(tx.id, refs.slice());
|
|
276
|
+
byId.set(tx.id, record);
|
|
277
|
+
earlier.push(record);
|
|
179
278
|
for (const ref of refs) update(ref, 1);
|
|
180
|
-
|
|
279
|
+
const origin = record.authored || tx.authored;
|
|
280
|
+
contexts.set(tx.id, {index: order.length, basis: origin?.basis || null}); order.push(tx.id);
|
|
281
|
+
if (tx.reverts) {
|
|
282
|
+
if (!lastUndo.has(tx.reverts)) lastUndo.set(tx.reverts, []);
|
|
283
|
+
lastUndo.get(tx.reverts).push(tx.id);
|
|
284
|
+
}
|
|
181
285
|
if (visit) visit(record, nodes, refs);
|
|
182
286
|
}
|
|
183
|
-
const effectiveActIds = [...nodes].filter(([, node]) => node.active).map(([id]) => id);
|
|
184
|
-
return {ok: true, effectiveActIds, active: new Set(effectiveActIds), references};
|
|
287
|
+
const effectiveActIds = [...nodes].filter(([id, node]) => node.active && !pictures.has(id)).map(([id]) => id);
|
|
288
|
+
return {ok: true, effectiveActIds, active: new Set(effectiveActIds), references, pictures};
|
|
185
289
|
} catch (_) { return {ok: false}; }
|
|
186
290
|
}
|
|
187
291
|
|
|
@@ -311,8 +415,8 @@ function _rapierValidLedgerRecord(record, prior, earlier, documentAuthority, max
|
|
|
311
415
|
/^(?:human|agent|system)$/.test(transaction.actor.kind) &&
|
|
312
416
|
typeof transaction.actor.id === 'string' && transaction.actor.id.length > 0 &&
|
|
313
417
|
transaction.actor.id.length <= _RAPIER_TRANSACTION_ACTOR_LIMIT &&
|
|
314
|
-
optional(transaction.actor.name, value =>
|
|
418
|
+
optional(transaction.actor.name, value => identity(value, 120));
|
|
315
419
|
} catch (_) { return false; }
|
|
316
420
|
}
|
|
317
421
|
|
|
318
|
-
export { _rapierValidAuthored, _RAPIER_TRANSACTION_ACTOR_LIMIT, _RAPIER_TRANSACTION_OPERATION_LIMIT, _RAPIER_TRANSACTION_REQUEST_LIMIT, _rapierTransformSplices, _rapierRecordSplices, _rapierValidLedgerRecord, _rapierJournalRecord, _rapierValidMetadata, _rapierValidMetadataEffect, _rapierMetadataDelta, _rapierTransformMetadata, _rapierRecordMetadata, _rapierHistoryEffects, _rapierMetadataState, _rapierReplayMetadata, _rapierHasHistoryEffect};
|
|
422
|
+
export { _rapierValidAuthored, _RAPIER_TRANSACTION_ACTOR_LIMIT, _RAPIER_TRANSACTION_OPERATION_LIMIT, _RAPIER_TRANSACTION_REQUEST_LIMIT, _rapierTransformSplices, _rapierRecordSplices, _rapierValidLedgerRecord, _rapierJournalRecord, _rapierValidMetadata, _rapierValidMetadataEffect, _rapierMetadataDelta, _rapierTransformMetadata, _rapierRecordMetadata, _rapierHistoryEffects, _rapierMetadataState, _rapierReplayMetadata, _rapierHasHistoryEffect, journalOperations};
|
|
@@ -39,7 +39,7 @@ function actFingerprint(record, effects) {
|
|
|
39
39
|
const tx = record.transaction, references = effects.references.get(tx.id) || [];
|
|
40
40
|
const {id, documentAuthority, baseRevision, revision, parent, reverts, reapplies,
|
|
41
41
|
sourceTransactionId, sourceTransactionIds, remoteTransactionId, ...authored} = tx;
|
|
42
|
-
return sha256(canonicalJSON({transaction: authored, references,
|
|
42
|
+
return sha256(canonicalJSON({transaction: authored, references, picture: effects.pictures.get(tx.id) || null,
|
|
43
43
|
changeSet: record.changeSet ?? null, derivedCommentIndex: record.derivedCommentIndex ?? null,
|
|
44
44
|
// Applied source placement is proved below, never inferred from this fingerprint.
|
|
45
45
|
// An inverse's physical metadata is projected from its targets at the destination.
|
|
@@ -143,7 +143,7 @@ export function merge(first, second) {
|
|
|
143
143
|
}
|
|
144
144
|
const records = ours.ledger.records.slice(), imported = new Set(localIds.keys());
|
|
145
145
|
let text = ours.text, metadata = {...ours.metadata}, root = ours.root;
|
|
146
|
-
const append = (splices, original, effect = null, inverseIds = [], origin = original.authored) => {
|
|
146
|
+
const append = (splices, original, effect = null, inverseIds = [], origin = original.authored, picture = null) => {
|
|
147
147
|
const next = _rapierTransformSplices(text, splices);
|
|
148
148
|
if (next === null) return null;
|
|
149
149
|
const beforeHash = root, revision = (records.at(-1)?.transaction.revision ?? ours.ledger.start.revision) + 1;
|
|
@@ -153,7 +153,7 @@ export function merge(first, second) {
|
|
|
153
153
|
const record = {beforeHash, afterHash: nextRoot, splices, ...(origin ? {authored: origin} : {}), transaction: {
|
|
154
154
|
...tx, id: tx.id, documentAuthority: ours.ledger.documentAuthority,
|
|
155
155
|
baseRevision: revision - 1, revision, actor: tx.actor, transport: tx.transport, operation: tx.operation, requestId: tx.requestId,
|
|
156
|
-
sourceTransactionId: inverseIds.length === 1 ? inverseIds[0] : null,
|
|
156
|
+
sourceTransactionId: picture || (inverseIds.length === 1 ? inverseIds[0] : null),
|
|
157
157
|
...(inverseIds.length > 1 ? {sourceTransactionIds: inverseIds} : {}),
|
|
158
158
|
...(tx.turnId ? {turnId: tx.turnId} : {}), affectedBlockIds: tx.affectedBlockIds.slice(),
|
|
159
159
|
parent: records.at(-1)?.transaction.id ?? null, reverts: null, reapplies: null, createdAt: tx.createdAt,
|
|
@@ -167,6 +167,7 @@ export function merge(first, second) {
|
|
|
167
167
|
const nextMetadata = _rapierTransformMetadata(metadata, effect);
|
|
168
168
|
if (!nextMetadata) return null;
|
|
169
169
|
if (effect) record.metadata = effect;
|
|
170
|
+
if (picture && !_rapierHistoryEffects([...records, record]).ok) return null;
|
|
170
171
|
records.push(record); text = next; metadata = nextMetadata; root = nextRoot;
|
|
171
172
|
return record.transaction.id;
|
|
172
173
|
};
|
|
@@ -209,6 +210,8 @@ export function merge(first, second) {
|
|
|
209
210
|
const references = incomingEffects.references.get(record.transaction.id) || [];
|
|
210
211
|
const inverseIds = references.map(id => mappedIds.get(id));
|
|
211
212
|
if (inverseIds.some(id => !id || !_rapierHistoryEffects(records).active.has(id))) return metadataConflict('inverse_target_unavailable');
|
|
213
|
+
const pictureOwner = incomingEffects.pictures.get(record.transaction.id), picture = pictureOwner && mappedIds.get(pictureOwner);
|
|
214
|
+
if (pictureOwner && (!picture || !_rapierHistoryEffects(records).active.has(picture))) return refused('history', 'record_transport_conflict');
|
|
212
215
|
let moved;
|
|
213
216
|
try { moved = transposeAuthored(ours.ledger.start.text, records, {...record, authored: incomingOrigins.get(record.transaction.id)},
|
|
214
217
|
theirs.ledger.records.slice(0, theirs.ledger.records.indexOf(record)).map(row => row.transaction.id)); } catch (_) {}
|
|
@@ -217,7 +220,7 @@ export function merge(first, second) {
|
|
|
217
220
|
const effect = record.metadata ? Object.fromEntries(Object.entries(record.metadata).map(([key, pair]) =>
|
|
218
221
|
[key, {before: metadata[key], after: pair.after}])) : null;
|
|
219
222
|
const origin = record.authored ?? (against.length ? incomingOrigins.get(record.transaction.id) : null);
|
|
220
|
-
const mapped = append(moved.splices, record, effect, inverseIds, origin);
|
|
223
|
+
const mapped = append(moved.splices, record, effect, inverseIds, origin, picture);
|
|
221
224
|
if (!mapped) return metadataConflict(inverseIds.length ? 'inverse_target_unavailable' : 'record_transport_conflict');
|
|
222
225
|
against = against.filter(row => row.transaction.id !== record.transaction.id);
|
|
223
226
|
mappedIds.set(record.transaction.id, mapped);
|