mirror of
https://github.com/jrnl-org/jrnl.git
synced 2025-07-07 00:36:13 +02:00
Merge branch 'master' into issue-170
This commit is contained in:
commit
f4e40fc43d
85 changed files with 2004 additions and 5773 deletions
|
@ -5,7 +5,13 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|||
import hashlib
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
import sys
|
||||
import os
|
||||
import base64
|
||||
import getpass
|
||||
import logging
|
||||
|
||||
log = logging.getLogger()
|
||||
|
||||
|
||||
def make_key(password):
|
||||
|
@ -27,6 +33,33 @@ class EncryptedJournal(Journal.Journal):
|
|||
super(EncryptedJournal, self).__init__(name, **kwargs)
|
||||
self.config['encrypt'] = True
|
||||
|
||||
def open(self, filename=None):
|
||||
"""Opens the journal file defined in the config and parses it into a list of Entries.
|
||||
Entries have the form (date, title, body)."""
|
||||
filename = filename or self.config['journal']
|
||||
|
||||
if not os.path.exists(filename):
|
||||
password = util.getpass("Enter password for new journal: ")
|
||||
if password:
|
||||
if util.yesno("Do you want to store the password in your keychain?", default=True):
|
||||
util.set_keychain(self.name, password)
|
||||
else:
|
||||
util.set_keychain(self.name, None)
|
||||
self.config['password'] = password
|
||||
text = ""
|
||||
self._store(filename, text)
|
||||
util.prompt("[Journal '{0}' created at {1}]".format(self.name, filename))
|
||||
else:
|
||||
util.prompt("No password supplied for encrypted journal")
|
||||
sys.exit(1)
|
||||
else:
|
||||
text = self._load(filename)
|
||||
self.entries = self._parse(text)
|
||||
self.sort()
|
||||
log.debug("opened %s with %d entries", self.__class__.__name__, len(self))
|
||||
return self
|
||||
|
||||
|
||||
def _load(self, filename, password=None):
|
||||
"""Loads an encrypted journal from a file and tries to decrypt it.
|
||||
If password is not provided, will look for password in the keychain
|
||||
|
|
|
@ -84,9 +84,20 @@ class Journal(object):
|
|||
def write(self, filename=None):
|
||||
"""Dumps the journal into the config file, overwriting it"""
|
||||
filename = filename or self.config['journal']
|
||||
text = "\n".join([e.__unicode__() for e in self.entries])
|
||||
text = self._to_text()
|
||||
self._store(filename, text)
|
||||
|
||||
def validate_parsing(self):
|
||||
"""Confirms that the jrnl is still parsed correctly after being dumped to text."""
|
||||
new_entries = self._parse(self._to_text())
|
||||
for i, entry in enumerate(self.entries):
|
||||
if entry != new_entries[i]:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _to_text(self):
|
||||
return "\n".join([e.__unicode__() for e in self.entries])
|
||||
|
||||
def _load(self, filename):
|
||||
raise NotImplementedError
|
||||
|
||||
|
@ -99,8 +110,14 @@ class Journal(object):
|
|||
|
||||
def _parse(self, journal_txt):
|
||||
"""Parses a journal that's stored in a string and returns a list of entries"""
|
||||
|
||||
# Return empty array if the journal is blank
|
||||
if not journal_txt:
|
||||
return []
|
||||
|
||||
# Initialise our current entry
|
||||
entries = []
|
||||
|
||||
date_blob_re = re.compile("(?:^|\n)\[([^\\]]+)\] ")
|
||||
last_entry_pos = 0
|
||||
for match in date_blob_re.finditer(journal_txt):
|
||||
|
@ -111,9 +128,13 @@ class Journal(object):
|
|||
entries[-1].text = journal_txt[last_entry_pos:match.start()]
|
||||
last_entry_pos = match.end()
|
||||
entries.append(Entry.Entry(self, date=new_date))
|
||||
# Finish the last entry
|
||||
if entries:
|
||||
entries[-1].text = journal_txt[last_entry_pos:]
|
||||
|
||||
# If no entries were found, treat all the existing text as an entry made now
|
||||
if not entries:
|
||||
entries.append(Entry.Entry(self, date=time.parse("now")))
|
||||
|
||||
# Fill in the text of the last entry
|
||||
entries[-1].text = journal_txt[last_entry_pos:]
|
||||
|
||||
for entry in entries:
|
||||
entry._parse_text()
|
||||
|
@ -165,7 +186,7 @@ class Journal(object):
|
|||
tag_counts = set([(tags.count(tag), tag) for tag in tags])
|
||||
return [Tag(tag, count=count) for count, tag in sorted(tag_counts)]
|
||||
|
||||
def filter(self, tags=[], start_date=None, end_date=None, starred=False, strict=False, short=False):
|
||||
def filter(self, tags=[], start_date=None, end_date=None, starred=False, strict=False, short=False, exclude=[]):
|
||||
"""Removes all entries from the journal that don't match the filter.
|
||||
|
||||
tags is a list of tags, each being a string that starts with one of the
|
||||
|
@ -176,19 +197,24 @@ class Journal(object):
|
|||
starred limits journal to starred entries
|
||||
|
||||
If strict is True, all tags must be present in an entry. If false, the
|
||||
entry is kept if any tag is present."""
|
||||
|
||||
exclude is a list of the tags which should not appear in the results.
|
||||
entry is kept if any tag is present, unless they appear in exclude."""
|
||||
self.search_tags = set([tag.lower() for tag in tags])
|
||||
excluded_tags = set([tag.lower() for tag in exclude])
|
||||
end_date = time.parse(end_date, inclusive=True)
|
||||
start_date = time.parse(start_date)
|
||||
|
||||
# If strict mode is on, all tags have to be present in entry
|
||||
tagged = self.search_tags.issubset if strict else self.search_tags.intersection
|
||||
excluded = lambda tags: len([tag for tag in tags if tag in excluded_tags]) > 0
|
||||
result = [
|
||||
entry for entry in self.entries
|
||||
if (not tags or tagged(entry.tags))
|
||||
and (not starred or entry.starred)
|
||||
and (not start_date or entry.date >= start_date)
|
||||
and (not end_date or entry.date <= end_date)
|
||||
and (not exclude or not excluded(entry.tags))
|
||||
]
|
||||
|
||||
self.entries = result
|
||||
|
@ -207,7 +233,11 @@ class Journal(object):
|
|||
if not date:
|
||||
colon_pos = first_line.find(": ")
|
||||
if colon_pos > 0:
|
||||
date = time.parse(raw[:colon_pos], default_hour=self.config['default_hour'], default_minute=self.config['default_minute'])
|
||||
date = time.parse(
|
||||
raw[:colon_pos],
|
||||
default_hour=self.config['default_hour'],
|
||||
default_minute=self.config['default_minute']
|
||||
)
|
||||
if date: # Parsed successfully, strip that from the raw text
|
||||
starred = raw[:colon_pos].strip().endswith("*")
|
||||
raw = raw[colon_pos + 1:].strip()
|
||||
|
@ -269,6 +299,7 @@ class LegacyJournal(Journal):
|
|||
# Initialise our current entry
|
||||
entries = []
|
||||
current_entry = None
|
||||
new_date_format_regex = re.compile(r'(^\[[^\]]+\].*?$)')
|
||||
for line in journal_txt.splitlines():
|
||||
line = line.rstrip()
|
||||
try:
|
||||
|
@ -288,7 +319,9 @@ class LegacyJournal(Journal):
|
|||
current_entry = Entry.Entry(self, date=new_date, text=line[date_length + 1:], starred=starred)
|
||||
except ValueError:
|
||||
# Happens when we can't parse the start of the line as an date.
|
||||
# In this case, just append line to our body.
|
||||
# In this case, just append line to our body (after some
|
||||
# escaping for the new format).
|
||||
line = new_date_format_regex.sub(r' \1', line)
|
||||
if current_entry:
|
||||
current_entry.text += line + u"\n"
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ jrnl is a simple journal application for your command line.
|
|||
from __future__ import absolute_import
|
||||
|
||||
__title__ = 'jrnl'
|
||||
__version__ = '2.0.0-rc1'
|
||||
__version__ = '2.0.0-rc3'
|
||||
__author__ = 'Manuel Ebert'
|
||||
__license__ = 'MIT License'
|
||||
__copyright__ = 'Copyright 2013 - 2015 Manuel Ebert'
|
||||
|
|
19
jrnl/cli.py
19
jrnl/cli.py
|
@ -13,7 +13,7 @@ from . import Journal
|
|||
from . import util
|
||||
from . import install
|
||||
from . import plugins
|
||||
from .util import ERROR_COLOR, RESET_COLOR
|
||||
from .util import ERROR_COLOR, RESET_COLOR, UserAbort
|
||||
import jrnl
|
||||
import argparse
|
||||
import sys
|
||||
|
@ -39,6 +39,7 @@ def parse_args(args=None):
|
|||
reading.add_argument('-and', dest='strict', action="store_true", help='Filter by tags using AND (default: OR)')
|
||||
reading.add_argument('-starred', dest='starred', action="store_true", help='Show only starred entries')
|
||||
reading.add_argument('-n', dest='limit', default=None, metavar="N", help="Shows the last n entries matching the filter. '-n 3' and '-3' have the same effect.", nargs="?", type=int)
|
||||
reading.add_argument('-not', dest='excluded', nargs='+', default=[], metavar="E", help="Exclude entries with these tags")
|
||||
|
||||
exporting = parser.add_argument_group('Export / Import', 'Options for transmogrifying your journal')
|
||||
exporting.add_argument('-s', '--short', dest='short', action="store_true", help='Show only titles or line containing the search tags')
|
||||
|
@ -143,7 +144,12 @@ def run(manual_args=None):
|
|||
print(util.py2encode(version_str))
|
||||
sys.exit(0)
|
||||
|
||||
config = install.load_or_install_jrnl()
|
||||
try:
|
||||
config = install.load_or_install_jrnl()
|
||||
except UserAbort as err:
|
||||
util.prompt("\n{}".format(err))
|
||||
sys.exit(1)
|
||||
|
||||
if args.ls:
|
||||
util.prnt(list_journals(config))
|
||||
sys.exit(0)
|
||||
|
@ -206,7 +212,11 @@ def run(manual_args=None):
|
|||
mode_compose = False
|
||||
|
||||
# This is where we finally open the journal!
|
||||
journal = Journal.open_journal(journal_name, config)
|
||||
try:
|
||||
journal = Journal.open_journal(journal_name, config)
|
||||
except KeyboardInterrupt:
|
||||
util.prompt("[Interrupted while opening journal]".format(journal_name))
|
||||
sys.exit(1)
|
||||
|
||||
# Import mode
|
||||
if mode_import:
|
||||
|
@ -230,7 +240,8 @@ def run(manual_args=None):
|
|||
start_date=args.start_date, end_date=args.end_date,
|
||||
strict=args.strict,
|
||||
short=args.short,
|
||||
starred=args.starred)
|
||||
starred=args.starred,
|
||||
exclude=args.excluded)
|
||||
journal.limit(args.limit)
|
||||
|
||||
# Reading mode
|
||||
|
|
|
@ -12,8 +12,10 @@ from . import upgrade
|
|||
from . import __version__
|
||||
from .Journal import PlainJournal
|
||||
from .EncryptedJournal import EncryptedJournal
|
||||
from .util import UserAbort
|
||||
import yaml
|
||||
import logging
|
||||
import sys
|
||||
|
||||
DEFAULT_CONFIG_NAME = 'jrnl.yaml'
|
||||
DEFAULT_JOURNAL_NAME = 'journal.txt'
|
||||
|
@ -85,12 +87,26 @@ def load_or_install_jrnl():
|
|||
if os.path.exists(config_path):
|
||||
log.debug('Reading configuration from file %s', config_path)
|
||||
config = util.load_config(config_path)
|
||||
upgrade.upgrade_jrnl_if_necessary(config_path)
|
||||
|
||||
try:
|
||||
upgrade.upgrade_jrnl_if_necessary(config_path)
|
||||
except upgrade.UpgradeValidationException:
|
||||
util.prompt("Aborting upgrade.")
|
||||
util.prompt("Please tell us about this problem at the following URL:")
|
||||
util.prompt("https://github.com/jrnl-org/jrnl/issues/new?title=UpgradeValidationException")
|
||||
util.prompt("Exiting.")
|
||||
sys.exit(1)
|
||||
|
||||
upgrade_config(config)
|
||||
|
||||
return config
|
||||
else:
|
||||
log.debug('Configuration file not found, installing jrnl...')
|
||||
return install()
|
||||
try:
|
||||
config = install()
|
||||
except KeyboardInterrupt:
|
||||
raise UserAbort("Installation aborted")
|
||||
return config
|
||||
|
||||
|
||||
def install():
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
from __future__ import absolute_import, unicode_literals, print_function
|
||||
from .text_exporter import TextExporter
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from ..util import WARNING_COLOR, RESET_COLOR
|
||||
|
@ -30,17 +31,17 @@ class MarkdownExporter(TextExporter):
|
|||
previous_line = ''
|
||||
warn_on_heading_level = False
|
||||
for line in body.splitlines(True):
|
||||
if re.match(r"#+ ", line):
|
||||
if re.match(r"^#+ ", line):
|
||||
"""ATX style headings"""
|
||||
newbody = newbody + previous_line + heading + line
|
||||
if re.match(r"#######+ ", heading + line):
|
||||
if re.match(r"^#######+ ", heading + line):
|
||||
warn_on_heading_level = True
|
||||
line = ''
|
||||
elif re.match(r"=+$", line) and not re.match(r"^$", previous_line):
|
||||
elif re.match(r"^=+$", line.rstrip()) and not re.match(r"^$", previous_line.strip()):
|
||||
"""Setext style H1"""
|
||||
newbody = newbody + heading + "# " + previous_line
|
||||
line = ''
|
||||
elif re.match(r"-+$", line) and not re.match(r"^$", previous_line):
|
||||
elif re.match(r"^-+$", line.rstrip()) and not re.match(r"^$", previous_line.strip()):
|
||||
"""Setext style H2"""
|
||||
newbody = newbody + heading + "## " + previous_line
|
||||
line = ''
|
||||
|
@ -68,12 +69,12 @@ class MarkdownExporter(TextExporter):
|
|||
for e in journal.entries:
|
||||
if not e.date.year == year:
|
||||
year = e.date.year
|
||||
out.append(str(year))
|
||||
out.append("=" * len(str(year)) + "\n")
|
||||
out.append("# " + str(year))
|
||||
out.append("")
|
||||
if not e.date.month == month:
|
||||
month = e.date.month
|
||||
out.append(e.date.strftime("%B"))
|
||||
out.append('-' * len(e.date.strftime("%B")) + "\n")
|
||||
out.append("## " + e.date.strftime("%B"))
|
||||
out.append("")
|
||||
out.append(cls.export_entry(e, False))
|
||||
result = "\n".join(out)
|
||||
return result
|
||||
|
|
|
@ -23,7 +23,7 @@ class Template(object):
|
|||
def from_file(cls, filename):
|
||||
with open(filename) as f:
|
||||
front_matter, body = f.read().strip("-\n").split("---", 2)
|
||||
front_matter = yaml.load(front_matter)
|
||||
front_matter = yaml.load(front_matter, Loader=yaml.FullLoader)
|
||||
template = cls(body)
|
||||
template.__dict__.update(front_matter)
|
||||
return template
|
||||
|
@ -39,7 +39,8 @@ class Template(object):
|
|||
return self._expand(self.blocks[block], **vars)
|
||||
|
||||
def _eval_context(self, vars):
|
||||
e = asteval.Interpreter(symtable=vars, use_numpy=False, writer=None)
|
||||
e = asteval.Interpreter(use_numpy=False, writer=None)
|
||||
e.symtable.update(vars)
|
||||
e.symtable['__last_iteration'] = vars.get("__last_iteration", False)
|
||||
return e
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
from __future__ import absolute_import, unicode_literals, print_function
|
||||
from .text_exporter import TextExporter
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from ..util import WARNING_COLOR, ERROR_COLOR, RESET_COLOR
|
||||
|
@ -34,17 +35,17 @@ class YAMLExporter(TextExporter):
|
|||
previous_line = ''
|
||||
warn_on_heading_level = False
|
||||
for line in entry.body.splitlines(True):
|
||||
if re.match(r"#+ ", line):
|
||||
if re.match(r"^#+ ", line):
|
||||
"""ATX style headings"""
|
||||
newbody = newbody + previous_line + heading + line
|
||||
if re.match(r"#######+ ", heading + line):
|
||||
if re.match(r"^#######+ ", heading + line):
|
||||
warn_on_heading_level = True
|
||||
line = ''
|
||||
elif re.match(r"=+$", line) and not re.match(r"^$", previous_line):
|
||||
elif re.match(r"^=+$", line.rstrip()) and not re.match(r"^$", previous_line.strip()):
|
||||
"""Setext style H1"""
|
||||
newbody = newbody + heading + "# " + previous_line
|
||||
line = ''
|
||||
elif re.match(r"-+$", line) and not re.match(r"^$", previous_line):
|
||||
elif re.match(r"^-+$", line.rstrip()) and not re.match(r"^$", previous_line.strip()):
|
||||
"""Setext style H2"""
|
||||
newbody = newbody + heading + "## " + previous_line
|
||||
line = ''
|
||||
|
|
|
@ -19,6 +19,10 @@ def parse(date_str, inclusive=False, default_hour=None, default_minute=None):
|
|||
elif isinstance(date_str, datetime):
|
||||
return date_str
|
||||
|
||||
# Don't try to parse anything with 6 or less characters. It's probably a markdown footnote
|
||||
if len(date_str) <= 6:
|
||||
return None
|
||||
|
||||
default_date = DEFAULT_FUTURE if inclusive else DEFAULT_PAST
|
||||
date = None
|
||||
year_present = False
|
||||
|
|
|
@ -4,7 +4,7 @@ from . import __version__
|
|||
from . import Journal
|
||||
from . import util
|
||||
from .EncryptedJournal import EncryptedJournal
|
||||
import sys
|
||||
from .util import UserAbort
|
||||
import os
|
||||
import codecs
|
||||
|
||||
|
@ -44,6 +44,7 @@ older versions of jrnl anymore.
|
|||
encrypted_journals = {}
|
||||
plain_journals = {}
|
||||
other_journals = {}
|
||||
all_journals = []
|
||||
|
||||
for journal_name, journal_conf in config['journals'].items():
|
||||
if isinstance(journal_conf, dict):
|
||||
|
@ -76,28 +77,44 @@ older versions of jrnl anymore.
|
|||
for journal, path in other_journals.items():
|
||||
util.prompt(" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name))
|
||||
|
||||
cont = util.yesno("\nContinue upgrading jrnl?", default=False)
|
||||
if not cont:
|
||||
util.prompt("jrnl NOT upgraded, exiting.")
|
||||
sys.exit(1)
|
||||
try:
|
||||
cont = util.yesno("\nContinue upgrading jrnl?", default=False)
|
||||
if not cont:
|
||||
raise KeyboardInterrupt
|
||||
except KeyboardInterrupt:
|
||||
raise UserAbort("jrnl NOT upgraded, exiting.")
|
||||
|
||||
for journal_name, path in encrypted_journals.items():
|
||||
util.prompt("\nUpgrading encrypted '{}' journal stored in {}...".format(journal_name, path))
|
||||
backup(path, binary=True)
|
||||
old_journal = Journal.open_journal(journal_name, util.scope_config(config, journal_name), legacy=True)
|
||||
new_journal = EncryptedJournal.from_journal(old_journal)
|
||||
new_journal.write()
|
||||
util.prompt(" Done.")
|
||||
all_journals.append(EncryptedJournal.from_journal(old_journal))
|
||||
|
||||
for journal_name, path in plain_journals.items():
|
||||
util.prompt("\nUpgrading plain text '{}' journal stored in {}...".format(journal_name, path))
|
||||
backup(path)
|
||||
old_journal = Journal.open_journal(journal_name, util.scope_config(config, journal_name), legacy=True)
|
||||
new_journal = Journal.PlainJournal.from_journal(old_journal)
|
||||
new_journal.write()
|
||||
util.prompt(" Done.")
|
||||
all_journals.append(Journal.PlainJournal.from_journal(old_journal))
|
||||
|
||||
# loop through lists to validate
|
||||
failed_journals = [j for j in all_journals if not j.validate_parsing()]
|
||||
|
||||
if len(failed_journals) > 0:
|
||||
util.prompt("\nThe following journal{} failed to upgrade:\n{}".format(
|
||||
's' if len(failed_journals) > 1 else '', "\n".join(j.name for j in failed_journals))
|
||||
)
|
||||
|
||||
raise UpgradeValidationException
|
||||
|
||||
# write all journals - or - don't
|
||||
for j in all_journals:
|
||||
j.write()
|
||||
|
||||
util.prompt("\nUpgrading config...")
|
||||
backup(config_path)
|
||||
|
||||
util.prompt("\nWe're all done here and you can start enjoying jrnl 2.".format(config_path))
|
||||
|
||||
class UpgradeValidationException(Exception):
|
||||
"""Raised when the contents of an upgraded journal do not match the old journal"""
|
||||
pass
|
||||
|
|
11
jrnl/util.py
11
jrnl/util.py
|
@ -47,6 +47,10 @@ SENTENCE_SPLITTER = re.compile(r"""
|
|||
)""", re.UNICODE | re.VERBOSE)
|
||||
|
||||
|
||||
class UserAbort(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def getpass(prompt="Password: "):
|
||||
if not TEST:
|
||||
return gp.getpass(bytes(prompt))
|
||||
|
@ -141,7 +145,7 @@ def load_config(config_path):
|
|||
"""Tries to load a config file from YAML.
|
||||
"""
|
||||
with open(config_path) as f:
|
||||
return yaml.load(f)
|
||||
return yaml.load(f, Loader=yaml.FullLoader)
|
||||
|
||||
|
||||
def scope_config(config, journal_name):
|
||||
|
@ -163,7 +167,10 @@ def get_text_from_editor(config, template=""):
|
|||
with codecs.open(tmpfile, 'w', "utf-8") as f:
|
||||
if template:
|
||||
f.write(template)
|
||||
subprocess.call(shlex.split(config['editor'], posix="win" not in sys.platform) + [tmpfile])
|
||||
try:
|
||||
subprocess.call(shlex.split(config['editor'], posix="win" not in sys.platform) + [tmpfile])
|
||||
except AttributeError:
|
||||
subprocess.call(config['editor'] + [tmpfile])
|
||||
with codecs.open(tmpfile, "r", "utf-8") as f:
|
||||
raw = f.read()
|
||||
os.close(filehandle)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue