diff --git a/build/lib/jrnl/Entry.py b/build/lib/jrnl/Entry.py new file mode 100644 index 00000000..142286ed --- /dev/null +++ b/build/lib/jrnl/Entry.py @@ -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 "".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 + ) diff --git a/build/lib/jrnl/Journal.py b/build/lib/jrnl/Journal.py new file mode 100644 index 00000000..b5d7a664 --- /dev/null +++ b/build/lib/jrnl/Journal.py @@ -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 "".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 + diff --git a/build/lib/jrnl/__init__.py b/build/lib/jrnl/__init__.py new file mode 100644 index 00000000..12c55ed0 --- /dev/null +++ b/build/lib/jrnl/__init__.py @@ -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 diff --git a/build/lib/jrnl/cli.py b/build/lib/jrnl/cli.py new file mode 100644 index 00000000..fe00977d --- /dev/null +++ b/build/lib/jrnl/cli.py @@ -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() diff --git a/build/lib/jrnl/exporters.py b/build/lib/jrnl/exporters.py new file mode 100644 index 00000000..b8463e03 --- /dev/null +++ b/build/lib/jrnl/exporters.py @@ -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) diff --git a/build/lib/jrnl/install.py b/build/lib/jrnl/install.py new file mode 100644 index 00000000..fa78ca0c --- /dev/null +++ b/build/lib/jrnl/install.py @@ -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 diff --git a/build/lib/jrnl/util.py b/build/lib/jrnl/util.py new file mode 100644 index 00000000..4b252cbd --- /dev/null +++ b/build/lib/jrnl/util.py @@ -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) + diff --git a/docs/usage.rst b/docs/usage.rst index 0b7f487f..2f42b9c2 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -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 ----------------- diff --git a/jrnl/Entry.py b/jrnl/Entry.py index 142286ed..d4d3bb6d 100644 --- a/jrnl/Entry.py +++ b/jrnl/Entry.py @@ -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: diff --git a/jrnl/Journal.py b/jrnl/Journal.py index b5d7a664..b450a25e 100644 --- a/jrnl/Journal.py +++ b/jrnl/Journal.py @@ -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() @@ -353,7 +355,7 @@ class DayOne(Journal): 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") + 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, @@ -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 - diff --git a/jrnl/__init__.py b/jrnl/__init__.py index 12c55ed0..da3f2a08 100644 --- a/jrnl/__init__.py +++ b/jrnl/__init__.py @@ -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' diff --git a/jrnl/cli.py b/jrnl/cli.py index fe00977d..abbefea1 100644 --- a/jrnl/cli.py +++ b/jrnl/cli.py @@ -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: