@ansonlai/docx-redline-js 0.8.2 → 0.8.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/CHANGELOG.md +75 -1
- package/README.md +28 -13
- package/core/list-targeting.js +12 -10
- package/dist/docx-redline-js.esm.js +837 -549
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +93 -90
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +837 -549
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +837 -549
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_KNOWLEDGE_BASE.md +4 -1
- package/docs/schemas/document-operations.schema.json +168 -168
- package/engine/oxml-engine.js +215 -195
- package/engine/reconstruction-mode.js +183 -183
- package/index.d.ts +4 -0
- package/index.js +36 -1
- package/node/cli.js +2 -1
- package/orchestration/list-structural-fallback.js +530 -518
- package/package.json +1 -1
- package/pipeline/list-generation.js +203 -168
- package/pipeline/serialization.js +111 -94
- package/services/batch-operation-orchestrator.js +6 -0
- package/services/document-inspection.js +10 -7
- package/services/document-operation-applier.js +2 -0
- package/services/document-operation-contract.js +553 -537
- package/services/document-operation-mutations.js +1140 -1011
- package/services/numbering-helpers.js +108 -0
- package/services/operation-batch-compiler.js +440 -436
- package/services/operation-heuristics.js +197 -173
- package/services/revision-comment-management.js +601 -594
- package/services/standalone-operation-runner.d.ts +429 -427
- package/services/table-reconciliation.js +48 -46
|
@@ -20,20 +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';
|
|
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';
|
|
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';
|
|
37
37
|
import { applyHighlightToOoxml } from '../engine/formatting-removal.js';
|
|
38
38
|
import { parseTable as parseMarkdownTable } from '../pipeline/pipeline.js';
|
|
39
39
|
import { injectCommentsIntoOoxml } from './comment-engine.js';
|
|
@@ -42,12 +42,13 @@ import {
|
|
|
42
42
|
getParagraphId,
|
|
43
43
|
createParagraphFingerprint,
|
|
44
44
|
isMarkdownTableText,
|
|
45
|
+
normalizeWhitespaceForTargeting,
|
|
45
46
|
findContainingWordElement,
|
|
46
|
-
resolveTargetParagraphWithSnapshot as resolveTargetParagraphWithSnapshotShared,
|
|
47
|
-
resolveParagraphRangeByRefs,
|
|
48
|
-
getDocumentParagraphNodes,
|
|
49
|
-
validateParagraphBoundaryMutation
|
|
50
|
-
} from '../core/paragraph-targeting.js';
|
|
47
|
+
resolveTargetParagraphWithSnapshot as resolveTargetParagraphWithSnapshotShared,
|
|
48
|
+
resolveParagraphRangeByRefs,
|
|
49
|
+
getDocumentParagraphNodes,
|
|
50
|
+
validateParagraphBoundaryMutation
|
|
51
|
+
} from '../core/paragraph-targeting.js';
|
|
51
52
|
import {
|
|
52
53
|
synthesizeExpandedListScopeEdit,
|
|
53
54
|
planListInsertionOnlyEdit,
|
|
@@ -70,6 +71,7 @@ import {
|
|
|
70
71
|
import {
|
|
71
72
|
reserveNextNumberingIdPair,
|
|
72
73
|
remapNumberingPayloadForDocument,
|
|
74
|
+
reuseSourceListNumbering,
|
|
73
75
|
overwriteParagraphNumIds,
|
|
74
76
|
extractFirstParagraphNumId,
|
|
75
77
|
buildExplicitDecimalMultilevelNumberingXml
|
|
@@ -81,17 +83,20 @@ import {
|
|
|
81
83
|
import { prepareRevisionAllocator } from './document-operation-session.js';
|
|
82
84
|
import {
|
|
83
85
|
buildExplicitRangeInsertionEntries,
|
|
86
|
+
deriveParagraphThenTableAppend,
|
|
84
87
|
deriveSingleParagraphListAdjacencyInsertion,
|
|
85
88
|
deriveSingleParagraphPlainAdjacencyInsertion
|
|
86
89
|
} from './operation-heuristics.js';
|
|
87
|
-
import {
|
|
88
|
-
import {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
90
|
+
import { generateTableOoxml } from './table-reconciliation.js';
|
|
91
|
+
import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
|
|
92
|
+
import { resolveTargetFromCapture, ensureParagraphIdsOnImportedNode } from './capture-engine.js';
|
|
93
|
+
import {
|
|
94
|
+
acceptTrackedChangesInOoxml,
|
|
95
|
+
rejectTrackedChangesInOoxml
|
|
96
|
+
} from './revision-comment-management.js';
|
|
97
|
+
|
|
98
|
+
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
99
|
+
const NS_W14 = 'http://schemas.microsoft.com/office/word/2010/wordml';
|
|
95
100
|
|
|
96
101
|
function getCommentIdsInElement(element) {
|
|
97
102
|
const ids = new Set();
|
|
@@ -169,111 +174,111 @@ async function reconcileMarkdownTableOoxml(oxml, originalText, markdownTable, op
|
|
|
169
174
|
};
|
|
170
175
|
}
|
|
171
176
|
|
|
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
|
-
}
|
|
226
|
-
|
|
227
|
-
function resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options) {
|
|
228
|
-
const revisionView = options?.targetDescriptor?.revisionView === 'rejected'
|
|
229
|
-
? 'rejected'
|
|
230
|
-
: 'accepted';
|
|
231
|
-
const viewMetadata = metadata?.revisionView === revisionView ? metadata : null;
|
|
232
|
-
const index = viewMetadata?.index ?? getDocumentParagraphNodes(xmlDoc).indexOf(paragraph) + 1;
|
|
233
|
-
const paragraphId = viewMetadata?.paragraphId ?? getParagraphId(paragraph);
|
|
234
|
-
const inTable = viewMetadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl');
|
|
235
|
-
const text = viewMetadata?.text ?? extractCanonicalParagraphText(paragraph, { revisionView });
|
|
236
|
-
const fingerprint = viewMetadata?.fingerprint ?? createParagraphFingerprint(paragraph, {
|
|
237
|
-
text,
|
|
238
|
-
index,
|
|
239
|
-
paragraphId,
|
|
240
|
-
inTable,
|
|
241
|
-
revisionView
|
|
242
|
-
});
|
|
243
|
-
return {
|
|
244
|
-
index,
|
|
245
|
-
paragraphId,
|
|
246
|
-
text,
|
|
247
|
-
fingerprint,
|
|
248
|
-
revisionView,
|
|
249
|
-
inTable,
|
|
250
|
-
targetTextMatch: describeTargetTextMatch(
|
|
251
|
-
text,
|
|
252
|
-
options?.targetDescriptor?.text ?? options?.targetDescriptor?.exactText ?? targetText
|
|
253
|
-
)
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
|
|
177
|
+
function getParagraphText(paragraph) {
|
|
178
|
+
return getParagraphTextFromOxml(paragraph);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function escapeInvisibleText(value, maxLength = 96) {
|
|
182
|
+
const text = String(value ?? '');
|
|
183
|
+
const bounded = text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
|
|
184
|
+
return bounded
|
|
185
|
+
.replace(/\\/g, '\\\\')
|
|
186
|
+
.replace(/\r/g, '\\r')
|
|
187
|
+
.replace(/\n/g, '\\n')
|
|
188
|
+
.replace(/\t/g, '\\t')
|
|
189
|
+
.replace(/\u00a0/g, '\\u00A0')
|
|
190
|
+
.replace(/\u202f/g, '\\u202F')
|
|
191
|
+
.replace(/\u2060/g, '\\u2060');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function codePointLabel(value, offset) {
|
|
195
|
+
if (offset >= value.length) return 'END';
|
|
196
|
+
return `U+${value.codePointAt(offset).toString(16).toUpperCase().padStart(4, '0')}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function describeTargetTextMatch(actualText, requestedText) {
|
|
200
|
+
const actual = String(actualText ?? '');
|
|
201
|
+
const requested = String(requestedText ?? '');
|
|
202
|
+
if (actual === requested) return { mode: 'exact' };
|
|
203
|
+
|
|
204
|
+
const differences = [];
|
|
205
|
+
const limit = Math.min(actual.length, requested.length);
|
|
206
|
+
for (let offset = 0; offset < limit && differences.length < 8; offset++) {
|
|
207
|
+
if (actual[offset] === requested[offset]) continue;
|
|
208
|
+
differences.push({
|
|
209
|
+
offset,
|
|
210
|
+
sourceCodePoint: codePointLabel(actual, offset),
|
|
211
|
+
requestedCodePoint: codePointLabel(requested, offset)
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
if (actual.length !== requested.length && differences.length < 8) {
|
|
215
|
+
differences.push({
|
|
216
|
+
offset: limit,
|
|
217
|
+
sourceCodePoint: codePointLabel(actual, limit),
|
|
218
|
+
requestedCodePoint: codePointLabel(requested, limit)
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const spaceEquivalent = actual.length === requested.length
|
|
223
|
+
&& actual.replace(/\u00a0/g, ' ') === requested.replace(/\u00a0/g, ' ');
|
|
224
|
+
return {
|
|
225
|
+
mode: spaceEquivalent ? 'space_equivalent' : 'normalized',
|
|
226
|
+
differences,
|
|
227
|
+
sourceExcerpt: escapeInvisibleText(actual),
|
|
228
|
+
requestedExcerpt: escapeInvisibleText(requested)
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options) {
|
|
233
|
+
const revisionView = options?.targetDescriptor?.revisionView === 'rejected'
|
|
234
|
+
? 'rejected'
|
|
235
|
+
: 'accepted';
|
|
236
|
+
const viewMetadata = metadata?.revisionView === revisionView ? metadata : null;
|
|
237
|
+
const index = viewMetadata?.index ?? getDocumentParagraphNodes(xmlDoc).indexOf(paragraph) + 1;
|
|
238
|
+
const paragraphId = viewMetadata?.paragraphId ?? getParagraphId(paragraph);
|
|
239
|
+
const inTable = viewMetadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl');
|
|
240
|
+
const text = viewMetadata?.text ?? extractCanonicalParagraphText(paragraph, { revisionView });
|
|
241
|
+
const fingerprint = viewMetadata?.fingerprint ?? createParagraphFingerprint(paragraph, {
|
|
242
|
+
text,
|
|
243
|
+
index,
|
|
244
|
+
paragraphId,
|
|
245
|
+
inTable,
|
|
246
|
+
revisionView
|
|
247
|
+
});
|
|
248
|
+
return {
|
|
249
|
+
index,
|
|
250
|
+
paragraphId,
|
|
251
|
+
text,
|
|
252
|
+
fingerprint,
|
|
253
|
+
revisionView,
|
|
254
|
+
inTable,
|
|
255
|
+
targetTextMatch: describeTargetTextMatch(
|
|
256
|
+
text,
|
|
257
|
+
options?.targetDescriptor?.text ?? options?.targetDescriptor?.exactText ?? targetText
|
|
258
|
+
)
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
|
|
258
263
|
const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
|
|
259
264
|
const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
|
|
260
265
|
const session = options?._documentOperationSession || null;
|
|
261
|
-
const paragraphMetadataIndex = session?.getParagraphMetadataIndex?.() || null;
|
|
262
|
-
|
|
263
|
-
if (options?._compiledSourceId && options?._sourceTargetRegistry) {
|
|
264
|
-
const resolved = options._sourceTargetRegistry.resolve(options._compiledSourceId, xmlDoc);
|
|
265
|
-
if (resolved?.error || !resolved?.paragraph) return resolved;
|
|
266
|
-
resolved.resolvedBy = options._compiledResolvedBy || resolved.resolvedBy;
|
|
267
|
-
if (options?._resolutionCapture) {
|
|
268
|
-
const paragraph = resolved.paragraph;
|
|
269
|
-
const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
|
|
270
|
-
Object.assign(options._resolutionCapture, {
|
|
271
|
-
resolvedBy: resolved.resolvedBy,
|
|
272
|
-
resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
return resolved;
|
|
276
|
-
}
|
|
266
|
+
const paragraphMetadataIndex = session?.getParagraphMetadataIndex?.() || null;
|
|
267
|
+
|
|
268
|
+
if (options?._compiledSourceId && options?._sourceTargetRegistry) {
|
|
269
|
+
const resolved = options._sourceTargetRegistry.resolve(options._compiledSourceId, xmlDoc);
|
|
270
|
+
if (resolved?.error || !resolved?.paragraph) return resolved;
|
|
271
|
+
resolved.resolvedBy = options._compiledResolvedBy || resolved.resolvedBy;
|
|
272
|
+
if (options?._resolutionCapture) {
|
|
273
|
+
const paragraph = resolved.paragraph;
|
|
274
|
+
const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
|
|
275
|
+
Object.assign(options._resolutionCapture, {
|
|
276
|
+
resolvedBy: resolved.resolvedBy,
|
|
277
|
+
resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return resolved;
|
|
281
|
+
}
|
|
277
282
|
|
|
278
283
|
if (options?.targetDescriptor?.captureRef) {
|
|
279
284
|
try {
|
|
@@ -281,10 +286,10 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
|
|
|
281
286
|
if (options?._resolutionCapture && resolved?.paragraph) {
|
|
282
287
|
const paragraph = resolved.paragraph;
|
|
283
288
|
const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
|
|
284
|
-
Object.assign(options._resolutionCapture, {
|
|
285
|
-
resolvedBy: resolved.resolvedBy,
|
|
286
|
-
resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
|
|
287
|
-
});
|
|
289
|
+
Object.assign(options._resolutionCapture, {
|
|
290
|
+
resolvedBy: resolved.resolvedBy,
|
|
291
|
+
resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
|
|
292
|
+
});
|
|
288
293
|
}
|
|
289
294
|
return resolved;
|
|
290
295
|
} catch (error) {
|
|
@@ -311,10 +316,10 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
|
|
|
311
316
|
if (options?._resolutionCapture && resolved?.paragraph) {
|
|
312
317
|
const paragraph = resolved.paragraph;
|
|
313
318
|
const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
|
|
314
|
-
Object.assign(options._resolutionCapture, {
|
|
315
|
-
resolvedBy: resolved.resolvedBy,
|
|
316
|
-
resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
|
|
317
|
-
});
|
|
319
|
+
Object.assign(options._resolutionCapture, {
|
|
320
|
+
resolvedBy: resolved.resolvedBy,
|
|
321
|
+
resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
|
|
322
|
+
});
|
|
318
323
|
}
|
|
319
324
|
return resolved;
|
|
320
325
|
}
|
|
@@ -351,7 +356,7 @@ function preprocessRedlineTargetParagraph(targetParagraph) {
|
|
|
351
356
|
removeProofErrNodes(targetParagraph);
|
|
352
357
|
}
|
|
353
358
|
|
|
354
|
-
function getDirectWordChild(element, localName) {
|
|
359
|
+
function getDirectWordChild(element, localName) {
|
|
355
360
|
if (!element) return null;
|
|
356
361
|
return Array.from(element.childNodes || []).find(
|
|
357
362
|
node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === localName
|
|
@@ -416,7 +421,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
|
|
|
416
421
|
|
|
417
422
|
const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
|
|
418
423
|
if (anchorPPr) {
|
|
419
|
-
paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
|
|
424
|
+
paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
|
|
420
425
|
}
|
|
421
426
|
ensureListProperties(xmlDoc, paragraph, entry.ilvl, entry.numId);
|
|
422
427
|
|
|
@@ -432,7 +437,7 @@ function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMeta
|
|
|
432
437
|
const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
|
|
433
438
|
const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
|
|
434
439
|
if (anchorRunPr) {
|
|
435
|
-
run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
|
|
440
|
+
run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
|
|
436
441
|
}
|
|
437
442
|
|
|
438
443
|
const textNode = createWordElement(xmlDoc, 'w:t');
|
|
@@ -505,21 +510,21 @@ function applyExplicitRangeListInsertions({
|
|
|
505
510
|
normalizeBodySectionOrder(xmlDoc);
|
|
506
511
|
return true;
|
|
507
512
|
}
|
|
508
|
-
function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, author, options = {}) {
|
|
513
|
+
function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, author, options = {}) {
|
|
509
514
|
const generateRedlines = options.generateRedlines !== false;
|
|
510
515
|
const paragraph = createWordElement(xmlDoc, 'w:p');
|
|
511
516
|
const run = createWordElement(xmlDoc, 'w:r');
|
|
512
517
|
const textNode = createWordElement(xmlDoc, 'w:t');
|
|
513
518
|
const safeText = String(text || '');
|
|
514
519
|
if (/^\s|\s$/.test(safeText)) textNode.setAttribute('xml:space', 'preserve');
|
|
515
|
-
textNode.textContent = safeText;
|
|
516
|
-
run.appendChild(textNode);
|
|
517
|
-
|
|
518
|
-
if (generateRedlines) {
|
|
519
|
-
if (options.trackParagraphMark === true) {
|
|
520
|
-
markParagraphMarkInserted(xmlDoc, paragraph, author);
|
|
521
|
-
}
|
|
522
|
-
const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, 'ins');
|
|
520
|
+
textNode.textContent = safeText;
|
|
521
|
+
run.appendChild(textNode);
|
|
522
|
+
|
|
523
|
+
if (generateRedlines) {
|
|
524
|
+
if (options.trackParagraphMark === true) {
|
|
525
|
+
markParagraphMarkInserted(xmlDoc, paragraph, author);
|
|
526
|
+
}
|
|
527
|
+
const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, 'ins');
|
|
523
528
|
const ins = createWordElement(xmlDoc, 'w:ins');
|
|
524
529
|
ins.setAttribute('w:id', String(metadata.id));
|
|
525
530
|
ins.setAttribute('w:author', metadata.author);
|
|
@@ -536,12 +541,12 @@ function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, aut
|
|
|
536
541
|
function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
|
|
537
542
|
const paragraph = createWordElement(xmlDoc, 'w:p');
|
|
538
543
|
const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
|
|
539
|
-
if (anchorPPr) paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
|
|
544
|
+
if (anchorPPr) paragraph.appendChild(clonePropertiesWithoutRevisionHistory(anchorPPr));
|
|
540
545
|
|
|
541
546
|
const run = createWordElement(xmlDoc, 'w:r');
|
|
542
547
|
const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
|
|
543
548
|
const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
|
|
544
|
-
if (anchorRunPr) run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
|
|
549
|
+
if (anchorRunPr) run.appendChild(clonePropertiesWithoutRevisionHistory(anchorRunPr));
|
|
545
550
|
|
|
546
551
|
const textNode = createWordElement(xmlDoc, 'w:t');
|
|
547
552
|
textNode.textContent = '';
|
|
@@ -550,19 +555,19 @@ function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
|
|
|
550
555
|
return paragraph;
|
|
551
556
|
}
|
|
552
557
|
|
|
553
|
-
function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, author, options = {}) {
|
|
554
|
-
const wrappedParagraph = createWordElement(xmlDoc, 'w:p');
|
|
555
|
-
const pPr = options.sanitizeParagraphProperties === true
|
|
556
|
-
? createSanitizedRestorationPPr(xmlDoc, paragraph)
|
|
557
|
-
: clonePropertiesWithoutRevisionHistory(getDirectWordChild(paragraph, 'pPr'));
|
|
558
|
-
if (pPr) wrappedParagraph.appendChild(pPr);
|
|
559
|
-
if (options.paragraphId) wrappedParagraph.setAttributeNS(NS_W14, 'w14:paraId', options.paragraphId);
|
|
560
|
-
if (options.trackParagraphMark === true) {
|
|
561
|
-
markParagraphMarkInserted(xmlDoc, wrappedParagraph, author);
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
const ins = createWordElement(xmlDoc, 'w:ins');
|
|
565
|
-
const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, 'ins');
|
|
558
|
+
function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, author, options = {}) {
|
|
559
|
+
const wrappedParagraph = createWordElement(xmlDoc, 'w:p');
|
|
560
|
+
const pPr = options.sanitizeParagraphProperties === true
|
|
561
|
+
? createSanitizedRestorationPPr(xmlDoc, paragraph)
|
|
562
|
+
: clonePropertiesWithoutRevisionHistory(getDirectWordChild(paragraph, 'pPr'));
|
|
563
|
+
if (pPr) wrappedParagraph.appendChild(pPr);
|
|
564
|
+
if (options.paragraphId) wrappedParagraph.setAttributeNS(NS_W14, 'w14:paraId', options.paragraphId);
|
|
565
|
+
if (options.trackParagraphMark === true) {
|
|
566
|
+
markParagraphMarkInserted(xmlDoc, wrappedParagraph, author);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const ins = createWordElement(xmlDoc, 'w:ins');
|
|
570
|
+
const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, 'ins');
|
|
566
571
|
ins.setAttribute('w:id', String(metadata.id));
|
|
567
572
|
ins.setAttribute('w:author', metadata.author);
|
|
568
573
|
ins.setAttribute('w:date', metadata.date);
|
|
@@ -603,9 +608,9 @@ async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisi
|
|
|
603
608
|
return buildFallbackInsertedPlainParagraph(
|
|
604
609
|
xmlDoc,
|
|
605
610
|
text,
|
|
606
|
-
revisionMetadata,
|
|
607
|
-
author,
|
|
608
|
-
{ ...options, generateRedlines }
|
|
611
|
+
revisionMetadata,
|
|
612
|
+
author,
|
|
613
|
+
{ ...options, generateRedlines }
|
|
609
614
|
);
|
|
610
615
|
}
|
|
611
616
|
|
|
@@ -613,10 +618,10 @@ async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisi
|
|
|
613
618
|
return sourceParagraph;
|
|
614
619
|
}
|
|
615
620
|
|
|
616
|
-
return wrapParagraphContentInInsertion(xmlDoc, sourceParagraph, revisionMetadata, author, options);
|
|
621
|
+
return wrapParagraphContentInInsertion(xmlDoc, sourceParagraph, revisionMetadata, author, options);
|
|
617
622
|
}
|
|
618
623
|
|
|
619
|
-
function collectNumberingIdsFromNodes(nodes) {
|
|
624
|
+
function collectNumberingIdsFromNodes(nodes) {
|
|
620
625
|
const ids = new Set();
|
|
621
626
|
for (const node of nodes || []) {
|
|
622
627
|
const numIdNodes = Array.from(node?.getElementsByTagNameNS?.('*', 'numId') || []);
|
|
@@ -628,719 +633,719 @@ function collectNumberingIdsFromNodes(nodes) {
|
|
|
628
633
|
}
|
|
629
634
|
}
|
|
630
635
|
return Array.from(ids);
|
|
631
|
-
}
|
|
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
|
-
|
|
698
|
-
const RESTORATION_PPR_ALLOWLIST = new Set([
|
|
699
|
-
'pStyle', 'numPr', 'ind', 'jc', 'spacing', 'tabs',
|
|
700
|
-
'keepNext', 'keepLines', 'outlineLvl', 'contextualSpacing'
|
|
701
|
-
]);
|
|
702
|
-
|
|
703
|
-
function wordAttribute(node, localName) {
|
|
704
|
-
return node?.getAttribute?.(`w:${localName}`) || node?.getAttribute?.(localName) || '';
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
function normalizedAuthor(author) {
|
|
708
|
-
return String(author || '').trim().toLowerCase();
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
function directWordParagraphSibling(paragraph, direction = 'nextSibling') {
|
|
712
|
-
let cursor = paragraph?.[direction] || null;
|
|
713
|
-
while (cursor) {
|
|
714
|
-
if (cursor.nodeType === 1 && cursor.namespaceURI === NS_W && cursor.localName === 'p') return cursor;
|
|
715
|
-
cursor = cursor[direction] || null;
|
|
716
|
-
}
|
|
717
|
-
return null;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
function createSanitizedRestorationPPr(xmlDoc, sourceParagraph) {
|
|
721
|
-
const sourcePPr = getDirectWordChild(sourceParagraph, 'pPr');
|
|
722
|
-
const pPr = createWordElement(xmlDoc, 'w:pPr');
|
|
723
|
-
if (!sourcePPr) return pPr;
|
|
724
|
-
for (const child of Array.from(sourcePPr.childNodes || [])) {
|
|
725
|
-
if (child.nodeType !== 1 || child.namespaceURI !== NS_W) continue;
|
|
726
|
-
if (!RESTORATION_PPR_ALLOWLIST.has(child.localName)) continue;
|
|
727
|
-
pPr.appendChild(xmlDoc.importNode(child, true));
|
|
728
|
-
}
|
|
729
|
-
return pPr;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
function collectRestorationAnchorWarnings(sourceParagraph) {
|
|
733
|
-
const warnings = [];
|
|
734
|
-
const bookmarkNames = new Set();
|
|
735
|
-
for (const node of Array.from(sourceParagraph?.getElementsByTagNameNS?.(NS_W, 'bookmarkStart') || [])) {
|
|
736
|
-
const name = wordAttribute(node, 'name') || '(unnamed)';
|
|
737
|
-
bookmarkNames.add(name);
|
|
738
|
-
}
|
|
739
|
-
for (const name of bookmarkNames) warnings.push(`RESTORATION_DROPPED_BOOKMARK:${name}`);
|
|
740
|
-
|
|
741
|
-
const commentIds = new Set();
|
|
742
|
-
for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
|
|
743
|
-
for (const node of Array.from(sourceParagraph?.getElementsByTagNameNS?.(NS_W, localName) || [])) {
|
|
744
|
-
const id = wordAttribute(node, 'id');
|
|
745
|
-
if (id !== '') commentIds.add(id);
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
for (const id of commentIds) warnings.push(`RESTORATION_DROPPED_COMMENT:${id}`);
|
|
749
|
-
return warnings;
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
function removeRestorationAnchors(paragraph) {
|
|
753
|
-
const anchorNames = [
|
|
754
|
-
'bookmarkStart', 'bookmarkEnd',
|
|
755
|
-
'commentRangeStart', 'commentRangeEnd', 'commentReference'
|
|
756
|
-
];
|
|
757
|
-
for (const localName of anchorNames) {
|
|
758
|
-
for (const node of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, localName) || [])) {
|
|
759
|
-
node.parentNode?.removeChild(node);
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
for (const run of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'r') || [])) {
|
|
763
|
-
const meaningful = Array.from(run.childNodes || []).some(child => (
|
|
764
|
-
child.nodeType === 1 && child.namespaceURI === NS_W && child.localName !== 'rPr'
|
|
765
|
-
));
|
|
766
|
-
if (!meaningful) run.parentNode?.removeChild(run);
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
function buildRejectedRestorationTemplate(xmlDoc, sourceParagraph, serializer) {
|
|
771
|
-
const rejected = rejectTrackedChangesInOoxml(serializer.serializeToString(sourceParagraph), { allAuthors: true });
|
|
772
|
-
const parsed = parseOoxmlSafe(rejected.oxml, 'application/xml');
|
|
773
|
-
const rejectedParagraph = parsed.doc?.getElementsByTagNameNS?.(NS_W, 'p')?.[0] || null;
|
|
774
|
-
if (!rejectedParagraph) return null;
|
|
775
|
-
|
|
776
|
-
const paragraph = createWordElement(xmlDoc, 'w:p');
|
|
777
|
-
paragraph.appendChild(createSanitizedRestorationPPr(xmlDoc, sourceParagraph));
|
|
778
|
-
for (const child of Array.from(rejectedParagraph.childNodes || [])) {
|
|
779
|
-
if (child.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'pPr') continue;
|
|
780
|
-
paragraph.appendChild(xmlDoc.importNode(child, true));
|
|
781
|
-
}
|
|
782
|
-
removeRestorationAnchors(paragraph);
|
|
783
|
-
return paragraph;
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
async function editRestorationTemplate(xmlDoc, template, modifiedText, author, serializer) {
|
|
787
|
-
const originalText = extractCanonicalParagraphText(template);
|
|
788
|
-
if (originalText === modifiedText) return template;
|
|
789
|
-
const result = await applyRedlineToOxml(
|
|
790
|
-
serializer.serializeToString(template),
|
|
791
|
-
originalText,
|
|
792
|
-
modifiedText,
|
|
793
|
-
{
|
|
794
|
-
author,
|
|
795
|
-
generateRedlines: false,
|
|
796
|
-
structuredContent: false
|
|
797
|
-
}
|
|
798
|
-
);
|
|
799
|
-
if (result?.status === 'error' || typeof result?.oxml !== 'string') return null;
|
|
800
|
-
const extracted = extractReplacementNodes(result.oxml);
|
|
801
|
-
const paragraph = (extracted.replacementNodes || []).find(node => (
|
|
802
|
-
node?.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
|
|
803
|
-
));
|
|
804
|
-
return paragraph ? xmlDoc.importNode(paragraph, true) : null;
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
function allocateFreshParagraphId(xmlDoc, operationSession) {
|
|
808
|
-
const used = new Set(getDocumentParagraphNodes(xmlDoc)
|
|
809
|
-
.map(paragraph => paragraph.getAttributeNS?.(NS_W14, 'paraId') || paragraph.getAttribute?.('w14:paraId') || '')
|
|
810
|
-
.filter(Boolean)
|
|
811
|
-
.map(value => value.toUpperCase()));
|
|
812
|
-
let candidate = null;
|
|
813
|
-
do {
|
|
814
|
-
candidate = operationSession?.generateParagraphId?.()
|
|
815
|
-
|| (0x40000000 + used.size + 1).toString(16).toUpperCase();
|
|
816
|
-
} while (used.has(candidate.toUpperCase()));
|
|
817
|
-
return candidate;
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
function trackRestoredParagraph(xmlDoc, paragraph, author, operationSession) {
|
|
821
|
-
const root = xmlDoc.documentElement;
|
|
822
|
-
if (root && !root.getAttribute('xmlns:w14')) root.setAttribute('xmlns:w14', NS_W14);
|
|
823
|
-
return wrapParagraphContentInInsertion(xmlDoc, paragraph, null, author, {
|
|
824
|
-
paragraphId: allocateFreshParagraphId(xmlDoc, operationSession),
|
|
825
|
-
sanitizeParagraphProperties: true,
|
|
826
|
-
trackParagraphMark: true
|
|
827
|
-
});
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
function isInsertedParagraphByAuthor(paragraph, author) {
|
|
831
|
-
const pPr = getDirectWordChild(paragraph, 'pPr');
|
|
832
|
-
const rPr = getDirectWordChild(pPr, 'rPr');
|
|
833
|
-
const marker = getDirectWordChild(rPr, 'ins');
|
|
834
|
-
return !!marker && normalizedAuthor(wordAttribute(marker, 'author')) === normalizedAuthor(author);
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
function followingParagraphBlock(lastSource, count) {
|
|
838
|
-
const paragraphs = [];
|
|
839
|
-
let cursor = lastSource;
|
|
840
|
-
for (let i = 0; i < count; i++) {
|
|
841
|
-
cursor = directWordParagraphSibling(cursor, 'nextSibling');
|
|
842
|
-
if (!cursor) return [];
|
|
843
|
-
paragraphs.push(cursor);
|
|
844
|
-
}
|
|
845
|
-
return paragraphs;
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
function paragraphTextVector(oxml) {
|
|
849
|
-
const parsed = parseOoxmlSafe(oxml, 'application/xml');
|
|
850
|
-
if (parsed.error || !parsed.doc) return null;
|
|
851
|
-
return getDocumentParagraphNodes(parsed.doc).map(paragraph => extractCanonicalParagraphText(paragraph));
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
function sameTextVector(left, right) {
|
|
855
|
-
return Array.isArray(left) && Array.isArray(right)
|
|
856
|
-
&& left.length === right.length
|
|
857
|
-
&& left.every((value, index) => value === right[index]);
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
function lifecycleTextVector(oxml, action) {
|
|
861
|
-
const result = action === 'accept'
|
|
862
|
-
? acceptTrackedChangesInOoxml(oxml, { allAuthors: true })
|
|
863
|
-
: rejectTrackedChangesInOoxml(oxml, { allAuthors: true });
|
|
864
|
-
return paragraphTextVector(result.oxml);
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
function buildExpectedRestorationDocument(beforeXml, sourceStartIndex, existingIndexes, templates) {
|
|
868
|
-
const parsed = parseOoxmlSafe(beforeXml, 'application/xml');
|
|
869
|
-
if (parsed.error || !parsed.doc) return null;
|
|
870
|
-
const expectedDoc = parsed.doc;
|
|
871
|
-
const originalParagraphs = getDocumentParagraphNodes(expectedDoc);
|
|
872
|
-
const lastSource = originalParagraphs[sourceStartIndex + templates.length - 1] || null;
|
|
873
|
-
if (!lastSource?.parentNode) return null;
|
|
874
|
-
|
|
875
|
-
for (const index of [...existingIndexes].sort((a, b) => b - a)) {
|
|
876
|
-
const paragraph = originalParagraphs[index];
|
|
877
|
-
paragraph?.parentNode?.removeChild(paragraph);
|
|
878
|
-
}
|
|
879
|
-
const insertionPoint = lastSource.nextSibling;
|
|
880
|
-
for (const template of templates) {
|
|
881
|
-
lastSource.parentNode.insertBefore(expectedDoc.importNode(template, true), insertionPoint);
|
|
882
|
-
}
|
|
883
|
-
return createSerializer().serializeToString(expectedDoc);
|
|
884
|
-
}
|
|
885
|
-
|
|
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) {
|
|
896
|
-
return {
|
|
897
|
-
valid: false,
|
|
898
|
-
stage: 'validation',
|
|
899
|
-
code: 'GENERATED_OOXML_INVALID',
|
|
900
|
-
generatedIssues,
|
|
901
|
-
envelopeIssues,
|
|
902
|
-
message: [...generatedErrors, ...envelopeIssues].map(issue => issue.message).join(' ')
|
|
903
|
-
};
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
const expectedXml = buildExpectedRestorationDocument(beforeXml, sourceStartIndex, existingIndexes, templates);
|
|
907
|
-
if (!expectedXml) return { valid: false, stage: 'expected-document', message: 'Could not construct the restoration lifecycle oracle.' };
|
|
908
|
-
|
|
909
|
-
const comparisons = [
|
|
910
|
-
['current', paragraphTextVector(expectedXml), paragraphTextVector(outputXml)],
|
|
911
|
-
['accept-all', lifecycleTextVector(expectedXml, 'accept'), lifecycleTextVector(outputXml, 'accept')],
|
|
912
|
-
['reject-all', lifecycleTextVector(beforeXml, 'reject'), lifecycleTextVector(outputXml, 'reject')]
|
|
913
|
-
];
|
|
914
|
-
for (const [stage, expected, actual] of comparisons) {
|
|
915
|
-
if (!sameTextVector(expected, actual)) {
|
|
916
|
-
return {
|
|
917
|
-
valid: false,
|
|
918
|
-
stage,
|
|
919
|
-
expected,
|
|
920
|
-
actual,
|
|
921
|
-
message: `Paragraph restoration ${stage} lifecycle text did not match the expected body-scoped paragraph sequence.`
|
|
922
|
-
};
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
return { valid: true };
|
|
926
|
-
}
|
|
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
|
-
|
|
1098
|
-
/**
|
|
1099
|
-
* Materializes an explicit counterproposal for one or more paragraphs wholly
|
|
1100
|
-
* deleted (content and paragraph mark) by another author.
|
|
1101
|
-
*/
|
|
1102
|
-
export async function restoreDeletedParagraphByExactText(
|
|
1103
|
-
documentXml,
|
|
1104
|
-
targetText,
|
|
1105
|
-
modified,
|
|
1106
|
-
author,
|
|
1107
|
-
targetRef = null,
|
|
1108
|
-
targetEndRef = null,
|
|
1109
|
-
runtimeContext = null,
|
|
1110
|
-
options = {}
|
|
1111
|
-
) {
|
|
1112
|
-
const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
|
|
1113
|
-
if (!xmlDoc) {
|
|
1114
|
-
return {
|
|
1115
|
-
documentXml,
|
|
1116
|
-
hasChanges: false,
|
|
1117
|
-
status: 'error',
|
|
1118
|
-
error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
|
|
1119
|
-
};
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'restore', runtimeContext, options);
|
|
1123
|
-
if (resolved?.error || !resolved?.paragraph) {
|
|
1124
|
-
return {
|
|
1125
|
-
documentXml,
|
|
1126
|
-
hasChanges: false,
|
|
1127
|
-
status: 'error',
|
|
1128
|
-
error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Restoration target paragraph not found.' }
|
|
1129
|
-
};
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
const firstSource = resolved.paragraph;
|
|
1133
|
-
let sourceParagraphs = [firstSource];
|
|
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) {
|
|
1150
|
-
sourceParagraphs = resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
|
|
1151
|
-
opType: 'restore',
|
|
1152
|
-
targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
|
|
1153
|
-
onInfo: options.onInfo,
|
|
1154
|
-
onWarn: options.onWarn
|
|
1155
|
-
});
|
|
1156
|
-
} else if (options.targetEndDescriptor) {
|
|
1157
|
-
const endResolved = resolveTargetParagraph(
|
|
1158
|
-
xmlDoc,
|
|
1159
|
-
options.targetEndDescriptor.text,
|
|
1160
|
-
options.targetEndDescriptor.index,
|
|
1161
|
-
'restore',
|
|
1162
|
-
runtimeContext,
|
|
1163
|
-
{
|
|
1164
|
-
...options,
|
|
1165
|
-
targetDescriptor: options.targetEndDescriptor,
|
|
1166
|
-
_compiledSourceId: options._compiledSourceEndId || null
|
|
1167
|
-
}
|
|
1168
|
-
);
|
|
1169
|
-
const allParagraphs = endResolved?.paragraph ? getDocumentParagraphNodes(xmlDoc) : [];
|
|
1170
|
-
const startIndex = allParagraphs.indexOf(firstSource);
|
|
1171
|
-
const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
|
|
1172
|
-
sourceParagraphs = startIndex >= 0 && endIndex >= startIndex
|
|
1173
|
-
? allParagraphs.slice(startIndex, endIndex + 1)
|
|
1174
|
-
: null;
|
|
1175
|
-
}
|
|
1176
|
-
if (!Array.isArray(sourceParagraphs) || sourceParagraphs.length === 0 || sourceParagraphs[0] !== firstSource) {
|
|
1177
|
-
return {
|
|
1178
|
-
documentXml,
|
|
1179
|
-
hasChanges: false,
|
|
1180
|
-
status: 'error',
|
|
1181
|
-
error: {
|
|
1182
|
-
code: 'RESTORATION_COUNT_MISMATCH',
|
|
1183
|
-
message: 'The restoration range could not be resolved as a contiguous paragraph block.'
|
|
1184
|
-
}
|
|
1185
|
-
};
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
const replacements = Array.isArray(modified) ? modified.map(String) : [String(modified || '')];
|
|
1189
|
-
if (replacements.length !== sourceParagraphs.length || replacements.some(text => text.length === 0)) {
|
|
1190
|
-
return {
|
|
1191
|
-
documentXml,
|
|
1192
|
-
hasChanges: false,
|
|
1193
|
-
status: 'error',
|
|
1194
|
-
error: {
|
|
1195
|
-
code: 'RESTORATION_COUNT_MISMATCH',
|
|
1196
|
-
message: `Restoration requires exactly one non-empty replacement per source paragraph (${sourceParagraphs.length} expected, ${replacements.length} received).`
|
|
1197
|
-
}
|
|
1198
|
-
};
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
const parent = firstSource.parentNode;
|
|
1202
|
-
if (!parent || sourceParagraphs.some(paragraph => paragraph.parentNode !== parent)) {
|
|
1203
|
-
return {
|
|
1204
|
-
documentXml,
|
|
1205
|
-
hasChanges: false,
|
|
1206
|
-
status: 'error',
|
|
1207
|
-
error: {
|
|
1208
|
-
code: 'UNSAFE_PARAGRAPH_PLACEMENT',
|
|
1209
|
-
message: 'Restoration source paragraphs must be a contiguous block in one structural container.'
|
|
1210
|
-
}
|
|
1211
|
-
};
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
for (const paragraph of sourceParagraphs) {
|
|
1215
|
-
const structuralRefusal = getParagraphRestorationRefusal(paragraph);
|
|
1216
|
-
if (structuralRefusal?.code === 'UNSUPPORTED_MOVE_REVISION') {
|
|
1217
|
-
return {
|
|
1218
|
-
documentXml,
|
|
1219
|
-
hasChanges: false,
|
|
1220
|
-
status: 'error',
|
|
1221
|
-
error: structuralRefusal
|
|
1222
|
-
};
|
|
1223
|
-
}
|
|
1224
|
-
const state = inspectForeignDeletedParagraphTarget(paragraph, author);
|
|
1225
|
-
if (!state.matches) {
|
|
1226
|
-
return {
|
|
1227
|
-
documentXml,
|
|
1228
|
-
hasChanges: false,
|
|
1229
|
-
status: 'error',
|
|
1230
|
-
error: {
|
|
1231
|
-
code: 'RESTORATION_STATE_REQUIRED',
|
|
1232
|
-
message: state.hasParagraphMarkDeletion && !state.foreignParagraphMarkDeletion
|
|
1233
|
-
? 'Explicit cross-author restoration does not apply to a paragraph deleted by the current author; use merge-same-author.'
|
|
1234
|
-
: 'Explicit restoration requires a foreign paragraph-mark deletion with every pre-existing content node deleted.',
|
|
1235
|
-
...(state.ownerAuthor ? { ownerAuthor: state.ownerAuthor } : {})
|
|
1236
|
-
}
|
|
1237
|
-
};
|
|
1238
|
-
}
|
|
1239
|
-
const refusal = structuralRefusal || getParagraphRestorationRefusal(paragraph);
|
|
1240
|
-
if (refusal) {
|
|
1241
|
-
return {
|
|
1242
|
-
documentXml,
|
|
1243
|
-
hasChanges: false,
|
|
1244
|
-
status: 'error',
|
|
1245
|
-
error: refusal
|
|
1246
|
-
};
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
const lastSource = sourceParagraphs[sourceParagraphs.length - 1];
|
|
1251
|
-
const existingBlock = followingParagraphBlock(lastSource, sourceParagraphs.length);
|
|
1252
|
-
const hasExistingRestoration = existingBlock.length === sourceParagraphs.length
|
|
1253
|
-
&& existingBlock.every(paragraph => isInsertedParagraphByAuthor(paragraph, author));
|
|
1254
|
-
if (
|
|
1255
|
-
hasExistingRestoration
|
|
1256
|
-
&& existingBlock.every((paragraph, index) => extractCanonicalParagraphText(paragraph) === replacements[index])
|
|
1257
|
-
) {
|
|
1258
|
-
return {
|
|
1259
|
-
documentXml,
|
|
1260
|
-
hasChanges: false,
|
|
1261
|
-
status: 'no-op',
|
|
1262
|
-
warnings: ['An identical same-author paragraph restoration already exists.']
|
|
1263
|
-
};
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
const beforeXml = serializer.serializeToString(xmlDoc);
|
|
1267
|
-
const beforeParagraphs = getDocumentParagraphNodes(xmlDoc);
|
|
1268
|
-
const sourceStartIndex = beforeParagraphs.indexOf(firstSource);
|
|
1269
|
-
const existingIndexes = hasExistingRestoration
|
|
1270
|
-
? existingBlock.map(paragraph => beforeParagraphs.indexOf(paragraph))
|
|
1271
|
-
: [];
|
|
1272
|
-
const templates = [];
|
|
1273
|
-
const warnings = [];
|
|
1274
|
-
for (let index = 0; index < sourceParagraphs.length; index++) {
|
|
1275
|
-
const source = sourceParagraphs[index];
|
|
1276
|
-
warnings.push(...collectRestorationAnchorWarnings(source));
|
|
1277
|
-
const template = buildRejectedRestorationTemplate(xmlDoc, source, serializer);
|
|
1278
|
-
const edited = template
|
|
1279
|
-
? await editRestorationTemplate(xmlDoc, template, replacements[index], author, serializer)
|
|
1280
|
-
: null;
|
|
1281
|
-
if (!edited) {
|
|
1282
|
-
return {
|
|
1283
|
-
documentXml,
|
|
1284
|
-
hasChanges: false,
|
|
1285
|
-
status: 'error',
|
|
1286
|
-
error: {
|
|
1287
|
-
code: 'PATCH_ROUNDTRIP_MISMATCH',
|
|
1288
|
-
message: `Could not reconstruct restoration paragraph ${index + 1} from its rejected revision view.`
|
|
1289
|
-
}
|
|
1290
|
-
};
|
|
1291
|
-
}
|
|
1292
|
-
templates.push(edited);
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
const insertedParagraphs = templates.map(template => {
|
|
1296
|
-
const paragraph = xmlDoc.importNode(template, true);
|
|
1297
|
-
return trackRestoredParagraph(xmlDoc, paragraph, author, operationSession);
|
|
1298
|
-
});
|
|
1299
|
-
const insertionPoint = lastSource.nextSibling;
|
|
1300
|
-
for (const paragraph of insertedParagraphs) parent.insertBefore(paragraph, insertionPoint);
|
|
1301
|
-
if (hasExistingRestoration) {
|
|
1302
|
-
for (const paragraph of existingBlock) {
|
|
1303
|
-
options?._mutationRemovedNodes?.push(paragraph);
|
|
1304
|
-
paragraph.parentNode?.removeChild(paragraph);
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
options?._mutationLiveNodes?.push(...insertedParagraphs);
|
|
1308
|
-
normalizeBodySectionOrder(xmlDoc);
|
|
1309
|
-
|
|
1310
|
-
const outputXml = serializer.serializeToString(xmlDoc);
|
|
1311
|
-
const oracle = verifyParagraphRestorationLifecycle(
|
|
1312
|
-
beforeXml,
|
|
1313
|
-
outputXml,
|
|
1314
|
-
sourceStartIndex,
|
|
1315
|
-
existingIndexes,
|
|
1316
|
-
templates,
|
|
1317
|
-
insertedParagraphs
|
|
1318
|
-
);
|
|
1319
|
-
if (!oracle.valid) {
|
|
1320
|
-
return {
|
|
1321
|
-
documentXml,
|
|
1322
|
-
hasChanges: false,
|
|
1323
|
-
status: 'error',
|
|
1324
|
-
error: {
|
|
1325
|
-
code: oracle.code || 'PATCH_ROUNDTRIP_MISMATCH',
|
|
1326
|
-
message: oracle.message,
|
|
1327
|
-
stage: oracle.stage,
|
|
1328
|
-
...(oracle.generatedIssues ? { generatedIssues: oracle.generatedIssues } : {}),
|
|
1329
|
-
...(oracle.envelopeIssues ? { envelopeIssues: oracle.envelopeIssues } : {}),
|
|
1330
|
-
...(oracle.expected ? { expected: oracle.expected } : {}),
|
|
1331
|
-
...(oracle.actual ? { actual: oracle.actual } : {})
|
|
1332
|
-
}
|
|
1333
|
-
};
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
return {
|
|
1337
|
-
documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
|
|
1338
|
-
hasChanges: true,
|
|
1339
|
-
status: 'ok',
|
|
1340
|
-
numberingXml: null,
|
|
1341
|
-
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
|
|
1342
|
-
};
|
|
1343
|
-
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function directDeletionCarrierText(carrier) {
|
|
639
|
+
return Array.from(carrier?.childNodes || []).reduce((text, child) => {
|
|
640
|
+
return text + (child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'r'
|
|
641
|
+
? getRunContentPieces(child).map(piece => piece.text).join('')
|
|
642
|
+
: '');
|
|
643
|
+
}, '');
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function findOccurrenceOffset(text, needle, occurrence) {
|
|
647
|
+
let from = 0;
|
|
648
|
+
let found = -1;
|
|
649
|
+
for (let index = 0; index < occurrence; index++) {
|
|
650
|
+
found = text.indexOf(needle, from);
|
|
651
|
+
if (found < 0) return -1;
|
|
652
|
+
from = found + Math.max(needle.length, 1);
|
|
653
|
+
}
|
|
654
|
+
return found;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function countNonOverlappingOccurrences(text, needle) {
|
|
658
|
+
if (!needle) return 0;
|
|
659
|
+
let count = 0;
|
|
660
|
+
let from = 0;
|
|
661
|
+
while (from <= text.length) {
|
|
662
|
+
const found = text.indexOf(needle, from);
|
|
663
|
+
if (found < 0) break;
|
|
664
|
+
count += 1;
|
|
665
|
+
from = found + needle.length;
|
|
666
|
+
}
|
|
667
|
+
return count;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function hasUnsupportedDeletionSplitMarkup(paragraph, carrier) {
|
|
671
|
+
const unsafeParagraphNames = new Set([
|
|
672
|
+
'commentRangeStart', 'commentRangeEnd', 'commentReference',
|
|
673
|
+
'bookmarkStart', 'bookmarkEnd', 'moveFromRangeStart', 'moveFromRangeEnd',
|
|
674
|
+
'moveToRangeStart', 'moveToRangeEnd'
|
|
675
|
+
]);
|
|
676
|
+
if (Array.from(paragraph.getElementsByTagName?.('*') || []).some(node => unsafeParagraphNames.has(node.localName))) {
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
for (const child of Array.from(carrier?.childNodes || [])) {
|
|
680
|
+
if (child.nodeType !== 1) continue;
|
|
681
|
+
if (!(child.namespaceURI === NS_W && child.localName === 'r')) return true;
|
|
682
|
+
for (const runChild of Array.from(child.childNodes || [])) {
|
|
683
|
+
if (runChild.nodeType !== 1 || runChild.namespaceURI !== NS_W) continue;
|
|
684
|
+
if (!['rPr', 'delText', 't', 'tab', 'br', 'cr', 'noBreakHyphen', 'softHyphen'].includes(runChild.localName)) return true;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function insertedRunPropertiesAtDeletionOffset(carrier, localOffset) {
|
|
691
|
+
let offset = 0;
|
|
692
|
+
for (const child of Array.from(carrier?.childNodes || [])) {
|
|
693
|
+
if (!(child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'r')) continue;
|
|
694
|
+
const length = getRunTextLength(getRunContentPieces(child));
|
|
695
|
+
if (localOffset <= offset + length) {
|
|
696
|
+
return clonePropertiesWithoutRevisionHistory(getDirectWordChild(child, 'rPr'));
|
|
697
|
+
}
|
|
698
|
+
offset += length;
|
|
699
|
+
}
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const RESTORATION_PPR_ALLOWLIST = new Set([
|
|
704
|
+
'pStyle', 'numPr', 'ind', 'jc', 'spacing', 'tabs',
|
|
705
|
+
'keepNext', 'keepLines', 'outlineLvl', 'contextualSpacing'
|
|
706
|
+
]);
|
|
707
|
+
|
|
708
|
+
function wordAttribute(node, localName) {
|
|
709
|
+
return node?.getAttribute?.(`w:${localName}`) || node?.getAttribute?.(localName) || '';
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function normalizedAuthor(author) {
|
|
713
|
+
return String(author || '').trim().toLowerCase();
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function directWordParagraphSibling(paragraph, direction = 'nextSibling') {
|
|
717
|
+
let cursor = paragraph?.[direction] || null;
|
|
718
|
+
while (cursor) {
|
|
719
|
+
if (cursor.nodeType === 1 && cursor.namespaceURI === NS_W && cursor.localName === 'p') return cursor;
|
|
720
|
+
cursor = cursor[direction] || null;
|
|
721
|
+
}
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function createSanitizedRestorationPPr(xmlDoc, sourceParagraph) {
|
|
726
|
+
const sourcePPr = getDirectWordChild(sourceParagraph, 'pPr');
|
|
727
|
+
const pPr = createWordElement(xmlDoc, 'w:pPr');
|
|
728
|
+
if (!sourcePPr) return pPr;
|
|
729
|
+
for (const child of Array.from(sourcePPr.childNodes || [])) {
|
|
730
|
+
if (child.nodeType !== 1 || child.namespaceURI !== NS_W) continue;
|
|
731
|
+
if (!RESTORATION_PPR_ALLOWLIST.has(child.localName)) continue;
|
|
732
|
+
pPr.appendChild(xmlDoc.importNode(child, true));
|
|
733
|
+
}
|
|
734
|
+
return pPr;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function collectRestorationAnchorWarnings(sourceParagraph) {
|
|
738
|
+
const warnings = [];
|
|
739
|
+
const bookmarkNames = new Set();
|
|
740
|
+
for (const node of Array.from(sourceParagraph?.getElementsByTagNameNS?.(NS_W, 'bookmarkStart') || [])) {
|
|
741
|
+
const name = wordAttribute(node, 'name') || '(unnamed)';
|
|
742
|
+
bookmarkNames.add(name);
|
|
743
|
+
}
|
|
744
|
+
for (const name of bookmarkNames) warnings.push(`RESTORATION_DROPPED_BOOKMARK:${name}`);
|
|
745
|
+
|
|
746
|
+
const commentIds = new Set();
|
|
747
|
+
for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
|
|
748
|
+
for (const node of Array.from(sourceParagraph?.getElementsByTagNameNS?.(NS_W, localName) || [])) {
|
|
749
|
+
const id = wordAttribute(node, 'id');
|
|
750
|
+
if (id !== '') commentIds.add(id);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
for (const id of commentIds) warnings.push(`RESTORATION_DROPPED_COMMENT:${id}`);
|
|
754
|
+
return warnings;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function removeRestorationAnchors(paragraph) {
|
|
758
|
+
const anchorNames = [
|
|
759
|
+
'bookmarkStart', 'bookmarkEnd',
|
|
760
|
+
'commentRangeStart', 'commentRangeEnd', 'commentReference'
|
|
761
|
+
];
|
|
762
|
+
for (const localName of anchorNames) {
|
|
763
|
+
for (const node of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, localName) || [])) {
|
|
764
|
+
node.parentNode?.removeChild(node);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
for (const run of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'r') || [])) {
|
|
768
|
+
const meaningful = Array.from(run.childNodes || []).some(child => (
|
|
769
|
+
child.nodeType === 1 && child.namespaceURI === NS_W && child.localName !== 'rPr'
|
|
770
|
+
));
|
|
771
|
+
if (!meaningful) run.parentNode?.removeChild(run);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function buildRejectedRestorationTemplate(xmlDoc, sourceParagraph, serializer) {
|
|
776
|
+
const rejected = rejectTrackedChangesInOoxml(serializer.serializeToString(sourceParagraph), { allAuthors: true });
|
|
777
|
+
const parsed = parseOoxmlSafe(rejected.oxml, 'application/xml');
|
|
778
|
+
const rejectedParagraph = parsed.doc?.getElementsByTagNameNS?.(NS_W, 'p')?.[0] || null;
|
|
779
|
+
if (!rejectedParagraph) return null;
|
|
780
|
+
|
|
781
|
+
const paragraph = createWordElement(xmlDoc, 'w:p');
|
|
782
|
+
paragraph.appendChild(createSanitizedRestorationPPr(xmlDoc, sourceParagraph));
|
|
783
|
+
for (const child of Array.from(rejectedParagraph.childNodes || [])) {
|
|
784
|
+
if (child.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'pPr') continue;
|
|
785
|
+
paragraph.appendChild(xmlDoc.importNode(child, true));
|
|
786
|
+
}
|
|
787
|
+
removeRestorationAnchors(paragraph);
|
|
788
|
+
return paragraph;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async function editRestorationTemplate(xmlDoc, template, modifiedText, author, serializer) {
|
|
792
|
+
const originalText = extractCanonicalParagraphText(template);
|
|
793
|
+
if (originalText === modifiedText) return template;
|
|
794
|
+
const result = await applyRedlineToOxml(
|
|
795
|
+
serializer.serializeToString(template),
|
|
796
|
+
originalText,
|
|
797
|
+
modifiedText,
|
|
798
|
+
{
|
|
799
|
+
author,
|
|
800
|
+
generateRedlines: false,
|
|
801
|
+
structuredContent: false
|
|
802
|
+
}
|
|
803
|
+
);
|
|
804
|
+
if (result?.status === 'error' || typeof result?.oxml !== 'string') return null;
|
|
805
|
+
const extracted = extractReplacementNodes(result.oxml);
|
|
806
|
+
const paragraph = (extracted.replacementNodes || []).find(node => (
|
|
807
|
+
node?.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
|
|
808
|
+
));
|
|
809
|
+
return paragraph ? xmlDoc.importNode(paragraph, true) : null;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function allocateFreshParagraphId(xmlDoc, operationSession) {
|
|
813
|
+
const used = new Set(getDocumentParagraphNodes(xmlDoc)
|
|
814
|
+
.map(paragraph => paragraph.getAttributeNS?.(NS_W14, 'paraId') || paragraph.getAttribute?.('w14:paraId') || '')
|
|
815
|
+
.filter(Boolean)
|
|
816
|
+
.map(value => value.toUpperCase()));
|
|
817
|
+
let candidate = null;
|
|
818
|
+
do {
|
|
819
|
+
candidate = operationSession?.generateParagraphId?.()
|
|
820
|
+
|| (0x40000000 + used.size + 1).toString(16).toUpperCase();
|
|
821
|
+
} while (used.has(candidate.toUpperCase()));
|
|
822
|
+
return candidate;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function trackRestoredParagraph(xmlDoc, paragraph, author, operationSession) {
|
|
826
|
+
const root = xmlDoc.documentElement;
|
|
827
|
+
if (root && !root.getAttribute('xmlns:w14')) root.setAttribute('xmlns:w14', NS_W14);
|
|
828
|
+
return wrapParagraphContentInInsertion(xmlDoc, paragraph, null, author, {
|
|
829
|
+
paragraphId: allocateFreshParagraphId(xmlDoc, operationSession),
|
|
830
|
+
sanitizeParagraphProperties: true,
|
|
831
|
+
trackParagraphMark: true
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function isInsertedParagraphByAuthor(paragraph, author) {
|
|
836
|
+
const pPr = getDirectWordChild(paragraph, 'pPr');
|
|
837
|
+
const rPr = getDirectWordChild(pPr, 'rPr');
|
|
838
|
+
const marker = getDirectWordChild(rPr, 'ins');
|
|
839
|
+
return !!marker && normalizedAuthor(wordAttribute(marker, 'author')) === normalizedAuthor(author);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function followingParagraphBlock(lastSource, count) {
|
|
843
|
+
const paragraphs = [];
|
|
844
|
+
let cursor = lastSource;
|
|
845
|
+
for (let i = 0; i < count; i++) {
|
|
846
|
+
cursor = directWordParagraphSibling(cursor, 'nextSibling');
|
|
847
|
+
if (!cursor) return [];
|
|
848
|
+
paragraphs.push(cursor);
|
|
849
|
+
}
|
|
850
|
+
return paragraphs;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function paragraphTextVector(oxml) {
|
|
854
|
+
const parsed = parseOoxmlSafe(oxml, 'application/xml');
|
|
855
|
+
if (parsed.error || !parsed.doc) return null;
|
|
856
|
+
return getDocumentParagraphNodes(parsed.doc).map(paragraph => extractCanonicalParagraphText(paragraph));
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function sameTextVector(left, right) {
|
|
860
|
+
return Array.isArray(left) && Array.isArray(right)
|
|
861
|
+
&& left.length === right.length
|
|
862
|
+
&& left.every((value, index) => value === right[index]);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function lifecycleTextVector(oxml, action) {
|
|
866
|
+
const result = action === 'accept'
|
|
867
|
+
? acceptTrackedChangesInOoxml(oxml, { allAuthors: true })
|
|
868
|
+
: rejectTrackedChangesInOoxml(oxml, { allAuthors: true });
|
|
869
|
+
return paragraphTextVector(result.oxml);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function buildExpectedRestorationDocument(beforeXml, sourceStartIndex, existingIndexes, templates) {
|
|
873
|
+
const parsed = parseOoxmlSafe(beforeXml, 'application/xml');
|
|
874
|
+
if (parsed.error || !parsed.doc) return null;
|
|
875
|
+
const expectedDoc = parsed.doc;
|
|
876
|
+
const originalParagraphs = getDocumentParagraphNodes(expectedDoc);
|
|
877
|
+
const lastSource = originalParagraphs[sourceStartIndex + templates.length - 1] || null;
|
|
878
|
+
if (!lastSource?.parentNode) return null;
|
|
879
|
+
|
|
880
|
+
for (const index of [...existingIndexes].sort((a, b) => b - a)) {
|
|
881
|
+
const paragraph = originalParagraphs[index];
|
|
882
|
+
paragraph?.parentNode?.removeChild(paragraph);
|
|
883
|
+
}
|
|
884
|
+
const insertionPoint = lastSource.nextSibling;
|
|
885
|
+
for (const template of templates) {
|
|
886
|
+
lastSource.parentNode.insertBefore(expectedDoc.importNode(template, true), insertionPoint);
|
|
887
|
+
}
|
|
888
|
+
return createSerializer().serializeToString(expectedDoc);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function verifyParagraphRestorationLifecycle(beforeXml, outputXml, sourceStartIndex, existingIndexes, templates, insertedParagraphs = []) {
|
|
892
|
+
const baselineValidation = validateRedlineOoxml(beforeXml);
|
|
893
|
+
const outputValidation = validateRedlineOoxml(outputXml);
|
|
894
|
+
const generatedIssues = subtractValidationIssueMultiset(outputValidation.issues, baselineValidation.issues);
|
|
895
|
+
const envelopeIssues = insertedParagraphs.flatMap(paragraph => (
|
|
896
|
+
validateRedlineOoxml(createSerializer().serializeToString(paragraph)).issues
|
|
897
|
+
.filter(issue => issue.severity === 'error')
|
|
898
|
+
));
|
|
899
|
+
const generatedErrors = validationErrors(generatedIssues);
|
|
900
|
+
if (generatedErrors.length > 0 || envelopeIssues.length > 0) {
|
|
901
|
+
return {
|
|
902
|
+
valid: false,
|
|
903
|
+
stage: 'validation',
|
|
904
|
+
code: 'GENERATED_OOXML_INVALID',
|
|
905
|
+
generatedIssues,
|
|
906
|
+
envelopeIssues,
|
|
907
|
+
message: [...generatedErrors, ...envelopeIssues].map(issue => issue.message).join(' ')
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const expectedXml = buildExpectedRestorationDocument(beforeXml, sourceStartIndex, existingIndexes, templates);
|
|
912
|
+
if (!expectedXml) return { valid: false, stage: 'expected-document', message: 'Could not construct the restoration lifecycle oracle.' };
|
|
913
|
+
|
|
914
|
+
const comparisons = [
|
|
915
|
+
['current', paragraphTextVector(expectedXml), paragraphTextVector(outputXml)],
|
|
916
|
+
['accept-all', lifecycleTextVector(expectedXml, 'accept'), lifecycleTextVector(outputXml, 'accept')],
|
|
917
|
+
['reject-all', lifecycleTextVector(beforeXml, 'reject'), lifecycleTextVector(outputXml, 'reject')]
|
|
918
|
+
];
|
|
919
|
+
for (const [stage, expected, actual] of comparisons) {
|
|
920
|
+
if (!sameTextVector(expected, actual)) {
|
|
921
|
+
return {
|
|
922
|
+
valid: false,
|
|
923
|
+
stage,
|
|
924
|
+
expected,
|
|
925
|
+
actual,
|
|
926
|
+
message: `Paragraph restoration ${stage} lifecycle text did not match the expected body-scoped paragraph sequence.`
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return { valid: true };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Inserts a new tracked run at an exact offset inside text visible only in the
|
|
935
|
+
* rejected view of a wholly deleted paragraph. The foreign deletion is split,
|
|
936
|
+
* never rewritten or re-authored.
|
|
937
|
+
*/
|
|
938
|
+
export async function insertIntoRejectedDeletedText(
|
|
939
|
+
documentXml,
|
|
940
|
+
targetText,
|
|
941
|
+
anchor,
|
|
942
|
+
modified,
|
|
943
|
+
author,
|
|
944
|
+
targetRef = null,
|
|
945
|
+
runtimeContext = null,
|
|
946
|
+
options = {}
|
|
947
|
+
) {
|
|
948
|
+
const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
|
|
949
|
+
if (!xmlDoc) {
|
|
950
|
+
return {
|
|
951
|
+
documentXml,
|
|
952
|
+
hasChanges: false,
|
|
953
|
+
status: 'error',
|
|
954
|
+
error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'insert', runtimeContext, options);
|
|
959
|
+
if (resolved?.error || !resolved?.paragraph) {
|
|
960
|
+
return {
|
|
961
|
+
documentXml,
|
|
962
|
+
hasChanges: false,
|
|
963
|
+
status: 'error',
|
|
964
|
+
error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Rejected-view insertion target was not found.' }
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const paragraph = resolved.paragraph;
|
|
969
|
+
const state = inspectForeignDeletedParagraphTarget(paragraph, author);
|
|
970
|
+
if (!state.matches) {
|
|
971
|
+
return {
|
|
972
|
+
documentXml,
|
|
973
|
+
hasChanges: false,
|
|
974
|
+
status: 'error',
|
|
975
|
+
error: {
|
|
976
|
+
code: 'REJECTED_INSERTION_STATE_REQUIRED',
|
|
977
|
+
message: 'Rejected-view insertion requires a wholly deleted paragraph owned by another author.'
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
const structuralRefusal = getParagraphRestorationRefusal(paragraph, { requireFollowingParagraph: false });
|
|
982
|
+
if (structuralRefusal) {
|
|
983
|
+
return { documentXml, hasChanges: false, status: 'error', error: structuralRefusal };
|
|
984
|
+
}
|
|
985
|
+
if (/\r|\n/.test(modified)) {
|
|
986
|
+
return {
|
|
987
|
+
documentXml,
|
|
988
|
+
hasChanges: false,
|
|
989
|
+
status: 'error',
|
|
990
|
+
error: {
|
|
991
|
+
code: 'UNSAFE_PARAGRAPH_BOUNDARY',
|
|
992
|
+
message: 'Rejected-view insertion supports run-level text only; use restore for paragraph boundaries.'
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const rejectedText = extractCanonicalParagraphText(paragraph, { revisionView: 'rejected' });
|
|
998
|
+
if (!anchor.occurrenceExplicit && countNonOverlappingOccurrences(rejectedText, anchor.exactText) > 1) {
|
|
999
|
+
return {
|
|
1000
|
+
documentXml,
|
|
1001
|
+
hasChanges: false,
|
|
1002
|
+
status: 'error',
|
|
1003
|
+
error: {
|
|
1004
|
+
code: 'AMBIGUOUS_ANCHOR',
|
|
1005
|
+
message: 'The rejected-view insertion anchor is repeated; provide anchor.occurrence explicitly.'
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
const anchorStart = findOccurrenceOffset(rejectedText, anchor.exactText, anchor.occurrence);
|
|
1010
|
+
if (anchorStart < 0) {
|
|
1011
|
+
return {
|
|
1012
|
+
documentXml,
|
|
1013
|
+
hasChanges: false,
|
|
1014
|
+
status: 'error',
|
|
1015
|
+
error: { code: 'ANCHOR_NOT_FOUND', message: 'The rejected-view insertion anchor was not found at the requested occurrence.' }
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
const insertionOffset = anchorStart + anchor.offset;
|
|
1019
|
+
const carriers = Array.from(paragraph.childNodes || []).filter(
|
|
1020
|
+
node => node?.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'del'
|
|
1021
|
+
);
|
|
1022
|
+
if (carriers.some(carrier => hasUnsupportedDeletionSplitMarkup(paragraph, carrier))) {
|
|
1023
|
+
return {
|
|
1024
|
+
documentXml,
|
|
1025
|
+
hasChanges: false,
|
|
1026
|
+
status: 'error',
|
|
1027
|
+
error: {
|
|
1028
|
+
code: 'UNSAFE_REVISION_BOUNDARY',
|
|
1029
|
+
message: 'The rejected-view insertion boundary contains comments, bookmarks, fields, hyperlinks, moves, or non-text run markup that cannot be split safely.'
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
let carrierStart = 0;
|
|
1034
|
+
let targetCarrier = null;
|
|
1035
|
+
let carrierOffset = 0;
|
|
1036
|
+
for (const carrier of carriers) {
|
|
1037
|
+
const length = directDeletionCarrierText(carrier).length;
|
|
1038
|
+
if (insertionOffset >= carrierStart && insertionOffset <= carrierStart + length) {
|
|
1039
|
+
targetCarrier = carrier;
|
|
1040
|
+
carrierOffset = insertionOffset - carrierStart;
|
|
1041
|
+
break;
|
|
1042
|
+
}
|
|
1043
|
+
carrierStart += length;
|
|
1044
|
+
}
|
|
1045
|
+
if (!targetCarrier || carriers.map(directDeletionCarrierText).join('') !== rejectedText) {
|
|
1046
|
+
return {
|
|
1047
|
+
documentXml,
|
|
1048
|
+
hasChanges: false,
|
|
1049
|
+
status: 'error',
|
|
1050
|
+
error: {
|
|
1051
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
1052
|
+
message: 'The rejected-view anchor is not contained in a supported direct deletion carrier.'
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const runProperties = insertedRunPropertiesAtDeletionOffset(targetCarrier, carrierOffset);
|
|
1058
|
+
const { leftCarrier, rightCarrier } = splitTrackChangeCarrier(
|
|
1059
|
+
xmlDoc,
|
|
1060
|
+
targetCarrier,
|
|
1061
|
+
carrierOffset,
|
|
1062
|
+
options._revisionIdAllocator || null
|
|
1063
|
+
);
|
|
1064
|
+
const insertion = createWordElement(xmlDoc, 'w:ins');
|
|
1065
|
+
const metadata = createRevisionMetadata(author, options._revisionIdAllocator || xmlDoc, 'ins');
|
|
1066
|
+
insertion.setAttribute('w:id', String(metadata.id));
|
|
1067
|
+
insertion.setAttribute('w:author', metadata.author);
|
|
1068
|
+
insertion.setAttribute('w:date', metadata.date);
|
|
1069
|
+
const run = createWordElement(xmlDoc, 'w:r');
|
|
1070
|
+
if (runProperties) run.appendChild(runProperties);
|
|
1071
|
+
const textNode = createWordElement(xmlDoc, 'w:t');
|
|
1072
|
+
if (/^\s|\s$/.test(modified)) textNode.setAttribute('xml:space', 'preserve');
|
|
1073
|
+
textNode.textContent = modified;
|
|
1074
|
+
run.appendChild(textNode);
|
|
1075
|
+
insertion.appendChild(run);
|
|
1076
|
+
|
|
1077
|
+
const parent = targetCarrier.parentNode;
|
|
1078
|
+
if (leftCarrier) parent.insertBefore(leftCarrier, targetCarrier);
|
|
1079
|
+
parent.insertBefore(insertion, targetCarrier);
|
|
1080
|
+
if (rightCarrier) parent.insertBefore(rightCarrier, targetCarrier);
|
|
1081
|
+
options?._mutationRemovedNodes?.push(targetCarrier);
|
|
1082
|
+
parent.removeChild(targetCarrier);
|
|
1083
|
+
options?._mutationLiveNodes?.push(paragraph, insertion);
|
|
1084
|
+
operationSession?.invalidateParagraphMetadata?.();
|
|
1085
|
+
|
|
1086
|
+
const outputXml = serializer.serializeToString(xmlDoc);
|
|
1087
|
+
const outputRejected = extractCanonicalParagraphText(paragraph, { revisionView: 'rejected' });
|
|
1088
|
+
const outputAccepted = extractCanonicalParagraphText(paragraph, { revisionView: 'accepted' });
|
|
1089
|
+
if (outputRejected !== rejectedText || !outputAccepted.includes(modified)) {
|
|
1090
|
+
return {
|
|
1091
|
+
documentXml,
|
|
1092
|
+
hasChanges: false,
|
|
1093
|
+
status: 'error',
|
|
1094
|
+
error: {
|
|
1095
|
+
code: 'PATCH_ROUNDTRIP_MISMATCH',
|
|
1096
|
+
message: 'Rejected-view insertion did not preserve the deleted source and expose the requested inserted text.'
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
return { documentXml: outputXml, hasChanges: true, status: 'ok' };
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Materializes an explicit counterproposal for one or more paragraphs wholly
|
|
1105
|
+
* deleted (content and paragraph mark) by another author.
|
|
1106
|
+
*/
|
|
1107
|
+
export async function restoreDeletedParagraphByExactText(
|
|
1108
|
+
documentXml,
|
|
1109
|
+
targetText,
|
|
1110
|
+
modified,
|
|
1111
|
+
author,
|
|
1112
|
+
targetRef = null,
|
|
1113
|
+
targetEndRef = null,
|
|
1114
|
+
runtimeContext = null,
|
|
1115
|
+
options = {}
|
|
1116
|
+
) {
|
|
1117
|
+
const { serializer, xmlDoc, operationSession } = resolveMutationDocument(documentXml, options);
|
|
1118
|
+
if (!xmlDoc) {
|
|
1119
|
+
return {
|
|
1120
|
+
documentXml,
|
|
1121
|
+
hasChanges: false,
|
|
1122
|
+
status: 'error',
|
|
1123
|
+
error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' }
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'restore', runtimeContext, options);
|
|
1128
|
+
if (resolved?.error || !resolved?.paragraph) {
|
|
1129
|
+
return {
|
|
1130
|
+
documentXml,
|
|
1131
|
+
hasChanges: false,
|
|
1132
|
+
status: 'error',
|
|
1133
|
+
error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Restoration target paragraph not found.' }
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const firstSource = resolved.paragraph;
|
|
1138
|
+
let sourceParagraphs = [firstSource];
|
|
1139
|
+
if (options._compiledSourceEndId) {
|
|
1140
|
+
const endResolved = resolveTargetParagraph(
|
|
1141
|
+
xmlDoc,
|
|
1142
|
+
options.targetEndDescriptor?.text || '',
|
|
1143
|
+
targetEndRef,
|
|
1144
|
+
'restore',
|
|
1145
|
+
runtimeContext,
|
|
1146
|
+
{ ...options, _compiledSourceId: options._compiledSourceEndId }
|
|
1147
|
+
);
|
|
1148
|
+
const allParagraphs = endResolved?.paragraph ? getDocumentParagraphNodes(xmlDoc) : [];
|
|
1149
|
+
const startIndex = allParagraphs.indexOf(firstSource);
|
|
1150
|
+
const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
|
|
1151
|
+
sourceParagraphs = startIndex >= 0 && endIndex >= startIndex
|
|
1152
|
+
? allParagraphs.slice(startIndex, endIndex + 1)
|
|
1153
|
+
: null;
|
|
1154
|
+
} else if (targetEndRef) {
|
|
1155
|
+
sourceParagraphs = resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
|
|
1156
|
+
opType: 'restore',
|
|
1157
|
+
targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
|
|
1158
|
+
onInfo: options.onInfo,
|
|
1159
|
+
onWarn: options.onWarn
|
|
1160
|
+
});
|
|
1161
|
+
} else if (options.targetEndDescriptor) {
|
|
1162
|
+
const endResolved = resolveTargetParagraph(
|
|
1163
|
+
xmlDoc,
|
|
1164
|
+
options.targetEndDescriptor.text,
|
|
1165
|
+
options.targetEndDescriptor.index,
|
|
1166
|
+
'restore',
|
|
1167
|
+
runtimeContext,
|
|
1168
|
+
{
|
|
1169
|
+
...options,
|
|
1170
|
+
targetDescriptor: options.targetEndDescriptor,
|
|
1171
|
+
_compiledSourceId: options._compiledSourceEndId || null
|
|
1172
|
+
}
|
|
1173
|
+
);
|
|
1174
|
+
const allParagraphs = endResolved?.paragraph ? getDocumentParagraphNodes(xmlDoc) : [];
|
|
1175
|
+
const startIndex = allParagraphs.indexOf(firstSource);
|
|
1176
|
+
const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
|
|
1177
|
+
sourceParagraphs = startIndex >= 0 && endIndex >= startIndex
|
|
1178
|
+
? allParagraphs.slice(startIndex, endIndex + 1)
|
|
1179
|
+
: null;
|
|
1180
|
+
}
|
|
1181
|
+
if (!Array.isArray(sourceParagraphs) || sourceParagraphs.length === 0 || sourceParagraphs[0] !== firstSource) {
|
|
1182
|
+
return {
|
|
1183
|
+
documentXml,
|
|
1184
|
+
hasChanges: false,
|
|
1185
|
+
status: 'error',
|
|
1186
|
+
error: {
|
|
1187
|
+
code: 'RESTORATION_COUNT_MISMATCH',
|
|
1188
|
+
message: 'The restoration range could not be resolved as a contiguous paragraph block.'
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const replacements = Array.isArray(modified) ? modified.map(String) : [String(modified || '')];
|
|
1194
|
+
if (replacements.length !== sourceParagraphs.length || replacements.some(text => text.length === 0)) {
|
|
1195
|
+
return {
|
|
1196
|
+
documentXml,
|
|
1197
|
+
hasChanges: false,
|
|
1198
|
+
status: 'error',
|
|
1199
|
+
error: {
|
|
1200
|
+
code: 'RESTORATION_COUNT_MISMATCH',
|
|
1201
|
+
message: `Restoration requires exactly one non-empty replacement per source paragraph (${sourceParagraphs.length} expected, ${replacements.length} received).`
|
|
1202
|
+
}
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const parent = firstSource.parentNode;
|
|
1207
|
+
if (!parent || sourceParagraphs.some(paragraph => paragraph.parentNode !== parent)) {
|
|
1208
|
+
return {
|
|
1209
|
+
documentXml,
|
|
1210
|
+
hasChanges: false,
|
|
1211
|
+
status: 'error',
|
|
1212
|
+
error: {
|
|
1213
|
+
code: 'UNSAFE_PARAGRAPH_PLACEMENT',
|
|
1214
|
+
message: 'Restoration source paragraphs must be a contiguous block in one structural container.'
|
|
1215
|
+
}
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
for (const paragraph of sourceParagraphs) {
|
|
1220
|
+
const structuralRefusal = getParagraphRestorationRefusal(paragraph);
|
|
1221
|
+
if (structuralRefusal?.code === 'UNSUPPORTED_MOVE_REVISION') {
|
|
1222
|
+
return {
|
|
1223
|
+
documentXml,
|
|
1224
|
+
hasChanges: false,
|
|
1225
|
+
status: 'error',
|
|
1226
|
+
error: structuralRefusal
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
const state = inspectForeignDeletedParagraphTarget(paragraph, author);
|
|
1230
|
+
if (!state.matches) {
|
|
1231
|
+
return {
|
|
1232
|
+
documentXml,
|
|
1233
|
+
hasChanges: false,
|
|
1234
|
+
status: 'error',
|
|
1235
|
+
error: {
|
|
1236
|
+
code: 'RESTORATION_STATE_REQUIRED',
|
|
1237
|
+
message: state.hasParagraphMarkDeletion && !state.foreignParagraphMarkDeletion
|
|
1238
|
+
? 'Explicit cross-author restoration does not apply to a paragraph deleted by the current author; use merge-same-author.'
|
|
1239
|
+
: 'Explicit restoration requires a foreign paragraph-mark deletion with every pre-existing content node deleted.',
|
|
1240
|
+
...(state.ownerAuthor ? { ownerAuthor: state.ownerAuthor } : {})
|
|
1241
|
+
}
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
const refusal = structuralRefusal || getParagraphRestorationRefusal(paragraph);
|
|
1245
|
+
if (refusal) {
|
|
1246
|
+
return {
|
|
1247
|
+
documentXml,
|
|
1248
|
+
hasChanges: false,
|
|
1249
|
+
status: 'error',
|
|
1250
|
+
error: refusal
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const lastSource = sourceParagraphs[sourceParagraphs.length - 1];
|
|
1256
|
+
const existingBlock = followingParagraphBlock(lastSource, sourceParagraphs.length);
|
|
1257
|
+
const hasExistingRestoration = existingBlock.length === sourceParagraphs.length
|
|
1258
|
+
&& existingBlock.every(paragraph => isInsertedParagraphByAuthor(paragraph, author));
|
|
1259
|
+
if (
|
|
1260
|
+
hasExistingRestoration
|
|
1261
|
+
&& existingBlock.every((paragraph, index) => extractCanonicalParagraphText(paragraph) === replacements[index])
|
|
1262
|
+
) {
|
|
1263
|
+
return {
|
|
1264
|
+
documentXml,
|
|
1265
|
+
hasChanges: false,
|
|
1266
|
+
status: 'no-op',
|
|
1267
|
+
warnings: ['An identical same-author paragraph restoration already exists.']
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const beforeXml = serializer.serializeToString(xmlDoc);
|
|
1272
|
+
const beforeParagraphs = getDocumentParagraphNodes(xmlDoc);
|
|
1273
|
+
const sourceStartIndex = beforeParagraphs.indexOf(firstSource);
|
|
1274
|
+
const existingIndexes = hasExistingRestoration
|
|
1275
|
+
? existingBlock.map(paragraph => beforeParagraphs.indexOf(paragraph))
|
|
1276
|
+
: [];
|
|
1277
|
+
const templates = [];
|
|
1278
|
+
const warnings = [];
|
|
1279
|
+
for (let index = 0; index < sourceParagraphs.length; index++) {
|
|
1280
|
+
const source = sourceParagraphs[index];
|
|
1281
|
+
warnings.push(...collectRestorationAnchorWarnings(source));
|
|
1282
|
+
const template = buildRejectedRestorationTemplate(xmlDoc, source, serializer);
|
|
1283
|
+
const edited = template
|
|
1284
|
+
? await editRestorationTemplate(xmlDoc, template, replacements[index], author, serializer)
|
|
1285
|
+
: null;
|
|
1286
|
+
if (!edited) {
|
|
1287
|
+
return {
|
|
1288
|
+
documentXml,
|
|
1289
|
+
hasChanges: false,
|
|
1290
|
+
status: 'error',
|
|
1291
|
+
error: {
|
|
1292
|
+
code: 'PATCH_ROUNDTRIP_MISMATCH',
|
|
1293
|
+
message: `Could not reconstruct restoration paragraph ${index + 1} from its rejected revision view.`
|
|
1294
|
+
}
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
templates.push(edited);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const insertedParagraphs = templates.map(template => {
|
|
1301
|
+
const paragraph = xmlDoc.importNode(template, true);
|
|
1302
|
+
return trackRestoredParagraph(xmlDoc, paragraph, author, operationSession);
|
|
1303
|
+
});
|
|
1304
|
+
const insertionPoint = lastSource.nextSibling;
|
|
1305
|
+
for (const paragraph of insertedParagraphs) parent.insertBefore(paragraph, insertionPoint);
|
|
1306
|
+
if (hasExistingRestoration) {
|
|
1307
|
+
for (const paragraph of existingBlock) {
|
|
1308
|
+
options?._mutationRemovedNodes?.push(paragraph);
|
|
1309
|
+
paragraph.parentNode?.removeChild(paragraph);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
options?._mutationLiveNodes?.push(...insertedParagraphs);
|
|
1313
|
+
normalizeBodySectionOrder(xmlDoc);
|
|
1314
|
+
|
|
1315
|
+
const outputXml = serializer.serializeToString(xmlDoc);
|
|
1316
|
+
const oracle = verifyParagraphRestorationLifecycle(
|
|
1317
|
+
beforeXml,
|
|
1318
|
+
outputXml,
|
|
1319
|
+
sourceStartIndex,
|
|
1320
|
+
existingIndexes,
|
|
1321
|
+
templates,
|
|
1322
|
+
insertedParagraphs
|
|
1323
|
+
);
|
|
1324
|
+
if (!oracle.valid) {
|
|
1325
|
+
return {
|
|
1326
|
+
documentXml,
|
|
1327
|
+
hasChanges: false,
|
|
1328
|
+
status: 'error',
|
|
1329
|
+
error: {
|
|
1330
|
+
code: oracle.code || 'PATCH_ROUNDTRIP_MISMATCH',
|
|
1331
|
+
message: oracle.message,
|
|
1332
|
+
stage: oracle.stage,
|
|
1333
|
+
...(oracle.generatedIssues ? { generatedIssues: oracle.generatedIssues } : {}),
|
|
1334
|
+
...(oracle.envelopeIssues ? { envelopeIssues: oracle.envelopeIssues } : {}),
|
|
1335
|
+
...(oracle.expected ? { expected: oracle.expected } : {}),
|
|
1336
|
+
...(oracle.actual ? { actual: oracle.actual } : {})
|
|
1337
|
+
}
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
return {
|
|
1342
|
+
documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
|
|
1343
|
+
hasChanges: true,
|
|
1344
|
+
status: 'ok',
|
|
1345
|
+
numberingXml: null,
|
|
1346
|
+
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1344
1349
|
|
|
1345
1350
|
async function tryExplicitDecimalHeaderListConversion({
|
|
1346
1351
|
xmlDoc,
|
|
@@ -1643,25 +1648,25 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
|
|
|
1643
1648
|
error: resolved?.error || { code: 'TARGET_NOT_FOUND', message: 'Target paragraph not found.' }
|
|
1644
1649
|
};
|
|
1645
1650
|
}
|
|
1646
|
-
const targetParagraph = resolved.paragraph;
|
|
1647
|
-
if (typeof modifiedText === 'string' && modifiedText.length > 0) {
|
|
1648
|
-
const resurrectionTarget = inspectForeignDeletedParagraphTarget(targetParagraph, author);
|
|
1649
|
-
if (resurrectionTarget.matches) {
|
|
1650
|
-
const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
|
|
1651
|
-
return {
|
|
1652
|
-
documentXml,
|
|
1653
|
-
hasChanges: false,
|
|
1654
|
-
numberingXml: null,
|
|
1655
|
-
status: 'error',
|
|
1656
|
-
error: {
|
|
1657
|
-
code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
|
|
1658
|
-
message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
|
|
1659
|
-
ownerAuthor
|
|
1660
|
-
}
|
|
1661
|
-
};
|
|
1662
|
-
}
|
|
1663
|
-
}
|
|
1664
|
-
preprocessRedlineTargetParagraph(targetParagraph);
|
|
1651
|
+
const targetParagraph = resolved.paragraph;
|
|
1652
|
+
if (typeof modifiedText === 'string' && modifiedText.length > 0) {
|
|
1653
|
+
const resurrectionTarget = inspectForeignDeletedParagraphTarget(targetParagraph, author);
|
|
1654
|
+
if (resurrectionTarget.matches) {
|
|
1655
|
+
const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
|
|
1656
|
+
return {
|
|
1657
|
+
documentXml,
|
|
1658
|
+
hasChanges: false,
|
|
1659
|
+
numberingXml: null,
|
|
1660
|
+
status: 'error',
|
|
1661
|
+
error: {
|
|
1662
|
+
code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
|
|
1663
|
+
message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
|
|
1664
|
+
ownerAuthor
|
|
1665
|
+
}
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
preprocessRedlineTargetParagraph(targetParagraph);
|
|
1665
1670
|
const currentParagraphText = getParagraphText(targetParagraph);
|
|
1666
1671
|
if (modifiedText === '') {
|
|
1667
1672
|
const commentIds = getCommentIdsInElement(targetParagraph);
|
|
@@ -1682,7 +1687,7 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
|
|
|
1682
1687
|
}
|
|
1683
1688
|
}
|
|
1684
1689
|
const boundaryCheck = validateParagraphBoundaryMutation(targetParagraph, modifiedText, options);
|
|
1685
|
-
if (!boundaryCheck.valid) {
|
|
1690
|
+
if (!boundaryCheck.valid) {
|
|
1686
1691
|
return {
|
|
1687
1692
|
documentXml,
|
|
1688
1693
|
hasChanges: false,
|
|
@@ -1691,111 +1696,111 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
|
|
|
1691
1696
|
error: {
|
|
1692
1697
|
code: boundaryCheck.code,
|
|
1693
1698
|
message: boundaryCheck.message
|
|
1694
|
-
}
|
|
1695
|
-
};
|
|
1696
|
-
}
|
|
1697
|
-
const hasRevisions = containsTrackedChanges(targetParagraph);
|
|
1698
|
-
const existingPolicy = options.existingRevisions || 'merge-same-author';
|
|
1699
|
-
if (
|
|
1700
|
-
hasRevisions
|
|
1701
|
-
&& existingPolicy !== 'accept-all-first'
|
|
1702
|
-
&& existingPolicy !== 'accept-all-first-keep-normalized'
|
|
1703
|
-
) {
|
|
1704
|
-
if (existingPolicy === 'merge-same-author' || existingPolicy === 'slice-cross-author') {
|
|
1705
|
-
const authors = getTrackedChangeAuthors(targetParagraph);
|
|
1706
|
-
const opAuthor = String(author || '').trim().toLowerCase();
|
|
1707
|
-
const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
|
|
1708
|
-
if (!allSame && existingPolicy === 'merge-same-author') {
|
|
1709
|
-
return {
|
|
1710
|
-
documentXml,
|
|
1711
|
-
hasChanges: false,
|
|
1712
|
-
numberingXml: null,
|
|
1713
|
-
status: 'error',
|
|
1714
|
-
error: {
|
|
1715
|
-
code: 'EXISTING_REVISIONS',
|
|
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
|
|
1719
|
-
}
|
|
1720
|
-
};
|
|
1721
|
-
}
|
|
1722
|
-
const mergeCommentIds = allSame ? getCommentIdsInElement(targetParagraph) : [];
|
|
1723
|
-
if (mergeCommentIds.length > 0) {
|
|
1724
|
-
const comments = commentDetailsForIds(mergeCommentIds, options._existingCommentDetails);
|
|
1725
|
-
return {
|
|
1726
|
-
documentXml,
|
|
1727
|
-
hasChanges: false,
|
|
1728
|
-
numberingXml: null,
|
|
1729
|
-
status: 'error',
|
|
1730
|
-
error: {
|
|
1731
|
-
code: 'COMMENTED_CONTENT_MERGE',
|
|
1732
|
-
message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
|
|
1733
|
-
commentIds: mergeCommentIds,
|
|
1734
|
-
...(comments.length > 0 ? { comments } : {})
|
|
1735
|
-
}
|
|
1736
|
-
};
|
|
1737
|
-
}
|
|
1738
|
-
if (!allSame) {
|
|
1739
|
-
const hasMove = targetParagraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
|
|
1740
|
-
|| targetParagraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
|
|
1741
|
-
if (hasMove) {
|
|
1742
|
-
return {
|
|
1743
|
-
documentXml,
|
|
1744
|
-
hasChanges: false,
|
|
1745
|
-
numberingXml: null,
|
|
1746
|
-
status: 'error',
|
|
1747
|
-
error: {
|
|
1748
|
-
code: 'UNSAFE_REVISION_NESTING',
|
|
1749
|
-
message: 'Cross-author slicing does not support pending move revisions.'
|
|
1750
|
-
}
|
|
1751
|
-
};
|
|
1752
|
-
}
|
|
1753
|
-
}
|
|
1754
|
-
} else {
|
|
1755
|
-
const hasDel = targetParagraph.getElementsByTagNameNS(NS_W, 'del').length > 0;
|
|
1756
|
-
const hasMove = targetParagraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
|
|
1757
|
-
|| targetParagraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
|
|
1758
|
-
if (hasDel) {
|
|
1759
|
-
return {
|
|
1760
|
-
documentXml,
|
|
1761
|
-
hasChanges: false,
|
|
1762
|
-
numberingXml: null,
|
|
1763
|
-
status: 'error',
|
|
1764
|
-
error: {
|
|
1765
|
-
code: 'UNSAFE_REVISION_NESTING',
|
|
1766
|
-
message: 'Refusing to replace content with pending deletions; nesting revisions is unsafe.'
|
|
1767
|
-
}
|
|
1768
|
-
};
|
|
1769
|
-
}
|
|
1770
|
-
if (hasMove) {
|
|
1771
|
-
return {
|
|
1772
|
-
documentXml,
|
|
1773
|
-
hasChanges: false,
|
|
1774
|
-
numberingXml: null,
|
|
1775
|
-
status: 'error',
|
|
1776
|
-
error: {
|
|
1777
|
-
code: 'UNSAFE_REVISION_NESTING',
|
|
1778
|
-
message: 'Refusing to mutate content with move revisions until move lifecycle is designed.'
|
|
1779
|
-
}
|
|
1780
|
-
};
|
|
1781
|
-
}
|
|
1782
|
-
return {
|
|
1783
|
-
documentXml,
|
|
1784
|
-
hasChanges: false,
|
|
1785
|
-
numberingXml: null,
|
|
1786
|
-
status: 'error',
|
|
1787
|
-
error: {
|
|
1788
|
-
code: 'EXISTING_REVISIONS',
|
|
1789
|
-
message: 'Target paragraph contains tracked changes and existingRevisions is "reject-input".'
|
|
1790
|
-
}
|
|
1791
|
-
};
|
|
1792
|
-
}
|
|
1793
|
-
}
|
|
1794
|
-
if (
|
|
1795
|
-
modifiedText === ''
|
|
1796
|
-
&& currentParagraphText === ''
|
|
1797
|
-
&& !(hasRevisions && existingPolicy === 'merge-same-author')
|
|
1798
|
-
) {
|
|
1699
|
+
}
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
const hasRevisions = containsTrackedChanges(targetParagraph);
|
|
1703
|
+
const existingPolicy = options.existingRevisions || 'merge-same-author';
|
|
1704
|
+
if (
|
|
1705
|
+
hasRevisions
|
|
1706
|
+
&& existingPolicy !== 'accept-all-first'
|
|
1707
|
+
&& existingPolicy !== 'accept-all-first-keep-normalized'
|
|
1708
|
+
) {
|
|
1709
|
+
if (existingPolicy === 'merge-same-author' || existingPolicy === 'slice-cross-author') {
|
|
1710
|
+
const authors = getTrackedChangeAuthors(targetParagraph);
|
|
1711
|
+
const opAuthor = String(author || '').trim().toLowerCase();
|
|
1712
|
+
const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
|
|
1713
|
+
if (!allSame && existingPolicy === 'merge-same-author') {
|
|
1714
|
+
return {
|
|
1715
|
+
documentXml,
|
|
1716
|
+
hasChanges: false,
|
|
1717
|
+
numberingXml: null,
|
|
1718
|
+
status: 'error',
|
|
1719
|
+
error: {
|
|
1720
|
+
code: 'EXISTING_REVISIONS',
|
|
1721
|
+
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.`,
|
|
1722
|
+
revisionAuthors: authors,
|
|
1723
|
+
currentPolicy: existingPolicy
|
|
1724
|
+
}
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
const mergeCommentIds = allSame ? getCommentIdsInElement(targetParagraph) : [];
|
|
1728
|
+
if (mergeCommentIds.length > 0) {
|
|
1729
|
+
const comments = commentDetailsForIds(mergeCommentIds, options._existingCommentDetails);
|
|
1730
|
+
return {
|
|
1731
|
+
documentXml,
|
|
1732
|
+
hasChanges: false,
|
|
1733
|
+
numberingXml: null,
|
|
1734
|
+
status: 'error',
|
|
1735
|
+
error: {
|
|
1736
|
+
code: 'COMMENTED_CONTENT_MERGE',
|
|
1737
|
+
message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
|
|
1738
|
+
commentIds: mergeCommentIds,
|
|
1739
|
+
...(comments.length > 0 ? { comments } : {})
|
|
1740
|
+
}
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
if (!allSame) {
|
|
1744
|
+
const hasMove = targetParagraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
|
|
1745
|
+
|| targetParagraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
|
|
1746
|
+
if (hasMove) {
|
|
1747
|
+
return {
|
|
1748
|
+
documentXml,
|
|
1749
|
+
hasChanges: false,
|
|
1750
|
+
numberingXml: null,
|
|
1751
|
+
status: 'error',
|
|
1752
|
+
error: {
|
|
1753
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
1754
|
+
message: 'Cross-author slicing does not support pending move revisions.'
|
|
1755
|
+
}
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
} else {
|
|
1760
|
+
const hasDel = targetParagraph.getElementsByTagNameNS(NS_W, 'del').length > 0;
|
|
1761
|
+
const hasMove = targetParagraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
|
|
1762
|
+
|| targetParagraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
|
|
1763
|
+
if (hasDel) {
|
|
1764
|
+
return {
|
|
1765
|
+
documentXml,
|
|
1766
|
+
hasChanges: false,
|
|
1767
|
+
numberingXml: null,
|
|
1768
|
+
status: 'error',
|
|
1769
|
+
error: {
|
|
1770
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
1771
|
+
message: 'Refusing to replace content with pending deletions; nesting revisions is unsafe.'
|
|
1772
|
+
}
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
if (hasMove) {
|
|
1776
|
+
return {
|
|
1777
|
+
documentXml,
|
|
1778
|
+
hasChanges: false,
|
|
1779
|
+
numberingXml: null,
|
|
1780
|
+
status: 'error',
|
|
1781
|
+
error: {
|
|
1782
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
1783
|
+
message: 'Refusing to mutate content with move revisions until move lifecycle is designed.'
|
|
1784
|
+
}
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
return {
|
|
1788
|
+
documentXml,
|
|
1789
|
+
hasChanges: false,
|
|
1790
|
+
numberingXml: null,
|
|
1791
|
+
status: 'error',
|
|
1792
|
+
error: {
|
|
1793
|
+
code: 'EXISTING_REVISIONS',
|
|
1794
|
+
message: 'Target paragraph contains tracked changes and existingRevisions is "reject-input".'
|
|
1795
|
+
}
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
if (
|
|
1800
|
+
modifiedText === ''
|
|
1801
|
+
&& currentParagraphText === ''
|
|
1802
|
+
&& !(hasRevisions && existingPolicy === 'merge-same-author')
|
|
1803
|
+
) {
|
|
1799
1804
|
if (generateRedlines) {
|
|
1800
1805
|
markParagraphMarkDeleted(xmlDoc, targetParagraph, author, createRevisionMetadata(author, xmlDoc));
|
|
1801
1806
|
options?._mutationLiveNodes?.push(targetParagraph);
|
|
@@ -1944,6 +1949,102 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
|
|
|
1944
1949
|
};
|
|
1945
1950
|
}
|
|
1946
1951
|
|
|
1952
|
+
// Retained paragraph followed by a Markdown table: keep the source paragraph in place (so its own
|
|
1953
|
+
// content, formatting and revisions are untouched), apply any requested inline formatting to it,
|
|
1954
|
+
// and insert the table after it instead of rewriting the paragraph as a deletion plus a copy.
|
|
1955
|
+
const tableAppendCandidate = (
|
|
1956
|
+
!useTableScope
|
|
1957
|
+
&& !hasExplicitRangeScope
|
|
1958
|
+
&& !targetListInfo
|
|
1959
|
+
&& !containingTable
|
|
1960
|
+
&& !targetEndRef
|
|
1961
|
+
)
|
|
1962
|
+
? deriveParagraphThenTableAppend(effectiveModifiedText)
|
|
1963
|
+
: null;
|
|
1964
|
+
const tableAppendParagraph = tableAppendCandidate
|
|
1965
|
+
? preprocessMarkdown(tableAppendCandidate.paragraphMarkdown)
|
|
1966
|
+
: null;
|
|
1967
|
+
if (
|
|
1968
|
+
tableAppendCandidate
|
|
1969
|
+
&& normalizeWhitespaceForTargeting(tableAppendParagraph.cleanText) === normalizeWhitespaceForTargeting(currentParagraphText)
|
|
1970
|
+
) {
|
|
1971
|
+
const tableData = parseMarkdownTable(tableAppendCandidate.tableText);
|
|
1972
|
+
if (tableData.headers.length > 0 || tableData.rows.length > 0) {
|
|
1973
|
+
onInfo('[Table] Applying retained-paragraph table append heuristic.');
|
|
1974
|
+
const parent = targetParagraph.parentNode;
|
|
1975
|
+
if (!parent) throw new Error('Target paragraph has no parent for table append');
|
|
1976
|
+
const followingNode = targetParagraph.nextSibling;
|
|
1977
|
+
if (tableAppendParagraph.formatHints.length > 0) {
|
|
1978
|
+
const formatted = await applyRedlineToOxml(
|
|
1979
|
+
serializer.serializeToString(targetParagraph),
|
|
1980
|
+
currentParagraphText || targetText,
|
|
1981
|
+
tableAppendCandidate.paragraphMarkdown,
|
|
1982
|
+
{
|
|
1983
|
+
author,
|
|
1984
|
+
generateRedlines,
|
|
1985
|
+
existingRevisions: options.existingRevisions || 'merge-same-author',
|
|
1986
|
+
structuredContent: false,
|
|
1987
|
+
_revisionIdAllocator: revisionIdAllocator
|
|
1988
|
+
}
|
|
1989
|
+
);
|
|
1990
|
+
if (formatted?.status === 'error') {
|
|
1991
|
+
return {
|
|
1992
|
+
documentXml,
|
|
1993
|
+
hasChanges: false,
|
|
1994
|
+
numberingXml: null,
|
|
1995
|
+
status: 'error',
|
|
1996
|
+
error: formatted.error
|
|
1997
|
+
};
|
|
1998
|
+
}
|
|
1999
|
+
if (formatted?.hasChanges) {
|
|
2000
|
+
if (typeof formatted.oxml !== 'string') {
|
|
2001
|
+
throw new Error('Reconciliation engine did not return OOXML for a changed paragraph format');
|
|
2002
|
+
}
|
|
2003
|
+
for (const node of extractReplacementNodes(formatted.oxml).replacementNodes) {
|
|
2004
|
+
const imported = xmlDoc.importNode(node, true);
|
|
2005
|
+
ensureParagraphIdsOnImportedNode(imported, operationSession);
|
|
2006
|
+
options?._mutationLiveNodes?.push(parent.insertBefore(imported, targetParagraph));
|
|
2007
|
+
}
|
|
2008
|
+
options?._mutationRemovedNodes?.push(targetParagraph);
|
|
2009
|
+
parent.removeChild(targetParagraph);
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
const tableOoxml = generateTableOoxml(tableData, {
|
|
2013
|
+
generateRedlines,
|
|
2014
|
+
author,
|
|
2015
|
+
revisionIdAllocator,
|
|
2016
|
+
trackAsBlock: true
|
|
2017
|
+
});
|
|
2018
|
+
for (const node of extractReplacementNodes(tableOoxml).replacementNodes) {
|
|
2019
|
+
options?._mutationLiveNodes?.push(parent.insertBefore(xmlDoc.importNode(node, true), followingNode));
|
|
2020
|
+
}
|
|
2021
|
+
// Word cannot end the body (or a container) with a table: it would add an untracked paragraph
|
|
2022
|
+
// there that Reject All leaves behind. Supply the paragraph ourselves with a tracked-inserted
|
|
2023
|
+
// mark so Reject All removes it and the source paragraph count is restored.
|
|
2024
|
+
const endsContainer = !followingNode
|
|
2025
|
+
|| (followingNode.nodeType === 1 && followingNode.namespaceURI === NS_W && followingNode.localName === 'sectPr');
|
|
2026
|
+
if (endsContainer) {
|
|
2027
|
+
const trailing = createWordElement(xmlDoc, 'w:p');
|
|
2028
|
+
const trailingPPr = clonePropertiesWithoutRevisionHistory(getDirectWordChild(targetParagraph, 'pPr'));
|
|
2029
|
+
if (trailingPPr) {
|
|
2030
|
+
for (const child of Array.from(trailingPPr.childNodes)) {
|
|
2031
|
+
if (child.nodeType === 1 && ['sectPr', 'numPr'].includes(child.localName)) trailingPPr.removeChild(child);
|
|
2032
|
+
}
|
|
2033
|
+
trailing.appendChild(trailingPPr);
|
|
2034
|
+
}
|
|
2035
|
+
if (generateRedlines) markParagraphMarkInserted(xmlDoc, trailing, author);
|
|
2036
|
+
ensureParagraphIdsOnImportedNode(trailing, operationSession);
|
|
2037
|
+
options?._mutationLiveNodes?.push(parent.insertBefore(trailing, followingNode));
|
|
2038
|
+
}
|
|
2039
|
+
normalizeBodySectionOrder(xmlDoc);
|
|
2040
|
+
return {
|
|
2041
|
+
documentXml: completedDocumentXml(xmlDoc, serializer, documentXml, operationSession),
|
|
2042
|
+
hasChanges: true,
|
|
2043
|
+
numberingXml: null
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
|
|
1947
2048
|
const plainAdjacencyInsertionCandidate = (
|
|
1948
2049
|
!bypassSingleParagraphHeuristics
|
|
1949
2050
|
&& !useTableScope
|
|
@@ -1971,7 +2072,9 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
|
|
|
1971
2072
|
paragraphText,
|
|
1972
2073
|
generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
|
|
1973
2074
|
author,
|
|
1974
|
-
|
|
2075
|
+
// A wholly inserted paragraph must track its own paragraph mark; otherwise Word's
|
|
2076
|
+
// Reject All removes the text but leaves an empty paragraph behind.
|
|
2077
|
+
{ generateRedlines, trackParagraphMark: true }
|
|
1975
2078
|
);
|
|
1976
2079
|
const imported = xmlDoc.importNode(plainParagraph, true);
|
|
1977
2080
|
ensureParagraphIdsOnImportedNode(imported, operationSession);
|
|
@@ -2078,8 +2181,8 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
|
|
|
2078
2181
|
? explicitRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
|
|
2079
2182
|
: (inferredTableRangeParagraphs
|
|
2080
2183
|
? inferredTableRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
|
|
2081
|
-
: (currentParagraphText || targetText))
|
|
2082
|
-
);
|
|
2184
|
+
: (currentParagraphText || targetText))
|
|
2185
|
+
);
|
|
2083
2186
|
const scopedXml = useTableScope
|
|
2084
2187
|
? serializer.serializeToString(containingTable)
|
|
2085
2188
|
: (
|
|
@@ -2112,6 +2215,7 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
|
|
|
2112
2215
|
explicitStructuredContent: options.explicitStructuredContent === true,
|
|
2113
2216
|
pairReplacements: options.pairReplacements === true,
|
|
2114
2217
|
insertionAffinity: options.insertionAffinity || null,
|
|
2218
|
+
literalTextEdit: options.literalTextEdit === true,
|
|
2115
2219
|
_revisionIdAllocator: revisionIdAllocator,
|
|
2116
2220
|
_isolatedTableCell: useTableScope
|
|
2117
2221
|
});
|
|
@@ -2135,6 +2239,14 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
|
|
|
2135
2239
|
const extracted = extractReplacementNodes(result.oxml);
|
|
2136
2240
|
let replacementNodes = removeListPackagingSentinel(extracted.replacementNodes, result.warnings);
|
|
2137
2241
|
let numberingXml = extracted.numberingXml;
|
|
2242
|
+
if (numberingXml && runtimeContext?.numberingIdState && !useTableScope) {
|
|
2243
|
+
const sourceParagraphs = useListScope ? listScopeEdit.paragraphs : (explicitRangeParagraphs || [targetParagraph]);
|
|
2244
|
+
const continued = reuseSourceListNumbering(numberingXml, replacementNodes, sourceParagraphs, runtimeContext.numberingIdState);
|
|
2245
|
+
if (continued) {
|
|
2246
|
+
replacementNodes = continued.replacementNodes;
|
|
2247
|
+
numberingXml = null;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2138
2250
|
if (numberingXml && runtimeContext?.numberingIdState) {
|
|
2139
2251
|
const normalizedNumbering = remapNumberingPayloadForDocument(numberingXml, replacementNodes, runtimeContext.numberingIdState);
|
|
2140
2252
|
replacementNodes = normalizedNumbering.replacementNodes;
|
|
@@ -2418,7 +2530,12 @@ export async function applyFormattingToParagraphByExactText(
|
|
|
2418
2530
|
}
|
|
2419
2531
|
|
|
2420
2532
|
const targetDescriptor = options.targetDescriptor;
|
|
2421
|
-
|
|
2533
|
+
// `textOccurrence` is the dedicated in-paragraph selector. Legacy calls without it keep
|
|
2534
|
+
// reading `target.occurrence` (which also selects the N-th matching paragraph).
|
|
2535
|
+
const explicitTextOccurrence = Number.isInteger(options.textOccurrence) && options.textOccurrence > 0
|
|
2536
|
+
? options.textOccurrence
|
|
2537
|
+
: null;
|
|
2538
|
+
const occurrence = explicitTextOccurrence ?? targetDescriptor?.occurrence ?? null;
|
|
2422
2539
|
let matchStart = -1;
|
|
2423
2540
|
if (occurrence != null && occurrence > 0) {
|
|
2424
2541
|
let count = 0;
|
|
@@ -2437,6 +2554,18 @@ export async function applyFormattingToParagraphByExactText(
|
|
|
2437
2554
|
matchStart = fullParaText.indexOf(textToFormat);
|
|
2438
2555
|
}
|
|
2439
2556
|
|
|
2557
|
+
if (matchStart === -1 && explicitTextOccurrence != null && fullParaText.includes(textToFormat)) {
|
|
2558
|
+
return {
|
|
2559
|
+
documentXml,
|
|
2560
|
+
hasChanges: false,
|
|
2561
|
+
status: 'error',
|
|
2562
|
+
error: {
|
|
2563
|
+
code: 'PATCH_SOURCE_NOT_FOUND',
|
|
2564
|
+
message: `Occurrence ${explicitTextOccurrence} of text to format was not found in target paragraph: "${textToFormat}".`
|
|
2565
|
+
}
|
|
2566
|
+
};
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2440
2569
|
if (matchStart === -1) {
|
|
2441
2570
|
return {
|
|
2442
2571
|
documentXml,
|