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>' % ('&nbsp;' * (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