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