@ansonlai/docx-redline-js 0.8.3 → 0.9.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.
- package/ARCHITECTURE.md +221 -12
- package/CHANGELOG.md +803 -586
- package/README.md +1018 -901
- package/core/current-properties.js +136 -0
- package/core/paragraph-text.js +211 -209
- package/core/types.js +9 -3
- package/core/word-revision-semantics.js +256 -0
- package/dist/docx-redline-js.esm.js +4551 -967
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +112 -106
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +4690 -1106
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +4690 -1106
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_FAST_START.md +2 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +23 -4
- package/docs/SKILL_AUTHORING.md +165 -150
- package/docs/TESTING.md +1142 -1059
- package/docs/schemas/document-operations.schema.json +221 -168
- package/document/docx-document.js +1022 -1010
- package/engine/oxml-engine.js +567 -560
- package/engine/paragraph-mark-revisions.js +292 -0
- package/engine/reconstruction-mapper.js +312 -311
- package/engine/reconstruction-mode.js +183 -183
- package/engine/reconstruction-writer.js +67 -10
- package/engine/rpr-helpers.js +42 -0
- package/engine/run-builders.js +0 -73
- package/engine/surgical-diff-application.js +604 -605
- package/engine/table-mode.js +63 -63
- package/index.d.ts +51 -1
- package/index.js +47 -3
- package/node/cli-help.js +1 -0
- package/node/cli.js +992 -974
- package/orchestration/list-structural-fallback.js +67 -9
- package/package.json +1 -1
- package/pipeline/diff-engine.js +130 -1
- package/pipeline/ingestion-export.js +227 -243
- package/pipeline/ingestion-paragraph.js +93 -98
- package/pipeline/list-generation.js +415 -419
- package/pipeline/pipeline.js +206 -204
- package/pipeline/serialization.js +111 -94
- package/scripts/export-validation-fixtures.mjs +19 -6
- package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
- package/scripts/lib/word-coverage-catalogue.mjs +7 -2
- package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
- package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
- package/scripts/word-com-differential.ps1 +443 -335
- package/scripts/word-com-visual-suite.ps1 +2 -2
- package/scripts/word-revision-semantics-matrix.ps1 +108 -0
- package/services/batch-operation-orchestrator.js +26 -0
- package/services/capture-engine.js +228 -226
- package/services/document-operation-applier.js +215 -0
- package/services/document-operation-contract.js +628 -537
- package/services/document-operation-mutations.js +789 -46
- package/services/error-recovery.js +17 -0
- package/services/list-conversion-diagnostics.js +296 -0
- package/services/list-format-change.js +586 -0
- package/services/list-format-mutation.js +416 -0
- package/services/list-format.js +305 -0
- package/services/numbering-allocation.js +642 -0
- package/services/numbering-helpers.js +354 -524
- package/services/operation-batch-compiler.js +612 -436
- package/services/operation-heuristics.js +197 -173
- package/services/operation-preflight.js +1 -0
- package/services/receipt-collector.js +288 -288
- package/services/revision-comment-management.js +634 -594
- package/services/revision-fidelity-verifier.js +498 -0
- package/services/standalone-docx-plumbing.js +627 -631
- package/services/standalone-operation-runner.d.ts +606 -427
- package/services/table-reconciliation.js +103 -49
|
@@ -1,436 +1,612 @@
|
|
|
1
|
-
import {
|
|
2
|
-
buildParagraphMetadataIndex,
|
|
3
|
-
getDocumentParagraphNodes,
|
|
4
|
-
getParagraphId,
|
|
5
|
-
resolveTargetParagraph
|
|
6
|
-
} from '../core/paragraph-targeting.js';
|
|
7
|
-
import {
|
|
8
|
-
normalizeDocumentOperation,
|
|
9
|
-
normalizeTargetDescriptor,
|
|
10
|
-
validateDocumentOperation
|
|
11
|
-
} from './document-operation-contract.js';
|
|
12
|
-
import { compileExactReplacements } from './localized-replacement-compiler.js';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
this.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
:
|
|
156
|
-
|
|
157
|
-
entry.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
entry
|
|
163
|
-
entry
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
1
|
+
import {
|
|
2
|
+
buildParagraphMetadataIndex,
|
|
3
|
+
getDocumentParagraphNodes,
|
|
4
|
+
getParagraphId,
|
|
5
|
+
resolveTargetParagraph
|
|
6
|
+
} from '../core/paragraph-targeting.js';
|
|
7
|
+
import {
|
|
8
|
+
normalizeDocumentOperation,
|
|
9
|
+
normalizeTargetDescriptor,
|
|
10
|
+
validateDocumentOperation
|
|
11
|
+
} from './document-operation-contract.js';
|
|
12
|
+
import { compileExactReplacements } from './localized-replacement-compiler.js';
|
|
13
|
+
import { getParagraphListInfo } from '../core/list-targeting.js';
|
|
14
|
+
import { parseTypedListMarker } from './list-format.js';
|
|
15
|
+
import { isTrackedDeletedParagraph } from './numbering-helpers.js';
|
|
16
|
+
|
|
17
|
+
// list-format strips typed markers (a text write) and binds numPr (a pPr write). On its own target it conflicts with
|
|
18
|
+
// every other text or format write except one compatible single-paragraph redline (`checkListFormatComposition`).
|
|
19
|
+
const TEXT_WRITE_KINDS = new Set(['redline', 'restore', 'rejected-insert', 'list-format']);
|
|
20
|
+
const FORMAT_WRITE_KINDS = new Set(['highlight', 'format', 'paragraph-format']);
|
|
21
|
+
const WORD_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
22
|
+
export const LIST_FORMAT_COMPOSITION_CONFLICT = 'LIST_FORMAT_COMPOSITION_CONFLICT';
|
|
23
|
+
const COMPOSABLE_REDLINE_TYPES = new Set(['redline', 'replace']);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The typed-marker prefix of a paragraph text as list-format reads it: the exact text a convert-plain strip removes
|
|
27
|
+
* ("1. ", "A. ", "- "), `unsupported:<marker>` for a marker shape list-format refuses, or null for no marker.
|
|
28
|
+
*/
|
|
29
|
+
function typedMarkerKey(text) {
|
|
30
|
+
const value = String(text ?? '');
|
|
31
|
+
const parsed = parseTypedListMarker(value);
|
|
32
|
+
if (!parsed) return null;
|
|
33
|
+
if (parsed.unsupported) return `unsupported:${parsed.marker}`;
|
|
34
|
+
return value.slice(0, parsed.prefixLength);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Rule for one `redline` and one `list-format` writing the same batch-start paragraph (the list-format targets it, or
|
|
39
|
+
* rebinds it through `scope: 'list'`). They compose when the redline changes only the paragraph's text after its typed
|
|
40
|
+
* marker and the list-format owns the marker and numbering: e.g. "1. Old" -> "1. New" as a redline plus
|
|
41
|
+
* `convert-plain`. Execution order is fixed: the redline runs first (its target binds at batch start), then the
|
|
42
|
+
* list-format strips the unchanged marker from the redlined paragraph. Returns null when compatible, else a reason.
|
|
43
|
+
*/
|
|
44
|
+
function checkListFormatComposition(redline, sourceText) {
|
|
45
|
+
const operation = redline.operation;
|
|
46
|
+
if (!COMPOSABLE_REDLINE_TYPES.has(operation?.type ?? 'redline')
|
|
47
|
+
|| operation.targetEnd != null || operation.targetEndRef != null
|
|
48
|
+
|| operation.anchor != null || operation.structuredContent === true || operation.part != null
|
|
49
|
+
|| operation.target?.revisionView === 'rejected'
|
|
50
|
+
|| typeof operation.modified !== 'string' || /[\r\n]/.test(operation.modified)) {
|
|
51
|
+
return {
|
|
52
|
+
reason: 'redline-shape',
|
|
53
|
+
hint: 'Pair a list-format only with a single-paragraph redline or replace of that paragraph: modified without line breaks, no targetEnd, anchor, part or structuredContent.'
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (operation.modified === sourceText) {
|
|
57
|
+
return {
|
|
58
|
+
reason: 'redline-unchanged',
|
|
59
|
+
hint: 'The redline does not change the text; drop it and send only the list-format.'
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const before = typedMarkerKey(sourceText);
|
|
63
|
+
const after = typedMarkerKey(operation.modified);
|
|
64
|
+
if (before !== after) {
|
|
65
|
+
return {
|
|
66
|
+
reason: 'marker-changed',
|
|
67
|
+
sourceMarker: before,
|
|
68
|
+
modifiedMarker: after,
|
|
69
|
+
hint: 'The redline adds, removes or changes the typed marker. Keep the marker text exactly as it is (the list-format strips it), and express a new label through list-format (strip "none" with format/start, or change-format).'
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Live body paragraphs a `scope: 'list'` change-format/join rebinds outside its named target. */
|
|
76
|
+
function listScopeRebindParagraphs(xmlDoc, registry, binding, operation) {
|
|
77
|
+
const mode = operation.list?.mode;
|
|
78
|
+
if (mode !== 'change-format' && mode !== 'join') return [];
|
|
79
|
+
if ((operation.list?.scope || 'list') !== 'list') return [];
|
|
80
|
+
const targets = binding.sourceIds.map(sourceId => registry.entries.get(sourceId)?.paragraph).filter(Boolean);
|
|
81
|
+
const info = targets.map(paragraph => getParagraphListInfo(paragraph)).find(Boolean);
|
|
82
|
+
if (!info) return [];
|
|
83
|
+
return Array.from(xmlDoc.getElementsByTagNameNS(WORD_NS, 'p')).filter(paragraph => {
|
|
84
|
+
if (targets.includes(paragraph) || isTrackedDeletedParagraph(paragraph)) return false;
|
|
85
|
+
return getParagraphListInfo(paragraph)?.numId === info.numId;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizedError(error) {
|
|
90
|
+
const details = {};
|
|
91
|
+
if (error && typeof error === 'object') {
|
|
92
|
+
for (const [key, value] of Object.entries(error)) {
|
|
93
|
+
if (key === 'name' || key === 'stack' || key === 'message' || key === 'code') continue;
|
|
94
|
+
details[key] = value;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
code: typeof error?.code === 'string' && error.code ? error.code : 'OPERATION_ERROR',
|
|
99
|
+
message: error?.message || String(error),
|
|
100
|
+
...details
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isParagraphAttached(document, paragraph) {
|
|
105
|
+
return !!paragraph && getDocumentParagraphNodes(document).includes(paragraph);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function paragraphNodesFromMutation(nodes = []) {
|
|
109
|
+
const paragraphs = [];
|
|
110
|
+
for (const node of nodes) {
|
|
111
|
+
if (!node || node.nodeType !== 1) continue;
|
|
112
|
+
if (node.localName === 'p') paragraphs.push(node);
|
|
113
|
+
for (const paragraph of Array.from(node.getElementsByTagNameNS?.('*', 'p') || [])) {
|
|
114
|
+
if (!paragraphs.includes(paragraph)) paragraphs.push(paragraph);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return paragraphs;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Session-local source identities over live paragraph nodes. */
|
|
121
|
+
export class SourceTargetRegistry {
|
|
122
|
+
constructor(document) {
|
|
123
|
+
this.document = document;
|
|
124
|
+
this.nextId = 1;
|
|
125
|
+
this.entries = new Map();
|
|
126
|
+
this.idsByNode = new WeakMap();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
register(paragraph, metadata = {}) {
|
|
130
|
+
const existing = this.idsByNode.get(paragraph);
|
|
131
|
+
if (existing) return existing;
|
|
132
|
+
const sourceId = `S${this.nextId++}`;
|
|
133
|
+
this.entries.set(sourceId, {
|
|
134
|
+
sourceId,
|
|
135
|
+
paragraph,
|
|
136
|
+
sourceIndex: metadata.index || null,
|
|
137
|
+
paragraphId: metadata.paragraphId || null,
|
|
138
|
+
fingerprint: metadata.fingerprint || null,
|
|
139
|
+
text: metadata.text || '',
|
|
140
|
+
revisionView: metadata.revisionView === 'rejected' ? 'rejected' : 'accepted',
|
|
141
|
+
consumed: false,
|
|
142
|
+
consumedByOperation: null
|
|
143
|
+
});
|
|
144
|
+
this.idsByNode.set(paragraph, sourceId);
|
|
145
|
+
return sourceId;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
describe(sourceId) {
|
|
149
|
+
const entry = this.entries.get(sourceId);
|
|
150
|
+
if (!entry) return null;
|
|
151
|
+
return {
|
|
152
|
+
sourceId,
|
|
153
|
+
index: entry.sourceIndex,
|
|
154
|
+
paragraphId: entry.paragraphId,
|
|
155
|
+
fingerprint: entry.fingerprint,
|
|
156
|
+
text: entry.text,
|
|
157
|
+
revisionView: entry.revisionView
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
resolve(sourceId, document = this.document) {
|
|
162
|
+
const entry = this.entries.get(sourceId);
|
|
163
|
+
if (!entry) {
|
|
164
|
+
return {
|
|
165
|
+
error: {
|
|
166
|
+
code: 'SOURCE_TARGET_NOT_FOUND',
|
|
167
|
+
message: `Compiled source target ${String(sourceId)} was not found.`
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (entry.consumed) {
|
|
172
|
+
return {
|
|
173
|
+
error: {
|
|
174
|
+
code: 'TARGET_CONSUMED_BY_OPERATION',
|
|
175
|
+
message: `Compiled source target ${sourceId} was consumed by operation ${entry.consumedByOperation}.`,
|
|
176
|
+
sourceTarget: this.describe(sourceId),
|
|
177
|
+
consumedByOperation: entry.consumedByOperation
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (isParagraphAttached(document, entry.paragraph)) {
|
|
182
|
+
return { paragraph: entry.paragraph, resolvedBy: 'batch_source' };
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
error: {
|
|
186
|
+
code: 'TARGET_CONSUMED_BY_OPERATION',
|
|
187
|
+
message: `Compiled source target ${sourceId} is no longer present in the live document.`,
|
|
188
|
+
sourceTarget: this.describe(sourceId),
|
|
189
|
+
consumedByOperation: entry.consumedByOperation
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
createSavepoint(document = this.document) {
|
|
195
|
+
const paragraphs = getDocumentParagraphNodes(document);
|
|
196
|
+
return new Map(Array.from(this.entries, ([sourceId, entry]) => [sourceId, {
|
|
197
|
+
paragraphIndex: paragraphs.indexOf(entry.paragraph),
|
|
198
|
+
consumed: entry.consumed,
|
|
199
|
+
consumedByOperation: entry.consumedByOperation
|
|
200
|
+
}]));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
restoreSavepoint(document, snapshot) {
|
|
204
|
+
this.document = document;
|
|
205
|
+
this.idsByNode = new WeakMap();
|
|
206
|
+
const paragraphs = getDocumentParagraphNodes(document);
|
|
207
|
+
for (const [sourceId, entry] of this.entries) {
|
|
208
|
+
const saved = snapshot instanceof Map ? snapshot.get(sourceId) : null;
|
|
209
|
+
entry.consumed = saved?.consumed === true;
|
|
210
|
+
entry.consumedByOperation = saved?.consumedByOperation || null;
|
|
211
|
+
entry.paragraph = Number.isInteger(saved?.paragraphIndex) && saved.paragraphIndex >= 0
|
|
212
|
+
? (paragraphs[saved.paragraphIndex] || null)
|
|
213
|
+
: null;
|
|
214
|
+
if (entry.paragraph) this.idsByNode.set(entry.paragraph, sourceId);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The paragraph that will stand for sourceId once this mutation commits (no state change). */
|
|
219
|
+
previewSuccessor(sourceId, removedNodes, liveNodes) {
|
|
220
|
+
const entry = this.entries.get(sourceId);
|
|
221
|
+
if (!entry || !entry.paragraph) return null;
|
|
222
|
+
if (!Array.isArray(removedNodes) || !removedNodes.includes(entry.paragraph)) return entry.paragraph;
|
|
223
|
+
const candidates = paragraphNodesFromMutation(liveNodes);
|
|
224
|
+
const sameId = entry.paragraphId
|
|
225
|
+
? candidates.filter(paragraph => getParagraphId(paragraph) === entry.paragraphId)
|
|
226
|
+
: [];
|
|
227
|
+
return sameId.length === 1
|
|
228
|
+
? sameId[0]
|
|
229
|
+
: (candidates.length === 1 ? candidates[0] : null);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
commitMutation(sourceId, removedNodes, liveNodes, operationIndex) {
|
|
233
|
+
const entry = this.entries.get(sourceId);
|
|
234
|
+
if (!entry || !Array.isArray(removedNodes) || !removedNodes.includes(entry.paragraph)) return;
|
|
235
|
+
const successor = this.previewSuccessor(sourceId, removedNodes, liveNodes);
|
|
236
|
+
if (successor) {
|
|
237
|
+
entry.paragraph = successor;
|
|
238
|
+
this.idsByNode.set(successor, sourceId);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
entry.paragraph = null;
|
|
242
|
+
entry.consumed = true;
|
|
243
|
+
entry.consumedByOperation = operationIndex;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function resolvedTarget(metadata, revisionView) {
|
|
248
|
+
return {
|
|
249
|
+
index: metadata.index,
|
|
250
|
+
paragraphId: metadata.paragraphId,
|
|
251
|
+
fingerprint: metadata.fingerprint,
|
|
252
|
+
text: metadata.text,
|
|
253
|
+
inTable: metadata.inTable,
|
|
254
|
+
revisionView
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function resolveDescriptor(xmlDoc, descriptor, kind, strictTargets, metadataIndices, callbacks = {}) {
|
|
259
|
+
const revisionView = descriptor?.revisionView === 'rejected' ? 'rejected' : 'accepted';
|
|
260
|
+
if (!metadataIndices[revisionView]) {
|
|
261
|
+
metadataIndices[revisionView] = buildParagraphMetadataIndex(xmlDoc, { revisionView });
|
|
262
|
+
}
|
|
263
|
+
const index = metadataIndices[revisionView];
|
|
264
|
+
const resolution = resolveTargetParagraph(xmlDoc, {
|
|
265
|
+
targetText: descriptor.text,
|
|
266
|
+
targetRef: descriptor.index,
|
|
267
|
+
targetDescriptor: descriptor,
|
|
268
|
+
opType: kind,
|
|
269
|
+
strictAmbiguity: strictTargets,
|
|
270
|
+
paragraphMetadataIndex: index,
|
|
271
|
+
metadataIndices,
|
|
272
|
+
onInfo: callbacks.onInfo,
|
|
273
|
+
onWarn: callbacks.onWarn
|
|
274
|
+
});
|
|
275
|
+
const metadata = index.byParagraph.get(resolution.paragraph);
|
|
276
|
+
return {
|
|
277
|
+
paragraph: resolution.paragraph,
|
|
278
|
+
resolvedBy: resolution.resolvedBy,
|
|
279
|
+
metadata: resolvedTarget(metadata, revisionView)
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function buildConflict(code, message, bindings, target, action, details = {}, hint = null) {
|
|
284
|
+
return {
|
|
285
|
+
code,
|
|
286
|
+
message,
|
|
287
|
+
operationIndexes: bindings.map(binding => binding.index).sort((a, b) => a - b),
|
|
288
|
+
target,
|
|
289
|
+
...details,
|
|
290
|
+
recovery: {
|
|
291
|
+
action,
|
|
292
|
+
sameArgumentsSafe: false,
|
|
293
|
+
requiresReinspection: false,
|
|
294
|
+
requiresUserAuthorization: false,
|
|
295
|
+
...(hint ? { hint } : {})
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Resolve source targets once and derive source-level conflicts before mutation. */
|
|
301
|
+
export function compileOperationBatch(xmlDoc, operations = [], options = {}) {
|
|
302
|
+
const sourceOperations = Array.isArray(operations) ? operations : [];
|
|
303
|
+
const strictTargets = options.strictTargets !== false;
|
|
304
|
+
const registry = new SourceTargetRegistry(xmlDoc);
|
|
305
|
+
const metadataIndices = {
|
|
306
|
+
accepted: buildParagraphMetadataIndex(xmlDoc, { revisionView: 'accepted' }),
|
|
307
|
+
rejected: null
|
|
308
|
+
};
|
|
309
|
+
const bindings = [];
|
|
310
|
+
const compiledOperations = [];
|
|
311
|
+
|
|
312
|
+
for (let index = 0; index < sourceOperations.length; index += 1) {
|
|
313
|
+
const sourceOperation = sourceOperations[index];
|
|
314
|
+
const validation = validateDocumentOperation(sourceOperation);
|
|
315
|
+
const operation = validation.operation || normalizeDocumentOperation(sourceOperation);
|
|
316
|
+
const binding = {
|
|
317
|
+
index: index + 1,
|
|
318
|
+
operationKind: operation.operationKind,
|
|
319
|
+
operationId: operation.operationId,
|
|
320
|
+
sourceIds: [],
|
|
321
|
+
warnings: []
|
|
322
|
+
};
|
|
323
|
+
const compiled = { ...sourceOperation };
|
|
324
|
+
|
|
325
|
+
const dynamicOccurrence = operation.targetDescriptor?.occurrence != null
|
|
326
|
+
&& !operation.targetDescriptor?.paragraphId
|
|
327
|
+
&& !operation.targetDescriptor?.fingerprint;
|
|
328
|
+
const compositeTextTarget = typeof operation.targetDescriptor?.text === 'string'
|
|
329
|
+
&& /\r|\n/.test(operation.targetDescriptor.text)
|
|
330
|
+
&& !operation.targetEndDescriptor
|
|
331
|
+
// Localized replacements always address one paragraph (the contract refuses ranges and
|
|
332
|
+
// captures), so "\n" in the target text is a soft line break (w:br), not a paragraph
|
|
333
|
+
// boundary. Strong or weak (index + exactText) it binds at batch start; leaving it
|
|
334
|
+
// dynamic would skip replacement compilation and apply an empty "modified".
|
|
335
|
+
&& !Array.isArray(operation.replacements);
|
|
336
|
+
if (dynamicOccurrence || compositeTextTarget) binding.dynamic = true;
|
|
337
|
+
|
|
338
|
+
if (
|
|
339
|
+
validation.valid
|
|
340
|
+
&& operation.operationKind !== 'comment_reply'
|
|
341
|
+
&& operation.operationKind !== 'comment_resolve'
|
|
342
|
+
&& !operation.targetDescriptor?.captureRef
|
|
343
|
+
&& !binding.dynamic
|
|
344
|
+
) {
|
|
345
|
+
try {
|
|
346
|
+
const start = resolveDescriptor(
|
|
347
|
+
xmlDoc,
|
|
348
|
+
operation.targetDescriptor,
|
|
349
|
+
operation.operationKind,
|
|
350
|
+
strictTargets,
|
|
351
|
+
metadataIndices,
|
|
352
|
+
{
|
|
353
|
+
onInfo: options.onInfo,
|
|
354
|
+
onWarn: warning => binding.warnings.push(String(warning))
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
const startId = registry.register(start.paragraph, start.metadata);
|
|
358
|
+
binding.sourceIds.push(startId);
|
|
359
|
+
binding.resolvedBy = start.resolvedBy;
|
|
360
|
+
binding.resolvedTarget = start.metadata;
|
|
361
|
+
compiled._compiledSourceId = startId;
|
|
362
|
+
compiled._compiledResolvedBy = start.resolvedBy;
|
|
363
|
+
if (binding.warnings.length > 0) compiled._compiledWarnings = binding.warnings;
|
|
364
|
+
|
|
365
|
+
if (Array.isArray(operation.replacements)) {
|
|
366
|
+
const patchCompilation = compileExactReplacements(
|
|
367
|
+
start.metadata.text,
|
|
368
|
+
operation.replacements
|
|
369
|
+
);
|
|
370
|
+
if (!patchCompilation.ok) {
|
|
371
|
+
throw Object.assign(new Error(patchCompilation.error.message), patchCompilation.error);
|
|
372
|
+
}
|
|
373
|
+
compiled.modified = patchCompilation.desiredText;
|
|
374
|
+
delete compiled.replacements;
|
|
375
|
+
compiled._localizedReplacementCompilation = {
|
|
376
|
+
sourceText: start.metadata.text,
|
|
377
|
+
desiredText: patchCompilation.desiredText,
|
|
378
|
+
replacements: patchCompilation.replacements
|
|
379
|
+
};
|
|
380
|
+
const canonicalValidation = validateDocumentOperation(compiled);
|
|
381
|
+
if (!canonicalValidation.valid) {
|
|
382
|
+
throw Object.assign(
|
|
383
|
+
new Error(canonicalValidation.error.message),
|
|
384
|
+
canonicalValidation.error
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
} else if (operation.operationKind === 'restore' && sourceOperation.modified === undefined) {
|
|
388
|
+
compiled.modified = start.metadata.text;
|
|
389
|
+
const canonicalValidation = validateDocumentOperation(compiled);
|
|
390
|
+
if (!canonicalValidation.valid) {
|
|
391
|
+
throw Object.assign(
|
|
392
|
+
new Error(canonicalValidation.error.message),
|
|
393
|
+
canonicalValidation.error
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const targetEndDescriptor = operation.targetEndDescriptor
|
|
399
|
+
|| (operation.targetEndRef != null
|
|
400
|
+
? normalizeTargetDescriptor(null, operation.targetEndRef, operation.targetDescriptor.revisionView)
|
|
401
|
+
: null);
|
|
402
|
+
if (targetEndDescriptor) {
|
|
403
|
+
const end = resolveDescriptor(
|
|
404
|
+
xmlDoc,
|
|
405
|
+
targetEndDescriptor,
|
|
406
|
+
operation.operationKind,
|
|
407
|
+
strictTargets,
|
|
408
|
+
metadataIndices,
|
|
409
|
+
{
|
|
410
|
+
onInfo: options.onInfo,
|
|
411
|
+
onWarn: warning => binding.warnings.push(String(warning))
|
|
412
|
+
}
|
|
413
|
+
);
|
|
414
|
+
const endId = registry.register(end.paragraph, end.metadata);
|
|
415
|
+
compiled._compiledSourceEndId = endId;
|
|
416
|
+
const paragraphs = getDocumentParagraphNodes(xmlDoc);
|
|
417
|
+
const startIndex = paragraphs.indexOf(start.paragraph);
|
|
418
|
+
const endIndex = paragraphs.indexOf(end.paragraph);
|
|
419
|
+
if (startIndex < 0 || endIndex < startIndex) {
|
|
420
|
+
throw Object.assign(new Error('Target range is not a forward contiguous paragraph range.'), {
|
|
421
|
+
code: 'TARGET_RANGE_INVALID'
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
for (const paragraph of paragraphs.slice(startIndex, endIndex + 1)) {
|
|
425
|
+
const viewIndex = metadataIndices[operation.targetDescriptor.revisionView] || metadataIndices.accepted;
|
|
426
|
+
const metadata = viewIndex.byParagraph.get(paragraph);
|
|
427
|
+
binding.sourceIds.push(registry.register(paragraph, metadata || {}));
|
|
428
|
+
}
|
|
429
|
+
binding.sourceIds = [...new Set(binding.sourceIds)];
|
|
430
|
+
binding.resolvedTargetEnd = end.metadata;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// list-format join reads (never writes) the paragraph it continues; bind it at batch start too.
|
|
434
|
+
if (operation.operationKind === 'list-format' && operation.list?.continueFrom && typeof operation.list.continueFrom === 'object') {
|
|
435
|
+
let continueFrom;
|
|
436
|
+
try {
|
|
437
|
+
continueFrom = resolveDescriptor(
|
|
438
|
+
xmlDoc,
|
|
439
|
+
normalizeTargetDescriptor(operation.list.continueFrom, null, 'accepted'),
|
|
440
|
+
operation.operationKind,
|
|
441
|
+
strictTargets,
|
|
442
|
+
metadataIndices,
|
|
443
|
+
{
|
|
444
|
+
onInfo: options.onInfo,
|
|
445
|
+
onWarn: warning => binding.warnings.push(String(warning))
|
|
446
|
+
}
|
|
447
|
+
);
|
|
448
|
+
} catch (error) {
|
|
449
|
+
throw Object.assign(error instanceof Error ? error : new Error(String(error)), { field: 'list.continueFrom' });
|
|
450
|
+
}
|
|
451
|
+
compiled._compiledContinueFromId = registry.register(continueFrom.paragraph, continueFrom.metadata);
|
|
452
|
+
binding.resolvedContinueFrom = continueFrom.metadata;
|
|
453
|
+
}
|
|
454
|
+
} catch (error) {
|
|
455
|
+
binding.error = normalizedError(error);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
bindings.push(binding);
|
|
459
|
+
compiledOperations.push(compiled);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const existingCaptureKeys = new Set(compiledOperations.map(operation => operation?.captureKey).filter(Boolean));
|
|
463
|
+
let nextImplicitCapture = 1;
|
|
464
|
+
for (const binding of bindings) {
|
|
465
|
+
if (binding.error?.code !== 'TARGET_NOT_FOUND') continue;
|
|
466
|
+
const consumerIndex = binding.index - 1;
|
|
467
|
+
const consumer = normalizeDocumentOperation(sourceOperations[consumerIndex]);
|
|
468
|
+
const targetText = consumer.targetDescriptor?.text;
|
|
469
|
+
if (!targetText) continue;
|
|
470
|
+
const producers = sourceOperations.flatMap((candidate, producerIndex) => {
|
|
471
|
+
if (producerIndex === consumerIndex || typeof candidate?.modified !== 'string') return [];
|
|
472
|
+
const createdParagraphs = candidate.modified.split(/\r?\n/).map(text => text.trim()).filter(Boolean);
|
|
473
|
+
return createdParagraphs.includes(targetText.trim()) ? [{ candidate, producerIndex }] : [];
|
|
474
|
+
});
|
|
475
|
+
if (producers.length !== 1) continue;
|
|
476
|
+
const [{ candidate: producer, producerIndex }] = producers;
|
|
477
|
+
let captureKey = producer.captureKey || compiledOperations[producerIndex].captureKey;
|
|
478
|
+
if (!captureKey) {
|
|
479
|
+
do {
|
|
480
|
+
captureKey = `__batch_created_${nextImplicitCapture++}`;
|
|
481
|
+
} while (existingCaptureKeys.has(captureKey));
|
|
482
|
+
existingCaptureKeys.add(captureKey);
|
|
483
|
+
compiledOperations[producerIndex].captureKey = captureKey;
|
|
484
|
+
}
|
|
485
|
+
compiledOperations[consumerIndex].target = { captureRef: captureKey, select: targetText };
|
|
486
|
+
delete compiledOperations[consumerIndex]._compiledSourceId;
|
|
487
|
+
delete compiledOperations[consumerIndex]._compiledResolvedBy;
|
|
488
|
+
delete binding.error;
|
|
489
|
+
binding.sourceIds = [];
|
|
490
|
+
binding.resolvedBy = 'created_content_dependency';
|
|
491
|
+
binding.createdByOperation = producerIndex + 1;
|
|
492
|
+
binding.captureRef = captureKey;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const bySource = new Map();
|
|
496
|
+
const rebindsBySource = new Map();
|
|
497
|
+
for (const binding of bindings) {
|
|
498
|
+
if (binding.error || binding.dynamic) continue;
|
|
499
|
+
for (const sourceId of binding.sourceIds) {
|
|
500
|
+
if (!bySource.has(sourceId)) bySource.set(sourceId, []);
|
|
501
|
+
bySource.get(sourceId).push(binding);
|
|
502
|
+
}
|
|
503
|
+
// A scope "list" change-format/join writes pPr on every live item of the target's list, so those items are
|
|
504
|
+
// registered as format writes of the operation: another write to them must compose or is refused.
|
|
505
|
+
if (binding.operationKind === 'list-format') {
|
|
506
|
+
const operation = compiledOperations[binding.index - 1];
|
|
507
|
+
for (const paragraph of listScopeRebindParagraphs(xmlDoc, registry, binding, operation)) {
|
|
508
|
+
const sourceId = registry.register(paragraph, metadataIndices.accepted.byParagraph.get(paragraph) || {});
|
|
509
|
+
if (binding.sourceIds.includes(sourceId)) continue;
|
|
510
|
+
(binding.rebindSourceIds ||= []).push(sourceId);
|
|
511
|
+
if (!bySource.has(sourceId)) bySource.set(sourceId, []);
|
|
512
|
+
if (!rebindsBySource.has(sourceId)) rebindsBySource.set(sourceId, []);
|
|
513
|
+
rebindsBySource.get(sourceId).push(binding);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const conflicts = [];
|
|
519
|
+
const conflictKeys = new Set();
|
|
520
|
+
const pushConflict = (key, conflict) => {
|
|
521
|
+
if (conflictKeys.has(key)) return;
|
|
522
|
+
conflictKeys.add(key);
|
|
523
|
+
conflicts.push(conflict);
|
|
524
|
+
};
|
|
525
|
+
for (const [sourceId, targetBindings] of bySource) {
|
|
526
|
+
const rebinds = rebindsBySource.get(sourceId) || [];
|
|
527
|
+
const writers = [...targetBindings, ...rebinds]
|
|
528
|
+
.filter(binding => TEXT_WRITE_KINDS.has(binding.operationKind) || FORMAT_WRITE_KINDS.has(binding.operationKind));
|
|
529
|
+
const listFormats = writers.filter(binding => binding.operationKind === 'list-format');
|
|
530
|
+
const others = writers.filter(binding => binding.operationKind !== 'list-format');
|
|
531
|
+
if (listFormats.length === 1 && others.length === 1 && others[0].operationKind === 'redline') {
|
|
532
|
+
const [listFormat] = listFormats;
|
|
533
|
+
const [redline] = others;
|
|
534
|
+
const describedSource = registry.describe(sourceId);
|
|
535
|
+
const incompatible = checkListFormatComposition(
|
|
536
|
+
{ operation: compiledOperations[redline.index - 1] },
|
|
537
|
+
redline.resolvedTarget?.text ?? describedSource?.text ?? ''
|
|
538
|
+
);
|
|
539
|
+
if (incompatible) {
|
|
540
|
+
const affected = [redline, listFormat];
|
|
541
|
+
pushConflict(`list:${affected.map(binding => binding.index).sort().join(',')}`, buildConflict(
|
|
542
|
+
LIST_FORMAT_COMPOSITION_CONFLICT,
|
|
543
|
+
`Operations ${affected.map(binding => binding.index).sort((a, b) => a - b).join(', ')} combine a redline and a list-format on the same paragraph in a way that does not compose (${incompatible.reason}).`,
|
|
544
|
+
affected,
|
|
545
|
+
describedSource,
|
|
546
|
+
'split_text_and_list_changes',
|
|
547
|
+
{ reason: incompatible.reason, ...(incompatible.sourceMarker !== undefined ? { sourceMarker: incompatible.sourceMarker, modifiedMarker: incompatible.modifiedMarker } : {}) },
|
|
548
|
+
incompatible.hint
|
|
549
|
+
));
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
// Deterministic order: the list-format runs after the redline, whatever the batch order.
|
|
553
|
+
const compiledListFormat = compiledOperations[listFormat.index - 1];
|
|
554
|
+
const runAfter = new Set(compiledListFormat._compiledRunAfter || []);
|
|
555
|
+
runAfter.add(redline.index - 1);
|
|
556
|
+
compiledListFormat._compiledRunAfter = [...runAfter].sort((a, b) => a - b);
|
|
557
|
+
listFormat.composedWith = [...new Set([...(listFormat.composedWith || []), redline.index])];
|
|
558
|
+
redline.composedWith = [...new Set([...(redline.composedWith || []), listFormat.index])];
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (rebinds.length > 0 && writers.length > 1) {
|
|
562
|
+
pushConflict(`rebind:${writers.map(binding => binding.index).sort().join(',')}`, buildConflict(
|
|
563
|
+
LIST_FORMAT_COMPOSITION_CONFLICT,
|
|
564
|
+
`Operation ${rebinds.map(binding => binding.index).join(', ')} (list-format, scope "list") rebinds this list item, and operations ${writers.filter(binding => !rebinds.includes(binding)).map(binding => binding.index).join(', ')} also write it.`,
|
|
565
|
+
writers,
|
|
566
|
+
registry.describe(sourceId),
|
|
567
|
+
'split_text_and_list_changes',
|
|
568
|
+
{ reason: 'list-rebind', rebindingOperationIndexes: rebinds.map(binding => binding.index) },
|
|
569
|
+
'A list-format with scope "list" rewrites every item of the list; in the same batch only one single-paragraph redline per item can compose with it. Send the other change in a separate apply, or use scope "range".'
|
|
570
|
+
));
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
const sourceBindings = targetBindings;
|
|
574
|
+
const textWrites = sourceBindings.filter(binding => TEXT_WRITE_KINDS.has(binding.operationKind));
|
|
575
|
+
const formatWrites = sourceBindings.filter(binding => FORMAT_WRITE_KINDS.has(binding.operationKind));
|
|
576
|
+
if (textWrites.length > 1) {
|
|
577
|
+
const key = `text:${textWrites.map(binding => binding.index).sort().join(',')}`;
|
|
578
|
+
if (!conflictKeys.has(key)) {
|
|
579
|
+
conflictKeys.add(key);
|
|
580
|
+
conflicts.push(buildConflict(
|
|
581
|
+
'OVERLAPPING_SOURCE_TARGETS',
|
|
582
|
+
`Operations ${textWrites.map(binding => binding.index).join(', ')} contain incompatible text writes to the same batch-start source target.`,
|
|
583
|
+
textWrites,
|
|
584
|
+
registry.describe(sourceId),
|
|
585
|
+
'consolidate_operations'
|
|
586
|
+
));
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (textWrites.length > 0 && formatWrites.length > 0) {
|
|
590
|
+
const affected = [...textWrites, ...formatWrites];
|
|
591
|
+
const key = `format:${affected.map(binding => binding.index).sort().join(',')}`;
|
|
592
|
+
if (!conflictKeys.has(key)) {
|
|
593
|
+
conflictKeys.add(key);
|
|
594
|
+
conflicts.push(buildConflict(
|
|
595
|
+
'REVISION_ORDER_CONFLICT',
|
|
596
|
+
`Operations ${affected.map(binding => binding.index).join(', ')} combine text and formatting writes on the same batch-start source target.`,
|
|
597
|
+
affected,
|
|
598
|
+
registry.describe(sourceId),
|
|
599
|
+
'split_or_consolidate_operations'
|
|
600
|
+
));
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
valid: conflicts.length === 0 && bindings.every(binding => !binding.error),
|
|
607
|
+
compiledOperations,
|
|
608
|
+
bindings,
|
|
609
|
+
conflicts,
|
|
610
|
+
registry
|
|
611
|
+
};
|
|
612
|
+
}
|