Files
Foundry/overlays/todoman-subtasks.patch

871 lines
29 KiB
Diff

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