@ansonlai/docx-redline-js 0.5.2 → 0.5.3

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.
@@ -14,6 +14,7 @@ const SUPPORTED_OPERATION_TYPES = new Set([
14
14
  'list-change',
15
15
  'table-reconciliation',
16
16
  'insert',
17
+ 'restore',
17
18
  'delete',
18
19
  'comment',
19
20
  'comment_reply',
@@ -42,6 +43,7 @@ function nonEmptyString(value) {
42
43
 
43
44
  export function getCanonicalOperationType(operation) {
44
45
  const type = operation?.type;
46
+ if (type === 'restore') return 'restore';
45
47
  if (type === 'comment' || type === 'comment_reply' || type === 'highlight') return type;
46
48
  if (type === 'paragraph-format') return 'paragraph-format';
47
49
  if (type === 'character-format' || (type === 'format' && (operation?.textToFormat != null || operation?.properties != null))) return 'format';
@@ -83,7 +85,7 @@ export function normalizeTargetDescriptor(target, legacyTargetRef = null) {
83
85
  export function normalizeDocumentOperation(operation) {
84
86
  const source = isRecord(operation) ? operation : {};
85
87
  const targetDescriptor = normalizeTargetDescriptor(source.target, source.targetRef);
86
- const targetEndDescriptor = isRecord(source.targetEnd)
88
+ const targetEndDescriptor = source.targetEnd != null
87
89
  ? normalizeTargetDescriptor(source.targetEnd, source.targetEndRef)
88
90
  : null;
89
91
  const kind = getCanonicalOperationType(source);
@@ -94,6 +96,7 @@ export function normalizeDocumentOperation(operation) {
94
96
  captureKey: nonEmptyString(source.captureKey) ? source.captureKey.trim() : null,
95
97
  operationKind: kind,
96
98
  targetDescriptor,
99
+ targetEndDescriptor,
97
100
  target: targetDescriptor.text,
98
101
  targetRef: targetDescriptor.index,
99
102
  targetEndRef: targetEndDescriptor?.index ?? source.targetEndRef ?? null,
@@ -174,7 +177,14 @@ export function validateDocumentOperation(operation) {
174
177
  }
175
178
 
176
179
  const target = normalized.targetDescriptor;
177
- if (normalized.operationKind !== 'comment_reply' && !nonEmptyString(target.text) && target.index == null && !target.paragraphId && !target.captureRef) {
180
+ if (
181
+ normalized.operationKind !== 'comment_reply'
182
+ && !nonEmptyString(target.text)
183
+ && target.index == null
184
+ && !target.paragraphId
185
+ && !target.fingerprint
186
+ && !target.captureRef
187
+ ) {
178
188
  return {
179
189
  valid: false,
180
190
  error: {
@@ -201,6 +211,31 @@ export function validateDocumentOperation(operation) {
201
211
  };
202
212
  }
203
213
 
214
+ if (normalized.operationKind === 'restore') {
215
+ const validSingle = nonEmptyString(normalized.modified);
216
+ const validRange = Array.isArray(normalized.modified)
217
+ && normalized.modified.length > 0
218
+ && normalized.modified.every(nonEmptyString);
219
+ if (!validSingle && !validRange) {
220
+ return {
221
+ valid: false,
222
+ error: {
223
+ code: 'INVALID_OPERATION',
224
+ message: 'Restore operations require a non-empty string or non-empty string array in "modified".'
225
+ }
226
+ };
227
+ }
228
+ if (normalized.generateRedlines === false) {
229
+ return {
230
+ valid: false,
231
+ error: {
232
+ code: 'INVALID_OPERATION',
233
+ message: 'Restore operations require tracked changes and cannot set generateRedlines to false.'
234
+ }
235
+ };
236
+ }
237
+ }
238
+
204
239
  if (normalized.structuredContent != null && typeof normalized.structuredContent !== 'boolean') {
205
240
  return {
206
241
  valid: false,
@@ -20,7 +20,13 @@ import {
20
20
  } from '../engine/rpr-helpers.js';
21
21
  import { refreshRunPropertyChangeIds } from '../core/revision-cloning.js';
22
22
  import { getDefaultAuthor } from '../adapters/config.js';
23
- import { applyRedlineToOxml as applyRedlineToOxmlEngine } from '../engine/oxml-engine.js';
23
+ import { applyRedlineToOxml as applyRedlineToOxmlEngine } from '../engine/oxml-engine.js';
24
+ import {
25
+ getParagraphRestorationRefusal,
26
+ inspectForeignDeletedParagraphTarget
27
+ } from '../core/paragraph-revision-safety.js';
28
+ import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
29
+ import { validateRedlineOoxml } from '../core/redline-validation.js';
24
30
  import { applyHighlightToOoxml } from '../engine/formatting-removal.js';
25
31
  import { parseTable as parseMarkdownTable } from '../pipeline/pipeline.js';
26
32
  import { injectCommentsIntoOoxml } from './comment-engine.js';
@@ -30,10 +36,11 @@ import {
30
36
  createParagraphFingerprint,
31
37
  isMarkdownTableText,
32
38
  findContainingWordElement,
33
- resolveTargetParagraphWithSnapshot as resolveTargetParagraphWithSnapshotShared,
34
- resolveParagraphRangeByRefs,
35
- validateParagraphBoundaryMutation
36
- } from '../core/paragraph-targeting.js';
39
+ resolveTargetParagraphWithSnapshot as resolveTargetParagraphWithSnapshotShared,
40
+ resolveParagraphRangeByRefs,
41
+ getDocumentParagraphNodes,
42
+ validateParagraphBoundaryMutation
43
+ } from '../core/paragraph-targeting.js';
37
44
  import {
38
45
  synthesizeExpandedListScopeEdit,
39
46
  planListInsertionOnlyEdit,
@@ -70,9 +77,14 @@ import {
70
77
  deriveSingleParagraphListAdjacencyInsertion,
71
78
  deriveSingleParagraphPlainAdjacencyInsertion
72
79
  } from './operation-heuristics.js';
73
- import { resolveTargetFromCapture, ensureParagraphIdsOnImportedNode } from './capture-engine.js';
74
-
75
- const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
80
+ import { resolveTargetFromCapture, ensureParagraphIdsOnImportedNode } from './capture-engine.js';
81
+ import {
82
+ acceptTrackedChangesInOoxml,
83
+ rejectTrackedChangesInOoxml
84
+ } from './revision-comment-management.js';
85
+
86
+ const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
87
+ const NS_W14 = 'http://schemas.microsoft.com/office/word/2010/wordml';
76
88
 
77
89
  function getCommentIdsInElement(element) {
78
90
  const ids = new Set();
@@ -402,18 +414,21 @@ function applyExplicitRangeListInsertions({
402
414
  normalizeBodySectionOrder(xmlDoc);
403
415
  return true;
404
416
  }
405
- function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, author, options = {}) {
417
+ function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, author, options = {}) {
406
418
  const generateRedlines = options.generateRedlines !== false;
407
419
  const paragraph = createWordElement(xmlDoc, 'w:p');
408
420
  const run = createWordElement(xmlDoc, 'w:r');
409
421
  const textNode = createWordElement(xmlDoc, 'w:t');
410
422
  const safeText = String(text || '');
411
423
  if (/^\s|\s$/.test(safeText)) textNode.setAttribute('xml:space', 'preserve');
412
- textNode.textContent = safeText;
413
- run.appendChild(textNode);
414
-
415
- if (generateRedlines) {
416
- const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc);
424
+ textNode.textContent = safeText;
425
+ run.appendChild(textNode);
426
+
427
+ if (generateRedlines) {
428
+ if (options.trackParagraphMark === true) {
429
+ markParagraphMarkInserted(xmlDoc, paragraph, author);
430
+ }
431
+ const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, 'ins');
417
432
  const ins = createWordElement(xmlDoc, 'w:ins');
418
433
  ins.setAttribute('w:id', String(metadata.id));
419
434
  ins.setAttribute('w:author', metadata.author);
@@ -444,13 +459,19 @@ function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
444
459
  return paragraph;
445
460
  }
446
461
 
447
- function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, author) {
448
- const wrappedParagraph = createWordElement(xmlDoc, 'w:p');
449
- const pPr = getDirectWordChild(paragraph, 'pPr');
450
- if (pPr) wrappedParagraph.appendChild(pPr.cloneNode(true));
451
-
452
- const ins = createWordElement(xmlDoc, 'w:ins');
453
- const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc);
462
+ function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, author, options = {}) {
463
+ const wrappedParagraph = createWordElement(xmlDoc, 'w:p');
464
+ const pPr = options.sanitizeParagraphProperties === true
465
+ ? createSanitizedRestorationPPr(xmlDoc, paragraph)
466
+ : getDirectWordChild(paragraph, 'pPr')?.cloneNode(true);
467
+ if (pPr) wrappedParagraph.appendChild(pPr);
468
+ if (options.paragraphId) wrappedParagraph.setAttributeNS(NS_W14, 'w14:paraId', options.paragraphId);
469
+ if (options.trackParagraphMark === true) {
470
+ markParagraphMarkInserted(xmlDoc, wrappedParagraph, author);
471
+ }
472
+
473
+ const ins = createWordElement(xmlDoc, 'w:ins');
474
+ const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, 'ins');
454
475
  ins.setAttribute('w:id', String(metadata.id));
455
476
  ins.setAttribute('w:author', metadata.author);
456
477
  ins.setAttribute('w:date', metadata.date);
@@ -491,9 +512,9 @@ async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisi
491
512
  return buildFallbackInsertedPlainParagraph(
492
513
  xmlDoc,
493
514
  text,
494
- revisionMetadata,
495
- author,
496
- { generateRedlines }
515
+ revisionMetadata,
516
+ author,
517
+ { ...options, generateRedlines }
497
518
  );
498
519
  }
499
520
 
@@ -501,10 +522,10 @@ async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisi
501
522
  return sourceParagraph;
502
523
  }
503
524
 
504
- return wrapParagraphContentInInsertion(xmlDoc, sourceParagraph, revisionMetadata, author);
525
+ return wrapParagraphContentInInsertion(xmlDoc, sourceParagraph, revisionMetadata, author, options);
505
526
  }
506
527
 
507
- function collectNumberingIdsFromNodes(nodes) {
528
+ function collectNumberingIdsFromNodes(nodes) {
508
529
  const ids = new Set();
509
530
  for (const node of nodes || []) {
510
531
  const numIdNodes = Array.from(node?.getElementsByTagNameNS?.('*', 'numId') || []);
@@ -516,7 +537,449 @@ function collectNumberingIdsFromNodes(nodes) {
516
537
  }
517
538
  }
518
539
  return Array.from(ids);
519
- }
540
+ }
541
+
542
+ const RESTORATION_PPR_ALLOWLIST = new Set([
543
+ 'pStyle', 'numPr', 'ind', 'jc', 'spacing', 'tabs',
544
+ 'keepNext', 'keepLines', 'outlineLvl', 'contextualSpacing'
545
+ ]);
546
+
547
+ function wordAttribute(node, localName) {
548
+ return node?.getAttribute?.(`w:${localName}`) || node?.getAttribute?.(localName) || '';
549
+ }
550
+
551
+ function normalizedAuthor(author) {
552
+ return String(author || '').trim().toLowerCase();
553
+ }
554
+
555
+ function directWordParagraphSibling(paragraph, direction = 'nextSibling') {
556
+ let cursor = paragraph?.[direction] || null;
557
+ while (cursor) {
558
+ if (cursor.nodeType === 1 && cursor.namespaceURI === NS_W && cursor.localName === 'p') return cursor;
559
+ cursor = cursor[direction] || null;
560
+ }
561
+ return null;
562
+ }
563
+
564
+ function createSanitizedRestorationPPr(xmlDoc, sourceParagraph) {
565
+ const sourcePPr = getDirectWordChild(sourceParagraph, 'pPr');
566
+ const pPr = createWordElement(xmlDoc, 'w:pPr');
567
+ if (!sourcePPr) return pPr;
568
+ for (const child of Array.from(sourcePPr.childNodes || [])) {
569
+ if (child.nodeType !== 1 || child.namespaceURI !== NS_W) continue;
570
+ if (!RESTORATION_PPR_ALLOWLIST.has(child.localName)) continue;
571
+ pPr.appendChild(xmlDoc.importNode(child, true));
572
+ }
573
+ return pPr;
574
+ }
575
+
576
+ function collectRestorationAnchorWarnings(sourceParagraph) {
577
+ const warnings = [];
578
+ const bookmarkNames = new Set();
579
+ for (const node of Array.from(sourceParagraph?.getElementsByTagNameNS?.(NS_W, 'bookmarkStart') || [])) {
580
+ const name = wordAttribute(node, 'name') || '(unnamed)';
581
+ bookmarkNames.add(name);
582
+ }
583
+ for (const name of bookmarkNames) warnings.push(`RESTORATION_DROPPED_BOOKMARK:${name}`);
584
+
585
+ const commentIds = new Set();
586
+ for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
587
+ for (const node of Array.from(sourceParagraph?.getElementsByTagNameNS?.(NS_W, localName) || [])) {
588
+ const id = wordAttribute(node, 'id');
589
+ if (id !== '') commentIds.add(id);
590
+ }
591
+ }
592
+ for (const id of commentIds) warnings.push(`RESTORATION_DROPPED_COMMENT:${id}`);
593
+ return warnings;
594
+ }
595
+
596
+ function removeRestorationAnchors(paragraph) {
597
+ const anchorNames = [
598
+ 'bookmarkStart', 'bookmarkEnd',
599
+ 'commentRangeStart', 'commentRangeEnd', 'commentReference'
600
+ ];
601
+ for (const localName of anchorNames) {
602
+ for (const node of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, localName) || [])) {
603
+ node.parentNode?.removeChild(node);
604
+ }
605
+ }
606
+ for (const run of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'r') || [])) {
607
+ const meaningful = Array.from(run.childNodes || []).some(child => (
608
+ child.nodeType === 1 && child.namespaceURI === NS_W && child.localName !== 'rPr'
609
+ ));
610
+ if (!meaningful) run.parentNode?.removeChild(run);
611
+ }
612
+ }
613
+
614
+ function buildRejectedRestorationTemplate(xmlDoc, sourceParagraph, serializer) {
615
+ const rejected = rejectTrackedChangesInOoxml(serializer.serializeToString(sourceParagraph), { allAuthors: true });
616
+ const parsed = parseOoxmlSafe(rejected.oxml, 'application/xml');
617
+ const rejectedParagraph = parsed.doc?.getElementsByTagNameNS?.(NS_W, 'p')?.[0] || null;
618
+ if (!rejectedParagraph) return null;
619
+
620
+ const paragraph = createWordElement(xmlDoc, 'w:p');
621
+ paragraph.appendChild(createSanitizedRestorationPPr(xmlDoc, sourceParagraph));
622
+ for (const child of Array.from(rejectedParagraph.childNodes || [])) {
623
+ if (child.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'pPr') continue;
624
+ paragraph.appendChild(xmlDoc.importNode(child, true));
625
+ }
626
+ removeRestorationAnchors(paragraph);
627
+ return paragraph;
628
+ }
629
+
630
+ async function editRestorationTemplate(xmlDoc, template, modifiedText, author, serializer) {
631
+ const originalText = extractCanonicalParagraphText(template);
632
+ if (originalText === modifiedText) return template;
633
+ const result = await applyRedlineToOxml(
634
+ serializer.serializeToString(template),
635
+ originalText,
636
+ modifiedText,
637
+ {
638
+ author,
639
+ generateRedlines: false,
640
+ structuredContent: false
641
+ }
642
+ );
643
+ if (result?.status === 'error' || typeof result?.oxml !== 'string') return null;
644
+ const extracted = extractReplacementNodes(result.oxml);
645
+ const paragraph = (extracted.replacementNodes || []).find(node => (
646
+ node?.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
647
+ ));
648
+ return paragraph ? xmlDoc.importNode(paragraph, true) : null;
649
+ }
650
+
651
+ function allocateFreshParagraphId(xmlDoc, operationSession) {
652
+ const used = new Set(getDocumentParagraphNodes(xmlDoc)
653
+ .map(paragraph => paragraph.getAttributeNS?.(NS_W14, 'paraId') || paragraph.getAttribute?.('w14:paraId') || '')
654
+ .filter(Boolean)
655
+ .map(value => value.toUpperCase()));
656
+ let candidate = null;
657
+ do {
658
+ candidate = operationSession?.generateParagraphId?.()
659
+ || (0x40000000 + used.size + 1).toString(16).toUpperCase();
660
+ } while (used.has(candidate.toUpperCase()));
661
+ return candidate;
662
+ }
663
+
664
+ function trackRestoredParagraph(xmlDoc, paragraph, author, operationSession) {
665
+ const root = xmlDoc.documentElement;
666
+ if (root && !root.getAttribute('xmlns:w14')) root.setAttribute('xmlns:w14', NS_W14);
667
+ return wrapParagraphContentInInsertion(xmlDoc, paragraph, null, author, {
668
+ paragraphId: allocateFreshParagraphId(xmlDoc, operationSession),
669
+ sanitizeParagraphProperties: true,
670
+ trackParagraphMark: true
671
+ });
672
+ }
673
+
674
+ function isInsertedParagraphByAuthor(paragraph, author) {
675
+ const pPr = getDirectWordChild(paragraph, 'pPr');
676
+ const rPr = getDirectWordChild(pPr, 'rPr');
677
+ const marker = getDirectWordChild(rPr, 'ins');
678
+ return !!marker && normalizedAuthor(wordAttribute(marker, 'author')) === normalizedAuthor(author);
679
+ }
680
+
681
+ function precedingParagraphBlock(firstSource, count) {
682
+ const paragraphs = [];
683
+ let cursor = firstSource;
684
+ for (let i = 0; i < count; i++) {
685
+ cursor = directWordParagraphSibling(cursor, 'previousSibling');
686
+ if (!cursor) return [];
687
+ paragraphs.unshift(cursor);
688
+ }
689
+ return paragraphs;
690
+ }
691
+
692
+ function paragraphTextVector(oxml) {
693
+ const parsed = parseOoxmlSafe(oxml, 'application/xml');
694
+ if (parsed.error || !parsed.doc) return null;
695
+ return getDocumentParagraphNodes(parsed.doc).map(paragraph => extractCanonicalParagraphText(paragraph));
696
+ }
697
+
698
+ function sameTextVector(left, right) {
699
+ return Array.isArray(left) && Array.isArray(right)
700
+ && left.length === right.length
701
+ && left.every((value, index) => value === right[index]);
702
+ }
703
+
704
+ function lifecycleTextVector(oxml, action) {
705
+ const result = action === 'accept'
706
+ ? acceptTrackedChangesInOoxml(oxml, { allAuthors: true })
707
+ : rejectTrackedChangesInOoxml(oxml, { allAuthors: true });
708
+ return paragraphTextVector(result.oxml);
709
+ }
710
+
711
+ function buildExpectedRestorationDocument(beforeXml, sourceStartIndex, existingIndexes, templates) {
712
+ const parsed = parseOoxmlSafe(beforeXml, 'application/xml');
713
+ if (parsed.error || !parsed.doc) return null;
714
+ const expectedDoc = parsed.doc;
715
+ const originalParagraphs = getDocumentParagraphNodes(expectedDoc);
716
+ const source = originalParagraphs[sourceStartIndex] || null;
717
+ if (!source?.parentNode) return null;
718
+
719
+ for (const index of [...existingIndexes].sort((a, b) => b - a)) {
720
+ const paragraph = originalParagraphs[index];
721
+ paragraph?.parentNode?.removeChild(paragraph);
722
+ }
723
+ for (const template of templates) {
724
+ source.parentNode.insertBefore(expectedDoc.importNode(template, true), source);
725
+ }
726
+ return createSerializer().serializeToString(expectedDoc);
727
+ }
728
+
729
+ function verifyParagraphRestorationLifecycle(beforeXml, outputXml, sourceStartIndex, existingIndexes, templates) {
730
+ const validation = validateRedlineOoxml(outputXml);
731
+ if (!validation.valid) {
732
+ return {
733
+ valid: false,
734
+ stage: 'validation',
735
+ message: validation.issues.filter(issue => issue.severity === 'error').map(issue => issue.message).join(' ')
736
+ };
737
+ }
738
+
739
+ const expectedXml = buildExpectedRestorationDocument(beforeXml, sourceStartIndex, existingIndexes, templates);
740
+ if (!expectedXml) return { valid: false, stage: 'expected-document', message: 'Could not construct the restoration lifecycle oracle.' };
741
+
742
+ const comparisons = [
743
+ ['current', paragraphTextVector(expectedXml), paragraphTextVector(outputXml)],
744
+ ['accept-all', lifecycleTextVector(expectedXml, 'accept'), lifecycleTextVector(outputXml, 'accept')],
745
+ ['reject-all', lifecycleTextVector(beforeXml, 'reject'), lifecycleTextVector(outputXml, 'reject')]
746
+ ];
747
+ for (const [stage, expected, actual] of comparisons) {
748
+ if (!sameTextVector(expected, actual)) {
749
+ return {
750
+ valid: false,
751
+ stage,
752
+ expected,
753
+ actual,
754
+ message: `Paragraph restoration ${stage} lifecycle text did not match the expected body-scoped paragraph sequence.`
755
+ };
756
+ }
757
+ }
758
+ return { valid: true };
759
+ }
760
+
761
+ /**
762
+ * Materializes an explicit counterproposal for one or more paragraphs wholly
763
+ * deleted (content and paragraph mark) by another author.
764
+ */
765
+ export async function restoreDeletedParagraphByExactText(
766
+ documentXml,
767
+ targetText,
768
+ modified,
769
+ author,
770
+ targetRef = null,
771
+ targetEndRef = null,
772
+ runtimeContext = null,
773
+ options = {}
774
+ ) {
775
+ const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
776
+ if (!xmlDoc) {
777
+ return {
778
+ documentXml,
779
+ hasChanges: false,
780
+ status: 'error',
781
+ error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
782
+ };
783
+ }
784
+
785
+ const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'restore', runtimeContext, options);
786
+ if (resolved?.error || !resolved?.paragraph) {
787
+ return {
788
+ documentXml,
789
+ hasChanges: false,
790
+ status: 'error',
791
+ error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Restoration target paragraph not found.' }
792
+ };
793
+ }
794
+
795
+ const firstSource = resolved.paragraph;
796
+ let sourceParagraphs = [firstSource];
797
+ if (targetEndRef) {
798
+ sourceParagraphs = resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
799
+ opType: 'restore',
800
+ targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
801
+ onInfo: options.onInfo,
802
+ onWarn: options.onWarn
803
+ });
804
+ } else if (options.targetEndDescriptor) {
805
+ const endResolved = resolveTargetParagraph(
806
+ xmlDoc,
807
+ options.targetEndDescriptor.text,
808
+ options.targetEndDescriptor.index,
809
+ 'restore',
810
+ runtimeContext,
811
+ { ...options, targetDescriptor: options.targetEndDescriptor }
812
+ );
813
+ const allParagraphs = endResolved?.paragraph ? getDocumentParagraphNodes(xmlDoc) : [];
814
+ const startIndex = allParagraphs.indexOf(firstSource);
815
+ const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
816
+ sourceParagraphs = startIndex >= 0 && endIndex >= startIndex
817
+ ? allParagraphs.slice(startIndex, endIndex + 1)
818
+ : null;
819
+ }
820
+ if (!Array.isArray(sourceParagraphs) || sourceParagraphs.length === 0 || sourceParagraphs[0] !== firstSource) {
821
+ return {
822
+ documentXml,
823
+ hasChanges: false,
824
+ status: 'error',
825
+ error: {
826
+ code: 'RESTORATION_COUNT_MISMATCH',
827
+ message: 'The restoration range could not be resolved as a contiguous paragraph block.'
828
+ }
829
+ };
830
+ }
831
+
832
+ const replacements = Array.isArray(modified) ? modified.map(String) : [String(modified || '')];
833
+ if (replacements.length !== sourceParagraphs.length || replacements.some(text => text.length === 0)) {
834
+ return {
835
+ documentXml,
836
+ hasChanges: false,
837
+ status: 'error',
838
+ error: {
839
+ code: 'RESTORATION_COUNT_MISMATCH',
840
+ message: `Restoration requires exactly one non-empty replacement per source paragraph (${sourceParagraphs.length} expected, ${replacements.length} received).`
841
+ }
842
+ };
843
+ }
844
+
845
+ const parent = firstSource.parentNode;
846
+ if (!parent || sourceParagraphs.some(paragraph => paragraph.parentNode !== parent)) {
847
+ return {
848
+ documentXml,
849
+ hasChanges: false,
850
+ status: 'error',
851
+ error: {
852
+ code: 'UNSAFE_PARAGRAPH_PLACEMENT',
853
+ message: 'Restoration source paragraphs must be a contiguous block in one structural container.'
854
+ }
855
+ };
856
+ }
857
+
858
+ for (const paragraph of sourceParagraphs) {
859
+ const structuralRefusal = getParagraphRestorationRefusal(paragraph);
860
+ if (structuralRefusal?.code === 'UNSUPPORTED_MOVE_REVISION') {
861
+ return {
862
+ documentXml,
863
+ hasChanges: false,
864
+ status: 'error',
865
+ error: structuralRefusal
866
+ };
867
+ }
868
+ const state = inspectForeignDeletedParagraphTarget(paragraph, author);
869
+ if (!state.matches) {
870
+ return {
871
+ documentXml,
872
+ hasChanges: false,
873
+ status: 'error',
874
+ error: {
875
+ code: 'RESTORATION_STATE_REQUIRED',
876
+ message: state.hasParagraphMarkDeletion && !state.foreignParagraphMarkDeletion
877
+ ? 'Explicit cross-author restoration does not apply to a paragraph deleted by the current author; use merge-same-author.'
878
+ : 'Explicit restoration requires a foreign paragraph-mark deletion with every pre-existing content node deleted.',
879
+ ...(state.ownerAuthor ? { ownerAuthor: state.ownerAuthor } : {})
880
+ }
881
+ };
882
+ }
883
+ const refusal = structuralRefusal || getParagraphRestorationRefusal(paragraph);
884
+ if (refusal) {
885
+ return {
886
+ documentXml,
887
+ hasChanges: false,
888
+ status: 'error',
889
+ error: refusal
890
+ };
891
+ }
892
+ }
893
+
894
+ const existingBlock = precedingParagraphBlock(firstSource, sourceParagraphs.length);
895
+ const hasExistingRestoration = existingBlock.length === sourceParagraphs.length
896
+ && existingBlock.every(paragraph => isInsertedParagraphByAuthor(paragraph, author));
897
+ if (
898
+ hasExistingRestoration
899
+ && existingBlock.every((paragraph, index) => extractCanonicalParagraphText(paragraph) === replacements[index])
900
+ ) {
901
+ return {
902
+ documentXml,
903
+ hasChanges: false,
904
+ status: 'no-op',
905
+ warnings: ['An identical same-author paragraph restoration already exists.']
906
+ };
907
+ }
908
+
909
+ const beforeXml = serializer.serializeToString(xmlDoc);
910
+ const beforeParagraphs = getDocumentParagraphNodes(xmlDoc);
911
+ const sourceStartIndex = beforeParagraphs.indexOf(firstSource);
912
+ const existingIndexes = hasExistingRestoration
913
+ ? existingBlock.map(paragraph => beforeParagraphs.indexOf(paragraph))
914
+ : [];
915
+ const templates = [];
916
+ const warnings = [];
917
+ for (let index = 0; index < sourceParagraphs.length; index++) {
918
+ const source = sourceParagraphs[index];
919
+ warnings.push(...collectRestorationAnchorWarnings(source));
920
+ const template = buildRejectedRestorationTemplate(xmlDoc, source, serializer);
921
+ const edited = template
922
+ ? await editRestorationTemplate(xmlDoc, template, replacements[index], author, serializer)
923
+ : null;
924
+ if (!edited) {
925
+ return {
926
+ documentXml,
927
+ hasChanges: false,
928
+ status: 'error',
929
+ error: {
930
+ code: 'PATCH_ROUNDTRIP_MISMATCH',
931
+ message: `Could not reconstruct restoration paragraph ${index + 1} from its rejected revision view.`
932
+ }
933
+ };
934
+ }
935
+ templates.push(edited);
936
+ }
937
+
938
+ const insertedParagraphs = templates.map(template => {
939
+ const paragraph = xmlDoc.importNode(template, true);
940
+ return trackRestoredParagraph(xmlDoc, paragraph, author, operationSession);
941
+ });
942
+ for (const paragraph of insertedParagraphs) parent.insertBefore(paragraph, firstSource);
943
+ if (hasExistingRestoration) {
944
+ for (const paragraph of existingBlock) {
945
+ options?._mutationRemovedNodes?.push(paragraph);
946
+ paragraph.parentNode?.removeChild(paragraph);
947
+ }
948
+ }
949
+ options?._mutationLiveNodes?.push(...insertedParagraphs);
950
+ normalizeBodySectionOrder(xmlDoc);
951
+
952
+ const outputXml = serializer.serializeToString(xmlDoc);
953
+ const oracle = verifyParagraphRestorationLifecycle(
954
+ beforeXml,
955
+ outputXml,
956
+ sourceStartIndex,
957
+ existingIndexes,
958
+ templates
959
+ );
960
+ if (!oracle.valid) {
961
+ return {
962
+ documentXml,
963
+ hasChanges: false,
964
+ status: 'error',
965
+ error: {
966
+ code: 'PATCH_ROUNDTRIP_MISMATCH',
967
+ message: oracle.message,
968
+ stage: oracle.stage,
969
+ ...(oracle.expected ? { expected: oracle.expected } : {}),
970
+ ...(oracle.actual ? { actual: oracle.actual } : {})
971
+ }
972
+ };
973
+ }
974
+
975
+ return {
976
+ documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
977
+ hasChanges: true,
978
+ status: 'ok',
979
+ numberingXml: null,
980
+ ...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
981
+ };
982
+ }
520
983
 
521
984
  async function tryExplicitDecimalHeaderListConversion({
522
985
  xmlDoc,
@@ -819,8 +1282,25 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
819
1282
  error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' }
820
1283
  };
821
1284
  }
822
- const targetParagraph = resolved.paragraph;
823
- preprocessRedlineTargetParagraph(targetParagraph);
1285
+ const targetParagraph = resolved.paragraph;
1286
+ if (typeof modifiedText === 'string' && modifiedText.length > 0) {
1287
+ const resurrectionTarget = inspectForeignDeletedParagraphTarget(targetParagraph, author);
1288
+ if (resurrectionTarget.matches) {
1289
+ const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
1290
+ return {
1291
+ documentXml,
1292
+ hasChanges: false,
1293
+ numberingXml: null,
1294
+ status: 'error',
1295
+ error: {
1296
+ code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
1297
+ message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
1298
+ ownerAuthor
1299
+ }
1300
+ };
1301
+ }
1302
+ }
1303
+ preprocessRedlineTargetParagraph(targetParagraph);
824
1304
  const currentParagraphText = getParagraphText(targetParagraph);
825
1305
  if (modifiedText === '') {
826
1306
  const commentIds = getCommentIdsInElement(targetParagraph);