From 19ed9a6cf8589832b2d5eb6fa8daf2ccc9270016 Mon Sep 17 00:00:00 2001 From: Manuel Ebert Date: Thu, 24 May 2012 13:23:46 +0200 Subject: [PATCH] Experimental new directory structure --- jrnl.py | 531 ----------------------------- jrnl/Entry.py | 56 +++ jrnl/Journal.py | 268 +++++++++++++++ jrnl/__init__.py | 5 + jrnl/install.py | 73 ++++ jrnl/jrnl.py | 198 +++++++++++ setup.py | 34 +- test_jrnl.py => tests/test_jrnl.py | 0 8 files changed, 623 insertions(+), 542 deletions(-) delete mode 100755 jrnl.py create mode 100644 jrnl/Entry.py create mode 100644 jrnl/Journal.py create mode 100644 jrnl/__init__.py create mode 100644 jrnl/install.py create mode 100755 jrnl/jrnl.py rename test_jrnl.py => tests/test_jrnl.py (100%) diff --git a/jrnl.py b/jrnl.py deleted file mode 100755 index 429f0c6a..00000000 --- a/jrnl.py +++ /dev/null @@ -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 "" % 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)) - diff --git a/jrnl/Entry.py b/jrnl/Entry.py new file mode 100644 index 00000000..35931bbf --- /dev/null +++ b/jrnl/Entry.py @@ -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 + } \ No newline at end of file diff --git a/jrnl/Journal.py b/jrnl/Journal.py new file mode 100644 index 00000000..374b8a6d --- /dev/null +++ b/jrnl/Journal.py @@ -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 "" % 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) diff --git a/jrnl/__init__.py b/jrnl/__init__.py new file mode 100644 index 00000000..38269459 --- /dev/null +++ b/jrnl/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +# encoding: utf-8 + +import Journal +from jrnl import cli diff --git a/jrnl/install.py b/jrnl/install.py new file mode 100644 index 00000000..debbaf7e --- /dev/null +++ b/jrnl/install.py @@ -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 + + \ No newline at end of file diff --git a/jrnl/jrnl.py b/jrnl/jrnl.py new file mode 100755 index 00000000..8a0657eb --- /dev/null +++ b/jrnl/jrnl.py @@ -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() + + \ No newline at end of file diff --git a/setup.py b/setup.py index 2d0033ab..7ba19896 100644 --- a/setup.py +++ b/setup.py @@ -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. @@ -34,35 +37,44 @@ Links """ -from setuptools import setup, find_packages -import os.path +try: + from setuptools import setup +except ImportError: + from distutils.core import setup +import os 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__)) setup( 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", - - packages = find_packages(), - scripts = ['jrnl.py'], + packages = ['jrnl'], install_requires = ["parsedatetime", "simplejson"], extras_require = { 'encryption': ["pycrypto"], 'highlight': ["cling"] }, - package_data={'': ['*.md']}, + entry_points={ + 'console_scripts': [ + 'jrnl = jrnl:cli', + ], + }, long_description=__doc__, classifiers=[ - 'Development Status :: 4 - Beta', - 'Development Status :: 4 - Beta', + 'Development Status :: 3 - Alpha', 'Environment :: Console', 'Intended Audience :: End Users/Desktop', - 'License :: Freely Distributable', 'License :: OSI Approved :: MIT License', 'Natural Language :: English', 'Operating System :: OS Independent', + 'Programming Language :: Python', 'Programming Language :: Python :: 2.7', 'Topic :: Office/Business :: News/Diary', 'Topic :: Text Processing' @@ -73,4 +85,4 @@ setup( license = "MIT License", keywords = "journal todo todo.txt jrnl".split(), url = "http://maebert.github.com/jrnl", # project home page, if any -) \ No newline at end of file +) diff --git a/test_jrnl.py b/tests/test_jrnl.py similarity index 100% rename from test_jrnl.py rename to tests/test_jrnl.py