@ansonlai/docx-redline-js 0.5.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +82 -667
- package/ARCHITECTURE.md +51 -4
- package/CHANGELOG.md +11 -0
- package/README.md +176 -39
- package/core/paragraph-revision-safety.js +10 -8
- package/core/paragraph-targeting.js +14 -2
- 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 +275 -45
- package/dist/docx-redline-js.esm.js.map +3 -3
- package/dist/docx-redline-js.esm.min.js +82 -82
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/AGENT_FAST_START.md +59 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +868 -0
- package/docs/TESTING.md +20 -1
- package/docs/schemas/document-operations.schema.json +16 -2
- package/docs/validation-reports/2026-09-12-agent-protocol-rollout.md +82 -0
- package/engine/oxml-engine.js +80 -13
- package/engine/run-builders.js +5 -15
- package/engine/surgical-mode.js +148 -3
- package/engine/surgical-run-splitting.js +19 -7
- package/engine/surgical-spans.js +2 -1
- package/index.d.ts +17 -1
- package/node/cli.js +235 -36
- package/node/docx-document.js +137 -83
- package/node/index.d.ts +6 -2
- package/package.json +10 -3
- package/pipeline/diff-engine.js +15 -0
- package/scripts/generate-cross-author-slicing-fixtures.ps1 +25 -25
- package/services/batch-operation-orchestrator.js +215 -120
- package/services/document-inspection.js +5 -3
- package/services/document-operation-applier.js +99 -36
- package/services/document-operation-contract.js +50 -6
- package/services/document-operation-mutations.js +404 -41
- package/services/document-operation-session.js +4 -0
- package/services/error-recovery.js +174 -0
- package/services/operation-batch-compiler.js +394 -0
- package/services/operation-preflight.js +91 -72
- package/services/standalone-operation-runner.d.ts +35 -1
- package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +0 -1669
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +0 -856
- package/docs/plans/completed/2026-03-01-release-0.1.4-design.md +0 -33
- package/docs/plans/completed/2026-03-01-release-0.1.4.md +0 -110
- package/docs/plans/completed/2026-05-31-architectural changes.md +0 -593
- package/docs/plans/completed/2026-08-02-reliability-improvements.md +0 -1155
- package/docs/plans/completed/2026-08-30-reliability-testing-improvements.md +0 -488
- package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +0 -669
- package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +0 -427
- package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +0 -519
- package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +0 -69
- package/docs/plans/completed/structural-revision-capability-matrix.md +0 -115
- package/docs/test-comparison-dashboard.html +0 -4338
- package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +0 -22
- package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +0 -24
- package/docs/validation-reports/2026-08-30-phase-3-coverage.md +0 -73
- package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +0 -82
- package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +0 -114
- package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +0 -79
|
@@ -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,15 +169,91 @@ 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
|
-
function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
|
|
227
|
+
function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
|
|
170
228
|
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
|
|
171
229
|
const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
|
|
172
230
|
const session = options?._documentOperationSession || null;
|
|
173
|
-
const paragraphMetadataIndex = session?.getParagraphMetadataIndex?.() || null;
|
|
231
|
+
const paragraphMetadataIndex = session?.getParagraphMetadataIndex?.() || null;
|
|
232
|
+
|
|
233
|
+
if (options?._compiledSourceId && options?._sourceTargetRegistry) {
|
|
234
|
+
const resolved = options._sourceTargetRegistry.resolve(options._compiledSourceId, xmlDoc);
|
|
235
|
+
if (resolved?.error || !resolved?.paragraph) return resolved;
|
|
236
|
+
resolved.resolvedBy = options._compiledResolvedBy || resolved.resolvedBy;
|
|
237
|
+
if (options?._resolutionCapture) {
|
|
238
|
+
const paragraph = resolved.paragraph;
|
|
239
|
+
const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
|
|
240
|
+
Object.assign(options._resolutionCapture, {
|
|
241
|
+
resolvedBy: resolved.resolvedBy,
|
|
242
|
+
resolvedTarget: {
|
|
243
|
+
index: metadata?.index ?? getDocumentParagraphNodes(xmlDoc).indexOf(paragraph) + 1,
|
|
244
|
+
paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
|
|
245
|
+
text: metadata?.text ?? getParagraphText(paragraph),
|
|
246
|
+
fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
|
|
247
|
+
inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
|
|
248
|
+
targetTextMatch: describeTargetTextMatch(
|
|
249
|
+
metadata?.text ?? getParagraphText(paragraph),
|
|
250
|
+
options?.targetDescriptor?.exactText ?? targetText
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return resolved;
|
|
256
|
+
}
|
|
174
257
|
|
|
175
258
|
if (options?.targetDescriptor?.captureRef) {
|
|
176
259
|
try {
|
|
@@ -178,16 +261,20 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
|
|
|
178
261
|
if (options?._resolutionCapture && resolved?.paragraph) {
|
|
179
262
|
const paragraph = resolved.paragraph;
|
|
180
263
|
const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
|
|
181
|
-
Object.assign(options._resolutionCapture, {
|
|
182
|
-
resolvedBy: resolved.resolvedBy,
|
|
183
|
-
resolvedTarget: {
|
|
264
|
+
Object.assign(options._resolutionCapture, {
|
|
265
|
+
resolvedBy: resolved.resolvedBy,
|
|
266
|
+
resolvedTarget: {
|
|
184
267
|
index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
|
|
185
268
|
paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
|
|
186
269
|
text: metadata?.text ?? getParagraphText(paragraph),
|
|
187
270
|
fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
|
|
188
|
-
inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl')
|
|
189
|
-
|
|
190
|
-
|
|
271
|
+
inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
|
|
272
|
+
targetTextMatch: describeTargetTextMatch(
|
|
273
|
+
metadata?.text ?? getParagraphText(paragraph),
|
|
274
|
+
options?.targetDescriptor?.exactText ?? targetText
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
});
|
|
191
278
|
}
|
|
192
279
|
return resolved;
|
|
193
280
|
} catch (error) {
|
|
@@ -216,14 +303,18 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
|
|
|
216
303
|
const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
|
|
217
304
|
Object.assign(options._resolutionCapture, {
|
|
218
305
|
resolvedBy: resolved.resolvedBy,
|
|
219
|
-
resolvedTarget: {
|
|
306
|
+
resolvedTarget: {
|
|
220
307
|
index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
|
|
221
308
|
paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
|
|
222
309
|
text: metadata?.text ?? getParagraphText(paragraph),
|
|
223
310
|
fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
|
|
224
|
-
inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl')
|
|
225
|
-
|
|
226
|
-
|
|
311
|
+
inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
|
|
312
|
+
targetTextMatch: describeTargetTextMatch(
|
|
313
|
+
metadata?.text ?? getParagraphText(paragraph),
|
|
314
|
+
options?.targetDescriptor?.exactText ?? targetText
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
});
|
|
227
318
|
}
|
|
228
319
|
return resolved;
|
|
229
320
|
}
|
|
@@ -260,7 +351,7 @@ function preprocessRedlineTargetParagraph(targetParagraph) {
|
|
|
260
351
|
removeProofErrNodes(targetParagraph);
|
|
261
352
|
}
|
|
262
353
|
|
|
263
|
-
function getDirectWordChild(element, localName) {
|
|
354
|
+
function getDirectWordChild(element, localName) {
|
|
264
355
|
if (!element) return null;
|
|
265
356
|
return Array.from(element.childNodes || []).find(
|
|
266
357
|
node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === localName
|
|
@@ -325,7 +416,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
|
|
|
325
416
|
|
|
326
417
|
const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
|
|
327
418
|
if (anchorPPr) {
|
|
328
|
-
paragraph.appendChild(anchorPPr
|
|
419
|
+
paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
|
|
329
420
|
}
|
|
330
421
|
ensureListProperties(xmlDoc, paragraph, entry.ilvl, entry.numId);
|
|
331
422
|
|
|
@@ -341,7 +432,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
|
|
|
341
432
|
const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
|
|
342
433
|
const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
|
|
343
434
|
if (anchorRunPr) {
|
|
344
|
-
run.appendChild(anchorRunPr
|
|
435
|
+
run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
|
|
345
436
|
}
|
|
346
437
|
|
|
347
438
|
const textNode = createWordElement(xmlDoc, 'w:t');
|
|
@@ -445,12 +536,12 @@ function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, aut
|
|
|
445
536
|
function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
|
|
446
537
|
const paragraph = createWordElement(xmlDoc, 'w:p');
|
|
447
538
|
const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
|
|
448
|
-
if (anchorPPr) paragraph.appendChild(anchorPPr
|
|
539
|
+
if (anchorPPr) paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
|
|
449
540
|
|
|
450
541
|
const run = createWordElement(xmlDoc, 'w:r');
|
|
451
542
|
const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
|
|
452
543
|
const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
|
|
453
|
-
if (anchorRunPr) run.appendChild(anchorRunPr
|
|
544
|
+
if (anchorRunPr) run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
|
|
454
545
|
|
|
455
546
|
const textNode = createWordElement(xmlDoc, 'w:t');
|
|
456
547
|
textNode.textContent = '';
|
|
@@ -463,7 +554,7 @@ function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, au
|
|
|
463
554
|
const wrappedParagraph = createWordElement(xmlDoc, 'w:p');
|
|
464
555
|
const pPr = options.sanitizeParagraphProperties === true
|
|
465
556
|
? createSanitizedRestorationPPr(xmlDoc, paragraph)
|
|
466
|
-
: getDirectWordChild(paragraph, 'pPr')
|
|
557
|
+
: clonePropertiesWithoutRevisionHistory(getDirectWordChild(paragraph, 'pPr'));
|
|
467
558
|
if (pPr) wrappedParagraph.appendChild(pPr);
|
|
468
559
|
if (options.paragraphId) wrappedParagraph.setAttributeNS(NS_W14, 'w14:paraId', options.paragraphId);
|
|
469
560
|
if (options.trackParagraphMark === true) {
|
|
@@ -539,6 +630,71 @@ function collectNumberingIdsFromNodes(nodes) {
|
|
|
539
630
|
return Array.from(ids);
|
|
540
631
|
}
|
|
541
632
|
|
|
633
|
+
function directDeletionCarrierText(carrier) {
|
|
634
|
+
return Array.from(carrier?.childNodes || []).reduce((text, child) => {
|
|
635
|
+
return text + (child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'r'
|
|
636
|
+
? getRunContentPieces(child).map(piece => piece.text).join('')
|
|
637
|
+
: '');
|
|
638
|
+
}, '');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function findOccurrenceOffset(text, needle, occurrence) {
|
|
642
|
+
let from = 0;
|
|
643
|
+
let found = -1;
|
|
644
|
+
for (let index = 0; index < occurrence; index++) {
|
|
645
|
+
found = text.indexOf(needle, from);
|
|
646
|
+
if (found < 0) return -1;
|
|
647
|
+
from = found + Math.max(needle.length, 1);
|
|
648
|
+
}
|
|
649
|
+
return found;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function countNonOverlappingOccurrences(text, needle) {
|
|
653
|
+
if (!needle) return 0;
|
|
654
|
+
let count = 0;
|
|
655
|
+
let from = 0;
|
|
656
|
+
while (from <= text.length) {
|
|
657
|
+
const found = text.indexOf(needle, from);
|
|
658
|
+
if (found < 0) break;
|
|
659
|
+
count += 1;
|
|
660
|
+
from = found + needle.length;
|
|
661
|
+
}
|
|
662
|
+
return count;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function hasUnsupportedDeletionSplitMarkup(paragraph, carrier) {
|
|
666
|
+
const unsafeParagraphNames = new Set([
|
|
667
|
+
'commentRangeStart', 'commentRangeEnd', 'commentReference',
|
|
668
|
+
'bookmarkStart', 'bookmarkEnd', 'moveFromRangeStart', 'moveFromRangeEnd',
|
|
669
|
+
'moveToRangeStart', 'moveToRangeEnd'
|
|
670
|
+
]);
|
|
671
|
+
if (Array.from(paragraph.getElementsByTagName?.('*') || []).some(node => unsafeParagraphNames.has(node.localName))) {
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
674
|
+
for (const child of Array.from(carrier?.childNodes || [])) {
|
|
675
|
+
if (child.nodeType !== 1) continue;
|
|
676
|
+
if (!(child.namespaceURI === NS_W && child.localName === 'r')) return true;
|
|
677
|
+
for (const runChild of Array.from(child.childNodes || [])) {
|
|
678
|
+
if (runChild.nodeType !== 1 || runChild.namespaceURI !== NS_W) continue;
|
|
679
|
+
if (!['rPr', 'delText', 't', 'tab', 'br', 'cr', 'noBreakHyphen', 'softHyphen'].includes(runChild.localName)) return true;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function insertedRunPropertiesAtDeletionOffset(carrier, localOffset) {
|
|
686
|
+
let offset = 0;
|
|
687
|
+
for (const child of Array.from(carrier?.childNodes || [])) {
|
|
688
|
+
if (!(child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'r')) continue;
|
|
689
|
+
const length = getRunTextLength(getRunContentPieces(child));
|
|
690
|
+
if (localOffset <= offset + length) {
|
|
691
|
+
return clonePropertiesWithoutRevisionHistory(getDirectWordChild(child, 'rPr'));
|
|
692
|
+
}
|
|
693
|
+
offset += length;
|
|
694
|
+
}
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
|
|
542
698
|
const RESTORATION_PPR_ALLOWLIST = new Set([
|
|
543
699
|
'pStyle', 'numPr', 'ind', 'jc', 'spacing', 'tabs',
|
|
544
700
|
'keepNext', 'keepLines', 'outlineLvl', 'contextualSpacing'
|
|
@@ -678,13 +834,13 @@ function isInsertedParagraphByAuthor(paragraph, author) {
|
|
|
678
834
|
return !!marker && normalizedAuthor(wordAttribute(marker, 'author')) === normalizedAuthor(author);
|
|
679
835
|
}
|
|
680
836
|
|
|
681
|
-
function
|
|
837
|
+
function followingParagraphBlock(lastSource, count) {
|
|
682
838
|
const paragraphs = [];
|
|
683
|
-
let cursor =
|
|
839
|
+
let cursor = lastSource;
|
|
684
840
|
for (let i = 0; i < count; i++) {
|
|
685
|
-
cursor = directWordParagraphSibling(cursor, '
|
|
841
|
+
cursor = directWordParagraphSibling(cursor, 'nextSibling');
|
|
686
842
|
if (!cursor) return [];
|
|
687
|
-
paragraphs.
|
|
843
|
+
paragraphs.push(cursor);
|
|
688
844
|
}
|
|
689
845
|
return paragraphs;
|
|
690
846
|
}
|
|
@@ -713,26 +869,37 @@ function buildExpectedRestorationDocument(beforeXml, sourceStartIndex, existingI
|
|
|
713
869
|
if (parsed.error || !parsed.doc) return null;
|
|
714
870
|
const expectedDoc = parsed.doc;
|
|
715
871
|
const originalParagraphs = getDocumentParagraphNodes(expectedDoc);
|
|
716
|
-
const
|
|
717
|
-
if (!
|
|
872
|
+
const lastSource = originalParagraphs[sourceStartIndex + templates.length - 1] || null;
|
|
873
|
+
if (!lastSource?.parentNode) return null;
|
|
718
874
|
|
|
719
875
|
for (const index of [...existingIndexes].sort((a, b) => b - a)) {
|
|
720
876
|
const paragraph = originalParagraphs[index];
|
|
721
877
|
paragraph?.parentNode?.removeChild(paragraph);
|
|
722
878
|
}
|
|
879
|
+
const insertionPoint = lastSource.nextSibling;
|
|
723
880
|
for (const template of templates) {
|
|
724
|
-
|
|
881
|
+
lastSource.parentNode.insertBefore(expectedDoc.importNode(template, true), insertionPoint);
|
|
725
882
|
}
|
|
726
883
|
return createSerializer().serializeToString(expectedDoc);
|
|
727
884
|
}
|
|
728
885
|
|
|
729
|
-
function verifyParagraphRestorationLifecycle(beforeXml, outputXml, sourceStartIndex, existingIndexes, templates) {
|
|
730
|
-
const
|
|
731
|
-
|
|
886
|
+
function verifyParagraphRestorationLifecycle(beforeXml, outputXml, sourceStartIndex, existingIndexes, templates, insertedParagraphs = []) {
|
|
887
|
+
const baselineValidation = validateRedlineOoxml(beforeXml);
|
|
888
|
+
const outputValidation = validateRedlineOoxml(outputXml);
|
|
889
|
+
const generatedIssues = subtractValidationIssueMultiset(outputValidation.issues, baselineValidation.issues);
|
|
890
|
+
const envelopeIssues = insertedParagraphs.flatMap(paragraph => (
|
|
891
|
+
validateRedlineOoxml(createSerializer().serializeToString(paragraph)).issues
|
|
892
|
+
.filter(issue => issue.severity === 'error')
|
|
893
|
+
));
|
|
894
|
+
const generatedErrors = validationErrors(generatedIssues);
|
|
895
|
+
if (generatedErrors.length > 0 || envelopeIssues.length > 0) {
|
|
732
896
|
return {
|
|
733
897
|
valid: false,
|
|
734
898
|
stage: 'validation',
|
|
735
|
-
|
|
899
|
+
code: 'GENERATED_OOXML_INVALID',
|
|
900
|
+
generatedIssues,
|
|
901
|
+
envelopeIssues,
|
|
902
|
+
message: [...generatedErrors, ...envelopeIssues].map(issue => issue.message).join(' ')
|
|
736
903
|
};
|
|
737
904
|
}
|
|
738
905
|
|
|
@@ -758,6 +925,176 @@ function verifyParagraphRestorationLifecycle(beforeXml, outputXml, sourceStartIn
|
|
|
758
925
|
return { valid: true };
|
|
759
926
|
}
|
|
760
927
|
|
|
928
|
+
/**
|
|
929
|
+
* Inserts a new tracked run at an exact offset inside text visible only in the
|
|
930
|
+
* rejected view of a wholly deleted paragraph. The foreign deletion is split,
|
|
931
|
+
* never rewritten or re-authored.
|
|
932
|
+
*/
|
|
933
|
+
export async function insertIntoRejectedDeletedText(
|
|
934
|
+
documentXml,
|
|
935
|
+
targetText,
|
|
936
|
+
anchor,
|
|
937
|
+
modified,
|
|
938
|
+
author,
|
|
939
|
+
targetRef = null,
|
|
940
|
+
runtimeContext = null,
|
|
941
|
+
options = {}
|
|
942
|
+
) {
|
|
943
|
+
const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
|
|
944
|
+
if (!xmlDoc) {
|
|
945
|
+
return {
|
|
946
|
+
documentXml,
|
|
947
|
+
hasChanges: false,
|
|
948
|
+
status: 'error',
|
|
949
|
+
error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'insert', runtimeContext, options);
|
|
954
|
+
if (resolved?.error || !resolved?.paragraph) {
|
|
955
|
+
return {
|
|
956
|
+
documentXml,
|
|
957
|
+
hasChanges: false,
|
|
958
|
+
status: 'error',
|
|
959
|
+
error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Rejected-view insertion target was not found.' }
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const paragraph = resolved.paragraph;
|
|
964
|
+
const state = inspectForeignDeletedParagraphTarget(paragraph, author);
|
|
965
|
+
if (!state.matches) {
|
|
966
|
+
return {
|
|
967
|
+
documentXml,
|
|
968
|
+
hasChanges: false,
|
|
969
|
+
status: 'error',
|
|
970
|
+
error: {
|
|
971
|
+
code: 'REJECTED_INSERTION_STATE_REQUIRED',
|
|
972
|
+
message: 'Rejected-view insertion requires a wholly deleted paragraph owned by another author.'
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
const structuralRefusal = getParagraphRestorationRefusal(paragraph, { requireFollowingParagraph: false });
|
|
977
|
+
if (structuralRefusal) {
|
|
978
|
+
return { documentXml, hasChanges: false, status: 'error', error: structuralRefusal };
|
|
979
|
+
}
|
|
980
|
+
if (/\r|\n/.test(modified)) {
|
|
981
|
+
return {
|
|
982
|
+
documentXml,
|
|
983
|
+
hasChanges: false,
|
|
984
|
+
status: 'error',
|
|
985
|
+
error: {
|
|
986
|
+
code: 'UNSAFE_PARAGRAPH_BOUNDARY',
|
|
987
|
+
message: 'Rejected-view insertion supports run-level text only; use restore for paragraph boundaries.'
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const rejectedText = extractCanonicalParagraphText(paragraph, { revisionView: 'rejected' });
|
|
993
|
+
if (!anchor.occurrenceExplicit && countNonOverlappingOccurrences(rejectedText, anchor.exactText) > 1) {
|
|
994
|
+
return {
|
|
995
|
+
documentXml,
|
|
996
|
+
hasChanges: false,
|
|
997
|
+
status: 'error',
|
|
998
|
+
error: {
|
|
999
|
+
code: 'AMBIGUOUS_ANCHOR',
|
|
1000
|
+
message: 'The rejected-view insertion anchor is repeated; provide anchor.occurrence explicitly.'
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
const anchorStart = findOccurrenceOffset(rejectedText, anchor.exactText, anchor.occurrence);
|
|
1005
|
+
if (anchorStart < 0) {
|
|
1006
|
+
return {
|
|
1007
|
+
documentXml,
|
|
1008
|
+
hasChanges: false,
|
|
1009
|
+
status: 'error',
|
|
1010
|
+
error: { code: 'ANCHOR_NOT_FOUND', message: 'The rejected-view insertion anchor was not found at the requested occurrence.' }
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
const insertionOffset = anchorStart + anchor.offset;
|
|
1014
|
+
const carriers = Array.from(paragraph.childNodes || []).filter(
|
|
1015
|
+
node => node?.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'del'
|
|
1016
|
+
);
|
|
1017
|
+
if (carriers.some(carrier => hasUnsupportedDeletionSplitMarkup(paragraph, carrier))) {
|
|
1018
|
+
return {
|
|
1019
|
+
documentXml,
|
|
1020
|
+
hasChanges: false,
|
|
1021
|
+
status: 'error',
|
|
1022
|
+
error: {
|
|
1023
|
+
code: 'UNSAFE_REVISION_BOUNDARY',
|
|
1024
|
+
message: 'The rejected-view insertion boundary contains comments, bookmarks, fields, hyperlinks, moves, or non-text run markup that cannot be split safely.'
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
let carrierStart = 0;
|
|
1029
|
+
let targetCarrier = null;
|
|
1030
|
+
let carrierOffset = 0;
|
|
1031
|
+
for (const carrier of carriers) {
|
|
1032
|
+
const length = directDeletionCarrierText(carrier).length;
|
|
1033
|
+
if (insertionOffset >= carrierStart && insertionOffset <= carrierStart + length) {
|
|
1034
|
+
targetCarrier = carrier;
|
|
1035
|
+
carrierOffset = insertionOffset - carrierStart;
|
|
1036
|
+
break;
|
|
1037
|
+
}
|
|
1038
|
+
carrierStart += length;
|
|
1039
|
+
}
|
|
1040
|
+
if (!targetCarrier || carriers.map(directDeletionCarrierText).join('') !== rejectedText) {
|
|
1041
|
+
return {
|
|
1042
|
+
documentXml,
|
|
1043
|
+
hasChanges: false,
|
|
1044
|
+
status: 'error',
|
|
1045
|
+
error: {
|
|
1046
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
1047
|
+
message: 'The rejected-view anchor is not contained in a supported direct deletion carrier.'
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const runProperties = insertedRunPropertiesAtDeletionOffset(targetCarrier, carrierOffset);
|
|
1053
|
+
const { leftCarrier, rightCarrier } = splitTrackChangeCarrier(
|
|
1054
|
+
xmlDoc,
|
|
1055
|
+
targetCarrier,
|
|
1056
|
+
carrierOffset,
|
|
1057
|
+
options._revisionIdAllocator || null
|
|
1058
|
+
);
|
|
1059
|
+
const insertion = createWordElement(xmlDoc, 'w:ins');
|
|
1060
|
+
const metadata = createRevisionMetadata(author, options._revisionIdAllocator || xmlDoc, 'ins');
|
|
1061
|
+
insertion.setAttribute('w:id', String(metadata.id));
|
|
1062
|
+
insertion.setAttribute('w:author', metadata.author);
|
|
1063
|
+
insertion.setAttribute('w:date', metadata.date);
|
|
1064
|
+
const run = createWordElement(xmlDoc, 'w:r');
|
|
1065
|
+
if (runProperties) run.appendChild(runProperties);
|
|
1066
|
+
const textNode = createWordElement(xmlDoc, 'w:t');
|
|
1067
|
+
if (/^\s|\s$/.test(modified)) textNode.setAttribute('xml:space', 'preserve');
|
|
1068
|
+
textNode.textContent = modified;
|
|
1069
|
+
run.appendChild(textNode);
|
|
1070
|
+
insertion.appendChild(run);
|
|
1071
|
+
|
|
1072
|
+
const parent = targetCarrier.parentNode;
|
|
1073
|
+
if (leftCarrier) parent.insertBefore(leftCarrier, targetCarrier);
|
|
1074
|
+
parent.insertBefore(insertion, targetCarrier);
|
|
1075
|
+
if (rightCarrier) parent.insertBefore(rightCarrier, targetCarrier);
|
|
1076
|
+
options?._mutationRemovedNodes?.push(targetCarrier);
|
|
1077
|
+
parent.removeChild(targetCarrier);
|
|
1078
|
+
options?._mutationLiveNodes?.push(paragraph, insertion);
|
|
1079
|
+
operationSession?.invalidateParagraphMetadata?.();
|
|
1080
|
+
|
|
1081
|
+
const outputXml = serializer.serializeToString(xmlDoc);
|
|
1082
|
+
const outputRejected = extractCanonicalParagraphText(paragraph, { revisionView: 'rejected' });
|
|
1083
|
+
const outputAccepted = extractCanonicalParagraphText(paragraph, { revisionView: 'accepted' });
|
|
1084
|
+
if (outputRejected !== rejectedText || !outputAccepted.includes(modified)) {
|
|
1085
|
+
return {
|
|
1086
|
+
documentXml,
|
|
1087
|
+
hasChanges: false,
|
|
1088
|
+
status: 'error',
|
|
1089
|
+
error: {
|
|
1090
|
+
code: 'PATCH_ROUNDTRIP_MISMATCH',
|
|
1091
|
+
message: 'Rejected-view insertion did not preserve the deleted source and expose the requested inserted text.'
|
|
1092
|
+
}
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
return { documentXml: outputXml, hasChanges: true, status: 'ok' };
|
|
1096
|
+
}
|
|
1097
|
+
|
|
761
1098
|
/**
|
|
762
1099
|
* Materializes an explicit counterproposal for one or more paragraphs wholly
|
|
763
1100
|
* deleted (content and paragraph mark) by another author.
|
|
@@ -794,7 +1131,22 @@ export async function restoreDeletedParagraphByExactText(
|
|
|
794
1131
|
|
|
795
1132
|
const firstSource = resolved.paragraph;
|
|
796
1133
|
let sourceParagraphs = [firstSource];
|
|
797
|
-
if (
|
|
1134
|
+
if (options._compiledSourceEndId) {
|
|
1135
|
+
const endResolved = resolveTargetParagraph(
|
|
1136
|
+
xmlDoc,
|
|
1137
|
+
options.targetEndDescriptor?.text || '',
|
|
1138
|
+
targetEndRef,
|
|
1139
|
+
'restore',
|
|
1140
|
+
runtimeContext,
|
|
1141
|
+
{ ...options, _compiledSourceId: options._compiledSourceEndId }
|
|
1142
|
+
);
|
|
1143
|
+
const allParagraphs = endResolved?.paragraph ? getDocumentParagraphNodes(xmlDoc) : [];
|
|
1144
|
+
const startIndex = allParagraphs.indexOf(firstSource);
|
|
1145
|
+
const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
|
|
1146
|
+
sourceParagraphs = startIndex >= 0 && endIndex >= startIndex
|
|
1147
|
+
? allParagraphs.slice(startIndex, endIndex + 1)
|
|
1148
|
+
: null;
|
|
1149
|
+
} else if (targetEndRef) {
|
|
798
1150
|
sourceParagraphs = resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
|
|
799
1151
|
opType: 'restore',
|
|
800
1152
|
targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
|
|
@@ -808,7 +1160,11 @@ export async function restoreDeletedParagraphByExactText(
|
|
|
808
1160
|
options.targetEndDescriptor.index,
|
|
809
1161
|
'restore',
|
|
810
1162
|
runtimeContext,
|
|
811
|
-
{
|
|
1163
|
+
{
|
|
1164
|
+
...options,
|
|
1165
|
+
targetDescriptor: options.targetEndDescriptor,
|
|
1166
|
+
_compiledSourceId: options._compiledSourceEndId || null
|
|
1167
|
+
}
|
|
812
1168
|
);
|
|
813
1169
|
const allParagraphs = endResolved?.paragraph ? getDocumentParagraphNodes(xmlDoc) : [];
|
|
814
1170
|
const startIndex = allParagraphs.indexOf(firstSource);
|
|
@@ -891,7 +1247,8 @@ export async function restoreDeletedParagraphByExactText(
|
|
|
891
1247
|
}
|
|
892
1248
|
}
|
|
893
1249
|
|
|
894
|
-
const
|
|
1250
|
+
const lastSource = sourceParagraphs[sourceParagraphs.length - 1];
|
|
1251
|
+
const existingBlock = followingParagraphBlock(lastSource, sourceParagraphs.length);
|
|
895
1252
|
const hasExistingRestoration = existingBlock.length === sourceParagraphs.length
|
|
896
1253
|
&& existingBlock.every(paragraph => isInsertedParagraphByAuthor(paragraph, author));
|
|
897
1254
|
if (
|
|
@@ -939,7 +1296,8 @@ export async function restoreDeletedParagraphByExactText(
|
|
|
939
1296
|
const paragraph = xmlDoc.importNode(template, true);
|
|
940
1297
|
return trackRestoredParagraph(xmlDoc, paragraph, author, operationSession);
|
|
941
1298
|
});
|
|
942
|
-
|
|
1299
|
+
const insertionPoint = lastSource.nextSibling;
|
|
1300
|
+
for (const paragraph of insertedParagraphs) parent.insertBefore(paragraph, insertionPoint);
|
|
943
1301
|
if (hasExistingRestoration) {
|
|
944
1302
|
for (const paragraph of existingBlock) {
|
|
945
1303
|
options?._mutationRemovedNodes?.push(paragraph);
|
|
@@ -955,7 +1313,8 @@ export async function restoreDeletedParagraphByExactText(
|
|
|
955
1313
|
outputXml,
|
|
956
1314
|
sourceStartIndex,
|
|
957
1315
|
existingIndexes,
|
|
958
|
-
templates
|
|
1316
|
+
templates,
|
|
1317
|
+
insertedParagraphs
|
|
959
1318
|
);
|
|
960
1319
|
if (!oracle.valid) {
|
|
961
1320
|
return {
|
|
@@ -963,9 +1322,11 @@ export async function restoreDeletedParagraphByExactText(
|
|
|
963
1322
|
hasChanges: false,
|
|
964
1323
|
status: 'error',
|
|
965
1324
|
error: {
|
|
966
|
-
code: 'PATCH_ROUNDTRIP_MISMATCH',
|
|
1325
|
+
code: oracle.code || 'PATCH_ROUNDTRIP_MISMATCH',
|
|
967
1326
|
message: oracle.message,
|
|
968
1327
|
stage: oracle.stage,
|
|
1328
|
+
...(oracle.generatedIssues ? { generatedIssues: oracle.generatedIssues } : {}),
|
|
1329
|
+
...(oracle.envelopeIssues ? { envelopeIssues: oracle.envelopeIssues } : {}),
|
|
969
1330
|
...(oracle.expected ? { expected: oracle.expected } : {}),
|
|
970
1331
|
...(oracle.actual ? { actual: oracle.actual } : {})
|
|
971
1332
|
}
|
|
@@ -1352,7 +1713,9 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
|
|
|
1352
1713
|
status: 'error',
|
|
1353
1714
|
error: {
|
|
1354
1715
|
code: 'EXISTING_REVISIONS',
|
|
1355
|
-
message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}).
|
|
1716
|
+
message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
|
|
1717
|
+
revisionAuthors: authors,
|
|
1718
|
+
currentPolicy: existingPolicy
|
|
1356
1719
|
}
|
|
1357
1720
|
};
|
|
1358
1721
|
}
|
|
@@ -1715,8 +2078,8 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
|
|
|
1715
2078
|
? explicitRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
|
|
1716
2079
|
: (inferredTableRangeParagraphs
|
|
1717
2080
|
? inferredTableRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
|
|
1718
|
-
: (currentParagraphText || targetText))
|
|
1719
|
-
);
|
|
2081
|
+
: (currentParagraphText || targetText))
|
|
2082
|
+
);
|
|
1720
2083
|
const scopedXml = useTableScope
|
|
1721
2084
|
? serializer.serializeToString(containingTable)
|
|
1722
2085
|
: (
|
|
@@ -42,6 +42,7 @@ export class DocumentOperationSession {
|
|
|
42
42
|
this.executionOrder = [];
|
|
43
43
|
this.authorsUsed = new Set();
|
|
44
44
|
this.captureTable = new Map();
|
|
45
|
+
this.sourceTargetRegistry = null;
|
|
45
46
|
this.nextCaptureParaId = 1;
|
|
46
47
|
this.receiptCollector = new ReceiptCollector();
|
|
47
48
|
|
|
@@ -111,6 +112,7 @@ export class DocumentOperationSession {
|
|
|
111
112
|
commentsXmlMode: this.commentsXmlMode,
|
|
112
113
|
commentsExtendedXmlMode: this.commentsExtendedXmlMode,
|
|
113
114
|
captureTable: cloneCaptureTable(this.captureTable),
|
|
115
|
+
sourceTargetRegistry: this.sourceTargetRegistry?.createSavepoint?.(this.document) || null,
|
|
114
116
|
nextCaptureParaId: this.nextCaptureParaId,
|
|
115
117
|
receiptCollector: this.receiptCollector ? this.receiptCollector.createSavepoint() : null
|
|
116
118
|
};
|
|
@@ -127,6 +129,7 @@ export class DocumentOperationSession {
|
|
|
127
129
|
this.commentsXmlMode = savepoint.commentsXmlMode || 'merge';
|
|
128
130
|
this.commentsExtendedXmlMode = savepoint.commentsExtendedXmlMode || 'merge';
|
|
129
131
|
this.captureTable = savepoint.captureTable ? cloneCaptureTable(savepoint.captureTable) : new Map();
|
|
132
|
+
this.sourceTargetRegistry?.restoreSavepoint?.(this.document, savepoint.sourceTargetRegistry);
|
|
130
133
|
if (typeof savepoint.nextCaptureParaId === 'number') {
|
|
131
134
|
this.nextCaptureParaId = savepoint.nextCaptureParaId;
|
|
132
135
|
}
|
|
@@ -160,6 +163,7 @@ export class DocumentOperationSession {
|
|
|
160
163
|
this.hasChanges = false;
|
|
161
164
|
this.documentHasChanges = false;
|
|
162
165
|
this.captureTable.clear();
|
|
166
|
+
this.sourceTargetRegistry = null;
|
|
163
167
|
this.receiptCollector?.clear();
|
|
164
168
|
return this.originalDocumentXml;
|
|
165
169
|
}
|