@ansonlai/docx-redline-js 0.4.0 → 0.5.0
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 +589 -287
- 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/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 +2849 -466
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +87 -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/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 +491 -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 +222 -37
- package/engine/surgical-mode.js +134 -6
- 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 +317 -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 +24 -3
- 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-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 +494 -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 +323 -0
- package/services/document-operation-mutations.js +1733 -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 +366 -0
- package/services/receipt-collector.js +288 -0
- package/services/revision-comment-management.js +37 -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
package/node/cli.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { access, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { openDocx } from './docx-document.js';
|
|
4
|
+
import { MemoryZip, unzipDocx } from './zip-archive.js';
|
|
5
|
+
import { validateDocxPackage } from '../services/standalone-docx-plumbing.js';
|
|
6
|
+
import { validateRedlineOoxml } from '../core/redline-validation.js';
|
|
7
|
+
import { configureLogger } from '../adapters/logger.js';
|
|
8
|
+
|
|
9
|
+
const suffixes = { apply: 'redlined', accept: 'accepted', reject: 'rejected', 'delete-comments': 'comments-removed' };
|
|
10
|
+
const CLI_CONTRACT_VERSION = 2;
|
|
11
|
+
const CLI_CAPABILITIES = [
|
|
12
|
+
'atomic-batch-results-on-package-failure',
|
|
13
|
+
'baseline-aware-validation',
|
|
14
|
+
'document-scoped-list-revision-ids'
|
|
15
|
+
];
|
|
16
|
+
const commandOptions = {
|
|
17
|
+
version: new Set(['help']),
|
|
18
|
+
inspect: new Set(['help', 'search', 'revised', 'table', 'body', 'nonEmpty', 'index', 'indexes', 'range', 'view']),
|
|
19
|
+
extract: new Set(['help', 'search', 'revised', 'table', 'body', 'nonEmpty', 'index', 'indexes', 'range', 'view']),
|
|
20
|
+
preflight: new Set(['help', 'operations', 'author', 'strictTargets', 'target', 'modified', 'comment', 'textToComment', 'targetRef', 'existingRevisions']),
|
|
21
|
+
apply: new Set(['help', 'operations', 'author', 'output', 'inPlace', 'force', 'noOverwrite', 'noClobber', 'expectedRevision', 'target', 'modified', 'comment', 'textToComment', 'targetRef', 'existingRevisions', 'atomic', 'generateRedlines', 'noRedlines']),
|
|
22
|
+
accept: new Set(['help', 'author', 'allAuthors', 'output', 'inPlace', 'force', 'noOverwrite', 'noClobber']),
|
|
23
|
+
reject: new Set(['help', 'author', 'allAuthors', 'output', 'inPlace', 'force', 'noOverwrite', 'noClobber']),
|
|
24
|
+
'delete-comments': new Set(['help', 'author', 'allAuthors', 'output', 'inPlace', 'force', 'noOverwrite', 'noClobber']),
|
|
25
|
+
validate: new Set(['help', 'baseline'])
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function cliError(code, message, exitCode = 2, details) { return { status: 'error', error: { code, message, ...(details ? { details } : {}) }, exitCode }; }
|
|
29
|
+
const optionAliases = new Map([
|
|
30
|
+
['operationsFile', 'operations'],
|
|
31
|
+
['o', 'output'],
|
|
32
|
+
['a', 'author'],
|
|
33
|
+
['i', 'inPlace'],
|
|
34
|
+
['f', 'force'],
|
|
35
|
+
['h', 'help'],
|
|
36
|
+
['no-overwrite', 'noOverwrite'],
|
|
37
|
+
['no-clobber', 'noClobber'],
|
|
38
|
+
['no-redlines', 'noRedlines'],
|
|
39
|
+
['generate-redlines', 'generateRedlines']
|
|
40
|
+
]);
|
|
41
|
+
function parseArgs(argv) {
|
|
42
|
+
const positionals = []; const flags = {};
|
|
43
|
+
for (let index = 0; index < argv.length; index++) {
|
|
44
|
+
const token = argv[index];
|
|
45
|
+
if (!token.startsWith('-') || token === '-') { positionals.push(token); continue; }
|
|
46
|
+
const prefixLength = token.startsWith('--') ? 2 : 1;
|
|
47
|
+
const [rawKey, inline] = token.slice(prefixLength).split(/=(.*)/s);
|
|
48
|
+
const normalizedKey = rawKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
49
|
+
const key = optionAliases.get(normalizedKey) || normalizedKey;
|
|
50
|
+
if (inline !== undefined) flags[key] = inline;
|
|
51
|
+
else if (argv[index + 1] && (!argv[index + 1].startsWith('-') || /^-\d/.test(argv[index + 1]))) flags[key] = argv[++index];
|
|
52
|
+
else flags[key] = true;
|
|
53
|
+
}
|
|
54
|
+
return { command: positionals[0], input: positionals[1], extraPositionals: positionals.slice(2), flags };
|
|
55
|
+
}
|
|
56
|
+
function positiveInteger(value) {
|
|
57
|
+
const text = String(value).trim();
|
|
58
|
+
const parsed = /^\d+$/.test(text) ? Number(text) : null;
|
|
59
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
|
|
60
|
+
}
|
|
61
|
+
function invalidFilter(message) {
|
|
62
|
+
const error = new Error(message);
|
|
63
|
+
error.code = 'INVALID_FILTER';
|
|
64
|
+
return error;
|
|
65
|
+
}
|
|
66
|
+
function parseIndexes(value) {
|
|
67
|
+
const tokens = String(value).split(',');
|
|
68
|
+
if (!tokens.length || tokens.some(token => positiveInteger(token) == null)) {
|
|
69
|
+
throw invalidFilter('--indexes must be a comma-separated list of positive 1-based integers.');
|
|
70
|
+
}
|
|
71
|
+
return tokens.map(positiveInteger);
|
|
72
|
+
}
|
|
73
|
+
function parseRange(value) {
|
|
74
|
+
const match = String(value).match(/^\s*(\d+)\s*([:,\-])\s*(\d+)\s*$/);
|
|
75
|
+
if (!match) throw invalidFilter('--range must use START:END with positive 1-based integers.');
|
|
76
|
+
const start = positiveInteger(match[1]);
|
|
77
|
+
const end = positiveInteger(match[3]);
|
|
78
|
+
if (start == null || end == null || end < start) {
|
|
79
|
+
throw invalidFilter('--range must have positive 1-based endpoints with END greater than or equal to START.');
|
|
80
|
+
}
|
|
81
|
+
return { start, end };
|
|
82
|
+
}
|
|
83
|
+
function validateCommandOptions(command, flags, extraPositionals) {
|
|
84
|
+
if (extraPositionals.length > 0) return cliError('UNEXPECTED_ARGUMENT', `Unexpected argument: ${extraPositionals[0]}`);
|
|
85
|
+
const allowed = commandOptions[command];
|
|
86
|
+
const unknown = Object.keys(flags).find(option => !allowed.has(option));
|
|
87
|
+
return unknown ? cliError('UNKNOWN_OPTION', `Unknown option for ${command}: --${unknown.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)}`) : null;
|
|
88
|
+
}
|
|
89
|
+
function inspectionOptions(flags) {
|
|
90
|
+
const options = {};
|
|
91
|
+
if (flags.search) options.search = flags.search;
|
|
92
|
+
if (flags.revised) options.revisedOnly = true;
|
|
93
|
+
if (flags.table) options.inTable = true;
|
|
94
|
+
if (flags.body) options.inTable = false;
|
|
95
|
+
if (flags.nonEmpty) options.skipEmpty = true;
|
|
96
|
+
const selectors = ['index', 'indexes', 'range'].filter(name => flags[name] !== undefined);
|
|
97
|
+
if (selectors.length > 1) throw invalidFilter('Use only one of --index, --indexes, or --range.');
|
|
98
|
+
if (flags.index !== undefined) {
|
|
99
|
+
const index = positiveInteger(flags.index);
|
|
100
|
+
if (index == null) throw invalidFilter('--index must be a positive 1-based integer.');
|
|
101
|
+
options.indexes = [index];
|
|
102
|
+
}
|
|
103
|
+
if (flags.indexes !== undefined) options.indexes = parseIndexes(flags.indexes);
|
|
104
|
+
if (flags.range !== undefined) options.range = parseRange(flags.range);
|
|
105
|
+
if (flags.view) {
|
|
106
|
+
if (!['accepted', 'rejected', 'current'].includes(String(flags.view))) {
|
|
107
|
+
throw invalidFilter('--view must be accepted, rejected, or current.');
|
|
108
|
+
}
|
|
109
|
+
options.revisionView = flags.view;
|
|
110
|
+
}
|
|
111
|
+
return options;
|
|
112
|
+
}
|
|
113
|
+
async function readOperations(file, flags = {}) {
|
|
114
|
+
if (!file && flags?.target) {
|
|
115
|
+
let op;
|
|
116
|
+
if (flags.comment) {
|
|
117
|
+
op = {
|
|
118
|
+
type: 'comment',
|
|
119
|
+
target: String(flags.target),
|
|
120
|
+
commentContent: String(flags.comment),
|
|
121
|
+
...(flags.textToComment ? { textToComment: String(flags.textToComment) } : {}),
|
|
122
|
+
...(flags.targetRef ? { targetRef: positiveInteger(flags.targetRef) } : {}),
|
|
123
|
+
...(flags.author ? { author: String(flags.author) } : {})
|
|
124
|
+
};
|
|
125
|
+
} else {
|
|
126
|
+
op = {
|
|
127
|
+
type: 'replace',
|
|
128
|
+
target: String(flags.target),
|
|
129
|
+
modified: flags.modified !== undefined ? String(flags.modified) : '',
|
|
130
|
+
...(flags.targetRef ? { targetRef: positiveInteger(flags.targetRef) } : {}),
|
|
131
|
+
...(flags.author ? { author: String(flags.author) } : {}),
|
|
132
|
+
...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return { operations: [op], expectedRevision: null };
|
|
136
|
+
}
|
|
137
|
+
if (!file) throw Object.assign(new Error('Use --operations <file.json> or --target <text>.'), { code: 'OPERATIONS_REQUIRED' });
|
|
138
|
+
let parsed; try { parsed = JSON.parse(await readFile(file, 'utf8')); } catch (error) { throw Object.assign(new Error(`Could not read operations JSON: ${error.message}`), { code: 'INVALID_OPERATIONS_FILE' }); }
|
|
139
|
+
const operations = Array.isArray(parsed) ? parsed : (parsed?.operations || parsed?.changes);
|
|
140
|
+
if (!Array.isArray(operations)) throw Object.assign(new Error('Operations JSON must be an array or an object with an operations or changes array.'), { code: 'INVALID_OPERATIONS_FILE' });
|
|
141
|
+
return { operations, expectedRevision: parsed?.expectedRevision || null };
|
|
142
|
+
}
|
|
143
|
+
function outputPath(command, input, flags) {
|
|
144
|
+
if (flags.inPlace) return input;
|
|
145
|
+
if (flags.output) return path.resolve(String(flags.output));
|
|
146
|
+
const parsed = path.parse(input); return path.join(parsed.dir, `${parsed.name}.${suffixes[command]}${parsed.ext || '.docx'}`);
|
|
147
|
+
}
|
|
148
|
+
async function writeMutation(command, input, flags, result) {
|
|
149
|
+
if (!result.written) return { status: result.status || 'ok', ...result, outputPath: null };
|
|
150
|
+
const destination = outputPath(command, input, flags);
|
|
151
|
+
const protectExisting = Boolean(flags.noOverwrite || flags.noClobber) && !flags.force;
|
|
152
|
+
if (!flags.inPlace && protectExisting) {
|
|
153
|
+
try {
|
|
154
|
+
await access(destination);
|
|
155
|
+
throw Object.assign(new Error(`Output already exists: ${destination}`), { code: 'OUTPUT_EXISTS' });
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error.code !== 'ENOENT') throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
await writeFile(destination, result.toBuffer());
|
|
161
|
+
return { status: result.status || 'ok', ...result, outputPath: destination };
|
|
162
|
+
}
|
|
163
|
+
function serializable(value) {
|
|
164
|
+
const { buffer: _buffer, toBuffer: _toBuffer, ...rest } = value || {};
|
|
165
|
+
return rest;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function collectValidationIssues(buffer) {
|
|
169
|
+
const entries = unzipDocx(buffer);
|
|
170
|
+
const documentXml = entries.get('word/document.xml')?.toString('utf8') || '';
|
|
171
|
+
const revision = validateRedlineOoxml(documentXml);
|
|
172
|
+
const issues = revision.issues.map(issue => ({ source: 'word/document.xml', ...issue }));
|
|
173
|
+
try {
|
|
174
|
+
await validateDocxPackage(new MemoryZip(entries));
|
|
175
|
+
} catch (error) {
|
|
176
|
+
issues.push({ source: 'package', code: 'PACKAGE_VALIDATION', severity: 'error', message: error.message });
|
|
177
|
+
}
|
|
178
|
+
return issues;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function validationIssueKey(issue) {
|
|
182
|
+
return `${issue.source || ''}:${issue.code}:${issue.message}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function subtractValidationIssues(issues, baselineIssues) {
|
|
186
|
+
const remainingBaseline = new Map();
|
|
187
|
+
for (const issue of baselineIssues) {
|
|
188
|
+
const key = validationIssueKey(issue);
|
|
189
|
+
remainingBaseline.set(key, (remainingBaseline.get(key) || 0) + 1);
|
|
190
|
+
}
|
|
191
|
+
return issues.filter(issue => {
|
|
192
|
+
const key = validationIssueKey(issue);
|
|
193
|
+
const remaining = remainingBaseline.get(key) || 0;
|
|
194
|
+
if (remaining === 0) return true;
|
|
195
|
+
remainingBaseline.set(key, remaining - 1);
|
|
196
|
+
return false;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function executeCli(argv) {
|
|
201
|
+
const { command, input: rawInput, extraPositionals, flags } = parseArgs(argv);
|
|
202
|
+
if (command === 'help' || flags.help) return { status: 'ok', command: 'help', usage: 'docx-redline <version|inspect|extract|preflight|apply|accept|reject|delete-comments|validate> [file.docx] [options]' };
|
|
203
|
+
if (!command) return cliError('COMMAND_REQUIRED', 'A command is required.');
|
|
204
|
+
if (!['version','inspect','extract','preflight','apply','accept','reject','delete-comments','validate'].includes(command)) return cliError('UNKNOWN_COMMAND', `Unknown command: ${command}`);
|
|
205
|
+
if (command === 'version') {
|
|
206
|
+
if (rawInput || extraPositionals.length > 0) return cliError('UNEXPECTED_ARGUMENT', `Unexpected argument: ${rawInput || extraPositionals[0]}`);
|
|
207
|
+
const optionError = validateCommandOptions(command, flags, []);
|
|
208
|
+
if (optionError) return optionError;
|
|
209
|
+
return {
|
|
210
|
+
status: 'ok',
|
|
211
|
+
command,
|
|
212
|
+
contractVersion: CLI_CONTRACT_VERSION,
|
|
213
|
+
capabilities: CLI_CAPABILITIES
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (!rawInput) return cliError('INPUT_REQUIRED', 'An input .docx path is required.');
|
|
217
|
+
const optionError = validateCommandOptions(command, flags, extraPositionals);
|
|
218
|
+
if (optionError) return optionError;
|
|
219
|
+
let inspectOptions = null;
|
|
220
|
+
if (command === 'inspect' || command === 'extract') {
|
|
221
|
+
try { inspectOptions = inspectionOptions(flags); }
|
|
222
|
+
catch (error) { return cliError(error.code || 'INVALID_FILTER', error.message); }
|
|
223
|
+
}
|
|
224
|
+
const input = path.resolve(rawInput);
|
|
225
|
+
let buffer; try { buffer = await readFile(input); } catch (error) { return cliError('INPUT_READ_FAILED', error.message); }
|
|
226
|
+
try {
|
|
227
|
+
const document = openDocx(buffer);
|
|
228
|
+
if (command === 'inspect') return { ...document.inspect(inspectOptions), command, input, indexBase: 1 };
|
|
229
|
+
if (command === 'extract') {
|
|
230
|
+
const inspected = document.inspect(inspectOptions);
|
|
231
|
+
return { status: inspected.status, command, input, indexBase: 1, paragraphs: inspected.paragraphs.map(({ index, ref, paragraphId, fingerprint, exactText, inTable, list, nearestHeading }) => ({ index, ref, paragraphId, fingerprint, exactText, inTable, list, nearestHeading })), warnings: inspected.warnings };
|
|
232
|
+
}
|
|
233
|
+
if (command === 'validate') {
|
|
234
|
+
const issues = await collectValidationIssues(buffer);
|
|
235
|
+
if (flags.baseline) {
|
|
236
|
+
const baseline = path.resolve(String(flags.baseline));
|
|
237
|
+
let baselineBuffer;
|
|
238
|
+
try { baselineBuffer = await readFile(baseline); }
|
|
239
|
+
catch (error) { return cliError('BASELINE_READ_FAILED', error.message); }
|
|
240
|
+
const baselineIssues = await collectValidationIssues(baselineBuffer);
|
|
241
|
+
const introducedIssues = subtractValidationIssues(issues, baselineIssues);
|
|
242
|
+
const hasIntroducedErrors = introducedIssues.some(issue => issue.severity === 'error');
|
|
243
|
+
return {
|
|
244
|
+
status: hasIntroducedErrors ? 'error' : 'ok',
|
|
245
|
+
command,
|
|
246
|
+
input,
|
|
247
|
+
baseline,
|
|
248
|
+
valid: !hasIntroducedErrors,
|
|
249
|
+
issues,
|
|
250
|
+
baselineIssues,
|
|
251
|
+
introducedIssues
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const hasErrors = issues.some(issue => issue.severity === 'error');
|
|
255
|
+
return { status: hasErrors ? 'error' : 'ok', command, input, valid: !hasErrors, issues };
|
|
256
|
+
}
|
|
257
|
+
const opsData = command === 'preflight' || command === 'apply' ? await readOperations(flags.operations, flags) : null;
|
|
258
|
+
const operations = opsData?.operations || null;
|
|
259
|
+
let expectedRevision = opsData?.expectedRevision || null;
|
|
260
|
+
if (flags.expectedRevision) {
|
|
261
|
+
if (typeof flags.expectedRevision === 'string') {
|
|
262
|
+
try {
|
|
263
|
+
expectedRevision = JSON.parse(flags.expectedRevision);
|
|
264
|
+
} catch {
|
|
265
|
+
expectedRevision = {
|
|
266
|
+
algorithm: 'sha256',
|
|
267
|
+
version: 1,
|
|
268
|
+
scope: 'package',
|
|
269
|
+
value: flags.expectedRevision.trim()
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
} else if (typeof flags.expectedRevision === 'object') {
|
|
273
|
+
expectedRevision = flags.expectedRevision;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (command === 'preflight') return {
|
|
277
|
+
...document.preflight(operations, flags.author, {
|
|
278
|
+
strictTargets: flags.strictTargets !== 'false',
|
|
279
|
+
...(flags.existingRevisions ? { existingRevisions: flags.existingRevisions } : {})
|
|
280
|
+
}),
|
|
281
|
+
command,
|
|
282
|
+
input
|
|
283
|
+
};
|
|
284
|
+
if (command === 'apply') {
|
|
285
|
+
const author = flags.author || process.env.DOCX_REDLINE_AUTHOR || 'AI Redliner';
|
|
286
|
+
const generateRedlines = flags.generateRedlines !== undefined
|
|
287
|
+
? (flags.generateRedlines !== 'false' && flags.generateRedlines !== false)
|
|
288
|
+
: (!flags.noRedlines);
|
|
289
|
+
const result = await document.applyOperations(operations, {
|
|
290
|
+
author,
|
|
291
|
+
atomic: flags.atomic === true || flags.atomic === 'true',
|
|
292
|
+
validate: true,
|
|
293
|
+
strictTargets: true,
|
|
294
|
+
generateRedlines,
|
|
295
|
+
...(flags.existingRevisions ? { existingRevisions: flags.existingRevisions } : {}),
|
|
296
|
+
...(expectedRevision ? { expectedRevision } : {})
|
|
297
|
+
});
|
|
298
|
+
const mutationResult = await writeMutation(command, input, flags, result);
|
|
299
|
+
return {
|
|
300
|
+
command,
|
|
301
|
+
input,
|
|
302
|
+
...serializable(mutationResult),
|
|
303
|
+
...(result.status === 'error' || result.error ? { exitCode: 2 } : {})
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const filter = flags.allAuthors ? { allAuthors: true } : flags.author ? { author: String(flags.author) } : null;
|
|
307
|
+
if (!filter) return cliError('AUTHOR_REQUIRED', 'Use --author <name> or --all-authors.');
|
|
308
|
+
const result = command === 'delete-comments' ? await document.deleteComments(filter) : await document.resolveRevisions(command, filter);
|
|
309
|
+
return { command, input, ...serializable(await writeMutation(command, input, flags, result)) };
|
|
310
|
+
} catch (error) { return cliError(error.code || 'CLI_FAILED', error.message); }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function runCli(argv = process.argv.slice(2), io = process) {
|
|
314
|
+
configureLogger({}, { level: 'silent' });
|
|
315
|
+
const result = await executeCli(argv); io.stdout.write(`${JSON.stringify(serializable(result), null, 2)}\n`);
|
|
316
|
+
return result.status === 'error' ? (result.exitCode || 1) : 0;
|
|
317
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
|
|
2
|
+
import { configureXmlProvider } from '../adapters/xml-adapter.js';
|
|
3
|
+
import { getDefaultAuthor } from '../adapters/config.js';
|
|
4
|
+
import { inspectDocumentParts } from '../services/document-inspection.js';
|
|
5
|
+
import { applyOperationsToDocumentXml, preflightOperations } from '../services/standalone-operation-runner.js';
|
|
6
|
+
import { createDynamicNumberingIdState, mergeNumberingXmlBySchemaOrder } from '../services/numbering-helpers.js';
|
|
7
|
+
import { ensureCommentsArtifactsInZip, ensureCommentsExtendedArtifactsInZip, ensureNumberingArtifactsInZip, validateDocxPackage } from '../services/standalone-docx-plumbing.js';
|
|
8
|
+
import { validateRedlineOoxml } from '../core/redline-validation.js';
|
|
9
|
+
import { acceptTrackedChangesInOoxml, rejectTrackedChangesInOoxml, deleteCommentsByAuthorInOoxml } from '../services/revision-comment-management.js';
|
|
10
|
+
import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { MemoryZip, unzipDocx, zipDocx } from './zip-archive.js';
|
|
13
|
+
import { computeRevisionTokenSync, validateRevisionToken, areRevisionTokensEqual } from '../services/revision-token.js';
|
|
14
|
+
|
|
15
|
+
configureXmlProvider({ DOMParser, XMLSerializer });
|
|
16
|
+
const text = (entries, path) => entries.get(path)?.toString('utf8') || null;
|
|
17
|
+
const cloneEntries = entries => new Map([...entries].map(([name, data]) => [name, Buffer.from(data)]));
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Computes a package-scoped revision token over all uncompressed entries in a DOCX archive.
|
|
21
|
+
*
|
|
22
|
+
* @param {Buffer|Uint8Array|Map<string, Buffer>|DocxDocument|object} input
|
|
23
|
+
* @returns {{ algorithm: 'sha256', version: number, scope: 'package', value: string, coveredParts: string[] }}
|
|
24
|
+
*/
|
|
25
|
+
export function computePackageRevisionToken(input) {
|
|
26
|
+
let entries;
|
|
27
|
+
if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
|
|
28
|
+
entries = unzipDocx(input);
|
|
29
|
+
} else if (input instanceof Map) {
|
|
30
|
+
entries = input;
|
|
31
|
+
} else if (input?.entries instanceof Map) {
|
|
32
|
+
entries = input.entries;
|
|
33
|
+
} else if (typeof input?.toBuffer === 'function') {
|
|
34
|
+
entries = unzipDocx(input.toBuffer());
|
|
35
|
+
} else {
|
|
36
|
+
throw new TypeError('computePackageRevisionToken requires a Buffer, Uint8Array, Map of entries, or DocxDocument.');
|
|
37
|
+
}
|
|
38
|
+
return computeRevisionTokenSync({
|
|
39
|
+
scope: 'package',
|
|
40
|
+
entries,
|
|
41
|
+
digestFn: bytes => createHash('sha256').update(bytes).digest('hex')
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function nextCommentId(entries) {
|
|
46
|
+
const ids = `${text(entries, 'word/document.xml') || ''} ${text(entries, 'word/comments.xml') || ''}`.match(/(?:w:)?id=["'](\d+)["']/g) || [];
|
|
47
|
+
let next = ids.reduce((max, token) => Math.max(max, Number(token.match(/\d+/)?.[0] || 0)), 0) + 1;
|
|
48
|
+
return () => next++;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function existingCommentDetails(entries) {
|
|
52
|
+
const commentsXml = text(entries, 'word/comments.xml');
|
|
53
|
+
if (!commentsXml) return {};
|
|
54
|
+
const parsed = parseOoxmlSafe(commentsXml, 'application/xml');
|
|
55
|
+
if (!parsed.doc || parsed.error) return {};
|
|
56
|
+
const details = {};
|
|
57
|
+
for (const comment of Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment'))) {
|
|
58
|
+
const id = comment.getAttribute('w:id') || comment.getAttribute('id');
|
|
59
|
+
if (id === '') continue;
|
|
60
|
+
details[id] = {
|
|
61
|
+
id,
|
|
62
|
+
author: comment.getAttribute('w:author') || comment.getAttribute('author') || '',
|
|
63
|
+
text: String(comment.textContent || '').trim(),
|
|
64
|
+
paraId: Array.from(comment.getElementsByTagNameNS('*', 'p'))[0]?.getAttribute('w14:paraId') || null
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return details;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class DocxDocument {
|
|
71
|
+
constructor(buffer) { this.originalBuffer = Buffer.from(buffer); this.entries = unzipDocx(this.originalBuffer); }
|
|
72
|
+
inspect(options = {}) {
|
|
73
|
+
const digestFn = options.digestFn || (bytes => createHash('sha256').update(bytes).digest('hex'));
|
|
74
|
+
return inspectDocumentParts({
|
|
75
|
+
documentXml: text(this.entries, 'word/document.xml'),
|
|
76
|
+
commentsXml: text(this.entries, 'word/comments.xml'),
|
|
77
|
+
commentsExtendedXml: text(this.entries, 'word/commentsExtended.xml'),
|
|
78
|
+
numberingXml: text(this.entries, 'word/numbering.xml'),
|
|
79
|
+
stylesXml: text(this.entries, 'word/styles.xml')
|
|
80
|
+
}, { ...options, digestFn });
|
|
81
|
+
}
|
|
82
|
+
getRevisionToken() { return computePackageRevisionToken(this.entries); }
|
|
83
|
+
get revisionToken() { return this.getRevisionToken(); }
|
|
84
|
+
preflight(operations, author = getDefaultAuthor(), options = {}) { return preflightOperations(text(this.entries, 'word/document.xml'), operations, author || getDefaultAuthor(), { ...options, _existingCommentDetails: existingCommentDetails(this.entries) }); }
|
|
85
|
+
toBuffer() { return zipDocx(this.entries); }
|
|
86
|
+
async applyOperations(operations, options = {}) {
|
|
87
|
+
if (options?.expectedRevision) {
|
|
88
|
+
const tokenValidation = validateRevisionToken(options.expectedRevision);
|
|
89
|
+
if (!tokenValidation.valid) {
|
|
90
|
+
return {
|
|
91
|
+
status: 'error',
|
|
92
|
+
hasChanges: false,
|
|
93
|
+
written: false,
|
|
94
|
+
rolledBack: true,
|
|
95
|
+
results: [],
|
|
96
|
+
artifactsChanged: [],
|
|
97
|
+
error: {
|
|
98
|
+
code: tokenValidation.error?.code || 'INVALID_REVISION_TOKEN',
|
|
99
|
+
message: tokenValidation.error?.message || 'Invalid revision token.'
|
|
100
|
+
},
|
|
101
|
+
validation: { originalIssues: [], generatedIssues: [] },
|
|
102
|
+
buffer: Buffer.from(this.originalBuffer),
|
|
103
|
+
toBuffer: () => Buffer.from(this.originalBuffer)
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (options.expectedRevision.scope !== 'package') {
|
|
107
|
+
return {
|
|
108
|
+
status: 'error',
|
|
109
|
+
hasChanges: false,
|
|
110
|
+
written: false,
|
|
111
|
+
rolledBack: true,
|
|
112
|
+
results: [],
|
|
113
|
+
artifactsChanged: [],
|
|
114
|
+
error: {
|
|
115
|
+
code: 'REVISION_TOKEN_SCOPE_MISMATCH',
|
|
116
|
+
message: `Revision token scope mismatch: expected 'package', got '${options.expectedRevision.scope}'.`
|
|
117
|
+
},
|
|
118
|
+
validation: { originalIssues: [], generatedIssues: [] },
|
|
119
|
+
buffer: Buffer.from(this.originalBuffer),
|
|
120
|
+
toBuffer: () => Buffer.from(this.originalBuffer)
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const currentToken = computePackageRevisionToken(this.entries);
|
|
124
|
+
if (!areRevisionTokensEqual(currentToken.value, options.expectedRevision.value)) {
|
|
125
|
+
return {
|
|
126
|
+
status: 'error',
|
|
127
|
+
hasChanges: false,
|
|
128
|
+
written: false,
|
|
129
|
+
rolledBack: true,
|
|
130
|
+
results: [],
|
|
131
|
+
artifactsChanged: [],
|
|
132
|
+
error: {
|
|
133
|
+
code: 'REVISION_MISMATCH',
|
|
134
|
+
message: `Document revision mismatch: expected '${options.expectedRevision.value}', current is '${currentToken.value}'.`
|
|
135
|
+
},
|
|
136
|
+
validation: { originalIssues: [], generatedIssues: [] },
|
|
137
|
+
buffer: Buffer.from(this.originalBuffer),
|
|
138
|
+
toBuffer: () => Buffer.from(this.originalBuffer)
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const originalEntries = this.entries; const working = cloneEntries(originalEntries); const zip = new MemoryZip(working);
|
|
144
|
+
const documentXml = text(working, 'word/document.xml');
|
|
145
|
+
let originalIssues = [];
|
|
146
|
+
let operationResult = null;
|
|
147
|
+
try {
|
|
148
|
+
if (!documentXml) throw new Error('Missing word/document.xml.');
|
|
149
|
+
const baseline = validateRedlineOoxml(documentXml);
|
|
150
|
+
originalIssues = baseline.issues.map(issue => ({ source: 'word/document.xml', ...issue }));
|
|
151
|
+
try { await validateDocxPackage(new MemoryZip(cloneEntries(originalEntries))); }
|
|
152
|
+
catch (error) { originalIssues.push({ source: 'package', code: 'PACKAGE_VALIDATION', severity: 'error', message: error.message }); }
|
|
153
|
+
const context = {
|
|
154
|
+
numberingIdState: createDynamicNumberingIdState(text(working, 'word/numbering.xml') || undefined),
|
|
155
|
+
commentsXml: text(working, 'word/comments.xml'),
|
|
156
|
+
commentsExtendedXml: text(working, 'word/commentsExtended.xml')
|
|
157
|
+
};
|
|
158
|
+
const { expectedRevision: _pkgExpectedRevision, ...runnerOptions } = options;
|
|
159
|
+
const author = options.author || getDefaultAuthor();
|
|
160
|
+
const result = operationResult = await applyOperationsToDocumentXml(documentXml, operations, author, context, {
|
|
161
|
+
...runnerOptions, atomic: options.atomic === true, strictTargets: options.strictTargets !== false,
|
|
162
|
+
_existingCommentDetails: existingCommentDetails(working),
|
|
163
|
+
commentIdAllocator: nextCommentId(working)
|
|
164
|
+
});
|
|
165
|
+
if (result.rolledBack || result.status === 'error') return { ...result, written: false, artifactsChanged: [], validation: { originalIssues, generatedIssues: [] }, buffer: this.originalBuffer, toBuffer: () => Buffer.from(this.originalBuffer) };
|
|
166
|
+
if (!result.hasChanges) return { ...result, status: result.status || 'ok', written: false, artifactsChanged: [], validation: { originalIssues, generatedIssues: [] }, buffer: Buffer.from(this.originalBuffer), toBuffer: () => Buffer.from(this.originalBuffer) };
|
|
167
|
+
working.set('word/document.xml', Buffer.from(result.documentXml));
|
|
168
|
+
await ensureNumberingArtifactsInZip(zip, result.numberingXmlParts, { mergeNumberingXml: mergeNumberingXmlBySchemaOrder });
|
|
169
|
+
const existingCommentsXml = text(working, 'word/comments.xml');
|
|
170
|
+
const commentsXmlForPackaging = result.commentsXml || existingCommentsXml;
|
|
171
|
+
await ensureCommentsArtifactsInZip(zip, commentsXmlForPackaging, {
|
|
172
|
+
replaceExisting: result.commentsXmlMode === 'replace' || (!result.commentsXml && !!existingCommentsXml)
|
|
173
|
+
});
|
|
174
|
+
const existingCommentsExtendedXml = text(working, 'word/commentsExtended.xml');
|
|
175
|
+
const commentsExtendedXmlForPackaging = result.commentsExtendedXml || existingCommentsExtendedXml;
|
|
176
|
+
await ensureCommentsExtendedArtifactsInZip(zip, commentsExtendedXmlForPackaging, {
|
|
177
|
+
replaceExisting: result.commentsExtendedXmlMode === 'replace' || (!result.commentsExtendedXml && !!existingCommentsExtendedXml)
|
|
178
|
+
});
|
|
179
|
+
if (options.validate !== false) {
|
|
180
|
+
const generated = validateRedlineOoxml(result.documentXml);
|
|
181
|
+
const baselineErrors = new Set(baseline.issues.filter(i => i.severity === 'error').map(i => `${i.code}:${i.message}`));
|
|
182
|
+
const introduced = generated.issues.filter(i => i.severity === 'error' && !baselineErrors.has(`${i.code}:${i.message}`));
|
|
183
|
+
if (introduced.length) {
|
|
184
|
+
const codes = [...new Set(introduced.map(issue => issue.code))].join(', ');
|
|
185
|
+
throw Object.assign(
|
|
186
|
+
new Error(`Applied operations introduced invalid revision markup (${codes}); these are generated-output issues, not pre-existing input issues.`),
|
|
187
|
+
{ issues: introduced }
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
await validateDocxPackage(zip);
|
|
191
|
+
}
|
|
192
|
+
this.entries = working;
|
|
193
|
+
const output = this.toBuffer();
|
|
194
|
+
this.originalBuffer = Buffer.from(output);
|
|
195
|
+
const artifactsChanged = [...working].filter(([name, data]) => !originalEntries.has(name) || !data.equals(originalEntries.get(name))).map(([name]) => name);
|
|
196
|
+
return { ...result, status: result.status || 'ok', written: true, artifactsChanged, validation: { originalIssues, generatedIssues: [] }, buffer: output, inspection: this.inspect(), toBuffer: () => Buffer.from(output) };
|
|
197
|
+
} catch (error) {
|
|
198
|
+
this.entries = originalEntries;
|
|
199
|
+
const generatedIssues = error.issues || [{ source: 'package', code: 'PACKAGE_OPERATION_FAILED', severity: 'error', message: error.message }];
|
|
200
|
+
return {
|
|
201
|
+
...(operationResult ? {
|
|
202
|
+
results: operationResult.results || [],
|
|
203
|
+
receipts: operationResult.receipts || [],
|
|
204
|
+
executionOrder: operationResult.executionOrder || [],
|
|
205
|
+
authorsUsed: operationResult.authorsUsed || []
|
|
206
|
+
} : { results: [] }),
|
|
207
|
+
status: 'error',
|
|
208
|
+
hasChanges: false,
|
|
209
|
+
written: false,
|
|
210
|
+
rolledBack: true,
|
|
211
|
+
artifactsChanged: [],
|
|
212
|
+
error: { code: 'PACKAGE_OPERATION_FAILED', message: error.message },
|
|
213
|
+
validation: { originalIssues, generatedIssues },
|
|
214
|
+
issues: generatedIssues,
|
|
215
|
+
buffer: Buffer.from(this.originalBuffer),
|
|
216
|
+
toBuffer: () => Buffer.from(this.originalBuffer)
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async resolveRevisions(action, options = {}) {
|
|
222
|
+
const transform = action === 'accept' ? acceptTrackedChangesInOoxml : action === 'reject' ? rejectTrackedChangesInOoxml : null;
|
|
223
|
+
if (!transform) return packageFailure(this.originalBuffer, 'INVALID_ACTION', `Unknown revision action: ${action}`);
|
|
224
|
+
const source = Buffer.from(this.originalBuffer); const working = cloneEntries(this.entries); const zip = new MemoryZip(working);
|
|
225
|
+
const result = transform(text(working, 'word/document.xml'), { author: options.author, allAuthors: options.allAuthors === true });
|
|
226
|
+
if (result.status === 'error' || result.error) return packageFailure(source, result.error?.code || 'REVISION_OPERATION_FAILED', result.error?.message || 'Revision operation failed.');
|
|
227
|
+
if (!result.hasChanges) return { ...result, status: 'ok', written: false, artifactsChanged: [], buffer: source, toBuffer: () => Buffer.from(source) };
|
|
228
|
+
working.set('word/document.xml', Buffer.from(result.oxml));
|
|
229
|
+
try { if (options.validate !== false) await validateDocxPackage(zip); }
|
|
230
|
+
catch (error) { return packageFailure(source, 'PACKAGE_VALIDATION', error.message); }
|
|
231
|
+
this.entries = working; const output = this.toBuffer(); this.originalBuffer = Buffer.from(output);
|
|
232
|
+
return { ...result, status: 'ok', written: true, artifactsChanged: ['word/document.xml'], buffer: output, toBuffer: () => Buffer.from(output) };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async deleteComments(options = {}) {
|
|
236
|
+
const source = Buffer.from(this.originalBuffer); const working = cloneEntries(this.entries); const commentsXml = text(working, 'word/comments.xml');
|
|
237
|
+
if (!commentsXml) return { status: 'ok', hasChanges: false, written: false, commentsRemoved: 0, referencesRemoved: 0, artifactsChanged: [], buffer: source, toBuffer: () => Buffer.from(source) };
|
|
238
|
+
const parsed = parseOoxmlSafe(commentsXml, 'application/xml');
|
|
239
|
+
if (!parsed.doc || parsed.error) return packageFailure(source, 'PARSE_ERROR', parsed.error?.message || 'Could not parse comments.xml.');
|
|
240
|
+
const matches = comment => options.allAuthors === true || (comment.getAttribute('w:author') || comment.getAttribute('author')) === options.author;
|
|
241
|
+
const ids = new Set(Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment')).filter(matches).map(node => node.getAttribute('w:id') || node.getAttribute('id')).filter(Boolean));
|
|
242
|
+
if (!ids.size) return { status: 'ok', hasChanges: false, written: false, commentsRemoved: 0, referencesRemoved: 0, artifactsChanged: [], buffer: source, toBuffer: () => Buffer.from(source) };
|
|
243
|
+
const commentsExtendedXml = text(working, 'word/commentsExtended.xml');
|
|
244
|
+
let extendedParsed = null;
|
|
245
|
+
const removedParaIds = new Set();
|
|
246
|
+
if (commentsExtendedXml) {
|
|
247
|
+
extendedParsed = parseOoxmlSafe(commentsExtendedXml, 'application/xml');
|
|
248
|
+
if (!extendedParsed.doc || extendedParsed.error) return packageFailure(source, 'PARSE_ERROR', extendedParsed.error?.message || 'Could not parse commentsExtended.xml.');
|
|
249
|
+
const idByParaId = new Map();
|
|
250
|
+
for (const comment of Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment'))) {
|
|
251
|
+
const id = comment.getAttribute('w:id') || comment.getAttribute('id');
|
|
252
|
+
const paragraph = Array.from(comment.getElementsByTagNameNS('*', 'p'))[0];
|
|
253
|
+
const paraId = paragraph?.getAttribute('w14:paraId') || paragraph?.getAttribute('paraId');
|
|
254
|
+
if (paraId) idByParaId.set(paraId.toUpperCase(), id);
|
|
255
|
+
}
|
|
256
|
+
for (const [paraId, id] of idByParaId) if (ids.has(id)) removedParaIds.add(paraId);
|
|
257
|
+
let expanded = true;
|
|
258
|
+
while (expanded) {
|
|
259
|
+
expanded = false;
|
|
260
|
+
for (const entry of Array.from(extendedParsed.doc.getElementsByTagNameNS('*', 'commentEx'))) {
|
|
261
|
+
const paraId = (entry.getAttribute('w15:paraId') || entry.getAttribute('paraId') || '').toUpperCase();
|
|
262
|
+
const parentParaId = (entry.getAttribute('w15:paraIdParent') || entry.getAttribute('paraIdParent') || '').toUpperCase();
|
|
263
|
+
if (parentParaId && removedParaIds.has(parentParaId) && !removedParaIds.has(paraId)) {
|
|
264
|
+
removedParaIds.add(paraId); if (idByParaId.has(paraId)) ids.add(idByParaId.get(paraId)); expanded = true;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const commentsResult = deleteCommentsByAuthorInOoxml(commentsXml, { author: options.author, allAuthors: options.allAuthors === true });
|
|
270
|
+
const remainingParsed = parseOoxmlSafe(commentsResult.oxml, 'application/xml');
|
|
271
|
+
if (!remainingParsed.doc || remainingParsed.error) return packageFailure(source, 'PARSE_ERROR', remainingParsed.error?.message || 'Could not parse updated comments.xml.');
|
|
272
|
+
for (const comment of Array.from(remainingParsed.doc.getElementsByTagNameNS('*', 'comment'))) {
|
|
273
|
+
const id = comment.getAttribute('w:id') || comment.getAttribute('id');
|
|
274
|
+
if (ids.has(id)) comment.parentNode?.removeChild(comment);
|
|
275
|
+
}
|
|
276
|
+
const documentParsed = parseOoxmlSafe(text(working, 'word/document.xml'), 'application/xml');
|
|
277
|
+
if (!documentParsed.doc || documentParsed.error) return packageFailure(source, 'PARSE_ERROR', documentParsed.error?.message || 'Could not parse document.xml.');
|
|
278
|
+
let referencesRemoved = 0;
|
|
279
|
+
for (const name of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) for (const node of Array.from(documentParsed.doc.getElementsByTagNameNS('*', name))) {
|
|
280
|
+
const id = node.getAttribute('w:id') || node.getAttribute('id'); if (!ids.has(id) || !node.parentNode) continue;
|
|
281
|
+
const parent = node.parentNode; parent.removeChild(node); referencesRemoved += 1;
|
|
282
|
+
if (name === 'commentReference' && parent.localName === 'r' && !Array.from(parent.childNodes || []).some(child => child.nodeType === 1 && child.localName !== 'rPr')) parent.parentNode?.removeChild(parent);
|
|
283
|
+
}
|
|
284
|
+
const serializer = createSerializer();
|
|
285
|
+
working.set('word/comments.xml', Buffer.from(serializer.serializeToString(remainingParsed.doc)));
|
|
286
|
+
if (extendedParsed?.doc) {
|
|
287
|
+
for (const entry of Array.from(extendedParsed.doc.getElementsByTagNameNS('*', 'commentEx'))) {
|
|
288
|
+
const paraId = (entry.getAttribute('w15:paraId') || entry.getAttribute('paraId') || '').toUpperCase();
|
|
289
|
+
if (removedParaIds.has(paraId)) entry.parentNode?.removeChild(entry);
|
|
290
|
+
}
|
|
291
|
+
working.set('word/commentsExtended.xml', Buffer.from(serializer.serializeToString(extendedParsed.doc)));
|
|
292
|
+
}
|
|
293
|
+
working.set('word/document.xml', Buffer.from(serializer.serializeToString(documentParsed.doc)));
|
|
294
|
+
try { if (options.validate !== false) await validateDocxPackage(new MemoryZip(working)); }
|
|
295
|
+
catch (error) { return packageFailure(source, 'PACKAGE_VALIDATION', error.message); }
|
|
296
|
+
this.entries = working; const output = this.toBuffer(); this.originalBuffer = Buffer.from(output);
|
|
297
|
+
return { status: 'ok', hasChanges: true, written: true, commentsRemoved: ids.size, referencesRemoved, artifactsChanged: ['word/document.xml', 'word/comments.xml', ...(extendedParsed?.doc ? ['word/commentsExtended.xml'] : [])], buffer: output, toBuffer: () => Buffer.from(output) };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function packageFailure(source, code, message) { return { status: 'error', hasChanges: false, written: false, rolledBack: true, error: { code, message }, artifactsChanged: [], buffer: Buffer.from(source), toBuffer: () => Buffer.from(source) }; }
|
|
302
|
+
export function openDocx(input) { return new DocxDocument(input); }
|
package/node/index.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { DocumentOperation, DocumentOperationBatchResult, OperationPreflightResult, StandaloneRunnerOptions } from '../services/standalone-operation-runner.js';
|
|
2
|
+
import type { DocumentInspectionOptions, DocumentInspectionResult, RevisionToken } from '../index.js';
|
|
3
|
+
|
|
4
|
+
export interface DocxApplyOptions extends StandaloneRunnerOptions {
|
|
5
|
+
author?: string;
|
|
6
|
+
validate?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface DocxApplyResult extends DocumentOperationBatchResult {
|
|
9
|
+
written: boolean;
|
|
10
|
+
buffer: Uint8Array;
|
|
11
|
+
inspection?: DocumentInspectionResult;
|
|
12
|
+
artifactsChanged?: string[];
|
|
13
|
+
validation?: { originalIssues: unknown[]; generatedIssues: unknown[] };
|
|
14
|
+
issues?: unknown[];
|
|
15
|
+
toBuffer(): Uint8Array;
|
|
16
|
+
}
|
|
17
|
+
export class DocxDocument {
|
|
18
|
+
constructor(input: Uint8Array);
|
|
19
|
+
inspect(options?: DocumentInspectionOptions): DocumentInspectionResult;
|
|
20
|
+
getRevisionToken(): RevisionToken;
|
|
21
|
+
readonly revisionToken: RevisionToken;
|
|
22
|
+
preflight(operations: DocumentOperation[], author?: string, options?: StandaloneRunnerOptions): OperationPreflightResult;
|
|
23
|
+
applyOperations(operations: DocumentOperation[], options?: DocxApplyOptions): Promise<DocxApplyResult>;
|
|
24
|
+
resolveRevisions(action: 'accept' | 'reject', options: { author?: string; allAuthors?: boolean; validate?: boolean }): Promise<DocxApplyResult>;
|
|
25
|
+
deleteComments(options: { author?: string; allAuthors?: boolean; validate?: boolean }): Promise<DocxApplyResult>;
|
|
26
|
+
toBuffer(): Uint8Array;
|
|
27
|
+
}
|
|
28
|
+
export function computePackageRevisionToken(input: unknown): RevisionToken;
|
|
29
|
+
export function openDocx(input: Uint8Array): DocxDocument;
|
|
30
|
+
export function executeCli(argv: string[]): Promise<Record<string, unknown>>;
|
|
31
|
+
export function runCli(argv?: string[], io?: { stdout: { write(value: string): unknown } }): Promise<number>;
|
package/node/index.js
ADDED