mirror of
https://github.com/jrnl-org/jrnl.git
synced 2025-05-10 16:48:31 +02:00
commit
99e8679b48
10 changed files with 631 additions and 552 deletions
|
@ -1,6 +1,8 @@
|
||||||
Changelog
|
Changelog
|
||||||
=========
|
=========
|
||||||
|
|
||||||
|
* Changed directory structure and install scripts (removing the necessity to make an alias from `jrnl` to `jrnl.py`)
|
||||||
|
|
||||||
### 0.2.4
|
### 0.2.4
|
||||||
|
|
||||||
* Fixes parsing of new lines in journal files and entries
|
* Fixes parsing of new lines in journal files and entries
|
||||||
|
|
14
README.md
14
README.md
|
@ -73,20 +73,16 @@ Timestamps that work:
|
||||||
Installation
|
Installation
|
||||||
------------
|
------------
|
||||||
|
|
||||||
You can install _jrnl_ manually by cloning the repository:
|
Install _jrnl_ using pip:
|
||||||
|
|
||||||
|
pip install jrnl
|
||||||
|
|
||||||
|
Alternatively, install manually by cloning the repository:
|
||||||
|
|
||||||
git clone git://github.com/maebert/jrnl.git
|
git clone git://github.com/maebert/jrnl.git
|
||||||
cd jrnl
|
cd jrnl
|
||||||
python setup.py install
|
python setup.py install
|
||||||
|
|
||||||
or by using pip:
|
|
||||||
|
|
||||||
pip install jrnl
|
|
||||||
|
|
||||||
Afterwards, you may want to create an alias in your `.bashrc` or `.bash_profile` or whatever floats your shell:
|
|
||||||
|
|
||||||
alias jrnl="jrnl.py"
|
|
||||||
|
|
||||||
### Known Issues
|
### Known Issues
|
||||||
|
|
||||||
- The Windows shell prior to Windows 7 has issues with unicode encoding. If you want to use non-ascii characters, change the codepage with `chcp 1252` before using `jrnl` (Thanks to Yves Pouplard for solving this!)
|
- The Windows shell prior to Windows 7 has issues with unicode encoding. If you want to use non-ascii characters, change the codepage with `chcp 1252` before using `jrnl` (Thanks to Yves Pouplard for solving this!)
|
||||||
|
|
531
jrnl.py
531
jrnl.py
|
@ -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
56
jrnl/Entry.py
Normal 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
268
jrnl/Journal.py
Normal 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
5
jrnl/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# encoding: utf-8
|
||||||
|
|
||||||
|
from Journal import Journal
|
||||||
|
from jrnl import cli
|
73
jrnl/install.py
Normal file
73
jrnl/install.py
Normal 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
198
jrnl/jrnl.py
Executable 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.5'
|
||||||
|
__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()
|
||||||
|
|
||||||
|
|
36
setup.py
36
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.
|
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.2.5",
|
||||||
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']},
|
|
||||||
long_description=__doc__,
|
long_description=__doc__,
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'jrnl = jrnl:cli',
|
||||||
|
],
|
||||||
|
},
|
||||||
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'
|
||||||
|
@ -72,5 +84,5 @@ setup(
|
||||||
author_email = "manuel.ebert@upf.edu",
|
author_email = "manuel.ebert@upf.edu",
|
||||||
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",
|
||||||
)
|
)
|
||||||
|
|
Loading…
Add table
Reference in a new issue