mirror of
https://github.com/jrnl-org/jrnl.git
synced 2025-05-10 16:48:31 +02:00
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:
parent
7c3abb2625
commit
631e08a557
30 changed files with 981 additions and 775 deletions
|
@ -180,7 +180,7 @@ Feature: Basic reading and writing to a journal
|
||||||
And the journal should contain "Life is good."
|
And the journal should contain "Life is good."
|
||||||
But the journal should not contain "I have an @idea"
|
But the journal should not contain "I have an @idea"
|
||||||
And the journal should not contain "I met with"
|
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."
|
Then the journal should contain "My first entry."
|
||||||
And the journal should contain "Life is good."
|
And the journal should contain "Life is good."
|
||||||
And the journal should contain "PROFIT!"
|
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."
|
And the journal should contain "Life is good."
|
||||||
But the journal should not contain "I have an @idea"
|
But the journal should not contain "I have an @idea"
|
||||||
And the journal should not contain "I met with"
|
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!
|
[2020-07-05 15:00] I should not exist!
|
||||||
"""
|
"""
|
||||||
Then the journal should contain "My first entry."
|
Then the journal should contain "My first entry."
|
||||||
And the journal should contain "PROFIT!"
|
And the journal should contain "PROFIT!"
|
||||||
But the journal should not contain "I should not exist!"
|
But the journal should not contain "I should not exist!"
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
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 +20,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 +47,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
|
||||||
|
|
||||||
|
|
|
@ -4,16 +4,23 @@ 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
|
||||||
|
|
||||||
|
from behave import given
|
||||||
|
from behave import then
|
||||||
|
from behave import when
|
||||||
import keyring
|
import keyring
|
||||||
import toml
|
import toml
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from behave import given, then, when
|
from jrnl import Journal
|
||||||
from jrnl import Journal, __version__, cli, install, plugins, util
|
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:
|
try:
|
||||||
import parsedatetime.parsedatetime_consts as pdt
|
import parsedatetime.parsedatetime_consts as pdt
|
||||||
|
@ -62,18 +69,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=not 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
|
||||||
|
@ -129,7 +136,7 @@ def open_editor_and_enter(context, method, text=""):
|
||||||
patch("subprocess.call", side_effect=_mock_editor_function), \
|
patch("subprocess.call", side_effect=_mock_editor_function), \
|
||||||
patch("sys.stdin.isatty", return_value=True) \
|
patch("sys.stdin.isatty", return_value=True) \
|
||||||
:
|
:
|
||||||
cli.run(["--edit"])
|
cli(["--edit"])
|
||||||
# fmt: on
|
# fmt: on
|
||||||
|
|
||||||
|
|
||||||
|
@ -193,7 +200,7 @@ def run_with_input(context, command, inputs=""):
|
||||||
patch("sys.stdin.read", side_effect=text) as mock_read \
|
patch("sys.stdin.read", side_effect=text) as mock_read \
|
||||||
:
|
:
|
||||||
try:
|
try:
|
||||||
cli.run(args or [])
|
cli(args or [])
|
||||||
context.exit_status = 0
|
context.exit_status = 0
|
||||||
except SystemExit as e:
|
except SystemExit as e:
|
||||||
context.exit_status = e.code
|
context.exit_status = e.code
|
||||||
|
@ -229,7 +236,7 @@ def run(context, command, text="", cache_dir=None):
|
||||||
with patch("sys.argv", args), patch(
|
with patch("sys.argv", args), patch(
|
||||||
"subprocess.call", side_effect=_mock_editor
|
"subprocess.call", side_effect=_mock_editor
|
||||||
), patch("sys.stdin.read", side_effect=lambda: text):
|
), patch("sys.stdin.read", side_effect=lambda: text):
|
||||||
cli.run(args[1:])
|
cli(args[1:])
|
||||||
context.exit_status = 0
|
context.exit_status = 0
|
||||||
except SystemExit as e:
|
except SystemExit as e:
|
||||||
context.exit_status = e.code
|
context.exit_status = e.code
|
||||||
|
@ -364,7 +371,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
|
||||||
|
@ -392,9 +399,10 @@ def list_journal_directory(context, journal="default"):
|
||||||
|
|
||||||
@then("the Python version warning should appear if our version is below {version}")
|
@then("the Python version warning should appear if our version is below {version}")
|
||||||
def check_python_warning_if_version_low_enough(context, version):
|
def check_python_warning_if_version_low_enough(context, version):
|
||||||
import packaging.version
|
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
|
import packaging.version
|
||||||
|
|
||||||
if packaging.version.parse(platform.python_version()) < packaging.version.parse(
|
if packaging.version.parse(platform.python_version()) < packaging.version.parse(
|
||||||
version
|
version
|
||||||
):
|
):
|
||||||
|
|
|
@ -3,7 +3,8 @@ import os
|
||||||
import shutil
|
import shutil
|
||||||
from xml.etree import ElementTree
|
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")
|
@then("the output should be parsable as json")
|
||||||
|
|
|
@ -4,18 +4,21 @@ from datetime import datetime
|
||||||
import fnmatch
|
import fnmatch
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
import plistlib
|
import plistlib
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from xml.parsers.expat import ExpatError
|
from xml.parsers.expat import ExpatError
|
||||||
import socket
|
|
||||||
import platform
|
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import tzlocal
|
import tzlocal
|
||||||
|
|
||||||
from . import __title__, __version__, Entry, Journal
|
from . import Entry
|
||||||
|
from . import Journal
|
||||||
|
from . import __title__
|
||||||
|
from . import __version__
|
||||||
|
|
||||||
|
|
||||||
class DayOne(Journal.Journal):
|
class DayOne(Journal.Journal):
|
||||||
|
|
|
@ -1,19 +1,25 @@
|
||||||
import base64
|
import base64
|
||||||
|
import getpass
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
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.backends import default_backend
|
||||||
from cryptography.hazmat.primitives import hashes, padding
|
from cryptography.hazmat.primitives import hashes
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
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 cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
|
||||||
from . import util
|
from .Journal import Journal
|
||||||
from .Journal import Journal, LegacyJournal
|
from .Journal import LegacyJournal
|
||||||
|
from .prompt import create_password
|
||||||
log = logging.getLogger()
|
|
||||||
|
|
||||||
|
|
||||||
def make_key(password):
|
def make_key(password):
|
||||||
|
@ -30,6 +36,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 +76,8 @@ 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}",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
|
@ -55,7 +86,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):
|
||||||
|
@ -79,7 +110,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)
|
||||||
|
@ -90,11 +121,16 @@ class EncryptedJournal(Journal):
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_journal(cls, other: Journal):
|
def from_journal(cls, other: Journal):
|
||||||
new_journal = super().from_journal(other)
|
new_journal = super().from_journal(other)
|
||||||
|
try:
|
||||||
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:
|
||||||
|
print("[Interrupted while creating new journal]", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
return new_journal
|
return new_journal
|
||||||
|
|
||||||
|
|
||||||
|
@ -132,4 +168,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,
|
||||||
|
)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
from . import cli
|
import sys
|
||||||
|
|
||||||
|
from .cli import cli
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
cli.run()
|
sys.exit(cli())
|
||||||
|
|
|
@ -2,20 +2,27 @@ import argparse
|
||||||
import re
|
import re
|
||||||
import textwrap
|
import textwrap
|
||||||
|
|
||||||
from .plugins import util
|
from .commands import postconfig_decrypt
|
||||||
from .plugins import IMPORT_FORMATS
|
from .commands import postconfig_encrypt
|
||||||
from .plugins import EXPORT_FORMATS
|
|
||||||
from .commands import preconfig_version
|
|
||||||
from .commands import preconfig_diagnostic
|
|
||||||
from .commands import postconfig_list
|
|
||||||
from .commands import postconfig_import
|
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):
|
def _split_lines(self, text, width):
|
||||||
text = self._whitespace_matcher.sub(" ", text).strip()
|
text = text.split("\n\n")
|
||||||
return textwrap.wrap(text, width=56)
|
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=[]):
|
def parse_args(args=[]):
|
||||||
|
@ -26,7 +33,7 @@ def parse_args(args=[]):
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
formatter_class=WrappingFormatter,
|
formatter_class=WrappingFormatter,
|
||||||
add_help=False,
|
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(
|
epilog=textwrap.dedent(
|
||||||
"""
|
"""
|
||||||
Thank you to all of our contributors! Come see the whole list of code and
|
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",
|
action="store_const",
|
||||||
const=preconfig_version,
|
const=preconfig_version,
|
||||||
dest="preconfig_cmd",
|
dest="preconfig_cmd",
|
||||||
help="prints version information",
|
help="Print version information",
|
||||||
)
|
)
|
||||||
standalone.add_argument(
|
standalone.add_argument(
|
||||||
"-v",
|
"-v",
|
||||||
|
@ -75,7 +82,7 @@ def parse_args(args=[]):
|
||||||
action="store_const",
|
action="store_const",
|
||||||
const=postconfig_list,
|
const=postconfig_list,
|
||||||
dest="postconfig_cmd",
|
dest="postconfig_cmd",
|
||||||
help="list all configured journals",
|
help="List all configured journals",
|
||||||
)
|
)
|
||||||
standalone.add_argument(
|
standalone.add_argument(
|
||||||
"--ls",
|
"--ls",
|
||||||
|
@ -95,40 +102,47 @@ def parse_args(args=[]):
|
||||||
)
|
)
|
||||||
standalone.add_argument(
|
standalone.add_argument(
|
||||||
"--encrypt",
|
"--encrypt",
|
||||||
metavar="FILENAME",
|
help="Encrypt selected journal with a password",
|
||||||
dest="encrypt",
|
action="store_const",
|
||||||
help="Encrypts your existing journal with a new password",
|
metavar="TYPE",
|
||||||
nargs="?",
|
const=postconfig_encrypt,
|
||||||
default=False,
|
dest="postconfig_cmd",
|
||||||
const=None,
|
|
||||||
)
|
)
|
||||||
standalone.add_argument(
|
standalone.add_argument(
|
||||||
"--decrypt",
|
"--decrypt",
|
||||||
metavar="FILENAME",
|
help="Decrypt selected journal and store it in plain text",
|
||||||
dest="decrypt",
|
action="store_const",
|
||||||
help="Decrypts your journal and stores it in plain text",
|
metavar="TYPE",
|
||||||
nargs="?",
|
const=postconfig_decrypt,
|
||||||
default=False,
|
dest="postconfig_cmd",
|
||||||
const=None,
|
|
||||||
)
|
)
|
||||||
standalone.add_argument(
|
standalone.add_argument(
|
||||||
"--import",
|
"--import",
|
||||||
action="store_const",
|
action="store_const",
|
||||||
metavar="TYPE",
|
metavar="TYPE",
|
||||||
dest="postconfig_cmd",
|
|
||||||
const=postconfig_import,
|
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(
|
standalone.add_argument(
|
||||||
"-i",
|
"--file",
|
||||||
metavar="FILENAME",
|
metavar="FILENAME",
|
||||||
dest="input",
|
dest="filename",
|
||||||
help="Optionally specifies input file when using --import.",
|
help=argparse.SUPPRESS,
|
||||||
default=False,
|
default=None,
|
||||||
const=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.
|
jrnl yesterday: I was walking and I found this big log.
|
||||||
|
|
||||||
|
@ -171,9 +185,7 @@ def parse_args(args=[]):
|
||||||
metavar="DATE",
|
metavar="DATE",
|
||||||
help="Show entries before, or on, this date (alias: -until)",
|
help="Show entries before, or on, this date (alias: -until)",
|
||||||
)
|
)
|
||||||
reading.add_argument(
|
reading.add_argument("-until", dest="end_date", help=argparse.SUPPRESS)
|
||||||
"-until", dest="end_date", help=argparse.SUPPRESS,
|
|
||||||
)
|
|
||||||
reading.add_argument(
|
reading.add_argument(
|
||||||
"-contains",
|
"-contains",
|
||||||
dest="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.
|
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"""
|
If used on their own (with no search), they will act on your entire journal"""
|
||||||
exporting = parser.add_argument_group(
|
exporting = parser.add_argument_group(
|
||||||
"Options for Searching", textwrap.dedent(search_options_msg)
|
"Searching Options", textwrap.dedent(search_options_msg)
|
||||||
)
|
)
|
||||||
exporting.add_argument(
|
exporting.add_argument(
|
||||||
"--edit",
|
"--edit",
|
||||||
|
@ -233,7 +245,15 @@ def parse_args(args=[]):
|
||||||
metavar="TYPE",
|
metavar="TYPE",
|
||||||
dest="export",
|
dest="export",
|
||||||
choices=EXPORT_FORMATS,
|
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,
|
default=False,
|
||||||
)
|
)
|
||||||
exporting.add_argument(
|
exporting.add_argument(
|
||||||
|
@ -259,17 +279,11 @@ def parse_args(args=[]):
|
||||||
"-s", dest="short", action="store_true", help=argparse.SUPPRESS,
|
"-s", dest="short", action="store_true", help=argparse.SUPPRESS,
|
||||||
)
|
)
|
||||||
exporting.add_argument(
|
exporting.add_argument(
|
||||||
"-o",
|
"-o", dest="filename", help=argparse.SUPPRESS,
|
||||||
metavar="FILENAME",
|
|
||||||
dest="output",
|
|
||||||
help="Optionally specifies output file (or directory) when using --format.",
|
|
||||||
default=False,
|
|
||||||
const=None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle '-123' as a shortcut for '-n 123'
|
# Handle '-123' as a shortcut for '-n 123'
|
||||||
num = re.compile(r"^-(\d+)$")
|
num = re.compile(r"^-(\d+)$")
|
||||||
args = [num.sub(r"-n \1", arg) for arg in args]
|
args = [num.sub(r"-n \1", arg) for arg in args]
|
||||||
|
|
||||||
# return parser.parse_args(args)
|
|
||||||
return parser.parse_intermixed_args(args)
|
return parser.parse_intermixed_args(args)
|
279
jrnl/cli.py
279
jrnl/cli.py
|
@ -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,94 +16,11 @@
|
||||||
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, plugins, util
|
from .jrnl import run
|
||||||
from .parse_args import parse_args
|
from .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)
|
|
||||||
|
|
||||||
|
|
||||||
def configure_logger(debug=False):
|
def configure_logger(debug=False):
|
||||||
|
@ -113,194 +28,20 @@ def configure_logger(debug=False):
|
||||||
level=logging.DEBUG if debug else logging.ERROR,
|
level=logging.DEBUG if debug else logging.ERROR,
|
||||||
format="%(levelname)-8s %(name)-12s %(message)s",
|
format="%(levelname)-8s %(name)-12s %(message)s",
|
||||||
)
|
)
|
||||||
logging.getLogger("parsedatetime").setLevel(
|
logging.getLogger("parsedatetime").setLevel(logging.INFO)
|
||||||
logging.INFO
|
logging.getLogger("keyring.backend").setLevel(logging.ERROR)
|
||||||
) # disable parsedatetime debug logging
|
|
||||||
|
|
||||||
|
|
||||||
def run(manual_args=None):
|
def cli(manual_args=None):
|
||||||
if packaging.version.parse(platform.python_version()) < packaging.version.parse(
|
try:
|
||||||
"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:
|
if manual_args is None:
|
||||||
manual_args = sys.argv[1:]
|
manual_args = sys.argv[1:]
|
||||||
|
|
||||||
args = parse_args(manual_args)
|
args = parse_args(manual_args)
|
||||||
configure_logger(args.debug)
|
configure_logger(args.debug)
|
||||||
|
logging.debug("Parsed args: %s", args)
|
||||||
|
|
||||||
# Run command if possible before config is available
|
return run(args)
|
||||||
if callable(args.preconfig_cmd):
|
|
||||||
args.preconfig_cmd(args)
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
# Load the config
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Run post-config command now that config is ready
|
|
||||||
if callable(args.postconfig_cmd):
|
|
||||||
args.postconfig_cmd(args=args, config=config)
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
# --- All the standalone commands are now done --- #
|
|
||||||
|
|
||||||
# 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:
|
except KeyboardInterrupt:
|
||||||
print("[Entry NOT saved to journal]", file=sys.stderr)
|
return 1
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
79
jrnl/color.py
Normal file
79
jrnl/color.py
Normal 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
|
|
@ -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(_):
|
def preconfig_diagnostic(_):
|
||||||
import platform
|
from jrnl import __version__
|
||||||
import sys
|
|
||||||
import jrnl
|
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"jrnl: {jrnl.__version__}\n"
|
f"jrnl: {__version__}\n"
|
||||||
f"Python: {sys.version}\n"
|
f"Python: {sys.version}\n"
|
||||||
f"OS: {platform.system()} {platform.release()}"
|
f"OS: {platform.system()} {platform.release()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def preconfig_version(_):
|
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)
|
print(version_str)
|
||||||
|
|
||||||
|
|
||||||
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))
|
||||||
|
|
||||||
|
|
||||||
def postconfig_import(args, config, **kwargs):
|
def postconfig_import(args, config, **kwargs):
|
||||||
from .plugins import get_importer
|
|
||||||
from .Journal import open_journal
|
from .Journal import open_journal
|
||||||
|
from .plugins import get_importer
|
||||||
|
|
||||||
# Requires opening the journal
|
# Requires opening the journal
|
||||||
journal = open_journal(args.journal_name, config)
|
journal = open_journal(args.journal_name, config)
|
||||||
|
|
||||||
format = args.export if args.export else "jrnl"
|
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
88
jrnl/config.py
Normal 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
57
jrnl/editor.py
Normal 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
8
jrnl/exception.py
Normal 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
|
|
@ -8,10 +8,15 @@ import sys
|
||||||
import xdg.BaseDirectory
|
import xdg.BaseDirectory
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from . import __version__, util
|
from . import __version__
|
||||||
from .util import UserAbort, verify_config
|
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
|
# readline is not included in Windows Active Python
|
||||||
import readline
|
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_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 +86,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:
|
||||||
|
@ -115,24 +108,24 @@ def load_or_install_jrnl():
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
upgrade_config(config)
|
upgrade_config(config)
|
||||||
verify_config(config)
|
verify_config_colors(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 +142,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 +154,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)
|
||||||
|
|
306
jrnl/jrnl.py
Normal file
306
jrnl/jrnl.py
Normal 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
3
jrnl/os_compat.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
from sys import platform
|
||||||
|
|
||||||
|
on_windows = "win32" in platform
|
33
jrnl/output.py
Normal file
33
jrnl/output.py
Normal 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
|
|
@ -5,7 +5,9 @@ import os
|
||||||
import re
|
import re
|
||||||
import sys
|
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
|
from .text_exporter import TextExporter
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -2,8 +2,11 @@
|
||||||
# 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
|
||||||
|
from jrnl.color import RESET_COLOR
|
||||||
|
|
||||||
|
|
||||||
class TextExporter:
|
class TextExporter:
|
||||||
|
@ -35,7 +38,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 +55,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
|
||||||
|
|
|
@ -5,7 +5,10 @@ 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
|
||||||
|
from jrnl.color import RESET_COLOR
|
||||||
|
from jrnl.color import WARNING_COLOR
|
||||||
|
|
||||||
from .text_exporter import TextExporter
|
from .text_exporter import TextExporter
|
||||||
|
|
||||||
|
|
||||||
|
|
28
jrnl/prompt.py
Normal file
28
jrnl/prompt.py
Normal 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)
|
|
@ -1,6 +1,5 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
FAKE_YEAR = 9999
|
FAKE_YEAR = 9999
|
||||||
DEFAULT_FUTURE = datetime(FAKE_YEAR, 12, 31, 23, 59, 59)
|
DEFAULT_FUTURE = datetime(FAKE_YEAR, 12, 31, 23, 59, 59)
|
||||||
DEFAULT_PAST = datetime(FAKE_YEAR, 1, 1, 0, 0)
|
DEFAULT_PAST = datetime(FAKE_YEAR, 1, 1, 0, 0)
|
||||||
|
|
|
@ -1,9 +1,15 @@
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from . import Journal, __version__, util
|
from . import Journal
|
||||||
|
from . import __version__
|
||||||
from .EncryptedJournal import EncryptedJournal
|
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):
|
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
|
|
||||||
|
|
338
jrnl/util.py
338
jrnl/util.py
|
@ -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
|
|
|
@ -40,15 +40,13 @@ pyflakes = "^2.2.0"
|
||||||
pytest = "^5.4.3"
|
pytest = "^5.4.3"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
jrnl = 'jrnl.cli:run'
|
jrnl = 'jrnl.cli:cli'
|
||||||
|
|
||||||
[tool.isort]
|
[tool.isort]
|
||||||
multi_line_output = 3
|
multi_line_output = 7
|
||||||
include_trailing_comma = true
|
force_single_line = true
|
||||||
force_grid_wrap = 0
|
|
||||||
use_parentheses = true
|
|
||||||
line_length = 88
|
line_length = 88
|
||||||
known_first_party = ["jrnl", "behave"]
|
known_first_party = ["jrnl"]
|
||||||
force_sort_within_sections = true
|
force_sort_within_sections = true
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
from jrnl.cli import parse_args
|
import shlex
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import shlex
|
|
||||||
|
from jrnl.args import parse_args
|
||||||
|
|
||||||
|
|
||||||
def cli_as_dict(str):
|
def cli_as_dict(str):
|
||||||
|
@ -14,17 +15,14 @@ def expected_args(**kwargs):
|
||||||
default_args = {
|
default_args = {
|
||||||
"contains": None,
|
"contains": None,
|
||||||
"debug": False,
|
"debug": False,
|
||||||
"decrypt": False,
|
|
||||||
"delete": False,
|
"delete": False,
|
||||||
"edit": False,
|
"edit": False,
|
||||||
"encrypt": False,
|
|
||||||
"end_date": None,
|
"end_date": None,
|
||||||
"excluded": [],
|
"excluded": [],
|
||||||
"export": False,
|
"export": False,
|
||||||
"input": False,
|
"filename": None,
|
||||||
"limit": None,
|
"limit": None,
|
||||||
"on_date": None,
|
"on_date": None,
|
||||||
"output": False,
|
|
||||||
"preconfig_cmd": None,
|
"preconfig_cmd": None,
|
||||||
"postconfig_cmd": None,
|
"postconfig_cmd": None,
|
||||||
"short": False,
|
"short": False,
|
||||||
|
@ -66,7 +64,15 @@ def test_edit_alone():
|
||||||
|
|
||||||
|
|
||||||
def test_encrypt_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():
|
def test_end_date_alone():
|
||||||
|
@ -110,15 +116,10 @@ def test_import_alone():
|
||||||
assert cli_as_dict("--import") == expected_args(postconfig_cmd=postconfig_import)
|
assert cli_as_dict("--import") == expected_args(postconfig_cmd=postconfig_import)
|
||||||
|
|
||||||
|
|
||||||
def test_input_flag_alone():
|
def test_file_flag_alone():
|
||||||
assert cli_as_dict("-i test.txt") == expected_args(input="test.txt")
|
assert cli_as_dict("--file test.txt") == expected_args(filename="test.txt")
|
||||||
assert cli_as_dict("-i 'lorem ipsum.txt'") == expected_args(input="lorem ipsum.txt")
|
assert cli_as_dict("--file 'lorem ipsum.txt'") == expected_args(
|
||||||
|
filename="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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from jrnl import time
|
from jrnl import time
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue