@ansonlai/docx-redline-js 0.8.4 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +679 -460
- package/CHANGELOG.md +208 -1
- package/README.md +152 -16
- package/core/current-properties.js +136 -0
- package/core/paragraph-text.js +211 -209
- package/core/types.js +9 -3
- package/core/word-revision-semantics.js +256 -0
- package/core/word-xml.js +160 -118
- package/dist/docx-redline-js.esm.js +4338 -796
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +111 -108
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +4463 -921
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +4463 -921
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_FAST_START.md +2 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
- package/docs/SKILL_AUTHORING.md +169 -150
- package/docs/TESTING.md +1146 -1059
- package/docs/schemas/document-operations.schema.json +55 -2
- package/document/docx-document.js +1022 -1010
- package/engine/oxml-engine.js +21 -22
- package/engine/paragraph-mark-revisions.js +292 -0
- package/engine/reconstruction-mapper.js +312 -311
- package/engine/reconstruction-writer.js +67 -10
- package/engine/rpr-helpers.js +42 -0
- package/engine/run-builders.js +0 -73
- package/engine/surgical-diff-application.js +604 -605
- package/engine/table-mode.js +63 -63
- package/index.d.ts +53 -2
- package/index.js +13 -2
- package/node/cli-help.js +1 -0
- package/node/cli.js +21 -3
- package/orchestration/list-structural-fallback.js +67 -9
- package/package.json +1 -1
- package/pipeline/diff-engine.js +130 -1
- package/pipeline/ingestion-export.js +227 -243
- package/pipeline/ingestion-paragraph.js +93 -98
- package/pipeline/list-generation.js +415 -419
- package/pipeline/markdown-processor.js +5 -2
- package/pipeline/pipeline.js +206 -204
- package/scripts/export-validation-fixtures.mjs +19 -6
- package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
- package/scripts/lib/word-coverage-catalogue.mjs +7 -2
- package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
- package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
- package/scripts/word-com-differential.ps1 +443 -335
- package/scripts/word-com-visual-suite.ps1 +2 -2
- package/scripts/word-revision-semantics-matrix.ps1 +108 -0
- package/services/batch-operation-orchestrator.js +23 -3
- package/services/capture-engine.js +228 -226
- package/services/comment-replies.js +1 -1
- package/services/document-operation-applier.js +214 -0
- package/services/document-operation-contract.js +76 -0
- package/services/document-operation-mutations.js +688 -51
- package/services/error-recovery.js +17 -0
- package/services/inspection-comparison.js +82 -0
- package/services/list-conversion-diagnostics.js +296 -0
- package/services/list-format-change.js +586 -0
- package/services/list-format-mutation.js +416 -0
- package/services/list-format.js +305 -0
- package/services/numbering-allocation.js +642 -0
- package/services/numbering-helpers.js +354 -524
- package/services/operation-batch-compiler.js +183 -11
- package/services/operation-preflight.js +43 -33
- package/services/receipt-collector.js +288 -288
- package/services/revision-comment-management.js +38 -5
- package/services/revision-fidelity-verifier.js +484 -0
- package/services/standalone-docx-plumbing.js +627 -631
- package/services/standalone-operation-runner.d.ts +182 -5
- package/services/table-reconciliation.js +59 -7
|
@@ -1,288 +1,288 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Internal mutation receipt collector for tracking allocated revision,
|
|
3
|
-
* comment, numbering, and relationship IDs per operation.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export class ReceiptCollector {
|
|
7
|
-
constructor() {
|
|
8
|
-
this.receipts = [];
|
|
9
|
-
this.activeReceipt = null;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
beginOperation(operationIndex, operationId = null, authorUsed = null) {
|
|
13
|
-
this.activeReceipt = {
|
|
14
|
-
operationIndex: typeof operationIndex === 'number' ? operationIndex : 1,
|
|
15
|
-
...(operationId ? { operationId: String(operationId) } : {}),
|
|
16
|
-
attemptedDisposition: 'applied',
|
|
17
|
-
finalDisposition: 'applied',
|
|
18
|
-
committed: true,
|
|
19
|
-
...(authorUsed ? { authorUsed: String(authorUsed) } : {}),
|
|
20
|
-
revisionItems: [],
|
|
21
|
-
commentIds: [],
|
|
22
|
-
numberingIds: [],
|
|
23
|
-
relationshipIds: [],
|
|
24
|
-
affectedTargets: [],
|
|
25
|
-
warnings: []
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
recordRevision(id, kind = 'structural', partName = 'word/document.xml') {
|
|
30
|
-
if (!this.activeReceipt || id == null) return;
|
|
31
|
-
const strId = String(id);
|
|
32
|
-
if (!this.activeReceipt.revisionItems.some(item => item.id === strId && item.kind === kind && item.partName === partName)) {
|
|
33
|
-
this.activeReceipt.revisionItems.push({
|
|
34
|
-
id: strId,
|
|
35
|
-
kind,
|
|
36
|
-
partName
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
recordComment(id, _partName = 'word/comments.xml') {
|
|
42
|
-
if (!this.activeReceipt || id == null) return;
|
|
43
|
-
const strId = String(id);
|
|
44
|
-
if (!this.activeReceipt.commentIds.includes(strId)) {
|
|
45
|
-
this.activeReceipt.commentIds.push(strId);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
recordNumbering(id, _partName = 'word/numbering.xml') {
|
|
50
|
-
if (!this.activeReceipt || id == null) return;
|
|
51
|
-
const strId = String(id);
|
|
52
|
-
if (!this.activeReceipt.numberingIds.includes(strId)) {
|
|
53
|
-
this.activeReceipt.numberingIds.push(strId);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
recordRelationship(id, _partName = 'word/_rels/document.xml.rels') {
|
|
58
|
-
if (!this.activeReceipt || id == null) return;
|
|
59
|
-
const strId = String(id);
|
|
60
|
-
if (!this.activeReceipt.relationshipIds.includes(strId)) {
|
|
61
|
-
this.activeReceipt.relationshipIds.push(strId);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
recordAffectedTarget(target) {
|
|
66
|
-
if (!this.activeReceipt || !target) return;
|
|
67
|
-
this.activeReceipt.affectedTargets.push(JSON.parse(JSON.stringify(target)));
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
recordWarning(warning) {
|
|
71
|
-
if (!this.activeReceipt || !warning) return;
|
|
72
|
-
this.activeReceipt.warnings.push(String(warning));
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
commitOperation(disposition = 'applied') {
|
|
76
|
-
if (!this.activeReceipt) return null;
|
|
77
|
-
this.activeReceipt.attemptedDisposition = disposition;
|
|
78
|
-
this.activeReceipt.finalDisposition = disposition;
|
|
79
|
-
this.activeReceipt.committed = disposition === 'applied';
|
|
80
|
-
const committed = JSON.parse(JSON.stringify(this.activeReceipt));
|
|
81
|
-
this.receipts.push(committed);
|
|
82
|
-
this.activeReceipt = null;
|
|
83
|
-
return committed;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
abortOperation(disposition = 'refused') {
|
|
87
|
-
if (!this.activeReceipt) return null;
|
|
88
|
-
this.activeReceipt.attemptedDisposition = disposition;
|
|
89
|
-
this.activeReceipt.finalDisposition = disposition;
|
|
90
|
-
this.activeReceipt.committed = false;
|
|
91
|
-
const aborted = JSON.parse(JSON.stringify(this.activeReceipt));
|
|
92
|
-
this.activeReceipt = null;
|
|
93
|
-
return aborted;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
createSavepoint() {
|
|
97
|
-
return {
|
|
98
|
-
receipts: JSON.parse(JSON.stringify(this.receipts)),
|
|
99
|
-
activeReceipt: this.activeReceipt ? JSON.parse(JSON.stringify(this.activeReceipt)) : null
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
restoreSavepoint(savepoint) {
|
|
104
|
-
if (!savepoint) return;
|
|
105
|
-
this.receipts = Array.isArray(savepoint.receipts)
|
|
106
|
-
? JSON.parse(JSON.stringify(savepoint.receipts))
|
|
107
|
-
: [];
|
|
108
|
-
this.activeReceipt = savepoint.activeReceipt
|
|
109
|
-
? JSON.parse(JSON.stringify(savepoint.activeReceipt))
|
|
110
|
-
: null;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
clear() {
|
|
114
|
-
this.receipts = [];
|
|
115
|
-
this.activeReceipt = null;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
markRolledBack() {
|
|
119
|
-
for (const receipt of this.receipts) {
|
|
120
|
-
if (receipt.attemptedDisposition === 'applied') {
|
|
121
|
-
receipt.finalDisposition = 'rolled_back';
|
|
122
|
-
receipt.committed = false;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
this.activeReceipt = null;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
getReceipts() {
|
|
129
|
-
return JSON.parse(JSON.stringify(this.receipts));
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
getCurrentReceipt() {
|
|
133
|
-
return this.activeReceipt ? JSON.parse(JSON.stringify(this.activeReceipt)) : null;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Creates an empty receipt structure for unattempted, refused, or no-op operations.
|
|
139
|
-
*
|
|
140
|
-
* @param {number} operationIndex
|
|
141
|
-
* @param {string|null} [operationId]
|
|
142
|
-
* @param {string|null} [authorUsed]
|
|
143
|
-
* @param {'applied'|'no_change'|'refused'|'not_attempted'} [disposition]
|
|
144
|
-
* @returns {Object}
|
|
145
|
-
*/
|
|
146
|
-
export function createEmptyReceipt(operationIndex, operationId = null, authorUsed = null, disposition = 'not_attempted') {
|
|
147
|
-
return {
|
|
148
|
-
operationIndex: typeof operationIndex === 'number' ? operationIndex : 1,
|
|
149
|
-
...(operationId ? { operationId: String(operationId) } : {}),
|
|
150
|
-
attemptedDisposition: disposition,
|
|
151
|
-
finalDisposition: disposition,
|
|
152
|
-
committed: false,
|
|
153
|
-
...(authorUsed ? { authorUsed: String(authorUsed) } : {}),
|
|
154
|
-
revisionItems: [],
|
|
155
|
-
commentIds: [],
|
|
156
|
-
numberingIds: [],
|
|
157
|
-
relationshipIds: [],
|
|
158
|
-
affectedTargets: [],
|
|
159
|
-
warnings: []
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Reconciles committed mutation receipts against the generated/committed output parts.
|
|
165
|
-
*
|
|
166
|
-
* @param {Object} parts
|
|
167
|
-
* @param {string} parts.documentXml - Serialized word/document.xml
|
|
168
|
-
* @param {string|null} [parts.commentsXml] - Serialized word/comments.xml
|
|
169
|
-
* @param {string|null} [parts.numberingXml] - Serialized word/numbering.xml
|
|
170
|
-
* @param {string[]} [parts.numberingXmlParts] - Additional numbering XML parts
|
|
171
|
-
* @param {string|null} [parts.relationshipsXml] - Serialized word/_rels/document.xml.rels
|
|
172
|
-
* @param {Array<Object>} receipts - Array of MutationReceipt objects
|
|
173
|
-
* @returns {{ valid: boolean, error?: { code: string, message: string } }}
|
|
174
|
-
*/
|
|
175
|
-
export function reconcileReceiptsAgainstOutput(parts, receipts) {
|
|
176
|
-
if (!Array.isArray(receipts) || receipts.length === 0) {
|
|
177
|
-
return { valid: true };
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const committedReceipts = receipts.filter(r => r && r.committed === true && r.finalDisposition === 'applied');
|
|
181
|
-
if (committedReceipts.length === 0) {
|
|
182
|
-
return { valid: true };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const revisionIdSet = new Set();
|
|
186
|
-
if (parts?.documentXml && typeof parts.documentXml === 'string') {
|
|
187
|
-
const revRegex = /<(?:w:)?(?:ins|del|rPrChange|pPrChange|moveFrom|moveTo)\b[^>]*?\b(?:w:)?id="([^"]+)"/g;
|
|
188
|
-
let m;
|
|
189
|
-
while ((m = revRegex.exec(parts.documentXml)) !== null) {
|
|
190
|
-
revisionIdSet.add(m[1]);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const commentIdSet = new Set();
|
|
195
|
-
if (parts?.commentsXml && typeof parts.commentsXml === 'string') {
|
|
196
|
-
const comRegex = /<(?:w:)?comment\b[^>]*?\b(?:w:)?id="([^"]+)"/g;
|
|
197
|
-
let m;
|
|
198
|
-
while ((m = comRegex.exec(parts.commentsXml)) !== null) {
|
|
199
|
-
commentIdSet.add(m[1]);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const numberingIdSet = new Set();
|
|
204
|
-
const combinedNumbering = [parts?.numberingXml, ...(parts?.numberingXmlParts || [])].filter(Boolean).join('\n');
|
|
205
|
-
if (combinedNumbering) {
|
|
206
|
-
const numRegex = /<(?:w:)?num\b[^>]*?\b(?:w:)?numId="([^"]+)"/g;
|
|
207
|
-
let m;
|
|
208
|
-
while ((m = numRegex.exec(combinedNumbering)) !== null) {
|
|
209
|
-
numberingIdSet.add(m[1]);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
if (parts?.documentXml && typeof parts.documentXml === 'string') {
|
|
213
|
-
const docNumRegex = /<(?:w:)?numId\b[^>]*?\b(?:w:)?val="([^"]+)"/g;
|
|
214
|
-
let m;
|
|
215
|
-
while ((m = docNumRegex.exec(parts.documentXml)) !== null) {
|
|
216
|
-
numberingIdSet.add(m[1]);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const relIdSet = new Set();
|
|
221
|
-
if (parts?.relationshipsXml && typeof parts.relationshipsXml === 'string') {
|
|
222
|
-
const relRegex = /<Relationship\b[^>]*?\bId="([^"]+)"/g;
|
|
223
|
-
let m;
|
|
224
|
-
while ((m = relRegex.exec(parts.relationshipsXml)) !== null) {
|
|
225
|
-
relIdSet.add(m[1]);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
for (const receipt of committedReceipts) {
|
|
230
|
-
if (Array.isArray(receipt.revisionItems)) {
|
|
231
|
-
for (const item of receipt.revisionItems) {
|
|
232
|
-
if (item.partName === 'word/document.xml' && !revisionIdSet.has(String(item.id))) {
|
|
233
|
-
return {
|
|
234
|
-
valid: false,
|
|
235
|
-
error: {
|
|
236
|
-
code: 'RECEIPT_RECONCILIATION_FAILED',
|
|
237
|
-
message: `Committed revision id '${item.id}' (kind: ${item.kind}) was not found in word/document.xml.`
|
|
238
|
-
}
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (Array.isArray(receipt.commentIds)) {
|
|
245
|
-
for (const id of receipt.commentIds) {
|
|
246
|
-
if (!commentIdSet.has(String(id))) {
|
|
247
|
-
return {
|
|
248
|
-
valid: false,
|
|
249
|
-
error: {
|
|
250
|
-
code: 'RECEIPT_RECONCILIATION_FAILED',
|
|
251
|
-
message: `Committed comment id '${id}' was not found in word/comments.xml.`
|
|
252
|
-
}
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
if (Array.isArray(receipt.numberingIds)) {
|
|
259
|
-
for (const id of receipt.numberingIds) {
|
|
260
|
-
if (!numberingIdSet.has(String(id))) {
|
|
261
|
-
return {
|
|
262
|
-
valid: false,
|
|
263
|
-
error: {
|
|
264
|
-
code: 'RECEIPT_RECONCILIATION_FAILED',
|
|
265
|
-
message: `Committed numbering id '${id}' was not found in numbering parts or document.`
|
|
266
|
-
}
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
if (parts?.relationshipsXml && Array.isArray(receipt.relationshipIds)) {
|
|
273
|
-
for (const id of receipt.relationshipIds) {
|
|
274
|
-
if (!relIdSet.has(String(id))) {
|
|
275
|
-
return {
|
|
276
|
-
valid: false,
|
|
277
|
-
error: {
|
|
278
|
-
code: 'RECEIPT_RECONCILIATION_FAILED',
|
|
279
|
-
message: `Committed relationship id '${id}' was not found in document.xml.rels.`
|
|
280
|
-
}
|
|
281
|
-
};
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
return { valid: true };
|
|
288
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Internal mutation receipt collector for tracking allocated revision,
|
|
3
|
+
* comment, numbering, and relationship IDs per operation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class ReceiptCollector {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.receipts = [];
|
|
9
|
+
this.activeReceipt = null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
beginOperation(operationIndex, operationId = null, authorUsed = null) {
|
|
13
|
+
this.activeReceipt = {
|
|
14
|
+
operationIndex: typeof operationIndex === 'number' ? operationIndex : 1,
|
|
15
|
+
...(operationId ? { operationId: String(operationId) } : {}),
|
|
16
|
+
attemptedDisposition: 'applied',
|
|
17
|
+
finalDisposition: 'applied',
|
|
18
|
+
committed: true,
|
|
19
|
+
...(authorUsed ? { authorUsed: String(authorUsed) } : {}),
|
|
20
|
+
revisionItems: [],
|
|
21
|
+
commentIds: [],
|
|
22
|
+
numberingIds: [],
|
|
23
|
+
relationshipIds: [],
|
|
24
|
+
affectedTargets: [],
|
|
25
|
+
warnings: []
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
recordRevision(id, kind = 'structural', partName = 'word/document.xml') {
|
|
30
|
+
if (!this.activeReceipt || id == null) return;
|
|
31
|
+
const strId = String(id);
|
|
32
|
+
if (!this.activeReceipt.revisionItems.some(item => item.id === strId && item.kind === kind && item.partName === partName)) {
|
|
33
|
+
this.activeReceipt.revisionItems.push({
|
|
34
|
+
id: strId,
|
|
35
|
+
kind,
|
|
36
|
+
partName
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
recordComment(id, _partName = 'word/comments.xml') {
|
|
42
|
+
if (!this.activeReceipt || id == null) return;
|
|
43
|
+
const strId = String(id);
|
|
44
|
+
if (!this.activeReceipt.commentIds.includes(strId)) {
|
|
45
|
+
this.activeReceipt.commentIds.push(strId);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
recordNumbering(id, _partName = 'word/numbering.xml') {
|
|
50
|
+
if (!this.activeReceipt || id == null) return;
|
|
51
|
+
const strId = String(id);
|
|
52
|
+
if (!this.activeReceipt.numberingIds.includes(strId)) {
|
|
53
|
+
this.activeReceipt.numberingIds.push(strId);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
recordRelationship(id, _partName = 'word/_rels/document.xml.rels') {
|
|
58
|
+
if (!this.activeReceipt || id == null) return;
|
|
59
|
+
const strId = String(id);
|
|
60
|
+
if (!this.activeReceipt.relationshipIds.includes(strId)) {
|
|
61
|
+
this.activeReceipt.relationshipIds.push(strId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
recordAffectedTarget(target) {
|
|
66
|
+
if (!this.activeReceipt || !target) return;
|
|
67
|
+
this.activeReceipt.affectedTargets.push(JSON.parse(JSON.stringify(target)));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
recordWarning(warning) {
|
|
71
|
+
if (!this.activeReceipt || !warning) return;
|
|
72
|
+
this.activeReceipt.warnings.push(String(warning));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
commitOperation(disposition = 'applied') {
|
|
76
|
+
if (!this.activeReceipt) return null;
|
|
77
|
+
this.activeReceipt.attemptedDisposition = disposition;
|
|
78
|
+
this.activeReceipt.finalDisposition = disposition;
|
|
79
|
+
this.activeReceipt.committed = disposition === 'applied';
|
|
80
|
+
const committed = JSON.parse(JSON.stringify(this.activeReceipt));
|
|
81
|
+
this.receipts.push(committed);
|
|
82
|
+
this.activeReceipt = null;
|
|
83
|
+
return committed;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
abortOperation(disposition = 'refused') {
|
|
87
|
+
if (!this.activeReceipt) return null;
|
|
88
|
+
this.activeReceipt.attemptedDisposition = disposition;
|
|
89
|
+
this.activeReceipt.finalDisposition = disposition;
|
|
90
|
+
this.activeReceipt.committed = false;
|
|
91
|
+
const aborted = JSON.parse(JSON.stringify(this.activeReceipt));
|
|
92
|
+
this.activeReceipt = null;
|
|
93
|
+
return aborted;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
createSavepoint() {
|
|
97
|
+
return {
|
|
98
|
+
receipts: JSON.parse(JSON.stringify(this.receipts)),
|
|
99
|
+
activeReceipt: this.activeReceipt ? JSON.parse(JSON.stringify(this.activeReceipt)) : null
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
restoreSavepoint(savepoint) {
|
|
104
|
+
if (!savepoint) return;
|
|
105
|
+
this.receipts = Array.isArray(savepoint.receipts)
|
|
106
|
+
? JSON.parse(JSON.stringify(savepoint.receipts))
|
|
107
|
+
: [];
|
|
108
|
+
this.activeReceipt = savepoint.activeReceipt
|
|
109
|
+
? JSON.parse(JSON.stringify(savepoint.activeReceipt))
|
|
110
|
+
: null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
clear() {
|
|
114
|
+
this.receipts = [];
|
|
115
|
+
this.activeReceipt = null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
markRolledBack() {
|
|
119
|
+
for (const receipt of this.receipts) {
|
|
120
|
+
if (receipt.attemptedDisposition === 'applied') {
|
|
121
|
+
receipt.finalDisposition = 'rolled_back';
|
|
122
|
+
receipt.committed = false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
this.activeReceipt = null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
getReceipts() {
|
|
129
|
+
return JSON.parse(JSON.stringify(this.receipts));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
getCurrentReceipt() {
|
|
133
|
+
return this.activeReceipt ? JSON.parse(JSON.stringify(this.activeReceipt)) : null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Creates an empty receipt structure for unattempted, refused, or no-op operations.
|
|
139
|
+
*
|
|
140
|
+
* @param {number} operationIndex
|
|
141
|
+
* @param {string|null} [operationId]
|
|
142
|
+
* @param {string|null} [authorUsed]
|
|
143
|
+
* @param {'applied'|'no_change'|'refused'|'not_attempted'} [disposition]
|
|
144
|
+
* @returns {Object}
|
|
145
|
+
*/
|
|
146
|
+
export function createEmptyReceipt(operationIndex, operationId = null, authorUsed = null, disposition = 'not_attempted') {
|
|
147
|
+
return {
|
|
148
|
+
operationIndex: typeof operationIndex === 'number' ? operationIndex : 1,
|
|
149
|
+
...(operationId ? { operationId: String(operationId) } : {}),
|
|
150
|
+
attemptedDisposition: disposition,
|
|
151
|
+
finalDisposition: disposition,
|
|
152
|
+
committed: false,
|
|
153
|
+
...(authorUsed ? { authorUsed: String(authorUsed) } : {}),
|
|
154
|
+
revisionItems: [],
|
|
155
|
+
commentIds: [],
|
|
156
|
+
numberingIds: [],
|
|
157
|
+
relationshipIds: [],
|
|
158
|
+
affectedTargets: [],
|
|
159
|
+
warnings: []
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Reconciles committed mutation receipts against the generated/committed output parts.
|
|
165
|
+
*
|
|
166
|
+
* @param {Object} parts
|
|
167
|
+
* @param {string} parts.documentXml - Serialized word/document.xml
|
|
168
|
+
* @param {string|null} [parts.commentsXml] - Serialized word/comments.xml
|
|
169
|
+
* @param {string|null} [parts.numberingXml] - Serialized word/numbering.xml
|
|
170
|
+
* @param {string[]} [parts.numberingXmlParts] - Additional numbering XML parts
|
|
171
|
+
* @param {string|null} [parts.relationshipsXml] - Serialized word/_rels/document.xml.rels
|
|
172
|
+
* @param {Array<Object>} receipts - Array of MutationReceipt objects
|
|
173
|
+
* @returns {{ valid: boolean, error?: { code: string, message: string } }}
|
|
174
|
+
*/
|
|
175
|
+
export function reconcileReceiptsAgainstOutput(parts, receipts) {
|
|
176
|
+
if (!Array.isArray(receipts) || receipts.length === 0) {
|
|
177
|
+
return { valid: true };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const committedReceipts = receipts.filter(r => r && r.committed === true && r.finalDisposition === 'applied');
|
|
181
|
+
if (committedReceipts.length === 0) {
|
|
182
|
+
return { valid: true };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const revisionIdSet = new Set();
|
|
186
|
+
if (parts?.documentXml && typeof parts.documentXml === 'string') {
|
|
187
|
+
const revRegex = /<(?:w:)?(?:ins|del|rPrChange|pPrChange|moveFrom|moveTo)\b[^>]*?\b(?:w:)?id="([^"]+)"/g;
|
|
188
|
+
let m;
|
|
189
|
+
while ((m = revRegex.exec(parts.documentXml)) !== null) {
|
|
190
|
+
revisionIdSet.add(m[1]);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const commentIdSet = new Set();
|
|
195
|
+
if (parts?.commentsXml && typeof parts.commentsXml === 'string') {
|
|
196
|
+
const comRegex = /<(?:w:)?comment\b[^>]*?\b(?:w:)?id="([^"]+)"/g;
|
|
197
|
+
let m;
|
|
198
|
+
while ((m = comRegex.exec(parts.commentsXml)) !== null) {
|
|
199
|
+
commentIdSet.add(m[1]);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const numberingIdSet = new Set();
|
|
204
|
+
const combinedNumbering = [parts?.numberingXml, ...(parts?.numberingXmlParts || [])].filter(Boolean).join('\n');
|
|
205
|
+
if (combinedNumbering) {
|
|
206
|
+
const numRegex = /<(?:w:)?num\b[^>]*?\b(?:w:)?numId="([^"]+)"/g;
|
|
207
|
+
let m;
|
|
208
|
+
while ((m = numRegex.exec(combinedNumbering)) !== null) {
|
|
209
|
+
numberingIdSet.add(m[1]);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (parts?.documentXml && typeof parts.documentXml === 'string') {
|
|
213
|
+
const docNumRegex = /<(?:w:)?numId\b[^>]*?\b(?:w:)?val="([^"]+)"/g;
|
|
214
|
+
let m;
|
|
215
|
+
while ((m = docNumRegex.exec(parts.documentXml)) !== null) {
|
|
216
|
+
numberingIdSet.add(m[1]);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const relIdSet = new Set();
|
|
221
|
+
if (parts?.relationshipsXml && typeof parts.relationshipsXml === 'string') {
|
|
222
|
+
const relRegex = /<Relationship\b[^>]*?\bId="([^"]+)"/g;
|
|
223
|
+
let m;
|
|
224
|
+
while ((m = relRegex.exec(parts.relationshipsXml)) !== null) {
|
|
225
|
+
relIdSet.add(m[1]);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const receipt of committedReceipts) {
|
|
230
|
+
if (Array.isArray(receipt.revisionItems)) {
|
|
231
|
+
for (const item of receipt.revisionItems) {
|
|
232
|
+
if (item.partName === 'word/document.xml' && !revisionIdSet.has(String(item.id))) {
|
|
233
|
+
return {
|
|
234
|
+
valid: false,
|
|
235
|
+
error: {
|
|
236
|
+
code: 'RECEIPT_RECONCILIATION_FAILED',
|
|
237
|
+
message: `Committed revision id '${item.id}' (kind: ${item.kind}) was not found in word/document.xml.`
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (Array.isArray(receipt.commentIds)) {
|
|
245
|
+
for (const id of receipt.commentIds) {
|
|
246
|
+
if (!commentIdSet.has(String(id))) {
|
|
247
|
+
return {
|
|
248
|
+
valid: false,
|
|
249
|
+
error: {
|
|
250
|
+
code: 'RECEIPT_RECONCILIATION_FAILED',
|
|
251
|
+
message: `Committed comment id '${id}' was not found in word/comments.xml.`
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (Array.isArray(receipt.numberingIds)) {
|
|
259
|
+
for (const id of receipt.numberingIds) {
|
|
260
|
+
if (!numberingIdSet.has(String(id))) {
|
|
261
|
+
return {
|
|
262
|
+
valid: false,
|
|
263
|
+
error: {
|
|
264
|
+
code: 'RECEIPT_RECONCILIATION_FAILED',
|
|
265
|
+
message: `Committed numbering id '${id}' was not found in numbering parts or document.`
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (parts?.relationshipsXml && Array.isArray(receipt.relationshipIds)) {
|
|
273
|
+
for (const id of receipt.relationshipIds) {
|
|
274
|
+
if (!relIdSet.has(String(id))) {
|
|
275
|
+
return {
|
|
276
|
+
valid: false,
|
|
277
|
+
error: {
|
|
278
|
+
code: 'RECEIPT_RECONCILIATION_FAILED',
|
|
279
|
+
message: `Committed relationship id '${id}' was not found in document.xml.rels.`
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { valid: true };
|
|
288
|
+
}
|
|
@@ -176,12 +176,23 @@ function getNextWordParagraph(paragraph) {
|
|
|
176
176
|
return null;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
|
|
179
|
+
/**
|
|
180
|
+
* Resolves a paragraph whose mark is being removed. `view` is 'accept' or 'reject'.
|
|
181
|
+
* A table cell must keep its last paragraph: Word keeps it on Reject even when the
|
|
182
|
+
* cell has other paragraphs, and on Accept only when it is the cell's sole paragraph
|
|
183
|
+
* (otherwise the previous paragraph absorbs the mark). `keptCellParagraphs`
|
|
184
|
+
* collects the sole kept paragraphs so Accept can drop fully-deleted tables.
|
|
185
|
+
*/
|
|
186
|
+
function mergeParagraphIntoNextAndRemove(paragraph, view = 'accept', keptCellParagraphs = null) {
|
|
180
187
|
if (!paragraph?.parentNode) return false;
|
|
181
188
|
const nextParagraph = getNextWordParagraph(paragraph);
|
|
182
189
|
if (!nextParagraph) {
|
|
183
|
-
if (isWordElement(paragraph.parentNode, 'tc')
|
|
184
|
-
|
|
190
|
+
if (isWordElement(paragraph.parentNode, 'tc')) {
|
|
191
|
+
const sole = getWordElementsByLocalName(paragraph.parentNode, 'p').length <= 1;
|
|
192
|
+
if (view === 'reject' || sole) {
|
|
193
|
+
if (sole && keptCellParagraphs) keptCellParagraphs.add(paragraph);
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
185
196
|
}
|
|
186
197
|
return removeNode(paragraph);
|
|
187
198
|
}
|
|
@@ -227,6 +238,7 @@ export function acceptTrackedChangesInOoxml(oxml, options = {}) {
|
|
|
227
238
|
const { xmlDoc, serializer } = parseResult;
|
|
228
239
|
warnings.push(...(parseResult.warnings || []));
|
|
229
240
|
let acceptedCount = 0;
|
|
241
|
+
const keptCellParagraphs = new Set();
|
|
230
242
|
|
|
231
243
|
for (const insNode of getWordElementsByLocalName(xmlDoc, 'ins')) {
|
|
232
244
|
if (!insNode.parentNode || !authorMatchesNode(insNode, filter)) continue;
|
|
@@ -245,7 +257,8 @@ export function acceptTrackedChangesInOoxml(oxml, options = {}) {
|
|
|
245
257
|
if (!delNode.parentNode || !authorMatchesNode(delNode, filter)) continue;
|
|
246
258
|
const paragraphMark = getContainingParagraphMarkRevision(delNode);
|
|
247
259
|
if (paragraphMark) {
|
|
248
|
-
if (mergeParagraphIntoNextAndRemove(paragraphMark)) acceptedCount += 1;
|
|
260
|
+
if (mergeParagraphIntoNextAndRemove(paragraphMark, 'accept', keptCellParagraphs)) acceptedCount += 1;
|
|
261
|
+
else if (removeNode(delNode)) acceptedCount += 1;
|
|
249
262
|
continue;
|
|
250
263
|
}
|
|
251
264
|
if (isTableRowRevisionMarker(delNode)) {
|
|
@@ -256,6 +269,19 @@ export function acceptTrackedChangesInOoxml(oxml, options = {}) {
|
|
|
256
269
|
if (removeNode(delNode)) acceptedCount += 1;
|
|
257
270
|
}
|
|
258
271
|
|
|
272
|
+
// Word removes a table outright when accepting leaves every cell with only a
|
|
273
|
+
// deleted, content-free paragraph.
|
|
274
|
+
for (const tableNode of getWordElementsByLocalName(xmlDoc, 'tbl')) {
|
|
275
|
+
if (!tableNode.parentNode) continue;
|
|
276
|
+
const cells = getWordElementsByLocalName(tableNode, 'tc');
|
|
277
|
+
const allDeleted = cells.length > 0 && cells.every(cell => {
|
|
278
|
+
const paragraphs = getWordElementsByLocalName(cell, 'p');
|
|
279
|
+
return paragraphs.length === 1 && keptCellParagraphs.has(paragraphs[0])
|
|
280
|
+
&& !Array.from(paragraphs[0].childNodes || []).some(child => !isWordElement(child, 'pPr'));
|
|
281
|
+
});
|
|
282
|
+
if (allDeleted && removeNode(tableNode)) acceptedCount += 1;
|
|
283
|
+
}
|
|
284
|
+
|
|
259
285
|
for (const moveFromNode of getWordElementsByLocalName(xmlDoc, 'moveFrom')) {
|
|
260
286
|
if (!moveFromNode.parentNode || !authorMatchesNode(moveFromNode, filter)) continue;
|
|
261
287
|
if (removeNode(moveFromNode)) acceptedCount += 1;
|
|
@@ -328,6 +354,11 @@ function rejectPropertyChangeNode(changeNode, localName) {
|
|
|
328
354
|
}
|
|
329
355
|
|
|
330
356
|
const toAppend = Array.from(historicalNode.childNodes || []);
|
|
357
|
+
// pPrChange records only paragraph properties: the paragraph-mark rPr and sectPr
|
|
358
|
+
// live in the same pPr but are not part of the recorded change, so they survive.
|
|
359
|
+
const keepNames = baseLocalName.toLowerCase() === 'ppr' ? ['rpr', 'sectpr'] : [];
|
|
360
|
+
const kept = Array.from(parent.childNodes || []).filter(child => child.nodeType === 1
|
|
361
|
+
&& child.namespaceURI === NS_W && keepNames.includes(String(child.localName || '').toLowerCase()));
|
|
331
362
|
while (parent.firstChild) {
|
|
332
363
|
parent.removeChild(parent.firstChild);
|
|
333
364
|
}
|
|
@@ -335,6 +366,7 @@ function rejectPropertyChangeNode(changeNode, localName) {
|
|
|
335
366
|
const clone = xmlDocImportNode(parent.ownerDocument, node);
|
|
336
367
|
parent.appendChild(clone);
|
|
337
368
|
}
|
|
369
|
+
for (const node of kept) parent.appendChild(node);
|
|
338
370
|
return true;
|
|
339
371
|
}
|
|
340
372
|
|
|
@@ -415,7 +447,8 @@ export function rejectTrackedChangesInOoxml(oxml, options = {}) {
|
|
|
415
447
|
if (!insNode.parentNode || !authorMatchesNode(insNode, filter)) continue;
|
|
416
448
|
const paragraphMark = getContainingParagraphMarkRevision(insNode);
|
|
417
449
|
if (paragraphMark) {
|
|
418
|
-
if (mergeParagraphIntoNextAndRemove(paragraphMark)) rejectedCount += 1;
|
|
450
|
+
if (mergeParagraphIntoNextAndRemove(paragraphMark, 'reject')) rejectedCount += 1;
|
|
451
|
+
else if (removeNode(insNode)) rejectedCount += 1;
|
|
419
452
|
continue;
|
|
420
453
|
}
|
|
421
454
|
if (isTableRowRevisionMarker(insNode)) {
|