@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.
- package/ARCHITECTURE.md +679 -460
- package/CHANGELOG.md +208 -1
- package/README.md +152 -16
- package/core/current-properties.js +136 -0
- package/core/paragraph-text.js +211 -209
- package/core/types.js +9 -3
- package/core/word-revision-semantics.js +256 -0
- package/core/word-xml.js +160 -118
- package/dist/docx-redline-js.esm.js +4338 -796
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +111 -108
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +4463 -921
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +4463 -921
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_FAST_START.md +2 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
- package/docs/SKILL_AUTHORING.md +169 -150
- package/docs/TESTING.md +1146 -1059
- package/docs/schemas/document-operations.schema.json +55 -2
- package/document/docx-document.js +1022 -1010
- package/engine/oxml-engine.js +21 -22
- package/engine/paragraph-mark-revisions.js +292 -0
- package/engine/reconstruction-mapper.js +312 -311
- package/engine/reconstruction-writer.js +67 -10
- package/engine/rpr-helpers.js +42 -0
- package/engine/run-builders.js +0 -73
- package/engine/surgical-diff-application.js +604 -605
- package/engine/table-mode.js +63 -63
- package/index.d.ts +53 -2
- package/index.js +13 -2
- package/node/cli-help.js +1 -0
- package/node/cli.js +21 -3
- package/orchestration/list-structural-fallback.js +67 -9
- package/package.json +1 -1
- package/pipeline/diff-engine.js +130 -1
- package/pipeline/ingestion-export.js +227 -243
- package/pipeline/ingestion-paragraph.js +93 -98
- package/pipeline/list-generation.js +415 -419
- package/pipeline/markdown-processor.js +5 -2
- package/pipeline/pipeline.js +206 -204
- package/scripts/export-validation-fixtures.mjs +19 -6
- package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
- package/scripts/lib/word-coverage-catalogue.mjs +7 -2
- package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
- package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
- package/scripts/word-com-differential.ps1 +443 -335
- package/scripts/word-com-visual-suite.ps1 +2 -2
- package/scripts/word-revision-semantics-matrix.ps1 +108 -0
- package/services/batch-operation-orchestrator.js +23 -3
- package/services/capture-engine.js +228 -226
- package/services/comment-replies.js +1 -1
- package/services/document-operation-applier.js +214 -0
- package/services/document-operation-contract.js +76 -0
- package/services/document-operation-mutations.js +688 -51
- package/services/error-recovery.js +17 -0
- package/services/inspection-comparison.js +82 -0
- package/services/list-conversion-diagnostics.js +296 -0
- package/services/list-format-change.js +586 -0
- package/services/list-format-mutation.js +416 -0
- package/services/list-format.js +305 -0
- package/services/numbering-allocation.js +642 -0
- package/services/numbering-helpers.js +354 -524
- package/services/operation-batch-compiler.js +183 -11
- package/services/operation-preflight.js +43 -33
- package/services/receipt-collector.js +288 -288
- package/services/revision-comment-management.js +38 -5
- package/services/revision-fidelity-verifier.js +484 -0
- package/services/standalone-docx-plumbing.js +627 -631
- package/services/standalone-operation-runner.d.ts +182 -5
- 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.
|
|
70
|
+
$document.Revisions.AcceptAll()
|
|
71
71
|
$exportItem = 0 # wdExportDocumentContent
|
|
72
72
|
}
|
|
73
73
|
elseif ($viewName -eq 'rejectAll') {
|
|
74
|
-
$document.
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
runtimeContext.
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
error
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* @param {
|
|
29
|
-
* @param {
|
|
30
|
-
* @
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
let
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (node
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* @param {
|
|
126
|
-
* @param {
|
|
127
|
-
* @param {object}
|
|
128
|
-
* @
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
matched = [liveParagraphs[
|
|
200
|
-
} else if (
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
matched =
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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(
|
|
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);
|