@ansonlai/docx-redline-js 0.5.4 → 0.6.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/AGENTS.md +78 -697
- package/ARCHITECTURE.md +13 -1
- package/CHANGELOG.md +5 -0
- package/README.md +140 -30
- package/core/paragraph-targeting.js +14 -2
- package/dist/docx-redline-js.esm.js +113 -33
- package/dist/docx-redline-js.esm.js.map +3 -3
- package/dist/docx-redline-js.esm.min.js +75 -75
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/AGENT_FAST_START.md +59 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +868 -0
- package/docs/TESTING.md +20 -1
- package/docs/schemas/document-operations.schema.json +5 -1
- package/docs/validation-reports/2026-09-12-agent-protocol-rollout.md +82 -0
- package/engine/oxml-engine.js +80 -13
- package/engine/run-builders.js +5 -15
- package/index.d.ts +17 -1
- package/node/cli.js +111 -27
- package/node/docx-document.js +120 -69
- package/node/index.d.ts +6 -2
- package/package.json +10 -3
- package/scripts/generate-cross-author-slicing-fixtures.ps1 +25 -25
- package/services/batch-operation-orchestrator.js +215 -120
- package/services/document-inspection.js +5 -3
- package/services/document-operation-applier.js +52 -34
- package/services/document-operation-contract.js +10 -6
- package/services/document-operation-mutations.js +51 -5
- package/services/document-operation-session.js +4 -0
- package/services/error-recovery.js +174 -0
- package/services/operation-batch-compiler.js +394 -0
- package/services/operation-preflight.js +91 -72
- package/services/standalone-operation-runner.d.ts +17 -1
- package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +0 -1669
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +0 -1399
- package/docs/plans/completed/2026-03-01-release-0.1.4-design.md +0 -33
- package/docs/plans/completed/2026-03-01-release-0.1.4.md +0 -110
- package/docs/plans/completed/2026-05-31-architectural changes.md +0 -593
- package/docs/plans/completed/2026-08-02-reliability-improvements.md +0 -1155
- package/docs/plans/completed/2026-08-30-reliability-testing-improvements.md +0 -488
- package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +0 -669
- package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +0 -427
- package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +0 -519
- package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +0 -69
- package/docs/plans/completed/structural-revision-capability-matrix.md +0 -115
- package/docs/test-comparison-dashboard.html +0 -4338
- package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +0 -22
- package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +0 -24
- package/docs/validation-reports/2026-08-30-phase-3-coverage.md +0 -73
- package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +0 -82
- package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +0 -114
- package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +0 -79
|
@@ -0,0 +1,394 @@
|
|
|
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
|
+
|
|
13
|
+
const TEXT_WRITE_KINDS = new Set(['redline', 'restore', 'rejected-insert']);
|
|
14
|
+
const FORMAT_WRITE_KINDS = new Set(['highlight', 'format', 'paragraph-format']);
|
|
15
|
+
|
|
16
|
+
function normalizedError(error) {
|
|
17
|
+
return {
|
|
18
|
+
code: typeof error?.code === 'string' && error.code ? error.code : 'OPERATION_ERROR',
|
|
19
|
+
message: error?.message || String(error),
|
|
20
|
+
...(Array.isArray(error?.candidates) ? { candidates: error.candidates } : {})
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isParagraphAttached(document, paragraph) {
|
|
25
|
+
return !!paragraph && getDocumentParagraphNodes(document).includes(paragraph);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function paragraphNodesFromMutation(nodes = []) {
|
|
29
|
+
const paragraphs = [];
|
|
30
|
+
for (const node of nodes) {
|
|
31
|
+
if (!node || node.nodeType !== 1) continue;
|
|
32
|
+
if (node.localName === 'p') paragraphs.push(node);
|
|
33
|
+
for (const paragraph of Array.from(node.getElementsByTagNameNS?.('*', 'p') || [])) {
|
|
34
|
+
if (!paragraphs.includes(paragraph)) paragraphs.push(paragraph);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return paragraphs;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Session-local source identities over live paragraph nodes. */
|
|
41
|
+
export class SourceTargetRegistry {
|
|
42
|
+
constructor(document) {
|
|
43
|
+
this.document = document;
|
|
44
|
+
this.nextId = 1;
|
|
45
|
+
this.entries = new Map();
|
|
46
|
+
this.idsByNode = new WeakMap();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
register(paragraph, metadata = {}) {
|
|
50
|
+
const existing = this.idsByNode.get(paragraph);
|
|
51
|
+
if (existing) return existing;
|
|
52
|
+
const sourceId = `S${this.nextId++}`;
|
|
53
|
+
this.entries.set(sourceId, {
|
|
54
|
+
sourceId,
|
|
55
|
+
paragraph,
|
|
56
|
+
sourceIndex: metadata.index || null,
|
|
57
|
+
paragraphId: metadata.paragraphId || null,
|
|
58
|
+
fingerprint: metadata.fingerprint || null,
|
|
59
|
+
text: metadata.text || '',
|
|
60
|
+
revisionView: metadata.revisionView === 'rejected' ? 'rejected' : 'accepted',
|
|
61
|
+
consumed: false,
|
|
62
|
+
consumedByOperation: null
|
|
63
|
+
});
|
|
64
|
+
this.idsByNode.set(paragraph, sourceId);
|
|
65
|
+
return sourceId;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe(sourceId) {
|
|
69
|
+
const entry = this.entries.get(sourceId);
|
|
70
|
+
if (!entry) return null;
|
|
71
|
+
return {
|
|
72
|
+
sourceId,
|
|
73
|
+
index: entry.sourceIndex,
|
|
74
|
+
paragraphId: entry.paragraphId,
|
|
75
|
+
fingerprint: entry.fingerprint,
|
|
76
|
+
text: entry.text,
|
|
77
|
+
revisionView: entry.revisionView
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
resolve(sourceId, document = this.document) {
|
|
82
|
+
const entry = this.entries.get(sourceId);
|
|
83
|
+
if (!entry) {
|
|
84
|
+
return {
|
|
85
|
+
error: {
|
|
86
|
+
code: 'SOURCE_TARGET_NOT_FOUND',
|
|
87
|
+
message: `Compiled source target ${String(sourceId)} was not found.`
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (entry.consumed) {
|
|
92
|
+
return {
|
|
93
|
+
error: {
|
|
94
|
+
code: 'TARGET_CONSUMED_BY_OPERATION',
|
|
95
|
+
message: `Compiled source target ${sourceId} was consumed by operation ${entry.consumedByOperation}.`,
|
|
96
|
+
sourceTarget: this.describe(sourceId),
|
|
97
|
+
consumedByOperation: entry.consumedByOperation
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (isParagraphAttached(document, entry.paragraph)) {
|
|
102
|
+
return { paragraph: entry.paragraph, resolvedBy: 'batch_source' };
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
error: {
|
|
106
|
+
code: 'TARGET_CONSUMED_BY_OPERATION',
|
|
107
|
+
message: `Compiled source target ${sourceId} is no longer present in the live document.`,
|
|
108
|
+
sourceTarget: this.describe(sourceId),
|
|
109
|
+
consumedByOperation: entry.consumedByOperation
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
createSavepoint(document = this.document) {
|
|
115
|
+
const paragraphs = getDocumentParagraphNodes(document);
|
|
116
|
+
return new Map(Array.from(this.entries, ([sourceId, entry]) => [sourceId, {
|
|
117
|
+
paragraphIndex: paragraphs.indexOf(entry.paragraph),
|
|
118
|
+
consumed: entry.consumed,
|
|
119
|
+
consumedByOperation: entry.consumedByOperation
|
|
120
|
+
}]));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
restoreSavepoint(document, snapshot) {
|
|
124
|
+
this.document = document;
|
|
125
|
+
this.idsByNode = new WeakMap();
|
|
126
|
+
const paragraphs = getDocumentParagraphNodes(document);
|
|
127
|
+
for (const [sourceId, entry] of this.entries) {
|
|
128
|
+
const saved = snapshot instanceof Map ? snapshot.get(sourceId) : null;
|
|
129
|
+
entry.consumed = saved?.consumed === true;
|
|
130
|
+
entry.consumedByOperation = saved?.consumedByOperation || null;
|
|
131
|
+
entry.paragraph = Number.isInteger(saved?.paragraphIndex) && saved.paragraphIndex >= 0
|
|
132
|
+
? (paragraphs[saved.paragraphIndex] || null)
|
|
133
|
+
: null;
|
|
134
|
+
if (entry.paragraph) this.idsByNode.set(entry.paragraph, sourceId);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
commitMutation(sourceId, removedNodes, liveNodes, operationIndex) {
|
|
139
|
+
const entry = this.entries.get(sourceId);
|
|
140
|
+
if (!entry || !Array.isArray(removedNodes) || !removedNodes.includes(entry.paragraph)) return;
|
|
141
|
+
const candidates = paragraphNodesFromMutation(liveNodes);
|
|
142
|
+
const sameId = entry.paragraphId
|
|
143
|
+
? candidates.filter(paragraph => getParagraphId(paragraph) === entry.paragraphId)
|
|
144
|
+
: [];
|
|
145
|
+
const successor = sameId.length === 1
|
|
146
|
+
? sameId[0]
|
|
147
|
+
: (candidates.length === 1 ? candidates[0] : null);
|
|
148
|
+
if (successor) {
|
|
149
|
+
entry.paragraph = successor;
|
|
150
|
+
this.idsByNode.set(successor, sourceId);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
entry.paragraph = null;
|
|
154
|
+
entry.consumed = true;
|
|
155
|
+
entry.consumedByOperation = operationIndex;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function resolvedTarget(metadata, revisionView) {
|
|
160
|
+
return {
|
|
161
|
+
index: metadata.index,
|
|
162
|
+
paragraphId: metadata.paragraphId,
|
|
163
|
+
fingerprint: metadata.fingerprint,
|
|
164
|
+
text: metadata.text,
|
|
165
|
+
inTable: metadata.inTable,
|
|
166
|
+
revisionView
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolveDescriptor(xmlDoc, descriptor, kind, strictTargets, metadataIndices, callbacks = {}) {
|
|
171
|
+
const revisionView = descriptor?.revisionView === 'rejected' ? 'rejected' : 'accepted';
|
|
172
|
+
if (!metadataIndices[revisionView]) {
|
|
173
|
+
metadataIndices[revisionView] = buildParagraphMetadataIndex(xmlDoc, { revisionView });
|
|
174
|
+
}
|
|
175
|
+
const index = metadataIndices[revisionView];
|
|
176
|
+
const resolution = resolveTargetParagraph(xmlDoc, {
|
|
177
|
+
targetText: descriptor.text,
|
|
178
|
+
targetRef: descriptor.index,
|
|
179
|
+
targetDescriptor: descriptor,
|
|
180
|
+
opType: kind,
|
|
181
|
+
strictAmbiguity: strictTargets,
|
|
182
|
+
paragraphMetadataIndex: index,
|
|
183
|
+
metadataIndices,
|
|
184
|
+
onInfo: callbacks.onInfo,
|
|
185
|
+
onWarn: callbacks.onWarn
|
|
186
|
+
});
|
|
187
|
+
const metadata = index.byParagraph.get(resolution.paragraph);
|
|
188
|
+
return {
|
|
189
|
+
paragraph: resolution.paragraph,
|
|
190
|
+
resolvedBy: resolution.resolvedBy,
|
|
191
|
+
metadata: resolvedTarget(metadata, revisionView)
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildConflict(code, message, bindings, target, action) {
|
|
196
|
+
return {
|
|
197
|
+
code,
|
|
198
|
+
message,
|
|
199
|
+
operationIndexes: bindings.map(binding => binding.index).sort((a, b) => a - b),
|
|
200
|
+
target,
|
|
201
|
+
recovery: {
|
|
202
|
+
action,
|
|
203
|
+
sameArgumentsSafe: false,
|
|
204
|
+
requiresReinspection: false,
|
|
205
|
+
requiresUserAuthorization: false
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Resolve source targets once and derive source-level conflicts before mutation. */
|
|
211
|
+
export function compileOperationBatch(xmlDoc, operations = [], options = {}) {
|
|
212
|
+
const sourceOperations = Array.isArray(operations) ? operations : [];
|
|
213
|
+
const strictTargets = options.strictTargets !== false;
|
|
214
|
+
const registry = new SourceTargetRegistry(xmlDoc);
|
|
215
|
+
const metadataIndices = {
|
|
216
|
+
accepted: buildParagraphMetadataIndex(xmlDoc, { revisionView: 'accepted' }),
|
|
217
|
+
rejected: null
|
|
218
|
+
};
|
|
219
|
+
const bindings = [];
|
|
220
|
+
const compiledOperations = [];
|
|
221
|
+
|
|
222
|
+
for (let index = 0; index < sourceOperations.length; index += 1) {
|
|
223
|
+
const sourceOperation = sourceOperations[index];
|
|
224
|
+
const validation = validateDocumentOperation(sourceOperation);
|
|
225
|
+
const operation = validation.operation || normalizeDocumentOperation(sourceOperation);
|
|
226
|
+
const binding = {
|
|
227
|
+
index: index + 1,
|
|
228
|
+
operationKind: operation.operationKind,
|
|
229
|
+
operationId: operation.operationId,
|
|
230
|
+
sourceIds: [],
|
|
231
|
+
warnings: []
|
|
232
|
+
};
|
|
233
|
+
const compiled = { ...sourceOperation };
|
|
234
|
+
|
|
235
|
+
const dynamicOccurrence = operation.targetDescriptor?.occurrence != null
|
|
236
|
+
&& !operation.targetDescriptor?.paragraphId
|
|
237
|
+
&& !operation.targetDescriptor?.fingerprint;
|
|
238
|
+
const compositeTextTarget = typeof operation.targetDescriptor?.text === 'string'
|
|
239
|
+
&& /\r|\n/.test(operation.targetDescriptor.text)
|
|
240
|
+
&& !operation.targetEndDescriptor;
|
|
241
|
+
if (dynamicOccurrence || compositeTextTarget) binding.dynamic = true;
|
|
242
|
+
|
|
243
|
+
if (
|
|
244
|
+
validation.valid
|
|
245
|
+
&& operation.operationKind !== 'comment_reply'
|
|
246
|
+
&& !operation.targetDescriptor?.captureRef
|
|
247
|
+
&& !binding.dynamic
|
|
248
|
+
) {
|
|
249
|
+
try {
|
|
250
|
+
const start = resolveDescriptor(
|
|
251
|
+
xmlDoc,
|
|
252
|
+
operation.targetDescriptor,
|
|
253
|
+
operation.operationKind,
|
|
254
|
+
strictTargets,
|
|
255
|
+
metadataIndices,
|
|
256
|
+
{
|
|
257
|
+
onInfo: options.onInfo,
|
|
258
|
+
onWarn: warning => binding.warnings.push(String(warning))
|
|
259
|
+
}
|
|
260
|
+
);
|
|
261
|
+
const startId = registry.register(start.paragraph, start.metadata);
|
|
262
|
+
binding.sourceIds.push(startId);
|
|
263
|
+
binding.resolvedBy = start.resolvedBy;
|
|
264
|
+
binding.resolvedTarget = start.metadata;
|
|
265
|
+
compiled._compiledSourceId = startId;
|
|
266
|
+
compiled._compiledResolvedBy = start.resolvedBy;
|
|
267
|
+
if (binding.warnings.length > 0) compiled._compiledWarnings = binding.warnings;
|
|
268
|
+
|
|
269
|
+
const targetEndDescriptor = operation.targetEndDescriptor
|
|
270
|
+
|| (operation.targetEndRef != null
|
|
271
|
+
? normalizeTargetDescriptor(null, operation.targetEndRef, operation.targetDescriptor.revisionView)
|
|
272
|
+
: null);
|
|
273
|
+
if (targetEndDescriptor) {
|
|
274
|
+
const end = resolveDescriptor(
|
|
275
|
+
xmlDoc,
|
|
276
|
+
targetEndDescriptor,
|
|
277
|
+
operation.operationKind,
|
|
278
|
+
strictTargets,
|
|
279
|
+
metadataIndices,
|
|
280
|
+
{
|
|
281
|
+
onInfo: options.onInfo,
|
|
282
|
+
onWarn: warning => binding.warnings.push(String(warning))
|
|
283
|
+
}
|
|
284
|
+
);
|
|
285
|
+
const endId = registry.register(end.paragraph, end.metadata);
|
|
286
|
+
compiled._compiledSourceEndId = endId;
|
|
287
|
+
const paragraphs = getDocumentParagraphNodes(xmlDoc);
|
|
288
|
+
const startIndex = paragraphs.indexOf(start.paragraph);
|
|
289
|
+
const endIndex = paragraphs.indexOf(end.paragraph);
|
|
290
|
+
if (startIndex < 0 || endIndex < startIndex) {
|
|
291
|
+
throw Object.assign(new Error('Target range is not a forward contiguous paragraph range.'), {
|
|
292
|
+
code: 'TARGET_RANGE_INVALID'
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
for (const paragraph of paragraphs.slice(startIndex, endIndex + 1)) {
|
|
296
|
+
const viewIndex = metadataIndices[operation.targetDescriptor.revisionView] || metadataIndices.accepted;
|
|
297
|
+
const metadata = viewIndex.byParagraph.get(paragraph);
|
|
298
|
+
binding.sourceIds.push(registry.register(paragraph, metadata || {}));
|
|
299
|
+
}
|
|
300
|
+
binding.sourceIds = [...new Set(binding.sourceIds)];
|
|
301
|
+
binding.resolvedTargetEnd = end.metadata;
|
|
302
|
+
}
|
|
303
|
+
} catch (error) {
|
|
304
|
+
binding.error = normalizedError(error);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
bindings.push(binding);
|
|
308
|
+
compiledOperations.push(compiled);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const existingCaptureKeys = new Set(compiledOperations.map(operation => operation?.captureKey).filter(Boolean));
|
|
312
|
+
let nextImplicitCapture = 1;
|
|
313
|
+
for (const binding of bindings) {
|
|
314
|
+
if (binding.error?.code !== 'TARGET_NOT_FOUND') continue;
|
|
315
|
+
const consumerIndex = binding.index - 1;
|
|
316
|
+
const consumer = normalizeDocumentOperation(sourceOperations[consumerIndex]);
|
|
317
|
+
const targetText = consumer.targetDescriptor?.text;
|
|
318
|
+
if (!targetText) continue;
|
|
319
|
+
const producers = sourceOperations.flatMap((candidate, producerIndex) => {
|
|
320
|
+
if (producerIndex === consumerIndex || typeof candidate?.modified !== 'string') return [];
|
|
321
|
+
const createdParagraphs = candidate.modified.split(/\r?\n/).map(text => text.trim()).filter(Boolean);
|
|
322
|
+
return createdParagraphs.includes(targetText.trim()) ? [{ candidate, producerIndex }] : [];
|
|
323
|
+
});
|
|
324
|
+
if (producers.length !== 1) continue;
|
|
325
|
+
const [{ candidate: producer, producerIndex }] = producers;
|
|
326
|
+
let captureKey = producer.captureKey || compiledOperations[producerIndex].captureKey;
|
|
327
|
+
if (!captureKey) {
|
|
328
|
+
do {
|
|
329
|
+
captureKey = `__batch_created_${nextImplicitCapture++}`;
|
|
330
|
+
} while (existingCaptureKeys.has(captureKey));
|
|
331
|
+
existingCaptureKeys.add(captureKey);
|
|
332
|
+
compiledOperations[producerIndex].captureKey = captureKey;
|
|
333
|
+
}
|
|
334
|
+
compiledOperations[consumerIndex].target = { captureRef: captureKey, select: targetText };
|
|
335
|
+
delete compiledOperations[consumerIndex]._compiledSourceId;
|
|
336
|
+
delete compiledOperations[consumerIndex]._compiledResolvedBy;
|
|
337
|
+
delete binding.error;
|
|
338
|
+
binding.sourceIds = [];
|
|
339
|
+
binding.resolvedBy = 'created_content_dependency';
|
|
340
|
+
binding.createdByOperation = producerIndex + 1;
|
|
341
|
+
binding.captureRef = captureKey;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const bySource = new Map();
|
|
345
|
+
for (const binding of bindings) {
|
|
346
|
+
if (binding.error || binding.dynamic) continue;
|
|
347
|
+
for (const sourceId of binding.sourceIds) {
|
|
348
|
+
if (!bySource.has(sourceId)) bySource.set(sourceId, []);
|
|
349
|
+
bySource.get(sourceId).push(binding);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const conflicts = [];
|
|
354
|
+
const conflictKeys = new Set();
|
|
355
|
+
for (const [sourceId, sourceBindings] of bySource) {
|
|
356
|
+
const textWrites = sourceBindings.filter(binding => TEXT_WRITE_KINDS.has(binding.operationKind));
|
|
357
|
+
const formatWrites = sourceBindings.filter(binding => FORMAT_WRITE_KINDS.has(binding.operationKind));
|
|
358
|
+
if (textWrites.length > 1) {
|
|
359
|
+
const key = `text:${textWrites.map(binding => binding.index).sort().join(',')}`;
|
|
360
|
+
if (!conflictKeys.has(key)) {
|
|
361
|
+
conflictKeys.add(key);
|
|
362
|
+
conflicts.push(buildConflict(
|
|
363
|
+
'OVERLAPPING_SOURCE_TARGETS',
|
|
364
|
+
`Operations ${textWrites.map(binding => binding.index).join(', ')} contain incompatible text writes to the same batch-start source target.`,
|
|
365
|
+
textWrites,
|
|
366
|
+
registry.describe(sourceId),
|
|
367
|
+
'consolidate_operations'
|
|
368
|
+
));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (textWrites.length > 0 && formatWrites.length > 0) {
|
|
372
|
+
const affected = [...textWrites, ...formatWrites];
|
|
373
|
+
const key = `format:${affected.map(binding => binding.index).sort().join(',')}`;
|
|
374
|
+
if (!conflictKeys.has(key)) {
|
|
375
|
+
conflictKeys.add(key);
|
|
376
|
+
conflicts.push(buildConflict(
|
|
377
|
+
'REVISION_ORDER_CONFLICT',
|
|
378
|
+
`Operations ${affected.map(binding => binding.index).join(', ')} combine text and formatting writes on the same batch-start source target.`,
|
|
379
|
+
affected,
|
|
380
|
+
registry.describe(sourceId),
|
|
381
|
+
'split_or_consolidate_operations'
|
|
382
|
+
));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
valid: conflicts.length === 0 && bindings.every(binding => !binding.error),
|
|
389
|
+
compiledOperations,
|
|
390
|
+
bindings,
|
|
391
|
+
conflicts,
|
|
392
|
+
registry
|
|
393
|
+
};
|
|
394
|
+
}
|
|
@@ -29,14 +29,12 @@ import {
|
|
|
29
29
|
resolveDocumentOperationAuthor,
|
|
30
30
|
validateDocumentOperation
|
|
31
31
|
} from './document-operation-contract.js';
|
|
32
|
-
import { buildOperationDependencyPlan } from './batch-operation-orchestrator.js';
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
...(Array.isArray(error?.candidates) ? { candidates: error.candidates } : {})
|
|
39
|
-
};
|
|
32
|
+
import { buildOperationDependencyPlan } from './batch-operation-orchestrator.js';
|
|
33
|
+
import { compileOperationBatch } from './operation-batch-compiler.js';
|
|
34
|
+
import { normalizeErrorWithRecovery } from './error-recovery.js';
|
|
35
|
+
|
|
36
|
+
function normalizedError(error, context = {}) {
|
|
37
|
+
return normalizeErrorWithRecovery(error, context);
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
function operationNeedsNumbering(operation) {
|
|
@@ -70,10 +68,6 @@ function targetMetadata(xmlDoc, paragraph, resolvedBy, suppliedText, paragraphMe
|
|
|
70
68
|
};
|
|
71
69
|
}
|
|
72
70
|
|
|
73
|
-
function buildConflict(code, message, operationIndexes, target) {
|
|
74
|
-
return { code, message, operationIndexes, target };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
71
|
function getCommentIdsInParagraph(paragraph) {
|
|
78
72
|
const ids = new Set();
|
|
79
73
|
for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
|
|
@@ -90,10 +84,10 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
90
84
|
return {
|
|
91
85
|
valid: false,
|
|
92
86
|
status: 'error',
|
|
93
|
-
error: {
|
|
87
|
+
error: normalizedError({
|
|
94
88
|
code: 'INVALID_OPERATION',
|
|
95
89
|
message: `Unsupported existingRevisions policy: "${String(options.existingRevisions)}".`
|
|
96
|
-
},
|
|
90
|
+
}),
|
|
97
91
|
results: [],
|
|
98
92
|
conflicts: [],
|
|
99
93
|
authorsUsed: [],
|
|
@@ -105,7 +99,7 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
105
99
|
return {
|
|
106
100
|
valid: false,
|
|
107
101
|
status: 'error',
|
|
108
|
-
error: parsed.error,
|
|
102
|
+
error: normalizedError(parsed.error),
|
|
109
103
|
results: [],
|
|
110
104
|
conflicts: [],
|
|
111
105
|
authorsUsed: [],
|
|
@@ -119,19 +113,23 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
119
113
|
rejected: null
|
|
120
114
|
};
|
|
121
115
|
const sourceOperations = Array.isArray(operations) ? operations : [];
|
|
122
|
-
const
|
|
123
|
-
|
|
116
|
+
const batchCompilation = compileOperationBatch(xmlDoc, sourceOperations, {
|
|
117
|
+
strictTargets: options.strictTargets !== false,
|
|
118
|
+
onInfo: options.onInfo,
|
|
119
|
+
onWarn: options.onWarn
|
|
120
|
+
});
|
|
121
|
+
const dependencyPlan = buildOperationDependencyPlan(batchCompilation.compiledOperations);
|
|
122
|
+
if (!dependencyPlan.valid) {
|
|
124
123
|
return {
|
|
125
124
|
valid: false,
|
|
126
125
|
status: 'error',
|
|
127
|
-
error: dependencyPlan.error,
|
|
126
|
+
error: normalizedError(dependencyPlan.error),
|
|
128
127
|
results: [],
|
|
129
128
|
conflicts: [],
|
|
130
129
|
authorsUsed: [],
|
|
131
130
|
requiredArtifacts: { comments: false, numbering: false }
|
|
132
131
|
};
|
|
133
|
-
}
|
|
134
|
-
|
|
132
|
+
}
|
|
135
133
|
const strictTargets = options.strictTargets !== false;
|
|
136
134
|
const results = [];
|
|
137
135
|
const authorsUsed = new Set();
|
|
@@ -175,7 +173,7 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
175
173
|
continue;
|
|
176
174
|
}
|
|
177
175
|
|
|
178
|
-
if (operation.targetDescriptor?.captureRef) {
|
|
176
|
+
if (operation.targetDescriptor?.captureRef) {
|
|
179
177
|
results.push({
|
|
180
178
|
index: index + 1,
|
|
181
179
|
type: sourceOperation?.type || 'redline',
|
|
@@ -186,8 +184,35 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
186
184
|
captureRef: operation.targetDescriptor.captureRef,
|
|
187
185
|
...(operation.targetDescriptor.select ? { select: operation.targetDescriptor.select } : {})
|
|
188
186
|
});
|
|
189
|
-
continue;
|
|
190
|
-
}
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const compiledBinding = batchCompilation.bindings[index];
|
|
191
|
+
if (compiledBinding?.error) {
|
|
192
|
+
results.push({
|
|
193
|
+
index: index + 1,
|
|
194
|
+
type: sourceOperation?.type || 'redline',
|
|
195
|
+
operationType: operation.operationKind,
|
|
196
|
+
status: 'error',
|
|
197
|
+
authorUsed,
|
|
198
|
+
error: compiledBinding.error
|
|
199
|
+
});
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (compiledBinding?.createdByOperation) {
|
|
203
|
+
results.push({
|
|
204
|
+
index: index + 1,
|
|
205
|
+
type: sourceOperation?.type || 'redline',
|
|
206
|
+
operationType: operation.operationKind,
|
|
207
|
+
status: 'deferred',
|
|
208
|
+
authorUsed,
|
|
209
|
+
resolvedBy: 'created_content_dependency',
|
|
210
|
+
captureRef: compiledBinding.captureRef,
|
|
211
|
+
select: operation.targetDescriptor.text,
|
|
212
|
+
createdByOperation: compiledBinding.createdByOperation
|
|
213
|
+
});
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
191
216
|
|
|
192
217
|
const targetView = operation.targetDescriptor?.revisionView === 'rejected' ? 'rejected' : 'accepted';
|
|
193
218
|
let currentMetadataIndex = targetView === 'rejected'
|
|
@@ -195,17 +220,28 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
195
220
|
: metadataIndices.accepted;
|
|
196
221
|
|
|
197
222
|
try {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
223
|
+
let resolved;
|
|
224
|
+
if (compiledBinding.dynamic) {
|
|
225
|
+
resolved = resolveTargetParagraph(xmlDoc, {
|
|
226
|
+
targetText: operation.target,
|
|
227
|
+
targetRef: operation.targetRef,
|
|
228
|
+
targetDescriptor: operation.targetDescriptor,
|
|
229
|
+
opType: operation.operationKind,
|
|
230
|
+
strictAmbiguity: strictTargets,
|
|
231
|
+
paragraphMetadataIndex: currentMetadataIndex,
|
|
232
|
+
metadataIndices,
|
|
233
|
+
onInfo: options.onInfo,
|
|
234
|
+
onWarn: options.onWarn
|
|
235
|
+
});
|
|
236
|
+
} else {
|
|
237
|
+
const bound = batchCompilation.registry.resolve(compiledBinding.sourceIds[0], xmlDoc);
|
|
238
|
+
if (bound.error) throw Object.assign(new Error(bound.error.message), bound.error);
|
|
239
|
+
resolved = {
|
|
240
|
+
paragraph: bound.paragraph,
|
|
241
|
+
resolvedBy: compiledBinding.resolvedBy,
|
|
242
|
+
warnings: compiledBinding.warnings
|
|
243
|
+
};
|
|
244
|
+
}
|
|
209
245
|
const paragraph = resolved.paragraph;
|
|
210
246
|
const metadata = targetMetadata(xmlDoc, paragraph, resolved.resolvedBy, operation.target, currentMetadataIndex, targetView);
|
|
211
247
|
const paragraphText = metadata.resolvedTarget.text;
|
|
@@ -313,8 +349,10 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
313
349
|
const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
|
|
314
350
|
if (!allSame && existingPolicy === 'merge-same-author') {
|
|
315
351
|
error = {
|
|
316
|
-
code: 'EXISTING_REVISIONS',
|
|
317
|
-
message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}).
|
|
352
|
+
code: 'EXISTING_REVISIONS',
|
|
353
|
+
message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
|
|
354
|
+
revisionAuthors: authors,
|
|
355
|
+
currentPolicy: existingPolicy
|
|
318
356
|
};
|
|
319
357
|
} else if (allSame) {
|
|
320
358
|
const mergeCommentIds = getCommentIdsInParagraph(paragraph);
|
|
@@ -408,43 +446,24 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
408
446
|
}
|
|
409
447
|
}
|
|
410
448
|
|
|
411
|
-
const conflicts =
|
|
412
|
-
const byTarget = new Map();
|
|
413
|
-
for (const result of results) {
|
|
414
|
-
const targetIndex = result.resolvedTarget?.index;
|
|
415
|
-
if (!targetIndex) continue;
|
|
416
|
-
if (!byTarget.has(targetIndex)) byTarget.set(targetIndex, []);
|
|
417
|
-
byTarget.get(targetIndex).push(result);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
for (const [targetIndex, targetResults] of byTarget) {
|
|
421
|
-
const redlines = targetResults.filter(result => ['redline', 'restore', 'rejected-insert'].includes(result.operationType));
|
|
422
|
-
const highlights = targetResults.filter(result => result.operationType === 'highlight');
|
|
423
|
-
const target = targetResults[0].resolvedTarget;
|
|
424
|
-
if (redlines.length > 1) {
|
|
425
|
-
conflicts.push(buildConflict(
|
|
426
|
-
'OVERLAPPING_TEXT_EDITS',
|
|
427
|
-
`Multiple text edits target paragraph ${targetIndex}; later operations may use a stale anchor.`,
|
|
428
|
-
redlines.map(result => result.index),
|
|
429
|
-
target
|
|
430
|
-
));
|
|
431
|
-
}
|
|
432
|
-
if (redlines.length > 0 && highlights.length > 0) {
|
|
433
|
-
conflicts.push(buildConflict(
|
|
434
|
-
'REVISION_ORDER_CONFLICT',
|
|
435
|
-
`A text edit and highlight target paragraph ${targetIndex}; operation order can invalidate the target or existing-revision policy.`,
|
|
436
|
-
[...redlines, ...highlights].map(result => result.index).sort((a, b) => a - b),
|
|
437
|
-
target
|
|
438
|
-
));
|
|
439
|
-
}
|
|
440
|
-
}
|
|
449
|
+
const conflicts = batchCompilation.conflicts;
|
|
441
450
|
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
451
|
+
const enrichedResults = results.map(result => result.error ? {
|
|
452
|
+
...result,
|
|
453
|
+
error: normalizedError(result.error, {
|
|
454
|
+
operationIndex: result.index,
|
|
455
|
+
...(sourceOperations[result.index - 1]?.operationId
|
|
456
|
+
? { operationId: sourceOperations[result.index - 1].operationId }
|
|
457
|
+
: {})
|
|
458
|
+
})
|
|
459
|
+
} : result);
|
|
460
|
+
const enrichedConflicts = conflicts.map(conflict => normalizedError(conflict));
|
|
461
|
+
const hasErrors = enrichedResults.some(result => result.status === 'error');
|
|
462
|
+
return {
|
|
463
|
+
valid: !hasErrors && enrichedConflicts.length === 0,
|
|
464
|
+
status: !hasErrors && enrichedConflicts.length === 0 ? 'ok' : 'error',
|
|
465
|
+
results: enrichedResults,
|
|
466
|
+
conflicts: enrichedConflicts,
|
|
448
467
|
authorsUsed: Array.from(authorsUsed),
|
|
449
468
|
requiredArtifacts: {
|
|
450
469
|
comments: commentsRequired,
|
|
@@ -10,9 +10,11 @@ export interface ParagraphTargetDescriptor {
|
|
|
10
10
|
inTable?: boolean;
|
|
11
11
|
fingerprint?: string;
|
|
12
12
|
sourceFingerprint?: string;
|
|
13
|
+
/** Defaults to rejected for restore operations and accepted for all other operations. */
|
|
13
14
|
revisionView?: 'accepted' | 'rejected';
|
|
14
15
|
captureRef?: string;
|
|
15
16
|
select?: string;
|
|
17
|
+
createdByOperation?: number;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export interface InsertionAffinity {
|
|
@@ -238,6 +240,17 @@ export interface DocumentOperationBatchResult {
|
|
|
238
240
|
status?: RedlineStatus;
|
|
239
241
|
error?: RedlineError;
|
|
240
242
|
warnings?: string[];
|
|
243
|
+
conflicts?: OperationConflict[];
|
|
244
|
+
retryPlan?: MutationRetryPlan;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface MutationRetryPlan {
|
|
248
|
+
base: 'original' | 'output';
|
|
249
|
+
committedIndexes: number[];
|
|
250
|
+
failedIndexes: number[];
|
|
251
|
+
unattemptedIndexes: number[];
|
|
252
|
+
replayWholeBatch: boolean;
|
|
253
|
+
sameArgumentsSafe: false;
|
|
241
254
|
}
|
|
242
255
|
|
|
243
256
|
export interface OperationPreflightItemResult {
|
|
@@ -271,10 +284,13 @@ export interface OperationPreflightItemResult {
|
|
|
271
284
|
}
|
|
272
285
|
|
|
273
286
|
export interface OperationConflict {
|
|
274
|
-
code: 'OVERLAPPING_TEXT_EDITS' | 'REVISION_ORDER_CONFLICT' | string;
|
|
287
|
+
code: 'OVERLAPPING_SOURCE_TARGETS' | 'OVERLAPPING_TEXT_EDITS' | 'REVISION_ORDER_CONFLICT' | 'CAPTURE_FANOUT_CONFLICT' | string;
|
|
275
288
|
message: string;
|
|
276
289
|
operationIndexes: number[];
|
|
277
290
|
target: ResolvedDocumentTarget;
|
|
291
|
+
recoveryVersion?: number;
|
|
292
|
+
category?: string;
|
|
293
|
+
recovery?: RedlineError['recovery'];
|
|
278
294
|
}
|
|
279
295
|
|
|
280
296
|
export interface OperationPreflightResult {
|