@ansonlai/docx-redline-js 0.5.0 → 0.5.1

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.
@@ -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);