@ansonlai/docx-redline-js 0.7.2 → 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.
- package/AGENTS.md +15 -10
- package/ARCHITECTURE.md +30 -10
- package/CHANGELOG.md +539 -431
- package/README.md +331 -309
- package/adapters/xml-adapter.js +84 -56
- package/core/sha256.js +120 -0
- package/dist/docx-redline-js.esm.js +8982 -693
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +102 -89
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +28305 -0
- package/dist/docx-redline.bundle.cjs.map +7 -0
- package/dist/docx-redline.bundle.js +28296 -0
- package/dist/docx-redline.bundle.js.map +7 -0
- package/docs/AGENT_FAST_START.md +2 -1
- package/docs/AGENT_KNOWLEDGE_BASE.md +480 -393
- package/docs/SKILL_AUTHORING.md +5 -2
- package/docs/TESTING.md +2 -1
- package/docs/schemas/document-operations.schema.json +29 -0
- package/document/docx-document.d.ts +39 -0
- package/document/docx-document.js +1010 -0
- package/document/zip-archive.js +258 -0
- package/engine/reconstruction-writer.js +18 -0
- package/index.d.ts +598 -531
- package/index.js +349 -340
- package/node/cli-help.js +5 -5
- package/node/cli.js +760 -754
- package/node/docx-document.js +11 -355
- package/node/index.d.ts +1 -1
- package/node/zip-archive.js +28 -38
- package/package.json +14 -11
- package/scripts/build.mjs +40 -5
- 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/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/revision-token.js +11 -7
- package/services/standalone-docx-plumbing.js +631 -569
- package/services/standalone-operation-runner.d.ts +19 -3
|
@@ -1,365 +1,366 @@
|
|
|
1
|
-
import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
2
|
-
import { NS_W } from '../core/types.js';
|
|
3
|
-
import { extractCanonicalParagraphText, readCanonicalRunText, extractParagraphRevisionSegments } from '../core/paragraph-text.js';
|
|
4
|
-
import { createParagraphFingerprint, getDocumentParagraphNodes, getParagraphId } from '../core/paragraph-targeting.js';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return {
|
|
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
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
base.
|
|
52
|
-
base.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (format === '
|
|
75
|
-
if (format === '
|
|
76
|
-
if (format === '
|
|
77
|
-
return
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
state.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
let
|
|
117
|
-
let
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
comment.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (name === '
|
|
177
|
-
|
|
178
|
-
else if (name === '
|
|
179
|
-
else
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (options.
|
|
190
|
-
if (options.
|
|
191
|
-
if (options.
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
let
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
const
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
owners.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
...(
|
|
254
|
-
...(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
const
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
if (
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
const
|
|
285
|
-
const
|
|
286
|
-
const
|
|
287
|
-
const
|
|
288
|
-
const
|
|
289
|
-
const
|
|
290
|
-
const
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
definition
|
|
308
|
-
definition.
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
let
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if (
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if (options.
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (altText.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}
|
|
1
|
+
import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
2
|
+
import { NS_W } from '../core/types.js';
|
|
3
|
+
import { extractCanonicalParagraphText, readCanonicalRunText, extractParagraphRevisionSegments } from '../core/paragraph-text.js';
|
|
4
|
+
import { createParagraphFingerprint, getDocumentParagraphNodes, getParagraphId } from '../core/paragraph-targeting.js';
|
|
5
|
+
import { commentParaId } from './comment-thread-parts.js';
|
|
6
|
+
import { extractDocumentPartsEntries, computeRevisionTokenSync } from './revision-token.js';
|
|
7
|
+
|
|
8
|
+
const attr = (node, name) => node?.getAttribute?.(`w:${name}`) || node?.getAttribute?.(name) || '';
|
|
9
|
+
const descendants = (node, name) => Array.from(node?.getElementsByTagNameNS?.(NS_W, name) || []);
|
|
10
|
+
const first = (node, name) => descendants(node, name)[0] || null;
|
|
11
|
+
function hasAncestor(node, localName) { let cursor = node?.parentNode; while (cursor) { if (cursor.localName === localName && (!cursor.namespaceURI || cursor.namespaceURI === NS_W)) return true; cursor = cursor.parentNode; } return false; }
|
|
12
|
+
|
|
13
|
+
function parseXml(xml, partName, required = false) {
|
|
14
|
+
if (!xml) return required ? { error: { code: 'MISSING_PART', message: `Missing ${partName}.` } } : { doc: null };
|
|
15
|
+
const parsed = parseOoxmlSafe(xml, 'application/xml');
|
|
16
|
+
if (!parsed.doc || parsed.error) return { error: { code: 'PARSE_ERROR', message: `Could not parse ${partName}: ${parsed.error?.message || 'invalid XML'}` } };
|
|
17
|
+
return { doc: parsed.doc, warnings: parsed.warnings || [] };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function paragraphListProperties(paragraph) {
|
|
21
|
+
const numPr = first(first(paragraph, 'pPr'), 'numPr');
|
|
22
|
+
if (!numPr) return null;
|
|
23
|
+
const numId = attr(first(numPr, 'numId'), 'val');
|
|
24
|
+
if (!numId || numId === '0') return null;
|
|
25
|
+
return { numId, level: Number.parseInt(attr(first(numPr, 'ilvl'), 'val') || '0', 10) || 0 };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseNumbering(numberingDoc) {
|
|
29
|
+
const abstracts = new Map();
|
|
30
|
+
for (const abstract of descendants(numberingDoc, 'abstractNum')) {
|
|
31
|
+
const levels = new Map();
|
|
32
|
+
for (const level of descendants(abstract, 'lvl')) {
|
|
33
|
+
const ilvl = Number.parseInt(attr(level, 'ilvl') || '0', 10) || 0;
|
|
34
|
+
levels.set(ilvl, {
|
|
35
|
+
start: Number.parseInt(attr(first(level, 'start'), 'val') || '1', 10) || 1,
|
|
36
|
+
format: attr(first(level, 'numFmt'), 'val') || 'decimal',
|
|
37
|
+
text: attr(first(level, 'lvlText'), 'val') || `%${ilvl + 1}.`
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
abstracts.set(attr(abstract, 'abstractNumId'), levels);
|
|
41
|
+
}
|
|
42
|
+
const nums = new Map();
|
|
43
|
+
for (const num of descendants(numberingDoc, 'num')) {
|
|
44
|
+
const abstractId = attr(first(num, 'abstractNumId'), 'val');
|
|
45
|
+
const levels = new Map(abstracts.get(abstractId) || []);
|
|
46
|
+
for (const override of descendants(num, 'lvlOverride')) {
|
|
47
|
+
const ilvl = Number.parseInt(attr(override, 'ilvl') || '0', 10) || 0;
|
|
48
|
+
const embedded = first(override, 'lvl');
|
|
49
|
+
const base = { ...(levels.get(ilvl) || { start: 1, format: 'decimal', text: `%${ilvl + 1}.` }) };
|
|
50
|
+
if (embedded) {
|
|
51
|
+
base.start = Number.parseInt(attr(first(embedded, 'start'), 'val') || String(base.start), 10) || base.start;
|
|
52
|
+
base.format = attr(first(embedded, 'numFmt'), 'val') || base.format;
|
|
53
|
+
base.text = attr(first(embedded, 'lvlText'), 'val') || base.text;
|
|
54
|
+
}
|
|
55
|
+
const startOverride = first(override, 'startOverride');
|
|
56
|
+
if (startOverride) base.start = Number.parseInt(attr(startOverride, 'val') || String(base.start), 10) || base.start;
|
|
57
|
+
levels.set(ilvl, base);
|
|
58
|
+
}
|
|
59
|
+
nums.set(attr(num, 'numId'), levels);
|
|
60
|
+
}
|
|
61
|
+
return nums;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function alpha(value, upper) {
|
|
65
|
+
let n = Math.max(1, value); let out = '';
|
|
66
|
+
while (n > 0) { n -= 1; out = String.fromCharCode(97 + (n % 26)) + out; n = Math.floor(n / 26); }
|
|
67
|
+
return upper ? out.toUpperCase() : out;
|
|
68
|
+
}
|
|
69
|
+
function roman(value) {
|
|
70
|
+
const pairs = [[1000,'M'],[900,'CM'],[500,'D'],[400,'CD'],[100,'C'],[90,'XC'],[50,'L'],[40,'XL'],[10,'X'],[9,'IX'],[5,'V'],[4,'IV'],[1,'I']];
|
|
71
|
+
let n = value; let out = ''; for (const [amount, glyph] of pairs) while (n >= amount) { out += glyph; n -= amount; } return out;
|
|
72
|
+
}
|
|
73
|
+
function formatCounter(value, format) {
|
|
74
|
+
if (format === 'lowerLetter') return alpha(value, false);
|
|
75
|
+
if (format === 'upperLetter') return alpha(value, true);
|
|
76
|
+
if (format === 'lowerRoman') return roman(value).toLowerCase();
|
|
77
|
+
if (format === 'upperRoman') return roman(value);
|
|
78
|
+
return String(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function createNumberingResolver(numberingDoc) {
|
|
82
|
+
const nums = numberingDoc ? parseNumbering(numberingDoc) : new Map();
|
|
83
|
+
const counters = new Map();
|
|
84
|
+
return list => {
|
|
85
|
+
if (!list?.numId) return null;
|
|
86
|
+
const levels = nums.get(String(list.numId));
|
|
87
|
+
if (!levels) return { ...list, label: null, format: null };
|
|
88
|
+
const state = counters.get(list.numId) || [];
|
|
89
|
+
const definition = levels.get(list.level) || { start: 1, format: 'decimal', text: `%${list.level + 1}.` };
|
|
90
|
+
state[list.level] = state[list.level] == null ? definition.start : state[list.level] + 1;
|
|
91
|
+
state.length = list.level + 1;
|
|
92
|
+
counters.set(list.numId, state);
|
|
93
|
+
const label = definition.text.replace(/%([1-9])/g, (_, raw) => {
|
|
94
|
+
const level = Number(raw) - 1;
|
|
95
|
+
const levelDef = levels.get(level) || definition;
|
|
96
|
+
return formatCounter(state[level] ?? levelDef.start, levelDef.format);
|
|
97
|
+
});
|
|
98
|
+
return { ...list, label, format: definition.format };
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function headingLevel(paragraph) {
|
|
103
|
+
const pPr = first(paragraph, 'pPr');
|
|
104
|
+
const style = attr(first(pPr, 'pStyle'), 'val');
|
|
105
|
+
const match = style.match(/^heading\s*([1-9])$/i);
|
|
106
|
+
if (match) return Math.min(Number(match[1]), 6);
|
|
107
|
+
const outline = Number.parseInt(attr(first(pPr, 'outlineLvl'), 'val'), 10);
|
|
108
|
+
return Number.isInteger(outline) ? Math.min(outline + 1, 6) : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function structuralContext(paragraph, text) {
|
|
112
|
+
const references = [];
|
|
113
|
+
for (const [name, type] of [['footnoteReference', 'footnote'], ['endnoteReference', 'endnote'], ['commentReference', 'comment']]) {
|
|
114
|
+
for (const node of descendants(paragraph, name)) references.push({ type, id: attr(node, 'id') || null });
|
|
115
|
+
}
|
|
116
|
+
let cell = paragraph.parentNode; while (cell && cell.localName !== 'tc') cell = cell.parentNode;
|
|
117
|
+
let row = cell?.parentNode; while (row && row.localName !== 'tr') row = row.parentNode;
|
|
118
|
+
let table = row?.parentNode; while (table && table.localName !== 'tbl') table = table.parentNode;
|
|
119
|
+
const all = paragraph.ownerDocument;
|
|
120
|
+
return {
|
|
121
|
+
references,
|
|
122
|
+
table: table ? {
|
|
123
|
+
tableIndex: Array.from(all.getElementsByTagNameNS(NS_W, 'tbl')).indexOf(table) + 1,
|
|
124
|
+
rowIndex: Array.from(table.getElementsByTagNameNS(NS_W, 'tr')).indexOf(row) + 1,
|
|
125
|
+
cellIndex: Array.from(row.getElementsByTagNameNS(NS_W, 'tc')).indexOf(cell) + 1
|
|
126
|
+
} : null,
|
|
127
|
+
empty: text.length === 0
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function revisionAuthors(paragraph) {
|
|
132
|
+
const authors = new Set();
|
|
133
|
+
for (const name of ['ins', 'del', 'moveFrom', 'moveTo', 'rPrChange', 'pPrChange']) {
|
|
134
|
+
for (const node of descendants(paragraph, name)) if (attr(node, 'author')) authors.add(attr(node, 'author'));
|
|
135
|
+
}
|
|
136
|
+
return [...authors].sort();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readCommentDefinitions(commentsDoc) {
|
|
140
|
+
const result = new Map();
|
|
141
|
+
for (const comment of descendants(commentsDoc, 'comment')) {
|
|
142
|
+
const paragraphs = descendants(comment, 'p');
|
|
143
|
+
result.set(attr(comment, 'id'), {
|
|
144
|
+
id: attr(comment, 'id'), author: attr(comment, 'author') || null, date: attr(comment, 'date') || null,
|
|
145
|
+
text: paragraphs.map(p => extractCanonicalParagraphText(p)).join('\n'),
|
|
146
|
+
paraId: commentParaId(comment) || null
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function attachCommentThreadMetadata(comments, commentsExtendedDoc) {
|
|
153
|
+
if (!commentsExtendedDoc) return;
|
|
154
|
+
const byParaId = new Map([...comments.values()].filter(c => c.paraId).map(c => [c.paraId.toUpperCase(), c]));
|
|
155
|
+
for (const entry of Array.from(commentsExtendedDoc.getElementsByTagNameNS('*', 'commentEx'))) {
|
|
156
|
+
const paraId = entry.getAttribute('w15:paraId') || entry.getAttribute('paraId') || '';
|
|
157
|
+
const parentParaId = entry.getAttribute('w15:paraIdParent') || entry.getAttribute('paraIdParent') || '';
|
|
158
|
+
const comment = byParaId.get(paraId.toUpperCase());
|
|
159
|
+
if (!comment) continue;
|
|
160
|
+
comment.done = (entry.getAttribute('w15:done') || entry.getAttribute('done')) === '1';
|
|
161
|
+
if (parentParaId) {
|
|
162
|
+
comment.parentParaId = parentParaId;
|
|
163
|
+
comment.parentCommentId = byParaId.get(parentParaId.toUpperCase())?.id || null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function collectDocumentCommentAnchors(paragraphNodes, revisionView) {
|
|
169
|
+
const active = new Map(); const anchors = new Map();
|
|
170
|
+
let paragraphBoundary = null;
|
|
171
|
+
const append = value => { for (const item of active.values()) item.text += value; };
|
|
172
|
+
const visit = node => {
|
|
173
|
+
for (const child of Array.from(node?.childNodes || [])) {
|
|
174
|
+
if (child?.nodeType !== 1) continue;
|
|
175
|
+
const name = child.localName;
|
|
176
|
+
if ((revisionView === 'accepted' && (name === 'del' || name === 'moveFrom')) || (revisionView === 'rejected' && (name === 'ins' || name === 'moveTo'))) continue;
|
|
177
|
+
if (name === 'commentRangeStart') active.set(attr(child, 'id'), { text: '' });
|
|
178
|
+
else if (name === 'commentRangeEnd') { const id = attr(child, 'id'); if (active.has(id)) { anchors.set(id, active.get(id).text); active.delete(id); } }
|
|
179
|
+
else if (name === 'r') append(readCanonicalRunText(child, { revisionView, boundary: paragraphBoundary }));
|
|
180
|
+
else visit(child);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
paragraphNodes.forEach((paragraph, index) => { paragraphBoundary = paragraph; visit(paragraph); if (index < paragraphNodes.length - 1 && active.size) append('\n'); });
|
|
184
|
+
return anchors;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function selectInspectionParagraphs(allParagraphs, options) {
|
|
188
|
+
let matches = allParagraphs;
|
|
189
|
+
if (options.revisedOnly) matches = matches.filter(item => item.hasRevisions);
|
|
190
|
+
if (options.inTable != null) matches = matches.filter(item => item.inTable === !!options.inTable);
|
|
191
|
+
if (options.skipEmpty) matches = matches.filter(item => item.text.length > 0);
|
|
192
|
+
if (options.search) {
|
|
193
|
+
const needle = String(options.search).toLowerCase();
|
|
194
|
+
matches = matches.filter(item => item.text.toLowerCase().includes(needle));
|
|
195
|
+
}
|
|
196
|
+
if (Array.isArray(options.indexes)) {
|
|
197
|
+
const indexes = new Set(options.indexes);
|
|
198
|
+
matches = matches.filter(item => indexes.has(item.index));
|
|
199
|
+
}
|
|
200
|
+
let rangeStart = 1;
|
|
201
|
+
let rangeEnd = allParagraphs.length;
|
|
202
|
+
if (options.range) {
|
|
203
|
+
rangeStart = Number(options.range.start ?? options.range[0]);
|
|
204
|
+
rangeEnd = Number(options.range.end ?? options.range[1]);
|
|
205
|
+
matches = matches.filter(item => item.index >= rangeStart && item.index <= rangeEnd);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const totalMatches = matches.length;
|
|
209
|
+
const after = Number.isInteger(options.after) && options.after > 0 ? options.after : null;
|
|
210
|
+
const remaining = after == null ? matches : matches.filter(item => item.index > after);
|
|
211
|
+
const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : null;
|
|
212
|
+
const selectedMatches = limit == null ? remaining : remaining.slice(0, limit);
|
|
213
|
+
const truncated = selectedMatches.length < remaining.length;
|
|
214
|
+
const around = Number.isInteger(options.around) && options.around > 0 ? options.around : 0;
|
|
215
|
+
const exposeSelection = !!options.search || around > 0 || limit != null || after != null;
|
|
216
|
+
|
|
217
|
+
let paragraphs = selectedMatches;
|
|
218
|
+
if (around > 0 && options.search) {
|
|
219
|
+
const directIndexes = new Set(selectedMatches.map(item => item.index));
|
|
220
|
+
const contextFor = new Map();
|
|
221
|
+
for (const match of selectedMatches) {
|
|
222
|
+
const start = Math.max(rangeStart, match.index - around);
|
|
223
|
+
const end = Math.min(rangeEnd, match.index + around);
|
|
224
|
+
for (let index = start; index <= end; index++) {
|
|
225
|
+
if (directIndexes.has(index)) continue;
|
|
226
|
+
const owners = contextFor.get(index) || [];
|
|
227
|
+
owners.push(match.index);
|
|
228
|
+
contextFor.set(index, owners);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const returnedIndexes = new Set([...directIndexes, ...contextFor.keys()]);
|
|
232
|
+
paragraphs = allParagraphs
|
|
233
|
+
.filter(item => returnedIndexes.has(item.index))
|
|
234
|
+
.map(item => directIndexes.has(item.index)
|
|
235
|
+
? { ...item, selectionRole: 'match' }
|
|
236
|
+
: { ...item, selectionRole: 'context', contextFor: contextFor.get(item.index) || [] });
|
|
237
|
+
} else if (exposeSelection) {
|
|
238
|
+
paragraphs = selectedMatches.map(item => ({ ...item, selectionRole: 'match' }));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
paragraphs,
|
|
243
|
+
...(exposeSelection ? {
|
|
244
|
+
selection: {
|
|
245
|
+
...(options.search ? { search: String(options.search), caseSensitive: false } : {}),
|
|
246
|
+
totalMatches,
|
|
247
|
+
returnedMatches: selectedMatches.length,
|
|
248
|
+
returnedParagraphs: paragraphs.length,
|
|
249
|
+
truncated,
|
|
250
|
+
nextAfter: truncated && selectedMatches.length > 0
|
|
251
|
+
? selectedMatches[selectedMatches.length - 1].index
|
|
252
|
+
: null,
|
|
253
|
+
...(limit != null ? { limit } : {}),
|
|
254
|
+
...(after != null ? { after } : {}),
|
|
255
|
+
...(around > 0 ? { around } : {})
|
|
256
|
+
}
|
|
257
|
+
} : {})
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Read-only, stable document-parts inspection for agents and package adapters. */
|
|
262
|
+
export function inspectDocumentParts(parts, options = {}) {
|
|
263
|
+
const documentPart = parseXml(parts?.documentXml, 'word/document.xml', true);
|
|
264
|
+
if (documentPart.error) return { status: 'error', error: documentPart.error, paragraphs: [], comments: [], warnings: [] };
|
|
265
|
+
const commentsPart = parseXml(parts?.commentsXml, 'word/comments.xml');
|
|
266
|
+
const commentsExtendedPart = parseXml(parts?.commentsExtendedXml, 'word/commentsExtended.xml');
|
|
267
|
+
const numberingPart = parseXml(parts?.numberingXml, 'word/numbering.xml');
|
|
268
|
+
const warnings = [...(documentPart.warnings || [])];
|
|
269
|
+
if (commentsPart.error) warnings.push(commentsPart.error.message);
|
|
270
|
+
if (commentsExtendedPart.error) warnings.push(commentsExtendedPart.error.message);
|
|
271
|
+
if (numberingPart.error) warnings.push(numberingPart.error.message);
|
|
272
|
+
const comments = readCommentDefinitions(commentsPart.doc);
|
|
273
|
+
attachCommentThreadMetadata(comments, commentsExtendedPart.doc);
|
|
274
|
+
const resolveNumbering = createNumberingResolver(numberingPart.doc);
|
|
275
|
+
let nearestHeading = null;
|
|
276
|
+
const paragraphNodes = getDocumentParagraphNodes(documentPart.doc);
|
|
277
|
+
const revisionView = options.revisionView === 'rejected' ? 'rejected' : 'accepted';
|
|
278
|
+
const commentAnchors = collectDocumentCommentAnchors(paragraphNodes, revisionView);
|
|
279
|
+
let paragraphs = paragraphNodes.map((paragraph, zeroIndex) => {
|
|
280
|
+
const text = extractCanonicalParagraphText(paragraph, { revisionView });
|
|
281
|
+
const level = headingLevel(paragraph);
|
|
282
|
+
if (level) nearestHeading = { level, text };
|
|
283
|
+
const ids = [...new Set([...descendants(paragraph, 'commentRangeStart'), ...descendants(paragraph, 'commentReference')].map(node => attr(node, 'id')).filter(Boolean))];
|
|
284
|
+
const authors = revisionAuthors(paragraph);
|
|
285
|
+
const list = resolveNumbering(paragraphListProperties(paragraph));
|
|
286
|
+
const styleId = attr(first(first(paragraph, 'pPr'), 'pStyle'), 'val') || null;
|
|
287
|
+
const structure = structuralContext(paragraph, text);
|
|
288
|
+
const index = zeroIndex + 1;
|
|
289
|
+
const provision = list?.label && list.format !== 'bullet' ? list.label : null;
|
|
290
|
+
const headingText = nearestHeading?.text || null;
|
|
291
|
+
const excerpt = text.slice(0, options.excerptLength || 120);
|
|
292
|
+
const humanReference = level
|
|
293
|
+
? text
|
|
294
|
+
: (provision
|
|
295
|
+
? [provision, headingText].filter(Boolean).join(' — ')
|
|
296
|
+
: (headingText ? [headingText, excerpt].filter(Boolean).join(' — ') : excerpt));
|
|
297
|
+
const segments = extractParagraphRevisionSegments(paragraph);
|
|
298
|
+
return {
|
|
299
|
+
index, ref: `P${index}`, paragraphId: getParagraphId(paragraph),
|
|
300
|
+
fingerprint: createParagraphFingerprint(paragraph, { text, index, revisionView }), revisionView,
|
|
301
|
+
text, exactText: text, excerpt, humanReference, provision, inTable: hasAncestor(paragraph, 'tc'), table: structure.table,
|
|
302
|
+
styleId, headingLevel: level, nearestHeading, list, structuralReferences: structure.references, hasRevisions: authors.length > 0, revisionAuthors: authors, commentIds: ids,
|
|
303
|
+
segments
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
for (const paragraph of paragraphs) for (const id of paragraph.commentIds) {
|
|
307
|
+
const definition = comments.get(id) || { id, author: null, date: null, text: '' };
|
|
308
|
+
definition.paragraphIndex ??= paragraph.index; definition.targetRef ??= paragraph.ref;
|
|
309
|
+
definition.anchoredText ??= commentAnchors.get(id) || paragraph.text;
|
|
310
|
+
comments.set(id, definition);
|
|
311
|
+
}
|
|
312
|
+
const selected = selectInspectionParagraphs(paragraphs, options);
|
|
313
|
+
if (options.search && selected.selection && selected.selection.totalMatches === 0) {
|
|
314
|
+
const needle = String(options.search).toLowerCase();
|
|
315
|
+
const alternateView = revisionView === 'accepted' ? 'rejected' : 'accepted';
|
|
316
|
+
let rangeStart = 1;
|
|
317
|
+
let rangeEnd = paragraphNodes.length;
|
|
318
|
+
if (options.range) {
|
|
319
|
+
rangeStart = Number(options.range.start ?? options.range[0]);
|
|
320
|
+
rangeEnd = Number(options.range.end ?? options.range[1]);
|
|
321
|
+
}
|
|
322
|
+
let alternateMatches = 0;
|
|
323
|
+
for (let i = 0; i < paragraphNodes.length; i++) {
|
|
324
|
+
const index = i + 1;
|
|
325
|
+
if (options.range && (index < rangeStart || index > rangeEnd)) continue;
|
|
326
|
+
if (Array.isArray(options.indexes) && !options.indexes.includes(index)) continue;
|
|
327
|
+
const pNode = paragraphNodes[i];
|
|
328
|
+
if (options.inTable != null && hasAncestor(pNode, 'tc') !== !!options.inTable) continue;
|
|
329
|
+
if (options.revisedOnly && revisionAuthors(pNode).length === 0) continue;
|
|
330
|
+
const altText = extractCanonicalParagraphText(pNode, { revisionView: alternateView });
|
|
331
|
+
if (options.skipEmpty && altText.length === 0) continue;
|
|
332
|
+
if (altText.toLowerCase().includes(needle)) {
|
|
333
|
+
alternateMatches++;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (alternateMatches > 0) {
|
|
337
|
+
selected.selection.hint = revisionView === 'accepted'
|
|
338
|
+
? `0 matches in accepted view, but ${alternateMatches} match${alternateMatches === 1 ? '' : 'es'} found in --view rejected. If searching for deleted text to restore, re-run with --view rejected.`
|
|
339
|
+
: `0 matches in rejected view, but ${alternateMatches} match${alternateMatches === 1 ? '' : 'es'} found in --view accepted. Re-run with --view accepted.`;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
paragraphs = selected.paragraphs;
|
|
343
|
+
const allRevisionAuthors = [...new Set(paragraphs.flatMap(item => item.revisionAuthors))].sort();
|
|
344
|
+
const coveredEntries = extractDocumentPartsEntries(parts);
|
|
345
|
+
const coveredParts = coveredEntries.map(e => e.name).sort();
|
|
346
|
+
let revisionToken = null;
|
|
347
|
+
if (typeof options.digestFn === 'function') {
|
|
348
|
+
revisionToken = computeRevisionTokenSync({
|
|
349
|
+
scope: 'document-parts',
|
|
350
|
+
entries: coveredEntries,
|
|
351
|
+
digestFn: options.digestFn
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
status: 'ok',
|
|
356
|
+
revisionToken,
|
|
357
|
+
coveredParts,
|
|
358
|
+
paragraphs,
|
|
359
|
+
...(selected.selection ? { selection: selected.selection } : {}),
|
|
360
|
+
comments: [...comments.values()],
|
|
361
|
+
revisionAuthors: allRevisionAuthors,
|
|
362
|
+
commentAuthors: [...new Set([...comments.values()].map(item => item.author).filter(Boolean))].sort(),
|
|
363
|
+
counts: { paragraphs: paragraphs.length, comments: comments.size, revisedParagraphs: paragraphs.filter(item => item.hasRevisions).length },
|
|
364
|
+
warnings
|
|
365
|
+
};
|
|
366
|
+
}
|