@adeu/core 1.17.1 → 1.18.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/dist/index.cjs +514 -106
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +49 -1
- package/dist/index.d.ts +49 -1
- package/dist/index.js +514 -106
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/engine.bugs.test.ts +12 -14
- package/src/engine.issue23.test.ts +23 -18
- package/src/engine.qa.test.ts +4 -4
- package/src/engine.tables.test.ts +138 -36
- package/src/engine.ts +700 -134
- package/src/ingest.ts +16 -1
- package/src/mapper.ts +23 -1
- package/src/markup.ts +1 -1
- package/src/reject_and_nested_redline.test.ts +137 -0
- package/src/repro.feedback.test.ts +160 -0
- package/src/repro.report.test.ts +132 -0
- package/src/repro_qa_report_v3.test.ts +186 -0
- package/src/repro_report_boundary_failures.test.ts +108 -0
package/src/engine.ts
CHANGED
|
@@ -65,6 +65,81 @@ function insertAtIndex(parent: Element, index: number, child: Node) {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
|
|
69
|
+
function safeCloneEdit(val: any, seen: WeakMap<any, any> = new WeakMap()): any {
|
|
70
|
+
if (val === null || typeof val !== "object") {
|
|
71
|
+
return val;
|
|
72
|
+
}
|
|
73
|
+
if (seen.has(val)) {
|
|
74
|
+
return seen.get(val);
|
|
75
|
+
}
|
|
76
|
+
if (val.nodeType !== undefined || typeof val.cloneNode === "function") {
|
|
77
|
+
return val;
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(val)) {
|
|
80
|
+
const copy: any[] = [];
|
|
81
|
+
seen.set(val, copy);
|
|
82
|
+
for (let i = 0; i < val.length; i++) {
|
|
83
|
+
copy.push(safeCloneEdit(val[i], seen));
|
|
84
|
+
}
|
|
85
|
+
return copy;
|
|
86
|
+
}
|
|
87
|
+
const copy: any = {};
|
|
88
|
+
seen.set(val, copy);
|
|
89
|
+
for (const key of Object.keys(val)) {
|
|
90
|
+
copy[key] = safeCloneEdit(val[key], seen);
|
|
91
|
+
}
|
|
92
|
+
return copy;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function takeSnapshot(doc: any): any {
|
|
96
|
+
const parts = [...doc.pkg.parts];
|
|
97
|
+
const unzipped = { ...doc.pkg.unzipped };
|
|
98
|
+
const rels = new Map<any, Map<string, any>>();
|
|
99
|
+
const elements = new Map<any, Element>();
|
|
100
|
+
for (const part of parts) {
|
|
101
|
+
rels.set(part, new Map(part.rels));
|
|
102
|
+
if (part._element) {
|
|
103
|
+
elements.set(part, part._element.cloneNode(true) as Element);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { parts, unzipped, rels, elements };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function restoreSnapshot(doc: any, snapshot: any): void {
|
|
110
|
+
doc.pkg.parts = [...snapshot.parts];
|
|
111
|
+
for (const key of Object.keys(doc.pkg.unzipped)) {
|
|
112
|
+
delete doc.pkg.unzipped[key];
|
|
113
|
+
}
|
|
114
|
+
for (const [key, val] of Object.entries(snapshot.unzipped)) {
|
|
115
|
+
doc.pkg.unzipped[key] = val;
|
|
116
|
+
}
|
|
117
|
+
for (const part of snapshot.parts) {
|
|
118
|
+
part.rels = new Map(snapshot.rels.get(part)!);
|
|
119
|
+
const originalEl = snapshot.elements.get(part);
|
|
120
|
+
if (originalEl && part._element) {
|
|
121
|
+
const xmlDoc = part._element.ownerDocument;
|
|
122
|
+
if (xmlDoc && xmlDoc.documentElement) {
|
|
123
|
+
xmlDoc.replaceChild(originalEl, xmlDoc.documentElement);
|
|
124
|
+
}
|
|
125
|
+
part._element = originalEl;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function stripMatchingHeadingHashes(target: string, newText: string): [string, string] {
|
|
131
|
+
if (!target || !newText) return [target, newText];
|
|
132
|
+
const targetMatch = target.match(/^(#+)\s+/);
|
|
133
|
+
const newMatch = newText.match(/^(#+)\s+/);
|
|
134
|
+
if (targetMatch && newMatch && targetMatch[1] === newMatch[1]) {
|
|
135
|
+
const hashes = targetMatch[1];
|
|
136
|
+
const targetClean = target.substring(hashes.length).trimStart();
|
|
137
|
+
const newClean = newText.substring(hashes.length).trimStart();
|
|
138
|
+
return [targetClean, newClean];
|
|
139
|
+
}
|
|
140
|
+
return [target, newText];
|
|
141
|
+
}
|
|
142
|
+
|
|
68
143
|
// --- Validation ---
|
|
69
144
|
export class BatchValidationError extends Error {
|
|
70
145
|
public errors: string[];
|
|
@@ -75,7 +150,7 @@ export class BatchValidationError extends Error {
|
|
|
75
150
|
}
|
|
76
151
|
}
|
|
77
152
|
|
|
78
|
-
export function validate_edit_strings(edits: any[]): string[] {
|
|
153
|
+
export function validate_edit_strings(edits: any[], index_offset: number = 0): string[] {
|
|
79
154
|
const errors: string[] = [];
|
|
80
155
|
|
|
81
156
|
for (let i = 0; i < edits.length; i++) {
|
|
@@ -90,7 +165,7 @@ export function validate_edit_strings(edits: any[]): string[] {
|
|
|
90
165
|
n_text.includes("{==")
|
|
91
166
|
) {
|
|
92
167
|
errors.push(
|
|
93
|
-
`- 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.`,
|
|
168
|
+
`- Edit ${i + 1 + index_offset} Failed: Do not manually write CriticMarkup tags ({++, {--, {>>, {==) in \`new_text\`. The engine handles redlining automatically. To add a comment, use the \`comment\` parameter.`,
|
|
94
169
|
);
|
|
95
170
|
}
|
|
96
171
|
|
|
@@ -107,11 +182,11 @@ export function validate_edit_strings(edits: any[]): string[] {
|
|
|
107
182
|
)
|
|
108
183
|
) {
|
|
109
184
|
errors.push(
|
|
110
|
-
`- 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.`,
|
|
185
|
+
`- Edit ${i + 1 + index_offset} Failed: Cannot insert footnote/endnote markers via text replace. Markers like \`[^fn-N]\` are read-only projections. Use Word's References menu.`,
|
|
111
186
|
);
|
|
112
187
|
} else {
|
|
113
188
|
errors.push(
|
|
114
|
-
`- Edit ${i + 1} Failed: Cannot delete footnote/endnote references via text replace. The marker corresponds to a structural XML element.`,
|
|
189
|
+
`- Edit ${i + 1 + index_offset} Failed: Cannot delete footnote/endnote references via text replace. The marker corresponds to a structural XML element.`,
|
|
115
190
|
);
|
|
116
191
|
}
|
|
117
192
|
}
|
|
@@ -123,11 +198,11 @@ export function validate_edit_strings(edits: any[]): string[] {
|
|
|
123
198
|
if (t_links.length !== n_links.length) {
|
|
124
199
|
if (n_links.length > t_links.length) {
|
|
125
200
|
errors.push(
|
|
126
|
-
`- Edit ${i + 1} Failed: Cannot insert hyperlinks via text replace. Use a dedicated structural operation.`,
|
|
201
|
+
`- Edit ${i + 1 + index_offset} Failed: Cannot insert hyperlinks via text replace. Use a dedicated structural operation.`,
|
|
127
202
|
);
|
|
128
203
|
} else {
|
|
129
204
|
errors.push(
|
|
130
|
-
`- Edit ${i + 1} Failed: Cannot delete hyperlinks via text replace. The marker corresponds to a structural XML element.`,
|
|
205
|
+
`- Edit ${i + 1 + index_offset} Failed: Cannot delete hyperlinks via text replace. The marker corresponds to a structural XML element.`,
|
|
131
206
|
);
|
|
132
207
|
}
|
|
133
208
|
} else if (
|
|
@@ -135,7 +210,7 @@ export function validate_edit_strings(edits: any[]): string[] {
|
|
|
135
210
|
JSON.stringify(t_links) !== JSON.stringify(n_links)
|
|
136
211
|
) {
|
|
137
212
|
errors.push(
|
|
138
|
-
`- Edit ${i + 1} Failed: Can only edit or retarget one hyperlink per text replacement. Please split into multiple edits.`,
|
|
213
|
+
`- Edit ${i + 1 + index_offset} Failed: Can only edit or retarget one hyperlink per text replacement. Please split into multiple edits.`,
|
|
139
214
|
);
|
|
140
215
|
}
|
|
141
216
|
}
|
|
@@ -146,18 +221,18 @@ export function validate_edit_strings(edits: any[]): string[] {
|
|
|
146
221
|
if (t_xrefs.length !== n_xrefs.length) {
|
|
147
222
|
if (n_xrefs.length > t_xrefs.length) {
|
|
148
223
|
errors.push(
|
|
149
|
-
`- Edit ${i + 1} Failed: Cannot insert cross-references via text replace. Markers are read-only projections.`,
|
|
224
|
+
`- Edit ${i + 1 + index_offset} Failed: Cannot insert cross-references via text replace. Markers are read-only projections.`,
|
|
150
225
|
);
|
|
151
226
|
} else {
|
|
152
227
|
errors.push(
|
|
153
|
-
`- Edit ${i + 1} Failed: Cannot delete cross-references via text replace. The marker corresponds to a structural XML element.`,
|
|
228
|
+
`- Edit ${i + 1 + index_offset} Failed: Cannot delete cross-references via text replace. The marker corresponds to a structural XML element.`,
|
|
154
229
|
);
|
|
155
230
|
}
|
|
156
231
|
} else {
|
|
157
232
|
// Advanced XREF validation simplified for port scope
|
|
158
233
|
if (JSON.stringify(t_xrefs) !== JSON.stringify(n_xrefs)) {
|
|
159
234
|
errors.push(
|
|
160
|
-
`- Edit ${i + 1} Failed: Modifying or retargeting cross-reference markers is disallowed to prevent dependency corruption.`,
|
|
235
|
+
`- Edit ${i + 1 + index_offset} Failed: Modifying or retargeting cross-reference markers is disallowed to prevent dependency corruption.`,
|
|
161
236
|
);
|
|
162
237
|
}
|
|
163
238
|
}
|
|
@@ -172,7 +247,7 @@ export function validate_edit_strings(edits: any[]): string[] {
|
|
|
172
247
|
t_anchors.filter((x: string) => x === a).length
|
|
173
248
|
) {
|
|
174
249
|
errors.push(
|
|
175
|
-
`- Edit ${i + 1} Failed: Cannot modify or insert internal anchor markers (\`{#...}\`). These represent structural XML bookmarks.`,
|
|
250
|
+
`- Edit ${i + 1 + index_offset} Failed: Cannot modify or insert internal anchor markers (\`{#...}\`). These represent structural XML bookmarks.`,
|
|
176
251
|
);
|
|
177
252
|
break;
|
|
178
253
|
}
|
|
@@ -190,7 +265,7 @@ export function validate_edit_strings(edits: any[]): string[] {
|
|
|
190
265
|
stripped.substring(level) === ""
|
|
191
266
|
) {
|
|
192
267
|
errors.push(
|
|
193
|
-
`- Edit ${i + 1} Failed: Heading level ${level} is not supported (maximum is 6).`,
|
|
268
|
+
`- Edit ${i + 1 + index_offset} Failed: Heading level ${level} is not supported (maximum is 6).`,
|
|
194
269
|
);
|
|
195
270
|
break;
|
|
196
271
|
}
|
|
@@ -202,10 +277,12 @@ export function validate_edit_strings(edits: any[]): string[] {
|
|
|
202
277
|
t_text.includes("READONLY_BOUNDARY_START") ||
|
|
203
278
|
n_text.includes("READONLY_BOUNDARY_START") ||
|
|
204
279
|
t_text.includes("# Document Structure (Read-Only)") ||
|
|
205
|
-
n_text.includes("# Document Structure (Read-Only)")
|
|
280
|
+
n_text.includes("# Document Structure (Read-Only)") ||
|
|
281
|
+
t_text.includes("Document Structure (Read-Only)") ||
|
|
282
|
+
n_text.includes("Document Structure (Read-Only)")
|
|
206
283
|
) {
|
|
207
284
|
errors.push(
|
|
208
|
-
`- Edit ${i + 1} Failed: Modification targets the read-only boundary (Structural Appendix). This section cannot be edited.`,
|
|
285
|
+
`- Edit ${i + 1 + index_offset} Failed: Modification targets the read-only boundary (Structural Appendix). This section cannot be edited.`,
|
|
209
286
|
);
|
|
210
287
|
}
|
|
211
288
|
}
|
|
@@ -233,11 +310,7 @@ export class RedlineEngine {
|
|
|
233
310
|
const w16du_ns =
|
|
234
311
|
"http://schemas.microsoft.com/office/word/2023/wordml/word16du";
|
|
235
312
|
for (const part of this.doc.pkg.parts) {
|
|
236
|
-
if (
|
|
237
|
-
part === this.doc.part ||
|
|
238
|
-
(part.contentType.includes("wordprocessingml") &&
|
|
239
|
-
part.contentType.endsWith("+xml"))
|
|
240
|
-
) {
|
|
313
|
+
if (part === this.doc.part) {
|
|
241
314
|
if (!part._element.hasAttribute("xmlns:w16du")) {
|
|
242
315
|
part._element.setAttribute("xmlns:w16du", w16du_ns);
|
|
243
316
|
}
|
|
@@ -256,7 +329,51 @@ export class RedlineEngine {
|
|
|
256
329
|
}
|
|
257
330
|
return null;
|
|
258
331
|
}
|
|
259
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Best-effort "did you mean" hint for a failed target. The common loop trap
|
|
334
|
+
* (observed in the field) is an anchored regex like `^\( x \)$` against a
|
|
335
|
+
* mid-document string: ^/$ bind to the whole full_text, so it never matches
|
|
336
|
+
* even though the literal `( x )` is present. We strip regex anchoring/escapes
|
|
337
|
+
* and probe full_text for a literal occurrence; if found, we tell the model
|
|
338
|
+
* the exact literal that WOULD match so it drops the anchors instead of
|
|
339
|
+
* escalating the regex further.
|
|
340
|
+
*/
|
|
341
|
+
private _nearest_match_hint(
|
|
342
|
+
target_text: string | undefined,
|
|
343
|
+
is_regex: boolean,
|
|
344
|
+
): string {
|
|
345
|
+
if (!target_text) return "";
|
|
346
|
+
let probe = target_text;
|
|
347
|
+
if (is_regex) {
|
|
348
|
+
// Strip leading/trailing anchors and surrounding \s* the model tends to add.
|
|
349
|
+
probe = probe.replace(/^\^/, "").replace(/\$$/, "");
|
|
350
|
+
probe = probe.replace(/^\\s\*/, "").replace(/\\s\*$/, "");
|
|
351
|
+
// Unescape the common literal escapes so "\( x \)" -> "( x )".
|
|
352
|
+
probe = probe.replace(/\\([.^$*+?()[\]{}|\\/])/g, "$1");
|
|
353
|
+
}
|
|
354
|
+
probe = probe.trim();
|
|
355
|
+
if (!probe || probe === target_text) {
|
|
356
|
+
// No anchors to strip, or nothing changed: nothing useful to suggest.
|
|
357
|
+
if (!is_regex) return "";
|
|
358
|
+
}
|
|
359
|
+
const idx = this.mapper.full_text.indexOf(probe);
|
|
360
|
+
if (idx !== -1) {
|
|
361
|
+
const ctx_start = Math.max(0, idx - 15);
|
|
362
|
+
const ctx_end = Math.min(
|
|
363
|
+
this.mapper.full_text.length,
|
|
364
|
+
idx + probe.length + 15,
|
|
365
|
+
);
|
|
366
|
+
const ctx = this.mapper.full_text
|
|
367
|
+
.substring(ctx_start, ctx_end)
|
|
368
|
+
.replace(/\n/g, " ");
|
|
369
|
+
return (
|
|
370
|
+
`\n Did you mean the literal "${probe}"? It appears in the document` +
|
|
371
|
+
` (…${ctx}…). If you used a regex, drop the ^/$ anchors — they match` +
|
|
372
|
+
` the start/end of the entire document, not a line.`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return "";
|
|
376
|
+
}
|
|
260
377
|
private _build_edit_context_previews(
|
|
261
378
|
edit: any,
|
|
262
379
|
): [string | null, string | null] {
|
|
@@ -264,10 +381,23 @@ export class RedlineEngine {
|
|
|
264
381
|
if (edit._resolved_proxy_edit) {
|
|
265
382
|
edit = edit._resolved_proxy_edit;
|
|
266
383
|
}
|
|
267
|
-
|
|
384
|
+
let start_idx = edit._resolved_start_idx;
|
|
268
385
|
if (start_idx === undefined || start_idx === null) return [null, null];
|
|
269
|
-
|
|
270
|
-
|
|
386
|
+
let target_text = edit.target_text || "";
|
|
387
|
+
let new_text = edit.new_text || "";
|
|
388
|
+
|
|
389
|
+
const [clean_target, target_style] = this._parse_markdown_style(target_text);
|
|
390
|
+
if (target_style && target_style.startsWith("Heading")) {
|
|
391
|
+
const prefix_len = target_text.length - clean_target.length;
|
|
392
|
+
start_idx += prefix_len;
|
|
393
|
+
target_text = clean_target;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const [clean_new, new_style] = this._parse_markdown_style(new_text);
|
|
397
|
+
if (new_style && new_style.startsWith("Heading")) {
|
|
398
|
+
new_text = clean_new;
|
|
399
|
+
}
|
|
400
|
+
|
|
271
401
|
const length = target_text.length;
|
|
272
402
|
const active_mapper = edit._active_mapper_ref || this.mapper;
|
|
273
403
|
const full_text = active_mapper.full_text;
|
|
@@ -303,7 +433,11 @@ export class RedlineEngine {
|
|
|
303
433
|
return maxId;
|
|
304
434
|
}
|
|
305
435
|
|
|
306
|
-
private _get_heading_path_and_page(
|
|
436
|
+
private _get_heading_path_and_page(
|
|
437
|
+
start_idx: number,
|
|
438
|
+
text: string,
|
|
439
|
+
page_offsets: number[],
|
|
440
|
+
): [string, number] {
|
|
307
441
|
let page = 1;
|
|
308
442
|
for (let i = 0; i < page_offsets.length; i++) {
|
|
309
443
|
if (start_idx >= page_offsets[i]) {
|
|
@@ -317,14 +451,17 @@ export class RedlineEngine {
|
|
|
317
451
|
const lines = textBefore.split("\n");
|
|
318
452
|
const path: string[] = [];
|
|
319
453
|
let current_level = 999;
|
|
320
|
-
|
|
454
|
+
|
|
321
455
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
322
456
|
const line = lines[i];
|
|
323
457
|
const m = line.match(/^(#{1,6})\s+(.*)/);
|
|
324
458
|
if (m) {
|
|
325
459
|
const level = m[1].length;
|
|
326
460
|
if (level < current_level) {
|
|
327
|
-
let cleanHeading = m[2]
|
|
461
|
+
let cleanHeading = m[2]
|
|
462
|
+
.replace(/\*\*|__|[*_]/g, "")
|
|
463
|
+
.replace(/\{#[^}]+\}/g, "")
|
|
464
|
+
.trim();
|
|
328
465
|
if (cleanHeading.length > 80) {
|
|
329
466
|
cleanHeading = cleanHeading.substring(0, 80) + "...";
|
|
330
467
|
}
|
|
@@ -536,6 +673,106 @@ export class RedlineEngine {
|
|
|
536
673
|
}
|
|
537
674
|
}
|
|
538
675
|
|
|
676
|
+
/**
|
|
677
|
+
* Revert every tracked change, returning the document to the state it had
|
|
678
|
+
* before any revision was proposed. The exact inverse of
|
|
679
|
+
* accept_all_revisions:
|
|
680
|
+
*
|
|
681
|
+
* - <w:ins> -> removed together with all of its content (the proposed
|
|
682
|
+
* insertion never existed); an inserted row (<w:ins> in
|
|
683
|
+
* <w:trPr>) drops the whole row.
|
|
684
|
+
* - <w:del> -> unwrapped, restoring the original text (<w:delText> becomes
|
|
685
|
+
* <w:t> again); a row-deletion mark in <w:trPr> is removed so
|
|
686
|
+
* the row survives.
|
|
687
|
+
* - paragraph-mark <w:del> in pPr/rPr -> removed, undoing a proposed merge.
|
|
688
|
+
*
|
|
689
|
+
* Comments are annotations, not revisions, so standalone comments are left in
|
|
690
|
+
* place; only anchors stranded inside a rejected insertion are cleaned up.
|
|
691
|
+
*
|
|
692
|
+
* Insertions are reverted before deletions are restored so a deletion nested
|
|
693
|
+
* inside a foreign author's insertion is removed wholesale with the insertion
|
|
694
|
+
* — the contingent text disappears rather than being promoted to committed
|
|
695
|
+
* body text.
|
|
696
|
+
*
|
|
697
|
+
* Known limitation: tracked paragraph STRUCTURE changes (a split recorded as a
|
|
698
|
+
* pilcrow <w:ins>, or a merge recorded as a pilcrow <w:del>) are reverted only
|
|
699
|
+
* to the extent of dropping/keeping the mark; the original paragraph boundary
|
|
700
|
+
* is not reconstructed, because the merge protocol coalesces paragraphs
|
|
701
|
+
* destructively at edit time. Reverting run-level insertions/deletions (the
|
|
702
|
+
* common case) is exact. This limitation is shared with the Python engine.
|
|
703
|
+
*/
|
|
704
|
+
public reject_all_revisions() {
|
|
705
|
+
const parts_to_process: Element[] = [this.doc.element];
|
|
706
|
+
|
|
707
|
+
for (const part of this.doc.pkg.parts) {
|
|
708
|
+
if (part === this.doc.part) continue;
|
|
709
|
+
if (
|
|
710
|
+
part.contentType.includes("wordprocessingml") &&
|
|
711
|
+
part.contentType.endsWith("+xml")
|
|
712
|
+
) {
|
|
713
|
+
parts_to_process.push(part._element);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
for (const root_element of parts_to_process) {
|
|
718
|
+
// 1. Reject insertions: drop the <w:ins> and everything inside it.
|
|
719
|
+
// Document order means an outer <w:ins> is handled before a nested
|
|
720
|
+
// one; removing the outer detaches the inner (guarded below).
|
|
721
|
+
const insNodes = findAllDescendants(root_element, "w:ins");
|
|
722
|
+
for (const ins of insNodes) {
|
|
723
|
+
const parent = ins.parentNode as Element | null;
|
|
724
|
+
if (!parent) continue;
|
|
725
|
+
this._clean_wrapping_comments(ins);
|
|
726
|
+
this._delete_comments_in_element(ins);
|
|
727
|
+
if (parent.tagName === "w:trPr") {
|
|
728
|
+
const row = parent.parentNode as Element | null;
|
|
729
|
+
if (row && row.parentNode) {
|
|
730
|
+
row.parentNode.removeChild(row);
|
|
731
|
+
}
|
|
732
|
+
} else {
|
|
733
|
+
parent.removeChild(ins);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// 2. Reject paragraph-mark deletions: keep the paragraph break.
|
|
738
|
+
const pNodes = findAllDescendants(root_element, "w:p");
|
|
739
|
+
for (const p of pNodes) {
|
|
740
|
+
const pPr = findChild(p, "w:pPr");
|
|
741
|
+
if (pPr) {
|
|
742
|
+
const rPr = findChild(pPr, "w:rPr");
|
|
743
|
+
const delMark = rPr ? findChild(rPr, "w:del") : null;
|
|
744
|
+
if (rPr && delMark) {
|
|
745
|
+
rPr.removeChild(delMark);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// 3. Reject deletions: restore the original text.
|
|
751
|
+
const delNodes = findAllDescendants(root_element, "w:del");
|
|
752
|
+
for (const d of delNodes) {
|
|
753
|
+
const parent = d.parentNode as Element | null;
|
|
754
|
+
if (!parent) continue;
|
|
755
|
+
this._clean_wrapping_comments(d);
|
|
756
|
+
if (parent.tagName === "w:trPr") {
|
|
757
|
+
parent.removeChild(d);
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
const delTexts = Array.from(d.getElementsByTagName("w:delText"));
|
|
761
|
+
for (const dt of delTexts) {
|
|
762
|
+
const t = d.ownerDocument!.createElement("w:t");
|
|
763
|
+
t.textContent = dt.textContent;
|
|
764
|
+
if (dt.hasAttribute("xml:space"))
|
|
765
|
+
t.setAttribute("xml:space", "preserve");
|
|
766
|
+
dt.parentNode?.replaceChild(t, dt);
|
|
767
|
+
}
|
|
768
|
+
while (d.firstChild) {
|
|
769
|
+
parent.insertBefore(d.firstChild, d);
|
|
770
|
+
}
|
|
771
|
+
parent.removeChild(d);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
539
776
|
private _getNextId(): string {
|
|
540
777
|
this.current_id++;
|
|
541
778
|
return this.current_id.toString();
|
|
@@ -1220,33 +1457,39 @@ export class RedlineEngine {
|
|
|
1220
1457
|
}
|
|
1221
1458
|
}
|
|
1222
1459
|
|
|
1223
|
-
public validate_edits(edits: any[]): string[] {
|
|
1460
|
+
public validate_edits(edits: any[], index_offset: number = 0): string[] {
|
|
1224
1461
|
const errors: string[] = [];
|
|
1225
1462
|
if (!this.mapper.full_text) this.mapper["_build_map"]();
|
|
1226
1463
|
|
|
1227
|
-
errors.push(...validate_edit_strings(edits));
|
|
1464
|
+
errors.push(...validate_edit_strings(edits, index_offset));
|
|
1228
1465
|
|
|
1229
1466
|
for (let i = 0; i < edits.length; i++) {
|
|
1230
1467
|
const edit = edits[i];
|
|
1231
1468
|
if (typeof edit !== "object" || edit === null) {
|
|
1232
1469
|
errors.push(
|
|
1233
|
-
`- Edit ${i + 1} Failed: Invalid change format. Expected a JSON object, but received a primitive ${typeof edit}. Do not pass raw strings
|
|
1470
|
+
`- Edit ${i + 1 + index_offset} Failed: Invalid change format. Expected a JSON object, but received a primitive ${typeof edit}. Do not pass raw strings.`,
|
|
1234
1471
|
);
|
|
1235
1472
|
continue;
|
|
1236
1473
|
}
|
|
1237
1474
|
if (!edit.target_text) continue;
|
|
1238
|
-
|
|
1475
|
+
|
|
1239
1476
|
const is_regex = (edit as any).regex || false;
|
|
1240
1477
|
const match_mode = (edit as any).match_mode || "strict";
|
|
1241
1478
|
|
|
1242
|
-
let matches = this.mapper.find_all_match_indices(
|
|
1479
|
+
let matches = this.mapper.find_all_match_indices(
|
|
1480
|
+
edit.target_text,
|
|
1481
|
+
is_regex,
|
|
1482
|
+
);
|
|
1243
1483
|
let activeText = this.mapper.full_text;
|
|
1244
1484
|
let target_mapper = this.mapper;
|
|
1245
1485
|
|
|
1246
1486
|
if (matches.length === 0) {
|
|
1247
1487
|
if (!this.clean_mapper)
|
|
1248
1488
|
this.clean_mapper = new DocumentMapper(this.doc, true);
|
|
1249
|
-
matches = this.clean_mapper.find_all_match_indices(
|
|
1489
|
+
matches = this.clean_mapper.find_all_match_indices(
|
|
1490
|
+
edit.target_text,
|
|
1491
|
+
is_regex,
|
|
1492
|
+
);
|
|
1250
1493
|
if (matches.length > 0) {
|
|
1251
1494
|
activeText = this.clean_mapper.full_text;
|
|
1252
1495
|
target_mapper = this.clean_mapper;
|
|
@@ -1278,7 +1521,10 @@ export class RedlineEngine {
|
|
|
1278
1521
|
if (!this.original_mapper) {
|
|
1279
1522
|
this.original_mapper = new DocumentMapper(this.doc, false, true);
|
|
1280
1523
|
}
|
|
1281
|
-
const orig_matches = this.original_mapper.find_all_match_indices(
|
|
1524
|
+
const orig_matches = this.original_mapper.find_all_match_indices(
|
|
1525
|
+
edit.target_text,
|
|
1526
|
+
is_regex,
|
|
1527
|
+
);
|
|
1282
1528
|
if (orig_matches.length > 0) {
|
|
1283
1529
|
is_deleted_text = true;
|
|
1284
1530
|
for (const [start, length] of orig_matches) {
|
|
@@ -1289,7 +1535,10 @@ export class RedlineEngine {
|
|
|
1289
1535
|
if (s.run !== null) {
|
|
1290
1536
|
let parent = s.run._element as Node | null;
|
|
1291
1537
|
while (parent) {
|
|
1292
|
-
if (
|
|
1538
|
+
if (
|
|
1539
|
+
parent.nodeType === 1 &&
|
|
1540
|
+
(parent as Element).tagName === "w:del"
|
|
1541
|
+
) {
|
|
1293
1542
|
const auth = (parent as Element).getAttribute("w:author");
|
|
1294
1543
|
if (auth) {
|
|
1295
1544
|
deleted_authors.add(auth);
|
|
@@ -1306,30 +1555,36 @@ export class RedlineEngine {
|
|
|
1306
1555
|
|
|
1307
1556
|
if (matches.length === 0) {
|
|
1308
1557
|
if (is_deleted_text) {
|
|
1309
|
-
const author_phrase =
|
|
1310
|
-
|
|
1311
|
-
|
|
1558
|
+
const author_phrase =
|
|
1559
|
+
deleted_authors.size > 0
|
|
1560
|
+
? `by ${Array.from(deleted_authors).sort().join(", ")}`
|
|
1561
|
+
: "by an existing revision";
|
|
1312
1562
|
errors.push(
|
|
1313
|
-
`- Edit ${i + 1} Failed: Target text matches text inside a tracked deletion ${author_phrase}. Reject/accept that change first or target the active replacement text instead.`,
|
|
1563
|
+
`- Edit ${i + 1 + index_offset} Failed: Target text matches text inside a tracked deletion ${author_phrase}. Reject/accept that change first or target the active replacement text instead.`,
|
|
1314
1564
|
);
|
|
1315
1565
|
} else {
|
|
1566
|
+
const hint = this._nearest_match_hint(edit.target_text, is_regex);
|
|
1316
1567
|
errors.push(
|
|
1317
|
-
`- Edit ${i + 1} Failed: Target text not found in document:\n "${edit.target_text}"`,
|
|
1568
|
+
`- Edit ${i + 1 + index_offset} Failed: Target text not found in document:\n "${edit.target_text}"${hint}`,
|
|
1318
1569
|
);
|
|
1319
1570
|
}
|
|
1320
1571
|
} else if (matches.length > 1 && match_mode === "strict") {
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1572
|
+
if (edit.target_text.includes("|")) {
|
|
1573
|
+
matches = matches.slice(0, 1);
|
|
1574
|
+
} else {
|
|
1575
|
+
const positions: [number, number][] = matches.map(([start, length]) => [
|
|
1576
|
+
start,
|
|
1577
|
+
start + length,
|
|
1578
|
+
]);
|
|
1579
|
+
errors.push(
|
|
1580
|
+
format_ambiguity_error(
|
|
1581
|
+
i + 1 + index_offset,
|
|
1582
|
+
edit.target_text,
|
|
1583
|
+
activeText,
|
|
1584
|
+
positions,
|
|
1585
|
+
),
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1333
1588
|
}
|
|
1334
1589
|
|
|
1335
1590
|
// BUG-23-4: when the effective (context-trimmed) target spans a
|
|
@@ -1346,38 +1601,63 @@ export class RedlineEngine {
|
|
|
1346
1601
|
(edit.new_text || "").length - sfx,
|
|
1347
1602
|
);
|
|
1348
1603
|
if (final_target.includes("\n\n")) {
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1604
|
+
// A *balanced* multi-paragraph modification (target and replacement
|
|
1605
|
+
// carry the same number of paragraph breaks) is safe: it is split
|
|
1606
|
+
// into one sub-edit per paragraph segment and applied, leaving the
|
|
1607
|
+
// structural \n\n breaks untouched. Only reject when the paragraph
|
|
1608
|
+
// structure would actually change (a merge or split), which cannot be
|
|
1609
|
+
// expressed as a per-paragraph text replacement. See
|
|
1610
|
+
// _pre_resolve_heuristic_edit.
|
|
1611
|
+
const balanced =
|
|
1612
|
+
matched.split("\n\n").length ===
|
|
1613
|
+
(edit.new_text || "").split("\n\n").length;
|
|
1614
|
+
if (!balanced) {
|
|
1615
|
+
if (final_new.includes("\n\n")) {
|
|
1616
|
+
const parts = matched.split("\n\n");
|
|
1617
|
+
if (
|
|
1618
|
+
parts.length >= 2 &&
|
|
1619
|
+
parts[0].trim() !== "" &&
|
|
1620
|
+
parts[parts.length - 1].trim() !== ""
|
|
1621
|
+
) {
|
|
1622
|
+
errors.push(
|
|
1623
|
+
`- Edit ${i + 1 + index_offset} Failed: target_text spans a paragraph boundary with body text on both sides. The paragraph break is a structural element, not literal text, so it cannot be replaced as a single span without corrupting the document. Split this into one edit per paragraph.`,
|
|
1624
|
+
);
|
|
1625
|
+
}
|
|
1626
|
+
} else {
|
|
1627
|
+
const parts = final_target.split("\n\n");
|
|
1628
|
+
if (
|
|
1629
|
+
parts.length >= 2 &&
|
|
1630
|
+
parts[0].trim() !== "" &&
|
|
1631
|
+
parts[parts.length - 1].trim() !== ""
|
|
1632
|
+
) {
|
|
1633
|
+
errors.push(
|
|
1634
|
+
`- Edit ${i + 1 + index_offset} Failed: target_text spans a paragraph boundary with body text on both sides. The paragraph break is a structural element, not literal text, so it cannot be replaced as a single span without corrupting the document. Split this into one edit per paragraph.`,
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1370
1637
|
}
|
|
1371
1638
|
}
|
|
1372
1639
|
}
|
|
1373
1640
|
}
|
|
1374
1641
|
|
|
1375
1642
|
for (const [start, length] of matches) {
|
|
1376
|
-
|
|
1643
|
+
// Filter spans from the SAME mapper the match indices came from
|
|
1644
|
+
// (target_mapper may be the clean mapper); using this.mapper.spans here
|
|
1645
|
+
// would read a different coordinate space and miss the foreign <w:ins>
|
|
1646
|
+
// overlap for clean-mapper-resolved targets — silently letting a
|
|
1647
|
+
// partial straddle through. (Python filters target_mapper.spans too.)
|
|
1648
|
+
const spans = target_mapper.spans.filter(
|
|
1377
1649
|
(s) => s.end > start && s.start < start + length,
|
|
1378
1650
|
);
|
|
1379
|
-
const
|
|
1651
|
+
const insAuthors = new Set<string>();
|
|
1652
|
+
const commentAuthors = new Set<string>();
|
|
1653
|
+
// Does any real (run-backed) text in the target lie OUTSIDE a foreign
|
|
1654
|
+
// insertion? If so the target only partially overlaps the insertion and
|
|
1655
|
+
// replacing it as one span would straddle the <w:ins> boundary — that
|
|
1656
|
+
// case must still be refused.
|
|
1657
|
+
let hasNonForeignRealText = false;
|
|
1380
1658
|
for (const s of spans) {
|
|
1659
|
+
if (s.run === null) continue;
|
|
1660
|
+
let isForeignIns = false;
|
|
1381
1661
|
if (s.ins_id) {
|
|
1382
1662
|
const insNodes = findAllDescendants(
|
|
1383
1663
|
this.doc.element,
|
|
@@ -1385,21 +1665,48 @@ export class RedlineEngine {
|
|
|
1385
1665
|
).filter((n) => n.getAttribute("w:id") === s.ins_id);
|
|
1386
1666
|
if (insNodes.length > 0) {
|
|
1387
1667
|
const auth = insNodes[0].getAttribute("w:author");
|
|
1388
|
-
if (auth && auth !== this.author)
|
|
1668
|
+
if (auth && auth !== this.author) {
|
|
1669
|
+
insAuthors.add(auth);
|
|
1670
|
+
isForeignIns = true;
|
|
1671
|
+
}
|
|
1389
1672
|
}
|
|
1390
1673
|
}
|
|
1674
|
+
if (!isForeignIns) hasNonForeignRealText = true;
|
|
1675
|
+
}
|
|
1676
|
+
for (const s of spans) {
|
|
1391
1677
|
if (s.comment_ids) {
|
|
1392
1678
|
for (const cid of s.comment_ids) {
|
|
1393
1679
|
const c_data = this.mapper.comments_map[cid];
|
|
1394
1680
|
if (c_data && c_data.author && c_data.author !== this.author) {
|
|
1395
|
-
|
|
1681
|
+
commentAuthors.add(c_data.author);
|
|
1396
1682
|
}
|
|
1397
1683
|
}
|
|
1398
1684
|
}
|
|
1399
1685
|
}
|
|
1400
|
-
if (
|
|
1686
|
+
if (insAuthors.size > 0 || commentAuthors.size > 0) {
|
|
1687
|
+
// A single (strict/first) modification whose target lies ENTIRELY
|
|
1688
|
+
// inside foreign-authored insertion(s), with no foreign comment
|
|
1689
|
+
// overlap, is allowed: track_delete_run splits the enclosing <w:ins>
|
|
1690
|
+
// and nests the change, producing valid tracked-change XML. Refuse the
|
|
1691
|
+
// remaining cases — match_mode "all" fan-outs, partial overlaps that
|
|
1692
|
+
// straddle the insertion boundary, and edits touching another author's
|
|
1693
|
+
// comment range.
|
|
1694
|
+
const fullyWithinForeignIns =
|
|
1695
|
+
insAuthors.size > 0 &&
|
|
1696
|
+
!hasNonForeignRealText &&
|
|
1697
|
+
commentAuthors.size === 0;
|
|
1698
|
+
if (
|
|
1699
|
+
(match_mode === "strict" || match_mode === "first") &&
|
|
1700
|
+
fullyWithinForeignIns
|
|
1701
|
+
) {
|
|
1702
|
+
continue;
|
|
1703
|
+
}
|
|
1704
|
+
const nestedAuthors = new Set<string>([
|
|
1705
|
+
...insAuthors,
|
|
1706
|
+
...commentAuthors,
|
|
1707
|
+
]);
|
|
1401
1708
|
errors.push(
|
|
1402
|
-
`- Edit ${i + 1} Failed: Modification targets an active insertion from another author (${Array.from(nestedAuthors).join(", ")}). Accept that change first or scope your edit outside of it.`,
|
|
1709
|
+
`- Edit ${i + 1 + index_offset} Failed: Modification targets an active insertion from another author (${Array.from(nestedAuthors).join(", ")}). Accept that change first or scope your edit outside of it.`,
|
|
1403
1710
|
);
|
|
1404
1711
|
}
|
|
1405
1712
|
}
|
|
@@ -1481,22 +1788,13 @@ export class RedlineEngine {
|
|
|
1481
1788
|
}
|
|
1482
1789
|
|
|
1483
1790
|
if (dry_run) {
|
|
1484
|
-
const
|
|
1485
|
-
|
|
1486
|
-
if (part._element) {
|
|
1487
|
-
baselines.set(part, part._element.cloneNode(true) as Element);
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1791
|
+
const snapshot = takeSnapshot(this.doc);
|
|
1792
|
+
const originalCurrentId = this.current_id;
|
|
1490
1793
|
try {
|
|
1491
1794
|
return this._process_batch_internal(changes, true);
|
|
1492
1795
|
} finally {
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
if (doc && doc.documentElement) {
|
|
1496
|
-
doc.replaceChild(originalEl, doc.documentElement);
|
|
1497
|
-
}
|
|
1498
|
-
part._element = originalEl;
|
|
1499
|
-
}
|
|
1796
|
+
restoreSnapshot(this.doc, snapshot);
|
|
1797
|
+
this.current_id = originalCurrentId;
|
|
1500
1798
|
this.mapper = new DocumentMapper(this.doc);
|
|
1501
1799
|
this.comments_manager = new CommentsManager(this.doc);
|
|
1502
1800
|
this.clean_mapper = null;
|
|
@@ -1510,23 +1808,52 @@ export class RedlineEngine {
|
|
|
1510
1808
|
changes: DocumentChange[],
|
|
1511
1809
|
dry_run_mode: boolean = false,
|
|
1512
1810
|
): any {
|
|
1811
|
+
// Pre-process edits: strip identical leading heading hashes from target_text and new_text
|
|
1812
|
+
for (const c of changes) {
|
|
1813
|
+
if (c && typeof c === "object" && (c as any).type === "modify" && (c as any).target_text && (c as any).new_text) {
|
|
1814
|
+
const [strippedTarget, strippedNew] = stripMatchingHeadingHashes((c as any).target_text, (c as any).new_text);
|
|
1815
|
+
(c as any).target_text = strippedTarget;
|
|
1816
|
+
(c as any).new_text = strippedNew;
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1513
1820
|
this.skipped_details = [];
|
|
1514
|
-
|
|
1515
|
-
|
|
1821
|
+
|
|
1822
|
+
const actions = changes.filter(
|
|
1823
|
+
(c) =>
|
|
1824
|
+
c !== null &&
|
|
1825
|
+
typeof c === "object" &&
|
|
1826
|
+
["accept", "reject", "reply"].includes(c.type),
|
|
1516
1827
|
);
|
|
1517
1828
|
const edits = changes.filter(
|
|
1518
|
-
(c) =>
|
|
1829
|
+
(c) =>
|
|
1830
|
+
c === null ||
|
|
1831
|
+
typeof c !== "object" ||
|
|
1832
|
+
!["accept", "reject", "reply"].includes(c.type),
|
|
1519
1833
|
);
|
|
1520
1834
|
|
|
1521
|
-
//
|
|
1835
|
+
// NOTE: a previous "edits_for_merge" pre-pass here silently UNWRAPPED a
|
|
1836
|
+
// foreign author's <w:ins> when a strict/first edit only partially straddled
|
|
1837
|
+
// its boundary — turning that author's tracked-inserted text into untracked
|
|
1838
|
+
// committed body text before the edit applied, destroying their provenance.
|
|
1839
|
+
// That is the same provenance-laundering failure mode the canonical engine
|
|
1840
|
+
// refuses, so it has been removed: a partial straddle now surfaces the
|
|
1841
|
+
// standard validation error ("Modification targets an active insertion from
|
|
1842
|
+
// another author …") via validate_edits, matching the Python engine. An edit
|
|
1843
|
+
// fully CONTAINED inside a foreign <w:ins> stays allowed and is handled by
|
|
1844
|
+
// nesting the <w:del> inside that <w:ins> (see _apply_single_edit_indexed /
|
|
1845
|
+
// _insert_and_split_ins).
|
|
1846
|
+
|
|
1847
|
+
// BUG-7: Unified single-pass validation in wet-run / standard mode.
|
|
1522
1848
|
if (!dry_run_mode) {
|
|
1523
|
-
const
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1849
|
+
const action_errors =
|
|
1850
|
+
actions.length > 0 ? this.validate_review_actions(actions) : [];
|
|
1851
|
+
const validate_edits_now = edits.length > 0 && action_errors.length > 0;
|
|
1852
|
+
const edit_errors = validate_edits_now ? this.validate_edits(edits) : [];
|
|
1853
|
+
const all_errors = [
|
|
1854
|
+
...action_errors,
|
|
1855
|
+
...edit_errors,
|
|
1856
|
+
];
|
|
1530
1857
|
if (all_errors.length > 0) {
|
|
1531
1858
|
throw new BatchValidationError(all_errors);
|
|
1532
1859
|
}
|
|
@@ -1564,8 +1891,9 @@ export class RedlineEngine {
|
|
|
1564
1891
|
|
|
1565
1892
|
if (edits.length > 0) {
|
|
1566
1893
|
if (dry_run_mode) {
|
|
1567
|
-
for (
|
|
1568
|
-
const
|
|
1894
|
+
for (let i = 0; i < edits.length; i++) {
|
|
1895
|
+
const edit = edits[i];
|
|
1896
|
+
const single_errors = this.validate_edits([edit], i);
|
|
1569
1897
|
const warning = this._check_punctuation_warning(
|
|
1570
1898
|
(edit as any).target_text || "",
|
|
1571
1899
|
);
|
|
@@ -1599,6 +1927,8 @@ export class RedlineEngine {
|
|
|
1599
1927
|
occurrences_modified: (edit as any)._occurrences_modified || 0,
|
|
1600
1928
|
match_mode: (edit as any).match_mode || "strict",
|
|
1601
1929
|
});
|
|
1930
|
+
this.mapper = new DocumentMapper(this.doc);
|
|
1931
|
+
this.clean_mapper = null;
|
|
1602
1932
|
} else {
|
|
1603
1933
|
skipped_edits++;
|
|
1604
1934
|
const error_msg =
|
|
@@ -1617,16 +1947,40 @@ export class RedlineEngine {
|
|
|
1617
1947
|
}
|
|
1618
1948
|
}
|
|
1619
1949
|
} else {
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1950
|
+
// Simulated dry-run sequentially for wet-run validation parity
|
|
1951
|
+
const snapshot = takeSnapshot(this.doc);
|
|
1952
|
+
const originalCurrentId = this.current_id;
|
|
1953
|
+
try {
|
|
1954
|
+
const sequential_errors: string[] = [];
|
|
1955
|
+
for (let i = 0; i < edits.length; i++) {
|
|
1956
|
+
const edit = edits[i];
|
|
1957
|
+
const single_errors = this.validate_edits([edit], i);
|
|
1958
|
+
if (single_errors.length > 0) {
|
|
1959
|
+
sequential_errors.push(...single_errors);
|
|
1960
|
+
} else {
|
|
1961
|
+
this.apply_edits([edit], page_offsets);
|
|
1962
|
+
this.mapper = new DocumentMapper(this.doc);
|
|
1963
|
+
this.clean_mapper = null;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
if (sequential_errors.length > 0) {
|
|
1967
|
+
throw new BatchValidationError(sequential_errors);
|
|
1968
|
+
}
|
|
1969
|
+
} catch (err) {
|
|
1970
|
+
restoreSnapshot(this.doc, snapshot);
|
|
1971
|
+
this.current_id = originalCurrentId;
|
|
1972
|
+
this.mapper = new DocumentMapper(this.doc);
|
|
1973
|
+
this.comments_manager = new CommentsManager(this.doc);
|
|
1974
|
+
this.clean_mapper = null;
|
|
1975
|
+
throw err;
|
|
1623
1976
|
}
|
|
1624
|
-
const cloned_edits = edits.map((e) => JSON.parse(JSON.stringify(e)));
|
|
1625
|
-
const res = this.apply_edits(cloned_edits, page_offsets);
|
|
1626
|
-
applied_edits = cloned_edits.filter((e) => (e as any)._applied_status).length;
|
|
1627
|
-
skipped_edits = cloned_edits.length - applied_edits;
|
|
1628
1977
|
|
|
1629
|
-
|
|
1978
|
+
applied_edits = edits.filter(
|
|
1979
|
+
(e) => (e as any)._applied_status,
|
|
1980
|
+
).length;
|
|
1981
|
+
skipped_edits = edits.length - applied_edits;
|
|
1982
|
+
|
|
1983
|
+
for (const edit of edits) {
|
|
1630
1984
|
const success = (edit as any)._applied_status || false;
|
|
1631
1985
|
const error_msg = (edit as any)._error_msg || null;
|
|
1632
1986
|
const warning = this._check_punctuation_warning(
|
|
@@ -1655,7 +2009,6 @@ export class RedlineEngine {
|
|
|
1655
2009
|
}
|
|
1656
2010
|
}
|
|
1657
2011
|
}
|
|
1658
|
-
|
|
1659
2012
|
return {
|
|
1660
2013
|
actions_applied: applied_actions,
|
|
1661
2014
|
actions_skipped: skipped_actions,
|
|
@@ -1668,7 +2021,10 @@ export class RedlineEngine {
|
|
|
1668
2021
|
};
|
|
1669
2022
|
}
|
|
1670
2023
|
|
|
1671
|
-
public apply_edits(
|
|
2024
|
+
public apply_edits(
|
|
2025
|
+
edits: any[],
|
|
2026
|
+
page_offsets: number[] = [],
|
|
2027
|
+
): [number, number] {
|
|
1672
2028
|
let applied = 0;
|
|
1673
2029
|
let skipped = 0;
|
|
1674
2030
|
|
|
@@ -1765,6 +2121,10 @@ export class RedlineEngine {
|
|
|
1765
2121
|
(b[0]._resolved_start_idx || 0) - (a[0]._resolved_start_idx || 0),
|
|
1766
2122
|
);
|
|
1767
2123
|
const occupied_ranges: [number, number][] = [];
|
|
2124
|
+
// Sub-edits split from one balanced multi-paragraph modification share a
|
|
2125
|
+
// _split_group_id; count the group as a single applied edit (and a single
|
|
2126
|
+
// occurrence), even though it touches several paragraphs.
|
|
2127
|
+
const counted_split_groups = new Set<number>();
|
|
1768
2128
|
|
|
1769
2129
|
for (const [edit, orig_new] of resolved_edits) {
|
|
1770
2130
|
const start = edit._resolved_start_idx || 0;
|
|
@@ -1797,21 +2157,45 @@ export class RedlineEngine {
|
|
|
1797
2157
|
}
|
|
1798
2158
|
|
|
1799
2159
|
if (success) {
|
|
1800
|
-
|
|
2160
|
+
// A balanced multi-paragraph split fans one logical edit into several
|
|
2161
|
+
// paragraph sub-edits sharing a _split_group_id; count it once. Edits
|
|
2162
|
+
// with no group id (the common case) always count.
|
|
2163
|
+
const group_id = edit._split_group_id;
|
|
2164
|
+
const first_in_group =
|
|
2165
|
+
group_id === undefined ||
|
|
2166
|
+
group_id === null ||
|
|
2167
|
+
!counted_split_groups.has(group_id);
|
|
2168
|
+
if (first_in_group && group_id !== undefined && group_id !== null) {
|
|
2169
|
+
counted_split_groups.add(group_id);
|
|
2170
|
+
}
|
|
2171
|
+
if (first_in_group) applied++;
|
|
1801
2172
|
occupied_ranges.push([start, end]);
|
|
1802
2173
|
edit._applied_status = true;
|
|
1803
2174
|
const parent = edit._parent_edit_ref;
|
|
1804
2175
|
if (parent) {
|
|
1805
2176
|
parent._applied_status = true;
|
|
1806
|
-
|
|
1807
|
-
|
|
2177
|
+
if (first_in_group) {
|
|
2178
|
+
parent._occurrences_modified =
|
|
2179
|
+
(parent._occurrences_modified || 0) + 1;
|
|
2180
|
+
}
|
|
2181
|
+
const [path, page] = this._get_heading_path_and_page(
|
|
2182
|
+
start,
|
|
2183
|
+
this.mapper.full_text,
|
|
2184
|
+
page_offsets,
|
|
2185
|
+
);
|
|
1808
2186
|
const pages: number[] = parent._pages || [];
|
|
1809
2187
|
if (!pages.includes(page)) pages.unshift(page);
|
|
1810
2188
|
parent._pages = pages;
|
|
1811
2189
|
parent._heading_path = path;
|
|
1812
2190
|
} else {
|
|
1813
|
-
|
|
1814
|
-
|
|
2191
|
+
if (first_in_group) {
|
|
2192
|
+
edit._occurrences_modified = (edit._occurrences_modified || 0) + 1;
|
|
2193
|
+
}
|
|
2194
|
+
const [path, page] = this._get_heading_path_and_page(
|
|
2195
|
+
start,
|
|
2196
|
+
this.mapper.full_text,
|
|
2197
|
+
page_offsets,
|
|
2198
|
+
);
|
|
1815
2199
|
const pages: number[] = edit._pages || [];
|
|
1816
2200
|
if (!pages.includes(page)) pages.unshift(page);
|
|
1817
2201
|
edit._pages = pages;
|
|
@@ -1993,13 +2377,19 @@ export class RedlineEngine {
|
|
|
1993
2377
|
const is_regex = edit.regex || false;
|
|
1994
2378
|
const match_mode = edit.match_mode || "strict";
|
|
1995
2379
|
|
|
1996
|
-
let matches = this.mapper.find_all_match_indices(
|
|
2380
|
+
let matches = this.mapper.find_all_match_indices(
|
|
2381
|
+
edit.target_text,
|
|
2382
|
+
is_regex,
|
|
2383
|
+
);
|
|
1997
2384
|
let use_clean_map = false;
|
|
1998
2385
|
|
|
1999
2386
|
if (matches.length === 0) {
|
|
2000
2387
|
if (!this.clean_mapper)
|
|
2001
2388
|
this.clean_mapper = new DocumentMapper(this.doc, true);
|
|
2002
|
-
matches = this.clean_mapper.find_all_match_indices(
|
|
2389
|
+
matches = this.clean_mapper.find_all_match_indices(
|
|
2390
|
+
edit.target_text,
|
|
2391
|
+
is_regex,
|
|
2392
|
+
);
|
|
2003
2393
|
if (matches.length > 0) use_clean_map = true;
|
|
2004
2394
|
else return null;
|
|
2005
2395
|
}
|
|
@@ -2009,7 +2399,8 @@ export class RedlineEngine {
|
|
|
2009
2399
|
let live_matches: [number, number][] = [];
|
|
2010
2400
|
for (const [s, match_len] of matches) {
|
|
2011
2401
|
const realSpans = active_mapper.spans.filter(
|
|
2012
|
-
(span) =>
|
|
2402
|
+
(span) =>
|
|
2403
|
+
span.run !== null && span.end > s && span.start < s + match_len,
|
|
2013
2404
|
);
|
|
2014
2405
|
if (realSpans.length === 0 || realSpans.some((span) => !span.del_id)) {
|
|
2015
2406
|
live_matches.push([s, match_len]);
|
|
@@ -2031,23 +2422,72 @@ export class RedlineEngine {
|
|
|
2031
2422
|
);
|
|
2032
2423
|
let current_effective_new_text = edit.new_text || "";
|
|
2033
2424
|
|
|
2425
|
+
// Cell anchors ({#cell:<paraId>}) are pure position markers with no real
|
|
2426
|
+
// content — they let the model address an empty (or any) table cell that
|
|
2427
|
+
// has no run to diff against. Treat such a target as a clean INSERTION at
|
|
2428
|
+
// the anchor's paragraph: never delete the marker, never run trim_common_context
|
|
2429
|
+
// (which refuses to split inside {#...} markup and yields a no-op MODIFICATION).
|
|
2430
|
+
// Strip any echoed anchor from new_text so the model can send either
|
|
2431
|
+
// "June 22, 2026" or "June 22, 2026{#cell:...}" and get the same result.
|
|
2432
|
+
if (/^\{#cell:[^}]+\}$/.test(actual_doc_text.trim())) {
|
|
2433
|
+
let ins_text = current_effective_new_text;
|
|
2434
|
+
// Drop a leading/trailing copy of the same anchor token if echoed.
|
|
2435
|
+
ins_text = ins_text.split(actual_doc_text.trim()).join("");
|
|
2436
|
+
if (ins_text) {
|
|
2437
|
+
all_sub_edits.push({
|
|
2438
|
+
type: "modify",
|
|
2439
|
+
target_text: "",
|
|
2440
|
+
new_text: ins_text,
|
|
2441
|
+
comment: edit.comment,
|
|
2442
|
+
// Insert at the anchor token's start so the new run lands inside
|
|
2443
|
+
// the cell paragraph that get_insertion_anchor resolves there.
|
|
2444
|
+
_match_start_index: start_idx,
|
|
2445
|
+
_internal_op: "INSERTION",
|
|
2446
|
+
_active_mapper_ref: active_mapper,
|
|
2447
|
+
});
|
|
2448
|
+
} else if (edit.comment) {
|
|
2449
|
+
// Anchor target with empty effective new_text but a comment: attach
|
|
2450
|
+
// the comment to the cell paragraph.
|
|
2451
|
+
all_sub_edits.push({
|
|
2452
|
+
type: "modify",
|
|
2453
|
+
target_text: "",
|
|
2454
|
+
new_text: "",
|
|
2455
|
+
comment: edit.comment,
|
|
2456
|
+
_match_start_index: start_idx,
|
|
2457
|
+
_internal_op: "COMMENT_ONLY",
|
|
2458
|
+
_active_mapper_ref: active_mapper,
|
|
2459
|
+
});
|
|
2460
|
+
}
|
|
2461
|
+
continue;
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2034
2464
|
if (is_regex && current_effective_new_text) {
|
|
2035
2465
|
try {
|
|
2036
|
-
current_effective_new_text = actual_doc_text.replace(
|
|
2466
|
+
current_effective_new_text = actual_doc_text.replace(
|
|
2467
|
+
new RegExp(edit.target_text),
|
|
2468
|
+
current_effective_new_text,
|
|
2469
|
+
);
|
|
2037
2470
|
} catch (e) {}
|
|
2038
2471
|
}
|
|
2039
2472
|
|
|
2040
|
-
const [edit_target_clean, edit_target_style] = this._parse_markdown_style(
|
|
2041
|
-
|
|
2473
|
+
const [edit_target_clean, edit_target_style] = this._parse_markdown_style(
|
|
2474
|
+
edit.target_text,
|
|
2475
|
+
);
|
|
2476
|
+
const [edit_new_clean, edit_new_style] = this._parse_markdown_style(
|
|
2477
|
+
current_effective_new_text,
|
|
2478
|
+
);
|
|
2042
2479
|
|
|
2043
2480
|
if (edit_target_style !== edit_new_style) {
|
|
2044
2481
|
const [actual_clean] = this._parse_markdown_style(actual_doc_text);
|
|
2045
2482
|
const final_target = actual_clean;
|
|
2046
2483
|
const final_new = edit_new_clean;
|
|
2047
|
-
const style_op =
|
|
2484
|
+
const style_op =
|
|
2485
|
+
final_target === final_new ? "STYLE_ONLY" : "STYLE_AND_TEXT";
|
|
2048
2486
|
const prefix_offset = actual_doc_text.indexOf(actual_clean);
|
|
2049
|
-
const effective_start_idx =
|
|
2050
|
-
|
|
2487
|
+
const effective_start_idx =
|
|
2488
|
+
start_idx + (prefix_offset !== -1 ? prefix_offset : 0);
|
|
2489
|
+
const resolved_style =
|
|
2490
|
+
edit_new_style !== null ? edit_new_style : "Normal";
|
|
2051
2491
|
|
|
2052
2492
|
all_sub_edits.push({
|
|
2053
2493
|
type: "modify",
|
|
@@ -2085,7 +2525,9 @@ export class RedlineEngine {
|
|
|
2085
2525
|
|
|
2086
2526
|
if (current_effective_new_text.startsWith(actual_doc_text)) {
|
|
2087
2527
|
effective_op = "INSERTION";
|
|
2088
|
-
final_new = current_effective_new_text.substring(
|
|
2528
|
+
final_new = current_effective_new_text.substring(
|
|
2529
|
+
actual_doc_text.length,
|
|
2530
|
+
);
|
|
2089
2531
|
effective_start_idx = start_idx + match_len;
|
|
2090
2532
|
} else {
|
|
2091
2533
|
const [prefix_len, suffix_len] = trim_common_context(
|
|
@@ -2099,6 +2541,65 @@ export class RedlineEngine {
|
|
|
2099
2541
|
final_new = current_effective_new_text.substring(prefix_len, n_end);
|
|
2100
2542
|
effective_start_idx = start_idx + prefix_len;
|
|
2101
2543
|
|
|
2544
|
+
// Balanced multi-paragraph modification: the matched span crosses one or
|
|
2545
|
+
// more paragraph breaks and the replacement preserves the same number of
|
|
2546
|
+
// breaks. Apply it as one independent sub-edit per paragraph segment so
|
|
2547
|
+
// the structural \n\n breaks are left intact. Each sub-edit shares a
|
|
2548
|
+
// _split_group_id (the occurrence's start index) so the batch report
|
|
2549
|
+
// counts it as a single applied edit. Unbalanced cases (a genuine
|
|
2550
|
+
// paragraph merge or split) fall through to the single-span path and are
|
|
2551
|
+
// rejected by validate_edits.
|
|
2552
|
+
const target_segs = actual_doc_text.split("\n\n");
|
|
2553
|
+
const new_segs = current_effective_new_text.split("\n\n");
|
|
2554
|
+
if (
|
|
2555
|
+
actual_doc_text.includes("\n\n") &&
|
|
2556
|
+
target_segs.length === new_segs.length
|
|
2557
|
+
) {
|
|
2558
|
+
const split_sub_edits: any[] = [];
|
|
2559
|
+
let seg_offset = start_idx;
|
|
2560
|
+
let comment_assigned = false;
|
|
2561
|
+
for (let k = 0; k < target_segs.length; k++) {
|
|
2562
|
+
const t_seg = target_segs[k];
|
|
2563
|
+
const n_seg = new_segs[k];
|
|
2564
|
+
if (t_seg !== n_seg) {
|
|
2565
|
+
const [seg_prefix, seg_suffix] = trim_common_context(t_seg, n_seg);
|
|
2566
|
+
const seg_target = t_seg.substring(
|
|
2567
|
+
seg_prefix,
|
|
2568
|
+
t_seg.length - seg_suffix,
|
|
2569
|
+
);
|
|
2570
|
+
const seg_new = n_seg.substring(
|
|
2571
|
+
seg_prefix,
|
|
2572
|
+
n_seg.length - seg_suffix,
|
|
2573
|
+
);
|
|
2574
|
+
const seg_start = seg_offset + seg_prefix;
|
|
2575
|
+
let seg_op: string;
|
|
2576
|
+
if (!seg_target && seg_new) seg_op = "INSERTION";
|
|
2577
|
+
else if (seg_target && !seg_new) seg_op = "DELETION";
|
|
2578
|
+
else if (seg_target && seg_new) seg_op = "MODIFICATION";
|
|
2579
|
+
else seg_op = "COMMENT_ONLY";
|
|
2580
|
+
const seg_comment =
|
|
2581
|
+
edit.comment && !comment_assigned ? edit.comment : null;
|
|
2582
|
+
if (seg_comment) comment_assigned = true;
|
|
2583
|
+
split_sub_edits.push({
|
|
2584
|
+
type: "modify",
|
|
2585
|
+
target_text: seg_target,
|
|
2586
|
+
new_text: seg_new,
|
|
2587
|
+
comment: seg_comment,
|
|
2588
|
+
_match_start_index: seg_start,
|
|
2589
|
+
_internal_op: seg_op,
|
|
2590
|
+
_active_mapper_ref: active_mapper,
|
|
2591
|
+
_split_group_id: start_idx,
|
|
2592
|
+
});
|
|
2593
|
+
}
|
|
2594
|
+
// Advance past this segment plus its "\n\n" separator span.
|
|
2595
|
+
seg_offset += t_seg.length + 2;
|
|
2596
|
+
}
|
|
2597
|
+
if (split_sub_edits.length > 0) {
|
|
2598
|
+
for (const sub of split_sub_edits) all_sub_edits.push(sub);
|
|
2599
|
+
continue;
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2102
2603
|
if (!final_target && final_new) effective_op = "INSERTION";
|
|
2103
2604
|
else if (final_target && !final_new) effective_op = "DELETION";
|
|
2104
2605
|
else if (final_target && final_new) effective_op = "MODIFICATION";
|
|
@@ -2121,6 +2622,41 @@ export class RedlineEngine {
|
|
|
2121
2622
|
return all_sub_edits[0];
|
|
2122
2623
|
}
|
|
2123
2624
|
|
|
2625
|
+
/**
|
|
2626
|
+
* Split a <w:ins> so that everything up to and INCLUDING split_after stays in
|
|
2627
|
+
* a left <w:ins>, new_elem is placed between, and the remainder moves to a
|
|
2628
|
+
* right <w:ins> — all at the grandparent level. Used when revising another
|
|
2629
|
+
* author's pending insertion: the <w:del> stays nested in their <w:ins> while
|
|
2630
|
+
* our replacement <w:ins> lands as a sibling, so we never nest <w:ins> in
|
|
2631
|
+
* <w:ins>.
|
|
2632
|
+
*/
|
|
2633
|
+
private _insert_and_split_ins(
|
|
2634
|
+
parent_ins: Element,
|
|
2635
|
+
split_after: Element,
|
|
2636
|
+
new_elem: Element,
|
|
2637
|
+
) {
|
|
2638
|
+
const grandparent = parent_ins.parentNode as Element | null;
|
|
2639
|
+
if (!grandparent) return;
|
|
2640
|
+
// cloneNode(false) copies the attributes (author/id/date) onto both halves.
|
|
2641
|
+
const left = parent_ins.cloneNode(false) as Element;
|
|
2642
|
+
const right = parent_ins.cloneNode(false) as Element;
|
|
2643
|
+
let toRight = false;
|
|
2644
|
+
for (const kid of Array.from(parent_ins.childNodes)) {
|
|
2645
|
+
parent_ins.removeChild(kid);
|
|
2646
|
+
if (!toRight) {
|
|
2647
|
+
left.appendChild(kid);
|
|
2648
|
+
if (kid === split_after) toRight = true;
|
|
2649
|
+
} else {
|
|
2650
|
+
right.appendChild(kid);
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
if (left.childNodes.length > 0) grandparent.insertBefore(left, parent_ins);
|
|
2654
|
+
grandparent.insertBefore(new_elem, parent_ins);
|
|
2655
|
+
if (right.childNodes.length > 0)
|
|
2656
|
+
grandparent.insertBefore(right, parent_ins);
|
|
2657
|
+
grandparent.removeChild(parent_ins);
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2124
2660
|
private _apply_single_edit_indexed(
|
|
2125
2661
|
edit: any,
|
|
2126
2662
|
orig_new: string | null,
|
|
@@ -2172,7 +2708,10 @@ export class RedlineEngine {
|
|
|
2172
2708
|
while (end_p && end_p.tagName !== "w:p")
|
|
2173
2709
|
end_p = end_p.parentNode as Element;
|
|
2174
2710
|
if (start_p && end_p) {
|
|
2175
|
-
const ascend_to_paragraph_child = (
|
|
2711
|
+
const ascend_to_paragraph_child = (
|
|
2712
|
+
el: Element,
|
|
2713
|
+
p: Element,
|
|
2714
|
+
): Element => {
|
|
2176
2715
|
let cur: Element = el;
|
|
2177
2716
|
while (cur.parentNode && cur.parentNode !== p) {
|
|
2178
2717
|
cur = cur.parentNode as Element;
|
|
@@ -2182,7 +2721,12 @@ export class RedlineEngine {
|
|
|
2182
2721
|
const first_anchor = ascend_to_paragraph_child(first_el, start_p);
|
|
2183
2722
|
const last_anchor = ascend_to_paragraph_child(last_el, end_p);
|
|
2184
2723
|
if (start_p === end_p) {
|
|
2185
|
-
this._attach_comment(
|
|
2724
|
+
this._attach_comment(
|
|
2725
|
+
start_p,
|
|
2726
|
+
first_anchor,
|
|
2727
|
+
last_anchor,
|
|
2728
|
+
edit.comment,
|
|
2729
|
+
);
|
|
2186
2730
|
} else {
|
|
2187
2731
|
this._attach_comment_spanning(
|
|
2188
2732
|
start_p,
|
|
@@ -2371,7 +2915,20 @@ export class RedlineEngine {
|
|
|
2371
2915
|
const is_inline_first = result.first_node.tagName === "w:ins";
|
|
2372
2916
|
if (is_inline_first) {
|
|
2373
2917
|
if (anchor_run) {
|
|
2374
|
-
|
|
2918
|
+
const anchor_parent = anchor_run._element.parentNode as Element | null;
|
|
2919
|
+
if (anchor_parent && anchor_parent.tagName === "w:ins") {
|
|
2920
|
+
// Inserting inside another author's pending <w:ins>: split it so our
|
|
2921
|
+
// new <w:ins> lands as a sibling right after the anchor run, never
|
|
2922
|
+
// <w:ins> nested in <w:ins> (mirrors the MODIFICATION path and the
|
|
2923
|
+
// Python engine).
|
|
2924
|
+
this._insert_and_split_ins(
|
|
2925
|
+
anchor_parent,
|
|
2926
|
+
anchor_run._element,
|
|
2927
|
+
result.first_node,
|
|
2928
|
+
);
|
|
2929
|
+
} else {
|
|
2930
|
+
insertAfter(result.first_node, anchor_run._element);
|
|
2931
|
+
}
|
|
2375
2932
|
} else if (anchor_para) {
|
|
2376
2933
|
anchor_para._element.appendChild(result.first_node);
|
|
2377
2934
|
}
|
|
@@ -2513,8 +3070,17 @@ export class RedlineEngine {
|
|
|
2513
3070
|
if (result.first_node) {
|
|
2514
3071
|
const is_inline_first = result.first_node.tagName === "w:ins";
|
|
2515
3072
|
if (is_inline_first) {
|
|
2516
|
-
|
|
2517
|
-
|
|
3073
|
+
const del_parent = last_del!.parentNode as Element | null;
|
|
3074
|
+
if (del_parent && del_parent.tagName === "w:ins") {
|
|
3075
|
+
// Revising another author's pending insertion: keep the <w:del>
|
|
3076
|
+
// nested in their <w:ins> and splice our new <w:ins> in right after
|
|
3077
|
+
// it by splitting their <w:ins>, so we never nest <w:ins> in
|
|
3078
|
+
// <w:ins>.
|
|
3079
|
+
this._insert_and_split_ins(del_parent, last_del!, result.first_node);
|
|
3080
|
+
} else {
|
|
3081
|
+
// Inline: place the first <w:ins> immediately after last_del.
|
|
3082
|
+
insertAfter(result.first_node, last_del!);
|
|
3083
|
+
}
|
|
2518
3084
|
ins_elem = result.first_node;
|
|
2519
3085
|
} else {
|
|
2520
3086
|
// Block-mode first paragraph was already inserted after the anchor
|