@ansonlai/docx-redline-js 0.5.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.
package/package.json CHANGED
@@ -1,123 +1,123 @@
1
- {
2
- "name": "@ansonlai/docx-redline-js",
3
- "version": "0.5.0",
4
- "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
- "license": "MIT",
6
- "type": "module",
7
- "main": "./index.js",
8
- "module": "./index.js",
9
- "types": "./index.d.ts",
10
- "bin": {
11
- "docx-redline": "./bin/docx-redline.js"
12
- },
13
- "exports": {
14
- ".": {
15
- "types": "./index.d.ts",
16
- "import": "./index.js",
17
- "default": "./index.js"
18
- },
19
- "./standalone-runner": {
20
- "types": "./services/standalone-operation-runner.d.ts",
21
- "import": "./services/standalone-operation-runner.js",
22
- "default": "./services/standalone-operation-runner.js"
23
- },
24
- "./node": {
25
- "types": "./node/index.d.ts",
26
- "import": "./node/index.js",
27
- "default": "./node/index.js"
28
- },
29
- "./redline-operation-converter": "./orchestration/redline-operation-converter.js",
30
- "./adapters/*": "./adapters/*",
31
- "./core/*": "./core/*",
32
- "./engine/*": "./engine/*",
33
- "./pipeline/*": "./pipeline/*",
34
- "./services/*": "./services/*",
35
- "./orchestration/*": "./orchestration/*"
36
- },
37
- "files": [
38
- "adapters/",
39
- "core/",
40
- "engine/",
41
- "pipeline/",
42
- "services/",
43
- "node/",
44
- "bin/",
45
- "orchestration/",
46
- "scripts/",
47
- "docs/",
48
- "index.js",
49
- "index.d.ts",
50
- "dist/",
51
- "ARCHITECTURE.md",
52
- "AGENTS.md",
53
- "CHANGELOG.md",
54
- "README.md",
55
- "LICENSE"
56
- ],
57
- "dependencies": {
58
- "diff-match-patch": "^1.0.5"
59
- },
60
- "peerDependencies": {
61
- "@xmldom/xmldom": ">=0.8.0"
62
- },
63
- "peerDependenciesMeta": {
64
- "@xmldom/xmldom": {
65
- "optional": true
66
- }
67
- },
68
- "devDependencies": {
69
- "@xmldom/xmldom": "^0.9.0",
70
- "c8": "^12.0.0",
71
- "docx-preview": "^0.4.0",
72
- "esbuild": "^0.28.1",
73
- "eslint": "^10.9.1",
74
- "jszip": "^3.10.1",
75
- "typescript": "^7.0.2"
76
- },
77
- "scripts": {
78
- "build": "node scripts/build.mjs",
79
- "check:types": "tsc -p tsconfig.types.json && node scripts/check-types.mjs",
80
- "lint": "eslint index.js adapters core engine pipeline services orchestration scripts tests",
81
- "test:coverage": "c8 --reporter=text --reporter=json --reporter=json-summary --reports-dir=coverage node scripts/run-tests.mjs",
82
- "coverage:gaps": "node scripts/report-coverage-gaps.mjs",
83
- "benchmark:session": "node scripts/benchmark-operation-session.mjs",
84
- "benchmark:targeting": "node scripts/benchmark-targeting-hot-paths.mjs",
85
- "benchmark:tests": "node scripts/benchmark-test-runner.mjs",
86
- "profile:routes": "node scripts/profile-route-selection.mjs",
87
- "smoke:word": "powershell -File scripts/word-com-smoke.ps1",
88
- "smoke:word:diff": "powershell -File scripts/word-com-differential.ps1",
89
- "test:word": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-suite.ps1",
90
- "test:word:visual": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-visual-suite.ps1",
91
- "test:corpus:word": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-corpus-suite.ps1",
92
- "test:corpus:word:visual": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-corpus-visual-suite.ps1",
93
- "test:visual:inspect": "node scripts/inspect-visual-evidence.mjs",
94
- "test:visual:sample": "node scripts/sample-multimodal-visual-check.mjs",
95
- "fixtures:reredline": "node scripts/export-reredline-stress-fixtures.mjs",
96
- "corpus:fetch:superdoc": "node scripts/fetch-superdoc-corpus.mjs",
97
- "report:word:coverage": "node scripts/report-word-coverage.mjs",
98
- "report:test:dashboard": "node scripts/build-test-dashboard.mjs",
99
- "review:word:prepare": "node scripts/prepare-word-review.mjs",
100
- "docx-redline": "node bin/docx-redline.js",
101
- "test": "node scripts/run-tests.mjs",
102
- "test:isolation": "node tests/no_word_api_index_check.mjs && node tests/core_dependency_graph_check.mjs",
103
- "prepublishOnly": "npm run test:isolation && npm run build"
104
- },
105
- "keywords": [
106
- "docx",
107
- "ooxml",
108
- "reconciliation",
109
- "track-changes",
110
- "redlines",
111
- "word",
112
- "office",
113
- "document",
114
- "xml"
115
- ],
116
- "repository": {
117
- "type": "git",
118
- "url": "https://github.com/AnsonLai/docx-redline-js.git"
119
- },
120
- "engines": {
121
- "node": ">=20.0.0"
122
- }
123
- }
1
+ {
2
+ "name": "@ansonlai/docx-redline-js",
3
+ "version": "0.5.1",
4
+ "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./index.js",
8
+ "module": "./index.js",
9
+ "types": "./index.d.ts",
10
+ "bin": {
11
+ "docx-redline": "./bin/docx-redline.js"
12
+ },
13
+ "exports": {
14
+ ".": {
15
+ "types": "./index.d.ts",
16
+ "import": "./index.js",
17
+ "default": "./index.js"
18
+ },
19
+ "./standalone-runner": {
20
+ "types": "./services/standalone-operation-runner.d.ts",
21
+ "import": "./services/standalone-operation-runner.js",
22
+ "default": "./services/standalone-operation-runner.js"
23
+ },
24
+ "./node": {
25
+ "types": "./node/index.d.ts",
26
+ "import": "./node/index.js",
27
+ "default": "./node/index.js"
28
+ },
29
+ "./redline-operation-converter": "./orchestration/redline-operation-converter.js",
30
+ "./adapters/*": "./adapters/*",
31
+ "./core/*": "./core/*",
32
+ "./engine/*": "./engine/*",
33
+ "./pipeline/*": "./pipeline/*",
34
+ "./services/*": "./services/*",
35
+ "./orchestration/*": "./orchestration/*"
36
+ },
37
+ "files": [
38
+ "adapters/",
39
+ "core/",
40
+ "engine/",
41
+ "pipeline/",
42
+ "services/",
43
+ "node/",
44
+ "bin/",
45
+ "orchestration/",
46
+ "scripts/",
47
+ "docs/",
48
+ "index.js",
49
+ "index.d.ts",
50
+ "dist/",
51
+ "ARCHITECTURE.md",
52
+ "AGENTS.md",
53
+ "CHANGELOG.md",
54
+ "README.md",
55
+ "LICENSE"
56
+ ],
57
+ "dependencies": {
58
+ "diff-match-patch": "^1.0.5"
59
+ },
60
+ "peerDependencies": {
61
+ "@xmldom/xmldom": ">=0.8.0"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "@xmldom/xmldom": {
65
+ "optional": true
66
+ }
67
+ },
68
+ "devDependencies": {
69
+ "@xmldom/xmldom": "^0.9.0",
70
+ "c8": "^12.0.0",
71
+ "docx-preview": "^0.4.0",
72
+ "esbuild": "^0.28.1",
73
+ "eslint": "^10.9.1",
74
+ "jszip": "^3.10.1",
75
+ "typescript": "^7.0.2"
76
+ },
77
+ "scripts": {
78
+ "build": "node scripts/build.mjs",
79
+ "check:types": "tsc -p tsconfig.types.json && node scripts/check-types.mjs",
80
+ "lint": "eslint index.js adapters core engine pipeline services orchestration scripts tests",
81
+ "test:coverage": "c8 --reporter=text --reporter=json --reporter=json-summary --reports-dir=coverage node scripts/run-tests.mjs",
82
+ "coverage:gaps": "node scripts/report-coverage-gaps.mjs",
83
+ "benchmark:session": "node scripts/benchmark-operation-session.mjs",
84
+ "benchmark:targeting": "node scripts/benchmark-targeting-hot-paths.mjs",
85
+ "benchmark:tests": "node scripts/benchmark-test-runner.mjs",
86
+ "profile:routes": "node scripts/profile-route-selection.mjs",
87
+ "smoke:word": "powershell -File scripts/word-com-smoke.ps1",
88
+ "smoke:word:diff": "powershell -File scripts/word-com-differential.ps1",
89
+ "test:word": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-suite.ps1",
90
+ "test:word:visual": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-visual-suite.ps1",
91
+ "test:corpus:word": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-corpus-suite.ps1",
92
+ "test:corpus:word:visual": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/word-com-corpus-visual-suite.ps1",
93
+ "test:visual:inspect": "node scripts/inspect-visual-evidence.mjs",
94
+ "test:visual:sample": "node scripts/sample-multimodal-visual-check.mjs",
95
+ "fixtures:reredline": "node scripts/export-reredline-stress-fixtures.mjs",
96
+ "corpus:fetch:superdoc": "node scripts/fetch-superdoc-corpus.mjs",
97
+ "report:word:coverage": "node scripts/report-word-coverage.mjs",
98
+ "report:test:dashboard": "node scripts/build-test-dashboard.mjs",
99
+ "review:word:prepare": "node scripts/prepare-word-review.mjs",
100
+ "docx-redline": "node bin/docx-redline.js",
101
+ "test": "node scripts/run-tests.mjs",
102
+ "test:isolation": "node tests/no_word_api_index_check.mjs && node tests/core_dependency_graph_check.mjs",
103
+ "prepublishOnly": "npm run test:isolation && npm run build"
104
+ },
105
+ "keywords": [
106
+ "docx",
107
+ "ooxml",
108
+ "reconciliation",
109
+ "track-changes",
110
+ "redlines",
111
+ "word",
112
+ "office",
113
+ "document",
114
+ "xml"
115
+ ],
116
+ "repository": {
117
+ "type": "git",
118
+ "url": "https://github.com/AnsonLai/docx-redline-js.git"
119
+ },
120
+ "engines": {
121
+ "node": ">=20.0.0"
122
+ }
123
+ }
@@ -0,0 +1,256 @@
1
+ $ErrorActionPreference = 'Stop'
2
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
3
+
4
+ $repoRoot = Split-Path -Parent $PSScriptRoot
5
+ $fixturesDir = Join-Path $repoRoot "tests\fixtures\cross-author-slicing"
6
+ if (-not (Test-Path $fixturesDir)) {
7
+ New-Item -ItemType Directory -Path $fixturesDir -Force | Out-Null
8
+ }
9
+
10
+ function Extract-DocumentXml($docxPath, $xmlPath) {
11
+ $zip = [System.IO.Compression.ZipFile]::OpenRead($docxPath)
12
+ try {
13
+ $entry = $zip.GetEntry("word/document.xml")
14
+ if ($entry) {
15
+ $reader = New-Object System.IO.StreamReader($entry.Open(), [System.Text.Encoding]::UTF8)
16
+ try {
17
+ $xml = $reader.ReadToEnd()
18
+ [System.IO.File]::WriteAllText($xmlPath, $xml, [System.Text.Encoding]::UTF8)
19
+ } finally {
20
+ $reader.Dispose()
21
+ }
22
+ }
23
+ } finally {
24
+ $zip.Dispose()
25
+ }
26
+ }
27
+
28
+ function Save-Triple($doc, [string]$baseName) {
29
+ $pendingDocx = [string](Join-Path $fixturesDir "$baseName-pending.docx")
30
+ $acceptedDocx = [string](Join-Path $fixturesDir "$baseName-accepted.docx")
31
+ $rejectedDocx = [string](Join-Path $fixturesDir "$baseName-rejected.docx")
32
+
33
+ [object]$pRef = $pendingDocx
34
+ [object]$aRef = $acceptedDocx
35
+ [object]$rRef = $rejectedDocx
36
+ [object]$fmt = 16
37
+
38
+ # Save pending
39
+ $doc.SaveAs2([ref]$pRef, [ref]$fmt)
40
+
41
+ # Accept All
42
+ $doc.Revisions.AcceptAll()
43
+ $doc.SaveAs2([ref]$aRef, [ref]$fmt)
44
+ $doc.Close($false)
45
+
46
+ # Reopen pending and Reject All
47
+ $reopened = $global:word.Documents.Open([ref]$pRef)
48
+ $reopened.Revisions.RejectAll()
49
+ $reopened.SaveAs2([ref]$rRef, [ref]$fmt)
50
+ $reopened.Close($false)
51
+
52
+ # Extract XML
53
+ Extract-DocumentXml $pendingDocx (Join-Path $fixturesDir "$baseName-pending.xml")
54
+ Extract-DocumentXml $acceptedDocx (Join-Path $fixturesDir "$baseName-accepted.xml")
55
+ Extract-DocumentXml $rejectedDocx (Join-Path $fixturesDir "$baseName-rejected.xml")
56
+ Write-Host "Saved: $baseName"
57
+ }
58
+
59
+ function Find-RequiredText($doc, [string]$text, [string]$scenario) {
60
+ $foundPos = $doc.Content.Text.IndexOf($text)
61
+ if ($foundPos -lt 0) {
62
+ throw "Scenario '$scenario' could not find required text '$text'."
63
+ }
64
+ return $foundPos
65
+ }
66
+
67
+ $origUserName = $null
68
+ $origUserInitials = $null
69
+
70
+ try {
71
+ $global:word = New-Object -ComObject Word.Application
72
+ $global:word.Visible = $false
73
+ $global:word.DisplayAlerts = 0
74
+ $origUserName = $global:word.UserName
75
+ $origUserInitials = $global:word.UserInitials
76
+
77
+ # -------------------------------------------------------------------------
78
+ # Scenario 1: insert-interior
79
+ # Author A (Barry) inserts: "amended by this Agreement."
80
+ # Author B (Anson) inserts "MASTER " before "Agreement"
81
+ # -------------------------------------------------------------------------
82
+ Write-Host "1. insert-interior"
83
+ $doc = $global:word.Documents.Add()
84
+ $doc.TrackRevisions = $false
85
+ $doc.Range(0, 0).Text = "Contract terms "
86
+
87
+ # Author A insertion
88
+ $global:word.UserName = "Barry Plasteras"
89
+ $global:word.UserInitials = "BP"
90
+ $doc.TrackRevisions = $true
91
+ $endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
92
+ $endR.Text = "amended by this Agreement."
93
+
94
+ # Author B insertion inside Author A's insertion
95
+ $global:word.UserName = "Anson Lai"
96
+ $global:word.UserInitials = "AL"
97
+ # Find "Agreement" and insert "MASTER " before it
98
+ $targetText = "Agreement."
99
+ $foundPos = Find-RequiredText $doc $targetText "insert-interior"
100
+ $insRange = $doc.Range($foundPos, $foundPos)
101
+ $insRange.Text = "MASTER "
102
+ Save-Triple $doc "insert-interior"
103
+
104
+ # -------------------------------------------------------------------------
105
+ # Scenario 2: delete-interior
106
+ # Author A (Barry) inserts: "The Services will process the Input to generate outputs for Customer."
107
+ # Author B (Anson) deletes: "generate "
108
+ # -------------------------------------------------------------------------
109
+ Write-Host "2. delete-interior"
110
+ $doc = $global:word.Documents.Add()
111
+ $doc.TrackRevisions = $false
112
+ $doc.Range(0, 0).Text = "Background. "
113
+
114
+ # Author A insertion
115
+ $global:word.UserName = "Barry Plasteras"
116
+ $global:word.UserInitials = "BP"
117
+ $doc.TrackRevisions = $true
118
+ $endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
119
+ $endR.Text = "The Services will process the Input to generate outputs for Customer."
120
+
121
+ # Author B deletion inside Author A's insertion
122
+ $global:word.UserName = "Anson Lai"
123
+ $global:word.UserInitials = "AL"
124
+ $delWord = "generate "
125
+ $foundPos = Find-RequiredText $doc $delWord "delete-interior"
126
+ $delRange = $doc.Range($foundPos, $foundPos + $delWord.Length)
127
+ $delRange.Delete() | Out-Null
128
+ Save-Triple $doc "delete-interior"
129
+
130
+ # -------------------------------------------------------------------------
131
+ # Scenario 3: delete-boundary-start
132
+ # Author A (Barry) inserts: "Notwithstanding the foregoing, the NDA remains in effect."
133
+ # Author B (Anson) deletes: "Notwithstanding the foregoing, "
134
+ # -------------------------------------------------------------------------
135
+ Write-Host "3. delete-boundary-start"
136
+ $doc = $global:word.Documents.Add()
137
+ $doc.TrackRevisions = $false
138
+ $doc.Range(0, 0).Text = "Section 1. "
139
+
140
+ # Author A insertion
141
+ $global:word.UserName = "Barry Plasteras"
142
+ $global:word.UserInitials = "BP"
143
+ $doc.TrackRevisions = $true
144
+ $endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
145
+ $endR.Text = "Notwithstanding the foregoing, the NDA remains in effect."
146
+
147
+ # Author B deletion at start of insertion
148
+ $global:word.UserName = "Anson Lai"
149
+ $global:word.UserInitials = "AL"
150
+ $delWord = "Notwithstanding the foregoing, "
151
+ $foundPos = Find-RequiredText $doc $delWord "delete-boundary-start"
152
+ $delRange = $doc.Range($foundPos, $foundPos + $delWord.Length)
153
+ $delRange.Delete() | Out-Null
154
+ Save-Triple $doc "delete-boundary-start"
155
+
156
+ # -------------------------------------------------------------------------
157
+ # Scenario 4: delete-boundary-end
158
+ # Author A (Barry) inserts: "subject to Section 2.8 and applicable law."
159
+ # Author B (Anson) deletes: " and applicable law."
160
+ # -------------------------------------------------------------------------
161
+ Write-Host "4. delete-boundary-end"
162
+ $doc = $global:word.Documents.Add()
163
+ $doc.TrackRevisions = $false
164
+ $doc.Range(0, 0).Text = "Compliance: "
165
+
166
+ # Author A insertion
167
+ $global:word.UserName = "Barry Plasteras"
168
+ $global:word.UserInitials = "BP"
169
+ $doc.TrackRevisions = $true
170
+ $endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
171
+ $endR.Text = "subject to Section 2.8 and applicable law."
172
+
173
+ # Author B deletion at end of insertion
174
+ $global:word.UserName = "Anson Lai"
175
+ $global:word.UserInitials = "AL"
176
+ $delWord = " and applicable law."
177
+ $foundPos = Find-RequiredText $doc $delWord "delete-boundary-end"
178
+ $delRange = $doc.Range($foundPos, $foundPos + $delWord.Length)
179
+ $delRange.Delete() | Out-Null
180
+ Save-Triple $doc "delete-boundary-end"
181
+
182
+ # -------------------------------------------------------------------------
183
+ # Scenario 5: delete-straddle-baseline-insertion
184
+ # Baseline: "Baseline start "
185
+ # Author A (Barry) inserts: "inserted finish."
186
+ # Author B (Anson) deletes: "start inserted" (straddling baseline and insertion)
187
+ # -------------------------------------------------------------------------
188
+ Write-Host "5. delete-straddle-baseline-insertion"
189
+ $doc = $global:word.Documents.Add()
190
+ $doc.TrackRevisions = $false
191
+ $doc.Range(0, 0).Text = "Baseline start "
192
+
193
+ # Author A insertion
194
+ $global:word.UserName = "Barry Plasteras"
195
+ $global:word.UserInitials = "BP"
196
+ $doc.TrackRevisions = $true
197
+ $endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
198
+ $endR.Text = "inserted finish."
199
+
200
+ # Author B deletion straddling baseline and insertion
201
+ $global:word.UserName = "Anson Lai"
202
+ $global:word.UserInitials = "AL"
203
+ $delWord = "start inserted"
204
+ $foundPos = Find-RequiredText $doc $delWord "delete-straddle-baseline-insertion"
205
+ $delRange = $doc.Range($foundPos, $foundPos + $delWord.Length)
206
+ $delRange.Delete() | Out-Null
207
+ Save-Triple $doc "delete-straddle-baseline-insertion"
208
+
209
+ # -------------------------------------------------------------------------
210
+ # Scenario 6: multi-author-stacked
211
+ # Baseline: "Provision "
212
+ # Author A (Barry) inserts: "first draft of the proposal with initial metrics."
213
+ # Author B (Anson) deletes: "of the proposal "
214
+ # Author C (Chris) deletes: "initial " from the remaining text
215
+ # -------------------------------------------------------------------------
216
+ Write-Host "6. multi-author-stacked"
217
+ $doc = $global:word.Documents.Add()
218
+ $doc.TrackRevisions = $false
219
+ $doc.Range(0, 0).Text = "Provision "
220
+
221
+ # Author A insertion
222
+ $global:word.UserName = "Barry Plasteras"
223
+ $global:word.UserInitials = "BP"
224
+ $doc.TrackRevisions = $true
225
+ $endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
226
+ $endR.Text = "first draft of the proposal with initial metrics."
227
+
228
+ # Author B deletion inside Author A's insertion
229
+ $global:word.UserName = "Anson Lai"
230
+ $global:word.UserInitials = "AL"
231
+ $delWordB = "of the proposal "
232
+ $foundPosB = Find-RequiredText $doc $delWordB "multi-author-stacked (Author B)"
233
+ $delRangeB = $doc.Range($foundPosB, $foundPosB + $delWordB.Length)
234
+ $delRangeB.Delete() | Out-Null
235
+
236
+ # Author C deletion inside Author A's remaining insertion
237
+ $global:word.UserName = "Chris Davis"
238
+ $global:word.UserInitials = "CD"
239
+ $delWordC = "initial "
240
+ $foundPosC = Find-RequiredText $doc $delWordC "multi-author-stacked (Author C)"
241
+ $delRangeC = $doc.Range($foundPosC, $foundPosC + $delWordC.Length)
242
+ $delRangeC.Delete() | Out-Null
243
+ Save-Triple $doc "multi-author-stacked"
244
+
245
+ Write-Host "All 6 fixture triples successfully generated!"
246
+ }
247
+ finally {
248
+ if ($null -ne $origUserName -and $null -ne $global:word) {
249
+ $global:word.UserName = $origUserName
250
+ $global:word.UserInitials = $origUserInitials
251
+ }
252
+ if ($null -ne $global:word) {
253
+ $global:word.Quit()
254
+ [System.Runtime.InteropServices.Marshal]::ReleaseComObject($global:word) | Out-Null
255
+ }
256
+ }
@@ -1,7 +1,8 @@
1
1
  import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
2
2
  import { getDefaultAuthor } from '../adapters/config.js';
3
- import {
4
- normalizeDocumentOperation,
3
+ import {
4
+ isExistingRevisionsPolicy,
5
+ normalizeDocumentOperation,
5
6
  resolveDocumentOperationAuthor
6
7
  } from './document-operation-contract.js';
7
8
  import {
@@ -183,15 +184,33 @@ function mergeCommentsXml(existingXml, incomingXml) {
183
184
  return serializer.serializeToString(existingDoc);
184
185
  }
185
186
 
186
- export async function applyOperationsToDocumentXml(documentXml, operations, author, runtimeContext = null, options = {}) {
187
+ export async function applyOperationsToDocumentXml(documentXml, operations, author, runtimeContext = null, options = {}) {
187
188
  const sourceOperations = Array.isArray(operations) ? operations : [];
188
189
  const defaultAuthor = getDefaultAuthor();
189
- const emptyReceipts = () => sourceOperations.map((op, i) => createEmptyReceipt(
190
+ const emptyReceipts = () => sourceOperations.map((op, i) => createEmptyReceipt(
190
191
  i + 1,
191
192
  op?.operationId,
192
193
  resolveDocumentOperationAuthor(op, author, defaultAuthor),
193
194
  'not_attempted'
194
- ));
195
+ ));
196
+
197
+ if (options.existingRevisions != null && !isExistingRevisionsPolicy(options.existingRevisions)) {
198
+ return {
199
+ documentXml,
200
+ hasChanges: false,
201
+ commentsXml: null,
202
+ numberingXmlParts: [],
203
+ results: [],
204
+ receipts: emptyReceipts(),
205
+ executionOrder: [],
206
+ authorsUsed: [],
207
+ status: 'error',
208
+ error: {
209
+ code: 'INVALID_OPERATION',
210
+ message: `Unsupported existingRevisions policy: "${String(options.existingRevisions)}".`
211
+ }
212
+ };
213
+ }
195
214
 
196
215
  if (options?.expectedRevision) {
197
216
  const tokenValidation = validateRevisionToken(options.expectedRevision);
@@ -20,6 +20,18 @@ const SUPPORTED_OPERATION_TYPES = new Set([
20
20
  'highlight'
21
21
  ]);
22
22
 
23
+ export const EXISTING_REVISIONS_POLICIES = Object.freeze([
24
+ 'merge-same-author',
25
+ 'slice-cross-author',
26
+ 'reject-input',
27
+ 'accept-all-first',
28
+ 'accept-all-first-keep-normalized'
29
+ ]);
30
+
31
+ export function isExistingRevisionsPolicy(value) {
32
+ return EXISTING_REVISIONS_POLICIES.includes(value);
33
+ }
34
+
23
35
  function isRecord(value) {
24
36
  return !!value && typeof value === 'object' && !Array.isArray(value);
25
37
  }
@@ -203,6 +215,16 @@ export function validateDocumentOperation(operation) {
203
215
  };
204
216
  }
205
217
 
218
+ if (normalized.existingRevisions != null && !isExistingRevisionsPolicy(normalized.existingRevisions)) {
219
+ return {
220
+ valid: false,
221
+ error: {
222
+ code: 'INVALID_OPERATION',
223
+ message: `existingRevisions must be one of: ${EXISTING_REVISIONS_POLICIES.join(', ')}.`
224
+ }
225
+ };
226
+ }
227
+
206
228
  if (normalized.insertionAffinity != null) {
207
229
  if (!isRecord(normalized.insertionAffinity)) {
208
230
  return {
@@ -860,11 +860,11 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
860
860
  && existingPolicy !== 'accept-all-first'
861
861
  && existingPolicy !== 'accept-all-first-keep-normalized'
862
862
  ) {
863
- if (existingPolicy === 'merge-same-author') {
863
+ if (existingPolicy === 'merge-same-author' || existingPolicy === 'slice-cross-author') {
864
864
  const authors = getTrackedChangeAuthors(targetParagraph);
865
865
  const opAuthor = String(author || '').trim().toLowerCase();
866
866
  const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
867
- if (!allSame) {
867
+ if (!allSame && existingPolicy === 'merge-same-author') {
868
868
  return {
869
869
  documentXml,
870
870
  hasChanges: false,
@@ -876,7 +876,7 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
876
876
  }
877
877
  };
878
878
  }
879
- const mergeCommentIds = getCommentIdsInElement(targetParagraph);
879
+ const mergeCommentIds = allSame ? getCommentIdsInElement(targetParagraph) : [];
880
880
  if (mergeCommentIds.length > 0) {
881
881
  const comments = commentDetailsForIds(mergeCommentIds, options._existingCommentDetails);
882
882
  return {
@@ -892,6 +892,22 @@ export async function applyToParagraphByExactText(documentXml, targetText, modif
892
892
  }
893
893
  };
894
894
  }
895
+ if (!allSame) {
896
+ const hasMove = targetParagraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
897
+ || targetParagraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
898
+ if (hasMove) {
899
+ return {
900
+ documentXml,
901
+ hasChanges: false,
902
+ numberingXml: null,
903
+ status: 'error',
904
+ error: {
905
+ code: 'UNSAFE_REVISION_NESTING',
906
+ message: 'Cross-author slicing does not support pending move revisions.'
907
+ }
908
+ };
909
+ }
910
+ }
895
911
  } else {
896
912
  const hasDel = targetParagraph.getElementsByTagNameNS(NS_W, 'del').length > 0;
897
913
  const hasMove = targetParagraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0