@ansonlai/docx-redline-js 0.6.2 → 0.7.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/AGENTS.md +19 -9
- package/CHANGELOG.md +43 -0
- package/README.md +39 -6
- package/dist/docx-redline-js.esm.js +1 -1
- package/docs/AGENT_FAST_START.md +22 -24
- package/docs/AGENT_KNOWLEDGE_BASE.md +26 -14
- package/docs/SKILL_AUTHORING.md +55 -19
- package/docs/releases/0.7.0.md +199 -0
- package/docs/schemas/document-operations.schema.json +13 -0
- package/docs/validation-reports/2026-09-14-localized-patching-rollout.md +90 -0
- package/index.d.ts +2 -0
- package/node/cli-help.js +20 -2
- package/node/cli.js +259 -34
- package/package.json +5 -1
- package/services/batch-operation-orchestrator.js +25 -16
- package/services/document-operation-applier.js +68 -9
- package/services/document-operation-contract.js +56 -5
- package/services/localized-replacement-compiler.js +304 -0
- package/services/operation-batch-compiler.js +33 -1
- package/services/operation-preflight.js +10 -6
- package/services/standalone-operation-runner.d.ts +55 -3
|
@@ -4,7 +4,8 @@ import {
|
|
|
4
4
|
resolveDocumentOperationAuthor,
|
|
5
5
|
validateDocumentOperation
|
|
6
6
|
} from './document-operation-contract.js';
|
|
7
|
-
import { DocumentOperationSession } from './document-operation-session.js';
|
|
7
|
+
import { DocumentOperationSession } from './document-operation-session.js';
|
|
8
|
+
import { compileOperationBatch } from './operation-batch-compiler.js';
|
|
8
9
|
import {
|
|
9
10
|
validateRevisionToken,
|
|
10
11
|
computeDocumentPartsRevisionToken,
|
|
@@ -31,6 +32,8 @@ import {
|
|
|
31
32
|
import { validateRedlineOoxml } from '../core/redline-validation.js';
|
|
32
33
|
import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
|
|
33
34
|
import { normalizeErrorWithRecovery } from './error-recovery.js';
|
|
35
|
+
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
36
|
+
import { buildLocalizedReplacementChange } from './localized-replacement-compiler.js';
|
|
34
37
|
|
|
35
38
|
export function normalizeOperationError(error, context = {}) {
|
|
36
39
|
return normalizeErrorWithRecovery(error, context);
|
|
@@ -60,7 +63,7 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
60
63
|
};
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
let operation = validation.operation || normalizeDocumentOperation(op);
|
|
64
67
|
const authorUsed = resolveDocumentOperationAuthor(operation, author, getDefaultAuthor());
|
|
65
68
|
|
|
66
69
|
if (
|
|
@@ -144,7 +147,7 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
144
147
|
const session = options?._documentOperationSession instanceof DocumentOperationSession
|
|
145
148
|
? options._documentOperationSession
|
|
146
149
|
: new DocumentOperationSession(documentXml, options);
|
|
147
|
-
if (!session.valid) {
|
|
150
|
+
if (!session.valid) {
|
|
148
151
|
return {
|
|
149
152
|
documentXml,
|
|
150
153
|
hasChanges: false,
|
|
@@ -154,10 +157,45 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
154
157
|
operationType: operation.operationKind,
|
|
155
158
|
authorUsed,
|
|
156
159
|
receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (Array.isArray(operation.replacements)) {
|
|
164
|
+
const compilation = compileOperationBatch(session.document, [op], {
|
|
165
|
+
strictTargets: options.strictTargets !== false,
|
|
166
|
+
onInfo: options.onInfo,
|
|
167
|
+
onWarn: options.onWarn
|
|
168
|
+
});
|
|
169
|
+
const compilationError = compilation.bindings[0]?.error || compilation.conflicts[0] || null;
|
|
170
|
+
if (compilationError) {
|
|
171
|
+
return {
|
|
172
|
+
documentXml,
|
|
173
|
+
hasChanges: false,
|
|
174
|
+
status: 'error',
|
|
175
|
+
error: normalizeOperationError(compilationError, errorContext),
|
|
176
|
+
operationType: operation.operationKind,
|
|
177
|
+
authorUsed,
|
|
178
|
+
receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const compiledValidation = validateDocumentOperation(compilation.compiledOperations[0]);
|
|
182
|
+
if (!compiledValidation.valid) {
|
|
183
|
+
return {
|
|
184
|
+
documentXml,
|
|
185
|
+
hasChanges: false,
|
|
186
|
+
status: 'error',
|
|
187
|
+
error: normalizeOperationError(compiledValidation.error, errorContext),
|
|
188
|
+
operationType: operation.operationKind,
|
|
189
|
+
authorUsed,
|
|
190
|
+
receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
session.sourceTargetRegistry = compilation.registry;
|
|
194
|
+
operation = compiledValidation.operation;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const resolutionCapture = {};
|
|
198
|
+
let localizedChange = null;
|
|
161
199
|
const savepoint = session.createSavepoint();
|
|
162
200
|
session.receiptCollector?.beginOperation(
|
|
163
201
|
operationIndex,
|
|
@@ -375,6 +413,26 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
375
413
|
operationIndex
|
|
376
414
|
);
|
|
377
415
|
}
|
|
416
|
+
if (operation._localizedReplacementCompilation) {
|
|
417
|
+
let outputText = null;
|
|
418
|
+
if (operation._compiledSourceId && session.sourceTargetRegistry) {
|
|
419
|
+
const bound = session.sourceTargetRegistry.resolve(
|
|
420
|
+
operation._compiledSourceId,
|
|
421
|
+
session.document
|
|
422
|
+
);
|
|
423
|
+
if (bound.paragraph) {
|
|
424
|
+
outputText = extractCanonicalParagraphText(bound.paragraph, {
|
|
425
|
+
revisionView: 'accepted'
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
localizedChange = buildLocalizedReplacementChange(
|
|
430
|
+
operation._localizedReplacementCompilation,
|
|
431
|
+
outputText,
|
|
432
|
+
resolutionCapture.resolvedTarget || {},
|
|
433
|
+
operation._speculativeContext || null
|
|
434
|
+
);
|
|
435
|
+
}
|
|
378
436
|
if (operation.captureKey && session.captureTable) {
|
|
379
437
|
session.captureTable.set(
|
|
380
438
|
operation.captureKey,
|
|
@@ -426,8 +484,9 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
426
484
|
...result,
|
|
427
485
|
operationType: operation.operationKind,
|
|
428
486
|
authorUsed,
|
|
429
|
-
receipt: operationReceipt,
|
|
430
|
-
...
|
|
487
|
+
receipt: operationReceipt,
|
|
488
|
+
...(localizedChange ? { change: localizedChange } : {}),
|
|
489
|
+
...resolutionCapture
|
|
431
490
|
};
|
|
432
491
|
} catch (error) {
|
|
433
492
|
session.restoreSavepoint(savepoint);
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* logic can operate on one internal shape.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { validateExactReplacementRequests } from './localized-replacement-compiler.js';
|
|
9
|
+
|
|
8
10
|
const SUPPORTED_OPERATION_TYPES = new Set([
|
|
9
11
|
'redline',
|
|
10
12
|
'replace',
|
|
@@ -215,11 +217,60 @@ export function validateDocumentOperation(operation) {
|
|
|
215
217
|
};
|
|
216
218
|
}
|
|
217
219
|
|
|
218
|
-
if (normalized.operationKind === 'redline'
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
220
|
+
if (normalized.operationKind === 'redline') {
|
|
221
|
+
const hasModified = typeof normalized.modified === 'string';
|
|
222
|
+
const hasReplacements = normalized.replacements !== undefined;
|
|
223
|
+
if (hasModified === hasReplacements) {
|
|
224
|
+
return {
|
|
225
|
+
valid: false,
|
|
226
|
+
error: {
|
|
227
|
+
code: 'INVALID_OPERATION',
|
|
228
|
+
message: 'Redline operations require exactly one of string "modified" or non-empty "replacements".'
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (hasReplacements) {
|
|
233
|
+
if (rawType != null && rawType !== '' && !['redline', 'replace'].includes(rawType)) {
|
|
234
|
+
return {
|
|
235
|
+
valid: false,
|
|
236
|
+
error: {
|
|
237
|
+
code: 'INVALID_OPERATION',
|
|
238
|
+
message: 'Localized replacements are supported only for redline or replace operations.'
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (
|
|
243
|
+
target.revisionView !== 'accepted'
|
|
244
|
+
|| target.captureRef
|
|
245
|
+
|| target.occurrence != null
|
|
246
|
+
|| normalized.captureKey
|
|
247
|
+
|| normalized.targetEndDescriptor
|
|
248
|
+
|| normalized.targetEndRef != null
|
|
249
|
+
|| /\r|\n/.test(target.text || '')
|
|
250
|
+
) {
|
|
251
|
+
return {
|
|
252
|
+
valid: false,
|
|
253
|
+
error: {
|
|
254
|
+
code: 'INVALID_OPERATION',
|
|
255
|
+
message: 'Localized replacements v1 require one batch-start accepted-view paragraph target without captures, target occurrence, or a target range.'
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const replacementValidation = validateExactReplacementRequests(normalized.replacements);
|
|
260
|
+
if (!replacementValidation.ok) {
|
|
261
|
+
return { valid: false, error: replacementValidation.error };
|
|
262
|
+
}
|
|
263
|
+
if (replacementValidation.replacements.some(replacement => /\r|\n/.test(replacement.replace))) {
|
|
264
|
+
return {
|
|
265
|
+
valid: false,
|
|
266
|
+
error: {
|
|
267
|
+
code: 'INVALID_OPERATION',
|
|
268
|
+
message: 'Localized replacements v1 cannot insert paragraph breaks.'
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
normalized.replacements = replacementValidation.replacements;
|
|
273
|
+
}
|
|
223
274
|
}
|
|
224
275
|
|
|
225
276
|
if (normalized.operationKind === 'rejected-insert') {
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { normalizeErrorWithRecovery } from './error-recovery.js';
|
|
2
|
+
|
|
3
|
+
function invalidReplacement(message, field, invalidCode) {
|
|
4
|
+
return normalizeErrorWithRecovery({
|
|
5
|
+
code: invalidCode,
|
|
6
|
+
message,
|
|
7
|
+
...(field ? { field } : {}),
|
|
8
|
+
recovery: {
|
|
9
|
+
action: 'change_request',
|
|
10
|
+
sameArgumentsSafe: false,
|
|
11
|
+
requiresReinspection: false,
|
|
12
|
+
requiresUserAuthorization: false
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function patchError(code, message, field, recoveryAction, details = {}) {
|
|
18
|
+
return normalizeErrorWithRecovery({
|
|
19
|
+
code,
|
|
20
|
+
message,
|
|
21
|
+
field,
|
|
22
|
+
...details,
|
|
23
|
+
recovery: {
|
|
24
|
+
action: recoveryAction,
|
|
25
|
+
sameArgumentsSafe: false,
|
|
26
|
+
requiresReinspection: false,
|
|
27
|
+
requiresUserAuthorization: false
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function occurrenceOffsets(sourceText, find) {
|
|
33
|
+
const offsets = [];
|
|
34
|
+
let from = 0;
|
|
35
|
+
while (from <= sourceText.length - find.length) {
|
|
36
|
+
const start = sourceText.indexOf(find, from);
|
|
37
|
+
if (start < 0) break;
|
|
38
|
+
offsets.push(start);
|
|
39
|
+
from = start + 1;
|
|
40
|
+
}
|
|
41
|
+
return offsets;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function offsetCandidate(sourceText, start, length) {
|
|
45
|
+
const excerptStart = Math.max(0, start - 40);
|
|
46
|
+
const excerptEnd = Math.min(sourceText.length, start + length + 40);
|
|
47
|
+
return {
|
|
48
|
+
start,
|
|
49
|
+
end: start + length,
|
|
50
|
+
excerpt: sourceText.slice(excerptStart, excerptEnd)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function boundedExcerpt(text, start, end, limit = 120) {
|
|
55
|
+
const source = String(text ?? '');
|
|
56
|
+
const safeStart = Math.max(0, Math.min(source.length, start));
|
|
57
|
+
const safeEnd = Math.max(safeStart, Math.min(source.length, end));
|
|
58
|
+
const center = Math.floor((safeStart + safeEnd) / 2);
|
|
59
|
+
let excerptStart = Math.max(0, center - Math.floor(limit / 2));
|
|
60
|
+
let excerptEnd = Math.min(source.length, excerptStart + limit);
|
|
61
|
+
excerptStart = Math.max(0, excerptEnd - limit);
|
|
62
|
+
const prefix = excerptStart > 0 ? '…' : '';
|
|
63
|
+
const suffix = excerptEnd < source.length ? '…' : '';
|
|
64
|
+
const available = Math.max(0, limit - prefix.length - suffix.length);
|
|
65
|
+
return `${prefix}${source.slice(excerptStart, excerptStart + available)}${suffix}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Validate and normalize exact replacement requests without resolving source ranges. */
|
|
69
|
+
export function validateExactReplacementRequests(
|
|
70
|
+
replacements,
|
|
71
|
+
field = 'replacements',
|
|
72
|
+
{ invalidCode = 'INVALID_OPERATION' } = {}
|
|
73
|
+
) {
|
|
74
|
+
if (!Array.isArray(replacements) || replacements.length === 0) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
error: invalidReplacement('replacements must be a non-empty array.', field, invalidCode)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const normalized = [];
|
|
82
|
+
for (let index = 0; index < replacements.length; index += 1) {
|
|
83
|
+
const replacement = replacements[index];
|
|
84
|
+
const itemField = `${field}[${index}]`;
|
|
85
|
+
if (!replacement || typeof replacement !== 'object' || Array.isArray(replacement)) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
error: invalidReplacement(`${itemField} must be an object.`, itemField, invalidCode)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (typeof replacement.find !== 'string' || replacement.find.length === 0) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
error: invalidReplacement(
|
|
95
|
+
`${itemField}.find must be a non-empty string.`,
|
|
96
|
+
`${itemField}.find`,
|
|
97
|
+
invalidCode
|
|
98
|
+
)
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (typeof replacement.replace !== 'string') {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: invalidReplacement(
|
|
105
|
+
`${itemField}.replace must be a string.`,
|
|
106
|
+
`${itemField}.replace`,
|
|
107
|
+
invalidCode
|
|
108
|
+
)
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (replacement.find === replacement.replace) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: invalidReplacement(
|
|
115
|
+
`${itemField} must change the matched text.`,
|
|
116
|
+
itemField,
|
|
117
|
+
invalidCode
|
|
118
|
+
)
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const occurrence = replacement.occurrence == null ? null : Number(replacement.occurrence);
|
|
122
|
+
if (occurrence != null && (!Number.isInteger(occurrence) || occurrence < 1)) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
error: invalidReplacement(
|
|
126
|
+
`${itemField}.occurrence must be a positive integer.`,
|
|
127
|
+
`${itemField}.occurrence`,
|
|
128
|
+
invalidCode
|
|
129
|
+
)
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
normalized.push({
|
|
133
|
+
find: replacement.find,
|
|
134
|
+
replace: replacement.replace,
|
|
135
|
+
...(occurrence == null ? {} : { occurrence })
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { ok: true, replacements: normalized };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Compile exact, source-relative replacement intents into complete desired text.
|
|
144
|
+
* All source ranges are resolved before mutation, so replacements are simultaneous.
|
|
145
|
+
*/
|
|
146
|
+
export function compileExactReplacements(
|
|
147
|
+
sourceText,
|
|
148
|
+
replacements,
|
|
149
|
+
field = 'replacements',
|
|
150
|
+
options = {}
|
|
151
|
+
) {
|
|
152
|
+
const invalidCode = options.invalidCode || 'INVALID_OPERATION';
|
|
153
|
+
if (typeof sourceText !== 'string') {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
error: invalidReplacement(
|
|
157
|
+
'Localized replacements require string source text.',
|
|
158
|
+
field,
|
|
159
|
+
invalidCode
|
|
160
|
+
)
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const validation = validateExactReplacementRequests(replacements, field, { invalidCode });
|
|
164
|
+
if (!validation.ok) return validation;
|
|
165
|
+
|
|
166
|
+
const resolved = [];
|
|
167
|
+
for (let index = 0; index < validation.replacements.length; index += 1) {
|
|
168
|
+
const replacement = validation.replacements[index];
|
|
169
|
+
const itemField = `${field}[${index}]`;
|
|
170
|
+
const occurrence = replacement.occurrence ?? null;
|
|
171
|
+
const offsets = occurrenceOffsets(sourceText, replacement.find);
|
|
172
|
+
if (offsets.length === 0 || (occurrence != null && occurrence > offsets.length)) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
error: patchError(
|
|
176
|
+
'PATCH_SOURCE_NOT_FOUND',
|
|
177
|
+
occurrence == null
|
|
178
|
+
? `Exact patch source was not found: "${replacement.find}".`
|
|
179
|
+
: `Occurrence ${occurrence} of exact patch source was not found: "${replacement.find}".`,
|
|
180
|
+
itemField,
|
|
181
|
+
'change_patch',
|
|
182
|
+
{ matchCount: offsets.length }
|
|
183
|
+
)
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (occurrence == null && offsets.length > 1) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
error: patchError(
|
|
190
|
+
'AMBIGUOUS_PATCH_SOURCE',
|
|
191
|
+
`Exact patch source matched ${offsets.length} locations; provide occurrence.`,
|
|
192
|
+
itemField,
|
|
193
|
+
'choose_occurrence',
|
|
194
|
+
{ candidates: offsets.map(start => offsetCandidate(sourceText, start, replacement.find.length)) }
|
|
195
|
+
)
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const start = offsets[(occurrence || 1) - 1];
|
|
200
|
+
resolved.push({
|
|
201
|
+
requestIndex: index,
|
|
202
|
+
start,
|
|
203
|
+
end: start + replacement.find.length,
|
|
204
|
+
find: replacement.find,
|
|
205
|
+
replace: replacement.replace,
|
|
206
|
+
occurrence: occurrence || 1
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
resolved.sort((left, right) => left.start - right.start || left.end - right.end || left.requestIndex - right.requestIndex);
|
|
211
|
+
const unique = [];
|
|
212
|
+
for (const replacement of resolved) {
|
|
213
|
+
const previous = unique.at(-1);
|
|
214
|
+
if (previous && replacement.start === previous.start && replacement.end === previous.end) {
|
|
215
|
+
if (replacement.replace !== previous.replace) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
error: patchError(
|
|
219
|
+
'CONFLICTING_PATCHES',
|
|
220
|
+
`Localized replacements ${previous.requestIndex + 1} and ${replacement.requestIndex + 1} assign different text to the same source range.`,
|
|
221
|
+
field,
|
|
222
|
+
'combine_patches',
|
|
223
|
+
{ replacementIndexes: [previous.requestIndex + 1, replacement.requestIndex + 1] }
|
|
224
|
+
)
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (previous && replacement.start < previous.end) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
error: patchError(
|
|
233
|
+
'OVERLAPPING_PATCHES',
|
|
234
|
+
`Localized replacements ${previous.requestIndex + 1} and ${replacement.requestIndex + 1} overlap.`,
|
|
235
|
+
field,
|
|
236
|
+
'combine_patches',
|
|
237
|
+
{ replacementIndexes: [previous.requestIndex + 1, replacement.requestIndex + 1] }
|
|
238
|
+
)
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
unique.push(replacement);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let cursor = 0;
|
|
245
|
+
let desiredText = '';
|
|
246
|
+
for (const replacement of unique) {
|
|
247
|
+
desiredText += sourceText.slice(cursor, replacement.start);
|
|
248
|
+
desiredText += replacement.replace;
|
|
249
|
+
cursor = replacement.end;
|
|
250
|
+
}
|
|
251
|
+
desiredText += sourceText.slice(cursor);
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
ok: true,
|
|
255
|
+
desiredText,
|
|
256
|
+
replacements: unique.map(replacement => ({
|
|
257
|
+
requestIndex: replacement.requestIndex + 1,
|
|
258
|
+
start: replacement.start,
|
|
259
|
+
end: replacement.end,
|
|
260
|
+
find: replacement.find,
|
|
261
|
+
replace: replacement.replace,
|
|
262
|
+
occurrence: replacement.occurrence,
|
|
263
|
+
removedLength: replacement.end - replacement.start,
|
|
264
|
+
insertedLength: replacement.replace.length
|
|
265
|
+
}))
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Build a compact receipt-like summary from actual source and output text. */
|
|
270
|
+
export function buildLocalizedReplacementChange(compilation, outputText, target = {}, context = null) {
|
|
271
|
+
if (!compilation || typeof compilation !== 'object') return null;
|
|
272
|
+
const sourceText = String(compilation.sourceText ?? '');
|
|
273
|
+
const desiredText = String(compilation.desiredText ?? '');
|
|
274
|
+
const replacements = Array.isArray(compilation.replacements) ? compilation.replacements : [];
|
|
275
|
+
let delta = 0;
|
|
276
|
+
const applied = replacements.map(replacement => {
|
|
277
|
+
const afterStart = replacement.start + delta;
|
|
278
|
+
const afterEnd = afterStart + replacement.replace.length;
|
|
279
|
+
delta += replacement.replace.length - (replacement.end - replacement.start);
|
|
280
|
+
return {
|
|
281
|
+
find: replacement.find,
|
|
282
|
+
replace: replacement.replace,
|
|
283
|
+
occurrence: replacement.occurrence,
|
|
284
|
+
beforeExcerpt: boundedExcerpt(sourceText, replacement.start, replacement.end),
|
|
285
|
+
afterExcerpt: boundedExcerpt(desiredText, afterStart, afterEnd)
|
|
286
|
+
};
|
|
287
|
+
});
|
|
288
|
+
return {
|
|
289
|
+
kind: 'localized_replacement',
|
|
290
|
+
committed: true,
|
|
291
|
+
finalDisposition: 'applied',
|
|
292
|
+
target: {
|
|
293
|
+
...(target.paragraphId ? { paragraphId: target.paragraphId } : {}),
|
|
294
|
+
...(Number.isInteger(target.index) ? { index: target.index } : {}),
|
|
295
|
+
...(target.fingerprint ? { fingerprint: target.fingerprint } : {}),
|
|
296
|
+
...(context?.humanReference ? { humanReference: context.humanReference } : {})
|
|
297
|
+
},
|
|
298
|
+
...(context ? { context: { ...context } } : {}),
|
|
299
|
+
replacements: applied,
|
|
300
|
+
verification: {
|
|
301
|
+
acceptedViewMatchesCompiledText: typeof outputText === 'string' && outputText === desiredText
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
@@ -9,15 +9,23 @@ import {
|
|
|
9
9
|
normalizeTargetDescriptor,
|
|
10
10
|
validateDocumentOperation
|
|
11
11
|
} from './document-operation-contract.js';
|
|
12
|
+
import { compileExactReplacements } from './localized-replacement-compiler.js';
|
|
12
13
|
|
|
13
14
|
const TEXT_WRITE_KINDS = new Set(['redline', 'restore', 'rejected-insert']);
|
|
14
15
|
const FORMAT_WRITE_KINDS = new Set(['highlight', 'format', 'paragraph-format']);
|
|
15
16
|
|
|
16
17
|
function normalizedError(error) {
|
|
18
|
+
const details = {};
|
|
19
|
+
if (error && typeof error === 'object') {
|
|
20
|
+
for (const [key, value] of Object.entries(error)) {
|
|
21
|
+
if (key === 'name' || key === 'stack' || key === 'message' || key === 'code') continue;
|
|
22
|
+
details[key] = value;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
17
25
|
return {
|
|
18
26
|
code: typeof error?.code === 'string' && error.code ? error.code : 'OPERATION_ERROR',
|
|
19
27
|
message: error?.message || String(error),
|
|
20
|
-
...
|
|
28
|
+
...details
|
|
21
29
|
};
|
|
22
30
|
}
|
|
23
31
|
|
|
@@ -266,6 +274,30 @@ export function compileOperationBatch(xmlDoc, operations = [], options = {}) {
|
|
|
266
274
|
compiled._compiledResolvedBy = start.resolvedBy;
|
|
267
275
|
if (binding.warnings.length > 0) compiled._compiledWarnings = binding.warnings;
|
|
268
276
|
|
|
277
|
+
if (Array.isArray(operation.replacements)) {
|
|
278
|
+
const patchCompilation = compileExactReplacements(
|
|
279
|
+
start.metadata.text,
|
|
280
|
+
operation.replacements
|
|
281
|
+
);
|
|
282
|
+
if (!patchCompilation.ok) {
|
|
283
|
+
throw Object.assign(new Error(patchCompilation.error.message), patchCompilation.error);
|
|
284
|
+
}
|
|
285
|
+
compiled.modified = patchCompilation.desiredText;
|
|
286
|
+
delete compiled.replacements;
|
|
287
|
+
compiled._localizedReplacementCompilation = {
|
|
288
|
+
sourceText: start.metadata.text,
|
|
289
|
+
desiredText: patchCompilation.desiredText,
|
|
290
|
+
replacements: patchCompilation.replacements
|
|
291
|
+
};
|
|
292
|
+
const canonicalValidation = validateDocumentOperation(compiled);
|
|
293
|
+
if (!canonicalValidation.valid) {
|
|
294
|
+
throw Object.assign(
|
|
295
|
+
new Error(canonicalValidation.error.message),
|
|
296
|
+
canonicalValidation.error
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
269
301
|
const targetEndDescriptor = operation.targetEndDescriptor
|
|
270
302
|
|| (operation.targetEndRef != null
|
|
271
303
|
? normalizeTargetDescriptor(null, operation.targetEndRef, operation.targetDescriptor.revisionView)
|
|
@@ -136,11 +136,16 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
136
136
|
let commentsRequired = false;
|
|
137
137
|
let numberingRequired = false;
|
|
138
138
|
|
|
139
|
-
for (let index = 0; index < sourceOperations.length; index++) {
|
|
140
|
-
const sourceOperation = sourceOperations[index];
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
const
|
|
139
|
+
for (let index = 0; index < sourceOperations.length; index++) {
|
|
140
|
+
const sourceOperation = sourceOperations[index];
|
|
141
|
+
const compiledBinding = batchCompilation.bindings[index];
|
|
142
|
+
const compiledOperation = batchCompilation.compiledOperations[index];
|
|
143
|
+
const operationInput = !compiledBinding?.error && compiledOperation?._localizedReplacementCompilation
|
|
144
|
+
? compiledOperation
|
|
145
|
+
: sourceOperation;
|
|
146
|
+
const validation = validateDocumentOperation(operationInput);
|
|
147
|
+
const fallbackOperation = normalizeDocumentOperation(sourceOperation);
|
|
148
|
+
const operation = validation.operation || fallbackOperation;
|
|
144
149
|
const authorUsed = resolveDocumentOperationAuthor(operation, author, getDefaultAuthor());
|
|
145
150
|
authorsUsed.add(authorUsed);
|
|
146
151
|
|
|
@@ -187,7 +192,6 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
187
192
|
continue;
|
|
188
193
|
}
|
|
189
194
|
|
|
190
|
-
const compiledBinding = batchCompilation.bindings[index];
|
|
191
195
|
if (compiledBinding?.error) {
|
|
192
196
|
results.push({
|
|
193
197
|
index: index + 1,
|
|
@@ -31,6 +31,12 @@ export interface RejectedTextInsertionAnchor {
|
|
|
31
31
|
offset: number;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
export interface LocalizedReplacement {
|
|
35
|
+
find: string;
|
|
36
|
+
replace: string;
|
|
37
|
+
occurrence?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
34
40
|
export interface DocumentOperationBase {
|
|
35
41
|
operationId?: string;
|
|
36
42
|
captureKey?: string;
|
|
@@ -43,9 +49,7 @@ export interface DocumentOperationBase {
|
|
|
43
49
|
insertionAffinity?: InsertionAffinity;
|
|
44
50
|
}
|
|
45
51
|
|
|
46
|
-
|
|
47
|
-
type: 'redline' | 'replace' | 'format' | 'list-change' | 'table-reconciliation' | 'insert';
|
|
48
|
-
modified: string;
|
|
52
|
+
interface RedlineDocumentOperationCommon extends DocumentOperationBase {
|
|
49
53
|
structuredContent?: boolean;
|
|
50
54
|
targetEnd?: ParagraphTargetDescriptor;
|
|
51
55
|
targetEndRef?: number | string | null;
|
|
@@ -53,6 +57,24 @@ export interface RedlineDocumentOperation extends DocumentOperationBase {
|
|
|
53
57
|
anchor?: RejectedTextInsertionAnchor;
|
|
54
58
|
}
|
|
55
59
|
|
|
60
|
+
export type RedlineDocumentOperation =
|
|
61
|
+
| (RedlineDocumentOperationCommon & {
|
|
62
|
+
type: 'redline' | 'replace';
|
|
63
|
+
modified: string;
|
|
64
|
+
replacements?: never;
|
|
65
|
+
})
|
|
66
|
+
| (RedlineDocumentOperationCommon & {
|
|
67
|
+
type: 'redline' | 'replace';
|
|
68
|
+
modified?: never;
|
|
69
|
+
/** Exact, case-sensitive replacements in one batch-start accepted-view paragraph. */
|
|
70
|
+
replacements: LocalizedReplacement[];
|
|
71
|
+
})
|
|
72
|
+
| (RedlineDocumentOperationCommon & {
|
|
73
|
+
type: 'format' | 'list-change' | 'table-reconciliation' | 'insert';
|
|
74
|
+
modified: string;
|
|
75
|
+
replacements?: never;
|
|
76
|
+
});
|
|
77
|
+
|
|
56
78
|
export interface RestoreDocumentOperation extends DocumentOperationBase {
|
|
57
79
|
type: 'restore';
|
|
58
80
|
modified: string | string[];
|
|
@@ -190,6 +212,34 @@ export interface MutationReceipt {
|
|
|
190
212
|
warnings: string[];
|
|
191
213
|
}
|
|
192
214
|
|
|
215
|
+
export interface LocalizedReplacementChange {
|
|
216
|
+
kind: 'localized_replacement';
|
|
217
|
+
committed: boolean;
|
|
218
|
+
finalDisposition: 'applied' | 'rolled_back';
|
|
219
|
+
target: {
|
|
220
|
+
paragraphId?: string;
|
|
221
|
+
index?: number;
|
|
222
|
+
fingerprint?: string;
|
|
223
|
+
humanReference?: string;
|
|
224
|
+
};
|
|
225
|
+
context?: {
|
|
226
|
+
search?: string;
|
|
227
|
+
range?: string;
|
|
228
|
+
anchorMatchCount?: number;
|
|
229
|
+
humanReference?: string;
|
|
230
|
+
};
|
|
231
|
+
replacements: Array<{
|
|
232
|
+
find: string;
|
|
233
|
+
replace: string;
|
|
234
|
+
occurrence?: number;
|
|
235
|
+
beforeExcerpt: string;
|
|
236
|
+
afterExcerpt: string;
|
|
237
|
+
}>;
|
|
238
|
+
verification: {
|
|
239
|
+
acceptedViewMatchesCompiledText: boolean;
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
193
243
|
export interface DocumentOperationResult {
|
|
194
244
|
documentXml: string;
|
|
195
245
|
hasChanges: boolean;
|
|
@@ -207,6 +257,7 @@ export interface DocumentOperationResult {
|
|
|
207
257
|
resolvedTarget?: ResolvedDocumentTarget;
|
|
208
258
|
resolvedAnchor?: ResolvedCommentAnchor;
|
|
209
259
|
receipt?: MutationReceipt;
|
|
260
|
+
change?: LocalizedReplacementChange;
|
|
210
261
|
}
|
|
211
262
|
|
|
212
263
|
export interface BatchOperationItemResult {
|
|
@@ -221,6 +272,7 @@ export interface BatchOperationItemResult {
|
|
|
221
272
|
warnings?: string[];
|
|
222
273
|
error?: RedlineError;
|
|
223
274
|
receipt?: MutationReceipt;
|
|
275
|
+
change?: LocalizedReplacementChange;
|
|
224
276
|
}
|
|
225
277
|
|
|
226
278
|
export interface DocumentOperationBatchResult {
|