@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.
- package/AGENTS.md +24 -3
- package/CHANGELOG.md +13 -1
- package/README.md +16 -3
- package/core/paragraph-revision-safety.js +213 -0
- package/core/paragraph-targeting.js +19 -0
- package/core/redline-validation.js +13 -0
- package/dist/docx-redline-js.esm.js +333 -89
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +79 -79
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +282 -1
- package/docs/schemas/document-operations.schema.json +3 -0
- package/engine/oxml-engine.js +24 -3
- package/engine/surgical-diff-application.js +35 -1
- package/engine/surgical-mode.js +55 -1
- package/index.d.ts +12 -0
- package/package.json +1 -1
- package/services/document-operation-applier.js +24 -5
- package/services/document-operation-contract.js +37 -2
- package/services/document-operation-mutations.js +509 -29
- package/services/operation-preflight.js +72 -8
- package/services/standalone-operation-runner.d.ts +8 -0
|
@@ -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 =
|
|
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 (
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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);
|