diff --git a/CHANGELOG.md b/CHANGELOG.md index c4ba7ec9..2b2fff7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ Changelog ========= + +### 1.7 (December 22, 2013) + +* __1.7.1__ Fixes issues with parsing time information in entries. +* __1.7.0__ Edit encrypted or DayOne journals with `jrnl --edit`. + + ### 1.6 (November 5, 2013) * __1.6.6__ -v prints the current version, also better strings for windows users. Furthermore, jrnl/jrnl.py moved to jrnl/cli.py diff --git a/docs/advanced.rst b/docs/advanced.rst index 6e49ea11..b00c4525 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -6,12 +6,12 @@ Advanced Usage Configuration File ------------------- -You can configure the way jrnl behaves in a configuration file. By default, this is `~/.jrnl_conf`. If you have the `XDG_CONFIG_HOME` variable set, the configuration file will be saved under `$XDG_CONFIG_HOME/jrnl`. The configuration file is a simple JSON file with the following options. +You can configure the way jrnl behaves in a configuration file. By default, this is ``~/.jrnl_conf``. If you have the ``XDG_CONFIG_HOME`` variable set, the configuration file will be saved under ``$XDG_CONFIG_HOME/jrnl``. The configuration file is a simple JSON file with the following options. - ``journals`` paths to your journal files - ``editor`` - if set, executes this command to launch an external editor for writing your entries, e.g. ``vim`` or ``subl -w`` (note the ``-w`` flag to make sure _jrnl_ waits for Sublime Text to close the file before writing into the journal. If you're using MacVim, that would be ``mvim -f``). + if set, executes this command to launch an external editor for writing your entries, e.g. ``vim`` or ``subl -w`` (note the ``-w`` flag to make sure *jrnl* waits for Sublime Text to close the file before writing into the journal. If you're using MacVim, that would be ``mvim -f``). - ``encrypt`` if ``true``, encrypts your journal using AES. - ``tagsymbols`` @@ -51,6 +51,7 @@ Using your DayOne journal instead of a flat text file is dead simple -- instead * ``~/Library/Mobile Documents/5U8NS4GX82~com~dayoneapp~dayone/Documents/`` if you're syncing with iCloud. Instead of all entries being in a single file, each entry will live in a separate `plist` file. + Multiple journal files ---------------------- diff --git a/docs/export.rst b/docs/export.rst index 4a040b09..e017951d 100644 --- a/docs/export.rst +++ b/docs/export.rst @@ -15,6 +15,8 @@ you'll get a list of all tags you used in your journal, sorted by most frequent. List of all entries ------------------- +:: + jrnl --short Will only display the date and title of each entry. diff --git a/docs/overview.rst b/docs/overview.rst index 532fde36..726fbcf1 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -15,5 +15,9 @@ Optionally, your journal can be encrypted using the `256-bit AES `):: + + jrnl -until 1950 @texas -and @history --edit + +Will open your editor with all entries tagged with ``@texas`` and ``@history`` before 1950. You can make any changes to them you want; after you save the file and close the editor, your journal will be updated. + +Of course, if you are using multiple journals, you can also edit e.g. the latest entry of your work journal with ``jrnl work -n 1 --edit``. In any case, this will bring up your editor and save (and, if applicable, encrypt) your edited journal after you save and exit the editor. + +You can also use this feature for deleting entries from your journal:: + + jrnl @girlfriend -until 'june 2012' --edit + +Just select all text, press delete, and everything is gone... + +Editing DayOne Journals +~~~~~~~~~~~~~~~~~~~~~~~ + +DayOne journals can be edited exactly the same way, however the output looks a little bit different because of the way DayOne stores its entries: + +.. code-block:: output + + # af8dbd0d43fb55458f11aad586ea2abf + 2013-05-02 15:30 I told everyone I built my @robot wife for sex. + But late at night when we're alone we mostly play Battleship. + + # 2391048fe24111e1983ed49a20be6f9e + 2013-08-10 03:22 I had all kinds of plans in case of a @zombie attack. + I just figured I'd be on the other side. + +The long strings starting with hash symbol are the so-called UUIDs, unique identifiers for each entry. Don't touch them. If you do, then the old entry would get deleted and a new one written, which means that you could DayOne loose data that jrnl can't handle (such as as the entry's geolocation). diff --git a/features/regression.feature b/features/regression.feature index da0ef277..af2c01f8 100644 --- a/features/regression.feature +++ b/features/regression.feature @@ -13,3 +13,10 @@ Feature: Zapped bugs should stay dead. When we run "jrnl Herro" Then we should get an error Then we should see the message "is a directory, but doesn't seem to be a DayOne journal either" + + Scenario: Date with time should be parsed correctly + # https://github.com/maebert/jrnl/issues/117 + Given we use the config "basic.json" + 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." diff --git a/jrnl/Entry.py b/jrnl/Entry.py index 4948cc1d..142286ed 100644 --- a/jrnl/Entry.py +++ b/jrnl/Entry.py @@ -9,10 +9,11 @@ class Entry: def __init__(self, journal, date=None, title="", body="", starred=False): self.journal = journal # Reference to journal mainly to access it's config self.date = date or datetime.now() - self.title = title.strip() - self.body = body.strip() + self.title = title.strip("\n ") + self.body = body.strip("\n ") self.tags = self.parse_tags() self.starred = starred + self.modified = False def parse_tags(self): fulltext = " ".join([self.title, self.body]).lower() @@ -67,6 +68,18 @@ class Entry: def __repr__(self): return "".format(self.title.strip(), self.date.strftime("%Y-%m-%d %H:%M")) + def __eq__(self, other): + if not isinstance(other, Entry) \ + or self.title.strip() != other.title.strip() \ + or self.body.strip() != other.body.strip() \ + or self.date != other.date \ + or self.starred != other.starred: + return False + return True + + def __ne__(self, other): + return not self.__eq__(other) + def to_dict(self): return { 'title': self.title.strip(), diff --git a/jrnl/Journal.py b/jrnl/Journal.py index 41d7a225..8da83dd3 100644 --- a/jrnl/Journal.py +++ b/jrnl/Journal.py @@ -11,6 +11,7 @@ try: import parsedatetime.parsedatetime_consts as pdt except ImportError: import parsedatetime.parsedatetime as pdt import re from datetime import datetime +import dateutil import time import sys try: @@ -50,9 +51,11 @@ class Journal(object): self.search_tags = None # Store tags we're highlighting self.name = name - journal_txt = self.open() - self.entries = self.parse(journal_txt) - self.sort() + self.open() + + def __len__(self): + """Returns the number of entries""" + return len(self.entries) def _colorize(self, string): if colorama: @@ -115,9 +118,10 @@ class Journal(object): else: with codecs.open(filename, "r", "utf-8") as f: journal = f.read() - return journal + self.entries = self._parse(journal) + self.sort() - def parse(self, journal): + 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 @@ -128,7 +132,7 @@ class Journal(object): entries = [] current_entry = None - for line in journal.splitlines(): + for line in journal_txt.splitlines(): try: # try to parse line as date => new entry begins line = line.strip() @@ -184,7 +188,7 @@ class Journal(object): def write(self, filename=None): """Dumps the journal into the config file, overwriting it""" filename = filename or self.config['journal'] - journal = "\n".join([e.__unicode__() for e in self.entries]) + journal = u"\n".join([e.__unicode__() for e in self.entries]) if self.config['encrypt']: journal = self._encrypt(journal) with open(filename, 'wb') as journal_file: @@ -249,7 +253,12 @@ class Journal(object): elif isinstance(date_str, datetime): return date_str - date, flag = self.dateparse.parse(date_str) + try: + date = dateutil.parser.parse(date_str) + flag = 1 if date.hour == 0 and date.minute == 0 else 2 + date = date.timetuple() + except: + date, flag = self.dateparse.parse(date_str) if not flag: # Oops, unparsable. try: # Try and parse this as a single year @@ -281,20 +290,15 @@ class Journal(object): raw = raw.replace('\\n ', '\n').replace('\\n', '\n') starred = False # Split raw text into title and body - title_end = len(raw) - for separator in ["\n", ". ", "? ", "! "]: - sep_pos = raw.find(separator) - if 1 < sep_pos < title_end: - title_end = sep_pos - title = raw[:title_end+1] - body = raw[title_end+1:].strip() + sep = re.search("[\n!?.]+", raw) + title, body = (raw[:sep.end()], raw[sep.end():]) if sep else (raw, "") starred = False if not date: - if title.find(":") > 0: - starred = "*" in title[:title.find(":")] - date = self.parse_date(title[:title.find(":")]) + if title.find(": ") > 0: + starred = "*" in title[:title.find(": ")] + date = self.parse_date(title[:title.find(": ")]) if date or starred: # Parsed successfully, strip that from the raw text - title = title[title.find(":")+1:].strip() + title = title[title.find(": ")+1:].strip() elif title.strip().startswith("*"): starred = True title = title[1:].strip() @@ -304,25 +308,36 @@ class Journal(object): if not date: # Still nothing? Meh, just live in the moment. date = self.parse_date("now") entry = Entry.Entry(self, date, title, body, starred=starred) + entry.modified = True self.entries.append(entry) if sort: self.sort() return entry + 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 u"\n".join([e.__unicode__() for e in self.entries]) + + def parse_editable_str(self, edited): + """Parses the output of self.editable_str and updates it's entries.""" + mod_entries = self._parse(edited) + # Match those entries that can be found in self.entries and set + # these to modified, so we can get a count of how many entries got + # modified and how many got deleted later. + for entry in mod_entries: + entry.modified = not any(entry == old_entry for old_entry in self.entries) + self.entries = mod_entries class DayOne(Journal): """A special Journal handling DayOne files""" def __init__(self, **kwargs): self.entries = [] + self._deleted_entries = [] super(DayOne, self).__init__(**kwargs) def open(self): - files = [os.path.join(self.config['journal'], "entries", f) for f in os.listdir(os.path.join(self.config['journal'], "entries"))] - return files - - def parse(self, filenames): - """Instead of parsing a string into an entry, this method will take a list - of filenames, interpret each as a plist file and create a new entry from that.""" + filenames = [os.path.join(self.config['journal'], "entries", f) for f in os.listdir(os.path.join(self.config['journal'], "entries"))] self.entries = [] for filename in filenames: with open(filename, 'rb') as plist_entry: @@ -333,34 +348,97 @@ class DayOne(Journal): timezone = pytz.timezone(util.get_local_timezone()) date = dict_entry['Creation Date'] date = date + timezone.utcoffset(date) - entry = self.new_entry(raw=dict_entry['Entry Text'], date=date, sort=False) - entry.starred = dict_entry["Starred"] + raw = dict_entry['Entry Text'] + sep = re.search("[\n!?.]+", raw) + title, body = (raw[:sep.end()], raw[sep.end():]) if sep else (raw, "") + entry = Entry.Entry(self, date, title, body, starred=dict_entry["Starred"]) entry.uuid = dict_entry["UUID"] entry.tags = dict_entry.get("Tags", []) - # We're using new_entry to create the Entry object, which adds the entry - # to self.entries already. However, in the original Journal.__init__, this - # method is expected to return a list of newly created entries, which is why - # we're returning the obvious. - return self.entries + self.entries.append(entry) + self.sort() def write(self): """Writes only the entries that have been modified into plist files.""" for entry in self.entries: - # Assumption: since jrnl can not manipulate existing entries, all entries - # that have a uuid will be old ones, and only the one that doesn't will - # have a new one! - if not hasattr(entry, "uuid"): + if entry.modified: + if not hasattr(entry, "uuid"): + entry.uuid = uuid.uuid1().hex utc_time = datetime.utcfromtimestamp(time.mktime(entry.date.timetuple())) - new_uuid = uuid.uuid1().hex - filename = os.path.join(self.config['journal'], "entries", new_uuid+".doentry") + filename = os.path.join(self.config['journal'], "entries", entry.uuid+".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': util.get_local_timezone(), - 'UUID': new_uuid, + 'UUID': entry.uuid, 'Tags': [tag.strip(self.config['tagsymbols']) for tag in entry.tags] } - # print entry_plist - plistlib.writePlist(entry_plist, filename) + for entry in self._deleted_entries: + 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 u"\n".join(["# {0}\n{1}".format(e.uuid, e.__unicode__()) for e in self.entries]) + + def parse_editable_str(self, edited): + """Parses the output of self.editable_str and updates it's entries.""" + # Method: create a new list of entries from the edited text, then match + # UUIDs of the new entries against self.entries, updating the entries + # if the edited entries differ, and deleting entries from self.entries + # if they don't show up in the edited entries anymore. + date_length = len(datetime.today().strftime(self.config['timeformat'])) + + # Initialise our current entry + entries = [] + current_entry = None + + for line in edited.splitlines(): + # try to parse line as UUID => new entry begins + line = line.strip() + m = re.match("# *([a-f0-9]+) *$", line.lower()) + if m: + if current_entry: + entries.append(current_entry) + current_entry = Entry.Entry(self) + current_entry.modified = False + current_entry.uuid = m.group(1).lower() + else: + try: + new_date = datetime.strptime(line[:date_length], self.config['timeformat']) + if line.endswith("*"): + current_entry.starred = True + line = line[:-1] + current_entry.title = line[date_length+1:] + current_entry.date = new_date + except ValueError: + if current_entry: + current_entry.body += line + "\n" + + # Append last entry + if current_entry: + entries.append(current_entry) + + # Now, update our current entries if they changed + for entry in entries: + entry.parse_tags() + matched_entries = [e for e in self.entries if e.uuid.lower() == entry.uuid] + if matched_entries: + # This entry is an existing entry + match = matched_entries[0] + if match != entry: + self.entries.remove(match) + entry.modified = True + self.entries.append(entry) + else: + # This entry seems to be new... save it. + entry.modified = True + self.entries.append(entry) + # Remove deleted entries + edited_uuids = [e.uuid for e in entries] + self._deleted_entries = [e for e in self.entries if e.uuid not in edited_uuids] + self.entries[:] = [e for e in self.entries if e.uuid in edited_uuids] + return entries + diff --git a/jrnl/__init__.py b/jrnl/__init__.py index ca25074f..c167789a 100644 --- a/jrnl/__init__.py +++ b/jrnl/__init__.py @@ -7,7 +7,7 @@ jrnl is a simple journal application for your command line. """ __title__ = 'jrnl' -__version__ = '1.6.6' +__version__ = '1.7.1' __author__ = 'Manuel Ebert' __license__ = 'MIT License' __copyright__ = 'Copyright 2013 Manuel Ebert' diff --git a/jrnl/cli.py b/jrnl/cli.py index b53d7b33..20e39f9a 100644 --- a/jrnl/cli.py +++ b/jrnl/cli.py @@ -20,8 +20,6 @@ except (SystemError, ValueError): import install import jrnl import os -import tempfile -import subprocess import argparse import sys @@ -51,7 +49,7 @@ def parse_args(args=None): exporting.add_argument('-o', metavar='OUTPUT', dest='output', help='The output of the file can be provided when using with --export', 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('--delete-last', dest='delete_last', help='Deletes the last entry from your journal file.', action="store_true") + exporting.add_argument('--edit', dest='edit', help='Opens your editor to edit the selected entries.', action="store_true") return parser.parse_args(args) @@ -59,7 +57,7 @@ def guess_mode(args, config): """Guesses the mode (compose, read or export) from the given arguments""" compose = True export = False - if args.decrypt is not False or args.encrypt is not False or args.export is not False or any((args.short, args.tags, args.delete_last)): + if args.decrypt is not False or args.encrypt is not False or args.export is not False or any((args.short, args.tags, args.edit)): compose = False export = True elif any((args.start_date, args.end_date, args.limit, args.strict, args.starred)): @@ -71,20 +69,6 @@ def guess_mode(args, config): return compose, export -def get_text_from_editor(config): - tmpfile = os.path.join(tempfile.gettempdir(), "jrnl") - subprocess.call(config['editor'].split() + [tmpfile]) - if os.path.exists(tmpfile): - with open(tmpfile) as f: - raw = f.read() - os.remove(tmpfile) - else: - util.prompt('[Nothing saved to file]') - raw = '' - - return raw - - def encrypt(journal, filename=None): """ Encrypt into new file. If filename is not set, we encrypt the journal file itself. """ password = util.getpass("Enter new password: ") @@ -164,11 +148,10 @@ def run(manual_args=None): else: journal = Journal.Journal(journal_name, **config) + # How to quit writing? if "win32" in sys.platform: - # for Windows systems _exit_multiline_code = "on a blank line, press Ctrl+Z and then Enter" else: - # for *nix systems (and others?) _exit_multiline_code = "press Ctrl+D" if mode_compose and not args.text: @@ -176,7 +159,7 @@ def run(manual_args=None): # Piping data into jrnl raw = util.py23_read() elif config['editor']: - raw = get_text_from_editor(config) + raw = util.get_text_from_editor(config) else: raw = util.py23_read("[Compose Entry; " + _exit_multiline_code + " to finish writing]\n") if raw: @@ -193,6 +176,7 @@ def run(manual_args=None): util.prompt("[Entry added to {0} journal]".format(journal_name)) journal.write() else: + old_entries = journal.entries journal.filter(tags=args.text, start_date=args.start_date, end_date=args.end_date, strict=args.strict, @@ -231,10 +215,20 @@ def run(manual_args=None): update_config(original_config, {"encrypt": False}, journal_name, force_local=True) install.save_config(original_config, config_path=CONFIG_PATH) - elif args.delete_last: - last_entry = journal.entries.pop() - util.prompt("[Deleted Entry:]") - print(last_entry.pprint()) + elif args.edit: + other_entries = [e for e in old_entries if e not in journal.entries] + # Edit + old_num_entries = len(journal) + edited = util.get_text_from_editor(config, journal.editable_str()) + journal.parse_editable_str(edited) + num_deleted = old_num_entries - len(journal) + num_edited = len([e for e in journal.entries if e.modified]) + prompts = [] + if num_deleted: prompts.append("{0} entries deleted".format(num_deleted)) + if num_edited: prompts.append("{0} entries modified".format(num_edited)) + if prompts: + util.prompt("[{0}]".format(", ".join(prompts).capitalize())) + journal.entries += other_entries journal.write() if __name__ == "__main__": diff --git a/jrnl/util.py b/jrnl/util.py index d461015b..e1766c9d 100644 --- a/jrnl/util.py +++ b/jrnl/util.py @@ -9,6 +9,9 @@ import pytz try: import simplejson as json except ImportError: import json import re +import tempfile +import subprocess +import codecs PY3 = sys.version_info[0] == 3 PY2 = sys.version_info[0] == 2 @@ -121,3 +124,19 @@ def load_and_fix_json(json_path): prompt("[Entry was NOT added to your journal]") sys.exit(1) +def get_text_from_editor(config, template=""): + tmpfile = os.path.join(tempfile.gettempdir(), "jrnl") + if template: + with codecs.open(tmpfile, 'w', "utf-8") as f: + f.write(template) + subprocess.call(config['editor'].split() + [tmpfile]) + if os.path.exists(tmpfile): + with codecs.open(tmpfile, "r", "utf-8") as f: + raw = f.read() + os.remove(tmpfile) + else: + prompt('[Nothing saved to file]') + raw = '' + + return raw +