nncli

NextCloud Notes Command Line Interface
git clone git://git.danielmoch.com/nncli.git
Log | Files | Refs | LICENSE

commit a0d05d3308d40f7b5b00930da747fe4ffcc0d1cc
Author: Eric Davis <edavis@insanum.com>
Date:   Thu, 19 Jun 2014 12:44:56 -0700

notes are now pulled down from simplenote
pulled in the simplenote.py module
pulled in the notes_db.py from nvpy

Diffstat:
Anotes_db.py | 786+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asimplenote.py | 328+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asncli.py | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Autils.py | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 1441 insertions(+), 0 deletions(-)

diff --git a/notes_db.py b/notes_db.py @@ -0,0 +1,786 @@ + +# nvPY: cross-platform note-taking app with simplenote syncing +# copyright 2012 by Charl P. Botha <cpbotha@vxlabs.com> +# new BSD license + +import codecs +import copy +import glob +import os +import json +import logging +from Queue import Queue, Empty +import re +import simplenote +simplenote.NOTE_FETCH_LENGTH=100 +from simplenote import Simplenote + +from threading import Thread +import time +import utils + +ACTION_SAVE = 0 +ACTION_SYNC_PARTIAL_TO_SERVER = 1 +ACTION_SYNC_PARTIAL_FROM_SERVER = 2 # UNUSED. + +class SyncError(RuntimeError): + pass + +class ReadError(RuntimeError): + pass + +class WriteError(RuntimeError): + pass + +class NotesDB(utils.SubjectMixin): + """NotesDB will take care of the local notes database and syncing with SN. + """ + def __init__(self, config): + utils.SubjectMixin.__init__(self) + + self.config = config + + # create db dir if it does not exist + if not os.path.exists(config.db_path): + os.mkdir(config.db_path) + + self.db_path = config.db_path + + now = time.time() + # now read all .json files from disk + fnlist = glob.glob(self.helper_key_to_fname('*')) + + self.notes = {} + + for fn in fnlist: + try: + n = json.load(open(fn, 'rb')) + + except IOError, e: + logging.error('NotesDB_init: Error opening %s: %s' % (fn, str(e))) + raise ReadError ('Error opening note file') + + except ValueError, e: + logging.error('NotesDB_init: Error reading %s: %s' % (fn, str(e))) + raise ReadError ('Error reading note file') + + else: + # we always have a localkey, also when we don't have a note['key'] yet (no sync) + localkey = os.path.splitext(os.path.basename(fn))[0] + self.notes[localkey] = n + # we maintain in memory a timestamp of the last save + # these notes have just been read, so at this moment + # they're in sync with the disc. + n['savedate'] = now + + # save and sync queue + self.q_save = Queue() + self.q_save_res = Queue() + + thread_save = Thread(target=self.worker_save) + thread_save.setDaemon(True) + thread_save.start() + + # initialise the simplenote instance we're going to use + # this does not yet need network access + self.simplenote = Simplenote(config.sn_username, config.sn_password) + + # we'll use this to store which notes are currently being synced by + # the background thread, so we don't add them anew if they're still + # in progress. This variable is only used by the background thread. + self.threaded_syncing_keys = {} + + # reading a variable or setting this variable is atomic + # so sync thread will write to it, main thread will only + # check it sometimes. + self.waiting_for_simplenote = False + + self.q_sync = Queue() + self.q_sync_res = Queue() + + thread_sync = Thread(target=self.worker_sync) + thread_sync.setDaemon(True) + thread_sync.start() + + def create_note(self, title): + # need to get a key unique to this database. not really important + # what it is, as long as it's unique. + new_key = utils.generate_random_key() + while new_key in self.notes: + new_key = utils.generate_random_key() + + timestamp = time.time() + + # note has no internal key yet. + new_note = { + 'content' : title, + 'modifydate' : timestamp, + 'createdate' : timestamp, + 'savedate' : 0, # never been written to disc + 'syncdate' : 0, # never been synced with server + 'tags' : [] + } + + self.notes[new_key] = new_note + + return new_key + + def delete_note(self, key): + n = self.notes[key] + n['deleted'] = 1 + n['modifydate'] = time.time() + + def filter_notes(self, search_string=None): + """Return list of notes filtered with search string. + + Based on the search mode that has been selected in self.config, + this method will call the appropriate helper method to do the + actual work of filtering the notes. + + @param search_string: String that will be used for searching. + Different meaning depending on the search mode. + @return: notes filtered with selected search mode and sorted according + to configuration. Two more elements in tuple: a regular expression + that can be used for highlighting strings in the text widget; the + total number of notes in memory. + """ + + if self.config.search_mode == 'regexp': + filtered_notes, match_regexp, active_notes = self.filter_notes_regexp(search_string) + else: + filtered_notes, match_regexp, active_notes = self.filter_notes_gstyle(search_string) + + if self.config.sort_mode == 0: + if self.config.pinned_ontop == 0: + # sort alphabetically on title + filtered_notes.sort(key=lambda o: utils.get_note_title(o.note)) + else: + filtered_notes.sort(utils.sort_by_title_pinned) + + else: + if self.config.pinned_ontop == 0: + # last modified on top + filtered_notes.sort(key=lambda o: -float(o.note.get('modifydate', 0))) + else: + filtered_notes.sort(utils.sort_by_modify_date_pinned, reverse=True) + + return filtered_notes, match_regexp, active_notes + + def _helper_gstyle_tagmatch(self, tag_pats, note): + if tag_pats: + tags = note.get('tags') + + # tag: patterns specified, but note has no tags, so no match + if not tags: + return 0 + + # for each tag_pat, we have to find a matching tag + for tp in tag_pats: + # at the first match between tp and a tag: + if next((tag for tag in tags if tag.startswith(tp)), None) is not None: + # we found a tag that matches current tagpat, so we move to the next tagpat + continue + + else: + # we found no tag that matches current tagpat, so we break out of for loop + break + + else: + # for loop never broke out due to no match for tagpat, so: + # all tag_pats could be matched, so note is a go. + return 1 + + + # break out of for loop will have us end up here + # for one of the tag_pats we found no matching tag + return 0 + + + else: + # match because no tag: patterns were specified + return 2 + + def _helper_gstyle_mswordmatch(self, msword_pats, content): + """If all words / multi-words in msword_pats are found in the content, + the note goes through, otherwise not. + + @param msword_pats: + @param content: + @return: + """ + + # no search patterns, so note goes through + if not msword_pats: + return True + + # search for the first p that does NOT occur in content + if next((p for p in msword_pats if p not in content), None) is None: + # we only found pats that DO occur in content so note goes through + return True + + else: + # we found the first p that does not occur in content + return False + + + + def filter_notes_gstyle(self, search_string=None): + + filtered_notes = [] + # total number of notes, excluding deleted + active_notes = 0 + + if not search_string: + for k in self.notes: + n = self.notes[k] + if not n.get('deleted'): + active_notes += 1 + filtered_notes.append(utils.KeyValueObject(key=k, note=n, tagfound=0)) + + return filtered_notes, [], active_notes + + # group0: ag - not used + # group1: t(ag)?:([^\s]+) + # group2: multiple words in quotes + # group3: single words + # example result for 't:tag1 t:tag2 word1 "word2 word3" tag:tag3' == + # [('', 'tag1', '', ''), ('', 'tag2', '', ''), ('', '', '', 'word1'), ('', '', 'word2 word3', ''), ('ag', 'tag3', '', '')] + + groups = re.findall('t(ag)?:([^\s]+)|"([^"]+)"|([^\s]+)', search_string) + tms_pats = [[] for _ in range(3)] + + # we end up with [[tag_pats],[multi_word_pats],[single_word_pats]] + for gi in groups: + for mi in range(1,4): + if gi[mi]: + tms_pats[mi-1].append(gi[mi]) + + for k in self.notes: + n = self.notes[k] + + if not n.get('deleted'): + active_notes += 1 + c = n.get('content') + + tagmatch = self._helper_gstyle_tagmatch(tms_pats[0], n) + msword_pats = tms_pats[1] + tms_pats[2] + + if tagmatch and self._helper_gstyle_mswordmatch(msword_pats, c): + # we have a note that can go through! + + # tagmatch == 1 if a tag was specced and found + # tagmatch == 2 if no tag was specced (so all notes go through) + tagfound = 1 if tagmatch == 1 else 0 + # we have to store our local key also + filtered_notes.append(utils.KeyValueObject(key=k, note=n, tagfound=tagfound)) + + return filtered_notes, '|'.join(tms_pats[1] + tms_pats[2]), active_notes + + + def filter_notes_regexp(self, search_string=None): + """Return list of notes filtered with search_string, + a regular expression, each a tuple with (local_key, note). + """ + + if search_string: + try: + sspat = re.compile(search_string) + except re.error: + sspat = None + + else: + sspat = None + + filtered_notes = [] + # total number of notes, excluding deleted ones + active_notes = 0 + for k in self.notes: + n = self.notes[k] + # we don't do anything with deleted notes (yet) + if n.get('deleted'): + continue + + active_notes += 1 + + c = n.get('content') + if self.config.search_tags == 1: + t = n.get('tags') + if sspat: + # this used to use a filter(), but that would by definition + # test all elements, whereas we can stop when the first + # matching element is found + # now I'm using this awesome trick by Alex Martelli on + # http://stackoverflow.com/a/2748753/532513 + # first parameter of next is a generator + # next() executes one step, but due to the if, this will + # either be first matching element or None (second param) + if t and next((ti for ti in t if sspat.search(ti)), None) is not None: + # we have to store our local key also + filtered_notes.append(utils.KeyValueObject(key=k, note=n, tagfound=1)) + + elif sspat.search(c): + # we have to store our local key also + filtered_notes.append(utils.KeyValueObject(key=k, note=n, tagfound=0)) + + else: + # we have to store our local key also + filtered_notes.append(utils.KeyValueObject(key=k, note=n, tagfound=0)) + else: + if (not sspat or sspat.search(c)): + # we have to store our local key also + filtered_notes.append(utils.KeyValueObject(key=k, note=n, tagfound=0)) + + match_regexp = search_string if sspat else '' + + return filtered_notes, match_regexp, active_notes + + def get_note(self, key): + return self.notes[key] + + def get_note_content(self, key): + return self.notes[key].get('content') + + def get_note_status(self, key): + n = self.notes[key] + o = utils.KeyValueObject(saved=False, synced=False, modified=False) + modifydate = float(n['modifydate']) + savedate = float(n['savedate']) + + if savedate > modifydate: + o.saved = True + else: + o.modified = True + + if float(n['syncdate']) > modifydate: + o.synced = True + + return o + + def get_save_queue_len(self): + return self.q_save.qsize() + + + def get_sync_queue_len(self): + return self.q_sync.qsize() + + def helper_key_to_fname(self, k): + return os.path.join(self.db_path, k) + '.json' + + def helper_save_note(self, k, note): + """Save a single note to disc. + + """ + + fn = self.helper_key_to_fname(k) + json.dump(note, open(fn, 'wb'), indent=2) + + # record that we saved this to disc. + note['savedate'] = time.time() + + def sync_note_unthreaded(self, k): + """Sync a single note with the server. + + Update existing note in memory with the returned data. + This is a sychronous (blocking) call. + """ + + note = self.notes[k] + + if not note.get('key') or float(note.get('modifydate')) > float(note.get('syncdate')): + # if has no key, or it has been modified sync last sync, + # update to server + uret = self.simplenote.update_note(note) + + if uret[1] == 0: + # success! + n = uret[0] + + # if content was unchanged, there'll be no content sent back! + if n.get('content', None): + new_content = True + + else: + new_content = False + + now = time.time() + # 1. store when we've synced + n['syncdate'] = now + + # update our existing note in-place! + note.update(n) + + # return the key + return (k, new_content) + + else: + return None + + + else: + # our note is synced up, but we check if server has something new for us + gret = self.simplenote.get_note(note['key']) + + if gret[1] == 0: + n = gret[0] + + if int(n.get('syncnum')) > int(note.get('syncnum')): + n['syncdate'] = time.time() + note.update(n) + return (k, True) + + else: + return (k, False) + + else: + return None + + + def save_threaded(self): + for k,n in self.notes.items(): + savedate = float(n.get('savedate')) + if float(n.get('modifydate')) > savedate or \ + float(n.get('syncdate')) > savedate: + cn = copy.deepcopy(n) + # put it on my queue as a save + o = utils.KeyValueObject(action=ACTION_SAVE, key=k, note=cn) + self.q_save.put(o) + + # in this same call, we process stuff that might have been put on the result queue + nsaved = 0 + something_in_queue = True + while something_in_queue: + try: + o = self.q_save_res.get_nowait() + + except Empty: + something_in_queue = False + + else: + # o (.action, .key, .note) is something that was written to disk + # we only record the savedate. + self.notes[o.key]['savedate'] = o.note['savedate'] + self.notify_observers('change:note-status', utils.KeyValueObject(what='savedate',key=o.key)) + nsaved += 1 + + return nsaved + + + def sync_to_server_threaded(self, wait_for_idle=True): + """Only sync notes that have been changed / created locally since previous sync. + + This function is called by the housekeeping handler, so once every + few seconds. + + @param wait_for_idle: Usually, last modification date has to be more + than a few seconds ago before a sync to server is attempted. If + wait_for_idle is set to False, no waiting is applied. Used by exit + cleanup in controller. + + """ + + # this many seconds of idle time (i.e. modification this long ago) + # before we try to sync. + if wait_for_idle: + lastmod = 3 + else: + lastmod = 0 + + now = time.time() + for k,n in self.notes.items(): + # if note has been modified sinc the sync, we need to sync. + # only do so if note hasn't been touched for 3 seconds + # and if this note isn't still in the queue to be processed by the + # worker (this last one very important) + modifydate = float(n.get('modifydate', -1)) + syncdate = float(n.get('syncdate', -1)) + if modifydate > syncdate and \ + now - modifydate > lastmod and \ + k not in self.threaded_syncing_keys: + # record that we've requested a sync on this note, + # so that we don't keep on putting stuff on the queue. + self.threaded_syncing_keys[k] = True + cn = copy.deepcopy(n) + # we store the timestamp when this copy was made as the syncdate + cn['syncdate'] = time.time() + # put it on my queue as a sync + o = utils.KeyValueObject(action=ACTION_SYNC_PARTIAL_TO_SERVER, key=k, note=cn) + self.q_sync.put(o) + + # in this same call, we read out the result queue + nsynced = 0 + nerrored = 0 + something_in_queue = True + while something_in_queue: + try: + o = self.q_sync_res.get_nowait() + + except Empty: + something_in_queue = False + + else: + okey = o.key + + if o.error: + nerrored += 1 + + else: + # o (.action, .key, .note) is something that was synced + + # we only apply the changes if the syncdate is newer than + # what we already have, since the main thread could be + # running a full sync whilst the worker thread is putting + # results in the queue. + if float(o.note['syncdate']) > float(self.notes[okey]['syncdate']): + + if float(o.note['syncdate']) > float(self.notes[okey]['modifydate']): + # note was synced AFTER the last modification to our local version + # do an in-place update of the existing note + # this could be with or without new content. + old_note = copy.deepcopy(self.notes[okey]) + self.notes[okey].update(o.note) + # notify anyone (probably nvPY) that this note has been changed + self.notify_observers('synced:note', utils.KeyValueObject(lkey=okey, old_note=old_note)) + + else: + # the user has changed stuff since the version that got synced + # just record syncnum and version that we got from simplenote + # if we don't do this, merging problems start happening. + # VERY importantly: also store the key. It + # could be that we've just created the + # note, but that the user continued + # typing. We need to store the new server + # key, else we'll keep on sending new + # notes. + tkeys = ['syncnum', 'version', 'syncdate', 'key'] + for tk in tkeys: + self.notes[okey][tk] = o.note[tk] + + nsynced += 1 + self.notify_observers('change:note-status', utils.KeyValueObject(what='syncdate',key=okey)) + + # after having handled the note that just came back, + # we can take it from this blocker dict + del self.threaded_syncing_keys[okey] + + return (nsynced, nerrored) + + + def sync_full(self): + """Perform a full bi-directional sync with server. + + This follows the recipe in the SimpleNote 2.0 API documentation. + After this, it could be that local keys have been changed, so + reset any views that you might have. + """ + + local_updates = {} + local_deletes = {} + now = time.time() + + self.notify_observers('progress:sync_full', utils.KeyValueObject(msg='Starting full sync.')) + # 1. go through local notes, if anything changed or new, update to server + for ni,lk in enumerate(self.notes.keys()): + n = self.notes[lk] + if not n.get('key') or float(n.get('modifydate')) > float(n.get('syncdate')): + uret = self.simplenote.update_note(n) + if uret[1] == 0: + # replace n with uret[0] + # if this was a new note, our local key is not valid anymore + del self.notes[lk] + # in either case (new or existing note), save note at assigned key + k = uret[0].get('key') + # we merge the note we got back (content coud be empty!) + n.update(uret[0]) + # and put it at the new key slot + self.notes[k] = n + + # record that we just synced + uret[0]['syncdate'] = now + + # whatever the case may be, k is now updated + local_updates[k] = True + if lk != k: + # if lk was a different (purely local) key, should be deleted + local_deletes[lk] = True + + self.notify_observers('progress:sync_full', utils.KeyValueObject(msg='Synced modified note %d to server.' % (ni,))) + + else: + raise SyncError("Sync step 1 error - Could not update note to server") + + # 2. if remote syncnum > local syncnum, update our note; if key is new, add note to local. + # this gets the FULL note list, even if multiple gets are required + self.notify_observers('progress:sync_full', utils.KeyValueObject(msg='Retrieving full note list from server, could take a while.')) + nl = self.simplenote.get_note_list() + if nl[1] == 0: + nl = nl[0] + self.notify_observers('progress:sync_full', utils.KeyValueObject(msg='Retrieved full note list from server.')) + + else: + raise SyncError('Could not get note list from server.') + + server_keys = {} + lennl = len(nl) + sync_from_server_errors = 0 + for ni,n in enumerate(nl): + k = n.get('key') + server_keys[k] = True + # this works, only because in phase 1 we rewrite local keys to + # server keys when we get an updated not back from the server + if k in self.notes: + # we already have this + # check if server n has a newer syncnum than mine + if int(n.get('syncnum')) > int(self.notes[k].get('syncnum', -1)): + # and the server is newer + ret = self.simplenote.get_note(k) + if ret[1] == 0: + self.notes[k].update(ret[0]) + local_updates[k] = True + # in both cases, new or newer note, syncdate is now. + self.notes[k]['syncdate'] = now + self.notify_observers('progress:sync_full', utils.KeyValueObject(msg='Synced newer note %d (%d) from server.' % (ni,lennl))) + + else: + logging.error('Error syncing newer note %s from server: %s' % (k, ret[0])) + sync_from_server_errors+=1 + + else: + # new note + ret = self.simplenote.get_note(k) + if ret[1] == 0: + self.notes[k] = ret[0] + local_updates[k] = True + # in both cases, new or newer note, syncdate is now. + self.notes[k]['syncdate'] = now + self.notify_observers('progress:sync_full', utils.KeyValueObject(msg='Synced new note %d (%d) from server.' % (ni,lennl))) + + else: + logging.error('Error syncing new note %s from server: %s' % (k, ret[0])) + sync_from_server_errors+=1 + + # 3. for each local note not in server index, remove. + for lk in self.notes.keys(): + if lk not in server_keys: + del self.notes[lk] + local_deletes[lk] = True + + # sync done, now write changes to db_path + for uk in local_updates.keys(): + try: + self.helper_save_note(uk, self.notes[uk]) + + except WriteError, e: + raise WriteError(e) + + for dk in local_deletes.keys(): + fn = self.helper_key_to_fname(dk) + if os.path.exists(fn): + os.unlink(fn) + + self.notify_observers('progress:sync_full', utils.KeyValueObject(msg='Full sync complete.')) + + return sync_from_server_errors + + def set_note_content(self, key, content): + n = self.notes[key] + old_content = n.get('content') + if content != old_content: + n['content'] = content + n['modifydate'] = time.time() + self.notify_observers('change:note-status', utils.KeyValueObject(what='modifydate', key=key)) + + def set_note_tags(self, key, tags): + n = self.notes[key] + old_tags = n.get('tags') + tags = utils.sanitise_tags(tags) + if tags != old_tags: + n['tags'] = tags + n['modifydate'] = time.time() + self.notify_observers('change:note-status', utils.KeyValueObject(what='modifydate', key=key)) + + def set_note_pinned(self, key, pinned): + n = self.notes[key] + old_pinned = utils.note_pinned(n) + if pinned != old_pinned: + if 'systemtags' not in n: + n['systemtags'] = [] + + systemtags = n['systemtags'] + + if pinned: + # which by definition means that it was NOT pinned + systemtags.append('pinned') + + else: + systemtags.remove('pinned') + + n['modifydate'] = time.time() + self.notify_observers('change:note-status', utils.KeyValueObject(what='modifydate', key=key)) + + + def worker_save(self): + while True: + o = self.q_save.get() + + if o.action == ACTION_SAVE: + # this will write the savedate into o.note + # with filename o.key.json + try: + self.helper_save_note(o.key, o.note) + + except WriteError, e: + logging.error('FATAL ERROR in access to file system') + print "FATAL ERROR: Check the nvpy.log" + os._exit(1) + + else: + # put the whole thing back into the result q + # now we don't have to copy, because this thread + # is never going to use o again. + # somebody has to read out the queue... + self.q_save_res.put(o) + + def worker_sync(self): + while True: + o = self.q_sync.get() + + if o.action == ACTION_SYNC_PARTIAL_TO_SERVER: + self.waiting_for_simplenote = True + if 'key' in o.note: + logging.debug('Updating note %s (local key %s) to server.' % (o.note['key'], o.key)) + + else: + logging.debug('Sending new note (local key %s) to server.' % (o.key,)) + + uret = self.simplenote.update_note(o.note) + self.waiting_for_simplenote = False + + if uret[1] == 0: + # success! + n = uret[0] + + if not n.get('content', None): + # if note has not been changed, we don't get content back + # delete our own copy too. + del o.note['content'] + + logging.debug('Server replies with updated note ' + n['key']) + + # syncdate was set when the note was copied into our queue + # we rely on that to determine when a returned note should + # overwrite a note in the main list. + + # store the actual note back into o + # in-place update of our existing note copy + o.note.update(n) + + # success! + o.error = 0 + + # and put it on the result queue + self.q_sync_res.put(o) + + else: + o.error = 1 + self.q_sync_res.put(o) + diff --git a/simplenote.py b/simplenote.py @@ -0,0 +1,328 @@ +# -*- coding: utf-8 -*- +""" + simplenote.py + ~~~~~~~~~~~~~~ + + Python library for accessing the Simplenote API + + :copyright: (c) 2011 by Daniel Schauenberg + :license: MIT, see LICENSE for more details. +""" + +import urllib +import urllib2 +from urllib2 import HTTPError +import base64 +import time +import datetime + +try: + import json +except ImportError: + try: + import simplejson as json + except ImportError: + # 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 + +class SimplenoteLoginFailed(Exception): + pass + + +class Simplenote(object): + """ Class for interacting with the simplenote web service """ + + def __init__(self, username, password): + """ object constructor """ + self.username = urllib2.quote(username) + self.password = urllib2.quote(password) + self.token = None + + def authenticate(self, user, password): + """ Method to get simplenote auth token + + Arguments: + - user (string): simplenote email address + - password (string): simplenote password + + Returns: + Simplenote API token as string + + """ + auth_params = "email=%s&password=%s" % (user, password) + values = base64.encodestring(auth_params) + request = Request(AUTH_URL, values) + try: + res = urllib2.urlopen(request).read() + token = urllib2.quote(res) + except HTTPError: + raise SimplenoteLoginFailed('Login to Simplenote API failed!') + except IOError: # no connection exception + token = None + return token + + def get_token(self): + """ Method to retrieve an auth token. + + The cached global token is looked up and returned if it exists. If it + is `None` a new one is requested and returned. + + Returns: + Simplenote API token as string + + """ + if self.token == None: + self.token = self.authenticate(self.username, self.password) + return self.token + + + def get_note(self, noteid, version=None): + """ method to get a specific note + + Arguments: + - noteid (string): ID of the note to get + - version (int): optional version of the note to get + + Returns: + A tuple `(note, status)` + + - note (dict): note object + - status (int): 0 on sucesss and -1 otherwise + + """ + # request note + params_version = "" + if version is not None: + params_version = '/' + str(version) + + params = '/%s%s?auth=%s&email=%s' % (str(noteid), params_version, self.get_token(), self.username) + request = Request(DATA_URL+params) + try: + response = urllib2.urlopen(request) + except HTTPError, e: + return e, -1 + except IOError, e: + return e, -1 + note = json.loads(response.read()) + # use UTF-8 encoding + note["content"] = note["content"].encode('utf-8') + # For early versions of notes, tags not always available + if note.has_key("tags"): + note["tags"] = [t.encode('utf-8') for t in note["tags"]] + return note, 0 + + def update_note(self, note): + """ function to update a specific note object, if the note object does not + have a "key" field, a new note is created + + Arguments + - note (dict): note object to update + + Returns: + A tuple `(note, status)` + + - note (dict): note object + - status (int): 0 on sucesss and -1 otherwise + + """ + # use UTF-8 encoding + note["content"] = unicode(note["content"], 'utf-8') + if "tags" in note: + note["tags"] = [unicode(t, 'utf-8') for t in note["tags"]] + + # determine whether to create a new note or updated an existing one + if "key" in note: + # set modification timestamp if not set by client + if 'modifydate' not in note: + note["modifydate"] = time.time() + + url = '%s/%s?auth=%s&email=%s' % (DATA_URL, note["key"], + self.get_token(), self.username) + else: + url = '%s?auth=%s&email=%s' % (DATA_URL, self.get_token(), self.username) + request = Request(url, urllib.quote(json.dumps(note))) + response = "" + try: + response = urllib2.urlopen(request) + except IOError, e: + return e, -1 + note = json.loads(response.read()) + if note.has_key("content"): + # use UTF-8 encoding + note["content"] = note["content"].encode('utf-8') + if note.has_key("tags"): + note["tags"] = [t.encode('utf-8') for t in note["tags"]] + return note, 0 + + def add_note(self, note): + """wrapper function to add a note + + The function 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. + + Arguments: + - note (dict or string): the note to add + + Returns: + A tuple `(note, status)` + + - note (dict): the newly created note + - status (int): 0 on sucesss and -1 otherwise + + """ + if type(note) == str: + return self.update_note({"content": note}) + elif (type(note) == dict) and "content" in note: + return self.update_note(note) + else: + return "No string or valid note.", -1 + + def get_note_list(self, since=None, tags=[]): + """ function 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. + + 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 + + Returns: + A tuple `(notes, status)` + + - notes (list): A list of note objects with all properties set except + `content`. + - status (int): 0 on sucesss and -1 otherwise + + """ + # initialize data + status = 0 + ret = [] + response = {} + notes = { "data" : [] } + + # get the note index + params = 'auth=%s&email=%s&length=%s' % (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=%s' % sinceUT + except ValueError: + pass + + # perform initial HTTP request + try: + request = Request(INDX_URL+params) + response = json.loads(urllib2.urlopen(request).read()) + notes["data"].extend(response["data"]) + except IOError: + status = -1 + + # get additional notes if bookmark was set in response + while "mark" in response: + vals = (self.get_token(), self.username, response["mark"], NOTE_FETCH_LENGTH) + params = 'auth=%s&email=%s&mark=%s&length=%s' % vals + if since is not None: + try: + sinceUT = time.mktime(datetime.datetime.strptime(since, "%Y-%m-%d").timetuple()) + params += '&since=%s' % sinceUT + except ValueError: + pass + + # perform the actual HTTP request + try: + request = Request(INDX_URL+params) + response = json.loads(urllib2.urlopen(request).read()) + notes["data"].extend(response["data"]) + except IOError: + status = -1 + + # parse data fields in response + note_list = notes["data"] + + # 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 + + Arguments: + - note_id (string): key of the note to trash + + Returns: + A tuple `(note, status)` + + - note (dict): the newly created note or an error message + - status (int): 0 on sucesss and -1 otherwise + + """ + # get note + 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) + + def delete_note(self, note_id): + """ method to permanently delete a note + + Arguments: + - note_id (string): key of the note to trash + + Returns: + A tuple `(note, status)` + + - note (dict): an empty dict or an error message + - status (int): 0 on sucesss and -1 otherwise + + """ + # notes have to be trashed before deletion + note, status = self.trash_note(note_id) + if (status == -1): + return note, status + + params = '/%s?auth=%s&email=%s' % (str(note_id), self.get_token(), + self.username) + request = Request(url=DATA_URL+params, method='DELETE') + try: + urllib2.urlopen(request) + except IOError, e: + return e, -1 + return {}, 0 + + +class Request(urllib2.Request): + """ monkey patched version of urllib2's Request to support HTTP DELETE + Taken from http://python-requests.org, thanks @kennethreitz + """ + + 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) + + diff --git a/sncli.py b/sncli.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python2 + +import os, sys, signal, time, logging, json +import ConfigParser +from simplenote import Simplenote +from notes_db import NotesDB, SyncError, ReadError, WriteError +from logging.handlers import RotatingFileHandler + +class Config: + + def __init__(self): + self.home = os.path.abspath(os.path.expanduser('~')) + defaults = { + 'sn_username' : 'edavis@insanum.com', + 'sn_password' : 'biteme55', + 'db_path' : os.path.join(self.home, '.sncli'), + 'search_mode' : 'gstyle', + 'search_tags' : '1', + 'sort_mode' : '1', + 'pinned_ontop' : '1', + } + + cp = ConfigParser.SafeConfigParser(defaults) + self.configs_read = cp.read([os.path.join(self.home, '.snclirc')]) + + cfg_sec = 'sncli' + + if not cp.has_section(cfg_sec): + cp.add_section(cfg_sec) + self.ok = False + else: + self.ok = True + + self.sn_username = cp.get(cfg_sec, 'sn_username', raw=True) + self.sn_password = cp.get(cfg_sec, 'sn_password', raw=True) + self.db_path = cp.get(cfg_sec, 'db_path') + self.search_mode = cp.get(cfg_sec, 'search_mode') + self.search_tags = cp.getint(cfg_sec, 'search_tags') + self.sort_mode = cp.getint(cfg_sec, 'sort_mode') + self.pinned_ontop = cp.getint(cfg_sec, 'pinned_ontop') + +class sncli: + + def __init__(self): + self.config = Config() + + if not os.path.exists(self.config.db_path): + os.mkdir(self.config.db_path) + + # configure the logging module + self.logfile = os.path.join(self.config.db_path, 'sncli.log') + self.loghandler = RotatingFileHandler(self.logfile, maxBytes=100000, backupCount=1) + self.loghandler.setLevel(logging.DEBUG) + self.loghandler.setFormatter(logging.Formatter(fmt='%(asctime)s [%(levelname)s] %(message)s')) + self.logger = logging.getLogger() + self.logger.setLevel(logging.DEBUG) + self.logger.addHandler(self.loghandler) + logging.debug('sncli logging initialized') + + try: + self.ndb = NotesDB(self.config) + except Exception, e: + print("ERROR: Please check sncli.log") + print(e) + exit(1) + + self.ndb.add_observer('synced:note', self.observer_notes_db_synced_note) + self.ndb.add_observer('change:note-status', self.observer_notes_db_change_note_status) + self.ndb.add_observer('progress:sync_full', self.observer_notes_db_sync_full) + self.sync_full() + + def do_it(self): + while True: + time.sleep(1) + + def sync_full(self): + try: + sync_from_server_errors = self.ndb.sync_full() + except Exception, e: + print("ERROR: Please check sncli.log") + print(e) + exit(1) + else: + if sync_from_server_errors > 0: + print('Error syncing %d notes from server. Please check sncli.log for details.' % (sync_from_server_errors)) + + def set_note_status(self, msg): + print(msg) + + def observer_notes_db_change_note_status(self, ndb, evt_type, evt): + skey = self.get_selected_note_key() + if skey == evt.key: + # XXX + #self.view.set_note_status(self.ndb.get_note_status(skey)) + self.set_note_status(self.ndb.get_note_status(skey)) + + def set_status_text(self, msg): + print(msg) + + def observer_notes_db_sync_full(self, ndb, evt_type, evt): + logging.debug(evt.msg) + # XXX + #self.view.set_status_text(evt.msg) + self.set_status_text(evt.msg) + + def observer_notes_db_synced_note(self, ndb, evt_type, evt): + """This observer gets called only when a note returns from + a sync that's more recent than our most recent mod to that note. + """ + + selected_note_o = self.notes_list_model.list[self.selected_note_idx] + # if the note synced back matches our currently selected note, + # we overwrite. + + # XXX + #if selected_note_o.key == evt.lkey: + # if selected_note_o.note['content'] != evt.old_note['content']: + # self.view.mute_note_data_changes() + # # in this case, we want to keep the user's undo buffer so that they + # # can undo synced back changes if they would want to. + # self.view.set_note_data(selected_note_o.note, reset_undo=False) + # self.view.unmute_note_data_changes() + + +def SIGINT_handler(signum, frame): + print('Signal caught, bye!') + sys.exit(1) + +signal.signal(signal.SIGINT, SIGINT_handler) + +def main(): + SNCLI = sncli() + SNCLI.do_it() + +if __name__ == '__main__': + main() + +#notes_list, status = sn.get_note_list() +#if status == -1: +# exit(1) + +#for i in notes_list: +# note = sn.get_note(i['key'], version=i['version']) +# if note[1] == 0: +# print '-----------------------------------' +# print i['key'] +# print note[0]['content'] + diff --git a/utils.py b/utils.py @@ -0,0 +1,179 @@ +# nvPY: cross-platform note-taking app with simplenote syncing +# copyright 2012 by Charl P. Botha <cpbotha@vxlabs.com> +# new BSD license + +import datetime +import random +import re +import string +import urllib2 + +# first line with non-whitespace should be the title +note_title_re = re.compile('\s*(.*)\n?') + +def generate_random_key(): + """Generate random 30 digit (15 byte) hex string. + + stackoverflow question 2782229 + """ + return '%030x' % (random.randrange(256**15),) + +def get_note_title(note): + mo = note_title_re.match(note.get('content', '')) + if mo: + return mo.groups()[0] + else: + return '' + +def get_note_title_file(note): + mo = note_title_re.match(note.get('content', '')) + if mo: + fn = mo.groups()[0] + fn = fn.replace(' ', '_') + fn = fn.replace('/', '_') + if not fn: + return '' + + if isinstance(fn, str): + fn = unicode(fn, 'utf-8') + else: + fn = unicode(fn) + + if note_markdown(note): + fn += '.mkdn' + else: + fn += '.txt' + + return fn + else: + return '' + +def human_date(timestamp): + """ + Given a timestamp, return pretty human format representation. + + For example, if timestamp is: + * today, then do "15:11" + * else if it is this year, then do "Aug 4" + * else do "Dec 11, 2011" + """ + + # this will also give us timestamp in the local timezone + dt = datetime.datetime.fromtimestamp(timestamp) + # this returns localtime + now = datetime.datetime.now() + + if dt.date() == now.date(): + # today: 15:11 + return dt.strftime('%H:%M') + + elif dt.year == now.year: + # this year: Aug 6 + # format code %d unfortunately 0-pads + return dt.strftime('%b') + ' ' + str(dt.day) + + else: + # not today or this year, so we do "Dec 11, 2011" + return '%s %d, %d' % (dt.strftime('%b'), dt.day, dt.year) + +def note_pinned(n): + asystags = n.get('systemtags', 0) + # no systemtag at all + if not asystags: + return 0 + + if 'pinned' in asystags: + return 1 + else: + return 0 + +def note_markdown(n): + asystags = n.get('systemtags', 0) + # no systemtag at all + if not asystags: + return 0 + + if 'markdown' in asystags: + return 1 + else: + return 0 + +tags_illegal_chars = re.compile(r'[\s]') +def sanitise_tags(tags): + """ + Given a string containing comma-separated tags, sanitise and return a list of string tags. + + The simplenote API doesn't allow for spaces, so we strip those out. + + @param tags: Comma-separated tags, one string. + @returns: List of strings. + """ + + # hack out all kinds of whitespace, then split on , + # if you run into more illegal characters (simplenote does not want to sync them) + # add them to the regular expression above. + illegals_removed = tags_illegal_chars.sub('', tags) + if len(illegals_removed) == 0: + # special case for empty string '' + # split turns that into [''], which is not valid + return [] + + else: + return illegals_removed.split(',') + +def sort_by_title_pinned(a, b): + if note_pinned(a.note) and not note_pinned(b.note): + return -1 + elif not note_pinned(a.note) and note_pinned(b.note): + return 1 + else: + return cmp(get_note_title(a.note), get_note_title(b.note)) + +def sort_by_modify_date_pinned(a, b): + if note_pinned(a.note) and not note_pinned(b.note): + return 1 + elif not note_pinned(a.note) and note_pinned(b.note): + return -1 + else: + return cmp(float(a.note.get('modifydate', 0)), float(b.note.get('modifydate', 0))) + +class KeyValueObject: + """Store key=value pairs in this object and retrieve with o.key. + + You should also be able to do MiscObject(**your_dict) for the same effect. + """ + + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + +class SubjectMixin: + """Maintain a list of callables for each event type. + + We follow the convention action:object, e.g. change:entry. + """ + + def __init__(self): + self.observers = {} + self.mutes = {} + + def add_observer(self, evt_type, o): + if evt_type not in self.observers: + self.observers[evt_type] = [o] + + elif o not in self.observers[evt_type]: + self.observers[evt_type].append(o) + + def notify_observers(self, evt_type, evt): + if evt_type in self.mutes or evt_type not in self.observers: + return + + for o in self.observers[evt_type]: + # invoke observers with ourselves as first param + o(self, evt_type, evt) + + def mute(self, evt_type): + self.mutes[evt_type] = True + + def unmute(self, evt_type): + if evt_type in self.mutes: + del self.mutes[evt_type]