@ansonlai/docx-redline-js 0.8.4 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +679 -460
- package/CHANGELOG.md +208 -1
- package/README.md +152 -16
- package/core/current-properties.js +136 -0
- package/core/paragraph-text.js +211 -209
- package/core/types.js +9 -3
- package/core/word-revision-semantics.js +256 -0
- package/core/word-xml.js +160 -118
- package/dist/docx-redline-js.esm.js +4338 -796
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +111 -108
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +4463 -921
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +4463 -921
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_FAST_START.md +2 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
- package/docs/SKILL_AUTHORING.md +169 -150
- package/docs/TESTING.md +1146 -1059
- package/docs/schemas/document-operations.schema.json +55 -2
- package/document/docx-document.js +1022 -1010
- package/engine/oxml-engine.js +21 -22
- package/engine/paragraph-mark-revisions.js +292 -0
- package/engine/reconstruction-mapper.js +312 -311
- package/engine/reconstruction-writer.js +67 -10
- package/engine/rpr-helpers.js +42 -0
- package/engine/run-builders.js +0 -73
- package/engine/surgical-diff-application.js +604 -605
- package/engine/table-mode.js +63 -63
- package/index.d.ts +53 -2
- package/index.js +13 -2
- package/node/cli-help.js +1 -0
- package/node/cli.js +21 -3
- package/orchestration/list-structural-fallback.js +67 -9
- package/package.json +1 -1
- package/pipeline/diff-engine.js +130 -1
- package/pipeline/ingestion-export.js +227 -243
- package/pipeline/ingestion-paragraph.js +93 -98
- package/pipeline/list-generation.js +415 -419
- package/pipeline/markdown-processor.js +5 -2
- package/pipeline/pipeline.js +206 -204
- package/scripts/export-validation-fixtures.mjs +19 -6
- package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
- package/scripts/lib/word-coverage-catalogue.mjs +7 -2
- package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
- package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
- package/scripts/word-com-differential.ps1 +443 -335
- package/scripts/word-com-visual-suite.ps1 +2 -2
- package/scripts/word-revision-semantics-matrix.ps1 +108 -0
- package/services/batch-operation-orchestrator.js +23 -3
- package/services/capture-engine.js +228 -226
- package/services/comment-replies.js +1 -1
- package/services/document-operation-applier.js +214 -0
- package/services/document-operation-contract.js +76 -0
- package/services/document-operation-mutations.js +688 -51
- package/services/error-recovery.js +17 -0
- package/services/inspection-comparison.js +82 -0
- package/services/list-conversion-diagnostics.js +296 -0
- package/services/list-format-change.js +586 -0
- package/services/list-format-mutation.js +416 -0
- package/services/list-format.js +305 -0
- package/services/numbering-allocation.js +642 -0
- package/services/numbering-helpers.js +354 -524
- package/services/operation-batch-compiler.js +183 -11
- package/services/operation-preflight.js +43 -33
- package/services/receipt-collector.js +288 -288
- package/services/revision-comment-management.js +38 -5
- package/services/revision-fidelity-verifier.js +484 -0
- package/services/standalone-docx-plumbing.js +627 -631
- package/services/standalone-operation-runner.d.ts +182 -5
- package/services/table-reconciliation.js +59 -7
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getDefaultAuthor } from '../adapters/config.js';
|
|
2
2
|
import {
|
|
3
3
|
normalizeDocumentOperation,
|
|
4
|
+
normalizeTargetDescriptor,
|
|
4
5
|
resolveDocumentOperationAuthor,
|
|
5
6
|
validateDocumentOperation
|
|
6
7
|
} from './document-operation-contract.js';
|
|
@@ -16,8 +17,10 @@ import {
|
|
|
16
17
|
applyFormattingToParagraphByExactText,
|
|
17
18
|
applyHighlightToParagraphByExactText,
|
|
18
19
|
applyParagraphFormatToParagraphByExactText,
|
|
20
|
+
applyListFormatToParagraphByExactText,
|
|
19
21
|
insertIntoRejectedDeletedText,
|
|
20
22
|
restoreDeletedParagraphByExactText,
|
|
23
|
+
resolveOperationSourceRange,
|
|
21
24
|
applyToParagraphByExactText
|
|
22
25
|
} from './document-operation-mutations.js';
|
|
23
26
|
import { applyCommentReplyToParts } from './comment-replies.js';
|
|
@@ -35,6 +38,18 @@ import { subtractValidationIssueMultiset, validationErrors } from '../core/valid
|
|
|
35
38
|
import { normalizeErrorWithRecovery } from './error-recovery.js';
|
|
36
39
|
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
37
40
|
import { buildLocalizedReplacementChange } from './localized-replacement-compiler.js';
|
|
41
|
+
import {
|
|
42
|
+
buildRevisionFidelityError,
|
|
43
|
+
createFidelityViews,
|
|
44
|
+
resolveRevisionFidelityMode,
|
|
45
|
+
revisionFidelityBaselineView,
|
|
46
|
+
isPlainTextIntent,
|
|
47
|
+
verifyAcceptIntent,
|
|
48
|
+
verifyNumberingReferences,
|
|
49
|
+
verifyRevisionFidelity,
|
|
50
|
+
verifyNoCreatedRevisions,
|
|
51
|
+
verifyUntrackedLocality
|
|
52
|
+
} from './revision-fidelity-verifier.js';
|
|
38
53
|
|
|
39
54
|
export function normalizeOperationError(error, context = {}) {
|
|
40
55
|
return normalizeErrorWithRecovery(error, context);
|
|
@@ -199,9 +214,36 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
199
214
|
operation = compiledValidation.operation;
|
|
200
215
|
}
|
|
201
216
|
|
|
217
|
+
if (Array.isArray(operation.replacements) && !operation._localizedReplacementCompilation) {
|
|
218
|
+
// Uncompiled replacements would otherwise reach the mutation with no "modified" text and
|
|
219
|
+
// delete the paragraph; never apply them without their compiled accepted-text intent.
|
|
220
|
+
return {
|
|
221
|
+
documentXml,
|
|
222
|
+
hasChanges: false,
|
|
223
|
+
status: 'error',
|
|
224
|
+
error: normalizeOperationError({
|
|
225
|
+
code: 'PATCH_ROUNDTRIP_MISMATCH',
|
|
226
|
+
stage: 'validation',
|
|
227
|
+
message: 'Localized replacements were not compiled against a batch-start paragraph target, so they were refused instead of applied.'
|
|
228
|
+
}, errorContext),
|
|
229
|
+
operationType: operation.operationKind,
|
|
230
|
+
authorUsed,
|
|
231
|
+
receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
202
235
|
const resolutionCapture = {};
|
|
203
236
|
let localizedChange = null;
|
|
204
237
|
const savepoint = session.createSavepoint();
|
|
238
|
+
// The previous verified operation's Reject All projection is this operation's baseline
|
|
239
|
+
// while the live DOM is the one it was computed on (a restored savepoint swaps the DOM).
|
|
240
|
+
// The memo is consumed here: any path that does not store a fresh one (an unverified commit, an
|
|
241
|
+
// early return) leaves it empty, so a stale entry can never describe a mutated DOM. The before-state
|
|
242
|
+
// entry is re-keyed to the savepoint clone, which is the live DOM again after a refusal restores it.
|
|
243
|
+
const priorViews = session._fidelityViews?.document === session.document ? session._fidelityViews : null;
|
|
244
|
+
session._fidelityViews = null;
|
|
245
|
+
const beforeViews = { ...(priorViews || createFidelityViews(null)), document: savepoint.document };
|
|
246
|
+
const afterViews = createFidelityViews(session.document);
|
|
205
247
|
session.receiptCollector?.beginOperation(
|
|
206
248
|
operationIndex,
|
|
207
249
|
operation.operationId,
|
|
@@ -216,15 +258,23 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
216
258
|
...(operation.existingRevisions ? { existingRevisions: operation.existingRevisions } : {}),
|
|
217
259
|
structuredContent: typeof operation.structuredContent === 'boolean' ? operation.structuredContent : (options.structuredContent !== false),
|
|
218
260
|
explicitStructuredContent: operation.structuredContent === true,
|
|
261
|
+
plainTextContent: operation.structuredContent === false || (operation.structuredContent == null && options.structuredContent === false),
|
|
219
262
|
...(operation._localizedReplacementCompilation && operation.structuredContent !== true ? { literalTextEdit: true } : {}),
|
|
220
263
|
pairReplacements: typeof operation.pairReplacements === 'boolean' ? operation.pairReplacements : (options.pairReplacements !== false),
|
|
221
264
|
...(operation.insertionAffinity ? { insertionAffinity: operation.insertionAffinity } : {}),
|
|
265
|
+
...(operation.listMarkers ? { listMarkers: operation.listMarkers } : {}),
|
|
222
266
|
...(operation.formattingRevisionPolicy ? { formattingRevisionPolicy: operation.formattingRevisionPolicy } : {}),
|
|
223
267
|
...(operation.operationKind === 'format' && Number.isInteger(operation.textOccurrence) ? { textOccurrence: operation.textOccurrence } : {}),
|
|
224
268
|
targetDescriptor: operation.targetDescriptor,
|
|
225
269
|
targetEndDescriptor: operation.targetEndDescriptor,
|
|
226
270
|
_compiledSourceId: operation._compiledSourceId || null,
|
|
227
271
|
_compiledSourceEndId: operation._compiledSourceEndId || null,
|
|
272
|
+
...(operation.operationKind === 'list-format' && operation.list?.continueFrom && typeof operation.list.continueFrom === 'object'
|
|
273
|
+
? {
|
|
274
|
+
continueFromDescriptor: normalizeTargetDescriptor(operation.list.continueFrom, null, 'accepted'),
|
|
275
|
+
_compiledContinueFromId: operation._compiledContinueFromId || null
|
|
276
|
+
}
|
|
277
|
+
: {}),
|
|
228
278
|
_compiledResolvedBy: operation._compiledResolvedBy || null,
|
|
229
279
|
_sourceTargetRegistry: session.sourceTargetRegistry || null,
|
|
230
280
|
_resolutionCapture: resolutionCapture,
|
|
@@ -241,6 +291,23 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
241
291
|
}
|
|
242
292
|
};
|
|
243
293
|
|
|
294
|
+
// Resolve the target range before mutating, for the revision fidelity verifier: the exact accepted scope of an
|
|
295
|
+
// accept-first range target, and the target of a plain-text redline's ACCEPT_INTENT check.
|
|
296
|
+
const isRangeTarget = !!(operation.targetEndDescriptor || operation.targetEndRef);
|
|
297
|
+
const earlyFidelityMode = resolveRevisionFidelityMode(options.verifyRevisionFidelity);
|
|
298
|
+
const acceptFirstBaseline = revisionFidelityBaselineView(operationOptions.existingRevisions) === 'accepted';
|
|
299
|
+
const declaresAcceptIntent = operation.operationKind === 'redline'
|
|
300
|
+
&& operationOptions.generateRedlines !== false
|
|
301
|
+
&& !operation._localizedReplacementCompilation
|
|
302
|
+
&& operation.structuredContent !== true
|
|
303
|
+
&& isPlainTextIntent(operation.modified)
|
|
304
|
+
&& !/\r?\n/.test(String(operation.targetDescriptor?.exactText ?? operation.targetDescriptor?.text ?? (typeof operation.target === 'string' ? operation.target : '')));
|
|
305
|
+
const sourceRange = earlyFidelityMode !== 'off' && session.document
|
|
306
|
+
&& ((isRangeTarget && acceptFirstBaseline) || declaresAcceptIntent)
|
|
307
|
+
? resolveOperationSourceRange(session.document, operation.target, operation.targetRef, operation.targetEndRef, runtimeContext, operationOptions)
|
|
308
|
+
: null;
|
|
309
|
+
const rangeEndIndex = sourceRange ? sourceRange.to : null;
|
|
310
|
+
|
|
244
311
|
try {
|
|
245
312
|
let result;
|
|
246
313
|
if (operation.operationKind === 'comment_reply') {
|
|
@@ -335,6 +402,17 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
335
402
|
runtimeContext,
|
|
336
403
|
operationOptions
|
|
337
404
|
);
|
|
405
|
+
} else if (operation.operationKind === 'list-format') {
|
|
406
|
+
result = await applyListFormatToParagraphByExactText(
|
|
407
|
+
documentXml,
|
|
408
|
+
operation.target,
|
|
409
|
+
operation.list,
|
|
410
|
+
authorUsed,
|
|
411
|
+
operation.targetRef,
|
|
412
|
+
operation.targetEndRef,
|
|
413
|
+
runtimeContext,
|
|
414
|
+
operationOptions
|
|
415
|
+
);
|
|
338
416
|
} else if (operation.operationKind === 'rejected-insert') {
|
|
339
417
|
result = await insertIntoRejectedDeletedText(
|
|
340
418
|
documentXml,
|
|
@@ -437,6 +515,142 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
437
515
|
...resolutionCapture
|
|
438
516
|
};
|
|
439
517
|
}
|
|
518
|
+
if (operation._localizedReplacementCompilation && operation.structuredContent !== true) {
|
|
519
|
+
// Accept-view intent: the target's accepted text must be exactly the compiled
|
|
520
|
+
// text, whatever path produced it. A tracked whole-paragraph deletion rejects
|
|
521
|
+
// back to the source, so the Reject All verifier alone cannot see it. Structured (Markdown)
|
|
522
|
+
// replacement text legitimately differs from its literal accepted text, so it is exempt.
|
|
523
|
+
let intentText = null;
|
|
524
|
+
if (operation.operationKind === 'restore') {
|
|
525
|
+
const restored = operationOptions._mutationLiveNodes.find(node => node?.localName === 'p');
|
|
526
|
+
if (restored) intentText = extractCanonicalParagraphText(restored, { revisionView: 'accepted' });
|
|
527
|
+
} else if (operation._compiledSourceId && session.sourceTargetRegistry) {
|
|
528
|
+
const live = session.sourceTargetRegistry.previewSuccessor(
|
|
529
|
+
operation._compiledSourceId,
|
|
530
|
+
operationOptions._mutationRemovedNodes,
|
|
531
|
+
operationOptions._mutationLiveNodes
|
|
532
|
+
);
|
|
533
|
+
if (live) intentText = extractCanonicalParagraphText(live, { revisionView: 'accepted' });
|
|
534
|
+
}
|
|
535
|
+
if (intentText !== operation._localizedReplacementCompilation.desiredText) {
|
|
536
|
+
session.restoreSavepoint(savepoint);
|
|
537
|
+
const message = 'The localized replacement did not produce the compiled accepted text, so it was refused before commit to avoid losing content. Re-extract the paragraph and retry with its current text.';
|
|
538
|
+
const refusedReceipt = createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused');
|
|
539
|
+
refusedReceipt.warnings.push(message);
|
|
540
|
+
return {
|
|
541
|
+
documentXml,
|
|
542
|
+
hasChanges: false,
|
|
543
|
+
status: 'error',
|
|
544
|
+
error: normalizeOperationError({ code: 'PATCH_ROUNDTRIP_MISMATCH', stage: 'validation', message }, errorContext),
|
|
545
|
+
warnings: [message],
|
|
546
|
+
operationType: operation.operationKind,
|
|
547
|
+
authorUsed,
|
|
548
|
+
receipt: refusedReceipt,
|
|
549
|
+
...resolutionCapture
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const fidelityMode = resolveRevisionFidelityMode(options.verifyRevisionFidelity);
|
|
554
|
+
const singleTargetPosition = !operation.targetEndDescriptor && !operation.targetEndRef
|
|
555
|
+
? resolutionCapture.resolvedTarget?.index
|
|
556
|
+
: null;
|
|
557
|
+
const fidelity = fidelityMode === 'off' ? null
|
|
558
|
+
: (operationOptions.generateRedlines !== false ? 'tracked' : (Number.isInteger(singleTargetPosition) ? 'untracked' : null));
|
|
559
|
+
if (fidelity === 'tracked') {
|
|
560
|
+
const baselineView = revisionFidelityBaselineView(operationOptions.existingRevisions);
|
|
561
|
+
// Accept-first policies accept revisions in the resolved target only. A range target uses the
|
|
562
|
+
// end resolved before mutation; if that could not be resolved the verifier falls back to its
|
|
563
|
+
// weaker region heuristic.
|
|
564
|
+
const targetPosition = resolutionCapture.resolvedTarget?.index;
|
|
565
|
+
const acceptedScope = !Number.isInteger(targetPosition) ? null
|
|
566
|
+
: !isRangeTarget ? { from: targetPosition - 1, to: targetPosition - 1 }
|
|
567
|
+
: (Number.isInteger(rangeEndIndex) && rangeEndIndex >= targetPosition - 1
|
|
568
|
+
? { from: targetPosition - 1, to: rangeEndIndex }
|
|
569
|
+
: null);
|
|
570
|
+
const fidelity = verifyRevisionFidelity({
|
|
571
|
+
before: savepoint.document,
|
|
572
|
+
after: session.document,
|
|
573
|
+
baselineView,
|
|
574
|
+
acceptedScope,
|
|
575
|
+
beforeViews,
|
|
576
|
+
afterViews
|
|
577
|
+
});
|
|
578
|
+
const numbering = verifyNumberingReferences({
|
|
579
|
+
before: savepoint.document,
|
|
580
|
+
after: session.document,
|
|
581
|
+
numberingXmls: [runtimeContext?.numberingXml || options.numberingXml, ...(session.numberingXmlParts || []), result.numberingXml],
|
|
582
|
+
beforeViews,
|
|
583
|
+
afterViews
|
|
584
|
+
});
|
|
585
|
+
if (!numbering.ok) {
|
|
586
|
+
fidelity.ok = false;
|
|
587
|
+
fidelity.violations = [...(fidelity.violations || []), ...numbering.violations];
|
|
588
|
+
}
|
|
589
|
+
if (declaresAcceptIntent && sourceRange) {
|
|
590
|
+
const intent = verifyAcceptIntent({ before: savepoint.document, after: session.document, range: sourceRange, modified: operation.modified, beforeViews, afterViews });
|
|
591
|
+
if (!intent.ok) {
|
|
592
|
+
fidelity.ok = false;
|
|
593
|
+
fidelity.violations = [...(fidelity.violations || []), ...intent.violations];
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (!fidelity.ok) {
|
|
597
|
+
const fidelityError = buildRevisionFidelityError(fidelity.violations, { mode: fidelityMode, baselineView });
|
|
598
|
+
const logLine = `${fidelityError.code} (operation ${operationIndex}${operation.operationId ? ` "${operation.operationId}"` : ''}): ${fidelityError.message}`;
|
|
599
|
+
if (typeof options?.onWarn === 'function') options.onWarn(logLine);
|
|
600
|
+
if (fidelityMode === 'enforce') {
|
|
601
|
+
session.restoreSavepoint(savepoint);
|
|
602
|
+
session._fidelityViews = beforeViews;
|
|
603
|
+
const refusedReceipt = createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused');
|
|
604
|
+
refusedReceipt.warnings.push(logLine);
|
|
605
|
+
return {
|
|
606
|
+
documentXml,
|
|
607
|
+
hasChanges: false,
|
|
608
|
+
status: 'error',
|
|
609
|
+
error: normalizeOperationError(fidelityError, errorContext),
|
|
610
|
+
warnings: [logLine],
|
|
611
|
+
operationType: operation.operationKind,
|
|
612
|
+
authorUsed,
|
|
613
|
+
receipt: refusedReceipt,
|
|
614
|
+
...resolutionCapture
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
result.warnings = [...(Array.isArray(result.warnings) ? result.warnings : []), logLine];
|
|
618
|
+
result.revisionFidelityViolations = fidelity.violations;
|
|
619
|
+
}
|
|
620
|
+
} else {
|
|
621
|
+
let locality = fidelity === 'untracked'
|
|
622
|
+
? verifyUntrackedLocality({ before: savepoint.document, after: session.document, targetSourceIndex: singleTargetPosition - 1, beforeViews, afterViews })
|
|
623
|
+
: { ok: true };
|
|
624
|
+
if (locality.ok && fidelityMode !== 'off' && operationOptions.generateRedlines === false) {
|
|
625
|
+
locality = verifyNoCreatedRevisions({ before: savepoint.document, after: session.document, beforeViews, afterViews });
|
|
626
|
+
}
|
|
627
|
+
if (!locality.ok) {
|
|
628
|
+
const fidelityError = buildRevisionFidelityError(locality.violations, { mode: fidelityMode });
|
|
629
|
+
const logLine = `${fidelityError.code} (operation ${operationIndex}${operation.operationId ? ` "${operation.operationId}"` : ''}): ${fidelityError.message}`;
|
|
630
|
+
if (typeof options?.onWarn === 'function') options.onWarn(logLine);
|
|
631
|
+
if (fidelityMode === 'enforce') {
|
|
632
|
+
session.restoreSavepoint(savepoint);
|
|
633
|
+
session._fidelityViews = beforeViews;
|
|
634
|
+
const refusedReceipt = createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused');
|
|
635
|
+
refusedReceipt.warnings.push(logLine);
|
|
636
|
+
return {
|
|
637
|
+
documentXml,
|
|
638
|
+
hasChanges: false,
|
|
639
|
+
status: 'error',
|
|
640
|
+
error: normalizeOperationError(fidelityError, errorContext),
|
|
641
|
+
warnings: [logLine],
|
|
642
|
+
operationType: operation.operationKind,
|
|
643
|
+
authorUsed,
|
|
644
|
+
receipt: refusedReceipt,
|
|
645
|
+
...resolutionCapture
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
result.warnings = [...(Array.isArray(result.warnings) ? result.warnings : []), logLine];
|
|
649
|
+
result.revisionFidelityViolations = locality.violations;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// Both verified branches leave the after-state's projections as the next operation's baseline.
|
|
653
|
+
if (fidelity === 'tracked' || fidelity === 'untracked') session._fidelityViews = afterViews;
|
|
440
654
|
session.markMutationCommitted(true);
|
|
441
655
|
if (operation._compiledSourceId && session.sourceTargetRegistry) {
|
|
442
656
|
session.sourceTargetRegistry.commitMutation(
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { validateExactReplacementRequests } from './localized-replacement-compiler.js';
|
|
9
|
+
import { validateParagraphNumberingShape, findUnknownParagraphFormatKeys, PARAGRAPH_FORMAT_PROPERTY_KEYS } from './list-conversion-diagnostics.js';
|
|
10
|
+
import { validateListFormatShape } from './list-format.js';
|
|
9
11
|
|
|
10
12
|
const SUPPORTED_OPERATION_TYPES = new Set([
|
|
11
13
|
'redline',
|
|
@@ -13,6 +15,7 @@ const SUPPORTED_OPERATION_TYPES = new Set([
|
|
|
13
15
|
'format',
|
|
14
16
|
'character-format',
|
|
15
17
|
'paragraph-format',
|
|
18
|
+
'list-format',
|
|
16
19
|
'list-change',
|
|
17
20
|
'table-reconciliation',
|
|
18
21
|
'insert',
|
|
@@ -25,6 +28,7 @@ const SUPPORTED_OPERATION_TYPES = new Set([
|
|
|
25
28
|
]);
|
|
26
29
|
|
|
27
30
|
export const EXISTING_REVISIONS_POLICIES = Object.freeze([
|
|
31
|
+
'auto',
|
|
28
32
|
'merge-same-author',
|
|
29
33
|
'slice-cross-author',
|
|
30
34
|
'reject-input',
|
|
@@ -55,6 +59,7 @@ export function getCanonicalOperationType(operation) {
|
|
|
55
59
|
if (type === 'restore') return 'restore';
|
|
56
60
|
if (type === 'comment' || type === 'comment_reply' || type === 'comment_resolve' || type === 'highlight') return type;
|
|
57
61
|
if (type === 'paragraph-format') return 'paragraph-format';
|
|
62
|
+
if (type === 'list-format') return 'list-format';
|
|
58
63
|
if (type === 'character-format' || (type === 'format' && (operation?.textToFormat != null || operation?.properties != null))) return 'format';
|
|
59
64
|
return 'redline';
|
|
60
65
|
}
|
|
@@ -408,6 +413,13 @@ export function validateDocumentOperation(operation) {
|
|
|
408
413
|
};
|
|
409
414
|
}
|
|
410
415
|
|
|
416
|
+
if (normalized.listMarkers != null && normalized.listMarkers !== 'literal') {
|
|
417
|
+
return {
|
|
418
|
+
valid: false,
|
|
419
|
+
error: { code: 'INVALID_OPERATION', message: 'listMarkers must be "literal" when provided.' }
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
411
423
|
if (normalized.existingRevisions != null && !isExistingRevisionsPolicy(normalized.existingRevisions)) {
|
|
412
424
|
return {
|
|
413
425
|
valid: false,
|
|
@@ -534,6 +546,70 @@ export function validateDocumentOperation(operation) {
|
|
|
534
546
|
error: { code: 'INVALID_OPERATION', message: 'Paragraph-format operations require a "properties" object.' }
|
|
535
547
|
};
|
|
536
548
|
}
|
|
549
|
+
const unknownKeys = findUnknownParagraphFormatKeys(normalized.properties);
|
|
550
|
+
if (unknownKeys.length > 0) {
|
|
551
|
+
return {
|
|
552
|
+
valid: false,
|
|
553
|
+
error: {
|
|
554
|
+
code: 'INVALID_OPERATION',
|
|
555
|
+
message: `paragraph-format does not support ${unknownKeys.length === 1 ? 'property' : 'properties'} ${unknownKeys.map(key => `"${key}"`).join(', ')}. Supported: ${PARAGRAPH_FORMAT_PROPERTY_KEYS.join(', ')}.`,
|
|
556
|
+
field: `properties.${unknownKeys[0]}`,
|
|
557
|
+
properties: unknownKeys,
|
|
558
|
+
recovery: { action: 'change_request', hint: `Use only: ${PARAGRAPH_FORMAT_PROPERTY_KEYS.join(', ')}.` }
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
if (normalized.properties.numbering !== undefined) {
|
|
563
|
+
const shapeError = validateParagraphNumberingShape(normalized.properties.numbering);
|
|
564
|
+
if (shapeError) {
|
|
565
|
+
return {
|
|
566
|
+
valid: false,
|
|
567
|
+
error: {
|
|
568
|
+
code: 'INVALID_OPERATION',
|
|
569
|
+
message: shapeError,
|
|
570
|
+
field: 'properties.numbering',
|
|
571
|
+
recovery: { action: 'change_request', hint: 'Pass numbering as { "numId": <existing numId from inspect>, "level": 0 } or null.' }
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (normalized.operationKind === 'list-format') {
|
|
579
|
+
const shapeError = validateListFormatShape(normalized.list);
|
|
580
|
+
if (shapeError) {
|
|
581
|
+
return {
|
|
582
|
+
valid: false,
|
|
583
|
+
error: {
|
|
584
|
+
code: 'INVALID_OPERATION',
|
|
585
|
+
message: shapeError.message,
|
|
586
|
+
field: shapeError.field,
|
|
587
|
+
recovery: {
|
|
588
|
+
action: 'change_request',
|
|
589
|
+
hint: 'Pass list as { "mode": "convert-plain" } (optional format, kind, level, strip "marker" | "none", start with strip "none"), { "mode": "change-format", "format": ... } (optional kind, lvlText, start, level, scope "list" | "range") or { "mode": "join", "continueFrom": { "paragraphId": ... } } (optional scope).'
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
if (operation.modified !== undefined || operation.replacements !== undefined) {
|
|
595
|
+
return {
|
|
596
|
+
valid: false,
|
|
597
|
+
error: {
|
|
598
|
+
code: 'INVALID_OPERATION',
|
|
599
|
+
message: 'list-format changes list structure only; express text changes as a separate redline operation.',
|
|
600
|
+
field: operation.modified !== undefined ? 'modified' : 'replacements'
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
if (target.captureRef || target.occurrence != null || target.revisionView !== 'accepted') {
|
|
605
|
+
return {
|
|
606
|
+
valid: false,
|
|
607
|
+
error: {
|
|
608
|
+
code: 'INVALID_OPERATION',
|
|
609
|
+
message: 'list-format v1 targets batch-start accepted-view paragraphs without captures or target occurrence.'
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
}
|
|
537
613
|
}
|
|
538
614
|
|
|
539
615
|
if (normalized.author != null && !nonEmptyString(normalized.author)) {
|