From 2a47506b6bcb267b28d1d526c2dfcac095d3258a Mon Sep 17 00:00:00 2001 From: Christof Schulze Date: Mon, 17 Sep 2018 03:12:26 +0200 Subject: [PATCH 1/5] Add support for categories. This fixes #10 --- AUTHORS.rst | 1 + CHANGELOG.rst | 1 + tests/test_cli.py | 23 +++++++++++++++++ todoman/cli.py | 29 ++++++++++++++++++++-- todoman/formatters.py | 15 +++++++++++ todoman/interactive.py | 11 ++++++++- todoman/model.py | 56 ++++++++++++++++++++++++++++++++++-------- 7 files changed, 123 insertions(+), 13 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 7bdaef9d..a0707409 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -13,6 +13,7 @@ Authors are listed in alphabetical order. * Anubha Agrawal * Benjamin Frank * Christian Geier +* Christof Schulze * Doron Behar * Guilhem Saurel * Hugo Osvaldo Barrera diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e5b0f27a..ceb31a24 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -98,6 +98,7 @@ New features * Increment sequence number upon edits. * Print a descriptive message when no lists are found. * Add full support for locations. +* Add full support for categories. Packaging changes ~~~~~~~~~~~~~~~~~ diff --git a/tests/test_cli.py b/tests/test_cli.py index 2d0538f4..f5d44b45 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -536,6 +536,29 @@ def test_empty_list(tmpdir, runner, create): assert expected in result.output +def test_show_categories(tmpdir, runner, create): + create('test.ics', 'SUMMARY:harhar\n', 'CATEGORIES:Online\n') + + result = runner.invoke(cli, ['show', '1']) + assert not result.exception + assert 'Online' in result.output + + +def test_categories(runner): + result = runner.invoke(cli, [ + 'new', '-l', 'default', '--categories', 'Offline', 'Event Name' + ]) + + assert not result.exception + assert 'Offline' in result.output + +def test_categories(runner): + result = runner.invoke(cli, [ + 'new', '-l', 'default', '--categories', 'Offline', '--categories', 'Phone', 'Event Name' + ]) + + assert not result.exception + assert 'Offline,Phone' in result.output def test_show_location(tmpdir, runner, create): create('test.ics', 'SUMMARY:harhar\n' 'LOCATION:Boston\n') diff --git a/todoman/cli.py b/todoman/cli.py index 09ae7665..21d9de52 100644 --- a/todoman/cli.py +++ b/todoman/cli.py @@ -1,3 +1,4 @@ +import logging import functools import glob import locale @@ -14,6 +15,7 @@ from todoman.interactive import TodoEditor from todoman.model import cached_property, Database, Todo +logger = logging.getLogger(name=__name__) click_log.basic_config() @@ -119,6 +121,13 @@ def _validate_todos(ctx, param, val): with handle_error(): return [ctx.db.todo(int(id)) for id in val] +def _validate_categories(ctx, param, val): + ctx = ctx.find_object(AppContext) + try: + return ctx.formatter.parse_category(val) + except ValueError as e: + raise click.BadParameter(e) + def _sort_callback(ctx, param, val): fields = val.split(',') if val else [] @@ -165,6 +174,12 @@ def _todo_property_options(command): '--location', help=('The location where ' 'this todo takes place.') )(command) + click.option( + '--categories', + default='', + multiple=True, + callback=_validate_categories, + help=('A category. May be used multiple times.'))(command) click.option( '--due', '-d', @@ -187,7 +202,7 @@ def _todo_property_options(command): def command_wrap(*a, **kw): kw['todo_properties'] = { key: kw.pop(key) - for key in ('due', 'start', 'location', 'priority') + for key in ('due', 'start', 'location', 'priority', 'categories') } return command(*a, **kw) @@ -355,6 +370,9 @@ def new(ctx, summary, list, todo_properties, read_description, interactive): for key, value in todo_properties.items(): if value: + logger.debug("property: " + key + " value: " + ','.join(value) + " (" + str(type(value)) + ")" ) + if key == "categories": + value = [v for v in value] setattr(todo, key, value) todo.summary = ' '.join(summary) @@ -551,7 +569,7 @@ 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('--categories', help='Only show tasks with categories containg TEXT') @click.option('--grep', help='Only show tasks with message containg TEXT') @click.option( '--sort', @@ -576,6 +594,13 @@ def move(ctx, list, ids): 'hours', type=int ) +@click.option( + '--categories', + default=None, + help='Only show tasks with categories.', + type=str, + callback=_validate_categories +) @click.option( '--priority', default=None, diff --git a/todoman/formatters.py b/todoman/formatters.py index 9ba4c956..a3a10eb5 100644 --- a/todoman/formatters.py +++ b/todoman/formatters.py @@ -104,6 +104,8 @@ def detailed(self, todo): extra_rows = [] if todo.description: extra_rows += self._columnize('Description', todo.description) + if todo.categories: + extra_rows += self._columnize('Categories', ','.join(todo.categories)) if todo.location: extra_rows += self._columnize('Location', todo.location) @@ -121,6 +123,18 @@ def format_datetime(self, dt): elif isinstance(dt, datetime.date): return dt.strftime(self.date_format) + def format_categories(self, categories): + if not categories: + return "" + else: + return ','.join(categories) + + def parse_category(self, categories): + if categories is None or categories == '': + return None + else: + return categories + def parse_priority(self, priority): if priority is None or priority is '': return None @@ -217,6 +231,7 @@ def _todo_as_dict(self, todo): percent=todo.percent_complete, summary=todo.summary, priority=todo.priority, + categories=todo.categories, location=todo.location, ) diff --git a/todoman/interactive.py b/todoman/interactive.py index d5cd1a50..f92aa39d 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), @@ -75,6 +76,10 @@ def _init_basic_fields(self): parent=self, edit_text=self.todo.location, ) + self._categories = widgets.ExtendedEdit( + parent=self, + edit_text=self.formatter.format_categories(self.todo.categories), + ) self._due = widgets.ExtendedEdit( parent=self, edit_text=self.formatter.format_datetime(self.todo.due), @@ -159,6 +164,7 @@ def _save_inner(self): self.todo.summary = self.summary self.todo.description = self.description self.todo.location = self.location + self.todo.categories = self.categories self.todo.due = self.formatter.parse_datetime(self.due) self.todo.start = self.formatter.parse_datetime(self.dtstart) if not self.todo.is_completed and self._completed.get_state(): @@ -168,7 +174,6 @@ def _save_inner(self): self.todo.completed_at = None self.todo.priority = self.priority - # TODO: categories # TODO: comment # https://tools.ietf.org/html/rfc5545#section-3.8 @@ -197,6 +202,10 @@ def location(self): def due(self): return self._due.edit_text + @property + def categories(self): + return self._categories.edit_text + @property def dtstart(self): return self._dtstart.edit_text diff --git a/todoman/model.py b/todoman/model.py index 54c0f260..c5be855d 100644 --- a/todoman/model.py +++ b/todoman/model.py @@ -170,6 +170,8 @@ def __setattr__(self, name, value): if name in Todo.INT_FIELDS: return object.__setattr__(self, name, 0) if name in Todo.LIST_FIELDS: + if value is not None and not isinstance(value, list): + raise ValueError("Got a {0} for {1} where list was expected!".format(type(value), name)) return object.__setattr__(self, name, []) return object.__setattr__(self, name, value) @@ -284,7 +286,7 @@ def serialize_field(self, name, value): if name in Todo.DATETIME_FIELDS: return self.normalize_datetime(value) if name in Todo.LIST_FIELDS: - return ','.join(value) + return value if name in Todo.INT_FIELDS: return int(value) if name in Todo.STRING_FIELDS: @@ -297,7 +299,11 @@ def set_field(self, name, value): self.vtodo.pop(name) if value: logger.debug("Setting field %s to %s.", name, value) - self.vtodo.add(name, value) + if name == 'categories': + v = icalendar.prop.vInline(','.join(value)) + self.vtodo.add(name, v) + else: + self.vtodo.add(name, value) def serialize(self, original=None): """Serialize a Todo into a VTODO.""" @@ -370,7 +376,7 @@ class Cache: may be used for filtering/sorting. """ - SCHEMA_VERSION = 5 + SCHEMA_VERSION = 6 def __init__(self, path): self.cache_path = str(path) @@ -404,6 +410,7 @@ def create_tables(self): DROP TABLE IF EXISTS lists; DROP TABLE IF EXISTS files; DROP TABLE IF EXISTS todos; + DROP TABLE IF EXISTS categories; ''' ) @@ -414,6 +421,15 @@ def create_tables(self): (Cache.SCHEMA_VERSION,), ) + self._conn.execute( + ''' + CREATE TABLE IF NOT EXISTS categories ( + "uid" TEXT, + "category" TEXT + ); + ''' + ) + self._conn.execute( ''' CREATE TABLE IF NOT EXISTS lists ( @@ -456,7 +472,6 @@ def create_tables(self): "status" TEXT, "description" TEXT, "location" TEXT, - "categories" TEXT, "sequence" INTEGER, "last_modified" INTEGER, "rrule" TEXT, @@ -518,6 +533,19 @@ def add_file(self, list_name, path, mtime): except sqlite3.IntegrityError as e: raise exceptions.AlreadyExists('file', list_name) from e + def add_category(self, uid, category): + self._conn.execute( + ''' + INSERT INTO categories ( + uid, + category + ) VALUES ( ?, ?); + ''', ( + uid, + category + ) + ) + def _serialize_datetime(self, todo, field): dt = todo.decoded(field, None) if not dt: @@ -560,11 +588,10 @@ def add_vtodo(self, todo, file_path, id=None): status, description, location, - categories, sequence, last_modified, rrule - ) VALUES ({}?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES ({}?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''' due = self._serialize_datetime(todo, 'due') @@ -587,7 +614,6 @@ def add_vtodo(self, todo, file_path, id=None): todo.get('status', 'NEEDS-ACTION'), todo.get('description', None), todo.get('location', None), - todo.get('categories', None), todo.get('sequence', 1), self._serialize_datetime(todo, 'last-modified'), self._serialize_rrule(todo, 'rrule'), @@ -606,6 +632,12 @@ def add_vtodo(self, todo, file_path, id=None): finally: cursor.close() + if todo.get('categories') is not None: + logger.debug("Categories: " + str(todo.get('categories'))) + for c in todo.get('categories').split(','): + logger.debug("adding category: " + c) + self.add_category(todo.get('uid'), c) + return rv def todos( @@ -613,13 +645,13 @@ def todos( lists=(), priority=None, location='', - category='', grep='', sort=(), reverse=True, due=None, start=None, startable=False, + categories=None, status=( 'NEEDS-ACTION', 'IN-PROCESS', @@ -638,7 +670,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 @@ -674,7 +706,8 @@ def todos( if location: extra_where.append('AND location LIKE ?') params.append('%{}%'.format(location)) - if category: + if categories: + # TODO: wie selektiert man geschickt mit TAbellen und kategorien? extra_where.append('AND categories LIKE ?') params.append('%{}%'.format(category)) if grep: @@ -771,6 +804,9 @@ def _todo_from_db(self, row): todo.status = row['status'] todo.description = row['description'] todo.location = row['location'] + todo.categories = None + #row['categories'] + # TODO: category korrekt befüllen todo.sequence = row['sequence'] todo.last_modified = row['last_modified'] todo.list = self.lists_map[row['list_name']] From 3a0f79e92486864731a29383d840549332f8de45 Mon Sep 17 00:00:00 2001 From: Christof Schulze Date: Thu, 20 Sep 2018 00:11:07 +0200 Subject: [PATCH 2/5] cli.py: fix typo: incomplete tasks are show. => incomplete tasks are shown --- todoman/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todoman/cli.py b/todoman/cli.py index 21d9de52..923e4d45 100644 --- a/todoman/cli.py +++ b/todoman/cli.py @@ -640,7 +640,7 @@ def list(ctx, **kwargs): List tasks. Filters any completed or cancelled tasks by default. If no arguments are provided, all lists will be displayed, and only - incomplete tasks are show. Otherwise, only todos for the specified list + incomplete tasks are shown. Otherwise, only todos for the specified list will be displayed. eg: From 65711f57501f7b989e0e50a3e70517049d9222ab Mon Sep 17 00:00:00 2001 From: Christof Schulze Date: Thu, 20 Sep 2018 02:20:51 +0200 Subject: [PATCH 3/5] fixup! Add support for categories. This fixes #10 --- todoman/interactive.py | 2 +- todoman/model.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/todoman/interactive.py b/todoman/interactive.py index f92aa39d..58fd7f04 100644 --- a/todoman/interactive.py +++ b/todoman/interactive.py @@ -164,7 +164,7 @@ def _save_inner(self): self.todo.summary = self.summary self.todo.description = self.description self.todo.location = self.location - self.todo.categories = self.categories + self.todo.categories = self.categories.split(',') self.todo.due = self.formatter.parse_datetime(self.due) self.todo.start = self.formatter.parse_datetime(self.dtstart) if not self.todo.is_completed and self._completed.get_state(): diff --git a/todoman/model.py b/todoman/model.py index c5be855d..9b3d51a9 100644 --- a/todoman/model.py +++ b/todoman/model.py @@ -633,7 +633,7 @@ def add_vtodo(self, todo, file_path, id=None): cursor.close() if todo.get('categories') is not None: - logger.debug("Categories: " + str(todo.get('categories'))) + logger.debug("Categories: %s %s", str(todo.get('categories')), str(type(todo.get('categories'))) ) for c in todo.get('categories').split(','): logger.debug("adding category: " + c) self.add_category(todo.get('uid'), c) From e9fa829ad97b637d65bc27b12200bc25b2932024 Mon Sep 17 00:00:00 2001 From: Christof Schulze Date: Fri, 21 Sep 2018 08:34:16 +0200 Subject: [PATCH 4/5] fixup! Add support for categories. This fixes #10 --- todoman/model.py | 48 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/todoman/model.py b/todoman/model.py index 9b3d51a9..5a3b8e87 100644 --- a/todoman/model.py +++ b/todoman/model.py @@ -707,9 +707,9 @@ def todos( extra_where.append('AND location LIKE ?') params.append('%{}%'.format(location)) if categories: - # TODO: wie selektiert man geschickt mit TAbellen und kategorien? - extra_where.append('AND categories LIKE ?') - params.append('%{}%'.format(category)) + # TODO: allow to filter for more than one category. + extra_where.append('AND upper(categories.category) = upper(?)') + params.append('{}'.format(categories)) if grep: # # requires sqlite with pcre, which won't be available everywhere: # extra_where.append('AND summary REGEXP ?') @@ -752,16 +752,26 @@ def todos( # Note the change in case to avoid swapping all of them. sqlite # 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 {} + 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( + '''.format( ' '.join(extra_where), - order, - ) + 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) @@ -805,8 +815,20 @@ def _todo_from_db(self, row): todo.description = row['description'] todo.location = row['location'] todo.categories = None - #row['categories'] - # TODO: category korrekt befüllen + query = ''' + SELECT distinct category + FROM categories + WHERE categories.uid = '{}' + '''.format( + todo.uid, + ) + logger.debug("query %s\n", query); + result = self._conn.execute(query) + for c in result: + logger.debug("result %s\n", str(c), str(type(c)) ); + todo.categories = ','.join(str([todo.categories, c])) + todo.categories = result; + 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 107357aa792f3380cad08cdb544569decc31be51 Mon Sep 17 00:00:00 2001 From: Christof Schulze Date: Fri, 28 Sep 2018 02:11:14 +0200 Subject: [PATCH 5/5] just sharing the latest code - still not working yet- () is written instead of category --- todoman/cli.py | 7 +++---- todoman/formatters.py | 5 ++++- todoman/interactive.py | 2 +- todoman/model.py | 11 ++++++----- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/todoman/cli.py b/todoman/cli.py index 923e4d45..866620a6 100644 --- a/todoman/cli.py +++ b/todoman/cli.py @@ -179,7 +179,8 @@ def _todo_property_options(command): default='', multiple=True, callback=_validate_categories, - help=('A category. May be used multiple times.'))(command) + help=('A category. May be used multiple times.') + )(command) click.option( '--due', '-d', @@ -370,9 +371,7 @@ def new(ctx, summary, list, todo_properties, read_description, interactive): for key, value in todo_properties.items(): if value: - logger.debug("property: " + key + " value: " + ','.join(value) + " (" + str(type(value)) + ")" ) - if key == "categories": - value = [v for v in value] + logger.debug("property: %s value: %s type: %s", key, ','.join(value), type(value) ) setattr(todo, key, value) todo.summary = ' '.join(summary) diff --git a/todoman/formatters.py b/todoman/formatters.py index a3a10eb5..eeddd939 100644 --- a/todoman/formatters.py +++ b/todoman/formatters.py @@ -1,3 +1,4 @@ +import logging import datetime import json from time import mktime @@ -9,6 +10,7 @@ from dateutil.tz import tzlocal from tabulate import tabulate +logger = logging.getLogger(name=__name__) def rgb_to_ansi(colour): """ @@ -133,7 +135,8 @@ def parse_category(self, categories): if categories is None or categories == '': return None else: - return categories + logger.debug("categories: value: %s type: %s", str(list(categories)), str(type(categories)) ) + return (c for c in str(categories).split(',')) def parse_priority(self, priority): if priority is None or priority is '': diff --git a/todoman/interactive.py b/todoman/interactive.py index 58fd7f04..fe6cd292 100644 --- a/todoman/interactive.py +++ b/todoman/interactive.py @@ -164,7 +164,7 @@ def _save_inner(self): self.todo.summary = self.summary self.todo.description = self.description self.todo.location = self.location - self.todo.categories = self.categories.split(',') + self.todo.categories = [c.strip() for c in self.categories.split(',')] self.todo.due = self.formatter.parse_datetime(self.due) self.todo.start = self.formatter.parse_datetime(self.dtstart) if not self.todo.is_completed and self._completed.get_state(): diff --git a/todoman/model.py b/todoman/model.py index 5a3b8e87..601568c4 100644 --- a/todoman/model.py +++ b/todoman/model.py @@ -170,7 +170,7 @@ def __setattr__(self, name, value): if name in Todo.INT_FIELDS: return object.__setattr__(self, name, 0) if name in Todo.LIST_FIELDS: - if value is not None and not isinstance(value, list): + if value is not None and not isinstance(value, list): raise ValueError("Got a {0} for {1} where list was expected!".format(type(value), name)) return object.__setattr__(self, name, []) @@ -300,7 +300,8 @@ def set_field(self, name, value): if value: logger.debug("Setting field %s to %s.", name, value) if name == 'categories': - v = icalendar.prop.vInline(','.join(value)) +# v = icalendar.prop.vInline(','.join(value)) + v = self.vtodo.set_inline(name, value) self.vtodo.add(name, v) else: self.vtodo.add(name, value) @@ -634,8 +635,8 @@ def add_vtodo(self, todo, file_path, id=None): if todo.get('categories') is not None: logger.debug("Categories: %s %s", str(todo.get('categories')), str(type(todo.get('categories'))) ) - for c in todo.get('categories').split(','): - logger.debug("adding category: " + c) + for c in todo.get('categories'): + logger.debug("adding category: %s", c) self.add_category(todo.get('uid'), c) return rv @@ -825,7 +826,7 @@ def _todo_from_db(self, row): logger.debug("query %s\n", query); result = self._conn.execute(query) for c in result: - logger.debug("result %s\n", str(c), str(type(c)) ); + logger.debug("result %s %s\n", str(c), str(type(c)) ); todo.categories = ','.join(str([todo.categories, c])) todo.categories = result; logger.debug("todo.categories: %s\n", todo.categories)