Updated docs from master

This commit is contained in:
Manuel Ebert 2014-03-19 15:24:39 -07:00
parent 0bcfdb2bde
commit af070b1ca3
12 changed files with 1164 additions and 6 deletions

105
build/lib/jrnl/Entry.py Normal file
View file

@ -0,0 +1,105 @@
#!/usr/bin/env python
# encoding: utf-8
import re
import textwrap
from datetime import datetime
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("\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()
tags = re.findall(r'(?u)([{tags}]\w+)'.format(tags=self.journal.config['tagsymbols']), fulltext, re.UNICODE)
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
if self.starred:
title += " *"
body = self.body.strip()
return u"{title}{sep}{body}\n".format(
title=title,
sep="\n" if self.body else "",
body=body
)
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 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="| ",
subsequent_indent="| ",
drop_whitespace=False).replace(' ', ' ')
for line in self.body.strip().splitlines()
])
else:
title = date_str + " " + self.title
body = self.body.strip()
# 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)
if short:
return title
else:
return u"{title}{sep}{body}\n".format(
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"))
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(),
'body': self.body.strip(),
'date': self.date.strftime("%Y-%m-%d"),
'time': self.date.strftime("%H:%M"),
'starred': self.starred
}
def to_md(self):
date_str = self.date.strftime(self.journal.config['timeformat'])
body_wrapper = "\n\n" if self.body.strip() else ""
body = body_wrapper + self.body.strip()
space = "\n"
md_head = "###"
return u"{md} {date}, {title} {body} {space}".format(
md=md_head,
date=date_str,
title=self.title,
body=body,
space=space
)

433
build/lib/jrnl/Journal.py Normal file
View file

@ -0,0 +1,433 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import
from . import Entry
from . import util
import codecs
import os
try: import parsedatetime.parsedatetime_consts as pdt
except ImportError: import parsedatetime as pdt
import re
from datetime import datetime
import dateutil
import time
import sys
try:
from Crypto.Cipher import AES
from Crypto import Random
crypto_installed = True
except ImportError:
crypto_installed = False
import hashlib
import plistlib
import pytz
import uuid
import tzlocal
class Journal(object):
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,
}
self.config.update(kwargs)
# Set up date parser
consts = pdt.Constants(usePyICU=False)
consts.DOWParseStyle = -1 # "Monday" will be either today or the last Monday
self.dateparse = pdt.Calendar(consts)
self.key = None # used to decrypt and encrypt the journal
self.search_tags = None # Store tags we're highlighting
self.name = name
self.open()
def __len__(self):
"""Returns the number of entries"""
return len(self.entries)
def _decrypt(self, cipher):
"""Decrypts a cipher string using self.key as the key and the first 16 byte of the cipher as the IV"""
if not crypto_installed:
sys.exit("Error: PyCrypto is not installed.")
if not cipher:
return ""
crypto = AES.new(self.key, AES.MODE_CBC, cipher[:16])
try:
plain = crypto.decrypt(cipher[16:])
except ValueError:
util.prompt("ERROR: Your journal file seems to be corrupted. You do have a backup, don't you?")
sys.exit(1)
padding = " ".encode("utf-8")
if not plain.endswith(padding): # Journals are always padded
return None
else:
return plain.decode("utf-8")
def _encrypt(self, plain):
"""Encrypt a plaintext string using self.key as the key"""
if not crypto_installed:
sys.exit("Error: PyCrypto is not installed.")
Random.atfork() # A seed for PyCrypto
iv = Random.new().read(AES.block_size)
crypto = AES.new(self.key, AES.MODE_CBC, iv)
plain = plain.encode("utf-8")
plain += b" " * (AES.block_size - len(plain) % AES.block_size)
return iv + crypto.encrypt(plain)
def make_key(self, password):
"""Creates an encryption key from the default password or prompts for a new password."""
self.key = hashlib.sha256(password.encode("utf-8")).digest()
def open(self, filename=None):
"""Opens the journal file defined in the config and parses it into a list of Entries.
Entries have the form (date, title, body)."""
filename = filename or self.config['journal']
if self.config['encrypt']:
with open(filename, "rb") as f:
journal_encrypted = f.read()
def validate_password(password):
self.make_key(password)
return self._decrypt(journal_encrypted)
# Soft-deprecated:
journal = None
if 'password' in self.config:
journal = validate_password(self.config['password'])
if journal is None:
journal = util.get_password(keychain=self.name, validator=validate_password)
else:
with codecs.open(filename, "r", "utf-8") as f:
journal = f.read()
self.entries = self._parse(journal)
self.sort()
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():
try:
# try to parse line as date => new entry begins
line = line.strip()
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 + "\n"
# Append last entry
if current_entry:
entries.append(current_entry)
for entry in entries:
entry.parse_tags()
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.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)
else:
pp = re.sub(r"(?u)([{tags}]\w+)".format(tags=self.config['tagsymbols']),
lambda match: util.colorize(match.group(0)),
pp)
return pp
def __repr__(self):
return "<Journal with {0} entries>".format(len(self.entries))
def write(self, filename=None):
"""Dumps the journal into the config file, overwriting it"""
filename = filename or self.config['journal']
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:
journal_file.write(journal)
else:
with codecs.open(filename, 'w', "utf-8") as journal_file:
journal_file.write(journal)
def sort(self):
"""Sorts the Journal's entries by date"""
self.entries = sorted(self.entries, key=lambda entry: entry.date)
def limit(self, n=None):
"""Removes all but the last n entries"""
if n:
self.entries = self.entries[-n:]
def filter(self, tags=[], start_date=None, end_date=None, starred=False, strict=False, short=False):
"""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
tag symbols defined in the config, e.g. ["@John", "#WorldDomination"].
start_date and end_date define a timespan by which to filter.
starred limits journal to starred entries
If strict is True, all tags must be present in an entry. If false, the
entry is kept if any tag is present."""
self.search_tags = set([tag.lower() for tag in tags])
end_date = self.parse_date(end_date)
start_date = self.parse_date(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
result = [
entry for entry in self.entries
if (not tags or tagged(entry.tags))
and (not starred or entry.starred)
and (not start_date or entry.date > start_date)
and (not end_date or entry.date < end_date)
]
if short:
if tags:
for e in self.entries:
res = []
for tag in tags:
matches = [m for m in re.finditer(tag, e.body)]
for m in matches:
date = e.date.strftime(self.config['timeformat'])
excerpt = e.body[m.start():min(len(e.body), m.end()+60)]
res.append('{0} {1} ..'.format(date, excerpt))
e.body = "\n".join(res)
else:
for e in self.entries:
e.body = ''
self.entries = result
def parse_date(self, date_str):
"""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
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
year = int(date_str)
return datetime(year, 1, 1)
except ValueError:
return None
except TypeError:
return None
if flag is 1: # Date found, but no time. Use the default time.
date = datetime(*date[:3], hour=self.config['default_hour'], minute=self.config['default_minute'])
else:
date = datetime(*date[:6])
# Ugly heuristic: if the date is more than 4 weeks in the future, we got the year wrong.
# Rather then this, we would like to see parsedatetime patched so we can tell it to prefer
# past dates
dt = datetime.now() - date
if dt.days < -28:
date = date.replace(date.year - 1)
return date
def new_entry(self, raw, date=None, sort=True):
"""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')
starred = False
# Split raw text into title and body
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 date or starred: # Parsed successfully, strip that from the raw text
title = title[title.find(": ")+1:].strip()
elif title.strip().startswith("*"):
starred = True
title = title[1:].strip()
elif title.strip().endswith("*"):
starred = True
title = title[:-1].strip()
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):
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:
dict_entry = plistlib.readPlist(plist_entry)
try:
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)
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", [])
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:
if entry.modified:
if not hasattr(entry, "uuid"):
entry.uuid = uuid.uuid1().hex
utc_time = datetime.utcfromtimestamp(time.mktime(entry.date.timetuple()))
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': str(tzlocal.get_localzone()),
'UUID': entry.uuid,
'Tags': [tag.strip(self.config['tagsymbols']) 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")
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

@ -0,0 +1,18 @@
#!/usr/bin/env python
# encoding: utf-8
"""
jrnl is a simple journal application for your command line.
"""
from __future__ import absolute_import
__title__ = 'jrnl'
__version__ = '1.7.10'
__author__ = 'Manuel Ebert'
__license__ = 'MIT License'
__copyright__ = 'Copyright 2013 - 2014 Manuel Ebert'
from . import Journal
from . import cli
from .cli import run

239
build/lib/jrnl/cli.py Normal file
View file

@ -0,0 +1,239 @@
#!/usr/bin/env python
# encoding: utf-8
"""
jrnl
license: MIT, see LICENSE for more details.
"""
from __future__ import absolute_import
from . import Journal
from . import util
from . import exporters
from . import install
from . import __version__
import jrnl
import os
import argparse
import sys
xdg_config = os.environ.get('XDG_CONFIG_HOME')
CONFIG_PATH = os.path.join(xdg_config, "jrnl") if xdg_config else os.path.expanduser('~/.jrnl_config')
PYCRYPTO = install.module_exists("Crypto")
def parse_args(args=None):
parser = argparse.ArgumentParser()
parser.add_argument('-v', '--version', dest='version', action="store_true", help="prints version information and exits")
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('-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)
exporting = parser.add_argument_group('Export / Import', 'Options for transmogrifying your journal')
exporting.add_argument('--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', help='Export your journal to Markdown, JSON or Text', default=False, const=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('--edit', dest='edit', help='Opens your editor to edit the selected entries.', action="store_true")
return parser.parse_args(args)
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.edit)):
compose = False
export = True
elif any((args.start_date, args.end_date, args.limit, args.strict, args.starred)):
# Any sign of displaying stuff?
compose = False
elif args.text and all(word[0] in config['tagsymbols'] for word in " ".join(args.text).split()):
# No date and only tags?
compose = False
return compose, export
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: ")
journal.make_key(password)
journal.config['encrypt'] = True
journal.write(filename)
if util.yesno("Do you want to store the password in your keychain?", default=True):
util.set_keychain(journal.name, password)
util.prompt("Journal encrypted to {0}.".format(filename or journal.config['journal']))
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.write(filename)
util.prompt("Journal decrypted to {0}.".format(filename or 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))
open(filename, 'a').close()
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)
elif scope and force_local: # Convert to dict
config['journals'][scope] = {"journal": config['journals'][scope]}
config['journals'][scope].update(new_config)
else:
config.update(new_config)
def run(manual_args=None):
args = parse_args(manual_args)
if args.version:
version_str = "{0} version {1}".format(jrnl.__title__, jrnl.__version__)
print(util.py2encode(version_str))
sys.exit(0)
if not os.path.exists(CONFIG_PATH):
config = install.install_jrnl(CONFIG_PATH)
else:
config = util.load_and_fix_json(CONFIG_PATH)
install.upgrade_config(config, config_path=CONFIG_PATH)
original_config = config.copy()
# check if the configuration is supported by available modules
if config['encrypt'] and not PYCRYPTO:
util.prompt("According to your jrnl_conf, your journal is encrypted, however PyCrypto was not found. To open your journal, install the PyCrypto package from http://www.pycrypto.org.")
sys.exit(1)
# 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':
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:
args.limit = int(args.text[0].lstrip("-"))
args.text = args.text[1:]
except:
pass
journal_conf = config['journals'].get(journal_name)
if type(journal_conf) is dict: # We can override the default config on a by-journal basis
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(config['journal'])
touch_journal(config['journal'])
mode_compose, mode_export = guess_mode(args, config)
# open journal file or folder
if os.path.isdir(config['journal']):
if config['journal'].strip("/").endswith(".dayone") or \
"entries" in os.listdir(config['journal']):
journal = Journal.DayOne(**config)
else:
util.prompt("[Error: {0} is a directory, but doesn't seem to be a DayOne journal either.".format(config['journal']))
sys.exit(1)
else:
journal = Journal.Journal(journal_name, **config)
# How to quit writing?
if "win32" in sys.platform:
_exit_multiline_code = "on a blank line, press Ctrl+Z and then Enter"
else:
_exit_multiline_code = "press Ctrl+D"
if mode_compose and not args.text:
if not sys.stdin.isatty():
# Piping data into jrnl
raw = util.py23_read()
elif config['editor']:
raw = util.get_text_from_editor(config)
else:
raw = util.py23_read("[Compose Entry; " + _exit_multiline_code + " to finish writing]\n")
if raw:
args.text = [raw]
else:
mode_compose = False
# Writing mode
if mode_compose:
raw = " ".join(args.text).strip()
if util.PY2 and type(raw) is not unicode:
raw = raw.decode(sys.getfilesystemencoding())
entry = journal.new_entry(raw)
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,
short=args.short,
starred=args.starred)
journal.limit(args.limit)
# Reading mode
if not mode_compose and not mode_export:
print(util.py2encode(journal.pprint()))
# Various export modes
elif args.short:
print(util.py2encode(journal.pprint(short=True)))
elif args.tags:
print(util.py2encode(exporters.to_tag_list(journal)))
elif args.export is not False:
print(util.py2encode(exporters.export(journal, args.export, args.output)))
elif (args.encrypt is not False or args.decrypt is not False) and not PYCRYPTO:
util.prompt("PyCrypto not found. To encrypt or decrypt your journal, install the PyCrypto package from http://www.pycrypto.org.")
elif args.encrypt is not False:
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)
install.save_config(original_config, config_path=CONFIG_PATH)
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)
install.save_config(original_config, config_path=CONFIG_PATH)
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__":
run()

106
build/lib/jrnl/exporters.py Normal file
View file

@ -0,0 +1,106 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import
import os
import json
from .util import u, slugify
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)
]
# 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])
return tag_counts
def to_tag_list(journal):
"""Prints a list of all tags and the number of occurrences."""
tag_counts = get_tags_count(journal)
result = ""
if not tag_counts:
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(u"{0:20} : {1}".format(tag, n) for n, tag in sorted(tag_counts, reverse=True))
return result
def to_json(journal):
"""Returns a JSON representation of the Journal."""
tags = get_tags_count(journal)
result = {
"tags": dict((tag, count) for count, tag in tags),
"entries": [e.to_dict() for e in journal.entries]
}
return json.dumps(result, indent=2)
def to_md(journal):
"""Returns a markdown representation of the Journal"""
out = []
year, month = -1, -1
for e in journal.entries:
if not e.date.year == year:
year = e.date.year
out.append(str(year))
out.append("=" * len(str(year)) + "\n")
if not e.date.month == month:
month = e.date.month
out.append(e.date.strftime("%B"))
out.append('-' * len(e.date.strftime("%B")) + "\n")
out.append(e.to_md())
result = "\n".join(out)
return result
def to_txt(journal):
"""Returns the complete text of the Journal."""
return journal.pprint()
def export(journal, format, output=None):
"""Exports the journal to various formats.
format should be one of json, txt, text, md, markdown.
If output is None, returns a unicode representation of the output.
If output is a directory, exports entries into individual files.
Otherwise, exports to the given output file.
"""
maps = {
"json": to_json,
"txt": to_txt,
"text": to_txt,
"md": to_md,
"markdown": to_md
}
if output and os.path.isdir(output): # multiple files
return write_files(journal, output, format)
else:
content = maps[format](journal)
if output:
try:
with open(output, 'w') as f:
f.write(content)
return "[Journal exported to {0}]".format(output)
except IOError as e:
return "[ERROR: {0} {1}]".format(e.filename, e.strerror)
else:
return content
def write_files(journal, path, format):
"""Turns your journal into separate files for each entry.
Format should be either json, md or txt."""
make_filename = lambda entry: e.date.strftime("%C-%m-%d_{0}.{1}".format(slugify(u(e.title)), format))
for e in journal.entries:
full_path = os.path.join(path, make_filename(e))
if format == 'json':
content = json.dumps(e.to_dict(), indent=2) + "\n"
elif format == 'md':
content = e.to_md()
elif format == 'txt':
content = u(e)
with open(full_path, 'w') as f:
f.write(content)
return "[Journal exported individual files in {0}]".format(path)

92
build/lib/jrnl/install.py Normal file
View file

@ -0,0 +1,92 @@
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import
import readline
import glob
import getpass
import json
import os
from . import util
def module_exists(module_name):
"""Checks if a module exists and can be imported"""
try:
__import__(module_name)
except ImportError:
return False
else:
return True
default_config = {
'journals': {
"default": os.path.expanduser("~/journal.txt")
},
'editor': "",
'encrypt': False,
'default_hour': 9,
'default_minute': 0,
'timeformat': "%Y-%m-%d %H:%M",
'tagsymbols': '@',
'highlight': True,
'linewrap': 79,
}
def upgrade_config(config, config_path=os.path.expanduser("~/.jrnl_conf")):
"""Checks if there are keys missing in a given config dict, and if so, updates the config file accordingly.
This essentially automatically ports jrnl installations if new config parameters are introduced in later
versions."""
missing_keys = set(default_config).difference(config)
if missing_keys:
for key in missing_keys:
config[key] = default_config[key]
with open(config_path, 'w') as f:
json.dump(config, f, indent=2)
print("[.jrnl_conf updated to newest version]")
def save_config(config=default_config, config_path=os.path.expanduser("~/.jrnl_conf")):
with open(config_path, 'w') as f:
json.dump(config, f, indent=2)
def install_jrnl(config_path='~/.jrnl_config'):
def autocomplete(text, state):
expansions = glob.glob(os.path.expanduser(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)
# Where to create the journal?
path_query = 'Path to your journal file (leave blank for ~/journal.txt): '
journal_path = util.py23_input(path_query).strip() or os.path.expanduser('~/journal.txt')
default_config['journals']['default'] = os.path.expanduser(journal_path)
# Encrypt it?
if module_exists("Crypto"):
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)
print("Journal will be encrypted.")
else:
password = None
print("PyCrypto not found. To encrypt your journal, install the PyCrypto package from http://www.pycrypto.org or with 'pip install pycrypto' and run 'jrnl --encrypt'. For now, your journal will be stored in plain text.")
open(default_config['journals']['default'], 'a').close() # Touch to make sure it's there
# Write config to ~/.jrnl_conf
with open(config_path, 'w') as f:
json.dump(default_config, f, indent=2)
config = default_config
if password:
config['password'] = password
return config

143
build/lib/jrnl/util.py Normal file
View file

@ -0,0 +1,143 @@
#!/usr/bin/env python
# encoding: utf-8
import sys
import os
from tzlocal import get_localzone
import getpass as gp
import keyring
import pytz
import json
if "win32" in sys.platform:
import colorama
colorama.init()
import re
import tempfile
import subprocess
import codecs
import unicodedata
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
def getpass(prompt="Password: "):
if not TEST:
return gp.getpass(prompt)
else:
return py23_input(prompt)
def get_password(validator, keychain=None, max_attempts=3):
pwd_from_keychain = keychain and get_keychain(keychain)
password = pwd_from_keychain or getpass()
result = validator(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)
attempt += 1
if result is not None:
return result
else:
prompt("Extremely wrong password.")
sys.exit(1)
def get_keychain(journal_name):
return keyring.get_password('jrnl', journal_name)
def set_keychain(journal_name, password):
if password is None:
try:
keyring.delete_password('jrnl', journal_name)
except:
pass
elif not TEST:
keyring.set_password('jrnl', journal_name, password)
def u(s):
"""Mock unicode function for python 2 and 3 compatibility."""
return s if PY3 or type(s) is unicode else unicode(s.encode('string-escape'), "unicode_escape")
def py2encode(s):
"""Encode in Python 2, but not in python 3."""
return s.encode("utf-8") if PY2 and type(s) is unicode else s
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=""):
STDERR.write(u(msg))
return STDIN.readline().strip()
def py23_read(msg=""):
STDERR.write(u(msg))
return STDIN.read()
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)
def load_and_fix_json(json_path):
"""Tries to load a json object from a file.
If that fails, tries to fix common errors (no or extra , at end of the line).
"""
with open(json_path) as f:
json_str = f.read()
config = fixed = None
try:
return json.loads(json_str)
except ValueError as e:
# Attempt to fix extra ,
json_str = re.sub(r",[ \n]*}", "}", json_str)
# Attempt to fix missing ,
json_str = re.sub(r"([^{,]) *\n *(\")", r"\1,\n \2", json_str)
try:
config = json.loads(json_str)
with open(json_path, 'w') as f:
json.dump(config, f, indent=2)
prompt("[Some errors in your jrnl config have been fixed for you.]")
return config
except ValueError as e:
prompt("[There seems to be something wrong with your jrnl config at {0}: {1}]".format(json_path, e.message))
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")
with codecs.open(tmpfile, 'w', "utf-8") as f:
if template:
f.write(template)
subprocess.call(config['editor'].split() + [tmpfile])
with codecs.open(tmpfile, "r", "utf-8") as f:
raw = f.read()
os.remove(tmpfile)
if not raw:
prompt('[Nothing saved to file]')
return raw
def colorize(string):
"""Returns the string wrapped in cyan ANSI escape"""
return u"\033[36m{}\033[39m".format(string)
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'))
no_punctuation = re.sub(r'[^\w\s-]', '', ascii_string).strip().lower()
slug = re.sub(r'[-\s]+', '-', no_punctuation)
return u(slug)

View file

@ -7,6 +7,14 @@ Basic Usage
We intentionally break a convention on command line arguments: all arguments starting with a `single dash` will `filter` your journal before viewing it, and can be combined arbitrarily. Arguments with a `double dash` will control how your journal is displayed or exported and are mutually exclusive (ie. you can only specify one way to display or export your journal at a time).
Listing Journals
----------------
You can list the journals accessible by jrnl::
jrnl -ls
The journals displayed correspond to those specified in the jrnl configuration file.
Composing Entries
-----------------

View file

@ -42,11 +42,11 @@ 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+" ",
textwrap.fill((line + " ") if (len(line) == 0) else line,
self.journal.config['linewrap'],
initial_indent="| ",
subsequent_indent="| ",
drop_whitespace=False).replace(' ', ' ')
drop_whitespace=False)
for line in self.body.strip().splitlines()
])
else:

View file

@ -25,6 +25,7 @@ import pytz
import uuid
import tzlocal
class Journal(object):
def __init__(self, name='default', **kwargs):
self.config = {
@ -318,6 +319,7 @@ class Journal(object):
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):
@ -342,7 +344,7 @@ class DayOne(Journal):
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", [])
entry.tags = [self.config['tagsymbols'][0] + tag for tag in dict_entry.get("Tags", [])]
self.entries.append(entry)
self.sort()
@ -430,4 +432,3 @@ class DayOne(Journal):
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

@ -8,7 +8,7 @@ jrnl is a simple journal application for your command line.
from __future__ import absolute_import
__title__ = 'jrnl'
__version__ = '1.7.10'
__version__ = '1.7.12'
__author__ = 'Manuel Ebert'
__license__ = 'MIT License'
__copyright__ = 'Copyright 2013 - 2014 Manuel Ebert'

View file

@ -26,6 +26,7 @@ PYCRYPTO = install.module_exists("Crypto")
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")
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="*")
@ -87,6 +88,14 @@ def touch_journal(filename):
util.prompt("[Journal created at {0}]".format(filename))
open(filename, 'a').close()
def list_journals(config):
"""List the journals specified in the configuration file"""
sep = "\n"
journal_list = sep.join(config['journals'])
return journal_list
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,
@ -113,6 +122,10 @@ def run(manual_args=None):
config = util.load_and_fix_json(CONFIG_PATH)
install.upgrade_config(config, config_path=CONFIG_PATH)
if args.ls:
print(util.py2encode(list_journals(config)))
sys.exit(0)
original_config = config.copy()
# check if the configuration is supported by available modules
if config['encrypt'] and not PYCRYPTO: