@ansonlai/docx-redline-js 0.4.0 → 0.5.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.
Files changed (104) hide show
  1. package/AGENTS.md +646 -288
  2. package/ARCHITECTURE.md +215 -9
  3. package/CHANGELOG.md +319 -0
  4. package/README.md +604 -360
  5. package/adapters/config.js +45 -43
  6. package/bin/docx-redline.js +3 -0
  7. package/core/list-targeting.js +101 -110
  8. package/core/paragraph-targeting.js +501 -61
  9. package/core/paragraph-text.js +209 -0
  10. package/core/redline-validation.js +11 -5
  11. package/core/revision-cloning.js +38 -0
  12. package/core/types.js +64 -10
  13. package/core/word-xml.js +43 -15
  14. package/dist/docx-redline-js.esm.js +3145 -505
  15. package/dist/docx-redline-js.esm.js.map +4 -4
  16. package/dist/docx-redline-js.esm.min.js +88 -76
  17. package/dist/docx-redline-js.esm.min.js.map +4 -4
  18. package/docs/TESTING.md +342 -23
  19. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  20. package/docs/plans/2026-09-08-cross-author-revision-slicing.md +505 -0
  21. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  22. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  23. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  24. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  25. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  26. package/docs/schemas/document-operations.schema.json +109 -0
  27. package/docs/test-comparison-dashboard.html +4250 -7
  28. package/engine/formatting-removal.js +11 -2
  29. package/engine/oxml-engine.js +508 -336
  30. package/engine/reconstruction-mode.js +15 -14
  31. package/engine/reconstruction-writer.js +247 -142
  32. package/engine/route-selection.js +35 -0
  33. package/engine/rpr-helpers.js +334 -35
  34. package/engine/run-builders.js +239 -196
  35. package/engine/surgical-diff-application.js +407 -50
  36. package/engine/surgical-mode.js +142 -6
  37. package/engine/surgical-run-splitting.js +103 -0
  38. package/engine/surgical-spans.js +52 -1
  39. package/engine/table-cell-context.js +3 -6
  40. package/engine/table-mode.js +1 -1
  41. package/index.d.ts +234 -6
  42. package/index.js +24 -1
  43. package/node/cli.js +322 -0
  44. package/node/docx-document.js +302 -0
  45. package/node/index.d.ts +31 -0
  46. package/node/index.js +2 -0
  47. package/node/zip-archive.js +52 -0
  48. package/orchestration/list-markdown.js +10 -16
  49. package/orchestration/list-parsing.js +7 -12
  50. package/orchestration/list-structural-fallback.js +21 -10
  51. package/package.json +123 -102
  52. package/pipeline/content-analysis.js +12 -17
  53. package/pipeline/ingestion-export.js +3 -31
  54. package/pipeline/ingestion-paragraph.js +10 -5
  55. package/pipeline/list-generation.js +150 -55
  56. package/pipeline/list-markers.js +70 -3
  57. package/pipeline/serialization.js +4 -2
  58. package/pipeline/structured-content.js +160 -0
  59. package/scripts/apply_changes.mjs +27 -0
  60. package/scripts/benchmark-operation-session.mjs +137 -0
  61. package/scripts/benchmark-targeting-browser.html +74 -0
  62. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  63. package/scripts/benchmark-test-runner.mjs +59 -0
  64. package/scripts/build-test-dashboard.mjs +23 -0
  65. package/scripts/export-lane1-fixtures.mjs +380 -0
  66. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  67. package/scripts/export-validation-fixtures.mjs +1 -1
  68. package/scripts/extract_text.mjs +7 -0
  69. package/scripts/generate-cross-author-slicing-fixtures.ps1 +256 -0
  70. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  71. package/scripts/generate-test-dashboard.mjs +362 -11
  72. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  73. package/scripts/profile-route-selection.mjs +19 -0
  74. package/scripts/render-agenda-multilevel.mjs +0 -5
  75. package/scripts/render-multilevel-cases.mjs +0 -1
  76. package/scripts/run-tests.mjs +107 -35
  77. package/scripts/word-com-corpus-suite.ps1 +3 -0
  78. package/scripts/word-com-differential.ps1 +64 -4
  79. package/scripts/word-com-suite.ps1 +3 -0
  80. package/services/batch-operation-orchestrator.js +513 -0
  81. package/services/capture-engine.js +226 -0
  82. package/services/comment-builders.js +23 -6
  83. package/services/comment-engine.js +108 -47
  84. package/services/comment-locator.js +187 -82
  85. package/services/comment-replies.js +95 -0
  86. package/services/document-inspection.js +258 -0
  87. package/services/document-operation-applier.js +372 -0
  88. package/services/document-operation-contract.js +345 -0
  89. package/services/document-operation-mutations.js +1749 -0
  90. package/services/document-operation-session.js +258 -0
  91. package/services/numbering-service.js +14 -5
  92. package/services/operation-heuristics.js +173 -0
  93. package/services/operation-preflight.js +390 -0
  94. package/services/receipt-collector.js +288 -0
  95. package/services/revision-comment-management.js +77 -5
  96. package/services/revision-token.js +290 -0
  97. package/services/standalone-docx-plumbing.js +123 -8
  98. package/services/standalone-operation-runner.d.ts +296 -0
  99. package/services/standalone-operation-runner.js +10 -1455
  100. package/services/table-reconciliation.js +15 -6
  101. package/docs/VALIDATION.md +0 -183
  102. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  103. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  104. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -1,1459 +1,14 @@
1
1
  /**
2
- * Standalone document-operation runner for redline/highlight/comment operations.
2
+ * Backwards-compatible public facade for standalone document operations.
3
3
  *
4
- * This module centralizes the browser-demo operation bridge so host layers can
5
- * stay focused on UI + prompt orchestration.
4
+ * Keep this module intentionally small: implementation lives behind focused
5
+ * internal boundaries while this path and its declarations remain stable.
6
6
  */
7
7
 
8
- import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
9
- import { findReconstructionParagraphRange } from '../engine/reconstruction-mapper.js';
10
- import {
11
- RevisionIdAllocator,
12
- createRevisionMetadata,
13
- seedRevisionIdsFromDocument,
14
- setRevisionIdAllocatorForDocument
15
- } from '../core/types.js';
16
- import { createWordElement } from '../core/word-xml.js';
17
- import { markParagraphMarkInserted } from '../engine/run-builders.js';
18
- import {
19
- applyRedlineToOxml,
20
- reconcileMarkdownTableOoxml,
21
- applyHighlightToOoxml,
22
- injectCommentsIntoOoxml,
23
- getParagraphText as getParagraphTextFromOxml,
24
- isMarkdownTableText,
25
- findContainingWordElement,
26
- buildTargetReferenceSnapshot,
27
- resolveTargetParagraphWithSnapshot as resolveTargetParagraphWithSnapshotShared,
28
- buildSingleLineListStructuralFallbackPlan,
29
- executeSingleLineListStructuralFallback,
30
- resolveSingleLineListFallbackNumberingAction,
31
- recordSingleLineListFallbackExplicitSequence,
32
- clearSingleLineListFallbackExplicitSequence,
33
- enforceListBindingOnParagraphNodes,
34
- synthesizeTableMarkdownFromMultilineCellEdit,
35
- synthesizeExpandedListScopeEdit,
36
- planListInsertionOnlyEdit,
37
- getParagraphListInfo,
38
- stripRedundantLeadingListMarkers,
39
- stripSingleLineListMarkerPrefix,
40
- normalizeWhitespaceForTargeting,
41
- reserveNextNumberingIdPair,
42
- remapNumberingPayloadForDocument,
43
- overwriteParagraphNumIds,
44
- extractFirstParagraphNumId,
45
- buildExplicitDecimalMultilevelNumberingXml,
46
- inferTableReplacementParagraphBlock,
47
- resolveParagraphRangeByRefs,
48
- extractReplacementNodesFromOoxml,
49
- normalizeBodySectionOrderStandalone
50
- } from '../index.js';
51
-
52
- const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
53
-
54
- function prepareRevisionAllocator(xmlDoc, options = {}) {
55
- const allocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
56
- ? options._revisionIdAllocator
57
- : new RevisionIdAllocator();
58
- seedRevisionIdsFromDocument(xmlDoc, allocator);
59
- setRevisionIdAllocatorForDocument(xmlDoc, allocator);
60
- return allocator;
61
- }
62
-
63
- function getParagraphText(paragraph) {
64
- return getParagraphTextFromOxml(paragraph);
65
- }
66
-
67
- function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
68
- const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
69
- const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
70
- return resolveTargetParagraphWithSnapshotShared(xmlDoc, {
71
- targetText,
72
- targetRef,
73
- opType,
74
- targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
75
- onInfo,
76
- onWarn
77
- });
78
- }
79
-
80
- function extractReplacementNodes(outputOxml) {
81
- return extractReplacementNodesFromOoxml(outputOxml);
82
- }
83
-
84
- function normalizeBodySectionOrder(xmlDoc) {
85
- normalizeBodySectionOrderStandalone(xmlDoc);
86
- }
87
-
88
- function removeProofErrNodes(paragraph) {
89
- for (const node of Array.from(paragraph?.getElementsByTagNameNS?.(NS_W, 'proofErr') || [])) {
90
- node.parentNode?.removeChild(node);
91
- }
92
- }
93
-
94
- function preprocessRedlineTargetParagraph(targetParagraph) {
95
- if (!targetParagraph) return;
96
- removeProofErrNodes(targetParagraph);
97
- }
98
-
99
- function getDirectWordChild(element, localName) {
100
- if (!element) return null;
101
- return Array.from(element.childNodes || []).find(
102
- node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === localName
103
- ) || null;
104
- }
105
-
106
- function computeTableIndexInDocument(xmlDoc, tableElement) {
107
- if (!xmlDoc || !tableElement) return null;
108
- const tables = Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'tbl'));
109
- const idx = tables.indexOf(tableElement);
110
- return idx >= 0 ? idx + 1 : null;
111
- }
112
-
113
- function normalizeMultilineTableStructuralPayload(text) {
114
- return String(text || '')
115
- .replace(/\r\n/g, '\n')
116
- .split('\n')
117
- .map(line => line.trim())
118
- .filter(Boolean)
119
- .join('\n');
120
- }
121
-
122
- function computeTableStructuralDedupeKey(xmlDoc, containingTable, modifiedText) {
123
- const tableIndex = computeTableIndexInDocument(xmlDoc, containingTable);
124
- if (!Number.isInteger(tableIndex) || tableIndex < 1) return null;
125
- const normalizedPayload = normalizeMultilineTableStructuralPayload(modifiedText);
126
- if (!normalizedPayload) return null;
127
- return `table:${tableIndex}|payload:${normalizedPayload}`;
128
- }
129
-
130
- function ensureListProperties(xmlDoc, paragraph, ilvl, numId) {
131
- let pPr = getDirectWordChild(paragraph, 'pPr');
132
- if (!pPr) {
133
- pPr = createWordElement(xmlDoc, 'w:pPr');
134
- paragraph.insertBefore(pPr, paragraph.firstChild);
135
- }
136
-
137
- let numPr = getDirectWordChild(pPr, 'numPr');
138
- if (!numPr) {
139
- numPr = createWordElement(xmlDoc, 'w:numPr');
140
- pPr.appendChild(numPr);
141
- }
142
-
143
- let ilvlEl = getDirectWordChild(numPr, 'ilvl');
144
- if (!ilvlEl) {
145
- ilvlEl = createWordElement(xmlDoc, 'w:ilvl');
146
- numPr.appendChild(ilvlEl);
147
- }
148
- ilvlEl.setAttribute('w:val', String(Math.max(0, Number.parseInt(ilvl, 10) || 0)));
149
-
150
- let numIdEl = getDirectWordChild(numPr, 'numId');
151
- if (!numIdEl) {
152
- numIdEl = createWordElement(xmlDoc, 'w:numId');
153
- numPr.appendChild(numIdEl);
154
- }
155
- numIdEl.setAttribute('w:val', String(numId));
156
- }
157
-
158
- function buildInsertedListParagraph(xmlDoc, anchorParagraph, entry, revisionMetadata, author, options = {}) {
159
- const generateRedlines = options.generateRedlines !== false;
160
- const paragraph = createWordElement(xmlDoc, 'w:p');
161
-
162
- const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
163
- if (anchorPPr) {
164
- paragraph.appendChild(anchorPPr.cloneNode(true));
165
- }
166
- ensureListProperties(xmlDoc, paragraph, entry.ilvl, entry.numId);
167
-
168
- // A new list item is a whole inserted paragraph, not merely inserted text.
169
- // Tracking its paragraph mark lets Word (and our accept/reject helpers)
170
- // remove the list paragraph itself on Reject All instead of leaving an
171
- // empty bullet or number behind.
172
- if (generateRedlines) {
173
- markParagraphMarkInserted(xmlDoc, paragraph, author);
174
- }
175
-
176
- const run = createWordElement(xmlDoc, 'w:r');
177
- const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
178
- const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
179
- if (anchorRunPr) {
180
- run.appendChild(anchorRunPr.cloneNode(true));
181
- }
182
-
183
- const textNode = createWordElement(xmlDoc, 'w:t');
184
- const safeText = String(entry.text || '').trim();
185
- if (/^\s|\s$/.test(safeText)) textNode.setAttribute('xml:space', 'preserve');
186
- textNode.textContent = safeText;
187
- run.appendChild(textNode);
188
- if (generateRedlines) {
189
- const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc);
190
- const ins = createWordElement(xmlDoc, 'w:ins');
191
- ins.setAttribute('w:id', String(metadata.id));
192
- ins.setAttribute('w:author', metadata.author);
193
- ins.setAttribute('w:date', metadata.date);
194
- ins.appendChild(run);
195
- paragraph.appendChild(ins);
196
- } else {
197
- paragraph.appendChild(run);
198
- }
199
-
200
- return paragraph;
201
- }
202
-
203
- function serializeParagraphRangeAsDocument(paragraphs, serializer) {
204
- const paragraphXml = (paragraphs || [])
205
- .map(paragraph => serializer.serializeToString(paragraph))
206
- .join('');
207
- return `<w:document xmlns:w="${NS_W}"><w:body>${paragraphXml}</w:body></w:document>`;
208
- }
209
-
210
- const LIST_LINE_REGEX = /^(\s*)((?:\d+(?:\.\d+)*\.?|\((?:\d+|[a-zA-Z]|[ivxlcIVXLC]+)\)|[a-zA-Z]\.|[ivxlcIVXLC]+\.|[-*+\u2022]))\s+(.*)$/;
211
- const INLINE_LIST_MARKER_REGEX = /(?:^|\s)(?:\d+(?:\.\d+)*\.?|[A-Za-z]\.|[ivxlcIVXLC]+\.)\s+/g;
212
-
213
- function parseOutlineLevelFromMarker(marker) {
214
- const normalized = String(marker || '').trim();
215
- if (!/^\d+(?:\.\d+)+\.?$/.test(normalized)) return null;
216
- const parts = normalized.replace(/\.$/, '').split('.');
217
- return Math.max(0, parts.length - 1);
218
- }
219
-
220
- function parseModifiedListLines(modifiedText) {
221
- const lines = String(modifiedText || '')
222
- .split(/\r?\n/g)
223
- .map(line => line.trimEnd())
224
- .filter(line => line.trim().length > 0);
225
- if (lines.length < 2) return null;
226
-
227
- const parsed = [];
228
- for (const line of lines) {
229
- const markerMatch = line.match(LIST_LINE_REGEX);
230
- if (!markerMatch) return null;
231
- const marker = markerMatch[2];
232
- const markerType = /^[-*+\u2022]$/.test(marker) ? 'bullet' : 'numbered';
233
- parsed.push({
234
- marker,
235
- markerType,
236
- level: Math.floor((markerMatch[1] || '').length / 2),
237
- outlineLevel: markerType === 'numbered' ? parseOutlineLevelFromMarker(marker) : null,
238
- text: stripRedundantLeadingListMarkers(markerMatch[3])
239
- });
240
- }
241
- return parsed.length >= 2 ? parsed : null;
242
- }
243
-
244
- function buildExplicitRangeInsertionEntries(explicitRangeParagraphs, modifiedText) {
245
- if (!Array.isArray(explicitRangeParagraphs) || explicitRangeParagraphs.length === 0) return null;
246
- const parsedLines = parseModifiedListLines(modifiedText);
247
- if (!parsedLines) return null;
248
-
249
- const originalTexts = explicitRangeParagraphs.map(paragraph =>
250
- normalizeWhitespaceForTargeting(getParagraphText(paragraph))
251
- );
252
- const modifiedTexts = parsedLines.map(item => normalizeWhitespaceForTargeting(item.text));
253
- if (originalTexts.some(text => !text) || modifiedTexts.some(text => !text)) return null;
254
-
255
- const listInfos = explicitRangeParagraphs.map(paragraph => getParagraphListInfo(paragraph));
256
- if (listInfos.some(info => !info || !info.numId)) return null;
257
- const baselineNumId = String(listInfos[0].numId);
258
- if (listInfos.some(info => String(info.numId) !== baselineNumId)) return null;
259
-
260
- const matchedPairs = [];
261
- let originalIndex = 0;
262
- for (let modifiedIndex = 0; modifiedIndex < modifiedTexts.length && originalIndex < originalTexts.length; modifiedIndex += 1) {
263
- if (modifiedTexts[modifiedIndex] === originalTexts[originalIndex]) {
264
- matchedPairs.push({ originalIndex, modifiedIndex });
265
- originalIndex += 1;
266
- }
267
- }
268
- if (originalIndex !== originalTexts.length) return null;
269
-
270
- const matchedModifiedIndexes = new Set(matchedPairs.map(pair => pair.modifiedIndex));
271
- const insertedIndexes = [];
272
- for (let idx = 0; idx < parsedLines.length; idx += 1) {
273
- if (!matchedModifiedIndexes.has(idx)) insertedIndexes.push(idx);
274
- }
275
- if (insertedIndexes.length === 0) return null;
276
-
277
- const baseIndentLevel = parsedLines[0]?.level || 0;
278
- return insertedIndexes.map(modifiedIndex => {
279
- const nextMatch = matchedPairs.find(pair => pair.modifiedIndex > modifiedIndex) || null;
280
- const prevMatch = [...matchedPairs].reverse().find(pair => pair.modifiedIndex < modifiedIndex) || null;
281
- const referenceMatch = nextMatch || prevMatch;
282
- if (!referenceMatch) return null;
283
- const referenceListInfo = listInfos[referenceMatch.originalIndex] || listInfos[0];
284
- if (!referenceListInfo) return null;
285
-
286
- const entry = parsedLines[modifiedIndex];
287
- const relativeLevel = Math.max(0, entry.level - baseIndentLevel);
288
- const explicitOutlineLevel = Number.isInteger(entry.outlineLevel) ? entry.outlineLevel : null;
289
- return {
290
- text: entry.text,
291
- markerType: entry.markerType,
292
- ilvl: explicitOutlineLevel != null
293
- ? explicitOutlineLevel
294
- : Math.max(0, (referenceListInfo.ilvl || 0) + relativeLevel),
295
- numId: String(referenceListInfo.numId),
296
- insertBeforeOriginalIndex: nextMatch ? nextMatch.originalIndex : null
297
- };
298
- }).filter(Boolean);
299
- }
300
-
301
- function applyExplicitRangeListInsertions({
302
- xmlDoc,
303
- explicitRangeParagraphs,
304
- insertionEntries,
305
- generateRedlines,
306
- author
307
- }) {
308
- if (!Array.isArray(explicitRangeParagraphs) || explicitRangeParagraphs.length === 0) return false;
309
- if (!Array.isArray(insertionEntries) || insertionEntries.length === 0) return false;
310
-
311
- const parent = explicitRangeParagraphs[0].parentNode;
312
- if (!parent || explicitRangeParagraphs.some(paragraph => paragraph.parentNode !== parent)) return false;
313
-
314
- const tailInsertionPoint = explicitRangeParagraphs[explicitRangeParagraphs.length - 1].nextSibling;
315
- for (const entry of insertionEntries) {
316
- const referenceParagraph = entry.insertBeforeOriginalIndex != null
317
- ? explicitRangeParagraphs[entry.insertBeforeOriginalIndex]
318
- : explicitRangeParagraphs[explicitRangeParagraphs.length - 1];
319
- if (!referenceParagraph) return false;
320
-
321
- const listParagraph = buildInsertedListParagraph(
322
- xmlDoc,
323
- referenceParagraph,
324
- {
325
- ilvl: entry.ilvl,
326
- markerType: entry.markerType,
327
- numId: entry.numId,
328
- text: entry.text
329
- },
330
- generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
331
- author,
332
- { generateRedlines }
333
- );
334
-
335
- if (entry.insertBeforeOriginalIndex != null) {
336
- parent.insertBefore(listParagraph, referenceParagraph);
337
- } else {
338
- parent.insertBefore(listParagraph, tailInsertionPoint);
339
- }
340
- }
341
-
342
- normalizeBodySectionOrder(xmlDoc);
343
- return true;
344
- }
345
-
346
- function countWords(text) {
347
- return String(text || '')
348
- .trim()
349
- .split(/\s+/)
350
- .filter(Boolean)
351
- .length;
352
- }
353
-
354
- function hasMultipleInlineListMarkers(text) {
355
- const source = String(text || '');
356
- if (!source) return false;
357
-
358
- let count = 0;
359
- const regex = new RegExp(INLINE_LIST_MARKER_REGEX.source, INLINE_LIST_MARKER_REGEX.flags);
360
- while (regex.exec(source)) {
361
- count += 1;
362
- if (count >= 2) return true;
363
- }
364
- return false;
365
- }
366
-
367
- function deriveSingleParagraphListAdjacencyInsertion(currentParagraphText, modifiedText) {
368
- const rawCurrent = String(currentParagraphText || '').trim();
369
- const rawModified = String(modifiedText || '').trim();
370
- if (!rawCurrent || !rawModified || rawModified === rawCurrent) return null;
371
- if (rawModified.includes('\n')) return null;
372
- const normalizedCurrent = normalizeWhitespaceForTargeting(rawCurrent);
373
-
374
- const minWords = 6;
375
- const sanitizeCandidate = text => stripRedundantLeadingListMarkers(String(text || '').trim()).trim();
376
- const buildCandidate = (position, text) => {
377
- const cleanedText = sanitizeCandidate(text);
378
- const cleanedNormalized = normalizeWhitespaceForTargeting(cleanedText);
379
- if (!cleanedText || cleanedText === rawCurrent) return null;
380
- if (countWords(cleanedText) < minWords) return null;
381
- if (hasMultipleInlineListMarkers(cleanedText)) return null;
382
- if (normalizedCurrent && cleanedNormalized.includes(normalizedCurrent)) return null;
383
- return { position, text: cleanedText };
384
- };
385
-
386
- if (rawModified.endsWith(rawCurrent)) {
387
- const prefix = rawModified.slice(0, rawModified.length - rawCurrent.length);
388
- const candidate = buildCandidate('before', prefix);
389
- if (candidate) return candidate;
390
- }
391
-
392
- if (rawModified.startsWith(rawCurrent)) {
393
- const suffix = rawModified.slice(rawCurrent.length);
394
- const candidate = buildCandidate('after', suffix);
395
- if (candidate) return candidate;
396
- }
397
-
398
- const normalizedModified = normalizeWhitespaceForTargeting(rawModified);
399
- if (!normalizedCurrent || normalizedCurrent === normalizedModified) return null;
400
-
401
- if (normalizedModified.endsWith(normalizedCurrent)) {
402
- const prefix = normalizedModified.slice(0, normalizedModified.length - normalizedCurrent.length);
403
- const candidate = buildCandidate('before', prefix);
404
- if (candidate) return candidate;
405
- }
406
-
407
- if (normalizedModified.startsWith(normalizedCurrent)) {
408
- const suffix = normalizedModified.slice(normalizedCurrent.length);
409
- const candidate = buildCandidate('after', suffix);
410
- if (candidate) return candidate;
411
- }
412
-
413
- return null;
414
- }
415
-
416
- function deriveSingleParagraphPlainAdjacencyInsertion(currentParagraphText, modifiedText) {
417
- const rawCurrent = String(currentParagraphText || '').trim();
418
- const rawModified = String(modifiedText || '');
419
- if (!rawCurrent || !rawModified || !rawModified.includes('\n')) return null;
420
-
421
- const lines = rawModified
422
- .split(/\r?\n/g)
423
- .map(line => String(line || '').trim())
424
- .filter(Boolean);
425
- if (lines.length < 2) return null;
426
-
427
- const normalize = value => normalizeWhitespaceForTargeting(String(value || ''));
428
- const normalizedCurrent = normalize(rawCurrent);
429
- const normalizedFirst = normalize(lines[0]);
430
- const normalizedLast = normalize(lines[lines.length - 1]);
431
-
432
- if (normalizedLast === normalizedCurrent) {
433
- const paragraphs = lines.slice(0, -1).map(line => String(line || '').trim()).filter(Boolean);
434
- if (paragraphs.length > 0) {
435
- return { position: 'before', paragraphs };
436
- }
437
- }
438
-
439
- if (normalizedFirst === normalizedCurrent) {
440
- const paragraphs = lines.slice(1).map(line => String(line || '').trim()).filter(Boolean);
441
- if (paragraphs.length > 0) {
442
- return { position: 'after', paragraphs };
443
- }
444
- }
445
-
446
- return null;
447
- }
448
-
449
- function buildFallbackInsertedPlainParagraph(xmlDoc, text, revisionMetadata, author, options = {}) {
450
- const generateRedlines = options.generateRedlines !== false;
451
- const paragraph = createWordElement(xmlDoc, 'w:p');
452
- const run = createWordElement(xmlDoc, 'w:r');
453
- const textNode = createWordElement(xmlDoc, 'w:t');
454
- const safeText = String(text || '');
455
- if (/^\s|\s$/.test(safeText)) textNode.setAttribute('xml:space', 'preserve');
456
- textNode.textContent = safeText;
457
- run.appendChild(textNode);
458
-
459
- if (generateRedlines) {
460
- const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc);
461
- const ins = createWordElement(xmlDoc, 'w:ins');
462
- ins.setAttribute('w:id', String(metadata.id));
463
- ins.setAttribute('w:author', metadata.author);
464
- ins.setAttribute('w:date', metadata.date);
465
- ins.appendChild(run);
466
- paragraph.appendChild(ins);
467
- } else {
468
- paragraph.appendChild(run);
469
- }
470
-
471
- return paragraph;
472
- }
473
-
474
- function buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph) {
475
- const paragraph = createWordElement(xmlDoc, 'w:p');
476
- const anchorPPr = getDirectWordChild(anchorParagraph, 'pPr');
477
- if (anchorPPr) paragraph.appendChild(anchorPPr.cloneNode(true));
478
-
479
- const run = createWordElement(xmlDoc, 'w:r');
480
- const anchorFirstRun = Array.from(anchorParagraph.getElementsByTagNameNS(NS_W, 'r'))[0] || null;
481
- const anchorRunPr = anchorFirstRun ? getDirectWordChild(anchorFirstRun, 'rPr') : null;
482
- if (anchorRunPr) run.appendChild(anchorRunPr.cloneNode(true));
483
-
484
- const textNode = createWordElement(xmlDoc, 'w:t');
485
- textNode.textContent = '';
486
- run.appendChild(textNode);
487
- paragraph.appendChild(run);
488
- return paragraph;
489
- }
490
-
491
- function wrapParagraphContentInInsertion(xmlDoc, paragraph, revisionMetadata, author) {
492
- const wrappedParagraph = createWordElement(xmlDoc, 'w:p');
493
- const pPr = getDirectWordChild(paragraph, 'pPr');
494
- if (pPr) wrappedParagraph.appendChild(pPr.cloneNode(true));
495
-
496
- const ins = createWordElement(xmlDoc, 'w:ins');
497
- const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc);
498
- ins.setAttribute('w:id', String(metadata.id));
499
- ins.setAttribute('w:author', metadata.author);
500
- ins.setAttribute('w:date', metadata.date);
501
-
502
- for (const child of Array.from(paragraph.childNodes || [])) {
503
- if (child?.nodeType === 1 && child.namespaceURI === NS_W && child.localName === 'pPr') continue;
504
- ins.appendChild(child.cloneNode(true));
505
- }
506
-
507
- wrappedParagraph.appendChild(ins);
508
- return wrappedParagraph;
509
- }
510
-
511
- async function buildInsertedPlainParagraph(xmlDoc, anchorParagraph, text, revisionMetadata, author, options = {}) {
512
- const generateRedlines = options.generateRedlines !== false;
513
- const serializer = createSerializer();
514
- const templateParagraph = buildEmptyParagraphTemplateFromAnchor(xmlDoc, anchorParagraph);
515
- const templateXml = serializer.serializeToString(templateParagraph);
516
- const markdownResult = await applyRedlineToOxml(
517
- templateXml,
518
- '',
519
- String(text || ''),
520
- {
521
- author,
522
- generateRedlines: false
523
- }
524
- );
525
-
526
- let sourceParagraph = null;
527
- if (typeof markdownResult?.oxml === 'string') {
528
- const extracted = extractReplacementNodes(markdownResult.oxml);
529
- sourceParagraph = (extracted.replacementNodes || []).find(
530
- node => node && node.nodeType === 1 && node.namespaceURI === NS_W && node.localName === 'p'
531
- ) || null;
532
- }
533
-
534
- if (!sourceParagraph) {
535
- return buildFallbackInsertedPlainParagraph(
536
- xmlDoc,
537
- text,
538
- revisionMetadata,
539
- author,
540
- { generateRedlines }
541
- );
542
- }
543
-
544
- if (!generateRedlines) {
545
- return sourceParagraph;
546
- }
547
-
548
- return wrapParagraphContentInInsertion(xmlDoc, sourceParagraph, revisionMetadata, author);
549
- }
550
-
551
- async function tryExplicitDecimalHeaderListConversion({
552
- xmlDoc,
553
- serializer,
554
- targetParagraph,
555
- currentParagraphText,
556
- modifiedText,
557
- author,
558
- runtimeContext,
559
- generateRedlines = true,
560
- onInfo = () => { }
561
- }) {
562
- if (!targetParagraph) return null;
563
- const scopedParagraphOxml = serializer.serializeToString(targetParagraph);
564
- const explicitPlan = buildSingleLineListStructuralFallbackPlan({
565
- oxml: scopedParagraphOxml,
566
- originalText: currentParagraphText,
567
- modifiedText,
568
- allowExistingList: false
569
- });
570
- if (
571
- !explicitPlan ||
572
- explicitPlan.numberingKey !== 'numbered:decimal:single' ||
573
- !Number.isInteger(explicitPlan.startAt) ||
574
- explicitPlan.startAt < 1
575
- ) {
576
- return null;
577
- }
578
-
579
- const strippedContent = stripSingleLineListMarkerPrefix(explicitPlan.listInput || modifiedText);
580
- if (!strippedContent) return null;
581
-
582
- onInfo('[List] Applying explicit numeric header conversion with direct list binding.');
583
- const redlineResult = await applyRedlineToOxml(
584
- serializer.serializeToString(targetParagraph),
585
- currentParagraphText,
586
- strippedContent,
587
- {
588
- author,
589
- generateRedlines
590
- }
591
- );
592
- if (!redlineResult?.hasChanges || typeof redlineResult?.oxml !== 'string') return null;
593
-
594
- const extracted = extractReplacementNodes(redlineResult.oxml);
595
- const replacementNodes = extracted.replacementNodes;
596
- const numberingAction = resolveSingleLineListFallbackNumberingAction(
597
- explicitPlan,
598
- runtimeContext?.listFallbackSequenceState || null
599
- );
600
-
601
- const explicitStart = explicitPlan.startAt;
602
- const numberingState = runtimeContext?.numberingIdState || null;
603
- let appliedNumId = null;
604
- let numberingXml = null;
605
-
606
- if (numberingAction.type === 'explicitReuse' && numberingAction.numId) {
607
- appliedNumId = String(numberingAction.numId);
608
- onInfo(`[List] Reusing explicit-start list sequence (${numberingAction.numberingKey} -> numId ${appliedNumId}, next ${explicitStart + 1}).`);
609
- } else {
610
- const reservedPair = reserveNextNumberingIdPair(numberingState);
611
- if (!reservedPair) return null;
612
-
613
- appliedNumId = String(reservedPair.numId);
614
- numberingXml = buildExplicitDecimalMultilevelNumberingXml(
615
- reservedPair.numId,
616
- reservedPair.abstractNumId,
617
- explicitStart
618
- );
619
-
620
- if (numberingAction.type === 'explicitStartNew') {
621
- onInfo(`[List] Started explicit-start list sequence (${numberingAction.numberingKey} -> numId ${appliedNumId}).`);
622
- }
623
- onInfo(`[List] Using isolated explicit-start numbering (start ${explicitStart}, numId ${appliedNumId}, abstractNumId ${reservedPair.abstractNumId}).`);
624
- }
625
-
626
- if (explicitPlan.numberingKey && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
627
- runtimeContext.listFallbackSharedNumIdByKey.delete(explicitPlan.numberingKey);
628
- }
629
-
630
- if (numberingAction.type === 'explicitStartNew' || numberingAction.type === 'explicitReuse') {
631
- recordSingleLineListFallbackExplicitSequence(
632
- runtimeContext?.listFallbackSequenceState || null,
633
- numberingAction.numberingKey || explicitPlan.numberingKey,
634
- appliedNumId,
635
- explicitStart
636
- );
637
- } else {
638
- clearSingleLineListFallbackExplicitSequence(
639
- runtimeContext?.listFallbackSequenceState || null,
640
- numberingAction.numberingKey || explicitPlan.numberingKey
641
- );
642
- }
643
-
644
- enforceListBindingOnParagraphNodes(replacementNodes, {
645
- numId: appliedNumId,
646
- ilvl: 0,
647
- clearParagraphPropertyChanges: true,
648
- removeListPropertyNode: true
649
- });
650
-
651
- const parent = targetParagraph.parentNode;
652
- if (!parent) return null;
653
- for (const node of replacementNodes) parent.insertBefore(xmlDoc.importNode(node, true), targetParagraph);
654
- parent.removeChild(targetParagraph);
655
- normalizeBodySectionOrder(xmlDoc);
656
- return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true, numberingXml };
657
- }
658
-
659
- async function trySingleParagraphListStructuralFallback({
660
- xmlDoc,
661
- serializer,
662
- targetParagraph,
663
- currentParagraphText,
664
- modifiedText,
665
- author,
666
- runtimeContext,
667
- generateRedlines = true,
668
- onInfo = () => { }
669
- }) {
670
- if (!targetParagraph) return null;
671
-
672
- const scopedParagraphOxml = serializer.serializeToString(targetParagraph);
673
- const fallbackPlan = buildSingleLineListStructuralFallbackPlan({
674
- oxml: scopedParagraphOxml,
675
- originalText: currentParagraphText,
676
- modifiedText,
677
- allowExistingList: false
678
- });
679
- if (!fallbackPlan) return null;
680
-
681
- onInfo('[List] No textual diff but list marker detected; forcing structural list conversion fallback.');
682
- const fallbackResult = await executeSingleLineListStructuralFallback(fallbackPlan, {
683
- author,
684
- generateRedlines,
685
- setAbstractStartOverride: false
686
- });
687
- if (!fallbackResult?.hasChanges || !fallbackResult?.oxml) {
688
- onInfo('[List] Structural list fallback produced no valid OOXML payload.');
689
- return null;
690
- }
691
-
692
- const extracted = extractReplacementNodes(fallbackResult.oxml);
693
- let replacementNodes = extracted.replacementNodes;
694
- let numberingXml = extracted.numberingXml || fallbackResult?.numberingXml || null;
695
- const hasExplicitStartAt = Number.isInteger(fallbackPlan?.startAt) && fallbackPlan.startAt > 0;
696
- const numberingKey = fallbackResult?.listStructuralFallbackKey || fallbackPlan?.numberingKey || null;
697
- const numberingAction = resolveSingleLineListFallbackNumberingAction(
698
- fallbackPlan,
699
- runtimeContext?.listFallbackSequenceState || null
700
- );
701
- if (hasExplicitStartAt) {
702
- const explicitStart = fallbackPlan.startAt;
703
- let explicitNumIdForBinding = null;
704
- const numberingState = runtimeContext?.numberingIdState || null;
705
- if (numberingAction.type === 'explicitReuse' && numberingAction.numId) {
706
- explicitNumIdForBinding = String(numberingAction.numId);
707
- numberingXml = null;
708
- onInfo(`[List] Reusing explicit-start list sequence (${numberingAction.numberingKey} -> numId ${explicitNumIdForBinding}, next ${explicitStart + 1}).`);
709
- } else if (numberingState) {
710
- const reservedPair = reserveNextNumberingIdPair(numberingState);
711
- if (!reservedPair) return null;
712
- overwriteParagraphNumIds(replacementNodes, reservedPair.numId);
713
- explicitNumIdForBinding = String(reservedPair.numId);
714
- numberingXml = buildExplicitDecimalMultilevelNumberingXml(
715
- reservedPair.numId,
716
- reservedPair.abstractNumId,
717
- explicitStart
718
- );
719
- if (numberingAction.type === 'explicitStartNew') {
720
- onInfo(`[List] Started explicit-start list sequence (${numberingAction.numberingKey} -> numId ${reservedPair.numId}).`);
721
- }
722
- onInfo(`[List] Using isolated explicit-start numbering (start ${explicitStart}, numId ${reservedPair.numId}, abstractNumId ${reservedPair.abstractNumId}).`);
723
- } else {
724
- const generatedNumId = extractFirstParagraphNumId(replacementNodes);
725
- explicitNumIdForBinding = generatedNumId ? String(generatedNumId) : null;
726
- onInfo(`[List] Using isolated list numbering with explicit start ${explicitStart}${generatedNumId ? ` (numId ${generatedNumId})` : ''}.`);
727
- }
728
-
729
- if (numberingKey && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
730
- runtimeContext.listFallbackSharedNumIdByKey.delete(numberingKey);
731
- }
732
- if (numberingAction.type === 'explicitStartNew' || numberingAction.type === 'explicitReuse') {
733
- recordSingleLineListFallbackExplicitSequence(
734
- runtimeContext?.listFallbackSequenceState || null,
735
- numberingAction.numberingKey || numberingKey,
736
- explicitNumIdForBinding,
737
- explicitStart
738
- );
739
- } else {
740
- clearSingleLineListFallbackExplicitSequence(
741
- runtimeContext?.listFallbackSequenceState || null,
742
- numberingAction.numberingKey || numberingKey
743
- );
744
- }
745
-
746
- if (explicitNumIdForBinding) {
747
- enforceListBindingOnParagraphNodes(replacementNodes, {
748
- numId: explicitNumIdForBinding,
749
- ilvl: 0,
750
- clearParagraphPropertyChanges: true,
751
- removeListPropertyNode: true
752
- });
753
- }
754
- } else {
755
- if (numberingXml && runtimeContext?.numberingIdState) {
756
- const normalizedNumbering = remapNumberingPayloadForDocument(numberingXml, replacementNodes, runtimeContext.numberingIdState);
757
- replacementNodes = normalizedNumbering.replacementNodes;
758
- numberingXml = normalizedNumbering.numberingXml;
759
- }
760
- clearSingleLineListFallbackExplicitSequence(
761
- runtimeContext?.listFallbackSequenceState || null,
762
- numberingAction.numberingKey || numberingKey
763
- );
764
- }
765
-
766
- if (!hasExplicitStartAt && runtimeContext?.listFallbackSharedNumIdByKey instanceof Map) {
767
- const sharedNumId = numberingKey ? runtimeContext.listFallbackSharedNumIdByKey.get(numberingKey) : null;
768
- if (sharedNumId) {
769
- overwriteParagraphNumIds(replacementNodes, sharedNumId);
770
- numberingXml = null;
771
- onInfo(`[List] Reusing shared list numbering (${numberingKey} -> numId ${sharedNumId}).`);
772
- } else if (numberingKey) {
773
- const generatedNumId = extractFirstParagraphNumId(replacementNodes);
774
- if (generatedNumId) {
775
- runtimeContext.listFallbackSharedNumIdByKey.set(numberingKey, generatedNumId);
776
- onInfo(`[List] Captured shared list numbering (${numberingKey} -> numId ${generatedNumId}).`);
777
- }
778
- }
779
- }
780
-
781
- const parent = targetParagraph.parentNode;
782
- if (!parent) return null;
783
- for (const node of replacementNodes) parent.insertBefore(xmlDoc.importNode(node, true), targetParagraph);
784
- parent.removeChild(targetParagraph);
785
- normalizeBodySectionOrder(xmlDoc);
786
- return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true, numberingXml };
787
- }
788
-
789
- async function applyToParagraphByExactText(documentXml, targetText, modifiedText, author, targetRef = null, targetEndRef = null, runtimeContext = null, options = {}) {
790
- const generateRedlines = options.generateRedlines !== false;
791
- const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
792
- const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
793
- const serializer = createSerializer();
794
- const xmlDoc = parseOoxmlSafe(documentXml, 'application/xml').doc;
795
- if (!xmlDoc) return { documentXml, hasChanges: false, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
796
- const revisionIdAllocator = prepareRevisionAllocator(xmlDoc, options);
797
- const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'redline', runtimeContext, { onInfo, onWarn });
798
- const targetParagraph = resolved.paragraph;
799
- preprocessRedlineTargetParagraph(targetParagraph);
800
- const currentParagraphText = getParagraphText(targetParagraph);
801
- const containingTable = findContainingWordElement(targetParagraph, 'tbl');
802
- const rawTableStructuralCandidate = !!containingTable
803
- && !targetEndRef
804
- && typeof modifiedText === 'string'
805
- && modifiedText.includes('\n')
806
- && !isMarkdownTableText(modifiedText);
807
- const rawTableStructuralDedupeKey = rawTableStructuralCandidate
808
- ? computeTableStructuralDedupeKey(xmlDoc, containingTable, modifiedText)
809
- : null;
810
- const tableStructuralDedupes = runtimeContext?.tableStructuralRedlineKeys instanceof Set
811
- ? runtimeContext.tableStructuralRedlineKeys
812
- : null;
813
- if (rawTableStructuralDedupeKey && tableStructuralDedupes?.has(rawTableStructuralDedupeKey)) {
814
- onInfo('[Table] Skipping duplicate table-structural redline for the same table/payload in this turn.');
815
- return {
816
- documentXml,
817
- hasChanges: false,
818
- numberingXml: null,
819
- warnings: ['Skipped duplicate table-structural redline in the same turn.']
820
- };
821
- }
822
- const synthesizedTableMarkdown = containingTable
823
- ? synthesizeTableMarkdownFromMultilineCellEdit(targetParagraph, modifiedText, {
824
- tableElement: containingTable,
825
- currentParagraphText,
826
- onInfo,
827
- onWarn
828
- })
829
- : null;
830
- let effectiveModifiedText = synthesizedTableMarkdown || modifiedText;
831
- const useTableScope = !!containingTable && isMarkdownTableText(effectiveModifiedText);
832
- const isTableMarkdownEdit = isMarkdownTableText(effectiveModifiedText);
833
- const explicitRangeParagraphs = targetEndRef
834
- ? resolveParagraphRangeByRefs(xmlDoc, targetRef, targetEndRef, {
835
- opType: 'redline',
836
- targetRefSnapshot: runtimeContext?.targetRefSnapshot || null,
837
- onInfo,
838
- onWarn
839
- })
840
- : (typeof targetText === 'string' && /\r?\n/.test(targetText)
841
- ? findReconstructionParagraphRange(xmlDoc, targetText)
842
- : null);
843
- const hasExplicitRangeScope = Array.isArray(explicitRangeParagraphs) && explicitRangeParagraphs.length > 0;
844
- if (!useTableScope && hasExplicitRangeScope) {
845
- const insertionEntries = buildExplicitRangeInsertionEntries(explicitRangeParagraphs, effectiveModifiedText);
846
- if (insertionEntries && insertionEntries.length > 0) {
847
- onInfo(`[List] Applying explicit-range insertion-only heuristic (${insertionEntries.length} new item(s)).`);
848
- for (const entry of insertionEntries) {
849
- onInfo(`[List] Explicit-range insertion: ilvl=${entry.ilvl}, markerType=${entry.markerType}, text="${String(entry.text || '').slice(0, 80)}${String(entry.text || '').length > 80 ? '…' : ''}"`);
850
- }
851
- const applied = applyExplicitRangeListInsertions({
852
- xmlDoc,
853
- explicitRangeParagraphs,
854
- insertionEntries,
855
- generateRedlines,
856
- author
857
- });
858
- if (applied) {
859
- return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true, numberingXml: null };
860
- }
861
- }
862
- }
863
- let inferredTableRangeParagraphs = null;
864
- if (!explicitRangeParagraphs && !useTableScope && isTableMarkdownEdit) {
865
- inferredTableRangeParagraphs = inferTableReplacementParagraphBlock(targetParagraph, {
866
- getParagraphText
867
- });
868
- if (inferredTableRangeParagraphs?.length > 1) {
869
- onInfo(`[Table] Heuristic range expansion selected ${inferredTableRangeParagraphs.length} paragraph(s) for replacement.`);
870
- }
871
- }
872
- const targetListInfo = getParagraphListInfo(targetParagraph);
873
- if (
874
- targetListInfo &&
875
- typeof effectiveModifiedText === 'string' &&
876
- !effectiveModifiedText.includes('\n')
877
- ) {
878
- const strippedListPrefix = stripRedundantLeadingListMarkers(effectiveModifiedText);
879
- if (strippedListPrefix && strippedListPrefix !== effectiveModifiedText.trim()) {
880
- onInfo('[List] Stripped redundant manual list marker prefix from single-line list item edit.');
881
- effectiveModifiedText = strippedListPrefix;
882
- }
883
- }
884
- if (useTableScope) {
885
- onInfo('[Table] Markdown table edit detected in table cell target; applying reconciliation at table scope.');
886
- }
887
-
888
- const adjacencyInsertionCandidate = (!useTableScope && !hasExplicitRangeScope && targetListInfo)
889
- ? deriveSingleParagraphListAdjacencyInsertion(currentParagraphText, effectiveModifiedText)
890
- : null;
891
- if (adjacencyInsertionCandidate) {
892
- onInfo(`[List] Applying single-paragraph list adjacency insertion heuristic (${adjacencyInsertionCandidate.position}).`);
893
- const parent = targetParagraph.parentNode;
894
- if (!parent) throw new Error('Target paragraph has no parent for adjacency list insertion');
895
-
896
- const listParagraph = buildInsertedListParagraph(
897
- xmlDoc,
898
- targetParagraph,
899
- {
900
- ilvl: targetListInfo.ilvl,
901
- numId: targetListInfo.numId,
902
- markerType: 'numbered',
903
- text: adjacencyInsertionCandidate.text
904
- },
905
- generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
906
- author,
907
- { generateRedlines }
908
- );
909
-
910
- const insertionPoint = adjacencyInsertionCandidate.position === 'before'
911
- ? targetParagraph
912
- : targetParagraph.nextSibling;
913
- parent.insertBefore(listParagraph, insertionPoint);
914
- normalizeBodySectionOrder(xmlDoc);
915
- return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true, numberingXml: null };
916
- }
917
-
918
- const plainAdjacencyInsertionCandidate = (!useTableScope && !hasExplicitRangeScope && !targetListInfo)
919
- ? deriveSingleParagraphPlainAdjacencyInsertion(currentParagraphText, effectiveModifiedText)
920
- : null;
921
- if (plainAdjacencyInsertionCandidate) {
922
- onInfo(
923
- `[Text] Applying single-paragraph plain adjacency insertion heuristic `
924
- + `(${plainAdjacencyInsertionCandidate.position}, count=${plainAdjacencyInsertionCandidate.paragraphs.length}).`
925
- );
926
- const parent = targetParagraph.parentNode;
927
- if (!parent) throw new Error('Target paragraph has no parent for plain adjacency insertion');
928
-
929
- const insertionPoint = plainAdjacencyInsertionCandidate.position === 'before'
930
- ? targetParagraph
931
- : targetParagraph.nextSibling;
932
-
933
- for (const paragraphText of plainAdjacencyInsertionCandidate.paragraphs) {
934
- const plainParagraph = await buildInsertedPlainParagraph(
935
- xmlDoc,
936
- targetParagraph,
937
- paragraphText,
938
- generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
939
- author,
940
- { generateRedlines }
941
- );
942
- parent.insertBefore(xmlDoc.importNode(plainParagraph, true), insertionPoint);
943
- }
944
-
945
- normalizeBodySectionOrder(xmlDoc);
946
- return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true, numberingXml: null };
947
- }
948
-
949
- const insertionOnlyPlan = (!useTableScope && !hasExplicitRangeScope)
950
- ? planListInsertionOnlyEdit(targetParagraph, effectiveModifiedText, {
951
- currentParagraphText,
952
- onInfo,
953
- onWarn
954
- })
955
- : null;
956
- if (insertionOnlyPlan && insertionOnlyPlan.entries.length > 0) {
957
- onInfo(`[List] Applying insertion-only list redline heuristic (${insertionOnlyPlan.entries.length} new item(s)).`);
958
- for (const entry of insertionOnlyPlan.entries) {
959
- onInfo(`[List] Insertion entry resolved: ilvl=${entry.ilvl}, markerType=${entry.markerType}, text="${String(entry.text || '').slice(0, 80)}${String(entry.text || '').length > 80 ? '…' : ''}"`);
960
- }
961
- const parent = targetParagraph.parentNode;
962
- if (!parent) throw new Error('Target paragraph has no parent for list insertion');
963
- const insertionPoint = targetParagraph.nextSibling;
964
- for (const entry of insertionOnlyPlan.entries) {
965
- const listParagraph = buildInsertedListParagraph(
966
- xmlDoc,
967
- targetParagraph,
968
- { ...entry, numId: insertionOnlyPlan.numId },
969
- generateRedlines ? createRevisionMetadata(author, xmlDoc) : null,
970
- author,
971
- { generateRedlines }
972
- );
973
- parent.insertBefore(listParagraph, insertionPoint);
974
- }
975
- normalizeBodySectionOrder(xmlDoc);
976
- return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true, numberingXml: null };
977
- }
978
-
979
- const listScopeEdit = (!useTableScope && !hasExplicitRangeScope)
980
- ? synthesizeExpandedListScopeEdit(targetParagraph, effectiveModifiedText, {
981
- currentParagraphText,
982
- onInfo,
983
- onWarn
984
- })
985
- : null;
986
- const useListScope = !!listScopeEdit && Array.isArray(listScopeEdit.paragraphs) && listScopeEdit.paragraphs.length > 0;
987
- if (useListScope) {
988
- effectiveModifiedText = listScopeEdit.modifiedText;
989
- }
990
-
991
- if (!useTableScope && !useListScope && !hasExplicitRangeScope) {
992
- const explicitHeaderListConversion = await tryExplicitDecimalHeaderListConversion({
993
- xmlDoc,
994
- serializer,
995
- targetParagraph,
996
- currentParagraphText,
997
- modifiedText: effectiveModifiedText,
998
- author,
999
- runtimeContext,
1000
- generateRedlines,
1001
- onInfo
1002
- });
1003
- if (explicitHeaderListConversion) return explicitHeaderListConversion;
1004
-
1005
- const listFallback = await trySingleParagraphListStructuralFallback({
1006
- xmlDoc,
1007
- serializer,
1008
- targetParagraph,
1009
- currentParagraphText,
1010
- modifiedText: effectiveModifiedText,
1011
- author,
1012
- runtimeContext,
1013
- generateRedlines,
1014
- onInfo
1015
- });
1016
- if (listFallback) return listFallback;
1017
- }
1018
-
1019
- const originalTextForApply = useListScope
1020
- ? listScopeEdit.originalText
1021
- : (
1022
- explicitRangeParagraphs
1023
- ? explicitRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
1024
- : (inferredTableRangeParagraphs
1025
- ? inferredTableRangeParagraphs.map(paragraph => getParagraphText(paragraph)).join('\n')
1026
- : (currentParagraphText || targetText))
1027
- );
1028
- const scopedXml = useTableScope
1029
- ? serializer.serializeToString(containingTable)
1030
- : (
1031
- useListScope
1032
- ? serializeParagraphRangeAsDocument(listScopeEdit.paragraphs, serializer)
1033
- : (
1034
- explicitRangeParagraphs
1035
- ? serializeParagraphRangeAsDocument(explicitRangeParagraphs, serializer)
1036
- : (inferredTableRangeParagraphs
1037
- ? serializeParagraphRangeAsDocument(inferredTableRangeParagraphs, serializer)
1038
- : serializer.serializeToString(targetParagraph))
1039
- )
1040
- );
1041
-
1042
- const result = isTableMarkdownEdit
1043
- ? await reconcileMarkdownTableOoxml(scopedXml, originalTextForApply, effectiveModifiedText, {
1044
- author,
1045
- generateRedlines,
1046
- existingRevisions: options.existingRevisions,
1047
- _revisionIdAllocator: revisionIdAllocator,
1048
- _isolatedTableCell: useTableScope
1049
- })
1050
- : await applyRedlineToOxml(scopedXml, originalTextForApply, effectiveModifiedText, {
1051
- author,
1052
- generateRedlines,
1053
- existingRevisions: options.existingRevisions,
1054
- _revisionIdAllocator: revisionIdAllocator,
1055
- _isolatedTableCell: useTableScope
1056
- });
1057
- if (!result?.hasChanges) {
1058
- return {
1059
- documentXml,
1060
- hasChanges: false,
1061
- numberingXml: null,
1062
- status: result?.status || 'no-op',
1063
- error: result?.error
1064
- };
1065
- }
1066
- if (result.useNativeApi && !result.oxml) {
1067
- const warning = 'Format-only fallback requires native Word API; browser demo skipped this operation.';
1068
- onWarn(`[WARN] ${warning}`);
1069
- return { documentXml, hasChanges: false, numberingXml: null, warnings: [warning] };
1070
- }
1071
- if (typeof result.oxml !== 'string') {
1072
- throw new Error('Reconciliation engine did not return OOXML for a changed redline operation');
1073
- }
1074
- const extracted = extractReplacementNodes(result.oxml);
1075
- let replacementNodes = extracted.replacementNodes;
1076
- let numberingXml = extracted.numberingXml;
1077
- if (numberingXml && runtimeContext?.numberingIdState) {
1078
- const normalizedNumbering = remapNumberingPayloadForDocument(numberingXml, replacementNodes, runtimeContext.numberingIdState);
1079
- replacementNodes = normalizedNumbering.replacementNodes;
1080
- numberingXml = normalizedNumbering.numberingXml;
1081
- }
1082
- const scopeNodes = useTableScope
1083
- ? [containingTable]
1084
- : (
1085
- useListScope
1086
- ? listScopeEdit.paragraphs
1087
- : (
1088
- explicitRangeParagraphs
1089
- ? explicitRangeParagraphs
1090
- : (inferredTableRangeParagraphs || [targetParagraph])
1091
- )
1092
- );
1093
- const anchorNode = scopeNodes[0];
1094
- const parent = anchorNode.parentNode;
1095
- for (const node of replacementNodes) parent.insertBefore(xmlDoc.importNode(node, true), anchorNode);
1096
- for (const scopeNode of scopeNodes) {
1097
- if (scopeNode && scopeNode.parentNode === parent) parent.removeChild(scopeNode);
1098
- }
1099
- normalizeBodySectionOrder(xmlDoc);
1100
- if (rawTableStructuralDedupeKey && tableStructuralDedupes && (useTableScope || containingTable)) {
1101
- tableStructuralDedupes.add(rawTableStructuralDedupeKey);
1102
- }
1103
- return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true, numberingXml, status: 'ok' };
1104
- }
1105
-
1106
- async function applyHighlightToParagraphByExactText(documentXml, targetText, textToHighlight, color, author, targetRef = null, runtimeContext = null, options = {}) {
1107
- const generateRedlines = options.generateRedlines !== false;
1108
- const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
1109
- const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
1110
- const serializer = createSerializer();
1111
- const xmlDoc = parseOoxmlSafe(documentXml, 'application/xml').doc;
1112
- if (!xmlDoc) return { documentXml, hasChanges: false, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
1113
- const revisionIdAllocator = prepareRevisionAllocator(xmlDoc, options);
1114
- const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'highlight', runtimeContext, { onInfo, onWarn });
1115
- const targetParagraph = resolved.paragraph;
1116
- const paragraphXml = serializer.serializeToString(targetParagraph);
1117
- const highlightedXml = applyHighlightToOoxml(paragraphXml, textToHighlight, color, {
1118
- generateRedlines,
1119
- author,
1120
- _revisionIdAllocator: revisionIdAllocator
1121
- });
1122
- if (!highlightedXml || highlightedXml === paragraphXml) return { documentXml, hasChanges: false };
1123
- const { replacementNodes } = extractReplacementNodes(highlightedXml);
1124
- const parent = targetParagraph.parentNode;
1125
- for (const node of replacementNodes) parent.insertBefore(xmlDoc.importNode(node, true), targetParagraph);
1126
- parent.removeChild(targetParagraph);
1127
- normalizeBodySectionOrder(xmlDoc);
1128
- return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true };
1129
- }
1130
-
1131
- async function applyCommentToParagraphByExactText(documentXml, targetText, textToComment, commentContent, author, targetRef = null, runtimeContext = null, options = {}) {
1132
- const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
1133
- const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
1134
- const serializer = createSerializer();
1135
- const xmlDoc = parseOoxmlSafe(documentXml, 'application/xml').doc;
1136
- if (!xmlDoc) return { documentXml, hasChanges: false, commentsXml: null, status: 'error', error: { code: 'PARSE_ERROR', message: 'Could not parse document OOXML.' } };
1137
- prepareRevisionAllocator(xmlDoc, options);
1138
- const resolved = resolveTargetParagraph(xmlDoc, targetText, targetRef, 'comment', runtimeContext, { onInfo, onWarn });
1139
- const targetParagraph = resolved.paragraph;
1140
- const paragraphXml = serializer.serializeToString(targetParagraph);
1141
- const commentResult = injectCommentsIntoOoxml(paragraphXml, [{ paragraphIndex: 1, textToFind: textToComment, commentContent }], { author });
1142
- if (!commentResult.commentsApplied) return { documentXml, hasChanges: false, commentsXml: null, warnings: commentResult.warnings || [] };
1143
- const { replacementNodes } = extractReplacementNodes(commentResult.oxml);
1144
- const parent = targetParagraph.parentNode;
1145
- for (const node of replacementNodes) parent.insertBefore(xmlDoc.importNode(node, true), targetParagraph);
1146
- parent.removeChild(targetParagraph);
1147
- normalizeBodySectionOrder(xmlDoc);
1148
- return { documentXml: serializer.serializeToString(xmlDoc), hasChanges: true, commentsXml: commentResult.commentsXml || null, warnings: commentResult.warnings || [] };
1149
- }
1150
-
1151
- function operationTargetPriority(op) {
1152
- if (op?.type === 'comment') return 0;
1153
- return 1;
1154
- }
1155
-
1156
- /**
1157
- * Returns a stable operation order that resolves anchor-based operations before
1158
- * text edits can mutate their target text. Comments run first; all other
1159
- * operation types retain their original relative order.
1160
- *
1161
- * @param {Object[]} operations - Structured document operations
1162
- * @returns {Object[]} A reordered copy; input objects and input array are not mutated
1163
- */
1164
- export function orderOperationsForStableTargets(operations = []) {
1165
- return (Array.isArray(operations) ? operations : [])
1166
- .map((operation, index) => ({ operation, index }))
1167
- .sort((a, b) => operationTargetPriority(a.operation) - operationTargetPriority(b.operation) || a.index - b.index)
1168
- .map(entry => entry.operation);
1169
- }
1170
-
1171
- function mergeCommentsXml(existingXml, incomingXml) {
1172
- if (!incomingXml) return existingXml || null;
1173
- if (!existingXml) return incomingXml;
1174
-
1175
- const serializer = createSerializer();
1176
- const existingDoc = parseOoxmlSafe(existingXml, 'application/xml').doc;
1177
- const incomingDoc = parseOoxmlSafe(incomingXml, 'application/xml').doc;
1178
- if (!existingDoc || !incomingDoc) return existingXml;
1179
- const existingRoot = existingDoc.documentElement;
1180
- const existingIds = new Set(
1181
- Array.from(existingRoot.getElementsByTagNameNS(NS_W, 'comment'))
1182
- .map(comment => comment.getAttribute('w:id') || comment.getAttribute('id'))
1183
- .filter(Boolean)
1184
- );
1185
-
1186
- for (const comment of Array.from(incomingDoc.getElementsByTagNameNS(NS_W, 'comment'))) {
1187
- const id = comment.getAttribute('w:id') || comment.getAttribute('id');
1188
- if (id && existingIds.has(id)) continue;
1189
- existingRoot.appendChild(existingDoc.importNode(comment, true));
1190
- if (id) existingIds.add(id);
1191
- }
1192
- return serializer.serializeToString(existingDoc);
1193
- }
1194
-
1195
- function cloneBatchRuntimeContext(runtimeContext) {
1196
- if (!runtimeContext || typeof runtimeContext !== 'object') return {};
1197
-
1198
- const context = { ...runtimeContext };
1199
- if (runtimeContext.listFallbackSharedNumIdByKey instanceof Map) {
1200
- context.listFallbackSharedNumIdByKey = new Map(runtimeContext.listFallbackSharedNumIdByKey);
1201
- }
1202
- if (runtimeContext.tableStructuralRedlineKeys instanceof Set) {
1203
- context.tableStructuralRedlineKeys = new Set(runtimeContext.tableStructuralRedlineKeys);
1204
- }
1205
- if (runtimeContext.numberingIdState && typeof runtimeContext.numberingIdState === 'object') {
1206
- context.numberingIdState = {
1207
- ...runtimeContext.numberingIdState,
1208
- usedNumIds: runtimeContext.numberingIdState.usedNumIds instanceof Set
1209
- ? new Set(runtimeContext.numberingIdState.usedNumIds)
1210
- : runtimeContext.numberingIdState.usedNumIds,
1211
- usedAbstractNumIds: runtimeContext.numberingIdState.usedAbstractNumIds instanceof Set
1212
- ? new Set(runtimeContext.numberingIdState.usedAbstractNumIds)
1213
- : runtimeContext.numberingIdState.usedAbstractNumIds
1214
- };
1215
- }
1216
- if (runtimeContext.listFallbackSequenceState && typeof runtimeContext.listFallbackSequenceState === 'object') {
1217
- context.listFallbackSequenceState = {
1218
- ...runtimeContext.listFallbackSequenceState,
1219
- explicitByNumberingKey: runtimeContext.listFallbackSequenceState.explicitByNumberingKey instanceof Map
1220
- ? new Map(runtimeContext.listFallbackSequenceState.explicitByNumberingKey)
1221
- : runtimeContext.listFallbackSequenceState.explicitByNumberingKey
1222
- };
1223
- }
1224
- return context;
1225
- }
1226
-
1227
- function commitBatchRuntimeContext(runtimeContext, context) {
1228
- if (!runtimeContext || typeof runtimeContext !== 'object') return;
1229
-
1230
- if (runtimeContext.listFallbackSharedNumIdByKey instanceof Map && context.listFallbackSharedNumIdByKey instanceof Map) {
1231
- runtimeContext.listFallbackSharedNumIdByKey.clear();
1232
- for (const entry of context.listFallbackSharedNumIdByKey) runtimeContext.listFallbackSharedNumIdByKey.set(...entry);
1233
- context.listFallbackSharedNumIdByKey = runtimeContext.listFallbackSharedNumIdByKey;
1234
- }
1235
- if (runtimeContext.tableStructuralRedlineKeys instanceof Set && context.tableStructuralRedlineKeys instanceof Set) {
1236
- runtimeContext.tableStructuralRedlineKeys.clear();
1237
- for (const value of context.tableStructuralRedlineKeys) runtimeContext.tableStructuralRedlineKeys.add(value);
1238
- context.tableStructuralRedlineKeys = runtimeContext.tableStructuralRedlineKeys;
1239
- }
1240
- if (runtimeContext.numberingIdState && context.numberingIdState) {
1241
- for (const key of ['usedNumIds', 'usedAbstractNumIds']) {
1242
- if (runtimeContext.numberingIdState[key] instanceof Set && context.numberingIdState[key] instanceof Set) {
1243
- runtimeContext.numberingIdState[key].clear();
1244
- for (const value of context.numberingIdState[key]) runtimeContext.numberingIdState[key].add(value);
1245
- context.numberingIdState[key] = runtimeContext.numberingIdState[key];
1246
- }
1247
- }
1248
- Object.assign(runtimeContext.numberingIdState, context.numberingIdState);
1249
- context.numberingIdState = runtimeContext.numberingIdState;
1250
- }
1251
- if (runtimeContext.listFallbackSequenceState && context.listFallbackSequenceState) {
1252
- const originalMap = runtimeContext.listFallbackSequenceState.explicitByNumberingKey;
1253
- const updatedMap = context.listFallbackSequenceState.explicitByNumberingKey;
1254
- if (originalMap instanceof Map && updatedMap instanceof Map) {
1255
- originalMap.clear();
1256
- for (const entry of updatedMap) originalMap.set(...entry);
1257
- context.listFallbackSequenceState.explicitByNumberingKey = originalMap;
1258
- }
1259
- Object.assign(runtimeContext.listFallbackSequenceState, context.listFallbackSequenceState);
1260
- context.listFallbackSequenceState = runtimeContext.listFallbackSequenceState;
1261
- }
1262
- Object.assign(runtimeContext, context);
1263
- }
1264
-
1265
- function normalizeOperationError(error) {
1266
- return {
1267
- code: typeof error?.code === 'string' && error.code ? error.code : 'OPERATION_ERROR',
1268
- message: error?.message || String(error)
1269
- };
1270
- }
1271
-
1272
- /**
1273
- * Applies one structured operation (`redline`, `highlight`, or `comment`) to
1274
- * a full `word/document.xml` payload.
1275
- *
1276
- * @param {string} documentXml
1277
- * @param {Object} op
1278
- * @param {string} author
1279
- * @param {Object|null} [runtimeContext=null]
1280
- * @param {{
1281
- * generateRedlines?: boolean,
1282
- * onInfo?: (message: string) => void,
1283
- * onWarn?: (message: string) => void
1284
- * }} [options={}]
1285
- * @returns {Promise<{ documentXml: string, hasChanges: boolean, numberingXml?: string|null, commentsXml?: string|null, warnings?: string[] }>}
1286
- */
1287
- export async function applyOperationToDocumentXml(documentXml, op, author, runtimeContext = null, options = {}) {
1288
- const parsed = parseOoxmlSafe(documentXml, 'application/xml');
1289
- if (parsed.error || !parsed.doc) {
1290
- return { documentXml, hasChanges: false, status: 'error', error: parsed.error, warnings: parsed.warnings };
1291
- }
1292
- const operationOptions = {
1293
- ...options,
1294
- _revisionIdAllocator: prepareRevisionAllocator(parsed.doc, options)
1295
- };
1296
- if (op?.type === 'highlight') {
1297
- return applyHighlightToParagraphByExactText(
1298
- documentXml,
1299
- op.target,
1300
- op.textToHighlight,
1301
- op.color,
1302
- author,
1303
- op.targetRef,
1304
- runtimeContext,
1305
- operationOptions
1306
- );
1307
- }
1308
- if (op?.type === 'comment') {
1309
- return applyCommentToParagraphByExactText(
1310
- documentXml,
1311
- op.target,
1312
- op.textToComment,
1313
- op.commentContent,
1314
- author,
1315
- op.targetRef,
1316
- runtimeContext,
1317
- operationOptions
1318
- );
1319
- }
1320
- return applyToParagraphByExactText(
1321
- documentXml,
1322
- op?.target,
1323
- op?.modified,
1324
- author,
1325
- op?.targetRef,
1326
- op?.targetEndRef,
1327
- runtimeContext,
1328
- operationOptions
1329
- );
1330
- }
1331
-
1332
- /**
1333
- * Applies a batch of operations using stable target ordering. This prevents a
1334
- * later comment from missing original text changed by an earlier replacement
1335
- * in the same batch.
1336
- *
1337
- * Results retain each operation's original 1-based index even though execution
1338
- * is reordered. Numbering payloads are returned as an array so package callers
1339
- * can merge each one with `ensureNumberingArtifactsInZip`.
1340
- *
1341
- * @param {string} documentXml - Full `word/document.xml` payload
1342
- * @param {Object[]} operations - Structured operations
1343
- * @param {string} author - Revision/comment author
1344
- * @param {Object|null} [runtimeContext=null] - Shared turn context
1345
- * @param {{
1346
- * atomic?: boolean,
1347
- * continueOnError?: boolean,
1348
- * generateRedlines?: boolean,
1349
- * onInfo?: (message: string) => void,
1350
- * onWarn?: (message: string) => void
1351
- * }} [options={}] - Runner options. `atomic` defaults to `true`, returning the
1352
- * original document and no package artifacts if any operation fails.
1353
- * `continueOnError` defaults to `true`, so all operations are attempted and
1354
- * represented in `results`; `false` stops after the first operation error.
1355
- * @returns {Promise<{
1356
- * documentXml: string,
1357
- * hasChanges: boolean,
1358
- * commentsXml: string|null,
1359
- * numberingXmlParts: string[],
1360
- * results: Array<{index:number,type:string,status:string,warnings?:string[],error?:Object}>,
1361
- * executionOrder: number[],
1362
- * rolledBack?: boolean
1363
- * }>}
1364
- */
1365
- export async function applyOperationsToDocumentXml(documentXml, operations, author, runtimeContext = null, options = {}) {
1366
- const parsed = parseOoxmlSafe(documentXml, 'application/xml');
1367
- if (parsed.error || !parsed.doc) {
1368
- return {
1369
- documentXml,
1370
- hasChanges: false,
1371
- commentsXml: null,
1372
- numberingXmlParts: [],
1373
- results: [],
1374
- executionOrder: [],
1375
- status: 'error',
1376
- error: parsed.error,
1377
- warnings: parsed.warnings
1378
- };
1379
- }
1380
- const sourceOperations = Array.isArray(operations) ? operations : [];
1381
- const scheduled = sourceOperations
1382
- .map((operation, index) => ({ operation, index }))
1383
- .sort((a, b) => operationTargetPriority(a.operation) - operationTargetPriority(b.operation) || a.index - b.index);
1384
-
1385
- const atomic = options.atomic !== false;
1386
- const continueOnError = options.continueOnError !== false;
1387
- const context = cloneBatchRuntimeContext(runtimeContext);
1388
- if (!(context.targetRefSnapshot instanceof Map)) {
1389
- context.targetRefSnapshot = buildTargetReferenceSnapshot(parsed.doc);
1390
- }
1391
-
1392
- let currentDocumentXml = documentXml;
1393
- let commentsXml = null;
1394
- let hasChanges = false;
1395
- const numberingXmlParts = [];
1396
- const results = [];
1397
- const executionOrder = [];
1398
- let operationFailed = false;
1399
-
1400
- for (const entry of scheduled) {
1401
- const { operation, index } = entry;
1402
- executionOrder.push(index + 1);
1403
- try {
1404
- const result = await applyOperationToDocumentXml(
1405
- currentDocumentXml,
1406
- operation,
1407
- author,
1408
- context,
1409
- options
1410
- );
1411
- currentDocumentXml = result.documentXml;
1412
- hasChanges = hasChanges || result.hasChanges === true;
1413
- commentsXml = mergeCommentsXml(commentsXml, result.commentsXml || null);
1414
- if (result.numberingXml) numberingXmlParts.push(result.numberingXml);
1415
- const isError = result.status === 'error' || !!result.error;
1416
- operationFailed = operationFailed || isError;
1417
- results.push({
1418
- index: index + 1,
1419
- type: operation?.type || 'redline',
1420
- status: isError ? 'error' : (result.hasChanges ? 'applied' : 'no_change'),
1421
- ...(Array.isArray(result.warnings) && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
1422
- ...(result.error ? { error: result.error } : {})
1423
- });
1424
- if (isError && !continueOnError) break;
1425
- } catch (error) {
1426
- const normalizedError = normalizeOperationError(error);
1427
- operationFailed = true;
1428
- results.push({
1429
- index: index + 1,
1430
- type: operation?.type || 'redline',
1431
- status: 'error',
1432
- warnings: [normalizedError.message],
1433
- error: normalizedError
1434
- });
1435
- if (!continueOnError) break;
1436
- }
1437
- }
1438
-
1439
- results.sort((a, b) => a.index - b.index);
1440
- const rolledBack = atomic && operationFailed;
1441
- if (!rolledBack) commitBatchRuntimeContext(runtimeContext, context);
1442
-
1443
- return {
1444
- documentXml: rolledBack ? documentXml : currentDocumentXml,
1445
- hasChanges: rolledBack ? false : hasChanges,
1446
- commentsXml: rolledBack ? null : commentsXml,
1447
- numberingXmlParts: rolledBack ? [] : numberingXmlParts,
1448
- results,
1449
- executionOrder,
1450
- ...(rolledBack ? {
1451
- rolledBack: true,
1452
- status: 'error',
1453
- error: {
1454
- code: 'BATCH_OPERATION_FAILED',
1455
- message: 'Atomic batch rolled back because one or more operations failed.'
1456
- }
1457
- } : {})
1458
- };
1459
- }
8
+ export { preflightOperations } from './operation-preflight.js';
9
+ export { applyOperationToDocumentXml } from './document-operation-applier.js';
10
+ export {
11
+ applyOperationsToDocumentXml,
12
+ orderOperationsForStableTargets,
13
+ buildOperationDependencyPlan
14
+ } from './batch-operation-orchestrator.js';