diff --git a/README.md b/README.md index 9afe95e..7dc4c20 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Conflict Resolution --------- If a change is made on a different client ![Alt Remote change](http://i.imgur.com/WjRAccA.png "Remote change") + And in the middle of that change and a sync we have made local changes in sublime ![Alt Local change](http://i.imgur.com/8YRoAmt.png "Local change") @@ -57,6 +58,7 @@ A small value might start making simplenote reject changes and a big value might **title_extension_map** is an array used to apply extensions to the temporal note files, so it can interact with other extensions, most notably plaintasks: ![Alt Plaintasks configuration](http://i.imgur.com/EbVj4Ul.png "Plaintasks configuration") + Each row of the array takes a regex that the plugin uses against the note title and an extension to add at the end. ![Alt Plaintasks use](http://i.imgur.com/VgGOlLf.png "Plaintasks use") diff --git a/simplenote.py b/simplenote.py index 4aa484b..1aa5b59 100644 --- a/simplenote.py +++ b/simplenote.py @@ -8,12 +8,23 @@ :copyright: (c) 2011 by Daniel Schauenberg :license: MIT, see LICENSE for more details. """ +import sys +if sys.version_info > (3, 0): + import urllib.request as urllib2 + import urllib.error + from urllib.error import HTTPError + import urllib.parse as urllib + import html +else: + import urllib2 + from urllib2 import HTTPError + import urllib + from HTMLParser import HTMLParser -import urllib.request, urllib.parse, urllib.error -from urllib.error import HTTPError import base64 import time import datetime +import uuid try: import json @@ -24,10 +35,15 @@ # For Google AppEngine from django.utils import simplejson as json -AUTH_URL = 'https://simple-note.appspot.com/api/login' -DATA_URL = 'https://simple-note.appspot.com/api2/data' -INDX_URL = 'https://simple-note.appspot.com/api2/index?' -NOTE_FETCH_LENGTH = 100 +APP_ID = 'chalk-bump-f49' +# There is no way for us to hide this key, only obfuscate it. +# So please be kind and don't (ab)use it. +# Simplenote/Simperium didn't have to provide us with this. +API_KEY = base64.b64decode('YzhjMmI4NjMzNzE1NGNkYWJjOTg5YjIzZTMwYzZiZjQ=') +BUCKET = 'note' +AUTH_URL = 'https://auth.simperium.com/1/%s/authorize/' % (APP_ID) +DATA_URL = 'https://api.simperium.com/1/%s/%s' % (APP_ID, BUCKET) +NOTE_FETCH_LENGTH = 1000 class SimplenoteLoginFailed(Exception): pass @@ -40,7 +56,9 @@ def __init__(self, username, password): """ object constructor """ self.username = username self.password = password + self.header = 'X-Simperium-Token' self.token = None + self.mark = "mark" def authenticate(self, user, password): """ Method to get simplenote auth token @@ -51,14 +69,18 @@ def authenticate(self, user, password): Returns: Simplenote API token as string - + """ - auth_params = "email={0}&password={1}".format(user, password) - values = base64.b64encode(bytes(auth_params,'utf-8')) - request = urllib.request.Request(AUTH_URL, values) + + request = Request(AUTH_URL) + request.add_header('X-Simperium-API-Key', API_KEY) + if sys.version_info < (3, 3): + request.add_data(json.dumps({'username': user, 'password': password})) + else: + request.data = json.dumps({'username': user, 'password': password}).encode() try: - res = urllib.request.urlopen(request).read() - token = res + res = urllib2.urlopen(request).read() + token = json.loads(res.decode('utf-8'))["access_token"] except HTTPError: raise SimplenoteLoginFailed('Login to Simplenote API failed!') except IOError: # no connection exception @@ -77,11 +99,13 @@ def get_token(self): """ if self.token == None: self.token = self.authenticate(self.username, self.password) - return str(self.token,'utf-8') - + try: + return str(self.token,'utf-8') + except TypeError: + return self.token def get_note(self, noteid, version=None): - """ method to get a specific note + """ Method to get a specific note Arguments: - noteid (string): ID of the note to get @@ -97,21 +121,28 @@ def get_note(self, noteid, version=None): # request note params_version = "" if version is not None: - params_version = '/' + str(version) - - params = '/{0}{1}?auth={2}&email={3}'.format(noteid, params_version, self.get_token(), self.username) - request = urllib.request.Request(DATA_URL+params) + params_version = '/v/' + str(version) + + params = '/i/%s%s' % (str(noteid), params_version) + request = Request(DATA_URL+params) + request.add_header(self.header, self.get_token()) try: - response = urllib.request.urlopen(request) + response = urllib2.urlopen(request) except HTTPError as e: return e, -1 except IOError as e: return e, -1 note = json.loads(response.read().decode('utf-8')) + note = self.__add_simplenote_api_fields(note, noteid, int(response.info().get("X-Simperium-Version"))) + # Sort tags + # For early versions of notes, tags not always available + if "tags" in note: + note["tags"] = sorted(note["tags"]) + return note, 0 def update_note(self, note): - """ function to update a specific note object, if the note object does not + """ Method to update a specific note object, if the note object does not have a "key" field, a new note is created Arguments @@ -119,34 +150,50 @@ def update_note(self, note): Returns: A tuple `(note, status)` - - note (dict): note object - status (int): 0 on sucesss and -1 otherwise """ - # determine whether to create a new note or updated an existing one + # determine whether to create a new note or update an existing one + # Also need to add/remove key field to keep simplenote.py consistency if "key" in note: + # Then already have a noteid we need to remove before passing to Simperium API + noteid = note.pop("key", None) # set modification timestamp if not set by client - if 'modifydate' not in note: - note["modifydate"] = time.time() + if 'modificationDate' not in note: + note["modificationDate"] = time.time() + else: + # Adding a new note + noteid = uuid.uuid4().hex + - url = '{0}/{1}?auth={2}&email={3}'.format(DATA_URL, note["key"], - self.get_token(), self.username) + # TODO: Set a ccid? + # ccid = uuid.uuid4().hex + if "version" in note: + version = note.pop("version", None) + url = '%s/i/%s/v/%s?response=1' % (DATA_URL, noteid, version) else: - url = '{0}?auth={1}&email={2}'.format(DATA_URL, self.get_token(), self.username) - request = urllib.request.Request(url, urllib.parse.quote(json.dumps(note)).encode('utf-8')) + url = '%s/i/%s?response=1' % (DATA_URL, noteid) + + # TODO: Could do with being consistent here. Everywhere else is Request(DATA_URL+params) + note = self.__remove_simplenote_api_fields(note) + request = Request(url, data=json.dumps(note).encode('utf-8')) + request.add_header(self.header, self.get_token()) + request.add_header('Content-Type', 'application/json') + response = "" try: - response = urllib.request.urlopen(request) + response = urllib2.urlopen(request) except IOError as e: return e, -1 note = json.loads(response.read().decode('utf-8')) + note = self.__add_simplenote_api_fields(note, noteid, int(response.info().get("X-Simperium-Version"))) return note, 0 def add_note(self, note): - """wrapper function to add a note + """ Wrapper method to add a note - The function can be passed the note as a dict with the `content` + The method can be passed the note as a dict with the `content` property set, which is then directly send to the web service for creation. Alternatively, only the body as string can also be passed. In this case the parameter is used as `content` for the new note. @@ -169,17 +216,14 @@ def add_note(self, note): else: return "No string or valid note.", -1 - def get_note_list(self, since=None, tags=[]): - """ function to get the note list + def get_note_list(self, tags=[]): + """ Method to get the note list - The function can be passed optional arguments to limit the - date range of the list returned and/or limit the list to notes - containing a certain tag. If omitted a list of all notes - is returned. + The method can be passed optional arguments to limit the + the list to notes containing a certain tag. If omitted a list + of all notes is returned. Arguments: - - since=YYYY-MM-DD string: only return notes modified - since this date - tags=[] list of tags as string: return notes that have at least one of these tags @@ -194,55 +238,56 @@ def get_note_list(self, since=None, tags=[]): # initialize data status = 0 ret = [] - response = {} - notes = { "data" : [] } + response_notes = {} + notes = { "index" : [] } # get the note index - params = 'auth={0}&email={1}&length={2}'.format(self.get_token(), self.username, - NOTE_FETCH_LENGTH) - if since is not None: - try: - sinceUT = time.mktime(datetime.datetime.strptime(since, "%Y-%m-%d").timetuple()) - params += '&since={0}'.format(sinceUT) - except ValueError: - pass + # TODO: Using data=false is actually fine with simplenote.vim - sadly no faster though + params = '/index?limit=%s&data=true' % (str(NOTE_FETCH_LENGTH)) + # perform initial HTTP request + request = Request(DATA_URL+params) + request.add_header(self.header, self.get_token()) try: - request = urllib.request.Request(INDX_URL+params) - response = json.loads(urllib.request.urlopen(request).read().decode('utf-8')) - notes["data"].extend(response["data"]) + response = urllib2.urlopen(request) + response_notes = json.loads(response.read().decode('utf-8')) + # re-write for v1 consistency + note_objects = [] + for n in response_notes["index"]: + note_object = self.__add_simplenote_api_fields(n['d'], n['id'], n['v']) + note_objects.append(note_object) + notes["index"].extend(note_objects) except IOError: status = -1 + # get additional notes if bookmark was set in response - while "mark" in response: - params = 'auth={0}&email={1}&mark={2}&length={3}'.format(self.get_token(), self.username, response["mark"], NOTE_FETCH_LENGTH) - if since is not None: - try: - sinceUT = time.mktime(datetime.datetime.strptime(since, "%Y-%m-%d").timetuple()) - params += '&since=%s' % sinceUT - except ValueError: - pass + while "mark" in response_notes: + params += '&mark=%s' % response_notes["mark"] # perform the actual HTTP request + request = Request(DATA_URL+params) + request.add_header(self.header, self.get_token()) try: - request = urllib.request.Request(INDX_URL+params) - response = json.loads(urllib.request.urlopen(request).read().decode('utf-8')) - notes["data"].extend(response["data"]) + response = urllib2.urlopen(request) + response_notes = json.loads(response.read().decode('utf-8')) + # re-write for v1 consistency + note_objects = [] + for n in response_notes["index"]: + note_object = n['d'] + note_object['version'] = n['v'] + note_object['key'] = n['id'] + note_objects.append(note_object) + notes["index"].extend(note_objects) except IOError: status = -1 - - # parse data fields in response - note_list = notes["data"] - + note_list = notes["index"] # Can only filter for tags at end, once all notes have been retrieved. - #Below based on simplenote.vim, except we return deleted notes as well if (len(tags) > 0): note_list = [n for n in note_list if (len(set(n["tags"]).intersection(tags)) > 0)] - return note_list, status def trash_note(self, note_id): - """ method to move a note to the trash + """ Method to move a note to the trash Arguments: - note_id (string): key of the note to trash @@ -258,13 +303,19 @@ def trash_note(self, note_id): note, status = self.get_note(note_id) if (status == -1): return note, status - # set deleted property - note["deleted"] = 1 - # update note - return self.update_note(note) + # set deleted property, but only if not already trashed + # TODO: A 412 is ok, that's unmodified. Should handle this in update_note and + # then not worry about checking here + if not note["deleted"]: + note["deleted"] = True + note["modificationDate"] = time.time() + # update note + return self.update_note(note) + else: + return 0, note def delete_note(self, note_id): - """ method to permanently delete a note + """ Method to permanently delete a note Arguments: - note_id (string): key of the note to trash @@ -281,11 +332,81 @@ def delete_note(self, note_id): if (status == -1): return note, status - params = '/{0}?auth={1}&email={2}'.format(str(note_id), self.get_token(), - self.username) - request = urllib.request.Request(url=DATA_URL+params, method='DELETE') + params = '/i/%s' % (str(note_id)) + request = Request(url=DATA_URL+params, method='DELETE') + request.add_header(self.header, self.get_token()) try: - urllib.request.urlopen(request) + response = urllib2.urlopen(request) except IOError as e: return e, -1 + except HTTPError as e: + return e, -1 return {}, 0 + + def __add_simplenote_api_fields(self, note, noteid, version): + # Compatibility with original Simplenote API v2.1.5 + note[u'key'] = noteid + note[u'version'] = version + note[u'modifydate'] = note["modificationDate"] + note[u'createdate'] = note["creationDate"] + note[u'systemtags'] = note["systemTags"] + return note + + def __remove_simplenote_api_fields(self, note): + # These two should have already removed by this point since they are + # needed for updating, etc, but _just_ incase... + note.pop("key", None) + note.pop("version", None) + # Let's only set these ones if they exist. We don't want None so we can + # still set defaults afterwards + mappings = { + "modifydate": "modificationDate", + "createdate": "creationDate", + "systemtags": "systemTags" + } + if sys.version_info < (3, 0): + for k,v in mappings.iteritems(): + if k in note: + note[v] = note.pop(k) + else: + for k,v in mappings.items(): + if k in note: + note[v] = note.pop(k) + # Need to add missing dict stuff if missing, might as well do by + # default, not just for note objects only containing content + createDate = time.time() + note_dict = { + "tags" : [], + "systemTags" : [], + "creationDate" : createDate, + "modificationDate" : createDate, + "deleted" : False, + "shareURL" : "", + "publishURL" : "", + } + if sys.version_info < (3, 0): + for k,v in note_dict.iteritems(): + note.setdefault(k, v) + else: + for k,v in note_dict.items(): + note.setdefault(k, v) + return note + +class Request(urllib2.Request): + """ monkey patched version of urllib2's Request to support HTTP DELETE + Taken from http://python-requests.org, thanks @kennethreitz + """ + + if sys.version_info < (3, 0): + def __init__(self, url, data=None, headers={}, origin_req_host=None, + unverifiable=False, method=None): + urllib2.Request.__init__(self, url, data, headers, origin_req_host, unverifiable) + self.method = method + + def get_method(self): + if self.method: + return self.method + + return urllib2.Request.get_method(self) + else: + pass