rapier-html 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.
@@ -0,0 +1,72 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Space-free scripts (Thai, Lao, Myanmar, Khmer, kana, Han) have no word separator; the platform dictionary finds words.
3
+ export const SPACELESS = /[\u0e00-\u0eff\u1000-\u109f\u1780-\u17ff\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/;
4
+ export const words = (segmenter, text) => [...text.matchAll(/[\p{L}\p{N}\p{M}_]+/gu)].flatMap(word => SPACELESS.test(word[0])
5
+ ? [...segmenter().segment(word[0])].map(s => [word.index + s.index, word.index + s.index + s.segment.length])
6
+ : [[word.index, word.index + word[0].length]]);
7
+ export const wordSegmenter = () => { let segmenter; return () => segmenter ||= new Intl.Segmenter('en', {granularity: 'word'}); };
8
+
9
+ // Segment only requested non-ASCII boundaries; observations never share a global document cache.
10
+ export function boundaries() {
11
+ const segments = Object.create(null);
12
+ let segmenter, last = [];
13
+ return function safe(text, at) {
14
+ if (!at || at === text.length) return true;
15
+ const a = text.charCodeAt(at - 1), b = text.charCodeAt(at);
16
+ if ((a & 0xfc00) === 0xd800 && (b & 0xfc00) === 0xdc00 || a === 13 && b === 10) return false;
17
+ if (a < 0x300 && b < 0x300) return true;
18
+ // Retreats probe one long cluster many times; answer inside the last cluster without the segmenter.
19
+ if (last[0] === text && at > last[1] && at < last[2]) return false;
20
+ const found = (segments[text] ||= (segmenter ||= new Intl.Segmenter('en', {granularity: 'grapheme'})).segment(text)).containing(at);
21
+ last = [text, found.index, found.index + found.segment.length];
22
+ return found.index === at;
23
+ }
24
+ }
25
+
26
+ // Letters are code points: an astral letter is one letter, never two lone surrogates.
27
+ const pair = (text, at) => (text.charCodeAt(at - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(at) & 0xfc00) === 0xdc00;
28
+ export const prev = (text, at) => text.slice(Math.max(0, at - 1 - pair(text, at - 1)), at), next = (text, at) => text.slice(at, at + 1 + pair(text, at + 1));
29
+ export const letter = c => c && /[\p{L}\p{M}\p{N}_]/u.test(c), within = (text, at) => letter(prev(text, at)) && letter(next(text, at));
30
+
31
+ // Tokens at a level: 0 parts, 1 lines, 2 words, spaces and marks, 3 characters.
32
+ export function tokenizer(c) {
33
+ const {spend, safe, segmenter, raw, aliases} = c;
34
+ return function tokens(text, start, end, level, parts) {
35
+ // Each source boundary is stored once, alongside comparison keys and optional owners.
36
+ const keys = [], at = [start], owners = [];
37
+ const out = [keys, at, owners];
38
+ const add = (a, b, part, value) => {
39
+ if (b <= a) return;
40
+ if (!part && owners[keys.length - 1]?.name && !/\S/.test(text.slice(a, b))) at[at.length - 1] = b;
41
+ else {
42
+ at.push(b); if (part) owners[keys.length] = part;
43
+ const key = part?.name ? '\0' + part.kind + '\0' + (aliases?.get(part)?.name || part.name) : value ?? text.slice(a, b);
44
+ keys.push(level === 1 && !raw ? key.trimEnd() : key);
45
+ }
46
+ };
47
+ if (parts?.length && level === 0) {
48
+ let at = start;
49
+ for (const part of parts) {
50
+ if (!spend()) return null;
51
+ if (part.start < at || part.end <= part.start || part.end > end) continue;
52
+ if (at < part.start) add(at, part.start);
53
+ add(part.start, part.end, part); at = part.end;
54
+ }
55
+ add(at, end); return out;
56
+ }
57
+ const pattern = level === 1 ? raw ? /[^\n]*\n|[^\n]+$/g : /[^\n]*\n(?:\r?\n)*|[^\n]+$/g : level === 2 ? /[\p{L}\p{M}\p{N}_]+|[^\S\r\n]+|\r\n|[^]/gu : /[^]/gu;
58
+ pattern.lastIndex = start;
59
+ let match;
60
+ while (pattern.lastIndex < end && (match = pattern.exec(text))) {
61
+ if (!spend()) return null;
62
+ const a = match.index, b = Math.min(end, pattern.lastIndex);
63
+ if (keys.length && !safe(text, a)) {
64
+ at[at.length - 1] = b; keys[keys.length - 1] = text.slice(at[at.length - 2], b);
65
+ } else if (level === 2 && b - a > 1 && SPACELESS.test(match[0])) {
66
+ for (const [c, d] of words(segmenter, text.slice(a, b))) { if (!spend()) return null; add(a + c, a + d); }
67
+ } else add(a, b, undefined, b === pattern.lastIndex ? match[0] : undefined);
68
+ if (b === end) break;
69
+ }
70
+ return out;
71
+ };
72
+ }
@@ -0,0 +1,219 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import {words, letter, within, prev, next} from './tokens.mjs';
3
+
4
+ // Exact source records for a range: lines, then words, then characters, or structural parts when supplied.
5
+ export function walker(c) {
6
+ const {before, after, spend, safe, budget, raw, chars, code, segmenter, tokens, matches} = c;
7
+ function record(a0, a1, b0, b1, unit, part, coarse = false) {
8
+ if (a1 - a0 === b1 - b0 && before.slice(a0, a1) === after.slice(b0, b1)) return;
9
+ // Internal records: before, after, coarse, concern, transported fragments.
10
+ c.result.push([[a0, a1], [b0, b1], coarse, [a0 === a1 ? 2 : b0 === b1 ? 0 : 1, part?.kind || unit, part?.name]]);
11
+ }
12
+
13
+ // A line deletion or insertion holding whole named parts and only blank text besides is one record per part,
14
+ // cut at line starts, so each keeps its own name (and its callers' breaks).
15
+ function name(from, ownersA, ownersB) {
16
+ for (let r = from; r < c.result.length; r++) {
17
+ const [[a0, a1], [b0, b1]] = c.result[r], side = a0 === a1 ? 1 : b0 === b1 ? 0 : -1;
18
+ if (side < 0) continue;
19
+ const [s0, s1] = c.result[r][side], text = side ? after : before;
20
+ const parts = (side ? ownersB : ownersA).filter(part => part?.name && part.start >= s0 && part.end <= s1);
21
+ if (!parts.length || !spend(s1 - s0)) continue;
22
+ let blank = text.slice(s0, parts[0].start);
23
+ for (let k = 1; k <= parts.length; k++) blank += text.slice(parts[k - 1].end, parts[k]?.start ?? s1);
24
+ if (/\S/.test(blank)) continue;
25
+ const rows = parts.map((part, k) => {
26
+ const start = k ? Math.max(parts[k - 1].end, text.lastIndexOf('\n', part.start - 1) + 1) : s0;
27
+ return [start, part];
28
+ });
29
+ c.result.splice(r, 1, ...rows.map(([start, part], k) => {
30
+ const end = rows[k + 1]?.[0] ?? s1, here = [side ? [a0, a0] : [start, end], side ? [start, end] : [b0, b0]];
31
+ return [here[0], here[1], false, [side ? 2 : 0, part.kind, part.name]];
32
+ }));
33
+ r += rows.length - 1;
34
+ }
35
+ }
36
+
37
+ // A search step scans at most one fixed-size source chunk.
38
+ function common(a, b, end, step) {
39
+ let n = 0;
40
+ while (n < end && spend()) {
41
+ const stop = Math.min(end, n + 256);
42
+ while (n < stop && before.charCodeAt(a) === after.charCodeAt(b)) { n++; a += step; b += step; }
43
+ if (n < stop) break;
44
+ }
45
+ return n;
46
+ }
47
+
48
+ function walk(a0, a1, b0, b1, level, ap, bp, context) {
49
+ if (a1 - a0 === b1 - b0 && before.slice(a0, a1) === after.slice(b0, b1)) return;
50
+ if (!spend()) { record(a0, a1, b0, b1, 'block', context, true); return; }
51
+ const structural = level <= 0 && level > -128 && (ap?.length || bp?.length);
52
+ if (!structural && raw !== 'word') {
53
+ let n = common(a0, b0, Math.min(a1 - a0, b1 - b0), 1);
54
+ while (n && (!safe(before, a0 + n) || !safe(after, b0 + n) || !raw && level < 3 && (within(before, a0 + n) || within(after, b0 + n)))) {
55
+ if (!spend()) { n = 0; break; } n--;
56
+ }
57
+ if (level === 1 && n) {
58
+ const next = Math.max(0, before.lastIndexOf('\n', a0 + n - 1) + 1 - a0);
59
+ spend(Math.ceil((n - next) / 256)); n = next;
60
+ }
61
+ a0 += n; b0 += n;
62
+ n = common(a1 - 1, b1 - 1, Math.min(a1 - a0, b1 - b0), -1);
63
+ while (n && (!safe(before, a1 - n) || !safe(after, b1 - n) || !raw && level < 3 && (within(before, a1 - n) || within(after, b1 - n)))) {
64
+ if (!spend()) { n = 0; break; } n--;
65
+ }
66
+ if (level === 1) while (n && (before[a1 - n - 1] !== '\n' || after[b1 - n - 1] !== '\n' || before[a1 - n] === '\n' || after[b1 - n] === '\n')) { if (!spend()) { n = 0; break; } n--; }
67
+ a1 -= n; b1 -= n;
68
+ if (budget.left < 0) { record(a0, a1, b0, b1, 'block', context, true); return; }
69
+ }
70
+ let unit = level >= 3 ? 'char' : level === 2 ? 'word' : 'line';
71
+ if (!raw && level === 2 && a1 - a0 + b1 - b0 <= 4 && [...before.slice(a0, a1), ...after.slice(b0, b1)].length <= 2 &&
72
+ letter(prev(before, a0) || prev(after, b0)) && letter(next(before, a1) || next(after, b1))) unit = 'char';
73
+ if (!structural && (a0 === a1 || b0 === b1)) { record(a0, a1, b0, b1, unit, context); return; }
74
+ const left = tokens(before, a0, a1, structural ? 0 : Math.max(1, level), ap);
75
+ const right = tokens(after, b0, b1, structural ? 0 : Math.max(1, level), bp);
76
+ if (!left || !right) { record(a0, a1, b0, b1, unit, context, true); return; }
77
+ const [a, x, ownersA] = left, [b, y, ownersB] = right, pairs = matches(a, b);
78
+ let ai = 0, bi = 0;
79
+ for (let i = 0; i <= a.length; i++) {
80
+ const j = i === a.length ? b.length : pairs[i];
81
+ if (j === undefined) continue;
82
+ const x0 = x[ai], x1 = x[i], y0 = y[bi], y1 = y[j];
83
+ if (ai < i || bi < j) {
84
+ if (budget.left < 0 || pairs.coarse?.has(ai)) record(x0, x1, y0, y1, unit, context, true);
85
+ else if (structural && (ai === i || bi === j)) {
86
+ for (let k = ai; k < i; k++) record(x[k], x[k + 1], y0, y0, unit, ownersA[k] || context);
87
+ for (let k = bi; k < j; k++) record(x1, x1, y[k], y[k + 1], unit, ownersB[k] || context);
88
+ } else if (structural) {
89
+ const p = ownersA[ai], q = ownersB[bi];
90
+ if (i - ai === 1 && j - bi === 1 && p && q && p.kind === q.kind) walk(x0, x1, y0, y1, level - 1, p.children, q.children, q);
91
+ else {
92
+ const from = c.result.length;
93
+ walk(x0, x1, y0, y1, 1, null, null, context);
94
+ name(from, ownersA.slice(ai, i), ownersB.slice(bi, j));
95
+ }
96
+ } else if (level < 2 && raw !== 'line') lines(x.slice(ai, i + 1), y.slice(bi, j + 1), context);
97
+ else if (level === 2 && chars && !raw && i - ai <= 1 && j - bi <= 1 && x1 - x0 + y1 - y0 < 100) {
98
+ // A changed token is replaced whole unless the caller asks for the characters inside it.
99
+ walk(x0, x1, y0, y1, 3, null, null, context);
100
+ } else record(x0, x1, y0, y1, unit, context);
101
+ }
102
+ if (i < a.length) {
103
+ const p = ownersA[i], q = ownersB[j];
104
+ if ((p || q || level <= 1 && !raw) && before.slice(x[i], x[i + 1]) !== after.slice(y[j], y[j + 1]))
105
+ walk(x[i], x[i + 1], y[j], y[j + 1], p || q ? level - 1 : 3, p?.children, q?.children, q || p || context);
106
+ }
107
+ ai = i + 1; bi = j + 1;
108
+ }
109
+ }
110
+
111
+ // Unequal lines pair in order by shared words. A line sharing under a quarter of its words with every candidate is
112
+ // replaced whole: an inserted line never borrows a neighbour's words, and a rewrite never reads as stray fragments.
113
+ function lines(x, y, context) {
114
+ const n = x.length - 1, m = y.length - 1;
115
+ if (n * m > 4096 || !spend(n * m + x[n] - x[0] + y[m] - y[0])) return row(x[0], x[n], y[0], y[m], context);
116
+ // A renamed name's sites count as shared words.
117
+ if (!c.renames) {
118
+ c.renames = [[], []];
119
+ for (const [p, old] of c.aliases || []) if (before.slice(...old.nameAt) !== after.slice(...p.nameAt)) {
120
+ c.renames[0].push(old.nameAt, ...c.la.filter(l => l.key === old.key).map(l => l.at));
121
+ c.renames[1].push(p.nameAt, ...c.lb.filter(l => l.key === p.key).map(l => l.at));
122
+ }
123
+ }
124
+ const weight = (side, a, b) => {
125
+ const counts = new Map(), text = side ? after : before;
126
+ for (const [c, d] of words(segmenter, text.slice(a, b))) counts.set(text.slice(a + c, a + d), (counts.get(text.slice(a + c, a + d)) || 0) + d - c);
127
+ return [counts, [...counts.values()].reduce((n, v) => n + v, 0), c.renames[side].reduce((n, at) => n + Math.max(0, Math.min(b, at[1]) - Math.max(a, at[0])), 0)];
128
+ };
129
+ const left = x.slice(0, -1).map((at, k) => weight(0, at, x[k + 1])), right = y.slice(0, -1).map((at, k) => weight(1, at, y[k + 1]));
130
+ const best = Array.from({length: n + 1}, () => new Float64Array(m + 1)), pairs = [];
131
+ for (let k = 1; k <= n; k++) for (let l = 1; l <= m; l++) {
132
+ const [a, sa, ra] = left[k - 1], [b, sb, rb] = right[l - 1];
133
+ let shared = sa || sb ? Math.max(ra, rb) : 0.5;
134
+ for (const [key, v] of a) shared += Math.min(v, b.get(key) || 0);
135
+ best[k][l] = Math.max(best[k - 1][l], best[k][l - 1], shared * 4 > Math.max(sa, sb) && best[k - 1][l - 1] + shared);
136
+ }
137
+ for (let k = n, l = m; k && l;) {
138
+ if (best[k][l] === best[k - 1][l]) k--;
139
+ else if (best[k][l] === best[k][l - 1]) l--;
140
+ else pairs.unshift([--k, --l]);
141
+ }
142
+ let k = 0, l = 0;
143
+ for (const [p, q] of [...pairs, [n, m]]) {
144
+ // A merge compares one line against one line by character, whatever they share.
145
+ if (raw === 'char' && p - k === 1 && q - l === 1) row(x[k], x[p], y[l], y[q], context);
146
+ else if (k < p || l < q) record(x[k], x[p], y[l], y[q], 'line', context);
147
+ if (p < n) row(x[p], x[p + 1], y[q], y[q + 1], context);
148
+ k = p + 1; l = q + 1;
149
+ }
150
+ }
151
+
152
+ // A table row with the same number of cells on both sides changes cell by cell; a character diff stays in its line.
153
+ function row(a0, a1, b0, b1, context) {
154
+ if (raw === 'char') return walk(a0, a1, b0, b1, 3, null, null, context);
155
+ const cells = (text, a, b) => /^[ \t]*\|/.test(text.slice(a, b)) && [...text.slice(a, b).matchAll(/(?<!\\)\|/g)].map(m => a + m.index);
156
+ const p = !code && cells(before, a0, a1), q = p && cells(after, b0, b1);
157
+ if (!q || p.length !== q.length || !spend(p.length)) return walk(a0, a1, b0, b1, 2, null, null, context);
158
+ for (let k = 0; k <= p.length; k++) walk(k ? p[k - 1] + 1 : a0, p[k] ?? a1, k ? q[k - 1] + 1 : b0, q[k] ?? b1, 2, null, null, context);
159
+ }
160
+
161
+ // Surviving source pieces keep the occurrence witnessed by the host.
162
+ function operations(rows) {
163
+ if (!Array.isArray(rows)) return null;
164
+ const pieces = [[0, before.length]];
165
+ let length = before.length, steps = 1e7;
166
+ const verify = n => {
167
+ if ((steps -= n) >= 0) return true;
168
+ budget.left = -1; return false;
169
+ };
170
+ const split = at => {
171
+ let position = 0;
172
+ for (let i = 0; i < pieces.length; i++) {
173
+ const [start, end, text] = pieces[i], size = end - start;
174
+ if (position === at) return i;
175
+ if (position + size > at) {
176
+ const n = at - position;
177
+ pieces.splice(i, 1, [start, start + n, text], [start + n, end, text]);
178
+ return i + 1;
179
+ }
180
+ position += size;
181
+ }
182
+ return pieces.length;
183
+ };
184
+ for (const row of rows) {
185
+ if (!row || !Number.isSafeInteger(row.pos) || typeof row.removed !== 'string' || typeof row.inserted !== 'string' ||
186
+ row.pos < 0 || row.pos + row.removed.length > length || !verify(pieces.length * 2 + 1)) return null;
187
+ const left = split(row.pos), right = split(row.pos + row.removed.length);
188
+ let at = 0;
189
+ for (let i = left; i < right; i++) {
190
+ const [start, end, text = before] = pieces[i];
191
+ if (!row.removed.startsWith(text.slice(start, end), at)) return null;
192
+ at += end - start;
193
+ }
194
+ if (row.removed === row.inserted) continue;
195
+ pieces.splice(left, right - left, [0, row.inserted.length, row.inserted]);
196
+ length += row.inserted.length - row.removed.length;
197
+ }
198
+ if (length !== after.length) return null;
199
+ const spans = [];
200
+ let a = 0, b = 0, at = 0;
201
+ for (const [start, end, text] of pieces) {
202
+ const size = end - start;
203
+ if (!verify(1) || !after.startsWith((text ?? before).slice(start, end), at)) return null;
204
+ if (text === undefined) {
205
+ let x = start, y = at, tail = end;
206
+ while (x < tail && (!safe(before, x) || !safe(after, y))) { x++; y++; if (!verify(1)) return null; }
207
+ while (tail > x && (!safe(before, tail) || !safe(after, at + tail - start))) { tail--; if (!verify(1)) return null; }
208
+ if (tail > x) {
209
+ if (a !== x || b !== y) spans.push([a, x, b, y]);
210
+ a = tail; b = at + tail - start;
211
+ }
212
+ }
213
+ at += size;
214
+ }
215
+ if (a !== before.length || b !== after.length) spans.push([a, before.length, b, after.length]);
216
+ return spans;
217
+ }
218
+ return {record, name, walk, operations};
219
+ }
@@ -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
- (expectedMetadata !== undefined && (!_rapierValidMetadata(expectedMetadata) || canonicalJSON(metadata) !== canonicalJSON(expectedMetadata))))
60
- throw fault('metadata does not describe this document');
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)) return historyFailure('history_unavailable');
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
- if (previous.source !== after.source) node.block.acts.push({...historyAct(record), before: previous, after});
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
- if (!sourceEffectChanged) return {ok: true, splices: [], after: prepared.source, actIds: [...requested],
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 {