nncli

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

commit 5af3cd7bd53c2c0940f5bef7534960a634a1a2f8
parent 04517eeab3ab8295ce352b6dcb876b11bb4f7a57
Author: Daniel Moch <daniel@danielmoch.com>
Date:   Sat, 28 Jul 2018 15:11:14 -0400

Basic functionality working

Notes can be created, edited, and synced with the server. Also added
todo.txt to capture things that still need to be ported over to
NextCloud Notes API.

Diffstat:
Annotes_cli/nextcloud_note.py | 273+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnnotes_cli/nncli.py | 2+-
Dnnotes_cli/nnotes.py | 269-------------------------------------------------------------------------------
Mnnotes_cli/notes_db.py | 45++++++++++++++++++++-------------------------
Atodo.txt | 2++
5 files changed, 296 insertions(+), 295 deletions(-)

diff --git a/nnotes_cli/nextcloud_note.py b/nnotes_cli/nextcloud_note.py @@ -0,0 +1,273 @@ +# +# The MIT License (MIT) +# +# Copyright (c) 2018 Daniel Moch +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +# Copyright (c) 2014 Eric Davis +# This file is *slightly* modified from simplynote.py. + +# -*- coding: utf-8 -*- +""" + nextcloud_note.py + ~~~~~~~~~~~~~~ + + Python library for accessing the NextCloud Notes API (v0.2) + + Modified from simplnote.py + :copyright: (c) 2011 by Daniel Schauenberg + :license: MIT, see LICENSE for more details. +""" + +import urllib.parse +from requests.exceptions import RequestException, ConnectionError +import base64 +import time +import datetime +import logging +import requests +import traceback + +try: + import json +except ImportError: + try: + import simplejson as json + except ImportError: + # For Google AppEngine + from django.utils import simplejson as json + +NOTE_FETCH_LENGTH = 100 + +class NextcloudLoginFailed(Exception): + pass + +class NextcloudNote(object): + """ Class for interacting with the NextCloud Notes web service """ + + def __init__(self, username, password, host): + """ object constructor """ + self.username = urllib.parse.quote(username) + self.password = urllib.parse.quote(password) + self.api_url = \ + 'https://{}:{}@{}/index.php/apps/notes/api/v0.2/notes'. \ + format(username, password, host) + self.sanitized_url = \ + 'https://{}:****@{}/index.php/apps/notes/api/v0.2/notes'. \ + format(username, host) + self.status = 'offline' + + def get_note(self, noteid): + """ method to get a specific note + + Arguments: + - noteid (string): ID of the note to get + + Returns: + A tuple `(note, status)` + + - note (dict): note object + - status (int): 0 on sucesss and -1 otherwise + + """ + # request note + url = '{}/{}'.format(self.api_url, str(noteid)) + #logging.debug('REQUEST: ' + self.sanitized_url+params) + try: + res = requests.get(url) + res.raise_for_status() + note = res.json() + self.status = 'online' + except ConnectionError as e: + self.status = 'offline, connection error' + return e, -1 + except RequestException as e: + # logging.debug('RESPONSE ERROR: ' + str(e)) + return e, -1 + except ValueError as e: + return e, -1 + + # # 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"]] + #logging.debug('RESPONSE OK: ' + str(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: all strings in notes stored as type str + # - use s.encode('utf-8') when bytes type needed + + # determine whether to create a new note or updated an existing one + if "id" in note: + # set modification timestamp if not set by client + if 'modified' not in note: + note["modified"] = int(time.time()) + + url = '{}/{}'.format(self.api_url, note["id"]) + else: + url = self.api_url + + #logging.debug('REQUEST: ' + url + ' - ' + str(note)) + try: + logging.debug('NOTE: ' + str(note)) + if "id" in note: + res = requests.put(url, data=note) + else: + res = requests.post(url, json=note) + note = res.json() + res.raise_for_status() + logging.debug('NOTE (from response): ' + str(note)) + self.status = 'online' + except ConnectionError as e: + self.status = 'offline, connection error' + return e, -1 + except RequestException as e: + logging.debug('RESPONSE ERROR: ' + str(e)) + logging.debug(traceback.print_exc()) + self.status = 'error updating note, check log' + return e, -1 + except ValueError as e: + return e, -1 + #logging.debug('RESPONSE OK: ' + str(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, category=None): + """ 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: + - 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`. + - status (int): 0 on sucesss and -1 otherwise + + """ + # initialize data + status = 0 + note_list = {} + + # get the note index + params = {'exclude': 'content'} + + # perform initial HTTP request + try: + logging.debug('REQUEST: ' + self.sanitized_url + \ + '?exclude=content') + res = requests.get(self.api_url, params=params) + res.raise_for_status() + #logging.debug('RESPONSE OK: ' + str(res)) + note_list = res.json() + self.status = 'online' + 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 + + # 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 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 + url = '{}/{}'.format(self.api_url, str(note_id)) + + try: + #logging.debug('REQUEST DELETE: ' + self.DATA_URL+params) + res = requests.delete(url) + res.raise_for_status() + self.status = 'online' + except ConnectionError as e: + self.status = 'offline, connection error' + return e, -1 + except RequestException as e: + return e, -1 + return {}, 0 diff --git a/nnotes_cli/nncli.py b/nnotes_cli/nncli.py @@ -31,7 +31,7 @@ from . import view_titles, view_note, view_help, view_log, user_input from . import utils, temp from .config import Config -from .nnotes import NextcloudNote +from .nextcloud_note import NextcloudNote from .notes_db import NotesDB, ReadError, WriteError from logging.handlers import RotatingFileHandler diff --git a/nnotes_cli/nnotes.py b/nnotes_cli/nnotes.py @@ -1,269 +0,0 @@ -# -# The MIT License (MIT) -# -# Copyright (c) 2018 Daniel Moch -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# - -# Copyright (c) 2014 Eric Davis -# This file is *slightly* modified from simplynote.py. - -# -*- coding: utf-8 -*- -""" - nnotes.py - ~~~~~~~~~~~~~~ - - Python library for accessing the NextCloud Notes API (v0.2) - - Modified from simplnote.py - :copyright: (c) 2011 by Daniel Schauenberg - :license: MIT, see LICENSE for more details. -""" - -import urllib.parse -from requests.exceptions import RequestException, ConnectionError -import base64 -import time -import datetime -import logging -import requests - -try: - import json -except ImportError: - try: - import simplejson as json - except ImportError: - # For Google AppEngine - from django.utils import simplejson as json - -NOTE_FETCH_LENGTH = 100 - -class NextcloudLoginFailed(Exception): - pass - -class NextcloudNote(object): - """ Class for interacting with the NextCloud Notes web service """ - - def __init__(self, username, password, host): - """ object constructor """ - self.username = urllib.parse.quote(username) - self.password = urllib.parse.quote(password) - self.api_url = \ - 'https://{}:{}@{}/index.php/apps/notes/api/v0.2/notes'. \ - format(username, password, host) - self.status = 'offline' - - def get_note(self, noteid): - """ method to get a specific note - - Arguments: - - noteid (string): ID of the note to get - - Returns: - A tuple `(note, status)` - - - note (dict): note object - - status (int): 0 on sucesss and -1 otherwise - - """ - # request note - url = '{}/{}'.format(self.api_url, str(noteid)) - #logging.debug('REQUEST: ' + self.DATA_URL+params) - try: - res = requests.get(url) - res.raise_for_status() - note = res.json() - self.status = 'online' - except ConnectionError as e: - self.status = 'offline, connection error' - return e, -1 - except RequestException as e: - # logging.debug('RESPONSE ERROR: ' + str(e)) - return e, -1 - except ValueError as e: - return e, -1 - - # # 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"]] - #logging.debug('RESPONSE OK: ' + str(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: all strings in notes stored as type str - # - use s.encode('utf-8') when bytes type needed - - # determine whether to create a new note or updated an existing one - params = {'auth': self.get_token(), - 'email': self.username} - if "id" in note: - # set modification timestamp if not set by client - if 'modified' not in note: - note["modified"] = time.time() - - url = '{}/{}'.format(self.api_url, note["id"]) - else: - url = self.api_url - - #logging.debug('REQUEST: ' + url + ' - ' + str(note)) - try: - data = urllib.parse.quote(json.dumps(note)) - if "id" in note: - res = requests.put(url, data=data) - else: - res = requests.post(url, data=data) - res.raise_for_status() - note = res.json() - self.status = 'online' - except ConnectionError as e: - self.status = 'offline, connection error' - return e, -1 - except RequestException as e: - logging.debug('RESPONSE ERROR: ' + str(e)) - self.status = 'error updating note, check log' - return e, -1 - except ValueError as e: - return e, -1 - #logging.debug('RESPONSE OK: ' + str(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, category=None): - """ 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: - - 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`. - - status (int): 0 on sucesss and -1 otherwise - - """ - # initialize data - status = 0 - note_list = {} - - # get the note index - params = {'exclude': 'content'} - - # perform initial HTTP request - try: - 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)) - note_list = res.json() - self.status = 'online' - 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 - - # 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 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 - url = '{}/{}'.format(self.api_url, str(note_id)) - - try: - #logging.debug('REQUEST DELETE: ' + self.DATA_URL+params) - res = requests.delete(url) - res.raise_for_status() - self.status = 'online' - except ConnectionError as e: - self.status = 'offline, connection error' - return e, -1 - except RequestException as e: - return e, -1 - return {}, 0 diff --git a/nnotes_cli/notes_db.py b/nnotes_cli/notes_db.py @@ -31,9 +31,9 @@ import os, time, re, glob, json, copy, threading from . import utils -from . import nnotes -nnotes.NOTE_FETCH_LENGTH=100 -from .nnotes import NextcloudNote +from . import nextcloud_note +nextcloud_note.NOTE_FETCH_LENGTH=100 +from .nextcloud_note import NextcloudNote import logging class ReadError(RuntimeError): @@ -58,7 +58,7 @@ def __init__(self, config, log, update_view): if not os.path.exists(self.config.get_config('db_path')): os.mkdir(self.config.get_config('db_path')) - now = time.time() + now = int(time.time()) # now read all .json files from disk fnlist = glob.glob(self.helper_key_to_fname('*')) @@ -302,11 +302,10 @@ def import_note(self, note): while new_key in self.notes: new_key = utils.generate_random_key() - timestamp = time.time() + timestamp = int(time.time()) try: 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') @@ -354,15 +353,14 @@ def create_note(self, content): while new_key in self.notes: new_key = utils.generate_random_key() - timestamp = time.time() + timestamp = int(time.time()) # note has no internal key yet. new_note = { 'localkey' : new_key, - 'content' : note.get('content', ''), - 'modified' : modified, - 'title' : note.get('title'), - 'category' : note.get('category', None), + 'content' : content, + 'modified' : timestamp, + 'category' : None, 'savedate' : 0, # never been written to disc 'syncdate' : 0, # never been synced with server 'favorite' : False @@ -395,7 +393,7 @@ def set_note_deleted(self, key, deleted): if (not n['deleted'] and deleted) or \ (n['deleted'] and not deleted): n['deleted'] = deleted - n['modified'] = time.time() + n['modified'] = int(time.time()) self.flag_what_changed(n, 'deleted') self.log('Note {0} (key={1})'.format('trashed' if deleted else 'untrashed', key)) @@ -404,7 +402,7 @@ def set_note_content(self, key, content): old_content = n.get('content') if content != old_content: n['content'] = content - n['modified'] = time.time() + n['modified'] = int(time.time()) self.flag_what_changed(n, 'content') self.log('Note content updated (key={0})'.format(key)) @@ -414,7 +412,7 @@ def set_note_tags(self, key, tags): tags = utils.sanitise_tags(tags) if tags != old_tags: n['tags'] = tags - n['modified'] = time.time() + n['modified'] = int(time.time()) self.flag_what_changed(n, 'tags') self.log('Note tags updated (key={0})'.format(key)) @@ -429,7 +427,7 @@ def set_note_pinned(self, key, pinned): systemtags.append('pinned') else: systemtags.remove('pinned') - n['modified'] = time.time() + n['modified'] = int(time.time()) self.flag_what_changed(n, 'systemtags') self.log('Note {0} (key={1})'.format('pinned' if pinned else 'unpinned', key)) @@ -442,7 +440,7 @@ def helper_save_note(self, k, note): json.dump(note, open(fn, 'w'), indent=2) # record that we saved this to disc. - note['savedate'] = time.time() + note['savedate'] = int(time.time()) def sync_notes(self, server_sync=True, full_sync=True): """Perform a full bi-directional sync with server. @@ -468,9 +466,9 @@ def sync_notes(self, server_sync=True, full_sync=True): local_updates = {} local_deletes = {} server_keys = {} - now = time.time() + now = int(time.time()) - sync_start_time = time.time() + sync_start_time = int(time.time()) sync_errors = 0 skip_remote_syncing = False @@ -507,17 +505,14 @@ def sync_notes(self, server_sync=True, full_sync=True): if 'minversion' in cn: del cn['minversion'] - del cn['createdate'] del cn['syncdate'] del cn['savedate'] if 'what_changed' in cn: - if 'deleted' not in cn['what_changed']: - del cn['deleted'] - if 'systemtags' not in cn['what_changed'] and 'systemtags' in cn: - del cn['systemtags'] - if 'tags' not in cn['what_changed']: - del cn['tags'] + if 'category' not in cn['what_changed']: + del cn['category'] + if 'favorite' not in cn['what_changed']: + del cn['favorite'] if 'content' not in cn['what_changed']: del cn['content'] del cn['what_changed'] diff --git a/todo.txt b/todo.txt @@ -0,0 +1,2 @@ +1. Trash->Delete +2. Pin->Favorite