linked-rolls 0.73.0 → 0.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,569 @@
1
+ /**
2
+ * The edits of a version, proposed from what it inserts and deletes:
3
+ * which of its changes belong together, and what kind of change each
4
+ * group is. Why a change was made is left to the editor.
5
+ */
6
+ import { v5 } from "uuid";
7
+ import { beliefIn } from "../model/Assumption.js";
8
+ import { deletedBy, editsOf, insertedBy, principalDerivationOf } from "../model/Version.js";
9
+ import { distance, mm, subtract } from "../model/Quantity.js";
10
+ import { trackerBarOf } from "../systems/index.js";
11
+ import { operationOf, substitutionsBetween } from "../collation/substitution.js";
12
+ import { isCollationsOwn, unchecked } from "../collation/Collation.js";
13
+ import { predecessorOf } from "../analysis/stemma.js";
14
+ import { placeOf, snapshotOf } from "../analysis/text.js";
15
+ import { symbolsIn, versionIn } from "../lookup/lookup.js";
16
+ import { groupBy } from "../shared/utils.js";
17
+ import { declaring, reading } from "../ops/draft.js";
18
+ import { ineffectiveAmong } from "./presence.js";
19
+ import { reads } from "./readings.js";
20
+ import { editRules, inMillimetres, keptSets, memberOf, recorded } from "./record.js";
21
+ /**
22
+ * How far a perforation may be put from where it stood for the two to be
23
+ * one move. WM 225 moves commands by up to about 60 mm.
24
+ * @category Filiation
25
+ */
26
+ export const defaultShiftWithin = mm(60);
27
+ /**
28
+ * How far an Off may follow an On for the two to be one function turned
29
+ * on and off again, as an accent is. WM 225 has crescendo accents of up
30
+ * to about 55 mm.
31
+ * @category Filiation
32
+ */
33
+ export const defaultPairWithin = mm(60);
34
+ /**
35
+ * How near the onsets of a perforation and the one put in its place lie
36
+ * for the one to be a longer or shorter version of the other rather than
37
+ * a moved one. The figure `mergeEdits` guesses with.
38
+ * @category Filiation
39
+ */
40
+ export const defaultResizeWithin = mm(5);
41
+ /**
42
+ * How near two perforations lie to stand at one place: the commands of a
43
+ * cancel row, or an accent's On and the note set off for it.
44
+ * @category Filiation
45
+ */
46
+ export const defaultSamePlaceWithin = mm(2);
47
+ const isExpression = (symbol) => symbol.type === 'expression';
48
+ /** The line a command stands on: a latched function, a held one, a command no other system shares, or a pitch. */
49
+ const lineOf = (symbol) => {
50
+ if (symbol.type === 'note')
51
+ return { key: `note ${symbol.pitch}`, latched: false };
52
+ const operation = operationOf(symbol.expressionType);
53
+ if (!operation)
54
+ return { key: `expression ${symbol.scope} ${symbol.expressionType}`, latched: false };
55
+ const operates = operation.sided ? `${operation.operates} ${symbol.scope}` : operation.operates;
56
+ return operation.spelling === 'held'
57
+ ? { key: `held ${operates}`, latched: false }
58
+ : { key: operates, latched: true };
59
+ };
60
+ const spellingOf = (symbol) => isExpression(symbol) ? operationOf(symbol.expressionType)?.spelling : undefined;
61
+ const isOn = (symbol) => spellingOf(symbol) === 'on';
62
+ const meaningOf = (symbol) => symbol.type === 'note'
63
+ ? { type: 'note', pitch: symbol.pitch }
64
+ : { type: 'expression', expressionType: symbol.expressionType, scope: symbol.scope };
65
+ /** The ids of the commands of the text that its latched functions make no use of. */
66
+ const ineffectiveIn = (bar, text, placed) => bar
67
+ ? ineffectiveAmong(bar, text.flatMap(symbol => {
68
+ const place = placed(symbol);
69
+ return symbol.type !== 'text' && place ? [{ id: symbol.id, meaning: meaningOf(symbol), place }] : [];
70
+ }))
71
+ : new Set();
72
+ const lengthOf = (span) => subtract(span.to, span.from);
73
+ const ofSide = (changes, side) => changes.filter(change => change.side === side);
74
+ const byPlace = (a, b) => a.place.from - b.place.from || (a.symbol.id < b.symbol.id ? -1 : a.symbol.id > b.symbol.id ? 1 : 0);
75
+ const inOrder = (changes) => [...changes].sort(byPlace);
76
+ const typesOf = (changes) => inOrder(changes).map(change => change.symbol.type === 'expression' ? change.symbol.expressionType : change.symbol.type);
77
+ const sameSequence = (a, b) => a.length === b.length && a.every((value, i) => value === b[i]);
78
+ const onsetOf = (changes) => Math.min(...changes.map(change => change.place.from));
79
+ /**
80
+ * The commands of one text that turn its function on and off again: an
81
+ * Off with the Ons right before it within the window. Ons that follow
82
+ * each other are one punched where the other stands, so an accent whose
83
+ * On moved forward keeps the one it left behind. The changes are those
84
+ * of one text on one line, in order, between two commands both texts keep.
85
+ */
86
+ const pairsIn = (changes, within) => changes.flatMap((change, index) => {
87
+ if (isOn(change.symbol))
88
+ return [];
89
+ const ons = [];
90
+ for (let i = index - 1; i >= 0 && isOn(changes[i].symbol) && change.place.from - changes[i].place.from <= within; i--) {
91
+ ons.unshift(changes[i]);
92
+ }
93
+ return ons.length > 0 ? [[...ons, change]] : [];
94
+ });
95
+ /**
96
+ * Of the changes left, an On with the Off right before it in the same
97
+ * text within the window, both of use to it: a function broken off and
98
+ * taken up again.
99
+ */
100
+ const breaksIn = (changes, left, within) => {
101
+ const breaks = [];
102
+ changes.forEach((change, index) => {
103
+ const before = changes[index - 1];
104
+ if (!before || !left.has(change) || !left.has(before) || breaks.some(pair => pair.includes(before)))
105
+ return;
106
+ if (before.redundant || change.redundant || !isOn(change.symbol) || isOn(before.symbol))
107
+ return;
108
+ if (change.place.from - before.place.from <= within)
109
+ breaks.push([before, change]);
110
+ });
111
+ return breaks;
112
+ };
113
+ /** Whether the one unit puts the perforations of the other elsewhere, near enough and each moved by about as much. */
114
+ const movesRigidly = (deleted, inserted, options) => {
115
+ const before = inOrder(deleted);
116
+ const after = inOrder(inserted);
117
+ if (!sameSequence(typesOf(before), typesOf(after)))
118
+ return false;
119
+ const shifts = after.map((change, index) => change.place.from - before[index].place.from);
120
+ return Math.abs(shifts[0]) <= options.shiftWithin
121
+ && Math.max(...shifts) - Math.min(...shifts) <= options.resizeWithin;
122
+ };
123
+ /** What the version deletes, each with what it puts in its place: the nearest candidate first, each taken once. */
124
+ const nearestFirst = (deleted, inserted, fits) => {
125
+ const candidates = deleted.flatMap(one => inserted
126
+ .filter(other => fits(one, other))
127
+ .map(other => ({ one, other, gap: Math.abs(onsetOf(other) - onsetOf(one)) })));
128
+ candidates.sort((a, b) => a.gap - b.gap || byPlace(inOrder(a.one)[0], inOrder(b.one)[0]) || byPlace(inOrder(a.other)[0], inOrder(b.other)[0]));
129
+ const moves = new Map();
130
+ const taken = new Set();
131
+ for (const { one, other } of candidates) {
132
+ if (moves.has(one) || taken.has(other))
133
+ continue;
134
+ moves.set(one, other);
135
+ taken.add(other);
136
+ }
137
+ return moves;
138
+ };
139
+ /** The units as groups, a deleted one together with what it moved to. */
140
+ const asGroups = (units, moves) => {
141
+ const partners = new Set(moves.values());
142
+ return units.flatMap((unit) => {
143
+ if (partners.has(unit))
144
+ return [];
145
+ const partner = moves.get(unit);
146
+ if (partner)
147
+ return [{ changes: [...unit, ...partner], grouping: 'move' }];
148
+ return [{ changes: unit, grouping: unit.length === 1 ? 'single' : 'overlap' }];
149
+ });
150
+ };
151
+ /**
152
+ * The changes on a latched line. A command both texts keep sets the
153
+ * function alike in both, so the line falls into segments between such
154
+ * commands, and no edit runs across one.
155
+ *
156
+ * Within a segment, a pair one text turns the function on and off again
157
+ * with is a unit. A pair the version withdraws and one it adds that puts
158
+ * each command elsewhere by about as much is one move. Otherwise each
159
+ * command the version deletes goes with the nearest of the same type it
160
+ * puts nearby, a pair broken up only where every command of it moves.
161
+ * What is left stands alone, but for an Off and an On right after it,
162
+ * which break the function off and take it up again.
163
+ */
164
+ const latchedGroups = (changes, kept, options) => {
165
+ const entries = [
166
+ ...kept.map(({ symbol, place }) => ({ at: place.from, on: isOn(symbol), id: symbol.id })),
167
+ ...changes.map(change => ({ at: change.place.from, on: isOn(change.symbol), id: change.symbol.id, change }))
168
+ ];
169
+ // At one place a cancel comes first, as the valves take it.
170
+ entries.sort((a, b) => a.at - b.at || Number(a.on) - Number(b.on) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
171
+ const segments = [[]];
172
+ for (const entry of entries) {
173
+ if (entry.change)
174
+ segments[segments.length - 1].push(entry.change);
175
+ else if (segments[segments.length - 1].length > 0)
176
+ segments.push([]);
177
+ }
178
+ return segments.flatMap(segment => {
179
+ const added = pairsIn(ofSide(segment, 'insert'), options.pairWithin);
180
+ const withdrawn = pairsIn(ofSide(segment, 'delete'), options.pairWithin);
181
+ const paired = new Set([...added, ...withdrawn].flat());
182
+ const singles = segment.filter(change => !paired.has(change));
183
+ const pairMoves = nearestFirst(withdrawn, added, (one, other) => movesRigidly(one, other, options));
184
+ const movedPairs = new Set([...pairMoves.keys(), ...pairMoves.values()]);
185
+ let open = [...added, ...withdrawn].filter(pair => !movedPairs.has(pair));
186
+ let units = [];
187
+ let moves = new Map();
188
+ for (;;) {
189
+ units = [...singles, ...open.flat()].map(change => [change]);
190
+ moves = nearestFirst(units.filter(unit => unit[0].side === 'delete'), units.filter(unit => unit[0].side === 'insert'), (one, other) => movesRigidly(one, other, options));
191
+ const moved = new Set([...moves.keys(), ...moves.values()].flat());
192
+ const intact = open.filter(pair => !pair.every(change => moved.has(change)));
193
+ if (intact.length === 0)
194
+ break;
195
+ open = open.filter(pair => !intact.includes(pair));
196
+ }
197
+ const whole = [...added, ...withdrawn].filter(pair => !movedPairs.has(pair) && !open.includes(pair));
198
+ const moved = new Set([...moves.keys(), ...moves.values()].flat());
199
+ const left = new Set(units.flat().filter(change => !moved.has(change)));
200
+ const breaks = [
201
+ ...breaksIn(ofSide(segment, 'insert'), left, options.pairWithin),
202
+ ...breaksIn(ofSide(segment, 'delete'), left, options.pairWithin)
203
+ ];
204
+ const broken = new Set(breaks.flat());
205
+ return [
206
+ ...asGroups([...pairMoves.keys(), ...pairMoves.values()], pairMoves),
207
+ ...[...whole, ...breaks].map((pair) => ({ changes: pair, grouping: 'pair' })),
208
+ ...asGroups(units.filter(unit => !broken.has(unit[0])), moves)
209
+ ];
210
+ });
211
+ };
212
+ /**
213
+ * The changes on a line of perforations that last as long as they
214
+ * sound: a pitch, or a held function. A perforation both texts keep
215
+ * divides the line. Changes that overlap are a unit, a note struck again
216
+ * or two tied into one, except where as many are deleted as inserted:
217
+ * then each deleted one is a unit with the inserted one in its place, in
218
+ * order. A unit the version deletes and one of the same shape it puts
219
+ * nearby are one move.
220
+ */
221
+ const spanGroups = (changes, kept, options) => {
222
+ const entries = [
223
+ ...kept.map(({ place }) => ({ from: place.from, to: place.to })),
224
+ ...inOrder(changes).map(change => ({ from: change.place.from, to: change.place.to, change }))
225
+ ].sort((a, b) => a.from - b.from);
226
+ const units = [];
227
+ const segmentOf = new Map();
228
+ let segment = 0;
229
+ let open = [];
230
+ let reach = -Infinity;
231
+ const close = () => {
232
+ const inserted = ofSide(open, 'insert');
233
+ const deleted = ofSide(open, 'delete');
234
+ const closed = inserted.length > 1 && inserted.length === deleted.length
235
+ ? inserted.map((change, index) => [deleted[index], change])
236
+ : open.length > 0 ? [open] : [];
237
+ closed.forEach(unit => segmentOf.set(unit, segment));
238
+ units.push(...closed);
239
+ open = [];
240
+ reach = -Infinity;
241
+ };
242
+ for (const entry of entries) {
243
+ if (!entry.change) {
244
+ close();
245
+ segment++;
246
+ continue;
247
+ }
248
+ if (entry.from > reach)
249
+ close();
250
+ open.push(entry.change);
251
+ reach = Math.max(reach, entry.to);
252
+ }
253
+ close();
254
+ const pure = units.filter(unit => unit.every(change => change.side === unit[0].side));
255
+ const moves = nearestFirst(pure.filter(unit => unit[0].side === 'delete'), pure.filter(unit => unit[0].side === 'insert'), (one, other) => segmentOf.get(one) === segmentOf.get(other) && movesRigidly(one, other, options));
256
+ return asGroups(units, moves);
257
+ };
258
+ /**
259
+ * Cancels the version punches across three lines or more at one place,
260
+ * none of them closing a pair or moved: a cancel row, as a roll has
261
+ * before its first note.
262
+ */
263
+ const rowsAmong = (groups, within) => {
264
+ const cancels = groups
265
+ .filter(group => group.grouping === 'single')
266
+ .flatMap(group => group.changes)
267
+ .filter(change => change.side === 'insert' && spellingOf(change.symbol) === 'off')
268
+ .sort(byPlace);
269
+ const rows = [];
270
+ for (let i = 0; i < cancels.length;) {
271
+ let j = i + 1;
272
+ while (j < cancels.length && cancels[j].place.from - cancels[i].place.from <= within)
273
+ j++;
274
+ const row = cancels.slice(i, j);
275
+ if (new Set(row.map(change => lineOf(change.symbol).key)).size >= 3)
276
+ rows.push({ changes: row, grouping: 'row' });
277
+ i = j;
278
+ }
279
+ return rows;
280
+ };
281
+ /** Whether the bar spells an accent with exactly these commands. */
282
+ const spellsAccent = (bar, types) => (bar?.accents ?? []).some(accent => sameSequence(types, accent));
283
+ /** What kind of change a group is, where its shape says. */
284
+ const typeOf = (group, bar, accentsAt, options) => {
285
+ const inserted = inOrder(ofSide(group.changes, 'insert'));
286
+ const deleted = inOrder(ofSide(group.changes, 'delete'));
287
+ if (group.grouping === 'substitution' || group.changes.some(change => change.foreign))
288
+ return 'recoding';
289
+ if (group.changes.every(change => change.redundant)) {
290
+ if (deleted.length === 0)
291
+ return 'add-redundancy';
292
+ if (inserted.length === 0)
293
+ return 'remove-redundancy';
294
+ }
295
+ if (group.grouping === 'pair') {
296
+ return deleted.length === 0 && spellsAccent(bar, typesOf(inserted.slice(-2))) ? 'additional-accent' : undefined;
297
+ }
298
+ if (inserted.length === 0 || deleted.length === 0)
299
+ return undefined;
300
+ if (inserted.length === 1 && deleted.length === 1) {
301
+ const [after, before] = [inserted[0].place, deleted[0].place];
302
+ if (distance(after.from, before.from) >= options.resizeWithin)
303
+ return 'shift';
304
+ // A note set off from its chord where the version puts an accent serves the accent.
305
+ const [early, late] = [Math.min(after.from, before.from), Math.max(after.from, before.from)];
306
+ const setOff = inserted[0].symbol.type === 'note' && late - early >= options.samePlaceWithin
307
+ && accentsAt.some(at => at >= early - options.samePlaceWithin && at <= late + options.samePlaceWithin);
308
+ if (setOff)
309
+ return 'additional-accent';
310
+ return lengthOf(after) < lengthOf(before) ? 'shorten' : 'prolong';
311
+ }
312
+ const notes = group.changes.every(change => change.symbol.type === 'note');
313
+ if (notes && deleted.length === 1)
314
+ return 'shorten';
315
+ if (notes && inserted.length === 1)
316
+ return 'prolong';
317
+ if (sameSequence(typesOf(inserted), typesOf(deleted)))
318
+ return 'shift';
319
+ return undefined;
320
+ };
321
+ const settingsOf = (options) => ({
322
+ shiftWithin: options.shiftWithin ?? defaultShiftWithin,
323
+ pairWithin: options.pairWithin ?? defaultPairWithin,
324
+ resizeWithin: options.resizeWithin ?? defaultResizeWithin,
325
+ samePlaceWithin: options.samePlaceWithin ?? defaultSamePlaceWithin
326
+ });
327
+ /**
328
+ * The edits of the version, proposed from what its edits insert and
329
+ * delete, however they group it now. The proposal depends on the
330
+ * version's changes and the text of its parent alone, so it is the same
331
+ * whatever order the changes come in.
332
+ *
333
+ * Changes are read line by line: a latched function on its side of the
334
+ * keyboard, a held one, or a pitch. A command or a note both texts keep
335
+ * divides its line, and no edit runs across one, so that every edit
336
+ * stays at one place. On a latched line, a function turned on and off
337
+ * again shortly after is one edit, as an accent is; a pair withdrawn and
338
+ * put elsewhere whole is one move, and so is a command put elsewhere on
339
+ * its own, the nearest first; an Off with an On right after it breaks
340
+ * the function off and takes it up again. Notes, and commands held for
341
+ * as long as they last, are one edit where they overlap, and one move
342
+ * where a note is put nearby. Where the version's system has no word for
343
+ * a command of the parent's, a held command standing for a latched pair
344
+ * is one edit with it; what has no counterpart stands alone. Cancels
345
+ * punched across three lines or more at one place are one edit, as a
346
+ * cancel row is.
347
+ *
348
+ * The type is read off the shape: a pair the bar spells an accent with is
349
+ * an additional accent, as is a note set off from its chord by a few
350
+ * millimetres where the version puts an accent's On; a perforation put
351
+ * elsewhere is a shift, or where it begins about where the other did,
352
+ * shortened or prolonged; a note struck again is shortened, two tied
353
+ * prolonged; a change the text makes no use of is a redundancy added or
354
+ * removed; and a change the other system has no word for is a recoding.
355
+ * Whether a shift corrects an error, and why any change was made, is
356
+ * not read off the shape.
357
+ *
358
+ * A version that derives from none states its text, not a change of
359
+ * another, and each of its symbols stands alone.
360
+ * @category Filiation
361
+ */
362
+ export const proposeEdits = (edition, versionId, options = {}) => {
363
+ const settings = settingsOf(options);
364
+ const version = versionIn(edition, versionId);
365
+ if (!version)
366
+ return [];
367
+ const parent = predecessorOf(edition, versionId);
368
+ if (!parent) {
369
+ return insertedBy(version).map(symbol => ({ insert: [symbol.id], delete: [], grouping: 'single' }));
370
+ }
371
+ const bar = trackerBarOf(version.system);
372
+ const parentBar = trackerBarOf(parent.system) ?? bar;
373
+ const placed = (symbol) => placeOf(edition, symbol);
374
+ const parentText = snapshotOf(edition, parent.id);
375
+ const ineffectiveBefore = ineffectiveIn(parentBar, parentText, placed);
376
+ const ineffectiveAfter = ineffectiveIn(bar, snapshotOf(edition, versionId), placed);
377
+ const unplaced = [];
378
+ const changes = [];
379
+ const add = (symbol, side) => {
380
+ const place = placed(symbol);
381
+ if (!place || symbol.type === 'text') {
382
+ unplaced.push({ insert: side === 'insert' ? [symbol.id] : [], delete: side === 'delete' ? [symbol.id] : [], grouping: 'single' });
383
+ return;
384
+ }
385
+ const other = side === 'insert' ? parentBar : bar;
386
+ changes.push({
387
+ symbol,
388
+ side,
389
+ place,
390
+ redundant: (side === 'insert' ? ineffectiveAfter : ineffectiveBefore).has(symbol.id),
391
+ foreign: isExpression(symbol) && !!other && !reads(other, meaningOf(symbol))
392
+ });
393
+ };
394
+ insertedBy(version).forEach(symbol => add(symbol, 'insert'));
395
+ symbolsIn(edition, deletedBy(version)).forEach(symbol => add(symbol, 'delete'));
396
+ changes.sort(byPlace);
397
+ // A held command of the version's system stands for a latched pair of the parent's.
398
+ const foreign = changes.filter(change => change.foreign);
399
+ const substituted = substitutionsBetween(ofSide(foreign, 'insert').map(change => change.symbol), ofSide(foreign, 'delete').map(change => change.symbol), placed).map(({ replaced, by }) => {
400
+ const ids = new Set([...replaced, ...by].map(symbol => symbol.id));
401
+ return { changes: foreign.filter(change => ids.has(change.symbol.id)), grouping: 'substitution' };
402
+ });
403
+ const substitutedIds = new Set(substituted.flatMap(group => group.changes.map(change => change.symbol.id)));
404
+ const recoded = foreign
405
+ .filter(change => !substitutedIds.has(change.symbol.id))
406
+ .map((change) => ({ changes: [change], grouping: 'single' }));
407
+ const changed = new Set(changes.map(change => change.symbol.id));
408
+ const keptOn = new Map();
409
+ for (const symbol of parentText) {
410
+ const place = placed(symbol);
411
+ if (changed.has(symbol.id) || symbol.type === 'text' || !place)
412
+ continue;
413
+ const key = lineOf(symbol).key;
414
+ keptOn.set(key, [...(keptOn.get(key) ?? []), { symbol, place }]);
415
+ }
416
+ const groupLines = (among) => [...groupBy(among, change => lineOf(change.symbol).key)].flatMap(([key, onLine]) => lineOf(onLine[0].symbol).latched
417
+ ? latchedGroups(onLine, keptOn.get(key) ?? [], settings)
418
+ : spanGroups(onLine, keptOn.get(key) ?? [], settings));
419
+ const native = changes.filter(change => !change.foreign);
420
+ const first = groupLines(native);
421
+ const rows = rowsAmong(first, settings.samePlaceWithin);
422
+ const inRow = new Set(rows.flatMap(row => row.changes));
423
+ const lines = inRow.size > 0 ? groupLines(native.filter(change => !inRow.has(change))) : first;
424
+ // Where the version puts the On of one of its bar's accents.
425
+ const accentOns = new Set((bar?.accents ?? []).map(accent => accent[0]));
426
+ const accentsAt = ofSide(native, 'insert')
427
+ .filter(change => isExpression(change.symbol) && accentOns.has(change.symbol.expressionType))
428
+ .map(change => change.place.from);
429
+ const proposed = [...substituted, ...recoded, ...lines, ...rows]
430
+ .sort((a, b) => byPlace(inOrder(a.changes)[0], inOrder(b.changes)[0]))
431
+ .map((group) => {
432
+ const editType = typeOf(group, bar, accentsAt, settings);
433
+ return {
434
+ insert: inOrder(ofSide(group.changes, 'insert')).map(change => change.symbol.id),
435
+ delete: inOrder(ofSide(group.changes, 'delete')).map(change => change.symbol.id),
436
+ ...(editType && { editType }),
437
+ grouping: group.grouping
438
+ };
439
+ });
440
+ return [...proposed, ...unplaced];
441
+ };
442
+ /** The namespace the ids of adopted edits are minted in, so that adopting again gives them again. */
443
+ const EDITS = '3c9e0f62-1d4b-4a7e-9b55-8e2f7a6d1c04';
444
+ const exchangeKey = (inserted, deleted) => `${[...inserted].sort().join(',')}/${[...deleted].sort().join(',')}`;
445
+ /**
446
+ * Whether the edit is left for a proposal to group again: one a
447
+ * collation or an adoption wrote, that nobody has given a motivation.
448
+ * @category Filiation
449
+ */
450
+ export const isRegroupable = (edit) => isCollationsOwn(edit) && (edit.motivation === undefined || edit.motivation === unchecked);
451
+ /**
452
+ * The edits the version comes to state when the proposal is adopted:
453
+ * those it keeps, and those put in place of the rest.
454
+ */
455
+ const adoptedEditsOf = (edition, version, options) => {
456
+ const edits = editsOf(version);
457
+ const held = edits.filter(edit => !isRegroupable(edit));
458
+ const spoken = new Set(held.flatMap(edit => [...(edit.insert ?? []).map(symbol => symbol.id), ...(edit.delete ?? [])]));
459
+ const symbolOf = new Map(insertedBy(version).map(symbol => [symbol.id, symbol]));
460
+ const stated = new Map(edits.map(edit => [exchangeKey((edit.insert ?? []).map(symbol => symbol.id), edit.delete ?? []), edit]));
461
+ const made = (inserted, deleted, editType) => {
462
+ const key = exchangeKey(inserted, deleted);
463
+ const same = stated.get(key);
464
+ if (same)
465
+ return same.editType || !editType ? same : { ...same, editType };
466
+ return {
467
+ type: 'edit',
468
+ id: v5(`edit|${version.id}|${key}`, EDITS),
469
+ ...(editType && { editType }),
470
+ motivation: unchecked,
471
+ ...(inserted.length > 0 && { insert: inserted.map(id => symbolOf.get(id)) }),
472
+ ...(deleted.length > 0 && { delete: [...deleted] })
473
+ };
474
+ };
475
+ const proposed = proposeEdits(edition, version.id, options).flatMap(edit => {
476
+ const free = (ids) => ids.filter(id => !spoken.has(id));
477
+ const [inserted, deleted] = [free(edit.insert), free(edit.delete)];
478
+ if (inserted.length === edit.insert.length && deleted.length === edit.delete.length) {
479
+ return [made(inserted, deleted, edit.editType)];
480
+ }
481
+ // What an edit that stays leaves of a proposed one stands symbol by symbol.
482
+ return [...inserted.map(id => made([id], [])), ...deleted.map(id => made([], [id]))];
483
+ });
484
+ return { held, proposed };
485
+ };
486
+ /**
487
+ * What the run of `proposeEdits` was computed with, in words, so that it
488
+ * can be run again.
489
+ */
490
+ const noteOn = (settings) => 'The changes of each version against the version it derives from, grouped line by line and never across a command both texts keep. '
491
+ + `A perforation put elsewhere within ${inMillimetres(settings.shiftWithin)} is one move with the one it replaces, `
492
+ + `an Off within ${inMillimetres(settings.pairWithin)} of an On one function turned on and off again, `
493
+ + `a perforation beginning within ${inMillimetres(settings.resizeWithin)} of the one it replaces a shortened or prolonged one, `
494
+ + `and perforations within ${inMillimetres(settings.samePlaceWithin)} of each other at one place.`;
495
+ /**
496
+ * Puts the proposed edits (`proposeEdits`) in place of those of the
497
+ * versions that a collation or an adoption wrote and nobody has given a
498
+ * motivation, every version but the root where none are named. An edit
499
+ * someone motivated, typed beyond what a collation types, or held under a
500
+ * belief stays as it is, and what a proposed edit shares with it stands
501
+ * symbol by symbol beside it. An edit stated already over the very same
502
+ * symbols as a proposed one is kept with its id and what was written on
503
+ * it, the proposed type filled in where it states none; the others are
504
+ * new, with ids that adopting again gives again, and marked unchecked,
505
+ * so that what is left to the editor is to say why.
506
+ *
507
+ * Applied on the same draft after `adoptStemma`, it proposes the edits of
508
+ * the stemma adopted, as one undo step.
509
+ *
510
+ * The edits it proposes are recorded as one proposition set, held under
511
+ * one belief, likely unless the run says otherwise, whose reason is the
512
+ * inference the program drew: it names this library and its release,
513
+ * the versions it read, the rule that keeps an edit at one place and, in
514
+ * its note, the distances it grouped with, and it rests on the beliefs
515
+ * the derivations of the versions are held with. An edit of the set
516
+ * that nobody has motivated is the program's, typed and all, and a
517
+ * later proposal groups it again. The run gives who ran the program and
518
+ * when (`Run`).
519
+ * @category Operations
520
+ */
521
+ export const adoptEdits = (versionIds, options = {}, run = {}) => reading(edition => {
522
+ const targets = (versionIds ?? edition.versions.map(version => version.id))
523
+ .map(id => versionIn(edition, id))
524
+ .filter((version) => version !== undefined && predecessorOf(edition, version.id) !== undefined);
525
+ const regrouped = targets.map(version => ({ version, ...adoptedEditsOf(edition, version, options) }));
526
+ const set = editSetOf(edition, regrouped, options, run);
527
+ const adopted = new Map(regrouped.flatMap(({ version, held, proposed }) => {
528
+ const edits = [...held, ...proposed.map(edit => memberOf(set.id, edit))];
529
+ const stated = editsOf(version);
530
+ if (edits.length === stated.length && edits.every((edit, index) => edit === stated[index]))
531
+ return [];
532
+ return [[version.id, { ...version, edits, motivations: declaring(version.motivations, edits) }]];
533
+ }));
534
+ const sets = keptSets({
535
+ ...edition,
536
+ versions: edition.versions.map(version => adopted.get(version.id) ?? version)
537
+ }, [set]);
538
+ // Each version is put in place whole: one that an operation before put
539
+ // on the same draft, as adopting a stemma does, is no draft to write to.
540
+ return draft => {
541
+ draft.versions.forEach((version, index) => {
542
+ const changed = adopted.get(version.id);
543
+ if (changed)
544
+ draft.versions[index] = changed;
545
+ });
546
+ if (sets)
547
+ draft.propositionSets = sets;
548
+ else
549
+ delete draft.propositionSets;
550
+ };
551
+ });
552
+ /**
553
+ * The set the run of `proposeEdits` concluded: the edits it proposed for
554
+ * the versions, held with the belief the run gives them. Its premises
555
+ * are the beliefs the derivations of those versions are held with, since
556
+ * what a version changes is read against the version it derives from: a
557
+ * proposed stemma's, where the derivation belongs to one.
558
+ */
559
+ const editSetOf = (edition, regrouped, options, run) => {
560
+ const settings = settingsOf(options);
561
+ const proposing = regrouped.filter(({ proposed }) => proposed.length > 0);
562
+ const premises = [...new Set(proposing.flatMap(({ version }) => {
563
+ const derivation = principalDerivationOf(version);
564
+ const belief = derivation && beliefIn(edition, derivation);
565
+ return belief ? [belief.id] : [];
566
+ }))];
567
+ const used = [...new Set(proposing.flatMap(({ version }) => [predecessorOf(edition, version.id).id, version.id]))];
568
+ return recorded('edits', [settings, premises, proposing.map(({ version, proposed }) => [version.id, proposed.map(edit => edit.id)])], { note: noteOn(settings), premises, used, applies: [editRules.onePlace] }, run);
569
+ };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Filiation: proposing a stemma from the copies alone, by the editorial
3
- * principles the rules of an edition state, and taking it into the
4
- * edition.
3
+ * principles the rules of an edition state, and the edits of each of its
4
+ * versions, and taking them into the edition.
5
5
  */
6
6
  export { collateCopies, readingsOfCopy, type CopyCollation, type CollatedReading, type CopyReading, type PairWindow, type Recoding } from "./readings.js";
7
7
  export { presenceIn, ineffectiveAmong, defaultMargin, type Presence, type PresenceMatrix } from "./presence.js";
@@ -9,5 +9,7 @@ export { defaultCosts, type Costs, type Scored, type Support } from "./search.js
9
9
  export { transfersOf, systemAbove } from "./systems.js";
10
10
  export { proposeStemma, defaultMovedWithin, type FiliationOptions, type ProposedVersion, type StemmaProposal, type Unpinned } from "./proposal.js";
11
11
  export { adoptionOf, adoptStemma, type Adoption } from "./adoption.js";
12
+ export { stemmaRules, editRules, type Run } from "./record.js";
12
13
  export { stemmaChanges, type VersionChange } from "./changes.js";
13
14
  export { sharedTexts, differencesBetween, defaultSameText, type SharedText, type KeptApart } from "./texts.js";
15
+ export { proposeEdits, adoptEdits, isRegroupable, defaultShiftWithin, defaultPairWithin, defaultResizeWithin, defaultSamePlaceWithin, type EditOptions, type EditGrouping, type ProposedEdit } from "./edits.js";
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Filiation: proposing a stemma from the copies alone, by the editorial
3
- * principles the rules of an edition state, and taking it into the
4
- * edition.
3
+ * principles the rules of an edition state, and the edits of each of its
4
+ * versions, and taking them into the edition.
5
5
  */
6
6
  export { collateCopies, readingsOfCopy } from "./readings.js";
7
7
  export { presenceIn, ineffectiveAmong, defaultMargin } from "./presence.js";
@@ -9,5 +9,7 @@ export { defaultCosts } from "./search.js";
9
9
  export { transfersOf, systemAbove } from "./systems.js";
10
10
  export { proposeStemma, defaultMovedWithin } from "./proposal.js";
11
11
  export { adoptionOf, adoptStemma } from "./adoption.js";
12
+ export { stemmaRules, editRules } from "./record.js";
12
13
  export { stemmaChanges } from "./changes.js";
13
14
  export { sharedTexts, differencesBetween, defaultSameText } from "./texts.js";
15
+ export { proposeEdits, adoptEdits, isRegroupable, defaultShiftWithin, defaultPairWithin, defaultResizeWithin, defaultSamePlaceWithin } from "./edits.js";