@adeu/core 1.17.2 → 1.18.1
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 +250 -87
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +48 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +250 -87
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/engine.bugs.test.ts +12 -14
- package/src/engine.feedback.test.ts +34 -2
- package/src/engine.issue23.test.ts +23 -18
- package/src/engine.ts +359 -98
- package/src/reject_and_nested_redline.test.ts +137 -0
- package/src/repro.report.test.ts +11 -9
- package/src/repro_qa_report_v3.test.ts +19 -27
- package/src/repro_report_boundary_failures.test.ts +108 -0
package/src/engine.ts
CHANGED
|
@@ -322,8 +322,19 @@ export class RedlineEngine {
|
|
|
322
322
|
this.comments_manager = new CommentsManager(this.doc);
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Return a hint when a short, single-token anchor contains punctuation that
|
|
327
|
+
* can split awkwardly, else null.
|
|
328
|
+
*
|
|
329
|
+
* Surface this ONLY for edits that actually failed to match/apply. On a
|
|
330
|
+
* successful edit the batch report already carries the redline preview, so
|
|
331
|
+
* emitting this would be a false positive: the punctuation (dates,
|
|
332
|
+
* `[_name_]` placeholders, `____` blanks) is frequently the literal target
|
|
333
|
+
* and the edit succeeds despite it. Mirrors the Python engine.
|
|
334
|
+
*/
|
|
325
335
|
private _check_punctuation_warning(target_text: string): string | null {
|
|
326
336
|
if (!target_text) return null;
|
|
337
|
+
if (target_text.length > 20 || target_text.includes(" ")) return null;
|
|
327
338
|
if (target_text.includes("_") || target_text.includes("-")) {
|
|
328
339
|
return `Warning: target_text '${target_text}' contains tokenization-splitting punctuation ('_' or '-'). This can trigger mid-word splits in the diff engine. Consider using a longer plain-prose anchor.`;
|
|
329
340
|
}
|
|
@@ -673,6 +684,106 @@ export class RedlineEngine {
|
|
|
673
684
|
}
|
|
674
685
|
}
|
|
675
686
|
|
|
687
|
+
/**
|
|
688
|
+
* Revert every tracked change, returning the document to the state it had
|
|
689
|
+
* before any revision was proposed. The exact inverse of
|
|
690
|
+
* accept_all_revisions:
|
|
691
|
+
*
|
|
692
|
+
* - <w:ins> -> removed together with all of its content (the proposed
|
|
693
|
+
* insertion never existed); an inserted row (<w:ins> in
|
|
694
|
+
* <w:trPr>) drops the whole row.
|
|
695
|
+
* - <w:del> -> unwrapped, restoring the original text (<w:delText> becomes
|
|
696
|
+
* <w:t> again); a row-deletion mark in <w:trPr> is removed so
|
|
697
|
+
* the row survives.
|
|
698
|
+
* - paragraph-mark <w:del> in pPr/rPr -> removed, undoing a proposed merge.
|
|
699
|
+
*
|
|
700
|
+
* Comments are annotations, not revisions, so standalone comments are left in
|
|
701
|
+
* place; only anchors stranded inside a rejected insertion are cleaned up.
|
|
702
|
+
*
|
|
703
|
+
* Insertions are reverted before deletions are restored so a deletion nested
|
|
704
|
+
* inside a foreign author's insertion is removed wholesale with the insertion
|
|
705
|
+
* — the contingent text disappears rather than being promoted to committed
|
|
706
|
+
* body text.
|
|
707
|
+
*
|
|
708
|
+
* Known limitation: tracked paragraph STRUCTURE changes (a split recorded as a
|
|
709
|
+
* pilcrow <w:ins>, or a merge recorded as a pilcrow <w:del>) are reverted only
|
|
710
|
+
* to the extent of dropping/keeping the mark; the original paragraph boundary
|
|
711
|
+
* is not reconstructed, because the merge protocol coalesces paragraphs
|
|
712
|
+
* destructively at edit time. Reverting run-level insertions/deletions (the
|
|
713
|
+
* common case) is exact. This limitation is shared with the Python engine.
|
|
714
|
+
*/
|
|
715
|
+
public reject_all_revisions() {
|
|
716
|
+
const parts_to_process: Element[] = [this.doc.element];
|
|
717
|
+
|
|
718
|
+
for (const part of this.doc.pkg.parts) {
|
|
719
|
+
if (part === this.doc.part) continue;
|
|
720
|
+
if (
|
|
721
|
+
part.contentType.includes("wordprocessingml") &&
|
|
722
|
+
part.contentType.endsWith("+xml")
|
|
723
|
+
) {
|
|
724
|
+
parts_to_process.push(part._element);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
for (const root_element of parts_to_process) {
|
|
729
|
+
// 1. Reject insertions: drop the <w:ins> and everything inside it.
|
|
730
|
+
// Document order means an outer <w:ins> is handled before a nested
|
|
731
|
+
// one; removing the outer detaches the inner (guarded below).
|
|
732
|
+
const insNodes = findAllDescendants(root_element, "w:ins");
|
|
733
|
+
for (const ins of insNodes) {
|
|
734
|
+
const parent = ins.parentNode as Element | null;
|
|
735
|
+
if (!parent) continue;
|
|
736
|
+
this._clean_wrapping_comments(ins);
|
|
737
|
+
this._delete_comments_in_element(ins);
|
|
738
|
+
if (parent.tagName === "w:trPr") {
|
|
739
|
+
const row = parent.parentNode as Element | null;
|
|
740
|
+
if (row && row.parentNode) {
|
|
741
|
+
row.parentNode.removeChild(row);
|
|
742
|
+
}
|
|
743
|
+
} else {
|
|
744
|
+
parent.removeChild(ins);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// 2. Reject paragraph-mark deletions: keep the paragraph break.
|
|
749
|
+
const pNodes = findAllDescendants(root_element, "w:p");
|
|
750
|
+
for (const p of pNodes) {
|
|
751
|
+
const pPr = findChild(p, "w:pPr");
|
|
752
|
+
if (pPr) {
|
|
753
|
+
const rPr = findChild(pPr, "w:rPr");
|
|
754
|
+
const delMark = rPr ? findChild(rPr, "w:del") : null;
|
|
755
|
+
if (rPr && delMark) {
|
|
756
|
+
rPr.removeChild(delMark);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// 3. Reject deletions: restore the original text.
|
|
762
|
+
const delNodes = findAllDescendants(root_element, "w:del");
|
|
763
|
+
for (const d of delNodes) {
|
|
764
|
+
const parent = d.parentNode as Element | null;
|
|
765
|
+
if (!parent) continue;
|
|
766
|
+
this._clean_wrapping_comments(d);
|
|
767
|
+
if (parent.tagName === "w:trPr") {
|
|
768
|
+
parent.removeChild(d);
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
const delTexts = Array.from(d.getElementsByTagName("w:delText"));
|
|
772
|
+
for (const dt of delTexts) {
|
|
773
|
+
const t = d.ownerDocument!.createElement("w:t");
|
|
774
|
+
t.textContent = dt.textContent;
|
|
775
|
+
if (dt.hasAttribute("xml:space"))
|
|
776
|
+
t.setAttribute("xml:space", "preserve");
|
|
777
|
+
dt.parentNode?.replaceChild(t, dt);
|
|
778
|
+
}
|
|
779
|
+
while (d.firstChild) {
|
|
780
|
+
parent.insertBefore(d.firstChild, d);
|
|
781
|
+
}
|
|
782
|
+
parent.removeChild(d);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
676
787
|
private _getNextId(): string {
|
|
677
788
|
this.current_id++;
|
|
678
789
|
return this.current_id.toString();
|
|
@@ -1501,38 +1612,63 @@ export class RedlineEngine {
|
|
|
1501
1612
|
(edit.new_text || "").length - sfx,
|
|
1502
1613
|
);
|
|
1503
1614
|
if (final_target.includes("\n\n")) {
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1615
|
+
// A *balanced* multi-paragraph modification (target and replacement
|
|
1616
|
+
// carry the same number of paragraph breaks) is safe: it is split
|
|
1617
|
+
// into one sub-edit per paragraph segment and applied, leaving the
|
|
1618
|
+
// structural \n\n breaks untouched. Only reject when the paragraph
|
|
1619
|
+
// structure would actually change (a merge or split), which cannot be
|
|
1620
|
+
// expressed as a per-paragraph text replacement. See
|
|
1621
|
+
// _pre_resolve_heuristic_edit.
|
|
1622
|
+
const balanced =
|
|
1623
|
+
matched.split("\n\n").length ===
|
|
1624
|
+
(edit.new_text || "").split("\n\n").length;
|
|
1625
|
+
if (!balanced) {
|
|
1626
|
+
if (final_new.includes("\n\n")) {
|
|
1627
|
+
const parts = matched.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
|
+
}
|
|
1637
|
+
} else {
|
|
1638
|
+
const parts = final_target.split("\n\n");
|
|
1639
|
+
if (
|
|
1640
|
+
parts.length >= 2 &&
|
|
1641
|
+
parts[0].trim() !== "" &&
|
|
1642
|
+
parts[parts.length - 1].trim() !== ""
|
|
1643
|
+
) {
|
|
1644
|
+
errors.push(
|
|
1645
|
+
`- 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.`,
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1525
1648
|
}
|
|
1526
1649
|
}
|
|
1527
1650
|
}
|
|
1528
1651
|
}
|
|
1529
1652
|
|
|
1530
1653
|
for (const [start, length] of matches) {
|
|
1531
|
-
|
|
1654
|
+
// Filter spans from the SAME mapper the match indices came from
|
|
1655
|
+
// (target_mapper may be the clean mapper); using this.mapper.spans here
|
|
1656
|
+
// would read a different coordinate space and miss the foreign <w:ins>
|
|
1657
|
+
// overlap for clean-mapper-resolved targets — silently letting a
|
|
1658
|
+
// partial straddle through. (Python filters target_mapper.spans too.)
|
|
1659
|
+
const spans = target_mapper.spans.filter(
|
|
1532
1660
|
(s) => s.end > start && s.start < start + length,
|
|
1533
1661
|
);
|
|
1534
|
-
const
|
|
1662
|
+
const insAuthors = new Set<string>();
|
|
1663
|
+
const commentAuthors = new Set<string>();
|
|
1664
|
+
// Does any real (run-backed) text in the target lie OUTSIDE a foreign
|
|
1665
|
+
// insertion? If so the target only partially overlaps the insertion and
|
|
1666
|
+
// replacing it as one span would straddle the <w:ins> boundary — that
|
|
1667
|
+
// case must still be refused.
|
|
1668
|
+
let hasNonForeignRealText = false;
|
|
1535
1669
|
for (const s of spans) {
|
|
1670
|
+
if (s.run === null) continue;
|
|
1671
|
+
let isForeignIns = false;
|
|
1536
1672
|
if (s.ins_id) {
|
|
1537
1673
|
const insNodes = findAllDescendants(
|
|
1538
1674
|
this.doc.element,
|
|
@@ -1541,24 +1677,45 @@ export class RedlineEngine {
|
|
|
1541
1677
|
if (insNodes.length > 0) {
|
|
1542
1678
|
const auth = insNodes[0].getAttribute("w:author");
|
|
1543
1679
|
if (auth && auth !== this.author) {
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
if (is_lockout) {
|
|
1547
|
-
nestedAuthors.add(auth);
|
|
1548
|
-
}
|
|
1680
|
+
insAuthors.add(auth);
|
|
1681
|
+
isForeignIns = true;
|
|
1549
1682
|
}
|
|
1550
1683
|
}
|
|
1551
1684
|
}
|
|
1685
|
+
if (!isForeignIns) hasNonForeignRealText = true;
|
|
1686
|
+
}
|
|
1687
|
+
for (const s of spans) {
|
|
1552
1688
|
if (s.comment_ids) {
|
|
1553
1689
|
for (const cid of s.comment_ids) {
|
|
1554
1690
|
const c_data = this.mapper.comments_map[cid];
|
|
1555
1691
|
if (c_data && c_data.author && c_data.author !== this.author) {
|
|
1556
|
-
|
|
1692
|
+
commentAuthors.add(c_data.author);
|
|
1557
1693
|
}
|
|
1558
1694
|
}
|
|
1559
1695
|
}
|
|
1560
1696
|
}
|
|
1561
|
-
if (
|
|
1697
|
+
if (insAuthors.size > 0 || commentAuthors.size > 0) {
|
|
1698
|
+
// A single (strict/first) modification whose target lies ENTIRELY
|
|
1699
|
+
// inside foreign-authored insertion(s), with no foreign comment
|
|
1700
|
+
// overlap, is allowed: track_delete_run splits the enclosing <w:ins>
|
|
1701
|
+
// and nests the change, producing valid tracked-change XML. Refuse the
|
|
1702
|
+
// remaining cases — match_mode "all" fan-outs, partial overlaps that
|
|
1703
|
+
// straddle the insertion boundary, and edits touching another author's
|
|
1704
|
+
// comment range.
|
|
1705
|
+
const fullyWithinForeignIns =
|
|
1706
|
+
insAuthors.size > 0 &&
|
|
1707
|
+
!hasNonForeignRealText &&
|
|
1708
|
+
commentAuthors.size === 0;
|
|
1709
|
+
if (
|
|
1710
|
+
(match_mode === "strict" || match_mode === "first") &&
|
|
1711
|
+
fullyWithinForeignIns
|
|
1712
|
+
) {
|
|
1713
|
+
continue;
|
|
1714
|
+
}
|
|
1715
|
+
const nestedAuthors = new Set<string>([
|
|
1716
|
+
...insAuthors,
|
|
1717
|
+
...commentAuthors,
|
|
1718
|
+
]);
|
|
1562
1719
|
errors.push(
|
|
1563
1720
|
`- 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.`,
|
|
1564
1721
|
);
|
|
@@ -1686,60 +1843,17 @@ export class RedlineEngine {
|
|
|
1686
1843
|
!["accept", "reject", "reply"].includes(c.type),
|
|
1687
1844
|
);
|
|
1688
1845
|
|
|
1689
|
-
//
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
matches = this.clean_mapper.find_all_match_indices(edit.target_text, is_regex);
|
|
1701
|
-
target_mapper = this.clean_mapper!;
|
|
1702
|
-
}
|
|
1703
|
-
for (const [start, length] of matches) {
|
|
1704
|
-
const spans = target_mapper.spans.filter(
|
|
1705
|
-
(s) => s.end > start && s.start < start + length,
|
|
1706
|
-
);
|
|
1707
|
-
for (const s of spans) {
|
|
1708
|
-
if (s.ins_id) {
|
|
1709
|
-
const insNodes = findAllDescendants(this.doc.element, "w:ins").filter(
|
|
1710
|
-
(n) => n.getAttribute("w:id") === s.ins_id,
|
|
1711
|
-
);
|
|
1712
|
-
if (insNodes.length > 0) {
|
|
1713
|
-
const auth = insNodes[0].getAttribute("w:author");
|
|
1714
|
-
if (auth && auth !== this.author) {
|
|
1715
|
-
const is_fully_contained_in_ins = start >= s.start && (start + length) <= s.end;
|
|
1716
|
-
const match_mode = edit.match_mode || "strict";
|
|
1717
|
-
if (!is_fully_contained_in_ins && match_mode !== "all") {
|
|
1718
|
-
const node = insNodes[0];
|
|
1719
|
-
this._clean_wrapping_comments(node);
|
|
1720
|
-
const parent = node.parentNode as Element | null;
|
|
1721
|
-
if (parent) {
|
|
1722
|
-
if (parent.tagName === "w:trPr") {
|
|
1723
|
-
parent.removeChild(node);
|
|
1724
|
-
} else {
|
|
1725
|
-
while (node.firstChild) {
|
|
1726
|
-
parent.insertBefore(node.firstChild, node);
|
|
1727
|
-
}
|
|
1728
|
-
parent.removeChild(node);
|
|
1729
|
-
}
|
|
1730
|
-
}
|
|
1731
|
-
mapper_dirty = true;
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
}
|
|
1735
|
-
}
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
}
|
|
1739
|
-
if (mapper_dirty) {
|
|
1740
|
-
this.mapper = new DocumentMapper(this.doc);
|
|
1741
|
-
this.clean_mapper = null;
|
|
1742
|
-
}
|
|
1846
|
+
// NOTE: a previous "edits_for_merge" pre-pass here silently UNWRAPPED a
|
|
1847
|
+
// foreign author's <w:ins> when a strict/first edit only partially straddled
|
|
1848
|
+
// its boundary — turning that author's tracked-inserted text into untracked
|
|
1849
|
+
// committed body text before the edit applied, destroying their provenance.
|
|
1850
|
+
// That is the same provenance-laundering failure mode the canonical engine
|
|
1851
|
+
// refuses, so it has been removed: a partial straddle now surfaces the
|
|
1852
|
+
// standard validation error ("Modification targets an active insertion from
|
|
1853
|
+
// another author …") via validate_edits, matching the Python engine. An edit
|
|
1854
|
+
// fully CONTAINED inside a foreign <w:ins> stays allowed and is handled by
|
|
1855
|
+
// nesting the <w:del> inside that <w:ins> (see _apply_single_edit_indexed /
|
|
1856
|
+
// _insert_and_split_ins).
|
|
1743
1857
|
|
|
1744
1858
|
// BUG-7: Unified single-pass validation in wet-run / standard mode.
|
|
1745
1859
|
if (!dry_run_mode) {
|
|
@@ -1791,11 +1905,16 @@ export class RedlineEngine {
|
|
|
1791
1905
|
for (let i = 0; i < edits.length; i++) {
|
|
1792
1906
|
const edit = edits[i];
|
|
1793
1907
|
const single_errors = this.validate_edits([edit], i);
|
|
1794
|
-
const warning = this._check_punctuation_warning(
|
|
1795
|
-
(edit as any).target_text || "",
|
|
1796
|
-
);
|
|
1797
1908
|
if (single_errors.length > 0) {
|
|
1798
1909
|
skipped_edits++;
|
|
1910
|
+
// Only surface the punctuation-anchor warning when the edit actually
|
|
1911
|
+
// failed. A clean apply already returns the redline preview, so the
|
|
1912
|
+
// warning is pure noise on success — and it misleads agents into
|
|
1913
|
+
// hunting for a "cleaner" anchor that was never needed (e.g. on
|
|
1914
|
+
// placeholders/dates where the punctuation IS the literal target).
|
|
1915
|
+
const warning = this._check_punctuation_warning(
|
|
1916
|
+
(edit as any).target_text || "",
|
|
1917
|
+
);
|
|
1799
1918
|
edits_reports.push({
|
|
1800
1919
|
status: "failed",
|
|
1801
1920
|
target_text: (edit as any).target_text || "",
|
|
@@ -1815,7 +1934,7 @@ export class RedlineEngine {
|
|
|
1815
1934
|
status: "applied",
|
|
1816
1935
|
target_text: (edit as any).target_text || "",
|
|
1817
1936
|
new_text: (edit as any).new_text || "",
|
|
1818
|
-
warning:
|
|
1937
|
+
warning: null,
|
|
1819
1938
|
error: null,
|
|
1820
1939
|
critic_markup: previews[0],
|
|
1821
1940
|
clean_text: previews[1],
|
|
@@ -1832,6 +1951,9 @@ export class RedlineEngine {
|
|
|
1832
1951
|
this.skipped_details.length > 0
|
|
1833
1952
|
? this.skipped_details[this.skipped_details.length - 1]
|
|
1834
1953
|
: "Failed to apply edit";
|
|
1954
|
+
const warning = this._check_punctuation_warning(
|
|
1955
|
+
(edit as any).target_text || "",
|
|
1956
|
+
);
|
|
1835
1957
|
edits_reports.push({
|
|
1836
1958
|
status: "failed",
|
|
1837
1959
|
target_text: (edit as any).target_text || "",
|
|
@@ -1880,15 +2002,19 @@ export class RedlineEngine {
|
|
|
1880
2002
|
for (const edit of edits) {
|
|
1881
2003
|
const success = (edit as any)._applied_status || false;
|
|
1882
2004
|
const error_msg = (edit as any)._error_msg || null;
|
|
1883
|
-
const warning = this._check_punctuation_warning(
|
|
1884
|
-
(edit as any).target_text || "",
|
|
1885
|
-
);
|
|
1886
2005
|
let critic_markup = null;
|
|
1887
2006
|
let clean_text = null;
|
|
2007
|
+
// Punctuation-anchor warning is failure-context only: on success the
|
|
2008
|
+
// redline preview below already reports the change cleanly.
|
|
2009
|
+
let warning: string | null = null;
|
|
1888
2010
|
if (success) {
|
|
1889
2011
|
const previews = this._build_edit_context_previews(edit);
|
|
1890
2012
|
critic_markup = previews[0];
|
|
1891
2013
|
clean_text = previews[1];
|
|
2014
|
+
} else {
|
|
2015
|
+
warning = this._check_punctuation_warning(
|
|
2016
|
+
(edit as any).target_text || "",
|
|
2017
|
+
);
|
|
1892
2018
|
}
|
|
1893
2019
|
edits_reports.push({
|
|
1894
2020
|
status: success ? "applied" : "failed",
|
|
@@ -2018,6 +2144,10 @@ export class RedlineEngine {
|
|
|
2018
2144
|
(b[0]._resolved_start_idx || 0) - (a[0]._resolved_start_idx || 0),
|
|
2019
2145
|
);
|
|
2020
2146
|
const occupied_ranges: [number, number][] = [];
|
|
2147
|
+
// Sub-edits split from one balanced multi-paragraph modification share a
|
|
2148
|
+
// _split_group_id; count the group as a single applied edit (and a single
|
|
2149
|
+
// occurrence), even though it touches several paragraphs.
|
|
2150
|
+
const counted_split_groups = new Set<number>();
|
|
2021
2151
|
|
|
2022
2152
|
for (const [edit, orig_new] of resolved_edits) {
|
|
2023
2153
|
const start = edit._resolved_start_idx || 0;
|
|
@@ -2050,14 +2180,27 @@ export class RedlineEngine {
|
|
|
2050
2180
|
}
|
|
2051
2181
|
|
|
2052
2182
|
if (success) {
|
|
2053
|
-
|
|
2183
|
+
// A balanced multi-paragraph split fans one logical edit into several
|
|
2184
|
+
// paragraph sub-edits sharing a _split_group_id; count it once. Edits
|
|
2185
|
+
// with no group id (the common case) always count.
|
|
2186
|
+
const group_id = edit._split_group_id;
|
|
2187
|
+
const first_in_group =
|
|
2188
|
+
group_id === undefined ||
|
|
2189
|
+
group_id === null ||
|
|
2190
|
+
!counted_split_groups.has(group_id);
|
|
2191
|
+
if (first_in_group && group_id !== undefined && group_id !== null) {
|
|
2192
|
+
counted_split_groups.add(group_id);
|
|
2193
|
+
}
|
|
2194
|
+
if (first_in_group) applied++;
|
|
2054
2195
|
occupied_ranges.push([start, end]);
|
|
2055
2196
|
edit._applied_status = true;
|
|
2056
2197
|
const parent = edit._parent_edit_ref;
|
|
2057
2198
|
if (parent) {
|
|
2058
2199
|
parent._applied_status = true;
|
|
2059
|
-
|
|
2060
|
-
|
|
2200
|
+
if (first_in_group) {
|
|
2201
|
+
parent._occurrences_modified =
|
|
2202
|
+
(parent._occurrences_modified || 0) + 1;
|
|
2203
|
+
}
|
|
2061
2204
|
const [path, page] = this._get_heading_path_and_page(
|
|
2062
2205
|
start,
|
|
2063
2206
|
this.mapper.full_text,
|
|
@@ -2068,7 +2211,9 @@ export class RedlineEngine {
|
|
|
2068
2211
|
parent._pages = pages;
|
|
2069
2212
|
parent._heading_path = path;
|
|
2070
2213
|
} else {
|
|
2071
|
-
|
|
2214
|
+
if (first_in_group) {
|
|
2215
|
+
edit._occurrences_modified = (edit._occurrences_modified || 0) + 1;
|
|
2216
|
+
}
|
|
2072
2217
|
const [path, page] = this._get_heading_path_and_page(
|
|
2073
2218
|
start,
|
|
2074
2219
|
this.mapper.full_text,
|
|
@@ -2419,6 +2564,65 @@ export class RedlineEngine {
|
|
|
2419
2564
|
final_new = current_effective_new_text.substring(prefix_len, n_end);
|
|
2420
2565
|
effective_start_idx = start_idx + prefix_len;
|
|
2421
2566
|
|
|
2567
|
+
// Balanced multi-paragraph modification: the matched span crosses one or
|
|
2568
|
+
// more paragraph breaks and the replacement preserves the same number of
|
|
2569
|
+
// breaks. Apply it as one independent sub-edit per paragraph segment so
|
|
2570
|
+
// the structural \n\n breaks are left intact. Each sub-edit shares a
|
|
2571
|
+
// _split_group_id (the occurrence's start index) so the batch report
|
|
2572
|
+
// counts it as a single applied edit. Unbalanced cases (a genuine
|
|
2573
|
+
// paragraph merge or split) fall through to the single-span path and are
|
|
2574
|
+
// rejected by validate_edits.
|
|
2575
|
+
const target_segs = actual_doc_text.split("\n\n");
|
|
2576
|
+
const new_segs = current_effective_new_text.split("\n\n");
|
|
2577
|
+
if (
|
|
2578
|
+
actual_doc_text.includes("\n\n") &&
|
|
2579
|
+
target_segs.length === new_segs.length
|
|
2580
|
+
) {
|
|
2581
|
+
const split_sub_edits: any[] = [];
|
|
2582
|
+
let seg_offset = start_idx;
|
|
2583
|
+
let comment_assigned = false;
|
|
2584
|
+
for (let k = 0; k < target_segs.length; k++) {
|
|
2585
|
+
const t_seg = target_segs[k];
|
|
2586
|
+
const n_seg = new_segs[k];
|
|
2587
|
+
if (t_seg !== n_seg) {
|
|
2588
|
+
const [seg_prefix, seg_suffix] = trim_common_context(t_seg, n_seg);
|
|
2589
|
+
const seg_target = t_seg.substring(
|
|
2590
|
+
seg_prefix,
|
|
2591
|
+
t_seg.length - seg_suffix,
|
|
2592
|
+
);
|
|
2593
|
+
const seg_new = n_seg.substring(
|
|
2594
|
+
seg_prefix,
|
|
2595
|
+
n_seg.length - seg_suffix,
|
|
2596
|
+
);
|
|
2597
|
+
const seg_start = seg_offset + seg_prefix;
|
|
2598
|
+
let seg_op: string;
|
|
2599
|
+
if (!seg_target && seg_new) seg_op = "INSERTION";
|
|
2600
|
+
else if (seg_target && !seg_new) seg_op = "DELETION";
|
|
2601
|
+
else if (seg_target && seg_new) seg_op = "MODIFICATION";
|
|
2602
|
+
else seg_op = "COMMENT_ONLY";
|
|
2603
|
+
const seg_comment =
|
|
2604
|
+
edit.comment && !comment_assigned ? edit.comment : null;
|
|
2605
|
+
if (seg_comment) comment_assigned = true;
|
|
2606
|
+
split_sub_edits.push({
|
|
2607
|
+
type: "modify",
|
|
2608
|
+
target_text: seg_target,
|
|
2609
|
+
new_text: seg_new,
|
|
2610
|
+
comment: seg_comment,
|
|
2611
|
+
_match_start_index: seg_start,
|
|
2612
|
+
_internal_op: seg_op,
|
|
2613
|
+
_active_mapper_ref: active_mapper,
|
|
2614
|
+
_split_group_id: start_idx,
|
|
2615
|
+
});
|
|
2616
|
+
}
|
|
2617
|
+
// Advance past this segment plus its "\n\n" separator span.
|
|
2618
|
+
seg_offset += t_seg.length + 2;
|
|
2619
|
+
}
|
|
2620
|
+
if (split_sub_edits.length > 0) {
|
|
2621
|
+
for (const sub of split_sub_edits) all_sub_edits.push(sub);
|
|
2622
|
+
continue;
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2422
2626
|
if (!final_target && final_new) effective_op = "INSERTION";
|
|
2423
2627
|
else if (final_target && !final_new) effective_op = "DELETION";
|
|
2424
2628
|
else if (final_target && final_new) effective_op = "MODIFICATION";
|
|
@@ -2441,6 +2645,41 @@ export class RedlineEngine {
|
|
|
2441
2645
|
return all_sub_edits[0];
|
|
2442
2646
|
}
|
|
2443
2647
|
|
|
2648
|
+
/**
|
|
2649
|
+
* Split a <w:ins> so that everything up to and INCLUDING split_after stays in
|
|
2650
|
+
* a left <w:ins>, new_elem is placed between, and the remainder moves to a
|
|
2651
|
+
* right <w:ins> — all at the grandparent level. Used when revising another
|
|
2652
|
+
* author's pending insertion: the <w:del> stays nested in their <w:ins> while
|
|
2653
|
+
* our replacement <w:ins> lands as a sibling, so we never nest <w:ins> in
|
|
2654
|
+
* <w:ins>.
|
|
2655
|
+
*/
|
|
2656
|
+
private _insert_and_split_ins(
|
|
2657
|
+
parent_ins: Element,
|
|
2658
|
+
split_after: Element,
|
|
2659
|
+
new_elem: Element,
|
|
2660
|
+
) {
|
|
2661
|
+
const grandparent = parent_ins.parentNode as Element | null;
|
|
2662
|
+
if (!grandparent) return;
|
|
2663
|
+
// cloneNode(false) copies the attributes (author/id/date) onto both halves.
|
|
2664
|
+
const left = parent_ins.cloneNode(false) as Element;
|
|
2665
|
+
const right = parent_ins.cloneNode(false) as Element;
|
|
2666
|
+
let toRight = false;
|
|
2667
|
+
for (const kid of Array.from(parent_ins.childNodes)) {
|
|
2668
|
+
parent_ins.removeChild(kid);
|
|
2669
|
+
if (!toRight) {
|
|
2670
|
+
left.appendChild(kid);
|
|
2671
|
+
if (kid === split_after) toRight = true;
|
|
2672
|
+
} else {
|
|
2673
|
+
right.appendChild(kid);
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
if (left.childNodes.length > 0) grandparent.insertBefore(left, parent_ins);
|
|
2677
|
+
grandparent.insertBefore(new_elem, parent_ins);
|
|
2678
|
+
if (right.childNodes.length > 0)
|
|
2679
|
+
grandparent.insertBefore(right, parent_ins);
|
|
2680
|
+
grandparent.removeChild(parent_ins);
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2444
2683
|
private _apply_single_edit_indexed(
|
|
2445
2684
|
edit: any,
|
|
2446
2685
|
orig_new: string | null,
|
|
@@ -2699,7 +2938,20 @@ export class RedlineEngine {
|
|
|
2699
2938
|
const is_inline_first = result.first_node.tagName === "w:ins";
|
|
2700
2939
|
if (is_inline_first) {
|
|
2701
2940
|
if (anchor_run) {
|
|
2702
|
-
|
|
2941
|
+
const anchor_parent = anchor_run._element.parentNode as Element | null;
|
|
2942
|
+
if (anchor_parent && anchor_parent.tagName === "w:ins") {
|
|
2943
|
+
// Inserting inside another author's pending <w:ins>: split it so our
|
|
2944
|
+
// new <w:ins> lands as a sibling right after the anchor run, never
|
|
2945
|
+
// <w:ins> nested in <w:ins> (mirrors the MODIFICATION path and the
|
|
2946
|
+
// Python engine).
|
|
2947
|
+
this._insert_and_split_ins(
|
|
2948
|
+
anchor_parent,
|
|
2949
|
+
anchor_run._element,
|
|
2950
|
+
result.first_node,
|
|
2951
|
+
);
|
|
2952
|
+
} else {
|
|
2953
|
+
insertAfter(result.first_node, anchor_run._element);
|
|
2954
|
+
}
|
|
2703
2955
|
} else if (anchor_para) {
|
|
2704
2956
|
anchor_para._element.appendChild(result.first_node);
|
|
2705
2957
|
}
|
|
@@ -2841,8 +3093,17 @@ export class RedlineEngine {
|
|
|
2841
3093
|
if (result.first_node) {
|
|
2842
3094
|
const is_inline_first = result.first_node.tagName === "w:ins";
|
|
2843
3095
|
if (is_inline_first) {
|
|
2844
|
-
|
|
2845
|
-
|
|
3096
|
+
const del_parent = last_del!.parentNode as Element | null;
|
|
3097
|
+
if (del_parent && del_parent.tagName === "w:ins") {
|
|
3098
|
+
// Revising another author's pending insertion: keep the <w:del>
|
|
3099
|
+
// nested in their <w:ins> and splice our new <w:ins> in right after
|
|
3100
|
+
// it by splitting their <w:ins>, so we never nest <w:ins> in
|
|
3101
|
+
// <w:ins>.
|
|
3102
|
+
this._insert_and_split_ins(del_parent, last_del!, result.first_node);
|
|
3103
|
+
} else {
|
|
3104
|
+
// Inline: place the first <w:ins> immediately after last_del.
|
|
3105
|
+
insertAfter(result.first_node, last_del!);
|
|
3106
|
+
}
|
|
2846
3107
|
ins_elem = result.first_node;
|
|
2847
3108
|
} else {
|
|
2848
3109
|
// Block-mode first paragraph was already inserted after the anchor
|