@ansonlai/docx-redline-js 0.8.4 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +679 -460
- package/CHANGELOG.md +208 -1
- package/README.md +152 -16
- package/core/current-properties.js +136 -0
- package/core/paragraph-text.js +211 -209
- package/core/types.js +9 -3
- package/core/word-revision-semantics.js +256 -0
- package/core/word-xml.js +160 -118
- package/dist/docx-redline-js.esm.js +4338 -796
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +111 -108
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +4463 -921
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +4463 -921
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_FAST_START.md +2 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
- package/docs/SKILL_AUTHORING.md +169 -150
- package/docs/TESTING.md +1146 -1059
- package/docs/schemas/document-operations.schema.json +55 -2
- package/document/docx-document.js +1022 -1010
- package/engine/oxml-engine.js +21 -22
- package/engine/paragraph-mark-revisions.js +292 -0
- package/engine/reconstruction-mapper.js +312 -311
- package/engine/reconstruction-writer.js +67 -10
- package/engine/rpr-helpers.js +42 -0
- package/engine/run-builders.js +0 -73
- package/engine/surgical-diff-application.js +604 -605
- package/engine/table-mode.js +63 -63
- package/index.d.ts +53 -2
- package/index.js +13 -2
- package/node/cli-help.js +1 -0
- package/node/cli.js +21 -3
- package/orchestration/list-structural-fallback.js +67 -9
- package/package.json +1 -1
- package/pipeline/diff-engine.js +130 -1
- package/pipeline/ingestion-export.js +227 -243
- package/pipeline/ingestion-paragraph.js +93 -98
- package/pipeline/list-generation.js +415 -419
- package/pipeline/markdown-processor.js +5 -2
- package/pipeline/pipeline.js +206 -204
- package/scripts/export-validation-fixtures.mjs +19 -6
- package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
- package/scripts/lib/word-coverage-catalogue.mjs +7 -2
- package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
- package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
- package/scripts/word-com-differential.ps1 +443 -335
- package/scripts/word-com-visual-suite.ps1 +2 -2
- package/scripts/word-revision-semantics-matrix.ps1 +108 -0
- package/services/batch-operation-orchestrator.js +23 -3
- package/services/capture-engine.js +228 -226
- package/services/comment-replies.js +1 -1
- package/services/document-operation-applier.js +214 -0
- package/services/document-operation-contract.js +76 -0
- package/services/document-operation-mutations.js +688 -51
- package/services/error-recovery.js +17 -0
- package/services/inspection-comparison.js +82 -0
- package/services/list-conversion-diagnostics.js +296 -0
- package/services/list-format-change.js +586 -0
- package/services/list-format-mutation.js +416 -0
- package/services/list-format.js +305 -0
- package/services/numbering-allocation.js +642 -0
- package/services/numbering-helpers.js +354 -524
- package/services/operation-batch-compiler.js +183 -11
- package/services/operation-preflight.js +43 -33
- package/services/receipt-collector.js +288 -288
- package/services/revision-comment-management.js +38 -5
- package/services/revision-fidelity-verifier.js +484 -0
- package/services/standalone-docx-plumbing.js +627 -631
- package/services/standalone-operation-runner.d.ts +182 -5
- package/services/table-reconciliation.js +59 -7
|
@@ -10,9 +10,81 @@ import {
|
|
|
10
10
|
validateDocumentOperation
|
|
11
11
|
} from './document-operation-contract.js';
|
|
12
12
|
import { compileExactReplacements } from './localized-replacement-compiler.js';
|
|
13
|
+
import { getParagraphListInfo } from '../core/list-targeting.js';
|
|
14
|
+
import { parseTypedListMarker } from './list-format.js';
|
|
15
|
+
import { isTrackedDeletedParagraph } from './numbering-helpers.js';
|
|
13
16
|
|
|
14
|
-
|
|
17
|
+
// list-format strips typed markers (a text write) and binds numPr (a pPr write). On its own target it conflicts with
|
|
18
|
+
// every other text or format write except one compatible single-paragraph redline (`checkListFormatComposition`).
|
|
19
|
+
const TEXT_WRITE_KINDS = new Set(['redline', 'restore', 'rejected-insert', 'list-format']);
|
|
15
20
|
const FORMAT_WRITE_KINDS = new Set(['highlight', 'format', 'paragraph-format']);
|
|
21
|
+
const WORD_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
22
|
+
export const LIST_FORMAT_COMPOSITION_CONFLICT = 'LIST_FORMAT_COMPOSITION_CONFLICT';
|
|
23
|
+
const COMPOSABLE_REDLINE_TYPES = new Set(['redline', 'replace']);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The typed-marker prefix of a paragraph text as list-format reads it: the exact text a convert-plain strip removes
|
|
27
|
+
* ("1. ", "A. ", "- "), `unsupported:<marker>` for a marker shape list-format refuses, or null for no marker.
|
|
28
|
+
*/
|
|
29
|
+
function typedMarkerKey(text) {
|
|
30
|
+
const value = String(text ?? '');
|
|
31
|
+
const parsed = parseTypedListMarker(value);
|
|
32
|
+
if (!parsed) return null;
|
|
33
|
+
if (parsed.unsupported) return `unsupported:${parsed.marker}`;
|
|
34
|
+
return value.slice(0, parsed.prefixLength);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Rule for one `redline` and one `list-format` writing the same batch-start paragraph (the list-format targets it, or
|
|
39
|
+
* rebinds it through `scope: 'list'`). They compose when the redline changes only the paragraph's text after its typed
|
|
40
|
+
* marker and the list-format owns the marker and numbering: e.g. "1. Old" -> "1. New" as a redline plus
|
|
41
|
+
* `convert-plain`. Execution order is fixed: the redline runs first (its target binds at batch start), then the
|
|
42
|
+
* list-format strips the unchanged marker from the redlined paragraph. Returns null when compatible, else a reason.
|
|
43
|
+
*/
|
|
44
|
+
function checkListFormatComposition(redline, sourceText) {
|
|
45
|
+
const operation = redline.operation;
|
|
46
|
+
if (!COMPOSABLE_REDLINE_TYPES.has(operation?.type ?? 'redline')
|
|
47
|
+
|| operation.targetEnd != null || operation.targetEndRef != null
|
|
48
|
+
|| operation.anchor != null || operation.structuredContent === true || operation.part != null
|
|
49
|
+
|| operation.target?.revisionView === 'rejected'
|
|
50
|
+
|| typeof operation.modified !== 'string' || /[\r\n]/.test(operation.modified)) {
|
|
51
|
+
return {
|
|
52
|
+
reason: 'redline-shape',
|
|
53
|
+
hint: 'Pair a list-format only with a single-paragraph redline or replace of that paragraph: modified without line breaks, no targetEnd, anchor, part or structuredContent.'
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (operation.modified === sourceText) {
|
|
57
|
+
return {
|
|
58
|
+
reason: 'redline-unchanged',
|
|
59
|
+
hint: 'The redline does not change the text; drop it and send only the list-format.'
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const before = typedMarkerKey(sourceText);
|
|
63
|
+
const after = typedMarkerKey(operation.modified);
|
|
64
|
+
if (before !== after) {
|
|
65
|
+
return {
|
|
66
|
+
reason: 'marker-changed',
|
|
67
|
+
sourceMarker: before,
|
|
68
|
+
modifiedMarker: after,
|
|
69
|
+
hint: 'The redline adds, removes or changes the typed marker. Keep the marker text exactly as it is (the list-format strips it), and express a new label through list-format (strip "none" with format/start, or change-format).'
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Live body paragraphs a `scope: 'list'` change-format/join rebinds outside its named target. */
|
|
76
|
+
function listScopeRebindParagraphs(xmlDoc, registry, binding, operation) {
|
|
77
|
+
const mode = operation.list?.mode;
|
|
78
|
+
if (mode !== 'change-format' && mode !== 'join') return [];
|
|
79
|
+
if ((operation.list?.scope || 'list') !== 'list') return [];
|
|
80
|
+
const targets = binding.sourceIds.map(sourceId => registry.entries.get(sourceId)?.paragraph).filter(Boolean);
|
|
81
|
+
const info = targets.map(paragraph => getParagraphListInfo(paragraph)).find(Boolean);
|
|
82
|
+
if (!info) return [];
|
|
83
|
+
return Array.from(xmlDoc.getElementsByTagNameNS(WORD_NS, 'p')).filter(paragraph => {
|
|
84
|
+
if (targets.includes(paragraph) || isTrackedDeletedParagraph(paragraph)) return false;
|
|
85
|
+
return getParagraphListInfo(paragraph)?.numId === info.numId;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
16
88
|
|
|
17
89
|
function normalizedError(error) {
|
|
18
90
|
const details = {};
|
|
@@ -143,16 +215,24 @@ export class SourceTargetRegistry {
|
|
|
143
215
|
}
|
|
144
216
|
}
|
|
145
217
|
|
|
146
|
-
|
|
218
|
+
/** The paragraph that will stand for sourceId once this mutation commits (no state change). */
|
|
219
|
+
previewSuccessor(sourceId, removedNodes, liveNodes) {
|
|
147
220
|
const entry = this.entries.get(sourceId);
|
|
148
|
-
if (!entry || !
|
|
221
|
+
if (!entry || !entry.paragraph) return null;
|
|
222
|
+
if (!Array.isArray(removedNodes) || !removedNodes.includes(entry.paragraph)) return entry.paragraph;
|
|
149
223
|
const candidates = paragraphNodesFromMutation(liveNodes);
|
|
150
224
|
const sameId = entry.paragraphId
|
|
151
225
|
? candidates.filter(paragraph => getParagraphId(paragraph) === entry.paragraphId)
|
|
152
226
|
: [];
|
|
153
|
-
|
|
227
|
+
return sameId.length === 1
|
|
154
228
|
? sameId[0]
|
|
155
229
|
: (candidates.length === 1 ? candidates[0] : null);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
commitMutation(sourceId, removedNodes, liveNodes, operationIndex) {
|
|
233
|
+
const entry = this.entries.get(sourceId);
|
|
234
|
+
if (!entry || !Array.isArray(removedNodes) || !removedNodes.includes(entry.paragraph)) return;
|
|
235
|
+
const successor = this.previewSuccessor(sourceId, removedNodes, liveNodes);
|
|
156
236
|
if (successor) {
|
|
157
237
|
entry.paragraph = successor;
|
|
158
238
|
this.idsByNode.set(successor, sourceId);
|
|
@@ -200,17 +280,19 @@ function resolveDescriptor(xmlDoc, descriptor, kind, strictTargets, metadataIndi
|
|
|
200
280
|
};
|
|
201
281
|
}
|
|
202
282
|
|
|
203
|
-
function buildConflict(code, message, bindings, target, action) {
|
|
283
|
+
function buildConflict(code, message, bindings, target, action, details = {}, hint = null) {
|
|
204
284
|
return {
|
|
205
285
|
code,
|
|
206
286
|
message,
|
|
207
287
|
operationIndexes: bindings.map(binding => binding.index).sort((a, b) => a - b),
|
|
208
288
|
target,
|
|
289
|
+
...details,
|
|
209
290
|
recovery: {
|
|
210
291
|
action,
|
|
211
292
|
sameArgumentsSafe: false,
|
|
212
293
|
requiresReinspection: false,
|
|
213
|
-
requiresUserAuthorization: false
|
|
294
|
+
requiresUserAuthorization: false,
|
|
295
|
+
...(hint ? { hint } : {})
|
|
214
296
|
}
|
|
215
297
|
};
|
|
216
298
|
}
|
|
@@ -246,10 +328,11 @@ export function compileOperationBatch(xmlDoc, operations = [], options = {}) {
|
|
|
246
328
|
const compositeTextTarget = typeof operation.targetDescriptor?.text === 'string'
|
|
247
329
|
&& /\r|\n/.test(operation.targetDescriptor.text)
|
|
248
330
|
&& !operation.targetEndDescriptor
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
331
|
+
// Localized replacements always address one paragraph (the contract refuses ranges and
|
|
332
|
+
// captures), so "\n" in the target text is a soft line break (w:br), not a paragraph
|
|
333
|
+
// boundary. Strong or weak (index + exactText) it binds at batch start; leaving it
|
|
334
|
+
// dynamic would skip replacement compilation and apply an empty "modified".
|
|
335
|
+
&& !Array.isArray(operation.replacements);
|
|
253
336
|
if (dynamicOccurrence || compositeTextTarget) binding.dynamic = true;
|
|
254
337
|
|
|
255
338
|
if (
|
|
@@ -346,6 +429,28 @@ export function compileOperationBatch(xmlDoc, operations = [], options = {}) {
|
|
|
346
429
|
binding.sourceIds = [...new Set(binding.sourceIds)];
|
|
347
430
|
binding.resolvedTargetEnd = end.metadata;
|
|
348
431
|
}
|
|
432
|
+
|
|
433
|
+
// list-format join reads (never writes) the paragraph it continues; bind it at batch start too.
|
|
434
|
+
if (operation.operationKind === 'list-format' && operation.list?.continueFrom && typeof operation.list.continueFrom === 'object') {
|
|
435
|
+
let continueFrom;
|
|
436
|
+
try {
|
|
437
|
+
continueFrom = resolveDescriptor(
|
|
438
|
+
xmlDoc,
|
|
439
|
+
normalizeTargetDescriptor(operation.list.continueFrom, null, 'accepted'),
|
|
440
|
+
operation.operationKind,
|
|
441
|
+
strictTargets,
|
|
442
|
+
metadataIndices,
|
|
443
|
+
{
|
|
444
|
+
onInfo: options.onInfo,
|
|
445
|
+
onWarn: warning => binding.warnings.push(String(warning))
|
|
446
|
+
}
|
|
447
|
+
);
|
|
448
|
+
} catch (error) {
|
|
449
|
+
throw Object.assign(error instanceof Error ? error : new Error(String(error)), { field: 'list.continueFrom' });
|
|
450
|
+
}
|
|
451
|
+
compiled._compiledContinueFromId = registry.register(continueFrom.paragraph, continueFrom.metadata);
|
|
452
|
+
binding.resolvedContinueFrom = continueFrom.metadata;
|
|
453
|
+
}
|
|
349
454
|
} catch (error) {
|
|
350
455
|
binding.error = normalizedError(error);
|
|
351
456
|
}
|
|
@@ -388,17 +493,84 @@ export function compileOperationBatch(xmlDoc, operations = [], options = {}) {
|
|
|
388
493
|
}
|
|
389
494
|
|
|
390
495
|
const bySource = new Map();
|
|
496
|
+
const rebindsBySource = new Map();
|
|
391
497
|
for (const binding of bindings) {
|
|
392
498
|
if (binding.error || binding.dynamic) continue;
|
|
393
499
|
for (const sourceId of binding.sourceIds) {
|
|
394
500
|
if (!bySource.has(sourceId)) bySource.set(sourceId, []);
|
|
395
501
|
bySource.get(sourceId).push(binding);
|
|
396
502
|
}
|
|
503
|
+
// A scope "list" change-format/join writes pPr on every live item of the target's list, so those items are
|
|
504
|
+
// registered as format writes of the operation: another write to them must compose or is refused.
|
|
505
|
+
if (binding.operationKind === 'list-format') {
|
|
506
|
+
const operation = compiledOperations[binding.index - 1];
|
|
507
|
+
for (const paragraph of listScopeRebindParagraphs(xmlDoc, registry, binding, operation)) {
|
|
508
|
+
const sourceId = registry.register(paragraph, metadataIndices.accepted.byParagraph.get(paragraph) || {});
|
|
509
|
+
if (binding.sourceIds.includes(sourceId)) continue;
|
|
510
|
+
(binding.rebindSourceIds ||= []).push(sourceId);
|
|
511
|
+
if (!bySource.has(sourceId)) bySource.set(sourceId, []);
|
|
512
|
+
if (!rebindsBySource.has(sourceId)) rebindsBySource.set(sourceId, []);
|
|
513
|
+
rebindsBySource.get(sourceId).push(binding);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
397
516
|
}
|
|
398
517
|
|
|
399
518
|
const conflicts = [];
|
|
400
519
|
const conflictKeys = new Set();
|
|
401
|
-
|
|
520
|
+
const pushConflict = (key, conflict) => {
|
|
521
|
+
if (conflictKeys.has(key)) return;
|
|
522
|
+
conflictKeys.add(key);
|
|
523
|
+
conflicts.push(conflict);
|
|
524
|
+
};
|
|
525
|
+
for (const [sourceId, targetBindings] of bySource) {
|
|
526
|
+
const rebinds = rebindsBySource.get(sourceId) || [];
|
|
527
|
+
const writers = [...targetBindings, ...rebinds]
|
|
528
|
+
.filter(binding => TEXT_WRITE_KINDS.has(binding.operationKind) || FORMAT_WRITE_KINDS.has(binding.operationKind));
|
|
529
|
+
const listFormats = writers.filter(binding => binding.operationKind === 'list-format');
|
|
530
|
+
const others = writers.filter(binding => binding.operationKind !== 'list-format');
|
|
531
|
+
if (listFormats.length === 1 && others.length === 1 && others[0].operationKind === 'redline') {
|
|
532
|
+
const [listFormat] = listFormats;
|
|
533
|
+
const [redline] = others;
|
|
534
|
+
const describedSource = registry.describe(sourceId);
|
|
535
|
+
const incompatible = checkListFormatComposition(
|
|
536
|
+
{ operation: compiledOperations[redline.index - 1] },
|
|
537
|
+
redline.resolvedTarget?.text ?? describedSource?.text ?? ''
|
|
538
|
+
);
|
|
539
|
+
if (incompatible) {
|
|
540
|
+
const affected = [redline, listFormat];
|
|
541
|
+
pushConflict(`list:${affected.map(binding => binding.index).sort().join(',')}`, buildConflict(
|
|
542
|
+
LIST_FORMAT_COMPOSITION_CONFLICT,
|
|
543
|
+
`Operations ${affected.map(binding => binding.index).sort((a, b) => a - b).join(', ')} combine a redline and a list-format on the same paragraph in a way that does not compose (${incompatible.reason}).`,
|
|
544
|
+
affected,
|
|
545
|
+
describedSource,
|
|
546
|
+
'split_text_and_list_changes',
|
|
547
|
+
{ reason: incompatible.reason, ...(incompatible.sourceMarker !== undefined ? { sourceMarker: incompatible.sourceMarker, modifiedMarker: incompatible.modifiedMarker } : {}) },
|
|
548
|
+
incompatible.hint
|
|
549
|
+
));
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
// Deterministic order: the list-format runs after the redline, whatever the batch order.
|
|
553
|
+
const compiledListFormat = compiledOperations[listFormat.index - 1];
|
|
554
|
+
const runAfter = new Set(compiledListFormat._compiledRunAfter || []);
|
|
555
|
+
runAfter.add(redline.index - 1);
|
|
556
|
+
compiledListFormat._compiledRunAfter = [...runAfter].sort((a, b) => a - b);
|
|
557
|
+
listFormat.composedWith = [...new Set([...(listFormat.composedWith || []), redline.index])];
|
|
558
|
+
redline.composedWith = [...new Set([...(redline.composedWith || []), listFormat.index])];
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (rebinds.length > 0 && writers.length > 1) {
|
|
562
|
+
pushConflict(`rebind:${writers.map(binding => binding.index).sort().join(',')}`, buildConflict(
|
|
563
|
+
LIST_FORMAT_COMPOSITION_CONFLICT,
|
|
564
|
+
`Operation ${rebinds.map(binding => binding.index).join(', ')} (list-format, scope "list") rebinds this list item, and operations ${writers.filter(binding => !rebinds.includes(binding)).map(binding => binding.index).join(', ')} also write it.`,
|
|
565
|
+
writers,
|
|
566
|
+
registry.describe(sourceId),
|
|
567
|
+
'split_text_and_list_changes',
|
|
568
|
+
{ reason: 'list-rebind', rebindingOperationIndexes: rebinds.map(binding => binding.index) },
|
|
569
|
+
'A list-format with scope "list" rewrites every item of the list; in the same batch only one single-paragraph redline per item can compose with it. Send the other change in a separate apply, or use scope "range".'
|
|
570
|
+
));
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
const sourceBindings = targetBindings;
|
|
402
574
|
const textWrites = sourceBindings.filter(binding => TEXT_WRITE_KINDS.has(binding.operationKind));
|
|
403
575
|
const formatWrites = sourceBindings.filter(binding => FORMAT_WRITE_KINDS.has(binding.operationKind));
|
|
404
576
|
if (textWrites.length > 1) {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
6
6
|
import { getDefaultAuthor } from '../adapters/config.js';
|
|
7
|
-
import { containsTrackedChanges, getTrackedChangeAuthors } from '../core/word-xml.js';
|
|
7
|
+
import { containsTrackedChanges, getTrackedChangeAuthors, hasUnattributedTrackedChange, resolveExistingRevisionsPolicy } from '../core/word-xml.js';
|
|
8
8
|
import { NS_W } from '../core/types.js';
|
|
9
9
|
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
10
10
|
import {
|
|
@@ -38,6 +38,7 @@ function normalizedError(error, context = {}) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
function operationNeedsNumbering(operation) {
|
|
41
|
+
if (operation.operationKind === 'list-format') return true;
|
|
41
42
|
if (operation.operationKind !== 'redline' || typeof operation.modified !== 'string') return false;
|
|
42
43
|
return operation.modified.split(/\r?\n/).some(line => /^\s*(?:[-+*]|\d+[.)])\s+/.test(line));
|
|
43
44
|
}
|
|
@@ -272,42 +273,49 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
272
273
|
: null;
|
|
273
274
|
const anchorFound = anchor == null
|
|
274
275
|
|| (anchorResolution ? anchorResolution.found : paragraphText.includes(anchor));
|
|
276
|
+
// The paragraphs from the target through targetEnd (just the target without one); null when the
|
|
277
|
+
// end does not resolve after the start.
|
|
278
|
+
const rangeParagraphs = opType => {
|
|
279
|
+
if (operation.targetEndRef) {
|
|
280
|
+
return resolveParagraphRangeByRefs(xmlDoc, operation.targetRef, operation.targetEndRef, {
|
|
281
|
+
opType,
|
|
282
|
+
targetRefSnapshot: options.targetRefSnapshot || null,
|
|
283
|
+
onInfo: options.onInfo,
|
|
284
|
+
onWarn: options.onWarn
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
if (!operation.targetEndDescriptor) return [paragraph];
|
|
288
|
+
const endResolved = resolveTargetParagraph(xmlDoc, {
|
|
289
|
+
targetText: operation.targetEndDescriptor.text,
|
|
290
|
+
targetRef: operation.targetEndDescriptor.index,
|
|
291
|
+
targetDescriptor: operation.targetEndDescriptor,
|
|
292
|
+
opType,
|
|
293
|
+
strictAmbiguity: strictTargets,
|
|
294
|
+
paragraphMetadataIndex: currentMetadataIndex,
|
|
295
|
+
metadataIndices,
|
|
296
|
+
onInfo: options.onInfo,
|
|
297
|
+
onWarn: options.onWarn
|
|
298
|
+
});
|
|
299
|
+
const allParagraphs = getDocumentParagraphNodes(xmlDoc);
|
|
300
|
+
const startIndex = allParagraphs.indexOf(paragraph);
|
|
301
|
+
const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
|
|
302
|
+
return startIndex >= 0 && endIndex >= startIndex
|
|
303
|
+
? allParagraphs.slice(startIndex, endIndex + 1)
|
|
304
|
+
: null;
|
|
305
|
+
};
|
|
275
306
|
const hasRevisions = containsTrackedChanges(paragraph);
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
307
|
+
const requestedPolicy = operation.existingRevisions || options.existingRevisions || 'merge-same-author';
|
|
308
|
+
const existingPolicy = resolveExistingRevisionsPolicy(requestedPolicy,
|
|
309
|
+
requestedPolicy === 'auto' && operation.operationKind === 'redline' && (operation.targetEndRef || operation.targetEndDescriptor)
|
|
310
|
+
? (rangeParagraphs('redline') || paragraph)
|
|
311
|
+
: paragraph,
|
|
312
|
+
authorUsed);
|
|
279
313
|
const deletingWholeParagraph = operation.operationKind === 'redline' && operation.modified === '';
|
|
280
314
|
const commentIds = deletingWholeParagraph ? getCommentIdsInParagraph(paragraph) : [];
|
|
281
315
|
|
|
282
316
|
let error = null;
|
|
283
317
|
if (operation.operationKind === 'restore') {
|
|
284
|
-
|
|
285
|
-
? resolveParagraphRangeByRefs(xmlDoc, operation.targetRef, operation.targetEndRef, {
|
|
286
|
-
opType: 'restore',
|
|
287
|
-
targetRefSnapshot: options.targetRefSnapshot || null,
|
|
288
|
-
onInfo: options.onInfo,
|
|
289
|
-
onWarn: options.onWarn
|
|
290
|
-
})
|
|
291
|
-
: [paragraph];
|
|
292
|
-
if (!operation.targetEndRef && operation.targetEndDescriptor) {
|
|
293
|
-
const endResolved = resolveTargetParagraph(xmlDoc, {
|
|
294
|
-
targetText: operation.targetEndDescriptor.text,
|
|
295
|
-
targetRef: operation.targetEndDescriptor.index,
|
|
296
|
-
targetDescriptor: operation.targetEndDescriptor,
|
|
297
|
-
opType: 'restore',
|
|
298
|
-
strictAmbiguity: strictTargets,
|
|
299
|
-
paragraphMetadataIndex: currentMetadataIndex,
|
|
300
|
-
metadataIndices,
|
|
301
|
-
onInfo: options.onInfo,
|
|
302
|
-
onWarn: options.onWarn
|
|
303
|
-
});
|
|
304
|
-
const allParagraphs = getDocumentParagraphNodes(xmlDoc);
|
|
305
|
-
const startIndex = allParagraphs.indexOf(paragraph);
|
|
306
|
-
const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
|
|
307
|
-
restorationParagraphs = startIndex >= 0 && endIndex >= startIndex
|
|
308
|
-
? allParagraphs.slice(startIndex, endIndex + 1)
|
|
309
|
-
: null;
|
|
310
|
-
}
|
|
318
|
+
const restorationParagraphs = rangeParagraphs('restore');
|
|
311
319
|
const replacements = Array.isArray(operation.modified) ? operation.modified : [operation.modified];
|
|
312
320
|
if (!restorationParagraphs || restorationParagraphs[0] !== paragraph || replacements.length !== restorationParagraphs.length) {
|
|
313
321
|
error = {
|
|
@@ -363,11 +371,13 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
363
371
|
if (existingPolicy === 'merge-same-author' || existingPolicy === 'slice-cross-author') {
|
|
364
372
|
const authors = getTrackedChangeAuthors(paragraph);
|
|
365
373
|
const opAuthor = String(authorUsed || '').trim().toLowerCase();
|
|
366
|
-
const allSame = authors.length > 0
|
|
374
|
+
const allSame = authors.length > 0
|
|
375
|
+
&& !hasUnattributedTrackedChange(paragraph)
|
|
376
|
+
&& authors.every(a => a.trim().toLowerCase() === opAuthor);
|
|
367
377
|
if (!allSame && existingPolicy === 'merge-same-author') {
|
|
368
378
|
error = {
|
|
369
379
|
code: 'EXISTING_REVISIONS',
|
|
370
|
-
message: `Target paragraph contains tracked changes from another author (${authors
|
|
380
|
+
message: `Target paragraph contains tracked changes from another author (${[...authors, ...(hasUnattributedTrackedChange(paragraph) ? ['unattributed'] : [])].join(', ')}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
|
|
371
381
|
revisionAuthors: authors,
|
|
372
382
|
currentPolicy: existingPolicy
|
|
373
383
|
};
|