@ansonlai/docx-redline-js 0.5.3 → 0.5.4
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 +46 -12
- package/ARCHITECTURE.md +38 -3
- package/CHANGELOG.md +6 -0
- package/README.md +37 -10
- package/core/paragraph-revision-safety.js +10 -8
- package/core/redline-validation.js +7 -4
- package/core/revision-cloning.js +21 -0
- package/core/validation-delta.js +23 -0
- package/dist/docx-redline-js.esm.js +164 -14
- package/dist/docx-redline-js.esm.js.map +2 -2
- package/dist/docx-redline-js.esm.min.js +79 -79
- package/dist/docx-redline-js.esm.min.js.map +3 -3
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +580 -37
- package/docs/schemas/document-operations.schema.json +11 -1
- package/engine/surgical-mode.js +148 -3
- package/engine/surgical-run-splitting.js +19 -7
- package/engine/surgical-spans.js +2 -1
- package/node/cli.js +128 -13
- package/node/docx-document.js +17 -14
- package/package.json +1 -1
- package/pipeline/diff-engine.js +15 -0
- package/services/document-operation-applier.js +49 -4
- package/services/document-operation-contract.js +40 -0
- package/services/document-operation-mutations.js +353 -36
- package/services/operation-preflight.js +1 -1
- package/services/standalone-operation-runner.d.ts +18 -0
|
@@ -28,6 +28,16 @@
|
|
|
28
28
|
"additionalProperties": false
|
|
29
29
|
},
|
|
30
30
|
"operationArray": { "type": "array", "items": { "$ref": "#/$defs/operation" } },
|
|
31
|
+
"rejectedTextInsertionAnchor": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"required": ["exactText", "offset"],
|
|
34
|
+
"properties": {
|
|
35
|
+
"exactText": { "type": "string", "minLength": 1 },
|
|
36
|
+
"occurrence": { "type": "integer", "minimum": 1 },
|
|
37
|
+
"offset": { "type": "integer", "minimum": 0 }
|
|
38
|
+
},
|
|
39
|
+
"additionalProperties": false
|
|
40
|
+
},
|
|
31
41
|
"target": {
|
|
32
42
|
"oneOf": [
|
|
33
43
|
{ "type": "string", "minLength": 1 },
|
|
@@ -86,7 +96,7 @@
|
|
|
86
96
|
"additionalProperties": false
|
|
87
97
|
},
|
|
88
98
|
{
|
|
89
|
-
"allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "required": ["modified"], "properties": { "type": { "enum": ["redline", "replace", "format", "list-change", "table-reconciliation", "insert"] }, "modified": { "type": "string" }, "structuredContent": { "type": "boolean" }, "targetEnd": { "$ref": "#/$defs/target" }, "targetEndRef": { "type": ["integer", "string", "null"] } } } ]
|
|
99
|
+
"allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "required": ["modified"], "properties": { "type": { "enum": ["redline", "replace", "format", "list-change", "table-reconciliation", "insert"] }, "modified": { "type": "string" }, "structuredContent": { "type": "boolean" }, "targetEnd": { "$ref": "#/$defs/target" }, "targetEndRef": { "type": ["integer", "string", "null"] }, "anchor": { "$ref": "#/$defs/rejectedTextInsertionAnchor" } } } ]
|
|
90
100
|
},
|
|
91
101
|
{
|
|
92
102
|
"allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "required": ["modified"], "properties": { "type": { "const": "restore" }, "modified": { "oneOf": [ { "type": "string", "minLength": 1 }, { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } ] }, "targetEnd": { "$ref": "#/$defs/target" }, "targetEndRef": { "type": ["integer", "string", "null"] } } } ]
|
package/engine/surgical-mode.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
|
|
9
|
-
import { computeInsertionOnlyDiffs, computeWordDiffs } from '../pipeline/diff-engine.js';
|
|
9
|
+
import { computeCharacterDiffs, computeInsertionOnlyDiffs, computeWordDiffs } from '../pipeline/diff-engine.js';
|
|
10
10
|
import { getDocumentParagraphs } from './format-extraction.js';
|
|
11
11
|
import { buildSpanIndex, buildSurgicalTextSpans, forEachOverlappingSpan } from './surgical-spans.js';
|
|
12
12
|
import {
|
|
@@ -115,7 +115,10 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
115
115
|
const insertionOnlyDiffs = options.existingRevisions === 'slice-cross-author'
|
|
116
116
|
? computeInsertionOnlyDiffs(fullText, modifiedText)
|
|
117
117
|
: null;
|
|
118
|
-
const diffs = insertionOnlyDiffs ||
|
|
118
|
+
const diffs = insertionOnlyDiffs || refineSpaceEquivalentReplacements(
|
|
119
|
+
computeWordDiffs(fullText, modifiedText, diffOptions),
|
|
120
|
+
diffOptions
|
|
121
|
+
);
|
|
119
122
|
const spanIndex = buildSpanIndex(textSpans);
|
|
120
123
|
const pairReplacements = options.pairReplacements === true;
|
|
121
124
|
const warnings = [];
|
|
@@ -157,6 +160,84 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
157
160
|
}
|
|
158
161
|
if (insertResult === true) hasChanges = true;
|
|
159
162
|
}
|
|
163
|
+
} else if (formatHints.length === 0) {
|
|
164
|
+
const editOperations = collectTextEditOperations(diffs);
|
|
165
|
+
for (const operation of editOperations.reverse()) {
|
|
166
|
+
const liveSpanIndex = buildSpanIndex(buildSurgicalTextSpans(allParagraphs).textSpans);
|
|
167
|
+
if (operation.type === 'insert') {
|
|
168
|
+
const inserted = processInsert(
|
|
169
|
+
xmlDoc,
|
|
170
|
+
liveSpanIndex,
|
|
171
|
+
operation.start,
|
|
172
|
+
operation.text.replace(/\n/g, ' '),
|
|
173
|
+
author,
|
|
174
|
+
formatHints,
|
|
175
|
+
operation.newPos,
|
|
176
|
+
generateRedlines,
|
|
177
|
+
allParagraphs[0] || null,
|
|
178
|
+
null,
|
|
179
|
+
options?.insertionAffinity || null,
|
|
180
|
+
options?.existingRevisions || 'merge-same-author'
|
|
181
|
+
);
|
|
182
|
+
if (inserted && typeof inserted === 'object' && inserted.error) {
|
|
183
|
+
return withOoxmlSourceType({
|
|
184
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
185
|
+
hasChanges: false,
|
|
186
|
+
status: 'error',
|
|
187
|
+
error: inserted.error
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (inserted === true) hasChanges = true;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let delMetadata = null;
|
|
195
|
+
let insMetadata = null;
|
|
196
|
+
if (operation.type === 'replace' && pairReplacements && generateRedlines && operation.text.replace(/\n/g, ' ')) {
|
|
197
|
+
const checkResult = checkSafeAdjacencyForPairing(
|
|
198
|
+
liveSpanIndex,
|
|
199
|
+
operation.start,
|
|
200
|
+
operation.end,
|
|
201
|
+
options?.existingRevisions === 'slice-cross-author'
|
|
202
|
+
);
|
|
203
|
+
if (checkResult.safe) {
|
|
204
|
+
const event = createReplacementRevisionEvent(author, xmlDoc);
|
|
205
|
+
delMetadata = { id: event.deletionId, author: event.author, date: event.date };
|
|
206
|
+
insMetadata = { id: event.insertionId, author: event.author, date: event.date };
|
|
207
|
+
} else if (checkResult.structuralBoundary) {
|
|
208
|
+
warnings.push('PAIRING_SKIPPED_STRUCTURAL_BOUNDARY');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (processDelete(xmlDoc, liveSpanIndex, operation.start, operation.end, author, generateRedlines, delMetadata)) {
|
|
213
|
+
hasChanges = true;
|
|
214
|
+
}
|
|
215
|
+
if (operation.type === 'replace') {
|
|
216
|
+
const inserted = processInsert(
|
|
217
|
+
xmlDoc,
|
|
218
|
+
liveSpanIndex,
|
|
219
|
+
operation.end,
|
|
220
|
+
operation.text.replace(/\n/g, ' '),
|
|
221
|
+
author,
|
|
222
|
+
formatHints,
|
|
223
|
+
operation.newPos,
|
|
224
|
+
generateRedlines,
|
|
225
|
+
allParagraphs[0] || null,
|
|
226
|
+
insMetadata,
|
|
227
|
+
options?.insertionAffinity || null,
|
|
228
|
+
options?.existingRevisions || 'merge-same-author'
|
|
229
|
+
);
|
|
230
|
+
if (inserted && typeof inserted === 'object' && inserted.error) {
|
|
231
|
+
return withOoxmlSourceType({
|
|
232
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
233
|
+
hasChanges: false,
|
|
234
|
+
status: 'error',
|
|
235
|
+
error: inserted.error
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (inserted === true) hasChanges = true;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
160
241
|
} else {
|
|
161
242
|
|
|
162
243
|
for (let i = 0; i < diffs.length; i++) {
|
|
@@ -267,7 +348,9 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
267
348
|
message: 'Generated OOXML accepted-view text does not match the requested modified text; the mutation was rejected.',
|
|
268
349
|
mismatchOffset,
|
|
269
350
|
expectedExcerpt: excerptAt(expectedText, mismatchOffset),
|
|
270
|
-
actualExcerpt: excerptAt(actualText, mismatchOffset)
|
|
351
|
+
actualExcerpt: excerptAt(actualText, mismatchOffset),
|
|
352
|
+
expectedCodePoint: codePointAtOffset(expectedText, mismatchOffset),
|
|
353
|
+
actualCodePoint: codePointAtOffset(actualText, mismatchOffset)
|
|
271
354
|
},
|
|
272
355
|
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
|
|
273
356
|
});
|
|
@@ -294,6 +377,68 @@ function excerptAt(text, offset, radius = 40) {
|
|
|
294
377
|
return text.slice(start, end);
|
|
295
378
|
}
|
|
296
379
|
|
|
380
|
+
function codePointAtOffset(text, offset) {
|
|
381
|
+
if (offset >= text.length) return 'END';
|
|
382
|
+
return `U+${text.codePointAt(offset).toString(16).toUpperCase().padStart(4, '0')}`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function refineSpaceEquivalentReplacements(diffs, diffOptions) {
|
|
386
|
+
const refined = [];
|
|
387
|
+
for (let index = 0; index < diffs.length; index++) {
|
|
388
|
+
const [op, text] = diffs[index];
|
|
389
|
+
const next = diffs[index + 1];
|
|
390
|
+
if (
|
|
391
|
+
op === -1
|
|
392
|
+
&& next?.[0] === 1
|
|
393
|
+
&& text !== next[1]
|
|
394
|
+
&& text.length === next[1].length
|
|
395
|
+
&& text.replace(/\u00a0/g, ' ') === next[1].replace(/\u00a0/g, ' ')
|
|
396
|
+
) {
|
|
397
|
+
refined.push(...computeCharacterDiffs(text, next[1], diffOptions));
|
|
398
|
+
index++;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
refined.push([op, text]);
|
|
402
|
+
}
|
|
403
|
+
return refined;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function collectTextEditOperations(diffs) {
|
|
407
|
+
const operations = [];
|
|
408
|
+
let originalPos = 0;
|
|
409
|
+
let newPos = 0;
|
|
410
|
+
for (let index = 0; index < diffs.length; index++) {
|
|
411
|
+
const [op, text] = diffs[index];
|
|
412
|
+
if (op === 0) {
|
|
413
|
+
originalPos += text.length;
|
|
414
|
+
newPos += text.length;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (op === -1) {
|
|
418
|
+
const next = diffs[index + 1];
|
|
419
|
+
if (next?.[0] === 1) {
|
|
420
|
+
operations.push({
|
|
421
|
+
type: 'replace',
|
|
422
|
+
start: originalPos,
|
|
423
|
+
end: originalPos + text.length,
|
|
424
|
+
newPos,
|
|
425
|
+
text: next[1]
|
|
426
|
+
});
|
|
427
|
+
originalPos += text.length;
|
|
428
|
+
newPos += next[1].length;
|
|
429
|
+
index++;
|
|
430
|
+
} else {
|
|
431
|
+
operations.push({ type: 'delete', start: originalPos, end: originalPos + text.length, newPos, text: '' });
|
|
432
|
+
originalPos += text.length;
|
|
433
|
+
}
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
operations.push({ type: 'insert', start: originalPos, end: originalPos, newPos, text });
|
|
437
|
+
newPos += text.length;
|
|
438
|
+
}
|
|
439
|
+
return operations;
|
|
440
|
+
}
|
|
441
|
+
|
|
297
442
|
function collectInsertionOperations(diffs) {
|
|
298
443
|
const operations = [];
|
|
299
444
|
let originalPos = 0;
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { refreshRunPropertyChangeIds } from '../core/revision-cloning.js';
|
|
9
9
|
import { getRunChildText, isTextLikeRunChild } from './surgical-spans.js';
|
|
10
10
|
|
|
11
|
-
const TRACK_CHANGE_CARRIERS = new Set(['ins']);
|
|
11
|
+
const TRACK_CHANGE_CARRIERS = new Set(['ins', 'del']);
|
|
12
12
|
|
|
13
13
|
export function getRunContentPieces(runElement) {
|
|
14
14
|
const pieces = [];
|
|
@@ -72,7 +72,7 @@ export function insertRunPiecesBefore(xmlDoc, parent, referenceNode, pieces, rPr
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
* Splits a run-level tracked-change carrier at
|
|
75
|
+
* Splits a run-level tracked-change carrier at its visible-view character
|
|
76
76
|
* offset without mutating the source carrier. The original revision ID stays
|
|
77
77
|
* with the leading fragment; an interior trailing fragment receives a fresh,
|
|
78
78
|
* document-scoped ID while all other carrier metadata remains unchanged.
|
|
@@ -86,7 +86,7 @@ export function insertRunPiecesBefore(xmlDoc, parent, referenceNode, pieces, rPr
|
|
|
86
86
|
export function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, allocator = null) {
|
|
87
87
|
const carrierName = getLocalName(carrierElement);
|
|
88
88
|
if (!TRACK_CHANGE_CARRIERS.has(carrierName)) {
|
|
89
|
-
throw new TypeError('splitTrackChangeCarrier requires a w:ins carrier.');
|
|
89
|
+
throw new TypeError('splitTrackChangeCarrier requires a w:ins or w:del carrier.');
|
|
90
90
|
}
|
|
91
91
|
if (!Number.isInteger(splitOffset) || splitOffset < 0) {
|
|
92
92
|
throw new RangeError('splitOffset must be a non-negative integer.');
|
|
@@ -129,17 +129,18 @@ export function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, all
|
|
|
129
129
|
} else {
|
|
130
130
|
const localOffset = splitOffset - offset;
|
|
131
131
|
const rPr = Array.from(child.childNodes || []).find(node => isWordElement(node, 'rPr')) || null;
|
|
132
|
-
const
|
|
133
|
-
const
|
|
132
|
+
const asDeletedText = carrierName === 'del';
|
|
133
|
+
const leftPieces = sliceRunPieces(xmlDoc, pieces, 0, localOffset, asDeletedText);
|
|
134
|
+
const rightPieces = sliceRunPieces(xmlDoc, pieces, localOffset, runLength, asDeletedText);
|
|
134
135
|
leftCarrier.appendChild(createRunFromPieces(xmlDoc, leftPieces, rPr));
|
|
135
136
|
const rightRun = createRunFromPieces(xmlDoc, rightPieces, rPr);
|
|
136
|
-
refreshRunPropertyChangeIds(rightRun, resolveAllocator(xmlDoc, allocator));
|
|
137
137
|
rightCarrier.appendChild(rightRun);
|
|
138
138
|
}
|
|
139
139
|
offset = runEnd;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
const resolvedAllocator = resolveAllocator(xmlDoc, allocator);
|
|
143
|
+
refreshRunPropertyChangeIds(rightCarrier, resolvedAllocator);
|
|
143
144
|
const nextId = resolvedAllocator.next();
|
|
144
145
|
setWordAttribute(rightCarrier, 'id', String(nextId));
|
|
145
146
|
resolvedAllocator._receiptCollector?.recordRevision(nextId, carrierName);
|
|
@@ -166,7 +167,7 @@ function getLocalName(element) {
|
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
|
|
169
|
-
if (asDeletedText) {
|
|
170
|
+
if (asDeletedText && (isWordElement(sourceNode, 'delText') || isWordElement(sourceNode, 't'))) {
|
|
170
171
|
const delText = createWordElement(xmlDoc, 'w:delText');
|
|
171
172
|
delText.setAttribute('xml:space', 'preserve');
|
|
172
173
|
delText.textContent = text;
|
|
@@ -192,6 +193,17 @@ function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
|
|
|
192
193
|
return sourceNode.cloneNode(true);
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
if (text === '\u00ad' && isWordElement(sourceNode, 'softHyphen')) {
|
|
197
|
+
return sourceNode.cloneNode(true);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (asDeletedText) {
|
|
201
|
+
const delText = createWordElement(xmlDoc, 'w:delText');
|
|
202
|
+
delText.setAttribute('xml:space', 'preserve');
|
|
203
|
+
delText.textContent = text;
|
|
204
|
+
return delText;
|
|
205
|
+
}
|
|
206
|
+
|
|
195
207
|
const textNode = createWordElement(xmlDoc, 'w:t');
|
|
196
208
|
textNode.setAttribute('xml:space', 'preserve');
|
|
197
209
|
textNode.textContent = text;
|
package/engine/surgical-spans.js
CHANGED
|
@@ -3,7 +3,7 @@ import { getFirstElementByTag } from '../core/xml-query.js';
|
|
|
3
3
|
import { isWordElement } from '../core/word-xml.js';
|
|
4
4
|
|
|
5
5
|
export function getRunChildText(child) {
|
|
6
|
-
if (isWordElement(child, 't')) return child.textContent || '';
|
|
6
|
+
if (isWordElement(child, 't') || isWordElement(child, 'delText')) return child.textContent || '';
|
|
7
7
|
if (isWordElement(child, 'br') || isWordElement(child, 'cr')) return '\n';
|
|
8
8
|
if (isWordElement(child, 'tab')) return '\t';
|
|
9
9
|
if (isWordElement(child, 'noBreakHyphen')) return '\u2011';
|
|
@@ -13,6 +13,7 @@ export function getRunChildText(child) {
|
|
|
13
13
|
|
|
14
14
|
export function isTextLikeRunChild(child) {
|
|
15
15
|
return isWordElement(child, 't')
|
|
16
|
+
|| isWordElement(child, 'delText')
|
|
16
17
|
|| isWordElement(child, 'br')
|
|
17
18
|
|| isWordElement(child, 'cr')
|
|
18
19
|
|| isWordElement(child, 'tab')
|
package/node/cli.js
CHANGED
|
@@ -8,13 +8,14 @@ import { configureLogger } from '../adapters/logger.js';
|
|
|
8
8
|
import { isExistingRevisionsPolicy } from '../services/document-operation-contract.js';
|
|
9
9
|
|
|
10
10
|
const suffixes = { apply: 'redlined', accept: 'accepted', reject: 'rejected', 'delete-comments': 'comments-removed' };
|
|
11
|
-
const CLI_CONTRACT_VERSION =
|
|
11
|
+
const CLI_CONTRACT_VERSION = 3;
|
|
12
12
|
const CLI_CAPABILITIES = [
|
|
13
13
|
'atomic-batch-results-on-package-failure',
|
|
14
14
|
'baseline-aware-validation',
|
|
15
|
+
'compact-mutation-results',
|
|
15
16
|
'cross-author-revision-slicing',
|
|
16
17
|
'document-scoped-list-revision-ids'
|
|
17
|
-
];
|
|
18
|
+
];
|
|
18
19
|
const commandOptions = {
|
|
19
20
|
version: new Set(['help']),
|
|
20
21
|
inspect: new Set(['help', 'search', 'revised', 'table', 'body', 'nonEmpty', 'index', 'indexes', 'range', 'view']),
|
|
@@ -162,10 +163,124 @@ async function writeMutation(command, input, flags, result) {
|
|
|
162
163
|
await writeFile(destination, result.toBuffer());
|
|
163
164
|
return { status: result.status || 'ok', ...result, outputPath: destination };
|
|
164
165
|
}
|
|
165
|
-
function serializable(value) {
|
|
166
|
-
const { buffer: _buffer, toBuffer: _toBuffer, ...rest } = value || {};
|
|
167
|
-
return rest;
|
|
168
|
-
}
|
|
166
|
+
function serializable(value) {
|
|
167
|
+
const { buffer: _buffer, toBuffer: _toBuffer, ...rest } = value || {};
|
|
168
|
+
return rest;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function boundedText(value, limit = 512) {
|
|
172
|
+
const text = String(value ?? '');
|
|
173
|
+
return text.length > limit ? `${text.slice(0, limit)}…` : text;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function compactError(error) {
|
|
177
|
+
if (!error || typeof error !== 'object') return error;
|
|
178
|
+
const fields = [
|
|
179
|
+
'code', 'stage', 'mismatchOffset', 'expectedExcerpt', 'actualExcerpt',
|
|
180
|
+
'expectedCodePoint', 'actualCodePoint', 'ownerAuthor', 'commentIds'
|
|
181
|
+
];
|
|
182
|
+
const compact = {};
|
|
183
|
+
for (const field of fields) {
|
|
184
|
+
if (error[field] !== undefined) compact[field] = error[field];
|
|
185
|
+
}
|
|
186
|
+
if (Array.isArray(error.comments)) {
|
|
187
|
+
compact.comments = error.comments.map(comment => ({
|
|
188
|
+
...(comment?.id !== undefined ? { id: comment.id } : {}),
|
|
189
|
+
...(comment?.author !== undefined ? { author: boundedText(comment.author, 160) } : {}),
|
|
190
|
+
...(comment?.text !== undefined ? { text: boundedText(comment.text, 512) } : {})
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
if (Array.isArray(error.candidates)) {
|
|
194
|
+
compact.candidates = error.candidates.map(compactResolvedTarget);
|
|
195
|
+
}
|
|
196
|
+
compact.message = boundedText(error.message || String(error));
|
|
197
|
+
return compact;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function compactResolvedTarget(target) {
|
|
201
|
+
if (!target || typeof target !== 'object') return target;
|
|
202
|
+
const { text: _text, exactText: _exactText, ...compact } = target;
|
|
203
|
+
return compact;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function compactReceipt(receipt) {
|
|
207
|
+
if (!receipt || typeof receipt !== 'object') return receipt;
|
|
208
|
+
return {
|
|
209
|
+
...receipt,
|
|
210
|
+
affectedTargets: Array.isArray(receipt.affectedTargets)
|
|
211
|
+
? receipt.affectedTargets.map(compactResolvedTarget)
|
|
212
|
+
: [],
|
|
213
|
+
warnings: Array.isArray(receipt.warnings) ? receipt.warnings.map(warning => boundedText(warning)) : []
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function compactOperationResult(result) {
|
|
218
|
+
if (!result || typeof result !== 'object') return result;
|
|
219
|
+
return {
|
|
220
|
+
...result,
|
|
221
|
+
...(result.resolvedTarget ? { resolvedTarget: compactResolvedTarget(result.resolvedTarget) } : {}),
|
|
222
|
+
...(result.resolvedAnchor ? { resolvedAnchor: compactResolvedTarget(result.resolvedAnchor) } : {}),
|
|
223
|
+
...(result.error ? { error: compactError(result.error) } : {}),
|
|
224
|
+
...(result.receipt ? { receipt: compactReceipt(result.receipt) } : {}),
|
|
225
|
+
...(Array.isArray(result.warnings) ? { warnings: result.warnings.map(warning => boundedText(warning)) } : {})
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function summarizeIssues(issues) {
|
|
230
|
+
const list = Array.isArray(issues) ? issues : [];
|
|
231
|
+
const grouped = new Map();
|
|
232
|
+
for (const issue of list) {
|
|
233
|
+
const code = issue?.code || 'UNKNOWN';
|
|
234
|
+
const source = issue?.source || 'unknown';
|
|
235
|
+
const severity = issue?.severity || 'error';
|
|
236
|
+
const key = `${source}:${severity}:${code}`;
|
|
237
|
+
const current = grouped.get(key) || { source, severity, code, count: 0 };
|
|
238
|
+
current.count++;
|
|
239
|
+
grouped.set(key, current);
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
total: list.length,
|
|
243
|
+
errors: list.filter(issue => issue?.severity === 'error').length,
|
|
244
|
+
warnings: list.filter(issue => issue?.severity === 'warning').length,
|
|
245
|
+
byCode: Array.from(grouped.values()).sort((a, b) => (
|
|
246
|
+
a.source.localeCompare(b.source) || a.code.localeCompare(b.code) || a.severity.localeCompare(b.severity)
|
|
247
|
+
))
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function compactMutationResult(value) {
|
|
252
|
+
const serialized = serializable(value);
|
|
253
|
+
const {
|
|
254
|
+
documentXml: _documentXml,
|
|
255
|
+
oxml: _oxml,
|
|
256
|
+
commentsXml: _commentsXml,
|
|
257
|
+
commentsExtendedXml: _commentsExtendedXml,
|
|
258
|
+
numberingXml: _numberingXml,
|
|
259
|
+
numberingXmlParts: _numberingXmlParts,
|
|
260
|
+
inspection: _inspection,
|
|
261
|
+
issues: _issues,
|
|
262
|
+
...compact
|
|
263
|
+
} = serialized;
|
|
264
|
+
const results = Array.isArray(compact.results) ? compact.results.map(compactOperationResult) : [];
|
|
265
|
+
const status = compact.status || 'ok';
|
|
266
|
+
return {
|
|
267
|
+
...compact,
|
|
268
|
+
...(Array.isArray(compact.results) ? { results } : {}),
|
|
269
|
+
...(Array.isArray(compact.receipts) ? { receipts: compact.receipts.map(compactReceipt) } : {}),
|
|
270
|
+
...(compact.error ? { error: compactError(compact.error) } : {}),
|
|
271
|
+
...(Array.isArray(compact.warnings) ? { warnings: compact.warnings.map(warning => boundedText(warning)) } : {}),
|
|
272
|
+
...(compact.validation ? {
|
|
273
|
+
validation: {
|
|
274
|
+
originalIssues: summarizeIssues(compact.validation.originalIssues),
|
|
275
|
+
generatedIssues: summarizeIssues(compact.validation.generatedIssues)
|
|
276
|
+
}
|
|
277
|
+
} : {}),
|
|
278
|
+
completion: compact.written === true
|
|
279
|
+
&& status !== 'error'
|
|
280
|
+
&& status !== 'partial'
|
|
281
|
+
&& results.every(result => result?.status !== 'error')
|
|
282
|
+
};
|
|
283
|
+
}
|
|
169
284
|
|
|
170
285
|
async function collectValidationIssues(buffer) {
|
|
171
286
|
const entries = unzipDocx(buffer);
|
|
@@ -301,17 +416,17 @@ export async function executeCli(argv) {
|
|
|
301
416
|
...(expectedRevision ? { expectedRevision } : {})
|
|
302
417
|
});
|
|
303
418
|
const mutationResult = await writeMutation(command, input, flags, result);
|
|
304
|
-
return {
|
|
305
|
-
command,
|
|
306
|
-
input,
|
|
307
|
-
...serializable(mutationResult),
|
|
308
|
-
...(result.status === 'error' || result.error ? { exitCode: 2 } : {})
|
|
309
|
-
};
|
|
419
|
+
return compactMutationResult({
|
|
420
|
+
command,
|
|
421
|
+
input,
|
|
422
|
+
...serializable(mutationResult),
|
|
423
|
+
...(result.status === 'error' || result.error ? { exitCode: 2 } : {})
|
|
424
|
+
});
|
|
310
425
|
}
|
|
311
426
|
const filter = flags.allAuthors ? { allAuthors: true } : flags.author ? { author: String(flags.author) } : null;
|
|
312
427
|
if (!filter) return cliError('AUTHOR_REQUIRED', 'Use --author <name> or --all-authors.');
|
|
313
428
|
const result = command === 'delete-comments' ? await document.deleteComments(filter) : await document.resolveRevisions(command, filter);
|
|
314
|
-
return { command, input, ...serializable(await writeMutation(command, input, flags, result)) };
|
|
429
|
+
return compactMutationResult({ command, input, ...serializable(await writeMutation(command, input, flags, result)) });
|
|
315
430
|
} catch (error) { return cliError(error.code || 'CLI_FAILED', error.message); }
|
|
316
431
|
}
|
|
317
432
|
|
package/node/docx-document.js
CHANGED
|
@@ -5,7 +5,8 @@ import { inspectDocumentParts } from '../services/document-inspection.js';
|
|
|
5
5
|
import { applyOperationsToDocumentXml, preflightOperations } from '../services/standalone-operation-runner.js';
|
|
6
6
|
import { createDynamicNumberingIdState, mergeNumberingXmlBySchemaOrder } from '../services/numbering-helpers.js';
|
|
7
7
|
import { ensureCommentsArtifactsInZip, ensureCommentsExtendedArtifactsInZip, ensureNumberingArtifactsInZip, validateDocxPackage } from '../services/standalone-docx-plumbing.js';
|
|
8
|
-
import { validateRedlineOoxml } from '../core/redline-validation.js';
|
|
8
|
+
import { validateRedlineOoxml } from '../core/redline-validation.js';
|
|
9
|
+
import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
|
|
9
10
|
import { acceptTrackedChangesInOoxml, rejectTrackedChangesInOoxml, deleteCommentsByAuthorInOoxml } from '../services/revision-comment-management.js';
|
|
10
11
|
import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
11
12
|
import { createHash } from 'node:crypto';
|
|
@@ -176,19 +177,21 @@ export class DocxDocument {
|
|
|
176
177
|
await ensureCommentsExtendedArtifactsInZip(zip, commentsExtendedXmlForPackaging, {
|
|
177
178
|
replaceExisting: result.commentsExtendedXmlMode === 'replace' || (!result.commentsExtendedXml && !!existingCommentsExtendedXml)
|
|
178
179
|
});
|
|
179
|
-
if (options.validate !== false) {
|
|
180
|
-
const generated = validateRedlineOoxml(result.documentXml);
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
180
|
+
if (options.validate !== false) {
|
|
181
|
+
const generated = validateRedlineOoxml(result.documentXml);
|
|
182
|
+
const outputIssues = generated.issues.map(issue => ({ source: 'word/document.xml', ...issue }));
|
|
183
|
+
try { await validateDocxPackage(zip); }
|
|
184
|
+
catch (error) { outputIssues.push({ source: 'package', code: 'PACKAGE_VALIDATION', severity: 'error', message: error.message }); }
|
|
185
|
+
const introduced = subtractValidationIssueMultiset(outputIssues, originalIssues);
|
|
186
|
+
const introducedErrors = validationErrors(introduced);
|
|
187
|
+
if (introducedErrors.length) {
|
|
188
|
+
const codes = [...new Set(introducedErrors.map(issue => issue.code))].join(', ');
|
|
189
|
+
throw Object.assign(
|
|
190
|
+
new Error(`Applied operations introduced invalid revision markup (${codes}); these are generated-output issues, not pre-existing input issues.`),
|
|
191
|
+
{ issues: introducedErrors }
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
192
195
|
this.entries = working;
|
|
193
196
|
const output = this.toBuffer();
|
|
194
197
|
this.originalBuffer = Buffer.from(output);
|
package/package.json
CHANGED
package/pipeline/diff-engine.js
CHANGED
|
@@ -232,6 +232,21 @@ export function computeWordDiffs(originalText, newText, options = {}) {
|
|
|
232
232
|
return decodeBmpDiffs(charDiffs, wordArray);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Computes a character-local diff without semantic cleanup. This is used to
|
|
237
|
+
* refine whitespace-only substitutions that a word-level token groups with
|
|
238
|
+
* adjacent unchanged content (for example, an NBSP beside a hyperlink).
|
|
239
|
+
*
|
|
240
|
+
* @param {string} originalText
|
|
241
|
+
* @param {string} newText
|
|
242
|
+
* @param {{ diffTimeoutSeconds?: number }} [options={}]
|
|
243
|
+
* @returns {Array<[number, string]>}
|
|
244
|
+
*/
|
|
245
|
+
export function computeCharacterDiffs(originalText, newText, options = {}) {
|
|
246
|
+
if (originalText === newText) return [[0, originalText]];
|
|
247
|
+
return createDiffEngine(options).diff_main(originalText, newText);
|
|
248
|
+
}
|
|
249
|
+
|
|
235
250
|
/**
|
|
236
251
|
* Returns a character-local diff only when the modified string can be made
|
|
237
252
|
* solely by inserting into the original. This prevents word-token cleanup
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getDefaultAuthor } from '../adapters/config.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
3
|
normalizeDocumentOperation,
|
|
4
4
|
resolveDocumentOperationAuthor,
|
|
5
5
|
validateDocumentOperation
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
applyFormattingToParagraphByExactText,
|
|
16
16
|
applyHighlightToParagraphByExactText,
|
|
17
17
|
applyParagraphFormatToParagraphByExactText,
|
|
18
|
+
insertIntoRejectedDeletedText,
|
|
18
19
|
restoreDeletedParagraphByExactText,
|
|
19
20
|
applyToParagraphByExactText
|
|
20
21
|
} from './document-operation-mutations.js';
|
|
@@ -26,7 +27,9 @@ import {
|
|
|
26
27
|
import {
|
|
27
28
|
createEmptyReceipt,
|
|
28
29
|
reconcileReceiptsAgainstOutput
|
|
29
|
-
} from './receipt-collector.js';
|
|
30
|
+
} from './receipt-collector.js';
|
|
31
|
+
import { validateRedlineOoxml } from '../core/redline-validation.js';
|
|
32
|
+
import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
|
|
30
33
|
|
|
31
34
|
export function normalizeOperationError(error) {
|
|
32
35
|
return {
|
|
@@ -61,6 +64,8 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
61
64
|
|
|
62
65
|
if (
|
|
63
66
|
operation.operationKind !== 'comment_reply'
|
|
67
|
+
&& operation.operationKind !== 'rejected-insert'
|
|
68
|
+
&& operation.operationKind !== 'restore'
|
|
64
69
|
&& (
|
|
65
70
|
operation.targetDescriptor?.revisionView === 'rejected'
|
|
66
71
|
|| operation.targetEndDescriptor?.revisionView === 'rejected'
|
|
@@ -250,6 +255,17 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
250
255
|
runtimeContext,
|
|
251
256
|
operationOptions
|
|
252
257
|
);
|
|
258
|
+
} else if (operation.operationKind === 'rejected-insert') {
|
|
259
|
+
result = await insertIntoRejectedDeletedText(
|
|
260
|
+
documentXml,
|
|
261
|
+
operation.target,
|
|
262
|
+
operation.anchor,
|
|
263
|
+
operation.modified,
|
|
264
|
+
authorUsed,
|
|
265
|
+
operation.targetRef,
|
|
266
|
+
runtimeContext,
|
|
267
|
+
operationOptions
|
|
268
|
+
);
|
|
253
269
|
} else if (operation.operationKind === 'restore') {
|
|
254
270
|
result = await restoreDeletedParagraphByExactText(
|
|
255
271
|
documentXml,
|
|
@@ -311,8 +327,37 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
311
327
|
operationReceipt.warnings.push(String(w));
|
|
312
328
|
}
|
|
313
329
|
}
|
|
314
|
-
} else {
|
|
315
|
-
|
|
330
|
+
} else {
|
|
331
|
+
const beforeValidation = validateRedlineOoxml(savepoint.document);
|
|
332
|
+
const afterValidation = validateRedlineOoxml(session.document);
|
|
333
|
+
const generatedIssues = subtractValidationIssueMultiset(afterValidation.issues, beforeValidation.issues);
|
|
334
|
+
const generatedErrors = validationErrors(generatedIssues);
|
|
335
|
+
if (generatedErrors.length > 0) {
|
|
336
|
+
session.restoreSavepoint(savepoint);
|
|
337
|
+
operationReceipt = createEmptyReceipt(
|
|
338
|
+
operationIndex,
|
|
339
|
+
operation.operationId,
|
|
340
|
+
authorUsed,
|
|
341
|
+
'refused'
|
|
342
|
+
);
|
|
343
|
+
const codes = [...new Set(generatedErrors.map(issue => issue.code))].join(', ');
|
|
344
|
+
return {
|
|
345
|
+
documentXml,
|
|
346
|
+
hasChanges: false,
|
|
347
|
+
status: 'error',
|
|
348
|
+
error: {
|
|
349
|
+
code: 'GENERATED_OOXML_INVALID',
|
|
350
|
+
stage: 'validation',
|
|
351
|
+
message: `Operation introduced invalid OOXML (${codes}).`,
|
|
352
|
+
generatedIssues: generatedErrors
|
|
353
|
+
},
|
|
354
|
+
operationType: operation.operationKind,
|
|
355
|
+
authorUsed,
|
|
356
|
+
receipt: operationReceipt,
|
|
357
|
+
...resolutionCapture
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
session.markMutationCommitted(operation.operationKind !== 'comment_reply');
|
|
316
361
|
if (operation.captureKey && session.captureTable) {
|
|
317
362
|
session.captureTable.set(
|
|
318
363
|
operation.captureKey,
|