nncli

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

commit b9e684429b3b4217fe4b64a6c3d30d381e6e889d
parent 37d0c543d708223cd7a587327c42089dc9dd1d57
Author: Daniel Moch <daniel@danielmoch.com>
Date:   Sun,  2 Sep 2018 15:48:08 -0400

Covert to Flit for builds

Diffstat:
M.gitignore | 2+-
A.travis.yml | 44++++++++++++++++++++++++++++++++++++++++++++
MMakefile | 22+++++++++++-----------
MPipfile | 6++++--
MPipfile.lock | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mdocs/source/conf.py | 6+++---
Dnncli | 7-------
Anncli/__init__.py | 4++++
Anncli/__main__.py | 6++++++
Rnnotes_cli/clipboard.py -> nncli/clipboard.py | 0
Anncli/config.py | 273+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rnnotes_cli/nextcloud_note.py -> nncli/nextcloud_note.py | 0
Anncli/nncli.py | 1305+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anncli/notes_db.py | 648+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rnnotes_cli/temp.py -> nncli/temp.py | 0
Anncli/user_input.py | 23+++++++++++++++++++++++
Rnnotes_cli/utils.py -> nncli/utils.py | 0
Anncli/view_help.py | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anncli/view_log.py | 47+++++++++++++++++++++++++++++++++++++++++++++++
Rnnotes_cli/view_note.py -> nncli/view_note.py | 0
Anncli/view_titles.py | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dnnotes_cli/__init__.py | 19-------------------
Dnnotes_cli/config.py | 274-------------------------------------------------------------------------------
Dnnotes_cli/nncli.py | 1316-------------------------------------------------------------------------------
Dnnotes_cli/notes_db.py | 649-------------------------------------------------------------------------------
Dnnotes_cli/user_input.py | 24------------------------
Dnnotes_cli/view_help.py | 126-------------------------------------------------------------------------------
Dnnotes_cli/view_log.py | 48------------------------------------------------
Dnnotes_cli/view_titles.py | 190-------------------------------------------------------------------------------
Apyproject.toml | 24++++++++++++++++++++++++
Apytest.ini | 2++
Dsetup.cfg | 5-----
Dsetup.py | 40----------------------------------------
Mtests/test_config.py | 2+-
Mtests/test_nncli.py | 31+++++--------------------------
Dtests/test_version.py | 35-----------------------------------
Atox.ini | 29+++++++++++++++++++++++++++++
37 files changed, 2804 insertions(+), 2793 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -2,7 +2,7 @@ build/ dist/ MANIFEST *.egg-info/ -nnotes_cli/version.py .coverage .pytest_cache/ docs/build/ +.tox diff --git a/.travis.yml b/.travis.yml @@ -0,0 +1,44 @@ +# https://travis-ci.org/djmoch/hookmeup +language: python + +matrix: + fast_finish: true + +.mixins: +- &xenial-mixin + dist: xenial + sudo: true + addons: + apt: + packages: + - libgnutls-dev + +env: + - PIPENV_HIDE_EMOJIS=1 PIPENV_NO_INHERIT=1 + +install: + - pip install pipenv python-coveralls + - make test-install + +jobs: + include: + - stage: test + script: make test + python: 3.4 + - stage: test + script: make test + python: 3.5 + - stage: test + script: make test + python: 3.6 + - stage: test + script: make test + <<: *xenial-mixin + python: 3.7 + - stage: lint + script: make lint + python: 3.6 + - stage: coverage + script: make coverage + after_success: coveralls + python: 3.5 diff --git a/Makefile b/Makefile @@ -56,29 +56,29 @@ clean-test: ## remove test and coverage artifacts rm -fr .tox lint: ## check style with pylint - $(PIPRUN) pylint nnotes_cli tests --disable=parse-error + $(PIPRUN) pylint nncli tests --disable=parse-error test: ## run tests quickly with the default Python $(PIPRUN) python -m pytest -# test-all: ## run tests on every Python version with tox -# $(PIPRUN) tox +test-all: ## run tests on every Python version with tox + $(PIPRUN) tox -# test-install: ## install dependenices from Pipfile (for tox / CI builds) -# $(PIPINST) +test-install: ## install dependenices from Pipfile (for tox / CI builds) + $(PIPINST) coverage: ## check code coverage quickly with the default Python - $(PIPRUN) python -m pytest --cov=nnotes_cli + $(PIPRUN) python -m pytest --cov=nncli coverage-html: coverage ## generate an HTML report and open in browser $(PIPRUN) coverage html $(BROWSER) htmlcov/index.html -# release: dist ## package and upload a release -# $(PIPRUN) flit publish +release: dist ## package and upload a release + $(PIPRUN) flit publish dist: ## builds source and wheel package - $(PIPRUN) python setup.py build sdist bdist_wheel + $(PIPRUN) flit build ls -l dist docs: ## builds the sphinx documentation and opens in the browser @@ -86,10 +86,10 @@ docs: ## builds the sphinx documentation and opens in the browser $(BROWSER) docs/build/html/index.html install: ## install the package to the active Python's site-packages - $(PIPRUN) python setup.py install --deps=none + $(PIPRUN) flit install --deps=none run: ## run the package from site-packages - $(PIPRUN) python -m nnotes_cli $(cmd) + $(PIPRUN) python -m nncli $(cmd) debug: install ## debug the package from site packages $(PIPRUN) pudb3 $$($(PIPRUN) which nncli) $(cmd) diff --git a/Pipfile b/Pipfile @@ -7,8 +7,6 @@ name = "pypi" appdirs = "*" requests = "*" urwid = "*" -setuptools = "*" -setuptools-scm = "*" [dev-packages] pytest = "*" @@ -18,3 +16,7 @@ pytest-runner = "*" pylint = "*" pudb = "*" sphinx = "*" +flit = "*" +setuptools = "*" +mock = "*" +tox = "*" diff --git a/Pipfile.lock b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "01e8eb4641e4f045cbea92e3fa3d85aa638729a04a12d94c92e755e8c2d217da" + "sha256": "b78d679a8d4aeef424469c6e67689b2652a5127407852ac6a70f7359ecbebdbb" }, "pipfile-spec": 6, "requires": {}, @@ -51,14 +51,6 @@ "index": "pypi", "version": "==2.19.1" }, - "setuptools-scm": { - "hashes": [ - "sha256:1191f2a136b5e86f7ca8ab00a97ef7aef997131f1f6d4971be69a1ef387d8b40", - "sha256:cc6953d224a22f10e933fa2f55c95979317c55259016adcf93310ba2997febfa" - ], - "index": "pypi", - "version": "==3.1.0" - }, "urllib3": { "hashes": [ "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", @@ -92,18 +84,18 @@ }, "atomicwrites": { "hashes": [ - "sha256:6b5282987b21cd79151f51caccead7a09d0a32e89c568bd9e3c4aaa7bbdf3f3a", - "sha256:e16334d50fe0f90919ef7339c24b9b62e6abaa78cd2d226f3d94eb067eb89043" + "sha256:0312ad34fcad8fac3704d441f7b317e50af620823353ec657a53e981f92920c0", + "sha256:ec9ae8adaae229e4f8446952d204a3e4b5fdd2d099f9be3aaf556120135fb3ee" ], - "markers": "python_version != '3.2.*' and python_version >= '2.7' and python_version != '3.1.*' and python_version != '3.3.*' and python_version != '3.0.*'", - "version": "==1.2.0" + "markers": "python_version != '3.0.*' and python_version != '3.3.*' and python_version != '3.2.*' and python_version >= '2.7' and python_version != '3.1.*'", + "version": "==1.2.1" }, "attrs": { "hashes": [ - "sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265", - "sha256:e0d0eb91441a3b53dab4d9b743eafc1ac44476296a2053b6ca3af0b139faf87b" + "sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69", + "sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb" ], - "version": "==18.1.0" + "version": "==18.2.0" }, "babel": { "hashes": [ @@ -171,6 +163,14 @@ ], "version": "==0.14" }, + "flit": { + "hashes": [ + "sha256:178e6865185b1802aa3b1944f4957d2c83fc56294dc8047d2c4722131f696e61", + "sha256:da823d4acae9bda42dcc0c7ab1d9be475a8a47aae5fd6dde63841d9f430ccb2f" + ], + "index": "pypi", + "version": "==1.1" + }, "idna": { "hashes": [ "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", @@ -248,6 +248,14 @@ ], "version": "==0.6.1" }, + "mock": { + "hashes": [ + "sha256:5ce3c71c5545b472da17b72268978914d0252980348636840bd34a00b5cc96c1", + "sha256:b158b6df76edd239b8208d481dc46b6afd45a846b7812ff0ce58971cf5bc8bba" + ], + "index": "pypi", + "version": "==2.0.0" + }, "more-itertools": { "hashes": [ "sha256:c187a73da93e7a8acc0001572aebc7e3c69daf7bf6881a2cea10650bd4420092", @@ -263,6 +271,13 @@ ], "version": "==17.1" }, + "pbr": { + "hashes": [ + "sha256:1b8be50d938c9bb75d0eaf7eda111eec1bf6dc88a62a6412e33bf077457e0f45", + "sha256:b486975c0cafb6beeb50ca0e17ba047647f229087bd74e37f4a7e2cac17d2caa" + ], + "version": "==4.2.0" + }, "pluggy": { "hashes": [ "sha256:6e3836e39f4d36ae72840833db137f7b7d35105079aee6ec4a62d9f80d594dd1", @@ -340,6 +355,12 @@ "index": "pypi", "version": "==4.2" }, + "pytoml": { + "hashes": [ + "sha256:dae3c4e31d09eb06a6076d671f2281ee5d2c43cbeae16599c3af20881bb818ac" + ], + "version": "==0.1.18" + }, "pytz": { "hashes": [ "sha256:a061aa0a9e06881eb8b3b2b43f05b9439d6583c206d0a6c340ff72a7b6669053", @@ -355,6 +376,13 @@ "index": "pypi", "version": "==2.19.1" }, + "requests-download": { + "hashes": [ + "sha256:92d895a6ca51ea51aa42bab864bddaee31b5601c7e7e1ade4c27b0eb6695d846", + "sha256:994d9d332befae6616f562769bab163f08d6404dc7e28fb7bfed4a0a43a754ad" + ], + "version": "==0.1.2" + }, "six": { "hashes": [ "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", @@ -385,6 +413,14 @@ "markers": "python_version != '3.0.*' and python_version != '3.1.*' and python_version >= '2.7' and python_version != '3.3.*' and python_version != '3.2.*'", "version": "==1.1.0" }, + "tox": { + "hashes": [ + "sha256:37cf240781b662fb790710c6998527e65ca6851eace84d1595ee71f7af4e85f7", + "sha256:eb61aa5bcce65325538686f09848f04ef679b5cd9b83cc491272099b28739600" + ], + "index": "pypi", + "version": "==3.2.1" + }, "urllib3": { "hashes": [ "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", @@ -400,6 +436,14 @@ "index": "pypi", "version": "==2.0.1" }, + "virtualenv": { + "hashes": [ + "sha256:2ce32cd126117ce2c539f0134eb89de91a8413a29baac49cbab3eb50e2026669", + "sha256:ca07b4c0b54e14a91af9f34d0919790b016923d157afda5efdde55c96718f752" + ], + "markers": "python_version != '3.2.*' and python_version >= '2.7' and python_version != '3.0.*' and python_version != '3.1.*'", + "version": "==16.0.0" + }, "wrapt": { "hashes": [ "sha256:d4d560d479f2c21e1b5443bbd15fe7ec4b37fe7e53d335d3b9b0a7b1226fe3c6" diff --git a/docs/source/conf.py b/docs/source/conf.py @@ -15,7 +15,7 @@ import os import sys sys.path.insert(0, os.path.abspath(os.path.sep.join(['..', '..']))) -import nnotes_cli +import nncli # -- Project information ----------------------------------------------------- @@ -24,9 +24,9 @@ author = 'Daniel Moch' # The short X.Y version -version = nnotes_cli.__version__ +version = nncli.__version__ # The full version, including alpha/beta/rc tags -release = nnotes_cli.__version__ +release = nncli.__version__ # -- General configuration --------------------------------------------------- diff --git a/nncli b/nncli @@ -1,7 +0,0 @@ -#!/usr/bin/env python3 -# -*- encoding: utf-8 -*- - -from nnotes_cli import nncli - -if __name__ == '__main__': - nncli.main() diff --git a/nncli/__init__.py b/nncli/__init__.py @@ -0,0 +1,4 @@ +# -*- encoding: utf-8 -*- +"""NextCloud Notes Command Line Interface""" + +__version__ = '1.3.0-dev' diff --git a/nncli/__main__.py b/nncli/__main__.py @@ -0,0 +1,6 @@ +# -*- encoding: utf-8 -*- +"""nncli main module""" +import nncli.nncli + +if __name__ == '__main__': + nncli.nncli.main() diff --git a/nnotes_cli/clipboard.py b/nncli/clipboard.py diff --git a/nncli/config.py b/nncli/config.py @@ -0,0 +1,273 @@ +# -*- encoding: utf-8 -*- + +import os, sys, urwid, collections, configparser, subprocess + +from appdirs import user_cache_dir, user_config_dir + +class Config: + + def __init__(self, custom_file=None): + self.config_home = user_config_dir('nncli', 'djmoch') + self.cache_home = user_cache_dir('nncli', 'djmoch') + + defaults = \ + { + 'cfg_nn_username' : '', + 'cfg_nn_password' : '', + 'cfg_nn_password_eval' : '', + 'cfg_db_path' : self.cache_home, + 'cfg_search_categories' : 'yes', # with regex searches + 'cfg_sort_mode' : 'date', # 'alpha' or 'date' + 'cfg_favorite_ontop' : 'yes', + 'cfg_tabstop' : '4', + 'cfg_format_strftime' : '%Y/%m/%d', + 'cfg_format_note_title' : '[%D] %F %-N %T', + 'cfg_status_bar' : 'yes', + 'cfg_editor' : os.environ['EDITOR'] if 'EDITOR' in os.environ else 'vim {fname} +{line}', + 'cfg_pager' : os.environ['PAGER'] if 'PAGER' in os.environ else 'less -c', + 'cfg_diff' : 'diff -b -U10', + 'cfg_max_logs' : '5', + 'cfg_log_timeout' : '5', + 'cfg_log_reversed' : 'yes', + 'cfg_nn_host' : '', + 'cfg_tempdir' : '', + + 'kb_help' : 'h', + 'kb_quit' : 'q', + 'kb_sync' : 'S', + 'kb_down' : 'j', + 'kb_up' : 'k', + 'kb_page_down' : 'space', + 'kb_page_up' : 'b', + 'kb_half_page_down' : 'ctrl d', + 'kb_half_page_up' : 'ctrl u', + 'kb_bottom' : 'G', + 'kb_top' : 'g', + 'kb_status' : 's', + 'kb_create_note' : 'C', + 'kb_edit_note' : 'e', + 'kb_view_note' : 'enter', + 'kb_view_note_ext' : 'meta enter', + 'kb_view_note_json' : 'O', + 'kb_pipe_note' : '|', + 'kb_view_next_note' : 'J', + 'kb_view_prev_note' : 'K', + 'kb_view_log' : 'l', + 'kb_tabstop2' : '2', + 'kb_tabstop4' : '4', + 'kb_tabstop8' : '8', + 'kb_search_gstyle' : '/', + 'kb_search_regex' : 'meta /', + 'kb_search_prev_gstyle' : '?', + 'kb_search_prev_regex' : 'meta ?', + 'kb_search_next' : 'n', + 'kb_search_prev' : 'N', + 'kb_clear_search' : 'A', + 'kb_sort_date' : 'd', + 'kb_sort_alpha' : 'a', + 'kb_sort_categories' : 'ctrl t', + 'kb_note_delete' : 'D', + 'kb_note_favorite' : 'p', + 'kb_note_category' : 't', + 'kb_copy_note_text' : 'y', + + 'clr_default_fg' : 'default', + 'clr_default_bg' : 'default', + 'clr_status_bar_fg' : 'dark gray', + 'clr_status_bar_bg' : 'light gray', + 'clr_log_fg' : 'dark gray', + 'clr_log_bg' : 'light gray', + 'clr_user_input_bar_fg' : 'white', + 'clr_user_input_bar_bg' : 'light red', + 'clr_note_focus_fg' : 'white', + 'clr_note_focus_bg' : 'light red', + 'clr_note_title_day_fg' : 'light red', + 'clr_note_title_day_bg' : 'default', + 'clr_note_title_week_fg' : 'light green', + 'clr_note_title_week_bg' : 'default', + 'clr_note_title_month_fg' : 'brown', + 'clr_note_title_month_bg' : 'default', + 'clr_note_title_year_fg' : 'light blue', + 'clr_note_title_year_bg' : 'default', + 'clr_note_title_ancient_fg' : 'light blue', + 'clr_note_title_ancient_bg' : 'default', + 'clr_note_date_fg' : 'dark blue', + 'clr_note_date_bg' : 'default', + 'clr_note_flags_fg' : 'dark magenta', + 'clr_note_flags_bg' : 'default', + 'clr_note_category_fg' : 'dark red', + 'clr_note_category_bg' : 'default', + 'clr_note_content_fg' : 'default', + 'clr_note_content_bg' : 'default', + 'clr_note_content_focus_fg' : 'white', + 'clr_note_content_focus_bg' : 'light red', + 'clr_note_content_old_fg' : 'yellow', + 'clr_note_content_old_bg' : 'dark gray', + 'clr_note_content_old_focus_fg' : 'white', + 'clr_note_content_old_focus_bg' : 'light red', + 'clr_help_focus_fg' : 'white', + 'clr_help_focus_bg' : 'light red', + 'clr_help_header_fg' : 'dark blue', + 'clr_help_header_bg' : 'default', + 'clr_help_config_fg' : 'dark green', + 'clr_help_config_bg' : 'default', + 'clr_help_value_fg' : 'dark red', + 'clr_help_value_bg' : 'default', + 'clr_help_descr_fg' : 'default', + 'clr_help_descr_bg' : 'default' + } + + 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.config_home, 'config')]) + + cfg_sec = 'nncli' + + if not cp.has_section(cfg_sec): + cp.add_section(cfg_sec) + + + # special handling for password so we can retrieve it by running a command + nn_password = cp.get(cfg_sec, 'cfg_nn_password', raw=True) + if not nn_password: + command = cp.get(cfg_sec, 'cfg_nn_password_eval', raw=True) + if command: + try: + nn_password = subprocess.check_output(command, shell=True, universal_newlines=True) + # remove trailing newlines to avoid requiring butchering shell commands (they can't usually be in passwords anyway) + nn_password = nn_password.rstrip('\n') + except subprocess.CalledProcessError as e: + print('Error evaluating command for password.') + print(e) + sys.exit(1) + + # ordered dicts used to ease help + + self.configs = collections.OrderedDict() + self.configs['nn_username'] = [ cp.get(cfg_sec, 'cfg_nn_username', raw=True), 'NextCloud Username' ] + self.configs['nn_password'] = [ nn_password, 'NextCloud Password' ] + self.configs['nn_host'] = [ cp.get(cfg_sec, 'cfg_nn_host', raw=True), 'NextCloud server hostname' ] + self.configs['db_path'] = [ cp.get(cfg_sec, 'cfg_db_path'), 'Note storage path' ] + self.configs['search_categories'] = [ cp.get(cfg_sec, 'cfg_search_categories'), 'Search categories as well' ] + self.configs['sort_mode'] = [ cp.get(cfg_sec, 'cfg_sort_mode'), 'Sort mode' ] + self.configs['favorite_ontop'] = [ cp.get(cfg_sec, 'cfg_favorite_ontop'), 'Favorite at top of list' ] + self.configs['tabstop'] = [ cp.get(cfg_sec, 'cfg_tabstop'), 'Tabstop spaces' ] + self.configs['format_strftime'] = [ cp.get(cfg_sec, 'cfg_format_strftime', raw=True), 'Date strftime format' ] + self.configs['format_note_title'] = [ cp.get(cfg_sec, 'cfg_format_note_title', raw=True), 'Note title format' ] + self.configs['status_bar'] = [ cp.get(cfg_sec, 'cfg_status_bar'), 'Show the status bar' ] + self.configs['editor'] = [ cp.get(cfg_sec, 'cfg_editor'), 'Editor command' ] + self.configs['pager'] = [ cp.get(cfg_sec, 'cfg_pager'), 'External pager command' ] + self.configs['diff'] = [ cp.get(cfg_sec, 'cfg_diff'), 'External diff command' ] + self.configs['max_logs'] = [ cp.get(cfg_sec, 'cfg_max_logs'), 'Max logs in footer' ] + self.configs['log_timeout'] = [ cp.get(cfg_sec, 'cfg_log_timeout'), 'Log timeout' ] + self.configs['log_reversed'] = [ cp.get(cfg_sec, 'cfg_log_reversed'), 'Log file reversed' ] + self.configs['tempdir'] = [ cp.get(cfg_sec, 'cfg_tempdir'), 'Temporary directory for note storage' ] + + self.keybinds = collections.OrderedDict() + self.keybinds['help'] = [ cp.get(cfg_sec, 'kb_help'), [ 'common' ], 'Help' ] + self.keybinds['quit'] = [ cp.get(cfg_sec, 'kb_quit'), [ 'common' ], 'Quit' ] + self.keybinds['sync'] = [ cp.get(cfg_sec, 'kb_sync'), [ 'common' ], 'Full sync' ] + self.keybinds['down'] = [ cp.get(cfg_sec, 'kb_down'), [ 'common' ], 'Scroll down one line' ] + self.keybinds['up'] = [ cp.get(cfg_sec, 'kb_up'), [ 'common' ], 'Scroll up one line' ] + self.keybinds['page_down'] = [ cp.get(cfg_sec, 'kb_page_down'), [ 'common' ], 'Page down' ] + self.keybinds['page_up'] = [ cp.get(cfg_sec, 'kb_page_up'), [ 'common' ], 'Page up' ] + self.keybinds['half_page_down'] = [ cp.get(cfg_sec, 'kb_half_page_down'), [ 'common' ], 'Half page down' ] + self.keybinds['half_page_up'] = [ cp.get(cfg_sec, 'kb_half_page_up'), [ 'common' ], 'Half page up' ] + self.keybinds['bottom'] = [ cp.get(cfg_sec, 'kb_bottom'), [ 'common' ], 'Goto bottom' ] + self.keybinds['top'] = [ cp.get(cfg_sec, 'kb_top'), [ 'common' ], 'Goto top' ] + self.keybinds['status'] = [ cp.get(cfg_sec, 'kb_status'), [ 'common' ], 'Toggle status bar' ] + self.keybinds['view_log'] = [ cp.get(cfg_sec, 'kb_view_log'), [ 'common' ], 'View log' ] + self.keybinds['create_note'] = [ cp.get(cfg_sec, 'kb_create_note'), [ 'titles' ], 'Create a new note' ] + self.keybinds['edit_note'] = [ cp.get(cfg_sec, 'kb_edit_note'), [ 'titles', 'notes' ], 'Edit note' ] + self.keybinds['view_note'] = [ cp.get(cfg_sec, 'kb_view_note'), [ 'titles' ], 'View note' ] + self.keybinds['view_note_ext'] = [ cp.get(cfg_sec, 'kb_view_note_ext'), [ 'titles', 'notes' ], 'View note with pager' ] + self.keybinds['view_note_json'] = [ cp.get(cfg_sec, 'kb_view_note_json'), [ 'titles', 'notes' ], 'View note raw json' ] + self.keybinds['pipe_note'] = [ cp.get(cfg_sec, 'kb_pipe_note'), [ 'titles', 'notes' ], 'Pipe note contents' ] + self.keybinds['view_next_note'] = [ cp.get(cfg_sec, 'kb_view_next_note'), [ 'notes' ], 'View next note' ] + self.keybinds['view_prev_note'] = [ cp.get(cfg_sec, 'kb_view_prev_note'), [ 'notes' ], 'View previous note' ] + self.keybinds['tabstop2'] = [ cp.get(cfg_sec, 'kb_tabstop2'), [ 'notes' ], 'View with tabstop=2' ] + self.keybinds['tabstop4'] = [ cp.get(cfg_sec, 'kb_tabstop4'), [ 'notes' ], 'View with tabstop=4' ] + self.keybinds['tabstop8'] = [ cp.get(cfg_sec, 'kb_tabstop8'), [ 'notes' ], 'View with tabstop=8' ] + self.keybinds['search_gstyle'] = [ cp.get(cfg_sec, 'kb_search_gstyle'), [ 'titles', 'notes' ], 'Search using gstyle' ] + self.keybinds['search_prev_gstyle'] = [ cp.get(cfg_sec, 'kb_search_prev_gstyle'), [ 'notes' ], 'Search backwards using gstyle' ] + self.keybinds['search_regex'] = [ cp.get(cfg_sec, 'kb_search_regex'), [ 'titles', 'notes' ], 'Search using regex' ] + self.keybinds['search_prev_regex'] = [ cp.get(cfg_sec, 'kb_search_prev_regex'), [ 'notes' ], 'Search backwards using regex' ] + self.keybinds['search_next'] = [ cp.get(cfg_sec, 'kb_search_next'), [ 'notes' ], 'Go to next search result' ] + self.keybinds['search_prev'] = [ cp.get(cfg_sec, 'kb_search_prev'), [ 'notes' ], 'Go to previous search result' ] + self.keybinds['clear_search'] = [ cp.get(cfg_sec, 'kb_clear_search'), [ 'titles' ], 'Show all notes' ] + self.keybinds['sort_date'] = [ cp.get(cfg_sec, 'kb_sort_date'), [ 'titles' ], 'Sort notes by date' ] + self.keybinds['sort_alpha'] = [ cp.get(cfg_sec, 'kb_sort_alpha'), [ 'titles' ], 'Sort notes by alpha' ] + self.keybinds['sort_categories'] = [ cp.get(cfg_sec, 'kb_sort_categories'), [ 'titles' ], 'Sort notes by categories' ] + self.keybinds['note_delete'] = [ cp.get(cfg_sec,'kb_note_delete'), [ 'titles', 'notes' ], 'Delete a note' ] + self.keybinds['note_favorite'] = [ cp.get(cfg_sec, 'kb_note_favorite'), [ 'titles', 'notes' ], 'Favorite note' ] + self.keybinds['note_category'] = [ cp.get(cfg_sec, 'kb_note_category'), [ 'titles', 'notes' ], 'Edit note category' ] + self.keybinds['copy_note_text'] = [ cp.get(cfg_sec, 'kb_copy_note_text'), [ 'notes' ], 'Copy line (xsel/pbcopy)' ] + + self.colors = collections.OrderedDict() + self.colors['default_fg'] = [ cp.get(cfg_sec, 'clr_default_fg'), 'Default fg' ] + self.colors['default_bg'] = [ cp.get(cfg_sec, 'clr_default_bg'), 'Default bg' ] + self.colors['status_bar_fg'] = [ cp.get(cfg_sec, 'clr_status_bar_fg'), 'Status bar fg' ] + self.colors['status_bar_bg'] = [ cp.get(cfg_sec, 'clr_status_bar_bg'), 'Status bar bg' ] + self.colors['log_fg'] = [ cp.get(cfg_sec, 'clr_log_fg'), 'Log message fg' ] + self.colors['log_bg'] = [ cp.get(cfg_sec, 'clr_log_bg'), 'Log message bg' ] + self.colors['user_input_bar_fg'] = [ cp.get(cfg_sec, 'clr_user_input_bar_fg'), 'User input bar fg' ] + self.colors['user_input_bar_bg'] = [ cp.get(cfg_sec, 'clr_user_input_bar_bg'), 'User input bar bg' ] + self.colors['note_focus_fg'] = [ cp.get(cfg_sec, 'clr_note_focus_fg'), 'Note title focus fg' ] + self.colors['note_focus_bg'] = [ cp.get(cfg_sec, 'clr_note_focus_bg'), 'Note title focus bg' ] + self.colors['note_title_day_fg'] = [ cp.get(cfg_sec, 'clr_note_title_day_fg'), 'Day old note title fg' ] + self.colors['note_title_day_bg'] = [ cp.get(cfg_sec, 'clr_note_title_day_bg'), 'Day old note title bg' ] + self.colors['note_title_week_fg'] = [ cp.get(cfg_sec, 'clr_note_title_week_fg'), 'Week old note title fg' ] + self.colors['note_title_week_bg'] = [ cp.get(cfg_sec, 'clr_note_title_week_bg'), 'Week old note title bg' ] + self.colors['note_title_month_fg'] = [ cp.get(cfg_sec, 'clr_note_title_month_fg'), 'Month old note title fg' ] + self.colors['note_title_month_bg'] = [ cp.get(cfg_sec, 'clr_note_title_month_bg'), 'Month old note title bg' ] + self.colors['note_title_year_fg'] = [ cp.get(cfg_sec, 'clr_note_title_year_fg'), 'Year old note title fg' ] + self.colors['note_title_year_bg'] = [ cp.get(cfg_sec, 'clr_note_title_year_bg'), 'Year old note title bg' ] + self.colors['note_title_ancient_fg'] = [ cp.get(cfg_sec, 'clr_note_title_ancient_fg'), 'Ancient note title fg' ] + self.colors['note_title_ancient_bg'] = [ cp.get(cfg_sec, 'clr_note_title_ancient_bg'), 'Ancient note title bg' ] + self.colors['note_date_fg'] = [ cp.get(cfg_sec, 'clr_note_date_fg'), 'Note date fg' ] + self.colors['note_date_bg'] = [ cp.get(cfg_sec, 'clr_note_date_bg'), 'Note date bg' ] + self.colors['note_flags_fg'] = [ cp.get(cfg_sec, 'clr_note_flags_fg'), 'Note flags fg' ] + self.colors['note_flags_bg'] = [ cp.get(cfg_sec, 'clr_note_flags_bg'), 'Note flags bg' ] + self.colors['note_category_fg'] = [ cp.get(cfg_sec, 'clr_note_category_fg'), 'Note category fg' ] + self.colors['note_category_bg'] = [ cp.get(cfg_sec, 'clr_note_category_bg'), 'Note category bg' ] + self.colors['note_content_fg'] = [ cp.get(cfg_sec, 'clr_note_content_fg'), 'Note content fg' ] + self.colors['note_content_bg'] = [ cp.get(cfg_sec, 'clr_note_content_bg'), 'Note content bg' ] + self.colors['note_content_focus_fg'] = [ cp.get(cfg_sec, 'clr_note_content_focus_fg'), 'Note content focus fg' ] + self.colors['note_content_focus_bg'] = [ cp.get(cfg_sec, 'clr_note_content_focus_bg'), 'Note content focus bg' ] + self.colors['note_content_old_fg'] = [ cp.get(cfg_sec, 'clr_note_content_old_fg'), 'Old note content fg' ] + self.colors['note_content_old_bg'] = [ cp.get(cfg_sec, 'clr_note_content_old_bg'), 'Old note content bg' ] + self.colors['note_content_old_focus_fg'] = [ cp.get(cfg_sec, 'clr_note_content_old_focus_fg'), 'Old note content focus fg' ] + self.colors['note_content_old_focus_bg'] = [ cp.get(cfg_sec, 'clr_note_content_old_focus_bg'), 'Old note content focus bg' ] + self.colors['help_focus_fg'] = [ cp.get(cfg_sec, 'clr_help_focus_fg'), 'Help focus fg' ] + self.colors['help_focus_bg'] = [ cp.get(cfg_sec, 'clr_help_focus_bg'), 'Help focus bg' ] + self.colors['help_header_fg'] = [ cp.get(cfg_sec, 'clr_help_header_fg'), 'Help header fg' ] + self.colors['help_header_bg'] = [ cp.get(cfg_sec, 'clr_help_header_bg'), 'Help header bg' ] + self.colors['help_config_fg'] = [ cp.get(cfg_sec, 'clr_help_config_fg'), 'Help config fg' ] + self.colors['help_config_bg'] = [ cp.get(cfg_sec, 'clr_help_config_bg'), 'Help config bg' ] + self.colors['help_value_fg'] = [ cp.get(cfg_sec, 'clr_help_value_fg'), 'Help value fg' ] + self.colors['help_value_bg'] = [ cp.get(cfg_sec, 'clr_help_value_bg'), 'Help value bg' ] + self.colors['help_descr_fg'] = [ cp.get(cfg_sec, 'clr_help_descr_fg'), 'Help description fg' ] + self.colors['help_descr_bg'] = [ cp.get(cfg_sec, 'clr_help_descr_bg'), 'Help description bg' ] + + def get_config(self, name): + return self.configs[name][0] + + def get_config_descr(self, name): + return self.configs[name][1] + + def get_keybind(self, name): + return self.keybinds[name][0] + + def get_keybind_use(self, name): + return self.keybinds[name][1] + + def get_keybind_descr(self, name): + return self.keybinds[name][2] + + def get_color(self, name): + return self.colors[name][0] + + def get_color_descr(self, name): + return self.colors[name][1] diff --git a/nnotes_cli/nextcloud_note.py b/nncli/nextcloud_note.py diff --git a/nncli/nncli.py b/nncli/nncli.py @@ -0,0 +1,1305 @@ +# -*- encoding: utf-8 -*- + +import os, sys, getopt, re, signal, time, datetime, shlex, hashlib +import subprocess, threading, logging +import copy, json, urwid, datetime +import nncli +from . import view_titles, view_note, view_help, view_log, user_input +from . import utils, temp +from .config import Config +from .nextcloud_note import NextcloudNote +from .notes_db import NotesDB, ReadError, WriteError +from logging.handlers import RotatingFileHandler + +class nncli: + + def __init__(self, do_server_sync, verbose=False, config_file=None): + self.config = Config(config_file) + self.do_server_sync = do_server_sync + self.verbose = verbose + self.do_gui = False + force_full_sync = False + self.current_sort_mode = self.config.get_config('sort_mode') + + self.tempdir = self.config.get_config('tempdir') + if self.tempdir == '': + self.tempdir = None + + if not os.path.exists(self.config.get_config('db_path')): + os.mkdir(self.config.get_config('db_path')) + force_full_sync = True + + # configure the logging module + self.logfile = os.path.join(self.config.get_config('db_path'), 'nncli.log') + self.loghandler = RotatingFileHandler(self.logfile, maxBytes=100000, backupCount=1) + self.loghandler.setLevel(logging.DEBUG) + self.loghandler.setFormatter(logging.Formatter(fmt='%(asctime)s [%(levelname)s] %(message)s')) + self.logger = logging.getLogger() + self.logger.setLevel(logging.DEBUG) + self.logger.addHandler(self.loghandler) + self.config.logfile = self.logfile + + logging.debug('nncli logging initialized') + + self.logs = [] + + try: + self.ndb = NotesDB(self.config, self.log, self.gui_update_view) + except Exception as e: + self.log(str(e)) + sys.exit(1) + + if force_full_sync: + # The note database doesn't exist so force a full sync. It is + # important to do this outside of the gui because an account + # with hundreds of notes will cause a recursion panic under + # urwid. This simple workaround gets the job done. :-) + self.verbose = True + self.log('nncli database doesn\'t exist, forcing full sync...') + self.sync_notes() + self.verbose = verbose + + def sync_notes(self): + self.ndb.sync_now(self.do_server_sync) + + def get_editor(self): + editor = self.config.get_config('editor') + if not editor: + self.log('No editor configured!') + return None + return editor + + def get_pager(self): + pager = self.config.get_config('pager') + if not pager: + self.log('No pager configured!') + return None + return pager + + def get_diff(self): + diff = self.config.get_config('diff') + if not diff: + self.log('No diff command configured!') + return None + return diff + + def exec_cmd_on_note(self, note, cmd=None, raw=False): + + if not cmd: + cmd = self.get_editor() + if not cmd: + return None + + tf = temp.tempfile_create(note if note else None, raw=raw, tempdir=self.tempdir) + fname = temp.tempfile_name(tf) + + focus_position = 0 + try: + focus_position = self.gui_body_get().focus_position + except IndexError: + # focus position will fail if no notes available (listbox empty) + # TODO: find a neater way to check than try/except + pass + except AttributeError: + # we're running in CLI mode + pass + + subs = { + 'fname': fname, + 'line': focus_position + 1, + } + cmd_list = [c.format(**subs) for c in shlex.split(cmd)] + + # if the filename wasn't able to be subbed, append it + # this makes it fully backwards compatible with previous configs + if '{fname}' not in cmd: + cmd_list.append(fname) + + self.log("EXECUTING: {}".format(cmd_list)) + + try: + subprocess.check_call(cmd_list) + except Exception as e: + self.log('Command error: ' + str(e)) + temp.tempfile_delete(tf) + return None + + content = None + if not raw: + content = temp.tempfile_content(tf) + if not content or content == '\n': + content = None + + temp.tempfile_delete(tf) + return content + + def exec_diff_on_note(self, note, old_note): + + diff = self.get_diff() + if not diff: + return None + + pager = self.get_pager() + if not pager: + return None + + ltf = temp.tempfile_create(note, tempdir=self.tempdir) + otf = temp.tempfile_create(old_note, tempdir=self.tempdir) + out = temp.tempfile_create(None, tempdir=self.tempdir) + + try: + subprocess.call(diff + ' ' + + temp.tempfile_name(ltf) + ' ' + + temp.tempfile_name(otf) + ' > ' + + temp.tempfile_name(out), + shell=True) + subprocess.check_call(pager + ' ' + + temp.tempfile_name(out), + shell=True) + except Exception as e: + self.log('Command error: ' + str(e)) + temp.tempfile_delete(ltf) + temp.tempfile_delete(otf) + temp.tempfile_delete(out) + return None + + temp.tempfile_delete(ltf) + temp.tempfile_delete(otf) + temp.tempfile_delete(out) + return None + + def gui_header_clear(self): + self.master_frame.contents['header'] = ( None, None ) + self.nncli_loop.draw_screen() + + def gui_header_set(self, w): + self.master_frame.contents['header'] = ( w, None ) + self.nncli_loop.draw_screen() + + def gui_header_get(self): + return self.master_frame.contents['header'][0] + + def gui_header_focus(self): + self.master_frame.focus_position = 'header' + + def gui_footer_log_clear(self): + ui = self.gui_footer_input_get() + self.master_frame.contents['footer'] = \ + (urwid.Pile([ urwid.Pile([]), urwid.Pile([ui]) ]), None) + self.nncli_loop.draw_screen() + + def gui_footer_log_set(self, pl): + ui = self.gui_footer_input_get() + self.master_frame.contents['footer'] = \ + (urwid.Pile([ urwid.Pile(pl), urwid.Pile([ui]) ]), None) + self.nncli_loop.draw_screen() + + def gui_footer_log_get(self): + return self.master_frame.contents['footer'][0].contents[0][0] + + def gui_footer_input_clear(self): + pl = self.gui_footer_log_get() + self.master_frame.contents['footer'] = \ + (urwid.Pile([ urwid.Pile([pl]), urwid.Pile([]) ]), None) + self.nncli_loop.draw_screen() + + def gui_footer_input_set(self, ui): + pl = self.gui_footer_log_get() + self.master_frame.contents['footer'] = \ + (urwid.Pile([ urwid.Pile([pl]), urwid.Pile([ui]) ]), None) + self.nncli_loop.draw_screen() + + def gui_footer_input_get(self): + return self.master_frame.contents['footer'][0].contents[1][0] + + def gui_footer_focus_input(self): + self.master_frame.focus_position = 'footer' + self.master_frame.contents['footer'][0].focus_position = 1 + + def gui_body_clear(self): + self.master_frame.contents['body'] = ( None, None ) + self.nncli_loop.draw_screen() + + def gui_body_set(self, w): + self.master_frame.contents['body'] = ( w, None ) + self.gui_update_status_bar() + self.nncli_loop.draw_screen() + + def gui_body_get(self): + return self.master_frame.contents['body'][0] + + def gui_body_focus(self): + self.master_frame.focus_position = 'body' + + def log_timeout(self, loop, arg): + self.log_lock.acquire() + + self.log_alarms -= 1 + + if self.log_alarms == 0: + self.gui_footer_log_clear() + self.logs = [] + else: + # for some reason having problems with this being empty? + if len(self.logs) > 0: + self.logs.pop(0) + + log_pile = [] + + for l in self.logs: + log_pile.append(urwid.AttrMap(urwid.Text(l), 'log')) + + if self.verbose: + self.gui_footer_log_set(log_pile) + + self.log_lock.release() + + def log(self, msg): + logging.debug(msg) + + if not self.do_gui: + if self.verbose: + print(msg) + return + + self.log_lock.acquire() + + self.log_alarms += 1 + self.logs.append(msg) + + if len(self.logs) > int(self.config.get_config('max_logs')): + self.log_alarms -= 1 + self.logs.pop(0) + + log_pile = [] + for l in self.logs: + log_pile.append(urwid.AttrMap(urwid.Text(l), 'log')) + + if self.verbose: + self.gui_footer_log_set(log_pile) + + self.nncli_loop.set_alarm_in( + int(self.config.get_config('log_timeout')), + self.log_timeout, None) + + self.log_lock.release() + + def gui_update_view(self): + if not self.do_gui: + return + + try: + cur_key = self.view_titles.note_list[self.view_titles.focus_position].note['localkey'] + except IndexError as e: + cur_key = None + pass + + self.view_titles.update_note_list(self.view_titles.search_string, sort_mode=self.current_sort_mode) + self.view_titles.focus_note(cur_key) + + if self.gui_body_get().__class__ == view_note.ViewNote: + self.view_note.update_note_view() + + self.gui_update_status_bar() + + def gui_update_status_bar(self): + if self.status_bar != 'yes': + self.gui_header_clear() + else: + self.gui_header_set(self.gui_body_get().get_status_bar()) + + def gui_switch_frame_body(self, new_view, save_current_view=True): + if new_view == None: + if len(self.last_view) == 0: + # XXX verify all notes saved... + self.gui_stop() + else: + self.gui_body_set(self.last_view.pop()) + else: + if self.gui_body_get().__class__ != new_view.__class__: + if save_current_view: + self.last_view.append(self.gui_body_get()) + self.gui_body_set(new_view) + + def delete_note_callback(self, key, delete): + if not delete: + return + note = self.ndb.get_note(key) + self.ndb.set_note_deleted(key, True) + + if self.gui_body_get().__class__ == view_titles.ViewTitles: + self.view_titles.update_note_title() + + self.gui_update_status_bar() + self.ndb.sync_worker_go() + + def gui_yes_no_input(self, args, yes_no): + self.gui_footer_input_clear() + self.gui_body_focus() + self.master_frame.keypress = self.gui_frame_keypress + args[0](args[1], + True if yes_no in [ 'YES', 'Yes', 'yes', 'Y', 'y' ] + else False) + + def gui_search_input(self, args, search_string): + self.gui_footer_input_clear() + self.gui_body_focus() + self.master_frame.keypress = self.gui_frame_keypress + if search_string: + if (self.gui_body_get() == self.view_note): + self.search_direction = args[1] + self.view_note.search_note_view_next(search_string=search_string, search_mode=args[0]) + else: + self.view_titles.update_note_list(search_string, args[0], sort_mode=self.current_sort_mode) + self.gui_body_set(self.view_titles) + + def gui_category_input(self, args, category): + self.gui_footer_input_clear() + self.gui_body_focus() + self.master_frame.keypress = self.gui_frame_keypress + if category != None: + if self.gui_body_get().__class__ == view_titles.ViewTitles: + note = self.view_titles.note_list[self.view_titles.focus_position].note + else: # self.gui_body_get().__class__ == view_note.ViewNote: + note = self.view_note.note + + self.ndb.set_note_category(note['localkey'], category) + + if self.gui_body_get().__class__ == view_titles.ViewTitles: + self.view_titles.update_note_title() + else: # self.gui_body_get().__class__ == view_note.ViewNote: + self.view_note.update_note_view() + + self.gui_update_status_bar() + self.ndb.sync_worker_go() + + def gui_pipe_input(self, args, cmd): + self.gui_footer_input_clear() + self.gui_body_focus() + self.master_frame.keypress = self.gui_frame_keypress + if cmd != None: + if self.gui_body_get().__class__ == view_titles.ViewTitles: + note = self.view_titles.note_list[self.view_titles.focus_position].note + else: # self.gui_body_get().__class__ == view_note.ViewNote: + note = self.view_note.old_note if self.view_note.old_note \ + else self.view_note.note + args = shlex.split(cmd) + try: + self.gui_clear() + pipe = subprocess.Popen(cmd, stdin=subprocess.PIPE, shell=True) + pipe.communicate(note['content'].encode('utf-8')) + pipe.stdin.close() + pipe.wait() + except OSError as e: + self.log('Pipe error: ' + str(e)) + finally: + self.gui_reset() + + def gui_frame_keypress(self, size, key): + # convert space character into name + if key == ' ': + key = 'space' + + lb = self.gui_body_get() + + if key == self.config.get_keybind('quit'): + self.gui_switch_frame_body(None) + + elif key == self.config.get_keybind('help'): + self.gui_switch_frame_body(self.view_help) + + elif key == self.config.get_keybind('sync'): + self.ndb.last_sync = 0 + self.ndb.sync_worker_go() + + elif key == self.config.get_keybind('view_log'): + self.view_log.update_log() + self.gui_switch_frame_body(self.view_log) + + elif key == self.config.get_keybind('down'): + if len(lb.body.positions()) <= 0: + return None + last = len(lb.body.positions()) + if lb.focus_position == (last - 1): + return None + lb.focus_position += 1 + lb.render(size) + + elif key == self.config.get_keybind('up'): + if len(lb.body.positions()) <= 0: + return None + if lb.focus_position == 0: + return None + lb.focus_position -= 1 + lb.render(size) + + elif key == self.config.get_keybind('page_down'): + if len(lb.body.positions()) <= 0: + return None + last = len(lb.body.positions()) + next_focus = lb.focus_position + size[1] + if next_focus >= last: + next_focus = last - 1 + lb.change_focus(size, next_focus, + offset_inset=0, + coming_from='above') + + elif key == self.config.get_keybind('page_up'): + if len(lb.body.positions()) <= 0: + return None + if 'bottom' in lb.ends_visible(size): + last = len(lb.body.positions()) + next_focus = last - size[1] - size[1] + else: + next_focus = lb.focus_position - size[1] + if next_focus < 0: + next_focus = 0 + lb.change_focus(size, next_focus, + offset_inset=0, + coming_from='below') + + elif key == self.config.get_keybind('half_page_down'): + if len(lb.body.positions()) <= 0: + return None + last = len(lb.body.positions()) + next_focus = lb.focus_position + (size[1] // 2) + if next_focus >= last: + next_focus = last - 1 + lb.change_focus(size, next_focus, + offset_inset=0, + coming_from='above') + + elif key == self.config.get_keybind('half_page_up'): + if len(lb.body.positions()) <= 0: + return None + if 'bottom' in lb.ends_visible(size): + last = len(lb.body.positions()) + next_focus = last - size[1] - (size[1] // 2) + else: + next_focus = lb.focus_position - (size[1] // 2) + if next_focus < 0: + next_focus = 0 + lb.change_focus(size, next_focus, + offset_inset=0, + coming_from='below') + + elif key == self.config.get_keybind('bottom'): + if len(lb.body.positions()) <= 0: + return None + lb.change_focus(size, (len(lb.body.positions()) - 1), + offset_inset=0, + coming_from='above') + + elif key == self.config.get_keybind('top'): + if len(lb.body.positions()) <= 0: + return None + lb.change_focus(size, 0, + offset_inset=0, + coming_from='below') + + elif key == self.config.get_keybind('view_next_note'): + if self.gui_body_get().__class__ != view_note.ViewNote: + return key + + if len(self.view_titles.body.positions()) <= 0: + return None + last = len(self.view_titles.body.positions()) + if self.view_titles.focus_position == (last - 1): + return None + self.view_titles.focus_position += 1 + lb.update_note_view( + self.view_titles.note_list[self.view_titles.focus_position].note['localkey']) + self.gui_switch_frame_body(self.view_note) + + elif key == self.config.get_keybind('view_prev_note'): + if self.gui_body_get().__class__ != view_note.ViewNote: + return key + + if len(self.view_titles.body.positions()) <= 0: + return None + if self.view_titles.focus_position == 0: + return None + self.view_titles.focus_position -= 1 + lb.update_note_view( + self.view_titles.note_list[self.view_titles.focus_position].note['localkey']) + self.gui_switch_frame_body(self.view_note) + + elif key == self.config.get_keybind('status'): + if self.status_bar == 'yes': + self.status_bar = 'no' + else: + self.status_bar = self.config.get_config('status_bar') + + elif key == self.config.get_keybind('create_note'): + if self.gui_body_get().__class__ != view_titles.ViewTitles: + return key + + self.gui_clear() + content = self.exec_cmd_on_note(None) + self.gui_reset() + + if content: + self.log('New note created') + self.ndb.create_note(content) + self.gui_update_view() + self.ndb.sync_worker_go() + + elif key == self.config.get_keybind('edit_note') or \ + key == self.config.get_keybind('view_note_ext') or \ + key == self.config.get_keybind('view_note_json'): + if self.gui_body_get().__class__ != view_titles.ViewTitles and \ + self.gui_body_get().__class__ != view_note.ViewNote: + return key + + if self.gui_body_get().__class__ == view_titles.ViewTitles: + if len(lb.body.positions()) <= 0: + return None + note = lb.note_list[lb.focus_position].note + else: # self.gui_body_get().__class__ == view_note.ViewNote: + if key == self.config.get_keybind('edit_note'): + note = lb.note + else: + note = lb.old_note if lb.old_note else lb.note + + self.gui_clear() + if key == self.config.get_keybind('edit_note'): + content = self.exec_cmd_on_note(note) + elif key == self.config.get_keybind('view_note_ext'): + content = self.exec_cmd_on_note(note, cmd=self.get_pager()) + else: # key == self.config.get_keybind('view_note_json') + content = self.exec_cmd_on_note(note, cmd=self.get_pager(), raw=True) + + self.gui_reset() + + if not content: + return None + + md5_old = hashlib.md5(note['content'].encode('utf-8')).digest() + md5_new = hashlib.md5(content.encode('utf-8')).digest() + + if md5_old != md5_new: + self.log('Note updated') + self.ndb.set_note_content(note['localkey'], content) + if self.gui_body_get().__class__ == view_titles.ViewTitles: + lb.update_note_title() + else: # self.gui_body_get().__class__ == view_note.ViewNote: + lb.update_note_view() + self.ndb.sync_worker_go() + else: + self.log('Note unchanged') + + elif key == self.config.get_keybind('view_note'): + if self.gui_body_get().__class__ != view_titles.ViewTitles: + return key + + if len(lb.body.positions()) <= 0: + return None + self.view_note.update_note_view( + lb.note_list[lb.focus_position].note['localkey']) + self.gui_switch_frame_body(self.view_note) + + elif key == self.config.get_keybind('pipe_note'): + if self.gui_body_get().__class__ != view_titles.ViewTitles and \ + self.gui_body_get().__class__ != view_note.ViewNote: + return key + + if self.gui_body_get().__class__ == view_titles.ViewTitles: + if len(lb.body.positions()) <= 0: + return None + note = lb.note_list[lb.focus_position].note + else: # self.gui_body_get().__class__ == view_note.ViewNote: + note = lb.old_note if lb.old_note else lb.note + + self.gui_footer_input_set( + urwid.AttrMap( + user_input.UserInput( + self.config, + key, + '', + self.gui_pipe_input, + None), + 'user_input_bar')) + self.gui_footer_focus_input() + self.master_frame.keypress = self.gui_footer_input_get().keypress + + elif key == self.config.get_keybind('note_delete'): + if self.gui_body_get().__class__ != view_titles.ViewTitles and \ + self.gui_body_get().__class__ != view_note.ViewNote: + return key + + if self.gui_body_get().__class__ == view_titles.ViewTitles: + if len(lb.body.positions()) <= 0: + return None + note = lb.note_list[lb.focus_position].note + else: # self.gui_body_get().__class__ == view_note.ViewNote: + note = lb.note + + self.gui_footer_input_set( + urwid.AttrMap( + user_input.UserInput( + self.config, + 'Delete (y/n): ', + '', + self.gui_yes_no_input, + [ self.delete_note_callback, note['localkey'] ]), + 'user_input_bar')) + self.gui_footer_focus_input() + self.master_frame.keypress = self.gui_footer_input_get().keypress + + elif key == self.config.get_keybind('note_favorite'): + if self.gui_body_get().__class__ != view_titles.ViewTitles and \ + self.gui_body_get().__class__ != view_note.ViewNote: + return key + + if self.gui_body_get().__class__ == view_titles.ViewTitles: + if len(lb.body.positions()) <= 0: + return None + note = lb.note_list[lb.focus_position].note + else: # self.gui_body_get().__class__ == view_note.ViewNote: + note = lb.note + + favorite = not note['favorite'] + + self.ndb.set_note_favorite(note['localkey'], favorite) + + if self.gui_body_get().__class__ == view_titles.ViewTitles: + lb.update_note_title() + + self.ndb.sync_worker_go() + + elif key == self.config.get_keybind('note_category'): + if self.gui_body_get().__class__ != view_titles.ViewTitles and \ + self.gui_body_get().__class__ != view_note.ViewNote: + return key + + if self.gui_body_get().__class__ == view_titles.ViewTitles: + if len(lb.body.positions()) <= 0: + return None + note = lb.note_list[lb.focus_position].note + else: # self.gui_body_get().__class__ == view_note.ViewNote: + note = lb.note + + self.gui_footer_input_set( + urwid.AttrMap( + user_input.UserInput( + self.config, + 'Category: ', + note['category'], + self.gui_category_input, + None), + 'user_input_bar')) + self.gui_footer_focus_input() + self.master_frame.keypress = self.gui_footer_input_get().keypress + + elif key == self.config.get_keybind('search_gstyle') or \ + key == self.config.get_keybind('search_regex') or \ + key == self.config.get_keybind('search_prev_gstyle') or \ + key == self.config.get_keybind('search_prev_regex'): + if self.gui_body_get().__class__ != view_titles.ViewTitles and \ + self.gui_body_get().__class__ != view_note.ViewNote: + return key + + if self.gui_body_get().__class__ == view_note.ViewNote: + if key == self.config.get_keybind('search_prev_gstyle') or \ + key == self.config.get_keybind('search_prev_regex'): + self.view_note.search_direction = 'backward' + else: + self.view_note.search_direction = 'forward' + + options = [ + 'gstyle' if key == self.config.get_keybind('search_gstyle') + or key == self.config.get_keybind('search_prev_gstyle') + else 'regex', + 'backward' if key == self.config.get_keybind('search_prev_gstyle') + or key == self.config.get_keybind('search_prev_regex') + else 'forward' + ] + + caption = '{}{}'.format('(regex) ' if options[0] == 'regex' else '', '/' if options[1] == 'forward' else '?') + + self.gui_footer_input_set( + urwid.AttrMap( + user_input.UserInput( + self.config, + caption, + '', + self.gui_search_input, + options), + 'user_input_bar')) + self.gui_footer_focus_input() + self.master_frame.keypress = self.gui_footer_input_get().keypress + + elif key == self.config.get_keybind('search_next'): + if self.gui_body_get().__class__ != view_note.ViewNote: + return key + + self.view_note.search_note_view_next() + + elif key == self.config.get_keybind('search_prev'): + if self.gui_body_get().__class__ != view_note.ViewNote: + return key + + self.view_note.search_note_view_prev() + + elif key == self.config.get_keybind('clear_search'): + if self.gui_body_get().__class__ != view_titles.ViewTitles: + return key + + self.view_titles.update_note_list(None, sort_mode=self.current_sort_mode) + self.gui_body_set(self.view_titles) + + elif key == self.config.get_keybind('sort_date'): + if self.gui_body_get().__class__ != view_titles.ViewTitles: + return key + + self.current_sort_mode = 'date' + self.view_titles.sort_note_list('date') + + elif key == self.config.get_keybind('sort_alpha'): + if self.gui_body_get().__class__ != view_titles.ViewTitles: + return key + + self.current_sort_mode = 'alpha' + self.view_titles.sort_note_list('alpha') + + elif key == self.config.get_keybind('sort_categories'): + if self.gui_body_get().__class__ != view_titles.ViewTitles: + return key + + self.current_sort_mode = 'categories' + self.view_titles.sort_note_list('categories') + + elif key == self.config.get_keybind('copy_note_text'): + if self.gui_body_get().__class__ != view_note.ViewNote: + return key + + self.view_note.copy_note_text() + + else: + return lb.keypress(size, key) + + self.gui_update_status_bar() + return None + + def gui_init_view(self, loop, view_note): + self.master_frame.keypress = self.gui_frame_keypress + self.gui_body_set(self.view_titles) + + if view_note: + # note that title view set first to prime the view stack + self.gui_switch_frame_body(self.view_note) + + self.thread_sync.start() + + def gui_clear(self): + self.nncli_loop.widget = urwid.Filler(urwid.Text('')) + self.nncli_loop.draw_screen() + + def gui_reset(self): + self.nncli_loop.widget = self.master_frame + self.nncli_loop.draw_screen() + + def gui_stop(self): + # don't exit if there are any notes not yet saved to the disk + + # NOTE: this was originally causing hangs on exit with urllib2 + # should not be a problem now since using the requests library + # ref https://github.com/insanum/sncli/issues/18#issuecomment-105517773 + if self.ndb.verify_all_saved(): + # clear the screen and exit the urwid run loop + self.gui_clear() + raise urwid.ExitMainLoop() + else: + self.log(u'WARNING: Not all notes saved to disk (wait for sync worker)') + + def gui(self, key): + + self.do_gui = True + + self.last_view = [] + self.status_bar = self.config.get_config('status_bar') + + self.log_alarms = 0 + self.log_lock = threading.Lock() + + self.thread_sync = threading.Thread(target=self.ndb.sync_worker, + args=[self.do_server_sync]) + self.thread_sync.setDaemon(True) + + self.view_titles = \ + view_titles.ViewTitles(self.config, + { + 'ndb' : self.ndb, + 'search_string' : None, + 'log' : self.log + }) + self.view_note = \ + view_note.ViewNote(self.config, + { + 'ndb' : self.ndb, + 'id' : key, # initial key to view or None + 'log' : self.log + }) + + self.view_log = view_log.ViewLog(self.config) + self.view_help = view_help.ViewHelp(self.config) + + palette = \ + [ + ('default', + self.config.get_color('default_fg'), + self.config.get_color('default_bg') ), + ('status_bar', + self.config.get_color('status_bar_fg'), + self.config.get_color('status_bar_bg') ), + ('log', + self.config.get_color('log_fg'), + self.config.get_color('log_bg') ), + ('user_input_bar', + self.config.get_color('user_input_bar_fg'), + self.config.get_color('user_input_bar_bg') ), + ('note_focus', + self.config.get_color('note_focus_fg'), + self.config.get_color('note_focus_bg') ), + ('note_title_day', + self.config.get_color('note_title_day_fg'), + self.config.get_color('note_title_day_bg') ), + ('note_title_week', + self.config.get_color('note_title_week_fg'), + self.config.get_color('note_title_week_bg') ), + ('note_title_month', + self.config.get_color('note_title_month_fg'), + self.config.get_color('note_title_month_bg') ), + ('note_title_year', + self.config.get_color('note_title_year_fg'), + self.config.get_color('note_title_year_bg') ), + ('note_title_ancient', + self.config.get_color('note_title_ancient_fg'), + self.config.get_color('note_title_ancient_bg') ), + ('note_date', + self.config.get_color('note_date_fg'), + self.config.get_color('note_date_bg') ), + ('note_flags', + self.config.get_color('note_flags_fg'), + self.config.get_color('note_flags_bg') ), + ('note_category', + self.config.get_color('note_category_fg'), + self.config.get_color('note_category_bg') ), + ('note_content', + self.config.get_color('note_content_fg'), + self.config.get_color('note_content_bg') ), + ('note_content_focus', + self.config.get_color('note_content_focus_fg'), + self.config.get_color('note_content_focus_bg') ), + ('note_content_old', + self.config.get_color('note_content_old_fg'), + self.config.get_color('note_content_old_bg') ), + ('note_content_old_focus', + self.config.get_color('note_content_old_focus_fg'), + self.config.get_color('note_content_old_focus_bg') ), + ('help_focus', + self.config.get_color('help_focus_fg'), + self.config.get_color('help_focus_bg') ), + ('help_header', + self.config.get_color('help_header_fg'), + self.config.get_color('help_header_bg') ), + ('help_config', + self.config.get_color('help_config_fg'), + self.config.get_color('help_config_bg') ), + ('help_value', + self.config.get_color('help_value_fg'), + self.config.get_color('help_value_bg') ), + ('help_descr', + self.config.get_color('help_descr_fg'), + self.config.get_color('help_descr_bg') ) + ] + + self.master_frame = urwid.Frame(body=urwid.Filler(urwid.Text('')), + header=None, + footer=urwid.Pile([ urwid.Pile([]), + urwid.Pile([]) ]), + focus_part='body') + + self.nncli_loop = urwid.MainLoop(self.master_frame, + palette, + handle_mouse=False) + + self.nncli_loop.set_alarm_in(0, self.gui_init_view, + True if key else False) + + self.nncli_loop.run() + + def cli_list_notes(self, regex, search_string): + + note_list, match_regex, all_notes_cnt = \ + self.ndb.filter_notes( + search_string, + search_mode='regex' if regex else 'gstyle', + sort_mode=self.config.get_config('sort_mode')) + for n in note_list: + flags = utils.get_note_flags(n.note) + print((str(n.key) + \ + ' [' + flags + '] ' + \ + utils.get_note_title(n.note))) + + def cli_note_dump(self, key): + + note = self.ndb.get_note(key) + if not note: + self.log('ERROR: Key does not exist') + return + + w = 60 + sep = '+' + '-'*(w+2) + '+' + 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) + category = utils.get_note_category(note) + + print(sep) + print(('| {:<' + str(w) + '} |').format((' Title: ' + title)[:w])) + print(('| {:<' + str(w) + '} |').format((' Key: ' + str(note.get('id', 'Localkey: {}'.format(note.get('localkey'))))[:w]))) + print(('| {:<' + str(w) + '} |').format((' Date: ' + mod_time)[:w])) + print(('| {:<' + str(w) + '} |').format((' Category: ' + category)[:w])) + print(('| {:<' + str(w) + '} |').format((' Flags: [' + flags + ']')[:w])) + print(sep) + print((note['content'])) + + def cli_dump_notes(self, regex, search_string): + + note_list, match_regex, all_notes_cnt = \ + self.ndb.filter_notes( + search_string, + search_mode='regex' if regex else 'gstyle', + sort_mode=self.config.get_config('sort_mode')) + for n in note_list: + self.cli_note_dump(n.key) + + def cli_note_create(self, from_stdin, title): + + if from_stdin: + content = ''.join(sys.stdin) + else: + content = self.exec_cmd_on_note(None) + + if title: + content = title + '\n\n' + content if content else '' + + if content: + self.log('New note created') + self.ndb.create_note(content) + self.sync_notes() + + def cli_note_import(self, from_stdin): + + if from_stdin: + raw = ''.join(sys.stdin) + else: + raw = self.exec_cmd_on_note(None) + + if raw: + try: + note = json.loads(raw) + self.log('New note created') + self.ndb.import_note(note) + self.sync_notes() + except json.decoder.JSONDecodeError as e: + self.log('(IMPORT) Decoding JSON has failed: {}'.format(e)) + sys.exit(1) + except ValueError as e: + self.log('(IMPORT) ValueError: {}'.format(e)) + sys.exit(1) + + def cli_note_export(self, key): + + note = self.ndb.get_note(key) + if not note: + self.log('ERROR: Key does not exist') + return + + print(json.dumps(note, indent=2)) + + def cli_export_notes(self, regex, search_string): + + note_list, match_regex, all_notes_cnt = \ + self.ndb.filter_notes( + search_string, + search_mode='regex' if regex else 'gstyle', + sort_mode=self.config.get_config('sort_mode')) + + notes_data = [n.note for n in note_list] + print(json.dumps(notes_data, indent=2)) + + def cli_note_edit(self, key): + + note = self.ndb.get_note(key) + if not note: + self.log('ERROR: Key does not exist') + return + + content = self.exec_cmd_on_note(note) + if not content: + return + + md5_old = hashlib.md5(note['content'].encode('utf-8')).digest() + md5_new = hashlib.md5(content.encode('utf-8')).digest() + + if md5_old != md5_new: + self.log('Note updated') + self.ndb.set_note_content(note['localkey'], content) + self.sync_notes() + else: + self.log('Note unchanged') + + def cli_note_delete(self, key, delete): + + note = self.ndb.get_note(key) + if not note: + self.log('ERROR: Key does not exist') + return + + self.ndb.set_note_deleted(key, delete) + self.sync_notes() + + def cli_note_favorite(self, key, favorite): + + note = self.ndb.get_note(key) + if not note: + self.log('ERROR: Key does not exist') + return + + self.ndb.set_note_favorite(key, favorite) + self.sync_notes() + + def cli_note_category_get(self, key): + + note = self.ndb.get_note(key) + if not note: + self.log('ERROR: Key does not exist') + return + + category = utils.get_note_category(note) + return category + + def cli_note_category_set(self, key, category): + + note = self.ndb.get_note(key) + if not note: + self.log('Error: Key does not exist') + return + + self.ndb.set_note_category(key, category.lower()) + self.sync_notes() + + def cli_note_category_rm(self, key): + + note = self.ndb.get_note(key) + if not note: + self.log('Error: Key does not exist') + return + + old_category = self.cli_note_category_get(key) + if old_category: + self.cli_note_category_set(key, '') + +def SIGINT_handler(signum, frame): + print('\nSignal caught, bye!') + sys.exit(1) + +signal.signal(signal.SIGINT, SIGINT_handler) + +def usage(): + print (''' +Usage: + nncli [OPTIONS] [COMMAND] [COMMAND_ARGS] + + OPTIONS: + -h, --help - usage help + -v, --verbose - verbose output + -n, --nosync - don't perform a server sync + -r, --regex - search string is a regular expression + -k <key>, --key=<key> - note key + -t <title>, --title=<title> - title of note for create (cli mode) + -c <file>, --config=<file> - config file to read from + -V, --version - version information + + COMMANDS: + <none> - console gui mode when no command specified + sync - perform a full sync with the server + list [search_string] - list notes (refined with search string) + export [search_string] - export notes in JSON (refined with search + string) + dump [search_string] - dump notes (refined with search string) + create [-] - create a note ('-' content from stdin) + import [-] - import a note in JSON format ('-' JSON from + stdin) + export - export a note in JSON format (specified by + <key>) + dump - dump a note (specified by <key>) + edit - edit a note (specified by <key>) + delete - delete a note (specified by <key>) + < favorite | unfavorite > - favorite/unfavorite a note (specified by <key>) + cat get - retrieve the category from a note (specified + by <key>) + cat set <category> - set the category for a note (specified by <key>) + cat rm - remove category from a note (specified by <key>) +''') + sys.exit(0) + +def version(): + version_info = 'nncli {}'.format(nncli.__version__) + print(version_info) + exit(0) + +def main(argv=sys.argv[1:]): + verbose = False + sync = True + regex = False + key = None + title = None + config = None + + try: + opts, args = getopt.getopt(argv, + 'hvnrk:t:c:V', + [ 'help', 'verbose', 'nosync', 'regex', 'key=', 'title=', \ + 'config=', 'version' ]) + except: + usage() + + for opt, arg in opts: + if opt in [ '-h', '--help']: + usage() + elif opt in ['-V', '--version' ]: + version() + elif opt in [ '-v', '--verbose']: + verbose = True + elif opt in [ '-n', '--nosync']: + sync = False + elif opt in [ '-r', '--regex']: + regex = True + elif opt in [ '-k', '--key']: + try: + key = int(arg) + except: + print('ERROR: Key specified with -k must be an integer') + elif opt in [ '-t', '--title']: + title = arg + elif opt in [ '-c', '--config']: + config = arg + else: + print('ERROR: Unhandled option') + usage() + + if not args: + nncli(sync, verbose, config).gui(key) + return + + def nncli_start(sync=sync, verbose=verbose, config=config): + sn = nncli(sync, verbose, config) + if sync: sn.sync_notes() + return sn + + if args[0] == 'sync': + sn = nncli_start(True) + + elif args[0] == 'list': + + sn = nncli_start() + sn.cli_list_notes(regex, ' '.join(args[1:])) + + elif args[0] == 'dump': + + sn = nncli_start() + if key: + sn.cli_note_dump(key) + else: + sn.cli_dump_notes(regex, ' '.join(args[1:])) + + elif args[0] == 'create': + + if len(args) == 1: + sn = nncli_start() + sn.cli_note_create(False, title) + elif len(args) == 2 and args[1] == '-': + sn = nncli_start() + sn.cli_note_create(True, title) + else: + usage() + + elif args[0] == 'import': + + if len(args) == 1: + sn = nncli_start() + sn.cli_note_import(False) + elif len(args) == 2 and args[1] == '-': + sn = nncli_start() + sn.cli_note_import(True) + else: + usage() + + elif args[0] == 'export': + + sn = nncli_start() + if key: + sn.cli_note_export(key) + else: + sn.cli_export_notes(regex, ' '.join(args[1:])) + + elif args[0] == 'edit': + + if not key: + usage() + + sn = nncli_start() + sn.cli_note_edit(key) + + elif args[0] == 'delete': + + if not key: + usage() + + sn = nncli_start() + sn.cli_note_delete(key, True) + + elif args[0] == 'favorite' or args[0] == 'unfavorite': + + if not key: + usage() + + sn = nncli_start() + sn.cli_note_favorite(key, 1 if args[0] == 'favorite' else 0) + + # Category API + elif args[0] == 'cat': + + if not key: + usage() + + nargs = len(args) + correct_other = (args[1] in ['get', 'rm'] and nargs == 2) + correct_set = (args[1] == 'set' and nargs == 3) + if not (correct_set or correct_other): + usage() + + if args[1] == 'get': + + sn = nncli_start() + category = sn.cli_note_category_get(key) + if category: + print(category) + + elif args[1] == 'set': + + category = args[2] + sn = nncli_start() + sn.cli_note_category_set(key, category) + + elif args[1] == 'rm': + + sn = nncli_start() + sn.cli_note_category_rm(key) + + else: + usage() diff --git a/nncli/notes_db.py b/nncli/notes_db.py @@ -0,0 +1,648 @@ +# -*- encoding: utf-8 -*- + +import os, time, re, glob, json, copy, threading +from . import utils +from . import nextcloud_note +nextcloud_note.NOTE_FETCH_LENGTH=100 +from .nextcloud_note import NextcloudNote +import logging + +class ReadError(RuntimeError): + pass + +class WriteError(RuntimeError): + pass + +class NotesDB(): + """NotesDB will take care of the local notes database and syncing with SN. + """ + def __init__(self, config, log, update_view): + self.config = config + self.log = log + self.update_view = update_view + + self.last_sync = 0 # set to zero to trigger a full sync + self.sync_lock = threading.Lock() + self.go_cond = threading.Condition() + + # create db dir if it does not exist + if not os.path.exists(self.config.get_config('db_path')): + os.mkdir(self.config.get_config('db_path')) + + now = int(time.time()) + # now read all .json files from disk + fnlist = glob.glob(self.helper_key_to_fname('*')) + + self.notes = {} + + for fn in fnlist: + try: + n = json.load(open(fn, 'r')) + except IOError as e: + raise ReadError ('Error opening {0}: {1}'.format(fn, str(e))) + 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['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: 'id' is used only for syncing with server - 'localkey' + # is used for everything else in nncli + n['localkey'] = localkey + + # add the note to our database + self.notes[localkey] = n + + # initialise the NextCloud instance we're going to use + # this does not yet need network access + self.note = NextcloudNote(self.config.get_config('nn_username'), + self.config.get_config('nn_password'), + self.config.get_config('nn_host')) + + # we'll use this to store which notes are currently being synced by + # the background thread, so we don't add them anew if they're still + # in progress. This variable is only used by the background thread. + self.threaded_syncing_keys = {} + + def filtered_notes_sort(self, filtered_notes, sort_mode='date'): + if sort_mode == 'date': + if self.config.get_config('favorite_ontop') == 'yes': + filtered_notes.sort(key=utils.sort_by_modify_date_favorite, reverse=True) + else: + filtered_notes.sort(key=lambda o: -float(o.note.get('modified', 0))) + elif sort_mode == 'alpha': + if self.config.get_config('favorite_ontop') == 'yes': + filtered_notes.sort(key=utils.sort_by_title_favorite) + else: + filtered_notes.sort(key=lambda o: utils.get_note_title(o.note)) + elif sort_mode == 'categories': + favorite = self.config.get_config('favorite_ontop') + utils.sort_notes_by_categories(filtered_notes, \ + favorite_ontop=favorite) + + def filter_notes(self, search_string=None, search_mode='gstyle', sort_mode='date'): + """Return list of notes filtered with search string. + + Based on the search mode that has been selected in self.config, + this method will call the appropriate helper method to do the + actual work of filtering the notes. + + Returns a list of filtered notes with selected search mode and sorted + according to configuration. Two more elements in tuple: a regular + expression that can be used for highlighting strings in the text widget + and the total number of notes in memory. + """ + + if search_mode == 'gstyle': + filtered_notes, match_regexp, active_notes = \ + self.filter_notes_gstyle(search_string) + else: + filtered_notes, match_regexp, active_notes = \ + self.filter_notes_regex(search_string) + + self.filtered_notes_sort(filtered_notes, sort_mode) + + return filtered_notes, match_regexp, active_notes + + def _helper_gstyle_categorymatch(self, cat_pats, note): + # Returns: + # 2 = match - no category patterns specified + # 1 = match - all category patterns match a category on this + # note + # 0 = no match - note has no category or not all category patterns match + + if not cat_pats: + # match because no category patterns were specified + return 2 + + note_category = note.get('category') + + if not note_category: + # category patterns specified but note has no categories, so no match + return 0 + + # for each cat_pat, we have to find a matching category + # .lower() used for case-insensitive search + cat_pats_matched = 0 + for tp in cat_pats: + tp = tp.lower() + for t in note_category: + if tp in t.lower(): + cat_pats_matched += 1 + break + + if cat_pats_matched == len(cat_pats): + # all category patterns specified matched a category on this note + return 1 + + # note doesn't match + return 0 + + def _helper_gstyle_wordmatch(self, word_pats, content): + if not word_pats: + return True + + word_pats_matched = 0 + lowercase_content = content.lower() # case insensitive search + for wp in word_pats: + wp = wp.lower() # case insensitive search + if wp in lowercase_content: + word_pats_matched += 1 + + if word_pats_matched == len(word_pats): + return True; + + return False + + def filter_notes_gstyle(self, search_string=None): + + filtered_notes = [] + active_notes = 0 + + if not search_string: + for k in self.notes: + n = self.notes[k] + active_notes += 1 + filtered_notes.append(utils.KeyValueObject(key=k, note=n, catfound=0)) + + return filtered_notes, [], active_notes + + # group0: category:([^\s]+) + # group1: multiple words in quotes + # group2: single words + + # example result for: 'category:category1 category:category2 word1 "word2 word3" category:category3' + # [ ('category1', '', ''), + # ('category2', '', ''), + # ('', '', 'word1'), + # ('', 'word2 word3', ''), + # ('category3', '', '') ] + + groups = re.findall('category:([^\s]+)|"([^"]+)"|([^\s]+)', search_string) + all_pats = [[] for _ in range(3)] + + # we end up with [[cat_pats],[multi_word_pats],[single_word_pats]] + for g in groups: + for i in range(3): + if g[i]: all_pats[i].append(g[i]) + + for k in self.notes: + n = self.notes[k] + + active_notes += 1 + + catmatch = self._helper_gstyle_categorymatch(all_pats[0], n) + + word_pats = all_pats[1] + all_pats[2] + + if catmatch and \ + self._helper_gstyle_wordmatch(word_pats, n.get('content')): + # we have a note that can go through! + filtered_notes.append( + utils.KeyValueObject(key=k, + note=n, + catfound=1 if catmatch == 1 else 0)) + + return filtered_notes, '|'.join(all_pats[1] + all_pats[2]), active_notes + + def filter_notes_regex(self, search_string=None): + """ + Return a list of notes filtered using the regex search_string. + Each element in the list is a tuple (local_key, note). + """ + sspat = utils.build_regex_search(search_string) + + filtered_notes = [] + active_notes = 0 # total number of notes, including deleted ones + + for k in self.notes: + n = self.notes[k] + + active_notes += 1 + + if not sspat: + filtered_notes.append(utils.KeyValueObject(key=k, note=n, catfound=0)) + continue + + if self.config.get_config('search_categories') == 'yes': + cat_matched = False + for t in n.get('category'): + if sspat.search(t): + cat_matched = True + filtered_notes.append(utils.KeyValueObject(key=k, note=n, catfound=1)) + break + if cat_matched: + continue + + if sspat.search(n.get('content')): + filtered_notes.append(utils.KeyValueObject(key=k, note=n, catfound=0)) + + match_regexp = search_string if sspat else '' + return filtered_notes, match_regexp, active_notes + + 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['id'] if note.get('id') else utils.generate_random_key() + while new_key in self.notes: + new_key = utils.generate_random_key() + + timestamp = int(time.time()) + + try: + modified = float(note.get('modified', 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', ''), + 'modified' : modified, + 'title' : note.get('title'), + 'category' : note.get('category') \ + if note.get('category') is not None \ + else '', + 'savedate' : 0, # never been written to disc + 'syncdate' : 0, # never been synced with server + 'favorite' : False, + 'deleted' : False + } + + # sanity check all note values + if not isinstance(new_note['content'], str): + raise ValueError('"content" must be a string') + + if not 0 <= new_note['modified'] <= timestamp: + raise ValueError('"modified" field must be real') + + if not isinstance(new_note['category'], str) or \ + new_note['category'] is None: + raise ValueError('"category" must be an string') + + if not isinstance(new_note['favorite'], bool): + raise ValueError('"favorite" must be a boolean') + + self.notes[new_key] = new_note + + return new_key + + def create_note(self, content): + # need to get a key unique to this database. not really important + # what it is, as long as it's unique. + new_key = utils.generate_random_key() + while new_key in self.notes: + new_key = utils.generate_random_key() + + timestamp = int(time.time()) + title = content.split('\n')[0] + + # note has no internal key yet. + new_note = { + 'localkey' : new_key, + 'content' : content, + 'modified' : timestamp, + 'category' : '', + 'savedate' : 0, # never been written to disc + 'syncdate' : 0, # never been synced with server + 'favorite' : False, + 'deleted' : False, + 'title' : title + } + + self.notes[new_key] = new_note + + return new_key + + def get_note(self, key): + return self.notes[key] + + def get_note_favorite(self, key): + return self.notes[key].get('favorite') + + def get_note_category(self, key): + return self.notes[key].get('category') + + def get_note_content(self, key): + return self.notes[key].get('content') + + def flag_what_changed(self, note, what_changed): + if 'what_changed' not in note: + note['what_changed'] = [] + if what_changed not in note['what_changed']: + note['what_changed'].append(what_changed) + + def set_note_deleted(self, key, deleted): + n = self.notes[key] + old_deleted = n['deleted'] if 'deleted' in n else 0 + if old_deleted != deleted: + n['deleted'] = deleted + n['modified'] = int(time.time()) + self.flag_what_changed(n, 'deleted') + self.log('Note marked for deletion (key={0})'.format(key)) + + def set_note_content(self, key, content): + n = self.notes[key] + old_content = n.get('content') + if content != old_content: + n['content'] = content + n['modified'] = int(time.time()) + self.flag_what_changed(n, 'content') + self.log('Note content updated (key={0})'.format(key)) + + def set_note_category(self, key, category): + n = self.notes[key] + old_category = n.get('category') + if category != old_category: + n['category'] = category + n['modified'] = int(time.time()) + self.flag_what_changed(n, 'category') + self.log('Note category updated (key={0})'.format(key)) + + def set_note_favorite(self, key, favorite): + n = self.notes[key] + old_favorite = utils.note_favorite(n) + if favorite != old_favorite: + n['favorite'] = favorite + n['modified'] = int(time.time()) + self.flag_what_changed(n, 'favorite') + self.log('Note {0} (key={1})'. \ + format('favorite' if favorite else \ + 'unfavorited', key)) + + def helper_key_to_fname(self, k): + 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. + fn = self.helper_key_to_fname(k) + json.dump(note, open(fn, 'w'), indent=2) + + # record that we saved this to disc. + note['savedate'] = int(time.time()) + + def sync_notes(self, server_sync=True, full_sync=True): + """Perform a full bi-directional sync with server. + + Psuedo-code algorithm for syncing: + + 1. for any note changed locally, including new notes: + save note to server, update note with response + (new title, modified, title, category, content, + favorite) + + 2. get all notes + + 3. for each remote note + if remote modified > local modified || + a new note and key is not in local store + retrieve note, update note with response + + 4. for each local note not in the index + PERMANENT DELETE, remove note from local store + """ + + local_updates = {} + local_deletes = {} + server_keys = {} + now = int(time.time()) + + sync_start_time = int(time.time()) + sync_errors = 0 + skip_remote_syncing = False + + if server_sync and full_sync: + self.log("Starting full sync") + + # 1. for any note changed locally, including new notes: + # save note to server, update note with response + for note_index, local_key in enumerate(self.notes.keys()): + n = self.notes[local_key] + + if not n.get('id') or \ + float(n.get('modified')) > float(n.get('syncdate')): + + savedate = float(n.get('savedate')) + 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 + local_updates[local_key] = True + + if not server_sync: + # the 'what_changed' field will be written to disk and + # picked up whenever the next full server sync occurs + continue + + # only send required fields + cn = copy.deepcopy(n) + if 'what_changed' in n: + del n['what_changed'] + + if 'localkey' in cn: + del cn['localkey'] + + if 'minversion' in cn: + del cn['minversion'] + del cn['syncdate'] + del cn['savedate'] + del cn['deleted'] + if 'etag' in cn: + del cn['etag'] + if 'title' in cn: + del cn['title'] + + if 'what_changed' in cn: + if 'content' not in cn['what_changed'] \ + and 'category' not in cn['what_changed']: + del cn['content'] + if 'category' not in cn['what_changed']: + del cn['category'] + if 'favorite' not in cn['what_changed']: + del cn['favorite'] + del cn['what_changed'] + + if 'favorite' in cn: + cn['favorite'] = str.lower(str(cn['favorite'])) + + if n['deleted']: + uret = self.note.delete_note(cn) + else: + uret = self.note.update_note(cn) + + if uret[1] == 0: # success + # if this is a new note our local key is not valid anymore + # 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('id') + t = uret[0].get('title') + c = uret[0].get('category') + c = c if c is not None else '' + n.update(uret[0]) + n['syncdate'] = now + n['localkey'] = k + n['category'] = c + self.notes[k] = n + + local_updates[k] = True + if local_key != k: + # if local_key was a different key it should be deleted + local_deletes[local_key] = True + if local_key in local_updates: + del local_updates[local_key] + + self.log('Synced note to server (key={0})'.format(local_key)) + else: + self.log('ERROR: Failed to sync note to server (key={0})'.format(local_key)) + sync_errors += 1 + + # 2. get the note index + if not server_sync: + nl = [] + else: + nl = self.note.get_note_list() + + if nl[1] == 0: # success + nl = nl[0] + else: + self.log('ERROR: Failed to get note list from server') + sync_errors += 1 + nl = [] + skip_remote_syncing = True + + # 3. for each remote note + # if remote modified > local modified || + # a new note and key is not in local store + # retrieve note, update note with response + if not skip_remote_syncing: + len_nl = len(nl) + for note_index, n in enumerate(nl): + k = n.get('id') + c = n.get('category') if n.get('category') is not None \ + else '' + 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 + if k in self.notes: + # we already have this note + # if the server note has a newer syncnum we need to get it + if int(n.get('modified')) > int(self.notes[k].get('modified')): + gret = self.note.get_note(k) + if gret[1] == 0: + self.notes[k].update(gret[0]) + local_updates[k] = True + self.notes[k]['syncdate'] = now + self.notes[k]['localkey'] = k + self.notes[k]['category'] = c + self.notes[k]['deleted'] = False + + self.log('Synced newer note from server (key={0})'.format(k)) + else: + self.log('ERROR: Failed to sync newer note from server (key={0})'.format(k)) + sync_errors += 1 + else: + # this is a new note + gret = self.note.get_note(k) + if gret[1] == 0: + self.notes[k] = gret[0] + local_updates[k] = True + self.notes[k]['syncdate'] = now + self.notes[k]['localkey'] = k + self.notes[k]['category'] = c + self.notes[k]['deleted'] = False + + self.log('Synced new note from server (key={0})'.format(k)) + else: + self.log('ERROR: Failed syncing new note from server (key={0})'.format(k)) + sync_errors += 1 + + # 4. for each local note not in the index + # PERMANENT DELETE, remove note from local store + # Only do this when a full sync (i.e. entire index) is performed! + if server_sync and full_sync and not skip_remote_syncing: + for local_key in list(self.notes.keys()): + if local_key not in server_keys: + del self.notes[local_key] + local_deletes[local_key] = True + + # sync done, now write changes to db_path + + for k in list(local_updates.keys()): + try: + self.helper_save_note(k, self.notes[k]) + except WriteError as e: + raise WriteError (str(e)) + self.log("Saved note to disk (key={0})".format(k)) + + for k in list(local_deletes.keys()): + fn = self.helper_key_to_fname(k) + if os.path.exists(fn): + os.unlink(fn) + self.log("Deleted note from disk (key={0})".format(k)) + + if not sync_errors: + self.last_sync = sync_start_time + + # if there were any changes then update the current view + if len(local_updates) > 0 or len(local_deletes) > 0: + self.update_view() + + if server_sync and full_sync: + self.log("Full sync completed") + + return sync_errors + + def get_note_status(self, key): + n = self.notes[key] + o = utils.KeyValueObject(saved=False, synced=False, modified=False) + modified = float(n['modified']) + savedate = float(n['savedate']) + syncdate = float(n['syncdate']) + + if savedate > modified: + o.saved = True + else: + o.modified = True + + if syncdate > modified: + o.synced = True + + return o + + def verify_all_saved(self): + all_saved = True + self.sync_lock.acquire() + for k in list(self.notes.keys()): + o = self.get_note_status(k) + if not o.saved: + all_saved = False + break + self.sync_lock.release() + return all_saved + + def sync_now(self, do_server_sync=True): + self.sync_lock.acquire() + self.sync_notes(server_sync=do_server_sync, + full_sync=True if not self.last_sync else False) + self.sync_lock.release() + + # sync worker thread... + def sync_worker(self, do_server_sync): + time.sleep(1) # give some time to wait for GUI initialization + self.log('Sync worker: started') + self.sync_now(do_server_sync) + while True: + self.go_cond.acquire() + self.go_cond.wait(15) + self.sync_now(do_server_sync) + self.go_cond.release() + + def sync_worker_go(self): + self.go_cond.acquire() + self.go_cond.notify() + self.go_cond.release() diff --git a/nnotes_cli/temp.py b/nncli/temp.py diff --git a/nncli/user_input.py b/nncli/user_input.py @@ -0,0 +1,23 @@ +# -*- encoding: utf-8 -*- + +import urwid + +class UserInput(urwid.Edit): + + def __init__(self, config, caption, edit_text, callback_func, args): + self.config = config + self.callback_func = callback_func + self.callback_func_args = args + super(UserInput, self).__init__(caption=caption, + edit_text=edit_text, + wrap='clip') + + def keypress(self, size, key): + size = (size[0],) # if this isn't here then urwid freaks out... + if key == 'esc': + self.callback_func(self.callback_func_args, None) + elif key == 'enter': + self.callback_func(self.callback_func_args, self.edit_text) + else: + return super(UserInput, self).keypress(size, key) + return None diff --git a/nnotes_cli/utils.py b/nncli/utils.py diff --git a/nncli/view_help.py b/nncli/view_help.py @@ -0,0 +1,125 @@ +# -*- encoding: utf-8 -*- + +import re, urwid + +class ViewHelp(urwid.ListBox): + + def __init__(self, config): + self.config = config + + self.descr_width = 26 + self.config_width = 29 + + lines = [] + lines.extend(self.create_kb_help_lines('Keybinds Common', 'common')) + lines.extend(self.create_kb_help_lines('Keybinds Note List', 'titles')) + lines.extend(self.create_kb_help_lines('Keybinds Note Content', 'notes')) + lines.extend(self.create_config_help_lines()) + lines.extend(self.create_color_help_lines()) + lines.append(urwid.Text(('help_header', ''))) + + super(ViewHelp, self).__init__(urwid.SimpleFocusListWalker(lines)) + + def get_status_bar(self): + cur = -1 + total = 0 + if len(self.body.positions()) > 0: + cur = self.focus_position + total = len(self.body.positions()) + + status_title = \ + urwid.AttrMap(urwid.Text('Help', + wrap='clip'), + 'status_bar') + status_index = \ + ('pack', urwid.AttrMap(urwid.Text(' ' + + str(cur + 1) + + '/' + + str(total)), + 'status_bar')) + return \ + urwid.AttrMap(urwid.Columns([ status_title, status_index ]), + 'status_bar') + + def create_kb_help_lines(self, header, use): + lines = [ urwid.AttrMap(urwid.Text(''), + 'help_header', + 'help_focus') ] + lines.append(urwid.AttrMap(urwid.Text(' ' + header), + 'help_header', + 'help_focus')) + for c in self.config.keybinds: + if use not in self.config.get_keybind_use(c): + continue + lines.append( + urwid.AttrMap(urwid.AttrMap( + urwid.Text( + [ + ('help_descr', ('{:>' + str(self.descr_width) + '} ').format(self.config.get_keybind_descr(c))), + ('help_config', ('{:>' + str(self.config_width) + '} ').format('kb_' + c)), + ('help_value', "'" + self.config.get_keybind(c) + "'") + ] + ), + attr_map = None, + focus_map = { + 'help_value' : 'help_focus', + 'help_config' : 'help_focus', + 'help_descr' : 'help_focus' + } + ), 'default', 'help_focus')) + return lines + + def create_config_help_lines(self): + lines = [ urwid.AttrMap(urwid.Text(''), + 'help_header', + 'help_focus') ] + lines.append(urwid.AttrMap(urwid.Text(' Configuration'), + 'help_header', + 'help_focus')) + for c in self.config.configs: + if c in [ 'sn_username', 'sn_password' ]: continue + lines.append( + urwid.AttrMap(urwid.AttrMap( + urwid.Text( + [ + ('help_descr', ('{:>' + str(self.descr_width) + '} ').format(self.config.get_config_descr(c))), + ('help_config', ('{:>' + str(self.config_width) + '} ').format('cfg_' + c)), + ('help_value', "'" + self.config.get_config(c) + "'") + ] + ), + attr_map = None, + focus_map = { + 'help_value' : 'help_focus', + 'help_config' : 'help_focus', + 'help_descr' : 'help_focus' + } + ), 'default', 'help_focus')) + return lines + + def create_color_help_lines(self): + lines = [ urwid.AttrMap(urwid.Text(''), + 'help_header', + 'help_focus') ] + lines.append(urwid.AttrMap(urwid.Text(' Colors'), + 'help_header', + 'help_focus')) + fmap = {} + for c in self.config.colors: + fmap[re.search('^(.*)(_fg|_bg)$', c).group(1)] = 'help_focus' + for c in self.config.colors: + lines.append( + urwid.AttrMap(urwid.AttrMap( + urwid.Text( + [ + ('help_descr', ('{:>' + str(self.descr_width) + '} ').format(self.config.get_color_descr(c))), + ('help_config', ('{:>' + str(self.config_width) + '} ').format('clr_' + c)), + (re.search('^(.*)(_fg|_bg)$', c).group(1), "'" + self.config.get_color(c) + "'") + ] + ), + attr_map = None, + focus_map = fmap + ), 'default', 'help_focus')) + return lines + + def keypress(self, size, key): + return key diff --git a/nncli/view_log.py b/nncli/view_log.py @@ -0,0 +1,47 @@ +# -*- encoding: utf-8 -*- + +import urwid + +class ViewLog(urwid.ListBox): + + def __init__(self, config): + self.config = config + super(ViewLog, self).__init__(urwid.SimpleFocusListWalker([])) + + def update_log(self): + lines = [] + f = open(self.config.logfile) + for line in f: + lines.append( + urwid.AttrMap(urwid.Text(line.rstrip()), + 'note_content', + 'note_content_focus')) + f.close() + if self.config.get_config('log_reversed') == 'yes': + lines.reverse() + self.body[:] = urwid.SimpleFocusListWalker(lines) + self.focus_position = 0 + + def get_status_bar(self): + cur = -1 + total = 0 + if len(self.body.positions()) > 0: + cur = self.focus_position + total = len(self.body.positions()) + + status_title = \ + urwid.AttrMap(urwid.Text('Sync Log', + wrap='clip'), + 'status_bar') + status_index = \ + ('pack', urwid.AttrMap(urwid.Text(' ' + + str(cur + 1) + + '/' + + str(total)), + 'status_bar')) + return \ + urwid.AttrMap(urwid.Columns([ status_title, status_index ]), + 'status_bar') + + def keypress(self, size, key): + return key diff --git a/nnotes_cli/view_note.py b/nncli/view_note.py diff --git a/nncli/view_titles.py b/nncli/view_titles.py @@ -0,0 +1,189 @@ +# encoding: utf-8 -*- + +import re, time, datetime, urwid, subprocess +from . import utils, view_note + +class ViewTitles(urwid.ListBox): + + def __init__(self, config, args): + self.config = config + self.ndb = args['ndb'] + self.search_string = args['search_string'] + self.log = args['log'] + self.note_list, self.match_regex, self.all_notes_cnt = \ + self.ndb.filter_notes(self.search_string, sort_mode=self.config.get_config('sort_mode')) + super(ViewTitles, self).__init__( + urwid.SimpleFocusListWalker(self.get_note_titles())) + + def update_note_list(self, search_string, search_mode='gstyle', sort_mode='date'): + self.search_string = search_string + self.note_list, self.match_regex, self.all_notes_cnt = \ + self.ndb.filter_notes(self.search_string, search_mode, sort_mode=sort_mode) + self.body[:] = \ + urwid.SimpleFocusListWalker(self.get_note_titles()) + if len(self.note_list) == 0: + self.log('No notes found!') + else: + self.focus_position = 0 + + def sort_note_list(self, sort_mode): + self.ndb.filtered_notes_sort(self.note_list, sort_mode) + self.body[:] = \ + urwid.SimpleFocusListWalker(self.get_note_titles()) + + def format_title(self, note): + """ + Various formatting tags are supported for dynamically building + the title string. Each of these formatting tags supports a width + specifier (decimal) and a left justification (-) like that + supported by printf. + + %F -- flags + %T -- category + %D -- date + %N -- note title + """ + + 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) + category = utils.get_note_category(note) + + # get the age of the note + dt = datetime.datetime.fromtimestamp(time.mktime(t)) + if dt > datetime.datetime.now() - datetime.timedelta(days=1): + note_age = 'd' # less than a day old + elif dt > datetime.datetime.now() - datetime.timedelta(weeks=1): + note_age = 'w' # less than a week old + elif dt > datetime.datetime.now() - datetime.timedelta(weeks=4): + note_age = 'm' # less than a month old + elif dt > datetime.datetime.now() - datetime.timedelta(weeks=52): + note_age = 'y' # less than a year old + else: + note_age = 'a' # ancient + + def recursive_format(title_format): + if not title_format: + return None + fmt = re.search("^(.*)%([-]*)([0-9]*)([FDTN])(.*)$", title_format) + if not fmt: + m = ('pack', urwid.AttrMap(urwid.Text(title_format), + 'default')) + l_fmt = None + r_fmt = None + else: + l = fmt.group(1) if fmt.group(1) else None + m = None + r = fmt.group(5) if fmt.group(5) else None + align = 'left' if fmt.group(2) == '-' else 'right' + width = int(fmt.group(3)) if fmt.group(3) else 'pack' + if fmt.group(4) == 'F': + m = (width, urwid.AttrMap(urwid.Text(flags, + align=align, + wrap='clip'), + 'note_flags')) + elif fmt.group(4) == 'D': + m = (width, urwid.AttrMap(urwid.Text(mod_time, + align=align, + wrap='clip'), + 'note_date')) + elif fmt.group(4) == 'T': + m = (width, urwid.AttrMap(urwid.Text(category, + align=align, + wrap='clip'), + 'note_category')) + elif fmt.group(4) == 'N': + if note_age == 'd': attr = 'note_title_day' + elif note_age == 'w': attr = 'note_title_week' + elif note_age == 'm': attr = 'note_title_month' + elif note_age == 'y': attr = 'note_title_year' + elif note_age == 'a': attr = 'note_title_ancient' + if width != 'pack': + m = (width, urwid.AttrMap(urwid.Text(title, + align=align, + wrap='clip'), + attr)) + else: + m = urwid.AttrMap(urwid.Text(title, + align=align, + wrap='clip'), + attr) + l_fmt = recursive_format(l) + r_fmt = recursive_format(r) + + tmp = [] + if l_fmt: tmp.extend(l_fmt) + tmp.append(m) + if r_fmt: tmp.extend(r_fmt) + return tmp + + # convert the format string into the actual note title line + title_line = recursive_format(self.config.get_config('format_note_title')) + return urwid.Columns(title_line) + + def get_note_title(self, note): + return urwid.AttrMap(self.format_title(note), + 'default', + { 'default' : 'note_focus', + 'note_title_day' : 'note_focus', + 'note_title_week' : 'note_focus', + 'note_title_month' : 'note_focus', + 'note_title_year' : 'note_focus', + 'note_title_ancient' : 'note_focus', + 'note_date' : 'note_focus', + 'note_flags' : 'note_focus', + 'note_categories' : 'note_focus' }) + + def get_note_titles(self): + lines = [] + for n in self.note_list: + lines.append(self.get_note_title(n.note)) + return lines + + def get_status_bar(self): + cur = -1 + total = 0 + if len(self.body.positions()) > 0: + cur = self.focus_position + total = len(self.body.positions()) + + hdr = 'NextCloud Notes' + + # include connection status in header + hdr += ' (' + self.ndb.note.status + ')' + + if self.search_string != None: + hdr += ' - Search: ' + self.search_string + + status_title = \ + urwid.AttrMap(urwid.Text(hdr, + wrap='clip'), + 'status_bar') + status_index = \ + ('pack', urwid.AttrMap(urwid.Text(' ' + + str(cur + 1) + + '/' + + str(total)), + 'status_bar')) + return \ + urwid.AttrMap(urwid.Columns([ status_title, status_index ]), + 'status_bar') + + def update_note_title(self, key=None): + if not key: + self.body[self.focus_position] = \ + self.get_note_title(self.note_list[self.focus_position].note) + else: + for i in range(len(self.note_list)): + if self.note_list[i].note['localkey'] == key: + self.body[i] = self.get_note_title(self.note_list[i].note) + + def focus_note(self, key): + for i in range(len(self.note_list)): + if 'localkey' in self.note_list[i].note and \ + self.note_list[i].note['localkey'] == key: + self.focus_position = i + + def keypress(self, size, key): + return key diff --git a/nnotes_cli/__init__.py b/nnotes_cli/__init__.py @@ -1,19 +0,0 @@ -# -*- encoding: utf-8 -*- - -try: - from . import version - __version__ = version.version -except ImportError: - try: - from setuptools_scm import get_version - __version__ = get_version(root='..', relative_to=__file__) - except: - __version__ = '??-dev' - -__productname__ = 'nncli' -__copyright__ = "Copyright (c) 2018 Daniel Moch" -__author__ = "Daniel Moch" -__author_email__ = "daniel@danielmoch.com" -__description__ = "NextCloud Notes Command Line Interface" -__url__ = "https://github.com/djmoch/nncli" -__license__ = "MIT" diff --git a/nnotes_cli/config.py b/nnotes_cli/config.py @@ -1,274 +0,0 @@ -# -*- encoding: utf-8 -*- - -import os, sys, urwid, collections, configparser, subprocess - -from appdirs import user_cache_dir, user_config_dir - -class Config: - - def __init__(self, custom_file=None): - self.config_home = user_config_dir('nncli', 'djmoch') - self.cache_home = user_cache_dir('nncli', 'djmoch') - - defaults = \ - { - 'cfg_nn_username' : '', - 'cfg_nn_password' : '', - 'cfg_nn_password_eval' : '', - 'cfg_db_path' : self.cache_home, - 'cfg_search_categories' : 'yes', # with regex searches - 'cfg_sort_mode' : 'date', # 'alpha' or 'date' - 'cfg_favorite_ontop' : 'yes', - 'cfg_tabstop' : '4', - 'cfg_format_strftime' : '%Y/%m/%d', - 'cfg_format_note_title' : '[%D] %F %-N %T', - 'cfg_status_bar' : 'yes', - 'cfg_editor' : os.environ['EDITOR'] if 'EDITOR' in os.environ else 'vim {fname} +{line}', - 'cfg_pager' : os.environ['PAGER'] if 'PAGER' in os.environ else 'less -c', - 'cfg_diff' : 'diff -b -U10', - 'cfg_max_logs' : '5', - 'cfg_log_timeout' : '5', - 'cfg_log_reversed' : 'yes', - 'cfg_nn_host' : '', - 'cfg_tempdir' : '', - - 'kb_help' : 'h', - 'kb_quit' : 'q', - 'kb_sync' : 'S', - 'kb_down' : 'j', - 'kb_up' : 'k', - 'kb_page_down' : 'space', - 'kb_page_up' : 'b', - 'kb_half_page_down' : 'ctrl d', - 'kb_half_page_up' : 'ctrl u', - 'kb_bottom' : 'G', - 'kb_top' : 'g', - 'kb_status' : 's', - 'kb_create_note' : 'C', - 'kb_edit_note' : 'e', - 'kb_view_note' : 'enter', - 'kb_view_note_ext' : 'meta enter', - 'kb_view_note_json' : 'O', - 'kb_pipe_note' : '|', - 'kb_view_next_note' : 'J', - 'kb_view_prev_note' : 'K', - 'kb_view_log' : 'l', - 'kb_tabstop2' : '2', - 'kb_tabstop4' : '4', - 'kb_tabstop8' : '8', - 'kb_search_gstyle' : '/', - 'kb_search_regex' : 'meta /', - 'kb_search_prev_gstyle' : '?', - 'kb_search_prev_regex' : 'meta ?', - 'kb_search_next' : 'n', - 'kb_search_prev' : 'N', - 'kb_clear_search' : 'A', - 'kb_sort_date' : 'd', - 'kb_sort_alpha' : 'a', - 'kb_sort_categories' : 'ctrl t', - 'kb_note_delete' : 'D', - 'kb_note_favorite' : 'p', - 'kb_note_category' : 't', - 'kb_copy_note_text' : 'y', - - 'clr_default_fg' : 'default', - 'clr_default_bg' : 'default', - 'clr_status_bar_fg' : 'dark gray', - 'clr_status_bar_bg' : 'light gray', - 'clr_log_fg' : 'dark gray', - 'clr_log_bg' : 'light gray', - 'clr_user_input_bar_fg' : 'white', - 'clr_user_input_bar_bg' : 'light red', - 'clr_note_focus_fg' : 'white', - 'clr_note_focus_bg' : 'light red', - 'clr_note_title_day_fg' : 'light red', - 'clr_note_title_day_bg' : 'default', - 'clr_note_title_week_fg' : 'light green', - 'clr_note_title_week_bg' : 'default', - 'clr_note_title_month_fg' : 'brown', - 'clr_note_title_month_bg' : 'default', - 'clr_note_title_year_fg' : 'light blue', - 'clr_note_title_year_bg' : 'default', - 'clr_note_title_ancient_fg' : 'light blue', - 'clr_note_title_ancient_bg' : 'default', - 'clr_note_date_fg' : 'dark blue', - 'clr_note_date_bg' : 'default', - 'clr_note_flags_fg' : 'dark magenta', - 'clr_note_flags_bg' : 'default', - 'clr_note_category_fg' : 'dark red', - 'clr_note_category_bg' : 'default', - 'clr_note_content_fg' : 'default', - 'clr_note_content_bg' : 'default', - 'clr_note_content_focus_fg' : 'white', - 'clr_note_content_focus_bg' : 'light red', - 'clr_note_content_old_fg' : 'yellow', - 'clr_note_content_old_bg' : 'dark gray', - 'clr_note_content_old_focus_fg' : 'white', - 'clr_note_content_old_focus_bg' : 'light red', - 'clr_help_focus_fg' : 'white', - 'clr_help_focus_bg' : 'light red', - 'clr_help_header_fg' : 'dark blue', - 'clr_help_header_bg' : 'default', - 'clr_help_config_fg' : 'dark green', - 'clr_help_config_bg' : 'default', - 'clr_help_value_fg' : 'dark red', - 'clr_help_value_bg' : 'default', - 'clr_help_descr_fg' : 'default', - 'clr_help_descr_bg' : 'default' - } - - 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.config_home, 'config')]) - - cfg_sec = 'nncli' - - if not cp.has_section(cfg_sec): - cp.add_section(cfg_sec) - - - # special handling for password so we can retrieve it by running a command - nn_password = cp.get(cfg_sec, 'cfg_nn_password', raw=True) - if not nn_password: - command = cp.get(cfg_sec, 'cfg_nn_password_eval', raw=True) - if command: - try: - nn_password = subprocess.check_output(command, shell=True, universal_newlines=True) - # remove trailing newlines to avoid requiring butchering shell commands (they can't usually be in passwords anyway) - nn_password = nn_password.rstrip('\n') - except subprocess.CalledProcessError as e: - print('Error evaluating command for password.') - print(e) - sys.exit(1) - - # ordered dicts used to ease help - - self.configs = collections.OrderedDict() - self.configs['nn_username'] = [ cp.get(cfg_sec, 'cfg_nn_username', raw=True), 'NextCloud Username' ] - self.configs['nn_password'] = [ nn_password, 'NextCloud Password' ] - self.configs['nn_host'] = [ cp.get(cfg_sec, 'cfg_nn_host', raw=True), 'NextCloud server hostname' ] - self.configs['db_path'] = [ cp.get(cfg_sec, 'cfg_db_path'), 'Note storage path' ] - self.configs['search_categories'] = [ cp.get(cfg_sec, 'cfg_search_categories'), 'Search categories as well' ] - self.configs['sort_mode'] = [ cp.get(cfg_sec, 'cfg_sort_mode'), 'Sort mode' ] - self.configs['favorite_ontop'] = [ cp.get(cfg_sec, 'cfg_favorite_ontop'), 'Favorite at top of list' ] - self.configs['tabstop'] = [ cp.get(cfg_sec, 'cfg_tabstop'), 'Tabstop spaces' ] - self.configs['format_strftime'] = [ cp.get(cfg_sec, 'cfg_format_strftime', raw=True), 'Date strftime format' ] - self.configs['format_note_title'] = [ cp.get(cfg_sec, 'cfg_format_note_title', raw=True), 'Note title format' ] - self.configs['status_bar'] = [ cp.get(cfg_sec, 'cfg_status_bar'), 'Show the status bar' ] - self.configs['editor'] = [ cp.get(cfg_sec, 'cfg_editor'), 'Editor command' ] - self.configs['pager'] = [ cp.get(cfg_sec, 'cfg_pager'), 'External pager command' ] - self.configs['diff'] = [ cp.get(cfg_sec, 'cfg_diff'), 'External diff command' ] - self.configs['max_logs'] = [ cp.get(cfg_sec, 'cfg_max_logs'), 'Max logs in footer' ] - self.configs['log_timeout'] = [ cp.get(cfg_sec, 'cfg_log_timeout'), 'Log timeout' ] - self.configs['log_reversed'] = [ cp.get(cfg_sec, 'cfg_log_reversed'), 'Log file reversed' ] - self.configs['tempdir'] = [ cp.get(cfg_sec, 'cfg_tempdir'), 'Temporary directory for note storage' ] - - self.keybinds = collections.OrderedDict() - self.keybinds['help'] = [ cp.get(cfg_sec, 'kb_help'), [ 'common' ], 'Help' ] - self.keybinds['quit'] = [ cp.get(cfg_sec, 'kb_quit'), [ 'common' ], 'Quit' ] - self.keybinds['sync'] = [ cp.get(cfg_sec, 'kb_sync'), [ 'common' ], 'Full sync' ] - self.keybinds['down'] = [ cp.get(cfg_sec, 'kb_down'), [ 'common' ], 'Scroll down one line' ] - self.keybinds['up'] = [ cp.get(cfg_sec, 'kb_up'), [ 'common' ], 'Scroll up one line' ] - self.keybinds['page_down'] = [ cp.get(cfg_sec, 'kb_page_down'), [ 'common' ], 'Page down' ] - self.keybinds['page_up'] = [ cp.get(cfg_sec, 'kb_page_up'), [ 'common' ], 'Page up' ] - self.keybinds['half_page_down'] = [ cp.get(cfg_sec, 'kb_half_page_down'), [ 'common' ], 'Half page down' ] - self.keybinds['half_page_up'] = [ cp.get(cfg_sec, 'kb_half_page_up'), [ 'common' ], 'Half page up' ] - self.keybinds['bottom'] = [ cp.get(cfg_sec, 'kb_bottom'), [ 'common' ], 'Goto bottom' ] - self.keybinds['top'] = [ cp.get(cfg_sec, 'kb_top'), [ 'common' ], 'Goto top' ] - self.keybinds['status'] = [ cp.get(cfg_sec, 'kb_status'), [ 'common' ], 'Toggle status bar' ] - self.keybinds['view_log'] = [ cp.get(cfg_sec, 'kb_view_log'), [ 'common' ], 'View log' ] - self.keybinds['create_note'] = [ cp.get(cfg_sec, 'kb_create_note'), [ 'titles' ], 'Create a new note' ] - self.keybinds['edit_note'] = [ cp.get(cfg_sec, 'kb_edit_note'), [ 'titles', 'notes' ], 'Edit note' ] - self.keybinds['view_note'] = [ cp.get(cfg_sec, 'kb_view_note'), [ 'titles' ], 'View note' ] - self.keybinds['view_note_ext'] = [ cp.get(cfg_sec, 'kb_view_note_ext'), [ 'titles', 'notes' ], 'View note with pager' ] - self.keybinds['view_note_json'] = [ cp.get(cfg_sec, 'kb_view_note_json'), [ 'titles', 'notes' ], 'View note raw json' ] - self.keybinds['pipe_note'] = [ cp.get(cfg_sec, 'kb_pipe_note'), [ 'titles', 'notes' ], 'Pipe note contents' ] - self.keybinds['view_next_note'] = [ cp.get(cfg_sec, 'kb_view_next_note'), [ 'notes' ], 'View next note' ] - self.keybinds['view_prev_note'] = [ cp.get(cfg_sec, 'kb_view_prev_note'), [ 'notes' ], 'View previous note' ] - self.keybinds['tabstop2'] = [ cp.get(cfg_sec, 'kb_tabstop2'), [ 'notes' ], 'View with tabstop=2' ] - self.keybinds['tabstop4'] = [ cp.get(cfg_sec, 'kb_tabstop4'), [ 'notes' ], 'View with tabstop=4' ] - self.keybinds['tabstop8'] = [ cp.get(cfg_sec, 'kb_tabstop8'), [ 'notes' ], 'View with tabstop=8' ] - self.keybinds['search_gstyle'] = [ cp.get(cfg_sec, 'kb_search_gstyle'), [ 'titles', 'notes' ], 'Search using gstyle' ] - self.keybinds['search_prev_gstyle'] = [ cp.get(cfg_sec, 'kb_search_prev_gstyle'), [ 'notes' ], 'Search backwards using gstyle' ] - self.keybinds['search_regex'] = [ cp.get(cfg_sec, 'kb_search_regex'), [ 'titles', 'notes' ], 'Search using regex' ] - self.keybinds['search_prev_regex'] = [ cp.get(cfg_sec, 'kb_search_prev_regex'), [ 'notes' ], 'Search backwards using regex' ] - self.keybinds['search_next'] = [ cp.get(cfg_sec, 'kb_search_next'), [ 'notes' ], 'Go to next search result' ] - self.keybinds['search_prev'] = [ cp.get(cfg_sec, 'kb_search_prev'), [ 'notes' ], 'Go to previous search result' ] - self.keybinds['clear_search'] = [ cp.get(cfg_sec, 'kb_clear_search'), [ 'titles' ], 'Show all notes' ] - self.keybinds['sort_date'] = [ cp.get(cfg_sec, 'kb_sort_date'), [ 'titles' ], 'Sort notes by date' ] - self.keybinds['sort_alpha'] = [ cp.get(cfg_sec, 'kb_sort_alpha'), [ 'titles' ], 'Sort notes by alpha' ] - self.keybinds['sort_categories'] = [ cp.get(cfg_sec, 'kb_sort_categories'), [ 'titles' ], 'Sort notes by categories' ] - self.keybinds['note_delete'] = [ cp.get(cfg_sec,'kb_note_delete'), [ 'titles', 'notes' ], 'Delete a note' ] - self.keybinds['note_favorite'] = [ cp.get(cfg_sec, 'kb_note_favorite'), [ 'titles', 'notes' ], 'Favorite note' ] - self.keybinds['note_category'] = [ cp.get(cfg_sec, 'kb_note_category'), [ 'titles', 'notes' ], 'Edit note category' ] - self.keybinds['copy_note_text'] = [ cp.get(cfg_sec, 'kb_copy_note_text'), [ 'notes' ], 'Copy line (xsel/pbcopy)' ] - - self.colors = collections.OrderedDict() - self.colors['default_fg'] = [ cp.get(cfg_sec, 'clr_default_fg'), 'Default fg' ] - self.colors['default_bg'] = [ cp.get(cfg_sec, 'clr_default_bg'), 'Default bg' ] - self.colors['status_bar_fg'] = [ cp.get(cfg_sec, 'clr_status_bar_fg'), 'Status bar fg' ] - self.colors['status_bar_bg'] = [ cp.get(cfg_sec, 'clr_status_bar_bg'), 'Status bar bg' ] - self.colors['log_fg'] = [ cp.get(cfg_sec, 'clr_log_fg'), 'Log message fg' ] - self.colors['log_bg'] = [ cp.get(cfg_sec, 'clr_log_bg'), 'Log message bg' ] - self.colors['user_input_bar_fg'] = [ cp.get(cfg_sec, 'clr_user_input_bar_fg'), 'User input bar fg' ] - self.colors['user_input_bar_bg'] = [ cp.get(cfg_sec, 'clr_user_input_bar_bg'), 'User input bar bg' ] - self.colors['note_focus_fg'] = [ cp.get(cfg_sec, 'clr_note_focus_fg'), 'Note title focus fg' ] - self.colors['note_focus_bg'] = [ cp.get(cfg_sec, 'clr_note_focus_bg'), 'Note title focus bg' ] - self.colors['note_title_day_fg'] = [ cp.get(cfg_sec, 'clr_note_title_day_fg'), 'Day old note title fg' ] - self.colors['note_title_day_bg'] = [ cp.get(cfg_sec, 'clr_note_title_day_bg'), 'Day old note title bg' ] - self.colors['note_title_week_fg'] = [ cp.get(cfg_sec, 'clr_note_title_week_fg'), 'Week old note title fg' ] - self.colors['note_title_week_bg'] = [ cp.get(cfg_sec, 'clr_note_title_week_bg'), 'Week old note title bg' ] - self.colors['note_title_month_fg'] = [ cp.get(cfg_sec, 'clr_note_title_month_fg'), 'Month old note title fg' ] - self.colors['note_title_month_bg'] = [ cp.get(cfg_sec, 'clr_note_title_month_bg'), 'Month old note title bg' ] - self.colors['note_title_year_fg'] = [ cp.get(cfg_sec, 'clr_note_title_year_fg'), 'Year old note title fg' ] - self.colors['note_title_year_bg'] = [ cp.get(cfg_sec, 'clr_note_title_year_bg'), 'Year old note title bg' ] - self.colors['note_title_ancient_fg'] = [ cp.get(cfg_sec, 'clr_note_title_ancient_fg'), 'Ancient note title fg' ] - self.colors['note_title_ancient_bg'] = [ cp.get(cfg_sec, 'clr_note_title_ancient_bg'), 'Ancient note title bg' ] - self.colors['note_date_fg'] = [ cp.get(cfg_sec, 'clr_note_date_fg'), 'Note date fg' ] - self.colors['note_date_bg'] = [ cp.get(cfg_sec, 'clr_note_date_bg'), 'Note date bg' ] - self.colors['note_flags_fg'] = [ cp.get(cfg_sec, 'clr_note_flags_fg'), 'Note flags fg' ] - self.colors['note_flags_bg'] = [ cp.get(cfg_sec, 'clr_note_flags_bg'), 'Note flags bg' ] - self.colors['note_category_fg'] = [ cp.get(cfg_sec, 'clr_note_category_fg'), 'Note category fg' ] - self.colors['note_category_bg'] = [ cp.get(cfg_sec, 'clr_note_category_bg'), 'Note category bg' ] - self.colors['note_content_fg'] = [ cp.get(cfg_sec, 'clr_note_content_fg'), 'Note content fg' ] - self.colors['note_content_bg'] = [ cp.get(cfg_sec, 'clr_note_content_bg'), 'Note content bg' ] - self.colors['note_content_focus_fg'] = [ cp.get(cfg_sec, 'clr_note_content_focus_fg'), 'Note content focus fg' ] - self.colors['note_content_focus_bg'] = [ cp.get(cfg_sec, 'clr_note_content_focus_bg'), 'Note content focus bg' ] - self.colors['note_content_old_fg'] = [ cp.get(cfg_sec, 'clr_note_content_old_fg'), 'Old note content fg' ] - self.colors['note_content_old_bg'] = [ cp.get(cfg_sec, 'clr_note_content_old_bg'), 'Old note content bg' ] - self.colors['note_content_old_focus_fg'] = [ cp.get(cfg_sec, 'clr_note_content_old_focus_fg'), 'Old note content focus fg' ] - self.colors['note_content_old_focus_bg'] = [ cp.get(cfg_sec, 'clr_note_content_old_focus_bg'), 'Old note content focus bg' ] - self.colors['help_focus_fg'] = [ cp.get(cfg_sec, 'clr_help_focus_fg'), 'Help focus fg' ] - self.colors['help_focus_bg'] = [ cp.get(cfg_sec, 'clr_help_focus_bg'), 'Help focus bg' ] - self.colors['help_header_fg'] = [ cp.get(cfg_sec, 'clr_help_header_fg'), 'Help header fg' ] - self.colors['help_header_bg'] = [ cp.get(cfg_sec, 'clr_help_header_bg'), 'Help header bg' ] - self.colors['help_config_fg'] = [ cp.get(cfg_sec, 'clr_help_config_fg'), 'Help config fg' ] - self.colors['help_config_bg'] = [ cp.get(cfg_sec, 'clr_help_config_bg'), 'Help config bg' ] - self.colors['help_value_fg'] = [ cp.get(cfg_sec, 'clr_help_value_fg'), 'Help value fg' ] - self.colors['help_value_bg'] = [ cp.get(cfg_sec, 'clr_help_value_bg'), 'Help value bg' ] - self.colors['help_descr_fg'] = [ cp.get(cfg_sec, 'clr_help_descr_fg'), 'Help description fg' ] - self.colors['help_descr_bg'] = [ cp.get(cfg_sec, 'clr_help_descr_bg'), 'Help description bg' ] - - def get_config(self, name): - return self.configs[name][0] - - def get_config_descr(self, name): - return self.configs[name][1] - - def get_keybind(self, name): - return self.keybinds[name][0] - - def get_keybind_use(self, name): - return self.keybinds[name][1] - - def get_keybind_descr(self, name): - return self.keybinds[name][2] - - def get_color(self, name): - return self.colors[name][0] - - def get_color_descr(self, name): - return self.colors[name][1] - diff --git a/nnotes_cli/nncli.py b/nnotes_cli/nncli.py @@ -1,1316 +0,0 @@ -# -*- encoding: utf-8 -*- - -import os, sys, getopt, re, signal, time, datetime, shlex, hashlib -import subprocess, threading, logging -import copy, json, urwid, datetime -import nnotes_cli -from . import view_titles, view_note, view_help, view_log, user_input -from . import utils, temp -from .config import Config -from .nextcloud_note import NextcloudNote -from .notes_db import NotesDB, ReadError, WriteError -from logging.handlers import RotatingFileHandler - -class nncli: - - def __init__(self, do_server_sync, verbose=False, config_file=None): - self.config = Config(config_file) - self.do_server_sync = do_server_sync - self.verbose = verbose - self.do_gui = False - force_full_sync = False - self.current_sort_mode = self.config.get_config('sort_mode') - - self.tempdir = self.config.get_config('tempdir') - if self.tempdir == '': - self.tempdir = None - - if not os.path.exists(self.config.get_config('db_path')): - os.mkdir(self.config.get_config('db_path')) - force_full_sync = True - - # configure the logging module - self.logfile = os.path.join(self.config.get_config('db_path'), 'nncli.log') - self.loghandler = RotatingFileHandler(self.logfile, maxBytes=100000, backupCount=1) - self.loghandler.setLevel(logging.DEBUG) - self.loghandler.setFormatter(logging.Formatter(fmt='%(asctime)s [%(levelname)s] %(message)s')) - self.logger = logging.getLogger() - self.logger.setLevel(logging.DEBUG) - self.logger.addHandler(self.loghandler) - self.config.logfile = self.logfile - - logging.debug('nncli logging initialized') - - self.logs = [] - - try: - self.ndb = NotesDB(self.config, self.log, self.gui_update_view) - except Exception as e: - self.log(str(e)) - sys.exit(1) - - if force_full_sync: - # The note database doesn't exist so force a full sync. It is - # important to do this outside of the gui because an account - # with hundreds of notes will cause a recursion panic under - # urwid. This simple workaround gets the job done. :-) - self.verbose = True - self.log('nncli database doesn\'t exist, forcing full sync...') - self.sync_notes() - self.verbose = verbose - - def sync_notes(self): - self.ndb.sync_now(self.do_server_sync) - - def get_editor(self): - editor = self.config.get_config('editor') - if not editor: - self.log('No editor configured!') - return None - return editor - - def get_pager(self): - pager = self.config.get_config('pager') - if not pager: - self.log('No pager configured!') - return None - return pager - - def get_diff(self): - diff = self.config.get_config('diff') - if not diff: - self.log('No diff command configured!') - return None - return diff - - def exec_cmd_on_note(self, note, cmd=None, raw=False): - - if not cmd: - cmd = self.get_editor() - if not cmd: - return None - - tf = temp.tempfile_create(note if note else None, raw=raw, tempdir=self.tempdir) - fname = temp.tempfile_name(tf) - - focus_position = 0 - try: - focus_position = self.gui_body_get().focus_position - except IndexError: - # focus position will fail if no notes available (listbox empty) - # TODO: find a neater way to check than try/except - pass - except AttributeError: - # we're running in CLI mode - pass - - subs = { - 'fname': fname, - 'line': focus_position + 1, - } - cmd_list = [c.format(**subs) for c in shlex.split(cmd)] - - # if the filename wasn't able to be subbed, append it - # this makes it fully backwards compatible with previous configs - if '{fname}' not in cmd: - cmd_list.append(fname) - - self.log("EXECUTING: {}".format(cmd_list)) - - try: - subprocess.check_call(cmd_list) - except Exception as e: - self.log('Command error: ' + str(e)) - temp.tempfile_delete(tf) - return None - - content = None - if not raw: - content = temp.tempfile_content(tf) - if not content or content == '\n': - content = None - - temp.tempfile_delete(tf) - return content - - def exec_diff_on_note(self, note, old_note): - - diff = self.get_diff() - if not diff: - return None - - pager = self.get_pager() - if not pager: - return None - - ltf = temp.tempfile_create(note, tempdir=self.tempdir) - otf = temp.tempfile_create(old_note, tempdir=self.tempdir) - out = temp.tempfile_create(None, tempdir=self.tempdir) - - try: - subprocess.call(diff + ' ' + - temp.tempfile_name(ltf) + ' ' + - temp.tempfile_name(otf) + ' > ' + - temp.tempfile_name(out), - shell=True) - subprocess.check_call(pager + ' ' + - temp.tempfile_name(out), - shell=True) - except Exception as e: - self.log('Command error: ' + str(e)) - temp.tempfile_delete(ltf) - temp.tempfile_delete(otf) - temp.tempfile_delete(out) - return None - - temp.tempfile_delete(ltf) - temp.tempfile_delete(otf) - temp.tempfile_delete(out) - return None - - def gui_header_clear(self): - self.master_frame.contents['header'] = ( None, None ) - self.nncli_loop.draw_screen() - - def gui_header_set(self, w): - self.master_frame.contents['header'] = ( w, None ) - self.nncli_loop.draw_screen() - - def gui_header_get(self): - return self.master_frame.contents['header'][0] - - def gui_header_focus(self): - self.master_frame.focus_position = 'header' - - def gui_footer_log_clear(self): - ui = self.gui_footer_input_get() - self.master_frame.contents['footer'] = \ - (urwid.Pile([ urwid.Pile([]), urwid.Pile([ui]) ]), None) - self.nncli_loop.draw_screen() - - def gui_footer_log_set(self, pl): - ui = self.gui_footer_input_get() - self.master_frame.contents['footer'] = \ - (urwid.Pile([ urwid.Pile(pl), urwid.Pile([ui]) ]), None) - self.nncli_loop.draw_screen() - - def gui_footer_log_get(self): - return self.master_frame.contents['footer'][0].contents[0][0] - - def gui_footer_input_clear(self): - pl = self.gui_footer_log_get() - self.master_frame.contents['footer'] = \ - (urwid.Pile([ urwid.Pile([pl]), urwid.Pile([]) ]), None) - self.nncli_loop.draw_screen() - - def gui_footer_input_set(self, ui): - pl = self.gui_footer_log_get() - self.master_frame.contents['footer'] = \ - (urwid.Pile([ urwid.Pile([pl]), urwid.Pile([ui]) ]), None) - self.nncli_loop.draw_screen() - - def gui_footer_input_get(self): - return self.master_frame.contents['footer'][0].contents[1][0] - - def gui_footer_focus_input(self): - self.master_frame.focus_position = 'footer' - self.master_frame.contents['footer'][0].focus_position = 1 - - def gui_body_clear(self): - self.master_frame.contents['body'] = ( None, None ) - self.nncli_loop.draw_screen() - - def gui_body_set(self, w): - self.master_frame.contents['body'] = ( w, None ) - self.gui_update_status_bar() - self.nncli_loop.draw_screen() - - def gui_body_get(self): - return self.master_frame.contents['body'][0] - - def gui_body_focus(self): - self.master_frame.focus_position = 'body' - - def log_timeout(self, loop, arg): - self.log_lock.acquire() - - self.log_alarms -= 1 - - if self.log_alarms == 0: - self.gui_footer_log_clear() - self.logs = [] - else: - # for some reason having problems with this being empty? - if len(self.logs) > 0: - self.logs.pop(0) - - log_pile = [] - - for l in self.logs: - log_pile.append(urwid.AttrMap(urwid.Text(l), 'log')) - - if self.verbose: - self.gui_footer_log_set(log_pile) - - self.log_lock.release() - - def log(self, msg): - logging.debug(msg) - - if not self.do_gui: - if self.verbose: - print(msg) - return - - self.log_lock.acquire() - - self.log_alarms += 1 - self.logs.append(msg) - - if len(self.logs) > int(self.config.get_config('max_logs')): - self.log_alarms -= 1 - self.logs.pop(0) - - log_pile = [] - for l in self.logs: - log_pile.append(urwid.AttrMap(urwid.Text(l), 'log')) - - if self.verbose: - self.gui_footer_log_set(log_pile) - - self.nncli_loop.set_alarm_in( - int(self.config.get_config('log_timeout')), - self.log_timeout, None) - - self.log_lock.release() - - def gui_update_view(self): - if not self.do_gui: - return - - try: - cur_key = self.view_titles.note_list[self.view_titles.focus_position].note['localkey'] - except IndexError as e: - cur_key = None - pass - - self.view_titles.update_note_list(self.view_titles.search_string, sort_mode=self.current_sort_mode) - self.view_titles.focus_note(cur_key) - - if self.gui_body_get().__class__ == view_note.ViewNote: - self.view_note.update_note_view() - - self.gui_update_status_bar() - - def gui_update_status_bar(self): - if self.status_bar != 'yes': - self.gui_header_clear() - else: - self.gui_header_set(self.gui_body_get().get_status_bar()) - - def gui_switch_frame_body(self, new_view, save_current_view=True): - if new_view == None: - if len(self.last_view) == 0: - # XXX verify all notes saved... - self.gui_stop() - else: - self.gui_body_set(self.last_view.pop()) - else: - if self.gui_body_get().__class__ != new_view.__class__: - if save_current_view: - self.last_view.append(self.gui_body_get()) - self.gui_body_set(new_view) - - def delete_note_callback(self, key, delete): - if not delete: - return - note = self.ndb.get_note(key) - self.ndb.set_note_deleted(key, True) - - if self.gui_body_get().__class__ == view_titles.ViewTitles: - self.view_titles.update_note_title() - - self.gui_update_status_bar() - self.ndb.sync_worker_go() - - def gui_yes_no_input(self, args, yes_no): - self.gui_footer_input_clear() - self.gui_body_focus() - self.master_frame.keypress = self.gui_frame_keypress - args[0](args[1], - True if yes_no in [ 'YES', 'Yes', 'yes', 'Y', 'y' ] - else False) - - def gui_search_input(self, args, search_string): - self.gui_footer_input_clear() - self.gui_body_focus() - self.master_frame.keypress = self.gui_frame_keypress - if search_string: - if (self.gui_body_get() == self.view_note): - self.search_direction = args[1] - self.view_note.search_note_view_next(search_string=search_string, search_mode=args[0]) - else: - self.view_titles.update_note_list(search_string, args[0], sort_mode=self.current_sort_mode) - self.gui_body_set(self.view_titles) - - def gui_category_input(self, args, category): - self.gui_footer_input_clear() - self.gui_body_focus() - self.master_frame.keypress = self.gui_frame_keypress - if category != None: - if self.gui_body_get().__class__ == view_titles.ViewTitles: - note = self.view_titles.note_list[self.view_titles.focus_position].note - else: # self.gui_body_get().__class__ == view_note.ViewNote: - note = self.view_note.note - - self.ndb.set_note_category(note['localkey'], category) - - if self.gui_body_get().__class__ == view_titles.ViewTitles: - self.view_titles.update_note_title() - else: # self.gui_body_get().__class__ == view_note.ViewNote: - self.view_note.update_note_view() - - self.gui_update_status_bar() - self.ndb.sync_worker_go() - - def gui_pipe_input(self, args, cmd): - self.gui_footer_input_clear() - self.gui_body_focus() - self.master_frame.keypress = self.gui_frame_keypress - if cmd != None: - if self.gui_body_get().__class__ == view_titles.ViewTitles: - note = self.view_titles.note_list[self.view_titles.focus_position].note - else: # self.gui_body_get().__class__ == view_note.ViewNote: - note = self.view_note.old_note if self.view_note.old_note \ - else self.view_note.note - args = shlex.split(cmd) - try: - self.gui_clear() - pipe = subprocess.Popen(cmd, stdin=subprocess.PIPE, shell=True) - pipe.communicate(note['content'].encode('utf-8')) - pipe.stdin.close() - pipe.wait() - except OSError as e: - self.log('Pipe error: ' + str(e)) - finally: - self.gui_reset() - - def gui_frame_keypress(self, size, key): - # convert space character into name - if key == ' ': - key = 'space' - - lb = self.gui_body_get() - - if key == self.config.get_keybind('quit'): - self.gui_switch_frame_body(None) - - elif key == self.config.get_keybind('help'): - self.gui_switch_frame_body(self.view_help) - - elif key == self.config.get_keybind('sync'): - self.ndb.last_sync = 0 - self.ndb.sync_worker_go() - - elif key == self.config.get_keybind('view_log'): - self.view_log.update_log() - self.gui_switch_frame_body(self.view_log) - - elif key == self.config.get_keybind('down'): - if len(lb.body.positions()) <= 0: - return None - last = len(lb.body.positions()) - if lb.focus_position == (last - 1): - return None - lb.focus_position += 1 - lb.render(size) - - elif key == self.config.get_keybind('up'): - if len(lb.body.positions()) <= 0: - return None - if lb.focus_position == 0: - return None - lb.focus_position -= 1 - lb.render(size) - - elif key == self.config.get_keybind('page_down'): - if len(lb.body.positions()) <= 0: - return None - last = len(lb.body.positions()) - next_focus = lb.focus_position + size[1] - if next_focus >= last: - next_focus = last - 1 - lb.change_focus(size, next_focus, - offset_inset=0, - coming_from='above') - - elif key == self.config.get_keybind('page_up'): - if len(lb.body.positions()) <= 0: - return None - if 'bottom' in lb.ends_visible(size): - last = len(lb.body.positions()) - next_focus = last - size[1] - size[1] - else: - next_focus = lb.focus_position - size[1] - if next_focus < 0: - next_focus = 0 - lb.change_focus(size, next_focus, - offset_inset=0, - coming_from='below') - - elif key == self.config.get_keybind('half_page_down'): - if len(lb.body.positions()) <= 0: - return None - last = len(lb.body.positions()) - next_focus = lb.focus_position + (size[1] // 2) - if next_focus >= last: - next_focus = last - 1 - lb.change_focus(size, next_focus, - offset_inset=0, - coming_from='above') - - elif key == self.config.get_keybind('half_page_up'): - if len(lb.body.positions()) <= 0: - return None - if 'bottom' in lb.ends_visible(size): - last = len(lb.body.positions()) - next_focus = last - size[1] - (size[1] // 2) - else: - next_focus = lb.focus_position - (size[1] // 2) - if next_focus < 0: - next_focus = 0 - lb.change_focus(size, next_focus, - offset_inset=0, - coming_from='below') - - elif key == self.config.get_keybind('bottom'): - if len(lb.body.positions()) <= 0: - return None - lb.change_focus(size, (len(lb.body.positions()) - 1), - offset_inset=0, - coming_from='above') - - elif key == self.config.get_keybind('top'): - if len(lb.body.positions()) <= 0: - return None - lb.change_focus(size, 0, - offset_inset=0, - coming_from='below') - - elif key == self.config.get_keybind('view_next_note'): - if self.gui_body_get().__class__ != view_note.ViewNote: - return key - - if len(self.view_titles.body.positions()) <= 0: - return None - last = len(self.view_titles.body.positions()) - if self.view_titles.focus_position == (last - 1): - return None - self.view_titles.focus_position += 1 - lb.update_note_view( - self.view_titles.note_list[self.view_titles.focus_position].note['localkey']) - self.gui_switch_frame_body(self.view_note) - - elif key == self.config.get_keybind('view_prev_note'): - if self.gui_body_get().__class__ != view_note.ViewNote: - return key - - if len(self.view_titles.body.positions()) <= 0: - return None - if self.view_titles.focus_position == 0: - return None - self.view_titles.focus_position -= 1 - lb.update_note_view( - self.view_titles.note_list[self.view_titles.focus_position].note['localkey']) - self.gui_switch_frame_body(self.view_note) - - elif key == self.config.get_keybind('status'): - if self.status_bar == 'yes': - self.status_bar = 'no' - else: - self.status_bar = self.config.get_config('status_bar') - - elif key == self.config.get_keybind('create_note'): - if self.gui_body_get().__class__ != view_titles.ViewTitles: - return key - - self.gui_clear() - content = self.exec_cmd_on_note(None) - self.gui_reset() - - if content: - self.log('New note created') - self.ndb.create_note(content) - self.gui_update_view() - self.ndb.sync_worker_go() - - elif key == self.config.get_keybind('edit_note') or \ - key == self.config.get_keybind('view_note_ext') or \ - key == self.config.get_keybind('view_note_json'): - if self.gui_body_get().__class__ != view_titles.ViewTitles and \ - self.gui_body_get().__class__ != view_note.ViewNote: - return key - - if self.gui_body_get().__class__ == view_titles.ViewTitles: - if len(lb.body.positions()) <= 0: - return None - note = lb.note_list[lb.focus_position].note - else: # self.gui_body_get().__class__ == view_note.ViewNote: - if key == self.config.get_keybind('edit_note'): - note = lb.note - else: - note = lb.old_note if lb.old_note else lb.note - - self.gui_clear() - if key == self.config.get_keybind('edit_note'): - content = self.exec_cmd_on_note(note) - elif key == self.config.get_keybind('view_note_ext'): - content = self.exec_cmd_on_note(note, cmd=self.get_pager()) - else: # key == self.config.get_keybind('view_note_json') - content = self.exec_cmd_on_note(note, cmd=self.get_pager(), raw=True) - - self.gui_reset() - - if not content: - return None - - md5_old = hashlib.md5(note['content'].encode('utf-8')).digest() - md5_new = hashlib.md5(content.encode('utf-8')).digest() - - if md5_old != md5_new: - self.log('Note updated') - self.ndb.set_note_content(note['localkey'], content) - if self.gui_body_get().__class__ == view_titles.ViewTitles: - lb.update_note_title() - else: # self.gui_body_get().__class__ == view_note.ViewNote: - lb.update_note_view() - self.ndb.sync_worker_go() - else: - self.log('Note unchanged') - - elif key == self.config.get_keybind('view_note'): - if self.gui_body_get().__class__ != view_titles.ViewTitles: - return key - - if len(lb.body.positions()) <= 0: - return None - self.view_note.update_note_view( - lb.note_list[lb.focus_position].note['localkey']) - self.gui_switch_frame_body(self.view_note) - - elif key == self.config.get_keybind('pipe_note'): - if self.gui_body_get().__class__ != view_titles.ViewTitles and \ - self.gui_body_get().__class__ != view_note.ViewNote: - return key - - if self.gui_body_get().__class__ == view_titles.ViewTitles: - if len(lb.body.positions()) <= 0: - return None - note = lb.note_list[lb.focus_position].note - else: # self.gui_body_get().__class__ == view_note.ViewNote: - note = lb.old_note if lb.old_note else lb.note - - self.gui_footer_input_set( - urwid.AttrMap( - user_input.UserInput( - self.config, - key, - '', - self.gui_pipe_input, - None), - 'user_input_bar')) - self.gui_footer_focus_input() - self.master_frame.keypress = self.gui_footer_input_get().keypress - - elif key == self.config.get_keybind('note_delete'): - if self.gui_body_get().__class__ != view_titles.ViewTitles and \ - self.gui_body_get().__class__ != view_note.ViewNote: - return key - - if self.gui_body_get().__class__ == view_titles.ViewTitles: - if len(lb.body.positions()) <= 0: - return None - note = lb.note_list[lb.focus_position].note - else: # self.gui_body_get().__class__ == view_note.ViewNote: - note = lb.note - - self.gui_footer_input_set( - urwid.AttrMap( - user_input.UserInput( - self.config, - 'Delete (y/n): ', - '', - self.gui_yes_no_input, - [ self.delete_note_callback, note['localkey'] ]), - 'user_input_bar')) - self.gui_footer_focus_input() - self.master_frame.keypress = self.gui_footer_input_get().keypress - - elif key == self.config.get_keybind('note_favorite'): - if self.gui_body_get().__class__ != view_titles.ViewTitles and \ - self.gui_body_get().__class__ != view_note.ViewNote: - return key - - if self.gui_body_get().__class__ == view_titles.ViewTitles: - if len(lb.body.positions()) <= 0: - return None - note = lb.note_list[lb.focus_position].note - else: # self.gui_body_get().__class__ == view_note.ViewNote: - note = lb.note - - favorite = not note['favorite'] - - self.ndb.set_note_favorite(note['localkey'], favorite) - - if self.gui_body_get().__class__ == view_titles.ViewTitles: - lb.update_note_title() - - self.ndb.sync_worker_go() - - elif key == self.config.get_keybind('note_category'): - if self.gui_body_get().__class__ != view_titles.ViewTitles and \ - self.gui_body_get().__class__ != view_note.ViewNote: - return key - - if self.gui_body_get().__class__ == view_titles.ViewTitles: - if len(lb.body.positions()) <= 0: - return None - note = lb.note_list[lb.focus_position].note - else: # self.gui_body_get().__class__ == view_note.ViewNote: - note = lb.note - - self.gui_footer_input_set( - urwid.AttrMap( - user_input.UserInput( - self.config, - 'Category: ', - note['category'], - self.gui_category_input, - None), - 'user_input_bar')) - self.gui_footer_focus_input() - self.master_frame.keypress = self.gui_footer_input_get().keypress - - elif key == self.config.get_keybind('search_gstyle') or \ - key == self.config.get_keybind('search_regex') or \ - key == self.config.get_keybind('search_prev_gstyle') or \ - key == self.config.get_keybind('search_prev_regex'): - if self.gui_body_get().__class__ != view_titles.ViewTitles and \ - self.gui_body_get().__class__ != view_note.ViewNote: - return key - - if self.gui_body_get().__class__ == view_note.ViewNote: - if key == self.config.get_keybind('search_prev_gstyle') or \ - key == self.config.get_keybind('search_prev_regex'): - self.view_note.search_direction = 'backward' - else: - self.view_note.search_direction = 'forward' - - options = [ - 'gstyle' if key == self.config.get_keybind('search_gstyle') - or key == self.config.get_keybind('search_prev_gstyle') - else 'regex', - 'backward' if key == self.config.get_keybind('search_prev_gstyle') - or key == self.config.get_keybind('search_prev_regex') - else 'forward' - ] - - caption = '{}{}'.format('(regex) ' if options[0] == 'regex' else '', '/' if options[1] == 'forward' else '?') - - self.gui_footer_input_set( - urwid.AttrMap( - user_input.UserInput( - self.config, - caption, - '', - self.gui_search_input, - options), - 'user_input_bar')) - self.gui_footer_focus_input() - self.master_frame.keypress = self.gui_footer_input_get().keypress - - elif key == self.config.get_keybind('search_next'): - if self.gui_body_get().__class__ != view_note.ViewNote: - return key - - self.view_note.search_note_view_next() - - elif key == self.config.get_keybind('search_prev'): - if self.gui_body_get().__class__ != view_note.ViewNote: - return key - - self.view_note.search_note_view_prev() - - elif key == self.config.get_keybind('clear_search'): - if self.gui_body_get().__class__ != view_titles.ViewTitles: - return key - - self.view_titles.update_note_list(None, sort_mode=self.current_sort_mode) - self.gui_body_set(self.view_titles) - - elif key == self.config.get_keybind('sort_date'): - if self.gui_body_get().__class__ != view_titles.ViewTitles: - return key - - self.current_sort_mode = 'date' - self.view_titles.sort_note_list('date') - - elif key == self.config.get_keybind('sort_alpha'): - if self.gui_body_get().__class__ != view_titles.ViewTitles: - return key - - self.current_sort_mode = 'alpha' - self.view_titles.sort_note_list('alpha') - - elif key == self.config.get_keybind('sort_categories'): - if self.gui_body_get().__class__ != view_titles.ViewTitles: - return key - - self.current_sort_mode = 'categories' - self.view_titles.sort_note_list('categories') - - elif key == self.config.get_keybind('copy_note_text'): - if self.gui_body_get().__class__ != view_note.ViewNote: - return key - - self.view_note.copy_note_text() - - else: - return lb.keypress(size, key) - - self.gui_update_status_bar() - return None - - def gui_init_view(self, loop, view_note): - self.master_frame.keypress = self.gui_frame_keypress - self.gui_body_set(self.view_titles) - - if view_note: - # note that title view set first to prime the view stack - self.gui_switch_frame_body(self.view_note) - - self.thread_sync.start() - - def gui_clear(self): - self.nncli_loop.widget = urwid.Filler(urwid.Text('')) - self.nncli_loop.draw_screen() - - def gui_reset(self): - self.nncli_loop.widget = self.master_frame - self.nncli_loop.draw_screen() - - def gui_stop(self): - # don't exit if there are any notes not yet saved to the disk - - # NOTE: this was originally causing hangs on exit with urllib2 - # should not be a problem now since using the requests library - # ref https://github.com/insanum/sncli/issues/18#issuecomment-105517773 - if self.ndb.verify_all_saved(): - # clear the screen and exit the urwid run loop - self.gui_clear() - raise urwid.ExitMainLoop() - else: - self.log(u'WARNING: Not all notes saved to disk (wait for sync worker)') - - def gui(self, key): - - self.do_gui = True - - self.last_view = [] - self.status_bar = self.config.get_config('status_bar') - - self.log_alarms = 0 - self.log_lock = threading.Lock() - - self.thread_sync = threading.Thread(target=self.ndb.sync_worker, - args=[self.do_server_sync]) - self.thread_sync.setDaemon(True) - - self.view_titles = \ - view_titles.ViewTitles(self.config, - { - 'ndb' : self.ndb, - 'search_string' : None, - 'log' : self.log - }) - self.view_note = \ - view_note.ViewNote(self.config, - { - 'ndb' : self.ndb, - 'id' : key, # initial key to view or None - 'log' : self.log - }) - - self.view_log = view_log.ViewLog(self.config) - self.view_help = view_help.ViewHelp(self.config) - - palette = \ - [ - ('default', - self.config.get_color('default_fg'), - self.config.get_color('default_bg') ), - ('status_bar', - self.config.get_color('status_bar_fg'), - self.config.get_color('status_bar_bg') ), - ('log', - self.config.get_color('log_fg'), - self.config.get_color('log_bg') ), - ('user_input_bar', - self.config.get_color('user_input_bar_fg'), - self.config.get_color('user_input_bar_bg') ), - ('note_focus', - self.config.get_color('note_focus_fg'), - self.config.get_color('note_focus_bg') ), - ('note_title_day', - self.config.get_color('note_title_day_fg'), - self.config.get_color('note_title_day_bg') ), - ('note_title_week', - self.config.get_color('note_title_week_fg'), - self.config.get_color('note_title_week_bg') ), - ('note_title_month', - self.config.get_color('note_title_month_fg'), - self.config.get_color('note_title_month_bg') ), - ('note_title_year', - self.config.get_color('note_title_year_fg'), - self.config.get_color('note_title_year_bg') ), - ('note_title_ancient', - self.config.get_color('note_title_ancient_fg'), - self.config.get_color('note_title_ancient_bg') ), - ('note_date', - self.config.get_color('note_date_fg'), - self.config.get_color('note_date_bg') ), - ('note_flags', - self.config.get_color('note_flags_fg'), - self.config.get_color('note_flags_bg') ), - ('note_category', - self.config.get_color('note_category_fg'), - self.config.get_color('note_category_bg') ), - ('note_content', - self.config.get_color('note_content_fg'), - self.config.get_color('note_content_bg') ), - ('note_content_focus', - self.config.get_color('note_content_focus_fg'), - self.config.get_color('note_content_focus_bg') ), - ('note_content_old', - self.config.get_color('note_content_old_fg'), - self.config.get_color('note_content_old_bg') ), - ('note_content_old_focus', - self.config.get_color('note_content_old_focus_fg'), - self.config.get_color('note_content_old_focus_bg') ), - ('help_focus', - self.config.get_color('help_focus_fg'), - self.config.get_color('help_focus_bg') ), - ('help_header', - self.config.get_color('help_header_fg'), - self.config.get_color('help_header_bg') ), - ('help_config', - self.config.get_color('help_config_fg'), - self.config.get_color('help_config_bg') ), - ('help_value', - self.config.get_color('help_value_fg'), - self.config.get_color('help_value_bg') ), - ('help_descr', - self.config.get_color('help_descr_fg'), - self.config.get_color('help_descr_bg') ) - ] - - self.master_frame = urwid.Frame(body=urwid.Filler(urwid.Text('')), - header=None, - footer=urwid.Pile([ urwid.Pile([]), - urwid.Pile([]) ]), - focus_part='body') - - self.nncli_loop = urwid.MainLoop(self.master_frame, - palette, - handle_mouse=False) - - self.nncli_loop.set_alarm_in(0, self.gui_init_view, - True if key else False) - - self.nncli_loop.run() - - def cli_list_notes(self, regex, search_string): - - note_list, match_regex, all_notes_cnt = \ - self.ndb.filter_notes( - search_string, - search_mode='regex' if regex else 'gstyle', - sort_mode=self.config.get_config('sort_mode')) - for n in note_list: - flags = utils.get_note_flags(n.note) - print((str(n.key) + \ - ' [' + flags + '] ' + \ - utils.get_note_title(n.note))) - - def cli_note_dump(self, key): - - note = self.ndb.get_note(key) - if not note: - self.log('ERROR: Key does not exist') - return - - w = 60 - sep = '+' + '-'*(w+2) + '+' - 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) - category = utils.get_note_category(note) - - print(sep) - print(('| {:<' + str(w) + '} |').format((' Title: ' + title)[:w])) - print(('| {:<' + str(w) + '} |').format((' Key: ' + str(note.get('id', 'Localkey: {}'.format(note.get('localkey'))))[:w]))) - print(('| {:<' + str(w) + '} |').format((' Date: ' + mod_time)[:w])) - print(('| {:<' + str(w) + '} |').format((' Category: ' + category)[:w])) - print(('| {:<' + str(w) + '} |').format((' Flags: [' + flags + ']')[:w])) - print(sep) - print((note['content'])) - - def cli_dump_notes(self, regex, search_string): - - note_list, match_regex, all_notes_cnt = \ - self.ndb.filter_notes( - search_string, - search_mode='regex' if regex else 'gstyle', - sort_mode=self.config.get_config('sort_mode')) - for n in note_list: - self.cli_note_dump(n.key) - - def cli_note_create(self, from_stdin, title): - - if from_stdin: - content = ''.join(sys.stdin) - else: - content = self.exec_cmd_on_note(None) - - if title: - content = title + '\n\n' + content if content else '' - - if content: - self.log('New note created') - self.ndb.create_note(content) - self.sync_notes() - - def cli_note_import(self, from_stdin): - - if from_stdin: - raw = ''.join(sys.stdin) - else: - raw = self.exec_cmd_on_note(None) - - if raw: - try: - note = json.loads(raw) - self.log('New note created') - self.ndb.import_note(note) - self.sync_notes() - except json.decoder.JSONDecodeError as e: - self.log('(IMPORT) Decoding JSON has failed: {}'.format(e)) - sys.exit(1) - except ValueError as e: - self.log('(IMPORT) ValueError: {}'.format(e)) - sys.exit(1) - - def cli_note_export(self, key): - - note = self.ndb.get_note(key) - if not note: - self.log('ERROR: Key does not exist') - return - - print(json.dumps(note, indent=2)) - - def cli_export_notes(self, regex, search_string): - - note_list, match_regex, all_notes_cnt = \ - self.ndb.filter_notes( - search_string, - search_mode='regex' if regex else 'gstyle', - sort_mode=self.config.get_config('sort_mode')) - - notes_data = [n.note for n in note_list] - print(json.dumps(notes_data, indent=2)) - - def cli_note_edit(self, key): - - note = self.ndb.get_note(key) - if not note: - self.log('ERROR: Key does not exist') - return - - content = self.exec_cmd_on_note(note) - if not content: - return - - md5_old = hashlib.md5(note['content'].encode('utf-8')).digest() - md5_new = hashlib.md5(content.encode('utf-8')).digest() - - if md5_old != md5_new: - self.log('Note updated') - self.ndb.set_note_content(note['localkey'], content) - self.sync_notes() - else: - self.log('Note unchanged') - - def cli_note_delete(self, key, delete): - - note = self.ndb.get_note(key) - if not note: - self.log('ERROR: Key does not exist') - return - - self.ndb.set_note_deleted(key, delete) - self.sync_notes() - - def cli_note_favorite(self, key, favorite): - - note = self.ndb.get_note(key) - if not note: - self.log('ERROR: Key does not exist') - return - - self.ndb.set_note_favorite(key, favorite) - self.sync_notes() - - def cli_note_category_get(self, key): - - note = self.ndb.get_note(key) - if not note: - self.log('ERROR: Key does not exist') - return - - category = utils.get_note_category(note) - return category - - def cli_note_category_set(self, key, category): - - note = self.ndb.get_note(key) - if not note: - self.log('Error: Key does not exist') - return - - self.ndb.set_note_category(key, category.lower()) - self.sync_notes() - - def cli_note_category_rm(self, key): - - note = self.ndb.get_note(key) - if not note: - self.log('Error: Key does not exist') - return - - old_category = self.cli_note_category_get(key) - if old_category: - self.cli_note_category_set(key, '') - -def SIGINT_handler(signum, frame): - print('\nSignal caught, bye!') - sys.exit(1) - -signal.signal(signal.SIGINT, SIGINT_handler) - -def usage(): - print (''' -Usage: - nncli [OPTIONS] [COMMAND] [COMMAND_ARGS] - - OPTIONS: - -h, --help - usage help - -v, --verbose - verbose output - -n, --nosync - don't perform a server sync - -r, --regex - search string is a regular expression - -k <key>, --key=<key> - note key - -t <title>, --title=<title> - title of note for create (cli mode) - -c <file>, --config=<file> - config file to read from - -V, --version - version information - - COMMANDS: - <none> - console gui mode when no command specified - sync - perform a full sync with the server - list [search_string] - list notes (refined with search string) - export [search_string] - export notes in JSON (refined with search - string) - dump [search_string] - dump notes (refined with search string) - create [-] - create a note ('-' content from stdin) - import [-] - import a note in JSON format ('-' JSON from - stdin) - export - export a note in JSON format (specified by - <key>) - dump - dump a note (specified by <key>) - edit - edit a note (specified by <key>) - delete - delete a note (specified by <key>) - < favorite | unfavorite > - favorite/unfavorite a note (specified by <key>) - cat get - retrieve the category from a note (specified - by <key>) - cat set <category> - set the category for a note (specified by <key>) - cat rm - remove category from a note (specified by <key>) -''') - sys.exit(0) - -def version(): - version_info = '' - version_info += nnotes_cli.__productname__ + ' v' + \ - nnotes_cli.__version__ + "\n" - version_info += nnotes_cli.__description__ + "\n\n" - version_info += nnotes_cli.__copyright__ + "\n" - version_info += "Written by " + nnotes_cli.__author__ + \ - " and others\n" - version_info += "Licensed under the terms of the " + \ - nnotes_cli.__license__ + " license\n" - version_info += "The latest code is available at: " + \ - nnotes_cli.__url__ - print(version_info) - exit(0) - -def main(argv=sys.argv[1:]): - verbose = False - sync = True - regex = False - key = None - title = None - config = None - - try: - opts, args = getopt.getopt(argv, - 'hvnrk:t:c:V', - [ 'help', 'verbose', 'nosync', 'regex', 'key=', 'title=', \ - 'config=', 'version' ]) - except: - usage() - - for opt, arg in opts: - if opt in [ '-h', '--help']: - usage() - elif opt in ['-V', '--version' ]: - version() - elif opt in [ '-v', '--verbose']: - verbose = True - elif opt in [ '-n', '--nosync']: - sync = False - elif opt in [ '-r', '--regex']: - regex = True - elif opt in [ '-k', '--key']: - try: - key = int(arg) - except: - print('ERROR: Key specified with -k must be an integer') - elif opt in [ '-t', '--title']: - title = arg - elif opt in [ '-c', '--config']: - config = arg - else: - print('ERROR: Unhandled option') - usage() - - if not args: - nncli(sync, verbose, config).gui(key) - return - - def nncli_start(sync=sync, verbose=verbose, config=config): - sn = nncli(sync, verbose, config) - if sync: sn.sync_notes() - return sn - - if args[0] == 'sync': - sn = nncli_start(True) - - elif args[0] == 'list': - - sn = nncli_start() - sn.cli_list_notes(regex, ' '.join(args[1:])) - - elif args[0] == 'dump': - - sn = nncli_start() - if key: - sn.cli_note_dump(key) - else: - sn.cli_dump_notes(regex, ' '.join(args[1:])) - - elif args[0] == 'create': - - if len(args) == 1: - sn = nncli_start() - sn.cli_note_create(False, title) - elif len(args) == 2 and args[1] == '-': - sn = nncli_start() - sn.cli_note_create(True, title) - else: - usage() - - elif args[0] == 'import': - - if len(args) == 1: - sn = nncli_start() - sn.cli_note_import(False) - elif len(args) == 2 and args[1] == '-': - sn = nncli_start() - sn.cli_note_import(True) - else: - usage() - - elif args[0] == 'export': - - sn = nncli_start() - if key: - sn.cli_note_export(key) - else: - sn.cli_export_notes(regex, ' '.join(args[1:])) - - elif args[0] == 'edit': - - if not key: - usage() - - sn = nncli_start() - sn.cli_note_edit(key) - - elif args[0] == 'delete': - - if not key: - usage() - - sn = nncli_start() - sn.cli_note_delete(key, True) - - elif args[0] == 'favorite' or args[0] == 'unfavorite': - - if not key: - usage() - - sn = nncli_start() - sn.cli_note_favorite(key, 1 if args[0] == 'favorite' else 0) - - # Category API - elif args[0] == 'cat': - - if not key: - usage() - - nargs = len(args) - correct_other = (args[1] in ['get', 'rm'] and nargs == 2) - correct_set = (args[1] == 'set' and nargs == 3) - if not (correct_set or correct_other): - usage() - - if args[1] == 'get': - - sn = nncli_start() - category = sn.cli_note_category_get(key) - if category: - print(category) - - elif args[1] == 'set': - - category = args[2] - sn = nncli_start() - sn.cli_note_category_set(key, category) - - elif args[1] == 'rm': - - sn = nncli_start() - sn.cli_note_category_rm(key) - - else: - usage() - diff --git a/nnotes_cli/notes_db.py b/nnotes_cli/notes_db.py @@ -1,649 +0,0 @@ -# -*- encoding: utf-8 -*- - -import os, time, re, glob, json, copy, threading -from . import utils -from . import nextcloud_note -nextcloud_note.NOTE_FETCH_LENGTH=100 -from .nextcloud_note import NextcloudNote -import logging - -class ReadError(RuntimeError): - pass - -class WriteError(RuntimeError): - pass - -class NotesDB(): - """NotesDB will take care of the local notes database and syncing with SN. - """ - def __init__(self, config, log, update_view): - self.config = config - self.log = log - self.update_view = update_view - - self.last_sync = 0 # set to zero to trigger a full sync - self.sync_lock = threading.Lock() - self.go_cond = threading.Condition() - - # create db dir if it does not exist - if not os.path.exists(self.config.get_config('db_path')): - os.mkdir(self.config.get_config('db_path')) - - now = int(time.time()) - # now read all .json files from disk - fnlist = glob.glob(self.helper_key_to_fname('*')) - - self.notes = {} - - for fn in fnlist: - try: - n = json.load(open(fn, 'r')) - except IOError as e: - raise ReadError ('Error opening {0}: {1}'.format(fn, str(e))) - 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['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: 'id' is used only for syncing with server - 'localkey' - # is used for everything else in nncli - n['localkey'] = localkey - - # add the note to our database - self.notes[localkey] = n - - # initialise the NextCloud instance we're going to use - # this does not yet need network access - self.note = NextcloudNote(self.config.get_config('nn_username'), - self.config.get_config('nn_password'), - self.config.get_config('nn_host')) - - # we'll use this to store which notes are currently being synced by - # the background thread, so we don't add them anew if they're still - # in progress. This variable is only used by the background thread. - self.threaded_syncing_keys = {} - - def filtered_notes_sort(self, filtered_notes, sort_mode='date'): - if sort_mode == 'date': - if self.config.get_config('favorite_ontop') == 'yes': - filtered_notes.sort(key=utils.sort_by_modify_date_favorite, reverse=True) - else: - filtered_notes.sort(key=lambda o: -float(o.note.get('modified', 0))) - elif sort_mode == 'alpha': - if self.config.get_config('favorite_ontop') == 'yes': - filtered_notes.sort(key=utils.sort_by_title_favorite) - else: - filtered_notes.sort(key=lambda o: utils.get_note_title(o.note)) - elif sort_mode == 'categories': - favorite = self.config.get_config('favorite_ontop') - utils.sort_notes_by_categories(filtered_notes, \ - favorite_ontop=favorite) - - def filter_notes(self, search_string=None, search_mode='gstyle', sort_mode='date'): - """Return list of notes filtered with search string. - - Based on the search mode that has been selected in self.config, - this method will call the appropriate helper method to do the - actual work of filtering the notes. - - Returns a list of filtered notes with selected search mode and sorted - according to configuration. Two more elements in tuple: a regular - expression that can be used for highlighting strings in the text widget - and the total number of notes in memory. - """ - - if search_mode == 'gstyle': - filtered_notes, match_regexp, active_notes = \ - self.filter_notes_gstyle(search_string) - else: - filtered_notes, match_regexp, active_notes = \ - self.filter_notes_regex(search_string) - - self.filtered_notes_sort(filtered_notes, sort_mode) - - return filtered_notes, match_regexp, active_notes - - def _helper_gstyle_categorymatch(self, cat_pats, note): - # Returns: - # 2 = match - no category patterns specified - # 1 = match - all category patterns match a category on this - # note - # 0 = no match - note has no category or not all category patterns match - - if not cat_pats: - # match because no category patterns were specified - return 2 - - note_category = note.get('category') - - if not note_category: - # category patterns specified but note has no categories, so no match - return 0 - - # for each cat_pat, we have to find a matching category - # .lower() used for case-insensitive search - cat_pats_matched = 0 - for tp in cat_pats: - tp = tp.lower() - for t in note_category: - if tp in t.lower(): - cat_pats_matched += 1 - break - - if cat_pats_matched == len(cat_pats): - # all category patterns specified matched a category on this note - return 1 - - # note doesn't match - return 0 - - def _helper_gstyle_wordmatch(self, word_pats, content): - if not word_pats: - return True - - word_pats_matched = 0 - lowercase_content = content.lower() # case insensitive search - for wp in word_pats: - wp = wp.lower() # case insensitive search - if wp in lowercase_content: - word_pats_matched += 1 - - if word_pats_matched == len(word_pats): - return True; - - return False - - def filter_notes_gstyle(self, search_string=None): - - filtered_notes = [] - active_notes = 0 - - if not search_string: - for k in self.notes: - n = self.notes[k] - active_notes += 1 - filtered_notes.append(utils.KeyValueObject(key=k, note=n, catfound=0)) - - return filtered_notes, [], active_notes - - # group0: category:([^\s]+) - # group1: multiple words in quotes - # group2: single words - - # example result for: 'category:category1 category:category2 word1 "word2 word3" category:category3' - # [ ('category1', '', ''), - # ('category2', '', ''), - # ('', '', 'word1'), - # ('', 'word2 word3', ''), - # ('category3', '', '') ] - - groups = re.findall('category:([^\s]+)|"([^"]+)"|([^\s]+)', search_string) - all_pats = [[] for _ in range(3)] - - # we end up with [[cat_pats],[multi_word_pats],[single_word_pats]] - for g in groups: - for i in range(3): - if g[i]: all_pats[i].append(g[i]) - - for k in self.notes: - n = self.notes[k] - - active_notes += 1 - - catmatch = self._helper_gstyle_categorymatch(all_pats[0], n) - - word_pats = all_pats[1] + all_pats[2] - - if catmatch and \ - self._helper_gstyle_wordmatch(word_pats, n.get('content')): - # we have a note that can go through! - filtered_notes.append( - utils.KeyValueObject(key=k, - note=n, - catfound=1 if catmatch == 1 else 0)) - - return filtered_notes, '|'.join(all_pats[1] + all_pats[2]), active_notes - - def filter_notes_regex(self, search_string=None): - """ - Return a list of notes filtered using the regex search_string. - Each element in the list is a tuple (local_key, note). - """ - sspat = utils.build_regex_search(search_string) - - filtered_notes = [] - active_notes = 0 # total number of notes, including deleted ones - - for k in self.notes: - n = self.notes[k] - - active_notes += 1 - - if not sspat: - filtered_notes.append(utils.KeyValueObject(key=k, note=n, catfound=0)) - continue - - if self.config.get_config('search_categories') == 'yes': - cat_matched = False - for t in n.get('category'): - if sspat.search(t): - cat_matched = True - filtered_notes.append(utils.KeyValueObject(key=k, note=n, catfound=1)) - break - if cat_matched: - continue - - if sspat.search(n.get('content')): - filtered_notes.append(utils.KeyValueObject(key=k, note=n, catfound=0)) - - match_regexp = search_string if sspat else '' - return filtered_notes, match_regexp, active_notes - - 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['id'] if note.get('id') else utils.generate_random_key() - while new_key in self.notes: - new_key = utils.generate_random_key() - - timestamp = int(time.time()) - - try: - modified = float(note.get('modified', 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', ''), - 'modified' : modified, - 'title' : note.get('title'), - 'category' : note.get('category') \ - if note.get('category') is not None \ - else '', - 'savedate' : 0, # never been written to disc - 'syncdate' : 0, # never been synced with server - 'favorite' : False, - 'deleted' : False - } - - # sanity check all note values - if not isinstance(new_note['content'], str): - raise ValueError('"content" must be a string') - - if not 0 <= new_note['modified'] <= timestamp: - raise ValueError('"modified" field must be real') - - if not isinstance(new_note['category'], str) or \ - new_note['category'] is None: - raise ValueError('"category" must be an string') - - if not isinstance(new_note['favorite'], bool): - raise ValueError('"favorite" must be a boolean') - - self.notes[new_key] = new_note - - return new_key - - def create_note(self, content): - # need to get a key unique to this database. not really important - # what it is, as long as it's unique. - new_key = utils.generate_random_key() - while new_key in self.notes: - new_key = utils.generate_random_key() - - timestamp = int(time.time()) - title = content.split('\n')[0] - - # note has no internal key yet. - new_note = { - 'localkey' : new_key, - 'content' : content, - 'modified' : timestamp, - 'category' : '', - 'savedate' : 0, # never been written to disc - 'syncdate' : 0, # never been synced with server - 'favorite' : False, - 'deleted' : False, - 'title' : title - } - - self.notes[new_key] = new_note - - return new_key - - def get_note(self, key): - return self.notes[key] - - def get_note_favorite(self, key): - return self.notes[key].get('favorite') - - def get_note_category(self, key): - return self.notes[key].get('category') - - def get_note_content(self, key): - return self.notes[key].get('content') - - def flag_what_changed(self, note, what_changed): - if 'what_changed' not in note: - note['what_changed'] = [] - if what_changed not in note['what_changed']: - note['what_changed'].append(what_changed) - - def set_note_deleted(self, key, deleted): - n = self.notes[key] - old_deleted = n['deleted'] if 'deleted' in n else 0 - if old_deleted != deleted: - n['deleted'] = deleted - n['modified'] = int(time.time()) - self.flag_what_changed(n, 'deleted') - self.log('Note marked for deletion (key={0})'.format(key)) - - def set_note_content(self, key, content): - n = self.notes[key] - old_content = n.get('content') - if content != old_content: - n['content'] = content - n['modified'] = int(time.time()) - self.flag_what_changed(n, 'content') - self.log('Note content updated (key={0})'.format(key)) - - def set_note_category(self, key, category): - n = self.notes[key] - old_category = n.get('category') - if category != old_category: - n['category'] = category - n['modified'] = int(time.time()) - self.flag_what_changed(n, 'category') - self.log('Note category updated (key={0})'.format(key)) - - def set_note_favorite(self, key, favorite): - n = self.notes[key] - old_favorite = utils.note_favorite(n) - if favorite != old_favorite: - n['favorite'] = favorite - n['modified'] = int(time.time()) - self.flag_what_changed(n, 'favorite') - self.log('Note {0} (key={1})'. \ - format('favorite' if favorite else \ - 'unfavorited', key)) - - def helper_key_to_fname(self, k): - 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. - fn = self.helper_key_to_fname(k) - json.dump(note, open(fn, 'w'), indent=2) - - # record that we saved this to disc. - note['savedate'] = int(time.time()) - - def sync_notes(self, server_sync=True, full_sync=True): - """Perform a full bi-directional sync with server. - - Psuedo-code algorithm for syncing: - - 1. for any note changed locally, including new notes: - save note to server, update note with response - (new title, modified, title, category, content, - favorite) - - 2. get all notes - - 3. for each remote note - if remote modified > local modified || - a new note and key is not in local store - retrieve note, update note with response - - 4. for each local note not in the index - PERMANENT DELETE, remove note from local store - """ - - local_updates = {} - local_deletes = {} - server_keys = {} - now = int(time.time()) - - sync_start_time = int(time.time()) - sync_errors = 0 - skip_remote_syncing = False - - if server_sync and full_sync: - self.log("Starting full sync") - - # 1. for any note changed locally, including new notes: - # save note to server, update note with response - for note_index, local_key in enumerate(self.notes.keys()): - n = self.notes[local_key] - - if not n.get('id') or \ - float(n.get('modified')) > float(n.get('syncdate')): - - savedate = float(n.get('savedate')) - 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 - local_updates[local_key] = True - - if not server_sync: - # the 'what_changed' field will be written to disk and - # picked up whenever the next full server sync occurs - continue - - # only send required fields - cn = copy.deepcopy(n) - if 'what_changed' in n: - del n['what_changed'] - - if 'localkey' in cn: - del cn['localkey'] - - if 'minversion' in cn: - del cn['minversion'] - del cn['syncdate'] - del cn['savedate'] - del cn['deleted'] - if 'etag' in cn: - del cn['etag'] - if 'title' in cn: - del cn['title'] - - if 'what_changed' in cn: - if 'content' not in cn['what_changed'] \ - and 'category' not in cn['what_changed']: - del cn['content'] - if 'category' not in cn['what_changed']: - del cn['category'] - if 'favorite' not in cn['what_changed']: - del cn['favorite'] - del cn['what_changed'] - - if 'favorite' in cn: - cn['favorite'] = str.lower(str(cn['favorite'])) - - if n['deleted']: - uret = self.note.delete_note(cn) - else: - uret = self.note.update_note(cn) - - if uret[1] == 0: # success - # if this is a new note our local key is not valid anymore - # 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('id') - t = uret[0].get('title') - c = uret[0].get('category') - c = c if c is not None else '' - n.update(uret[0]) - n['syncdate'] = now - n['localkey'] = k - n['category'] = c - self.notes[k] = n - - local_updates[k] = True - if local_key != k: - # if local_key was a different key it should be deleted - local_deletes[local_key] = True - if local_key in local_updates: - del local_updates[local_key] - - self.log('Synced note to server (key={0})'.format(local_key)) - else: - self.log('ERROR: Failed to sync note to server (key={0})'.format(local_key)) - sync_errors += 1 - - # 2. get the note index - if not server_sync: - nl = [] - else: - nl = self.note.get_note_list() - - if nl[1] == 0: # success - nl = nl[0] - else: - self.log('ERROR: Failed to get note list from server') - sync_errors += 1 - nl = [] - skip_remote_syncing = True - - # 3. for each remote note - # if remote modified > local modified || - # a new note and key is not in local store - # retrieve note, update note with response - if not skip_remote_syncing: - len_nl = len(nl) - for note_index, n in enumerate(nl): - k = n.get('id') - c = n.get('category') if n.get('category') is not None \ - else '' - 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 - if k in self.notes: - # we already have this note - # if the server note has a newer syncnum we need to get it - if int(n.get('modified')) > int(self.notes[k].get('modified')): - gret = self.note.get_note(k) - if gret[1] == 0: - self.notes[k].update(gret[0]) - local_updates[k] = True - self.notes[k]['syncdate'] = now - self.notes[k]['localkey'] = k - self.notes[k]['category'] = c - self.notes[k]['deleted'] = False - - self.log('Synced newer note from server (key={0})'.format(k)) - else: - self.log('ERROR: Failed to sync newer note from server (key={0})'.format(k)) - sync_errors += 1 - else: - # this is a new note - gret = self.note.get_note(k) - if gret[1] == 0: - self.notes[k] = gret[0] - local_updates[k] = True - self.notes[k]['syncdate'] = now - self.notes[k]['localkey'] = k - self.notes[k]['category'] = c - self.notes[k]['deleted'] = False - - self.log('Synced new note from server (key={0})'.format(k)) - else: - self.log('ERROR: Failed syncing new note from server (key={0})'.format(k)) - sync_errors += 1 - - # 4. for each local note not in the index - # PERMANENT DELETE, remove note from local store - # Only do this when a full sync (i.e. entire index) is performed! - if server_sync and full_sync and not skip_remote_syncing: - for local_key in list(self.notes.keys()): - if local_key not in server_keys: - del self.notes[local_key] - local_deletes[local_key] = True - - # sync done, now write changes to db_path - - for k in list(local_updates.keys()): - try: - self.helper_save_note(k, self.notes[k]) - except WriteError as e: - raise WriteError (str(e)) - self.log("Saved note to disk (key={0})".format(k)) - - for k in list(local_deletes.keys()): - fn = self.helper_key_to_fname(k) - if os.path.exists(fn): - os.unlink(fn) - self.log("Deleted note from disk (key={0})".format(k)) - - if not sync_errors: - self.last_sync = sync_start_time - - # if there were any changes then update the current view - if len(local_updates) > 0 or len(local_deletes) > 0: - self.update_view() - - if server_sync and full_sync: - self.log("Full sync completed") - - return sync_errors - - def get_note_status(self, key): - n = self.notes[key] - o = utils.KeyValueObject(saved=False, synced=False, modified=False) - modified = float(n['modified']) - savedate = float(n['savedate']) - syncdate = float(n['syncdate']) - - if savedate > modified: - o.saved = True - else: - o.modified = True - - if syncdate > modified: - o.synced = True - - return o - - def verify_all_saved(self): - all_saved = True - self.sync_lock.acquire() - for k in list(self.notes.keys()): - o = self.get_note_status(k) - if not o.saved: - all_saved = False - break - self.sync_lock.release() - return all_saved - - def sync_now(self, do_server_sync=True): - self.sync_lock.acquire() - self.sync_notes(server_sync=do_server_sync, - full_sync=True if not self.last_sync else False) - self.sync_lock.release() - - # sync worker thread... - def sync_worker(self, do_server_sync): - time.sleep(1) # give some time to wait for GUI initialization - self.log('Sync worker: started') - self.sync_now(do_server_sync) - while True: - self.go_cond.acquire() - self.go_cond.wait(15) - self.sync_now(do_server_sync) - self.go_cond.release() - - def sync_worker_go(self): - self.go_cond.acquire() - self.go_cond.notify() - self.go_cond.release() - diff --git a/nnotes_cli/user_input.py b/nnotes_cli/user_input.py @@ -1,24 +0,0 @@ -# -*- encoding: utf-8 -*- - -import urwid - -class UserInput(urwid.Edit): - - def __init__(self, config, caption, edit_text, callback_func, args): - self.config = config - self.callback_func = callback_func - self.callback_func_args = args - super(UserInput, self).__init__(caption=caption, - edit_text=edit_text, - wrap='clip') - - def keypress(self, size, key): - size = (size[0],) # if this isn't here then urwid freaks out... - if key == 'esc': - self.callback_func(self.callback_func_args, None) - elif key == 'enter': - self.callback_func(self.callback_func_args, self.edit_text) - else: - return super(UserInput, self).keypress(size, key) - return None - diff --git a/nnotes_cli/view_help.py b/nnotes_cli/view_help.py @@ -1,126 +0,0 @@ -# -*- encoding: utf-8 -*- - -import re, urwid - -class ViewHelp(urwid.ListBox): - - def __init__(self, config): - self.config = config - - self.descr_width = 26 - self.config_width = 29 - - lines = [] - lines.extend(self.create_kb_help_lines('Keybinds Common', 'common')) - lines.extend(self.create_kb_help_lines('Keybinds Note List', 'titles')) - lines.extend(self.create_kb_help_lines('Keybinds Note Content', 'notes')) - lines.extend(self.create_config_help_lines()) - lines.extend(self.create_color_help_lines()) - lines.append(urwid.Text(('help_header', ''))) - - super(ViewHelp, self).__init__(urwid.SimpleFocusListWalker(lines)) - - def get_status_bar(self): - cur = -1 - total = 0 - if len(self.body.positions()) > 0: - cur = self.focus_position - total = len(self.body.positions()) - - status_title = \ - urwid.AttrMap(urwid.Text('Help', - wrap='clip'), - 'status_bar') - status_index = \ - ('pack', urwid.AttrMap(urwid.Text(' ' + - str(cur + 1) + - '/' + - str(total)), - 'status_bar')) - return \ - urwid.AttrMap(urwid.Columns([ status_title, status_index ]), - 'status_bar') - - def create_kb_help_lines(self, header, use): - lines = [ urwid.AttrMap(urwid.Text(''), - 'help_header', - 'help_focus') ] - lines.append(urwid.AttrMap(urwid.Text(' ' + header), - 'help_header', - 'help_focus')) - for c in self.config.keybinds: - if use not in self.config.get_keybind_use(c): - continue - lines.append( - urwid.AttrMap(urwid.AttrMap( - urwid.Text( - [ - ('help_descr', ('{:>' + str(self.descr_width) + '} ').format(self.config.get_keybind_descr(c))), - ('help_config', ('{:>' + str(self.config_width) + '} ').format('kb_' + c)), - ('help_value', "'" + self.config.get_keybind(c) + "'") - ] - ), - attr_map = None, - focus_map = { - 'help_value' : 'help_focus', - 'help_config' : 'help_focus', - 'help_descr' : 'help_focus' - } - ), 'default', 'help_focus')) - return lines - - def create_config_help_lines(self): - lines = [ urwid.AttrMap(urwid.Text(''), - 'help_header', - 'help_focus') ] - lines.append(urwid.AttrMap(urwid.Text(' Configuration'), - 'help_header', - 'help_focus')) - for c in self.config.configs: - if c in [ 'sn_username', 'sn_password' ]: continue - lines.append( - urwid.AttrMap(urwid.AttrMap( - urwid.Text( - [ - ('help_descr', ('{:>' + str(self.descr_width) + '} ').format(self.config.get_config_descr(c))), - ('help_config', ('{:>' + str(self.config_width) + '} ').format('cfg_' + c)), - ('help_value', "'" + self.config.get_config(c) + "'") - ] - ), - attr_map = None, - focus_map = { - 'help_value' : 'help_focus', - 'help_config' : 'help_focus', - 'help_descr' : 'help_focus' - } - ), 'default', 'help_focus')) - return lines - - def create_color_help_lines(self): - lines = [ urwid.AttrMap(urwid.Text(''), - 'help_header', - 'help_focus') ] - lines.append(urwid.AttrMap(urwid.Text(' Colors'), - 'help_header', - 'help_focus')) - fmap = {} - for c in self.config.colors: - fmap[re.search('^(.*)(_fg|_bg)$', c).group(1)] = 'help_focus' - for c in self.config.colors: - lines.append( - urwid.AttrMap(urwid.AttrMap( - urwid.Text( - [ - ('help_descr', ('{:>' + str(self.descr_width) + '} ').format(self.config.get_color_descr(c))), - ('help_config', ('{:>' + str(self.config_width) + '} ').format('clr_' + c)), - (re.search('^(.*)(_fg|_bg)$', c).group(1), "'" + self.config.get_color(c) + "'") - ] - ), - attr_map = None, - focus_map = fmap - ), 'default', 'help_focus')) - return lines - - def keypress(self, size, key): - return key - diff --git a/nnotes_cli/view_log.py b/nnotes_cli/view_log.py @@ -1,48 +0,0 @@ -# -*- encoding: utf-8 -*- - -import urwid - -class ViewLog(urwid.ListBox): - - def __init__(self, config): - self.config = config - super(ViewLog, self).__init__(urwid.SimpleFocusListWalker([])) - - def update_log(self): - lines = [] - f = open(self.config.logfile) - for line in f: - lines.append( - urwid.AttrMap(urwid.Text(line.rstrip()), - 'note_content', - 'note_content_focus')) - f.close() - if self.config.get_config('log_reversed') == 'yes': - lines.reverse() - self.body[:] = urwid.SimpleFocusListWalker(lines) - self.focus_position = 0 - - def get_status_bar(self): - cur = -1 - total = 0 - if len(self.body.positions()) > 0: - cur = self.focus_position - total = len(self.body.positions()) - - status_title = \ - urwid.AttrMap(urwid.Text('Sync Log', - wrap='clip'), - 'status_bar') - status_index = \ - ('pack', urwid.AttrMap(urwid.Text(' ' + - str(cur + 1) + - '/' + - str(total)), - 'status_bar')) - return \ - urwid.AttrMap(urwid.Columns([ status_title, status_index ]), - 'status_bar') - - def keypress(self, size, key): - return key - diff --git a/nnotes_cli/view_titles.py b/nnotes_cli/view_titles.py @@ -1,190 +0,0 @@ -# encoding: utf-8 -*- - -import re, time, datetime, urwid, subprocess -from . import utils, view_note - -class ViewTitles(urwid.ListBox): - - def __init__(self, config, args): - self.config = config - self.ndb = args['ndb'] - self.search_string = args['search_string'] - self.log = args['log'] - self.note_list, self.match_regex, self.all_notes_cnt = \ - self.ndb.filter_notes(self.search_string, sort_mode=self.config.get_config('sort_mode')) - super(ViewTitles, self).__init__( - urwid.SimpleFocusListWalker(self.get_note_titles())) - - def update_note_list(self, search_string, search_mode='gstyle', sort_mode='date'): - self.search_string = search_string - self.note_list, self.match_regex, self.all_notes_cnt = \ - self.ndb.filter_notes(self.search_string, search_mode, sort_mode=sort_mode) - self.body[:] = \ - urwid.SimpleFocusListWalker(self.get_note_titles()) - if len(self.note_list) == 0: - self.log('No notes found!') - else: - self.focus_position = 0 - - def sort_note_list(self, sort_mode): - self.ndb.filtered_notes_sort(self.note_list, sort_mode) - self.body[:] = \ - urwid.SimpleFocusListWalker(self.get_note_titles()) - - def format_title(self, note): - """ - Various formatting tags are supported for dynamically building - the title string. Each of these formatting tags supports a width - specifier (decimal) and a left justification (-) like that - supported by printf. - - %F -- flags - %T -- category - %D -- date - %N -- note title - """ - - 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) - category = utils.get_note_category(note) - - # get the age of the note - dt = datetime.datetime.fromtimestamp(time.mktime(t)) - if dt > datetime.datetime.now() - datetime.timedelta(days=1): - note_age = 'd' # less than a day old - elif dt > datetime.datetime.now() - datetime.timedelta(weeks=1): - note_age = 'w' # less than a week old - elif dt > datetime.datetime.now() - datetime.timedelta(weeks=4): - note_age = 'm' # less than a month old - elif dt > datetime.datetime.now() - datetime.timedelta(weeks=52): - note_age = 'y' # less than a year old - else: - note_age = 'a' # ancient - - def recursive_format(title_format): - if not title_format: - return None - fmt = re.search("^(.*)%([-]*)([0-9]*)([FDTN])(.*)$", title_format) - if not fmt: - m = ('pack', urwid.AttrMap(urwid.Text(title_format), - 'default')) - l_fmt = None - r_fmt = None - else: - l = fmt.group(1) if fmt.group(1) else None - m = None - r = fmt.group(5) if fmt.group(5) else None - align = 'left' if fmt.group(2) == '-' else 'right' - width = int(fmt.group(3)) if fmt.group(3) else 'pack' - if fmt.group(4) == 'F': - m = (width, urwid.AttrMap(urwid.Text(flags, - align=align, - wrap='clip'), - 'note_flags')) - elif fmt.group(4) == 'D': - m = (width, urwid.AttrMap(urwid.Text(mod_time, - align=align, - wrap='clip'), - 'note_date')) - elif fmt.group(4) == 'T': - m = (width, urwid.AttrMap(urwid.Text(category, - align=align, - wrap='clip'), - 'note_category')) - elif fmt.group(4) == 'N': - if note_age == 'd': attr = 'note_title_day' - elif note_age == 'w': attr = 'note_title_week' - elif note_age == 'm': attr = 'note_title_month' - elif note_age == 'y': attr = 'note_title_year' - elif note_age == 'a': attr = 'note_title_ancient' - if width != 'pack': - m = (width, urwid.AttrMap(urwid.Text(title, - align=align, - wrap='clip'), - attr)) - else: - m = urwid.AttrMap(urwid.Text(title, - align=align, - wrap='clip'), - attr) - l_fmt = recursive_format(l) - r_fmt = recursive_format(r) - - tmp = [] - if l_fmt: tmp.extend(l_fmt) - tmp.append(m) - if r_fmt: tmp.extend(r_fmt) - return tmp - - # convert the format string into the actual note title line - title_line = recursive_format(self.config.get_config('format_note_title')) - return urwid.Columns(title_line) - - def get_note_title(self, note): - return urwid.AttrMap(self.format_title(note), - 'default', - { 'default' : 'note_focus', - 'note_title_day' : 'note_focus', - 'note_title_week' : 'note_focus', - 'note_title_month' : 'note_focus', - 'note_title_year' : 'note_focus', - 'note_title_ancient' : 'note_focus', - 'note_date' : 'note_focus', - 'note_flags' : 'note_focus', - 'note_categories' : 'note_focus' }) - - def get_note_titles(self): - lines = [] - for n in self.note_list: - lines.append(self.get_note_title(n.note)) - return lines - - def get_status_bar(self): - cur = -1 - total = 0 - if len(self.body.positions()) > 0: - cur = self.focus_position - total = len(self.body.positions()) - - hdr = 'NextCloud Notes' - - # include connection status in header - hdr += ' (' + self.ndb.note.status + ')' - - if self.search_string != None: - hdr += ' - Search: ' + self.search_string - - status_title = \ - urwid.AttrMap(urwid.Text(hdr, - wrap='clip'), - 'status_bar') - status_index = \ - ('pack', urwid.AttrMap(urwid.Text(' ' + - str(cur + 1) + - '/' + - str(total)), - 'status_bar')) - return \ - urwid.AttrMap(urwid.Columns([ status_title, status_index ]), - 'status_bar') - - def update_note_title(self, key=None): - if not key: - self.body[self.focus_position] = \ - self.get_note_title(self.note_list[self.focus_position].note) - else: - for i in range(len(self.note_list)): - if self.note_list[i].note['localkey'] == key: - self.body[i] = self.get_note_title(self.note_list[i].note) - - def focus_note(self, key): - for i in range(len(self.note_list)): - if 'localkey' in self.note_list[i].note and \ - self.note_list[i].note['localkey'] == key: - self.focus_position = i - - def keypress(self, size, key): - return key - diff --git a/pyproject.toml b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["flit"] +build-backend = "flit.buildapi" + +[tool.flit.metadata] +module = "nncli" +author = "Daniel Moch" +author-email = "daniel@danielmoch.com" +home-page = "https://github.com/djmoch/nncli" +description-file = "README.rst" +requires = ["urwid", "requests", "appdirs"] +classifiers = ["License :: OSI Approved :: MIT License", + "Development Status :: 4 - Beta", + "Environment :: Console :: Curses", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3 :: Only"] +requires-python = ">=3" + +[tool.flit.metadata.requires-extra] +dev = ["pipenv"] + +[tool.flit.scripts] +nncli = "nncli.nncli:main" diff --git a/pytest.ini b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +mock_use_standalone_module = true diff --git a/setup.cfg b/setup.cfg @@ -1,5 +0,0 @@ -[aliases] -test=pytest - -[tool:pytest] -testpaths = tests diff --git a/setup.py b/setup.py @@ -1,40 +0,0 @@ -# -*- encoding: utf-8 -*- - -from setuptools import setup -import nnotes_cli - -deps = ['urwid', 'requests', 'appdirs'] -test_deps = ['pytest', 'pytest-cov', 'pytest-runner', 'pytest-mock'] - -with open("README.rst", "r") as fh: - long_description = fh.read() - -setup( - name=nnotes_cli.__productname__, - description=nnotes_cli.__description__, - long_description=long_description, - long_description_content_type="text/x-rst", - author=nnotes_cli.__author__, - author_email=nnotes_cli.__author_email__, - url=nnotes_cli.__url__, - license=nnotes_cli.__license__, - requires=deps, - install_requires=deps, - tests_require=test_deps, - use_scm_version= {'write_to': 'nnotes_cli/version.py'}, - setup_requires=['setuptools_scm'], - extras_require={'docs': ['sphinx']}, - packages=['nnotes_cli'], - entry_points={ - 'console_scripts': [ - 'nncli = nnotes_cli.nncli:main' - ] - }, - classifiers=[ - 'Development Status :: 4 - Beta', - 'Environment :: Console :: Curses', - 'Intended Audience :: End Users/Desktop', - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3 :: Only', - ], -) diff --git a/tests/test_config.py b/tests/test_config.py @@ -24,7 +24,7 @@ import os import sys -from nnotes_cli.config import Config +from nncli.config import Config from pytest import raises def test_init(): diff --git a/tests/test_nncli.py b/tests/test_nncli.py @@ -1,38 +1,17 @@ -# -# 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. -# +# -*- encoding: utf-8 -*- + import logging import os import pytest import shutil from logging.handlers import RotatingFileHandler -from nnotes_cli.nncli import nncli +from nncli.nncli import nncli def mock_nncli(mocker): mocker.patch('logging.getLogger') - mocker.patch('nnotes_cli.config.Config') - mocker.patch('nnotes_cli.notes_db.NotesDB') + mocker.patch('nncli.config.Config') + mocker.patch('nncli.notes_db.NotesDB') mocker.patch('os.mkdir') mocker.patch.object(RotatingFileHandler, '_open') diff --git a/tests/test_version.py b/tests/test_version.py @@ -1,35 +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. -# -import nnotes_cli -import pytest - -from nnotes_cli import version -from setuptools_scm import get_version - -@pytest.mark.skip(reason="test_version will fail outside of a Git repo") -def test_version(): - vers = get_version(root="..", relative_to=__file__) - - assert nnotes_cli.__version__ == vers - assert version.version == vers diff --git a/tox.ini b/tox.ini @@ -0,0 +1,29 @@ +[tox] +envlist = py34, py35, py36, py37, pylint, coverage +skipsdist = True + +[testenv:pylint] +deps = pylint +whitelist_externals = make +commands = make lint + +[testenv:coverage] +deps = pipenv +setenv = + PIPENV_NO_INHERIT = 1 + PIPENV_HIDE_EMOJIS = 1 +whitelist_externals = make +commands = + make test-install + make coverage + +[testenv] +deps = pipenv +setenv = + PIPENV_NO_INHERIT = 1 + PIPENV_HIDE_EMOJIS = 1 +whitelist_externals = make +commands = + make test-install + make install + make test