@adeu/core 1.17.2 → 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 +227 -80
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +38 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +227 -80
- 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.ts +329 -91
- 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/package.json
CHANGED
package/src/engine.bugs.test.ts
CHANGED
|
@@ -418,7 +418,7 @@ describe("Resolved Bugs Core Engine Verification", () => {
|
|
|
418
418
|
expect(pTarget.parentNode).toBeNull();
|
|
419
419
|
});
|
|
420
420
|
|
|
421
|
-
it("BUG-EXPLORE-2:
|
|
421
|
+
it("BUG-EXPLORE-2: a strict edit inside a foreign author's insertion applies (nested change)", async () => {
|
|
422
422
|
const doc = await createTestDocument();
|
|
423
423
|
addParagraph(doc, "Original baseline.");
|
|
424
424
|
|
|
@@ -432,20 +432,18 @@ describe("Resolved Bugs Core Engine Verification", () => {
|
|
|
432
432
|
},
|
|
433
433
|
]);
|
|
434
434
|
|
|
435
|
-
// Author B
|
|
435
|
+
// Author B modifies Author A's pending insertion — this now applies: the
|
|
436
|
+
// enclosing <w:ins> is split and Author B's change nested inside it.
|
|
436
437
|
const engineB = new RedlineEngine(doc, "Author B");
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
}).toThrowError(
|
|
447
|
-
/Accept that change first or scope your edit outside of it/,
|
|
448
|
-
);
|
|
438
|
+
const result = engineB.process_batch([
|
|
439
|
+
{
|
|
440
|
+
type: "modify",
|
|
441
|
+
target_text: "Inserted by A.",
|
|
442
|
+
new_text: "Modified by B.",
|
|
443
|
+
},
|
|
444
|
+
]);
|
|
445
|
+
expect(result.edits_applied).toBe(1);
|
|
446
|
+
expect(result.edits_skipped).toBe(0);
|
|
449
447
|
});
|
|
450
448
|
|
|
451
449
|
it("BUG-CROSS-PARA-1: Cross-paragraph modify coalesces paragraphs and tracks para-mark deletion", async () => {
|
|
@@ -422,31 +422,36 @@ describe("BUG-23-4: multi-paragraph target_text must produce actionable feedback
|
|
|
422
422
|
}
|
|
423
423
|
});
|
|
424
424
|
|
|
425
|
-
it("BUG-23-4-NN:
|
|
425
|
+
it("BUG-23-4-NN: applies balanced plain-paragraph N->N modifications per paragraph, preserving the boundary", async () => {
|
|
426
426
|
const doc = await createTestDocument();
|
|
427
427
|
addParagraph(doc, "Clause 1 ends here.");
|
|
428
428
|
addParagraph(doc, "Clause 2 begins here.");
|
|
429
429
|
|
|
430
430
|
const engine = new RedlineEngine(doc, "Test Author");
|
|
431
431
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
432
|
+
// Both target and replacement carry the same number of \n\n breaks, so the
|
|
433
|
+
// paragraph structure is preserved. The engine splits this into one sub-edit
|
|
434
|
+
// per paragraph and applies it as a single logical edit. (Unbalanced
|
|
435
|
+
// merges/splits are still rejected — see the regex case in engine.qa.test.)
|
|
436
|
+
const result = engine.process_batch([
|
|
437
|
+
{
|
|
438
|
+
type: "modify",
|
|
439
|
+
target_text: "ends here.\n\nClause 2 begins",
|
|
440
|
+
new_text: "ends here. MERGED\n\nClause 2 begins CHANGED",
|
|
441
|
+
},
|
|
442
|
+
]);
|
|
444
443
|
|
|
445
|
-
expect(
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
444
|
+
expect(result.edits_applied).toBe(1);
|
|
445
|
+
expect(result.edits_skipped).toBe(0);
|
|
446
|
+
|
|
447
|
+
const buf = await doc.save();
|
|
448
|
+
const text = await extractTextFromBuffer(buf);
|
|
449
|
+
// The paragraph boundary must survive — the two clauses are NOT merged.
|
|
450
|
+
expect(text).not.toContain("ends here.Clause 2 begins");
|
|
451
|
+
expect(text).not.toContain("ends here. Clause 2 begins");
|
|
452
|
+
// Both per-paragraph insertions land.
|
|
453
|
+
expect(text).toContain("MERGED");
|
|
454
|
+
expect(text).toContain("CHANGED");
|
|
450
455
|
});
|
|
451
456
|
});
|
|
452
457
|
|
package/src/engine.ts
CHANGED
|
@@ -673,6 +673,106 @@ export class RedlineEngine {
|
|
|
673
673
|
}
|
|
674
674
|
}
|
|
675
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
|
+
|
|
676
776
|
private _getNextId(): string {
|
|
677
777
|
this.current_id++;
|
|
678
778
|
return this.current_id.toString();
|
|
@@ -1501,38 +1601,63 @@ export class RedlineEngine {
|
|
|
1501
1601
|
(edit.new_text || "").length - sfx,
|
|
1502
1602
|
);
|
|
1503
1603
|
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
|
-
|
|
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
|
+
}
|
|
1525
1637
|
}
|
|
1526
1638
|
}
|
|
1527
1639
|
}
|
|
1528
1640
|
}
|
|
1529
1641
|
|
|
1530
1642
|
for (const [start, length] of matches) {
|
|
1531
|
-
|
|
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(
|
|
1532
1649
|
(s) => s.end > start && s.start < start + length,
|
|
1533
1650
|
);
|
|
1534
|
-
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;
|
|
1535
1658
|
for (const s of spans) {
|
|
1659
|
+
if (s.run === null) continue;
|
|
1660
|
+
let isForeignIns = false;
|
|
1536
1661
|
if (s.ins_id) {
|
|
1537
1662
|
const insNodes = findAllDescendants(
|
|
1538
1663
|
this.doc.element,
|
|
@@ -1541,24 +1666,45 @@ export class RedlineEngine {
|
|
|
1541
1666
|
if (insNodes.length > 0) {
|
|
1542
1667
|
const auth = insNodes[0].getAttribute("w:author");
|
|
1543
1668
|
if (auth && auth !== this.author) {
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
if (is_lockout) {
|
|
1547
|
-
nestedAuthors.add(auth);
|
|
1548
|
-
}
|
|
1669
|
+
insAuthors.add(auth);
|
|
1670
|
+
isForeignIns = true;
|
|
1549
1671
|
}
|
|
1550
1672
|
}
|
|
1551
1673
|
}
|
|
1674
|
+
if (!isForeignIns) hasNonForeignRealText = true;
|
|
1675
|
+
}
|
|
1676
|
+
for (const s of spans) {
|
|
1552
1677
|
if (s.comment_ids) {
|
|
1553
1678
|
for (const cid of s.comment_ids) {
|
|
1554
1679
|
const c_data = this.mapper.comments_map[cid];
|
|
1555
1680
|
if (c_data && c_data.author && c_data.author !== this.author) {
|
|
1556
|
-
|
|
1681
|
+
commentAuthors.add(c_data.author);
|
|
1557
1682
|
}
|
|
1558
1683
|
}
|
|
1559
1684
|
}
|
|
1560
1685
|
}
|
|
1561
|
-
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
|
+
]);
|
|
1562
1708
|
errors.push(
|
|
1563
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.`,
|
|
1564
1710
|
);
|
|
@@ -1686,60 +1832,17 @@ export class RedlineEngine {
|
|
|
1686
1832
|
!["accept", "reject", "reply"].includes(c.type),
|
|
1687
1833
|
);
|
|
1688
1834
|
|
|
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
|
-
}
|
|
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).
|
|
1743
1846
|
|
|
1744
1847
|
// BUG-7: Unified single-pass validation in wet-run / standard mode.
|
|
1745
1848
|
if (!dry_run_mode) {
|
|
@@ -2018,6 +2121,10 @@ export class RedlineEngine {
|
|
|
2018
2121
|
(b[0]._resolved_start_idx || 0) - (a[0]._resolved_start_idx || 0),
|
|
2019
2122
|
);
|
|
2020
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>();
|
|
2021
2128
|
|
|
2022
2129
|
for (const [edit, orig_new] of resolved_edits) {
|
|
2023
2130
|
const start = edit._resolved_start_idx || 0;
|
|
@@ -2050,14 +2157,27 @@ export class RedlineEngine {
|
|
|
2050
2157
|
}
|
|
2051
2158
|
|
|
2052
2159
|
if (success) {
|
|
2053
|
-
|
|
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++;
|
|
2054
2172
|
occupied_ranges.push([start, end]);
|
|
2055
2173
|
edit._applied_status = true;
|
|
2056
2174
|
const parent = edit._parent_edit_ref;
|
|
2057
2175
|
if (parent) {
|
|
2058
2176
|
parent._applied_status = true;
|
|
2059
|
-
|
|
2060
|
-
|
|
2177
|
+
if (first_in_group) {
|
|
2178
|
+
parent._occurrences_modified =
|
|
2179
|
+
(parent._occurrences_modified || 0) + 1;
|
|
2180
|
+
}
|
|
2061
2181
|
const [path, page] = this._get_heading_path_and_page(
|
|
2062
2182
|
start,
|
|
2063
2183
|
this.mapper.full_text,
|
|
@@ -2068,7 +2188,9 @@ export class RedlineEngine {
|
|
|
2068
2188
|
parent._pages = pages;
|
|
2069
2189
|
parent._heading_path = path;
|
|
2070
2190
|
} else {
|
|
2071
|
-
|
|
2191
|
+
if (first_in_group) {
|
|
2192
|
+
edit._occurrences_modified = (edit._occurrences_modified || 0) + 1;
|
|
2193
|
+
}
|
|
2072
2194
|
const [path, page] = this._get_heading_path_and_page(
|
|
2073
2195
|
start,
|
|
2074
2196
|
this.mapper.full_text,
|
|
@@ -2419,6 +2541,65 @@ export class RedlineEngine {
|
|
|
2419
2541
|
final_new = current_effective_new_text.substring(prefix_len, n_end);
|
|
2420
2542
|
effective_start_idx = start_idx + prefix_len;
|
|
2421
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
|
+
|
|
2422
2603
|
if (!final_target && final_new) effective_op = "INSERTION";
|
|
2423
2604
|
else if (final_target && !final_new) effective_op = "DELETION";
|
|
2424
2605
|
else if (final_target && final_new) effective_op = "MODIFICATION";
|
|
@@ -2441,6 +2622,41 @@ export class RedlineEngine {
|
|
|
2441
2622
|
return all_sub_edits[0];
|
|
2442
2623
|
}
|
|
2443
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
|
+
|
|
2444
2660
|
private _apply_single_edit_indexed(
|
|
2445
2661
|
edit: any,
|
|
2446
2662
|
orig_new: string | null,
|
|
@@ -2699,7 +2915,20 @@ export class RedlineEngine {
|
|
|
2699
2915
|
const is_inline_first = result.first_node.tagName === "w:ins";
|
|
2700
2916
|
if (is_inline_first) {
|
|
2701
2917
|
if (anchor_run) {
|
|
2702
|
-
|
|
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
|
+
}
|
|
2703
2932
|
} else if (anchor_para) {
|
|
2704
2933
|
anchor_para._element.appendChild(result.first_node);
|
|
2705
2934
|
}
|
|
@@ -2841,8 +3070,17 @@ export class RedlineEngine {
|
|
|
2841
3070
|
if (result.first_node) {
|
|
2842
3071
|
const is_inline_first = result.first_node.tagName === "w:ins";
|
|
2843
3072
|
if (is_inline_first) {
|
|
2844
|
-
|
|
2845
|
-
|
|
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
|
+
}
|
|
2846
3084
|
ins_elem = result.first_node;
|
|
2847
3085
|
} else {
|
|
2848
3086
|
// Block-mode first paragraph was already inserted after the anchor
|