diff --git a/.gitignore b/.gitignore index 4d443c0a..911a0e5a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,6 @@ docs/_build/ .coverage .hypothesis *.tmp +.mypy_cache/ todoman/version.py diff --git a/AUTHORS.rst b/AUTHORS.rst index 558e93a2..771fde96 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -34,3 +34,4 @@ Authors are listed in alphabetical order. * Swati Garg * Thomas Glanzmann * https://github.com/Pikrass +* https://github.com/powerjungle diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 775a49bb..2eef44b4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,11 @@ v4.7.0 * Removed ``bin/todo``. It is no longer requierd, as the entry point generated by setuptools no longer has performance issues. +* 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.6.0 ------ diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst index 98d0349c..48433b9a 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 diff --git a/tests/test_backend.py b/tests/test_backend.py index 2a09a6b5..decd110f 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_cli.py b/tests/test_cli.py index 75e0cabd..315faf0f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -472,7 +472,8 @@ def test_color_due_dates( due = datetime.datetime.now() + datetime.timedelta(hours=hours) create( "test.ics", - "SUMMARY:aaa\nSTATUS:IN-PROCESS\nDUE;VALUE=DATE-TIME;TZID=ART:{}\n".format( + "SUMMARY:aaa\nSTATUS:IN-PROCESS\nDUE;VALUE=DATE-TIME;TZID=ART:{}\n" + .format( due.strftime("%Y%m%dT%H%M%S") ), ) @@ -482,11 +483,11 @@ def test_color_due_dates( 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" + 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" + f"[ ] 1 \x1b[31m{due_str}\x1b[0m aaa @default\x1b[0m\n" ) assert result.output == expected @@ -497,16 +498,16 @@ 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" + == "[ ] 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" + == "[ ] 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 8676d4a6..fefdf53f 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -108,15 +108,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", @@ -204,7 +206,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/tests/test_model.py b/tests/test_model.py index cdb5d0bc..d029016f 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -250,6 +250,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: @@ -391,6 +397,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() @@ -402,6 +410,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/tests/test_porcelain.py b/tests/test_porcelain.py index 14b97bcf..5d53c997 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/cli.py b/todoman/cli.py index d4ea7224..26b0319b 100644 --- a/todoman/cli.py +++ b/todoman/cli.py @@ -53,7 +53,9 @@ def wrapper(*a, **kw) -> _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( @@ -220,9 +222,10 @@ def _todo_property_options(command: Callable) -> Callable: callback=_validate_priority_param, help="Priority for this task", )(command) - click.option("--location", help="The location where this todo takes place.")( - command - ) + click.option( + "--location", + help="The location where this todo takes place." + )(command) click.option( "--due", "-d", @@ -238,10 +241,19 @@ 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"] = { - key: kw.pop(key) for key in ("due", "start", "location", "priority") + key: kw.pop(key) for key in ( + "due", "start", "location", "priority" + ) } # longform is singular since user can pass it multiple times, but # in actuality it's plural, so manually changing for #cache.todos. @@ -284,6 +296,21 @@ def formatter(self) -> formatters.Formatter: 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() @@ -422,6 +449,7 @@ def repl(ctx: click.Context) -> None: ) @_todo_property_options @_interactive_option +@_subtask_option @pass_ctx @catch_errors def new( @@ -431,6 +459,7 @@ def new( todo_properties: dict, read_description: bool, interactive: bool, + subtask_for: int | None ) -> None: """ Create a new task with SUMMARY. @@ -452,6 +481,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() @@ -486,6 +520,8 @@ def new( ) @_todo_property_options @_interactive_option +@_subtask_option +@_not_subtask_option @with_id_arg @catch_errors def edit( @@ -495,6 +531,8 @@ def edit( interactive: bool, read_description: bool, raw: bool, + subtask_for: int | None, + not_subtask: bool ) -> None: """ Edit the task with id ID. @@ -515,6 +553,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() @@ -527,6 +576,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)) diff --git a/todoman/formatters.py b/todoman/formatters.py index 2424eef3..848c9d95 100644 --- a/todoman/formatters.py +++ b/todoman/formatters.py @@ -20,7 +20,6 @@ from todoman.model import Todo from todoman.model import TodoList - def rgb_to_ansi(colour: str | None) -> str | None: """ Convert a string containing an RGB colour to ANSI escapes @@ -119,52 +118,151 @@ def simple_action(self, action: str, todo: Todo) -> str: def compact(self, todo: Todo) -> str: 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 = [] + def compact_multiple(self, todos: Iterable[Todo], + hide_list: bool = False) -> str: + # 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" - - # 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}" - ) - - return "\n".join(table) + summary = f"{todo.summary} "\ + f"{self.format_database(todo.list)}{percent}" + + tree[todo.uid] = [f"[{completed}] {todo.id} {priority}{due} " + f"{recurring}{summary} {categories}\n", + None] + + self._tree_reorder_related(tree, related_todos) + + 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() @@ -313,6 +411,8 @@ def _todo_as_dict(self, todo: Todo) -> dict: "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: diff --git a/todoman/model.py b/todoman/model.py index b35e958d..71dfecb2 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 @@ def __init__( 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 @@ def clone(self) -> 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 @@ def clone(self) -> Todo: "summary", "uid", "rrule", + "related_to", ) INT_FIELDS = ( "percent_complete", @@ -309,6 +316,7 @@ class VtodoWriter: "created_at": "created", "last_modified": "last-modified", "rrule": "rrule", + "related_to": "related-to", } ) @@ -355,7 +363,14 @@ def serialize(self, original: icalendar.Todo = None) -> icalendar.Todo: 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.") @@ -418,11 +433,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) @@ -533,6 +553,8 @@ def create_tables(self) -> None: "sequence" INTEGER, "last_modified" INTEGER, "rrule" TEXT, + "related_to" TEXT, + "related_to_reltype" TEXT, FOREIGN KEY(file_path) REFERENCES files(path) ON DELETE CASCADE ); @@ -657,6 +679,11 @@ def add_vtodo( :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 ( {} @@ -677,9 +704,11 @@ def add_vtodo( location, sequence, last_modified, - rrule + rrule, + related_to, + related_to_reltype ) VALUES ({}?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?) + ?, ?, ?) """ due, due_dt = self._serialize_datetime(todo, "due") @@ -707,6 +736,8 @@ def add_vtodo( 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: @@ -917,6 +948,8 @@ def _todo_from_db(self, row: dict) -> Todo: 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]: