rapier-markdown-kit 1.2.3 → 1.2.5
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 +3 -2
- package/README.md +16 -4
- package/dist/images/gif.mjs +3 -1
- package/dist/images/playback.mjs +6 -4
- package/dist/kit/conformance/run.mjs +1 -1
- package/dist/kit/ledger/authored-origin.mjs +49 -0
- package/dist/kit/ledger/authorship.mjs +18 -9
- package/dist/kit/ledger/data.mjs +7 -4
- package/dist/kit/ledger/format.mjs +25 -8
- package/dist/kit/ledger/history.mjs +31 -5
- package/dist/kit/ledger/inverse-source.mjs +111 -49
- package/dist/kit/ledger/journal-records.mjs +24 -29
- package/dist/kit/ledger/merge.mjs +48 -13
- package/dist/kit/ledger/text-merge.mjs +75 -29
- package/dist/kit/ledger/transport.mjs +30 -45
- package/dist/kit/pdf-vectors.mjs +2 -1
- package/dist/kit/print-capitals.mjs +0 -1
- package/dist/kit/rapier-ledger.mjs +2 -0
- package/dist/kit/render-markdown.mjs +10 -1
- package/dist/kit/render-print.mjs +43 -7
- package/dist/kit/render-sanitize.mjs +19 -12
- package/dist/kit/render-styles.mjs +3 -3
- package/dist/kit/render.mjs +5 -40
- package/dist/layout/paragraph-fit.mjs +1 -15
- package/dist/layout/typography.mjs +2 -1
- package/dist/packages/rapier-diff/concerns.mjs +163 -0
- package/dist/packages/rapier-diff/diff.mjs +68 -0
- package/dist/packages/rapier-diff/index.mjs +4 -744
- package/dist/packages/rapier-diff/matching.mjs +157 -0
- package/dist/packages/rapier-diff/merge.mjs +226 -0
- package/dist/packages/rapier-diff/moves.mjs +130 -0
- package/dist/packages/rapier-diff/structure.mjs +50 -0
- package/dist/packages/rapier-diff/tokens.mjs +72 -0
- package/dist/packages/rapier-diff/walk.mjs +219 -0
- package/dist/spec/html-blocks.mjs +3 -3
- package/dist/spec/md-layout.mjs +1 -1
- package/dist/spec/md-marks.mjs +1 -1
- package/dist/spec/md-source.mjs +15 -7
- package/package.json +1 -1
package/LICENSE
CHANGED
|
@@ -35,8 +35,9 @@ own MIT notices. The edit-ledger module (`kit/rapier-ledger.mjs`, `kit/ledger/*.
|
|
|
35
35
|
its editor compatibility import (`editor/journal-records.mjs`) are also covered. The
|
|
36
36
|
record validator/replayer and text-only conflict merger were moved, not copied,
|
|
37
37
|
into that module; the Notes index merger and the rest of the agent/editor remain
|
|
38
|
-
AGPL-3.0-only. The wrapper
|
|
39
|
-
|
|
38
|
+
AGPL-3.0-only. The page wrapper (`skills/rapier-html/wrap.mjs`, `skills/rapier-html/page.mjs`,
|
|
39
|
+
`skills/rapier-html/return-address.mjs`) and its carrier-staging helper
|
|
40
|
+
(`skills/rapier-html/package-files.mjs`) are MIT. The kit's MIT-marked facades,
|
|
40
41
|
`kit/render-entry.mjs`, `kit/render*.mjs`, `kit/export-fonts.mjs`, `kit/print-capitals.mjs`,
|
|
41
42
|
`kit/pdf-vectors.mjs`, `kit/pdf-glyphs.mjs`, `kit/stage.mjs` and MIT-marked conformance
|
|
42
43
|
modules are covered too. These names describe the Rapier-owned files carrying the
|
package/README.md
CHANGED
|
@@ -623,7 +623,9 @@ the authorship projection recomputed from the ledger must agree exactly.
|
|
|
623
623
|
text. Runs are `[start, end, actorKind, actorId, at]`, in order, nonoverlapping and
|
|
624
624
|
covering every current character. A start without earlier authorship is attributed
|
|
625
625
|
to `system/unattributed`, time zero. Insertions take the inserting record's actor
|
|
626
|
-
and time
|
|
626
|
+
and time, except text an Undo or Redo puts back: a splice inserting exactly the text
|
|
627
|
+
that a splice of the referenced act removed keeps the runs that text had. Deleted
|
|
628
|
+
runs disappear. Thus the projection contains **no removed text**.
|
|
627
629
|
An authorship-only import becomes a clearly incomplete insertion ledger; future
|
|
628
630
|
attribution is again derived from that one ledger, never a mutable blame database.
|
|
629
631
|
|
|
@@ -669,9 +671,19 @@ editor's restore reader can replay the result. Both merge orders yield the same
|
|
|
669
671
|
text for independently edited nonoverlapping ranges; their ordered ledgers need
|
|
670
672
|
not be identical. Full and conflict receipts make repeated imports idempotent.
|
|
671
673
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
674
|
+
An identical replacement converges with both original acts retained and one physical
|
|
675
|
+
splice. The format identifier stays `rapier-ledger/2`. That unchanged identifier is not
|
|
676
|
+
backward compatibility with an older reader. There is no `rapier-ledger/3`, no
|
|
677
|
+
compatibility reader, and no migration. An older reader rejects the empty physical
|
|
678
|
+
witness with `ledger_invalid` and does not mutate the ledger. Mixed-version sync throws
|
|
679
|
+
`notes_history_unavailable` (`the note and its canonical history cannot be proved`).
|
|
680
|
+
The older participant's store snapshot and remote object count stay unchanged. That
|
|
681
|
+
throw is not a per-note `held` or `caughtUp` result. The same format string is not
|
|
682
|
+
interoperability, and the ledger is not rewritten into the older shape. Reactivating
|
|
683
|
+
any author of that replacement checks the shared text again; undoing that author
|
|
684
|
+
withdraws it once no author remains. A different overlap returns `text_overlap_unpreserved` and the caller's exact
|
|
685
|
+
ledger, including the text merge's alternatives for review, and does not mint a synthetic
|
|
686
|
+
act. Different Will `keep` regions require conflict review even when one copy was
|
|
675
687
|
unchanged. `review.required` names an unresolved result; a UI must not auto-accept
|
|
676
688
|
it. Rapier offers carried-copy imports from Compare through its existing review,
|
|
677
689
|
checks the live document again at commit, and installs the proven ledger before
|
package/dist/images/gif.mjs
CHANGED
|
@@ -39,7 +39,9 @@ export function inspectGIF(bytes) {
|
|
|
39
39
|
at += 9; if (packed & 128) at += 3 * (2 << (packed & 7));
|
|
40
40
|
need(1); if (bytes[at] < 2 || bytes[at] > 8) fail('Invalid GIF code size');
|
|
41
41
|
at++; blocks(); frames++;
|
|
42
|
-
|
|
42
|
+
// A delay of 0 or 1 centisecond shows for 100 ms, as the browser draws it (images/playback.mjs times frames the same).
|
|
43
|
+
const delay = control ? control[4] + control[5] * 256 : 0;
|
|
44
|
+
duration += delay > 1 ? delay * 10 : 100;
|
|
43
45
|
if (frames > 240 || width * height * frames > 96000000 || duration > 120000) fail('GIF animation exceeds the frame, work or duration budget');
|
|
44
46
|
first ||= {control, image: bytes.subarray(start, at)}; control = null;
|
|
45
47
|
}
|
package/dist/images/playback.mjs
CHANGED
|
@@ -31,8 +31,9 @@ export function createMediaPlayback(win, inspectGIF) {
|
|
|
31
31
|
})();
|
|
32
32
|
admitted.catch(error => {if (!closed) {state.status = 'admission-failed'; state.error = String(error.message).slice(0, 160); changed(state);}});
|
|
33
33
|
// A decoder closes only after its in-flight decode settles; Chrome 155's JPEG XL decoder fails when closed mid-decode.
|
|
34
|
+
// Its budget place goes with it: a poster or a finished loop holds no decoder.
|
|
34
35
|
let inflight = null;
|
|
35
|
-
const closeDecoder = () => {ticket++; win.clearTimeout(timer); timer = 0; const old = decoder; decoder = null; if (old) inflight ? inflight.then(() => old.close()) : old.close();};
|
|
36
|
+
const closeDecoder = () => {ticket++; win.clearTimeout(timer); timer = 0; active.delete(canvas); const old = decoder; decoder = null; if (old) inflight ? inflight.then(() => old.close()) : old.close();};
|
|
36
37
|
const settle = async promise => {
|
|
37
38
|
let timeout;
|
|
38
39
|
try {return await Promise.race([promise, new Promise((_, reject) => {timeout = win.setTimeout(() => reject(new Error('Animation decode timeout')), 5000);})]);}
|
|
@@ -72,7 +73,8 @@ export function createMediaPlayback(win, inspectGIF) {
|
|
|
72
73
|
if (canvas.height !== h) canvas.height = h;
|
|
73
74
|
const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, w, h); ctx.drawImage(frame, 0, 0);
|
|
74
75
|
if (!state.first && next === 0) state.first = canvas.toDataURL('image/png');
|
|
75
|
-
|
|
76
|
+
// A frame of 10 ms or less shows for 100 ms, as the browser draws an <img> (images/gif.mjs counts the same).
|
|
77
|
+
const delay = frame.duration > 10000 ? Math.max(20, frame.duration / 1000) : 100;
|
|
76
78
|
duration += delay; if (duration > 120000) throw new Error('Animation exceeds the duration budget');
|
|
77
79
|
state.status = 'ready'; state.frame = next; changed(state);
|
|
78
80
|
if (single) {paused = true; closeDecoder(); return;}
|
|
@@ -93,8 +95,8 @@ export function createMediaPlayback(win, inspectGIF) {
|
|
|
93
95
|
async first() {if (!state.first && !closed) {single = true; next = 0; paused = false; try {await step();} finally {single = false; paused = true; closeDecoder(); if (held) {held = false; this.suspend();}}} return state.first;},
|
|
94
96
|
// Pause keeps the decoder and its frame position; only suspension and removal release it.
|
|
95
97
|
pause() {paused = true; ticket++; win.clearTimeout(timer); timer = 0;},
|
|
96
|
-
suspend() {if (single) {held = true; return;} paused = true; closeDecoder(); canvas.width = canvas.height = 0;
|
|
97
|
-
close() {closed = true; paused = true; closeDecoder(); canvas.width = canvas.height = 0;
|
|
98
|
+
suspend() {if (single) {held = true; return;} paused = true; closeDecoder(); canvas.width = canvas.height = 0; state.status = 'suspended'; changed(state);},
|
|
99
|
+
close() {closed = true; paused = true; closeDecoder(); canvas.width = canvas.height = 0;},
|
|
98
100
|
get paused() {return paused;}};
|
|
99
101
|
}
|
|
100
102
|
return {raster, inspectGIF, encode, source: '(' + createMediaPlayback.toString() + ')(window, ' + inspectGIF.toString() + ')'};
|
|
@@ -115,7 +115,7 @@ console.log(`A row's own hits/misses can undercount its real coverage: several f
|
|
|
115
115
|
console.log(`segment cache (agent/vendor/pretext/measurement.js) serves a repeated (font, segment) query straight from that cache without calling`);
|
|
116
116
|
console.log(`measureText again at all -- real data, just not counted against the SECOND document to ask for it. Two named, separate reasons a row still`);
|
|
117
117
|
console.log(`reads CLOSE rather than AGREE, none of them "close enough": (a) no real trace exists at all for 01-inline/02-width-x-align/05-behind/06-front`);
|
|
118
|
-
console.log(`-- their picture pushes no flow obstacle, so
|
|
118
|
+
console.log(`-- their picture pushes no flow obstacle, so kit/render.mjs's exported projector's prepare() never calls Pretext for this paragraph during export (see`);
|
|
119
119
|
console.log(`measurer.mjs) -- the from-scratch character-sum approximation disagrees with real shaping by a bounded, named amount; (b) 03/11/13 DO run`);
|
|
120
120
|
console.log(`Pretext for real (hits > 0, 13-rtl-text at 100% -- every query answered from the real trace) yet still carry a small, constant left-edge delta`);
|
|
121
121
|
console.log(`(9.64px today, a constant from before this measurer existed) -- a picture-obstacle geometry fact, not a text one: these`);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Immutable authored origins shared by causal transport and inverse projection.
|
|
3
|
+
import {_rapierTransformSplices, _rapierValidAuthored} from './journal-records.mjs';
|
|
4
|
+
import {sha256} from './hash.mjs';
|
|
5
|
+
|
|
6
|
+
// The frontier names only causal maxima. Linear history needs one predecessor,
|
|
7
|
+
// and concurrent predecessors remain separate until an act observes both.
|
|
8
|
+
function authoredFrontier(records, visit = null) {
|
|
9
|
+
const dependencies = new Map(), frontier = new Set();
|
|
10
|
+
for (const record of records) {
|
|
11
|
+
const id = record.transaction.id, basis = record.authored?.basis;
|
|
12
|
+
if (visit) visit(record, basis ?? [...frontier].sort());
|
|
13
|
+
if (!basis) { dependencies.set(id, [...frontier]); frontier.clear(); }
|
|
14
|
+
else {
|
|
15
|
+
const pending = [...basis], seen = new Set();
|
|
16
|
+
while (pending.length) {
|
|
17
|
+
const prior = pending.pop();
|
|
18
|
+
if (seen.has(prior)) continue;
|
|
19
|
+
if (!dependencies.has(prior)) throw new RangeError('canonical_origin_invalid');
|
|
20
|
+
seen.add(prior); frontier.delete(prior); pending.push(...dependencies.get(prior));
|
|
21
|
+
}
|
|
22
|
+
dependencies.set(id, basis);
|
|
23
|
+
}
|
|
24
|
+
frontier.add(id);
|
|
25
|
+
}
|
|
26
|
+
return [...frontier].sort();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function authoredBasis(records) { return authoredFrontier(records); }
|
|
30
|
+
|
|
31
|
+
export function authoredPlacements(base, records) {
|
|
32
|
+
const origins = new Map(); let source = base;
|
|
33
|
+
authoredFrontier(records, (record, basis) => {
|
|
34
|
+
origins.set(record.transaction.id, record.authored ?? authoredPlacement(source, record.splices, basis));
|
|
35
|
+
source = _rapierTransformSplices(source, record.splices);
|
|
36
|
+
if (source === null) throw new RangeError('canonical_origin_invalid');
|
|
37
|
+
});
|
|
38
|
+
return origins;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Immutable authored placement stays on its own act. Basis IDs refer only to this
|
|
42
|
+
// canonical history; no source snapshot or duplicate history is stored.
|
|
43
|
+
export function authoredPlacement(source, splices, basis) {
|
|
44
|
+
const origin = {basis: basis.slice().sort(), source: sha256(source), splices: splices.map(row => ({...row}))};
|
|
45
|
+
if (!_rapierValidAuthored(origin) || _rapierTransformSplices(source, splices) === null)
|
|
46
|
+
throw new RangeError('canonical_origin_invalid');
|
|
47
|
+
return origin;
|
|
48
|
+
}
|
|
49
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
import {readLedger, exportLedger, textRoot, rootAfter} from './format.mjs';
|
|
3
|
-
import {_rapierTransformSplices} from './journal-records.mjs';
|
|
3
|
+
import {_rapierTransformSplices, _rapierHistoryEffects} from './journal-records.mjs';
|
|
4
4
|
import {sha256} from './hash.mjs';
|
|
5
5
|
import {plainData, documentText, nonnegative, exactKeys, fault} from './data.mjs';
|
|
6
6
|
function coalesce(runs) {
|
|
@@ -14,16 +14,25 @@ function coalesce(runs) {
|
|
|
14
14
|
return out;
|
|
15
15
|
}
|
|
16
16
|
export function authorship(input) {
|
|
17
|
-
const {ledger, text} = readLedger(input);
|
|
17
|
+
const {ledger, text} = readLedger(input), references = _rapierHistoryEffects(ledger.records).references, removed = new Map();
|
|
18
18
|
let runs = ledger.start.text ? [[0, ledger.start.text.length, 'system', 'unattributed', 0]] : [];
|
|
19
|
-
for (const record of ledger.records)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
for (const record of ledger.records) {
|
|
20
|
+
// An Undo or Redo puts back text the act it references removed: that text keeps the runs it had.
|
|
21
|
+
const tx = record.transaction, gone = [], kept = (references?.get(tx.id) || []).flatMap(id => removed.get(id) || []);
|
|
22
|
+
for (const row of record.splices) {
|
|
23
|
+
const cut = row.pos + row.removed.length, delta = row.inserted.length - row.removed.length, before = [], after = [], inside = [];
|
|
24
|
+
for (const run of runs) {
|
|
25
|
+
if (run[0] < row.pos) before.push([run[0], Math.min(run[1], row.pos), ...run.slice(2)]);
|
|
26
|
+
if (run[1] > cut) after.push([Math.max(run[0], cut) + delta, run[1] + delta, ...run.slice(2)]);
|
|
27
|
+
if (run[0] < cut && run[1] > row.pos) inside.push([Math.max(run[0], row.pos) - row.pos, Math.min(run[1], cut) - row.pos, ...run.slice(2)]);
|
|
28
|
+
}
|
|
29
|
+
if (row.removed) gone.push({text: row.removed, runs: inside});
|
|
30
|
+
const at = row.inserted ? kept.findIndex(entry => entry.text === row.inserted) : -1;
|
|
31
|
+
const put = at < 0 ? [[row.pos, row.pos + row.inserted.length, tx.actor.kind, tx.actor.id, tx.createdAt]]
|
|
32
|
+
: kept.splice(at, 1)[0].runs.map(run => [run[0] + row.pos, run[1] + row.pos, ...run.slice(2)]);
|
|
33
|
+
runs = coalesce([...before, ...put, ...after]);
|
|
24
34
|
}
|
|
25
|
-
|
|
26
|
-
runs = coalesce([...before, [row.pos, row.pos + row.inserted.length, tx.actor.kind, tx.actor.id, tx.createdAt], ...after]);
|
|
35
|
+
removed.set(tx.id, gone);
|
|
27
36
|
}
|
|
28
37
|
return {format: 'rapier-authorship/1', sha256: sha256(text), runs};
|
|
29
38
|
}
|
package/dist/kit/ledger/data.mjs
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
// Only plain JSON data enters the public format. Accessors and toJSON are never executed.
|
|
3
3
|
export function fault(message) { return Object.assign(new Error('Ledger: ' + message), {code: 'ledger_invalid'}); }
|
|
4
|
-
|
|
4
|
+
// A plain object's prototype is null or Object's own. Both lookups read descriptors, so no getter runs.
|
|
5
|
+
export const plainPrototype = proto => proto === null || Object.getPrototypeOf(proto) === null &&
|
|
6
|
+
Object.getOwnPropertyDescriptor(Object.getOwnPropertyDescriptor(proto, 'constructor')?.value || {}, 'name')?.value === 'Object';
|
|
7
|
+
export function fields(value, depth, active, sorted = true) {
|
|
5
8
|
if (!value || typeof value !== 'object' || depth > 64 || active.has(value)) throw fault('not plain JSON data');
|
|
6
|
-
const array = Array.isArray(value)
|
|
7
|
-
if (!array &&
|
|
9
|
+
const array = Array.isArray(value);
|
|
10
|
+
if (!array && !plainPrototype(Object.getPrototypeOf(value))) throw fault('not a plain object');
|
|
8
11
|
const descriptors = Object.getOwnPropertyDescriptors(value), keys = Reflect.ownKeys(descriptors);
|
|
9
12
|
for (const key of keys) {
|
|
10
13
|
if (array && key === 'length') continue;
|
|
@@ -14,7 +17,7 @@ function fields(value, depth, active, sorted = true) {
|
|
|
14
17
|
if (array && (keys.length !== value.length + 1 || keys.some(k => k !== 'length' && (!/^(0|[1-9]\d*)$/.test(k) || +k >= value.length)))) throw fault('sparse array');
|
|
15
18
|
return {array, descriptors, keys: array ? keys.filter(key => key !== 'length') : sorted ? keys.sort() : keys};
|
|
16
19
|
}
|
|
17
|
-
function scalar(value) {
|
|
20
|
+
export function scalar(value) {
|
|
18
21
|
if (value === null || typeof value === 'boolean') return true;
|
|
19
22
|
if (typeof value === 'string') { if (/[\uD800-\uDFFF]/u.test(value)) throw fault('unpaired surrogate'); return true; }
|
|
20
23
|
return typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0);
|
|
@@ -27,12 +27,34 @@ function* checkedRecords(ledger, prepared) {
|
|
|
27
27
|
}
|
|
28
28
|
if (ledger.head.revision !== revision || ledger.head.root !== root) throw fault('history does not describe this document');
|
|
29
29
|
}
|
|
30
|
+
// A ledger object read again is proved once: a boot reads one stored history through several records. The proof holds only
|
|
31
|
+
// while that object still carries the exact proved data (its start text compared whole, the rest as canonical JSON), so a
|
|
32
|
+
// changed byte is proved again, and refused. The proof lives as long as the object does.
|
|
33
|
+
const proofs = new WeakMap();
|
|
30
34
|
function readLedgerState(input, expectedText, expectedMetadata) {
|
|
31
35
|
const ledger = plainData(input);
|
|
36
|
+
// The identifier rapier-ledger/2 is unchanged and is not backward compatible. This reader
|
|
37
|
+
// admits an empty physical splice whose authored rows name the same replacement. A reader
|
|
38
|
+
// from before that rule rejects the chain. Mixed-version sync throws notes_history_unavailable
|
|
39
|
+
// and leaves the older store unchanged; that refusal is not a held or caughtUp result.
|
|
32
40
|
if (!exactKeys(ledger, ['format','documentAuthority','start','records','head','complete','sha256']) || ledger.format !== 'rapier-ledger/2' ||
|
|
33
41
|
typeof ledger.documentAuthority !== 'string' || !ledger.documentAuthority || ledger.documentAuthority.length > 256 ||
|
|
34
42
|
typeof ledger.complete !== 'boolean' || !Array.isArray(ledger.records) ||
|
|
35
43
|
!exactKeys(ledger.start, ['text','revision','root','sha256','metadata']) || !exactKeys(ledger.head, ['revision','root','sha256','metadata'])) throw fault('unsupported or invalid envelope');
|
|
44
|
+
const shell = canonicalJSON({...ledger, start: {...ledger.start, text: ''}});
|
|
45
|
+
let proof = typeof input === 'object' ? proofs.get(input) : undefined;
|
|
46
|
+
if (!proof || proof.shell !== shell || proof.startText !== ledger.start.text) {
|
|
47
|
+
proof = {shell, startText: ledger.start.text, ...proveLedger(ledger)};
|
|
48
|
+
if (typeof input === 'object') proofs.set(input, proof);
|
|
49
|
+
}
|
|
50
|
+
proofs.set(ledger, proof);
|
|
51
|
+
const {text, metadata, root, revision, prepared, source} = proof;
|
|
52
|
+
if (expectedMetadata !== undefined && (!_rapierValidMetadata(expectedMetadata) || canonicalJSON(metadata) !== canonicalJSON(expectedMetadata)))
|
|
53
|
+
throw fault('metadata does not describe this document');
|
|
54
|
+
if (expectedText !== undefined && text !== expectedText) throw fault('history does not describe this document');
|
|
55
|
+
return {value: {ledger, text, metadata: {...metadata}, root, revision, complete: ledger.complete}, prepared, source};
|
|
56
|
+
}
|
|
57
|
+
function proveLedger(ledger) {
|
|
36
58
|
const {sha256: claimed, ...payload} = ledger;
|
|
37
59
|
if (claimed !== digest(payload)) throw fault('envelope checksum differs');
|
|
38
60
|
let text = documentText(ledger.start.text), root = ledger.start.root, revision = ledger.start.revision;
|
|
@@ -55,12 +77,9 @@ function readLedgerState(input, expectedText, expectedMetadata) {
|
|
|
55
77
|
try { authoredHistory(ledger.start.text, ledger.records); } catch (_) { throw fault('authored origin differs'); }
|
|
56
78
|
}
|
|
57
79
|
const metadata = prepared.replayMetadata(ledger.start.metadata);
|
|
58
|
-
if (!metadata || canonicalJSON(metadata) !== canonicalJSON(ledger.head.metadata)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text) ||
|
|
62
|
-
(expectedText !== undefined && text !== expectedText)) throw fault('history does not describe this document');
|
|
63
|
-
return {value: {ledger, text, metadata, root, revision, complete: ledger.complete}, prepared, source};
|
|
80
|
+
if (!metadata || canonicalJSON(metadata) !== canonicalJSON(ledger.head.metadata)) throw fault('metadata does not describe this document');
|
|
81
|
+
if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text)) throw fault('history does not describe this document');
|
|
82
|
+
return {text, metadata, root, revision, prepared, source};
|
|
64
83
|
}
|
|
65
84
|
// A stored copy may hold the head text alone. Its start is that text with every record reversed; readLedger proves it.
|
|
66
85
|
export function ledgerWithStart(input, text) {
|
|
@@ -172,9 +191,7 @@ function finish(producer) {
|
|
|
172
191
|
return step.value;
|
|
173
192
|
}
|
|
174
193
|
export function exportLedger(input) { return finish(produceLedger(input)).ledger; }
|
|
175
|
-
// Both synchronous public export and cooperative recovery use the same producer.
|
|
176
194
|
// Its returned facts were computed while admitting/hashing the exact head, never supplied.
|
|
177
|
-
export function prepareLedger(input) { return finish(produceLedger(input)); }
|
|
178
195
|
export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {}) {
|
|
179
196
|
const producer = produceLedger(input);
|
|
180
197
|
let step;
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
import {_rapierTransformSplices, _rapierValidMetadata, _rapierTransformMetadata, _rapierMetadataDelta, _rapierHistoryEffects, _rapierMetadataState, _rapierPrepareHistoryRecords} from './journal-records.mjs';
|
|
4
4
|
import {projectInverseSource, HistorySource} from './inverse-source.mjs';
|
|
5
5
|
import {textRootParts, rootAfter} from './hash.mjs';
|
|
6
|
-
import {authoredHistory} from './transport.mjs';
|
|
7
|
-
import {plainData} from './data.mjs';
|
|
6
|
+
import {authoredHistory, authoredPlacements} from './transport.mjs';
|
|
7
|
+
import {plainData, canonicalJSON} from './data.mjs';
|
|
8
8
|
|
|
9
9
|
const historyFailure = reason => ({ok: false, reason});
|
|
10
10
|
const historyInteger = value => Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0);
|
|
@@ -104,8 +104,11 @@ function prepareHistory(input) {
|
|
|
104
104
|
!(tx.createdAt === null || historyInteger(tx.createdAt)) ||
|
|
105
105
|
(tx.turnId != null && (typeof tx.turnId !== 'string' || !tx.turnId || tx.turnId.length > 160))) return historyFailure('history_invalid');
|
|
106
106
|
if (tx.baseRevision !== prior) complete = false;
|
|
107
|
+
const coincidentWitness = !splices.length && record.authored && Array.isArray(record.authored.splices) &&
|
|
108
|
+
record.authored.splices.some(row => row.removed.length);
|
|
107
109
|
if (!splices || effect === undefined || (!splices.length &&
|
|
108
|
-
!Object.values(effect || {}).some(pair => pair.before !== pair.after) && !effects.references.get(tx.id)?.length
|
|
110
|
+
!Object.values(effect || {}).some(pair => pair.before !== pair.after) && !effects.references.get(tx.id)?.length &&
|
|
111
|
+
!coincidentWitness)) return historyFailure('history_unavailable');
|
|
109
112
|
const row = {record, splices, metadata: effect};
|
|
110
113
|
rows.push(row); byId.set(tx.id, row); prior = tx.revision;
|
|
111
114
|
}
|
|
@@ -293,10 +296,13 @@ export function historyPlaces(input, blocks) {
|
|
|
293
296
|
if (!slices.has(key)) slices.set(key, source.slice(from, to));
|
|
294
297
|
return slices.get(key);
|
|
295
298
|
};
|
|
299
|
+
// One record's act is read once; the blocks it touched share its fields (a whole-document delete touches every block).
|
|
300
|
+
let act = null;
|
|
296
301
|
for (const [node, afterRange] of touched) {
|
|
297
302
|
const beforeRange = ranges.range(node), previous = {...beforeRange, source: cut(beforeSource, 'b', beforeRange.from, beforeRange.to)},
|
|
298
303
|
after = {...afterRange, source: cut(afterSource, 'a', afterRange.from, afterRange.to)};
|
|
299
|
-
|
|
304
|
+
// A point (where words were deleted) has no words to compare: the act that touched it wrote there.
|
|
305
|
+
if (previous.source !== after.source || afterRange.from === afterRange.to) node.block.acts.push({...(act ||= historyAct(record)), before: previous, after});
|
|
300
306
|
}
|
|
301
307
|
}
|
|
302
308
|
|
|
@@ -334,7 +340,27 @@ export function selectiveUndo(input, actIds, options = {}) {
|
|
|
334
340
|
const metadata = _rapierMetadataDelta(prepared.metadata, afterMetadata), effectChanged = selected.size > 0;
|
|
335
341
|
const sourceEffectChanged = records.some(record => record.splices.length && !prepared.effects.references.get(record.transaction.id)?.length &&
|
|
336
342
|
prepared.effects.active.has(record.transaction.id) !== projectedEffects.active.has(record.transaction.id));
|
|
337
|
-
|
|
343
|
+
// One shared replacement is visible while any of its authors is active. That
|
|
344
|
+
// includes an author stored with no second physical splice, and an inverse
|
|
345
|
+
// whose only effect is to make such an author active again.
|
|
346
|
+
let coverVisibilityChanged = false;
|
|
347
|
+
if (!sourceEffectChanged && selected.size) {
|
|
348
|
+
let origins = null;
|
|
349
|
+
try { origins = authoredPlacements(prepared.initialSource, records); } catch { origins = null; }
|
|
350
|
+
const groups = new Map();
|
|
351
|
+
if (origins) for (const record of records) {
|
|
352
|
+
const tx = record.transaction, origin = origins.get(tx.id);
|
|
353
|
+
if (!origin?.splices.some(row => row.removed.length) || tx.reverts || tx.reapplies ||
|
|
354
|
+
tx.sourceTransactionId || tx.sourceTransactionIds?.length) continue;
|
|
355
|
+
const key = canonicalJSON({basis: origin.basis, splices: origin.splices});
|
|
356
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
357
|
+
groups.get(key).push(tx.id);
|
|
358
|
+
}
|
|
359
|
+
const covered = (effects, ids) => ids.some(id => effects.active.has(id));
|
|
360
|
+
for (const ids of groups.values()) if (ids.length > 1 && covered(prepared.effects, ids) !== covered(projectedEffects, ids))
|
|
361
|
+
coverVisibilityChanged = true;
|
|
362
|
+
}
|
|
363
|
+
if (!sourceEffectChanged && !coverVisibilityChanged) return {ok: true, splices: [], after: prepared.source, actIds: [...requested],
|
|
338
364
|
sourceTransactionIds: [...selected], metadata, afterMetadata, effectChanged, unchanged: !metadata && !effectChanged,
|
|
339
365
|
superseded: [], overlaps: []};
|
|
340
366
|
try {
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Exact source projection shared by canonical history and causal transport.
|
|
3
3
|
// Callers admit and order the records first; this projection retains no state.
|
|
4
4
|
import {_rapierTransformSplices, _rapierHistoryEffects} from './journal-records.mjs';
|
|
5
|
+
import {authoredPlacements} from './authored-origin.mjs';
|
|
6
|
+
import {canonicalJSON} from './data.mjs';
|
|
5
7
|
import {diff} from '../../packages/rapier-diff/index.mjs';
|
|
6
8
|
|
|
7
9
|
const historyFailure = reason => ({ok: false, reason});
|
|
@@ -174,6 +176,24 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
174
176
|
try {
|
|
175
177
|
let pieces = prepared.initialSource ? [{text: prepared.initialSource, owner: null, lineage: []}] : [];
|
|
176
178
|
const all = [], byId = new Map(), byAct = new Map(), processed = [], keptInverseAncestors = new Set();
|
|
179
|
+
const recordsForCover = prepared.records || prepared.rows.map(({record, splices}) => record.splices === splices ? record : {...record, splices});
|
|
180
|
+
const coverGroups = new Map();
|
|
181
|
+
let coverOrigins = null;
|
|
182
|
+
// An empty document is a real causal source. Fresh notes start there, and a
|
|
183
|
+
// shared replacement authored against that source still has to stay covered.
|
|
184
|
+
if (typeof prepared.initialSource === 'string' && recordsForCover.some(record => record.authored?.splices?.some(row => row.removed.length))) {
|
|
185
|
+
try { coverOrigins = authoredPlacements(prepared.initialSource, recordsForCover); } catch { coverOrigins = null; }
|
|
186
|
+
}
|
|
187
|
+
const coverIdsFor = id => {
|
|
188
|
+
const origin = coverOrigins?.get(id);
|
|
189
|
+
if (!origin?.splices.some(row => row.removed.length)) return null;
|
|
190
|
+
const key = canonicalJSON({basis: origin.basis, splices: origin.splices});
|
|
191
|
+
if (!coverGroups.has(key)) coverGroups.set(key, []);
|
|
192
|
+
const ids = coverGroups.get(key);
|
|
193
|
+
if (!ids.includes(id)) ids.push(id);
|
|
194
|
+
return ids;
|
|
195
|
+
};
|
|
196
|
+
const coverHeld = hunk => hunk.coverIds?.length > 1 && hunk.coverIds.some(id => projectedEffects.active.has(id));
|
|
177
197
|
const texts = rows => rows.map(row => row.text || '');
|
|
178
198
|
const textOf = rows => texts(rows).join('');
|
|
179
199
|
const lengthOf = (rows, to = rows.length) => { let size = 0; for (let i = 0; i < to; i++) size += rows[i].text?.length || 0; return size; };
|
|
@@ -234,8 +254,16 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
234
254
|
return next && effects.active.has(next.actId) && !prepared.effects.references.get(next.actId)?.length;
|
|
235
255
|
});
|
|
236
256
|
const reactivate = (actIds, effects) => {
|
|
257
|
+
// Any author of one shared removal or replacement restores that one physical
|
|
258
|
+
// hunk, including an author who stored no splice. A later independent act
|
|
259
|
+
// keeps the bytes it wrote until that act itself is withdrawn.
|
|
260
|
+
const wanted = new Set(actIds);
|
|
261
|
+
for (const id of actIds) for (const hunk of all) {
|
|
262
|
+
if (hunk.reversedHunk || hunk.derived || !hunk.coverIds?.includes(id) || wanted.has(hunk.actId)) continue;
|
|
263
|
+
if (hunk.coverIds.some(cover => effects.active.has(cover))) wanted.add(hunk.actId);
|
|
264
|
+
}
|
|
237
265
|
for (const hunk of all) {
|
|
238
|
-
if (!
|
|
266
|
+
if (!wanted.has(hunk.actId) || hunk.derived || prepared.effects.references.get(hunk.actId)?.length) continue;
|
|
239
267
|
if (keptInverseAncestors.has(hunk.id)) continue;
|
|
240
268
|
if (laterSource(hunk, effects) || pieces.some(piece => piece.text && piece.owner === hunk.id) ||
|
|
241
269
|
(!hunk.inserted && hunk.active)) continue;
|
|
@@ -245,8 +273,9 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
245
273
|
// inverse's marker. Its exact ancestry also preserves later inserted runs.
|
|
246
274
|
const inverse = all.findLast(prior => prior.reversedHunk === hunk.id && pieces.includes(prior.marker));
|
|
247
275
|
if (!inverse) throw Error('reactivation');
|
|
248
|
-
|
|
249
|
-
|
|
276
|
+
const runs = all.filter(prior => prior.actId === inverse.actId && prior.reversedHunk === hunk.id && pieces.includes(prior.marker));
|
|
277
|
+
if (runs.some(prior => laterSource(prior, effects))) continue;
|
|
278
|
+
for (const prior of runs) { restoreHunk(prior); prior.active = false; }
|
|
250
279
|
hunk.active = true; delete hunk.undoneBy;
|
|
251
280
|
continue;
|
|
252
281
|
}
|
|
@@ -291,51 +320,65 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
291
320
|
if (references.size) {
|
|
292
321
|
// Exact inverse rows restore the previous run identities. Retain the inverse's
|
|
293
322
|
// own transient marker too, so reversing it restores that same ancestry again.
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
323
|
+
const invert = targets => {
|
|
324
|
+
const restored = pieces.slice(), inverses = [], priorSplices = options.keepHunk ? [] : null;
|
|
325
|
+
for (let index = targets.length - 1; index >= 0; index--) {
|
|
326
|
+
const target = targets[index], first = restored.indexOf(target.marker);
|
|
327
|
+
if (first < 0) return null;
|
|
328
|
+
// Each run of the target's own pieces inverts alone: a later author's pieces between
|
|
329
|
+
// its runs stay in place, outside every inverse, so reversing it cannot move them.
|
|
330
|
+
const runs = [];
|
|
331
|
+
for (let at = first; at < restored.length; at++) if (owns(restored[at], target))
|
|
332
|
+
if (runs.at(-1)?.[1] === at) runs.at(-1)[1]++; else runs.push([at, at + 1]);
|
|
333
|
+
for (let run = runs.length - 1; run >= 0; run--) {
|
|
334
|
+
const [from, to] = runs[run], before = restored.slice(from, to), pos = lengthOf(restored, from);
|
|
335
|
+
const replacement = run ? [] : target.before;
|
|
336
|
+
const id = tx.id + '#inverse:' + index + (run ? '.' + run : ''), lineage = new Set([id]);
|
|
337
|
+
for (const piece of before) for (const parent of piece.lineage || piece.mark?.lineage || []) lineage.add(parent);
|
|
338
|
+
const hunk = {id, actId: tx.id, removed: textOf(before), inserted: textOf(replacement), before, reversedHunk: target.id,
|
|
339
|
+
chosen: selected.has(tx.id), lineage: [...lineage], supersededBy: new Set(), active: true,
|
|
340
|
+
restoredMarkers: new Set(replacement.filter(piece => !piece.text))};
|
|
341
|
+
if (hunk.chosen && options.keepHunk) {
|
|
342
|
+
const spliceIndex = splices.findIndex(row => row.pos <= pos && row.pos + row.removed.length >= pos);
|
|
343
|
+
const keep = options.keepHunk({actId: tx.id, spliceIndex: Math.max(0, spliceIndex), hunkIndex: index,
|
|
344
|
+
splice: {pos, removed: hunk.removed, inserted: hunk.inserted}, beforeSource: textOf(restored), actSource, priorSplices});
|
|
345
|
+
if (typeof keep !== 'boolean') throw Error('hunk_selection');
|
|
346
|
+
hunk.chosen = !keep;
|
|
347
|
+
}
|
|
348
|
+
hunk.kept = selected.has(tx.id) && !hunk.chosen;
|
|
349
|
+
// A semantic owner retaining this inverse also retains the exact ancestors
|
|
350
|
+
// it restored; effect reactivation must not change them behind that owner.
|
|
351
|
+
if (hunk.kept) for (let prior = target; prior; prior = byId.get(prior.reversedHunk)) keptInverseAncestors.add(prior.id);
|
|
352
|
+
hunk.marker = {mark: hunk};
|
|
353
|
+
const insertion = replacement.map(piece => piece.text ? {...piece,
|
|
354
|
+
lineage: [...new Set([...(piece.lineage || []), id])],
|
|
355
|
+
restoredBy: [...(piece.restoredBy || []), id]} : piece);
|
|
356
|
+
restored.splice(from, to - from, hunk.marker, ...insertion);
|
|
357
|
+
inverses.push(hunk); priorSplices?.push({pos, removed: hunk.removed, inserted: hunk.inserted});
|
|
358
|
+
}
|
|
319
359
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (
|
|
332
|
-
|
|
333
|
-
|
|
360
|
+
return replays(pieces, restored, splices) ? {restored, inverses} : null;
|
|
361
|
+
};
|
|
362
|
+
// An inverse taken while a later edit had replaced some of the act's hunks restored only
|
|
363
|
+
// the rest. Those replaced hunks survive it: no later projection treats them as undone.
|
|
364
|
+
// Recorded splices decide. An author who stored no splice still inverts the one
|
|
365
|
+
// shared hunk; an empty inverse does not match that text change and leaves it.
|
|
366
|
+
const direct = all.filter(hunk => hunk.active && references.has(hunk.actId));
|
|
367
|
+
const targets = direct.length ? direct : all.filter(hunk => hunk.active && !hunk.reversedHunk && !hunk.derived &&
|
|
368
|
+
hunk.coverIds?.some(id => references.has(id)));
|
|
369
|
+
const free = targets.filter(hunk => ![...hunk.supersededBy].some(id => byId.get(id)?.active));
|
|
370
|
+
let found = targets.length ? invert(targets) : null, survivors = [];
|
|
371
|
+
if (!found && free.length && free.length < targets.length && (found = invert(free)))
|
|
372
|
+
survivors = targets.filter(hunk => !free.includes(hunk));
|
|
373
|
+
if (found) {
|
|
374
|
+
pieces = found.restored;
|
|
375
|
+
for (const target of targets) if (survivors.includes(target)) target.survived = true;
|
|
376
|
+
else { target.active = false; target.undoneBy = tx.id; }
|
|
334
377
|
for (const piece of pieces) if (piece.mark) {
|
|
335
378
|
piece.mark.active = true; delete piece.mark.undoneBy;
|
|
336
379
|
}
|
|
337
|
-
for (const hunk of inverses) { all.push(hunk); byId.set(hunk.id, hunk); }
|
|
338
|
-
byAct.set(tx.id, inverses);
|
|
380
|
+
for (const hunk of found.inverses) { all.push(hunk); byId.set(hunk.id, hunk); }
|
|
381
|
+
byAct.set(tx.id, found.inverses);
|
|
339
382
|
continue;
|
|
340
383
|
}
|
|
341
384
|
}
|
|
@@ -354,6 +397,7 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
354
397
|
for (const [hunk, active, undoneBy] of priorStates) { hunk.active = active; hunk.undoneBy = undoneBy; }
|
|
355
398
|
}
|
|
356
399
|
}
|
|
400
|
+
if (!references.size) coverIdsFor(tx.id);
|
|
357
401
|
const priorSplices = options.keepHunk ? [] : null;
|
|
358
402
|
// An admitted codec finish replaces the complete embedded definition/reference.
|
|
359
403
|
// Keep its inserted run whole, including byte-identical prefixes of a re-edit;
|
|
@@ -395,7 +439,8 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
395
439
|
const lineage = new Set([id]);
|
|
396
440
|
for (const piece of removed) for (const parent of piece.lineage || piece.mark?.lineage || []) lineage.add(parent);
|
|
397
441
|
const hunk = {id, actId: tx.id, removed: row.removed, inserted: row.inserted, before: removed, derived,
|
|
398
|
-
chosen, kept: selected.has(tx.id) && !chosen, lineage: [...lineage], supersededBy: new Set(), active: true
|
|
442
|
+
chosen, kept: selected.has(tx.id) && !chosen, lineage: [...lineage], supersededBy: new Set(), active: true,
|
|
443
|
+
...(coverIdsFor(tx.id) ? {coverIds: coverIdsFor(tx.id)} : {})};
|
|
399
444
|
hunk.marker = {mark: hunk};
|
|
400
445
|
all.push(hunk); byId.set(id, hunk);
|
|
401
446
|
if (!byAct.has(tx.id)) byAct.set(tx.id, []);
|
|
@@ -414,9 +459,22 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
414
459
|
let offset = 0;
|
|
415
460
|
for (const piece of pieces) if (piece.text) { origins.set(piece, offset); offset += piece.text.length; }
|
|
416
461
|
const superseded = [];
|
|
462
|
+
// Survival means an inverse left this hunk in place because a later independent
|
|
463
|
+
// edit still covered it. Once that edit is gone, deactivating its last owner through
|
|
464
|
+
// any inverse path withdraws every exposed hunk. Undoing only the later
|
|
465
|
+
// edit reveals the survived insertion and does not discard it.
|
|
466
|
+
for (const hunk of all) if (hunk.survived) {
|
|
467
|
+
const covered = [...hunk.supersededBy].some(id => {
|
|
468
|
+
const next = byId.get(id);
|
|
469
|
+
return next?.active && projectedEffects.active.has(next.actId) && !prepared.effects.references.get(next.actId)?.length;
|
|
470
|
+
});
|
|
471
|
+
const owners = hunk.coverIds?.length ? hunk.coverIds : [hunk.actId];
|
|
472
|
+
if (!covered && owners.some(id => prepared.effects.active.has(id)) && !owners.some(id => projectedEffects.active.has(id))) hunk.survived = false;
|
|
473
|
+
}
|
|
417
474
|
for (let index = all.length - 1; index >= 0; index--) {
|
|
418
475
|
const hunk = all[index];
|
|
419
|
-
|
|
476
|
+
if (coverHeld(hunk)) continue;
|
|
477
|
+
const inheritedInactive = !hunk.derived && !hunk.kept && !hunk.survived && !keptInverseAncestors.has(hunk.id) && !projectedEffects.active.has(hunk.actId) &&
|
|
420
478
|
!prepared.effects.references.get(hunk.actId)?.length;
|
|
421
479
|
if (!hunk.chosen && !inheritedInactive) continue;
|
|
422
480
|
hunk.active = prepared.effects.active.has(hunk.actId);
|
|
@@ -432,9 +490,13 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
|
|
|
432
490
|
restoreHunk(hunk);
|
|
433
491
|
}
|
|
434
492
|
// A hidden withdrawal can have no splice of its own. Reversing that withdrawal
|
|
435
|
-
// reactivates the
|
|
436
|
-
|
|
437
|
-
|
|
493
|
+
// reactivates the shared hunk through the inverse that still carries it.
|
|
494
|
+
// Withdrawing a later act reveals that same hunk while any original author remains.
|
|
495
|
+
const reactivated = new Set(records.filter(record => !prepared.effects.active.has(record.transaction.id) &&
|
|
496
|
+
projectedEffects.active.has(record.transaction.id)).map(record => record.transaction.id));
|
|
497
|
+
for (const hunk of all) if (!hunk.reversedHunk && !hunk.derived && hunk.coverIds?.length > 1 &&
|
|
498
|
+
hunk.coverIds.some(id => projectedEffects.active.has(id))) reactivated.add(hunk.actId);
|
|
499
|
+
reactivate(reactivated, projectedEffects);
|
|
438
500
|
const after = textOf(pieces), changes = [];
|
|
439
501
|
let cursor = 0, inserted = '';
|
|
440
502
|
for (const piece of pieces) {
|