diff --git a/features/environment.py b/features/environment.py index bc4a8dcb..533dcea5 100644 --- a/features/environment.py +++ b/features/environment.py @@ -1,6 +1,8 @@ import os import shutil -import sys + +from jrnl.os_compat import on_windows + CWD = os.getcwd() @@ -19,7 +21,7 @@ def before_feature(context, feature): feature.skip("Marked with @skip") return - if "skip_win" in feature.tags and "win32" in sys.platform: + if "skip_win" in feature.tags and on_windows: feature.skip("Skipping on Windows") return @@ -46,7 +48,7 @@ def before_scenario(context, scenario): scenario.skip("Marked with @skip") return - if "skip_win" in scenario.effective_tags and "win32" in sys.platform: + if "skip_win" in scenario.effective_tags and on_windows: scenario.skip("Skipping on Windows") return diff --git a/features/steps/core.py b/features/steps/core.py index d1446a48..449c2874 100644 --- a/features/steps/core.py +++ b/features/steps/core.py @@ -4,7 +4,6 @@ import os from pathlib import Path import re import shlex -import sys import time from unittest.mock import patch @@ -13,7 +12,14 @@ import toml import yaml from behave import given, then, when -from jrnl import Journal, __version__, cli, install, plugins, util +from jrnl import Journal +from jrnl import __version__ +from jrnl import cli +from jrnl import install +from jrnl import plugins + +from jrnl.config import load_config +from jrnl.os_compat import on_windows try: import parsedatetime.parsedatetime_consts as pdt @@ -62,18 +68,18 @@ keyring.set_keyring(TestKeyring()) def ushlex(command): - return shlex.split(command, posix="win32" not in sys.platform) + return shlex.split(command, posix=on_windows) def read_journal(journal_name="default"): - config = util.load_config(install.CONFIG_FILE_PATH) + config = load_config(install.CONFIG_FILE_PATH) with open(config["journals"][journal_name]) as journal_file: journal = journal_file.read() return journal def open_journal(journal_name="default"): - config = util.load_config(install.CONFIG_FILE_PATH) + config = load_config(install.CONFIG_FILE_PATH) journal_conf = config["journals"][journal_name] # We can override the default config on a by-journal basis @@ -364,7 +370,7 @@ def config_var(context, key, value, journal=None): # Handle value being a dictionary value = ast.literal_eval(value) - config = util.load_config(install.CONFIG_FILE_PATH) + config = load_config(install.CONFIG_FILE_PATH) if journal: config = config["journals"][journal] assert key in config diff --git a/jrnl/EncryptedJournal.py b/jrnl/EncryptedJournal.py index 5f553603..16cdb337 100644 --- a/jrnl/EncryptedJournal.py +++ b/jrnl/EncryptedJournal.py @@ -3,6 +3,8 @@ import hashlib import logging import os import sys +from typing import Callable, Optional +import getpass from cryptography.fernet import Fernet, InvalidToken from cryptography.hazmat.backends import default_backend @@ -10,11 +12,9 @@ from cryptography.hazmat.primitives import hashes, padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -from . import util +from .prompt import create_password from .Journal import Journal, LegacyJournal -log = logging.getLogger() - def make_key(password): password = password.encode("utf-8") @@ -30,6 +30,30 @@ def make_key(password): return base64.urlsafe_b64encode(key) +def decrypt_content( + decrypt_func: Callable[[str], Optional[str]], + keychain: str = None, + max_attempts: int = 3, +) -> str: + pwd_from_keychain = keychain and get_keychain(keychain) + password = pwd_from_keychain or getpass.getpass() + result = decrypt_func(password) + # Password is bad: + if result is None and pwd_from_keychain: + set_keychain(keychain, None) + attempt = 1 + while result is None and attempt < max_attempts: + print("Wrong password, try again.", file=sys.stderr) + password = getpass.getpass() + result = decrypt_func(password) + attempt += 1 + if result is not None: + return result + else: + print("Extremely wrong password.", file=sys.stderr) + sys.exit(1) + + class EncryptedJournal(Journal): def __init__(self, name="default", **kwargs): super().__init__(name, **kwargs) @@ -46,7 +70,7 @@ class EncryptedJournal(Journal): os.makedirs(dirname) print(f"[Directory {dirname} created]", file=sys.stderr) self.create_file(filename) - self.password = util.create_password(self.name) + self.password = create_password(self.name) print( f"Encrypted journal '{self.name}' created at {filename}", @@ -56,7 +80,7 @@ class EncryptedJournal(Journal): text = self._load(filename) self.entries = self._parse(text) self.sort() - log.debug("opened %s with %d entries", self.__class__.__name__, len(self)) + logging.debug("opened %s with %d entries", self.__class__.__name__, len(self)) return self def _load(self, filename): @@ -80,7 +104,7 @@ class EncryptedJournal(Journal): if self.password: return decrypt_journal(self.password) - return util.decrypt_content(keychain=self.name, decrypt_func=decrypt_journal) + return decrypt_content(keychain=self.name, decrypt_func=decrypt_journal) def _store(self, filename, text): key = make_key(self.password) @@ -95,7 +119,7 @@ class EncryptedJournal(Journal): new_journal.password = ( other.password if hasattr(other, "password") - else util.create_password(other.name) + else create_password(other.name) ) except KeyboardInterrupt: print("[Interrupted while creating new journal]", file=sys.stderr) @@ -138,4 +162,31 @@ class LegacyEncryptedJournal(LegacyJournal): if self.password: return decrypt_journal(self.password) - return util.decrypt_content(keychain=self.name, decrypt_func=decrypt_journal) + return decrypt_content(keychain=self.name, decrypt_func=decrypt_journal) + + +def get_keychain(journal_name): + import keyring + + try: + return keyring.get_password("jrnl", journal_name) + except RuntimeError: + return "" + + +def set_keychain(journal_name, password): + import keyring + + if password is None: + try: + keyring.delete_password("jrnl", journal_name) + except keyring.errors.PasswordDeleteError: + pass + else: + try: + keyring.set_password("jrnl", journal_name, password) + except keyring.errors.NoKeyringError: + print( + "Keyring backend not found. Please install one of the supported backends by visiting: https://pypi.org/project/keyring/", + file=sys.stderr, + ) diff --git a/jrnl/Entry.py b/jrnl/Entry.py index 807ed86d..101c4a63 100755 --- a/jrnl/Entry.py +++ b/jrnl/Entry.py @@ -5,7 +5,8 @@ import re import ansiwrap -from .util import colorize, highlight_tags_with_background_color, split_title +from .color import colorize +from .color import highlight_tags_with_background_color class Entry: @@ -194,3 +195,28 @@ class Entry: def __ne__(self, other): return not self.__eq__(other) + + +# Based on Segtok by Florian Leitner +# https://github.com/fnl/segtok +SENTENCE_SPLITTER = re.compile( + r""" +( # A sentence ends at one of two sequences: + [.!?\u203C\u203D\u2047\u2048\u2049\u3002\uFE52\uFE57\uFF01\uFF0E\uFF1F\uFF61] # Either, a sequence starting with a sentence terminal, + [\'\u2019\"\u201D]? # an optional right quote, + [\]\)]* # optional closing brackets and + \s+ # a sequence of required spaces. +)""", + re.VERBOSE, +) +SENTENCE_SPLITTER_ONLY_NEWLINE = re.compile("\n") + + +def split_title(text): + """Splits the first sentence off from a text.""" + sep = SENTENCE_SPLITTER_ONLY_NEWLINE.search(text.lstrip()) + if not sep: + sep = SENTENCE_SPLITTER.search(text) + if not sep: + return text, "" + return text[: sep.end()].strip(), text[sep.end() :].strip() diff --git a/jrnl/Journal.py b/jrnl/Journal.py index a862d5e9..a6bf35d0 100644 --- a/jrnl/Journal.py +++ b/jrnl/Journal.py @@ -6,9 +6,9 @@ import os import re import sys -from jrnl import Entry, time, util - -log = logging.getLogger(__name__) +from . import Entry +from . import time +from .prompt import yesno class Tag: @@ -56,7 +56,7 @@ class Journal: another journal object""" new_journal = cls(other.name, **other.config) new_journal.entries = other.entries - log.debug( + logging.debug( "Imported %d entries from %s to %s", len(new_journal), other.__class__.__name__, @@ -85,7 +85,7 @@ class Journal: text = self._load(filename) self.entries = self._parse(text) self.sort() - log.debug("opened %s with %d entries", self.__class__.__name__, len(self)) + logging.debug("opened %s with %d entries", self.__class__.__name__, len(self)) return self def write(self, filename=None): @@ -248,9 +248,7 @@ class Journal: to_delete = [] def ask_delete(entry): - return util.yesno( - f"Delete entry '{entry.pprint(short=True)}'?", default=False, - ) + return yesno(f"Delete entry '{entry.pprint(short=True)}'?", default=False,) for entry in self.entries: if ask_delete(entry): diff --git a/jrnl/cli.py b/jrnl/cli.py index f2cbbc3e..87c0bcab 100644 --- a/jrnl/cli.py +++ b/jrnl/cli.py @@ -1,5 +1,4 @@ #!/usr/bin/env python - """ jrnl @@ -7,8 +6,7 @@ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. + the Free Software Foundation, either version 3 of the License. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of @@ -18,34 +16,17 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ - import logging -import packaging.version -import platform import sys -from . import install, util from . import jrnl +from . import install + from .parse_args import parse_args +from .config import scope_config +from .exception import UserAbort from .Journal import open_journal -from .util import UserAbort -from .util import get_journal_name -from .util import WARNING_COLOR, RESET_COLOR - -log = logging.getLogger(__name__) - - -def update_config(config, new_config, scope, force_local=False): - """Updates a config dict with new values - either global if scope is None - or config['journals'][scope] is just a string pointing to a journal file, - or within the scope""" - if scope and type(config["journals"][scope]) is dict: # Update to journal specific - config["journals"][scope].update(new_config) - elif scope and force_local: # Convert to dict - config["journals"][scope] = {"journal": config["journals"][scope]} - config["journals"][scope].update(new_config) - else: - config.update(new_config) +from .config import get_journal_name def configure_logger(debug=False): @@ -58,19 +39,6 @@ def configure_logger(debug=False): def run(manual_args=None): - if packaging.version.parse(platform.python_version()) < packaging.version.parse( - "3.7" - ): - print( - f"""{ERROR_COLOR} -ERROR: Python version {platform.python_version()} not supported. - -Please update to Python 3.7 (or higher) in order to use jrnl. -{RESET_COLOR}""", - file=sys.stderr, - ) - sys.exit(1) - """ Flow: 1. Parse cli arguments @@ -86,7 +54,7 @@ Please update to Python 3.7 (or higher) in order to use jrnl. args = parse_args(manual_args) configure_logger(args.debug) - log.debug("Parsed args: %s", args) + logging.debug("Parsed args: %s", args) # Run command if possible before config is available if callable(args.preconfig_cmd): @@ -98,7 +66,7 @@ Please update to Python 3.7 (or higher) in order to use jrnl. config = install.load_or_install_jrnl() original_config = config.copy() args = get_journal_name(args, config) - config = util.scope_config(config, args.journal_name) + config = scope_config(config, args.journal_name) except UserAbort as err: print(f"\n{err}", file=sys.stderr) sys.exit(1) diff --git a/jrnl/color.py b/jrnl/color.py new file mode 100644 index 00000000..fbdd2b8b --- /dev/null +++ b/jrnl/color.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python +import colorama +import re +from string import punctuation +from string import whitespace +from .os_compat import on_windows + +if on_windows: + colorama.init() + +WARNING_COLOR = colorama.Fore.YELLOW +ERROR_COLOR = colorama.Fore.RED +RESET_COLOR = colorama.Fore.RESET + + +def colorize(string, color, bold=False): + """Returns the string colored with colorama.Fore.color. If the color set by + the user is "NONE" or the color doesn't exist in the colorama.Fore attributes, + it returns the string without any modification.""" + color_escape = getattr(colorama.Fore, color.upper(), None) + if not color_escape: + return string + elif not bold: + return color_escape + string + colorama.Fore.RESET + else: + return colorama.Style.BRIGHT + color_escape + string + colorama.Style.RESET_ALL + + +def highlight_tags_with_background_color(entry, text, color, is_title=False): + """ + Takes a string and colorizes the tags in it based upon the config value for + color.tags, while colorizing the rest of the text based on `color`. + :param entry: Entry object, for access to journal config + :param text: Text to be colorized + :param color: Color for non-tag text, passed to colorize() + :param is_title: Boolean flag indicating if the text is a title or not + :return: Colorized str + """ + + def colorized_text_generator(fragments): + """Efficiently generate colorized tags / text from text fragments. + Taken from @shobrook. Thanks, buddy :) + :param fragments: List of strings representing parts of entry (tag or word). + :rtype: List of tuples + :returns [(colorized_str, original_str)]""" + for part in fragments: + if part and part[0] not in config["tagsymbols"]: + yield (colorize(part, color, bold=is_title), part) + elif part: + yield (colorize(part, config["colors"]["tags"], bold=True), part) + + config = entry.journal.config + if config["highlight"]: # highlight tags + text_fragments = re.split(entry.tag_regex(config["tagsymbols"]), text) + + # Colorizing tags inside of other blocks of text + final_text = "" + previous_piece = "" + for colorized_piece, piece in colorized_text_generator(text_fragments): + # If this piece is entirely punctuation or whitespace or the start + # of a line or the previous piece was a tag or this piece is a tag, + # then add it to the final text without a leading space. + if ( + all(char in punctuation + whitespace for char in piece) + or previous_piece.endswith("\n") + or (previous_piece and previous_piece[0] in config["tagsymbols"]) + or piece[0] in config["tagsymbols"] + ): + final_text += colorized_piece + else: + # Otherwise add a leading space and then append the piece. + final_text += " " + colorized_piece + + previous_piece = piece + return final_text.lstrip() + else: + return text diff --git a/jrnl/commands.py b/jrnl/commands.py index 544dd1dc..96dc68c0 100644 --- a/jrnl/commands.py +++ b/jrnl/commands.py @@ -34,7 +34,7 @@ def preconfig_version(_): def postconfig_list(config, **kwargs): - from .util import list_journals + from .output import list_journals print(list_journals(config)) @@ -56,7 +56,7 @@ def postconfig_encrypt(args, config, original_config, **kwargs): """ from .EncryptedJournal import EncryptedJournal from .Journal import open_journal - from .cli import update_config + from .config import update_config from .install import save_config # Open the journal @@ -84,7 +84,7 @@ def postconfig_decrypt(args, config, original_config, **kwargs): """ Decrypts into new file. If filename is not set, we encrypt the journal file itself. """ from .Journal import PlainJournal from .Journal import open_journal - from .cli import update_config + from .config import update_config from .install import save_config journal = open_journal(args.journal_name, config) diff --git a/jrnl/config.py b/jrnl/config.py new file mode 100644 index 00000000..2f9388ce --- /dev/null +++ b/jrnl/config.py @@ -0,0 +1,87 @@ +import logging +import colorama +import sys +import yaml + +from .color import ERROR_COLOR +from .color import RESET_COLOR +from .output import list_journals + + +def scope_config(config, journal_name): + if journal_name not in config["journals"]: + return config + config = config.copy() + journal_conf = config["journals"].get(journal_name) + if type(journal_conf) is dict: + # We can override the default config on a by-journal basis + logging.debug( + "Updating configuration with specific journal overrides %s", journal_conf + ) + config.update(journal_conf) + else: + # But also just give them a string to point to the journal file + config["journal"] = journal_conf + return config + + +def verify_config(config): + """ + Ensures the keys set for colors are valid colorama.Fore attributes, or "None" + :return: True if all keys are set correctly, False otherwise + """ + all_valid_colors = True + for key, color in config["colors"].items(): + upper_color = color.upper() + if upper_color == "NONE": + continue + if not getattr(colorama.Fore, upper_color, None): + print( + "[{2}ERROR{3}: {0} set to invalid color: {1}]".format( + key, color, ERROR_COLOR, RESET_COLOR + ), + file=sys.stderr, + ) + all_valid_colors = False + return all_valid_colors + + +def load_config(config_path): + """Tries to load a config file from YAML.""" + with open(config_path) as f: + return yaml.load(f, Loader=yaml.FullLoader) + + +def is_config_json(config_path): + with open(config_path, "r", encoding="utf-8") as f: + config_file = f.read() + return config_file.strip().startswith("{") + + +def update_config(config, new_config, scope, force_local=False): + """Updates a config dict with new values - either global if scope is None + or config['journals'][scope] is just a string pointing to a journal file, + or within the scope""" + if scope and type(config["journals"][scope]) is dict: # Update to journal specific + config["journals"][scope].update(new_config) + elif scope and force_local: # Convert to dict + config["journals"][scope] = {"journal": config["journals"][scope]} + config["journals"][scope].update(new_config) + else: + config.update(new_config) + + +def get_journal_name(args, config): + from . import install + + args.journal_name = install.DEFAULT_JOURNAL_KEY + if args.text and args.text[0] in config["journals"]: + args.journal_name = args.text[0] + args.text = args.text[1:] + elif install.DEFAULT_JOURNAL_KEY not in config["journals"]: + print("No default journal configured.", file=sys.stderr) + print(list_journals(config), file=sys.stderr) + sys.exit(1) + + logging.debug("Using journal name: %s", args.journal_name) + return args diff --git a/jrnl/editor.py b/jrnl/editor.py new file mode 100644 index 00000000..fb7c39c1 --- /dev/null +++ b/jrnl/editor.py @@ -0,0 +1,40 @@ +import tempfile +import os +import sys +import shlex +import subprocess +import textwrap + +from .color import ERROR_COLOR +from .color import RESET_COLOR +from .os_compat import on_windows + + +def get_text_from_editor(config, template=""): + filehandle, tmpfile = tempfile.mkstemp(prefix="jrnl", text=True, suffix=".txt") + os.close(filehandle) + + with open(tmpfile, "w", encoding="utf-8") as f: + if template: + f.write(template) + + try: + subprocess.call(shlex.split(config["editor"], posix=on_windows) + [tmpfile]) + except Exception as e: + error_msg = f""" + {ERROR_COLOR}{str(e)}{RESET_COLOR} + + Please check the 'editor' key in your config file for errors: + {repr(config['editor'])} + """ + print(textwrap.dedent(error_msg).strip(), file=sys.stderr) + exit(1) + + with open(tmpfile, "r", encoding="utf-8") as f: + raw = f.read() + os.remove(tmpfile) + + if not raw: + print("[Nothing saved to file]", file=sys.stderr) + + return raw diff --git a/jrnl/exception.py b/jrnl/exception.py new file mode 100644 index 00000000..9ed93e25 --- /dev/null +++ b/jrnl/exception.py @@ -0,0 +1,8 @@ +class UserAbort(Exception): + pass + + +class UpgradeValidationException(Exception): + """Raised when the contents of an upgraded journal do not match the old journal""" + + pass diff --git a/jrnl/install.py b/jrnl/install.py index 80934a71..af71a40d 100644 --- a/jrnl/install.py +++ b/jrnl/install.py @@ -4,14 +4,17 @@ import glob import logging import os import sys - import xdg.BaseDirectory import yaml +from . import __version__ +from .config import load_config +from .config import verify_config +from .exception import UserAbort +from .os_compat import on_windows +from .prompt import yesno +from .upgrade import is_old_version -from . import __version__, util -from .util import UserAbort, verify_config - -if "win32" not in sys.platform: +if not on_windows: # readline is not included in Windows Active Python import readline @@ -29,18 +32,6 @@ CONFIG_FILE_PATH_FALLBACK = os.path.join(USER_HOME, ".jrnl_config") JOURNAL_PATH = xdg.BaseDirectory.save_data_path(XDG_RESOURCE) or USER_HOME JOURNAL_FILE_PATH = os.path.join(JOURNAL_PATH, DEFAULT_JOURNAL_NAME) -log = logging.getLogger(__name__) - - -def module_exists(module_name): - """Checks if a module exists and can be imported""" - try: - __import__(module_name) - except ImportError: - return False - else: - return True - default_config = { "version": __version__, @@ -93,10 +84,10 @@ def load_or_install_jrnl(): else CONFIG_FILE_PATH_FALLBACK ) if os.path.exists(config_path): - log.debug("Reading configuration from file %s", config_path) - config = util.load_config(config_path) + logging.debug("Reading configuration from file %s", config_path) + config = load_config(config_path) - if util.is_old_version(config_path): + if is_old_version(config_path): from . import upgrade try: @@ -118,21 +109,21 @@ def load_or_install_jrnl(): verify_config(config) else: - log.debug("Configuration file not found, installing jrnl...") + logging.debug("Configuration file not found, installing jrnl...") try: config = install() except KeyboardInterrupt: raise UserAbort("Installation aborted") - log.debug('Using configuration "%s"', config) + logging.debug('Using configuration "%s"', config) return config def install(): - if "win32" not in sys.platform: + if not on_windows: readline.set_completer_delims(" \t\n;") readline.parse_and_bind("tab: complete") - readline.set_completer(autocomplete) + readline.set_completer(_autocomplete_path) # Where to create the journal? path_query = f"Path to your journal file (leave blank for {JOURNAL_FILE_PATH}): " @@ -149,7 +140,7 @@ def install(): pass # Encrypt it? - encrypt = util.yesno( + encrypt = yesno( "Do you want to encrypt your journal? You can always change this later", default=False, ) @@ -161,7 +152,7 @@ def install(): return default_config -def autocomplete(text, state): +def _autocomplete_path(text, state): expansions = glob.glob(os.path.expanduser(os.path.expandvars(text)) + "*") expansions = [e + "/" if os.path.isdir(e) else e for e in expansions] expansions.append(None) diff --git a/jrnl/jrnl.py b/jrnl/jrnl.py index c7a80368..0c72e559 100644 --- a/jrnl/jrnl.py +++ b/jrnl/jrnl.py @@ -1,14 +1,13 @@ import logging import sys -from . import util from . import install from . import plugins +from .editor import get_text_from_editor -from .util import ERROR_COLOR -from .util import RESET_COLOR - -log = logging.getLogger(__name__) +from .color import ERROR_COLOR +from .color import RESET_COLOR +from .os_compat import on_windows def _is_write_mode(args, config, **kwargs): @@ -53,30 +52,30 @@ def write_mode(args, config, journal, **kwargs): 4. Use stdin.read as last resort 6. Write any found text to journal, or exit """ - log.debug("Write mode: starting") + logging.debug("Write mode: starting") if args.text: - log.debug("Write mode: cli text detected: %s", args.text) + logging.debug("Write mode: cli text detected: %s", args.text) raw = " ".join(args.text).strip() elif not sys.stdin.isatty(): - log.debug("Write mode: receiving piped text") + logging.debug("Write mode: receiving piped text") raw = sys.stdin.read() else: raw = _write_in_editor(config) if not raw: - log.error("Write mode: couldn't get raw text") + logging.error("Write mode: couldn't get raw text") sys.exit() - log.debug( + logging.debug( 'Write mode: appending raw text to journal "%s": %s', args.journal_name, raw ) journal.new_entry(raw) print(f"[Entry added to {args.journal_name} journal]", file=sys.stderr) journal.write() - log.debug("Write mode: completed journal.write()", args.journal_name, raw) + logging.debug("Write mode: completed journal.write()", args.journal_name, raw) def search_mode(args, journal, **kwargs): @@ -109,12 +108,12 @@ def search_mode(args, journal, **kwargs): def _write_in_editor(config): if config["editor"]: - log.debug("Write mode: opening editor") + logging.debug("Write mode: opening editor") template = _get_editor_template(config) - raw = util.get_text_from_editor(config, template) + raw = get_text_from_editor(config, template) else: - _how_to_quit = "Ctrl+z and then Enter" if "win32" in sys.platform else "Ctrl+d" + _how_to_quit = "Ctrl+z and then Enter" if on_windows else "Ctrl+d" print( f"[Writing Entry; on a blank line, press {_how_to_quit} to finish writing]\n", file=sys.stderr, @@ -122,7 +121,7 @@ def _write_in_editor(config): try: raw = sys.stdin.read() except KeyboardInterrupt: - log.error("Write mode: keyboard interrupt") + logging.error("Write mode: keyboard interrupt") print("[Entry NOT saved to journal]", file=sys.stderr) sys.exit(0) @@ -130,17 +129,17 @@ def _write_in_editor(config): def _get_editor_template(config, **kwargs): - log.debug("Write mode: loading template for entry") + logging.debug("Write mode: loading template for entry") if not config["template"]: - log.debug("Write mode: no template configured") + logging.debug("Write mode: no template configured") return "" try: template = open(config["template"]).read() - log.debug("Write mode: template loaded: %s", template) + logging.debug("Write mode: template loaded: %s", template) except OSError: - log.error("Write mode: template not loaded") + logging.error("Write mode: template not loaded") print( f"[Could not read template at '{config['template']}']", file=sys.stderr, ) @@ -191,7 +190,7 @@ def _edit_search_results(config, journal, old_entries, **kwargs): old_stats = _get_predit_stats(journal) # Send user to the editor - edited = util.get_text_from_editor(config, journal.editable_str()) + edited = get_text_from_editor(config, journal.editable_str()) journal.parse_editable_str(edited) # Print summary if available diff --git a/jrnl/os_compat.py b/jrnl/os_compat.py new file mode 100644 index 00000000..33fd47e1 --- /dev/null +++ b/jrnl/os_compat.py @@ -0,0 +1,3 @@ +from sys import platform + +on_windows = "win32" in platform diff --git a/jrnl/output.py b/jrnl/output.py new file mode 100644 index 00000000..271a6c00 --- /dev/null +++ b/jrnl/output.py @@ -0,0 +1,31 @@ +import logging + + +def deprecated_cmd(old_cmd, new_cmd, callback=None, **kwargs): + import sys + import textwrap + from .color import RESET_COLOR, WARNING_COLOR + + warning_msg = f""" + The command {old_cmd} is deprecated and will be removed from jrnl soon. + Please us {new_cmd} instead. + """ + warning_msg = textwrap.dedent(warning_msg) + logging.warning(warning_msg) + print(f"{WARNING_COLOR}{warning_msg}{RESET_COLOR}", file=sys.stderr) + + if callback is not None: + callback(**kwargs) + + +def list_journals(config): + from . import install + + """List the journals specified in the configuration file""" + result = f"Journals defined in {install.CONFIG_FILE_PATH}\n" + ml = min(max(len(k) for k in config["journals"]), 20) + for journal, cfg in config["journals"].items(): + result += " * {:{}} -> {}\n".format( + journal, ml, cfg["journal"] if isinstance(cfg, dict) else cfg + ) + return result diff --git a/jrnl/parse_args.py b/jrnl/parse_args.py index 8623a603..3127b8f0 100644 --- a/jrnl/parse_args.py +++ b/jrnl/parse_args.py @@ -11,7 +11,7 @@ from .commands import postconfig_list from .commands import postconfig_import from .commands import postconfig_encrypt from .commands import postconfig_decrypt -from .util import deprecated_cmd +from .output import deprecated_cmd class WrappingFormatter(argparse.RawTextHelpFormatter): diff --git a/jrnl/plugins/markdown_exporter.py b/jrnl/plugins/markdown_exporter.py index 65cba0fe..5c0fd486 100644 --- a/jrnl/plugins/markdown_exporter.py +++ b/jrnl/plugins/markdown_exporter.py @@ -5,7 +5,7 @@ import os import re import sys -from ..util import RESET_COLOR, WARNING_COLOR +from jrnl.color import RESET_COLOR, WARNING_COLOR from .text_exporter import TextExporter diff --git a/jrnl/plugins/text_exporter.py b/jrnl/plugins/text_exporter.py index 4fa781ab..eccd6b5b 100644 --- a/jrnl/plugins/text_exporter.py +++ b/jrnl/plugins/text_exporter.py @@ -2,8 +2,10 @@ # encoding: utf-8 import os +import re +import unicodedata -from ..util import ERROR_COLOR, RESET_COLOR, slugify +from jrnl.color import ERROR_COLOR, RESET_COLOR class TextExporter: @@ -35,7 +37,7 @@ class TextExporter: @classmethod def make_filename(cls, entry): return entry.date.strftime( - "%Y-%m-%d_{}.{}".format(slugify(str(entry.title)), cls.extension) + "%Y-%m-%d_{}.{}".format(cls._slugify(str(entry.title)), cls.extension) ) @classmethod @@ -52,6 +54,15 @@ class TextExporter: ) return "[Journal exported to {}]".format(path) + def _slugify(string): + """Slugifies a string. + Based on public domain code from https://github.com/zacharyvoase/slugify + """ + normalized_string = str(unicodedata.normalize("NFKD", string)) + no_punctuation = re.sub(r"[^\w\s-]", "", normalized_string).strip().lower() + slug = re.sub(r"[-\s]+", "-", no_punctuation) + return slug + @classmethod def export(cls, journal, output=None): """Exports to individual files if output is an existing path, or into diff --git a/jrnl/plugins/yaml_exporter.py b/jrnl/plugins/yaml_exporter.py index 23fded65..637990d0 100644 --- a/jrnl/plugins/yaml_exporter.py +++ b/jrnl/plugins/yaml_exporter.py @@ -5,7 +5,7 @@ import os import re import sys -from ..util import ERROR_COLOR, RESET_COLOR, WARNING_COLOR +from jrnl.color import ERROR_COLOR, RESET_COLOR, WARNING_COLOR from .text_exporter import TextExporter diff --git a/jrnl/prompt.py b/jrnl/prompt.py new file mode 100644 index 00000000..2d68d5e4 --- /dev/null +++ b/jrnl/prompt.py @@ -0,0 +1,28 @@ +import sys +import getpass + + +def create_password( + journal_name: str, prompt: str = "Enter password for new journal: " +) -> str: + while True: + pw = getpass.getpass(prompt) + if not pw: + print("Password can't be an empty string!", file=sys.stderr) + continue + elif pw == getpass.getpass("Enter password again: "): + break + + print("Passwords did not match, please try again", file=sys.stderr) + + if yesno("Do you want to store the password in your keychain?", default=True): + from .EncryptedJournal import set_keychain + + set_keychain(journal_name, pw) + return pw + + +def yesno(prompt, default=True): + prompt = f"{prompt.strip()} {'[Y/n]' if default else '[y/N]'} " + response = input(prompt) + return {"y": True, "n": False}.get(response.lower().strip(), default) diff --git a/jrnl/upgrade.py b/jrnl/upgrade.py index 4a638563..1b47eb9c 100644 --- a/jrnl/upgrade.py +++ b/jrnl/upgrade.py @@ -1,9 +1,15 @@ import os import sys -from . import Journal, __version__, util +from . import __version__ +from . import Journal +from .config import is_config_json +from .config import load_config +from .config import scope_config +from .exception import UpgradeValidationException +from .exception import UserAbort +from .prompt import yesno from .EncryptedJournal import EncryptedJournal -from .util import UserAbort def backup(filename, binary=False): @@ -19,7 +25,7 @@ def backup(filename, binary=False): except FileNotFoundError: print(f"\nError: {filename} does not exist.") try: - cont = util.yesno(f"\nCreate {filename}?", default=False) + cont = yesno(f"\nCreate {filename}?", default=False) if not cont: raise KeyboardInterrupt @@ -35,7 +41,7 @@ def check_exists(path): def upgrade_jrnl(config_path): - config = util.load_config(config_path) + config = load_config(config_path) print( f"""Welcome to jrnl {__version__}. @@ -113,7 +119,7 @@ older versions of jrnl anymore. ) try: - cont = util.yesno("\nContinue upgrading jrnl?", default=False) + cont = yesno("\nContinue upgrading jrnl?", default=False) if not cont: raise KeyboardInterrupt except KeyboardInterrupt: @@ -126,7 +132,7 @@ older versions of jrnl anymore. ) backup(path, binary=True) old_journal = Journal.open_journal( - journal_name, util.scope_config(config, journal_name), legacy=True + journal_name, scope_config(config, journal_name), legacy=True ) all_journals.append(EncryptedJournal.from_journal(old_journal)) @@ -137,7 +143,7 @@ older versions of jrnl anymore. ) backup(path) old_journal = Journal.open_journal( - journal_name, util.scope_config(config, journal_name), legacy=True + journal_name, scope_config(config, journal_name), legacy=True ) all_journals.append(Journal.PlainJournal.from_journal(old_journal)) @@ -166,7 +172,5 @@ older versions of jrnl anymore. print("\nWe're all done here and you can start enjoying jrnl 2.", file=sys.stderr) -class UpgradeValidationException(Exception): - """Raised when the contents of an upgraded journal do not match the old journal""" - - pass +def is_old_version(config_path): + return is_config_json(config_path) diff --git a/jrnl/util.py b/jrnl/util.py deleted file mode 100644 index 9e1fb3df..00000000 --- a/jrnl/util.py +++ /dev/null @@ -1,338 +0,0 @@ -#!/usr/bin/env python - -import getpass as gp -import logging -import os -import re -import shlex -from string import punctuation, whitespace -import subprocess -import sys -import tempfile -import textwrap -from typing import Callable, Optional -import unicodedata - -import colorama -import yaml - -if "win32" in sys.platform: - colorama.init() - -log = logging.getLogger(__name__) - -WARNING_COLOR = colorama.Fore.YELLOW -ERROR_COLOR = colorama.Fore.RED -RESET_COLOR = colorama.Fore.RESET - -# Based on Segtok by Florian Leitner -# https://github.com/fnl/segtok -SENTENCE_SPLITTER = re.compile( - r""" -( # A sentence ends at one of two sequences: - [.!?\u203C\u203D\u2047\u2048\u2049\u3002\uFE52\uFE57\uFF01\uFF0E\uFF1F\uFF61] # Either, a sequence starting with a sentence terminal, - [\'\u2019\"\u201D]? # an optional right quote, - [\]\)]* # optional closing brackets and - \s+ # a sequence of required spaces. -)""", - re.VERBOSE, -) -SENTENCE_SPLITTER_ONLY_NEWLINE = re.compile("\n") - - -class UserAbort(Exception): - pass - - -def create_password( - journal_name: str, prompt: str = "Enter password for new journal: " -) -> str: - while True: - pw = gp.getpass(prompt) - if not pw: - print("Password can't be an empty string!", file=sys.stderr) - continue - elif pw == gp.getpass("Enter password again: "): - break - - print("Passwords did not match, please try again", file=sys.stderr) - - if yesno("Do you want to store the password in your keychain?", default=True): - set_keychain(journal_name, pw) - return pw - - -def decrypt_content( - decrypt_func: Callable[[str], Optional[str]], - keychain: str = None, - max_attempts: int = 3, -) -> str: - pwd_from_keychain = keychain and get_keychain(keychain) - password = pwd_from_keychain or gp.getpass() - result = decrypt_func(password) - # Password is bad: - if result is None and pwd_from_keychain: - set_keychain(keychain, None) - attempt = 1 - while result is None and attempt < max_attempts: - print("Wrong password, try again.", file=sys.stderr) - password = gp.getpass() - result = decrypt_func(password) - attempt += 1 - if result is not None: - return result - else: - print("Extremely wrong password.", file=sys.stderr) - sys.exit(1) - - -def get_keychain(journal_name): - import keyring - - try: - return keyring.get_password("jrnl", journal_name) - except RuntimeError: - return "" - - -def set_keychain(journal_name, password): - import keyring - - if password is None: - try: - keyring.delete_password("jrnl", journal_name) - except keyring.errors.PasswordDeleteError: - pass - else: - try: - keyring.set_password("jrnl", journal_name, password) - except keyring.errors.NoKeyringError: - print( - "Keyring backend not found. Please install one of the supported backends by visiting: https://pypi.org/project/keyring/", - file=sys.stderr, - ) - - -def yesno(prompt, default=True): - prompt = f"{prompt.strip()} {'[Y/n]' if default else '[y/N]'} " - response = input(prompt) - return {"y": True, "n": False}.get(response.lower().strip(), default) - - -def load_config(config_path): - """Tries to load a config file from YAML. - """ - with open(config_path) as f: - return yaml.load(f, Loader=yaml.FullLoader) - - -def is_config_json(config_path): - with open(config_path, "r", encoding="utf-8") as f: - config_file = f.read() - return config_file.strip().startswith("{") - - -def is_old_version(config_path): - return is_config_json(config_path) - - -def scope_config(config, journal_name): - if journal_name not in config["journals"]: - return config - config = config.copy() - journal_conf = config["journals"].get(journal_name) - if type(journal_conf) is dict: - # We can override the default config on a by-journal basis - log.debug( - "Updating configuration with specific journal overrides %s", journal_conf - ) - config.update(journal_conf) - else: - # But also just give them a string to point to the journal file - config["journal"] = journal_conf - return config - - -def verify_config(config): - """ - Ensures the keys set for colors are valid colorama.Fore attributes, or "None" - :return: True if all keys are set correctly, False otherwise - """ - all_valid_colors = True - for key, color in config["colors"].items(): - upper_color = color.upper() - if upper_color == "NONE": - continue - if not getattr(colorama.Fore, upper_color, None): - print( - "[{2}ERROR{3}: {0} set to invalid color: {1}]".format( - key, color, ERROR_COLOR, RESET_COLOR - ), - file=sys.stderr, - ) - all_valid_colors = False - return all_valid_colors - - -def get_text_from_editor(config, template=""): - filehandle, tmpfile = tempfile.mkstemp(prefix="jrnl", text=True, suffix=".txt") - os.close(filehandle) - - with open(tmpfile, "w", encoding="utf-8") as f: - if template: - f.write(template) - - try: - subprocess.call( - shlex.split(config["editor"], posix="win32" not in sys.platform) + [tmpfile] - ) - except Exception as e: - error_msg = f""" - {ERROR_COLOR}{str(e)}{RESET_COLOR} - - Please check the 'editor' key in your config file for errors: - {repr(config['editor'])} - """ - print(textwrap.dedent(error_msg).strip(), file=sys.stderr) - exit(1) - - with open(tmpfile, "r", encoding="utf-8") as f: - raw = f.read() - os.remove(tmpfile) - - if not raw: - print("[Nothing saved to file]", file=sys.stderr) - - return raw - - -def colorize(string, color, bold=False): - """Returns the string colored with colorama.Fore.color. If the color set by - the user is "NONE" or the color doesn't exist in the colorama.Fore attributes, - it returns the string without any modification.""" - color_escape = getattr(colorama.Fore, color.upper(), None) - if not color_escape: - return string - elif not bold: - return color_escape + string + colorama.Fore.RESET - else: - return colorama.Style.BRIGHT + color_escape + string + colorama.Style.RESET_ALL - - -def highlight_tags_with_background_color(entry, text, color, is_title=False): - """ - Takes a string and colorizes the tags in it based upon the config value for - color.tags, while colorizing the rest of the text based on `color`. - :param entry: Entry object, for access to journal config - :param text: Text to be colorized - :param color: Color for non-tag text, passed to colorize() - :param is_title: Boolean flag indicating if the text is a title or not - :return: Colorized str - """ - - def colorized_text_generator(fragments): - """Efficiently generate colorized tags / text from text fragments. - Taken from @shobrook. Thanks, buddy :) - :param fragments: List of strings representing parts of entry (tag or word). - :rtype: List of tuples - :returns [(colorized_str, original_str)]""" - for part in fragments: - if part and part[0] not in config["tagsymbols"]: - yield (colorize(part, color, bold=is_title), part) - elif part: - yield (colorize(part, config["colors"]["tags"], bold=True), part) - - config = entry.journal.config - if config["highlight"]: # highlight tags - text_fragments = re.split(entry.tag_regex(config["tagsymbols"]), text) - - # Colorizing tags inside of other blocks of text - final_text = "" - previous_piece = "" - for colorized_piece, piece in colorized_text_generator(text_fragments): - # If this piece is entirely punctuation or whitespace or the start - # of a line or the previous piece was a tag or this piece is a tag, - # then add it to the final text without a leading space. - if ( - all(char in punctuation + whitespace for char in piece) - or previous_piece.endswith("\n") - or (previous_piece and previous_piece[0] in config["tagsymbols"]) - or piece[0] in config["tagsymbols"] - ): - final_text += colorized_piece - else: - # Otherwise add a leading space and then append the piece. - final_text += " " + colorized_piece - - previous_piece = piece - return final_text.lstrip() - else: - return text - - -def slugify(string): - """Slugifies a string. - Based on public domain code from https://github.com/zacharyvoase/slugify - """ - normalized_string = str(unicodedata.normalize("NFKD", string)) - no_punctuation = re.sub(r"[^\w\s-]", "", normalized_string).strip().lower() - slug = re.sub(r"[-\s]+", "-", no_punctuation) - return slug - - -def split_title(text): - """Splits the first sentence off from a text.""" - sep = SENTENCE_SPLITTER_ONLY_NEWLINE.search(text.lstrip()) - if not sep: - sep = SENTENCE_SPLITTER.search(text) - if not sep: - return text, "" - return text[: sep.end()].strip(), text[sep.end() :].strip() - - -def deprecated_cmd(old_cmd, new_cmd, callback=None, **kwargs): - import sys - import textwrap - from .util import RESET_COLOR, WARNING_COLOR - - log = logging.getLogger(__name__) - - warning_msg = f""" - The command {old_cmd} is deprecated and will be removed from jrnl soon. - Please us {new_cmd} instead. - """ - warning_msg = textwrap.dedent(warning_msg) - log.warning(warning_msg) - print(f"{WARNING_COLOR}{warning_msg}{RESET_COLOR}", file=sys.stderr) - - if callback is not None: - callback(**kwargs) - - -def list_journals(config): - from . import install - - """List the journals specified in the configuration file""" - result = f"Journals defined in {install.CONFIG_FILE_PATH}\n" - ml = min(max(len(k) for k in config["journals"]), 20) - for journal, cfg in config["journals"].items(): - result += " * {:{}} -> {}\n".format( - journal, ml, cfg["journal"] if isinstance(cfg, dict) else cfg - ) - return result - - -def get_journal_name(args, config): - from . import install - - args.journal_name = install.DEFAULT_JOURNAL_KEY - if args.text and args.text[0] in config["journals"]: - args.journal_name = args.text[0] - args.text = args.text[1:] - elif install.DEFAULT_JOURNAL_KEY not in config["journals"]: - print("No default journal configured.", file=sys.stderr) - print(list_journals(config), file=sys.stderr) - sys.exit(1) - - log.debug("Using journal name: %s", args.journal_name) - return args diff --git a/tests/test_parse_args.py b/tests/test_parse_args.py index 2920322a..70cae1b8 100644 --- a/tests/test_parse_args.py +++ b/tests/test_parse_args.py @@ -1,4 +1,4 @@ -from jrnl.cli import parse_args +from jrnl.parse_args import parse_args import pytest import shlex