@ansonlai/docx-redline-js 0.8.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/ARCHITECTURE.md +679 -460
  2. package/CHANGELOG.md +208 -1
  3. package/README.md +152 -16
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/core/word-xml.js +160 -118
  9. package/dist/docx-redline-js.esm.js +4338 -796
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +111 -108
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/dist/docx-redline.bundle.cjs +4463 -921
  14. package/dist/docx-redline.bundle.cjs.map +4 -4
  15. package/dist/docx-redline.bundle.js +4463 -921
  16. package/dist/docx-redline.bundle.js.map +4 -4
  17. package/docs/AGENT_FAST_START.md +2 -0
  18. package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
  19. package/docs/SKILL_AUTHORING.md +169 -150
  20. package/docs/TESTING.md +1146 -1059
  21. package/docs/schemas/document-operations.schema.json +55 -2
  22. package/document/docx-document.js +1022 -1010
  23. package/engine/oxml-engine.js +21 -22
  24. package/engine/paragraph-mark-revisions.js +292 -0
  25. package/engine/reconstruction-mapper.js +312 -311
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +53 -2
  32. package/index.js +13 -2
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +21 -3
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/markdown-processor.js +5 -2
  42. package/pipeline/pipeline.js +206 -204
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +23 -3
  52. package/services/capture-engine.js +228 -226
  53. package/services/comment-replies.js +1 -1
  54. package/services/document-operation-applier.js +214 -0
  55. package/services/document-operation-contract.js +76 -0
  56. package/services/document-operation-mutations.js +688 -51
  57. package/services/error-recovery.js +17 -0
  58. package/services/inspection-comparison.js +82 -0
  59. package/services/list-conversion-diagnostics.js +296 -0
  60. package/services/list-format-change.js +586 -0
  61. package/services/list-format-mutation.js +416 -0
  62. package/services/list-format.js +305 -0
  63. package/services/numbering-allocation.js +642 -0
  64. package/services/numbering-helpers.js +354 -524
  65. package/services/operation-batch-compiler.js +183 -11
  66. package/services/operation-preflight.js +43 -33
  67. package/services/receipt-collector.js +288 -288
  68. package/services/revision-comment-management.js +38 -5
  69. package/services/revision-fidelity-verifier.js +484 -0
  70. package/services/standalone-docx-plumbing.js +627 -631
  71. package/services/standalone-operation-runner.d.ts +182 -5
  72. package/services/table-reconciliation.js +59 -7
@@ -10,9 +10,81 @@ import {
10
10
  validateDocumentOperation
11
11
  } from './document-operation-contract.js';
12
12
  import { compileExactReplacements } from './localized-replacement-compiler.js';
13
+ import { getParagraphListInfo } from '../core/list-targeting.js';
14
+ import { parseTypedListMarker } from './list-format.js';
15
+ import { isTrackedDeletedParagraph } from './numbering-helpers.js';
13
16
 
14
- const TEXT_WRITE_KINDS = new Set(['redline', 'restore', 'rejected-insert']);
17
+ // list-format strips typed markers (a text write) and binds numPr (a pPr write). On its own target it conflicts with
18
+ // every other text or format write except one compatible single-paragraph redline (`checkListFormatComposition`).
19
+ const TEXT_WRITE_KINDS = new Set(['redline', 'restore', 'rejected-insert', 'list-format']);
15
20
  const FORMAT_WRITE_KINDS = new Set(['highlight', 'format', 'paragraph-format']);
21
+ const WORD_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
22
+ export const LIST_FORMAT_COMPOSITION_CONFLICT = 'LIST_FORMAT_COMPOSITION_CONFLICT';
23
+ const COMPOSABLE_REDLINE_TYPES = new Set(['redline', 'replace']);
24
+
25
+ /**
26
+ * The typed-marker prefix of a paragraph text as list-format reads it: the exact text a convert-plain strip removes
27
+ * ("1. ", "A. ", "- "), `unsupported:<marker>` for a marker shape list-format refuses, or null for no marker.
28
+ */
29
+ function typedMarkerKey(text) {
30
+ const value = String(text ?? '');
31
+ const parsed = parseTypedListMarker(value);
32
+ if (!parsed) return null;
33
+ if (parsed.unsupported) return `unsupported:${parsed.marker}`;
34
+ return value.slice(0, parsed.prefixLength);
35
+ }
36
+
37
+ /**
38
+ * Rule for one `redline` and one `list-format` writing the same batch-start paragraph (the list-format targets it, or
39
+ * rebinds it through `scope: 'list'`). They compose when the redline changes only the paragraph's text after its typed
40
+ * marker and the list-format owns the marker and numbering: e.g. "1. Old" -> "1. New" as a redline plus
41
+ * `convert-plain`. Execution order is fixed: the redline runs first (its target binds at batch start), then the
42
+ * list-format strips the unchanged marker from the redlined paragraph. Returns null when compatible, else a reason.
43
+ */
44
+ function checkListFormatComposition(redline, sourceText) {
45
+ const operation = redline.operation;
46
+ if (!COMPOSABLE_REDLINE_TYPES.has(operation?.type ?? 'redline')
47
+ || operation.targetEnd != null || operation.targetEndRef != null
48
+ || operation.anchor != null || operation.structuredContent === true || operation.part != null
49
+ || operation.target?.revisionView === 'rejected'
50
+ || typeof operation.modified !== 'string' || /[\r\n]/.test(operation.modified)) {
51
+ return {
52
+ reason: 'redline-shape',
53
+ hint: 'Pair a list-format only with a single-paragraph redline or replace of that paragraph: modified without line breaks, no targetEnd, anchor, part or structuredContent.'
54
+ };
55
+ }
56
+ if (operation.modified === sourceText) {
57
+ return {
58
+ reason: 'redline-unchanged',
59
+ hint: 'The redline does not change the text; drop it and send only the list-format.'
60
+ };
61
+ }
62
+ const before = typedMarkerKey(sourceText);
63
+ const after = typedMarkerKey(operation.modified);
64
+ if (before !== after) {
65
+ return {
66
+ reason: 'marker-changed',
67
+ sourceMarker: before,
68
+ modifiedMarker: after,
69
+ hint: 'The redline adds, removes or changes the typed marker. Keep the marker text exactly as it is (the list-format strips it), and express a new label through list-format (strip "none" with format/start, or change-format).'
70
+ };
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /** Live body paragraphs a `scope: 'list'` change-format/join rebinds outside its named target. */
76
+ function listScopeRebindParagraphs(xmlDoc, registry, binding, operation) {
77
+ const mode = operation.list?.mode;
78
+ if (mode !== 'change-format' && mode !== 'join') return [];
79
+ if ((operation.list?.scope || 'list') !== 'list') return [];
80
+ const targets = binding.sourceIds.map(sourceId => registry.entries.get(sourceId)?.paragraph).filter(Boolean);
81
+ const info = targets.map(paragraph => getParagraphListInfo(paragraph)).find(Boolean);
82
+ if (!info) return [];
83
+ return Array.from(xmlDoc.getElementsByTagNameNS(WORD_NS, 'p')).filter(paragraph => {
84
+ if (targets.includes(paragraph) || isTrackedDeletedParagraph(paragraph)) return false;
85
+ return getParagraphListInfo(paragraph)?.numId === info.numId;
86
+ });
87
+ }
16
88
 
17
89
  function normalizedError(error) {
18
90
  const details = {};
@@ -143,16 +215,24 @@ export class SourceTargetRegistry {
143
215
  }
144
216
  }
145
217
 
146
- commitMutation(sourceId, removedNodes, liveNodes, operationIndex) {
218
+ /** The paragraph that will stand for sourceId once this mutation commits (no state change). */
219
+ previewSuccessor(sourceId, removedNodes, liveNodes) {
147
220
  const entry = this.entries.get(sourceId);
148
- if (!entry || !Array.isArray(removedNodes) || !removedNodes.includes(entry.paragraph)) return;
221
+ if (!entry || !entry.paragraph) return null;
222
+ if (!Array.isArray(removedNodes) || !removedNodes.includes(entry.paragraph)) return entry.paragraph;
149
223
  const candidates = paragraphNodesFromMutation(liveNodes);
150
224
  const sameId = entry.paragraphId
151
225
  ? candidates.filter(paragraph => getParagraphId(paragraph) === entry.paragraphId)
152
226
  : [];
153
- const successor = sameId.length === 1
227
+ return sameId.length === 1
154
228
  ? sameId[0]
155
229
  : (candidates.length === 1 ? candidates[0] : null);
230
+ }
231
+
232
+ commitMutation(sourceId, removedNodes, liveNodes, operationIndex) {
233
+ const entry = this.entries.get(sourceId);
234
+ if (!entry || !Array.isArray(removedNodes) || !removedNodes.includes(entry.paragraph)) return;
235
+ const successor = this.previewSuccessor(sourceId, removedNodes, liveNodes);
156
236
  if (successor) {
157
237
  entry.paragraph = successor;
158
238
  this.idsByNode.set(successor, sourceId);
@@ -200,17 +280,19 @@ function resolveDescriptor(xmlDoc, descriptor, kind, strictTargets, metadataIndi
200
280
  };
201
281
  }
202
282
 
203
- function buildConflict(code, message, bindings, target, action) {
283
+ function buildConflict(code, message, bindings, target, action, details = {}, hint = null) {
204
284
  return {
205
285
  code,
206
286
  message,
207
287
  operationIndexes: bindings.map(binding => binding.index).sort((a, b) => a - b),
208
288
  target,
289
+ ...details,
209
290
  recovery: {
210
291
  action,
211
292
  sameArgumentsSafe: false,
212
293
  requiresReinspection: false,
213
- requiresUserAuthorization: false
294
+ requiresUserAuthorization: false,
295
+ ...(hint ? { hint } : {})
214
296
  }
215
297
  };
216
298
  }
@@ -246,10 +328,11 @@ export function compileOperationBatch(xmlDoc, operations = [], options = {}) {
246
328
  const compositeTextTarget = typeof operation.targetDescriptor?.text === 'string'
247
329
  && /\r|\n/.test(operation.targetDescriptor.text)
248
330
  && !operation.targetEndDescriptor
249
- // A strong paragraph target with localized replacements is one paragraph whose
250
- // soft line breaks (w:br) appear as "\n" in text; it still binds at batch start.
251
- && !(Array.isArray(operation.replacements)
252
- && (operation.targetDescriptor.paragraphId || operation.targetDescriptor.fingerprint));
331
+ // Localized replacements always address one paragraph (the contract refuses ranges and
332
+ // captures), so "\n" in the target text is a soft line break (w:br), not a paragraph
333
+ // boundary. Strong or weak (index + exactText) it binds at batch start; leaving it
334
+ // dynamic would skip replacement compilation and apply an empty "modified".
335
+ && !Array.isArray(operation.replacements);
253
336
  if (dynamicOccurrence || compositeTextTarget) binding.dynamic = true;
254
337
 
255
338
  if (
@@ -346,6 +429,28 @@ export function compileOperationBatch(xmlDoc, operations = [], options = {}) {
346
429
  binding.sourceIds = [...new Set(binding.sourceIds)];
347
430
  binding.resolvedTargetEnd = end.metadata;
348
431
  }
432
+
433
+ // list-format join reads (never writes) the paragraph it continues; bind it at batch start too.
434
+ if (operation.operationKind === 'list-format' && operation.list?.continueFrom && typeof operation.list.continueFrom === 'object') {
435
+ let continueFrom;
436
+ try {
437
+ continueFrom = resolveDescriptor(
438
+ xmlDoc,
439
+ normalizeTargetDescriptor(operation.list.continueFrom, null, 'accepted'),
440
+ operation.operationKind,
441
+ strictTargets,
442
+ metadataIndices,
443
+ {
444
+ onInfo: options.onInfo,
445
+ onWarn: warning => binding.warnings.push(String(warning))
446
+ }
447
+ );
448
+ } catch (error) {
449
+ throw Object.assign(error instanceof Error ? error : new Error(String(error)), { field: 'list.continueFrom' });
450
+ }
451
+ compiled._compiledContinueFromId = registry.register(continueFrom.paragraph, continueFrom.metadata);
452
+ binding.resolvedContinueFrom = continueFrom.metadata;
453
+ }
349
454
  } catch (error) {
350
455
  binding.error = normalizedError(error);
351
456
  }
@@ -388,17 +493,84 @@ export function compileOperationBatch(xmlDoc, operations = [], options = {}) {
388
493
  }
389
494
 
390
495
  const bySource = new Map();
496
+ const rebindsBySource = new Map();
391
497
  for (const binding of bindings) {
392
498
  if (binding.error || binding.dynamic) continue;
393
499
  for (const sourceId of binding.sourceIds) {
394
500
  if (!bySource.has(sourceId)) bySource.set(sourceId, []);
395
501
  bySource.get(sourceId).push(binding);
396
502
  }
503
+ // A scope "list" change-format/join writes pPr on every live item of the target's list, so those items are
504
+ // registered as format writes of the operation: another write to them must compose or is refused.
505
+ if (binding.operationKind === 'list-format') {
506
+ const operation = compiledOperations[binding.index - 1];
507
+ for (const paragraph of listScopeRebindParagraphs(xmlDoc, registry, binding, operation)) {
508
+ const sourceId = registry.register(paragraph, metadataIndices.accepted.byParagraph.get(paragraph) || {});
509
+ if (binding.sourceIds.includes(sourceId)) continue;
510
+ (binding.rebindSourceIds ||= []).push(sourceId);
511
+ if (!bySource.has(sourceId)) bySource.set(sourceId, []);
512
+ if (!rebindsBySource.has(sourceId)) rebindsBySource.set(sourceId, []);
513
+ rebindsBySource.get(sourceId).push(binding);
514
+ }
515
+ }
397
516
  }
398
517
 
399
518
  const conflicts = [];
400
519
  const conflictKeys = new Set();
401
- for (const [sourceId, sourceBindings] of bySource) {
520
+ const pushConflict = (key, conflict) => {
521
+ if (conflictKeys.has(key)) return;
522
+ conflictKeys.add(key);
523
+ conflicts.push(conflict);
524
+ };
525
+ for (const [sourceId, targetBindings] of bySource) {
526
+ const rebinds = rebindsBySource.get(sourceId) || [];
527
+ const writers = [...targetBindings, ...rebinds]
528
+ .filter(binding => TEXT_WRITE_KINDS.has(binding.operationKind) || FORMAT_WRITE_KINDS.has(binding.operationKind));
529
+ const listFormats = writers.filter(binding => binding.operationKind === 'list-format');
530
+ const others = writers.filter(binding => binding.operationKind !== 'list-format');
531
+ if (listFormats.length === 1 && others.length === 1 && others[0].operationKind === 'redline') {
532
+ const [listFormat] = listFormats;
533
+ const [redline] = others;
534
+ const describedSource = registry.describe(sourceId);
535
+ const incompatible = checkListFormatComposition(
536
+ { operation: compiledOperations[redline.index - 1] },
537
+ redline.resolvedTarget?.text ?? describedSource?.text ?? ''
538
+ );
539
+ if (incompatible) {
540
+ const affected = [redline, listFormat];
541
+ pushConflict(`list:${affected.map(binding => binding.index).sort().join(',')}`, buildConflict(
542
+ LIST_FORMAT_COMPOSITION_CONFLICT,
543
+ `Operations ${affected.map(binding => binding.index).sort((a, b) => a - b).join(', ')} combine a redline and a list-format on the same paragraph in a way that does not compose (${incompatible.reason}).`,
544
+ affected,
545
+ describedSource,
546
+ 'split_text_and_list_changes',
547
+ { reason: incompatible.reason, ...(incompatible.sourceMarker !== undefined ? { sourceMarker: incompatible.sourceMarker, modifiedMarker: incompatible.modifiedMarker } : {}) },
548
+ incompatible.hint
549
+ ));
550
+ continue;
551
+ }
552
+ // Deterministic order: the list-format runs after the redline, whatever the batch order.
553
+ const compiledListFormat = compiledOperations[listFormat.index - 1];
554
+ const runAfter = new Set(compiledListFormat._compiledRunAfter || []);
555
+ runAfter.add(redline.index - 1);
556
+ compiledListFormat._compiledRunAfter = [...runAfter].sort((a, b) => a - b);
557
+ listFormat.composedWith = [...new Set([...(listFormat.composedWith || []), redline.index])];
558
+ redline.composedWith = [...new Set([...(redline.composedWith || []), listFormat.index])];
559
+ continue;
560
+ }
561
+ if (rebinds.length > 0 && writers.length > 1) {
562
+ pushConflict(`rebind:${writers.map(binding => binding.index).sort().join(',')}`, buildConflict(
563
+ LIST_FORMAT_COMPOSITION_CONFLICT,
564
+ `Operation ${rebinds.map(binding => binding.index).join(', ')} (list-format, scope "list") rebinds this list item, and operations ${writers.filter(binding => !rebinds.includes(binding)).map(binding => binding.index).join(', ')} also write it.`,
565
+ writers,
566
+ registry.describe(sourceId),
567
+ 'split_text_and_list_changes',
568
+ { reason: 'list-rebind', rebindingOperationIndexes: rebinds.map(binding => binding.index) },
569
+ 'A list-format with scope "list" rewrites every item of the list; in the same batch only one single-paragraph redline per item can compose with it. Send the other change in a separate apply, or use scope "range".'
570
+ ));
571
+ continue;
572
+ }
573
+ const sourceBindings = targetBindings;
402
574
  const textWrites = sourceBindings.filter(binding => TEXT_WRITE_KINDS.has(binding.operationKind));
403
575
  const formatWrites = sourceBindings.filter(binding => FORMAT_WRITE_KINDS.has(binding.operationKind));
404
576
  if (textWrites.length > 1) {
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
6
6
  import { getDefaultAuthor } from '../adapters/config.js';
7
- import { containsTrackedChanges, getTrackedChangeAuthors } from '../core/word-xml.js';
7
+ import { containsTrackedChanges, getTrackedChangeAuthors, hasUnattributedTrackedChange, resolveExistingRevisionsPolicy } from '../core/word-xml.js';
8
8
  import { NS_W } from '../core/types.js';
9
9
  import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
10
10
  import {
@@ -38,6 +38,7 @@ function normalizedError(error, context = {}) {
38
38
  }
39
39
 
40
40
  function operationNeedsNumbering(operation) {
41
+ if (operation.operationKind === 'list-format') return true;
41
42
  if (operation.operationKind !== 'redline' || typeof operation.modified !== 'string') return false;
42
43
  return operation.modified.split(/\r?\n/).some(line => /^\s*(?:[-+*]|\d+[.)])\s+/.test(line));
43
44
  }
@@ -272,42 +273,49 @@ export function preflightOperations(documentXml, operations, author, options = {
272
273
  : null;
273
274
  const anchorFound = anchor == null
274
275
  || (anchorResolution ? anchorResolution.found : paragraphText.includes(anchor));
276
+ // The paragraphs from the target through targetEnd (just the target without one); null when the
277
+ // end does not resolve after the start.
278
+ const rangeParagraphs = opType => {
279
+ if (operation.targetEndRef) {
280
+ return resolveParagraphRangeByRefs(xmlDoc, operation.targetRef, operation.targetEndRef, {
281
+ opType,
282
+ targetRefSnapshot: options.targetRefSnapshot || null,
283
+ onInfo: options.onInfo,
284
+ onWarn: options.onWarn
285
+ });
286
+ }
287
+ if (!operation.targetEndDescriptor) return [paragraph];
288
+ const endResolved = resolveTargetParagraph(xmlDoc, {
289
+ targetText: operation.targetEndDescriptor.text,
290
+ targetRef: operation.targetEndDescriptor.index,
291
+ targetDescriptor: operation.targetEndDescriptor,
292
+ opType,
293
+ strictAmbiguity: strictTargets,
294
+ paragraphMetadataIndex: currentMetadataIndex,
295
+ metadataIndices,
296
+ onInfo: options.onInfo,
297
+ onWarn: options.onWarn
298
+ });
299
+ const allParagraphs = getDocumentParagraphNodes(xmlDoc);
300
+ const startIndex = allParagraphs.indexOf(paragraph);
301
+ const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
302
+ return startIndex >= 0 && endIndex >= startIndex
303
+ ? allParagraphs.slice(startIndex, endIndex + 1)
304
+ : null;
305
+ };
275
306
  const hasRevisions = containsTrackedChanges(paragraph);
276
- const existingPolicy = operation.existingRevisions
277
- || options.existingRevisions
278
- || 'merge-same-author';
307
+ const requestedPolicy = operation.existingRevisions || options.existingRevisions || 'merge-same-author';
308
+ const existingPolicy = resolveExistingRevisionsPolicy(requestedPolicy,
309
+ requestedPolicy === 'auto' && operation.operationKind === 'redline' && (operation.targetEndRef || operation.targetEndDescriptor)
310
+ ? (rangeParagraphs('redline') || paragraph)
311
+ : paragraph,
312
+ authorUsed);
279
313
  const deletingWholeParagraph = operation.operationKind === 'redline' && operation.modified === '';
280
314
  const commentIds = deletingWholeParagraph ? getCommentIdsInParagraph(paragraph) : [];
281
315
 
282
316
  let error = null;
283
317
  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
- }
318
+ const restorationParagraphs = rangeParagraphs('restore');
311
319
  const replacements = Array.isArray(operation.modified) ? operation.modified : [operation.modified];
312
320
  if (!restorationParagraphs || restorationParagraphs[0] !== paragraph || replacements.length !== restorationParagraphs.length) {
313
321
  error = {
@@ -363,11 +371,13 @@ export function preflightOperations(documentXml, operations, author, options = {
363
371
  if (existingPolicy === 'merge-same-author' || existingPolicy === 'slice-cross-author') {
364
372
  const authors = getTrackedChangeAuthors(paragraph);
365
373
  const opAuthor = String(authorUsed || '').trim().toLowerCase();
366
- const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
374
+ const allSame = authors.length > 0
375
+ && !hasUnattributedTrackedChange(paragraph)
376
+ && authors.every(a => a.trim().toLowerCase() === opAuthor);
367
377
  if (!allSame && existingPolicy === 'merge-same-author') {
368
378
  error = {
369
379
  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.`,
380
+ message: `Target paragraph contains tracked changes from another author (${[...authors, ...(hasUnattributedTrackedChange(paragraph) ? ['unattributed'] : [])].join(', ')}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
371
381
  revisionAuthors: authors,
372
382
  currentPolicy: existingPolicy
373
383
  };