@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,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only preflight for document operation batches.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
6
|
+
import { getDefaultAuthor } from '../adapters/config.js';
|
|
7
|
+
import { containsTrackedChanges, getTrackedChangeAuthors } from '../core/word-xml.js';
|
|
8
|
+
import { NS_W } from '../core/types.js';
|
|
9
|
+
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
10
|
+
import {
|
|
11
|
+
buildParagraphMetadataIndex,
|
|
12
|
+
createParagraphFingerprint,
|
|
13
|
+
findContainingWordElement,
|
|
14
|
+
getDocumentParagraphNodes,
|
|
15
|
+
getParagraphId,
|
|
16
|
+
normalizeWhitespaceForTargeting,
|
|
17
|
+
resolveTargetParagraph,
|
|
18
|
+
validateParagraphBoundaryMutation
|
|
19
|
+
} from '../core/paragraph-targeting.js';
|
|
20
|
+
import { createParagraphTextIndex, resolveTextInParagraphIndex } from './comment-locator.js';
|
|
21
|
+
import {
|
|
22
|
+
normalizeDocumentOperation,
|
|
23
|
+
resolveDocumentOperationAuthor,
|
|
24
|
+
validateDocumentOperation
|
|
25
|
+
} from './document-operation-contract.js';
|
|
26
|
+
import { buildOperationDependencyPlan } from './batch-operation-orchestrator.js';
|
|
27
|
+
|
|
28
|
+
function normalizedError(error) {
|
|
29
|
+
return {
|
|
30
|
+
code: typeof error?.code === 'string' && error.code ? error.code : 'OPERATION_ERROR',
|
|
31
|
+
message: error?.message || String(error),
|
|
32
|
+
...(Array.isArray(error?.candidates) ? { candidates: error.candidates } : {})
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function operationNeedsNumbering(operation) {
|
|
37
|
+
if (operation.operationKind !== 'redline' || typeof operation.modified !== 'string') return false;
|
|
38
|
+
return operation.modified.split(/\r?\n/).some(line => /^\s*(?:[-+*]|\d+[.)])\s+/.test(line));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function targetMetadata(xmlDoc, paragraph, resolvedBy, suppliedText, paragraphMetadataIndex = null, revisionView = 'accepted') {
|
|
42
|
+
const cached = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
|
|
43
|
+
const paragraphs = cached ? null : getDocumentParagraphNodes(xmlDoc);
|
|
44
|
+
const actualText = cached?.text ?? extractCanonicalParagraphText(paragraph, { revisionView });
|
|
45
|
+
const normalizedSupplied = normalizeWhitespaceForTargeting(suppliedText || '');
|
|
46
|
+
const normalizedActual = normalizeWhitespaceForTargeting(actualText);
|
|
47
|
+
return {
|
|
48
|
+
resolvedBy,
|
|
49
|
+
resolvedTarget: {
|
|
50
|
+
index: cached?.index ?? paragraphs.indexOf(paragraph) + 1,
|
|
51
|
+
paragraphId: cached?.paragraphId ?? getParagraphId(paragraph),
|
|
52
|
+
text: actualText,
|
|
53
|
+
fingerprint: cached?.fingerprint ?? createParagraphFingerprint(paragraph, { text: actualText, revisionView }),
|
|
54
|
+
inTable: cached?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
|
|
55
|
+
revisionView
|
|
56
|
+
},
|
|
57
|
+
matchDiagnostics: {
|
|
58
|
+
exactTextMatch: typeof suppliedText === 'string' && suppliedText === actualText,
|
|
59
|
+
normalizedTextMatch: !!normalizedSupplied && normalizedSupplied === normalizedActual,
|
|
60
|
+
suppliedText: suppliedText || '',
|
|
61
|
+
actualText,
|
|
62
|
+
revisionView
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildConflict(code, message, operationIndexes, target) {
|
|
68
|
+
return { code, message, operationIndexes, target };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getCommentIdsInParagraph(paragraph) {
|
|
72
|
+
const ids = new Set();
|
|
73
|
+
for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
|
|
74
|
+
for (const node of Array.from(paragraph?.getElementsByTagNameNS?.('*', localName) || [])) {
|
|
75
|
+
const id = node.getAttribute('w:id') || node.getAttribute('id');
|
|
76
|
+
if (id !== '') ids.add(id);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return Array.from(ids).sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function preflightOperations(documentXml, operations, author, options = {}) {
|
|
83
|
+
const parsed = parseOoxmlSafe(documentXml, 'application/xml');
|
|
84
|
+
if (parsed.error || !parsed.doc) {
|
|
85
|
+
return {
|
|
86
|
+
valid: false,
|
|
87
|
+
status: 'error',
|
|
88
|
+
error: parsed.error,
|
|
89
|
+
results: [],
|
|
90
|
+
conflicts: [],
|
|
91
|
+
authorsUsed: [],
|
|
92
|
+
requiredArtifacts: { comments: false, numbering: false }
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const xmlDoc = parsed.doc;
|
|
97
|
+
const metadataIndices = {
|
|
98
|
+
accepted: buildParagraphMetadataIndex(xmlDoc, { revisionView: 'accepted' }),
|
|
99
|
+
rejected: null
|
|
100
|
+
};
|
|
101
|
+
const sourceOperations = Array.isArray(operations) ? operations : [];
|
|
102
|
+
const dependencyPlan = buildOperationDependencyPlan(sourceOperations);
|
|
103
|
+
if (!dependencyPlan.valid) {
|
|
104
|
+
return {
|
|
105
|
+
valid: false,
|
|
106
|
+
status: 'error',
|
|
107
|
+
error: dependencyPlan.error,
|
|
108
|
+
results: [],
|
|
109
|
+
conflicts: [],
|
|
110
|
+
authorsUsed: [],
|
|
111
|
+
requiredArtifacts: { comments: false, numbering: false }
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const strictTargets = options.strictTargets !== false;
|
|
116
|
+
const results = [];
|
|
117
|
+
const authorsUsed = new Set();
|
|
118
|
+
let commentsRequired = false;
|
|
119
|
+
let numberingRequired = false;
|
|
120
|
+
|
|
121
|
+
for (let index = 0; index < sourceOperations.length; index++) {
|
|
122
|
+
const sourceOperation = sourceOperations[index];
|
|
123
|
+
const validation = validateDocumentOperation(sourceOperation);
|
|
124
|
+
const fallbackOperation = normalizeDocumentOperation(sourceOperation);
|
|
125
|
+
const operation = validation.operation || fallbackOperation;
|
|
126
|
+
const authorUsed = resolveDocumentOperationAuthor(operation, author, getDefaultAuthor());
|
|
127
|
+
authorsUsed.add(authorUsed);
|
|
128
|
+
|
|
129
|
+
if (!validation.valid) {
|
|
130
|
+
results.push({
|
|
131
|
+
index: index + 1,
|
|
132
|
+
type: sourceOperation?.type || 'redline',
|
|
133
|
+
operationType: operation.operationKind,
|
|
134
|
+
status: 'error',
|
|
135
|
+
authorUsed,
|
|
136
|
+
error: validation.error
|
|
137
|
+
});
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
commentsRequired = commentsRequired || operation.operationKind === 'comment' || operation.operationKind === 'comment_reply';
|
|
142
|
+
numberingRequired = numberingRequired || operationNeedsNumbering(operation);
|
|
143
|
+
|
|
144
|
+
if (operation.operationKind === 'comment_reply') {
|
|
145
|
+
const parentId = String(operation.parentCommentId);
|
|
146
|
+
const parent = options._existingCommentDetails?.[parentId];
|
|
147
|
+
results.push(parent ? {
|
|
148
|
+
index: index + 1, type: sourceOperation.type, operationType: operation.operationKind,
|
|
149
|
+
status: 'ready', authorUsed, resolvedBy: 'parent_comment', parentCommentId: parentId
|
|
150
|
+
} : {
|
|
151
|
+
index: index + 1, type: sourceOperation.type, operationType: operation.operationKind,
|
|
152
|
+
status: 'error', authorUsed,
|
|
153
|
+
error: { code: 'PARENT_COMMENT_NOT_FOUND', message: `Parent comment '${parentId}' was not found.` }
|
|
154
|
+
});
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (operation.targetDescriptor?.captureRef) {
|
|
159
|
+
results.push({
|
|
160
|
+
index: index + 1,
|
|
161
|
+
type: sourceOperation?.type || 'redline',
|
|
162
|
+
operationType: operation.operationKind,
|
|
163
|
+
status: 'deferred',
|
|
164
|
+
authorUsed,
|
|
165
|
+
resolvedBy: 'capture',
|
|
166
|
+
captureRef: operation.targetDescriptor.captureRef,
|
|
167
|
+
...(operation.targetDescriptor.select ? { select: operation.targetDescriptor.select } : {})
|
|
168
|
+
});
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const targetView = operation.targetDescriptor?.revisionView === 'rejected' ? 'rejected' : 'accepted';
|
|
173
|
+
let currentMetadataIndex = targetView === 'rejected'
|
|
174
|
+
? (metadataIndices.rejected || (metadataIndices.rejected = buildParagraphMetadataIndex(xmlDoc, { revisionView: 'rejected' })))
|
|
175
|
+
: metadataIndices.accepted;
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const resolved = resolveTargetParagraph(xmlDoc, {
|
|
179
|
+
targetText: operation.target,
|
|
180
|
+
targetRef: operation.targetRef,
|
|
181
|
+
targetDescriptor: operation.targetDescriptor,
|
|
182
|
+
opType: operation.operationKind,
|
|
183
|
+
strictAmbiguity: strictTargets,
|
|
184
|
+
paragraphMetadataIndex: currentMetadataIndex,
|
|
185
|
+
metadataIndices,
|
|
186
|
+
onInfo: options.onInfo,
|
|
187
|
+
onWarn: options.onWarn
|
|
188
|
+
});
|
|
189
|
+
const paragraph = resolved.paragraph;
|
|
190
|
+
const metadata = targetMetadata(xmlDoc, paragraph, resolved.resolvedBy, operation.target, currentMetadataIndex, targetView);
|
|
191
|
+
const paragraphText = metadata.resolvedTarget.text;
|
|
192
|
+
const anchor = operation.operationKind === 'comment'
|
|
193
|
+
? (operation.textToComment || paragraphText)
|
|
194
|
+
: (operation.operationKind === 'highlight'
|
|
195
|
+
? operation.textToHighlight
|
|
196
|
+
: (operation.operationKind === 'format' ? operation.textToFormat : null));
|
|
197
|
+
const anchorResolution = operation.operationKind === 'comment' && anchor != null
|
|
198
|
+
? resolveTextInParagraphIndex(createParagraphTextIndex(paragraph, { revisionView: targetView }), anchor)
|
|
199
|
+
: null;
|
|
200
|
+
const anchorFound = anchor == null
|
|
201
|
+
|| (anchorResolution ? anchorResolution.found : paragraphText.includes(anchor));
|
|
202
|
+
const hasRevisions = containsTrackedChanges(paragraph);
|
|
203
|
+
const existingPolicy = operation.existingRevisions
|
|
204
|
+
|| options.existingRevisions
|
|
205
|
+
|| 'merge-same-author';
|
|
206
|
+
const deletingWholeParagraph = operation.operationKind === 'redline' && operation.modified === '';
|
|
207
|
+
const commentIds = deletingWholeParagraph ? getCommentIdsInParagraph(paragraph) : [];
|
|
208
|
+
|
|
209
|
+
let error = null;
|
|
210
|
+
if (!anchorFound) {
|
|
211
|
+
error = anchorResolution?.error || {
|
|
212
|
+
code: 'ANCHOR_NOT_FOUND',
|
|
213
|
+
message: `Anchor text was not found in target paragraph: "${anchor}".`
|
|
214
|
+
};
|
|
215
|
+
} else if (commentIds.length > 0) {
|
|
216
|
+
const comments = commentIds
|
|
217
|
+
.map(id => options._existingCommentDetails?.[id])
|
|
218
|
+
.filter(Boolean);
|
|
219
|
+
error = {
|
|
220
|
+
code: 'COMMENTED_CONTENT_DELETE',
|
|
221
|
+
message: 'Refusing to delete a paragraph with existing comments. Resolve or explicitly remove the comments before deleting the paragraph.',
|
|
222
|
+
commentIds,
|
|
223
|
+
...(comments.length > 0 ? { comments } : {})
|
|
224
|
+
};
|
|
225
|
+
} else if (
|
|
226
|
+
operation.operationKind === 'redline'
|
|
227
|
+
&& hasRevisions
|
|
228
|
+
&& existingPolicy !== 'accept-all-first'
|
|
229
|
+
&& existingPolicy !== 'accept-all-first-keep-normalized'
|
|
230
|
+
) {
|
|
231
|
+
if (existingPolicy === 'merge-same-author') {
|
|
232
|
+
const authors = getTrackedChangeAuthors(paragraph);
|
|
233
|
+
const opAuthor = String(authorUsed || '').trim().toLowerCase();
|
|
234
|
+
const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
|
|
235
|
+
if (!allSame) {
|
|
236
|
+
error = {
|
|
237
|
+
code: 'EXISTING_REVISIONS',
|
|
238
|
+
message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Pass existingRevisions: "accept-all-first" or resolve revisions first.`
|
|
239
|
+
};
|
|
240
|
+
} else {
|
|
241
|
+
const mergeCommentIds = getCommentIdsInParagraph(paragraph);
|
|
242
|
+
if (mergeCommentIds.length > 0) {
|
|
243
|
+
const comments = mergeCommentIds
|
|
244
|
+
.map(id => options._existingCommentDetails?.[id])
|
|
245
|
+
.filter(Boolean);
|
|
246
|
+
error = {
|
|
247
|
+
code: 'COMMENTED_CONTENT_MERGE',
|
|
248
|
+
message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
|
|
249
|
+
commentIds: mergeCommentIds,
|
|
250
|
+
...(comments.length > 0 ? { comments } : {})
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} else {
|
|
255
|
+
const hasDel = paragraph.getElementsByTagNameNS(NS_W, 'del').length > 0;
|
|
256
|
+
const hasMove = paragraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
|
|
257
|
+
|| paragraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
|
|
258
|
+
if (hasDel) {
|
|
259
|
+
error = {
|
|
260
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
261
|
+
message: 'Refusing to replace content with pending deletions; nesting revisions is unsafe.'
|
|
262
|
+
};
|
|
263
|
+
} else if (hasMove) {
|
|
264
|
+
error = {
|
|
265
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
266
|
+
message: 'Refusing to mutate content with move revisions until move lifecycle is designed.'
|
|
267
|
+
};
|
|
268
|
+
} else {
|
|
269
|
+
error = {
|
|
270
|
+
code: 'EXISTING_REVISIONS',
|
|
271
|
+
message: 'Target paragraph contains tracked changes and existingRevisions is "reject-input".'
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!error && operation.operationKind === 'redline') {
|
|
278
|
+
const boundaryCheck = validateParagraphBoundaryMutation(paragraph, operation.modified, options);
|
|
279
|
+
if (!boundaryCheck.valid) {
|
|
280
|
+
error = {
|
|
281
|
+
code: boundaryCheck.code,
|
|
282
|
+
message: boundaryCheck.message
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
results.push({
|
|
288
|
+
index: index + 1,
|
|
289
|
+
type: sourceOperation?.type || 'redline',
|
|
290
|
+
operationType: operation.operationKind,
|
|
291
|
+
status: error ? 'error' : 'ready',
|
|
292
|
+
authorUsed,
|
|
293
|
+
...metadata,
|
|
294
|
+
anchor: anchor == null ? null : {
|
|
295
|
+
text: anchor,
|
|
296
|
+
found: anchorFound,
|
|
297
|
+
...(anchorResolution?.found ? {
|
|
298
|
+
resolvedBy: anchorResolution.resolvedBy,
|
|
299
|
+
start: anchorResolution.start,
|
|
300
|
+
end: anchorResolution.end
|
|
301
|
+
} : {}),
|
|
302
|
+
...(!anchorResolution?.found && Array.isArray(anchorResolution?.error?.candidates)
|
|
303
|
+
? { candidates: anchorResolution.error.candidates }
|
|
304
|
+
: {})
|
|
305
|
+
},
|
|
306
|
+
hasRevisions,
|
|
307
|
+
existingRevisions: existingPolicy,
|
|
308
|
+
...(resolved?.warnings?.length ? { warnings: resolved.warnings } : {}),
|
|
309
|
+
...(error ? { error } : {})
|
|
310
|
+
});
|
|
311
|
+
} catch (error) {
|
|
312
|
+
results.push({
|
|
313
|
+
index: index + 1,
|
|
314
|
+
type: sourceOperation?.type || 'redline',
|
|
315
|
+
operationType: operation.operationKind,
|
|
316
|
+
status: 'error',
|
|
317
|
+
authorUsed,
|
|
318
|
+
error: normalizedError(error)
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const conflicts = [];
|
|
324
|
+
const byTarget = new Map();
|
|
325
|
+
for (const result of results) {
|
|
326
|
+
const targetIndex = result.resolvedTarget?.index;
|
|
327
|
+
if (!targetIndex) continue;
|
|
328
|
+
if (!byTarget.has(targetIndex)) byTarget.set(targetIndex, []);
|
|
329
|
+
byTarget.get(targetIndex).push(result);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
for (const [targetIndex, targetResults] of byTarget) {
|
|
333
|
+
const redlines = targetResults.filter(result => result.operationType === 'redline');
|
|
334
|
+
const highlights = targetResults.filter(result => result.operationType === 'highlight');
|
|
335
|
+
const target = targetResults[0].resolvedTarget;
|
|
336
|
+
if (redlines.length > 1) {
|
|
337
|
+
conflicts.push(buildConflict(
|
|
338
|
+
'OVERLAPPING_TEXT_EDITS',
|
|
339
|
+
`Multiple text edits target paragraph ${targetIndex}; later operations may use a stale anchor.`,
|
|
340
|
+
redlines.map(result => result.index),
|
|
341
|
+
target
|
|
342
|
+
));
|
|
343
|
+
}
|
|
344
|
+
if (redlines.length > 0 && highlights.length > 0) {
|
|
345
|
+
conflicts.push(buildConflict(
|
|
346
|
+
'REVISION_ORDER_CONFLICT',
|
|
347
|
+
`A text edit and highlight target paragraph ${targetIndex}; operation order can invalidate the target or existing-revision policy.`,
|
|
348
|
+
[...redlines, ...highlights].map(result => result.index).sort((a, b) => a - b),
|
|
349
|
+
target
|
|
350
|
+
));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const hasErrors = results.some(result => result.status === 'error');
|
|
355
|
+
return {
|
|
356
|
+
valid: !hasErrors && conflicts.length === 0,
|
|
357
|
+
status: !hasErrors && conflicts.length === 0 ? 'ok' : 'error',
|
|
358
|
+
results,
|
|
359
|
+
conflicts,
|
|
360
|
+
authorsUsed: Array.from(authorsUsed),
|
|
361
|
+
requiredArtifacts: {
|
|
362
|
+
comments: commentsRequired,
|
|
363
|
+
numbering: numberingRequired
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal mutation receipt collector for tracking allocated revision,
|
|
3
|
+
* comment, numbering, and relationship IDs per operation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class ReceiptCollector {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.receipts = [];
|
|
9
|
+
this.activeReceipt = null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
beginOperation(operationIndex, operationId = null, authorUsed = null) {
|
|
13
|
+
this.activeReceipt = {
|
|
14
|
+
operationIndex: typeof operationIndex === 'number' ? operationIndex : 1,
|
|
15
|
+
...(operationId ? { operationId: String(operationId) } : {}),
|
|
16
|
+
attemptedDisposition: 'applied',
|
|
17
|
+
finalDisposition: 'applied',
|
|
18
|
+
committed: true,
|
|
19
|
+
...(authorUsed ? { authorUsed: String(authorUsed) } : {}),
|
|
20
|
+
revisionItems: [],
|
|
21
|
+
commentIds: [],
|
|
22
|
+
numberingIds: [],
|
|
23
|
+
relationshipIds: [],
|
|
24
|
+
affectedTargets: [],
|
|
25
|
+
warnings: []
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
recordRevision(id, kind = 'structural', partName = 'word/document.xml') {
|
|
30
|
+
if (!this.activeReceipt || id == null) return;
|
|
31
|
+
const strId = String(id);
|
|
32
|
+
if (!this.activeReceipt.revisionItems.some(item => item.id === strId && item.kind === kind && item.partName === partName)) {
|
|
33
|
+
this.activeReceipt.revisionItems.push({
|
|
34
|
+
id: strId,
|
|
35
|
+
kind,
|
|
36
|
+
partName
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
recordComment(id, _partName = 'word/comments.xml') {
|
|
42
|
+
if (!this.activeReceipt || id == null) return;
|
|
43
|
+
const strId = String(id);
|
|
44
|
+
if (!this.activeReceipt.commentIds.includes(strId)) {
|
|
45
|
+
this.activeReceipt.commentIds.push(strId);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
recordNumbering(id, _partName = 'word/numbering.xml') {
|
|
50
|
+
if (!this.activeReceipt || id == null) return;
|
|
51
|
+
const strId = String(id);
|
|
52
|
+
if (!this.activeReceipt.numberingIds.includes(strId)) {
|
|
53
|
+
this.activeReceipt.numberingIds.push(strId);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
recordRelationship(id, _partName = 'word/_rels/document.xml.rels') {
|
|
58
|
+
if (!this.activeReceipt || id == null) return;
|
|
59
|
+
const strId = String(id);
|
|
60
|
+
if (!this.activeReceipt.relationshipIds.includes(strId)) {
|
|
61
|
+
this.activeReceipt.relationshipIds.push(strId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
recordAffectedTarget(target) {
|
|
66
|
+
if (!this.activeReceipt || !target) return;
|
|
67
|
+
this.activeReceipt.affectedTargets.push(JSON.parse(JSON.stringify(target)));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
recordWarning(warning) {
|
|
71
|
+
if (!this.activeReceipt || !warning) return;
|
|
72
|
+
this.activeReceipt.warnings.push(String(warning));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
commitOperation(disposition = 'applied') {
|
|
76
|
+
if (!this.activeReceipt) return null;
|
|
77
|
+
this.activeReceipt.attemptedDisposition = disposition;
|
|
78
|
+
this.activeReceipt.finalDisposition = disposition;
|
|
79
|
+
this.activeReceipt.committed = disposition === 'applied';
|
|
80
|
+
const committed = JSON.parse(JSON.stringify(this.activeReceipt));
|
|
81
|
+
this.receipts.push(committed);
|
|
82
|
+
this.activeReceipt = null;
|
|
83
|
+
return committed;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
abortOperation(disposition = 'refused') {
|
|
87
|
+
if (!this.activeReceipt) return null;
|
|
88
|
+
this.activeReceipt.attemptedDisposition = disposition;
|
|
89
|
+
this.activeReceipt.finalDisposition = disposition;
|
|
90
|
+
this.activeReceipt.committed = false;
|
|
91
|
+
const aborted = JSON.parse(JSON.stringify(this.activeReceipt));
|
|
92
|
+
this.activeReceipt = null;
|
|
93
|
+
return aborted;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
createSavepoint() {
|
|
97
|
+
return {
|
|
98
|
+
receipts: JSON.parse(JSON.stringify(this.receipts)),
|
|
99
|
+
activeReceipt: this.activeReceipt ? JSON.parse(JSON.stringify(this.activeReceipt)) : null
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
restoreSavepoint(savepoint) {
|
|
104
|
+
if (!savepoint) return;
|
|
105
|
+
this.receipts = Array.isArray(savepoint.receipts)
|
|
106
|
+
? JSON.parse(JSON.stringify(savepoint.receipts))
|
|
107
|
+
: [];
|
|
108
|
+
this.activeReceipt = savepoint.activeReceipt
|
|
109
|
+
? JSON.parse(JSON.stringify(savepoint.activeReceipt))
|
|
110
|
+
: null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
clear() {
|
|
114
|
+
this.receipts = [];
|
|
115
|
+
this.activeReceipt = null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
markRolledBack() {
|
|
119
|
+
for (const receipt of this.receipts) {
|
|
120
|
+
if (receipt.attemptedDisposition === 'applied') {
|
|
121
|
+
receipt.finalDisposition = 'rolled_back';
|
|
122
|
+
receipt.committed = false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
this.activeReceipt = null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
getReceipts() {
|
|
129
|
+
return JSON.parse(JSON.stringify(this.receipts));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
getCurrentReceipt() {
|
|
133
|
+
return this.activeReceipt ? JSON.parse(JSON.stringify(this.activeReceipt)) : null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Creates an empty receipt structure for unattempted, refused, or no-op operations.
|
|
139
|
+
*
|
|
140
|
+
* @param {number} operationIndex
|
|
141
|
+
* @param {string|null} [operationId]
|
|
142
|
+
* @param {string|null} [authorUsed]
|
|
143
|
+
* @param {'applied'|'no_change'|'refused'|'not_attempted'} [disposition]
|
|
144
|
+
* @returns {Object}
|
|
145
|
+
*/
|
|
146
|
+
export function createEmptyReceipt(operationIndex, operationId = null, authorUsed = null, disposition = 'not_attempted') {
|
|
147
|
+
return {
|
|
148
|
+
operationIndex: typeof operationIndex === 'number' ? operationIndex : 1,
|
|
149
|
+
...(operationId ? { operationId: String(operationId) } : {}),
|
|
150
|
+
attemptedDisposition: disposition,
|
|
151
|
+
finalDisposition: disposition,
|
|
152
|
+
committed: false,
|
|
153
|
+
...(authorUsed ? { authorUsed: String(authorUsed) } : {}),
|
|
154
|
+
revisionItems: [],
|
|
155
|
+
commentIds: [],
|
|
156
|
+
numberingIds: [],
|
|
157
|
+
relationshipIds: [],
|
|
158
|
+
affectedTargets: [],
|
|
159
|
+
warnings: []
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Reconciles committed mutation receipts against the generated/committed output parts.
|
|
165
|
+
*
|
|
166
|
+
* @param {Object} parts
|
|
167
|
+
* @param {string} parts.documentXml - Serialized word/document.xml
|
|
168
|
+
* @param {string|null} [parts.commentsXml] - Serialized word/comments.xml
|
|
169
|
+
* @param {string|null} [parts.numberingXml] - Serialized word/numbering.xml
|
|
170
|
+
* @param {string[]} [parts.numberingXmlParts] - Additional numbering XML parts
|
|
171
|
+
* @param {string|null} [parts.relationshipsXml] - Serialized word/_rels/document.xml.rels
|
|
172
|
+
* @param {Array<Object>} receipts - Array of MutationReceipt objects
|
|
173
|
+
* @returns {{ valid: boolean, error?: { code: string, message: string } }}
|
|
174
|
+
*/
|
|
175
|
+
export function reconcileReceiptsAgainstOutput(parts, receipts) {
|
|
176
|
+
if (!Array.isArray(receipts) || receipts.length === 0) {
|
|
177
|
+
return { valid: true };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const committedReceipts = receipts.filter(r => r && r.committed === true && r.finalDisposition === 'applied');
|
|
181
|
+
if (committedReceipts.length === 0) {
|
|
182
|
+
return { valid: true };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const revisionIdSet = new Set();
|
|
186
|
+
if (parts?.documentXml && typeof parts.documentXml === 'string') {
|
|
187
|
+
const revRegex = /<(?:w:)?(?:ins|del|rPrChange|pPrChange|moveFrom|moveTo)\b[^>]*?\b(?:w:)?id="([^"]+)"/g;
|
|
188
|
+
let m;
|
|
189
|
+
while ((m = revRegex.exec(parts.documentXml)) !== null) {
|
|
190
|
+
revisionIdSet.add(m[1]);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const commentIdSet = new Set();
|
|
195
|
+
if (parts?.commentsXml && typeof parts.commentsXml === 'string') {
|
|
196
|
+
const comRegex = /<(?:w:)?comment\b[^>]*?\b(?:w:)?id="([^"]+)"/g;
|
|
197
|
+
let m;
|
|
198
|
+
while ((m = comRegex.exec(parts.commentsXml)) !== null) {
|
|
199
|
+
commentIdSet.add(m[1]);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const numberingIdSet = new Set();
|
|
204
|
+
const combinedNumbering = [parts?.numberingXml, ...(parts?.numberingXmlParts || [])].filter(Boolean).join('\n');
|
|
205
|
+
if (combinedNumbering) {
|
|
206
|
+
const numRegex = /<(?:w:)?num\b[^>]*?\b(?:w:)?numId="([^"]+)"/g;
|
|
207
|
+
let m;
|
|
208
|
+
while ((m = numRegex.exec(combinedNumbering)) !== null) {
|
|
209
|
+
numberingIdSet.add(m[1]);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (parts?.documentXml && typeof parts.documentXml === 'string') {
|
|
213
|
+
const docNumRegex = /<(?:w:)?numId\b[^>]*?\b(?:w:)?val="([^"]+)"/g;
|
|
214
|
+
let m;
|
|
215
|
+
while ((m = docNumRegex.exec(parts.documentXml)) !== null) {
|
|
216
|
+
numberingIdSet.add(m[1]);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const relIdSet = new Set();
|
|
221
|
+
if (parts?.relationshipsXml && typeof parts.relationshipsXml === 'string') {
|
|
222
|
+
const relRegex = /<Relationship\b[^>]*?\bId="([^"]+)"/g;
|
|
223
|
+
let m;
|
|
224
|
+
while ((m = relRegex.exec(parts.relationshipsXml)) !== null) {
|
|
225
|
+
relIdSet.add(m[1]);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const receipt of committedReceipts) {
|
|
230
|
+
if (Array.isArray(receipt.revisionItems)) {
|
|
231
|
+
for (const item of receipt.revisionItems) {
|
|
232
|
+
if (item.partName === 'word/document.xml' && !revisionIdSet.has(String(item.id))) {
|
|
233
|
+
return {
|
|
234
|
+
valid: false,
|
|
235
|
+
error: {
|
|
236
|
+
code: 'RECEIPT_RECONCILIATION_FAILED',
|
|
237
|
+
message: `Committed revision id '${item.id}' (kind: ${item.kind}) was not found in word/document.xml.`
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (Array.isArray(receipt.commentIds)) {
|
|
245
|
+
for (const id of receipt.commentIds) {
|
|
246
|
+
if (!commentIdSet.has(String(id))) {
|
|
247
|
+
return {
|
|
248
|
+
valid: false,
|
|
249
|
+
error: {
|
|
250
|
+
code: 'RECEIPT_RECONCILIATION_FAILED',
|
|
251
|
+
message: `Committed comment id '${id}' was not found in word/comments.xml.`
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (Array.isArray(receipt.numberingIds)) {
|
|
259
|
+
for (const id of receipt.numberingIds) {
|
|
260
|
+
if (!numberingIdSet.has(String(id))) {
|
|
261
|
+
return {
|
|
262
|
+
valid: false,
|
|
263
|
+
error: {
|
|
264
|
+
code: 'RECEIPT_RECONCILIATION_FAILED',
|
|
265
|
+
message: `Committed numbering id '${id}' was not found in numbering parts or document.`
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (parts?.relationshipsXml && Array.isArray(receipt.relationshipIds)) {
|
|
273
|
+
for (const id of receipt.relationshipIds) {
|
|
274
|
+
if (!relIdSet.has(String(id))) {
|
|
275
|
+
return {
|
|
276
|
+
valid: false,
|
|
277
|
+
error: {
|
|
278
|
+
code: 'RECEIPT_RECONCILIATION_FAILED',
|
|
279
|
+
message: `Committed relationship id '${id}' was not found in document.xml.rels.`
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { valid: true };
|
|
288
|
+
}
|