@ansonlai/docx-redline-js 0.8.3 → 0.8.4
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/CHANGELOG.md +627 -586
- package/README.md +905 -901
- package/dist/docx-redline-js.esm.js +771 -609
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +92 -89
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +771 -609
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +771 -609
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_KNOWLEDGE_BASE.md +4 -1
- package/docs/schemas/document-operations.schema.json +168 -168
- package/engine/oxml-engine.js +215 -195
- package/engine/reconstruction-mode.js +183 -183
- package/index.d.ts +4 -0
- package/index.js +36 -1
- package/node/cli.js +2 -1
- package/package.json +1 -1
- package/pipeline/serialization.js +111 -94
- package/services/batch-operation-orchestrator.js +6 -0
- package/services/document-operation-applier.js +2 -0
- package/services/document-operation-contract.js +553 -537
- package/services/document-operation-mutations.js +2721 -2603
- package/services/operation-batch-compiler.js +440 -436
- package/services/operation-heuristics.js +197 -173
- package/services/revision-comment-management.js +601 -594
- package/services/standalone-operation-runner.d.ts +429 -427
- package/services/table-reconciliation.js +48 -46
|
@@ -1,436 +1,440 @@
|
|
|
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
|
-
const TEXT_WRITE_KINDS = new Set(['redline', 'restore', 'rejected-insert']);
|
|
15
|
-
const FORMAT_WRITE_KINDS = new Set(['highlight', 'format', 'paragraph-format']);
|
|
16
|
-
|
|
17
|
-
function normalizedError(error) {
|
|
18
|
-
const details = {};
|
|
19
|
-
if (error && typeof error === 'object') {
|
|
20
|
-
for (const [key, value] of Object.entries(error)) {
|
|
21
|
-
if (key === 'name' || key === 'stack' || key === 'message' || key === 'code') continue;
|
|
22
|
-
details[key] = value;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
return {
|
|
26
|
-
code: typeof error?.code === 'string' && error.code ? error.code : 'OPERATION_ERROR',
|
|
27
|
-
message: error?.message || String(error),
|
|
28
|
-
...details
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function isParagraphAttached(document, paragraph) {
|
|
33
|
-
return !!paragraph && getDocumentParagraphNodes(document).includes(paragraph);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function paragraphNodesFromMutation(nodes = []) {
|
|
37
|
-
const paragraphs = [];
|
|
38
|
-
for (const node of nodes) {
|
|
39
|
-
if (!node || node.nodeType !== 1) continue;
|
|
40
|
-
if (node.localName === 'p') paragraphs.push(node);
|
|
41
|
-
for (const paragraph of Array.from(node.getElementsByTagNameNS?.('*', 'p') || [])) {
|
|
42
|
-
if (!paragraphs.includes(paragraph)) paragraphs.push(paragraph);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return paragraphs;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Session-local source identities over live paragraph nodes. */
|
|
49
|
-
export class SourceTargetRegistry {
|
|
50
|
-
constructor(document) {
|
|
51
|
-
this.document = document;
|
|
52
|
-
this.nextId = 1;
|
|
53
|
-
this.entries = new Map();
|
|
54
|
-
this.idsByNode = new WeakMap();
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
register(paragraph, metadata = {}) {
|
|
58
|
-
const existing = this.idsByNode.get(paragraph);
|
|
59
|
-
if (existing) return existing;
|
|
60
|
-
const sourceId = `S${this.nextId++}`;
|
|
61
|
-
this.entries.set(sourceId, {
|
|
62
|
-
sourceId,
|
|
63
|
-
paragraph,
|
|
64
|
-
sourceIndex: metadata.index || null,
|
|
65
|
-
paragraphId: metadata.paragraphId || null,
|
|
66
|
-
fingerprint: metadata.fingerprint || null,
|
|
67
|
-
text: metadata.text || '',
|
|
68
|
-
revisionView: metadata.revisionView === 'rejected' ? 'rejected' : 'accepted',
|
|
69
|
-
consumed: false,
|
|
70
|
-
consumedByOperation: null
|
|
71
|
-
});
|
|
72
|
-
this.idsByNode.set(paragraph, sourceId);
|
|
73
|
-
return sourceId;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
describe(sourceId) {
|
|
77
|
-
const entry = this.entries.get(sourceId);
|
|
78
|
-
if (!entry) return null;
|
|
79
|
-
return {
|
|
80
|
-
sourceId,
|
|
81
|
-
index: entry.sourceIndex,
|
|
82
|
-
paragraphId: entry.paragraphId,
|
|
83
|
-
fingerprint: entry.fingerprint,
|
|
84
|
-
text: entry.text,
|
|
85
|
-
revisionView: entry.revisionView
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
resolve(sourceId, document = this.document) {
|
|
90
|
-
const entry = this.entries.get(sourceId);
|
|
91
|
-
if (!entry) {
|
|
92
|
-
return {
|
|
93
|
-
error: {
|
|
94
|
-
code: 'SOURCE_TARGET_NOT_FOUND',
|
|
95
|
-
message: `Compiled source target ${String(sourceId)} was not found.`
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
if (entry.consumed) {
|
|
100
|
-
return {
|
|
101
|
-
error: {
|
|
102
|
-
code: 'TARGET_CONSUMED_BY_OPERATION',
|
|
103
|
-
message: `Compiled source target ${sourceId} was consumed by operation ${entry.consumedByOperation}.`,
|
|
104
|
-
sourceTarget: this.describe(sourceId),
|
|
105
|
-
consumedByOperation: entry.consumedByOperation
|
|
106
|
-
}
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
if (isParagraphAttached(document, entry.paragraph)) {
|
|
110
|
-
return { paragraph: entry.paragraph, resolvedBy: 'batch_source' };
|
|
111
|
-
}
|
|
112
|
-
return {
|
|
113
|
-
error: {
|
|
114
|
-
code: 'TARGET_CONSUMED_BY_OPERATION',
|
|
115
|
-
message: `Compiled source target ${sourceId} is no longer present in the live document.`,
|
|
116
|
-
sourceTarget: this.describe(sourceId),
|
|
117
|
-
consumedByOperation: entry.consumedByOperation
|
|
118
|
-
}
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
createSavepoint(document = this.document) {
|
|
123
|
-
const paragraphs = getDocumentParagraphNodes(document);
|
|
124
|
-
return new Map(Array.from(this.entries, ([sourceId, entry]) => [sourceId, {
|
|
125
|
-
paragraphIndex: paragraphs.indexOf(entry.paragraph),
|
|
126
|
-
consumed: entry.consumed,
|
|
127
|
-
consumedByOperation: entry.consumedByOperation
|
|
128
|
-
}]));
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
restoreSavepoint(document, snapshot) {
|
|
132
|
-
this.document = document;
|
|
133
|
-
this.idsByNode = new WeakMap();
|
|
134
|
-
const paragraphs = getDocumentParagraphNodes(document);
|
|
135
|
-
for (const [sourceId, entry] of this.entries) {
|
|
136
|
-
const saved = snapshot instanceof Map ? snapshot.get(sourceId) : null;
|
|
137
|
-
entry.consumed = saved?.consumed === true;
|
|
138
|
-
entry.consumedByOperation = saved?.consumedByOperation || null;
|
|
139
|
-
entry.paragraph = Number.isInteger(saved?.paragraphIndex) && saved.paragraphIndex >= 0
|
|
140
|
-
? (paragraphs[saved.paragraphIndex] || null)
|
|
141
|
-
: null;
|
|
142
|
-
if (entry.paragraph) this.idsByNode.set(entry.paragraph, sourceId);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
commitMutation(sourceId, removedNodes, liveNodes, operationIndex) {
|
|
147
|
-
const entry = this.entries.get(sourceId);
|
|
148
|
-
if (!entry || !Array.isArray(removedNodes) || !removedNodes.includes(entry.paragraph)) return;
|
|
149
|
-
const candidates = paragraphNodesFromMutation(liveNodes);
|
|
150
|
-
const sameId = entry.paragraphId
|
|
151
|
-
? candidates.filter(paragraph => getParagraphId(paragraph) === entry.paragraphId)
|
|
152
|
-
: [];
|
|
153
|
-
const successor = sameId.length === 1
|
|
154
|
-
? sameId[0]
|
|
155
|
-
: (candidates.length === 1 ? candidates[0] : null);
|
|
156
|
-
if (successor) {
|
|
157
|
-
entry.paragraph = successor;
|
|
158
|
-
this.idsByNode.set(successor, sourceId);
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
entry.paragraph = null;
|
|
162
|
-
entry.consumed = true;
|
|
163
|
-
entry.consumedByOperation = operationIndex;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function resolvedTarget(metadata, revisionView) {
|
|
168
|
-
return {
|
|
169
|
-
index: metadata.index,
|
|
170
|
-
paragraphId: metadata.paragraphId,
|
|
171
|
-
fingerprint: metadata.fingerprint,
|
|
172
|
-
text: metadata.text,
|
|
173
|
-
inTable: metadata.inTable,
|
|
174
|
-
revisionView
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function resolveDescriptor(xmlDoc, descriptor, kind, strictTargets, metadataIndices, callbacks = {}) {
|
|
179
|
-
const revisionView = descriptor?.revisionView === 'rejected' ? 'rejected' : 'accepted';
|
|
180
|
-
if (!metadataIndices[revisionView]) {
|
|
181
|
-
metadataIndices[revisionView] = buildParagraphMetadataIndex(xmlDoc, { revisionView });
|
|
182
|
-
}
|
|
183
|
-
const index = metadataIndices[revisionView];
|
|
184
|
-
const resolution = resolveTargetParagraph(xmlDoc, {
|
|
185
|
-
targetText: descriptor.text,
|
|
186
|
-
targetRef: descriptor.index,
|
|
187
|
-
targetDescriptor: descriptor,
|
|
188
|
-
opType: kind,
|
|
189
|
-
strictAmbiguity: strictTargets,
|
|
190
|
-
paragraphMetadataIndex: index,
|
|
191
|
-
metadataIndices,
|
|
192
|
-
onInfo: callbacks.onInfo,
|
|
193
|
-
onWarn: callbacks.onWarn
|
|
194
|
-
});
|
|
195
|
-
const metadata = index.byParagraph.get(resolution.paragraph);
|
|
196
|
-
return {
|
|
197
|
-
paragraph: resolution.paragraph,
|
|
198
|
-
resolvedBy: resolution.resolvedBy,
|
|
199
|
-
metadata: resolvedTarget(metadata, revisionView)
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function buildConflict(code, message, bindings, target, action) {
|
|
204
|
-
return {
|
|
205
|
-
code,
|
|
206
|
-
message,
|
|
207
|
-
operationIndexes: bindings.map(binding => binding.index).sort((a, b) => a - b),
|
|
208
|
-
target,
|
|
209
|
-
recovery: {
|
|
210
|
-
action,
|
|
211
|
-
sameArgumentsSafe: false,
|
|
212
|
-
requiresReinspection: false,
|
|
213
|
-
requiresUserAuthorization: false
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Resolve source targets once and derive source-level conflicts before mutation. */
|
|
219
|
-
export function compileOperationBatch(xmlDoc, operations = [], options = {}) {
|
|
220
|
-
const sourceOperations = Array.isArray(operations) ? operations : [];
|
|
221
|
-
const strictTargets = options.strictTargets !== false;
|
|
222
|
-
const registry = new SourceTargetRegistry(xmlDoc);
|
|
223
|
-
const metadataIndices = {
|
|
224
|
-
accepted: buildParagraphMetadataIndex(xmlDoc, { revisionView: 'accepted' }),
|
|
225
|
-
rejected: null
|
|
226
|
-
};
|
|
227
|
-
const bindings = [];
|
|
228
|
-
const compiledOperations = [];
|
|
229
|
-
|
|
230
|
-
for (let index = 0; index < sourceOperations.length; index += 1) {
|
|
231
|
-
const sourceOperation = sourceOperations[index];
|
|
232
|
-
const validation = validateDocumentOperation(sourceOperation);
|
|
233
|
-
const operation = validation.operation || normalizeDocumentOperation(sourceOperation);
|
|
234
|
-
const binding = {
|
|
235
|
-
index: index + 1,
|
|
236
|
-
operationKind: operation.operationKind,
|
|
237
|
-
operationId: operation.operationId,
|
|
238
|
-
sourceIds: [],
|
|
239
|
-
warnings: []
|
|
240
|
-
};
|
|
241
|
-
const compiled = { ...sourceOperation };
|
|
242
|
-
|
|
243
|
-
const dynamicOccurrence = operation.targetDescriptor?.occurrence != null
|
|
244
|
-
&& !operation.targetDescriptor?.paragraphId
|
|
245
|
-
&& !operation.targetDescriptor?.fingerprint;
|
|
246
|
-
const compositeTextTarget = typeof operation.targetDescriptor?.text === 'string'
|
|
247
|
-
&& /\r|\n/.test(operation.targetDescriptor.text)
|
|
248
|
-
&& !operation.targetEndDescriptor
|
|
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
|
-
const
|
|
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
|
-
if (
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
binding.
|
|
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
|
+
|
|
14
|
+
const TEXT_WRITE_KINDS = new Set(['redline', 'restore', 'rejected-insert']);
|
|
15
|
+
const FORMAT_WRITE_KINDS = new Set(['highlight', 'format', 'paragraph-format']);
|
|
16
|
+
|
|
17
|
+
function normalizedError(error) {
|
|
18
|
+
const details = {};
|
|
19
|
+
if (error && typeof error === 'object') {
|
|
20
|
+
for (const [key, value] of Object.entries(error)) {
|
|
21
|
+
if (key === 'name' || key === 'stack' || key === 'message' || key === 'code') continue;
|
|
22
|
+
details[key] = value;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
code: typeof error?.code === 'string' && error.code ? error.code : 'OPERATION_ERROR',
|
|
27
|
+
message: error?.message || String(error),
|
|
28
|
+
...details
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isParagraphAttached(document, paragraph) {
|
|
33
|
+
return !!paragraph && getDocumentParagraphNodes(document).includes(paragraph);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function paragraphNodesFromMutation(nodes = []) {
|
|
37
|
+
const paragraphs = [];
|
|
38
|
+
for (const node of nodes) {
|
|
39
|
+
if (!node || node.nodeType !== 1) continue;
|
|
40
|
+
if (node.localName === 'p') paragraphs.push(node);
|
|
41
|
+
for (const paragraph of Array.from(node.getElementsByTagNameNS?.('*', 'p') || [])) {
|
|
42
|
+
if (!paragraphs.includes(paragraph)) paragraphs.push(paragraph);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return paragraphs;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Session-local source identities over live paragraph nodes. */
|
|
49
|
+
export class SourceTargetRegistry {
|
|
50
|
+
constructor(document) {
|
|
51
|
+
this.document = document;
|
|
52
|
+
this.nextId = 1;
|
|
53
|
+
this.entries = new Map();
|
|
54
|
+
this.idsByNode = new WeakMap();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
register(paragraph, metadata = {}) {
|
|
58
|
+
const existing = this.idsByNode.get(paragraph);
|
|
59
|
+
if (existing) return existing;
|
|
60
|
+
const sourceId = `S${this.nextId++}`;
|
|
61
|
+
this.entries.set(sourceId, {
|
|
62
|
+
sourceId,
|
|
63
|
+
paragraph,
|
|
64
|
+
sourceIndex: metadata.index || null,
|
|
65
|
+
paragraphId: metadata.paragraphId || null,
|
|
66
|
+
fingerprint: metadata.fingerprint || null,
|
|
67
|
+
text: metadata.text || '',
|
|
68
|
+
revisionView: metadata.revisionView === 'rejected' ? 'rejected' : 'accepted',
|
|
69
|
+
consumed: false,
|
|
70
|
+
consumedByOperation: null
|
|
71
|
+
});
|
|
72
|
+
this.idsByNode.set(paragraph, sourceId);
|
|
73
|
+
return sourceId;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe(sourceId) {
|
|
77
|
+
const entry = this.entries.get(sourceId);
|
|
78
|
+
if (!entry) return null;
|
|
79
|
+
return {
|
|
80
|
+
sourceId,
|
|
81
|
+
index: entry.sourceIndex,
|
|
82
|
+
paragraphId: entry.paragraphId,
|
|
83
|
+
fingerprint: entry.fingerprint,
|
|
84
|
+
text: entry.text,
|
|
85
|
+
revisionView: entry.revisionView
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
resolve(sourceId, document = this.document) {
|
|
90
|
+
const entry = this.entries.get(sourceId);
|
|
91
|
+
if (!entry) {
|
|
92
|
+
return {
|
|
93
|
+
error: {
|
|
94
|
+
code: 'SOURCE_TARGET_NOT_FOUND',
|
|
95
|
+
message: `Compiled source target ${String(sourceId)} was not found.`
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (entry.consumed) {
|
|
100
|
+
return {
|
|
101
|
+
error: {
|
|
102
|
+
code: 'TARGET_CONSUMED_BY_OPERATION',
|
|
103
|
+
message: `Compiled source target ${sourceId} was consumed by operation ${entry.consumedByOperation}.`,
|
|
104
|
+
sourceTarget: this.describe(sourceId),
|
|
105
|
+
consumedByOperation: entry.consumedByOperation
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (isParagraphAttached(document, entry.paragraph)) {
|
|
110
|
+
return { paragraph: entry.paragraph, resolvedBy: 'batch_source' };
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
error: {
|
|
114
|
+
code: 'TARGET_CONSUMED_BY_OPERATION',
|
|
115
|
+
message: `Compiled source target ${sourceId} is no longer present in the live document.`,
|
|
116
|
+
sourceTarget: this.describe(sourceId),
|
|
117
|
+
consumedByOperation: entry.consumedByOperation
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
createSavepoint(document = this.document) {
|
|
123
|
+
const paragraphs = getDocumentParagraphNodes(document);
|
|
124
|
+
return new Map(Array.from(this.entries, ([sourceId, entry]) => [sourceId, {
|
|
125
|
+
paragraphIndex: paragraphs.indexOf(entry.paragraph),
|
|
126
|
+
consumed: entry.consumed,
|
|
127
|
+
consumedByOperation: entry.consumedByOperation
|
|
128
|
+
}]));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
restoreSavepoint(document, snapshot) {
|
|
132
|
+
this.document = document;
|
|
133
|
+
this.idsByNode = new WeakMap();
|
|
134
|
+
const paragraphs = getDocumentParagraphNodes(document);
|
|
135
|
+
for (const [sourceId, entry] of this.entries) {
|
|
136
|
+
const saved = snapshot instanceof Map ? snapshot.get(sourceId) : null;
|
|
137
|
+
entry.consumed = saved?.consumed === true;
|
|
138
|
+
entry.consumedByOperation = saved?.consumedByOperation || null;
|
|
139
|
+
entry.paragraph = Number.isInteger(saved?.paragraphIndex) && saved.paragraphIndex >= 0
|
|
140
|
+
? (paragraphs[saved.paragraphIndex] || null)
|
|
141
|
+
: null;
|
|
142
|
+
if (entry.paragraph) this.idsByNode.set(entry.paragraph, sourceId);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
commitMutation(sourceId, removedNodes, liveNodes, operationIndex) {
|
|
147
|
+
const entry = this.entries.get(sourceId);
|
|
148
|
+
if (!entry || !Array.isArray(removedNodes) || !removedNodes.includes(entry.paragraph)) return;
|
|
149
|
+
const candidates = paragraphNodesFromMutation(liveNodes);
|
|
150
|
+
const sameId = entry.paragraphId
|
|
151
|
+
? candidates.filter(paragraph => getParagraphId(paragraph) === entry.paragraphId)
|
|
152
|
+
: [];
|
|
153
|
+
const successor = sameId.length === 1
|
|
154
|
+
? sameId[0]
|
|
155
|
+
: (candidates.length === 1 ? candidates[0] : null);
|
|
156
|
+
if (successor) {
|
|
157
|
+
entry.paragraph = successor;
|
|
158
|
+
this.idsByNode.set(successor, sourceId);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
entry.paragraph = null;
|
|
162
|
+
entry.consumed = true;
|
|
163
|
+
entry.consumedByOperation = operationIndex;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function resolvedTarget(metadata, revisionView) {
|
|
168
|
+
return {
|
|
169
|
+
index: metadata.index,
|
|
170
|
+
paragraphId: metadata.paragraphId,
|
|
171
|
+
fingerprint: metadata.fingerprint,
|
|
172
|
+
text: metadata.text,
|
|
173
|
+
inTable: metadata.inTable,
|
|
174
|
+
revisionView
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function resolveDescriptor(xmlDoc, descriptor, kind, strictTargets, metadataIndices, callbacks = {}) {
|
|
179
|
+
const revisionView = descriptor?.revisionView === 'rejected' ? 'rejected' : 'accepted';
|
|
180
|
+
if (!metadataIndices[revisionView]) {
|
|
181
|
+
metadataIndices[revisionView] = buildParagraphMetadataIndex(xmlDoc, { revisionView });
|
|
182
|
+
}
|
|
183
|
+
const index = metadataIndices[revisionView];
|
|
184
|
+
const resolution = resolveTargetParagraph(xmlDoc, {
|
|
185
|
+
targetText: descriptor.text,
|
|
186
|
+
targetRef: descriptor.index,
|
|
187
|
+
targetDescriptor: descriptor,
|
|
188
|
+
opType: kind,
|
|
189
|
+
strictAmbiguity: strictTargets,
|
|
190
|
+
paragraphMetadataIndex: index,
|
|
191
|
+
metadataIndices,
|
|
192
|
+
onInfo: callbacks.onInfo,
|
|
193
|
+
onWarn: callbacks.onWarn
|
|
194
|
+
});
|
|
195
|
+
const metadata = index.byParagraph.get(resolution.paragraph);
|
|
196
|
+
return {
|
|
197
|
+
paragraph: resolution.paragraph,
|
|
198
|
+
resolvedBy: resolution.resolvedBy,
|
|
199
|
+
metadata: resolvedTarget(metadata, revisionView)
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function buildConflict(code, message, bindings, target, action) {
|
|
204
|
+
return {
|
|
205
|
+
code,
|
|
206
|
+
message,
|
|
207
|
+
operationIndexes: bindings.map(binding => binding.index).sort((a, b) => a - b),
|
|
208
|
+
target,
|
|
209
|
+
recovery: {
|
|
210
|
+
action,
|
|
211
|
+
sameArgumentsSafe: false,
|
|
212
|
+
requiresReinspection: false,
|
|
213
|
+
requiresUserAuthorization: false
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Resolve source targets once and derive source-level conflicts before mutation. */
|
|
219
|
+
export function compileOperationBatch(xmlDoc, operations = [], options = {}) {
|
|
220
|
+
const sourceOperations = Array.isArray(operations) ? operations : [];
|
|
221
|
+
const strictTargets = options.strictTargets !== false;
|
|
222
|
+
const registry = new SourceTargetRegistry(xmlDoc);
|
|
223
|
+
const metadataIndices = {
|
|
224
|
+
accepted: buildParagraphMetadataIndex(xmlDoc, { revisionView: 'accepted' }),
|
|
225
|
+
rejected: null
|
|
226
|
+
};
|
|
227
|
+
const bindings = [];
|
|
228
|
+
const compiledOperations = [];
|
|
229
|
+
|
|
230
|
+
for (let index = 0; index < sourceOperations.length; index += 1) {
|
|
231
|
+
const sourceOperation = sourceOperations[index];
|
|
232
|
+
const validation = validateDocumentOperation(sourceOperation);
|
|
233
|
+
const operation = validation.operation || normalizeDocumentOperation(sourceOperation);
|
|
234
|
+
const binding = {
|
|
235
|
+
index: index + 1,
|
|
236
|
+
operationKind: operation.operationKind,
|
|
237
|
+
operationId: operation.operationId,
|
|
238
|
+
sourceIds: [],
|
|
239
|
+
warnings: []
|
|
240
|
+
};
|
|
241
|
+
const compiled = { ...sourceOperation };
|
|
242
|
+
|
|
243
|
+
const dynamicOccurrence = operation.targetDescriptor?.occurrence != null
|
|
244
|
+
&& !operation.targetDescriptor?.paragraphId
|
|
245
|
+
&& !operation.targetDescriptor?.fingerprint;
|
|
246
|
+
const compositeTextTarget = typeof operation.targetDescriptor?.text === 'string'
|
|
247
|
+
&& /\r|\n/.test(operation.targetDescriptor.text)
|
|
248
|
+
&& !operation.targetEndDescriptor
|
|
249
|
+
// A strong paragraph target with localized replacements is one paragraph whose
|
|
250
|
+
// soft line breaks (w:br) appear as "\n" in text; it still binds at batch start.
|
|
251
|
+
&& !(Array.isArray(operation.replacements)
|
|
252
|
+
&& (operation.targetDescriptor.paragraphId || operation.targetDescriptor.fingerprint));
|
|
253
|
+
if (dynamicOccurrence || compositeTextTarget) binding.dynamic = true;
|
|
254
|
+
|
|
255
|
+
if (
|
|
256
|
+
validation.valid
|
|
257
|
+
&& operation.operationKind !== 'comment_reply'
|
|
258
|
+
&& operation.operationKind !== 'comment_resolve'
|
|
259
|
+
&& !operation.targetDescriptor?.captureRef
|
|
260
|
+
&& !binding.dynamic
|
|
261
|
+
) {
|
|
262
|
+
try {
|
|
263
|
+
const start = resolveDescriptor(
|
|
264
|
+
xmlDoc,
|
|
265
|
+
operation.targetDescriptor,
|
|
266
|
+
operation.operationKind,
|
|
267
|
+
strictTargets,
|
|
268
|
+
metadataIndices,
|
|
269
|
+
{
|
|
270
|
+
onInfo: options.onInfo,
|
|
271
|
+
onWarn: warning => binding.warnings.push(String(warning))
|
|
272
|
+
}
|
|
273
|
+
);
|
|
274
|
+
const startId = registry.register(start.paragraph, start.metadata);
|
|
275
|
+
binding.sourceIds.push(startId);
|
|
276
|
+
binding.resolvedBy = start.resolvedBy;
|
|
277
|
+
binding.resolvedTarget = start.metadata;
|
|
278
|
+
compiled._compiledSourceId = startId;
|
|
279
|
+
compiled._compiledResolvedBy = start.resolvedBy;
|
|
280
|
+
if (binding.warnings.length > 0) compiled._compiledWarnings = binding.warnings;
|
|
281
|
+
|
|
282
|
+
if (Array.isArray(operation.replacements)) {
|
|
283
|
+
const patchCompilation = compileExactReplacements(
|
|
284
|
+
start.metadata.text,
|
|
285
|
+
operation.replacements
|
|
286
|
+
);
|
|
287
|
+
if (!patchCompilation.ok) {
|
|
288
|
+
throw Object.assign(new Error(patchCompilation.error.message), patchCompilation.error);
|
|
289
|
+
}
|
|
290
|
+
compiled.modified = patchCompilation.desiredText;
|
|
291
|
+
delete compiled.replacements;
|
|
292
|
+
compiled._localizedReplacementCompilation = {
|
|
293
|
+
sourceText: start.metadata.text,
|
|
294
|
+
desiredText: patchCompilation.desiredText,
|
|
295
|
+
replacements: patchCompilation.replacements
|
|
296
|
+
};
|
|
297
|
+
const canonicalValidation = validateDocumentOperation(compiled);
|
|
298
|
+
if (!canonicalValidation.valid) {
|
|
299
|
+
throw Object.assign(
|
|
300
|
+
new Error(canonicalValidation.error.message),
|
|
301
|
+
canonicalValidation.error
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
} else if (operation.operationKind === 'restore' && sourceOperation.modified === undefined) {
|
|
305
|
+
compiled.modified = start.metadata.text;
|
|
306
|
+
const canonicalValidation = validateDocumentOperation(compiled);
|
|
307
|
+
if (!canonicalValidation.valid) {
|
|
308
|
+
throw Object.assign(
|
|
309
|
+
new Error(canonicalValidation.error.message),
|
|
310
|
+
canonicalValidation.error
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const targetEndDescriptor = operation.targetEndDescriptor
|
|
316
|
+
|| (operation.targetEndRef != null
|
|
317
|
+
? normalizeTargetDescriptor(null, operation.targetEndRef, operation.targetDescriptor.revisionView)
|
|
318
|
+
: null);
|
|
319
|
+
if (targetEndDescriptor) {
|
|
320
|
+
const end = resolveDescriptor(
|
|
321
|
+
xmlDoc,
|
|
322
|
+
targetEndDescriptor,
|
|
323
|
+
operation.operationKind,
|
|
324
|
+
strictTargets,
|
|
325
|
+
metadataIndices,
|
|
326
|
+
{
|
|
327
|
+
onInfo: options.onInfo,
|
|
328
|
+
onWarn: warning => binding.warnings.push(String(warning))
|
|
329
|
+
}
|
|
330
|
+
);
|
|
331
|
+
const endId = registry.register(end.paragraph, end.metadata);
|
|
332
|
+
compiled._compiledSourceEndId = endId;
|
|
333
|
+
const paragraphs = getDocumentParagraphNodes(xmlDoc);
|
|
334
|
+
const startIndex = paragraphs.indexOf(start.paragraph);
|
|
335
|
+
const endIndex = paragraphs.indexOf(end.paragraph);
|
|
336
|
+
if (startIndex < 0 || endIndex < startIndex) {
|
|
337
|
+
throw Object.assign(new Error('Target range is not a forward contiguous paragraph range.'), {
|
|
338
|
+
code: 'TARGET_RANGE_INVALID'
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
for (const paragraph of paragraphs.slice(startIndex, endIndex + 1)) {
|
|
342
|
+
const viewIndex = metadataIndices[operation.targetDescriptor.revisionView] || metadataIndices.accepted;
|
|
343
|
+
const metadata = viewIndex.byParagraph.get(paragraph);
|
|
344
|
+
binding.sourceIds.push(registry.register(paragraph, metadata || {}));
|
|
345
|
+
}
|
|
346
|
+
binding.sourceIds = [...new Set(binding.sourceIds)];
|
|
347
|
+
binding.resolvedTargetEnd = end.metadata;
|
|
348
|
+
}
|
|
349
|
+
} catch (error) {
|
|
350
|
+
binding.error = normalizedError(error);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
bindings.push(binding);
|
|
354
|
+
compiledOperations.push(compiled);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const existingCaptureKeys = new Set(compiledOperations.map(operation => operation?.captureKey).filter(Boolean));
|
|
358
|
+
let nextImplicitCapture = 1;
|
|
359
|
+
for (const binding of bindings) {
|
|
360
|
+
if (binding.error?.code !== 'TARGET_NOT_FOUND') continue;
|
|
361
|
+
const consumerIndex = binding.index - 1;
|
|
362
|
+
const consumer = normalizeDocumentOperation(sourceOperations[consumerIndex]);
|
|
363
|
+
const targetText = consumer.targetDescriptor?.text;
|
|
364
|
+
if (!targetText) continue;
|
|
365
|
+
const producers = sourceOperations.flatMap((candidate, producerIndex) => {
|
|
366
|
+
if (producerIndex === consumerIndex || typeof candidate?.modified !== 'string') return [];
|
|
367
|
+
const createdParagraphs = candidate.modified.split(/\r?\n/).map(text => text.trim()).filter(Boolean);
|
|
368
|
+
return createdParagraphs.includes(targetText.trim()) ? [{ candidate, producerIndex }] : [];
|
|
369
|
+
});
|
|
370
|
+
if (producers.length !== 1) continue;
|
|
371
|
+
const [{ candidate: producer, producerIndex }] = producers;
|
|
372
|
+
let captureKey = producer.captureKey || compiledOperations[producerIndex].captureKey;
|
|
373
|
+
if (!captureKey) {
|
|
374
|
+
do {
|
|
375
|
+
captureKey = `__batch_created_${nextImplicitCapture++}`;
|
|
376
|
+
} while (existingCaptureKeys.has(captureKey));
|
|
377
|
+
existingCaptureKeys.add(captureKey);
|
|
378
|
+
compiledOperations[producerIndex].captureKey = captureKey;
|
|
379
|
+
}
|
|
380
|
+
compiledOperations[consumerIndex].target = { captureRef: captureKey, select: targetText };
|
|
381
|
+
delete compiledOperations[consumerIndex]._compiledSourceId;
|
|
382
|
+
delete compiledOperations[consumerIndex]._compiledResolvedBy;
|
|
383
|
+
delete binding.error;
|
|
384
|
+
binding.sourceIds = [];
|
|
385
|
+
binding.resolvedBy = 'created_content_dependency';
|
|
386
|
+
binding.createdByOperation = producerIndex + 1;
|
|
387
|
+
binding.captureRef = captureKey;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const bySource = new Map();
|
|
391
|
+
for (const binding of bindings) {
|
|
392
|
+
if (binding.error || binding.dynamic) continue;
|
|
393
|
+
for (const sourceId of binding.sourceIds) {
|
|
394
|
+
if (!bySource.has(sourceId)) bySource.set(sourceId, []);
|
|
395
|
+
bySource.get(sourceId).push(binding);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const conflicts = [];
|
|
400
|
+
const conflictKeys = new Set();
|
|
401
|
+
for (const [sourceId, sourceBindings] of bySource) {
|
|
402
|
+
const textWrites = sourceBindings.filter(binding => TEXT_WRITE_KINDS.has(binding.operationKind));
|
|
403
|
+
const formatWrites = sourceBindings.filter(binding => FORMAT_WRITE_KINDS.has(binding.operationKind));
|
|
404
|
+
if (textWrites.length > 1) {
|
|
405
|
+
const key = `text:${textWrites.map(binding => binding.index).sort().join(',')}`;
|
|
406
|
+
if (!conflictKeys.has(key)) {
|
|
407
|
+
conflictKeys.add(key);
|
|
408
|
+
conflicts.push(buildConflict(
|
|
409
|
+
'OVERLAPPING_SOURCE_TARGETS',
|
|
410
|
+
`Operations ${textWrites.map(binding => binding.index).join(', ')} contain incompatible text writes to the same batch-start source target.`,
|
|
411
|
+
textWrites,
|
|
412
|
+
registry.describe(sourceId),
|
|
413
|
+
'consolidate_operations'
|
|
414
|
+
));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (textWrites.length > 0 && formatWrites.length > 0) {
|
|
418
|
+
const affected = [...textWrites, ...formatWrites];
|
|
419
|
+
const key = `format:${affected.map(binding => binding.index).sort().join(',')}`;
|
|
420
|
+
if (!conflictKeys.has(key)) {
|
|
421
|
+
conflictKeys.add(key);
|
|
422
|
+
conflicts.push(buildConflict(
|
|
423
|
+
'REVISION_ORDER_CONFLICT',
|
|
424
|
+
`Operations ${affected.map(binding => binding.index).join(', ')} combine text and formatting writes on the same batch-start source target.`,
|
|
425
|
+
affected,
|
|
426
|
+
registry.describe(sourceId),
|
|
427
|
+
'split_or_consolidate_operations'
|
|
428
|
+
));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
valid: conflicts.length === 0 && bindings.every(binding => !binding.error),
|
|
435
|
+
compiledOperations,
|
|
436
|
+
bindings,
|
|
437
|
+
conflicts,
|
|
438
|
+
registry
|
|
439
|
+
};
|
|
440
|
+
}
|