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