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
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
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
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
# -----------------------------------------------------------------------------
21
Remember the milk backend
32
from dateutil.tz import tzutc, tzlocal
34
from GTG.backends.genericbackend import GenericBackend
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
49
class Backend(PeriodicImportBackend):
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"),\
64
_static_parameters = { \
66
GenericBackend.PARAM_TYPE: GenericBackend.TYPE_INT, \
67
GenericBackend.PARAM_DEFAULT_VALUE: 10, },
69
GenericBackend.PARAM_TYPE: GenericBackend.TYPE_BOOL, \
70
GenericBackend.PARAM_DEFAULT_VALUE: True, },
73
###############################################################################
74
### Backend standard methods ##################################################
75
###############################################################################
77
def __init__(self, parameters):
79
See GenericBackend for an explanation of this function.
80
Loads the saved state of the sync, if any
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, \
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
98
See GenericBackend for an explanation of this function.
100
super(Backend, self).initialize()
101
self.rtm_proxy = RTMProxy(self._ask_user_to_confirm_authentication,
104
def save_state(self):
106
See GenericBackend for an explanation of this function.
108
self._store_pickled_file(self.sync_engine_path, self.sync_engine)
110
def _ask_user_to_confirm_authentication(self):
112
Calls for a user interaction during authentication
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, \
122
self.login_event.wait()
126
Called when the user confirms the login
128
self.login_event.set()
130
###############################################################################
131
### TWO WAY SYNC ##############################################################
132
###############################################################################
134
def do_periodic_import(self):
136
See PeriodicImportBackend for an explanation of this function.
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()]
144
if self._this_is_the_first_loop:
145
self._on_successful_authentication()
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
151
if self._parameters["is-first-run"]:
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):
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)
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]
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
169
gtg_task = self.datastore.get_task(tid)
170
meme = SyncMeme(gtg_task.get_modified(),
171
rtm_task.get_modified(),
173
self.sync_engine.record_relationship( \
175
remote_id = rtm_task.get_id(),
179
#a first run has been completed successfully
180
self._parameters["is-first-run"] = False
182
for rtm_task_id in current_rtm_task_ids:
183
self.cancellation_point()
185
self._process_rtm_task(rtm_task_id)
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)
195
self.sync_engine.break_relationship(remote_id = \
201
def _on_successful_authentication(self):
203
Saves the token and requests a full flush on first autentication
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
210
self.datastore.flush_all_tasks,
211
args =(self.get_id(),)).start()
214
def remove_task(self, tid):
216
See GenericBackend for an explanation of this function.
218
if not self.rtm_proxy.is_authenticated():
220
self.cancellation_point()
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]
228
Log.debug("removing task %s from RTM" % rtm_task_id)
232
self.sync_engine.break_relationship(local_id = tid)
238
###############################################################################
239
### Process tasks #############################################################
240
###############################################################################
243
def set_task(self, task):
245
See GenericBackend for an explanation of this function.
247
if not self.rtm_proxy.is_authenticated():
249
self.cancellation_point()
251
is_syncable = self._gtg_task_is_syncable_per_attached_tags(task)
252
action, rtm_task_id = self.sync_engine.analyze_local_id( \
254
self.datastore.has_task, \
255
self.rtm_proxy.has_rtm_task, \
257
Log.debug("GTG->RTM set task (%s, %s)" % (action, is_syncable))
262
if action == SyncEngine.ADD:
263
if task.get_status() != Task.STA_ACTIVE:
265
#we don't sync tasks that have already been closed before we
266
# even synced them once
269
rtm_task = self.rtm_proxy.create_new_rtm_task(task.get_title())
270
self._populate_rtm_task(task, rtm_task)
274
meme = SyncMeme(task.get_modified(),
275
rtm_task.get_modified(),
277
self.sync_engine.record_relationship( \
278
local_id = tid, remote_id = rtm_task.get_id(), meme = meme)
280
elif action == SyncEngine.UPDATE:
282
rtm_task = self.rtm_proxy.get_rtm_tasks_dict()[rtm_task_id]
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":
295
self._populate_rtm_task(task, rtm_task, transaction_ids)
297
self.rtm_proxy.unroll_changes(transaction_ids)
299
meme.set_remote_last_modified(rtm_task.get_modified())
300
meme.set_local_last_modified(task.get_modified())
302
#we skip saving the state
305
elif action == SyncEngine.REMOVE:
306
self.datastore.request_task_deletion(tid)
308
self.sync_engine.break_relationship(local_id = tid)
312
elif action == SyncEngine.LOST_SYNCABILITY:
314
rtm_task = self.rtm_proxy.get_rtm_tasks_dict()[rtm_task_id]
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)
324
def _exec_lost_syncability(self, tid, rtm_task):
326
Executed when a relationship between tasks loses its syncability
327
property. See SyncEngine for an explanation of that.
329
@param tid: a GTG task tid
330
@param note: a RTM task
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":
339
self.datastore.request_task_deletion(tid)
341
def _process_rtm_task(self, rtm_task_id):
343
Takes a rtm task id and carries out the necessary operations to
344
refresh the sync state
346
self.cancellation_point()
347
if not self.rtm_proxy.is_authenticated():
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( \
353
self.datastore.has_task,
354
self.rtm_proxy.has_rtm_task,
356
Log.debug("GTG<-RTM set task (%s, %s)" % (action, is_syncable))
361
if action == SyncEngine.ADD:
362
if rtm_task.get_status() != Task.STA_ACTIVE:
364
#we don't sync tasks that have already been closed before we
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(),
373
self.sync_engine.record_relationship( \
375
remote_id = rtm_task_id,
377
self.datastore.push_task(task)
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())
390
#we skip saving the state
393
elif action == SyncEngine.REMOVE:
396
self.sync_engine.break_relationship(remote_id = rtm_task_id)
400
elif action == SyncEngine.LOST_SYNCABILITY:
401
self._exec_lost_syncability(tid, rtm_task)
405
###############################################################################
406
### Helper methods ############################################################
407
###############################################################################
409
def _populate_task(self, task, rtm_task):
411
Copies the content of a RTMTask in a Task
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())
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()])
423
for tag in gtg_tags_lower.difference(tags):
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]
434
def _populate_rtm_task(self, task, rtm_task, transaction_ids = []):
436
Copies the content of a Task into a RTMTask
438
@param task: a GTG Task
439
@param rtm_task: an RTMTask
440
@param transaction_ids: a list to fill with transaction ids
442
#Get methods of an rtm_task are fast, set are slow: therefore,
443
# we try to use set as rarely as possible
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()
458
for tag in rtm_task.get_tags():
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):
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,
473
def __call_or_retry(self, fun, *args):
475
This function cannot stand the call "fun" to fail, so it retries
476
three times before giving up.
479
for i in xrange(MAX_ATTEMPTS):
483
if i >= MAX_ATTEMPTS:
486
def _rtm_task_is_syncable_per_attached_tags(self, rtm_task):
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.
493
@returns bool: True if the task should be synced
495
attached_tags = self.get_attached_tags()
496
if GenericBackend.ALLTASKS_TAG in attached_tags:
498
for tag in rtm_task.get_tags():
499
if "@" + tag in attached_tags:
503
###############################################################################
504
### RTM PROXY #################################################################
505
###############################################################################
507
class RTMProxy(object):
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
517
PUBLIC_KEY = "2a440fdfe9d890c343c25a91afd84c7e"
518
PRIVATE_KEY = "ca078fee48d0bbfa"
523
self.auth_confirm = auth_confirm_fun
525
self.authenticated = threading.Event()
526
self.login_event = threading.Event()
527
self.is_not_refreshing = threading.Event()
528
self.is_not_refreshing.set()
530
##########################################################################
531
### AUTHENTICATION #######################################################
532
##########################################################################
534
def start_authentication(self):
536
Launches the authentication process
538
initialize_thread = threading.Thread(target = self._authenticate)
539
initialize_thread.setDaemon(True)
540
initialize_thread.start()
542
def is_authenticated(self):
544
Returns true if we've autheticated to RTM
546
return self.authenticated.isSet()
548
def wait_for_authentication(self):
550
Inhibits the thread until authentication occours
552
self.authenticated.wait()
554
def get_auth_token(self):
556
Returns the oauth token, or none
563
def _authenticate(self):
565
authentication main function
567
self.authenticated.clear()
568
while not self.authenticated.isSet():
570
self.rtm= createRTM(self.PUBLIC_KEY, self.PRIVATE_KEY, self.token)
571
subprocess.Popen(['xdg-open', self.rtm.getAuthURL()])
575
self.token = self.rtm.getToken()
577
#something went wrong.
582
self.authenticated.set()
583
except exceptions.IOError, e:
584
BackendSignals().backend_failed(self.get_id(), \
585
BackendSignals.ERRNO_NETWORK)
589
Tries to establish a connection to rtm with a token got from the
590
authentication process
593
self.rtm = createRTM(self.PUBLIC_KEY, self.PRIVATE_KEY, self.token)
594
self.timeline = self.rtm.timelines.create().timeline
596
except (RTMError, RTMAPIError), e:
597
Log.error("RTM ERROR" + str(e))
600
##########################################################################
601
### RTM TASKS HANDLING ###################################################
602
##########################################################################
604
def unroll_changes(self, transaction_ids):
606
Roll backs the changes tracked by the list of transaction_ids given
608
for transaction_id in transaction_ids:
609
self.rtm.transactions.undo(timeline = self.timeline,
610
transaction_id = transaction_id)
612
def get_rtm_tasks_dict(self):
614
Returns a dict of RTMtasks. It will start authetication if necessary.
615
The dict is kept updated automatically.
617
if not hasattr(self, '_rtm_task_dict'):
618
self.refresh_rtm_tasks_dict()
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()
626
def __getattr_the_rtm_way(self, an_object, attribute):
628
RTM, to compress the XML file they send to you, cuts out all the
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).
635
list_or_object = getattr(an_object, attribute)
636
except AttributeError:
639
if isinstance(list_or_object, list):
640
return list_or_object
642
return [list_or_object]
644
def __get_rtm_lists(self):
646
Gets the list of the RTM Lists (the tabs on the top of rtm website)
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')
653
def __get_rtm_taskseries_in_list(self, list_id):
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
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:
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')
671
def refresh_rtm_tasks_dict(self):
673
Builds a list of RTMTasks fetched from RTM
675
if not self.is_authenticated():
676
self.start_authentication()
677
self.wait_for_authentication()
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()
684
self.is_not_refreshing.clear()
685
Log.debug('refreshing rtm')
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
691
#our purpose is to fill this with "tasks_id: RTMTask" items
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
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,
711
#we're done: we store the dict in this class and we annotate the time we
713
self._rtm_task_dict = rtm_tasks_dict
714
self.__rtm_task_dict_timestamp = datetime.datetime.now()
715
self.is_not_refreshing.set()
717
def has_rtm_task(self, rtm_task_id):
719
Returns True if we have seen that task id
721
cache_result = rtm_task_id in self.get_rtm_tasks_dict()
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.
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()
730
def create_new_rtm_task(self, title, transaction_ids = []):
732
Creates a new rtm task
734
result = self.rtm.tasks.add(timeline = self.timeline, name = title)
735
rtm_task = RTMTask(result.list.taskseries.task,
736
result.list.taskseries,
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
745
self._rtm_task_dict[rtm_task.get_id()] = rtm_task
746
transaction_ids.append(result.transaction.id)
751
###############################################################################
752
### RTM TASK ##################################################################
753
###############################################################################
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}
760
RTM_TO_GTG_STATUS = {True: Task.STA_ACTIVE,
761
False: Task.STA_DONE}
765
class RTMTask(object):
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
776
def __init__(self, rtm_task, rtm_taskseries, rtm_list, rtm, timeline):
778
sets up the various parameters needed to interact with a task.
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
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.
801
self.rtm_task = rtm_task
802
self.rtm_list = rtm_list
803
self.rtm_taskseries = rtm_taskseries
805
self.timeline = timeline
808
'''Returns the title of the task, if any'''
809
return self.rtm_taskseries.name
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,
819
transaction_ids.append(result.transaction.id)
822
'''Return the task id. The taskseries id is *different*'''
823
return self.rtm_task.id
825
def get_status(self):
826
'''Returns the task status, in GTG terminology'''
827
return RTM_TO_GTG_STATUS[self.rtm_task.completed == ""]
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]
833
api_call = self.rtm.tasks.uncomplete
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)
844
'''Returns the task tags'''
845
tags = self.rtm_taskseries.tags
849
return self.__getattr_the_rtm_way(tags, 'tag')
851
def __getattr_the_rtm_way(self, an_object, attribute):
853
RTM, to compress the XML file they send to you, cuts out all the
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).
860
list_or_object = getattr(an_object, attribute)
861
except AttributeError:
863
if isinstance(list_or_object, list):
864
return list_or_object
866
return [list_or_object]
868
def set_tags(self, tags, transaction_ids = []):
870
Sets a new set of tags to a task. Old tags are deleted.
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
876
tagstxt = reduce(lambda x,y: x + ", " + y, tags)
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,
884
transaction_ids.append(result.transaction.id)
888
Gets the content of RTM notes, aggregated in a single string
890
notes = self.rtm_taskseries.notes
894
note_list = self.__getattr_the_rtm_way(notes, 'note')
895
return "".join(map(lambda note: "%s\n" %getattr(note, '$t'),
898
def set_text(self, text, transaction_ids = []):
900
deletes all the old notes in a task and sets a single note with the
904
notes = self.rtm_taskseries.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,
910
transaction_ids.append(result.transaction.id)
914
text = cgi.escape(text)
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)
922
text_cursor_start = text_cursor_end - 1000
923
if text_cursor_start < 0:
924
text_cursor_start = 0
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,
931
note_text = text[text_cursor_start:
933
transaction_ids.append(result.transaction.id)
934
if text_cursor_start <= 0:
936
text_cursor_end = text_cursor_start - 1
938
def get_due_date(self):
940
Gets the task due date
942
due = self.rtm_task.due
945
date = self.__time_rtm_to_datetime(due).date()
947
return RealDate(date)
951
def set_due_date(self, due, transaction_ids = []):
953
Sets the task due date
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}
961
kwargs['due'] = self.__time_date_to_rtm(due)
962
result = self.rtm.tasks.setDueDate(**kwargs)
963
transaction_ids.append(result.transaction.id)
965
def get_modified(self):
967
Gets the task modified time, in local time
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)
975
rtm_task_modified = self.__time_rtm_to_datetime(\
977
if hasattr(self.rtm_taskseries, 'modified'):
978
rtm_taskseries_modified = self.__time_rtm_to_datetime(\
979
self.rtm_taskseries.modified)
981
rtm_taskseries_modified = self.__time_rtm_to_datetime(\
982
self.rtm_taskseries.added)
983
return max(rtm_task_modified, rtm_taskseries_modified)
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)
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)
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)
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)
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)
1013
def __time_datetime_to_rtm(self, timeobject):
1014
if timeobject == None:
1016
timeobject = self.__tz_local_to_utc(timeobject)
1017
return timeobject.strftime("%Y-%m-%dT%H:%M:%S")
1019
def __time_date_to_rtm(self, timeobject):
1020
if timeobject == None:
1022
#WARNING: no timezone? seems to break the symmetry.
1023
return timeobject.strftime("%Y-%m-%d")
1026
return "Task %s (%s)" % (self.get_title(), self.get_id())