New upstream version 2.2.0
Jochen Sprickerhof
2 years ago
0 | ##### Sun Apr 17 2022 - released v2.2.0 | |
1 | ||
2 | * **Sun Apr 17 2022:** bump dependency versions | |
3 | * **Sun Apr 17 2022:** bump minimum Python version to 3.7 | |
4 | * **Tue Mar 22 2022:** Simplify timezone handling | |
5 | * **Sat Jul 24 2021:** Replace pytz and tzlocal by zoneinfo | |
6 | * **Sat Mar 05 2022:** Make vit respect taskrc in config.ini | |
7 | ||
8 | ##### Fri Nov 26 2021 - released v2.2.0b1 | |
9 | ||
10 | * **Fri Nov 26 2021:** fix #317: Broken links on PyPi | |
11 | * **Mon Nov 22 2021:** fix #313: ACTION_REFRESH keybind triggers while entering text | |
12 | * **Wed Oct 27 2021:** add `focus_on_add` configuration parameter, allows focusing on newly added task | |
13 | * **Wed Oct 27 2021:** properly escape search terms | |
14 | * **Tue Oct 12 2021:** Include 'report.X.context=0' option of tw 2.6.0 | |
15 | * **Sat Oct 09 2021:** fix #305: vit fails when using new context definition | |
16 | * **Wed Oct 06 2021:** bump tasklib min version | |
17 | * **Wed Oct 06 2021:** Support XDG_CONFIG_DIR taskrc location | |
18 | * **Wed Sep 29 2021:** fix #302: display task id of created task in command bar | |
19 | * **Sun Aug 29 2021:** fix #140, fix #230. smarter handling of spaces/quotes in autocomplete | |
20 | * **Sun Aug 29 2021:** clarify doc for finding user config directory | |
21 | * **Sun Aug 11 2019:** Add support for XDG Base Directory | |
22 | * **Thu Jul 15 2021:** set VIT_TASK_UUID environment variable when executing external scripts | |
23 | * **Thu Jul 15 2021:** allow passing custom environment variables to external commands | |
24 | * **Mon Jun 07 2021:** fix #296: AutoComplete space_escape_regex not initialized for 'wait' command | |
25 | * **Tue Mar 16 2021:** fix #287: Incorrect marker width calculation of Unicode symbols can cause markers to not be displayed | |
26 | * **Sun Feb 28 2021:** add support for TaskWarrior >= 2.5.2 to changelog | |
27 | ||
0 | 28 | ##### Sun Feb 28 2021 - released v2.1.0 |
29 | ||
30 | Support for TaskWarrior >= 2.5.2 | |
1 | 31 | |
2 | 32 | This release includes a breaking change to the keybinding parser, and may affect |
3 | 33 | users who have implemented custom keybindings in their configuration. |
1 | 1 | |
2 | 2 | ### Configuration |
3 | 3 | |
4 | #### VIT's configuration | |
5 | ||
4 | 6 | VIT provides a user directory that allows for configuring basic settings *(via ```config.ini```)*, as well as custom themes, formatters, and keybindings. |
5 | 7 | |
6 | By default, the directory is located at ```~/.vit``` | |
8 | VIT searches for the user directory in this order of priority: | |
7 | 9 | |
8 | To customize the location of the user directory, you can set the ```VIT_DIR``` environment variable. | |
10 | 1. The ```VIT_DIR``` environment variable | |
11 | 2. ```~/.vit``` (the default location) | |
12 | 3. A ```vit``` directory in any valid [XDG base directory](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) | |
13 | ||
14 | #### Taskwarrior configuration | |
9 | 15 | |
10 | 16 | By default, VIT uses the default location of the Taskwarrior configuration file to read configuration from Taskwarrior. |
11 | 17 |
0 | [build-system] | |
1 | requires = [ | |
2 | "setuptools>=42", | |
3 | "wheel" | |
4 | ] | |
5 | build-backend = "setuptools.build_meta" |
0 | pytz>=2019.3 | |
1 | tasklib>=1.3.0 | |
2 | tzlocal>=2.0.0 | |
3 | urwid>=2.1.0 | |
0 | tasklib>=2.4.3 | |
1 | urwid>=2.1.2 | |
2 | backports.zoneinfo;python_version<"3.9" |
0 | #!/usr/bin/env bash | |
1 | ||
2 | # Convenience script to handle preparing for PyPi release, and printing out the | |
3 | # commands to execute it. | |
4 | ||
5 | execute() { | |
6 | local update_build_release_packages="pip install --upgrade wheel build twine" | |
7 | local clean="rm -rfv dist/ build/" | |
8 | local build="python -m build" | |
9 | local test_pypi_upload="python -m twine upload --repository testpypi dist/*" | |
10 | local pypi_upload="python -m twine upload --skip-existing dist/*" | |
11 | ||
12 | echo "Updating build and release packages with command:" | |
13 | echo " ${update_build_release_packages}" | |
14 | ${update_build_release_packages} | |
15 | ||
16 | if [ $? -eq 0 ]; then | |
17 | echo "Cleaning build environment with command:" | |
18 | echo " ${clean}" | |
19 | ${clean} | |
20 | if [ $? -eq 0 ]; then | |
21 | echo "Building release with command:" | |
22 | echo " ${build}" | |
23 | ${build} | |
24 | if [ $? -eq 0 ]; then | |
25 | echo "Build successful" | |
26 | echo | |
27 | echo "Test release with command:" | |
28 | echo " ${test_pypi_upload}" | |
29 | echo | |
30 | echo "Release with command:" | |
31 | echo " ${pypi_upload}" | |
32 | fi | |
33 | fi | |
34 | fi | |
35 | } | |
36 | ||
37 | if [ -d vit ] && [ -r setup.py ]; then | |
38 | execute | |
39 | else | |
40 | echo "ERROR: must run script from VIT repository root" | |
41 | fi |
1 | 1 | from setuptools import setup |
2 | 2 | from os import path |
3 | 3 | |
4 | DEFAULT_BRANCH = "2.x" | |
5 | BASE_GITHUB_URL = "https://github.com/vit-project/vit/blob" | |
6 | ||
7 | MARKUP_LINK_REGEX = "\[([^]]+)\]\(([\w]+\.md)\)" | |
4 | 8 | FILE_DIR = path.dirname(path.abspath(path.realpath(__file__))) |
5 | 9 | |
6 | 10 | with open(path.join(FILE_DIR, 'README.md')) as f: |
7 | README = f.read() | |
11 | readme_contents = f.read() | |
12 | markup_link_substitution = '[\\1](%s/%s/\\2)' % (BASE_GITHUB_URL, DEFAULT_BRANCH) | |
13 | README = re.sub(MARKUP_LINK_REGEX, markup_link_substitution, readme_contents, flags=re.MULTILINE) | |
8 | 14 | |
9 | 15 | with open(path.join(FILE_DIR, 'requirements.txt')) as f: |
10 | 16 | INSTALL_PACKAGES = f.read().splitlines() |
45 | 51 | ], |
46 | 52 | }, |
47 | 53 | include_package_data=True, |
48 | python_requires='>=3.5', | |
54 | python_requires='>=3.7', | |
49 | 55 | zip_safe=False |
50 | 56 | ) |
11 | 11 | class TestListBatcher(unittest.TestCase): |
12 | 12 | |
13 | 13 | def test_batch_default_batch(self): |
14 | batcher, batch_to = default_batcher() | |
15 | complete = batcher.add() | |
16 | self.assertEqual(complete, True) | |
17 | self.assertEqual(batcher.get_last_position(), len(DEFAULT_BATCH_FROM)) | |
18 | self.assertEqual(batch_to, DEFAULT_BATCH_FROM) | |
14 | batcher, batch_to = default_batcher() | |
15 | complete = batcher.add() | |
16 | self.assertEqual(complete, True) | |
17 | self.assertEqual(batcher.get_last_position(), len(DEFAULT_BATCH_FROM)) | |
18 | self.assertEqual(batch_to, DEFAULT_BATCH_FROM) | |
19 | 19 | |
20 | 20 | def test_batch_1(self): |
21 | batcher, batch_to = default_batcher() | |
22 | complete = batcher.add(1) | |
23 | self.assertEqual(complete, False) | |
24 | self.assertEqual(batcher.get_last_position(), 1) | |
25 | self.assertEqual(batch_to, ['a']) | |
21 | batcher, batch_to = default_batcher() | |
22 | complete = batcher.add(1) | |
23 | self.assertEqual(complete, False) | |
24 | self.assertEqual(batcher.get_last_position(), 1) | |
25 | self.assertEqual(batch_to, ['a']) | |
26 | 26 | |
27 | 27 | def test_batch_5(self): |
28 | batcher, batch_to = default_batcher() | |
29 | complete = batcher.add(5) | |
30 | self.assertEqual(complete, False) | |
31 | self.assertEqual(batcher.get_last_position(), 5) | |
32 | self.assertEqual(batch_to, ['a', 'b', 'c', 'd', 'e']) | |
28 | batcher, batch_to = default_batcher() | |
29 | complete = batcher.add(5) | |
30 | self.assertEqual(complete, False) | |
31 | self.assertEqual(batcher.get_last_position(), 5) | |
32 | self.assertEqual(batch_to, ['a', 'b', 'c', 'd', 'e']) | |
33 | 33 | |
34 | 34 | def test_batch_1_and_1(self): |
35 | batcher, batch_to = default_batcher() | |
36 | batcher.add(1) | |
37 | complete = batcher.add(1) | |
38 | self.assertEqual(complete, False) | |
39 | self.assertEqual(batcher.get_last_position(), 2) | |
40 | self.assertEqual(batch_to, ['a', 'b']) | |
35 | batcher, batch_to = default_batcher() | |
36 | batcher.add(1) | |
37 | complete = batcher.add(1) | |
38 | self.assertEqual(complete, False) | |
39 | self.assertEqual(batcher.get_last_position(), 2) | |
40 | self.assertEqual(batch_to, ['a', 'b']) | |
41 | 41 | |
42 | 42 | def test_batch_1_and_rest(self): |
43 | batcher, batch_to = default_batcher() | |
44 | batcher.add(1) | |
45 | complete = batcher.add(0) | |
46 | self.assertEqual(complete, True) | |
47 | self.assertEqual(batcher.get_last_position(), len(DEFAULT_BATCH_FROM)) | |
48 | self.assertEqual(batch_to, DEFAULT_BATCH_FROM) | |
43 | batcher, batch_to = default_batcher() | |
44 | batcher.add(1) | |
45 | complete = batcher.add(0) | |
46 | self.assertEqual(complete, True) | |
47 | self.assertEqual(batcher.get_last_position(), len(DEFAULT_BATCH_FROM)) | |
48 | self.assertEqual(batch_to, DEFAULT_BATCH_FROM) | |
49 | 49 | |
50 | 50 | def test_batch_batch_size_greater_than_default(self): |
51 | batcher, batch_to = default_batcher() | |
52 | complete = batcher.add(100000) | |
53 | self.assertEqual(complete, True) | |
54 | self.assertEqual(batcher.get_last_position(), len(DEFAULT_BATCH_FROM)) | |
55 | self.assertEqual(batch_to, DEFAULT_BATCH_FROM) | |
51 | batcher, batch_to = default_batcher() | |
52 | complete = batcher.add(100000) | |
53 | self.assertEqual(complete, True) | |
54 | self.assertEqual(batcher.get_last_position(), len(DEFAULT_BATCH_FROM)) | |
55 | self.assertEqual(batch_to, DEFAULT_BATCH_FROM) | |
56 | 56 | |
57 | 57 | def test_batch_add_on_completed(self): |
58 | batcher, batch_to = default_batcher() | |
59 | complete = batcher.add() | |
60 | self.assertEqual(complete, True) | |
61 | complete = batcher.add() | |
62 | self.assertEqual(complete, True) | |
63 | self.assertEqual(batcher.get_last_position(), len(DEFAULT_BATCH_FROM)) | |
64 | self.assertEqual(batch_to, DEFAULT_BATCH_FROM) | |
58 | batcher, batch_to = default_batcher() | |
59 | complete = batcher.add() | |
60 | self.assertEqual(complete, True) | |
61 | complete = batcher.add() | |
62 | self.assertEqual(complete, True) | |
63 | self.assertEqual(batcher.get_last_position(), len(DEFAULT_BATCH_FROM)) | |
64 | self.assertEqual(batch_to, DEFAULT_BATCH_FROM) | |
65 | 65 | |
66 | 66 | def test_batch_5_with_formatter(self): |
67 | def formatter(partial, start_idx): | |
68 | return ['before'] + [row * 2 for row in partial] + ['after'] | |
69 | batch_to = [] | |
70 | batcher = ListBatcher(DEFAULT_BATCH_FROM, batch_to, batch_to_formatter=formatter) | |
71 | complete = batcher.add(5) | |
72 | self.assertEqual(complete, False) | |
73 | self.assertEqual(batcher.get_last_position(), 5) | |
74 | self.assertEqual(batch_to, ['before', 'aa', 'bb', 'cc', 'dd', 'ee', 'after']) | |
67 | def formatter(partial, start_idx): | |
68 | return ['before'] + [row * 2 for row in partial] + ['after'] | |
69 | batch_to = [] | |
70 | batcher = ListBatcher(DEFAULT_BATCH_FROM, batch_to, batch_to_formatter=formatter) | |
71 | complete = batcher.add(5) | |
72 | self.assertEqual(complete, False) | |
73 | self.assertEqual(batcher.get_last_position(), 5) | |
74 | self.assertEqual(batch_to, ['before', 'aa', 'bb', 'cc', 'dd', 'ee', 'after']) | |
75 | 75 | |
76 | 76 | if __name__ == '__main__': |
77 | 77 | unittest.main() |
18 | 18 | from vit import event |
19 | 19 | from vit.loader import Loader |
20 | 20 | from vit.config_parser import ConfigParser, TaskParser |
21 | from vit.util import clear_screen, string_to_args, is_mouse_event | |
21 | from vit.util import clear_screen, string_to_args, is_mouse_event, task_id_or_uuid_short | |
22 | 22 | from vit.process import Command |
23 | 23 | from vit.task import TaskListModel |
24 | 24 | from vit.autocomplete import AutoComplete |
52 | 52 | self.action_manager_registrar = self.action_manager.get_registrar() |
53 | 53 | self.action_manager_registrar.register('REFRESH', self.refresh) |
54 | 54 | |
55 | def is_default_refresh_key(self, keys): | |
56 | return keys == 'ctrl l' | |
57 | ||
55 | 58 | def keypress(self, size, key): |
56 | 59 | keys = self.key_cache.get(key) |
57 | if self.action_manager_registrar.handled_action(keys): | |
60 | if self.action_manager_registrar.handled_action(keys) and self.is_default_refresh_key(keys): | |
58 | 61 | # NOTE: Calling refresh directly here to avoid the |
59 | 62 | # action-manager:action-executed event, which clobbers the load |
60 | 63 | # time currently. |
145 | 148 | self.action_manager_registrar = self.action_manager.get_registrar() |
146 | 149 | self.action_manager_registrar.register('QUIT', self.quit) |
147 | 150 | self.action_manager_registrar.register('QUIT_WITH_CONFIRM', self.activate_command_bar_quit_with_confirm) |
151 | # NOTE: This is a no-op for the default refresh keybinding, which is | |
152 | # handled by the MainFrame() class. It's included here in case the user | |
153 | # assigns a non-default key to the REFRESH action. | |
154 | self.action_manager_registrar.register('REFRESH', self.refresh) | |
148 | 155 | self.action_manager_registrar.register('TASK_ADD', self.activate_command_bar_add) |
149 | 156 | self.action_manager_registrar.register('REPORT_FILTER', self.activate_command_bar_filter) |
150 | 157 | self.action_manager_registrar.register('TASK_UNDO', self.task_undo) |
353 | 360 | elif len(args) > 0: |
354 | 361 | if op == 'add': |
355 | 362 | if self.execute_command(['task', 'add'] + args, wait=self.wait): |
356 | self.activate_message_bar('Task added') | |
363 | task = self.task_get_latest() | |
364 | self.activate_message_bar('Task %s added' % task_id_or_uuid_short(task)) | |
365 | self.focus_new_task(task) | |
357 | 366 | elif op == 'modify': |
358 | 367 | # TODO: Will this break if user clicks another list item |
359 | 368 | # before hitting enter? |
379 | 388 | if 'uuid' in metadata: |
380 | 389 | self.task_list.focus_by_task_uuid(metadata['uuid'], self.previous_focus_position) |
381 | 390 | |
391 | def focus_new_task(self, task): | |
392 | if self.config.get('vit', 'focus_on_add'): | |
393 | self.task_list.focus_by_task_uuid(task['uuid'], self.previous_focus_position) | |
394 | ||
382 | 395 | def key_pressed(self, key): |
383 | 396 | if is_mouse_event(key): |
384 | 397 | return None |
437 | 450 | kwargs['wait'] = False |
438 | 451 | else: |
439 | 452 | kwargs['wait'] = True |
453 | uuid, _ = self.get_focused_task() | |
454 | if not uuid: | |
455 | uuid = "" | |
456 | kwargs['custom_env'] = { | |
457 | "VIT_TASK_UUID": uuid, | |
458 | } | |
440 | 459 | self.execute_command(args, **kwargs) |
441 | 460 | elif command.isdigit(): |
442 | 461 | self.task_list.focus_by_task_id(int(command)) |
484 | 503 | self.task_list.focus_position = new_focus |
485 | 504 | |
486 | 505 | def search_rows(self, term, start_index=0, reverse=False): |
487 | search_regex = re.compile(term, re.MULTILINE) | |
506 | escaped_term = re.escape(term) | |
507 | search_regex = re.compile(escaped_term, re.MULTILINE) | |
488 | 508 | rows = self.table.rows |
489 | 509 | current_index = start_index |
490 | 510 | last_index = len(rows) - 1 |
535 | 555 | return False |
536 | 556 | |
537 | 557 | def get_focused_task(self): |
538 | if self.widget.focus_position == 'body': | |
539 | try: | |
540 | uuid = self.task_list.focus.uuid | |
541 | task = self.model.get_task(uuid) | |
542 | return uuid, task | |
543 | except: | |
544 | pass | |
558 | try: | |
559 | uuid = self.task_list.focus.uuid | |
560 | task = self.model.get_task(uuid) | |
561 | return uuid, task | |
562 | except: | |
563 | pass | |
545 | 564 | return False, False |
546 | 565 | |
547 | 566 | def quit(self): |
636 | 655 | self.command_bar.activate(caption, metadata, edit_text) |
637 | 656 | self.widget.focus_position = 'footer' |
638 | 657 | |
639 | def activate_command_bar_add(self): | |
640 | self.activate_command_bar('add', 'Add: ') | |
641 | 658 | |
642 | 659 | def activate_command_bar_filter(self): |
643 | 660 | self.activate_command_bar('filter', 'Filter: ') |
647 | 664 | |
648 | 665 | def task_sync(self): |
649 | 666 | self.execute_command(['task', 'sync']) |
667 | ||
668 | def task_get_latest(self): | |
669 | returncode, stdout, stderr = self.command.run(['task', '+LATEST', 'uuids'], capture_output=True) | |
670 | if returncode == 0: | |
671 | return self.model.get_task(stdout) | |
672 | else: | |
673 | raise RuntimeError("Error retrieving latest task UUID: %s" % stderr) | |
650 | 674 | |
651 | 675 | def activate_command_bar_quit_with_confirm(self): |
652 | 676 | if self.confirm: |
682 | 706 | def global_escape(self): |
683 | 707 | self.denotation_pop_up.close_pop_up() |
684 | 708 | |
709 | def activate_command_bar_add(self): | |
710 | self.activate_command_bar('add', 'Add: ') | |
685 | 711 | |
686 | 712 | def task_done(self, uuid): |
687 | 713 | success, task = self.model.task_done(uuid) |
731 | 757 | def task_action_denotate(self): |
732 | 758 | uuid, task = self.get_focused_task() |
733 | 759 | if task and task['annotations']: |
734 | self.denotation_pop_up.open(task) | |
760 | self.denotation_pop_up.open(task) | |
735 | 761 | |
736 | 762 | def task_action_modify(self): |
737 | 763 | uuid, _ = self.get_focused_task() |
908 | 934 | self.task_config.get_projects() |
909 | 935 | self.refresh_blocking_task_uuids() |
910 | 936 | self.formatter.recalculate_due_datetimes() |
911 | context_filters = self.contexts[self.context]['filter'] if self.context else [] | |
937 | context_filters = self.contexts[self.context]['filter'] if self.context and self.reports[self.report].get('context', 1) else [] | |
912 | 938 | try: |
913 | 939 | self.model.update_report(self.report, context_filters=context_filters, extra_filters=self.extra_filters) |
914 | 940 | except VitException as err: |
124 | 124 | self.teardown() |
125 | 125 | |
126 | 126 | def activate(self, text, edit_pos, reverse=False): |
127 | if not self.is_setup: | |
128 | return | |
127 | 129 | if self.activated: |
128 | 130 | self.send_tabbed_text(text, edit_pos, reverse) |
129 | 131 | return |
179 | 181 | |
180 | 182 | def add_space_escaping(self, text): |
181 | 183 | if self.space_escape_regex.match(text): |
182 | return text.replace(' ', '\ ') | |
184 | parts = text.split(':', 1) | |
185 | if len(parts) > 1: | |
186 | return "%s:'%s'" % (parts[0], parts[1]) | |
187 | else: | |
188 | return "'%s'" % text | |
183 | 189 | else: |
184 | 190 | return text |
185 | 191 | |
188 | 194 | |
189 | 195 | def parse_text(self, text, edit_pos): |
190 | 196 | full_prefix = text[:edit_pos] |
191 | self.prefix_parts = list(map(self.add_space_escaping, util.string_to_args(full_prefix))) | |
197 | self.prefix_parts = list(map(self.add_space_escaping, util.string_to_args_on_whitespace(full_prefix))) | |
192 | 198 | if not self.prefix_parts: |
193 | 199 | self.search_fragment = self.prefix = full_prefix |
194 | 200 | self.suffix = text[(edit_pos + 1):] |
51 | 51 | |
52 | 52 | # Boolean. If true, hitting backspace against an empty prompt aborts the prompt. |
53 | 53 | #abort_backspace = False |
54 | ||
55 | # Boolean. If true, VIT will focus on the newly added task. Note: the new task must be | |
56 | #included in the active filter for this setting to have effect. | |
57 | #focus_on_add = False | |
54 | 58 | |
55 | 59 | [report] |
56 | 60 |
11 | 11 | except ImportError: |
12 | 12 | import ConfigParser as configparser |
13 | 13 | |
14 | from vit import env | |
14 | from vit import env, xdg | |
15 | 15 | from vit.process import Command |
16 | 16 | |
17 | 17 | SORT_ORDER_CHARACTERS = ['+', '-'] |
44 | 44 | 'wait': True, |
45 | 45 | 'mouse': False, |
46 | 46 | 'abort_backspace': False, |
47 | 'focus_on_add': False, | |
47 | 48 | }, |
48 | 49 | 'report': { |
49 | 50 | 'default_report': 'next', |
96 | 97 | def __init__(self, loader): |
97 | 98 | self.loader = loader |
98 | 99 | self.config = configparser.SafeConfigParser() |
99 | self.config.optionxform=str | |
100 | self.config.optionxform = str | |
100 | 101 | self.user_config_dir = self.loader.user_config_dir |
101 | 102 | self.user_config_filepath = '%s/%s' % (self.user_config_dir, VIT_CONFIG_FILE) |
102 | 103 | if not self.config_file_exists(self.user_config_filepath): |
103 | 104 | self.optional_create_config_file(self.user_config_filepath) |
105 | self.config.read(self.user_config_filepath) | |
104 | 106 | self.taskrc_path = self.get_taskrc_path() |
105 | 107 | self.validate_taskrc() |
106 | self.config.read(self.user_config_filepath) | |
107 | 108 | self.defaults = DEFAULTS |
108 | 109 | self.set_config_data() |
109 | 110 | |
185 | 186 | return True if CONFIG_BOOLEAN_TRUE_REGEX.match(value) else False |
186 | 187 | |
187 | 188 | def get_taskrc_path(self): |
188 | return os.path.expanduser('TASKRC' in env.user and env.user['TASKRC'] or self.get('taskwarrior', 'taskrc')) | |
189 | taskrc_path = os.path.expanduser('TASKRC' in env.user and env.user['TASKRC'] or self.get('taskwarrior', 'taskrc')) | |
190 | ||
191 | if not os.path.exists(taskrc_path): | |
192 | xdg_dir = xdg.get_xdg_config_dir(taskrc_path, "task") | |
193 | if xdg_dir: | |
194 | taskrc_path = os.path.join(xdg_dir, "taskrc") | |
195 | ||
196 | return taskrc_path | |
189 | 197 | |
190 | 198 | def is_subproject_indentable(self): |
191 | 199 | return self.get('report', 'indent_subprojects') |
264 | 272 | return list(filter(lambda config_pair: re.match(matcher_regex, config_pair[0]), self.task_config)) |
265 | 273 | |
266 | 274 | def subtree(self, matcher, walk_subtree=True): |
267 | matcher_regex = matcher | |
268 | if walk_subtree: | |
269 | matcher_regex = r'%s' % (('^%s' % matcher).replace('.', '\.')) | |
270 | full_tree = {} | |
271 | lines = self.filter(matcher_regex) | |
272 | for (hierarchy, value) in lines: | |
273 | # NOTE: This is necessary in order to convert Taskwarrior's dotted | |
274 | # config syntax into a full tree, as some leaves are both branches | |
275 | # and leaves. | |
276 | hierarchy = self.transform_string_leaves(hierarchy) | |
277 | parts = hierarchy.split('.') | |
278 | tree_location = full_tree | |
279 | while True: | |
280 | if len(parts): | |
281 | part = parts.pop(0) | |
282 | if part not in tree_location: | |
283 | tree_location[part] = {} if len(parts) else value | |
284 | tree_location = tree_location[part] | |
285 | else: | |
286 | break | |
287 | if walk_subtree: | |
288 | parts = matcher.split('.') | |
289 | subtree = full_tree | |
290 | while True: | |
291 | if len(parts): | |
292 | part = parts.pop(0) | |
293 | if part in subtree: | |
294 | subtree = subtree[part] | |
295 | else: | |
296 | return subtree | |
297 | else: | |
298 | return full_tree | |
275 | matcher_regex = matcher | |
276 | if walk_subtree: | |
277 | matcher_regex = r'%s' % (('^%s' % matcher).replace('.', '\.')) | |
278 | full_tree = {} | |
279 | lines = self.filter(matcher_regex) | |
280 | for (hierarchy, value) in lines: | |
281 | # NOTE: This is necessary in order to convert Taskwarrior's dotted | |
282 | # config syntax into a full tree, as some leaves are both branches | |
283 | # and leaves. | |
284 | hierarchy = self.transform_string_leaves(hierarchy) | |
285 | parts = hierarchy.split('.') | |
286 | tree_location = full_tree | |
287 | while True: | |
288 | if len(parts): | |
289 | part = parts.pop(0) | |
290 | if part not in tree_location: | |
291 | tree_location[part] = {} if len(parts) else value | |
292 | tree_location = tree_location[part] | |
293 | else: | |
294 | break | |
295 | if walk_subtree: | |
296 | parts = matcher.split('.') | |
297 | subtree = full_tree | |
298 | while True: | |
299 | if len(parts): | |
300 | part = parts.pop(0) | |
301 | if part in subtree: | |
302 | subtree = subtree[part] | |
303 | else: | |
304 | return subtree | |
305 | else: | |
306 | return full_tree | |
299 | 307 | |
300 | 308 | def parse_sort_column(self, column_string): |
301 | 309 | order = collate = None |
323 | 331 | self.get_task_config() |
324 | 332 | subtree = self.subtree('context.') |
325 | 333 | for context, filters in list(subtree.items()): |
326 | filters = shlex.split(re.sub(FILTER_PARENS_REGEX, r' \1 ', filters)) | |
327 | 334 | contexts[context] = { |
328 | 'filter': [f for f in filters if not FILTER_EXCLUSION_REGEX.match(f)], | |
335 | 'filter': self.parse_context_filters(context, filters), | |
329 | 336 | } |
330 | 337 | self.contexts = contexts |
331 | 338 | return self.contexts |
332 | 339 | |
340 | def parse_context_filters(self, context, filters): | |
341 | # Filters can be a string (pre-2.6.0 definition, context.work=+work) | |
342 | # or a dict (2.6.0 and newer, context.work.read=+work and | |
343 | # context.work.write=+work). | |
344 | if type(filters) is dict: | |
345 | if 'read' in filters: | |
346 | filters = filters['read'] | |
347 | else: | |
348 | return [] # Only contexts with read component defined should be considered. | |
349 | filters = shlex.split(re.sub(FILTER_PARENS_REGEX, r' \1 ', filters)) | |
350 | final_filters = [f for f in filters if not FILTER_EXCLUSION_REGEX.match(f)] | |
351 | return final_filters | |
352 | ||
333 | 353 | def get_reports(self): |
334 | reports = {} | |
335 | subtree = self.subtree('report.') | |
336 | for report, attrs in list(subtree.items()): | |
337 | if report in self.disallowed_reports: | |
338 | continue | |
339 | reports[report] = { | |
340 | 'name': report, | |
341 | 'subproject_indentable': False, | |
342 | } | |
343 | if 'columns' in attrs: | |
344 | reports[report]['columns'] = attrs['columns'].split(',') | |
345 | if 'description' in attrs: | |
346 | reports[report]['description'] = attrs['description'] | |
347 | if 'filter' in attrs: | |
348 | # Allows quoted strings. | |
349 | # Adjust for missing spaces around parentheses. | |
350 | filters = shlex.split(re.sub(FILTER_PARENS_REGEX, r' \1 ', attrs['filter'])) | |
351 | reports[report]['filter'] = [f for f in filters if not FILTER_EXCLUSION_REGEX.match(f)] | |
352 | if 'labels' in attrs: | |
353 | reports[report]['labels'] = attrs['labels'].split(',') | |
354 | else: | |
355 | reports[report]['labels'] = [ column.title() for column in attrs['columns'].split(',') ] | |
356 | if 'sort' in attrs: | |
357 | columns = attrs['sort'].split(',') | |
358 | reports[report]['sort'] = [self.parse_sort_column(c) for c in columns] | |
359 | if 'dateformat' in attrs: | |
360 | reports[report]['dateformat'] = self.translate_date_markers(attrs['dateformat']) | |
361 | ||
362 | self.reports = reports | |
363 | # Another pass is needed after all report data has been parsed. | |
364 | for report_name, report in self.reports.items(): | |
365 | self.reports[report_name] = self.rectify_report(report_name, report) | |
366 | return self.reports | |
354 | reports = {} | |
355 | subtree = self.subtree('report.') | |
356 | for report, attrs in list(subtree.items()): | |
357 | if report in self.disallowed_reports: | |
358 | continue | |
359 | reports[report] = { | |
360 | 'name': report, | |
361 | 'subproject_indentable': False, | |
362 | } | |
363 | if 'columns' in attrs: | |
364 | reports[report]['columns'] = attrs['columns'].split(',') | |
365 | if 'context' in attrs: | |
366 | reports[report]['context'] = int(attrs['context']) | |
367 | if 'description' in attrs: | |
368 | reports[report]['description'] = attrs['description'] | |
369 | if 'filter' in attrs: | |
370 | # Allows quoted strings. | |
371 | # Adjust for missing spaces around parentheses. | |
372 | filters = shlex.split(re.sub(FILTER_PARENS_REGEX, r' \1 ', attrs['filter'])) | |
373 | reports[report]['filter'] = [f for f in filters if not FILTER_EXCLUSION_REGEX.match(f)] | |
374 | if 'labels' in attrs: | |
375 | reports[report]['labels'] = attrs['labels'].split(',') | |
376 | else: | |
377 | reports[report]['labels'] = [column.title() for column in attrs['columns'].split(',')] | |
378 | if 'sort' in attrs: | |
379 | columns = attrs['sort'].split(',') | |
380 | reports[report]['sort'] = [self.parse_sort_column(c) for c in columns] | |
381 | if 'dateformat' in attrs: | |
382 | reports[report]['dateformat'] = self.translate_date_markers(attrs['dateformat']) | |
383 | ||
384 | self.reports = reports | |
385 | # Another pass is needed after all report data has been parsed. | |
386 | for report_name, report in self.reports.items(): | |
387 | self.reports[report_name] = self.rectify_report(report_name, report) | |
388 | return self.reports | |
367 | 389 | |
368 | 390 | def rectify_report(self, report_name, report): |
369 | 391 | report['subproject_indentable'] = self.has_project_column(report_name) and self.has_primary_project_ascending_sort(report) |
0 | 0 | import math |
1 | 1 | from datetime import datetime |
2 | from pytz import timezone | |
2 | try: | |
3 | from zoneinfo import ZoneInfo | |
4 | except ImportError: | |
5 | from backports.zoneinfo import ZoneInfo | |
3 | 6 | |
4 | 7 | TIME_UNIT_MAP = { |
5 | 8 | 'seconds': { |
149 | 152 | def age(self, dt): |
150 | 153 | if dt == None: |
151 | 154 | return '' |
152 | now = datetime.now(self.formatter.zone) | |
155 | now = datetime.now().astimezone() | |
153 | 156 | seconds = (now - dt).total_seconds() |
154 | 157 | return self.format_duration_vague(seconds) |
155 | 158 | |
156 | 159 | def countdown(self, dt): |
157 | 160 | if dt == None: |
158 | 161 | return '' |
159 | now = datetime.now(self.formatter.zone) | |
162 | now = datetime.now().astimezone() | |
160 | 163 | if dt < now: |
161 | 164 | return '' |
162 | 165 | seconds = (dt - now).total_seconds() |
165 | 168 | def relative(self, dt): |
166 | 169 | if dt == None: |
167 | 170 | return '' |
168 | now = datetime.now(self.formatter.zone) | |
171 | now = datetime.now().astimezone() | |
169 | 172 | seconds = (dt - now).total_seconds() |
170 | 173 | return self.format_duration_vague(seconds) |
171 | 174 | |
172 | 175 | def remaining(self, dt): |
173 | 176 | if dt == None: |
174 | 177 | return '' |
175 | now = datetime.now(self.formatter.zone) | |
178 | now = datetime.now().astimezone() | |
176 | 179 | if dt < now: |
177 | 180 | return '' |
178 | 181 | seconds = (dt - now).total_seconds() |
197 | 200 | def iso(self, dt): |
198 | 201 | if dt == None: |
199 | 202 | return '' |
200 | dt = dt.replace(tzinfo=timezone('UTC')) | |
203 | dt = dt.replace(tzinfo=ZoneInfo('UTC')) | |
201 | 204 | return dt.isoformat() |
202 | 205 | |
203 | 206 | class List(Formatter): |
0 | import unicodedata | |
0 | 1 | from vit.formatter import Marker |
1 | 2 | |
2 | 3 | class Markers(Marker): |
34 | 35 | def add_label(self, color, label, width, text_markup): |
35 | 36 | if self.color_required(color) or not label: |
36 | 37 | return width, text_markup |
37 | width += len(label) | |
38 | width += len(label) + len([c for c in label if unicodedata.east_asian_width(c) == 'W']) | |
38 | 39 | text_markup += [(color, label)] |
39 | 40 | return width, text_markup |
40 | 41 |
0 | 0 | from importlib import import_module |
1 | 1 | from datetime import datetime, timedelta |
2 | from tzlocal import get_localzone | |
3 | from pytz import timezone | |
2 | try: | |
3 | from zoneinfo import ZoneInfo | |
4 | except ImportError: | |
5 | from backports.zoneinfo import ZoneInfo | |
4 | 6 | |
5 | 7 | from vit import util |
6 | 8 | from vit import uda |
26 | 28 | self.report = self.task_config.translate_date_markers(self.task_config.subtree('dateformat.report')) or self.date_default |
27 | 29 | self.annotation = self.task_config.translate_date_markers(self.task_config.subtree('dateformat.annotation')) or self.date_default |
28 | 30 | self.description_truncate_len = DEFAULT_DESCRIPTION_TRUNCATE_LEN |
29 | self.zone = get_localzone() | |
30 | self.epoch_datetime = datetime(1970, 1, 1, tzinfo=timezone('UTC')) | |
31 | self.epoch_datetime = datetime(1970, 1, 1, tzinfo=ZoneInfo('UTC')) | |
31 | 32 | self.due_days = int(self.task_config.subtree('due')) |
32 | 33 | self.none_label = config.get('color', 'none_label') |
33 | 34 | self.build_indicators() |
96 | 97 | return (width, ' ' * space_padding , indicator, subproject) |
97 | 98 | |
98 | 99 | def recalculate_due_datetimes(self): |
99 | self.now = datetime.now(self.zone) | |
100 | self.now = datetime.now().astimezone() | |
100 | 101 | # NOTE: For some reason using self.zone for the tzinfo below results |
101 | 102 | # in the tzinfo object having a zone of 'LMT', which is wrong. Using |
102 | 103 | # the tzinfo associated with self.now returns the correct value, no |
3 | 3 | except: |
4 | 4 | import imp |
5 | 5 | |
6 | from vit import env | |
6 | from vit import env, xdg | |
7 | 7 | |
8 | 8 | DEFAULT_VIT_DIR = '~/.vit' |
9 | 9 | |
10 | 10 | class Loader(object): |
11 | 11 | def __init__(self): |
12 | 12 | self.user_config_dir = os.path.expanduser('VIT_DIR' in env.user and env.user['VIT_DIR'] or DEFAULT_VIT_DIR) |
13 | ||
14 | if not os.path.exists(self.user_config_dir): | |
15 | xdg_dir = xdg.get_xdg_config_dir(self.user_config_dir, "vit") | |
16 | if xdg_dir: | |
17 | self.user_config_dir = xdg_dir | |
13 | 18 | |
14 | 19 | def load_user_class(self, module_type, module_name, class_name): |
15 | 20 | module = '%s.%s' % (module_type, module_name) |
0 | 0 | import os |
1 | 1 | import re |
2 | 2 | import subprocess |
3 | import copy | |
3 | 4 | |
4 | 5 | from vit import env |
5 | 6 | from vit.util import clear_screen, string_to_args |
13 | 14 | self.env = env.user.copy() |
14 | 15 | self.env['TASKRC'] = self.config.taskrc_path |
15 | 16 | |
16 | def run(self, command, capture_output=False): | |
17 | def run(self, command, capture_output=False, custom_env={}): | |
17 | 18 | if isinstance(command, str): |
18 | 19 | command = string_to_args(command) |
20 | env = copy.copy(self.env) | |
21 | env.update(custom_env) | |
19 | 22 | kwargs = { |
20 | 'env': self.env, | |
23 | 'env': env, | |
21 | 24 | } |
22 | 25 | if capture_output: |
23 | 26 | kwargs.update({ |
35 | 38 | returncode = 1 |
36 | 39 | return returncode, stdout, self.filter_errors(returncode, stderr) |
37 | 40 | |
38 | def result(self, command, confirm=DEFAULT_CONFIRM, capture_output=False, print_output=False, clear=True): | |
41 | def result(self, command, confirm=DEFAULT_CONFIRM, capture_output=False, print_output=False, clear=True, custom_env={}): | |
39 | 42 | if clear: |
40 | 43 | clear_screen() |
41 | returncode, stdout, stderr = self.run(command, capture_output) | |
44 | returncode, stdout, stderr = self.run(command, capture_output, custom_env) | |
42 | 45 | output = returncode == 0 and stdout or stderr |
43 | 46 | if print_output: |
44 | 47 | print(output) |
72 | 72 | |
73 | 73 | edit_text = self.edit_obj.get_edit_text() |
74 | 74 | self.edit_obj.set_edit_text( |
75 | edit_text[: new_edit_pos - 2] + | |
76 | edit_text[new_edit_pos - 1] + | |
77 | edit_text[new_edit_pos - 2] + | |
78 | edit_text[new_edit_pos :]) | |
75 | edit_text[: new_edit_pos - 2] + | |
76 | edit_text[new_edit_pos - 1] + | |
77 | edit_text[new_edit_pos - 2] + | |
78 | edit_text[new_edit_pos :]) | |
79 | 79 | self.edit_obj.set_edit_pos(new_edit_pos) |
80 | 80 | return None |
81 | 81 | # Delete backwards to the beginning of the line. |
12 | 12 | |
13 | 13 | def string_to_args(string): |
14 | 14 | try: |
15 | return shlex.split(string) | |
15 | return shlex.split(string) | |
16 | 16 | except ValueError: |
17 | return [] | |
17 | return [] | |
18 | ||
19 | def string_to_args_on_whitespace(string): | |
20 | try: | |
21 | lex = shlex.shlex(string) | |
22 | lex.whitespace_split = True | |
23 | return list(lex) | |
24 | except ValueError: | |
25 | return [] | |
18 | 26 | |
19 | 27 | def is_mouse_event(key): |
20 | 28 | return not isinstance(key, str) |
0 | import os | |
1 | ||
2 | from vit import env | |
3 | ||
4 | ||
5 | def get_xdg_config_dir(user_config_dir, resource): | |
6 | xdg_config_home = env.user.get("XDG_CONFIG_HOME") or os.path.join( | |
7 | os.path.expanduser("~"), ".config" | |
8 | ) | |
9 | ||
10 | xdg_config_dirs = [xdg_config_home] + ( | |
11 | env.user.get("XDG_CONFIG_DIRS") or "/etc/xdg" | |
12 | ).split(":") | |
13 | ||
14 | for config_dir in xdg_config_dirs: | |
15 | path = os.path.join(config_dir, resource) | |
16 | if os.path.exists(path): | |
17 | return path | |
18 | return None |