PK`'M;8:\\nncli/__init__.py# -*- coding: utf-8 -*- """NextCloud Notes Command Line Interface""" __version__ = '0.3.0' PKCd'MVCrrnncli/__main__.py# -*- coding: utf-8 -*- """nncli main module""" import nncli.cli if __name__ == '__main__': nncli.cli.main() PKCd'M sf nncli/cli.py# -*- coding: utf-8 -*- """Command line interface module""" import click from . import __version__ from .nncli import Nncli class StdinFlag(click.ParamType): """StdinFlag Click Parameter Type""" name = "stdin_flag" def convert(self, value, param, ctx): if value == '-': return True return self.fail('%s is not a valid stdin_flag') STDIN_FLAG = StdinFlag() @click.command() @click.pass_obj def rm_category(ctx_obj): """Remove note category.""" nncli = ctx_obj['nncli'] key = ctx_obj['key'] nncli.cli_note_category_rm(key) @click.command() @click.argument('category', required=True) @click.pass_obj def set_category(ctx_obj, category): """Set the note category.""" nncli = ctx_obj['nncli'] key = ctx_obj['key'] nncli.cli_note_category_set(key, category) @click.command(short_help="Print the note category.") @click.pass_obj def get_category(ctx_obj): """Print the category for the given note on stdout.""" nncli = ctx_obj['nncli'] key = ctx_obj['key'] category = nncli.cli_note_category_get(key) if category: print(category) @click.group() @click.option( '-k', '--key', required=True, type=click.INT, help="Specify the note key." ) @click.pass_context def cat(ctx, key): """Operate on the note category.""" nncli = ctx.obj ctx.obj = {} ctx.obj['nncli'] = nncli ctx.obj['key'] = key cat.add_command(get_category, 'get') cat.add_command(set_category, 'set') cat.add_command(rm_category, 'rm') @click.command() @click.option( '-k', '--key', required=True, type=click.INT, help="Specify the note key.") @click.pass_obj def favorite(nncli, key): """Mark as note as a favorite.""" nncli.cli_note_favorite(key, 1) @click.command() @click.option( '-k', '--key', required=True, type=click.INT, help="Specify the note key." ) @click.pass_obj def unfavorite(nncli, key): """Remove favorite flag from a note.""" nncli.cli_note_favorite(key, 0) @click.command(short_help="Print JSON-formatted note to stdout.") @click.option('-k', '--key', type=click.INT, help="Specify the note key.") @click.option( '-r', '--regex', is_flag=True, help="Treat search term(s) as regular expressions." ) @click.argument('search_terms', nargs=-1) @click.pass_obj def export(nncli, key, regex, search_terms): """ Print JSON-formatted note to stdout. If a key is specified, then regex and search_terms are ignored. """ if key: nncli.cli_note_export(key) else: nncli.cli_export_notes(regex, ' '.join(search_terms)) @click.command(short_help="Print note contents to stdout.") @click.option('-k', '--key', type=click.INT, help="Specify the note key.") @click.option( '-r', '--regex', is_flag=True, help="Treat search term(s) as regular expressions." ) @click.argument('search_terms', nargs=-1) @click.pass_obj def dump(nncli, key, regex, search_terms): """ Print note contents to stdout. If a key is specified, then regex and search_terms are ignored. """ if key: nncli.cli_note_dump(key) else: nncli.cli_dump_notes(regex, ' '.join(search_terms)) @click.command(short_help="List notes.") @click.option( '-r', '--regex', is_flag=True, help="Treat search term(s) as regular expressions." ) @click.argument('search_terms', nargs=-1) @click.pass_obj def list_notes(nncli, regex, search_terms): """ List notes, optionally providing search terms to narrow the results. """ nncli.cli_list_notes(regex, ' '.join(search_terms)) @click.command(short_help="Sync notes to server.") def sync(): """ Perform a full, bi-directional sync of your notes between the server and the local cache. """ pass @click.command() @click.option( '-k', '--key', required=True, type=click.INT, help="Specify the note key." ) @click.pass_obj def delete(nncli, key): """Delete an existing note.""" nncli.cli_note_delete(key, True) @click.command() @click.option( '-k', '--key', required=True, type=click.INT, help="Specify the note key." ) @click.pass_obj def edit(nncli, key): """Edit an existing note.""" nncli.cli_note_edit(key) @click.command(short_help="Import a JSON note.") @click.argument('from_stdin', metavar='[-]', type=STDIN_FLAG) @click.pass_obj def json_import(nncli, from_stdin): """ Import a JSON-formatted note file into your account. The expected JSON format is the same format used internally by nncli. If - is specified, the note is read from stdin, otherwise the editor will open. """ nncli.cli_note_import(from_stdin) @click.command(short_help="Add a new note.") @click.option('-t', '--title', help="Specify the title of note for create.") @click.argument('from_stdin', metavar='[-]', type=STDIN_FLAG) @click.pass_obj def create(nncli, title, from_stdin): """ Create a new note, either opening the editor or, if - is specified, reading from stdin. """ nncli.cli_note_create(from_stdin, title) @click.group(invoke_without_command=True) @click.option( '-n', '--nosync', is_flag=True, help="Don't perform a server sync." ) @click.option('-v', '--verbose', is_flag=True, help="Print verbose output.") @click.option( '-c', '--config', type=click.Path(exists=True), help="Specify the config file to read from." ) @click.option('-k', '--key', type=click.INT, help="Specify the note key.") @click.version_option(version=__version__, message='%(prog)s %(version)s') @click.pass_context def main(ctx, nosync, verbose, config, key): """ Run the NextClound Note Command Line Interface. No COMMAND means to open the console GUI. """ ctx.obj = Nncli(not nosync, verbose, config) if ctx.invoked_subcommand is None: ctx.obj.gui(key) elif not nosync: ctx.obj.sync_notes() main.add_command(create) main.add_command(edit) main.add_command(delete) main.add_command(sync) main.add_command(json_import, name='import') main.add_command(list_notes, name='list') main.add_command(dump) main.add_command(export) main.add_command(favorite) main.add_command(unfavorite) main.add_command(cat) PKCd'Mnncli/clipboard.py# -*- coding: utf-8 -*- import os from distutils import spawn class Clipboard(object): def __init__(self): self.copy_command = self.get_copy_command() def get_copy_command(self): if (spawn.find_executable('xsel')): return 'echo "%s" | xsel -ib' if (spawn.find_executable('pbcopy')): return 'echo "%s" | pbcopy' return None def copy(self, text): if (self.copy_command): os.system(self.copy_command % text) PKCd'MzP qDqDnncli/config.py# -*- coding: 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_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: cp.read([custom_file]) else: 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['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] PKCd'M$ nncli/nextcloud_note.py# -*- coding: utf-8 -*- from requests.exceptions import RequestException, ConnectionError import time import datetime import logging import requests import traceback try: import json except ImportError: try: import simplejson as json except ImportError: # For Google AppEngine from django.utils import simplejson as json class NextcloudNote(object): """ Class for interacting with the NextCloud Notes web service """ def __init__(self, username, password, host): """ object constructor """ self.username = username self.password = password self.url = \ 'https://{}/index.php/apps/notes/api/v0.2/notes'. \ format(host) self.status = 'offline' def get_note(self, noteid): """ method to get a specific note Arguments: - noteid (string): ID of the note to get Returns: A tuple `(note, status)` - note (dict): note object - status (int): 0 on sucesss and -1 otherwise """ # request note url = '{}/{}'.format(self.url, str(noteid)) #logging.debug('REQUEST: ' + self.url+params) try: res = requests.get(url, auth=(self.username, self.password)) res.raise_for_status() note = res.json() self.status = 'online' except ConnectionError as e: self.status = 'offline, connection error' return e, -1 except RequestException as e: # logging.debug('RESPONSE ERROR: ' + str(e)) return e, -1 except ValueError as e: return e, -1 # # use UTF-8 encoding # note["content"] = note["content"].encode('utf-8') # # For early versions of notes, category is not always available # if "category" in note: # note["category"] = [t.encode('utf-8') for t in note["category"]] #logging.debug('RESPONSE OK: ' + str(note)) return note, 0 def update_note(self, note): """ function to update a specific note object, if the note object does not have a "key" field, a new note is created Arguments - note (dict): note object to update Returns: A tuple `(note, status)` - note (dict): note object - status (int): 0 on sucesss and -1 otherwise """ # Note: all strings in notes stored as type str # - use s.encode('utf-8') when bytes type needed # determine whether to create a new note or updated an existing one if "id" in note: # set modification timestamp if not set by client if 'modified' not in note: note["modified"] = int(time.time()) url = '{}/{}'.format(self.url, note["id"]) del note["id"] else: url = self.url #logging.debug('REQUEST: ' + url + ' - ' + str(note)) try: logging.debug('NOTE: ' + str(note)) if url != self.url: res = requests.put(url, auth=(self.username, self.password), json=note) else: res = requests.post(url, auth=(self.username, self.password), json=note) note = res.json() res.raise_for_status() logging.debug('NOTE (from response): ' + str(res.json())) self.status = 'online' except ConnectionError as e: self.status = 'offline, connection error' return e, -1 except RequestException as e: logging.debug('RESPONSE ERROR: ' + str(e)) logging.debug(traceback.print_exc()) self.status = 'error updating note, check log' return e, -1 except ValueError as e: return e, -1 #logging.debug('RESPONSE OK: ' + str(note)) return note, 0 def get_note_list(self, category=None): """ function to get the note list The function can be passed optional arguments to limit the date range of the list returned and/or limit the list to notes containing a certain category. If omitted a list of all notes is returned. Arguments: - category=None category as string: return notes tagged to this category Returns: A tuple `(notes, status)` - notes (list): A list of note objects with all properties set except `content`. - status (int): 0 on sucesss and -1 otherwise """ # initialize data status = 0 note_list = {} # get the note index params = {'exclude': 'content'} # perform initial HTTP request try: logging.debug('REQUEST: ' + self.url + \ '?exclude=content') res = requests.get(self.url, auth=(self.username, self.password), params=params) res.raise_for_status() #logging.debug('RESPONSE OK: ' + str(res)) note_list = res.json() self.status = 'online' except ConnectionError as e: logging.exception('connection error') self.status = 'offline, connection error' status = -1 except RequestException as e: # if problem with network request/response logging.exception('request error') status = -1 except ValueError as e: # if invalid json data status = -1 logging.exception('request returned bad JSON data') # Can only filter for category at end, once all notes have been # retrieved. Below based on simplenote.vim, except we return # deleted notes as well if category is not None: note_list = \ [n for n in note_list if n["category"] == category] return note_list, status def delete_note(self, note): """ method to permanently delete a note Arguments: - note_id (string): key of the note to delete Returns: A tuple `(note, status)` - note (dict): an empty dict or an error message - status (int): 0 on sucesss and -1 otherwise """ url = '{}/{}'.format(self.url, str(note['id'])) try: logging.debug('REQUEST DELETE: ' + url) res = requests.delete(url, auth=(self.username, self.password)) res.raise_for_status() self.status = 'online' except ConnectionError as e: self.status = 'offline, connection error' return e, -1 except RequestException as e: return e, -1 return {}, 0 PKCd'M5A3$nncli/nncli.py# -*- 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__ 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 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)') 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) PKZ'MFd\\nncli/notes_db.py# -*- coding: utf-8 -*- import os, time, re, glob, json, copy, threading from . import utils 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')) 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_category(self, key): return self.notes[key].get('category') 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 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: 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']) if savedate > modified: o.saved = 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() PKCd'Mlz؏ nncli/temp.py# -*- coding: utf-8 -*- import os, json, tempfile def tempfile_create(note, raw=False, tempdir=None): if raw: # dump the raw json of the note tf = tempfile.NamedTemporaryFile(suffix='.json', delete=False, dir=tempdir) contents = json.dumps(note, indent=2) tf.write(contents.encode('utf-8')) tf.flush() else: ext = '.mkd' tf = tempfile.NamedTemporaryFile(suffix=ext, delete=False, dir=tempdir) if note: contents = note['content'] tf.write(contents.encode('utf-8')) tf.flush() return tf def tempfile_delete(tf): if tf: tf.close() os.unlink(tf.name) def tempfile_name(tf): if tf: return tf.name return '' def tempfile_content(tf): # This 'hack' is needed because some editors use an intermediate temporary # file, and rename it to that of the correct file, overwriting it. This # means that the tf file handle won't be updated with the new contents, and # the tempfile must be re-opened and read if not tf: return None with open(tf.name, 'rb') as f: updated_tf_contents = f.read() return updated_tf_contents.decode('utf-8') PKCd'MC,n55nncli/user_input.py# -*- coding: 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 PKCd'MI I nncli/utils.py# -*- coding: utf-8 -*- import datetime, random, re def generate_random_key(): """Generate random 30 digit (15 byte) hex string. stackoverflow question 2782229 """ return '%030x' % (random.randrange(256**15),) def get_note_category(note): if 'category' in note: category = note['category'] if note['category'] is not None else '' else: category = '' return category # Returns a fixed length string: # 'X' - needs sync # '*' - favorite def get_note_flags(note): flags = '' flags += 'X' if float(note['modified']) > float(note['syncdate']) else ' ' if 'favorite' in note: flags += '*' if note['favorite'] else ' ' else: flags += ' ' return flags def get_note_title(note): if 'title' in note: return note['title'] else: return '' def note_favorite(n): if 'favorite' in n: return n['favorite'] else: return False def sort_by_title_favorite(a): return (not note_favorite(a.note), get_note_title(a.note)) def sort_notes_by_categories(notes, favorite_ontop=False): notes.sort(key=lambda i: (favorite_ontop and not note_favorite(i.note), i.note.get('category'), get_note_title(i.note))) def sort_by_modify_date_favorite(a): if note_favorite(a.note): return 100.0 * float(a.note.get('modified', 0)) else: return float(a.note.get('modified', 0)) class KeyValueObject: """Store key=value pairs in this object and retrieve with o.key. You should also be able to do MiscObject(**your_dict) for the same effect. """ def __init__(self, **kwargs): self.__dict__.update(kwargs) def build_regex_search(search_string): """ Build up a compiled regular expression from the search string. Supports the use of flags - ie. search for `nothing/i` will perform a case-insensitive regex for `nothing` """ sspat = None valid_flags = { 'i': re.IGNORECASE } if search_string: try: search_string, flag_letters = re.match(r'^(.+?)(?:/([a-z]+))?$', search_string).groups() flags = 0 # if flags are given, OR together all the valid flags # see https://docs.python.org/3/library/re.html#re.compile if flag_letters: for letter in flag_letters: if letter in valid_flags: flags = flags | valid_flags[letter] sspat = re.compile(search_string, flags) except re.error: sspat = None return sspat PKCd'Mizznncli/view_help.py# -*- coding: 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 PKCd'Msnncli/view_log.py# -*- coding: 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 PKCd'MvT݆nncli/view_note.py# -*- coding: utf-8 -*- import time, urwid from . import utils import re from .clipboard import Clipboard import logging class ViewNote(urwid.ListBox): def __init__(self, config, args): self.config = config self.ndb = args['ndb'] self.key = args['id'] self.log = args['log'] self.search_string = '' self.search_mode = 'gstyle' self.search_direction = '' self.note = self.ndb.get_note(self.key) if self.key else None self.old_note = None self.tabstop = int(self.config.get_config('tabstop')) self.clipboard = Clipboard() super(ViewNote, self).__init__( urwid.SimpleFocusListWalker(self.get_note_content_as_list())) def get_note_content_as_list(self): lines = [] if not self.key: return lines if self.old_note: for l in self.old_note['content'].split('\n'): lines.append( urwid.AttrMap(urwid.Text(l.replace('\t', ' ' * self.tabstop)), 'note_content_old', 'note_content_old_focus')) else: for l in self.note['content'].split('\n'): lines.append( urwid.AttrMap(urwid.Text(l.replace('\t', ' ' * self.tabstop)), 'note_content', 'note_content_focus')) lines.append(urwid.AttrMap(urwid.Divider('-'), 'default')) return lines def update_note_view(self, key=None, version=None): if key: # setting a new note self.key = key self.note = self.ndb.get_note(self.key) self.old_note = None self.body[:] = \ urwid.SimpleFocusListWalker(self.get_note_content_as_list()) if not self.search_string: self.focus_position = 0 def lines_after_current_position(self): lines_after_current_position = list(range(self.focus_position + 1, len(self.body.positions()) - 1)) return lines_after_current_position def lines_before_current_position(self): lines_before_current_position = list(range(0, self.focus_position)) lines_before_current_position.reverse() return lines_before_current_position def search_note_view_next(self, search_string=None, search_mode=None): if search_string: self.search_string = search_string if search_mode: self.search_mode = search_mode note_range = self.lines_after_current_position() if self.search_direction == 'forward' else self.lines_before_current_position() self.search_note_range(note_range) def search_note_view_prev(self, search_string=None, search_mode=None): if search_string: self.search_string = search_string if search_mode: self.search_mode = search_mode note_range = self.lines_after_current_position() if self.search_direction == 'backward' else self.lines_before_current_position() self.search_note_range(note_range) def search_note_range(self, note_range): for line in note_range: line_content = self.note['content'].split('\n')[line] if (self.is_match(self.search_string, line_content)): self.focus_position = line break self.update_note_view() def is_match(self, term, full_text): if self.search_mode == 'gstyle': return term in full_text else: sspat = utils.build_regex_search(term) return sspat and sspat.search(full_text) def get_status_bar(self): if not self.key: return \ urwid.AttrMap(urwid.Text('No note...'), 'status_bar') cur = -1 total = 0 if len(self.body.positions()) > 0: cur = self.focus_position total = len(self.body.positions()) t = time.localtime(float(self.note['modified'])) title = utils.get_note_title(self.note) flags = utils.get_note_flags(self.note) category = utils.get_note_category(self.note) mod_time = time.strftime('Date: %a, %d %b %Y %H:%M:%S', t) status_title = \ urwid.AttrMap(urwid.Text('Title: ' + title, wrap='clip'), 'status_bar') status_key_index = \ ('pack', urwid.AttrMap(urwid.Text(' [' + str(self.key) + '] ' + str(cur + 1) + '/' + str(total)), 'status_bar')) status_date = \ urwid.AttrMap(urwid.Text(mod_time, wrap='clip'), 'status_bar') status_category_flags = \ ('pack', urwid.AttrMap(urwid.Text('[' + category + '] [' + flags + ']'), 'status_bar')) pile_top = urwid.Columns([ status_title, status_key_index ]) pile_bottom = urwid.Columns([ status_date, status_category_flags ]) return \ urwid.AttrMap(urwid.Pile([ pile_top, pile_bottom ]), 'status_bar') def copy_note_text(self): line_content = self.note['content'].split('\n')[self.focus_position] self.clipboard.copy(line_content) def keypress(self, size, key): if key == self.config.get_keybind('tabstop2'): self.tabstop = 2 self.body[:] = \ urwid.SimpleFocusListWalker(self.get_note_content_as_list()) elif key == self.config.get_keybind('tabstop4'): self.tabstop = 4 self.body[:] = \ urwid.SimpleFocusListWalker(self.get_note_content_as_list()) elif key == self.config.get_keybind('tabstop8'): self.tabstop = 8 self.body[:] = \ urwid.SimpleFocusListWalker(self.get_note_content_as_list()) else: return key return None PKCd'M=nncli/view_titles.py# -*- coding: 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 PK!H2%(&nncli-0.3.0.dist-info/entry_points.txtN+I/N.,()Kɴz@lPKy"M3֋nncli-0.3.0.dist-info/LICENSE 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. ------------------------------------------------------------------------------- Portions of this software are adapted from sncli by Eric Davis: ** The MIT License ** Copyright (c) 2014 Eric Davis (edavis@insanum.com) 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. Dude... just buy me a beer. :-) ------------------------------------------------------------------------------- Portions of nextcloud_note.py are adapted from simplenote.py by Daniel Schauenberg: Copyright (c) 2011 Daniel Schauenberg 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. ------------------------------------------------------------------------------- Portions of notes_db and utils modules are adapted from the corresponding module in nvpy by Charl P. Botha: Copyright (c) 2012, Charl P. Botha All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL CHARL P. BOTHA BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. PK!HSmPOnncli-0.3.0.dist-info/WHEEL HM K-*ϳR03rOK-J,/RH,rzd&Y)r$[)T&UrPK!HͰI. 'nncli-0.3.0.dist-info/METADATAW[S7~_qK(î'w)1$HB:}y%J[Ikgiml δl9߹p"r^[3+Ѩ1S:^'ūk1]/aRNҕ Ă`$]hhjrsQWۨ-Bhx4tXtH~nl%KTƃ귫_e]XX73a[1H%aU5Vv) ~})_:ĒO,3!ط0K|&^ϵ͔NٕrqC9S+U۶n "t {f5<:t+4O;)#9}QB[[?8[94Tt!Lաj̟ȟN:S)>>sk-?:|U>mvOrխ2%r^V\ʐ^_ 8k{YUᔐa-}HQ-*VZ9*ˢ iOb5_Q5TێDY*)X"K PݕTT RA l`!f`'E>QXz8r)9z| ylOQ?dɨ3쐟 CFTOlNlm |;.)S ?v@Ȝ kג%O}:ܫ$V,c\$Ƙ '2g"Ď=ꀃD6v{i4vX=pڹSҩ­zt}}AyLa 9j řV? Zy˄,nؓlh?BL*^,~'g̝mȬXd'=q"׋| Ll,f9"fo'vp񡰮xoi`jsݧ`MYGׯ=(ʑ?+Q|3DNL]YPK!H_ 8nncli-0.3.0.dist-info/RECORDuɒH}ˌ,z!" nTHd黺vD-r ALty_4Bk`4|=eP3mZHRWn!;RB_NA􇳥gYu>#|*ѯ;:ri | \lJp-ڒQ|,R%à(w$G!,RߪMTX;CL6knvmr-NNR0[V4׈@dm[7쌔¸&*; vM"ϼKBU8]tU'-Ǻ :7g2-FY-e*H$=gRUƧ K0f/9gsP{e8 ܥӘ`%Ax7j0-A9/v۫x3s| ^nb\gL ]Qo e`@ n\%nhZz o7U jQe#mbfK黴ʺ^=~QP'{wwЊG;m6{ ntuM Ĭ1&nO:Ζ*SlBAW#ecs%u'ϛ?{w5Taeu 1SR$YREKצzTDD[۸A> |d? gQaT>%DZUJ^|Q|y5{w[%!.eXvNꬳ'Jzo>&67%s71W_uA}$$Dwp;u>|s! Ed=֡]t܊gG9\`{7Zϕыw^t99)xO4ԸgSDJn6#y$_9H"^"7zW \둳"ğB8gY;7׊([W?fQN$ti{0n(l'11# {yįPK`'M;8:\\nncli/__init__.pyPKCd'MVCrrnncli/__main__.pyPKCd'M sf ,nncli/cli.pyPKCd'Mnncli/clipboard.pyPKCd'MzP qDqDnncli/config.pyPKCd'M$ anncli/nextcloud_note.pyPKCd'M5A3$Y|nncli/nncli.pyPKZ'MFd\\snncli/notes_db.pyPKCd'Mlz؏ tnncli/temp.pyPKCd'MC,n55ynncli/user_input.pyPKCd'MI I |nncli/utils.pyPKCd'Mizzfnncli/view_help.pyPKCd'Msnncli/view_log.pyPKCd'MvT݆Bnncli/view_note.pyPKCd'M=nncli/view_titles.pyPK!H2%(&'nncli-0.3.0.dist-info/entry_points.txtPKy"M3֋nncli-0.3.0.dist-info/LICENSEPK!HSmPOenncli-0.3.0.dist-info/WHEELPK!HͰI. 'nncli-0.3.0.dist-info/METADATAPK!H_ 87nncli-0.3.0.dist-info/RECORDPK,