@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/AGENTS.md +76 -15
- package/CHANGELOG.md +24 -1
- package/README.md +43 -11
- package/core/redline-validation.js +11 -5
- package/dist/docx-redline-js.esm.js +363 -55
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +77 -74
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +18 -0
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +575 -0
- package/docs/schemas/document-operations.schema.json +1 -1
- package/engine/oxml-engine.js +39 -16
- package/engine/surgical-diff-application.js +203 -19
- package/engine/surgical-mode.js +55 -10
- package/engine/surgical-run-splitting.js +103 -0
- package/index.d.ts +1 -1
- package/node/cli.js +12 -7
- package/package.json +123 -123
- package/pipeline/diff-engine.js +22 -0
- package/scripts/generate-cross-author-slicing-fixtures.ps1 +256 -0
- package/services/batch-operation-orchestrator.js +24 -5
- package/services/document-operation-contract.js +22 -0
- package/services/document-operation-mutations.js +19 -3
- package/services/operation-preflight.js +32 -8
- package/services/revision-comment-management.js +40 -0
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
|
-
'
|
|
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.
|
|
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
|
+
}
|
package/pipeline/diff-engine.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|