~saurabhanandiit/gtg/exportFixed

« back to all changes in this revision

Viewing changes to GTG/backends/backend_rtm.py

Merge of my work on liblarch newbase and all the backends ported to liblarch
(which mainly means porting the datastore).
One failing test, will check it.

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# -*- coding: utf-8 -*-
 
2
# -----------------------------------------------------------------------------
 
3
# Getting Things Gnome! - a personal organizer for the GNOME desktop
 
4
# Copyright (c) 2008-2009 - Lionel Dricot & Bertrand Rousseau
 
5
#
 
6
# This program is free software: you can redistribute it and/or modify it under
 
7
# the terms of the GNU General Public License as published by the Free Software
 
8
# Foundation, either version 3 of the License, or (at your option) any later
 
9
# version.
 
10
#
 
11
# This program is distributed in the hope that it will be useful, but WITHOUT
 
12
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 
13
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
 
14
# details.
 
15
#
 
16
# You should have received a copy of the GNU General Public License along with
 
17
# this program.  If not, see <http://www.gnu.org/licenses/>.
 
18
# -----------------------------------------------------------------------------
 
19
 
 
20
'''
 
21
Remember the milk backend
 
22
'''
 
23
 
 
24
import os
 
25
import cgi
 
26
import uuid
 
27
import time
 
28
import threading
 
29
import datetime
 
30
import subprocess
 
31
import exceptions
 
32
from dateutil.tz                        import tzutc, tzlocal
 
33
 
 
34
from GTG.backends.genericbackend        import GenericBackend
 
35
from GTG                                import _
 
36
from GTG.backends.backendsignals        import BackendSignals
 
37
from GTG.backends.syncengine            import SyncEngine, SyncMeme
 
38
from GTG.backends.rtm.rtm               import createRTM, RTMError, RTMAPIError
 
39
from GTG.backends.periodicimportbackend import PeriodicImportBackend
 
40
from GTG.tools.dates                    import RealDate, NoDate
 
41
from GTG.core.task                      import Task
 
42
from GTG.tools.interruptible            import interruptible
 
43
from GTG.tools.logger                   import Log
 
44
 
 
45
 
 
46
 
 
47
 
 
48
 
 
49
class Backend(PeriodicImportBackend):
 
50
    
 
51
 
 
52
    _general_description = { \
 
53
        GenericBackend.BACKEND_NAME:       "backend_rtm", \
 
54
        GenericBackend.BACKEND_HUMAN_NAME: _("Remember The Milk"), \
 
55
        GenericBackend.BACKEND_AUTHORS:    ["Luca Invernizzi"], \
 
56
        GenericBackend.BACKEND_TYPE:       GenericBackend.TYPE_READWRITE, \
 
57
        GenericBackend.BACKEND_DESCRIPTION: \
 
58
            _("This backend synchronizes your tasks with the web service"
 
59
              " RememberTheMilk:\n\t\thttp://rememberthemilk.com\n\n"
 
60
              "Note: This product uses the Remember The Milk API but is not"
 
61
              " endorsed or certified by Remember The Milk"),\
 
62
        }
 
63
 
 
64
    _static_parameters = { \
 
65
        "period": { \
 
66
            GenericBackend.PARAM_TYPE: GenericBackend.TYPE_INT, \
 
67
            GenericBackend.PARAM_DEFAULT_VALUE: 10, },
 
68
        "is-first-run": { \
 
69
            GenericBackend.PARAM_TYPE: GenericBackend.TYPE_BOOL, \
 
70
            GenericBackend.PARAM_DEFAULT_VALUE: True, },
 
71
        }
 
72
 
 
73
###############################################################################
 
74
### Backend standard methods ##################################################
 
75
###############################################################################
 
76
 
 
77
    def __init__(self, parameters):
 
78
        '''
 
79
        See GenericBackend for an explanation of this function.
 
80
        Loads the saved state of the sync, if any
 
81
        '''
 
82
        super(Backend, self).__init__(parameters)
 
83
        #loading the saved state of the synchronization, if any
 
84
        self.sync_engine_path = os.path.join('backends/rtm/', \
 
85
                                      "sync_engine-" + self.get_id())
 
86
        self.sync_engine = self._load_pickled_file(self.sync_engine_path, \
 
87
                                                   SyncEngine())
 
88
        #reloading the oauth authentication token, if any
 
89
        self.token_path = os.path.join('backends/rtm/', \
 
90
                                      "auth_token-" + self.get_id())
 
91
        self.token = self._load_pickled_file(self.token_path, None)
 
92
        self.enqueued_start_get_task = False
 
93
        self.login_event = threading.Event()
 
94
        self._this_is_the_first_loop = True
 
95
        
 
96
    def initialize(self):
 
97
        """
 
98
        See GenericBackend for an explanation of this function.
 
99
        """
 
100
        super(Backend, self).initialize()
 
101
        self.rtm_proxy = RTMProxy(self._ask_user_to_confirm_authentication,
 
102
                                  self.token)
 
103
 
 
104
    def save_state(self):
 
105
        """
 
106
        See GenericBackend for an explanation of this function.
 
107
        """
 
108
        self._store_pickled_file(self.sync_engine_path, self.sync_engine)
 
109
 
 
110
    def _ask_user_to_confirm_authentication(self):
 
111
        '''
 
112
        Calls for a user interaction during authentication
 
113
        '''
 
114
        self.login_event.clear()
 
115
        BackendSignals().interaction_requested(self.get_id(),
 
116
            "You need to authenticate to Remember The Milk. A browser"
 
117
            " is opening with a login page.\n When you have "
 
118
            " logged in and given GTG the requested permissions,\n"
 
119
            " press the 'Confirm' button", \
 
120
            BackendSignals().INTERACTION_CONFIRM, \
 
121
            "on_login")
 
122
        self.login_event.wait()
 
123
        
 
124
    def on_login(self):
 
125
        '''
 
126
        Called when the user confirms the login
 
127
        '''
 
128
        self.login_event.set()
 
129
 
 
130
###############################################################################
 
131
### TWO WAY SYNC ##############################################################
 
132
###############################################################################
 
133
 
 
134
    def do_periodic_import(self):
 
135
        """
 
136
        See PeriodicImportBackend for an explanation of this function.
 
137
        """
 
138
 
 
139
        #we get the old list of synced tasks, and compare with the new tasks set
 
140
        stored_rtm_task_ids = self.sync_engine.get_all_remote()
 
141
        current_rtm_task_ids = [tid for tid in \
 
142
                            self.rtm_proxy.get_rtm_tasks_dict().iterkeys()]
 
143
 
 
144
        if self._this_is_the_first_loop:
 
145
            self._on_successful_authentication()
 
146
 
 
147
        #If it's the very first time the backend is run, it's possible that the
 
148
        # user already synced his tasks in some way (but we don't know that).
 
149
        # Therefore, we attempt to induce those tasks relationships matching the
 
150
        # titles.
 
151
        if self._parameters["is-first-run"]:
 
152
            gtg_titles_dic = {}
 
153
            for tid in self.datastore.get_all_tasks():
 
154
                gtg_task = self.datastore.get_task(tid)
 
155
                if not self._gtg_task_is_syncable_per_attached_tags(gtg_task):
 
156
                    continue
 
157
                gtg_title = gtg_task.get_title()
 
158
                if gtg_titles_dic.has_key(gtg_title):
 
159
                    gtg_titles_dic[gtg_task.get_title()].append(tid)
 
160
                else:
 
161
                    gtg_titles_dic[gtg_task.get_title()] = [tid]
 
162
            for rtm_task_id in current_rtm_task_ids:
 
163
                rtm_task = self.rtm_proxy.get_rtm_tasks_dict()[rtm_task_id]
 
164
                try:
 
165
                    tids = gtg_titles_dic[rtm_task.get_title()]
 
166
                    #we remove the tid, so that it can't be linked to two
 
167
                    # different rtm tasks
 
168
                    tid = tids.pop()
 
169
                    gtg_task = self.datastore.get_task(tid)
 
170
                    meme = SyncMeme(gtg_task.get_modified(),
 
171
                                    rtm_task.get_modified(),
 
172
                                    "GTG")
 
173
                    self.sync_engine.record_relationship( \
 
174
                         local_id = tid,
 
175
                         remote_id = rtm_task.get_id(),
 
176
                         meme = meme)
 
177
                except KeyError:
 
178
                    pass
 
179
            #a first run has been completed successfully
 
180
            self._parameters["is-first-run"] = False
 
181
 
 
182
        for rtm_task_id in current_rtm_task_ids:
 
183
            self.cancellation_point()
 
184
            #Adding and updating
 
185
            self._process_rtm_task(rtm_task_id)
 
186
 
 
187
        for rtm_task_id in set(stored_rtm_task_ids).difference(\
 
188
                                        set(current_rtm_task_ids)):
 
189
            self.cancellation_point()
 
190
            #Removing the old ones
 
191
            if not self.please_quit:
 
192
                tid = self.sync_engine.get_local_id(rtm_task_id)
 
193
                self.datastore.request_task_deletion(tid)
 
194
                try:
 
195
                    self.sync_engine.break_relationship(remote_id = \
 
196
                                                        rtm_task_id)
 
197
                    self.save_state()
 
198
                except KeyError:
 
199
                    pass
 
200
 
 
201
    def _on_successful_authentication(self):
 
202
        '''
 
203
        Saves the token and requests a full flush on first autentication
 
204
        '''
 
205
        self._this_is_the_first_loop = False
 
206
        self._store_pickled_file(self.token_path,
 
207
                             self.rtm_proxy.get_auth_token())
 
208
        #we ask the Datastore to flush all the tasks on us
 
209
        threading.Timer(10,
 
210
                        self.datastore.flush_all_tasks,
 
211
                        args =(self.get_id(),)).start()
 
212
 
 
213
    @interruptible
 
214
    def remove_task(self, tid):
 
215
        """
 
216
        See GenericBackend for an explanation of this function.
 
217
        """
 
218
        if not self.rtm_proxy.is_authenticated():
 
219
            return
 
220
        self.cancellation_point()
 
221
        try:
 
222
            rtm_task_id = self.sync_engine.get_remote_id(tid)
 
223
            if rtm_task_id not in self.rtm_proxy.get_rtm_tasks_dict():
 
224
                #we might need to refresh our task cache
 
225
                self.rtm_proxy.refresh_rtm_tasks_dict()
 
226
            rtm_task = self.rtm_proxy.get_rtm_tasks_dict()[rtm_task_id]
 
227
            rtm_task.delete()
 
228
            Log.debug("removing task %s from RTM" % rtm_task_id)
 
229
        except KeyError:
 
230
            pass
 
231
            try:
 
232
                self.sync_engine.break_relationship(local_id = tid)
 
233
                self.save_state()
 
234
            except:
 
235
                pass
 
236
 
 
237
 
 
238
###############################################################################
 
239
### Process tasks #############################################################
 
240
###############################################################################
 
241
 
 
242
    @interruptible
 
243
    def set_task(self, task):
 
244
        """
 
245
        See GenericBackend for an explanation of this function.
 
246
        """
 
247
        if not self.rtm_proxy.is_authenticated():
 
248
            return
 
249
        self.cancellation_point()
 
250
        tid = task.get_id()
 
251
        is_syncable = self._gtg_task_is_syncable_per_attached_tags(task)
 
252
        action, rtm_task_id = self.sync_engine.analyze_local_id( \
 
253
                                tid, \
 
254
                                self.datastore.has_task, \
 
255
                                self.rtm_proxy.has_rtm_task, \
 
256
                                is_syncable)
 
257
        Log.debug("GTG->RTM set task (%s, %s)" % (action, is_syncable))
 
258
 
 
259
        if action == None:
 
260
            return
 
261
 
 
262
        if action == SyncEngine.ADD:
 
263
            if task.get_status() != Task.STA_ACTIVE:
 
264
                #OPTIMIZATION:
 
265
                #we don't sync tasks that have already been closed before we
 
266
                # even synced them once
 
267
                return
 
268
            try:
 
269
                rtm_task = self.rtm_proxy.create_new_rtm_task(task.get_title())
 
270
                self._populate_rtm_task(task, rtm_task)
 
271
            except:
 
272
                rtm_task.delete()
 
273
                raise
 
274
            meme = SyncMeme(task.get_modified(),
 
275
                            rtm_task.get_modified(),
 
276
                            "GTG")
 
277
            self.sync_engine.record_relationship( \
 
278
                local_id = tid, remote_id = rtm_task.get_id(), meme = meme)
 
279
 
 
280
        elif action == SyncEngine.UPDATE:
 
281
            try:
 
282
                rtm_task = self.rtm_proxy.get_rtm_tasks_dict()[rtm_task_id]
 
283
            except KeyError:
 
284
                #in this case, we don't have yet the task in our local cache
 
285
                # of what's on the rtm website
 
286
                self.rtm_proxy.refresh_rtm_tasks_dict()
 
287
                rtm_task = self.rtm_proxy.get_rtm_tasks_dict()[rtm_task_id]
 
288
            with self.datastore.get_backend_mutex():
 
289
                meme = self.sync_engine.get_meme_from_local_id(task.get_id())
 
290
                newest = meme.which_is_newest(task.get_modified(),
 
291
                                              rtm_task.get_modified())
 
292
                if newest == "local":
 
293
                    transaction_ids = []
 
294
                    try:
 
295
                        self._populate_rtm_task(task, rtm_task, transaction_ids)
 
296
                    except:
 
297
                        self.rtm_proxy.unroll_changes(transaction_ids)
 
298
                        raise
 
299
                    meme.set_remote_last_modified(rtm_task.get_modified())
 
300
                    meme.set_local_last_modified(task.get_modified())
 
301
                else:
 
302
                    #we skip saving the state
 
303
                    return
 
304
 
 
305
        elif action == SyncEngine.REMOVE:
 
306
            self.datastore.request_task_deletion(tid)
 
307
            try:
 
308
                self.sync_engine.break_relationship(local_id = tid)
 
309
            except KeyError:
 
310
                pass
 
311
 
 
312
        elif action == SyncEngine.LOST_SYNCABILITY:
 
313
            try:
 
314
                rtm_task = self.rtm_proxy.get_rtm_tasks_dict()[rtm_task_id]
 
315
            except KeyError:
 
316
                #in this case, we don't have yet the task in our local cache
 
317
                # of what's on the rtm website
 
318
                self.rtm_proxy.refresh_rtm_tasks_dict()
 
319
                rtm_task = self.rtm_proxy.get_rtm_tasks_dict()[rtm_task_id]
 
320
            self._exec_lost_syncability(tid, rtm_task)
 
321
 
 
322
            self.save_state()
 
323
 
 
324
    def _exec_lost_syncability(self, tid, rtm_task):
 
325
        '''
 
326
        Executed when a relationship between tasks loses its syncability
 
327
        property. See SyncEngine for an explanation of that.
 
328
 
 
329
        @param tid: a GTG task tid
 
330
        @param note: a RTM task
 
331
        '''
 
332
        self.cancellation_point()
 
333
        meme = self.sync_engine.get_meme_from_local_id(tid)
 
334
        #First of all, the relationship is lost
 
335
        self.sync_engine.break_relationship(local_id = tid)
 
336
        if meme.get_origin() == "GTG":
 
337
            rtm_task.delete()
 
338
        else:
 
339
            self.datastore.request_task_deletion(tid)
 
340
            
 
341
    def _process_rtm_task(self, rtm_task_id):
 
342
        '''
 
343
        Takes a rtm task id and carries out the necessary operations to
 
344
        refresh the sync state
 
345
        '''
 
346
        self.cancellation_point()
 
347
        if not self.rtm_proxy.is_authenticated():
 
348
            return
 
349
        rtm_task = self.rtm_proxy.get_rtm_tasks_dict()[rtm_task_id]
 
350
        is_syncable = self._rtm_task_is_syncable_per_attached_tags(rtm_task)
 
351
        action, tid = self.sync_engine.analyze_remote_id( \
 
352
                                             rtm_task_id,
 
353
                                             self.datastore.has_task,
 
354
                                             self.rtm_proxy.has_rtm_task,
 
355
                                             is_syncable)
 
356
        Log.debug("GTG<-RTM set task (%s, %s)" % (action, is_syncable))
 
357
 
 
358
        if action == None:
 
359
            return
 
360
 
 
361
        if action == SyncEngine.ADD:
 
362
            if rtm_task.get_status() != Task.STA_ACTIVE:
 
363
                #OPTIMIZATION:
 
364
                #we don't sync tasks that have already been closed before we
 
365
                # even saw them
 
366
                return
 
367
            tid = str(uuid.uuid4())
 
368
            task = self.datastore.task_factory(tid)
 
369
            self._populate_task(task, rtm_task)
 
370
            meme = SyncMeme(task.get_modified(),
 
371
                            rtm_task.get_modified(),
 
372
                            "RTM")
 
373
            self.sync_engine.record_relationship( \
 
374
                    local_id = tid,
 
375
                    remote_id = rtm_task_id,
 
376
                    meme = meme)
 
377
            self.datastore.push_task(task)
 
378
 
 
379
        elif action == SyncEngine.UPDATE:
 
380
            task = self.datastore.get_task(tid)
 
381
            with self.datastore.get_backend_mutex():
 
382
                meme = self.sync_engine.get_meme_from_remote_id(rtm_task_id)
 
383
                newest = meme.which_is_newest(task.get_modified(),
 
384
                                              rtm_task.get_modified())
 
385
                if newest == "remote":
 
386
                    self._populate_task(task, rtm_task)
 
387
                    meme.set_remote_last_modified(rtm_task.get_modified())
 
388
                    meme.set_local_last_modified(task.get_modified())
 
389
                else:
 
390
                    #we skip saving the state
 
391
                    return
 
392
 
 
393
        elif action == SyncEngine.REMOVE:
 
394
            try:
 
395
                rtm_task.delete()
 
396
                self.sync_engine.break_relationship(remote_id = rtm_task_id)
 
397
            except KeyError:
 
398
                pass
 
399
 
 
400
        elif action == SyncEngine.LOST_SYNCABILITY:
 
401
            self._exec_lost_syncability(tid, rtm_task)
 
402
 
 
403
        self.save_state()
 
404
 
 
405
###############################################################################
 
406
### Helper methods ############################################################
 
407
###############################################################################
 
408
 
 
409
    def _populate_task(self, task, rtm_task):
 
410
        '''
 
411
        Copies the content of a RTMTask in a Task
 
412
        '''
 
413
        task.set_title(rtm_task.get_title())
 
414
        task.set_text(rtm_task.get_text())
 
415
        task.set_due_date(rtm_task.get_due_date())
 
416
        status = rtm_task.get_status()
 
417
        if GTG_TO_RTM_STATUS[task.get_status()] != status:
 
418
            task.set_status(rtm_task.get_status())
 
419
        #tags
 
420
        tags = set(['@%s' % tag for tag in rtm_task.get_tags()])
 
421
        gtg_tags_lower = set([t.get_name().lower() for t in task.get_tags()])
 
422
        #tags to remove
 
423
        for tag in gtg_tags_lower.difference(tags):
 
424
            task.remove_tag(tag)
 
425
        #tags to add
 
426
        for tag in tags.difference(gtg_tags_lower):
 
427
            gtg_all_tags = [t.get_name() for t in \
 
428
                            self.datastore.get_all_tags()]
 
429
            matching_tags = filter(lambda t: t.lower() == tag, gtg_all_tags)
 
430
            if len(matching_tags) !=  0:
 
431
                tag = matching_tags[0]
 
432
            task.add_tag(tag)
 
433
 
 
434
    def _populate_rtm_task(self, task, rtm_task, transaction_ids = []):
 
435
        '''
 
436
        Copies the content of a Task into a RTMTask
 
437
 
 
438
        @param task: a GTG Task
 
439
        @param rtm_task: an RTMTask
 
440
        @param transaction_ids: a list to fill with transaction ids
 
441
        '''
 
442
        #Get methods of an rtm_task are fast, set are slow: therefore,
 
443
        # we try to use set as rarely as possible
 
444
 
 
445
        #first thing: the status. This way, if we are syncing a completed
 
446
        # task it doesn't linger for ten seconds in the RTM Inbox
 
447
        status = task.get_status()
 
448
        if rtm_task.get_status() != status:
 
449
            self.__call_or_retry(rtm_task.set_status, status, transaction_ids)
 
450
        title = task.get_title()
 
451
        if rtm_task.get_title() != title:
 
452
            self.__call_or_retry(rtm_task.set_title, title, transaction_ids)
 
453
        text = task.get_excerpt(strip_tags = True, strip_subtasks = True)
 
454
        if rtm_task.get_text() != text:
 
455
            self.__call_or_retry(rtm_task.set_text, text, transaction_ids)
 
456
        tags = task.get_tags_name()
 
457
        rtm_task_tags = []
 
458
        for tag in rtm_task.get_tags():
 
459
            if tag[0] != '@':
 
460
                tag = '@' + tag
 
461
            rtm_task_tags.append(tag)
 
462
        #rtm tags are lowercase only
 
463
        if rtm_task_tags != [t.lower() for t in tags]:
 
464
            self.__call_or_retry(rtm_task.set_tags, tags, transaction_ids)
 
465
        if isinstance(task.get_due_date(), NoDate):
 
466
            due_date = None
 
467
        else:
 
468
            due_date = task.get_due_date().to_py_date()
 
469
        if rtm_task.get_due_date() != due_date:
 
470
            self.__call_or_retry(rtm_task.set_due_date, due_date,
 
471
                                 transaction_ids)
 
472
 
 
473
    def __call_or_retry(self, fun, *args):
 
474
        '''
 
475
        This function cannot stand the call "fun" to fail, so it retries
 
476
        three times before giving up.
 
477
        '''
 
478
        MAX_ATTEMPTS = 3
 
479
        for i in xrange(MAX_ATTEMPTS):
 
480
            try:
 
481
                return fun(*args)
 
482
            except:
 
483
                if i >= MAX_ATTEMPTS:
 
484
                    raise
 
485
 
 
486
    def _rtm_task_is_syncable_per_attached_tags(self, rtm_task):
 
487
        '''
 
488
        Helper function which checks if the given task satisfies the filtering
 
489
        imposed by the tags attached to the backend.
 
490
        That means, if a user wants a backend to sync only tasks tagged @works,
 
491
        this function should be used to check if that is verified.
 
492
 
 
493
        @returns bool: True if the task should be synced
 
494
        '''
 
495
        attached_tags = self.get_attached_tags()
 
496
        if GenericBackend.ALLTASKS_TAG in attached_tags:
 
497
            return True
 
498
        for tag in rtm_task.get_tags():
 
499
            if "@" + tag in attached_tags:
 
500
                return  True
 
501
        return False
 
502
 
 
503
###############################################################################
 
504
### RTM PROXY #################################################################
 
505
###############################################################################
 
506
 
 
507
class RTMProxy(object):
 
508
    '''
 
509
    The purpose of this class is producing an updated list of RTMTasks.
 
510
    To do that, it handles:
 
511
        - authentication to RTM
 
512
        - keeping the list fresh
 
513
        - downloading the list
 
514
    '''
 
515
 
 
516
 
 
517
    PUBLIC_KEY = "2a440fdfe9d890c343c25a91afd84c7e"
 
518
    PRIVATE_KEY = "ca078fee48d0bbfa"
 
519
 
 
520
    def __init__(self,
 
521
                 auth_confirm_fun,
 
522
                 token = None):
 
523
        self.auth_confirm = auth_confirm_fun
 
524
        self.token = token
 
525
        self.authenticated = threading.Event()
 
526
        self.login_event = threading.Event()
 
527
        self.is_not_refreshing = threading.Event()
 
528
        self.is_not_refreshing.set()
 
529
 
 
530
    ##########################################################################
 
531
    ### AUTHENTICATION #######################################################
 
532
    ##########################################################################
 
533
 
 
534
    def start_authentication(self):
 
535
        '''
 
536
        Launches the authentication process
 
537
        '''
 
538
        initialize_thread = threading.Thread(target = self._authenticate)
 
539
        initialize_thread.setDaemon(True)
 
540
        initialize_thread.start()
 
541
    
 
542
    def is_authenticated(self):
 
543
        '''
 
544
        Returns true if we've autheticated to RTM
 
545
        '''
 
546
        return self.authenticated.isSet()
 
547
 
 
548
    def wait_for_authentication(self):
 
549
        '''
 
550
        Inhibits the thread until authentication occours
 
551
        '''
 
552
        self.authenticated.wait()
 
553
 
 
554
    def get_auth_token(self):
 
555
        '''
 
556
        Returns the oauth token, or none
 
557
        '''
 
558
        try:
 
559
            return self.token
 
560
        except:
 
561
            return None
 
562
 
 
563
    def _authenticate(self):
 
564
        '''
 
565
        authentication main function
 
566
        '''
 
567
        self.authenticated.clear()
 
568
        while not self.authenticated.isSet():
 
569
            if not self.token:
 
570
                self.rtm= createRTM(self.PUBLIC_KEY, self.PRIVATE_KEY, self.token)
 
571
                subprocess.Popen(['xdg-open', self.rtm.getAuthURL()])
 
572
                self.auth_confirm()
 
573
                try:
 
574
                    time.sleep(1)
 
575
                    self.token = self.rtm.getToken()
 
576
                except Exception, e:
 
577
                    #something went wrong.
 
578
                    self.token = None
 
579
                    continue
 
580
            try:
 
581
                if self._login():
 
582
                    self.authenticated.set()
 
583
            except exceptions.IOError, e:
 
584
                BackendSignals().backend_failed(self.get_id(), \
 
585
                            BackendSignals.ERRNO_NETWORK)
 
586
 
 
587
    def _login(self):
 
588
        '''
 
589
        Tries to establish a connection to rtm with a token got from the
 
590
        authentication process
 
591
        '''
 
592
        try:
 
593
            self.rtm = createRTM(self.PUBLIC_KEY, self.PRIVATE_KEY, self.token)
 
594
            self.timeline = self.rtm.timelines.create().timeline
 
595
            return True
 
596
        except (RTMError, RTMAPIError), e:
 
597
            Log.error("RTM ERROR" + str(e))
 
598
        return False
 
599
    
 
600
    ##########################################################################
 
601
    ### RTM TASKS HANDLING ###################################################
 
602
    ##########################################################################
 
603
 
 
604
    def unroll_changes(self, transaction_ids):
 
605
        '''
 
606
        Roll backs the changes tracked by the list of transaction_ids given
 
607
        '''
 
608
        for transaction_id in transaction_ids:
 
609
            self.rtm.transactions.undo(timeline = self.timeline,
 
610
                                       transaction_id = transaction_id)
 
611
 
 
612
    def get_rtm_tasks_dict(self):
 
613
        '''
 
614
        Returns a dict of RTMtasks. It will start authetication if necessary.
 
615
        The dict is kept updated automatically.
 
616
        '''
 
617
        if not hasattr(self, '_rtm_task_dict'):
 
618
            self.refresh_rtm_tasks_dict()
 
619
        else:
 
620
            time_difference = datetime.datetime.now() - \
 
621
                          self.__rtm_task_dict_timestamp
 
622
            if time_difference.seconds > 60:
 
623
                self.refresh_rtm_tasks_dict()
 
624
        return self._rtm_task_dict.copy()
 
625
 
 
626
    def __getattr_the_rtm_way(self, an_object, attribute):
 
627
        '''
 
628
        RTM, to compress the XML file they send to you, cuts out all the
 
629
        unnecessary stuff.
 
630
        Because of that, getting an attribute from an object must check if one
 
631
        of those optimizations has been used.
 
632
        This function always returns a list wrapping the objects found (if any).
 
633
        '''
 
634
        try:
 
635
            list_or_object = getattr(an_object, attribute)
 
636
        except AttributeError:
 
637
            return []
 
638
 
 
639
        if isinstance(list_or_object, list):
 
640
            return list_or_object
 
641
        else:
 
642
            return [list_or_object]
 
643
 
 
644
    def __get_rtm_lists(self):
 
645
        '''
 
646
        Gets the list of the RTM Lists (the tabs on the top of rtm website)
 
647
        '''
 
648
        rtm_get_list_output = self.rtm.lists.getList()
 
649
        #Here's the attributes of RTM lists. For the list of them, see
 
650
        #http://www.rememberthemilk.com/services/api/methods/rtm.lists.getList.rtm
 
651
        return self.__getattr_the_rtm_way(self.rtm.lists.getList().lists, 'list')
 
652
 
 
653
    def __get_rtm_taskseries_in_list(self, list_id):
 
654
        '''
 
655
        Gets the list of "taskseries" objects in a rtm list.
 
656
        For an explenation of what are those, see
 
657
        http://www.rememberthemilk.com/services/api/tasks.rtm
 
658
        '''
 
659
        list_object_wrapper = self.rtm.tasks.getList(list_id = list_id, \
 
660
                                filter = 'includeArchived:true').tasks
 
661
        list_object_list = self.__getattr_the_rtm_way(list_object_wrapper, 'list')
 
662
        if not list_object_list:
 
663
            return []
 
664
        #we asked for one, so we should get one
 
665
        assert(len(list_object_list), 1)
 
666
        list_object = list_object_list[0]
 
667
        #check that the given list is the correct one
 
668
        assert(list_object.id == list_id)
 
669
        return self.__getattr_the_rtm_way(list_object, 'taskseries')
 
670
 
 
671
    def refresh_rtm_tasks_dict(self):
 
672
        '''
 
673
        Builds a list of RTMTasks fetched from RTM
 
674
        '''
 
675
        if not self.is_authenticated():
 
676
            self.start_authentication()
 
677
            self.wait_for_authentication()
 
678
 
 
679
        if not self.is_not_refreshing.isSet():
 
680
            #if we're already refreshing, we just wait for that to happen and
 
681
            # then we immediately return
 
682
            self.is_not_refreshing.wait()
 
683
            return
 
684
        self.is_not_refreshing.clear()
 
685
        Log.debug('refreshing rtm')
 
686
 
 
687
        #To understand what this function does, here's a sample output of the
 
688
        #plain getLists() from RTM api:
 
689
        #    http://www.rememberthemilk.com/services/api/tasks.rtm
 
690
 
 
691
        #our purpose is to fill this with "tasks_id: RTMTask" items
 
692
        rtm_tasks_dict = {}
 
693
 
 
694
        rtm_lists_list = self.__get_rtm_lists()
 
695
        #for each rtm list, we retrieve all the tasks in it
 
696
        for rtm_list in rtm_lists_list:
 
697
            if rtm_list.archived != '0' or rtm_list.smart != '0':
 
698
                #we skip archived and smart lists
 
699
                continue
 
700
            rtm_taskseries_list = self.__get_rtm_taskseries_in_list(rtm_list.id)
 
701
            for rtm_taskseries in rtm_taskseries_list:
 
702
                #we drill down to actual tasks
 
703
                rtm_tasks_list = self.__getattr_the_rtm_way(rtm_taskseries, 'task')
 
704
                for rtm_task in rtm_tasks_list:
 
705
                    rtm_tasks_dict[rtm_task.id] = RTMTask(rtm_task,
 
706
                                                          rtm_taskseries,
 
707
                                                          rtm_list,
 
708
                                                          self.rtm,
 
709
                                                          self.timeline)
 
710
 
 
711
        #we're done: we store the dict in this class and we annotate the time we
 
712
        # got it
 
713
        self._rtm_task_dict = rtm_tasks_dict
 
714
        self.__rtm_task_dict_timestamp = datetime.datetime.now()
 
715
        self.is_not_refreshing.set()
 
716
 
 
717
    def has_rtm_task(self, rtm_task_id):
 
718
        '''
 
719
        Returns True if we have seen that task id
 
720
        '''
 
721
        cache_result = rtm_task_id in self.get_rtm_tasks_dict()
 
722
        return cache_result
 
723
        #it may happen that the rtm_task is on the website but we haven't
 
724
        #downloaded it yet. We need to update the local cache.
 
725
 
 
726
        #it's a big speed loss. Let's see if we can avoid it.
 
727
        #self.refresh_rtm_tasks_dict()
 
728
        #return rtm_task_id in self.get_rtm_tasks_dict()
 
729
 
 
730
    def create_new_rtm_task(self, title, transaction_ids = []):
 
731
        '''
 
732
        Creates a new rtm task
 
733
        '''
 
734
        result = self.rtm.tasks.add(timeline = self.timeline,  name = title)
 
735
        rtm_task = RTMTask(result.list.taskseries.task,
 
736
                           result.list.taskseries,
 
737
                           result.list,
 
738
                           self.rtm,
 
739
                           self.timeline)
 
740
        #adding to the dict right away
 
741
        if hasattr(self, '_rtm_task_dict'):
 
742
            #if the list hasn't been downloaded yet, we do not create a list,
 
743
            # because the fact that the list is created is used to keep track of
 
744
            # list updates
 
745
            self._rtm_task_dict[rtm_task.get_id()] = rtm_task
 
746
        transaction_ids.append(result.transaction.id)
 
747
        return rtm_task
 
748
 
 
749
 
 
750
 
 
751
###############################################################################
 
752
### RTM TASK ##################################################################
 
753
###############################################################################
 
754
 
 
755
#dictionaries to translate a RTM status into a GTG one (and back)
 
756
GTG_TO_RTM_STATUS = {Task.STA_ACTIVE: True,
 
757
                     Task.STA_DONE: False,
 
758
                     Task.STA_DISMISSED: False}
 
759
 
 
760
RTM_TO_GTG_STATUS = {True: Task.STA_ACTIVE,
 
761
                     False: Task.STA_DONE}
 
762
 
 
763
 
 
764
 
 
765
class RTMTask(object):
 
766
    '''
 
767
    A proxy object that encapsulates a RTM task, giving an easier API to access
 
768
    and modify its attributes.
 
769
    This backend already uses a library to interact with RTM, but that is just a
 
770
    thin proxy for HTML gets and posts.
 
771
    The meaning of all "special words"
 
772
        http://www.rememberthemilk.com/services/api/tasks.rtm
 
773
    '''
 
774
    
 
775
 
 
776
    def __init__(self, rtm_task, rtm_taskseries, rtm_list, rtm, timeline):
 
777
        '''
 
778
        sets up the various parameters needed to interact with a task.
 
779
 
 
780
         @param task: the task object given by the underlying library
 
781
         @param rtm_list: the rtm list the task resides in.
 
782
         @param rtm_taskseries: all the tasks are encapsulated in a taskseries
 
783
                               object. From RTM website:
 
784
                               "A task series is a grouping of tasks generated
 
785
                               by a recurrence pattern (more specifically, a
 
786
                               recurrence pattern of type every – an after type
 
787
                               recurrence generates a new task series for every
 
788
                               occurrence). Task series' share common
 
789
                               properties such as:
 
790
                                 Name.
 
791
                                 Recurrence pattern.
 
792
                                 Tags.
 
793
                                 Notes.
 
794
                                 Priority."
 
795
        @param rtm: a handle of the rtm object, to be able to speak with rtm.
 
796
                    Authentication should have already been done.
 
797
        @param timeline: a "timeline" is a series of operations rtm can undo in
 
798
                         bulk. We are free of requesting new timelines as we
 
799
                         please, with the obvious drawback of being slower.
 
800
        '''
 
801
        self.rtm_task = rtm_task
 
802
        self.rtm_list = rtm_list
 
803
        self.rtm_taskseries = rtm_taskseries
 
804
        self.rtm = rtm
 
805
        self.timeline = timeline
 
806
 
 
807
    def get_title(self):
 
808
        '''Returns the title of the task, if any'''
 
809
        return self.rtm_taskseries.name
 
810
 
 
811
    def set_title(self, title, transaction_ids = []):
 
812
        '''Sets the task title'''
 
813
        title = cgi.escape(title)
 
814
        result = self.rtm.tasks.setName(timeline      = self.timeline,
 
815
                                        list_id       = self.rtm_list.id,
 
816
                                        taskseries_id = self.rtm_taskseries.id,
 
817
                                        task_id       = self.rtm_task.id,
 
818
                                        name          = title)
 
819
        transaction_ids.append(result.transaction.id)
 
820
 
 
821
    def get_id(self):
 
822
        '''Return the task id. The taskseries id is *different*'''
 
823
        return self.rtm_task.id
 
824
 
 
825
    def get_status(self):
 
826
        '''Returns the task status, in GTG terminology'''
 
827
        return RTM_TO_GTG_STATUS[self.rtm_task.completed == ""]
 
828
 
 
829
    def set_status(self, gtg_status, transaction_ids = []):
 
830
        '''Sets the task status, in GTG terminology'''
 
831
        status = GTG_TO_RTM_STATUS[gtg_status]
 
832
        if status == True:
 
833
            api_call = self.rtm.tasks.uncomplete
 
834
        else:
 
835
            api_call = self.rtm.tasks.complete
 
836
        result = api_call(timeline      = self.timeline,
 
837
                          list_id       = self.rtm_list.id,
 
838
                          taskseries_id = self.rtm_taskseries.id,
 
839
                          task_id       = self.rtm_task.id)
 
840
        transaction_ids.append(result.transaction.id)
 
841
 
 
842
 
 
843
    def get_tags(self):
 
844
        '''Returns the task tags'''
 
845
        tags = self.rtm_taskseries.tags
 
846
        if not tags:
 
847
            return []
 
848
        else:
 
849
            return self.__getattr_the_rtm_way(tags, 'tag')
 
850
 
 
851
    def __getattr_the_rtm_way(self, an_object, attribute):
 
852
        '''
 
853
        RTM, to compress the XML file they send to you, cuts out all the
 
854
        unnecessary stuff.
 
855
        Because of that, getting an attribute from an object must check if one
 
856
        of those optimizations has been used.
 
857
        This function always returns a list wrapping the objects found (if any).
 
858
        '''
 
859
        try:
 
860
            list_or_object = getattr(an_object, attribute)
 
861
        except AttributeError:
 
862
            return []
 
863
        if isinstance(list_or_object, list):
 
864
            return list_or_object
 
865
        else:
 
866
            return [list_or_object]
 
867
 
 
868
    def set_tags(self, tags, transaction_ids = []):
 
869
        '''
 
870
        Sets a new set of tags to a task. Old tags are deleted.
 
871
        '''
 
872
        #RTM accept tags without "@" as prefix,  and lowercase
 
873
        tags = [tag[1:].lower() for tag in tags]
 
874
        #formatting them in a comma-separated string
 
875
        if len(tags) > 0:
 
876
            tagstxt = reduce(lambda x,y: x + ", " + y, tags)
 
877
        else:
 
878
            tagstxt = ""
 
879
        result = self.rtm.tasks.setTags(timeline     = self.timeline,
 
880
                                        list_id       = self.rtm_list.id,
 
881
                                        taskseries_id = self.rtm_taskseries.id,
 
882
                                        task_id       = self.rtm_task.id,
 
883
                                        tags          = tagstxt)
 
884
        transaction_ids.append(result.transaction.id)
 
885
 
 
886
    def get_text(self):
 
887
        '''
 
888
        Gets the content of RTM notes, aggregated in a single string
 
889
        '''
 
890
        notes = self.rtm_taskseries.notes
 
891
        if not notes:
 
892
            return ""
 
893
        else:
 
894
            note_list = self.__getattr_the_rtm_way(notes, 'note')
 
895
            return "".join(map(lambda note: "%s\n" %getattr(note, '$t'),
 
896
                                note_list))
 
897
 
 
898
    def set_text(self, text, transaction_ids = []):
 
899
        '''
 
900
        deletes all the old notes in a task and sets a single note with the
 
901
        given text
 
902
        '''
 
903
        #delete old notes
 
904
        notes = self.rtm_taskseries.notes
 
905
        if notes:
 
906
            note_list = self.__getattr_the_rtm_way(notes, 'note')
 
907
            for note_id in [note.id for note in note_list]:
 
908
                result = self.rtm.tasksNotes.delete(timeline = self.timeline,
 
909
                                                    note_id  = note_id)
 
910
                transaction_ids.append(result.transaction.id)
 
911
 
 
912
        if text == "":
 
913
            return
 
914
        text = cgi.escape(text)
 
915
 
 
916
        #RTM does not support well long notes (that is, it denies the request)
 
917
        #Thus, we split long text in chunks. To make them show in the correct
 
918
        #order on the website, we have to upload them from the last to the first 
 
919
        # (they show the most recent on top)
 
920
        text_cursor_end = len(text)
 
921
        while True:
 
922
            text_cursor_start = text_cursor_end - 1000
 
923
            if text_cursor_start < 0:
 
924
                text_cursor_start = 0
 
925
 
 
926
            result = self.rtm.tasksNotes.add(timeline      = self.timeline,
 
927
                                             list_id       = self.rtm_list.id,
 
928
                                             taskseries_id = self.rtm_taskseries.id,
 
929
                                             task_id       = self.rtm_task.id,
 
930
                                             note_title    = "",
 
931
                                             note_text     = text[text_cursor_start:
 
932
                                                                  text_cursor_end])
 
933
            transaction_ids.append(result.transaction.id)
 
934
            if text_cursor_start <= 0:
 
935
                break
 
936
            text_cursor_end = text_cursor_start - 1
 
937
 
 
938
    def get_due_date(self):
 
939
        '''
 
940
        Gets the task due date
 
941
        '''
 
942
        due = self.rtm_task.due
 
943
        if due == "":
 
944
            return NoDate()
 
945
        date = self.__time_rtm_to_datetime(due).date()
 
946
        if date:
 
947
            return RealDate(date)
 
948
        else:
 
949
            return NoDate()
 
950
 
 
951
    def set_due_date(self, due, transaction_ids = []):
 
952
        '''
 
953
        Sets the task due date
 
954
        '''
 
955
        kwargs = {'timeline':      self.timeline,
 
956
                  'list_id':       self.rtm_list.id,
 
957
                  'taskseries_id': self.rtm_taskseries.id,
 
958
                  'task_id':       self.rtm_task.id}
 
959
        if due != None:
 
960
            kwargs['parse'] = 1
 
961
            kwargs['due'] = self.__time_date_to_rtm(due)
 
962
        result = self.rtm.tasks.setDueDate(**kwargs)
 
963
        transaction_ids.append(result.transaction.id)
 
964
 
 
965
    def get_modified(self):
 
966
        '''
 
967
        Gets the task modified time, in local time
 
968
        '''
 
969
        #RTM does not set a "modified" attribute in a new note because it uses a
 
970
        # "added" attribute. We need to check for both.
 
971
        if hasattr(self.rtm_task, 'modified'):
 
972
            rtm_task_modified = self.__time_rtm_to_datetime(\
 
973
                                                    self.rtm_task.modified)
 
974
        else:
 
975
            rtm_task_modified = self.__time_rtm_to_datetime(\
 
976
                                                    self.rtm_task.added)
 
977
        if hasattr(self.rtm_taskseries, 'modified'):
 
978
            rtm_taskseries_modified = self.__time_rtm_to_datetime(\
 
979
                                                self.rtm_taskseries.modified)
 
980
        else:
 
981
            rtm_taskseries_modified = self.__time_rtm_to_datetime(\
 
982
                                                self.rtm_taskseries.added)
 
983
        return max(rtm_task_modified, rtm_taskseries_modified)
 
984
 
 
985
    def delete(self):
 
986
        self.rtm.tasks.delete(timeline      = self.timeline,
 
987
                              list_id       = self.rtm_list.id,
 
988
                              taskseries_id = self.rtm_taskseries.id,
 
989
                              task_id       = self.rtm_task.id)
 
990
 
 
991
    #RTM speaks utc, and accepts utc if the "parse" option is set.
 
992
    def __tz_utc_to_local(self, dt):
 
993
        dt = dt.replace(tzinfo = tzutc())
 
994
        dt = dt.astimezone(tzlocal())
 
995
        return dt.replace(tzinfo = None)
 
996
 
 
997
    def __tz_local_to_utc(self, dt):
 
998
        dt = dt.replace(tzinfo = tzlocal())
 
999
        dt = dt.astimezone(tzutc())
 
1000
        return dt.replace(tzinfo = None)
 
1001
 
 
1002
    def __time_rtm_to_datetime(self, string):
 
1003
        string = string.split('.')[0].split('Z')[0]
 
1004
        dt = datetime.datetime.strptime(string.split(".")[0], \
 
1005
                                          "%Y-%m-%dT%H:%M:%S")
 
1006
        return self.__tz_utc_to_local(dt)
 
1007
        
 
1008
    def __time_rtm_to_date(self, string):
 
1009
        string = string.split('.')[0].split('Z')[0]
 
1010
        dt = datetime.datetime.strptime(string.split(".")[0], "%Y-%m-%d")
 
1011
        return self.__tz_utc_to_local(dt)
 
1012
 
 
1013
    def __time_datetime_to_rtm(self, timeobject):
 
1014
        if timeobject == None:
 
1015
            return ""
 
1016
        timeobject = self.__tz_local_to_utc(timeobject)
 
1017
        return timeobject.strftime("%Y-%m-%dT%H:%M:%S")
 
1018
 
 
1019
    def __time_date_to_rtm(self, timeobject):
 
1020
        if timeobject == None:
 
1021
            return ""
 
1022
        #WARNING: no timezone? seems to break the symmetry.
 
1023
        return timeobject.strftime("%Y-%m-%d")
 
1024
 
 
1025
    def __str__(self):
 
1026
        return "Task %s (%s)" % (self.get_title(), self.get_id())