@ansonlai/docx-redline-js 0.8.4 → 0.10.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 (72) hide show
  1. package/ARCHITECTURE.md +679 -460
  2. package/CHANGELOG.md +208 -1
  3. package/README.md +152 -16
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/core/word-xml.js +160 -118
  9. package/dist/docx-redline-js.esm.js +4338 -796
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +111 -108
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/dist/docx-redline.bundle.cjs +4463 -921
  14. package/dist/docx-redline.bundle.cjs.map +4 -4
  15. package/dist/docx-redline.bundle.js +4463 -921
  16. package/dist/docx-redline.bundle.js.map +4 -4
  17. package/docs/AGENT_FAST_START.md +2 -0
  18. package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
  19. package/docs/SKILL_AUTHORING.md +169 -150
  20. package/docs/TESTING.md +1146 -1059
  21. package/docs/schemas/document-operations.schema.json +55 -2
  22. package/document/docx-document.js +1022 -1010
  23. package/engine/oxml-engine.js +21 -22
  24. package/engine/paragraph-mark-revisions.js +292 -0
  25. package/engine/reconstruction-mapper.js +312 -311
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +53 -2
  32. package/index.js +13 -2
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +21 -3
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/markdown-processor.js +5 -2
  42. package/pipeline/pipeline.js +206 -204
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +23 -3
  52. package/services/capture-engine.js +228 -226
  53. package/services/comment-replies.js +1 -1
  54. package/services/document-operation-applier.js +214 -0
  55. package/services/document-operation-contract.js +76 -0
  56. package/services/document-operation-mutations.js +688 -51
  57. package/services/error-recovery.js +17 -0
  58. package/services/inspection-comparison.js +82 -0
  59. package/services/list-conversion-diagnostics.js +296 -0
  60. package/services/list-format-change.js +586 -0
  61. package/services/list-format-mutation.js +416 -0
  62. package/services/list-format.js +305 -0
  63. package/services/numbering-allocation.js +642 -0
  64. package/services/numbering-helpers.js +354 -524
  65. package/services/operation-batch-compiler.js +183 -11
  66. package/services/operation-preflight.js +43 -33
  67. package/services/receipt-collector.js +288 -288
  68. package/services/revision-comment-management.js +38 -5
  69. package/services/revision-fidelity-verifier.js +484 -0
  70. package/services/standalone-docx-plumbing.js +627 -631
  71. package/services/standalone-operation-runner.d.ts +182 -5
  72. package/services/table-reconciliation.js +59 -7
@@ -67,11 +67,11 @@ function Export-View($word, [string]$docxPath, [string]$pdfPath, [string]$viewNa
67
67
  try {
68
68
  $document = $word.Documents.OpenNoRepairDialog($docxPath)
69
69
  if ($viewName -eq 'acceptAll') {
70
- $document.AcceptAllRevisions()
70
+ $document.Revisions.AcceptAll()
71
71
  $exportItem = 0 # wdExportDocumentContent
72
72
  }
73
73
  elseif ($viewName -eq 'rejectAll') {
74
- $document.RejectAllRevisions()
74
+ $document.Revisions.RejectAll()
75
75
  $exportItem = 0
76
76
  }
77
77
  else {
@@ -0,0 +1,108 @@
1
+ param(
2
+ [string]$FixturesDir = "tests/fixtures/word-revision-semantics"
3
+ )
4
+
5
+ # Run with pwsh (PowerShell 7): Windows PowerShell 5.1 hangs in SaveAs2 over COM here.
6
+ # Records what desktop Word does on Reject All / Accept All for each paragraph-mark
7
+ # revision shape in cases.json, and writes expectations.json (checked in). Run
8
+ # `node scripts/generate-word-revision-semantics-matrix.mjs` first.
9
+
10
+ $ErrorActionPreference = 'Stop'
11
+ $FixturesDir = (Resolve-Path $FixturesDir).Path
12
+ $cases = Get-Content (Join-Path $FixturesDir 'cases.json') -Raw -Encoding UTF8 | ConvertFrom-Json
13
+ $work = Join-Path ([System.IO.Path]::GetTempPath()) ("word-rev-sem-" + [guid]::NewGuid().ToString('N'))
14
+ New-Item -ItemType Directory -Path $work | Out-Null
15
+
16
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
17
+
18
+ function Convert-Text([string]$t) {
19
+ $t = $t -replace [string][char]7, ''
20
+ $t = $t -replace [string][char]11, "`n"
21
+ $t = $t -replace "`r", ''
22
+ return $t
23
+ }
24
+
25
+ function Get-XmlParagraphCount([string]$path) {
26
+ $zip = [System.IO.Compression.ZipFile]::OpenRead($path)
27
+ try {
28
+ $entry = $zip.GetEntry('word/document.xml')
29
+ $reader = New-Object System.IO.StreamReader($entry.Open())
30
+ $xml = $reader.ReadToEnd(); $reader.Close()
31
+ return ([regex]::Matches($xml, '<w:p[ >/]')).Count
32
+ } finally { $zip.Dispose() }
33
+ }
34
+
35
+ function Get-Snapshot($doc) {
36
+ $items = @()
37
+ $count = $doc.Paragraphs.Count
38
+ for ($i = 1; $i -le $count; $i++) {
39
+ $p = $doc.Paragraphs.Item($i)
40
+ $r = $p.Range
41
+ $raw = [string]$r.Text
42
+ $inTable = [bool]$r.Information(12)
43
+ # End-of-row marks surface as paragraphs with text "\r\a" and no cell; skip them.
44
+ if ($inTable -and $raw -eq ("`r" + [char]7)) {
45
+ $cellCount = 0
46
+ try { $cellCount = $r.Cells.Count } catch { $cellCount = 0 }
47
+ if ($cellCount -eq 0) { continue }
48
+ }
49
+ $items += [ordered]@{
50
+ text = (Convert-Text $raw)
51
+ style = [string]$p.Style.NameLocal
52
+ listType = [int]$r.ListFormat.ListType
53
+ listString = [string]$r.ListFormat.ListString
54
+ inTable = $inTable
55
+ }
56
+ }
57
+ return ,$items
58
+ }
59
+
60
+ function Invoke-Mode($word, [string]$src, [string]$mode, [string]$name) {
61
+ $copy = Join-Path $work "$name-$mode.docx"
62
+ Copy-Item $src $copy
63
+ $doc = $word.Documents.Open($copy, $false, $false)
64
+ try {
65
+ $doc.TrackRevisions = $false
66
+ $before = $doc.Revisions.Count
67
+ if ($mode -eq 'reject') { $doc.Revisions.RejectAll() } else { $doc.Revisions.AcceptAll() }
68
+ $snap = Get-Snapshot $doc
69
+ $out = Join-Path $work "$name-$mode-out.docx"
70
+ $doc.SaveAs2($out, 16)
71
+ } finally { $doc.Close($false) }
72
+ return @{ paragraphs = $snap; xmlParagraphs = (Get-XmlParagraphCount $out); revisionsBefore = $before }
73
+ }
74
+
75
+ $word = New-Object -ComObject Word.Application
76
+ try {
77
+ $word.Visible = $false
78
+ $word.DisplayAlerts = 0
79
+ $results = @()
80
+ foreach ($case in $cases) {
81
+ $src = Join-Path $FixturesDir ($case.name + '.docx')
82
+ $rej = Invoke-Mode $word $src 'reject' $case.name
83
+ $acc = Invoke-Mode $word $src 'accept' $case.name
84
+ Write-Output ("{0}: revisions={1} reject={2}p accept={3}p" -f $case.name, $rej.revisionsBefore, $rej.paragraphs.Count, $acc.paragraphs.Count)
85
+ $results += [ordered]@{
86
+ name = $case.name
87
+ description = $case.description
88
+ bodyXml = $case.bodyXml
89
+ revisionsBefore = $rej.revisionsBefore
90
+ reject = $rej.paragraphs
91
+ accept = $acc.paragraphs
92
+ rejectXmlParagraphs = $rej.xmlParagraphs
93
+ acceptXmlParagraphs = $acc.xmlParagraphs
94
+ }
95
+ }
96
+ $doc = [ordered]@{
97
+ wordVersion = [string]$word.Version
98
+ wordBuild = [string]$word.Build
99
+ generatedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
100
+ cases = $results
101
+ }
102
+ $json = $doc | ConvertTo-Json -Depth 8
103
+ [System.IO.File]::WriteAllText((Join-Path $FixturesDir 'expectations.json'), $json + "`n", (New-Object System.Text.UTF8Encoding($false)))
104
+ } finally {
105
+ try { $word.Quit(0) } catch {}
106
+ [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($word)
107
+ Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue
108
+ }
@@ -127,6 +127,18 @@ export function buildOperationDependencyPlan(operations = []) {
127
127
  }
128
128
  }
129
129
 
130
+ // Compiler-fixed order: a list-format composed with a redline on the same paragraph runs after that redline.
131
+ for (let i = 0; i < list.length; i++) {
132
+ const runAfter = Array.isArray(list[i]?._compiledRunAfter) ? list[i]._compiledRunAfter : [];
133
+ for (const producerIndex of runAfter) {
134
+ if (!Number.isInteger(producerIndex) || producerIndex < 0 || producerIndex >= list.length || producerIndex === i) continue;
135
+ if (dependencies[i].has(producerIndex)) continue;
136
+ dependencies[i].add(producerIndex);
137
+ dependents[producerIndex].add(i);
138
+ inDegrees[i]++;
139
+ }
140
+ }
141
+
130
142
  for (const [captureRef, consumers] of mutatingCaptureConsumers) {
131
143
  if (consumers.length < 2) continue;
132
144
  const selectors = consumers.map(consumer => consumer.select);
@@ -370,11 +382,17 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
370
382
  const atomic = options.atomic === true;
371
383
  const continueOnError = options.continueOnError !== false;
372
384
  // Generated list definitions must not collide with the supplied source numbering part.
373
- if (runtimeContext && typeof runtimeContext === 'object' && !runtimeContext.numberingIdState
374
- && typeof runtimeContext.numberingXml === 'string' && runtimeContext.numberingXml.trim()) {
375
- runtimeContext.numberingIdState = createDynamicNumberingIdState(runtimeContext.numberingXml);
385
+ // Always seed a state (bare document.xml has no numbering part) to match the packaged facade.
386
+ if (runtimeContext && typeof runtimeContext === 'object' && !runtimeContext.numberingIdState) {
387
+ const seedXml = typeof runtimeContext.numberingXml === 'string' && runtimeContext.numberingXml.trim()
388
+ ? runtimeContext.numberingXml
389
+ : undefined;
390
+ runtimeContext.numberingIdState = createDynamicNumberingIdState(seedXml);
376
391
  }
377
392
  const context = cloneBatchRuntimeContext(runtimeContext);
393
+ if (!context.numberingIdState) {
394
+ context.numberingIdState = createDynamicNumberingIdState(undefined);
395
+ }
378
396
  if (!(context.targetRefSnapshot instanceof Map)) {
379
397
  context.targetRefSnapshot = session.initialTargetReferenceSnapshot;
380
398
  }
@@ -458,10 +476,12 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
458
476
  ...(result.resolvedTarget ? { resolvedTarget: result.resolvedTarget } : {}),
459
477
  ...(result.resolvedAnchor ? { resolvedAnchor: result.resolvedAnchor } : {}),
460
478
  ...(result.change ? { change: result.change } : {}),
479
+ ...(result.list && !isError ? { list: result.list } : {}),
461
480
  ...(result.operationType === 'comment_resolve' && !isError
462
481
  ? { resolved: result.resolved, threadRootId: result.threadRootId, commentIds: result.commentIds }
463
482
  : {}),
464
483
  ...(Array.isArray(result.warnings) && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
484
+ ...(Array.isArray(result.diagnostics) && result.diagnostics.length > 0 ? { diagnostics: result.diagnostics } : {}),
465
485
  ...(result.error ? { error: result.error } : {}),
466
486
  ...(result.receipt ? { receipt: result.receipt } : {})
467
487
  });
@@ -1,226 +1,228 @@
1
- /**
2
- * Capture entity management, resolution, and validation for batch operations.
3
- */
4
-
5
- import { NS_W } from '../core/types.js';
6
- import {
7
- createParagraphFingerprint,
8
- getDocumentParagraphNodes,
9
- getParagraphId,
10
- findContainingWordElement
11
- } from '../core/paragraph-targeting.js';
12
- import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
13
-
14
- function createCaptureError(code, message) {
15
- const error = new Error(message);
16
- error.code = code;
17
- return error;
18
- }
19
-
20
- export function ensureParagraphIdsOnImportedNode(_node, _session) {
21
- // Preserves existing paragraph attributes without injecting undeclared namespaces into the document.
22
- }
23
-
24
- /**
25
- * Derives a serializable CapturedEntity record from the live DOM nodes produced by an operation.
26
- *
27
- * @param {import('./document-operation-session.js').DocumentOperationSession} session
28
- * @param {object} operation
29
- * @param {Array<Element>} liveNodes
30
- * @returns {object} CapturedEntity
31
- */
32
- export function deriveCapturedEntity(session, operation, liveNodes) {
33
- const paragraphs = [];
34
- let hasTable = false;
35
- let hasList = false;
36
-
37
- for (const node of (liveNodes || [])) {
38
- if (!node) continue;
39
- if (node.localName === 'tbl') {
40
- hasTable = true;
41
- paragraphs.push(...Array.from(node.getElementsByTagNameNS(NS_W, 'p')));
42
- } else if (node.localName === 'p') {
43
- if (node.getElementsByTagNameNS(NS_W, 'numPr').length > 0) {
44
- hasList = true;
45
- }
46
- paragraphs.push(node);
47
- }
48
- }
49
-
50
- const kind = hasTable
51
- ? 'table'
52
- : (hasList ? 'list' : (paragraphs.length > 1 ? 'range' : 'paragraph'));
53
-
54
- const generatedParagraphIds = [];
55
- const fingerprints = [];
56
- const expectedText = [];
57
- const structuralPathHints = [];
58
-
59
- const xmlDoc = session.document;
60
- const allDocParagraphs = getDocumentParagraphNodes(xmlDoc);
61
-
62
- for (const p of paragraphs) {
63
- const paraId = getParagraphId(p) || '';
64
- generatedParagraphIds.push(paraId);
65
- const text = extractCanonicalParagraphText(p);
66
- expectedText.push(text);
67
- const pIndex = allDocParagraphs.indexOf(p) + 1;
68
- fingerprints.push(createParagraphFingerprint(p, {
69
- text,
70
- paragraphId: paraId || null,
71
- index: pIndex > 0 ? pIndex : undefined,
72
- inTable: hasTable || !!findContainingWordElement(p, 'tbl')
73
- }));
74
- const parentTag = p.parentNode?.localName || 'body';
75
- structuralPathHints.push(`${parentTag}/${p.localName}`);
76
- }
77
-
78
- return {
79
- captureKey: operation.captureKey,
80
- operationIndex: typeof operation.index === 'number' ? operation.index : 0,
81
- kind,
82
- generatedParagraphIds,
83
- fingerprints,
84
- expectedText,
85
- structuralPathHints,
86
- stale: false
87
- };
88
- }
89
-
90
- /**
91
- * Invalidates any captures in the session capture table that referenced removed or replaced paragraphs.
92
- *
93
- * @param {Map<string, object>} captureTable
94
- * @param {Array<Element>} removedNodes
95
- */
96
- export function invalidateAffectedCaptures(captureTable, removedNodes) {
97
- if (!captureTable || !(captureTable instanceof Map) || !Array.isArray(removedNodes) || removedNodes.length === 0) return;
98
- const removedIds = new Set();
99
- for (const node of removedNodes) {
100
- if (!node) continue;
101
- if (node.localName === 'p') {
102
- const id = getParagraphId(node);
103
- if (id) removedIds.add(id);
104
- } else if (typeof node.getElementsByTagNameNS === 'function') {
105
- for (const p of Array.from(node.getElementsByTagNameNS(NS_W, 'p'))) {
106
- const id = getParagraphId(p);
107
- if (id) removedIds.add(id);
108
- }
109
- }
110
- }
111
- if (removedIds.size === 0) return;
112
- for (const [, captured] of captureTable) {
113
- if (captured.stale) continue;
114
- if (captured.generatedParagraphIds.some(id => id && removedIds.has(id))) {
115
- captured.stale = true;
116
- }
117
- }
118
- }
119
-
120
- /**
121
- * Resolves a target paragraph from an existing capture in the session capture table.
122
- *
123
- * @param {Document} xmlDoc
124
- * @param {import('./document-operation-session.js').DocumentOperationSession} session
125
- * @param {object} targetDescriptor
126
- * @param {string} opType
127
- * @param {object} options
128
- * @returns {{ paragraph: Element, resolvedBy: 'capture' }}
129
- */
130
- export function resolveTargetFromCapture(xmlDoc, session, targetDescriptor, _opType = 'redline', _options = {}) {
131
- const captureRef = targetDescriptor?.captureRef;
132
- if (!captureRef) {
133
- throw createCaptureError('INVALID_OPERATION', 'Operation target is missing captureRef.');
134
- }
135
-
136
- if (!session || !session.captureTable || !session.captureTable.has(captureRef)) {
137
- throw createCaptureError('CAPTURE_NOT_FOUND', `Capture "${captureRef}" was not found in the session capture table.`);
138
- }
139
-
140
- const captured = session.captureTable.get(captureRef);
141
- if (captured.stale) {
142
- throw createCaptureError('CAPTURE_STALE', `Capture "${captureRef}" is stale; the captured content was modified or removed.`);
143
- }
144
-
145
- const metadataIndex = session.getParagraphMetadataIndex();
146
- const liveParagraphs = [];
147
-
148
- for (let i = 0; i < captured.expectedText.length; i++) {
149
- const expectedParaId = captured.generatedParagraphIds[i];
150
- const expected = captured.expectedText[i];
151
- let foundNode = null;
152
-
153
- if (expectedParaId && metadataIndex.byId.has(expectedParaId)) {
154
- foundNode = metadataIndex.byId.get(expectedParaId).paragraph;
155
- }
156
-
157
- if (!foundNode) {
158
- const candidate = metadataIndex.entries.find(entry =>
159
- entry.text === expected || entry.fingerprint === captured.fingerprints[i]
160
- );
161
- if (candidate) foundNode = candidate.paragraph;
162
- }
163
-
164
- if (!foundNode || !foundNode.parentNode) {
165
- captured.stale = true;
166
- throw createCaptureError('CAPTURE_STALE', `Capture "${captureRef}" is stale; paragraph ${i + 1} was removed from the document.`);
167
- }
168
-
169
- const currentText = extractCanonicalParagraphText(foundNode);
170
- if (currentText !== expected) {
171
- captured.stale = true;
172
- throw createCaptureError('CAPTURE_STALE', `Capture "${captureRef}" is stale; paragraph ${i + 1} content drifted from "${expected}" to "${currentText}".`);
173
- }
174
-
175
- liveParagraphs.push(foundNode);
176
- }
177
-
178
- if (liveParagraphs.length === 0) {
179
- captured.stale = true;
180
- throw createCaptureError('CAPTURE_STALE', `Capture "${captureRef}" is stale; no live paragraphs found.`);
181
- }
182
-
183
- const select = targetDescriptor.select;
184
- if (select == null || select === '') {
185
- if (liveParagraphs.length === 1) {
186
- return { paragraph: liveParagraphs[0], resolvedBy: 'capture' };
187
- }
188
- throw createCaptureError(
189
- 'AMBIGUOUS_CAPTURE_SELECTION',
190
- `Capture "${captureRef}" contains ${liveParagraphs.length} paragraphs; target.select is required to disambiguate.`
191
- );
192
- }
193
-
194
- let matched = [];
195
- const trimmedSelect = String(select).trim();
196
- if (trimmedSelect === ':first' || trimmedSelect === 'first') {
197
- matched = [liveParagraphs[0]];
198
- } else if (trimmedSelect === ':last' || trimmedSelect === 'last') {
199
- matched = [liveParagraphs[liveParagraphs.length - 1]];
200
- } else if (/^\d+$/.test(trimmedSelect)) {
201
- const index1Based = parseInt(trimmedSelect, 10);
202
- if (index1Based >= 1 && index1Based <= liveParagraphs.length) {
203
- matched = [liveParagraphs[index1Based - 1]];
204
- }
205
- } else {
206
- const exactMatches = liveParagraphs.filter(p => extractCanonicalParagraphText(p).trim() === trimmedSelect);
207
- if (exactMatches.length > 0) {
208
- matched = exactMatches;
209
- } else {
210
- matched = liveParagraphs.filter(p => extractCanonicalParagraphText(p).includes(trimmedSelect));
211
- }
212
- }
213
-
214
- if (matched.length === 0) {
215
- throw createCaptureError('TARGET_NOT_FOUND', `Target selection "${select}" was not found in capture "${captureRef}".`);
216
- }
217
-
218
- if (matched.length > 1) {
219
- throw createCaptureError(
220
- 'AMBIGUOUS_CAPTURE_SELECTION',
221
- `Target selection "${select}" matched ${matched.length} paragraphs in capture "${captureRef}".`
222
- );
223
- }
224
-
225
- return { paragraph: matched[0], resolvedBy: 'capture' };
226
- }
1
+ /**
2
+ * Capture entity management, resolution, and validation for batch operations.
3
+ */
4
+
5
+ import { NS_W } from '../core/types.js';
6
+ import { getCurrentParagraphProperties } from '../core/current-properties.js';
7
+ import {
8
+ createParagraphFingerprint,
9
+ getDocumentParagraphNodes,
10
+ getParagraphId,
11
+ findContainingWordElement
12
+ } from '../core/paragraph-targeting.js';
13
+ import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
14
+
15
+ function createCaptureError(code, message) {
16
+ const error = new Error(message);
17
+ error.code = code;
18
+ return error;
19
+ }
20
+
21
+ export function ensureParagraphIdsOnImportedNode(_node, _session) {
22
+ // Preserves existing paragraph attributes without injecting undeclared namespaces into the document.
23
+ }
24
+
25
+ /**
26
+ * Derives a serializable CapturedEntity record from the live DOM nodes produced by an operation.
27
+ *
28
+ * @param {import('./document-operation-session.js').DocumentOperationSession} session
29
+ * @param {object} operation
30
+ * @param {Array<Element>} liveNodes
31
+ * @returns {object} CapturedEntity
32
+ */
33
+ export function deriveCapturedEntity(session, operation, liveNodes) {
34
+ const paragraphs = [];
35
+ let hasTable = false;
36
+ let hasList = false;
37
+
38
+ for (const node of (liveNodes || [])) {
39
+ if (!node) continue;
40
+ if (node.localName === 'tbl') {
41
+ hasTable = true;
42
+ paragraphs.push(...Array.from(node.getElementsByTagNameNS(NS_W, 'p')));
43
+ } else if (node.localName === 'p') {
44
+ // Current numPr only; a pPrChange snapshot does not make the captured paragraph a list.
45
+ if (getCurrentParagraphProperties(node).numPr) {
46
+ hasList = true;
47
+ }
48
+ paragraphs.push(node);
49
+ }
50
+ }
51
+
52
+ const kind = hasTable
53
+ ? 'table'
54
+ : (hasList ? 'list' : (paragraphs.length > 1 ? 'range' : 'paragraph'));
55
+
56
+ const generatedParagraphIds = [];
57
+ const fingerprints = [];
58
+ const expectedText = [];
59
+ const structuralPathHints = [];
60
+
61
+ const xmlDoc = session.document;
62
+ const allDocParagraphs = getDocumentParagraphNodes(xmlDoc);
63
+
64
+ for (const p of paragraphs) {
65
+ const paraId = getParagraphId(p) || '';
66
+ generatedParagraphIds.push(paraId);
67
+ const text = extractCanonicalParagraphText(p);
68
+ expectedText.push(text);
69
+ const pIndex = allDocParagraphs.indexOf(p) + 1;
70
+ fingerprints.push(createParagraphFingerprint(p, {
71
+ text,
72
+ paragraphId: paraId || null,
73
+ index: pIndex > 0 ? pIndex : undefined,
74
+ inTable: hasTable || !!findContainingWordElement(p, 'tbl')
75
+ }));
76
+ const parentTag = p.parentNode?.localName || 'body';
77
+ structuralPathHints.push(`${parentTag}/${p.localName}`);
78
+ }
79
+
80
+ return {
81
+ captureKey: operation.captureKey,
82
+ operationIndex: typeof operation.index === 'number' ? operation.index : 0,
83
+ kind,
84
+ generatedParagraphIds,
85
+ fingerprints,
86
+ expectedText,
87
+ structuralPathHints,
88
+ stale: false
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Invalidates any captures in the session capture table that referenced removed or replaced paragraphs.
94
+ *
95
+ * @param {Map<string, object>} captureTable
96
+ * @param {Array<Element>} removedNodes
97
+ */
98
+ export function invalidateAffectedCaptures(captureTable, removedNodes) {
99
+ if (!captureTable || !(captureTable instanceof Map) || !Array.isArray(removedNodes) || removedNodes.length === 0) return;
100
+ const removedIds = new Set();
101
+ for (const node of removedNodes) {
102
+ if (!node) continue;
103
+ if (node.localName === 'p') {
104
+ const id = getParagraphId(node);
105
+ if (id) removedIds.add(id);
106
+ } else if (typeof node.getElementsByTagNameNS === 'function') {
107
+ for (const p of Array.from(node.getElementsByTagNameNS(NS_W, 'p'))) {
108
+ const id = getParagraphId(p);
109
+ if (id) removedIds.add(id);
110
+ }
111
+ }
112
+ }
113
+ if (removedIds.size === 0) return;
114
+ for (const [, captured] of captureTable) {
115
+ if (captured.stale) continue;
116
+ if (captured.generatedParagraphIds.some(id => id && removedIds.has(id))) {
117
+ captured.stale = true;
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Resolves a target paragraph from an existing capture in the session capture table.
124
+ *
125
+ * @param {Document} xmlDoc
126
+ * @param {import('./document-operation-session.js').DocumentOperationSession} session
127
+ * @param {object} targetDescriptor
128
+ * @param {string} opType
129
+ * @param {object} options
130
+ * @returns {{ paragraph: Element, resolvedBy: 'capture' }}
131
+ */
132
+ export function resolveTargetFromCapture(xmlDoc, session, targetDescriptor, _opType = 'redline', _options = {}) {
133
+ const captureRef = targetDescriptor?.captureRef;
134
+ if (!captureRef) {
135
+ throw createCaptureError('INVALID_OPERATION', 'Operation target is missing captureRef.');
136
+ }
137
+
138
+ if (!session || !session.captureTable || !session.captureTable.has(captureRef)) {
139
+ throw createCaptureError('CAPTURE_NOT_FOUND', `Capture "${captureRef}" was not found in the session capture table.`);
140
+ }
141
+
142
+ const captured = session.captureTable.get(captureRef);
143
+ if (captured.stale) {
144
+ throw createCaptureError('CAPTURE_STALE', `Capture "${captureRef}" is stale; the captured content was modified or removed.`);
145
+ }
146
+
147
+ const metadataIndex = session.getParagraphMetadataIndex();
148
+ const liveParagraphs = [];
149
+
150
+ for (let i = 0; i < captured.expectedText.length; i++) {
151
+ const expectedParaId = captured.generatedParagraphIds[i];
152
+ const expected = captured.expectedText[i];
153
+ let foundNode = null;
154
+
155
+ if (expectedParaId && metadataIndex.byId.has(expectedParaId)) {
156
+ foundNode = metadataIndex.byId.get(expectedParaId).paragraph;
157
+ }
158
+
159
+ if (!foundNode) {
160
+ const candidate = metadataIndex.entries.find(entry =>
161
+ entry.text === expected || entry.fingerprint === captured.fingerprints[i]
162
+ );
163
+ if (candidate) foundNode = candidate.paragraph;
164
+ }
165
+
166
+ if (!foundNode || !foundNode.parentNode) {
167
+ captured.stale = true;
168
+ throw createCaptureError('CAPTURE_STALE', `Capture "${captureRef}" is stale; paragraph ${i + 1} was removed from the document.`);
169
+ }
170
+
171
+ const currentText = extractCanonicalParagraphText(foundNode);
172
+ if (currentText !== expected) {
173
+ captured.stale = true;
174
+ throw createCaptureError('CAPTURE_STALE', `Capture "${captureRef}" is stale; paragraph ${i + 1} content drifted from "${expected}" to "${currentText}".`);
175
+ }
176
+
177
+ liveParagraphs.push(foundNode);
178
+ }
179
+
180
+ if (liveParagraphs.length === 0) {
181
+ captured.stale = true;
182
+ throw createCaptureError('CAPTURE_STALE', `Capture "${captureRef}" is stale; no live paragraphs found.`);
183
+ }
184
+
185
+ const select = targetDescriptor.select;
186
+ if (select == null || select === '') {
187
+ if (liveParagraphs.length === 1) {
188
+ return { paragraph: liveParagraphs[0], resolvedBy: 'capture' };
189
+ }
190
+ throw createCaptureError(
191
+ 'AMBIGUOUS_CAPTURE_SELECTION',
192
+ `Capture "${captureRef}" contains ${liveParagraphs.length} paragraphs; target.select is required to disambiguate.`
193
+ );
194
+ }
195
+
196
+ let matched = [];
197
+ const trimmedSelect = String(select).trim();
198
+ if (trimmedSelect === ':first' || trimmedSelect === 'first') {
199
+ matched = [liveParagraphs[0]];
200
+ } else if (trimmedSelect === ':last' || trimmedSelect === 'last') {
201
+ matched = [liveParagraphs[liveParagraphs.length - 1]];
202
+ } else if (/^\d+$/.test(trimmedSelect)) {
203
+ const index1Based = parseInt(trimmedSelect, 10);
204
+ if (index1Based >= 1 && index1Based <= liveParagraphs.length) {
205
+ matched = [liveParagraphs[index1Based - 1]];
206
+ }
207
+ } else {
208
+ const exactMatches = liveParagraphs.filter(p => extractCanonicalParagraphText(p).trim() === trimmedSelect);
209
+ if (exactMatches.length > 0) {
210
+ matched = exactMatches;
211
+ } else {
212
+ matched = liveParagraphs.filter(p => extractCanonicalParagraphText(p).includes(trimmedSelect));
213
+ }
214
+ }
215
+
216
+ if (matched.length === 0) {
217
+ throw createCaptureError('TARGET_NOT_FOUND', `Target selection "${select}" was not found in capture "${captureRef}".`);
218
+ }
219
+
220
+ if (matched.length > 1) {
221
+ throw createCaptureError(
222
+ 'AMBIGUOUS_CAPTURE_SELECTION',
223
+ `Target selection "${select}" matched ${matched.length} paragraphs in capture "${captureRef}".`
224
+ );
225
+ }
226
+
227
+ return { paragraph: matched[0], resolvedBy: 'capture' };
228
+ }
@@ -32,7 +32,7 @@ export function applyCommentReplyToParts({ commentsXml, commentsExtendedXml = nu
32
32
  }
33
33
  const occupied = usedParaIds(commentsDoc, extendedDoc);
34
34
  // paraIds are unique across the whole package; body paragraphs can carry them too.
35
- if (documentXml) for (const match of String(documentXml).matchAll(/w14:paraId="([0-9A-Fa-f]{8})"/g)) occupied.add(match[1].toUpperCase());
35
+ if (documentXml) for (const match of String(documentXml).matchAll(/\bw14:paraId="([0-9A-Fa-f]{8})"/g)) occupied.add(match[1].toUpperCase());
36
36
  const parentParagraph = commentThreadParagraph(parent);
37
37
  if (!parentParagraph) return { status: 'error', error: { code: 'PARENT_COMMENT_INVALID', message: `Parent comment '${parentCommentId}' has no paragraph.` } };
38
38
  let parentParaId = commentParaId(parent);