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..866620a6 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,13 @@ 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 +203,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 +371,7 @@ def new(ctx, summary, list, todo_properties, read_description, interactive): for key, value in todo_properties.items(): if value: + logger.debug("property: %s value: %s type: %s", key, ','.join(value), type(value) ) setattr(todo, key, value) todo.summary = ' '.join(summary) @@ -551,7 +568,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 +593,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, @@ -615,7 +639,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: diff --git a/todoman/formatters.py b/todoman/formatters.py index 9ba4c956..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): """ @@ -104,6 +106,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 +125,19 @@ 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: + 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 '': return None @@ -217,6 +234,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..fe6cd292 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 = [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(): @@ -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..601568c4 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,12 @@ 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)) + v = self.vtodo.set_inline(name, 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 +377,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 +411,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 +422,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 +473,6 @@ def create_tables(self): "status" TEXT, "description" TEXT, "location" TEXT, - "categories" TEXT, "sequence" INTEGER, "last_modified" INTEGER, "rrule" TEXT, @@ -518,6 +534,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 +589,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 +615,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 +633,12 @@ def add_vtodo(self, todo, file_path, id=None): finally: cursor.close() + 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'): + logger.debug("adding category: %s", c) + self.add_category(todo.get('uid'), c) + return rv def todos( @@ -613,13 +646,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 +671,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,9 +707,10 @@ def todos( if location: extra_where.append('AND location LIKE ?') params.append('%{}%'.format(location)) - if category: - extra_where.append('AND categories LIKE ?') - params.append('%{}%'.format(category)) + if categories: + # 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 ?') @@ -719,16 +753,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) @@ -771,6 +815,21 @@ def _todo_from_db(self, row): todo.status = row['status'] todo.description = row['description'] todo.location = row['location'] + todo.categories = None + 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 %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']]