@ansonlai/docx-redline-js 0.8.0 → 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/CHANGELOG.md +539 -486
- package/README.md +331 -309
- package/dist/docx-redline-js.esm.js +1083 -163
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +107 -107
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +1083 -163
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +1083 -163
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_KNOWLEDGE_BASE.md +69 -8
- package/docs/schemas/document-operations.schema.json +29 -0
- package/document/docx-document.js +416 -22
- package/engine/reconstruction-writer.js +18 -0
- package/index.d.ts +598 -575
- package/index.js +349 -348
- package/node/cli-help.js +5 -5
- package/node/cli.js +760 -754
- package/node/index.d.ts +1 -1
- package/package.json +2 -1
- package/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/standalone-docx-plumbing.js +631 -569
- package/services/standalone-operation-runner.d.ts +19 -3
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { getDefaultAuthor } from '../adapters/config.js';
|
|
10
|
+
import { applyThreadResolutionToParts, commentParaId, reconcileCommentSiblingParts } from '../services/comment-thread-parts.js';
|
|
11
|
+
import { collectRevisionIds, countRevisionsInsideFields, discoverHeaderFooterParts, isHeaderFooterPath, renumberCollidingRevisionIds, resolvePartSelector } from '../services/headers-footers.js';
|
|
10
12
|
import { inspectDocumentParts } from '../services/document-inspection.js';
|
|
11
13
|
import { applyOperationsToDocumentXml, preflightOperations } from '../services/standalone-operation-runner.js';
|
|
12
14
|
import { createDynamicNumberingIdState, mergeNumberingXmlBySchemaOrder } from '../services/numbering-helpers.js';
|
|
13
|
-
import { ensureCommentsArtifactsInZip, ensureCommentsExtendedArtifactsInZip, ensureNumberingArtifactsInZip, validateDocxPackage } from '../services/standalone-docx-plumbing.js';
|
|
15
|
+
import { ensureCommentsArtifactsInZip, ensureCommentsExtendedArtifactsInZip, ensureNumberingArtifactsInZip, repairKnownContentTypes, validateDocxPackage } from '../services/standalone-docx-plumbing.js';
|
|
14
16
|
import { validateRedlineOoxml } from '../core/redline-validation.js';
|
|
15
17
|
import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
|
|
16
18
|
import { acceptTrackedChangesInOoxml, rejectTrackedChangesInOoxml, deleteCommentsByAuthorInOoxml } from '../services/revision-comment-management.js';
|
|
@@ -30,6 +32,17 @@ const text = (entries, path) => {
|
|
|
30
32
|
return textDecoder.decode(entry);
|
|
31
33
|
};
|
|
32
34
|
|
|
35
|
+
/** Keeps commentsIds/commentsExtensible in step with comments.xml after comments were added or removed. */
|
|
36
|
+
function reconcileCommentSiblings(working) {
|
|
37
|
+
const updated = reconcileCommentSiblingParts({
|
|
38
|
+
commentsXml: text(working, 'word/comments.xml'),
|
|
39
|
+
commentsIdsXml: text(working, 'word/commentsIds.xml'),
|
|
40
|
+
commentsExtensibleXml: text(working, 'word/commentsExtensible.xml')
|
|
41
|
+
});
|
|
42
|
+
if (updated.commentsIdsXml) working.set('word/commentsIds.xml', textEncoder.encode(updated.commentsIdsXml));
|
|
43
|
+
if (updated.commentsExtensibleXml) working.set('word/commentsExtensible.xml', textEncoder.encode(updated.commentsExtensibleXml));
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
const cloneEntries = entries => new Map([...entries].map(([name, data]) => [name, new Uint8Array(data)]));
|
|
34
47
|
|
|
35
48
|
function toBufferCompatible(uint8Arr) {
|
|
@@ -108,6 +121,9 @@ export function computePackageRevisionToken(input) {
|
|
|
108
121
|
});
|
|
109
122
|
}
|
|
110
123
|
|
|
124
|
+
// Word does not support comments in headers or footers, so these are refused when `part` is set.
|
|
125
|
+
const COMMENT_OPERATION_TYPES = new Set(['comment', 'comment_reply', 'comment_resolve']);
|
|
126
|
+
|
|
111
127
|
function nextCommentId(entries) {
|
|
112
128
|
const ids = `${text(entries, 'word/document.xml') || ''} ${text(entries, 'word/comments.xml') || ''}`.match(/(?:w:)?id=["'](\d+)["']/g) || [];
|
|
113
129
|
let next = ids.reduce((max, token) => Math.max(max, Number(token.match(/\d+/)?.[0] || 0)), 0) + 1;
|
|
@@ -127,7 +143,7 @@ function existingCommentDetails(entries) {
|
|
|
127
143
|
id,
|
|
128
144
|
author: comment.getAttribute('w:author') || comment.getAttribute('author') || '',
|
|
129
145
|
text: String(comment.textContent || '').trim(),
|
|
130
|
-
paraId:
|
|
146
|
+
paraId: commentParaId(comment) || null
|
|
131
147
|
};
|
|
132
148
|
}
|
|
133
149
|
return details;
|
|
@@ -165,13 +181,35 @@ export class DocxDocument {
|
|
|
165
181
|
|
|
166
182
|
inspect(options = {}) {
|
|
167
183
|
const digestFn = options.digestFn || (bytes => sha256(bytes));
|
|
168
|
-
|
|
184
|
+
const inspection = inspectDocumentParts({
|
|
169
185
|
documentXml: text(this.entries, 'word/document.xml'),
|
|
170
186
|
commentsXml: text(this.entries, 'word/comments.xml'),
|
|
171
187
|
commentsExtendedXml: text(this.entries, 'word/commentsExtended.xml'),
|
|
172
188
|
numberingXml: text(this.entries, 'word/numbering.xml'),
|
|
173
189
|
stylesXml: text(this.entries, 'word/styles.xml')
|
|
174
190
|
}, { ...options, digestFn });
|
|
191
|
+
if (inspection.status === 'error') return inspection;
|
|
192
|
+
return { ...inspection, headersFooters: this.inspectHeadersFooters() };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Header and footer parts the document uses, with their paragraph text, so operations can target them with
|
|
197
|
+
* `part`. `hasFields` marks parts containing PAGE/NUMPAGES-style fields (edit around them, not inside).
|
|
198
|
+
*/
|
|
199
|
+
inspectHeadersFooters() {
|
|
200
|
+
return discoverHeaderFooterParts({
|
|
201
|
+
documentXml: text(this.entries, 'word/document.xml'),
|
|
202
|
+
relsXml: text(this.entries, 'word/_rels/document.xml.rels'),
|
|
203
|
+
settingsXml: text(this.entries, 'word/settings.xml')
|
|
204
|
+
}).map(part => {
|
|
205
|
+
const xml = text(this.entries, part.path) || '';
|
|
206
|
+
const partInspection = xml ? inspectDocumentParts({ documentXml: xml }) : { paragraphs: [] };
|
|
207
|
+
return {
|
|
208
|
+
...part,
|
|
209
|
+
hasFields: /<w:fldChar\b|<w:fldSimple\b/.test(xml),
|
|
210
|
+
paragraphs: (partInspection.paragraphs || []).map(paragraph => ({ index: paragraph.index, text: paragraph.text }))
|
|
211
|
+
};
|
|
212
|
+
});
|
|
175
213
|
}
|
|
176
214
|
|
|
177
215
|
getRevisionToken() {
|
|
@@ -183,12 +221,54 @@ export class DocxDocument {
|
|
|
183
221
|
}
|
|
184
222
|
|
|
185
223
|
preflight(operations, author = getDefaultAuthor(), options = {}) {
|
|
186
|
-
|
|
224
|
+
const list = Array.isArray(operations) ? operations : [];
|
|
225
|
+
const runBody = ops => preflightOperations(
|
|
187
226
|
text(this.entries, 'word/document.xml'),
|
|
188
|
-
|
|
227
|
+
ops,
|
|
189
228
|
author || getDefaultAuthor(),
|
|
190
229
|
{ ...options, _existingCommentDetails: existingCommentDetails(this.entries) }
|
|
191
230
|
);
|
|
231
|
+
if (!list.some(operation => operation && typeof operation === 'object' && operation.part != null)) return runBody(operations);
|
|
232
|
+
|
|
233
|
+
const parts = this.inspectHeadersFooters();
|
|
234
|
+
const bodyOps = [];
|
|
235
|
+
const results = [];
|
|
236
|
+
const conflicts = [];
|
|
237
|
+
const authorsUsed = new Set();
|
|
238
|
+
let valid = true;
|
|
239
|
+
list.forEach((operation, i) => {
|
|
240
|
+
if (!operation || typeof operation !== 'object' || operation.part == null) {
|
|
241
|
+
bodyOps.push({ operation, origIndex: i });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const resolved = resolvePartSelector(parts, operation.part);
|
|
245
|
+
const refusal = COMMENT_OPERATION_TYPES.has(operation.type)
|
|
246
|
+
? { code: 'COMMENT_IN_HEADER_FOOTER', message: 'Word does not support comments in headers or footers.' }
|
|
247
|
+
: resolved.error;
|
|
248
|
+
if (refusal) {
|
|
249
|
+
valid = false;
|
|
250
|
+
results.push({ index: i + 1, type: operation.type || 'redline', status: 'error', error: normalizeErrorWithRecovery({ ...refusal, operationIndex: i + 1 }) });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const { part: _selector, ...rest } = operation;
|
|
254
|
+
const partResult = preflightOperations(text(this.entries, resolved.part.path), [rest], author || getDefaultAuthor(), { ...options, _existingCommentDetails: {} });
|
|
255
|
+
valid = valid && partResult.valid;
|
|
256
|
+
for (const result of partResult.results || []) results.push({ ...result, index: i + 1, part: resolved.part.path });
|
|
257
|
+
conflicts.push(...(partResult.conflicts || []));
|
|
258
|
+
for (const name of partResult.authorsUsed || []) authorsUsed.add(name);
|
|
259
|
+
});
|
|
260
|
+
let requiredArtifacts = { comments: false, numbering: false };
|
|
261
|
+
if (bodyOps.length) {
|
|
262
|
+
const body = runBody(bodyOps.map(item => item.operation));
|
|
263
|
+
valid = valid && body.valid;
|
|
264
|
+
for (const result of body.results || []) results.push({ ...result, index: bodyOps[result.index - 1].origIndex + 1 });
|
|
265
|
+
conflicts.push(...(body.conflicts || []));
|
|
266
|
+
for (const name of body.authorsUsed || []) authorsUsed.add(name);
|
|
267
|
+
requiredArtifacts = body.requiredArtifacts || requiredArtifacts;
|
|
268
|
+
}
|
|
269
|
+
results.sort((a, b) => a.index - b.index);
|
|
270
|
+
const ok = valid && conflicts.length === 0;
|
|
271
|
+
return { valid: ok, status: ok ? 'ok' : 'error', results, conflicts, authorsUsed: [...authorsUsed], requiredArtifacts };
|
|
192
272
|
}
|
|
193
273
|
|
|
194
274
|
toUint8Array() {
|
|
@@ -199,7 +279,230 @@ export class DocxDocument {
|
|
|
199
279
|
return toBufferCompatible(this.toUint8Array());
|
|
200
280
|
}
|
|
201
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Applies operations to the document. Operations without `part` target the body (word/document.xml).
|
|
284
|
+
* An operation with `part` targets one header or footer part:
|
|
285
|
+
* `{ part: { kind: 'footer', type: 'default', section: 0 } | 'word/footer2.xml', target: { exactText }, ... }`.
|
|
286
|
+
* Discover parts with `inspect().headersFooters`.
|
|
287
|
+
*/
|
|
202
288
|
async applyOperations(operations, options = {}) {
|
|
289
|
+
const list = Array.isArray(operations) ? operations : [];
|
|
290
|
+
if (!list.some(operation => operation && typeof operation === 'object' && operation.part != null)) {
|
|
291
|
+
return this.applyBodyOperations(operations, options);
|
|
292
|
+
}
|
|
293
|
+
return this.applyOperationsWithParts(list, options);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async applyOperationsWithParts(operations, options = {}) {
|
|
297
|
+
const author = options.author || getDefaultAuthor();
|
|
298
|
+
const atomic = options.atomic === true;
|
|
299
|
+
const snapshotEntries = this.entries;
|
|
300
|
+
const snapshotBytes = this.originalBytes;
|
|
301
|
+
const total = operations.length;
|
|
302
|
+
const restore = () => { this.entries = snapshotEntries; this.originalBytes = snapshotBytes; };
|
|
303
|
+
const original = () => ({
|
|
304
|
+
uint8Array: snapshotBytes,
|
|
305
|
+
toUint8Array: () => new Uint8Array(snapshotBytes),
|
|
306
|
+
buffer: toBufferCompatible(snapshotBytes),
|
|
307
|
+
toBuffer: () => toBufferCompatible(snapshotBytes)
|
|
308
|
+
});
|
|
309
|
+
const refuse = (error, results = [], receipts = [], extra = {}) => ({
|
|
310
|
+
status: 'error', hasChanges: false, written: false, rolledBack: true, results, receipts, executionOrder: [], authorsUsed: [],
|
|
311
|
+
artifactsChanged: [],
|
|
312
|
+
error: normalizeErrorWithRecovery(error),
|
|
313
|
+
retryPlan: createRetryPlan({ atomic: true, rolledBack: true, results, receipts, operationCount: total }),
|
|
314
|
+
validation: { originalIssues: [], generatedIssues: [] },
|
|
315
|
+
...extra,
|
|
316
|
+
...original()
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
if (options.expectedRevision) {
|
|
320
|
+
const precondition = await this.applyBodyOperations([], { expectedRevision: options.expectedRevision });
|
|
321
|
+
if (precondition.status === 'error') return precondition;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Resolve every selector before touching anything, so a bad selector fails the whole request.
|
|
325
|
+
const parts = discoverHeaderFooterParts({
|
|
326
|
+
documentXml: text(this.entries, 'word/document.xml'),
|
|
327
|
+
relsXml: text(this.entries, 'word/_rels/document.xml.rels'),
|
|
328
|
+
settingsXml: text(this.entries, 'word/settings.xml')
|
|
329
|
+
});
|
|
330
|
+
const bodyOps = [];
|
|
331
|
+
const partOps = [];
|
|
332
|
+
for (let i = 0; i < operations.length; i++) {
|
|
333
|
+
const operation = operations[i];
|
|
334
|
+
if (!operation || typeof operation !== 'object' || operation.part == null) {
|
|
335
|
+
bodyOps.push({ operation, origIndex: i });
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (COMMENT_OPERATION_TYPES.has(operation.type)) {
|
|
339
|
+
return refuse({ code: 'COMMENT_IN_HEADER_FOOTER', message: `Operation ${i + 1}: Word does not support comments in headers or footers.`, operationIndex: i + 1 });
|
|
340
|
+
}
|
|
341
|
+
const resolved = resolvePartSelector(parts, operation.part);
|
|
342
|
+
if (resolved.error) return refuse({ ...resolved.error, message: `Operation ${i + 1}: ${resolved.error.message}`, operationIndex: i + 1 });
|
|
343
|
+
const { part: _selector, ...rest } = operation;
|
|
344
|
+
partOps.push({ operation: rest, origIndex: i, part: resolved.part });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const results = [];
|
|
348
|
+
const receipts = [];
|
|
349
|
+
const executionOrder = [];
|
|
350
|
+
const authorsUsed = new Set();
|
|
351
|
+
const originalIssues = [];
|
|
352
|
+
let anyChange = false;
|
|
353
|
+
let anyFailure = false;
|
|
354
|
+
let bodyResult = null;
|
|
355
|
+
|
|
356
|
+
if (bodyOps.length) {
|
|
357
|
+
bodyResult = await this.applyBodyOperations(bodyOps.map(item => item.operation), { ...options, expectedRevision: undefined });
|
|
358
|
+
const toOriginal = index => bodyOps[index - 1]?.origIndex + 1;
|
|
359
|
+
for (const result of bodyResult.results || []) {
|
|
360
|
+
results.push({
|
|
361
|
+
...result,
|
|
362
|
+
index: toOriginal(result.index),
|
|
363
|
+
...(result.receipt ? { receipt: { ...result.receipt, operationIndex: toOriginal(result.receipt.operationIndex) } } : {})
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
for (const receipt of bodyResult.receipts || []) receipts.push({ ...receipt, operationIndex: toOriginal(receipt.operationIndex) });
|
|
367
|
+
for (const index of bodyResult.executionOrder || []) executionOrder.push(toOriginal(index));
|
|
368
|
+
for (const name of bodyResult.authorsUsed || []) authorsUsed.add(name);
|
|
369
|
+
originalIssues.push(...(bodyResult.validation?.originalIssues || []));
|
|
370
|
+
if (bodyResult.status === 'error' || bodyResult.rolledBack) {
|
|
371
|
+
anyFailure = true;
|
|
372
|
+
if (atomic) {
|
|
373
|
+
restore();
|
|
374
|
+
return { ...bodyResult, results, receipts, executionOrder, written: false, artifactsChanged: [], rolledBack: true, ...original() };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
anyChange = bodyResult.hasChanges === true;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const working = cloneEntries(this.entries);
|
|
381
|
+
const zip = new MemoryZip(working);
|
|
382
|
+
const changedParts = new Set();
|
|
383
|
+
const { expectedRevision: _ignored, ...runnerOptions } = options;
|
|
384
|
+
const failPart = (item, error) => {
|
|
385
|
+
anyFailure = true;
|
|
386
|
+
const failure = normalizeErrorWithRecovery({ ...error, operationIndex: item.origIndex + 1 });
|
|
387
|
+
results.push({ index: item.origIndex + 1, type: item.operation.type || 'redline', status: 'error', authorUsed: author, part: item.part.path, error: failure });
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
for (const item of partOps) {
|
|
391
|
+
const path = item.part.path;
|
|
392
|
+
const xml = text(working, path);
|
|
393
|
+
if (!xml) {
|
|
394
|
+
failPart(item, { code: 'PART_NOT_FOUND', message: `Part '${path}' is referenced but missing from the package.` });
|
|
395
|
+
if (atomic) break;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const numberingText = text(working, 'word/numbering.xml');
|
|
399
|
+
const outcome = await applyOperationsToDocumentXml(xml, [item.operation], author, { numberingIdState: createDynamicNumberingIdState(numberingText || undefined) }, {
|
|
400
|
+
...runnerOptions, atomic: true, strictTargets: options.strictTargets !== false, _existingCommentDetails: {}
|
|
401
|
+
});
|
|
402
|
+
const single = outcome.results?.[0];
|
|
403
|
+
if (outcome.rolledBack || outcome.status === 'error') {
|
|
404
|
+
failPart(item, single?.error || outcome.error || { code: 'OPERATION_FAILED', message: 'Operation failed.' });
|
|
405
|
+
if (atomic) break;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (!outcome.hasChanges) {
|
|
409
|
+
results.push({ ...single, index: item.origIndex + 1, part: path });
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (countRevisionsInsideFields(outcome.documentXml) > countRevisionsInsideFields(xml)) {
|
|
413
|
+
failPart(item, { code: 'FIELD_EDIT_REFUSED', message: `The edit would change a field (for example PAGE or NUMPAGES) in ${path}. Word recomputes fields; edit the text around them instead.` });
|
|
414
|
+
if (atomic) break;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const taken = new Set();
|
|
418
|
+
for (const [name, data] of working) {
|
|
419
|
+
if (name === path || !(name === 'word/document.xml' || isHeaderFooterPath(name))) continue;
|
|
420
|
+
for (const id of collectRevisionIds(textDecoder.decode(data))) taken.add(id);
|
|
421
|
+
}
|
|
422
|
+
const renumbered = renumberCollidingRevisionIds(outcome.documentXml, taken);
|
|
423
|
+
working.set(path, textEncoder.encode(renumbered.xml));
|
|
424
|
+
// Receipts must describe what is in the file: the part it lives in and its final revision ids.
|
|
425
|
+
const describeReceipt = receipt => (receipt?.revisionItems
|
|
426
|
+
? { ...receipt, revisionItems: receipt.revisionItems.map(revision => ({ ...revision, id: renumbered.idMap.get(String(revision.id)) ?? revision.id, partName: path })) }
|
|
427
|
+
: receipt);
|
|
428
|
+
if (outcome.numberingXmlParts?.length) {
|
|
429
|
+
await ensureNumberingArtifactsInZip(zip, outcome.numberingXmlParts, { mergeNumberingXml: mergeNumberingXmlBySchemaOrder });
|
|
430
|
+
}
|
|
431
|
+
changedParts.add(path);
|
|
432
|
+
anyChange = true;
|
|
433
|
+
results.push({
|
|
434
|
+
...single,
|
|
435
|
+
index: item.origIndex + 1,
|
|
436
|
+
part: path,
|
|
437
|
+
partSections: item.part.appliesToSections,
|
|
438
|
+
...(single?.receipt ? { receipt: { ...describeReceipt(single.receipt), operationIndex: item.origIndex + 1 } } : {})
|
|
439
|
+
});
|
|
440
|
+
if (outcome.receipts?.[0]) receipts.push({ ...describeReceipt(outcome.receipts[0]), operationIndex: item.origIndex + 1 });
|
|
441
|
+
executionOrder.push(item.origIndex + 1);
|
|
442
|
+
for (const name of outcome.authorsUsed || []) authorsUsed.add(name);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Each changed part must not introduce revision-markup problems, and the package must still validate.
|
|
446
|
+
const generatedIssues = [];
|
|
447
|
+
if (!(atomic && anyFailure)) {
|
|
448
|
+
for (const path of changedParts) {
|
|
449
|
+
const before = validateRedlineOoxml(text(this.entries, path)).issues.map(issue => ({ source: path, ...issue }));
|
|
450
|
+
const after = validateRedlineOoxml(text(working, path)).issues.map(issue => ({ source: path, ...issue }));
|
|
451
|
+
generatedIssues.push(...validationErrors(subtractValidationIssueMultiset(after, before)));
|
|
452
|
+
}
|
|
453
|
+
if (options.validate !== false && changedParts.size) {
|
|
454
|
+
try {
|
|
455
|
+
await validateDocxPackage(zip);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
generatedIssues.push({ source: 'package', code: 'PACKAGE_VALIDATION', severity: 'error', message: error.message });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if ((atomic && anyFailure) || generatedIssues.length) {
|
|
462
|
+
restore();
|
|
463
|
+
const error = generatedIssues.length
|
|
464
|
+
? { code: 'PACKAGE_OPERATION_FAILED', message: `Applied operations introduced invalid markup (${[...new Set(generatedIssues.map(issue => issue.code))].join(', ')}).`, issues: generatedIssues }
|
|
465
|
+
: { code: 'BATCH_OPERATION_FAILED', message: 'Atomic batch rolled back because one or more operations failed.' };
|
|
466
|
+
const rolledBackResults = results
|
|
467
|
+
.map(result => (result.receipt ? { ...result, receipt: { ...result.receipt, committed: false, finalDisposition: 'rolled_back' } } : result))
|
|
468
|
+
.sort((a, b) => a.index - b.index);
|
|
469
|
+
const rolledBackReceipts = receipts.map(receipt => ({ ...receipt, committed: false, finalDisposition: 'rolled_back' }));
|
|
470
|
+
return refuse(error, rolledBackResults, rolledBackReceipts, { validation: { originalIssues, generatedIssues }, issues: generatedIssues });
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
results.sort((a, b) => a.index - b.index);
|
|
474
|
+
receipts.sort((a, b) => a.operationIndex - b.operationIndex);
|
|
475
|
+
const status = !anyFailure ? 'ok' : (anyChange ? 'partial' : 'error');
|
|
476
|
+
const failureError = anyFailure
|
|
477
|
+
? { error: normalizeErrorWithRecovery({ code: 'BATCH_OPERATION_FAILED', message: 'One or more operations failed; the others were applied.' }) }
|
|
478
|
+
: {};
|
|
479
|
+
if (!changedParts.size) {
|
|
480
|
+
// No part changed; the body call (if any) already committed its own result.
|
|
481
|
+
const buf = toBufferCompatible(this.originalBytes);
|
|
482
|
+
return {
|
|
483
|
+
...(bodyResult || {}), results, receipts, executionOrder, authorsUsed: [...authorsUsed], status, hasChanges: anyChange,
|
|
484
|
+
written: bodyResult?.written === true, artifactsChanged: bodyResult?.artifactsChanged || [],
|
|
485
|
+
validation: { originalIssues, generatedIssues: [] }, ...failureError,
|
|
486
|
+
uint8Array: this.originalBytes, toUint8Array: () => new Uint8Array(this.originalBytes), buffer: buf, toBuffer: () => toBufferCompatible(this.originalBytes)
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
this.entries = working;
|
|
491
|
+
const outputBytes = this.toUint8Array();
|
|
492
|
+
this.originalBytes = outputBytes;
|
|
493
|
+
const artifactsChanged = [...working]
|
|
494
|
+
.filter(([name, data]) => !snapshotEntries.has(name) || !areByteArraysEqual(data, snapshotEntries.get(name)))
|
|
495
|
+
.map(([name]) => name);
|
|
496
|
+
return {
|
|
497
|
+
...(bodyResult || {}),
|
|
498
|
+
status, hasChanges: true, written: true, results, receipts, executionOrder, authorsUsed: [...authorsUsed], artifactsChanged,
|
|
499
|
+
validation: { originalIssues, generatedIssues: [] }, ...failureError,
|
|
500
|
+
uint8Array: outputBytes, toUint8Array: () => new Uint8Array(outputBytes), buffer: toBufferCompatible(outputBytes),
|
|
501
|
+
inspection: this.inspect(), toBuffer: () => toBufferCompatible(outputBytes)
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async applyBodyOperations(operations, options = {}) {
|
|
203
506
|
const failedApply = error => {
|
|
204
507
|
const results = [];
|
|
205
508
|
const receipts = [];
|
|
@@ -317,7 +620,9 @@ export class DocxDocument {
|
|
|
317
620
|
};
|
|
318
621
|
}
|
|
319
622
|
|
|
320
|
-
|
|
623
|
+
// Resolving a thread only changes the comment parts; keep document.xml byte-identical.
|
|
624
|
+
const bodyChanged = (result.results || []).some(item => item?.status !== 'error' && item?.operationType !== 'comment_resolve');
|
|
625
|
+
if (bodyChanged) working.set('word/document.xml', textEncoder.encode(result.documentXml));
|
|
321
626
|
await ensureNumberingArtifactsInZip(zip, result.numberingXmlParts, { mergeNumberingXml: mergeNumberingXmlBySchemaOrder });
|
|
322
627
|
|
|
323
628
|
const existingCommentsXml = text(working, 'word/comments.xml');
|
|
@@ -326,11 +631,16 @@ export class DocxDocument {
|
|
|
326
631
|
replaceExisting: result.commentsXmlMode === 'replace' || (!result.commentsXml && !!existingCommentsXml)
|
|
327
632
|
});
|
|
328
633
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
634
|
+
// Only touch the extended part (and its content type/rel) when an operation changed it.
|
|
635
|
+
// Rewriting it on every save is what stamped a bad content type onto every
|
|
636
|
+
// already-commented document.
|
|
637
|
+
if (result.commentsExtendedXml) {
|
|
638
|
+
await ensureCommentsExtendedArtifactsInZip(zip, result.commentsExtendedXml, {
|
|
639
|
+
replaceExisting: result.commentsExtendedXmlMode === 'replace'
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
await repairKnownContentTypes(zip);
|
|
643
|
+
reconcileCommentSiblings(working);
|
|
334
644
|
|
|
335
645
|
if (options.validate !== false) {
|
|
336
646
|
const generated = validateRedlineOoxml(result.documentXml);
|
|
@@ -407,6 +717,7 @@ export class DocxDocument {
|
|
|
407
717
|
if (!transform) return packageFailure(this.originalBytes, 'INVALID_ACTION', `Unknown revision action: ${action}`);
|
|
408
718
|
|
|
409
719
|
const sourceBytes = this.originalBytes;
|
|
720
|
+
const originalEntries = this.entries;
|
|
410
721
|
const working = cloneEntries(this.entries);
|
|
411
722
|
const zip = new MemoryZip(working);
|
|
412
723
|
const result = transform(text(working, 'word/document.xml'), { author: options.author, allAuthors: options.allAuthors === true });
|
|
@@ -415,7 +726,20 @@ export class DocxDocument {
|
|
|
415
726
|
return packageFailure(sourceBytes, result.error?.code || 'REVISION_OPERATION_FAILED', result.error?.message || 'Revision operation failed.');
|
|
416
727
|
}
|
|
417
728
|
|
|
418
|
-
|
|
729
|
+
// Headers and footers carry their own tracked changes; resolve them with the same filter.
|
|
730
|
+
const partUpdates = new Map();
|
|
731
|
+
for (const part of this.inspectHeadersFooters()) {
|
|
732
|
+
const partXml = text(working, part.path);
|
|
733
|
+
if (!partXml) continue;
|
|
734
|
+
const partResult = transform(partXml, { author: options.author, allAuthors: options.allAuthors === true });
|
|
735
|
+
if (partResult.status === 'error' || partResult.error) {
|
|
736
|
+
return packageFailure(sourceBytes, partResult.error?.code || 'REVISION_OPERATION_FAILED', `${part.path}: ${partResult.error?.message || 'Revision operation failed.'}`);
|
|
737
|
+
}
|
|
738
|
+
if (partResult.hasChanges) partUpdates.set(part.path, partResult.oxml);
|
|
739
|
+
}
|
|
740
|
+
const hasChanges = result.hasChanges || partUpdates.size > 0;
|
|
741
|
+
|
|
742
|
+
if (!hasChanges) {
|
|
419
743
|
const buf = toBufferCompatible(sourceBytes);
|
|
420
744
|
return {
|
|
421
745
|
...result,
|
|
@@ -429,8 +753,10 @@ export class DocxDocument {
|
|
|
429
753
|
};
|
|
430
754
|
}
|
|
431
755
|
|
|
432
|
-
working.set('word/document.xml', textEncoder.encode(result.oxml));
|
|
756
|
+
if (result.hasChanges) working.set('word/document.xml', textEncoder.encode(result.oxml));
|
|
757
|
+
for (const [path, xml] of partUpdates) working.set(path, textEncoder.encode(xml));
|
|
433
758
|
try {
|
|
759
|
+
await repairKnownContentTypes(zip);
|
|
434
760
|
if (options.validate !== false) await validateDocxPackage(zip);
|
|
435
761
|
} catch (error) {
|
|
436
762
|
return packageFailure(sourceBytes, 'PACKAGE_VALIDATION', error.message);
|
|
@@ -445,7 +771,10 @@ export class DocxDocument {
|
|
|
445
771
|
...result,
|
|
446
772
|
status: 'ok',
|
|
447
773
|
written: true,
|
|
448
|
-
|
|
774
|
+
hasChanges: true,
|
|
775
|
+
artifactsChanged: [...working]
|
|
776
|
+
.filter(([name, data]) => !originalEntries.has(name) || !areByteArraysEqual(data, originalEntries.get(name)))
|
|
777
|
+
.map(([name]) => name),
|
|
449
778
|
uint8Array: outputBytes,
|
|
450
779
|
toUint8Array: () => new Uint8Array(outputBytes),
|
|
451
780
|
buffer: outputBuf,
|
|
@@ -453,6 +782,55 @@ export class DocxDocument {
|
|
|
453
782
|
};
|
|
454
783
|
}
|
|
455
784
|
|
|
785
|
+
/**
|
|
786
|
+
* Resolves (or reopens) the comment thread containing `commentId`.
|
|
787
|
+
* Resolved is a thread-level state in Word, so the root and every reply are updated together.
|
|
788
|
+
*/
|
|
789
|
+
async resolveComment(commentId, options = {}) {
|
|
790
|
+
const sourceBytes = this.originalBytes;
|
|
791
|
+
const working = cloneEntries(this.entries);
|
|
792
|
+
const resolved = options.resolved !== false;
|
|
793
|
+
const noChange = extra => {
|
|
794
|
+
const buf = toBufferCompatible(sourceBytes);
|
|
795
|
+
return {
|
|
796
|
+
status: 'ok', hasChanges: false, written: false, artifactsChanged: [], ...extra,
|
|
797
|
+
uint8Array: sourceBytes, toUint8Array: () => new Uint8Array(sourceBytes), buffer: buf, toBuffer: () => toBufferCompatible(sourceBytes)
|
|
798
|
+
};
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
const result = applyThreadResolutionToParts({
|
|
802
|
+
commentsXml: text(working, 'word/comments.xml'),
|
|
803
|
+
commentsExtendedXml: text(working, 'word/commentsExtended.xml'),
|
|
804
|
+
commentId,
|
|
805
|
+
resolved
|
|
806
|
+
});
|
|
807
|
+
if (result.status === 'error') return packageFailure(sourceBytes, result.error.code, result.error.message);
|
|
808
|
+
if (!result.hasChanges) return noChange({ resolved, threadRootId: result.threadRootId, commentIds: result.commentIds });
|
|
809
|
+
|
|
810
|
+
const zip = new MemoryZip(working);
|
|
811
|
+
try {
|
|
812
|
+
if (result.commentsXml) working.set('word/comments.xml', textEncoder.encode(result.commentsXml));
|
|
813
|
+
await ensureCommentsExtendedArtifactsInZip(zip, result.commentsExtendedXml, { replaceExisting: true });
|
|
814
|
+
await repairKnownContentTypes(zip);
|
|
815
|
+
if (options.validate !== false) await validateDocxPackage(zip);
|
|
816
|
+
} catch (error) {
|
|
817
|
+
return packageFailure(sourceBytes, 'PACKAGE_VALIDATION', error.message);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const artifactsChanged = [...working]
|
|
821
|
+
.filter(([name, data]) => !this.entries.has(name) || !areByteArraysEqual(data, this.entries.get(name)))
|
|
822
|
+
.map(([name]) => name);
|
|
823
|
+
this.entries = working;
|
|
824
|
+
const outputBytes = this.toUint8Array();
|
|
825
|
+
this.originalBytes = outputBytes;
|
|
826
|
+
return {
|
|
827
|
+
status: 'ok', hasChanges: true, written: true, resolved,
|
|
828
|
+
threadRootId: result.threadRootId, commentIds: result.commentIds, artifactsChanged,
|
|
829
|
+
uint8Array: outputBytes, toUint8Array: () => new Uint8Array(outputBytes),
|
|
830
|
+
buffer: toBufferCompatible(outputBytes), toBuffer: () => toBufferCompatible(outputBytes)
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
456
834
|
async deleteComments(options = {}) {
|
|
457
835
|
const sourceBytes = this.originalBytes;
|
|
458
836
|
const working = cloneEntries(this.entries);
|
|
@@ -479,7 +857,16 @@ export class DocxDocument {
|
|
|
479
857
|
return packageFailure(sourceBytes, 'PARSE_ERROR', parsed.error?.message || 'Could not parse comments.xml.');
|
|
480
858
|
}
|
|
481
859
|
|
|
482
|
-
|
|
860
|
+
// `ids` deletes specific comments (a thread root also takes its replies); otherwise filter by author.
|
|
861
|
+
const requestedIds = Array.isArray(options.ids) ? new Set(options.ids.map(String)) : null;
|
|
862
|
+
if (requestedIds) {
|
|
863
|
+
const known = new Set(Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment')).map(node => node.getAttribute('w:id') || node.getAttribute('id')));
|
|
864
|
+
const missing = [...requestedIds].filter(id => !known.has(id));
|
|
865
|
+
if (missing.length) return packageFailure(sourceBytes, 'COMMENT_NOT_FOUND', `Comment id(s) not found: ${missing.join(', ')}.`);
|
|
866
|
+
}
|
|
867
|
+
const matches = comment => (requestedIds
|
|
868
|
+
? requestedIds.has(comment.getAttribute('w:id') || comment.getAttribute('id'))
|
|
869
|
+
: options.allAuthors === true || (comment.getAttribute('w:author') || comment.getAttribute('author')) === options.author);
|
|
483
870
|
const ids = new Set(
|
|
484
871
|
Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment'))
|
|
485
872
|
.filter(matches)
|
|
@@ -514,9 +901,8 @@ export class DocxDocument {
|
|
|
514
901
|
const idByParaId = new Map();
|
|
515
902
|
for (const comment of Array.from(parsed.doc.getElementsByTagNameNS('*', 'comment'))) {
|
|
516
903
|
const id = comment.getAttribute('w:id') || comment.getAttribute('id');
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
if (paraId) idByParaId.set(paraId.toUpperCase(), id);
|
|
904
|
+
const paraId = commentParaId(comment);
|
|
905
|
+
if (paraId) idByParaId.set(paraId, id);
|
|
520
906
|
}
|
|
521
907
|
for (const [paraId, id] of idByParaId) {
|
|
522
908
|
if (ids.has(id)) removedParaIds.add(paraId);
|
|
@@ -536,8 +922,10 @@ export class DocxDocument {
|
|
|
536
922
|
}
|
|
537
923
|
}
|
|
538
924
|
|
|
539
|
-
const
|
|
540
|
-
|
|
925
|
+
const remainingXml = requestedIds
|
|
926
|
+
? commentsXml
|
|
927
|
+
: deleteCommentsByAuthorInOoxml(commentsXml, { author: options.author, allAuthors: options.allAuthors === true }).oxml;
|
|
928
|
+
const remainingParsed = parseOoxmlSafe(remainingXml, 'application/xml');
|
|
541
929
|
if (!remainingParsed.doc || remainingParsed.error) {
|
|
542
930
|
return packageFailure(sourceBytes, 'PARSE_ERROR', remainingParsed.error?.message || 'Could not parse updated comments.xml.');
|
|
543
931
|
}
|
|
@@ -580,11 +968,17 @@ export class DocxDocument {
|
|
|
580
968
|
working.set('word/document.xml', textEncoder.encode(serializer.serializeToString(documentParsed.doc)));
|
|
581
969
|
|
|
582
970
|
try {
|
|
583
|
-
|
|
971
|
+
reconcileCommentSiblings(working);
|
|
972
|
+
const finalZip = new MemoryZip(working);
|
|
973
|
+
await repairKnownContentTypes(finalZip);
|
|
974
|
+
if (options.validate !== false) await validateDocxPackage(finalZip);
|
|
584
975
|
} catch (error) {
|
|
585
976
|
return packageFailure(sourceBytes, 'PACKAGE_VALIDATION', error.message);
|
|
586
977
|
}
|
|
587
978
|
|
|
979
|
+
const artifactsChanged = [...working]
|
|
980
|
+
.filter(([name, data]) => !this.entries.has(name) || !areByteArraysEqual(data, this.entries.get(name)))
|
|
981
|
+
.map(([name]) => name);
|
|
588
982
|
this.entries = working;
|
|
589
983
|
const outputBytes = this.toUint8Array();
|
|
590
984
|
this.originalBytes = outputBytes;
|
|
@@ -596,7 +990,7 @@ export class DocxDocument {
|
|
|
596
990
|
written: true,
|
|
597
991
|
commentsRemoved: ids.size,
|
|
598
992
|
referencesRemoved,
|
|
599
|
-
artifactsChanged
|
|
993
|
+
artifactsChanged,
|
|
600
994
|
uint8Array: outputBytes,
|
|
601
995
|
toUint8Array: () => new Uint8Array(outputBytes),
|
|
602
996
|
buffer: outputBuf,
|
|
@@ -164,6 +164,24 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
|
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
// Comment markers are emitted when a diff part starts at their offset. Markers at the very end of the
|
|
168
|
+
// paragraph text (commentRangeEnd and the reference run of a comment on the last words) are only reached
|
|
169
|
+
// when the final diff part is an insert; after an unchanged or deleted tail nothing starts there, so they
|
|
170
|
+
// were dropped and the comment lost its end anchor. Flush whatever is left, in original order.
|
|
171
|
+
for (const offset of Array.from(sentinelMapByStart.keys()).sort((a, b) => a - b)) {
|
|
172
|
+
for (const sentinel of sentinelMapByStart.get(offset)) {
|
|
173
|
+
if (!sentinel.isCommentMarker || emittedCommentMarkers.has(sentinel.node)) continue;
|
|
174
|
+
emittedCommentMarkers.add(sentinel.node);
|
|
175
|
+
if (isWordElement(sentinel.node, 'commentReference')) {
|
|
176
|
+
const run = createWordElement(xmlDoc, 'w:r');
|
|
177
|
+
run.appendChild(sentinel.node.cloneNode(true));
|
|
178
|
+
currentParagraph.appendChild(run);
|
|
179
|
+
} else {
|
|
180
|
+
currentParagraph.appendChild(sentinel.node.cloneNode(true));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
167
185
|
if (generateRedlines) {
|
|
168
186
|
containerFragments.forEach(fragment => {
|
|
169
187
|
Array.from(fragment.childNodes).forEach(node => {
|