Commits

Marcin Kasperski committed c2651c3

rename mekk.rtmimport -> mekk.rtm. Old name no longer makes sense

Comments (0)

Files changed (25)

 syntax: regexp
 
 ~$
-^src/mekk.rtmimport.egg-info/
+^src/mekk.rtm.egg-info/
 \.py[oc]$
 ^dist/
 .. -*- mode: rst; coding: utf-8 -*-
 
 ================
-mekk.rtmimport
+mekk.rtm
 ================
 
-``mekk.rtmimport`` implements a few command-line utilities on data
-kept on RememberTheMilk_ account. Initially written to handle data
-import (therefore the, somewhat unappropriate, name), currently it
-also helps export the data and reorganise it.
+``mekk.rtm`` is both:
+
+- RememberTheMilk_ client library (which you can use to write client
+  programs/libraries),
+- set of command-line utilities working on RememberTheMilk_ data
+  (which you can use to import, export, massively tag and/or move
+  etc).
+
+.. contents::
+   :local:
+   :backlinks: none
+   :depth: 3
+
+mekk.rtm as client library
+==========================
+
+
+
+
 
 Currently available commands:
 

sample/sample_client.py

 #import xml.etree.ElementTree as ElementTree
 import keyring
 import getpass
-from mekk.rtmimport import RtmConnector, RtmClient
+from mekk.rtm import RtmConnector, RtmClient
 import logging
 
 logging.basicConfig(level = logging.DEBUG)
 
-APP_NAME = "mekk.rtmimport sample"
+APP_NAME = "mekk.rtm sample"
 API_LABEL = "api-key"
 SEC_LABEL = "sec-code"
 TOKEN_LABEL = "default-user-token"

sample/sample_connector.py

 """
 
 import webbrowser
-from mekk.rtmimport.rtm_connector import RtmConnector
+from mekk.rtm.rtm_connector import RtmConnector
 import keyring
 import getpass
 import logging
 
 logging.basicConfig(level = logging.DEBUG)
 
-APP_NAME = "mekk.rtmimport lowlevel sample"
+APP_NAME = "mekk.rtm lowlevel sample"
 API_LABEL = "api-key"
 SEC_LABEL = "sec-code"
 TOKEN_LABEL = "default-user-token"
     # TODO: Development Status, Environment, Topic
     ]
 
-setup(name='mekk.rtmimport',
+setup(name='mekk.rtm',
       version=version,
-      description="Import foreign (at the moment, Nozbe) data to RememberTheMilk",
+      description="RememberTheMilk client API and command line client",
       long_description=long_description,
       classifiers=classifiers,
       keywords='rtm,RememberTheMilk',
       ],
       entry_points = {
         'console_scripts': [
-            'rtmimport = mekk.rtmimport.run_import:run',
-            'rtmhelper = mekk.rtmimport.run_helper:run',
-            'rtmexport = mekk.rtmimport.run_export:run',
+            'rtmimport = mekk.rtm.run_import:run',
+            'rtmhelper = mekk.rtm.run_helper:run',
+            'rtmexport = mekk.rtm.run_export:run',
             ],
         },
 

src/mekk/rtm/__access__.py

+
+API="Y2IwYzQ1ZGUwNDI5NTFiNDJjYjUzYzhmNjI0NjliMjk="
+SEC="Y2QyODU4Yzk0ZTQ2ZjdhNg=="
+

src/mekk/rtm/__init__.py

+
+from mekk.rtm.rtm_connector import RtmConnector, RtmException, RtmServiceException, RtmConnectException
+from mekk.rtm.rtm_client import RtmClient

src/mekk/rtm/connect.py

+# -*- coding: utf-8 -*-
+
+from __access__ import API, SEC
+import webbrowser
+import keyring
+from base64 import decodestring as __
+from rtmapi import Rtm
+from rtm_client import RtmClient
+
+def grab_access_token():
+    KEYRING_APP = "mekk-rtm"
+    KEYRING_TOKEN_USER = "default-user-token"
+
+    token = keyring.get_password(KEYRING_APP, KEYRING_TOKEN_USER)
+    # Legacy data move
+    if not token:
+        token = keyring.get_password("rtmimport", "default-user")
+
+    api = Rtm(__(API), __(SEC), "write", token)
+    
+    if not api.token_valid():
+        url, frob = api.authenticate_desktop()
+        print "Opening browser window to authenticate the script"
+        webbrowser.open(url)
+        raw_input("Press Enter once you authenticated script to access your RememberTheMilk account.")
+        api.retrieve_token(frob)
+        keyring.set_password(KEYRING_APP, KEYRING_TOKEN_USER, api.token)
+        print "Access token received and saved for future use"
+
+    return token
+
+def create_rtm_client():
+    """
+    Configures and creates connected and authorized RtmClient.
+    May use browser authorization to grab necessary permissions.
+    """
+
+    token = grab_access_token()
+    if not token:
+        raise Exception("Failed to grab working access token. Check API key")
+    api = Rtm(__(API), __(SEC), "write", token)
+    client = RtmClient(api)
+    return client

src/mekk/rtm/nozbe_import.py

+# -*- coding: utf-8 -*-
+
+import re
+from collections import defaultdict
+
+# Mapping nozbe codes to rtm codes
+RECUR_2_MILK = {
+    '0' : None,
+    '1' : u"Every day",
+    '2' : u"Every weekday",
+    '3' : u"Every week",
+    '4' : u"Every 2 weeks",
+    '5' : u"Every month",
+    '6' : u"Every 6 months",
+    '7' : u"Every year",
+    }
+
+# Name used for "preserve notes" task
+NOTE_TASK_NAME = u"Save this note"
+
+re_badchars = re.compile(u"[^-\w]+", re.UNICODE)
+def context_to_tag(ctx_name):
+    name = re_badchars.sub("-",ctx_name)
+    return u"@" + name
+
+#print context_to_tag("Ala ma kota")
+#print context_to_tag(u"Zażółć gęślą jaźń")
+#print context_to_tag(u"Komp/Platon")
+
+
+def import_nozbe_actions(rmt_client, actions, notes, verbose, dry_run):
+
+    for action in actions:
+        name = action['name']
+        project_name = action['project_name']
+
+        completed = (str(action.get('completed', 0)) == "1")
+        if completed:
+            #print (u"Skipping completed action from project %s: %s" % (project_name, name)).encode('utf-8')
+            continue
+
+        if dry_run or (project_name == "Inbox"):
+            list_id = None
+        else:
+            list_id = rmt_client.find_or_create_list(project_name).id
+
+        completed = (str(action.get('completed', 0)) == "1")
+                
+        tags = []
+        if action['context_name']:
+            tags.append(context_to_tag(action['context_name']))
+        if str(action['next']) == "1":
+            tags.append("Next")
+
+        due_date = None  # ...
+        if action['datetime']:
+            due_date = action['datetime']
+
+        repeat = None
+        if not completed: # avoid resurrecting old tasks
+            repeat = RECUR_2_MILK[ action['recur']  ]
+
+        estimate = None  # 3 days 2 hours 10 minutes
+        at = str(action['time'])
+        if at != "0":
+            estimate = at + " minutes"
+
+        if verbose:
+            intro = (completed and "Saving completed task" or "Creating new task")
+            print (u"%(intro)s on list %(project_name)s\n   Task name: %(name)s \n   Repeat: %(repeat)s, due: %(due_date)s, estimate: %(estimate)s, tags: %(tags)s" % locals()).encode('utf-8')
+
+        if not dry_run:
+            rmt_client.create_task(
+                task_name = name,
+                list_id = list_id,
+                tags = tags,
+                due_date = due_date,
+                estimate = estimate,
+                repeat = repeat,
+                completed = completed)
+           # priority, url, notes - unused
+    
+    # First group notes by project
+    project_notes = defaultdict(lambda: [])
+    for note in notes:
+        project_notes[note['project_name']].append(
+            (note['name'], note['body'])
+            )
+
+    # ... and save them
+    for project_name, here_notes in project_notes.iteritems():
+        if dry_run or (project_name == "Inbox"):
+            list_id = None
+        else:
+            list_id = rmt_client.find_or_create_list(project_name).id
+        task_name = NOTE_TASK_NAME
+        if verbose:
+            print "Creating preserve note task on list %(project_name)s. Task name: %(task_name)s\n" % locals()
+            print "Notes bound:\n"
+            for (title, body) in here_notes:
+                print title, "\n"
+                print "   ", body.replace("\n", "\n    "), "\n"
+        if not dry_run:
+            rmt_client.create_task(
+                task_name = task_name,
+                list_id = list_id,
+                tags = ["Note"],
+                due_date = "today",
+                notes = here_notes)

src/mekk/rtm/rtm_client.py

+# -*- coding: utf-8 -*-
+
+from collections import namedtuple
+from dateutil.parser import parse as dateutil_parse
+from dateutil.tz import tzutc
+import datetime
+
+List = namedtuple('List', 'id name archived')  
+SmartList = namedtuple('SmartList', 'id name filter archived')
+# List representations. id, name and filter are strings, archived is bool
+
+Note = namedtuple('Note', 'id title body')
+# All fields are strings
+
+TaskKey = namedtuple('TaskKey', 'list_id task_id taskseries_id')
+# All fields are strings
+
+Task = namedtuple('Task', 'key name tags notes due estimate priority completed postponed repeat url')
+# key is TaskKey, tags and notes are lists (maybe empty), due is datetime or None,
+# priority is int or None, completed is datetime (if completed) or None, 
+# postponed is int, remaining fields are strings
+
+
+# Note: I abstract TaskSeries->Task hierarchy, using only Tasks
+# (and adding taskseries attributes to them). I am not quite
+# sure what are taskseries for but any update api require specific task.
+
+class RtmClient(object):
+    """
+    Wrapper for RTM client calls. Handles listing, creating
+    and modifying lists, tags, locations, and tasks. Keeps
+    the cache of known lists.
+
+    Apart from wrapping RememberTheMilk API quirks it:
+
+    - keeps the cache of known lists to avoid re-downloading them
+    - handles duplicate detection in case of lists (so the same list
+      is not created again)
+
+    All updates are performed inside single timeline (Rtm undo
+    context) unless set_undo_point routine is called.
+    """
+    
+    def __init__(self, connector):
+        """
+        Initializes object. 
+
+        @param connector authorized RtmConnector object used to handle communication.
+        """
+        self.connector = connector
+        self._list_cache = None      # name -> List
+        self._smartlist_cache = None # name -> SmartList
+        self._timeline = None
+
+    def find_or_create_list(self, list_name):
+        """
+        Looks for the (normal) list of given name.
+        If such list does not exist, creates it.
+
+        Uses list cache for better performance.
+
+        Returns list object
+        """
+        self._load_list_cache_if_necessary()
+        list_info = self._list_cache.get(list_name)
+        if list_info:
+            #if list_info.archived:
+            #    self.unarchive_list(list_info.id)
+            return list_info
+        timeline = self._get_timeline()
+
+        r = self.connector.call(
+            "rtm.lists.add",
+            timeline = timeline,
+            name = list_name)
+        new_list = self._process_list_reply(r['list'])
+        return new_list
+
+    def unarchive_list(self, list_id):
+        """
+        Unarchives list of given id, returns the List or SmartList
+        object for it.
+        """
+        r = self.connector.call(
+            "rtm.lists.unarchive", 
+            timeline = self._get_timeline(),
+            list_id = list_id)
+        return self._process_list_reply(r['list'])
+
+    def archive_list(self, list_id):
+        """
+        Archives list of given id, returns the List or SmartList
+        object for it.
+        """
+        r = self.connector.call(
+            "rtm.lists.archive",
+            timeline = self._get_timeline(),
+            list_id = list_id)
+        return self._process_list_reply(r['list'])
+
+    def delete_list(self, list_id):
+        """
+        Deletes list of given id. Returns List object showing before-deletion data
+        """
+        r = self.connector.call(
+            "rtm.lists.delete",
+            timeline = self._get_timeline(),
+            list_id = list_id)
+        return self._process_list_reply(r['list'])
+
+    def known_lists(self):
+        """
+        Returns all known lists (except smartlists, only true lists are returned).
+
+        Return value is a list of List objects/tuples
+        """
+        self._load_list_cache_if_necessary()
+        return self._list_cache.values()
+
+    def known_smart_lists(self):
+        """
+        Returns all known smart lists.
+
+        Return value is a list of SmartList namedtuples
+        """
+        self._load_list_cache_if_necessary()
+        return self._smartlist_cache.values()
+
+    def create_task(self, task_name,
+                    list_id = None,
+                    tags = [], 
+                    completed = False,
+                    priority = None,
+                    due_date = None,
+                    estimate = None,
+                    repeat = None,
+                    url = None,
+                    notes = [],
+                    ):
+        """
+        Creates new task and sets some attributes. Returns TaskKey
+        object needed to update the task later.
+
+        @param task_name 
+        @param list_id list identifier, can be None (then Inbox is used)
+        @param tags list of tags (note: names can't contain ',')
+        @param completed is task already completed
+        @param priority  1, 2, or 3
+        @param due_date due date (datetime.date or ISO 8601 text, if zone-unaware assumed to be UTC)
+        @param estimate estimated cost (text like "3 days 2 hours 40 minutes")
+        @param repeat task repeating clause (text like "Every Tuesday", "Every month on the 4th", "Every week until 1/1/2007",
+                "After a week" etc. See http://www.rememberthemilk.com/help/answers/basics/repeatformat.rtm )
+        @param url - url related to the task
+        @param notes - list of pairs (title, text)
+
+        @returns Task object representing the task
+        """
+        timeline = self._get_timeline()
+        r = self.connector.call("rtm.tasks.add",
+                                timeline = timeline,
+                                list_id = list_id,
+                                name = task_name)
+        if not list_id:
+            # addTags and other methods require it
+            list_id = r['list']['id']
+        task = self._parse_task(list_id, r['list']['taskseries'])
+
+        self.update_task(task.key,
+                         add_tags = tags,
+                         completed = completed,
+                         priority = priority,
+                         due_date = due_date,
+                         estimate = estimate,
+                         repeat = repeat,
+                         url = url,
+                         notes = notes,
+                         )
+
+        return task
+
+    def update_task(self, task_key,
+                    add_tags = [],  # add tags without changing existing
+                    set_tags = None, # If specified, list of tags, drop old tags
+                    completed = False,
+                    priority = None,
+                    due_date = None,
+                    estimate = None,
+                    repeat = None,
+                    url = None,
+                    notes = [],
+                    ):
+        """
+        Updates given task attributes. All parameters has
+        the same semantic as in create_task except  set_tags,
+        which, if specified, resets tag list to given items
+        (removing all old tags). 
+        """
+        timeline = self._get_timeline()
+
+        r = None
+
+        if set_tags is not None:
+            tag_text = ", ".join(set_tags)
+            r = self.connector.call(
+                "rtm.tasks.setTags",
+                timeline = timeline,
+                tags = tag_text,
+                **task_key._asdict())            
+            
+        if add_tags:
+            tag_text = ", ".join(add_tags)
+            r = self.connector.call(
+                "rtm.tasks.addTags",
+                timeline = timeline,
+                tags = tag_text,
+                **task_key._asdict())
+
+        if completed:
+            r = self.connector.call(
+                "rtm.tasks.complete",
+                timeline = timeline,
+                **task_key._asdict())
+
+        if priority:
+            r = self.connector.call(
+                "rtm.tasks.setPriority",
+                timeline = timeline,
+                priority = str(priority),
+                **task_key._asdict())
+
+        if due_date:
+            r = self.connector.call(
+                "rtm.tasks.setDueDate",
+                timeline = timeline,
+                due = self._normalize_date(due_date),
+                parse = "1", # has_due_time = 1
+                **task_key._asdict())
+
+        if estimate:
+            r = self.connector.call(
+                "rtm.tasks.setEstimate",
+                timeline = timeline,
+                estimate = estimate,
+                **task_key._asdict())
+
+        if repeat:
+            r = self.connector.call(
+                "rtm.tasks.setRecurrence",
+                timeline = timeline,
+                repeat = repeat,
+                **task_key._asdict())
+            
+        if url:
+            r = self.connector.call(
+                "rtm.tasks.setURL",
+                timeline = timeline,
+                url = url,
+                **task_key._asdict())
+
+        if r:
+            task = self._parse_task(task_key.list_id, r['list']['taskseries'])
+        else:
+            task = None
+
+        for (note_title, note_text) in notes:
+            rn = self.add_task_note(task_key,
+                               note_title = note_title, note_text = note_text)
+            if task:
+                task.notes.append(rn)
+
+        return task
+
+    def add_task_note(self, task_key, 
+                      note_title, note_text):
+        """
+        Adds new note to the task. 
+
+        @param task_key: task identity
+        @param note_title: note label (invisible?)
+        @param note_text: actual body
+        @returns: Note object repesenting note
+        """
+        timeline = self._get_timeline()
+        r = self.connector.call(
+            "rtm.tasks.notes.add",
+            timeline = timeline,
+            note_title = note_title, note_text = note_text,
+            **task_key._asdict())
+        return self._parse_note(r['note'])
+
+    def find_tasks(self,
+                   list_id = None, 
+                   filter = None,
+                   last_sync = None):
+        """
+        Finds all asks matchin given criteria: belonging to specified
+        list (or any list if missing), satisfying filter if present,
+        and modified since last_sync (if present). last_sync can be
+        specified as datetime
+
+        Filter syntax: http://www.rememberthemilk.com/help/answers/search/advanced.rtm
+
+        yields all found tasks (acts as a generator, wrap with list() to get list)
+        """
+        r = self.connector.call(
+            "rtm.tasks.getList",
+            list_id = list_id,
+            filter = filter,
+            last_sync = last_sync)
+        #from pprint import pprint
+        #pprint(r['tasks'])
+        tasks = r['tasks']['list']
+        if type(tasks) is dict:
+            tasks = [ tasks ]
+        for task_list in tasks:
+            list_id = task_list['id']
+            for taskseries in task_list['taskseries']:
+                task_block = taskseries['task']
+                if type(task_block) is not list:
+                    task_block = [ task_block ]
+                for task in task_block:
+                    yield self._parse_task(list_id, taskseries, task)
+
+    def set_undo_point(self):
+        # Just forgetting previous timeline. New one will be created before first update
+        self._timeline = None
+
+    def _get_timeline(self):
+        if self._timeline is None:
+            r = self.connector.call(
+                "rtm.timelines.create")
+            self._timeline = r['timeline']
+        return self._timeline
+
+    def _load_list_cache_if_necessary(self):
+        if self._list_cache is None:
+            r = self.connector.call(
+                "rtm.lists.getList")
+            self._list_cache = {}
+            self._smartlist_cache = {}
+            for l in r['lists']['list']:
+                self._process_list_reply(l)
+
+    def _process_list_reply(self, list_dic):
+        """
+        Converts XML list data (as returned by many rtm.lists commands)
+        to List or SmartList tuple and updates internal cache for this list.
+        """
+        id = list_dic['id']
+        name = list_dic['name']
+        archived = self._parse_numflag_as_bool(list_dic['archived'])
+        deleted = self._parse_numflag_as_bool(list_dic['deleted'])
+        if not self._parse_numflag_as_bool(list_dic['smart']):
+            r = List(
+                id = id,
+                name = name,
+                archived = archived)
+            if not deleted:
+                self._list_cache[name] = r
+            else:
+                del self._list_cache[name]
+        else:
+            r = SmartList(
+                id = id,
+                name = name,
+                archived = archived,
+                filter = list_dic['filter'])
+            if not deleted:
+                self._smartlist_cache[name] = r
+            else:
+                del self._smartlist_cache[name]
+        return r
+
+    def _parse_task(self, list_id, taskseries_blk, task_blk = None):
+        """
+        Converts XML data to Task. If task_blk is not given, assumes first
+        """
+        if task_blk is None:
+            task_blk = taskseries_blk['task']
+
+        tag_blk = taskseries_blk['tags']
+        if type(tag_blk) is list:
+            if tag_blk != []:
+                # I've never seen nonempty list here, just dict, but ...
+                raise Exception("tags block is nonempty list: %s\nNotify developer" % str(tag_blk))
+            tags = []
+        else:
+            tags = tag_blk['tag']
+
+        note_blk = taskseries_blk['notes']
+        if type(note_blk) is list:
+            if note_blk != []:
+                # I've never seen nonempty list here, just dict, but ...
+                raise Exception("notes block is nonempty list: %s\nNotify developer" % str(note_blk))
+            notes = []
+        else:
+            #from pprint import pprint
+            #pprint(note_blk)
+            notes = note_blk['note']
+
+        return Task(
+            key = TaskKey(list_id = list_id, taskseries_id = taskseries_blk['id'], task_id = task_blk['id']),
+            name = taskseries_blk['name'],
+            tags = tags,
+            notes = [ self._parse_note(n) for n in notes ],
+            due = self._parse_date(task_blk['due']),
+            estimate = self._parse_txt_treating_empty_as_none(task_blk['estimate']),
+            priority = self._parse_priority(task_blk['priority']),
+            completed = self._parse_date(task_blk['completed']),
+            postponed = int(task_blk['postponed']),
+            repeat = self._parse_rrule(taskseries_blk.get('rrule')),
+            url = taskseries_blk.get('url'),
+            )
+
+    def _parse_date(self, date_attr):
+        if date_attr:
+            return dateutil_parse(date_attr)
+        else:
+            return None
+
+    def _parse_numflag_as_bool(self, flag):
+        return bool(int(flag or "0"))
+        
+    def _parse_txt_treating_empty_as_none(self, txt):
+        if txt:
+            return txt
+        else:
+            return None
+
+    def _parse_priority(self, txt):
+        if txt and txt != "N":
+            return int(txt)
+        else:
+            return None
+
+    def _parse_rrule(self, rrule_blk):
+        # TODO: convert those ugly FREQ=DAILY;INTERVAL=1 back to 1 day etc
+        if not rrule_blk:
+            return None
+        if rrule_blk.get('after'):
+            pfx = "after"
+        elif rrule_blk.get('every'):
+            pfx = "every"
+        return pfx + " " + rrule_blk['$t']
+
+    def _parse_note(self, note_blk):
+        return Note(id = note_blk['id'],
+                    title = note_blk['title'],
+                    body = note_blk['$t'])
+            
+    def _normalize_date(self, some_date):
+        if type(some_date) is datetime.date:
+            return some_date.isoformat() + "T00:00:00Z"
+        if not ((type(some_date) is datetime.datetime)):
+            some_date = dateutil_parse(some_date)
+        if not some_date.tzinfo:
+            some_date = some_date.replace(tzinfo=tzutc())
+        return some_date.isoformat()
+

src/mekk/rtm/rtm_connector.py

+# -*- coding: utf-8 -*-
+
+"""
+Low-level RememberTheMilk connection handling.
+
+Heavily inspired by rtmapi, but modified to use JSON instead of
+(frequently confusing) XML, add logging, and be explicit
+"""
+
+import hashlib
+import httplib2
+import urllib
+import simplejson
+import logging
+
+__all__ = ('RtmException', 'RtmConnector')
+
+logger = logging.getLogger(__name__)
+
+class RtmException(Exception): pass
+class RtmConnectException(RtmException): pass
+class RtmServiceException(RtmException): pass
+
+class RtmConnector(object):
+    _auth_url = "http://api.rememberthemilk.com/services/auth/"
+    _base_url = "http://api.rememberthemilk.com/services/rest/"
+    
+    """
+    @param api_key: your API key
+    @param shared_secret: your shared secret
+    @param perms: desired access permissions, one of "read", "write"
+                  and "delete"
+    @param token: token for granted access (optional, if not set,
+                  authentication must be performed later)
+    """
+    def __init__(self, api_key, shared_secret, perms = "read", token = None):
+        self.api_key = api_key
+        self.shared_secret = shared_secret
+        self.perms = perms
+        self.token = token
+        self.user_id = self.user_name = self.user_full_name = None
+        self.http = httplib2.Http()
+    
+    ######################################################################
+    # Authentication (routines to call if token is not yet known)
+    ######################################################################
+
+    """
+    Authenticate as a desktop application.
+    
+    @returns: (url, frob) tuple with url being the url the user should open and
+                          frob the identifier for usage with retrieve_token
+                          after the user authorized the application
+    """
+    def authenticate_desktop(self):
+        rsp = self.call_anonymously("rtm.auth.getFrob")
+        frob = rsp["frob"]
+        url = self._make_request_url(
+            called_url = self._auth_url, perms=self.perms, frob=frob)
+        return url, frob
+    
+    """
+    Authenticate as a web application.
+    @returns: url
+    """
+    def authenticate_webapp(self):
+        url = self._make_request_url(
+            called_url = self._auth_url, perms=self.perms)
+        return url
+    
+    """
+    Checks whether the stored token is valid. Apart from testing that,
+    on success sets user_id, user_name and user_full_name
+
+    @returns: bool validity
+    """
+    def token_valid(self):
+        if self.token is None:
+            return False
+        try:
+            rsp = self.call_anonymously("rtm.auth.checkToken",
+                                        auth_token=self.token)
+            self._set_user_attributes(rsp['auth']['user'])
+        except RtmServiceException, e:
+            logger.info("Failure verifying token: %s" % str(e))
+            return False
+        return True
+    
+    """
+    Retrieves a token for the given frob
+
+    Additionally, on success sets .token, .user_id, .user_name .user_full_name attributes
+
+    @returns: True/False
+    """
+    def retrieve_token(self, frob):
+        try:
+            rsp = self.call_anonymously(
+                "rtm.auth.getToken", frob=frob)
+        except RtmServiceException, e:
+            logger.warn("Failure retrieving token: %s" % str(e))
+            self.token = None
+            self.user_id = self.user_name = self.user_full_name = None
+            return False
+        self.token = rsp['auth']['token']
+        self._set_user_attributes(rsp['auth']['user'])
+        return True
+
+    ######################################################################
+    # Main call routines
+    ######################################################################
+    
+    def call_anonymously(self, method_name, **params):
+        """
+        Executes given method without authorization credentials.
+
+        @param method_name called metod (for example "rtm.auth.checkToken"
+               or "rtm.test.echo")
+        @param params any extra params named according to docs (note: params with value
+               None will be dropped)
+        @result output structure without wrappers
+
+        See http://www.rememberthemilk.com/services/api/methods/ for method list.
+        """
+        return self._make_request(method = method_name, **params)
+    
+    def call_authorized(self, method_name, **params):
+        """
+        Executes given method with authorization credentials.
+
+        @param method_name called metod (for example "rtm.auth.checkToken"
+               or "rtm.test.echo")
+        @param params any extra params named according to docs (note: params with value
+               None will be dropped)
+        @result output structure without wrappers
+
+        See http://www.rememberthemilk.com/services/api/methods/ for method list.
+        """
+        return self._make_request(method = method_name, auth_token = self.token, **params)
+
+    call = call_authorized
+    
+    ######################################################################
+    # Internal helpers
+    ######################################################################
+
+    def _make_request(self, method, called_url = None, **params):
+        final_url = self._make_request_url(called_url = called_url, method=method, **params)
+        logger.debug("Calling %s" % final_url)
+        info, data = self.http.request(
+            final_url,
+            headers={'Cache-Control':'no-cache, max-age=0'})
+        if info['status'] != '200':
+            raise RtmConnectException("Connection failure: %s (%s)" % (
+                    info['status'], info.get('reason', '')))
+        logger.debug("Call to %s executed. Result: %s" % (method, data))
+        reply = simplejson.loads(data)
+        rsp = reply['rsp']
+        if rsp['stat'] != "ok":
+            err = rsp.get("err")
+            if err:
+                raise RtmServiceException(
+                    "Service failure: %(code)s (%(msg)s)" % err)
+            else:
+                raise RtmServiceException(
+                    "Unknown service failure during call to %s" % final_url)
+        return rsp
+    
+    def _make_request_url(self, called_url = None, **params):
+        """
+        Construct final REST-style URL. Adds format, api_key and signature.
+        """
+        params["api_key"] = self.api_key
+        params["format"] = "json"
+        param_pairs = [ (k,v) for k,v in params.iteritems() 
+                              if v is not None ]
+        param_pairs.append( ("api_sig", self._sign_request(param_pairs)) )
+        quote_utf8 = lambda s: urllib.quote_plus(s.encode('utf-8'))
+        params_joined = "&".join("%s=%s" % (quote_utf8(k), quote_utf8(v))
+                                           for k, v in param_pairs)
+        return (called_url or self._base_url) + "?" + params_joined
+    
+    def _sign_request(self, param_pairs):
+        param_pairs.sort()
+        request_string = self.shared_secret + u''.join(k+v
+                                                       for k, v in param_pairs)
+        return hashlib.md5(request_string.encode('utf-8')).hexdigest()
+
+    def _set_user_attributes(self, user):
+        self.user_id = user['id']
+        self.user_name = user['username']
+        self.user_full_name = user['fullname']

src/mekk/rtm/run_export.py

+# -*- coding: utf-8 -*-
+
+usage = """
+Simple RememberTheMilk data exporter.
+
+Exporting data (lists and tasks) to JSON file:
+
+    %prog --json=myrtm.json
+
+Exporting tasks to CSV file:
+
+    %prog --csv=myrtm.csv
+
+Doing both simultaneously (and downloading the data just once):
+
+    %prog --csv=myrtm.csv --json=myrtm.json
+
+"""
+
+from connect import create_rtm_client
+import simplejson
+import csv
+
+def parse_options():
+    from optparse import OptionParser
+    opt_parser = OptionParser(usage=usage)
+    opt_parser.add_option("-j", "--json",
+                          action="store", type="string", dest="json",
+                          help="The name of .json file being exported")
+    opt_parser.add_option("-c", "--csv",
+                          action="store", type="string", dest="csv",
+                          help="The name of .csv file being exported")
+    opt_parser.add_option("-v", "--verbose",
+                          action="store_true", dest="verbose",
+                          help="Print diagnostic messages")
+                          
+    opt_parser.set_defaults(verbose = False, dry_run = False)
+    (opts, args) = opt_parser.parse_args()
+
+    if not (opts.json or opts.csv):
+        opt_parser.error("Operation not selected (--json and/or --csv expected)")
+    return opts
+
+def run():
+
+    opts = parse_options()
+    client = create_rtm_client()
+
+    lists = client.known_lists()
+    locations = client.known_locations()
+    tasks = client.mm()
+
+    if opts.csv:
+        csv_output = csv.writer(open(opts.csv, 'w'))
+        csv_output.writerow([u'Task', u'List', u'Recurring'])
+        # .encode('utf8')
+    elif opts.json:
+        
+
+    if opts.nozbe_json:
+        data = simplejson.load(open(opts.nozbe_json),
+                               encoding = "utf-8")
+
+        from nozbe_import import import_nozbe_actions
+
+        import_nozbe_actions(client, actions = data['actions'], notes = data['notes'],
+                             verbose = opts.verbose, dry_run = opts.dry_run)
+
+
+  output = codecs.open(json_filename, "w", encoding = "utf-8")
+    simplejson.dump(
+        dict(projects = projects, contexts = contexts, actions = all_actions, notes = notes),
+        output,
+        sort_keys = True,
+        indent = 4,
+        )
+    output.close()

src/mekk/rtm/run_helper.py

+# -*- coding: utf-8 -*-
+
+usage = """
+A few command-line helpers operating on RememberTheMilk account.
+
+1) Move selected items to given list
+
+    rtm_helper move-items --query="inlist:'List A'" --target-list="List B"
+
+2) Add tag to selected items
+
+3) Remove tag from selected items
+
+4) Remove or archive empty lists
+
+
+
+
+2) Remove empty lists
+
+    rtm_helper remove-empty-lists
+
+removes all empty lists.
+
+3) Archive empty list
+
+    rtm_helper archive-empty-lists
+
+archives all empty lists.
+"""
+
+
+from connect import create_rtm_client
+
+def parse_options():
+    from optparse import OptionParser
+    opt_parser = OptionParser(usage=usage)
+    opt_parser.add_option("-v", "--verbose",
+                          action="store_true", dest="verbose",
+                          help="Print diagnostic messages")
+    opt_parser.add_option("-d", "--dry-run",
+                          action="store_true", dest="dry_run",
+                          help="Don't execute any updates, just check input and (if verbose) print planned actions")
+                          
+    opt_parser.set_defaults(verbose = False, dry_run = False)
+    (opts, args) = opt_parser.parse_args()
+
+    first_arg = ""
+    if args:
+        first_arg = args[0]
+        args = args[1:]
+
+    if first_arg == "merge-lists":
+        pass
+    elif first_arg == "remove-empty-lists":
+        pass
+    elif first_arg == "archive-empty-lists":
+        pass
+    elif first_arg == "help":
+        pass
+    else:
+        opt_parser.error("Operation not selected (use 'rtm_helper help' for command list)")
+
+    return opts
+
+    
+def run():
+
+    opts = parse_options()
+    client = create_rtm_client()
+
+    # ...
+

src/mekk/rtm/run_import.py

+# -*- coding: utf-8 -*-
+
+usage = """
+Simple RememberTheMilk importer.
+
+Importing Nozbe export file (obtained with nozbetool export --json=<file>):
+
+    %prog --nozbe-json=nozbe.json  [--verbose] [--dry-run]
+"""
+
+from connect import create_rtm_client
+import simplejson
+
+def parse_options():
+    from optparse import OptionParser
+    opt_parser = OptionParser(usage=usage)
+    opt_parser.add_option("-n", "--nozbe-json",
+                          action="store", type="string", dest="nozbe_json",
+                          help="The name of .json file exported using nozbetool export --json ")
+    opt_parser.add_option("-v", "--verbose",
+                          action="store_true", dest="verbose",
+                          help="Print diagnostic messages")
+    opt_parser.add_option("-d", "--dry-run",
+                          action="store_true", dest="dry_run",
+                          help="Don't execute anything, just check input and (if verbose) print planned actions")
+                          
+    opt_parser.set_defaults(verbose = False, dry_run = False)
+    (opts, args) = opt_parser.parse_args()
+
+    if not opts.nozbe_json:  # Alternative formats can be considered in future
+        opt_parser.error("Operation not selected (--nozbe-json expected)")
+
+    return opts
+
+    
+def run():
+
+    opts = parse_options()
+    client = create_rtm_client()
+
+    if opts.nozbe_json:
+        data = simplejson.load(open(opts.nozbe_json),
+                               encoding = "utf-8")
+
+        from nozbe_import import import_nozbe_actions
+
+        import_nozbe_actions(
+            client, actions = data['actions'], notes = data['notes'],
+            verbose = opts.verbose, dry_run = opts.dry_run)

src/mekk/rtmimport/__access__.py

-
-API="Y2IwYzQ1ZGUwNDI5NTFiNDJjYjUzYzhmNjI0NjliMjk="
-SEC="Y2QyODU4Yzk0ZTQ2ZjdhNg=="
-

src/mekk/rtmimport/__init__.py

-
-from mekk.rtmimport.rtm_connector import RtmConnector, RtmException, RtmServiceException, RtmConnectException
-from mekk.rtmimport.rtm_client import RtmClient

src/mekk/rtmimport/connect.py

-# -*- coding: utf-8 -*-
-
-from __access__ import API, SEC
-import webbrowser
-import keyring
-from base64 import decodestring as __
-from rtmapi import Rtm
-from rtm_client import RtmClient
-
-def grab_access_token():
-    token = keyring.get_password("rtmimport", "default-user")
-    api = Rtm(__(API), __(SEC), "write", token)
-    
-    if not api.token_valid():
-        url, frob = api.authenticate_desktop()
-        print "Opening browser window to authenticate the script"
-        webbrowser.open(url)
-        raw_input("Press Enter once you authenticated script to access your RememberTheMilk account.")
-        api.retrieve_token(frob)
-        keyring.set_password("rtmimport", "default-user", api.token)
-        print "Access token received and saved for future use"
-
-    return token
-
-def create_rtm_client():
-    """
-    Configures and creates connected and authorized RtmClient.
-    May use browser authorization to grab necessary permissions.
-    """
-
-    token = grab_access_token()
-    if not token:
-        raise Exception("Failed to grab working access token. Check API key")
-    api = Rtm(__(API), __(SEC), "write", token)
-    client = RtmClient(api)
-    return client

src/mekk/rtmimport/nozbe_import.py

-# -*- coding: utf-8 -*-
-
-import re
-from collections import defaultdict
-
-# Mapping nozbe codes to rtm codes
-RECUR_2_MILK = {
-    '0' : None,
-    '1' : u"Every day",
-    '2' : u"Every weekday",
-    '3' : u"Every week",
-    '4' : u"Every 2 weeks",
-    '5' : u"Every month",
-    '6' : u"Every 6 months",
-    '7' : u"Every year",
-    }
-
-# Name used for "preserve notes" task
-NOTE_TASK_NAME = u"Save this note"
-
-re_badchars = re.compile(u"[^-\w]+", re.UNICODE)
-def context_to_tag(ctx_name):
-    name = re_badchars.sub("-",ctx_name)
-    return u"@" + name
-
-#print context_to_tag("Ala ma kota")
-#print context_to_tag(u"Zażółć gęślą jaźń")
-#print context_to_tag(u"Komp/Platon")
-
-
-def import_nozbe_actions(rmt_client, actions, notes, verbose, dry_run):
-
-    for action in actions:
-        name = action['name']
-        project_name = action['project_name']
-
-        completed = (str(action.get('completed', 0)) == "1")
-        if completed:
-            #print (u"Skipping completed action from project %s: %s" % (project_name, name)).encode('utf-8')
-            continue
-
-        if dry_run or (project_name == "Inbox"):
-            list_id = None
-        else:
-            list_id = rmt_client.find_or_create_list(project_name).id
-
-        completed = (str(action.get('completed', 0)) == "1")
-                
-        tags = []
-        if action['context_name']:
-            tags.append(context_to_tag(action['context_name']))
-        if str(action['next']) == "1":
-            tags.append("Next")
-
-        due_date = None  # ...
-        if action['datetime']:
-            due_date = action['datetime']
-
-        repeat = None
-        if not completed: # avoid resurrecting old tasks
-            repeat = RECUR_2_MILK[ action['recur']  ]
-
-        estimate = None  # 3 days 2 hours 10 minutes
-        at = str(action['time'])
-        if at != "0":
-            estimate = at + " minutes"
-
-        if verbose:
-            intro = (completed and "Saving completed task" or "Creating new task")
-            print (u"%(intro)s on list %(project_name)s\n   Task name: %(name)s \n   Repeat: %(repeat)s, due: %(due_date)s, estimate: %(estimate)s, tags: %(tags)s" % locals()).encode('utf-8')
-
-        if not dry_run:
-            rmt_client.create_task(
-                task_name = name,
-                list_id = list_id,
-                tags = tags,
-                due_date = due_date,
-                estimate = estimate,
-                repeat = repeat,
-                completed = completed)
-           # priority, url, notes - unused
-    
-    # First group notes by project
-    project_notes = defaultdict(lambda: [])
-    for note in notes:
-        project_notes[note['project_name']].append(
-            (note['name'], note['body'])
-            )
-
-    # ... and save them
-    for project_name, here_notes in project_notes.iteritems():
-        if dry_run or (project_name == "Inbox"):
-            list_id = None
-        else:
-            list_id = rmt_client.find_or_create_list(project_name).id
-        task_name = NOTE_TASK_NAME
-        if verbose:
-            print "Creating preserve note task on list %(project_name)s. Task name: %(task_name)s\n" % locals()
-            print "Notes bound:\n"
-            for (title, body) in here_notes:
-                print title, "\n"
-                print "   ", body.replace("\n", "\n    "), "\n"
-        if not dry_run:
-            rmt_client.create_task(
-                task_name = task_name,
-                list_id = list_id,
-                tags = ["Note"],
-                due_date = "today",
-                notes = here_notes)

src/mekk/rtmimport/rtm_client.py

-# -*- coding: utf-8 -*-
-
-from collections import namedtuple
-from dateutil.parser import parse as dateutil_parse
-from dateutil.tz import tzutc
-import datetime
-
-List = namedtuple('List', 'id name archived')  
-SmartList = namedtuple('SmartList', 'id name filter archived')
-# List representations. id, name and filter are strings, archived is bool
-
-Note = namedtuple('Note', 'id title body')
-# All fields are strings
-
-TaskKey = namedtuple('TaskKey', 'list_id task_id taskseries_id')
-# All fields are strings
-
-Task = namedtuple('Task', 'key name tags notes due estimate priority completed postponed repeat url')
-# key is TaskKey, tags and notes are lists (maybe empty), due is datetime or None,
-# priority is int or None, completed is datetime (if completed) or None, 
-# postponed is int, remaining fields are strings
-
-
-# Note: I abstract TaskSeries->Task hierarchy, using only Tasks
-# (and adding taskseries attributes to them). I am not quite
-# sure what are taskseries for but any update api require specific task.
-
-class RtmClient(object):
-    """
-    Wrapper for RTM client calls. Handles listing, creating
-    and modifying lists, tags, locations, and tasks. Keeps
-    the cache of known lists.
-
-    Apart from wrapping RememberTheMilk API quirks it:
-
-    - keeps the cache of known lists to avoid re-downloading them
-    - handles duplicate detection in case of lists (so the same list
-      is not created again)
-
-    All updates are performed inside single timeline (Rtm undo
-    context) unless set_undo_point routine is called.
-    """
-    
-    def __init__(self, connector):
-        """
-        Initializes object. 
-
-        @param connector authorized RtmConnector object used to handle communication.
-        """
-        self.connector = connector
-        self._list_cache = None      # name -> List
-        self._smartlist_cache = None # name -> SmartList
-        self._timeline = None
-
-    def find_or_create_list(self, list_name):
-        """
-        Looks for the (normal) list of given name.
-        If such list does not exist, creates it.
-
-        Uses list cache for better performance.
-
-        Returns list object
-        """
-        self._load_list_cache_if_necessary()
-        list_info = self._list_cache.get(list_name)
-        if list_info:
-            #if list_info.archived:
-            #    self.unarchive_list(list_info.id)
-            return list_info
-        timeline = self._get_timeline()
-
-        r = self.connector.call(
-            "rtm.lists.add",
-            timeline = timeline,
-            name = list_name)
-        new_list = self._process_list_reply(r['list'])
-        return new_list
-
-    def unarchive_list(self, list_id):
-        """
-        Unarchives list of given id, returns the List or SmartList
-        object for it.
-        """
-        r = self.connector.call(
-            "rtm.lists.unarchive", 
-            timeline = self._get_timeline(),
-            list_id = list_id)
-        return self._process_list_reply(r['list'])
-
-    def archive_list(self, list_id):
-        """
-        Archives list of given id, returns the List or SmartList
-        object for it.
-        """
-        r = self.connector.call(
-            "rtm.lists.archive",
-            timeline = self._get_timeline(),
-            list_id = list_id)
-        return self._process_list_reply(r['list'])
-
-    def delete_list(self, list_id):
-        """
-        Deletes list of given id. Returns List object showing before-deletion data
-        """
-        r = self.connector.call(
-            "rtm.lists.delete",
-            timeline = self._get_timeline(),
-            list_id = list_id)
-        return self._process_list_reply(r['list'])
-
-    def known_lists(self):
-        """
-        Returns all known lists (except smartlists, only true lists are returned).
-
-        Return value is a list of List objects/tuples
-        """
-        self._load_list_cache_if_necessary()
-        return self._list_cache.values()
-
-    def known_smart_lists(self):
-        """
-        Returns all known smart lists.
-
-        Return value is a list of SmartList namedtuples
-        """
-        self._load_list_cache_if_necessary()
-        return self._smartlist_cache.values()
-
-    def create_task(self, task_name,
-                    list_id = None,
-                    tags = [], 
-                    completed = False,
-                    priority = None,
-                    due_date = None,
-                    estimate = None,
-                    repeat = None,
-                    url = None,
-                    notes = [],
-                    ):
-        """
-        Creates new task and sets some attributes. Returns TaskKey
-        object needed to update the task later.
-
-        @param task_name 
-        @param list_id list identifier, can be None (then Inbox is used)
-        @param tags list of tags (note: names can't contain ',')
-        @param completed is task already completed
-        @param priority  1, 2, or 3
-        @param due_date due date (datetime.date or ISO 8601 text, if zone-unaware assumed to be UTC)
-        @param estimate estimated cost (text like "3 days 2 hours 40 minutes")
-        @param repeat task repeating clause (text like "Every Tuesday", "Every month on the 4th", "Every week until 1/1/2007",
-                "After a week" etc. See http://www.rememberthemilk.com/help/answers/basics/repeatformat.rtm )
-        @param url - url related to the task
-        @param notes - list of pairs (title, text)
-
-        @returns Task object representing the task
-        """
-        timeline = self._get_timeline()
-        r = self.connector.call("rtm.tasks.add",
-                                timeline = timeline,
-                                list_id = list_id,
-                                name = task_name)
-        if not list_id:
-            # addTags and other methods require it
-            list_id = r['list']['id']
-        task = self._parse_task(list_id, r['list']['taskseries'])
-
-        self.update_task(task.key,
-                         add_tags = tags,
-                         completed = completed,
-                         priority = priority,
-                         due_date = due_date,
-                         estimate = estimate,
-                         repeat = repeat,
-                         url = url,
-                         notes = notes,
-                         )
-
-        return task
-
-    def update_task(self, task_key,
-                    add_tags = [],  # add tags without changing existing
-                    set_tags = None, # If specified, list of tags, drop old tags
-                    completed = False,
-                    priority = None,
-                    due_date = None,
-                    estimate = None,
-                    repeat = None,
-                    url = None,
-                    notes = [],
-                    ):
-        """
-        Updates given task attributes. All parameters has
-        the same semantic as in create_task except  set_tags,
-        which, if specified, resets tag list to given items
-        (removing all old tags). 
-        """
-        timeline = self._get_timeline()
-
-        r = None
-
-        if set_tags is not None:
-            tag_text = ", ".join(set_tags)
-            r = self.connector.call(
-                "rtm.tasks.setTags",
-                timeline = timeline,
-                tags = tag_text,
-                **task_key._asdict())            
-            
-        if add_tags:
-            tag_text = ", ".join(add_tags)
-            r = self.connector.call(
-                "rtm.tasks.addTags",
-                timeline = timeline,
-                tags = tag_text,
-                **task_key._asdict())
-
-        if completed:
-            r = self.connector.call(
-                "rtm.tasks.complete",
-                timeline = timeline,
-                **task_key._asdict())
-
-        if priority:
-            r = self.connector.call(
-                "rtm.tasks.setPriority",
-                timeline = timeline,
-                priority = str(priority),
-                **task_key._asdict())
-
-        if due_date:
-            r = self.connector.call(
-                "rtm.tasks.setDueDate",
-                timeline = timeline,
-                due = self._normalize_date(due_date),
-                parse = "1", # has_due_time = 1
-                **task_key._asdict())
-
-        if estimate:
-            r = self.connector.call(
-                "rtm.tasks.setEstimate",
-                timeline = timeline,
-                estimate = estimate,
-                **task_key._asdict())
-
-        if repeat:
-            r = self.connector.call(
-                "rtm.tasks.setRecurrence",
-                timeline = timeline,
-                repeat = repeat,
-                **task_key._asdict())
-            
-        if url:
-            r = self.connector.call(
-                "rtm.tasks.setURL",
-                timeline = timeline,
-                url = url,
-                **task_key._asdict())
-
-        if r:
-            task = self._parse_task(task_key.list_id, r['list']['taskseries'])
-        else:
-            task = None
-
-        for (note_title, note_text) in notes:
-            rn = self.add_task_note(task_key,
-                               note_title = note_title, note_text = note_text)
-            if task:
-                task.notes.append(rn)
-
-        return task
-
-    def add_task_note(self, task_key, 
-                      note_title, note_text):
-        """
-        Adds new note to the task. 
-
-        @param task_key: task identity
-        @param note_title: note label (invisible?)
-        @param note_text: actual body
-        @returns: Note object repesenting note
-        """
-        timeline = self._get_timeline()
-        r = self.connector.call(
-            "rtm.tasks.notes.add",
-            timeline = timeline,
-            note_title = note_title, note_text = note_text,
-            **task_key._asdict())
-        return self._parse_note(r['note'])
-
-    def find_tasks(self,
-                   list_id = None, 
-                   filter = None,
-                   last_sync = None):
-        """
-        Finds all asks matchin given criteria: belonging to specified
-        list (or any list if missing), satisfying filter if present,
-        and modified since last_sync (if present). last_sync can be
-        specified as datetime
-
-        Filter syntax: http://www.rememberthemilk.com/help/answers/search/advanced.rtm
-
-        yields all found tasks (acts as a generator, wrap with list() to get list)
-        """
-        r = self.connector.call(
-            "rtm.tasks.getList",
-            list_id = list_id,
-            filter = filter,
-            last_sync = last_sync)
-        #from pprint import pprint
-        #pprint(r['tasks'])
-        tasks = r['tasks']['list']
-        if type(tasks) is dict:
-            tasks = [ tasks ]
-        for task_list in tasks:
-            list_id = task_list['id']
-            for taskseries in task_list['taskseries']:
-                task_block = taskseries['task']
-                if type(task_block) is not list:
-                    task_block = [ task_block ]
-                for task in task_block:
-                    yield self._parse_task(list_id, taskseries, task)
-
-    def set_undo_point(self):
-        # Just forgetting previous timeline. New one will be created before first update
-        self._timeline = None
-
-    def _get_timeline(self):
-        if self._timeline is None:
-            r = self.connector.call(
-                "rtm.timelines.create")
-            self._timeline = r['timeline']
-        return self._timeline
-
-    def _load_list_cache_if_necessary(self):
-        if self._list_cache is None:
-            r = self.connector.call(
-                "rtm.lists.getList")
-            self._list_cache = {}
-            self._smartlist_cache = {}
-            for l in r['lists']['list']:
-                self._process_list_reply(l)
-
-    def _process_list_reply(self, list_dic):
-        """
-        Converts XML list data (as returned by many rtm.lists commands)
-        to List or SmartList tuple and updates internal cache for this list.
-        """
-        id = list_dic['id']
-        name = list_dic['name']
-        archived = self._parse_numflag_as_bool(list_dic['archived'])
-        deleted = self._parse_numflag_as_bool(list_dic['deleted'])
-        if not self._parse_numflag_as_bool(list_dic['smart']):
-            r = List(
-                id = id,
-                name = name,
-                archived = archived)
-            if not deleted:
-                self._list_cache[name] = r
-            else:
-                del self._list_cache[name]
-        else:
-            r = SmartList(
-                id = id,
-                name = name,
-                archived = archived,
-                filter = list_dic['filter'])
-            if not deleted:
-                self._smartlist_cache[name] = r
-            else:
-                del self._smartlist_cache[name]
-        return r
-
-    def _parse_task(self, list_id, taskseries_blk, task_blk = None):
-        """
-        Converts XML data to Task. If task_blk is not given, assumes first
-        """
-        if task_blk is None:
-            task_blk = taskseries_blk['task']
-
-        tag_blk = taskseries_blk['tags']
-        if type(tag_blk) is list:
-            if tag_blk != []:
-                # I've never seen nonempty list here, just dict, but ...
-                raise Exception("tags block is nonempty list: %s\nNotify developer" % str(tag_blk))
-            tags = []
-        else:
-            tags = tag_blk['tag']
-
-        note_blk = taskseries_blk['notes']
-        if type(note_blk) is list:
-            if note_blk != []:
-                # I've never seen nonempty list here, just dict, but ...
-                raise Exception("notes block is nonempty list: %s\nNotify developer" % str(note_blk))
-            notes = []
-        else:
-            #from pprint import pprint
-            #pprint(note_blk)
-            notes = note_blk['note']
-
-        return Task(
-            key = TaskKey(list_id = list_id, taskseries_id = taskseries_blk['id'], task_id = task_blk['id']),
-            name = taskseries_blk['name'],
-            tags = tags,
-            notes = [ self._parse_note(n) for n in notes ],
-            due = self._parse_date(task_blk['due']),
-            estimate = self._parse_txt_treating_empty_as_none(task_blk['estimate']),
-            priority = self._parse_priority(task_blk['priority']),
-            completed = self._parse_date(task_blk['completed']),
-            postponed = int(task_blk['postponed']),
-            repeat = self._parse_rrule(taskseries_blk.get('rrule')),
-            url = taskseries_blk.get('url'),
-            )
-
-    def _parse_date(self, date_attr):
-        if date_attr:
-            return dateutil_parse(date_attr)
-        else:
-            return None
-
-    def _parse_numflag_as_bool(self, flag):
-        return bool(int(flag or "0"))
-        
-    def _parse_txt_treating_empty_as_none(self, txt):
-        if txt:
-            return txt
-        else:
-            return None
-
-    def _parse_priority(self, txt):
-        if txt and txt != "N":
-            return int(txt)
-        else:
-            return None
-
-    def _parse_rrule(self, rrule_blk):
-        # TODO: convert those ugly FREQ=DAILY;INTERVAL=1 back to 1 day etc
-        if not rrule_blk:
-            return None
-        if rrule_blk.get('after'):
-            pfx = "after"
-        elif rrule_blk.get('every'):
-            pfx = "every"
-        return pfx + " " + rrule_blk['$t']
-
-    def _parse_note(self, note_blk):
-        return Note(id = note_blk['id'],
-                    title = note_blk['title'],
-                    body = note_blk['$t'])
-            
-    def _normalize_date(self, some_date):
-        if type(some_date) is datetime.date:
-            return some_date.isoformat() + "T00:00:00Z"
-        if not ((type(some_date) is datetime.datetime)):
-            some_date = dateutil_parse(some_date)
-        if not some_date.tzinfo:
-            some_date = some_date.replace(tzinfo=tzutc())
-        return some_date.isoformat()
-

src/mekk/rtmimport/rtm_connector.py

-# -*- coding: utf-8 -*-
-
-"""
-Low-level RememberTheMilk connection handling.
-
-Heavily inspired by rtmapi, but modified to use JSON instead of
-(frequently confusing) XML, add logging, and be explicit
-"""
-
-import hashlib
-import httplib2
-import urllib
-import simplejson
-import logging
-
-__all__ = ('RtmException', 'RtmConnector')
-
-logger = logging.getLogger(__name__)
-
-class RtmException(Exception): pass
-class RtmConnectException(RtmException): pass
-class RtmServiceException(RtmException): pass
-
-class RtmConnector(object):
-    _auth_url = "http://api.rememberthemilk.com/services/auth/"
-    _base_url = "http://api.rememberthemilk.com/services/rest/"
-    
-    """
-    @param api_key: your API key
-    @param shared_secret: your shared secret
-    @param perms: desired access permissions, one of "read", "write"
-                  and "delete"
-    @param token: token for granted access (optional, if not set,
-                  authentication must be performed later)
-    """
-    def __init__(self, api_key, shared_secret, perms = "read", token = None):
-        self.api_key = api_key
-        self.shared_secret = shared_secret
-        self.perms = perms
-        self.token = token
-        self.user_id = self.user_name = self.user_full_name = None
-        self.http = httplib2.Http()
-    
-    ######################################################################
-    # Authentication (routines to call if token is not yet known)
-    ######################################################################
-
-    """
-    Authenticate as a desktop application.
-    
-    @returns: (url, frob) tuple with url being the url the user should open and
-                          frob the identifier for usage with retrieve_token
-                          after the user authorized the application
-    """
-    def authenticate_desktop(self):
-        rsp = self.call_anonymously("rtm.auth.getFrob")
-        frob = rsp["frob"]
-        url = self._make_request_url(
-            called_url = self._auth_url, perms=self.perms, frob=frob)
-        return url, frob
-    
-    """
-    Authenticate as a web application.
-    @returns: url
-    """
-    def authenticate_webapp(self):
-        url = self._make_request_url(
-            called_url = self._auth_url, perms=self.perms)
-        return url
-    
-    """
-    Checks whether the stored token is valid. Apart from testing that,
-    on success sets user_id, user_name and user_full_name
-
-    @returns: bool validity
-    """
-    def token_valid(self):
-        if self.token is None:
-            return False
-        try:
-            rsp = self.call_anonymously("rtm.auth.checkToken",
-                                        auth_token=self.token)
-            self._set_user_attributes(rsp['auth']['user'])
-        except RtmServiceException, e:
-            logger.info("Failure verifying token: %s" % str(e))
-            return False
-        return True
-    
-    """
-    Retrieves a token for the given frob
-
-    Additionally, on success sets .token, .user_id, .user_name .user_full_name attributes
-
-    @returns: True/False
-    """
-    def retrieve_token(self, frob):
-        try:
-            rsp = self.call_anonymously(
-                "rtm.auth.getToken", frob=frob)
-        except RtmServiceException, e:
-            logger.warn("Failure retrieving token: %s" % str(e))
-            self.token = None
-            self.user_id = self.user_name = self.user_full_name = None
-            return False
-        self.token = rsp['auth']['token']
-        self._set_user_attributes(rsp['auth']['user'])
-        return True
-
-    ######################################################################
-    # Main call routines
-    ######################################################################
-    
-    def call_anonymously(self, method_name, **params):
-        """
-        Executes given method without authorization credentials.
-
-        @param method_name called metod (for example "rtm.auth.checkToken"
-               or "rtm.test.echo")
-        @param params any extra params named according to docs (note: params with value
-               None will be dropped)
-        @result output structure without wrappers
-
-        See http://www.rememberthemilk.com/services/api/methods/ for method list.
-        """
-        return self._make_request(method = method_name, **params)
-    
-    def call_authorized(self, method_name, **params):
-        """
-        Executes given method with authorization credentials.
-
-        @param method_name called metod (for example "rtm.auth.checkToken"
-               or "rtm.test.echo")
-        @param params any extra params named according to docs (note: params with value
-               None will be dropped)
-        @result output structure without wrappers
-
-        See http://www.rememberthemilk.com/services/api/methods/ for method list.
-        """
-        return self._make_request(method = method_name, auth_token = self.token, **params)
-
-    call = call_authorized
-    
-    ######################################################################
-    # Internal helpers
-    ######################################################################
-
-    def _make_request(self, method, called_url = None, **params):
-        final_url = self._make_request_url(called_url = called_url, method=method, **params)
-        logger.debug("Calling %s" % final_url)
-        info, data = self.http.request(
-            final_url,
-            headers={'Cache-Control':'no-cache, max-age=0'})
-        if info['status'] != '200':
-            raise RtmConnectException("Connection failure: %s (%s)" % (
-                    info['status'], info.get('reason', '')))
-        logger.debug("Call to %s executed. Result: %s" % (method, data))
-        reply = simplejson.loads(data)
-        rsp = reply['rsp']
-        if rsp['stat'] != "ok":
-            err = rsp.get("err")
-            if err:
-                raise RtmServiceException(
-                    "Service failure: %(code)s (%(msg)s)" % err)
-            else:
-                raise RtmServiceException(
-                    "Unknown service failure during call to %s" % final_url)
-        return rsp
-    
-    def _make_request_url(self, called_url = None, **params):
-        """
-        Construct final REST-style URL. Adds format, api_key and signature.
-        """
-        params["api_key"] = self.api_key
-        params["format"] = "json"
-        param_pairs = [ (k,v) for k,v in params.iteritems() 
-                              if v is not None ]
-        param_pairs.append( ("api_sig", self._sign_request(param_pairs)) )
-        quote_utf8 = lambda s: urllib.quote_plus(s.encode('utf-8'))
-        params_joined = "&".join("%s=%s" % (quote_utf8(k), quote_utf8(v))
-                                           for k, v in param_pairs)
-        return (called_url or self._base_url) + "?" + params_joined
-    
-    def _sign_request(self, param_pairs):
-        param_pairs.sort()
-        request_string = self.shared_secret + u''.join(k+v
-                                                       for k, v in param_pairs)
-        return hashlib.md5(request_string.encode('utf-8')).hexdigest()
-
-    def _set_user_attributes(self, user):
-        self.user_id = user['id']
-        self.user_name = user['username']
-        self.user_full_name = user['fullname']

src/mekk/rtmimport/run_export.py

-# -*- coding: utf-8 -*-
-
-usage = """
-Simple RememberTheMilk data exporter.
-
-Exporting data (lists and tasks) to JSON file:
-
-    %prog --json=myrtm.json
-
-Exporting tasks to CSV file:
-
-    %prog --csv=myrtm.csv
-
-Doing both simultaneously (and downloading the data just once):
-
-    %prog --csv=myrtm.csv --json=myrtm.json
-
-"""
-
-from connect import create_rtm_client
-import simplejson
-import csv
-
-def parse_options():
-    from optparse import OptionParser
-    opt_parser = OptionParser(usage=usage)
-    opt_parser.add_option("-j", "--json",
-                          action="store", type="string", dest="json",
-                          help="The name of .json file being exported")
-    opt_parser.add_option("-c", "--csv",
-                          action="store", type="string", dest="csv",
-                          help="The name of .csv file being exported")
-    opt_parser.add_option("-v", "--verbose",
-                          action="store_true", dest="verbose",
-                          help="Print diagnostic messages")
-                          
-    opt_parser.set_defaults(verbose = False, dry_run = False)
-    (opts, args) = opt_parser.parse_args()
-
-    if not (opts.json or opts.csv):
-        opt_parser.error("Operation not selected (--json and/or --csv expected)")
-    return opts
-
-def run():
-
-    opts = parse_options()
-    client = create_rtm_client()
-
-    lists = client.known_lists()
-    locations = client.known_locations()
-    tasks = client.mm()
-
-    if opts.csv:
-        csv_output = csv.writer(open(opts.csv, 'w'))
-        csv_output.writerow([u'Task', u'List', u'Recurring'])
-        # .encode('utf8')
-    elif opts.json:
-        
-
-    if opts.nozbe_json:
-        data = simplejson.load(open(opts.nozbe_json),
-                               encoding = "utf-8")
-
-        from nozbe_import import import_nozbe_actions
-
-        import_nozbe_actions(client, actions = data['actions'], notes = data['notes'],
-                             verbose = opts.verbose, dry_run = opts.dry_run)
-
-
-  output = codecs.open(json_filename, "w", encoding = "utf-8")
-    simplejson.dump(
-        dict(projects = projects, contexts = contexts, actions = all_actions, notes = notes),
-        output,
-        sort_keys = True,
-        indent = 4,
-        )
-    output.close()

src/mekk/rtmimport/run_helper.py

-# -*- coding: utf-8 -*-
-
-usage = """
-A few command-line helpers operating on RememberTheMilk account.
-
-1) Move selected items to given list
-
-    rtm_helper move-items --query="inlist:'List A'" --target-list="List B"
-
-2) Add tag to selected items
-
-3) Remove tag from selected items
-
-4) Remove or archive empty lists
-
-
-
-
-2) Remove empty lists