From a5ce00ca99c87c2bbe9488366fb70192ae9e07e3 Mon Sep 17 00:00:00 2001 From: Nick <linickx@gmail.com> Date: Fri, 2 Dec 2016 11:05:57 +0000 Subject: [PATCH] Public Upload --- .editorconfig | 14 + .gitignore | 3 + README.rst | 73 +++++ docs/snsync_screenshot.gif | Bin 0 -> 41791 bytes simplenote_sync/config.py | 74 +++++ simplenote_sync/db.py | 311 ++++++++++++++++++++ simplenote_sync/notes.py | 179 ++++++++++++ simplenote_sync/simplenote.py | 389 +++++++++++++++++++++++++ simplenote_sync/snsync.py | 519 ++++++++++++++++++++++++++++++++++ simplenote_sync/version.py | 1 + snsync | 10 + 11 files changed, 1573 insertions(+) create mode 100644 .editorconfig create mode 100644 README.rst create mode 100644 docs/snsync_screenshot.gif create mode 100644 simplenote_sync/config.py create mode 100644 simplenote_sync/db.py create mode 100644 simplenote_sync/notes.py create mode 100644 simplenote_sync/simplenote.py create mode 100644 simplenote_sync/snsync.py create mode 100644 simplenote_sync/version.py create mode 100755 snsync diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..58c0808 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# editorconfig.org +root = true + +# Defaults +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +# Python +[*.py] +indent_style = space +indent_size = 4 diff --git a/.gitignore b/.gitignore index 72364f9..70def6c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Project specific stuff +testing/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..d145bf2 --- /dev/null +++ b/README.rst @@ -0,0 +1,73 @@ +snsync, like rsync for Simplenote +################################## + +snsync is a kinda rsync implementation for Simplenote where your notes can be downloaded (& and uploaded) from plain text files. + +The primary use case is for periodic synchronisation by cron, with all the *useful* output going to a log file and the console output being *pretty* for when humans sync manually. + +The configuration file +---------------------- + +By default, you need `~/.snsync` but the command line options do allow to select another file, the minimal info needed is a username and password:: + + [snsync] + cfg_sn_username = me@here.com + cfg_sn_password = secret + +*IMPORTANT! Protect your .snsync file with the correct permissions and disk encryption* + +A few additional options are possible: + +* `cfg_nt_path = /Users/linickx/mynotes` to change the default note path (`~/Simplenote`) +* `cfg_log_path = /Users/Library/Logs/snsync.log` to change the default log path (which is typically within `cfg_nt_path`). Use the keyword `DISABLED` to enable console logging. +* `cfg_log_level = debug` the default logging level is `info`, the brave can change this to `error`, ninja's can enable `debug` + +The command line options +------------------------ + +The following usage/options are available:: + + Usage: snsync [OPTIONS] + + OPTIONS: + -h, --help Help! + -d, --dry-run Dry Run Mode (no changes made/saved) + -s, --silent Silent Mode (no std output) + -c, --config= Config file to read (default: ~/.snsync) + +For example: just `snsync` on it's own should work, but something like this can be used for cron: `snsync -s --config=something.txt` + +File Deletions +-------------- + +snsync doesn't delete any files, you can check the source code yourself ;) + +When a file is marked for deletion on Simplenote, the local note (*text file*) equivalent is moved to a `.trash` directory. When a file is deleted locally the Simplenote equivalent is marked with Trash tag. + +File Conflicts +-------------- + +If your cron job is very sporadic it possible that a change could be made on the Simplenote server and locally, when this happens the local file is renamed, for example `hello world.txt` would become `DUP_date_hello world.txt` (*where date is the date/time the file was moved*). Duplicates are then uploaded back into Simplenote for safe keeping. + +Local file names are based on the first line of the Simplenote "note". Filenames are generated on a first come, first served basis, for example if you create a Simplenote online with the first line "hello world" then `hello world.txt` will be created, if you create a 2nd note, with completely different contents but the first line is "hello world" then the 2nd file will be called `date_hello world.txt` (*where date is the date/time the file was created*) + +File Modifications +------------------ + +snsync works by maintaining a local sqlite database, typically `.snsycn.sqlite` inside your `cfg_nt_path`. The database maintains a copy of the Simplenote list and a meta table that links Simplenotes to text files. + +The script works by comparing the latest Simplenote list to the local cache, and then compares the last modified dates of local files; moves/adds/changes/deletions are then replicated by-directionally. The `--dry-run` option can be used to observe what is going to happen without making any changes. + +For those wondering what the log file strings like `agtzaW1wbZRiusssu5sIDAasdfuhas` are; that's the "key" used in the Simplenote cloud to store your note, the local meta database keeps track of those and associates a file name... the cloud don't need no file names dude! ;-) + +Large Note Databases +-------------------- + +The Simplenote API is rate limited, if your note database is large (like mine -> 1,200 notes) then the first full sync will take a long time (mine -> approx 15mins) you will also find a high number of `HTTP ERRORS` reported, just wait and re-run the script, missed notes will be downloaded. + +AoB +--- + +No warranty is offered, use this at your own risk; I use this for my personal production notes but I always keep backups. The recommended approach is to manually download all your notes for a backup, then use the `--dry-run` option to observe changes until you are happy. + +Credz, props and big-ups to https://github.com/insanum/sncli and https://github.com/mrtazz/Simplenote.py as without these opensource projects, snsync would not have got off the ground :) \ No newline at end of file diff --git a/docs/snsync_screenshot.gif b/docs/snsync_screenshot.gif new file mode 100644 index 0000000000000000000000000000000000000000..c5b0f8f456e0d850928c168f808e7c0aa8bca087 GIT binary patch literal 41791 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 QD328Io^mVyvw#2qJILntssI20 literal 0 HcmV?d00001 diff --git a/simplenote_sync/config.py b/simplenote_sync/config.py new file mode 100644 index 0000000..3333b38 --- /dev/null +++ b/simplenote_sync/config.py @@ -0,0 +1,74 @@ +""" + Configuration settings for snsync +""" +# pylint: disable=W0702 +# pylint: disable=C0301 + +import os +import collections +import configparser + +class Config: + """ + Config Object + """ + + def __init__(self, custom_file=None): + """ + Defult settings and the like. + """ + self.home = os.path.abspath(os.path.expanduser('~')) + # Static Defaults + defaults = \ + { + 'cfg_sn_username' : '', + 'cfg_sn_password' : '', + 'cfg_nt_ext' : 'txt', + 'cfg_nt_path' : os.path.join(self.home, 'Simplenote'), + 'cfg_nt_trashpath' : '.trash', + 'cfg_nt_filenamelen' : '60', + 'cfg_log_level' : 'info' + } + + cp = configparser.SafeConfigParser(defaults) + if custom_file is not None: + self.configs_read = cp.read([custom_file]) + else: + self.configs_read = cp.read([os.path.join(self.home, '.snsync')]) + + cfg_sec = 'snsync' + + if not cp.has_section(cfg_sec): + cp.add_section(cfg_sec) + + self.configs = collections.OrderedDict() + self.configs['sn_username'] = [cp.get(cfg_sec, 'cfg_sn_username', raw=True), 'Simplenote Username'] + self.configs['sn_password'] = [cp.get(cfg_sec, 'cfg_sn_password', raw=True), 'Simplenote Password'] + self.configs['cfg_nt_ext'] = [cp.get(cfg_sec, 'cfg_nt_ext'), 'Note file extension'] + self.configs['cfg_nt_path'] = [cp.get(cfg_sec, 'cfg_nt_path'), 'Note storage path'] + self.configs['cfg_nt_trashpath'] = [cp.get(cfg_sec, 'cfg_nt_trashpath'), 'Note Trash Bin Folder for deleted notes'] + self.configs['cfg_nt_filenamelen'] = [cp.get(cfg_sec, 'cfg_nt_filenamelen'), 'Length of Filename'] + self.configs['cfg_log_level'] = [cp.get(cfg_sec, 'cfg_log_level'), 'snsync log level'] + + # Dynamic Defaults + if cp.has_option(cfg_sec, 'cfg_db_path'): + self.configs['cfg_db_path'] = [cp.get(cfg_sec, 'cfg_db_path'), 'snsync database location'] + else: + self.configs['cfg_db_path'] = [os.path.join(cp.get(cfg_sec, 'cfg_nt_path'), '.snsync.sqlite'), 'snsync database location'] + + if cp.has_option(cfg_sec, 'cfg_log_path'): + self.configs['cfg_log_path'] = [cp.get(cfg_sec, 'cfg_log_path'), 'snsync log location'] + else: + self.configs['cfg_log_path'] = [os.path.join(cp.get(cfg_sec, 'cfg_nt_path'), '.snsync.log'), 'snsync log location'] + + def get_config(self, name): + """ + Return a config setting + """ + return self.configs[name][0] + + def get_config_descr(self, name): + """ + Return a config description (future use in docs) + """ + return self.configs[name][1] diff --git a/simplenote_sync/db.py b/simplenote_sync/db.py new file mode 100644 index 0000000..dce9df2 --- /dev/null +++ b/simplenote_sync/db.py @@ -0,0 +1,311 @@ +""" + Database functions/settings for snsync +""" +import os +import sys +import sqlite3 +import json +# pylint: disable=W0702 +# pylint: disable=C0301 + + +db_version = int("1") # Increment this with DB/Schema updates. + +class Database: + """ + Main Database object + """ + + def __init__(self, config, logger): + """" + Initial Database Setup + """ + + filename = config.get_config('cfg_db_path') + self.log = logger + + if not self.isSQLite3(filename): + self.log.warning("404 DB not found: %s", filename) + self.dbconn, self.db = self.connect(filename) + self.createdb_schmea_1() + else: + self.dbconn, self.db = self.connect(filename) + + # Future proof, i.e. if the table structure changes. + version = self.get_schema_version() + if version == db_version: + self.log.debug("File Version: %s Our Version: %s", version, db_version) + else: + self.log.critical("Database Version/Schemea Mismatch! - File Version: %s Our Version: %s", version, db_version) + sys.exit(1) + + def isSQLite3(self, filename): + """" + Check if a file is an sqlite3 file. + # http://stackoverflow.com/a/15355790 + """ + + if not os.path.isfile(filename): + return False + if os.path.getsize(filename) < 100: # SQLite database file header is 100 bytes + return False + + with open(filename, 'rb') as fd: + header = fd.read(100) + + return header[:16] == b'SQLite format 3\x00' + + def connect(self, filename): + """ + Connection Setup + """ + self.log.debug("Connecting DB: %s", filename) + conn = sqlite3.connect(filename) + c = conn.cursor() + return conn, c + + def commit(self): + """ + Commit to DB + """ + self.dbconn.commit() + + def disconnect(self): + """ + Connection teardown + """ + self.dbconn.commit() + self.dbconn.close() + self.log.debug("Disconnecting DB") + + def get_schema_version(self): + """ + Get the Schema Version + # http://stackoverflow.com/a/19332352 + """ + cursor = self.dbconn.execute('PRAGMA user_version') + return cursor.fetchone()[0] + + def set_schema_version(self, version): + """ + Set the Schema Version + """ + self.dbconn.execute('PRAGMA user_version={:d}'.format(version)) + + def createdb_schmea_1(self): + """ + Create a DB (Schema Version 1) + """ + self.log.info("Creating new version 1 database") + version = int("1") + + try: + self.dbconn.execute('CREATE TABLE simplenote (\ + key TEXT PRIMARY KEY,\ + createdate BLOB,\ + deleted TEXT,\ + minversion TEXT,\ + modifydate BLOB,\ + syncnum TEXT,\ + systemtags TEXT,\ + tags TEXT,\ + version TEXT\ + )') + self.dbconn.execute('CREATE TABLE notefile (\ + key TEXT PRIMARY KEY,\ + createdate TEXT,\ + deleted TEXT,\ + modifydate TEXT,\ + filename TEXT\ + )') + self.dbconn.execute('CREATE TABLE snsync (\ + name TEXT PRIMARY KEY,\ + value BLOB\ + )') + self.set_schema_version(version) + except sqlite3.OperationalError: + self.log.error("Unabled to setup local database") + self.log.debug("Exception: %s", sys.exc_info()[1]) + sys.exit(1) + + def find_sn_by_key(self, key): + """ + Find a simple note by key + """ + + self.log.debug("Looking for SN: %s", key) + + try: + self.db.execute('SELECT * FROM simplenote WHERE key=?', (key,)) + except sqlite3.OperationalError: + self.log.debug("Exception: %s", sys.exc_info()[1]) + + try: + key_row = self.db.fetchone() + except sqlite3.OperationalError: + self.log.debug("Exception: %s", sys.exc_info()[1]) + + if key_row == None: + self.log.debug("404 Not Found: %s", key) + return False + else: + note = {} + note['key'] = key_row[0] + note['createdate'] = key_row[1] + note['deleted'] = key_row[2] + note['minversion'] = key_row[3] + note['modifydate'] = key_row[4] + note['syncnum'] = key_row[5] + note['systemtags'] = key_row[6] + note['tags'] = key_row[7] + note['version'] = key_row[8] + self.log.debug("SIMPLENOTE: %s", note) + return note + + def sn(self, note): + """ + Insert a note to the DB from an existing Simplenote + - or replace (for updates) + """ + try: + self.log.debug("Updating SN Database: %s", note) + + self.db.execute('INSERT OR REPLACE INTO Simplenote \ + (key, createdate, deleted, minversion, modifydate, syncnum, systemtags, tags, version) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', (\ + note['key'],note['createdate'],note['deleted'],\ + note['minversion'],note['modifydate'],note['syncnum'],\ + json.dumps(note['systemtags']),json.dumps(note['tags']),note['version'],)\ + ) + return True + except sqlite3.OperationalError: + self.log.debug("Exception: %s", sys.exc_info()[1]) + return False + + def update_snsync(self, name, value): + """ + Add or updated meta data in snsync table + """ + try: + self.log.debug("Updating %s -> %s", name, value) + self.db.execute('INSERT OR REPLACE INTO snsync (name, value) VALUES (?,?)', \ + (name, value,)) + return True + except sqlite3.OperationalError: + self.log.debug("Exception: %s", sys.exc_info()[1]) + return False + + def get_snsync_meta(self, name): + """ + Get snsync meta data + """ + + self.log.debug("Looking for Meta %s", name) + + try: + self.db.execute('SELECT * FROM snsync WHERE name=?', (name,)) + except sqlite3.OperationalError: + self.log.debug("Exception: %s", sys.exc_info()[1]) + + try: + row = self.db.fetchone() + except sqlite3.OperationalError: + self.log.debug("Exception: %s", sys.exc_info()[1]) + + if row == None: + self.log.debug("404 SN Not Found: %s", name) + return False + else: + value = row[1] + self.log.debug("Meta Value %s", value) + return value + + def nf(self, nf_meta): + """ + Insert a note file meta to the local DB + """ + try: + self.log.debug("Updating Note File DB: %s", nf_meta) + + self.db.execute('INSERT OR REPLACE INTO notefile \ + (key, createdate, deleted, modifydate, filename) \ + VALUES (?, ?, ?, ?, ?)', (\ + nf_meta['key'],nf_meta['createdate'],nf_meta['deleted'],\ + nf_meta['modifydate'],nf_meta['filename'],)\ + ) + return True + except sqlite3.OperationalError: + self.log.debug("Exception: %s", sys.exc_info()[1]) + return False + + def del_nf(self, key): + """ + Delete Notefile Meta - to "forget a file" + """ + try: + self.log.debug("Deleting NF Meta for : %s", key) + self.db.execute("DELETE FROM notefile WHERE key=?", (key,)) + return True + except sqlite3.OperationalError: + self.log.debug("Exception: %s", sys.exc_info()[1]) + return False + + def find_nf_by_key(self, key): + """ + Find a note file by key + """ + + self.log.debug("Looking for NF: %s", key) + + try: + self.db.execute('SELECT * FROM notefile WHERE key=?', (key,)) + except sqlite3.OperationalError: + self.log.debug("Exception: %s", sys.exc_info()[1]) + + try: + key_row = self.db.fetchone() + except sqlite3.OperationalError: + self.log.debug("Exception: %s", sys.exc_info()[1]) + + if key_row == None: + self.log.debug("404 NF Not Found: %s", key) + return False + else: + note = {} + note['key'] = key_row[0] + note['createdate'] = key_row[1] + note['deleted'] = key_row[2] + note['modifydate'] = key_row[3] + note['filename'] = key_row[4] + self.log.debug("NOTEFILE: %s", note) + return note + + def find_nf_by_name(self, filename): + """ + Find a note file by name (filename) + """ + + self.log.debug("Looking for NF META: %s", filename) + + try: + self.db.execute('SELECT * FROM notefile WHERE filename=?', (filename,)) + except sqlite3.OperationalError: + self.log.debug("Exception: %s", sys.exc_info()[1]) + + try: + key_row = self.db.fetchone() + except sqlite3.OperationalError: + self.log.debug("Exception: %s", sys.exc_info()[1]) + + if key_row == None: + self.log.debug("404 NF Meta Not Found: %s", filename) + return False + else: + note = {} + note['key'] = key_row[0] + note['createdate'] = key_row[1] + note['deleted'] = key_row[2] + note['modifydate'] = key_row[3] + note['filename'] = key_row[4] + self.log.debug("NOTEFILE: %s", note) + return note diff --git a/simplenote_sync/notes.py b/simplenote_sync/notes.py new file mode 100644 index 0000000..7b40904 --- /dev/null +++ b/simplenote_sync/notes.py @@ -0,0 +1,179 @@ +""" + Note File (local file) functions/settings for snsync +""" +# pylint: disable=W0702 +# pylint: disable=C0301 + +import sys +import os +import string +import hashlib +import time +import datetime +import re + +class Note: + """ + Main Note File (local file) object + """ + + def __init__(self, config, logger): + """" + Initial Notes Setup + """ + + self.log = logger + self.config = config + + # create note dir if it does not exist - cfg_nt_path + if not os.path.exists(self.config.get_config('cfg_nt_path')): + try: + os.mkdir(self.config.get_config('cfg_nt_path')) + self.log.info("Creating directory %s", self.config.get_config('cfg_nt_path')) + except: + self.log.critical("Error creating directory %s", self.config.get_config('cfg_nt_path')) + self.log.debug("Exception: %s", sys.exc_info()[1]) + sys.exit(1) + + # Try to create Recycle Bin (Trash) - cfg_nt_trashpath + if not os.path.exists(self.config.get_config('cfg_nt_path') + "/" + self.config.get_config('cfg_nt_trashpath')): + try: + os.mkdir(self.config.get_config('cfg_nt_path') + "/" + self.config.get_config('cfg_nt_trashpath')) + self.log.info("Creating directory %s", self.config.get_config('cfg_nt_path') + "/" + self.config.get_config('cfg_nt_trashpath')) + except: + self.log.critical("Error creating directory %s/%s", self.config.get_config('cfg_nt_path'), self.config.get_config('cfg_nt_trashpath')) + self.log.debug("Exception: %s", sys.exc_info()[1]) + sys.exit(1) + + def new(self, note): + """ + Create a new note file, returns filename + """ + path = self.config.get_config('cfg_nt_path') + filename = self.get_filename(note['content']) + access_time = time.time() + filetime = datetime.datetime.now().strftime("%y%m%d-%H%M%S") + + if filename: + if os.path.isfile(path + "/" + filename): + filename = filetime + "_" + filename # Don't blast over files with same name, i.e. same first line. + + try: + f = open(path + "/" + filename, 'w') + f.write(note['content']) + f.close() + self.log.info("Writing %s", filename) + + os.utime(path + "/" + filename, (access_time, float(note['modifydate']))) + + return filename + except: + self.log.error("Error writing note: %s", note['key']) + self.log.debug("Exception: %s", sys.exc_info()[1]) + else: + self.log.error("Error generating filename for note: %s", note['key']) + + return False + + def get_filename(self, content): + """ + Generate Safe Filename from Note Content + """ + note_data = str.splitlines(content) + line_one = note_data[0] + file_ext = self.config.get_config('cfg_nt_ext') + filename_len = int(self.config.get_config('cfg_nt_filenamelen')) + + # http://stackoverflow.com/a/295146 + try: + safechars = string.ascii_letters + string.digits + " -_." + safename = ''.join(c for c in line_one if c in safechars) + + if len(safename) >= filename_len: # truncate long names + safename = safename[:filename_len] + + self.log.debug("Make Safe In: %s Out: %s", line_one, safename) + filename = safename.strip() + "." + file_ext + return filename + except: + self.log.debug("Exception: %s", sys.exc_info()[1]) + return False + + def gen_meta(self, filename): + """ + Generate notefile meta from filename - returns dict + """ + nf_meta = {} + nf_meta['filename'] = filename + nf_meta['deleted'] = 0 + + # http://stackoverflow.com/a/5297483 + nf_meta['key'] = hashlib.md5(str(filename).encode('utf-8')).hexdigest() + self.log.debug("Note File Meta Key: %s", nf_meta['key']) + + path = self.config.get_config('cfg_nt_path') + + # WARNING THIS IS PLATFORM SPECIFIC + nf_meta['createdate'] = os.stat(path + "/" + filename).st_birthtime + self.log.debug("Note File Meta Created: %s [%s]", nf_meta['createdate'], time.ctime(nf_meta['createdate'])) + + nf_meta['modifydate'] = os.stat(path + "/" + filename).st_mtime + self.log.debug("Note File Meta Modified: %s [%s]", nf_meta['modifydate'], time.ctime(nf_meta['modifydate'])) + + return nf_meta + + def update(self, note, nf_meta): + """ + Create a new note file, returns filename + """ + path = self.config.get_config('cfg_nt_path') + filename = nf_meta['filename'] + access_time = time.time() + + try: + f = open(path + "/" + filename, 'w') + f.write(note['content']) + f.close() + self.log.info("Writing %s", filename) + + os.utime(path + "/" + filename, (access_time, float(note['modifydate']))) + + return True + except: + self.log.error("Error writing note: %s", note['key']) + self.log.debug("Exception: %s", sys.exc_info()[1]) + + return False + + def open(self, filename): + """ + Open a notefile, returns Dict: content & modifydate + """ + notefile = {} + path = self.config.get_config('cfg_nt_path') + + if os.path.isfile(path + "/" + filename): + try: + f = open(path + "/" + filename, 'r') + notefile['content'] = f.read() + f.close() + except: + self.log.error("Failed to OPEN/READ: %s", path + "/" + filename) + self.log.debug("Exception: %s", sys.exc_info()[1]) + return False + else: + self.log.error("Notefile not found: %s", path + "/" + filename) + return False + + notefile['modifydate'] = os.stat(path + "/" + filename).st_mtime + self.log.debug("Note File Modified: %s [%s]", notefile['modifydate'], time.ctime(notefile['modifydate'])) + + if re.match('darwin', sys.platform): + # WARNING THIS IS PLATFORM SPECIFIC + notefile['createdate'] = os.stat(path + "/" + filename).st_birthtime + self.log.debug("Note File Created: %s [%s]", notefile['createdate'], time.ctime(notefile['createdate'])) + else: + notefile['createdate'] = notefile['modifydate'] + self.log.debug("Using Modify Date for Birth/Create Date") + + return notefile diff --git a/simplenote_sync/simplenote.py b/simplenote_sync/simplenote.py new file mode 100644 index 0000000..6e914b2 --- /dev/null +++ b/simplenote_sync/simplenote.py @@ -0,0 +1,389 @@ +# -*- 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 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 +else: + import urllib2 + from urllib2 import HTTPError + import urllib + + +import base64 +import time +import datetime + +# crappy hack for inserting user-agent into Simplenote requests +from .version import __version__ as snsync_version +custom_user_agent = 'snsync/' + snsync_version + '(https://github.com/linickx/snsync)' + +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://app.simplenote.com/api/login' +DATA_URL = 'https://app.simplenote.com/api2/data' +INDX_URL = 'https://app.simplenote.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 = username + self.password = password + self.token = None + self.mark = "mark" + + 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={0}&password={1}".format(user, password) + try: + values = base64.b64encode(bytes(auth_params,'utf-8')) + except TypeError: + values = base64.encodestring(auth_params) + + request = Request(AUTH_URL, values) + try: + res = urllib2.urlopen(request).read() + token = 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) + 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 + + 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 = '/{0}{1}?auth={2}&email={3}'.format(noteid, params_version, self.get_token(), self.username) + request = Request(DATA_URL+params) + try: + 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.__encode(note) + 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 + + """ + note = self.__decode(note) + # determine whether to create a new note or update 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 = '{0}/{1}?auth={2}&email={3}'.format(DATA_URL, note["key"], + self.get_token(), self.username) + else: + url = '{0}?auth={1}&email={2}'.format(DATA_URL, self.get_token(), self.username) + request = Request(url, urllib.quote(json.dumps(note)).encode('utf-8')) + response = "" + try: + response = urllib2.urlopen(request) + except IOError as e: + return e, -1 + note = json.loads(response.read().decode('utf-8')) + note = self.__encode(note) + 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 = [] + notes = { "data" : [] } + self.mark = "mark" + + params = 'auth={0}&email={1}&length={2}'.format(self.get_token(), self.username, + NOTE_FETCH_LENGTH) + + try: + sinceUT = time.mktime(datetime.datetime.strptime(since, "%Y-%m-%d").timetuple()) + params += '&since={0}'.format(sinceUT) + except (TypeError, ValueError): + #I.e. None or invalid date format + pass + + # get notes + while self.mark: + notes, status = self.__get_notes(notes, params) + + # 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 = '/{0}?auth={1}&email={2}'.format(str(note_id), self.get_token(), + self.username) + request = Request(url=DATA_URL+params, method='DELETE') + try: + urllib2.urlopen(request) + except IOError as e: + return e, -1 + return {}, 0 + + def __encode(self, note): + """ Private method to UTF-8 encode for Python 2 + + Arguments: + A note + + Returns: + A note + + """ + + if sys.version_info < (3, 0): + if "content" in note: + # use UTF-8 encoding + note["content"] = note["content"].encode('utf-8') + # For early versions of notes, tags not always available + if "tags" in note: + note["tags"] = [t.encode('utf-8') for t in note["tags"]] + return note + + def __decode(self, note): + """ Utility method to UTF-8 decode for Python 2 + + Arguments: + A note + + Returns: + A note + + """ + if sys.version_info < (3, 0): + if "content" in note: + note["content"] = unicode(note["content"], 'utf-8') + if "tags" in note: + note["tags"] = [unicode(t, 'utf-8') for t in note["tags"]] + return note + + def __get_notes(self, notes, params): + """ Private method to fetch a chunk of notes + + Arguments: + - Notes + - URL parameters + - since date + + Returns: + - Notes + - Status + + """ + + notes_index = {} + + if self.mark != "mark": + params += '&mark={0}'.format(self.mark) + # perform HTTP request + try: + request = Request(INDX_URL+params) + response = urllib2.urlopen(request) + notes_index = json.loads(response.read().decode('utf-8')) + notes["data"].extend(notes_index["data"]) + status = 0 + except IOError: + status = -1 + if "mark" in notes_index: + self.mark = notes_index["mark"] + else: + self.mark = "" + return notes, status + + +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): + + headers = {'user-agent': custom_user_agent} # Nick woz ere, hacky, crap, hack. + + 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 diff --git a/simplenote_sync/snsync.py b/simplenote_sync/snsync.py new file mode 100644 index 0000000..62b0f25 --- /dev/null +++ b/simplenote_sync/snsync.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python3 +# coding=utf-8 +# I like catch-all excepts +# pylint: disable=W0702 +# Widescreen baby! +# pylint: disable=C0301 +# No idea why pylint import fails. +# pylint: disable=F0401 + +""" + + Like rsync for Simplenote. + Sync simple notes to local text files + + .. currentmodule:: snsync + .. moduleauthor:: Nick Bettison - www.linickx.com + + # Logic + 1. Get list of notes from Simplenote (LOOP1) + 2. Add new Simplenotes to local SN DB (cache) and create txt file (with meta DB). + 3. Existing notes: Compare & update based on modifieddate (update contents & modifieddate) + 4. Deleted on SN (move local files to trash) + 5. Deleted locally (mark deleted/trashed on SN) + 6. Add new local note (file) to Simple Note (LOOP2) + + -- + IDEAS: + - Markdown - use Simplenote markdown tag to create .md files + - delta (fast) sync - use the last sync time to limit the processing (sn_last_sync) + - check file permissions of config file + - Merge (difflib) conflicts instead of creating files (maybe?) + - garbage collection - empty trash folder (every x days?) + - external [web?] hook - create customisable notification of status + BUGS: + - pylint + - R0912 Too many branches / R0914 Too many local vars - Both: Line 82 + - R01101 Too many nested blocks (line 216) + - W0612 Unused Var (lastsync) - See ^ideas^ this is for delta/fast sync. + - W0612 Unused Var (args) - RTFM: I'm sure I did this for a reason :-/ + -- + +""" + +import time +import datetime +import logging +import os +import sys +import re +import getopt + +from .simplenote import Simplenote +from .config import Config +from .db import Database +from .notes import Note +from .version import __version__ + + +__version__ = "0.1" # Version Ctl +start_time = time.monotonic() # Simple Performance Monitoring + +logger = logging.getLogger("snsync") +logger.setLevel(logging.DEBUG) +log_formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s') + +# Log to console until file is setup. +chandler = logging.StreamHandler() +chandler.setFormatter(log_formatter) +chandler.setLevel(logging.DEBUG) +logger.addHandler(chandler) + +def usage(): + """ + Print Help / Usage + """ + print(''' +Usage: snsync [OPTIONS] + +OPTIONS: + -h, --help Help! + -d, --dry-run Dry Run Mode (no changes made/saved) + -s, --silent Silent Mode (no std output) + -c, --config= Config file to read (default: ~/.snsync) + +Version: %s +''' % __version__) + sys.exit(0) + +def main(argv=sys.argv[1:]): + """ + Main body, system argements, 2 loops. + """ + + # Default Vars + dry_run = False + silent_mode = False + config_file = None + + # CMD Line options + try: + opts, args = getopt.getopt(argv, + 'hds:c:', + ['help', 'dry-run', 'silent', 'config=']) + except: + logger.debug("Exception: %s", sys.exc_info()[1]) + usage() + + for opt, arg in opts: + if opt in ['-h', '--help']: + usage() + elif opt in ['-d', '--dry-run']: + dry_run = True + elif opt in ['-s', '--silent']: + silent_mode = True + elif opt in ['-c', '--config']: + config_file = arg + else: + print('ERROR: Unhandled option') + usage() + + config = Config(config_file) # Config Setup + + note = Note(config, logger) # Local Notes Setup (folders) + + log_file = config.get_config('cfg_log_path') + log_level = config.get_config('cfg_log_level') + + # https://docs.python.org/2.6/library/logging.html + LEVELS = {'debug': logging.DEBUG, + 'info': logging.INFO, + 'warning': logging.WARNING, + 'error': logging.ERROR, + 'critical': logging.CRITICAL} + + if log_file == "DISABLED": + logger.info("Console Errors Enabled") + silent_mode = True # Disable other output (progress bar) + else: # logfile is ready + if not silent_mode: + print("Logging to File: %s" % log_file) + fhandler = logging.FileHandler(log_file) + fhandler.setFormatter(log_formatter) + level = LEVELS.get(log_level, logging.INFO) + fhandler.setLevel(level) + logger.addHandler(fhandler) # add file handler + logger.removeHandler(chandler) # remove console handler + logger.info('--[ START Version %s ]--', __version__) # Begining of log file + + # Catch missing configs + if config_file is not None: + if not os.path.isfile(config_file): + logger.critical("Config file not found: %s", config_file) + if not silent_mode: + print('Config file not found: %s' % config_file) + sys.exit(1) + + db = Database(config, logger) # DB setup + + # System Vars + filetime = datetime.datetime.now().strftime("%y%m%d-%H%M%S") # timestamp for files + the_os = sys.platform + + # Uer config vars (DO NOT CHANGE THESE) + path = config.get_config('cfg_nt_path') + trash_path = config.get_config('cfg_nt_trashpath') + file_ext = config.get_config('cfg_nt_ext') + sn_username = config.get_config('sn_username') + sn_password = config.get_config('sn_password') + + # Catch blank credentials + if sn_username == '' or sn_password == '': + logger.critical("Simplenote Username/Password not set, probably no ~/.snsync config file.") + if not silent_mode: + print('Simplenote Username/Password not set, probably no ~/.snsync config file.') + sys.exit(1) + + # Main simplenote object + simplenote = Simplenote(sn_username, sn_password) + + if re.match('linux', the_os): + logger.debug('OS: Linux') + elif re.match('darwin', the_os): + logger.debug('OS: MacOS') + else: + logger.warning('Unsupported OS') + + if dry_run: + logger.warning('DRY RUN Mode') + + try: + notes = simplenote.get_note_list() # the mac daddy important bit! + except: + logger.debug("Exception: %s", sys.exc_info()[1]) + logger.critical("Simplenote Login Failed") + if not silent_mode: + print('Simplenote Login Failed') + sys.exit(1) + + logger.debug('API Result: %s', notes) + + if notes[1] == 0: # success + notes = notes[0] + else: + logger.error('Simplenote LIST Request FAILED') + sys.exit() + + # Counters! + counter_changes = 0 + counter_modified = 0 + counter_added = 0 + counter_deleted = 0 + counter_http_errors = 0 + + if not silent_mode: + print("Scanning %s Simplenotes" % len(notes)) + + # Progress bar + sys.stdout.write("[%s]" % (" " * len(notes))) + sys.stdout.flush() + sys.stdout.write("\b" * (len(notes)+1)) # return to start of line, after '[' + + # Loop 1 + for n in notes: + + db.commit() # Commit the last note + + if not silent_mode: + time.sleep(0.05) # print doesn't work if too fast + sys.stdout.write("#") + sys.stdout.flush() + + thisnote = db.find_sn_by_key(n['key']) + + if thisnote: + # Existig Simple Note + thisfile = db.find_nf_by_key(n['key']) # Note File Meta + sn_modify = False # Set default status for simplenote + nf_modify = False # Set default status for notefile + + if thisfile and n['deleted'] == 0: # Modified S-Notes + if os.path.isfile(path + "/" + thisfile['filename']): + file_modifydate = os.path.getmtime(path + "/" + thisfile['filename']) + + sn_modifyseconds = n['modifydate'].split(".")[0] # Simple Note Modify Time + logger.debug('SN Modified: %s [%s]', sn_modifyseconds, time.ctime(int(sn_modifyseconds))) + + sncache_modifyseconds = thisnote['modifydate'].split(".")[0] # Last known Simple Note Time + logger.debug('SN (cached) Modified: %s [%s]', sncache_modifyseconds, time.ctime(int(sncache_modifyseconds))) + + nf_modifyseconds = str(file_modifydate).split(".")[0] # Note File modify Time + logger.debug('NF Modified: %s [%s]', nf_modifyseconds, time.ctime(int(nf_modifyseconds))) + + if int(sn_modifyseconds) > int(sncache_modifyseconds): + sn_modify = True + logger.debug('SN %s is newer than NF %s', n['key'], thisfile['filename']) + + if int(nf_modifyseconds) > int(sn_modifyseconds): + nf_modify = True + logger.debug('NF %s is newer than SN %s', thisfile['filename'], n['key']) + + if nf_modify and sn_modify: + logger.error('DUP! Modified Date Clash %s', thisfile['filename']) + + old_fqdn = path + "/" + thisfile['filename'] + new_fqdn = path + "/DUP_" + filetime + "_" + thisfile['filename'] + logger.info('Duplicate File Created %s', new_fqdn) + + nf_modify = False # Reset this, gonna move the old file + + logger.debug("DUP | Old: %s New: %s", old_fqdn, new_fqdn) + if not dry_run: + try: + os.rename(old_fqdn, new_fqdn) + except: + logger.error("Failed to move file %s -> %s", old_fqdn, new_fqdn) + logger.debug("Exception: %s", sys.exc_info()[1]) + + if not nf_modify and not sn_modify: + logger.debug('No changes required for %s [%s]', thisfile['filename'], n['key']) + + if sn_modify: + logger.info('[SN] > [NF] | %s -> %s', n['key'], thisfile['filename']) + nf_filename = thisfile['filename'] + + if nf_modify: + logger.info('[SN] < [NF] | %s <- %s', n['key'], thisfile['filename']) + nf_filename = thisfile['filename'] + + else: + logger.critical("Local File [%s] DELETED but not marked for deletion locally, assuming delete SN -> [%s]", thisfile['filename'], n['key']) + counter_deleted += 1 + + if not dry_run: + trash_note = simplenote.trash_note(n['key']) + logger.debug('API Result: %s', trash_note) + + if trash_note[1] == 0: + db.sn(trash_note[0]) + db.del_nf(n['key']) + logger.info('SN Deleted [%s]', n['key']) + else: + logger.error('Simplenote DELETE Request Failed [%s]', n['key']) + counter_http_errors += 1 + counter_deleted -= 1 # giveth and taketh away! + + elif thisfile and n['deleted'] == 1: # Seen and Deleted SN + if os.path.isfile(path + "/" + thisfile['filename']): + logger.info('Deleting File: %s', thisfile['filename']) + counter_deleted += 1 + + if not dry_run: + del thisfile['deleted'] # Remove old deleted meta + thisfile['deleted'] = 1 + db.del_nf(n['key']) # delete nofile meta (forget the file) + db.sn(n) # update simplenote cache + + old_fqdn = path + "/" + thisfile['filename'] + new_fqdn = path + "/" + trash_path + "/" + filetime + "_" + thisfile['filename'] + + try: + os.rename(old_fqdn, new_fqdn) + logger.debug("TRASH | Old: %s New: %s", old_fqdn, new_fqdn) + except: + logger.error("Failed to move file %s -> %s", old_fqdn, new_fqdn) + logger.debug("Exception: %s", sys.exc_info()[1]) + + else: # No file meta + if n['deleted'] == 0: # Exists in Simple note, but no meta + logger.critical("File Meta AWOL - %s", n['key']) + sn_modify = True # Generate new local file + else: # No Meta and Deleted in Simple Note + logger.debug("No file meta for deleted file simplenote, probably never written to disk") + + if sn_modify: # Simplenote has been modified! + counter_modified += 1 + if not dry_run: + thisnote_full = simplenote.get_note(n['key']) # Get the latest note + logger.debug('API Result: %s', thisnote_full) + + if thisnote_full[1] == 0: + try: + nf_filename + except: # Catch critial AWOL Files + nf_filename = note.get_filename(thisnote_full[0]['content']) + + # Generate new notefile meta + nf_meta = {} + nf_meta['filename'] = nf_filename + nf_meta['key'] = n['key'] + nf_meta['createdate'] = n['createdate'] + nf_meta['modifydate'] = n['modifydate'] + nf_meta['deleted'] = n['deleted'] + + db.sn(n) # Update simplenote Cache + db.nf(nf_meta) # Update notefile meta + + thisnote_file = note.update(thisnote_full[0], nf_meta) # Write to file + else: + logger.error('Simplenote DOWNLOAD Request FAILED [%s]', n['key']) + counter_http_errors += 1 + counter_modified -= 1 # so far yet so close ;) + + if nf_modify: # Local note has been modified! + counter_modified += 1 + if not dry_run: + notefile_full = note.open(nf_filename) + + nf = {} + nf['key'] = n['key'] + nf['content'] = notefile_full['content'] + nf['modifydate'] = notefile_full['modifydate'] + nf['version'] = n['version'] + + note_update = simplenote.update_note(nf) + logger.debug('API Result: %s', note_update) + + if note_update[1] == 0: + nf_meta = {} # update meta + nf_meta['filename'] = nf_filename + nf_meta['key'] = note_update[0]['key'] + nf_meta['createdate'] = note_update[0]['createdate'] + nf_meta['modifydate'] = note_update[0]['modifydate'] + nf_meta['deleted'] = note_update[0]['deleted'] + + db.sn(note_update[0]) + db.nf(nf_meta) + + logger.info('SN Updated [%s] from %s', n['key'], nf_filename) + else: + logger.error('Simplenote UPDATE Request FAILED [%s] <- %s', n['key'], nf_filename) + counter_http_errors += 1 + counter_modified -= 1 + + + else: + # New Note Added/Found in Simplenote + logger.info('Adding SN NOTE: %s to Local DB', n['key']) + counter_added += 1 + + if dry_run: + thisnote = False + else: + thisnote = db.sn(n) + + if thisnote: + if n['deleted'] == 0: # Don't save deleted notes! + thisnote_full = simplenote.get_note(n['key']) + logger.debug('API Result: %s', thisnote_full) + + if thisnote_full[1] == 0: # success + thisnote_file = note.new(thisnote_full[0]) + + if thisnote_file: + nf_meta = {} + nf_meta['filename'] = thisnote_file + nf_meta['key'] = n['key'] + nf_meta['createdate'] = n['createdate'] + nf_meta['modifydate'] = n['modifydate'] + nf_meta['deleted'] = n['deleted'] + db.nf(nf_meta) + else: + logger.error("Failed to write note: %s", n['key']) + + else: + logger.error('Simplenote DOWNLOAD Request FAILED [%s]', n['key']) + counter_http_errors += 1 + counter_added -= 1 + + else: + if not dry_run: + logger.error('Failed to updated DB with %s', n['key']) + + if not silent_mode: + sys.stdout.write("\n") # New Line for end of progress bar + + # Loop 2 + if not silent_mode: + print("Scanning %s local files" % len(os.listdir(path))) + # Progress bar + sys.stdout.write("[%s]" % (" " * len(os.listdir(path)))) + sys.stdout.flush() + sys.stdout.write("\b" * (len(os.listdir(path))+1)) # return to start of line, after '[' + + for notefile in os.listdir(path): # local search for new files + + if not silent_mode: + if not silent_mode: + time.sleep(0.05) # print doesn't work if too fast + sys.stdout.write("#") + sys.stdout.flush() + + if notefile.endswith(file_ext): # only work with .txt file (or whatever!) + logger.debug('Checking NF: %s', notefile) + + nf_meta = db.find_nf_by_name(notefile) # Note File Meta + + if not nf_meta: # If there's no meta, this must be a new file + logger.info('NEW notefile for upload: %s', notefile) + counter_added += 1 + + if not dry_run: + nf_meta = note.gen_meta(notefile) + nf_detail = note.open(notefile) + + new_sn_object = {} + new_sn_object['key'] = nf_meta['key'] + new_sn_object['createdate'] = nf_meta['createdate'] + new_sn_object['modifydate'] = nf_meta['modifydate'] + new_sn_object['content'] = nf_detail['content'] + + new_sn = simplenote.add_note(new_sn_object) # Add the note! + logger.debug('API Result: %s', new_sn) + + if new_sn[1] == 0: + logger.debug('New Simplenote Created: %s', new_sn) + db.sn(new_sn[0]) # Update simplenote Cache + db.nf(nf_meta) # Update notefile meta + else: + logger.error('Simplenote ADD Request FAILED [%s]', new_sn) + counter_http_errors += 1 + counter_added -= 1 + + if not silent_mode: + sys.stdout.write("\n") # New Line for end of progress bar + + if not dry_run: + lastsync = db.update_snsync("sn_last_sync", time.time()) # record last sync + db.disconnect() # Saves the sqlite db. + + # end of play report + counter_changes = counter_modified + counter_added + counter_deleted + logger.info('Changes: %s', counter_changes) + if not silent_mode: + print('Changes: %s' % counter_changes) + + if counter_modified > 0: + logger.info('Modified: %s', counter_modified) + if not silent_mode: + print('- Modified: %s' % counter_modified) + + if counter_added > 0: + logger.info('Added: %s', counter_added) + if not silent_mode: + print('- Added: %s' % counter_added) + + if counter_deleted > 0: + logger.info('Deleted: %s', counter_deleted) + if not silent_mode: + print('- Deleted: %s' % counter_deleted) + + if counter_http_errors > 0: + logger.info('HTTP ERRORS: %s', counter_http_errors) + if not silent_mode: + print('HTTP ERRORS: %s' % counter_http_errors) + + end_time = time.monotonic() # http://stackoverflow.com/a/26099345 + logger.info('Time Taken: %s', datetime.timedelta(seconds=end_time - start_time)) + if not silent_mode: + print('Time Taken: %s' % datetime.timedelta(seconds=end_time - start_time)) diff --git a/simplenote_sync/version.py b/simplenote_sync/version.py new file mode 100644 index 0000000..5e3048b --- /dev/null +++ b/simplenote_sync/version.py @@ -0,0 +1 @@ +__version__ = '0.1' \ No newline at end of file diff --git a/snsync b/snsync new file mode 100755 index 0000000..eeef95d --- /dev/null +++ b/snsync @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +""" + snsync, a rsync client for Simplenote. + + - This is a wrapper without a file extension to allow you to type "snsync" +""" +from simplenote_sync import snsync + +if __name__ == '__main__': + snsync.main() \ No newline at end of file