@ansonlai/docx-redline-js 0.5.2 → 0.5.4
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 +67 -12
- package/ARCHITECTURE.md +38 -3
- package/CHANGELOG.md +19 -1
- package/README.md +52 -12
- package/core/paragraph-revision-safety.js +215 -0
- package/core/paragraph-targeting.js +19 -0
- package/core/redline-validation.js +20 -4
- package/core/revision-cloning.js +21 -0
- package/core/validation-delta.js +23 -0
- package/dist/docx-redline-js.esm.js +493 -99
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +84 -84
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +861 -37
- package/docs/schemas/document-operations.schema.json +14 -1
- package/engine/oxml-engine.js +24 -3
- package/engine/surgical-diff-application.js +35 -1
- package/engine/surgical-mode.js +203 -4
- package/engine/surgical-run-splitting.js +19 -7
- package/engine/surgical-spans.js +2 -1
- package/index.d.ts +12 -0
- package/node/cli.js +128 -13
- package/node/docx-document.js +17 -14
- package/package.json +1 -1
- package/pipeline/diff-engine.js +15 -0
- package/services/document-operation-applier.js +73 -9
- package/services/document-operation-contract.js +77 -2
- package/services/document-operation-mutations.js +846 -49
- package/services/operation-preflight.js +72 -8
- package/services/standalone-operation-runner.d.ts +26 -0
|
@@ -20,7 +20,20 @@ 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';
|
|
30
|
+
import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
|
|
31
|
+
import { clonePropertiesWithoutRevisionHistory } from '../core/revision-cloning.js';
|
|
32
|
+
import {
|
|
33
|
+
getRunContentPieces,
|
|
34
|
+
getRunTextLength,
|
|
35
|
+
splitTrackChangeCarrier
|
|
36
|
+
} from '../engine/surgical-run-splitting.js';
|
|
24
37
|
import { applyHighlightToOoxml } from '../engine/formatting-removal.js';
|
|
25
38
|
import { parseTable as parseMarkdownTable } from '../pipeline/pipeline.js';
|
|
26
39
|
import { injectCommentsIntoOoxml } from './comment-engine.js';
|
|
@@ -30,10 +43,11 @@ import {
|
|
|
30
43
|
createParagraphFingerprint,
|
|
31
44
|
isMarkdownTableText,
|
|
32
45
|
findContainingWordElement,
|
|
33
|
-
resolveTargetParagraphWithSnapshot as resolveTargetParagraphWithSnapshotShared,
|
|
34
|
-
resolveParagraphRangeByRefs,
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
resolveTargetParagraphWithSnapshot as resolveTargetParagraphWithSnapshotShared,
|
|
47
|
+
resolveParagraphRangeByRefs,
|
|
48
|
+
getDocumentParagraphNodes,
|
|
49
|
+
validateParagraphBoundaryMutation
|
|
50
|
+
} from '../core/paragraph-targeting.js';
|
|
37
51
|
import {
|
|
38
52
|
synthesizeExpandedListScopeEdit,
|
|
39
53
|
planListInsertionOnlyEdit,
|
|
@@ -70,9 +84,14 @@ import {
|
|
|
70
84
|
deriveSingleParagraphListAdjacencyInsertion,
|
|
71
85
|
deriveSingleParagraphPlainAdjacencyInsertion
|
|
72
86
|
} from './operation-heuristics.js';
|
|
73
|
-
import { resolveTargetFromCapture, ensureParagraphIdsOnImportedNode } from './capture-engine.js';
|
|
74
|
-
|
|
75
|
-
|
|
87
|
+
import { resolveTargetFromCapture, ensureParagraphIdsOnImportedNode } from './capture-engine.js';
|
|
88
|
+
import {
|
|
89
|
+
acceptTrackedChangesInOoxml,
|
|
90
|
+
rejectTrackedChangesInOoxml
|
|
91
|
+
} from './revision-comment-management.js';
|
|
92
|
+
|
|
93
|
+
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
94
|
+
const NS_W14 = 'http://schemas.microsoft.com/office/word/2010/wordml';
|
|
76
95
|
|
|
77
96
|
function getCommentIdsInElement(element) {
|
|
78
97
|
const ids = new Set();
|
|
@@ -150,9 +169,60 @@ async function reconcileMarkdownTableOoxml(oxml, originalText, markdownTable, op
|
|
|
150
169
|
};
|
|
151
170
|
}
|
|
152
171
|
|
|
153
|
-
function getParagraphText(paragraph) {
|
|
154
|
-
return getParagraphTextFromOxml(paragraph);
|
|
155
|
-
}
|
|
172
|
+
function getParagraphText(paragraph) {
|
|
173
|
+
return getParagraphTextFromOxml(paragraph);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function escapeInvisibleText(value, maxLength = 96) {
|
|
177
|
+
const text = String(value ?? '');
|
|
178
|
+
const bounded = text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
|
|
179
|
+
return bounded
|
|
180
|
+
.replace(/\\/g, '\\\\')
|
|
181
|
+
.replace(/\r/g, '\\r')
|
|
182
|
+
.replace(/\n/g, '\\n')
|
|
183
|
+
.replace(/\t/g, '\\t')
|
|
184
|
+
.replace(/\u00a0/g, '\\u00A0')
|
|
185
|
+
.replace(/\u202f/g, '\\u202F')
|
|
186
|
+
.replace(/\u2060/g, '\\u2060');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function codePointLabel(value, offset) {
|
|
190
|
+
if (offset >= value.length) return 'END';
|
|
191
|
+
return `U+${value.codePointAt(offset).toString(16).toUpperCase().padStart(4, '0')}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function describeTargetTextMatch(actualText, requestedText) {
|
|
195
|
+
const actual = String(actualText ?? '');
|
|
196
|
+
const requested = String(requestedText ?? '');
|
|
197
|
+
if (actual === requested) return { mode: 'exact' };
|
|
198
|
+
|
|
199
|
+
const differences = [];
|
|
200
|
+
const limit = Math.min(actual.length, requested.length);
|
|
201
|
+
for (let offset = 0; offset < limit && differences.length < 8; offset++) {
|
|
202
|
+
if (actual[offset] === requested[offset]) continue;
|
|
203
|
+
differences.push({
|
|
204
|
+
offset,
|
|
205
|
+
sourceCodePoint: codePointLabel(actual, offset),
|
|
206
|
+
requestedCodePoint: codePointLabel(requested, offset)
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
if (actual.length !== requested.length && differences.length < 8) {
|
|
210
|
+
differences.push({
|
|
211
|
+
offset: limit,
|
|
212
|
+
sourceCodePoint: codePointLabel(actual, limit),
|
|
213
|
+
requestedCodePoint: codePointLabel(requested, limit)
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const spaceEquivalent = actual.length === requested.length
|
|
218
|
+
&& actual.replace(/\u00a0/g, ' ') === requested.replace(/\u00a0/g, ' ');
|
|
219
|
+
return {
|
|
220
|
+
mode: spaceEquivalent ? 'space_equivalent' : 'normalized',
|
|
221
|
+
differences,
|
|
222
|
+
sourceExcerpt: escapeInvisibleText(actual),
|
|
223
|
+
requestedExcerpt: escapeInvisibleText(requested)
|
|
224
|
+
};
|
|
225
|
+
}
|
|
156
226
|
|
|
157
227
|
function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
|
|
158
228
|
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
|
|
@@ -166,16 +236,20 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
|
|
|
166
236
|
if (options?._resolutionCapture && resolved?.paragraph) {
|
|
167
237
|
const paragraph = resolved.paragraph;
|
|
168
238
|
const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
|
|
169
|
-
Object.assign(options._resolutionCapture, {
|
|
170
|
-
resolvedBy: resolved.resolvedBy,
|
|
171
|
-
resolvedTarget: {
|
|
239
|
+
Object.assign(options._resolutionCapture, {
|
|
240
|
+
resolvedBy: resolved.resolvedBy,
|
|
241
|
+
resolvedTarget: {
|
|
172
242
|
index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
|
|
173
243
|
paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
|
|
174
244
|
text: metadata?.text ?? getParagraphText(paragraph),
|
|
175
245
|
fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
|
|
176
|
-
inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl')
|
|
177
|
-
|
|
178
|
-
|
|
246
|
+
inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
|
|
247
|
+
targetTextMatch: describeTargetTextMatch(
|
|
248
|
+
metadata?.text ?? getParagraphText(paragraph),
|
|
249
|
+
options?.targetDescriptor?.exactText ?? targetText
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
});
|
|
179
253
|
}
|
|
180
254
|
return resolved;
|
|
181
255
|
} catch (error) {
|
|
@@ -204,14 +278,18 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
|
|
|
204
278
|
const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
|
|
205
279
|
Object.assign(options._resolutionCapture, {
|
|
206
280
|
resolvedBy: resolved.resolvedBy,
|
|
207
|
-
resolvedTarget: {
|
|
281
|
+
resolvedTarget: {
|
|
208
282
|
index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
|
|
209
283
|
paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
|
|
210
284
|
text: metadata?.text ?? getParagraphText(paragraph),
|
|
211
285
|
fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
|
|
212
|
-
inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl')
|
|
213
|
-
|
|
214
|
-
|
|
286
|
+
inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
|
|
287
|
+
targetTextMatch: describeTargetTextMatch(
|
|
288
|
+
metadata?.text ?? getParagraphText(paragraph),
|
|
289
|
+
options?.targetDescriptor?.exactText ?? targetText
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
});
|
|
215
293
|
}
|
|
216
294
|
return resolved;
|
|
217
295
|
}
|
|
@@ -248,7 +326,7 @@ function preprocessRedlineTargetParagraph(targetParagraph) {
|
|
|
248
326
|
removeProofErrNodes(targetParagraph);
|
|
249
327
|
}
|
|
250
328
|
|
|
251
|
-
function getDirectWordChild(element, localName) {
|
|
329
|
+
function getDirectWordChild(element, localName) {
|
|
252
330
|
if (!element) return null;
|
|
253
331
|
return Array.from(element.childNodes || []).find(
|
|
254
332
|
node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === localName
|
|
@@ -313,7 +391,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
|
|
|
313
391
|
|
|
314
392
|
const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
|
|
315
393
|
if (anchorPPr) {
|
|
316
|
-
paragraph.appendChild(anchorPPr
|
|
394
|
+
paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
|
|
317
395
|
}
|
|
318
396
|
ensureListProperties(xmlDoc, paragraph, entry.ilvl, entry.numId);
|
|
319
397
|
|
|
@@ -329,7 +407,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
|
|
|
329
407
|
const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
|
|
330
408
|
const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
|
|
331
409
|
if (anchorRunPr) {
|
|
332
|
-
run.appendChild(anchorRunPr
|
|
410
|
+
run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
|
|
333
411
|
}
|
|
334
412
|
|
|
335
413
|
const textNode = createWordElement(xmlDoc, 'w:t');
|
|
@@ -402,18 +480,21 @@ function applyExplicitRangeListInsertions({
|
|
|
402
480
|
normalizeBodySectionOrder(xmlDoc);
|
|
403
481
|
return true;
|
|
404
482
|
}
|
|
405
|
-
function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, author, options = {}) {
|
|
483
|
+
function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, author, options = {}) {
|
|
406
484
|
const generateRedlines = options.generateRedlines !== false;
|
|
407
485
|
const paragraph = createWordElement(xmlDoc, 'w:p');
|
|
408
486
|
const run = createWordElement(xmlDoc, 'w:r');
|
|
409
487
|
const textNode = createWordElement(xmlDoc, 'w:t');
|
|
410
488
|
const safeText = String(text || '');
|
|
411
489
|
if (/^\s|\s$/.test(safeText)) textNode.setAttribute('xml:space', 'preserve');
|
|
412
|
-
textNode.textContent = safeText;
|
|
413
|
-
run.appendChild(textNode);
|
|
414
|
-
|
|
415
|
-
if (generateRedlines) {
|
|
416
|
-
|
|
490
|
+
textNode.textContent = safeText;
|
|
491
|
+
run.appendChild(textNode);
|
|
492
|
+
|
|
493
|
+
if (generateRedlines) {
|
|
494
|
+
if (options.trackParagraphMark === true) {
|
|
495
|
+
markParagraphMarkInserted(xmlDoc, paragraph, author);
|
|
496
|
+
}
|
|
497
|
+
const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, 'ins');
|
|
417
498
|
const ins = createWordElement(xmlDoc, 'w:ins');
|
|
418
499
|
ins.setAttribute('w:id', String(metadata.id));
|
|
419
500
|
ins.setAttribute('w:author', metadata.author);
|
|
@@ -430,12 +511,12 @@ function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, aut
|
|
|
430
511
|
function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
|
|
431
512
|
const paragraph = createWordElement(xmlDoc, 'w:p');
|
|
432
513
|
const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
|
|
433
|
-
if (anchorPPr) paragraph.appendChild(anchorPPr
|
|
514
|
+
if (anchorPPr) paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
|
|
434
515
|
|
|
435
516
|
const run = createWordElement(xmlDoc, 'w:r');
|
|
436
517
|
const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
|
|
437
518
|
const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
|
|
438
|
-
if (anchorRunPr) run.appendChild(anchorRunPr
|
|
519
|
+
if (anchorRunPr) run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
|
|
439
520
|
|
|
440
521
|
const textNode = createWordElement(xmlDoc, 'w:t');
|
|
441
522
|
textNode.textContent = '';
|
|
@@ -444,13 +525,19 @@ function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
|
|
|
444
525
|
return paragraph;
|
|
445
526
|
}
|
|
446
527
|
|
|
447
|
-
function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, author) {
|
|
448
|
-
const wrappedParagraph = createWordElement(xmlDoc, 'w:p');
|
|
449
|
-
const pPr =
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
528
|
+
function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, author, options = {}) {
|
|
529
|
+
const wrappedParagraph = createWordElement(xmlDoc, 'w:p');
|
|
530
|
+
const pPr = options.sanitizeParagraphProperties === true
|
|
531
|
+
? createSanitizedRestorationPPr(xmlDoc, paragraph)
|
|
532
|
+
: clonePropertiesWithoutRevisionHistory(getDirectWordChild(paragraph, 'pPr'));
|
|
533
|
+
if (pPr) wrappedParagraph.appendChild(pPr);
|
|
534
|
+
if (options.paragraphId) wrappedParagraph.setAttributeNS(NS_W14, 'w14:paraId', options.paragraphId);
|
|
535
|
+
if (options.trackParagraphMark === true) {
|
|
536
|
+
markParagraphMarkInserted(xmlDoc, wrappedParagraph, author);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const ins = createWordElement(xmlDoc, 'w:ins');
|
|
540
|
+
const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, 'ins');
|
|
454
541
|
ins.setAttribute('w:id', String(metadata.id));
|
|
455
542
|
ins.setAttribute('w:author', metadata.author);
|
|
456
543
|
ins.setAttribute('w:date', metadata.date);
|
|
@@ -491,9 +578,9 @@ async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisi
|
|
|
491
578
|
return buildFallbackInsertedPlainParagraph(
|
|
492
579
|
xmlDoc,
|
|
493
580
|
text,
|
|
494
|
-
revisionMetadata,
|
|
495
|
-
author,
|
|
496
|
-
{ generateRedlines }
|
|
581
|
+
revisionMetadata,
|
|
582
|
+
author,
|
|
583
|
+
{ ...options, generateRedlines }
|
|
497
584
|
);
|
|
498
585
|
}
|
|
499
586
|
|
|
@@ -501,10 +588,10 @@ async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisi
|
|
|
501
588
|
return sourceParagraph;
|
|
502
589
|
}
|
|
503
590
|
|
|
504
|
-
return wrapParagraphContentInInsertion(xmlDoc, sourceParagraph, revisionMetadata, author);
|
|
591
|
+
return wrapParagraphContentInInsertion(xmlDoc, sourceParagraph, revisionMetadata, author, options);
|
|
505
592
|
}
|
|
506
593
|
|
|
507
|
-
function collectNumberingIdsFromNodes(nodes) {
|
|
594
|
+
function collectNumberingIdsFromNodes(nodes) {
|
|
508
595
|
const ids = new Set();
|
|
509
596
|
for (const node of nodes || []) {
|
|
510
597
|
const numIdNodes = Array.from(node?.getElementsByTagNameNS?.('*', 'numId') || []);
|
|
@@ -516,7 +603,700 @@ function collectNumberingIdsFromNodes(nodes) {
|
|
|
516
603
|
}
|
|
517
604
|
}
|
|
518
605
|
return Array.from(ids);
|
|
519
|
-
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function directDeletionCarrierText(carrier) {
|
|
609
|
+
return Array.from(carrier?.childNodes || []).reduce((text, child) => {
|
|
610
|
+
return text + (child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'r'
|
|
611
|
+
? getRunContentPieces(child).map(piece => piece.text).join('')
|
|
612
|
+
: '');
|
|
613
|
+
}, '');
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function findOccurrenceOffset(text, needle, occurrence) {
|
|
617
|
+
let from = 0;
|
|
618
|
+
let found = -1;
|
|
619
|
+
for (let index = 0; index < occurrence; index++) {
|
|
620
|
+
found = text.indexOf(needle, from);
|
|
621
|
+
if (found < 0) return -1;
|
|
622
|
+
from = found + Math.max(needle.length, 1);
|
|
623
|
+
}
|
|
624
|
+
return found;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function countNonOverlappingOccurrences(text, needle) {
|
|
628
|
+
if (!needle) return 0;
|
|
629
|
+
let count = 0;
|
|
630
|
+
let from = 0;
|
|
631
|
+
while (from <= text.length) {
|
|
632
|
+
const found = text.indexOf(needle, from);
|
|
633
|
+
if (found < 0) break;
|
|
634
|
+
count += 1;
|
|
635
|
+
from = found + needle.length;
|
|
636
|
+
}
|
|
637
|
+
return count;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function hasUnsupportedDeletionSplitMarkup(paragraph, carrier) {
|
|
641
|
+
const unsafeParagraphNames = new Set([
|
|
642
|
+
'commentRangeStart', 'commentRangeEnd', 'commentReference',
|
|
643
|
+
'bookmarkStart', 'bookmarkEnd', 'moveFromRangeStart', 'moveFromRangeEnd',
|
|
644
|
+
'moveToRangeStart', 'moveToRangeEnd'
|
|
645
|
+
]);
|
|
646
|
+
if (Array.from(paragraph.getElementsByTagName?.('*') || []).some(node => unsafeParagraphNames.has(node.localName))) {
|
|
647
|
+
return true;
|
|
648
|
+
}
|
|
649
|
+
for (const child of Array.from(carrier?.childNodes || [])) {
|
|
650
|
+
if (child.nodeType !== 1) continue;
|
|
651
|
+
if (!(child.namespaceURI === NS_W && child.localName === 'r')) return true;
|
|
652
|
+
for (const runChild of Array.from(child.childNodes || [])) {
|
|
653
|
+
if (runChild.nodeType !== 1 || runChild.namespaceURI !== NS_W) continue;
|
|
654
|
+
if (!['rPr', 'delText', 't', 'tab', 'br', 'cr', 'noBreakHyphen', 'softHyphen'].includes(runChild.localName)) return true;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function insertedRunPropertiesAtDeletionOffset(carrier, localOffset) {
|
|
661
|
+
let offset = 0;
|
|
662
|
+
for (const child of Array.from(carrier?.childNodes || [])) {
|
|
663
|
+
if (!(child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'r')) continue;
|
|
664
|
+
const length = getRunTextLength(getRunContentPieces(child));
|
|
665
|
+
if (localOffset <= offset + length) {
|
|
666
|
+
return clonePropertiesWithoutRevisionHistory(getDirectWordChild(child, 'rPr'));
|
|
667
|
+
}
|
|
668
|
+
offset += length;
|
|
669
|
+
}
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const RESTORATION_PPR_ALLOWLIST = new Set([
|
|
674
|
+
'pStyle', 'numPr', 'ind', 'jc', 'spacing', 'tabs',
|
|
675
|
+
'keepNext', 'keepLines', 'outlineLvl', 'contextualSpacing'
|
|
676
|
+
]);
|
|
677
|
+
|
|
678
|
+
function wordAttribute(node, localName) {
|
|
679
|
+
return node?.getAttribute?.(`w:${localName}`) || node?.getAttribute?.(localName) || '';
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function normalizedAuthor(author) {
|
|
683
|
+
return String(author || '').trim().toLowerCase();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function directWordParagraphSibling(paragraph, direction = 'nextSibling') {
|
|
687
|
+
let cursor = paragraph?.[direction] || null;
|
|
688
|
+
while (cursor) {
|
|
689
|
+
if (cursor.nodeType === 1 && cursor.namespaceURI === NS_W && cursor.localName === 'p') return cursor;
|
|
690
|
+
cursor = cursor[direction] || null;
|
|
691
|
+
}
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function createSanitizedRestorationPPr(xmlDoc, sourceParagraph) {
|
|
696
|
+
const sourcePPr = getDirectWordChild(sourceParagraph, 'pPr');
|
|
697
|
+
const pPr = createWordElement(xmlDoc, 'w:pPr');
|
|
698
|
+
if (!sourcePPr) return pPr;
|
|
699
|
+
for (const child of Array.from(sourcePPr.childNodes || [])) {
|
|
700
|
+
if (child.nodeType !== 1 || child.namespaceURI !== NS_W) continue;
|
|
701
|
+
if (!RESTORATION_PPR_ALLOWLIST.has(child.localName)) continue;
|
|
702
|
+
pPr.appendChild(xmlDoc.importNode(child, true));
|
|
703
|
+
}
|
|
704
|
+
return pPr;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function collectRestorationAnchorWarnings(sourceParagraph) {
|
|
708
|
+
const warnings = [];
|
|
709
|
+
const bookmarkNames = new Set();
|
|
710
|
+
for (const node of Array.from(sourceParagraph?.getElementsByTagNameNS?.(NS_W, 'bookmarkStart') || [])) {
|
|
711
|
+
const name = wordAttribute(node, 'name') || '(unnamed)';
|
|
712
|
+
bookmarkNames.add(name);
|
|
713
|
+
}
|
|
714
|
+
for (const name of bookmarkNames) warnings.push(`RESTORATION_DROPPED_BOOKMARK:${name}`);
|
|
715
|
+
|
|
716
|
+
const commentIds = new Set();
|
|
717
|
+
for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
|
|
718
|
+
for (const node of Array.from(sourceParagraph?.getElementsByTagNameNS?.(NS_W, localName) || [])) {
|
|
719
|
+
const id = wordAttribute(node, 'id');
|
|
720
|
+
if (id !== '') commentIds.add(id);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
for (const id of commentIds) warnings.push(`RESTORATION_DROPPED_COMMENT:${id}`);
|
|
724
|
+
return warnings;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function removeRestorationAnchors(paragraph) {
|
|
728
|
+
const anchorNames = [
|
|
729
|
+
'bookmarkStart', 'bookmarkEnd',
|
|
730
|
+
'commentRangeStart', 'commentRangeEnd', 'commentReference'
|
|
731
|
+
];
|
|
732
|
+
for (const localName of anchorNames) {
|
|
733
|
+
for (const node of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, localName) || [])) {
|
|
734
|
+
node.parentNode?.removeChild(node);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
for (const run of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'r') || [])) {
|
|
738
|
+
const meaningful = Array.from(run.childNodes || []).some(child => (
|
|
739
|
+
child.nodeType === 1 && child.namespaceURI === NS_W && child.localName !== 'rPr'
|
|
740
|
+
));
|
|
741
|
+
if (!meaningful) run.parentNode?.removeChild(run);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function buildRejectedRestorationTemplate(xmlDoc, sourceParagraph, serializer) {
|
|
746
|
+
const rejected = rejectTrackedChangesInOoxml(serializer.serializeToString(sourceParagraph), { allAuthors: true });
|
|
747
|
+
const parsed = parseOoxmlSafe(rejected.oxml, 'application/xml');
|
|
748
|
+
const rejectedParagraph = parsed.doc?.getElementsByTagNameNS?.(NS_W, 'p')?.[0] || null;
|
|
749
|
+
if (!rejectedParagraph) return null;
|
|
750
|
+
|
|
751
|
+
const paragraph = createWordElement(xmlDoc, 'w:p');
|
|
752
|
+
paragraph.appendChild(createSanitizedRestorationPPr(xmlDoc, sourceParagraph));
|
|
753
|
+
for (const child of Array.from(rejectedParagraph.childNodes || [])) {
|
|
754
|
+
if (child.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'pPr') continue;
|
|
755
|
+
paragraph.appendChild(xmlDoc.importNode(child, true));
|
|
756
|
+
}
|
|
757
|
+
removeRestorationAnchors(paragraph);
|
|
758
|
+
return paragraph;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function editRestorationTemplate(xmlDoc, template, modifiedText, author, serializer) {
|
|
762
|
+
const originalText = extractCanonicalParagraphText(template);
|
|
763
|
+
if (originalText === modifiedText) return template;
|
|
764
|
+
const result = await applyRedlineToOxml(
|
|
765
|
+
serializer.serializeToString(template),
|
|
766
|
+
originalText,
|
|
767
|
+
modifiedText,
|
|
768
|
+
{
|
|
769
|
+
author,
|
|
770
|
+
generateRedlines: false,
|
|
771
|
+
structuredContent: false
|
|
772
|
+
}
|
|
773
|
+
);
|
|
774
|
+
if (result?.status === 'error' || typeof result?.oxml !== 'string') return null;
|
|
775
|
+
const extracted = extractReplacementNodes(result.oxml);
|
|
776
|
+
const paragraph = (extracted.replacementNodes || []).find(node => (
|
|
777
|
+
node?.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
|
|
778
|
+
));
|
|
779
|
+
return paragraph ? xmlDoc.importNode(paragraph, true) : null;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function allocateFreshParagraphId(xmlDoc, operationSession) {
|
|
783
|
+
const used = new Set(getDocumentParagraphNodes(xmlDoc)
|
|
784
|
+
.map(paragraph => paragraph.getAttributeNS?.(NS_W14, 'paraId') || paragraph.getAttribute?.('w14:paraId') || '')
|
|
785
|
+
.filter(Boolean)
|
|
786
|
+
.map(value => value.toUpperCase()));
|
|
787
|
+
let candidate = null;
|
|
788
|
+
do {
|
|
789
|
+
candidate = operationSession?.generateParagraphId?.()
|
|
790
|
+
|| (0x40000000 + used.size + 1).toString(16).toUpperCase();
|
|
791
|
+
} while (used.has(candidate.toUpperCase()));
|
|
792
|
+
return candidate;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function trackRestoredParagraph(xmlDoc, paragraph, author, operationSession) {
|
|
796
|
+
const root = xmlDoc.documentElement;
|
|
797
|
+
if (root && !root.getAttribute('xmlns:w14')) root.setAttribute('xmlns:w14', NS_W14);
|
|
798
|
+
return wrapParagraphContentInInsertion(xmlDoc, paragraph, null, author, {
|
|
799
|
+
paragraphId: allocateFreshParagraphId(xmlDoc, operationSession),
|
|
800
|
+
sanitizeParagraphProperties: true,
|
|
801
|
+
trackParagraphMark: true
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function isInsertedParagraphByAuthor(paragraph, author) {
|
|
806
|
+
const pPr = getDirectWordChild(paragraph, 'pPr');
|
|
807
|
+
const rPr = getDirectWordChild(pPr, 'rPr');
|
|
808
|
+
const marker = getDirectWordChild(rPr, 'ins');
|
|
809
|
+
return !!marker && normalizedAuthor(wordAttribute(marker, 'author')) === normalizedAuthor(author);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function followingParagraphBlock(lastSource, count) {
|
|
813
|
+
const paragraphs = [];
|
|
814
|
+
let cursor = lastSource;
|
|
815
|
+
for (let i = 0; i < count; i++) {
|
|
816
|
+
cursor = directWordParagraphSibling(cursor, 'nextSibling');
|
|
817
|
+
if (!cursor) return [];
|
|
818
|
+
paragraphs.push(cursor);
|
|
819
|
+
}
|
|
820
|
+
return paragraphs;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function paragraphTextVector(oxml) {
|
|
824
|
+
const parsed = parseOoxmlSafe(oxml, 'application/xml');
|
|
825
|
+
if (parsed.error || !parsed.doc) return null;
|
|
826
|
+
return getDocumentParagraphNodes(parsed.doc).map(paragraph => extractCanonicalParagraphText(paragraph));
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function sameTextVector(left, right) {
|
|
830
|
+
return Array.isArray(left) && Array.isArray(right)
|
|
831
|
+
&& left.length === right.length
|
|
832
|
+
&& left.every((value, index) => value === right[index]);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function lifecycleTextVector(oxml, action) {
|
|
836
|
+
const result = action === 'accept'
|
|
837
|
+
? acceptTrackedChangesInOoxml(oxml, { allAuthors: true })
|
|
838
|
+
: rejectTrackedChangesInOoxml(oxml, { allAuthors: true });
|
|
839
|
+
return paragraphTextVector(result.oxml);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function buildExpectedRestorationDocument(beforeXml, sourceStartIndex, existingIndexes, templates) {
|
|
843
|
+
const parsed = parseOoxmlSafe(beforeXml, 'application/xml');
|
|
844
|
+
if (parsed.error || !parsed.doc) return null;
|
|
845
|
+
const expectedDoc = parsed.doc;
|
|
846
|
+
const originalParagraphs = getDocumentParagraphNodes(expectedDoc);
|
|
847
|
+
const lastSource = originalParagraphs[sourceStartIndex + templates.length - 1] || null;
|
|
848
|
+
if (!lastSource?.parentNode) return null;
|
|
849
|
+
|
|
850
|
+
for (const index of [...existingIndexes].sort((a, b) => b - a)) {
|
|
851
|
+
const paragraph = originalParagraphs[index];
|
|
852
|
+
paragraph?.parentNode?.removeChild(paragraph);
|
|
853
|
+
}
|
|
854
|
+
const insertionPoint = lastSource.nextSibling;
|
|
855
|
+
for (const template of templates) {
|
|
856
|
+
lastSource.parentNode.insertBefore(expectedDoc.importNode(template, true), insertionPoint);
|
|
857
|
+
}
|
|
858
|
+
return createSerializer().serializeToString(expectedDoc);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function verifyParagraphRestorationLifecycle(beforeXml, outputXml, sourceStartIndex, existingIndexes, templates, insertedParagraphs = []) {
|
|
862
|
+
const baselineValidation = validateRedlineOoxml(beforeXml);
|
|
863
|
+
const outputValidation = validateRedlineOoxml(outputXml);
|
|
864
|
+
const generatedIssues = subtractValidationIssueMultiset(outputValidation.issues, baselineValidation.issues);
|
|
865
|
+
const envelopeIssues = insertedParagraphs.flatMap(paragraph => (
|
|
866
|
+
validateRedlineOoxml(createSerializer().serializeToString(paragraph)).issues
|
|
867
|
+
.filter(issue => issue.severity === 'error')
|
|
868
|
+
));
|
|
869
|
+
const generatedErrors = validationErrors(generatedIssues);
|
|
870
|
+
if (generatedErrors.length > 0 || envelopeIssues.length > 0) {
|
|
871
|
+
return {
|
|
872
|
+
valid: false,
|
|
873
|
+
stage: 'validation',
|
|
874
|
+
code: 'GENERATED_OOXML_INVALID',
|
|
875
|
+
generatedIssues,
|
|
876
|
+
envelopeIssues,
|
|
877
|
+
message: [...generatedErrors, ...envelopeIssues].map(issue => issue.message).join(' ')
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const expectedXml = buildExpectedRestorationDocument(beforeXml, sourceStartIndex, existingIndexes, templates);
|
|
882
|
+
if (!expectedXml) return { valid: false, stage: 'expected-document', message: 'Could not construct the restoration lifecycle oracle.' };
|
|
883
|
+
|
|
884
|
+
const comparisons = [
|
|
885
|
+
['current', paragraphTextVector(expectedXml), paragraphTextVector(outputXml)],
|
|
886
|
+
['accept-all', lifecycleTextVector(expectedXml, 'accept'), lifecycleTextVector(outputXml, 'accept')],
|
|
887
|
+
['reject-all', lifecycleTextVector(beforeXml, 'reject'), lifecycleTextVector(outputXml, 'reject')]
|
|
888
|
+
];
|
|
889
|
+
for (const [stage, expected, actual] of comparisons) {
|
|
890
|
+
if (!sameTextVector(expected, actual)) {
|
|
891
|
+
return {
|
|
892
|
+
valid: false,
|
|
893
|
+
stage,
|
|
894
|
+
expected,
|
|
895
|
+
actual,
|
|
896
|
+
message: `Paragraph restoration ${stage} lifecycle text did not match the expected body-scoped paragraph sequence.`
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return { valid: true };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Inserts a new tracked run at an exact offset inside text visible only in the
|
|
905
|
+
* rejected view of a wholly deleted paragraph. The foreign deletion is split,
|
|
906
|
+
* never rewritten or re-authored.
|
|
907
|
+
*/
|
|
908
|
+
export async function insertIntoRejectedDeletedText(
|
|
909
|
+
documentXml,
|
|
910
|
+
targetText,
|
|
911
|
+
anchor,
|
|
912
|
+
modified,
|
|
913
|
+
author,
|
|
914
|
+
targetRef = null,
|
|
915
|
+
runtimeContext = null,
|
|
916
|
+
options = {}
|
|
917
|
+
) {
|
|
918
|
+
const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
|
|
919
|
+
if (!xmlDoc) {
|
|
920
|
+
return {
|
|
921
|
+
documentXml,
|
|
922
|
+
hasChanges: false,
|
|
923
|
+
status: 'error',
|
|
924
|
+
error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'insert', runtimeContext, options);
|
|
929
|
+
if (resolved?.error || !resolved?.paragraph) {
|
|
930
|
+
return {
|
|
931
|
+
documentXml,
|
|
932
|
+
hasChanges: false,
|
|
933
|
+
status: 'error',
|
|
934
|
+
error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Rejected-view insertion target was not found.' }
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const paragraph = resolved.paragraph;
|
|
939
|
+
const state = inspectForeignDeletedParagraphTarget(paragraph, author);
|
|
940
|
+
if (!state.matches) {
|
|
941
|
+
return {
|
|
942
|
+
documentXml,
|
|
943
|
+
hasChanges: false,
|
|
944
|
+
status: 'error',
|
|
945
|
+
error: {
|
|
946
|
+
code: 'REJECTED_INSERTION_STATE_REQUIRED',
|
|
947
|
+
message: 'Rejected-view insertion requires a wholly deleted paragraph owned by another author.'
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
const structuralRefusal = getParagraphRestorationRefusal(paragraph, { requireFollowingParagraph: false });
|
|
952
|
+
if (structuralRefusal) {
|
|
953
|
+
return { documentXml, hasChanges: false, status: 'error', error: structuralRefusal };
|
|
954
|
+
}
|
|
955
|
+
if (/\r|\n/.test(modified)) {
|
|
956
|
+
return {
|
|
957
|
+
documentXml,
|
|
958
|
+
hasChanges: false,
|
|
959
|
+
status: 'error',
|
|
960
|
+
error: {
|
|
961
|
+
code: 'UNSAFE_PARAGRAPH_BOUNDARY',
|
|
962
|
+
message: 'Rejected-view insertion supports run-level text only; use restore for paragraph boundaries.'
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const rejectedText = extractCanonicalParagraphText(paragraph, { revisionView: 'rejected' });
|
|
968
|
+
if (!anchor.occurrenceExplicit && countNonOverlappingOccurrences(rejectedText, anchor.exactText) > 1) {
|
|
969
|
+
return {
|
|
970
|
+
documentXml,
|
|
971
|
+
hasChanges: false,
|
|
972
|
+
status: 'error',
|
|
973
|
+
error: {
|
|
974
|
+
code: 'AMBIGUOUS_ANCHOR',
|
|
975
|
+
message: 'The rejected-view insertion anchor is repeated; provide anchor.occurrence explicitly.'
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
const anchorStart = findOccurrenceOffset(rejectedText, anchor.exactText, anchor.occurrence);
|
|
980
|
+
if (anchorStart < 0) {
|
|
981
|
+
return {
|
|
982
|
+
documentXml,
|
|
983
|
+
hasChanges: false,
|
|
984
|
+
status: 'error',
|
|
985
|
+
error: { code: 'ANCHOR_NOT_FOUND', message: 'The rejected-view insertion anchor was not found at the requested occurrence.' }
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
const insertionOffset = anchorStart + anchor.offset;
|
|
989
|
+
const carriers = Array.from(paragraph.childNodes || []).filter(
|
|
990
|
+
node => node?.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'del'
|
|
991
|
+
);
|
|
992
|
+
if (carriers.some(carrier => hasUnsupportedDeletionSplitMarkup(paragraph, carrier))) {
|
|
993
|
+
return {
|
|
994
|
+
documentXml,
|
|
995
|
+
hasChanges: false,
|
|
996
|
+
status: 'error',
|
|
997
|
+
error: {
|
|
998
|
+
code: 'UNSAFE_REVISION_BOUNDARY',
|
|
999
|
+
message: 'The rejected-view insertion boundary contains comments, bookmarks, fields, hyperlinks, moves, or non-text run markup that cannot be split safely.'
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
let carrierStart = 0;
|
|
1004
|
+
let targetCarrier = null;
|
|
1005
|
+
let carrierOffset = 0;
|
|
1006
|
+
for (const carrier of carriers) {
|
|
1007
|
+
const length = directDeletionCarrierText(carrier).length;
|
|
1008
|
+
if (insertionOffset >= carrierStart && insertionOffset <= carrierStart + length) {
|
|
1009
|
+
targetCarrier = carrier;
|
|
1010
|
+
carrierOffset = insertionOffset - carrierStart;
|
|
1011
|
+
break;
|
|
1012
|
+
}
|
|
1013
|
+
carrierStart += length;
|
|
1014
|
+
}
|
|
1015
|
+
if (!targetCarrier || carriers.map(directDeletionCarrierText).join('') !== rejectedText) {
|
|
1016
|
+
return {
|
|
1017
|
+
documentXml,
|
|
1018
|
+
hasChanges: false,
|
|
1019
|
+
status: 'error',
|
|
1020
|
+
error: {
|
|
1021
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
1022
|
+
message: 'The rejected-view anchor is not contained in a supported direct deletion carrier.'
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const runProperties = insertedRunPropertiesAtDeletionOffset(targetCarrier, carrierOffset);
|
|
1028
|
+
const { leftCarrier, rightCarrier } = splitTrackChangeCarrier(
|
|
1029
|
+
xmlDoc,
|
|
1030
|
+
targetCarrier,
|
|
1031
|
+
carrierOffset,
|
|
1032
|
+
options._revisionIdAllocator || null
|
|
1033
|
+
);
|
|
1034
|
+
const insertion = createWordElement(xmlDoc, 'w:ins');
|
|
1035
|
+
const metadata = createRevisionMetadata(author, options._revisionIdAllocator || xmlDoc, 'ins');
|
|
1036
|
+
insertion.setAttribute('w:id', String(metadata.id));
|
|
1037
|
+
insertion.setAttribute('w:author', metadata.author);
|
|
1038
|
+
insertion.setAttribute('w:date', metadata.date);
|
|
1039
|
+
const run = createWordElement(xmlDoc, 'w:r');
|
|
1040
|
+
if (runProperties) run.appendChild(runProperties);
|
|
1041
|
+
const textNode = createWordElement(xmlDoc, 'w:t');
|
|
1042
|
+
if (/^\s|\s$/.test(modified)) textNode.setAttribute('xml:space', 'preserve');
|
|
1043
|
+
textNode.textContent = modified;
|
|
1044
|
+
run.appendChild(textNode);
|
|
1045
|
+
insertion.appendChild(run);
|
|
1046
|
+
|
|
1047
|
+
const parent = targetCarrier.parentNode;
|
|
1048
|
+
if (leftCarrier) parent.insertBefore(leftCarrier, targetCarrier);
|
|
1049
|
+
parent.insertBefore(insertion, targetCarrier);
|
|
1050
|
+
if (rightCarrier) parent.insertBefore(rightCarrier, targetCarrier);
|
|
1051
|
+
options?._mutationRemovedNodes?.push(targetCarrier);
|
|
1052
|
+
parent.removeChild(targetCarrier);
|
|
1053
|
+
options?._mutationLiveNodes?.push(paragraph, insertion);
|
|
1054
|
+
operationSession?.invalidateParagraphMetadata?.();
|
|
1055
|
+
|
|
1056
|
+
const outputXml = serializer.serializeToString(xmlDoc);
|
|
1057
|
+
const outputRejected = extractCanonicalParagraphText(paragraph, { revisionView: 'rejected' });
|
|
1058
|
+
const outputAccepted = extractCanonicalParagraphText(paragraph, { revisionView: 'accepted' });
|
|
1059
|
+
if (outputRejected !== rejectedText || !outputAccepted.includes(modified)) {
|
|
1060
|
+
return {
|
|
1061
|
+
documentXml,
|
|
1062
|
+
hasChanges: false,
|
|
1063
|
+
status: 'error',
|
|
1064
|
+
error: {
|
|
1065
|
+
code: 'PATCH_ROUNDTRIP_MISMATCH',
|
|
1066
|
+
message: 'Rejected-view insertion did not preserve the deleted source and expose the requested inserted text.'
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
return { documentXml: outputXml, hasChanges: true, status: 'ok' };
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Materializes an explicit counterproposal for one or more paragraphs wholly
|
|
1075
|
+
* deleted (content and paragraph mark) by another author.
|
|
1076
|
+
*/
|
|
1077
|
+
export async function restoreDeletedParagraphByExactText(
|
|
1078
|
+
documentXml,
|
|
1079
|
+
targetText,
|
|
1080
|
+
modified,
|
|
1081
|
+
author,
|
|
1082
|
+
targetRef = null,
|
|
1083
|
+
targetEndRef = null,
|
|
1084
|
+
runtimeContext = null,
|
|
1085
|
+
options = {}
|
|
1086
|
+
) {
|
|
1087
|
+
const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
|
|
1088
|
+
if (!xmlDoc) {
|
|
1089
|
+
return {
|
|
1090
|
+
documentXml,
|
|
1091
|
+
hasChanges: false,
|
|
1092
|
+
status: 'error',
|
|
1093
|
+
error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'restore', runtimeContext, options);
|
|
1098
|
+
if (resolved?.error || !resolved?.paragraph) {
|
|
1099
|
+
return {
|
|
1100
|
+
documentXml,
|
|
1101
|
+
hasChanges: false,
|
|
1102
|
+
status: 'error',
|
|
1103
|
+
error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Restoration target paragraph not found.' }
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const firstSource = resolved.paragraph;
|
|
1108
|
+
let sourceParagraphs = [firstSource];
|
|
1109
|
+
if (targetEndRef) {
|
|
1110
|
+
sourceParagraphs = resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
|
|
1111
|
+
opType: 'restore',
|
|
1112
|
+
targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
|
|
1113
|
+
onInfo: options.onInfo,
|
|
1114
|
+
onWarn: options.onWarn
|
|
1115
|
+
});
|
|
1116
|
+
} else if (options.targetEndDescriptor) {
|
|
1117
|
+
const endResolved = resolveTargetParagraph(
|
|
1118
|
+
xmlDoc,
|
|
1119
|
+
options.targetEndDescriptor.text,
|
|
1120
|
+
options.targetEndDescriptor.index,
|
|
1121
|
+
'restore',
|
|
1122
|
+
runtimeContext,
|
|
1123
|
+
{ ...options, targetDescriptor: options.targetEndDescriptor }
|
|
1124
|
+
);
|
|
1125
|
+
const allParagraphs = endResolved?.paragraph ? getDocumentParagraphNodes(xmlDoc) : [];
|
|
1126
|
+
const startIndex = allParagraphs.indexOf(firstSource);
|
|
1127
|
+
const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
|
|
1128
|
+
sourceParagraphs = startIndex >= 0 && endIndex >= startIndex
|
|
1129
|
+
? allParagraphs.slice(startIndex, endIndex + 1)
|
|
1130
|
+
: null;
|
|
1131
|
+
}
|
|
1132
|
+
if (!Array.isArray(sourceParagraphs) || sourceParagraphs.length === 0 || sourceParagraphs[0] !== firstSource) {
|
|
1133
|
+
return {
|
|
1134
|
+
documentXml,
|
|
1135
|
+
hasChanges: false,
|
|
1136
|
+
status: 'error',
|
|
1137
|
+
error: {
|
|
1138
|
+
code: 'RESTORATION_COUNT_MISMATCH',
|
|
1139
|
+
message: 'The restoration range could not be resolved as a contiguous paragraph block.'
|
|
1140
|
+
}
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const replacements = Array.isArray(modified) ? modified.map(String) : [String(modified || '')];
|
|
1145
|
+
if (replacements.length !== sourceParagraphs.length || replacements.some(text => text.length === 0)) {
|
|
1146
|
+
return {
|
|
1147
|
+
documentXml,
|
|
1148
|
+
hasChanges: false,
|
|
1149
|
+
status: 'error',
|
|
1150
|
+
error: {
|
|
1151
|
+
code: 'RESTORATION_COUNT_MISMATCH',
|
|
1152
|
+
message: `Restoration requires exactly one non-empty replacement per source paragraph (${sourceParagraphs.length} expected, ${replacements.length} received).`
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
const parent = firstSource.parentNode;
|
|
1158
|
+
if (!parent || sourceParagraphs.some(paragraph => paragraph.parentNode !== parent)) {
|
|
1159
|
+
return {
|
|
1160
|
+
documentXml,
|
|
1161
|
+
hasChanges: false,
|
|
1162
|
+
status: 'error',
|
|
1163
|
+
error: {
|
|
1164
|
+
code: 'UNSAFE_PARAGRAPH_PLACEMENT',
|
|
1165
|
+
message: 'Restoration source paragraphs must be a contiguous block in one structural container.'
|
|
1166
|
+
}
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
for (const paragraph of sourceParagraphs) {
|
|
1171
|
+
const structuralRefusal = getParagraphRestorationRefusal(paragraph);
|
|
1172
|
+
if (structuralRefusal?.code === 'UNSUPPORTED_MOVE_REVISION') {
|
|
1173
|
+
return {
|
|
1174
|
+
documentXml,
|
|
1175
|
+
hasChanges: false,
|
|
1176
|
+
status: 'error',
|
|
1177
|
+
error: structuralRefusal
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
const state = inspectForeignDeletedParagraphTarget(paragraph, author);
|
|
1181
|
+
if (!state.matches) {
|
|
1182
|
+
return {
|
|
1183
|
+
documentXml,
|
|
1184
|
+
hasChanges: false,
|
|
1185
|
+
status: 'error',
|
|
1186
|
+
error: {
|
|
1187
|
+
code: 'RESTORATION_STATE_REQUIRED',
|
|
1188
|
+
message: state.hasParagraphMarkDeletion && !state.foreignParagraphMarkDeletion
|
|
1189
|
+
? 'Explicit cross-author restoration does not apply to a paragraph deleted by the current author; use merge-same-author.'
|
|
1190
|
+
: 'Explicit restoration requires a foreign paragraph-mark deletion with every pre-existing content node deleted.',
|
|
1191
|
+
...(state.ownerAuthor ? { ownerAuthor: state.ownerAuthor } : {})
|
|
1192
|
+
}
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
const refusal = structuralRefusal || getParagraphRestorationRefusal(paragraph);
|
|
1196
|
+
if (refusal) {
|
|
1197
|
+
return {
|
|
1198
|
+
documentXml,
|
|
1199
|
+
hasChanges: false,
|
|
1200
|
+
status: 'error',
|
|
1201
|
+
error: refusal
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const lastSource = sourceParagraphs[sourceParagraphs.length - 1];
|
|
1207
|
+
const existingBlock = followingParagraphBlock(lastSource, sourceParagraphs.length);
|
|
1208
|
+
const hasExistingRestoration = existingBlock.length === sourceParagraphs.length
|
|
1209
|
+
&& existingBlock.every(paragraph => isInsertedParagraphByAuthor(paragraph, author));
|
|
1210
|
+
if (
|
|
1211
|
+
hasExistingRestoration
|
|
1212
|
+
&& existingBlock.every((paragraph, index) => extractCanonicalParagraphText(paragraph) === replacements[index])
|
|
1213
|
+
) {
|
|
1214
|
+
return {
|
|
1215
|
+
documentXml,
|
|
1216
|
+
hasChanges: false,
|
|
1217
|
+
status: 'no-op',
|
|
1218
|
+
warnings: ['An identical same-author paragraph restoration already exists.']
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const beforeXml = serializer.serializeToString(xmlDoc);
|
|
1223
|
+
const beforeParagraphs = getDocumentParagraphNodes(xmlDoc);
|
|
1224
|
+
const sourceStartIndex = beforeParagraphs.indexOf(firstSource);
|
|
1225
|
+
const existingIndexes = hasExistingRestoration
|
|
1226
|
+
? existingBlock.map(paragraph => beforeParagraphs.indexOf(paragraph))
|
|
1227
|
+
: [];
|
|
1228
|
+
const templates = [];
|
|
1229
|
+
const warnings = [];
|
|
1230
|
+
for (let index = 0; index < sourceParagraphs.length; index++) {
|
|
1231
|
+
const source = sourceParagraphs[index];
|
|
1232
|
+
warnings.push(...collectRestorationAnchorWarnings(source));
|
|
1233
|
+
const template = buildRejectedRestorationTemplate(xmlDoc, source, serializer);
|
|
1234
|
+
const edited = template
|
|
1235
|
+
? await editRestorationTemplate(xmlDoc, template, replacements[index], author, serializer)
|
|
1236
|
+
: null;
|
|
1237
|
+
if (!edited) {
|
|
1238
|
+
return {
|
|
1239
|
+
documentXml,
|
|
1240
|
+
hasChanges: false,
|
|
1241
|
+
status: 'error',
|
|
1242
|
+
error: {
|
|
1243
|
+
code: 'PATCH_ROUNDTRIP_MISMATCH',
|
|
1244
|
+
message: `Could not reconstruct restoration paragraph ${index + 1} from its rejected revision view.`
|
|
1245
|
+
}
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
templates.push(edited);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const insertedParagraphs = templates.map(template => {
|
|
1252
|
+
const paragraph = xmlDoc.importNode(template, true);
|
|
1253
|
+
return trackRestoredParagraph(xmlDoc, paragraph, author, operationSession);
|
|
1254
|
+
});
|
|
1255
|
+
const insertionPoint = lastSource.nextSibling;
|
|
1256
|
+
for (const paragraph of insertedParagraphs) parent.insertBefore(paragraph, insertionPoint);
|
|
1257
|
+
if (hasExistingRestoration) {
|
|
1258
|
+
for (const paragraph of existingBlock) {
|
|
1259
|
+
options?._mutationRemovedNodes?.push(paragraph);
|
|
1260
|
+
paragraph.parentNode?.removeChild(paragraph);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
options?._mutationLiveNodes?.push(...insertedParagraphs);
|
|
1264
|
+
normalizeBodySectionOrder(xmlDoc);
|
|
1265
|
+
|
|
1266
|
+
const outputXml = serializer.serializeToString(xmlDoc);
|
|
1267
|
+
const oracle = verifyParagraphRestorationLifecycle(
|
|
1268
|
+
beforeXml,
|
|
1269
|
+
outputXml,
|
|
1270
|
+
sourceStartIndex,
|
|
1271
|
+
existingIndexes,
|
|
1272
|
+
templates,
|
|
1273
|
+
insertedParagraphs
|
|
1274
|
+
);
|
|
1275
|
+
if (!oracle.valid) {
|
|
1276
|
+
return {
|
|
1277
|
+
documentXml,
|
|
1278
|
+
hasChanges: false,
|
|
1279
|
+
status: 'error',
|
|
1280
|
+
error: {
|
|
1281
|
+
code: oracle.code || 'PATCH_ROUNDTRIP_MISMATCH',
|
|
1282
|
+
message: oracle.message,
|
|
1283
|
+
stage: oracle.stage,
|
|
1284
|
+
...(oracle.generatedIssues ? { generatedIssues: oracle.generatedIssues } : {}),
|
|
1285
|
+
...(oracle.envelopeIssues ? { envelopeIssues: oracle.envelopeIssues } : {}),
|
|
1286
|
+
...(oracle.expected ? { expected: oracle.expected } : {}),
|
|
1287
|
+
...(oracle.actual ? { actual: oracle.actual } : {})
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
return {
|
|
1293
|
+
documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
|
|
1294
|
+
hasChanges: true,
|
|
1295
|
+
status: 'ok',
|
|
1296
|
+
numberingXml: null,
|
|
1297
|
+
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
520
1300
|
|
|
521
1301
|
async function tryExplicitDecimalHeaderListConversion({
|
|
522
1302
|
xmlDoc,
|
|
@@ -819,8 +1599,25 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
|
|
|
819
1599
|
error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' }
|
|
820
1600
|
};
|
|
821
1601
|
}
|
|
822
|
-
const targetParagraph = resolved.paragraph;
|
|
823
|
-
|
|
1602
|
+
const targetParagraph = resolved.paragraph;
|
|
1603
|
+
if (typeof modifiedText === 'string' && modifiedText.length > 0) {
|
|
1604
|
+
const resurrectionTarget = inspectForeignDeletedParagraphTarget(targetParagraph, author);
|
|
1605
|
+
if (resurrectionTarget.matches) {
|
|
1606
|
+
const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
|
|
1607
|
+
return {
|
|
1608
|
+
documentXml,
|
|
1609
|
+
hasChanges: false,
|
|
1610
|
+
numberingXml: null,
|
|
1611
|
+
status: 'error',
|
|
1612
|
+
error: {
|
|
1613
|
+
code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
|
|
1614
|
+
message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
|
|
1615
|
+
ownerAuthor
|
|
1616
|
+
}
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
preprocessRedlineTargetParagraph(targetParagraph);
|
|
824
1621
|
const currentParagraphText = getParagraphText(targetParagraph);
|
|
825
1622
|
if (modifiedText === '') {
|
|
826
1623
|
const commentIds = getCommentIdsInElement(targetParagraph);
|
|
@@ -1235,8 +2032,8 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
|
|
|
1235
2032
|
? explicitRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
|
|
1236
2033
|
: (inferredTableRangeParagraphs
|
|
1237
2034
|
? inferredTableRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
|
|
1238
|
-
: (currentParagraphText || targetText))
|
|
1239
|
-
);
|
|
2035
|
+
: (currentParagraphText || targetText))
|
|
2036
|
+
);
|
|
1240
2037
|
const scopedXml = useTableScope
|
|
1241
2038
|
? serializer.serializeToString(containingTable)
|
|
1242
2039
|
: (
|