From 7817f29220c615a7ad3ee62a1c168b842a8d3128 Mon Sep 17 00:00:00 2001
From: powerjungle <fabsidereal@gmail.com>
Date: Mon, 25 Aug 2025 20:21:24 +0200
Subject: [PATCH 1/9] feat: add support for `RELATED-TO` property and `RELTYPE`
 parameter

Taken from RFC 5545, section `3.8.4.5`. If present in a file,
these values are now read and stored in the cache. Nothing in the UI
has been changed. Tests are modified to reflect the change.

Increment `SCHEMA_VERSION` to allow cache recreation on todoman update.
---
 tests/test_backend.py |  6 ++++++
 tests/test_model.py   | 10 ++++++++++
 todoman/model.py      | 41 +++++++++++++++++++++++++++++++++++++----
 3 files changed, 53 insertions(+), 4 deletions(-)

diff --git a/tests/test_backend.py b/tests/test_backend.py
index bf3f0f1..821bb77 100644
--- a/tests/test_backend.py
+++ b/tests/test_backend.py
@@ -49,6 +49,8 @@ def test_supported_fields_are_serializeable() -> None:
 def test_vtodo_serialization(todo_factory: Callable) -> None:
     """Test VTODO serialization: one field of each type."""
     description = "A tea would be nice, thanks."
+    related_val = "1292818859927632133"
+    related_val_reltype = "PARENT"
     todo = todo_factory(
         categories=["tea", "drinking", "hot"],
         description=description,
@@ -58,6 +60,8 @@ def test_vtodo_serialization(todo_factory: Callable) -> None:
         status="IN-PROCESS",
         summary="Some tea",
         rrule="FREQ=MONTHLY",
+        related_to=related_val,
+        related_to_reltype=related_val_reltype,
     )
     writer = VtodoWriter(todo)
     vtodo = writer.serialize()
@@ -69,6 +73,8 @@ def test_vtodo_serialization(todo_factory: Callable) -> None:
     assert vtodo.decoded("dtstart") == date(3000, 3, 21)
     assert str(vtodo.get("status")) == "IN-PROCESS"
     assert vtodo.get("rrule") == icalendar.vRecur.from_ical("FREQ=MONTHLY")
+    assert vtodo.get("related-to") == related_val
+    assert vtodo.get("related-to").params.get("reltype") == related_val_reltype


 @freeze_time("2017-04-04 20:11:57")
diff --git a/tests/test_model.py b/tests/test_model.py
index a1989f9..d122039 100644
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -260,6 +260,12 @@ def test_todo_setters(todo_factory: Callable) -> None:
     todo.due = None
     assert todo.due is None

+    todo.related_to = "123"
+    assert todo.related_to == "123"
+
+    todo.related_to_reltype = "CHILD"
+    assert todo.related_to_reltype == "CHILD"
+

 @freeze_time("2017-03-19-15")
 def test_is_completed() -> None:
@@ -401,6 +407,8 @@ def test_clone() -> None:
     todo.uid = "123"
     todo.id = 123
     todo.filename = "123.ics"
+    todo.related_to = "12345678"
+    todo.related_to_reltype = "PARENT"

     clone = todo.clone()

@@ -412,6 +420,8 @@ def test_clone() -> None:
     assert clone.id is None
     assert todo.filename != clone.filename
     assert clone.uid in clone.filename
+    assert todo.related_to == clone.related_to
+    assert todo.related_to_reltype == clone.related_to_reltype


 @freeze_time("2017, 3, 20")
diff --git a/todoman/model.py b/todoman/model.py
index 0411eee..bce806d 100644
--- a/todoman/model.py
+++ b/todoman/model.py
@@ -55,6 +55,8 @@ class Todo:
     last_modified: datetime | None
     related: list[Todo]
     rrule: str | None
+    related_to: str
+    related_to_reltype: str
     start: date | None
     id: int | None

@@ -93,6 +95,8 @@ class Todo:
         self.location = ""
         self.percent_complete = 0
         self.priority = 0
+        self.related_to = ""
+        self.related_to_reltype = ""
         self.rrule = ""
         self.sequence = 0
         self.start = None
@@ -126,6 +130,8 @@ class Todo:
         for field in fields:
             setattr(todo, field, getattr(self, field))

+        todo.related_to_reltype = self.related_to_reltype
+
         return todo

     STRING_FIELDS = (
@@ -135,6 +141,7 @@ class Todo:
         "summary",
         "uid",
         "rrule",
+        "related_to",
     )
     INT_FIELDS = (
         "percent_complete",
@@ -314,6 +321,7 @@ class VtodoWriter:
             "created_at": "created",
             "last_modified": "last-modified",
             "rrule": "rrule",
+            "related_to": "related-to",
         }
     )

@@ -360,7 +368,14 @@ class VtodoWriter:
                 elif source in Todo.INT_FIELDS:
                     value = int(raw)
                 elif source in Todo.STRING_FIELDS:
-                    value = raw
+                    if source == "related_to":
+                        set_params = {}
+                        reltype_value = self.todo.related_to_reltype
+                        if reltype_value != "":
+                            set_params = {"reltype": reltype_value}
+                        value = icalendar.prop.vText(raw, params=set_params)
+                    else:
+                        value = raw
                 else:
                     raise Exception(f"Unknown field {source} serialized.")

@@ -427,11 +442,16 @@ class Cache:
     load times, but, more importantly, provides a simpler interface for
     filtering/querying/sorting.

+    The `SCHEMA_VERSION` should be incremented when breaking changes to the
+    database are made. For example adding new fields or removing no longer
+    needed fields and etc. This will ensure that the cache is rebuilt
+    automatically after todoman was updated.
+
     [1]: Relevant fields are those we show when listing todos, or those which
     may be used for filtering/sorting.
     """

-    SCHEMA_VERSION = 10
+    SCHEMA_VERSION = 11

     def __init__(self, path: str) -> None:
         self.cache_path = str(path)
@@ -545,6 +565,8 @@ class Cache:
                 "sequence" INTEGER,
                 "last_modified" INTEGER,
                 "rrule" TEXT,
+                "related_to" TEXT,
+                "related_to_reltype" TEXT,

                 FOREIGN KEY(file_path) REFERENCES files(path) ON DELETE CASCADE
             );
@@ -669,6 +691,11 @@ class Cache:
         :param todo: The icalendar component object on which
         """

+        got_related_to_reltype = ""
+        got_related_to = todo.get("related-to", "")
+        if got_related_to != "":
+            got_related_to_reltype = got_related_to.params.get("reltype")
+
         sql = """
             INSERT INTO todos (
                 {}
@@ -689,9 +716,11 @@ class Cache:
                 location,
                 sequence,
                 last_modified,
-                rrule
+                rrule,
+                related_to,
+                related_to_reltype
             ) VALUES ({}?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
-                ?)
+                ?, ?, ?)
             """

         due, due_dt = self._serialize_datetime(todo, "due")
@@ -719,6 +748,8 @@ class Cache:
             todo.get("sequence", 1),
             self._serialize_datetime(todo, "last-modified")[0],
             self._serialize_rrule(todo, "rrule"),
+            got_related_to,
+            got_related_to_reltype,
         ]

         if id:
@@ -929,6 +960,8 @@ class Cache:
         todo.list = self.lists_map[row["list_name"]]
         todo.filename = os.path.basename(row["path"])
         todo.rrule = row["rrule"]
+        todo.related_to = row["related_to"]
+        todo.related_to_reltype = row["related_to_reltype"]
         return todo

     def lists(self) -> Iterator[TodoList]:
--
2.53.0


From e40e99897f8589ea5699b09378d66494a8981f23 Mon Sep 17 00:00:00 2001
From: powerjungle <fabsidereal@gmail.com>
Date: Mon, 25 Aug 2025 20:38:01 +0200
Subject: [PATCH 2/9] chore(gitignore): add `.mypy_cache/`

---
 .gitignore | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.gitignore b/.gitignore
index 4d443c0..911a0e5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,5 +13,6 @@ docs/_build/
 .coverage
 .hypothesis
 *.tmp
+.mypy_cache/

 todoman/version.py
--
2.53.0


From b57a13ea62a236e93a9ce44487587155f09c30be Mon Sep 17 00:00:00 2001
From: powerjungle <fabsidereal@gmail.com>
Date: Wed, 3 Sep 2025 18:48:56 +0200
Subject: [PATCH 3/9] feat(porcelain): add `related_to` and
 `related_to_reltype`

New names and values added to the JSON output for the `RELATED-TO`
property.
---
 tests/test_porcelain.py | 15 ++++++++++++++-
 todoman/formatters.py   |  2 ++
 2 files changed, 16 insertions(+), 1 deletion(-)

diff --git a/tests/test_porcelain.py b/tests/test_porcelain.py
index e54d8f9..ce7c04f 100644
--- a/tests/test_porcelain.py
+++ b/tests/test_porcelain.py
@@ -22,7 +22,8 @@ def test_list_all(tmpdir: py.path.local, runner: CliRunner, create: Callable) ->
         "DUE;VALUE=DATE-TIME;TZID=CET:20160102T000000\n"
         "DTSTART:20160101T000000Z\n"
         "PERCENT-COMPLETE:26\n"
-        "LOCATION:Wherever\n",
+        "LOCATION:Wherever\n"
+        "RELATED-TO;RELTYPE=PARENT:123456789\n",
     )
     result = runner.invoke(cli, ["--porcelain", "list", "--status", "ANY"])

@@ -40,6 +41,8 @@ def test_list_all(tmpdir: py.path.local, runner: CliRunner, create: Callable) ->
             "percent": 26,
             "priority": 0,
             "recurring": False,
+            "related_to": "123456789",
+            "related_to_reltype": "PARENT",
             "start": 1451606400,
             "summary": "Do stuff",
         }
@@ -78,6 +81,8 @@ def test_list_start_date(
             "percent": 26,
             "priority": 0,
             "recurring": False,
+            "related_to": "",
+            "related_to_reltype": "",
             "start": 1451692800,
             "summary": "Do stuff",
         }
@@ -114,6 +119,8 @@ def test_list_due_date(
             "percent": 26,
             "priority": 0,
             "recurring": False,
+            "related_to": "",
+            "related_to_reltype": "",
             "start": None,
             "summary": "Do stuff",
         }
@@ -143,6 +150,8 @@ def test_list_nodue(tmpdir: py.path.local, runner: CliRunner, create: Callable)
             "location": "",
             "percent": 12,
             "recurring": False,
+            "related_to": "",
+            "related_to_reltype": "",
             "priority": 4,
             "start": None,
             "summary": "Do stuff",
@@ -216,6 +225,8 @@ def test_show(tmpdir: py.path.local, runner: CliRunner, create: Callable) -> Non
         "percent": 0,
         "priority": 5,
         "recurring": False,
+        "related_to": "",
+        "related_to_reltype": "",
         "start": None,
         "summary": "harhar",
     }
@@ -241,6 +252,8 @@ def test_simple_action(todo_factory: Callable) -> None:
         "percent": 0,
         "priority": 0,
         "recurring": False,
+        "related_to": "",
+        "related_to_reltype": "",
         "start": None,
         "summary": "YARR!",
     }
diff --git a/todoman/formatters.py b/todoman/formatters.py
index 2424eef..88f7cee 100644
--- a/todoman/formatters.py
+++ b/todoman/formatters.py
@@ -313,6 +313,8 @@ class PorcelainFormatter(DefaultFormatter):
             "description": todo.description,
             "completed_at": self.format_datetime(todo.completed_at),
             "recurring": todo.is_recurring,
+            "related_to": todo.related_to,
+            "related_to_reltype": todo.related_to_reltype,
         }

     def compact(self, todo: Todo) -> str:
--
2.53.0


From 9b557d41c629e415e9182664c92e071d742990e2 Mon Sep 17 00:00:00 2001
From: powerjungle <fabsidereal@gmail.com>
Date: Sun, 7 Sep 2025 21:06:58 +0200
Subject: [PATCH 4/9] feat(formatters): indentated output for related tasks
 (subtasks)

List text output is now with a tree structure:
task 1
    task 2
    task 3
        task 4
task 5

The table list is changed to a dictionary representing a tree.
No changes to the SQL queries are made.

RELTYPEs PARENT, CHILD and SIBLING are supported.
https://www.rfc-editor.org/rfc/rfc5545#section-3.2.15

Includes output space formatting fixes.

Tests adjusted to changes.
---
 tests/test_cli.py       |  16 ++---
 tests/test_formatter.py |  10 +--
 todoman/formatters.py   | 142 ++++++++++++++++++++++++++++++++++------
 3 files changed, 134 insertions(+), 34 deletions(-)

diff --git a/tests/test_cli.py b/tests/test_cli.py
index b85bec8..7de9b3b 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -484,13 +484,9 @@ def test_color_due_dates(
     assert not result.exception
     due_str = due.strftime("%Y-%m-%d")
     if hours == 72:
-        expected = (
-            f"[ ] 1 \x1b[35m\x1b[0m \x1b[37m{due_str}\x1b[0m aaa @default\x1b[0m\n"
-        )
+        expected = f"[ ] 1 \x1b[37m{due_str}\x1b[0m aaa @default\x1b[0m\n"
     else:
-        expected = (
-            f"[ ] 1 \x1b[35m\x1b[0m \x1b[31m{due_str}\x1b[0m aaa @default\x1b[0m\n"
-        )
+        expected = f"[ ] 1 \x1b[31m{due_str}\x1b[0m aaa @default\x1b[0m\n"
     assert result.output == expected


@@ -499,17 +495,15 @@ def test_color_flag(runner: CliRunner, todo_factory: Callable) -> None:

     result = runner.invoke(cli, ["--color", "always"], color=True)
     assert (
-        result.output.strip()
-        == "[ ] 1 \x1b[35m\x1b[0m \x1b[31m2007-03-22\x1b[0m YARR! @default\x1b[0m"
+        result.output.strip() == "[ ] 1 \x1b[31m2007-03-22\x1b[0m YARR! @default\x1b[0m"
     )
     result = runner.invoke(cli, color=True)
     assert (
-        result.output.strip()
-        == "[ ] 1 \x1b[35m\x1b[0m \x1b[31m2007-03-22\x1b[0m YARR! @default\x1b[0m"
+        result.output.strip() == "[ ] 1 \x1b[31m2007-03-22\x1b[0m YARR! @default\x1b[0m"
     )

     result = runner.invoke(cli, ["--color", "never"], color=True)
-    assert result.output.strip() == "[ ] 1  2007-03-22 YARR! @default"
+    assert result.output.strip() == "[ ] 1 2007-03-22 YARR! @default"


 def test_flush(
diff --git a/tests/test_formatter.py b/tests/test_formatter.py
index 15946fb..d5075c2 100644
--- a/tests/test_formatter.py
+++ b/tests/test_formatter.py
@@ -106,15 +106,17 @@ def test_format_datetime(default_formatter: DefaultFormatter) -> None:
 def test_detailed_format(runner: CliRunner, todo_factory: Callable) -> None:
     todo_factory(
         description=(
-            "Test detailed formatting\nThis includes multiline descriptions\nBlah!"
+            "Test detailed formatting\n"
+            + "This includes multiline descriptions\n"
+            + "Blah!"
         ),
         location="Over the hills, and far away",
     )

-    # TODO:use formatter instead of runner?
+    # TODO: use formatter instead of runner?
     result = runner.invoke(cli, ["show", "1"])
     expected = [
-        "[ ] 1  (no due date) YARR! @default",
+        "[ ] 1 (no due date) YARR! @default",
         "",
         "Description:",
         "Test detailed formatting",
@@ -202,7 +204,7 @@ def test_format_multiple_with_list(
     assert todo.list
     assert (
         default_formatter.compact_multiple([todo])
-        == "[ ] 1 \x1b[35m\x1b[0m \x1b[37m(no due date)\x1b[0m YARR! @default\x1b[0m"
+        == "[ ] 1 \x1b[37m(no due date)\x1b[0m YARR! @default\x1b[0m"
     )


diff --git a/todoman/formatters.py b/todoman/formatters.py
index 88f7cee..f3a4bd0 100644
--- a/todoman/formatters.py
+++ b/todoman/formatters.py
@@ -120,51 +120,155 @@ class DefaultFormatter(Formatter):
         return self.compact_multiple([todo])

     def compact_multiple(self, todos: Iterable[Todo], hide_list: bool = False) -> str:
-        # TODO: format lines fuidly and drop the table
-        # it can end up being more readable when too many columns are empty.
-        # show dates that are in the future in yellow (in 24hs) or grey (future)
-        table = []
+        # TODO: show dates that are in the future in yellow (in 24hs)
+        # or grey (future)
+
+        # Holds information needed to properly order the text output.
+        #
+        # key: UID of todo
+        # value: list which contains 2 elements:
+        #            0: the formatted todo text line for the output
+        #            1: a dictionary which represents child todos which have
+        #               the same structure as the parent
+        tree: dict[str, list] = {}
+
+        # Holds all of the todo relationships in the form of key-value pairs.
+        #
+        # key: child
+        # value: list which contains 2 elements:
+        #            0: parent
+        #            1: any special information like "SIBLING"
+        related_todos: dict[str, list] = {}
+
         for todo in todos:
+            # If RELTYPE is empty, the default is PARENT.
+            # Source:
+            # https://www.rfc-editor.org/rfc/rfc5545#section-3.2.15
+            #
+            # "To preserve backwards compatibility, the value type MUST be
+            # UID when the PARENT, SIBLING, or CHILD relationships
+            # are specified."
+            # Source:
+            # https://www.rfc-editor.org/rfc/rfc9253#section-9.1
+            if todo.related_to != "":
+                if todo.related_to_reltype == "PARENT" or todo.related_to_reltype == "":
+                    related_todos[todo.uid] = [todo.related_to, None]
+                elif todo.related_to_reltype == "CHILD":
+                    related_todos[todo.related_to] = [todo.uid, None]
+                elif todo.related_to_reltype == "SIBLING":
+                    related_todos[todo.uid] = [todo.related_to, "SIBLING"]
+
             completed = "X" if todo.is_completed else " "
+
             percent = todo.percent_complete or ""
             if percent:
                 percent = f" ({percent}%)"

             if todo.categories:
-                categories = " [" + ", ".join(todo.categories) + "]"
+                categories = "[" + ", ".join(todo.categories) + "]"
             else:
                 categories = ""

-            priority = click.style(
-                self.format_priority_compact(todo.priority),
-                fg="magenta",
-            )
+            priority = self.format_priority_compact(todo.priority)
+            if priority != "":
+                priority = click.style(
+                    priority + " ",
+                    fg="magenta",
+                )

             due = self.format_datetime(todo.due) or "(no due date)"
             due_colour = self._due_colour(todo)
             if due_colour:
                 due = click.style(str(due), fg=due_colour)

-            recurring = "⟳" if todo.is_recurring else ""
+            recurring = "⟳ " if todo.is_recurring else ""

             if hide_list:
-                summary = f"{todo.summary} {percent}"
+                summary = f"{todo.summary}{percent}"
             else:
                 if not todo.list:
                     raise ValueError("Cannot format todo without a list")

                 summary = f"{todo.summary} {self.format_database(todo.list)}{percent}"

-            # TODO: add spaces on the left based on max todos"
+            tree[todo.uid] = [
+                f"[{completed}] {todo.id} {priority}{due} "
+                f"{recurring}{summary} {categories}\n",
+                None,
+            ]

-            # FIXME: double space when no priority
-            # split into parts to satisfy linter line too long
-            table.append(
-                f"[{completed}] {todo.id} {priority} {due} "
-                f"{recurring}{summary}{categories}"
-            )
+        self._tree_reorder_related(tree, related_todos)

-        return "\n".join(table)
+        return self._join_tree(tree).strip()
+
+    def _tree_reorder_related(
+        self, tree: dict[str, list], related_todos: dict[str, list]
+    ) -> None:
+        """Move all related todos to their proper positions within the tree
+        dictionary."""
+        store_path: list = []
+        for related, related_to in related_todos.items():
+            # Find the root parent todo of the `related_to` todo.
+            related_to_path_tracer: str = related_to[0]
+            related_dict: dict[str, list] = tree
+            while related_to_path_tracer in related_todos:
+                related_to_path_tracer = related_todos[related_to_path_tracer][0]
+                store_path.append(related_to_path_tracer)
+
+            # Walk from the parent root todo, down to the
+            # `related_to` todo itself.
+            store_path.reverse()
+            # If `related_to` is a SIBLING, walk one todo backwards towards the
+            # root parent todo. If there is no path to be walked,
+            # just ignore it.
+            with contextlib.suppress(IndexError):
+                if related_to[1] == "SIBLING":
+                    related_to[0] = store_path.pop()
+            for inwards in store_path:
+                try:
+                    related_dict = related_dict[inwards][1]
+                    if related_dict is None:
+                        break
+                except KeyError:
+                    related_dict = tree
+                    break
+
+            self._tree_move_related(related, related_to[0], tree, related_dict)
+
+            store_path.clear()
+
+    def _tree_move_related(
+        self,
+        related: str,
+        to: str,
+        tree: dict[str, list],
+        related_dict: dict[str, list] | None,
+    ) -> None:
+        """Move a todo from the top of the tree dictionary, to the child
+        dictionary of a todo."""
+        if related_dict is None:
+            related_dict = tree
+
+        if related not in tree or to not in related_dict:
+            return
+
+        if related_dict[to][1] is None:
+            related_dict[to][1] = {related: tree.pop(related)}
+        else:
+            related_dict[to][1][related] = tree.pop(related)
+
+    def _join_tree(self, tree: dict[str, list], space: str = "") -> str:
+        """Recursively walk the whole tree dictionary and combine all todos as
+        an indented text output."""
+        output: str = ""
+        prev_space: str = space
+        for _, val in tree.items():
+            output = output + space + val[0]
+            if val[1] is not None:
+                space = space + "    "
+                output = output + self._join_tree(val[1], space)
+            space = prev_space
+        return output

     def _due_colour(self, todo: Todo) -> str:
         now = self.now if isinstance(todo.due, datetime) else self.now.date()
--
2.53.0


From 5707b8ae25fa47c3322cb500c776c1d35087cf2b Mon Sep 17 00:00:00 2001
From: powerjungle <fabsidereal@gmail.com>
Date: Mon, 8 Sep 2025 16:53:32 +0200
Subject: [PATCH 5/9] refactor(cli): description of `_todo_property_options`
 wrapper

Comment for describing the weird code for merging arguments.
---
 todoman/cli.py | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/todoman/cli.py b/todoman/cli.py
index 5d7a44f..9488405 100644
--- a/todoman/cli.py
+++ b/todoman/cli.py
@@ -238,6 +238,13 @@ def _todo_property_options(command: Callable) -> Callable:
         help="When the task starts.",
     )(command)

+    # Merges all different property arguments into one dictionary
+    # `todo_properties` argument, so that it can be directly looped through
+    # easily for directly setting the `todoman.model.Todo` class attributes
+    # from within a command function.
+    #
+    # The names of the options are the same as the
+    # `todoman.model.Todo` class attributes.
     @functools.wraps(command)
     def command_wrap(*a, **kw) -> click.Command:
         kw["todo_properties"] = {
--
2.53.0


From 64f4b6bacddeb4b144bf8018034a0c0e3a119753 Mon Sep 17 00:00:00 2001
From: powerjungle <fabsidereal@gmail.com>
Date: Mon, 8 Sep 2025 18:11:52 +0200
Subject: [PATCH 6/9] feat(cli): support for editing and adding subtasks

Using the newly supported attributes, two new options are added.
`--subtask-for`, making a task be a subtask for another and
`--not-subtask`, making a task no longer be a subtask.
The commands `new` and `edit` are fitted with these new options.
---
 todoman/cli.py | 42 +++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 41 insertions(+), 1 deletion(-)

diff --git a/todoman/cli.py b/todoman/cli.py
index 9488405..0fd59df 100644
--- a/todoman/cli.py
+++ b/todoman/cli.py
@@ -54,7 +54,9 @@ def catch_errors(f: Callable[_P, _T]) -> Callable[_P, _T]:


 TODO_ID_MIN = 1
-with_id_arg = click.argument("id", type=click.IntRange(min=TODO_ID_MIN))
+CLICK_TYPE_ID = click.IntRange(min=TODO_ID_MIN)
+
+with_id_arg = click.argument("id", type=CLICK_TYPE_ID)


 def _validate_lists_param(
@@ -291,6 +293,21 @@ _interactive_option = click.option(
     help="Go into interactive mode before saving the task.",
 )

+_subtask_option = click.option(
+    "--subtask-for",
+    is_flag=False,
+    default=None,
+    type=CLICK_TYPE_ID,
+    help="Set task to be a subtask for the given id.",
+)
+
+_not_subtask_option = click.option(
+    "--not-subtask",
+    is_flag=True,
+    default=False,
+    help="Make task no longer be a subtask.",
+)
+

 @click.group(invoke_without_command=True)
 @click_log.simple_verbosity_option()
@@ -430,6 +447,7 @@ def repl(ctx: click.Context) -> None:
 )
 @_todo_property_options
 @_interactive_option
+@_subtask_option
 @pass_ctx
 @catch_errors
 def new(
@@ -439,6 +457,7 @@ def new(
     todo_properties: dict,
     read_description: bool,
     interactive: bool,
+    subtask_for: int | None,
 ) -> None:
     """
     Create a new task with SUMMARY.
@@ -460,6 +479,11 @@ def new(
             setattr(todo, key, value)
     todo.summary = " ".join(summary)

+    if subtask_for is not None:
+        parent_todo = ctx.db.todo(subtask_for)
+        todo.related_to = parent_todo.uid
+        todo.related_to_reltype = "PARENT"
+
     if read_description:
         todo.description = sys.stdin.read()

@@ -494,6 +518,8 @@ def new(
 )
 @_todo_property_options
 @_interactive_option
+@_subtask_option
+@_not_subtask_option
 @with_id_arg
 @catch_errors
 def edit(
@@ -503,6 +529,8 @@ def edit(
     interactive: bool,
     read_description: bool,
     raw: bool,
+    subtask_for: int | None,
+    not_subtask: bool,
 ) -> None:
     """
     Edit the task with id ID.
@@ -523,6 +551,17 @@ def edit(
         changes = True
         todo.description = sys.stdin.read()

+    if subtask_for is not None and not_subtask is False:
+        changes = True
+        parent_todo = ctx.db.todo(subtask_for)
+        todo.related_to = parent_todo.uid
+        todo.related_to_reltype = "PARENT"
+
+    if not_subtask:
+        changes = True
+        todo.related_to = ""
+        todo.related_to_reltype = ""
+
     if interactive or (not changes and interactive is None):
         ui = TodoEditor(todo, ctx.db.lists(), ctx.ui_formatter)
         ui.edit()
@@ -535,6 +574,7 @@ def edit(
     ctx.db.save(todo)
     if old_list != new_list:
         ctx.db.move(todo, new_list=new_list, from_list=old_list)
+
     click.echo(ctx.formatter.detailed(todo))


--
2.53.0


From 977ea6e8934ada8d9407b037c9ffd3ba51dfe56c Mon Sep 17 00:00:00 2001
From: powerjungle <fabsidereal@gmail.com>
Date: Mon, 8 Sep 2025 18:44:37 +0200
Subject: [PATCH 7/9] chore(CHANGELOG): describe new subtasks feature

---
 CHANGELOG.rst | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index a990ba2..4f816f5 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -4,6 +4,14 @@ Changelog
 This file contains a brief summary of new features and dependency changes or
 releases, in reverse chronological order.

+v4.8.0
+------
+* Added support for RELATED-TO property and RELTYPE parameter, allowing the
+  parsing and creation of subtasks. This requires adding two new columns to
+  the database. The `SCHEMA_VERSION` has been incremented, so the cache will
+  be recreated. No changes to the configuration or lists is needed. The tasks
+  are now printed as a tree when subtasks are present.
+
 v4.7.0
 ------

--
2.53.0


From 4cd86ac883e7995f1c57d6024e07df09441a6450 Mon Sep 17 00:00:00 2001
From: powerjungle <fabsidereal@gmail.com>
Date: Mon, 8 Sep 2025 18:48:07 +0200
Subject: [PATCH 8/9] chore(AUTHORS): add powerjungle

---
 AUTHORS.rst | 1 +
 1 file changed, 1 insertion(+)

diff --git a/AUTHORS.rst b/AUTHORS.rst
index 558e93a..771fde9 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -34,3 +34,4 @@ Authors are listed in alphabetical order.
 * Swati Garg <swati4star@gmail.com>
 * Thomas Glanzmann <thomas@glanzmann.de>
 * https://github.com/Pikrass
+* https://github.com/powerjungle
--
2.53.0


From 4e7d91df489e26b65b54ee5c08ca8d7f932a052c Mon Sep 17 00:00:00 2001
From: powerjungle <fabsidereal@gmail.com>
Date: Wed, 1 Oct 2025 16:49:40 +0200
Subject: [PATCH 9/9] docs(Hacking): new description of `SCHEMA_VERSION`
 variable

---
 docs/source/contributing.rst | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst
index 98d0349..48433b9 100644
--- a/docs/source/contributing.rst
+++ b/docs/source/contributing.rst
@@ -23,6 +23,11 @@ without them being properly documented.
 Run ``pip install -e .`` to install todoman and its dependencies into a
 virtualenv.

+If the database is changed in a breaking way, the ``SCHEMA_VERSION`` variable
+in the class ``Cache`` has to be incremented to allow the cache to be recreated
+after todoman has been updated. An example would be adding new fields or
+removing old unnecessary fields and etc.
+
 We use ``pre-commit`` to run style and convention checks. Run ``pre-commit
 install``` to install our git-hooks. These will check code style and inform you
 of any issues when attempting to commit. This will also run ``black`` to
--
2.53.0
