From 7da666f4234d156a4e199c0092775821dd9046e3 Mon Sep 17 00:00:00 2001 From: Jonathan Wren Date: Sat, 22 Aug 2020 11:40:39 -0700 Subject: [PATCH] Clean up help screen, get rid of util.py (#1027) * More refactoring of cli.py break up code from cli.py (now in jrnl.py) up into smaller functions get rid of export mode move --encrypt and --decrypt to commands.py clean up the help screen even more update flag name for import * reorganize code, move around lots of functions * clean up import statements * move run function out of cli and into jrnl * rename confusingly named function * move editor function into editor file * rename parse_args.py to args.py to make room for more args functions * Fix error in test suite for windows I accidentally flipped the conditional, so this fixes it. Co-authored-by: Micah Jerome Ellison * Update app description on help screen Co-authored-by: Micah Jerome Ellison --- features/core.feature | 5 +- features/environment.py | 7 +- features/steps/core.py | 30 ++- features/steps/export_steps.py | 3 +- jrnl/DayOneJournal.py | 9 +- jrnl/EncryptedJournal.py | 95 +++++++-- jrnl/Entry.py | 28 ++- jrnl/Journal.py | 14 +- jrnl/__main__.py | 6 +- jrnl/{parse_args.py => args.py} | 104 +++++---- jrnl/cli.py | 287 ++----------------------- jrnl/color.py | 79 +++++++ jrnl/commands.py | 89 +++++++- jrnl/config.py | 88 ++++++++ jrnl/editor.py | 57 +++++ jrnl/exception.py | 8 + jrnl/install.py | 43 ++-- jrnl/jrnl.py | 306 +++++++++++++++++++++++++++ jrnl/os_compat.py | 3 + jrnl/output.py | 33 +++ jrnl/plugins/markdown_exporter.py | 4 +- jrnl/plugins/text_exporter.py | 16 +- jrnl/plugins/yaml_exporter.py | 5 +- jrnl/prompt.py | 28 +++ jrnl/time.py | 1 - jrnl/upgrade.py | 26 ++- jrnl/util.py | 338 ------------------------------ pyproject.toml | 10 +- tests/test_parse_args.py | 33 +-- tests/test_time.py | 1 + 30 files changed, 981 insertions(+), 775 deletions(-) rename jrnl/{parse_args.py => args.py} (78%) create mode 100644 jrnl/color.py create mode 100644 jrnl/config.py create mode 100644 jrnl/editor.py create mode 100644 jrnl/exception.py create mode 100644 jrnl/jrnl.py create mode 100644 jrnl/os_compat.py create mode 100644 jrnl/output.py create mode 100644 jrnl/prompt.py delete mode 100644 jrnl/util.py diff --git a/features/core.feature b/features/core.feature index 4418d1b9..b8a987ec 100644 --- a/features/core.feature +++ b/features/core.feature @@ -180,7 +180,7 @@ Feature: Basic reading and writing to a journal And the journal should contain "Life is good." But the journal should not contain "I have an @idea" And the journal should not contain "I met with" - When we run "jrnl --import -i features/journals/tags.journal" + When we run "jrnl --import --file features/journals/tags.journal" Then the journal should contain "My first entry." And the journal should contain "Life is good." And the journal should contain "PROFIT!" @@ -191,10 +191,11 @@ Feature: Basic reading and writing to a journal And the journal should contain "Life is good." But the journal should not contain "I have an @idea" And the journal should not contain "I met with" - When we run "jrnl --import -i features/journals/tags.journal" and pipe + When we run "jrnl --import --file features/journals/tags.journal" and pipe """ [2020-07-05 15:00] I should not exist! """ Then the journal should contain "My first entry." And the journal should contain "PROFIT!" But the journal should not contain "I should not exist!" + diff --git a/features/environment.py b/features/environment.py index bc4a8dcb..63fec4a6 100644 --- a/features/environment.py +++ b/features/environment.py @@ -1,6 +1,7 @@ import os import shutil -import sys + +from jrnl.os_compat import on_windows CWD = os.getcwd() @@ -19,7 +20,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 +47,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..f82cf8cd 100644 --- a/features/steps/core.py +++ b/features/steps/core.py @@ -4,16 +4,23 @@ import os from pathlib import Path import re import shlex -import sys import time from unittest.mock import patch +from behave import given +from behave import then +from behave import when import keyring 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 install +from jrnl import plugins +from jrnl.cli import cli +from jrnl.config import load_config +from jrnl.os_compat import on_windows try: import parsedatetime.parsedatetime_consts as pdt @@ -62,18 +69,18 @@ keyring.set_keyring(TestKeyring()) def ushlex(command): - return shlex.split(command, posix="win32" not in sys.platform) + return shlex.split(command, posix=not 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 @@ -129,7 +136,7 @@ def open_editor_and_enter(context, method, text=""): patch("subprocess.call", side_effect=_mock_editor_function), \ patch("sys.stdin.isatty", return_value=True) \ : - cli.run(["--edit"]) + cli(["--edit"]) # fmt: on @@ -193,7 +200,7 @@ def run_with_input(context, command, inputs=""): patch("sys.stdin.read", side_effect=text) as mock_read \ : try: - cli.run(args or []) + cli(args or []) context.exit_status = 0 except SystemExit as e: context.exit_status = e.code @@ -229,7 +236,7 @@ def run(context, command, text="", cache_dir=None): with patch("sys.argv", args), patch( "subprocess.call", side_effect=_mock_editor ), patch("sys.stdin.read", side_effect=lambda: text): - cli.run(args[1:]) + cli(args[1:]) context.exit_status = 0 except SystemExit as e: context.exit_status = e.code @@ -364,7 +371,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 @@ -392,9 +399,10 @@ def list_journal_directory(context, journal="default"): @then("the Python version warning should appear if our version is below {version}") def check_python_warning_if_version_low_enough(context, version): - import packaging.version import platform + import packaging.version + if packaging.version.parse(platform.python_version()) < packaging.version.parse( version ): diff --git a/features/steps/export_steps.py b/features/steps/export_steps.py index 2e59d729..d75aea64 100644 --- a/features/steps/export_steps.py +++ b/features/steps/export_steps.py @@ -3,7 +3,8 @@ import os import shutil from xml.etree import ElementTree -from behave import given, then +from behave import given +from behave import then @then("the output should be parsable as json") diff --git a/jrnl/DayOneJournal.py b/jrnl/DayOneJournal.py index cb568bc3..6e1b8345 100644 --- a/jrnl/DayOneJournal.py +++ b/jrnl/DayOneJournal.py @@ -4,18 +4,21 @@ from datetime import datetime import fnmatch import os from pathlib import Path +import platform import plistlib import re +import socket import time import uuid from xml.parsers.expat import ExpatError -import socket -import platform import pytz import tzlocal -from . import __title__, __version__, Entry, Journal +from . import Entry +from . import Journal +from . import __title__ +from . import __version__ class DayOne(Journal.Journal): diff --git a/jrnl/EncryptedJournal.py b/jrnl/EncryptedJournal.py index ec4ceee8..e1d248aa 100644 --- a/jrnl/EncryptedJournal.py +++ b/jrnl/EncryptedJournal.py @@ -1,19 +1,25 @@ import base64 +import getpass import hashlib import logging import os import sys +from typing import Callable +from typing import Optional -from cryptography.fernet import Fernet, InvalidToken +from cryptography.fernet import Fernet +from cryptography.fernet import InvalidToken from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import hashes, padding -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher +from cryptography.hazmat.primitives.ciphers import algorithms +from cryptography.hazmat.primitives.ciphers import modes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -from . import util -from .Journal import Journal, LegacyJournal - -log = logging.getLogger() +from .Journal import Journal +from .Journal import LegacyJournal +from .prompt import create_password def make_key(password): @@ -30,6 +36,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 +76,8 @@ 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}", file=sys.stderr, @@ -55,7 +86,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): @@ -79,7 +110,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) @@ -90,11 +121,16 @@ class EncryptedJournal(Journal): @classmethod def from_journal(cls, other: Journal): new_journal = super().from_journal(other) - new_journal.password = ( - other.password - if hasattr(other, "password") - else util.create_password(other.name) - ) + try: + new_journal.password = ( + other.password + if hasattr(other, "password") + else create_password(other.name) + ) + except KeyboardInterrupt: + print("[Interrupted while creating new journal]", file=sys.stderr) + sys.exit(1) + return new_journal @@ -132,4 +168,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/__main__.py b/jrnl/__main__.py index 2b46147a..a67d0add 100644 --- a/jrnl/__main__.py +++ b/jrnl/__main__.py @@ -1,5 +1,7 @@ #!/usr/bin/env python -from . import cli +import sys + +from .cli import cli if __name__ == "__main__": - cli.run() + sys.exit(cli()) diff --git a/jrnl/parse_args.py b/jrnl/args.py similarity index 78% rename from jrnl/parse_args.py rename to jrnl/args.py index 46df31c3..69462bc8 100644 --- a/jrnl/parse_args.py +++ b/jrnl/args.py @@ -2,20 +2,27 @@ import argparse import re import textwrap -from .plugins import util -from .plugins import IMPORT_FORMATS -from .plugins import EXPORT_FORMATS -from .commands import preconfig_version -from .commands import preconfig_diagnostic -from .commands import postconfig_list +from .commands import postconfig_decrypt +from .commands import postconfig_encrypt from .commands import postconfig_import -from .util import deprecated_cmd +from .commands import postconfig_list +from .commands import preconfig_diagnostic +from .commands import preconfig_version +from .output import deprecated_cmd +from .plugins import EXPORT_FORMATS +from .plugins import IMPORT_FORMATS +from .plugins import util -class WrappingFormatter(argparse.RawDescriptionHelpFormatter): +class WrappingFormatter(argparse.RawTextHelpFormatter): + """Used in help screen""" + def _split_lines(self, text, width): - text = self._whitespace_matcher.sub(" ", text).strip() - return textwrap.wrap(text, width=56) + text = text.split("\n\n") + text = map(lambda t: self._whitespace_matcher.sub(" ", t).strip(), text) + text = map(lambda t: textwrap.wrap(t, width=56), text) + text = [item for sublist in text for item in sublist] + return text def parse_args(args=[]): @@ -26,7 +33,7 @@ def parse_args(args=[]): parser = argparse.ArgumentParser( formatter_class=WrappingFormatter, add_help=False, - description="The command-line note-taking and journaling app.", + description="Collect your thoughts and notes without leaving the command line", epilog=textwrap.dedent( """ Thank you to all of our contributors! Come see the whole list of code and @@ -54,7 +61,7 @@ def parse_args(args=[]): action="store_const", const=preconfig_version, dest="preconfig_cmd", - help="prints version information", + help="Print version information", ) standalone.add_argument( "-v", @@ -75,7 +82,7 @@ def parse_args(args=[]): action="store_const", const=postconfig_list, dest="postconfig_cmd", - help="list all configured journals", + help="List all configured journals", ) standalone.add_argument( "--ls", @@ -95,40 +102,47 @@ def parse_args(args=[]): ) standalone.add_argument( "--encrypt", - metavar="FILENAME", - dest="encrypt", - help="Encrypts your existing journal with a new password", - nargs="?", - default=False, - const=None, + help="Encrypt selected journal with a password", + action="store_const", + metavar="TYPE", + const=postconfig_encrypt, + dest="postconfig_cmd", ) standalone.add_argument( "--decrypt", - metavar="FILENAME", - dest="decrypt", - help="Decrypts your journal and stores it in plain text", - nargs="?", - default=False, - const=None, + help="Decrypt selected journal and store it in plain text", + action="store_const", + metavar="TYPE", + const=postconfig_decrypt, + dest="postconfig_cmd", ) standalone.add_argument( "--import", action="store_const", metavar="TYPE", - dest="postconfig_cmd", const=postconfig_import, - help=f"Import entries into your journal. TYPE can be: {util.oxford_list(IMPORT_FORMATS)} (default: jrnl)", + dest="postconfig_cmd", + help=f""" + Import entries from another journal. + + Optional parameters: + + --file FILENAME (default: uses stdin) + + --format [{util.oxford_list(IMPORT_FORMATS)}] (default: jrnl) + """, ) standalone.add_argument( - "-i", + "--file", metavar="FILENAME", - dest="input", - help="Optionally specifies input file when using --import.", - default=False, - const=None, + dest="filename", + help=argparse.SUPPRESS, + default=None, ) + standalone.add_argument("-i", dest="filename", help=argparse.SUPPRESS) - compose_msg = """ To add a new entry into your journal, simply write it on the command line: + compose_msg = """ + To add a new entry into your journal, simply write it on the command line: jrnl yesterday: I was walking and I found this big log. @@ -171,9 +185,7 @@ def parse_args(args=[]): metavar="DATE", help="Show entries before, or on, this date (alias: -until)", ) - reading.add_argument( - "-until", dest="end_date", help=argparse.SUPPRESS, - ) + reading.add_argument("-until", dest="end_date", help=argparse.SUPPRESS) reading.add_argument( "-contains", dest="contains", @@ -214,7 +226,7 @@ def parse_args(args=[]): search_options_msg = """ These help you do various tasks with the selected entries from your search. If used on their own (with no search), they will act on your entire journal""" exporting = parser.add_argument_group( - "Options for Searching", textwrap.dedent(search_options_msg) + "Searching Options", textwrap.dedent(search_options_msg) ) exporting.add_argument( "--edit", @@ -233,7 +245,15 @@ def parse_args(args=[]): metavar="TYPE", dest="export", choices=EXPORT_FORMATS, - help=f"Display selected entries in an alternate format (other than jrnl). TYPE can be: {util.oxford_list(EXPORT_FORMATS)}.", + help=f""" + Display selected entries in an alternate format. + + TYPE can be: {util.oxford_list(EXPORT_FORMATS)}. + + Optional parameters: + + --file FILENAME Write output to file instead of stdout + """, default=False, ) exporting.add_argument( @@ -259,17 +279,11 @@ def parse_args(args=[]): "-s", dest="short", action="store_true", help=argparse.SUPPRESS, ) exporting.add_argument( - "-o", - metavar="FILENAME", - dest="output", - help="Optionally specifies output file (or directory) when using --format.", - default=False, - const=None, + "-o", dest="filename", help=argparse.SUPPRESS, ) # Handle '-123' as a shortcut for '-n 123' num = re.compile(r"^-(\d+)$") args = [num.sub(r"-n \1", arg) for arg in args] - # return parser.parse_args(args) return parser.parse_intermixed_args(args) diff --git a/jrnl/cli.py b/jrnl/cli.py index 2b15ccba..2f2b6564 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,94 +16,11 @@ 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, plugins, util -from .parse_args import parse_args -from .Journal import PlainJournal, open_journal -from .util import ERROR_COLOR, RESET_COLOR, UserAbort -from .util import get_journal_name - -log = logging.getLogger(__name__) -logging.getLogger("keyring.backend").setLevel(logging.ERROR) - - -def guess_mode(args, config): - """Guesses the mode (compose, read or export) from the given arguments""" - compose = True - export = False - if ( - args.decrypt is not False - or args.encrypt is not False - or args.export is not False - or any((args.short, args.tags, args.edit, args.delete)) - ): - compose = False - export = True - elif any( - ( - args.start_date, - args.end_date, - args.on_date, - args.limit, - args.strict, - args.starred, - args.contains, - ) - ): - # Any sign of displaying stuff? - compose = False - elif args.text and all( - word[0] in config["tagsymbols"] for word in " ".join(args.text).split() - ): - # No date and only tags? - compose = False - - return compose, export - - -def encrypt(journal, filename=None): - """ Encrypt into new file. If filename is not set, we encrypt the journal file itself. """ - from .EncryptedJournal import EncryptedJournal - - journal.config["encrypt"] = True - - new_journal = EncryptedJournal.from_journal(journal) - new_journal.write(filename) - - print( - "Journal encrypted to {}.".format(filename or new_journal.config["journal"]), - file=sys.stderr, - ) - - -def decrypt(journal, filename=None): - """ Decrypts into new file. If filename is not set, we encrypt the journal file itself. """ - journal.config["encrypt"] = False - - new_journal = PlainJournal.from_journal(journal) - new_journal.write(filename) - print( - "Journal decrypted to {}.".format(filename or new_journal.config["journal"]), - file=sys.stderr, - ) - - -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 .jrnl import run +from .args import parse_args def configure_logger(debug=False): @@ -113,194 +28,20 @@ def configure_logger(debug=False): level=logging.DEBUG if debug else logging.ERROR, format="%(levelname)-8s %(name)-12s %(message)s", ) - logging.getLogger("parsedatetime").setLevel( - logging.INFO - ) # disable parsedatetime debug logging + logging.getLogger("parsedatetime").setLevel(logging.INFO) + logging.getLogger("keyring.backend").setLevel(logging.ERROR) -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) - - if manual_args is None: - manual_args = sys.argv[1:] - - args = parse_args(manual_args) - configure_logger(args.debug) - - # Run command if possible before config is available - if callable(args.preconfig_cmd): - args.preconfig_cmd(args) - sys.exit(0) - - # Load the config +def cli(manual_args=None): try: - config = install.load_or_install_jrnl() - original_config = config.copy() - args = get_journal_name(args, config) - config = util.scope_config(config, args.journal_name) - except UserAbort as err: - print(f"\n{err}", file=sys.stderr) - sys.exit(1) + if manual_args is None: + manual_args = sys.argv[1:] - # Run post-config command now that config is ready - if callable(args.postconfig_cmd): - args.postconfig_cmd(args=args, config=config) - sys.exit(0) + args = parse_args(manual_args) + configure_logger(args.debug) + logging.debug("Parsed args: %s", args) - # --- All the standalone commands are now done --- # + return run(args) - # Get the journal we're going to be working with - journal = open_journal(args.journal_name, config) - - mode_compose, mode_export = guess_mode(args, config) - - if mode_compose and not args.text: - if not sys.stdin.isatty(): - # Piping data into jrnl - raw = sys.stdin.read() - elif config["editor"]: - template = "" - if config["template"]: - try: - template = open(config["template"]).read() - except OSError: - print( - f"[Could not read template at '{config['template']}']", - file=sys.stderr, - ) - sys.exit(1) - raw = util.get_text_from_editor(config, template) - else: - try: - _how_to_quit = ( - "Ctrl+z and then Enter" if "win32" in sys.platform else "Ctrl+d" - ) - print( - f"[Writing Entry; on a blank line, press {_how_to_quit} to finish writing]\n", - file=sys.stderr, - ) - raw = sys.stdin.read() - except KeyboardInterrupt: - print("[Entry NOT saved to journal]", file=sys.stderr) - sys.exit(0) - if raw: - args.text = [raw] - else: - sys.exit() - - # Writing mode - if mode_compose: - raw = " ".join(args.text).strip() - log.debug('Appending raw line "%s" to journal "%s"', raw, args.journal_name) - journal.new_entry(raw) - print(f"[Entry added to {args.journal_name} journal]", file=sys.stderr) - journal.write() - - if not mode_compose: - old_entries = journal.entries - if args.on_date: - args.start_date = args.end_date = args.on_date - journal.filter( - tags=args.text, - start_date=args.start_date, - end_date=args.end_date, - strict=args.strict, - starred=args.starred, - exclude=args.excluded, - contains=args.contains, - ) - journal.limit(args.limit) - - # Reading mode - if not mode_compose and not mode_export: - print(journal.pprint()) - - # Various export modes - elif args.short: - print(journal.pprint(short=True)) - - elif args.tags: - print(plugins.get_exporter("tags").export(journal)) - - elif args.export is not False: - exporter = plugins.get_exporter(args.export) - print(exporter.export(journal, args.output)) - - elif args.encrypt is not False: - encrypt(journal, filename=args.encrypt) - # Not encrypting to a separate file: update config! - if not args.encrypt: - update_config( - original_config, {"encrypt": True}, args.journal_name, force_local=True - ) - install.save_config(original_config) - - elif args.decrypt is not False: - decrypt(journal, filename=args.decrypt) - # Not decrypting to a separate file: update config! - if not args.decrypt: - update_config( - original_config, {"encrypt": False}, args.journal_name, force_local=True - ) - install.save_config(original_config) - - elif args.edit: - if not config["editor"]: - print( - "[{1}ERROR{2}: You need to specify an editor in {0} to use the --edit function.]".format( - install.CONFIG_FILE_PATH, ERROR_COLOR, RESET_COLOR - ), - file=sys.stderr, - ) - sys.exit(1) - other_entries = [e for e in old_entries if e not in journal.entries] - # Edit - old_num_entries = len(journal) - edited = util.get_text_from_editor(config, journal.editable_str()) - journal.parse_editable_str(edited) - num_deleted = old_num_entries - len(journal) - num_edited = len([e for e in journal.entries if e.modified]) - prompts = [] - if num_deleted: - prompts.append( - "{} {} deleted".format( - num_deleted, "entry" if num_deleted == 1 else "entries" - ) - ) - if num_edited: - prompts.append( - "{} {} modified".format( - num_edited, "entry" if num_deleted == 1 else "entries" - ) - ) - if prompts: - print("[{}]".format(", ".join(prompts).capitalize()), file=sys.stderr) - journal.entries += other_entries - journal.sort() - journal.write() - - elif args.delete: - if journal.entries: - entries_to_delete = journal.prompt_delete_entries() - - if entries_to_delete: - journal.entries = old_entries - journal.delete_entries(entries_to_delete) - - journal.write() - else: - print( - "No entries deleted, because the search returned no results.", - file=sys.stderr, - ) + except KeyboardInterrupt: + return 1 diff --git a/jrnl/color.py b/jrnl/color.py new file mode 100644 index 00000000..dca28117 --- /dev/null +++ b/jrnl/color.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python +import re +from string import punctuation +from string import whitespace + +import colorama + +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 8461d64a..d8d36571 100644 --- a/jrnl/commands.py +++ b/jrnl/commands.py @@ -1,34 +1,105 @@ +""" +Functions in this file are standalone commands. All standalone commands are split into +two categories depending on whether they require the config to be loaded to be able to +run. + +1. "preconfig" commands don't require the config at all, and can be run before the + config has been loaded. +2. "postconfig" commands require to config to have already been loaded, parsed, and + scoped before they can be run. + +Also, please note that all (non-builtin) imports should be scoped to each function to +avoid any possible overhead for these standalone commands. +""" +import platform +import sys + + def preconfig_diagnostic(_): - import platform - import sys - import jrnl + from jrnl import __version__ print( - f"jrnl: {jrnl.__version__}\n" + f"jrnl: {__version__}\n" f"Python: {sys.version}\n" f"OS: {platform.system()} {platform.release()}" ) def preconfig_version(_): - import jrnl + from jrnl import __title__ + from jrnl import __version__ - version_str = f"{jrnl.__title__} version {jrnl.__version__}" + version_str = f"{__title__} version {__version__}" print(version_str) def postconfig_list(config, **kwargs): - from .util import list_journals + from .output import list_journals print(list_journals(config)) def postconfig_import(args, config, **kwargs): - from .plugins import get_importer from .Journal import open_journal + from .plugins import get_importer # Requires opening the journal journal = open_journal(args.journal_name, config) format = args.export if args.export else "jrnl" - get_importer(format).import_(journal, args.input) + get_importer(format).import_(journal, args.filename) + + +def postconfig_encrypt(args, config, original_config, **kwargs): + """ + Encrypt a journal in place, or optionally to a new file + """ + from .EncryptedJournal import EncryptedJournal + from .Journal import open_journal + from .config import update_config + from .install import save_config + + # Open the journal + journal = open_journal(args.journal_name, config) + + journal.config["encrypt"] = True + + new_journal = EncryptedJournal.from_journal(journal) + new_journal.write(args.filename) + + print( + f"Journal encrypted to {args.filename or new_journal.config['journal']}.", + file=sys.stderr, + ) + + # Update the config, if we encrypted in place + if not args.filename: + update_config( + original_config, {"encrypt": True}, args.journal_name, force_local=True + ) + save_config(original_config) + + +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 .config import update_config + from .install import save_config + + journal = open_journal(args.journal_name, config) + journal.config["encrypt"] = False + + new_journal = PlainJournal.from_journal(journal) + new_journal.write(args.filename) + print( + f"Journal decrypted to {args.filename or new_journal.config['journal']}.", + file=sys.stderr, + ) + + # Update the config, if we decrypted in place + if not args.filename: + update_config( + original_config, {"encrypt": False}, args.journal_name, force_local=True + ) + save_config(original_config) diff --git a/jrnl/config.py b/jrnl/config.py new file mode 100644 index 00000000..da772927 --- /dev/null +++ b/jrnl/config.py @@ -0,0 +1,88 @@ +import logging +import sys + +import colorama +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_colors(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..3397cdac --- /dev/null +++ b/jrnl/editor.py @@ -0,0 +1,57 @@ +import logging +import os +import shlex +import subprocess +import sys +import tempfile +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 + + +def get_text_from_stdin(): + _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, + ) + try: + raw = sys.stdin.read() + except KeyboardInterrupt: + logging.error("Write mode: keyboard interrupt") + print("[Entry NOT saved to journal]", file=sys.stderr) + sys.exit(0) + + 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..5f5f0032 100644 --- a/jrnl/install.py +++ b/jrnl/install.py @@ -8,10 +8,15 @@ import sys import xdg.BaseDirectory import yaml -from . import __version__, util -from .util import UserAbort, verify_config +from . import __version__ +from .config import load_config +from .config import verify_config_colors +from .exception import UserAbort +from .os_compat import on_windows +from .prompt import yesno +from .upgrade import is_old_version -if "win32" not in sys.platform: +if not on_windows: # readline is not included in Windows Active Python import readline @@ -29,18 +34,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 +86,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: @@ -115,24 +108,24 @@ def load_or_install_jrnl(): sys.exit(1) upgrade_config(config) - verify_config(config) + verify_config_colors(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 +142,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 +154,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 new file mode 100644 index 00000000..33eabdd0 --- /dev/null +++ b/jrnl/jrnl.py @@ -0,0 +1,306 @@ +import logging +import sys + +from . import install +from . import plugins +from .Journal import open_journal +from .color import ERROR_COLOR +from .color import RESET_COLOR +from .config import get_journal_name +from .config import scope_config +from .editor import get_text_from_editor +from .editor import get_text_from_stdin +from .exception import UserAbort + + +def run(args): + """ + Flow: + 1. Run standalone command if it doesn't require config (help, version, etc), then exit + 2. Load config + 3. Run standalone command if it does require config (encrypt, decrypt, etc), then exit + 4. Load specified journal + 5. Start write mode, or search mode + 6. Profit + """ + + # Run command if possible before config is available + if callable(args.preconfig_cmd): + return args.preconfig_cmd(args) + + # Load the config, and extract journal name + try: + config = install.load_or_install_jrnl() + original_config = config.copy() + args = get_journal_name(args, config) + config = scope_config(config, args.journal_name) + except UserAbort as err: + print(f"\n{err}", file=sys.stderr) + sys.exit(1) + + # Run post-config command now that config is ready + if callable(args.postconfig_cmd): + return args.postconfig_cmd( + args=args, config=config, original_config=original_config + ) + + # --- All the standalone commands are now done --- # + + # Get the journal we're going to be working with + journal = open_journal(args.journal_name, config) + + kwargs = { + "args": args, + "config": config, + "journal": journal, + } + + if _is_write_mode(**kwargs): + write_mode(**kwargs) + else: + search_mode(**kwargs) + + +def _is_write_mode(args, config, **kwargs): + """Determines if we are in write mode (as opposed to search mode)""" + write_mode = True + + # Are any search filters present? If so, then search mode. + write_mode = not any( + ( + args.contains, + args.delete, + args.edit, + args.export, + args.end_date, + args.limit, + args.on_date, + args.short, + args.starred, + args.start_date, + args.strict, + args.tags, + ) + ) + + # If the text is entirely tags, then we are also searching (not writing) + if ( + write_mode + and args.text + and all(word[0] in config["tagsymbols"] for word in " ".join(args.text).split()) + ): + write_mode = False + + return write_mode + + +def write_mode(args, config, journal, **kwargs): + """ + Gets input from the user to write to the journal + 1. Check for input from cli + 2. Check input being piped in + 3. Open editor if configured (prepopulated with template if available) + 4. Use stdin.read as last resort + 6. Write any found text to journal, or exit + """ + logging.debug("Write mode: starting") + + if args.text: + logging.debug("Write mode: cli text detected: %s", args.text) + raw = " ".join(args.text).strip() + + elif not sys.stdin.isatty(): + logging.debug("Write mode: receiving piped text") + raw = sys.stdin.read() + + else: + raw = _write_in_editor(config) + + if not raw: + logging.error("Write mode: couldn't get raw text") + sys.exit() + + 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() + logging.debug("Write mode: completed journal.write()", args.journal_name, raw) + + +def search_mode(args, journal, **kwargs): + """ + Search for entries in a journal, then either: + 1. Send them to configured editor for user manipulation + 2. Delete them (with confirmation for each entry) + 3. Display them (with formatting options) + """ + kwargs = { + **kwargs, + "args": args, + "journal": journal, + "old_entries": journal.entries, + } + + # Filters the journal entries in place + _search_journal(**kwargs) + + # Where do the search results go? + if args.edit: + _edit_search_results(**kwargs) + + elif args.delete: + _delete_search_results(**kwargs) + + else: + _display_search_results(**kwargs) + + +def _write_in_editor(config): + if config["editor"]: + logging.debug("Write mode: opening editor") + template = _get_editor_template(config) + raw = get_text_from_editor(config, template) + + else: + raw = get_text_from_stdin() + + return raw + + +def _get_editor_template(config, **kwargs): + logging.debug("Write mode: loading template for entry") + + if not config["template"]: + logging.debug("Write mode: no template configured") + return "" + + try: + template = open(config["template"]).read() + logging.debug("Write mode: template loaded: %s", template) + except OSError: + logging.error("Write mode: template not loaded") + print( + f"[Could not read template at '{config['template']}']", file=sys.stderr, + ) + sys.exit(1) + + return template + + +def _search_journal(args, journal, **kwargs): + """ Search the journal with the given args""" + if args.on_date: + args.start_date = args.end_date = args.on_date + + journal.filter( + tags=args.text, + start_date=args.start_date, + end_date=args.end_date, + strict=args.strict, + starred=args.starred, + exclude=args.excluded, + contains=args.contains, + ) + journal.limit(args.limit) + + +def _edit_search_results(config, journal, old_entries, **kwargs): + """ + 1. Send the given journal entries to the user-configured editor + 2. Print out stats on any modifications to journal + 3. Write modifications to journal + """ + if not config["editor"]: + print( + f""" + [{ERROR_COLOR}ERROR{RESET_COLOR}: There is no editor configured.] + + Please specify an editor in config file ({install.CONFIG_FILE_PATH}) + to use the --edit option. + """, + file=sys.stderr, + ) + sys.exit(1) + + # separate entries we are not editing + other_entries = [e for e in old_entries if e not in journal.entries] + + # Get stats now for summary later + old_stats = _get_predit_stats(journal) + + # Send user to the editor + edited = get_text_from_editor(config, journal.editable_str()) + journal.parse_editable_str(edited) + + # Print summary if available + _print_edited_summary(journal, old_stats) + + # Put back entries we separated earlier, sort, and write the journal + journal.entries += other_entries + journal.sort() + journal.write() + + +def _print_edited_summary(journal, old_stats, **kwargs): + stats = { + "deleted": old_stats["count"] - len(journal), + "modified": len([e for e in journal.entries if e.modified]), + } + + prompts = [] + + if stats["deleted"]: + prompts.append( + f"{stats['deleted']} {_pluralize_entry(stats['deleted'])} deleted" + ) + + if stats["modified"]: + prompts.append( + f"{stats['modified']} {_pluralize_entry(stats['modified'])} modified" + ) + + if prompts: + print(f"[{', '.join(prompts).capitalize()}]", file=sys.stderr) + + +def _get_predit_stats(journal): + return {"count": len(journal)} + + +def _pluralize_entry(num): + return "entry" if num == 1 else "entries" + + +def _delete_search_results(journal, old_entries, **kwargs): + if not journal.entries: + print( + "[No entries deleted, because the search returned no results.]", + file=sys.stderr, + ) + sys.exit(1) + + entries_to_delete = journal.prompt_delete_entries() + + if entries_to_delete: + journal.entries = old_entries + journal.delete_entries(entries_to_delete) + + journal.write() + + +def _display_search_results(args, journal, **kwargs): + if args.short: + print(journal.pprint(short=True)) + + elif args.tags: + print(plugins.get_exporter("tags").export(journal)) + + elif args.export: + exporter = plugins.get_exporter(args.export) + print(exporter.export(journal, args.filename)) + + else: + # Default display mode + print(journal.pprint()) 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..b645d6d1 --- /dev/null +++ b/jrnl/output.py @@ -0,0 +1,33 @@ +import logging + + +def deprecated_cmd(old_cmd, new_cmd, callback=None, **kwargs): + import sys + import textwrap + + from .color import RESET_COLOR + from .color import 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/plugins/markdown_exporter.py b/jrnl/plugins/markdown_exporter.py index 65cba0fe..7ee20472 100644 --- a/jrnl/plugins/markdown_exporter.py +++ b/jrnl/plugins/markdown_exporter.py @@ -5,7 +5,9 @@ import os import re import sys -from ..util import RESET_COLOR, WARNING_COLOR +from jrnl.color import RESET_COLOR +from jrnl.color import WARNING_COLOR + from .text_exporter import TextExporter diff --git a/jrnl/plugins/text_exporter.py b/jrnl/plugins/text_exporter.py index 4fa781ab..68061e4e 100644 --- a/jrnl/plugins/text_exporter.py +++ b/jrnl/plugins/text_exporter.py @@ -2,8 +2,11 @@ # encoding: utf-8 import os +import re +import unicodedata -from ..util import ERROR_COLOR, RESET_COLOR, slugify +from jrnl.color import ERROR_COLOR +from jrnl.color import RESET_COLOR class TextExporter: @@ -35,7 +38,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 +55,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..0d431967 100644 --- a/jrnl/plugins/yaml_exporter.py +++ b/jrnl/plugins/yaml_exporter.py @@ -5,7 +5,10 @@ import os import re import sys -from ..util import ERROR_COLOR, RESET_COLOR, WARNING_COLOR +from jrnl.color import ERROR_COLOR +from jrnl.color import RESET_COLOR +from jrnl.color import WARNING_COLOR + from .text_exporter import TextExporter diff --git a/jrnl/prompt.py b/jrnl/prompt.py new file mode 100644 index 00000000..13828620 --- /dev/null +++ b/jrnl/prompt.py @@ -0,0 +1,28 @@ +import getpass +import sys + + +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/time.py b/jrnl/time.py index 25fba0d7..45fc15cc 100644 --- a/jrnl/time.py +++ b/jrnl/time.py @@ -1,6 +1,5 @@ from datetime import datetime - FAKE_YEAR = 9999 DEFAULT_FUTURE = datetime(FAKE_YEAR, 12, 31, 23, 59, 59) DEFAULT_PAST = datetime(FAKE_YEAR, 1, 1, 0, 0) diff --git a/jrnl/upgrade.py b/jrnl/upgrade.py index 4a638563..fb526e97 100644 --- a/jrnl/upgrade.py +++ b/jrnl/upgrade.py @@ -1,9 +1,15 @@ import os import sys -from . import Journal, __version__, util +from . import Journal +from . import __version__ from .EncryptedJournal import EncryptedJournal -from .util import UserAbort +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 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/pyproject.toml b/pyproject.toml index 4d43d73a..9b4c81f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,15 +40,13 @@ pyflakes = "^2.2.0" pytest = "^5.4.3" [tool.poetry.scripts] -jrnl = 'jrnl.cli:run' +jrnl = 'jrnl.cli:cli' [tool.isort] -multi_line_output = 3 -include_trailing_comma = true -force_grid_wrap = 0 -use_parentheses = true +multi_line_output = 7 +force_single_line = true line_length = 88 -known_first_party = ["jrnl", "behave"] +known_first_party = ["jrnl"] force_sort_within_sections = true [build-system] diff --git a/tests/test_parse_args.py b/tests/test_parse_args.py index dbec5a6b..6f6a02db 100644 --- a/tests/test_parse_args.py +++ b/tests/test_parse_args.py @@ -1,7 +1,8 @@ -from jrnl.cli import parse_args +import shlex import pytest -import shlex + +from jrnl.args import parse_args def cli_as_dict(str): @@ -14,17 +15,14 @@ def expected_args(**kwargs): default_args = { "contains": None, "debug": False, - "decrypt": False, "delete": False, "edit": False, - "encrypt": False, "end_date": None, "excluded": [], "export": False, - "input": False, + "filename": None, "limit": None, "on_date": None, - "output": False, "preconfig_cmd": None, "postconfig_cmd": None, "short": False, @@ -66,7 +64,15 @@ def test_edit_alone(): def test_encrypt_alone(): - assert cli_as_dict("--encrypt 'test.txt'") == expected_args(encrypt="test.txt") + from jrnl.commands import postconfig_encrypt + + assert cli_as_dict("--encrypt") == expected_args(postconfig_cmd=postconfig_encrypt) + + +def test_decrypt_alone(): + from jrnl.commands import postconfig_decrypt + + assert cli_as_dict("--decrypt") == expected_args(postconfig_cmd=postconfig_decrypt) def test_end_date_alone(): @@ -110,15 +116,10 @@ def test_import_alone(): assert cli_as_dict("--import") == expected_args(postconfig_cmd=postconfig_import) -def test_input_flag_alone(): - assert cli_as_dict("-i test.txt") == expected_args(input="test.txt") - assert cli_as_dict("-i 'lorem ipsum.txt'") == expected_args(input="lorem ipsum.txt") - - -def test_output_flag_alone(): - assert cli_as_dict("-o test.txt") == expected_args(output="test.txt") - assert cli_as_dict("-o 'lorem ipsum.txt'") == expected_args( - output="lorem ipsum.txt" +def test_file_flag_alone(): + assert cli_as_dict("--file test.txt") == expected_args(filename="test.txt") + assert cli_as_dict("--file 'lorem ipsum.txt'") == expected_args( + filename="lorem ipsum.txt" ) diff --git a/tests/test_time.py b/tests/test_time.py index 697a409f..e04e1733 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -1,4 +1,5 @@ import datetime + from jrnl import time