diff --git a/README.md b/README.md index 5c996f7..a215e2f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Visual Interactive Taskwarrior full-screen terminal interface. * Speed * Per-column colorization * Advanced tab completion + * Bulk modification of tasks * Multiple/customizable themes * Override/customize column formatters * Intelligent sub-project indenting diff --git a/vit/actions.py b/vit/actions.py index 6a30453..67dfbfb 100644 --- a/vit/actions.py +++ b/vit/actions.py @@ -38,6 +38,7 @@ def register(self): self.action_registrar.register('TASK_DELETE', 'Delete task') self.action_registrar.register('TASK_DENOTATE', 'Denotate a task') self.action_registrar.register('TASK_MODIFY', 'Modify task (supports tab completion)') + self.action_registrar.register('TASK_MODIFY_ALL', 'Modify all tasks currently in view') self.action_registrar.register('TASK_START_STOP', 'Start/stop task') self.action_registrar.register('TASK_DONE', 'Mark task done') self.action_registrar.register('TASK_PRIORITY', 'Modify task priority') diff --git a/vit/application.py b/vit/application.py index c82ea3e..a490d89 100644 --- a/vit/application.py +++ b/vit/application.py @@ -97,6 +97,13 @@ def setup_main_loop(self): def set_active_context(self): self.context = self.task_config.get_active_context() + def active_context_filter(self): + return self.contexts[self.context]['filter'] if self.context and self.reports[self.report].get('context', 1) else [] + + def active_view_filters(self): + # precedence-preserving concatenation of context, report and extra filters + return self.model.build_task_filters(self.active_context_filter(), self.model.active_report_filter(), self.extra_filters) + def load_contexts(self): self.contexts = self.task_config.get_contexts() @@ -164,6 +171,7 @@ def register_managed_actions(self): self.action_manager_registrar.register('TASK_DELETE', self.task_action_delete) self.action_manager_registrar.register('TASK_DENOTATE', self.task_action_denotate) self.action_manager_registrar.register('TASK_MODIFY', self.task_action_modify) + self.action_manager_registrar.register('TASK_MODIFY_ALL', self.task_action_modify_all) self.action_manager_registrar.register('TASK_START_STOP', self.task_action_start_stop) self.action_manager_registrar.register('TASK_DONE', self.task_action_done) self.action_manager_registrar.register('TASK_PRIORITY', self.task_action_priority) @@ -195,6 +203,7 @@ def _task_attribute_replace(task, attribute): else: return str(task[attribute]) return '' + replacements = [ { 'match_callback': _task_attribute_match, @@ -361,6 +370,28 @@ def command_bar_keypress(self, data): # before hitting enter? if self.execute_command(['task', metadata['uuid'], 'modify'] + args, wait=self.wait): self.activate_message_bar('Task %s modified' % self.model.task_id(metadata['uuid'])) + elif op == 'modify_bulk': + # same underlying command as the modify command above, only + # the message bar is set to information about the number of + # tasks modified, instead of a single task info + + # to be absolutely safe, double-check whether the number of tasks matched by + # the filter is still the same (or has changed because of some other operations, + # due/schedule/wait timeouts etc) + ntasks = self.model.get_n_tasks(metadata['target']) + if ntasks != metadata['ntasks']: + self.activate_message_bar('Not applying the modification because the number of tasks has changed (was %s now %s)' % (metadata['ntasks'], ntasks)) + elif self.execute_command(['task', metadata['target'], 'modify'] + args, wait=self.wait): + # TODO depending on interactive confirmation prompts, + # not all tasks might actually have been modified. for + # completeness one would need to extract the number of + # modified tasks from the stdout of the task command above. + # this is currently not possible because execute_command(), + # even when called with capture_output=True, doesn't return + # the captured output back to here. An easier method might + # be to go through tasklib and simply check the number of + # modified tasks based on their modified time + self.activate_message_bar('Attempted to modify %s tasks, mileage may vary' % ntasks) elif op == 'annotate': task = self.model.task_annotate(metadata['uuid'], data['text']) if task: @@ -754,6 +785,11 @@ def task_action_modify(self): self.activate_command_bar('modify', 'Modify: ', {'uuid': uuid}) self.task_list.focus_by_task_uuid(uuid, self.previous_focus_position) + def task_action_modify_all(self): + current_view_filter = self.active_view_filters() + ntasks = self.model.get_n_tasks(current_view_filter) + self.activate_command_bar('modify_bulk', 'Modify all (%s tasks): ' % ntasks, {'target': current_view_filter, 'ntasks': ntasks}) + def task_action_start_stop(self): uuid, task = self.get_focused_task() if task: @@ -824,7 +860,7 @@ def refresh_blocking_task_uuids(self): def setup_autocomplete(self, op): callback = self.command_bar.set_edit_text_callback() - if op in ('filter', 'add', 'modify'): + if op in ('filter', 'add', 'modify', 'modify_bulk'): self.autocomplete.setup(callback) elif op in ('ex',): filters = ('report', 'column', 'project', 'tag', 'help') @@ -923,7 +959,7 @@ def update_report(self, report=None): self.task_config.get_projects() self.refresh_blocking_task_uuids() self.formatter.recalculate_due_datetimes() - context_filters = self.contexts[self.context]['filter'] if self.context and self.reports[self.report].get('context', 1) else [] + context_filters = self.active_context_filter() try: self.model.update_report(self.report, context_filters=context_filters, extra_filters=self.extra_filters) except VitException as err: diff --git a/vit/config/config.sample.ini b/vit/config/config.sample.ini index fdd23f2..aa0a405 100644 --- a/vit/config/config.sample.ini +++ b/vit/config/config.sample.ini @@ -194,6 +194,11 @@ # For capital letter keybindings, use the letter directly: # D = {ACTION_TASK_DONE} +# Modify all tasks currently listed in vit. If the number of affected tasks +# exceeds your .taskrc's 'rc.bulk' setting (default is 3), taskwarrior's +# interactive prompt will ask you to confirm the modifications. +# M = {ACTION_TASK_MODIFY_ALL} + # For a list of available actions, run 'vit --list-actions'. # A great reference for many of the available meta keys, and understanding the # default keybindings is the 'keybinding/vi.ini' file. diff --git a/vit/task.py b/vit/task.py index c3e8206..b3188f2 100644 --- a/vit/task.py +++ b/vit/task.py @@ -33,8 +33,7 @@ def parse_error(self, err): def update_report(self, report, context_filters=[], extra_filters=[]): self.report = report - active_report = self.active_report() - report_filters = active_report['filter'] if 'filter' in active_report else [] + report_filters = self.active_report_filter() filters = self.build_task_filters(context_filters, report_filters, extra_filters) try: self.tasks = self.tw.tasks.filter(filters) if filters else self.tw.tasks.all() @@ -45,6 +44,10 @@ def update_report(self, report, context_filters=[], extra_filters=[]): except TaskWarriorException as err: raise VitException(self.parse_error(err)) + def active_report_filter(self): + active_report = self.active_report() + return active_report['filter'] if 'filter' in active_report else [] + def build_task_filters(self, *all_filters): def reducer(accum, filters): if filters: @@ -53,6 +56,9 @@ def reducer(accum, filters): filter_parts = reduce(reducer, all_filters, []) return ' '.join(filter_parts) if filter_parts else '' + def get_n_tasks(self, filter): + return len(self.tw.tasks.filter(filter)) + def get_task(self, uuid): try: return self.tw.tasks.get(uuid=uuid)