@ansonlai/docx-redline-js 0.7.2 → 0.8.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 (53) hide show
  1. package/AGENTS.md +15 -10
  2. package/ARCHITECTURE.md +30 -10
  3. package/CHANGELOG.md +539 -431
  4. package/README.md +331 -309
  5. package/adapters/xml-adapter.js +84 -56
  6. package/core/sha256.js +120 -0
  7. package/dist/docx-redline-js.esm.js +8982 -693
  8. package/dist/docx-redline-js.esm.js.map +4 -4
  9. package/dist/docx-redline-js.esm.min.js +102 -89
  10. package/dist/docx-redline-js.esm.min.js.map +4 -4
  11. package/dist/docx-redline.bundle.cjs +28305 -0
  12. package/dist/docx-redline.bundle.cjs.map +7 -0
  13. package/dist/docx-redline.bundle.js +28296 -0
  14. package/dist/docx-redline.bundle.js.map +7 -0
  15. package/docs/AGENT_FAST_START.md +2 -1
  16. package/docs/AGENT_KNOWLEDGE_BASE.md +480 -393
  17. package/docs/SKILL_AUTHORING.md +5 -2
  18. package/docs/TESTING.md +2 -1
  19. package/docs/schemas/document-operations.schema.json +29 -0
  20. package/document/docx-document.d.ts +39 -0
  21. package/document/docx-document.js +1010 -0
  22. package/document/zip-archive.js +258 -0
  23. package/engine/reconstruction-writer.js +18 -0
  24. package/index.d.ts +598 -531
  25. package/index.js +349 -340
  26. package/node/cli-help.js +5 -5
  27. package/node/cli.js +760 -754
  28. package/node/docx-document.js +11 -355
  29. package/node/index.d.ts +1 -1
  30. package/node/zip-archive.js +28 -38
  31. package/package.json +14 -11
  32. package/scripts/build.mjs +40 -5
  33. package/scripts/generate-word-comment-edge-case-fixture.ps1 +58 -0
  34. package/scripts/generate-word-comment-thread-fixture.ps1 +36 -0
  35. package/scripts/generate-word-header-footer-fixture.ps1 +32 -0
  36. package/scripts/generate-word-multi-section-fixture.ps1 +27 -0
  37. package/scripts/generate-word-package-integrity-cases.mjs +141 -0
  38. package/scripts/generate-word-thread-state-fixtures.ps1 +50 -0
  39. package/scripts/word-com-package-integrity.ps1 +98 -0
  40. package/services/batch-operation-orchestrator.js +262 -259
  41. package/services/comment-replies.js +96 -95
  42. package/services/comment-thread-parts.js +280 -0
  43. package/services/document-inspection.js +366 -365
  44. package/services/document-operation-applier.js +230 -202
  45. package/services/document-operation-contract.js +17 -2
  46. package/services/error-recovery.js +6 -0
  47. package/services/headers-footers.js +206 -0
  48. package/services/operation-batch-compiler.js +1 -0
  49. package/services/operation-preflight.js +231 -218
  50. package/services/package-parts.js +80 -0
  51. package/services/revision-token.js +11 -7
  52. package/services/standalone-docx-plumbing.js +631 -569
  53. package/services/standalone-operation-runner.d.ts +19 -3
@@ -6,35 +6,35 @@ import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
6
6
  import { getDefaultAuthor } from '../adapters/config.js';
7
7
  import { containsTrackedChanges, getTrackedChangeAuthors } from '../core/word-xml.js';
8
8
  import { NS_W } from '../core/types.js';
9
- import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
10
- import {
11
- getParagraphRestorationRefusal,
12
- inspectForeignDeletedParagraphTarget
13
- } from '../core/paragraph-revision-safety.js';
9
+ import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
10
+ import {
11
+ getParagraphRestorationRefusal,
12
+ inspectForeignDeletedParagraphTarget
13
+ } from '../core/paragraph-revision-safety.js';
14
14
  import {
15
15
  buildParagraphMetadataIndex,
16
16
  createParagraphFingerprint,
17
17
  findContainingWordElement,
18
18
  getDocumentParagraphNodes,
19
19
  getParagraphId,
20
- normalizeWhitespaceForTargeting,
21
- resolveParagraphRangeByRefs,
22
- resolveTargetParagraph,
20
+ normalizeWhitespaceForTargeting,
21
+ resolveParagraphRangeByRefs,
22
+ resolveTargetParagraph,
23
23
  validateParagraphBoundaryMutation
24
24
  } from '../core/paragraph-targeting.js';
25
25
  import { createParagraphTextIndex, resolveTextInParagraphIndex } from './comment-locator.js';
26
- import {
27
- isExistingRevisionsPolicy,
28
- normalizeDocumentOperation,
26
+ import {
27
+ isExistingRevisionsPolicy,
28
+ normalizeDocumentOperation,
29
29
  resolveDocumentOperationAuthor,
30
30
  validateDocumentOperation
31
31
  } from './document-operation-contract.js';
32
- import { buildOperationDependencyPlan } from './batch-operation-orchestrator.js';
33
- import { compileOperationBatch } from './operation-batch-compiler.js';
34
- import { normalizeErrorWithRecovery } from './error-recovery.js';
35
-
36
- function normalizedError(error, context = {}) {
37
- return normalizeErrorWithRecovery(error, context);
32
+ import { buildOperationDependencyPlan } from './batch-operation-orchestrator.js';
33
+ import { compileOperationBatch } from './operation-batch-compiler.js';
34
+ import { normalizeErrorWithRecovery } from './error-recovery.js';
35
+
36
+ function normalizedError(error, context = {}) {
37
+ return normalizeErrorWithRecovery(error, context);
38
38
  }
39
39
 
40
40
  function operationNeedsNumbering(operation) {
@@ -79,27 +79,27 @@ function getCommentIdsInParagraph(paragraph) {
79
79
  return Array.from(ids).sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
80
80
  }
81
81
 
82
- export function preflightOperations(documentXml, operations, author, options = {}) {
83
- if (options.existingRevisions != null && !isExistingRevisionsPolicy(options.existingRevisions)) {
84
- return {
85
- valid: false,
86
- status: 'error',
87
- error: normalizedError({
88
- code: 'INVALID_OPERATION',
89
- message: `Unsupported existingRevisions policy: "${String(options.existingRevisions)}".`
90
- }),
91
- results: [],
92
- conflicts: [],
93
- authorsUsed: [],
94
- requiredArtifacts: { comments: false, numbering: false }
95
- };
96
- }
97
- const parsed = parseOoxmlSafe(documentXml, 'application/xml');
82
+ export function preflightOperations(documentXml, operations, author, options = {}) {
83
+ if (options.existingRevisions != null && !isExistingRevisionsPolicy(options.existingRevisions)) {
84
+ return {
85
+ valid: false,
86
+ status: 'error',
87
+ error: normalizedError({
88
+ code: 'INVALID_OPERATION',
89
+ message: `Unsupported existingRevisions policy: "${String(options.existingRevisions)}".`
90
+ }),
91
+ results: [],
92
+ conflicts: [],
93
+ authorsUsed: [],
94
+ requiredArtifacts: { comments: false, numbering: false }
95
+ };
96
+ }
97
+ const parsed = parseOoxmlSafe(documentXml, 'application/xml');
98
98
  if (parsed.error || !parsed.doc) {
99
99
  return {
100
100
  valid: false,
101
101
  status: 'error',
102
- error: normalizedError(parsed.error),
102
+ error: normalizedError(parsed.error),
103
103
  results: [],
104
104
  conflicts: [],
105
105
  authorsUsed: [],
@@ -113,39 +113,39 @@ export function preflightOperations(documentXml, operations, author, options = {
113
113
  rejected: null
114
114
  };
115
115
  const sourceOperations = Array.isArray(operations) ? operations : [];
116
- const batchCompilation = compileOperationBatch(xmlDoc, sourceOperations, {
117
- strictTargets: options.strictTargets !== false,
118
- onInfo: options.onInfo,
119
- onWarn: options.onWarn
120
- });
121
- const dependencyPlan = buildOperationDependencyPlan(batchCompilation.compiledOperations);
122
- if (!dependencyPlan.valid) {
116
+ const batchCompilation = compileOperationBatch(xmlDoc, sourceOperations, {
117
+ strictTargets: options.strictTargets !== false,
118
+ onInfo: options.onInfo,
119
+ onWarn: options.onWarn
120
+ });
121
+ const dependencyPlan = buildOperationDependencyPlan(batchCompilation.compiledOperations);
122
+ if (!dependencyPlan.valid) {
123
123
  return {
124
124
  valid: false,
125
125
  status: 'error',
126
- error: normalizedError(dependencyPlan.error),
126
+ error: normalizedError(dependencyPlan.error),
127
127
  results: [],
128
128
  conflicts: [],
129
129
  authorsUsed: [],
130
130
  requiredArtifacts: { comments: false, numbering: false }
131
131
  };
132
- }
132
+ }
133
133
  const strictTargets = options.strictTargets !== false;
134
134
  const results = [];
135
135
  const authorsUsed = new Set();
136
136
  let commentsRequired = false;
137
137
  let numberingRequired = false;
138
138
 
139
- for (let index = 0; index < sourceOperations.length; index++) {
140
- const sourceOperation = sourceOperations[index];
141
- const compiledBinding = batchCompilation.bindings[index];
142
- const compiledOperation = batchCompilation.compiledOperations[index];
143
- const operationInput = !compiledBinding?.error && compiledOperation?._localizedReplacementCompilation
144
- ? compiledOperation
145
- : sourceOperation;
146
- const validation = validateDocumentOperation(operationInput);
147
- const fallbackOperation = normalizeDocumentOperation(sourceOperation);
148
- const operation = validation.operation || fallbackOperation;
139
+ for (let index = 0; index < sourceOperations.length; index++) {
140
+ const sourceOperation = sourceOperations[index];
141
+ const compiledBinding = batchCompilation.bindings[index];
142
+ const compiledOperation = batchCompilation.compiledOperations[index];
143
+ const operationInput = !compiledBinding?.error && compiledOperation?._localizedReplacementCompilation
144
+ ? compiledOperation
145
+ : sourceOperation;
146
+ const validation = validateDocumentOperation(operationInput);
147
+ const fallbackOperation = normalizeDocumentOperation(sourceOperation);
148
+ const operation = validation.operation || fallbackOperation;
149
149
  const authorUsed = resolveDocumentOperationAuthor(operation, author, getDefaultAuthor());
150
150
  authorsUsed.add(authorUsed);
151
151
 
@@ -164,6 +164,19 @@ export function preflightOperations(documentXml, operations, author, options = {
164
164
  commentsRequired = commentsRequired || operation.operationKind === 'comment' || operation.operationKind === 'comment_reply';
165
165
  numberingRequired = numberingRequired || operationNeedsNumbering(operation);
166
166
 
167
+ if (operation.operationKind === 'comment_resolve') {
168
+ const commentId = String(operation.commentId);
169
+ results.push(options._existingCommentDetails?.[commentId] ? {
170
+ index: index + 1, type: sourceOperation.type, operationType: operation.operationKind,
171
+ status: 'ready', authorUsed, resolvedBy: 'comment_id', commentId
172
+ } : {
173
+ index: index + 1, type: sourceOperation.type, operationType: operation.operationKind,
174
+ status: 'error', authorUsed,
175
+ error: { code: 'COMMENT_NOT_FOUND', message: `Comment '${commentId}' was not found.` }
176
+ });
177
+ continue;
178
+ }
179
+
167
180
  if (operation.operationKind === 'comment_reply') {
168
181
  const parentId = String(operation.parentCommentId);
169
182
  const parent = options._existingCommentDetails?.[parentId];
@@ -178,7 +191,7 @@ export function preflightOperations(documentXml, operations, author, options = {
178
191
  continue;
179
192
  }
180
193
 
181
- if (operation.targetDescriptor?.captureRef) {
194
+ if (operation.targetDescriptor?.captureRef) {
182
195
  results.push({
183
196
  index: index + 1,
184
197
  type: sourceOperation?.type || 'redline',
@@ -189,34 +202,34 @@ export function preflightOperations(documentXml, operations, author, options = {
189
202
  captureRef: operation.targetDescriptor.captureRef,
190
203
  ...(operation.targetDescriptor.select ? { select: operation.targetDescriptor.select } : {})
191
204
  });
192
- continue;
193
- }
194
-
195
- if (compiledBinding?.error) {
196
- results.push({
197
- index: index + 1,
198
- type: sourceOperation?.type || 'redline',
199
- operationType: operation.operationKind,
200
- status: 'error',
201
- authorUsed,
202
- error: compiledBinding.error
203
- });
204
- continue;
205
- }
206
- if (compiledBinding?.createdByOperation) {
207
- results.push({
208
- index: index + 1,
209
- type: sourceOperation?.type || 'redline',
210
- operationType: operation.operationKind,
211
- status: 'deferred',
212
- authorUsed,
213
- resolvedBy: 'created_content_dependency',
214
- captureRef: compiledBinding.captureRef,
215
- select: operation.targetDescriptor.text,
216
- createdByOperation: compiledBinding.createdByOperation
217
- });
218
- continue;
219
- }
205
+ continue;
206
+ }
207
+
208
+ if (compiledBinding?.error) {
209
+ results.push({
210
+ index: index + 1,
211
+ type: sourceOperation?.type || 'redline',
212
+ operationType: operation.operationKind,
213
+ status: 'error',
214
+ authorUsed,
215
+ error: compiledBinding.error
216
+ });
217
+ continue;
218
+ }
219
+ if (compiledBinding?.createdByOperation) {
220
+ results.push({
221
+ index: index + 1,
222
+ type: sourceOperation?.type || 'redline',
223
+ operationType: operation.operationKind,
224
+ status: 'deferred',
225
+ authorUsed,
226
+ resolvedBy: 'created_content_dependency',
227
+ captureRef: compiledBinding.captureRef,
228
+ select: operation.targetDescriptor.text,
229
+ createdByOperation: compiledBinding.createdByOperation
230
+ });
231
+ continue;
232
+ }
220
233
 
221
234
  const targetView = operation.targetDescriptor?.revisionView === 'rejected' ? 'rejected' : 'accepted';
222
235
  let currentMetadataIndex = targetView === 'rejected'
@@ -224,28 +237,28 @@ export function preflightOperations(documentXml, operations, author, options = {
224
237
  : metadataIndices.accepted;
225
238
 
226
239
  try {
227
- let resolved;
228
- if (compiledBinding.dynamic) {
229
- resolved = resolveTargetParagraph(xmlDoc, {
230
- targetText: operation.target,
231
- targetRef: operation.targetRef,
232
- targetDescriptor: operation.targetDescriptor,
233
- opType: operation.operationKind,
234
- strictAmbiguity: strictTargets,
235
- paragraphMetadataIndex: currentMetadataIndex,
236
- metadataIndices,
237
- onInfo: options.onInfo,
238
- onWarn: options.onWarn
239
- });
240
- } else {
241
- const bound = batchCompilation.registry.resolve(compiledBinding.sourceIds[0], xmlDoc);
242
- if (bound.error) throw Object.assign(new Error(bound.error.message), bound.error);
243
- resolved = {
244
- paragraph: bound.paragraph,
245
- resolvedBy: compiledBinding.resolvedBy,
246
- warnings: compiledBinding.warnings
247
- };
248
- }
240
+ let resolved;
241
+ if (compiledBinding.dynamic) {
242
+ resolved = resolveTargetParagraph(xmlDoc, {
243
+ targetText: operation.target,
244
+ targetRef: operation.targetRef,
245
+ targetDescriptor: operation.targetDescriptor,
246
+ opType: operation.operationKind,
247
+ strictAmbiguity: strictTargets,
248
+ paragraphMetadataIndex: currentMetadataIndex,
249
+ metadataIndices,
250
+ onInfo: options.onInfo,
251
+ onWarn: options.onWarn
252
+ });
253
+ } else {
254
+ const bound = batchCompilation.registry.resolve(compiledBinding.sourceIds[0], xmlDoc);
255
+ if (bound.error) throw Object.assign(new Error(bound.error.message), bound.error);
256
+ resolved = {
257
+ paragraph: bound.paragraph,
258
+ resolvedBy: compiledBinding.resolvedBy,
259
+ warnings: compiledBinding.warnings
260
+ };
261
+ }
249
262
  const paragraph = resolved.paragraph;
250
263
  const metadata = targetMetadata(xmlDoc, paragraph, resolved.resolvedBy, operation.target, currentMetadataIndex, targetView);
251
264
  const paragraphText = metadata.resolvedTarget.text;
@@ -264,69 +277,69 @@ export function preflightOperations(documentXml, operations, author, options = {
264
277
  || options.existingRevisions
265
278
  || 'merge-same-author';
266
279
  const deletingWholeParagraph = operation.operationKind === 'redline' && operation.modified === '';
267
- const commentIds = deletingWholeParagraph ? getCommentIdsInParagraph(paragraph) : [];
268
-
269
- let error = null;
270
- if (operation.operationKind === 'restore') {
271
- let restorationParagraphs = operation.targetEndRef
272
- ? resolveParagraphRangeByRefs(xmlDoc, operation.targetRef, operation.targetEndRef, {
273
- opType: 'restore',
274
- targetRefSnapshot: options.targetRefSnapshot || null,
275
- onInfo: options.onInfo,
276
- onWarn: options.onWarn
277
- })
278
- : [paragraph];
279
- if (!operation.targetEndRef && operation.targetEndDescriptor) {
280
- const endResolved = resolveTargetParagraph(xmlDoc, {
281
- targetText: operation.targetEndDescriptor.text,
282
- targetRef: operation.targetEndDescriptor.index,
283
- targetDescriptor: operation.targetEndDescriptor,
284
- opType: 'restore',
285
- strictAmbiguity: strictTargets,
286
- paragraphMetadataIndex: currentMetadataIndex,
287
- metadataIndices,
288
- onInfo: options.onInfo,
289
- onWarn: options.onWarn
290
- });
291
- const allParagraphs = getDocumentParagraphNodes(xmlDoc);
292
- const startIndex = allParagraphs.indexOf(paragraph);
293
- const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
294
- restorationParagraphs = startIndex >= 0 && endIndex >= startIndex
295
- ? allParagraphs.slice(startIndex, endIndex + 1)
296
- : null;
297
- }
298
- const replacements = Array.isArray(operation.modified) ? operation.modified : [operation.modified];
299
- if (!restorationParagraphs || restorationParagraphs[0] !== paragraph || replacements.length !== restorationParagraphs.length) {
300
- error = {
301
- code: 'RESTORATION_COUNT_MISMATCH',
302
- message: 'Restoration requires one replacement for every paragraph in a contiguous resolved range.'
303
- };
304
- } else {
305
- for (const restorationParagraph of restorationParagraphs) {
306
- const structuralRefusal = getParagraphRestorationRefusal(restorationParagraph);
307
- if (structuralRefusal?.code === 'UNSUPPORTED_MOVE_REVISION') {
308
- error = structuralRefusal;
309
- break;
310
- }
311
- const state = inspectForeignDeletedParagraphTarget(restorationParagraph, authorUsed);
312
- const refusal = state.matches
313
- ? (structuralRefusal || getParagraphRestorationRefusal(restorationParagraph))
314
- : null;
315
- if (!state.matches) {
316
- error = {
317
- code: 'RESTORATION_STATE_REQUIRED',
318
- message: 'Explicit restoration requires a foreign paragraph-mark deletion with every pre-existing content node deleted.',
319
- ...(state.ownerAuthor ? { ownerAuthor: state.ownerAuthor } : {})
320
- };
321
- break;
322
- }
323
- if (refusal) {
324
- error = refusal;
325
- break;
326
- }
327
- }
328
- }
329
- } else if (!anchorFound) {
280
+ const commentIds = deletingWholeParagraph ? getCommentIdsInParagraph(paragraph) : [];
281
+
282
+ let error = null;
283
+ if (operation.operationKind === 'restore') {
284
+ let restorationParagraphs = operation.targetEndRef
285
+ ? resolveParagraphRangeByRefs(xmlDoc, operation.targetRef, operation.targetEndRef, {
286
+ opType: 'restore',
287
+ targetRefSnapshot: options.targetRefSnapshot || null,
288
+ onInfo: options.onInfo,
289
+ onWarn: options.onWarn
290
+ })
291
+ : [paragraph];
292
+ if (!operation.targetEndRef && operation.targetEndDescriptor) {
293
+ const endResolved = resolveTargetParagraph(xmlDoc, {
294
+ targetText: operation.targetEndDescriptor.text,
295
+ targetRef: operation.targetEndDescriptor.index,
296
+ targetDescriptor: operation.targetEndDescriptor,
297
+ opType: 'restore',
298
+ strictAmbiguity: strictTargets,
299
+ paragraphMetadataIndex: currentMetadataIndex,
300
+ metadataIndices,
301
+ onInfo: options.onInfo,
302
+ onWarn: options.onWarn
303
+ });
304
+ const allParagraphs = getDocumentParagraphNodes(xmlDoc);
305
+ const startIndex = allParagraphs.indexOf(paragraph);
306
+ const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
307
+ restorationParagraphs = startIndex >= 0 && endIndex >= startIndex
308
+ ? allParagraphs.slice(startIndex, endIndex + 1)
309
+ : null;
310
+ }
311
+ const replacements = Array.isArray(operation.modified) ? operation.modified : [operation.modified];
312
+ if (!restorationParagraphs || restorationParagraphs[0] !== paragraph || replacements.length !== restorationParagraphs.length) {
313
+ error = {
314
+ code: 'RESTORATION_COUNT_MISMATCH',
315
+ message: 'Restoration requires one replacement for every paragraph in a contiguous resolved range.'
316
+ };
317
+ } else {
318
+ for (const restorationParagraph of restorationParagraphs) {
319
+ const structuralRefusal = getParagraphRestorationRefusal(restorationParagraph);
320
+ if (structuralRefusal?.code === 'UNSUPPORTED_MOVE_REVISION') {
321
+ error = structuralRefusal;
322
+ break;
323
+ }
324
+ const state = inspectForeignDeletedParagraphTarget(restorationParagraph, authorUsed);
325
+ const refusal = state.matches
326
+ ? (structuralRefusal || getParagraphRestorationRefusal(restorationParagraph))
327
+ : null;
328
+ if (!state.matches) {
329
+ error = {
330
+ code: 'RESTORATION_STATE_REQUIRED',
331
+ message: 'Explicit restoration requires a foreign paragraph-mark deletion with every pre-existing content node deleted.',
332
+ ...(state.ownerAuthor ? { ownerAuthor: state.ownerAuthor } : {})
333
+ };
334
+ break;
335
+ }
336
+ if (refusal) {
337
+ error = refusal;
338
+ break;
339
+ }
340
+ }
341
+ }
342
+ } else if (!anchorFound) {
330
343
  error = anchorResolution?.error || {
331
344
  code: 'ANCHOR_NOT_FOUND',
332
345
  message: `Anchor text was not found in target paragraph: "${anchor}".`
@@ -347,40 +360,40 @@ export function preflightOperations(documentXml, operations, author, options = {
347
360
  && existingPolicy !== 'accept-all-first'
348
361
  && existingPolicy !== 'accept-all-first-keep-normalized'
349
362
  ) {
350
- if (existingPolicy === 'merge-same-author' || existingPolicy === 'slice-cross-author') {
351
- const authors = getTrackedChangeAuthors(paragraph);
352
- const opAuthor = String(authorUsed || '').trim().toLowerCase();
353
- const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
354
- if (!allSame && existingPolicy === 'merge-same-author') {
355
- error = {
356
- code: 'EXISTING_REVISIONS',
357
- message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
358
- revisionAuthors: authors,
359
- currentPolicy: existingPolicy
360
- };
361
- } else if (allSame) {
362
- const mergeCommentIds = getCommentIdsInParagraph(paragraph);
363
- if (mergeCommentIds.length > 0) {
364
- const comments = mergeCommentIds
365
- .map(id => options._existingCommentDetails?.[id])
366
- .filter(Boolean);
367
- error = {
368
- code: 'COMMENTED_CONTENT_MERGE',
369
- message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
370
- commentIds: mergeCommentIds,
371
- ...(comments.length > 0 ? { comments } : {})
372
- };
373
- }
374
- } else {
375
- const hasMove = paragraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
376
- || paragraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
377
- if (hasMove) {
378
- error = {
379
- code: 'UNSAFE_REVISION_NESTING',
380
- message: 'Cross-author slicing does not support pending move revisions.'
381
- };
382
- }
383
- }
363
+ if (existingPolicy === 'merge-same-author' || existingPolicy === 'slice-cross-author') {
364
+ const authors = getTrackedChangeAuthors(paragraph);
365
+ const opAuthor = String(authorUsed || '').trim().toLowerCase();
366
+ const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
367
+ if (!allSame && existingPolicy === 'merge-same-author') {
368
+ error = {
369
+ code: 'EXISTING_REVISIONS',
370
+ message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
371
+ revisionAuthors: authors,
372
+ currentPolicy: existingPolicy
373
+ };
374
+ } else if (allSame) {
375
+ const mergeCommentIds = getCommentIdsInParagraph(paragraph);
376
+ if (mergeCommentIds.length > 0) {
377
+ const comments = mergeCommentIds
378
+ .map(id => options._existingCommentDetails?.[id])
379
+ .filter(Boolean);
380
+ error = {
381
+ code: 'COMMENTED_CONTENT_MERGE',
382
+ message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
383
+ commentIds: mergeCommentIds,
384
+ ...(comments.length > 0 ? { comments } : {})
385
+ };
386
+ }
387
+ } else {
388
+ const hasMove = paragraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
389
+ || paragraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
390
+ if (hasMove) {
391
+ error = {
392
+ code: 'UNSAFE_REVISION_NESTING',
393
+ message: 'Cross-author slicing does not support pending move revisions.'
394
+ };
395
+ }
396
+ }
384
397
  } else {
385
398
  const hasDel = paragraph.getElementsByTagNameNS(NS_W, 'del').length > 0;
386
399
  const hasMove = paragraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
@@ -450,24 +463,24 @@ export function preflightOperations(documentXml, operations, author, options = {
450
463
  }
451
464
  }
452
465
 
453
- const conflicts = batchCompilation.conflicts;
466
+ const conflicts = batchCompilation.conflicts;
454
467
 
455
- const enrichedResults = results.map(result => result.error ? {
456
- ...result,
457
- error: normalizedError(result.error, {
458
- operationIndex: result.index,
459
- ...(sourceOperations[result.index - 1]?.operationId
460
- ? { operationId: sourceOperations[result.index - 1].operationId }
461
- : {})
462
- })
463
- } : result);
464
- const enrichedConflicts = conflicts.map(conflict => normalizedError(conflict));
465
- const hasErrors = enrichedResults.some(result => result.status === 'error');
466
- return {
467
- valid: !hasErrors && enrichedConflicts.length === 0,
468
- status: !hasErrors && enrichedConflicts.length === 0 ? 'ok' : 'error',
469
- results: enrichedResults,
470
- conflicts: enrichedConflicts,
468
+ const enrichedResults = results.map(result => result.error ? {
469
+ ...result,
470
+ error: normalizedError(result.error, {
471
+ operationIndex: result.index,
472
+ ...(sourceOperations[result.index - 1]?.operationId
473
+ ? { operationId: sourceOperations[result.index - 1].operationId }
474
+ : {})
475
+ })
476
+ } : result);
477
+ const enrichedConflicts = conflicts.map(conflict => normalizedError(conflict));
478
+ const hasErrors = enrichedResults.some(result => result.status === 'error');
479
+ return {
480
+ valid: !hasErrors && enrichedConflicts.length === 0,
481
+ status: !hasErrors && enrichedConflicts.length === 0 ? 'ok' : 'error',
482
+ results: enrichedResults,
483
+ conflicts: enrichedConflicts,
471
484
  authorsUsed: Array.from(authorsUsed),
472
485
  requiredArtifacts: {
473
486
  comments: commentsRequired,
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Registry of OPC package parts this library reads or writes.
3
+ *
4
+ * Single source of truth for part paths, content types and relationship types so the
5
+ * package plumbing never hard-codes them. Every `verified` entry is checked against
6
+ * documents saved by real Word in tests/package_parts_registry_tests.mjs
7
+ * (fixtures under tests/fixtures/word-authored/).
8
+ */
9
+
10
+ const WORD_ML = 'application/vnd.openxmlformats-officedocument.wordprocessingml';
11
+ const REL_OFFICE_DOC = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
12
+
13
+ /**
14
+ * @typedef {object} PackagePartSpec
15
+ * @property {string} id
16
+ * @property {string|null} path Fixed part path, or null when the name is not fixed (headers/footers).
17
+ * @property {RegExp|null} pathPattern Matches every part name of this kind, when the name is not fixed.
18
+ * @property {string} contentType The value Word writes in [Content_Types].xml.
19
+ * @property {string[]} legacyContentTypes Known-wrong values written by earlier versions; safe to rewrite.
20
+ * @property {string} relType Relationship type from word/_rels/document.xml.rels.
21
+ * @property {string|null} relTarget Relative target for a fixed-path part.
22
+ * @property {boolean} verified Confirmed against a Word-saved document.
23
+ */
24
+
25
+ /** @type {ReadonlyArray<Readonly<PackagePartSpec>>} */
26
+ const PART_SPECS = Object.freeze([
27
+ { id: 'document', path: 'word/document.xml', pathPattern: null, contentType: `${WORD_ML}.document.main+xml`, legacyContentTypes: [], relType: `${REL_OFFICE_DOC}/officeDocument`, relTarget: null, verified: true },
28
+ { id: 'numbering', path: 'word/numbering.xml', pathPattern: null, contentType: `${WORD_ML}.numbering+xml`, legacyContentTypes: [], relType: `${REL_OFFICE_DOC}/numbering`, relTarget: 'numbering.xml', verified: false },
29
+ { id: 'comments', path: 'word/comments.xml', pathPattern: null, contentType: `${WORD_ML}.comments+xml`, legacyContentTypes: [], relType: `${REL_OFFICE_DOC}/comments`, relTarget: 'comments.xml', verified: true },
30
+ {
31
+ id: 'commentsExtended', path: 'word/commentsExtended.xml', pathPattern: null,
32
+ contentType: `${WORD_ML}.commentsExtended+xml`,
33
+ // Written by this package through 0.8.0; Word does not recognize it and offers to repair the file.
34
+ legacyContentTypes: ['application/vnd.ms-word.commentsExtended+xml'],
35
+ relType: 'http://schemas.microsoft.com/office/2011/relationships/commentsExtended', relTarget: 'commentsExtended.xml', verified: true
36
+ },
37
+ { id: 'commentsIds', path: 'word/commentsIds.xml', pathPattern: null, contentType: `${WORD_ML}.commentsIds+xml`, legacyContentTypes: [], relType: 'http://schemas.microsoft.com/office/2016/09/relationships/commentsIds', relTarget: 'commentsIds.xml', verified: true },
38
+ { id: 'commentsExtensible', path: 'word/commentsExtensible.xml', pathPattern: null, contentType: `${WORD_ML}.commentsExtensible+xml`, legacyContentTypes: [], relType: 'http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible', relTarget: 'commentsExtensible.xml', verified: true },
39
+ // Not yet produced by any Word fixture (Word only writes people.xml for signed-in authors): unverified.
40
+ { id: 'people', path: 'word/people.xml', pathPattern: null, contentType: `${WORD_ML}.people+xml`, legacyContentTypes: [], relType: 'http://schemas.microsoft.com/office/2011/relationships/people', relTarget: 'people.xml', verified: false },
41
+ { id: 'header', path: null, pathPattern: /^word\/header\d+\.xml$/i, contentType: `${WORD_ML}.header+xml`, legacyContentTypes: [], relType: `${REL_OFFICE_DOC}/header`, relTarget: null, verified: true },
42
+ { id: 'footer', path: null, pathPattern: /^word\/footer\d+\.xml$/i, contentType: `${WORD_ML}.footer+xml`, legacyContentTypes: [], relType: `${REL_OFFICE_DOC}/footer`, relTarget: null, verified: true }
43
+ ].map(spec => Object.freeze(spec)));
44
+
45
+ const BY_ID = new Map(PART_SPECS.map(spec => [spec.id, spec]));
46
+
47
+ /** Returns the spec for a registry id; throws for an unknown id so typos fail loudly. */
48
+ export function getPartSpec(id) {
49
+ const spec = BY_ID.get(id);
50
+ if (!spec) throw new Error(`Unknown package part: ${id}`);
51
+ return spec;
52
+ }
53
+
54
+ /** All registered part specs. */
55
+ export function listPartSpecs() {
56
+ return PART_SPECS;
57
+ }
58
+
59
+ /** Normalizes '/word/x.xml' or 'word/x.xml' to 'word/x.xml'. */
60
+ function toEntryName(partName) {
61
+ return String(partName || '').replace(/^\/+/, '');
62
+ }
63
+
64
+ /** Finds the spec that owns a part name (with or without a leading slash), or null. */
65
+ export function findPartSpecByPartName(partName) {
66
+ const name = toEntryName(partName);
67
+ const lower = name.toLowerCase();
68
+ return PART_SPECS.find(spec => spec.path ? spec.path.toLowerCase() === lower : spec.pathPattern?.test(name)) || null;
69
+ }
70
+
71
+ /**
72
+ * When an override's content type is a value earlier versions of this package are known to have
73
+ * written wrongly for that part, returns the correct one; otherwise null. Deliberately does not
74
+ * "correct" types it has no evidence about (for example macro-enabled document types).
75
+ */
76
+ export function correctedContentTypeFor(partName, actualContentType) {
77
+ const spec = findPartSpecByPartName(partName);
78
+ if (!spec || !spec.legacyContentTypes.includes(actualContentType)) return null;
79
+ return spec.contentType;
80
+ }