@ansonlai/docx-redline-js 0.4.0 → 0.5.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.
- package/AGENTS.md +646 -288
- 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/redline-validation.js +11 -5
- 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 +3145 -505
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +88 -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/2026-09-08-cross-author-revision-slicing.md +505 -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 +508 -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 +407 -50
- package/engine/surgical-mode.js +142 -6
- package/engine/surgical-run-splitting.js +103 -0
- 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 +322 -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 +123 -102
- 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-cross-author-slicing-fixtures.ps1 +256 -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 +513 -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 +345 -0
- package/services/document-operation-mutations.js +1749 -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 +390 -0
- package/services/receipt-collector.js +288 -0
- package/services/revision-comment-management.js +77 -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
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture entity management, resolution, and validation for batch operations.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { NS_W } from '../core/types.js';
|
|
6
|
+
import {
|
|
7
|
+
createParagraphFingerprint,
|
|
8
|
+
getDocumentParagraphNodes,
|
|
9
|
+
getParagraphId,
|
|
10
|
+
findContainingWordElement
|
|
11
|
+
} from '../core/paragraph-targeting.js';
|
|
12
|
+
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
13
|
+
|
|
14
|
+
function createCaptureError(code, message) {
|
|
15
|
+
const error = new Error(message);
|
|
16
|
+
error.code = code;
|
|
17
|
+
return error;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ensureParagraphIdsOnImportedNode(_node, _session) {
|
|
21
|
+
// Preserves existing paragraph attributes without injecting undeclared namespaces into the document.
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Derives a serializable CapturedEntity record from the live DOM nodes produced by an operation.
|
|
26
|
+
*
|
|
27
|
+
* @param {import('./document-operation-session.js').DocumentOperationSession} session
|
|
28
|
+
* @param {object} operation
|
|
29
|
+
* @param {Array<Element>} liveNodes
|
|
30
|
+
* @returns {object} CapturedEntity
|
|
31
|
+
*/
|
|
32
|
+
export function deriveCapturedEntity(session, operation, liveNodes) {
|
|
33
|
+
const paragraphs = [];
|
|
34
|
+
let hasTable = false;
|
|
35
|
+
let hasList = false;
|
|
36
|
+
|
|
37
|
+
for (const node of (liveNodes || [])) {
|
|
38
|
+
if (!node) continue;
|
|
39
|
+
if (node.localName === 'tbl') {
|
|
40
|
+
hasTable = true;
|
|
41
|
+
paragraphs.push(...Array.from(node.getElementsByTagNameNS(NS_W, 'p')));
|
|
42
|
+
} else if (node.localName === 'p') {
|
|
43
|
+
if (node.getElementsByTagNameNS(NS_W, 'numPr').length > 0) {
|
|
44
|
+
hasList = true;
|
|
45
|
+
}
|
|
46
|
+
paragraphs.push(node);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const kind = hasTable
|
|
51
|
+
? 'table'
|
|
52
|
+
: (hasList ? 'list' : (paragraphs.length > 1 ? 'range' : 'paragraph'));
|
|
53
|
+
|
|
54
|
+
const generatedParagraphIds = [];
|
|
55
|
+
const fingerprints = [];
|
|
56
|
+
const expectedText = [];
|
|
57
|
+
const structuralPathHints = [];
|
|
58
|
+
|
|
59
|
+
const xmlDoc = session.document;
|
|
60
|
+
const allDocParagraphs = getDocumentParagraphNodes(xmlDoc);
|
|
61
|
+
|
|
62
|
+
for (const p of paragraphs) {
|
|
63
|
+
const paraId = getParagraphId(p) || '';
|
|
64
|
+
generatedParagraphIds.push(paraId);
|
|
65
|
+
const text = extractCanonicalParagraphText(p);
|
|
66
|
+
expectedText.push(text);
|
|
67
|
+
const pIndex = allDocParagraphs.indexOf(p) + 1;
|
|
68
|
+
fingerprints.push(createParagraphFingerprint(p, {
|
|
69
|
+
text,
|
|
70
|
+
paragraphId: paraId || null,
|
|
71
|
+
index: pIndex > 0 ? pIndex : undefined,
|
|
72
|
+
inTable: hasTable || !!findContainingWordElement(p, 'tbl')
|
|
73
|
+
}));
|
|
74
|
+
const parentTag = p.parentNode?.localName || 'body';
|
|
75
|
+
structuralPathHints.push(`${parentTag}/${p.localName}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
captureKey: operation.captureKey,
|
|
80
|
+
operationIndex: typeof operation.index === 'number' ? operation.index : 0,
|
|
81
|
+
kind,
|
|
82
|
+
generatedParagraphIds,
|
|
83
|
+
fingerprints,
|
|
84
|
+
expectedText,
|
|
85
|
+
structuralPathHints,
|
|
86
|
+
stale: false
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Invalidates any captures in the session capture table that referenced removed or replaced paragraphs.
|
|
92
|
+
*
|
|
93
|
+
* @param {Map<string, object>} captureTable
|
|
94
|
+
* @param {Array<Element>} removedNodes
|
|
95
|
+
*/
|
|
96
|
+
export function invalidateAffectedCaptures(captureTable, removedNodes) {
|
|
97
|
+
if (!captureTable || !(captureTable instanceof Map) || !Array.isArray(removedNodes) || removedNodes.length === 0) return;
|
|
98
|
+
const removedIds = new Set();
|
|
99
|
+
for (const node of removedNodes) {
|
|
100
|
+
if (!node) continue;
|
|
101
|
+
if (node.localName === 'p') {
|
|
102
|
+
const id = getParagraphId(node);
|
|
103
|
+
if (id) removedIds.add(id);
|
|
104
|
+
} else if (typeof node.getElementsByTagNameNS === 'function') {
|
|
105
|
+
for (const p of Array.from(node.getElementsByTagNameNS(NS_W, 'p'))) {
|
|
106
|
+
const id = getParagraphId(p);
|
|
107
|
+
if (id) removedIds.add(id);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (removedIds.size === 0) return;
|
|
112
|
+
for (const [, captured] of captureTable) {
|
|
113
|
+
if (captured.stale) continue;
|
|
114
|
+
if (captured.generatedParagraphIds.some(id => id && removedIds.has(id))) {
|
|
115
|
+
captured.stale = true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolves a target paragraph from an existing capture in the session capture table.
|
|
122
|
+
*
|
|
123
|
+
* @param {Document} xmlDoc
|
|
124
|
+
* @param {import('./document-operation-session.js').DocumentOperationSession} session
|
|
125
|
+
* @param {object} targetDescriptor
|
|
126
|
+
* @param {string} opType
|
|
127
|
+
* @param {object} options
|
|
128
|
+
* @returns {{ paragraph: Element, resolvedBy: 'capture' }}
|
|
129
|
+
*/
|
|
130
|
+
export function resolveTargetFromCapture(xmlDoc, session, targetDescriptor, _opType = 'redline', _options = {}) {
|
|
131
|
+
const captureRef = targetDescriptor?.captureRef;
|
|
132
|
+
if (!captureRef) {
|
|
133
|
+
throw createCaptureError('INVALID_OPERATION', 'Operation target is missing captureRef.');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!session || !session.captureTable || !session.captureTable.has(captureRef)) {
|
|
137
|
+
throw createCaptureError('CAPTURE_NOT_FOUND', `Capture "${captureRef}" was not found in the session capture table.`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const captured = session.captureTable.get(captureRef);
|
|
141
|
+
if (captured.stale) {
|
|
142
|
+
throw createCaptureError('CAPTURE_STALE', `Capture "${captureRef}" is stale; the captured content was modified or removed.`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const metadataIndex = session.getParagraphMetadataIndex();
|
|
146
|
+
const liveParagraphs = [];
|
|
147
|
+
|
|
148
|
+
for (let i = 0; i < captured.expectedText.length; i++) {
|
|
149
|
+
const expectedParaId = captured.generatedParagraphIds[i];
|
|
150
|
+
const expected = captured.expectedText[i];
|
|
151
|
+
let foundNode = null;
|
|
152
|
+
|
|
153
|
+
if (expectedParaId && metadataIndex.byId.has(expectedParaId)) {
|
|
154
|
+
foundNode = metadataIndex.byId.get(expectedParaId).paragraph;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!foundNode) {
|
|
158
|
+
const candidate = metadataIndex.entries.find(entry =>
|
|
159
|
+
entry.text === expected || entry.fingerprint === captured.fingerprints[i]
|
|
160
|
+
);
|
|
161
|
+
if (candidate) foundNode = candidate.paragraph;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!foundNode || !foundNode.parentNode) {
|
|
165
|
+
captured.stale = true;
|
|
166
|
+
throw createCaptureError('CAPTURE_STALE', `Capture "${captureRef}" is stale; paragraph ${i + 1} was removed from the document.`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const currentText = extractCanonicalParagraphText(foundNode);
|
|
170
|
+
if (currentText !== expected) {
|
|
171
|
+
captured.stale = true;
|
|
172
|
+
throw createCaptureError('CAPTURE_STALE', `Capture "${captureRef}" is stale; paragraph ${i + 1} content drifted from "${expected}" to "${currentText}".`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
liveParagraphs.push(foundNode);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (liveParagraphs.length === 0) {
|
|
179
|
+
captured.stale = true;
|
|
180
|
+
throw createCaptureError('CAPTURE_STALE', `Capture "${captureRef}" is stale; no live paragraphs found.`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const select = targetDescriptor.select;
|
|
184
|
+
if (select == null || select === '') {
|
|
185
|
+
if (liveParagraphs.length === 1) {
|
|
186
|
+
return { paragraph: liveParagraphs[0], resolvedBy: 'capture' };
|
|
187
|
+
}
|
|
188
|
+
throw createCaptureError(
|
|
189
|
+
'AMBIGUOUS_CAPTURE_SELECTION',
|
|
190
|
+
`Capture "${captureRef}" contains ${liveParagraphs.length} paragraphs; target.select is required to disambiguate.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let matched = [];
|
|
195
|
+
const trimmedSelect = String(select).trim();
|
|
196
|
+
if (trimmedSelect === ':first' || trimmedSelect === 'first') {
|
|
197
|
+
matched = [liveParagraphs[0]];
|
|
198
|
+
} else if (trimmedSelect === ':last' || trimmedSelect === 'last') {
|
|
199
|
+
matched = [liveParagraphs[liveParagraphs.length - 1]];
|
|
200
|
+
} else if (/^\d+$/.test(trimmedSelect)) {
|
|
201
|
+
const index1Based = parseInt(trimmedSelect, 10);
|
|
202
|
+
if (index1Based >= 1 && index1Based <= liveParagraphs.length) {
|
|
203
|
+
matched = [liveParagraphs[index1Based - 1]];
|
|
204
|
+
}
|
|
205
|
+
} else {
|
|
206
|
+
const exactMatches = liveParagraphs.filter(p => extractCanonicalParagraphText(p).trim() === trimmedSelect);
|
|
207
|
+
if (exactMatches.length > 0) {
|
|
208
|
+
matched = exactMatches;
|
|
209
|
+
} else {
|
|
210
|
+
matched = liveParagraphs.filter(p => extractCanonicalParagraphText(p).includes(trimmedSelect));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (matched.length === 0) {
|
|
215
|
+
throw createCaptureError('TARGET_NOT_FOUND', `Target selection "${select}" was not found in capture "${captureRef}".`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (matched.length > 1) {
|
|
219
|
+
throw createCaptureError(
|
|
220
|
+
'AMBIGUOUS_CAPTURE_SELECTION',
|
|
221
|
+
`Target selection "${select}" matched ${matched.length} paragraphs in capture "${captureRef}".`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { paragraph: matched[0], resolvedBy: 'capture' };
|
|
226
|
+
}
|
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
* Comment XML builders.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { NS_W, escapeXml } from '../core/types.js';
|
|
5
|
+
import { NS_W, escapeXml } from '../core/types.js';
|
|
6
|
+
|
|
7
|
+
export const NS_W14 = 'http://schemas.microsoft.com/office/word/2010/wordml';
|
|
8
|
+
export const NS_W15 = 'http://schemas.microsoft.com/office/word/2012/wordml';
|
|
9
|
+
|
|
10
|
+
export function createCommentParaId(commentId) {
|
|
11
|
+
const numeric = Number.parseInt(String(commentId), 10);
|
|
12
|
+
const value = Number.isFinite(numeric) ? (0x70000000 + (numeric >>> 0)) >>> 0 : 0x70000000;
|
|
13
|
+
return value.toString(16).toUpperCase().padStart(8, '0').slice(-8);
|
|
14
|
+
}
|
|
6
15
|
|
|
7
16
|
/**
|
|
8
17
|
* Builds a single w:comment element.
|
|
@@ -13,13 +22,13 @@ import { NS_W, escapeXml } from '../core/types.js';
|
|
|
13
22
|
* @param {string} date - ISO date string
|
|
14
23
|
* @returns {string}
|
|
15
24
|
*/
|
|
16
|
-
export function buildCommentElement(commentId, author, content, date) {
|
|
25
|
+
export function buildCommentElement(commentId, author, content, date, paraId = createCommentParaId(commentId)) {
|
|
17
26
|
const initials = author.split(' ').map(word => word[0]).join('').toUpperCase() || 'AI';
|
|
18
27
|
const escapedContent = escapeXml(content);
|
|
19
28
|
const escapedAuthor = escapeXml(author);
|
|
20
29
|
|
|
21
30
|
return `<w:comment w:id="${commentId}" w:author="${escapedAuthor}" w:date="${date}" w:initials="${initials}">
|
|
22
|
-
<w:p>
|
|
31
|
+
<w:p w14:paraId="${escapeXml(paraId)}" xmlns:w14="${NS_W14}">
|
|
23
32
|
<w:r><w:t>${escapedContent}</w:t></w:r>
|
|
24
33
|
</w:p>
|
|
25
34
|
</w:comment>`;
|
|
@@ -31,19 +40,27 @@ export function buildCommentElement(commentId, author, content, date) {
|
|
|
31
40
|
* @param {Array<{id:number,content:string,author:string,date:string}>} comments - Placed comments
|
|
32
41
|
* @returns {string}
|
|
33
42
|
*/
|
|
34
|
-
export function buildCommentsPartXml(comments) {
|
|
43
|
+
export function buildCommentsPartXml(comments) {
|
|
35
44
|
if (!comments || comments.length === 0) {
|
|
36
45
|
return `<w:comments xmlns:w="${NS_W}"></w:comments>`;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
const commentElements = comments.map(comment =>
|
|
40
|
-
buildCommentElement(comment.id, comment.author, comment.content, comment.date)
|
|
49
|
+
buildCommentElement(comment.id, comment.author, comment.content, comment.date, comment.paraId)
|
|
41
50
|
).join('\n ');
|
|
42
51
|
|
|
43
52
|
return `<w:comments xmlns:w="${NS_W}">
|
|
44
53
|
${commentElements}
|
|
45
54
|
</w:comments>`;
|
|
46
|
-
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildCommentsExtendedPartXml(entries) {
|
|
58
|
+
const body = (entries || []).map(entry => {
|
|
59
|
+
const parent = entry.paraIdParent ? ` w15:paraIdParent="${escapeXml(entry.paraIdParent)}"` : '';
|
|
60
|
+
return `<w15:commentEx w15:paraId="${escapeXml(entry.paraId)}"${parent} w15:done="${entry.done ? '1' : '0'}"/>`;
|
|
61
|
+
}).join('');
|
|
62
|
+
return `<w15:commentsEx xmlns:w15="${NS_W15}">${body}</w15:commentsEx>`;
|
|
63
|
+
}
|
|
47
64
|
|
|
48
65
|
/**
|
|
49
66
|
* Builds inline range/reference markers for a comment id.
|
|
@@ -4,13 +4,19 @@
|
|
|
4
4
|
* Provides pure OOXML-based comment insertion without Word JS API calls.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
NS_W,
|
|
9
|
+
createRevisionIdAllocator,
|
|
10
|
+
getNextRevisionId,
|
|
11
|
+
getRevisionTimestamp,
|
|
12
|
+
resetRevisionIdCounter
|
|
13
|
+
} from '../core/types.js';
|
|
8
14
|
import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
9
15
|
import { log, error as logError } from '../adapters/logger.js';
|
|
10
16
|
import { getElementsByTag, getFirstElementByTag, getXmlParseError } from '../core/xml-query.js';
|
|
11
17
|
import { buildCommentElement, buildCommentsPartXml, buildCommentMarkers } from './comment-builders.js';
|
|
12
18
|
import { getDefaultAuthor } from '../adapters/config.js';
|
|
13
|
-
import { createParagraphTextIndex, injectMarkersIntoParagraph } from './comment-locator.js';
|
|
19
|
+
import { createParagraphTextIndex, injectMarkersIntoParagraph, resolveTextInParagraphIndex } from './comment-locator.js';
|
|
14
20
|
import {
|
|
15
21
|
injectCommentsIntoPackage as injectCommentsIntoExistingPackage,
|
|
16
22
|
wrapParagraphWithComments,
|
|
@@ -63,7 +69,9 @@ export function injectCommentsIntoOoxml(oxml, comments, options = {}) {
|
|
|
63
69
|
const author = options?.author || getDefaultAuthor();
|
|
64
70
|
const date = getRevisionTimestamp();
|
|
65
71
|
const warnings = [];
|
|
66
|
-
const placedComments = [];
|
|
72
|
+
const placedComments = [];
|
|
73
|
+
const resolvedAnchors = [];
|
|
74
|
+
const errors = [];
|
|
67
75
|
|
|
68
76
|
if (!comments || comments.length === 0) {
|
|
69
77
|
return {
|
|
@@ -93,26 +101,29 @@ export function injectCommentsIntoOoxml(oxml, comments, options = {}) {
|
|
|
93
101
|
};
|
|
94
102
|
}
|
|
95
103
|
|
|
96
|
-
const xmlDoc = parseResult.xmlDoc;
|
|
97
|
-
const
|
|
104
|
+
const xmlDoc = parseResult.xmlDoc;
|
|
105
|
+
const revisionIdAllocator = createRevisionIdAllocator(xmlDoc);
|
|
106
|
+
const paragraphs = getElementsByTag(xmlDoc, 'w:p');
|
|
98
107
|
log(`[CommentEngine] Found ${paragraphs.length} paragraphs, processing ${comments.length} comment requests`);
|
|
99
108
|
|
|
100
109
|
/** @type {Map<number, number>} */
|
|
101
110
|
const remainingRequestsByParagraph = new Map();
|
|
102
|
-
for (const request of comments) {
|
|
103
|
-
const paragraphIndex = request.paragraphIndex - 1;
|
|
104
|
-
if (paragraphIndex < 0 || paragraphIndex >= paragraphs.length) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
111
|
+
for (const request of comments) {
|
|
112
|
+
const paragraphIndex = request.paragraphIndex - 1;
|
|
113
|
+
if (paragraphIndex < 0 || paragraphIndex >= paragraphs.length) {
|
|
114
|
+
const message = `Paragraph ${request.paragraphIndex} out of range (1-${paragraphs.length})`;
|
|
115
|
+
warnings.push(message);
|
|
116
|
+
errors.push({ code: 'TARGET_NOT_FOUND', message, paragraphIndex: request.paragraphIndex });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
108
119
|
remainingRequestsByParagraph.set(paragraphIndex, (remainingRequestsByParagraph.get(paragraphIndex) || 0) + 1);
|
|
109
120
|
}
|
|
110
121
|
|
|
111
122
|
/** @type {Map<number, { fullText: string, runOffsets: Array<{run: Element, start: number, end: number}> }>} */
|
|
112
123
|
const paragraphIndexes = new Map();
|
|
113
124
|
|
|
114
|
-
for (const request of comments) {
|
|
115
|
-
const paragraphIndex = request.paragraphIndex - 1;
|
|
125
|
+
for (const [requestIndex, request] of comments.entries()) {
|
|
126
|
+
const paragraphIndex = request.paragraphIndex - 1;
|
|
116
127
|
if (paragraphIndex < 0 || paragraphIndex >= paragraphs.length) {
|
|
117
128
|
continue;
|
|
118
129
|
}
|
|
@@ -124,32 +135,64 @@ export function injectCommentsIntoOoxml(oxml, comments, options = {}) {
|
|
|
124
135
|
paragraphIndexes.set(paragraphIndex, textIndex);
|
|
125
136
|
}
|
|
126
137
|
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
138
|
+
const anchorText = String(request.textToFind ?? '');
|
|
139
|
+
const resolution = resolveTextInParagraphIndex(textIndex, anchorText);
|
|
140
|
+
const remaining = (remainingRequestsByParagraph.get(paragraphIndex) || 1) - 1;
|
|
141
|
+
remainingRequestsByParagraph.set(paragraphIndex, remaining);
|
|
142
|
+
if (!resolution.found) {
|
|
143
|
+
const error = {
|
|
144
|
+
...resolution.error,
|
|
145
|
+
requestIndex: requestIndex + 1,
|
|
146
|
+
paragraphIndex: request.paragraphIndex
|
|
147
|
+
};
|
|
148
|
+
errors.push(error);
|
|
149
|
+
warnings.push(error.message);
|
|
150
|
+
if (remaining === 0) paragraphIndexes.delete(paragraphIndex);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const commentId = typeof options.commentIdAllocator === 'function'
|
|
155
|
+
? options.commentIdAllocator()
|
|
156
|
+
: getNextRevisionId();
|
|
157
|
+
const success = injectMarkersIntoParagraph(
|
|
158
|
+
xmlDoc,
|
|
159
|
+
targetParagraph,
|
|
160
|
+
anchorText,
|
|
161
|
+
commentId,
|
|
162
|
+
textIndex,
|
|
163
|
+
revisionIdAllocator,
|
|
164
|
+
resolution
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
if (!success) {
|
|
168
|
+
const error = {
|
|
169
|
+
code: 'ANCHOR_INSERTION_FAILED',
|
|
170
|
+
message: `Resolved comment anchor could not be inserted in paragraph ${request.paragraphIndex}.`,
|
|
171
|
+
requestIndex: requestIndex + 1,
|
|
172
|
+
paragraphIndex: request.paragraphIndex
|
|
173
|
+
};
|
|
174
|
+
errors.push(error);
|
|
175
|
+
warnings.push(error.message);
|
|
176
|
+
if (remaining === 0) {
|
|
177
|
+
paragraphIndexes.delete(paragraphIndex);
|
|
178
|
+
}
|
|
144
179
|
continue;
|
|
145
180
|
}
|
|
146
181
|
|
|
147
|
-
placedComments.push({
|
|
182
|
+
placedComments.push({
|
|
148
183
|
id: commentId,
|
|
149
184
|
content: request.commentContent,
|
|
150
185
|
author,
|
|
151
|
-
date
|
|
152
|
-
});
|
|
186
|
+
date
|
|
187
|
+
});
|
|
188
|
+
resolvedAnchors.push({
|
|
189
|
+
requestIndex: requestIndex + 1,
|
|
190
|
+
paragraphIndex: request.paragraphIndex,
|
|
191
|
+
text: anchorText,
|
|
192
|
+
resolvedBy: resolution.resolvedBy,
|
|
193
|
+
start: resolution.start,
|
|
194
|
+
end: resolution.end
|
|
195
|
+
});
|
|
153
196
|
|
|
154
197
|
if (remaining > 0) {
|
|
155
198
|
// Rebuild only when another request still targets this paragraph.
|
|
@@ -159,21 +202,26 @@ export function injectCommentsIntoOoxml(oxml, comments, options = {}) {
|
|
|
159
202
|
}
|
|
160
203
|
}
|
|
161
204
|
|
|
162
|
-
if (placedComments.length === 0) {
|
|
205
|
+
if (placedComments.length === 0) {
|
|
163
206
|
return {
|
|
164
207
|
oxml,
|
|
165
208
|
hasChanges: false,
|
|
166
209
|
commentsApplied: 0,
|
|
167
|
-
warnings
|
|
210
|
+
warnings,
|
|
211
|
+
resolvedAnchors,
|
|
212
|
+
...(errors.length > 0 ? { status: 'error', error: errors[0], errors } : {})
|
|
168
213
|
};
|
|
169
|
-
}
|
|
214
|
+
}
|
|
170
215
|
|
|
171
216
|
return {
|
|
172
217
|
oxml: serializer.serializeToString(xmlDoc),
|
|
173
218
|
hasChanges: true,
|
|
174
219
|
commentsXml: buildCommentsPartXml(placedComments),
|
|
175
|
-
commentsApplied: placedComments.length,
|
|
176
|
-
|
|
220
|
+
commentsApplied: placedComments.length,
|
|
221
|
+
placedComments,
|
|
222
|
+
warnings,
|
|
223
|
+
resolvedAnchors,
|
|
224
|
+
...(errors.length > 0 ? { status: 'error', error: errors[0], errors } : {})
|
|
177
225
|
};
|
|
178
226
|
}
|
|
179
227
|
|
|
@@ -187,10 +235,9 @@ export function injectCommentsIntoOoxml(oxml, comments, options = {}) {
|
|
|
187
235
|
* @param {string} [options.author='AI Assistant'] - Comment author
|
|
188
236
|
* @returns {{ success: boolean, package?: string, warning?: string, commentId?: number }}
|
|
189
237
|
*/
|
|
190
|
-
export function injectCommentIntoParagraphOoxml(paragraphOoxml, textToFind, commentContent, options = {}) {
|
|
191
|
-
const { author = 'AI Assistant' } = options;
|
|
192
|
-
const date = getRevisionTimestamp();
|
|
193
|
-
const commentId = getNextRevisionId();
|
|
238
|
+
export function injectCommentIntoParagraphOoxml(paragraphOoxml, textToFind, commentContent, options = {}) {
|
|
239
|
+
const { author = 'AI Assistant' } = options;
|
|
240
|
+
const date = getRevisionTimestamp();
|
|
194
241
|
|
|
195
242
|
const serializer = createSerializer();
|
|
196
243
|
const parseResult = parseDocumentOxml(
|
|
@@ -202,15 +249,29 @@ export function injectCommentIntoParagraphOoxml(paragraphOoxml, textToFind, comm
|
|
|
202
249
|
return { success: false, warning: parseResult.warning };
|
|
203
250
|
}
|
|
204
251
|
|
|
205
|
-
const xmlDoc = parseResult.xmlDoc;
|
|
206
|
-
const
|
|
252
|
+
const xmlDoc = parseResult.xmlDoc;
|
|
253
|
+
const revisionIdAllocator = createRevisionIdAllocator(xmlDoc);
|
|
254
|
+
const paragraphs = getElementsByTag(xmlDoc, 'w:p');
|
|
207
255
|
if (paragraphs.length === 0) {
|
|
208
256
|
return { success: false, warning: 'No paragraph found in OOXML' };
|
|
209
257
|
}
|
|
210
258
|
|
|
211
|
-
const paragraph = paragraphs[0];
|
|
212
|
-
const paragraphIndex = createParagraphTextIndex(paragraph);
|
|
213
|
-
const
|
|
259
|
+
const paragraph = paragraphs[0];
|
|
260
|
+
const paragraphIndex = createParagraphTextIndex(paragraph);
|
|
261
|
+
const resolution = resolveTextInParagraphIndex(paragraphIndex, textToFind);
|
|
262
|
+
if (!resolution.found) {
|
|
263
|
+
return { success: false, warning: resolution.error.message, error: resolution.error };
|
|
264
|
+
}
|
|
265
|
+
const commentId = getNextRevisionId();
|
|
266
|
+
const success = injectMarkersIntoParagraph(
|
|
267
|
+
xmlDoc,
|
|
268
|
+
paragraph,
|
|
269
|
+
textToFind,
|
|
270
|
+
commentId,
|
|
271
|
+
paragraphIndex,
|
|
272
|
+
revisionIdAllocator,
|
|
273
|
+
resolution
|
|
274
|
+
);
|
|
214
275
|
if (!success) {
|
|
215
276
|
return { success: false, warning: `Could not find "${textToFind.substring(0, 30)}..." in paragraph` };
|
|
216
277
|
}
|