1 2# Copyright 2009-2017 Jaap Karssenberg <jaap.karssenberg@gmail.com> 3 4from gi.repository import Gtk 5from gi.repository import GObject 6from gi.repository import Pango 7 8import logging 9import re 10 11from zim.plugins import find_extension 12 13import zim.datetimetz as datetime 14from zim.utils import natural_sorted 15 16from zim.notebook import Path 17from zim.gui.widgets import \ 18 Dialog, WindowSidePaneWidget, InputEntry, \ 19 BrowserTreeView, SingleClickTreeView, ScrolledWindow, HPaned, \ 20 encode_markup_text, decode_markup_text 21from zim.gui.clipboard import Clipboard 22from zim.signals import DelayedCallback, SIGNAL_AFTER 23from zim.plugins import DialogExtensionBase, extendable 24 25logger = logging.getLogger('zim.plugins.tasklist') 26 27from .indexer import _MAX_DUE_DATE, _NO_TAGS, _date_re, _tag_re, _parse_task_labels, _task_labels_re 28 29 30class TaskListWidgetMixin(object): 31 32 def on_populate_popup(self, o, menu): 33 sep = Gtk.SeparatorMenuItem() 34 menu.append(sep) 35 36 item = Gtk.CheckMenuItem(_('Show Tasks as Flat List')) 37 # T: Checkbox in task list - hides parent items 38 item.set_active(self.uistate['show_flatlist']) 39 item.connect('toggled', self.on_show_flatlist_toggle) 40 item.show_all() 41 menu.append(item) 42 43 item = Gtk.CheckMenuItem(_('Only Show Active Tasks')) 44 # T: Checkbox in task list - this options hides tasks that are not yet started 45 item.set_active(self.uistate['only_show_act']) 46 item.connect('toggled', self.on_show_active_toggle) 47 item.show_all() 48 menu.append(item) 49 50 def on_show_active_toggle(self, *a): 51 active = not self.uistate['only_show_act'] 52 self.uistate['only_show_act'] = active 53 self.task_list.set_filter_actionable(active) 54 55 def on_show_flatlist_toggle(self, *a): 56 active = not self.uistate['show_flatlist'] 57 self.uistate['show_flatlist'] = active 58 self.task_list.set_flatlist(active) 59 60 61class TaskListWidget(Gtk.VBox, TaskListWidgetMixin, WindowSidePaneWidget): 62 63 title = _('Tas_ks') # T: tab label for side pane 64 65 def __init__(self, tasksview, opener, properties, with_due, uistate): 66 GObject.GObject.__init__(self) 67 self.uistate = uistate 68 self.uistate.setdefault('only_show_act', False) 69 self.uistate.setdefault('show_flatlist', False) 70 71 column_layout=TaskListTreeView.COMPACT_COLUMN_LAYOUT_WITH_DUE \ 72 if with_due else TaskListTreeView.COMPACT_COLUMN_LAYOUT 73 self.task_list = TaskListTreeView( 74 tasksview, opener, 75 _parse_task_labels(properties['labels']), 76 nonactionable_tags=_parse_task_labels(properties['nonactionable_tags']), 77 filter_actionable=self.uistate['only_show_act'], 78 tag_by_page=properties['tag_by_page'], 79 use_workweek=properties['use_workweek'], 80 column_layout=column_layout, 81 flatlist=self.uistate['show_flatlist'], 82 ) 83 self.task_list.connect('populate-popup', self.on_populate_popup) 84 self.task_list.set_headers_visible(True) 85 86 self.connectto(properties, 'changed', self.on_properties_changed) 87 88 self.filter_entry = InputEntry(placeholder_text=_('Filter')) # T: label for filtering/searching tasks 89 self.filter_entry.set_icon_to_clear() 90 filter_cb = DelayedCallback(500, 91 lambda o: self.task_list.set_filter(self.filter_entry.get_text())) 92 self.filter_entry.connect('changed', filter_cb) 93 94 self.pack_start(ScrolledWindow(self.task_list), True, True, 0) 95 self.pack_end(self.filter_entry, False, True, 0) 96 97 def on_properties_changed(self, properties): 98 self.task_list.update_properties( 99 task_labels=_parse_task_labels(properties['labels']), 100 nonactionable_tags=_parse_task_labels(properties['nonactionable_tags']), 101 tag_by_page=properties['tag_by_page'], 102 use_workweek=properties['use_workweek'], 103 ) 104 105 106class TaskListDialogExtension(DialogExtensionBase): 107 pass 108 109@extendable(TaskListDialogExtension) 110class TaskListDialog(TaskListWidgetMixin, Dialog): 111 112 def __init__(self, parent, tasksview, properties): 113 Dialog.__init__(self, parent, _('Task List'), # T: dialog title 114 buttons=Gtk.ButtonsType.CLOSE, help=':Plugins:Task List', 115 defaultwindowsize=(550, 400)) 116 self.properties = properties 117 self.tasksview = tasksview 118 self.notebook = parent.notebook 119 120 hbox = Gtk.HBox(spacing=5) 121 self.vbox.pack_start(hbox, False, True, 0) 122 self.hpane = HPaned() 123 self.uistate.setdefault('hpane_pos', 75) 124 self.hpane.set_position(self.uistate['hpane_pos']) 125 self.vbox.pack_start(self.hpane, True, True, 0) 126 127 # Task list 128 self.uistate.setdefault('only_show_act', False) 129 self.uistate.setdefault('show_flatlist', False) 130 self.uistate.setdefault('sort_column', 0) 131 self.uistate.setdefault('sort_order', int(Gtk.SortType.DESCENDING)) 132 133 opener = parent.navigation 134 self.task_list = TaskListTreeView( 135 self.tasksview, opener, 136 _parse_task_labels(properties['labels']), 137 nonactionable_tags=_parse_task_labels(properties['nonactionable_tags']), 138 filter_actionable=self.uistate['only_show_act'], 139 tag_by_page=properties['tag_by_page'], 140 use_workweek=properties['use_workweek'], 141 flatlist=self.uistate['show_flatlist'], 142 sort_column=self.uistate['sort_column'], 143 sort_order=self.uistate['sort_order'] 144 ) 145 self.task_list.set_headers_visible(True) 146 self.task_list.connect('populate-popup', self.on_populate_popup) 147 self.hpane.add2(ScrolledWindow(self.task_list)) 148 149 # Tag list 150 self.tag_list = TagListTreeView(self.task_list) 151 self.hpane.add1(ScrolledWindow(self.tag_list)) 152 153 self.connectto(properties, 'changed', self.on_properties_changed) 154 155 # Filter input 156 hbox.pack_start(Gtk.Label(_('Filter') + ': '), False, True, 0) # T: Input label 157 filter_entry = InputEntry() 158 filter_entry.set_icon_to_clear() 159 hbox.pack_start(filter_entry, False, True, 0) 160 filter_cb = DelayedCallback(500, 161 lambda o: self.task_list.set_filter(filter_entry.get_text())) 162 filter_entry.connect('changed', filter_cb) 163 164 # TODO: use menu button here and add same options as in context menu 165 # for filtering the list 166 def on_show_active_toggle(o): 167 active = self.act_toggle.get_active() 168 if self.uistate['only_show_act'] != active: 169 self.uistate['only_show_act'] = active 170 self.task_list.set_filter_actionable(active) 171 172 self.act_toggle = Gtk.CheckButton.new_with_mnemonic(_('Only Show Active Tasks')) 173 # T: Checkbox in task list - this options hides tasks that are not yet started 174 self.act_toggle.set_active(self.uistate['only_show_act']) 175 self.act_toggle.connect('toggled', on_show_active_toggle) 176 self.uistate.connect('changed', lambda o: self.act_toggle.set_active(self.uistate['only_show_act'])) 177 hbox.pack_start(self.act_toggle, False, True, 0) 178 179 # Statistics label 180 self.statistics_label = Gtk.Label() 181 hbox.pack_end(self.statistics_label, False, True, 0) 182 183 def set_statistics(): 184 total = self.task_list.get_n_tasks() 185 text = ngettext('%i open item', '%i open items', total) % total 186 # T: Label for task List, %i is the number of tasks 187 self.statistics_label.set_text(text) 188 189 set_statistics() 190 191 def on_tasklist_changed(o): 192 self.task_list.refresh() 193 self.tag_list.refresh(self.task_list) 194 set_statistics() 195 196 callback = DelayedCallback(10, on_tasklist_changed) 197 # Don't really care about the delay, but want to 198 # make it less blocking - should be async preferably 199 # now it is at least on idle 200 201 from . import TaskListNotebookExtension 202 nb_ext = find_extension(self.notebook, TaskListNotebookExtension) 203 self.connectto(nb_ext, 'tasklist-changed', callback) 204 205 def on_properties_changed(self, properties): 206 self.task_list.update_properties( 207 task_labels=_parse_task_labels(properties['labels']), 208 nonactionable_tags=_parse_task_labels(properties['nonactionable_tags']), 209 tag_by_page=properties['tag_by_page'], 210 use_workweek=properties['use_workweek'], 211 ) 212 self.tag_list.refresh(self.task_list) 213 214 def do_response(self, response): 215 self.uistate['hpane_pos'] = self.hpane.get_position() 216 217 for column in self.task_list.get_columns(): 218 if column.get_sort_indicator(): 219 self.uistate['sort_column'] = column.get_sort_column_id() 220 self.uistate['sort_order'] = int(column.get_sort_order()) 221 break 222 else: 223 # if it is unsorted, just use the defaults 224 self.uistate['sort_column'] = TaskListTreeView.PRIO_COL 225 self.uistate['sort_order'] = Gtk.SortType.ASCENDING 226 227 Dialog.do_response(self, response) 228 229 230class TagListTreeView(SingleClickTreeView): 231 '''TreeView with a single column 'Tags' which shows all tags available 232 in a TaskListTreeView. Selecting a tag will filter the task list to 233 only show tasks with that tag. 234 ''' 235 236 _type_separator = 0 237 _type_label = 1 238 _type_tag = 2 239 _type_untagged = 3 240 241 def __init__(self, task_list): 242 model = Gtk.ListStore(str, int, int, int) # tag name, number of tasks, type, weight 243 SingleClickTreeView.__init__(self, model) 244 self.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) 245 self.task_list = task_list 246 247 column = Gtk.TreeViewColumn(_('Tags')) 248 # T: Column header for tag list in Task List dialog 249 column.set_expand(True) 250 self.append_column(column) 251 252 cr1 = Gtk.CellRendererText() 253 cr1.set_property('ellipsize', Pango.EllipsizeMode.END) 254 column.pack_start(cr1, True) 255 column.set_attributes(cr1, text=0, weight=3) # tag name, weight 256 257 column = Gtk.TreeViewColumn('') 258 self.append_column(column) 259 260 cr2 = self.get_cell_renderer_number_of_items() 261 column.pack_start(cr2, False) 262 column.set_attributes(cr2, text=1) # number of tasks 263 264 self.set_row_separator_func(lambda m, i: m[i][2] == self._type_separator) 265 266 self._block_selection_change = False 267 self.get_selection().connect('changed', self.on_selection_changed) 268 269 self.refresh(task_list) 270 271 def get_tags(self): 272 '''Returns current selected tags, or None for all tags''' 273 tags = [] 274 for row in self._get_selected(): 275 if row[2] == self._type_tag: 276 tags.append(row[0]) 277 elif row[2] == self._type_untagged: 278 tags.append(_NO_TAGS) 279 return tags or None 280 281 def get_labels(self): 282 '''Returns current selected labels''' 283 labels = [] 284 for row in self._get_selected(): 285 if row[2] == self._type_label: 286 labels.append(row[0]) 287 return labels or None 288 289 def _get_selected(self): 290 selection = self.get_selection() 291 if selection: 292 model, paths = selection.get_selected_rows() 293 if not paths or any(p == Gtk.TreePath(0) for p in paths): 294 return [] 295 else: 296 return [model[path] for path in paths] 297 else: 298 return [] 299 300 def refresh(self, task_list): 301 self._block_selection_change = True 302 selected = [(row[0], row[2]) for row in self._get_selected()] # remember name and type 303 304 # Rebuild model 305 model = self.get_model() 306 if model is None: 307 return 308 model.clear() 309 310 n_all = self.task_list.get_n_tasks() 311 model.append((_('All Tasks'), n_all, self._type_label, Pango.Weight.BOLD)) # T: "tag" for showing all tasks 312 313 used_labels = self.task_list.get_labels() 314 for label in self.task_list.task_labels: # explicitly keep sorting from properties 315 if label in used_labels: 316 model.append((label, used_labels[label], self._type_label, Pango.Weight.BOLD)) 317 318 tags = self.task_list.get_tags() 319 if _NO_TAGS in tags: 320 n_untagged = tags.pop(_NO_TAGS) 321 model.append((_('Untagged'), n_untagged, self._type_untagged, Pango.Weight.NORMAL)) 322 # T: label in tasklist plugins for tasks without a tag 323 324 model.append(('', 0, self._type_separator, 0)) # separator 325 326 for tag in natural_sorted(tags): 327 model.append((tag, tags[tag], self._type_tag, Pango.Weight.NORMAL)) 328 329 # Restore selection 330 def reselect(model, path, iter): 331 row = model[path] 332 name_type = (row[0], row[2]) 333 if name_type in selected: 334 self.get_selection().select_iter(iter) 335 336 if selected: 337 model.foreach(reselect) 338 self._block_selection_change = False 339 340 def on_selection_changed(self, selection): 341 if not self._block_selection_change: 342 tags = self.get_tags() 343 labels = self.get_labels() 344 self.task_list.set_tag_filter(tags, labels) 345 346 347HIGH_COLOR = '#EF5151' # red (derived from Tango style guide - #EF2929) 348MEDIUM_COLOR = '#FCB956' # orange ("idem" - #FCAF3E) 349ALERT_COLOR = '#FCEB65' # yellow ("idem" - #FCE94F) 350# FIXME: should these be configurable ? 351 352COLORS = [None, ALERT_COLOR, MEDIUM_COLOR, HIGH_COLOR] # index 0..3 353 354def days_to_str(days): 355 if days > 290: 356 return '%iy' % round(float(days) / 365) # round up to 1 year from ~10 months 357 elif days > 25: 358 return '%im' % round(float(days) / 30) 359 elif days > 10: 360 return '%iw' % round(float(days) / 7) 361 else: 362 return '%id' % days 363 364 365class TaskListTreeView(BrowserTreeView): 366 367 # idem for flat list vs tree 368 369 VIS_COL = 0 # visible 370 ACT_COL = 1 # actionable 371 PRIO_COL = 2 372 START_COL = 3 373 DUE_COL = 4 374 TAGS_COL = 5 375 DESC_COL = 6 376 PAGE_COL = 7 377 TASKID_COL = 8 378 PRIO_SORT_COL = 9 379 PRIO_SORT_LABEL_COL = 10 380 381 RICH_COLUMN_LAYOUT = 11 382 COMPACT_COLUMN_LAYOUT = 12 383 COMPACT_COLUMN_LAYOUT_WITH_DUE = 13 384 385 def __init__(self, 386 tasksview, opener, 387 task_labels, 388 nonactionable_tags=(), 389 filter_actionable=False, tag_by_page=False, use_workweek=False, 390 column_layout=RICH_COLUMN_LAYOUT, flatlist=False, 391 sort_column=PRIO_COL, sort_order=Gtk.SortType.DESCENDING 392 ): 393 self.real_model = Gtk.TreeStore(bool, bool, int, str, str, object, str, str, int, int, str) 394 # VIS_COL, ACT_COL, PRIO_COL, START_COL, DUE_COL, TAGS_COL, DESC_COL, PAGE_COL, TASKID_COL, PRIO_SORT_COL, PRIO_SORT_LABEL_COL 395 model = self.real_model.filter_new() 396 model.set_visible_column(self.VIS_COL) 397 model = Gtk.TreeModelSort(model) 398 model.set_sort_column_id(sort_column, sort_order) 399 BrowserTreeView.__init__(self, model) 400 401 self.tasksview = tasksview 402 self.opener = opener 403 self.filter = None 404 self.tag_filter = None 405 self.label_filter = None 406 self.filter_actionable = filter_actionable 407 self.nonactionable_tags = tuple(t.strip('@').lower() for t in nonactionable_tags) 408 self.tag_by_page = tag_by_page 409 self.task_labels = task_labels 410 self._tags = {} 411 self._labels = {} 412 self.flatlist = flatlist 413 414 # Add some rendering for the Prio column 415 def render_prio(col, cell, model, i, data): 416 prio = model.get_value(i, self.PRIO_COL) 417 text = model.get_value(i, self.PRIO_SORT_LABEL_COL) 418 if text.startswith('>'): 419 text = '<span color="darkgrey">%s</span>' % text 420 bg = None 421 else: 422 bg = COLORS[min(prio, 3)] 423 cell.set_property('markup', text) 424 cell.set_property('cell-background', bg) 425 426 cell_renderer = Gtk.CellRendererText() 427 column = Gtk.TreeViewColumn('!', cell_renderer) 428 column.set_cell_data_func(cell_renderer, render_prio) 429 column.set_sort_column_id(self.PRIO_SORT_COL) 430 self.append_column(column) 431 432 # Rendering for task description column 433 cell_renderer = Gtk.CellRendererText() 434 cell_renderer.set_property('ellipsize', Pango.EllipsizeMode.END) 435 column = Gtk.TreeViewColumn(_('Task'), cell_renderer, markup=self.DESC_COL) 436 # T: Column header Task List dialog 437 column.set_resizable(True) 438 column.set_sort_column_id(self.DESC_COL) 439 column.set_expand(True) 440 if column_layout != self.RICH_COLUMN_LAYOUT: 441 column.set_min_width(100) 442 else: 443 column.set_min_width(300) # don't let this column get too small 444 self.append_column(column) 445 self.set_expander_column(column) 446 447 # custom tooltip 448 self.props.has_tooltip = True 449 self.connect("query-tooltip", self._query_tooltip_cb) 450 451 # Rendering of the Date column 452 day_of_week = datetime.date.today().isoweekday() 453 if use_workweek and day_of_week == 4: 454 # Today is Thursday - 2nd day ahead is after the weekend 455 delta1, delta2 = 1, 3 456 elif use_workweek and day_of_week == 5: 457 # Today is Friday - next day ahead is after the weekend 458 delta1, delta2 = 3, 4 459 else: 460 delta1, delta2 = 1, 2 461 462 today = str(datetime.date.today()) 463 tomorrow = str(datetime.date.today() + datetime.timedelta(days=delta1)) 464 dayafter = str(datetime.date.today() + datetime.timedelta(days=delta2)) 465 def render_date(col, cell, model, i, data): 466 date = model.get_value(i, self.DUE_COL) 467 if date == _MAX_DUE_DATE: 468 cell.set_property('text', '') 469 else: 470 cell.set_property('text', date) 471 # TODO allow strftime here 472 473 if date <= today: 474 color = HIGH_COLOR 475 elif date <= tomorrow: 476 color = MEDIUM_COLOR 477 elif date <= dayafter: 478 color = ALERT_COLOR 479 # "<=" because tomorrow and/or dayafter can be after the weekend 480 else: 481 color = None 482 cell.set_property('cell-background', color) 483 484 if column_layout != self.COMPACT_COLUMN_LAYOUT: 485 cell_renderer = Gtk.CellRendererText() 486 column = Gtk.TreeViewColumn(_('Date'), cell_renderer) 487 # T: Column header Task List dialog 488 column.set_cell_data_func(cell_renderer, render_date) 489 column.set_sort_column_id(self.DUE_COL) 490 self.append_column(column) 491 492 # Rendering for page name column 493 if column_layout == self.RICH_COLUMN_LAYOUT: 494 cell_renderer = Gtk.CellRendererText() 495 column = Gtk.TreeViewColumn(_('Page'), cell_renderer, text=self.PAGE_COL) 496 # T: Column header Task List dialog 497 column.set_sort_column_id(self.PAGE_COL) 498 self.append_column(column) 499 500 # Finalize 501 self.refresh() 502 503 # HACK because we can not register ourselves :S 504 self.connect('row_activated', self.__class__.do_row_activated) 505 self.connect('focus-in-event', self.__class__.do_focus_in_event) 506 507 def update_properties(self, 508 task_labels=None, 509 nonactionable_tags=None, 510 tag_by_page=None, 511 use_workweek=None, 512 ): 513 if task_labels is not None: 514 self.task_labels = task_labels 515 516 if nonactionable_tags is not None: 517 self.nonactionable_tags = tuple(t.strip('@').lower() for t in nonactionable_tags) 518 519 if tag_by_page is not None: 520 self.tag_by_page = tag_by_page 521 522 if use_workweek is not None: 523 print("TODO udate_use_workweek rendering") 524 525 self.refresh() 526 527 def refresh(self): 528 '''Refresh the model based on index data''' 529 # Update data 530 self._clear() 531 self._append_tasks(None, None, {}) 532 self._today = datetime.date.today() 533 534 # Make tags case insensitive 535 tags = sorted((t.lower(), t) for t in self._tags) 536 # tuple sorting will sort ("foo", "Foo") before ("foo", "foo"), 537 # but ("bar", ..) before ("foo", ..) 538 prev = ('', '') 539 for tag in tags: 540 if tag[0] == prev[0]: 541 self._tags[prev[1]] += self._tags[tag[1]] 542 self._tags.pop(tag[1]) 543 else: 544 prev = tag 545 546 # Set view 547 self._eval_filter() # keep current selection 548 self.expand_all() 549 550 def _clear(self): 551 self.real_model.clear() # flush 552 self._tags = {} 553 self._labels = {} 554 555 def _append_tasks(self, task, iter, path_cache): 556 task_label_re = _task_labels_re(self.task_labels) 557 today = datetime.date.today() 558 today_str = str(today) 559 560 if self.flatlist: 561 assert task is None 562 tasks = self.tasksview.list_open_tasks_flatlist() 563 else: 564 tasks = self.tasksview.list_open_tasks(task) 565 566 for prio_sort_int, row in enumerate(tasks): 567 if row['source'] not in path_cache: 568 # TODO: add pagename to list_open_tasks query - need new index 569 path = self.tasksview.get_path(row) 570 if path is None: 571 # Be robust for glitches - filter these out 572 continue 573 else: 574 path_cache[row['source']] = path 575 576 path = path_cache[row['source']] 577 578 # Update labels 579 for label in task_label_re.findall(row['description']): 580 self._labels[label] = self._labels.get(label, 0) + 1 581 582 # Update tag count 583 tags = [t for t in row['tags'].split(',') if t] 584 if self.tag_by_page: 585 tags = tags + path.parts 586 587 if tags: 588 for tag in tags: 589 self._tags[tag] = self._tags.get(tag, 0) + 1 590 else: 591 self._tags[_NO_TAGS] = self._tags.get(_NO_TAGS, 0) + 1 592 593 lowertags = [t.lower() for t in tags] 594 actionable = not any(t in lowertags for t in self.nonactionable_tags) 595 596 # Format label for "prio" column 597 if row['start'] > today_str: 598 actionable = False 599 y, m, d = row['start'].split('-') 600 td = datetime.date(int(y), int(m), int(d)) - today 601 prio_sort_label = '>' + days_to_str(td.days) 602 if row['prio'] > 0: 603 prio_sort_label += ' ' + '!' * min(row['prio'], 3) 604 elif row['due'] < _MAX_DUE_DATE: 605 y, m, d = row['due'].split('-') 606 td = datetime.date(int(y), int(m), int(d)) - today 607 prio_sort_label = \ 608 '!' * min(row['prio'], 3) + ' ' if row['prio'] > 0 else '' 609 if td.days < 0: 610 prio_sort_label += '<b><u>OD</u></b>' # over due 611 elif td.days == 0: 612 prio_sort_label += '<u>TD</u>' # today 613 else: 614 prio_sort_label += days_to_str(td.days) 615 else: 616 prio_sort_label = '!' * min(row['prio'], 3) 617 618 # Format description 619 desc = _date_re.sub('', row['description']) 620 desc = re.sub('\s*!+\s*', ' ', desc) # get rid of exclamation marks 621 desc = encode_markup_text(desc) 622 if actionable: 623 desc = _tag_re.sub(r'<span color="#ce5c00">@\1</span>', desc) # highlight tags - same color as used in pageview 624 desc = task_label_re.sub(r'<b>\1</b>', desc) # highlight labels 625 else: 626 desc = r'<span color="darkgrey">%s</span>' % desc 627 628 # Insert all columns 629 modelrow = [False, actionable, row['prio'], row['start'], row['due'], tags, desc, path.name, row['id'], prio_sort_int, prio_sort_label] 630 # VIS_COL, ACT_COL, PRIO_COL, START_COL, DUE_COL, TAGS_COL, DESC_COL, PAGE_COL, TASKID_COL, PRIO_SORT_COL, PRIO_SORT_LABEL_COL 631 modelrow[0] = self._filter_item(modelrow) 632 myiter = self.real_model.append(iter, modelrow) 633 634 if row['haschildren'] and not self.flatlist: 635 self._append_tasks(row, myiter, path_cache) # recurs 636 637 def set_filter_actionable(self, filter): 638 '''Set filter state for non-actionable items 639 @param filter: if C{False} all items are shown, if C{True} only actionable items 640 ''' 641 self.filter_actionable = filter 642 self._eval_filter() 643 644 def set_flatlist(self, flatlist): 645 self.flatlist = flatlist 646 self.refresh() 647 648 def set_filter(self, string): 649 # TODO allow more complex queries here - same parse as for search 650 if string: 651 inverse = False 652 if string.lower().startswith('not '): 653 # Quick HACK to support e.g. "not @waiting" 654 inverse = True 655 string = string[4:] 656 self.filter = (inverse, string.strip().lower()) 657 else: 658 self.filter = None 659 self._eval_filter() 660 661 def get_labels(self): 662 '''Get all labels that are in use 663 @returns: a dict with labels as keys and the number of tasks 664 per label as value 665 ''' 666 return self._labels 667 668 def get_tags(self): 669 '''Get all tags that are in use 670 @returns: a dict with tags as keys and the number of tasks 671 per tag as value 672 ''' 673 return self._tags 674 675 def get_n_tasks(self): 676 '''Get the number of tasks in the list 677 @returns: total number 678 ''' 679 counter = [0] 680 def count(model, path, iter): 681 counter[0] += 1 682 self.real_model.foreach(count) 683 return counter[0] 684 685 def set_tag_filter(self, tags=None, labels=None): 686 if tags: 687 self.tag_filter = [tag.lower() for tag in tags] 688 else: 689 self.tag_filter = None 690 691 if labels: 692 self.label_filter = [label.lower() for label in labels] 693 else: 694 self.label_filter = None 695 696 self._eval_filter() 697 698 def _eval_filter(self): 699 #logger.debug('Filtering with labels: %s tags: %s, filter: %s', self.label_filter, self.tag_filter, self.filter) 700 701 def filter(model, path, iter): 702 visible = self._filter_item(model[iter]) 703 model[iter][self.VIS_COL] = visible 704 if visible: 705 parent = model.iter_parent(iter) 706 while parent: 707 model[parent][self.VIS_COL] = visible 708 parent = model.iter_parent(parent) 709 710 self.real_model.foreach(filter) 711 self.expand_all() 712 713 def _filter_item(self, modelrow): 714 # This method filters case insensitive because both filters and 715 # text are first converted to lower case text. 716 visible = True 717 718 if not modelrow[self.ACT_COL] and self.filter_actionable: 719 visible = False 720 721 description = modelrow[self.DESC_COL].lower() 722 pagename = modelrow[self.PAGE_COL].lower() 723 tags = [t.lower() for t in modelrow[self.TAGS_COL]] 724 725 if visible and self.label_filter: 726 # Any labels need to be present 727 for label in self.label_filter: 728 if label in description: 729 break 730 else: 731 visible = False # no label found 732 733 if visible and self.tag_filter: 734 # Any tag should match 735 if (_NO_TAGS in self.tag_filter and not tags) \ 736 or any(tag in tags for tag in self.tag_filter): 737 visible = True 738 else: 739 visible = False 740 741 if visible and self.filter: 742 # And finally the filter string should match 743 # FIXME: we are matching against markup text here - may fail for some cases 744 inverse, string = self.filter 745 if string.startswith('@'): 746 match = string[1:].lower() in [t.lower() for t in tags] 747 else: 748 match = string in description or string in pagename 749 if (not inverse and not match) or (inverse and match): 750 visible = False 751 752 return visible 753 754 def do_focus_in_event(self, event): 755 #print ">>>", self._today, datetime.date.today() 756 if self._today != datetime.date.today(): 757 self.refresh() 758 759 def do_row_activated(self, path, column): 760 model = self.get_model() 761 page = Path(model[path][self.PAGE_COL]) 762 text = self._get_raw_text(model[path]) 763 764 pageview = self.opener.open_page(page) 765 pageview.find(text) 766 767 def _get_raw_text(self, task): 768 id = task[self.TASKID_COL] 769 row = self.tasksview.get_task(id) 770 return row['description'] 771 772 def do_initialize_popup(self, menu): 773 item = Gtk.MenuItem.new_with_mnemonic(_('_Copy')) # T: menu label 774 item.connect('activate', self.copy_to_clipboard) 775 menu.append(item) 776 self.populate_popup_expand_collapse(menu) 777 778 779 def _query_tooltip_cb(self, widget, x, y, keyboard_tip, tooltip): 780 context = widget.get_tooltip_context(x, y, keyboard_tip) 781 if not context: 782 return False 783 784 model, iter = context.model, context.iter 785 if not (model and iter): 786 return 787 788 task = model[iter][self.DESC_COL] 789 start = model[iter][self.START_COL] 790 due = model[iter][self.DUE_COL] 791 page = model[iter][self.PAGE_COL] 792 793 today = str(datetime.date.today()) 794 795 text = [task, '\n'] 796 if start and start > today: 797 text += ['<b>', _('Start'), ':</b> ', start, '\n'] # T: start date for task 798 if due != _MAX_DUE_DATE: 799 text += ['<b>', _('Due'), ':</b> ', due, '\n'] # T: due date for task 800 801 text += ['<b>', _('Page'), ':</b> ', encode_markup_text(page)] # T: page label 802 803 tooltip.set_markup(''.join(text)) 804 return True 805 806 def copy_to_clipboard(self, *a): 807 '''Exports currently visible elements from the tasks list''' 808 logger.debug('Exporting to clipboard current view of task list.') 809 text = self.get_visible_data_as_csv() 810 Clipboard.set_text(text) 811 # TODO set as object that knows how to format as text / html / .. 812 # unify with export hooks 813 814 def get_visible_data_as_csv(self): 815 text = "" 816 for indent, prio, desc, date, page in self.get_visible_data(): 817 prio = str(prio) 818 desc = decode_markup_text(desc) 819 desc = '"' + desc.replace('"', '""') + '"' 820 text += ",".join((prio, desc, date, page)) + "\n" 821 return text 822 823 def get_visible_data_as_html(self): 824 html = '''\ 825<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"> 826<html> 827 <head> 828 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> 829 <title>Task List - Zim</title> 830 <meta name='Generator' content='Zim [%% zim.version %%]'> 831 <style type='text/css'> 832 table.tasklist { 833 border-width: 1px; 834 border-spacing: 2px; 835 border-style: solid; 836 border-color: gray; 837 border-collapse: collapse; 838 } 839 table.tasklist th { 840 border-width: 1px; 841 padding: 1px; 842 border-style: solid; 843 border-color: gray; 844 } 845 table.tasklist td { 846 border-width: 1px; 847 padding: 1px; 848 border-style: solid; 849 border-color: gray; 850 } 851 .high {background-color: %s} 852 .medium {background-color: %s} 853 .alert {background-color: %s} 854 </style> 855 </head> 856 <body> 857 858<h1>Task List - Zim</h1> 859 860<table class="tasklist"> 861<tr><th>Prio</th><th>Task</th><th>Date</th><th>Page</th></tr> 862''' % (HIGH_COLOR, MEDIUM_COLOR, ALERT_COLOR) 863 864 today = str(datetime.date.today()) 865 tomorrow = str(datetime.date.today() + datetime.timedelta(days=1)) 866 dayafter = str(datetime.date.today() + datetime.timedelta(days=2)) 867 for indent, prio, desc, date, page in self.get_visible_data(): 868 if prio >= 3: 869 prio = '<td class="high">%s</td>' % prio 870 elif prio == 2: 871 prio = '<td class="medium">%s</td>' % prio 872 elif prio == 1: 873 prio = '<td class="alert">%s</td>' % prio 874 else: 875 prio = '<td>%s</td>' % prio 876 877 if date and date <= today: 878 date = '<td class="high">%s</td>' % date 879 elif date == tomorrow: 880 date = '<td class="medium">%s</td>' % date 881 elif date == dayafter: 882 date = '<td class="alert">%s</td>' % date 883 else: 884 date = '<td>%s</td>' % date 885 886 desc = '<td>%s%s</td>' % (' ' * (4 * indent), desc) 887 page = '<td>%s</td>' % page 888 889 html += '<tr>' + prio + desc + date + page + '</tr>\n' 890 891 html += '''\ 892</table> 893 894 </body> 895 896</html> 897''' 898 return html 899 900 def get_visible_data(self): 901 rows = [] 902 903 def collect(model, path, iter): 904 indent = len(path) - 1 # path is tuple with indexes 905 906 row = model[iter] 907 prio = row[self.PRIO_COL] 908 desc = row[self.DESC_COL] 909 date = row[self.DUE_COL] 910 page = row[self.PAGE_COL] 911 912 if date == _MAX_DUE_DATE: 913 date = '' 914 915 rows.append((indent, prio, desc, date, page)) 916 917 model = self.get_model() 918 model.foreach(collect) 919 920 return rows 921