@ansonlai/docx-redline-js 0.4.0 → 0.5.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/AGENTS.md +589 -287
- package/ARCHITECTURE.md +215 -9
- package/CHANGELOG.md +319 -0
- package/README.md +604 -360
- package/adapters/config.js +45 -43
- package/bin/docx-redline.js +3 -0
- package/core/list-targeting.js +101 -110
- package/core/paragraph-targeting.js +501 -61
- package/core/paragraph-text.js +209 -0
- package/core/revision-cloning.js +38 -0
- package/core/types.js +64 -10
- package/core/word-xml.js +43 -15
- package/dist/docx-redline-js.esm.js +2849 -466
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +87 -76
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +342 -23
- package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
- package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
- package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
- package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
- package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
- package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
- package/docs/schemas/document-operations.schema.json +109 -0
- package/docs/test-comparison-dashboard.html +4250 -7
- package/engine/formatting-removal.js +11 -2
- package/engine/oxml-engine.js +491 -336
- package/engine/reconstruction-mode.js +15 -14
- package/engine/reconstruction-writer.js +247 -142
- package/engine/route-selection.js +35 -0
- package/engine/rpr-helpers.js +334 -35
- package/engine/run-builders.js +239 -196
- package/engine/surgical-diff-application.js +222 -37
- package/engine/surgical-mode.js +134 -6
- package/engine/surgical-spans.js +52 -1
- package/engine/table-cell-context.js +3 -6
- package/engine/table-mode.js +1 -1
- package/index.d.ts +234 -6
- package/index.js +24 -1
- package/node/cli.js +317 -0
- package/node/docx-document.js +302 -0
- package/node/index.d.ts +31 -0
- package/node/index.js +2 -0
- package/node/zip-archive.js +52 -0
- package/orchestration/list-markdown.js +10 -16
- package/orchestration/list-parsing.js +7 -12
- package/orchestration/list-structural-fallback.js +21 -10
- package/package.json +24 -3
- package/pipeline/content-analysis.js +12 -17
- package/pipeline/ingestion-export.js +3 -31
- package/pipeline/ingestion-paragraph.js +10 -5
- package/pipeline/list-generation.js +150 -55
- package/pipeline/list-markers.js +70 -3
- package/pipeline/serialization.js +4 -2
- package/pipeline/structured-content.js +160 -0
- package/scripts/apply_changes.mjs +27 -0
- package/scripts/benchmark-operation-session.mjs +137 -0
- package/scripts/benchmark-targeting-browser.html +74 -0
- package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
- package/scripts/benchmark-test-runner.mjs +59 -0
- package/scripts/build-test-dashboard.mjs +23 -0
- package/scripts/export-lane1-fixtures.mjs +380 -0
- package/scripts/export-reredline-stress-fixtures.mjs +317 -0
- package/scripts/export-validation-fixtures.mjs +1 -1
- package/scripts/extract_text.mjs +7 -0
- package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
- package/scripts/generate-test-dashboard.mjs +362 -11
- package/scripts/lib/word-coverage-catalogue.mjs +6 -2
- package/scripts/profile-route-selection.mjs +19 -0
- package/scripts/render-agenda-multilevel.mjs +0 -5
- package/scripts/render-multilevel-cases.mjs +0 -1
- package/scripts/run-tests.mjs +107 -35
- package/scripts/word-com-corpus-suite.ps1 +3 -0
- package/scripts/word-com-differential.ps1 +64 -4
- package/scripts/word-com-suite.ps1 +3 -0
- package/services/batch-operation-orchestrator.js +494 -0
- package/services/capture-engine.js +226 -0
- package/services/comment-builders.js +23 -6
- package/services/comment-engine.js +108 -47
- package/services/comment-locator.js +187 -82
- package/services/comment-replies.js +95 -0
- package/services/document-inspection.js +258 -0
- package/services/document-operation-applier.js +372 -0
- package/services/document-operation-contract.js +323 -0
- package/services/document-operation-mutations.js +1733 -0
- package/services/document-operation-session.js +258 -0
- package/services/numbering-service.js +14 -5
- package/services/operation-heuristics.js +173 -0
- package/services/operation-preflight.js +366 -0
- package/services/receipt-collector.js +288 -0
- package/services/revision-comment-management.js +37 -5
- package/services/revision-token.js +290 -0
- package/services/standalone-docx-plumbing.js +123 -8
- package/services/standalone-operation-runner.d.ts +296 -0
- package/services/standalone-operation-runner.js +10 -1455
- package/services/table-reconciliation.js +15 -6
- package/docs/VALIDATION.md +0 -183
- package/docs/WORD-MANUAL-REVIEW.md +0 -138
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
- /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { NS_W } from './types.js';
|
|
2
|
+
|
|
3
|
+
function localName(node) {
|
|
4
|
+
return String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Returns whether a node contributes to the selected revision view. */
|
|
8
|
+
export function isNodeVisibleInRevisionView(node, boundary = null, revisionView = 'accepted') {
|
|
9
|
+
const view = revisionView === 'current' ? 'accepted' : revisionView;
|
|
10
|
+
let cursor = node;
|
|
11
|
+
while (cursor && cursor !== boundary) {
|
|
12
|
+
const name = localName(cursor);
|
|
13
|
+
if (view === 'accepted' && (name === 'del' || name === 'moveFrom')) return false;
|
|
14
|
+
if (view === 'rejected' && (name === 'ins' || name === 'moveTo')) return false;
|
|
15
|
+
cursor = cursor.parentNode;
|
|
16
|
+
}
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Reads exact visible text from a run, including tabs, breaks and non-breaking hyphens. */
|
|
21
|
+
export function readCanonicalRunText(run, options = {}) {
|
|
22
|
+
const revisionView = options.revisionView === 'current' ? 'accepted' : (options.revisionView || 'accepted');
|
|
23
|
+
const boundary = options.boundary || null;
|
|
24
|
+
if (!isNodeVisibleInRevisionView(run, boundary, revisionView)) return '';
|
|
25
|
+
let text = '';
|
|
26
|
+
const visit = node => {
|
|
27
|
+
for (const child of Array.from(node?.childNodes || [])) {
|
|
28
|
+
if (child?.nodeType !== 1 || (child.namespaceURI && child.namespaceURI !== NS_W)) continue;
|
|
29
|
+
if (!isNodeVisibleInRevisionView(child, boundary, revisionView)) continue;
|
|
30
|
+
const name = localName(child);
|
|
31
|
+
if (name === 't' || (name === 'delText' && revisionView === 'rejected')) text += child.textContent || '';
|
|
32
|
+
else if (name === 'tab') text += '\t';
|
|
33
|
+
else if (name === 'br' || name === 'cr') text += '\n';
|
|
34
|
+
else if (name === 'noBreakHyphen') text += '\u2011';
|
|
35
|
+
else if (name === 'softHyphen') text += '\u00ad';
|
|
36
|
+
else visit(child);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
visit(run);
|
|
40
|
+
return text;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const attr = (node, name) => node?.getAttribute?.(`w:${name}`) || node?.getAttribute?.(name) || '';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extracts revision-view text segments from a paragraph or container node.
|
|
47
|
+
*
|
|
48
|
+
* Emits structural text pieces tracking revision ancestry (ins, del, moveFrom, moveTo),
|
|
49
|
+
* computes independent accepted and rejected UTF-16 cursor offsets, and merges
|
|
50
|
+
* adjacent pieces with compatible carrier containers and revision metadata.
|
|
51
|
+
*
|
|
52
|
+
* @param {Element | null | undefined} paragraph - Paragraph or container element
|
|
53
|
+
* @param {Object} [options={}] - Extraction options
|
|
54
|
+
* @param {boolean} [options.mergeRuns=true] - Merge adjacent runs sharing compatible container and revision metadata
|
|
55
|
+
* @returns {Array<{ text: string, kind: 'baseline' | 'insertion' | 'deletion' | 'move_from' | 'move_to', author?: string, revisionId?: string, acceptedStart: number | null, rejectedStart: number | null }>}
|
|
56
|
+
*/
|
|
57
|
+
export function extractParagraphRevisionSegments(paragraph, options = {}) {
|
|
58
|
+
if (!paragraph) return [];
|
|
59
|
+
|
|
60
|
+
const rawPieces = [];
|
|
61
|
+
|
|
62
|
+
function walk(node, currentRevision, currentCarrier) {
|
|
63
|
+
for (const child of Array.from(node?.childNodes || [])) {
|
|
64
|
+
if (child?.nodeType !== 1) continue;
|
|
65
|
+
if (child.namespaceURI && child.namespaceURI !== NS_W) continue;
|
|
66
|
+
|
|
67
|
+
const name = localName(child);
|
|
68
|
+
if (name === 'pPr' || name === 'rPr') continue;
|
|
69
|
+
|
|
70
|
+
let nextRevision = currentRevision;
|
|
71
|
+
if (name === 'ins') {
|
|
72
|
+
nextRevision = {
|
|
73
|
+
kind: 'insertion',
|
|
74
|
+
author: attr(child, 'author') || undefined,
|
|
75
|
+
revisionId: attr(child, 'id') || undefined
|
|
76
|
+
};
|
|
77
|
+
} else if (name === 'del') {
|
|
78
|
+
nextRevision = {
|
|
79
|
+
kind: 'deletion',
|
|
80
|
+
author: attr(child, 'author') || undefined,
|
|
81
|
+
revisionId: attr(child, 'id') || undefined
|
|
82
|
+
};
|
|
83
|
+
} else if (name === 'moveFrom') {
|
|
84
|
+
nextRevision = {
|
|
85
|
+
kind: 'move_from',
|
|
86
|
+
author: attr(child, 'author') || undefined,
|
|
87
|
+
revisionId: attr(child, 'id') || undefined
|
|
88
|
+
};
|
|
89
|
+
} else if (name === 'moveTo') {
|
|
90
|
+
nextRevision = {
|
|
91
|
+
kind: 'move_to',
|
|
92
|
+
author: attr(child, 'author') || undefined,
|
|
93
|
+
revisionId: attr(child, 'id') || undefined
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let nextCarrier = currentCarrier;
|
|
98
|
+
if (name === 'r') {
|
|
99
|
+
nextCarrier = child;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let text = null;
|
|
103
|
+
let kind = nextRevision ? nextRevision.kind : 'baseline';
|
|
104
|
+
let author = nextRevision?.author;
|
|
105
|
+
let revisionId = nextRevision?.revisionId;
|
|
106
|
+
|
|
107
|
+
if (name === 't') {
|
|
108
|
+
text = child.textContent || '';
|
|
109
|
+
} else if (name === 'delText') {
|
|
110
|
+
text = child.textContent || '';
|
|
111
|
+
if (!nextRevision) {
|
|
112
|
+
kind = 'deletion';
|
|
113
|
+
}
|
|
114
|
+
} else if (name === 'tab') {
|
|
115
|
+
text = '\t';
|
|
116
|
+
} else if (name === 'br' || name === 'cr') {
|
|
117
|
+
text = '\n';
|
|
118
|
+
} else if (name === 'noBreakHyphen') {
|
|
119
|
+
text = '\u2011';
|
|
120
|
+
} else if (name === 'softHyphen') {
|
|
121
|
+
text = '\u00ad';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (text !== null) {
|
|
125
|
+
if (text.length > 0) {
|
|
126
|
+
rawPieces.push({
|
|
127
|
+
text,
|
|
128
|
+
kind,
|
|
129
|
+
author,
|
|
130
|
+
revisionId,
|
|
131
|
+
carrier: nextCarrier || child,
|
|
132
|
+
carrierContainer: (nextCarrier || child)?.parentNode || null
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
walk(child, nextRevision, nextCarrier);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const initialCarrier = localName(paragraph) === 'r' ? paragraph : null;
|
|
142
|
+
walk(paragraph, null, initialCarrier);
|
|
143
|
+
|
|
144
|
+
if (rawPieces.length === 0) return [];
|
|
145
|
+
|
|
146
|
+
const mergeRuns = options.mergeRuns !== false;
|
|
147
|
+
const merged = [];
|
|
148
|
+
let current = null;
|
|
149
|
+
|
|
150
|
+
for (const piece of rawPieces) {
|
|
151
|
+
if (!current) {
|
|
152
|
+
current = { ...piece };
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const sameKind = current.kind === piece.kind;
|
|
157
|
+
const sameAuthor = current.author === piece.author;
|
|
158
|
+
const sameRevisionId = current.revisionId === piece.revisionId;
|
|
159
|
+
const sameCarrier = current.carrier === piece.carrier;
|
|
160
|
+
const compatibleContainer = mergeRuns && current.carrierContainer === piece.carrierContainer;
|
|
161
|
+
|
|
162
|
+
if (sameKind && sameAuthor && sameRevisionId && (sameCarrier || compatibleContainer)) {
|
|
163
|
+
current.text += piece.text;
|
|
164
|
+
} else {
|
|
165
|
+
merged.push(current);
|
|
166
|
+
current = { ...piece };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (current) merged.push(current);
|
|
170
|
+
|
|
171
|
+
let acceptedCursor = 0;
|
|
172
|
+
let rejectedCursor = 0;
|
|
173
|
+
const segments = [];
|
|
174
|
+
|
|
175
|
+
for (const item of merged) {
|
|
176
|
+
const isAccepted = item.kind !== 'deletion' && item.kind !== 'move_from';
|
|
177
|
+
const isRejected = item.kind !== 'insertion' && item.kind !== 'move_to';
|
|
178
|
+
|
|
179
|
+
const acceptedStart = isAccepted ? acceptedCursor : null;
|
|
180
|
+
const rejectedStart = isRejected ? rejectedCursor : null;
|
|
181
|
+
|
|
182
|
+
if (isAccepted) acceptedCursor += item.text.length;
|
|
183
|
+
if (isRejected) rejectedCursor += item.text.length;
|
|
184
|
+
|
|
185
|
+
const segment = {
|
|
186
|
+
text: item.text,
|
|
187
|
+
kind: item.kind,
|
|
188
|
+
acceptedStart,
|
|
189
|
+
rejectedStart
|
|
190
|
+
};
|
|
191
|
+
if (item.author !== undefined) segment.author = item.author;
|
|
192
|
+
if (item.revisionId !== undefined) segment.revisionId = item.revisionId;
|
|
193
|
+
|
|
194
|
+
segments.push(segment);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return segments;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Canonical exact paragraph text used by inspection, targeting and ingestion. */
|
|
201
|
+
export function extractCanonicalParagraphText(paragraph, options = {}) {
|
|
202
|
+
if (!paragraph) return '';
|
|
203
|
+
const view = options.revisionView === 'current' ? 'accepted' : (options.revisionView || 'accepted');
|
|
204
|
+
const segments = extractParagraphRevisionSegments(paragraph, options);
|
|
205
|
+
if (view === 'rejected') {
|
|
206
|
+
return segments.filter(s => s.rejectedStart !== null).map(s => s.text).join('');
|
|
207
|
+
}
|
|
208
|
+
return segments.filter(s => s.acceptedStart !== null).map(s => s.text).join('');
|
|
209
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NS_W,
|
|
3
|
+
RevisionIdAllocator,
|
|
4
|
+
createRevisionIdAllocator,
|
|
5
|
+
getRevisionIdAllocatorForDocument
|
|
6
|
+
} from './types.js';
|
|
7
|
+
import { isWordElement } from './word-xml.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Assigns fresh document-scoped IDs to w:rPrChange elements in a cloned
|
|
11
|
+
* run-properties subtree. This preserves formatting-revision metadata while
|
|
12
|
+
* preventing a DOM split from duplicating the original revision ID.
|
|
13
|
+
*
|
|
14
|
+
* @param {Element} root - Cloned subtree whose revision IDs should be refreshed
|
|
15
|
+
* @param {RevisionIdAllocator|null} [allocator=null] - Document-scoped allocator
|
|
16
|
+
* @returns {Element}
|
|
17
|
+
*/
|
|
18
|
+
export function refreshRunPropertyChangeIds(root, allocator = null) {
|
|
19
|
+
if (!root) return root;
|
|
20
|
+
|
|
21
|
+
const xmlDoc = root.nodeType === 9 ? root : root.ownerDocument;
|
|
22
|
+
const resolvedAllocator = allocator instanceof RevisionIdAllocator
|
|
23
|
+
? allocator
|
|
24
|
+
: (getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc));
|
|
25
|
+
const candidates = [root, ...Array.from(root.getElementsByTagName?.('*') || [])];
|
|
26
|
+
|
|
27
|
+
for (const node of candidates) {
|
|
28
|
+
if (!isWordElement(node, 'rPrChange')) continue;
|
|
29
|
+
const nextId = String(resolvedAllocator.next());
|
|
30
|
+
if (typeof node.setAttributeNS === 'function') {
|
|
31
|
+
node.setAttributeNS(NS_W, 'w:id', nextId);
|
|
32
|
+
} else {
|
|
33
|
+
node.setAttribute('w:id', nextId);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return root;
|
|
38
|
+
}
|
package/core/types.js
CHANGED
|
@@ -202,15 +202,24 @@ export class RevisionIdAllocator {
|
|
|
202
202
|
|
|
203
203
|
seed(xmlDoc) {
|
|
204
204
|
let maxFound = -1;
|
|
205
|
-
const
|
|
206
|
-
|
|
205
|
+
const traversalRoot = xmlDoc?.nodeType === 9 ? xmlDoc.documentElement : xmlDoc;
|
|
206
|
+
let node = traversalRoot || null;
|
|
207
207
|
|
|
208
|
-
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
208
|
+
while (node) {
|
|
209
|
+
if (isRevisionIdElement(node)) {
|
|
210
|
+
const id = readWordId(node);
|
|
211
|
+
if (id != null) {
|
|
212
|
+
this.occupiedIds.add(id);
|
|
213
|
+
maxFound = Math.max(maxFound, id);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (node.firstChild) {
|
|
218
|
+
node = node.firstChild;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
while (node && node !== traversalRoot && !node.nextSibling) node = node.parentNode;
|
|
222
|
+
node = node && node !== traversalRoot ? node.nextSibling : null;
|
|
214
223
|
}
|
|
215
224
|
|
|
216
225
|
const highRiskBoundary = MAX_PRACTICAL_REVISION_ID - REVISION_ID_SAFETY_MARGIN;
|
|
@@ -286,7 +295,7 @@ export function getRevisionTimestamp(date = new Date()) {
|
|
|
286
295
|
* @param {RevisionIdAllocator|Document|Element|null} [allocatorOrNode] - Scoped allocator or registered OOXML node
|
|
287
296
|
* @returns {{ id: number, author: string, date: string }}
|
|
288
297
|
*/
|
|
289
|
-
export function createRevisionMetadata(author, allocatorOrNode = null) {
|
|
298
|
+
export function createRevisionMetadata(author, allocatorOrNode = null, kind = null) {
|
|
290
299
|
const resolvedAuthor = typeof author === 'string' && author.trim()
|
|
291
300
|
? author.trim()
|
|
292
301
|
: getDefaultAuthor();
|
|
@@ -294,13 +303,58 @@ export function createRevisionMetadata(author, allocatorOrNode = null) {
|
|
|
294
303
|
? allocatorOrNode
|
|
295
304
|
: (getRevisionIdAllocatorForDocument(allocatorOrNode) || defaultRevisionIdAllocator);
|
|
296
305
|
|
|
306
|
+
const id = allocator.next();
|
|
307
|
+
if (allocator._receiptCollector) {
|
|
308
|
+
allocator._receiptCollector.recordRevision(id, kind || 'structural');
|
|
309
|
+
}
|
|
310
|
+
|
|
297
311
|
return {
|
|
298
|
-
id
|
|
312
|
+
id,
|
|
299
313
|
author: resolvedAuthor,
|
|
300
314
|
date: getRevisionTimestamp()
|
|
301
315
|
};
|
|
302
316
|
}
|
|
303
317
|
|
|
318
|
+
/**
|
|
319
|
+
* @typedef {Object} ReplacementRevisionEvent
|
|
320
|
+
* @property {number} deletionId - Unique revision ID for deletion
|
|
321
|
+
* @property {number} insertionId - Unique revision ID for insertion
|
|
322
|
+
* @property {string} author - Change author
|
|
323
|
+
* @property {string} date - Shared ISO timestamp
|
|
324
|
+
*/
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Creates paired revision metadata for a replacement event.
|
|
328
|
+
* Allocates two unique IDs but shares author and timestamp.
|
|
329
|
+
*
|
|
330
|
+
* @param {string} [author]
|
|
331
|
+
* @param {RevisionIdAllocator|Document|Element|null} [allocatorOrNode=null]
|
|
332
|
+
* @returns {ReplacementRevisionEvent}
|
|
333
|
+
*/
|
|
334
|
+
export function createReplacementRevisionEvent(author, allocatorOrNode = null) {
|
|
335
|
+
const resolvedAuthor = typeof author === 'string' && author.trim()
|
|
336
|
+
? author.trim()
|
|
337
|
+
: getDefaultAuthor();
|
|
338
|
+
const allocator = allocatorOrNode instanceof RevisionIdAllocator
|
|
339
|
+
? allocatorOrNode
|
|
340
|
+
: (getRevisionIdAllocatorForDocument(allocatorOrNode) || defaultRevisionIdAllocator);
|
|
341
|
+
const date = getRevisionTimestamp();
|
|
342
|
+
const deletionId = allocator.next();
|
|
343
|
+
const insertionId = allocator.next();
|
|
344
|
+
|
|
345
|
+
if (allocator._receiptCollector) {
|
|
346
|
+
allocator._receiptCollector.recordRevision(deletionId, 'del');
|
|
347
|
+
allocator._receiptCollector.recordRevision(insertionId, 'ins');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
deletionId,
|
|
352
|
+
insertionId,
|
|
353
|
+
author: resolvedAuthor,
|
|
354
|
+
date
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
304
358
|
/**
|
|
305
359
|
* Seeds the revision ID counter above any existing Word revision/comment id values.
|
|
306
360
|
*
|
package/core/word-xml.js
CHANGED
|
@@ -33,6 +33,27 @@ function wordElementsByLocalName(xmlDoc, localName) {
|
|
|
33
33
|
return Array.from(xmlDoc?.getElementsByTagName?.('*') || []).filter(node => isWordElement(node, localName));
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Keep revision discovery and author discovery on the same taxonomy. Cell
|
|
37
|
+
// markers are included for fail-closed detection even though selective
|
|
38
|
+
// acceptance/rejection of those structural revisions is not yet supported.
|
|
39
|
+
const TRACKED_CHANGE_NAMES = [
|
|
40
|
+
'ins',
|
|
41
|
+
'del',
|
|
42
|
+
'moveFrom',
|
|
43
|
+
'moveTo',
|
|
44
|
+
'moveFromRangeStart',
|
|
45
|
+
'moveFromRangeEnd',
|
|
46
|
+
'moveToRangeStart',
|
|
47
|
+
'moveToRangeEnd',
|
|
48
|
+
'rPrChange',
|
|
49
|
+
'pPrChange',
|
|
50
|
+
'tblPrChange',
|
|
51
|
+
'trPrChange',
|
|
52
|
+
'tcPrChange',
|
|
53
|
+
'cellIns',
|
|
54
|
+
'cellDel'
|
|
55
|
+
];
|
|
56
|
+
|
|
36
57
|
/**
|
|
37
58
|
* Returns true if a document or fragment contains Word tracked-change markup.
|
|
38
59
|
*
|
|
@@ -40,22 +61,29 @@ function wordElementsByLocalName(xmlDoc, localName) {
|
|
|
40
61
|
* @returns {boolean}
|
|
41
62
|
*/
|
|
42
63
|
export function containsTrackedChanges(xmlDoc) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
'del',
|
|
46
|
-
'moveFrom',
|
|
47
|
-
'moveTo',
|
|
48
|
-
'moveFromRangeStart',
|
|
49
|
-
'moveFromRangeEnd',
|
|
50
|
-
'moveToRangeStart',
|
|
51
|
-
'moveToRangeEnd',
|
|
52
|
-
'rPrChange',
|
|
53
|
-
'pPrChange',
|
|
54
|
-
'cellIns',
|
|
55
|
-
'cellDel'
|
|
56
|
-
];
|
|
64
|
+
return TRACKED_CHANGE_NAMES.some(localName => wordElementsByLocalName(xmlDoc, localName).length > 0);
|
|
65
|
+
}
|
|
57
66
|
|
|
58
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Collects all distinct author names from tracked changes in the given XML document or element.
|
|
69
|
+
*
|
|
70
|
+
* @param {Document|Element|null|undefined} xmlDocOrElement
|
|
71
|
+
* @returns {string[]} Sorted unique list of author names.
|
|
72
|
+
*/
|
|
73
|
+
export function getTrackedChangeAuthors(xmlDocOrElement) {
|
|
74
|
+
if (!xmlDocOrElement) return [];
|
|
75
|
+
const authors = new Set();
|
|
76
|
+
for (const localName of TRACKED_CHANGE_NAMES) {
|
|
77
|
+
for (const node of wordElementsByLocalName(xmlDocOrElement, localName)) {
|
|
78
|
+
const author = node.getAttribute?.('w:author')
|
|
79
|
+
|| node.getAttribute?.('author')
|
|
80
|
+
|| (typeof node.getAttributeNS === 'function' ? node.getAttributeNS(NS_W, 'author') : null);
|
|
81
|
+
if (author && typeof author === 'string' && author.trim()) {
|
|
82
|
+
authors.add(author.trim());
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [...authors].sort();
|
|
59
87
|
}
|
|
60
88
|
|
|
61
89
|
/**
|