@ansonlai/docx-redline-js 0.1.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.
Files changed (64) hide show
  1. package/AGENTS.md +176 -0
  2. package/ARCHITECTURE.md +121 -0
  3. package/LICENSE +21 -0
  4. package/README.md +177 -0
  5. package/adapters/config.js +43 -0
  6. package/adapters/logger.js +89 -0
  7. package/adapters/xml-adapter.js +74 -0
  8. package/core/list-targeting.js +398 -0
  9. package/core/ooxml-identifiers.js +15 -0
  10. package/core/paragraph-offset-policy.js +50 -0
  11. package/core/paragraph-targeting.js +501 -0
  12. package/core/table-targeting.js +233 -0
  13. package/core/types.js +204 -0
  14. package/core/xml-query.js +99 -0
  15. package/dist/docx-redline-js.esm.js +8801 -0
  16. package/dist/docx-redline-js.esm.js.map +7 -0
  17. package/dist/docx-redline-js.esm.min.js +195 -0
  18. package/dist/docx-redline-js.esm.min.js.map +7 -0
  19. package/engine/format-application.js +358 -0
  20. package/engine/format-extraction.js +232 -0
  21. package/engine/format-paragraph-targeting.js +208 -0
  22. package/engine/format-span-application.js +178 -0
  23. package/engine/formatting-removal.js +330 -0
  24. package/engine/oxml-engine.js +279 -0
  25. package/engine/reconstruction-mapper.js +270 -0
  26. package/engine/reconstruction-mode.js +38 -0
  27. package/engine/reconstruction-writer.js +276 -0
  28. package/engine/rpr-helpers.js +194 -0
  29. package/engine/run-builders.js +235 -0
  30. package/engine/surgical-mode.js +520 -0
  31. package/engine/table-cell-context.js +151 -0
  32. package/engine/table-mode.js +172 -0
  33. package/index.js +308 -0
  34. package/orchestration/list-markdown.js +141 -0
  35. package/orchestration/list-parsing.js +73 -0
  36. package/orchestration/list-structural-fallback.js +530 -0
  37. package/orchestration/redline-operation-converter.js +141 -0
  38. package/orchestration/route-plan.js +160 -0
  39. package/package.json +76 -0
  40. package/pipeline/content-analysis.js +107 -0
  41. package/pipeline/diff-engine.js +204 -0
  42. package/pipeline/ingestion-export.js +255 -0
  43. package/pipeline/ingestion-paragraph.js +351 -0
  44. package/pipeline/ingestion-table.js +169 -0
  45. package/pipeline/ingestion-xml.js +39 -0
  46. package/pipeline/ingestion.js +8 -0
  47. package/pipeline/list-generation.js +280 -0
  48. package/pipeline/list-markers.js +77 -0
  49. package/pipeline/markdown-processor.js +160 -0
  50. package/pipeline/patching.js +408 -0
  51. package/pipeline/pipeline.js +326 -0
  52. package/pipeline/serialization.js +395 -0
  53. package/services/browser-demo-prompt-context.js +345 -0
  54. package/services/comment-builders.js +60 -0
  55. package/services/comment-engine.js +248 -0
  56. package/services/comment-locator.js +197 -0
  57. package/services/comment-package.js +113 -0
  58. package/services/numbering-helpers.js +416 -0
  59. package/services/numbering-service.js +290 -0
  60. package/services/package-builder.js +147 -0
  61. package/services/standalone-docx-plumbing.js +443 -0
  62. package/services/standalone-operation-runner.js +1169 -0
  63. package/services/table-reconciliation.js +344 -0
  64. package/standalone.js +5 -0
@@ -0,0 +1,530 @@
1
+ /**
2
+ * Shared fallback helpers for structural list conversion when a redline is a
3
+ * text no-op but the target is marker-prefixed plain text (for example `1. X`).
4
+ */
5
+
6
+ import { createParser, createSerializer } from '../adapters/xml-adapter.js';
7
+ import { getXmlParseError } from '../core/xml-query.js';
8
+ import {
9
+ getDocumentParagraphNodes,
10
+ normalizeWhitespaceForTargeting
11
+ } from '../core/paragraph-targeting.js';
12
+ import { getParagraphListInfo } from '../core/list-targeting.js';
13
+ import { ReconciliationPipeline } from '../pipeline/pipeline.js';
14
+ import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
15
+ import { parseMarkdownListContent, hasListItems } from './list-parsing.js';
16
+ import { inferNumberingStyleFromMarker } from './list-markdown.js';
17
+
18
+ function parseSingleLineListCandidate(text) {
19
+ const rawText = String(text || '');
20
+ if (!rawText.trim()) return null;
21
+ if (rawText.includes('\n')) return null;
22
+
23
+ const parsed = parseMarkdownListContent(rawText);
24
+ if (!parsed || !hasListItems(parsed) || !Array.isArray(parsed.items) || parsed.items.length !== 1) {
25
+ return null;
26
+ }
27
+
28
+ const item = parsed.items[0];
29
+ if (!item || (item.type !== 'numbered' && item.type !== 'bullet')) return null;
30
+
31
+ const marker = String(item.marker || '').trim();
32
+ const numberingStyle = item.type === 'numbered'
33
+ ? inferNumberingStyleFromMarker(marker || '1.')
34
+ : 'bullet';
35
+
36
+ return {
37
+ type: item.type,
38
+ marker,
39
+ numberingStyle,
40
+ startAt: parseMarkerStart(marker, numberingStyle),
41
+ contentText: String(item.text || '').trim(),
42
+ normalizedContent: normalizeWhitespaceForTargeting(String(item.text || ''))
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Removes the leading single-line list marker from text when present.
48
+ *
49
+ * Example:
50
+ * - `1. HEADER` -> `HEADER`
51
+ * - `2.2.1. Clause` -> `Clause`
52
+ *
53
+ * @param {string} text - Candidate single-line list text
54
+ * @returns {string}
55
+ */
56
+ export function stripSingleLineListMarkerPrefix(text) {
57
+ const candidate = parseSingleLineListCandidate(text);
58
+ if (!candidate) return String(text || '').trim();
59
+ return String(candidate.contentText || '').trim();
60
+ }
61
+
62
+ function parseMarkerStart(marker, numberingStyle) {
63
+ if (numberingStyle !== 'decimal') return null;
64
+ const match = String(marker || '').trim().match(/^(\d+)\.?$/);
65
+ if (!match) return null;
66
+ const value = Number.parseInt(match[1], 10);
67
+ return Number.isFinite(value) && value > 0 ? value : null;
68
+ }
69
+
70
+ /**
71
+ * Resolves how a caller should handle numbering ownership for a single-line
72
+ * structural list fallback across multi-operation runs.
73
+ *
74
+ * @param {{
75
+ * numberingKey?: string|null,
76
+ * startAt?: number|null
77
+ * }|null} plan - Single-line fallback plan
78
+ * @param {{
79
+ * explicitByNumberingKey?: Map<string, { numId: string, nextStartAt: number }>
80
+ * }|null} sequenceState - Optional mutable sequence state
81
+ * @returns {{
82
+ * type: 'none'|'sharedByStyle'|'explicitReuse'|'explicitStartNew'|'explicitIsolated',
83
+ * numberingKey: string|null,
84
+ * startAt: number|null,
85
+ * numId: string|null
86
+ * }}
87
+ */
88
+ export function resolveSingleLineListFallbackNumberingAction(plan, sequenceState = null) {
89
+ const numberingKey = plan?.numberingKey ? String(plan.numberingKey) : null;
90
+ const startAt = Number.isInteger(plan?.startAt) && plan.startAt > 0 ? plan.startAt : null;
91
+
92
+ if (!numberingKey) {
93
+ return {
94
+ type: 'none',
95
+ numberingKey: null,
96
+ startAt,
97
+ numId: null
98
+ };
99
+ }
100
+
101
+ if (startAt == null) {
102
+ return {
103
+ type: 'sharedByStyle',
104
+ numberingKey,
105
+ startAt: null,
106
+ numId: null
107
+ };
108
+ }
109
+
110
+ const explicitMap = sequenceState?.explicitByNumberingKey;
111
+ if (!(explicitMap instanceof Map)) {
112
+ return {
113
+ type: 'explicitIsolated',
114
+ numberingKey,
115
+ startAt,
116
+ numId: null
117
+ };
118
+ }
119
+
120
+ const existing = explicitMap.get(numberingKey) || null;
121
+ if (
122
+ existing &&
123
+ existing.numId != null &&
124
+ Number.isInteger(existing.nextStartAt) &&
125
+ existing.nextStartAt === startAt
126
+ ) {
127
+ return {
128
+ type: 'explicitReuse',
129
+ numberingKey,
130
+ startAt,
131
+ numId: String(existing.numId)
132
+ };
133
+ }
134
+
135
+ return {
136
+ type: 'explicitStartNew',
137
+ numberingKey,
138
+ startAt,
139
+ numId: null
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Records/advances explicit numeric single-line sequence state.
145
+ *
146
+ * @param {{
147
+ * explicitByNumberingKey?: Map<string, { numId: string, nextStartAt: number }>
148
+ * }|null} sequenceState - Mutable sequence state
149
+ * @param {string|null} numberingKey - Numbering signature key
150
+ * @param {string|number|null} numId - Sequence numId
151
+ * @param {number|null} startAt - Current explicit start marker
152
+ */
153
+ export function recordSingleLineListFallbackExplicitSequence(sequenceState, numberingKey, numId, startAt) {
154
+ if (!sequenceState || !(sequenceState.explicitByNumberingKey instanceof Map)) return;
155
+ if (!numberingKey || numId == null || !Number.isInteger(startAt) || startAt < 1) return;
156
+
157
+ sequenceState.explicitByNumberingKey.set(String(numberingKey), {
158
+ numId: String(numId),
159
+ nextStartAt: startAt + 1
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Clears explicit sequence tracking for a numbering signature.
165
+ *
166
+ * @param {{
167
+ * explicitByNumberingKey?: Map<string, { numId: string, nextStartAt: number }>
168
+ * }|null} sequenceState - Mutable sequence state
169
+ * @param {string|null} numberingKey - Numbering signature key
170
+ */
171
+ export function clearSingleLineListFallbackExplicitSequence(sequenceState, numberingKey) {
172
+ if (!sequenceState || !(sequenceState.explicitByNumberingKey instanceof Map)) return;
173
+ if (!numberingKey) return;
174
+ sequenceState.explicitByNumberingKey.delete(String(numberingKey));
175
+ }
176
+
177
+ function getDirectWordChild(element, localName) {
178
+ if (!element) return null;
179
+ return Array.from(element.childNodes || []).find(
180
+ node =>
181
+ node &&
182
+ node.nodeType === 1 &&
183
+ node.namespaceURI === 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' &&
184
+ node.localName === localName
185
+ ) || null;
186
+ }
187
+
188
+ /**
189
+ * Forces explicit list bindings on paragraph nodes.
190
+ *
191
+ * Useful when callers need deterministic `w:numPr` assignment regardless of
192
+ * incoming generator quirks or paragraph property tracked-change payloads.
193
+ *
194
+ * @param {Node[]|null|undefined} nodes - Candidate nodes containing paragraphs
195
+ * @param {{
196
+ * numId: string|number,
197
+ * ilvl?: number,
198
+ * clearParagraphPropertyChanges?: boolean,
199
+ * removeListPropertyNode?: boolean
200
+ * }} options - Binding options
201
+ * @returns {number} Number of paragraph nodes updated
202
+ */
203
+ export function enforceListBindingOnParagraphNodes(nodes, options = {}) {
204
+ const numId = options?.numId;
205
+ if (numId == null) return 0;
206
+ const ilvl = Number.isInteger(options?.ilvl) ? Math.max(0, options.ilvl) : 0;
207
+ const clearParagraphPropertyChanges = options?.clearParagraphPropertyChanges !== false;
208
+ const removeListPropertyNode = options?.removeListPropertyNode !== false;
209
+
210
+ const paragraphs = (Array.isArray(nodes) ? nodes : [])
211
+ .filter(node => node && node.nodeType === 1 && node.localName === 'p');
212
+ let updated = 0;
213
+
214
+ for (const paragraph of paragraphs) {
215
+ const ownerDoc = paragraph.ownerDocument;
216
+ if (!ownerDoc) continue;
217
+
218
+ let pPr = getDirectWordChild(paragraph, 'pPr');
219
+ if (!pPr) {
220
+ pPr = ownerDoc.createElementNS(
221
+ 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
222
+ 'w:pPr'
223
+ );
224
+ paragraph.insertBefore(pPr, paragraph.firstChild);
225
+ }
226
+
227
+ if (clearParagraphPropertyChanges) {
228
+ const pPrChange = getDirectWordChild(pPr, 'pPrChange');
229
+ if (pPrChange) pPr.removeChild(pPrChange);
230
+ }
231
+
232
+ if (removeListPropertyNode) {
233
+ const listPr = getDirectWordChild(pPr, 'listPr');
234
+ if (listPr) pPr.removeChild(listPr);
235
+ }
236
+
237
+ let numPr = getDirectWordChild(pPr, 'numPr');
238
+ if (!numPr) {
239
+ numPr = ownerDoc.createElementNS(
240
+ 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
241
+ 'w:numPr'
242
+ );
243
+ pPr.appendChild(numPr);
244
+ }
245
+
246
+ let ilvlEl = getDirectWordChild(numPr, 'ilvl');
247
+ if (!ilvlEl) {
248
+ ilvlEl = ownerDoc.createElementNS(
249
+ 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
250
+ 'w:ilvl'
251
+ );
252
+ numPr.appendChild(ilvlEl);
253
+ }
254
+ ilvlEl.setAttribute('w:val', String(ilvl));
255
+
256
+ let numIdEl = getDirectWordChild(numPr, 'numId');
257
+ if (!numIdEl) {
258
+ numIdEl = ownerDoc.createElementNS(
259
+ 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
260
+ 'w:numId'
261
+ );
262
+ numPr.appendChild(numIdEl);
263
+ }
264
+ numIdEl.setAttribute('w:val', String(numId));
265
+ updated++;
266
+ }
267
+
268
+ return updated;
269
+ }
270
+
271
+ function getFirstParagraphFromOxml(oxml) {
272
+ const parser = createParser();
273
+ const doc = parser.parseFromString(String(oxml || ''), 'application/xml');
274
+ const parseError = getXmlParseError(doc);
275
+ if (parseError) return null;
276
+ const paragraphs = getDocumentParagraphNodes(doc);
277
+ return paragraphs[0] || null;
278
+ }
279
+
280
+ function trimTrailingBlankParagraph(oxml) {
281
+ if (!oxml) return '';
282
+ return String(oxml).replace(/<w:p>\s*<w:pPr>\s*<\/w:pPr>\s*<\/w:p>\s*$/i, '');
283
+ }
284
+
285
+ function getAttributeFirst(element, names) {
286
+ for (const name of names) {
287
+ const value = element.getAttribute(name);
288
+ if (value != null && value !== '') return value;
289
+ }
290
+ return null;
291
+ }
292
+
293
+ function getElementId(element, names) {
294
+ const raw = getAttributeFirst(element, names);
295
+ const parsed = Number.parseInt(String(raw || ''), 10);
296
+ return Number.isFinite(parsed) ? parsed : null;
297
+ }
298
+
299
+ function setElementVal(element, value) {
300
+ element.setAttribute('w:val', String(value));
301
+ }
302
+
303
+ function extractFirstParagraphNumIdFromOxml(oxml) {
304
+ const parser = createParser();
305
+ const doc = parser.parseFromString(String(oxml || ''), 'application/xml');
306
+ const parseError = getXmlParseError(doc);
307
+ if (parseError) return null;
308
+
309
+ const paragraphs = getDocumentParagraphNodes(doc);
310
+ const firstParagraph = paragraphs[0] || null;
311
+ if (!firstParagraph) return null;
312
+
313
+ const numIdNodes = Array.from(firstParagraph.getElementsByTagNameNS('*', 'numId'));
314
+ for (const numIdNode of numIdNodes) {
315
+ const numId = getElementId(numIdNode, ['w:val', 'val']);
316
+ if (numId != null) return String(numId);
317
+ }
318
+ return null;
319
+ }
320
+
321
+ function applyStartOverrideToNumberingXml(numberingXml, targetNumId, startAt, options = {}) {
322
+ if (!numberingXml || !targetNumId || !Number.isInteger(startAt) || startAt < 1) return numberingXml;
323
+ const setAbstractStartOverride = options.setAbstractStartOverride !== false;
324
+ const parser = createParser();
325
+ const serializer = createSerializer();
326
+ const numberingDoc = parser.parseFromString(String(numberingXml || ''), 'application/xml');
327
+ const parseError = getXmlParseError(numberingDoc);
328
+ if (parseError) return numberingXml;
329
+
330
+ const nums = Array.from(numberingDoc.getElementsByTagNameNS('*', 'num'));
331
+ const target = nums.find(node => {
332
+ const id = getElementId(node, ['w:numId', 'numId']);
333
+ return id != null && String(id) === String(targetNumId);
334
+ });
335
+ if (!target) return numberingXml;
336
+
337
+ const abstractNumIdNode = Array.from(target.getElementsByTagNameNS('*', 'abstractNumId'))[0] || null;
338
+ const abstractNumId = getElementId(abstractNumIdNode, ['w:val', 'val']);
339
+
340
+ let lvlOverride = Array.from(target.getElementsByTagNameNS('*', 'lvlOverride'))
341
+ .find(node => {
342
+ const ilvl = getElementId(node, ['w:ilvl', 'ilvl']);
343
+ return ilvl === 0;
344
+ }) || null;
345
+ if (!lvlOverride) {
346
+ lvlOverride = numberingDoc.createElementNS(
347
+ 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
348
+ 'w:lvlOverride'
349
+ );
350
+ lvlOverride.setAttribute('w:ilvl', '0');
351
+ target.appendChild(lvlOverride);
352
+ }
353
+
354
+ let startOverride = Array.from(lvlOverride.getElementsByTagNameNS('*', 'startOverride'))[0] || null;
355
+ if (!startOverride) {
356
+ startOverride = numberingDoc.createElementNS(
357
+ 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
358
+ 'w:startOverride'
359
+ );
360
+ lvlOverride.appendChild(startOverride);
361
+ }
362
+ setElementVal(startOverride, startAt);
363
+
364
+ // Optional compatibility mode: set abstract-level <w:start> in addition to
365
+ // num-level <w:startOverride>. This can influence other lists sharing that
366
+ // abstract definition in some renderers, so callers may disable it.
367
+ if (setAbstractStartOverride && abstractNumId != null) {
368
+ const abstractNums = Array.from(numberingDoc.getElementsByTagNameNS('*', 'abstractNum'));
369
+ const abstractNum = abstractNums.find(node => {
370
+ const id = getElementId(node, ['w:abstractNumId', 'abstractNumId']);
371
+ return id != null && id === abstractNumId;
372
+ }) || null;
373
+ if (abstractNum) {
374
+ let lvl = Array.from(abstractNum.getElementsByTagNameNS('*', 'lvl'))
375
+ .find(node => {
376
+ const ilvl = getElementId(node, ['w:ilvl', 'ilvl']);
377
+ return ilvl === 0;
378
+ }) || null;
379
+ if (!lvl) {
380
+ lvl = numberingDoc.createElementNS(
381
+ 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
382
+ 'w:lvl'
383
+ );
384
+ lvl.setAttribute('w:ilvl', '0');
385
+ abstractNum.appendChild(lvl);
386
+ }
387
+
388
+ let startNode = Array.from(lvl.getElementsByTagNameNS('*', 'start'))[0] || null;
389
+ if (!startNode) {
390
+ startNode = numberingDoc.createElementNS(
391
+ 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
392
+ 'w:start'
393
+ );
394
+ lvl.insertBefore(startNode, lvl.firstChild);
395
+ }
396
+ setElementVal(startNode, startAt);
397
+ }
398
+ }
399
+
400
+ return serializer.serializeToString(numberingDoc);
401
+ }
402
+
403
+ /**
404
+ * Builds a plan for single-line structural list fallback.
405
+ *
406
+ * @param {{
407
+ * oxml: string,
408
+ * originalText: string,
409
+ * modifiedText: string,
410
+ * allowExistingList?: boolean
411
+ * }} options - Input values for fallback detection
412
+ * @returns {{ listInput: string, numberingKey: string, originalText: string, wasListParagraph: boolean, startAt: number|null }|null}
413
+ */
414
+ export function buildSingleLineListStructuralFallbackPlan(options = {}) {
415
+ const oxml = String(options.oxml || '');
416
+ const originalText = String(options.originalText || '');
417
+ const modifiedText = String(options.modifiedText || '');
418
+ const allowExistingList = options.allowExistingList === true;
419
+
420
+ if (!oxml.trim() || !modifiedText.trim()) return null;
421
+
422
+ const paragraph = getFirstParagraphFromOxml(oxml);
423
+ if (!paragraph) return null;
424
+
425
+ const existingListInfo = getParagraphListInfo(paragraph);
426
+ if (existingListInfo && !allowExistingList) return null;
427
+
428
+ const modifiedCleanText = preprocessMarkdown(modifiedText).cleanText || modifiedText;
429
+ const modifiedCandidate = parseSingleLineListCandidate(modifiedText)
430
+ || parseSingleLineListCandidate(modifiedCleanText);
431
+ if (!modifiedCandidate) return null;
432
+
433
+ const currentCandidate = parseSingleLineListCandidate(originalText);
434
+ const sameRawText =
435
+ normalizeWhitespaceForTargeting(originalText) === normalizeWhitespaceForTargeting(modifiedCleanText);
436
+ const sameListText = !!currentCandidate
437
+ && currentCandidate.type === modifiedCandidate.type
438
+ && currentCandidate.normalizedContent === modifiedCandidate.normalizedContent;
439
+ if (!sameRawText && !sameListText) return null;
440
+
441
+ return {
442
+ listInput: `${modifiedCandidate.marker} ${modifiedCandidate.contentText}`.trim(),
443
+ numberingKey: `${modifiedCandidate.type}:${modifiedCandidate.numberingStyle}:single`,
444
+ originalText,
445
+ wasListParagraph: !!existingListInfo,
446
+ startAt: modifiedCandidate.startAt
447
+ };
448
+ }
449
+
450
+ /**
451
+ * Executes a single-line structural list fallback plan.
452
+ *
453
+ * Returns list fragment OOXML (not wrapped package), plus `numberingXml`.
454
+ *
455
+ * @param {{ listInput: string, numberingKey: string, originalText?: string, startAt?: number|null }} plan - Fallback plan
456
+ * @param {{
457
+ * author?: string,
458
+ * generateRedlines?: boolean,
459
+ * pipeline?: ReconciliationPipeline,
460
+ * setAbstractStartOverride?: boolean
461
+ * }} [options={}] - Execution options
462
+ * @returns {Promise<{
463
+ * hasChanges: boolean,
464
+ * oxml: string,
465
+ * numberingXml: string|null,
466
+ * includeNumbering: boolean,
467
+ * listStructuralFallbackApplied: boolean,
468
+ * listStructuralFallbackKey: string|null,
469
+ * warnings: string[]
470
+ * }>}
471
+ */
472
+ export async function executeSingleLineListStructuralFallback(plan, options = {}) {
473
+ if (!plan || !plan.listInput) {
474
+ return {
475
+ hasChanges: false,
476
+ oxml: '',
477
+ numberingXml: null,
478
+ includeNumbering: false,
479
+ listStructuralFallbackApplied: false,
480
+ listStructuralFallbackKey: null,
481
+ warnings: ['Single-line list fallback plan missing']
482
+ };
483
+ }
484
+
485
+ const author = options.author || 'AI';
486
+ const generateRedlines = options.generateRedlines ?? true;
487
+ const pipeline = options.pipeline || new ReconciliationPipeline({ author, generateRedlines });
488
+
489
+ const result = await pipeline.executeListGeneration(
490
+ plan.listInput,
491
+ null,
492
+ null,
493
+ String(plan.originalText || '')
494
+ );
495
+
496
+ const rawOxml = result?.oxml || result?.ooxml || '';
497
+ const oxml = trimTrailingBlankParagraph(rawOxml);
498
+ const generatedNumId = extractFirstParagraphNumIdFromOxml(oxml);
499
+ const numberingXmlWithStart = applyStartOverrideToNumberingXml(
500
+ result?.numberingXml || null,
501
+ generatedNumId,
502
+ Number.isInteger(plan?.startAt) ? plan.startAt : null,
503
+ {
504
+ setAbstractStartOverride: options.setAbstractStartOverride
505
+ }
506
+ );
507
+ const isValid = result?.isValid !== false;
508
+ if (!oxml || !isValid) {
509
+ return {
510
+ hasChanges: false,
511
+ oxml: '',
512
+ numberingXml: null,
513
+ includeNumbering: false,
514
+ listStructuralFallbackApplied: false,
515
+ listStructuralFallbackKey: plan.numberingKey || null,
516
+ warnings: ['Single-line list fallback produced no valid OOXML']
517
+ };
518
+ }
519
+
520
+ return {
521
+ hasChanges: true,
522
+ oxml,
523
+ numberingXml: numberingXmlWithStart,
524
+ includeNumbering: true,
525
+ listStructuralFallbackApplied: true,
526
+ listStructuralFallbackKey: plan.numberingKey || null,
527
+ listStructuralFallbackStartAt: Number.isInteger(plan?.startAt) ? plan.startAt : null,
528
+ warnings: ['Single-line list structural fallback applied']
529
+ };
530
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Converts add-in redline tool payloads into shared standalone redline operations.
3
+ */
4
+
5
+ function normalizeEscapes(content) {
6
+ if (content == null) return '';
7
+ return String(content)
8
+ .replace(/\\n/g, '\n')
9
+ .replace(/\\t/g, '\t')
10
+ .replace(/\\r/g, '\r');
11
+ }
12
+
13
+ function toNonNegativeInteger(value, fallback = 0) {
14
+ const parsed = Number.parseInt(String(value ?? ''), 10);
15
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
16
+ }
17
+
18
+ /**
19
+ * Applies one substring replacement against source text.
20
+ *
21
+ * @param {string} sourceText
22
+ * @param {string} searchText
23
+ * @param {string} replacementText
24
+ * @returns {{ applied: boolean, modifiedText: string, matchMode: 'exact'|'case_insensitive'|null }}
25
+ */
26
+ export function applySubstringSearchReplace(sourceText, searchText, replacementText) {
27
+ const source = String(sourceText || '');
28
+ const search = String(searchText || '');
29
+ const replacement = String(replacementText || '');
30
+ if (!search) {
31
+ return {
32
+ applied: false,
33
+ modifiedText: source,
34
+ matchMode: null
35
+ };
36
+ }
37
+
38
+ const exactIndex = source.indexOf(search);
39
+ if (exactIndex >= 0) {
40
+ return {
41
+ applied: true,
42
+ modifiedText: `${source.slice(0, exactIndex)}${replacement}${source.slice(exactIndex + search.length)}`,
43
+ matchMode: 'exact'
44
+ };
45
+ }
46
+
47
+ const lowerSource = source.toLowerCase();
48
+ const lowerSearch = search.toLowerCase();
49
+ const insensitiveIndex = lowerSource.indexOf(lowerSearch);
50
+ if (insensitiveIndex >= 0) {
51
+ return {
52
+ applied: true,
53
+ modifiedText: `${source.slice(0, insensitiveIndex)}${replacement}${source.slice(insensitiveIndex + search.length)}`,
54
+ matchMode: 'case_insensitive'
55
+ };
56
+ }
57
+
58
+ return {
59
+ applied: false,
60
+ modifiedText: source,
61
+ matchMode: null
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Builds a shared redline operation for a scoped paragraph/range OOXML document.
67
+ *
68
+ * @param {Object} change - Add-in tool payload
69
+ * @param {{
70
+ * scopeStartText?: string,
71
+ * scopeParagraphCount?: number,
72
+ * insertionBeforeStart?: boolean
73
+ * }} [context={}]
74
+ * @returns {{ ok: true, operation: Object } | { ok: false, reason: string }}
75
+ */
76
+ export function toScopedSharedRedlineOperation(change, context = {}) {
77
+ const operationName = String(change?.operation || '').trim().toLowerCase();
78
+ const scopeStartText = String(context.scopeStartText || '').trim();
79
+ const scopeParagraphCount = Math.max(1, toNonNegativeInteger(context.scopeParagraphCount, 1));
80
+ const insertionBeforeStart = context.insertionBeforeStart === true;
81
+
82
+ if (!scopeStartText) {
83
+ return { ok: false, reason: 'Target paragraph text is empty; cannot build shared redline target.' };
84
+ }
85
+
86
+ if (!operationName) {
87
+ return { ok: false, reason: 'Missing change.operation value.' };
88
+ }
89
+
90
+ let modifiedText = null;
91
+ if (operationName === 'edit_paragraph') {
92
+ if (change?.newContent == null) {
93
+ return { ok: false, reason: 'Missing newContent for edit_paragraph.' };
94
+ }
95
+ modifiedText = normalizeEscapes(change.newContent);
96
+ } else if (operationName === 'replace_paragraph' || operationName === 'replace_range') {
97
+ const replacementContent = change?.content ?? change?.newContent ?? change?.replacementText;
98
+ if (replacementContent == null) {
99
+ return { ok: false, reason: `Missing content for ${operationName}.` };
100
+ }
101
+ const normalizedReplacement = normalizeEscapes(replacementContent);
102
+ if (operationName === 'replace_range' && insertionBeforeStart) {
103
+ // Model occasionally emits an insertion-at-start shape as:
104
+ // replace_range(P1..P0). Normalize to "insert paragraph before P1"
105
+ // by preserving P1 text and prefixing inserted content + paragraph break.
106
+ modifiedText = scopeStartText
107
+ ? `${normalizedReplacement}\n${scopeStartText}`
108
+ : normalizedReplacement;
109
+ } else {
110
+ modifiedText = normalizedReplacement;
111
+ }
112
+ } else if (operationName === 'modify_text') {
113
+ const searchText = normalizeEscapes(change?.originalText || '');
114
+ if (!searchText.trim()) {
115
+ return { ok: false, reason: 'Missing originalText for modify_text.' };
116
+ }
117
+ const replacementText = normalizeEscapes(change?.replacementText || '');
118
+ const replacement = applySubstringSearchReplace(scopeStartText, searchText, replacementText);
119
+ if (!replacement.applied) {
120
+ return { ok: false, reason: `Could not find modify_text originalText in target paragraph.` };
121
+ }
122
+ modifiedText = replacement.modifiedText;
123
+ } else {
124
+ return { ok: false, reason: `Unsupported redline operation: ${operationName}` };
125
+ }
126
+
127
+ const op = {
128
+ type: 'redline',
129
+ // Scoped bridge payloads are rebuilt as P1..Pn; always anchor to P1.
130
+ targetRef: 'P1',
131
+ target: scopeStartText,
132
+ modified: modifiedText
133
+ };
134
+ if (operationName === 'replace_range') {
135
+ if (scopeParagraphCount > 1) {
136
+ op.targetEndRef = `P${scopeParagraphCount}`;
137
+ }
138
+ }
139
+
140
+ return { ok: true, operation: op };
141
+ }