@ansonlai/docx-redline-js 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- const operation = validation.operation || normalizeDocumentOperation(op);
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,48 @@ 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
- const resolutionCapture = {};
160
+ };
161
+ }
162
+
163
+ if (
164
+ Array.isArray(operation.replacements)
165
+ || (operation.operationKind === 'restore' && op?.modified === undefined)
166
+ ) {
167
+ const compilation = compileOperationBatch(session.document, [op], {
168
+ strictTargets: options.strictTargets !== false,
169
+ onInfo: options.onInfo,
170
+ onWarn: options.onWarn
171
+ });
172
+ const compilationError = compilation.bindings[0]?.error || compilation.conflicts[0] || null;
173
+ if (compilationError) {
174
+ return {
175
+ documentXml,
176
+ hasChanges: false,
177
+ status: 'error',
178
+ error: normalizeOperationError(compilationError, errorContext),
179
+ operationType: operation.operationKind,
180
+ authorUsed,
181
+ receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
182
+ };
183
+ }
184
+ const compiledValidation = validateDocumentOperation(compilation.compiledOperations[0]);
185
+ if (!compiledValidation.valid) {
186
+ return {
187
+ documentXml,
188
+ hasChanges: false,
189
+ status: 'error',
190
+ error: normalizeOperationError(compiledValidation.error, errorContext),
191
+ operationType: operation.operationKind,
192
+ authorUsed,
193
+ receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
194
+ };
195
+ }
196
+ session.sourceTargetRegistry = compilation.registry;
197
+ operation = compiledValidation.operation;
198
+ }
199
+
200
+ const resolutionCapture = {};
201
+ let localizedChange = null;
161
202
  const savepoint = session.createSavepoint();
162
203
  session.receiptCollector?.beginOperation(
163
204
  operationIndex,
@@ -375,6 +416,33 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
375
416
  operationIndex
376
417
  );
377
418
  }
419
+ if (operation._localizedReplacementCompilation) {
420
+ let outputText = null;
421
+ if (operation.operationKind === 'restore') {
422
+ const restoredParagraph = operationOptions._mutationLiveNodes.find(node => node?.localName === 'p');
423
+ if (restoredParagraph) {
424
+ outputText = extractCanonicalParagraphText(restoredParagraph, {
425
+ revisionView: 'accepted'
426
+ });
427
+ }
428
+ } else if (operation._compiledSourceId && session.sourceTargetRegistry) {
429
+ const bound = session.sourceTargetRegistry.resolve(
430
+ operation._compiledSourceId,
431
+ session.document
432
+ );
433
+ if (bound.paragraph) {
434
+ outputText = extractCanonicalParagraphText(bound.paragraph, {
435
+ revisionView: 'accepted'
436
+ });
437
+ }
438
+ }
439
+ localizedChange = buildLocalizedReplacementChange(
440
+ operation._localizedReplacementCompilation,
441
+ outputText,
442
+ resolutionCapture.resolvedTarget || {},
443
+ operation._speculativeContext || null
444
+ );
445
+ }
378
446
  if (operation.captureKey && session.captureTable) {
379
447
  session.captureTable.set(
380
448
  operation.captureKey,
@@ -426,8 +494,9 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
426
494
  ...result,
427
495
  operationType: operation.operationKind,
428
496
  authorUsed,
429
- receipt: operationReceipt,
430
- ...resolutionCapture
497
+ receipt: operationReceipt,
498
+ ...(localizedChange ? { change: localizedChange } : {}),
499
+ ...resolutionCapture
431
500
  };
432
501
  } catch (error) {
433
502
  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' && typeof normalized.modified !== 'string') {
219
- return {
220
- valid: false,
221
- error: { code: 'INVALID_OPERATION', message: 'Redline operations require a string "modified" field.' }
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') {
@@ -256,19 +307,66 @@ export function validateDocumentOperation(operation) {
256
307
  }
257
308
 
258
309
  if (normalized.operationKind === 'restore') {
310
+ const hasModified = operation.modified !== undefined;
311
+ const hasReplacements = operation.replacements !== undefined;
259
312
  const validSingle = nonEmptyString(normalized.modified);
260
313
  const validRange = Array.isArray(normalized.modified)
261
314
  && normalized.modified.length > 0
262
315
  && normalized.modified.every(nonEmptyString);
263
- if (!validSingle && !validRange) {
316
+ if (hasModified && hasReplacements) {
317
+ return {
318
+ valid: false,
319
+ error: {
320
+ code: 'INVALID_OPERATION',
321
+ message: 'Restore operations accept modified or replacements, not both.'
322
+ }
323
+ };
324
+ }
325
+ if (hasModified && !validSingle && !validRange) {
264
326
  return {
265
327
  valid: false,
266
328
  error: {
267
329
  code: 'INVALID_OPERATION',
268
- message: 'Restore operations require a non-empty string or non-empty string array in "modified".'
330
+ message: 'Restore modified must be a non-empty string or non-empty string array.'
269
331
  }
270
332
  };
271
333
  }
334
+ if (!hasModified) {
335
+ if (
336
+ target.revisionView !== 'rejected'
337
+ || (!target.paragraphId && !target.fingerprint)
338
+ || target.captureRef
339
+ || target.occurrence != null
340
+ || normalized.captureKey
341
+ || normalized.targetEndDescriptor
342
+ || normalized.targetEndRef != null
343
+ || /\r|\n/.test(target.text || '')
344
+ ) {
345
+ return {
346
+ valid: false,
347
+ error: {
348
+ code: 'INVALID_OPERATION',
349
+ message: 'Verbatim and localized restore require one strong batch-start rejected-view paragraph target without captures, target occurrence, or a target range.'
350
+ }
351
+ };
352
+ }
353
+ }
354
+ if (hasReplacements) {
355
+ const replacementValidation = validateExactReplacementRequests(normalized.replacements);
356
+ if (!replacementValidation.ok) {
357
+ return { valid: false, error: replacementValidation.error };
358
+ }
359
+ if (replacementValidation.replacements.some(replacement => /\r|\n/.test(replacement.replace))) {
360
+ return {
361
+ valid: false,
362
+ error: {
363
+ code: 'INVALID_OPERATION',
364
+ message: 'Localized restore cannot insert paragraph breaks.'
365
+ }
366
+ };
367
+ }
368
+ normalized.replacements = replacementValidation.replacements;
369
+ }
272
370
  if (normalized.generateRedlines === false) {
273
371
  return {
274
372
  valid: false,
@@ -223,7 +223,37 @@ function describeTargetTextMatch(actualText, requestedText) {
223
223
  requestedExcerpt: escapeInvisibleText(requested)
224
224
  };
225
225
  }
226
-
226
+
227
+ function resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options) {
228
+ const revisionView = options?.targetDescriptor?.revisionView === 'rejected'
229
+ ? 'rejected'
230
+ : 'accepted';
231
+ const viewMetadata = metadata?.revisionView === revisionView ? metadata : null;
232
+ const index = viewMetadata?.index ?? getDocumentParagraphNodes(xmlDoc).indexOf(paragraph) + 1;
233
+ const paragraphId = viewMetadata?.paragraphId ?? getParagraphId(paragraph);
234
+ const inTable = viewMetadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl');
235
+ const text = viewMetadata?.text ?? extractCanonicalParagraphText(paragraph, { revisionView });
236
+ const fingerprint = viewMetadata?.fingerprint ?? createParagraphFingerprint(paragraph, {
237
+ text,
238
+ index,
239
+ paragraphId,
240
+ inTable,
241
+ revisionView
242
+ });
243
+ return {
244
+ index,
245
+ paragraphId,
246
+ text,
247
+ fingerprint,
248
+ revisionView,
249
+ inTable,
250
+ targetTextMatch: describeTargetTextMatch(
251
+ text,
252
+ options?.targetDescriptor?.text ?? options?.targetDescriptor?.exactText ?? targetText
253
+ )
254
+ };
255
+ }
256
+
227
257
  function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
228
258
  const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
229
259
  const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
@@ -239,17 +269,7 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
239
269
  const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
240
270
  Object.assign(options._resolutionCapture, {
241
271
  resolvedBy: resolved.resolvedBy,
242
- resolvedTarget: {
243
- index: metadata?.index ?? getDocumentParagraphNodes(xmlDoc).indexOf(paragraph) + 1,
244
- paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
245
- text: metadata?.text ?? getParagraphText(paragraph),
246
- fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
247
- inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
248
- targetTextMatch: describeTargetTextMatch(
249
- metadata?.text ?? getParagraphText(paragraph),
250
- options?.targetDescriptor?.exactText ?? targetText
251
- )
252
- }
272
+ resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
253
273
  });
254
274
  }
255
275
  return resolved;
@@ -263,17 +283,7 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
263
283
  const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
264
284
  Object.assign(options._resolutionCapture, {
265
285
  resolvedBy: resolved.resolvedBy,
266
- resolvedTarget: {
267
- index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
268
- paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
269
- text: metadata?.text ?? getParagraphText(paragraph),
270
- fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
271
- inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
272
- targetTextMatch: describeTargetTextMatch(
273
- metadata?.text ?? getParagraphText(paragraph),
274
- options?.targetDescriptor?.exactText ?? targetText
275
- )
276
- }
286
+ resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
277
287
  });
278
288
  }
279
289
  return resolved;
@@ -301,19 +311,9 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
301
311
  if (options?._resolutionCapture && resolved?.paragraph) {
302
312
  const paragraph = resolved.paragraph;
303
313
  const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
304
- Object.assign(options._resolutionCapture, {
305
- resolvedBy: resolved.resolvedBy,
306
- resolvedTarget: {
307
- index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
308
- paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
309
- text: metadata?.text ?? getParagraphText(paragraph),
310
- fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
311
- inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
312
- targetTextMatch: describeTargetTextMatch(
313
- metadata?.text ?? getParagraphText(paragraph),
314
- options?.targetDescriptor?.exactText ?? targetText
315
- )
316
- }
314
+ Object.assign(options._resolutionCapture, {
315
+ resolvedBy: resolved.resolvedBy,
316
+ resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
317
317
  });
318
318
  }
319
319
  return resolved;
@@ -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
+ }