@adeu/core 1.6.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/dist/index.cjs +3627 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +247 -0
- package/dist/index.d.ts +247 -0
- package/dist/index.js +3579 -0
- package/dist/index.js.map +1 -0
- package/package.json +38 -0
- package/src/comments.test.ts +38 -0
- package/src/comments.ts +451 -0
- package/src/diff.test.ts +62 -0
- package/src/diff.ts +251 -0
- package/src/docx/bridge.ts +189 -0
- package/src/docx/dom.ts +54 -0
- package/src/docx/primitives.ts +65 -0
- package/src/domain.ts +11 -0
- package/src/engine.atomic.test.ts +58 -0
- package/src/engine.batch.test.ts +93 -0
- package/src/engine.safety.test.ts +42 -0
- package/src/engine.tables.test.ts +166 -0
- package/src/engine.ts +735 -0
- package/src/index.test.ts +8 -0
- package/src/index.ts +14 -0
- package/src/ingest.test.ts +44 -0
- package/src/ingest.ts +400 -0
- package/src/mapper.test.ts +66 -0
- package/src/mapper.ts +835 -0
- package/src/markup.test.ts +150 -0
- package/src/markup.ts +323 -0
- package/src/models.ts +51 -0
- package/src/outline.ts +377 -0
- package/src/pagination.ts +239 -0
- package/src/test-utils.ts +142 -0
- package/src/utils/docx.ts +478 -0
- package/tsconfig.json +21 -0
- package/tsup.config.ts +10 -0
- package/vitest.config.ts +12 -0
package/src/engine.ts
ADDED
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
import { DocumentObject } from './docx/bridge.js';
|
|
2
|
+
import { Paragraph, Table, Run, DocxEvent } from './docx/primitives.js';
|
|
3
|
+
import { DocumentMapper, TextSpan } from './mapper.js';
|
|
4
|
+
import { CommentsManager } from './comments.js';
|
|
5
|
+
import {
|
|
6
|
+
ModifyText, InsertTableRow, DeleteTableRow, AcceptChange, RejectChange, ReplyComment, DocumentChange
|
|
7
|
+
} from './models.js';
|
|
8
|
+
import { trim_common_context } from './diff.js';
|
|
9
|
+
import { findChild, findAllDescendants, serializeXml } from './docx/dom.js';
|
|
10
|
+
import {
|
|
11
|
+
is_heading_paragraph, is_native_heading, get_run_style_markers, get_run_text, apply_formatting_to_segments
|
|
12
|
+
} from './utils/docx.js';
|
|
13
|
+
|
|
14
|
+
// --- DOM Mutation Helpers for xmldom ---
|
|
15
|
+
function getNextElement(el: Element): Element | null {
|
|
16
|
+
let next = el.nextSibling;
|
|
17
|
+
while (next) {
|
|
18
|
+
if (next.nodeType === 1) return next as Element;
|
|
19
|
+
next = next.nextSibling;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getPreviousElement(el: Element): Element | null {
|
|
25
|
+
let prev = el.previousSibling;
|
|
26
|
+
while (prev) {
|
|
27
|
+
if (prev.nodeType === 1) return prev as Element;
|
|
28
|
+
prev = prev.previousSibling;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function insertAfter(newNode: Node, refNode: Element) {
|
|
34
|
+
if (refNode.parentNode) {
|
|
35
|
+
refNode.parentNode.insertBefore(newNode, refNode.nextSibling);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function insertBefore(newNode: Node, refNode: Element) {
|
|
40
|
+
if (refNode.parentNode) {
|
|
41
|
+
refNode.parentNode.insertBefore(newNode, refNode);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function insertAtIndex(parent: Element, index: number, child: Node) {
|
|
46
|
+
const children = Array.from(parent.childNodes).filter(n => n.nodeType === 1);
|
|
47
|
+
if (index >= children.length) {
|
|
48
|
+
parent.appendChild(child);
|
|
49
|
+
} else {
|
|
50
|
+
parent.insertBefore(child, children[index]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- Validation ---
|
|
55
|
+
export class BatchValidationError extends Error {
|
|
56
|
+
public errors: string[];
|
|
57
|
+
constructor(errors: string[]) {
|
|
58
|
+
super("Batch validation failed:\n" + errors.join("\n"));
|
|
59
|
+
this.name = "BatchValidationError";
|
|
60
|
+
this.errors = errors;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function validate_edit_strings(edits: any[]): string[] {
|
|
65
|
+
const errors: string[] = [];
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < edits.length; i++) {
|
|
68
|
+
const edit = edits[i];
|
|
69
|
+
const t_text = edit.target_text || "";
|
|
70
|
+
const n_text = edit.new_text || "";
|
|
71
|
+
|
|
72
|
+
if (n_text.includes("{++") || n_text.includes("{--") || n_text.includes("{>>") || n_text.includes("{==")) {
|
|
73
|
+
errors.push(`- Edit ${i + 1} Failed: Do not manually write CriticMarkup tags ({++, {--, {>>, {==) in \`new_text\`. The engine handles redlining automatically. To add a comment, use the \`comment\` parameter.`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (t_text.includes("[^") || n_text.includes("[^")) {
|
|
77
|
+
const t_fns = (t_text.match(/\[\^(?:fn|en)-[^\]]+\]/g) || []).sort();
|
|
78
|
+
const n_fns = (n_text.match(/\[\^(?:fn|en)-[^\]]+\]/g) || []).sort();
|
|
79
|
+
if (JSON.stringify(t_fns) !== JSON.stringify(n_fns)) {
|
|
80
|
+
if (n_fns.length > t_fns.length || n_fns.some((f: string) => n_fns.filter((x: string) => x===f).length > t_fns.filter((x: string) => x===f).length)) {
|
|
81
|
+
errors.push(`- Edit ${i + 1} Failed: Cannot insert footnote/endnote markers via text replace. Markers like \`[^fn-N]\` are read-only projections. Use Word's References menu.`);
|
|
82
|
+
} else {
|
|
83
|
+
errors.push(`- Edit ${i + 1} Failed: Cannot delete footnote/endnote references via text replace. The marker corresponds to a structural XML element.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (t_text.includes("](") || n_text.includes("](")) {
|
|
89
|
+
const t_links = (t_text.match(/\[(?!~)[^\]]+\]\([^)]+\)/g) || []).sort();
|
|
90
|
+
const n_links = (n_text.match(/\[(?!~)[^\]]+\]\([^)]+\)/g) || []).sort();
|
|
91
|
+
if (t_links.length !== n_links.length) {
|
|
92
|
+
if (n_links.length > t_links.length) {
|
|
93
|
+
errors.push(`- Edit ${i + 1} Failed: Cannot insert hyperlinks via text replace. Use a dedicated structural operation.`);
|
|
94
|
+
} else {
|
|
95
|
+
errors.push(`- Edit ${i + 1} Failed: Cannot delete hyperlinks via text replace. The marker corresponds to a structural XML element.`);
|
|
96
|
+
}
|
|
97
|
+
} else if (t_links.length > 1 && JSON.stringify(t_links) !== JSON.stringify(n_links)) {
|
|
98
|
+
errors.push(`- Edit ${i + 1} Failed: Can only edit or retarget one hyperlink per text replacement. Please split into multiple edits.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (t_text.includes("[~") || n_text.includes("[~")) {
|
|
103
|
+
const t_xrefs = (t_text.match(/\[~[^~]+~\]\(#[^\)]+\)/g) || []);
|
|
104
|
+
const n_xrefs = (n_text.match(/\[~[^~]+~\]\(#[^\)]+\)/g) || []);
|
|
105
|
+
if (t_xrefs.length !== n_xrefs.length) {
|
|
106
|
+
if (n_xrefs.length > t_xrefs.length) {
|
|
107
|
+
errors.push(`- Edit ${i + 1} Failed: Cannot insert cross-references via text replace. Markers are read-only projections.`);
|
|
108
|
+
} else {
|
|
109
|
+
errors.push(`- Edit ${i + 1} Failed: Cannot delete cross-references via text replace. The marker corresponds to a structural XML element.`);
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
// Advanced XREF validation simplified for port scope
|
|
113
|
+
if (JSON.stringify(t_xrefs) !== JSON.stringify(n_xrefs)) {
|
|
114
|
+
errors.push(`- Edit ${i + 1} Failed: Modifying or retargeting cross-reference markers is disallowed to prevent dependency corruption.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (t_text.includes("{#") || n_text.includes("{#")) {
|
|
120
|
+
const t_anchors = t_text.match(/\{#[^\}]+\}/g) || [];
|
|
121
|
+
const n_anchors = n_text.match(/\{#[^\}]+\}/g) || [];
|
|
122
|
+
for (const a of n_anchors) {
|
|
123
|
+
if (n_anchors.filter((x: string) => x===a).length > t_anchors.filter((x: string) => x===a).length) {
|
|
124
|
+
errors.push(`- Edit ${i + 1} Failed: Cannot modify or insert internal anchor markers (\`{#...}\`). These represent structural XML bookmarks.`);
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (edit.type === 'modify' && n_text) {
|
|
131
|
+
const lines = n_text.split('\n');
|
|
132
|
+
for (const line of lines) {
|
|
133
|
+
const stripped = line.trimStart();
|
|
134
|
+
if (stripped.startsWith("#######")) {
|
|
135
|
+
const level = stripped.length - stripped.replace(/^#+/, '').length;
|
|
136
|
+
if (stripped.substring(level).startsWith(' ') || stripped.substring(level) === '') {
|
|
137
|
+
errors.push(`- Edit ${i + 1} Failed: Heading level ${level} is not supported (maximum is 6).`);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (t_text.includes("READONLY_BOUNDARY_START") || n_text.includes("READONLY_BOUNDARY_START") ||
|
|
145
|
+
t_text.includes("# Document Structure (Read-Only)") || n_text.includes("# Document Structure (Read-Only)")) {
|
|
146
|
+
errors.push(`- Edit ${i + 1} Failed: Modification targets the read-only boundary (Structural Appendix). This section cannot be edited.`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return errors;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Engine ---
|
|
154
|
+
export class RedlineEngine {
|
|
155
|
+
public doc: DocumentObject;
|
|
156
|
+
public author: string;
|
|
157
|
+
public timestamp: string;
|
|
158
|
+
public current_id: number;
|
|
159
|
+
public mapper: DocumentMapper;
|
|
160
|
+
public comments_manager: CommentsManager;
|
|
161
|
+
public clean_mapper: DocumentMapper | null = null;
|
|
162
|
+
public skipped_details: string[] = [];
|
|
163
|
+
|
|
164
|
+
constructor(doc: DocumentObject, author: string = "Adeu AI (TS)") {
|
|
165
|
+
this.doc = doc;
|
|
166
|
+
this.author = author;
|
|
167
|
+
this.timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
168
|
+
|
|
169
|
+
const w16du_ns = "http://schemas.microsoft.com/office/word/2023/wordml/word16du";
|
|
170
|
+
for (const part of this.doc.pkg.parts) {
|
|
171
|
+
if (part === this.doc.part || (part.contentType.includes('wordprocessingml') && part.contentType.endsWith('+xml'))) {
|
|
172
|
+
if (!part._element.hasAttribute('xmlns:w16du')) {
|
|
173
|
+
part._element.setAttribute('xmlns:w16du', w16du_ns);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this.current_id = this._scan_existing_ids();
|
|
179
|
+
this.mapper = new DocumentMapper(this.doc);
|
|
180
|
+
this.comments_manager = new CommentsManager(this.doc);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private _scan_existing_ids(): number {
|
|
184
|
+
let maxId = 0;
|
|
185
|
+
for (const tag of ['w:ins', 'w:del']) {
|
|
186
|
+
const elements = findAllDescendants(this.doc.element, tag);
|
|
187
|
+
for (const el of elements) {
|
|
188
|
+
const val = parseInt(el.getAttribute('w:id') || '0', 10);
|
|
189
|
+
if (!isNaN(val) && val > maxId) maxId = val;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return maxId;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
public accept_all_revisions() {
|
|
196
|
+
const dels = findAllDescendants(this.doc.element, 'w:del');
|
|
197
|
+
for (const d of dels) {
|
|
198
|
+
const parent = d.parentNode as Element | null;
|
|
199
|
+
if (parent?.tagName === 'w:trPr') {
|
|
200
|
+
const tr = parent.parentNode;
|
|
201
|
+
tr?.parentNode?.removeChild(tr);
|
|
202
|
+
} else {
|
|
203
|
+
parent?.removeChild(d);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const insNodes = findAllDescendants(this.doc.element, 'w:ins');
|
|
207
|
+
for (const i of insNodes) {
|
|
208
|
+
const parent = i.parentNode as Element | null;
|
|
209
|
+
if (parent?.tagName === 'w:trPr') {
|
|
210
|
+
parent.removeChild(i);
|
|
211
|
+
} else {
|
|
212
|
+
while (i.firstChild) parent?.insertBefore(i.firstChild, i);
|
|
213
|
+
parent?.removeChild(i);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private _getNextId(): string {
|
|
219
|
+
this.current_id++;
|
|
220
|
+
return this.current_id.toString();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private _create_track_change_tag(tagName: string, author: string = "", reuseId: string | null = null): Element {
|
|
224
|
+
const xmlDoc = this.doc.part._element.ownerDocument!;
|
|
225
|
+
const tag = xmlDoc.createElement(tagName);
|
|
226
|
+
const wid = reuseId !== null ? reuseId : this._getNextId();
|
|
227
|
+
tag.setAttribute("w:id", wid);
|
|
228
|
+
tag.setAttribute("w:author", author || this.author);
|
|
229
|
+
tag.setAttribute("w:date", this.timestamp);
|
|
230
|
+
tag.setAttribute("w16du:dateUtc", this.timestamp);
|
|
231
|
+
return tag;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private _set_text_content(element: Element, text: string) {
|
|
235
|
+
element.textContent = text;
|
|
236
|
+
if (text.trim() !== text) {
|
|
237
|
+
element.setAttribute("xml:space", "preserve");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private _parse_markdown_style(text: string): [string, string | null] {
|
|
242
|
+
const stripped_text = text.trimStart();
|
|
243
|
+
|
|
244
|
+
if (stripped_text.startsWith("#")) {
|
|
245
|
+
let level = 0;
|
|
246
|
+
let temp = stripped_text;
|
|
247
|
+
while (temp.startsWith("#")) {
|
|
248
|
+
level++;
|
|
249
|
+
temp = temp.substring(1);
|
|
250
|
+
}
|
|
251
|
+
if (temp.startsWith(" ")) return [temp.trim(), `Heading ${level}`];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (stripped_text.startsWith("* ") || stripped_text.startsWith("- ")) {
|
|
255
|
+
return [stripped_text.substring(2).trim(), "List Paragraph"];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const match = stripped_text.match(/^\d+\.\s+/);
|
|
259
|
+
if (match) {
|
|
260
|
+
return [stripped_text.substring(match[0].length).trim(), "List Number"];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return [text, null];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private _parse_inline_markdown(text: string, baseStyle: any = {}): [string, any][] {
|
|
267
|
+
if (!text) return [];
|
|
268
|
+
|
|
269
|
+
const tokenPattern = /(\*\*.*?\*\*)|(_.*?_)/;
|
|
270
|
+
const match = text.match(tokenPattern);
|
|
271
|
+
|
|
272
|
+
if (!match) return [[text, baseStyle]];
|
|
273
|
+
|
|
274
|
+
const start = match.index!;
|
|
275
|
+
const raw = match[0];
|
|
276
|
+
const end = start + raw.length;
|
|
277
|
+
|
|
278
|
+
const isBold = raw.startsWith('**');
|
|
279
|
+
const innerContent = isBold ? raw.substring(2, raw.length - 2) : raw.substring(1, raw.length - 1);
|
|
280
|
+
|
|
281
|
+
const preText = text.substring(0, start);
|
|
282
|
+
const postText = text.substring(end);
|
|
283
|
+
|
|
284
|
+
const results: [string, any][] = [];
|
|
285
|
+
if (preText) results.push([preText, baseStyle]);
|
|
286
|
+
|
|
287
|
+
const newStyle = { ...baseStyle };
|
|
288
|
+
if (isBold) newStyle.bold = true;
|
|
289
|
+
else newStyle.italic = true;
|
|
290
|
+
|
|
291
|
+
results.push(...this._parse_inline_markdown(innerContent, newStyle));
|
|
292
|
+
results.push(...this._parse_inline_markdown(postText, baseStyle));
|
|
293
|
+
|
|
294
|
+
return results;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private _apply_run_props(runElement: Element, props: any, suppressInherited: boolean = false) {
|
|
298
|
+
if (!props) {
|
|
299
|
+
if (!suppressInherited) return;
|
|
300
|
+
props = {};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let rPr = findChild(runElement, 'w:rPr');
|
|
304
|
+
if (!rPr && (props.bold || props.italic || suppressInherited)) {
|
|
305
|
+
const doc = runElement.ownerDocument!;
|
|
306
|
+
rPr = doc.createElement('w:rPr');
|
|
307
|
+
runElement.appendChild(rPr);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (rPr) {
|
|
311
|
+
const doc = runElement.ownerDocument!;
|
|
312
|
+
if (props.bold) {
|
|
313
|
+
let b = findChild(rPr, 'w:b');
|
|
314
|
+
if (!b) { b = doc.createElement('w:b'); rPr.appendChild(b); }
|
|
315
|
+
b.setAttribute('w:val', '1');
|
|
316
|
+
} else if (suppressInherited) {
|
|
317
|
+
const b = findChild(rPr, 'w:b');
|
|
318
|
+
if (b) rPr.removeChild(b);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (props.italic) {
|
|
322
|
+
let i = findChild(rPr, 'w:i');
|
|
323
|
+
if (!i) { i = doc.createElement('w:i'); rPr.appendChild(i); }
|
|
324
|
+
i.setAttribute('w:val', '1');
|
|
325
|
+
} else if (suppressInherited) {
|
|
326
|
+
const i = findChild(rPr, 'w:i');
|
|
327
|
+
if (i) rPr.removeChild(i);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
public validate_edits(edits: any[]): string[] {
|
|
333
|
+
const errors: string[] = [];
|
|
334
|
+
if (!this.mapper.full_text) this.mapper['_build_map']();
|
|
335
|
+
|
|
336
|
+
errors.push(...validate_edit_strings(edits));
|
|
337
|
+
|
|
338
|
+
for (let i = 0; i < edits.length; i++) {
|
|
339
|
+
const edit = edits[i];
|
|
340
|
+
if (!edit.target_text) continue;
|
|
341
|
+
|
|
342
|
+
let matches = this.mapper.find_all_match_indices(edit.target_text);
|
|
343
|
+
let activeText = this.mapper.full_text;
|
|
344
|
+
|
|
345
|
+
if (matches.length === 0) {
|
|
346
|
+
if (!this.clean_mapper) this.clean_mapper = new DocumentMapper(this.doc, true);
|
|
347
|
+
matches = this.clean_mapper.find_all_match_indices(edit.target_text);
|
|
348
|
+
if (matches.length > 0) activeText = this.clean_mapper.full_text;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (matches.length === 0) {
|
|
352
|
+
errors.push(`- Edit ${i + 1} Failed: Target text not found in document:\n "${edit.target_text}"`);
|
|
353
|
+
} else if (matches.length > 1) {
|
|
354
|
+
errors.push(`- Edit ${i + 1} Failed: Target text is ambiguous. Found ${matches.length} matches.\nProvide more context.`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const [start, length] of matches) {
|
|
358
|
+
const spans = this.mapper.spans.filter(s => s.end > start && s.start < start + length);
|
|
359
|
+
const nestedAuthors = new Set<string>();
|
|
360
|
+
for (const s of spans) {
|
|
361
|
+
if (s.ins_id) {
|
|
362
|
+
const insNodes = findAllDescendants(this.doc.element, 'w:ins').filter(n => n.getAttribute('w:id') === s.ins_id);
|
|
363
|
+
if (insNodes.length > 0) {
|
|
364
|
+
const auth = insNodes[0].getAttribute('w:author');
|
|
365
|
+
if (auth && auth !== this.author) nestedAuthors.add(auth);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (nestedAuthors.size > 0) {
|
|
370
|
+
errors.push(`- Edit ${i + 1} Failed: Modification targets an active insertion from another author (${Array.from(nestedAuthors).join(', ')}).`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return errors;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
public process_batch(changes: DocumentChange[]): any {
|
|
378
|
+
this.skipped_details = [];
|
|
379
|
+
const actions = changes.filter(c => ['accept', 'reject', 'reply'].includes(c.type));
|
|
380
|
+
const edits = changes.filter(c => !['accept', 'reject', 'reply'].includes(c.type));
|
|
381
|
+
|
|
382
|
+
let applied_actions = 0, skipped_actions = 0;
|
|
383
|
+
if (actions.length > 0) {
|
|
384
|
+
const res = this.apply_review_actions(actions);
|
|
385
|
+
applied_actions = res[0];
|
|
386
|
+
skipped_actions = res[1];
|
|
387
|
+
if (applied_actions > 0) {
|
|
388
|
+
this.mapper['_build_map']();
|
|
389
|
+
if (this.clean_mapper) this.clean_mapper['_build_map']();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (edits.length > 0) {
|
|
394
|
+
const errors = this.validate_edits(edits);
|
|
395
|
+
if (errors.length > 0) throw new BatchValidationError(errors);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
let applied_edits = 0, skipped_edits = 0;
|
|
399
|
+
if (edits.length > 0) {
|
|
400
|
+
const res = this.apply_edits(edits as any[]);
|
|
401
|
+
applied_edits = res[0];
|
|
402
|
+
skipped_edits = res[1];
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
actions_applied: applied_actions,
|
|
407
|
+
actions_skipped: skipped_actions,
|
|
408
|
+
edits_applied: applied_edits,
|
|
409
|
+
edits_skipped: skipped_edits,
|
|
410
|
+
skipped_details: this.skipped_details,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
public apply_edits(edits: any[]): [number, number] {
|
|
415
|
+
let applied = 0;
|
|
416
|
+
let skipped = 0;
|
|
417
|
+
const resolved_edits: [any, string | null][] = [];
|
|
418
|
+
|
|
419
|
+
for (const edit of edits) {
|
|
420
|
+
if (edit._match_start_index !== undefined && edit._match_start_index !== null) {
|
|
421
|
+
resolved_edits.push([edit, edit.new_text || null]);
|
|
422
|
+
} else if (edit.type === 'insert_row' || edit.type === 'delete_row') {
|
|
423
|
+
const [idx] = this.mapper.find_match_index(edit.target_text);
|
|
424
|
+
if (idx !== -1) {
|
|
425
|
+
edit._match_start_index = idx;
|
|
426
|
+
resolved_edits.push([edit, null]);
|
|
427
|
+
} else {
|
|
428
|
+
skipped++;
|
|
429
|
+
this.skipped_details.push(`- Failed to locate row target: '${(edit.target_text || '').substring(0, 40)}...'`);
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
const resolved = this._pre_resolve_heuristic_edit(edit);
|
|
433
|
+
if (resolved) {
|
|
434
|
+
if (Array.isArray(resolved)) {
|
|
435
|
+
for (const r of resolved) resolved_edits.push([r, r.new_text]);
|
|
436
|
+
} else {
|
|
437
|
+
resolved_edits.push([resolved, (resolved as any).new_text]);
|
|
438
|
+
}
|
|
439
|
+
} else {
|
|
440
|
+
skipped++;
|
|
441
|
+
this.skipped_details.push(`- Failed to apply edit targeting: '${(edit.target_text || 'insertion').substring(0, 40)}...'`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
resolved_edits.sort((a, b) => (b[0]._match_start_index || 0) - (a[0]._match_start_index || 0));
|
|
447
|
+
const occupied_ranges: [number, number][] = [];
|
|
448
|
+
|
|
449
|
+
for (const [edit, orig_new] of resolved_edits) {
|
|
450
|
+
const start = edit._match_start_index || 0;
|
|
451
|
+
const end = start + (edit.target_text ? edit.target_text.length : 0);
|
|
452
|
+
|
|
453
|
+
const overlaps = occupied_ranges.some(([occ_start, occ_end]) => start < occ_end && end > occ_start);
|
|
454
|
+
if (overlaps) {
|
|
455
|
+
skipped++;
|
|
456
|
+
this.skipped_details.push(`- Skipped overlapping edit targeting: '${(edit.target_text || 'insertion').substring(0, 40)}...'`);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
let success = false;
|
|
461
|
+
if (edit.type === 'modify') {
|
|
462
|
+
success = this._apply_single_edit_indexed(edit, orig_new, false);
|
|
463
|
+
} else if (edit.type === 'insert_row' || edit.type === 'delete_row') {
|
|
464
|
+
success = this._apply_table_edit(edit, false);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (success) {
|
|
468
|
+
applied++;
|
|
469
|
+
occupied_ranges.push([start, end]);
|
|
470
|
+
} else {
|
|
471
|
+
skipped++;
|
|
472
|
+
this.skipped_details.push(`- Failed to apply edit targeting: '${(edit.target_text || 'insertion').substring(0, 40)}...'`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return [applied, skipped];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
public apply_review_actions(actions: any[]): [number, number] {
|
|
480
|
+
let applied = 0;
|
|
481
|
+
let skipped = 0;
|
|
482
|
+
|
|
483
|
+
for (const action of actions) {
|
|
484
|
+
const type = action.type;
|
|
485
|
+
if (type === 'reply') {
|
|
486
|
+
const cid = action.target_id.replace('Com:', '');
|
|
487
|
+
this.comments_manager.addComment(this.author, action.text, cid);
|
|
488
|
+
applied++;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const target_id = action.target_id.replace('Chg:', '');
|
|
493
|
+
const all_ins = findAllDescendants(this.doc.element, 'w:ins').filter(n => n.getAttribute('w:id') === target_id);
|
|
494
|
+
const all_del = findAllDescendants(this.doc.element, 'w:del').filter(n => n.getAttribute('w:id') === target_id);
|
|
495
|
+
const all_nodes = [...all_ins, ...all_del];
|
|
496
|
+
|
|
497
|
+
if (all_nodes.length === 0) {
|
|
498
|
+
skipped++;
|
|
499
|
+
this.skipped_details.push(`- Failed to apply action: Target ID ${action.target_id} not found.`);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
for (const node of all_nodes) {
|
|
504
|
+
const is_ins = node.tagName === 'w:ins';
|
|
505
|
+
const parent_tag = node.parentNode ? (node.parentNode as Element).tagName : '';
|
|
506
|
+
const is_trPr = parent_tag === 'w:trPr';
|
|
507
|
+
|
|
508
|
+
if (type === 'accept') {
|
|
509
|
+
if (is_ins) {
|
|
510
|
+
if (is_trPr) node.parentNode?.removeChild(node);
|
|
511
|
+
else {
|
|
512
|
+
while (node.firstChild) node.parentNode?.insertBefore(node.firstChild, node);
|
|
513
|
+
node.parentNode?.removeChild(node);
|
|
514
|
+
}
|
|
515
|
+
} else {
|
|
516
|
+
if (is_trPr) {
|
|
517
|
+
const tr = node.parentNode?.parentNode;
|
|
518
|
+
tr?.parentNode?.removeChild(tr);
|
|
519
|
+
} else {
|
|
520
|
+
node.parentNode?.removeChild(node);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
} else if (type === 'reject') {
|
|
524
|
+
if (is_ins) {
|
|
525
|
+
if (is_trPr) {
|
|
526
|
+
const tr = node.parentNode?.parentNode;
|
|
527
|
+
tr?.parentNode?.removeChild(tr);
|
|
528
|
+
} else node.parentNode?.removeChild(node);
|
|
529
|
+
} else {
|
|
530
|
+
if (is_trPr) node.parentNode?.removeChild(node);
|
|
531
|
+
else {
|
|
532
|
+
const delTexts = Array.from(node.getElementsByTagName('w:delText'));
|
|
533
|
+
for (const dt of delTexts) {
|
|
534
|
+
const t = dt.ownerDocument!.createElement('w:t');
|
|
535
|
+
t.textContent = dt.textContent;
|
|
536
|
+
if (dt.hasAttribute('xml:space')) t.setAttribute('xml:space', 'preserve');
|
|
537
|
+
dt.parentNode?.replaceChild(t, dt);
|
|
538
|
+
}
|
|
539
|
+
while (node.firstChild) node.parentNode?.insertBefore(node.firstChild, node);
|
|
540
|
+
node.parentNode?.removeChild(node);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
applied++;
|
|
546
|
+
}
|
|
547
|
+
return [applied, skipped];
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private _apply_table_edit(edit: any, rebuild_map: boolean): boolean {
|
|
551
|
+
const start_idx = edit._match_start_index || 0;
|
|
552
|
+
const [anchor_run, anchor_para] = this.mapper.get_insertion_anchor(start_idx, rebuild_map);
|
|
553
|
+
|
|
554
|
+
let target_element: Element | null = null;
|
|
555
|
+
if (anchor_run) target_element = anchor_run._element;
|
|
556
|
+
else if (anchor_para) target_element = anchor_para._element;
|
|
557
|
+
|
|
558
|
+
if (!target_element) return false;
|
|
559
|
+
|
|
560
|
+
let tr: Element | null = target_element;
|
|
561
|
+
while (tr && tr.tagName !== 'w:tr') tr = tr.parentNode as Element;
|
|
562
|
+
if (!tr) return false;
|
|
563
|
+
|
|
564
|
+
if (edit.type === 'delete_row') {
|
|
565
|
+
let trPr = findChild(tr, 'w:trPr');
|
|
566
|
+
if (!trPr) {
|
|
567
|
+
trPr = tr.ownerDocument!.createElement('w:trPr');
|
|
568
|
+
tr.insertBefore(trPr, tr.firstChild);
|
|
569
|
+
}
|
|
570
|
+
trPr.appendChild(this._create_track_change_tag('w:del'));
|
|
571
|
+
return true;
|
|
572
|
+
} else if (edit.type === 'insert_row') {
|
|
573
|
+
const new_tr = tr.ownerDocument!.createElement('w:tr');
|
|
574
|
+
const trPr = tr.ownerDocument!.createElement('w:trPr');
|
|
575
|
+
new_tr.appendChild(trPr);
|
|
576
|
+
trPr.appendChild(this._create_track_change_tag('w:ins'));
|
|
577
|
+
for (const cellText of edit.cells) {
|
|
578
|
+
const tc = tr.ownerDocument!.createElement('w:tc');
|
|
579
|
+
const p = tr.ownerDocument!.createElement('w:p');
|
|
580
|
+
const r = tr.ownerDocument!.createElement('w:r');
|
|
581
|
+
const t = tr.ownerDocument!.createElement('w:t');
|
|
582
|
+
t.textContent = cellText;
|
|
583
|
+
if (cellText.trim() !== cellText) t.setAttribute('xml:space', 'preserve');
|
|
584
|
+
r.appendChild(t); p.appendChild(r); tc.appendChild(p); new_tr.appendChild(tc);
|
|
585
|
+
}
|
|
586
|
+
if (edit.position === 'above') tr.parentNode?.insertBefore(new_tr, tr);
|
|
587
|
+
else insertAfter(new_tr, tr);
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private _pre_resolve_heuristic_edit(edit: any): any {
|
|
594
|
+
if (!edit.target_text) return null;
|
|
595
|
+
|
|
596
|
+
let [start_idx, match_len] = this.mapper.find_match_index(edit.target_text);
|
|
597
|
+
let use_clean_map = false;
|
|
598
|
+
|
|
599
|
+
if (start_idx === -1) {
|
|
600
|
+
if (!this.clean_mapper) this.clean_mapper = new DocumentMapper(this.doc, true);
|
|
601
|
+
[start_idx, match_len] = this.clean_mapper.find_match_index(edit.target_text);
|
|
602
|
+
if (start_idx !== -1) use_clean_map = true;
|
|
603
|
+
else return null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const active_mapper = use_clean_map ? this.clean_mapper! : this.mapper;
|
|
607
|
+
const effective_new_text = edit.new_text || "";
|
|
608
|
+
const actual_doc_text = this.mapper.full_text.substring(start_idx, start_idx + match_len);
|
|
609
|
+
|
|
610
|
+
if (actual_doc_text === effective_new_text || edit.target_text === effective_new_text) {
|
|
611
|
+
return {
|
|
612
|
+
type: "modify",
|
|
613
|
+
target_text: actual_doc_text,
|
|
614
|
+
new_text: actual_doc_text,
|
|
615
|
+
comment: edit.comment,
|
|
616
|
+
_match_start_index: start_idx,
|
|
617
|
+
_internal_op: "COMMENT_ONLY",
|
|
618
|
+
_active_mapper_ref: active_mapper
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
let effective_op = "";
|
|
623
|
+
let final_target = "";
|
|
624
|
+
let final_new = "";
|
|
625
|
+
let effective_start_idx = start_idx;
|
|
626
|
+
|
|
627
|
+
if (effective_new_text.startsWith(actual_doc_text)) {
|
|
628
|
+
effective_op = "INSERTION";
|
|
629
|
+
final_new = effective_new_text.substring(actual_doc_text.length);
|
|
630
|
+
effective_start_idx = start_idx + match_len;
|
|
631
|
+
} else {
|
|
632
|
+
const [prefix_len, suffix_len] = trim_common_context(actual_doc_text, effective_new_text);
|
|
633
|
+
const t_end = actual_doc_text.length - suffix_len;
|
|
634
|
+
const n_end = effective_new_text.length - suffix_len;
|
|
635
|
+
|
|
636
|
+
final_target = actual_doc_text.substring(prefix_len, t_end);
|
|
637
|
+
final_new = effective_new_text.substring(prefix_len, n_end);
|
|
638
|
+
effective_start_idx = start_idx + prefix_len
|
|
639
|
+
|
|
640
|
+
if (!final_target && final_new) effective_op = "INSERTION";
|
|
641
|
+
else if (final_target && !final_new) effective_op = "DELETION";
|
|
642
|
+
else if (final_target && final_new) effective_op = "MODIFICATION";
|
|
643
|
+
else effective_op = "COMMENT_ONLY";
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return {
|
|
647
|
+
type: "modify",
|
|
648
|
+
target_text: final_target,
|
|
649
|
+
new_text: final_new,
|
|
650
|
+
comment: edit.comment,
|
|
651
|
+
_match_start_index: effective_start_idx,
|
|
652
|
+
_internal_op: effective_op,
|
|
653
|
+
_active_mapper_ref: active_mapper
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
private _apply_single_edit_indexed(edit: any, orig_new: string | null, rebuild_map: boolean): boolean {
|
|
658
|
+
let op = edit._internal_op;
|
|
659
|
+
const active_mapper = edit._active_mapper_ref || this.mapper;
|
|
660
|
+
const start_idx = edit._match_start_index || 0;
|
|
661
|
+
const length = edit.target_text ? edit.target_text.length : 0;
|
|
662
|
+
|
|
663
|
+
const del_id = ['DELETION', 'MODIFICATION'].includes(op) ? this._getNextId() : null;
|
|
664
|
+
const ins_id = ['INSERTION', 'MODIFICATION'].includes(op) ? this._getNextId() : null;
|
|
665
|
+
|
|
666
|
+
if (op === "COMMENT_ONLY") {
|
|
667
|
+
// Mocked for Port limits, normally anchors to found runs
|
|
668
|
+
return true;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (op === "INSERTION") {
|
|
672
|
+
const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(start_idx, rebuild_map);
|
|
673
|
+
if (!anchor_run && !anchor_para) return false;
|
|
674
|
+
|
|
675
|
+
const xmlDoc = this.doc.part._element.ownerDocument!;
|
|
676
|
+
const ins = this._create_track_change_tag('w:ins', '', ins_id);
|
|
677
|
+
|
|
678
|
+
const segments = this._parse_inline_markdown(edit.new_text || "");
|
|
679
|
+
for (const [segText, segProps] of segments) {
|
|
680
|
+
const r = xmlDoc.createElement('w:r');
|
|
681
|
+
this._apply_run_props(r, segProps, false);
|
|
682
|
+
const t = xmlDoc.createElement('w:t');
|
|
683
|
+
this._set_text_content(t, segText);
|
|
684
|
+
r.appendChild(t);
|
|
685
|
+
ins.appendChild(r);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (anchor_run) {
|
|
689
|
+
insertAfter(ins, anchor_run._element);
|
|
690
|
+
} else if (anchor_para) {
|
|
691
|
+
anchor_para._element.appendChild(ins);
|
|
692
|
+
}
|
|
693
|
+
return true;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// DELETION / MODIFICATION
|
|
697
|
+
const target_runs = active_mapper.find_target_runs_by_index(start_idx, length, rebuild_map);
|
|
698
|
+
if (target_runs.length === 0) return false;
|
|
699
|
+
|
|
700
|
+
let last_del: Element | null = null;
|
|
701
|
+
for (const run of target_runs) {
|
|
702
|
+
const del_tag = this._create_track_change_tag('w:del', '', del_id);
|
|
703
|
+
const new_run = run._element.cloneNode(true) as Element;
|
|
704
|
+
|
|
705
|
+
const tNodes = Array.from(new_run.getElementsByTagName('w:t'));
|
|
706
|
+
tNodes.forEach(t => {
|
|
707
|
+
const delText = new_run.ownerDocument!.createElement('w:delText');
|
|
708
|
+
delText.textContent = t.textContent;
|
|
709
|
+
if (t.hasAttribute('xml:space')) delText.setAttribute('xml:space', 'preserve');
|
|
710
|
+
new_run.replaceChild(delText, t);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
del_tag.appendChild(new_run);
|
|
714
|
+
run._element.parentNode?.replaceChild(del_tag, run._element);
|
|
715
|
+
last_del = del_tag;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (op === "MODIFICATION" && edit.new_text && last_del) {
|
|
719
|
+
const xmlDoc = this.doc.part._element.ownerDocument!;
|
|
720
|
+
const ins = this._create_track_change_tag('w:ins', '', ins_id);
|
|
721
|
+
const segments = this._parse_inline_markdown(edit.new_text);
|
|
722
|
+
for (const [segText, segProps] of segments) {
|
|
723
|
+
const r = xmlDoc.createElement('w:r');
|
|
724
|
+
this._apply_run_props(r, segProps, false);
|
|
725
|
+
const t = xmlDoc.createElement('w:t');
|
|
726
|
+
this._set_text_content(t, segText);
|
|
727
|
+
r.appendChild(t);
|
|
728
|
+
ins.appendChild(r);
|
|
729
|
+
}
|
|
730
|
+
insertAfter(ins, last_del);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return true;
|
|
734
|
+
}
|
|
735
|
+
}
|