@ansonlai/docx-redline-js 0.4.0 → 0.5.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 +589 -287
- package/ARCHITECTURE.md +215 -9
- package/CHANGELOG.md +319 -0
- package/README.md +604 -360
- package/adapters/config.js +45 -43
- package/bin/docx-redline.js +3 -0
- package/core/list-targeting.js +101 -110
- package/core/paragraph-targeting.js +501 -61
- package/core/paragraph-text.js +209 -0
- package/core/revision-cloning.js +38 -0
- package/core/types.js +64 -10
- package/core/word-xml.js +43 -15
- package/dist/docx-redline-js.esm.js +2849 -466
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +87 -76
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +342 -23
- package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
- package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
- package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
- package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
- package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
- package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
- package/docs/schemas/document-operations.schema.json +109 -0
- package/docs/test-comparison-dashboard.html +4250 -7
- package/engine/formatting-removal.js +11 -2
- package/engine/oxml-engine.js +491 -336
- package/engine/reconstruction-mode.js +15 -14
- package/engine/reconstruction-writer.js +247 -142
- package/engine/route-selection.js +35 -0
- package/engine/rpr-helpers.js +334 -35
- package/engine/run-builders.js +239 -196
- package/engine/surgical-diff-application.js +222 -37
- package/engine/surgical-mode.js +134 -6
- package/engine/surgical-spans.js +52 -1
- package/engine/table-cell-context.js +3 -6
- package/engine/table-mode.js +1 -1
- package/index.d.ts +234 -6
- package/index.js +24 -1
- package/node/cli.js +317 -0
- package/node/docx-document.js +302 -0
- package/node/index.d.ts +31 -0
- package/node/index.js +2 -0
- package/node/zip-archive.js +52 -0
- package/orchestration/list-markdown.js +10 -16
- package/orchestration/list-parsing.js +7 -12
- package/orchestration/list-structural-fallback.js +21 -10
- package/package.json +24 -3
- package/pipeline/content-analysis.js +12 -17
- package/pipeline/ingestion-export.js +3 -31
- package/pipeline/ingestion-paragraph.js +10 -5
- package/pipeline/list-generation.js +150 -55
- package/pipeline/list-markers.js +70 -3
- package/pipeline/serialization.js +4 -2
- package/pipeline/structured-content.js +160 -0
- package/scripts/apply_changes.mjs +27 -0
- package/scripts/benchmark-operation-session.mjs +137 -0
- package/scripts/benchmark-targeting-browser.html +74 -0
- package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
- package/scripts/benchmark-test-runner.mjs +59 -0
- package/scripts/build-test-dashboard.mjs +23 -0
- package/scripts/export-lane1-fixtures.mjs +380 -0
- package/scripts/export-reredline-stress-fixtures.mjs +317 -0
- package/scripts/export-validation-fixtures.mjs +1 -1
- package/scripts/extract_text.mjs +7 -0
- package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
- package/scripts/generate-test-dashboard.mjs +362 -11
- package/scripts/lib/word-coverage-catalogue.mjs +6 -2
- package/scripts/profile-route-selection.mjs +19 -0
- package/scripts/render-agenda-multilevel.mjs +0 -5
- package/scripts/render-multilevel-cases.mjs +0 -1
- package/scripts/run-tests.mjs +107 -35
- package/scripts/word-com-corpus-suite.ps1 +3 -0
- package/scripts/word-com-differential.ps1 +64 -4
- package/scripts/word-com-suite.ps1 +3 -0
- package/services/batch-operation-orchestrator.js +494 -0
- package/services/capture-engine.js +226 -0
- package/services/comment-builders.js +23 -6
- package/services/comment-engine.js +108 -47
- package/services/comment-locator.js +187 -82
- package/services/comment-replies.js +95 -0
- package/services/document-inspection.js +258 -0
- package/services/document-operation-applier.js +372 -0
- package/services/document-operation-contract.js +323 -0
- package/services/document-operation-mutations.js +1733 -0
- package/services/document-operation-session.js +258 -0
- package/services/numbering-service.js +14 -5
- package/services/operation-heuristics.js +173 -0
- package/services/operation-preflight.js +366 -0
- package/services/receipt-collector.js +288 -0
- package/services/revision-comment-management.js +37 -5
- package/services/revision-token.js +290 -0
- package/services/standalone-docx-plumbing.js +123 -8
- package/services/standalone-operation-runner.d.ts +296 -0
- package/services/standalone-operation-runner.js +10 -1455
- package/services/table-reconciliation.js +15 -6
- package/docs/VALIDATION.md +0 -183
- package/docs/WORD-MANUAL-REVIEW.md +0 -138
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
- /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
2
|
+
import { getDefaultAuthor } from '../adapters/config.js';
|
|
3
|
+
import {
|
|
4
|
+
normalizeDocumentOperation,
|
|
5
|
+
resolveDocumentOperationAuthor
|
|
6
|
+
} from './document-operation-contract.js';
|
|
7
|
+
import {
|
|
8
|
+
applyOperationToDocumentXml,
|
|
9
|
+
normalizeOperationError
|
|
10
|
+
} from './document-operation-applier.js';
|
|
11
|
+
import {
|
|
12
|
+
cloneBatchRuntimeContext,
|
|
13
|
+
commitBatchRuntimeContext,
|
|
14
|
+
DocumentOperationSession
|
|
15
|
+
} from './document-operation-session.js';
|
|
16
|
+
import {
|
|
17
|
+
validateRevisionToken,
|
|
18
|
+
computeDocumentPartsRevisionToken,
|
|
19
|
+
areRevisionTokensEqual
|
|
20
|
+
} from './revision-token.js';
|
|
21
|
+
import {
|
|
22
|
+
createEmptyReceipt,
|
|
23
|
+
reconcileReceiptsAgainstOutput
|
|
24
|
+
} from './receipt-collector.js';
|
|
25
|
+
|
|
26
|
+
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
27
|
+
|
|
28
|
+
function operationTargetPriority(op) {
|
|
29
|
+
return op?.type === 'comment' || op?.type === 'comment_reply' ? 0 : 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Builds a dependency graph and topological execution plan for a batch of operations.
|
|
34
|
+
*
|
|
35
|
+
* @param {Array<object>} operations
|
|
36
|
+
* @returns {{ valid: boolean, scheduled?: Array<{ operation: object, index: number }>, captureProducers?: Map<string, number>, error?: { code: string, message: string } }}
|
|
37
|
+
*/
|
|
38
|
+
export function buildOperationDependencyPlan(operations = []) {
|
|
39
|
+
const list = Array.isArray(operations) ? operations : [];
|
|
40
|
+
const captureProducers = new Map();
|
|
41
|
+
|
|
42
|
+
// 1. Collect and validate capture keys
|
|
43
|
+
for (let i = 0; i < list.length; i++) {
|
|
44
|
+
const op = list[i];
|
|
45
|
+
const captureKey = op?.captureKey ?? op?.targetDescriptor?.captureKey;
|
|
46
|
+
if (captureKey != null) {
|
|
47
|
+
if (typeof captureKey !== 'string' || !captureKey.trim() || captureKey.trim().length > 256) {
|
|
48
|
+
return {
|
|
49
|
+
valid: false,
|
|
50
|
+
error: {
|
|
51
|
+
code: 'INVALID_OPERATION',
|
|
52
|
+
message: `Operation ${i + 1} declared an invalid captureKey: must be a non-empty string under 256 characters.`
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const key = captureKey.trim();
|
|
57
|
+
if (captureProducers.has(key)) {
|
|
58
|
+
const priorIndex = captureProducers.get(key);
|
|
59
|
+
return {
|
|
60
|
+
valid: false,
|
|
61
|
+
error: {
|
|
62
|
+
code: 'DUPLICATE_CAPTURE_KEY',
|
|
63
|
+
message: `Duplicate capture key "${key}" declared by operations at indices ${priorIndex + 1} and ${i + 1}.`
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
captureProducers.set(key, i);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2. Build dependency edges
|
|
72
|
+
const inDegrees = new Array(list.length).fill(0);
|
|
73
|
+
const dependents = Array.from({ length: list.length }, () => new Set());
|
|
74
|
+
const dependencies = Array.from({ length: list.length }, () => new Set());
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < list.length; i++) {
|
|
77
|
+
const op = list[i];
|
|
78
|
+
const captureRef = op?.target?.captureRef ?? op?.targetDescriptor?.captureRef;
|
|
79
|
+
if (captureRef != null) {
|
|
80
|
+
if (typeof captureRef !== 'string' || !captureRef.trim() || captureRef.trim().length > 256) {
|
|
81
|
+
return {
|
|
82
|
+
valid: false,
|
|
83
|
+
error: {
|
|
84
|
+
code: 'INVALID_OPERATION',
|
|
85
|
+
message: `Operation ${i + 1} referenced an invalid captureRef: must be a non-empty string under 256 characters.`
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const ref = captureRef.trim();
|
|
90
|
+
if (!captureProducers.has(ref)) {
|
|
91
|
+
return {
|
|
92
|
+
valid: false,
|
|
93
|
+
error: {
|
|
94
|
+
code: 'CAPTURE_NOT_FOUND',
|
|
95
|
+
message: `Capture "${ref}" referenced by operation ${i + 1} was not found in the batch.`
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const producerIndex = captureProducers.get(ref);
|
|
100
|
+
if (producerIndex === i) {
|
|
101
|
+
return {
|
|
102
|
+
valid: false,
|
|
103
|
+
error: {
|
|
104
|
+
code: 'CAPTURE_DEPENDENCY_CYCLE',
|
|
105
|
+
message: `Capture dependency cycle detected: operation ${i + 1} references its own capture "${ref}".`
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (!dependencies[i].has(producerIndex)) {
|
|
110
|
+
dependencies[i].add(producerIndex);
|
|
111
|
+
dependents[producerIndex].add(i);
|
|
112
|
+
inDegrees[i]++;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 3. Stable topological sort with comment priority among ready nodes
|
|
118
|
+
const ready = [];
|
|
119
|
+
for (let i = 0; i < list.length; i++) {
|
|
120
|
+
if (inDegrees[i] === 0) {
|
|
121
|
+
ready.push({ operation: list[i], index: i });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const scheduled = [];
|
|
126
|
+
while (ready.length > 0) {
|
|
127
|
+
ready.sort((a, b) => operationTargetPriority(a.operation) - operationTargetPriority(b.operation) || a.index - b.index);
|
|
128
|
+
const next = ready.shift();
|
|
129
|
+
scheduled.push(next);
|
|
130
|
+
|
|
131
|
+
for (const depIndex of dependents[next.index]) {
|
|
132
|
+
inDegrees[depIndex]--;
|
|
133
|
+
if (inDegrees[depIndex] === 0) {
|
|
134
|
+
ready.push({ operation: list[depIndex], index: depIndex });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (scheduled.length < list.length) {
|
|
140
|
+
return {
|
|
141
|
+
valid: false,
|
|
142
|
+
error: {
|
|
143
|
+
code: 'CAPTURE_DEPENDENCY_CYCLE',
|
|
144
|
+
message: 'Capture dependency cycle detected among batch operations.'
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
valid: true,
|
|
151
|
+
scheduled,
|
|
152
|
+
captureProducers
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function orderOperationsForStableTargets(operations = []) {
|
|
157
|
+
const plan = buildOperationDependencyPlan(operations);
|
|
158
|
+
if (!plan.valid) {
|
|
159
|
+
throw Object.assign(new Error(plan.error.message), { code: plan.error.code });
|
|
160
|
+
}
|
|
161
|
+
return plan.scheduled.map(item => item.operation);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function mergeCommentsXml(existingXml, incomingXml) {
|
|
165
|
+
if (!incomingXml) return existingXml;
|
|
166
|
+
if (!existingXml) return incomingXml;
|
|
167
|
+
const existingDoc = parseOoxmlSafe(existingXml, 'application/xml')?.doc;
|
|
168
|
+
const incomingDoc = parseOoxmlSafe(incomingXml, 'application/xml')?.doc;
|
|
169
|
+
if (!existingDoc || !incomingDoc) return existingXml;
|
|
170
|
+
const serializer = createSerializer();
|
|
171
|
+
const existingRoot = existingDoc.documentElement;
|
|
172
|
+
const existingIds = new Set(
|
|
173
|
+
Array.from(existingDoc.getElementsByTagNameNS(NS_W, 'comment'))
|
|
174
|
+
.map(node => node.getAttribute('w:id') || node.getAttribute('id'))
|
|
175
|
+
.filter(Boolean)
|
|
176
|
+
);
|
|
177
|
+
for (const comment of Array.from(incomingDoc.getElementsByTagNameNS(NS_W, 'comment'))) {
|
|
178
|
+
const id = comment.getAttribute('w:id') || comment.getAttribute('id');
|
|
179
|
+
if (id && existingIds.has(id)) continue;
|
|
180
|
+
existingRoot.appendChild(existingDoc.importNode(comment, true));
|
|
181
|
+
if (id) existingIds.add(id);
|
|
182
|
+
}
|
|
183
|
+
return serializer.serializeToString(existingDoc);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function applyOperationsToDocumentXml(documentXml, operations, author, runtimeContext = null, options = {}) {
|
|
187
|
+
const sourceOperations = Array.isArray(operations) ? operations : [];
|
|
188
|
+
const defaultAuthor = getDefaultAuthor();
|
|
189
|
+
const emptyReceipts = () => sourceOperations.map((op, i) => createEmptyReceipt(
|
|
190
|
+
i + 1,
|
|
191
|
+
op?.operationId,
|
|
192
|
+
resolveDocumentOperationAuthor(op, author, defaultAuthor),
|
|
193
|
+
'not_attempted'
|
|
194
|
+
));
|
|
195
|
+
|
|
196
|
+
if (options?.expectedRevision) {
|
|
197
|
+
const tokenValidation = validateRevisionToken(options.expectedRevision);
|
|
198
|
+
if (!tokenValidation.valid) {
|
|
199
|
+
return {
|
|
200
|
+
documentXml,
|
|
201
|
+
hasChanges: false,
|
|
202
|
+
commentsXml: null,
|
|
203
|
+
numberingXmlParts: [],
|
|
204
|
+
results: [],
|
|
205
|
+
receipts: emptyReceipts(),
|
|
206
|
+
executionOrder: [],
|
|
207
|
+
authorsUsed: [],
|
|
208
|
+
status: 'error',
|
|
209
|
+
error: {
|
|
210
|
+
code: tokenValidation.error?.code || 'INVALID_REVISION_TOKEN',
|
|
211
|
+
message: tokenValidation.error?.message || 'Invalid revision token.'
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (options.expectedRevision.scope !== 'document-parts') {
|
|
216
|
+
return {
|
|
217
|
+
documentXml,
|
|
218
|
+
hasChanges: false,
|
|
219
|
+
commentsXml: null,
|
|
220
|
+
numberingXmlParts: [],
|
|
221
|
+
results: [],
|
|
222
|
+
receipts: emptyReceipts(),
|
|
223
|
+
executionOrder: [],
|
|
224
|
+
authorsUsed: [],
|
|
225
|
+
status: 'error',
|
|
226
|
+
error: {
|
|
227
|
+
code: 'REVISION_TOKEN_SCOPE_MISMATCH',
|
|
228
|
+
message: `Revision token scope mismatch: expected 'document-parts', got '${options.expectedRevision.scope}'.`
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
const currentToken = await computeDocumentPartsRevisionToken({
|
|
233
|
+
documentXml,
|
|
234
|
+
commentsXml: runtimeContext?.commentsXml || options.commentsXml,
|
|
235
|
+
commentsExtendedXml: runtimeContext?.commentsExtendedXml || options.commentsExtendedXml,
|
|
236
|
+
numberingXml: runtimeContext?.numberingXml || options.numberingXml,
|
|
237
|
+
stylesXml: runtimeContext?.stylesXml || options.stylesXml
|
|
238
|
+
}, options);
|
|
239
|
+
if (!areRevisionTokensEqual(currentToken.value, options.expectedRevision.value)) {
|
|
240
|
+
return {
|
|
241
|
+
documentXml,
|
|
242
|
+
hasChanges: false,
|
|
243
|
+
commentsXml: null,
|
|
244
|
+
numberingXmlParts: [],
|
|
245
|
+
results: [],
|
|
246
|
+
receipts: emptyReceipts(),
|
|
247
|
+
executionOrder: [],
|
|
248
|
+
authorsUsed: [],
|
|
249
|
+
status: 'error',
|
|
250
|
+
error: {
|
|
251
|
+
code: 'REVISION_MISMATCH',
|
|
252
|
+
message: `Document revision mismatch: expected '${options.expectedRevision.value}', current is '${currentToken.value}'.`
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const session = options?._documentOperationSession instanceof DocumentOperationSession
|
|
259
|
+
? options._documentOperationSession
|
|
260
|
+
: new DocumentOperationSession(documentXml, {
|
|
261
|
+
...options,
|
|
262
|
+
_deferDocumentSerialization: true
|
|
263
|
+
});
|
|
264
|
+
if (!session.valid) {
|
|
265
|
+
return {
|
|
266
|
+
documentXml,
|
|
267
|
+
hasChanges: false,
|
|
268
|
+
commentsXml: null,
|
|
269
|
+
numberingXmlParts: [],
|
|
270
|
+
results: [],
|
|
271
|
+
receipts: emptyReceipts(),
|
|
272
|
+
executionOrder: [],
|
|
273
|
+
authorsUsed: [],
|
|
274
|
+
status: 'error',
|
|
275
|
+
error: session.parseResult.error,
|
|
276
|
+
warnings: session.parseResult.warnings
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
const dependencyPlan = buildOperationDependencyPlan(sourceOperations);
|
|
280
|
+
if (!dependencyPlan.valid) {
|
|
281
|
+
return {
|
|
282
|
+
documentXml,
|
|
283
|
+
hasChanges: false,
|
|
284
|
+
commentsXml: null,
|
|
285
|
+
numberingXmlParts: [],
|
|
286
|
+
results: [],
|
|
287
|
+
receipts: emptyReceipts(),
|
|
288
|
+
executionOrder: [],
|
|
289
|
+
authorsUsed: [],
|
|
290
|
+
status: 'error',
|
|
291
|
+
error: dependencyPlan.error
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const scheduled = dependencyPlan.scheduled;
|
|
295
|
+
|
|
296
|
+
const atomic = options.atomic === true;
|
|
297
|
+
const continueOnError = options.continueOnError !== false;
|
|
298
|
+
const context = cloneBatchRuntimeContext(runtimeContext);
|
|
299
|
+
if (!(context.targetRefSnapshot instanceof Map)) {
|
|
300
|
+
context.targetRefSnapshot = session.initialTargetReferenceSnapshot;
|
|
301
|
+
}
|
|
302
|
+
session.runtimeContext = context;
|
|
303
|
+
if (sourceOperations.some(op => op?.type === 'comment_reply')) {
|
|
304
|
+
session.commentsXml = context.commentsXml || options.commentsXml || null;
|
|
305
|
+
session.commentsExtendedXml = context.commentsExtendedXml || options.commentsExtendedXml || null;
|
|
306
|
+
session.commentsXmlMode = 'replace';
|
|
307
|
+
session.commentsExtendedXmlMode = 'replace';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let hasChanges = false;
|
|
311
|
+
const results = session.results;
|
|
312
|
+
const executionOrder = session.executionOrder;
|
|
313
|
+
const authorsUsed = session.authorsUsed;
|
|
314
|
+
let operationFailed = false;
|
|
315
|
+
|
|
316
|
+
for (const { operation, index } of scheduled) {
|
|
317
|
+
executionOrder.push(index + 1);
|
|
318
|
+
try {
|
|
319
|
+
const result = await applyOperationToDocumentXml(
|
|
320
|
+
session.currentDocumentXml,
|
|
321
|
+
operation,
|
|
322
|
+
author,
|
|
323
|
+
context,
|
|
324
|
+
{
|
|
325
|
+
...options,
|
|
326
|
+
_documentOperationSession: session,
|
|
327
|
+
_deferDocumentSerialization: true,
|
|
328
|
+
_operationIndex: index + 1
|
|
329
|
+
}
|
|
330
|
+
);
|
|
331
|
+
hasChanges = hasChanges || result.hasChanges === true;
|
|
332
|
+
session.commentsXml = result.commentsXmlMode === 'replace'
|
|
333
|
+
? (result.commentsXml || session.commentsXml)
|
|
334
|
+
: mergeCommentsXml(session.commentsXml, result.commentsXml || null);
|
|
335
|
+
session.commentsExtendedXml = result.commentsExtendedXmlMode === 'replace'
|
|
336
|
+
? (result.commentsExtendedXml || session.commentsExtendedXml)
|
|
337
|
+
: (result.commentsExtendedXml || session.commentsExtendedXml);
|
|
338
|
+
if (result.commentsXmlMode === 'replace') session.commentsXmlMode = 'replace';
|
|
339
|
+
if (result.commentsExtendedXmlMode === 'replace') session.commentsExtendedXmlMode = 'replace';
|
|
340
|
+
if (result.numberingXml) session.numberingXmlParts.push(result.numberingXml);
|
|
341
|
+
const isError = result.status === 'error' || !!result.error;
|
|
342
|
+
operationFailed = operationFailed || isError;
|
|
343
|
+
if (!isError && result.hasChanges && result.authorUsed) authorsUsed.add(result.authorUsed);
|
|
344
|
+
results.push({
|
|
345
|
+
index: index + 1,
|
|
346
|
+
type: operation?.type || 'redline',
|
|
347
|
+
status: isError ? 'error' : (result.hasChanges ? 'applied' : 'no_change'),
|
|
348
|
+
operationType: result.operationType || 'redline',
|
|
349
|
+
authorUsed: result.authorUsed,
|
|
350
|
+
...(result.resolvedBy ? { resolvedBy: result.resolvedBy } : {}),
|
|
351
|
+
...(result.resolvedTarget ? { resolvedTarget: result.resolvedTarget } : {}),
|
|
352
|
+
...(result.resolvedAnchor ? { resolvedAnchor: result.resolvedAnchor } : {}),
|
|
353
|
+
...(Array.isArray(result.warnings) && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
|
|
354
|
+
...(result.error ? { error: result.error } : {}),
|
|
355
|
+
...(result.receipt ? { receipt: result.receipt } : {})
|
|
356
|
+
});
|
|
357
|
+
if (isError && !continueOnError) break;
|
|
358
|
+
} catch (error) {
|
|
359
|
+
const normalizedError = normalizeOperationError(error);
|
|
360
|
+
operationFailed = true;
|
|
361
|
+
const authorUsed = resolveDocumentOperationAuthor(operation, author, getDefaultAuthor());
|
|
362
|
+
const errorReceipt = createEmptyReceipt(
|
|
363
|
+
index + 1,
|
|
364
|
+
operation?.operationId,
|
|
365
|
+
authorUsed,
|
|
366
|
+
'refused'
|
|
367
|
+
);
|
|
368
|
+
errorReceipt.warnings.push(normalizedError.message);
|
|
369
|
+
results.push({
|
|
370
|
+
index: index + 1,
|
|
371
|
+
type: operation?.type || 'redline',
|
|
372
|
+
status: 'error',
|
|
373
|
+
operationType: normalizeDocumentOperation(operation).operationKind,
|
|
374
|
+
authorUsed,
|
|
375
|
+
warnings: [normalizedError.message],
|
|
376
|
+
error: normalizedError,
|
|
377
|
+
receipt: errorReceipt
|
|
378
|
+
});
|
|
379
|
+
if (!continueOnError) break;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
results.sort((a, b) => a.index - b.index);
|
|
384
|
+
|
|
385
|
+
const allReceipts = [];
|
|
386
|
+
for (let i = 0; i < sourceOperations.length; i++) {
|
|
387
|
+
const opIndex = i + 1;
|
|
388
|
+
const executed = results.find(r => r.index === opIndex);
|
|
389
|
+
if (executed?.receipt) {
|
|
390
|
+
allReceipts.push(executed.receipt);
|
|
391
|
+
} else {
|
|
392
|
+
const unattemptedReceipt = createEmptyReceipt(
|
|
393
|
+
opIndex,
|
|
394
|
+
sourceOperations[i]?.operationId,
|
|
395
|
+
resolveDocumentOperationAuthor(sourceOperations[i], author, defaultAuthor),
|
|
396
|
+
'not_attempted'
|
|
397
|
+
);
|
|
398
|
+
allReceipts.push(unattemptedReceipt);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let rolledBack = atomic && operationFailed;
|
|
403
|
+
let outputDocumentXml = documentXml;
|
|
404
|
+
let serializationError = null;
|
|
405
|
+
if (!rolledBack && hasChanges) {
|
|
406
|
+
try {
|
|
407
|
+
outputDocumentXml = session.serializeCurrent();
|
|
408
|
+
} catch (error) {
|
|
409
|
+
serializationError = normalizeOperationError(error);
|
|
410
|
+
rolledBack = true;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let reconciliationError = null;
|
|
415
|
+
if (!rolledBack && hasChanges) {
|
|
416
|
+
const reconciliation = reconcileReceiptsAgainstOutput({
|
|
417
|
+
documentXml: outputDocumentXml,
|
|
418
|
+
commentsXml: session.commentsXml,
|
|
419
|
+
commentsExtendedXml: session.commentsExtendedXml,
|
|
420
|
+
numberingXmlParts: session.numberingXmlParts
|
|
421
|
+
}, allReceipts);
|
|
422
|
+
if (!reconciliation.valid) {
|
|
423
|
+
reconciliationError = reconciliation.error;
|
|
424
|
+
if (atomic) {
|
|
425
|
+
rolledBack = true;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (rolledBack) {
|
|
431
|
+
for (const r of results) {
|
|
432
|
+
if (r.receipt && r.receipt.attemptedDisposition === 'applied') {
|
|
433
|
+
r.receipt.finalDisposition = 'rolled_back';
|
|
434
|
+
r.receipt.committed = false;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
for (const receipt of allReceipts) {
|
|
438
|
+
if (receipt.attemptedDisposition === 'applied') {
|
|
439
|
+
receipt.finalDisposition = 'rolled_back';
|
|
440
|
+
receipt.committed = false;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
for (const r of results) {
|
|
445
|
+
if (r.receipt && r.receipt.attemptedDisposition === 'applied') {
|
|
446
|
+
r.receipt.committed = true;
|
|
447
|
+
r.receipt.finalDisposition = 'applied';
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
for (const receipt of allReceipts) {
|
|
451
|
+
if (receipt.attemptedDisposition === 'applied') {
|
|
452
|
+
receipt.committed = true;
|
|
453
|
+
receipt.finalDisposition = 'applied';
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (!rolledBack) commitBatchRuntimeContext(runtimeContext, context);
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
documentXml: rolledBack ? session.rollback() : outputDocumentXml,
|
|
462
|
+
hasChanges: rolledBack ? false : hasChanges,
|
|
463
|
+
commentsXml: rolledBack ? null : session.commentsXml,
|
|
464
|
+
commentsExtendedXml: rolledBack ? null : session.commentsExtendedXml,
|
|
465
|
+
commentsXmlMode: session.commentsXmlMode,
|
|
466
|
+
commentsExtendedXmlMode: session.commentsExtendedXmlMode,
|
|
467
|
+
numberingXmlParts: rolledBack ? [] : session.numberingXmlParts,
|
|
468
|
+
results: [...results].sort((a, b) => (a.index || 0) - (b.index || 0)),
|
|
469
|
+
receipts: allReceipts.sort((a, b) => a.operationIndex - b.operationIndex),
|
|
470
|
+
executionOrder,
|
|
471
|
+
authorsUsed: rolledBack ? [] : Array.from(authorsUsed),
|
|
472
|
+
...(rolledBack ? {
|
|
473
|
+
rolledBack: true,
|
|
474
|
+
status: 'error',
|
|
475
|
+
error: {
|
|
476
|
+
code: reconciliationError ? reconciliationError.code : (serializationError ? 'DOCUMENT_SERIALIZATION_FAILED' : 'BATCH_OPERATION_FAILED'),
|
|
477
|
+
message: reconciliationError?.message
|
|
478
|
+
|| serializationError?.message
|
|
479
|
+
|| 'Atomic batch rolled back because one or more operations failed.'
|
|
480
|
+
}
|
|
481
|
+
} : (reconciliationError ? {
|
|
482
|
+
status: 'error',
|
|
483
|
+
error: reconciliationError
|
|
484
|
+
} : (operationFailed ? {
|
|
485
|
+
status: hasChanges ? 'partial' : 'error',
|
|
486
|
+
error: {
|
|
487
|
+
code: 'BATCH_OPERATION_FAILED',
|
|
488
|
+
message: 'One or more operations failed.'
|
|
489
|
+
}
|
|
490
|
+
} : {
|
|
491
|
+
status: 'ok'
|
|
492
|
+
})))
|
|
493
|
+
};
|
|
494
|
+
}
|