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 <micah.jerome.ellison@gmail.com>

* Update app description on help screen

Co-authored-by: Micah Jerome Ellison <micah.jerome.ellison@gmail.com>
This commit is contained in:
Jonathan Wren 2020-08-22 11:40:39 -07:00 committed by GitHub
parent 75730aca93
commit 7da666f423
30 changed files with 981 additions and 775 deletions

View file

@ -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!"

View file

@ -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

View file

@ -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
):

View file

@ -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")

View file

@ -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):

View file

@ -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,
)

View file

@ -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()

View file

@ -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):

View file

@ -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())

View file

@ -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)

View file

@ -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 <https://www.gnu.org/licenses/>.
"""
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

79
jrnl/color.py Normal file
View file

@ -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

View file

@ -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)

88
jrnl/config.py Normal file
View file

@ -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

57
jrnl/editor.py Normal file
View file

@ -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

8
jrnl/exception.py Normal file
View file

@ -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

View file

@ -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)

306
jrnl/jrnl.py Normal file
View file

@ -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())

3
jrnl/os_compat.py Normal file
View file

@ -0,0 +1,3 @@
from sys import platform
on_windows = "win32" in platform

33
jrnl/output.py Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

28
jrnl/prompt.py Normal file
View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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]

View file

@ -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"
)

View file

@ -1,4 +1,5 @@
import datetime
from jrnl import time