nncli

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

commit 5eda83ae4d474931f996a745deb0f26acea4bcb9
parent 64c35e8ee5294b268e789b5b64737d2cff3fff49
Author: Daniel Moch <daniel@danielmoch.com>
Date:   Sat,  8 Sep 2018 06:31:06 -0400

Address pylint findings in nncli.py

Ref #10

Diffstat:
Mnncli/config.py | 12+++++++++++-
Anncli/gui.py | 911+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anncli/log.py | 40++++++++++++++++++++++++++++++++++++++++
Mnncli/nncli.py | 1019+++++++++----------------------------------------------------------------------
Mnncli/notes_db.py | 9+++++++--
Mnncli/utils.py | 79++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mnncli/view_help.py | 4++--
Mtests/test_nncli.py | 42+++---------------------------------------
8 files changed, 1160 insertions(+), 956 deletions(-)

diff --git a/nncli/config.py b/nncli/config.py @@ -10,7 +10,16 @@ class Config: """A class to contain all configuration data for nncli""" + class State: + """A container class for state information""" + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + def __init__(self, custom_file=None): + self.state = Config.State(do_server_sync=True, + verbose=False, + do_gui=False, + search_direction=None) self.config_home = user_config_dir('nncli', 'djmoch') self.cache_home = user_cache_dir('nncli', 'djmoch') @@ -589,7 +598,8 @@ def _create_configs_dict(self, parser, cfg_sec): [parser.get(cfg_sec, 'cfg_log_reversed'), 'Log file reversed'] self.configs['tempdir'] = \ [ - parser.get(cfg_sec, 'cfg_tempdir'), + None if parser.get(cfg_sec, 'cfg_tempdir') == '' \ + else parser.get(cfg_sec, 'cfg_tempdir'), 'Temporary directory for note storage' ] diff --git a/nncli/gui.py b/nncli/gui.py @@ -0,0 +1,911 @@ +# -*- coding: utf-8 -*- +"""nncli_gui module""" +import hashlib +import subprocess +import threading + +import urwid +from . import view_titles, view_note, view_help, view_log, user_input +from .utils import exec_cmd_on_note, get_pager + +# pylint: disable=too-many-instance-attributes, unused-argument +class NncliGui: + """NncliGui class. Responsible for the console GUI view logic.""" + def __init__(self, config, logger, ndb, key=None): + self.ndb = ndb + self.logger = logger + self.config = config + self.last_view = [] + self.status_bar = self.config.get_config('status_bar') + + + self.log_lock = threading.Lock() + self.log_alarms = 0 + self.logs = [] + + self.thread_sync = threading.Thread( + target=self.ndb.sync_worker, + args=[self.config.state.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) + + def run(self): + """Run the GUI""" + self.nncli_loop.run() + + def gui_header_clear(self): + """Clear the console GUI header row""" + self.master_frame.contents['header'] = (None, None) + self.nncli_loop.draw_screen() + + def gui_header_set(self, widget): + """Set the content of the console GUI header row""" + self.master_frame.contents['header'] = (widget, None) + self.nncli_loop.draw_screen() + + def gui_footer_log_clear(self): + """Clear the log at the bottom of the GUI""" + gui = self.gui_footer_input_get() + self.master_frame.contents['footer'] = \ + (urwid.Pile([urwid.Pile([]), urwid.Pile([gui])]), None) + self.nncli_loop.draw_screen() + + def gui_footer_log_set(self, pile): + """Set the log at the bottom of the GUI""" + gui = self.gui_footer_input_get() + self.master_frame.contents['footer'] = \ + (urwid.Pile([urwid.Pile(pile), urwid.Pile([gui])]), None) + self.nncli_loop.draw_screen() + + def gui_footer_log_get(self): + """Get the log at the bottom of the GUI""" + return self.master_frame.contents['footer'][0].contents[0][0] + + def gui_footer_input_clear(self): + """Clear the input at the bottom of the GUI""" + pile = self.gui_footer_log_get() + self.master_frame.contents['footer'] = \ + (urwid.Pile([urwid.Pile([pile]), urwid.Pile([])]), None) + self.nncli_loop.draw_screen() + + def gui_footer_input_set(self, gui): + """Set the input at the bottom of the GUI""" + pile = self.gui_footer_log_get() + self.master_frame.contents['footer'] = \ + (urwid.Pile([urwid.Pile([pile]), urwid.Pile([gui])]), None) + self.nncli_loop.draw_screen() + + def gui_footer_input_get(self): + """Get the input at the bottom of the GUI""" + return self.master_frame.contents['footer'][0].contents[1][0] + + def gui_footer_focus_input(self): + """Set the GUI focus to the input at the bottom of the GUI""" + self.master_frame.focus_position = 'footer' + self.master_frame.contents['footer'][0].focus_position = 1 + + def gui_body_set(self, widget): + """Set the GUI body""" + self.master_frame.contents['body'] = (widget, None) + self.gui_update_status_bar() + self.nncli_loop.draw_screen() + + def gui_body_get(self): + """Get the GUI body""" + return self.master_frame.contents['body'][0] + + def gui_body_focus(self): + """Set the GUI focus to the body""" + self.master_frame.focus_position = 'body' + + def gui_update_view(self): + """Update the GUI""" + if not self.config.state.do_gui: + return + + try: + cur_key = self.view_titles.note_list \ + [self.view_titles.focus_position].note['localkey'] + except IndexError: + cur_key = None + + self.view_titles.update_note_list( + self.view_titles.search_string, + sort_mode=self.config.state.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): + """Update the GUI status bar""" + 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): + """ + Switch the body frame of the GUI. Used to switch to a new + view + """ + if new_view is None: + if not self.last_view: + 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): + """Update the GUI after deleting a note""" + if not delete: + return + 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): + """Create a yes/no input dialog at the GUI footer""" + 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): + """Create a search input dialog at the GUI footer""" + 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.config.state.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.config.state.current_sort_mode + ) + self.gui_body_set(self.view_titles) + + def gui_category_input(self, args, category): + """Create a category input at the GUI footer""" + self.gui_footer_input_clear() + self.gui_body_focus() + self.master_frame.keypress = self.gui_frame_keypress + if category is not 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): + """Create a pipe input dialog at the GUI footoer""" + self.gui_footer_input_clear() + self.gui_body_focus() + self.master_frame.keypress = self.gui_frame_keypress + if cmd is not 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 + 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 ex: + self.log('Pipe error: %s' % ex) + finally: + self.gui_reset() + + # pylint: disable=too-many-return-statements, too-many-branches + # pylint: disable=too-many-statements + def gui_frame_keypress(self, size, key): + """Keypress handler for the GUI""" + # convert space character into name + if key == ' ': + key = 'space' + + contents = 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 not contents.body.positions(): + return None + last = len(contents.body.positions()) + if contents.focus_position == (last - 1): + return None + contents.focus_position += 1 + contents.render(size) + + elif key == self.config.get_keybind('up'): + if not contents.body.positions(): + return None + if contents.focus_position == 0: + return None + contents.focus_position -= 1 + contents.render(size) + + elif key == self.config.get_keybind('page_down'): + if not contents.body.positions(): + return None + last = len(contents.body.positions()) + next_focus = contents.focus_position + size[1] + if next_focus >= last: + next_focus = last - 1 + contents.change_focus(size, next_focus, + offset_inset=0, + coming_from='above') + + elif key == self.config.get_keybind('page_up'): + if not contents.body.positions(): + return None + if 'bottom' in contents.ends_visible(size): + last = len(contents.body.positions()) + next_focus = last - size[1] - size[1] + else: + next_focus = contents.focus_position - size[1] + if next_focus < 0: + next_focus = 0 + contents.change_focus(size, next_focus, + offset_inset=0, + coming_from='below') + + elif key == self.config.get_keybind('half_page_down'): + if not contents.body.positions(): + return None + last = len(contents.body.positions()) + next_focus = contents.focus_position + (size[1] // 2) + if next_focus >= last: + next_focus = last - 1 + contents.change_focus(size, next_focus, + offset_inset=0, + coming_from='above') + + elif key == self.config.get_keybind('half_page_up'): + if not contents.body.positions(): + return None + if 'bottom' in contents.ends_visible(size): + last = len(contents.body.positions()) + next_focus = last - size[1] - (size[1] // 2) + else: + next_focus = contents.focus_position - (size[1] // 2) + if next_focus < 0: + next_focus = 0 + contents.change_focus(size, next_focus, + offset_inset=0, + coming_from='below') + + elif key == self.config.get_keybind('bottom'): + if not contents.body.positions(): + return None + contents.change_focus(size, (len(contents.body.positions()) - 1), + offset_inset=0, + coming_from='above') + + elif key == self.config.get_keybind('top'): + if not contents.body.positions(): + return None + contents.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 not self.view_titles.body.positions(): + 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 + contents.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 not self.view_titles.body.positions(): + return None + if self.view_titles.focus_position == 0: + return None + self.view_titles.focus_position -= 1 + contents.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 = exec_cmd_on_note(None, self.config, self, self.logger) + 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 not contents.body.positions(): + return None + note = contents.note_list[contents.focus_position].note + else: # self.gui_body_get().__class__ == view_note.ViewNote: + if key == self.config.get_keybind('edit_note'): + note = contents.note + else: + note = contents.old_note if contents.old_note \ + else contents.note + + self.gui_clear() + if key == self.config.get_keybind('edit_note'): + content = exec_cmd_on_note(note, self.config, self, + self.logger) + elif key == self.config.get_keybind('view_note_ext'): + content = exec_cmd_on_note( + note, + self.config, + self, + self.logger, + cmd=get_pager(self.config, self.logger)) + else: # key == self.config.get_keybind('view_note_json') + content = exec_cmd_on_note( + note, + self.config, + self, + self.logger, + cmd=get_pager(self.config, self.logger), + 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: + contents.update_note_title() + else: # self.gui_body_get().__class__ == view_note.ViewNote: + contents.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 not contents.body.positions(): + return None + self.view_note.update_note_view( + contents.note_list[contents.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 not contents.body.positions(): + return None + note = contents.note_list[contents.focus_position].note + else: # self.gui_body_get().__class__ == view_note.ViewNote: + note = contents.old_note if contents.old_note else contents.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 not contents.body.positions(): + return None + note = contents.note_list[contents.focus_position].note + else: # self.gui_body_get().__class__ == view_note.ViewNote: + note = contents.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 not contents.body.positions(): + return None + note = contents.note_list[contents.focus_position].note + else: # self.gui_body_get().__class__ == view_note.ViewNote: + note = contents.note + + favorite = not note['favorite'] + + self.ndb.set_note_favorite(note['localkey'], favorite) + + if self.gui_body_get().__class__ == view_titles.ViewTitles: + contents.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 not contents.body.positions(): + return None + note = contents.note_list[contents.focus_position].note + else: # self.gui_body_get().__class__ == view_note.ViewNote: + note = contents.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.config.state.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.config.state.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.config.state.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.config.state.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 contents.keypress(size, key) + + self.gui_update_status_bar() + return None + + def gui_init_view(self, loop, show_note): + """Initialize the GUI""" + self.master_frame.keypress = self.gui_frame_keypress + self.gui_body_set(self.view_titles) + + if show_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): + """Clear the GUI""" + self.nncli_loop.widget = urwid.Filler(urwid.Text('')) + self.nncli_loop.draw_screen() + + def gui_reset(self): + """Reset the GUI""" + self.nncli_loop.widget = self.master_frame + self.nncli_loop.draw_screen() + + def gui_stop(self): + """Stop the GUI""" + # 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('WARNING: Not all notes saved' + 'to disk (wait for sync worker)') + + def log(self, msg): + """Log as message, displaying to the user as appropriate""" + self.logger.log(msg) + + 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 log in self.logs: + log_pile.append(urwid.AttrMap(urwid.Text(log), 'log')) + + if self.config.state.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 log_timeout(self, loop, arg): + """ + Run periodically to check for new log entries to append to + the GUI footer + """ + 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 not self.logs: + self.logs.pop(0) + + log_pile = [] + + for log in self.logs: + log_pile.append(urwid.AttrMap(urwid.Text(log), 'log')) + + if self.config.state.verbose: + self.gui_footer_log_set(log_pile) + + self.log_lock.release() diff --git a/nncli/log.py b/nncli/log.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +"""log module""" +import logging +from logging.handlers import RotatingFileHandler + +import os + +# pylint: disable=unused-argument +class Logger: + """Handles logging for the application""" + def __init__(self, config): + self.config = config + self.logfile = os.path.join( + 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) + + logging.debug('nncli logging initialized') + + def log(self, msg): + """Log as message, displaying to the user as appropriate""" + logging.debug(msg) + + if not self.config.state.do_gui: + if self.config.state.verbose: + print(msg) diff --git a/nncli/nncli.py b/nncli/nncli.py @@ -1,980 +1,175 @@ # -*- coding: utf-8 -*- - -import os, sys, getopt, re, signal, time, datetime, shlex, hashlib -import subprocess, threading, logging -import copy, json, urwid, datetime -from . import view_titles, view_note, view_help, view_log, user_input -from . import utils, temp, __version__ +"""nncli module""" +import hashlib +import json +import os +import signal +import sys +import time + +from . import utils, __version__ from .config import Config -from .nextcloud_note import NextcloudNote +from .gui import NncliGui +from .log import Logger from .notes_db import NotesDB, ReadError, WriteError -from logging.handlers import RotatingFileHandler +from .utils import exec_cmd_on_note +# pylint: disable=unused-argument class Nncli: - + """Nncli class. Responsible for most of the application logic""" 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 + self.config = Config(config_file) + self.config.state.do_server_sync = do_server_sync + self.config.state.verbose = verbose + force_full_sync = False 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 = [] + self.logger = Logger(self.config) try: - self.ndb = NotesDB(self.config, self.log, self.gui_update_view) - except Exception as e: - self.log(str(e)) + self.ndb = NotesDB( + self.config, + self.logger.log + ) + except (ReadError, WriteError) as ex: + self.logger.log(str(ex)) sys.exit(1) + self.nncli_gui = NncliGui(self.config, self.logger, self.ndb) + self.ndb.set_update_view(self.nncli_gui.gui_update_view) + 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.config.state.verbose = True + self.logger.log('nncli database doesn\'t exist,' + ' forcing full sync...') self.sync_notes() - self.verbose = verbose + self.config.state.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 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) - - if self.do_gui: - self.nncli_loop.screen.clear() - self.nncli_loop.draw_screen() - - return content - - 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_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_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)') + """Sync notes with the server""" + self.ndb.sync_now(self.config.state.do_server_sync) 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() + """Method to initialize and display the GUI""" + self.config.state.do_gui = True + self.ndb.log = self.nncli_gui.log + self.nncli_gui.run() def cli_list_notes(self, regex, search_string): - - note_list, match_regex, all_notes_cnt = \ + """List the notes on the command line""" + note_list, _, _ = \ 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) + \ + for nnote in note_list: + flags = utils.get_note_flags(nnote.note) + print((str(nnote.key) + \ ' [' + flags + '] ' + \ - utils.get_note_title(n.note))) + utils.get_note_title(nnote.note))) def cli_note_dump(self, key): - + """Dump a note to the command line""" note = self.ndb.get_note(key) if not note: - self.log('ERROR: Key does not exist') + self.logger.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) + width = 60 + sep = '+' + '-' * (width + 2) + '+' + localtime = time.localtime(float(note['modified'])) + mod_time = time.strftime('%a, %d %b %Y %H:%M:%S', localtime) title = utils.get_note_title(note) flags = utils.get_note_flags(note) - category = utils.get_note_category(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(('| {:<' + str(width) + '} |').format( + (' Title: ' + title)[:width])) + print(('| {:<' + str(width) + '} |').format( + (' Key: ' + + str(note.get( + 'id', + 'Localkey: {}'.format(note.get('localkey')) + ) + )[:width] + ))) + print(('| {:<' + str(width) + '} |').format( + (' Date: ' + mod_time)[:width])) + print(('| {:<' + str(width) + '} |').format( + (' Category: ' + category)[:width])) + print(('| {:<' + str(width) + '} |').format( + (' Flags: [' + flags + ']')[:width])) print(sep) print((note['content'])) def cli_dump_notes(self, regex, search_string): - - note_list, match_regex, all_notes_cnt = \ + """Dump multiple notes to the command line""" + note_list, _, _ = \ 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) + for note in note_list: + self.cli_note_dump(note.key) def cli_note_create(self, from_stdin, title): - + """Create a new note from the command line""" if from_stdin: content = ''.join(sys.stdin) else: - content = self.exec_cmd_on_note(None) + content = exec_cmd_on_note(None, self.config, self.nncli_gui, + self.logger) if title: content = title + '\n\n' + content if content else '' if content: - self.log('New note created') + self.logger.log('New note created') self.ndb.create_note(content) self.sync_notes() def cli_note_import(self, from_stdin): - + """Import a note from the command line""" if from_stdin: raw = ''.join(sys.stdin) else: - raw = self.exec_cmd_on_note(None) + raw = exec_cmd_on_note(None, self.config, self.nncli_gui, + self.logger) if raw: try: note = json.loads(raw) - self.log('New note created') + self.logger.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)) + except json.decoder.JSONDecodeError as ex: + self.logger.log( + '(IMPORT) Decoding JSON has failed: {}'.format(ex)) sys.exit(1) - except ValueError as e: - self.log('(IMPORT) ValueError: {}'.format(e)) + except ValueError as ex: + self.logger.log('(IMPORT) ValueError: {}'.format(ex)) sys.exit(1) def cli_note_export(self, key): - + """Export a note to the command line""" note = self.ndb.get_note(key) if not note: - self.log('ERROR: Key does not exist') + self.logger.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 = \ + """Export multiple notes to the command line""" + note_list, _, _ = \ self.ndb.filter_notes( search_string, search_mode='regex' if regex else 'gstyle', @@ -984,13 +179,14 @@ def cli_export_notes(self, regex, search_string): print(json.dumps(notes_data, indent=2)) def cli_note_edit(self, key): - + """Edit a note from the command line""" note = self.ndb.get_note(key) if not note: - self.log('ERROR: Key does not exist') + self.logger.log('ERROR: Key does not exist') return - content = self.exec_cmd_on_note(note) + content = exec_cmd_on_note(note, self.config, self.nncli_gui, + self.logger) if not content: return @@ -998,65 +194,66 @@ def cli_note_edit(self, key): md5_new = hashlib.md5(content.encode('utf-8')).digest() if md5_old != md5_new: - self.log('Note updated') + self.logger.log('Note updated') self.ndb.set_note_content(note['localkey'], content) self.sync_notes() else: - self.log('Note unchanged') + self.logger.log('Note unchanged') def cli_note_delete(self, key, delete): - + """Delete a note from the command line""" note = self.ndb.get_note(key) if not note: - self.log('ERROR: Key does not exist') + self.logger.log('ERROR: Key does not exist') return self.ndb.set_note_deleted(key, delete) self.sync_notes() def cli_note_favorite(self, key, favorite): - + """Favorite a note from the command line""" note = self.ndb.get_note(key) if not note: - self.log('ERROR: Key does not exist') + self.logger.log('ERROR: Key does not exist') return self.ndb.set_note_favorite(key, favorite) self.sync_notes() def cli_note_category_get(self, key): - + """Get a note category from the command line""" note = self.ndb.get_note(key) if not note: - self.log('ERROR: Key does not exist') - return + self.logger.log('ERROR: Key does not exist') + return '' category = utils.get_note_category(note) return category def cli_note_category_set(self, key, category): - + """Set a note category from the command line""" note = self.ndb.get_note(key) if not note: - self.log('Error: Key does not exist') + self.logger.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): - + """Remove a note category from the command line""" note = self.ndb.get_note(key) if not note: - self.log('Error: Key does not exist') + self.logger.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): +def sigint_handler(signum, frame): + """Handle sigint""" print('\nSignal caught, bye!') sys.exit(1) -signal.signal(signal.SIGINT, SIGINT_handler) +signal.signal(signal.SIGINT, sigint_handler) diff --git a/nncli/notes_db.py b/nncli/notes_db.py @@ -12,9 +12,11 @@ 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): + NotesDB will take care of the local notes database and syncing with + NextCloud Notes + """ + def __init__(self, config, log, update_view=None): self.config = config self.log = log self.update_view = update_view @@ -62,6 +64,9 @@ def __init__(self, config, log, update_view): self.config.get_config('nn_password'), self.config.get_config('nn_host')) + def set_update_view(self, update_view): + self.update_view = update_view + def filtered_notes_sort(self, filtered_notes, sort_mode='date'): if sort_mode == 'date': if self.config.get_config('favorite_ontop') == 'yes': diff --git a/nncli/utils.py b/nncli/utils.py @@ -1,6 +1,83 @@ # -*- coding: utf-8 -*- -import datetime, random, re +import datetime +import random +import re +import shlex + +import subprocess +from subprocess import CalledProcessError + +from . import temp + +def get_editor(config, logger): + """Get the editor""" + editor = config.get_config('editor') + if not editor: + logger.log('No editor configured!') + return None + return editor + +def get_pager(config, logger): + """Get the pager""" + pager = config.get_config('pager') + if not pager: + logger.log('No pager configured!') + return None + return pager + +def exec_cmd_on_note(note, config, gui, logger, cmd=None, raw=False): + """Execute an external command to operate on the note""" + + if not cmd: + cmd = get_editor(config, logger) + if not cmd: + return None + + tfile = temp.tempfile_create( + note if note else None, + raw=raw, + tempdir=config.get_config('tempdir') + ) + fname = temp.tempfile_name(tfile) + + if config.state.do_gui: + focus_position = 0 + try: + focus_position = gui.gui_body_get().focus_position + except IndexError: + 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) + + logger.log("EXECUTING: {}".format(cmd_list)) + + try: + subprocess.check_call(cmd_list) + except CalledProcessError as ex: + logger.log('Command error: %s' % ex) + temp.tempfile_delete(tfile) + return None + + content = None + if not raw: + content = temp.tempfile_content(tfile) + if not content or content == '\n': + content = None + + temp.tempfile_delete(tfile) + + if config.state.do_gui: + gui.nncli_loop.screen.clear() + gui.nncli_loop.draw_screen() + + return content def generate_random_key(): """Generate random 30 digit (15 byte) hex string. diff --git a/nncli/view_help.py b/nncli/view_help.py @@ -77,14 +77,14 @@ def create_config_help_lines(self): 'help_header', 'help_focus')) for c in self.config.configs: - if c in [ 'sn_username', 'sn_password' ]: continue + if c in [ 'nn_username', 'nn_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) + "'") + ('help_value', "'" + str(self.config.get_config(c)) + "'") ] ), attr_map = None, diff --git a/tests/test_nncli.py b/tests/test_nncli.py @@ -12,6 +12,7 @@ def mock_nncli(mocker): mocker.patch('logging.getLogger') mocker.patch('nncli.nncli.NotesDB') + mocker.patch('nncli.nncli.NncliGui') mocker.patch('os.mkdir') mocker.patch.object(RotatingFileHandler, '_open') mocker.patch('subprocess.check_output') @@ -28,56 +29,19 @@ def assert_initialized(): RotatingFileHandler._open.assert_called_once() os.mkdir.assert_called_once() -def test_init_no_tempdir(mocker, mock_nncli): - mock_get_config(mocker, ['what', '', 'duh', 'duh', 'duh']) - nn = nncli.nncli.Nncli(False) - assert_initialized() - assert nn.tempdir == None - os.mkdir.assert_called_with('duh') - def test_init(mocker, mock_nncli): - mock_get_config(mocker, ['what', 'blah', 'duh', 'duh', 'duh']) + mock_get_config(mocker, ['what', 'what', 'duh', 'duh', 'duh']) nn = nncli.nncli.Nncli(False) assert_initialized() - assert nn.tempdir == 'blah' def test_init_notesdb_fail(mocker, mock_nncli): - mock_get_config(mocker, ['what', 'blah', 'duh', 'duh', 'duh']) + mock_get_config(mocker, ['what', 'what', 'duh', 'duh', 'duh']) mocker.patch('nncli.nncli.NotesDB', new=mocker.MagicMock(side_effect=SystemExit) ) with pytest.raises(SystemExit): nn = nncli.nncli.Nncli(False) -def test_get_editor(mocker, mock_nncli): - mock_get_config(mocker, ['what', 'blah', 'duh', 'duh', 'duh', 'vim', '']) - nn = nncli.nncli.Nncli(False) - assert_initialized() - assert nn.get_editor() == 'vim' - assert nn.get_editor() == None - -def test_get_pager(mocker, mock_nncli): - mock_get_config(mocker, ['what', 'blah', 'duh', 'duh', 'duh', 'less', '']) - nn = nncli.nncli.Nncli(False) - assert_initialized() - assert nn.get_editor() == 'less' - assert nn.get_editor() == None - -def test_get_diff(mocker, mock_nncli): - mock_get_config(mocker, ['what', 'blah', 'duh', 'duh', 'duh', 'diff', '']) - nn = nncli.nncli.Nncli(False) - assert_initialized() - assert nn.get_editor() == 'diff' - assert nn.get_editor() == None - -@pytest.mark.skip -def test_exec_cmd_on_note(mocker, mock_nncli): - mocker.patch.object( - 'nncli.nncli.Nncli', - get_editor, - new=mocker.MagicMock(return_value='vim')) - mocker.patch('nncli.temp.tempfile_create') - @pytest.mark.skip def test_exec_diff_on_note(): pass