@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.
- package/AGENTS.md +589 -287
- package/ARCHITECTURE.md +215 -9
- package/CHANGELOG.md +319 -0
- package/README.md +604 -360
- package/adapters/config.js +45 -43
- package/bin/docx-redline.js +3 -0
- package/core/list-targeting.js +101 -110
- package/core/paragraph-targeting.js +501 -61
- package/core/paragraph-text.js +209 -0
- package/core/revision-cloning.js +38 -0
- package/core/types.js +64 -10
- package/core/word-xml.js +43 -15
- package/dist/docx-redline-js.esm.js +2849 -466
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +87 -76
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +342 -23
- package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
- package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
- package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
- package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
- package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
- package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
- package/docs/schemas/document-operations.schema.json +109 -0
- package/docs/test-comparison-dashboard.html +4250 -7
- package/engine/formatting-removal.js +11 -2
- package/engine/oxml-engine.js +491 -336
- package/engine/reconstruction-mode.js +15 -14
- package/engine/reconstruction-writer.js +247 -142
- package/engine/route-selection.js +35 -0
- package/engine/rpr-helpers.js +334 -35
- package/engine/run-builders.js +239 -196
- package/engine/surgical-diff-application.js +222 -37
- package/engine/surgical-mode.js +134 -6
- package/engine/surgical-spans.js +52 -1
- package/engine/table-cell-context.js +3 -6
- package/engine/table-mode.js +1 -1
- package/index.d.ts +234 -6
- package/index.js +24 -1
- package/node/cli.js +317 -0
- package/node/docx-document.js +302 -0
- package/node/index.d.ts +31 -0
- package/node/index.js +2 -0
- package/node/zip-archive.js +52 -0
- package/orchestration/list-markdown.js +10 -16
- package/orchestration/list-parsing.js +7 -12
- package/orchestration/list-structural-fallback.js +21 -10
- package/package.json +24 -3
- package/pipeline/content-analysis.js +12 -17
- package/pipeline/ingestion-export.js +3 -31
- package/pipeline/ingestion-paragraph.js +10 -5
- package/pipeline/list-generation.js +150 -55
- package/pipeline/list-markers.js +70 -3
- package/pipeline/serialization.js +4 -2
- package/pipeline/structured-content.js +160 -0
- package/scripts/apply_changes.mjs +27 -0
- package/scripts/benchmark-operation-session.mjs +137 -0
- package/scripts/benchmark-targeting-browser.html +74 -0
- package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
- package/scripts/benchmark-test-runner.mjs +59 -0
- package/scripts/build-test-dashboard.mjs +23 -0
- package/scripts/export-lane1-fixtures.mjs +380 -0
- package/scripts/export-reredline-stress-fixtures.mjs +317 -0
- package/scripts/export-validation-fixtures.mjs +1 -1
- package/scripts/extract_text.mjs +7 -0
- package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
- package/scripts/generate-test-dashboard.mjs +362 -11
- package/scripts/lib/word-coverage-catalogue.mjs +6 -2
- package/scripts/profile-route-selection.mjs +19 -0
- package/scripts/render-agenda-multilevel.mjs +0 -5
- package/scripts/render-multilevel-cases.mjs +0 -1
- package/scripts/run-tests.mjs +107 -35
- package/scripts/word-com-corpus-suite.ps1 +3 -0
- package/scripts/word-com-differential.ps1 +64 -4
- package/scripts/word-com-suite.ps1 +3 -0
- package/services/batch-operation-orchestrator.js +494 -0
- package/services/capture-engine.js +226 -0
- package/services/comment-builders.js +23 -6
- package/services/comment-engine.js +108 -47
- package/services/comment-locator.js +187 -82
- package/services/comment-replies.js +95 -0
- package/services/document-inspection.js +258 -0
- package/services/document-operation-applier.js +372 -0
- package/services/document-operation-contract.js +323 -0
- package/services/document-operation-mutations.js +1733 -0
- package/services/document-operation-session.js +258 -0
- package/services/numbering-service.js +14 -5
- package/services/operation-heuristics.js +173 -0
- package/services/operation-preflight.js +366 -0
- package/services/receipt-collector.js +288 -0
- package/services/revision-comment-management.js +37 -5
- package/services/revision-token.js +290 -0
- package/services/standalone-docx-plumbing.js +123 -8
- package/services/standalone-operation-runner.d.ts +296 -0
- package/services/standalone-operation-runner.js +10 -1455
- package/services/table-reconciliation.js +15 -6
- package/docs/VALIDATION.md +0 -183
- package/docs/WORD-MANUAL-REVIEW.md +0 -138
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
- /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
|
-
|
|
10
|
-
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
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(
|
|
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(
|
|
182
|
-
|
|
183
|
-
|
|
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
|
+
}
|