nncli

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

commit 83a8c9d9190ff889732856cb1e76f97eb2daf288
parent cc241e583d345ed364a46bb57f677fdf028fd660
Author: Daniel Moch <daniel@danielmoch.com>
Date:   Fri, 27 Jul 2018 12:41:00 -0400

Initial refactor to use NextCloud Notes API

Still many broken areas

Diffstat:
MREADME.md | 2+-
Mnnotes_cli/nncli.py | 6+++---
Mnnotes_cli/nnotes.py | 187+++++++++++++++----------------------------------------------------------------
Mnnotes_cli/notes_db.py | 67+++++++++++++++++++++++++++++++++----------------------------------
Mnnotes_cli/utils.py | 6+++---
Mnnotes_cli/view_note.py | 4++--
Mnnotes_cli/view_titles.py | 2+-
7 files changed, 78 insertions(+), 196 deletions(-)

diff --git a/README.md b/README.md @@ -216,7 +216,7 @@ example: ``` echo '{"tags":["testing","new"],"content":"New note!"}' | nncli import - ``` -Allowed fields are `content`, `tags`, `systemtags`, `modifydate`, +Allowed fields are `content`, `tags`, `systemtags`, `modified`, `createdate`, and `deleted`. ### Exporting diff --git a/nnotes_cli/nncli.py b/nnotes_cli/nncli.py @@ -984,7 +984,7 @@ def gui(self, key): view_note.ViewNote(self.config, { 'ndb' : self.ndb, - 'key' : key, # initial key to view or None + 'id' : key, # initial key to view or None 'log' : self.log }) @@ -1098,7 +1098,7 @@ def cli_note_dump(self, key): w = 60 sep = '+' + '-'*(w+2) + '+' - t = time.localtime(float(note['modifydate'])) + t = time.localtime(float(note['modified'])) mod_time = time.strftime('%a, %d %b %Y %H:%M:%S', t) title = utils.get_note_title(note) flags = utils.get_note_flags(note) @@ -1106,7 +1106,7 @@ def cli_note_dump(self, key): print(sep) print(('| {:<' + str(w) + '} |').format((' Title: ' + title)[:w])) - print(('| {:<' + str(w) + '} |').format((' Key: ' + note.get('key', 'Localkey: {}'.format(note.get('localkey'))))[:w])) + print(('| {:<' + str(w) + '} |').format((' Key: ' + note.get('id', 'Localkey: {}'.format(note.get('localkey'))))[:w])) print(('| {:<' + str(w) + '} |').format((' Date: ' + mod_time)[:w])) print(('| {:<' + str(w) + '} |').format((' Tags: ' + tags)[:w])) print(('| {:<' + str(w) + '} |').format((' Version: v' + str(note.get('version', 0)))[:w])) diff --git a/nnotes_cli/nnotes.py b/nnotes_cli/nnotes.py @@ -66,64 +66,16 @@ def __init__(self, username, password, host): """ object constructor """ self.username = urllib.parse.quote(username) self.password = urllib.parse.quote(password) - self.AUTH_URL = 'https://{0}/api/login'.format(host) - self.DATA_URL = 'https://{0}/api2/data'.format(host) - self.INDX_URL = 'https://{0}/api2/index?'.format(host) - self.token = None + self.api_url = \ + 'https://{}:{}@{}/index.php/apps/notes/api/v0.2/notes'. \ + format(username, password, host) self.status = 'offline' - def authenticate(self, user, password): - """ Method to get NextCloud Notes auth token - - Arguments: - - user (string): NextCloud username - - password (string): NextCloud password - - Returns: - NextCloud API token as string - - """ - auth_params = "email=%s&password=%s" % (user, password) - values = base64.encodestring(auth_params.encode()) - try: - res = requests.post(self.AUTH_URL, data=values) - token = res.text - if res.status_code != 200: - self.status = 'login failed with status {}, check credentials'.format(res.status_code) - token = None - else: - self.status = 'online' - except ConnectionError as e: - token = None - self.status = 'offline, connection error' - except RequestException as e: - token = None - self.status = 'login failed, check log' - - logging.debug('AUTHENTICATE: ' + self.status) - 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: - NextCloud API token as string - - """ - if self.token is None: - self.token = self.authenticate(self.username, self.password) - return self.token - - - def get_note(self, noteid, version=None): + def get_note(self, noteid): """ 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)` @@ -133,16 +85,10 @@ def get_note(self, noteid, version=None): """ # request note - params_version = "" - if version is not None: - params_version = '/' + str(version) - - params = {'auth': self.get_token(), - 'email': self.username } - url = '{}/{}{}'.format(self.DATA_URL, str(noteid), params_version) + url = '{}/{}'.format(self.api_url, str(noteid)) #logging.debug('REQUEST: ' + self.DATA_URL+params) try: - res = requests.get(url, params=params) + res = requests.get(url) res.raise_for_status() note = res.json() except ConnectionError as e: @@ -163,8 +109,8 @@ def get_note(self, noteid, version=None): 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 + """ 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 @@ -182,19 +128,22 @@ def update_note(self, note): # determine whether to create a new note or updated an existing one params = {'auth': self.get_token(), 'email': self.username} - if "key" in note: + if "id" in note: # set modification timestamp if not set by client - if 'modifydate' not in note: - note["modifydate"] = time.time() + if 'modified' not in note: + note["modified"] = time.time() - url = '%s/%s' % (self.DATA_URL, note["key"]) + url = '{}/{}'.format(self.api_url, note["id"]) else: - url = self.DATA_URL + url = self.api_url #logging.debug('REQUEST: ' + url + ' - ' + str(note)) try: data = urllib.parse.quote(json.dumps(note)) - res = requests.post(url, data=data, params=params) + if "id" in note: + res = requests.put(url, data=data) + else: + res = requests.post(url, data=data) res.raise_for_status() note = res.json() except ConnectionError as e: @@ -234,7 +183,7 @@ def add_note(self, note): else: return "No string or valid note.", -1 - def get_note_list(self, since=None, tags=[]): + def get_note_list(self, category=None): """ function to get the note list The function can be passed optional arguments to limit the @@ -243,40 +192,32 @@ def get_note_list(self, since=None, tags=[]): is returned. Arguments: - - since=time.time() epoch stamp: only return notes modified - since this date - - tags=[] list of tags as string: return notes that have + - category=None 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`. + - 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 - notes = { "data" : [] } - json_data = {} + note_list = {} # get the note index - params = {'auth': self.get_token(), - 'email': self.username, - 'length': NOTE_FETCH_LENGTH - } - if since is not None: - params['since'] = since + params = {'exclude': 'content'} # perform initial HTTP request try: - #logging.debug('REQUEST: ' + self.INDX_URL+params) - res = requests.get(self.INDX_URL, params=params) + logging.debug('REQUEST: ' + self.api_url + \ + '?exclude=content') + res = requests.get(self.api_url, params=params) res.raise_for_status() #logging.debug('RESPONSE OK: ' + str(res)) - json_data = res.json() - notes["data"].extend(json_data["data"]) + note_list = res.json() except ConnectionError as e: self.status = 'offline, connection error' status = -1 @@ -287,66 +228,15 @@ def get_note_list(self, since=None, tags=[]): # if invalid json data status = -1 - # get additional notes if bookmark was set in response - while "mark" in json_data: - params = {'auth': self.get_token(), - 'email': self.username, - 'mark': json_data['mark'], - 'length': NOTE_FETCH_LENGTH - } - if since is not None: - params['since'] = since - - # perform the actual HTTP request - try: - #logging.debug('REQUEST: ' + self.INDX_URL+params) - res = requests.get(self.INDX_URL, params=params) - res.raise_for_status() - json_data = res.json() - #logging.debug('RESPONSE OK: ' + str(res)) - notes["data"].extend(json_data["data"]) - except ConnectionError as e: - self.status = 'offline, connection error' - status = -1 - except RequestException as e: - # if problem with network request/response - status = -1 - except ValueError as e: - # if invalid json data - 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)] + # Can only filter for category at end, once all notes have been + # retrieved. Below based on simplenote.vim, except we return + # deleted notes as well + if category is not None: + note_list = \ + [n for n in note_list if n["category"] == category] 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 @@ -361,17 +251,11 @@ def delete_note(self, note_id): """ # notes have to be trashed before deletion - note, status = self.trash_note(note_id) - if (status == -1): - return note, status - - params = {'auth': self.get_token(), - 'email': self.username } - url = '{}/{}'.format(self.DATA_URL, str(note_id)) + url = '{}/{}'.format(self.api_url, str(note_id)) try: #logging.debug('REQUEST DELETE: ' + self.DATA_URL+params) - res = requests.delete(url, params=params) + res = requests.delete(url) res.raise_for_status() except ConnectionError as e: self.status = 'offline, connection error' @@ -379,4 +263,3 @@ def delete_note(self, note_id): except RequestException as e: return e, -1 return {}, 0 - diff --git a/nnotes_cli/notes_db.py b/nnotes_cli/notes_db.py @@ -1,4 +1,3 @@ - # # The MIT License (MIT) # @@ -73,14 +72,15 @@ def __init__(self, config, log, update_view): except ValueError as e: raise ReadError ('Error reading {0}: {1}'.format(fn, str(e))) else: - # we always have a localkey, also when we don't have a note['key'] yet (no sync) + # we always have a localkey, also when we don't have a + # note['id'] yet (no sync) localkey = n.get('localkey', os.path.splitext(os.path.basename(fn))[0]) # 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 # set a localkey to each note in memory - # Note: 'key' is used only for syncing with server - 'localkey' + # Note: 'id' is used only for syncing with server - 'localkey' # is used for everything else in nncli n['localkey'] = localkey @@ -103,7 +103,7 @@ def filtered_notes_sort(self, filtered_notes, sort_mode='date'): if self.config.get_config('pinned_ontop') == 'yes': filtered_notes.sort(key=utils.sort_by_modify_date_pinned, reverse=True) else: - filtered_notes.sort(key=lambda o: -float(o.note.get('modifydate', 0))) + filtered_notes.sort(key=lambda o: -float(o.note.get('modified', 0))) elif sort_mode == 'alpha': if self.config.get_config('pinned_ontop') == 'yes': filtered_notes.sort(key=utils.sort_by_title_pinned) @@ -298,28 +298,27 @@ def filter_notes_regex(self, search_string=None): def import_note(self, note): # need to get a key unique to this database. not really important # what it is, as long as it's unique. - new_key = note['key'] if note.get('key') else utils.generate_random_key() + new_key = note['id'] if note.get('id') else utils.generate_random_key() while new_key in self.notes: new_key = utils.generate_random_key() timestamp = time.time() try: - modifydate = float(note.get('modifydate', timestamp)) + modified = float(note.get('modified', timestamp)) createdate = float(note.get('createdate', timestamp)) except ValueError: raise ValueError('date fields must be numbers or string representations of numbers') # note has no internal key yet. new_note = { - 'content' : note.get('content', ''), - 'deleted' : note.get('deleted', 0), - 'modifydate' : modifydate, - 'createdate' : createdate, + 'content' : note.get('content', ''), + 'modified' : modified, + 'title' : note.get('title'), + 'category' : note.get('category', None), 'savedate' : 0, # never been written to disc 'syncdate' : 0, # never been synced with server - 'tags' : note.get('tags', []), - 'systemtags' : note.get('systemtags', []) + 'favorite' : False } # sanity check all note values @@ -328,7 +327,7 @@ def import_note(self, note): if not new_note['deleted'] in (0, 1): raise ValueError('"deleted" must be 0 or 1') - for n in (new_note['modifydate'], new_note['createdate']): + for n in (new_note['modified']): if not 0 <= n <= timestamp: raise ValueError('date fields must be real') @@ -359,14 +358,14 @@ def create_note(self, content): # note has no internal key yet. new_note = { - 'localkey' : new_key, - 'content' : content, - 'deleted' : 0, - 'modifydate' : timestamp, - 'createdate' : timestamp, + 'localkey' : new_key, + 'content' : note.get('content', ''), + 'modified' : modified, + 'title' : note.get('title'), + 'category' : note.get('category', None), 'savedate' : 0, # never been written to disc 'syncdate' : 0, # never been synced with server - 'tags' : [] + 'favorite' : False } self.notes[new_key] = new_note @@ -396,7 +395,7 @@ def set_note_deleted(self, key, deleted): if (not n['deleted'] and deleted) or \ (n['deleted'] and not deleted): n['deleted'] = deleted - n['modifydate'] = time.time() + n['modified'] = time.time() self.flag_what_changed(n, 'deleted') self.log('Note {0} (key={1})'.format('trashed' if deleted else 'untrashed', key)) @@ -405,7 +404,7 @@ def set_note_content(self, key, content): old_content = n.get('content') if content != old_content: n['content'] = content - n['modifydate'] = time.time() + n['modified'] = time.time() self.flag_what_changed(n, 'content') self.log('Note content updated (key={0})'.format(key)) @@ -415,7 +414,7 @@ def set_note_tags(self, key, tags): tags = utils.sanitise_tags(tags) if tags != old_tags: n['tags'] = tags - n['modifydate'] = time.time() + n['modified'] = time.time() self.flag_what_changed(n, 'tags') self.log('Note tags updated (key={0})'.format(key)) @@ -430,7 +429,7 @@ def set_note_pinned(self, key, pinned): systemtags.append('pinned') else: systemtags.remove('pinned') - n['modifydate'] = time.time() + n['modified'] = time.time() self.flag_what_changed(n, 'systemtags') self.log('Note {0} (key={1})'.format('pinned' if pinned else 'unpinned', key)) @@ -445,12 +444,12 @@ def set_note_markdown(self, key, markdown): systemtags.append('markdown') else: systemtags.remove('markdown') - n['modifydate'] = time.time() + n['modified'] = time.time() self.flag_what_changed(n, 'systemtags') self.log('Note markdown {0} (key={1})'.format('flagged' if markdown else 'unflagged', key)) def helper_key_to_fname(self, k): - return os.path.join(self.config.get_config('db_path'), k) + '.json' + return os.path.join(self.config.get_config('db_path'), str(k)) + '.json' def helper_save_note(self, k, note): # Save a single note to disc. @@ -498,11 +497,11 @@ def sync_notes(self, server_sync=True, full_sync=True): for note_index, local_key in enumerate(self.notes.keys()): n = self.notes[local_key] - if not n.get('key') or \ - float(n.get('modifydate')) > float(n.get('syncdate')): + if not n.get('id') or \ + float(n.get('modified')) > float(n.get('syncdate')): savedate = float(n.get('savedate')) - if float(n.get('modifydate')) > savedate or \ + if float(n.get('modified')) > savedate or \ float(n.get('syncdate')) > savedate: # this will trigger a save to disk after sync algorithm # we want this note saved even if offline or sync fails @@ -545,7 +544,7 @@ def sync_notes(self, server_sync=True, full_sync=True): # merge the note we got back (content could be empty) # record syncdate and save the note at the assigned key del self.notes[local_key] - k = uret[0].get('key') + k = uret[0].get('id') n.update(uret[0]) n['syncdate'] = now n['localkey'] = k @@ -567,7 +566,7 @@ def sync_notes(self, server_sync=True, full_sync=True): if not server_sync: nl = [] else: - nl = self.note.get_note_list(since=None if full_sync else self.last_sync) + nl = self.note.get_note_list() if nl[1] == 0: # success nl = nl[0] @@ -584,7 +583,7 @@ def sync_notes(self, server_sync=True, full_sync=True): if not skip_remote_syncing: len_nl = len(nl) for note_index, n in enumerate(nl): - k = n.get('key') + k = n.get('id') server_keys[k] = True # this works because in the prior step we rewrite local keys to # server keys when we get an updated note back from the server @@ -660,16 +659,16 @@ def get_note_version(self, key, version): def get_note_status(self, key): n = self.notes[key] o = utils.KeyValueObject(saved=False, synced=False, modified=False) - modifydate = float(n['modifydate']) + modified = float(n['modified']) savedate = float(n['savedate']) syncdate = float(n['syncdate']) - if savedate > modifydate: + if savedate > modified: o.saved = True else: o.modified = True - if syncdate > modifydate: + if syncdate > modified: o.synced = True return o diff --git a/nnotes_cli/utils.py b/nnotes_cli/utils.py @@ -59,7 +59,7 @@ def get_note_tags(note): # 'm' - markdown def get_note_flags(note): flags = '' - flags += 'X' if float(note['modifydate']) > float(note['syncdate']) else ' ' + flags += 'X' if float(note['modified']) > float(note['syncdate']) else ' ' flags += 'T' if 'deleted' in note and note['deleted'] else ' ' if 'systemtags' in note: flags += '*' if 'pinned' in note['systemtags'] else ' ' @@ -180,9 +180,9 @@ def sort_notes_by_tags(notes, pinned_ontop=False): def sort_by_modify_date_pinned(a): if note_pinned(a.note): - return 100.0 * float(a.note.get('modifydate', 0)) + return 100.0 * float(a.note.get('modified', 0)) else: - return float(a.note.get('modifydate', 0)) + return float(a.note.get('modified', 0)) class KeyValueObject: """Store key=value pairs in this object and retrieve with o.key. diff --git a/nnotes_cli/view_note.py b/nnotes_cli/view_note.py @@ -13,7 +13,7 @@ class ViewNote(urwid.ListBox): def __init__(self, config, args): self.config = config self.ndb = args['ndb'] - self.key = args['key'] + self.key = args['id'] self.log = args['log'] self.search_string = '' self.search_mode = 'gstyle' @@ -136,7 +136,7 @@ def get_status_bar(self): title = utils.get_note_title(self.old_note) version = self.old_note['version'] else: - t = time.localtime(float(self.note['modifydate'])) + t = time.localtime(float(self.note['modified'])) title = utils.get_note_title(self.note) flags = utils.get_note_flags(self.note) tags = utils.get_note_tags(self.note) diff --git a/nnotes_cli/view_titles.py b/nnotes_cli/view_titles.py @@ -46,7 +46,7 @@ def format_title(self, note): %N -- note title """ - t = time.localtime(float(note['modifydate'])) + t = time.localtime(float(note['modified'])) mod_time = time.strftime(self.config.get_config('format_strftime'), t) title = utils.get_note_title(note) flags = utils.get_note_flags(note)