Commits

Steve Cooper committed dc3b087

first version

Comments (0)

Files changed (5)

+<menu>
+  <item caption="List Simplenote" command="listSimplenoteItems"/>
+</menu>

Default.sublime-keymap

+<bindings>
+ <!-- <binding key="alt+x,alt+s,alt+n" command="listSimplenoteItems" /> -->
+ <binding key="ctrl+alt+s,ctrl+alt+n" command="listSimplenoteItems" />
+</bindings> 
+from urllib import urlopen        # standard Python library
+from base64 import b64encode      # standard Python library
+import json
+import pickle
+import os, re
+from threading import Thread
+
+class Note:
+  """
+  Describes a single document in simplenote
+  """
+
+  def __init__(self, note):
+    """
+    constructs a pyton Note object from the dictionary returned
+    from the network
+    """
+    self.key = note['key']
+    self.modify = note['modify']
+    self.deleted = note['deleted']
+
+class SimpleNoteApi:
+  """
+  Abstraction of the simplenote api, with CRUD operations
+  """
+  
+  def report(self,message):
+    """
+    a boring report function, for overriding like an 'event
+    handler'
+    """
+    print message
+  
+  def __init__(self, email, password):
+    """
+    Construct an object which interfaces with the simplenote web
+    server;  requires your login details. Careful not to store
+    these in source control!
+
+    @param email:the email address you use to log in to simplenote
+    @param password:the plain text password you use to log in to simplenote. Yes, I understand this is shitty security.
+    """
+    self.email = email
+    self.password = password
+    self.baseURL = u'https://simple-note.appspot.com/api/note?key=%s&auth=%s&email=%s' 
+    self.token = None
+    
+  def noteURL(self, key):
+    """
+    generates a download link for the note with the given key
+    """
+    url = self.baseURL % (key, self.token, self.email)
+    return url
+    
+  def ensureToken(self):
+    """
+    make sure we've got an authorization token for API calls.
+    """
+    if self.token == None:
+      self.token = self.getAuthToken()
+
+  def getAuthToken(self):
+    """
+    Get my authorization token for later calls.
+    """
+    self.report("authorising %s" % self.email)
+    loginURL = 'https://simple-note.appspot.com/api/login'
+    creds = b64encode('email=%s&password=%s' % (self.email, self.password))
+    login = urlopen(loginURL, creds)
+    token = login.readline().rstrip()
+    login.close()
+    return token
+
+  def getNoteList(self):    
+    """
+    get the list of notes stored on the simplenote server
+    """
+    self.report("listing notes for %s" % self.email)
+    self.ensureToken()
+    indexURL = 'https://simple-note.appspot.com/api/index?auth=%s&email=%s' % (self.token, self.email)
+    index = urlopen(indexURL)
+    noteList = json.load(index)
+    
+    # remove the deleted notes
+    noteList = [item for item in noteList if not self._deleted(item)]
+    
+    # convert to a dictionary by key
+    noteDictionary = dict()
+    for item in noteList:
+      note = Note(item)
+      noteDictionary[note.key] = note
+      
+    return noteDictionary
+
+   
+  def _deleted(self, note):
+    """
+    tells you if a note object has been deleted; it seems some
+    notes can be marked deleted without actually being removed
+    from the simplenote server.
+    """
+    return note['deleted']
+
+  def deleteNote(self, key):
+    """
+    Removes a note from the simplenote server
+
+    @param key:the key for the note
+    @type key:string
+    """
+    self.report("deleting '%s' from %s" % (key, self.email))
+    self.ensureToken()
+    url = 'https://simple-note.appspot.com/api/delete?key=%s&auth=%s&email=%s' % (key, self.token, self.email)
+    response = urlopen(url)
+    result = response.read().rstrip()
+    response.close()
+    return result
+    
+  def setNoteContent(self, content, key):
+    """
+    updates a note with the given content on the simplenote server.
+
+    @type content:string
+    @param content:the new content for the note
+    @param key:the key for the note
+    @type key:string
+    """
+    self.report("saving '%s' to %s" % (key,self.email))
+    self.ensureToken()
+    enccontent = b64encode(content.encode('utf-8'))
+    url = self.noteURL(key)
+    response = urlopen(url, enccontent)
+    result = response.read().rstrip()
+    response.close()
+    return result
+
+  def createNote(self, content):
+    """
+    Create a new note on the simplenote server, and return the key of the newly-created note.
+
+    @type content:string
+    @param content:the content for the new note
+    """
+    self.report("creating new note for %s" % self.email)
+    return self.setNoteContent(content, '')
+    
+  def noteStream(self, key):
+    """
+    opens the note and returns a streamlike web response object
+
+    @param key:the key for the note
+    @type key:string
+    """
+    self.ensureToken()
+    noteURL = self.noteURL(key)
+    stream = urlopen(noteURL)
+    return stream
+    
+  def getFirstLine(self, key):
+    """
+    gets the first line of text in the file -- what Simplenote displays as the title.
+
+    @param key:the key for the note
+    @type key:string
+    """
+    firstLine = self.getNoteContent(key) #noteStream(key).readline().decode('utf-8')
+    title = self.cleanFirstLine(firstLine)
+    return title
+    
+  def cleanFirstLine(self, line):
+    """
+    Takes a string and truncates it to a short line with no line
+    breaks. Also removes a single preceding '%' character, because
+    I (steve cooper) have documents with these '%' marks that I
+    want shown without.
+
+    @param line:the raw content to clean up
+    @type line:string 
+    """
+    line = line.rstrip()[:72]
+    if line.startswith('%'):
+      line = line[1:]
+    firstCrAt = line.find("\n")
+    if firstCrAt != -1:
+      line = line[:firstCrAt]
+    return line
+
+  def getNoteContent(self, key):
+    """
+    gets all the content of the note
+
+    @param key:the key for the note
+    @type key:string
+    """
+    self.report("reading '%s' from %s" % (key,self.email))
+    content = self.noteStream(key).read().decode('utf-8')
+    return content
+
+class FileCache:
+  """
+  Saves local versions of files, along with their  modification
+  date, so that we have a local version of every file
+  """
+  
+  def __init__(self, api, setup):
+    self.api = api
+    self.setup = setup
+  
+  def _localFile(self, key, modified):
+    cacheFileName = "%s/%s.%s.%s" % (self.setup.workingDirectory, key, modified.replace(":", "").replace(" ", ""), self.setup.editorExtension)
+    return cacheFileName
+    
+  def isCached(self, key, modified):
+    fileName = self._localFile(key, modified)
+    return os.path.exists(fileName)
+    
+  def getNoteContent(self, key, modified):
+    fileName = self._localFile(key, modified)
+    if os.path.exists(fileName):
+      content = open(fileName, 'r').readall()
+    else:
+      content = self.api.getNoteContent(key)
+      f = open(fileName, 'w')
+      f.write(content)
+      f.close()
+    return content
+
+  def getNoteContentAsync(self, key, modified):
+    fileName = self._localFile(key, modified)
+    if os.path.exists(fileName):
+      thread = StringReadingThread(fileName)
+    else:
+      thread = GetNoteContentThread(self.api, key)
+    thread.start()
+    return thread
+
+class GetNoteContentThread(Thread):
+  def __init__(self, api, key):
+    Thread.__init__(self)
+    self.api = api
+    self.key = key
+    self.content = ""
+    
+  def run(self):
+    print "Running thread for key %s" % self.key
+    self.content = self.api.getNoteContent(self.key)    
+    f = open(fileName, 'w')
+    f.write(content)
+    f.close()
+
+class StringReadingThread(Thread):
+  def __init__(self, fileName):
+    Thread.__init__(self)
+    self.fileName = fileName
+    self.content = ""
+  
+  def run(self):
+    print "reading from file at %s" % self.fileName
+    if not os.path.exists(self.fileName):
+      print "no file at %s" % self.fileName
+    f = open(self.fileName, 'r')
+    result = f.readall()
+    self.content = result
+    
+    
+
+class TitleCache:
+  """If you have a lot of notes, it can take ages to 
+  download each one to get the first line or title. 
+  This is a cache of titles, saved to disk, which 
+  remembers what the titles where at a particular
+  point in time. This drastically speeds up displaying 
+  a list of names"""
+
+  def __init__(self, api):
+    self.api = api
+    self.modified = False
+
+  def report(self,message):
+    # a boring report function, for overriding like an 'event handler'
+    print message
+
+  def loadDictionary(self):
+    dictionaryPath = os.path.expandvars('$APPDATA\simplenote-sublime-text-cache.txt')
+    if not os.path.exists(dictionaryPath):
+      self.report("creating new dictionary")
+      self.dictionary = dict()
+    else:
+      self.report("loading dictionary from %s" % dictionaryPath)
+      pkl_file = open(dictionaryPath, 'rb')
+      self.dictionary = pickle.load(pkl_file)
+      pkl_file.close()
+      self.report("loaded dictionary from %s" % dictionaryPath)
+  
+  def saveDictionary(self):
+    dictionaryPath = os.path.expandvars('$APPDATA\simplenote-sublime-text-cache.txt')
+    if self.modified:
+      self.report("saving dictionary to %s" % dictionaryPath)
+      output = open(dictionaryPath, 'wb')
+      pickle.dump(self.dictionary, output)
+      output.close()
+      self.report("saved dictionary to %s" % dictionaryPath)
+   
+  def reset(self):
+    self.report("resetting dictionary")
+    self.dictionary = dict()
+    self.modified = True
+    self.saveDictionary()
+
+  def getFirstLine(self, key, modified):
+    if key in self.dictionary:
+      existingModified, firstLine = self.dictionary[key]
+      print "modified at %s, previously %s" % (modified, existingModified)
+      if existingModified < modified:
+        # old version of the first line
+        self.dictionary[key] = (modified, firstLine)
+        self.modified = True
+      return firstLine
+    else:
+      # not present in the dictionary
+      print "key not present; %s %s" % (modified, key)
+      firstLine = self.api.getFirstLine(key)
+      self.dictionary[key] = (modified, firstLine)
+      self.modified = True
+      return firstLine
+      
+
+
+class SimpleNoteApiSetup():
+  """
+  
+  Loads the api login details, such as username and password, and
+  optional properties like the folder to hold temporary files, and
+  the extension you want for the files, so that sublime loads them
+  with appropriate syntax highlighting. Files look like;::
+
+    username:hektor@troy.com
+    password:mypassword
+    working directory:c:\\temp
+    editor extension: text
+
+  The file must be saved in your windows APPDATA folder and is called 'simplenote-api-key.txt'
+
+  """
+
+  def __init__(self):
+    self.username = ""
+    self.password = ""
+    self.editorExtension = "txt"
+    
+  def Load(self):
+    userDetails = os.path.expandvars("$APPDATA\\simplenote-api-key.txt")
+    if not os.path.exists(userDetails):
+      self.error = "Could not find config file at '%s'" % userDetails
+      return False
+    else:
+      d = dict()
+      for line in open(userDetails,'r'):
+        m = re.search('(?P<key>.*?):(?P<val>.*)', line) 
+        key = m.group('key').strip()
+        val = m.group('val').strip()
+        d[key] = val
+      
+      self.error = ""
+      if 'username' in d:
+        self.username = d['username']
+      else:
+        self.error= self.error + " no username"
+      if 'password' in d:
+        self.password = d['password']
+      else:
+        self.error = self.error + " no password"
+      if 'working directory' in d:
+        self.workingDirectory = d['working directory'].lower()
+      else:
+        self.workingDirectory = os.path.expandvars("$TMP").lower()
+      if 'editor extension' in d:
+        self.editorExtension = d['editor extension']          
+      return len(self.error) == 0
+import sublime, sublimeplugin, os, re
+import simplenote
+import codecs
+
+def report(message):
+  """Writes a status message for the simplenote plugin"""
+  print "REPORT: %s" % message
+  wnd = sublime.activeWindow()
+  if wnd == None:
+    return
+  vw = wnd.activeView()
+  if vw == None:
+    return
+  vw.setStatus('simplenote integration',message)
+
+
+class ListSimplenoteItemsCommand(sublimeplugin.WindowCommand):
+  """Loads the list of notes from the simplenote server, 
+  and displays it to the user. Note titles are cached for speed."""
+  
+  def __init__(self):
+    self.cache = cache
+  
+  def noteSortKey(self, item):
+    title, key = item
+    if title.startswith('['):
+      title = title[1:]
+    return title.lower()
+  
+  def run(self, window, args):
+    noteList = api.getNoteList()
+        
+    items = []
+
+    for key in noteList: #noteList:
+      note= noteList[key]
+      modified = note.modify
+      title = self.cache.getFirstLine(key, modified)
+      if note.deleted:
+        title = title + " (deleted)"
+      items.append( (title, key) )
+    
+    items.sort(key=self.noteSortKey)
+    items.append ( ('<reset title cache: only required if lines look blank>', '<reset title cache>') )
+    items.append ( ('<new note>', '<new note>') )
+
+    self.cache.saveDictionary()
+    
+    enabled  = [(name, cmd) for (name, cmd) in items]
+    commands = [x for x,y in enabled]
+    names    = [y for x,y in enabled]
+    
+    window.showQuickPanel("", "editSelectedNote", names, commands)
+    report("")
+    
+class EditSelectedNoteCommand(sublimeplugin.WindowCommand):
+  """Called in repsonse to the quick panel; we expect either a note key, 
+  an instruction to create a new note, or other command ID"""
+  
+  def run(self, window, args):
+    if len(args) != 1:
+      print "%s items selected; expected 1" % len(args)
+      return
+    key = args[0]
+    print "selected key %s" % key
+    
+    if key == '<new note>':
+      # the user has asked to create a new note
+      # we create a note with trivial content on the server
+      report("creating new note")
+      content = "New Note from sublime text"
+      key = api.createNote(content)
+    elif key == '<reset title cache>':      
+      # sometimes the titles read incorrectly. This will
+      # reload the titles from the server next time they
+      # are requested
+      report("resetting title cache")
+      cache.reset()
+      report("")
+      return
+    else:
+      # this is the key for an existing
+      # note. Download the content
+      content = api.getNoteContent(key)
+      # thread = api.getNoteContentAsync(key)
+      # print "thread"
+      # thread.join()
+      # content = thread.content
+
+    
+    # create a short title, used to create a meaningful
+    # filename for the text editor.
+    title = api.cleanFirstLine(content[:72]).replace(" ", "-")
+    
+    # create the filename. Note that everything after '-simplenote-' is a key.
+    filename = os.path.join(workingDirectory, '%s-simplenote-%s.%s' % (title,key,editorExtension))
+    report("Editing %s" % filename)
+    
+    # write the content into a file, then open it in the editor.
+    f = codecs.open(filename,'w','utf-8')
+    f.write(content)
+    f.close()
+    report("")
+    view = window.openFile(filename, 0,0)
+
+class SimpleNotePlugin(sublimeplugin.Plugin):
+  
+    def onPostSave(self, view):
+      print "onPostSave"
+      # what file just got saved?
+      filename = view.fileName()
+      
+      folder, fileOnly = os.path.split(filename)
+      
+      # the folder must be the same as our working directory,
+      # or this is definitely not a simplenote file      
+      folder = folder.lower()
+
+      if (folder!=workingDirectory):
+        report("onPostSave: not a simplenote file: %s is not %s" % (folder, workingDirectory))
+        return
+      
+      # we can extract the simplenote key by matching the right pattern
+      pattern = "^(.*)-simplenote-(.*?)." + re.escape(editorExtension) + "$"
+      report(pattern)
+      m = re.search(pattern, fileOnly)
+      if m == None:
+        report("")
+        return 
+      else:
+        key = m.groups(0)[1]
+        report("This is a simplenote working file, with a key of '%s'" % key)
+        content = view.substr(sublime.Region(0, view.size()))
+        
+        if (content.strip() == ""):
+          # no content in the file -- delete it
+          api.deleteNote(key)
+          report("delted %s from %s" % (key, simplenoteuser))
+          sublime.messageBox("Deleted")
+        else:
+          api.setNoteContent(content, key)
+          report("saved %s to %s" % (key, simplenoteuser))
+          
+          # verification - download the file again and compare
+          verification = api.getNoteContent(key)
+          if (content == verification):
+            report("saved and verified")
+            sublime.messageBox("Saved and Verified")
+          else:
+            sublime.messageBox("FAIL: saved but verification failed.")
+          
+        report("")
+
+setup = simplenote.SimpleNoteApiSetup()
+loaded = setup.Load()
+if not loaded:
+  print "FAIL: could not load: err='%s'" % setup.error
+  cache = None
+else:
+  editorExtension = setup.editorExtension
+  workingDirectory = setup.workingDirectory
+  simplenoteuser = setup.username
+  simplenotepass = setup.password
+  
+  api = simplenote.SimpleNoteApi(simplenoteuser, simplenotepass)    
+  api.report = report
+  cache = simplenote.TitleCache(api)
+  cache.report = report
+  cache.loadDictionary()
+  fileCache = simplenote.FileCache(api, setup)
+# coding=utf-8
+import simplenote
+import hashlib
+import os
+import re
+
+print "loading tests"
+if __name__ == "__main__":
+  
+  class FakeApi:
+    def __init__(self):
+      pass
+    
+    def getNoteContent(self, key):
+      return "NOTE CONTENT"
+  
+  class FakeSetup:
+    def __init__(self, user, password, ext, wd):
+      self.userName = user
+      self.password = password
+      self.editorExtension = ext
+      self.workingDirectory = wd
+      
+  def testFileCache():
+    api = FakeApi()
+    testWorkingDir = "c:/temp/testcache_deletable"
+    if not os.path.exists(testWorkingDir):
+      os.makedirs(testWorkingDir)
+    for f in os.listdir(testWorkingDir):
+      f = os.path.join(testWorkingDir, f)
+      os.unlink(f)
+    setup = FakeSetup("", "", "text", testWorkingDir)
+    cache = simplenote.FileCache(api, setup)
+    if cache.isCached("key1", "2010-03-03 23:32:12"):
+      return "FAIL: cache should be empty"
+    content = cache.getNoteContent("key1", "2010-03-03 23:32:12")
+    if content != "NOTE CONTENT":
+      return "FAIL: wrong content"
+    thread = cache.getNoteContentAsync("key1", "2010-03-03 23:32:12")
+    thread.join()
+    content = thread.content
+    if content != "NOTE CONTENT":
+      return "FAIL: wrong content async"
+    if not cache.isCached("key1", "2010-03-03 23:32:12"):
+      return "FAIL: cache should now be filled"
+    print "SUCCESS"  
+  
+  def testSetup():
+    setup = simplenote.SimpleNoteApiSetup()
+    loaded = setup.Load()
+    if not loaded:
+      return "FAIL: could not load: err='%s'" % setup.error
+    
+    userHash = hashlib.sha224(setup.username).hexdigest()
+    passHash = hashlib.sha224(setup.password).hexdigest()
+    if userHash != "d2d0bd41fa747def6e058c00e8ae08ee077a345c58795df9455df206":
+      return "FAIL: Wrong user name for tests"
+    if passHash != "df9506850fc108c041666d9fe3dcc49d51161bc89f375202ffe1bcf4":
+      return "FAIL: Wrong password for tests"
+    
+    return "SUCCESS"
+    
+  def testApi():
+    def localReport(message):
+      pass
+      #print "LOCAL: " + message
+   
+    setup = simplenote.SimpleNoteApiSetup()
+    setup.Load()
+    api = simplenote.SimpleNoteApi(setup.username, setup.password)
+    api.report = localReport
+    
+    notes = api.getNoteList()
+    
+    noteCount1 = len(notes)
+    
+    # add a note and make sure we have one more
+    key = api.createNote("test note -- delete!")
+    notes = api.getNoteList()
+    noteCount2 = len(notes)
+    if (noteCount2 != noteCount1 + 1):
+      return "FAIL: did not create note"
+
+    # update the note with new content and make sure it sticks
+    for content in ["SOMETHING", u"UNICODE:אבגדהוזחטיךכלםמןנסעףפץ"]:
+      api.setNoteContent(content, key)
+      if (content != api.getNoteContent(key)):
+        return u"FAIL: did not set note content to '%s'" % content
+  
+    # delete the note and make sure it's gone
+    api.deleteNote(key)
+    notes = api.getNoteList()
+    noteCount3 = len(notes)
+    
+    if (noteCount3 != noteCount1):
+      return "FAIL: did not delete note"
+    
+    return "SUCCESS"
+   
+  print "done"
+  print testFileCache()
+  print "done"
+  print testSetup()
+  print testApi()