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