@ansonlai/docx-redline-js 0.8.3 → 0.9.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 +221 -12
- package/CHANGELOG.md +803 -586
- package/README.md +1018 -901
- 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/dist/docx-redline-js.esm.js +4551 -967
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +112 -106
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +4690 -1106
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +4690 -1106
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_FAST_START.md +2 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +23 -4
- package/docs/SKILL_AUTHORING.md +165 -150
- package/docs/TESTING.md +1142 -1059
- package/docs/schemas/document-operations.schema.json +221 -168
- package/document/docx-document.js +1022 -1010
- package/engine/oxml-engine.js +567 -560
- package/engine/paragraph-mark-revisions.js +292 -0
- package/engine/reconstruction-mapper.js +312 -311
- package/engine/reconstruction-mode.js +183 -183
- 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 +51 -1
- package/index.js +47 -3
- package/node/cli-help.js +1 -0
- package/node/cli.js +992 -974
- 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/pipeline.js +206 -204
- package/pipeline/serialization.js +111 -94
- 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 +26 -0
- package/services/capture-engine.js +228 -226
- package/services/document-operation-applier.js +215 -0
- package/services/document-operation-contract.js +628 -537
- package/services/document-operation-mutations.js +789 -46
- package/services/error-recovery.js +17 -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 +612 -436
- package/services/operation-heuristics.js +197 -173
- package/services/operation-preflight.js +1 -0
- package/services/receipt-collector.js +288 -288
- package/services/revision-comment-management.js +634 -594
- package/services/revision-fidelity-verifier.js +498 -0
- package/services/standalone-docx-plumbing.js +627 -631
- package/services/standalone-operation-runner.d.ts +606 -427
- package/services/table-reconciliation.js +103 -49
|
@@ -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
|
+
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
reconcileReceiptsAgainstOutput
|
|
25
25
|
} from './receipt-collector.js';
|
|
26
26
|
import { compileOperationBatch } from './operation-batch-compiler.js';
|
|
27
|
+
import { createDynamicNumberingIdState } from './numbering-helpers.js';
|
|
27
28
|
import { createRetryPlan } from './error-recovery.js';
|
|
28
29
|
|
|
29
30
|
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
@@ -126,6 +127,18 @@ export function buildOperationDependencyPlan(operations = []) {
|
|
|
126
127
|
}
|
|
127
128
|
}
|
|
128
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
|
+
|
|
129
142
|
for (const [captureRef, consumers] of mutatingCaptureConsumers) {
|
|
130
143
|
if (consumers.length < 2) continue;
|
|
131
144
|
const selectors = consumers.map(consumer => consumer.select);
|
|
@@ -368,7 +381,18 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
|
|
|
368
381
|
|
|
369
382
|
const atomic = options.atomic === true;
|
|
370
383
|
const continueOnError = options.continueOnError !== false;
|
|
384
|
+
// Generated list definitions must not collide with the supplied source numbering part.
|
|
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);
|
|
391
|
+
}
|
|
371
392
|
const context = cloneBatchRuntimeContext(runtimeContext);
|
|
393
|
+
if (!context.numberingIdState) {
|
|
394
|
+
context.numberingIdState = createDynamicNumberingIdState(undefined);
|
|
395
|
+
}
|
|
372
396
|
if (!(context.targetRefSnapshot instanceof Map)) {
|
|
373
397
|
context.targetRefSnapshot = session.initialTargetReferenceSnapshot;
|
|
374
398
|
}
|
|
@@ -452,10 +476,12 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
|
|
|
452
476
|
...(result.resolvedTarget ? { resolvedTarget: result.resolvedTarget } : {}),
|
|
453
477
|
...(result.resolvedAnchor ? { resolvedAnchor: result.resolvedAnchor } : {}),
|
|
454
478
|
...(result.change ? { change: result.change } : {}),
|
|
479
|
+
...(result.list && !isError ? { list: result.list } : {}),
|
|
455
480
|
...(result.operationType === 'comment_resolve' && !isError
|
|
456
481
|
? { resolved: result.resolved, threadRootId: result.threadRootId, commentIds: result.commentIds }
|
|
457
482
|
: {}),
|
|
458
483
|
...(Array.isArray(result.warnings) && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
|
|
484
|
+
...(Array.isArray(result.diagnostics) && result.diagnostics.length > 0 ? { diagnostics: result.diagnostics } : {}),
|
|
459
485
|
...(result.error ? { error: result.error } : {}),
|
|
460
486
|
...(result.receipt ? { receipt: result.receipt } : {})
|
|
461
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
|
+
}
|