@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.
@@ -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
- normalizeDocumentOperation,
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
- const parsed = parseOoxmlSafe(documentXml, 'application/xml');
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);