From 8ecccb8b241cf8d20f1952dce7eadc0161c8bfbb Mon Sep 17 00:00:00 2001
From: Lyz <lyz@riseup.net>
Date: Sun, 26 Jan 2025 12:03:47 +0100
Subject: [PATCH] feat: add refile heading

---
 org_rw/org_rw.py  |  62 ++++++++++++++++-
 tests/test_org.py | 165 ++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 226 insertions(+), 1 deletion(-)

diff --git a/org_rw/org_rw.py b/org_rw/org_rw.py
index 31b904c..558e7a2 100644
--- a/org_rw/org_rw.py
+++ b/org_rw/org_rw.py
@@ -329,7 +329,7 @@ class Headline:
         closed: Optional[Time] = None,
     ):
         self.start_line = start_line
-        self.depth = depth
+        self._depth = depth
         self.orig = orig
         self.properties = properties
         self.keywords = keywords
@@ -762,6 +762,16 @@ class Headline:
             pass
         self._state = new_state
 
+    @property
+    def depth(self):
+        return self._depth
+
+    @depth.setter
+    def depth(self, value):
+        self._depth = value
+        for child in self.children:
+            child.depth = value + 1
+
     @property
     def clock(self):
         times = []
@@ -1066,6 +1076,38 @@ class Headline:
         self.children.append(headline)
         return headline
 
+    def refile(
+        self, destination: Union["Headline", OrgDoc], top: bool = False
+    ) -> Union["Headline", OrgDoc]:
+        """Refile this headline to a new destination.
+
+        Args:
+            destination: The headline to which this headline will be moved
+            top: Whether to append to bottom or insert at top of destination's children
+
+        Returns:
+            The destination headline
+        """
+        # Remove from the parent
+        if self.parent:
+            if isinstance(self.parent, Headline):
+                self.parent.children.remove(self)
+            else:
+                self.parent.headlines.remove(self)
+
+        # Add ourselves to the destination
+        if top:
+            destination.children.insert(0, self)
+        else:
+            destination.children.append(self)
+
+        # Adjust the depth
+        self.depth = destination.depth + 1
+
+        # Adjust our parent
+        self.parent = destination
+        return destination
+
 
 RawLine = collections.namedtuple("RawLine", ("linenum", "line"))
 Keyword = collections.namedtuple(
@@ -2366,6 +2408,24 @@ class OrgDoc:
     def shallow_tags(self) -> list[str]:
         return self.tags
 
+    @property
+    def depth(self):
+        """
+        Attribute to be compatible with the signature of the Headlines.
+
+        Useful when doing operations across the headline hierarchy
+        """
+        return 0
+
+    @property
+    def children(self):
+        """
+        Attribute to be compatible with the signature of the Headlines.
+
+        Useful when doing operations across the headline hierarchy
+        """
+        return self.headlines
+
     ## Querying
     def get_links(self):
         for headline in self.headlines:
diff --git a/tests/test_org.py b/tests/test_org.py
index a1fdff1..59441d5 100644
--- a/tests/test_org.py
+++ b/tests/test_org.py
@@ -1024,6 +1024,171 @@ class TestSerde(unittest.TestCase):
 
         self.assertEqual(dumps(doc), "* TODO First entry")
 
+    def test_refile_headline_down_to_bottom(self) -> None:
+        orig = """* Source Headline
+** Child of Source
+* Destination Headline
+** Existing Child"""
+        doc = loads(orig)
+
+        source_headline = doc.headlines[0]
+        destination_headline = doc.headlines[1]
+
+        result = source_headline.refile(destination_headline)
+
+        assert result == destination_headline
+        assert source_headline.parent == destination_headline
+        assert source_headline in destination_headline.children
+        assert destination_headline.children[-1] == source_headline
+        assert (
+            dumps(doc)
+            == """* Destination Headline
+** Existing Child
+** Source Headline
+*** Child of Source"""
+        )
+
+    def test_refile_headline_down_to_top(self) -> None:
+        orig = """* Source Headline
+** Child of Source
+* Destination Headline
+** Existing Child"""
+        doc = loads(orig)
+
+        source_headline = doc.headlines[0]
+        destination_headline = doc.headlines[1]
+
+        result = source_headline.refile(destination_headline, top=True)
+
+        assert result == destination_headline
+        assert source_headline.parent == destination_headline
+        assert source_headline in destination_headline.children
+        assert destination_headline.children[0] == source_headline
+        assert (
+            dumps(doc)
+            == """* Destination Headline
+** Source Headline
+*** Child of Source
+** Existing Child"""
+        )
+
+    def test_refile_headline_down_to_existing_child(self) -> None:
+        orig = """* Source Headline
+** Child of Source
+* Destination Parent
+** Destination Headline"""
+        doc = loads(orig)
+
+        source_headline = doc.headlines[0]
+        destination_headline = doc.headlines[1]
+        destination_child = destination_headline.children[0]
+
+        result = source_headline.refile(destination_child)
+
+        assert result == destination_child
+        assert source_headline.parent == destination_child
+        assert source_headline in destination_child.children
+        assert destination_child.children[-1] == source_headline
+        assert (
+            dumps(doc)
+            == """* Destination Parent
+** Destination Headline
+*** Source Headline
+**** Child of Source"""
+        )
+
+    def test_refile_headline_from_child_to_parent_bottom(self) -> None:
+        orig = """* Destination Headline
+** Existing Child
+*** Source Headline
+**** Source Child"""
+        doc = loads(orig)
+        source_headline = doc.headlines[0].children[0].children[0]
+        destination_headline = doc.headlines[0]
+
+        result = source_headline.refile(destination_headline)
+
+        assert result == destination_headline
+        assert source_headline.parent == destination_headline
+        assert source_headline in destination_headline.children
+        assert destination_headline.children[-1] == source_headline
+        assert (
+            dumps(doc)
+            == """* Destination Headline
+** Existing Child
+** Source Headline
+*** Source Child"""
+        )
+
+    def test_refile_headline_from_child_to_parent_top(self) -> None:
+        orig = """* Destination Headline
+** Existing Child
+*** Source Headline
+**** Source Child"""
+        doc = loads(orig)
+        source_headline = doc.headlines[0].children[0].children[0]
+        destination_headline = doc.headlines[0]
+
+        result = source_headline.refile(destination_headline, top=True)
+
+        assert result == destination_headline
+        assert source_headline.parent == destination_headline
+        assert source_headline in destination_headline.children
+        assert destination_headline.children[0] == source_headline
+        assert (
+            dumps(doc)
+            == """* Destination Headline
+** Source Headline
+*** Source Child
+** Existing Child"""
+        )
+
+    def test_refile_headline_from_child_to_first_level_at_bottom(self) -> None:
+        orig = """* Destination Headline
+** Existing Child
+*** Source Headline
+**** Source Child"""
+        doc = loads(orig)
+        source_headline = doc.headlines[0].children[0].children[0]
+        destination_headline = doc.headlines[0].parent
+
+        result = source_headline.refile(destination_headline)
+
+        assert result == destination_headline
+        assert source_headline.parent == destination_headline
+        assert source_headline in destination_headline.children
+        assert destination_headline.children[-1] == source_headline
+        assert (
+            dumps(doc)
+            == """* Destination Headline
+** Existing Child
+* Source Headline
+** Source Child"""
+        )
+
+    def test_refile_headline_from_child_to_first_level_at_top(self) -> None:
+        orig = """* Destination Headline
+** Existing Child
+*** Source Headline
+**** Source Child"""
+        doc = loads(orig)
+        source_headline = doc.headlines[0].children[0].children[0]
+        destination_headline = doc.headlines[0].parent
+
+        result = source_headline.refile(destination_headline, top=True)
+
+        assert result == destination_headline
+        assert source_headline.parent == destination_headline
+        assert source_headline in destination_headline.children
+        assert destination_headline.children[0] == source_headline
+        assert (
+            dumps(doc)
+            == """* Source Headline
+** Source Child
+* Destination Headline
+** Existing Child"""
+        )
+
 
 def print_tree(tree, indentation=0, headline=None):
     for element in tree: