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