Merge branch 'develop' into v2.5

This commit is contained in:
Jonathan Wren 2020-02-08 14:29:10 -08:00 committed by GitHub
commit 97cf65e516
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
75 changed files with 2578 additions and 1204 deletions

View file

@ -1,7 +1,5 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import, unicode_literals
from . import Entry
from . import Journal
from . import time as jrnl_time
@ -21,36 +19,55 @@ class DayOne(Journal.Journal):
"""A special Journal handling DayOne files"""
# InvalidFileException was added to plistlib in Python3.4
PLIST_EXCEPTIONS = (ExpatError, plistlib.InvalidFileException) if hasattr(plistlib, "InvalidFileException") else ExpatError
PLIST_EXCEPTIONS = (
(ExpatError, plistlib.InvalidFileException)
if hasattr(plistlib, "InvalidFileException")
else ExpatError
)
def __init__(self, **kwargs):
self.entries = []
self._deleted_entries = []
super(DayOne, self).__init__(**kwargs)
super().__init__(**kwargs)
def open(self):
filenames = [os.path.join(self.config['journal'], "entries", f) for f in os.listdir(os.path.join(self.config['journal'], "entries"))]
filenames = [
os.path.join(self.config["journal"], "entries", f)
for f in os.listdir(os.path.join(self.config["journal"], "entries"))
]
filenames = []
for root, dirnames, f in os.walk(self.config['journal']):
for filename in fnmatch.filter(f, '*.doentry'):
for root, dirnames, f in os.walk(self.config["journal"]):
for filename in fnmatch.filter(f, "*.doentry"):
filenames.append(os.path.join(root, filename))
self.entries = []
for filename in filenames:
with open(filename, 'rb') as plist_entry:
with open(filename, "rb") as plist_entry:
try:
dict_entry = plistlib.readPlist(plist_entry)
except self.PLIST_EXCEPTIONS:
pass
else:
try:
timezone = pytz.timezone(dict_entry['Time Zone'])
timezone = pytz.timezone(dict_entry["Time Zone"])
except (KeyError, pytz.exceptions.UnknownTimeZoneError):
timezone = tzlocal.get_localzone()
date = dict_entry['Creation Date']
date = date + timezone.utcoffset(date, is_dst=False)
entry = Entry.Entry(self, date, text=dict_entry['Entry Text'], starred=dict_entry["Starred"])
date = dict_entry["Creation Date"]
# convert the date to UTC rather than keep messing with
# timezones
if timezone.zone != "UTC":
date = date + timezone.utcoffset(date, is_dst=False)
entry = Entry.Entry(
self,
date,
text=dict_entry["Entry Text"],
starred=dict_entry["Starred"],
)
entry.uuid = dict_entry["UUID"]
entry._tags = [self.config['tagsymbols'][0] + tag.lower() for tag in dict_entry.get("Tags", [])]
entry._tags = [
self.config["tagsymbols"][0] + tag.lower()
for tag in dict_entry.get("Tags", [])
]
self.entries.append(entry)
self.sort()
@ -60,30 +77,39 @@ class DayOne(Journal.Journal):
"""Writes only the entries that have been modified into plist files."""
for entry in self.entries:
if entry.modified:
utc_time = datetime.utcfromtimestamp(time.mktime(entry.date.timetuple()))
utc_time = datetime.utcfromtimestamp(
time.mktime(entry.date.timetuple())
)
if not hasattr(entry, "uuid"):
entry.uuid = uuid.uuid1().hex
filename = os.path.join(self.config['journal'], "entries", entry.uuid.upper() + ".doentry")
filename = os.path.join(
self.config["journal"], "entries", entry.uuid.upper() + ".doentry"
)
entry_plist = {
'Creation Date': utc_time,
'Starred': entry.starred if hasattr(entry, 'starred') else False,
'Entry Text': entry.title + "\n" + entry.body,
'Time Zone': str(tzlocal.get_localzone()),
'UUID': entry.uuid.upper(),
'Tags': [tag.strip(self.config['tagsymbols']).replace("_", " ") for tag in entry.tags]
"Creation Date": utc_time,
"Starred": entry.starred if hasattr(entry, "starred") else False,
"Entry Text": entry.title + "\n" + entry.body,
"Time Zone": str(tzlocal.get_localzone()),
"UUID": entry.uuid.upper(),
"Tags": [
tag.strip(self.config["tagsymbols"]).replace("_", " ")
for tag in entry.tags
],
}
plistlib.writePlist(entry_plist, filename)
for entry in self._deleted_entries:
filename = os.path.join(self.config['journal'], "entries", entry.uuid + ".doentry")
filename = os.path.join(
self.config["journal"], "entries", entry.uuid + ".doentry"
)
os.remove(filename)
def editable_str(self):
"""Turns the journal into a string of entries that can be edited
manually and later be parsed with eslf.parse_editable_str."""
return "\n".join(["# {0}\n{1}".format(e.uuid, e.__unicode__()) for e in self.entries])
return "\n".join([f"# {e.uuid}\n{str(e)}" for e in self.entries])
def parse_editable_str(self, edited):
"""Parses the output of self.editable_str and updates its entries."""
@ -107,7 +133,7 @@ class DayOne(Journal.Journal):
current_entry.modified = False
current_entry.uuid = m.group(1).lower()
else:
date_blob_re = re.compile("^\[[^\\]]+\] ")
date_blob_re = re.compile("^\\[[^\\]]+\\] ")
date_blob = date_blob_re.findall(line)
if date_blob:
date_blob = date_blob[0]
@ -115,7 +141,7 @@ class DayOne(Journal.Journal):
if line.endswith("*"):
current_entry.starred = True
line = line[:-1]
current_entry.title = line[len(date_blob) - 1:]
current_entry.title = line[len(date_blob) - 1 :]
current_entry.date = new_date
elif current_entry:
current_entry.body += line + "\n"

View file

@ -1,4 +1,5 @@
from . import Journal, util
from . import util
from .Journal import Journal, LegacyJournal
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes, padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
@ -8,120 +9,124 @@ from cryptography.hazmat.backends import default_backend
import sys
import os
import base64
import getpass
import logging
from typing import Optional
log = logging.getLogger()
def make_key(password):
password = util.bytes(password)
password = password.encode("utf-8")
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
# Salt is hard-coded
salt=b'\xf2\xd5q\x0e\xc1\x8d.\xde\xdc\x8e6t\x89\x04\xce\xf8',
salt=b"\xf2\xd5q\x0e\xc1\x8d.\xde\xdc\x8e6t\x89\x04\xce\xf8",
iterations=100000,
backend=default_backend()
backend=default_backend(),
)
key = kdf.derive(password)
return base64.urlsafe_b64encode(key)
class EncryptedJournal(Journal.Journal):
def __init__(self, name='default', **kwargs):
super(EncryptedJournal, self).__init__(name, **kwargs)
self.config['encrypt'] = True
class EncryptedJournal(Journal):
def __init__(self, name="default", **kwargs):
super().__init__(name, **kwargs)
self.config["encrypt"] = True
self.password = None
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']
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.create_file(filename)
self.password = util.create_password(self.name)
print(
f"Encrypted journal '{self.name}' created at {filename}",
file=sys.stderr,
)
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):
def _load(self, filename):
"""Loads an encrypted journal from a file and tries to decrypt it.
If password is not provided, will look for password in the keychain
and otherwise ask the user to enter a password up to three times.
If the password is provided but wrong (or corrupt), this will simply
return None."""
with open(filename, 'rb') as f:
with open(filename, "rb") as f:
journal_encrypted = f.read()
def validate_password(password):
def decrypt_journal(password):
key = make_key(password)
try:
plain = Fernet(key).decrypt(journal_encrypted).decode('utf-8')
self.config['password'] = password
plain = Fernet(key).decrypt(journal_encrypted).decode("utf-8")
self.password = password
return plain
except (InvalidToken, IndexError):
return None
if password:
return validate_password(password)
return util.get_password(keychain=self.name, validator=validate_password)
if self.password:
return decrypt_journal(self.password)
return util.decrypt_content(keychain=self.name, decrypt_func=decrypt_journal)
def _store(self, filename, text):
key = make_key(self.config['password'])
journal = Fernet(key).encrypt(text.encode('utf-8'))
with open(filename, 'wb') as f:
key = make_key(self.password)
journal = Fernet(key).encrypt(text.encode("utf-8"))
with open(filename, "wb") as f:
f.write(journal)
@classmethod
def _create(cls, filename, password):
key = make_key(password)
dummy = Fernet(key).encrypt(b"")
with open(filename, 'wb') as f:
f.write(dummy)
def from_journal(cls, other: Journal):
new_journal = super().from_journal(other)
new_journal.password = (
other.password
if hasattr(other, "password")
else util.create_password(other.name)
)
return new_journal
class LegacyEncryptedJournal(Journal.LegacyJournal):
class LegacyEncryptedJournal(LegacyJournal):
"""Legacy class to support opening journals encrypted with the jrnl 1.x
standard. You'll not be able to save these journals anymore."""
def __init__(self, name='default', **kwargs):
super(LegacyEncryptedJournal, self).__init__(name, **kwargs)
self.config['encrypt'] = True
def _load(self, filename, password=None):
with open(filename, 'rb') as f:
def __init__(self, name="default", **kwargs):
super().__init__(name, **kwargs)
self.config["encrypt"] = True
self.password = None
def _load(self, filename):
with open(filename, "rb") as f:
journal_encrypted = f.read()
iv, cipher = journal_encrypted[:16], journal_encrypted[16:]
def validate_password(password):
decryption_key = hashlib.sha256(password.encode('utf-8')).digest()
decryptor = Cipher(algorithms.AES(decryption_key), modes.CBC(iv), default_backend()).decryptor()
def decrypt_journal(password):
decryption_key = hashlib.sha256(password.encode("utf-8")).digest()
decryptor = Cipher(
algorithms.AES(decryption_key), modes.CBC(iv), default_backend()
).decryptor()
try:
plain_padded = decryptor.update(cipher) + decryptor.finalize()
self.config['password'] = password
self.password = password
if plain_padded[-1] in (" ", 32):
# Ancient versions of jrnl. Do not judge me.
return plain_padded.decode('utf-8').rstrip(" ")
return plain_padded.decode("utf-8").rstrip(" ")
else:
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
plain = unpadder.update(plain_padded) + unpadder.finalize()
return plain.decode('utf-8')
return plain.decode("utf-8")
except ValueError:
return None
if password:
return validate_password(password)
return util.get_password(keychain=self.name, validator=validate_password)
if self.password:
return decrypt_journal(self.password)
return util.decrypt_content(keychain=self.name, decrypt_func=decrypt_journal)

View file

@ -1,7 +1,5 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import unicode_literals
import re
import textwrap
from datetime import datetime
@ -24,7 +22,7 @@ class Entry:
def _parse_text(self):
raw_text = self.text
lines = raw_text.splitlines()
if lines[0].strip().endswith("*"):
if lines and lines[0].strip().endswith("*"):
self.starred = True
raw_text = lines[0].strip("\n *") + "\n" + "\n".join(lines[1:])
self._title, self._body = split_title(raw_text)
@ -51,72 +49,84 @@ class Entry:
@staticmethod
def tag_regex(tagsymbols):
pattern = r'(?u)(?:^|\s)([{tags}][-+*#/\w]+)'.format(tags=tagsymbols)
return re.compile(pattern, re.UNICODE)
pattern = fr"(?u)(?:^|\s)([{tagsymbols}][-+*#/\w]+)"
return re.compile(pattern)
def _parse_tags(self):
tagsymbols = self.journal.config['tagsymbols']
return set(tag.lower() for tag in re.findall(Entry.tag_regex(tagsymbols), self.text))
tagsymbols = self.journal.config["tagsymbols"]
return {
tag.lower() for tag in re.findall(Entry.tag_regex(tagsymbols), self.text)
}
def __unicode__(self):
def __str__(self):
"""Returns a string representation of the entry to be written into a journal file."""
date_str = self.date.strftime(self.journal.config['timeformat'])
date_str = self.date.strftime(self.journal.config["timeformat"])
title = "[{}] {}".format(date_str, self.title.rstrip("\n "))
if self.starred:
title += " *"
return "{title}{sep}{body}\n".format(
title=title,
sep="\n" if self.body.rstrip("\n ") else "",
body=self.body.rstrip("\n ")
body=self.body.rstrip("\n "),
)
def pprint(self, short=False):
"""Returns a pretty-printed version of the entry.
If short is true, only print the title."""
date_str = self.date.strftime(self.journal.config['timeformat'])
if self.journal.config['indent_character']:
indent = self.journal.config['indent_character'].rstrip() + " "
date_str = self.date.strftime(self.journal.config["timeformat"])
if self.journal.config["indent_character"]:
indent = self.journal.config["indent_character"].rstrip() + " "
else:
indent = ""
if not short and self.journal.config['linewrap']:
title = textwrap.fill(date_str + " " + self.title, self.journal.config['linewrap'])
body = "\n".join([
textwrap.fill(
line,
self.journal.config['linewrap'],
initial_indent=indent,
subsequent_indent=indent,
drop_whitespace=True) or indent
for line in self.body.rstrip(" \n").splitlines()
])
if not short and self.journal.config["linewrap"]:
title = textwrap.fill(
date_str + " " + self.title, self.journal.config["linewrap"]
)
body = "\n".join(
[
textwrap.fill(
line,
self.journal.config["linewrap"],
initial_indent=indent,
subsequent_indent=indent,
drop_whitespace=True,
)
or indent
for line in self.body.rstrip(" \n").splitlines()
]
)
else:
title = date_str + " " + self.title.rstrip("\n ")
body = self.body.rstrip("\n ")
# Suppress bodies that are just blanks and new lines.
has_body = len(self.body) > 20 or not all(char in (" ", "\n") for char in self.body)
has_body = len(self.body) > 20 or not all(
char in (" ", "\n") for char in self.body
)
if short:
return title
else:
return "{title}{sep}{body}\n".format(
title=title,
sep="\n" if has_body else "",
body=body if has_body else "",
title=title, sep="\n" if has_body else "", body=body if has_body else ""
)
def __repr__(self):
return "<Entry '{0}' on {1}>".format(self.title.strip(), self.date.strftime("%Y-%m-%d %H:%M"))
return "<Entry '{}' on {}>".format(
self.title.strip(), self.date.strftime("%Y-%m-%d %H:%M")
)
def __hash__(self):
return hash(self.__repr__())
def __eq__(self, other):
if not isinstance(other, Entry) \
or self.title.strip() != other.title.strip() \
or self.body.rstrip() != other.body.rstrip() \
or self.date != other.date \
or self.starred != other.starred:
if (
not isinstance(other, Entry)
or self.title.strip() != other.title.strip()
or self.body.rstrip() != other.body.rstrip()
or self.date != other.date
or self.starred != other.starred
):
return False
return True

View file

@ -1,13 +1,10 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import, unicode_literals
from . import Entry
from . import util
from . import time
import os
import sys
import codecs
import re
from datetime import datetime
import logging
@ -15,7 +12,7 @@ import logging
log = logging.getLogger(__name__)
class Tag(object):
class Tag:
def __init__(self, name, count=0):
self.name = name
self.count = count
@ -24,26 +21,27 @@ class Tag(object):
return self.name
def __repr__(self):
return "<Tag '{}'>".format(self.name)
return f"<Tag '{self.name}'>"
class Journal(object):
def __init__(self, name='default', **kwargs):
class Journal:
def __init__(self, name="default", **kwargs):
self.config = {
'journal': "journal.txt",
'encrypt': False,
'default_hour': 9,
'default_minute': 0,
'timeformat': "%Y-%m-%d %H:%M",
'tagsymbols': '@',
'highlight': True,
'linewrap': 80,
'indent_character': '|',
"journal": "journal.txt",
"encrypt": False,
"default_hour": 9,
"default_minute": 0,
"timeformat": "%Y-%m-%d %H:%M",
"tagsymbols": "@",
"highlight": True,
"linewrap": 80,
"indent_character": "|",
}
self.config.update(kwargs)
# Set up date parser
self.search_tags = None # Store tags we're highlighting
self.name = name
self.entries = []
def __len__(self):
"""Returns the number of entries"""
@ -59,21 +57,28 @@ class Journal(object):
another journal object"""
new_journal = cls(other.name, **other.config)
new_journal.entries = other.entries
log.debug("Imported %d entries from %s to %s", len(new_journal), other.__class__.__name__, cls.__name__)
log.debug(
"Imported %d entries from %s to %s",
len(new_journal),
other.__class__.__name__,
cls.__name__,
)
return new_journal
def import_(self, other_journal_txt):
self.entries = list(frozenset(self.entries) | frozenset(self._parse(other_journal_txt)))
self.entries = list(
frozenset(self.entries) | frozenset(self._parse(other_journal_txt))
)
self.sort()
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']
filename = filename or self.config["journal"]
if not os.path.exists(filename):
util.prompt("[Journal '{0}' created at {1}]".format(self.name, filename))
self._create(filename)
self.create_file(filename)
print(f"[Journal '{self.name}' created at {filename}]", file=sys.stderr)
text = self._load(filename)
self.entries = self._parse(text)
@ -83,7 +88,7 @@ class Journal(object):
def write(self, filename=None):
"""Dumps the journal into the config file, overwriting it"""
filename = filename or self.config['journal']
filename = filename or self.config["journal"]
text = self._to_text()
self._store(filename, text)
@ -95,8 +100,13 @@ class Journal(object):
return False
return True
@staticmethod
def create_file(filename):
with open(filename, "w"):
pass
def _to_text(self):
return "\n".join([e.__unicode__() for e in self.entries])
return "\n".join([str(e) for e in self.entries])
def _load(self, filename):
raise NotImplementedError
@ -104,10 +114,6 @@ class Journal(object):
def _store(self, filename, text):
raise NotImplementedError
@classmethod
def _create(cls, filename):
raise NotImplementedError
def _parse(self, journal_txt):
"""Parses a journal that's stored in a string and returns a list of entries"""
@ -118,14 +124,19 @@ class Journal(object):
# Initialise our current entry
entries = []
date_blob_re = re.compile("(?:^|\n)\[([^\\]]+)\] ")
date_blob_re = re.compile("(?:^|\n)\\[([^\\]]+)\\] ")
last_entry_pos = 0
for match in date_blob_re.finditer(journal_txt):
date_blob = match.groups()[0]
new_date = time.parse(date_blob)
try:
new_date = datetime.strptime(date_blob, self.config["timeformat"])
except ValueError:
# Passing in a date that had brackets around it
new_date = time.parse(date_blob, bracketed=True)
if new_date:
if entries:
entries[-1].text = journal_txt[last_entry_pos:match.start()]
entries[-1].text = journal_txt[last_entry_pos : match.start()]
last_entry_pos = match.end()
entries.append(Entry.Entry(self, date=new_date))
@ -140,30 +151,28 @@ class Journal(object):
entry._parse_text()
return entries
def __unicode__(self):
return self.pprint()
def pprint(self, short=False):
"""Prettyprints the journal's entries"""
sep = "\n"
pp = sep.join([e.pprint(short=short) for e in self.entries])
if self.config['highlight']: # highlight tags
if self.config["highlight"]: # highlight tags
if self.search_tags:
for tag in self.search_tags:
tagre = re.compile(re.escape(tag), re.IGNORECASE)
pp = re.sub(tagre,
lambda match: util.colorize(match.group(0)),
pp, re.UNICODE)
pp = re.sub(tagre, lambda match: util.colorize(match.group(0)), pp)
else:
pp = re.sub(
Entry.Entry.tag_regex(self.config['tagsymbols']),
Entry.Entry.tag_regex(self.config["tagsymbols"]),
lambda match: util.colorize(match.group(0)),
pp
pp,
)
return pp
def __str__(self):
return self.pprint()
def __repr__(self):
return "<Journal with {0} entries>".format(len(self.entries))
return f"<Journal with {len(self.entries)} entries>"
def sort(self):
"""Sorts the Journal's entries by date"""
@ -179,14 +188,22 @@ class Journal(object):
"""Returns a set of tuples (count, tag) for all tags present in the journal."""
# Astute reader: should the following line leave you as puzzled as me the first time
# I came across this construction, worry not and embrace the ensuing moment of enlightment.
tags = [tag
for entry in self.entries
for tag in set(entry.tags)]
tags = [tag for entry in self.entries for tag in set(entry.tags)]
# To be read: [for entry in journal.entries: for tag in set(entry.tags): tag]
tag_counts = set([(tags.count(tag), tag) for tag in tags])
tag_counts = {(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, exclude=[]):
def filter(
self,
tags=[],
start_date=None,
end_date=None,
starred=False,
strict=False,
short=False,
contains=None,
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
@ -200,21 +217,32 @@ class Journal(object):
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])
self.search_tags = {tag.lower() for tag in tags}
excluded_tags = {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
if contains:
contains_lower = contains.casefold()
result = [
entry for entry in self.entries
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))
and (
not contains
or (
contains_lower in entry.title.casefold()
or contains_lower in entry.body.casefold()
)
)
]
self.entries = result
@ -223,11 +251,11 @@ class Journal(object):
"""Constructs a new entry from some raw text input.
If a date is given, it will parse and use this, otherwise scan for a date in the input first."""
raw = raw.replace('\\n ', '\n').replace('\\n', '\n')
raw = raw.replace("\\n ", "\n").replace("\\n", "\n")
starred = False
# Split raw text into title and body
sep = re.search("\n|[\?!.]+ +\n?", raw)
first_line = raw[:sep.end()].strip() if sep else raw
sep = re.search(r"\n|[?!.]+ +\n?", raw)
first_line = raw[: sep.end()].strip() if sep else raw
starred = False
if not date:
@ -235,12 +263,12 @@ class Journal(object):
if colon_pos > 0:
date = time.parse(
raw[:colon_pos],
default_hour=self.config['default_hour'],
default_minute=self.config['default_minute']
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()
raw = raw[colon_pos + 1 :].strip()
starred = starred or first_line.startswith("*") or first_line.endswith("*")
if not date: # Still nothing? Meh, just live in the moment.
date = time.parse("now")
@ -254,7 +282,7 @@ class Journal(object):
def editable_str(self):
"""Turns the journal into a string of entries that can be edited
manually and later be parsed with eslf.parse_editable_str."""
return "\n".join([e.__unicode__() for e in self.entries])
return "\n".join([str(e) for e in self.entries])
def parse_editable_str(self, edited):
"""Parses the output of self.editable_str and updates it's entries."""
@ -268,17 +296,12 @@ class Journal(object):
class PlainJournal(Journal):
@classmethod
def _create(cls, filename):
with codecs.open(filename, "a", "utf-8"):
pass
def _load(self, filename):
with codecs.open(filename, "r", "utf-8") as f:
with open(filename, "r", encoding="utf-8") as f:
return f.read()
def _store(self, filename, text):
with codecs.open(filename, 'w', "utf-8") as f:
with open(filename, "w", encoding="utf-8") as f:
f.write(text)
@ -286,25 +309,28 @@ class LegacyJournal(Journal):
"""Legacy class to support opening journals formatted with the jrnl 1.x
standard. Main difference here is that in 1.x, timestamps were not cuddled
by square brackets. You'll not be able to save these journals anymore."""
def _load(self, filename):
with codecs.open(filename, "r", "utf-8") as f:
with open(filename, "r", encoding="utf-8") as f:
return f.read()
def _parse(self, journal_txt):
"""Parses a journal that's stored in a string and returns a list of entries"""
# Entries start with a line that looks like 'date title' - let's figure out how
# long the date will be by constructing one
date_length = len(datetime.today().strftime(self.config['timeformat']))
date_length = len(datetime.today().strftime(self.config["timeformat"]))
# Initialise our current entry
entries = []
current_entry = None
new_date_format_regex = re.compile(r'(^\[[^\]]+\].*?$)')
new_date_format_regex = re.compile(r"(^\[[^\]]+\].*?$)")
for line in journal_txt.splitlines():
line = line.rstrip()
try:
# try to parse line as date => new entry begins
new_date = datetime.strptime(line[:date_length], self.config['timeformat'])
new_date = datetime.strptime(
line[:date_length], self.config["timeformat"]
)
# parsing successful => save old entry and create new one
if new_date and current_entry:
@ -316,14 +342,16 @@ class LegacyJournal(Journal):
else:
starred = False
current_entry = Entry.Entry(self, date=new_date, text=line[date_length + 1:], starred=starred)
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 (after some
# escaping for the new format).
line = new_date_format_regex.sub(r' \1', line)
line = new_date_format_regex.sub(r" \1", line)
if current_entry:
current_entry.text += line + u"\n"
current_entry.text += line + "\n"
# Append last entry
if current_entry:
@ -340,22 +368,26 @@ def open_journal(name, config, legacy=False):
backwards compatibility with jrnl 1.x
"""
config = config.copy()
config['journal'] = os.path.expanduser(os.path.expandvars(config['journal']))
config["journal"] = os.path.expanduser(os.path.expandvars(config["journal"]))
if os.path.isdir(config['journal']):
if config['journal'].strip("/").endswith(".dayone") or "entries" in os.listdir(config['journal']):
if os.path.isdir(config["journal"]):
if config["journal"].strip("/").endswith(".dayone") or "entries" in os.listdir(
config["journal"]
):
from . import DayOneJournal
return DayOneJournal.DayOne(**config).open()
else:
from . import FolderJournal
return FolderJournal.Folder(**config).open()
if not config['encrypt']:
if not config["encrypt"]:
if legacy:
return LegacyJournal(name, **config).open()
return PlainJournal(name, **config).open()
else:
from . import EncryptedJournal
if legacy:
return EncryptedJournal.LegacyEncryptedJournal(name, **config).open()
return EncryptedJournal.EncryptedJournal(name, **config).open()

View file

@ -1,9 +1,9 @@
#!/usr/bin/env python
# encoding: utf-8
import pkg_resources
dist = pkg_resources.get_distribution('jrnl')
__title__ = dist.project_name
__version__ = dist.version
import os
try:
from .__version__ import __version__
except ImportError:
__version__ = "source"
__title__ = "jrnl"

View file

@ -1,6 +1,4 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import, unicode_literals
from . import cli

1
jrnl/__version__.py Normal file
View file

@ -0,0 +1 @@
__version__ = "v2.2-beta"

View file

@ -1,5 +1,4 @@
#!/usr/bin/env python
# encoding: utf-8
"""
jrnl
@ -7,9 +6,8 @@
license: MIT, see LICENSE for more details.
"""
from __future__ import unicode_literals
from __future__ import absolute_import
from . import Journal
from .Journal import PlainJournal, open_journal
from .EncryptedJournal import EncryptedJournal
from . import util
from . import install
from . import plugins
@ -25,32 +23,155 @@ logging.getLogger("keyring.backend").setLevel(logging.ERROR)
def parse_args(args=None):
parser = argparse.ArgumentParser()
parser.add_argument('-v', '--version', dest='version', action="store_true", help="prints version information and exits")
parser.add_argument('-ls', dest='ls', action="store_true", help="displays accessible journals")
parser.add_argument('-d', '--debug', dest='debug', action='store_true', help='execute in debug mode')
parser.add_argument(
"-v",
"--version",
dest="version",
action="store_true",
help="prints version information and exits",
)
parser.add_argument(
"-ls", dest="ls", action="store_true", help="displays accessible journals"
)
parser.add_argument(
"-d", "--debug", dest="debug", action="store_true", help="execute in debug mode"
)
composing = parser.add_argument_group('Composing', 'To write an entry simply write it on the command line, e.g. "jrnl yesterday at 1pm: Went to the gym."')
composing.add_argument('text', metavar='', nargs="*")
composing = parser.add_argument_group(
"Composing",
'To write an entry simply write it on the command line, e.g. "jrnl yesterday at 1pm: Went to the gym."',
)
composing.add_argument("text", metavar="", nargs="*")
reading = parser.add_argument_group('Reading', 'Specifying either of these parameters will display posts of your journal')
reading.add_argument('-from', dest='start_date', metavar="DATE", help='View entries after this date')
reading.add_argument('-until', '-to', dest='end_date', metavar="DATE", help='View entries before this date')
reading.add_argument('-on', dest='on_date', metavar="DATE", help='View entries on this date')
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")
reading = parser.add_argument_group(
"Reading",
"Specifying either of these parameters will display posts of your journal",
)
reading.add_argument(
"-from", dest="start_date", metavar="DATE", help="View entries after this date"
)
reading.add_argument(
"-until",
"-to",
dest="end_date",
metavar="DATE",
help="View entries before this date",
)
reading.add_argument(
"-contains", dest="contains", help="View entries containing a specific string"
)
reading.add_argument(
"-on", dest="on_date", metavar="DATE", help="View entries on this date"
)
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')
exporting.add_argument('--tags', dest='tags', action="store_true", help='Returns a list of all tags and number of occurences')
exporting.add_argument('--export', metavar='TYPE', dest='export', choices=plugins.EXPORT_FORMATS, help='Export your journal. TYPE can be {}.'.format(plugins.util.oxford_list(plugins.EXPORT_FORMATS)), default=False, const=None)
exporting.add_argument('-o', metavar='OUTPUT', dest='output', help='Optionally specifies output file when using --export. If OUTPUT is a directory, exports each entry into an individual file instead.', default=False, const=None)
exporting.add_argument('--import', metavar='TYPE', dest='import_', choices=plugins.IMPORT_FORMATS, help='Import entries into your journal. TYPE can be {}, and it defaults to jrnl if nothing else is specified.'.format(plugins.util.oxford_list(plugins.IMPORT_FORMATS)), default=False, const='jrnl', nargs='?')
exporting.add_argument('-i', metavar='INPUT', dest='input', help='Optionally specifies input file when using --import.', default=False, const=None)
exporting.add_argument('--encrypt', metavar='FILENAME', dest='encrypt', help='Encrypts your existing journal with a new password', nargs='?', default=False, const=None)
exporting.add_argument('--decrypt', metavar='FILENAME', dest='decrypt', help='Decrypts your journal and stores it in plain text', nargs='?', default=False, const=None)
exporting.add_argument('--edit', dest='edit', help='Opens your editor to edit the selected entries.', action="store_true")
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",
)
exporting.add_argument(
"--tags",
dest="tags",
action="store_true",
help="Returns a list of all tags and number of occurences",
)
exporting.add_argument(
"--export",
metavar="TYPE",
dest="export",
choices=plugins.EXPORT_FORMATS,
help="Export your journal. TYPE can be {}.".format(
plugins.util.oxford_list(plugins.EXPORT_FORMATS)
),
default=False,
const=None,
)
exporting.add_argument(
"-o",
metavar="OUTPUT",
dest="output",
help="Optionally specifies output file when using --export. If OUTPUT is a directory, exports each entry into an individual file instead.",
default=False,
const=None,
)
exporting.add_argument(
"--import",
metavar="TYPE",
dest="import_",
choices=plugins.IMPORT_FORMATS,
help="Import entries into your journal. TYPE can be {}, and it defaults to jrnl if nothing else is specified.".format(
plugins.util.oxford_list(plugins.IMPORT_FORMATS)
),
default=False,
const="jrnl",
nargs="?",
)
exporting.add_argument(
"-i",
metavar="INPUT",
dest="input",
help="Optionally specifies input file when using --import.",
default=False,
const=None,
)
exporting.add_argument(
"--encrypt",
metavar="FILENAME",
dest="encrypt",
help="Encrypts your existing journal with a new password",
nargs="?",
default=False,
const=None,
)
exporting.add_argument(
"--decrypt",
metavar="FILENAME",
dest="decrypt",
help="Decrypts your journal and stores it in plain text",
nargs="?",
default=False,
const=None,
)
exporting.add_argument(
"--edit",
dest="edit",
help="Opens your editor to edit the selected entries.",
action="store_true",
)
return parser.parse_args(args)
@ -64,13 +185,30 @@ def guess_mode(args, config):
compose = False
export = False
import_ = True
elif 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)):
elif (
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))
):
compose = False
export = True
elif any((args.start_date, args.end_date, args.on_date, args.limit, args.strict, args.starred)):
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()):
elif args.text and all(
word[0] in config["tagsymbols"] for word in " ".join(args.text).split()
):
# No date and only tags?
compose = False
@ -79,38 +217,37 @@ def guess_mode(args, config):
def encrypt(journal, filename=None):
""" Encrypt into new file. If filename is not set, we encrypt the journal file itself. """
from . import EncryptedJournal
journal.config["encrypt"] = True
journal.config['password'] = util.getpass("Enter new password: ")
journal.config['encrypt'] = True
new_journal = EncryptedJournal.EncryptedJournal(None, **journal.config)
new_journal.entries = journal.entries
new_journal = EncryptedJournal.from_journal(journal)
new_journal.write(filename)
if util.yesno("Do you want to store the password in your keychain?", default=True):
util.set_keychain(journal.name, journal.config['password'])
util.prompt("Journal encrypted to {0}.".format(filename or new_journal.config['journal']))
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
journal.config['password'] = ""
journal.config["encrypt"] = False
new_journal = Journal.PlainJournal(filename, **journal.config)
new_journal.entries = journal.entries
new_journal = PlainJournal.from_journal(journal)
new_journal.write(filename)
util.prompt("Journal decrypted to {0}.".format(filename or new_journal.config['journal']))
print(
"Journal decrypted to {}.".format(filename or new_journal.config["journal"]),
file=sys.stderr,
)
def list_journals(config):
"""List the journals specified in the configuration file"""
result = "Journals defined in {}\n".format(install.CONFIG_FILE_PATH)
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)
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
@ -118,11 +255,11 @@ 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)
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)
config["journals"][scope] = {"journal": config["journals"][scope]}
config["journals"][scope].update(new_config)
else:
config.update(new_config)
@ -130,28 +267,29 @@ def update_config(config, new_config, scope, force_local=False):
def configure_logger(debug=False):
logging.basicConfig(
level=logging.DEBUG if debug else logging.INFO,
format='%(levelname)-8s %(name)-12s %(message)s'
format="%(levelname)-8s %(name)-12s %(message)s",
)
logging.getLogger('parsedatetime').setLevel(logging.INFO) # disable parsedatetime debug logging
logging.getLogger("parsedatetime").setLevel(
logging.INFO
) # disable parsedatetime debug logging
def run(manual_args=None):
args = parse_args(manual_args)
configure_logger(args.debug)
args.text = [p.decode('utf-8') if util.PY2 and not isinstance(p, unicode) else p for p in args.text]
if args.version:
version_str = "{0} version {1}".format(jrnl.__title__, jrnl.__version__)
print(util.py2encode(version_str))
version_str = f"{jrnl.__title__} version {jrnl.__version__}"
print(version_str)
sys.exit(0)
try:
config = install.load_or_install_jrnl()
except UserAbort as err:
util.prompt("\n{}".format(err))
print(f"\n{err}", file=sys.stderr)
sys.exit(1)
if args.ls:
util.prnt(list_journals(config))
print(list_journals(config))
sys.exit(0)
log.debug('Using configuration "%s"', config)
@ -159,13 +297,14 @@ def run(manual_args=None):
# If the first textual argument points to a journal file,
# use this!
journal_name = args.text[0] if (args.text and args.text[0] in config['journals']) else 'default'
if journal_name is not 'default':
journal_name = install.DEFAULT_JOURNAL_KEY
if args.text and args.text[0] in config["journals"]:
journal_name = args.text[0]
args.text = args.text[1:]
elif "default" not in config['journals']:
util.prompt("No default journal configured.")
util.prompt(list_journals(config))
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)
config = util.scope_config(config, journal_name)
@ -175,7 +314,7 @@ def run(manual_args=None):
try:
args.limit = int(args.text[0].lstrip("-"))
args.text = args.text[1:]
except:
except ValueError:
pass
log.debug('Using journal "%s"', journal_name)
@ -190,32 +329,39 @@ def run(manual_args=None):
if mode_compose and not args.text:
if not sys.stdin.isatty():
# Piping data into jrnl
raw = util.py23_read()
elif config['editor']:
raw = sys.stdin.read()
elif config["editor"]:
template = ""
if config['template']:
if config["template"]:
try:
template = open(config['template']).read()
except:
util.prompt("[Could not read template at '']".format(config['template']))
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:
raw = util.py23_read("[Compose Entry; " + _exit_multiline_code + " to finish writing]\n")
print(
"[Compose Entry; " + _exit_multiline_code + " to finish writing]\n",
file=sys.stderr,
)
raw = sys.stdin.read()
except KeyboardInterrupt:
util.prompt("[Entry NOT saved to journal.]")
print("[Entry NOT saved to journal.]", file=sys.stderr)
sys.exit(0)
if raw:
args.text = [raw]
else:
mode_compose = False
sys.exit()
# This is where we finally open the journal!
try:
journal = Journal.open_journal(journal_name, config)
journal = open_journal(journal_name, config)
except KeyboardInterrupt:
util.prompt("[Interrupted while opening journal]".format(journal_name))
print(f"[Interrupted while opening journal]", file=sys.stderr)
sys.exit(1)
# Import mode
@ -225,35 +371,37 @@ def run(manual_args=None):
# Writing mode
elif mode_compose:
raw = " ".join(args.text).strip()
if util.PY2 and type(raw) is not unicode:
raw = raw.decode(sys.getfilesystemencoding())
log.debug('Appending raw line "%s" to journal "%s"', raw, journal_name)
journal.new_entry(raw)
util.prompt("[Entry added to {0} journal]".format(journal_name))
print(f"[Entry added to {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,
short=args.short,
starred=args.starred,
exclude=args.excluded)
journal.filter(
tags=args.text,
start_date=args.start_date,
end_date=args.end_date,
strict=args.strict,
short=args.short,
starred=args.starred,
exclude=args.excluded,
contains=args.contains,
)
journal.limit(args.limit)
# Reading mode
if not mode_compose and not mode_export and not mode_import:
print(util.py2encode(journal.pprint()))
print(journal.pprint())
# Various export modes
elif args.short:
print(util.py2encode(journal.pprint(short=True)))
print(journal.pprint(short=True))
elif args.tags:
print(util.py2encode(plugins.get_exporter("tags").export(journal)))
print(plugins.get_exporter("tags").export(journal))
elif args.export is not False:
exporter = plugins.get_exporter(args.export)
@ -263,19 +411,28 @@ def run(manual_args=None):
encrypt(journal, filename=args.encrypt)
# Not encrypting to a separate file: update config!
if not args.encrypt:
update_config(original_config, {"encrypt": True}, journal_name, force_local=True)
update_config(
original_config, {"encrypt": True}, 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}, journal_name, force_local=True)
update_config(
original_config, {"encrypt": False}, journal_name, force_local=True
)
install.save_config(original_config)
elif args.edit:
if not config['editor']:
util.prompt("[{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))
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
@ -286,11 +443,19 @@ def run(manual_args=None):
num_edited = len([e for e in journal.entries if e.modified])
prompts = []
if num_deleted:
prompts.append("{0} {1} deleted".format(num_deleted, "entry" if num_deleted == 1 else "entries"))
prompts.append(
"{} {} deleted".format(
num_deleted, "entry" if num_deleted == 1 else "entries"
)
)
if num_edited:
prompts.append("{0} {1} modified".format(num_edited, "entry" if num_deleted == 1 else "entries"))
prompts.append(
"{} {} modified".format(
num_edited, "entry" if num_deleted == 1 else "entries"
)
)
if prompts:
util.prompt("[{0}]".format(", ".join(prompts).capitalize()))
print("[{}]".format(", ".join(prompts).capitalize()), file=sys.stderr)
journal.entries += other_entries
journal.sort()
journal.write()

View file

@ -1,66 +0,0 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import, unicode_literals
from .util import ERROR_COLOR, RESET_COLOR
from .util import slugify, u
from .template import Template
import os
import codecs
class Exporter(object):
"""This Exporter can convert entries and journals into text files."""
def __init__(self, format):
with open("jrnl/templates/" + format + ".template") as f:
front_matter, body = f.read().strip("-\n").split("---", 2)
self.template = Template(body)
def export_entry(self, entry):
"""Returns a unicode representation of a single entry."""
return entry.__unicode__()
def _get_vars(self, journal):
return {
'journal': journal,
'entries': journal.entries,
'tags': journal.tags
}
def export_journal(self, journal):
"""Returns a unicode representation of an entire journal."""
return self.template.render_block("journal", **self._get_vars(journal))
def write_file(self, journal, path):
"""Exports a journal into a single file."""
try:
with codecs.open(path, "w", "utf-8") as f:
f.write(self.export_journal(journal))
return "[Journal exported to {0}]".format(path)
except IOError as e:
return "[{2}ERROR{3}: {0} {1}]".format(e.filename, e.strerror, ERROR_COLOR, RESET_COLOR)
def make_filename(self, entry):
return entry.date.strftime("%Y-%m-%d_{0}.{1}".format(slugify(u(entry.title)), self.extension))
def write_files(self, journal, path):
"""Exports a journal into individual files for each entry."""
for entry in journal.entries:
try:
full_path = os.path.join(path, self.make_filename(entry))
with codecs.open(full_path, "w", "utf-8") as f:
f.write(self.export_entry(entry))
except IOError as e:
return "[{2}ERROR{3}: {0} {1}]".format(e.filename, e.strerror, ERROR_COLOR, RESET_COLOR)
return "[Journal exported to {0}]".format(path)
def export(self, journal, format="text", output=None):
"""Exports to individual files if output is an existing path, or into
a single file if output is a file name, or returns the exporter's
representation as unicode if output is None."""
if output and os.path.isdir(output): # multiple files
return self.write_files(journal, output)
elif output: # single file
return self.write_file(journal, output)
else:
return self.export_journal(journal)

View file

@ -1,8 +1,5 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import
import readline
import glob
import getpass
import os
@ -17,11 +14,16 @@ import yaml
import logging
import sys
DEFAULT_CONFIG_NAME = 'jrnl.yaml'
DEFAULT_JOURNAL_NAME = 'journal.txt'
XDG_RESOURCE = 'jrnl'
if "win32" not in sys.platform:
# readline is not included in Windows Active Python
import readline
USER_HOME = os.path.expanduser('~')
DEFAULT_CONFIG_NAME = "jrnl.yaml"
DEFAULT_JOURNAL_NAME = "journal.txt"
DEFAULT_JOURNAL_KEY = "default"
XDG_RESOURCE = "jrnl"
USER_HOME = os.path.expanduser("~")
CONFIG_PATH = xdg.BaseDirectory.save_config_path(XDG_RESOURCE) or USER_HOME
CONFIG_FILE_PATH = os.path.join(CONFIG_PATH, DEFAULT_CONFIG_NAME)
@ -42,21 +44,20 @@ def module_exists(module_name):
else:
return True
default_config = {
'version': __version__,
'journals': {
"default": JOURNAL_FILE_PATH
},
'editor': os.getenv('VISUAL') or os.getenv('EDITOR') or "",
'encrypt': False,
'template': False,
'default_hour': 9,
'default_minute': 0,
'timeformat': "%Y-%m-%d %H:%M",
'tagsymbols': '@',
'highlight': True,
'linewrap': 79,
'indent_character': '|',
"version": __version__,
"journals": {DEFAULT_JOURNAL_KEY: JOURNAL_FILE_PATH},
"editor": os.getenv("VISUAL") or os.getenv("EDITOR") or "",
"encrypt": False,
"template": False,
"default_hour": 9,
"default_minute": 0,
"timeformat": "%Y-%m-%d %H:%M",
"tagsymbols": "@",
"highlight": True,
"linewrap": 79,
"indent_character": "|",
}
@ -69,13 +70,18 @@ def upgrade_config(config):
for key in missing_keys:
config[key] = default_config[key]
save_config(config)
print("[Configuration updated to newest version at {}]".format(CONFIG_FILE_PATH))
print(
f"[Configuration updated to newest version at {CONFIG_FILE_PATH}]",
file=sys.stderr,
)
def save_config(config):
config['version'] = __version__
with open(CONFIG_FILE_PATH, 'w') as f:
yaml.safe_dump(config, f, encoding='utf-8', allow_unicode=True, default_flow_style=False)
config["version"] = __version__
with open(CONFIG_FILE_PATH, "w") as f:
yaml.safe_dump(
config, f, encoding="utf-8", allow_unicode=True, default_flow_style=False
)
def load_or_install_jrnl():
@ -83,25 +89,35 @@ def load_or_install_jrnl():
If jrnl is already installed, loads and returns a config object.
Else, perform various prompts to install jrnl.
"""
config_path = CONFIG_FILE_PATH if os.path.exists(CONFIG_FILE_PATH) else CONFIG_FILE_PATH_FALLBACK
config_path = (
CONFIG_FILE_PATH
if os.path.exists(CONFIG_FILE_PATH)
else CONFIG_FILE_PATH_FALLBACK
)
if os.path.exists(config_path):
log.debug('Reading configuration from file %s', config_path)
log.debug("Reading configuration from file %s", config_path)
config = util.load_config(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.")
print("Aborting upgrade.", file=sys.stderr)
print(
"Please tell us about this problem at the following URL:",
file=sys.stderr,
)
print(
"https://github.com/jrnl-org/jrnl/issues/new?title=UpgradeValidationException",
file=sys.stderr,
)
print("Exiting.", file=sys.stderr)
sys.exit(1)
upgrade_config(config)
return config
else:
log.debug('Configuration file not found, installing jrnl...')
log.debug("Configuration file not found, installing jrnl...")
try:
config = install()
except KeyboardInterrupt:
@ -110,41 +126,40 @@ def load_or_install_jrnl():
def install():
def autocomplete(text, state):
expansions = glob.glob(os.path.expanduser(os.path.expandvars(text)) + '*')
expansions = [e + "/" if os.path.isdir(e) else e for e in expansions]
expansions.append(None)
return expansions[state]
readline.set_completer_delims(' \t\n;')
readline.parse_and_bind("tab: complete")
readline.set_completer(autocomplete)
if "win32" not in sys.platform:
readline.set_completer_delims(" \t\n;")
readline.parse_and_bind("tab: complete")
readline.set_completer(autocomplete)
# Where to create the journal?
path_query = 'Path to your journal file (leave blank for {}): '.format(JOURNAL_FILE_PATH)
journal_path = util.py23_input(path_query).strip() or JOURNAL_FILE_PATH
default_config['journals']['default'] = os.path.expanduser(os.path.expandvars(journal_path))
path_query = f"Path to your journal file (leave blank for {JOURNAL_FILE_PATH}): "
journal_path = input(path_query).strip() or JOURNAL_FILE_PATH
default_config["journals"][DEFAULT_JOURNAL_KEY] = os.path.expanduser(
os.path.expandvars(journal_path)
)
path = os.path.split(default_config['journals']['default'])[0] # If the folder doesn't exist, create it
# If the folder doesn't exist, create it
path = os.path.split(default_config["journals"][DEFAULT_JOURNAL_KEY])[0]
try:
os.makedirs(path)
except OSError:
pass
# Encrypt it?
password = getpass.getpass("Enter password for journal (leave blank for no encryption): ")
if password:
default_config['encrypt'] = True
if util.yesno("Do you want to store the password in your keychain?", default=True):
util.set_keychain("default", password)
else:
util.set_keychain("default", None)
EncryptedJournal._create(default_config['journals']['default'], password)
print("Journal will be encrypted.")
else:
PlainJournal._create(default_config['journals']['default'])
encrypt = util.yesno(
"Do you want to encrypt your journal? You can always change this later",
default=False,
)
if encrypt:
default_config["encrypt"] = True
print("Journal will be encrypted.", file=sys.stderr)
config = default_config
save_config(config)
if password:
config['password'] = password
return config
save_config(default_config)
return default_config
def autocomplete(text, state):
expansions = glob.glob(os.path.expanduser(os.path.expandvars(text)) + "*")
expansions = [e + "/" if os.path.isdir(e) else e for e in expansions]
expansions.append(None)
return expansions[state]

View file

@ -1,8 +1,6 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import, unicode_literals
from .text_exporter import TextExporter
from .jrnl_importer import JRNLImporter
from .json_exporter import JSONExporter
@ -11,16 +9,26 @@ from .tag_exporter import TagExporter
from .xml_exporter import XMLExporter
from .yaml_exporter import YAMLExporter
from .template_exporter import __all__ as template_exporters
from .fancy_exporter import FancyExporter
__exporters =[JSONExporter, MarkdownExporter, TagExporter, TextExporter, XMLExporter, YAMLExporter] + template_exporters
__importers =[JRNLImporter]
__exporters = [
JSONExporter,
MarkdownExporter,
TagExporter,
TextExporter,
XMLExporter,
YAMLExporter,
FancyExporter,
] + template_exporters
__importers = [JRNLImporter]
__exporter_types = dict([(name, plugin) for plugin in __exporters for name in plugin.names])
__importer_types = dict([(name, plugin) for plugin in __importers for name in plugin.names])
__exporter_types = {name: plugin for plugin in __exporters for name in plugin.names}
__importer_types = {name: plugin for plugin in __importers for name in plugin.names}
EXPORT_FORMATS = sorted(__exporter_types.keys())
IMPORT_FORMATS = sorted(__importer_types.keys())
def get_exporter(format):
for exporter in __exporters:
if hasattr(exporter, "names") and format in exporter.names:

View file

@ -0,0 +1,74 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import, unicode_literals, print_function
from .text_exporter import TextExporter
from textwrap import TextWrapper
class FancyExporter(TextExporter):
"""This Exporter can convert entries and journals into text with unicode box drawing characters."""
names = ["fancy", "boxed"]
extension = "txt"
border_a = ""
border_b = ""
border_c = ""
border_d = ""
border_e = ""
border_f = ""
border_g = ""
border_h = ""
border_i = ""
border_j = ""
border_k = ""
border_l = ""
border_m = ""
@classmethod
def export_entry(cls, entry):
"""Returns a fancy unicode representation of a single entry."""
date_str = entry.date.strftime(entry.journal.config["timeformat"])
linewrap = entry.journal.config["linewrap"] or 78
initial_linewrap = linewrap - len(date_str) - 2
body_linewrap = linewrap - 2
card = [
cls.border_a + cls.border_b * (initial_linewrap) + cls.border_c + date_str
]
w = TextWrapper(
width=initial_linewrap,
initial_indent=cls.border_g + " ",
subsequent_indent=cls.border_g + " ",
)
title_lines = w.wrap(entry.title)
card.append(
title_lines[0].ljust(initial_linewrap + 1)
+ cls.border_d
+ cls.border_e * (len(date_str) - 1)
+ cls.border_f
)
w.width = body_linewrap
if len(title_lines) > 1:
for line in w.wrap(
" ".join(
[
title_line[len(w.subsequent_indent) :]
for title_line in title_lines[1:]
]
)
):
card.append(line.ljust(body_linewrap + 1) + cls.border_h)
if entry.body:
card.append(cls.border_i + cls.border_j * body_linewrap + cls.border_k)
for line in entry.body.splitlines():
body_lines = w.wrap(line) or [cls.border_g]
for body_line in body_lines:
card.append(body_line.ljust(body_linewrap + 1) + cls.border_h)
card.append(cls.border_l + cls.border_b * body_linewrap + cls.border_m)
return "\n".join(card)
@classmethod
def export_journal(cls, journal):
"""Returns a unicode representation of an entire journal."""
return "\n".join(cls.export_entry(entry) for entry in journal)

View file

@ -1,13 +1,13 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import, unicode_literals
import codecs
import sys
from .. import util
class JRNLImporter(object):
class JRNLImporter:
"""This plugin imports entries from other jrnl files."""
names = ["jrnl"]
@staticmethod
@ -17,15 +17,18 @@ class JRNLImporter(object):
old_cnt = len(journal.entries)
old_entries = journal.entries
if input:
with codecs.open(input, "r", "utf-8") as f:
with open(input, "r", encoding="utf-8") as f:
other_journal_txt = f.read()
else:
try:
other_journal_txt = util.py23_read()
other_journal_txt = sys.stdin.read()
except KeyboardInterrupt:
util.prompt("[Entries NOT imported into journal.]")
print("[Entries NOT imported into journal.]", file=sys.stderr)
sys.exit(0)
journal.import_(other_journal_txt)
new_cnt = len(journal.entries)
util.prompt("[{0} imported to {1} journal]".format(new_cnt - old_cnt, journal.name))
print(
"[{} imported to {} journal]".format(new_cnt - old_cnt, journal.name),
file=sys.stderr,
)
journal.write()

View file

@ -1,7 +1,6 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import, unicode_literals
from .text_exporter import TextExporter
import json
from .util import get_tags_count
@ -9,20 +8,21 @@ from .util import get_tags_count
class JSONExporter(TextExporter):
"""This Exporter can convert entries and journals into json."""
names = ["json"]
extension = "json"
@classmethod
def entry_to_dict(cls, entry):
entry_dict = {
'title': entry.title,
'body': entry.body,
'date': entry.date.strftime("%Y-%m-%d"),
'time': entry.date.strftime("%H:%M"),
'starred': entry.starred
"title": entry.title,
"body": entry.body,
"date": entry.date.strftime("%Y-%m-%d"),
"time": entry.date.strftime("%H:%M"),
"starred": entry.starred,
}
if hasattr(entry, "uuid"):
entry_dict['uuid'] = entry.uuid
entry_dict["uuid"] = entry.uuid
return entry_dict
@classmethod
@ -35,7 +35,7 @@ class JSONExporter(TextExporter):
"""Returns a json representation of an entire journal."""
tags = get_tags_count(journal)
result = {
"tags": dict((tag, count) for count, tag in tags),
"entries": [cls.entry_to_dict(e) for e in journal.entries]
"tags": {tag: count for count, tag in tags},
"entries": [cls.entry_to_dict(e) for e in journal.entries],
}
return json.dumps(result, indent=2)

View file

@ -1,7 +1,6 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import, unicode_literals, print_function
from .text_exporter import TextExporter
import os
import re
@ -11,24 +10,25 @@ from ..util import WARNING_COLOR, RESET_COLOR
class MarkdownExporter(TextExporter):
"""This Exporter can convert entries and journals into Markdown."""
names = ["md", "markdown"]
extension = "md"
@classmethod
def export_entry(cls, entry, to_multifile=True):
"""Returns a markdown representation of a single entry."""
date_str = entry.date.strftime(entry.journal.config['timeformat'])
date_str = entry.date.strftime(entry.journal.config["timeformat"])
body_wrapper = "\n" if entry.body else ""
body = body_wrapper + entry.body
if to_multifile is True:
heading = '#'
heading = "#"
else:
heading = '###'
heading = "###"
'''Increase heading levels in body text'''
newbody = ''
previous_line = ''
"""Increase heading levels in body text"""
newbody = ""
previous_line = ""
warn_on_heading_level = False
for line in body.splitlines(True):
if re.match(r"^#+ ", line):
@ -36,30 +36,32 @@ class MarkdownExporter(TextExporter):
newbody = newbody + previous_line + heading + line
if re.match(r"^#######+ ", heading + line):
warn_on_heading_level = True
line = ''
elif re.match(r"^=+$", line.rstrip()) and not re.match(r"^$", previous_line.strip()):
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.rstrip()) and not re.match(r"^$", previous_line.strip()):
line = ""
elif re.match(r"^-+$", line.rstrip()) and not re.match(
r"^$", previous_line.strip()
):
"""Setext style H2"""
newbody = newbody + heading + "## " + previous_line
line = ''
line = ""
else:
newbody = newbody + previous_line
previous_line = line
newbody = newbody + previous_line # add very last line
newbody = newbody + previous_line # add very last line
if warn_on_heading_level is True:
print("{}WARNING{}: Headings increased past H6 on export - {} {}".format(WARNING_COLOR, RESET_COLOR, date_str, entry.title), file=sys.stderr)
print(
f"{WARNING_COLOR}WARNING{RESET_COLOR}: "
f"Headings increased past H6 on export - {date_str} {entry.title}",
file=sys.stderr,
)
return "{md} {date} {title}\n{body} {space}".format(
md=heading,
date=date_str,
title=entry.title,
body=newbody,
space=""
)
return f"{heading} {date_str} {entry.title}\n{newbody} "
@classmethod
def export_journal(cls, journal):

View file

@ -1,13 +1,13 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import, unicode_literals
from .text_exporter import TextExporter
from .util import get_tags_count
class TagExporter(TextExporter):
"""This Exporter can lists the tags for entries and journals, exported as a plain text file."""
names = ["tags"]
extension = "tags"
@ -22,9 +22,11 @@ class TagExporter(TextExporter):
tag_counts = get_tags_count(journal)
result = ""
if not tag_counts:
return '[No tags found in journal.]'
return "[No tags found in journal.]"
elif min(tag_counts)[0] == 0:
tag_counts = filter(lambda x: x[0] > 1, tag_counts)
result += '[Removed tags that appear only once.]\n'
result += "\n".join("{0:20} : {1}".format(tag, n) for n, tag in sorted(tag_counts, reverse=True))
result += "[Removed tags that appear only once.]\n"
result += "\n".join(
"{:20} : {}".format(tag, n) for n, tag in sorted(tag_counts, reverse=True)
)
return result

View file

@ -1,5 +1,4 @@
import re
import asteval
import yaml
VAR_RE = r"[_a-zA-Z][a-zA-Z0-9_]*"
@ -7,13 +6,15 @@ EXPRESSION_RE = r"[\[\]():.a-zA-Z0-9_]*"
PRINT_RE = r"{{ *(.+?) *}}"
START_BLOCK_RE = r"{% *(if|for) +(.+?) *%}"
END_BLOCK_RE = r"{% *end(for|if) *%}"
FOR_RE = r"{{% *for +({varname}) +in +([^%]+) *%}}".format(varname=VAR_RE, expression=EXPRESSION_RE)
FOR_RE = r"{{% *for +({varname}) +in +([^%]+) *%}}".format(
varname=VAR_RE, expression=EXPRESSION_RE
)
IF_RE = r"{% *if +(.+?) *%}"
BLOCK_RE = r"{% *block +(.+?) *%}((?:.|\n)+?){% *endblock *%}"
INCLUDE_RE = r"{% *include +(.+?) *%}"
class Template(object):
class Template:
def __init__(self, template):
self.template = template
self.clean_template = None
@ -39,9 +40,11 @@ class Template(object):
return self._expand(self.blocks[block], **vars)
def _eval_context(self, vars):
import asteval
e = asteval.Interpreter(use_numpy=False, writer=None)
e.symtable.update(vars)
e.symtable['__last_iteration'] = vars.get("__last_iteration", False)
e.symtable["__last_iteration"] = vars.get("__last_iteration", False)
return e
def _get_blocks(self):
@ -49,12 +52,19 @@ class Template(object):
name, contents = match.groups()
self.blocks[name] = self._strip_single_nl(contents)
return ""
self.clean_template = re.sub(BLOCK_RE, s, self.template, flags=re.MULTILINE)
def _expand(self, template, **vars):
stack = sorted(
[(m.start(), 1, m.groups()[0]) for m in re.finditer(START_BLOCK_RE, template)] +
[(m.end(), -1, m.groups()[0]) for m in re.finditer(END_BLOCK_RE, template)]
[
(m.start(), 1, m.groups()[0])
for m in re.finditer(START_BLOCK_RE, template)
]
+ [
(m.end(), -1, m.groups()[0])
for m in re.finditer(END_BLOCK_RE, template)
]
)
last_nesting, nesting = 0, 0
@ -80,19 +90,23 @@ class Template(object):
start = pos
last_nesting = nesting
result += self._expand_vars(template[stack[-1][0]:], **vars)
result += self._expand_vars(template[stack[-1][0] :], **vars)
return result
def _expand_vars(self, template, **vars):
safe_eval = self._eval_context(vars)
expanded = re.sub(INCLUDE_RE, lambda m: self.render_block(m.groups()[0], **vars), template)
expanded = re.sub(
INCLUDE_RE, lambda m: self.render_block(m.groups()[0], **vars), template
)
return re.sub(PRINT_RE, lambda m: str(safe_eval(m.groups()[0])), expanded)
def _expand_cond(self, template, **vars):
start_block = re.search(IF_RE, template, re.M)
end_block = list(re.finditer(END_BLOCK_RE, template, re.M))[-1]
expression = start_block.groups()[0]
sub_template = self._strip_single_nl(template[start_block.end():end_block.start()])
sub_template = self._strip_single_nl(
template[start_block.end() : end_block.start()]
)
safe_eval = self._eval_context(vars)
if safe_eval(expression):
@ -110,15 +124,17 @@ class Template(object):
start_block = re.search(FOR_RE, template, re.M)
end_block = list(re.finditer(END_BLOCK_RE, template, re.M))[-1]
var_name, iterator = start_block.groups()
sub_template = self._strip_single_nl(template[start_block.end():end_block.start()], strip_r=False)
sub_template = self._strip_single_nl(
template[start_block.end() : end_block.start()], strip_r=False
)
safe_eval = self._eval_context(vars)
result = ''
result = ""
items = safe_eval(iterator)
for idx, var in enumerate(items):
vars[var_name] = var
vars['__last_iteration'] = idx == len(items) - 1
vars["__last_iteration"] = idx == len(items) - 1
result += self._expand(sub_template, **vars)
del vars[var_name]
return self._strip_single_nl(result)

View file

@ -1,8 +1,6 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import, unicode_literals
from .text_exporter import TextExporter
from .template import Template
import os
@ -14,21 +12,14 @@ class GenericTemplateExporter(TextExporter):
@classmethod
def export_entry(cls, entry):
"""Returns a unicode representation of a single entry."""
vars = {
'entry': entry,
'tags': entry.tags
}
"""Returns a string representation of a single entry."""
vars = {"entry": entry, "tags": entry.tags}
return cls.template.render_block("entry", **vars)
@classmethod
def export_journal(cls, journal):
"""Returns a unicode representation of an entire journal."""
vars = {
'journal': journal,
'entries': journal.entries,
'tags': journal.tags
}
"""Returns a string representation of an entire journal."""
vars = {"journal": journal, "entries": journal.entries, "tags": journal.tags}
return cls.template.render_block("journal", **vars)
@ -36,11 +27,12 @@ def __exporter_from_file(template_file):
"""Create a template class from a file"""
name = os.path.basename(template_file).replace(".template", "")
template = Template.from_file(template_file)
return type(str("{}Exporter".format(name.title())), (GenericTemplateExporter, ), {
"names": [name],
"extension": template.extension,
"template": template
})
return type(
str(f"{name.title()}Exporter"),
(GenericTemplateExporter,),
{"names": [name], "extension": template.extension, "template": template},
)
__all__ = []

View file

@ -1,41 +1,42 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import, unicode_literals
import codecs
from ..util import u, slugify
from ..util import slugify
import os
from ..util import ERROR_COLOR, RESET_COLOR
class TextExporter(object):
class TextExporter:
"""This Exporter can convert entries and journals into text files."""
names = ["text", "txt"]
extension = "txt"
@classmethod
def export_entry(cls, entry):
"""Returns a unicode representation of a single entry."""
return entry.__unicode__()
"""Returns a string representation of a single entry."""
return str(entry)
@classmethod
def export_journal(cls, journal):
"""Returns a unicode representation of an entire journal."""
"""Returns a string representation of an entire journal."""
return "\n".join(cls.export_entry(entry) for entry in journal)
@classmethod
def write_file(cls, journal, path):
"""Exports a journal into a single file."""
try:
with codecs.open(path, "w", "utf-8") as f:
with open(path, "w", encoding="utf-8") as f:
f.write(cls.export_journal(journal))
return "[Journal exported to {0}]".format(path)
return f"[Journal exported to {path}]"
except IOError as e:
return "[{2}ERROR{3}: {0} {1}]".format(e.filename, e.strerror, ERROR_COLOR, RESET_COLOR)
return f"[{ERROR_COLOR}ERROR{RESET_COLOR}: {e.filename} {e.strerror}]"
@classmethod
def make_filename(cls, entry):
return entry.date.strftime("%Y-%m-%d_{0}.{1}".format(slugify(u(entry.title)), cls.extension))
return entry.date.strftime(
"%Y-%m-%d_{}.{}".format(slugify(str(entry.title)), cls.extension)
)
@classmethod
def write_files(cls, journal, path):
@ -43,20 +44,22 @@ class TextExporter(object):
for entry in journal.entries:
try:
full_path = os.path.join(path, cls.make_filename(entry))
with codecs.open(full_path, "w", "utf-8") as f:
with open(full_path, "w", encoding="utf-8") as f:
f.write(cls.export_entry(entry))
except IOError as e:
return "[{2}ERROR{3}: {0} {1}]".format(e.filename, e.strerror, ERROR_COLOR, RESET_COLOR)
return "[Journal exported to {0}]".format(path)
return "[{2}ERROR{3}: {0} {1}]".format(
e.filename, e.strerror, ERROR_COLOR, RESET_COLOR
)
return "[Journal exported to {}]".format(path)
@classmethod
def export(cls, journal, output=None):
"""Exports to individual files if output is an existing path, or into
a single file if output is a file name, or returns the exporter's
representation as unicode if output is None."""
representation as string if output is None."""
if output and os.path.isdir(output): # multiple files
return cls.write_files(journal, output)
elif output: # single file
elif output: # single file
return cls.write_file(journal, output)
else:
return cls.export_journal(journal)

View file

@ -6,11 +6,9 @@ def get_tags_count(journal):
"""Returns a set of tuples (count, tag) for all tags present in the journal."""
# Astute reader: should the following line leave you as puzzled as me the first time
# I came across this construction, worry not and embrace the ensuing moment of enlightment.
tags = [tag
for entry in journal.entries
for tag in set(entry.tags)]
tags = [tag for entry in journal.entries for tag in set(entry.tags)]
# To be read: [for entry in journal.entries: for tag in set(entry.tags): tag]
tag_counts = set([(tags.count(tag), tag) for tag in tags])
tag_counts = {(tags.count(tag), tag) for tag in tags}
return tag_counts
@ -24,4 +22,4 @@ def oxford_list(lst):
elif len(lst) == 2:
return lst[0] + " or " + lst[1]
else:
return ', '.join(lst[:-1]) + ", or " + lst[-1]
return ", ".join(lst[:-1]) + ", or " + lst[-1]

View file

@ -1,15 +1,14 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import, unicode_literals
from .json_exporter import JSONExporter
from .util import get_tags_count
from ..util import u
from xml.dom import minidom
class XMLExporter(JSONExporter):
"""This Exporter can convert entries and journals into XML."""
names = ["xml"]
extension = "xml"
@ -17,10 +16,10 @@ class XMLExporter(JSONExporter):
def export_entry(cls, entry, doc=None):
"""Returns an XML representation of a single entry."""
doc_el = doc or minidom.Document()
entry_el = doc_el.createElement('entry')
entry_el = doc_el.createElement("entry")
for key, value in cls.entry_to_dict(entry).items():
elem = doc_el.createElement(key)
elem.appendChild(doc_el.createTextNode(u(value)))
elem.appendChild(doc_el.createTextNode(value))
entry_el.appendChild(elem)
if not doc:
doc_el.appendChild(entry_el)
@ -30,11 +29,11 @@ class XMLExporter(JSONExporter):
@classmethod
def entry_to_xml(cls, entry, doc):
entry_el = doc.createElement('entry')
entry_el.setAttribute('date', entry.date.isoformat())
entry_el = doc.createElement("entry")
entry_el.setAttribute("date", entry.date.isoformat())
if hasattr(entry, "uuid"):
entry_el.setAttribute('uuid', u(entry.uuid))
entry_el.setAttribute('starred', u(entry.starred))
entry_el.setAttribute("uuid", entry.uuid)
entry_el.setAttribute("starred", entry.starred)
entry_el.appendChild(doc.createTextNode(entry.fulltext))
return entry_el
@ -43,13 +42,13 @@ class XMLExporter(JSONExporter):
"""Returns an XML representation of an entire journal."""
tags = get_tags_count(journal)
doc = minidom.Document()
xml = doc.createElement('journal')
tags_el = doc.createElement('tags')
entries_el = doc.createElement('entries')
xml = doc.createElement("journal")
tags_el = doc.createElement("tags")
entries_el = doc.createElement("entries")
for count, tag in tags:
tag_el = doc.createElement('tag')
tag_el.setAttribute('name', tag)
count_node = doc.createTextNode(u(count))
tag_el = doc.createElement("tag")
tag_el.setAttribute("name", tag)
count_node = doc.createTextNode(str(count))
tag_el.appendChild(count_node)
tags_el.appendChild(tag_el)
for entry in journal.entries:

View file

@ -1,7 +1,6 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import, unicode_literals, print_function
from .text_exporter import TextExporter
import os
import re
@ -11,6 +10,7 @@ from ..util import WARNING_COLOR, ERROR_COLOR, RESET_COLOR
class YAMLExporter(TextExporter):
"""This Exporter can convert entries and journals into Markdown formatted text with YAML front matter."""
names = ["yaml"]
extension = "md"
@ -18,67 +18,109 @@ class YAMLExporter(TextExporter):
def export_entry(cls, entry, to_multifile=True):
"""Returns a markdown representation of a single entry, with YAML front matter."""
if to_multifile is False:
print("{}ERROR{}: YAML export must be to individual files. Please specify a directory to export to.".format("\033[31m", "\033[0m", file=sys.stderr))
print(
"{}ERROR{}: YAML export must be to individual files. Please \
specify a directory to export to.".format(
ERROR_COLOR, RESET_COLOR, file=sys.stderr
)
)
return
date_str = entry.date.strftime(entry.journal.config['timeformat'])
date_str = entry.date.strftime(entry.journal.config["timeformat"])
body_wrapper = "\n" if entry.body else ""
body = body_wrapper + entry.body
tagsymbols = entry.journal.config['tagsymbols']
tagsymbols = entry.journal.config["tagsymbols"]
# see also Entry.Entry.rag_regex
multi_tag_regex = re.compile(r'(?u)^\s*([{tags}][-+*#/\w]+\s*)+$'.format(tags=tagsymbols), re.UNICODE)
multi_tag_regex = re.compile(fr"(?u)^\s*([{tagsymbols}][-+*#/\w]+\s*)+$")
'''Increase heading levels in body text'''
newbody = ''
heading = '#'
previous_line = ''
"""Increase heading levels in body text"""
newbody = ""
heading = "#"
previous_line = ""
warn_on_heading_level = False
for line in entry.body.splitlines(True):
for line in body.splitlines(True):
if re.match(r"^#+ ", line):
"""ATX style headings"""
newbody = newbody + previous_line + heading + line
if re.match(r"^#######+ ", heading + line):
warn_on_heading_level = True
line = ''
elif re.match(r"^=+$", line.rstrip()) and not re.match(r"^$", previous_line.strip()):
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.rstrip()) and not re.match(r"^$", previous_line.strip()):
line = ""
elif re.match(r"^-+$", line.rstrip()) and not re.match(
r"^$", previous_line.strip()
):
"""Setext style H2"""
newbody = newbody + heading + "## " + previous_line
line = ''
line = ""
elif multi_tag_regex.match(line):
"""Tag only lines"""
line = ''
line = ""
else:
newbody = newbody + previous_line
previous_line = line
newbody = newbody + previous_line # add very last line
newbody = newbody + previous_line # add very last line
if warn_on_heading_level is True:
print("{}WARNING{}: Headings increased past H6 on export - {} {}".format(WARNING_COLOR, RESET_COLOR, date_str, entry.title), file=sys.stderr)
print(
"{}WARNING{}: Headings increased past H6 on export - {} {}".format(
WARNING_COLOR, RESET_COLOR, date_str, entry.title
),
file=sys.stderr,
)
dayone_attributes = ''
dayone_attributes = ""
if hasattr(entry, "uuid"):
dayone_attributes += 'uuid: ' + entry.uuid + '\n'
# TODO: copy over pictures, if present
# source directory is entry.journal.config['journal']
# output directory is...?
dayone_attributes += "uuid: " + entry.uuid + "\n"
if (
hasattr(entry, "creator_device_agent")
or hasattr(entry, "creator_generation_date")
or hasattr(entry, "creator_host_name")
or hasattr(entry, "creator_os_agent")
or hasattr(entry, "creator_software_agent")
):
dayone_attributes += "creator:\n"
if hasattr(entry, "creator_device_agent"):
dayone_attributes += f" device agent: {entry.creator_device_agent}\n"
if hasattr(entry, "creator_generation_date"):
dayone_attributes += " generation date: {}\n".format(
str(entry.creator_generation_date)
)
if hasattr(entry, "creator_host_name"):
dayone_attributes += f" host name: {entry.creator_host_name}\n"
if hasattr(entry, "creator_os_agent"):
dayone_attributes += f" os agent: {entry.creator_os_agent}\n"
if hasattr(entry, "creator_software_agent"):
dayone_attributes += (
f" software agent: {entry.creator_software_agent}\n"
)
# TODO: copy over pictures, if present
# source directory is entry.journal.config['journal']
# output directory is...?
return "title: {title}\ndate: {date}\nstared: {stared}\ntags: {tags}\n{dayone} {body} {space}".format(
date = date_str,
title = entry.title,
stared = entry.starred,
tags = ', '.join([tag[1:] for tag in entry.tags]),
dayone = dayone_attributes,
body = newbody,
space=""
date=date_str,
title=entry.title,
stared=entry.starred,
tags=", ".join([tag[1:] for tag in entry.tags]),
dayone=dayone_attributes,
body=newbody,
space="",
)
@classmethod
def export_journal(cls, journal):
"""Returns an error, as YAML export requires a directory as a target."""
print("{}ERROR{}: YAML export must be to individual files. Please specify a directory to export to.".format(ERROR_COLOR, RESET_COLOR), file=sys.stderr)
print(
"{}ERROR{}: YAML export must be to individual files. Please specify a directory to export to.".format(
ERROR_COLOR, RESET_COLOR
),
file=sys.stderr,
)
return

View file

@ -1,7 +1,10 @@
from datetime import datetime
from dateutil.parser import parse as dateparse
try: import parsedatetime.parsedatetime_consts as pdt
except ImportError: import parsedatetime as pdt
try:
import parsedatetime.parsedatetime_consts as pdt
except ImportError:
import parsedatetime as pdt
FAKE_YEAR = 9999
DEFAULT_FUTURE = datetime(FAKE_YEAR, 12, 31, 23, 59, 59)
@ -12,15 +15,18 @@ consts.DOWParseStyle = -1 # "Monday" will be either today or the last Monday
CALENDAR = pdt.Calendar(consts)
def parse(date_str, inclusive=False, default_hour=None, default_minute=None):
def parse(
date_str, inclusive=False, default_hour=None, default_minute=None, bracketed=False
):
"""Parses a string containing a fuzzy date and returns a datetime.datetime object"""
if not date_str:
return 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:
# Don't try to parse anything with 6 or less characters and was parsed from the existing journal.
# It's probably a markdown footnote
if len(date_str) <= 6 and bracketed:
return None
default_date = DEFAULT_FUTURE if inclusive else DEFAULT_PAST
@ -36,7 +42,7 @@ def parse(date_str, inclusive=False, default_hour=None, default_minute=None):
flag = 1 if date.hour == date.minute == 0 else 2
date = date.timetuple()
except Exception as e:
if e.args[0] == 'day is out of range for month':
if e.args[0] == "day is out of range for month":
y, m, d, H, M, S = default_date.timetuple()[:6]
default_date = datetime(y, m, d - 1, H, M, S)
else:
@ -52,7 +58,12 @@ def parse(date_str, inclusive=False, default_hour=None, default_minute=None):
return None
if flag is 1: # Date found, but no time. Use the default time.
date = datetime(*date[:3], hour=default_hour or 0, minute=default_minute or 0)
date = datetime(
*date[:3],
hour=23 if inclusive else default_hour or 0,
minute=59 if inclusive else default_minute or 0,
second=59 if inclusive else 0
)
else:
date = datetime(*date[:6])

View file

@ -1,4 +1,4 @@
from __future__ import absolute_import, unicode_literals
import sys
from . import __version__
from . import Journal
@ -6,27 +6,27 @@ from . import util
from .EncryptedJournal import EncryptedJournal
from .util import UserAbort
import os
import codecs
def backup(filename, binary=False):
util.prompt(" Created a backup at {}.backup".format(filename))
print(f" Created a backup at {filename}.backup", file=sys.stderr)
filename = os.path.expanduser(os.path.expandvars(filename))
with open(filename, 'rb' if binary else 'r') as original:
with open(filename, "rb" if binary else "r") as original:
contents = original.read()
with open(filename + ".backup", 'wb' if binary else 'w') as backup:
with open(filename + ".backup", "wb" if binary else "w") as backup:
backup.write(contents)
def upgrade_jrnl_if_necessary(config_path):
with codecs.open(config_path, "r", "utf-8") as f:
with open(config_path, "r", encoding="utf-8") as f:
config_file = f.read()
if not config_file.strip().startswith("{"):
return
config = util.load_config(config_path)
util.prompt("""Welcome to jrnl {}.
print(
f"""Welcome to jrnl {__version__}.
It looks like you've been using an older version of jrnl until now. That's
okay - jrnl will now upgrade your configuration and journal files. Afterwards
@ -40,20 +40,24 @@ you can enjoy all of the great new features that come with jrnl 2:
Please note that jrnl 1.x is NOT forward compatible with this version of jrnl.
If you choose to proceed, you will not be able to use your journals with
older versions of jrnl anymore.
""".format(__version__))
"""
)
encrypted_journals = {}
plain_journals = {}
other_journals = {}
all_journals = []
for journal_name, journal_conf in config['journals'].items():
for journal_name, journal_conf in config["journals"].items():
if isinstance(journal_conf, dict):
path = journal_conf.get("journal")
encrypt = journal_conf.get("encrypt")
else:
encrypt = config.get('encrypt')
encrypt = config.get("encrypt")
path = journal_conf
path = os.path.expanduser(path)
if encrypt:
encrypted_journals[journal_name] = path
elif os.path.isdir(path):
@ -61,21 +65,36 @@ older versions of jrnl anymore.
else:
plain_journals[journal_name] = path
longest_journal_name = max([len(journal) for journal in config['journals']])
longest_journal_name = max([len(journal) for journal in config["journals"]])
if encrypted_journals:
util.prompt("\nFollowing encrypted journals will be upgraded to jrnl {}:".format(__version__))
print(
f"\nFollowing encrypted journals will be upgraded to jrnl {__version__}:",
file=sys.stderr,
)
for journal, path in encrypted_journals.items():
util.prompt(" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name))
print(
" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name),
file=sys.stderr,
)
if plain_journals:
util.prompt("\nFollowing plain text journals will upgraded to jrnl {}:".format(__version__))
print(
f"\nFollowing plain text journals will upgraded to jrnl {__version__}:",
file=sys.stderr,
)
for journal, path in plain_journals.items():
util.prompt(" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name))
print(
" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name),
file=sys.stderr,
)
if other_journals:
util.prompt("\nFollowing journals will be not be touched:")
print("\nFollowing journals will be not be touched:", file=sys.stderr)
for journal, path in other_journals.items():
util.prompt(" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name))
print(
" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name),
file=sys.stderr,
)
try:
cont = util.yesno("\nContinue upgrading jrnl?", default=False)
@ -85,23 +104,37 @@ older versions of jrnl anymore.
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))
print(
f"\nUpgrading encrypted '{journal_name}' journal stored in {path}...",
file=sys.stderr,
)
backup(path, binary=True)
old_journal = Journal.open_journal(journal_name, util.scope_config(config, journal_name), legacy=True)
old_journal = Journal.open_journal(
journal_name, util.scope_config(config, journal_name), legacy=True
)
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))
print(
f"\nUpgrading plain text '{journal_name}' journal stored in {path}...",
file=sys.stderr,
)
backup(path)
old_journal = Journal.open_journal(journal_name, util.scope_config(config, journal_name), legacy=True)
old_journal = Journal.open_journal(
journal_name, util.scope_config(config, journal_name), legacy=True
)
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))
print(
"\nThe following journal{} failed to upgrade:\n{}".format(
"s" if len(failed_journals) > 1 else "",
"\n".join(j.name for j in failed_journals),
),
file=sys.stderr,
)
raise UpgradeValidationException
@ -110,11 +143,13 @@ older versions of jrnl anymore.
for j in all_journals:
j.write()
util.prompt("\nUpgrading config...")
print("\nUpgrading config...", file=sys.stderr)
backup(config_path)
util.prompt("\nWe're all done here and you can start enjoying jrnl 2.".format(config_path))
print("\nWe're all done here and you can start enjoying jrnl 2.", file=sys.stderr)
class UpgradeValidationException(Exception):
"""Raised when the contents of an upgraded journal do not match the old journal"""
pass

View file

@ -1,42 +1,32 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import unicode_literals
from __future__ import absolute_import
import sys
import os
import getpass as gp
import yaml
if "win32" in sys.platform:
import colorama
colorama.init()
import re
import tempfile
import subprocess
import codecs
import unicodedata
import shlex
import logging
from typing import Optional, Callable
log = logging.getLogger(__name__)
PY3 = sys.version_info[0] == 3
PY2 = sys.version_info[0] == 2
STDIN = sys.stdin
STDERR = sys.stderr
STDOUT = sys.stdout
TEST = False
__cached_tz = None
WARNING_COLOR = "\033[33m"
ERROR_COLOR = "\033[31m"
RESET_COLOR = "\033[0m"
# Based on Segtok by Florian Leitner
# https://github.com/fnl/segtok
SENTENCE_SPLITTER = re.compile(r"""
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,
@ -44,101 +34,85 @@ SENTENCE_SPLITTER = re.compile(r"""
\s+ # a sequence of required spaces.
| # Otherwise,
\n # a sentence also terminates newlines.
)""", re.UNICODE | re.VERBOSE)
)""",
re.VERBOSE,
)
class UserAbort(Exception):
pass
def getpass(prompt="Password: "):
if not TEST:
return gp.getpass(bytes(prompt))
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)
else:
return py23_input(prompt)
set_keychain(journal_name, None)
return pw
def get_password(validator, keychain=None, max_attempts=3):
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()
result = validator(password)
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:
prompt("Wrong password, try again.")
password = getpass()
result = validator(password)
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:
prompt("Extremely wrong password.")
print("Extremely wrong password.", file=sys.stderr)
sys.exit(1)
def get_keychain(journal_name):
import keyring
return keyring.get_password('jrnl', journal_name)
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.delete_password("jrnl", journal_name)
except keyring.errors.PasswordDeleteError:
pass
elif not TEST:
keyring.set_password('jrnl', journal_name, password)
def u(s):
"""Mock unicode function for python 2 and 3 compatibility."""
if not isinstance(s, str):
s = str(s)
return s if PY3 or type(s) is unicode else s.decode("utf-8")
def py2encode(s):
"""Encodes to UTF-8 in Python 2 but not in Python 3."""
return s.encode("utf-8") if PY2 and type(s) is unicode else s
def bytes(s):
"""Returns bytes, no matter what."""
if PY3:
return s.encode("utf-8") if type(s) is not bytes else s
return s.encode("utf-8") if type(s) is unicode else s
def prnt(s):
"""Encode and print a string"""
STDOUT.write(u(s + "\n"))
def prompt(msg):
"""Prints a message to the std err stream defined in util."""
if not msg.endswith("\n"):
msg += "\n"
STDERR.write(u(msg))
def py23_input(msg=""):
prompt(msg)
return STDIN.readline().strip()
def py23_read(msg=""):
print(msg)
return STDIN.read()
else:
keyring.set_password("jrnl", journal_name, password)
def yesno(prompt, default=True):
prompt = prompt.strip() + (" [Y/n]" if default else " [y/N]")
raw = py23_input(prompt)
return {'y': True, 'n': False}.get(raw.lower(), default)
prompt = f"{prompt.strip()} {'[Y/n]' if default else '[y/N]'} "
response = input(prompt)
return {"y": True, "n": False}.get(response.lower(), default)
def load_config(config_path):
@ -149,66 +123,61 @@ def load_config(config_path):
def scope_config(config, journal_name):
if journal_name not in config['journals']:
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)
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
config.pop('journals')
config["journal"] = journal_conf
config.pop("journals")
return config
def get_text_from_editor(config, template=""):
filehandle, tmpfile = tempfile.mkstemp(prefix="jrnl", text=True, suffix=".txt")
with codecs.open(tmpfile, 'w', "utf-8") as f:
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="win" not in sys.platform) + [tmpfile])
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:
subprocess.call(config["editor"] + [tmpfile])
with open(tmpfile, "r", encoding="utf-8") as f:
raw = f.read()
os.close(filehandle)
os.remove(tmpfile)
if not raw:
prompt('[Nothing saved to file]')
print("[Nothing saved to file]", file=sys.stderr)
return raw
def colorize(string):
"""Returns the string wrapped in cyan ANSI escape"""
return u"\033[36m{}\033[39m".format(string)
return f"\033[36m{string}\033[39m"
def slugify(string):
"""Slugifies a string.
Based on public domain code from https://github.com/zacharyvoase/slugify
and ported to deal with all kinds of python 2 and 3 strings
"""
string = u(string)
ascii_string = str(unicodedata.normalize('NFKD', string).encode('ascii', 'ignore'))
if PY3:
ascii_string = ascii_string[1:] # removed the leading 'b'
no_punctuation = re.sub(r'[^\w\s-]', '', ascii_string).strip().lower()
slug = re.sub(r'[-\s]+', '-', no_punctuation)
return u(slug)
def int2byte(i):
"""Converts an integer to a byte.
This is equivalent to chr() in Python 2 and bytes((i,)) in Python 3."""
return chr(i) if PY2 else bytes((i,))
def byte2int(b):
"""Converts a byte to an integer.
This is equivalent to ord(bs[0]) on Python 2 and bs[0] on Python 3."""
return ord(b)if PY2 else b
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):
@ -216,4 +185,4 @@ def split_title(text):
punkt = SENTENCE_SPLITTER.search(text)
if not punkt:
return text, ""
return text[:punkt.end()].strip(), text[punkt.end():].strip()
return text[: punkt.end()].strip(), text[punkt.end() :].strip()