@ansonlai/docx-redline-js 0.5.0 → 0.5.2

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/node/cli.js CHANGED
@@ -4,14 +4,16 @@ import { openDocx } from './docx-document.js';
4
4
  import { MemoryZip, unzipDocx } from './zip-archive.js';
5
5
  import { validateDocxPackage } from '../services/standalone-docx-plumbing.js';
6
6
  import { validateRedlineOoxml } from '../core/redline-validation.js';
7
- import { configureLogger } from '../adapters/logger.js';
7
+ import { configureLogger } from '../adapters/logger.js';
8
+ import { isExistingRevisionsPolicy } from '../services/document-operation-contract.js';
8
9
 
9
10
  const suffixes = { apply: 'redlined', accept: 'accepted', reject: 'rejected', 'delete-comments': 'comments-removed' };
10
11
  const CLI_CONTRACT_VERSION = 2;
11
- const CLI_CAPABILITIES = [
12
- 'atomic-batch-results-on-package-failure',
13
- 'baseline-aware-validation',
14
- 'document-scoped-list-revision-ids'
12
+ const CLI_CAPABILITIES = [
13
+ 'atomic-batch-results-on-package-failure',
14
+ 'baseline-aware-validation',
15
+ 'cross-author-revision-slicing',
16
+ 'document-scoped-list-revision-ids'
15
17
  ];
16
18
  const commandOptions = {
17
19
  version: new Set(['help']),
@@ -214,8 +216,11 @@ export async function executeCli(argv) {
214
216
  };
215
217
  }
216
218
  if (!rawInput) return cliError('INPUT_REQUIRED', 'An input .docx path is required.');
217
- const optionError = validateCommandOptions(command, flags, extraPositionals);
218
- if (optionError) return optionError;
219
+ const optionError = validateCommandOptions(command, flags, extraPositionals);
220
+ if (optionError) return optionError;
221
+ if (flags.existingRevisions != null && !isExistingRevisionsPolicy(flags.existingRevisions)) {
222
+ return cliError('INVALID_OPERATION', `Unsupported existing-revisions policy: "${String(flags.existingRevisions)}".`);
223
+ }
219
224
  let inspectOptions = null;
220
225
  if (command === 'inspect' || command === 'extract') {
221
226
  try { inspectOptions = inspectionOptions(flags); }
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.2",
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
+ }
@@ -232,6 +232,28 @@ export function computeWordDiffs(originalText, newText, options = {}) {
232
232
  return decodeBmpDiffs(charDiffs, wordArray);
233
233
  }
234
234
 
235
+ /**
236
+ * Returns a character-local diff only when the modified string can be made
237
+ * solely by inserting into the original. This prevents word-token cleanup
238
+ * from relocating small insertions between repeated phrases or containers.
239
+ *
240
+ * @param {string} originalText
241
+ * @param {string} newText
242
+ * @returns {Array<[number, string]>|null}
243
+ */
244
+ export function computeInsertionOnlyDiffs(originalText, newText) {
245
+ if (originalText === newText) return [[0, originalText]];
246
+ if (!originalText) return [[1, newText]];
247
+ let originalIndex = 0;
248
+ for (let modifiedIndex = 0; modifiedIndex < newText.length && originalIndex < originalText.length; modifiedIndex++) {
249
+ if (newText[modifiedIndex] === originalText[originalIndex]) originalIndex++;
250
+ }
251
+ if (originalIndex !== originalText.length) return null;
252
+
253
+ const diffs = createDiffEngine().diff_main(originalText, newText);
254
+ return diffs.some(([op]) => op === -1) ? null : diffs;
255
+ }
256
+
235
257
  /**
236
258
  * Computes word-level diff operations with offset tracking.
237
259
  *
@@ -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 {