From 386f1d9da1300675014866ac1acd29174d8582df Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Sat, 22 Jan 2022 14:04:51 -0500 Subject: [PATCH 01/26] feat(categories): added basic category support --- todoman/cli.py | 25 +++++++++++- todoman/formatters.py | 24 +++++++++++- todoman/interactive.py | 10 +++++ todoman/model.py | 87 ++++++++++++++++++++++++++++++++++-------- 4 files changed, 128 insertions(+), 18 deletions(-) diff --git a/todoman/cli.py b/todoman/cli.py index b3bc373e..9061ff94 100644 --- a/todoman/cli.py +++ b/todoman/cli.py @@ -81,6 +81,14 @@ def _validate_date_param(ctx, param, val): raise click.BadParameter(e) +def _validate_categories_param(ctx, param, val): + ctx = ctx.find_object(AppContext) + try: + return ctx.formatter.parse_categories(val) + except ValueError as e: + raise click.BadParameter(e) + + def _validate_priority_param(ctx, param, val): ctx = ctx.find_object(AppContext) try: @@ -147,6 +155,13 @@ def validate_status(ctx=None, param=None, val=None) -> str: def _todo_property_options(command): + click.option( + "--categories", + "-c", + default="", + callback=_validate_categories_param, + help="Task categories.", + )(command) click.option( "--priority", default="", @@ -174,7 +189,7 @@ def _todo_property_options(command): @functools.wraps(command) def command_wrap(*a, **kw): kw["todo_properties"] = { - key: kw.pop(key) for key in ("due", "start", "location", "priority") + key: kw.pop(key) for key in ("due", "start", "location", "categories", "priority") } return command(*a, **kw) @@ -548,7 +563,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 +582,13 @@ def move(ctx, list, ids): @click.option( "--due", default=None, help="Only show tasks due in INTEGER hours", type=int ) +@click.option( + "--categories", + default=None, + help="Only show tasks with specified categories.", + type=str, + callback=_validate_categories_param +) @click.option( "--priority", default=None, diff --git a/todoman/formatters.py b/todoman/formatters.py index d19ced16..0133869c 100644 --- a/todoman/formatters.py +++ b/todoman/formatters.py @@ -70,6 +70,10 @@ 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) + "]" + priority = click.style( self.format_priority_compact(todo.priority), fg="magenta", @@ -113,7 +117,7 @@ def compact_multiple(self, todos: Iterable[Todo], hide_list=False) -> str: # FIXME: double space when no priority table.append( - f"[{completed}] {todo.id} {priority} {due} {recurring}{summary}" + f"[{completed}] {todo.id} {priority} {due} {recurring}{summary} {categories}" ) return "\n".join(table) @@ -138,6 +142,9 @@ def detailed(self, todo: Todo) -> str: if todo.location: extra_lines.append(self._format_multiline("Location", todo.location)) + if todo.categories: + extra_lines.append(self._format_multiline("Categories", ", ".join(todo.categories))) + return f"{self.compact(todo)}{''.join(extra_lines)}" def format_datetime(self, dt: Optional[date]) -> Union[str, int, None]: @@ -148,6 +155,20 @@ 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): + if not categories: + return "" + else: + return ",".join(categories) + + def parse_categories(self, categories): + if categories is None or categories == '': + return None + else: + print('->', categories) + return categories.split(',') + # return (c for c in str(categories).split(',')) + def parse_priority(self, priority: Optional[str]) -> Optional[int]: if priority is None or priority == "": return None @@ -243,6 +264,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..112513d1 100644 --- a/todoman/interactive.py +++ b/todoman/interactive.py @@ -37,6 +37,7 @@ def __init__(self, todo, lists, formatter): ("Start", self._dtstart), ("Due", self._due), ("Completed", self._completed), + ("Categories", self._categories), ("Priority", self._priority), ]: label = urwid.Text(label + ":", align="right") @@ -78,6 +79,10 @@ def _init_basic_fields(self): edit_text=self.formatter.format_datetime(self.todo.start), ) self._completed = urwid.CheckBox("", state=self.todo.is_completed) + self._categories = widgets.ExtendedEdit( + parent=self, + edit_text=self.formatter.format_categories(self.todo.categories) + ), self._priority = widgets.PrioritySelector( parent=self, priority=self.todo.priority, @@ -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..048a0fb4 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 = 8 def __init__(self, path: str): self.cache_path = str(path) @@ -453,6 +453,7 @@ def create_tables(self): """ DROP TABLE IF EXISTS lists; DROP TABLE IF EXISTS files; + DROP TABLE IF EXISTS categories; DROP TABLE IF EXISTS todos; """ ) @@ -490,6 +491,18 @@ def create_tables(self): """ ) + self._conn.execute( + """ + CREATE TABLE IF NOT EXISTS categories ( + "uid" TEXT, + "category" TEXT + + CONSTRAINT category_unique UNIQUE (uid, category), + FOREIGN KEY(uid) REFERENCES todos(uid) ON DELETE CASCADE + ); + """ + ) + self._conn.execute( """ CREATE TABLE IF NOT EXISTS todos ( @@ -502,6 +515,7 @@ def create_tables(self): "due_dt" INTEGER, "start" INTEGER, "start_dt" INTEGER, + "categories" TEXT, "priority" INTEGER, "created_at" INTEGER, "completed_at" INTEGER, @@ -510,7 +524,6 @@ def create_tables(self): "status" TEXT, "description" TEXT, "location" TEXT, - "categories" TEXT, "sequence" INTEGER, "last_modified" INTEGER, "rrule" TEXT, @@ -581,6 +594,22 @@ 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, uid, category): + try: + self._conn.execute( + """ + INSERT INTO categories ( + uid, + category + ) VALUES ( ?, ?); + """, ( + uid, + category + ) + ) + except sqlite3.IntegrityError as e: + raise exceptions.AlreadyExists("category", category) from e + def _serialize_datetime( self, todo: icalendar.Todo, @@ -694,14 +723,17 @@ def add_vtodo(self, todo: icalendar.Todo, file_path: str, id=None) -> int: finally: cursor.close() + for category in todo.get("categories").cats: + self.add_category(todo.get("uid"), category) + return rv def todos( self, lists=(), + categories=None, priority=None, location="", - category="", grep="", sort=(), reverse=True, @@ -723,7 +755,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 +790,16 @@ def todos( q = ", ".join(["?"] * len(lists)) extra_where.append(f"AND files.list_name IN ({q})") params.extend(lists) + if categories: + # TODO: allow to filter for more than one category. + extra_where.append('AND upper(categories.category) = upper(?)') + params.append('{}'.format(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 ?') @@ -810,15 +843,25 @@ def todos( # doesn't care about casing anyway. 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 {} + # TODO: check if works + if categories: + query = ''' + SELECT distinct todos.*, files.list_name, files.path + FROM todos, files, categories + WHERE categories.uid = todos.uid and todos.file_path = files.path {} ORDER BY {} - """.format( - " ".join(extra_where), - order, - ) + '''.format( + ' '.join(extra_where), order, + ) + else: + query = ''' + SELECT todos.*, files.list_name, files.path + FROM todos, files + WHERE todos.file_path = files.path {} + ORDER BY {} + '''.format( + ' '.join(extra_where), order, + ) logger.debug(query) logger.debug(params) @@ -863,6 +906,18 @@ 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 = None + query = ''' + SELECT distinct category + FROM categories + WHERE categories.uid = '{}' + '''.format( + todo.uid, + ) + categories = self._conn.execute(query).fetchall() + todo.categories = [i['category'] for i in 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 +926,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"]] From 94f7c08922b9353b2bd10a7486803bff5a0bde39 Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Sun, 23 Jan 2022 18:03:19 -0500 Subject: [PATCH 02/26] feat(categories): added to interactive layer --- todoman/cli.py | 8 +++++--- todoman/formatters.py | 8 ++++---- todoman/interactive.py | 10 +++++----- todoman/model.py | 4 ++-- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/todoman/cli.py b/todoman/cli.py index 9061ff94..9098d83e 100644 --- a/todoman/cli.py +++ b/todoman/cli.py @@ -158,7 +158,8 @@ def _todo_property_options(command): click.option( "--categories", "-c", - default="", + multiple=True, + default=[], callback=_validate_categories_param, help="Task categories.", )(command) @@ -584,9 +585,10 @@ def move(ctx, list, ids): ) @click.option( "--categories", - default=None, + "-c", + multiple=True, + default=[], help="Only show tasks with specified categories.", - type=str, callback=_validate_categories_param ) @click.option( diff --git a/todoman/formatters.py b/todoman/formatters.py index 0133869c..54988672 100644 --- a/todoman/formatters.py +++ b/todoman/formatters.py @@ -159,15 +159,15 @@ def format_categories(self, categories): if not categories: return "" else: - return ",".join(categories) + return "" #",".join(categories) def parse_categories(self, categories): if categories is None or categories == '': return None else: - print('->', categories) - return categories.split(',') - # return (c for c in str(categories).split(',')) + # 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 == "": diff --git a/todoman/interactive.py b/todoman/interactive.py index 112513d1..b6dc1d6a 100644 --- a/todoman/interactive.py +++ b/todoman/interactive.py @@ -34,10 +34,10 @@ 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), - ("Categories", self._categories), ("Priority", self._priority), ]: label = urwid.Text(label + ":", align="right") @@ -66,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, @@ -79,10 +83,6 @@ def _init_basic_fields(self): edit_text=self.formatter.format_datetime(self.todo.start), ) self._completed = urwid.CheckBox("", state=self.todo.is_completed) - self._categories = widgets.ExtendedEdit( - parent=self, - edit_text=self.formatter.format_categories(self.todo.categories) - ), self._priority = widgets.PrioritySelector( parent=self, priority=self.todo.priority, diff --git a/todoman/model.py b/todoman/model.py index 048a0fb4..75a9a963 100644 --- a/todoman/model.py +++ b/todoman/model.py @@ -643,11 +643,11 @@ def _serialize_rrule(self, todo, field) -> str | None: return rrule.to_ical().decode() def _serialize_categories(self, todo, field) -> str: - categories = todo.get(field, []) + categories = todo.get(field, []).cats if not categories: return "" - return ",".join([str(category) for category in categories.cats]) + return ",".join([str(category) for category in categories]) def add_vtodo(self, todo: icalendar.Todo, file_path: str, id=None) -> int: """ From a1529da482237fc19e804b556912b64fae8d358f Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Mon, 24 Jan 2022 12:53:51 -0500 Subject: [PATCH 03/26] feat(categories): allow for filtering by multiple categories and fixed database antipattern --- todoman/formatters.py | 2 ++ todoman/model.py | 47 +++++++++++++++++++++++-------------------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/todoman/formatters.py b/todoman/formatters.py index 54988672..0408fae8 100644 --- a/todoman/formatters.py +++ b/todoman/formatters.py @@ -73,6 +73,8 @@ def compact_multiple(self, todos: Iterable[Todo], hide_list=False) -> str: if todo.categories: categories = "[" + ", ".join(todo.categories) + "]" + else: + categories = "" priority = click.style( self.format_priority_compact(todo.priority), diff --git a/todoman/model.py b/todoman/model.py index 75a9a963..44a28d74 100644 --- a/todoman/model.py +++ b/todoman/model.py @@ -420,7 +420,7 @@ class Cache: may be used for filtering/sorting. """ - SCHEMA_VERSION = 8 + SCHEMA_VERSION = 9 def __init__(self, path: str): self.cache_path = str(path) @@ -445,19 +445,21 @@ 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 categories; - DROP TABLE IF EXISTS todos; """ ) + def create_tables(self): + if self.is_latest_version(): + return + self._conn.execute('CREATE TABLE IF NOT EXISTS meta ("version" INT)') self._conn.execute( @@ -494,11 +496,12 @@ def create_tables(self): self._conn.execute( """ CREATE TABLE IF NOT EXISTS categories ( - "uid" TEXT, - "category" TEXT + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "todos_id" INTEGER NOT NULL, + "category" TEXT, - CONSTRAINT category_unique UNIQUE (uid, category), - FOREIGN KEY(uid) REFERENCES todos(uid) ON DELETE CASCADE + CONSTRAINT category_unique UNIQUE (todos_id,category), + FOREIGN KEY(todos_id) REFERENCES todos(id) ON DELETE CASCADE ); """ ) @@ -594,16 +597,16 @@ 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, uid, category): + def add_category(self, todos_id, category): try: self._conn.execute( """ INSERT INTO categories ( - uid, + todos_id, category - ) VALUES ( ?, ?); + ) VALUES (?, ?); """, ( - uid, + todos_id, category ) ) @@ -724,7 +727,7 @@ def add_vtodo(self, todo: icalendar.Todo, file_path: str, id=None) -> int: cursor.close() for category in todo.get("categories").cats: - self.add_category(todo.get("uid"), category) + self.add_category(rv, category) return rv @@ -787,13 +790,13 @@ def todos( lists = [ list_.name if isinstance(list_, TodoList) else list_ for list_ in lists ] - q = ", ".join(["?"] * len(lists)) - extra_where.append(f"AND files.list_name IN ({q})") + category_slots = ", ".join(["?"] * len(lists)) + extra_where.append(f"AND files.list_name IN ({category_slots})") params.extend(lists) if categories: - # TODO: allow to filter for more than one category. - extra_where.append('AND upper(categories.category) = upper(?)') - params.append('{}'.format(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}") @@ -848,7 +851,7 @@ def todos( query = ''' SELECT distinct todos.*, files.list_name, files.path FROM todos, files, categories - WHERE categories.uid = todos.uid and todos.file_path = files.path {} + WHERE categories.todos_id = todos.id and todos.file_path = files.path {} ORDER BY {} '''.format( ' '.join(extra_where), order, @@ -911,9 +914,9 @@ def _todo_from_db(self, row: dict) -> Todo: query = ''' SELECT distinct category FROM categories - WHERE categories.uid = '{}' + WHERE categories.todos_id = '{}' '''.format( - todo.uid, + todo.id, ) categories = self._conn.execute(query).fetchall() todo.categories = [i['category'] for i in categories] From 0500cc5205342cb4d664f0b46b1884e2a34e0290 Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Mon, 24 Jan 2022 13:26:13 -0500 Subject: [PATCH 04/26] chore: removed unnecessary db fields/code --- todoman/formatters.py | 9 --------- todoman/model.py | 4 +--- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/todoman/formatters.py b/todoman/formatters.py index 0408fae8..1b67b031 100644 --- a/todoman/formatters.py +++ b/todoman/formatters.py @@ -144,9 +144,6 @@ def detailed(self, todo: Todo) -> str: if todo.location: extra_lines.append(self._format_multiline("Location", todo.location)) - if todo.categories: - extra_lines.append(self._format_multiline("Categories", ", ".join(todo.categories))) - return f"{self.compact(todo)}{''.join(extra_lines)}" def format_datetime(self, dt: Optional[date]) -> Union[str, int, None]: @@ -157,12 +154,6 @@ 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): - if not categories: - return "" - else: - return "" #",".join(categories) - def parse_categories(self, categories): if categories is None or categories == '': return None diff --git a/todoman/model.py b/todoman/model.py index 44a28d74..f91f7168 100644 --- a/todoman/model.py +++ b/todoman/model.py @@ -677,11 +677,10 @@ def add_vtodo(self, todo: icalendar.Todo, file_path: str, id=None) -> int: status, description, location, - categories, sequence, last_modified, rrule - ) VALUES ({}?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ) VALUES ({}?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ @@ -707,7 +706,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"), From 44355a88d462880796aeb37974723a859b165ef8 Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Mon, 24 Jan 2022 15:44:17 -0500 Subject: [PATCH 05/26] tests: all tests pass --- AUTHORS.rst | 2 ++ tests/test_cli.py | 2 +- tests/test_porcelain.py | 5 +++++ todoman/cli.py | 20 +++++++++++++------- todoman/formatters.py | 10 ++++++++-- todoman/model.py | 7 ++++--- 6 files changed, 33 insertions(+), 13 deletions(-) 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/tests/test_cli.py b/tests/test_cli.py index 5f2a6362..fc7e8819 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -964,7 +964,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_porcelain.py b/tests/test_porcelain.py index ede7b80b..59d28bde 100644 --- a/tests/test_porcelain.py +++ b/tests/test_porcelain.py @@ -21,6 +21,7 @@ def test_list_all(tmpdir, runner, create): expected = [ { + "categories": [], "completed": True, "completed_at": 1545765154, "description": "", @@ -51,6 +52,7 @@ def test_list_due_date(tmpdir, runner, create): expected = [ { + "categories": [], "completed": True, "completed_at": None, "description": "", @@ -74,6 +76,7 @@ def test_list_nodue(tmpdir, runner, create): expected = [ { + "categories": [], "completed": False, "completed_at": None, "description": "", @@ -137,6 +140,7 @@ def test_show(tmpdir, runner, create): result = runner.invoke(cli, ["--porcelain", "show", "1"]) expected = { + "categories": [], "completed": False, "completed_at": None, "description": "Lots of text. Yum!", @@ -158,6 +162,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 9098d83e..29e901c4 100644 --- a/todoman/cli.py +++ b/todoman/cli.py @@ -156,12 +156,12 @@ def validate_status(ctx=None, param=None, val=None) -> str: def _todo_property_options(command): click.option( - "--categories", + "--category", "-c", multiple=True, - default=[], + default=(), callback=_validate_categories_param, - help="Task categories.", + help="Task categories. Can be used multiple times.", )(command) click.option( "--priority", @@ -190,8 +190,12 @@ def _todo_property_options(command): @functools.wraps(command) def command_wrap(*a, **kw): kw["todo_properties"] = { - key: kw.pop(key) for key in ("due", "start", "location", "categories", "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. + kw["todo_properties"]["categories"] = kw.pop("category") + return command(*a, **kw) return command_wrap @@ -420,7 +424,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: changes = True setattr(todo, key, value) @@ -584,10 +588,10 @@ def move(ctx, list, ids): "--due", default=None, help="Only show tasks due in INTEGER hours", type=int ) @click.option( - "--categories", + "--category", "-c", multiple=True, - default=[], + default=(), help="Only show tasks with specified categories.", callback=_validate_categories_param ) @@ -652,5 +656,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 1b67b031..2325b2f0 100644 --- a/todoman/formatters.py +++ b/todoman/formatters.py @@ -72,7 +72,7 @@ def compact_multiple(self, todos: Iterable[Todo], hide_list=False) -> str: percent = f" ({percent}%)" if todo.categories: - categories = "[" + ", ".join(todo.categories) + "]" + categories = " [" + ", ".join(todo.categories) + "]" else: categories = "" @@ -119,7 +119,7 @@ def compact_multiple(self, todos: Iterable[Todo], hide_list=False) -> str: # FIXME: double space when no priority table.append( - f"[{completed}] {todo.id} {priority} {due} {recurring}{summary} {categories}" + f"[{completed}] {todo.id} {priority} {due} {recurring}{summary}{categories}" ) return "\n".join(table) @@ -154,6 +154,12 @@ 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): + if not categories: + return "" + else: + return "" + def parse_categories(self, categories): if categories is None or categories == '': return None diff --git a/todoman/model.py b/todoman/model.py index f91f7168..0789ad83 100644 --- a/todoman/model.py +++ b/todoman/model.py @@ -646,7 +646,7 @@ def _serialize_rrule(self, todo, field) -> str | None: return rrule.to_ical().decode() def _serialize_categories(self, todo, field) -> str: - categories = todo.get(field, []).cats + categories = todo.get(field, []) if not categories: return "" @@ -724,8 +724,9 @@ def add_vtodo(self, todo: icalendar.Todo, file_path: str, id=None) -> int: finally: cursor.close() - for category in todo.get("categories").cats: - self.add_category(rv, category) + if todo.get("categories"): + for category in todo.get("categories").cats: + self.add_category(rv, category) return rv From 512766e1645824269f385c7831d8ac6d732c516e Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Mon, 24 Jan 2022 16:32:56 -0500 Subject: [PATCH 06/26] tests(category): created tests --- tests/test_cli.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index fc7e8819..a0c862ca 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -845,6 +845,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"SUMMARY: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) From a9fb63d8535c64583832a8e2945fd0b34f2ba369 Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Mon, 24 Jan 2022 16:56:05 -0500 Subject: [PATCH 07/26] docs: added category support --- CHANGELOG.rst | 1 + 1 file changed, 1 insertion(+) 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 ------ From 6a9592395d3acd90d3a8e46869e7c6f88a537ad3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 24 Jan 2022 23:03:02 +0000 Subject: [PATCH 08/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_cli.py | 8 ++++---- todoman/cli.py | 2 +- todoman/formatters.py | 2 +- todoman/model.py | 38 +++++++++++++++++++++----------------- 4 files changed, 27 insertions(+), 23 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index a0c862ca..45538754 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -846,9 +846,7 @@ def test_invoke_invalid_command(runner, tmpdir, config): def test_new_categories_single(runner): - result = runner.invoke( - cli, ["new", "-l", "default", "--category", "mine", "title"] - ) + result = runner.invoke(cli, ["new", "-l", "default", "--category", "mine", "title"]) assert "[mine]" in result.output @@ -873,7 +871,9 @@ 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]]) + 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 diff --git a/todoman/cli.py b/todoman/cli.py index 29e901c4..cbe899a4 100644 --- a/todoman/cli.py +++ b/todoman/cli.py @@ -593,7 +593,7 @@ def move(ctx, list, ids): multiple=True, default=(), help="Only show tasks with specified categories.", - callback=_validate_categories_param + callback=_validate_categories_param, ) @click.option( "--priority", diff --git a/todoman/formatters.py b/todoman/formatters.py index 2325b2f0..7a56eb79 100644 --- a/todoman/formatters.py +++ b/todoman/formatters.py @@ -161,7 +161,7 @@ def format_categories(self, categories): return "" def parse_categories(self, categories): - if categories is None or categories == '': + if categories is None or categories == "": return None else: # existing code assumes categories is list, diff --git a/todoman/model.py b/todoman/model.py index 0789ad83..0ee525a8 100644 --- a/todoman/model.py +++ b/todoman/model.py @@ -600,15 +600,13 @@ def add_file(self, list_name: str, path: str, mtime: int): def add_category(self, todos_id, category): try: self._conn.execute( - """ + """ INSERT INTO categories ( todos_id, category ) VALUES (?, ?); - """, ( - todos_id, - category - ) + """, + (todos_id, category), ) except sqlite3.IntegrityError as e: raise exceptions.AlreadyExists("category", category) from e @@ -794,7 +792,11 @@ def todos( 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)) + 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 <= ?") @@ -847,22 +849,24 @@ def todos( # TODO: check if works if categories: - query = ''' + query = """ SELECT distinct todos.*, files.list_name, files.path FROM todos, files, categories WHERE categories.todos_id = todos.id and todos.file_path = files.path {} ORDER BY {} - '''.format( - ' '.join(extra_where), order, + """.format( + " ".join(extra_where), + order, ) else: - query = ''' + query = """ SELECT todos.*, files.list_name, files.path FROM todos, files WHERE todos.file_path = files.path {} ORDER BY {} - '''.format( - ' '.join(extra_where), order, + """.format( + " ".join(extra_where), + order, ) logger.debug(query) @@ -910,15 +914,15 @@ def _todo_from_db(self, row: dict) -> Todo: todo.start = self._date_from_db(row["start"], row["start_dt"]) todo.categories = None - query = ''' + query = """ SELECT distinct category FROM categories WHERE categories.todos_id = '{}' - '''.format( - todo.id, - ) + """.format( + todo.id, + ) categories = self._conn.execute(query).fetchall() - todo.categories = [i['category'] for i in categories] + todo.categories = [i["category"] for i in categories] todo.priority = row["priority"] todo.created_at = self._datetime_from_db(row["created_at"]) From 20df442640ebeebad70ad79a438df8aa578a9983 Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Mon, 24 Jan 2022 18:50:26 -0500 Subject: [PATCH 09/26] fix: incompatible types --- todoman/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todoman/model.py b/todoman/model.py index 0ee525a8..854c75b1 100644 --- a/todoman/model.py +++ b/todoman/model.py @@ -913,7 +913,7 @@ def _todo_from_db(self, row: dict) -> Todo: todo.due = self._date_from_db(row["due"], row["due_dt"]) todo.start = self._date_from_db(row["start"], row["start_dt"]) - todo.categories = None + todo.categories = [] query = """ SELECT distinct category FROM categories From e7123270b1e11a82fbfa6be25927942539361cae Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Mon, 24 Jan 2022 18:53:47 -0500 Subject: [PATCH 10/26] ci: fix E501 (line too long) --- todoman/formatters.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/todoman/formatters.py b/todoman/formatters.py index 7a56eb79..c0203718 100644 --- a/todoman/formatters.py +++ b/todoman/formatters.py @@ -118,9 +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 - table.append( - f"[{completed}] {todo.id} {priority} {due} {recurring}{summary}{categories}" - ) + # split into parts to satisfy linter line too long + part1 = f"[{completed}] {todo.id} {priority} {due} " + part2 = f"{recurring}{summary}{categories}" + table.append(part1 + part2) return "\n".join(table) From 9e6c9fa55213d732455e188ee9c675f6ee66aac0 Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Mon, 24 Jan 2022 19:07:30 -0500 Subject: [PATCH 11/26] chore(ci): removed unnecesary lines to increase required code coverage --- todoman/formatters.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/todoman/formatters.py b/todoman/formatters.py index c0203718..439a216c 100644 --- a/todoman/formatters.py +++ b/todoman/formatters.py @@ -156,18 +156,12 @@ def format_datetime(self, dt: Optional[date]) -> Union[str, int, None]: return dt.strftime(self.date_format) def format_categories(self, categories): - if not categories: - return "" - else: - return "" + return "" def parse_categories(self, categories): - if categories is None or categories == "": - return None - else: - # existing code assumes categories is list, - # but click passes tuple - return list(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 == "": From f80d93c1ee0b677420ef2df712c22bc9b2f75fa9 Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Tue, 25 Jan 2022 11:14:49 -0500 Subject: [PATCH 12/26] fix: can edit categories in interactive mode --- todoman/formatters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todoman/formatters.py b/todoman/formatters.py index 439a216c..187011fc 100644 --- a/todoman/formatters.py +++ b/todoman/formatters.py @@ -156,7 +156,7 @@ def format_datetime(self, dt: Optional[date]) -> Union[str, int, None]: return dt.strftime(self.date_format) def format_categories(self, categories): - return "" + return ", ".join(categories) def parse_categories(self, categories): # existing code assumes categories is list, From 3a0a699777aa903107edd247a410ca4714441c37 Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Tue, 25 Jan 2022 11:56:46 -0500 Subject: [PATCH 13/26] chore: removed unneeded code for code coverage --- todoman/cli.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/todoman/cli.py b/todoman/cli.py index cbe899a4..ee4453c6 100644 --- a/todoman/cli.py +++ b/todoman/cli.py @@ -83,10 +83,7 @@ def _validate_date_param(ctx, param, val): def _validate_categories_param(ctx, param, val): ctx = ctx.find_object(AppContext) - try: - return ctx.formatter.parse_categories(val) - except ValueError as e: - raise click.BadParameter(e) + return ctx.formatter.parse_categories(val) def _validate_priority_param(ctx, param, val): From f0440967a18ca74bdb9ad59254afede661a13112 Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Tue, 25 Jan 2022 13:17:41 -0500 Subject: [PATCH 14/26] test: category sql contraint --- tests/test_model.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_model.py b/tests/test_model.py index 922d0bbe..acf7e495 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -161,6 +161,17 @@ 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_todo_setters(todo_factory): todo = todo_factory() From 0738e0ac8cac7832ec5c98594d848afe5e72d170 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 25 Jan 2022 18:17:57 +0000 Subject: [PATCH 15/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_model.py b/tests/test_model.py index acf7e495..7fbe16bf 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -166,7 +166,7 @@ def test_category_integrity(tmpdir, create, default_database): db = Database([tmpdir.join("default")], tmpdir.join("cache.sqlite")) todo = db.todo(1, read_only=False) - todo.categories = ['hi', 'hi'] + todo.categories = ["hi", "hi"] with pytest.raises(AlreadyExists): default_database.save(todo) From 16cb9417137b5a4f451ec2976f7fa277ba59bd6e Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Tue, 25 Jan 2022 13:30:03 -0500 Subject: [PATCH 16/26] chore: removed unused method, added back drop_tables --- todoman/model.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/todoman/model.py b/todoman/model.py index 854c75b1..5b7e3d89 100644 --- a/todoman/model.py +++ b/todoman/model.py @@ -460,6 +460,8 @@ 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( @@ -643,13 +645,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]) - def add_vtodo(self, todo: icalendar.Todo, file_path: str, id=None) -> int: """ Adds a todo into the cache. From 004d812c27d5a1ea2ec89f5c37bfdde50565f169 Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Mon, 31 Jan 2022 14:15:31 -0500 Subject: [PATCH 17/26] test: category is deleted on todo deletion --- tests/test_model.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_model.py b/tests/test_model.py index 7fbe16bf..91af71c5 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -172,6 +172,32 @@ def test_category_integrity(tmpdir, create, default_database): 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() From b2063c84fbcd0fb84d3bb540e661e40f6b505d3b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 31 Jan 2022 19:15:47 +0000 Subject: [PATCH 18/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_model.py b/tests/test_model.py index 91af71c5..6cc740c2 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -173,7 +173,7 @@ def test_category_integrity(tmpdir, create, default_database): def test_category_deletes_on_todo_delete(tmpdir, create, default_database): - uid = 'my_id' + uid = "my_id" create("test.ics", f"UID:{uid}\nSUMMARY:RAWR\n") db = Database([tmpdir.join("default")], tmpdir.join("cache.sqlite")) From 43d38cf8ca89881f41b81d4c3336b2fd4713ac48 Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Mon, 31 Jan 2022 14:29:41 -0500 Subject: [PATCH 19/26] fix: add back stricter check --- todoman/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todoman/cli.py b/todoman/cli.py index ee4453c6..f167f41b 100644 --- a/todoman/cli.py +++ b/todoman/cli.py @@ -376,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) From a20f595169d4533abbaff6a4974706269fe15ac7 Mon Sep 17 00:00:00 2001 From: Styx Meiseles <18606569+0styx0@users.noreply.github.com> Date: Mon, 31 Jan 2022 19:33:14 +0000 Subject: [PATCH 20/26] style: remove unecessarily verbose method of concatting strings Co-authored-by: Hugo Osvaldo Barrera --- todoman/formatters.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/todoman/formatters.py b/todoman/formatters.py index 187011fc..c4ede123 100644 --- a/todoman/formatters.py +++ b/todoman/formatters.py @@ -119,9 +119,10 @@ def compact_multiple(self, todos: Iterable[Todo], hide_list=False) -> str: # FIXME: double space when no priority # split into parts to satisfy linter line too long - part1 = f"[{completed}] {todo.id} {priority} {due} " - part2 = f"{recurring}{summary}{categories}" - table.append(part1 + part2) + table.append( + f"[{completed}] {todo.id} {priority} {due} " + f"{recurring}{summary}{categories}" + ) return "\n".join(table) From a7341c331195f5d1ef4350d9f69894bac40574f3 Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Mon, 31 Jan 2022 14:54:14 -0500 Subject: [PATCH 21/26] fix: revert to previous variable name search and replace gone wrong --- todoman/model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/todoman/model.py b/todoman/model.py index 5b7e3d89..8b28d564 100644 --- a/todoman/model.py +++ b/todoman/model.py @@ -782,8 +782,8 @@ def todos( lists = [ list_.name if isinstance(list_, TodoList) else list_ for list_ in lists ] - category_slots = ", ".join(["?"] * len(lists)) - extra_where.append(f"AND files.list_name IN ({category_slots})") + q = ", ".join(["?"] * len(lists)) + extra_where.append(f"AND files.list_name IN ({q})") params.extend(lists) if categories: category_slots = ", ".join(["?"] * len(categories)) From f81e26f0def69468a49b512fd7bdccec44685a00 Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Mon, 31 Jan 2022 17:03:58 -0500 Subject: [PATCH 22/26] refactor: multiple selects -> left join --- todoman/model.py | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/todoman/model.py b/todoman/model.py index 8b28d564..417e3d15 100644 --- a/todoman/model.py +++ b/todoman/model.py @@ -842,27 +842,17 @@ def todos( # doesn't care about casing anyway. order = order.replace(" DESC", " asc").replace(" ASC", " desc") - # TODO: check if works - if categories: - query = """ - SELECT distinct todos.*, files.list_name, files.path - FROM todos, files, categories - WHERE categories.todos_id = todos.id and todos.file_path = files.path {} - ORDER BY {} - """.format( - " ".join(extra_where), - order, - ) - else: - query = """ - SELECT todos.*, files.list_name, files.path - FROM todos, files - WHERE todos.file_path = files.path {} - ORDER BY {} - """.format( - " ".join(extra_where), - order, - ) + query = """ + SELECT DISTINCT todos.*, files.list_name, files.path + FROM todos, files + LEFT JOIN categories + ON categories.todos_id = todos.id + WHERE todos.file_path = files.path {} + ORDER BY {} + """.format( + " ".join(extra_where), + order, + ) logger.debug(query) logger.debug(params) From 2825fbf371602cb509c0c9d69076765d0099b9cd Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Tue, 1 Feb 2022 11:42:37 -0500 Subject: [PATCH 23/26] perf: use join instead of separate select statement --- tests/test_cli.py | 56 ++++++++++++++++++++++++++++------------- tests/test_filtering.py | 24 ++++++++++-------- tests/test_model.py | 22 +++++++++++----- tests/test_porcelain.py | 20 ++++++++++----- todoman/model.py | 28 ++++++++++----------- 5 files changed, 94 insertions(+), 56 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 45538754..70c004c4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -11,6 +11,7 @@ from dateutil.tz import tzlocal from freezegun import freeze_time from hypothesis import given +from uuid import uuid4 from tests.helpers import fs_case_sensitive from tests.helpers import pyicu_sensitive @@ -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,7 @@ 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 +132,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 +171,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 +201,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 +218,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 +345,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 +380,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 +541,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 +564,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 +602,10 @@ 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: @@ -861,7 +879,9 @@ def test_new_categories_multiple(runner): def test_list_categories_single(tmpdir, runner, create): category = "fizzbuzz" - create("test.ics", f"SUMMARY:harhar\nCATEGORIES:{category}\n") + 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 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 6cc740c2..1569e7fb 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -8,6 +8,7 @@ from dateutil.tz import tzlocal from dateutil.tz.tz import tzoffset from freezegun import freeze_time +from uuid import uuid4 from todoman.exceptions import AlreadyExists from todoman.model import Database @@ -21,7 +22,8 @@ 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 +37,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 +67,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 +137,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()) diff --git a/tests/test_porcelain.py b/tests/test_porcelain.py index 59d28bde..722ade61 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 @@ -71,7 +72,10 @@ 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 = [ @@ -98,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 @@ -136,7 +140,11 @@ 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 = { diff --git a/todoman/model.py b/todoman/model.py index 417e3d15..4b8bd635 100644 --- a/todoman/model.py +++ b/todoman/model.py @@ -520,7 +520,6 @@ def create_tables(self): "due_dt" INTEGER, "start" INTEGER, "start_dt" INTEGER, - "categories" TEXT, "priority" INTEGER, "created_at" INTEGER, "completed_at" INTEGER, @@ -843,11 +842,12 @@ def todos( order = order.replace(" DESC", " asc").replace(" ASC", " desc") query = """ - SELECT DISTINCT todos.*, files.list_name, files.path + 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), @@ -890,6 +890,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"] @@ -897,18 +903,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 = [] - query = """ - SELECT distinct category - FROM categories - WHERE categories.todos_id = '{}' - """.format( - todo.id, - ) - categories = self._conn.execute(query).fetchall() - todo.categories = [i["category"] for i in categories] - + 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"]) @@ -956,10 +951,13 @@ 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 + 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() From 64834bbdaf17c136409f2bda4c0720aaf2cffc46 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 1 Feb 2022 16:42:57 +0000 Subject: [PATCH 24/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_cli.py | 19 +++++++++---------- tests/test_model.py | 11 ++++++----- tests/test_porcelain.py | 4 ++-- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 70c004c4..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 @@ -11,7 +12,6 @@ from dateutil.tz import tzlocal from freezegun import freeze_time from hypothesis import given -from uuid import uuid4 from tests.helpers import fs_case_sensitive from tests.helpers import pyicu_sensitive @@ -123,7 +123,9 @@ def test_list_inexistant(tmpdir, runner, create): def test_show_existing(tmpdir, runner, create): - create("test.ics", f"UID:{uuid4()}\nSUMMARY: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 @@ -348,12 +350,12 @@ def test_sorting_output(tmpdir, runner, create): create( "test.ics", f"UID:{uuid4()}\nSUMMARY:aaa\n" - "DUE;VALUE=DATE-TIME;TZID=ART:20160102T000000\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" + "DUE;VALUE=DATE-TIME;TZID=ART:20160101T000000\n", ) examples = [("-summary", ["aaa", "bbb"]), ("due", ["aaa", "bbb"])] @@ -384,7 +386,7 @@ def test_sorting_null_values(tmpdir, runner, create): create( "test2.ics", f"UID:{uuid4()}\nSUMMARY:bbb\n" - "DUE;VALUE=DATE-TIME;TZID=ART:20160101T000000\n" + "DUE;VALUE=DATE-TIME;TZID=ART:20160101T000000\n", ) result = runner.invoke(cli) @@ -603,8 +605,7 @@ def test_due_bad_date(runner): def test_multiple_todos_in_file(runner, create): path = create( - "test.ics", - f"UID:{uuid4()}\nSUMMARY:a\nEND:VTODO\nBEGIN:VTODO\nSUMMARY:b\n" + "test.ics", f"UID:{uuid4()}\nSUMMARY:a\nEND:VTODO\nBEGIN:VTODO\nSUMMARY:b\n" ) for _ in range(2): @@ -879,9 +880,7 @@ def test_new_categories_multiple(runner): def test_list_categories_single(tmpdir, runner, create): category = "fizzbuzz" - create( - "test.ics", f"UID:{uuid4()}\nSUMMARY:harhar\nCATEGORIES:{category}\n" - ) + 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 diff --git a/tests/test_model.py b/tests/test_model.py index 1569e7fb..028c0d32 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -2,13 +2,13 @@ from datetime import datetime from datetime import timedelta from unittest.mock import patch +from uuid import uuid4 import pytest import pytz from dateutil.tz import tzlocal from dateutil.tz.tz import tzoffset from freezegun import freeze_time -from uuid import uuid4 from todoman.exceptions import AlreadyExists from todoman.model import Database @@ -22,8 +22,9 @@ def test_querying(create, tmpdir): for i, location in enumerate("abc"): create( f"test{i}.ics", - ("UID:{}\nSUMMARY:test_querying\r\nLOCATION:{}\r\n") - .format(uuid4(), location), + ("UID:{}\nSUMMARY:test_querying\r\nLOCATION:{}\r\n").format( + uuid4(), location + ), list_name=list, ) @@ -40,12 +41,12 @@ def test_retain_tz(tmpdir, create, todos): create( "ar.ics", f"UID:{uuid4()}\nSUMMARY:blah.ar\n" - "DUE;VALUE=DATE-TIME;TZID=HST:20160102T000000\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" + "DUE;VALUE=DATE-TIME;TZID=CET:20160102T000000\n", ) todos = list(todos()) diff --git a/tests/test_porcelain.py b/tests/test_porcelain.py index 722ade61..3df41c7b 100644 --- a/tests/test_porcelain.py +++ b/tests/test_porcelain.py @@ -74,7 +74,7 @@ def test_list_due_date(tmpdir, runner, create): def test_list_nodue(tmpdir, runner, create): create( "test.ics", - f"UID:{uuid4()}\nSUMMARY:Do stuff\nPERCENT-COMPLETE:12\nPRIORITY:4\n" + f"UID:{uuid4()}\nSUMMARY:Do stuff\nPERCENT-COMPLETE:12\nPRIORITY:4\n", ) result = runner.invoke(cli, ["--porcelain", "list"]) @@ -143,7 +143,7 @@ def test_show(tmpdir, runner, create): create( "test.ics", f"UID:{uuid4()}\nSUMMARY:harhar\n" - "DESCRIPTION:Lots of text. Yum!\nPRIORITY:5\n" + "DESCRIPTION:Lots of text. Yum!\nPRIORITY:5\n", ) result = runner.invoke(cli, ["--porcelain", "show", "1"]) From 118df5549043bc893893957b1445398ad0baccc5 Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Tue, 1 Feb 2022 11:46:45 -0500 Subject: [PATCH 25/26] style: fixed line length warning --- todoman/model.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/todoman/model.py b/todoman/model.py index 4b8bd635..6416e4af 100644 --- a/todoman/model.py +++ b/todoman/model.py @@ -842,7 +842,8 @@ def todos( order = order.replace(" DESC", " asc").replace(" ASC", " desc") query = """ - SELECT DISTINCT todos.*, files.list_name, files.path, group_concat(category) AS categories + 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 @@ -951,8 +952,9 @@ 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, group_concat(category) AS categories - 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 From c6af1d7b32f668bddf40f2493fbd7b763fa2603f Mon Sep 17 00:00:00 2001 From: Styx Meiseles Date: Thu, 3 Feb 2022 00:03:42 -0500 Subject: [PATCH 26/26] fix: added back stricter check adding the [] check to pass tests/test_cli.py::test_edit_move --- todoman/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todoman/cli.py b/todoman/cli.py index f167f41b..baaba977 100644 --- a/todoman/cli.py +++ b/todoman/cli.py @@ -421,7 +421,7 @@ def edit(ctx, id, todo_properties, interactive, raw): changes = False for key, value in todo_properties.items(): - if value: + if value is not None and value != []: changes = True setattr(todo, key, value)