Commit 25265e12 authored by Bram Schoenmakers's avatar Bram Schoenmakers

Merge branch 'master' of github.com:bram85/topydo

parents 12d7edf0 9d0f961b
test/data/* text eol=lf
test/data/*.ics binary
...@@ -4,7 +4,14 @@ python: ...@@ -4,7 +4,14 @@ python:
- "3.3" - "3.3"
- "3.4" - "3.4"
- "3.5" - "3.5"
- "pypy3" env:
- GREEN_OPTS=
matrix:
include:
- python: "pypy3"
env: GREEN_OPTS='--processes 1'
install: install:
- "python -m pip install pip --upgrade" - "python -m pip install pip --upgrade"
- "pip install ." - "pip install ."
...@@ -15,7 +22,7 @@ install: ...@@ -15,7 +22,7 @@ install:
- "pip install pylint" - "pip install pylint"
- "pip install codecov" - "pip install codecov"
script: script:
- "green -vvr" - "green -vvr $GREEN_OPTS"
- "python -m pylint --errors-only topydo test" - "python -m pylint --errors-only topydo test"
# Cache Dependencies # Cache Dependencies
after_script: after_script:
......
0.11
----
* New: `ls` can group items with the `-g` flag, accepting a group expression
(which has the same format as a sort expression). To group items by project,
run: `topydo ls -g project`.
* New: `ls` can print todo items in the Graphviz Dot format, such that
dependencies can be visualized. Use `ls -f dot`, or
`topydo ls -f dot +ProjectA | dot -Tsvg -o projectA.svg` to make a graph for
project A.
* New: Focus and mark colors are customizable in the column UI (thanks to
@colinsullivan).
* New: todo items can be hidden by adding a `h:1` tag (thanks to @MinchinWeb).
* New: an alternative column definition file can be given with the `-l` flag:
`topydo columns -ls /path/to/columns.conf` (thanks to @mruwek).
* Fix: column UI reloads automatically when the todo.txt file was changed
externally.
* Fix: `edit` did not work on some operating systems (e.g. Mac OS X).
* Fix: relative dates were sometimes one day off.
* Fix: Minor importance calculation fix during the weekend for distant mondays
(thanks to @aetherknight).
* Fix: tests were made more deterministic.
* Change: Performance improvements for the column UI, it scales better with
large todo lists.
* Change: temporary files (for editing) will be detected as todo.txt files by
the todo.txt-vim plugin.
0.10.1 0.10.1
------ ------
......
...@@ -20,6 +20,8 @@ def find_version(*file_paths): ...@@ -20,6 +20,8 @@ def find_version(*file_paths):
return version_match.group(1) return version_match.group(1)
raise RuntimeError("Unable to find version string.") raise RuntimeError("Unable to find version string.")
WATCHDOG = 'watchdog >= 0.8.3'
setup( setup(
name = "topydo", name = "topydo",
packages = find_packages(exclude=["test"]), packages = find_packages(exclude=["test"]),
...@@ -34,9 +36,9 @@ setup( ...@@ -34,9 +36,9 @@ setup(
extras_require = { extras_require = {
':sys_platform=="win32"': ['colorama>=0.2.5'], ':sys_platform=="win32"': ['colorama>=0.2.5'],
':python_version=="3.2"': ['backports.shutil_get_terminal_size>=1.0.0'], ':python_version=="3.2"': ['backports.shutil_get_terminal_size>=1.0.0'],
'columns': ['urwid >= 1.3.0'], 'columns': ['urwid >= 1.3.0', WATCHDOG],
'ical': ['icalendar'], 'ical': ['icalendar'],
'prompt': ['prompt_toolkit >= 0.53'], 'prompt': ['prompt_toolkit >= 0.53', WATCHDOG],
'test': ['coverage', 'freezegun', 'green', ], 'test': ['coverage', 'freezegun', 'green', ],
'test:python_version=="3.2"': ['mock'], 'test:python_version=="3.2"': ['mock'],
}, },
......
...@@ -14,6 +14,7 @@ ...@@ -14,6 +14,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
from test.topydo_testcase import TopydoTest from test.topydo_testcase import TopydoTest
from topydo.lib.Utils import escape_ansi from topydo.lib.Utils import escape_ansi
...@@ -27,12 +28,12 @@ class CommandTest(TopydoTest): ...@@ -27,12 +28,12 @@ class CommandTest(TopydoTest):
def out(self, p_output): def out(self, p_output):
if isinstance(p_output, list) and p_output: if isinstance(p_output, list) and p_output:
self.output += escape_ansi( self.output += escape_ansi(
"\n".join([str(s) for s in p_output]) + "\n") os.linesep.join([str(s) for s in p_output]) + os.linesep)
elif p_output: elif p_output:
self.output += str(p_output) + "\n" self.output += str(p_output) + os.linesep
def error(self, p_error): def error(self, p_error):
if isinstance(p_error, list) and p_error: if isinstance(p_error, list) and p_error:
self.errors += escape_ansi(p_error + "\n") + "\n" self.errors += escape_ansi(p_error + os.linesep) + os.linesep
elif p_error: elif p_error:
self.errors += str(p_error) + "\n" self.errors += str(p_error) + os.linesep
...@@ -4,3 +4,5 @@ project_color = ...@@ -4,3 +4,5 @@ project_color =
context_color = context_color =
link_color = link_color =
metadata_color = metadata_color =
focus_background_color =
marked_background_color =
...@@ -24,3 +24,5 @@ project_color = junk ...@@ -24,3 +24,5 @@ project_color = junk
context_color = junk context_color = junk
metadata_color = junk metadata_color = junk
link_color = junk link_color = junk
focus_background_color = junk
marked_background_color = junk
(C) 2015-11-05 Foo @Context2 Not@Context +Project1 Not+Project due:2016-11-18 t:2016-11-17
(D) Bar @Context1 +Project2 p:1
(C) Baz @Context1 +Project1 key:value id:1
(C) Drink beer @ home
(C) 13 + 29 = 42
x 2014-12-12 Completed but with date:2014-12-12
+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
digraph topydo {
node [ shape="none" margin="0" fontsize="9" fontname="Helvetica" ]
_1 [label=<<TABLE CELLBORDER="0" CELLSPACING="1" VALIGN="top"><TR><TD><B>1</B></TD><TD BALIGN="LEFT"><B>Foo @Context2 Not@Context +Project1<BR />Not+Project</B></TD></TR><HR/><TR><TD ALIGN="RIGHT">Prio:</TD><TD ALIGN="LEFT">C</TD></TR><TR><TD ALIGN="RIGHT">Starts:</TD><TD ALIGN="LEFT">2016-11-17 (today)</TD></TR><TR><TD ALIGN="RIGHT">Due:</TD><TD ALIGN="LEFT">2016-11-18 (in a day)</TD></TR></TABLE>> style=filled fillcolor="#008000" fontcolor="#ffffff"]
_3 [label=<<TABLE CELLBORDER="0" CELLSPACING="1" VALIGN="top"><TR><TD><B>3</B></TD><TD BALIGN="LEFT"><B>Baz @Context1 +Project1</B></TD></TR><HR/><TR><TD ALIGN="RIGHT">Prio:</TD><TD ALIGN="LEFT">C</TD></TR></TABLE>> style=filled fillcolor="#008000" fontcolor="#ffffff"]
_4 [label=<<TABLE CELLBORDER="0" CELLSPACING="1" VALIGN="top"><TR><TD><B>4</B></TD><TD BALIGN="LEFT"><B>Drink beer @ home</B></TD></TR><HR/><TR><TD ALIGN="RIGHT">Prio:</TD><TD ALIGN="LEFT">C</TD></TR></TABLE>> style=filled fillcolor="#008000" fontcolor="#ffffff"]
_5 [label=<<TABLE CELLBORDER="0" CELLSPACING="1" VALIGN="top"><TR><TD><B>5</B></TD><TD BALIGN="LEFT"><B>13 + 29 = 42</B></TD></TR><HR/><TR><TD ALIGN="RIGHT">Prio:</TD><TD ALIGN="LEFT">C</TD></TR></TABLE>> style=filled fillcolor="#008000" fontcolor="#ffffff"]
_2 [label=<<TABLE CELLBORDER="0" CELLSPACING="1" VALIGN="top"><TR><TD><B>2</B></TD><TD BALIGN="LEFT"><B>Bar @Context1 +Project2</B></TD></TR><HR/><TR><TD ALIGN="RIGHT">Prio:</TD><TD ALIGN="LEFT">D</TD></TR></TABLE>> style=filled fillcolor="#008000" fontcolor="#ffffff"]
_6 [label=<<TABLE CELLBORDER="0" CELLSPACING="1" VALIGN="top"><TR><TD><B>6</B></TD><TD BALIGN="LEFT"><B><S>Completed but with</S></B></TD></TR></TABLE>> style=filled fillcolor="#008000" fontcolor="#ffffff"]
_3 -> _2
_1 -> _4 [style="invis"]
_4 -> _5 [style="invis"]
_5 -> _6 [style="invis"]
}
...@@ -5,3 +5,4 @@ ...@@ -5,3 +5,4 @@
(C) 13 + 29 = 42 (C) 13 + 29 = 42
x 2014-12-12 Completed but with date:2014-12-12 x 2014-12-12 Completed but with date:2014-12-12
hidden item h:1
First first
(A) Foo (A) Foo
2014-06-14 Last 2014-06-14 Last
(A) Foo (A) Foo
2014-06-14 Last 2014-06-14 Last
First first
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from topydo.lib.PrettyPrinter import PrettyPrinter from topydo.lib.printers.PrettyPrinter import PrettyPrinter
from topydo.lib.Todo import Todo from topydo.lib.Todo import Todo
from topydo.lib.TodoFile import TodoFile from topydo.lib.TodoFile import TodoFile
from topydo.lib.TodoList import TodoList from topydo.lib.TodoList import TodoList
......
...@@ -154,6 +154,14 @@ class ColorsTest(TopydoTest): ...@@ -154,6 +154,14 @@ class ColorsTest(TopydoTest):
self.assertEqual(color_b, '') self.assertEqual(color_b, '')
self.assertEqual(color_c, '') self.assertEqual(color_c, '')
def test_focus_color(self):
config(p_overrides={('colorscheme', 'focus_background_color'): 'gray'})
self.assertEqual(config().focus_background_color().as_ansi(), '\033[0;37m')
def test_mark_color(self):
config(p_overrides={('colorscheme', 'marked_background_color'): 'blue'})
self.assertEqual(config().marked_background_color().as_ansi(), '\033[0;34m')
def test_empty_color_values(self): def test_empty_color_values(self):
config("test/data/ColorsTest5.conf") config("test/data/ColorsTest5.conf")
project_color = config().project_color().as_ansi(p_decoration='bold') project_color = config().project_color().as_ansi(p_decoration='bold')
......
...@@ -128,6 +128,18 @@ class ConfigTest(TopydoTest): ...@@ -128,6 +128,18 @@ class ConfigTest(TopydoTest):
self.assertEqual(config("test/data/ConfigTest5.conf").link_color().color, 6) self.assertEqual(config("test/data/ConfigTest5.conf").link_color().color, 6)
def test_config24(self): def test_config24(self):
""" No focus background color value. """
self.assertEqual(config("test/data/ConfigTest5.conf").focus_background_color().color, 7)
def test_config25(self):
""" No mark background color value. """
self.assertEqual(config("test/data/ConfigTest5.conf").marked_background_color().color, 4)
def test_config26(self):
self.assertTrue(config("test/data/ConfigTest4.conf").focus_background_color().is_neutral())
self.assertTrue(config("test/data/ConfigTest4.conf").marked_background_color().is_neutral())
def test_config27(self):
""" column_keymap test. """ """ column_keymap test. """
keymap, keystates = config("test/data/ConfigTest6.conf").column_keymap() keymap, keystates = config("test/data/ConfigTest6.conf").column_keymap()
......
...@@ -295,6 +295,39 @@ class DepCommandTest(CommandTest): ...@@ -295,6 +295,39 @@ class DepCommandTest(CommandTest):
self.assertEqual(self.output, "") self.assertEqual(self.output, "")
self.assertEqual(self.errors, command.usage() + "\n") self.assertEqual(self.errors, command.usage() + "\n")
def test_dot1(self):
command = DepCommand(["dot"], self.todolist, self.out, self.error)
command.execute()
self.assertFalse(self.todolist.dirty)
self.assertEqual(self.output, "")
self.assertEqual(self.errors, command.usage() + "\n")
def test_dot2(self):
self.maxDiff = None
command = DepCommand(["dot", "1"], self.todolist, self.out, self.error)
command.execute()
self.assertFalse(self.todolist.dirty)
self.assertEqual(self.output, """digraph topydo {
node [ shape="none" margin="0" fontsize="9" fontname="Helvetica" ]
_2 [label=<<TABLE CELLBORDER="0" CELLSPACING="1" VALIGN="top"><TR><TD><B>2</B></TD><TD BALIGN="LEFT"><B>Bar</B></TD></TR></TABLE>> style=filled fillcolor="#008000" fontcolor="#ffffff"]
_3 [label=<<TABLE CELLBORDER="0" CELLSPACING="1" VALIGN="top"><TR><TD><B>3</B></TD><TD BALIGN="LEFT"><B>Baz</B></TD></TR></TABLE>> style=filled fillcolor="#008000" fontcolor="#ffffff"]
_1 [label=<<TABLE CELLBORDER="0" CELLSPACING="1" VALIGN="top"><TR><TD><B>1</B></TD><TD BALIGN="LEFT"><B>Foo</B></TD></TR></TABLE>> style=filled fillcolor="#008000" fontcolor="#ffffff"]
_1 -> _2
_1 -> _3
}\n
""")
self.assertEqual(self.errors, "")
def test_dot3(self):
command = DepCommand(["dot", "99"], self.todolist, self.out, self.error)
command.execute()
self.assertFalse(self.todolist.dirty)
self.assertEqual(self.output, "")
self.assertEqual(self.errors, "Invalid todo number given." + "\n")
def gc_helper(self, p_subcommand): def gc_helper(self, p_subcommand):
command = DepCommand([p_subcommand], self.todolist, self.out, command = DepCommand([p_subcommand], self.todolist, self.out,
self.error) self.error)
......
...@@ -56,5 +56,39 @@ class ImportanceTest(TopydoTest): ...@@ -56,5 +56,39 @@ class ImportanceTest(TopydoTest):
todo = Todo("(C) Foo " + config().tag_due() + ":" + "2015-11-09") todo = Todo("(C) Foo " + config().tag_due() + ":" + "2015-11-09")
self.assertEqual(importance(todo), 6) self.assertEqual(importance(todo), 6)
@freeze_time("2016, 10, 21")
class ImportanceWeekendFridayTest(TopydoTest):
def test_importance_ignore_weekends_due_not_next_monday(self):
# Today is friday
# due on a monday, but over a month away.
# So 2 + 0 (no priority) + 0 (no star) + 0 (due > 14 days)
config(p_overrides={('sort', 'ignore_weekends'): '1'})
todo = Todo("Foo " + config().tag_due() + ":" + "2016-11-28")
self.assertEqual(importance(todo), 2)
@freeze_time("2016, 10, 22")
class ImportanceWeekendSaturdayTest(TopydoTest):
def test_importance_ignore_weekends_due_not_next_monday(self):
# Today is saturday
# due on a monday, but over a month away.
# So 2 + 0 (no priority) + 0 (no star) + 0 (due > 14 days)
config(p_overrides={('sort', 'ignore_weekends'): '1'})
todo = Todo("Foo " + config().tag_due() + ":" + "2016-11-28")
self.assertEqual(importance(todo), 2)
@freeze_time("2016, 10, 23")
class ImportanceWeekendSundayTest(TopydoTest):
def test_importance_ignore_weekends_due_not_next_monday(self):
# Today is sunday
# due on a monday, but over a month away.
# So 2 + 0 (no priority) + 0 (no star) + 0 (due > 14 days)
config(p_overrides={('sort', 'ignore_weekends'): '1'})
todo = Todo("Foo " + config().tag_due() + ":" + "2016-11-28")
self.assertEqual(importance(todo), 2)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
import unittest import unittest
from test.topydo_testcase import TopydoTest from test.topydo_testcase import TopydoTest
from topydo.lib.JsonPrinter import JsonPrinter from topydo.lib.printers.Json import JsonPrinter
from topydo.lib.Todo import Todo from topydo.lib.Todo import Todo
......
This diff is collapsed.
...@@ -33,14 +33,14 @@ class SortCommandTest(CommandTest): ...@@ -33,14 +33,14 @@ class SortCommandTest(CommandTest):
command.execute() command.execute()
self.assertEqual(self.todolist.print_todos(), self.assertEqual(self.todolist.print_todos(),
"First\n(A) Foo\n2014-06-14 Last") "first\n(A) Foo\n2014-06-14 Last")
def test_sort2(self): def test_sort2(self):
command = SortCommand([], self.todolist, self.out, self.error) command = SortCommand([], self.todolist, self.out, self.error)
command.execute() command.execute()
self.assertEqual(self.todolist.print_todos(), self.assertEqual(self.todolist.print_todos(),
"(A) Foo\n2014-06-14 Last\nFirst") "(A) Foo\n2014-06-14 Last\nfirst")
def test_sort3(self): def test_sort3(self):
""" Check that order does not influence the UID of a todo. """ """ Check that order does not influence the UID of a todo. """
......
...@@ -50,6 +50,8 @@ append_parent_contexts = 0 ...@@ -50,6 +50,8 @@ append_parent_contexts = 0
; context_color = magenta ; context_color = magenta
; metadata_color = green ; metadata_color = green
; link_color = light-cyan ; link_color = light-cyan
; focus_background_color = gray
; marked_background_color = blue
[aliases] [aliases]
;showall = ls -x ;showall = ls -x
......
...@@ -17,7 +17,8 @@ ...@@ -17,7 +17,8 @@
from topydo.lib import Filter from topydo.lib import Filter
from topydo.lib.Command import Command, InvalidCommandArgument from topydo.lib.Command import Command, InvalidCommandArgument
from topydo.lib.Config import config from topydo.lib.Config import config
from topydo.lib.PrettyPrinter import pretty_printer_factory from topydo.lib.printers.Dot import DotPrinter
from topydo.lib.printers.PrettyPrinter import pretty_printer_factory
from topydo.lib.Sorter import Sorter from topydo.lib.Sorter import Sorter
from topydo.lib.TodoListBase import InvalidTodoException from topydo.lib.TodoListBase import InvalidTodoException
from topydo.lib.View import View from topydo.lib.View import View
...@@ -130,6 +131,26 @@ class DepCommand(Command): ...@@ -130,6 +131,26 @@ class DepCommand(Command):
except InvalidCommandArgument: except InvalidCommandArgument:
self.error(self.usage()) self.error(self.usage())
def _handle_dot(self):
""" Handles the dot subsubcommand. """
self.printer = DotPrinter(self.todolist)
try:
arg = self.argument(1)
todo = self.todolist.todo(arg)
arg = self.argument(1)
todos = set([self.todolist.todo(arg)])
todos |= set(self.todolist.children(todo))
todos |= set(self.todolist.parents(todo))
todos = sorted(todos, key=lambda t: t.text())
self.out(self.printer.print_list(todos))
except InvalidTodoException:
self.error("Invalid todo number given.")
except InvalidCommandArgument:
self.error(self.usage())
def execute(self): def execute(self):
if not super().execute(): if not super().execute():
return False return False
...@@ -140,6 +161,7 @@ class DepCommand(Command): ...@@ -140,6 +161,7 @@ class DepCommand(Command):
'del': self._handle_rm, 'del': self._handle_rm,
'ls': self._handle_ls, 'ls': self._handle_ls,
'clean': self.todolist.clean_dependencies, 'clean': self.todolist.clean_dependencies,
'dot': self._handle_dot,
'gc': self.todolist.clean_dependencies, 'gc': self.todolist.clean_dependencies,
} }
...@@ -154,6 +176,7 @@ class DepCommand(Command): ...@@ -154,6 +176,7 @@ class DepCommand(Command):
dep add <NUMBER> <before|partof|after|parents-of|children-of> <NUMBER> dep add <NUMBER> <before|partof|after|parents-of|children-of> <NUMBER>
dep ls <NUMBER> to dep ls <NUMBER> to
dep ls to <NUMBER> dep ls to <NUMBER>
dep dot <NUMBER>
dep clean""" dep clean"""
def help(self): def help(self):
...@@ -163,5 +186,6 @@ class DepCommand(Command): ...@@ -163,5 +186,6 @@ class DepCommand(Command):
item 1. item 1.
* rm (alias: del) : Removes a dependency. * rm (alias: del) : Removes a dependency.
* ls : Lists all dependencies to or from a certain todo. * ls : Lists all dependencies to or from a certain todo.
* dot : Prints a dependency tree as a Dot graph.
* clean (alias: gc) : Removes redundant id or p tags.\ * clean (alias: gc) : Removes redundant id or p tags.\
""" """
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
from datetime import date from datetime import date
from topydo.lib.DCommand import DCommand from topydo.lib.DCommand import DCommand
from topydo.lib.PrettyPrinter import PrettyPrinter from topydo.lib.printers.PrettyPrinter import PrettyPrinter
from topydo.lib.prettyprinters.Numbers import PrettyPrinterNumbers from topydo.lib.prettyprinters.Numbers import PrettyPrinterNumbers
from topydo.lib.Recurrence import NoRecurrenceException, advance_recurring_todo from topydo.lib.Recurrence import NoRecurrenceException, advance_recurring_todo
from topydo.lib.RelativeDate import relative_date_to_date from topydo.lib.RelativeDate import relative_date_to_date
......
...@@ -15,6 +15,7 @@ ...@@ -15,6 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import os import os
import codecs
import tempfile import tempfile
from subprocess import CalledProcessError, check_call from subprocess import CalledProcessError, check_call
...@@ -54,16 +55,16 @@ class EditCommand(MultiCommand): ...@@ -54,16 +55,16 @@ class EditCommand(MultiCommand):
self.multi_mode = False self.multi_mode = False
def _todos_to_temp(self): def _todos_to_temp(self):
f = tempfile.NamedTemporaryFile() f = tempfile.NamedTemporaryFile(delete=False, suffix='.todo.txt')
for todo in self.todos: for todo in self.todos:
f.write((todo.source() + "\n").encode('utf-8')) f.write((todo.source() + "\n").encode('utf-8'))
f.seek(0) f.close()
return f return f
def _todos_from_temp(self, p_temp_file): def _todos_from_temp(self, p_temp_file):
p_temp_file.seek(0) f = codecs.open(p_temp_file.name, encoding='utf-8')
todos = p_temp_file.read().decode('utf-8').splitlines() todos = f.read().splitlines()
todo_objs = [] todo_objs = []
for todo in todos: for todo in todos:
...@@ -121,6 +122,8 @@ class EditCommand(MultiCommand): ...@@ -121,6 +122,8 @@ class EditCommand(MultiCommand):
else: else:
self.error(self.usage()) self.error(self.usage())
os.unlink(temp_todos.name)
def _execute_not_multi(self): def _execute_not_multi(self):
if self.edit_archive: if self.edit_archive:
archive = config().archive() archive = config().archive()
......
...@@ -20,11 +20,13 @@ import os ...@@ -20,11 +20,13 @@ import os
from topydo.lib.Config import config from topydo.lib.Config import config
from topydo.lib.ExpressionCommand import ExpressionCommand from topydo.lib.ExpressionCommand import ExpressionCommand
from topydo.lib.Filter import InstanceFilter from topydo.lib.Filter import HiddenTagFilter, InstanceFilter
from topydo.lib.PrettyPrinter import pretty_printer_factory from topydo.lib.printers.PrettyPrinter import pretty_printer_factory
from topydo.lib.prettyprinters.Format import PrettyPrinterFormatFilter from topydo.lib.prettyprinters.Format import PrettyPrinterFormatFilter
from topydo.lib.TodoListBase import InvalidTodoException from topydo.lib.TodoListBase import InvalidTodoException
from topydo.lib.Sorter import Sorter
from topydo.lib.Utils import get_terminal_size from topydo.lib.Utils import get_terminal_size
from topydo.lib.View import View
class ListCommand(ExpressionCommand): class ListCommand(ExpressionCommand):
...@@ -37,6 +39,7 @@ class ListCommand(ExpressionCommand): ...@@ -37,6 +39,7 @@ class ListCommand(ExpressionCommand):
self.printer = None self.printer = None
self.sort_expression = config().sort_string() self.sort_expression = config().sort_string()
self.group_expression = config().group_string()
self.show_all = False self.show_all = False
self.ids = None self.ids = None
self.format = config().list_format() self.format = config().list_format()
...@@ -55,7 +58,7 @@ class ListCommand(ExpressionCommand): ...@@ -55,7 +58,7 @@ class ListCommand(ExpressionCommand):
return True return True
def _process_flags(self): 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: for opt, value in opts:
if opt == '-x': if opt == '-x':
...@@ -64,16 +67,25 @@ class ListCommand(ExpressionCommand): ...@@ -64,16 +67,25 @@ class ListCommand(ExpressionCommand):
self.sort_expression = value self.sort_expression = value
elif opt == '-f': elif opt == '-f':
if value == 'json': if value == 'json':
from topydo.lib.JsonPrinter import JsonPrinter from topydo.lib.printers.Json import JsonPrinter
self.printer = JsonPrinter() self.printer = JsonPrinter()
elif value == 'ical': elif value == 'ical':
if self._poke_icalendar(): if self._poke_icalendar():
from topydo.lib.IcalPrinter import IcalPrinter from topydo.lib.printers.Ical import IcalPrinter
self.printer = IcalPrinter(self.todolist) self.printer = IcalPrinter(self.todolist)
elif value == 'dot':
from topydo.lib.printers.Dot import DotPrinter
self.printer = DotPrinter(self.todolist)
# a graph without dependencies is not so useful, hence
# show all
self.show_all = True
else: else:
self.printer = None self.printer = None
elif opt == '-F': elif opt == '-F':
self.format = value self.format = value
elif opt == '-g':
self.group_expression = value
elif opt == '-N': elif opt == '-N':
# 2 lines are assumed to be taken up by printing the next prompt # 2 lines are assumed to be taken up by printing the next prompt
# display at least one item # display at least one item
...@@ -93,8 +105,9 @@ class ListCommand(ExpressionCommand): ...@@ -93,8 +105,9 @@ class ListCommand(ExpressionCommand):
def _filters(self): def _filters(self):
""" """
Additional filters to select particular todo items given with the -i Additional filters to:
flag. - select particular todo items given with the -i flag,
- hide appropriately tagged items in the absense of the -x flag.
""" """
filters = super()._filters() filters = super()._filters()
...@@ -112,6 +125,9 @@ class ListCommand(ExpressionCommand): ...@@ -112,6 +125,9 @@ class ListCommand(ExpressionCommand):
todos = [get_todo(i) for i in self.ids] todos = [get_todo(i) for i in self.ids]
filters.append(InstanceFilter(todos)) filters.append(InstanceFilter(todos))
if not self.show_all:
filters.append(HiddenTagFilter())
return filters return filters
def _print(self): def _print(self):
...@@ -132,7 +148,16 @@ class ListCommand(ExpressionCommand): ...@@ -132,7 +148,16 @@ class ListCommand(ExpressionCommand):
self.printer = pretty_printer_factory(self.todolist, filters) 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): def _N_lines(self):
''' Determine how many lines to print, such that the number of items ''' Determine how many lines to print, such that the number of items
...@@ -179,9 +204,9 @@ class ListCommand(ExpressionCommand): ...@@ -179,9 +204,9 @@ class ListCommand(ExpressionCommand):
return True return True
def usage(self): def usage(self):
return """Synopsis: ls [-x] [-s <SORT EXPRESSION>] [-f <OUTPUT FORMAT>] return """Synopsis: ls [-x] [-s <SORT EXPRESSION>]
[-F <FORMAT STRING>] [-i <NUMBER 1>[,<NUMBER 2> ...]] [-N | -n <INTEGER>] [-g <GROUP EXPRESSION>] [-f <OUTPUT FORMAT>] [-F <FORMAT STRING>]
[EXPRESSION]""" [-i <NUMBER 1>[,<NUMBER 2> ...]] [-N | -n <INTEGER>] [EXPRESSION]"""
def help(self): def help(self):
return """\ return """\
...@@ -193,9 +218,12 @@ Lists all relevant todos. A todo is relevant when: ...@@ -193,9 +218,12 @@ Lists all relevant todos. A todo is relevant when:
When an EXPRESSION is given, only the todos matching that EXPRESSION are shown. When an EXPRESSION is given, only the todos matching that EXPRESSION are shown.
-f : Specify the OUTPUT FORMAT, being 'text' (default), 'ical' or 'json'. -f : Specify the OUTPUT format, being 'text' (default), 'dot' or 'ical' or
'json'.
* 'text' - Text output with colors and indentation if applicable. * 'text' - Text output with colors and indentation if applicable.
* 'dot' - Prints a dependency graph for the selected items in GraphViz
Dot format.
* 'ical' - iCalendar (RFC 2445). Is not supported in Python 3.2. Be aware * 'ical' - iCalendar (RFC 2445). Is not supported in Python 3.2. Be aware
that this is not a read-only operation, todo items may obtain that this is not a read-only operation, todo items may obtain
an 'ical' tag with a unique ID. Completed todo items may be an 'ical' tag with a unique ID. Completed todo items may be
...@@ -233,10 +261,14 @@ When an EXPRESSION is given, only the todos matching that EXPRESSION are shown. ...@@ -233,10 +261,14 @@ When an EXPRESSION is given, only the todos matching that EXPRESSION are shown.
(empty string) when an item has no priority set. (empty string) when an item has no priority set.
A tab character serves as a marker to start right alignment. 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. -i : Comma separated list of todo IDs to print.
-n : Number of items to display. Defaults to the value in the configuration. -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. -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 -s : Sort the list according to a SORT EXPRESSION. Defaults to the sort
in the configuration. expression in the configuration.
-x : Show all todos (i.e. do not filter on dependencies or relevance).\ -x : Show all todos (i.e. do not filter on dependencies, relevance, or hidden
status).\
""" """
...@@ -45,6 +45,62 @@ class Color: ...@@ -45,6 +45,62 @@ class Color:
'white': 15, 'white': 15,
} }
# Source: https://gist.github.com/jasonm23/2868981
html_color_dict = {
0: "#000000", 1: "#800000", 2: "#008000", 3: "#808000", 4: "#000080",
5: "#800080", 6: "#008080", 7: "#c0c0c0", 8: "#808080", 9: "#ff0000",
10: "#00ff00", 11: "#ffff00", 12: "#0000ff", 13: "#ff00ff", 14: "#00ffff",
15: "#ffffff", 16: "#000000", 17: "#00005f", 18: "#000087", 19: "#0000af",
20: "#0000d7", 21: "#0000ff", 22: "#005f00", 23: "#005f5f", 24: "#005f87",
25: "#005faf", 26: "#005fd7", 27: "#005fff", 28: "#008700", 29: "#00875f",
30: "#008787", 31: "#0087af", 32: "#0087d7", 33: "#0087ff", 34: "#00af00",
35: "#00af5f", 36: "#00af87", 37: "#00afaf", 38: "#00afd7", 39: "#00afff",
40: "#00d700", 41: "#00d75f", 42: "#00d787", 43: "#00d7af", 44: "#00d7d7",
45: "#00d7ff", 46: "#00ff00", 47: "#00ff5f", 48: "#00ff87", 49: "#00ffaf",
50: "#00ffd7", 51: "#00ffff", 52: "#5f0000", 53: "#5f005f", 54: "#5f0087",
55: "#5f00af", 56: "#5f00d7", 57: "#5f00ff", 58: "#5f5f00", 59: "#5f5f5f",
60: "#5f5f87", 61: "#5f5faf", 62: "#5f5fd7", 63: "#5f5fff", 64: "#5f8700",
65: "#5f875f", 66: "#5f8787", 67: "#5f87af", 68: "#5f87d7", 69: "#5f87ff",
70: "#5faf00", 71: "#5faf5f", 72: "#5faf87", 73: "#5fafaf", 74: "#5fafd7",
75: "#5fafff", 76: "#5fd700", 77: "#5fd75f", 78: "#5fd787", 79: "#5fd7af",
80: "#5fd7d7", 81: "#5fd7ff", 82: "#5fff00", 83: "#5fff5f", 84: "#5fff87",
85: "#5fffaf", 86: "#5fffd7", 87: "#5fffff", 88: "#870000", 89: "#87005f",
90: "#870087", 91: "#8700af", 92: "#8700d7", 93: "#8700ff", 94: "#875f00",
95: "#875f5f", 96: "#875f87", 97: "#875faf", 98: "#875fd7", 99: "#875fff",
100: "#878700", 101: "#87875f", 102: "#878787", 103: "#8787af", 104: "#8787d7",
105: "#8787ff", 106: "#87af00", 107: "#87af5f", 108: "#87af87", 109: "#87afaf",
110: "#87afd7", 111: "#87afff", 112: "#87d700", 113: "#87d75f", 114: "#87d787",
115: "#87d7af", 116: "#87d7d7", 117: "#87d7ff", 118: "#87ff00", 119: "#87ff5f",
120: "#87ff87", 121: "#87ffaf", 122: "#87ffd7", 123: "#87ffff", 124: "#af0000",
125: "#af005f", 126: "#af0087", 127: "#af00af", 128: "#af00d7", 129: "#af00ff",
130: "#af5f00", 131: "#af5f5f", 132: "#af5f87", 133: "#af5faf", 134: "#af5fd7",
135: "#af5fff", 136: "#af8700", 137: "#af875f", 138: "#af8787", 139: "#af87af",
140: "#af87d7", 141: "#af87ff", 142: "#afaf00", 143: "#afaf5f", 144: "#afaf87",
145: "#afafaf", 146: "#afafd7", 147: "#afafff", 148: "#afd700", 149: "#afd75f",
150: "#afd787", 151: "#afd7af", 152: "#afd7d7", 153: "#afd7ff", 154: "#afff00",
155: "#afff5f", 156: "#afff87", 157: "#afffaf", 158: "#afffd7", 159: "#afffff",
160: "#d70000", 161: "#d7005f", 162: "#d70087", 163: "#d700af", 164: "#d700d7",
165: "#d700ff", 166: "#d75f00", 167: "#d75f5f", 168: "#d75f87", 169: "#d75faf",
170: "#d75fd7", 171: "#d75fff", 172: "#d78700", 173: "#d7875f", 174: "#d78787",
175: "#d787af", 176: "#d787d7", 177: "#d787ff", 178: "#dfaf00", 179: "#dfaf5f",
180: "#dfaf87", 181: "#dfafaf", 182: "#dfafdf", 183: "#dfafff", 184: "#dfdf00",
185: "#dfdf5f", 186: "#dfdf87", 187: "#dfdfaf", 188: "#dfdfdf", 189: "#dfdfff",
190: "#dfff00", 191: "#dfff5f", 192: "#dfff87", 193: "#dfffaf", 194: "#dfffdf",
195: "#dfffff", 196: "#ff0000", 197: "#ff005f", 198: "#ff0087", 199: "#ff00af",
200: "#ff00df", 201: "#ff00ff", 202: "#ff5f00", 203: "#ff5f5f", 204: "#ff5f87",
205: "#ff5faf", 206: "#ff5fdf", 207: "#ff5fff", 208: "#ff8700", 209: "#ff875f",
210: "#ff8787", 211: "#ff87af", 212: "#ff87df", 213: "#ff87ff", 214: "#ffaf00",
215: "#ffaf5f", 216: "#ffaf87", 217: "#ffafaf", 218: "#ffafdf", 219: "#ffafff",
220: "#ffdf00", 221: "#ffdf5f", 222: "#ffdf87", 223: "#ffdfaf", 224: "#ffdfdf",
225: "#ffdfff", 226: "#ffff00", 227: "#ffff5f", 228: "#ffff87", 229: "#ffffaf",
230: "#ffffdf", 231: "#ffffff", 232: "#080808", 233: "#121212", 234: "#1c1c1c",
235: "#262626", 236: "#303030", 237: "#3a3a3a", 238: "#444444", 239: "#4e4e4e",
240: "#585858", 241: "#626262", 242: "#6c6c6c", 243: "#767676", 244: "#808080",
245: "#8a8a8a", 246: "#949494", 247: "#9e9e9e", 248: "#a8a8a8", 249: "#b2b2b2",
250: "#bcbcbc", 251: "#c6c6c6", 252: "#d0d0d0", 253: "#dadada", 254: "#e4e4e4",
255: "#eeeeee",
}
def __init__(self, p_value=None): def __init__(self, p_value=None):
""" p_value is user input, be it a word color or an xterm code """ """ p_value is user input, be it a word color or an xterm code """
self._value = None self._value = None
...@@ -117,3 +173,21 @@ class Color: ...@@ -117,3 +173,21 @@ class Color:
color color
) )
def as_html(self):
try:
return Color.html_color_dict[self.color]
except KeyError:
return '#ffffff'
def as_rgb(self):
"""
Returns a tuple (r, g, b) of the color.
"""
html = self.as_html()
return (
int(html[1:3], 16),
int(html[3:5], 16),
int(html[5:7], 16)
)
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
import getopt import getopt
from topydo.lib.PrettyPrinter import PrettyPrinter from topydo.lib.printers.PrettyPrinter import PrettyPrinter
class InvalidCommandArgument(Exception): class InvalidCommandArgument(Exception):
......
...@@ -77,6 +77,7 @@ class _Config: ...@@ -77,6 +77,7 @@ class _Config:
'ls': { 'ls': {
'hide_tags': 'id,p,ical', 'hide_tags': 'id,p,ical',
'hidden_item_tags': 'h,hide',
'indent': '0', 'indent': '0',
'list_limit': '-1', 'list_limit': '-1',
'list_format': '|%I| %x %{(}p{)} %c %s %k %{due:}d %{t:}t', 'list_format': '|%I| %x %{(}p{)} %c %s %k %{due:}d %{t:}t',
...@@ -91,6 +92,7 @@ class _Config: ...@@ -91,6 +92,7 @@ class _Config:
'sort': { 'sort': {
'keep_sorted': '0', 'keep_sorted': '0',
'sort_string': 'desc:importance,due,desc:priority', 'sort_string': 'desc:importance,due,desc:priority',
'group_string': '',
'ignore_weekends': '1', 'ignore_weekends': '1',
}, },
...@@ -105,6 +107,8 @@ class _Config: ...@@ -105,6 +107,8 @@ class _Config:
'metadata_color': 'green', 'metadata_color': 'green',
'link_color': 'cyan', 'link_color': 'cyan',
'priority_colors': 'A:cyan,B:yellow,C:blue', 'priority_colors': 'A:cyan,B:yellow,C:blue',
'focus_background_color': 'gray',
'marked_background_color': 'blue'
}, },
'aliases': { 'aliases': {
...@@ -269,6 +273,9 @@ class _Config: ...@@ -269,6 +273,9 @@ class _Config:
def sort_string(self): def sort_string(self):
return self.cp.get('sort', 'sort_string') return self.cp.get('sort', 'sort_string')
def group_string(self):
return self.cp.get('sort', 'group_string')
def ignore_weekends(self): def ignore_weekends(self):
try: try:
return self.cp.getboolean('sort', 'ignore_weekends') return self.cp.getboolean('sort', 'ignore_weekends')
...@@ -311,6 +318,13 @@ class _Config: ...@@ -311,6 +318,13 @@ class _Config:
return [] if hidden_tags == '' else [tag.strip() for tag in return [] if hidden_tags == '' else [tag.strip() for tag in
hidden_tags.split(',')] hidden_tags.split(',')]
def hidden_item_tags(self):
""" Returns a list of tags which hide an item from the 'ls' output. """
hidden_item_tags = self.cp.get('ls', 'hidden_item_tags')
# pylint: disable=no-member
return [] if hidden_item_tags == '' else [tag.strip() for tag in
hidden_item_tags.split(',')]
def priority_color(self, p_priority): def priority_color(self, p_priority):
""" """
Returns a dict with priorities as keys and color numbers as value. Returns a dict with priorities as keys and color numbers as value.
...@@ -359,6 +373,18 @@ class _Config: ...@@ -359,6 +373,18 @@ class _Config:
except ValueError: except ValueError:
return Color(self.cp.get('colorscheme', 'link_color')) return Color(self.cp.get('colorscheme', 'link_color'))
def focus_background_color(self):
try:
return Color(self.cp.getint('colorscheme', 'focus_background_color'))
except ValueError:
return Color(self.cp.get('colorscheme', 'focus_background_color'))
def marked_background_color(self):
try:
return Color(self.cp.getint('colorscheme', 'marked_background_color'))
except ValueError:
return Color(self.cp.get('colorscheme', 'marked_background_color'))
def auto_creation_date(self): def auto_creation_date(self):
try: try:
return self.cp.getboolean('add', 'auto_creation_date') return self.cp.getboolean('add', 'auto_creation_date')
......
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
import re import re
from topydo.lib.MultiCommand import MultiCommand from topydo.lib.MultiCommand import MultiCommand
from topydo.lib.PrettyPrinter import PrettyPrinter from topydo.lib.printers.PrettyPrinter import PrettyPrinter
from topydo.lib.prettyprinters.Numbers import PrettyPrinterNumbers from topydo.lib.prettyprinters.Numbers import PrettyPrinterNumbers
......
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
import re import re
from topydo.lib.Config import config
from topydo.lib.RelativeDate import relative_date_to_date from topydo.lib.RelativeDate import relative_date_to_date
from topydo.lib.Utils import date_string_to_date from topydo.lib.Utils import date_string_to_date
...@@ -91,8 +92,8 @@ class RelevanceFilter(Filter): ...@@ -91,8 +92,8 @@ class RelevanceFilter(Filter):
The item has not been completed AND The item has not been completed AND
The start date is blank, today or in the past, AND The start date is blank, today or in the past, AND
The priority is 'A' or the priority is B with due date within 30 days or The priority is 'A', or the priority is 'B' with due date within 30 days, or
the priority is C with due date within 14 days. the priority is 'C' with due date within 14 days.
""" """
def match(self, p_todo): def match(self, p_todo):
...@@ -154,6 +155,28 @@ class InstanceFilter(Filter): ...@@ -154,6 +155,28 @@ class InstanceFilter(Filter):
return False return False
class HiddenTagFilter(Filter):
def __init__(self):
"""
Constructor.
A filter which hides items marked with the approriate tag.
"""
super().__init__()
def match(self, p_todo):
"""
Returns True when p_todo doesn't have a tag to mark it as hidden.
"""
for my_tag in config().hidden_item_tags():
my_values = p_todo.tag_values(my_tag)
for my_value in my_values:
if not my_value in (0, '0', False, 'False'):
return False
return True
class LimitFilter(Filter): class LimitFilter(Filter):
def __init__(self, p_limit): def __init__(self, p_limit):
super().__init__() super().__init__()
......
...@@ -31,12 +31,14 @@ IMPORTANCE_VALUE = {'A': 3, 'B': 2, 'C': 1} ...@@ -31,12 +31,14 @@ IMPORTANCE_VALUE = {'A': 3, 'B': 2, 'C': 1}
def is_due_next_monday(p_todo): def is_due_next_monday(p_todo):
""" Returns True when the given task is due next Monday. """ """ Returns True when today is Friday (or the weekend) and the given task
is due next Monday.
"""
today = date.today() today = date.today()
due = p_todo.due_date() due = p_todo.due_date()
return due and due.weekday() == 0 and today.weekday() >= 4 and \ return due and due.weekday() == 0 and today.weekday() >= 4 and \
p_todo.days_till_due() p_todo.days_till_due() <= 3
def importance(p_todo, p_ignore_weekend=config().ignore_weekends()): def importance(p_todo, p_ignore_weekend=config().ignore_weekends()):
......
...@@ -21,7 +21,7 @@ import re ...@@ -21,7 +21,7 @@ import re
from topydo.lib.Config import config from topydo.lib.Config import config
from topydo.lib.ProgressColor import progress_color from topydo.lib.ProgressColor import progress_color
from topydo.lib.Utils import get_terminal_size, escape_ansi from topydo.lib.Utils import get_terminal_size, escape_ansi, humanize_date
MAIN_PATTERN = (r'^({{(?P<before>.+?)}})?' MAIN_PATTERN = (r'^({{(?P<before>.+?)}})?'
r'(?P<placeholder>{ph}|\[{ph}\])' r'(?P<placeholder>{ph}|\[{ph}\])'
...@@ -39,12 +39,6 @@ def _filler(p_str, p_len): ...@@ -39,12 +39,6 @@ def _filler(p_str, p_len):
to_fill = p_len - len(p_str) to_fill = p_len - len(p_str)
return to_fill*' ' + p_str return to_fill*' ' + p_str
def humanize_date(p_datetime):
""" Returns a relative date string from a datetime object. """
now = arrow.now()
date = now.replace(day=p_datetime.day, month=p_datetime.month, year=p_datetime.year)
return date.humanize().replace('just now', 'today')
def humanize_dates(p_due=None, p_start=None, p_creation=None): def humanize_dates(p_due=None, p_start=None, p_creation=None):
""" """
Returns string with humanized versions of p_due, p_start and p_creation. Returns string with humanized versions of p_due, p_start and p_creation.
......
...@@ -16,56 +16,110 @@ ...@@ -16,56 +16,110 @@
""" This module provides functionality to sort lists with todo items. """ """ This module provides functionality to sort lists with todo items. """
from collections import OrderedDict, namedtuple
from itertools import groupby
import re import re
from datetime import date from datetime import date
from topydo.lib.Config import config
from topydo.lib.Importance import average_importance, importance from topydo.lib.Importance import average_importance, importance
from topydo.lib.Utils import humanize_date
def is_priority_field(p_field): Field = namedtuple('Field', ['sort', 'group', 'label'])
""" Returns True when the field name denotes the priority. """
return p_field.startswith('prio')
FIELDS = {
def get_field_function(p_field): 'completed': Field(
""" # when a task has no completion date, push it to the end by assigning it
Given a property (string) of a todo, return a function that attempts to # the maximum possible date.
access that property. If the property could not be located, return the sort=(lambda t: t.completion_date() if t.completion_date() else date.max),
identity function. group=(lambda t: humanize_date(t.completion_date()) if t.completion_date() else 'None'),
""" label='Completed',
result = lambda a: a ),
'context': Field(
if is_priority_field(p_field): sort=lambda t: sorted(c.lower() for c in t.contexts()) or ['zz'],
# assign dummy priority when a todo has no priority group=lambda t: sorted(t.contexts()) or ['None'],
result = lambda a: a.priority() or 'ZZ' label='Context'
elif p_field == 'context' or p_field == 'contexts': ),
result = lambda a: sorted([c.lower() for c in a.contexts()]) 'created': Field(
elif p_field == 'creationdate' or p_field == 'creation':
# when a task has no creation date, push it to the end by assigning it # when a task has no creation date, push it to the end by assigning it
# the maximum possible date. # the maximum possible date.
result = (lambda a: a.creation_date() if a.creation_date() sort=(lambda t: t.creation_date() if t.creation_date() else date.max),
else date.max) group=(lambda t: humanize_date(t.creation_date()) if t.creation_date() else 'None'),
elif p_field == 'done' or p_field == 'completed' or p_field == 'completion': label='Created',
result = (lambda a: a.completion_date() if a.completion_date() ),
else date.max) 'importance': Field(
elif p_field == 'importance': sort=importance,
result = importance group=importance,
elif p_field == 'importance-avg' or p_field == 'importance-average': label='Importance',
result = average_importance ),
elif p_field == 'length': 'importance-avg': Field(
result = lambda a: a.length() sort= average_importance,
elif p_field == 'project' or p_field == 'projects': group=lambda t: round(average_importance(t), 1),
result = lambda a: sorted([c.lower() for c in a.projects()]) label='Importance (avg)',
elif p_field == 'text': ),
result = lambda a: a.text() 'length': Field(
else: sort=lambda t: t.length(),
# try to find the corresponding tag group=lambda t: t.length(),
# when a tag is not present, push it to the end of the list by giving label='Length',
# it an artificially higher value ),
result = (lambda a: "0" + a.tag_value(p_field) if a.has_tag(p_field) 'priority': Field(
else "1") sort=(lambda t: t.priority() or 'ZZ'),
group=(lambda t: t.priority() or 'None'),
return result 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): class Sorter(object):
...@@ -93,10 +147,10 @@ class Sorter(object): ...@@ -93,10 +147,10 @@ class Sorter(object):
stable. stable.
""" """
def __init__(self, p_sortstring="desc:priority"): def __init__(self, p_sortstring="desc:priority", p_groupstring=""):
self.sortstring = p_sortstring self.groupfunctions = self._parse(p_groupstring, p_group=True) if p_groupstring else []
self.functions = [] self.pregroupfunctions = self._parse(p_groupstring, p_group=False) if p_groupstring else []
self._parse() self.sortfunctions = self._parse(p_sortstring, p_group=False)
def sort(self, p_todos): def sort(self, p_todos):
""" """
...@@ -107,19 +161,85 @@ class Sorter(object): ...@@ -107,19 +161,85 @@ class Sorter(object):
sort operation is done first, relying on the stability of the sorted() sort operation is done first, relying on the stability of the sorted()
function. function.
""" """
sorted_todos = p_todos return _apply_sort_functions(p_todos, self.sortfunctions)
for function, order in reversed(self.functions):
sorted_todos = sorted(sorted_todos, key=function,
reverse=(order == 'desc'))
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. 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: for field in fields:
parsed_field = re.match( parsed_field = re.match(
...@@ -134,11 +254,14 @@ class Sorter(object): ...@@ -134,11 +254,14 @@ class Sorter(object):
field = parsed_field.group('field') field = parsed_field.group('field')
if field: if field:
function = get_field_function(field) function = get_field_function(field, p_group)
# reverse order for priority: lower characters have higher # reverse order for priority: lower characters have higher
# priority # priority
if is_priority_field(field): if field in FIELD_MAP and FIELD_MAP[field] == 'priority':
order = 'asc' if order == 'desc' else 'desc' order = 'asc' if order == 'desc' else 'desc'
self.functions.append((function, order)) result.append((function, order))
return result
...@@ -19,6 +19,7 @@ This module deals with todo.txt files. ...@@ -19,6 +19,7 @@ This module deals with todo.txt files.
""" """
import codecs import codecs
import os.path
class TodoFile(object): class TodoFile(object):
...@@ -28,7 +29,7 @@ class TodoFile(object): ...@@ -28,7 +29,7 @@ class TodoFile(object):
""" """
def __init__(self, p_path): def __init__(self, p_path):
self.path = p_path self.path = os.path.abspath(p_path)
def read(self): def read(self):
""" Reads the todo.txt file and returns a list of todo items. """ """ Reads the todo.txt file and returns a list of todo items. """
...@@ -49,6 +50,7 @@ class TodoFile(object): ...@@ -49,6 +50,7 @@ class TodoFile(object):
p_todos can be a list of todo items, or a string that is just written p_todos can be a list of todo items, or a string that is just written
to the file. to the file.
""" """
todofile = codecs.open(self.path, 'w', encoding="utf-8") todofile = codecs.open(self.path, 'w', encoding="utf-8")
if p_todos is list: if p_todos is list:
......
# Topydo - A todo.txt client written in Python.
# Copyright (C) 2014 - 2016 Bram Schoenmakers <bram@topydo.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
This module deals with todo.txt files while putting a watch on them for file
changes.
"""
import os.path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileModifiedEvent, FileCreatedEvent
from topydo.lib.TodoFile import TodoFile
class TodoFileWatched(TodoFile):
"""
This class represents a todo.txt file, which can be read from or written
to.
"""
def __init__(self, p_path, p_on_update):
super().__init__(p_path)
self.self_write = False
class EventHandler(FileSystemEventHandler):
"""
Event handler to catch modifications (or creations) of the
current todo.txt file.
"""
def __init__(self, p_file):
super().__init__()
self.file = p_file
def _handle(self, p_event):
right_type = isinstance(p_event, FileModifiedEvent) or isinstance(p_event, FileCreatedEvent)
should_trigger = right_type and p_event.src_path == self.file.path
if self.file.self_write and should_trigger:
# the file was written by topydo, unmark that so we can
# record external writes again.
self.file.self_write = False
elif should_trigger:
p_on_update()
def on_created(self, p_event):
"""
Because vim deletes and creates a file on buffer save, also
catch a creation event.
"""
self._handle(p_event)
def on_modified(self, p_event):
self._handle(p_event)
observer = Observer()
observer.schedule(EventHandler(self), os.path.dirname(self.path))
observer.start()
def write(self, p_todos):
# make sure not to reread the todo file because this instance is
# actually writing it
self.self_write = True
super().write(p_todos)
...@@ -24,7 +24,7 @@ from datetime import date ...@@ -24,7 +24,7 @@ from datetime import date
from topydo.lib import Filter from topydo.lib import Filter
from topydo.lib.Config import config from topydo.lib.Config import config
from topydo.lib.HashListValues import hash_list_values from topydo.lib.HashListValues import hash_list_values
from topydo.lib.PrettyPrinter import PrettyPrinter from topydo.lib.printers.PrettyPrinter import PrettyPrinter
from topydo.lib.Todo import Todo from topydo.lib.Todo import Todo
from topydo.lib.View import View from topydo.lib.View import View
......
...@@ -18,6 +18,7 @@ ...@@ -18,6 +18,7 @@
Various utility functions. Various utility functions.
""" """
import arrow
import re import re
from collections import namedtuple from collections import namedtuple
...@@ -109,3 +110,10 @@ def translate_key_to_config(p_key): ...@@ -109,3 +110,10 @@ def translate_key_to_config(p_key):
key = p_key key = p_key
return key return key
def humanize_date(p_datetime):
""" Returns a relative date string from a datetime object. """
now = arrow.now()
date = now.replace(day=p_datetime.day, month=p_datetime.month, year=p_datetime.year)
return date.humanize(now).replace('just now', 'today')
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # 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): class View(object):
...@@ -29,12 +29,22 @@ class View(object): ...@@ -29,12 +29,22 @@ class View(object):
self._sorter = p_sorter self._sorter = p_sorter
self._filters = p_filters self._filters = p_filters
@property def _apply_filters(self, p_todos):
def todos(self): """ Applies the filters to the list of todo items. """
""" Returns a sorted and filtered list of todos in this view. """ result = p_todos
result = self._sorter.sort(self.todolist.todos())
for _filter in self._filters: for _filter in self._filters:
result = _filter.filter(result) result = _filter.filter(result)
return 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)
# Topydo - A todo.txt client written in Python.
# Copyright (C) 2015 Bram Schoenmakers <me@bramschoenmakers.nl>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Provides a printer that transforms a list of Todo items to a graph in Dot
notation. Useful for displaying dependencies.
"""
from textwrap import wrap
from topydo.lib.printers.PrettyPrinter import Printer
from topydo.lib.ProgressColor import progress_color
from topydo.lib.Utils import humanize_date
class DotPrinter(Printer):
"""
A printer that converts a list of Todo items to a string in Dot format.
"""
def __init__(self, p_todolist):
super(DotPrinter, self).__init__()
self.todolist = p_todolist
def print_list(self, p_todos):
def node_label(p_todo):
"""
Prints an HTML table for a node label with some todo details.
"""
node_result = '<<TABLE CELLBORDER="0" CELLSPACING="1" VALIGN="top">'
def print_row(p_value1, p_value2):
return '<TR><TD ALIGN="RIGHT">{}</TD><TD ALIGN="LEFT">{}</TD></TR>'.format(p_value1, p_value2)
node_result += '<TR><TD><B>{}</B></TD><TD BALIGN="LEFT"><B>{}{}{}</B></TD></TR>'.format(
self.todolist.number(p_todo),
"<S>" if todo.is_completed() else "",
"<BR />".join(wrap(p_todo.text(), 35)),
"</S>" if todo.is_completed() else "",
)
priority = p_todo.priority()
start_date = p_todo.start_date()
due_date = p_todo.due_date()
if priority or start_date or due_date:
node_result += '<HR/>'
if priority:
node_result += print_row('Prio:', p_todo.priority())
if start_date:
node_result += print_row('Starts:', "{} ({})".format(
start_date.isoformat(),
humanize_date(start_date)
))
if due_date:
node_result += print_row('Due:', "{} ({})".format(
due_date.isoformat(),
humanize_date(due_date)
))
node_result += '</TABLE>>'
return node_result
def foreground(p_background):
"""
Chooses a suitable foreground color (black or white) given a
background color.
"""
(r, g, b) = p_background.as_rgb()
brightness = (r * 299 + g * 587 + b * 114) / ( 255 * 1000 )
return '#ffffff' if brightness < 0.5 else '#000000'
node_name = lambda t: '_' + str(self.todolist.number(t))
result = 'digraph topydo {\n'
result += 'node [ shape="none" margin="0" fontsize="9" fontname="Helvetica" ]\n';
# print todos
for todo in p_todos:
background_color = progress_color(todo)
result += ' {} [label={} style=filled fillcolor="{}" fontcolor="{}"]\n'.format(
node_name(todo),
node_label(todo),
background_color.as_html(),
foreground(background_color),
)
# print edges
for todo in p_todos:
# only print the children that are actually in the list of todos
children = set(p_todos) & set(self.todolist.children(todo,
p_only_direct=True))
for child in sorted(list(children), key=lambda t: t.text()):
result += ' {} -> {}\n'.format(
node_name(todo),
node_name(child)
)
todos_without_dependencies = [todo for todo in p_todos if not self.todolist.children(todo) and not self.todolist.parents(todo)]
for index in range(0, len(todos_without_dependencies) - 1):
this_todo = todos_without_dependencies[index]
next_todo = todos_without_dependencies[index + 1]
result += ' {} -> {} [style="invis"]\n'.format(node_name(this_todo), node_name(next_todo))
result += '}\n'
return result
...@@ -23,7 +23,7 @@ import random ...@@ -23,7 +23,7 @@ import random
import string import string
from datetime import datetime, time from datetime import datetime, time
from topydo.lib.PrettyPrinter import Printer from topydo.lib.printers.PrettyPrinter import Printer
def _convert_priority(p_priority): def _convert_priority(p_priority):
......
...@@ -21,7 +21,7 @@ such that other applications can process it. ...@@ -21,7 +21,7 @@ such that other applications can process it.
import json import json
from topydo.lib.PrettyPrinter import Printer from topydo.lib.printers.PrettyPrinter import Printer
def _convert_todo(p_todo): def _convert_todo(p_todo):
......
...@@ -14,6 +14,8 @@ ...@@ -14,6 +14,8 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # 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.Colors import PrettyPrinterColorFilter
from topydo.lib.prettyprinters.Numbers import PrettyPrinterNumbers from topydo.lib.prettyprinters.Numbers import PrettyPrinterNumbers
from topydo.lib.TopydoString import TopydoString from topydo.lib.TopydoString import TopydoString
...@@ -30,8 +32,16 @@ class Printer(object): ...@@ -30,8 +32,16 @@ class Printer(object):
raise NotImplementedError raise NotImplementedError
def print_list(self, p_todos): def print_list(self, p_todos):
result = ''
for todo in p_todos: 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): class PrettyPrinter(Printer):
...@@ -76,6 +86,29 @@ class PrettyPrinter(Printer): ...@@ -76,6 +86,29 @@ class PrettyPrinter(Printer):
""" """
return [self.print_todo(todo) for todo in p_todos] 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): def pretty_printer_factory(p_todolist, p_additional_filters=None):
""" Returns a pretty printer suitable for the ls and dep subcommands. """ """ Returns a pretty printer suitable for the ls and dep subcommands. """
......
...@@ -44,13 +44,13 @@ def main(): ...@@ -44,13 +44,13 @@ def main():
from topydo.ui.prompt.Prompt import PromptApplication from topydo.ui.prompt.Prompt import PromptApplication
PromptApplication().run() PromptApplication().run()
except ImportError: except ImportError:
error("You have to install prompt-toolkit to run prompt mode.") error("Some additional dependencies for prompt mode were not installed, please install with 'pip install topydo[prompt]'")
elif args[0] == 'columns': elif args[0] == 'columns':
try: try:
from topydo.ui.columns.Main import UIApplication from topydo.ui.columns.Main import UIApplication
UIApplication().run() UIApplication().run()
except ImportError: except ImportError:
error("You have to install urwid to run column mode.") error("Some additional dependencies for column mode were not installed, please install with 'pip install topydo[columns]'")
else: else:
CLIApplication().run() CLIApplication().run()
except IndexError: except IndexError:
......
...@@ -15,10 +15,12 @@ ...@@ -15,10 +15,12 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import configparser import configparser
from os.path import expanduser
from topydo.lib.Config import home_config_path, config from topydo.lib.Config import home_config_path, config
def columns():
def columns(p_alt_layout_path=None):
""" """
Returns list with complete column configuration dicts. Returns list with complete column configuration dicts.
""" """
...@@ -28,6 +30,7 @@ def columns(): ...@@ -28,6 +30,7 @@ def columns():
column_dict['title'] = p_cp.get(p_column, 'title') column_dict['title'] = p_cp.get(p_column, 'title')
column_dict['filterexpr'] = p_cp.get(p_column, 'filterexpr') column_dict['filterexpr'] = p_cp.get(p_column, 'filterexpr')
column_dict['sortexpr'] = p_cp.get(p_column, 'sortexpr') 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') column_dict['show_all'] = p_cp.getboolean(p_column, 'show_all')
return column_dict return column_dict
...@@ -36,6 +39,7 @@ def columns(): ...@@ -36,6 +39,7 @@ def columns():
'title': 'Yet another column', 'title': 'Yet another column',
'filterexpr': '', 'filterexpr': '',
'sortexpr': config().sort_string(), 'sortexpr': config().sort_string(),
'groupexpr': config().group_string(),
'show_all': '0', 'show_all': '0',
} }
...@@ -49,6 +53,8 @@ def columns(): ...@@ -49,6 +53,8 @@ def columns():
"/etc/topydo_columns.conf", "/etc/topydo_columns.conf",
] ]
if p_alt_layout_path is not None:
files.insert(0, expanduser(p_alt_layout_path))
for filename in files: for filename in files:
if cp.read(filename): if cp.read(filename):
break break
......
...@@ -15,7 +15,9 @@ ...@@ -15,7 +15,9 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime import datetime
import getopt
import shlex import shlex
import sys
import time import time
import urwid import urwid
...@@ -28,12 +30,13 @@ from topydo.lib.Sorter import Sorter ...@@ -28,12 +30,13 @@ from topydo.lib.Sorter import Sorter
from topydo.lib.Filter import get_filter_list, RelevanceFilter, DependencyFilter from topydo.lib.Filter import get_filter_list, RelevanceFilter, DependencyFilter
from topydo.lib.Utils import get_terminal_size from topydo.lib.Utils import get_terminal_size
from topydo.lib.View import View from topydo.lib.View import View
from topydo.lib import TodoFile from topydo.lib.TodoFileWatched import TodoFileWatched
from topydo.lib import TodoList from topydo.lib import TodoList
from topydo.ui.CLIApplicationBase import CLIApplicationBase from topydo.ui.CLIApplicationBase import CLIApplicationBase, error
from topydo.ui.columns.CommandLineWidget import CommandLineWidget from topydo.ui.columns.CommandLineWidget import CommandLineWidget
from topydo.ui.columns.ConsoleWidget import ConsoleWidget from topydo.ui.columns.ConsoleWidget import ConsoleWidget
from topydo.ui.columns.KeystateWidget import KeystateWidget from topydo.ui.columns.KeystateWidget import KeystateWidget
from topydo.ui.columns.TodoWidget import TodoWidget
from topydo.ui.columns.TodoListWidget import TodoListWidget from topydo.ui.columns.TodoListWidget import TodoListWidget
from topydo.ui.columns.Utils import PaletteItem, to_urwid_color from topydo.ui.columns.Utils import PaletteItem, to_urwid_color
from topydo.ui.columns.ViewWidget import ViewWidget from topydo.ui.columns.ViewWidget import ViewWidget
...@@ -91,10 +94,28 @@ class UIApplication(CLIApplicationBase): ...@@ -91,10 +94,28 @@ class UIApplication(CLIApplicationBase):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._process_flags() args = self._process_flags()
try:
opts, args = getopt.getopt(args[1:], 'l:')
except getopt.GetoptError as e:
error(str(e))
sys.exit(1)
self.alt_layout_path = None
for opt, value in opts:
if opt == "-l":
self.alt_layout_path = value
def callback():
self.todolist.erase()
self.todolist.add_list(self.todofile.read())
self._update_all_columns()
self._redraw()
self.column_width = config().column_width() self.column_width = config().column_width()
self.todofile = TodoFile.TodoFile(config().todotxt()) self.todofile = TodoFileWatched(config().todotxt(), callback)
self.todolist = TodoList.TodoList(self.todofile.read()) self.todolist = TodoList.TodoList(self.todofile.read())
self.marked_todos = [] self.marked_todos = []
...@@ -172,18 +193,20 @@ class UIApplication(CLIApplicationBase): ...@@ -172,18 +193,20 @@ class UIApplication(CLIApplicationBase):
context_color = to_urwid_color(config().context_color()) context_color = to_urwid_color(config().context_color())
metadata_color = to_urwid_color(config().metadata_color()) metadata_color = to_urwid_color(config().metadata_color())
link_color = to_urwid_color(config().link_color()) link_color = to_urwid_color(config().link_color())
focus_background_color = to_urwid_color(config().focus_background_color())
marked_background_color = to_urwid_color(config().marked_background_color())
palette = [ palette = [
(PaletteItem.PROJECT, '', '', '', project_color, ''), (PaletteItem.PROJECT, '', '', '', project_color, ''),
(PaletteItem.PROJECT_FOCUS, '', 'light gray', '', project_color, None), (PaletteItem.PROJECT_FOCUS, '', 'light gray', '', project_color, focus_background_color),
(PaletteItem.CONTEXT, '', '', '', context_color, ''), (PaletteItem.CONTEXT, '', '', '', context_color, ''),
(PaletteItem.CONTEXT_FOCUS, '', 'light gray', '', context_color, None), (PaletteItem.CONTEXT_FOCUS, '', 'light gray', '', context_color, focus_background_color),
(PaletteItem.METADATA, '', '', '', metadata_color, ''), (PaletteItem.METADATA, '', '', '', metadata_color, ''),
(PaletteItem.METADATA_FOCUS, '', 'light gray', '', metadata_color, None), (PaletteItem.METADATA_FOCUS, '', 'light gray', '', metadata_color, focus_background_color),
(PaletteItem.LINK, '', '', '', link_color, ''), (PaletteItem.LINK, '', '', '', link_color, ''),
(PaletteItem.LINK_FOCUS, '', 'light gray', '', link_color, None), (PaletteItem.LINK_FOCUS, '', 'light gray', '', link_color, focus_background_color),
(PaletteItem.DEFAULT_FOCUS, 'black', 'light gray'), (PaletteItem.DEFAULT_FOCUS, '', 'light gray', '', '', focus_background_color),
(PaletteItem.MARKED, '', 'light blue'), (PaletteItem.MARKED, '', 'light blue', '', '', marked_background_color),
] ]
for C in ascii_uppercase: for C in ascii_uppercase:
...@@ -196,7 +219,7 @@ class UIApplication(CLIApplicationBase): ...@@ -196,7 +219,7 @@ class UIApplication(CLIApplicationBase):
'pri_' + C, '', '', '', pri_color, '' 'pri_' + C, '', '', '', pri_color, ''
)) ))
palette.append(( palette.append((
'pri_' + C + '_focus', '', 'light gray', '', pri_color_focus, None 'pri_' + C + '_focus', '', 'light gray', '', pri_color_focus, focus_background_color
)) ))
return palette return palette
...@@ -220,6 +243,7 @@ class UIApplication(CLIApplicationBase): ...@@ -220,6 +243,7 @@ class UIApplication(CLIApplicationBase):
def _set_alarm_for_next_midnight_update(self): def _set_alarm_for_next_midnight_update(self):
def callback(p_loop, p_data): def callback(p_loop, p_data):
TodoWidget.wipe_cache()
self._update_all_columns() self._update_all_columns()
self._set_alarm_for_next_midnight_update() self._set_alarm_for_next_midnight_update()
...@@ -299,6 +323,8 @@ class UIApplication(CLIApplicationBase): ...@@ -299,6 +323,8 @@ class UIApplication(CLIApplicationBase):
self._output if verbosity else lambda _: None) self._output if verbosity else lambda _: None)
def _reset_state(self): def _reset_state(self):
for widget in TodoWidget.cache.values():
widget.unmark()
self.marked_todos = [] self.marked_todos = []
self._update_all_columns() self._update_all_columns()
...@@ -389,7 +415,7 @@ class UIApplication(CLIApplicationBase): ...@@ -389,7 +415,7 @@ class UIApplication(CLIApplicationBase):
""" """
Converts a dictionary describing a view to an actual UIView instance. 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 = [] filters = []
if not p_data['show_all']: if not p_data['show_all']:
...@@ -535,13 +561,16 @@ class UIApplication(CLIApplicationBase): ...@@ -535,13 +561,16 @@ class UIApplication(CLIApplicationBase):
self._console_visible = True self._console_visible = True
self.console.print_text(p_text) self.console.print_text(p_text)
def _redraw(self):
self.mainloop.draw_screen()
def _input(self, p_question): def _input(self, p_question):
self._print_to_console(p_question) self._print_to_console(p_question)
# don't wait for the event loop to enter idle, there is a command # don't wait for the event loop to enter idle, there is a command
# waiting for input right now, so already go ahead and draw the # waiting for input right now, so already go ahead and draw the
# question on screen. # question on screen.
self.mainloop.draw_screen() self._redraw()
user_input = self.mainloop.screen.get_input() user_input = self.mainloop.screen.get_input()
self._console_visible = False self._console_visible = False
...@@ -572,7 +601,7 @@ class UIApplication(CLIApplicationBase): ...@@ -572,7 +601,7 @@ class UIApplication(CLIApplicationBase):
return False return False
def run(self): def run(self):
layout = columns() layout = columns(self.alt_layout_path)
if len(layout) > 0: if len(layout) > 0:
for column in layout: for column in layout:
self._add_column(self._viewdata_to_view(column)) self._add_column(self._viewdata_to_view(column))
...@@ -580,6 +609,7 @@ class UIApplication(CLIApplicationBase): ...@@ -580,6 +609,7 @@ class UIApplication(CLIApplicationBase):
dummy = { dummy = {
"title": "All tasks", "title": "All tasks",
"sortexpr": "desc:prio", "sortexpr": "desc:prio",
"groupexpr": "",
"filterexpr": "", "filterexpr": "",
"show_all": True, "show_all": True,
} }
......
...@@ -92,10 +92,17 @@ class TodoListWidget(urwid.LineBox): ...@@ -92,10 +92,17 @@ class TodoListWidget(urwid.LineBox):
del self.todolist[:] del self.todolist[:]
for todo in self.view.todos: for group, todos in self.view.groups.items():
todowidget = TodoWidget(todo, self.view.todolist.number(todo)) if len(self.view.groups) > 1:
self.todolist.append(todowidget) grouplabel = ", ".join(group)
self.todolist.append(urwid.Divider('-')) 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: if old_focus_position:
try: try:
......
...@@ -53,7 +53,7 @@ def _markup(p_todo, p_focus): ...@@ -53,7 +53,7 @@ def _markup(p_todo, p_focus):
class TodoWidget(urwid.WidgetWrap): class TodoWidget(urwid.WidgetWrap):
def __init__(self, p_todo, p_number): def __init__(self, p_todo):
# clients use this to associate this widget with the given todo item # clients use this to associate this widget with the given todo item
self.todo = p_todo self.todo = p_todo
...@@ -87,21 +87,21 @@ class TodoWidget(urwid.WidgetWrap): ...@@ -87,21 +87,21 @@ class TodoWidget(urwid.WidgetWrap):
else: else:
txt_markup.append(substring) txt_markup.append(substring)
id_widget = urwid.Text(str(p_number), align='right') self.id_widget = urwid.Text('', align='right')
priority_widget = urwid.Text(priority_text) priority_widget = urwid.Text(priority_text)
self.text_widget = urwid.Text(txt_markup) self.text_widget = urwid.Text(txt_markup)
progress = to_urwid_color(progress_color(p_todo)) if config().colors() else PaletteItem.DEFAULT progress = to_urwid_color(progress_color(p_todo)) if config().colors() else PaletteItem.DEFAULT
progress_bar = urwid.AttrMap( self.progress_bar = urwid.AttrMap(
urwid.SolidFill(' '), urwid.SolidFill(' '),
urwid.AttrSpec(PaletteItem.DEFAULT, progress, 256), {},
urwid.AttrSpec(PaletteItem.DEFAULT, progress, 256),
) )
self.update_progress()
self.columns = urwid.Columns( self.columns = urwid.Columns(
[ [
(1, progress_bar), (1, self.progress_bar),
(4, id_widget), (4, self.id_widget),
(3, priority_widget), (3, priority_widget),
('weight', 1, self.text_widget), ('weight', 1, self.text_widget),
], ],
...@@ -128,6 +128,21 @@ class TodoWidget(urwid.WidgetWrap): ...@@ -128,6 +128,21 @@ class TodoWidget(urwid.WidgetWrap):
# make sure that ListBox will highlight this widget # make sure that ListBox will highlight this widget
return True return True
@property
def number(self):
pass
@number.setter
def number(self, p_number):
self.id_widget.set_text(str(p_number))
def update_progress(self):
color = to_urwid_color(progress_color(self.todo)) if config().colors() else PaletteItem.DEFAULT
self.progress_bar.set_attr_map(
{None: urwid.AttrSpec(PaletteItem.DEFAULT, color, 256)}
)
def mark(self): def mark(self):
attr_map = { attr_map = {
None: PaletteItem.MARKED, None: PaletteItem.MARKED,
...@@ -140,3 +155,45 @@ class TodoWidget(urwid.WidgetWrap): ...@@ -140,3 +155,45 @@ class TodoWidget(urwid.WidgetWrap):
def unmark(self): def unmark(self):
self.widget.set_attr_map(_markup(self.todo, False)) self.widget.set_attr_map(_markup(self.todo, False))
cache = {}
@classmethod
def create(p_class, p_todo):
"""
Creates a TodoWidget instance for the given todo. Widgets are
cached, the same object is returned for the same todo item.
"""
def parent_progress_may_have_changed(p_todo):
"""
Returns True when a todo's progress should be updated because it is
dependent on the parent's progress.
"""
return p_todo.has_tag('p') and not p_todo.has_tag('due')
source = p_todo.source()
if source in p_class.cache:
widget = p_class.cache[source]
if p_todo is not widget.todo:
# same source text but different todo instance (could happen
# after an edit where a new Todo instance is created with the
# same text as before)
# simply fix the reference in the stored widget.
widget.todo = p_todo
if parent_progress_may_have_changed(p_todo):
widget.update_progress()
else:
widget = p_class(p_todo)
p_class.cache[source] = widget
return widget
@classmethod
def wipe_cache(p_class):
""" Wipes the cache """
p_class.cache = {}
...@@ -24,16 +24,18 @@ class ViewWidget(urwid.LineBox): ...@@ -24,16 +24,18 @@ class ViewWidget(urwid.LineBox):
self.titleedit = urwid.Edit("Title: ", "") self.titleedit = urwid.Edit("Title: ", "")
self.sortedit = urwid.Edit("Sort expression: ", "") self.sortedit = urwid.Edit("Sort expression: ", "")
self.groupedit = urwid.Edit("Group expression: ", "")
self.filteredit = urwid.Edit("Filter expression: ", "") self.filteredit = urwid.Edit("Filter expression: ", "")
group = [] radiogroup = []
self.relevantradio = urwid.RadioButton(group, "Only show relevant todo items", True) self.relevantradio = urwid.RadioButton(radiogroup, "Only show relevant todo items", True)
self.allradio = urwid.RadioButton(group, "Show all todo items") self.allradio = urwid.RadioButton(radiogroup, "Show all todo items")
self.pile = urwid.Pile([ self.pile = urwid.Pile([
self.filteredit, self.filteredit,
self.titleedit, self.titleedit,
self.sortedit, self.sortedit,
self.groupedit,
self.relevantradio, self.relevantradio,
self.allradio, self.allradio,
urwid.Button("Save", lambda _: urwid.emit_signal(self, 'save')), urwid.Button("Save", lambda _: urwid.emit_signal(self, 'save')),
...@@ -51,6 +53,7 @@ class ViewWidget(urwid.LineBox): ...@@ -51,6 +53,7 @@ class ViewWidget(urwid.LineBox):
return { return {
'title': self.titleedit.edit_text or self.filteredit.edit_text, 'title': self.titleedit.edit_text or self.filteredit.edit_text,
'sortexpr': self.sortedit.edit_text or config().sort_string(), 'sortexpr': self.sortedit.edit_text or config().sort_string(),
'groupexpr': self.groupedit.edit_text or config().group_string(),
'filterexpr': self.filteredit.edit_text, 'filterexpr': self.filteredit.edit_text,
'show_all': self.allradio.state, 'show_all': self.allradio.state,
} }
...@@ -59,6 +62,7 @@ class ViewWidget(urwid.LineBox): ...@@ -59,6 +62,7 @@ class ViewWidget(urwid.LineBox):
def data(self, p_data): def data(self, p_data):
self.titleedit.edit_text = p_data['title'] self.titleedit.edit_text = p_data['title']
self.sortedit.edit_text = p_data['sortexpr'] self.sortedit.edit_text = p_data['sortexpr']
self.groupedit.edit_text = p_data['groupexpr']
self.filteredit.edit_text = p_data['filterexpr'] self.filteredit.edit_text = p_data['filterexpr']
self.relevantradio.set_state(not p_data['show_all']) self.relevantradio.set_state(not p_data['show_all'])
self.allradio.set_state(p_data['show_all']) self.allradio.set_state(p_data['show_all'])
......
...@@ -37,21 +37,10 @@ except ConfigError as config_error: ...@@ -37,21 +37,10 @@ except ConfigError as config_error:
sys.exit(1) sys.exit(1)
from topydo.Commands import get_subcommand from topydo.Commands import get_subcommand
from topydo.lib import TodoFile from topydo.lib.TodoFileWatched import TodoFileWatched
from topydo.lib import TodoList from topydo.lib import TodoList
def _todotxt_mtime():
"""
Returns the mtime for the configured todo.txt file.
"""
try:
return os.path.getmtime(config().todotxt())
except os.error:
# file not found
return None
class PromptApplication(CLIApplicationBase): class PromptApplication(CLIApplicationBase):
""" """
This class implements a variant of topydo's CLI showing a shell and This class implements a variant of topydo's CLI showing a shell and
...@@ -62,33 +51,25 @@ class PromptApplication(CLIApplicationBase): ...@@ -62,33 +51,25 @@ class PromptApplication(CLIApplicationBase):
super().__init__() super().__init__()
self._process_flags() self._process_flags()
self.mtime = None
self.completer = None self.completer = None
self.todofile = TodoFileWatched(config().todotxt(), self._load_file)
def _load_file(self): def _load_file(self):
""" """
Reads the configured todo.txt file and loads it into the todo list Reads the configured todo.txt file and loads it into the todo list
instance. instance.
If the modification time of the todo.txt file is equal to the last time
it was checked, nothing will be done.
""" """
current_mtime = _todotxt_mtime() self.todolist.erase()
self.todolist.add_list(self.todofile.read())
if not self.todofile or self.mtime != current_mtime: self.completer = TopydoCompleter(self.todolist)
self.todofile = TodoFile.TodoFile(config().todotxt())
self.todolist = TodoList.TodoList(self.todofile.read())
self.mtime = current_mtime
self.completer = TopydoCompleter(self.todolist)
def run(self): def run(self):
""" Main entry function. """ """ Main entry function. """
history = InMemoryHistory() history = InMemoryHistory()
self._load_file()
while True: while True:
# (re)load the todo.txt file (only if it has been modified) # (re)load the todo.txt file (only if it has been modified)
self._load_file()
try: try:
user_input = prompt(u'topydo> ', history=history, user_input = prompt(u'topydo> ', history=history,
...@@ -103,20 +84,12 @@ class PromptApplication(CLIApplicationBase): ...@@ -103,20 +84,12 @@ class PromptApplication(CLIApplicationBase):
error('Error: ' + str(verr)) error('Error: ' + str(verr))
continue continue
mtime_after = _todotxt_mtime()
try: try:
(subcommand, args) = get_subcommand(user_input) (subcommand, args) = get_subcommand(user_input)
except ConfigError as ce: except ConfigError as ce:
error('Error: ' + str(ce) + '. Check your aliases configuration') error('Error: ' + str(ce) + '. Check your aliases configuration')
continue continue
# refuse to perform operations such as 'del' and 'do' if the
# todo.txt file has been changed in the background.
if subcommand and not self.is_read_only(subcommand) and self.mtime != mtime_after:
error("WARNING: todo.txt file was modified by another application.\nTo prevent unintended changes, this operation was not executed.")
continue
try: try:
if self._execute(subcommand, args) != False: if self._execute(subcommand, args) != False:
self._post_execute() self._post_execute()
......
[all] [all]
title = All tasks title = All tasks
filterexpr = filterexpr =
groupexpr =
[today] [today]
title = Due 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