reorganize code, move around lots of functions

This commit is contained in:
Jonathan Wren 2020-08-15 18:43:54 -07:00
parent 38b78b1d1f
commit 73a348b033
No known key found for this signature in database
GPG key ID: 43D5FF8722E7F68A
23 changed files with 462 additions and 470 deletions

View file

@ -1,6 +1,8 @@
import os import os
import shutil import shutil
import sys
from jrnl.os_compat import on_windows
CWD = os.getcwd() CWD = os.getcwd()
@ -19,7 +21,7 @@ def before_feature(context, feature):
feature.skip("Marked with @skip") feature.skip("Marked with @skip")
return 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") feature.skip("Skipping on Windows")
return return
@ -46,7 +48,7 @@ def before_scenario(context, scenario):
scenario.skip("Marked with @skip") scenario.skip("Marked with @skip")
return 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") scenario.skip("Skipping on Windows")
return return

View file

@ -4,7 +4,6 @@ import os
from pathlib import Path from pathlib import Path
import re import re
import shlex import shlex
import sys
import time import time
from unittest.mock import patch from unittest.mock import patch
@ -13,7 +12,14 @@ import toml
import yaml import yaml
from behave import given, then, when from behave import given, then, when
from jrnl import Journal, __version__, cli, install, plugins, util from jrnl import Journal
from jrnl import __version__
from jrnl import cli
from jrnl import install
from jrnl import plugins
from jrnl.config import load_config
from jrnl.os_compat import on_windows
try: try:
import parsedatetime.parsedatetime_consts as pdt import parsedatetime.parsedatetime_consts as pdt
@ -62,18 +68,18 @@ keyring.set_keyring(TestKeyring())
def ushlex(command): def ushlex(command):
return shlex.split(command, posix="win32" not in sys.platform) return shlex.split(command, posix=on_windows)
def read_journal(journal_name="default"): 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: with open(config["journals"][journal_name]) as journal_file:
journal = journal_file.read() journal = journal_file.read()
return journal return journal
def open_journal(journal_name="default"): 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] journal_conf = config["journals"][journal_name]
# We can override the default config on a by-journal basis # We can override the default config on a by-journal basis
@ -364,7 +370,7 @@ def config_var(context, key, value, journal=None):
# Handle value being a dictionary # Handle value being a dictionary
value = ast.literal_eval(value) value = ast.literal_eval(value)
config = util.load_config(install.CONFIG_FILE_PATH) config = load_config(install.CONFIG_FILE_PATH)
if journal: if journal:
config = config["journals"][journal] config = config["journals"][journal]
assert key in config assert key in config

View file

@ -3,6 +3,8 @@ import hashlib
import logging import logging
import os import os
import sys import sys
from typing import Callable, Optional
import getpass
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
@ -10,11 +12,9 @@ from cryptography.hazmat.primitives import hashes, padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from . import util from .prompt import create_password
from .Journal import Journal, LegacyJournal from .Journal import Journal, LegacyJournal
log = logging.getLogger()
def make_key(password): def make_key(password):
password = password.encode("utf-8") password = password.encode("utf-8")
@ -30,6 +30,30 @@ def make_key(password):
return base64.urlsafe_b64encode(key) 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): class EncryptedJournal(Journal):
def __init__(self, name="default", **kwargs): def __init__(self, name="default", **kwargs):
super().__init__(name, **kwargs) super().__init__(name, **kwargs)
@ -46,7 +70,7 @@ class EncryptedJournal(Journal):
os.makedirs(dirname) os.makedirs(dirname)
print(f"[Directory {dirname} created]", file=sys.stderr) print(f"[Directory {dirname} created]", file=sys.stderr)
self.create_file(filename) self.create_file(filename)
self.password = util.create_password(self.name) self.password = create_password(self.name)
print( print(
f"Encrypted journal '{self.name}' created at {filename}", f"Encrypted journal '{self.name}' created at {filename}",
@ -56,7 +80,7 @@ class EncryptedJournal(Journal):
text = self._load(filename) text = self._load(filename)
self.entries = self._parse(text) self.entries = self._parse(text)
self.sort() 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 return self
def _load(self, filename): def _load(self, filename):
@ -80,7 +104,7 @@ class EncryptedJournal(Journal):
if self.password: if self.password:
return decrypt_journal(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): def _store(self, filename, text):
key = make_key(self.password) key = make_key(self.password)
@ -95,7 +119,7 @@ class EncryptedJournal(Journal):
new_journal.password = ( new_journal.password = (
other.password other.password
if hasattr(other, "password") if hasattr(other, "password")
else util.create_password(other.name) else create_password(other.name)
) )
except KeyboardInterrupt: except KeyboardInterrupt:
print("[Interrupted while creating new journal]", file=sys.stderr) print("[Interrupted while creating new journal]", file=sys.stderr)
@ -138,4 +162,31 @@ class LegacyEncryptedJournal(LegacyJournal):
if self.password: if self.password:
return decrypt_journal(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 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: class Entry:
@ -194,3 +195,28 @@ class Entry:
def __ne__(self, other): def __ne__(self, other):
return not self.__eq__(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 re
import sys import sys
from jrnl import Entry, time, util from . import Entry
from . import time
log = logging.getLogger(__name__) from .prompt import yesno
class Tag: class Tag:
@ -56,7 +56,7 @@ class Journal:
another journal object""" another journal object"""
new_journal = cls(other.name, **other.config) new_journal = cls(other.name, **other.config)
new_journal.entries = other.entries new_journal.entries = other.entries
log.debug( logging.debug(
"Imported %d entries from %s to %s", "Imported %d entries from %s to %s",
len(new_journal), len(new_journal),
other.__class__.__name__, other.__class__.__name__,
@ -85,7 +85,7 @@ class Journal:
text = self._load(filename) text = self._load(filename)
self.entries = self._parse(text) self.entries = self._parse(text)
self.sort() 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 return self
def write(self, filename=None): def write(self, filename=None):
@ -248,9 +248,7 @@ class Journal:
to_delete = [] to_delete = []
def ask_delete(entry): def ask_delete(entry):
return util.yesno( return yesno(f"Delete entry '{entry.pprint(short=True)}'?", default=False,)
f"Delete entry '{entry.pprint(short=True)}'?", default=False,
)
for entry in self.entries: for entry in self.entries:
if ask_delete(entry): if ask_delete(entry):

View file

@ -1,5 +1,4 @@
#!/usr/bin/env python #!/usr/bin/env python
""" """
jrnl jrnl
@ -7,8 +6,7 @@
This program is free software: you can redistribute it and/or modify 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 it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License.
(at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
@ -18,34 +16,17 @@
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
import logging import logging
import packaging.version
import platform
import sys import sys
from . import install, util
from . import jrnl from . import jrnl
from . import install
from .parse_args import parse_args from .parse_args import parse_args
from .config import scope_config
from .exception import UserAbort
from .Journal import open_journal from .Journal import open_journal
from .util import UserAbort from .config import get_journal_name
from .util import get_journal_name
from .util import WARNING_COLOR, RESET_COLOR
log = logging.getLogger(__name__)
def update_config(config, new_config, scope, force_local=False):
"""Updates a config dict with new values - either global if scope is None
or config['journals'][scope] is just a string pointing to a journal file,
or within the scope"""
if scope and type(config["journals"][scope]) is dict: # Update to journal specific
config["journals"][scope].update(new_config)
elif scope and force_local: # Convert to dict
config["journals"][scope] = {"journal": config["journals"][scope]}
config["journals"][scope].update(new_config)
else:
config.update(new_config)
def configure_logger(debug=False): def configure_logger(debug=False):
@ -58,19 +39,6 @@ def configure_logger(debug=False):
def run(manual_args=None): def run(manual_args=None):
if packaging.version.parse(platform.python_version()) < packaging.version.parse(
"3.7"
):
print(
f"""{ERROR_COLOR}
ERROR: Python version {platform.python_version()} not supported.
Please update to Python 3.7 (or higher) in order to use jrnl.
{RESET_COLOR}""",
file=sys.stderr,
)
sys.exit(1)
""" """
Flow: Flow:
1. Parse cli arguments 1. Parse cli arguments
@ -86,7 +54,7 @@ Please update to Python 3.7 (or higher) in order to use jrnl.
args = parse_args(manual_args) args = parse_args(manual_args)
configure_logger(args.debug) configure_logger(args.debug)
log.debug("Parsed args: %s", args) logging.debug("Parsed args: %s", args)
# Run command if possible before config is available # Run command if possible before config is available
if callable(args.preconfig_cmd): if callable(args.preconfig_cmd):
@ -98,7 +66,7 @@ Please update to Python 3.7 (or higher) in order to use jrnl.
config = install.load_or_install_jrnl() config = install.load_or_install_jrnl()
original_config = config.copy() original_config = config.copy()
args = get_journal_name(args, config) args = get_journal_name(args, config)
config = util.scope_config(config, args.journal_name) config = scope_config(config, args.journal_name)
except UserAbort as err: except UserAbort as err:
print(f"\n{err}", file=sys.stderr) print(f"\n{err}", file=sys.stderr)
sys.exit(1) sys.exit(1)

77
jrnl/color.py Normal file
View file

@ -0,0 +1,77 @@
#!/usr/bin/env python
import colorama
import re
from string import punctuation
from string import whitespace
from .os_compat import on_windows
if on_windows:
colorama.init()
WARNING_COLOR = colorama.Fore.YELLOW
ERROR_COLOR = colorama.Fore.RED
RESET_COLOR = colorama.Fore.RESET
def colorize(string, color, bold=False):
"""Returns the string colored with colorama.Fore.color. If the color set by
the user is "NONE" or the color doesn't exist in the colorama.Fore attributes,
it returns the string without any modification."""
color_escape = getattr(colorama.Fore, color.upper(), None)
if not color_escape:
return string
elif not bold:
return color_escape + string + colorama.Fore.RESET
else:
return colorama.Style.BRIGHT + color_escape + string + colorama.Style.RESET_ALL
def highlight_tags_with_background_color(entry, text, color, is_title=False):
"""
Takes a string and colorizes the tags in it based upon the config value for
color.tags, while colorizing the rest of the text based on `color`.
:param entry: Entry object, for access to journal config
:param text: Text to be colorized
:param color: Color for non-tag text, passed to colorize()
:param is_title: Boolean flag indicating if the text is a title or not
:return: Colorized str
"""
def colorized_text_generator(fragments):
"""Efficiently generate colorized tags / text from text fragments.
Taken from @shobrook. Thanks, buddy :)
:param fragments: List of strings representing parts of entry (tag or word).
:rtype: List of tuples
:returns [(colorized_str, original_str)]"""
for part in fragments:
if part and part[0] not in config["tagsymbols"]:
yield (colorize(part, color, bold=is_title), part)
elif part:
yield (colorize(part, config["colors"]["tags"], bold=True), part)
config = entry.journal.config
if config["highlight"]: # highlight tags
text_fragments = re.split(entry.tag_regex(config["tagsymbols"]), text)
# Colorizing tags inside of other blocks of text
final_text = ""
previous_piece = ""
for colorized_piece, piece in colorized_text_generator(text_fragments):
# If this piece is entirely punctuation or whitespace or the start
# of a line or the previous piece was a tag or this piece is a tag,
# then add it to the final text without a leading space.
if (
all(char in punctuation + whitespace for char in piece)
or previous_piece.endswith("\n")
or (previous_piece and previous_piece[0] in config["tagsymbols"])
or piece[0] in config["tagsymbols"]
):
final_text += colorized_piece
else:
# Otherwise add a leading space and then append the piece.
final_text += " " + colorized_piece
previous_piece = piece
return final_text.lstrip()
else:
return text

View file

@ -34,7 +34,7 @@ def preconfig_version(_):
def postconfig_list(config, **kwargs): def postconfig_list(config, **kwargs):
from .util import list_journals from .output import list_journals
print(list_journals(config)) print(list_journals(config))
@ -56,7 +56,7 @@ def postconfig_encrypt(args, config, original_config, **kwargs):
""" """
from .EncryptedJournal import EncryptedJournal from .EncryptedJournal import EncryptedJournal
from .Journal import open_journal from .Journal import open_journal
from .cli import update_config from .config import update_config
from .install import save_config from .install import save_config
# Open the journal # Open the journal
@ -84,7 +84,7 @@ def postconfig_decrypt(args, config, original_config, **kwargs):
""" Decrypts into new file. If filename is not set, we encrypt the journal file itself. """ """ Decrypts into new file. If filename is not set, we encrypt the journal file itself. """
from .Journal import PlainJournal from .Journal import PlainJournal
from .Journal import open_journal from .Journal import open_journal
from .cli import update_config from .config import update_config
from .install import save_config from .install import save_config
journal = open_journal(args.journal_name, config) journal = open_journal(args.journal_name, config)

87
jrnl/config.py Normal file
View file

@ -0,0 +1,87 @@
import logging
import colorama
import sys
import yaml
from .color import ERROR_COLOR
from .color import RESET_COLOR
from .output import list_journals
def scope_config(config, journal_name):
if journal_name not in config["journals"]:
return config
config = config.copy()
journal_conf = config["journals"].get(journal_name)
if type(journal_conf) is dict:
# We can override the default config on a by-journal basis
logging.debug(
"Updating configuration with specific journal overrides %s", journal_conf
)
config.update(journal_conf)
else:
# But also just give them a string to point to the journal file
config["journal"] = journal_conf
return config
def verify_config(config):
"""
Ensures the keys set for colors are valid colorama.Fore attributes, or "None"
:return: True if all keys are set correctly, False otherwise
"""
all_valid_colors = True
for key, color in config["colors"].items():
upper_color = color.upper()
if upper_color == "NONE":
continue
if not getattr(colorama.Fore, upper_color, None):
print(
"[{2}ERROR{3}: {0} set to invalid color: {1}]".format(
key, color, ERROR_COLOR, RESET_COLOR
),
file=sys.stderr,
)
all_valid_colors = False
return all_valid_colors
def load_config(config_path):
"""Tries to load a config file from YAML."""
with open(config_path) as f:
return yaml.load(f, Loader=yaml.FullLoader)
def is_config_json(config_path):
with open(config_path, "r", encoding="utf-8") as f:
config_file = f.read()
return config_file.strip().startswith("{")
def update_config(config, new_config, scope, force_local=False):
"""Updates a config dict with new values - either global if scope is None
or config['journals'][scope] is just a string pointing to a journal file,
or within the scope"""
if scope and type(config["journals"][scope]) is dict: # Update to journal specific
config["journals"][scope].update(new_config)
elif scope and force_local: # Convert to dict
config["journals"][scope] = {"journal": config["journals"][scope]}
config["journals"][scope].update(new_config)
else:
config.update(new_config)
def get_journal_name(args, config):
from . import install
args.journal_name = install.DEFAULT_JOURNAL_KEY
if args.text and args.text[0] in config["journals"]:
args.journal_name = args.text[0]
args.text = args.text[1:]
elif install.DEFAULT_JOURNAL_KEY not in config["journals"]:
print("No default journal configured.", file=sys.stderr)
print(list_journals(config), file=sys.stderr)
sys.exit(1)
logging.debug("Using journal name: %s", args.journal_name)
return args

40
jrnl/editor.py Normal file
View file

@ -0,0 +1,40 @@
import tempfile
import os
import sys
import shlex
import subprocess
import textwrap
from .color import ERROR_COLOR
from .color import RESET_COLOR
from .os_compat import on_windows
def get_text_from_editor(config, template=""):
filehandle, tmpfile = tempfile.mkstemp(prefix="jrnl", text=True, suffix=".txt")
os.close(filehandle)
with open(tmpfile, "w", encoding="utf-8") as f:
if template:
f.write(template)
try:
subprocess.call(shlex.split(config["editor"], posix=on_windows) + [tmpfile])
except Exception as e:
error_msg = f"""
{ERROR_COLOR}{str(e)}{RESET_COLOR}
Please check the 'editor' key in your config file for errors:
{repr(config['editor'])}
"""
print(textwrap.dedent(error_msg).strip(), file=sys.stderr)
exit(1)
with open(tmpfile, "r", encoding="utf-8") as f:
raw = f.read()
os.remove(tmpfile)
if not raw:
print("[Nothing saved to file]", file=sys.stderr)
return raw

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

@ -4,14 +4,17 @@ import glob
import logging import logging
import os import os
import sys import sys
import xdg.BaseDirectory import xdg.BaseDirectory
import yaml import yaml
from . import __version__
from .config import load_config
from .config import verify_config
from .exception import UserAbort
from .os_compat import on_windows
from .prompt import yesno
from .upgrade import is_old_version
from . import __version__, util if not on_windows:
from .util import UserAbort, verify_config
if "win32" not in sys.platform:
# readline is not included in Windows Active Python # readline is not included in Windows Active Python
import readline import readline
@ -29,18 +32,6 @@ CONFIG_FILE_PATH_FALLBACK = os.path.join(USER_HOME, ".jrnl_config")
JOURNAL_PATH = xdg.BaseDirectory.save_data_path(XDG_RESOURCE) or USER_HOME JOURNAL_PATH = xdg.BaseDirectory.save_data_path(XDG_RESOURCE) or USER_HOME
JOURNAL_FILE_PATH = os.path.join(JOURNAL_PATH, DEFAULT_JOURNAL_NAME) 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 = { default_config = {
"version": __version__, "version": __version__,
@ -93,10 +84,10 @@ def load_or_install_jrnl():
else CONFIG_FILE_PATH_FALLBACK else CONFIG_FILE_PATH_FALLBACK
) )
if os.path.exists(config_path): if os.path.exists(config_path):
log.debug("Reading configuration from file %s", config_path) logging.debug("Reading configuration from file %s", config_path)
config = util.load_config(config_path) config = load_config(config_path)
if util.is_old_version(config_path): if is_old_version(config_path):
from . import upgrade from . import upgrade
try: try:
@ -118,21 +109,21 @@ def load_or_install_jrnl():
verify_config(config) verify_config(config)
else: else:
log.debug("Configuration file not found, installing jrnl...") logging.debug("Configuration file not found, installing jrnl...")
try: try:
config = install() config = install()
except KeyboardInterrupt: except KeyboardInterrupt:
raise UserAbort("Installation aborted") raise UserAbort("Installation aborted")
log.debug('Using configuration "%s"', config) logging.debug('Using configuration "%s"', config)
return config return config
def install(): def install():
if "win32" not in sys.platform: if not on_windows:
readline.set_completer_delims(" \t\n;") readline.set_completer_delims(" \t\n;")
readline.parse_and_bind("tab: complete") readline.parse_and_bind("tab: complete")
readline.set_completer(autocomplete) readline.set_completer(_autocomplete_path)
# Where to create the journal? # Where to create the journal?
path_query = f"Path to your journal file (leave blank for {JOURNAL_FILE_PATH}): " path_query = f"Path to your journal file (leave blank for {JOURNAL_FILE_PATH}): "
@ -149,7 +140,7 @@ def install():
pass pass
# Encrypt it? # Encrypt it?
encrypt = util.yesno( encrypt = yesno(
"Do you want to encrypt your journal? You can always change this later", "Do you want to encrypt your journal? You can always change this later",
default=False, default=False,
) )
@ -161,7 +152,7 @@ def install():
return default_config return default_config
def autocomplete(text, state): def _autocomplete_path(text, state):
expansions = glob.glob(os.path.expanduser(os.path.expandvars(text)) + "*") expansions = glob.glob(os.path.expanduser(os.path.expandvars(text)) + "*")
expansions = [e + "/" if os.path.isdir(e) else e for e in expansions] expansions = [e + "/" if os.path.isdir(e) else e for e in expansions]
expansions.append(None) expansions.append(None)

View file

@ -1,14 +1,13 @@
import logging import logging
import sys import sys
from . import util
from . import install from . import install
from . import plugins from . import plugins
from .editor import get_text_from_editor
from .util import ERROR_COLOR from .color import ERROR_COLOR
from .util import RESET_COLOR from .color import RESET_COLOR
from .os_compat import on_windows
log = logging.getLogger(__name__)
def _is_write_mode(args, config, **kwargs): def _is_write_mode(args, config, **kwargs):
@ -53,30 +52,30 @@ def write_mode(args, config, journal, **kwargs):
4. Use stdin.read as last resort 4. Use stdin.read as last resort
6. Write any found text to journal, or exit 6. Write any found text to journal, or exit
""" """
log.debug("Write mode: starting") logging.debug("Write mode: starting")
if args.text: if args.text:
log.debug("Write mode: cli text detected: %s", args.text) logging.debug("Write mode: cli text detected: %s", args.text)
raw = " ".join(args.text).strip() raw = " ".join(args.text).strip()
elif not sys.stdin.isatty(): elif not sys.stdin.isatty():
log.debug("Write mode: receiving piped text") logging.debug("Write mode: receiving piped text")
raw = sys.stdin.read() raw = sys.stdin.read()
else: else:
raw = _write_in_editor(config) raw = _write_in_editor(config)
if not raw: if not raw:
log.error("Write mode: couldn't get raw text") logging.error("Write mode: couldn't get raw text")
sys.exit() sys.exit()
log.debug( logging.debug(
'Write mode: appending raw text to journal "%s": %s', args.journal_name, raw 'Write mode: appending raw text to journal "%s": %s', args.journal_name, raw
) )
journal.new_entry(raw) journal.new_entry(raw)
print(f"[Entry added to {args.journal_name} journal]", file=sys.stderr) print(f"[Entry added to {args.journal_name} journal]", file=sys.stderr)
journal.write() journal.write()
log.debug("Write mode: completed journal.write()", args.journal_name, raw) logging.debug("Write mode: completed journal.write()", args.journal_name, raw)
def search_mode(args, journal, **kwargs): def search_mode(args, journal, **kwargs):
@ -109,12 +108,12 @@ def search_mode(args, journal, **kwargs):
def _write_in_editor(config): def _write_in_editor(config):
if config["editor"]: if config["editor"]:
log.debug("Write mode: opening editor") logging.debug("Write mode: opening editor")
template = _get_editor_template(config) template = _get_editor_template(config)
raw = util.get_text_from_editor(config, template) raw = get_text_from_editor(config, template)
else: else:
_how_to_quit = "Ctrl+z and then Enter" if "win32" in sys.platform else "Ctrl+d" _how_to_quit = "Ctrl+z and then Enter" if on_windows else "Ctrl+d"
print( print(
f"[Writing Entry; on a blank line, press {_how_to_quit} to finish writing]\n", f"[Writing Entry; on a blank line, press {_how_to_quit} to finish writing]\n",
file=sys.stderr, file=sys.stderr,
@ -122,7 +121,7 @@ def _write_in_editor(config):
try: try:
raw = sys.stdin.read() raw = sys.stdin.read()
except KeyboardInterrupt: except KeyboardInterrupt:
log.error("Write mode: keyboard interrupt") logging.error("Write mode: keyboard interrupt")
print("[Entry NOT saved to journal]", file=sys.stderr) print("[Entry NOT saved to journal]", file=sys.stderr)
sys.exit(0) sys.exit(0)
@ -130,17 +129,17 @@ def _write_in_editor(config):
def _get_editor_template(config, **kwargs): def _get_editor_template(config, **kwargs):
log.debug("Write mode: loading template for entry") logging.debug("Write mode: loading template for entry")
if not config["template"]: if not config["template"]:
log.debug("Write mode: no template configured") logging.debug("Write mode: no template configured")
return "" return ""
try: try:
template = open(config["template"]).read() template = open(config["template"]).read()
log.debug("Write mode: template loaded: %s", template) logging.debug("Write mode: template loaded: %s", template)
except OSError: except OSError:
log.error("Write mode: template not loaded") logging.error("Write mode: template not loaded")
print( print(
f"[Could not read template at '{config['template']}']", file=sys.stderr, f"[Could not read template at '{config['template']}']", file=sys.stderr,
) )
@ -191,7 +190,7 @@ def _edit_search_results(config, journal, old_entries, **kwargs):
old_stats = _get_predit_stats(journal) old_stats = _get_predit_stats(journal)
# Send user to the editor # Send user to the editor
edited = util.get_text_from_editor(config, journal.editable_str()) edited = get_text_from_editor(config, journal.editable_str())
journal.parse_editable_str(edited) journal.parse_editable_str(edited)
# Print summary if available # Print summary if available

3
jrnl/os_compat.py Normal file
View file

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

31
jrnl/output.py Normal file
View file

@ -0,0 +1,31 @@
import logging
def deprecated_cmd(old_cmd, new_cmd, callback=None, **kwargs):
import sys
import textwrap
from .color import RESET_COLOR, WARNING_COLOR
warning_msg = f"""
The command {old_cmd} is deprecated and will be removed from jrnl soon.
Please us {new_cmd} instead.
"""
warning_msg = textwrap.dedent(warning_msg)
logging.warning(warning_msg)
print(f"{WARNING_COLOR}{warning_msg}{RESET_COLOR}", file=sys.stderr)
if callback is not None:
callback(**kwargs)
def list_journals(config):
from . import install
"""List the journals specified in the configuration file"""
result = f"Journals defined in {install.CONFIG_FILE_PATH}\n"
ml = min(max(len(k) for k in config["journals"]), 20)
for journal, cfg in config["journals"].items():
result += " * {:{}} -> {}\n".format(
journal, ml, cfg["journal"] if isinstance(cfg, dict) else cfg
)
return result

View file

@ -11,7 +11,7 @@ from .commands import postconfig_list
from .commands import postconfig_import from .commands import postconfig_import
from .commands import postconfig_encrypt from .commands import postconfig_encrypt
from .commands import postconfig_decrypt from .commands import postconfig_decrypt
from .util import deprecated_cmd from .output import deprecated_cmd
class WrappingFormatter(argparse.RawTextHelpFormatter): class WrappingFormatter(argparse.RawTextHelpFormatter):

View file

@ -5,7 +5,7 @@ import os
import re import re
import sys import sys
from ..util import RESET_COLOR, WARNING_COLOR from jrnl.color import RESET_COLOR, WARNING_COLOR
from .text_exporter import TextExporter from .text_exporter import TextExporter

View file

@ -2,8 +2,10 @@
# encoding: utf-8 # encoding: utf-8
import os import os
import re
import unicodedata
from ..util import ERROR_COLOR, RESET_COLOR, slugify from jrnl.color import ERROR_COLOR, RESET_COLOR
class TextExporter: class TextExporter:
@ -35,7 +37,7 @@ class TextExporter:
@classmethod @classmethod
def make_filename(cls, entry): def make_filename(cls, entry):
return entry.date.strftime( 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 @classmethod
@ -52,6 +54,15 @@ class TextExporter:
) )
return "[Journal exported to {}]".format(path) 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 @classmethod
def export(cls, journal, output=None): def export(cls, journal, output=None):
"""Exports to individual files if output is an existing path, or into """Exports to individual files if output is an existing path, or into

View file

@ -5,7 +5,7 @@ import os
import re import re
import sys import sys
from ..util import ERROR_COLOR, RESET_COLOR, WARNING_COLOR from jrnl.color import ERROR_COLOR, RESET_COLOR, WARNING_COLOR
from .text_exporter import TextExporter from .text_exporter import TextExporter

28
jrnl/prompt.py Normal file
View file

@ -0,0 +1,28 @@
import sys
import getpass
def create_password(
journal_name: str, prompt: str = "Enter password for new journal: "
) -> str:
while True:
pw = getpass.getpass(prompt)
if not pw:
print("Password can't be an empty string!", file=sys.stderr)
continue
elif pw == getpass.getpass("Enter password again: "):
break
print("Passwords did not match, please try again", file=sys.stderr)
if yesno("Do you want to store the password in your keychain?", default=True):
from .EncryptedJournal import set_keychain
set_keychain(journal_name, pw)
return pw
def yesno(prompt, default=True):
prompt = f"{prompt.strip()} {'[Y/n]' if default else '[y/N]'} "
response = input(prompt)
return {"y": True, "n": False}.get(response.lower().strip(), default)

View file

@ -1,9 +1,15 @@
import os import os
import sys import sys
from . import Journal, __version__, util from . import __version__
from . import Journal
from .config import is_config_json
from .config import load_config
from .config import scope_config
from .exception import UpgradeValidationException
from .exception import UserAbort
from .prompt import yesno
from .EncryptedJournal import EncryptedJournal from .EncryptedJournal import EncryptedJournal
from .util import UserAbort
def backup(filename, binary=False): def backup(filename, binary=False):
@ -19,7 +25,7 @@ def backup(filename, binary=False):
except FileNotFoundError: except FileNotFoundError:
print(f"\nError: {filename} does not exist.") print(f"\nError: {filename} does not exist.")
try: try:
cont = util.yesno(f"\nCreate {filename}?", default=False) cont = yesno(f"\nCreate {filename}?", default=False)
if not cont: if not cont:
raise KeyboardInterrupt raise KeyboardInterrupt
@ -35,7 +41,7 @@ def check_exists(path):
def upgrade_jrnl(config_path): def upgrade_jrnl(config_path):
config = util.load_config(config_path) config = load_config(config_path)
print( print(
f"""Welcome to jrnl {__version__}. f"""Welcome to jrnl {__version__}.
@ -113,7 +119,7 @@ older versions of jrnl anymore.
) )
try: try:
cont = util.yesno("\nContinue upgrading jrnl?", default=False) cont = yesno("\nContinue upgrading jrnl?", default=False)
if not cont: if not cont:
raise KeyboardInterrupt raise KeyboardInterrupt
except KeyboardInterrupt: except KeyboardInterrupt:
@ -126,7 +132,7 @@ older versions of jrnl anymore.
) )
backup(path, binary=True) backup(path, binary=True)
old_journal = Journal.open_journal( 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)) all_journals.append(EncryptedJournal.from_journal(old_journal))
@ -137,7 +143,7 @@ older versions of jrnl anymore.
) )
backup(path) backup(path)
old_journal = Journal.open_journal( 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)) 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) print("\nWe're all done here and you can start enjoying jrnl 2.", file=sys.stderr)
class UpgradeValidationException(Exception): def is_old_version(config_path):
"""Raised when the contents of an upgraded journal do not match the old journal""" return is_config_json(config_path)
pass

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

@ -1,4 +1,4 @@
from jrnl.cli import parse_args from jrnl.parse_args import parse_args
import pytest import pytest
import shlex import shlex