@ansonlai/docx-redline-js 0.4.0 → 0.5.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.
Files changed (100) hide show
  1. package/AGENTS.md +589 -287
  2. package/ARCHITECTURE.md +215 -9
  3. package/CHANGELOG.md +319 -0
  4. package/README.md +604 -360
  5. package/adapters/config.js +45 -43
  6. package/bin/docx-redline.js +3 -0
  7. package/core/list-targeting.js +101 -110
  8. package/core/paragraph-targeting.js +501 -61
  9. package/core/paragraph-text.js +209 -0
  10. package/core/revision-cloning.js +38 -0
  11. package/core/types.js +64 -10
  12. package/core/word-xml.js +43 -15
  13. package/dist/docx-redline-js.esm.js +2849 -466
  14. package/dist/docx-redline-js.esm.js.map +4 -4
  15. package/dist/docx-redline-js.esm.min.js +87 -76
  16. package/dist/docx-redline-js.esm.min.js.map +4 -4
  17. package/docs/TESTING.md +342 -23
  18. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  19. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  20. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  21. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  22. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  23. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  24. package/docs/schemas/document-operations.schema.json +109 -0
  25. package/docs/test-comparison-dashboard.html +4250 -7
  26. package/engine/formatting-removal.js +11 -2
  27. package/engine/oxml-engine.js +491 -336
  28. package/engine/reconstruction-mode.js +15 -14
  29. package/engine/reconstruction-writer.js +247 -142
  30. package/engine/route-selection.js +35 -0
  31. package/engine/rpr-helpers.js +334 -35
  32. package/engine/run-builders.js +239 -196
  33. package/engine/surgical-diff-application.js +222 -37
  34. package/engine/surgical-mode.js +134 -6
  35. package/engine/surgical-spans.js +52 -1
  36. package/engine/table-cell-context.js +3 -6
  37. package/engine/table-mode.js +1 -1
  38. package/index.d.ts +234 -6
  39. package/index.js +24 -1
  40. package/node/cli.js +317 -0
  41. package/node/docx-document.js +302 -0
  42. package/node/index.d.ts +31 -0
  43. package/node/index.js +2 -0
  44. package/node/zip-archive.js +52 -0
  45. package/orchestration/list-markdown.js +10 -16
  46. package/orchestration/list-parsing.js +7 -12
  47. package/orchestration/list-structural-fallback.js +21 -10
  48. package/package.json +24 -3
  49. package/pipeline/content-analysis.js +12 -17
  50. package/pipeline/ingestion-export.js +3 -31
  51. package/pipeline/ingestion-paragraph.js +10 -5
  52. package/pipeline/list-generation.js +150 -55
  53. package/pipeline/list-markers.js +70 -3
  54. package/pipeline/serialization.js +4 -2
  55. package/pipeline/structured-content.js +160 -0
  56. package/scripts/apply_changes.mjs +27 -0
  57. package/scripts/benchmark-operation-session.mjs +137 -0
  58. package/scripts/benchmark-targeting-browser.html +74 -0
  59. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  60. package/scripts/benchmark-test-runner.mjs +59 -0
  61. package/scripts/build-test-dashboard.mjs +23 -0
  62. package/scripts/export-lane1-fixtures.mjs +380 -0
  63. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  64. package/scripts/export-validation-fixtures.mjs +1 -1
  65. package/scripts/extract_text.mjs +7 -0
  66. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  67. package/scripts/generate-test-dashboard.mjs +362 -11
  68. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  69. package/scripts/profile-route-selection.mjs +19 -0
  70. package/scripts/render-agenda-multilevel.mjs +0 -5
  71. package/scripts/render-multilevel-cases.mjs +0 -1
  72. package/scripts/run-tests.mjs +107 -35
  73. package/scripts/word-com-corpus-suite.ps1 +3 -0
  74. package/scripts/word-com-differential.ps1 +64 -4
  75. package/scripts/word-com-suite.ps1 +3 -0
  76. package/services/batch-operation-orchestrator.js +494 -0
  77. package/services/capture-engine.js +226 -0
  78. package/services/comment-builders.js +23 -6
  79. package/services/comment-engine.js +108 -47
  80. package/services/comment-locator.js +187 -82
  81. package/services/comment-replies.js +95 -0
  82. package/services/document-inspection.js +258 -0
  83. package/services/document-operation-applier.js +372 -0
  84. package/services/document-operation-contract.js +323 -0
  85. package/services/document-operation-mutations.js +1733 -0
  86. package/services/document-operation-session.js +258 -0
  87. package/services/numbering-service.js +14 -5
  88. package/services/operation-heuristics.js +173 -0
  89. package/services/operation-preflight.js +366 -0
  90. package/services/receipt-collector.js +288 -0
  91. package/services/revision-comment-management.js +37 -5
  92. package/services/revision-token.js +290 -0
  93. package/services/standalone-docx-plumbing.js +123 -8
  94. package/services/standalone-operation-runner.d.ts +296 -0
  95. package/services/standalone-operation-runner.js +10 -1455
  96. package/services/table-reconciliation.js +15 -6
  97. package/docs/VALIDATION.md +0 -183
  98. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  99. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  100. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -1,32 +1,152 @@
1
- /**
2
- * Comment text location and marker injection helpers.
3
- */
4
-
1
+ /**
2
+ * Comment text location and marker injection helpers.
3
+ */
4
+
5
5
  import { createWordElement } from '../core/word-xml.js';
6
6
  import { getElementsByTag, getFirstElementByTag } from '../core/xml-query.js';
7
-
8
- /**
9
- * Builds a paragraph text index in a single pass for repeated lookups.
10
- *
11
- * @param {Element} paragraph - w:p element
12
- * @returns {{ fullText: string, runOffsets: Array<{run: Element, start: number, end: number}> }}
13
- */
14
- export function createParagraphTextIndex(paragraph) {
15
- const runs = getElementsByTag(paragraph, 'w:r');
16
- const runOffsets = [];
17
- let fullText = '';
18
-
19
- for (const run of runs) {
20
- const start = fullText.length;
21
- const textNodes = getElementsByTag(run, 'w:t');
22
- for (const textNode of textNodes) {
23
- fullText += textNode.textContent || '';
24
- }
25
- runOffsets.push({ run, start, end: fullText.length });
26
- }
27
-
28
- return { fullText, runOffsets };
29
- }
7
+ import { refreshRunPropertyChangeIds } from '../core/revision-cloning.js';
8
+ import { readCanonicalRunText, isNodeVisibleInRevisionView } from '../core/paragraph-text.js';
9
+
10
+ /**
11
+ * Builds a paragraph text index in a single pass for repeated lookups.
12
+ *
13
+ * @param {Element} paragraph - w:p element
14
+ * @param {Object} [options={}] - Options
15
+ * @param {'accepted'|'rejected'} [options.revisionView='accepted'] - Revision view
16
+ * @returns {{ fullText: string, runOffsets: Array<{run: Element, start: number, end: number}> }}
17
+ */
18
+ export function createParagraphTextIndex(paragraph, options = {}) {
19
+ const revisionView = options.revisionView === 'current' ? 'accepted' : (options.revisionView || 'accepted');
20
+ const runs = getElementsByTag(paragraph, 'w:r');
21
+ const runOffsets = [];
22
+ let fullText = '';
23
+
24
+ for (const run of runs) {
25
+ if (!isNodeVisibleInRevisionView(run, paragraph, revisionView)) continue;
26
+ const start = fullText.length;
27
+ const text = readCanonicalRunText(run, { revisionView, boundary: paragraph });
28
+ fullText += text;
29
+ runOffsets.push({ run, start, end: fullText.length });
30
+ }
31
+
32
+ return { fullText, runOffsets };
33
+ }
34
+
35
+ function candidateOffsets(haystack, needle) {
36
+ if (!needle) return [];
37
+ const candidates = [];
38
+ let offset = haystack.indexOf(needle);
39
+ while (offset !== -1) {
40
+ candidates.push({ start: offset, end: offset + needle.length });
41
+ offset = haystack.indexOf(needle, offset + 1);
42
+ }
43
+ return candidates;
44
+ }
45
+
46
+ function spaceEquivalentText(value) {
47
+ return String(value).replace(/[ \u00a0]/g, ' ');
48
+ }
49
+
50
+ function anchorError(code, message, candidates = []) {
51
+ return {
52
+ code,
53
+ message,
54
+ ...(candidates.length > 0 ? { candidates } : {})
55
+ };
56
+ }
57
+
58
+ function attachRunOffsets(paragraphIndex, candidate, resolvedBy) {
59
+ let startRun = null;
60
+ let endRun = null;
61
+ let startOffset = 0;
62
+ let endOffset = 0;
63
+ for (const { run, start, end } of paragraphIndex.runOffsets) {
64
+ if (candidate.start >= start && candidate.start < end) {
65
+ startRun = run;
66
+ startOffset = candidate.start - start;
67
+ }
68
+ if (candidate.end > start && candidate.end <= end) {
69
+ endRun = run;
70
+ endOffset = candidate.end - start;
71
+ }
72
+ }
73
+ if (!startRun || !endRun) return null;
74
+ return { found: true, resolvedBy, ...candidate, startRun, startOffset, endRun, endOffset };
75
+ }
76
+
77
+ function findAncestorTag(node, tagNames, boundary = null) {
78
+ let current = node?.parentNode;
79
+ while (current && current.nodeType === 1 && current !== boundary && (current.localName || current.nodeName.replace(/^.*:/, '')) !== 'p') {
80
+ const local = current.localName || current.nodeName.replace(/^.*:/, '');
81
+ if (tagNames.includes(local)) return { node: current, tag: local };
82
+ current = current.parentNode;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ function validateAnchorLocation(paragraphIndex, location) {
88
+ if (!location || !location.found) return location;
89
+
90
+ const intersectingRuns = paragraphIndex.runOffsets
91
+ .filter(entry => entry.end > location.start && entry.start < location.end)
92
+ .map(entry => entry.run);
93
+
94
+ for (const run of intersectingRuns) {
95
+ if (findAncestorTag(run, ['del'])) {
96
+ return {
97
+ found: false,
98
+ error: anchorError('UNSAFE_REVISION_NESTING', 'Refusing to attach comment to pending deletion.', [location])
99
+ };
100
+ }
101
+ if (findAncestorTag(run, ['moveFrom', 'moveTo'])) {
102
+ return {
103
+ found: false,
104
+ error: anchorError('UNSAFE_REVISION_NESTING', 'Refusing to comment on move revision until move lifecycle is designed.', [location])
105
+ };
106
+ }
107
+ }
108
+
109
+ return location;
110
+ }
111
+
112
+ /**
113
+ * Resolves a unique comment anchor without mutating the paragraph DOM.
114
+ * Exact matching wins. A one-to-one ASCII-space/NBSP comparison is used only
115
+ * when no exact match exists, so raw offsets remain stable.
116
+ */
117
+ export function resolveTextInParagraphIndex(paragraphIndex, searchText) {
118
+ const needle = String(searchText ?? '');
119
+ const exactCandidates = candidateOffsets(paragraphIndex.fullText, needle);
120
+ if (exactCandidates.length > 1) {
121
+ return {
122
+ found: false,
123
+ error: anchorError('AMBIGUOUS_ANCHOR', `Anchor text matched ${exactCandidates.length} locations in the target paragraph.`, exactCandidates)
124
+ };
125
+ }
126
+ if (exactCandidates.length === 1) {
127
+ const resolved = attachRunOffsets(paragraphIndex, exactCandidates[0], 'exact_anchor');
128
+ if (resolved) return validateAnchorLocation(paragraphIndex, resolved);
129
+ }
130
+
131
+ const normalizedNeedle = spaceEquivalentText(needle);
132
+ const normalizedParagraph = spaceEquivalentText(paragraphIndex.fullText);
133
+ const equivalentCandidates = candidateOffsets(normalizedParagraph, normalizedNeedle);
134
+ if (equivalentCandidates.length > 1) {
135
+ return {
136
+ found: false,
137
+ error: anchorError('AMBIGUOUS_ANCHOR', `Space-equivalent anchor text matched ${equivalentCandidates.length} locations in the target paragraph.`, equivalentCandidates)
138
+ };
139
+ }
140
+ if (equivalentCandidates.length === 1) {
141
+ const resolved = attachRunOffsets(paragraphIndex, equivalentCandidates[0], 'space_equivalent_anchor');
142
+ if (resolved) return validateAnchorLocation(paragraphIndex, resolved);
143
+ }
144
+
145
+ return {
146
+ found: false,
147
+ error: anchorError('ANCHOR_NOT_FOUND', `Could not find anchor text in the target paragraph: "${needle}".`)
148
+ };
149
+ }
30
150
 
31
151
  /**
32
152
  * Finds text within a prebuilt paragraph text index.
@@ -35,42 +155,18 @@ export function createParagraphTextIndex(paragraph) {
35
155
  * @param {string} searchText - Text to locate
36
156
  * @returns {{ found: boolean, startRun?: Element, startOffset?: number, endRun?: Element, endOffset?: number }}
37
157
  */
38
- export function findTextInParagraphIndex(paragraphIndex, searchText) {
39
- const searchIndex = paragraphIndex.fullText.indexOf(searchText);
40
- if (searchIndex === -1) {
41
- return { found: false };
42
- }
43
-
44
- const searchEnd = searchIndex + searchText.length;
45
- let startRun = null;
46
- let endRun = null;
47
- let startOffset = 0;
48
- let endOffset = 0;
49
-
50
- for (const { run, start, end } of paragraphIndex.runOffsets) {
51
- if (searchIndex >= start && searchIndex < end) {
52
- startRun = run;
53
- startOffset = searchIndex - start;
54
- }
55
- if (searchEnd > start && searchEnd <= end) {
56
- endRun = run;
57
- endOffset = searchEnd - start;
58
- }
59
- }
60
-
61
- return {
62
- found: true,
63
- startRun,
64
- startOffset,
65
- endRun,
66
- endOffset
67
- };
158
+ export function findTextInParagraphIndex(paragraphIndex, searchText) {
159
+ return resolveTextInParagraphIndex(paragraphIndex, searchText);
68
160
  }
69
161
 
70
- function cloneRunWithText(xmlDoc, rPr, newText) {
162
+ function cloneRunWithText(xmlDoc, rPr, newText, revisionIdAllocator, preserveRevisionIds = false) {
71
163
  const newRun = createWordElement(xmlDoc, 'w:r');
72
- if (rPr) {
73
- newRun.appendChild(rPr.cloneNode(true));
164
+ if (rPr) {
165
+ const clonedRPr = rPr.cloneNode(true);
166
+ if (!preserveRevisionIds) {
167
+ refreshRunPropertyChangeIds(clonedRPr, revisionIdAllocator);
168
+ }
169
+ newRun.appendChild(clonedRPr);
74
170
  }
75
171
 
76
172
  const newTextNode = createWordElement(xmlDoc, 'w:t');
@@ -88,11 +184,12 @@ function cloneRunWithText(xmlDoc, rPr, newText) {
88
184
  * @param {string} textToFind - Target text
89
185
  * @param {number} commentId - Comment id
90
186
  * @param {{ fullText: string, runOffsets: Array<{run: Element, start: number, end: number}> }|null} [paragraphIndex=null] - Optional prebuilt index
91
- * @returns {boolean}
92
- */
93
- export function injectMarkersIntoParagraph(xmlDoc, paragraph, textToFind, commentId, paragraphIndex = null) {
94
- const activeIndex = paragraphIndex || createParagraphTextIndex(paragraph);
95
- const location = findTextInParagraphIndex(activeIndex, textToFind);
187
+ * @param {import('../core/types.js').RevisionIdAllocator|null} [revisionIdAllocator=null] - Document-scoped revision ID allocator
188
+ * @returns {boolean}
189
+ */
190
+ export function injectMarkersIntoParagraph(xmlDoc, paragraph, textToFind, commentId, paragraphIndex = null, revisionIdAllocator = null, resolvedLocation = null) {
191
+ const activeIndex = paragraphIndex || createParagraphTextIndex(paragraph);
192
+ const location = resolvedLocation || resolveTextInParagraphIndex(activeIndex, textToFind);
96
193
  if (!location.found || !location.startRun) {
97
194
  return false;
98
195
  }
@@ -127,12 +224,13 @@ export function injectMarkersIntoParagraph(xmlDoc, paragraph, textToFind, commen
127
224
  const beforeText = fullText.substring(0, location.startOffset);
128
225
  const highlightedText = fullText.substring(location.startOffset, location.endOffset);
129
226
  const afterText = fullText.substring(location.endOffset);
130
- const rPr = getFirstElementByTag(run, 'w:rPr');
131
- const parent = run.parentNode;
132
-
133
- if (beforeText) {
134
- parent.insertBefore(cloneRunWithText(xmlDoc, rPr, beforeText), run);
135
- }
227
+ const rPr = getFirstElementByTag(run, 'w:rPr');
228
+ const parent = run.parentNode;
229
+
230
+ if (beforeText) {
231
+ parent.insertBefore(cloneRunWithText(xmlDoc, rPr, beforeText, revisionIdAllocator, true), run);
232
+ refreshRunPropertyChangeIds(rPr, revisionIdAllocator);
233
+ }
136
234
 
137
235
  parent.insertBefore(startMarker, run);
138
236
  textNode.textContent = highlightedText;
@@ -144,8 +242,8 @@ export function injectMarkersIntoParagraph(xmlDoc, paragraph, textToFind, commen
144
242
  }
145
243
  parent.insertBefore(referenceRun, endMarker.nextSibling || null);
146
244
 
147
- if (afterText) {
148
- parent.insertBefore(cloneRunWithText(xmlDoc, rPr, afterText), referenceRun.nextSibling || null);
245
+ if (afterText) {
246
+ parent.insertBefore(cloneRunWithText(xmlDoc, rPr, afterText, revisionIdAllocator), referenceRun.nextSibling || null);
149
247
  }
150
248
 
151
249
  return true;
@@ -157,9 +255,13 @@ export function injectMarkersIntoParagraph(xmlDoc, paragraph, textToFind, commen
157
255
  const beforeText = fullText.substring(0, location.startOffset);
158
256
  const highlightedStart = fullText.substring(location.startOffset);
159
257
 
160
- if (beforeText) {
161
- const rPr = getFirstElementByTag(location.startRun, 'w:rPr');
162
- location.startRun.parentNode.insertBefore(cloneRunWithText(xmlDoc, rPr, beforeText), location.startRun);
258
+ if (beforeText) {
259
+ const rPr = getFirstElementByTag(location.startRun, 'w:rPr');
260
+ location.startRun.parentNode.insertBefore(
261
+ cloneRunWithText(xmlDoc, rPr, beforeText, revisionIdAllocator, true),
262
+ location.startRun
263
+ );
264
+ refreshRunPropertyChangeIds(rPr, revisionIdAllocator);
163
265
  }
164
266
  startTextNode.textContent = highlightedStart;
165
267
  }
@@ -176,12 +278,15 @@ export function injectMarkersIntoParagraph(xmlDoc, paragraph, textToFind, commen
176
278
  endTextNode.textContent = highlightedEnd;
177
279
 
178
280
  if (afterText) {
179
- const rPr = getFirstElementByTag(endRun, 'w:rPr');
180
- if (endRun.nextSibling) {
181
- endRun.parentNode.insertBefore(cloneRunWithText(xmlDoc, rPr, afterText), endRun.nextSibling);
182
- } else {
183
- endRun.parentNode.appendChild(cloneRunWithText(xmlDoc, rPr, afterText));
184
- }
281
+ const rPr = getFirstElementByTag(endRun, 'w:rPr');
282
+ if (endRun.nextSibling) {
283
+ endRun.parentNode.insertBefore(
284
+ cloneRunWithText(xmlDoc, rPr, afterText, revisionIdAllocator),
285
+ endRun.nextSibling
286
+ );
287
+ } else {
288
+ endRun.parentNode.appendChild(cloneRunWithText(xmlDoc, rPr, afterText, revisionIdAllocator));
289
+ }
185
290
  }
186
291
  }
187
292
 
@@ -0,0 +1,95 @@
1
+ import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
2
+ import { buildCommentElement, buildCommentsExtendedPartXml, createCommentParaId, NS_W14, NS_W15 } from './comment-builders.js';
3
+
4
+ const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
5
+
6
+ function attr(node, qualified, local) {
7
+ return node?.getAttribute?.(qualified) || node?.getAttribute?.(local) || '';
8
+ }
9
+
10
+ function parseRequired(xml, partName) {
11
+ const parsed = parseOoxmlSafe(xml, 'application/xml');
12
+ if (!parsed.doc || parsed.error) {
13
+ return { error: { code: 'PARSE_ERROR', message: `Could not parse ${partName}: ${parsed.error?.message || 'invalid XML'}` } };
14
+ }
15
+ return { doc: parsed.doc };
16
+ }
17
+
18
+ function usedParaIds(commentsDoc, extendedDoc) {
19
+ const ids = new Set();
20
+ for (const p of Array.from(commentsDoc?.getElementsByTagNameNS('*', 'p') || [])) {
21
+ const id = attr(p, 'w14:paraId', 'paraId');
22
+ if (id) ids.add(id.toUpperCase());
23
+ }
24
+ for (const ex of Array.from(extendedDoc?.getElementsByTagNameNS('*', 'commentEx') || [])) {
25
+ const id = attr(ex, 'w15:paraId', 'paraId');
26
+ if (id) ids.add(id.toUpperCase());
27
+ }
28
+ return ids;
29
+ }
30
+
31
+ function allocateParaId(commentId, occupied) {
32
+ let candidate = createCommentParaId(commentId);
33
+ let value = Number.parseInt(candidate, 16) >>> 0;
34
+ while (occupied.has(candidate)) {
35
+ value = (value + 1) >>> 0;
36
+ candidate = value.toString(16).toUpperCase().padStart(8, '0');
37
+ }
38
+ occupied.add(candidate);
39
+ return candidate;
40
+ }
41
+
42
+ export function applyCommentReplyToParts({ commentsXml, commentsExtendedXml = null, parentCommentId, commentId, commentContent, author, date = new Date().toISOString() }) {
43
+ if (!commentsXml) return { status: 'error', error: { code: 'COMMENTS_PART_MISSING', message: 'A comment reply requires an existing word/comments.xml part.' } };
44
+ const commentsParsed = parseRequired(commentsXml, 'word/comments.xml');
45
+ if (commentsParsed.error) return { status: 'error', error: commentsParsed.error };
46
+ const commentsDoc = commentsParsed.doc;
47
+ const parent = Array.from(commentsDoc.getElementsByTagNameNS('*', 'comment')).find(node => attr(node, 'w:id', 'id') === String(parentCommentId));
48
+ if (!parent) return { status: 'error', error: { code: 'PARENT_COMMENT_NOT_FOUND', message: `Parent comment '${parentCommentId}' was not found.` } };
49
+
50
+ let extendedDoc = null;
51
+ if (commentsExtendedXml) {
52
+ const parsed = parseRequired(commentsExtendedXml, 'word/commentsExtended.xml');
53
+ if (parsed.error) return { status: 'error', error: parsed.error };
54
+ extendedDoc = parsed.doc;
55
+ }
56
+ const occupied = usedParaIds(commentsDoc, extendedDoc);
57
+ const parentParagraph = Array.from(parent.getElementsByTagNameNS(NS_W, 'p'))[0] || Array.from(parent.getElementsByTagNameNS('*', 'p'))[0];
58
+ if (!parentParagraph) return { status: 'error', error: { code: 'PARENT_COMMENT_INVALID', message: `Parent comment '${parentCommentId}' has no paragraph.` } };
59
+ let parentParaId = attr(parentParagraph, 'w14:paraId', 'paraId');
60
+ if (!parentParaId) {
61
+ parentParaId = allocateParaId(parentCommentId, occupied);
62
+ parentParagraph.setAttributeNS(NS_W14, 'w14:paraId', parentParaId);
63
+ } else {
64
+ parentParaId = parentParaId.toUpperCase();
65
+ }
66
+ const replyParaId = allocateParaId(commentId, occupied);
67
+ const replyParsed = parseRequired(`<w:comments xmlns:w="${NS_W}" xmlns:w14="${NS_W14}">${buildCommentElement(commentId, author, commentContent, date, replyParaId)}</w:comments>`, 'reply comment');
68
+ commentsDoc.documentElement.appendChild(commentsDoc.importNode(replyParsed.doc.documentElement.firstChild, true));
69
+
70
+ if (!extendedDoc) {
71
+ extendedDoc = parseRequired(buildCommentsExtendedPartXml([]), 'word/commentsExtended.xml').doc;
72
+ }
73
+ const root = extendedDoc.documentElement;
74
+ const entries = Array.from(root.getElementsByTagNameNS('*', 'commentEx'));
75
+ if (!entries.some(node => attr(node, 'w15:paraId', 'paraId').toUpperCase() === parentParaId)) {
76
+ const parentEx = extendedDoc.createElementNS(NS_W15, 'w15:commentEx');
77
+ parentEx.setAttributeNS(NS_W15, 'w15:paraId', parentParaId);
78
+ parentEx.setAttributeNS(NS_W15, 'w15:done', '0');
79
+ root.appendChild(parentEx);
80
+ }
81
+ const replyEx = extendedDoc.createElementNS(NS_W15, 'w15:commentEx');
82
+ replyEx.setAttributeNS(NS_W15, 'w15:paraId', replyParaId);
83
+ replyEx.setAttributeNS(NS_W15, 'w15:paraIdParent', parentParaId);
84
+ replyEx.setAttributeNS(NS_W15, 'w15:done', '0');
85
+ root.appendChild(replyEx);
86
+
87
+ const serializer = createSerializer();
88
+ return {
89
+ status: 'ok', hasChanges: true,
90
+ commentsXml: serializer.serializeToString(commentsDoc),
91
+ commentsExtendedXml: serializer.serializeToString(extendedDoc),
92
+ commentsXmlMode: 'replace', commentsExtendedXmlMode: 'replace',
93
+ commentId, parentCommentId: String(parentCommentId), paraId: replyParaId, parentParaId
94
+ };
95
+ }