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