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