Last active
July 13, 2024 20:15
-
-
Save muguu/1d5910ef05259595fe49cde4c9db9d39 to your computer and use it in GitHub Desktop.
Plugin for Zim Wiki >=V0.61 and < V0.66
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# -*- coding: utf-8 -*- | |
# Copyright 2009-2017 Jaap Karssenberg <[email protected]> | |
# Copyright 2017 Murat Guven <[email protected]> | |
# This is a plugin for ZimWiki from Jaap Karssenberg <[email protected]> | |
# | |
# | |
# Additions to the original tasklist plugin by Murat Guven <[email protected]> | |
# V1.99 Taskcomment: Added option to add / remove date, added all styles for the comment string and the comment | |
# Added Suffix for Task comment to allow stripping down a task comment prefix to just '>' | |
# V1.98 Preferences: Corrected text in preferences | |
# V1.97 Tasklist: re-arranged elements within the dialog to fit for low resolution screens | |
# V1.96 Tasklist: fixed index issue when tasklist was activated before updating the plugin by increasing SQL_FORMAT_VERSION to 0.61 | |
# V1.95 Taskcomment: code cleaning and fix for comment in strong / emphasis | |
# V1.94 Tasklist: label comments are also collected | |
# V1.93 Autocompletion: If text is selected after activation of AC, the selected text is replaced. If selected text | |
# was itself a tag and the @ sign wasn't selected with, it remains after inserting the new selected tag | |
# V1.92 Ticking issue while column resizing solved, Pane positions are saved again | |
# V1.91 Filtering works now with comments as well | |
# V1.9 Added "tick / untick all tasks". Reworked tags list: Added tags from ticked tasks to tag list | |
# V1.84 Fixed [no date] entry for children | |
# V1.83 Fixed wrong view in Task list history when parent task is open and child task is ticked. | |
# V1.82 Tag list rearranged in vbox tasklist dialog | |
# V1.81 using Zim widgets for autocompletion, duedate. Removed grey border from autocompletion entry (only visible in Windows) | |
# V1.8 added history of ticked tasks with ticked date (together with pageview) | |
# V1.74 little bug fix in comment dates format taken from dates.list | |
# V1.73 little bug fix in print function | |
# V1.72 added time for task comment | |
# V1.71 bug fixed with string truncating in comment | |
# V1.7 added possiblity to tick tasks | |
# V1.6 merged autocompletion for tags | |
# V1.5 merged with due date plugin | |
# V1.4 merged with task comment plugin | |
# V1.3 added comment column reading comment bullets under tasks + added tags column to dialog | |
# V1.21 added standard tag to look for on start | |
# V1.2 added new entry with autocompletion for tags | |
from __future__ import with_statement | |
import gtk | |
import pango | |
import logging | |
import re | |
import gtk.gdk | |
import sqlite3 | |
import zim.datetimetz as zim_datetime | |
from datetime import datetime as dtime | |
import datetime | |
import time | |
from zim.utils import natural_sorted | |
from zim.parsing import parse_date | |
from zim.plugins import PluginClass, extends, ObjectExtension, WindowExtension | |
from zim.actions import action | |
from zim.notebook import Path | |
from zim.gui.widgets import ui_environment, \ | |
Dialog, MessageDialog, QuestionDialog, \ | |
InputEntry, Button, IconButton, MenuButton, \ | |
BrowserTreeView, SingleClickTreeView, Window, ScrolledWindow, HPaned, VPaned, \ | |
encode_markup_text, decode_markup_text | |
from zim.gui.clipboard import Clipboard | |
from zim.signals import DelayedCallback, SIGNAL_AFTER | |
from zim.formats import get_format, \ | |
UNCHECKED_BOX, CHECKED_BOX, XCHECKED_BOX, BULLET, BLOCK, \ | |
PARAGRAPH, NUMBEREDLIST, BULLETLIST, LISTITEM, STRIKE, \ | |
Visitor, VisitorSkip | |
from zim.config import StringAllowEmpty, ConfigManager | |
from zim.plugins.calendar import daterange_from_path | |
logger = logging.getLogger('zim.plugins.tasklist') | |
KEYVALS_AT = map (gtk.gdk.keyval_from_name, ('at')) | |
KEYVALS_ESC = map (gtk.gdk.keyval_from_name, ('Escape')) | |
KEYSTATES = gtk.gdk.CONTROL_MASK | gtk.gdk.MOD2_MASK | |
ALTQ = '<alt>q' | |
ALTat = '<alt>at' | |
SUPER = _('Superscript') | |
SUB = _('Subsript') | |
BOLD = _('Bold') | |
ITALIC = _('Italic') | |
NOF = _('No Format') | |
SQL_FORMAT_VERSION = (0, 61) | |
SQL_FORMAT_VERSION_STRING = "0.61" | |
# task column is needed for the pageview module to compare when a task is ticked | |
SQL_CREATE_TABLES = ''' | |
create table if not exists tasklist ( | |
id INTEGER PRIMARY KEY, | |
source INTEGER, | |
parent INTEGER, | |
haschildren BOOLEAN, | |
open BOOLEAN, | |
actionable BOOLEAN, | |
prio INTEGER, | |
due TEXT, | |
tags TEXT, | |
description TEXT, | |
comment TEXT, | |
tickmark BOOLEAN, | |
tickdate TEXT, | |
task TEXT | |
); | |
create table if not exists tasktickdate ( | |
id INTEGER PRIMARY KEY, | |
tickmark BOOLEAN, | |
tickdate TEXT, | |
task TEXT | |
); | |
''' | |
_tag_re = re.compile(r'(?<!\S)@(\w+)\b', re.U) | |
_date_re = re.compile(r'\s*\[d:(.+)\]') | |
_tdate_re = re.compile(r'\s*\[x:(.+)\]') | |
_NO_DATE = '9999' # Constant for empty due date - value chosen for sorting properties | |
_NO_TAGS = '__no_tags__' # Constant that serves as the "no tags" tag - _must_ be lower case | |
# FUTURE: add an interface for this plugin in the WWW frontend | |
# TODO allow more complex queries for filter, in particular (NOT tag AND tag) | |
# See function filter_item for the AND implementation (all instead of any at 'if visible and self.tag_filter:' | |
# TODO: think about what "actionable" means | |
# - no open dependencies | |
# - no defer date in the future | |
# - no child item ?? -- hide in flat list ? | |
# - no @waiting ?? -> use defer date for this use case | |
# TODO | |
# commandline option | |
# - open dialog | |
# - output to stdout with configurable format | |
# - force update, intialization | |
class TaskListPlugin(PluginClass): | |
plugin_info = { | |
'name': _('Task List'), # T: plugin name | |
'description': _('''\ | |
This plugin adds a dialog showing all open tasks in | |
this notebook. Open tasks can be either open checkboxes | |
or items marked with tags like "TODO" or "FIXME". | |
Additions by Murat Güven: | |
Auto completion for tags: | |
When you press the @ key within a note, a list of available tags are shown and can be selected. | |
Currently the activation is set to <ALT> + Q, as the Windows version does not work properly with <ALT-Gr> + Q. | |
Due date: | |
To easily add or update a due date to a task in this format "[d:date]" via keyboard shortcut <ctrl> + period | |
Options (see configuration): | |
- add x number of days to current date | |
- show due date in entry for quicker date selection (+ press c for calendar) | |
- show calendar for selecting due date | |
The due date format can be either maintained within the dates.list file | |
(used by the date function <ctrl>D) or changed in Preferences. | |
Standard is [d: %Y-%m-%d] | |
Task comment: | |
To easily add a comment below a task via keyboard shortcut <ctrl> + <shift> + > | |
Either configure your format in preferences or re-use the date format within the dates.list file. | |
Standard is [comment: %Y-%m-%d] | |
The task comments are shown within the task list. | |
Ticking tasks: | |
Tasks can be ticked / unticked within the tasklist window | |
History of ticked tasks with ticked date are shown | |
(with update of core pageview, ticked dates are handled outside of tasklist too) | |
[ChangeLog] | |
# V1.98 Preferences: Corrected text in preferences | |
# V1.97 Tasklist: re-arranged elements within the dialog to fit for low resolution screens | |
# V1.96 Tasklist: fixed index issue when tasklist was activated before updating the plugin by increasing SQL_FORMAT_VERSION to 0.61 | |
# V1.95 Taskcomment: code cleaning and fix for comment in strong / emphasis | |
# V1.94 Tasklist: label comments are also collected | |
# V1.93 Autocompletion: If text is selected after activation of AC, the selected text is replaced. If selected text | |
# was itself a tag and the @ sign wasn't selected with, it remains after inserting the new selected tag | |
# V1.92 Ticking issue while column resizing solved, Pane positions are saved again | |
# V1.91 Filtering works now with comments as well | |
# V1.9 Added "tick / untick all tasks". Reworked tags list: Added tags from ticked tasks to tag list | |
# V1.84 Fixed [no date] entry for children | |
# V1.83 Fixed wrong view in Task list history when parent task is open and child task is ticked. | |
# V1.82 Tag list rearranged in vbox tasklist dialog | |
# V1.81 using Zim widgets for autocompletion, duedate. Removed grey border from autocompletion entry (only visible in Windows) | |
# V1.8 added history of ticked tasks with ticked date (together with pageview) | |
# V1.7 added possiblity to tick tasks | |
# V1.6 merged with tag autocompletion plugin | |
# V1.5 merged with due date plugin | |
# V1.4 merged with task comment plugin | |
# V1.3 added column for task comments + added column for tags to dialog + Print | |
# V1.21 added standard tag to look for on start | |
# V1.2 added new entry with autocompletion for tags | |
This is a core plugin shipping with zim. | |
'''), # T: plugin description | |
'author': 'Jaap Karssenberg', | |
'help': 'Plugins:Task List' | |
} | |
plugin_preferences = ( | |
# key, type, label, default | |
('all_checkboxes', 'bool', _('Consider all checkboxes as tasks'), True), | |
# T: label for plugin preferences dialog | |
('tag_by_page', 'bool', _('Turn page name into tags for task items'), False), | |
# T: label for plugin preferences dialog | |
('deadline_by_page', 'bool', _('Implicit due date for task items in calendar pages'), False), | |
# T: label for plugin preferences dialog | |
('use_workweek', 'bool', _('Flag tasks due on Monday or Tuesday before the weekend'), True), | |
('show_history', 'bool', _('Show history of ticked tasks'), True), | |
# T: label for plugin preferences dialog | |
('labels', 'string', _('Labels marking tasks'), 'FIXME, TODO', StringAllowEmpty), | |
# T: label for plugin preferences dialog - labels are e.g. "FIXME", "TODO", "TASKS" | |
('next_label', 'string', _('Label for next task'), 'Next:', StringAllowEmpty), | |
# T: label for plugin preferences dialog - label is by default "Next" | |
('nonactionable_tags', 'string', _('Tags for non-actionable tasks'), '', StringAllowEmpty), | |
# T: label for plugin preferences dialog | |
('included_subtrees', 'string', _('Subtree(s) to index'), '', StringAllowEmpty), | |
# T: subtree to search for tasks - default is the whole tree (empty string means everything) | |
('excluded_subtrees', 'string', _('Subtree(s) to ignore'), '', StringAllowEmpty), | |
# T: subtrees of the included subtrees to *not* search for tasks - default is none | |
('standard_tag', 'string', _('Set tag to be searched for on start (without @)'), '', StringAllowEmpty), | |
# T: subtrees of the included subtrees to *not* search for tasks - default is none | |
('task_comment_string', 'string', _('Name the string for task comments'), 'comment'), | |
('task_comment_string_style', 'choice', _('Style for the task comment string'), SUPER, (SUPER, SUB, BOLD, ITALIC, NOF)), | |
('task_comment_date', 'bool', _('Add date to task comment'), True), | |
('task_comment_date_format', 'string', _('Format for the task comment date string'), '%Y-%m-%d', StringAllowEmpty), | |
('task_comment_dateslist', 'bool', _('Use due-date format in dates.list file for the date string'), False), | |
('task_comment_time', 'bool', _('Add time to task comment date'), False), | |
('task_comment_time_format', 'string', _('Format for the time string'), '%H:%M', StringAllowEmpty), | |
('task_comment_style', 'choice', _('Task comment style'), NOF, (NOF, BOLD, ITALIC, SUPER, SUB)), | |
('task_comment_suff', 'string', _('Suffix for the task comment'), '>', StringAllowEmpty), | |
('due_date_plus', 'int', _('Due date: Add days to today [d:today + days]'), 0, (0, 365)), | |
('due_date_entry', 'bool', _('Due date: Show entry popup'), False), | |
('due_date_cal', 'bool', _('Due date: Show calendar popup'), False), | |
('completion_non_modal', 'bool', _('Due date: Disable lock of main window, if back focus does not work'), False), | |
('single_match', 'bool', _('Tag auto completion: Single match shall not be shown in a popup'), False), | |
) | |
_rebuild_on_preferences = ['all_checkboxes', 'labels', 'next_label', 'deadline_by_page', 'nonactionable_tags', | |
'included_subtrees', 'excluded_subtrees' ] | |
# Rebuild database table if any of these preferences changed. | |
# But leave it alone if others change. | |
def extend(self, obj): | |
name = obj.__class__.__name__ | |
if name == 'MainWindow': | |
index = obj.ui.notebook.index # XXX | |
i_ext = self.get_extension(IndexExtension, index=index) | |
mw_ext = MainWindowExtension(self, obj, i_ext) | |
self.extensions.add(mw_ext) | |
else: | |
PluginClass.extend(self, obj) | |
@extends('Index') | |
class IndexExtension(ObjectExtension): | |
# define signals we want to use - (closure type, return type and arg types) | |
__signals__ = { | |
'tasklist-changed': (None, None, ()), | |
} | |
def __init__(self, plugin, index): | |
ObjectExtension.__init__(self, plugin, index) | |
self.plugin = plugin | |
self.index = index | |
self.preferences = plugin.preferences | |
self.task_labels = None | |
self.task_label_re = None | |
self.next_label = None | |
self.next_label_re = None | |
self.nonactionable_tags = [] | |
self.included_re = None | |
self.excluded_re = None | |
self.db_initialized = False | |
self._current_preferences = None | |
db_version = self.index.properties['plugin_tasklist_format'] | |
if db_version == '%i.%i' % SQL_FORMAT_VERSION: | |
self.db_initialized = True | |
self._set_preferences() | |
self.connectto(plugin.preferences, 'changed', self.on_preferences_changed) | |
self.connectto_all(self.index, ( | |
('initialize-db', self.initialize_db, None, SIGNAL_AFTER), | |
('page-indexed', self.index_page), | |
('page-deleted', self.remove_page), | |
)) | |
# We don't care about pages that are moved | |
def on_preferences_changed(self, preferences): | |
if self._current_preferences is None \ | |
or not self.db_initialized: | |
return | |
new_preferences = self._serialize_rebuild_on_preferences() | |
if new_preferences != self._current_preferences: | |
self._drop_table() | |
self._set_preferences() # Sets _current_preferences | |
def _set_preferences(self): | |
self._current_preferences = self._serialize_rebuild_on_preferences() | |
string = self.preferences['labels'].strip(' ,') | |
if string: | |
self.task_labels = [s.strip() for s in self.preferences['labels'].split(',')] | |
else: | |
self.task_labels = [] | |
if self.preferences['next_label']: | |
self.next_label = self.preferences['next_label'] | |
# Adding this avoid the need for things like "TODO: Next: do this next" | |
self.next_label_re = re.compile(r'^' + re.escape(self.next_label) + r':?\s+' ) | |
self.task_labels.append(self.next_label) | |
else: | |
self.next_label = None | |
self.next_label_re = None | |
if self.preferences['nonactionable_tags']: | |
self.nonactionable_tags = [ | |
t.strip('@').lower() | |
for t in self.preferences['nonactionable_tags'].replace(',', ' ').strip().split()] | |
else: | |
self.nonactionable_tags = [] | |
if self.task_labels: | |
regex = r'^(' + '|'.join(map(re.escape, self.task_labels)) + r')(?!\w)' | |
self.task_label_re = re.compile(regex) | |
else: | |
self.task_label_re = None | |
if self.preferences['included_subtrees']: | |
included = [i.strip().strip(':') for i in self.preferences['included_subtrees'].split(',')] | |
included.sort(key=lambda s: len(s), reverse=True) # longest first | |
included_re = '^(' + '|'.join(map(re.escape, included)) + ')(:.+)?$' | |
#~ print '>>>>>', "included_re", repr(included_re) | |
self.included_re = re.compile(included_re) | |
else: | |
self.included_re = None | |
if self.preferences['excluded_subtrees']: | |
excluded = [i.strip().strip(':') for i in self.preferences['excluded_subtrees'].split(',')] | |
excluded.sort(key=lambda s: len(s), reverse=True) # longest first | |
excluded_re = '^(' + '|'.join(map(re.escape, excluded)) + ')(:.+)?$' | |
#~ print '>>>>>', "excluded_re", repr(excluded_re) | |
self.excluded_re = re.compile(excluded_re) | |
else: | |
self.excluded_re = None | |
def _serialize_rebuild_on_preferences(self): | |
# string mapping settings that influence building the table | |
string = '' | |
for pref in self.plugin._rebuild_on_preferences: | |
string += str(self.preferences[pref]) | |
return string | |
def initialize_db(self, index): | |
with index.db_commit: | |
index.db.executescript(SQL_CREATE_TABLES) | |
self.index.properties['plugin_tasklist_format'] = '%i.%i' % SQL_FORMAT_VERSION | |
self.db_initialized = True | |
def teardown(self): | |
self._drop_table() | |
def _drop_table(self): | |
self.index.properties['plugin_tasklist_format'] = 0 | |
try: | |
self.index.db.execute('DROP TABLE "tasklist"') | |
self.index.db.execute('DROP TABLE "tasktickdate"') | |
except: | |
if self.db_initialized: | |
logger.exception('Could not drop table:') | |
self.db_initialized = False | |
def _excluded(self, path): | |
if self.included_re and self.excluded_re: | |
# judge which match is more specific | |
# this allows including subnamespace of excluded namespace | |
# and vice versa | |
inc_match = self.included_re.match(path.name) | |
exc_match = self.excluded_re.match(path.name) | |
if not exc_match: | |
return not bool(inc_match) | |
elif not inc_match: | |
return bool(exc_match) | |
else: | |
return len(inc_match.group(1)) < len(exc_match.group(1)) | |
elif self.included_re: | |
return not bool(self.included_re.match(path.name)) | |
elif self.excluded_re: | |
return bool(self.excluded_re.match(path.name)) | |
else: | |
return False | |
def index_page(self, index, path, page): | |
if not self.db_initialized: return | |
#~ print '>>>>>', path, page, page.hascontent | |
tasksfound = self.remove_page(index, path, _emit=False) | |
if self._excluded(path): | |
if tasksfound: | |
self.emit('tasklist-changed') | |
return | |
parsetree = page.get_parsetree() | |
if not parsetree: | |
return | |
#~ print '!! Checking for tasks in', path | |
dates = daterange_from_path(path) | |
if dates and self.preferences['deadline_by_page']: | |
deadline = dates[2] | |
else: | |
deadline = None | |
tasks = self._extract_tasks(parsetree, deadline) | |
if tasks: | |
# Do insert with a single commit | |
with self.index.db_commit: | |
self._insert(path, 0, tasks) | |
if tasks or tasksfound: | |
self.emit('tasklist-changed') | |
def _insert(self, page, parentid, children): | |
# Helper function to insert tasks in table | |
c = self.index.db.cursor() | |
for task, grandchildren in children: | |
task[4] = ','.join(sorted(task[4])) # set to text | |
task[6] = ''.join(sorted(task[6], reverse=True)) # sort comments against date and set to text | |
c.execute( | |
'insert into tasklist(source, parent, haschildren, open, actionable, prio, due, tags, description, comment, tickmark, tickdate, task)' | |
'values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', | |
(page.id, parentid, bool(grandchildren)) + tuple(task) | |
) | |
if grandchildren: | |
self._insert(page, c.lastrowid, grandchildren) # recurs | |
def _extract_tasks(self, parsetree, defaultdate=None): | |
'''Extract all tasks from a parsetree. | |
@param parsetree: a L{zim.formats.ParseTree} object | |
@param defaultdate: default due date for the whole page (e.g. for calendar pages) as string | |
@returns: nested list of tasks, each task is given as a 2-tuple, 1st item is a tuple | |
with following properties: C{(open, actionable, prio, due, description)}, 2nd item | |
is a list of child tasks (if any). | |
''' | |
parser = TasksParser( | |
self.task_label_re, | |
self.next_label_re, | |
self.nonactionable_tags, | |
self.preferences['all_checkboxes'], | |
defaultdate, | |
self.preferences | |
) | |
parser.parse(parsetree) | |
return parser.get_tasks() | |
def remove_page(self, index, path, _emit=True): | |
if not self.db_initialized: return | |
tasksfound = False | |
with index.db_commit: | |
cursor = index.db.cursor() | |
cursor.execute( | |
'delete from tasklist where source=?', (path.id,) ) | |
tasksfound = cursor.rowcount > 0 | |
if tasksfound and _emit: | |
self.emit('tasklist-changed') | |
return tasksfound | |
def list_tasks(self, parent=None): | |
'''List tasks | |
@param parent: the parent task (as returned by this method) or C{None} to list | |
all top level tasks | |
@returns: a list of tasks at this level as sqlite Row objects | |
''' | |
if parent: parentid = parent['id'] | |
else: parentid = 0 | |
if self.db_initialized: | |
cursor = self.index.db.cursor() | |
cursor.execute('select * from tasklist where parent=? order by prio, due, description', (parentid,)) | |
# Want order by prio & due - add desc to keep sorting more or less stable | |
for row in cursor: | |
yield row | |
def get_task(self, taskid): | |
cursor = self.index.db.cursor() | |
cursor.execute('select * from tasklist where id=?', (taskid,)) | |
return cursor.fetchone() | |
def get_path(self, task): | |
'''Get the L{Path} for the source of a task | |
@param task: the task (as returned by L{list_tasks()} | |
@returns: an L{IndexPath} object | |
''' | |
return self.index.lookup_id(task['source']) | |
def put_new_tickdate_to_db(self, task, tickmark=True): | |
date = zim_datetime.now() | |
cursor = self.index.db.cursor() | |
cursor.execute('UPDATE tasktickdate set tickdate=? WHERE task=?', (date, unicode(task))) | |
if not cursor.rowcount: | |
cursor.execute('INSERT into tasktickdate(tickmark, tickdate, task) values (?, ?, ?)', (tickmark, unicode(date), unicode(task))) | |
def put_existing_tickdate_to_db(self, task, date, tickmark=True): | |
cursor = self.index.db.cursor() | |
cursor.execute('UPDATE tasktickdate set tickdate=? WHERE task=?', (date, unicode(task))) | |
if not cursor.rowcount: | |
cursor.execute('INSERT into tasktickdate(tickmark, tickdate, task) values (?, ?, ?)', (tickmark, unicode(date), unicode(task))) | |
def get_tickdate_from_db(self, task): | |
cursor = self.index.db.cursor() | |
cursor.execute('SELECT * FROM tasktickdate WHERE task=?', (task,)) | |
return cursor.fetchone() | |
def del_tickdate_from_db(self, task): | |
cursor = self.index.db.cursor() | |
cursor.execute('DELETE FROM tasktickdate WHERE task=?', (unicode(task),)) | |
@extends('MainWindow') | |
class MainWindowExtension(WindowExtension): | |
uimanager_xml = ''' | |
<ui> | |
<menubar name='menubar'> | |
<menu action='view_menu'> | |
<placeholder name="plugin_items"> | |
<menuitem action="show_task_list" /> | |
</placeholder> | |
</menu> | |
<menu action='tools_menu'> | |
<placeholder name='plugin_items'> | |
<menuitem action='task_comment'/> | |
</placeholder> | |
<placeholder name='plugin_items'> | |
<menuitem action='due_date'/> | |
</placeholder> | |
<placeholder name='plugin_items'> | |
<menuitem action='auto_completion'/> | |
</placeholder> | |
</menu> | |
</menubar> | |
<toolbar name='toolbar'> | |
<placeholder name='tools'> | |
<toolitem action='show_task_list'/> | |
</placeholder> | |
</toolbar> | |
</ui> | |
''' | |
def __init__(self, plugin, window, index_ext): | |
WindowExtension.__init__(self, plugin, window) | |
self.index_ext = index_ext | |
@action(_('Task List'), stock='zim-task-list', accelerator='<ctrl>t', readonly=True) # T: menu item | |
def show_task_list(self): | |
if not self.index_ext.db_initialized: | |
MessageDialog(self.window, ( | |
_('Need to index the notebook'), | |
# T: Short message text on first time use of task list plugin | |
_('This is the first time the task list is opened.\n' | |
'Therefore the index needs to be rebuild.\n' | |
'Depending on the size of the notebook this can\n' | |
'take up to several minutes. Next time you use the\n' | |
'task list this will not be needed again.' ) | |
# T: Long message text on first time use of task list plugin | |
) ).run() | |
logger.info('Tasklist not initialized, need to rebuild index') | |
finished = self.window.ui.reload_index(flush=True) # XXX | |
# Flush + Reload will also initialize task list | |
if not finished: | |
self.index_ext.db_initialized = False | |
return | |
dialog = TaskListDialog.unique(self, self.window, self.index_ext, self.plugin.preferences) | |
dialog.present() | |
#~ accel_key = 'at' | |
ac_accel_key = ALTQ | |
@action(_('Task Comment'), accelerator='<ctrl>greater') # T: menu item | |
def task_comment(self): | |
tc = TaskComment(self.window, self.plugin.preferences) | |
tc.add() | |
@action(_('_Due Date'), accelerator='<ctrl>period') # T: menu item | |
def due_date(self): | |
dd = DueDate(self.window, self.plugin.preferences) | |
dd.add() | |
@action(_('_Auto Completion'), accelerator=ac_accel_key) # T: menu item | |
def auto_completion(self): | |
ac = AutoCompletion(self.window, self.plugin.preferences) | |
ac.show() | |
class AutoCompletion(): | |
def __init__(self, window, plugin_preferences): | |
self.plugin_prefs = plugin_preferences | |
self.window = window | |
self.zim_buffer = self.window.pageview.view.get_buffer() | |
self.zim_index = self.window.ui.notebook.index | |
self.main_window = self.window.pageview.view | |
def show(self): | |
# get selection if any before it is removed by the entry completion selection | |
try: | |
self.start, self.end = self.zim_buffer.get_selection_bounds() | |
self.text_is_selected = True | |
except: | |
self.text_is_selected = False | |
self.completion_window = Window() | |
self.entry = InputEntry() | |
# No frame around entry | |
self.entry.set_inner_border(None) | |
self.completion_window.add(self.entry) | |
# remove grey border from completion window around entry | |
self.completion_window.set_geometry_hints(self.entry, max_height=1) | |
#to prevent that main window is used during autocompletion | |
if not self.plugin_prefs['completion_non_modal']: | |
self.completion_window.set_modal(True) | |
self.completion_window.set_keep_above(True) | |
self.cursor = self.zim_buffer.get_iter_at_mark(self.zim_buffer.get_insert()) | |
x, y, height = self.ac_get_iter_pos(self.main_window, self.cursor) | |
self.completion_window.move(x, y) | |
self.entry_completion = gtk.EntryCompletion() | |
# this brings the hit from the list into the entry box so | |
# it can be selected hitting RETURN | |
self.entry_completion.set_inline_completion(True) | |
# add tags from zim index to the completion list | |
tag_liststore = gtk.ListStore(str) | |
self.ac_set_tag_liststore(tag_liststore) | |
# if there is only one hit, don't show popup | |
if self.plugin_prefs['single_match']: | |
self.entry_completion.set_popup_single_match(False) | |
self.entry.set_completion(self.entry_completion) | |
self.entry_completion.set_text_column(0) | |
# just the plane entry box | |
self.completion_window.set_decorated(False) | |
# as lowercase entries are not matched after the 3rd character somehow with the standard completion | |
# entry_completion.set_match_func(self.new_completion_match_func, 0) # not working yet | |
self.completion_window.show_all() | |
# if list element is selected via mouse klick or cursor + return | |
self.entry_completion.connect('match-selected', self.ac_match_selected) | |
# when return key is pressed | |
self.entry.connect('activate', self.ac_activate_return) | |
#listen if ESC key is pressed for the entry completion | |
self.entry.add_events(gtk.gdk.KEY_PRESS_MASK) | |
self.entry.connect('key_press_event', self.ac_kpe_entry) | |
# not working yet. Want to use this as the entry completion is not really working with lower case | |
# def new_completion_match_func(completion, entry_str, iter, data): | |
# self.column = data | |
# self.model = completion.get_model() | |
# self.modelstr = self.model[iter][self.column] | |
# return self.modelstr.upper()(entry_str.upper()) | |
def ac_set_tag_liststore(self, tag_liststore): | |
self.zim_tags = self.zim_index.list_all_tags() | |
tag_liststore.clear() | |
for self.tag in self.zim_tags: | |
tag_liststore.append([self.tag.name]) | |
self.entry_completion.set_model(tag_liststore) | |
def ac_kpe_entry(self, widget, event): | |
if event.keyval: | |
if event.keyval == gtk.gdk.keyval_from_name('Escape'): | |
self.completion_window.destroy() | |
if event.keyval == gtk.gdk.keyval_from_name('Tab'): | |
self.entry.activate() | |
def ac_match_selected(self, completion, model, iter): | |
if self.text_is_selected: | |
self.zim_buffer.delete(self.start, self.end) | |
if self.char_is_at_before_selection(): | |
self.tag = model[iter][0] | |
else: | |
self.tag = "@" + model[iter][0] | |
self.completion_window.destroy() | |
#get the position of the cursor from the parent window/textbuffer | |
cursor = self.zim_buffer.get_iter_at_mark(self.zim_buffer.get_insert()) | |
self.zim_buffer.insert(cursor, self.tag) | |
def ac_activate_return(self, widget): | |
#get the selected tag string | |
self.tag = self.entry.get_text() | |
# if not return was hit in an empty box without text | |
if self.tag: | |
if self.text_is_selected: | |
self.zim_buffer.delete(self.start, self.end) | |
if not self.char_is_at_before_selection(): | |
self.tag = "@" + self.tag | |
else: | |
self.tag = "@" + self.tag | |
#get the position of the cursor from the parent window/textbuffer | |
cursor = self.zim_buffer.get_iter_at_mark(self.zim_buffer.get_insert()) | |
#add tag string into textbuffer | |
self.zim_buffer.insert(cursor, self.tag) | |
#close autocompletion window | |
#self.tag = '' | |
self.completion_window.destroy() | |
def char_is_at_before_selection(self): | |
cursor_orig = self.zim_buffer.get_iter_at_mark(self.zim_buffer.get_insert()) | |
cursor_orig.backward_chars(1) | |
cursor = self.zim_buffer.get_iter_at_mark(self.zim_buffer.get_insert()) | |
self.zim_buffer.place_cursor(cursor) | |
start = self.zim_buffer.get_iter_at_mark(self.zim_buffer.get_insert()) | |
end = cursor_orig | |
char = self.zim_buffer.get_text(start, end) | |
if char == "@": | |
return True | |
return False | |
def ac_get_iter_pos(self, textview, cursor): | |
self.top_x, self.top_y = textview.get_toplevel().get_position() | |
self.iter_location = textview.get_iter_location(cursor) | |
self.mark_x, self.mark_y = self.iter_location.x, self.iter_location.y + self.iter_location.height | |
#calculate buffer-coordinates to coordinates within the window | |
self.win_location = textview.buffer_to_window_coords(gtk.TEXT_WINDOW_WIDGET, | |
int(self.mark_x), int(self.mark_y)) | |
#now find the right window --> Editor Window and the right pos on screen | |
self.win = textview.get_window(gtk.TEXT_WINDOW_WIDGET); | |
self.view_pos = self.win.get_position() | |
self.xx = self.win_location[0] + self.view_pos[0] | |
self.yy = self.win_location[1] + self.view_pos[1] + self.iter_location.height | |
self.x = self.top_x + self.xx | |
self.y = self.top_y + self.yy | |
return (self.x, self.y, self.iter_location.height) | |
class DueDate(): | |
def __init__(self, window, plugin_preferences): | |
self.plugin_prefs = plugin_preferences | |
self.window = window | |
def add(self): | |
#self.cal_window = gtk.Window() | |
self.cal_window = Window() | |
self.cal_window.set_decorated(False) | |
self.cal_window.set_modal(True) | |
self.cal_window.set_keep_above(True) | |
self.cal = gtk.Calendar() | |
self.cal.display_options(gtk.CALENDAR_SHOW_WEEK_NUMBERS | gtk.CALENDAR_SHOW_HEADING | gtk.CALENDAR_SHOW_DAY_NAMES) | |
self.cal_window.add(self.cal) | |
#self.entry_window = gtk.Window() | |
self.entry_window = Window() | |
self.entry_window.set_decorated(False) | |
self.entry_window.set_modal(True) | |
self.entry_window.set_keep_above(True) | |
self.vbox = gtk.VBox() | |
self.label_day_name = gtk.Label() | |
self.entry_hbox = gtk.HBox() | |
#self.entry_year = gtk.Entry(4) | |
self.entry_year = InputEntry() | |
self.entry_year.set_max_length(4) | |
self.entry_year.set_width_chars(4) | |
#self.entry_month = gtk.Entry(2) | |
self.entry_month = InputEntry() | |
self.entry_month.set_max_length(2) | |
self.entry_month.set_width_chars(2) | |
#self.entry_day = gtk.Entry(2) | |
self.entry_day = InputEntry() | |
self.entry_day.set_max_length(2) | |
self.entry_day.set_width_chars(2) | |
#self.entry_week_no = gtk.Entry(2) | |
self.entry_week_no = InputEntry() | |
self.entry_week_no.set_max_length(2) | |
self.entry_week_no.set_width_chars(2) | |
self.entry_week_no.set_sensitive(False) | |
self.label_due_date_plus = gtk.Label() | |
self.label_due_date_plus.set_text('Today + ') | |
#self.entry_due_date_plus = gtk.Entry(6) | |
self.entry_due_date_plus = InputEntry() | |
self.entry_due_date_plus.set_max_length(6) | |
self.entry_due_date_plus.set_width_chars(6) | |
self.due_date_plus_hbox = gtk.HBox() | |
self.due_date_plus_hbox.pack_start(self.label_due_date_plus, expand = False, padding = 2) | |
self.due_date_plus_hbox.pack_start(self.entry_due_date_plus, expand = True, padding = 1) | |
self.due_date_plus = self.plugin_prefs['due_date_plus'] | |
self.vbox.pack_start(self.label_day_name, expand = False, padding=0) | |
self.vbox.pack_start(self.entry_hbox, expand = False, padding=0) | |
self.vbox.pack_start(self.due_date_plus_hbox, expand = False, padding = 2) | |
''' | |
[0: current_date_element, 1: prev_date_element] | |
1: year, 2: month, 3: day, 4: due_date_plus, 5: week_no (not really used any more from history) | |
''' | |
# in fact I don't need 2 history levels any more, but I keep it for later use | |
self.entry_history = { 1: [0, False], | |
2: [0, False], | |
3: [0, False], | |
4: [0, False], | |
5: [0, False] | |
} | |
def up_down_arrow_key(arrow_key_dict): | |
# get the elements from the dict / list | |
function = arrow_key_dict[0] | |
entry = arrow_key_dict[1] | |
ddplus_calc_value = arrow_key_dict[2] | |
# call the right function from the list with its params | |
function(ddplus_calc_value, entry) | |
# in case someone enters a new day and then directly uses Up arrow key | |
self.update_due_date_plus() | |
# put back focus to the correct entry as it's set to entry_due_date_plus in above function | |
entry.grab_focus() | |
def left_right_arrow_key(arrow_key_dict): | |
function = arrow_key_dict[0] | |
entry = arrow_key_dict[1] | |
entry.grab_focus() | |
#select data in entry | |
function(0 , -1) | |
def cal_lrud_key(value, widget): | |
self.due_date_plus += value | |
new_date = self.get_date_with_ddplus(None, self.due_date_plus) | |
widget.select_month(new_date.month-1, new_date.year) | |
self.cal_select_day(new_date.day) | |
pass | |
''' | |
this dict provides me a set of functions which I call according to the id of the key press event | |
this way I avoid repeating code... | |
''' | |
self.entry_key_inputs = { 65362 : (up_down_arrow_key, {'day' : [self.ce_kpe_day_and_ddplus, self.entry_day, 1], # up arrow key | |
'month' : [self.ce_kpe_month, self.entry_month, 1], | |
'year' : [self.ce_kpe_year, self.entry_year, 1], | |
'ddplus' : [self.ce_kpe_day_and_ddplus, self.entry_due_date_plus, 1]}), | |
65364 : (up_down_arrow_key, {'day' : [self.ce_kpe_day_and_ddplus, self.entry_day, -1], # down arrow key | |
'month' : [self.ce_kpe_month, self.entry_month, -1], | |
'year' : [self.ce_kpe_year, self.entry_year, -1], | |
'ddplus' : [self.ce_kpe_day_and_ddplus, self.entry_due_date_plus, -1]}), | |
65361 : (left_right_arrow_key, {'day' : [self.entry_month.select_region, self.entry_month], # when event comes from entry_day, I need to go to month (left) | |
'month' : [self.entry_year.select_region, self.entry_year], # when event comes from entry_month, I need to go to year (left) | |
'year' : [self.entry_due_date_plus.select_region, self.entry_due_date_plus], # when event comes from entry_year, I need to go to ddplus (left) | |
'ddplus' : [self.entry_day.select_region, self.entry_day]}), # when event comes from entry_due_date_plus, I need to go to day (left) | |
65363 : (left_right_arrow_key, {'day' : [self.entry_due_date_plus.select_region, self.entry_due_date_plus], # when event comes from entry_day, I need to go to entry_due_date_plus (right) | |
'ddplus' : [self.entry_year.select_region, self.entry_year], # when event comes from entry_due_date_plus, I need to go to entry_year (right) | |
'year' : [self.entry_month.select_region, self.entry_month], # when event comes from entry_year, I need to go to entry_month (right) | |
'month' : [self.entry_day.select_region, self.entry_day]}) # when event comes from entry_month, I need to go to entry_day (right) | |
} | |
self.cal_key_inputs = { 65361 : (cal_lrud_key, -1), # left arrow key | |
65363 : (cal_lrud_key, +1), # right arrow key | |
65362 : (cal_lrud_key, -7), # up arrow key | |
65364 : (cal_lrud_key, +7) # down arrow key | |
} | |
# TODO: need to set focus chain manually to prevent focus on due_date_plus entry when arrow down is clicked | |
# not quite satisfied with the current result though | |
self.vbox.set_focus_chain([self.entry_due_date_plus, self.entry_day]) | |
self.entry_hbox.pack_start(self.entry_year, expand=True, padding=1) | |
self.entry_hbox.pack_start(self.entry_month, expand=True, padding=1) | |
self.entry_hbox.pack_start(self.entry_day, expand=True, padding=1) | |
self.entry_hbox.pack_start(self.entry_week_no, expand=True, padding=2) | |
self.entry_window.add(self.vbox) | |
self.buf = self.window.pageview.view.get_buffer() | |
# needed to highlight due date | |
self.text_tag = self.buf.create_tag(background = "grey") | |
self.page_view = self.window.pageview.view | |
self.cursor = self.buf.get_iter_at_mark(self.buf.get_insert()) | |
self.due_date_begin_iter = "" | |
self.due_date_end_iter = "" | |
# preserve cursor position | |
self.cursor_orig = self.buf.get_iter_at_mark(self.buf.get_insert()) | |
self.format = self.get_format() | |
self.exist_due_date = None | |
self.get_due_date_at_line(self.buf) | |
if self.plugin_prefs['due_date_entry']: | |
self.show_entries() | |
elif self.plugin_prefs['due_date_cal']: | |
self.show_cal() | |
else: | |
self.insert_due_date(self.exist_due_date) | |
def show_entries(self): | |
# put data into entries | |
self.fill_entries(self.exist_due_date) | |
# move entries to cursor position | |
x, y, height = self.get_iter_pos() | |
self.entry_window.move(x, y) | |
self.entry_day.connect("key_press_event", self.ce_keypress, "day") | |
self.entry_month.connect("key_press_event", self.ce_keypress, "month") | |
self.entry_year.connect("key_press_event", self.ce_keypress, "year") | |
self.entry_due_date_plus.connect("key_press_event", self.ce_keypress, "ddplus") | |
self.entry_year.connect("activate", self.ce_activate_insert_due_date) | |
self.entry_month.connect("activate", self.ce_activate_insert_due_date) | |
self.entry_day.connect("activate", self.ce_activate_insert_due_date) | |
self.entry_due_date_plus.connect("activate", self.ce_activate_insert_due_date) | |
self.entry_year.connect("event", self.ce_event, self.entry_year) | |
self.entry_month.connect("event", self.ce_event, self.entry_month) | |
self.entry_day.connect("event", self.ce_event, self.entry_day) | |
self.entry_due_date_plus.connect("event", self.ce_event, self.entry_due_date_plus) | |
self.entry_window.show_all() | |
def show_day_name(self, year, month, day): | |
date = self.get_specific_date(year, month, day) | |
day_name = date.strftime("%A") | |
self.label_day_name.set_text(str(day_name)) | |
def show_cal(self): | |
self.entry_window.destroy() | |
# TODO: it's more logical to show the calendar in addition, but needs some re-design, | |
# that if calender entry is selected with cursor, it should also run through the entry date_elements | |
# only if date is clicked in calender, add selection to buffer | |
due_date = self.get_date_with_ddplus() | |
# due_date.month-1 as calendar starts counting with 0 as January .... | |
self.cal.select_month(due_date.month-1, due_date.year) | |
self.cal.select_day(due_date.day) | |
x, y, height = self.get_iter_pos() | |
# x-200 when above TODO is done | |
self.cal_window.move(x, y) | |
self.cal_window.show_all() | |
self.cal_mark_today() | |
self.cal.connect("key_press_event", self.c_kpe_calendar, self.window) | |
self.cal.connect("day-selected-double-click", self.ce_selected_cal_date) | |
def show_date_element_data_in_entry(self, entry, date_element_data, history_index): | |
''' | |
data 0: current_date_element, | |
data 1: prev_date_element] | |
index 1: year, 2: month, 3: day, 4: due_date_plus | |
''' | |
self.put_date_element_data_into_hist(date_element_data, history_index) | |
entry.set_text(str(date_element_data)) | |
def show_all_date_element_data(self, year, month, day, ddplus, focus_entry=None): | |
year, month, day, ddplus = self.validate_date_element_data(year, month, day, ddplus) | |
# only validated data should be in history... | |
self.put_all_date_element_data_into_hist(year, month, day, ddplus) | |
week_no = self.get_week_no(year, month, day) | |
self.show_day_name(year, month, day) | |
# calculate the number of due date plus from current date to today | |
self.calculate_due_date_plus_from_today(year, month, day) | |
self.show_date_element_data_in_entry(self.entry_due_date_plus, self.due_date_plus, 4) | |
self.show_date_element_data_in_entry(self.entry_week_no, week_no, 5) | |
self.show_date_element_data_in_entry(self.entry_year, year, 1) | |
self.show_date_element_data_in_entry(self.entry_month, month, 2) | |
self.show_date_element_data_in_entry(self.entry_day, day, 3) | |
focus_entry.grab_focus() | |
focus_entry.select_region(0 , -1) | |
def get_week_no(self, year, month, day): | |
date = self.get_specific_date(year, month, day) | |
week_no = date.isocalendar()[1] | |
return week_no | |
def validate_date_element_data(self, year, month, day, ddplus): | |
# make data int. By doing this in the beginning, I ensure that empty elements or chars are already handled. Only if 0 is entered, I need to check below | |
try: | |
year = int(year) | |
except ValueError: | |
# If I ensure that only integer is put into history, I don't need to integer this again | |
year = self.entry_history[1][0] | |
try: | |
month = int(month) | |
except ValueError: | |
month = self.entry_history[2][0] | |
try: | |
day = int(day) | |
except ValueError: | |
day = self.entry_history[3][0] | |
try: | |
ddplus = int(ddplus) | |
except ValueError: | |
ddplus = self.entry_history[4][0] | |
# identify, if data was passed. If None or empty, get history data | |
if year == 0: | |
year = self.entry_history[1][0] | |
if month == 0: | |
month = self.entry_history[2][0] | |
if day == 0: | |
day = self.entry_history[3][0] | |
# now ensure that year, month and day is a valid number. datetime takes only year > 1900, month obviously <= 12 | |
if year < 1900: | |
year = 1900 | |
if month > 12: | |
month = 12 | |
last_day_of_month = self.get_last_day_of_month(year, month) | |
if day > last_day_of_month: | |
day = last_day_of_month | |
return year, month, day, ddplus | |
def put_all_date_element_data_into_hist(self, year, month, day, ddplus): | |
''' | |
The history is needed if user removes data from entry and then press enter or clicks to another entry | |
data 0: current_date_element, | |
data 1: prev_date_element] | |
index 1: year, 2: month, 3: day, 4: due_date_plus, 5: week_no not needed any more | |
''' | |
list_of_elements = [year, month, day, ddplus] | |
for history_index in range(1-4): | |
# put data into history and cycle old elements through all levels of the history dict | |
# depth is the max number of history levels: -1 as 0 is the first level -> depth = len -1 --> flexibility if I want to increase history depth per entry in the dict | |
depth = len(self.entry_history[history_index]) -1 | |
# start with level 0, so level 0 is the first entry in the history | |
level = 0 | |
# depth = 0 is filled after this loop with current date_element_data | |
while level < depth: | |
# fill up the history from the bottom. So very last is put into one above | |
self.entry_history[history_index][depth-level] = self.entry_history[history_index][depth-level-1] | |
level += 1 | |
self.entry_history[history_index][0] = list_of_elements[history_index-1] | |
def put_date_element_data_into_hist(self, date_element_data, history_index): | |
''' | |
The history is needed if user removes data from entry and then press enter or clicks to another entry | |
data 0: current_date_element, | |
data 1: prev_date_element] | |
index 1: year, 2: month, 3: day, 4: due_date_plus, 5: week_no not needed any more | |
''' | |
# put data into history and cycle old elements through all levels of the history dict | |
# depth is the max number of history levels: -1 as 0 is the first level -> depth = len -1 --> flexibility if I want to increase history depth per entry in the dict | |
depth = len(self.entry_history[history_index]) -1 | |
# start with level 0, so level 0 is the first entry in the history | |
level = 0 | |
# depth = 0 is filled after this loop with current date_element_data | |
while level < depth: | |
# fill up the history from the bottom. So very last is put into one above | |
self.entry_history[history_index][depth-level] = self.entry_history[history_index][depth-level-1] | |
level += 1 | |
self.entry_history[history_index][0] = date_element_data | |
def print_history(self): | |
# to check if history is working correct | |
for index in range(1, 5): | |
for level in range(len(self.entry_history[index])): | |
print 'Index: {0}, Level: {1}, data: , {2}'.format(index, level, self.entry_history[index][level]) | |
def print_data(self, method=None, data1=None, data2=None, data3=None): | |
print 'method: {0}, data1: {1}, data2: {2}, data3: , {3}'.format(method, data1, data2, data3) | |
def insert_due_date(self, due_date = None): | |
# delete existing due date | |
if self.due_date_begin_iter: | |
self.del_due_date_at_line(self.buf) | |
if not due_date: | |
due_date = self.get_date_with_ddplus() | |
# get the user's due date format | |
# format = self.get_format() --> already in self.format | |
# now put the due date into the format | |
date_with_format = zim_datetime.strftime(self.format, due_date) | |
self.buf.insert(self.cursor, date_with_format) | |
self.high_light_chars(2) | |
return | |
def calculate_due_date_plus_from_today(self, year, month, day): | |
date = self.get_specific_date(year, month, day) | |
date_today = zim_datetime.date.today() | |
today = self.get_specific_date(date_today.year, date_today.month, date_today.day) | |
date_diff = date - today | |
self.due_date_plus = date_diff.days | |
def ce_event(self, widget, event, entry): | |
if event.type == gtk.gdk.BUTTON_RELEASE: | |
# in case someone enters new data into the other entries and then directly clicks into due_date_plus entry | |
self.update_due_date_plus() | |
entry.grab_focus() | |
def update_due_date_plus(self): | |
year, month, day, ddplus = self.get_all_date_element_data() | |
self.calculate_due_date_plus_from_today(year, month, day) | |
self.show_all_date_element_data(year, month, day, ddplus, focus_entry = self.entry_due_date_plus) | |
def ce_activate_insert_due_date(self, widget): # ce = connect entry... | |
#add due date string into textbuffer at cursor | |
year, month, day, ddplus = self.get_all_date_element_data() | |
year_hist, month_hist, day_hist, ddplus_hist = self.get_all_date_element_data_from_hist() | |
# if nothing has been changed manually, the date can be taken and inserted into buffer | |
if year == year_hist and month == month_hist and day == day_hist and ddplus == ddplus_hist: | |
due_date = self.put_date_to_format(year, month, day) | |
if self.due_date_begin_iter: | |
self.del_due_date_at_line(self.buf) | |
self.buf.insert(self.cursor, due_date) | |
self.entry_window.destroy() | |
# If due date plus = 0 entered --> reset to today | |
if ddplus == 0: | |
self.due_date_plus = 0 | |
self.fill_entries(zim_datetime.date.today()) | |
return | |
# something was entered into due_date_plus entry | |
if ddplus <> ddplus_hist: | |
self.due_date_plus = ddplus | |
self.due_date = self.get_date_with_ddplus(None, ddplus) | |
self.fill_entries(self.due_date) | |
else: | |
self.calculate_due_date_plus_from_today(year, month, day) | |
self.show_all_date_element_data(year, month, day, ddplus, focus_entry = self.entry_due_date_plus) | |
return | |
def ce_selected_cal_date(self, calendar): | |
(year, month, day) = calendar.get_date() | |
date = self.put_date_to_format(year, month+1, day) | |
if self.due_date_begin_iter: | |
self.del_due_date_at_line(self.buf) | |
cursor = self.buf.get_iter_at_mark(self.buf.get_insert()) | |
self.buf.insert(cursor, date + " ") | |
self.cal_window.destroy() | |
def put_date_to_format(self, year, month, day): | |
date = self.get_specific_date(year,month,day) | |
#format = self.get_format() | |
return date.strftime(self.format) | |
def fill_entries(self, date = None): | |
if not date: | |
date = self.get_date_with_ddplus() | |
self.show_all_date_element_data(date.year, date.month, date.day, self.due_date_plus, focus_entry = self.entry_day) | |
def ce_keypress(self, widget, event, entry_type): | |
''' | |
entry_type = day, month, year, ddplus | |
''' | |
try: | |
# I get the function which I want to call and the list of arguments according to the keyval from self.inputs | |
function_for_arrow_key, args_for_arrow_key_function = self.entry_key_inputs[event.keyval] | |
# I call the necessary function with the list of arguments, using the entry type to identify the right element in the dict within the list of arguments | |
function_for_arrow_key(args_for_arrow_key_function[entry_type]) | |
except (KeyError): | |
# all other keys:'c' or ESC or Pos1 | |
self.ce_events_common_key(event.keyval) | |
def ce_events_common_key(self, keyval): | |
if keyval == gtk.gdk.keyval_from_name('Escape'): | |
try: | |
self.buf.remove_tag(self.text_tag, self.due_date_begin_iter[0], self.due_date_end_iter[1]) | |
except (ValueError, AttributeError, TypeError): | |
dummy = 0 | |
self.buf.place_cursor(self.cursor_orig) | |
self.entry_window.destroy() | |
elif keyval == gtk.gdk.keyval_from_name('c'): | |
self.show_cal() | |
elif keyval == 65360: # Pos1 key | |
# reset entries to today | |
self.due_date_plus = 0 | |
self.fill_entries(zim_datetime.date.today()) | |
def ce_kpe_day_and_ddplus(self, value, entry): | |
self.due_date_plus += value | |
new_date = self.get_date_with_ddplus(None, self.due_date_plus) | |
self.show_all_date_element_data(new_date.year, new_date.month, new_date.day, self.due_date_plus, focus_entry = entry) | |
def ce_kpe_month(self, value, entry): | |
year, month, day, due_date_plus = self.get_all_date_element_data() | |
self.due_date_plus += value * self.get_last_day_of_month(year, month) | |
new_date = self.get_date_with_ddplus(None, self.due_date_plus) | |
self.show_all_date_element_data(new_date.year, new_date.month, new_date.day, self.due_date_plus, focus_entry = entry) | |
def ce_kpe_year(self, value, entry): | |
year, month, day, due_date_plus = self.get_all_date_element_data() | |
day_of_year = self.get_yday(year, 12, 31) | |
self.due_date_plus += value * day_of_year | |
new_date = self.get_date_with_ddplus(None, self.due_date_plus) | |
self.show_all_date_element_data(new_date.year, new_date.month, new_date.day, self.due_date_plus, focus_entry = entry) | |
def cal_mark_today(self): | |
# just in case, make a clear start | |
self.cal.clear_marks() | |
(year, month, day) = self.cal.get_date() | |
today = zim_datetime.date.today() | |
# if calendar is displaying current month, mark today | |
if year == today.year and month == today.month-1: | |
self.cal.mark_day(today.day) | |
# c - connect | |
def c_kpe_calendar(self, widget, event, window): | |
# just in case, make a clear start | |
widget.clear_marks() | |
(year, month, day) = self.cal.get_date() | |
today = zim_datetime.date.today() | |
# if calendar is displaying current month, mark today | |
if year == today.year and month == today.month-1: | |
self.cal.mark_day(today.day) | |
if event.keyval == gtk.gdk.keyval_from_name('Escape'): | |
self.cal_window.destroy() | |
if event.keyval == gtk.gdk.keyval_from_name('Return'): | |
self.ce_selected_cal_date(widget) | |
if event.keyval == 65360: # Pos1 key | |
# reset calendar to today | |
self.due_date_plus = 0 | |
due_date = self.get_date_with_ddplus() | |
# -1 as calendar starts counting with 0 as January .... | |
self.cal.select_month(due_date.month-1, due_date.year) | |
self.cal_select_day(due_date.day) | |
## Picture up and down keys | |
if event.keyval == 65365: # Picture up key | |
next_month = month + 1 | |
if next_month == 12: | |
next_month = 0 | |
year = year + 1 | |
widget.select_month(next_month, year) | |
# or statement in order to take the last day of month for further months | |
if (day > self.get_last_day_of_month(year, next_month+1)) or (day == self.get_last_day_of_month(year, month+1)): | |
day = self.get_last_day_of_month(year, next_month+1) | |
self.cal_select_day(day) | |
if event.keyval == 65366: # Picture down key | |
prev_month = month - 1 | |
if prev_month == -1: | |
prev_month = 11 | |
year = year - 1 | |
widget.select_month(prev_month, year) | |
# +1 as calendar counts starting with 0 | |
if (day > self.get_last_day_of_month(year, prev_month+1)) or (day == self.get_last_day_of_month(year, month+1)): | |
day = self.get_last_day_of_month(year, prev_month+1) | |
self.cal_select_day(day) | |
try: | |
# I get the function which I want to call and the list of arguments according to the keyval from self.cal_key_inputs | |
function_for_arrow_key, args_for_arrow_key_function = self.cal_key_inputs[event.keyval] | |
# I call the necessary function with the list of arguments | |
function_for_arrow_key(args_for_arrow_key_function, widget) | |
except (KeyError): | |
pass | |
def cal_select_day(self, selected_day): | |
# just in case, make a clear start | |
self.cal.clear_marks() | |
(year, month, day) = self.cal.get_date() | |
today = zim_datetime.date.today() | |
# if calendar is displaying current month, mark today | |
if year == today.year and month == today.month-1: | |
self.cal.mark_day(today.day) | |
self.cal.select_day(selected_day) | |
def high_light_chars(self, chars_n): | |
# now move 1 char back and place the cursor | |
self.cursor.backward_chars(chars_n-1) | |
self.buf.place_cursor(self.cursor) | |
# now get the iter at cursor pos | |
bound = self.buf.get_iter_at_mark(self.buf.get_insert()) | |
# now2 move 2 char back and place the cursor | |
self.cursor.backward_chars(chars_n) | |
self.buf.place_cursor(self.cursor) | |
# now get the iter at cursor pos | |
ins = self.buf.get_iter_at_mark(self.buf.get_insert()) | |
# now select 2 chars | |
self.buf.select_range(ins, bound) | |
def get_specific_date(self, year, month, day): | |
try: | |
date = zim_datetime.datetime(year, month, day) | |
except ValueError: | |
date = zim_datetime.datetime(1900, month, day) | |
return date | |
def get_date_with_ddplus(self, current_date=None, due_date_plus=None): | |
if not current_date: | |
current_date = zim_datetime.date.today() | |
# get the current date + x days according to the days given in prefs or from entry due_date_plus | |
if not due_date_plus: | |
try: | |
due_date = current_date + zim_datetime.timedelta(days=self.due_date_plus) | |
if due_date.year < 1900: | |
raise ValueError | |
except ValueError: | |
due_date = self.get_specific_date(1900, 1, 1) | |
date_today = zim_datetime.date.today() | |
today = self.get_specific_date(date_today.year, date_today.month, date_today.day) | |
date_diff = due_date - today | |
self.due_date_plus = date_diff.days | |
else: | |
try: | |
due_date = current_date + zim_datetime.timedelta(days=due_date_plus) | |
if due_date.year < 1900: | |
raise ValueError | |
except ValueError: | |
due_date = self.get_specific_date(1900, 1, 1) | |
date_today = zim_datetime.date.today() | |
today = self.get_specific_date(date_today.year, date_today.month, date_today.day) | |
date_diff = due_date - today | |
self.due_date_plus = date_diff.days | |
return due_date | |
def get_last_day_of_month(self, year, month): | |
""" Work out the last day of the month """ | |
last_days = [31, 30, 29, 28, 27] | |
for i in last_days: | |
try: | |
end = dtime(year, month, i) | |
except ValueError: | |
continue | |
else: | |
return i | |
return None | |
def get_yday(self, year, month, day): | |
date = self.get_specific_date(year,month,day) | |
date_tt = date.timetuple() | |
year_of_day = date_tt.tm_yday | |
return year_of_day | |
def get_all_date_element_data(self): | |
year = self.entry_year.get_text() | |
month = self.entry_month.get_text() | |
day = self.entry_day.get_text() | |
ddplus = self.entry_due_date_plus.get_text() | |
year, month, day, ddplus = self.validate_date_element_data(year, month, day, ddplus) | |
return year, month, day, ddplus | |
def get_all_date_element_data_from_hist(self): | |
year_hist = self.entry_history[1][0] | |
month_hist = self.entry_history[2][0] | |
day_hist = self.entry_history[3][0] | |
ddplus_hist = self.entry_history[4][0] | |
return year_hist, month_hist, day_hist, ddplus_hist | |
def get_format(self): | |
#get the dates.list config file | |
file = ConfigManager().get_config_file('<profile>/dates.list') | |
format = "[d: %Y-%m-%d]" # This is the given standard format | |
# look for a due date format in dates.list file | |
for line in file.readlines(): | |
line = line.strip() | |
if not line.startswith("[d:"): | |
continue | |
if format in line: | |
return format | |
else: | |
format = line | |
return format | |
def get_iter_pos(self): | |
self.top_x, self.top_y = self.page_view.get_toplevel().get_position() | |
self.iter_location = self.page_view.get_iter_location(self.cursor) | |
self.mark_x, self.mark_y = self.iter_location.x, self.iter_location.y + self.iter_location.height | |
#calculate buffer-coordinates to coordinates within the window | |
self.win_location = self.page_view.buffer_to_window_coords(gtk.TEXT_WINDOW_WIDGET, | |
int(self.mark_x), int(self.mark_y)) | |
#now find the right window --> Editor Window and the right pos on screen | |
self.win = self.page_view.get_window(gtk.TEXT_WINDOW_WIDGET); | |
self.view_pos = self.win.get_position() | |
self.xx = self.win_location[0] + self.view_pos[0] | |
self.yy = self.win_location[1] + self.view_pos[1] + self.iter_location.height | |
self.x = self.top_x + self.xx | |
self.y = self.top_y + self.yy | |
return (self.x, self.y, self.iter_location.height) | |
def del_due_date_at_line(self, buffer): | |
try: | |
self.due_date_begin_iter, self.due_date_end_iter = self.get_due_date_at_line(buffer) | |
buffer.delete(self.due_date_begin_iter, self.due_date_end_iter) | |
buffer.place_cursor(self.due_date_begin_iter) | |
self.cursor = self.buf.get_iter_at_mark(self.buf.get_insert()) | |
except TypeError: | |
return | |
def get_due_date_at_line(self, buffer): | |
self.due_date_begin_iter = False | |
self.due_date_end_iter = False | |
# in order to get a due date entry no matter where the cursor was placed within the line, | |
# move the cursor to the end of the line and look for [d: | |
cursor = buffer.get_iter_at_mark(buffer.get_insert()) | |
# if there is nothing but a linefeed, just return, nothing to check | |
if cursor.ends_line() and cursor.starts_line(): | |
return | |
# this is a workaround to get the start of the line...first move to end of previous line() | |
cursor.backward_line() | |
# then move to next line(), which positions at the start of the line ... | |
cursor.forward_line() | |
# now place the cursor at start of line | |
buffer.place_cursor(cursor) | |
line_start = buffer.get_iter_at_mark(buffer.get_insert()) | |
# move to end of line and then start search backwards. | |
cursor.forward_to_line_end() | |
buffer.place_cursor(cursor) | |
self.due_date_begin_iter = cursor.backward_search("[d:", gtk.TEXT_SEARCH_TEXT_ONLY, limit = line_start) | |
if self.due_date_begin_iter: | |
# this might lead to a selection of more than wanted if there is a ] somewhere after the due date string. | |
# leaving it this way right now as this is not very likely to happen. | |
self.due_date_end_iter = cursor.backward_search("]", gtk.TEXT_SEARCH_TEXT_ONLY, limit = line_start) | |
try: | |
buffer.apply_tag(self.text_tag, self.due_date_begin_iter[0], self.due_date_end_iter[1]) | |
exist_due_date_txt = buffer.get_slice(self.due_date_begin_iter[0], self.due_date_end_iter[1]) | |
self.exist_due_date = self.put_txt_into_date(exist_due_date_txt) | |
except (UnboundLocalError, AttributeError, ValueError, TypeError): | |
buffer.place_cursor(self.cursor_orig) | |
return self.due_date_begin_iter, self.due_date_end_iter | |
# put the cursor to the end of the existing due date entry, so the entry or calendar can be shown there | |
self.cursor = self.due_date_end_iter[1] | |
return self.due_date_begin_iter[0], self.due_date_end_iter[1] | |
def put_txt_into_date(self, date_txt): | |
plain_format = self.format.strip("[d: ").strip("]") | |
plain_due_date = date_txt.strip("[d: ").strip("]") | |
exist_due_date = datetime.datetime.strptime(plain_due_date, plain_format).date() | |
return exist_due_date | |
class TaskComment(): | |
def __init__(self, window, plugin_preferences): | |
self.plugin_prefs = plugin_preferences | |
self.window = window | |
def add(self): | |
_style = {SUB : 'sub', SUPER : 'sup', NOF : None, BOLD : 'strong', ITALIC : 'emphasis'} | |
current_date = zim_datetime.date.today() | |
# get the user's task comment format | |
format = self.get_format() | |
self.task_comment_with_date = zim_datetime.strftime(format, current_date) | |
# get the text buffer | |
self.buffer = self.window.pageview.view.get_buffer() | |
current_style = self.buffer.get_textstyle() | |
line_number = self.buffer.get_insert_iter().get_line() | |
indent_level = self.buffer.get_indent(line_number) | |
new_line = line_number + 1 | |
# create new sub-bullet | |
iter = self.buffer.get_iter_at_line(line_number) | |
iter.forward_to_line_end() | |
self.buffer.place_cursor(iter) # works also if cursor is placed inside a tag | |
self.buffer.insert_at_cursor("\n") | |
self.buffer.set_indent(new_line, indent_level + 1) | |
# bullet * is used for now. | |
# TODO: declare > or >> as new bullet | |
self.buffer.set_bullet(new_line, '*') # set bullet type | |
# place cursor in right spot | |
iter = self.buffer.get_iter_at_line(new_line) | |
#iter.forward_to_line_end() | |
self.buffer.place_cursor(iter) | |
# get the cursor position | |
cursor = self.buffer.get_insert_iter() | |
## add text at cursor position | |
string_style = self.plugin_prefs['task_comment_string_style'] | |
comment_style = self.plugin_prefs['task_comment_style'] | |
self.buffer.set_textstyle(_style[string_style]) | |
self.buffer.insert(cursor, self.task_comment_with_date) | |
self.buffer.set_textstyle(_style[comment_style]) | |
# to make the current style work... | |
if self.plugin_prefs['task_comment_suff']: | |
comment_suffix = self.plugin_prefs['task_comment_suff'] + " " | |
else: | |
comment_suffix = " " | |
self.buffer.insert(cursor, comment_suffix) | |
page = self.window.pageview.get_page() | |
def get_format(self): | |
format = self.set_format(date_string = self.plugin_prefs['task_comment_date_format']) # this is the format set in preferences | |
if self.plugin_prefs['task_comment_date']: | |
if self.plugin_prefs['task_comment_dateslist']: | |
# get the dates.list config file | |
file = ConfigManager().get_config_file('<profile>/dates.list') | |
for line in file.readlines(): | |
line = line.strip() | |
if not line.startswith("[d:"): | |
continue | |
# lazy approach to remove ] from the end of the date string | |
line = re.sub(']', '',line) | |
# and then strip [d: from format in dates.list and build format string set in preferences | |
format = self.set_format(date_string = line.lstrip("[d: ").lstrip("]")) | |
return format | |
def set_format(self, date_string): | |
if self.plugin_prefs['task_comment_date']: | |
date_string = "[" + date_string + "]" | |
if self.plugin_prefs['task_comment_time']: | |
if self.plugin_prefs['task_comment_time_format']: | |
time_format = self.plugin_prefs['task_comment_time_format'] | |
else: | |
time_format = '%H:%M' | |
current_time = zim_datetime.datetime.now().strftime(time_format) | |
date_string = date_string + "[" + current_time + "]" | |
format = self.plugin_prefs['task_comment_string'] + " " + date_string | |
else: | |
format = self.plugin_prefs['task_comment_string'] | |
return format | |
class TasksParser(Visitor): | |
'''Parse tasks from a parsetree''' | |
def __init__(self, task_label_re, next_label_re, nonactionable_tags, all_checkboxes, defaultdate, preferences): | |
self.task_label_re = task_label_re | |
self.next_label_re = next_label_re | |
self.nonactionable_tags = nonactionable_tags | |
self.all_checkboxes = all_checkboxes | |
defaults = (True, True, 0, defaultdate or _NO_DATE, set(), None) | |
# (open, actionable, prio, due, tags, description) | |
self._tasks = [] | |
self._tasks_comments = [] | |
self.preferences = preferences | |
self._stack = [(-1, defaults, self._tasks)] | |
# Stack for parsed tasks with tuples like (level, task, children) | |
# We need to include the list level in the stack because we can | |
# have mixed bullet lists with checkboxes, so task nesting is | |
# not the same as list nesting | |
# Parsing state | |
self._text = [] # buffer with pieces of text | |
self._depth = 0 # nesting depth for list items | |
self._last_node = (None, None) # (tag, attrib) of last item seen by start() | |
self._intasklist = False # True if we are in a tasklist with a header | |
self._tasklist_tags = None # global tags from the tasklist header | |
def parse(self, parsetree): | |
#~ filter = TreeFilter( | |
#~ TextCollectorFilter(self), | |
#~ tags=['p', 'ul', 'ol', 'li'], | |
#~ exclude=['strike'] | |
#~ ) | |
parsetree.visit(self) | |
def get_tasks(self): | |
'''Get the tasks that were collected by visiting the tree | |
@returns: nested list of tasks, each task is given as a 2-tuple, | |
1st item is a tuple with following properties: | |
C{(open, actionable, prio, due, description)}, | |
2nd item is a list of child tasks (if any). | |
''' | |
return self._tasks | |
def start(self, tag, attrib): | |
if tag == STRIKE: | |
raise VisitorSkip # skip this node | |
elif tag in (PARAGRAPH, NUMBEREDLIST, BULLETLIST, LISTITEM): | |
if tag == PARAGRAPH: | |
self._intasklist = False | |
# Parse previous chuck of text (para level text) | |
if self._text: | |
if tag in (NUMBEREDLIST, BULLETLIST) \ | |
and self._last_node[0] == PARAGRAPH \ | |
and self._check_para_start(self._text): | |
pass | |
else: | |
self._parse_para_text(self._text) | |
self._text = [] # flush | |
# Update parser state | |
if tag in (NUMBEREDLIST, BULLETLIST): | |
self._depth += 1 | |
elif tag == LISTITEM: | |
self._pop_stack() # see comment in end() | |
self._last_node = (tag, attrib) | |
else: | |
pass # do nothing for other tags (we still get the text) | |
def text(self, text): | |
self._text.append(text) | |
def end(self, tag): | |
if tag == PARAGRAPH: | |
if self._text: | |
self._parse_para_text(self._text) | |
self._text = [] # flush | |
self._depth = 0 | |
self._pop_stack() | |
elif tag in (NUMBEREDLIST, BULLETLIST): | |
self._depth -= 1 | |
self._pop_stack() | |
elif tag == LISTITEM: | |
if self._text: | |
attrib = self._last_node[1] | |
self._parse_list_item(attrib, self._text) | |
self._text = [] # flush | |
# Don't pop here, next item may be child | |
# Instead pop when next item opens | |
# want to parse comments without a bullet, but seems to be the wrong way | |
# to look for BLOCK text. Rather declare comment char >> or > as a new bullet | |
#elif tag == BLOCK: | |
# if self._text: | |
# self._parse_para_text(self._text) | |
# self._parse_block_text(self._text) | |
# self._text = [] # flush | |
else: | |
pass # do nothing for other tags | |
def _pop_stack(self): | |
# Drop stack to current level | |
assert self._depth >= 0, 'BUG: stack count corrupted' | |
level = self._depth | |
if level > 0: | |
level -= 1 # first list level should be same as level of line items in para | |
while self._stack[-1][0] >= level: | |
self._stack.pop() | |
def _check_para_start(self, strings): | |
# Check first line for task list header | |
# SHould look like "TODO @foo @bar:" | |
# FIXME shouldn't we depend on tag elements in the tree ?? | |
line = u''.join(strings).strip() | |
if not '\n' in line \ | |
and self._matches_label(line): | |
words = line.strip(':').split() | |
words.pop(0) # label | |
if all(w.startswith('@') for w in words): | |
self._intasklist = True | |
self._tasklist_tags = set(w.strip('@') for w in words) | |
else: | |
self._intasklist = False | |
else: | |
self._intasklist = False | |
return self._intasklist | |
def _parse_para_text(self, strings): | |
# Paragraph text to be parsed - just look for lines with label | |
for line in u''.join(strings).splitlines(): | |
if self._matches_label(line): | |
self._parse_task(line) | |
def _parse_list_item(self, attrib, text): | |
# List item to parse - check bullet, then match label | |
bullet = attrib.get('bullet') | |
line = u''.join(text) | |
# get comment | |
self._parse_comment(line) | |
if (bullet in (UNCHECKED_BOX, CHECKED_BOX, XCHECKED_BOX) | |
and (self._intasklist or self.all_checkboxes)): | |
open = (bullet == UNCHECKED_BOX) | |
# added bullet to params to identify if a task is ticked for the tick box | |
self._parse_task(line, bullet, open=open) | |
elif self._matches_label(line): | |
self._parse_task(line) | |
#def _parse_block_text(self, text): | |
# # Block text to parse | |
# line = u''.join(text).strip() | |
# if '\n' in line and self._matches_label(line): | |
# line_end = line.find('\n') | |
# print "lind_end = ", line_end | |
# else: | |
# line_end = 0 | |
# print ">>>> parsing block text ", line | |
# # get comment | |
# self._parse_comment(line[line_end:]) | |
def _matches_label(self, line): | |
return self.task_label_re and self.task_label_re.match(line) | |
def _matches_next_label(self, line): | |
return self.next_label_re and self.next_label_re.match(line) | |
def _parse_t_date(self, text): | |
return text.partition("[x:")[2].partition("]")[0] | |
pass | |
def _parse_comment(self, line): | |
comment_string = self.preferences['task_comment_string'] | |
if comment_string in line: | |
return_value = line.find(comment_string) | |
parent_level, parent, parent_children = self._stack[-1] | |
c_string_len_formatting = 1 | |
c_string_len = len(self.preferences['task_comment_string']) + c_string_len_formatting | |
comment_strip = line[c_string_len:] + "\n" | |
try: | |
parent[6].append(comment_strip) | |
except IndexError: | |
try: | |
# Can't really understand why parent does not show the task if it's a label. | |
# The following seem to work. | |
parent = parent_children[-1][0] | |
parent[6].append(comment_strip) | |
pass | |
except IndexError: | |
print "index error" | |
pass | |
def _parse_task(self, text, bullet=None, open=True): | |
level = self._depth | |
if level > 0: | |
level -= 1 # first list level should be same as level of line items in para | |
parent_level, parent, parent_children = self._stack[-1] | |
# Get prio | |
prio = text.count('!') | |
if prio == 0: | |
prio = parent[2] # default to parent prio | |
# Get due date | |
due = _NO_DATE | |
datematch = _date_re.search(text) # first match | |
if datematch: | |
date = parse_date(datematch.group(0)) | |
if date: | |
due = '%04i-%02i-%02i' % date # (y, m, d) | |
if due == _NO_DATE: | |
due = parent[3] # default to parent date (or default for root) | |
# Find tags | |
tags = set(_tag_re.findall(text)) | |
if self._intasklist and self._tasklist_tags: | |
tags |= self._tasklist_tags | |
tags |= parent[4] # add parent tags | |
# Check actionable | |
if not parent[1]: # default parent not actionable | |
actionable = False | |
elif any(t.lower() in self.nonactionable_tags for t in tags): | |
actionable = False | |
elif self._matches_next_label(text) and parent_children: | |
previous = parent_children[-1][0] | |
actionable = not previous[0] # previous task not open | |
else: | |
actionable = True | |
# Parents are not closed if it has open child items | |
if self._depth > 0 and open: | |
for l, t, c in self._stack[1:]: | |
t[0] = True | |
# quick way to find out if task is already ticked (is needed to display already ticked parent tasks, while child is unticked) | |
tickdate = "" | |
if bullet in (CHECKED_BOX, XCHECKED_BOX): | |
tickmark = True | |
# Get ticked date (if pageview adds tickdate to task within text) | |
t_datematch = _tdate_re.search(text) # first match | |
if t_datematch: | |
tickdate = self._parse_t_date(t_datematch.group(0)) | |
else: | |
tickmark = False | |
# And finally add to stack | |
comment=[] | |
# adding text as this contains the whole task string | |
task = [open, actionable, prio, due, tags, text, comment, tickmark, tickdate, text] | |
#task = [open, actionable, prio, due, tags, text, comment, tickmark,] | |
children = [] | |
task[6] = [] # flush old comments | |
parent_children.append((task, children)) | |
if self._depth > 0: # (don't add paragraph level items to the stack) | |
self._stack.append((level, task, children)) | |
class TaskListDialog(Dialog): | |
def __init__(self, window, index_ext, preferences): | |
screen_width = gtk.gdk.screen_width() | |
screen_height = gtk.gdk.screen_height() | |
TASKLISTDIALOG_WIDTH = screen_width / 4 | |
TASKLISTDIALOG_HEIGHT = screen_height / 4 | |
Dialog.__init__(self, window, _('Task List'), # T: dialog title | |
buttons=gtk.BUTTONS_CLOSE, help=':Plugins:Task List', | |
defaultwindowsize=(TASKLISTDIALOG_WIDTH, TASKLISTDIALOG_HEIGHT) ) | |
self.preferences = preferences | |
self.index_ext = index_ext | |
# Divider between Tasks and Tasks History | |
# BUG? The position is not preserved | |
self.vpane = VPaned() | |
self.uistate.setdefault('vpane_pos', TASKLISTDIALOG_HEIGHT/2) | |
self.vpane.set_position(self.uistate['vpane_pos']) | |
# Divider between Tags and Tasks | |
self.hpane = HPaned() | |
self.uistate.setdefault('hpane_pos', TASKLISTDIALOG_WIDTH/2) | |
self.hpane.set_position(self.uistate['hpane_pos']) | |
self.hpane.add2(self.vpane) | |
vbox_task_top = gtk.VBox(spacing = 5) | |
hbox_task_top = gtk.HBox(spacing = 5) | |
vbox_task_bottom = gtk.VBox(spacing = 5) | |
self.vpane.add1(vbox_task_top) | |
self.vpane.add2(vbox_task_bottom) | |
vbox_top = gtk.VBox(spacing=5) | |
# Entries hbox | |
hbox_entries = gtk.HBox(spacing=5) | |
vbox_top.pack_start(hbox_entries, False) | |
self.vbox.pack_start(vbox_top, False) | |
# now pack the hpane to vbox to have it below the entries | |
self.vbox.pack_end(self.hpane, True) | |
# Task list | |
self.uistate.setdefault('only_show_act', False) | |
self.uistate.setdefault('tick_all', False) | |
opener = window.get_resource_opener() | |
self.task_list = TaskListTreeView( | |
window, self.index_ext, opener, | |
#tick_all=self.uistate['tick_all'], | |
filter_actionable=self.uistate['only_show_act'], | |
tag_by_page=preferences['tag_by_page'], | |
use_workweek=preferences['use_workweek'], | |
) | |
self.task_list.set_headers_visible(True) # Fix for maemo | |
self.task_list.set_grid_lines(gtk.TREE_VIEW_GRID_LINES_HORIZONTAL) | |
# Task list history | |
if self.preferences['show_history']: | |
hbox_hist = gtk.HBox(spacing=10) | |
vbox_task_bottom.pack_start(gtk.Label(_('History of ticked tasks')), False) # T: Input label | |
self.hpane_hist = HPaned() | |
self.uistate.setdefault('hpane_hist_pos', 75) | |
self.hpane_hist.set_position(self.uistate['hpane_hist_pos']) | |
# Ticked Task history list | |
if self.preferences['show_history']: | |
self.task_list_hist = TaskListHistoryTreeView( | |
window, self.index_ext, opener, | |
filter_actionable=self.uistate['only_show_act'], | |
tag_by_page=preferences['tag_by_page'], | |
use_workweek=preferences['use_workweek'], | |
) | |
self.task_list_hist.set_headers_visible(True) # Fix for maemo | |
vbox_task_bottom.pack_end(ScrolledWindow(self.task_list_hist), True) | |
self.task_list_hist.set_grid_lines(gtk.TREE_VIEW_GRID_LINES_HORIZONTAL) | |
# Tag auto completion | |
hbox_entries.pack_start(gtk.Label(_('Tags')+': '), False) # T: Input label | |
tag_entry = InputEntry() | |
hbox_entries.pack_start(tag_entry, False) | |
tag_entry.set_icon_to_clear() | |
if self.preferences['show_history']: | |
self.tag_list = TagListTreeView(self.index_ext, self.task_list, tag_entry, self.preferences, self.task_list_hist) | |
else: | |
self.tag_list = TagListTreeView(self.index_ext, self.task_list, tag_entry, self.preferences) | |
self.hpane.add1(ScrolledWindow(self.tag_list)) | |
# clear filter when icon is pressed | |
def clear_entry(entry, iconpos, event): | |
entry.set_text('') | |
self.task_list.set_tag_filter(None, None) | |
self.task_list_hist.set_tag_filter(None, None) | |
tag_entry.connect("icon-press", clear_entry) | |
# Filter input | |
hbox_entries.pack_start(gtk.Label(_('Filter')+': '), False) # T: Input label | |
filter_entry = InputEntry() | |
filter_entry.set_icon_to_clear() | |
hbox_entries.pack_start(filter_entry, False) | |
filter_cb = DelayedCallback(500, | |
lambda o: self.task_list.set_filter(filter_entry.get_text())) | |
filter_entry.connect('changed', filter_cb) | |
if self.preferences['show_history']: | |
filter_cb_hist = DelayedCallback(500, | |
lambda o: self.task_list_hist.set_filter(filter_entry.get_text())) | |
filter_entry.connect('changed', filter_cb_hist) | |
# Dropdown with options - TODO | |
#~ menu = gtk.Menu() | |
#~ showtree = gtk.CheckMenuItem(_('Show _Tree')) # T: menu item in options menu | |
#~ menu.append(showtree) | |
#~ menu.append(gtk.SeparatorMenuItem()) | |
#~ showall = gtk.RadioMenuItem(None, _('Show _All Items')) # T: menu item in options menu | |
#~ showopen = gtk.RadioMenuItem(showall, _('Show _Open Items')) # T: menu item in options menu | |
#~ menu.append(showall) | |
#~ menu.append(showopen) | |
#~ menubutton = MenuButton(_('_Options'), menu) # T: Button label | |
#~ hbox.pack_start(menubutton, False) | |
# Tick all tasks | |
self.tick_all_toggle = gtk.CheckButton(_('Tick all tasks')) | |
# Don't want to mess around to get a consistent tick state depending on if list is empty or not | |
self.tick_all_toggle.set_inconsistent(True) | |
self.tick_all_toggle.connect('toggled', lambda o: self.task_list._toggle_all_tasks(o.get_active(),self)) | |
hbox_task_top.pack_start(self.tick_all_toggle, False) | |
# Untick all tasks | |
self.untick_all_toggle = gtk.CheckButton(_('Untick all tasks')) | |
self.untick_all_toggle.set_inconsistent(True) | |
untick_toggle_handler_id = self.untick_all_toggle.connect('toggled', lambda o: self.task_list_hist._toggle_all_tasks(o.get_active(), self)) | |
vbox_task_bottom.pack_start(self.untick_all_toggle, False) | |
self.act_toggle = gtk.CheckButton(_('Only Show Actionable Tasks')) | |
# T: Checkbox in task list | |
self.act_toggle.set_active(self.uistate['only_show_act']) | |
self.act_toggle.connect('toggled', lambda o: self.task_list.set_filter_actionable(o.get_active())) | |
#vbox_top.pack_end(self.act_toggle, False) | |
vbox_task_top.pack_start(self.act_toggle, False) | |
vbox_task_top.pack_start(hbox_task_top, False) | |
vbox_task_top.pack_end(ScrolledWindow(self.task_list), True) | |
# Statistics label | |
self.statistics_label = gtk.Label() | |
#vbox_entries_labels.pack_end(self.statistics_label, False) | |
hbox_task_top.pack_end(self.statistics_label, False) | |
def set_statistics(): | |
total, stats = self.task_list.get_statistics() | |
text = ngettext('%i open item', '%i open items', total) % total | |
# T: Label for statistics in Task List, %i is the number of tasks | |
text += ' (' + '/'.join(map(str, stats)) + ')' | |
self.statistics_label.set_text(text) | |
set_statistics() | |
def on_tasklist_changed(o): | |
self.task_list.refresh() | |
self.tag_list.refresh(self.task_list) | |
self.task_list_hist.refresh() | |
set_statistics() | |
callback = DelayedCallback(10, on_tasklist_changed) | |
# Don't really care about the delay, but want to | |
# make it less blocking - should be async preferably | |
# now it is at least on idle | |
self.connectto(index_ext, 'tasklist-changed', callback) | |
def do_response(self, response): | |
self.uistate['hpane_pos'] = self.hpane.get_position() | |
self.uistate['hpane_hist_pos'] = self.hpane_hist.get_position() | |
self.uistate['vpane_pos'] = self.vpane.get_position() | |
self.uistate['only_show_act'] = self.act_toggle.get_active() | |
#self.uistate['tick_all'] = self.tick_all_toggle.get_active() | |
Dialog.do_response(self, response) | |
class TagListTreeView(SingleClickTreeView): | |
'''TreeView with a single column 'Tags' which shows all tags available | |
in a TaskListTreeView. Selecting a tag will filter the task list to | |
only show tasks with that tag. | |
''' | |
_type_separator = 0 | |
_type_label = 1 | |
_type_tag = 2 | |
_type_untagged = 3 | |
def __init__(self, index_ext, task_list, tag_entry, preferences, task_list_hist=None): | |
model = gtk.ListStore(str, int, int, int) # tag name, number of tasks, type, weight | |
SingleClickTreeView.__init__(self, model) | |
self.get_selection().set_mode(gtk.SELECTION_MULTIPLE) | |
self.index_ext = index_ext | |
self.task_list = task_list | |
# depending of preferences | |
if not task_list_hist: | |
self.task_list_hist = self.task_list | |
else: | |
self.task_list_hist = task_list_hist | |
self.preferences = preferences | |
self.tag_entry = tag_entry | |
# add additional entry with auto completion for tags within tasks | |
self.completion = gtk.EntryCompletion() | |
# it's better to not have a popup for a single match here | |
self.completion.set_inline_completion(True) | |
self.completion.set_model(model) | |
self.completion.set_popup_single_match(False) | |
self.tag_entry.set_completion(self.completion) | |
self.completion.set_text_column(0) | |
# if user has selected a tag from the popup list | |
self.completion.connect('match-selected', self.match_selected) | |
#if only remaining match is shown and RETURN is hit | |
self.tag_entry.connect('activate', self.activate_return) | |
column = gtk.TreeViewColumn(_('Tags')) | |
# T: Column header for tag list in Task List dialog | |
self.append_column(column) | |
cr1 = gtk.CellRendererText() | |
cr1.set_property('ellipsize', pango.ELLIPSIZE_END) | |
column.pack_start(cr1, True) | |
column.set_attributes(cr1, text=0, weight=3) # tag name, weight | |
cr2 = self.get_cell_renderer_number_of_items() | |
column.pack_start(cr2, False) | |
column.set_attributes(cr2, text=1) # number of tasks | |
self.set_row_separator_func(lambda m, i: m[i][2] == self._type_separator) | |
self._block_selection_change = False | |
self.get_selection().connect('changed', self.on_selection_changed) | |
# get the standard_tag from preferences and set entry | |
standard_tag = self.preferences['standard_tag'] | |
if standard_tag: | |
self.tag_entry.set_text(standard_tag) | |
self.activate_return(self.tag_entry) | |
self.refresh(task_list) | |
def match_selected(self, completion, model, iter): | |
tags = [] | |
tags.append(model[iter][0]) | |
# there can be still a additional filter set | |
labels = self.get_labels() | |
self.task_list.set_tag_filter(tags, labels) | |
self.task_list_hist.set_tag_filter(tags, labels) | |
def activate_return(self, entry): | |
# if return is hit without anything, don't show untagged tasks accidentally | |
# TODO: This is another 'hidden' functionality for an easy way to show them | |
# but it needs to be implemented in a proper way | |
if entry.get_text() == "": | |
return | |
tags = [] | |
tag_entered = entry.get_text() | |
tags.append(tag_entered) | |
# there can be still a additional filter set | |
labels = self.get_labels() | |
self.task_list.set_tag_filter(tags, labels) | |
self.task_list_hist.set_tag_filter(tags, labels) | |
tree_selection = self.get_selection() | |
# get the path for the tag from the tag list to highlight in list | |
model = self.get_model() | |
path = 0 | |
for element in model: | |
if tag_entered == element[0]: | |
break | |
path += 1 | |
tree_selection.select_path(path) | |
def get_tags(self): | |
'''Returns current selected tags, or None for all tags''' | |
tags = [] | |
for row in self._get_selected(): | |
if row[2] == self._type_tag: | |
tags.append(row[0].decode('utf-8')) | |
elif row[2] == self._type_untagged: | |
tags.append(_NO_TAGS) | |
return tags or None | |
def get_labels(self): | |
'''Returns current selected labels''' | |
labels = [] | |
for row in self._get_selected(): | |
if row[2] == self._type_label: | |
labels.append(row[0].decode('utf-8')) | |
return labels or None | |
def _get_selected(self): | |
selection = self.get_selection() | |
if selection: | |
model, paths = selection.get_selected_rows() | |
if not paths or (0,) in paths: | |
return [] | |
else: | |
self.tag_entry.clear() | |
return [model[path] for path in paths] | |
else: | |
return [] | |
def refresh(self, task_list): | |
self._block_selection_change = True | |
selected = [(row[0], row[2]) for row in self._get_selected()] # remember name and type | |
# Rebuild model | |
model = self.get_model() | |
if model is None: return | |
model.clear() | |
n_all = self.task_list.get_n_tasks() | |
#n_all_hist = self.task_list_hist.get_n_tasks() | |
model.append((_('All Tasks'), n_all, self._type_label, pango.WEIGHT_BOLD)) # T: "tag" for showing all tasks | |
used_labels = self.task_list.get_labels() | |
for label in self.index_ext.task_labels: # explicitly keep sorting from preferences | |
if label in used_labels \ | |
and label != self.index_ext.next_label: | |
model.append((label, used_labels[label], self._type_label, pango.WEIGHT_BOLD)) | |
tags = self.task_list.get_tags() | |
if _NO_TAGS in tags: | |
n_untagged = tags.pop(_NO_TAGS) | |
model.append((_('Untagged'), n_untagged, self._type_untagged, pango.WEIGHT_NORMAL)) | |
# T: label in tasklist plugins for tasks without a tag | |
model.append(('', 0, self._type_separator, 0)) # separator | |
model.append(('', 0, self._type_separator, 0)) # separator | |
for tag in natural_sorted(tags): | |
model.append((tag, tags[tag], self._type_tag, pango.WEIGHT_NORMAL)) | |
model.append(('', 0, self._type_separator, 0)) # separator for tags of closed tags | |
used_labels = self.task_list_hist.get_labels() | |
for label in self.index_ext.task_labels: # explicitly keep sorting from preferences | |
if label in used_labels \ | |
and label != self.index_ext.next_label: | |
model.append((label, used_labels[label], self._type_label, pango.WEIGHT_BOLD)) | |
tags_hist = self.task_list_hist.get_tags() | |
if _NO_TAGS in tags_hist: | |
n_untagged = tags_hist.pop(_NO_TAGS) | |
model.append((_('Untagged'), n_untagged, self._type_untagged, pango.WEIGHT_NORMAL)) | |
# T: label in tasklist plugins for tasks without a tag | |
# append tags from task list history, but only if it's not already in the list of tags from task list | |
for tag in natural_sorted(tags_hist): | |
if tag not in tags: | |
model.append((tag, tags_hist[tag], self._type_tag, pango.WEIGHT_NORMAL)) | |
# Restore selection | |
def reselect(model, path, iter): | |
row = model[path] | |
name_type = (row[0], row[2]) | |
if name_type in selected: | |
self.get_selection().select_iter(iter) | |
if selected: | |
model.foreach(reselect) | |
self._block_selection_change = False | |
def on_selection_changed(self, selection): | |
if not self._block_selection_change: | |
tags = self.get_tags() | |
labels = self.get_labels() | |
self.task_list.set_tag_filter(tags, labels) | |
self.task_list_hist.set_tag_filter(tags, labels) | |
HIGH_COLOR = '#EF5151' # red (derived from Tango style guide - #EF2929) | |
MEDIUM_COLOR = '#FCB956' # orange ("idem" - #FCAF3E) | |
ALERT_COLOR = '#FCEB65' # yellow ("idem" - #FCE94F) | |
# FIXME: should these be configurable ? | |
class TaskListTreeView(BrowserTreeView): | |
VIS_COL = 0 # visible | |
TICKED_COL = 1 # additions | |
PRIO_COL = 2 | |
TASK_COL = 3 | |
DATE_COL = 4 | |
PAGE_COL = 5 | |
TAGS0_COL = 6 # additions | |
TASK_COMMENT_COL = 7 # additions | |
ACT_COL = 8 # actionable | |
OPEN_COL = 9 # item not closed | |
TASKID_COL = 10 | |
TAGS_COL = 11 | |
TASK0_COL = 12 | |
def __init__(self, window, index_ext, opener, filter_actionable=False, tag_by_page=False, use_workweek=False): | |
self.real_model = gtk.TreeStore(bool, bool, int, str, str, str, str, str, bool, bool, int, object, str) | |
# VIS_COL, TICKED_COL, PRIO_COL, TASK_COL, DATE_COL, TAGS0_COL, TASK_COMMENT_COL, PAGE_COL, ACT_COL, OPEN_COL, TASKID_COL, TAGS_COL, TASK0_COL | |
model = self.real_model.filter_new() | |
model.set_visible_column(self.VIS_COL) | |
model = gtk.TreeModelSort(model) | |
model.set_sort_column_id(self.PRIO_COL, gtk.SORT_DESCENDING) | |
BrowserTreeView.__init__(self, model) | |
screen_width = gtk.gdk.screen_width() | |
screen_height = gtk.gdk.screen_height() | |
self.index_ext = index_ext | |
self.opener = opener | |
self.filter = None | |
self.tag_filter = None | |
self.label_filter = None | |
self.filter_actionable = filter_actionable | |
self.tag_by_page = tag_by_page | |
self._tags = {} | |
self._labels = {} | |
self.win = window | |
column_width = 150 | |
self.tick_column = False | |
# Wrap text in column on resize | |
def set_column_width(column, width, renderer, pan = True): | |
column_width = column.get_width() | |
renderer.props.wrap_width = column_width | |
if pan: | |
renderer.props.wrap_mode = pango.WRAP_WORD | |
else: | |
renderer.props.wrap_mode = gtk.WRAP_WORD | |
# Add some rendering for the task tick box | |
cell_renderer = gtk.CellRendererToggle() | |
cell_renderer.set_property('activatable', True) | |
column = gtk.TreeViewColumn('Tick', cell_renderer) | |
column.set_sort_column_id(self.TICKED_COL) | |
column.add_attribute(cell_renderer, "active", self.TICKED_COL) | |
self.append_column(column) | |
cell_renderer.connect("toggled", self.tick_column_clicked) | |
# Add some rendering for the Prio column | |
def render_prio(col, cell, model, i): | |
prio = model.get_value(i, self.PRIO_COL) | |
cell.set_property('text', str(prio)) | |
if prio >= 3: color = HIGH_COLOR | |
elif prio == 2: color = MEDIUM_COLOR | |
elif prio == 1: color = ALERT_COLOR | |
else: color = None | |
cell.set_property('cell-background', color) | |
cell_renderer = gtk.CellRendererText() | |
column = gtk.TreeViewColumn(' ! ', cell_renderer) | |
column.set_cell_data_func(cell_renderer, render_prio) | |
column.set_sort_column_id(self.PRIO_COL) | |
self.append_column(column) | |
# Rendering for task description column | |
cell_renderer = gtk.CellRendererText() | |
cell_renderer.set_property('ellipsize', pango.ELLIPSIZE_END) | |
column = gtk.TreeViewColumn(_('Task'), cell_renderer, markup=self.TASK_COL) | |
# T: Column header Task List dialog | |
column.set_resizable(True) | |
column.set_sort_column_id(self.TASK_COL) | |
column.set_expand(True) | |
#if ui_environment['platform'] == 'maemo': | |
# column.set_min_width(100) # don't let this column get too small | |
#else: | |
# column.set_min_width(150) # don't let this column get too small | |
self.append_column(column) | |
self.set_expander_column(column) | |
if gtk.gtk_version >= (2, 12) \ | |
and gtk.pygtk_version >= (2, 12): | |
self.set_tooltip_column(self.TASK_COL) | |
# Rendering of the Date column | |
day_of_week = datetime.date.today().isoweekday() | |
if use_workweek and day_of_week == 4: | |
# Today is Thursday - 2nd day ahead is after the weekend | |
delta1, delta2 = 1, 3 | |
elif use_workweek and day_of_week == 5: | |
# Today is Friday - next day ahead is after the weekend | |
delta1, delta2 = 3, 4 | |
else: | |
delta1, delta2 = 1, 2 | |
today = str( datetime.date.today() ) | |
tomorrow = str( datetime.date.today() + datetime.timedelta(days=delta1)) | |
dayafter = str( datetime.date.today() + datetime.timedelta(days=delta2)) | |
def render_date(col, cell, model, i): | |
date = model.get_value(i, self.DATE_COL) | |
if date == _NO_DATE: | |
cell.set_property('text', '') | |
else: | |
cell.set_property('text', date) | |
# TODO allow strftime here | |
if date <= today: color = HIGH_COLOR | |
elif date <= tomorrow: color = MEDIUM_COLOR | |
elif date <= dayafter: color = ALERT_COLOR | |
# "<=" because tomorrow and/or dayafter can be after the weekend | |
else: color = None | |
cell.set_property('cell-background', color) | |
cell_renderer = gtk.CellRendererText() | |
column = gtk.TreeViewColumn(_('Date'), cell_renderer) | |
# T: Column header Task List dialog | |
column.set_cell_data_func(cell_renderer, render_date) | |
column.set_sort_column_id(self.DATE_COL) | |
self.append_column(column) | |
# Rendering for tag column | |
cell_renderer = gtk.CellRendererText() | |
cell_renderer.set_property('ellipsize', pango.ELLIPSIZE_END) | |
column = gtk.TreeViewColumn(_('Tags'), cell_renderer, markup=self.TAGS0_COL) | |
column.set_resizable(True) | |
column.set_sort_column_id(self.TAGS0_COL) | |
column.set_min_width(50) | |
column.connect_after("notify::width", set_column_width, cell_renderer) | |
self.append_column(column) | |
# Rendering for task comment column | |
cell_renderer = gtk.CellRendererText() | |
column = gtk.TreeViewColumn(_('Comment'), cell_renderer, text=self.TASK_COMMENT_COL) | |
column.set_resizable(True) | |
column.set_sort_column_id(self.TASK_COMMENT_COL) | |
#column.set_min_width(column_width) | |
cell_renderer.props.wrap_width = int(screen_width*0.05) | |
cell_renderer.props.wrap_mode = pango.WRAP_WORD | |
column.connect_after("notify::width", set_column_width, cell_renderer) | |
self.append_column(column) | |
# Rendering for page name column | |
cell_renderer = gtk.CellRendererText() | |
column = gtk.TreeViewColumn(_('Page'), cell_renderer, text=self.PAGE_COL) | |
# T: Column header Task List dialog | |
column.set_sort_column_id(self.PAGE_COL) | |
self.append_column(column) | |
# Finalize | |
self.refresh() | |
# HACK because we can not register ourselves :S | |
self.connect_after('row_activated', self.__class__.do_row_activated) | |
def refresh(self): | |
'''Refresh the model based on index data''' | |
# Update data | |
self._clear() | |
self._append_tasks(None, None, {}) | |
# Make tags case insensitive | |
tags = sorted((t.lower(), t) for t in self._tags) | |
# tuple sorting will sort ("foo", "Foo") before ("foo", "foo"), | |
# but ("bar", ..) before ("foo", ..) | |
prev = ('', '') | |
for tag in tags: | |
if tag[0] == prev[0]: | |
self._tags[prev[1]] += self._tags[tag[1]] | |
self._tags.pop(tag[1]) | |
prev = tag | |
# Set view | |
self._eval_filter() # keep current selection | |
self.expand_all() | |
def _clear(self): | |
self.real_model.clear() # flush | |
self._tags = {} | |
self._labels = {} | |
def _append_tasks(self, task, iter, path_cache): | |
for row in self.index_ext.list_tasks(task): | |
if not row['open']: | |
continue # Only include open items for now | |
if row['source'] not in path_cache: | |
path = self.index_ext.get_path(row) | |
if path is None: | |
# Be robust for glitches - filter these out | |
continue | |
else: | |
path_cache[row['source']] = path | |
path = path_cache[row['source']] | |
# Update labels | |
for label in self.index_ext.task_label_re.findall(row['description']): | |
self._labels[label] = self._labels.get(label, 0) + 1 | |
# Update tag count | |
tags = row['tags'].split(',') | |
# Want to show tags one below the other instead of separated by comma, so creating tags0 instead | |
tags0 = "" | |
for tag in tags: | |
tags0 += "<span color=\"#ce5c00\">" + tag + "</span>" + "\n" | |
if self.tag_by_page: | |
tags = tags + path.parts | |
if tags: | |
for tag in tags: | |
self._tags[tag] = self._tags.get(tag, 0) + 1 | |
else: | |
self._tags[_NO_TAGS] = self._tags.get(_NO_TAGS, 0) + 1 | |
# Format description | |
task = _date_re.sub('', row['description'], count=1) | |
task = _tdate_re.sub('', task, count=1) | |
task = re.sub('\s*!+\s*', ' ', task) # get rid of exclamation marks | |
task = self.index_ext.next_label_re.sub('', task) # get rid of "next" label in description | |
task = encode_markup_text(task) | |
if row['actionable']: | |
#task = _tag_re.sub(r'<span color="#ce5c00">@\1</span>', task) # highlight tags - same color as used in pageview | |
task = _tag_re.sub(r'', task) # get rid of tags in task description --> most probably not the best place to do that... | |
task = self.index_ext.task_label_re.sub(r'<b>\1</b>', task) # highlight labels | |
else: | |
task = r'<span color="darkgrey">%s</span>' % task | |
# Insert all columns | |
modelrow = [False, row['tickmark'], row['prio'], task, row['due'], path.name, tags0, row['comment'], row['actionable'], row['open'], row['id'], tags, row['task']] | |
# VIS_COL, , TICKMARK, PRIO_COL, TASK_COL, DATE_COL, PAGE_COL, ACT_COL, OPEN_COL, TASKID_COL, TAGS_COL | |
modelrow[0] = self._filter_item(modelrow) | |
myiter = self.real_model.append(iter, modelrow) | |
if row['haschildren']: | |
self._append_tasks(row, myiter, path_cache) # recurs | |
def _toggle_all_tasks(self, tick_status, dialog): | |
model = self.get_model() | |
toggle_all_ok = self._toggle_all_tasks_dialog(dialog) | |
if toggle_all_ok: | |
model.foreach(self._do_toggle_all_tasks) | |
def _toggle_all_tasks_dialog(self, main_dialog): | |
task_count = self.get_n_tasks() | |
response = QuestionDialog(main_dialog, (_('Tick ' + str(task_count) + ' tasks?'), | |
# T: Short message text on first time use of task list plugin | |
_('You are about to tick ' + str(task_count) + ' tasks.\n\n' | |
'Are you sure?\n') | |
# T: Long message text on first time use of task list plugin | |
) ).run() | |
return response | |
def _do_toggle_all_tasks(self, model, path, iter): | |
# skip tasks which are ticked but are show due to unticked children | |
if not model[path][self.TICKED_COL]: | |
page = Path( model[path][self.PAGE_COL] ) | |
text = self._get_raw_text(model[path]) | |
pageview = self.opener.open_page(page) | |
pageview.find(text) | |
task = model[path][self.TASK0_COL] | |
# if pageview has not been updated to add tickdate to task into the text, then the tickdate is | |
# preserved at least within the index (for the time being) | |
self.index_ext.put_new_tickdate_to_db(task) | |
self.win.pageview.toggle_checkbox() | |
def set_filter_actionable(self, filter): | |
'''Set filter state for non-actionable items | |
@param filter: if C{False} all items are shown, if C{True} only actionable items | |
''' | |
self.filter_actionable = filter | |
self._eval_filter() | |
def set_filter(self, string): | |
# TODO allow more complex queries here - same parse as for search | |
if string: | |
inverse = False | |
if string.lower().startswith('not '): | |
# Quick HACK to support e.g. "not @waiting" | |
inverse = True | |
string = string[4:] | |
self.filter = (inverse, string.strip().lower()) | |
else: | |
self.filter = None | |
self._eval_filter() | |
def get_labels(self): | |
'''Get all labels that are in use | |
@returns: a dict with labels as keys and the number of tasks | |
per label as value | |
''' | |
return self._labels | |
def get_tags(self): | |
'''Get all tags that are in use | |
@returns: a dict with tags as keys and the number of tasks | |
per tag as value | |
''' | |
return self._tags | |
def get_n_tasks(self): | |
'''Get the number of tasks in the list | |
@returns: total number | |
''' | |
model = self.get_model() | |
counter = [0] | |
def count(model, path, iter): | |
if model[iter][self.OPEN_COL]: | |
# only count open items | |
counter[0] += 1 | |
model.foreach(count) | |
return counter[0] | |
def get_statistics(self): | |
statsbyprio = {} | |
def count(model, path, iter): | |
# only count open items | |
row = model[iter] | |
if row[self.OPEN_COL]: | |
prio = row[self.PRIO_COL] | |
statsbyprio.setdefault(prio, 0) | |
statsbyprio[prio] += 1 | |
self.real_model.foreach(count) | |
if statsbyprio: | |
total = reduce(int.__add__, statsbyprio.values()) | |
highest = max([0] + statsbyprio.keys()) | |
stats = [statsbyprio.get(k, 0) for k in range(highest+1)] | |
stats.reverse() # highest first | |
return total, stats | |
else: | |
return 0, [] | |
def set_tag_filter(self, tags=None, labels=None): | |
if tags: | |
self.tag_filter = [tag.lower() for tag in tags] | |
else: | |
self.tag_filter = None | |
if labels: | |
self.label_filter = [label.lower() for label in labels] | |
else: | |
self.label_filter = None | |
self._eval_filter() | |
def _eval_filter(self): | |
logger.debug('Filtering task list with labels: %s tags: %s, filter: %s', self.label_filter, self.tag_filter, self.filter) | |
def filter(model, path, iter): | |
visible = self._filter_item(model[iter]) | |
model[iter][self.VIS_COL] = visible | |
if visible: | |
parent = model.iter_parent(iter) | |
while parent: | |
model[parent][self.VIS_COL] = visible | |
parent = model.iter_parent(parent) | |
self.real_model.foreach(filter) | |
self.expand_all() | |
def _filter_item(self, modelrow): | |
# This method filters case insensitive because both filters and | |
# text are first converted to lower case text. | |
visible = True | |
if not modelrow[self.OPEN_COL] \ | |
or (not modelrow[self.ACT_COL] and self.filter_actionable): | |
visible = False | |
description = modelrow[self.TASK_COL].decode('utf-8').lower() | |
pagename = modelrow[self.PAGE_COL].decode('utf-8').lower() | |
tags = [t.lower() for t in modelrow[self.TAGS_COL]] | |
comments = modelrow[self.TASK_COMMENT_COL].decode('utf-8').lower() | |
if visible and self.label_filter: | |
# Any labels need to be present | |
for label in self.label_filter: | |
if label in description: | |
break | |
else: | |
visible = False # no label found | |
if visible and self.tag_filter: | |
# Any tag should match --> changed to all to 'activate' | |
# 'hidden' functionality for multiple selection of tags with | |
# the use of the ctrl key. | |
if (_NO_TAGS in self.tag_filter and not tags) \ | |
or all(tag in tags for tag in self.tag_filter): | |
visible = True | |
else: | |
visible = False | |
if visible and self.filter: | |
# And finally the filter string should match | |
# FIXME: we are matching against markup text here - may fail for some cases | |
inverse, string = self.filter | |
match = string in description or string in pagename or string in comments | |
if (not inverse and not match) or (inverse and match): | |
visible = False | |
return visible | |
def tick_column_clicked(self, toggle, path): | |
''' | |
As resizing the columns emit the clicked signal somehow with the "tick" column as parameter | |
I use this Workaround for determining if really column "tick" was ticked. | |
''' | |
self.tick_column = True | |
def do_row_activated(self, path, column): | |
model = self.get_model() | |
page = Path( model[path][self.PAGE_COL] ) | |
text = self._get_raw_text(model[path]) | |
pageview = self.opener.open_page(page) | |
pageview.find(text) | |
# I need to get the task text only to compare against the model below | |
task_text = model[path][self.TASK_COL] | |
# now check if column Tick was clicked and tick box and action on page selected | |
# Need the workaround with self.tick_column as resizing the columns emit a clicked signal | |
# with the column title = Tick | |
if column.get_title() == "Tick" and self.tick_column: | |
real_path = model.convert_path_to_child_path(path) | |
if (self.label_filter or self.tag_filter or self.filter): | |
real_path = self.get_real_path(task_text) | |
self.real_model[real_path][self.TICKED_COL] = not self.real_model[real_path][self.TICKED_COL] | |
task = self.real_model[real_path][self.TASK0_COL] | |
# if pageview has not been updated to add tickdate to task into the text, then the tickdate is | |
# preserved at least within the index (for the time being) | |
self.index_ext.put_new_tickdate_to_db(task) | |
self.win.pageview.toggle_checkbox() | |
self.tick_column = False | |
def get_real_path(self, text): | |
''' | |
this method looks for the correct path in the real_model according to the selected row in treeview. | |
As treeview can be filtered against tag etc., the only reliable way to find the right path (even with children) is to | |
compare the text selected against the text in self.real_model and then return the path | |
''' | |
# start from the beginning | |
iter = self.real_model.get_iter_first() | |
while iter: | |
path = self.real_model.get_path(iter) | |
if self.real_model[path][self.TASK_COL] == text: | |
#parent found | |
return path | |
if self.real_model.iter_has_child(iter): | |
# there are children | |
n_children = self.real_model.iter_n_children(iter) | |
# how many? | |
for child_no in range(0, n_children): | |
child_iter = self.real_model.iter_nth_child(iter, child_no) | |
child_path = self.real_model.get_path(child_iter) | |
if self.real_model[child_path][self.TASK_COL] == text: | |
# so there is a child found | |
return child_path | |
iter = self.real_model.iter_next(iter) | |
def _get_raw_text(self, task): | |
id = task[self.TASKID_COL] | |
row = self.index_ext.get_task(id) | |
return row['description'] | |
def do_initialize_popup(self, menu): | |
item = gtk.ImageMenuItem('gtk-copy') | |
item.connect('activate', self.copy_to_clipboard) | |
menu.append(item) | |
self.populate_popup_expand_collapse(menu) | |
def copy_to_clipboard(self, *a): | |
'''Exports currently visible elements from the tasks list''' | |
logger.debug('Exporting to clipboard current view of task list.') | |
text = self.get_visible_data_as_csv() | |
Clipboard.set_text(text) | |
# TODO set as object that knows how to format as text / html / .. | |
# unify with export hooks | |
def get_visible_data_as_csv(self): | |
text = "" | |
for indent, prio, desc, date, tags0, comment, page in self.get_visible_data(): | |
prio = str(prio) | |
desc = decode_markup_text(desc) | |
desc = '"' + desc.replace('"', '""') + '"' | |
text += ",".join((prio, desc, date, tags0, comment, page)) + "\n" | |
return text | |
# TODO: Show filter (e.g. list of selected tags) which lead to the print out | |
def get_visible_data_as_html(self): | |
html = '''\ | |
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"> | |
<html> | |
<head> | |
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> | |
<title>Task List - Zim</title> | |
<meta name='Generator' content='Zim [%% zim.version %%]'> | |
<style type='text/css'> | |
table.tasklist { | |
border-width: 1px; | |
border-spacing: 2px; | |
border-style: solid; | |
border-color: gray; | |
border-collapse: collapse; | |
} | |
table.tasklist th { | |
border-width: 1px; | |
padding: 8px; | |
border-style: solid; | |
border-color: gray; | |
text-align: left; | |
background-color: gray; | |
color: white; | |
} | |
table.tasklist td { | |
border-width: 1px; | |
padding: 8px; | |
border-style: solid; | |
border-color: gray; | |
text-align: left; | |
} | |
.high {background-color: %s} | |
.medium {background-color: %s} | |
.alert {background-color: %s} | |
</style> | |
</head> | |
<body> | |
<h1>Task List - Zim</h1> | |
<table class="tasklist"> | |
<tr><th>Status</th><th>Prio</th><th>Task</th><th>Date</th><th>Tags</th><th>Comments</th></tr> | |
''' % (HIGH_COLOR, MEDIUM_COLOR, ALERT_COLOR) | |
today = str( datetime.date.today() ) | |
tomorrow = str( datetime.date.today() + datetime.timedelta(days=1)) | |
dayafter = str( datetime.date.today() + datetime.timedelta(days=2)) | |
for indent, status, prio, desc, date, tags0, comment, page in self.get_visible_data(): | |
if status == 1: status_str = '<td>Closed</td>' | |
if status == 0: status_str = '<td>Open</td>' | |
if prio >= 3: prio = '<td class="high">%s</td>' % prio | |
elif prio == 2: prio = '<td class="medium">%s</td>' % prio | |
elif prio == 1: prio = '<td class="alert">%s</td>' % prio | |
else: prio = '<td>%s</td>' % prio | |
if date and date <= today: date = '<td class="high">%s</td>' % date | |
elif date == tomorrow: date = '<td class="medium">%s</td>' % date | |
elif date == dayafter: date = '<td class="alert">%s</td>' % date | |
else: date = '<td>%s</td>' % date | |
desc = '<td>%s%s</td>' % (' ' * (4 * indent), desc) | |
if "\n" in tags0: | |
tags0 = tags0.replace("\n", "<br />") | |
tags0 = '<td>%s</td>' % tags0 | |
if "\n" in comment: | |
comment = comment.replace("\n", "<br />") | |
comment = '<td>%s</td>' % comment | |
page = '<td>%s</td>' % page | |
html += '<tr>' + status_str + prio + desc + date + tags0 + comment + '</tr>\n' | |
html += '''\ | |
</table> | |
</body> | |
</html> | |
''' | |
return html | |
def get_visible_data(self): | |
rows = [] | |
def collect(model, path, iter): | |
indent = len(path) - 1 # path is tuple with indexes | |
row = model[iter] | |
status = row[self.TICKED_COL] | |
prio = row[self.PRIO_COL] | |
desc = row[self.TASK_COL].decode('utf-8') | |
date = row[self.DATE_COL] | |
page = row[self.PAGE_COL].decode('utf-8') | |
tags0 = row[self.TAGS0_COL].decode('utf-8') | |
comment = row[self.TASK_COMMENT_COL].decode('utf-8') | |
if date == _NO_DATE: | |
date = '' | |
rows.append((indent, status, prio, desc, date, tags0, comment, page)) | |
model = self.get_model() | |
model.foreach(collect) | |
return rows | |
class TaskListHistoryTreeView(BrowserTreeView): | |
VIS_COL = 0 # visible | |
TICKED_COL = 1 | |
TICKED_DATE_COL = 2 | |
PRIO_COL = 3 | |
TASK_COL = 4 | |
DATE_COL = 5 | |
PAGE_COL = 6 | |
TAGS0_COL = 7 # TAGS separated by \n | |
TASK_COMMENT_COL = 8 | |
ACT_COL = 9 # actionable | |
OPEN_COL = 10 # item not closed | |
TASKID_COL = 11 | |
TAGS_COL = 12 | |
TASK0_COL = 13 # Tasks string complete (for search in index) | |
def __init__(self, window, index_ext, opener, filter_actionable=False, tag_by_page=False, use_workweek=False): | |
self.real_model = gtk.TreeStore(bool, bool, str, int, str, str, str, str, str, bool, bool, int, object, str) | |
# VIS_COL, TICKED_COL, TICKED_DATE_COL, PRIO_COL, TASK_COL, DATE_COL, TAGS0_COL, TASK_COMMENT_COL, PAGE_COL, ACT_COL, OPEN_COL, TASKID_COL, TAGS_COL, TASK0_COL | |
model = self.real_model.filter_new() | |
model.set_visible_column(self.VIS_COL) | |
model = gtk.TreeModelSort(model) | |
model.set_sort_column_id(self.TICKED_DATE_COL, gtk.SORT_DESCENDING) | |
BrowserTreeView.__init__(self, model) | |
screen_width = gtk.gdk.screen_width() | |
screen_height = gtk.gdk.screen_height() | |
self.index_ext = index_ext | |
self.opener = opener | |
self.filter = None | |
self.tag_filter = None | |
self.label_filter = None | |
self.filter_actionable = filter_actionable | |
self.tag_by_page = tag_by_page | |
self._tags = {} | |
self._labels = {} | |
self.win = window | |
column_width = 300 | |
# Wrap text in column on resize | |
def set_column_width(column, width, renderer, pan = True): | |
column_width = column.get_width() | |
renderer.props.wrap_width = column_width | |
if pan: | |
renderer.props.wrap_mode = pango.WRAP_WORD | |
else: | |
renderer.props.wrap_mode = gtk.WRAP_WORD | |
# Add some rendering for the task tick box | |
cell_renderer = gtk.CellRendererToggle() | |
cell_renderer.set_property('activatable', True) | |
column = gtk.TreeViewColumn('Tick', cell_renderer) | |
column.set_sort_column_id(self.TICKED_COL) | |
#column.set_resizable(False) | |
column.add_attribute(cell_renderer, "active", self.TICKED_COL) | |
self.append_column(column) | |
# Add some rendering for the task tick date | |
cell_renderer = gtk.CellRendererText() | |
column = gtk.TreeViewColumn(_('Ticked Date'), cell_renderer, text=self.TICKED_DATE_COL) | |
column.set_sort_column_id(self.TICKED_DATE_COL) | |
column.set_resizable(True) | |
self.append_column(column) | |
# Add some rendering for the Prio column | |
def render_prio(col, cell, model, i): | |
prio = model.get_value(i, self.PRIO_COL) | |
cell.set_property('text', str(prio)) | |
if prio >= 3: color = HIGH_COLOR | |
elif prio == 2: color = MEDIUM_COLOR | |
elif prio == 1: color = ALERT_COLOR | |
else: color = None | |
cell.set_property('cell-background', color) | |
cell_renderer = gtk.CellRendererText() | |
#~ column = gtk.TreeViewColumn(_('Prio'), cell_renderer) | |
# T: Column header Task List dialog | |
column = gtk.TreeViewColumn(' ! ', cell_renderer) | |
column.set_cell_data_func(cell_renderer, render_prio) | |
column.set_sort_column_id(self.PRIO_COL) | |
#column.set_resizable(True) | |
self.append_column(column) | |
# Rendering for task description column | |
cell_renderer = gtk.CellRendererText() | |
cell_renderer.set_property('ellipsize', pango.ELLIPSIZE_END) | |
column = gtk.TreeViewColumn(_('Task'), cell_renderer, markup=self.TASK_COL) | |
# T: Column header Task List dialog | |
column.set_resizable(True) | |
column.set_sort_column_id(self.TASK_COL) | |
column.set_expand(True) | |
#if ui_environment['platform'] == 'maemo': | |
# column.set_min_width(100) # don't let this column get too small | |
#else: | |
# column.set_min_width(150) # don't let this column get too small | |
self.append_column(column) | |
self.set_expander_column(column) | |
if gtk.gtk_version >= (2, 12) \ | |
and gtk.pygtk_version >= (2, 12): | |
self.set_tooltip_column(self.TASK_COL) | |
## Rendering of the Date column | |
#day_of_week = datetime.date.today().isoweekday() | |
#if use_workweek and day_of_week == 4: | |
# # Today is Thursday - 2nd day ahead is after the weekend | |
# delta1, delta2 = 1, 3 | |
#elif use_workweek and day_of_week == 5: | |
# # Today is Friday - next day ahead is after the weekend | |
# delta1, delta2 = 3, 4 | |
#else: | |
# delta1, delta2 = 1, 2 | |
#today = str( datetime.date.today() ) | |
#tomorrow = str( datetime.date.today() + datetime.timedelta(days=delta1)) | |
#dayafter = str( datetime.date.today() + datetime.timedelta(days=delta2)) | |
def render_date(col, cell, model, i): | |
date = model.get_value(i, self.DATE_COL) | |
if date == _NO_DATE: | |
cell.set_property('text', '') | |
#else: | |
# cell.set_property('text', date) | |
# TODO allow strftime here | |
# if date <= today: color = HIGH_COLOR | |
# elif date <= tomorrow: color = MEDIUM_COLOR | |
# elif date <= dayafter: color = ALERT_COLOR | |
# # "<=" because tomorrow and/or dayafter can be after the weekend | |
# else: color = None | |
# cell.set_property('cell-background', color) | |
cell_renderer = gtk.CellRendererText() | |
column = gtk.TreeViewColumn(_('Date'), cell_renderer, text=self.DATE_COL) | |
# T: Column header Task List dialog | |
column.set_cell_data_func(cell_renderer, render_date) | |
column.set_sort_column_id(self.DATE_COL) | |
self.append_column(column) | |
# Rendering for tag column | |
cell_renderer = gtk.CellRendererText() | |
cell_renderer.set_property('ellipsize', pango.ELLIPSIZE_END) | |
column = gtk.TreeViewColumn(_('Tags'), cell_renderer, markup=self.TAGS0_COL) | |
column.set_resizable(True) | |
column.set_sort_column_id(self.TAGS0_COL) | |
column.set_min_width(100) | |
column.connect_after("notify::width", set_column_width, cell_renderer) | |
self.append_column(column) | |
# Rendering for task comment column | |
cell_renderer = gtk.CellRendererText() | |
column = gtk.TreeViewColumn(_('Comment'), cell_renderer, text=self.TASK_COMMENT_COL) | |
column.set_resizable(True) | |
column.set_sort_column_id(self.TASK_COMMENT_COL) | |
#column.set_min_width(column_width) | |
cell_renderer.props.wrap_width = int(screen_width*0.05) | |
cell_renderer.props.wrap_mode = pango.WRAP_WORD | |
column.connect_after("notify::width", set_column_width, cell_renderer) | |
self.append_column(column) | |
# Rendering for page name column | |
cell_renderer = gtk.CellRendererText() | |
column = gtk.TreeViewColumn(_('Page'), cell_renderer, text=self.PAGE_COL) | |
# T: Column header Task List dialog | |
column.set_sort_column_id(self.PAGE_COL) | |
self.append_column(column) | |
# Finalize | |
self.refresh() | |
# HACK because we can not register ourselves :S | |
self.connect('row_activated', self.__class__.do_row_activated) | |
def refresh(self): | |
'''Refresh the model based on index data''' | |
# Update data | |
self._clear() | |
self._append_tasks(None, None, {}) | |
# Make tags case insensitive | |
tags = sorted((t.lower(), t) for t in self._tags) | |
# tuple sorting will sort ("foo", "Foo") before ("foo", "foo"), | |
# but ("bar", ..) before ("foo", ..) | |
prev = ('', '') | |
for tag in tags: | |
if tag[0] == prev[0]: | |
self._tags[prev[1]] += self._tags[tag[1]] | |
self._tags.pop(tag[1]) | |
prev = tag | |
# Set view | |
self._eval_filter() # keep current selection | |
self.expand_all() | |
def _clear(self): | |
self.real_model.clear() # flush | |
self._tags = {} | |
self._labels = {} | |
def _append_tasks(self, task, iter, path_cache): | |
for row in self.index_ext.list_tasks(task): | |
# only include ticked tasks and open tasks with ticked children | |
if not row['tickmark'] and not row['haschildren']: | |
continue | |
if row['source'] not in path_cache: | |
path = self.index_ext.get_path(row) | |
if path is None: | |
# Be robust for glitches - filter these out | |
continue | |
else: | |
path_cache[row['source']] = path | |
path = path_cache[row['source']] | |
# Update labels | |
for label in self.index_ext.task_label_re.findall(row['description']): | |
self._labels[label] = self._labels.get(label, 0) + 1 | |
# Update tag count | |
tags = row['tags'].split(',') | |
# Want to show tags one below the other instead of separated by comma, so creating tags0 instead | |
tags0 = "" | |
for tag in tags: | |
tags0 += "<span color=\"#ce5c00\">" + tag + "</span>" + "\n" | |
if self.tag_by_page: | |
tags = tags + path.parts | |
if tags: | |
for tag in tags: | |
self._tags[tag] = self._tags.get(tag, 0) + 1 | |
else: | |
self._tags[_NO_TAGS] = self._tags.get(_NO_TAGS, 0) + 1 | |
# Format description | |
task = _date_re.sub('', row['description'], count=1) | |
task = _tdate_re.sub('', task, count=1) | |
task = re.sub('\s*!+\s*', ' ', task) # get rid of exclamation marks | |
task = self.index_ext.next_label_re.sub('', task) # get rid of "next" label in description | |
task = encode_markup_text(task) | |
if row['actionable']: | |
#if row['tickmark']: | |
#task = _tag_re.sub(r'<span color="#ce5c00">@\1</span>', task) # highlight tags - same color as used in pageview | |
task = _tag_re.sub(r'', task) # get rid of tags in task description --> most probably not the best place to do that... | |
task = self.index_ext.task_label_re.sub(r'<b>\1</b>', task) # highlight labels | |
else: | |
task = r'<span color="darkgrey">%s</span>' % task | |
tickdate = "[no date]" | |
if row['tickdate']: | |
# This means that pageview has added tickdate to task within text and has been parsed by Taskparser | |
tickdate = row['tickdate'] | |
else: | |
# Either pageview is not updated to add tickdates to task, or it's disabled in pageview prefs. | |
# Therefore get the tickdat from the index. But if child is ticked, | |
# and parent is not but has entry in index, don't show date | |
if row['tickmark']: | |
tickdate = self.get_tickdate_for_task(row['task']) | |
if not tickdate: | |
tickdate = "[no date]" | |
# Insert all columns | |
modelrow = [False, row['tickmark'], tickdate, row['prio'], task, row['due'], path.name, tags0, row['comment'], row['actionable'], row['open'], row['id'], tags, row['task']] | |
modelrow[0] = self._filter_item(modelrow) | |
myiter = self.real_model.append(iter, modelrow) | |
if row['haschildren']: | |
self._append_tasks(row, myiter, path_cache) # recurs | |
def _toggle_all_tasks(self, tick_status, dialog): | |
toggle_all_ok = self._toggle_all_tasks_dialog(dialog) | |
if toggle_all_ok: | |
model = self.get_model() | |
model.foreach(self._do_toggle_all_tasks) | |
def _toggle_all_tasks_dialog(self, main_dialog): | |
task_count = self.get_n_tasks() | |
response = QuestionDialog(main_dialog, (_('Untick ' + str(task_count) + ' tasks?'), | |
# T: Short message text on first time use of task list plugin | |
_('You are about to untick ' + str(task_count) + ' tasks.\n\n' | |
'Are you sure?\n') | |
# T: Long message text on first time use of task list plugin | |
) ).run() | |
return response | |
def _do_toggle_all_tasks(self, model, path, iter): | |
# skip tasks which are not ticked but are shown due to ticked children | |
if model[path][self.TICKED_COL]: | |
page = Path( model[path][self.PAGE_COL] ) | |
text = self._get_raw_text(model[path]) | |
pageview = self.opener.open_page(page) | |
pageview.find(text) | |
task = model[path][self.TASK0_COL] | |
# if pageview has not been updated to add tickdate to task into the text, then the tickdate is | |
# preserved at least within the index (for the time being) | |
self.index_ext.put_new_tickdate_to_db(task) | |
self.win.pageview.toggle_checkbox() | |
def get_tickdate_for_task(self, task): | |
date = self.index_ext.get_tickdate_from_db(task) | |
if date: | |
return date[2] | |
return False | |
def put_tickdate_to_db(self, task, date): | |
self.index_ext.put_existing_tickdate_to_db(task, date, tickmark=True) | |
def set_filter_actionable(self, filter): | |
'''Set filter state for non-actionable items | |
@param filter: if C{False} all items are shown, if C{True} only actionable items | |
''' | |
self.filter_actionable = filter | |
self._eval_filter() | |
def set_filter(self, string): | |
# TODO allow more complex queries here - same parse as for search | |
if string: | |
inverse = False | |
if string.lower().startswith('not '): | |
# Quick HACK to support e.g. "not @waiting" | |
inverse = True | |
string = string[4:] | |
self.filter = (inverse, string.strip().lower()) | |
else: | |
self.filter = None | |
self._eval_filter() | |
def get_labels(self): | |
'''Get all labels that are in use | |
@returns: a dict with labels as keys and the number of tasks | |
per label as value | |
''' | |
return self._labels | |
def get_tags(self): | |
'''Get all tags that are in use | |
@returns: a dict with tags as keys and the number of tasks | |
per tag as value | |
''' | |
return self._tags | |
def get_n_tasks(self): | |
'''Get the number of tasks in the list | |
@returns: total number | |
''' | |
model = self.get_model() | |
counter = [0] | |
def count(model, path, iter): | |
if not model[iter][self.OPEN_COL]: | |
# only count open items | |
counter[0] += 1 | |
model.foreach(count) | |
return counter[0] | |
def get_statistics(self): | |
statsbyprio = {} | |
def count(model, path, iter): | |
# only count open items | |
row = model[iter] | |
if row[self.OPEN_COL]: | |
prio = row[self.PRIO_COL] | |
statsbyprio.setdefault(prio, 0) | |
statsbyprio[prio] += 1 | |
self.real_model.foreach(count) | |
if statsbyprio: | |
total = reduce(int.__add__, statsbyprio.values()) | |
highest = max([0] + statsbyprio.keys()) | |
stats = [statsbyprio.get(k, 0) for k in range(highest+1)] | |
stats.reverse() # highest first | |
return total, stats | |
else: | |
return 0, [] | |
def set_tag_filter(self, tags=None, labels=None): | |
if tags: | |
self.tag_filter = [tag.lower() for tag in tags] | |
else: | |
self.tag_filter = None | |
if labels: | |
self.label_filter = [label.lower() for label in labels] | |
else: | |
self.label_filter = None | |
self._eval_filter() | |
def _eval_filter(self): | |
logger.debug('Filtering task list history with labels: %s tags: %s, filter: %s', self.label_filter, self.tag_filter, self.filter) | |
def filter(model, path, iter): | |
visible = self._filter_item(model[iter]) | |
model[iter][self.VIS_COL] = visible | |
if visible: | |
parent = model.iter_parent(iter) | |
while parent: | |
model[parent][self.VIS_COL] = visible | |
parent = model.iter_parent(parent) | |
self.real_model.foreach(filter) | |
self.expand_all() | |
def _filter_item(self, modelrow): | |
# This method filters case insensitive because both filters and | |
# text are first converted to lower case text. | |
visible = True | |
if not modelrow[self.TICKED_COL]: | |
visible = False | |
#if not modelrow[self.OPEN_COL] \ | |
#or (not modelrow[self.ACT_COL] and self.filter_actionable): | |
# visible = False | |
description = modelrow[self.TASK_COL].decode('utf-8').lower() | |
pagename = modelrow[self.PAGE_COL].decode('utf-8').lower() | |
tags = [t.lower() for t in modelrow[self.TAGS_COL]] | |
comments = modelrow[self.TASK_COMMENT_COL].decode('utf-8').lower() | |
if visible and self.label_filter: | |
# Any labels need to be present | |
for label in self.label_filter: | |
if label in description: | |
break | |
else: | |
visible = False # no label found | |
if visible and self.tag_filter: | |
# Any tag should match --> changed to all to 'activate' | |
# 'hidden' functionality for multiple selection of tags with | |
# the use of the ctrl key. | |
if (_NO_TAGS in self.tag_filter and not tags) \ | |
or all(tag in tags for tag in self.tag_filter): | |
visible = True | |
else: | |
visible = False | |
if visible and self.filter: | |
# And finally the filter string should match | |
# FIXME: we are matching against markup text here - may fail for some cases | |
inverse, string = self.filter | |
match = string in description or string in pagename or string in comments | |
if (not inverse and not match) or (inverse and match): | |
visible = False | |
return visible | |
def do_row_activated(self, path, column): | |
model = self.get_model() | |
page = Path( model[path][self.PAGE_COL] ) | |
text = self._get_raw_text(model[path]) | |
pageview = self.opener.open_page(page) | |
pageview.find(text) | |
# I need to get the task text only to compare against the model below | |
task_text = model[path][self.TASK_COL] | |
# now check if column Tick was clicked and tick box and action on page selected | |
if column.get_title() == "Tick": | |
real_path = model.convert_path_to_child_path(path) | |
# get the real path to self.real_model no matter what filter or tag is selected | |
if (self.label_filter or self.tag_filter or self.filter): | |
real_path = self.get_real_path(task_text) | |
self.real_model[real_path][self.TICKED_COL] = not self.real_model[real_path][self.TICKED_COL] | |
task = self.real_model[real_path][self.TASK0_COL] | |
# the tick date is removed from index no matter if pageview removes tickdate from task within text | |
self.index_ext.del_tickdate_from_db(task) | |
self.win.pageview.toggle_checkbox() | |
def get_real_path(self, text): | |
''' | |
this method looks for the correct path in the real_model according to the selected row in treeview. | |
As treeview can be filtered against tag etc., the only reliable way to find the right path (even with children) is to | |
compare the text selected against the text in self.real_model and then return the path | |
''' | |
# start from the beginning | |
iter = self.real_model.get_iter_first() | |
while iter: | |
path = self.real_model.get_path(iter) | |
if self.real_model[path][self.TASK_COL] == text: | |
#parent found | |
return path | |
if self.real_model.iter_has_child(iter): | |
# there are children | |
n_children = self.real_model.iter_n_children(iter) | |
# how many? | |
for child_no in range(0, n_children): | |
child_iter = self.real_model.iter_nth_child(iter, child_no) | |
child_path = self.real_model.get_path(child_iter) | |
if self.real_model[child_path][self.TASK_COL] == text: | |
# so there is a child found | |
return child_path | |
iter = self.real_model.iter_next(iter) | |
def _get_raw_text(self, task): | |
id = task[self.TASKID_COL] | |
row = self.index_ext.get_task(id) | |
return row['description'] | |
def do_initialize_popup(self, menu): | |
item = gtk.ImageMenuItem('gtk-copy') | |
item.connect('activate', self.copy_to_clipboard) | |
menu.append(item) | |
self.populate_popup_expand_collapse(menu) | |
def copy_to_clipboard(self, *a): | |
'''Exports currently visible elements from the tasks list''' | |
logger.debug('Exporting to clipboard current view of task list.') | |
text = self.get_visible_data_as_csv() | |
Clipboard.set_text(text) | |
# TODO set as object that knows how to format as text / html / .. | |
# unify with export hooks | |
def get_visible_data_as_csv(self): | |
text = "" | |
for indent, prio, desc, date, tags0, comment, page in self.get_visible_data(): | |
prio = str(prio) | |
desc = decode_markup_text(desc) | |
desc = '"' + desc.replace('"', '""') + '"' | |
text += ",".join((prio, desc, date, tags0, comment, page)) + "\n" | |
return text | |
# TODO: Show filter (e.g. list of selected tags) which lead to the print out | |
def get_visible_data_as_html(self): | |
html = '''\ | |
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"> | |
<html> | |
<head> | |
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> | |
<title>Task List - Zim</title> | |
<meta name='Generator' content='Zim [%% zim.version %%]'> | |
<style type='text/css'> | |
table.tasklist { | |
border-width: 1px; | |
border-spacing: 2px; | |
border-style: solid; | |
border-color: gray; | |
border-collapse: collapse; | |
} | |
table.tasklist th { | |
border-width: 1px; | |
padding: 8px; | |
border-style: solid; | |
border-color: gray; | |
text-align: left; | |
background-color: gray; | |
color: white; | |
} | |
table.tasklist td { | |
border-width: 1px; | |
padding: 8px; | |
border-style: solid; | |
border-color: gray; | |
text-align: left; | |
} | |
.high {background-color: %s} | |
.medium {background-color: %s} | |
.alert {background-color: %s} | |
</style> | |
</head> | |
<body> | |
<h1>Task List - Zim</h1> | |
<table class="tasklist"> | |
<tr><th>Status</th><th>Prio</th><th>Task</th><th>Date</th><th>Tags</th><th>Comments</th></tr> | |
''' % (HIGH_COLOR, MEDIUM_COLOR, ALERT_COLOR) | |
today = str( datetime.date.today() ) | |
tomorrow = str( datetime.date.today() + datetime.timedelta(days=1)) | |
dayafter = str( datetime.date.today() + datetime.timedelta(days=2)) | |
for indent, status, prio, desc, date, tags0, comment, page in self.get_visible_data(): | |
if status == 1: status_str = '<td>Closed</td>' | |
if status == 0: status_str = '<td>Open</td>' | |
if prio >= 3: prio = '<td class="high">%s</td>' % prio | |
elif prio == 2: prio = '<td class="medium">%s</td>' % prio | |
elif prio == 1: prio = '<td class="alert">%s</td>' % prio | |
else: prio = '<td>%s</td>' % prio | |
if date and date <= today: date = '<td class="high">%s</td>' % date | |
elif date == tomorrow: date = '<td class="medium">%s</td>' % date | |
elif date == dayafter: date = '<td class="alert">%s</td>' % date | |
else: date = '<td>%s</td>' % date | |
desc = '<td>%s%s</td>' % (' ' * (4 * indent), desc) | |
if "\n" in tags0: | |
tags0 = tags0.replace("\n", "<br />") | |
tags0 = '<td>%s</td>' % tags0 | |
if "\n" in comment: | |
comment = comment.replace("\n", "<br />") | |
comment = '<td>%s</td>' % comment | |
page = '<td>%s</td>' % page | |
html += '<tr>' + status_str + prio + desc + date + tags0 + comment + '</tr>\n' | |
html += '''\ | |
</table> | |
</body> | |
</html> | |
''' | |
return html | |
def get_visible_data(self): | |
rows = [] | |
def collect(model, path, iter): | |
indent = len(path) - 1 # path is tuple with indexes | |
row = model[iter] | |
status = row[self.TICKED_COL] | |
prio = row[self.PRIO_COL] | |
desc = row[self.TASK_COL].decode('utf-8') | |
date = row[self.DATE_COL] | |
page = row[self.PAGE_COL].decode('utf-8') | |
tags0 = row[self.TAGS0_COL].decode('utf-8') | |
comment = row[self.TASK_COMMENT_COL].decode('utf-8') | |
if date == _NO_DATE: | |
date = '' | |
rows.append((indent, status, prio, desc, date, tags0, comment, page)) | |
model = self.get_model() | |
model.foreach(collect) | |
return rows | |
# Need to register classes defining gobject signals | |
#~ gobject.type_register(TaskListTreeView) | |
# NOTE: enabling this line causes this treeview to have wrong theming under default ubuntu them !??? |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
The key accelerator
CTRL+T
("Task List") conflicts with the formating action "text verbatim (monospace font)". Please, use another combination.