@ansonlai/docx-redline-js 0.8.2 → 0.8.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/README.md +28 -13
  3. package/core/list-targeting.js +12 -10
  4. package/dist/docx-redline-js.esm.js +837 -549
  5. package/dist/docx-redline-js.esm.js.map +4 -4
  6. package/dist/docx-redline-js.esm.min.js +93 -90
  7. package/dist/docx-redline-js.esm.min.js.map +4 -4
  8. package/dist/docx-redline.bundle.cjs +837 -549
  9. package/dist/docx-redline.bundle.cjs.map +4 -4
  10. package/dist/docx-redline.bundle.js +837 -549
  11. package/dist/docx-redline.bundle.js.map +4 -4
  12. package/docs/AGENT_KNOWLEDGE_BASE.md +4 -1
  13. package/docs/schemas/document-operations.schema.json +168 -168
  14. package/engine/oxml-engine.js +215 -195
  15. package/engine/reconstruction-mode.js +183 -183
  16. package/index.d.ts +4 -0
  17. package/index.js +36 -1
  18. package/node/cli.js +2 -1
  19. package/orchestration/list-structural-fallback.js +530 -518
  20. package/package.json +1 -1
  21. package/pipeline/list-generation.js +203 -168
  22. package/pipeline/serialization.js +111 -94
  23. package/services/batch-operation-orchestrator.js +6 -0
  24. package/services/document-inspection.js +10 -7
  25. package/services/document-operation-applier.js +2 -0
  26. package/services/document-operation-contract.js +553 -537
  27. package/services/document-operation-mutations.js +1140 -1011
  28. package/services/numbering-helpers.js +108 -0
  29. package/services/operation-batch-compiler.js +440 -436
  30. package/services/operation-heuristics.js +197 -173
  31. package/services/revision-comment-management.js +601 -594
  32. package/services/standalone-operation-runner.d.ts +429 -427
  33. package/services/table-reconciliation.js +48 -46
@@ -1,537 +1,553 @@
1
- /**
2
- * Public document-operation normalization and validation.
3
- *
4
- * Keep compatibility aliases at this boundary so the runner and preflight
5
- * logic can operate on one internal shape.
6
- */
7
-
8
- import { validateExactReplacementRequests } from './localized-replacement-compiler.js';
9
-
10
- const SUPPORTED_OPERATION_TYPES = new Set([
11
- 'redline',
12
- 'replace',
13
- 'format',
14
- 'character-format',
15
- 'paragraph-format',
16
- 'list-change',
17
- 'table-reconciliation',
18
- 'insert',
19
- 'restore',
20
- 'delete',
21
- 'comment',
22
- 'comment_reply',
23
- 'comment_resolve',
24
- 'highlight'
25
- ]);
26
-
27
- export const EXISTING_REVISIONS_POLICIES = Object.freeze([
28
- 'merge-same-author',
29
- 'slice-cross-author',
30
- 'reject-input',
31
- 'accept-all-first',
32
- 'accept-all-first-keep-normalized'
33
- ]);
34
-
35
- export function isExistingRevisionsPolicy(value) {
36
- return EXISTING_REVISIONS_POLICIES.includes(value);
37
- }
38
-
39
- function isRecord(value) {
40
- return !!value && typeof value === 'object' && !Array.isArray(value);
41
- }
42
-
43
- function nonEmptyString(value) {
44
- return typeof value === 'string' && value.trim().length > 0;
45
- }
46
-
47
- /** Operations that address an existing comment thread by id instead of targeting a paragraph. */
48
- export function isCommentThreadOperationKind(kind) {
49
- return kind === 'comment_reply' || kind === 'comment_resolve';
50
- }
51
-
52
- export function getCanonicalOperationType(operation) {
53
- const type = operation?.type;
54
- if (type === 'insert' && operation?.target?.revisionView === 'rejected') return 'rejected-insert';
55
- if (type === 'restore') return 'restore';
56
- if (type === 'comment' || type === 'comment_reply' || type === 'comment_resolve' || type === 'highlight') return type;
57
- if (type === 'paragraph-format') return 'paragraph-format';
58
- if (type === 'character-format' || (type === 'format' && (operation?.textToFormat != null || operation?.properties != null))) return 'format';
59
- return 'redline';
60
- }
61
-
62
- export function normalizeTargetDescriptor(target, legacyTargetRef = null, defaultRevisionView = 'accepted') {
63
- const fallbackRevisionView = defaultRevisionView === 'rejected' ? 'rejected' : 'accepted';
64
- if (!isRecord(target)) {
65
- return {
66
- text: typeof target === 'string' ? target : '',
67
- index: legacyTargetRef ?? null,
68
- paragraphId: null,
69
- occurrence: null,
70
- inTable: null,
71
- fingerprint: null,
72
- revisionView: fallbackRevisionView
73
- };
74
- }
75
-
76
- return {
77
- text: typeof target.exactText === 'string'
78
- ? target.exactText
79
- : (typeof target.text === 'string' ? target.text : ''),
80
- index: target.index ?? target.paragraphIndex ?? legacyTargetRef ?? null,
81
- paragraphId: nonEmptyString(target.paragraphId) ? target.paragraphId.trim() : null,
82
- occurrence: Number.isInteger(target.occurrence) && target.occurrence > 0
83
- ? target.occurrence
84
- : null,
85
- inTable: typeof target.inTable === 'boolean' ? target.inTable : null,
86
- fingerprint: nonEmptyString(target.fingerprint)
87
- ? target.fingerprint.trim()
88
- : (nonEmptyString(target.sourceFingerprint) ? target.sourceFingerprint.trim() : null),
89
- revisionView: target.revisionView === 'rejected'
90
- ? 'rejected'
91
- : (target.revisionView === 'accepted' ? 'accepted' : fallbackRevisionView),
92
- captureRef: nonEmptyString(target.captureRef) ? target.captureRef.trim() : null,
93
- select: typeof target.select === 'string' ? target.select : null
94
- };
95
- }
96
-
97
- export function normalizeDocumentOperation(operation) {
98
- const source = isRecord(operation) ? operation : {};
99
- const kind = getCanonicalOperationType(source);
100
- const defaultRevisionView = kind === 'restore' ? 'rejected' : 'accepted';
101
- const targetDescriptor = normalizeTargetDescriptor(source.target, source.targetRef, defaultRevisionView);
102
- const targetEndDescriptor = source.targetEnd != null
103
- ? normalizeTargetDescriptor(source.targetEnd, source.targetEndRef, defaultRevisionView)
104
- : null;
105
-
106
- return {
107
- ...source,
108
- operationId: nonEmptyString(source.operationId) ? source.operationId.trim() : null,
109
- captureKey: nonEmptyString(source.captureKey) ? source.captureKey.trim() : null,
110
- operationKind: kind,
111
- anchor: isRecord(source.anchor) ? {
112
- exactText: typeof source.anchor.exactText === 'string' ? source.anchor.exactText : '',
113
- occurrence: Number.isInteger(source.anchor.occurrence) && source.anchor.occurrence > 0 ? source.anchor.occurrence : 1,
114
- occurrenceExplicit: Number.isInteger(source.anchor.occurrence) && source.anchor.occurrence > 0,
115
- offset: Number.isInteger(source.anchor.offset) ? source.anchor.offset : null
116
- } : null,
117
- targetDescriptor,
118
- targetEndDescriptor,
119
- target: targetDescriptor.text,
120
- targetRef: targetDescriptor.index,
121
- targetEndRef: targetEndDescriptor?.index ?? source.targetEndRef ?? null,
122
- ...(source.type === 'delete' && source.modified == null ? { modified: '' } : {})
123
- };
124
- }
125
-
126
- export function validateDocumentOperation(operation) {
127
- if (!isRecord(operation)) {
128
- return {
129
- valid: false,
130
- error: { code: 'INVALID_OPERATION', message: 'Operation must be an object.' }
131
- };
132
- }
133
-
134
- const rawType = operation.type;
135
- if (rawType != null && rawType !== '' && !SUPPORTED_OPERATION_TYPES.has(rawType)) {
136
- return {
137
- valid: false,
138
- error: {
139
- code: 'INVALID_OPERATION',
140
- message: `Unsupported operation type: "${String(rawType)}".`
141
- }
142
- };
143
- }
144
-
145
- const normalized = normalizeDocumentOperation(operation);
146
- if (isRecord(operation.target) && operation.target.revisionView != null) {
147
- if (operation.target.revisionView !== 'accepted' && operation.target.revisionView !== 'rejected') {
148
- return {
149
- valid: false,
150
- error: {
151
- code: 'INVALID_OPERATION',
152
- message: 'Target revisionView must be "accepted" or "rejected" when provided.'
153
- }
154
- };
155
- }
156
- }
157
-
158
- if (isRecord(operation.targetEnd) && operation.targetEnd.revisionView != null) {
159
- if (operation.targetEnd.revisionView !== 'accepted' && operation.targetEnd.revisionView !== 'rejected') {
160
- return {
161
- valid: false,
162
- error: {
163
- code: 'INVALID_OPERATION',
164
- message: 'Target revisionView must be "accepted" or "rejected" when provided.'
165
- }
166
- };
167
- }
168
- }
169
-
170
- if (operation.operationId != null && (!nonEmptyString(operation.operationId) || operation.operationId.trim().length > 256)) {
171
- return {
172
- valid: false,
173
- error: { code: 'INVALID_OPERATION', message: 'operationId must be a non-empty string under 256 characters when provided.' }
174
- };
175
- }
176
-
177
- if (operation.captureKey != null && (!nonEmptyString(operation.captureKey) || operation.captureKey.trim().length > 256)) {
178
- return {
179
- valid: false,
180
- error: { code: 'INVALID_OPERATION', message: 'captureKey must be a non-empty string under 256 characters when provided.' }
181
- };
182
- }
183
-
184
- if (isRecord(operation.target) && operation.target.captureRef != null && (!nonEmptyString(operation.target.captureRef) || operation.target.captureRef.trim().length > 256)) {
185
- return {
186
- valid: false,
187
- error: { code: 'INVALID_OPERATION', message: 'target.captureRef must be a non-empty string under 256 characters when provided.' }
188
- };
189
- }
190
-
191
- if (isRecord(operation.target) && operation.target.select != null && typeof operation.target.select !== 'string') {
192
- return {
193
- valid: false,
194
- error: { code: 'INVALID_OPERATION', message: 'target.select must be a string when provided.' }
195
- };
196
- }
197
-
198
- const target = normalized.targetDescriptor;
199
- if (
200
- !isCommentThreadOperationKind(normalized.operationKind)
201
- && !nonEmptyString(target.text)
202
- && target.index == null
203
- && !target.paragraphId
204
- && !target.fingerprint
205
- && !target.captureRef
206
- ) {
207
- return {
208
- valid: false,
209
- error: {
210
- code: 'INVALID_OPERATION',
211
- message: 'Operation target must provide text, a paragraph index, a paragraphId, or a captureRef.'
212
- }
213
- };
214
- }
215
-
216
- if (target.occurrence != null && !nonEmptyString(target.text)) {
217
- return {
218
- valid: false,
219
- error: {
220
- code: 'INVALID_OPERATION',
221
- message: 'Target occurrence requires target text.'
222
- }
223
- };
224
- }
225
-
226
- if (normalized.operationKind === 'redline') {
227
- const hasModified = typeof normalized.modified === 'string';
228
- const hasReplacements = normalized.replacements !== undefined;
229
- if (hasModified === hasReplacements) {
230
- return {
231
- valid: false,
232
- error: {
233
- code: 'INVALID_OPERATION',
234
- message: 'Redline operations require exactly one of string "modified" or non-empty "replacements".'
235
- }
236
- };
237
- }
238
- if (hasReplacements) {
239
- if (rawType != null && rawType !== '' && !['redline', 'replace'].includes(rawType)) {
240
- return {
241
- valid: false,
242
- error: {
243
- code: 'INVALID_OPERATION',
244
- message: 'Localized replacements are supported only for redline or replace operations.'
245
- }
246
- };
247
- }
248
- if (
249
- target.revisionView !== 'accepted'
250
- || target.captureRef
251
- || target.occurrence != null
252
- || normalized.captureKey
253
- || normalized.targetEndDescriptor
254
- || normalized.targetEndRef != null
255
- || /\r|\n/.test(target.text || '')
256
- ) {
257
- return {
258
- valid: false,
259
- error: {
260
- code: 'INVALID_OPERATION',
261
- message: 'Localized replacements v1 require one batch-start accepted-view paragraph target without captures, target occurrence, or a target range.'
262
- }
263
- };
264
- }
265
- const replacementValidation = validateExactReplacementRequests(normalized.replacements);
266
- if (!replacementValidation.ok) {
267
- return { valid: false, error: replacementValidation.error };
268
- }
269
- if (replacementValidation.replacements.some(replacement => /\r|\n/.test(replacement.replace))) {
270
- return {
271
- valid: false,
272
- error: {
273
- code: 'INVALID_OPERATION',
274
- message: 'Localized replacements v1 cannot insert paragraph breaks.'
275
- }
276
- };
277
- }
278
- normalized.replacements = replacementValidation.replacements;
279
- }
280
- }
281
-
282
- if (normalized.operationKind === 'rejected-insert') {
283
- if (!nonEmptyString(normalized.modified)) {
284
- return {
285
- valid: false,
286
- error: { code: 'INVALID_OPERATION', message: 'Rejected-view insert operations require non-empty string "modified" text.' }
287
- };
288
- }
289
- if (
290
- !normalized.anchor
291
- || !nonEmptyString(normalized.anchor.exactText)
292
- || !Number.isInteger(normalized.anchor.offset)
293
- || normalized.anchor.offset < 0
294
- || normalized.anchor.offset > normalized.anchor.exactText.length
295
- ) {
296
- return {
297
- valid: false,
298
- error: {
299
- code: 'INVALID_OPERATION',
300
- message: 'Rejected-view insert operations require anchor.exactText, a positive occurrence, and an offset within the anchor text.'
301
- }
302
- };
303
- }
304
- if (normalized.existingRevisions !== 'slice-cross-author') {
305
- return {
306
- valid: false,
307
- error: {
308
- code: 'INVALID_OPERATION',
309
- message: 'Rejected-view insert operations require existingRevisions: "slice-cross-author".'
310
- }
311
- };
312
- }
313
- }
314
-
315
- if (normalized.operationKind === 'restore') {
316
- const hasModified = operation.modified !== undefined;
317
- const hasReplacements = operation.replacements !== undefined;
318
- const validSingle = nonEmptyString(normalized.modified);
319
- const validRange = Array.isArray(normalized.modified)
320
- && normalized.modified.length > 0
321
- && normalized.modified.every(nonEmptyString);
322
- if (hasModified && hasReplacements) {
323
- return {
324
- valid: false,
325
- error: {
326
- code: 'INVALID_OPERATION',
327
- message: 'Restore operations accept modified or replacements, not both.'
328
- }
329
- };
330
- }
331
- if (hasModified && !validSingle && !validRange) {
332
- return {
333
- valid: false,
334
- error: {
335
- code: 'INVALID_OPERATION',
336
- message: 'Restore modified must be a non-empty string or non-empty string array.'
337
- }
338
- };
339
- }
340
- if (!hasModified) {
341
- if (
342
- target.revisionView !== 'rejected'
343
- || (!target.paragraphId && !target.fingerprint)
344
- || target.captureRef
345
- || target.occurrence != null
346
- || normalized.captureKey
347
- || normalized.targetEndDescriptor
348
- || normalized.targetEndRef != null
349
- || /\r|\n/.test(target.text || '')
350
- ) {
351
- return {
352
- valid: false,
353
- error: {
354
- code: 'INVALID_OPERATION',
355
- message: 'Verbatim and localized restore require one strong batch-start rejected-view paragraph target without captures, target occurrence, or a target range.'
356
- }
357
- };
358
- }
359
- }
360
- if (hasReplacements) {
361
- const replacementValidation = validateExactReplacementRequests(normalized.replacements);
362
- if (!replacementValidation.ok) {
363
- return { valid: false, error: replacementValidation.error };
364
- }
365
- if (replacementValidation.replacements.some(replacement => /\r|\n/.test(replacement.replace))) {
366
- return {
367
- valid: false,
368
- error: {
369
- code: 'INVALID_OPERATION',
370
- message: 'Localized restore cannot insert paragraph breaks.'
371
- }
372
- };
373
- }
374
- normalized.replacements = replacementValidation.replacements;
375
- }
376
- if (normalized.generateRedlines === false) {
377
- return {
378
- valid: false,
379
- error: {
380
- code: 'INVALID_OPERATION',
381
- message: 'Restore operations require tracked changes and cannot set generateRedlines to false.'
382
- }
383
- };
384
- }
385
- }
386
-
387
- if (normalized.structuredContent != null && typeof normalized.structuredContent !== 'boolean') {
388
- return {
389
- valid: false,
390
- error: { code: 'INVALID_OPERATION', message: 'structuredContent must be a boolean when provided.' }
391
- };
392
- }
393
-
394
- if (normalized.pairReplacements != null && typeof normalized.pairReplacements !== 'boolean') {
395
- return {
396
- valid: false,
397
- error: { code: 'INVALID_OPERATION', message: 'pairReplacements must be a boolean when provided.' }
398
- };
399
- }
400
-
401
- if (normalized.existingRevisions != null && !isExistingRevisionsPolicy(normalized.existingRevisions)) {
402
- return {
403
- valid: false,
404
- error: {
405
- code: 'INVALID_OPERATION',
406
- message: `existingRevisions must be one of: ${EXISTING_REVISIONS_POLICIES.join(', ')}.`
407
- }
408
- };
409
- }
410
-
411
- if (normalized.insertionAffinity != null) {
412
- if (!isRecord(normalized.insertionAffinity)) {
413
- return {
414
- valid: false,
415
- error: { code: 'INVALID_OPERATION', message: 'insertionAffinity must be an object when provided.' }
416
- };
417
- }
418
- const { formatting, hyperlink, revision, bookmark, comment } = normalized.insertionAffinity;
419
- if (formatting != null && !['left', 'right', 'none'].includes(formatting)) {
420
- return {
421
- valid: false,
422
- error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.formatting must be "left", "right", or "none".' }
423
- };
424
- }
425
- if (hyperlink != null && !['inside', 'outside', 'preserve'].includes(hyperlink)) {
426
- return {
427
- valid: false,
428
- error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.hyperlink must be "inside", "outside", or "preserve".' }
429
- };
430
- }
431
- if (revision != null && !['coalesce_same_author', 'separate'].includes(revision)) {
432
- return {
433
- valid: false,
434
- error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.revision must be "coalesce_same_author" or "separate".' }
435
- };
436
- }
437
- if (bookmark != null && !['inside', 'outside'].includes(bookmark)) {
438
- return {
439
- valid: false,
440
- error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.bookmark must be "inside" or "outside".' }
441
- };
442
- }
443
- if (comment != null && !['inside', 'outside'].includes(comment)) {
444
- return {
445
- valid: false,
446
- error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.comment must be "inside" or "outside".' }
447
- };
448
- }
449
- }
450
-
451
- if (normalized.operationKind === 'comment') {
452
- if (!nonEmptyString(normalized.commentContent)) {
453
- return {
454
- valid: false,
455
- error: { code: 'INVALID_OPERATION', message: 'Comment operations require a non-empty "commentContent" field.' }
456
- };
457
- }
458
- }
459
-
460
- if (normalized.operationKind === 'comment_reply') {
461
- if (!nonEmptyString(String(normalized.parentCommentId ?? ''))) {
462
- return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment reply operations require a "parentCommentId".' } };
463
- }
464
- if (!nonEmptyString(normalized.commentContent)) {
465
- return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment reply operations require a non-empty "commentContent" field.' } };
466
- }
467
- }
468
-
469
- if (normalized.operationKind === 'comment_resolve') {
470
- if (!nonEmptyString(String(normalized.commentId ?? ''))) {
471
- return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment resolve operations require a "commentId".' } };
472
- }
473
- if (normalized.resolved != null && typeof normalized.resolved !== 'boolean') {
474
- return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment resolve "resolved" must be a boolean when provided.' } };
475
- }
476
- }
477
-
478
- if (normalized.operationKind === 'highlight') {
479
- if (!nonEmptyString(normalized.textToHighlight)) {
480
- return {
481
- valid: false,
482
- error: { code: 'INVALID_OPERATION', message: 'Highlight operations require a non-empty "textToHighlight" field.' }
483
- };
484
- }
485
- if (normalized.color != null && !nonEmptyString(normalized.color)) {
486
- return {
487
- valid: false,
488
- error: { code: 'INVALID_OPERATION', message: 'Highlight color must be a non-empty string when provided.' }
489
- };
490
- }
491
- }
492
-
493
- if (normalized.operationKind === 'format') {
494
- if (!nonEmptyString(normalized.textToFormat)) {
495
- return {
496
- valid: false,
497
- error: { code: 'INVALID_OPERATION', message: 'Format operations require a non-empty "textToFormat" field.' }
498
- };
499
- }
500
- if (!isRecord(normalized.properties)) {
501
- return {
502
- valid: false,
503
- error: { code: 'INVALID_OPERATION', message: 'Format operations require a "properties" object.' }
504
- };
505
- }
506
- if (normalized.formattingRevisionPolicy != null && !['always', 'coalesce-own-insertion'].includes(normalized.formattingRevisionPolicy)) {
507
- return {
508
- valid: false,
509
- error: { code: 'INVALID_OPERATION', message: 'formattingRevisionPolicy must be "always" or "coalesce-own-insertion".' }
510
- };
511
- }
512
- }
513
-
514
- if (normalized.operationKind === 'paragraph-format') {
515
- if (!isRecord(normalized.properties)) {
516
- return {
517
- valid: false,
518
- error: { code: 'INVALID_OPERATION', message: 'Paragraph-format operations require a "properties" object.' }
519
- };
520
- }
521
- }
522
-
523
- if (normalized.author != null && !nonEmptyString(normalized.author)) {
524
- return {
525
- valid: false,
526
- error: { code: 'INVALID_OPERATION', message: 'Operation author must be a non-empty string when provided.' }
527
- };
528
- }
529
-
530
- return { valid: true, operation: normalized };
531
- }
532
-
533
- export function resolveDocumentOperationAuthor(operation, batchAuthor, fallbackAuthor) {
534
- if (nonEmptyString(operation?.author)) return operation.author.trim();
535
- if (nonEmptyString(batchAuthor)) return batchAuthor.trim();
536
- return nonEmptyString(fallbackAuthor) ? fallbackAuthor.trim() : 'Author';
537
- }
1
+ /**
2
+ * Public document-operation normalization and validation.
3
+ *
4
+ * Keep compatibility aliases at this boundary so the runner and preflight
5
+ * logic can operate on one internal shape.
6
+ */
7
+
8
+ import { validateExactReplacementRequests } from './localized-replacement-compiler.js';
9
+
10
+ const SUPPORTED_OPERATION_TYPES = new Set([
11
+ 'redline',
12
+ 'replace',
13
+ 'format',
14
+ 'character-format',
15
+ 'paragraph-format',
16
+ 'list-change',
17
+ 'table-reconciliation',
18
+ 'insert',
19
+ 'restore',
20
+ 'delete',
21
+ 'comment',
22
+ 'comment_reply',
23
+ 'comment_resolve',
24
+ 'highlight'
25
+ ]);
26
+
27
+ export const EXISTING_REVISIONS_POLICIES = Object.freeze([
28
+ 'merge-same-author',
29
+ 'slice-cross-author',
30
+ 'reject-input',
31
+ 'accept-all-first',
32
+ 'accept-all-first-keep-normalized'
33
+ ]);
34
+
35
+ export function isExistingRevisionsPolicy(value) {
36
+ return EXISTING_REVISIONS_POLICIES.includes(value);
37
+ }
38
+
39
+ function isRecord(value) {
40
+ return !!value && typeof value === 'object' && !Array.isArray(value);
41
+ }
42
+
43
+ function nonEmptyString(value) {
44
+ return typeof value === 'string' && value.trim().length > 0;
45
+ }
46
+
47
+ /** Operations that address an existing comment thread by id instead of targeting a paragraph. */
48
+ export function isCommentThreadOperationKind(kind) {
49
+ return kind === 'comment_reply' || kind === 'comment_resolve';
50
+ }
51
+
52
+ export function getCanonicalOperationType(operation) {
53
+ const type = operation?.type;
54
+ if (type === 'insert' && operation?.target?.revisionView === 'rejected') return 'rejected-insert';
55
+ if (type === 'restore') return 'restore';
56
+ if (type === 'comment' || type === 'comment_reply' || type === 'comment_resolve' || type === 'highlight') return type;
57
+ if (type === 'paragraph-format') return 'paragraph-format';
58
+ if (type === 'character-format' || (type === 'format' && (operation?.textToFormat != null || operation?.properties != null))) return 'format';
59
+ return 'redline';
60
+ }
61
+
62
+ export function normalizeTargetDescriptor(target, legacyTargetRef = null, defaultRevisionView = 'accepted') {
63
+ const fallbackRevisionView = defaultRevisionView === 'rejected' ? 'rejected' : 'accepted';
64
+ if (!isRecord(target)) {
65
+ return {
66
+ text: typeof target === 'string' ? target : '',
67
+ index: legacyTargetRef ?? null,
68
+ paragraphId: null,
69
+ occurrence: null,
70
+ inTable: null,
71
+ fingerprint: null,
72
+ revisionView: fallbackRevisionView
73
+ };
74
+ }
75
+
76
+ return {
77
+ text: typeof target.exactText === 'string'
78
+ ? target.exactText
79
+ : (typeof target.text === 'string' ? target.text : ''),
80
+ index: target.index ?? target.paragraphIndex ?? legacyTargetRef ?? null,
81
+ paragraphId: nonEmptyString(target.paragraphId) ? target.paragraphId.trim() : null,
82
+ occurrence: Number.isInteger(target.occurrence) && target.occurrence > 0
83
+ ? target.occurrence
84
+ : null,
85
+ inTable: typeof target.inTable === 'boolean' ? target.inTable : null,
86
+ fingerprint: nonEmptyString(target.fingerprint)
87
+ ? target.fingerprint.trim()
88
+ : (nonEmptyString(target.sourceFingerprint) ? target.sourceFingerprint.trim() : null),
89
+ revisionView: target.revisionView === 'rejected'
90
+ ? 'rejected'
91
+ : (target.revisionView === 'accepted' ? 'accepted' : fallbackRevisionView),
92
+ captureRef: nonEmptyString(target.captureRef) ? target.captureRef.trim() : null,
93
+ select: typeof target.select === 'string' ? target.select : null
94
+ };
95
+ }
96
+
97
+ export function normalizeDocumentOperation(operation) {
98
+ const source = isRecord(operation) ? operation : {};
99
+ const kind = getCanonicalOperationType(source);
100
+ const defaultRevisionView = kind === 'restore' ? 'rejected' : 'accepted';
101
+ const targetDescriptor = normalizeTargetDescriptor(source.target, source.targetRef, defaultRevisionView);
102
+ const targetEndDescriptor = source.targetEnd != null
103
+ ? normalizeTargetDescriptor(source.targetEnd, source.targetEndRef, defaultRevisionView)
104
+ : null;
105
+
106
+ return {
107
+ ...source,
108
+ operationId: nonEmptyString(source.operationId) ? source.operationId.trim() : null,
109
+ captureKey: nonEmptyString(source.captureKey) ? source.captureKey.trim() : null,
110
+ operationKind: kind,
111
+ anchor: isRecord(source.anchor) ? {
112
+ exactText: typeof source.anchor.exactText === 'string' ? source.anchor.exactText : '',
113
+ occurrence: Number.isInteger(source.anchor.occurrence) && source.anchor.occurrence > 0 ? source.anchor.occurrence : 1,
114
+ occurrenceExplicit: Number.isInteger(source.anchor.occurrence) && source.anchor.occurrence > 0,
115
+ offset: Number.isInteger(source.anchor.offset) ? source.anchor.offset : null
116
+ } : null,
117
+ targetDescriptor,
118
+ targetEndDescriptor,
119
+ target: targetDescriptor.text,
120
+ targetRef: targetDescriptor.index,
121
+ targetEndRef: targetEndDescriptor?.index ?? source.targetEndRef ?? null,
122
+ ...(source.type === 'delete' && source.modified == null ? { modified: '' } : {})
123
+ };
124
+ }
125
+
126
+ export function validateDocumentOperation(operation) {
127
+ if (!isRecord(operation)) {
128
+ return {
129
+ valid: false,
130
+ error: { code: 'INVALID_OPERATION', message: 'Operation must be an object.' }
131
+ };
132
+ }
133
+
134
+ const rawType = operation.type;
135
+ if (rawType != null && rawType !== '' && !SUPPORTED_OPERATION_TYPES.has(rawType)) {
136
+ return {
137
+ valid: false,
138
+ error: {
139
+ code: 'INVALID_OPERATION',
140
+ message: `Unsupported operation type: "${String(rawType)}".`
141
+ }
142
+ };
143
+ }
144
+
145
+ const normalized = normalizeDocumentOperation(operation);
146
+ if (isRecord(operation.target) && operation.target.revisionView != null) {
147
+ if (operation.target.revisionView !== 'accepted' && operation.target.revisionView !== 'rejected') {
148
+ return {
149
+ valid: false,
150
+ error: {
151
+ code: 'INVALID_OPERATION',
152
+ message: 'Target revisionView must be "accepted" or "rejected" when provided.'
153
+ }
154
+ };
155
+ }
156
+ }
157
+
158
+ if (isRecord(operation.targetEnd) && operation.targetEnd.revisionView != null) {
159
+ if (operation.targetEnd.revisionView !== 'accepted' && operation.targetEnd.revisionView !== 'rejected') {
160
+ return {
161
+ valid: false,
162
+ error: {
163
+ code: 'INVALID_OPERATION',
164
+ message: 'Target revisionView must be "accepted" or "rejected" when provided.'
165
+ }
166
+ };
167
+ }
168
+ }
169
+
170
+ if (operation.operationId != null && (!nonEmptyString(operation.operationId) || operation.operationId.trim().length > 256)) {
171
+ return {
172
+ valid: false,
173
+ error: { code: 'INVALID_OPERATION', message: 'operationId must be a non-empty string under 256 characters when provided.' }
174
+ };
175
+ }
176
+
177
+ if (operation.captureKey != null && (!nonEmptyString(operation.captureKey) || operation.captureKey.trim().length > 256)) {
178
+ return {
179
+ valid: false,
180
+ error: { code: 'INVALID_OPERATION', message: 'captureKey must be a non-empty string under 256 characters when provided.' }
181
+ };
182
+ }
183
+
184
+ if (isRecord(operation.target) && operation.target.captureRef != null && (!nonEmptyString(operation.target.captureRef) || operation.target.captureRef.trim().length > 256)) {
185
+ return {
186
+ valid: false,
187
+ error: { code: 'INVALID_OPERATION', message: 'target.captureRef must be a non-empty string under 256 characters when provided.' }
188
+ };
189
+ }
190
+
191
+ if (isRecord(operation.target) && operation.target.select != null && typeof operation.target.select !== 'string') {
192
+ return {
193
+ valid: false,
194
+ error: { code: 'INVALID_OPERATION', message: 'target.select must be a string when provided.' }
195
+ };
196
+ }
197
+
198
+ const target = normalized.targetDescriptor;
199
+ if (
200
+ !isCommentThreadOperationKind(normalized.operationKind)
201
+ && !nonEmptyString(target.text)
202
+ && target.index == null
203
+ && !target.paragraphId
204
+ && !target.fingerprint
205
+ && !target.captureRef
206
+ ) {
207
+ return {
208
+ valid: false,
209
+ error: {
210
+ code: 'INVALID_OPERATION',
211
+ message: 'Operation target must provide text, a paragraph index, a paragraphId, or a captureRef.'
212
+ }
213
+ };
214
+ }
215
+
216
+ if (target.occurrence != null && !nonEmptyString(target.text)) {
217
+ return {
218
+ valid: false,
219
+ error: {
220
+ code: 'INVALID_OPERATION',
221
+ message: 'Target occurrence requires target text.'
222
+ }
223
+ };
224
+ }
225
+
226
+ if (normalized.operationKind === 'redline') {
227
+ const hasModified = typeof normalized.modified === 'string';
228
+ const hasReplacements = normalized.replacements !== undefined;
229
+ if (hasModified === hasReplacements) {
230
+ return {
231
+ valid: false,
232
+ error: {
233
+ code: 'INVALID_OPERATION',
234
+ message: 'Redline operations require exactly one of string "modified" or non-empty "replacements".'
235
+ }
236
+ };
237
+ }
238
+ if (hasReplacements) {
239
+ if (rawType != null && rawType !== '' && !['redline', 'replace'].includes(rawType)) {
240
+ return {
241
+ valid: false,
242
+ error: {
243
+ code: 'INVALID_OPERATION',
244
+ message: 'Localized replacements are supported only for redline or replace operations.'
245
+ }
246
+ };
247
+ }
248
+ if (
249
+ target.revisionView !== 'accepted'
250
+ || target.captureRef
251
+ || target.occurrence != null
252
+ || normalized.captureKey
253
+ || normalized.targetEndDescriptor
254
+ || normalized.targetEndRef != null
255
+ ) {
256
+ return {
257
+ valid: false,
258
+ error: {
259
+ code: 'INVALID_OPERATION',
260
+ message: 'Localized replacements v1 require one batch-start accepted-view paragraph target without captures, target occurrence, or a target range.'
261
+ }
262
+ };
263
+ }
264
+ const replacementValidation = validateExactReplacementRequests(normalized.replacements);
265
+ if (!replacementValidation.ok) {
266
+ return { valid: false, error: replacementValidation.error };
267
+ }
268
+ if (replacementValidation.replacements.some(replacement => /\r|\n/.test(replacement.replace))) {
269
+ return {
270
+ valid: false,
271
+ error: {
272
+ code: 'INVALID_OPERATION',
273
+ message: 'Localized replacements v1 cannot insert paragraph breaks.'
274
+ }
275
+ };
276
+ }
277
+ // A soft line break (w:br reads as "\n") is not text the localized
278
+ // diff can delete, so a find spanning one would keep the break.
279
+ if (replacementValidation.replacements.some(replacement => /\r|\n/.test(replacement.find))) {
280
+ return {
281
+ valid: false,
282
+ error: {
283
+ code: 'INVALID_OPERATION',
284
+ message: 'Localized replacements v1 cannot span a line break; use one replacement per line or a full "modified" paragraph.'
285
+ }
286
+ };
287
+ }
288
+ normalized.replacements = replacementValidation.replacements;
289
+ }
290
+ }
291
+
292
+ if (normalized.operationKind === 'rejected-insert') {
293
+ if (!nonEmptyString(normalized.modified)) {
294
+ return {
295
+ valid: false,
296
+ error: { code: 'INVALID_OPERATION', message: 'Rejected-view insert operations require non-empty string "modified" text.' }
297
+ };
298
+ }
299
+ if (
300
+ !normalized.anchor
301
+ || !nonEmptyString(normalized.anchor.exactText)
302
+ || !Number.isInteger(normalized.anchor.offset)
303
+ || normalized.anchor.offset < 0
304
+ || normalized.anchor.offset > normalized.anchor.exactText.length
305
+ ) {
306
+ return {
307
+ valid: false,
308
+ error: {
309
+ code: 'INVALID_OPERATION',
310
+ message: 'Rejected-view insert operations require anchor.exactText, a positive occurrence, and an offset within the anchor text.'
311
+ }
312
+ };
313
+ }
314
+ if (normalized.existingRevisions !== 'slice-cross-author') {
315
+ return {
316
+ valid: false,
317
+ error: {
318
+ code: 'INVALID_OPERATION',
319
+ message: 'Rejected-view insert operations require existingRevisions: "slice-cross-author".'
320
+ }
321
+ };
322
+ }
323
+ }
324
+
325
+ if (normalized.operationKind === 'restore') {
326
+ const hasModified = operation.modified !== undefined;
327
+ const hasReplacements = operation.replacements !== undefined;
328
+ const validSingle = nonEmptyString(normalized.modified);
329
+ const validRange = Array.isArray(normalized.modified)
330
+ && normalized.modified.length > 0
331
+ && normalized.modified.every(nonEmptyString);
332
+ if (hasModified && hasReplacements) {
333
+ return {
334
+ valid: false,
335
+ error: {
336
+ code: 'INVALID_OPERATION',
337
+ message: 'Restore operations accept modified or replacements, not both.'
338
+ }
339
+ };
340
+ }
341
+ if (hasModified && !validSingle && !validRange) {
342
+ return {
343
+ valid: false,
344
+ error: {
345
+ code: 'INVALID_OPERATION',
346
+ message: 'Restore modified must be a non-empty string or non-empty string array.'
347
+ }
348
+ };
349
+ }
350
+ if (!hasModified) {
351
+ if (
352
+ target.revisionView !== 'rejected'
353
+ || (!target.paragraphId && !target.fingerprint)
354
+ || target.captureRef
355
+ || target.occurrence != null
356
+ || normalized.captureKey
357
+ || normalized.targetEndDescriptor
358
+ || normalized.targetEndRef != null
359
+ || /\r|\n/.test(target.text || '')
360
+ ) {
361
+ return {
362
+ valid: false,
363
+ error: {
364
+ code: 'INVALID_OPERATION',
365
+ message: 'Verbatim and localized restore require one strong batch-start rejected-view paragraph target without captures, target occurrence, or a target range.'
366
+ }
367
+ };
368
+ }
369
+ }
370
+ if (hasReplacements) {
371
+ const replacementValidation = validateExactReplacementRequests(normalized.replacements);
372
+ if (!replacementValidation.ok) {
373
+ return { valid: false, error: replacementValidation.error };
374
+ }
375
+ if (replacementValidation.replacements.some(replacement => /\r|\n/.test(replacement.replace))) {
376
+ return {
377
+ valid: false,
378
+ error: {
379
+ code: 'INVALID_OPERATION',
380
+ message: 'Localized restore cannot insert paragraph breaks.'
381
+ }
382
+ };
383
+ }
384
+ normalized.replacements = replacementValidation.replacements;
385
+ }
386
+ if (normalized.generateRedlines === false) {
387
+ return {
388
+ valid: false,
389
+ error: {
390
+ code: 'INVALID_OPERATION',
391
+ message: 'Restore operations require tracked changes and cannot set generateRedlines to false.'
392
+ }
393
+ };
394
+ }
395
+ }
396
+
397
+ if (normalized.structuredContent != null && typeof normalized.structuredContent !== 'boolean') {
398
+ return {
399
+ valid: false,
400
+ error: { code: 'INVALID_OPERATION', message: 'structuredContent must be a boolean when provided.' }
401
+ };
402
+ }
403
+
404
+ if (normalized.pairReplacements != null && typeof normalized.pairReplacements !== 'boolean') {
405
+ return {
406
+ valid: false,
407
+ error: { code: 'INVALID_OPERATION', message: 'pairReplacements must be a boolean when provided.' }
408
+ };
409
+ }
410
+
411
+ if (normalized.existingRevisions != null && !isExistingRevisionsPolicy(normalized.existingRevisions)) {
412
+ return {
413
+ valid: false,
414
+ error: {
415
+ code: 'INVALID_OPERATION',
416
+ message: `existingRevisions must be one of: ${EXISTING_REVISIONS_POLICIES.join(', ')}.`
417
+ }
418
+ };
419
+ }
420
+
421
+ if (normalized.insertionAffinity != null) {
422
+ if (!isRecord(normalized.insertionAffinity)) {
423
+ return {
424
+ valid: false,
425
+ error: { code: 'INVALID_OPERATION', message: 'insertionAffinity must be an object when provided.' }
426
+ };
427
+ }
428
+ const { formatting, hyperlink, revision, bookmark, comment } = normalized.insertionAffinity;
429
+ if (formatting != null && !['left', 'right', 'none'].includes(formatting)) {
430
+ return {
431
+ valid: false,
432
+ error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.formatting must be "left", "right", or "none".' }
433
+ };
434
+ }
435
+ if (hyperlink != null && !['inside', 'outside', 'preserve'].includes(hyperlink)) {
436
+ return {
437
+ valid: false,
438
+ error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.hyperlink must be "inside", "outside", or "preserve".' }
439
+ };
440
+ }
441
+ if (revision != null && !['coalesce_same_author', 'separate'].includes(revision)) {
442
+ return {
443
+ valid: false,
444
+ error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.revision must be "coalesce_same_author" or "separate".' }
445
+ };
446
+ }
447
+ if (bookmark != null && !['inside', 'outside'].includes(bookmark)) {
448
+ return {
449
+ valid: false,
450
+ error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.bookmark must be "inside" or "outside".' }
451
+ };
452
+ }
453
+ if (comment != null && !['inside', 'outside'].includes(comment)) {
454
+ return {
455
+ valid: false,
456
+ error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.comment must be "inside" or "outside".' }
457
+ };
458
+ }
459
+ }
460
+
461
+ if (normalized.operationKind === 'comment') {
462
+ if (!nonEmptyString(normalized.commentContent)) {
463
+ return {
464
+ valid: false,
465
+ error: { code: 'INVALID_OPERATION', message: 'Comment operations require a non-empty "commentContent" field.' }
466
+ };
467
+ }
468
+ }
469
+
470
+ if (normalized.operationKind === 'comment_reply') {
471
+ if (!nonEmptyString(String(normalized.parentCommentId ?? ''))) {
472
+ return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment reply operations require a "parentCommentId".' } };
473
+ }
474
+ if (!nonEmptyString(normalized.commentContent)) {
475
+ return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment reply operations require a non-empty "commentContent" field.' } };
476
+ }
477
+ }
478
+
479
+ if (normalized.operationKind === 'comment_resolve') {
480
+ if (!nonEmptyString(String(normalized.commentId ?? ''))) {
481
+ return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment resolve operations require a "commentId".' } };
482
+ }
483
+ if (normalized.resolved != null && typeof normalized.resolved !== 'boolean') {
484
+ return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment resolve "resolved" must be a boolean when provided.' } };
485
+ }
486
+ }
487
+
488
+ if (normalized.operationKind === 'highlight') {
489
+ if (!nonEmptyString(normalized.textToHighlight)) {
490
+ return {
491
+ valid: false,
492
+ error: { code: 'INVALID_OPERATION', message: 'Highlight operations require a non-empty "textToHighlight" field.' }
493
+ };
494
+ }
495
+ if (normalized.color != null && !nonEmptyString(normalized.color)) {
496
+ return {
497
+ valid: false,
498
+ error: { code: 'INVALID_OPERATION', message: 'Highlight color must be a non-empty string when provided.' }
499
+ };
500
+ }
501
+ }
502
+
503
+ if (normalized.operationKind === 'format') {
504
+ if (!nonEmptyString(normalized.textToFormat)) {
505
+ return {
506
+ valid: false,
507
+ error: { code: 'INVALID_OPERATION', message: 'Format operations require a non-empty "textToFormat" field.' }
508
+ };
509
+ }
510
+ if (normalized.textOccurrence != null && (!Number.isInteger(normalized.textOccurrence) || normalized.textOccurrence < 1)) {
511
+ return {
512
+ valid: false,
513
+ error: { code: 'INVALID_OPERATION', message: 'textOccurrence must be a positive integer when provided.' }
514
+ };
515
+ }
516
+ if (!isRecord(normalized.properties)) {
517
+ return {
518
+ valid: false,
519
+ error: { code: 'INVALID_OPERATION', message: 'Format operations require a "properties" object.' }
520
+ };
521
+ }
522
+ if (normalized.formattingRevisionPolicy != null && !['always', 'coalesce-own-insertion'].includes(normalized.formattingRevisionPolicy)) {
523
+ return {
524
+ valid: false,
525
+ error: { code: 'INVALID_OPERATION', message: 'formattingRevisionPolicy must be "always" or "coalesce-own-insertion".' }
526
+ };
527
+ }
528
+ }
529
+
530
+ if (normalized.operationKind === 'paragraph-format') {
531
+ if (!isRecord(normalized.properties)) {
532
+ return {
533
+ valid: false,
534
+ error: { code: 'INVALID_OPERATION', message: 'Paragraph-format operations require a "properties" object.' }
535
+ };
536
+ }
537
+ }
538
+
539
+ if (normalized.author != null && !nonEmptyString(normalized.author)) {
540
+ return {
541
+ valid: false,
542
+ error: { code: 'INVALID_OPERATION', message: 'Operation author must be a non-empty string when provided.' }
543
+ };
544
+ }
545
+
546
+ return { valid: true, operation: normalized };
547
+ }
548
+
549
+ export function resolveDocumentOperationAuthor(operation, batchAuthor, fallbackAuthor) {
550
+ if (nonEmptyString(operation?.author)) return operation.author.trim();
551
+ if (nonEmptyString(batchAuthor)) return batchAuthor.trim();
552
+ return nonEmptyString(fallbackAuthor) ? fallbackAuthor.trim() : 'Author';
553
+ }