nncli

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

commit 58618398420ee21845e8d1ffb1d2aa3662ae4abf
parent c8541098ea3ffbb0fadbd00f0e348d55e6b9ccca
Author: Daniel Moch <daniel@danielmoch.com>
Date:   Mon, 23 Jul 2018 19:09:36 -0400

Initial nncli refactor

Diffstat:
ALICENSE | 23+++++++++++++++++++++++
MREADME.md | 342++++++++++++++++++++++++++++++++++++-------------------------------------------
Anncli | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Annotes_cli/__init__.py | 8++++++++
Rsimplenote_cli/clipboard.py -> nnotes_cli/clipboard.py | 0
Annotes_cli/config.py | 285+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Annotes_cli/nncli.py | 1476+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Annotes_cli/nnotes.py | 382+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Annotes_cli/notes_db.py | 709+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsimplenote_cli/temp.py -> nnotes_cli/temp.py | 0
Rsimplenote_cli/user_input.py -> nnotes_cli/user_input.py | 0
Annotes_cli/utils.py | 223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsimplenote_cli/view_help.py -> nnotes_cli/view_help.py | 0
Rsimplenote_cli/view_log.py -> nnotes_cli/view_log.py | 0
Rsimplenote_cli/view_note.py -> nnotes_cli/view_note.py | 0
Annotes_cli/view_titles.py | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsimplenote_cli/__init__.py | 8--------
Dsimplenote_cli/config.py | 285-------------------------------------------------------------------------------
Dsimplenote_cli/notes_db.py | 696-------------------------------------------------------------------------------
Dsimplenote_cli/simplenote.py | 358-------------------------------------------------------------------------------
Dsimplenote_cli/sncli.py | 1476-------------------------------------------------------------------------------
Dsimplenote_cli/utils.py | 198-------------------------------------------------------------------------------
Dsimplenote_cli/view_titles.py | 192-------------------------------------------------------------------------------
Dsncli | 33---------------------------------
24 files changed, 3511 insertions(+), 3431 deletions(-)

diff --git a/LICENSE b/LICENSE @@ -0,0 +1,23 @@ + +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. + diff --git a/README.md b/README.md @@ -1,16 +1,17 @@ -sncli +nncli ===== -Simplenote Command Line Interface +NextCloud Notes Command Line Interface -sncli is a Python application that gives you access to your Simplenote account -via the command line. You can access your notes via a customizable console GUI -that implements vi-like keybinds or via a simple command line interface that -you can script. +nncli is a Python application that gives you access to your NextCloud +Notes account via the command line. It's a fork of +[sncli](https://github.com/insanum/sncli) You can access your notes via +a customizable console GUI that implements vi-like keybinds or via a +simple command line interface that you can script. -Notes can be viewed/created/edited in *both an* **online** *and* **offline** -*mode*. All changes are saved to a local cache on disk and automatically -sync'ed when sncli is brought online. +Notes can be viewed/created/edited in *both an* **online** *and* +**offline** *mode*. All changes are saved to a local cache on disk and +automatically sync'ed when nncli is brought online. **Pull requests are welcome!** @@ -21,26 +22,31 @@ Check your OS distribution for installation packages. * [Python 3](http://python.org) * [pip](https://pip.pypa.io/en/stable/) * [Urwid](http://urwid.org) Python 3 module -* [Requests](https://requests.readthedocs.org/en/master/) Python 3 module +* [Requests](https://requests.readthedocs.org/en/master/) Python 3 + module * A love for the command line! ### Installation * Via pip (latest release): - - `pip3 install sncli` + - `pip3 install nncli` * Manually: - - Clone this repository to your hard disk: `git clone https://github.com/insanum/sncli.git` + - Clone this repository to your hard disk: `git clone + https://github.com/insanum/nncli.git` - Install the requirements `pip3 install -r requirements.txt` - - Install _sncli_: `python3 setup.py install` + - Install _nncli_: `python3 setup.py install` ### Features * Console GUI - - full two-way sync with Simplenote performed dynamically in the background + - full two-way sync with NextCloud Notes performed dynamically in the + background - all actions logged and easily reviewed - - list note titles (configurable format w/ title, date, flags, tags, keys, etc) + - list note titles (configurable format w/ title, date, flags, tags, + keys, etc) - sort notes by date, alpha by title, tags, pinned on top - - search for notes using a Google style search pattern or Regular Expression + - search for notes using a Google style search pattern or Regular + Expression - view note contents and meta data - view and restore previous versions of notes - pipe note contents to external command @@ -52,10 +58,11 @@ Check your OS distribution for installation packages. - vi-like keybinds (fully configurable) - Colors! (fully configurable) * Command Line (scripting) - - force a full two-way sync with Simplenote + - force a full two-way sync with NextCloud Notes - all actions logged and easily reviewed - list note titles and keys - - search for notes using a Google style search pattern or Regular Expression + - search for notes using a Google style search pattern or Regular + Expression - dump note contents - create a new note (via stdin or editor) - import a note with raw json data (stdin or editor) @@ -67,241 +74,206 @@ Check your OS distribution for installation packages. ### Screenshots -![sncli](https://github.com/insanum/sncli/raw/master/screenshots/screenshot1.png) -![sncli](https://github.com/insanum/sncli/raw/master/screenshots/screenshot2.png) -![sncli](https://github.com/insanum/sncli/raw/master/screenshots/screenshot3.png) -![sncli](https://github.com/insanum/sncli/raw/master/screenshots/screenshot4.png) +![nncli](https://github.com/insanum/nncli/raw/master/screenshots/screenshot1.png) +![nncli](https://github.com/insanum/nncli/raw/master/screenshots/screenshot2.png) +![nncli](https://github.com/insanum/nncli/raw/master/screenshots/screenshot3.png) +![nncli](https://github.com/insanum/nncli/raw/master/screenshots/screenshot4.png) ### HowTo -``` -Usage: - sncli [OPTIONS] [COMMAND] [COMMAND_ARGS] - - OPTIONS: - -h, --help - usage help - -v, --verbose - verbose output - -n, --nosync - don't perform a server sync - -r, --regex - search string is a regular expression - -k <key>, --key=<key> - note key - -t <title>, --title=<title> - title of note for create (cli mode) - -c <file>, --config=<file> - config file to read from (defaults to ~/.snclirc) - - COMMANDS: - <none> - console gui mode when no command specified - sync - perform a full sync with the server - list [search_string] - list notes (refined with search string) - export [search_string] - export notes in JSON (refined with search string) - dump [search_string] - dump notes (refined with search string) - create [-] - create a note ('-' content from stdin) - import [-] - import a note in JSON format ('-' JSON from stdin) - export - export a note in JSON format (specified by <key>) - dump - dump a note (specified by <key>) - edit - edit a note (specified by <key>) - < trash | untrash > - trash/untrash a note (specified by <key>) - < pin | unpin > - pin/unpin a note (specified by <key>) - < markdown | unmarkdown > - markdown/unmarkdown a note (specified by <key>) - tag get - retrieve the tags from a note (specified by <key>) - tag set <tags> - set the tags for a note (specified by <key>) - tag add <tags> - add tags to a note (specified by <key>) - tag rm <tags> - remove tags from a note (specified by <key>) -``` +``` Usage: nncli [OPTIONS] [COMMAND] [COMMAND_ARGS] + + OPTIONS: -h, --help - usage help -v, --verbose + - verbose output -n, --nosync - don't perform a server + sync -r, --regex - search string is a regular + expression -k <key>, --key=<key> - note key -t <title>, + --title=<title> - title of note for create (cli mode) -c <file>, + --config=<file> - config file to read from (defaults to ~/.nnclirc) + + COMMANDS: <none> - console gui mode when no + command specified sync - perform a full sync + with the server list [search_string] - list notes (refined with + search string) export [search_string] - export notes in JSON + (refined with search string) dump [search_string] - dump notes + (refined with search string) create [-] - create a + note ('-' content from stdin) import [-] - import a + note in JSON format ('-' JSON from stdin) export - + export a note in JSON format (specified by <key>) dump + - dump a note (specified by <key>) edit - edit a + note (specified by <key>) < trash | untrash > - trash/untrash + a note (specified by <key>) < pin | unpin > - pin/unpin a + note (specified by <key>) < markdown | unmarkdown > - + markdown/unmarkdown a note (specified by <key>) tag get + - retrieve the tags from a note (specified by <key>) tag set <tags> + - set the tags for a note (specified by <key>) tag add <tags> + - add tags to a note (specified by <key>) tag rm <tags> + - remove tags from a note (specified by <key>) ``` #### Configuration -The current Simplenote API does not support oauth authentication so your -Simplenote account information must live in the configuration file. Please be -sure to protect this file. +The current NextCloud Notes API does not support oauth authentication so your +NextCloud Notes account information must live in the configuration file. +Please be sure to protect this file. -sncli pulls in configuration from the `.snclirc` file located in your $HOME -directory. At the very least, the following example `.snclirc` will get you -going (using your account information): +nncli pulls in configuration from the `.nnclirc` file located in your +$HOME directory. At the very least, the following example `.nnclirc` +will get you going (using your account information): -``` -[sncli] -cfg_sn_username = lebowski@thedude.com -cfg_sn_password = nihilist -``` +``` [nncli] cfg_sn_username = lebowski@thedude.com cfg_sn_password = +nihilist ``` -Start sncli with no arguments which starts the console GUI mode. sncli with -start sync'ing all your existing notes and you'll see log messages at the -bottom of the console. You can view these log messages at any time by pressing -the `l` key. +Start nncli with no arguments which starts the console GUI mode. nncli +with start sync'ing all your existing notes and you'll see log messages +at the bottom of the console. You can view these log messages at any +time by pressing the `l` key. View the help by pressing `h`. Here you'll see all the keybinds and -configuration items. The middle column shows the config name that can be used -in your `.snclirc` to override the default setting. +configuration items. The middle column shows the config name that can be +used in your `.nnclirc` to override the default setting. See example configuration file below for more notes. -``` -[sncli] -cfg_sn_username = lebowski@thedude.com -cfg_sn_password = nihilist - -# as an alternate to cfg_sn_password you could use the following config item -# any shell command can be used; its stdout is used for the password -# trailing newlines are stripped for ease of use -# note: if both password config are given, cfg_sn_password will be used -cfg_sn_password_eval = gpg --quiet --for-your-eyes-only --no-tty --decrypt ~/.sncli-pass.gpg - -# see http://urwid.org/manual/userinput.html for examples of more key combinations -kb_edit_note = space -kb_page_down = ctrl f - -# note that values must not be quoted -clr_note_focus_bg = light blue - -# if this editor config value is not provided, the $EDITOR env var will be used instead -# warning: if neither $EDITOR or cfg_editor is set, it will be impossible to edit notes -cfg_editor = nvim - -# alternatively, {fname} and/or {line} are substituted with the filename and -# current line number in sncli's pager. -# If {fname} isn't supplied, the filename is simply appended. -# examples: -cfg_editor = nvim {fname} +{line} -cfg_editor = nano +{line} - -# this is also supported for the pager: -cfg_pager = less -c +{line} -N {fname} -``` +``` [nncli] cfg_sn_username = lebowski@thedude.com cfg_sn_password = +nihilist + +# as an alternate to cfg_sn_password you could use the following config +item # any shell command can be used; its stdout is used for the +password # trailing newlines are stripped for ease of use # note: if +both password config are given, cfg_sn_password will be used +cfg_sn_password_eval = gpg --quiet --for-your-eyes-only --no-tty +--decrypt ~/.nncli-pass.gpg + +# see http://urwid.org/manual/userinput.html for examples of more key +combinations kb_edit_note = space kb_page_down = ctrl f + +# note that values must not be quoted clr_note_focus_bg = light blue + +# if this editor config value is not provided, the $EDITOR env var will +be used instead # warning: if neither $EDITOR or cfg_editor is set, it +will be impossible to edit notes cfg_editor = nvim + +# alternatively, {fname} and/or {line} are substituted with the filename +and # current line number in nncli's pager. # If {fname} isn't +supplied, the filename is simply appended. # examples: cfg_editor = +nvim {fname} +{line} cfg_editor = nano +{line} + +# this is also supported for the pager: cfg_pager = less -c +{line} -N +{fname} ``` #### Note Title Format The format of each line in the note list is driven by the -`cfg_format_note_title` config item. 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: +`cfg_format_note_title` config item. 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 (fixed 5 char width) - X - needs sync - T - trashed - * - pinned - S - published/shared - m - markdown - %T - tags - %D - date - %N - title -``` +``` %F - flags (fixed 5 char width) X - needs sync T - trashed + * - pinned S - published/shared m - markdown %T - tags %D - date + %N - title ``` -The default note title format pushes the note tags to the far right of the -terminal and left justifies the note title after the date and flags: +The default note title format pushes the note tags to the far right of +the terminal and left justifies the note title after the date and flags: -``` -cfg_format_note_title = '[%D] %F %-N %T' -``` +``` cfg_format_note_title = '[%D] %F %-N %T' ``` Note that the `%D` date format is further defined by the strftime format specified in `cfg_format_strftime`. #### Colors -sncli utilizes the Python [Uwrid](http://urwid.org) module to implement the -console user interface. +nncli utilizes the Python [Uwrid](http://urwid.org) module to implement +the console user interface. -At this time, sncli does not yet support 256-color terminals and is limited to -just 16-colors. Color names that can be specified in the `.snclirc` file are -listed [here](http://urwid.org/manual/displayattributes.html#standard-foreground-colors). +At this time, nncli does not yet support 256-color terminals and is +limited to just 16-colors. Color names that can be specified in the +`.nnclirc` file are listed +[here](http://urwid.org/manual/displayattributes.html#standard-foreground-colors). ### Searching -sncli supports two styles of search strings. First is a Google style search -string and second is a Regular Expression. +nncli supports two styles of search strings. First is a Google style +search string and second is a Regular Expression. -A Google style search string is a group of tokens (separated by spaces) with -an implied *AND* between each token. This style search is case insensitive. For -example: +A Google style search string is a group of tokens (separated by spaces) +with an implied *AND* between each token. This style search is case +insensitive. For example: -``` -/tag:tag1 tag:tag2 word1 "word2 word3" tag:tag3 -``` +``` /tag:tag1 tag:tag2 word1 "word2 word3" tag:tag3 ``` -Regular expression searching also supports the use of flags (currently only case-insensitive) by adding a final forward -slash followed by the flags. The following example will do a case-insensitive search for `something`: +Regular expression searching also supports the use of flags (currently +only case-insensitive) by adding a final forward slash followed by the +flags. The following example will do a case-insensitive search for +`something`: -``` -(regex) /something/i -``` +``` (regex) /something/i ``` ### Creating from command line -``` -# create a new note and open in editor -sncli create +``` # create a new note and open in editor nncli create -# create a new note with contents of stdin -echo 'hi' | sncli create - +# create a new note with contents of stdin echo 'hi' | nncli create - ``` ### Importing -sncli can import notes from raw json data (via stdin or editor). For example: +nncli can import notes from raw json data (via stdin or editor). For +example: -``` -echo '{"tags":["testing","new"],"content":"New note!"}' | sncli import - -``` +``` echo '{"tags":["testing","new"],"content":"New note!"}' | nncli +import - ``` -Allowed fields are `content`, `tags`, `systemtags`, `modifydate`, `createdate`, and `deleted`. +Allowed fields are `content`, `tags`, `systemtags`, `modifydate`, +`createdate`, and `deleted`. ### Exporting -sncli can export notes as json data to stdout. Example: +nncli can export notes as json data to stdout. Example: -``` -# export a single note by id -sncli -k somekeyid export +``` # export a single note by id nncli -k somekeyid export -# export all notes -sncli export +# export all notes nncli export -# export notes matching search string -sncli [-r] export some search keywords or regex -``` +# export notes matching search string nncli [-r] export some search +keywords or regex ``` -Note that sncli still stores all the notes data in the directory specified by -`cfg_db_path`, so for easy backups, it may be easier/quicker to simply backup -this entire directory. +Note that nncli still stores all the notes data in the directory +specified by `cfg_db_path`, so for easy backups, it may be +easier/quicker to simply backup this entire directory. ### Tags Note tags can be modified directly from the command line. Example: -``` -# Retrieve note tags, as one comma-separated string (e.g. "tag1,tag2") -sncli -k somekeyid tag get # Returns "tag1,tag2" +``` # Retrieve note tags, as one comma-separated string (e.g. +"tag1,tag2") nncli -k somekeyid tag get # Returns +"tag1,tag2" -# Add a tag to a note, if it doesn't already have it -sncli -k somekeyid tag add "tag3" # Now tagged as "tag1,tag2,tag3" +# Add a tag to a note, if it doesn't already have it nncli -k somekeyid +tag add "tag3" # Now tagged as "tag1,tag2,tag3" -# Remove a tag from a note -sncli -k somekeyid tag rm "tag2" # Now tagged as "tag1,tag3" +# Remove a tag from a note nncli -k somekeyid tag rm "tag2" # +Now tagged as "tag1,tag3" -# Overwrite all of the tags for a note -sncli -k somekeyid tag set "tag2,tag4" # Now tagged as "tag2,tag4" -``` +# Overwrite all of the tags for a note nncli -k somekeyid tag set +"tag2,tag4" # Now tagged as "tag2,tag4" ``` -Note that in SimpleNote, tags are case-insensitive, so "TAG2", "tag2", and -"tAg2" are interpreted as the same and will all be converted to lowercase. +Note that in SimpleNote, tags are case-insensitive, so "TAG2", "tag2", +and "tAg2" are interpreted as the same and will all be converted to +lowercase. ### Tricks I personally store a lot of my notes in -[Votl/VimOutliner](https://github.com/insanum/votl) format. Specific to Vim, I -put a modeline at the end of these notes (note that Emacs also supports -modelines): - -``` -; vim:ft=votl -``` +[Votl/VimOutliner](https://github.com/insanum/votl) format. Specific to +Vim, I put a modeline at the end of these notes (note that Emacs also +supports modelines): -Now when I edit this note Vim will automatically load the votl plugin. Lots of -possibilities here... +``` ; vim:ft=votl ``` -_Note: more tips and tricks on the [GitHub wiki](https://github.com/insanum/sncli/wiki/Tips-and-Tricks)!_ +Now when I edit this note Vim will automatically load the votl plugin. +Lots of possibilities here... ### Thanks diff --git a/nncli b/nncli @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# +# 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. +# + +# +# ** 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. :-) +# + +from nnotes_cli import nncli + +if __name__ == '__main__': + nncli.main() + diff --git a/nnotes_cli/__init__.py b/nnotes_cli/__init__.py @@ -0,0 +1,8 @@ +__productname__ = 'nncli' +__version__ = '0.1.0-dev' +__copyright__ = "Copyright (c) 2018 Daniel Moch" +__author__ = "Daniel Moch" +__author_email__ = "daniel@danielmoch.com" +__description__ = "NextCloud Notes Command Line Interface" +__url__ = "https://github.com/djmoch/nncli" +__license__ = "MIT" diff --git a/simplenote_cli/clipboard.py b/nnotes_cli/clipboard.py diff --git a/nnotes_cli/config.py b/nnotes_cli/config.py @@ -0,0 +1,285 @@ + +# Copyright (c) 2014 Eric Davis +# Licensed under the MIT License + +import os, sys, urwid, collections, configparser, subprocess + +class Config: + + def __init__(self, custom_file=None): + self.home = os.path.abspath(os.path.expanduser('~')) + defaults = \ + { + 'cfg_sn_username' : '', + 'cfg_sn_password' : '', + 'cfg_db_path' : os.path.join(self.home, '.sncli'), + 'cfg_search_tags' : 'yes', # with regex searches + 'cfg_sort_mode' : 'date', # 'alpha' or 'date' + 'cfg_pinned_ontop' : 'yes', + 'cfg_tabstop' : '4', + 'cfg_format_strftime' : '%Y/%m/%d', + 'cfg_format_note_title' : '[%D] %F %-N %T', + 'cfg_status_bar' : 'yes', + 'cfg_editor' : os.environ['EDITOR'] if 'EDITOR' in os.environ else 'vim {fname} +{line}', + 'cfg_pager' : os.environ['PAGER'] if 'PAGER' in os.environ else 'less -c', + 'cfg_diff' : 'diff -b -U10', + 'cfg_max_logs' : '5', + 'cfg_log_timeout' : '5', + 'cfg_log_reversed' : 'yes', + 'cfg_sn_host' : 'simple-note.appspot.com', + '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_prev_version' : '<', + 'kb_next_version' : '>', + 'kb_diff_version' : 'D', + 'kb_restore_version' : 'R', + 'kb_latest_version' : 'L', + 'kb_select_version' : '#', + '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_tags' : 'ctrl t', + 'kb_note_trash' : 'T', + 'kb_note_pin' : 'p', + 'kb_note_markdown' : 'm', + 'kb_note_tags' : '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_tags_fg' : 'dark red', + 'clr_note_tags_bg' : 'default', + 'clr_note_content_fg' : 'default', + 'clr_note_content_bg' : 'default', + 'clr_note_content_focus_fg' : 'white', + 'clr_note_content_focus_bg' : 'light red', + 'clr_note_content_old_fg' : 'yellow', + 'clr_note_content_old_bg' : 'dark gray', + 'clr_note_content_old_focus_fg' : 'white', + 'clr_note_content_old_focus_bg' : 'light red', + 'clr_help_focus_fg' : 'white', + 'clr_help_focus_bg' : 'light red', + 'clr_help_header_fg' : 'dark blue', + 'clr_help_header_bg' : 'default', + 'clr_help_config_fg' : 'dark green', + 'clr_help_config_bg' : 'default', + 'clr_help_value_fg' : 'dark red', + 'clr_help_value_bg' : 'default', + 'clr_help_descr_fg' : 'default', + 'clr_help_descr_bg' : 'default' + } + + cp = configparser.SafeConfigParser(defaults) + if custom_file is not None: + self.configs_read = cp.read([custom_file]) + else: + self.configs_read = cp.read([os.path.join(self.home, '.snclirc')]) + + cfg_sec = 'sncli' + + 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 + sn_password = cp.get(cfg_sec, 'cfg_sn_password', raw=True) + if not sn_password: + command = cp.get(cfg_sec, 'cfg_sn_password_eval', raw=True) + if command: + try: + sn_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) + sn_password = sn_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_tags'] = [ cp.get(cfg_sec, 'cfg_search_tags'), 'Search tags as well' ] + self.configs['sort_mode'] = [ cp.get(cfg_sec, 'cfg_sort_mode'), 'Sort mode' ] + self.configs['pinned_ontop'] = [ cp.get(cfg_sec, 'cfg_pinned_ontop'), 'Pinned at top of list' ] + self.configs['tabstop'] = [ cp.get(cfg_sec, 'cfg_tabstop'), 'Tabstop spaces' ] + self.configs['format_strftime'] = [ cp.get(cfg_sec, 'cfg_format_strftime', raw=True), 'Date strftime format' ] + self.configs['format_note_title'] = [ cp.get(cfg_sec, 'cfg_format_note_title', raw=True), 'Note title format' ] + self.configs['status_bar'] = [ cp.get(cfg_sec, 'cfg_status_bar'), 'Show the status bar' ] + self.configs['editor'] = [ cp.get(cfg_sec, 'cfg_editor'), 'Editor command' ] + self.configs['pager'] = [ cp.get(cfg_sec, 'cfg_pager'), 'External pager command' ] + self.configs['diff'] = [ cp.get(cfg_sec, 'cfg_diff'), 'External diff command' ] + self.configs['max_logs'] = [ cp.get(cfg_sec, 'cfg_max_logs'), 'Max logs in footer' ] + self.configs['log_timeout'] = [ cp.get(cfg_sec, 'cfg_log_timeout'), 'Log timeout' ] + self.configs['log_reversed'] = [ cp.get(cfg_sec, 'cfg_log_reversed'), 'Log file reversed' ] + self.configs['tempdir'] = [ cp.get(cfg_sec, 'cfg_tempdir'), 'Temporary directory for note storage' ] + + self.keybinds = collections.OrderedDict() + self.keybinds['help'] = [ cp.get(cfg_sec, 'kb_help'), [ 'common' ], 'Help' ] + self.keybinds['quit'] = [ cp.get(cfg_sec, 'kb_quit'), [ 'common' ], 'Quit' ] + self.keybinds['sync'] = [ cp.get(cfg_sec, 'kb_sync'), [ 'common' ], 'Full sync' ] + self.keybinds['down'] = [ cp.get(cfg_sec, 'kb_down'), [ 'common' ], 'Scroll down one line' ] + self.keybinds['up'] = [ cp.get(cfg_sec, 'kb_up'), [ 'common' ], 'Scroll up one line' ] + self.keybinds['page_down'] = [ cp.get(cfg_sec, 'kb_page_down'), [ 'common' ], 'Page down' ] + self.keybinds['page_up'] = [ cp.get(cfg_sec, 'kb_page_up'), [ 'common' ], 'Page up' ] + self.keybinds['half_page_down'] = [ cp.get(cfg_sec, 'kb_half_page_down'), [ 'common' ], 'Half page down' ] + self.keybinds['half_page_up'] = [ cp.get(cfg_sec, 'kb_half_page_up'), [ 'common' ], 'Half page up' ] + self.keybinds['bottom'] = [ cp.get(cfg_sec, 'kb_bottom'), [ 'common' ], 'Goto bottom' ] + self.keybinds['top'] = [ cp.get(cfg_sec, 'kb_top'), [ 'common' ], 'Goto top' ] + self.keybinds['status'] = [ cp.get(cfg_sec, 'kb_status'), [ 'common' ], 'Toggle status bar' ] + self.keybinds['view_log'] = [ cp.get(cfg_sec, 'kb_view_log'), [ 'common' ], 'View log' ] + self.keybinds['create_note'] = [ cp.get(cfg_sec, 'kb_create_note'), [ 'titles' ], 'Create a new note' ] + self.keybinds['edit_note'] = [ cp.get(cfg_sec, 'kb_edit_note'), [ 'titles', 'notes' ], 'Edit note' ] + self.keybinds['view_note'] = [ cp.get(cfg_sec, 'kb_view_note'), [ 'titles' ], 'View note' ] + self.keybinds['view_note_ext'] = [ cp.get(cfg_sec, 'kb_view_note_ext'), [ 'titles', 'notes' ], 'View note with pager' ] + self.keybinds['view_note_json'] = [ cp.get(cfg_sec, 'kb_view_note_json'), [ 'titles', 'notes' ], 'View note raw json' ] + self.keybinds['pipe_note'] = [ cp.get(cfg_sec, 'kb_pipe_note'), [ 'titles', 'notes' ], 'Pipe note contents' ] + self.keybinds['view_next_note'] = [ cp.get(cfg_sec, 'kb_view_next_note'), [ 'notes' ], 'View next note' ] + self.keybinds['view_prev_note'] = [ cp.get(cfg_sec, 'kb_view_prev_note'), [ 'notes' ], 'View previous note' ] + self.keybinds['tabstop2'] = [ cp.get(cfg_sec, 'kb_tabstop2'), [ 'notes' ], 'View with tabstop=2' ] + self.keybinds['tabstop4'] = [ cp.get(cfg_sec, 'kb_tabstop4'), [ 'notes' ], 'View with tabstop=4' ] + self.keybinds['tabstop8'] = [ cp.get(cfg_sec, 'kb_tabstop8'), [ 'notes' ], 'View with tabstop=8' ] + self.keybinds['prev_version'] = [ cp.get(cfg_sec, 'kb_prev_version'), [ 'notes' ], 'View previous version' ] + self.keybinds['next_version'] = [ cp.get(cfg_sec, 'kb_next_version'), [ 'notes' ], 'View next version' ] + self.keybinds['diff_version'] = [ cp.get(cfg_sec, 'kb_diff_version'), [ 'notes' ], 'Diff version of note' ] + self.keybinds['restore_version'] = [ cp.get(cfg_sec, 'kb_restore_version'), [ 'notes' ], 'Restore version of note' ] + self.keybinds['latest_version'] = [ cp.get(cfg_sec, 'kb_latest_version'), [ 'notes' ], 'View latest version' ] + self.keybinds['select_version'] = [ cp.get(cfg_sec, 'kb_select_version'), [ 'notes' ], 'Select version' ] + 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_tags'] = [ cp.get(cfg_sec, 'kb_sort_tags'), [ 'titles' ], 'Sort notes by tags' ] + self.keybinds['note_trash'] = [ cp.get(cfg_sec, 'kb_note_trash'), [ 'titles', 'notes' ], 'Trash a note' ] + self.keybinds['note_pin'] = [ cp.get(cfg_sec, 'kb_note_pin'), [ 'titles', 'notes' ], 'Pin note' ] + self.keybinds['note_markdown'] = [ cp.get(cfg_sec, 'kb_note_markdown'), [ 'titles', 'notes' ], 'Flag note as markdown' ] + self.keybinds['note_tags'] = [ cp.get(cfg_sec, 'kb_note_tags'), [ 'titles', 'notes' ], 'Edit note tags' ] + 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_tags_fg'] = [ cp.get(cfg_sec, 'clr_note_tags_fg'), 'Note tags fg' ] + self.colors['note_tags_bg'] = [ cp.get(cfg_sec, 'clr_note_tags_bg'), 'Note tags bg' ] + self.colors['note_content_fg'] = [ cp.get(cfg_sec, 'clr_note_content_fg'), 'Note content fg' ] + self.colors['note_content_bg'] = [ cp.get(cfg_sec, 'clr_note_content_bg'), 'Note content bg' ] + self.colors['note_content_focus_fg'] = [ cp.get(cfg_sec, 'clr_note_content_focus_fg'), 'Note content focus fg' ] + self.colors['note_content_focus_bg'] = [ cp.get(cfg_sec, 'clr_note_content_focus_bg'), 'Note content focus bg' ] + self.colors['note_content_old_fg'] = [ cp.get(cfg_sec, 'clr_note_content_old_fg'), 'Old note content fg' ] + self.colors['note_content_old_bg'] = [ cp.get(cfg_sec, 'clr_note_content_old_bg'), 'Old note content bg' ] + self.colors['note_content_old_focus_fg'] = [ cp.get(cfg_sec, 'clr_note_content_old_focus_fg'), 'Old note content focus fg' ] + self.colors['note_content_old_focus_bg'] = [ cp.get(cfg_sec, 'clr_note_content_old_focus_bg'), 'Old note content focus bg' ] + self.colors['help_focus_fg'] = [ cp.get(cfg_sec, 'clr_help_focus_fg'), 'Help focus fg' ] + self.colors['help_focus_bg'] = [ cp.get(cfg_sec, 'clr_help_focus_bg'), 'Help focus bg' ] + self.colors['help_header_fg'] = [ cp.get(cfg_sec, 'clr_help_header_fg'), 'Help header fg' ] + self.colors['help_header_bg'] = [ cp.get(cfg_sec, 'clr_help_header_bg'), 'Help header bg' ] + self.colors['help_config_fg'] = [ cp.get(cfg_sec, 'clr_help_config_fg'), 'Help config fg' ] + self.colors['help_config_bg'] = [ cp.get(cfg_sec, 'clr_help_config_bg'), 'Help config bg' ] + self.colors['help_value_fg'] = [ cp.get(cfg_sec, 'clr_help_value_fg'), 'Help value fg' ] + self.colors['help_value_bg'] = [ cp.get(cfg_sec, 'clr_help_value_bg'), 'Help value bg' ] + self.colors['help_descr_fg'] = [ cp.get(cfg_sec, 'clr_help_descr_fg'), 'Help description fg' ] + self.colors['help_descr_bg'] = [ cp.get(cfg_sec, 'clr_help_descr_bg'), 'Help description bg' ] + + def get_config(self, name): + return self.configs[name][0] + + def get_config_descr(self, name): + return self.configs[name][1] + + def get_keybind(self, name): + return self.keybinds[name][0] + + def get_keybind_use(self, name): + return self.keybinds[name][1] + + def get_keybind_descr(self, name): + return self.keybinds[name][2] + + def get_color(self, name): + return self.colors[name][0] + + def get_color_descr(self, name): + return self.colors[name][1] + diff --git a/nnotes_cli/nncli.py b/nnotes_cli/nncli.py @@ -0,0 +1,1476 @@ + +# Copyright (c) 2014 Eric Davis +# Licensed under the MIT License + +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 +from .config import Config +from .nnotes import NextcloudNote +from .notes_db import NotesDB, ReadError, WriteError +from logging.handlers import RotatingFileHandler + +class sncli: + + 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'), 'sncli.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('sncli 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('sncli database doesn\'t exist, forcing full sync...') + self.sync_notes() + self.verbose = verbose + + def sync_notes(self): + self.ndb.sync_now(self.do_server_sync) + + def get_editor(self): + editor = self.config.get_config('editor') + if not editor: + self.log('No editor configured!') + return None + return editor + + def get_pager(self): + pager = self.config.get_config('pager') + if not pager: + self.log('No pager configured!') + return None + return pager + + def get_diff(self): + diff = self.config.get_config('diff') + if not diff: + self.log('No diff command configured!') + return None + return diff + + def exec_cmd_on_note(self, note, cmd=None, raw=False): + + if not cmd: + cmd = self.get_editor() + if not cmd: + return None + + tf = temp.tempfile_create(note if note else None, raw=raw, tempdir=self.tempdir) + fname = temp.tempfile_name(tf) + + focus_position = 0 + try: + focus_position = self.gui_body_get().focus_position + except IndexError: + # focus position will fail if no notes available (listbox empty) + # TODO: find a neater way to check than try/except + pass + + subs = { + 'fname': fname, + 'line': focus_position + 1, + } + cmd_list = [c.format(**subs) for c in shlex.split(cmd)] + + # if the filename wasn't able to be subbed, append it + # this makes it fully backwards compatible with previous configs + if '{fname}' not in cmd: + cmd_list.append(fname) + + self.log("EXECUTING: {}".format(cmd_list)) + + try: + subprocess.check_call(cmd_list) + except Exception as e: + self.log('Command error: ' + str(e)) + temp.tempfile_delete(tf) + return None + + content = None + if not raw: + content = temp.tempfile_content(tf) + if not content or content == '\n': + content = None + + temp.tempfile_delete(tf) + return content + + def exec_diff_on_note(self, note, old_note): + + diff = self.get_diff() + if not diff: + return None + + pager = self.get_pager() + if not pager: + return None + + ltf = temp.tempfile_create(note, tempdir=self.tempdir) + otf = temp.tempfile_create(old_note, tempdir=self.tempdir) + out = temp.tempfile_create(None, tempdir=self.tempdir) + + try: + subprocess.call(diff + ' ' + + temp.tempfile_name(ltf) + ' ' + + temp.tempfile_name(otf) + ' > ' + + temp.tempfile_name(out), + shell=True) + subprocess.check_call(pager + ' ' + + temp.tempfile_name(out), + shell=True) + except Exception as e: + self.log('Command error: ' + str(e)) + temp.tempfile_delete(ltf) + temp.tempfile_delete(otf) + temp.tempfile_delete(out) + return None + + temp.tempfile_delete(ltf) + temp.tempfile_delete(otf) + temp.tempfile_delete(out) + return None + + def gui_header_clear(self): + self.master_frame.contents['header'] = ( None, None ) + self.sncli_loop.draw_screen() + + def gui_header_set(self, w): + self.master_frame.contents['header'] = ( w, None ) + self.sncli_loop.draw_screen() + + def gui_header_get(self): + return self.master_frame.contents['header'][0] + + def gui_header_focus(self): + self.master_frame.focus_position = 'header' + + def gui_footer_log_clear(self): + ui = self.gui_footer_input_get() + self.master_frame.contents['footer'] = \ + (urwid.Pile([ urwid.Pile([]), urwid.Pile([ui]) ]), None) + self.sncli_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.sncli_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.sncli_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.sncli_loop.draw_screen() + + def gui_footer_input_get(self): + return self.master_frame.contents['footer'][0].contents[1][0] + + def gui_footer_focus_input(self): + self.master_frame.focus_position = 'footer' + self.master_frame.contents['footer'][0].focus_position = 1 + + def gui_body_clear(self): + self.master_frame.contents['body'] = ( None, None ) + self.sncli_loop.draw_screen() + + def gui_body_set(self, w): + self.master_frame.contents['body'] = ( w, None ) + self.gui_update_status_bar() + self.sncli_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.sncli_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 trash_note_callback(self, key, yes): + if not yes: + return + + # toggle the deleted flag + note = self.ndb.get_note(key) + self.ndb.set_note_deleted(key, 0 if note['deleted'] else 1) + + 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 restore_note_callback(self, key, yes): + if not yes: + return + + # restore the contents of the old_note + self.log('Restoring version v{0} (key={1})'. + format(self.view_note.old_note['version'], key)) + self.ndb.set_note_content(key, self.view_note.old_note['content']) + + self.view_note.update_note_view() + 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_version_input(self, args, version): + self.gui_footer_input_clear() + self.gui_body_focus() + self.master_frame.keypress = self.gui_frame_keypress + if version: + try: + # verify input is a number + int(version) + except ValueError as e: + self.log('ERROR: Invalid version value') + return + self.view_note.update_note_view(version=version) + self.gui_update_status_bar() + + def gui_tags_input(self, args, tags): + self.gui_footer_input_clear() + self.gui_body_focus() + self.master_frame.keypress = self.gui_frame_keypress + if tags != 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_tags(note['localkey'], tags) + + 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('prev_version') or \ + key == self.config.get_keybind('next_version'): + if self.gui_body_get().__class__ != view_note.ViewNote: + return key + + diff = -1 if key == self.config.get_keybind('prev_version') else 1 + + version = diff + (self.view_note.old_note['version'] + if self.view_note.old_note else + self.view_note.note['version']) + + lb.update_note_view(version=version) + + elif key == self.config.get_keybind('diff_version'): + if self.gui_body_get().__class__ != view_note.ViewNote: + return key + + if not self.view_note.old_note: + self.log('Already at latest version (key={0})'. + format(self.view_note.key)) + return None + + self.gui_clear() + self.exec_diff_on_note(self.view_note.note, + self.view_note.old_note) + self.gui_reset() + + elif key == self.config.get_keybind('restore_version'): + if self.gui_body_get().__class__ != view_note.ViewNote: + return key + + if not self.view_note.old_note: + self.log('Already at latest version (key={0})'. + format(self.view_note.key)) + return None + + self.gui_footer_input_set( + urwid.AttrMap( + user_input.UserInput( + self.config, + 'Restore v{0} (y/n): '.format(self.view_note.old_note['version']), + '', + self.gui_yes_no_input, + [ self.restore_note_callback, self.view_note.key ]), + 'user_input_bar')) + self.gui_footer_focus_input() + self.master_frame.keypress = self.gui_footer_input_get().keypress + + elif key == self.config.get_keybind('latest_version'): + if self.gui_body_get().__class__ != view_note.ViewNote: + return key + + lb.update_note_view(version=None) + + elif key == self.config.get_keybind('select_version'): + if self.gui_body_get().__class__ != view_note.ViewNote: + return key + + self.gui_footer_input_set( + urwid.AttrMap( + user_input.UserInput( + self.config, + key, + '', + self.gui_version_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('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_trash'): + 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, + '{0} (y/n): '.format('Untrash' if note['deleted'] else 'Trash'), + '', + self.gui_yes_no_input, + [ self.trash_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_pin'): + 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 + + pin = 1 + if 'systemtags' in note: + if 'pinned' in note['systemtags']: pin = 0 + else: pin = 1 + + self.ndb.set_note_pinned(note['localkey'], pin) + + 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_markdown'): + 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 + + md = 1 + if 'systemtags' in note: + if 'markdown' in note['systemtags']: md = 0 + else: md = 1 + + self.ndb.set_note_markdown(note['localkey'], md) + + 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_tags'): + 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, + 'Tags: ', + '%s' % ','.join(note['tags']), + self.gui_tags_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_tags'): + if self.gui_body_get().__class__ != view_titles.ViewTitles: + return key + + self.current_sort_mode = 'tags' + self.view_titles.sort_note_list('tags') + + 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.sncli_loop.widget = urwid.Filler(urwid.Text('')) + self.sncli_loop.draw_screen() + + def gui_reset(self): + self.sncli_loop.widget = self.master_frame + self.sncli_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, + 'key' : 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_tags', + self.config.get_color('note_tags_fg'), + self.config.get_color('note_tags_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.sncli_loop = urwid.MainLoop(self.master_frame, + palette, + handle_mouse=False) + + self.sncli_loop.set_alarm_in(0, self.gui_init_view, + True if key else False) + + self.sncli_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((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['modifydate'])) + mod_time = time.strftime('%a, %d %b %Y %H:%M:%S', t) + title = utils.get_note_title(note) + flags = utils.get_note_flags(note) + tags = utils.get_note_tags(note) + + print(sep) + print(('| {:<' + str(w) + '} |').format((' Title: ' + title)[:w])) + print(('| {:<' + str(w) + '} |').format((' Key: ' + note.get('key', 'Localkey: {}'.format(note.get('localkey'))))[:w])) + print(('| {:<' + str(w) + '} |').format((' Date: ' + mod_time)[:w])) + print(('| {:<' + str(w) + '} |').format((' Tags: ' + tags)[:w])) + print(('| {:<' + str(w) + '} |').format((' Version: v' + str(note.get('version', 0)))[:w])) + print(('| {:<' + str(w) + '} |').format((' Flags: [' + flags + ']')[:w])) + if utils.note_published(note) and 'publishkey' in note: + print(('| {:<' + str(w) + '} |').format(('Published: http://simp.ly/publish/' + note['publishkey'])[:w])) + else: + print(('| {:<' + str(w) + '} |').format(('Published: n/a')[: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_trash(self, key, trash): + + note = self.ndb.get_note(key) + if not note: + self.log('ERROR: Key does not exist') + return + + self.ndb.set_note_deleted(key, trash) + self.sync_notes() + + def cli_note_pin(self, key, pin): + + note = self.ndb.get_note(key) + if not note: + self.log('ERROR: Key does not exist') + return + + self.ndb.set_note_pinned(key, pin) + self.sync_notes() + + def cli_note_markdown(self, key, markdown): + + note = self.ndb.get_note(key) + if not note: + self.log('ERROR: Key does not exist') + return + + self.ndb.set_note_markdown(key, markdown) + self.sync_notes() + + def cli_note_tags_get(self, key): + + note = self.ndb.get_note(key) + if not note: + self.log('ERROR: Key does not exist') + return + + tags = utils.get_note_tags(note) + return tags + + def cli_note_tags_set(self, key, tags): + + note = self.ndb.get_note(key) + if not note: + self.log('Error: Key does not exist') + return + + self.ndb.set_note_tags(key, tags.lower()) + self.sync_notes() + + def cli_note_tags_add(self, key, new_tags): + + note = self.ndb.get_note(key) + if not note: + self.log('Error: Key does not exist') + return + + # Add tag only if it isn't already there + old_tags = self.cli_note_tags_get(key) + if old_tags: + old_tag_list = old_tags.lower().split(',') + new_tag_list = new_tags.lower().split(',') + tag_list = old_tag_list + for tag in new_tag_list: + if tag not in tag_list: + tag_list.append(tag) + tags = ','.join(tag_list) + else: + tags = new_tags + + self.cli_note_tags_set(key, tags) + + def cli_note_tags_rm(self, key, rm_tags): + + note = self.ndb.get_note(key) + if not note: + self.log('Error: Key does not exist') + return + + old_tags = self.cli_note_tags_get(key) + if old_tags: + old_tag_list = old_tags.lower().split(',') + rm_tag_list = rm_tags.lower().split(',') + tag_list = old_tag_list + for tag in rm_tag_list: + if tag in tag_list: + tag_list.remove(tag) + tags = ','.join(tag_list) + self.cli_note_tags_set(key, tags) + +def SIGINT_handler(signum, frame): + print('\nSignal caught, bye!') + sys.exit(1) + +signal.signal(signal.SIGINT, SIGINT_handler) + +def usage(): + print (''' +Usage: + sncli [OPTIONS] [COMMAND] [COMMAND_ARGS] + + OPTIONS: + -h, --help - usage help + -v, --verbose - verbose output + -n, --nosync - don't perform a server sync + -r, --regex - search string is a regular expression + -k <key>, --key=<key> - note key + -t <title>, --title=<title> - title of note for create (cli mode) + -c <file>, --config=<file> - config file to read from (defaults to ~/.snclirc) + + COMMANDS: + <none> - console gui mode when no command specified + sync - perform a full sync with the server + list [search_string] - list notes (refined with search string) + export [search_string] - export notes in JSON (refined with search string) + dump [search_string] - dump notes (refined with search string) + create [-] - create a note ('-' content from stdin) + import [-] - import a note in JSON format ('-' JSON from stdin) + export - export a note in JSON format (specified by <key>) + dump - dump a note (specified by <key>) + edit - edit a note (specified by <key>) + < trash | untrash > - trash/untrash a note (specified by <key>) + < pin | unpin > - pin/unpin a note (specified by <key>) + < markdown | unmarkdown > - markdown/unmarkdown a note (specified by <key>) + tag get - retrieve the tags from a note (specified by <key>) + tag set <tags> - set the tags for a note (specified by <key>) + tag add <tags> - add tags to a note (specified by <key>) + tag rm <tags> - remove tags from a note (specified by <key>) +''') + sys.exit(0) + + +def main(argv=sys.argv[1:]): + verbose = False + sync = True + regex = False + key = None + title = None + config = None + + try: + opts, args = getopt.getopt(argv, + 'hvnrk:t:c:', + [ 'help', 'verbose', 'nosync', 'regex', 'key=', 'title=', 'config=' ]) + except: + usage() + + for opt, arg in opts: + if opt in [ '-h', '--help']: + usage() + elif opt in [ '-v', '--verbose']: + verbose = True + elif opt in [ '-n', '--nosync']: + sync = False + elif opt in [ '-r', '--regex']: + regex = True + elif opt in [ '-k', '--key']: + key = arg + elif opt in [ '-t', '--title']: + title = arg + elif opt in [ '-c', '--config']: + config = arg + else: + print('ERROR: Unhandled option') + usage() + + if not args: + sncli(sync, verbose, config).gui(key) + return + + def sncli_start(sync=sync, verbose=verbose, config=config): + sn = sncli(sync, verbose, config) + if sync: sn.sync_notes() + return sn + + if args[0] == 'sync': + sn = sncli_start(True) + + elif args[0] == 'list': + + sn = sncli_start() + sn.cli_list_notes(regex, ' '.join(args[1:])) + + elif args[0] == 'dump': + + sn = sncli_start() + if key: + sn.cli_note_dump(key) + else: + sn.cli_dump_notes(regex, ' '.join(args[1:])) + + elif args[0] == 'create': + + if len(args) == 1: + sn = sncli_start() + sn.cli_note_create(False, title) + elif len(args) == 2 and args[1] == '-': + sn = sncli_start() + sn.cli_note_create(True, title) + else: + usage() + + elif args[0] == 'import': + + if len(args) == 1: + sn = sncli_start() + sn.cli_note_import(False) + elif len(args) == 2 and args[1] == '-': + sn = sncli_start() + sn.cli_note_import(True) + else: + usage() + + elif args[0] == 'export': + + sn = sncli_start() + if key: + sn.cli_note_export(key) + else: + sn.cli_export_notes(regex, ' '.join(args[1:])) + + elif args[0] == 'edit': + + if not key: + usage() + + sn = sncli_start() + sn.cli_note_edit(key) + + elif args[0] == 'trash' or args[0] == 'untrash': + + if not key: + usage() + + sn = sncli_start() + sn.cli_note_trash(key, 1 if args[0] == 'trash' else 0) + + elif args[0] == 'pin' or args[0] == 'unpin': + + if not key: + usage() + + sn = sncli_start() + sn.cli_note_pin(key, 1 if args[0] == 'pin' else 0) + + elif args[0] == 'markdown' or args[0] == 'unmarkdown': + + if not key: + usage() + + sn = sncli_start() + sn.cli_note_markdown(key, 1 if args[0] == 'markdown' else 0) + + # Tag API + elif args[0] == 'tag': + + if not key: + usage() + + nargs = len(args) + correct_get = (args[1] == 'get' and nargs == 2) + correct_other = (args[1] in ['set', 'add', 'rm'] and nargs == 3) + if not (correct_get or correct_other): + usage() + + if args[1] == 'get': + + sn = sncli_start() + tags = sn.cli_note_tags_get(key) + if tags: + print(tags) + + elif args[1] == 'set': + + tags = args[2] + sn = sncli_start() + sn.cli_note_tags_set(key, tags) + + elif args[1] == 'add': + + new_tags = args[2] + sn = sncli_start() + sn.cli_note_tags_add(key, new_tags) + + elif args[1] == 'rm': + + rm_tags = args[2] + sn = sncli_start() + sn.cli_note_tags_rm(key, rm_tags) + + else: + usage() + diff --git a/nnotes_cli/nnotes.py b/nnotes_cli/nnotes.py @@ -0,0 +1,382 @@ +# +# The MIT License (MIT) +# +# Copyright (c) 2018 Daniel Moch +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +# Copyright (c) 2014 Eric Davis +# This file is *slightly* modified from simplynote.py. + +# -*- coding: utf-8 -*- +""" + nnotes.py + ~~~~~~~~~~~~~~ + + Python library for accessing the NextCloud Notes API (v0.2) + + Modified from simplnote.py + :copyright: (c) 2011 by Daniel Schauenberg + :license: MIT, see LICENSE for more details. +""" + +import urllib.parse +from requests.exceptions import RequestException, ConnectionError +import base64 +import time +import datetime +import logging +import requests + +try: + import json +except ImportError: + try: + import simplejson as json + except ImportError: + # For Google AppEngine + from django.utils import simplejson as json + +NOTE_FETCH_LENGTH = 100 + +class NextcloudLoginFailed(Exception): + pass + +class NextcloudNote(object): + """ Class for interacting with the NextCloud Notes web service """ + + def __init__(self, username, password, host): + """ object constructor """ + self.username = urllib.parse.quote(username) + self.password = urllib.parse.quote(password) + self.AUTH_URL = 'https://{0}/api/login'.format(host) + self.DATA_URL = 'https://{0}/api2/data'.format(host) + self.INDX_URL = 'https://{0}/api2/index?'.format(host) + self.token = None + self.status = 'offline' + + def authenticate(self, user, password): + """ Method to get NextCloud Notes auth token + + Arguments: + - user (string): NextCloud username + - password (string): NextCloud password + + Returns: + NextCloud API token as string + + """ + auth_params = "email=%s&password=%s" % (user, password) + values = base64.encodestring(auth_params.encode()) + try: + res = requests.post(self.AUTH_URL, data=values) + token = res.text + if res.status_code != 200: + self.status = 'login failed with status {}, check credentials'.format(res.status_code) + token = None + else: + self.status = 'online' + except ConnectionError as e: + token = None + self.status = 'offline, connection error' + except RequestException as e: + token = None + self.status = 'login failed, check log' + + logging.debug('AUTHENTICATE: ' + self.status) + return token + + def get_token(self): + """ Method to retrieve an auth token. + + The cached global token is looked up and returned if it exists. If it + is `None` a new one is requested and returned. + + Returns: + NextCloud API token as string + + """ + if self.token is None: + self.token = self.authenticate(self.username, self.password) + return self.token + + + def get_note(self, noteid, version=None): + """ method to get a specific note + + Arguments: + - noteid (string): ID of the note to get + - version (int): optional version of the note to get + + Returns: + A tuple `(note, status)` + + - note (dict): note object + - status (int): 0 on sucesss and -1 otherwise + + """ + # request note + params_version = "" + if version is not None: + params_version = '/' + str(version) + + params = {'auth': self.get_token(), + 'email': self.username } + url = '{}/{}{}'.format(self.DATA_URL, str(noteid), params_version) + #logging.debug('REQUEST: ' + self.DATA_URL+params) + try: + res = requests.get(url, params=params) + res.raise_for_status() + note = res.json() + except ConnectionError as e: + self.status = 'offline, connection error' + return e, -1 + except RequestException as e: + # logging.debug('RESPONSE ERROR: ' + str(e)) + return e, -1 + except ValueError as e: + return e, -1 + + # # use UTF-8 encoding + # note["content"] = note["content"].encode('utf-8') + # # For early versions of notes, tags not always available + # if "tags" in note: + # note["tags"] = [t.encode('utf-8') for t in note["tags"]] + #logging.debug('RESPONSE OK: ' + str(note)) + return note, 0 + + def update_note(self, note): + """ function to update a specific note object, if the note object does not + have a "key" field, a new note is created + + Arguments + - note (dict): note object to update + + Returns: + A tuple `(note, status)` + + - note (dict): note object + - status (int): 0 on sucesss and -1 otherwise + + """ + # Note: all strings in notes stored as type str + # - use s.encode('utf-8') when bytes type needed + + # determine whether to create a new note or updated an existing one + params = {'auth': self.get_token(), + 'email': self.username} + if "key" in note: + # set modification timestamp if not set by client + if 'modifydate' not in note: + note["modifydate"] = time.time() + + url = '%s/%s' % (self.DATA_URL, note["key"]) + else: + url = self.DATA_URL + + #logging.debug('REQUEST: ' + url + ' - ' + str(note)) + try: + data = urllib.parse.quote(json.dumps(note)) + res = requests.post(url, data=data, params=params) + res.raise_for_status() + note = res.json() + except ConnectionError as e: + self.status = 'offline, connection error' + return e, -1 + except RequestException as e: + logging.debug('RESPONSE ERROR: ' + str(e)) + self.status = 'error updating note, check log' + return e, -1 + except ValueError as e: + return e, -1 + #logging.debug('RESPONSE OK: ' + str(note)) + return note, 0 + + def add_note(self, note): + """wrapper function to add a note + + The function can be passed the note as a dict with the `content` + property set, which is then directly send to the web service for + creation. Alternatively, only the body as string can also be passed. In + this case the parameter is used as `content` for the new note. + + Arguments: + - note (dict or string): the note to add + + Returns: + A tuple `(note, status)` + + - note (dict): the newly created note + - status (int): 0 on sucesss and -1 otherwise + + """ + if type(note) == str: + return self.update_note({"content": note}) + elif (type(note) == dict) and "content" in note: + return self.update_note(note) + else: + return "No string or valid note.", -1 + + def get_note_list(self, since=None, tags=[]): + """ function to get the note list + + The function can be passed optional arguments to limit the + date range of the list returned and/or limit the list to notes + containing a certain tag. If omitted a list of all notes + is returned. + + Arguments: + - since=time.time() epoch stamp: only return notes modified + since this date + - tags=[] list of tags as string: return notes that have + at least one of these tags + + Returns: + A tuple `(notes, status)` + + - notes (list): A list of note objects with all properties set except + `content`. + - status (int): 0 on sucesss and -1 otherwise + + """ + # initialize data + status = 0 + notes = { "data" : [] } + json_data = {} + + # get the note index + params = {'auth': self.get_token(), + 'email': self.username, + 'length': NOTE_FETCH_LENGTH + } + if since is not None: + params['since'] = since + + # perform initial HTTP request + try: + #logging.debug('REQUEST: ' + self.INDX_URL+params) + res = requests.get(self.INDX_URL, params=params) + res.raise_for_status() + #logging.debug('RESPONSE OK: ' + str(res)) + json_data = res.json() + notes["data"].extend(json_data["data"]) + except ConnectionError as e: + self.status = 'offline, connection error' + status = -1 + except RequestException as e: + # if problem with network request/response + status = -1 + except ValueError as e: + # if invalid json data + status = -1 + + # get additional notes if bookmark was set in response + while "mark" in json_data: + params = {'auth': self.get_token(), + 'email': self.username, + 'mark': json_data['mark'], + 'length': NOTE_FETCH_LENGTH + } + if since is not None: + params['since'] = since + + # perform the actual HTTP request + try: + #logging.debug('REQUEST: ' + self.INDX_URL+params) + res = requests.get(self.INDX_URL, params=params) + res.raise_for_status() + json_data = res.json() + #logging.debug('RESPONSE OK: ' + str(res)) + notes["data"].extend(json_data["data"]) + except ConnectionError as e: + self.status = 'offline, connection error' + status = -1 + except RequestException as e: + # if problem with network request/response + status = -1 + except ValueError as e: + # if invalid json data + status = -1 + + # parse data fields in response + note_list = notes["data"] + + # Can only filter for tags at end, once all notes have been retrieved. + #Below based on simplenote.vim, except we return deleted notes as well + if (len(tags) > 0): + note_list = [n for n in note_list if (len(set(n["tags"]).intersection(tags)) > 0)] + + return note_list, status + + def trash_note(self, note_id): + """ method to move a note to the trash + + Arguments: + - note_id (string): key of the note to trash + + Returns: + A tuple `(note, status)` + + - note (dict): the newly created note or an error message + - status (int): 0 on sucesss and -1 otherwise + + """ + # get note + note, status = self.get_note(note_id) + if (status == -1): + return note, status + # set deleted property + note["deleted"] = 1 + # update note + return self.update_note(note) + + def delete_note(self, note_id): + """ method to permanently delete a note + + Arguments: + - note_id (string): key of the note to trash + + Returns: + A tuple `(note, status)` + + - note (dict): an empty dict or an error message + - status (int): 0 on sucesss and -1 otherwise + + """ + # notes have to be trashed before deletion + note, status = self.trash_note(note_id) + if (status == -1): + return note, status + + params = {'auth': self.get_token(), + 'email': self.username } + url = '{}/{}'.format(self.DATA_URL, str(note_id)) + + try: + #logging.debug('REQUEST DELETE: ' + self.DATA_URL+params) + res = requests.delete(url, params=params) + res.raise_for_status() + except ConnectionError as e: + self.status = 'offline, connection error' + return e, -1 + except RequestException as e: + return e, -1 + return {}, 0 + diff --git a/nnotes_cli/notes_db.py b/nnotes_cli/notes_db.py @@ -0,0 +1,709 @@ + +# +# The MIT License (MIT) +# +# Copyright (c) 2018 Daniel Moch +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +# Copyright (c) 2014 Eric Davis +# This file is *heavily* modified from nvpy. + +# nvPY: cross-platform note-taking app with simplenote syncing +# copyright 2012 by Charl P. Botha <cpbotha@vxlabs.com> +# new BSD license + +import os, time, re, glob, json, copy, threading +from . import utils +from . import nnotes +nnotes.NOTE_FETCH_LENGTH=100 +from .nnotes 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 = 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['key'] yet (no sync) + localkey = n.get('localkey', os.path.splitext(os.path.basename(fn))[0]) + # we maintain in memory a timestamp of the last save + # these notes have just been read, so at this moment + # they're in sync with the disc. + n['savedate'] = now + # set a localkey to each note in memory + # Note: 'key' is used only for syncing with server - 'localkey' + # is used for everything else in sncli + n['localkey'] = localkey + + # add the note to our database + self.notes[localkey] = n + + # initialise the NextCloud instance we're going to use + # this does not yet need network access + self.note = NextcloudNote(self.config.get_config('nn_username'), + self.config.get_config('nn_password'), + self.config.get_config('nn_host')) + + # we'll use this to store which notes are currently being synced by + # the background thread, so we don't add them anew if they're still + # in progress. This variable is only used by the background thread. + self.threaded_syncing_keys = {} + + def filtered_notes_sort(self, filtered_notes, sort_mode='date'): + if sort_mode == 'date': + if self.config.get_config('pinned_ontop') == 'yes': + filtered_notes.sort(key=utils.sort_by_modify_date_pinned, reverse=True) + else: + filtered_notes.sort(key=lambda o: -float(o.note.get('modifydate', 0))) + elif sort_mode == 'alpha': + if self.config.get_config('pinned_ontop') == 'yes': + filtered_notes.sort(key=utils.sort_by_title_pinned) + else: + filtered_notes.sort(key=lambda o: utils.get_note_title(o.note)) + elif sort_mode == 'tags': + pinned = self.config.get_config('pinned_ontop') + utils.sort_notes_by_tags(filtered_notes, pinned_ontop=pinned) + + 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_tagmatch(self, tag_pats, note): + # Returns: + # 2 = match - no tag patterns specified + # 1 = match - all tag patterns match a tag on this note + # 0 = no match - note has no tags or not all tag patterns match + + if not tag_pats: + # match because no tag patterns were specified + return 2 + + note_tags = note.get('tags') + + if not note_tags: + # tag patterns specified but note has no tags, so no match + return 0 + + # for each tag_pat, we have to find a matching tag + # .lower() used for case-insensitive search + tag_pats_matched = 0 + for tp in tag_pats: + tp = tp.lower() + for t in note_tags: + if tp in t.lower(): + tag_pats_matched += 1 + break + + if tag_pats_matched == len(tag_pats): + # all tag patterns specified matched a tag 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 = [] + + # total number of notes, excluding deleted + # if tag:trash then counts deleted as well + active_notes = 0 + + if not search_string: + for k in self.notes: + n = self.notes[k] + if n.get('deleted'): + continue + active_notes += 1 + filtered_notes.append(utils.KeyValueObject(key=k, note=n, tagfound=0)) + + return filtered_notes, [], active_notes + + # group0: tag:([^\s]+) + # group1: multiple words in quotes + # group2: single words + + # example result for: 'tag:tag1 tag:tag2 word1 "word2 word3" tag:tag3' + # [ ('tag1', '', ''), + # ('tag2', '', ''), + # ('', '', 'word1'), + # ('', 'word2 word3', ''), + # ('tag3', '', '') ] + + groups = re.findall('tag:([^\s]+)|"([^"]+)"|([^\s]+)', search_string) + all_pats = [[] for _ in range(3)] + + search_trash = False + for g in groups: + if g[0] == 'trash': + groups.remove(g) + search_trash = True + + # we end up with [[tag_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] + + if not search_trash and n.get('deleted'): + continue + + active_notes += 1 + + if search_trash and len(groups) == 0: + # simple search of only 'tag:trash' to get all trashed notes + if n.get('deleted'): + filtered_notes.append( + utils.KeyValueObject(key=k, + note=n, + tagfound=1)) + continue + + tagmatch = self._helper_gstyle_tagmatch(all_pats[0], n) + + word_pats = all_pats[1] + all_pats[2] + + if tagmatch 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, + tagfound=1 if tagmatch == 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, tagfound=0)) + continue + + if self.config.get_config('search_tags') == 'yes': + tag_matched = False + for t in n.get('tags'): + if sspat.search(t): + tag_matched = True + filtered_notes.append(utils.KeyValueObject(key=k, note=n, tagfound=1)) + break + if tag_matched: + continue + + if sspat.search(n.get('content')): + filtered_notes.append(utils.KeyValueObject(key=k, note=n, tagfound=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['key'] if note.get('key') else utils.generate_random_key() + while new_key in self.notes: + new_key = utils.generate_random_key() + + timestamp = time.time() + + try: + modifydate = float(note.get('modifydate', timestamp)) + createdate = float(note.get('createdate', timestamp)) + except ValueError: + raise ValueError('date fields must be numbers or string representations of numbers') + + # note has no internal key yet. + new_note = { + 'content' : note.get('content', ''), + 'deleted' : note.get('deleted', 0), + 'modifydate' : modifydate, + 'createdate' : createdate, + 'savedate' : 0, # never been written to disc + 'syncdate' : 0, # never been synced with server + 'tags' : note.get('tags', []), + 'systemtags' : note.get('systemtags', []) + } + + # sanity check all note values + if not isinstance(new_note['content'], str): + raise ValueError('"content" must be a string') + if not new_note['deleted'] in (0, 1): + raise ValueError('"deleted" must be 0 or 1') + + for n in (new_note['modifydate'], new_note['createdate']): + if not 0 <= n <= timestamp: + raise ValueError('date fields must be real') + + if not isinstance(new_note['tags'], list): + raise ValueError('"tags" must be an array') + for tag in new_note['tags']: + if not isinstance(tag, str): + raise ValueError('items in the "tags" array must be strings') + + if not isinstance(new_note['systemtags'], list): + raise ValueError('"systemtags" must be an array') + for tag in new_note['systemtags']: + if not isinstance(tag, str): + raise ValueError('items in the "systemtags" array must be strings') + + 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 = time.time() + + # note has no internal key yet. + new_note = { + 'localkey' : new_key, + 'content' : content, + 'deleted' : 0, + 'modifydate' : timestamp, + 'createdate' : timestamp, + 'savedate' : 0, # never been written to disc + 'syncdate' : 0, # never been synced with server + 'tags' : [] + } + + self.notes[new_key] = new_note + + return new_key + + def get_note(self, key): + return self.notes[key] + + def get_note_systemtags(self, key): + return self.notes[key].get('systemtags') + + def get_note_tags(self, key): + return self.notes[key].get('tags') + + def get_note_content(self, key): + return self.notes[key].get('content') + + def flag_what_changed(self, note, what_changed): + if 'what_changed' not in note: + note['what_changed'] = [] + if what_changed not in note['what_changed']: + note['what_changed'].append(what_changed) + + def set_note_deleted(self, key, deleted): + n = self.notes[key] + if (not n['deleted'] and deleted) or \ + (n['deleted'] and not deleted): + n['deleted'] = deleted + n['modifydate'] = time.time() + self.flag_what_changed(n, 'deleted') + self.log('Note {0} (key={1})'.format('trashed' if deleted else 'untrashed', 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['modifydate'] = time.time() + self.flag_what_changed(n, 'content') + self.log('Note content updated (key={0})'.format(key)) + + def set_note_tags(self, key, tags): + n = self.notes[key] + old_tags = n.get('tags') + tags = utils.sanitise_tags(tags) + if tags != old_tags: + n['tags'] = tags + n['modifydate'] = time.time() + self.flag_what_changed(n, 'tags') + self.log('Note tags updated (key={0})'.format(key)) + + def set_note_pinned(self, key, pinned): + n = self.notes[key] + old_pinned = utils.note_pinned(n) + if pinned != old_pinned: + if 'systemtags' not in n: + n['systemtags'] = [] + systemtags = n['systemtags'] + if pinned: + systemtags.append('pinned') + else: + systemtags.remove('pinned') + n['modifydate'] = time.time() + self.flag_what_changed(n, 'systemtags') + self.log('Note {0} (key={1})'.format('pinned' if pinned else 'unpinned', key)) + + def set_note_markdown(self, key, markdown): + n = self.notes[key] + old_markdown = utils.note_markdown(n) + if markdown != old_markdown: + if 'systemtags' not in n: + n['systemtags'] = [] + systemtags = n['systemtags'] + if markdown: + systemtags.append('markdown') + else: + systemtags.remove('markdown') + n['modifydate'] = time.time() + self.flag_what_changed(n, 'systemtags') + self.log('Note markdown {0} (key={1})'.format('flagged' if markdown else 'unflagged', key)) + + def helper_key_to_fname(self, k): + return os.path.join(self.config.get_config('db_path'), k) + '.json' + + 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'] = 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 = time.time() + + sync_start_time = 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('key') or \ + float(n.get('modifydate')) > float(n.get('syncdate')): + + savedate = float(n.get('savedate')) + if float(n.get('modifydate')) > 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['createdate'] + del cn['syncdate'] + del cn['savedate'] + + if 'what_changed' in cn: + if 'deleted' not in cn['what_changed']: + del cn['deleted'] + if 'systemtags' not in cn['what_changed'] and 'systemtags' in cn: + del cn['systemtags'] + if 'tags' not in cn['what_changed']: + del cn['tags'] + if 'content' not in cn['what_changed']: + del cn['content'] + del cn['what_changed'] + + 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('key') + n.update(uret[0]) + n['syncdate'] = now + n['localkey'] = k + 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(since=None if full_sync else self.last_sync) + + 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 syncnum > local syncnum || + # a new note and key is not in local store + # retrieve note, update note with response + if not skip_remote_syncing: + len_nl = len(nl) + for note_index, n in enumerate(nl): + k = n.get('key') + 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('syncnum')) > int(self.notes[k].get('syncnum', -1)): + 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.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.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_version(self, key, version): + gret = self.note.get_note(key, version) + return gret[0] if gret[1] == 0 else None + + def get_note_status(self, key): + n = self.notes[key] + o = utils.KeyValueObject(saved=False, synced=False, modified=False) + modifydate = float(n['modifydate']) + savedate = float(n['savedate']) + syncdate = float(n['syncdate']) + + if savedate > modifydate: + o.saved = True + else: + o.modified = True + + if syncdate > modifydate: + o.synced = True + + return o + + def verify_all_saved(self): + all_saved = True + self.sync_lock.acquire() + for k in list(self.notes.keys()): + o = self.get_note_status(k) + if not o.saved: + all_saved = False + break + self.sync_lock.release() + return all_saved + + def sync_now(self, do_server_sync=True): + self.sync_lock.acquire() + self.sync_notes(server_sync=do_server_sync, + full_sync=True if not self.last_sync else False) + self.sync_lock.release() + + # sync worker thread... + def sync_worker(self, do_server_sync): + time.sleep(1) # give some time to wait for GUI initialization + self.log('Sync worker: started') + self.sync_now(do_server_sync) + while True: + self.go_cond.acquire() + self.go_cond.wait(15) + self.sync_now(do_server_sync) + self.go_cond.release() + + def sync_worker_go(self): + self.go_cond.acquire() + self.go_cond.notify() + self.go_cond.release() + diff --git a/simplenote_cli/temp.py b/nnotes_cli/temp.py diff --git a/simplenote_cli/user_input.py b/nnotes_cli/user_input.py diff --git a/nnotes_cli/utils.py b/nnotes_cli/utils.py @@ -0,0 +1,223 @@ +# +# The MIT License (MIT) +# +# Copyright (c) 2018 Daniel Moch +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +# Copyright (c) 2014 Eric Davis +# This file is *heavily* modified from nvpy. + +# nvPY: cross-platform note-taking app with simplenote syncing +# copyright 2012 by Charl P. Botha <cpbotha@vxlabs.com> +# new BSD license + +import datetime, random, re + +# first line with non-whitespace should be the title +note_title_re = re.compile('\s*(.*)\n?') + +def generate_random_key(): + """Generate random 30 digit (15 byte) hex string. + + stackoverflow question 2782229 + """ + return '%030x' % (random.randrange(256**15),) + +def get_note_tags(note): + if 'tags' in note: + tags = '%s' % ','.join(note['tags']) + if 'deleted' in note and note['deleted']: + if tags: tags += ',trash' + else: tags = 'trash' + else: + tags = '' + return tags + +# Returns a fixed length string: +# 'X' - needs sync +# 'T' - trashed +# '*' - pinned +# 'S' - published/shared +# 'm' - markdown +def get_note_flags(note): + flags = '' + flags += 'X' if float(note['modifydate']) > float(note['syncdate']) else ' ' + flags += 'T' if 'deleted' in note and note['deleted'] else ' ' + if 'systemtags' in note: + flags += '*' if 'pinned' in note['systemtags'] else ' ' + flags += 'S' if 'published' in note['systemtags'] else ' ' + flags += 'm' if 'markdown' in note['systemtags'] else ' ' + else: + flags += ' ' + return flags + +def get_note_title(note): + mo = note_title_re.match(note.get('content', '')) + if mo: + return mo.groups()[0] + else: + return '' + +def get_note_title_file(note): + mo = note_title_re.match(note.get('content', '')) + if mo: + fn = mo.groups()[0] + fn = fn.replace(' ', '_') + fn = fn.replace('/', '_') + if not fn: + return '' + + if isinstance(fn, str): + fn = str(fn, 'utf-8') + else: + fn = str(fn) + + if note_markdown(note): + fn += '.mkdn' + else: + fn += '.txt' + + return fn + else: + return '' + +def human_date(timestamp): + """ + Given a timestamp, return pretty human format representation. + + For example, if timestamp is: + * today, then do "15:11" + * else if it is this year, then do "Aug 4" + * else do "Dec 11, 2011" + """ + + # this will also give us timestamp in the local timezone + dt = datetime.datetime.fromtimestamp(timestamp) + # this returns localtime + now = datetime.datetime.now() + + if dt.date() == now.date(): + # today: 15:11 + return dt.strftime('%H:%M') + + elif dt.year == now.year: + # this year: Aug 6 + # format code %d unfortunately 0-pads + return dt.strftime('%b') + ' ' + str(dt.day) + + else: + # not today or this year, so we do "Dec 11, 2011" + return '%s %d, %d' % (dt.strftime('%b'), dt.day, dt.year) + +def note_published(n): + asystags = n.get('systemtags', 0) + if not asystags: + return 0 + return 1 if 'published' in asystags else 0 + +def note_pinned(n): + asystags = n.get('systemtags', 0) + if not asystags: + return 0 + return 1 if 'pinned' in asystags else 0 + +def note_markdown(n): + asystags = n.get('systemtags', 0) + if not asystags: + return 0 + return 1 if 'markdown' in asystags else 0 + +# TODO: NextCloud notes doesn't have a concept of tags, but it does +# allow assignment of notes to a single category. Refactor to take this +# into account +tags_illegal_chars = re.compile(r'[\s]') +def sanitise_tags(tags): + """ + Given a string containing comma-separated tags, sanitise and return a list of string tags. + + The NextCloud API doesn't allow for spaces, so we strip those out. + + @param tags: Comma-separated tags, one string. + @returns: List of strings. + """ + # hack out all kinds of whitespace, then split on , + # if you run into more illegal characters (NextCloud does not want to sync them) + # add them to the regular expression above. + illegals_removed = tags_illegal_chars.sub('', tags) + if len(illegals_removed) == 0: + # special case for empty string '' + # split turns that into [''], which is not valid + return [] + + else: + return illegals_removed.split(',') + +def sort_by_title_pinned(a): + return (not note_pinned(a.note), get_note_title(a.note)) + +def sort_notes_by_tags(notes, pinned_ontop=False): + notes.sort(key=lambda i: (pinned_ontop and not note_pinned(i.note), + i.note.get('tags'), + get_note_title(i.note))) + +def sort_by_modify_date_pinned(a): + if note_pinned(a.note): + return 100.0 * float(a.note.get('modifydate', 0)) + else: + return float(a.note.get('modifydate', 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 diff --git a/simplenote_cli/view_help.py b/nnotes_cli/view_help.py diff --git a/simplenote_cli/view_log.py b/nnotes_cli/view_log.py diff --git a/simplenote_cli/view_note.py b/nnotes_cli/view_note.py diff --git a/nnotes_cli/view_titles.py b/nnotes_cli/view_titles.py @@ -0,0 +1,192 @@ + +# Copyright (c) 2014 Eric Davis +# Licensed under the MIT License + +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 -- tags + %D -- date + %N -- note title + """ + + t = time.localtime(float(note['modifydate'])) + mod_time = time.strftime(self.config.get_config('format_strftime'), t) + title = utils.get_note_title(note) + flags = utils.get_note_flags(note) + tags = utils.get_note_tags(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(tags, + align=align, + wrap='clip'), + 'note_tags')) + 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_tags' : 'note_focus' }) + + def get_note_titles(self): + lines = [] + for n in self.note_list: + lines.append(self.get_note_title(n.note)) + return lines + + def get_status_bar(self): + cur = -1 + total = 0 + if len(self.body.positions()) > 0: + cur = self.focus_position + total = len(self.body.positions()) + + hdr = 'NextCloud Notes' + + # include connection status in header + hdr += ' (' + self.ndb.note.status + ')' + + if self.search_string != None: + hdr += ' - Search: ' + self.search_string + + status_title = \ + urwid.AttrMap(urwid.Text(hdr, + wrap='clip'), + 'status_bar') + status_index = \ + ('pack', urwid.AttrMap(urwid.Text(' ' + + str(cur + 1) + + '/' + + str(total)), + 'status_bar')) + return \ + urwid.AttrMap(urwid.Columns([ status_title, status_index ]), + 'status_bar') + + def update_note_title(self, key=None): + if not key: + self.body[self.focus_position] = \ + self.get_note_title(self.note_list[self.focus_position].note) + else: + for i in range(len(self.note_list)): + if self.note_list[i].note['localkey'] == key: + self.body[i] = self.get_note_title(self.note_list[i].note) + + def focus_note(self, key): + for i in range(len(self.note_list)): + if 'localkey' in self.note_list[i].note and \ + self.note_list[i].note['localkey'] == key: + self.focus_position = i + + def keypress(self, size, key): + return key + diff --git a/simplenote_cli/__init__.py b/simplenote_cli/__init__.py @@ -1,8 +0,0 @@ -__productname__ = 'sncli' -__version__ = '0.2.1' -__copyright__ = "Copyright (c) 2014 Eric Davis" -__author__ = "Eric Davis" -__author_email__ = "edavis@insanum.com" -__description__ = "Simplenote Command Line Interface" -__url__ = "https://github.com/insanum/sncli" -__license__ = "MIT" diff --git a/simplenote_cli/config.py b/simplenote_cli/config.py @@ -1,285 +0,0 @@ - -# Copyright (c) 2014 Eric Davis -# Licensed under the MIT License - -import os, sys, urwid, collections, configparser, subprocess - -class Config: - - def __init__(self, custom_file=None): - self.home = os.path.abspath(os.path.expanduser('~')) - defaults = \ - { - 'cfg_sn_username' : '', - 'cfg_sn_password' : '', - 'cfg_db_path' : os.path.join(self.home, '.sncli'), - 'cfg_search_tags' : 'yes', # with regex searches - 'cfg_sort_mode' : 'date', # 'alpha' or 'date' - 'cfg_pinned_ontop' : 'yes', - 'cfg_tabstop' : '4', - 'cfg_format_strftime' : '%Y/%m/%d', - 'cfg_format_note_title' : '[%D] %F %-N %T', - 'cfg_status_bar' : 'yes', - 'cfg_editor' : os.environ['EDITOR'] if 'EDITOR' in os.environ else 'vim {fname} +{line}', - 'cfg_pager' : os.environ['PAGER'] if 'PAGER' in os.environ else 'less -c', - 'cfg_diff' : 'diff -b -U10', - 'cfg_max_logs' : '5', - 'cfg_log_timeout' : '5', - 'cfg_log_reversed' : 'yes', - 'cfg_sn_host' : 'simple-note.appspot.com', - '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_prev_version' : '<', - 'kb_next_version' : '>', - 'kb_diff_version' : 'D', - 'kb_restore_version' : 'R', - 'kb_latest_version' : 'L', - 'kb_select_version' : '#', - '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_tags' : 'ctrl t', - 'kb_note_trash' : 'T', - 'kb_note_pin' : 'p', - 'kb_note_markdown' : 'm', - 'kb_note_tags' : '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_tags_fg' : 'dark red', - 'clr_note_tags_bg' : 'default', - 'clr_note_content_fg' : 'default', - 'clr_note_content_bg' : 'default', - 'clr_note_content_focus_fg' : 'white', - 'clr_note_content_focus_bg' : 'light red', - 'clr_note_content_old_fg' : 'yellow', - 'clr_note_content_old_bg' : 'dark gray', - 'clr_note_content_old_focus_fg' : 'white', - 'clr_note_content_old_focus_bg' : 'light red', - 'clr_help_focus_fg' : 'white', - 'clr_help_focus_bg' : 'light red', - 'clr_help_header_fg' : 'dark blue', - 'clr_help_header_bg' : 'default', - 'clr_help_config_fg' : 'dark green', - 'clr_help_config_bg' : 'default', - 'clr_help_value_fg' : 'dark red', - 'clr_help_value_bg' : 'default', - 'clr_help_descr_fg' : 'default', - 'clr_help_descr_bg' : 'default' - } - - cp = configparser.SafeConfigParser(defaults) - if custom_file is not None: - self.configs_read = cp.read([custom_file]) - else: - self.configs_read = cp.read([os.path.join(self.home, '.snclirc')]) - - cfg_sec = 'sncli' - - 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 - sn_password = cp.get(cfg_sec, 'cfg_sn_password', raw=True) - if not sn_password: - command = cp.get(cfg_sec, 'cfg_sn_password_eval', raw=True) - if command: - try: - sn_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) - sn_password = sn_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['sn_username'] = [ cp.get(cfg_sec, 'cfg_sn_username', raw=True), 'Simplenote Username' ] - self.configs['sn_password'] = [ sn_password, 'Simplenote Password' ] - self.configs['sn_host'] = [ cp.get(cfg_sec, 'cfg_sn_host', raw=True), 'Simplenote server hostname' ] - self.configs['db_path'] = [ cp.get(cfg_sec, 'cfg_db_path'), 'Note storage path' ] - self.configs['search_tags'] = [ cp.get(cfg_sec, 'cfg_search_tags'), 'Search tags as well' ] - self.configs['sort_mode'] = [ cp.get(cfg_sec, 'cfg_sort_mode'), 'Sort mode' ] - self.configs['pinned_ontop'] = [ cp.get(cfg_sec, 'cfg_pinned_ontop'), 'Pinned at top of list' ] - self.configs['tabstop'] = [ cp.get(cfg_sec, 'cfg_tabstop'), 'Tabstop spaces' ] - self.configs['format_strftime'] = [ cp.get(cfg_sec, 'cfg_format_strftime', raw=True), 'Date strftime format' ] - self.configs['format_note_title'] = [ cp.get(cfg_sec, 'cfg_format_note_title', raw=True), 'Note title format' ] - self.configs['status_bar'] = [ cp.get(cfg_sec, 'cfg_status_bar'), 'Show the status bar' ] - self.configs['editor'] = [ cp.get(cfg_sec, 'cfg_editor'), 'Editor command' ] - self.configs['pager'] = [ cp.get(cfg_sec, 'cfg_pager'), 'External pager command' ] - self.configs['diff'] = [ cp.get(cfg_sec, 'cfg_diff'), 'External diff command' ] - self.configs['max_logs'] = [ cp.get(cfg_sec, 'cfg_max_logs'), 'Max logs in footer' ] - self.configs['log_timeout'] = [ cp.get(cfg_sec, 'cfg_log_timeout'), 'Log timeout' ] - self.configs['log_reversed'] = [ cp.get(cfg_sec, 'cfg_log_reversed'), 'Log file reversed' ] - self.configs['tempdir'] = [ cp.get(cfg_sec, 'cfg_tempdir'), 'Temporary directory for note storage' ] - - self.keybinds = collections.OrderedDict() - self.keybinds['help'] = [ cp.get(cfg_sec, 'kb_help'), [ 'common' ], 'Help' ] - self.keybinds['quit'] = [ cp.get(cfg_sec, 'kb_quit'), [ 'common' ], 'Quit' ] - self.keybinds['sync'] = [ cp.get(cfg_sec, 'kb_sync'), [ 'common' ], 'Full sync' ] - self.keybinds['down'] = [ cp.get(cfg_sec, 'kb_down'), [ 'common' ], 'Scroll down one line' ] - self.keybinds['up'] = [ cp.get(cfg_sec, 'kb_up'), [ 'common' ], 'Scroll up one line' ] - self.keybinds['page_down'] = [ cp.get(cfg_sec, 'kb_page_down'), [ 'common' ], 'Page down' ] - self.keybinds['page_up'] = [ cp.get(cfg_sec, 'kb_page_up'), [ 'common' ], 'Page up' ] - self.keybinds['half_page_down'] = [ cp.get(cfg_sec, 'kb_half_page_down'), [ 'common' ], 'Half page down' ] - self.keybinds['half_page_up'] = [ cp.get(cfg_sec, 'kb_half_page_up'), [ 'common' ], 'Half page up' ] - self.keybinds['bottom'] = [ cp.get(cfg_sec, 'kb_bottom'), [ 'common' ], 'Goto bottom' ] - self.keybinds['top'] = [ cp.get(cfg_sec, 'kb_top'), [ 'common' ], 'Goto top' ] - self.keybinds['status'] = [ cp.get(cfg_sec, 'kb_status'), [ 'common' ], 'Toggle status bar' ] - self.keybinds['view_log'] = [ cp.get(cfg_sec, 'kb_view_log'), [ 'common' ], 'View log' ] - self.keybinds['create_note'] = [ cp.get(cfg_sec, 'kb_create_note'), [ 'titles' ], 'Create a new note' ] - self.keybinds['edit_note'] = [ cp.get(cfg_sec, 'kb_edit_note'), [ 'titles', 'notes' ], 'Edit note' ] - self.keybinds['view_note'] = [ cp.get(cfg_sec, 'kb_view_note'), [ 'titles' ], 'View note' ] - self.keybinds['view_note_ext'] = [ cp.get(cfg_sec, 'kb_view_note_ext'), [ 'titles', 'notes' ], 'View note with pager' ] - self.keybinds['view_note_json'] = [ cp.get(cfg_sec, 'kb_view_note_json'), [ 'titles', 'notes' ], 'View note raw json' ] - self.keybinds['pipe_note'] = [ cp.get(cfg_sec, 'kb_pipe_note'), [ 'titles', 'notes' ], 'Pipe note contents' ] - self.keybinds['view_next_note'] = [ cp.get(cfg_sec, 'kb_view_next_note'), [ 'notes' ], 'View next note' ] - self.keybinds['view_prev_note'] = [ cp.get(cfg_sec, 'kb_view_prev_note'), [ 'notes' ], 'View previous note' ] - self.keybinds['tabstop2'] = [ cp.get(cfg_sec, 'kb_tabstop2'), [ 'notes' ], 'View with tabstop=2' ] - self.keybinds['tabstop4'] = [ cp.get(cfg_sec, 'kb_tabstop4'), [ 'notes' ], 'View with tabstop=4' ] - self.keybinds['tabstop8'] = [ cp.get(cfg_sec, 'kb_tabstop8'), [ 'notes' ], 'View with tabstop=8' ] - self.keybinds['prev_version'] = [ cp.get(cfg_sec, 'kb_prev_version'), [ 'notes' ], 'View previous version' ] - self.keybinds['next_version'] = [ cp.get(cfg_sec, 'kb_next_version'), [ 'notes' ], 'View next version' ] - self.keybinds['diff_version'] = [ cp.get(cfg_sec, 'kb_diff_version'), [ 'notes' ], 'Diff version of note' ] - self.keybinds['restore_version'] = [ cp.get(cfg_sec, 'kb_restore_version'), [ 'notes' ], 'Restore version of note' ] - self.keybinds['latest_version'] = [ cp.get(cfg_sec, 'kb_latest_version'), [ 'notes' ], 'View latest version' ] - self.keybinds['select_version'] = [ cp.get(cfg_sec, 'kb_select_version'), [ 'notes' ], 'Select version' ] - 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_tags'] = [ cp.get(cfg_sec, 'kb_sort_tags'), [ 'titles' ], 'Sort notes by tags' ] - self.keybinds['note_trash'] = [ cp.get(cfg_sec, 'kb_note_trash'), [ 'titles', 'notes' ], 'Trash a note' ] - self.keybinds['note_pin'] = [ cp.get(cfg_sec, 'kb_note_pin'), [ 'titles', 'notes' ], 'Pin note' ] - self.keybinds['note_markdown'] = [ cp.get(cfg_sec, 'kb_note_markdown'), [ 'titles', 'notes' ], 'Flag note as markdown' ] - self.keybinds['note_tags'] = [ cp.get(cfg_sec, 'kb_note_tags'), [ 'titles', 'notes' ], 'Edit note tags' ] - 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_tags_fg'] = [ cp.get(cfg_sec, 'clr_note_tags_fg'), 'Note tags fg' ] - self.colors['note_tags_bg'] = [ cp.get(cfg_sec, 'clr_note_tags_bg'), 'Note tags bg' ] - self.colors['note_content_fg'] = [ cp.get(cfg_sec, 'clr_note_content_fg'), 'Note content fg' ] - self.colors['note_content_bg'] = [ cp.get(cfg_sec, 'clr_note_content_bg'), 'Note content bg' ] - self.colors['note_content_focus_fg'] = [ cp.get(cfg_sec, 'clr_note_content_focus_fg'), 'Note content focus fg' ] - self.colors['note_content_focus_bg'] = [ cp.get(cfg_sec, 'clr_note_content_focus_bg'), 'Note content focus bg' ] - self.colors['note_content_old_fg'] = [ cp.get(cfg_sec, 'clr_note_content_old_fg'), 'Old note content fg' ] - self.colors['note_content_old_bg'] = [ cp.get(cfg_sec, 'clr_note_content_old_bg'), 'Old note content bg' ] - self.colors['note_content_old_focus_fg'] = [ cp.get(cfg_sec, 'clr_note_content_old_focus_fg'), 'Old note content focus fg' ] - self.colors['note_content_old_focus_bg'] = [ cp.get(cfg_sec, 'clr_note_content_old_focus_bg'), 'Old note content focus bg' ] - self.colors['help_focus_fg'] = [ cp.get(cfg_sec, 'clr_help_focus_fg'), 'Help focus fg' ] - self.colors['help_focus_bg'] = [ cp.get(cfg_sec, 'clr_help_focus_bg'), 'Help focus bg' ] - self.colors['help_header_fg'] = [ cp.get(cfg_sec, 'clr_help_header_fg'), 'Help header fg' ] - self.colors['help_header_bg'] = [ cp.get(cfg_sec, 'clr_help_header_bg'), 'Help header bg' ] - self.colors['help_config_fg'] = [ cp.get(cfg_sec, 'clr_help_config_fg'), 'Help config fg' ] - self.colors['help_config_bg'] = [ cp.get(cfg_sec, 'clr_help_config_bg'), 'Help config bg' ] - self.colors['help_value_fg'] = [ cp.get(cfg_sec, 'clr_help_value_fg'), 'Help value fg' ] - self.colors['help_value_bg'] = [ cp.get(cfg_sec, 'clr_help_value_bg'), 'Help value bg' ] - self.colors['help_descr_fg'] = [ cp.get(cfg_sec, 'clr_help_descr_fg'), 'Help description fg' ] - self.colors['help_descr_bg'] = [ cp.get(cfg_sec, 'clr_help_descr_bg'), 'Help description bg' ] - - def get_config(self, name): - return self.configs[name][0] - - def get_config_descr(self, name): - return self.configs[name][1] - - def get_keybind(self, name): - return self.keybinds[name][0] - - def get_keybind_use(self, name): - return self.keybinds[name][1] - - def get_keybind_descr(self, name): - return self.keybinds[name][2] - - def get_color(self, name): - return self.colors[name][0] - - def get_color_descr(self, name): - return self.colors[name][1] - diff --git a/simplenote_cli/notes_db.py b/simplenote_cli/notes_db.py @@ -1,696 +0,0 @@ - -# Copyright (c) 2014 Eric Davis -# This file is *heavily* modified from nvpy. - -# nvPY: cross-platform note-taking app with simplenote syncing -# copyright 2012 by Charl P. Botha <cpbotha@vxlabs.com> -# new BSD license - -import os, time, re, glob, json, copy, threading -from . import utils -from . import simplenote -simplenote.NOTE_FETCH_LENGTH=100 -from .simplenote import Simplenote -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 = 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['key'] yet (no sync) - localkey = n.get('localkey', os.path.splitext(os.path.basename(fn))[0]) - # we maintain in memory a timestamp of the last save - # these notes have just been read, so at this moment - # they're in sync with the disc. - n['savedate'] = now - # set a localkey to each note in memory - # Note: 'key' is used only for syncing with server - 'localkey' - # is used for everything else in sncli - n['localkey'] = localkey - - # add the note to our database - self.notes[localkey] = n - - # initialise the simplenote instance we're going to use - # this does not yet need network access - self.simplenote = Simplenote(self.config.get_config('sn_username'), - self.config.get_config('sn_password'), - self.config.get_config('sn_host')) - - # we'll use this to store which notes are currently being synced by - # the background thread, so we don't add them anew if they're still - # in progress. This variable is only used by the background thread. - self.threaded_syncing_keys = {} - - def filtered_notes_sort(self, filtered_notes, sort_mode='date'): - if sort_mode == 'date': - if self.config.get_config('pinned_ontop') == 'yes': - filtered_notes.sort(key=utils.sort_by_modify_date_pinned, reverse=True) - else: - filtered_notes.sort(key=lambda o: -float(o.note.get('modifydate', 0))) - elif sort_mode == 'alpha': - if self.config.get_config('pinned_ontop') == 'yes': - filtered_notes.sort(key=utils.sort_by_title_pinned) - else: - filtered_notes.sort(key=lambda o: utils.get_note_title(o.note)) - elif sort_mode == 'tags': - pinned = self.config.get_config('pinned_ontop') - utils.sort_notes_by_tags(filtered_notes, pinned_ontop=pinned) - - 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_tagmatch(self, tag_pats, note): - # Returns: - # 2 = match - no tag patterns specified - # 1 = match - all tag patterns match a tag on this note - # 0 = no match - note has no tags or not all tag patterns match - - if not tag_pats: - # match because no tag patterns were specified - return 2 - - note_tags = note.get('tags') - - if not note_tags: - # tag patterns specified but note has no tags, so no match - return 0 - - # for each tag_pat, we have to find a matching tag - # .lower() used for case-insensitive search - tag_pats_matched = 0 - for tp in tag_pats: - tp = tp.lower() - for t in note_tags: - if tp in t.lower(): - tag_pats_matched += 1 - break - - if tag_pats_matched == len(tag_pats): - # all tag patterns specified matched a tag 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 = [] - - # total number of notes, excluding deleted - # if tag:trash then counts deleted as well - active_notes = 0 - - if not search_string: - for k in self.notes: - n = self.notes[k] - if n.get('deleted'): - continue - active_notes += 1 - filtered_notes.append(utils.KeyValueObject(key=k, note=n, tagfound=0)) - - return filtered_notes, [], active_notes - - # group0: tag:([^\s]+) - # group1: multiple words in quotes - # group2: single words - - # example result for: 'tag:tag1 tag:tag2 word1 "word2 word3" tag:tag3' - # [ ('tag1', '', ''), - # ('tag2', '', ''), - # ('', '', 'word1'), - # ('', 'word2 word3', ''), - # ('tag3', '', '') ] - - groups = re.findall('tag:([^\s]+)|"([^"]+)"|([^\s]+)', search_string) - all_pats = [[] for _ in range(3)] - - search_trash = False - for g in groups: - if g[0] == 'trash': - groups.remove(g) - search_trash = True - - # we end up with [[tag_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] - - if not search_trash and n.get('deleted'): - continue - - active_notes += 1 - - if search_trash and len(groups) == 0: - # simple search of only 'tag:trash' to get all trashed notes - if n.get('deleted'): - filtered_notes.append( - utils.KeyValueObject(key=k, - note=n, - tagfound=1)) - continue - - tagmatch = self._helper_gstyle_tagmatch(all_pats[0], n) - - word_pats = all_pats[1] + all_pats[2] - - if tagmatch 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, - tagfound=1 if tagmatch == 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, tagfound=0)) - continue - - if self.config.get_config('search_tags') == 'yes': - tag_matched = False - for t in n.get('tags'): - if sspat.search(t): - tag_matched = True - filtered_notes.append(utils.KeyValueObject(key=k, note=n, tagfound=1)) - break - if tag_matched: - continue - - if sspat.search(n.get('content')): - filtered_notes.append(utils.KeyValueObject(key=k, note=n, tagfound=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['key'] if note.get('key') else utils.generate_random_key() - while new_key in self.notes: - new_key = utils.generate_random_key() - - timestamp = time.time() - - try: - modifydate = float(note.get('modifydate', timestamp)) - createdate = float(note.get('createdate', timestamp)) - except ValueError: - raise ValueError('date fields must be numbers or string representations of numbers') - - # note has no internal key yet. - new_note = { - 'content' : note.get('content', ''), - 'deleted' : note.get('deleted', 0), - 'modifydate' : modifydate, - 'createdate' : createdate, - 'savedate' : 0, # never been written to disc - 'syncdate' : 0, # never been synced with server - 'tags' : note.get('tags', []), - 'systemtags' : note.get('systemtags', []) - } - - # sanity check all note values - if not isinstance(new_note['content'], str): - raise ValueError('"content" must be a string') - if not new_note['deleted'] in (0, 1): - raise ValueError('"deleted" must be 0 or 1') - - for n in (new_note['modifydate'], new_note['createdate']): - if not 0 <= n <= timestamp: - raise ValueError('date fields must be real') - - if not isinstance(new_note['tags'], list): - raise ValueError('"tags" must be an array') - for tag in new_note['tags']: - if not isinstance(tag, str): - raise ValueError('items in the "tags" array must be strings') - - if not isinstance(new_note['systemtags'], list): - raise ValueError('"systemtags" must be an array') - for tag in new_note['systemtags']: - if not isinstance(tag, str): - raise ValueError('items in the "systemtags" array must be strings') - - 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 = time.time() - - # note has no internal key yet. - new_note = { - 'localkey' : new_key, - 'content' : content, - 'deleted' : 0, - 'modifydate' : timestamp, - 'createdate' : timestamp, - 'savedate' : 0, # never been written to disc - 'syncdate' : 0, # never been synced with server - 'tags' : [] - } - - self.notes[new_key] = new_note - - return new_key - - def get_note(self, key): - return self.notes[key] - - def get_note_systemtags(self, key): - return self.notes[key].get('systemtags') - - def get_note_tags(self, key): - return self.notes[key].get('tags') - - def get_note_content(self, key): - return self.notes[key].get('content') - - def flag_what_changed(self, note, what_changed): - if 'what_changed' not in note: - note['what_changed'] = [] - if what_changed not in note['what_changed']: - note['what_changed'].append(what_changed) - - def set_note_deleted(self, key, deleted): - n = self.notes[key] - if (not n['deleted'] and deleted) or \ - (n['deleted'] and not deleted): - n['deleted'] = deleted - n['modifydate'] = time.time() - self.flag_what_changed(n, 'deleted') - self.log('Note {0} (key={1})'.format('trashed' if deleted else 'untrashed', 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['modifydate'] = time.time() - self.flag_what_changed(n, 'content') - self.log('Note content updated (key={0})'.format(key)) - - def set_note_tags(self, key, tags): - n = self.notes[key] - old_tags = n.get('tags') - tags = utils.sanitise_tags(tags) - if tags != old_tags: - n['tags'] = tags - n['modifydate'] = time.time() - self.flag_what_changed(n, 'tags') - self.log('Note tags updated (key={0})'.format(key)) - - def set_note_pinned(self, key, pinned): - n = self.notes[key] - old_pinned = utils.note_pinned(n) - if pinned != old_pinned: - if 'systemtags' not in n: - n['systemtags'] = [] - systemtags = n['systemtags'] - if pinned: - systemtags.append('pinned') - else: - systemtags.remove('pinned') - n['modifydate'] = time.time() - self.flag_what_changed(n, 'systemtags') - self.log('Note {0} (key={1})'.format('pinned' if pinned else 'unpinned', key)) - - def set_note_markdown(self, key, markdown): - n = self.notes[key] - old_markdown = utils.note_markdown(n) - if markdown != old_markdown: - if 'systemtags' not in n: - n['systemtags'] = [] - systemtags = n['systemtags'] - if markdown: - systemtags.append('markdown') - else: - systemtags.remove('markdown') - n['modifydate'] = time.time() - self.flag_what_changed(n, 'systemtags') - self.log('Note markdown {0} (key={1})'.format('flagged' if markdown else 'unflagged', key)) - - def helper_key_to_fname(self, k): - return os.path.join(self.config.get_config('db_path'), k) + '.json' - - 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'] = time.time() - - def sync_notes(self, server_sync=True, full_sync=True): - """Perform a full bi-directional sync with server. - - This follows the recipe in the SimpleNote 2.0 API documentation. - After this, it could be that local keys have been changed, so - reset any views that you might have! - - From Simplenote API v2.1.3... - - To check for changes you can use 'syncnum' and 'version'. 'syncnum' will - increment whenever there is any change to a note, content change, tag - change, etc. 'version' will increment whenever the content property is - changed. You should store both these numbers in your client to track - changes and determine when a note needs to be updated or saved. - - Psuedo-code algorithm for syncing: - - 1. for any note changed locally, including new notes: - save note to server, update note with response - // (new syncnum, version, possible newly-merged content) - - 2. get the note index - - 3. for each remote note - if remote syncnum > local syncnum || - 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 = time.time() - - sync_start_time = 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('key') or \ - float(n.get('modifydate')) > float(n.get('syncdate')): - - savedate = float(n.get('savedate')) - if float(n.get('modifydate')) > 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['createdate'] - del cn['syncdate'] - del cn['savedate'] - - if 'what_changed' in cn: - if 'deleted' not in cn['what_changed']: - del cn['deleted'] - if 'systemtags' not in cn['what_changed'] and 'systemtags' in cn: - del cn['systemtags'] - if 'tags' not in cn['what_changed']: - del cn['tags'] - if 'content' not in cn['what_changed']: - del cn['content'] - del cn['what_changed'] - - uret = self.simplenote.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('key') - n.update(uret[0]) - n['syncdate'] = now - n['localkey'] = k - 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.simplenote.get_note_list(since=None if full_sync else self.last_sync) - - 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 syncnum > local syncnum || - # a new note and key is not in local store - # retrieve note, update note with response - if not skip_remote_syncing: - len_nl = len(nl) - for note_index, n in enumerate(nl): - k = n.get('key') - 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('syncnum')) > int(self.notes[k].get('syncnum', -1)): - gret = self.simplenote.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.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.simplenote.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.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_version(self, key, version): - gret = self.simplenote.get_note(key, version) - return gret[0] if gret[1] == 0 else None - - def get_note_status(self, key): - n = self.notes[key] - o = utils.KeyValueObject(saved=False, synced=False, modified=False) - modifydate = float(n['modifydate']) - savedate = float(n['savedate']) - syncdate = float(n['syncdate']) - - if savedate > modifydate: - o.saved = True - else: - o.modified = True - - if syncdate > modifydate: - o.synced = True - - return o - - def verify_all_saved(self): - all_saved = True - self.sync_lock.acquire() - for k in list(self.notes.keys()): - o = self.get_note_status(k) - if not o.saved: - all_saved = False - break - self.sync_lock.release() - return all_saved - - def sync_now(self, do_server_sync=True): - self.sync_lock.acquire() - self.sync_notes(server_sync=do_server_sync, - full_sync=True if not self.last_sync else False) - self.sync_lock.release() - - # sync worker thread... - def sync_worker(self, do_server_sync): - time.sleep(1) # give some time to wait for GUI initialization - self.log('Sync worker: started') - self.sync_now(do_server_sync) - while True: - self.go_cond.acquire() - self.go_cond.wait(15) - self.sync_now(do_server_sync) - self.go_cond.release() - - def sync_worker_go(self): - self.go_cond.acquire() - self.go_cond.notify() - self.go_cond.release() - diff --git a/simplenote_cli/simplenote.py b/simplenote_cli/simplenote.py @@ -1,358 +0,0 @@ - -# Copyright (c) 2014 Eric Davis -# This file is *slightly* modified from simplynote.py. - -# -*- coding: utf-8 -*- -""" - simplenote.py - ~~~~~~~~~~~~~~ - - Python library for accessing the Simplenote API - - :copyright: (c) 2011 by Daniel Schauenberg - :license: MIT, see LICENSE for more details. -""" - -import urllib.parse -from requests.exceptions import RequestException, ConnectionError -import base64 -import time -import datetime -import logging -import requests - -try: - import json -except ImportError: - try: - import simplejson as json - except ImportError: - # For Google AppEngine - from django.utils import simplejson as json - -NOTE_FETCH_LENGTH = 100 - -class SimplenoteLoginFailed(Exception): - pass - -class Simplenote(object): - """ Class for interacting with the simplenote web service """ - - def __init__(self, username, password, host): - """ object constructor """ - self.username = urllib.parse.quote(username) - self.password = urllib.parse.quote(password) - self.AUTH_URL = 'https://{0}/api/login'.format(host) - self.DATA_URL = 'https://{0}/api2/data'.format(host) - self.INDX_URL = 'https://{0}/api2/index?'.format(host) - self.token = None - self.status = 'offline' - - def authenticate(self, user, password): - """ Method to get simplenote auth token - - Arguments: - - user (string): simplenote email address - - password (string): simplenote password - - Returns: - Simplenote API token as string - - """ - auth_params = "email=%s&password=%s" % (user, password) - values = base64.encodestring(auth_params.encode()) - try: - res = requests.post(self.AUTH_URL, data=values) - token = res.text - if res.status_code != 200: - self.status = 'login failed with status {}, check credentials'.format(res.status_code) - token = None - else: - self.status = 'online' - except ConnectionError as e: - token = None - self.status = 'offline, connection error' - except RequestException as e: - token = None - self.status = 'login failed, check log' - - logging.debug('AUTHENTICATE: ' + self.status) - return token - - def get_token(self): - """ Method to retrieve an auth token. - - The cached global token is looked up and returned if it exists. If it - is `None` a new one is requested and returned. - - Returns: - Simplenote API token as string - - """ - if self.token is None: - self.token = self.authenticate(self.username, self.password) - return self.token - - - def get_note(self, noteid, version=None): - """ method to get a specific note - - Arguments: - - noteid (string): ID of the note to get - - version (int): optional version of the note to get - - Returns: - A tuple `(note, status)` - - - note (dict): note object - - status (int): 0 on sucesss and -1 otherwise - - """ - # request note - params_version = "" - if version is not None: - params_version = '/' + str(version) - - params = {'auth': self.get_token(), - 'email': self.username } - url = '{}/{}{}'.format(self.DATA_URL, str(noteid), params_version) - #logging.debug('REQUEST: ' + self.DATA_URL+params) - try: - res = requests.get(url, params=params) - res.raise_for_status() - note = res.json() - except ConnectionError as e: - self.status = 'offline, connection error' - return e, -1 - except RequestException as e: - # logging.debug('RESPONSE ERROR: ' + str(e)) - return e, -1 - except ValueError as e: - return e, -1 - - # # use UTF-8 encoding - # note["content"] = note["content"].encode('utf-8') - # # For early versions of notes, tags not always available - # if "tags" in note: - # note["tags"] = [t.encode('utf-8') for t in note["tags"]] - #logging.debug('RESPONSE OK: ' + str(note)) - return note, 0 - - def update_note(self, note): - """ function to update a specific note object, if the note object does not - have a "key" field, a new note is created - - Arguments - - note (dict): note object to update - - Returns: - A tuple `(note, status)` - - - note (dict): note object - - status (int): 0 on sucesss and -1 otherwise - - """ - # Note: all strings in notes stored as type str - # - use s.encode('utf-8') when bytes type needed - - # determine whether to create a new note or updated an existing one - params = {'auth': self.get_token(), - 'email': self.username} - if "key" in note: - # set modification timestamp if not set by client - if 'modifydate' not in note: - note["modifydate"] = time.time() - - url = '%s/%s' % (self.DATA_URL, note["key"]) - else: - url = self.DATA_URL - - #logging.debug('REQUEST: ' + url + ' - ' + str(note)) - try: - data = urllib.parse.quote(json.dumps(note)) - res = requests.post(url, data=data, params=params) - res.raise_for_status() - note = res.json() - except ConnectionError as e: - self.status = 'offline, connection error' - return e, -1 - except RequestException as e: - logging.debug('RESPONSE ERROR: ' + str(e)) - self.status = 'error updating note, check log' - return e, -1 - except ValueError as e: - return e, -1 - #logging.debug('RESPONSE OK: ' + str(note)) - return note, 0 - - def add_note(self, note): - """wrapper function to add a note - - The function can be passed the note as a dict with the `content` - property set, which is then directly send to the web service for - creation. Alternatively, only the body as string can also be passed. In - this case the parameter is used as `content` for the new note. - - Arguments: - - note (dict or string): the note to add - - Returns: - A tuple `(note, status)` - - - note (dict): the newly created note - - status (int): 0 on sucesss and -1 otherwise - - """ - if type(note) == str: - return self.update_note({"content": note}) - elif (type(note) == dict) and "content" in note: - return self.update_note(note) - else: - return "No string or valid note.", -1 - - def get_note_list(self, since=None, tags=[]): - """ function to get the note list - - The function can be passed optional arguments to limit the - date range of the list returned and/or limit the list to notes - containing a certain tag. If omitted a list of all notes - is returned. - - Arguments: - - since=time.time() epoch stamp: only return notes modified - since this date - - tags=[] list of tags as string: return notes that have - at least one of these tags - - Returns: - A tuple `(notes, status)` - - - notes (list): A list of note objects with all properties set except - `content`. - - status (int): 0 on sucesss and -1 otherwise - - """ - # initialize data - status = 0 - notes = { "data" : [] } - json_data = {} - - # get the note index - params = {'auth': self.get_token(), - 'email': self.username, - 'length': NOTE_FETCH_LENGTH - } - if since is not None: - params['since'] = since - - # perform initial HTTP request - try: - #logging.debug('REQUEST: ' + self.INDX_URL+params) - res = requests.get(self.INDX_URL, params=params) - res.raise_for_status() - #logging.debug('RESPONSE OK: ' + str(res)) - json_data = res.json() - notes["data"].extend(json_data["data"]) - except ConnectionError as e: - self.status = 'offline, connection error' - status = -1 - except RequestException as e: - # if problem with network request/response - status = -1 - except ValueError as e: - # if invalid json data - status = -1 - - # get additional notes if bookmark was set in response - while "mark" in json_data: - params = {'auth': self.get_token(), - 'email': self.username, - 'mark': json_data['mark'], - 'length': NOTE_FETCH_LENGTH - } - if since is not None: - params['since'] = since - - # perform the actual HTTP request - try: - #logging.debug('REQUEST: ' + self.INDX_URL+params) - res = requests.get(self.INDX_URL, params=params) - res.raise_for_status() - json_data = res.json() - #logging.debug('RESPONSE OK: ' + str(res)) - notes["data"].extend(json_data["data"]) - except ConnectionError as e: - self.status = 'offline, connection error' - status = -1 - except RequestException as e: - # if problem with network request/response - status = -1 - except ValueError as e: - # if invalid json data - status = -1 - - # parse data fields in response - note_list = notes["data"] - - # Can only filter for tags at end, once all notes have been retrieved. - #Below based on simplenote.vim, except we return deleted notes as well - if (len(tags) > 0): - note_list = [n for n in note_list if (len(set(n["tags"]).intersection(tags)) > 0)] - - return note_list, status - - def trash_note(self, note_id): - """ method to move a note to the trash - - Arguments: - - note_id (string): key of the note to trash - - Returns: - A tuple `(note, status)` - - - note (dict): the newly created note or an error message - - status (int): 0 on sucesss and -1 otherwise - - """ - # get note - note, status = self.get_note(note_id) - if (status == -1): - return note, status - # set deleted property - note["deleted"] = 1 - # update note - return self.update_note(note) - - def delete_note(self, note_id): - """ method to permanently delete a note - - Arguments: - - note_id (string): key of the note to trash - - Returns: - A tuple `(note, status)` - - - note (dict): an empty dict or an error message - - status (int): 0 on sucesss and -1 otherwise - - """ - # notes have to be trashed before deletion - note, status = self.trash_note(note_id) - if (status == -1): - return note, status - - params = {'auth': self.get_token(), - 'email': self.username } - url = '{}/{}'.format(self.DATA_URL, str(note_id)) - - try: - #logging.debug('REQUEST DELETE: ' + self.DATA_URL+params) - res = requests.delete(url, params=params) - res.raise_for_status() - except ConnectionError as e: - self.status = 'offline, connection error' - return e, -1 - except RequestException as e: - return e, -1 - return {}, 0 - diff --git a/simplenote_cli/sncli.py b/simplenote_cli/sncli.py @@ -1,1476 +0,0 @@ - -# Copyright (c) 2014 Eric Davis -# Licensed under the MIT License - -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 -from .config import Config -from .simplenote import Simplenote -from .notes_db import NotesDB, ReadError, WriteError -from logging.handlers import RotatingFileHandler - -class sncli: - - 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'), 'sncli.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('sncli 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('sncli database doesn\'t exist, forcing full sync...') - self.sync_notes() - self.verbose = verbose - - def sync_notes(self): - self.ndb.sync_now(self.do_server_sync) - - def get_editor(self): - editor = self.config.get_config('editor') - if not editor: - self.log('No editor configured!') - return None - return editor - - def get_pager(self): - pager = self.config.get_config('pager') - if not pager: - self.log('No pager configured!') - return None - return pager - - def get_diff(self): - diff = self.config.get_config('diff') - if not diff: - self.log('No diff command configured!') - return None - return diff - - def exec_cmd_on_note(self, note, cmd=None, raw=False): - - if not cmd: - cmd = self.get_editor() - if not cmd: - return None - - tf = temp.tempfile_create(note if note else None, raw=raw, tempdir=self.tempdir) - fname = temp.tempfile_name(tf) - - focus_position = 0 - try: - focus_position = self.gui_body_get().focus_position - except IndexError: - # focus position will fail if no notes available (listbox empty) - # TODO: find a neater way to check than try/except - pass - - subs = { - 'fname': fname, - 'line': focus_position + 1, - } - cmd_list = [c.format(**subs) for c in shlex.split(cmd)] - - # if the filename wasn't able to be subbed, append it - # this makes it fully backwards compatible with previous configs - if '{fname}' not in cmd: - cmd_list.append(fname) - - self.log("EXECUTING: {}".format(cmd_list)) - - try: - subprocess.check_call(cmd_list) - except Exception as e: - self.log('Command error: ' + str(e)) - temp.tempfile_delete(tf) - return None - - content = None - if not raw: - content = temp.tempfile_content(tf) - if not content or content == '\n': - content = None - - temp.tempfile_delete(tf) - return content - - def exec_diff_on_note(self, note, old_note): - - diff = self.get_diff() - if not diff: - return None - - pager = self.get_pager() - if not pager: - return None - - ltf = temp.tempfile_create(note, tempdir=self.tempdir) - otf = temp.tempfile_create(old_note, tempdir=self.tempdir) - out = temp.tempfile_create(None, tempdir=self.tempdir) - - try: - subprocess.call(diff + ' ' + - temp.tempfile_name(ltf) + ' ' + - temp.tempfile_name(otf) + ' > ' + - temp.tempfile_name(out), - shell=True) - subprocess.check_call(pager + ' ' + - temp.tempfile_name(out), - shell=True) - except Exception as e: - self.log('Command error: ' + str(e)) - temp.tempfile_delete(ltf) - temp.tempfile_delete(otf) - temp.tempfile_delete(out) - return None - - temp.tempfile_delete(ltf) - temp.tempfile_delete(otf) - temp.tempfile_delete(out) - return None - - def gui_header_clear(self): - self.master_frame.contents['header'] = ( None, None ) - self.sncli_loop.draw_screen() - - def gui_header_set(self, w): - self.master_frame.contents['header'] = ( w, None ) - self.sncli_loop.draw_screen() - - def gui_header_get(self): - return self.master_frame.contents['header'][0] - - def gui_header_focus(self): - self.master_frame.focus_position = 'header' - - def gui_footer_log_clear(self): - ui = self.gui_footer_input_get() - self.master_frame.contents['footer'] = \ - (urwid.Pile([ urwid.Pile([]), urwid.Pile([ui]) ]), None) - self.sncli_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.sncli_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.sncli_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.sncli_loop.draw_screen() - - def gui_footer_input_get(self): - return self.master_frame.contents['footer'][0].contents[1][0] - - def gui_footer_focus_input(self): - self.master_frame.focus_position = 'footer' - self.master_frame.contents['footer'][0].focus_position = 1 - - def gui_body_clear(self): - self.master_frame.contents['body'] = ( None, None ) - self.sncli_loop.draw_screen() - - def gui_body_set(self, w): - self.master_frame.contents['body'] = ( w, None ) - self.gui_update_status_bar() - self.sncli_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.sncli_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 trash_note_callback(self, key, yes): - if not yes: - return - - # toggle the deleted flag - note = self.ndb.get_note(key) - self.ndb.set_note_deleted(key, 0 if note['deleted'] else 1) - - 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 restore_note_callback(self, key, yes): - if not yes: - return - - # restore the contents of the old_note - self.log('Restoring version v{0} (key={1})'. - format(self.view_note.old_note['version'], key)) - self.ndb.set_note_content(key, self.view_note.old_note['content']) - - self.view_note.update_note_view() - 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_version_input(self, args, version): - self.gui_footer_input_clear() - self.gui_body_focus() - self.master_frame.keypress = self.gui_frame_keypress - if version: - try: - # verify input is a number - int(version) - except ValueError as e: - self.log('ERROR: Invalid version value') - return - self.view_note.update_note_view(version=version) - self.gui_update_status_bar() - - def gui_tags_input(self, args, tags): - self.gui_footer_input_clear() - self.gui_body_focus() - self.master_frame.keypress = self.gui_frame_keypress - if tags != 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_tags(note['localkey'], tags) - - 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('prev_version') or \ - key == self.config.get_keybind('next_version'): - if self.gui_body_get().__class__ != view_note.ViewNote: - return key - - diff = -1 if key == self.config.get_keybind('prev_version') else 1 - - version = diff + (self.view_note.old_note['version'] - if self.view_note.old_note else - self.view_note.note['version']) - - lb.update_note_view(version=version) - - elif key == self.config.get_keybind('diff_version'): - if self.gui_body_get().__class__ != view_note.ViewNote: - return key - - if not self.view_note.old_note: - self.log('Already at latest version (key={0})'. - format(self.view_note.key)) - return None - - self.gui_clear() - self.exec_diff_on_note(self.view_note.note, - self.view_note.old_note) - self.gui_reset() - - elif key == self.config.get_keybind('restore_version'): - if self.gui_body_get().__class__ != view_note.ViewNote: - return key - - if not self.view_note.old_note: - self.log('Already at latest version (key={0})'. - format(self.view_note.key)) - return None - - self.gui_footer_input_set( - urwid.AttrMap( - user_input.UserInput( - self.config, - 'Restore v{0} (y/n): '.format(self.view_note.old_note['version']), - '', - self.gui_yes_no_input, - [ self.restore_note_callback, self.view_note.key ]), - 'user_input_bar')) - self.gui_footer_focus_input() - self.master_frame.keypress = self.gui_footer_input_get().keypress - - elif key == self.config.get_keybind('latest_version'): - if self.gui_body_get().__class__ != view_note.ViewNote: - return key - - lb.update_note_view(version=None) - - elif key == self.config.get_keybind('select_version'): - if self.gui_body_get().__class__ != view_note.ViewNote: - return key - - self.gui_footer_input_set( - urwid.AttrMap( - user_input.UserInput( - self.config, - key, - '', - self.gui_version_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('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_trash'): - 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, - '{0} (y/n): '.format('Untrash' if note['deleted'] else 'Trash'), - '', - self.gui_yes_no_input, - [ self.trash_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_pin'): - 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 - - pin = 1 - if 'systemtags' in note: - if 'pinned' in note['systemtags']: pin = 0 - else: pin = 1 - - self.ndb.set_note_pinned(note['localkey'], pin) - - 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_markdown'): - 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 - - md = 1 - if 'systemtags' in note: - if 'markdown' in note['systemtags']: md = 0 - else: md = 1 - - self.ndb.set_note_markdown(note['localkey'], md) - - 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_tags'): - 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, - 'Tags: ', - '%s' % ','.join(note['tags']), - self.gui_tags_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_tags'): - if self.gui_body_get().__class__ != view_titles.ViewTitles: - return key - - self.current_sort_mode = 'tags' - self.view_titles.sort_note_list('tags') - - 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.sncli_loop.widget = urwid.Filler(urwid.Text('')) - self.sncli_loop.draw_screen() - - def gui_reset(self): - self.sncli_loop.widget = self.master_frame - self.sncli_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, - 'key' : 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_tags', - self.config.get_color('note_tags_fg'), - self.config.get_color('note_tags_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.sncli_loop = urwid.MainLoop(self.master_frame, - palette, - handle_mouse=False) - - self.sncli_loop.set_alarm_in(0, self.gui_init_view, - True if key else False) - - self.sncli_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((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['modifydate'])) - mod_time = time.strftime('%a, %d %b %Y %H:%M:%S', t) - title = utils.get_note_title(note) - flags = utils.get_note_flags(note) - tags = utils.get_note_tags(note) - - print(sep) - print(('| {:<' + str(w) + '} |').format((' Title: ' + title)[:w])) - print(('| {:<' + str(w) + '} |').format((' Key: ' + note.get('key', 'Localkey: {}'.format(note.get('localkey'))))[:w])) - print(('| {:<' + str(w) + '} |').format((' Date: ' + mod_time)[:w])) - print(('| {:<' + str(w) + '} |').format((' Tags: ' + tags)[:w])) - print(('| {:<' + str(w) + '} |').format((' Version: v' + str(note.get('version', 0)))[:w])) - print(('| {:<' + str(w) + '} |').format((' Flags: [' + flags + ']')[:w])) - if utils.note_published(note) and 'publishkey' in note: - print(('| {:<' + str(w) + '} |').format(('Published: http://simp.ly/publish/' + note['publishkey'])[:w])) - else: - print(('| {:<' + str(w) + '} |').format(('Published: n/a')[: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_trash(self, key, trash): - - note = self.ndb.get_note(key) - if not note: - self.log('ERROR: Key does not exist') - return - - self.ndb.set_note_deleted(key, trash) - self.sync_notes() - - def cli_note_pin(self, key, pin): - - note = self.ndb.get_note(key) - if not note: - self.log('ERROR: Key does not exist') - return - - self.ndb.set_note_pinned(key, pin) - self.sync_notes() - - def cli_note_markdown(self, key, markdown): - - note = self.ndb.get_note(key) - if not note: - self.log('ERROR: Key does not exist') - return - - self.ndb.set_note_markdown(key, markdown) - self.sync_notes() - - def cli_note_tags_get(self, key): - - note = self.ndb.get_note(key) - if not note: - self.log('ERROR: Key does not exist') - return - - tags = utils.get_note_tags(note) - return tags - - def cli_note_tags_set(self, key, tags): - - note = self.ndb.get_note(key) - if not note: - self.log('Error: Key does not exist') - return - - self.ndb.set_note_tags(key, tags.lower()) - self.sync_notes() - - def cli_note_tags_add(self, key, new_tags): - - note = self.ndb.get_note(key) - if not note: - self.log('Error: Key does not exist') - return - - # Add tag only if it isn't already there - old_tags = self.cli_note_tags_get(key) - if old_tags: - old_tag_list = old_tags.lower().split(',') - new_tag_list = new_tags.lower().split(',') - tag_list = old_tag_list - for tag in new_tag_list: - if tag not in tag_list: - tag_list.append(tag) - tags = ','.join(tag_list) - else: - tags = new_tags - - self.cli_note_tags_set(key, tags) - - def cli_note_tags_rm(self, key, rm_tags): - - note = self.ndb.get_note(key) - if not note: - self.log('Error: Key does not exist') - return - - old_tags = self.cli_note_tags_get(key) - if old_tags: - old_tag_list = old_tags.lower().split(',') - rm_tag_list = rm_tags.lower().split(',') - tag_list = old_tag_list - for tag in rm_tag_list: - if tag in tag_list: - tag_list.remove(tag) - tags = ','.join(tag_list) - self.cli_note_tags_set(key, tags) - -def SIGINT_handler(signum, frame): - print('\nSignal caught, bye!') - sys.exit(1) - -signal.signal(signal.SIGINT, SIGINT_handler) - -def usage(): - print (''' -Usage: - sncli [OPTIONS] [COMMAND] [COMMAND_ARGS] - - OPTIONS: - -h, --help - usage help - -v, --verbose - verbose output - -n, --nosync - don't perform a server sync - -r, --regex - search string is a regular expression - -k <key>, --key=<key> - note key - -t <title>, --title=<title> - title of note for create (cli mode) - -c <file>, --config=<file> - config file to read from (defaults to ~/.snclirc) - - COMMANDS: - <none> - console gui mode when no command specified - sync - perform a full sync with the server - list [search_string] - list notes (refined with search string) - export [search_string] - export notes in JSON (refined with search string) - dump [search_string] - dump notes (refined with search string) - create [-] - create a note ('-' content from stdin) - import [-] - import a note in JSON format ('-' JSON from stdin) - export - export a note in JSON format (specified by <key>) - dump - dump a note (specified by <key>) - edit - edit a note (specified by <key>) - < trash | untrash > - trash/untrash a note (specified by <key>) - < pin | unpin > - pin/unpin a note (specified by <key>) - < markdown | unmarkdown > - markdown/unmarkdown a note (specified by <key>) - tag get - retrieve the tags from a note (specified by <key>) - tag set <tags> - set the tags for a note (specified by <key>) - tag add <tags> - add tags to a note (specified by <key>) - tag rm <tags> - remove tags from a note (specified by <key>) -''') - sys.exit(0) - - -def main(argv=sys.argv[1:]): - verbose = False - sync = True - regex = False - key = None - title = None - config = None - - try: - opts, args = getopt.getopt(argv, - 'hvnrk:t:c:', - [ 'help', 'verbose', 'nosync', 'regex', 'key=', 'title=', 'config=' ]) - except: - usage() - - for opt, arg in opts: - if opt in [ '-h', '--help']: - usage() - elif opt in [ '-v', '--verbose']: - verbose = True - elif opt in [ '-n', '--nosync']: - sync = False - elif opt in [ '-r', '--regex']: - regex = True - elif opt in [ '-k', '--key']: - key = arg - elif opt in [ '-t', '--title']: - title = arg - elif opt in [ '-c', '--config']: - config = arg - else: - print('ERROR: Unhandled option') - usage() - - if not args: - sncli(sync, verbose, config).gui(key) - return - - def sncli_start(sync=sync, verbose=verbose, config=config): - sn = sncli(sync, verbose, config) - if sync: sn.sync_notes() - return sn - - if args[0] == 'sync': - sn = sncli_start(True) - - elif args[0] == 'list': - - sn = sncli_start() - sn.cli_list_notes(regex, ' '.join(args[1:])) - - elif args[0] == 'dump': - - sn = sncli_start() - if key: - sn.cli_note_dump(key) - else: - sn.cli_dump_notes(regex, ' '.join(args[1:])) - - elif args[0] == 'create': - - if len(args) == 1: - sn = sncli_start() - sn.cli_note_create(False, title) - elif len(args) == 2 and args[1] == '-': - sn = sncli_start() - sn.cli_note_create(True, title) - else: - usage() - - elif args[0] == 'import': - - if len(args) == 1: - sn = sncli_start() - sn.cli_note_import(False) - elif len(args) == 2 and args[1] == '-': - sn = sncli_start() - sn.cli_note_import(True) - else: - usage() - - elif args[0] == 'export': - - sn = sncli_start() - if key: - sn.cli_note_export(key) - else: - sn.cli_export_notes(regex, ' '.join(args[1:])) - - elif args[0] == 'edit': - - if not key: - usage() - - sn = sncli_start() - sn.cli_note_edit(key) - - elif args[0] == 'trash' or args[0] == 'untrash': - - if not key: - usage() - - sn = sncli_start() - sn.cli_note_trash(key, 1 if args[0] == 'trash' else 0) - - elif args[0] == 'pin' or args[0] == 'unpin': - - if not key: - usage() - - sn = sncli_start() - sn.cli_note_pin(key, 1 if args[0] == 'pin' else 0) - - elif args[0] == 'markdown' or args[0] == 'unmarkdown': - - if not key: - usage() - - sn = sncli_start() - sn.cli_note_markdown(key, 1 if args[0] == 'markdown' else 0) - - # Tag API - elif args[0] == 'tag': - - if not key: - usage() - - nargs = len(args) - correct_get = (args[1] == 'get' and nargs == 2) - correct_other = (args[1] in ['set', 'add', 'rm'] and nargs == 3) - if not (correct_get or correct_other): - usage() - - if args[1] == 'get': - - sn = sncli_start() - tags = sn.cli_note_tags_get(key) - if tags: - print(tags) - - elif args[1] == 'set': - - tags = args[2] - sn = sncli_start() - sn.cli_note_tags_set(key, tags) - - elif args[1] == 'add': - - new_tags = args[2] - sn = sncli_start() - sn.cli_note_tags_add(key, new_tags) - - elif args[1] == 'rm': - - rm_tags = args[2] - sn = sncli_start() - sn.cli_note_tags_rm(key, rm_tags) - - else: - usage() - diff --git a/simplenote_cli/utils.py b/simplenote_cli/utils.py @@ -1,198 +0,0 @@ - -# Copyright (c) 2014 Eric Davis -# This file is *heavily* modified from nvpy. - -# nvPY: cross-platform note-taking app with simplenote syncing -# copyright 2012 by Charl P. Botha <cpbotha@vxlabs.com> -# new BSD license - -import datetime, random, re - -# first line with non-whitespace should be the title -note_title_re = re.compile('\s*(.*)\n?') - -def generate_random_key(): - """Generate random 30 digit (15 byte) hex string. - - stackoverflow question 2782229 - """ - return '%030x' % (random.randrange(256**15),) - -def get_note_tags(note): - if 'tags' in note: - tags = '%s' % ','.join(note['tags']) - if 'deleted' in note and note['deleted']: - if tags: tags += ',trash' - else: tags = 'trash' - else: - tags = '' - return tags - -# Returns a fixed length string: -# 'X' - needs sync -# 'T' - trashed -# '*' - pinned -# 'S' - published/shared -# 'm' - markdown -def get_note_flags(note): - flags = '' - flags += 'X' if float(note['modifydate']) > float(note['syncdate']) else ' ' - flags += 'T' if 'deleted' in note and note['deleted'] else ' ' - if 'systemtags' in note: - flags += '*' if 'pinned' in note['systemtags'] else ' ' - flags += 'S' if 'published' in note['systemtags'] else ' ' - flags += 'm' if 'markdown' in note['systemtags'] else ' ' - else: - flags += ' ' - return flags - -def get_note_title(note): - mo = note_title_re.match(note.get('content', '')) - if mo: - return mo.groups()[0] - else: - return '' - -def get_note_title_file(note): - mo = note_title_re.match(note.get('content', '')) - if mo: - fn = mo.groups()[0] - fn = fn.replace(' ', '_') - fn = fn.replace('/', '_') - if not fn: - return '' - - if isinstance(fn, str): - fn = str(fn, 'utf-8') - else: - fn = str(fn) - - if note_markdown(note): - fn += '.mkdn' - else: - fn += '.txt' - - return fn - else: - return '' - -def human_date(timestamp): - """ - Given a timestamp, return pretty human format representation. - - For example, if timestamp is: - * today, then do "15:11" - * else if it is this year, then do "Aug 4" - * else do "Dec 11, 2011" - """ - - # this will also give us timestamp in the local timezone - dt = datetime.datetime.fromtimestamp(timestamp) - # this returns localtime - now = datetime.datetime.now() - - if dt.date() == now.date(): - # today: 15:11 - return dt.strftime('%H:%M') - - elif dt.year == now.year: - # this year: Aug 6 - # format code %d unfortunately 0-pads - return dt.strftime('%b') + ' ' + str(dt.day) - - else: - # not today or this year, so we do "Dec 11, 2011" - return '%s %d, %d' % (dt.strftime('%b'), dt.day, dt.year) - -def note_published(n): - asystags = n.get('systemtags', 0) - if not asystags: - return 0 - return 1 if 'published' in asystags else 0 - -def note_pinned(n): - asystags = n.get('systemtags', 0) - if not asystags: - return 0 - return 1 if 'pinned' in asystags else 0 - -def note_markdown(n): - asystags = n.get('systemtags', 0) - if not asystags: - return 0 - return 1 if 'markdown' in asystags else 0 - -tags_illegal_chars = re.compile(r'[\s]') -def sanitise_tags(tags): - """ - Given a string containing comma-separated tags, sanitise and return a list of string tags. - - The simplenote API doesn't allow for spaces, so we strip those out. - - @param tags: Comma-separated tags, one string. - @returns: List of strings. - """ - - # hack out all kinds of whitespace, then split on , - # if you run into more illegal characters (simplenote does not want to sync them) - # add them to the regular expression above. - illegals_removed = tags_illegal_chars.sub('', tags) - if len(illegals_removed) == 0: - # special case for empty string '' - # split turns that into [''], which is not valid - return [] - - else: - return illegals_removed.split(',') - -def sort_by_title_pinned(a): - return (not note_pinned(a.note), get_note_title(a.note)) - -def sort_notes_by_tags(notes, pinned_ontop=False): - notes.sort(key=lambda i: (pinned_ontop and not note_pinned(i.note), - i.note.get('tags'), - get_note_title(i.note))) - -def sort_by_modify_date_pinned(a): - if note_pinned(a.note): - return 100.0 * float(a.note.get('modifydate', 0)) - else: - return float(a.note.get('modifydate', 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 diff --git a/simplenote_cli/view_titles.py b/simplenote_cli/view_titles.py @@ -1,192 +0,0 @@ - -# Copyright (c) 2014 Eric Davis -# Licensed under the MIT License - -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 -- tags - %D -- date - %N -- note title - """ - - t = time.localtime(float(note['modifydate'])) - mod_time = time.strftime(self.config.get_config('format_strftime'), t) - title = utils.get_note_title(note) - flags = utils.get_note_flags(note) - tags = utils.get_note_tags(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(tags, - align=align, - wrap='clip'), - 'note_tags')) - 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_tags' : '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 = 'Simplenote' - - # include simplenote connection status in header - hdr += ' (' + self.ndb.simplenote.status + ')' - - if self.search_string != None: - hdr += ' - Search: ' + self.search_string - - status_title = \ - urwid.AttrMap(urwid.Text(hdr, - wrap='clip'), - 'status_bar') - status_index = \ - ('pack', urwid.AttrMap(urwid.Text(' ' + - str(cur + 1) + - '/' + - str(total)), - 'status_bar')) - return \ - urwid.AttrMap(urwid.Columns([ status_title, status_index ]), - 'status_bar') - - def update_note_title(self, key=None): - if not key: - self.body[self.focus_position] = \ - self.get_note_title(self.note_list[self.focus_position].note) - else: - for i in range(len(self.note_list)): - if self.note_list[i].note['localkey'] == key: - self.body[i] = self.get_note_title(self.note_list[i].note) - - def focus_note(self, key): - for i in range(len(self.note_list)): - if 'localkey' in self.note_list[i].note and \ - self.note_list[i].note['localkey'] == key: - self.focus_position = i - - def keypress(self, size, key): - return key - diff --git a/sncli b/sncli @@ -1,33 +0,0 @@ -#!/usr/bin/env python3 - -# -# ** 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. :-) -# - -from simplenote_cli import sncli - -if __name__ == '__main__': - sncli.main() -