@ansonlai/docx-redline-js 0.8.0 → 0.8.2
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/CHANGELOG.md +553 -486
- package/README.md +331 -309
- package/dist/docx-redline-js.esm.js +1209 -168
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +107 -107
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +1209 -168
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +1209 -168
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_KNOWLEDGE_BASE.md +69 -8
- package/docs/schemas/document-operations.schema.json +29 -0
- package/document/docx-document.js +416 -22
- package/engine/reconstruction-mode.js +183 -54
- package/engine/reconstruction-writer.js +18 -0
- package/index.d.ts +598 -575
- package/index.js +349 -348
- package/node/cli-help.js +5 -5
- package/node/cli.js +760 -754
- package/node/index.d.ts +1 -1
- package/package.json +2 -1
- package/pipeline/diff-engine.js +274 -258
- package/scripts/generate-word-comment-edge-case-fixture.ps1 +58 -0
- package/scripts/generate-word-comment-thread-fixture.ps1 +36 -0
- package/scripts/generate-word-header-footer-fixture.ps1 +32 -0
- package/scripts/generate-word-multi-section-fixture.ps1 +27 -0
- package/scripts/generate-word-package-integrity-cases.mjs +141 -0
- package/scripts/generate-word-thread-state-fixtures.ps1 +50 -0
- package/scripts/run-tests.mjs +14 -2
- package/scripts/word-com-package-integrity.ps1 +98 -0
- package/services/batch-operation-orchestrator.js +262 -259
- package/services/comment-replies.js +96 -95
- package/services/comment-thread-parts.js +280 -0
- package/services/document-inspection.js +366 -365
- package/services/document-operation-applier.js +230 -202
- package/services/document-operation-contract.js +17 -2
- package/services/error-recovery.js +6 -0
- package/services/headers-footers.js +206 -0
- package/services/operation-batch-compiler.js +1 -0
- package/services/operation-preflight.js +231 -218
- package/services/package-parts.js +80 -0
- package/services/standalone-docx-plumbing.js +631 -569
- package/services/standalone-operation-runner.d.ts +19 -3
|
@@ -1,95 +1,96 @@
|
|
|
1
|
-
import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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',
|
|
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
|
+
}
|