4c704c02f5cfe9fc8b7359d0a373b27726405136
[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 GtkRtMilk(object):
326
327         def __init__(self, widgetTree, errorDisplay):
328                 """
329                 @note Thread agnostic
330                 """
331                 self._errorDisplay = errorDisplay
332                 self._manager = None
333                 self._credentials = "", "", ""
334
335                 self._editDialog = gtk_toolbox.EditTaskDialog(widgetTree)
336                 self._projDialog = gtk_toolbox.ProjectsDialog(widgetTree)
337                 self._projectsList = gtk.ListStore(gobject.TYPE_STRING)
338                 self._projectsCombo = widgetTree.get_widget("projectsCombo")
339                 self._projectCell = gtk.CellRendererText()
340                 self._onListActivateId = 0
341
342                 self._projectMenuItem = widgetTree.get_widget("projectMenuItem")
343                 self._onProjectMenuItemActivated = 0
344
345                 self._itemView = ItemListView(widgetTree, self._errorDisplay)
346                 addSink = coroutines.CoSwitch(["add", "add-edit"])
347                 addSink.register_sink("add", coroutines.func_sink(self._on_add))
348                 addSink.register_sink("add-edit", coroutines.func_sink(self._on_add_edit))
349                 self._addView = gtk_toolbox.QuickAddView(widgetTree, self._errorDisplay, addSink, "add")
350                 self._credentialsDialog = gtk_toolbox.LoginWindow(widgetTree)
351
352         @staticmethod
353         def name():
354                 return "Remember The Milk"
355
356         def load_settings(self, config):
357                 """
358                 @note Thread Agnostic
359                 """
360                 blobs = (
361                         config.get(self.name(), "bin_blob_%i" % i)
362                         for i in xrange(len(self._credentials))
363                 )
364                 creds = (
365                         base64.b64decode(blob)
366                         for blob in blobs
367                 )
368                 self._credentials = tuple(creds)
369
370         def save_settings(self, config):
371                 """
372                 @note Thread Agnostic
373                 """
374                 config.add_section(self.name())
375                 for i, value in enumerate(self._credentials):
376                         blob = base64.b64encode(value)
377                         config.set(self.name(), "bin_blob_%i" % i, blob)
378
379         def login(self):
380                 """
381                 @note UI Thread
382                 """
383                 if self._manager is not None:
384                         return
385
386                 credentials = self._credentials
387                 while True:
388                         try:
389                                 self._manager = rtm_backend.RtmBackend(*credentials)
390                                 # self._manager = cache_backend.LazyCacheBackend(self._manager)
391                                 self._credentials = credentials
392                                 return # Login succeeded
393                         except rtm_api.AuthStateMachine.NoData:
394                                 # Login failed, grab new credentials
395                                 credentials = get_credentials(self._credentialsDialog)
396
397         def logout(self):
398                 """
399                 @note Thread Agnostic
400                 """
401                 self._credentials = "", "", ""
402                 self._manager = None
403
404         def enable(self):
405                 """
406                 @note UI Thread
407                 """
408                 self._projectsList.clear()
409                 self._populate_projects()
410                 cell = self._projectCell
411                 self._projectsCombo.pack_start(cell, True)
412                 self._projectsCombo.add_attribute(cell, 'text', 0)
413
414                 currentProject = self._get_project()
415                 projId = self._manager.lookup_project(currentProject)["id"]
416                 self._addView.enable(self._manager)
417                 self._itemView.enable(self._manager, projId)
418
419                 self._onListActivateId = self._projectsCombo.connect("changed", self._on_list_activate)
420                 self._onProjectMenuItemActivated = self._projectMenuItem.connect("activate", self._on_proj_activate)
421
422         def disable(self):
423                 """
424                 @note UI Thread
425                 """
426                 self._projectsCombo.disconnect(self._onListActivateId)
427                 self._projectMenuItem.disconnect(self._onProjectMenuItemActivated)
428                 self._onListActivateId = 0
429
430                 self._addView.disable()
431                 self._itemView.disable()
432
433                 self._projectsList.clear()
434                 self._projectsCombo.set_model(None)
435
436         def _populate_projects(self):
437                 projects = self._manager.get_projects()
438                 sortedProjects = project_sort_by_type(projects)
439                 for project in sortedProjects:
440                         projectName = project["name"]
441                         isVisible = project["isVisible"]
442                         row = (projectName, )
443                         if isVisible:
444                                 self._projectsList.append(row)
445                 self._projectsCombo.set_model(self._projectsList)
446                 self._projectsCombo.set_active(0)
447
448         def _reset_task_list(self):
449                 projectName = self._get_project()
450                 projId = self._manager.lookup_project(projectName)["id"]
451                 self._itemView.reset_task_list(projId)
452
453                 isMeta = self._manager.get_project(projId)["isMeta"]
454                 # @todo RTM handles this by defaulting to a specific list
455                 self._addView.set_addability(not isMeta)
456
457         def _get_project(self):
458                 currentProjectName = self._projectsCombo.get_active_text()
459                 return currentProjectName
460
461         def _on_list_activate(self, *args):
462                 try:
463                         self._reset_task_list()
464                 except StandardError, e:
465                         self._errorDisplay.push_exception()
466
467         def _on_add(self, eventData):
468                 eventName, taskName, = eventData
469                 projectName = self._get_project()
470                 projId = self._manager.lookup_project(projectName)["id"]
471
472                 taskId = self._manager.add_task(projId, taskName)
473
474                 self._itemView.reset_task_list(projId)
475
476         def _on_add_edit(self, eventData):
477                 eventName, taskName, = eventData
478                 projectName = self._get_project()
479                 projId = self._manager.lookup_project(projectName)["id"]
480
481                 taskId = self._manager.add_task(projId, taskName)
482
483                 self._editDialog.enable(self._manager)
484                 try:
485                         self._editDialog.request_task(self._manager, taskId)
486                 finally:
487                         self._editDialog.disable()
488                 self._itemView.reset_task_list(projId)
489
490         def _on_proj_activate(self, *args):
491                 self._projDialog.enable(self._manager)