@ansonlai/docx-redline-js 0.5.0 → 0.5.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.
@@ -12,7 +12,8 @@ import {
12
12
  getRunContentPieces,
13
13
  getRunTextLength,
14
14
  insertRunPiecesBefore,
15
- sliceRunPieces
15
+ sliceRunPieces,
16
+ splitTrackChangeCarrier
16
17
  } from './surgical-run-splitting.js';
17
18
  import {
18
19
  findContainingSpan,
@@ -86,8 +87,7 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
86
87
  spansByRun.get(span.runElement).push(span);
87
88
  });
88
89
 
89
- let changed = false;
90
- let usedDelMetadata = false;
90
+ const records = [];
91
91
  spansByRun.forEach((runSpans, runElement) => {
92
92
  const parent = runElement.parentNode;
93
93
  if (!parent) return;
@@ -111,31 +111,104 @@ export function processDelete(xmlDoc, spanIndex, startPos, endPos, author, gener
111
111
 
112
112
  if (!Number.isFinite(deleteStart) || deleteEnd <= deleteStart) return;
113
113
 
114
- const beforePieces = sliceRunPieces(xmlDoc, pieces, 0, deleteStart, false);
115
- const deletedPieces = sliceRunPieces(xmlDoc, pieces, deleteStart, deleteEnd, true);
116
- const afterPieces = sliceRunPieces(xmlDoc, pieces, deleteEnd, getRunTextLength(pieces), false);
114
+ records.push({
115
+ runElement,
116
+ parent,
117
+ rPr: runSpans[0].rPr,
118
+ beforePieces: sliceRunPieces(xmlDoc, pieces, 0, deleteStart, false),
119
+ deletedPieces: sliceRunPieces(xmlDoc, pieces, deleteStart, deleteEnd, true),
120
+ afterPieces: sliceRunPieces(xmlDoc, pieces, deleteEnd, getRunTextLength(pieces), false),
121
+ globalStart: Math.max(startPos, Math.min(...runSpans.map(span => span.charStart))),
122
+ globalEnd: Math.min(endPos, Math.max(...runSpans.map(span => span.charEnd))),
123
+ carrierGlobalStart: isWordElement(parent, 'ins') ? getCarrierGlobalStart(spanIndex, parent) : null
124
+ });
125
+ });
117
126
 
118
- insertRunPiecesBefore(xmlDoc, parent, runElement, beforePieces, runSpans[0].rPr);
127
+ const groups = [];
128
+ for (const record of records) {
129
+ const previousGroup = groups[groups.length - 1];
130
+ const previousRecord = previousGroup?.[previousGroup.length - 1];
131
+ if (
132
+ previousRecord
133
+ && previousRecord.parent === record.parent
134
+ && nextElementSibling(previousRecord.runElement) === record.runElement
135
+ ) {
136
+ previousGroup.push(record);
137
+ } else {
138
+ groups.push([record]);
139
+ }
140
+ }
119
141
 
120
- if (generateRedlines && deletedPieces.length > 0) {
121
- const delRun = createRunFromPieces(xmlDoc, deletedPieces, runSpans[0].rPr);
142
+ let changed = false;
143
+ let usedDelMetadata = false;
144
+ for (const group of groups) {
145
+ const firstRecord = group[0];
146
+ let delWrapper = null;
147
+ if (generateRedlines && group.some(record => record.deletedPieces.length > 0)) {
122
148
  const metadata = revisionMetadata
123
- ? (usedDelMetadata ? { ...revisionMetadata, id: createRevisionMetadata(author, xmlDoc).id } : revisionMetadata)
149
+ ? (usedDelMetadata ? { ...revisionMetadata, id: createRevisionMetadata(author, xmlDoc, 'del').id } : revisionMetadata)
124
150
  : null;
125
151
  usedDelMetadata = true;
126
- const delWrapper = createTrackChange(xmlDoc, 'del', delRun, author, metadata);
127
- parent.insertBefore(delWrapper, runElement);
152
+ delWrapper = createTrackChange(xmlDoc, 'del', null, author, metadata);
128
153
  }
129
154
 
130
- insertRunPiecesBefore(xmlDoc, parent, runElement, afterPieces, runSpans[0].rPr);
131
- parent.removeChild(runElement);
132
- changed = true;
133
- });
155
+ for (const record of group) {
156
+ const { parent, runElement } = record;
157
+ insertRunPiecesBefore(xmlDoc, parent, runElement, record.beforePieces, record.rPr);
158
+ if (delWrapper && record === firstRecord) {
159
+ parent.insertBefore(delWrapper, runElement);
160
+ }
161
+ if (delWrapper && record.deletedPieces.length > 0) {
162
+ delWrapper.appendChild(createRunFromPieces(xmlDoc, record.deletedPieces, record.rPr));
163
+ }
164
+ insertRunPiecesBefore(xmlDoc, parent, runElement, record.afterPieces, record.rPr);
165
+ parent.removeChild(runElement);
166
+ changed = true;
167
+ }
168
+
169
+ const carrier = isWordElement(firstRecord.parent, 'ins') ? firstRecord.parent : null;
170
+ const groupEnd = Math.max(...group.map(record => record.globalEnd));
171
+ if (carrier && groupEnd === endPos) {
172
+ const carrierStart = firstRecord.carrierGlobalStart;
173
+ const deletedBeforeEnd = group
174
+ .filter(record => record.globalStart < endPos)
175
+ .reduce((sum, record) => sum + record.deletedPieces.reduce((n, piece) => n + (piece.textContent || '').length, 0), 0);
176
+ const currentOffset = Math.max(0, endPos - carrierStart - deletedBeforeEnd);
177
+ if (!spanIndex.revisionInsertionAnchors) spanIndex.revisionInsertionAnchors = new Map();
178
+ spanIndex.revisionInsertionAnchors.set(endPos, {
179
+ carrier,
180
+ splitOffset: currentOffset,
181
+ rPr: firstRecord.rPr
182
+ });
183
+ }
184
+ }
134
185
 
135
186
  return changed;
136
187
  }
137
188
 
138
- export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null, revisionMetadata = null, affinity = null) {
189
+ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints = [], insertOffset = 0, generateRedlines = true, fallbackParagraph = null, revisionMetadata = null, affinity = null, existingRevisions = 'merge-same-author') {
190
+ const mutationAnchor = spanIndex.revisionInsertionAnchors?.get(pos) || null;
191
+ if (
192
+ mutationAnchor
193
+ && existingRevisions === 'slice-cross-author'
194
+ && isConnected(mutationAnchor.carrier)
195
+ && isForeignInsertion(mutationAnchor.carrier, author)
196
+ ) {
197
+ spanIndex.revisionInsertionAnchors.delete(pos);
198
+ return spliceInsertionAtCarrierOffset(
199
+ xmlDoc,
200
+ mutationAnchor.carrier,
201
+ mutationAnchor.splitOffset,
202
+ text,
203
+ mutationAnchor.rPr,
204
+ author,
205
+ formatHints,
206
+ insertOffset,
207
+ generateRedlines,
208
+ revisionMetadata
209
+ );
210
+ }
211
+
139
212
  if (!affinity) {
140
213
  let targetSpan = findContainingSpan(spanIndex, pos);
141
214
 
@@ -164,6 +237,25 @@ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints
164
237
  return true;
165
238
  }
166
239
 
240
+ if (
241
+ generateRedlines
242
+ && existingRevisions === 'slice-cross-author'
243
+ && isForeignInsertion(parent, author)
244
+ ) {
245
+ return spliceInsertionAtCarrierOffset(
246
+ xmlDoc,
247
+ parent,
248
+ getCarrierSplitOffset(spanIndex, parent, pos),
249
+ text,
250
+ targetSpan.rPr,
251
+ author,
252
+ formatHints,
253
+ insertOffset,
254
+ generateRedlines,
255
+ revisionMetadata
256
+ );
257
+ }
258
+
167
259
  const pieces = getRunContentPieces(targetSpan.runElement);
168
260
  const targetPiece = pieces.find(piece => piece.node === targetSpan.textElement);
169
261
  const localInsertPos = targetPiece
@@ -357,10 +449,90 @@ export function processInsert(xmlDoc, spanIndex, pos, text, author, formatHints
357
449
  }
358
450
  }
359
451
 
452
+ if (
453
+ generateRedlines
454
+ && existingRevisions === 'slice-cross-author'
455
+ && isForeignInsertion(parent, author)
456
+ ) {
457
+ return spliceInsertionAtCarrierOffset(
458
+ xmlDoc,
459
+ parent,
460
+ getCarrierSplitOffset(spanIndex, parent, pos),
461
+ text,
462
+ baseRPr,
463
+ author,
464
+ formatHints,
465
+ insertOffset,
466
+ generateRedlines,
467
+ revisionMetadata
468
+ );
469
+ }
470
+
360
471
  insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata);
361
472
  return true;
362
473
  }
363
474
 
475
+ function spliceInsertionAtCarrierOffset(xmlDoc, carrier, splitOffset, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata) {
476
+ const parent = carrier.parentNode;
477
+ if (!parent) return false;
478
+
479
+ const { leftCarrier, rightCarrier } = splitTrackChangeCarrier(xmlDoc, carrier, splitOffset);
480
+ if (leftCarrier) parent.insertBefore(leftCarrier, carrier);
481
+ insertTextRuns(
482
+ xmlDoc,
483
+ parent,
484
+ carrier,
485
+ text,
486
+ withoutRunPropertyChanges(baseRPr),
487
+ author,
488
+ formatHints,
489
+ insertOffset,
490
+ generateRedlines,
491
+ revisionMetadata
492
+ );
493
+ if (rightCarrier) parent.insertBefore(rightCarrier, carrier);
494
+ parent.removeChild(carrier);
495
+ return true;
496
+ }
497
+
498
+ function withoutRunPropertyChanges(rPr) {
499
+ if (!rPr) return null;
500
+ const clone = rPr.cloneNode(true);
501
+ const changes = Array.from(clone.getElementsByTagName?.('*') || [])
502
+ .filter(node => isWordElement(node, 'rPrChange'));
503
+ changes.forEach(node => node.parentNode?.removeChild(node));
504
+ return clone;
505
+ }
506
+
507
+ function getCarrierSplitOffset(spanIndex, carrier, pos) {
508
+ const carrierStart = getCarrierGlobalStart(spanIndex, carrier);
509
+ const carrierLength = spanIndex.spans
510
+ .filter(span => span.runElement?.parentNode === carrier)
511
+ .reduce((length, span) => length + (span.charEnd - span.charStart), 0);
512
+ return Math.max(0, Math.min(pos - carrierStart, carrierLength));
513
+ }
514
+
515
+ function getCarrierGlobalStart(spanIndex, carrier) {
516
+ const carrierSpans = spanIndex.spans.filter(span => span.runElement?.parentNode === carrier);
517
+ return carrierSpans.length > 0 ? Math.min(...carrierSpans.map(span => span.charStart)) : 0;
518
+ }
519
+
520
+ function isForeignInsertion(node, author) {
521
+ if (!isWordElement(node, 'ins')) return false;
522
+ const carrierAuthor = node.getAttribute('w:author') || node.getAttributeNS?.(NS_W, 'author') || '';
523
+ return carrierAuthor.trim().toLowerCase() !== String(author || '').trim().toLowerCase();
524
+ }
525
+
526
+ function nextElementSibling(node) {
527
+ let sibling = node?.nextSibling || null;
528
+ while (sibling && sibling.nodeType !== 1) sibling = sibling.nextSibling;
529
+ return sibling;
530
+ }
531
+
532
+ function isConnected(node) {
533
+ return !!node?.parentNode;
534
+ }
535
+
364
536
  function insertTextRuns(xmlDoc, parent, referenceNode, text, baseRPr, author, formatHints, insertOffset, generateRedlines, revisionMetadata = null) {
365
537
  const applicableHints = getApplicableFormatHints(formatHints, insertOffset, insertOffset + text.length);
366
538
 
@@ -17,7 +17,7 @@ import {
17
17
  import { withOoxmlSourceType } from '../core/word-xml.js';
18
18
  import { createReplacementRevisionEvent } from '../core/types.js';
19
19
 
20
- function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos) {
20
+ function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos, allowInsertionCarrier = false) {
21
21
  const spans = [];
22
22
  forEachOverlappingSpan(spanIndex, startPos, endPos, span => spans.push(span));
23
23
  if (spans.length === 0) return { safe: false };
@@ -32,7 +32,10 @@ function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos) {
32
32
 
33
33
  // Parent container itself cannot be an existing revision or unsupported container
34
34
  const parentLocal = (parent.localName || parent.nodeName.replace(/^.*:/, ''));
35
- if (['hyperlink', 'sdt', 'ins', 'del', 'moveFrom', 'moveTo'].includes(parentLocal)) {
35
+ if (
36
+ ['hyperlink', 'sdt', 'del', 'moveFrom', 'moveTo'].includes(parentLocal)
37
+ || (parentLocal === 'ins' && !allowInsertionCarrier)
38
+ ) {
36
39
  return { safe: false, structuralBoundary: true };
37
40
  }
38
41
 
@@ -149,7 +152,12 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
149
152
  const nextText = diffs[i + 1][1];
150
153
  const textWithoutNewlines = nextText.replace(/\n/g, ' ');
151
154
  if (textWithoutNewlines.trim().length > 0) {
152
- const checkResult = checkSafeAdjacencyForPairing(spanIndex, originalPos, originalPos + text.length);
155
+ const checkResult = checkSafeAdjacencyForPairing(
156
+ spanIndex,
157
+ originalPos,
158
+ originalPos + text.length,
159
+ options?.existingRevisions === 'slice-cross-author'
160
+ );
153
161
  if (checkResult.safe) {
154
162
  const event = createReplacementRevisionEvent(author, xmlDoc);
155
163
  delMetadata = { id: event.deletionId, author: event.author, date: event.date };
@@ -171,7 +179,7 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
171
179
  const [, nextText] = diffs[i];
172
180
  const textWithoutNewlines = nextText.replace(/\n/g, ' ');
173
181
  if (textWithoutNewlines.trim().length > 0) {
174
- const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null);
182
+ const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null, options?.existingRevisions || 'merge-same-author');
175
183
  if (insertResult && typeof insertResult === 'object' && insertResult.error) {
176
184
  return withOoxmlSourceType({
177
185
  oxml: serializer.serializeToString(xmlDoc),
@@ -189,7 +197,7 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
189
197
  } else if (op === 1) {
190
198
  const textWithoutNewlines = text.replace(/\n/g, ' ');
191
199
  if (textWithoutNewlines.trim().length > 0) {
192
- const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null);
200
+ const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null, options?.existingRevisions || 'merge-same-author');
193
201
  if (insertResult && typeof insertResult === 'object' && insertResult.error) {
194
202
  return withOoxmlSourceType({
195
203
  oxml: serializer.serializeToString(xmlDoc),
@@ -1,6 +1,15 @@
1
1
  import { createWordElement, isWordElement } from '../core/word-xml.js';
2
+ import {
3
+ NS_W,
4
+ RevisionIdAllocator,
5
+ createRevisionIdAllocator,
6
+ getRevisionIdAllocatorForDocument
7
+ } from '../core/types.js';
8
+ import { refreshRunPropertyChangeIds } from '../core/revision-cloning.js';
2
9
  import { getRunChildText, isTextLikeRunChild } from './surgical-spans.js';
3
10
 
11
+ const TRACK_CHANGE_CARRIERS = new Set(['ins']);
12
+
4
13
  export function getRunContentPieces(runElement) {
5
14
  const pieces = [];
6
15
  let offset = 0;
@@ -62,6 +71,100 @@ export function insertRunPiecesBefore(xmlDoc, parent, referenceNode, pieces, rPr
62
71
  return run;
63
72
  }
64
73
 
74
+ /**
75
+ * Splits a run-level tracked-change carrier at an accepted-view character
76
+ * offset without mutating the source carrier. The original revision ID stays
77
+ * with the leading fragment; an interior trailing fragment receives a fresh,
78
+ * document-scoped ID while all other carrier metadata remains unchanged.
79
+ *
80
+ * @param {Document} xmlDoc
81
+ * @param {Element} carrierElement
82
+ * @param {number} splitOffset
83
+ * @param {RevisionIdAllocator|null} [allocator=null]
84
+ * @returns {{ leftCarrier: Element|null, rightCarrier: Element|null }}
85
+ */
86
+ export function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, allocator = null) {
87
+ const carrierName = getLocalName(carrierElement);
88
+ if (!TRACK_CHANGE_CARRIERS.has(carrierName)) {
89
+ throw new TypeError('splitTrackChangeCarrier requires a w:ins carrier.');
90
+ }
91
+ if (!Number.isInteger(splitOffset) || splitOffset < 0) {
92
+ throw new RangeError('splitOffset must be a non-negative integer.');
93
+ }
94
+
95
+ const children = Array.from(carrierElement.childNodes || []);
96
+ const totalLength = children.reduce((length, child) => {
97
+ return length + (isWordElement(child, 'r') ? getRunTextLength(getRunContentPieces(child)) : 0);
98
+ }, 0);
99
+ if (splitOffset > totalLength) {
100
+ throw new RangeError(`splitOffset ${splitOffset} exceeds carrier text length ${totalLength}.`);
101
+ }
102
+
103
+ if (splitOffset === 0) {
104
+ return { leftCarrier: null, rightCarrier: carrierElement.cloneNode(true) };
105
+ }
106
+ if (splitOffset === totalLength) {
107
+ return { leftCarrier: carrierElement.cloneNode(true), rightCarrier: null };
108
+ }
109
+
110
+ const leftCarrier = carrierElement.cloneNode(false);
111
+ const rightCarrier = carrierElement.cloneNode(false);
112
+ let offset = 0;
113
+
114
+ for (const child of children) {
115
+ if (!isWordElement(child, 'r')) {
116
+ const destination = offset <= splitOffset ? leftCarrier : rightCarrier;
117
+ destination.appendChild(child.cloneNode(true));
118
+ continue;
119
+ }
120
+
121
+ const pieces = getRunContentPieces(child);
122
+ const runLength = getRunTextLength(pieces);
123
+ const runEnd = offset + runLength;
124
+
125
+ if (runEnd <= splitOffset) {
126
+ leftCarrier.appendChild(child.cloneNode(true));
127
+ } else if (offset >= splitOffset) {
128
+ rightCarrier.appendChild(child.cloneNode(true));
129
+ } else {
130
+ const localOffset = splitOffset - offset;
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);
134
+ leftCarrier.appendChild(createRunFromPieces(xmlDoc, leftPieces, rPr));
135
+ const rightRun = createRunFromPieces(xmlDoc, rightPieces, rPr);
136
+ refreshRunPropertyChangeIds(rightRun, resolveAllocator(xmlDoc, allocator));
137
+ rightCarrier.appendChild(rightRun);
138
+ }
139
+ offset = runEnd;
140
+ }
141
+
142
+ const resolvedAllocator = resolveAllocator(xmlDoc, allocator);
143
+ const nextId = resolvedAllocator.next();
144
+ setWordAttribute(rightCarrier, 'id', String(nextId));
145
+ resolvedAllocator._receiptCollector?.recordRevision(nextId, carrierName);
146
+
147
+ return { leftCarrier, rightCarrier };
148
+ }
149
+
150
+ function resolveAllocator(xmlDoc, allocator) {
151
+ return allocator instanceof RevisionIdAllocator
152
+ ? allocator
153
+ : (getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc));
154
+ }
155
+
156
+ function setWordAttribute(element, localName, value) {
157
+ if (typeof element.setAttributeNS === 'function') {
158
+ element.setAttributeNS(NS_W, `w:${localName}`, value);
159
+ } else {
160
+ element.setAttribute(`w:${localName}`, value);
161
+ }
162
+ }
163
+
164
+ function getLocalName(element) {
165
+ return String(element?.localName || element?.nodeName || '').replace(/^.*:/, '');
166
+ }
167
+
65
168
  function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
66
169
  if (asDeletedText) {
67
170
  const delText = createWordElement(xmlDoc, 'w:delText');
package/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type OoxmlSourceType = 'package' | 'document' | 'fragment';
2
2
  export type RedlineStatus = 'ok' | 'no-op' | 'error';
3
- export type ExistingRevisionsPolicy = 'merge-same-author' | 'reject-input' | 'accept-all-first' | 'accept-all-first-keep-normalized';
3
+ export type ExistingRevisionsPolicy = 'merge-same-author' | 'slice-cross-author' | 'reject-input' | 'accept-all-first' | 'accept-all-first-keep-normalized';
4
4
  export type RevisionView = 'accepted' | 'rejected';
5
5
 
6
6
  export interface RevisionTextSegment {
package/node/cli.js CHANGED
@@ -4,14 +4,16 @@ import { openDocx } from './docx-document.js';
4
4
  import { MemoryZip, unzipDocx } from './zip-archive.js';
5
5
  import { validateDocxPackage } from '../services/standalone-docx-plumbing.js';
6
6
  import { validateRedlineOoxml } from '../core/redline-validation.js';
7
- import { configureLogger } from '../adapters/logger.js';
7
+ import { configureLogger } from '../adapters/logger.js';
8
+ import { isExistingRevisionsPolicy } from '../services/document-operation-contract.js';
8
9
 
9
10
  const suffixes = { apply: 'redlined', accept: 'accepted', reject: 'rejected', 'delete-comments': 'comments-removed' };
10
11
  const CLI_CONTRACT_VERSION = 2;
11
- const CLI_CAPABILITIES = [
12
- 'atomic-batch-results-on-package-failure',
13
- 'baseline-aware-validation',
14
- 'document-scoped-list-revision-ids'
12
+ const CLI_CAPABILITIES = [
13
+ 'atomic-batch-results-on-package-failure',
14
+ 'baseline-aware-validation',
15
+ 'cross-author-revision-slicing',
16
+ 'document-scoped-list-revision-ids'
15
17
  ];
16
18
  const commandOptions = {
17
19
  version: new Set(['help']),
@@ -214,8 +216,11 @@ export async function executeCli(argv) {
214
216
  };
215
217
  }
216
218
  if (!rawInput) return cliError('INPUT_REQUIRED', 'An input .docx path is required.');
217
- const optionError = validateCommandOptions(command, flags, extraPositionals);
218
- if (optionError) return optionError;
219
+ const optionError = validateCommandOptions(command, flags, extraPositionals);
220
+ if (optionError) return optionError;
221
+ if (flags.existingRevisions != null && !isExistingRevisionsPolicy(flags.existingRevisions)) {
222
+ return cliError('INVALID_OPERATION', `Unsupported existing-revisions policy: "${String(flags.existingRevisions)}".`);
223
+ }
219
224
  let inspectOptions = null;
220
225
  if (command === 'inspect' || command === 'extract') {
221
226
  try { inspectOptions = inspectionOptions(flags); }