Commit 3f3a6c4e authored by Bram Schoenmakers's avatar Bram Schoenmakers

Merge branch 'groups/master'

parents 6383df70 ca331b82
+A only test:test_group1
+B only test:test_group1
+A and +B test:test_group1
No project test:test_group1
Different item test:test_group2 l:1
Another item test:test_group2 l:0
Test 1 due:2016-12-06 test:test_group3
Test 2 due:2016-12-07 test:test_group3
Test 1 t:2016-12-06 test:test_group4 test:test_group5
Test 2 t:2016-12-07 test:test_group4 test:test_group5
Group by non-existing tag test:test_group6
Sort descending +A test:test_group7
Sort descending +B test:test_group7
Inner sort 1 +A @A test:test_group8
Inner sort 2 +A @B test:test_group8
Inner sort 3 +B @A test:test_group8
Inner sort 4 +B @B test:test_group8
Inner sort 1 +A test:test_group9
Inner sort 2 +A test:test_group9
......@@ -552,5 +552,225 @@ class ListCommandDotTest(CommandTest):
self.assertEqual(self.errors, "")
@freeze_time('2016, 12, 6')
class ListCommandGroupTest(CommandTest):
def test_group1(self):
todolist = load_file_to_todolist("test/data/ListCommandGroupTest.txt")
command = ListCommand(["-g", "project", "test:test_group1"], todolist, self.out, self.error)
command.execute()
self.assertFalse(todolist.dirty)
self.assertEqual(self.output, """\
Project: A
==========
| 1| +A only test:test_group1
| 3| +A and +B test:test_group1
Project: B
==========
| 3| +A and +B test:test_group1
| 2| +B only test:test_group1
Project: None
=============
| 4| No project test:test_group1
""")
def test_group2(self):
todolist = load_file_to_todolist("test/data/ListCommandGroupTest.txt")
command = ListCommand(["-g", "l", "test:test_group2"], todolist, self.out, self.error)
command.execute()
self.assertFalse(todolist.dirty)
self.assertEqual(self.output, """\
l: 0
====
| 6| Another item l:0 test:test_group2
l: 1
====
| 5| Different item l:1 test:test_group2
""")
def test_group3(self):
todolist = load_file_to_todolist("test/data/ListCommandGroupTest.txt")
command = ListCommand(["-g", "due", "test:test_group3"], todolist, self.out, self.error)
command.execute()
self.assertFalse(todolist.dirty)
self.assertEqual(self.output, """\
due: today
==========
| 7| Test 1 test:test_group3 due:2016-12-06
due: in a day
=============
| 8| Test 2 test:test_group3 due:2016-12-07
""")
def test_group4(self):
todolist = load_file_to_todolist("test/data/ListCommandGroupTest.txt")
command = ListCommand(["-g", "t", "test:test_group4"], todolist, self.out, self.error)
command.execute()
self.assertFalse(todolist.dirty)
self.assertEqual(self.output, """\
t: today
========
| 9| Test 1 test:test_group4 test:test_group5 t:2016-12-06
""")
def test_group5(self):
todolist = load_file_to_todolist("test/data/ListCommandGroupTest.txt")
command = ListCommand(["-x", "-g", "t", "test:test_group5"], todolist, self.out, self.error)
command.execute()
self.assertFalse(todolist.dirty)
self.assertEqual(self.output, """\
t: today
========
| 9| Test 1 test:test_group4 test:test_group5 t:2016-12-06
t: in a day
===========
| 10| Test 2 test:test_group4 test:test_group5 t:2016-12-07
""")
def test_group6(self):
todolist = load_file_to_todolist("test/data/ListCommandGroupTest.txt")
command = ListCommand(["-x", "-g", "fake", "test_group6"], todolist, self.out, self.error)
command.execute()
self.assertFalse(todolist.dirty)
self.assertEqual(self.output, """\
fake: No value
==============
| 11| Group by non-existing tag test:test_group6
""")
def test_group7(self):
todolist = load_file_to_todolist("test/data/ListCommandGroupTest.txt")
command = ListCommand(["-x", "-g", "desc:project", "test_group7"], todolist, self.out, self.error)
command.execute()
self.assertFalse(todolist.dirty)
self.assertEqual(self.output, """\
Project: B
==========
| 13| Sort descending +B test:test_group7
Project: A
==========
| 12| Sort descending +A test:test_group7
""")
def test_group8(self):
todolist = load_file_to_todolist("test/data/ListCommandGroupTest.txt")
command = ListCommand(["-x", "-g", "project,desc:context", "test_group8"], todolist, self.out, self.error)
command.execute()
self.assertFalse(todolist.dirty)
self.assertEqual(self.output, """\
Project: A, Context: B
======================
| 15| Inner sort 2 +A @B test:test_group8
Project: A, Context: A
======================
| 14| Inner sort 1 +A @A test:test_group8
Project: B, Context: B
======================
| 17| Inner sort 4 +B @B test:test_group8
Project: B, Context: A
======================
| 16| Inner sort 3 +B @A test:test_group8
""")
def test_group9(self):
todolist = load_file_to_todolist("test/data/ListCommandGroupTest.txt")
command = ListCommand(["-x", "-g", "project", "-s", "desc:text", "test_group9"], todolist, self.out, self.error)
command.execute()
self.assertFalse(todolist.dirty)
self.assertEqual(self.output, """\
Project: A
==========
| 19| Inner sort 2 +A test:test_group9
| 18| Inner sort 1 +A test:test_group9
""")
def test_group10(self):
todolist = load_file_to_todolist("test/data/ListCommandGroupTest.txt")
command = ListCommand(["-x", "-g"], todolist, self.out, self.error)
command.execute()
self.assertFalse(todolist.dirty)
self.assertEqual(self.output, "")
self.assertEqual(self.errors, "option -g requires argument\n")
def test_group11(self):
config(p_overrides={('sort', 'group_string'): 'project'})
todolist = load_file_to_todolist("test/data/ListCommandGroupTest.txt")
command = ListCommand(["test:test_group1"], todolist, self.out, self.error)
command.execute()
self.assertFalse(todolist.dirty)
self.assertEqual(self.output, """\
Project: A
==========
| 1| +A only test:test_group1
| 3| +A and +B test:test_group1
Project: B
==========
| 3| +A and +B test:test_group1
| 2| +B only test:test_group1
Project: None
=============
| 4| No project test:test_group1
""")
self.assertEqual(self.errors, "")
def test_group12(self):
todolist = load_file_to_todolist("test/data/ListCommandGroupTest.txt")
command = ListCommand(["-g", ",", "test:test_group1"], todolist, self.out, self.error)
command.execute()
self.assertFalse(todolist.dirty)
self.assertEqual(self.output, """\
| 1| +A only test:test_group1
| 2| +B only test:test_group1
| 3| +A and +B test:test_group1
| 4| No project test:test_group1
""")
self.assertEqual(self.errors, "")
if __name__ == '__main__':
unittest.main()
......@@ -24,7 +24,9 @@ from topydo.lib.Filter import HiddenTagFilter, InstanceFilter
from topydo.lib.printers.PrettyPrinter import pretty_printer_factory
from topydo.lib.prettyprinters.Format import PrettyPrinterFormatFilter
from topydo.lib.TodoListBase import InvalidTodoException
from topydo.lib.Sorter import Sorter
from topydo.lib.Utils import get_terminal_size
from topydo.lib.View import View
class ListCommand(ExpressionCommand):
......@@ -37,6 +39,7 @@ class ListCommand(ExpressionCommand):
self.printer = None
self.sort_expression = config().sort_string()
self.group_expression = config().group_string()
self.show_all = False
self.ids = None
self.format = config().list_format()
......@@ -55,7 +58,7 @@ class ListCommand(ExpressionCommand):
return True
def _process_flags(self):
opts, args = self.getopt('f:F:i:n:Ns:x')
opts, args = self.getopt('f:F:g:i:n:Ns:x')
for opt, value in opts:
if opt == '-x':
......@@ -81,6 +84,8 @@ class ListCommand(ExpressionCommand):
self.printer = None
elif opt == '-F':
self.format = value
elif opt == '-g':
self.group_expression = value
elif opt == '-N':
# 2 lines are assumed to be taken up by printing the next prompt
# display at least one item
......@@ -143,7 +148,16 @@ class ListCommand(ExpressionCommand):
self.printer = pretty_printer_factory(self.todolist, filters)
self.out(self.printer.print_list(self._view().todos))
if self.group_expression:
self.out(self.printer.print_groups(self._view().groups))
else:
self.out(self.printer.print_list(self._view().todos))
def _view(self):
sorter = Sorter(self.sort_expression, self.group_expression)
filters = self._filters()
return View(sorter, filters, self.todolist)
def _N_lines(self):
''' Determine how many lines to print, such that the number of items
......@@ -190,9 +204,9 @@ class ListCommand(ExpressionCommand):
return True
def usage(self):
return """Synopsis: ls [-x] [-s <SORT EXPRESSION>] [-f <OUTPUT FORMAT>]
[-F <FORMAT STRING>] [-i <NUMBER 1>[,<NUMBER 2> ...]] [-N | -n <INTEGER>]
[EXPRESSION]"""
return """Synopsis: ls [-x] [-s <SORT EXPRESSION>]
[-g <GROUP EXPRESSION>] [-f <OUTPUT FORMAT>] [-F <FORMAT STRING>]
[-i <NUMBER 1>[,<NUMBER 2> ...]] [-N | -n <INTEGER>] [EXPRESSION]"""
def help(self):
return """\
......@@ -247,11 +261,14 @@ When an EXPRESSION is given, only the todos matching that EXPRESSION are shown.
(empty string) when an item has no priority set.
A tab character serves as a marker to start right alignment.
-g : Group items according to a GROUP EXPRESSION. A group expression is similar
to a sort expression. Defaults to the group expression in the
configuration.
-i : Comma separated list of todo IDs to print.
-n : Number of items to display. Defaults to the value in the configuration.
-N : Limit number of items displayed such that they fit on the terminal.
-s : Sort the list according to a SORT EXPRESSION. Defaults to the expression
in the configuration.
-s : Sort the list according to a SORT EXPRESSION. Defaults to the sort
expression in the configuration.
-x : Show all todos (i.e. do not filter on dependencies, relevance, or hidden
status).\
"""
......@@ -92,6 +92,7 @@ class _Config:
'sort': {
'keep_sorted': '0',
'sort_string': 'desc:importance,due,desc:priority',
'group_string': '',
'ignore_weekends': '1',
},
......@@ -272,6 +273,9 @@ class _Config:
def sort_string(self):
return self.cp.get('sort', 'sort_string')
def group_string(self):
return self.cp.get('sort', 'group_string')
def ignore_weekends(self):
try:
return self.cp.getboolean('sort', 'ignore_weekends')
......
......@@ -16,56 +16,110 @@
""" This module provides functionality to sort lists with todo items. """
from collections import OrderedDict, namedtuple
from itertools import groupby
import re
from datetime import date
from topydo.lib.Config import config
from topydo.lib.Importance import average_importance, importance
from topydo.lib.Utils import humanize_date
def is_priority_field(p_field):
""" Returns True when the field name denotes the priority. """
return p_field.startswith('prio')
Field = namedtuple('Field', ['sort', 'group', 'label'])
def get_field_function(p_field):
"""
Given a property (string) of a todo, return a function that attempts to
access that property. If the property could not be located, return the
identity function.
"""
result = lambda a: a
if is_priority_field(p_field):
# assign dummy priority when a todo has no priority
result = lambda a: a.priority() or 'ZZ'
elif p_field == 'context' or p_field == 'contexts':
result = lambda a: sorted([c.lower() for c in a.contexts()])
elif p_field == 'creationdate' or p_field == 'creation':
FIELDS = {
'completed': Field(
# when a task has no completion date, push it to the end by assigning it
# the maximum possible date.
sort=(lambda t: t.completion_date() if t.completion_date() else date.max),
group=(lambda t: humanize_date(t.completion_date()) if t.completion_date() else 'None'),
label='Completed',
),
'context': Field(
sort=lambda t: sorted(c.lower() for c in t.contexts()) or ['zz'],
group=lambda t: sorted(t.contexts()) or ['None'],
label='Context'
),
'created': Field(
# when a task has no creation date, push it to the end by assigning it
# the maximum possible date.
result = (lambda a: a.creation_date() if a.creation_date()
else date.max)
elif p_field == 'done' or p_field == 'completed' or p_field == 'completion':
result = (lambda a: a.completion_date() if a.completion_date()
else date.max)
elif p_field == 'importance':
result = importance
elif p_field == 'importance-avg' or p_field == 'importance-average':
result = average_importance
elif p_field == 'length':
result = lambda a: a.length()
elif p_field == 'project' or p_field == 'projects':
result = lambda a: sorted([c.lower() for c in a.projects()])
elif p_field == 'text':
result = lambda a: a.text().lower()
else:
# try to find the corresponding tag
# when a tag is not present, push it to the end of the list by giving
# it an artificially higher value
result = (lambda a: "0" + a.tag_value(p_field) if a.has_tag(p_field)
else "1")
return result
sort=(lambda t: t.creation_date() if t.creation_date() else date.max),
group=(lambda t: humanize_date(t.creation_date()) if t.creation_date() else 'None'),
label='Created',
),
'importance': Field(
sort=importance,
group=importance,
label='Importance',
),
'importance-avg': Field(
sort= average_importance,
group=lambda t: round(average_importance(t), 1),
label='Importance (avg)',
),
'length': Field(
sort=lambda t: t.length(),
group=lambda t: t.length(),
label='Length',
),
'priority': Field(
sort=(lambda t: t.priority() or 'ZZ'),
group=(lambda t: t.priority() or 'None'),
label='Priority',
),
'project': Field(
sort=lambda t: sorted(p.lower() for p in t.projects()) or ['zz'],
group=lambda t: sorted(t.projects()) or ['None'],
label='Project',
),
'text': Field(
sort=lambda t: t.text().lower(),
group=lambda t: t.text(),
label='Text',
),
}
# map UI properties to properties in the FIELDS hash
FIELD_MAP = {
'completed': 'completed',
'completion': 'completed',
'completion_date': 'completed',
'done': 'completed',
'context': 'context',
'contexts': 'context',
'created': 'created',
'creation': 'created',
'creation_date': 'created',
'importance': 'importance',
'importance-avg': 'importance-avg',
'importance-average': 'importance-avg',
'length': 'length',
'len': 'length',
'prio': 'priority',
'priorities': 'priority',
'priority': 'priority',
'project': 'project',
'projects': 'project',
'text': 'text',
}
def _apply_sort_functions(p_todos, p_functions):
sorted_todos = p_todos
for function, order in reversed(p_functions):
sorted_todos = sorted(sorted_todos, key=function,
reverse=(order == 'desc'))
return sorted_todos
class Sorter(object):
......@@ -93,10 +147,10 @@ class Sorter(object):
stable.
"""
def __init__(self, p_sortstring="desc:priority"):
self.sortstring = p_sortstring
self.functions = []
self._parse()
def __init__(self, p_sortstring="desc:priority", p_groupstring=""):
self.groupfunctions = self._parse(p_groupstring, p_group=True) if p_groupstring else []
self.pregroupfunctions = self._parse(p_groupstring, p_group=False) if p_groupstring else []
self.sortfunctions = self._parse(p_sortstring, p_group=False)
def sort(self, p_todos):
"""
......@@ -107,19 +161,85 @@ class Sorter(object):
sort operation is done first, relying on the stability of the sorted()
function.
"""
sorted_todos = p_todos
for function, order in reversed(self.functions):
sorted_todos = sorted(sorted_todos, key=function,
reverse=(order == 'desc'))
return _apply_sort_functions(p_todos, self.sortfunctions)
return sorted_todos
def group(self, p_todos):
"""
Groups the todos according to the given group string.
"""
# preorder todos for the group sort
p_todos = _apply_sort_functions(p_todos, self.pregroupfunctions)
# initialize result with a single group
result = OrderedDict([((), p_todos)])
for (function, label), _ in self.groupfunctions:
oldresult = result
result = OrderedDict()
for oldkey, oldgroup in oldresult.items():
for key, _group in groupby(oldgroup, function):
newgroup = list(_group)
if not isinstance(key, list):
key = [key]
for subkey in key:
subkey = "{}: {}".format(label, subkey)
newkey = oldkey + (subkey,)
if newkey in result:
result[newkey] = result[newkey] + newgroup
else:
result[newkey] = newgroup
# sort all groups
for key, _group in result.items():
result[key] = self.sort(_group)
def _parse(self):
return result
def _parse(self, p_string, p_group):
"""
Parses a sort string and returns a list of functions and the
Parses a sort/group string and returns a list of functions and the
desired order.
"""
fields = self.sortstring.lower().split(',')
def get_field_function(p_field, p_group=False):
"""
Turns a field, part of a sort/group string, into a lambda that
takes a todo item and returns the field value.
"""
compose = lambda i: i.sort if not p_group else (i.group, i.label)
def group_value(p_todo):
"""
Returns a value to assign the given todo to a group. Date tags
are grouped according to the relative date (1 day, 1 month,
...)
"""
result = 'No value'
if p_todo.has_tag(p_field):
if p_field == config().tag_due():
result = humanize_date(p_todo.due_date())
elif p_field == config().tag_start():
result = humanize_date(p_todo.start_date())
else:
result = p_todo.tag_value(p_field)
return result
if p_field in FIELD_MAP:
return compose(FIELDS[FIELD_MAP[p_field]])
else:
# treat it as a tag value
return compose(Field(
sort=lambda t: '0' + t.tag_value(p_field) if t.has_tag(p_field) else '1',
group=group_value,
label=p_field,
))
result = []
fields = p_string.lower().split(',')
for field in fields:
parsed_field = re.match(
......@@ -134,11 +254,14 @@ class Sorter(object):
field = parsed_field.group('field')
if field:
function = get_field_function(field)
function = get_field_function(field, p_group)
# reverse order for priority: lower characters have higher
# priority
if is_priority_field(field):
if field in FIELD_MAP and FIELD_MAP[field] == 'priority':
order = 'asc' if order == 'desc' else 'desc'
self.functions.append((function, order))
result.append((function, order))
return result
......@@ -14,7 +14,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
""" A view is a list of todos, sorted and filtered. """
""" A view is a list of todos, sorted, grouped and filtered. """
class View(object):
......@@ -29,12 +29,22 @@ class View(object):
self._sorter = p_sorter
self._filters = p_filters
@property
def todos(self):
""" Returns a sorted and filtered list of todos in this view. """
result = self._sorter.sort(self.todolist.todos())
def _apply_filters(self, p_todos):
""" Applies the filters to the list of todo items. """
result = p_todos
for _filter in self._filters:
result = _filter.filter(result)
return result
@property
def todos(self):
""" Returns a sorted and filtered list of todos in this view. """
result = self._sorter.sort(self.todolist.todos())
return self._apply_filters(result)
@property
def groups(self):
result = self._apply_filters(self.todolist.todos())
return self._sorter.group(result)
......@@ -14,6 +14,8 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from itertools import chain
from topydo.lib.prettyprinters.Colors import PrettyPrinterColorFilter
from topydo.lib.prettyprinters.Numbers import PrettyPrinterNumbers
from topydo.lib.TopydoString import TopydoString
......@@ -30,8 +32,16 @@ class Printer(object):
raise NotImplementedError
def print_list(self, p_todos):
result = ''
for todo in p_todos:
self.print_todo(todo)
result += self.print_todo(todo)
return result
def print_groups(self, p_groups):
todos = list(chain.from_iterable(p_groups.values()))
return self.print_list(todos)
class PrettyPrinter(Printer):
......@@ -76,6 +86,29 @@ class PrettyPrinter(Printer):
"""
return [self.print_todo(todo) for todo in p_todos]
def print_groups(self, p_groups):
result = []
first = True
def print_header(p_key):
""" Prints a header for the given key. """
if not first:
result.append('')
key_string = ", ".join(p_key)
result.append(key_string)
result.append("=" * len(key_string))
for key, todos in p_groups.items():
if key != ():
# don't print a header for the case that no valid grouping
# could be made (e.g. an invalid group expression)
print_header(key)
first = False
result += self.print_list(todos)
return [TopydoString(s) for s in result]
def pretty_printer_factory(p_todolist, p_additional_filters=None):
""" Returns a pretty printer suitable for the ls and dep subcommands. """
......
......@@ -30,6 +30,7 @@ def columns(p_alt_layout_path=None):
column_dict['title'] = p_cp.get(p_column, 'title')
column_dict['filterexpr'] = p_cp.get(p_column, 'filterexpr')
column_dict['sortexpr'] = p_cp.get(p_column, 'sortexpr')
column_dict['groupexpr'] = p_cp.get(p_column, 'groupexpr')
column_dict['show_all'] = p_cp.getboolean(p_column, 'show_all')
return column_dict
......@@ -38,6 +39,7 @@ def columns(p_alt_layout_path=None):
'title': 'Yet another column',
'filterexpr': '',
'sortexpr': config().sort_string(),
'groupexpr': config().group_string(),
'show_all': '0',
}
......
......@@ -415,7 +415,7 @@ class UIApplication(CLIApplicationBase):
"""
Converts a dictionary describing a view to an actual UIView instance.
"""
sorter = Sorter(p_data['sortexpr'])
sorter = Sorter(p_data['sortexpr'], p_data['groupexpr'])
filters = []
if not p_data['show_all']:
......@@ -609,6 +609,7 @@ class UIApplication(CLIApplicationBase):
dummy = {
"title": "All tasks",
"sortexpr": "desc:prio",
"groupexpr": "",
"filterexpr": "",
"show_all": True,
}
......
......@@ -92,11 +92,17 @@ class TodoListWidget(urwid.LineBox):
del self.todolist[:]
for todo in self.view.todos:
todowidget = TodoWidget.create(todo)
todowidget.number = self.view.todolist.number(todo)
self.todolist.append(todowidget)
self.todolist.append(urwid.Divider('-'))
for group, todos in self.view.groups.items():
if len(self.view.groups) > 1:
grouplabel = ", ".join(group)
self.todolist.append(urwid.Text(grouplabel))
self.todolist.append(urwid.Divider('-'))
for todo in todos:
todowidget = TodoWidget.create(todo)
todowidget.number = self.view.todolist.number(todo)
self.todolist.append(todowidget)
self.todolist.append(urwid.Divider('-'))
if old_focus_position:
try:
......
......@@ -24,16 +24,18 @@ class ViewWidget(urwid.LineBox):
self.titleedit = urwid.Edit("Title: ", "")
self.sortedit = urwid.Edit("Sort expression: ", "")
self.groupedit = urwid.Edit("Group expression: ", "")
self.filteredit = urwid.Edit("Filter expression: ", "")
group = []
self.relevantradio = urwid.RadioButton(group, "Only show relevant todo items", True)
self.allradio = urwid.RadioButton(group, "Show all todo items")
radiogroup = []
self.relevantradio = urwid.RadioButton(radiogroup, "Only show relevant todo items", True)
self.allradio = urwid.RadioButton(radiogroup, "Show all todo items")
self.pile = urwid.Pile([
self.filteredit,
self.titleedit,
self.sortedit,
self.groupedit,
self.relevantradio,
self.allradio,
urwid.Button("Save", lambda _: urwid.emit_signal(self, 'save')),
......@@ -51,6 +53,7 @@ class ViewWidget(urwid.LineBox):
return {
'title': self.titleedit.edit_text or self.filteredit.edit_text,
'sortexpr': self.sortedit.edit_text or config().sort_string(),
'groupexpr': self.groupedit.edit_text or config().group_string(),
'filterexpr': self.filteredit.edit_text,
'show_all': self.allradio.state,
}
......@@ -59,6 +62,7 @@ class ViewWidget(urwid.LineBox):
def data(self, p_data):
self.titleedit.edit_text = p_data['title']
self.sortedit.edit_text = p_data['sortexpr']
self.groupedit.edit_text = p_data['groupexpr']
self.filteredit.edit_text = p_data['filterexpr']
self.relevantradio.set_state(not p_data['show_all'])
self.allradio.set_state(p_data['show_all'])
......
[all]
title = All tasks
filterexpr =
groupexpr =
[today]
title = Due today
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment