Experimental new directory structure

This commit is contained in:
Manuel Ebert 2012-05-24 13:23:46 +02:00
parent 5bee8ff1e1
commit 19ed9a6cf8
8 changed files with 623 additions and 542 deletions

531
jrnl.py
View file

@ -1,531 +0,0 @@
#!/usr/bin/env python
# encoding: utf-8
"""
jrnl
license: MIT, see LICENSE for more details.
"""
import os
import tempfile
import parsedatetime.parsedatetime as pdt
import parsedatetime.parsedatetime_consts as pdc
import subprocess
import re
import argparse
from datetime import datetime
import time
try: import simplejson as json
except ImportError: import json
import sys
import readline, glob
try:
from Crypto.Cipher import AES
from Crypto.Random import random, atfork
PYCRYPTO = True
except ImportError:
PYCRYPTO = False
import hashlib
import getpass
try:
import clint
CLINT = True
except ImportError:
CLINT = False
__title__ = 'jrnl'
__version__ = '0.2.4'
__author__ = 'Manuel Ebert, Stephan Gabler'
__license__ = 'MIT'
default_config = {
'journal': os.path.expanduser("~/journal.txt"),
'editor': "",
'encrypt': False,
'password': "",
'default_hour': 9,
'default_minute': 0,
'timeformat': "%Y-%m-%d %H:%M",
'tagsymbols': '@',
'highlight': True,
}
CONFIG_PATH = os.path.expanduser('~/.jrnl_config')
class Entry:
def __init__(self, journal, date=None, title="", body=""):
self.journal = journal # Reference to journal mainly to access it's config
self.date = date
self.title = title.strip()
self.body = body.strip()
self.tags = self.parse_tags()
def parse_tags(self):
fulltext = " ".join([self.title, self.body]).lower()
tags = re.findall(r"([%s]\w+)" % self.journal.config['tagsymbols'], fulltext)
self.tags = set(tags)
def __str__(self):
date_str = self.date.strftime(self.journal.config['timeformat'])
body_wrapper = "\n" if self.body else ""
body = body_wrapper + self.body.strip()
space = "\n"
return "%(date)s %(title)s %(body)s %(space)s" % {
'date': date_str,
'title': self.title,
'body': body,
'space': space
}
def __repr__(self):
return str(self)
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")
}
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 "%(md)s %(date)s, %(title)s %(body)s %(space)s" % {
'md': md_head,
'date': date_str,
'title': self.title,
'body': body,
'space': space
}
class Journal:
def __init__(self, config, **kwargs):
config.update(kwargs)
self.config = config
# Set up date parser
consts = pdc.Constants()
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
journal_txt = self.open()
self.entries = self.parse(journal_txt)
self.sort()
def _colorize(self, string, color='red'):
if CLINT:
return str(clint.textui.colored.ColoredString(color.upper(), string))
else:
return string
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 cipher:
return ""
crypto = AES.new(self.key, AES.MODE_CBC, cipher[:16])
plain = crypto.decrypt(cipher[16:])
if plain[-1] != " ": # Journals are always padded
return None
else:
return plain
def _encrypt(self, plain):
"""Encrypt a plaintext string using self.key as the key"""
atfork() # A seed for PyCrypto
iv = ''.join(chr(random.randint(0, 0xFF)) for i in range(16))
crypto = AES.new(self.key, AES.MODE_CBC, iv)
if len(plain) % 16 != 0:
plain += " " * (16 - len(plain) % 16)
else: # Always pad so we can detect properly decrypted files :)
plain += " " * 16
return iv + crypto.encrypt(plain)
def make_key(self, prompt="Password: "):
"""Creates an encryption key from the default password or prompts for a new password."""
password = self.config['password'] or getpass.getpass(prompt)
self.key = hashlib.sha256(password).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']
journal = None
with open(filename) as f:
journal = f.read()
if self.config['encrypt']:
decrypted = None
attempts = 0
while decrypted is None:
self.make_key()
decrypted = self._decrypt(journal)
if decrypted is None:
attempts += 1
self.config['password'] = None # This password doesn't work.
if attempts < 3:
print("Wrong password, try again.")
else:
print("Extremely wrong password.")
sys.exit(-1)
journal = decrypted
return journal
def parse(self, journal):
"""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.splitlines():
try:
# try to parse line as date => new entry begins
new_date = datetime.strptime(line[:date_length], self.config['timeformat'])
# parsing successfull => save old entry and create new one
if new_date and current_entry:
entries.append(current_entry)
current_entry = Entry(self, date=new_date, title=line[date_length+1:])
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.
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 __str__(self):
"""Prettyprints the journal's entries"""
sep = "-"*60+"\n"
pp = sep.join([str(e) for e in self.entries])
if self.config['highlight']: # highlight tags
if hasattr(self, 'search_tags'):
for tag in self.search_tags:
pp = pp.replace(tag, self._colorize(tag))
else:
pp = re.sub(r"([%s]\w+)" % self.config['tagsymbols'],
lambda match: self._colorize(match.group(0), 'cyan'),
pp)
return pp
def to_json(self):
"""Returns a JSON representation of the Journal."""
return json.dumps([e.to_dict() for e in self.entries], indent=2)
def to_md(self):
"""Returns a markdown representation of the Journal"""
out = []
year, month = -1, -1
for e in self.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())
return "\n".join(out)
def __repr__(self):
return "<Journal with %d entries>" % len(self.entries)
def write(self, filename = None):
"""Dumps the journal into the config file, overwriting it"""
filename = filename or self.config['journal']
journal = "\n".join([str(e) for e in self.entries])
if self.config['encrypt']:
journal = self._encrypt(journal)
with open(filename, 'w') 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, 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.
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 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('%s %s ..' % (date, excerpt))
e.body = "\n".join(res)
else:
for e in self.entries:
e.body = ''
self.entries = result
def parse_date(self, date):
"""Parses a string containing a fuzzy date and returns a datetime.datetime object"""
if not date:
return None
elif type(date) is datetime:
return date
date, flag = self.dateparse.parse(date)
if not flag: # Oops, unparsable.
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])
return date
def new_entry(self, raw, date=None):
"""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."""
# Split raw text into title and body
title_end = len(raw)
for separator in ".?!":
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()
if not date:
if title.find(":") > 0:
date = self.parse_date(title[:title.find(":")])
if date: # Parsed successfully, strip that from the raw text
title = title[title.find(":")+1:].strip()
if not date: # Still nothing? Meh, just live in the moment.
date = self.parse_date("now")
self.entries.append(Entry(self, date, title, body))
self.sort()
def save_config(self, config_path = CONFIG_PATH):
with open(config_path, 'w') as f:
json.dump(self.config, f, indent=2)
def setup():
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 = raw_input(path_query).strip() or os.path.expanduser('~/journal.txt')
default_config['journal'] = os.path.expanduser(journal_path)
# Encrypt it?
if PYCRYPTO:
password = getpass.getpass("Enter password for journal (leave blank for no encryption): ")
if password:
default_config['encrypt'] = True
print("Journal will be encrypted.")
print("If you want to, you can store your password in .jrnl_config and will never be bothered about it again.")
else:
password = None
print("PyCrypto not found. To encrypt your journal, install the PyCrypto package from http://www.pycrypto.org and run 'jrnl --encrypt'. For now, your journal will be stored in plain text.")
# Use highlighting:
if not CLINT:
print("clint not found. To turn on highlighting, install clint and set highlight to true in your .jrnl_conf.")
default_config['highlight'] = False
open(default_config['journal'], '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
if __name__ == "__main__":
if not os.path.exists(CONFIG_PATH):
config = setup()
else:
with open(CONFIG_PATH) as f:
config = json.load(f)
# update config file with settings introduced in a later version
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)
# check if the configuration is supported by available modules
if config['encrypt'] and not PYCRYPTO:
print("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)
parser = argparse.ArgumentParser()
composing = parser.add_argument_group('Composing', 'Will make an entry out of whatever follows as arguments')
composing.add_argument('-date', dest='date', help='Date, e.g. "yesterday at 5pm"')
composing.add_argument('text', metavar='text', nargs="*", help='Log entry (or tags by which to filter in viewing mode)')
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('-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('-n', dest='limit', default=None, metavar="N", help='Shows the last n entries matching the filter', nargs="?", type=int)
reading.add_argument('-short', dest='short', action="store_true", help='Show only titles or line containing the search tags')
exporting = parser.add_argument_group('Export / Import', 'Options for transmogrifying your journal')
exporting.add_argument('--tags', dest='tags', action="store_true", help='Returns a list of all tags and number of occurences')
exporting.add_argument('--json', dest='json', action="store_true", help='Returns a JSON-encoded version of the Journal')
exporting.add_argument('--markdown', dest='markdown', action="store_true", help='Returns a Markdown-formated version of the Journal')
exporting.add_argument('--encrypt', dest='encrypt', help='Encrypts your existing journal with a new password', nargs='?', default=False, const=True)
exporting.add_argument('--decrypt', dest='decrypt', help='Decrypts your journal and stores it in plain text', nargs='?', default=False, const=True)
args = parser.parse_args()
# Guess mode
compose = True
export = False
if args.json or args.decrypt or args.encrypt or args.markdown or args.tags:
compose = False
export = True
elif args.start_date or args.end_date or args.limit or args.strict or args.short:
# Any sign of displaying stuff?
compose = False
elif not args.date and args.text and all(word[0] in config['tagsymbols'] for word in args.text):
# No date and only tags?
compose = False
# No text? Query
if compose and not args.text:
if config['editor']:
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:
print('nothing saved to file')
raw = ''
else:
raw = raw_input("Compose Entry: ")
if raw:
args.text = [raw]
else:
compose = False
# open journal
journal = Journal(config=config)
# Writing mode
if compose:
raw = " ".join(args.text).strip()
journal.new_entry(raw, args.date)
print("Entry added.")
journal.write()
elif not export: # read mode
journal.filter(tags=args.text,
start_date=args.start_date, end_date=args.end_date,
strict=args.strict,
short=args.short)
journal.limit(args.limit)
print(journal)
elif args.tags: # get all tags
# 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 = {(tags.count(tag), tag) for tag in tags}
for n, tag in sorted(tag_counts, reverse=True):
print("{:20} : {}".format(tag, n))
elif args.json: # export to json
print(journal.to_json())
elif args.markdown: # export to json
print(journal.to_md())
elif (args.encrypt or args.decrypt) and not PYCRYPTO:
print("PyCrypto not found. To encrypt or decrypt your journal, install the PyCrypto package from http://www.pycrypto.org.")
# Encrypt into new file If args.encrypt is True, that it is present in the command line arguments
# but isn't followed by any value - in which case we encrypt the journal file itself. Otherwise
# encrypt to a new file.
elif args.encrypt:
journal.make_key(prompt="Enter new password:")
journal.config['encrypt'] = True
journal.config['password'] = ""
if args.encrypt is True:
journal.write()
journal.save_config()
print("Journal encrypted to %s." % journal.config['journal'])
else:
journal.write(args.encrypt)
print("Journal encrypted to %s." % os.path.realpath(args.encrypt))
elif args.decrypt:
journal.config['encrypt'] = False
journal.config['password'] = ""
if args.decrypt is True:
journal.write()
journal.save_config()
print("Journal decrypted to %s." % journal.config['journal'])
else:
journal.write(args.decrypt)
print("Journal encrypted to %s." % os.path.realpath(args.decrypt))

56
jrnl/Entry.py Normal file
View file

@ -0,0 +1,56 @@
#!/usr/bin/env python
# encoding: utf-8
import re
class Entry:
def __init__(self, journal, date=None, title="", body=""):
self.journal = journal # Reference to journal mainly to access it's config
self.date = date
self.title = title.strip()
self.body = body.strip()
self.tags = self.parse_tags()
def parse_tags(self):
fulltext = " ".join([self.title, self.body]).lower()
tags = re.findall(r"([%s]\w+)" % self.journal.config['tagsymbols'], fulltext)
self.tags = set(tags)
def __str__(self):
date_str = self.date.strftime(self.journal.config['timeformat'])
body_wrapper = "\n" if self.body else ""
body = body_wrapper + self.body.strip()
space = "\n"
return "%(date)s %(title)s %(body)s %(space)s" % {
'date': date_str,
'title': self.title,
'body': body,
'space': space
}
def __repr__(self):
return str(self)
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")
}
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 "%(md)s %(date)s, %(title)s %(body)s %(space)s" % {
'md': md_head,
'date': date_str,
'title': self.title,
'body': body,
'space': space
}

268
jrnl/Journal.py Normal file
View file

@ -0,0 +1,268 @@
#!/usr/bin/env python
# encoding: utf-8
from Entry import Entry
import os
import parsedatetime.parsedatetime as pdt
import parsedatetime.parsedatetime_consts as pdc
import re
from datetime import datetime
import time
try: import simplejson as json
except ImportError: import json
import sys
import readline, glob
try:
from Crypto.Cipher import AES
from Crypto.Random import random, atfork
except ImportError:
pass
import hashlib
import getpass
try:
import clint
except ImportError:
clint = None
class Journal:
def __init__(self, config, **kwargs):
config.update(kwargs)
self.config = config
# Set up date parser
consts = pdc.Constants()
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
journal_txt = self.open()
self.entries = self.parse(journal_txt)
self.sort()
def _colorize(self, string, color='red'):
if clint:
return str(clint.textui.colored.ColoredString(color.upper(), string))
else:
return string
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 cipher:
return ""
crypto = AES.new(self.key, AES.MODE_CBC, cipher[:16])
plain = crypto.decrypt(cipher[16:])
if plain[-1] != " ": # Journals are always padded
return None
else:
return plain
def _encrypt(self, plain):
"""Encrypt a plaintext string using self.key as the key"""
atfork() # A seed for PyCrypto
iv = ''.join(chr(random.randint(0, 0xFF)) for i in range(16))
crypto = AES.new(self.key, AES.MODE_CBC, iv)
if len(plain) % 16 != 0:
plain += " " * (16 - len(plain) % 16)
else: # Always pad so we can detect properly decrypted files :)
plain += " " * 16
return iv + crypto.encrypt(plain)
def make_key(self, prompt="Password: "):
"""Creates an encryption key from the default password or prompts for a new password."""
password = self.config['password'] or getpass.getpass(prompt)
self.key = hashlib.sha256(password).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']
journal = None
with open(filename) as f:
journal = f.read()
if self.config['encrypt']:
decrypted = None
attempts = 0
while decrypted is None:
self.make_key()
decrypted = self._decrypt(journal)
if decrypted is None:
attempts += 1
self.config['password'] = None # This password doesn't work.
if attempts < 3:
print("Wrong password, try again.")
else:
print("Extremely wrong password.")
sys.exit(-1)
journal = decrypted
return journal
def parse(self, journal):
"""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.splitlines():
try:
# try to parse line as date => new entry begins
new_date = datetime.strptime(line[:date_length], self.config['timeformat'])
# parsing successfull => save old entry and create new one
if new_date and current_entry:
entries.append(current_entry)
current_entry = Entry(self, date=new_date, title=line[date_length+1:])
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.
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 __str__(self):
"""Prettyprints the journal's entries"""
sep = "-"*60+"\n"
pp = sep.join([str(e) for e in self.entries])
if self.config['highlight']: # highlight tags
if hasattr(self, 'search_tags'):
for tag in self.search_tags:
pp = pp.replace(tag, self._colorize(tag))
else:
pp = re.sub(r"([%s]\w+)" % self.config['tagsymbols'],
lambda match: self._colorize(match.group(0), 'cyan'),
pp)
return pp
def to_json(self):
"""Returns a JSON representation of the Journal."""
return json.dumps([e.to_dict() for e in self.entries], indent=2)
def to_md(self):
"""Returns a markdown representation of the Journal"""
out = []
year, month = -1, -1
for e in self.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())
return "\n".join(out)
def __repr__(self):
return "<Journal with %d entries>" % len(self.entries)
def write(self, filename = None):
"""Dumps the journal into the config file, overwriting it"""
filename = filename or self.config['journal']
journal = "\n".join([str(e) for e in self.entries])
if self.config['encrypt']:
journal = self._encrypt(journal)
with open(filename, 'w') 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, 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.
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 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('%s %s ..' % (date, excerpt))
e.body = "\n".join(res)
else:
for e in self.entries:
e.body = ''
self.entries = result
def parse_date(self, date):
"""Parses a string containing a fuzzy date and returns a datetime.datetime object"""
if not date:
return None
elif type(date) is datetime:
return date
date, flag = self.dateparse.parse(date)
if not flag: # Oops, unparsable.
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])
return date
def new_entry(self, raw, date=None):
"""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."""
# Split raw text into title and body
title_end = len(raw)
for separator in ".?!":
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()
if not date:
if title.find(":") > 0:
date = self.parse_date(title[:title.find(":")])
if date: # Parsed successfully, strip that from the raw text
title = title[title.find(":")+1:].strip()
if not date: # Still nothing? Meh, just live in the moment.
date = self.parse_date("now")
self.entries.append(Entry(self, date, title, body))
self.sort()
def save_config(self, config_path):
with open(config_path, 'w') as f:
json.dump(self.config, f, indent=2)

5
jrnl/__init__.py Normal file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env python
# encoding: utf-8
import Journal
from jrnl import cli

73
jrnl/install.py Normal file
View file

@ -0,0 +1,73 @@
#!/usr/bin/env python
# encoding: utf-8
import readline, glob
import getpass
try: import simplejson as json
except ImportError: import json
import os
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 = {
'journal': os.path.expanduser("~/journal.txt"),
'editor': "",
'encrypt': False,
'password': "",
'default_hour': 9,
'default_minute': 0,
'timeformat': "%Y-%m-%d %H:%M",
'tagsymbols': '@',
'highlight': True,
}
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 = raw_input(path_query).strip() or os.path.expanduser('~/journal.txt')
default_config['journal'] = 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
print("Journal will be encrypted.")
print("If you want to, you can store your password in .jrnl_config and will never be bothered about it again.")
else:
password = None
print("PyCrypto not found. To encrypt your journal, install the PyCrypto package from http://www.pycrypto.org and run 'jrnl --encrypt'. For now, your journal will be stored in plain text.")
# Use highlighting:
if module_exists("clint"):
print("clint not found. To turn on highlighting, install clint and set highlight to true in your .jrnl_conf.")
default_config['highlight'] = False
open(default_config['journal'], '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

198
jrnl/jrnl.py Executable file
View file

@ -0,0 +1,198 @@
#!/usr/bin/env python
# encoding: utf-8
"""
jrnl
license: MIT, see LICENSE for more details.
"""
import Journal
from install import *
import os
import tempfile
import subprocess
import argparse
import sys
try: import simplejson as json
except ImportError: import json
__title__ = 'jrnl'
__version__ = '0.2.4'
__author__ = 'Manuel Ebert, Stephan Gabler'
__license__ = 'MIT'
CONFIG_PATH = os.path.expanduser('~/.jrnl_config')
PYCRYPTO = module_exists("Crypto")
def update_config(config):
"""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)
def parse_args():
parser = argparse.ArgumentParser()
composing = parser.add_argument_group('Composing', 'Will make an entry out of whatever follows as arguments')
composing.add_argument('-date', dest='date', help='Date, e.g. "yesterday at 5pm"')
composing.add_argument('text', metavar='text', nargs="*", help='Log entry (or tags by which to filter in viewing mode)')
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('-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('-n', dest='limit', default=None, metavar="N", help='Shows the last n entries matching the filter', nargs="?", type=int)
reading.add_argument('-short', dest='short', action="store_true", help='Show only titles or line containing the search tags')
exporting = parser.add_argument_group('Export / Import', 'Options for transmogrifying your journal')
exporting.add_argument('--tags', dest='tags', action="store_true", help='Returns a list of all tags and number of occurences')
exporting.add_argument('--json', dest='json', action="store_true", help='Returns a JSON-encoded version of the Journal')
exporting.add_argument('--markdown', dest='markdown', action="store_true", help='Returns a Markdown-formated version of the Journal')
exporting.add_argument('--encrypt', dest='encrypt', help='Encrypts your existing journal with a new password', nargs='?', default=False, const=None)
exporting.add_argument('--decrypt', dest='decrypt', help='Decrypts your journal and stores it in plain text', nargs='?', default=False, const=None)
return parser.parse_args()
def guess_mode(args, config):
"""Guesses the mode (compose, read or export) from the given arguments"""
compose = True
export = False
if args.json or args.decrypt or args.encrypt or args.markdown or args.tags:
compose = False
export = True
elif args.start_date or args.end_date or args.limit or args.strict or args.short:
# Any sign of displaying stuff?
compose = False
elif not args.date and args.text and all(word[0] in config['tagsymbols'] for word in args.text):
# No date and only tags?
compose = False
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:
print('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. """
journal.make_key(prompt="Enter new password:")
journal.config['encrypt'] = True
journal.config['password'] = ""
if not filename:
journal.write()
journal.save_config(CONFIG_PATH)
print("Journal encrypted to %s." % journal.config['journal'])
else:
journal.write(filename)
print("Journal encrypted to %s." % os.path.realpath(filename))
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'] = ""
if not filename:
journal.write()
journal.save_config()
print("Journal decrypted to %s." % journal.config['journal'])
else:
journal.write(filename)
print("Journal encrypted to %s." % os.path.realpath(filename))
def print_tags(journal):
"""Prints a list of all tags and the number of occurances."""
# 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 = {(tags.count(tag), tag) for tag in tags}
for n, tag in sorted(tag_counts, reverse=True):
print("{:20} : {}".format(tag, n))
def cli():
if not os.path.exists(CONFIG_PATH):
config = install_jrnl(CONFIG_PATH)
else:
with open(CONFIG_PATH) as f:
config = json.load(f)
update_config(config)
# check if the configuration is supported by available modules
if config['encrypt'] and not PYCRYPTO:
print("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)
args = parse_args()
mode_compose, mode_export = guess_mode(args, config)
if mode_compose and not args.text:
if config['editor']:
raw = get_text_from_editor(config)
else:
raw = raw_input("Compose Entry: ")
if raw:
args.text = [raw]
else:
mode_compose = False
# open journal
journal = Journal.Journal(config=config)
# Writing mode
if mode_compose:
raw = " ".join(args.text).strip()
journal.new_entry(raw, args.date)
print("Entry added.")
journal.write()
# Reading mode
elif not mode_export:
journal.filter(tags=args.text,
start_date=args.start_date, end_date=args.end_date,
strict=args.strict,
short=args.short)
journal.limit(args.limit)
print(journal)
# Various export modes
elif args.tags:
print_tags(journal)
elif args.json: # export to json
print(journal.to_json())
elif args.markdown: # export to json
print(journal.to_md())
elif (args.encrypt is not False or args.decrypt is not False) and not PYCRYPTO:
print("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)
elif args.decrypt is not False:
decrypt(journal, filename=args.decrypt)
if __name__ == "__main__":
cli()

View file

@ -1,3 +1,6 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
""" """
jrnl is a simple journal application for your command line. Journals are stored as human readable plain text files - you can put them into a Dropbox folder for instant syncinc and you can be assured that your journal will still be readable in 2050, when all your fancy iPad journal applications will long be forgotten. jrnl is a simple journal application for your command line. Journals are stored as human readable plain text files - you can put them into a Dropbox folder for instant syncinc and you can be assured that your journal will still be readable in 2050, when all your fancy iPad journal applications will long be forgotten.
@ -34,35 +37,44 @@ Links
""" """
from setuptools import setup, find_packages try:
import os.path from setuptools import setup
except ImportError:
from distutils.core import setup
import os
import sys import sys
if sys.argv[-1] == 'publish':
os.system("python setup.py bdist-egg upload")
os.system("python setup.py sdist upload")
sys.exit()
base_dir = os.path.dirname(os.path.abspath(__file__)) base_dir = os.path.dirname(os.path.abspath(__file__))
setup( setup(
name = "jrnl", name = "jrnl",
version = "0.2.4", version = "0.3.0",
description = "A command line journal application that stores your journal in a plain text file", description = "A command line journal application that stores your journal in a plain text file",
packages = ['jrnl'],
packages = find_packages(),
scripts = ['jrnl.py'],
install_requires = ["parsedatetime", "simplejson"], install_requires = ["parsedatetime", "simplejson"],
extras_require = { extras_require = {
'encryption': ["pycrypto"], 'encryption': ["pycrypto"],
'highlight': ["cling"] 'highlight': ["cling"]
}, },
package_data={'': ['*.md']}, entry_points={
'console_scripts': [
'jrnl = jrnl:cli',
],
},
long_description=__doc__, long_description=__doc__,
classifiers=[ classifiers=[
'Development Status :: 4 - Beta', 'Development Status :: 3 - Alpha',
'Development Status :: 4 - Beta',
'Environment :: Console', 'Environment :: Console',
'Intended Audience :: End Users/Desktop', 'Intended Audience :: End Users/Desktop',
'License :: Freely Distributable',
'License :: OSI Approved :: MIT License', 'License :: OSI Approved :: MIT License',
'Natural Language :: English', 'Natural Language :: English',
'Operating System :: OS Independent', 'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 2.7',
'Topic :: Office/Business :: News/Diary', 'Topic :: Office/Business :: News/Diary',
'Topic :: Text Processing' 'Topic :: Text Processing'
@ -73,4 +85,4 @@ setup(
license = "MIT License", license = "MIT License",
keywords = "journal todo todo.txt jrnl".split(), keywords = "journal todo todo.txt jrnl".split(),
url = "http://maebert.github.com/jrnl", # project home page, if any url = "http://maebert.github.com/jrnl", # project home page, if any
) )