@ansonlai/docx-redline-js 0.1.3 → 0.1.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/index.js CHANGED
@@ -234,12 +234,17 @@ export { preprocessMarkdown } from './pipeline/markdown-processor.js';
234
234
  export { serializeToOoxml, wrapInDocumentFragment } from './pipeline/serialization.js';
235
235
 
236
236
  // Comment engine
237
- export {
238
- injectCommentsIntoOoxml,
239
- injectCommentsIntoPackage,
240
- buildCommentElement,
241
- buildCommentsPartXml
242
- } from './services/comment-engine.js';
237
+ export {
238
+ injectCommentsIntoOoxml,
239
+ injectCommentsIntoPackage,
240
+ buildCommentElement,
241
+ buildCommentsPartXml
242
+ } from './services/comment-engine.js';
243
+ export {
244
+ acceptTrackedChangesInOoxml,
245
+ rejectTrackedChangesInOoxml,
246
+ deleteCommentsByAuthorInOoxml
247
+ } from './services/revision-comment-management.js';
243
248
 
244
249
  // Formatting removal utilities
245
250
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansonlai/docx-redline-js",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Revision/comment management utilities for OOXML payloads.
3
+ */
4
+
5
+ import { NS_W } from '../core/types.js';
6
+ import { createParser, createSerializer } from '../adapters/xml-adapter.js';
7
+ import { getXmlParseError } from '../core/xml-query.js';
8
+
9
+ function getAttributeByLocalName(node, localName) {
10
+ if (!node || !node.attributes) return '';
11
+ for (const attr of Array.from(node.attributes)) {
12
+ if ((attr.localName || '').toLowerCase() === localName.toLowerCase()) {
13
+ return String(attr.value || '');
14
+ }
15
+ }
16
+ return String(
17
+ node.getAttribute?.(`w:${localName}`)
18
+ || node.getAttribute?.(localName)
19
+ || ''
20
+ );
21
+ }
22
+
23
+ function normalizeAuthor(author) {
24
+ return typeof author === 'string' ? author.trim().toLowerCase() : '';
25
+ }
26
+
27
+ function isElement(node) {
28
+ return !!node && node.nodeType === 1;
29
+ }
30
+
31
+ function isWordElement(node, localName) {
32
+ return isElement(node)
33
+ && node.namespaceURI === NS_W
34
+ && String(node.localName || '').toLowerCase() === localName.toLowerCase();
35
+ }
36
+
37
+ function getWordElementsByLocalName(xmlDoc, localName) {
38
+ return Array.from(xmlDoc.getElementsByTagNameNS(NS_W, localName));
39
+ }
40
+
41
+ function resolveAuthorFilter(options = {}) {
42
+ if (options?.allAuthors === true) {
43
+ return { valid: true, allAuthors: true, normalizedAuthor: '' };
44
+ }
45
+ const normalizedAuthor = normalizeAuthor(options?.author);
46
+ if (!normalizedAuthor) {
47
+ return {
48
+ valid: false,
49
+ allAuthors: false,
50
+ normalizedAuthor: '',
51
+ warning: 'No author provided. Pass { author } or set { allAuthors: true }.'
52
+ };
53
+ }
54
+ return { valid: true, allAuthors: false, normalizedAuthor };
55
+ }
56
+
57
+ function authorMatchesNode(node, filter) {
58
+ if (filter.allAuthors) return true;
59
+ const nodeAuthor = normalizeAuthor(getAttributeByLocalName(node, 'author'));
60
+ return !!nodeAuthor && nodeAuthor === filter.normalizedAuthor;
61
+ }
62
+
63
+ function parseXmlWithWarnings(oxml, parseFailurePrefix) {
64
+ const parser = createParser();
65
+ const xmlDoc = parser.parseFromString(oxml, 'application/xml');
66
+ const parseError = getXmlParseError(xmlDoc);
67
+ if (parseError) {
68
+ return {
69
+ xmlDoc: null,
70
+ serializer: null,
71
+ warning: `${parseFailurePrefix}: ${parseError.textContent || 'parse error'}`
72
+ };
73
+ }
74
+ return { xmlDoc, serializer: createSerializer(), warning: null };
75
+ }
76
+
77
+ function removeNode(node) {
78
+ if (node?.parentNode) {
79
+ node.parentNode.removeChild(node);
80
+ return true;
81
+ }
82
+ return false;
83
+ }
84
+
85
+ function unwrapNode(node) {
86
+ const parent = node?.parentNode;
87
+ if (!parent) return false;
88
+ while (node.firstChild) {
89
+ parent.insertBefore(node.firstChild, node);
90
+ }
91
+ parent.removeChild(node);
92
+ return true;
93
+ }
94
+
95
+ function isTableRowRevisionMarker(node) {
96
+ const parent = node?.parentNode;
97
+ return isWordElement(parent, 'trPr') && isWordElement(parent?.parentNode, 'tr');
98
+ }
99
+
100
+ /**
101
+ * Accepts tracked changes (`w:ins`, `w:del`, and *PrChange tags) for one author
102
+ * or all authors in the provided OOXML payload.
103
+ *
104
+ * @param {string} oxml
105
+ * @param {{ author?: string, allAuthors?: boolean }} [options]
106
+ * @returns {{ oxml: string, hasChanges: boolean, acceptedCount: number, warnings: string[] }}
107
+ */
108
+ export function acceptTrackedChangesInOoxml(oxml, options = {}) {
109
+ const warnings = [];
110
+ const filter = resolveAuthorFilter(options);
111
+ if (!filter.valid) {
112
+ return { oxml, hasChanges: false, acceptedCount: 0, warnings: [filter.warning] };
113
+ }
114
+
115
+ const parseResult = parseXmlWithWarnings(oxml, 'Failed to parse OOXML');
116
+ if (!parseResult.xmlDoc) {
117
+ return { oxml, hasChanges: false, acceptedCount: 0, warnings: [parseResult.warning] };
118
+ }
119
+
120
+ const { xmlDoc, serializer } = parseResult;
121
+ let acceptedCount = 0;
122
+
123
+ for (const insNode of getWordElementsByLocalName(xmlDoc, 'ins')) {
124
+ if (!insNode.parentNode || !authorMatchesNode(insNode, filter)) continue;
125
+ if (isTableRowRevisionMarker(insNode)) {
126
+ if (removeNode(insNode)) acceptedCount += 1;
127
+ continue;
128
+ }
129
+ if (unwrapNode(insNode)) acceptedCount += 1;
130
+ }
131
+
132
+ for (const delNode of getWordElementsByLocalName(xmlDoc, 'del')) {
133
+ if (!delNode.parentNode || !authorMatchesNode(delNode, filter)) continue;
134
+ if (isTableRowRevisionMarker(delNode)) {
135
+ const rowNode = delNode.parentNode?.parentNode;
136
+ if (removeNode(rowNode)) acceptedCount += 1;
137
+ continue;
138
+ }
139
+ if (removeNode(delNode)) acceptedCount += 1;
140
+ }
141
+
142
+ const changeTags = ['rPrChange', 'pPrChange', 'tblPrChange', 'trPrChange', 'tcPrChange'];
143
+ for (const localName of changeTags) {
144
+ for (const changeNode of getWordElementsByLocalName(xmlDoc, localName)) {
145
+ if (!changeNode.parentNode || !authorMatchesNode(changeNode, filter)) continue;
146
+ if (removeNode(changeNode)) acceptedCount += 1;
147
+ }
148
+ }
149
+
150
+ return {
151
+ oxml: serializer.serializeToString(xmlDoc),
152
+ hasChanges: acceptedCount > 0,
153
+ acceptedCount,
154
+ warnings
155
+ };
156
+ }
157
+
158
+ function convertDeletionTextNodes(xmlDoc, delNode) {
159
+ for (const delTextNode of Array.from(delNode.getElementsByTagNameNS(NS_W, 'delText'))) {
160
+ const normalText = xmlDoc.createElementNS(NS_W, 'w:t');
161
+ const spaceValue = delTextNode.getAttribute('xml:space');
162
+ if (spaceValue) {
163
+ normalText.setAttribute('xml:space', spaceValue);
164
+ }
165
+ while (delTextNode.firstChild) {
166
+ normalText.appendChild(delTextNode.firstChild);
167
+ }
168
+ delTextNode.parentNode?.replaceChild(normalText, delTextNode);
169
+ }
170
+ }
171
+
172
+ function rejectPropertyChangeNode(changeNode, localName) {
173
+ const parent = changeNode?.parentNode;
174
+ if (!parent) return false;
175
+
176
+ const baseLocalName = localName.endsWith('Change')
177
+ ? localName.slice(0, -'Change'.length)
178
+ : '';
179
+
180
+ if (
181
+ !baseLocalName
182
+ || String(parent.localName || '').toLowerCase() !== baseLocalName.toLowerCase()
183
+ || parent.namespaceURI !== NS_W
184
+ ) {
185
+ return removeNode(changeNode);
186
+ }
187
+
188
+ const historicalNode = Array.from(changeNode.childNodes || []).find(
189
+ child => child.nodeType === 1 && child.namespaceURI === NS_W
190
+ && String(child.localName || '').toLowerCase() === baseLocalName.toLowerCase()
191
+ );
192
+
193
+ if (!historicalNode) {
194
+ return removeNode(changeNode);
195
+ }
196
+
197
+ const toAppend = Array.from(historicalNode.childNodes || []);
198
+ while (parent.firstChild) {
199
+ parent.removeChild(parent.firstChild);
200
+ }
201
+ for (const node of toAppend) {
202
+ const clone = xmlDocImportNode(parent.ownerDocument, node);
203
+ parent.appendChild(clone);
204
+ }
205
+ return true;
206
+ }
207
+
208
+ function xmlDocImportNode(xmlDoc, node) {
209
+ if (xmlDoc && typeof xmlDoc.importNode === 'function') {
210
+ return xmlDoc.importNode(node, true);
211
+ }
212
+ return node.cloneNode(true);
213
+ }
214
+
215
+ /**
216
+ * Rejects tracked changes (`w:ins`, `w:del`, and *PrChange tags) for one author
217
+ * or all authors in the provided OOXML payload.
218
+ *
219
+ * @param {string} oxml
220
+ * @param {{ author?: string, allAuthors?: boolean }} [options]
221
+ * @returns {{ oxml: string, hasChanges: boolean, rejectedCount: number, warnings: string[] }}
222
+ */
223
+ export function rejectTrackedChangesInOoxml(oxml, options = {}) {
224
+ const warnings = [];
225
+ const filter = resolveAuthorFilter(options);
226
+ if (!filter.valid) {
227
+ return { oxml, hasChanges: false, rejectedCount: 0, warnings: [filter.warning] };
228
+ }
229
+
230
+ const parseResult = parseXmlWithWarnings(oxml, 'Failed to parse OOXML');
231
+ if (!parseResult.xmlDoc) {
232
+ return { oxml, hasChanges: false, rejectedCount: 0, warnings: [parseResult.warning] };
233
+ }
234
+
235
+ const { xmlDoc, serializer } = parseResult;
236
+ let rejectedCount = 0;
237
+
238
+ for (const insNode of getWordElementsByLocalName(xmlDoc, 'ins')) {
239
+ if (!insNode.parentNode || !authorMatchesNode(insNode, filter)) continue;
240
+ if (isTableRowRevisionMarker(insNode)) {
241
+ const rowNode = insNode.parentNode?.parentNode;
242
+ if (removeNode(rowNode)) rejectedCount += 1;
243
+ continue;
244
+ }
245
+ if (removeNode(insNode)) rejectedCount += 1;
246
+ }
247
+
248
+ for (const delNode of getWordElementsByLocalName(xmlDoc, 'del')) {
249
+ if (!delNode.parentNode || !authorMatchesNode(delNode, filter)) continue;
250
+ if (isTableRowRevisionMarker(delNode)) {
251
+ if (removeNode(delNode)) rejectedCount += 1;
252
+ continue;
253
+ }
254
+ convertDeletionTextNodes(xmlDoc, delNode);
255
+ if (unwrapNode(delNode)) rejectedCount += 1;
256
+ }
257
+
258
+ const changeTags = ['rPrChange', 'pPrChange', 'tblPrChange', 'trPrChange', 'tcPrChange'];
259
+ for (const localName of changeTags) {
260
+ for (const changeNode of getWordElementsByLocalName(xmlDoc, localName)) {
261
+ if (!changeNode.parentNode || !authorMatchesNode(changeNode, filter)) continue;
262
+ if (rejectPropertyChangeNode(changeNode, localName)) rejectedCount += 1;
263
+ }
264
+ }
265
+
266
+ return {
267
+ oxml: serializer.serializeToString(xmlDoc),
268
+ hasChanges: rejectedCount > 0,
269
+ rejectedCount,
270
+ warnings
271
+ };
272
+ }
273
+
274
+ function collectCommentTargetIds(xmlDoc, filter) {
275
+ const targetIds = new Set();
276
+ const commentNodes = getWordElementsByLocalName(xmlDoc, 'comment');
277
+
278
+ for (const commentNode of commentNodes) {
279
+ if (!authorMatchesNode(commentNode, filter)) continue;
280
+ const id = getAttributeByLocalName(commentNode, 'id');
281
+ if (id) targetIds.add(id);
282
+ }
283
+
284
+ return { targetIds, commentNodes };
285
+ }
286
+
287
+ function removeCommentNodesById(commentNodes, targetIds) {
288
+ let removed = 0;
289
+ for (const commentNode of commentNodes) {
290
+ const id = getAttributeByLocalName(commentNode, 'id');
291
+ if (!id || !targetIds.has(id)) continue;
292
+ if (removeNode(commentNode)) removed += 1;
293
+ }
294
+ return removed;
295
+ }
296
+
297
+ function runIsOnlyCommentReference(runNode) {
298
+ if (!isWordElement(runNode, 'r')) return false;
299
+ const meaningfulChildren = Array.from(runNode.childNodes || []).filter(child => {
300
+ if (child.nodeType === 3) return String(child.nodeValue || '').trim().length > 0;
301
+ if (child.nodeType !== 1) return false;
302
+ if (child.namespaceURI !== NS_W) return true;
303
+ const local = String(child.localName || '').toLowerCase();
304
+ return local !== 'rpr' && local !== 'commentreference';
305
+ });
306
+ return meaningfulChildren.length === 0;
307
+ }
308
+
309
+ function removeCommentAnchors(xmlDoc, targetIds) {
310
+ let removed = 0;
311
+ const anchorTags = ['commentRangeStart', 'commentRangeEnd', 'commentReference'];
312
+
313
+ for (const localName of anchorTags) {
314
+ for (const node of getWordElementsByLocalName(xmlDoc, localName)) {
315
+ if (!node.parentNode) continue;
316
+ const id = getAttributeByLocalName(node, 'id');
317
+ if (!id || !targetIds.has(id)) continue;
318
+
319
+ if (localName === 'commentReference' && runIsOnlyCommentReference(node.parentNode)) {
320
+ if (removeNode(node.parentNode)) {
321
+ removed += 1;
322
+ }
323
+ continue;
324
+ }
325
+ if (removeNode(node)) {
326
+ removed += 1;
327
+ }
328
+ }
329
+ }
330
+ return removed;
331
+ }
332
+
333
+ /**
334
+ * Deletes comments authored by one user (or all users) and removes matching
335
+ * comment anchors/references from the OOXML payload.
336
+ *
337
+ * @param {string} oxml
338
+ * @param {{ author?: string, allAuthors?: boolean }} [options]
339
+ * @returns {{ oxml: string, hasChanges: boolean, commentsRemoved: number, referencesRemoved: number, warnings: string[] }}
340
+ */
341
+ export function deleteCommentsByAuthorInOoxml(oxml, options = {}) {
342
+ const warnings = [];
343
+ const filter = resolveAuthorFilter(options);
344
+ if (!filter.valid) {
345
+ return {
346
+ oxml,
347
+ hasChanges: false,
348
+ commentsRemoved: 0,
349
+ referencesRemoved: 0,
350
+ warnings: [filter.warning]
351
+ };
352
+ }
353
+
354
+ const parseResult = parseXmlWithWarnings(oxml, 'Failed to parse OOXML');
355
+ if (!parseResult.xmlDoc) {
356
+ return {
357
+ oxml,
358
+ hasChanges: false,
359
+ commentsRemoved: 0,
360
+ referencesRemoved: 0,
361
+ warnings: [parseResult.warning]
362
+ };
363
+ }
364
+
365
+ const { xmlDoc, serializer } = parseResult;
366
+ const { targetIds, commentNodes } = collectCommentTargetIds(xmlDoc, filter);
367
+
368
+ if (filter.allAuthors) {
369
+ for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
370
+ for (const node of getWordElementsByLocalName(xmlDoc, localName)) {
371
+ const id = getAttributeByLocalName(node, 'id');
372
+ if (id) targetIds.add(id);
373
+ }
374
+ }
375
+ }
376
+
377
+ const commentsRemoved = removeCommentNodesById(commentNodes, targetIds);
378
+ const referencesRemoved = removeCommentAnchors(xmlDoc, targetIds);
379
+
380
+ return {
381
+ oxml: serializer.serializeToString(xmlDoc),
382
+ hasChanges: commentsRemoved > 0 || referencesRemoved > 0,
383
+ commentsRemoved,
384
+ referencesRemoved,
385
+ warnings
386
+ };
387
+ }