@ansonlai/docx-redline-js 0.5.2 → 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.
@@ -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,10 @@
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" } } } ]
100
+ },
101
+ {
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"] } } } ]
90
103
  },
91
104
  {
92
105
  "allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "properties": { "type": { "const": "delete" }, "modified": { "const": "" } } } ]
@@ -40,6 +40,7 @@ import { getDocumentParagraphs } from './format-extraction.js';
40
40
  import { isDiffTokenLimitError } from '../pipeline/diff-engine.js';
41
41
  import { NumberingService } from '../services/numbering-service.js';
42
42
  import { recordRouteSelection } from './route-selection.js';
43
+ import { inspectForeignDeletedParagraphTarget } from '../core/paragraph-revision-safety.js';
43
44
 
44
45
  function getCommentIdsInOoxml(node) {
45
46
  const ids = new Set();
@@ -138,9 +139,29 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
138
139
  const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
139
140
  ? options._revisionIdAllocator
140
141
  : new RevisionIdAllocator();
141
- seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
142
-
143
- if (containsTrackedChanges(xmlDoc)) {
142
+ seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
143
+
144
+ const inputParagraphs = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
145
+ ? [xmlDoc.documentElement]
146
+ : getDocumentParagraphs(xmlDoc);
147
+ if (inputParagraphs.length === 1 && modifiedText.length > 0) {
148
+ const resurrectionTarget = inspectForeignDeletedParagraphTarget(inputParagraphs[0], author);
149
+ if (resurrectionTarget.matches) {
150
+ const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
151
+ return finalize({
152
+ oxml: inputOoxml,
153
+ hasChanges: false,
154
+ status: 'error',
155
+ error: {
156
+ code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
157
+ message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
158
+ ownerAuthor
159
+ }
160
+ });
161
+ }
162
+ }
163
+
164
+ if (containsTrackedChanges(xmlDoc)) {
144
165
  if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
145
166
  const authors = getTrackedChangeAuthors(xmlDoc);
146
167
  const currentAuthor = String(author || '').trim().toLowerCase();
@@ -161,7 +161,18 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
161
161
  if (delWrapper && record.deletedPieces.length > 0) {
162
162
  delWrapper.appendChild(createRunFromPieces(xmlDoc, record.deletedPieces, record.rPr));
163
163
  }
164
- insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
164
+ const afterRun = insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
165
+ if (
166
+ record.globalEnd === endPos
167
+ && !isWordElement(parent, 'ins')
168
+ ) {
169
+ if (!spanIndex.replacementInsertionAnchors) spanIndex.replacementInsertionAnchors = new Map();
170
+ spanIndex.replacementInsertionAnchors.set(endPos, {
171
+ parent,
172
+ referenceNode: afterRun || runElement.nextSibling,
173
+ rPr: record.rPr
174
+ });
175
+ }
165
176
  parent.removeChild(runElement);
166
177
  changed = true;
167
178
  }
@@ -209,6 +220,29 @@ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints
209
220
  );
210
221
  }
211
222
 
223
+ const replacementAnchor = spanIndex.replacementInsertionAnchors?.get(pos) || null;
224
+ if (
225
+ replacementAnchor
226
+ && !affinity
227
+ && isConnected(replacementAnchor.parent)
228
+ && (!replacementAnchor.referenceNode || replacementAnchor.referenceNode.parentNode === replacementAnchor.parent)
229
+ ) {
230
+ spanIndex.replacementInsertionAnchors.delete(pos);
231
+ insertTextRuns(
232
+ xmlDoc,
233
+ replacementAnchor.parent,
234
+ replacementAnchor.referenceNode,
235
+ text,
236
+ replacementAnchor.rPr,
237
+ author,
238
+ formatHints,
239
+ insertOffset,
240
+ generateRedlines,
241
+ revisionMetadata
242
+ );
243
+ return true;
244
+ }
245
+
212
246
  if (!affinity) {
213
247
  let targetSpan = findContainingSpan(spanIndex, pos);
214
248
 
@@ -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 || computeWordDiffs(fullText, modifiedText, diffOptions);
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 = [];
@@ -124,7 +127,120 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
124
127
  let newPos = 0;
125
128
  let hasChanges = false;
126
129
 
127
- for (let i = 0; i < diffs.length; i++) {
130
+ const insertionOperations = insertionOnlyDiffs
131
+ ? collectInsertionOperations(insertionOnlyDiffs)
132
+ : [];
133
+ if (insertionOperations.length > 1 && formatHints.length === 0) {
134
+ for (const operation of insertionOperations.slice().reverse()) {
135
+ const liveSpans = buildSurgicalTextSpans(allParagraphs).textSpans;
136
+ const liveSpanIndex = buildSpanIndex(liveSpans);
137
+ const textWithoutNewlines = operation.text.replace(/\n/g, ' ');
138
+ if (textWithoutNewlines.length === 0) continue;
139
+ const insertResult = processInsert(
140
+ xmlDoc,
141
+ liveSpanIndex,
142
+ operation.originalPos,
143
+ textWithoutNewlines,
144
+ author,
145
+ formatHints,
146
+ operation.newPos,
147
+ generateRedlines,
148
+ allParagraphs[0] || null,
149
+ null,
150
+ options?.insertionAffinity || null,
151
+ options?.existingRevisions || 'merge-same-author'
152
+ );
153
+ if (insertResult && typeof insertResult === 'object' && insertResult.error) {
154
+ return withOoxmlSourceType({
155
+ oxml: serializer.serializeToString(xmlDoc),
156
+ hasChanges: false,
157
+ status: 'error',
158
+ error: insertResult.error
159
+ });
160
+ }
161
+ if (insertResult === true) hasChanges = true;
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
+ }
241
+ } else {
242
+
243
+ for (let i = 0; i < diffs.length; i++) {
128
244
  const [op, text] = diffs[i];
129
245
  if (op === 0) {
130
246
  const len = text.length;
@@ -216,6 +332,7 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
216
332
  }
217
333
  newPos += text.length;
218
334
  }
335
+ }
219
336
  }
220
337
 
221
338
  const actualText = allParagraphs.map(paragraph => extractCanonicalParagraphText(paragraph)).join('\n');
@@ -231,7 +348,9 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
231
348
  message: 'Generated OOXML accepted-view text does not match the requested modified text; the mutation was rejected.',
232
349
  mismatchOffset,
233
350
  expectedExcerpt: excerptAt(expectedText, mismatchOffset),
234
- actualExcerpt: excerptAt(actualText, mismatchOffset)
351
+ actualExcerpt: excerptAt(actualText, mismatchOffset),
352
+ expectedCodePoint: codePointAtOffset(expectedText, mismatchOffset),
353
+ actualCodePoint: codePointAtOffset(actualText, mismatchOffset)
235
354
  },
236
355
  ...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
237
356
  });
@@ -257,3 +376,83 @@ function excerptAt(text, offset, radius = 40) {
257
376
  const end = Math.min(text.length, offset + radius);
258
377
  return text.slice(start, end);
259
378
  }
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
+
442
+ function collectInsertionOperations(diffs) {
443
+ const operations = [];
444
+ let originalPos = 0;
445
+ let newPos = 0;
446
+ for (const [op, text] of diffs) {
447
+ if (op === 0) {
448
+ originalPos += text.length;
449
+ newPos += text.length;
450
+ } else if (op === -1) {
451
+ originalPos += text.length;
452
+ } else if (op === 1) {
453
+ operations.push({ originalPos, newPos, text });
454
+ newPos += text.length;
455
+ }
456
+ }
457
+ return operations;
458
+ }
@@ -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 an accepted-view character
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 leftPieces = sliceRunPieces(xmlDoc, pieces, 0, localOffset, false);
133
- const rightPieces = sliceRunPieces(xmlDoc, pieces, localOffset, runLength, false);
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;
@@ -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/index.d.ts CHANGED
@@ -29,6 +29,7 @@ export type {
29
29
  OperationPreflightResult,
30
30
  ParagraphTargetDescriptor,
31
31
  RedlineDocumentOperation,
32
+ RestoreDocumentOperation,
32
33
  ResolvedCommentAnchor,
33
34
  ResolvedDocumentTarget,
34
35
  StandaloneRunnerOptions
@@ -43,6 +44,13 @@ export interface RedlineError {
43
44
  | 'UNSUPPORTED_REVISION_VIEW_MUTATION'
44
45
  | 'UNSUPPORTED_INSERTION_AFFINITY'
45
46
  | 'UNSAFE_PARAGRAPH_BOUNDARY'
47
+ | 'FOREIGN_PARAGRAPH_MARK_DELETION'
48
+ | 'RESTORATION_STATE_REQUIRED'
49
+ | 'RESTORATION_COUNT_MISMATCH'
50
+ | 'UNSAFE_DELETED_TABLE_ROW'
51
+ | 'UNSUPPORTED_MOVE_REVISION'
52
+ | 'SECTION_BREAK_PARAGRAPH'
53
+ | 'UNSAFE_PARAGRAPH_PLACEMENT'
46
54
  | 'TARGET_INDEX_MISMATCH'
47
55
  | 'REVISION_MISMATCH'
48
56
  | 'REVISION_TOKEN_SCOPE_MISMATCH'
@@ -54,6 +62,10 @@ export interface RedlineError {
54
62
  | 'CAPTURE_STALE'
55
63
  | string;
56
64
  message: string;
65
+ ownerAuthor?: string;
66
+ stage?: string;
67
+ expected?: unknown;
68
+ actual?: unknown;
57
69
  commentIds?: string[];
58
70
  comments?: Array<{ id: string; author: string; text: string }>;
59
71
  }
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 = 2;
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