Switching selecting the due date to displaying a popup calendar
[doneit] / src / rtm_view.py
1 """
2 @todo Add an agenda view to the task list
3         Tree of days, with each successive 7 days dropping the visibility of further lower priority items
4 @todo Add a map view
5                 Using new api widgets people are developing)
6                 Integrate GPS w/ fallback to default location
7                 Use locations for mapping
8 @todo Add a quick search (OR within a property type, and between property types) view
9                 Drop down for multi selecting priority
10                 Drop down for multi selecting tags
11                 Drop down for multi selecting locations
12                 Calendar selector for choosing due date range
13 @todo Remove blocking operations from UI thread
14 """
15
16 import webbrowser
17 import datetime
18 import urlparse
19 import base64
20
21 import gobject
22 import gtk
23
24 import coroutines
25 import toolbox
26 import gtk_toolbox
27 import rtm_backend
28 import rtm_api
29
30
31 def abbreviate(text, expectedLen):
32         singleLine = " ".join(text.split("\n"))
33         lineLen = len(singleLine)
34         if lineLen <= expectedLen:
35                 return singleLine
36
37         abbrev = "..."
38
39         leftLen = expectedLen // 2 - 1
40         rightLen = max(expectedLen - leftLen - len(abbrev) + 1, 1)
41
42         abbrevText =  singleLine[0:leftLen] + abbrev + singleLine[-rightLen:-1]
43         assert len(abbrevText) <= expectedLen, "Too long: '%s'" % abbrevText
44         return abbrevText
45
46
47 def abbreviate_url(url, domainLength, pathLength):
48         urlParts = urlparse.urlparse(url)
49
50         netloc = urlParts.netloc
51         path = urlParts.path
52
53         pathLength += max(domainLength - len(netloc), 0)
54         domainLength += max(pathLength - len(path), 0)
55
56         netloc = abbreviate(netloc, domainLength)
57         path = abbreviate(path, pathLength)
58         return netloc + path
59
60
61 def get_token(username, apiKey, secret):
62         token = None
63         rtm = rtm_api.RTMapi(username, apiKey, secret, token)
64
65         authURL = rtm.getAuthURL()
66         webbrowser.open(authURL)
67         mb = gtk_toolbox.MessageBox2("You need to authorize DoneIt with\nRemember The Milk.\nClick OK after you authorize.")
68         mb.run()
69
70         token = rtm.getToken()
71         return token
72
73
74 def get_credentials(credentialsDialog):
75         username, password = credentialsDialog.request_credentials()
76         token = get_token(username, rtm_backend.RtMilkManager.API_KEY, rtm_backend.RtMilkManager.SECRET)
77         return username, password, token
78
79
80 def item_sort_by_priority_then_date(items):
81         sortedTasks = list(items)
82         sortedTasks.sort(
83                 key = lambda taskDetails: (
84                         taskDetails["priority"].get_nothrow(4),
85                         taskDetails["dueDate"].get_nothrow(datetime.datetime.max),
86                 ),
87         )
88         return sortedTasks
89
90
91 def item_sort_by_date_then_priority(items):
92         sortedTasks = list(items)
93         sortedTasks.sort(
94                 key = lambda taskDetails: (
95                         taskDetails["dueDate"].get_nothrow(datetime.datetime.max),
96                         taskDetails["priority"].get_nothrow(4),
97                 ),
98         )
99         return sortedTasks
100
101
102 def item_in_agenda(item):
103         taskDate = item["dueDate"].get_nothrow(datetime.datetime.max)
104         today = datetime.datetime.now()
105         delta = taskDate - today
106         dayDelta = abs(delta.days)
107
108         priority = item["priority"].get_nothrow(4)
109         weeksVisible = 5 - priority
110
111         isVisible = not bool(dayDelta / (weeksVisible * 7))
112         return isVisible
113
114
115 def item_sort_by_fuzzydate_then_priority(items):
116         sortedTasks = list(items)
117
118         def advanced_key(taskDetails):
119                 dueDate = taskDetails["dueDate"].get_nothrow(datetime.datetime.max)
120                 priority = taskDetails["priority"].get_nothrow(4)
121                 isNotSameYear = not toolbox.is_same_year(dueDate)
122                 isNotSameMonth = not toolbox.is_same_month(dueDate)
123                 isNotSameDay = not toolbox.is_same_day(dueDate)
124                 return isNotSameDay, isNotSameMonth, isNotSameYear, priority, dueDate
125
126         sortedTasks.sort(key=advanced_key)
127         return sortedTasks
128
129
130 class ItemListView(object):
131
132         ID_IDX = 0
133         COMPLETION_IDX = 1
134         NAME_IDX = 2
135         PRIORITY_IDX = 3
136         DUE_IDX = 4
137         FUZZY_IDX = 5
138         LINK_IDX = 6
139         NOTES_IDX = 7
140
141         def __init__(self, widgetTree, errorDisplay):
142                 self._errorDisplay = errorDisplay
143                 self._manager = None
144                 self._projId = None
145                 self._showCompleted = False
146                 self._showIncomplete = True
147
148                 self._editDialog = gtk_toolbox.EditTaskDialog(widgetTree)
149
150                 self._itemList = gtk.ListStore(
151                         gobject.TYPE_STRING, # id
152                         gobject.TYPE_BOOLEAN, # is complete
153                         gobject.TYPE_STRING, # name
154                         gobject.TYPE_STRING, # priority
155                         gobject.TYPE_STRING, # due
156                         gobject.TYPE_STRING, # fuzzy due
157                         gobject.TYPE_STRING, # Link
158                         gobject.TYPE_STRING, # Notes
159                 )
160                 self._completionColumn = gtk.TreeViewColumn('') # Complete?
161                 self._completionCell = gtk.CellRendererToggle()
162                 self._completionCell.set_property("activatable", True)
163                 self._completionCell.connect("toggled", self._on_completion_change)
164                 self._completionColumn.pack_start(self._completionCell, False)
165                 self._completionColumn.set_attributes(self._completionCell, active=self.COMPLETION_IDX)
166                 self._priorityColumn = gtk.TreeViewColumn('') # Priority
167                 self._priorityCell = gtk.CellRendererText()
168                 self._priorityColumn.pack_start(self._priorityCell, False)
169                 self._priorityColumn.set_attributes(self._priorityCell, text=self.PRIORITY_IDX)
170                 self._nameColumn = gtk.TreeViewColumn('Name')
171                 self._nameCell = gtk.CellRendererText()
172                 self._nameColumn.pack_start(self._nameCell, True)
173                 self._nameColumn.set_attributes(self._nameCell, text=self.NAME_IDX)
174                 self._dueColumn = gtk.TreeViewColumn('Due')
175                 self._dueCell = gtk.CellRendererText()
176                 self._dueColumn.pack_start(self._nameCell, False)
177                 self._dueColumn.set_attributes(self._nameCell, text=self.FUZZY_IDX)
178                 self._linkColumn = gtk.TreeViewColumn('') # Link
179                 self._linkCell = gtk.CellRendererText()
180                 self._linkColumn.pack_start(self._nameCell, False)
181                 self._linkColumn.set_attributes(self._nameCell, text=self.LINK_IDX)
182                 self._notesColumn = gtk.TreeViewColumn('') # Notes
183                 self._notesCell = gtk.CellRendererText()
184                 self._notesColumn.pack_start(self._nameCell, False)
185                 self._notesColumn.set_attributes(self._nameCell, text=self.NOTES_IDX)
186
187                 self._todoBox = widgetTree.get_widget("todoBox")
188                 self._todoItemScroll = gtk.ScrolledWindow()
189                 self._todoItemScroll.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
190                 self._todoItemTree = gtk.TreeView()
191                 self._todoItemScroll.add(self._todoItemTree)
192                 self._onItemSelectId = 0
193
194         def enable(self, manager, projId):
195                 self._manager = manager
196                 self._projId = projId
197
198                 self._todoBox.pack_start(self._todoItemScroll)
199                 self._todoItemScroll.show_all()
200
201                 self._itemList.clear()
202                 self._todoItemTree.append_column(self._completionColumn)
203                 self._todoItemTree.append_column(self._priorityColumn)
204                 self._todoItemTree.append_column(self._nameColumn)
205                 self._todoItemTree.append_column(self._dueColumn)
206                 self._todoItemTree.append_column(self._linkColumn)
207                 self._todoItemTree.append_column(self._notesColumn)
208                 try:
209                         self.reset_task_list(self._projId)
210                 except StandardError, e:
211                         self._errorDisplay.push_exception()
212
213                 self._todoItemTree.set_headers_visible(False)
214                 self._nameColumn.set_expand(True)
215
216                 self._onItemSelectId = self._todoItemTree.connect("row-activated", self._on_item_select)
217
218         def disable(self):
219                 self._manager = None
220                 self._projId = None
221
222                 self._todoBox.remove(self._todoItemScroll)
223                 self._todoItemScroll.hide_all()
224                 self._todoItemTree.disconnect(self._onItemSelectId)
225
226                 self._todoItemTree.remove_column(self._completionColumn)
227                 self._todoItemTree.remove_column(self._priorityColumn)
228                 self._todoItemTree.remove_column(self._nameColumn)
229                 self._todoItemTree.remove_column(self._dueColumn)
230                 self._todoItemTree.remove_column(self._linkColumn)
231                 self._todoItemTree.remove_column(self._notesColumn)
232                 self._itemList.clear()
233                 self._itemList.set_model(None)
234
235         def reset_task_list(self, projId):
236                 self._projId = projId
237                 self._itemList.clear()
238                 self._populate_items()
239
240         def _populate_items(self):
241                 projId = self._projId
242                 rawTasks = self._manager.get_tasks_with_details(projId)
243                 filteredTasks = (
244                         taskDetails
245                         for taskDetails in rawTasks
246                                 if self._showCompleted and taskDetails["isCompleted"] or self._showIncomplete and not taskDetails["isCompleted"]
247                 )
248                 # filteredTasks = (taskDetails for taskDetails in filteredTasks if item_in_agenda(taskDetails))
249                 sortedTasks = item_sort_by_priority_then_date(filteredTasks)
250                 # sortedTasks = item_sort_by_date_then_priority(filteredTasks)
251                 # sortedTasks = item_sort_by_fuzzydate_then_priority(filteredTasks)
252                 for taskDetails in sortedTasks:
253                         id = taskDetails["id"]
254                         isCompleted = taskDetails["isCompleted"]
255                         name = abbreviate(taskDetails["name"], 100)
256                         priority = str(taskDetails["priority"].get_nothrow(""))
257                         if taskDetails["dueDate"].is_good():
258                                 dueDate = taskDetails["dueDate"].get()
259                                 dueDescription = dueDate.strftime("%Y-%m-%d %H:%M:%S")
260                                 fuzzyDue = toolbox.to_fuzzy_date(dueDate)
261                         else:
262                                 dueDescription = ""
263                                 fuzzyDue = ""
264
265                         linkDisplay = taskDetails["url"]
266                         linkDisplay = abbreviate_url(linkDisplay, 20, 10)
267
268                         notes = taskDetails["notes"]
269                         notesDisplay = "%d Notes" % len(notes) if notes else ""
270
271                         row = (id, isCompleted, name, priority, dueDescription, fuzzyDue, linkDisplay, notesDisplay)
272                         self._itemList.append(row)
273                 self._todoItemTree.set_model(self._itemList)
274
275         def _on_item_select(self, treeView, path, viewColumn):
276                 try:
277                         # @todo See if there is a way to use the new gtk_toolbox.ContextHandler
278                         taskId = self._itemList[path[0]][self.ID_IDX]
279
280                         if viewColumn is self._priorityColumn:
281                                 pass
282                         elif viewColumn is self._nameColumn:
283                                 self._editDialog.enable(self._manager)
284                                 try:
285                                         self._editDialog.request_task(self._manager, taskId)
286                                 finally:
287                                         self._editDialog.disable()
288                                 self.reset_task_list(self._projId)
289                         elif viewColumn is self._dueColumn:
290                                 due = self._manager.get_task_details(taskId)["dueDate"]
291                                 if due.is_good():
292                                         calendar = gtk_toolbox.PopupCalendar(None, due.get())
293                                         calendar.run()
294                         elif viewColumn is self._linkColumn:
295                                 webbrowser.open(self._manager.get_task_details(taskId)["url"])
296                         elif viewColumn is self._notesColumn:
297                                 pass
298                 except StandardError, e:
299                         self._errorDisplay.push_exception()
300
301         def _on_completion_change(self, cell, path):
302                 try:
303                         taskId = self._itemList[path[0]][self.ID_IDX]
304                         self._manager.complete_task(taskId)
305                         self.reset_task_list(self._projId)
306                 except StandardError, e:
307                         self._errorDisplay.push_exception()
308
309
310 class QuickAddView(object):
311
312         def __init__(self, widgetTree, errorDisplay, addSink):
313                 self._errorDisplay = errorDisplay
314                 self._manager = None
315                 self._projId = None
316                 self._addSink = addSink
317
318                 self._clipboard = gtk.clipboard_get()
319                 self._editDialog = gtk_toolbox.EditTaskDialog(widgetTree)
320
321                 self._taskNameEntry = widgetTree.get_widget("add-taskNameEntry")
322                 self._addTaskButton = widgetTree.get_widget("add-addTaskButton")
323                 self._pasteTaskNameButton = widgetTree.get_widget("add-pasteTaskNameButton")
324                 self._clearTaskNameButton = widgetTree.get_widget("add-clearTaskNameButton")
325                 self._onAddId = None
326                 self._onAddClickedId = None
327                 self._onAddReleasedId = None
328                 self._addToEditTimerId = None
329                 self._onClearId = None
330                 self._onPasteId = None
331
332         def enable(self, manager, projId):
333                 self._manager = manager
334                 self._projId = projId
335
336                 self._onAddId = self._addTaskButton.connect("clicked", self._on_add)
337                 self._onAddClickedId = self._addTaskButton.connect("pressed", self._on_add_pressed)
338                 self._onAddReleasedId = self._addTaskButton.connect("released", self._on_add_released)
339                 self._onPasteId = self._pasteTaskNameButton.connect("clicked", self._on_paste)
340                 self._onClearId = self._clearTaskNameButton.connect("clicked", self._on_clear)
341
342         def disable(self):
343                 self._manager = None
344                 self._projId = None
345
346                 self._addTaskButton.disconnect(self._onAddId)
347                 self._addTaskButton.disconnect(self._onAddClickedId)
348                 self._addTaskButton.disconnect(self._onAddReleasedId)
349                 self._pasteTaskNameButton.disconnect(self._onPasteId)
350                 self._clearTaskNameButton.disconnect(self._onClearId)
351
352         def reset_task_list(self, projId):
353                 self._projId = projId
354                 isMeta = self._manager.get_project(self._projId)["isMeta"]
355                 # @todo RTM handles this by defaulting to a specific list
356                 self._addTaskButton.set_sensitive(not isMeta)
357
358         def _on_add(self, *args):
359                 try:
360                         name = self._taskNameEntry.get_text()
361
362                         projId = self._projId
363                         taskId = self._manager.add_task(projId, name)
364
365                         self._taskNameEntry.set_text("")
366                         self._addSink.send((projId, taskId))
367                 except StandardError, e:
368                         self._errorDisplay.push_exception()
369
370         def _on_add_edit(self, *args):
371                 try:
372                         name = self._taskNameEntry.get_text()
373
374                         projId = self._projId
375                         taskId = self._manager.add_task(projId, name)
376
377                         try:
378                                 self._editDialog.enable(self._manager)
379                                 try:
380                                         self._editDialog.request_task(self._manager, taskId)
381                                 finally:
382                                         self._editDialog.disable()
383                         finally:
384                                 self._taskNameEntry.set_text("")
385                                 self._addSink.send((projId, taskId))
386                 except StandardError, e:
387                         self._errorDisplay.push_exception()
388
389         def _on_add_pressed(self, widget):
390                 try:
391                         self._addToEditTimerId = gobject.timeout_add(1000, self._on_add_edit)
392                 except StandardError, e:
393                         self._errorDisplay.push_exception()
394
395         def _on_add_released(self, widget):
396                 try:
397                         if self._addToEditTimerId is not None:
398                                 gobject.source_remove(self._addToEditTimerId)
399                         self._addToEditTimerId = None
400                 except StandardError, e:
401                         self._errorDisplay.push_exception()
402
403         def _on_paste(self, *args):
404                 try:
405                         entry = self._taskNameEntry.get_text()
406                         entry += self._clipboard.wait_for_text()
407                         self._taskNameEntry.set_text(entry)
408                 except StandardError, e:
409                         self._errorDisplay.push_exception()
410
411         def _on_clear(self, *args):
412                 try:
413                         self._taskNameEntry.set_text("")
414                 except StandardError, e:
415                         self._errorDisplay.push_exception()
416
417
418 class GtkRtMilk(object):
419
420         def __init__(self, widgetTree, errorDisplay):
421                 """
422                 @note Thread agnostic
423                 """
424                 self._errorDisplay = errorDisplay
425                 self._manager = None
426                 self._credentials = "", "", ""
427
428                 self._projectsList = gtk.ListStore(gobject.TYPE_STRING)
429                 self._projectsCombo = widgetTree.get_widget("projectsCombo")
430                 self._onListActivateId = 0
431
432                 self._itemView = ItemListView(widgetTree, self._errorDisplay)
433                 addSink = coroutines.func_sink(lambda eventData: self._itemView.reset_task_list(eventData[0]))
434                 self._addView = QuickAddView(widgetTree, self._errorDisplay, addSink)
435                 self._credentialsDialog = gtk_toolbox.LoginWindow(widgetTree)
436
437         @staticmethod
438         def name():
439                 return "Remember The Milk"
440
441         def load_settings(self, config):
442                 """
443                 @note Thread Agnostic
444                 """
445                 blobs = (
446                         config.get(self.name(), "bin_blob_%i" % i)
447                         for i in xrange(len(self._credentials))
448                 )
449                 creds = (
450                         base64.b64decode(blob)
451                         for blob in blobs
452                 )
453                 self._credentials = tuple(creds)
454
455         def save_settings(self, config):
456                 """
457                 @note Thread Agnostic
458                 """
459                 config.add_section(self.name())
460                 for i, value in enumerate(self._credentials):
461                         blob = base64.b64encode(value)
462                         config.set(self.name(), "bin_blob_%i" % i, blob)
463
464         def login(self):
465                 """
466                 @note UI Thread
467                 """
468                 if self._manager is not None:
469                         return
470
471                 credentials = self._credentials
472                 while True:
473                         try:
474                                 self._manager = rtm_backend.RtMilkManager(*credentials)
475                                 self._credentials = credentials
476                                 return # Login succeeded
477                         except rtm_api.AuthStateMachine.NoData:
478                                 # Login failed, grab new credentials
479                                 credentials = get_credentials(self._credentialsDialog)
480
481         def logout(self):
482                 """
483                 @note Thread Agnostic
484                 """
485                 self._credentials = "", "", ""
486                 self._manager = None
487
488         def enable(self):
489                 """
490                 @note UI Thread
491                 """
492                 self._projectsList.clear()
493                 self._populate_projects()
494
495                 currentProject = self._get_project()
496                 projId = self._manager.lookup_project(currentProject)["id"]
497                 self._addView.enable(self._manager, projId)
498                 self._itemView.enable(self._manager, projId)
499
500                 self._onListActivateId = self._projectsCombo.connect("changed", self._on_list_activate)
501
502         def disable(self):
503                 """
504                 @note UI Thread
505                 """
506                 self._projectsCombo.disconnect(self._onListActivateId)
507
508                 self._addView.disable()
509                 self._itemView.disable()
510
511                 self._projectsList.clear()
512                 self._projectsCombo.set_model(None)
513                 self._projectsCombo.disconnect("changed", self._on_list_activate)
514
515                 self._manager = None
516
517         def _populate_projects(self):
518                 for project in self._manager.get_projects():
519                         projectName = project["name"]
520                         isVisible = project["isVisible"]
521                         row = (projectName, )
522                         if isVisible:
523                                 self._projectsList.append(row)
524                 self._projectsCombo.set_model(self._projectsList)
525                 cell = gtk.CellRendererText()
526                 self._projectsCombo.pack_start(cell, True)
527                 self._projectsCombo.add_attribute(cell, 'text', 0)
528                 self._projectsCombo.set_active(0)
529
530         def _reset_task_list(self):
531                 projectName = self._get_project()
532                 projId = self._manager.lookup_project(projectName)["id"]
533                 self._addView.reset_task_list(projId)
534                 self._itemView.reset_task_list(projId)
535
536         def _get_project(self):
537                 currentProjectName = self._projectsCombo.get_active_text()
538                 return currentProjectName
539
540         def _on_list_activate(self, *args):
541                 try:
542                         self._reset_task_list()
543                 except StandardError, e:
544                         self._errorDisplay.push_exception()