Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ docs/_build/
.coverage
.hypothesis
*.tmp
.mypy_cache/

todoman/version.py
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ Authors are listed in alphabetical order.
* Swati Garg <[email protected]>
* Thomas Glanzmann <[email protected]>
* https://github.com/Pikrass
* https://github.com/powerjungle
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
------
Expand Down
5 changes: 5 additions & 0 deletions docs/source/contributing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions tests/test_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand All @@ -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")
Expand Down
13 changes: 7 additions & 6 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
),
)
Expand All @@ -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

Expand All @@ -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(
Expand Down
10 changes: 6 additions & 4 deletions tests/test_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
)


Expand Down
10 changes: 10 additions & 0 deletions tests/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()

Expand All @@ -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")
Expand Down
15 changes: 14 additions & 1 deletion tests/test_porcelain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand All @@ -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",
}
Expand Down Expand Up @@ -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",
}
Expand Down Expand Up @@ -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",
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
}
Expand All @@ -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!",
}
Expand Down
60 changes: 55 additions & 5 deletions todoman/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand All @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -422,6 +449,7 @@ def repl(ctx: click.Context) -> None:
)
@_todo_property_options
@_interactive_option
@_subtask_option
@pass_ctx
@catch_errors
def new(
Expand All @@ -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.
Expand All @@ -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()

Expand Down Expand Up @@ -486,6 +520,8 @@ def new(
)
@_todo_property_options
@_interactive_option
@_subtask_option
@_not_subtask_option
@with_id_arg
@catch_errors
def edit(
Expand All @@ -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.
Expand All @@ -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()
Expand All @@ -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))


Expand Down
Loading