Merge pull request #118 from maebert/develop

Develop
This commit is contained in:
Manuel Ebert 2013-12-22 09:42:14 -08:00
commit 01ab0fee83
11 changed files with 232 additions and 73 deletions

View file

@ -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

View file

@ -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
----------------------

View file

@ -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.

View file

@ -15,5 +15,9 @@ Optionally, your journal can be encrypted using the `256-bit AES <http://en.wiki
Why keep a journal?
-------------------
Journals aren't only for 13-year old girls and people who have too much time on their summer vacation. A journal helps you to keep track of the things you get done and how you did them. Your imagination may be limitless, but your memory isn't. For personal use, make it a good habit to write at least 20 words a day. Just to reflect what made this day special, why you haven't wasted it. For professional use, consider a text-based journal to be the perfect complement to your GTD todo list - a documentation of what and how you've done it.
Journals aren't only for 13-year old girls and people who have too much time on their summer vacation. A journal helps you to keep track of the things you get done and how you did them. Your imagination may be limitless, but your memory isn't.
For personal use, make it a good habit to write at least 20 words a day. Just to reflect what made this day special, why you haven't wasted it.
For professional use, consider a text-based journal to be the perfect complement to your GTD todo list - a documentation of what and how you've done it. Or use it as a quick way to keep a change log. Or use it to keep a lab book.

View file

@ -83,5 +83,39 @@ the last five entries containing both ``@pineapple`` **and** ``@lubricant``. You
.. note::
``jrnl @pinkie @WorldDomination`` will switch to viewing mode because although _no_ command line arguments are given, all the input strings look like tags - *jrnl* will assume you want to filter by tag.
``jrnl @pinkie @WorldDomination`` will switch to viewing mode because although **no** command line arguments are given, all the input strings look like tags - *jrnl* will assume you want to filter by tag.
Editing older entries
---------------------
You can edit selected entries after you wrote them. This is particularly useful when your journal file is encrypted or if you're using a DayOne journal. To use this feature, you need to have an editor configured in your journal configuration file (see :doc:`advanced usage <advanced>`)::
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).

View file

@ -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."

View file

@ -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 "<Entry '{0}' on {1}>".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(),

View file

@ -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

View file

@ -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'

View file

@ -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__":

View file

@ -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