@ansonlai/docx-redline-js 0.8.0 → 0.8.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +539 -486
  2. package/README.md +331 -309
  3. package/dist/docx-redline-js.esm.js +1083 -163
  4. package/dist/docx-redline-js.esm.js.map +4 -4
  5. package/dist/docx-redline-js.esm.min.js +107 -107
  6. package/dist/docx-redline-js.esm.min.js.map +4 -4
  7. package/dist/docx-redline.bundle.cjs +1083 -163
  8. package/dist/docx-redline.bundle.cjs.map +4 -4
  9. package/dist/docx-redline.bundle.js +1083 -163
  10. package/dist/docx-redline.bundle.js.map +4 -4
  11. package/docs/AGENT_KNOWLEDGE_BASE.md +69 -8
  12. package/docs/schemas/document-operations.schema.json +29 -0
  13. package/document/docx-document.js +416 -22
  14. package/engine/reconstruction-writer.js +18 -0
  15. package/index.d.ts +598 -575
  16. package/index.js +349 -348
  17. package/node/cli-help.js +5 -5
  18. package/node/cli.js +760 -754
  19. package/node/index.d.ts +1 -1
  20. package/package.json +2 -1
  21. package/scripts/generate-word-comment-edge-case-fixture.ps1 +58 -0
  22. package/scripts/generate-word-comment-thread-fixture.ps1 +36 -0
  23. package/scripts/generate-word-header-footer-fixture.ps1 +32 -0
  24. package/scripts/generate-word-multi-section-fixture.ps1 +27 -0
  25. package/scripts/generate-word-package-integrity-cases.mjs +141 -0
  26. package/scripts/generate-word-thread-state-fixtures.ps1 +50 -0
  27. package/scripts/word-com-package-integrity.ps1 +98 -0
  28. package/services/batch-operation-orchestrator.js +262 -259
  29. package/services/comment-replies.js +96 -95
  30. package/services/comment-thread-parts.js +280 -0
  31. package/services/document-inspection.js +366 -365
  32. package/services/document-operation-applier.js +230 -202
  33. package/services/document-operation-contract.js +17 -2
  34. package/services/error-recovery.js +6 -0
  35. package/services/headers-footers.js +206 -0
  36. package/services/operation-batch-compiler.js +1 -0
  37. package/services/operation-preflight.js +231 -218
  38. package/services/package-parts.js +80 -0
  39. package/services/standalone-docx-plumbing.js +631 -569
  40. package/services/standalone-operation-runner.d.ts +19 -3
@@ -1,95 +1,96 @@
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
- }
1
+ import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
2
+ import { allocateParaId, commentParaId, commentThreadParagraph, usedParaIds } from './comment-thread-parts.js';
3
+ import { buildCommentElement, buildCommentsExtendedPartXml, NS_W14, NS_W15 } from './comment-builders.js';
4
+
5
+ const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
6
+
7
+ function attr(node, qualified, local) {
8
+ return node?.getAttribute?.(qualified) || node?.getAttribute?.(local) || '';
9
+ }
10
+
11
+ function parseRequired(xml, partName) {
12
+ const parsed = parseOoxmlSafe(xml, 'application/xml');
13
+ if (!parsed.doc || parsed.error) {
14
+ return { error: { code: 'PARSE_ERROR', message: `Could not parse ${partName}: ${parsed.error?.message || 'invalid XML'}` } };
15
+ }
16
+ return { doc: parsed.doc };
17
+ }
18
+
19
+ export function applyCommentReplyToParts({ commentsXml, commentsExtendedXml = null, documentXml = null, parentCommentId, commentId, commentContent, author, date = new Date().toISOString() }) {
20
+ if (!commentsXml) return { status: 'error', error: { code: 'COMMENTS_PART_MISSING', message: 'A comment reply requires an existing word/comments.xml part.' } };
21
+ const commentsParsed = parseRequired(commentsXml, 'word/comments.xml');
22
+ if (commentsParsed.error) return { status: 'error', error: commentsParsed.error };
23
+ const commentsDoc = commentsParsed.doc;
24
+ const parent = Array.from(commentsDoc.getElementsByTagNameNS('*', 'comment')).find(node => attr(node, 'w:id', 'id') === String(parentCommentId));
25
+ if (!parent) return { status: 'error', error: { code: 'PARENT_COMMENT_NOT_FOUND', message: `Parent comment '${parentCommentId}' was not found.` } };
26
+
27
+ let extendedDoc = null;
28
+ if (commentsExtendedXml) {
29
+ const parsed = parseRequired(commentsExtendedXml, 'word/commentsExtended.xml');
30
+ if (parsed.error) return { status: 'error', error: parsed.error };
31
+ extendedDoc = parsed.doc;
32
+ }
33
+ const occupied = usedParaIds(commentsDoc, extendedDoc);
34
+ // paraIds are unique across the whole package; body paragraphs can carry them too.
35
+ if (documentXml) for (const match of String(documentXml).matchAll(/w14:paraId="([0-9A-Fa-f]{8})"/g)) occupied.add(match[1].toUpperCase());
36
+ const parentParagraph = commentThreadParagraph(parent);
37
+ if (!parentParagraph) return { status: 'error', error: { code: 'PARENT_COMMENT_INVALID', message: `Parent comment '${parentCommentId}' has no paragraph.` } };
38
+ let parentParaId = commentParaId(parent);
39
+ if (!parentParaId) {
40
+ parentParaId = allocateParaId(parentCommentId, occupied);
41
+ parentParagraph.setAttributeNS(NS_W14, 'w14:paraId', parentParaId);
42
+ } else {
43
+ parentParaId = parentParaId.toUpperCase();
44
+ }
45
+ // Word threads are flat: a reply to a reply belongs to the same root thread.
46
+ for (const node of Array.from(extendedDoc?.getElementsByTagNameNS('*', 'commentEx') || [])) {
47
+ if (attr(node, 'w15:paraId', 'paraId').toUpperCase() === parentParaId) {
48
+ const root = attr(node, 'w15:paraIdParent', 'paraIdParent').toUpperCase();
49
+ if (root) parentParaId = root;
50
+ break;
51
+ }
52
+ }
53
+ // Comments already in this thread (root first), needed to anchor the reply in the body.
54
+ const threadCommentIds = [];
55
+ const commentNodes = Array.from(commentsDoc.getElementsByTagNameNS('*', 'comment'));
56
+ const parentOf = new Map(Array.from(extendedDoc?.getElementsByTagNameNS('*', 'commentEx') || [])
57
+ .map(node => [attr(node, 'w15:paraId', 'paraId').toUpperCase(), attr(node, 'w15:paraIdParent', 'paraIdParent').toUpperCase()]));
58
+ for (const node of commentNodes) {
59
+ const key = commentParaId(node);
60
+ if (key === parentParaId || parentOf.get(key) === parentParaId) threadCommentIds.push(attr(node, 'w:id', 'id'));
61
+ }
62
+ const threadRootCommentId = attr(commentNodes.find(node => commentParaId(node) === parentParaId), 'w:id', 'id') || String(parentCommentId);
63
+ const replyParaId = allocateParaId(commentId, occupied);
64
+ const replyParsed = parseRequired(`<w:comments xmlns:w="${NS_W}" xmlns:w14="${NS_W14}">${buildCommentElement(commentId, author, commentContent, date, replyParaId)}</w:comments>`, 'reply comment');
65
+ commentsDoc.documentElement.appendChild(commentsDoc.importNode(replyParsed.doc.documentElement.firstChild, true));
66
+
67
+ if (!extendedDoc) {
68
+ extendedDoc = parseRequired(buildCommentsExtendedPartXml([]), 'word/commentsExtended.xml').doc;
69
+ }
70
+ const root = extendedDoc.documentElement;
71
+ const entries = Array.from(root.getElementsByTagNameNS('*', 'commentEx'));
72
+ const rootEntry = entries.find(node => attr(node, 'w15:paraId', 'paraId').toUpperCase() === parentParaId);
73
+ if (!rootEntry) {
74
+ const parentEx = extendedDoc.createElementNS(NS_W15, 'w15:commentEx');
75
+ parentEx.setAttributeNS(NS_W15, 'w15:paraId', parentParaId);
76
+ parentEx.setAttributeNS(NS_W15, 'w15:done', '0');
77
+ root.appendChild(parentEx);
78
+ }
79
+ // Resolved is thread-level: Word gives a reply added to a resolved thread done="1" too.
80
+ const threadDone = attr(rootEntry, 'w15:done', 'done') === '1' ? '1' : '0';
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', threadDone);
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
+ threadRootCommentId, threadCommentIds: threadCommentIds.length ? threadCommentIds : [threadRootCommentId]
95
+ };
96
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Helpers for the Word comment-threading parts (commentsExtended, commentsIds, commentsExtensible).
3
+ *
4
+ * Word joins those parts to comments.xml through w14:paraId, and it uses the paraId of the LAST
5
+ * paragraph of a comment (verified against real Word output, see
6
+ * tests/fixtures/word-authored/multi-paragraph-thread.docx). Comments with one paragraph make first and
7
+ * last identical, which is why treating the first paragraph as the key went unnoticed.
8
+ */
9
+
10
+ import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
11
+ import { createCommentParaId, NS_W14, NS_W15 } from './comment-builders.js';
12
+
13
+ const attrOf = (node, qualified, local) => node?.getAttribute?.(qualified) || node?.getAttribute?.(local) || '';
14
+
15
+ /** The paragraph whose paraId identifies a w:comment in the threading parts, or null. */
16
+ export function commentThreadParagraph(commentElement) {
17
+ const paragraphs = Array.from(commentElement?.getElementsByTagNameNS?.('*', 'p') || []);
18
+ return paragraphs.length ? paragraphs[paragraphs.length - 1] : null;
19
+ }
20
+
21
+ /** The threading paraId (upper-cased) of a w:comment, or '' when its last paragraph has none. */
22
+ export function commentParaId(commentElement) {
23
+ return attrOf(commentThreadParagraph(commentElement), 'w14:paraId', 'paraId').toUpperCase();
24
+ }
25
+
26
+ /** Every paraId (upper-cased) already used by comments.xml paragraphs and commentsExtended entries. */
27
+ export function usedParaIds(commentsDoc, extendedDoc) {
28
+ const ids = new Set();
29
+ for (const p of Array.from(commentsDoc?.getElementsByTagNameNS('*', 'p') || [])) {
30
+ const id = attrOf(p, 'w14:paraId', 'paraId');
31
+ if (id) ids.add(id.toUpperCase());
32
+ }
33
+ for (const ex of Array.from(extendedDoc?.getElementsByTagNameNS('*', 'commentEx') || [])) {
34
+ const id = attrOf(ex, 'w15:paraId', 'paraId');
35
+ if (id) ids.add(id.toUpperCase());
36
+ }
37
+ return ids;
38
+ }
39
+
40
+ /** Allocates a paraId derived from the comment id that is not in `occupied`, and marks it occupied. */
41
+ export function allocateParaId(commentId, occupied) {
42
+ let candidate = createCommentParaId(commentId);
43
+ let value = Number.parseInt(candidate, 16) >>> 0;
44
+ while (occupied.has(candidate)) {
45
+ value = (value + 1) >>> 0;
46
+ candidate = value.toString(16).toUpperCase().padStart(8, '0');
47
+ }
48
+ occupied.add(candidate);
49
+ return candidate;
50
+ }
51
+
52
+ const parseDoc = (xml, partName) => {
53
+ const parsed = parseOoxmlSafe(xml, 'application/xml');
54
+ if (!parsed.doc || parsed.error) return { error: { code: 'PARSE_ERROR', message: `Could not parse ${partName}: ${parsed.error?.message || 'invalid XML'}` } };
55
+ return { doc: parsed.doc };
56
+ };
57
+
58
+ /**
59
+ * Sets the resolved ("done") state of a whole comment thread.
60
+ *
61
+ * Word treats resolved as a property of the thread: resolving the root marks every reply done, and
62
+ * resolving a single reply marks the root and its siblings done too (verified against real Word,
63
+ * tests/fixtures/word-authored/resolved-threads.docx). So this always updates root + all replies.
64
+ *
65
+ * @returns {{ status: 'ok', hasChanges: boolean, commentsXml?: string, commentsExtendedXml?: string,
66
+ * threadRootId: string, commentIds: string[], resolved: boolean } | { status: 'error', error: object }}
67
+ */
68
+ export function applyThreadResolutionToParts({ commentsXml, commentsExtendedXml = null, commentId, resolved = true }) {
69
+ if (!commentsXml) return { status: 'error', error: { code: 'COMMENTS_PART_MISSING', message: 'Resolving a comment requires an existing word/comments.xml part.' } };
70
+ const commentsParsed = parseDoc(commentsXml, 'word/comments.xml');
71
+ if (commentsParsed.error) return { status: 'error', error: commentsParsed.error };
72
+ const commentsDoc = commentsParsed.doc;
73
+ const idOf = node => attrOf(node, 'w:id', 'id');
74
+ const comments = Array.from(commentsDoc.getElementsByTagNameNS('*', 'comment'));
75
+ const target = comments.find(node => idOf(node) === String(commentId));
76
+ if (!target) return { status: 'error', error: { code: 'COMMENT_NOT_FOUND', message: `Comment '${commentId}' was not found.` } };
77
+
78
+ let extendedDoc;
79
+ if (commentsExtendedXml) {
80
+ const parsed = parseDoc(commentsExtendedXml, 'word/commentsExtended.xml');
81
+ if (parsed.error) return { status: 'error', error: parsed.error };
82
+ extendedDoc = parsed.doc;
83
+ } else {
84
+ extendedDoc = parseDoc(`<w15:commentsEx xmlns:w15="${NS_W15}"/>`, 'word/commentsExtended.xml').doc;
85
+ }
86
+ const root = extendedDoc.documentElement;
87
+ const entryFor = paraId => Array.from(root.getElementsByTagNameNS('*', 'commentEx')).find(node => attrOf(node, 'w15:paraId', 'paraId').toUpperCase() === paraId) || null;
88
+
89
+ // Every comment needs a threading key. Legacy comments without one get a fresh paraId.
90
+ const occupied = usedParaIds(commentsDoc, extendedDoc);
91
+ let commentsChanged = false;
92
+ const keyOf = node => {
93
+ let paraId = commentParaId(node);
94
+ if (!paraId) {
95
+ paraId = allocateParaId(idOf(node), occupied);
96
+ commentThreadParagraph(node)?.setAttributeNS(NS_W14, 'w14:paraId', paraId);
97
+ commentsChanged = true;
98
+ }
99
+ return paraId;
100
+ };
101
+
102
+ const targetKey = keyOf(target);
103
+ const targetEntry = entryFor(targetKey);
104
+ const rootKey = (targetEntry && attrOf(targetEntry, 'w15:paraIdParent', 'paraIdParent').toUpperCase()) || targetKey;
105
+
106
+ const threadKeys = new Set([rootKey, targetKey]);
107
+ for (const entry of Array.from(root.getElementsByTagNameNS('*', 'commentEx'))) {
108
+ if (attrOf(entry, 'w15:paraIdParent', 'paraIdParent').toUpperCase() === rootKey) threadKeys.add(attrOf(entry, 'w15:paraId', 'paraId').toUpperCase());
109
+ }
110
+
111
+ const doneValue = resolved ? '1' : '0';
112
+ let extendedChanged = !commentsExtendedXml;
113
+ for (const key of threadKeys) {
114
+ let entry = entryFor(key);
115
+ if (!entry) {
116
+ entry = extendedDoc.createElementNS(NS_W15, 'w15:commentEx');
117
+ entry.setAttributeNS(NS_W15, 'w15:paraId', key);
118
+ if (key !== rootKey) entry.setAttributeNS(NS_W15, 'w15:paraIdParent', rootKey);
119
+ entry.setAttributeNS(NS_W15, 'w15:done', doneValue);
120
+ root.appendChild(entry);
121
+ extendedChanged = true;
122
+ } else if (attrOf(entry, 'w15:done', 'done') !== doneValue) {
123
+ entry.setAttributeNS(NS_W15, 'w15:done', doneValue);
124
+ extendedChanged = true;
125
+ }
126
+ }
127
+
128
+ const byKey = new Map(comments.map(node => [commentParaId(node), node]));
129
+ const commentIds = [...threadKeys].map(key => byKey.get(key)).filter(Boolean).map(idOf);
130
+ const rootComment = byKey.get(rootKey);
131
+ const serializer = createSerializer();
132
+ const hasChanges = extendedChanged || commentsChanged;
133
+ return {
134
+ status: 'ok', hasChanges, resolved,
135
+ threadRootId: rootComment ? idOf(rootComment) : String(commentId),
136
+ commentIds,
137
+ ...(hasChanges ? { commentsExtendedXml: serializer.serializeToString(extendedDoc) } : {}),
138
+ ...(commentsChanged ? { commentsXml: serializer.serializeToString(commentsDoc) } : {})
139
+ };
140
+ }
141
+
142
+ const NS_W_MAIN = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
143
+
144
+ /**
145
+ * Anchors a reply in the document body the way Word does.
146
+ *
147
+ * Word gives every reply its own commentRangeStart / commentRangeEnd / commentReference around the same
148
+ * range as its thread (verified in tests/fixtures/word-authored/threaded-comments.docx). A reply defined only
149
+ * in comments.xml/commentsExtended.xml is not shown by Word at all, so the markers are required.
150
+ *
151
+ * The new start goes right after the last existing thread member's start; the new end and reference run go
152
+ * right after the last existing thread member's reference run, mirroring Word's ordering.
153
+ *
154
+ * @param {Document} xmlDoc live document.xml DOM (mutated)
155
+ * @param {string[]} memberIds comment ids already in the thread (root first)
156
+ * @param {string|number} newId id of the reply comment
157
+ * @returns {boolean} false when the thread has no anchors in the body (nothing is changed)
158
+ */
159
+ export function anchorReplyInDocument(xmlDoc, memberIds, newId) {
160
+ const wanted = new Set(memberIds.map(String));
161
+ const idOf = node => attrOf(node, 'w:id', 'id');
162
+ const all = name => Array.from(xmlDoc.getElementsByTagNameNS('*', name)).filter(node => wanted.has(idOf(node)));
163
+ const starts = all('commentRangeStart');
164
+ const references = all('commentReference');
165
+ if (!starts.length || !references.length) return false;
166
+
167
+ const lastStart = starts[starts.length - 1];
168
+ const lastReference = references[references.length - 1];
169
+ const referenceRun = lastReference.parentNode?.localName === 'r' ? lastReference.parentNode : lastReference;
170
+
171
+ const make = (name, parent) => {
172
+ const element = xmlDoc.createElementNS(NS_W_MAIN, `w:${name}`);
173
+ element.setAttributeNS(NS_W_MAIN, 'w:id', String(newId));
174
+ if (parent) parent.appendChild(element);
175
+ return element;
176
+ };
177
+ const insertAfter = (anchor, node) => anchor.parentNode.insertBefore(node, anchor.nextSibling);
178
+
179
+ insertAfter(lastStart, make('commentRangeStart'));
180
+ const end = make('commentRangeEnd');
181
+ const run = xmlDoc.createElementNS(NS_W_MAIN, 'w:r');
182
+ run.appendChild(xmlDoc.createElementNS(NS_W_MAIN, 'w:rPr'));
183
+ make('commentReference', run);
184
+ insertAfter(referenceRun, end);
185
+ insertAfter(end, run);
186
+ return true;
187
+ }
188
+
189
+ const NS_W16CID = 'http://schemas.microsoft.com/office/word/2016/wordml/cid';
190
+ const NS_W16CEX = 'http://schemas.microsoft.com/office/word/2018/wordml/cex';
191
+
192
+ const durableIdFor = (paraId, taken) => {
193
+ // Word's durable ids are 8 hex digits below 0x7FFFFFFF. Derive from the paraId so output is deterministic.
194
+ let value = (Number.parseInt(paraId, 16) ^ 0x2545F491) & 0x7FFFFFFE;
195
+ let candidate = value.toString(16).toUpperCase().padStart(8, '0');
196
+ while (taken.has(candidate)) {
197
+ value = (value + 2) & 0x7FFFFFFE;
198
+ candidate = value.toString(16).toUpperCase().padStart(8, '0');
199
+ }
200
+ taken.add(candidate);
201
+ return candidate;
202
+ };
203
+
204
+ /**
205
+ * Brings commentsIds.xml and commentsExtensible.xml in line with comments.xml. Only parts that already
206
+ * exist are touched (Word does not require them, so they are never created). Entries for comments that no
207
+ * longer exist are removed and comments that lack an entry get one.
208
+ *
209
+ * @returns {{ commentsIdsXml?: string, commentsExtensibleXml?: string }} only the parts that changed
210
+ */
211
+ export function reconcileCommentSiblingParts({ commentsXml, commentsIdsXml = null, commentsExtensibleXml = null }) {
212
+ const out = {};
213
+ if (!commentsXml || (!commentsIdsXml && !commentsExtensibleXml)) return out;
214
+ const commentsParsed = parseOoxmlSafe(commentsXml, 'application/xml');
215
+ if (!commentsParsed.doc || commentsParsed.error) return out;
216
+ const serializer = createSerializer();
217
+
218
+ const byKey = new Map();
219
+ for (const node of Array.from(commentsParsed.doc.getElementsByTagNameNS('*', 'comment'))) {
220
+ const key = commentParaId(node);
221
+ if (key) byKey.set(key, node);
222
+ }
223
+
224
+ let idsDoc = null;
225
+ const durableByKey = new Map();
226
+ const taken = new Set();
227
+ let idsChanged = false;
228
+ if (commentsIdsXml) {
229
+ const parsed = parseOoxmlSafe(commentsIdsXml, 'application/xml');
230
+ if (!parsed.doc || parsed.error) return out;
231
+ idsDoc = parsed.doc;
232
+ const root = idsDoc.documentElement;
233
+ for (const entry of Array.from(root.getElementsByTagNameNS('*', 'commentId'))) {
234
+ const key = attrOf(entry, 'w16cid:paraId', 'paraId').toUpperCase();
235
+ const durable = attrOf(entry, 'w16cid:durableId', 'durableId').toUpperCase();
236
+ if (durable) taken.add(durable);
237
+ if (!byKey.has(key)) { root.removeChild(entry); idsChanged = true; continue; }
238
+ durableByKey.set(key, durable);
239
+ }
240
+ for (const key of byKey.keys()) {
241
+ if (durableByKey.has(key)) continue;
242
+ const durable = durableIdFor(key, taken);
243
+ const entry = idsDoc.createElementNS(NS_W16CID, 'w16cid:commentId');
244
+ entry.setAttributeNS(NS_W16CID, 'w16cid:paraId', key);
245
+ entry.setAttributeNS(NS_W16CID, 'w16cid:durableId', durable);
246
+ root.appendChild(entry);
247
+ durableByKey.set(key, durable);
248
+ idsChanged = true;
249
+ }
250
+ }
251
+
252
+ let extDoc = null;
253
+ let extChanged = false;
254
+ if (commentsExtensibleXml && idsDoc) {
255
+ const parsed = parseOoxmlSafe(commentsExtensibleXml, 'application/xml');
256
+ if (parsed.doc && !parsed.error) {
257
+ extDoc = parsed.doc;
258
+ const root = extDoc.documentElement;
259
+ const live = new Set(durableByKey.values());
260
+ const present = new Set();
261
+ for (const entry of Array.from(root.getElementsByTagNameNS('*', 'commentExtensible'))) {
262
+ const durable = attrOf(entry, 'w16cex:durableId', 'durableId').toUpperCase();
263
+ if (!live.has(durable)) { root.removeChild(entry); extChanged = true; } else present.add(durable);
264
+ }
265
+ for (const [key, durable] of durableByKey) {
266
+ if (present.has(durable)) continue;
267
+ const date = attrOf(byKey.get(key), 'w:date', 'date');
268
+ const entry = extDoc.createElementNS(NS_W16CEX, 'w16cex:commentExtensible');
269
+ entry.setAttributeNS(NS_W16CEX, 'w16cex:durableId', durable);
270
+ if (date) entry.setAttributeNS(NS_W16CEX, 'w16cex:dateUtc', date);
271
+ root.appendChild(entry);
272
+ extChanged = true;
273
+ }
274
+ }
275
+ }
276
+
277
+ if (idsChanged) out.commentsIdsXml = serializer.serializeToString(idsDoc);
278
+ if (extChanged) out.commentsExtensibleXml = serializer.serializeToString(extDoc);
279
+ return out;
280
+ }