@ansonlai/docx-redline-js 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +76 -15
- package/CHANGELOG.md +24 -1
- package/README.md +43 -11
- package/core/redline-validation.js +11 -5
- package/dist/docx-redline-js.esm.js +363 -55
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +77 -74
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +18 -0
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +575 -0
- package/docs/schemas/document-operations.schema.json +1 -1
- package/engine/oxml-engine.js +39 -16
- package/engine/surgical-diff-application.js +203 -19
- package/engine/surgical-mode.js +55 -10
- package/engine/surgical-run-splitting.js +103 -0
- package/index.d.ts +1 -1
- package/node/cli.js +12 -7
- package/package.json +123 -123
- package/pipeline/diff-engine.js +22 -0
- package/scripts/generate-cross-author-slicing-fixtures.ps1 +256 -0
- package/services/batch-operation-orchestrator.js +24 -5
- package/services/document-operation-contract.js +22 -0
- package/services/document-operation-mutations.js +19 -3
- package/services/operation-preflight.js +32 -8
- package/services/revision-comment-management.js +40 -0
|
@@ -860,11 +860,11 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
|
|
|
860
860
|
&& existingPolicy !== 'accept-all-first'
|
|
861
861
|
&& existingPolicy !== 'accept-all-first-keep-normalized'
|
|
862
862
|
) {
|
|
863
|
-
if (existingPolicy === 'merge-same-author') {
|
|
863
|
+
if (existingPolicy === 'merge-same-author' || existingPolicy === 'slice-cross-author') {
|
|
864
864
|
const authors = getTrackedChangeAuthors(targetParagraph);
|
|
865
865
|
const opAuthor = String(author || '').trim().toLowerCase();
|
|
866
866
|
const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
|
|
867
|
-
if (!allSame) {
|
|
867
|
+
if (!allSame && existingPolicy === 'merge-same-author') {
|
|
868
868
|
return {
|
|
869
869
|
documentXml,
|
|
870
870
|
hasChanges: false,
|
|
@@ -876,7 +876,7 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
|
|
|
876
876
|
}
|
|
877
877
|
};
|
|
878
878
|
}
|
|
879
|
-
const mergeCommentIds = getCommentIdsInElement(targetParagraph);
|
|
879
|
+
const mergeCommentIds = allSame ? getCommentIdsInElement(targetParagraph) : [];
|
|
880
880
|
if (mergeCommentIds.length > 0) {
|
|
881
881
|
const comments = commentDetailsForIds(mergeCommentIds, options._existingCommentDetails);
|
|
882
882
|
return {
|
|
@@ -892,6 +892,22 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
|
|
|
892
892
|
}
|
|
893
893
|
};
|
|
894
894
|
}
|
|
895
|
+
if (!allSame) {
|
|
896
|
+
const hasMove = targetParagraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
|
|
897
|
+
|| targetParagraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
|
|
898
|
+
if (hasMove) {
|
|
899
|
+
return {
|
|
900
|
+
documentXml,
|
|
901
|
+
hasChanges: false,
|
|
902
|
+
numberingXml: null,
|
|
903
|
+
status: 'error',
|
|
904
|
+
error: {
|
|
905
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
906
|
+
message: 'Cross-author slicing does not support pending move revisions.'
|
|
907
|
+
}
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
}
|
|
895
911
|
} else {
|
|
896
912
|
const hasDel = targetParagraph.getElementsByTagNameNS(NS_W, 'del').length > 0;
|
|
897
913
|
const hasMove = targetParagraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
|
|
@@ -18,8 +18,9 @@ import {
|
|
|
18
18
|
validateParagraphBoundaryMutation
|
|
19
19
|
} from '../core/paragraph-targeting.js';
|
|
20
20
|
import { createParagraphTextIndex, resolveTextInParagraphIndex } from './comment-locator.js';
|
|
21
|
-
import {
|
|
22
|
-
|
|
21
|
+
import {
|
|
22
|
+
isExistingRevisionsPolicy,
|
|
23
|
+
normalizeDocumentOperation,
|
|
23
24
|
resolveDocumentOperationAuthor,
|
|
24
25
|
validateDocumentOperation
|
|
25
26
|
} from './document-operation-contract.js';
|
|
@@ -79,8 +80,22 @@ function getCommentIdsInParagraph(paragraph) {
|
|
|
79
80
|
return Array.from(ids).sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
export function preflightOperations(documentXml, operations, author, options = {}) {
|
|
83
|
-
|
|
83
|
+
export function preflightOperations(documentXml, operations, author, options = {}) {
|
|
84
|
+
if (options.existingRevisions != null && !isExistingRevisionsPolicy(options.existingRevisions)) {
|
|
85
|
+
return {
|
|
86
|
+
valid: false,
|
|
87
|
+
status: 'error',
|
|
88
|
+
error: {
|
|
89
|
+
code: 'INVALID_OPERATION',
|
|
90
|
+
message: `Unsupported existingRevisions policy: "${String(options.existingRevisions)}".`
|
|
91
|
+
},
|
|
92
|
+
results: [],
|
|
93
|
+
conflicts: [],
|
|
94
|
+
authorsUsed: [],
|
|
95
|
+
requiredArtifacts: { comments: false, numbering: false }
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const parsed = parseOoxmlSafe(documentXml, 'application/xml');
|
|
84
99
|
if (parsed.error || !parsed.doc) {
|
|
85
100
|
return {
|
|
86
101
|
valid: false,
|
|
@@ -228,16 +243,16 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
228
243
|
&& existingPolicy !== 'accept-all-first'
|
|
229
244
|
&& existingPolicy !== 'accept-all-first-keep-normalized'
|
|
230
245
|
) {
|
|
231
|
-
if (existingPolicy === 'merge-same-author') {
|
|
246
|
+
if (existingPolicy === 'merge-same-author' || existingPolicy === 'slice-cross-author') {
|
|
232
247
|
const authors = getTrackedChangeAuthors(paragraph);
|
|
233
248
|
const opAuthor = String(authorUsed || '').trim().toLowerCase();
|
|
234
249
|
const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
|
|
235
|
-
if (!allSame) {
|
|
236
|
-
error = {
|
|
250
|
+
if (!allSame && existingPolicy === 'merge-same-author') {
|
|
251
|
+
error = {
|
|
237
252
|
code: 'EXISTING_REVISIONS',
|
|
238
253
|
message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Pass existingRevisions: "accept-all-first" or resolve revisions first.`
|
|
239
254
|
};
|
|
240
|
-
} else {
|
|
255
|
+
} else if (allSame) {
|
|
241
256
|
const mergeCommentIds = getCommentIdsInParagraph(paragraph);
|
|
242
257
|
if (mergeCommentIds.length > 0) {
|
|
243
258
|
const comments = mergeCommentIds
|
|
@@ -250,6 +265,15 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
250
265
|
...(comments.length > 0 ? { comments } : {})
|
|
251
266
|
};
|
|
252
267
|
}
|
|
268
|
+
} else {
|
|
269
|
+
const hasMove = paragraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
|
|
270
|
+
|| paragraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
|
|
271
|
+
if (hasMove) {
|
|
272
|
+
error = {
|
|
273
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
274
|
+
message: 'Cross-author slicing does not support pending move revisions.'
|
|
275
|
+
};
|
|
276
|
+
}
|
|
253
277
|
}
|
|
254
278
|
} else {
|
|
255
279
|
const hasDel = paragraph.getElementsByTagNameNS(NS_W, 'del').length > 0;
|
|
@@ -113,6 +113,44 @@ function unwrapNode(node) {
|
|
|
113
113
|
return true;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
function nextElementSibling(node) {
|
|
117
|
+
let sibling = node?.nextSibling || null;
|
|
118
|
+
while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;
|
|
119
|
+
return sibling;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function revisionMetadataWithoutId(node) {
|
|
123
|
+
return Array.from(node?.attributes || [])
|
|
124
|
+
.filter(attribute => String(attribute.localName || attribute.name || '').toLowerCase() !== 'id')
|
|
125
|
+
.map(attribute => `${attribute.namespaceURI || ''}|${attribute.localName || attribute.name}=${attribute.value}`)
|
|
126
|
+
.sort()
|
|
127
|
+
.join('\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function coalesceAdjacentCompatibleInsertions(xmlDoc) {
|
|
131
|
+
let coalesced = 0;
|
|
132
|
+
const insertions = getWordElementsByLocalName(xmlDoc, 'ins');
|
|
133
|
+
|
|
134
|
+
for (const insertion of insertions) {
|
|
135
|
+
if (!insertion.parentNode || isParagraphMarkRevisionMarker(insertion)) continue;
|
|
136
|
+
let sibling = nextElementSibling(insertion);
|
|
137
|
+
while (
|
|
138
|
+
isWordElement(sibling, 'ins')
|
|
139
|
+
&& normalizeAuthor(getAttributeByLocalName(sibling, 'author'))
|
|
140
|
+
=== normalizeAuthor(getAttributeByLocalName(insertion, 'author'))
|
|
141
|
+
&& revisionMetadataWithoutId(sibling) === revisionMetadataWithoutId(insertion)
|
|
142
|
+
) {
|
|
143
|
+
const next = nextElementSibling(sibling);
|
|
144
|
+
while (sibling.firstChild) insertion.appendChild(sibling.firstChild);
|
|
145
|
+
sibling.parentNode?.removeChild(sibling);
|
|
146
|
+
coalesced += 1;
|
|
147
|
+
sibling = next;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return coalesced;
|
|
152
|
+
}
|
|
153
|
+
|
|
116
154
|
function isTableRowRevisionMarker(node) {
|
|
117
155
|
const parent = node?.parentNode;
|
|
118
156
|
return isWordElement(parent, 'trPr') && isWordElement(parent?.parentNode, 'tr');
|
|
@@ -423,6 +461,8 @@ export function rejectTrackedChangesInOoxml(oxml, options = {}) {
|
|
|
423
461
|
}
|
|
424
462
|
}
|
|
425
463
|
|
|
464
|
+
coalesceAdjacentCompatibleInsertions(xmlDoc);
|
|
465
|
+
|
|
426
466
|
const serializedOxml = parseResult.isFragmentWrapped
|
|
427
467
|
? Array.from(xmlDoc.documentElement.childNodes).map(n => serializer.serializeToString(n)).join('')
|
|
428
468
|
: serializer.serializeToString(xmlDoc);
|