@ansonlai/docx-redline-js 0.5.3 → 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 +82 -667
- package/ARCHITECTURE.md +51 -4
- package/CHANGELOG.md +11 -0
- package/README.md +176 -39
- package/core/paragraph-revision-safety.js +10 -8
- package/core/paragraph-targeting.js +14 -2
- package/core/redline-validation.js +7 -4
- package/core/revision-cloning.js +21 -0
- package/core/validation-delta.js +23 -0
- package/dist/docx-redline-js.esm.js +275 -45
- package/dist/docx-redline-js.esm.js.map +3 -3
- package/dist/docx-redline-js.esm.min.js +82 -82
- 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 +16 -2
- 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/engine/surgical-mode.js +148 -3
- package/engine/surgical-run-splitting.js +19 -7
- package/engine/surgical-spans.js +2 -1
- package/index.d.ts +17 -1
- package/node/cli.js +235 -36
- package/node/docx-document.js +137 -83
- package/node/index.d.ts +6 -2
- package/package.json +10 -3
- package/pipeline/diff-engine.js +15 -0
- 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 +99 -36
- package/services/document-operation-contract.js +50 -6
- package/services/document-operation-mutations.js +404 -41
- 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 +35 -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 -856
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ansonlai/docx-redline-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -44,7 +44,13 @@
|
|
|
44
44
|
"bin/",
|
|
45
45
|
"orchestration/",
|
|
46
46
|
"scripts/",
|
|
47
|
-
"
|
|
47
|
+
"!scripts/benchmark-agent-workflow.mjs",
|
|
48
|
+
"!scripts/lib/agent-performance-cases.mjs",
|
|
49
|
+
"docs/AGENT_FAST_START.md",
|
|
50
|
+
"docs/AGENT_KNOWLEDGE_BASE.md",
|
|
51
|
+
"docs/TESTING.md",
|
|
52
|
+
"docs/schemas/document-operations.schema.json",
|
|
53
|
+
"docs/validation-reports/2026-09-12-agent-protocol-rollout.md",
|
|
48
54
|
"index.js",
|
|
49
55
|
"index.d.ts",
|
|
50
56
|
"dist/",
|
|
@@ -77,10 +83,11 @@
|
|
|
77
83
|
"scripts": {
|
|
78
84
|
"build": "node scripts/build.mjs",
|
|
79
85
|
"check:types": "tsc -p tsconfig.types.json && node scripts/check-types.mjs",
|
|
80
|
-
"lint": "eslint index.js adapters core engine pipeline services orchestration scripts tests",
|
|
86
|
+
"lint": "eslint index.js adapters core engine pipeline services orchestration scripts examples tests",
|
|
81
87
|
"test:coverage": "c8 --reporter=text --reporter=json --reporter=json-summary --reports-dir=coverage node scripts/run-tests.mjs",
|
|
82
88
|
"coverage:gaps": "node scripts/report-coverage-gaps.mjs",
|
|
83
89
|
"benchmark:session": "node scripts/benchmark-operation-session.mjs",
|
|
90
|
+
"benchmark:agent": "node scripts/benchmark-agent-workflow.mjs",
|
|
84
91
|
"benchmark:targeting": "node scripts/benchmark-targeting-hot-paths.mjs",
|
|
85
92
|
"benchmark:tests": "node scripts/benchmark-test-runner.mjs",
|
|
86
93
|
"profile:routes": "node scripts/profile-route-selection.mjs",
|
package/pipeline/diff-engine.js
CHANGED
|
@@ -232,6 +232,21 @@ export function computeWordDiffs(originalText, newText, options = {}) {
|
|
|
232
232
|
return decodeBmpDiffs(charDiffs, wordArray);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Computes a character-local diff without semantic cleanup. This is used to
|
|
237
|
+
* refine whitespace-only substitutions that a word-level token groups with
|
|
238
|
+
* adjacent unchanged content (for example, an NBSP beside a hyperlink).
|
|
239
|
+
*
|
|
240
|
+
* @param {string} originalText
|
|
241
|
+
* @param {string} newText
|
|
242
|
+
* @param {{ diffTimeoutSeconds?: number }} [options={}]
|
|
243
|
+
* @returns {Array<[number, string]>}
|
|
244
|
+
*/
|
|
245
|
+
export function computeCharacterDiffs(originalText, newText, options = {}) {
|
|
246
|
+
if (originalText === newText) return [[0, originalText]];
|
|
247
|
+
return createDiffEngine(options).diff_main(originalText, newText);
|
|
248
|
+
}
|
|
249
|
+
|
|
235
250
|
/**
|
|
236
251
|
* Returns a character-local diff only when the modified string can be made
|
|
237
252
|
* solely by inserting into the original. This prevents word-token cleanup
|
|
@@ -76,7 +76,7 @@ try {
|
|
|
76
76
|
|
|
77
77
|
# -------------------------------------------------------------------------
|
|
78
78
|
# Scenario 1: insert-interior
|
|
79
|
-
# Author A (
|
|
79
|
+
# Author A (John) inserts: "amended by this Agreement."
|
|
80
80
|
# Author B (Anson) inserts "MASTER " before "Agreement"
|
|
81
81
|
# -------------------------------------------------------------------------
|
|
82
82
|
Write-Host "1. insert-interior"
|
|
@@ -85,8 +85,8 @@ try {
|
|
|
85
85
|
$doc.Range(0, 0).Text = "Contract terms "
|
|
86
86
|
|
|
87
87
|
# Author A insertion
|
|
88
|
-
$global:word.UserName = "
|
|
89
|
-
$global:word.UserInitials = "
|
|
88
|
+
$global:word.UserName = "John Doe"
|
|
89
|
+
$global:word.UserInitials = "JD"
|
|
90
90
|
$doc.TrackRevisions = $true
|
|
91
91
|
$endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
|
|
92
92
|
$endR.Text = "amended by this Agreement."
|
|
@@ -103,7 +103,7 @@ try {
|
|
|
103
103
|
|
|
104
104
|
# -------------------------------------------------------------------------
|
|
105
105
|
# Scenario 2: delete-interior
|
|
106
|
-
# Author A (
|
|
106
|
+
# Author A (John) inserts: "The Platform will process the Data to generate deliverables for Client."
|
|
107
107
|
# Author B (Anson) deletes: "generate "
|
|
108
108
|
# -------------------------------------------------------------------------
|
|
109
109
|
Write-Host "2. delete-interior"
|
|
@@ -112,11 +112,11 @@ try {
|
|
|
112
112
|
$doc.Range(0, 0).Text = "Background. "
|
|
113
113
|
|
|
114
114
|
# Author A insertion
|
|
115
|
-
$global:word.UserName = "
|
|
116
|
-
$global:word.UserInitials = "
|
|
115
|
+
$global:word.UserName = "John Doe"
|
|
116
|
+
$global:word.UserInitials = "JD"
|
|
117
117
|
$doc.TrackRevisions = $true
|
|
118
118
|
$endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
|
|
119
|
-
$endR.Text = "The
|
|
119
|
+
$endR.Text = "The Platform will process the Data to generate deliverables for Client."
|
|
120
120
|
|
|
121
121
|
# Author B deletion inside Author A's insertion
|
|
122
122
|
$global:word.UserName = "Anson Lai"
|
|
@@ -129,8 +129,8 @@ try {
|
|
|
129
129
|
|
|
130
130
|
# -------------------------------------------------------------------------
|
|
131
131
|
# Scenario 3: delete-boundary-start
|
|
132
|
-
# Author A (
|
|
133
|
-
# Author B (Anson) deletes: "Notwithstanding the
|
|
132
|
+
# Author A (John) inserts: "Notwithstanding anything to the contrary, the confidentiality terms remain in effect."
|
|
133
|
+
# Author B (Anson) deletes: "Notwithstanding anything to the contrary, "
|
|
134
134
|
# -------------------------------------------------------------------------
|
|
135
135
|
Write-Host "3. delete-boundary-start"
|
|
136
136
|
$doc = $global:word.Documents.Add()
|
|
@@ -138,16 +138,16 @@ try {
|
|
|
138
138
|
$doc.Range(0, 0).Text = "Section 1. "
|
|
139
139
|
|
|
140
140
|
# Author A insertion
|
|
141
|
-
$global:word.UserName = "
|
|
142
|
-
$global:word.UserInitials = "
|
|
141
|
+
$global:word.UserName = "John Doe"
|
|
142
|
+
$global:word.UserInitials = "JD"
|
|
143
143
|
$doc.TrackRevisions = $true
|
|
144
144
|
$endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
|
|
145
|
-
$endR.Text = "Notwithstanding the
|
|
145
|
+
$endR.Text = "Notwithstanding anything to the contrary, the confidentiality terms remain in effect."
|
|
146
146
|
|
|
147
147
|
# Author B deletion at start of insertion
|
|
148
148
|
$global:word.UserName = "Anson Lai"
|
|
149
149
|
$global:word.UserInitials = "AL"
|
|
150
|
-
$delWord = "Notwithstanding the
|
|
150
|
+
$delWord = "Notwithstanding anything to the contrary, "
|
|
151
151
|
$foundPos = Find-RequiredText $doc $delWord "delete-boundary-start"
|
|
152
152
|
$delRange = $doc.Range($foundPos, $foundPos + $delWord.Length)
|
|
153
153
|
$delRange.Delete() | Out-Null
|
|
@@ -155,8 +155,8 @@ try {
|
|
|
155
155
|
|
|
156
156
|
# -------------------------------------------------------------------------
|
|
157
157
|
# Scenario 4: delete-boundary-end
|
|
158
|
-
# Author A (
|
|
159
|
-
# Author B (Anson) deletes: " and applicable
|
|
158
|
+
# Author A (John) inserts: "subject to Section 4.2 and applicable standards."
|
|
159
|
+
# Author B (Anson) deletes: " and applicable standards."
|
|
160
160
|
# -------------------------------------------------------------------------
|
|
161
161
|
Write-Host "4. delete-boundary-end"
|
|
162
162
|
$doc = $global:word.Documents.Add()
|
|
@@ -164,16 +164,16 @@ try {
|
|
|
164
164
|
$doc.Range(0, 0).Text = "Compliance: "
|
|
165
165
|
|
|
166
166
|
# Author A insertion
|
|
167
|
-
$global:word.UserName = "
|
|
168
|
-
$global:word.UserInitials = "
|
|
167
|
+
$global:word.UserName = "John Doe"
|
|
168
|
+
$global:word.UserInitials = "JD"
|
|
169
169
|
$doc.TrackRevisions = $true
|
|
170
170
|
$endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
|
|
171
|
-
$endR.Text = "subject to Section 2
|
|
171
|
+
$endR.Text = "subject to Section 4.2 and applicable standards."
|
|
172
172
|
|
|
173
173
|
# Author B deletion at end of insertion
|
|
174
174
|
$global:word.UserName = "Anson Lai"
|
|
175
175
|
$global:word.UserInitials = "AL"
|
|
176
|
-
$delWord = " and applicable
|
|
176
|
+
$delWord = " and applicable standards."
|
|
177
177
|
$foundPos = Find-RequiredText $doc $delWord "delete-boundary-end"
|
|
178
178
|
$delRange = $doc.Range($foundPos, $foundPos + $delWord.Length)
|
|
179
179
|
$delRange.Delete() | Out-Null
|
|
@@ -182,7 +182,7 @@ try {
|
|
|
182
182
|
# -------------------------------------------------------------------------
|
|
183
183
|
# Scenario 5: delete-straddle-baseline-insertion
|
|
184
184
|
# Baseline: "Baseline start "
|
|
185
|
-
# Author A (
|
|
185
|
+
# Author A (John) inserts: "inserted finish."
|
|
186
186
|
# Author B (Anson) deletes: "start inserted" (straddling baseline and insertion)
|
|
187
187
|
# -------------------------------------------------------------------------
|
|
188
188
|
Write-Host "5. delete-straddle-baseline-insertion"
|
|
@@ -191,8 +191,8 @@ try {
|
|
|
191
191
|
$doc.Range(0, 0).Text = "Baseline start "
|
|
192
192
|
|
|
193
193
|
# Author A insertion
|
|
194
|
-
$global:word.UserName = "
|
|
195
|
-
$global:word.UserInitials = "
|
|
194
|
+
$global:word.UserName = "John Doe"
|
|
195
|
+
$global:word.UserInitials = "JD"
|
|
196
196
|
$doc.TrackRevisions = $true
|
|
197
197
|
$endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
|
|
198
198
|
$endR.Text = "inserted finish."
|
|
@@ -209,7 +209,7 @@ try {
|
|
|
209
209
|
# -------------------------------------------------------------------------
|
|
210
210
|
# Scenario 6: multi-author-stacked
|
|
211
211
|
# Baseline: "Provision "
|
|
212
|
-
# Author A (
|
|
212
|
+
# Author A (John) inserts: "first draft of the proposal with initial metrics."
|
|
213
213
|
# Author B (Anson) deletes: "of the proposal "
|
|
214
214
|
# Author C (Chris) deletes: "initial " from the remaining text
|
|
215
215
|
# -------------------------------------------------------------------------
|
|
@@ -219,8 +219,8 @@ try {
|
|
|
219
219
|
$doc.Range(0, 0).Text = "Provision "
|
|
220
220
|
|
|
221
221
|
# Author A insertion
|
|
222
|
-
$global:word.UserName = "
|
|
223
|
-
$global:word.UserInitials = "
|
|
222
|
+
$global:word.UserName = "John Doe"
|
|
223
|
+
$global:word.UserInitials = "JD"
|
|
224
224
|
$doc.TrackRevisions = $true
|
|
225
225
|
$endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
|
|
226
226
|
$endR.Text = "first draft of the proposal with initial metrics."
|
|
@@ -19,10 +19,12 @@ import {
|
|
|
19
19
|
computeDocumentPartsRevisionToken,
|
|
20
20
|
areRevisionTokensEqual
|
|
21
21
|
} from './revision-token.js';
|
|
22
|
-
import {
|
|
23
|
-
createEmptyReceipt,
|
|
24
|
-
reconcileReceiptsAgainstOutput
|
|
25
|
-
} from './receipt-collector.js';
|
|
22
|
+
import {
|
|
23
|
+
createEmptyReceipt,
|
|
24
|
+
reconcileReceiptsAgainstOutput
|
|
25
|
+
} from './receipt-collector.js';
|
|
26
|
+
import { compileOperationBatch } from './operation-batch-compiler.js';
|
|
27
|
+
import { createRetryPlan } from './error-recovery.js';
|
|
26
28
|
|
|
27
29
|
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
28
30
|
|
|
@@ -70,9 +72,10 @@ export function buildOperationDependencyPlan(operations = []) {
|
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
// 2. Build dependency edges
|
|
73
|
-
const inDegrees = new Array(list.length).fill(0);
|
|
74
|
-
const dependents = Array.from({ length: list.length }, () => new Set());
|
|
75
|
-
const dependencies = Array.from({ length: list.length }, () => new Set());
|
|
75
|
+
const inDegrees = new Array(list.length).fill(0);
|
|
76
|
+
const dependents = Array.from({ length: list.length }, () => new Set());
|
|
77
|
+
const dependencies = Array.from({ length: list.length }, () => new Set());
|
|
78
|
+
const mutatingCaptureConsumers = new Map();
|
|
76
79
|
|
|
77
80
|
for (let i = 0; i < list.length; i++) {
|
|
78
81
|
const op = list[i];
|
|
@@ -107,13 +110,40 @@ export function buildOperationDependencyPlan(operations = []) {
|
|
|
107
110
|
}
|
|
108
111
|
};
|
|
109
112
|
}
|
|
110
|
-
if (!dependencies[i].has(producerIndex)) {
|
|
111
|
-
dependencies[i].add(producerIndex);
|
|
112
|
-
dependents[producerIndex].add(i);
|
|
113
|
-
inDegrees[i]++;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
if (!dependencies[i].has(producerIndex)) {
|
|
114
|
+
dependencies[i].add(producerIndex);
|
|
115
|
+
dependents[producerIndex].add(i);
|
|
116
|
+
inDegrees[i]++;
|
|
117
|
+
}
|
|
118
|
+
const kind = normalizeDocumentOperation(op).operationKind;
|
|
119
|
+
if (kind !== 'comment' && kind !== 'comment_reply') {
|
|
120
|
+
if (!mutatingCaptureConsumers.has(ref)) mutatingCaptureConsumers.set(ref, []);
|
|
121
|
+
mutatingCaptureConsumers.get(ref).push({
|
|
122
|
+
index: i,
|
|
123
|
+
select: op?.target?.select ?? op?.targetDescriptor?.select ?? null
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const [captureRef, consumers] of mutatingCaptureConsumers) {
|
|
130
|
+
if (consumers.length < 2) continue;
|
|
131
|
+
const selectors = consumers.map(consumer => consumer.select);
|
|
132
|
+
const distinctSelectors = new Set(selectors);
|
|
133
|
+
if (selectors.every(selector => typeof selector === 'string' && selector.length > 0)
|
|
134
|
+
&& distinctSelectors.size === selectors.length) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
valid: false,
|
|
139
|
+
error: {
|
|
140
|
+
code: 'CAPTURE_FANOUT_CONFLICT',
|
|
141
|
+
message: `Capture "${captureRef}" has overlapping or unscoped mutating consumers at operation indices ${consumers.map(consumer => consumer.index + 1).join(', ')}.`,
|
|
142
|
+
operationIndexes: consumers.map(consumer => consumer.index + 1),
|
|
143
|
+
captureRef
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
117
147
|
|
|
118
148
|
// 3. Stable topological sort with comment priority among ready nodes
|
|
119
149
|
const ready = [];
|
|
@@ -191,62 +221,56 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
|
|
|
191
221
|
i + 1,
|
|
192
222
|
op?.operationId,
|
|
193
223
|
resolveDocumentOperationAuthor(op, author, defaultAuthor),
|
|
194
|
-
'not_attempted'
|
|
224
|
+
'not_attempted'
|
|
195
225
|
));
|
|
196
|
-
|
|
197
|
-
|
|
226
|
+
const failedBeforeExecution = (error, extra = {}) => {
|
|
227
|
+
const receipts = emptyReceipts();
|
|
198
228
|
return {
|
|
199
229
|
documentXml,
|
|
200
230
|
hasChanges: false,
|
|
201
231
|
commentsXml: null,
|
|
202
232
|
numberingXmlParts: [],
|
|
203
233
|
results: [],
|
|
204
|
-
receipts
|
|
234
|
+
receipts,
|
|
205
235
|
executionOrder: [],
|
|
206
236
|
authorsUsed: [],
|
|
237
|
+
...(options.atomic === true ? { rolledBack: true } : {}),
|
|
207
238
|
status: 'error',
|
|
208
|
-
error:
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
239
|
+
error: normalizeOperationError(error),
|
|
240
|
+
retryPlan: createRetryPlan({
|
|
241
|
+
atomic: options.atomic === true,
|
|
242
|
+
rolledBack: options.atomic === true,
|
|
243
|
+
results: [],
|
|
244
|
+
receipts,
|
|
245
|
+
operationCount: sourceOperations.length
|
|
246
|
+
}),
|
|
247
|
+
...extra
|
|
212
248
|
};
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
if (options.existingRevisions != null && !isExistingRevisionsPolicy(options.existingRevisions)) {
|
|
252
|
+
return failedBeforeExecution({
|
|
253
|
+
code: 'INVALID_OPERATION',
|
|
254
|
+
message: `Unsupported existingRevisions policy: "${String(options.existingRevisions)}".`,
|
|
255
|
+
field: 'existingRevisions'
|
|
256
|
+
});
|
|
213
257
|
}
|
|
214
258
|
|
|
215
259
|
if (options?.expectedRevision) {
|
|
216
260
|
const tokenValidation = validateRevisionToken(options.expectedRevision);
|
|
217
261
|
if (!tokenValidation.valid) {
|
|
218
|
-
return {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
numberingXmlParts: [],
|
|
223
|
-
results: [],
|
|
224
|
-
receipts: emptyReceipts(),
|
|
225
|
-
executionOrder: [],
|
|
226
|
-
authorsUsed: [],
|
|
227
|
-
status: 'error',
|
|
228
|
-
error: {
|
|
229
|
-
code: tokenValidation.error?.code || 'INVALID_REVISION_TOKEN',
|
|
230
|
-
message: tokenValidation.error?.message || 'Invalid revision token.'
|
|
231
|
-
}
|
|
232
|
-
};
|
|
262
|
+
return failedBeforeExecution({
|
|
263
|
+
code: tokenValidation.error?.code || 'INVALID_REVISION_TOKEN',
|
|
264
|
+
message: tokenValidation.error?.message || 'Invalid revision token.'
|
|
265
|
+
});
|
|
233
266
|
}
|
|
234
267
|
if (options.expectedRevision.scope !== 'document-parts') {
|
|
235
|
-
return {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
receipts: emptyReceipts(),
|
|
242
|
-
executionOrder: [],
|
|
243
|
-
authorsUsed: [],
|
|
244
|
-
status: 'error',
|
|
245
|
-
error: {
|
|
246
|
-
code: 'REVISION_TOKEN_SCOPE_MISMATCH',
|
|
247
|
-
message: `Revision token scope mismatch: expected 'document-parts', got '${options.expectedRevision.scope}'.`
|
|
248
|
-
}
|
|
249
|
-
};
|
|
268
|
+
return failedBeforeExecution({
|
|
269
|
+
code: 'REVISION_TOKEN_SCOPE_MISMATCH',
|
|
270
|
+
message: `Revision token scope mismatch: expected 'document-parts', got '${options.expectedRevision.scope}'.`,
|
|
271
|
+
expectedScope: 'document-parts',
|
|
272
|
+
actualScope: options.expectedRevision.scope
|
|
273
|
+
});
|
|
250
274
|
}
|
|
251
275
|
const currentToken = await computeDocumentPartsRevisionToken({
|
|
252
276
|
documentXml,
|
|
@@ -256,21 +280,12 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
|
|
|
256
280
|
stylesXml: runtimeContext?.stylesXml || options.stylesXml
|
|
257
281
|
}, options);
|
|
258
282
|
if (!areRevisionTokensEqual(currentToken.value, options.expectedRevision.value)) {
|
|
259
|
-
return {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
receipts: emptyReceipts(),
|
|
266
|
-
executionOrder: [],
|
|
267
|
-
authorsUsed: [],
|
|
268
|
-
status: 'error',
|
|
269
|
-
error: {
|
|
270
|
-
code: 'REVISION_MISMATCH',
|
|
271
|
-
message: `Document revision mismatch: expected '${options.expectedRevision.value}', current is '${currentToken.value}'.`
|
|
272
|
-
}
|
|
273
|
-
};
|
|
283
|
+
return failedBeforeExecution({
|
|
284
|
+
code: 'REVISION_MISMATCH',
|
|
285
|
+
message: `Document revision mismatch: expected '${options.expectedRevision.value}', current is '${currentToken.value}'.`,
|
|
286
|
+
expectedRevision: options.expectedRevision,
|
|
287
|
+
currentRevision: currentToken
|
|
288
|
+
});
|
|
274
289
|
}
|
|
275
290
|
}
|
|
276
291
|
|
|
@@ -281,34 +296,73 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
|
|
|
281
296
|
_deferDocumentSerialization: true
|
|
282
297
|
});
|
|
283
298
|
if (!session.valid) {
|
|
284
|
-
return {
|
|
285
|
-
documentXml,
|
|
286
|
-
hasChanges: false,
|
|
287
|
-
commentsXml: null,
|
|
288
|
-
numberingXmlParts: [],
|
|
289
|
-
results: [],
|
|
290
|
-
receipts: emptyReceipts(),
|
|
291
|
-
executionOrder: [],
|
|
292
|
-
authorsUsed: [],
|
|
293
|
-
status: 'error',
|
|
294
|
-
error: session.parseResult.error,
|
|
295
|
-
warnings: session.parseResult.warnings
|
|
296
|
-
};
|
|
299
|
+
return failedBeforeExecution(session.parseResult.error, { warnings: session.parseResult.warnings });
|
|
297
300
|
}
|
|
298
|
-
const
|
|
301
|
+
const compilation = compileOperationBatch(session.document, sourceOperations, {
|
|
302
|
+
strictTargets: options.strictTargets !== false,
|
|
303
|
+
onInfo: options.onInfo,
|
|
304
|
+
onWarn: options.onWarn
|
|
305
|
+
});
|
|
306
|
+
session.sourceTargetRegistry = compilation.registry;
|
|
307
|
+
if (compilation.conflicts.length > 0) {
|
|
308
|
+
const conflictByIndex = new Map();
|
|
309
|
+
for (const conflict of compilation.conflicts) {
|
|
310
|
+
for (const index of conflict.operationIndexes) {
|
|
311
|
+
if (!conflictByIndex.has(index)) conflictByIndex.set(index, conflict);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const conflictResults = Array.from(conflictByIndex, ([index, conflict]) => {
|
|
315
|
+
const operation = sourceOperations[index - 1];
|
|
316
|
+
const authorUsed = resolveDocumentOperationAuthor(operation, author, defaultAuthor);
|
|
317
|
+
const normalizedConflict = normalizeOperationError(conflict, {
|
|
318
|
+
operationIndex: index,
|
|
319
|
+
...(operation?.operationId ? { operationId: operation.operationId } : {})
|
|
320
|
+
});
|
|
321
|
+
return {
|
|
322
|
+
index,
|
|
323
|
+
type: operation?.type || 'redline',
|
|
324
|
+
operationType: normalizeDocumentOperation(operation).operationKind,
|
|
325
|
+
status: 'error',
|
|
326
|
+
authorUsed,
|
|
327
|
+
error: normalizedConflict,
|
|
328
|
+
receipt: createEmptyReceipt(index, operation?.operationId, authorUsed, 'refused')
|
|
329
|
+
};
|
|
330
|
+
}).sort((left, right) => left.index - right.index);
|
|
331
|
+
const receipts = sourceOperations.map((operation, index) => {
|
|
332
|
+
const conflictResult = conflictResults.find(result => result.index === index + 1);
|
|
333
|
+
return conflictResult?.receipt || createEmptyReceipt(
|
|
334
|
+
index + 1,
|
|
335
|
+
operation?.operationId,
|
|
336
|
+
resolveDocumentOperationAuthor(operation, author, defaultAuthor),
|
|
337
|
+
'not_attempted'
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
return {
|
|
341
|
+
documentXml,
|
|
342
|
+
hasChanges: false,
|
|
343
|
+
commentsXml: null,
|
|
344
|
+
numberingXmlParts: [],
|
|
345
|
+
results: conflictResults,
|
|
346
|
+
receipts,
|
|
347
|
+
executionOrder: [],
|
|
348
|
+
authorsUsed: [],
|
|
349
|
+
rolledBack: options.atomic === true,
|
|
350
|
+
status: 'error',
|
|
351
|
+
error: normalizeOperationError(compilation.conflicts[0]),
|
|
352
|
+
conflicts: compilation.conflicts.map(conflict => normalizeOperationError(conflict)),
|
|
353
|
+
retryPlan: createRetryPlan({
|
|
354
|
+
atomic: options.atomic === true,
|
|
355
|
+
rolledBack: options.atomic === true,
|
|
356
|
+
results: conflictResults,
|
|
357
|
+
receipts,
|
|
358
|
+
operationCount: sourceOperations.length
|
|
359
|
+
})
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
const executableOperations = compilation.compiledOperations;
|
|
363
|
+
const dependencyPlan = buildOperationDependencyPlan(executableOperations);
|
|
299
364
|
if (!dependencyPlan.valid) {
|
|
300
|
-
return
|
|
301
|
-
documentXml,
|
|
302
|
-
hasChanges: false,
|
|
303
|
-
commentsXml: null,
|
|
304
|
-
numberingXmlParts: [],
|
|
305
|
-
results: [],
|
|
306
|
-
receipts: emptyReceipts(),
|
|
307
|
-
executionOrder: [],
|
|
308
|
-
authorsUsed: [],
|
|
309
|
-
status: 'error',
|
|
310
|
-
error: dependencyPlan.error
|
|
311
|
-
};
|
|
365
|
+
return failedBeforeExecution(dependencyPlan.error);
|
|
312
366
|
}
|
|
313
367
|
const scheduled = dependencyPlan.scheduled;
|
|
314
368
|
|
|
@@ -332,9 +386,37 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
|
|
|
332
386
|
const authorsUsed = session.authorsUsed;
|
|
333
387
|
let operationFailed = false;
|
|
334
388
|
|
|
335
|
-
for (const { operation, index } of scheduled) {
|
|
336
|
-
executionOrder.push(index + 1);
|
|
337
|
-
|
|
389
|
+
for (const { operation, index } of scheduled) {
|
|
390
|
+
executionOrder.push(index + 1);
|
|
391
|
+
const sourceBinding = compilation.bindings[index];
|
|
392
|
+
if (sourceBinding?.error) {
|
|
393
|
+
operationFailed = true;
|
|
394
|
+
const authorUsed = resolveDocumentOperationAuthor(operation, author, defaultAuthor);
|
|
395
|
+
const errorReceipt = createEmptyReceipt(
|
|
396
|
+
index + 1,
|
|
397
|
+
operation?.operationId,
|
|
398
|
+
authorUsed,
|
|
399
|
+
'refused'
|
|
400
|
+
);
|
|
401
|
+
const normalizedBindingError = normalizeOperationError(sourceBinding.error, {
|
|
402
|
+
operationIndex: index + 1,
|
|
403
|
+
...(operation?.operationId ? { operationId: operation.operationId } : {})
|
|
404
|
+
});
|
|
405
|
+
errorReceipt.warnings.push(normalizedBindingError.message);
|
|
406
|
+
results.push({
|
|
407
|
+
index: index + 1,
|
|
408
|
+
type: operation?.type || 'redline',
|
|
409
|
+
status: 'error',
|
|
410
|
+
operationType: normalizeDocumentOperation(operation).operationKind,
|
|
411
|
+
authorUsed,
|
|
412
|
+
warnings: [normalizedBindingError.message],
|
|
413
|
+
error: normalizedBindingError,
|
|
414
|
+
receipt: errorReceipt
|
|
415
|
+
});
|
|
416
|
+
if (!continueOnError) break;
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
338
420
|
const result = await applyOperationToDocumentXml(
|
|
339
421
|
session.currentDocumentXml,
|
|
340
422
|
operation,
|
|
@@ -374,8 +456,11 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
|
|
|
374
456
|
...(result.receipt ? { receipt: result.receipt } : {})
|
|
375
457
|
});
|
|
376
458
|
if (isError && !continueOnError) break;
|
|
377
|
-
} catch (error) {
|
|
378
|
-
const normalizedError = normalizeOperationError(error
|
|
459
|
+
} catch (error) {
|
|
460
|
+
const normalizedError = normalizeOperationError(error, {
|
|
461
|
+
operationIndex: index + 1,
|
|
462
|
+
...(operation?.operationId ? { operationId: operation.operationId } : {})
|
|
463
|
+
});
|
|
379
464
|
operationFailed = true;
|
|
380
465
|
const authorUsed = resolveDocumentOperationAuthor(operation, author, getDefaultAuthor());
|
|
381
466
|
const errorReceipt = createEmptyReceipt(
|
|
@@ -476,8 +561,8 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
|
|
|
476
561
|
|
|
477
562
|
if (!rolledBack) commitBatchRuntimeContext(runtimeContext, context);
|
|
478
563
|
|
|
479
|
-
|
|
480
|
-
documentXml: rolledBack ? session.rollback() : outputDocumentXml,
|
|
564
|
+
const batchResult = {
|
|
565
|
+
documentXml: rolledBack ? session.rollback() : outputDocumentXml,
|
|
481
566
|
hasChanges: rolledBack ? false : hasChanges,
|
|
482
567
|
commentsXml: rolledBack ? null : session.commentsXml,
|
|
483
568
|
commentsExtendedXml: rolledBack ? null : session.commentsExtendedXml,
|
|
@@ -491,23 +576,33 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
|
|
|
491
576
|
...(rolledBack ? {
|
|
492
577
|
rolledBack: true,
|
|
493
578
|
status: 'error',
|
|
494
|
-
error: {
|
|
495
|
-
code: reconciliationError ? reconciliationError.code : (serializationError ? 'DOCUMENT_SERIALIZATION_FAILED' : 'BATCH_OPERATION_FAILED'),
|
|
496
|
-
message: reconciliationError?.message
|
|
497
|
-
|| serializationError?.message
|
|
498
|
-
|| 'Atomic batch rolled back because one or more operations failed.'
|
|
499
|
-
}
|
|
500
|
-
} : (reconciliationError ? {
|
|
501
|
-
status: 'error',
|
|
502
|
-
error: reconciliationError
|
|
503
|
-
} : (operationFailed ? {
|
|
504
|
-
status: hasChanges ? 'partial' : 'error',
|
|
505
|
-
error: {
|
|
506
|
-
code: 'BATCH_OPERATION_FAILED',
|
|
507
|
-
message: 'One or more operations failed.'
|
|
508
|
-
}
|
|
509
|
-
} : {
|
|
510
|
-
status: 'ok'
|
|
511
|
-
})))
|
|
512
|
-
};
|
|
513
|
-
|
|
579
|
+
error: normalizeOperationError({
|
|
580
|
+
code: reconciliationError ? reconciliationError.code : (serializationError ? 'DOCUMENT_SERIALIZATION_FAILED' : 'BATCH_OPERATION_FAILED'),
|
|
581
|
+
message: reconciliationError?.message
|
|
582
|
+
|| serializationError?.message
|
|
583
|
+
|| 'Atomic batch rolled back because one or more operations failed.'
|
|
584
|
+
})
|
|
585
|
+
} : (reconciliationError ? {
|
|
586
|
+
status: 'error',
|
|
587
|
+
error: normalizeOperationError(reconciliationError)
|
|
588
|
+
} : (operationFailed ? {
|
|
589
|
+
status: hasChanges ? 'partial' : 'error',
|
|
590
|
+
error: normalizeOperationError({
|
|
591
|
+
code: 'BATCH_OPERATION_FAILED',
|
|
592
|
+
message: 'One or more operations failed.'
|
|
593
|
+
})
|
|
594
|
+
} : {
|
|
595
|
+
status: 'ok'
|
|
596
|
+
})))
|
|
597
|
+
};
|
|
598
|
+
if (batchResult.status === 'error' || batchResult.status === 'partial') {
|
|
599
|
+
batchResult.retryPlan = createRetryPlan({
|
|
600
|
+
atomic,
|
|
601
|
+
rolledBack,
|
|
602
|
+
results: batchResult.results,
|
|
603
|
+
receipts: batchResult.receipts,
|
|
604
|
+
operationCount: sourceOperations.length
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
return batchResult;
|
|
608
|
+
}
|
|
@@ -199,9 +199,10 @@ export function inspectDocumentParts(parts, options = {}) {
|
|
|
199
199
|
const resolveNumbering = createNumberingResolver(numberingPart.doc);
|
|
200
200
|
let nearestHeading = null;
|
|
201
201
|
const paragraphNodes = getDocumentParagraphNodes(documentPart.doc);
|
|
202
|
-
const
|
|
202
|
+
const revisionView = options.revisionView === 'rejected' ? 'rejected' : 'accepted';
|
|
203
|
+
const commentAnchors = collectDocumentCommentAnchors(paragraphNodes, revisionView);
|
|
203
204
|
let paragraphs = paragraphNodes.map((paragraph, zeroIndex) => {
|
|
204
|
-
const text = extractCanonicalParagraphText(paragraph, { revisionView
|
|
205
|
+
const text = extractCanonicalParagraphText(paragraph, { revisionView });
|
|
205
206
|
const level = headingLevel(paragraph);
|
|
206
207
|
if (level) nearestHeading = { level, text };
|
|
207
208
|
const ids = [...new Set([...descendants(paragraph, 'commentRangeStart'), ...descendants(paragraph, 'commentReference')].map(node => attr(node, 'id')).filter(Boolean))];
|
|
@@ -215,7 +216,8 @@ export function inspectDocumentParts(parts, options = {}) {
|
|
|
215
216
|
const humanReference = [provision, headingText, text.slice(0, options.excerptLength || 120)].filter(Boolean).join(' — ');
|
|
216
217
|
const segments = extractParagraphRevisionSegments(paragraph);
|
|
217
218
|
return {
|
|
218
|
-
index, ref: `P${index}`, paragraphId: getParagraphId(paragraph),
|
|
219
|
+
index, ref: `P${index}`, paragraphId: getParagraphId(paragraph),
|
|
220
|
+
fingerprint: createParagraphFingerprint(paragraph, { text, index, revisionView }), revisionView,
|
|
219
221
|
text, exactText: text, excerpt: text.slice(0, options.excerptLength || 120), humanReference, inTable: hasAncestor(paragraph, 'tc'), table: structure.table,
|
|
220
222
|
styleId, headingLevel: level, nearestHeading, list, structuralReferences: structure.references, hasRevisions: authors.length > 0, revisionAuthors: authors, commentIds: ids,
|
|
221
223
|
segments
|