@ansonlai/docx-redline-js 0.8.0 → 0.8.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/CHANGELOG.md +539 -486
- package/README.md +331 -309
- package/dist/docx-redline-js.esm.js +1083 -163
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +107 -107
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +1083 -163
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +1083 -163
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_KNOWLEDGE_BASE.md +69 -8
- package/docs/schemas/document-operations.schema.json +29 -0
- package/document/docx-document.js +416 -22
- package/engine/reconstruction-writer.js +18 -0
- package/index.d.ts +598 -575
- package/index.js +349 -348
- package/node/cli-help.js +5 -5
- package/node/cli.js +760 -754
- package/node/index.d.ts +1 -1
- package/package.json +2 -1
- package/scripts/generate-word-comment-edge-case-fixture.ps1 +58 -0
- package/scripts/generate-word-comment-thread-fixture.ps1 +36 -0
- package/scripts/generate-word-header-footer-fixture.ps1 +32 -0
- package/scripts/generate-word-multi-section-fixture.ps1 +27 -0
- package/scripts/generate-word-package-integrity-cases.mjs +141 -0
- package/scripts/generate-word-thread-state-fixtures.ps1 +50 -0
- package/scripts/word-com-package-integrity.ps1 +98 -0
- package/services/batch-operation-orchestrator.js +262 -259
- package/services/comment-replies.js +96 -95
- package/services/comment-thread-parts.js +280 -0
- package/services/document-inspection.js +366 -365
- package/services/document-operation-applier.js +230 -202
- package/services/document-operation-contract.js +17 -2
- package/services/error-recovery.js +6 -0
- package/services/headers-footers.js +206 -0
- package/services/operation-batch-compiler.js +1 -0
- package/services/operation-preflight.js +231 -218
- package/services/package-parts.js +80 -0
- package/services/standalone-docx-plumbing.js +631 -569
- package/services/standalone-operation-runner.d.ts +19 -3
|
@@ -6,35 +6,35 @@ import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
|
|
|
6
6
|
import { getDefaultAuthor } from '../adapters/config.js';
|
|
7
7
|
import { containsTrackedChanges, getTrackedChangeAuthors } from '../core/word-xml.js';
|
|
8
8
|
import { NS_W } from '../core/types.js';
|
|
9
|
-
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
10
|
-
import {
|
|
11
|
-
getParagraphRestorationRefusal,
|
|
12
|
-
inspectForeignDeletedParagraphTarget
|
|
13
|
-
} from '../core/paragraph-revision-safety.js';
|
|
9
|
+
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
10
|
+
import {
|
|
11
|
+
getParagraphRestorationRefusal,
|
|
12
|
+
inspectForeignDeletedParagraphTarget
|
|
13
|
+
} from '../core/paragraph-revision-safety.js';
|
|
14
14
|
import {
|
|
15
15
|
buildParagraphMetadataIndex,
|
|
16
16
|
createParagraphFingerprint,
|
|
17
17
|
findContainingWordElement,
|
|
18
18
|
getDocumentParagraphNodes,
|
|
19
19
|
getParagraphId,
|
|
20
|
-
normalizeWhitespaceForTargeting,
|
|
21
|
-
resolveParagraphRangeByRefs,
|
|
22
|
-
resolveTargetParagraph,
|
|
20
|
+
normalizeWhitespaceForTargeting,
|
|
21
|
+
resolveParagraphRangeByRefs,
|
|
22
|
+
resolveTargetParagraph,
|
|
23
23
|
validateParagraphBoundaryMutation
|
|
24
24
|
} from '../core/paragraph-targeting.js';
|
|
25
25
|
import { createParagraphTextIndex, resolveTextInParagraphIndex } from './comment-locator.js';
|
|
26
|
-
import {
|
|
27
|
-
isExistingRevisionsPolicy,
|
|
28
|
-
normalizeDocumentOperation,
|
|
26
|
+
import {
|
|
27
|
+
isExistingRevisionsPolicy,
|
|
28
|
+
normalizeDocumentOperation,
|
|
29
29
|
resolveDocumentOperationAuthor,
|
|
30
30
|
validateDocumentOperation
|
|
31
31
|
} from './document-operation-contract.js';
|
|
32
|
-
import { buildOperationDependencyPlan } from './batch-operation-orchestrator.js';
|
|
33
|
-
import { compileOperationBatch } from './operation-batch-compiler.js';
|
|
34
|
-
import { normalizeErrorWithRecovery } from './error-recovery.js';
|
|
35
|
-
|
|
36
|
-
function normalizedError(error, context = {}) {
|
|
37
|
-
return normalizeErrorWithRecovery(error, context);
|
|
32
|
+
import { buildOperationDependencyPlan } from './batch-operation-orchestrator.js';
|
|
33
|
+
import { compileOperationBatch } from './operation-batch-compiler.js';
|
|
34
|
+
import { normalizeErrorWithRecovery } from './error-recovery.js';
|
|
35
|
+
|
|
36
|
+
function normalizedError(error, context = {}) {
|
|
37
|
+
return normalizeErrorWithRecovery(error, context);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
function operationNeedsNumbering(operation) {
|
|
@@ -79,27 +79,27 @@ function getCommentIdsInParagraph(paragraph) {
|
|
|
79
79
|
return Array.from(ids).sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
export function preflightOperations(documentXml, operations, author, options = {}) {
|
|
83
|
-
if (options.existingRevisions != null && !isExistingRevisionsPolicy(options.existingRevisions)) {
|
|
84
|
-
return {
|
|
85
|
-
valid: false,
|
|
86
|
-
status: 'error',
|
|
87
|
-
error: normalizedError({
|
|
88
|
-
code: 'INVALID_OPERATION',
|
|
89
|
-
message: `Unsupported existingRevisions policy: "${String(options.existingRevisions)}".`
|
|
90
|
-
}),
|
|
91
|
-
results: [],
|
|
92
|
-
conflicts: [],
|
|
93
|
-
authorsUsed: [],
|
|
94
|
-
requiredArtifacts: { comments: false, numbering: false }
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
const parsed = parseOoxmlSafe(documentXml, 'application/xml');
|
|
82
|
+
export function preflightOperations(documentXml, operations, author, options = {}) {
|
|
83
|
+
if (options.existingRevisions != null && !isExistingRevisionsPolicy(options.existingRevisions)) {
|
|
84
|
+
return {
|
|
85
|
+
valid: false,
|
|
86
|
+
status: 'error',
|
|
87
|
+
error: normalizedError({
|
|
88
|
+
code: 'INVALID_OPERATION',
|
|
89
|
+
message: `Unsupported existingRevisions policy: "${String(options.existingRevisions)}".`
|
|
90
|
+
}),
|
|
91
|
+
results: [],
|
|
92
|
+
conflicts: [],
|
|
93
|
+
authorsUsed: [],
|
|
94
|
+
requiredArtifacts: { comments: false, numbering: false }
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const parsed = parseOoxmlSafe(documentXml, 'application/xml');
|
|
98
98
|
if (parsed.error || !parsed.doc) {
|
|
99
99
|
return {
|
|
100
100
|
valid: false,
|
|
101
101
|
status: 'error',
|
|
102
|
-
error: normalizedError(parsed.error),
|
|
102
|
+
error: normalizedError(parsed.error),
|
|
103
103
|
results: [],
|
|
104
104
|
conflicts: [],
|
|
105
105
|
authorsUsed: [],
|
|
@@ -113,39 +113,39 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
113
113
|
rejected: null
|
|
114
114
|
};
|
|
115
115
|
const sourceOperations = Array.isArray(operations) ? operations : [];
|
|
116
|
-
const batchCompilation = compileOperationBatch(xmlDoc, sourceOperations, {
|
|
117
|
-
strictTargets: options.strictTargets !== false,
|
|
118
|
-
onInfo: options.onInfo,
|
|
119
|
-
onWarn: options.onWarn
|
|
120
|
-
});
|
|
121
|
-
const dependencyPlan = buildOperationDependencyPlan(batchCompilation.compiledOperations);
|
|
122
|
-
if (!dependencyPlan.valid) {
|
|
116
|
+
const batchCompilation = compileOperationBatch(xmlDoc, sourceOperations, {
|
|
117
|
+
strictTargets: options.strictTargets !== false,
|
|
118
|
+
onInfo: options.onInfo,
|
|
119
|
+
onWarn: options.onWarn
|
|
120
|
+
});
|
|
121
|
+
const dependencyPlan = buildOperationDependencyPlan(batchCompilation.compiledOperations);
|
|
122
|
+
if (!dependencyPlan.valid) {
|
|
123
123
|
return {
|
|
124
124
|
valid: false,
|
|
125
125
|
status: 'error',
|
|
126
|
-
error: normalizedError(dependencyPlan.error),
|
|
126
|
+
error: normalizedError(dependencyPlan.error),
|
|
127
127
|
results: [],
|
|
128
128
|
conflicts: [],
|
|
129
129
|
authorsUsed: [],
|
|
130
130
|
requiredArtifacts: { comments: false, numbering: false }
|
|
131
131
|
};
|
|
132
|
-
}
|
|
132
|
+
}
|
|
133
133
|
const strictTargets = options.strictTargets !== false;
|
|
134
134
|
const results = [];
|
|
135
135
|
const authorsUsed = new Set();
|
|
136
136
|
let commentsRequired = false;
|
|
137
137
|
let numberingRequired = false;
|
|
138
138
|
|
|
139
|
-
for (let index = 0; index < sourceOperations.length; index++) {
|
|
140
|
-
const sourceOperation = sourceOperations[index];
|
|
141
|
-
const compiledBinding = batchCompilation.bindings[index];
|
|
142
|
-
const compiledOperation = batchCompilation.compiledOperations[index];
|
|
143
|
-
const operationInput = !compiledBinding?.error && compiledOperation?._localizedReplacementCompilation
|
|
144
|
-
? compiledOperation
|
|
145
|
-
: sourceOperation;
|
|
146
|
-
const validation = validateDocumentOperation(operationInput);
|
|
147
|
-
const fallbackOperation = normalizeDocumentOperation(sourceOperation);
|
|
148
|
-
const operation = validation.operation || fallbackOperation;
|
|
139
|
+
for (let index = 0; index < sourceOperations.length; index++) {
|
|
140
|
+
const sourceOperation = sourceOperations[index];
|
|
141
|
+
const compiledBinding = batchCompilation.bindings[index];
|
|
142
|
+
const compiledOperation = batchCompilation.compiledOperations[index];
|
|
143
|
+
const operationInput = !compiledBinding?.error && compiledOperation?._localizedReplacementCompilation
|
|
144
|
+
? compiledOperation
|
|
145
|
+
: sourceOperation;
|
|
146
|
+
const validation = validateDocumentOperation(operationInput);
|
|
147
|
+
const fallbackOperation = normalizeDocumentOperation(sourceOperation);
|
|
148
|
+
const operation = validation.operation || fallbackOperation;
|
|
149
149
|
const authorUsed = resolveDocumentOperationAuthor(operation, author, getDefaultAuthor());
|
|
150
150
|
authorsUsed.add(authorUsed);
|
|
151
151
|
|
|
@@ -164,6 +164,19 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
164
164
|
commentsRequired = commentsRequired || operation.operationKind === 'comment' || operation.operationKind === 'comment_reply';
|
|
165
165
|
numberingRequired = numberingRequired || operationNeedsNumbering(operation);
|
|
166
166
|
|
|
167
|
+
if (operation.operationKind === 'comment_resolve') {
|
|
168
|
+
const commentId = String(operation.commentId);
|
|
169
|
+
results.push(options._existingCommentDetails?.[commentId] ? {
|
|
170
|
+
index: index + 1, type: sourceOperation.type, operationType: operation.operationKind,
|
|
171
|
+
status: 'ready', authorUsed, resolvedBy: 'comment_id', commentId
|
|
172
|
+
} : {
|
|
173
|
+
index: index + 1, type: sourceOperation.type, operationType: operation.operationKind,
|
|
174
|
+
status: 'error', authorUsed,
|
|
175
|
+
error: { code: 'COMMENT_NOT_FOUND', message: `Comment '${commentId}' was not found.` }
|
|
176
|
+
});
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
167
180
|
if (operation.operationKind === 'comment_reply') {
|
|
168
181
|
const parentId = String(operation.parentCommentId);
|
|
169
182
|
const parent = options._existingCommentDetails?.[parentId];
|
|
@@ -178,7 +191,7 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
178
191
|
continue;
|
|
179
192
|
}
|
|
180
193
|
|
|
181
|
-
if (operation.targetDescriptor?.captureRef) {
|
|
194
|
+
if (operation.targetDescriptor?.captureRef) {
|
|
182
195
|
results.push({
|
|
183
196
|
index: index + 1,
|
|
184
197
|
type: sourceOperation?.type || 'redline',
|
|
@@ -189,34 +202,34 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
189
202
|
captureRef: operation.targetDescriptor.captureRef,
|
|
190
203
|
...(operation.targetDescriptor.select ? { select: operation.targetDescriptor.select } : {})
|
|
191
204
|
});
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (compiledBinding?.error) {
|
|
196
|
-
results.push({
|
|
197
|
-
index: index + 1,
|
|
198
|
-
type: sourceOperation?.type || 'redline',
|
|
199
|
-
operationType: operation.operationKind,
|
|
200
|
-
status: 'error',
|
|
201
|
-
authorUsed,
|
|
202
|
-
error: compiledBinding.error
|
|
203
|
-
});
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
if (compiledBinding?.createdByOperation) {
|
|
207
|
-
results.push({
|
|
208
|
-
index: index + 1,
|
|
209
|
-
type: sourceOperation?.type || 'redline',
|
|
210
|
-
operationType: operation.operationKind,
|
|
211
|
-
status: 'deferred',
|
|
212
|
-
authorUsed,
|
|
213
|
-
resolvedBy: 'created_content_dependency',
|
|
214
|
-
captureRef: compiledBinding.captureRef,
|
|
215
|
-
select: operation.targetDescriptor.text,
|
|
216
|
-
createdByOperation: compiledBinding.createdByOperation
|
|
217
|
-
});
|
|
218
|
-
continue;
|
|
219
|
-
}
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (compiledBinding?.error) {
|
|
209
|
+
results.push({
|
|
210
|
+
index: index + 1,
|
|
211
|
+
type: sourceOperation?.type || 'redline',
|
|
212
|
+
operationType: operation.operationKind,
|
|
213
|
+
status: 'error',
|
|
214
|
+
authorUsed,
|
|
215
|
+
error: compiledBinding.error
|
|
216
|
+
});
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (compiledBinding?.createdByOperation) {
|
|
220
|
+
results.push({
|
|
221
|
+
index: index + 1,
|
|
222
|
+
type: sourceOperation?.type || 'redline',
|
|
223
|
+
operationType: operation.operationKind,
|
|
224
|
+
status: 'deferred',
|
|
225
|
+
authorUsed,
|
|
226
|
+
resolvedBy: 'created_content_dependency',
|
|
227
|
+
captureRef: compiledBinding.captureRef,
|
|
228
|
+
select: operation.targetDescriptor.text,
|
|
229
|
+
createdByOperation: compiledBinding.createdByOperation
|
|
230
|
+
});
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
220
233
|
|
|
221
234
|
const targetView = operation.targetDescriptor?.revisionView === 'rejected' ? 'rejected' : 'accepted';
|
|
222
235
|
let currentMetadataIndex = targetView === 'rejected'
|
|
@@ -224,28 +237,28 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
224
237
|
: metadataIndices.accepted;
|
|
225
238
|
|
|
226
239
|
try {
|
|
227
|
-
let resolved;
|
|
228
|
-
if (compiledBinding.dynamic) {
|
|
229
|
-
resolved = resolveTargetParagraph(xmlDoc, {
|
|
230
|
-
targetText: operation.target,
|
|
231
|
-
targetRef: operation.targetRef,
|
|
232
|
-
targetDescriptor: operation.targetDescriptor,
|
|
233
|
-
opType: operation.operationKind,
|
|
234
|
-
strictAmbiguity: strictTargets,
|
|
235
|
-
paragraphMetadataIndex: currentMetadataIndex,
|
|
236
|
-
metadataIndices,
|
|
237
|
-
onInfo: options.onInfo,
|
|
238
|
-
onWarn: options.onWarn
|
|
239
|
-
});
|
|
240
|
-
} else {
|
|
241
|
-
const bound = batchCompilation.registry.resolve(compiledBinding.sourceIds[0], xmlDoc);
|
|
242
|
-
if (bound.error) throw Object.assign(new Error(bound.error.message), bound.error);
|
|
243
|
-
resolved = {
|
|
244
|
-
paragraph: bound.paragraph,
|
|
245
|
-
resolvedBy: compiledBinding.resolvedBy,
|
|
246
|
-
warnings: compiledBinding.warnings
|
|
247
|
-
};
|
|
248
|
-
}
|
|
240
|
+
let resolved;
|
|
241
|
+
if (compiledBinding.dynamic) {
|
|
242
|
+
resolved = resolveTargetParagraph(xmlDoc, {
|
|
243
|
+
targetText: operation.target,
|
|
244
|
+
targetRef: operation.targetRef,
|
|
245
|
+
targetDescriptor: operation.targetDescriptor,
|
|
246
|
+
opType: operation.operationKind,
|
|
247
|
+
strictAmbiguity: strictTargets,
|
|
248
|
+
paragraphMetadataIndex: currentMetadataIndex,
|
|
249
|
+
metadataIndices,
|
|
250
|
+
onInfo: options.onInfo,
|
|
251
|
+
onWarn: options.onWarn
|
|
252
|
+
});
|
|
253
|
+
} else {
|
|
254
|
+
const bound = batchCompilation.registry.resolve(compiledBinding.sourceIds[0], xmlDoc);
|
|
255
|
+
if (bound.error) throw Object.assign(new Error(bound.error.message), bound.error);
|
|
256
|
+
resolved = {
|
|
257
|
+
paragraph: bound.paragraph,
|
|
258
|
+
resolvedBy: compiledBinding.resolvedBy,
|
|
259
|
+
warnings: compiledBinding.warnings
|
|
260
|
+
};
|
|
261
|
+
}
|
|
249
262
|
const paragraph = resolved.paragraph;
|
|
250
263
|
const metadata = targetMetadata(xmlDoc, paragraph, resolved.resolvedBy, operation.target, currentMetadataIndex, targetView);
|
|
251
264
|
const paragraphText = metadata.resolvedTarget.text;
|
|
@@ -264,69 +277,69 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
264
277
|
|| options.existingRevisions
|
|
265
278
|
|| 'merge-same-author';
|
|
266
279
|
const deletingWholeParagraph = operation.operationKind === 'redline' && operation.modified === '';
|
|
267
|
-
const commentIds = deletingWholeParagraph ? getCommentIdsInParagraph(paragraph) : [];
|
|
268
|
-
|
|
269
|
-
let error = null;
|
|
270
|
-
if (operation.operationKind === 'restore') {
|
|
271
|
-
let restorationParagraphs = operation.targetEndRef
|
|
272
|
-
? resolveParagraphRangeByRefs(xmlDoc, operation.targetRef, operation.targetEndRef, {
|
|
273
|
-
opType: 'restore',
|
|
274
|
-
targetRefSnapshot: options.targetRefSnapshot || null,
|
|
275
|
-
onInfo: options.onInfo,
|
|
276
|
-
onWarn: options.onWarn
|
|
277
|
-
})
|
|
278
|
-
: [paragraph];
|
|
279
|
-
if (!operation.targetEndRef && operation.targetEndDescriptor) {
|
|
280
|
-
const endResolved = resolveTargetParagraph(xmlDoc, {
|
|
281
|
-
targetText: operation.targetEndDescriptor.text,
|
|
282
|
-
targetRef: operation.targetEndDescriptor.index,
|
|
283
|
-
targetDescriptor: operation.targetEndDescriptor,
|
|
284
|
-
opType: 'restore',
|
|
285
|
-
strictAmbiguity: strictTargets,
|
|
286
|
-
paragraphMetadataIndex: currentMetadataIndex,
|
|
287
|
-
metadataIndices,
|
|
288
|
-
onInfo: options.onInfo,
|
|
289
|
-
onWarn: options.onWarn
|
|
290
|
-
});
|
|
291
|
-
const allParagraphs = getDocumentParagraphNodes(xmlDoc);
|
|
292
|
-
const startIndex = allParagraphs.indexOf(paragraph);
|
|
293
|
-
const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
|
|
294
|
-
restorationParagraphs = startIndex >= 0 && endIndex >= startIndex
|
|
295
|
-
? allParagraphs.slice(startIndex, endIndex + 1)
|
|
296
|
-
: null;
|
|
297
|
-
}
|
|
298
|
-
const replacements = Array.isArray(operation.modified) ? operation.modified : [operation.modified];
|
|
299
|
-
if (!restorationParagraphs || restorationParagraphs[0] !== paragraph || replacements.length !== restorationParagraphs.length) {
|
|
300
|
-
error = {
|
|
301
|
-
code: 'RESTORATION_COUNT_MISMATCH',
|
|
302
|
-
message: 'Restoration requires one replacement for every paragraph in a contiguous resolved range.'
|
|
303
|
-
};
|
|
304
|
-
} else {
|
|
305
|
-
for (const restorationParagraph of restorationParagraphs) {
|
|
306
|
-
const structuralRefusal = getParagraphRestorationRefusal(restorationParagraph);
|
|
307
|
-
if (structuralRefusal?.code === 'UNSUPPORTED_MOVE_REVISION') {
|
|
308
|
-
error = structuralRefusal;
|
|
309
|
-
break;
|
|
310
|
-
}
|
|
311
|
-
const state = inspectForeignDeletedParagraphTarget(restorationParagraph, authorUsed);
|
|
312
|
-
const refusal = state.matches
|
|
313
|
-
? (structuralRefusal || getParagraphRestorationRefusal(restorationParagraph))
|
|
314
|
-
: null;
|
|
315
|
-
if (!state.matches) {
|
|
316
|
-
error = {
|
|
317
|
-
code: 'RESTORATION_STATE_REQUIRED',
|
|
318
|
-
message: 'Explicit restoration requires a foreign paragraph-mark deletion with every pre-existing content node deleted.',
|
|
319
|
-
...(state.ownerAuthor ? { ownerAuthor: state.ownerAuthor } : {})
|
|
320
|
-
};
|
|
321
|
-
break;
|
|
322
|
-
}
|
|
323
|
-
if (refusal) {
|
|
324
|
-
error = refusal;
|
|
325
|
-
break;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
} else if (!anchorFound) {
|
|
280
|
+
const commentIds = deletingWholeParagraph ? getCommentIdsInParagraph(paragraph) : [];
|
|
281
|
+
|
|
282
|
+
let error = null;
|
|
283
|
+
if (operation.operationKind === 'restore') {
|
|
284
|
+
let restorationParagraphs = operation.targetEndRef
|
|
285
|
+
? resolveParagraphRangeByRefs(xmlDoc, operation.targetRef, operation.targetEndRef, {
|
|
286
|
+
opType: 'restore',
|
|
287
|
+
targetRefSnapshot: options.targetRefSnapshot || null,
|
|
288
|
+
onInfo: options.onInfo,
|
|
289
|
+
onWarn: options.onWarn
|
|
290
|
+
})
|
|
291
|
+
: [paragraph];
|
|
292
|
+
if (!operation.targetEndRef && operation.targetEndDescriptor) {
|
|
293
|
+
const endResolved = resolveTargetParagraph(xmlDoc, {
|
|
294
|
+
targetText: operation.targetEndDescriptor.text,
|
|
295
|
+
targetRef: operation.targetEndDescriptor.index,
|
|
296
|
+
targetDescriptor: operation.targetEndDescriptor,
|
|
297
|
+
opType: 'restore',
|
|
298
|
+
strictAmbiguity: strictTargets,
|
|
299
|
+
paragraphMetadataIndex: currentMetadataIndex,
|
|
300
|
+
metadataIndices,
|
|
301
|
+
onInfo: options.onInfo,
|
|
302
|
+
onWarn: options.onWarn
|
|
303
|
+
});
|
|
304
|
+
const allParagraphs = getDocumentParagraphNodes(xmlDoc);
|
|
305
|
+
const startIndex = allParagraphs.indexOf(paragraph);
|
|
306
|
+
const endIndex = allParagraphs.indexOf(endResolved?.paragraph);
|
|
307
|
+
restorationParagraphs = startIndex >= 0 && endIndex >= startIndex
|
|
308
|
+
? allParagraphs.slice(startIndex, endIndex + 1)
|
|
309
|
+
: null;
|
|
310
|
+
}
|
|
311
|
+
const replacements = Array.isArray(operation.modified) ? operation.modified : [operation.modified];
|
|
312
|
+
if (!restorationParagraphs || restorationParagraphs[0] !== paragraph || replacements.length !== restorationParagraphs.length) {
|
|
313
|
+
error = {
|
|
314
|
+
code: 'RESTORATION_COUNT_MISMATCH',
|
|
315
|
+
message: 'Restoration requires one replacement for every paragraph in a contiguous resolved range.'
|
|
316
|
+
};
|
|
317
|
+
} else {
|
|
318
|
+
for (const restorationParagraph of restorationParagraphs) {
|
|
319
|
+
const structuralRefusal = getParagraphRestorationRefusal(restorationParagraph);
|
|
320
|
+
if (structuralRefusal?.code === 'UNSUPPORTED_MOVE_REVISION') {
|
|
321
|
+
error = structuralRefusal;
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
const state = inspectForeignDeletedParagraphTarget(restorationParagraph, authorUsed);
|
|
325
|
+
const refusal = state.matches
|
|
326
|
+
? (structuralRefusal || getParagraphRestorationRefusal(restorationParagraph))
|
|
327
|
+
: null;
|
|
328
|
+
if (!state.matches) {
|
|
329
|
+
error = {
|
|
330
|
+
code: 'RESTORATION_STATE_REQUIRED',
|
|
331
|
+
message: 'Explicit restoration requires a foreign paragraph-mark deletion with every pre-existing content node deleted.',
|
|
332
|
+
...(state.ownerAuthor ? { ownerAuthor: state.ownerAuthor } : {})
|
|
333
|
+
};
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
if (refusal) {
|
|
337
|
+
error = refusal;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
} else if (!anchorFound) {
|
|
330
343
|
error = anchorResolution?.error || {
|
|
331
344
|
code: 'ANCHOR_NOT_FOUND',
|
|
332
345
|
message: `Anchor text was not found in target paragraph: "${anchor}".`
|
|
@@ -347,40 +360,40 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
347
360
|
&& existingPolicy !== 'accept-all-first'
|
|
348
361
|
&& existingPolicy !== 'accept-all-first-keep-normalized'
|
|
349
362
|
) {
|
|
350
|
-
if (existingPolicy === 'merge-same-author' || existingPolicy === 'slice-cross-author') {
|
|
351
|
-
const authors = getTrackedChangeAuthors(paragraph);
|
|
352
|
-
const opAuthor = String(authorUsed || '').trim().toLowerCase();
|
|
353
|
-
const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
|
|
354
|
-
if (!allSame && existingPolicy === 'merge-same-author') {
|
|
355
|
-
error = {
|
|
356
|
-
code: 'EXISTING_REVISIONS',
|
|
357
|
-
message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
|
|
358
|
-
revisionAuthors: authors,
|
|
359
|
-
currentPolicy: existingPolicy
|
|
360
|
-
};
|
|
361
|
-
} else if (allSame) {
|
|
362
|
-
const mergeCommentIds = getCommentIdsInParagraph(paragraph);
|
|
363
|
-
if (mergeCommentIds.length > 0) {
|
|
364
|
-
const comments = mergeCommentIds
|
|
365
|
-
.map(id => options._existingCommentDetails?.[id])
|
|
366
|
-
.filter(Boolean);
|
|
367
|
-
error = {
|
|
368
|
-
code: 'COMMENTED_CONTENT_MERGE',
|
|
369
|
-
message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
|
|
370
|
-
commentIds: mergeCommentIds,
|
|
371
|
-
...(comments.length > 0 ? { comments } : {})
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
} else {
|
|
375
|
-
const hasMove = paragraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
|
|
376
|
-
|| paragraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
|
|
377
|
-
if (hasMove) {
|
|
378
|
-
error = {
|
|
379
|
-
code: 'UNSAFE_REVISION_NESTING',
|
|
380
|
-
message: 'Cross-author slicing does not support pending move revisions.'
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
}
|
|
363
|
+
if (existingPolicy === 'merge-same-author' || existingPolicy === 'slice-cross-author') {
|
|
364
|
+
const authors = getTrackedChangeAuthors(paragraph);
|
|
365
|
+
const opAuthor = String(authorUsed || '').trim().toLowerCase();
|
|
366
|
+
const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
|
|
367
|
+
if (!allSame && existingPolicy === 'merge-same-author') {
|
|
368
|
+
error = {
|
|
369
|
+
code: 'EXISTING_REVISIONS',
|
|
370
|
+
message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
|
|
371
|
+
revisionAuthors: authors,
|
|
372
|
+
currentPolicy: existingPolicy
|
|
373
|
+
};
|
|
374
|
+
} else if (allSame) {
|
|
375
|
+
const mergeCommentIds = getCommentIdsInParagraph(paragraph);
|
|
376
|
+
if (mergeCommentIds.length > 0) {
|
|
377
|
+
const comments = mergeCommentIds
|
|
378
|
+
.map(id => options._existingCommentDetails?.[id])
|
|
379
|
+
.filter(Boolean);
|
|
380
|
+
error = {
|
|
381
|
+
code: 'COMMENTED_CONTENT_MERGE',
|
|
382
|
+
message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
|
|
383
|
+
commentIds: mergeCommentIds,
|
|
384
|
+
...(comments.length > 0 ? { comments } : {})
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
} else {
|
|
388
|
+
const hasMove = paragraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
|
|
389
|
+
|| paragraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
|
|
390
|
+
if (hasMove) {
|
|
391
|
+
error = {
|
|
392
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
393
|
+
message: 'Cross-author slicing does not support pending move revisions.'
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
}
|
|
384
397
|
} else {
|
|
385
398
|
const hasDel = paragraph.getElementsByTagNameNS(NS_W, 'del').length > 0;
|
|
386
399
|
const hasMove = paragraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
|
|
@@ -450,24 +463,24 @@ export function preflightOperations(documentXml, operations, author, options = {
|
|
|
450
463
|
}
|
|
451
464
|
}
|
|
452
465
|
|
|
453
|
-
const conflicts = batchCompilation.conflicts;
|
|
466
|
+
const conflicts = batchCompilation.conflicts;
|
|
454
467
|
|
|
455
|
-
const enrichedResults = results.map(result => result.error ? {
|
|
456
|
-
...result,
|
|
457
|
-
error: normalizedError(result.error, {
|
|
458
|
-
operationIndex: result.index,
|
|
459
|
-
...(sourceOperations[result.index - 1]?.operationId
|
|
460
|
-
? { operationId: sourceOperations[result.index - 1].operationId }
|
|
461
|
-
: {})
|
|
462
|
-
})
|
|
463
|
-
} : result);
|
|
464
|
-
const enrichedConflicts = conflicts.map(conflict => normalizedError(conflict));
|
|
465
|
-
const hasErrors = enrichedResults.some(result => result.status === 'error');
|
|
466
|
-
return {
|
|
467
|
-
valid: !hasErrors && enrichedConflicts.length === 0,
|
|
468
|
-
status: !hasErrors && enrichedConflicts.length === 0 ? 'ok' : 'error',
|
|
469
|
-
results: enrichedResults,
|
|
470
|
-
conflicts: enrichedConflicts,
|
|
468
|
+
const enrichedResults = results.map(result => result.error ? {
|
|
469
|
+
...result,
|
|
470
|
+
error: normalizedError(result.error, {
|
|
471
|
+
operationIndex: result.index,
|
|
472
|
+
...(sourceOperations[result.index - 1]?.operationId
|
|
473
|
+
? { operationId: sourceOperations[result.index - 1].operationId }
|
|
474
|
+
: {})
|
|
475
|
+
})
|
|
476
|
+
} : result);
|
|
477
|
+
const enrichedConflicts = conflicts.map(conflict => normalizedError(conflict));
|
|
478
|
+
const hasErrors = enrichedResults.some(result => result.status === 'error');
|
|
479
|
+
return {
|
|
480
|
+
valid: !hasErrors && enrichedConflicts.length === 0,
|
|
481
|
+
status: !hasErrors && enrichedConflicts.length === 0 ? 'ok' : 'error',
|
|
482
|
+
results: enrichedResults,
|
|
483
|
+
conflicts: enrichedConflicts,
|
|
471
484
|
authorsUsed: Array.from(authorsUsed),
|
|
472
485
|
requiredArtifacts: {
|
|
473
486
|
comments: commentsRequired,
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry of OPC package parts this library reads or writes.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for part paths, content types and relationship types so the
|
|
5
|
+
* package plumbing never hard-codes them. Every `verified` entry is checked against
|
|
6
|
+
* documents saved by real Word in tests/package_parts_registry_tests.mjs
|
|
7
|
+
* (fixtures under tests/fixtures/word-authored/).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const WORD_ML = 'application/vnd.openxmlformats-officedocument.wordprocessingml';
|
|
11
|
+
const REL_OFFICE_DOC = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {object} PackagePartSpec
|
|
15
|
+
* @property {string} id
|
|
16
|
+
* @property {string|null} path Fixed part path, or null when the name is not fixed (headers/footers).
|
|
17
|
+
* @property {RegExp|null} pathPattern Matches every part name of this kind, when the name is not fixed.
|
|
18
|
+
* @property {string} contentType The value Word writes in [Content_Types].xml.
|
|
19
|
+
* @property {string[]} legacyContentTypes Known-wrong values written by earlier versions; safe to rewrite.
|
|
20
|
+
* @property {string} relType Relationship type from word/_rels/document.xml.rels.
|
|
21
|
+
* @property {string|null} relTarget Relative target for a fixed-path part.
|
|
22
|
+
* @property {boolean} verified Confirmed against a Word-saved document.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** @type {ReadonlyArray<Readonly<PackagePartSpec>>} */
|
|
26
|
+
const PART_SPECS = Object.freeze([
|
|
27
|
+
{ id: 'document', path: 'word/document.xml', pathPattern: null, contentType: `${WORD_ML}.document.main+xml`, legacyContentTypes: [], relType: `${REL_OFFICE_DOC}/officeDocument`, relTarget: null, verified: true },
|
|
28
|
+
{ id: 'numbering', path: 'word/numbering.xml', pathPattern: null, contentType: `${WORD_ML}.numbering+xml`, legacyContentTypes: [], relType: `${REL_OFFICE_DOC}/numbering`, relTarget: 'numbering.xml', verified: false },
|
|
29
|
+
{ id: 'comments', path: 'word/comments.xml', pathPattern: null, contentType: `${WORD_ML}.comments+xml`, legacyContentTypes: [], relType: `${REL_OFFICE_DOC}/comments`, relTarget: 'comments.xml', verified: true },
|
|
30
|
+
{
|
|
31
|
+
id: 'commentsExtended', path: 'word/commentsExtended.xml', pathPattern: null,
|
|
32
|
+
contentType: `${WORD_ML}.commentsExtended+xml`,
|
|
33
|
+
// Written by this package through 0.8.0; Word does not recognize it and offers to repair the file.
|
|
34
|
+
legacyContentTypes: ['application/vnd.ms-word.commentsExtended+xml'],
|
|
35
|
+
relType: 'http://schemas.microsoft.com/office/2011/relationships/commentsExtended', relTarget: 'commentsExtended.xml', verified: true
|
|
36
|
+
},
|
|
37
|
+
{ id: 'commentsIds', path: 'word/commentsIds.xml', pathPattern: null, contentType: `${WORD_ML}.commentsIds+xml`, legacyContentTypes: [], relType: 'http://schemas.microsoft.com/office/2016/09/relationships/commentsIds', relTarget: 'commentsIds.xml', verified: true },
|
|
38
|
+
{ id: 'commentsExtensible', path: 'word/commentsExtensible.xml', pathPattern: null, contentType: `${WORD_ML}.commentsExtensible+xml`, legacyContentTypes: [], relType: 'http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible', relTarget: 'commentsExtensible.xml', verified: true },
|
|
39
|
+
// Not yet produced by any Word fixture (Word only writes people.xml for signed-in authors): unverified.
|
|
40
|
+
{ id: 'people', path: 'word/people.xml', pathPattern: null, contentType: `${WORD_ML}.people+xml`, legacyContentTypes: [], relType: 'http://schemas.microsoft.com/office/2011/relationships/people', relTarget: 'people.xml', verified: false },
|
|
41
|
+
{ id: 'header', path: null, pathPattern: /^word\/header\d+\.xml$/i, contentType: `${WORD_ML}.header+xml`, legacyContentTypes: [], relType: `${REL_OFFICE_DOC}/header`, relTarget: null, verified: true },
|
|
42
|
+
{ id: 'footer', path: null, pathPattern: /^word\/footer\d+\.xml$/i, contentType: `${WORD_ML}.footer+xml`, legacyContentTypes: [], relType: `${REL_OFFICE_DOC}/footer`, relTarget: null, verified: true }
|
|
43
|
+
].map(spec => Object.freeze(spec)));
|
|
44
|
+
|
|
45
|
+
const BY_ID = new Map(PART_SPECS.map(spec => [spec.id, spec]));
|
|
46
|
+
|
|
47
|
+
/** Returns the spec for a registry id; throws for an unknown id so typos fail loudly. */
|
|
48
|
+
export function getPartSpec(id) {
|
|
49
|
+
const spec = BY_ID.get(id);
|
|
50
|
+
if (!spec) throw new Error(`Unknown package part: ${id}`);
|
|
51
|
+
return spec;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** All registered part specs. */
|
|
55
|
+
export function listPartSpecs() {
|
|
56
|
+
return PART_SPECS;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Normalizes '/word/x.xml' or 'word/x.xml' to 'word/x.xml'. */
|
|
60
|
+
function toEntryName(partName) {
|
|
61
|
+
return String(partName || '').replace(/^\/+/, '');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Finds the spec that owns a part name (with or without a leading slash), or null. */
|
|
65
|
+
export function findPartSpecByPartName(partName) {
|
|
66
|
+
const name = toEntryName(partName);
|
|
67
|
+
const lower = name.toLowerCase();
|
|
68
|
+
return PART_SPECS.find(spec => spec.path ? spec.path.toLowerCase() === lower : spec.pathPattern?.test(name)) || null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* When an override's content type is a value earlier versions of this package are known to have
|
|
73
|
+
* written wrongly for that part, returns the correct one; otherwise null. Deliberately does not
|
|
74
|
+
* "correct" types it has no evidence about (for example macro-enabled document types).
|
|
75
|
+
*/
|
|
76
|
+
export function correctedContentTypeFor(partName, actualContentType) {
|
|
77
|
+
const spec = findPartSpecByPartName(partName);
|
|
78
|
+
if (!spec || !spec.legacyContentTypes.includes(actualContentType)) return null;
|
|
79
|
+
return spec.contentType;
|
|
80
|
+
}
|