@ansonlai/docx-redline-js 0.4.0 → 0.5.1

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.
Files changed (104) hide show
  1. package/AGENTS.md +646 -288
  2. package/ARCHITECTURE.md +215 -9
  3. package/CHANGELOG.md +319 -0
  4. package/README.md +604 -360
  5. package/adapters/config.js +45 -43
  6. package/bin/docx-redline.js +3 -0
  7. package/core/list-targeting.js +101 -110
  8. package/core/paragraph-targeting.js +501 -61
  9. package/core/paragraph-text.js +209 -0
  10. package/core/redline-validation.js +11 -5
  11. package/core/revision-cloning.js +38 -0
  12. package/core/types.js +64 -10
  13. package/core/word-xml.js +43 -15
  14. package/dist/docx-redline-js.esm.js +3145 -505
  15. package/dist/docx-redline-js.esm.js.map +4 -4
  16. package/dist/docx-redline-js.esm.min.js +88 -76
  17. package/dist/docx-redline-js.esm.min.js.map +4 -4
  18. package/docs/TESTING.md +342 -23
  19. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  20. package/docs/plans/2026-09-08-cross-author-revision-slicing.md +505 -0
  21. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  22. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  23. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  24. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  25. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  26. package/docs/schemas/document-operations.schema.json +109 -0
  27. package/docs/test-comparison-dashboard.html +4250 -7
  28. package/engine/formatting-removal.js +11 -2
  29. package/engine/oxml-engine.js +508 -336
  30. package/engine/reconstruction-mode.js +15 -14
  31. package/engine/reconstruction-writer.js +247 -142
  32. package/engine/route-selection.js +35 -0
  33. package/engine/rpr-helpers.js +334 -35
  34. package/engine/run-builders.js +239 -196
  35. package/engine/surgical-diff-application.js +407 -50
  36. package/engine/surgical-mode.js +142 -6
  37. package/engine/surgical-run-splitting.js +103 -0
  38. package/engine/surgical-spans.js +52 -1
  39. package/engine/table-cell-context.js +3 -6
  40. package/engine/table-mode.js +1 -1
  41. package/index.d.ts +234 -6
  42. package/index.js +24 -1
  43. package/node/cli.js +322 -0
  44. package/node/docx-document.js +302 -0
  45. package/node/index.d.ts +31 -0
  46. package/node/index.js +2 -0
  47. package/node/zip-archive.js +52 -0
  48. package/orchestration/list-markdown.js +10 -16
  49. package/orchestration/list-parsing.js +7 -12
  50. package/orchestration/list-structural-fallback.js +21 -10
  51. package/package.json +123 -102
  52. package/pipeline/content-analysis.js +12 -17
  53. package/pipeline/ingestion-export.js +3 -31
  54. package/pipeline/ingestion-paragraph.js +10 -5
  55. package/pipeline/list-generation.js +150 -55
  56. package/pipeline/list-markers.js +70 -3
  57. package/pipeline/serialization.js +4 -2
  58. package/pipeline/structured-content.js +160 -0
  59. package/scripts/apply_changes.mjs +27 -0
  60. package/scripts/benchmark-operation-session.mjs +137 -0
  61. package/scripts/benchmark-targeting-browser.html +74 -0
  62. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  63. package/scripts/benchmark-test-runner.mjs +59 -0
  64. package/scripts/build-test-dashboard.mjs +23 -0
  65. package/scripts/export-lane1-fixtures.mjs +380 -0
  66. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  67. package/scripts/export-validation-fixtures.mjs +1 -1
  68. package/scripts/extract_text.mjs +7 -0
  69. package/scripts/generate-cross-author-slicing-fixtures.ps1 +256 -0
  70. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  71. package/scripts/generate-test-dashboard.mjs +362 -11
  72. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  73. package/scripts/profile-route-selection.mjs +19 -0
  74. package/scripts/render-agenda-multilevel.mjs +0 -5
  75. package/scripts/render-multilevel-cases.mjs +0 -1
  76. package/scripts/run-tests.mjs +107 -35
  77. package/scripts/word-com-corpus-suite.ps1 +3 -0
  78. package/scripts/word-com-differential.ps1 +64 -4
  79. package/scripts/word-com-suite.ps1 +3 -0
  80. package/services/batch-operation-orchestrator.js +513 -0
  81. package/services/capture-engine.js +226 -0
  82. package/services/comment-builders.js +23 -6
  83. package/services/comment-engine.js +108 -47
  84. package/services/comment-locator.js +187 -82
  85. package/services/comment-replies.js +95 -0
  86. package/services/document-inspection.js +258 -0
  87. package/services/document-operation-applier.js +372 -0
  88. package/services/document-operation-contract.js +345 -0
  89. package/services/document-operation-mutations.js +1749 -0
  90. package/services/document-operation-session.js +258 -0
  91. package/services/numbering-service.js +14 -5
  92. package/services/operation-heuristics.js +173 -0
  93. package/services/operation-preflight.js +390 -0
  94. package/services/receipt-collector.js +288 -0
  95. package/services/revision-comment-management.js +77 -5
  96. package/services/revision-token.js +290 -0
  97. package/services/standalone-docx-plumbing.js +123 -8
  98. package/services/standalone-operation-runner.d.ts +296 -0
  99. package/services/standalone-operation-runner.js +10 -1455
  100. package/services/table-reconciliation.js +15 -6
  101. package/docs/VALIDATION.md +0 -183
  102. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  103. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  104. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -0,0 +1,390 @@
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
+ isExistingRevisionsPolicy,
23
+ normalizeDocumentOperation,
24
+ resolveDocumentOperationAuthor,
25
+ validateDocumentOperation
26
+ } from './document-operation-contract.js';
27
+ import { buildOperationDependencyPlan } from './batch-operation-orchestrator.js';
28
+
29
+ function normalizedError(error) {
30
+ return {
31
+ code: typeof error?.code === 'string' && error.code ? error.code : 'OPERATION_ERROR',
32
+ message: error?.message || String(error),
33
+ ...(Array.isArray(error?.candidates) ? { candidates: error.candidates } : {})
34
+ };
35
+ }
36
+
37
+ function operationNeedsNumbering(operation) {
38
+ if (operation.operationKind !== 'redline' || typeof operation.modified !== 'string') return false;
39
+ return operation.modified.split(/\r?\n/).some(line => /^\s*(?:[-+*]|\d+[.)])\s+/.test(line));
40
+ }
41
+
42
+ function targetMetadata(xmlDoc, paragraph, resolvedBy, suppliedText, paragraphMetadataIndex = null, revisionView = 'accepted') {
43
+ const cached = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
44
+ const paragraphs = cached ? null : getDocumentParagraphNodes(xmlDoc);
45
+ const actualText = cached?.text ?? extractCanonicalParagraphText(paragraph, { revisionView });
46
+ const normalizedSupplied = normalizeWhitespaceForTargeting(suppliedText || '');
47
+ const normalizedActual = normalizeWhitespaceForTargeting(actualText);
48
+ return {
49
+ resolvedBy,
50
+ resolvedTarget: {
51
+ index: cached?.index ?? paragraphs.indexOf(paragraph) + 1,
52
+ paragraphId: cached?.paragraphId ?? getParagraphId(paragraph),
53
+ text: actualText,
54
+ fingerprint: cached?.fingerprint ?? createParagraphFingerprint(paragraph, { text: actualText, revisionView }),
55
+ inTable: cached?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
56
+ revisionView
57
+ },
58
+ matchDiagnostics: {
59
+ exactTextMatch: typeof suppliedText === 'string' && suppliedText === actualText,
60
+ normalizedTextMatch: !!normalizedSupplied && normalizedSupplied === normalizedActual,
61
+ suppliedText: suppliedText || '',
62
+ actualText,
63
+ revisionView
64
+ }
65
+ };
66
+ }
67
+
68
+ function buildConflict(code, message, operationIndexes, target) {
69
+ return { code, message, operationIndexes, target };
70
+ }
71
+
72
+ function getCommentIdsInParagraph(paragraph) {
73
+ const ids = new Set();
74
+ for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
75
+ for (const node of Array.from(paragraph?.getElementsByTagNameNS?.('*', localName) || [])) {
76
+ const id = node.getAttribute('w:id') || node.getAttribute('id');
77
+ if (id !== '') ids.add(id);
78
+ }
79
+ }
80
+ return Array.from(ids).sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
81
+ }
82
+
83
+ export function preflightOperations(documentXml, operations, author, options = {}) {
84
+ if (options.existingRevisions != null && !isExistingRevisionsPolicy(options.existingRevisions)) {
85
+ return {
86
+ valid: false,
87
+ status: 'error',
88
+ error: {
89
+ code: 'INVALID_OPERATION',
90
+ message: `Unsupported existingRevisions policy: "${String(options.existingRevisions)}".`
91
+ },
92
+ results: [],
93
+ conflicts: [],
94
+ authorsUsed: [],
95
+ requiredArtifacts: { comments: false, numbering: false }
96
+ };
97
+ }
98
+ const parsed = parseOoxmlSafe(documentXml, 'application/xml');
99
+ if (parsed.error || !parsed.doc) {
100
+ return {
101
+ valid: false,
102
+ status: 'error',
103
+ error: parsed.error,
104
+ results: [],
105
+ conflicts: [],
106
+ authorsUsed: [],
107
+ requiredArtifacts: { comments: false, numbering: false }
108
+ };
109
+ }
110
+
111
+ const xmlDoc = parsed.doc;
112
+ const metadataIndices = {
113
+ accepted: buildParagraphMetadataIndex(xmlDoc, { revisionView: 'accepted' }),
114
+ rejected: null
115
+ };
116
+ const sourceOperations = Array.isArray(operations) ? operations : [];
117
+ const dependencyPlan = buildOperationDependencyPlan(sourceOperations);
118
+ if (!dependencyPlan.valid) {
119
+ return {
120
+ valid: false,
121
+ status: 'error',
122
+ error: dependencyPlan.error,
123
+ results: [],
124
+ conflicts: [],
125
+ authorsUsed: [],
126
+ requiredArtifacts: { comments: false, numbering: false }
127
+ };
128
+ }
129
+
130
+ const strictTargets = options.strictTargets !== false;
131
+ const results = [];
132
+ const authorsUsed = new Set();
133
+ let commentsRequired = false;
134
+ let numberingRequired = false;
135
+
136
+ for (let index = 0; index < sourceOperations.length; index++) {
137
+ const sourceOperation = sourceOperations[index];
138
+ const validation = validateDocumentOperation(sourceOperation);
139
+ const fallbackOperation = normalizeDocumentOperation(sourceOperation);
140
+ const operation = validation.operation || fallbackOperation;
141
+ const authorUsed = resolveDocumentOperationAuthor(operation, author, getDefaultAuthor());
142
+ authorsUsed.add(authorUsed);
143
+
144
+ if (!validation.valid) {
145
+ results.push({
146
+ index: index + 1,
147
+ type: sourceOperation?.type || 'redline',
148
+ operationType: operation.operationKind,
149
+ status: 'error',
150
+ authorUsed,
151
+ error: validation.error
152
+ });
153
+ continue;
154
+ }
155
+
156
+ commentsRequired = commentsRequired || operation.operationKind === 'comment' || operation.operationKind === 'comment_reply';
157
+ numberingRequired = numberingRequired || operationNeedsNumbering(operation);
158
+
159
+ if (operation.operationKind === 'comment_reply') {
160
+ const parentId = String(operation.parentCommentId);
161
+ const parent = options._existingCommentDetails?.[parentId];
162
+ results.push(parent ? {
163
+ index: index + 1, type: sourceOperation.type, operationType: operation.operationKind,
164
+ status: 'ready', authorUsed, resolvedBy: 'parent_comment', parentCommentId: parentId
165
+ } : {
166
+ index: index + 1, type: sourceOperation.type, operationType: operation.operationKind,
167
+ status: 'error', authorUsed,
168
+ error: { code: 'PARENT_COMMENT_NOT_FOUND', message: `Parent comment '${parentId}' was not found.` }
169
+ });
170
+ continue;
171
+ }
172
+
173
+ if (operation.targetDescriptor?.captureRef) {
174
+ results.push({
175
+ index: index + 1,
176
+ type: sourceOperation?.type || 'redline',
177
+ operationType: operation.operationKind,
178
+ status: 'deferred',
179
+ authorUsed,
180
+ resolvedBy: 'capture',
181
+ captureRef: operation.targetDescriptor.captureRef,
182
+ ...(operation.targetDescriptor.select ? { select: operation.targetDescriptor.select } : {})
183
+ });
184
+ continue;
185
+ }
186
+
187
+ const targetView = operation.targetDescriptor?.revisionView === 'rejected' ? 'rejected' : 'accepted';
188
+ let currentMetadataIndex = targetView === 'rejected'
189
+ ? (metadataIndices.rejected || (metadataIndices.rejected = buildParagraphMetadataIndex(xmlDoc, { revisionView: 'rejected' })))
190
+ : metadataIndices.accepted;
191
+
192
+ try {
193
+ const resolved = resolveTargetParagraph(xmlDoc, {
194
+ targetText: operation.target,
195
+ targetRef: operation.targetRef,
196
+ targetDescriptor: operation.targetDescriptor,
197
+ opType: operation.operationKind,
198
+ strictAmbiguity: strictTargets,
199
+ paragraphMetadataIndex: currentMetadataIndex,
200
+ metadataIndices,
201
+ onInfo: options.onInfo,
202
+ onWarn: options.onWarn
203
+ });
204
+ const paragraph = resolved.paragraph;
205
+ const metadata = targetMetadata(xmlDoc, paragraph, resolved.resolvedBy, operation.target, currentMetadataIndex, targetView);
206
+ const paragraphText = metadata.resolvedTarget.text;
207
+ const anchor = operation.operationKind === 'comment'
208
+ ? (operation.textToComment || paragraphText)
209
+ : (operation.operationKind === 'highlight'
210
+ ? operation.textToHighlight
211
+ : (operation.operationKind === 'format' ? operation.textToFormat : null));
212
+ const anchorResolution = operation.operationKind === 'comment' && anchor != null
213
+ ? resolveTextInParagraphIndex(createParagraphTextIndex(paragraph, { revisionView: targetView }), anchor)
214
+ : null;
215
+ const anchorFound = anchor == null
216
+ || (anchorResolution ? anchorResolution.found : paragraphText.includes(anchor));
217
+ const hasRevisions = containsTrackedChanges(paragraph);
218
+ const existingPolicy = operation.existingRevisions
219
+ || options.existingRevisions
220
+ || 'merge-same-author';
221
+ const deletingWholeParagraph = operation.operationKind === 'redline' && operation.modified === '';
222
+ const commentIds = deletingWholeParagraph ? getCommentIdsInParagraph(paragraph) : [];
223
+
224
+ let error = null;
225
+ if (!anchorFound) {
226
+ error = anchorResolution?.error || {
227
+ code: 'ANCHOR_NOT_FOUND',
228
+ message: `Anchor text was not found in target paragraph: "${anchor}".`
229
+ };
230
+ } else if (commentIds.length > 0) {
231
+ const comments = commentIds
232
+ .map(id => options._existingCommentDetails?.[id])
233
+ .filter(Boolean);
234
+ error = {
235
+ code: 'COMMENTED_CONTENT_DELETE',
236
+ message: 'Refusing to delete a paragraph with existing comments. Resolve or explicitly remove the comments before deleting the paragraph.',
237
+ commentIds,
238
+ ...(comments.length > 0 ? { comments } : {})
239
+ };
240
+ } else if (
241
+ operation.operationKind === 'redline'
242
+ && hasRevisions
243
+ && existingPolicy !== 'accept-all-first'
244
+ && existingPolicy !== 'accept-all-first-keep-normalized'
245
+ ) {
246
+ if (existingPolicy === 'merge-same-author' || existingPolicy === 'slice-cross-author') {
247
+ const authors = getTrackedChangeAuthors(paragraph);
248
+ const opAuthor = String(authorUsed || '').trim().toLowerCase();
249
+ const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
250
+ if (!allSame && existingPolicy === 'merge-same-author') {
251
+ error = {
252
+ code: 'EXISTING_REVISIONS',
253
+ message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Pass existingRevisions: "accept-all-first" or resolve revisions first.`
254
+ };
255
+ } else if (allSame) {
256
+ const mergeCommentIds = getCommentIdsInParagraph(paragraph);
257
+ if (mergeCommentIds.length > 0) {
258
+ const comments = mergeCommentIds
259
+ .map(id => options._existingCommentDetails?.[id])
260
+ .filter(Boolean);
261
+ error = {
262
+ code: 'COMMENTED_CONTENT_MERGE',
263
+ message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
264
+ commentIds: mergeCommentIds,
265
+ ...(comments.length > 0 ? { comments } : {})
266
+ };
267
+ }
268
+ } else {
269
+ const hasMove = paragraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
270
+ || paragraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
271
+ if (hasMove) {
272
+ error = {
273
+ code: 'UNSAFE_REVISION_NESTING',
274
+ message: 'Cross-author slicing does not support pending move revisions.'
275
+ };
276
+ }
277
+ }
278
+ } else {
279
+ const hasDel = paragraph.getElementsByTagNameNS(NS_W, 'del').length > 0;
280
+ const hasMove = paragraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
281
+ || paragraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
282
+ if (hasDel) {
283
+ error = {
284
+ code: 'UNSAFE_REVISION_NESTING',
285
+ message: 'Refusing to replace content with pending deletions; nesting revisions is unsafe.'
286
+ };
287
+ } else if (hasMove) {
288
+ error = {
289
+ code: 'UNSAFE_REVISION_NESTING',
290
+ message: 'Refusing to mutate content with move revisions until move lifecycle is designed.'
291
+ };
292
+ } else {
293
+ error = {
294
+ code: 'EXISTING_REVISIONS',
295
+ message: 'Target paragraph contains tracked changes and existingRevisions is "reject-input".'
296
+ };
297
+ }
298
+ }
299
+ }
300
+
301
+ if (!error && operation.operationKind === 'redline') {
302
+ const boundaryCheck = validateParagraphBoundaryMutation(paragraph, operation.modified, options);
303
+ if (!boundaryCheck.valid) {
304
+ error = {
305
+ code: boundaryCheck.code,
306
+ message: boundaryCheck.message
307
+ };
308
+ }
309
+ }
310
+
311
+ results.push({
312
+ index: index + 1,
313
+ type: sourceOperation?.type || 'redline',
314
+ operationType: operation.operationKind,
315
+ status: error ? 'error' : 'ready',
316
+ authorUsed,
317
+ ...metadata,
318
+ anchor: anchor == null ? null : {
319
+ text: anchor,
320
+ found: anchorFound,
321
+ ...(anchorResolution?.found ? {
322
+ resolvedBy: anchorResolution.resolvedBy,
323
+ start: anchorResolution.start,
324
+ end: anchorResolution.end
325
+ } : {}),
326
+ ...(!anchorResolution?.found && Array.isArray(anchorResolution?.error?.candidates)
327
+ ? { candidates: anchorResolution.error.candidates }
328
+ : {})
329
+ },
330
+ hasRevisions,
331
+ existingRevisions: existingPolicy,
332
+ ...(resolved?.warnings?.length ? { warnings: resolved.warnings } : {}),
333
+ ...(error ? { error } : {})
334
+ });
335
+ } catch (error) {
336
+ results.push({
337
+ index: index + 1,
338
+ type: sourceOperation?.type || 'redline',
339
+ operationType: operation.operationKind,
340
+ status: 'error',
341
+ authorUsed,
342
+ error: normalizedError(error)
343
+ });
344
+ }
345
+ }
346
+
347
+ const conflicts = [];
348
+ const byTarget = new Map();
349
+ for (const result of results) {
350
+ const targetIndex = result.resolvedTarget?.index;
351
+ if (!targetIndex) continue;
352
+ if (!byTarget.has(targetIndex)) byTarget.set(targetIndex, []);
353
+ byTarget.get(targetIndex).push(result);
354
+ }
355
+
356
+ for (const [targetIndex, targetResults] of byTarget) {
357
+ const redlines = targetResults.filter(result => result.operationType === 'redline');
358
+ const highlights = targetResults.filter(result => result.operationType === 'highlight');
359
+ const target = targetResults[0].resolvedTarget;
360
+ if (redlines.length > 1) {
361
+ conflicts.push(buildConflict(
362
+ 'OVERLAPPING_TEXT_EDITS',
363
+ `Multiple text edits target paragraph ${targetIndex}; later operations may use a stale anchor.`,
364
+ redlines.map(result => result.index),
365
+ target
366
+ ));
367
+ }
368
+ if (redlines.length > 0 && highlights.length > 0) {
369
+ conflicts.push(buildConflict(
370
+ 'REVISION_ORDER_CONFLICT',
371
+ `A text edit and highlight target paragraph ${targetIndex}; operation order can invalidate the target or existing-revision policy.`,
372
+ [...redlines, ...highlights].map(result => result.index).sort((a, b) => a - b),
373
+ target
374
+ ));
375
+ }
376
+ }
377
+
378
+ const hasErrors = results.some(result => result.status === 'error');
379
+ return {
380
+ valid: !hasErrors && conflicts.length === 0,
381
+ status: !hasErrors && conflicts.length === 0 ? 'ok' : 'error',
382
+ results,
383
+ conflicts,
384
+ authorsUsed: Array.from(authorsUsed),
385
+ requiredArtifacts: {
386
+ comments: commentsRequired,
387
+ numbering: numberingRequired
388
+ }
389
+ };
390
+ }
@@ -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
+ }