1749e0e8d9dcffcba8c5e7d0628d7a3c89763a3d
[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         @bug Not all columns are visible on maemo
133         """
134
135         ID_IDX = 0
136         COMPLETION_IDX = 1
137         NAME_IDX = 2
138         PRIORITY_IDX = 3
139         DUE_IDX = 4
140         FUZZY_IDX = 5
141         LINK_IDX = 6
142         NOTES_IDX = 7
143
144         def __init__(self, widgetTree, errorDisplay):
145                 self._errorDisplay = errorDisplay
146                 self._manager = None
147                 self._projId = None
148                 self._showCompleted = False
149                 self._showIncomplete = True
150
151                 self._editDialog = gtk_toolbox.EditTaskDialog(widgetTree)
152                 self._notesDialog = gtk_toolbox.NotesDialog(widgetTree)
153
154                 self._itemList = gtk.ListStore(
155                         gobject.TYPE_STRING, # id
156                         gobject.TYPE_BOOLEAN, # is complete
157                         gobject.TYPE_STRING, # name
158                         gobject.TYPE_STRING, # priority
159                         gobject.TYPE_STRING, # due
160                         gobject.TYPE_STRING, # fuzzy due
161                         gobject.TYPE_STRING, # Link
162                         gobject.TYPE_STRING, # Notes
163                 )
164                 self._completionColumn = gtk.TreeViewColumn('') # Complete?
165                 self._completionCell = gtk.CellRendererToggle()
166                 self._completionCell.set_property("activatable", True)
167                 self._completionCell.connect("toggled", self._on_completion_change)
168                 self._completionColumn.pack_start(self._completionCell, False)
169                 self._completionColumn.set_attributes(self._completionCell, active=self.COMPLETION_IDX)
170                 self._priorityColumn = gtk.TreeViewColumn('') # Priority
171                 self._priorityCell = gtk.CellRendererText()
172                 self._priorityColumn.pack_start(self._priorityCell, False)
173                 self._priorityColumn.set_attributes(self._priorityCell, text=self.PRIORITY_IDX)
174                 self._nameColumn = gtk.TreeViewColumn('Name')
175                 self._nameCell = gtk.CellRendererText()
176                 self._nameColumn.pack_start(self._nameCell, True)
177                 self._nameColumn.set_attributes(self._nameCell, text=self.NAME_IDX)
178                 self._nameColumn.set_expand(True)
179                 self._dueColumn = gtk.TreeViewColumn('Due')
180                 self._dueCell = gtk.CellRendererText()
181                 self._dueColumn.pack_start(self._nameCell, False)
182                 self._dueColumn.set_attributes(self._nameCell, text=self.FUZZY_IDX)
183                 self._linkColumn = gtk.TreeViewColumn('') # Link
184                 self._linkCell = gtk.CellRendererText()
185                 self._linkColumn.pack_start(self._nameCell, False)
186                 self._linkColumn.set_attributes(self._nameCell, text=self.LINK_IDX)
187                 self._notesColumn = gtk.TreeViewColumn('Notes') # Notes
188                 self._notesCell = gtk.CellRendererText()
189                 self._notesColumn.pack_start(self._nameCell, False)
190                 self._notesColumn.set_attributes(self._nameCell, text=self.NOTES_IDX)
191
192                 self._todoBox = widgetTree.get_widget("todoBox")
193                 self._todoItemScroll = gtk.ScrolledWindow()
194                 self._todoItemScroll.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
195                 self._todoItemTree = gtk.TreeView()
196                 self._todoItemTree.set_headers_visible(True)
197                 self._todoItemTree.set_rules_hint(True)
198                 self._todoItemTree.set_search_column(self.NAME_IDX)
199                 self._todoItemTree.set_enable_search(True)
200                 self._todoItemTree.append_column(self._completionColumn)
201                 self._todoItemTree.append_column(self._priorityColumn)
202                 self._todoItemTree.append_column(self._nameColumn)
203                 self._todoItemTree.append_column(self._dueColumn)
204                 self._todoItemTree.append_column(self._linkColumn)
205                 self._todoItemTree.append_column(self._notesColumn)
206                 self._todoItemTree.connect("row-activated", self._on_item_select)
207                 self._todoItemScroll.add(self._todoItemTree)
208
209         def enable(self, manager, projId):
210                 self._manager = manager
211                 self._projId = projId
212
213                 self._todoBox.pack_start(self._todoItemScroll)
214                 self._todoItemScroll.show_all()
215
216                 self._itemList.clear()
217                 try:
218                         self.reset_task_list(self._projId)
219                 except StandardError, e:
220                         self._errorDisplay.push_exception()
221
222         def disable(self):
223                 self._manager = None
224                 self._projId = None
225
226                 self._todoBox.remove(self._todoItemScroll)
227                 self._todoItemScroll.hide_all()
228
229                 self._itemList.clear()
230                 self._itemList.set_model(None)
231
232         def reset_task_list(self, projId):
233                 self._projId = projId
234                 self._itemList.clear()
235                 self._populate_items()
236
237         def _populate_items(self):
238                 projId = self._projId
239                 rawTasks = self._manager.get_tasks_with_details(projId)
240                 filteredTasks = (
241                         taskDetails
242                         for taskDetails in rawTasks
243                                 if self._showCompleted and taskDetails["isCompleted"] or self._showIncomplete and not taskDetails["isCompleted"]
244                 )
245                 # filteredTasks = (taskDetails for taskDetails in filteredTasks if item_in_agenda(taskDetails))
246                 sortedTasks = item_sort_by_priority_then_date(filteredTasks)
247                 # sortedTasks = item_sort_by_date_then_priority(filteredTasks)
248                 # sortedTasks = item_sort_by_fuzzydate_then_priority(filteredTasks)
249                 for taskDetails in sortedTasks:
250                         id = taskDetails["id"]
251                         isCompleted = taskDetails["isCompleted"]
252                         name = abbreviate(taskDetails["name"], 100)
253                         priority = str(taskDetails["priority"].get_nothrow(""))
254                         if taskDetails["dueDate"].is_good():
255                                 dueDate = taskDetails["dueDate"].get()
256                                 dueDescription = dueDate.strftime("%Y-%m-%d %H:%M:%S")
257                                 fuzzyDue = toolbox.to_fuzzy_date(dueDate)
258                         else:
259                                 dueDescription = ""
260                                 fuzzyDue = ""
261
262                         linkDisplay = taskDetails["url"]
263                         linkDisplay = abbreviate_url(linkDisplay, 20, 10)
264
265                         notes = taskDetails["notes"]
266                         notesDisplay = "%d Notes" % len(notes) if notes else ""
267
268                         row = (id, isCompleted, name, priority, dueDescription, fuzzyDue, linkDisplay, notesDisplay)
269                         self._itemList.append(row)
270                 self._todoItemTree.set_model(self._itemList)
271
272         def _on_item_select(self, treeView, path, viewColumn):
273                 try:
274                         # @todo See if there is a way to use the new gtk_toolbox.ContextHandler
275                         taskId = self._itemList[path[0]][self.ID_IDX]
276
277                         if viewColumn is self._priorityColumn:
278                                 pass
279                         elif viewColumn is self._nameColumn:
280                                 self._editDialog.enable(self._manager)
281                                 try:
282                                         self._editDialog.request_task(self._manager, taskId)
283                                 finally:
284                                         self._editDialog.disable()
285                                 self.reset_task_list(self._projId)
286                         elif viewColumn is self._dueColumn:
287                                 due = self._manager.get_task_details(taskId)["dueDate"]
288                                 if due.is_good():
289                                         calendar = gtk_toolbox.PopupCalendar(None, due.get(), "Due Date")
290                                         calendar.run()
291                         elif viewColumn is self._linkColumn:
292                                 webbrowser.open(self._manager.get_task_details(taskId)["url"])
293                         elif viewColumn is self._notesColumn:
294                                 self._notesDialog.enable()
295                                 try:
296                                         self._notesDialog.run(self._manager, taskId)
297                                 finally:
298                                         self._notesDialog.disable()
299                 except StandardError, e:
300                         self._errorDisplay.push_exception()
301
302         def _on_completion_change(self, cell, path):
303                 try:
304                         taskId = self._itemList[path[0]][self.ID_IDX]
305                         self._manager.complete_task(taskId)
306                         self.reset_task_list(self._projId)
307                 except StandardError, e:
308                         self._errorDisplay.push_exception()
309
310
311 class QuickAddView(object):
312
313         def __init__(self, widgetTree, errorDisplay, addSink):
314                 self._errorDisplay = errorDisplay
315                 self._manager = None
316                 self._projId = None
317                 self._addSink = addSink
318
319                 self._clipboard = gtk.clipboard_get()
320                 self._editDialog = gtk_toolbox.EditTaskDialog(widgetTree)
321
322                 self._taskNameEntry = widgetTree.get_widget("add-taskNameEntry")
323                 self._addTaskButton = widgetTree.get_widget("add-addTaskButton")
324                 self._pasteTaskNameButton = widgetTree.get_widget("add-pasteTaskNameButton")
325                 self._clearTaskNameButton = widgetTree.get_widget("add-clearTaskNameButton")
326                 self._onAddId = None
327                 self._onAddClickedId = None
328                 self._onAddReleasedId = None
329                 self._addToEditTimerId = None
330                 self._onClearId = None
331                 self._onPasteId = None
332
333         def enable(self, manager, projId):
334                 self._manager = manager
335                 self._projId = projId
336
337                 self._onAddId = self._addTaskButton.connect("clicked", self._on_add)
338                 self._onAddClickedId = self._addTaskButton.connect("pressed", self._on_add_pressed)
339                 self._onAddReleasedId = self._addTaskButton.connect("released", self._on_add_released)
340                 self._onPasteId = self._pasteTaskNameButton.connect("clicked", self._on_paste)
341                 self._onClearId = self._clearTaskNameButton.connect("clicked", self._on_clear)
342
343         def disable(self):
344                 self._manager = None
345                 self._projId = None
346
347                 self._addTaskButton.disconnect(self._onAddId)
348                 self._addTaskButton.disconnect(self._onAddClickedId)
349                 self._addTaskButton.disconnect(self._onAddReleasedId)
350                 self._pasteTaskNameButton.disconnect(self._onPasteId)
351                 self._clearTaskNameButton.disconnect(self._onClearId)
352
353         def reset_task_list(self, projId):
354                 self._projId = projId
355                 isMeta = self._manager.get_project(self._projId)["isMeta"]
356                 # @todo RTM handles this by defaulting to a specific list
357                 self._addTaskButton.set_sensitive(not isMeta)
358
359         def _on_add(self, *args):
360                 try:
361                         name = self._taskNameEntry.get_text()
362
363                         projId = self._projId
364                         taskId = self._manager.add_task(projId, name)
365
366                         self._taskNameEntry.set_text("")
367                         self._addSink.send((projId, taskId))
368                 except StandardError, e:
369                         self._errorDisplay.push_exception()
370
371         def _on_add_edit(self, *args):
372                 try:
373                         name = self._taskNameEntry.get_text()
374
375                         projId = self._projId
376                         taskId = self._manager.add_task(projId, name)
377
378                         try:
379                                 self._editDialog.enable(self._manager)
380                                 try:
381                                         self._editDialog.request_task(self._manager, taskId)
382                                 finally:
383                                         self._editDialog.disable()
384                         finally:
385                                 self._taskNameEntry.set_text("")
386                                 self._addSink.send((projId, taskId))
387                 except StandardError, e:
388                         self._errorDisplay.push_exception()
389
390         def _on_add_pressed(self, widget):
391                 try:
392                         self._addToEditTimerId = gobject.timeout_add(1000, self._on_add_edit)
393                 except StandardError, e:
394                         self._errorDisplay.push_exception()
395
396         def _on_add_released(self, widget):
397                 try:
398                         if self._addToEditTimerId is not None:
399                                 gobject.source_remove(self._addToEditTimerId)
400                         self._addToEditTimerId = None
401                 except StandardError, e:
402                         self._errorDisplay.push_exception()
403
404         def _on_paste(self, *args):
405                 try:
406                         entry = self._taskNameEntry.get_text()
407                         entry += self._clipboard.wait_for_text()
408                         self._taskNameEntry.set_text(entry)
409                 except StandardError, e:
410                         self._errorDisplay.push_exception()
411
412         def _on_clear(self, *args):
413                 try:
414                         self._taskNameEntry.set_text("")
415                 except StandardError, e:
416                         self._errorDisplay.push_exception()
417
418
419 class GtkRtMilk(object):
420
421         def __init__(self, widgetTree, errorDisplay):
422                 """
423                 @note Thread agnostic
424                 """
425                 self._errorDisplay = errorDisplay
426                 self._manager = None
427                 self._credentials = "", "", ""
428
429                 self._projectsList = gtk.ListStore(gobject.TYPE_STRING)
430                 self._projectsCombo = widgetTree.get_widget("projectsCombo")
431                 self._onListActivateId = 0
432
433                 self._itemView = ItemListView(widgetTree, self._errorDisplay)
434                 addSink = coroutines.func_sink(lambda eventData: self._itemView.reset_task_list(eventData[0]))
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.RtMilkManager(*credentials)
476                                 self._credentials = credentials
477                                 return # Login succeeded
478                         except rtm_api.AuthStateMachine.NoData:
479                                 # Login failed, grab new credentials
480                                 credentials = get_credentials(self._credentialsDialog)
481
482         def logout(self):
483                 """
484                 @note Thread Agnostic
485                 """
486                 self._credentials = "", "", ""
487                 self._manager = None
488
489         def enable(self):
490                 """
491                 @note UI Thread
492                 """
493                 self._projectsList.clear()
494                 self._populate_projects()
495
496                 currentProject = self._get_project()
497                 projId = self._manager.lookup_project(currentProject)["id"]
498                 self._addView.enable(self._manager, projId)
499                 self._itemView.enable(self._manager, projId)
500
501                 self._onListActivateId = self._projectsCombo.connect("changed", self._on_list_activate)
502
503         def disable(self):
504                 """
505                 @note UI Thread
506                 """
507                 self._projectsCombo.disconnect(self._onListActivateId)
508
509                 self._addView.disable()
510                 self._itemView.disable()
511
512                 self._projectsList.clear()
513                 self._projectsCombo.set_model(None)
514                 self._projectsCombo.disconnect("changed", self._on_list_activate)
515
516                 self._manager = None
517
518         def _populate_projects(self):
519                 for project in self._manager.get_projects():
520                         projectName = project["name"]
521                         isVisible = project["isVisible"]
522                         row = (projectName, )
523                         if isVisible:
524                                 self._projectsList.append(row)
525                 self._projectsCombo.set_model(self._projectsList)
526                 cell = gtk.CellRendererText()
527                 self._projectsCombo.pack_start(cell, True)
528                 self._projectsCombo.add_attribute(cell, 'text', 0)
529                 self._projectsCombo.set_active(0)
530
531         def _reset_task_list(self):
532                 projectName = self._get_project()
533                 projId = self._manager.lookup_project(projectName)["id"]
534                 self._addView.reset_task_list(projId)
535                 self._itemView.reset_task_list(projId)
536
537         def _get_project(self):
538                 currentProjectName = self._projectsCombo.get_active_text()
539                 return currentProjectName
540
541         def _on_list_activate(self, *args):
542                 try:
543                         self._reset_task_list()
544                 except StandardError, e:
545                         self._errorDisplay.push_exception()