diff --git a/AUTHORS.rst b/AUTHORS.rst index 0ba3ce5f..a7be8552 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -15,6 +15,7 @@ Authors are listed in alphabetical order. * Ben Moran * Benjamin Frank * Christian Geier +* Christof Schulze * Doron Behar * Guilhem Saurel * Hugo Osvaldo Barrera @@ -28,6 +29,7 @@ Authors are listed in alphabetical order. * Rimsha Khan * Sakshi Saraswat * Stephan Weller +* Styx Meiseles * Swati Garg * Thomas Glanzmann * https://github.com/Pikrass diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5f89e248..6aa05045 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,7 @@ v4.1.0 * The "table" layout has been dropped in favour of a simpler, fluid layout. As such, ``tabulate`` is not longer a required dependency. * Added support for python 3.10. +* Added full support for categories. v4.0.1 ------ diff --git a/tests/test_cli.py b/tests/test_cli.py index 5f2a6362..63ba89d8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,6 +4,7 @@ from unittest import mock from unittest.mock import call from unittest.mock import patch +from uuid import uuid4 import click import hypothesis.strategies as st @@ -27,7 +28,7 @@ def test_list(tmpdir, runner, create): assert not result.exception assert not result.output.strip() - create("test.ics", "SUMMARY:harhar\n") + create("test.ics", f"UID:{uuid4()}\nSUMMARY:harhar\n") result = runner.invoke(cli, ["list"]) assert not result.exception assert "harhar" in result.output @@ -57,14 +58,14 @@ def test_no_extra_whitespace(tmpdir, runner, create): assert not result.exception assert result.output == "\n" - create("test.ics", "SUMMARY:harhar\n") + create("test.ics", f"UID:{uuid4()}\nSUMMARY:harhar\n") result = runner.invoke(cli, ["list"]) assert not result.exception assert len(result.output.splitlines()) == 1 def test_percent(tmpdir, runner, create): - create("test.ics", "SUMMARY:harhar\nPERCENT-COMPLETE:78\n") + create("test.ics", f"UID:{uuid4()}\nSUMMARY:harhar\nPERCENT-COMPLETE:78\n") result = runner.invoke(cli, ["list"]) assert not result.exception assert "78%" in result.output @@ -122,7 +123,9 @@ def test_list_inexistant(tmpdir, runner, create): def test_show_existing(tmpdir, runner, create): - create("test.ics", "SUMMARY:harhar\nDESCRIPTION:Lots of text. Yum!\n") + create( + "test.ics", f"UID:{uuid4()}\nSUMMARY:harhar\nDESCRIPTION:Lots of text. Yum!\n" + ) result = runner.invoke(cli, ["list"]) result = runner.invoke(cli, ["show", "1"]) assert not result.exception @@ -131,7 +134,7 @@ def test_show_existing(tmpdir, runner, create): def test_show_inexistant(tmpdir, runner, create): - create("test.ics", "SUMMARY:harhar\n") + create("test.ics", f"UID:{uuid4()}\nSUMMARY:harhar\n") result = runner.invoke(cli, ["list"]) result = runner.invoke(cli, ["show", "2"]) assert result.exit_code == 20 @@ -170,14 +173,14 @@ def test_two_events(tmpdir, runner): def test_default_command(tmpdir, runner, create): - create("test.ics", "SUMMARY:harhar\n") + create("test.ics", f"UID:{uuid4()}\nSUMMARY:harhar\n") result = runner.invoke(cli) assert not result.exception assert "harhar" in result.output def test_delete(runner, create): - create("test.ics", "SUMMARY:harhar\n") + create("test.ics", f"UID:{uuid4()}\nSUMMARY:harhar\n") result = runner.invoke(cli, ["list"]) assert not result.exception result = runner.invoke(cli, ["delete", "1", "--yes"]) @@ -200,7 +203,7 @@ def test_delete_prompt(todo_factory, runner, todos): def test_copy(tmpdir, runner, create): tmpdir.mkdir("other_list") - create("test.ics", "SUMMARY:test_copy\n") + create("test.ics", f"UID:{uuid4()}\nSUMMARY:test_copy\n") result = runner.invoke(cli, ["list"]) assert not result.exception assert "test_copy" in result.output @@ -217,7 +220,7 @@ def test_copy(tmpdir, runner, create): def test_move(tmpdir, runner, create): tmpdir.mkdir("other_list") - create("test.ics", "SUMMARY:test_move\n") + create("test.ics", f"UID:{uuid4()}\nSUMMARY:test_move\n") result = runner.invoke(cli, ["list"]) assert not result.exception assert "test_move" in result.output @@ -344,8 +347,16 @@ def run_test(sort_key): def test_sorting_output(tmpdir, runner, create): - create("test.ics", "SUMMARY:aaa\nDUE;VALUE=DATE-TIME;TZID=ART:20160102T000000\n") - create("test2.ics", "SUMMARY:bbb\nDUE;VALUE=DATE-TIME;TZID=ART:20160101T000000\n") + create( + "test.ics", + f"UID:{uuid4()}\nSUMMARY:aaa\n" + "DUE;VALUE=DATE-TIME;TZID=ART:20160102T000000\n", + ) + create( + "test2.ics", + f"UID:{uuid4()}\nSUMMARY:bbb\n" + "DUE;VALUE=DATE-TIME;TZID=ART:20160101T000000\n", + ) examples = [("-summary", ["aaa", "bbb"]), ("due", ["aaa", "bbb"])] @@ -371,8 +382,12 @@ def test_sorting_output(tmpdir, runner, create): def test_sorting_null_values(tmpdir, runner, create): - create("test.ics", "SUMMARY:aaa\nPRIORITY:9\n") - create("test2.ics", "SUMMARY:bbb\nDUE;VALUE=DATE-TIME;TZID=ART:20160101T000000\n") + create("test.ics", f"UID:{uuid4()}\nSUMMARY:aaa\nPRIORITY:9\n") + create( + "test2.ics", + f"UID:{uuid4()}\nSUMMARY:bbb\n" + "DUE;VALUE=DATE-TIME;TZID=ART:20160101T000000\n", + ) result = runner.invoke(cli) assert not result.exception @@ -528,7 +543,7 @@ def test_empty_list(tmpdir, runner, create): def test_show_location(tmpdir, runner, create): - create("test.ics", "SUMMARY:harhar\nLOCATION:Boston\n") + create("test.ics", f"UID:{uuid4()}\nSUMMARY:harhar\nLOCATION:Boston\n") result = runner.invoke(cli, ["show", "1"]) assert "Boston" in result.output @@ -551,11 +566,13 @@ def test_sort_mixed_timezones(runner, create): """ create( "test.ics", - "SUMMARY:first\nDUE;VALUE=DATE-TIME;TZID=CET:20170304T180000\n", # 1700 UTC + f"UID:{uuid4()}\nSUMMARY:first\nDUE;" + "VALUE=DATE-TIME;TZID=CET:20170304T180000\n", # 1700 UTC ) create( "test2.ics", - "SUMMARY:second\nDUE;VALUE=DATE-TIME;TZID=HST:20170304T080000\n", # 1800 UTC + f"UID:{uuid4()}\nSUMMARY:second\nDUE;" + "VALUE=DATE-TIME;TZID=HST:20170304T080000\n", # 1800 UTC ) result = runner.invoke(cli, ["list", "--status", "ANY"]) @@ -587,7 +604,9 @@ def test_due_bad_date(runner): def test_multiple_todos_in_file(runner, create): - path = create("test.ics", "SUMMARY:a\nEND:VTODO\nBEGIN:VTODO\nSUMMARY:b\n") + path = create( + "test.ics", f"UID:{uuid4()}\nSUMMARY:a\nEND:VTODO\nBEGIN:VTODO\nSUMMARY:b\n" + ) for _ in range(2): with patch("todoman.model.logger", spec=True) as mocked_logger: @@ -845,6 +864,40 @@ def test_invoke_invalid_command(runner, tmpdir, config): assert "Error: Invalid setting for [default_command]" in result.output +def test_new_categories_single(runner): + result = runner.invoke(cli, ["new", "-l", "default", "--category", "mine", "title"]) + + assert "[mine]" in result.output + + +def test_new_categories_multiple(runner): + result = runner.invoke( + cli, ["new", "-l", "default", "-c", "first", "-c", "second", "title"] + ) + + assert "[first, second]" in result.output + + +def test_list_categories_single(tmpdir, runner, create): + category = "fizzbuzz" + create("test.ics", f"UID:{uuid4()}\nSUMMARY:harhar\nCATEGORIES:{category}\n") + result = runner.invoke(cli, ["list", "--category", category]) + assert not result.exception + assert category in result.output + + +def test_list_categories_multiple(tmpdir, runner, create): + category = ["git", "gud"] + create("test.ics", f"SUMMARY:harhar\nCATEGORIES:{category[0]}\n") + create("test1.ics", f"SUMMARY:harhar1\nCATEGORIES:{category[1]}\n") + result = runner.invoke( + cli, ["list", "--category", category[0], "--category", category[1]] + ) + assert not result.exception + assert category[0] in result.output + assert category[1] in result.output + + def test_show_priority(runner, todo_factory, todos): todo_factory(summary="harhar\n", priority=1) @@ -964,7 +1017,7 @@ def test_default_command_args(config, runner): reverse=False, lists=[], location=None, - category=None, + categories=(), grep=None, start=None, startable=None, diff --git a/tests/test_filtering.py b/tests/test_filtering.py index 917a6171..fde7e9bf 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -1,5 +1,6 @@ from datetime import datetime from datetime import timedelta +from uuid import uuid4 from todoman.cli import cli from todoman.model import Database @@ -11,10 +12,10 @@ def test_priority(tmpdir, runner, create): assert not result.exception assert not result.output.strip() - create("one.ics", "SUMMARY:haha\nPRIORITY:4\n") - create("two.ics", "SUMMARY:hoho\nPRIORITY:9\n") - create("three.ics", "SUMMARY:hehe\nPRIORITY:5\n") - create("four.ics", "SUMMARY:huhu\n") + create("one.ics", f"UID:{uuid4()}\nSUMMARY:haha\nPRIORITY:4\n") + create("two.ics", f"UID:{uuid4()}\nSUMMARY:hoho\nPRIORITY:9\n") + create("three.ics", f"UID:{uuid4()}\nSUMMARY:hehe\nPRIORITY:5\n") + create("four.ics", f"UID:{uuid4()}\nSUMMARY:huhu\n") result_high = runner.invoke(cli, ["list", "--priority=high"]) assert not result_high.exception @@ -53,9 +54,9 @@ def test_location(tmpdir, runner, create): assert not result.exception assert not result.output.strip() - create("one.ics", "SUMMARY:haha\nLOCATION: The Pool\n") - create("two.ics", "SUMMARY:hoho\nLOCATION: The Dungeon\n") - create("two.ics", "SUMMARY:harhar\n") + create("one.ics", f"UID:{uuid4()}\nSUMMARY:haha\nLOCATION: The Pool\n") + create("two.ics", f"UID:{uuid4()}\nSUMMARY:hoho\nLOCATION: The Dungeon\n") + create("two.ics", f"UID:{uuid4()}\nSUMMARY:harhar\n") result = runner.invoke(cli, ["list", "--location", "Pool"]) assert not result.exception assert "haha" in result.output @@ -68,9 +69,9 @@ def test_category(tmpdir, runner, create): assert not result.exception assert not result.output.strip() - create("one.ics", "SUMMARY:haha\nCATEGORIES:work,trip\n") + create("one.ics", f"UID:{uuid4()}\nSUMMARY:haha\nCATEGORIES:work,trip\n") create("two.ics", "CATEGORIES:trip\nSUMMARY:hoho\n") - create("three.ics", "SUMMARY:harhar\n") + create("three.ics", f"UID:{uuid4()}\nSUMMARY:harhar\n") result = runner.invoke(cli, ["list", "--category", "work"]) assert not result.exception assert "haha" in result.output @@ -103,7 +104,7 @@ def test_grep(tmpdir, runner, create): "five.ics", "SUMMARY:research\nDESCRIPTION: Cure cancer\n", ) - create("six.ics", "SUMMARY:hoho\n") + create("six.ics", f"UID:{uuid4()}\nSUMMARY:hoho\n") result = runner.invoke(cli, ["list", "--grep", "fun"]) assert not result.exception assert "fun" in result.output @@ -180,7 +181,8 @@ def test_due_naive(tmpdir, runner, create): due = now + timedelta(hours=i) create( f"test_{i}.ics", - "SUMMARY:{}\nDUE;VALUE=DATE-TIME:{}\n".format( + "UID:{}\nSUMMARY:{}\nDUE;VALUE=DATE-TIME:{}\n".format( + uuid4(), i, due.strftime("%Y%m%dT%H%M%S"), ), diff --git a/tests/test_model.py b/tests/test_model.py index 922d0bbe..028c0d32 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -2,6 +2,7 @@ from datetime import datetime from datetime import timedelta from unittest.mock import patch +from uuid import uuid4 import pytest import pytz @@ -21,7 +22,9 @@ def test_querying(create, tmpdir): for i, location in enumerate("abc"): create( f"test{i}.ics", - ("SUMMARY:test_querying\r\nLOCATION:{}\r\n").format(location), + ("UID:{}\nSUMMARY:test_querying\r\nLOCATION:{}\r\n").format( + uuid4(), location + ), list_name=list, ) @@ -35,8 +38,16 @@ def test_querying(create, tmpdir): def test_retain_tz(tmpdir, create, todos): - create("ar.ics", "SUMMARY:blah.ar\nDUE;VALUE=DATE-TIME;TZID=HST:20160102T000000\n") - create("de.ics", "SUMMARY:blah.de\nDUE;VALUE=DATE-TIME;TZID=CET:20160102T000000\n") + create( + "ar.ics", + f"UID:{uuid4()}\nSUMMARY:blah.ar\n" + "DUE;VALUE=DATE-TIME;TZID=HST:20160102T000000\n", + ) + create( + "de.ics", + f"UID:{uuid4()}\nSUMMARY:blah.de\n" + "DUE;VALUE=DATE-TIME;TZID=CET:20160102T000000\n", + ) todos = list(todos()) @@ -57,7 +68,7 @@ def test_due_date(tmpdir, create, todos): def test_change_paths(tmpdir, create): old_todos = set("abcdefghijk") for x in old_todos: - create(f"{x}.ics", f"SUMMARY:{x}\n", x) + create(f"{x}.ics", f"UID:{uuid4()}\nSUMMARY:{x}\n", x) tmpdir.mkdir("3") @@ -127,8 +138,8 @@ def test_list_no_colour(tmpdir): def test_database_priority_sorting(create, todos): for i in [1, 5, 9, 0]: - create(f"test{i}.ics", f"PRIORITY:{i}\n") - create("test_none.ics", "SUMMARY:No priority (eg: None)\n") + create(f"test{i}.ics", f"UID:{uuid4()}\nPRIORITY:{i}\n") + create("test_none.ics", f"UID:{uuid4()}\nSUMMARY:No priority (eg: None)\n") todos = list(todos()) @@ -161,6 +172,43 @@ def test_retain_unknown_fields(tmpdir, create, default_database): assert "X-RAWR-TYPE:Reptar" in lines +def test_category_integrity(tmpdir, create, default_database): + create("test.ics", "UID:AVERYUNIQUEID\nSUMMARY:RAWR\n") + db = Database([tmpdir.join("default")], tmpdir.join("cache.sqlite")) + + todo = db.todo(1, read_only=False) + todo.categories = ["hi", "hi"] + + with pytest.raises(AlreadyExists): + default_database.save(todo) + + +def test_category_deletes_on_todo_delete(tmpdir, create, default_database): + uid = "my_id" + create("test.ics", f"UID:{uid}\nSUMMARY:RAWR\n") + db = Database([tmpdir.join("default")], tmpdir.join("cache.sqlite")) + + todo = db.todo(1, read_only=False) + todo.categories = ["my_cat"] + default_database.save(todo) + + assert default_database.todos().__next__().uid == uid + + default_database.delete(todo) + default_database.update_cache() + + query = """ + SELECT distinct category + FROM categories + WHERE categories.todos_id = '{}' + """.format( + todo.id, + ) + + categories = default_database.cache._conn.execute(query).fetchall() + assert categories == [] + + def test_todo_setters(todo_factory): todo = todo_factory() diff --git a/tests/test_porcelain.py b/tests/test_porcelain.py index ede7b80b..3df41c7b 100644 --- a/tests/test_porcelain.py +++ b/tests/test_porcelain.py @@ -1,5 +1,6 @@ import json from datetime import datetime +from uuid import uuid4 import pytz @@ -21,6 +22,7 @@ def test_list_all(tmpdir, runner, create): expected = [ { + "categories": [], "completed": True, "completed_at": 1545765154, "description": "", @@ -51,6 +53,7 @@ def test_list_due_date(tmpdir, runner, create): expected = [ { + "categories": [], "completed": True, "completed_at": None, "description": "", @@ -69,11 +72,15 @@ def test_list_due_date(tmpdir, runner, create): def test_list_nodue(tmpdir, runner, create): - create("test.ics", "SUMMARY:Do stuff\nPERCENT-COMPLETE:12\nPRIORITY:4\n") + create( + "test.ics", + f"UID:{uuid4()}\nSUMMARY:Do stuff\nPERCENT-COMPLETE:12\nPRIORITY:4\n", + ) result = runner.invoke(cli, ["--porcelain", "list"]) expected = [ { + "categories": [], "completed": False, "completed_at": None, "description": "", @@ -95,10 +102,10 @@ def test_list_priority(tmpdir, runner, create): result = runner.invoke(cli, ["--porcelain", "list"], catch_exceptions=False) assert not result.exception assert result.output.strip() == "[]" - create("one.ics", "SUMMARY:haha\nPRIORITY:4\n") - create("two.ics", "SUMMARY:hoho\nPRIORITY:9\n") - create("three.ics", "SUMMARY:hehe\nPRIORITY:5\n") - create("four.ics", "SUMMARY:huhu\n") + create("one.ics", f"UID:{uuid4()}\nSUMMARY:haha\nPRIORITY:4\n") + create("two.ics", f"UID:{uuid4()}\nSUMMARY:hoho\nPRIORITY:9\n") + create("three.ics", f"UID:{uuid4()}\nSUMMARY:hehe\nPRIORITY:5\n") + create("four.ics", f"UID:{uuid4()}\nSUMMARY:huhu\n") result_high = runner.invoke(cli, ["--porcelain", "list", "--priority=4"]) assert not result_high.exception @@ -133,10 +140,15 @@ def test_list_priority(tmpdir, runner, create): def test_show(tmpdir, runner, create): - create("test.ics", "SUMMARY:harhar\nDESCRIPTION:Lots of text. Yum!\nPRIORITY:5\n") + create( + "test.ics", + f"UID:{uuid4()}\nSUMMARY:harhar\n" + "DESCRIPTION:Lots of text. Yum!\nPRIORITY:5\n", + ) result = runner.invoke(cli, ["--porcelain", "show", "1"]) expected = { + "categories": [], "completed": False, "completed_at": None, "description": "Lots of text. Yum!", @@ -158,6 +170,7 @@ def test_simple_action(todo_factory): todo = todo_factory(id=7, location="Downtown") expected = { + "categories": [], "completed": False, "completed_at": None, "description": "", diff --git a/todoman/cli.py b/todoman/cli.py index b3bc373e..baaba977 100644 --- a/todoman/cli.py +++ b/todoman/cli.py @@ -81,6 +81,11 @@ def _validate_date_param(ctx, param, val): raise click.BadParameter(e) +def _validate_categories_param(ctx, param, val): + ctx = ctx.find_object(AppContext) + return ctx.formatter.parse_categories(val) + + def _validate_priority_param(ctx, param, val): ctx = ctx.find_object(AppContext) try: @@ -147,6 +152,14 @@ def validate_status(ctx=None, param=None, val=None) -> str: def _todo_property_options(command): + click.option( + "--category", + "-c", + multiple=True, + default=(), + callback=_validate_categories_param, + help="Task categories. Can be used multiple times.", + )(command) click.option( "--priority", default="", @@ -176,6 +189,10 @@ def command_wrap(*a, **kw): kw["todo_properties"] = { 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. + kw["todo_properties"]["categories"] = kw.pop("category") + return command(*a, **kw) return command_wrap @@ -359,7 +376,7 @@ def new(ctx, summary, list, todo_properties, read_description, interactive): todo.priority = default_priority for key, value in todo_properties.items(): - if value: + if value is not None: setattr(todo, key, value) todo.summary = " ".join(summary) @@ -404,7 +421,7 @@ def edit(ctx, id, todo_properties, interactive, raw): changes = False for key, value in todo_properties.items(): - if value is not None: + if value is not None and value != []: changes = True setattr(todo, key, value) @@ -548,7 +565,6 @@ def move(ctx, list, ids): @pass_ctx @click.argument("lists", nargs=-1, callback=_validate_lists_param) @click.option("--location", help="Only show tasks with location containg TEXT") -@click.option("--category", help="Only show tasks with category containg TEXT") @click.option("--grep", help="Only show tasks with message containg TEXT") @click.option( "--sort", @@ -568,6 +584,14 @@ def move(ctx, list, ids): @click.option( "--due", default=None, help="Only show tasks due in INTEGER hours", type=int ) +@click.option( + "--category", + "-c", + multiple=True, + default=(), + help="Only show tasks with specified categories.", + callback=_validate_categories_param, +) @click.option( "--priority", default=None, @@ -629,5 +653,7 @@ def list(ctx, *args, **kwargs): len(kwargs["lists"]) == 1 ) + kwargs["categories"] = kwargs.pop("category") + todos = ctx.db.todos(**kwargs) click.echo(ctx.formatter.compact_multiple(todos, hide_list)) diff --git a/todoman/formatters.py b/todoman/formatters.py index d19ced16..c4ede123 100644 --- a/todoman/formatters.py +++ b/todoman/formatters.py @@ -70,6 +70,12 @@ def compact_multiple(self, todos: Iterable[Todo], hide_list=False) -> str: percent = todo.percent_complete or "" if percent: percent = f" ({percent}%)" + + if todo.categories: + categories = " [" + ", ".join(todo.categories) + "]" + else: + categories = "" + priority = click.style( self.format_priority_compact(todo.priority), fg="magenta", @@ -112,8 +118,10 @@ def compact_multiple(self, todos: Iterable[Todo], hide_list=False) -> str: # 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} {recurring}{summary}" + f"[{completed}] {todo.id} {priority} {due} " + f"{recurring}{summary}{categories}" ) return "\n".join(table) @@ -148,6 +156,14 @@ def format_datetime(self, dt: Optional[date]) -> Union[str, int, None]: elif isinstance(dt, date): return dt.strftime(self.date_format) + def format_categories(self, categories): + return ", ".join(categories) + + def parse_categories(self, categories): + # existing code assumes categories is list, + # but click passes tuple + return list(categories) + def parse_priority(self, priority: Optional[str]) -> Optional[int]: if priority is None or priority == "": return None @@ -243,6 +259,7 @@ def _todo_as_dict(self, todo): "list": todo.list.name, "percent": todo.percent_complete, "summary": todo.summary, + "categories": todo.categories, "priority": todo.priority, "location": todo.location, "description": todo.description, diff --git a/todoman/interactive.py b/todoman/interactive.py index 20209d61..b6dc1d6a 100644 --- a/todoman/interactive.py +++ b/todoman/interactive.py @@ -34,6 +34,7 @@ def __init__(self, todo, lists, formatter): ("Summary", self._summary), ("Description", self._description), ("Location", self._location), + ("Categories", self._categories), ("Start", self._dtstart), ("Due", self._due), ("Completed", self._completed), @@ -65,6 +66,10 @@ def _init_basic_fields(self): edit_text=self.todo.description, multiline=True, ) + self._categories = widgets.ExtendedEdit( + parent=self, + edit_text=self.formatter.format_categories(self.todo.categories), + ) self._location = widgets.ExtendedEdit( parent=self, edit_text=self.todo.location, @@ -157,6 +162,7 @@ def _save_inner(self): elif self.todo.is_completed and not self._completed.get_state(): self.todo.status = "NEEDS-ACTION" self.todo.completed_at = None + self.todo.categories = [c.strip() for c in self.categories.split(",")] self.todo.priority = self.priority # TODO: categories @@ -192,6 +198,10 @@ def due(self): def dtstart(self): return self._dtstart.edit_text + @property + def categories(self): + return self._categories.edit_text + @property def priority(self): return self._priority.priority diff --git a/todoman/model.py b/todoman/model.py index deff9f98..6416e4af 100644 --- a/todoman/model.py +++ b/todoman/model.py @@ -420,7 +420,7 @@ class Cache: may be used for filtering/sorting. """ - SCHEMA_VERSION = 7 + SCHEMA_VERSION = 9 def __init__(self, path: str): self.cache_path = str(path) @@ -445,18 +445,23 @@ def is_latest_version(self): except sqlite3.OperationalError: return False - def create_tables(self): - if self.is_latest_version(): - return + def drop_tables(self): self._conn.executescript( """ + DROP TABLE IF EXISTS todos; DROP TABLE IF EXISTS lists; DROP TABLE IF EXISTS files; - DROP TABLE IF EXISTS todos; + DROP TABLE IF EXISTS categories; """ ) + def create_tables(self): + if self.is_latest_version(): + return + + self.drop_tables() + self._conn.execute('CREATE TABLE IF NOT EXISTS meta ("version" INT)') self._conn.execute( @@ -490,6 +495,19 @@ def create_tables(self): """ ) + self._conn.execute( + """ + CREATE TABLE IF NOT EXISTS categories ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "todos_id" INTEGER NOT NULL, + "category" TEXT, + + CONSTRAINT category_unique UNIQUE (todos_id,category), + FOREIGN KEY(todos_id) REFERENCES todos(id) ON DELETE CASCADE + ); + """ + ) + self._conn.execute( """ CREATE TABLE IF NOT EXISTS todos ( @@ -510,7 +528,6 @@ def create_tables(self): "status" TEXT, "description" TEXT, "location" TEXT, - "categories" TEXT, "sequence" INTEGER, "last_modified" INTEGER, "rrule" TEXT, @@ -581,6 +598,20 @@ def add_file(self, list_name: str, path: str, mtime: int): except sqlite3.IntegrityError as e: raise exceptions.AlreadyExists("file", list_name) from e + def add_category(self, todos_id, category): + try: + self._conn.execute( + """ + INSERT INTO categories ( + todos_id, + category + ) VALUES (?, ?); + """, + (todos_id, category), + ) + except sqlite3.IntegrityError as e: + raise exceptions.AlreadyExists("category", category) from e + def _serialize_datetime( self, todo: icalendar.Todo, @@ -613,13 +644,6 @@ def _serialize_rrule(self, todo, field) -> str | None: return rrule.to_ical().decode() - def _serialize_categories(self, todo, field) -> str: - categories = todo.get(field, []) - if not categories: - return "" - - return ",".join([str(category) for category in categories.cats]) - def add_vtodo(self, todo: icalendar.Todo, file_path: str, id=None) -> int: """ Adds a todo into the cache. @@ -645,11 +669,10 @@ def add_vtodo(self, todo: icalendar.Todo, file_path: str, id=None) -> int: status, description, location, - categories, sequence, last_modified, rrule - ) VALUES ({}?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ) VALUES ({}?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ @@ -675,7 +698,6 @@ def add_vtodo(self, todo: icalendar.Todo, file_path: str, id=None) -> int: todo.get("status", "NEEDS-ACTION"), todo.get("description", None), todo.get("location", None), - self._serialize_categories(todo, "categories"), todo.get("sequence", 1), self._serialize_datetime(todo, "last-modified")[0], self._serialize_rrule(todo, "rrule"), @@ -694,14 +716,18 @@ def add_vtodo(self, todo: icalendar.Todo, file_path: str, id=None) -> int: finally: cursor.close() + if todo.get("categories"): + for category in todo.get("categories").cats: + self.add_category(rv, category) + return rv def todos( self, lists=(), + categories=None, priority=None, location="", - category="", grep="", sort=(), reverse=True, @@ -723,7 +749,7 @@ def todos( :param list lists: Only return todos for these lists. :param str location: Only return todos with a location containing this string. - :param str category: Only return todos with a category containing this + :param str categories: Only return todos with a category containing this string. :param str grep: Filter common fields with this substring. :param list sort: Order returned todos by these fields. Field names @@ -758,15 +784,20 @@ def todos( q = ", ".join(["?"] * len(lists)) extra_where.append(f"AND files.list_name IN ({q})") params.extend(lists) + if categories: + category_slots = ", ".join(["?"] * len(categories)) + extra_where.append( + "AND upper(categories.category) IN ({category_slots})".format( + category_slots=category_slots + ) + ) + params = params + [category.upper() for category in categories] if priority: extra_where.append("AND PRIORITY > 0 AND PRIORITY <= ?") params.append(f"{priority}") if location: extra_where.append("AND location LIKE ?") params.append(f"%{location}%") - if category: - extra_where.append("AND categories LIKE ?") - params.append(f"%{category}%") if grep: # # requires sqlite with pcre, which won't be available everywhere: # extra_where.append('AND summary REGEXP ?') @@ -811,10 +842,14 @@ def todos( order = order.replace(" DESC", " asc").replace(" ASC", " desc") query = """ - SELECT todos.*, files.list_name, files.path - FROM todos, files - WHERE todos.file_path = files.path {} - ORDER BY {} + SELECT DISTINCT todos.*, files.list_name, files.path, + group_concat(category) AS categories + FROM todos, files + LEFT JOIN categories + ON categories.todos_id = todos.id + WHERE todos.file_path = files.path {} + GROUP BY uid + ORDER BY {} """.format( " ".join(extra_where), order, @@ -856,6 +891,12 @@ def _date_from_db(self, dt, is_date=False) -> date | None: else: return datetime.fromtimestamp(dt, LOCAL_TIMEZONE) + def _categories_from_db(self, categories): + if categories: + return categories.split(",") + + return [] + def _todo_from_db(self, row: dict) -> Todo: todo = Todo() todo.id = row["id"] @@ -863,6 +904,7 @@ def _todo_from_db(self, row: dict) -> Todo: todo.summary = row["summary"] todo.due = self._date_from_db(row["due"], row["due_dt"]) todo.start = self._date_from_db(row["start"], row["start_dt"]) + todo.categories = self._categories_from_db(row["categories"]) todo.priority = row["priority"] todo.created_at = self._datetime_from_db(row["created_at"]) todo.completed_at = self._datetime_from_db(row["completed_at"]) @@ -871,6 +913,8 @@ def _todo_from_db(self, row: dict) -> Todo: todo.status = row["status"] todo.description = row["description"] todo.location = row["location"] + + logger.debug("todo.categories: %s\n", todo.categories) todo.sequence = row["sequence"] todo.last_modified = row["last_modified"] todo.list = self.lists_map[row["list_name"]] @@ -908,10 +952,14 @@ def todo(self, id: int, read_only=False) -> Todo: # XXX: DON'T USE READ_ONLY result = self._conn.execute( """ - SELECT todos.*, files.list_name, files.path - FROM todos, files + SELECT todos.*, files.list_name, files.path, + group_concat(category) AS categories + FROM todos, files + LEFT JOIN categories + ON categories.todos_id = todos.id WHERE files.path = todos.file_path AND todos.id = ? + GROUP BY uid """, (id,), ).fetchone()