@adeu/core 1.18.4 → 1.19.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 +271 -76
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +271 -76
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/engine.ts +364 -91
- package/src/repro.indexed-edit-insertion-loss.test.ts +137 -0
- package/src/repro.report.test.ts +1 -0
- package/src/repro_surgical_word_diff.test.ts +26 -0
package/package.json
CHANGED
package/src/engine.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
ReplyComment,
|
|
12
12
|
DocumentChange,
|
|
13
13
|
} from "./models.js";
|
|
14
|
-
import { trim_common_context } from "./diff.js";
|
|
14
|
+
import { trim_common_context, generate_edits_from_text } from "./diff.js";
|
|
15
15
|
import { findChild, findAllDescendants, serializeXml } from "./docx/dom.js";
|
|
16
16
|
import { split_structural_appendix, paginate } from "./pagination.js";
|
|
17
17
|
import {
|
|
@@ -345,6 +345,90 @@ export class RedlineEngine {
|
|
|
345
345
|
}
|
|
346
346
|
return null;
|
|
347
347
|
}
|
|
348
|
+
|
|
349
|
+
private _word_diff_sub_edits(
|
|
350
|
+
target_str: string,
|
|
351
|
+
new_str: string,
|
|
352
|
+
base_offset: number,
|
|
353
|
+
parent_comment: string | null = null,
|
|
354
|
+
is_table: boolean = false,
|
|
355
|
+
active_mapper: any = null,
|
|
356
|
+
): any[] {
|
|
357
|
+
let raw_sub_edits: any[] = [];
|
|
358
|
+
try {
|
|
359
|
+
raw_sub_edits = generate_edits_from_text(target_str, new_str);
|
|
360
|
+
console.log("_word_diff_sub_edits RAW_SUB_EDITS:", JSON.stringify(raw_sub_edits, null, 2));
|
|
361
|
+
} catch (e) {
|
|
362
|
+
console.warn("generate_edits_from_text failed, falling back to wholesale edit", e);
|
|
363
|
+
raw_sub_edits = [];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!raw_sub_edits || raw_sub_edits.length === 0) {
|
|
367
|
+
const fallback_edit: any = {
|
|
368
|
+
type: "modify",
|
|
369
|
+
target_text: target_str,
|
|
370
|
+
new_text: new_str,
|
|
371
|
+
comment: parent_comment,
|
|
372
|
+
};
|
|
373
|
+
fallback_edit._resolved_start_idx = base_offset;
|
|
374
|
+
fallback_edit._match_start_index = base_offset;
|
|
375
|
+
fallback_edit._active_mapper_ref = active_mapper;
|
|
376
|
+
if (is_table) {
|
|
377
|
+
fallback_edit._is_table_edit = true;
|
|
378
|
+
}
|
|
379
|
+
if (target_str === new_str) {
|
|
380
|
+
fallback_edit._internal_op = "COMMENT_ONLY";
|
|
381
|
+
} else if (!target_str && new_str) {
|
|
382
|
+
fallback_edit._internal_op = "INSERTION";
|
|
383
|
+
} else if (target_str && !new_str) {
|
|
384
|
+
fallback_edit._internal_op = "DELETION";
|
|
385
|
+
} else if (target_str && new_str) {
|
|
386
|
+
fallback_edit._internal_op = "MODIFICATION";
|
|
387
|
+
} else {
|
|
388
|
+
fallback_edit._internal_op = "COMMENT_ONLY";
|
|
389
|
+
}
|
|
390
|
+
return [fallback_edit];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const sub_edits: any[] = [];
|
|
394
|
+
let comment_assigned = false;
|
|
395
|
+
for (const raw_edit of raw_sub_edits) {
|
|
396
|
+
const sub_start = base_offset + (raw_edit._match_start_index || 0);
|
|
397
|
+
const should_attach_comment = (parent_comment !== null) && !comment_assigned;
|
|
398
|
+
if (should_attach_comment) {
|
|
399
|
+
comment_assigned = true;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const sub_edit: any = {
|
|
403
|
+
type: "modify",
|
|
404
|
+
target_text: raw_edit.target_text,
|
|
405
|
+
new_text: raw_edit.new_text,
|
|
406
|
+
comment: should_attach_comment ? parent_comment : null,
|
|
407
|
+
};
|
|
408
|
+
sub_edit._resolved_start_idx = sub_start;
|
|
409
|
+
sub_edit._match_start_index = sub_start;
|
|
410
|
+
sub_edit._active_mapper_ref = active_mapper;
|
|
411
|
+
if (is_table) {
|
|
412
|
+
sub_edit._is_table_edit = true;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const t_val = raw_edit.target_text;
|
|
416
|
+
const n_val = raw_edit.new_text;
|
|
417
|
+
if (!t_val && n_val) {
|
|
418
|
+
sub_edit._internal_op = "INSERTION";
|
|
419
|
+
} else if (t_val && !n_val) {
|
|
420
|
+
sub_edit._internal_op = "DELETION";
|
|
421
|
+
} else if (t_val && n_val) {
|
|
422
|
+
sub_edit._internal_op = "MODIFICATION";
|
|
423
|
+
} else {
|
|
424
|
+
sub_edit._internal_op = "COMMENT_ONLY";
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
sub_edits.push(sub_edit);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return sub_edits;
|
|
431
|
+
}
|
|
348
432
|
/**
|
|
349
433
|
* Best-effort "did you mean" hint for a failed target. The common loop trap
|
|
350
434
|
* (observed in the field) is an anchored regex like `^\( x \)$` against a
|
|
@@ -1525,6 +1609,18 @@ export class RedlineEngine {
|
|
|
1525
1609
|
continue;
|
|
1526
1610
|
}
|
|
1527
1611
|
if (!edit.target_text) continue;
|
|
1612
|
+
// Caller-pinned indexes (e.g. generate_edits_from_text output) resolve
|
|
1613
|
+
// by position, not content: ambiguity / not-found checks are meaningless
|
|
1614
|
+
// for them and false-positive whenever the target coincidentally matches
|
|
1615
|
+
// unrelated text (a comment timestamp, an earlier redline). The
|
|
1616
|
+
// string-shape checks above still apply.
|
|
1617
|
+
if (
|
|
1618
|
+
(edit._match_start_index !== undefined &&
|
|
1619
|
+
edit._match_start_index !== null) ||
|
|
1620
|
+
(edit._resolved_start_idx !== undefined &&
|
|
1621
|
+
edit._resolved_start_idx !== null)
|
|
1622
|
+
)
|
|
1623
|
+
continue;
|
|
1528
1624
|
|
|
1529
1625
|
const is_regex = (edit as any).regex || false;
|
|
1530
1626
|
const match_mode = (edit as any).match_mode || "strict";
|
|
@@ -1953,9 +2049,41 @@ export class RedlineEngine {
|
|
|
1953
2049
|
let skipped_edits = 0;
|
|
1954
2050
|
|
|
1955
2051
|
if (edits.length > 0) {
|
|
2052
|
+
// Sequential application rebuilds the mapper after every applied edit,
|
|
2053
|
+
// shifting every position at/after that edit. Caller-pinned indexes
|
|
2054
|
+
// (_match_start_index / _resolved_start_idx, e.g. generate_edits_from_text
|
|
2055
|
+
// output) are coordinates in the INITIAL document state, so apply indexed
|
|
2056
|
+
// edits bottom-up first — positions below an applied edit never move (the
|
|
2057
|
+
// same invariant apply_edits' reverse sweep relies on) — then let
|
|
2058
|
+
// text-anchored edits re-resolve against the mutated text as before.
|
|
2059
|
+
// Reports are keyed by `i` so they stay in batch order.
|
|
2060
|
+
const pinned_idx = (e: any): number | null => {
|
|
2061
|
+
if (
|
|
2062
|
+
e._resolved_start_idx !== undefined &&
|
|
2063
|
+
e._resolved_start_idx !== null
|
|
2064
|
+
)
|
|
2065
|
+
return e._resolved_start_idx;
|
|
2066
|
+
if (
|
|
2067
|
+
e._match_start_index !== undefined &&
|
|
2068
|
+
e._match_start_index !== null
|
|
2069
|
+
)
|
|
2070
|
+
return e._match_start_index;
|
|
2071
|
+
return null;
|
|
2072
|
+
};
|
|
2073
|
+
const ordered_edits = edits
|
|
2074
|
+
.map((edit, i) => ({ edit: edit as any, i }))
|
|
2075
|
+
.sort((a, b) => {
|
|
2076
|
+
const ka = pinned_idx(a.edit);
|
|
2077
|
+
const kb = pinned_idx(b.edit);
|
|
2078
|
+
if (ka === null && kb === null) return a.i - b.i;
|
|
2079
|
+
if (ka === null) return 1;
|
|
2080
|
+
if (kb === null) return -1;
|
|
2081
|
+
return kb - ka || a.i - b.i;
|
|
2082
|
+
});
|
|
2083
|
+
|
|
1956
2084
|
if (dry_run_mode) {
|
|
1957
|
-
|
|
1958
|
-
|
|
2085
|
+
const reports_by_input: any[] = new Array(edits.length);
|
|
2086
|
+
for (const { edit, i } of ordered_edits) {
|
|
1959
2087
|
const single_errors = this.validate_edits([edit], i);
|
|
1960
2088
|
if (single_errors.length > 0) {
|
|
1961
2089
|
skipped_edits++;
|
|
@@ -1967,7 +2095,7 @@ export class RedlineEngine {
|
|
|
1967
2095
|
const warning = this._check_punctuation_warning(
|
|
1968
2096
|
(edit as any).target_text || "",
|
|
1969
2097
|
);
|
|
1970
|
-
|
|
2098
|
+
reports_by_input[i] = {
|
|
1971
2099
|
status: "failed",
|
|
1972
2100
|
target_text: (edit as any).target_text || "",
|
|
1973
2101
|
new_text: (edit as any).new_text || "",
|
|
@@ -1975,14 +2103,14 @@ export class RedlineEngine {
|
|
|
1975
2103
|
error: single_errors[0],
|
|
1976
2104
|
critic_markup: null,
|
|
1977
2105
|
clean_text: null,
|
|
1978
|
-
}
|
|
2106
|
+
};
|
|
1979
2107
|
continue;
|
|
1980
2108
|
}
|
|
1981
2109
|
const res = this.apply_edits([edit], page_offsets);
|
|
1982
2110
|
if ((edit as any)._applied_status) {
|
|
1983
2111
|
applied_edits++;
|
|
1984
2112
|
const previews = this._build_edit_context_previews(edit);
|
|
1985
|
-
|
|
2113
|
+
reports_by_input[i] = {
|
|
1986
2114
|
status: "applied",
|
|
1987
2115
|
target_text: (edit as any).target_text || "",
|
|
1988
2116
|
new_text: (edit as any).new_text || "",
|
|
@@ -1994,7 +2122,7 @@ export class RedlineEngine {
|
|
|
1994
2122
|
heading_path: (edit as any)._heading_path || "",
|
|
1995
2123
|
occurrences_modified: (edit as any)._occurrences_modified || 0,
|
|
1996
2124
|
match_mode: (edit as any).match_mode || "strict",
|
|
1997
|
-
}
|
|
2125
|
+
};
|
|
1998
2126
|
this.mapper = new DocumentMapper(this.doc);
|
|
1999
2127
|
this.clean_mapper = null;
|
|
2000
2128
|
} else {
|
|
@@ -2006,7 +2134,7 @@ export class RedlineEngine {
|
|
|
2006
2134
|
const warning = this._check_punctuation_warning(
|
|
2007
2135
|
(edit as any).target_text || "",
|
|
2008
2136
|
);
|
|
2009
|
-
|
|
2137
|
+
reports_by_input[i] = {
|
|
2010
2138
|
status: "failed",
|
|
2011
2139
|
target_text: (edit as any).target_text || "",
|
|
2012
2140
|
new_text: (edit as any).new_text || "",
|
|
@@ -2014,17 +2142,17 @@ export class RedlineEngine {
|
|
|
2014
2142
|
error: error_msg,
|
|
2015
2143
|
critic_markup: null,
|
|
2016
2144
|
clean_text: null,
|
|
2017
|
-
}
|
|
2145
|
+
};
|
|
2018
2146
|
}
|
|
2019
2147
|
}
|
|
2148
|
+
edits_reports.push(...reports_by_input);
|
|
2020
2149
|
} else {
|
|
2021
2150
|
// Simulated dry-run sequentially for wet-run validation parity
|
|
2022
2151
|
const snapshot = takeSnapshot(this.doc);
|
|
2023
2152
|
const originalCurrentId = this.current_id;
|
|
2024
2153
|
try {
|
|
2025
2154
|
const sequential_errors: string[] = [];
|
|
2026
|
-
for (
|
|
2027
|
-
const edit = edits[i];
|
|
2155
|
+
for (const { edit, i } of ordered_edits) {
|
|
2028
2156
|
const single_errors = this.validate_edits([edit], i);
|
|
2029
2157
|
if (single_errors.length > 0) {
|
|
2030
2158
|
sequential_errors.push(...single_errors);
|
|
@@ -2224,7 +2352,8 @@ export class RedlineEngine {
|
|
|
2224
2352
|
|
|
2225
2353
|
let success = false;
|
|
2226
2354
|
if (edit.type === "modify") {
|
|
2227
|
-
|
|
2355
|
+
const rebuild = edit._split_group_id !== undefined && edit._split_group_id !== null;
|
|
2356
|
+
success = this._apply_single_edit_indexed(edit, orig_new, rebuild);
|
|
2228
2357
|
} else if (edit.type === "insert_row" || edit.type === "delete_row") {
|
|
2229
2358
|
success = this._apply_table_edit(edit, false);
|
|
2230
2359
|
}
|
|
@@ -2591,6 +2720,97 @@ export class RedlineEngine {
|
|
|
2591
2720
|
continue;
|
|
2592
2721
|
}
|
|
2593
2722
|
|
|
2723
|
+
let overlaps_virtual_pipe = false;
|
|
2724
|
+
if (active_mapper) {
|
|
2725
|
+
overlaps_virtual_pipe = active_mapper.spans.some(
|
|
2726
|
+
(s: any) =>
|
|
2727
|
+
s.text === " | " &&
|
|
2728
|
+
(s.run === null || s.run === undefined) &&
|
|
2729
|
+
s.start < start_idx + match_len &&
|
|
2730
|
+
s.end > start_idx,
|
|
2731
|
+
);
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
if (overlaps_virtual_pipe) {
|
|
2735
|
+
const actual_cells = actual_doc_text.split("|");
|
|
2736
|
+
const new_cells = current_effective_new_text.split("|");
|
|
2737
|
+
|
|
2738
|
+
if (actual_cells.length === new_cells.length && actual_cells.length > 1) {
|
|
2739
|
+
const sub_edits: any[] = [];
|
|
2740
|
+
let search_offset = start_idx;
|
|
2741
|
+
|
|
2742
|
+
// Determine which cell receives the comment
|
|
2743
|
+
let target_comment_idx = 0;
|
|
2744
|
+
for (let idx = 0; idx < actual_cells.length; idx++) {
|
|
2745
|
+
if (actual_cells[idx].trim() !== new_cells[idx].trim()) {
|
|
2746
|
+
target_comment_idx = idx;
|
|
2747
|
+
break;
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
for (let cell_idx = 0; cell_idx < actual_cells.length; cell_idx++) {
|
|
2752
|
+
const a_cell = actual_cells[cell_idx];
|
|
2753
|
+
const n_cell = new_cells[cell_idx];
|
|
2754
|
+
const a_clean = a_cell.trim();
|
|
2755
|
+
const n_clean = n_cell.trim();
|
|
2756
|
+
|
|
2757
|
+
let actual_start = search_offset;
|
|
2758
|
+
if (a_clean) {
|
|
2759
|
+
actual_start = this.mapper.full_text.indexOf(a_clean, search_offset);
|
|
2760
|
+
if (actual_start === -1 || actual_start > search_offset + 10) {
|
|
2761
|
+
actual_start = search_offset;
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
const should_attach_comment = (edit.comment !== null && edit.comment !== undefined) && (cell_idx === target_comment_idx);
|
|
2766
|
+
|
|
2767
|
+
if (a_clean !== n_clean || should_attach_comment) {
|
|
2768
|
+
const cell_sub_edits = this._word_diff_sub_edits(
|
|
2769
|
+
a_clean,
|
|
2770
|
+
n_clean,
|
|
2771
|
+
actual_start,
|
|
2772
|
+
should_attach_comment ? edit.comment : null,
|
|
2773
|
+
true,
|
|
2774
|
+
active_mapper,
|
|
2775
|
+
);
|
|
2776
|
+
for (const se of cell_sub_edits) {
|
|
2777
|
+
se._original_target_text = edit.target_text;
|
|
2778
|
+
se._split_group_id = start_idx;
|
|
2779
|
+
sub_edits.push(se);
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
if (a_clean) {
|
|
2784
|
+
search_offset = actual_start + a_clean.length;
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
const next_pipe = this.mapper.full_text.indexOf(" | ", search_offset);
|
|
2788
|
+
if (next_pipe !== -1 && next_pipe <= search_offset + 10) {
|
|
2789
|
+
search_offset = next_pipe + 3;
|
|
2790
|
+
} else {
|
|
2791
|
+
search_offset += a_cell.length + 1;
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
for (const sub of sub_edits) {
|
|
2796
|
+
all_sub_edits.push(sub);
|
|
2797
|
+
}
|
|
2798
|
+
continue;
|
|
2799
|
+
} else {
|
|
2800
|
+
throw new BatchValidationError([
|
|
2801
|
+
`Target text spans ${actual_cells.length} table cells, but replacement provides ${new_cells.length}. To modify text without altering table structure (rows or columns), ensure the replacement contains the exact same number of '|' separators (e.g., replace with 'CellC | ' to empty the second cell).`
|
|
2802
|
+
]);
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
let has_markdown = false;
|
|
2807
|
+
if (edit.target_text && (edit.target_text.includes("**") || edit.target_text.includes("_"))) {
|
|
2808
|
+
has_markdown = true;
|
|
2809
|
+
}
|
|
2810
|
+
if (current_effective_new_text && (current_effective_new_text.includes("**") || current_effective_new_text.includes("_"))) {
|
|
2811
|
+
has_markdown = true;
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2594
2814
|
let effective_op = "";
|
|
2595
2815
|
let final_target = "";
|
|
2596
2816
|
let final_new = "";
|
|
@@ -2598,9 +2818,7 @@ export class RedlineEngine {
|
|
|
2598
2818
|
|
|
2599
2819
|
if (current_effective_new_text.startsWith(actual_doc_text)) {
|
|
2600
2820
|
effective_op = "INSERTION";
|
|
2601
|
-
final_new = current_effective_new_text.substring(
|
|
2602
|
-
actual_doc_text.length,
|
|
2603
|
-
);
|
|
2821
|
+
final_new = current_effective_new_text.substring(actual_doc_text.length);
|
|
2604
2822
|
effective_start_idx = start_idx + match_len;
|
|
2605
2823
|
} else {
|
|
2606
2824
|
const [prefix_len, suffix_len] = trim_common_context(
|
|
@@ -2613,84 +2831,101 @@ export class RedlineEngine {
|
|
|
2613
2831
|
final_target = actual_doc_text.substring(prefix_len, t_end);
|
|
2614
2832
|
final_new = current_effective_new_text.substring(prefix_len, n_end);
|
|
2615
2833
|
effective_start_idx = start_idx + prefix_len;
|
|
2834
|
+
}
|
|
2616
2835
|
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2836
|
+
if (has_markdown) {
|
|
2837
|
+
if (!final_target && final_new) {
|
|
2838
|
+
effective_op = "INSERTION";
|
|
2839
|
+
} else if (final_target && !final_new) {
|
|
2840
|
+
effective_op = "DELETION";
|
|
2841
|
+
} else if (final_target && final_new) {
|
|
2842
|
+
effective_op = "MODIFICATION";
|
|
2843
|
+
} else {
|
|
2844
|
+
all_sub_edits.push({
|
|
2845
|
+
type: "modify",
|
|
2846
|
+
target_text: final_target,
|
|
2847
|
+
new_text: final_new,
|
|
2848
|
+
comment: edit.comment,
|
|
2849
|
+
_match_start_index: effective_start_idx,
|
|
2850
|
+
_internal_op: "COMMENT_ONLY",
|
|
2851
|
+
_active_mapper_ref: active_mapper,
|
|
2852
|
+
});
|
|
2853
|
+
continue;
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
all_sub_edits.push({
|
|
2857
|
+
type: "modify",
|
|
2858
|
+
target_text: final_target,
|
|
2859
|
+
new_text: final_new,
|
|
2860
|
+
comment: edit.comment,
|
|
2861
|
+
_resolved_start_idx: effective_start_idx,
|
|
2862
|
+
_match_start_index: effective_start_idx,
|
|
2863
|
+
_internal_op: effective_op,
|
|
2864
|
+
_active_mapper_ref: active_mapper,
|
|
2865
|
+
});
|
|
2866
|
+
continue;
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
// Balanced multi-paragraph modification: the matched span crosses one or
|
|
2870
|
+
// more paragraph breaks and the replacement preserves the same number of
|
|
2871
|
+
// breaks. Apply it as one independent sub-edit per paragraph segment so
|
|
2872
|
+
// the structural \n\n breaks are left intact. Each sub-edit shares a
|
|
2873
|
+
// _split_group_id (the occurrence's start index) so the batch report
|
|
2874
|
+
// counts it as a single applied edit. Unbalanced cases (a genuine
|
|
2875
|
+
// paragraph merge or split) fall through to the single-span path and are
|
|
2876
|
+
// rejected by validate_edits.
|
|
2877
|
+
const target_segs = actual_doc_text.split("\n\n");
|
|
2878
|
+
const new_segs = current_effective_new_text.split("\n\n");
|
|
2879
|
+
if (
|
|
2880
|
+
actual_doc_text.includes("\n\n") &&
|
|
2881
|
+
target_segs.length === new_segs.length
|
|
2882
|
+
) {
|
|
2883
|
+
const split_sub_edits: any[] = [];
|
|
2884
|
+
let seg_offset = start_idx;
|
|
2885
|
+
let comment_assigned = false;
|
|
2886
|
+
for (let k = 0; k < target_segs.length; k++) {
|
|
2887
|
+
const t_seg = target_segs[k];
|
|
2888
|
+
const n_seg = new_segs[k];
|
|
2889
|
+
if (t_seg !== n_seg) {
|
|
2890
|
+
const seg_comment =
|
|
2891
|
+
edit.comment && !comment_assigned ? edit.comment : null;
|
|
2892
|
+
const seg_sub_edits = this._word_diff_sub_edits(
|
|
2893
|
+
t_seg,
|
|
2894
|
+
n_seg,
|
|
2895
|
+
seg_offset,
|
|
2896
|
+
seg_comment,
|
|
2897
|
+
false,
|
|
2898
|
+
active_mapper,
|
|
2899
|
+
);
|
|
2900
|
+
if (seg_sub_edits.some((se) => se.comment !== null && se.comment !== undefined)) {
|
|
2901
|
+
comment_assigned = true;
|
|
2902
|
+
}
|
|
2903
|
+
for (const se of seg_sub_edits) {
|
|
2904
|
+
se._split_group_id = start_idx;
|
|
2905
|
+
split_sub_edits.push(se);
|
|
2669
2906
|
}
|
|
2670
|
-
// Advance past this segment plus its "\n\n" separator span.
|
|
2671
|
-
seg_offset += t_seg.length + 2;
|
|
2672
|
-
}
|
|
2673
|
-
if (split_sub_edits.length > 0) {
|
|
2674
|
-
for (const sub of split_sub_edits) all_sub_edits.push(sub);
|
|
2675
|
-
continue;
|
|
2676
2907
|
}
|
|
2908
|
+
// Advance past this segment plus its "\n\n" separator span.
|
|
2909
|
+
seg_offset += t_seg.length + 2;
|
|
2910
|
+
}
|
|
2911
|
+
if (split_sub_edits.length > 0) {
|
|
2912
|
+
for (const sub of split_sub_edits) all_sub_edits.push(sub);
|
|
2913
|
+
continue;
|
|
2677
2914
|
}
|
|
2678
|
-
|
|
2679
|
-
if (!final_target && final_new) effective_op = "INSERTION";
|
|
2680
|
-
else if (final_target && !final_new) effective_op = "DELETION";
|
|
2681
|
-
else if (final_target && final_new) effective_op = "MODIFICATION";
|
|
2682
|
-
else effective_op = "COMMENT_ONLY";
|
|
2683
2915
|
}
|
|
2684
2916
|
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2917
|
+
const sub_edits = this._word_diff_sub_edits(
|
|
2918
|
+
actual_doc_text,
|
|
2919
|
+
current_effective_new_text,
|
|
2920
|
+
start_idx,
|
|
2921
|
+
edit.comment,
|
|
2922
|
+
false,
|
|
2923
|
+
active_mapper,
|
|
2924
|
+
);
|
|
2925
|
+
for (const se of sub_edits) {
|
|
2926
|
+
se._split_group_id = start_idx;
|
|
2927
|
+
all_sub_edits.push(se);
|
|
2928
|
+
}
|
|
2694
2929
|
}
|
|
2695
2930
|
|
|
2696
2931
|
if (all_sub_edits.length === 0) return null;
|
|
@@ -2708,20 +2943,24 @@ export class RedlineEngine {
|
|
|
2708
2943
|
*/
|
|
2709
2944
|
private _insert_and_split_ins(
|
|
2710
2945
|
parent_ins: Element,
|
|
2711
|
-
|
|
2946
|
+
anchor: Element,
|
|
2712
2947
|
new_elem: Element,
|
|
2948
|
+
split_before: boolean = false,
|
|
2713
2949
|
) {
|
|
2714
2950
|
const grandparent = parent_ins.parentNode as Element | null;
|
|
2715
2951
|
if (!grandparent) return;
|
|
2716
2952
|
// cloneNode(false) copies the attributes (author/id/date) onto both halves.
|
|
2953
|
+
// The split lands after `anchor` by default; with split_before the anchor
|
|
2954
|
+
// itself goes to the right half so new_elem ends up in front of it.
|
|
2717
2955
|
const left = parent_ins.cloneNode(false) as Element;
|
|
2718
2956
|
const right = parent_ins.cloneNode(false) as Element;
|
|
2719
2957
|
let toRight = false;
|
|
2720
2958
|
for (const kid of Array.from(parent_ins.childNodes)) {
|
|
2721
2959
|
parent_ins.removeChild(kid);
|
|
2960
|
+
if (split_before && kid === anchor) toRight = true;
|
|
2722
2961
|
if (!toRight) {
|
|
2723
2962
|
left.appendChild(kid);
|
|
2724
|
-
if (kid ===
|
|
2963
|
+
if (kid === anchor) toRight = true;
|
|
2725
2964
|
} else {
|
|
2726
2965
|
right.appendChild(kid);
|
|
2727
2966
|
}
|
|
@@ -2747,6 +2986,22 @@ export class RedlineEngine {
|
|
|
2747
2986
|
: edit._match_start_index || 0;
|
|
2748
2987
|
const length = edit.target_text ? edit.target_text.length : 0;
|
|
2749
2988
|
|
|
2989
|
+
// Indexed edits (caller-supplied _match_start_index, e.g. straight from
|
|
2990
|
+
// generate_edits_from_text) bypass _pre_resolve_heuristic_edit — the only
|
|
2991
|
+
// place _internal_op is normally assigned. Without this fallback the
|
|
2992
|
+
// deletion sweep below still runs but no insertion branch does: a
|
|
2993
|
+
// replacement silently degrades to a pure tracked deletion and a pure
|
|
2994
|
+
// insertion fails. Mirrors the Python engine.
|
|
2995
|
+
if (op === undefined || op === null) {
|
|
2996
|
+
if (!edit.target_text && edit.new_text) {
|
|
2997
|
+
op = "INSERTION";
|
|
2998
|
+
} else if (edit.target_text && !edit.new_text) {
|
|
2999
|
+
op = "DELETION";
|
|
3000
|
+
} else {
|
|
3001
|
+
op = "MODIFICATION";
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
|
|
2750
3005
|
if (op === "STYLE_ONLY" || op === "STYLE_AND_TEXT") {
|
|
2751
3006
|
const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
|
|
2752
3007
|
start_idx,
|
|
@@ -2993,21 +3248,39 @@ export class RedlineEngine {
|
|
|
2993
3248
|
if (anchor_run) {
|
|
2994
3249
|
const anchor_parent = anchor_run._element
|
|
2995
3250
|
.parentNode as Element | null;
|
|
3251
|
+
// get_insertion_anchor(0) resolves to the document's FIRST run: the
|
|
3252
|
+
// insertion point precedes it, so the new <w:ins> must land before
|
|
3253
|
+
// the anchor, not after (mirrors the Python engine's insert_before
|
|
3254
|
+
// path).
|
|
3255
|
+
const before_anchor = start_idx === 0;
|
|
2996
3256
|
if (anchor_parent && anchor_parent.tagName === "w:ins") {
|
|
2997
3257
|
// Inserting inside another author's pending <w:ins>: split it so our
|
|
2998
|
-
// new <w:ins> lands as a sibling right
|
|
3258
|
+
// new <w:ins> lands as a sibling right next to the anchor run, never
|
|
2999
3259
|
// <w:ins> nested in <w:ins> (mirrors the MODIFICATION path and the
|
|
3000
3260
|
// Python engine).
|
|
3001
3261
|
this._insert_and_split_ins(
|
|
3002
3262
|
anchor_parent,
|
|
3003
3263
|
anchor_run._element,
|
|
3004
3264
|
result.first_node,
|
|
3265
|
+
before_anchor,
|
|
3005
3266
|
);
|
|
3267
|
+
} else if (before_anchor && anchor_parent) {
|
|
3268
|
+
anchor_parent.insertBefore(result.first_node, anchor_run._element);
|
|
3006
3269
|
} else {
|
|
3007
3270
|
insertAfter(result.first_node, anchor_run._element);
|
|
3008
3271
|
}
|
|
3009
3272
|
} else if (anchor_para) {
|
|
3010
|
-
|
|
3273
|
+
// Paragraph-anchored insertion: the anchor resolves to a paragraph
|
|
3274
|
+
// (not a run) for zero-width paragraph-start spans — e.g. index 0 of
|
|
3275
|
+
// the document. The insertion point is the START of the paragraph
|
|
3276
|
+
// content, so land right after pPr, mirroring the Python engine;
|
|
3277
|
+
// appendChild would drop the text at the paragraph's END.
|
|
3278
|
+
const para_el = anchor_para._element;
|
|
3279
|
+
let ref: Node | null = para_el.firstChild;
|
|
3280
|
+
while (ref && (ref as Element).tagName === "w:pPr") {
|
|
3281
|
+
ref = ref.nextSibling;
|
|
3282
|
+
}
|
|
3283
|
+
para_el.insertBefore(result.first_node, ref);
|
|
3011
3284
|
}
|
|
3012
3285
|
}
|
|
3013
3286
|
|