diff --git a/features/core.feature b/features/core.feature index 85b13d90..08f7aea8 100644 --- a/features/core.feature +++ b/features/core.feature @@ -39,7 +39,7 @@ Feature: Basic reading and writing to a journal Given we use the config "basic.yaml" When we run "jrnl" and enter "25 jul 2013: I saw Elvis. He's alive." Then we should get no error - and the journal should contain "2013-07-25 09:00 I saw Elvis." + and the journal should contain "[2013-07-25 09:00] I saw Elvis." and the journal should contain "He's alive." Scenario: Displaying the version number diff --git a/features/data/configs/upgrade_from_195.json b/features/data/configs/upgrade_from_195.json new file mode 100644 index 00000000..ec380372 --- /dev/null +++ b/features/data/configs/upgrade_from_195.json @@ -0,0 +1,11 @@ +{ +"default_hour": 9, +"timeformat": "%Y-%m-%d %H:%M", +"linewrap": 80, +"encrypt": false, +"editor": "", +"default_minute": 0, +"highlight": true, +"journals": {"default": "features/journals/simple_jrnl-1-9-5.journal"}, +"tagsymbols": "@" +} diff --git a/features/data/journals/encrypted.journal b/features/data/journals/encrypted.journal index f628c25c..d2a5fcbe 100644 --- a/features/data/journals/encrypted.journal +++ b/features/data/journals/encrypted.journal @@ -1 +1 @@ -gAAAAABVH4F009PRK-vz0bGa2elPRuNWvQOFjDt_TQtTbgHDBCiWgEzsTF7c4Vy-iqm-MYOh2UUrh_kUX7vTzsj3R-OJsKEYRy060yUaOH3cfBB1QHmMBhefV2XSJ-A5u_PryN137rf7kbV5Xk0jSDi2GbRuIRT6yRER1y-MAn4RDs0jfpxfeskZ65ykaB9-5Rm-lA_1ygHM9Uwrcu3HyrMJei1C6kl23w== +gAAAAABVIHB7tnwKExG7aC5ZbAbBL9SG2oY2GENeoOJ22i1PZigOvCYvrQN3kpsu0KGr7ay5K-_46R5YFlqJvtQ8anPH2FSITsaZy-l5Lz_5quw3rmzhLwAR1tc0icgtR4MEpXEdsuQ7cyb12Xq-JLDrnATs0id5Vow9Ri_tE7Xe4BXgXaySn3aRPwWKoninVxVPVvETY3MXHSUEXV9OZ-pH5kYBLGYbLA== diff --git a/features/data/journals/simple.journal b/features/data/journals/simple.journal index 66d8439c..8336068e 100644 --- a/features/data/journals/simple.journal +++ b/features/data/journals/simple.journal @@ -1,5 +1,5 @@ -2013-06-09 15:39 My first entry. +[2013-06-09 15:39] My first entry. Everything is alright -2013-06-10 15:40 Life is good. +[2013-06-10 15:40] Life is good. But I'm better. diff --git a/features/data/journals/simple_jrnl-1-9-5.journal b/features/data/journals/simple_jrnl-1-9-5.journal new file mode 100644 index 00000000..f660305b --- /dev/null +++ b/features/data/journals/simple_jrnl-1-9-5.journal @@ -0,0 +1,3 @@ +2010-06-10 15:00 A life without chocolate is like a bad analogy. + +2013-06-10 15:40 He said "[this] is the best time to be alive". diff --git a/features/data/journals/tags-216.journal b/features/data/journals/tags-216.journal index 4bd942b9..08b6d630 100644 --- a/features/data/journals/tags-216.journal +++ b/features/data/journals/tags-216.journal @@ -1,2 +1,2 @@ -2013-06-10 15:40 I programmed for @OS/2. +[2013-06-10 15:40] I programmed for @OS/2. Almost makes me want to go back to @C++, though. (Still better than @C#). diff --git a/features/data/journals/tags-237.journal b/features/data/journals/tags-237.journal index a1b5f5d4..be050652 100644 --- a/features/data/journals/tags-237.journal +++ b/features/data/journals/tags-237.journal @@ -1,3 +1,3 @@ -2014-07-22 11:11 This entry has an email. +[2014-07-22 11:11] This entry has an email. @Newline tag should show as a tag. Kyla's @email is kyla@clevelandunderdog.org and Guinness's is guinness@fortheloveofpits.org. diff --git a/features/data/journals/tags.journal b/features/data/journals/tags.journal index b4186a53..a28f3159 100644 --- a/features/data/journals/tags.journal +++ b/features/data/journals/tags.journal @@ -1,8 +1,8 @@ -2013-04-09 15:39 I have an @idea: +[2013-04-09 15:39] I have an @idea: (1) write a command line @journal software (2) ??? (3) PROFIT! -2013-06-10 15:40 I met with @dan. +[2013-06-10 15:40] I met with @dan. As alway's he shared his latest @idea on how to rule the world with me. inst diff --git a/features/encryption.feature b/features/encryption.feature index 2212fc33..ebb3cc02 100644 --- a/features/encryption.feature +++ b/features/encryption.feature @@ -36,6 +36,7 @@ """ Y bad doggie no biscuit + bad doggie no biscuit """ Then we should see the message "Password" and the output should contain "2013-06-10 15:40 Life is good" diff --git a/features/exporting.feature b/features/exporting.feature index 003801a3..3d6c2607 100644 --- a/features/exporting.feature +++ b/features/exporting.feature @@ -20,3 +20,9 @@ Feature: Exporting a Journal and "tags" in the json output should contain "@journal" and "tags" in the json output should not contain "@dan" + Scenario: Exporting dayone to json + Given we use the config "dayone.yaml" + When we run "jrnl --export json" + Then we should get no error + and the output should be parsable as json + and the json output should contain entries.0.uuid = "4BB1F46946AD439996C9B59DE7C4DDC1" diff --git a/features/regression.feature b/features/regression.feature index 9ae04713..6013b804 100644 --- a/features/regression.feature +++ b/features/regression.feature @@ -19,14 +19,14 @@ Feature: Zapped bugs should stay dead. Given we use the config "basic.yaml" When we run "jrnl 2013-11-30 15:42: Project Started." Then we should see the message "Entry added" - and the journal should contain "2013-11-30 15:42 Project Started." + and the journal should contain "[2013-11-30 15:42] Project Started." Scenario: Date in the future should be parsed correctly # https://github.com/maebert/jrnl/issues/185 Given we use the config "basic.yaml" When we run "jrnl 26/06/2019: Planet? Earth. Year? 2019." Then we should see the message "Entry added" - and the journal should contain "2019-06-26 09:00 Planet?" + and the journal should contain "[2019-06-26 09:00] Planet?" Scenario: Loading entry with ambiguous time stamp #https://github.com/maebert/jrnl/issues/153 @@ -38,16 +38,26 @@ Feature: Zapped bugs should stay dead. 2013-10-27 03:27 Some text. """ - Scenario: Title with an embedded period. - Given we use the config "basic.yaml" - When we run "jrnl 04-24-2014: Created a new website - empty.com. Hope to get a lot of traffic." - Then we should see the message "Entry added" - When we run "jrnl -1" - Then the output should be - """ - 2014-04-24 09:00 Created a new website - empty.com. - | Hope to get a lot of traffic. - """ + Scenario: Title with an embedded period. + Given we use the config "basic.yaml" + When we run "jrnl 04-24-2014: Created a new website - empty.com. Hope to get a lot of traffic." + Then we should see the message "Entry added" + When we run "jrnl -1" + Then the output should be + """ + 2014-04-24 09:00 Created a new website - empty.com. + | Hope to get a lot of traffic. + """ + + Scenario: Upgrade and parse journals with square brackets + Given we use the config "upgrade_from_195.json" + When we run "jrnl -2" and enter "Y" + Then the output should contain + """ + 2010-06-10 15:00 A life without chocolate is like a bad analogy. + + 2013-06-10 15:40 He said "[this] is the best time to be alive". + """ Scenario: Title with an embedded period on DayOne journal Given we use the config "dayone.yaml" diff --git a/features/starring.feature b/features/starring.feature index 35154045..18113dc3 100644 --- a/features/starring.feature +++ b/features/starring.feature @@ -4,7 +4,7 @@ Feature: Starring entries Given we use the config "basic.yaml" When we run "jrnl 20 july 2013 *: Best day of my life!" Then we should see the message "Entry added" - and the journal should contain "2013-07-20 09:00 Best day of my life! *" + and the journal should contain "[2013-07-20 09:00] Best day of my life! *" Scenario: Filtering by starred entries Given we use the config "basic.yaml" diff --git a/features/steps/core.py b/features/steps/core.py index e7711e9a..3e0b1de8 100644 --- a/features/steps/core.py +++ b/features/steps/core.py @@ -126,6 +126,22 @@ def check_output_field_key(context, field, key): assert key in out_json[field] +@then('the json output should contain {path} = "{value}"') +def check_json_output_path(context, path, value): + """ E.g. + the json output should contain entries.0.title = "hello" + """ + out = context.stdout_capture.getvalue() + struct = json.loads(out) + + for node in path.split('.'): + try: + struct = struct[int(node)] + except ValueError: + struct = struct[node] + assert struct == value, struct + + @then('the output should be') @then('the output should be "{text}"') def check_output(context, text=None): @@ -146,12 +162,14 @@ def check_output_time_inline(context, text): assert local_date in out, local_date +@then('the output should contain') @then('the output should contain "{text}"') -def check_output_inline(context, text): +def check_output_inline(context, text=None): + text = text or context.text out = context.stdout_capture.getvalue() if isinstance(out, bytes): out = out.decode('utf-8') - assert text in out + assert text in out, text @then('the output should not contain "{text}"') diff --git a/jrnl/EncryptedJournal.py b/jrnl/EncryptedJournal.py index 16821191..ae37769d 100644 --- a/jrnl/EncryptedJournal.py +++ b/jrnl/EncryptedJournal.py @@ -1,19 +1,20 @@ from . import Journal, util from cryptography.fernet import Fernet, InvalidToken -from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import hashes, padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +import hashlib from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.backends import default_backend import base64 def make_key(password): - if type(password) is unicode: - password = password.encode('utf-8') + password = util.bytes(password) kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=32, # Salt is hard-coded - salt='\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() ) @@ -32,13 +33,15 @@ class EncryptedJournal(Journal.Journal): 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) as f: + with open(filename, 'rb') as f: journal_encrypted = f.read() def validate_password(password): key = make_key(password) try: - return Fernet(key).decrypt(journal_encrypted).decode('utf-8') + plain = Fernet(key).decrypt(journal_encrypted).decode('utf-8') + self.config['password'] = password + return plain except (InvalidToken, IndexError): return None if password: @@ -48,7 +51,7 @@ class EncryptedJournal(Journal.Journal): def _store(self, filename, text): key = make_key(self.config['password']) journal = Fernet(key).encrypt(text.encode('utf-8')) - with open(filename, 'w') as f: + with open(filename, 'wb') as f: f.write(journal) @classmethod @@ -57,3 +60,35 @@ class EncryptedJournal(Journal.Journal): dummy = Fernet(key).encrypt("") with open(filename, 'w') as f: f.write(dummy) + + +class LegacyEncryptedJournal(Journal.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: + 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() + try: + plain_padded = decryptor.update(cipher) + decryptor.finalize() + self.config['password'] = password + if plain_padded[-1] in (" ", 32): + # Ancient versions of jrnl. Do not judge me. + 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') + except ValueError: + return None + if password: + return validate_password(password) + return util.get_password(keychain=self.name, validator=validate_password) diff --git a/jrnl/Entry.py b/jrnl/Entry.py index 34503cd9..4504982b 100755 --- a/jrnl/Entry.py +++ b/jrnl/Entry.py @@ -20,19 +20,19 @@ class Entry: @staticmethod def tag_regex(tagsymbols): pattern = r'(?u)\s([{tags}][-+*#/\w]+)'.format(tags=tagsymbols) - return re.compile( pattern, re.UNICODE ) + return re.compile(pattern, re.UNICODE) def parse_tags(self): - fulltext = " " + " ".join([self.title, self.body]).lower() + fulltext = " " + " ".join([self.title, self.body]).lower() tagsymbols = self.journal.config['tagsymbols'] - tags = re.findall( Entry.tag_regex(tagsymbols), fulltext ) + tags = re.findall(Entry.tag_regex(tagsymbols), fulltext) self.tags = tags return set(tags) def __unicode__(self): """Returns a string representation of the entry to be written into a journal file.""" date_str = self.date.strftime(self.journal.config['timeformat']) - title = date_str + " " + self.title.rstrip("\n ") + title = "[{}] {}".format(date_str, self.title.rstrip("\n ")) if self.starred: title += " *" return "{title}{sep}{body}\n".format( @@ -48,13 +48,14 @@ class Entry: 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 + " ") if (len(line) == 0) else line, - self.journal.config['linewrap'], - initial_indent="| ", - subsequent_indent="| ", - drop_whitespace=False) - for line in self.body.rstrip(" \n").splitlines() - ]) + textwrap.fill( + (line + " ") if (len(line) == 0) else line, + self.journal.config['linewrap'], + initial_indent="| ", + subsequent_indent="| ", + drop_whitespace=False) + for line in self.body.rstrip(" \n").splitlines() + ]) else: title = date_str + " " + self.title.rstrip("\n ") body = self.body.rstrip("\n ") @@ -83,7 +84,7 @@ class Entry: or self.body.rstrip() != other.body.rstrip() \ or self.date != other.date \ or self.starred != other.starred: - return False + return False return True def __ne__(self, other): diff --git a/jrnl/Journal.py b/jrnl/Journal.py index 7371703c..af9e6b9e 100644 --- a/jrnl/Journal.py +++ b/jrnl/Journal.py @@ -10,6 +10,9 @@ import sys import codecs import re from datetime import datetime +import logging + +log = logging.getLogger(__name__) class Journal(object): @@ -33,6 +36,15 @@ class Journal(object): """Returns the number of entries""" return len(self.entries) + @classmethod + def from_journal(cls, other): + """Creates a new journal by copying configuration and entries from + 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__) + return new_journal + def import_(self, other_journal_txt): self.entries = list(frozenset(self.entries) | frozenset(self._parse(other_journal_txt))) self.sort() @@ -41,9 +53,15 @@ class Journal(object): """Opens the journal file defined in the config and parses it into a list of Entries. Entries have the form (date, title, body).""" filename = filename or self.config['journal'] + + if not os.path.exists(filename): + util.prompt("[Journal '{0}' created at {1}]".format(self.name, filename)) + self._create(filename) + 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 write(self, filename=None): @@ -64,37 +82,36 @@ class Journal(object): 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'])) - # Initialise our current entry entries = [] current_entry = None - + date_blob_re = re.compile("^\[[^\\]]+\] ") 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']) + date_blob = date_blob_re.findall(line) + if date_blob: + date_blob = date_blob[0] + new_date = time.parse(date_blob.strip(" []")) + if new_date: + # Found a date at the start of the line: This is a new entry. + if current_entry: + entries.append(current_entry) - # parsing successful => save old entry and create new one - if new_date and current_entry: - entries.append(current_entry) + if line.endswith("*"): + starred = True + line = line[:-1] + else: + starred = False - if line.endswith("*"): - starred = True - line = line[:-1] - else: - starred = False - - current_entry = Entry.Entry(self, date=new_date, title=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. - if current_entry: - current_entry.body += line + "\n" + current_entry = Entry.Entry( + self, + date=new_date, + title=line[len(date_blob):], + starred=starred + ) + elif current_entry: + # Didn't find a date - keep on feeding to current entry. + current_entry.body += line + "\n" # Append last entry if current_entry: @@ -226,9 +243,6 @@ class Journal(object): class PlainJournal(Journal): - def __init__(self, name='default', **kwargs): - super(PlainJournal, self).__init__(name, **kwargs) - @classmethod def _create(cls, filename): with codecs.open(filename, "a", "utf-8"): @@ -243,10 +257,70 @@ class PlainJournal(Journal): f.write(text) -def open_journal(name, config): +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, and the line break between the title and the rest of + the entry was not enforced. You'll not be able to save these journals anymore.""" + def _load(self, filename): + with codecs.open(filename, "r", "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'])) + + # Initialise our current entry + entries = [] + current_entry = None + 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']) + + # parsing successful => save old entry and create new one + if new_date and current_entry: + entries.append(current_entry) + + if line.endswith("*"): + starred = True + line = line[:-1] + else: + starred = False + + current_entry = Entry.Entry(self, date=new_date, title=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. + if current_entry: + current_entry.body += line + u"\n" + + # Append last entry + if current_entry: + entries.append(current_entry) + for entry in entries: + entry.parse_tags() + return entries + + +def open_journal(name, config, legacy=False): """ Creates a normal, encrypted or DayOne journal based on the passed config. + If legacy is True, it will open Journals with legacy classes build for + backwards compatibility with jrnl 1.x """ + config = config.copy() + journal_conf = config['journals'].get(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['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']): from . import DayOneJournal @@ -256,7 +330,11 @@ def open_journal(name, config): sys.exit(1) 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() diff --git a/jrnl/cli.py b/jrnl/cli.py index fbbdefec..389afc0a 100644 --- a/jrnl/cli.py +++ b/jrnl/cli.py @@ -13,7 +13,6 @@ from . import util from . import install from . import plugins import jrnl -import os import argparse import sys import logging @@ -102,13 +101,6 @@ def decrypt(journal, filename=None): util.prompt("Journal decrypted to {0}.".format(filename or new_journal.config['journal'])) -def touch_journal(filename): - """If filename does not exist, touch the file""" - if not os.path.exists(filename): - util.prompt("[Journal created at {0}]".format(filename)) - Journal.PlainJournal._create(filename) - - def list_journals(config): """List the journals specified in the configuration file""" sep = "\n" @@ -147,7 +139,6 @@ def run(manual_args=None): sys.exit(0) config = install.load_or_install_jrnl() - if args.ls: util.prnt(u"Journals defined in {}".format(install.CONFIG_FILE_PATH)) ml = min(max(len(k) for k in config['journals']), 20) @@ -163,6 +154,7 @@ def run(manual_args=None): journal_name = args.text[0] if (args.text and args.text[0] in config['journals']) else 'default' if journal_name is not 'default': args.text = args.text[1:] + # If the first remaining argument looks like e.g. '-3', interpret that as a limiter if not args.limit and args.text and args.text[0].startswith("-"): try: @@ -172,16 +164,7 @@ def run(manual_args=None): pass log.debug('Using journal "%s"', journal_name) - 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 jourlnal 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['journal'] = os.path.expanduser(os.path.expandvars(config['journal'])) - touch_journal(config['journal']) mode_compose, mode_export, mode_import = guess_mode(args, config) - log.debug('Using journal path %(journal)s', config) # How to quit writing? if "win32" in sys.platform: @@ -206,6 +189,7 @@ def run(manual_args=None): else: mode_compose = False + # This is where we finally open the journal! journal = Journal.open_journal(journal_name, config) # Import mode diff --git a/jrnl/install.py b/jrnl/install.py index 550d9935..bee35924 100644 --- a/jrnl/install.py +++ b/jrnl/install.py @@ -65,7 +65,7 @@ def upgrade_config(config): for key in missing_keys: config[key] = default_config[key] save_config(config) - print("[.jrnl_conf updated to newest version at {}]".format(CONFIG_FILE_PATH)) + print("[Configuration updated to newest version at {}]".format(CONFIG_FILE_PATH)) def save_config(config): diff --git a/jrnl/plugins/__init__.py b/jrnl/plugins/__init__.py index 301ad76d..6f802342 100644 --- a/jrnl/plugins/__init__.py +++ b/jrnl/plugins/__init__.py @@ -32,15 +32,10 @@ class PluginMeta(type): else: return ', '.join(plugin_names[:-1]) + ", or " + plugin_names[-1] - -class BaseExporter(object): - __metaclass__ = PluginMeta - names = [] - - -class BaseImporter(object): - __metaclass__ = PluginMeta - names = [] +# This looks a bit arcane, but is basically bilingual speak for defining a +# class with meta class 'PluginMeta' for both Python 2 and 3. +BaseExporter = PluginMeta(str('BaseExporter'), (), {'names': []}) +BaseImporter = PluginMeta(str('BaseImporter'), (), {'names': []}) for module in glob.glob(os.path.dirname(__file__) + "/*.py"): diff --git a/jrnl/plugins/json_exporter.py b/jrnl/plugins/json_exporter.py index 374e4e51..5abaf916 100644 --- a/jrnl/plugins/json_exporter.py +++ b/jrnl/plugins/json_exporter.py @@ -14,13 +14,16 @@ class JSONExporter(TextExporter): @classmethod def entry_to_dict(cls, entry): - return { + entry_dict = { '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 + return entry_dict @classmethod def export_entry(cls, entry): diff --git a/jrnl/upgrade.py b/jrnl/upgrade.py index b8941c5b..c73fa086 100644 --- a/jrnl/upgrade.py +++ b/jrnl/upgrade.py @@ -1,37 +1,19 @@ -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import padding -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes -import hashlib -import util +from __future__ import absolute_import, unicode_literals + from . import __version__ -from . import EncryptedJournal +from . import Journal +from . import util +from .EncryptedJournal import EncryptedJournal import sys -from cryptography.fernet import Fernet +import os -def upgrade_encrypted_journal(filename, key_plain): - """Decrypts a journal in memory using the jrnl 1.x encryption scheme - and returns it in plain text.""" - with open(filename) as f: - iv_cipher = f.read() - iv, cipher = iv_cipher[:16], iv_cipher[16:] - decryption_key = hashlib.sha256(key_plain.encode('utf-8')).digest() - decryptor = Cipher(algorithms.AES(decryption_key), modes.CBC(iv), default_backend()).decryptor() - try: - plain_padded = decryptor.update(cipher) + decryptor.finalize() - if plain_padded[-1] == " ": - # Ancient versions of jrnl. Do not judge me. - plain = plain_padded.rstrip(" ") - else: - unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder() - plain = unpadder.update(plain_padded) + unpadder.finalize() - except ValueError: - return None - key = EncryptedJournal.make_key(key_plain) - journal = Fernet(key).encrypt(plain) - with open(filename, 'w') as f: - f.write(journal) - return plain +def backup(filename, binary=False): + util.prompt(" Created a backup at {}.backup".format(filename)) + with open(filename, 'rb' if binary else 'r') as original: + contents = original.read() + with open(filename + ".backup", 'wb' if binary else 'w') as backup: + backup.write(contents) def upgrade_jrnl_if_necessary(config_path): @@ -57,41 +39,63 @@ 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 = {} - for journal, journal_conf in config['journals'].items(): + other_journals = {} + + for journal_name, journal_conf in config['journals'].items(): if isinstance(journal_conf, dict): - if journal_conf.get("encrypt"): - encrypted_journals[journal] = journal_conf.get("journal") - else: - plain_journals[journal] = journal_conf.get("journal") + path = journal_conf.get("journal") + encrypt = journal_conf.get("encrypt") else: - if config.get('encrypt'): - encrypted_journals[journal] = journal_conf - else: - plain_journals[journal] = journal_conf + encrypt = config.get('encrypt') + path = journal_conf + + if encrypt: + encrypted_journals[journal_name] = path + elif os.path.isdir(path): + other_journals[journal_name] = path + else: + plain_journals[journal_name] = path + + longest_journal_name = max([len(journal) for journal in config['journals']]) if encrypted_journals: - longest_journal_name = max([len(journal) for journal in config['journals']]) util.prompt("\nFollowing encrypted journals will be upgraded to jrnl {}:".format(__version__)) for journal, path in encrypted_journals.items(): util.prompt(" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name)) - if plain_journals: - util.prompt("\nFollowing plain text journals will be not be touched:") - for journal, path in plain_journals.items(): - util.prompt(" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name)) - cont = util.yesno("Continue upgrading jrnl?", default=False) + if plain_journals: + util.prompt("\nFollowing plain text journals will upgraded to jrnl {}:".format(__version__)) + for journal, path in plain_journals.items(): + util.prompt(" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name)) + + if other_journals: + util.prompt("\nFollowing journals will be not be touched:") + for journal, path in other_journals.items(): + util.prompt(" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name)) + + cont = util.yesno("\nContinue upgrading jrnl?", default=False) if not cont: util.prompt("jrnl NOT upgraded, exiting.") sys.exit(1) - for journal, path in encrypted_journals.items(): - util.prompt("Enter password for {} journal (stored in {}).".format(journal, path)) - util.get_password(keychain=journal, validator=lambda pwd: upgrade_encrypted_journal(path, pwd)) + for journal_name, path in encrypted_journals.items(): + util.prompt("\nUpgrading encrypted '{}' journal stored in {}...".format(journal_name, path)) + backup(path, binary=True) + old_journal = Journal.open_journal(journal_name, config, legacy=True) + new_journal = EncryptedJournal.from_journal(old_journal) + new_journal.write() + util.prompt(" Done.") - with open(config_path + ".backup", 'w') as config_backup: - config_backup.write(config_file) + for journal_name, path in plain_journals.items(): + util.prompt("\nUpgrading plain text '{}' journal stored in {}...".format(journal_name, path)) + backup(path) + old_journal = Journal.open_journal(journal_name, config, legacy=True) + new_journal = Journal.PlainJournal.from_journal(old_journal) + new_journal.write() + util.prompt(" Done.") - util.prompt("""\n\nYour old config has been backed up to {}.backup. -We're all done here and you can start enjoying jrnl 2.""".format(config_path)) + util.prompt("\nUpgrading config...") + backup(config_path) + + util.prompt("\nWe're all done here and you can start enjoying jrnl 2.".format(config_path)) diff --git a/jrnl/util.py b/jrnl/util.py index bf3bb3c8..c6ea4659 100644 --- a/jrnl/util.py +++ b/jrnl/util.py @@ -75,10 +75,17 @@ def u(s): def py2encode(s): - """Encode in Python 2, but not in python 3.""" + """Encodes to UTF-8 in Python 2 but not r.""" 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")) @@ -92,7 +99,7 @@ def prompt(msg): def py23_input(msg=""): - STDERR.write(u(msg)) + prompt(msg) return STDIN.readline().strip() @@ -114,7 +121,7 @@ def load_config(config_path): def get_text_from_editor(config, template=""): - tmpfile = os.path.join(tempfile.mktemp(prefix="jrnl")) + tmpfile = os.path.join(tempfile.mktemp(prefix="jrnl", suffix=".txt")) with codecs.open(tmpfile, 'w', "utf-8") as f: if template: f.write(template) diff --git a/setup.py b/setup.py index 0c1b4036..bcd80e54 100644 --- a/setup.py +++ b/setup.py @@ -75,11 +75,11 @@ conditional_dependencies = { setup( - name = "jrnl", - version = get_version(), - description = "A command line journal application that stores your journal in a plain text file", - packages = ['jrnl'], - install_requires = [ + name="jrnl", + version=get_version(), + description="A command line journal application that stores your journal in a plain text file", + packages=['jrnl'], + install_requires=[ "pyxdg>=0.19", "parsedatetime>=1.2", "pytz>=2013b", @@ -115,9 +115,9 @@ setup( 'Topic :: Text Processing' ], # metadata for upload to PyPI - author = "Manuel Ebert", - author_email = "manuel@1450.me", + author="Manuel Ebert", + author_email="manuel@1450.me", license="LICENSE", - keywords = "journal todo todo.txt jrnl".split(), - url = "http://www.jrnl.sh", + keywords="journal todo todo.txt jrnl".split(), + url="http://www.jrnl.sh", )