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