a2593bf4e10b9f7afbd02da8476e95e99e5d0ec3
[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, addSink):
328                 self._errorDisplay = errorDisplay
329                 self._manager = None
330                 self._projId = None
331                 self._addSink = addSink
332
333                 self._clipboard = gtk.clipboard_get()
334                 self._editDialog = gtk_toolbox.EditTaskDialog(widgetTree)
335
336                 self._taskNameEntry = widgetTree.get_widget("add-taskNameEntry")
337                 self._addTaskButton = widgetTree.get_widget("add-addTaskButton")
338                 self._pasteTaskNameButton = widgetTree.get_widget("add-pasteTaskNameButton")
339                 self._clearTaskNameButton = widgetTree.get_widget("add-clearTaskNameButton")
340                 self._onAddId = None
341                 self._onAddClickedId = None
342                 self._onAddReleasedId = None
343                 self._addToEditTimerId = None
344                 self._onClearId = None
345                 self._onPasteId = None
346
347         def enable(self, manager, projId):
348                 self._manager = manager
349                 self._projId = projId
350
351                 self._onAddId = self._addTaskButton.connect("clicked", self._on_add)
352                 self._onAddClickedId = self._addTaskButton.connect("pressed", self._on_add_pressed)
353                 self._onAddReleasedId = self._addTaskButton.connect("released", self._on_add_released)
354                 self._onPasteId = self._pasteTaskNameButton.connect("clicked", self._on_paste)
355                 self._onClearId = self._clearTaskNameButton.connect("clicked", self._on_clear)
356
357         def disable(self):
358                 self._manager = None
359                 self._projId = None
360
361                 self._addTaskButton.disconnect(self._onAddId)
362                 self._addTaskButton.disconnect(self._onAddClickedId)
363                 self._addTaskButton.disconnect(self._onAddReleasedId)
364                 self._pasteTaskNameButton.disconnect(self._onPasteId)
365                 self._clearTaskNameButton.disconnect(self._onClearId)
366
367         def reset_task_list(self, projId):
368                 self._projId = projId
369                 isMeta = self._manager.get_project(self._projId)["isMeta"]
370                 # @todo RTM handles this by defaulting to a specific list
371                 self._addTaskButton.set_sensitive(not isMeta)
372
373         def _on_add(self, *args):
374                 try:
375                         name = self._taskNameEntry.get_text()
376
377                         projId = self._projId
378                         taskId = self._manager.add_task(projId, name)
379
380                         self._taskNameEntry.set_text("")
381                         self._addSink.send((projId, taskId))
382                 except StandardError, e:
383                         self._errorDisplay.push_exception()
384
385         def _on_add_edit(self, *args):
386                 try:
387                         name = self._taskNameEntry.get_text()
388
389                         projId = self._projId
390                         taskId = self._manager.add_task(projId, name)
391
392                         try:
393                                 self._editDialog.enable(self._manager)
394                                 try:
395                                         self._editDialog.request_task(self._manager, taskId)
396                                 finally:
397                                         self._editDialog.disable()
398                         finally:
399                                 self._taskNameEntry.set_text("")
400                                 self._addSink.send((projId, taskId))
401                 except StandardError, e:
402                         self._errorDisplay.push_exception()
403
404         def _on_add_pressed(self, widget):
405                 try:
406                         self._addToEditTimerId = gobject.timeout_add(1000, self._on_add_edit)
407                 except StandardError, e:
408                         self._errorDisplay.push_exception()
409
410         def _on_add_released(self, widget):
411                 try:
412                         if self._addToEditTimerId is not None:
413                                 gobject.source_remove(self._addToEditTimerId)
414                         self._addToEditTimerId = None
415                 except StandardError, e:
416                         self._errorDisplay.push_exception()
417
418         def _on_paste(self, *args):
419                 try:
420                         entry = self._taskNameEntry.get_text()
421                         entry += self._clipboard.wait_for_text()
422                         self._taskNameEntry.set_text(entry)
423                 except StandardError, e:
424                         self._errorDisplay.push_exception()
425
426         def _on_clear(self, *args):
427                 try:
428                         self._taskNameEntry.set_text("")
429                 except StandardError, e:
430                         self._errorDisplay.push_exception()
431
432
433 class GtkRtMilk(object):
434
435         def __init__(self, widgetTree, errorDisplay):
436                 """
437                 @note Thread agnostic
438                 """
439                 self._errorDisplay = errorDisplay
440                 self._manager = None
441                 self._credentials = "", "", ""
442
443                 self._projectsList = gtk.ListStore(gobject.TYPE_STRING)
444                 self._projectsCombo = widgetTree.get_widget("projectsCombo")
445                 self._projectCell = gtk.CellRendererText()
446                 self._onListActivateId = 0
447
448                 self._itemView = ItemListView(widgetTree, self._errorDisplay)
449                 addSink = coroutines.func_sink(lambda eventData: self._itemView.reset_task_list(eventData[0]))
450                 self._addView = QuickAddView(widgetTree, self._errorDisplay, addSink)
451                 self._credentialsDialog = gtk_toolbox.LoginWindow(widgetTree)
452
453         @staticmethod
454         def name():
455                 return "Remember The Milk"
456
457         def load_settings(self, config):
458                 """
459                 @note Thread Agnostic
460                 """
461                 blobs = (
462                         config.get(self.name(), "bin_blob_%i" % i)
463                         for i in xrange(len(self._credentials))
464                 )
465                 creds = (
466                         base64.b64decode(blob)
467                         for blob in blobs
468                 )
469                 self._credentials = tuple(creds)
470
471         def save_settings(self, config):
472                 """
473                 @note Thread Agnostic
474                 """
475                 config.add_section(self.name())
476                 for i, value in enumerate(self._credentials):
477                         blob = base64.b64encode(value)
478                         config.set(self.name(), "bin_blob_%i" % i, blob)
479
480         def login(self):
481                 """
482                 @note UI Thread
483                 """
484                 if self._manager is not None:
485                         return
486
487                 credentials = self._credentials
488                 while True:
489                         try:
490                                 self._manager = rtm_backend.RtmBackend(*credentials)
491                                 self._manager = cache_backend.LazyCacheBackend(self._manager)
492                                 self._credentials = credentials
493                                 return # Login succeeded
494                         except rtm_api.AuthStateMachine.NoData:
495                                 # Login failed, grab new credentials
496                                 credentials = get_credentials(self._credentialsDialog)
497
498         def logout(self):
499                 """
500                 @note Thread Agnostic
501                 """
502                 self._credentials = "", "", ""
503                 self._manager = None
504
505         def enable(self):
506                 """
507                 @note UI Thread
508                 """
509                 self._projectsList.clear()
510                 self._populate_projects()
511                 cell = self._projectCell
512                 self._projectsCombo.pack_start(cell, True)
513                 self._projectsCombo.add_attribute(cell, 'text', 0)
514
515                 currentProject = self._get_project()
516                 projId = self._manager.lookup_project(currentProject)["id"]
517                 self._addView.enable(self._manager, projId)
518                 self._itemView.enable(self._manager, projId)
519
520                 self._onListActivateId = self._projectsCombo.connect("changed", self._on_list_activate)
521
522         def disable(self):
523                 """
524                 @note UI Thread
525                 """
526                 self._projectsCombo.disconnect(self._onListActivateId)
527                 self._onListActivateId = 0
528
529                 self._addView.disable()
530                 self._itemView.disable()
531
532                 self._projectsList.clear()
533                 self._projectsCombo.set_model(None)
534
535         def _populate_projects(self):
536                 projects = self._manager.get_projects()
537                 sortedProjects = project_sort_by_type(projects)
538                 for project in sortedProjects:
539                         projectName = project["name"]
540                         isVisible = project["isVisible"]
541                         row = (projectName, )
542                         if isVisible:
543                                 self._projectsList.append(row)
544                 self._projectsCombo.set_model(self._projectsList)
545                 self._projectsCombo.set_active(0)
546
547         def _reset_task_list(self):
548                 projectName = self._get_project()
549                 projId = self._manager.lookup_project(projectName)["id"]
550                 self._addView.reset_task_list(projId)
551                 self._itemView.reset_task_list(projId)
552
553         def _get_project(self):
554                 currentProjectName = self._projectsCombo.get_active_text()
555                 return currentProjectName
556
557         def _on_list_activate(self, *args):
558                 try:
559                         self._reset_task_list()
560                 except StandardError, e:
561                         self._errorDisplay.push_exception()