@ansonlai/docx-redline-js 0.8.0 → 0.8.2

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 (43) hide show
  1. package/CHANGELOG.md +553 -486
  2. package/README.md +331 -309
  3. package/dist/docx-redline-js.esm.js +1209 -168
  4. package/dist/docx-redline-js.esm.js.map +4 -4
  5. package/dist/docx-redline-js.esm.min.js +107 -107
  6. package/dist/docx-redline-js.esm.min.js.map +4 -4
  7. package/dist/docx-redline.bundle.cjs +1209 -168
  8. package/dist/docx-redline.bundle.cjs.map +4 -4
  9. package/dist/docx-redline.bundle.js +1209 -168
  10. package/dist/docx-redline.bundle.js.map +4 -4
  11. package/docs/AGENT_KNOWLEDGE_BASE.md +69 -8
  12. package/docs/schemas/document-operations.schema.json +29 -0
  13. package/document/docx-document.js +416 -22
  14. package/engine/reconstruction-mode.js +183 -54
  15. package/engine/reconstruction-writer.js +18 -0
  16. package/index.d.ts +598 -575
  17. package/index.js +349 -348
  18. package/node/cli-help.js +5 -5
  19. package/node/cli.js +760 -754
  20. package/node/index.d.ts +1 -1
  21. package/package.json +2 -1
  22. package/pipeline/diff-engine.js +274 -258
  23. package/scripts/generate-word-comment-edge-case-fixture.ps1 +58 -0
  24. package/scripts/generate-word-comment-thread-fixture.ps1 +36 -0
  25. package/scripts/generate-word-header-footer-fixture.ps1 +32 -0
  26. package/scripts/generate-word-multi-section-fixture.ps1 +27 -0
  27. package/scripts/generate-word-package-integrity-cases.mjs +141 -0
  28. package/scripts/generate-word-thread-state-fixtures.ps1 +50 -0
  29. package/scripts/run-tests.mjs +14 -2
  30. package/scripts/word-com-package-integrity.ps1 +98 -0
  31. package/services/batch-operation-orchestrator.js +262 -259
  32. package/services/comment-replies.js +96 -95
  33. package/services/comment-thread-parts.js +280 -0
  34. package/services/document-inspection.js +366 -365
  35. package/services/document-operation-applier.js +230 -202
  36. package/services/document-operation-contract.js +17 -2
  37. package/services/error-recovery.js +6 -0
  38. package/services/headers-footers.js +206 -0
  39. package/services/operation-batch-compiler.js +1 -0
  40. package/services/operation-preflight.js +231 -218
  41. package/services/package-parts.js +80 -0
  42. package/services/standalone-docx-plumbing.js +631 -569
  43. package/services/standalone-operation-runner.d.ts +19 -3
@@ -1,11 +1,11 @@
1
1
  import { getDefaultAuthor } from '../adapters/config.js';
2
- import {
2
+ import {
3
3
  normalizeDocumentOperation,
4
4
  resolveDocumentOperationAuthor,
5
5
  validateDocumentOperation
6
6
  } from './document-operation-contract.js';
7
- import { DocumentOperationSession } from './document-operation-session.js';
8
- import { compileOperationBatch } from './operation-batch-compiler.js';
7
+ import { DocumentOperationSession } from './document-operation-session.js';
8
+ import { compileOperationBatch } from './operation-batch-compiler.js';
9
9
  import {
10
10
  validateRevisionToken,
11
11
  computeDocumentPartsRevisionToken,
@@ -15,12 +15,13 @@ import {
15
15
  applyCommentToParagraphByExactText,
16
16
  applyFormattingToParagraphByExactText,
17
17
  applyHighlightToParagraphByExactText,
18
- applyParagraphFormatToParagraphByExactText,
19
- insertIntoRejectedDeletedText,
20
- restoreDeletedParagraphByExactText,
21
- applyToParagraphByExactText
18
+ applyParagraphFormatToParagraphByExactText,
19
+ insertIntoRejectedDeletedText,
20
+ restoreDeletedParagraphByExactText,
21
+ applyToParagraphByExactText
22
22
  } from './document-operation-mutations.js';
23
23
  import { applyCommentReplyToParts } from './comment-replies.js';
24
+ import { anchorReplyInDocument, applyThreadResolutionToParts } from './comment-thread-parts.js';
24
25
  import {
25
26
  deriveCapturedEntity,
26
27
  invalidateAffectedCaptures
@@ -28,27 +29,27 @@ import {
28
29
  import {
29
30
  createEmptyReceipt,
30
31
  reconcileReceiptsAgainstOutput
31
- } from './receipt-collector.js';
32
- import { validateRedlineOoxml } from '../core/redline-validation.js';
33
- import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
34
- import { normalizeErrorWithRecovery } from './error-recovery.js';
35
- import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
36
- import { buildLocalizedReplacementChange } from './localized-replacement-compiler.js';
37
-
38
- export function normalizeOperationError(error, context = {}) {
39
- return normalizeErrorWithRecovery(error, context);
40
- }
32
+ } from './receipt-collector.js';
33
+ import { validateRedlineOoxml } from '../core/redline-validation.js';
34
+ import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
35
+ import { normalizeErrorWithRecovery } from './error-recovery.js';
36
+ import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
37
+ import { buildLocalizedReplacementChange } from './localized-replacement-compiler.js';
38
+
39
+ export function normalizeOperationError(error, context = {}) {
40
+ return normalizeErrorWithRecovery(error, context);
41
+ }
41
42
 
42
43
  /**
43
44
  * Validates and dispatches one structured operation against full document XML.
44
45
  * Result metadata is assembled here so every mutation path exposes the same
45
46
  * */
46
- export async function applyOperationToDocumentXml(documentXml, op, author, runtimeContext = null, options = {}) {
47
- const operationIndex = typeof options._operationIndex === 'number' ? options._operationIndex : 1;
48
- const errorContext = {
49
- operationIndex,
50
- ...(typeof op?.operationId === 'string' ? { operationId: op.operationId } : {})
51
- };
47
+ export async function applyOperationToDocumentXml(documentXml, op, author, runtimeContext = null, options = {}) {
48
+ const operationIndex = typeof options._operationIndex === 'number' ? options._operationIndex : 1;
49
+ const errorContext = {
50
+ operationIndex,
51
+ ...(typeof op?.operationId === 'string' ? { operationId: op.operationId } : {})
52
+ };
52
53
  const validation = validateDocumentOperation(op);
53
54
  if (!validation.valid) {
54
55
  const authorUsed = resolveDocumentOperationAuthor(op, author, getDefaultAuthor());
@@ -56,33 +57,34 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
56
57
  documentXml,
57
58
  hasChanges: false,
58
59
  status: 'error',
59
- error: normalizeOperationError(validation.error, errorContext),
60
+ error: normalizeOperationError(validation.error, errorContext),
60
61
  operationType: normalizeDocumentOperation(op).operationKind,
61
62
  authorUsed,
62
63
  receipt: createEmptyReceipt(operationIndex, op?.operationId, authorUsed, 'refused')
63
64
  };
64
65
  }
65
66
 
66
- let operation = validation.operation || normalizeDocumentOperation(op);
67
+ let operation = validation.operation || normalizeDocumentOperation(op);
67
68
  const authorUsed = resolveDocumentOperationAuthor(operation, author, getDefaultAuthor());
68
69
 
69
- if (
70
- operation.operationKind !== 'comment_reply'
71
- && operation.operationKind !== 'rejected-insert'
72
- && operation.operationKind !== 'restore'
73
- && (
74
- operation.targetDescriptor?.revisionView === 'rejected'
75
- || operation.targetEndDescriptor?.revisionView === 'rejected'
76
- )
77
- ) {
70
+ if (
71
+ operation.operationKind !== 'comment_reply'
72
+ && operation.operationKind !== 'comment_resolve'
73
+ && operation.operationKind !== 'rejected-insert'
74
+ && operation.operationKind !== 'restore'
75
+ && (
76
+ operation.targetDescriptor?.revisionView === 'rejected'
77
+ || operation.targetEndDescriptor?.revisionView === 'rejected'
78
+ )
79
+ ) {
78
80
  return {
79
81
  documentXml,
80
82
  hasChanges: false,
81
83
  status: 'error',
82
- error: normalizeOperationError({
83
- code: 'UNSUPPORTED_REVISION_VIEW_MUTATION',
84
- message: 'Targeting rejected revision view for mutation is not supported yet.'
85
- }, errorContext),
84
+ error: normalizeOperationError({
85
+ code: 'UNSUPPORTED_REVISION_VIEW_MUTATION',
86
+ message: 'Targeting rejected revision view for mutation is not supported yet.'
87
+ }, errorContext),
86
88
  operationType: operation.operationKind,
87
89
  authorUsed,
88
90
  receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
@@ -96,10 +98,10 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
96
98
  documentXml,
97
99
  hasChanges: false,
98
100
  status: 'error',
99
- error: normalizeOperationError({
100
- code: tokenValidation.error?.code || 'INVALID_REVISION_TOKEN',
101
- message: tokenValidation.error?.message || 'Invalid revision token.'
102
- }, errorContext),
101
+ error: normalizeOperationError({
102
+ code: tokenValidation.error?.code || 'INVALID_REVISION_TOKEN',
103
+ message: tokenValidation.error?.message || 'Invalid revision token.'
104
+ }, errorContext),
103
105
  operationType: operation.operationKind,
104
106
  authorUsed,
105
107
  receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
@@ -110,10 +112,10 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
110
112
  documentXml,
111
113
  hasChanges: false,
112
114
  status: 'error',
113
- error: normalizeOperationError({
114
- code: 'REVISION_TOKEN_SCOPE_MISMATCH',
115
- message: `Revision token scope mismatch: expected 'document-parts', got '${options.expectedRevision.scope}'.`
116
- }, errorContext),
115
+ error: normalizeOperationError({
116
+ code: 'REVISION_TOKEN_SCOPE_MISMATCH',
117
+ message: `Revision token scope mismatch: expected 'document-parts', got '${options.expectedRevision.scope}'.`
118
+ }, errorContext),
117
119
  operationType: operation.operationKind,
118
120
  authorUsed,
119
121
  receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
@@ -131,12 +133,12 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
131
133
  documentXml,
132
134
  hasChanges: false,
133
135
  status: 'error',
134
- error: normalizeOperationError({
135
- code: 'REVISION_MISMATCH',
136
- message: `Document revision mismatch: expected '${options.expectedRevision.value}', current is '${currentToken.value}'.`,
137
- expectedRevision: options.expectedRevision,
138
- currentRevision: currentToken
139
- }, errorContext),
136
+ error: normalizeOperationError({
137
+ code: 'REVISION_MISMATCH',
138
+ message: `Document revision mismatch: expected '${options.expectedRevision.value}', current is '${currentToken.value}'.`,
139
+ expectedRevision: options.expectedRevision,
140
+ currentRevision: currentToken
141
+ }, errorContext),
140
142
  operationType: operation.operationKind,
141
143
  authorUsed,
142
144
  receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
@@ -147,67 +149,67 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
147
149
  const session = options?._documentOperationSession instanceof DocumentOperationSession
148
150
  ? options._documentOperationSession
149
151
  : new DocumentOperationSession(documentXml, options);
150
- if (!session.valid) {
152
+ if (!session.valid) {
151
153
  return {
152
154
  documentXml,
153
155
  hasChanges: false,
154
156
  status: 'error',
155
- error: normalizeOperationError(session.parseResult.error, errorContext),
157
+ error: normalizeOperationError(session.parseResult.error, errorContext),
156
158
  warnings: session.parseResult.warnings,
157
159
  operationType: operation.operationKind,
158
160
  authorUsed,
159
161
  receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
160
- };
161
- }
162
-
163
- if (
164
- Array.isArray(operation.replacements)
165
- || (operation.operationKind === 'restore' && op?.modified === undefined)
166
- ) {
167
- const compilation = compileOperationBatch(session.document, [op], {
168
- strictTargets: options.strictTargets !== false,
169
- onInfo: options.onInfo,
170
- onWarn: options.onWarn
171
- });
172
- const compilationError = compilation.bindings[0]?.error || compilation.conflicts[0] || null;
173
- if (compilationError) {
174
- return {
175
- documentXml,
176
- hasChanges: false,
177
- status: 'error',
178
- error: normalizeOperationError(compilationError, errorContext),
179
- operationType: operation.operationKind,
180
- authorUsed,
181
- receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
182
- };
183
- }
184
- const compiledValidation = validateDocumentOperation(compilation.compiledOperations[0]);
185
- if (!compiledValidation.valid) {
186
- return {
187
- documentXml,
188
- hasChanges: false,
189
- status: 'error',
190
- error: normalizeOperationError(compiledValidation.error, errorContext),
191
- operationType: operation.operationKind,
192
- authorUsed,
193
- receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
194
- };
195
- }
196
- session.sourceTargetRegistry = compilation.registry;
197
- operation = compiledValidation.operation;
198
- }
199
-
200
- const resolutionCapture = {};
201
- let localizedChange = null;
162
+ };
163
+ }
164
+
165
+ if (
166
+ Array.isArray(operation.replacements)
167
+ || (operation.operationKind === 'restore' && op?.modified === undefined)
168
+ ) {
169
+ const compilation = compileOperationBatch(session.document, [op], {
170
+ strictTargets: options.strictTargets !== false,
171
+ onInfo: options.onInfo,
172
+ onWarn: options.onWarn
173
+ });
174
+ const compilationError = compilation.bindings[0]?.error || compilation.conflicts[0] || null;
175
+ if (compilationError) {
176
+ return {
177
+ documentXml,
178
+ hasChanges: false,
179
+ status: 'error',
180
+ error: normalizeOperationError(compilationError, errorContext),
181
+ operationType: operation.operationKind,
182
+ authorUsed,
183
+ receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
184
+ };
185
+ }
186
+ const compiledValidation = validateDocumentOperation(compilation.compiledOperations[0]);
187
+ if (!compiledValidation.valid) {
188
+ return {
189
+ documentXml,
190
+ hasChanges: false,
191
+ status: 'error',
192
+ error: normalizeOperationError(compiledValidation.error, errorContext),
193
+ operationType: operation.operationKind,
194
+ authorUsed,
195
+ receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
196
+ };
197
+ }
198
+ session.sourceTargetRegistry = compilation.registry;
199
+ operation = compiledValidation.operation;
200
+ }
201
+
202
+ const resolutionCapture = {};
203
+ let localizedChange = null;
202
204
  const savepoint = session.createSavepoint();
203
205
  session.receiptCollector?.beginOperation(
204
206
  operationIndex,
205
207
  operation.operationId,
206
208
  authorUsed
207
209
  );
208
- const operationWarnings = Array.isArray(operation._compiledWarnings)
209
- ? [...operation._compiledWarnings]
210
- : [];
210
+ const operationWarnings = Array.isArray(operation._compiledWarnings)
211
+ ? [...operation._compiledWarnings]
212
+ : [];
211
213
  const operationOptions = {
212
214
  ...options,
213
215
  ...(typeof operation.generateRedlines === 'boolean' ? { generateRedlines: operation.generateRedlines } : {}),
@@ -217,12 +219,12 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
217
219
  pairReplacements: typeof operation.pairReplacements === 'boolean' ? operation.pairReplacements : (options.pairReplacements !== false),
218
220
  ...(operation.insertionAffinity ? { insertionAffinity: operation.insertionAffinity } : {}),
219
221
  ...(operation.formattingRevisionPolicy ? { formattingRevisionPolicy: operation.formattingRevisionPolicy } : {}),
220
- targetDescriptor: operation.targetDescriptor,
221
- targetEndDescriptor: operation.targetEndDescriptor,
222
- _compiledSourceId: operation._compiledSourceId || null,
223
- _compiledSourceEndId: operation._compiledSourceEndId || null,
224
- _compiledResolvedBy: operation._compiledResolvedBy || null,
225
- _sourceTargetRegistry: session.sourceTargetRegistry || null,
222
+ targetDescriptor: operation.targetDescriptor,
223
+ targetEndDescriptor: operation.targetEndDescriptor,
224
+ _compiledSourceId: operation._compiledSourceId || null,
225
+ _compiledSourceEndId: operation._compiledSourceEndId || null,
226
+ _compiledResolvedBy: operation._compiledResolvedBy || null,
227
+ _sourceTargetRegistry: session.sourceTargetRegistry || null,
226
228
  _resolutionCapture: resolutionCapture,
227
229
  _revisionIdAllocator: session.revisionIdAllocator,
228
230
  _documentOperationSession: session,
@@ -253,6 +255,7 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
253
255
  result = applyCommentReplyToParts({
254
256
  commentsXml: existingCommentsXml,
255
257
  commentsExtendedXml: existingExtendedXml,
258
+ documentXml,
256
259
  parentCommentId: operation.parentCommentId,
257
260
  commentId,
258
261
  commentContent: operation.commentContent,
@@ -260,8 +263,33 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
260
263
  date: operation.date || new Date().toISOString()
261
264
  });
262
265
  result.documentXml = documentXml;
263
- if (result.hasChanges && session.receiptCollector) session.receiptCollector.recordComment(commentId);
266
+ if (result.status !== 'error' && result.hasChanges) {
267
+ // Word only shows a reply that has its own range/reference markers in the body.
268
+ if (!session.document || !anchorReplyInDocument(session.document, result.threadCommentIds, commentId)) {
269
+ result = { documentXml, hasChanges: false, status: 'error', error: { code: 'PARENT_ANCHOR_NOT_FOUND', message: `Comment '${operation.parentCommentId}' has no anchor in the document body, so a reply cannot be placed.` } };
270
+ } else if (session.receiptCollector) {
271
+ session.receiptCollector.recordComment(commentId);
272
+ }
273
+ }
264
274
  }
275
+ } else if (operation.operationKind === 'comment_resolve') {
276
+ const resolution = applyThreadResolutionToParts({
277
+ commentsXml: session.commentsXml || runtimeContext?.commentsXml || options.commentsXml,
278
+ commentsExtendedXml: session.commentsExtendedXml || runtimeContext?.commentsExtendedXml || options.commentsExtendedXml,
279
+ commentId: operation.commentId,
280
+ resolved: operation.resolved !== false
281
+ });
282
+ result = resolution.status === 'error'
283
+ ? { documentXml, hasChanges: false, status: 'error', error: resolution.error }
284
+ : {
285
+ documentXml,
286
+ hasChanges: resolution.hasChanges,
287
+ ...(resolution.commentsXml ? { commentsXml: resolution.commentsXml, commentsXmlMode: 'replace' } : {}),
288
+ ...(resolution.commentsExtendedXml ? { commentsExtendedXml: resolution.commentsExtendedXml, commentsExtendedXmlMode: 'replace' } : {}),
289
+ resolved: resolution.resolved,
290
+ threadRootId: resolution.threadRootId,
291
+ commentIds: resolution.commentIds
292
+ };
265
293
  } else if (operation.operationKind === 'highlight') {
266
294
  result = await applyHighlightToParagraphByExactText(
267
295
  documentXml,
@@ -305,29 +333,29 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
305
333
  runtimeContext,
306
334
  operationOptions
307
335
  );
308
- } else if (operation.operationKind === 'rejected-insert') {
309
- result = await insertIntoRejectedDeletedText(
310
- documentXml,
311
- operation.target,
312
- operation.anchor,
313
- operation.modified,
314
- authorUsed,
315
- operation.targetRef,
316
- runtimeContext,
317
- operationOptions
318
- );
319
- } else if (operation.operationKind === 'restore') {
320
- result = await restoreDeletedParagraphByExactText(
321
- documentXml,
322
- operation.target,
323
- operation.modified,
324
- authorUsed,
325
- operation.targetRef,
326
- operation.targetEndRef,
327
- runtimeContext,
328
- operationOptions
329
- );
330
- } else {
336
+ } else if (operation.operationKind === 'rejected-insert') {
337
+ result = await insertIntoRejectedDeletedText(
338
+ documentXml,
339
+ operation.target,
340
+ operation.anchor,
341
+ operation.modified,
342
+ authorUsed,
343
+ operation.targetRef,
344
+ runtimeContext,
345
+ operationOptions
346
+ );
347
+ } else if (operation.operationKind === 'restore') {
348
+ result = await restoreDeletedParagraphByExactText(
349
+ documentXml,
350
+ operation.target,
351
+ operation.modified,
352
+ authorUsed,
353
+ operation.targetRef,
354
+ operation.targetEndRef,
355
+ runtimeContext,
356
+ operationOptions
357
+ );
358
+ } else {
331
359
  result = await applyToParagraphByExactText(
332
360
  documentXml,
333
361
  operation.target,
@@ -377,72 +405,72 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
377
405
  operationReceipt.warnings.push(String(w));
378
406
  }
379
407
  }
380
- } else {
381
- const beforeValidation = validateRedlineOoxml(savepoint.document);
382
- const afterValidation = validateRedlineOoxml(session.document);
383
- const generatedIssues = subtractValidationIssueMultiset(afterValidation.issues, beforeValidation.issues);
384
- const generatedErrors = validationErrors(generatedIssues);
385
- if (generatedErrors.length > 0) {
386
- session.restoreSavepoint(savepoint);
387
- operationReceipt = createEmptyReceipt(
388
- operationIndex,
389
- operation.operationId,
390
- authorUsed,
391
- 'refused'
392
- );
393
- const codes = [...new Set(generatedErrors.map(issue => issue.code))].join(', ');
394
- return {
395
- documentXml,
396
- hasChanges: false,
397
- status: 'error',
398
- error: normalizeOperationError({
399
- code: 'GENERATED_OOXML_INVALID',
400
- stage: 'validation',
401
- message: `Operation introduced invalid OOXML (${codes}).`,
402
- generatedIssues: generatedErrors
403
- }, errorContext),
404
- operationType: operation.operationKind,
405
- authorUsed,
406
- receipt: operationReceipt,
407
- ...resolutionCapture
408
- };
409
- }
410
- session.markMutationCommitted(operation.operationKind !== 'comment_reply');
411
- if (operation._compiledSourceId && session.sourceTargetRegistry) {
412
- session.sourceTargetRegistry.commitMutation(
413
- operation._compiledSourceId,
414
- operationOptions._mutationRemovedNodes,
415
- operationOptions._mutationLiveNodes,
416
- operationIndex
417
- );
418
- }
419
- if (operation._localizedReplacementCompilation) {
420
- let outputText = null;
421
- if (operation.operationKind === 'restore') {
422
- const restoredParagraph = operationOptions._mutationLiveNodes.find(node => node?.localName === 'p');
423
- if (restoredParagraph) {
424
- outputText = extractCanonicalParagraphText(restoredParagraph, {
425
- revisionView: 'accepted'
426
- });
427
- }
428
- } else if (operation._compiledSourceId && session.sourceTargetRegistry) {
429
- const bound = session.sourceTargetRegistry.resolve(
430
- operation._compiledSourceId,
431
- session.document
432
- );
433
- if (bound.paragraph) {
434
- outputText = extractCanonicalParagraphText(bound.paragraph, {
435
- revisionView: 'accepted'
436
- });
437
- }
438
- }
439
- localizedChange = buildLocalizedReplacementChange(
440
- operation._localizedReplacementCompilation,
441
- outputText,
442
- resolutionCapture.resolvedTarget || {},
443
- operation._speculativeContext || null
444
- );
445
- }
408
+ } else {
409
+ const beforeValidation = validateRedlineOoxml(savepoint.document);
410
+ const afterValidation = validateRedlineOoxml(session.document);
411
+ const generatedIssues = subtractValidationIssueMultiset(afterValidation.issues, beforeValidation.issues);
412
+ const generatedErrors = validationErrors(generatedIssues);
413
+ if (generatedErrors.length > 0) {
414
+ session.restoreSavepoint(savepoint);
415
+ operationReceipt = createEmptyReceipt(
416
+ operationIndex,
417
+ operation.operationId,
418
+ authorUsed,
419
+ 'refused'
420
+ );
421
+ const codes = [...new Set(generatedErrors.map(issue => issue.code))].join(', ');
422
+ return {
423
+ documentXml,
424
+ hasChanges: false,
425
+ status: 'error',
426
+ error: normalizeOperationError({
427
+ code: 'GENERATED_OOXML_INVALID',
428
+ stage: 'validation',
429
+ message: `Operation introduced invalid OOXML (${codes}).`,
430
+ generatedIssues: generatedErrors
431
+ }, errorContext),
432
+ operationType: operation.operationKind,
433
+ authorUsed,
434
+ receipt: operationReceipt,
435
+ ...resolutionCapture
436
+ };
437
+ }
438
+ session.markMutationCommitted(true);
439
+ if (operation._compiledSourceId && session.sourceTargetRegistry) {
440
+ session.sourceTargetRegistry.commitMutation(
441
+ operation._compiledSourceId,
442
+ operationOptions._mutationRemovedNodes,
443
+ operationOptions._mutationLiveNodes,
444
+ operationIndex
445
+ );
446
+ }
447
+ if (operation._localizedReplacementCompilation) {
448
+ let outputText = null;
449
+ if (operation.operationKind === 'restore') {
450
+ const restoredParagraph = operationOptions._mutationLiveNodes.find(node => node?.localName === 'p');
451
+ if (restoredParagraph) {
452
+ outputText = extractCanonicalParagraphText(restoredParagraph, {
453
+ revisionView: 'accepted'
454
+ });
455
+ }
456
+ } else if (operation._compiledSourceId && session.sourceTargetRegistry) {
457
+ const bound = session.sourceTargetRegistry.resolve(
458
+ operation._compiledSourceId,
459
+ session.document
460
+ );
461
+ if (bound.paragraph) {
462
+ outputText = extractCanonicalParagraphText(bound.paragraph, {
463
+ revisionView: 'accepted'
464
+ });
465
+ }
466
+ }
467
+ localizedChange = buildLocalizedReplacementChange(
468
+ operation._localizedReplacementCompilation,
469
+ outputText,
470
+ resolutionCapture.resolvedTarget || {},
471
+ operation._speculativeContext || null
472
+ );
473
+ }
446
474
  if (operation.captureKey && session.captureTable) {
447
475
  session.captureTable.set(
448
476
  operation.captureKey,
@@ -479,7 +507,7 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
479
507
  documentXml,
480
508
  hasChanges: false,
481
509
  status: 'error',
482
- error: normalizeOperationError(reconciliation.error, errorContext),
510
+ error: normalizeOperationError(reconciliation.error, errorContext),
483
511
  warnings: [reconciliation.error.message],
484
512
  operationType: operation.operationKind,
485
513
  authorUsed,
@@ -489,18 +517,18 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
489
517
  }
490
518
  }
491
519
  }
492
- if (result?.error) result.error = normalizeOperationError(result.error, errorContext);
493
- return {
520
+ if (result?.error) result.error = normalizeOperationError(result.error, errorContext);
521
+ return {
494
522
  ...result,
495
523
  operationType: operation.operationKind,
496
524
  authorUsed,
497
- receipt: operationReceipt,
498
- ...(localizedChange ? { change: localizedChange } : {}),
499
- ...resolutionCapture
525
+ receipt: operationReceipt,
526
+ ...(localizedChange ? { change: localizedChange } : {}),
527
+ ...resolutionCapture
500
528
  };
501
529
  } catch (error) {
502
530
  session.restoreSavepoint(savepoint);
503
- const normalizedError = normalizeOperationError(error, errorContext);
531
+ const normalizedError = normalizeOperationError(error, errorContext);
504
532
  const operationReceipt = createEmptyReceipt(
505
533
  operationIndex,
506
534
  operation.operationId,
@@ -20,6 +20,7 @@ const SUPPORTED_OPERATION_TYPES = new Set([
20
20
  'delete',
21
21
  'comment',
22
22
  'comment_reply',
23
+ 'comment_resolve',
23
24
  'highlight'
24
25
  ]);
25
26
 
@@ -43,11 +44,16 @@ function nonEmptyString(value) {
43
44
  return typeof value === 'string' && value.trim().length > 0;
44
45
  }
45
46
 
47
+ /** Operations that address an existing comment thread by id instead of targeting a paragraph. */
48
+ export function isCommentThreadOperationKind(kind) {
49
+ return kind === 'comment_reply' || kind === 'comment_resolve';
50
+ }
51
+
46
52
  export function getCanonicalOperationType(operation) {
47
53
  const type = operation?.type;
48
54
  if (type === 'insert' && operation?.target?.revisionView === 'rejected') return 'rejected-insert';
49
55
  if (type === 'restore') return 'restore';
50
- if (type === 'comment' || type === 'comment_reply' || type === 'highlight') return type;
56
+ if (type === 'comment' || type === 'comment_reply' || type === 'comment_resolve' || type === 'highlight') return type;
51
57
  if (type === 'paragraph-format') return 'paragraph-format';
52
58
  if (type === 'character-format' || (type === 'format' && (operation?.textToFormat != null || operation?.properties != null))) return 'format';
53
59
  return 'redline';
@@ -191,7 +197,7 @@ export function validateDocumentOperation(operation) {
191
197
 
192
198
  const target = normalized.targetDescriptor;
193
199
  if (
194
- normalized.operationKind !== 'comment_reply'
200
+ !isCommentThreadOperationKind(normalized.operationKind)
195
201
  && !nonEmptyString(target.text)
196
202
  && target.index == null
197
203
  && !target.paragraphId
@@ -460,6 +466,15 @@ export function validateDocumentOperation(operation) {
460
466
  }
461
467
  }
462
468
 
469
+ if (normalized.operationKind === 'comment_resolve') {
470
+ if (!nonEmptyString(String(normalized.commentId ?? ''))) {
471
+ return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment resolve operations require a "commentId".' } };
472
+ }
473
+ if (normalized.resolved != null && typeof normalized.resolved !== 'boolean') {
474
+ return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment resolve "resolved" must be a boolean when provided.' } };
475
+ }
476
+ }
477
+
463
478
  if (normalized.operationKind === 'highlight') {
464
479
  if (!nonEmptyString(normalized.textToHighlight)) {
465
480
  return {
@@ -35,6 +35,12 @@ const RULES = Object.freeze({
35
35
  STRUCTURED_CONTENT_INVALID: ['request', 'request_fixable', 'change_request'],
36
36
  UNSUPPORTED_INSERTION_AFFINITY: ['request', 'request_fixable', 'change_request'],
37
37
  EXISTING_REVISIONS: ['target-safety', 'policy_choice_required', 'set_option'],
38
+ PART_NOT_FOUND: ['target-resolution', 'target_refresh_required', 'reinspect'],
39
+ PART_AMBIGUOUS: ['target-resolution', 'candidate_selection_required', 'choose_candidate'],
40
+ COMMENT_IN_HEADER_FOOTER: ['request', 'request_fixable', 'change_request'],
41
+ FIELD_EDIT_REFUSED: ['target-safety', 'request_fixable', 'change_request'],
42
+ COMMENT_NOT_FOUND: ['target-resolution', 'target_refresh_required', 'reinspect'],
43
+ PARENT_ANCHOR_NOT_FOUND: ['target-resolution', 'target_refresh_required', 'reinspect'],
38
44
  COMMENTED_CONTENT_MERGE: ['target-safety', 'user_authorization_required', 'resolve_comments'],
39
45
  COMMENTED_CONTENT_DELETE: ['target-safety', 'user_authorization_required', 'resolve_comments'],
40
46
  OVERLAPPING_SOURCE_TARGETS: ['batch-compilation', 'source_conflict', 'consolidate_operations'],