@ansonlai/docx-redline-js 0.5.3 → 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 +46 -12
- package/ARCHITECTURE.md +38 -3
- package/CHANGELOG.md +6 -0
- package/README.md +37 -10
- package/core/paragraph-revision-safety.js +10 -8
- package/core/redline-validation.js +7 -4
- package/core/revision-cloning.js +21 -0
- package/core/validation-delta.js +23 -0
- package/dist/docx-redline-js.esm.js +164 -14
- package/dist/docx-redline-js.esm.js.map +2 -2
- package/dist/docx-redline-js.esm.min.js +79 -79
- package/dist/docx-redline-js.esm.min.js.map +3 -3
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +580 -37
- package/docs/schemas/document-operations.schema.json +11 -1
- package/engine/surgical-mode.js +148 -3
- package/engine/surgical-run-splitting.js +19 -7
- package/engine/surgical-spans.js +2 -1
- 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 +49 -4
- package/services/document-operation-contract.js +40 -0
- package/services/document-operation-mutations.js +353 -36
- package/services/operation-preflight.js +1 -1
- package/services/standalone-operation-runner.d.ts +18 -0
|
@@ -43,6 +43,7 @@ function nonEmptyString(value) {
|
|
|
43
43
|
|
|
44
44
|
export function getCanonicalOperationType(operation) {
|
|
45
45
|
const type = operation?.type;
|
|
46
|
+
if (type === 'insert' && operation?.target?.revisionView === 'rejected') return 'rejected-insert';
|
|
46
47
|
if (type === 'restore') return 'restore';
|
|
47
48
|
if (type === 'comment' || type === 'comment_reply' || type === 'highlight') return type;
|
|
48
49
|
if (type === 'paragraph-format') return 'paragraph-format';
|
|
@@ -95,6 +96,12 @@ export function normalizeDocumentOperation(operation) {
|
|
|
95
96
|
operationId: nonEmptyString(source.operationId) ? source.operationId.trim() : null,
|
|
96
97
|
captureKey: nonEmptyString(source.captureKey) ? source.captureKey.trim() : null,
|
|
97
98
|
operationKind: kind,
|
|
99
|
+
anchor: isRecord(source.anchor) ? {
|
|
100
|
+
exactText: typeof source.anchor.exactText === 'string' ? source.anchor.exactText : '',
|
|
101
|
+
occurrence: Number.isInteger(source.anchor.occurrence) && source.anchor.occurrence > 0 ? source.anchor.occurrence : 1,
|
|
102
|
+
occurrenceExplicit: Number.isInteger(source.anchor.occurrence) && source.anchor.occurrence > 0,
|
|
103
|
+
offset: Number.isInteger(source.anchor.offset) ? source.anchor.offset : null
|
|
104
|
+
} : null,
|
|
98
105
|
targetDescriptor,
|
|
99
106
|
targetEndDescriptor,
|
|
100
107
|
target: targetDescriptor.text,
|
|
@@ -211,6 +218,39 @@ export function validateDocumentOperation(operation) {
|
|
|
211
218
|
};
|
|
212
219
|
}
|
|
213
220
|
|
|
221
|
+
if (normalized.operationKind === 'rejected-insert') {
|
|
222
|
+
if (!nonEmptyString(normalized.modified)) {
|
|
223
|
+
return {
|
|
224
|
+
valid: false,
|
|
225
|
+
error: { code: 'INVALID_OPERATION', message: 'Rejected-view insert operations require non-empty string "modified" text.' }
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (
|
|
229
|
+
!normalized.anchor
|
|
230
|
+
|| !nonEmptyString(normalized.anchor.exactText)
|
|
231
|
+
|| !Number.isInteger(normalized.anchor.offset)
|
|
232
|
+
|| normalized.anchor.offset < 0
|
|
233
|
+
|| normalized.anchor.offset > normalized.anchor.exactText.length
|
|
234
|
+
) {
|
|
235
|
+
return {
|
|
236
|
+
valid: false,
|
|
237
|
+
error: {
|
|
238
|
+
code: 'INVALID_OPERATION',
|
|
239
|
+
message: 'Rejected-view insert operations require anchor.exactText, a positive occurrence, and an offset within the anchor text.'
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (normalized.existingRevisions !== 'slice-cross-author') {
|
|
244
|
+
return {
|
|
245
|
+
valid: false,
|
|
246
|
+
error: {
|
|
247
|
+
code: 'INVALID_OPERATION',
|
|
248
|
+
message: 'Rejected-view insert operations require existingRevisions: "slice-cross-author".'
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
214
254
|
if (normalized.operationKind === 'restore') {
|
|
215
255
|
const validSingle = nonEmptyString(normalized.modified);
|
|
216
256
|
const validRange = Array.isArray(normalized.modified)
|
|
@@ -27,6 +27,13 @@ import {
|
|
|
27
27
|
} from '../core/paragraph-revision-safety.js';
|
|
28
28
|
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
29
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';
|
|
30
37
|
import { applyHighlightToOoxml } from '../engine/formatting-removal.js';
|
|
31
38
|
import { parseTable as parseMarkdownTable } from '../pipeline/pipeline.js';
|
|
32
39
|
import { injectCommentsIntoOoxml } from './comment-engine.js';
|
|
@@ -162,9 +169,60 @@ async function reconcileMarkdownTableOoxml(oxml, originalText, markdownTable, op
|
|
|
162
169
|
};
|
|
163
170
|
}
|
|
164
171
|
|
|
165
|
-
function getParagraphText(paragraph) {
|
|
166
|
-
return getParagraphTextFromOxml(paragraph);
|
|
167
|
-
}
|
|
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
|
+
}
|
|
168
226
|
|
|
169
227
|
function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
|
|
170
228
|
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
|
|
@@ -178,16 +236,20 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
|
|
|
178
236
|
if (options?._resolutionCapture && resolved?.paragraph) {
|
|
179
237
|
const paragraph = resolved.paragraph;
|
|
180
238
|
const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
|
|
181
|
-
Object.assign(options._resolutionCapture, {
|
|
182
|
-
resolvedBy: resolved.resolvedBy,
|
|
183
|
-
resolvedTarget: {
|
|
239
|
+
Object.assign(options._resolutionCapture, {
|
|
240
|
+
resolvedBy: resolved.resolvedBy,
|
|
241
|
+
resolvedTarget: {
|
|
184
242
|
index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
|
|
185
243
|
paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
|
|
186
244
|
text: metadata?.text ?? getParagraphText(paragraph),
|
|
187
245
|
fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
|
|
188
|
-
inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl')
|
|
189
|
-
|
|
190
|
-
|
|
246
|
+
inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
|
|
247
|
+
targetTextMatch: describeTargetTextMatch(
|
|
248
|
+
metadata?.text ?? getParagraphText(paragraph),
|
|
249
|
+
options?.targetDescriptor?.exactText ?? targetText
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
});
|
|
191
253
|
}
|
|
192
254
|
return resolved;
|
|
193
255
|
} catch (error) {
|
|
@@ -216,14 +278,18 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
|
|
|
216
278
|
const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
|
|
217
279
|
Object.assign(options._resolutionCapture, {
|
|
218
280
|
resolvedBy: resolved.resolvedBy,
|
|
219
|
-
resolvedTarget: {
|
|
281
|
+
resolvedTarget: {
|
|
220
282
|
index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
|
|
221
283
|
paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
|
|
222
284
|
text: metadata?.text ?? getParagraphText(paragraph),
|
|
223
285
|
fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
|
|
224
|
-
inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl')
|
|
225
|
-
|
|
226
|
-
|
|
286
|
+
inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
|
|
287
|
+
targetTextMatch: describeTargetTextMatch(
|
|
288
|
+
metadata?.text ?? getParagraphText(paragraph),
|
|
289
|
+
options?.targetDescriptor?.exactText ?? targetText
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
});
|
|
227
293
|
}
|
|
228
294
|
return resolved;
|
|
229
295
|
}
|
|
@@ -260,7 +326,7 @@ function preprocessRedlineTargetParagraph(targetParagraph) {
|
|
|
260
326
|
removeProofErrNodes(targetParagraph);
|
|
261
327
|
}
|
|
262
328
|
|
|
263
|
-
function getDirectWordChild(element, localName) {
|
|
329
|
+
function getDirectWordChild(element, localName) {
|
|
264
330
|
if (!element) return null;
|
|
265
331
|
return Array.from(element.childNodes || []).find(
|
|
266
332
|
node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === localName
|
|
@@ -325,7 +391,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
|
|
|
325
391
|
|
|
326
392
|
const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
|
|
327
393
|
if (anchorPPr) {
|
|
328
|
-
paragraph.appendChild(anchorPPr
|
|
394
|
+
paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
|
|
329
395
|
}
|
|
330
396
|
ensureListProperties(xmlDoc, paragraph, entry.ilvl, entry.numId);
|
|
331
397
|
|
|
@@ -341,7 +407,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
|
|
|
341
407
|
const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
|
|
342
408
|
const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
|
|
343
409
|
if (anchorRunPr) {
|
|
344
|
-
run.appendChild(anchorRunPr
|
|
410
|
+
run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
|
|
345
411
|
}
|
|
346
412
|
|
|
347
413
|
const textNode = createWordElement(xmlDoc, 'w:t');
|
|
@@ -445,12 +511,12 @@ function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, aut
|
|
|
445
511
|
function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
|
|
446
512
|
const paragraph = createWordElement(xmlDoc, 'w:p');
|
|
447
513
|
const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
|
|
448
|
-
if (anchorPPr) paragraph.appendChild(anchorPPr
|
|
514
|
+
if (anchorPPr) paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
|
|
449
515
|
|
|
450
516
|
const run = createWordElement(xmlDoc, 'w:r');
|
|
451
517
|
const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
|
|
452
518
|
const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
|
|
453
|
-
if (anchorRunPr) run.appendChild(anchorRunPr
|
|
519
|
+
if (anchorRunPr) run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
|
|
454
520
|
|
|
455
521
|
const textNode = createWordElement(xmlDoc, 'w:t');
|
|
456
522
|
textNode.textContent = '';
|
|
@@ -463,7 +529,7 @@ function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, au
|
|
|
463
529
|
const wrappedParagraph = createWordElement(xmlDoc, 'w:p');
|
|
464
530
|
const pPr = options.sanitizeParagraphProperties === true
|
|
465
531
|
? createSanitizedRestorationPPr(xmlDoc, paragraph)
|
|
466
|
-
: getDirectWordChild(paragraph, 'pPr')
|
|
532
|
+
: clonePropertiesWithoutRevisionHistory(getDirectWordChild(paragraph, 'pPr'));
|
|
467
533
|
if (pPr) wrappedParagraph.appendChild(pPr);
|
|
468
534
|
if (options.paragraphId) wrappedParagraph.setAttributeNS(NS_W14, 'w14:paraId', options.paragraphId);
|
|
469
535
|
if (options.trackParagraphMark === true) {
|
|
@@ -539,6 +605,71 @@ function collectNumberingIdsFromNodes(nodes) {
|
|
|
539
605
|
return Array.from(ids);
|
|
540
606
|
}
|
|
541
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
|
+
|
|
542
673
|
const RESTORATION_PPR_ALLOWLIST = new Set([
|
|
543
674
|
'pStyle', 'numPr', 'ind', 'jc', 'spacing', 'tabs',
|
|
544
675
|
'keepNext', 'keepLines', 'outlineLvl', 'contextualSpacing'
|
|
@@ -678,13 +809,13 @@ function isInsertedParagraphByAuthor(paragraph, author) {
|
|
|
678
809
|
return !!marker && normalizedAuthor(wordAttribute(marker, 'author')) === normalizedAuthor(author);
|
|
679
810
|
}
|
|
680
811
|
|
|
681
|
-
function
|
|
812
|
+
function followingParagraphBlock(lastSource, count) {
|
|
682
813
|
const paragraphs = [];
|
|
683
|
-
let cursor =
|
|
814
|
+
let cursor = lastSource;
|
|
684
815
|
for (let i = 0; i < count; i++) {
|
|
685
|
-
cursor = directWordParagraphSibling(cursor, '
|
|
816
|
+
cursor = directWordParagraphSibling(cursor, 'nextSibling');
|
|
686
817
|
if (!cursor) return [];
|
|
687
|
-
paragraphs.
|
|
818
|
+
paragraphs.push(cursor);
|
|
688
819
|
}
|
|
689
820
|
return paragraphs;
|
|
690
821
|
}
|
|
@@ -713,26 +844,37 @@ function buildExpectedRestorationDocument(beforeXml, sourceStartIndex, existingI
|
|
|
713
844
|
if (parsed.error || !parsed.doc) return null;
|
|
714
845
|
const expectedDoc = parsed.doc;
|
|
715
846
|
const originalParagraphs = getDocumentParagraphNodes(expectedDoc);
|
|
716
|
-
const
|
|
717
|
-
if (!
|
|
847
|
+
const lastSource = originalParagraphs[sourceStartIndex + templates.length - 1] || null;
|
|
848
|
+
if (!lastSource?.parentNode) return null;
|
|
718
849
|
|
|
719
850
|
for (const index of [...existingIndexes].sort((a, b) => b - a)) {
|
|
720
851
|
const paragraph = originalParagraphs[index];
|
|
721
852
|
paragraph?.parentNode?.removeChild(paragraph);
|
|
722
853
|
}
|
|
854
|
+
const insertionPoint = lastSource.nextSibling;
|
|
723
855
|
for (const template of templates) {
|
|
724
|
-
|
|
856
|
+
lastSource.parentNode.insertBefore(expectedDoc.importNode(template, true), insertionPoint);
|
|
725
857
|
}
|
|
726
858
|
return createSerializer().serializeToString(expectedDoc);
|
|
727
859
|
}
|
|
728
860
|
|
|
729
|
-
function verifyParagraphRestorationLifecycle(beforeXml, outputXml, sourceStartIndex, existingIndexes, templates) {
|
|
730
|
-
const
|
|
731
|
-
|
|
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) {
|
|
732
871
|
return {
|
|
733
872
|
valid: false,
|
|
734
873
|
stage: 'validation',
|
|
735
|
-
|
|
874
|
+
code: 'GENERATED_OOXML_INVALID',
|
|
875
|
+
generatedIssues,
|
|
876
|
+
envelopeIssues,
|
|
877
|
+
message: [...generatedErrors, ...envelopeIssues].map(issue => issue.message).join(' ')
|
|
736
878
|
};
|
|
737
879
|
}
|
|
738
880
|
|
|
@@ -758,6 +900,176 @@ function verifyParagraphRestorationLifecycle(beforeXml, outputXml, sourceStartIn
|
|
|
758
900
|
return { valid: true };
|
|
759
901
|
}
|
|
760
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
|
+
|
|
761
1073
|
/**
|
|
762
1074
|
* Materializes an explicit counterproposal for one or more paragraphs wholly
|
|
763
1075
|
* deleted (content and paragraph mark) by another author.
|
|
@@ -891,7 +1203,8 @@ export async function restoreDeletedParagraphByExactText(
|
|
|
891
1203
|
}
|
|
892
1204
|
}
|
|
893
1205
|
|
|
894
|
-
const
|
|
1206
|
+
const lastSource = sourceParagraphs[sourceParagraphs.length - 1];
|
|
1207
|
+
const existingBlock = followingParagraphBlock(lastSource, sourceParagraphs.length);
|
|
895
1208
|
const hasExistingRestoration = existingBlock.length === sourceParagraphs.length
|
|
896
1209
|
&& existingBlock.every(paragraph => isInsertedParagraphByAuthor(paragraph, author));
|
|
897
1210
|
if (
|
|
@@ -939,7 +1252,8 @@ export async function restoreDeletedParagraphByExactText(
|
|
|
939
1252
|
const paragraph = xmlDoc.importNode(template, true);
|
|
940
1253
|
return trackRestoredParagraph(xmlDoc, paragraph, author, operationSession);
|
|
941
1254
|
});
|
|
942
|
-
|
|
1255
|
+
const insertionPoint = lastSource.nextSibling;
|
|
1256
|
+
for (const paragraph of insertedParagraphs) parent.insertBefore(paragraph, insertionPoint);
|
|
943
1257
|
if (hasExistingRestoration) {
|
|
944
1258
|
for (const paragraph of existingBlock) {
|
|
945
1259
|
options?._mutationRemovedNodes?.push(paragraph);
|
|
@@ -955,7 +1269,8 @@ export async function restoreDeletedParagraphByExactText(
|
|
|
955
1269
|
outputXml,
|
|
956
1270
|
sourceStartIndex,
|
|
957
1271
|
existingIndexes,
|
|
958
|
-
templates
|
|
1272
|
+
templates,
|
|
1273
|
+
insertedParagraphs
|
|
959
1274
|
);
|
|
960
1275
|
if (!oracle.valid) {
|
|
961
1276
|
return {
|
|
@@ -963,9 +1278,11 @@ export async function restoreDeletedParagraphByExactText(
|
|
|
963
1278
|
hasChanges: false,
|
|
964
1279
|
status: 'error',
|
|
965
1280
|
error: {
|
|
966
|
-
code: 'PATCH_ROUNDTRIP_MISMATCH',
|
|
1281
|
+
code: oracle.code || 'PATCH_ROUNDTRIP_MISMATCH',
|
|
967
1282
|
message: oracle.message,
|
|
968
1283
|
stage: oracle.stage,
|
|
1284
|
+
...(oracle.generatedIssues ? { generatedIssues: oracle.generatedIssues } : {}),
|
|
1285
|
+
...(oracle.envelopeIssues ? { envelopeIssues: oracle.envelopeIssues } : {}),
|
|
969
1286
|
...(oracle.expected ? { expected: oracle.expected } : {}),
|
|
970
1287
|
...(oracle.actual ? { actual: oracle.actual } : {})
|
|
971
1288
|
}
|
|
@@ -1715,8 +2032,8 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
|
|
|
1715
2032
|
? explicitRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
|
|
1716
2033
|
: (inferredTableRangeParagraphs
|
|
1717
2034
|
? inferredTableRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
|
|
1718
|
-
: (currentParagraphText || targetText))
|
|
1719
|
-
);
|
|
2035
|
+
: (currentParagraphText || targetText))
|
|
2036
|
+
);
|
|
1720
2037
|
const scopedXml = useTableScope
|
|
1721
2038
|
? serializer.serializeToString(containingTable)
|
|
1722
2039
|
: (
|
|
@@ -418,7 +418,7 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
418
418
|
}
|
|
419
419
|
|
|
420
420
|
for (const [targetIndex, targetResults] of byTarget) {
|
|
421
|
-
const redlines = targetResults.filter(result => ['redline', 'restore'].includes(result.operationType));
|
|
421
|
+
const redlines = targetResults.filter(result => ['redline', 'restore', 'rejected-insert'].includes(result.operationType));
|
|
422
422
|
const highlights = targetResults.filter(result => result.operationType === 'highlight');
|
|
423
423
|
const target = targetResults[0].resolvedTarget;
|
|
424
424
|
if (redlines.length > 1) {
|
|
@@ -23,6 +23,12 @@ export interface InsertionAffinity {
|
|
|
23
23
|
comment?: 'inside' | 'outside';
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export interface RejectedTextInsertionAnchor {
|
|
27
|
+
exactText: string;
|
|
28
|
+
occurrence?: number;
|
|
29
|
+
offset: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
export interface DocumentOperationBase {
|
|
27
33
|
operationId?: string;
|
|
28
34
|
captureKey?: string;
|
|
@@ -41,6 +47,8 @@ export interface RedlineDocumentOperation extends DocumentOperationBase {
|
|
|
41
47
|
structuredContent?: boolean;
|
|
42
48
|
targetEnd?: ParagraphTargetDescriptor;
|
|
43
49
|
targetEndRef?: number | string | null;
|
|
50
|
+
/** Required when type is insert and target.revisionView is rejected. */
|
|
51
|
+
anchor?: RejectedTextInsertionAnchor;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export interface RestoreDocumentOperation extends DocumentOperationBase {
|
|
@@ -124,6 +132,16 @@ export interface ResolvedDocumentTarget {
|
|
|
124
132
|
text: string;
|
|
125
133
|
fingerprint?: string;
|
|
126
134
|
inTable?: boolean;
|
|
135
|
+
targetTextMatch?: {
|
|
136
|
+
mode: 'exact' | 'space_equivalent' | 'normalized';
|
|
137
|
+
differences?: Array<{
|
|
138
|
+
offset: number;
|
|
139
|
+
sourceCodePoint: string;
|
|
140
|
+
requestedCodePoint: string;
|
|
141
|
+
}>;
|
|
142
|
+
sourceExcerpt?: string;
|
|
143
|
+
requestedExcerpt?: string;
|
|
144
|
+
};
|
|
127
145
|
}
|
|
128
146
|
|
|
129
147
|
export interface ResolvedCommentAnchor {
|