Merge pull request #1 from maebert/master

i think i'm re-syncing all the updates from maebrt:master back to alapolloni:master.
This commit is contained in:
Apolloni 2013-06-19 03:13:19 -07:00
commit 5daf8f010a
12 changed files with 220 additions and 106 deletions

View file

@ -1,7 +1,13 @@
language: python
python:
- "2.6"
- "2.7"
# - "3.2"
- "3.2"
- "3.3"
install: "pip install -r requirements.txt --use-mirrors"
# command to run tests
script: nosetests
matrix:
allow_failures: # python 3 support for travis is shaky....
- python: 3.2
- python: 3.3

View file

@ -1,7 +1,32 @@
Changelog
=========
### 1.0.1 (March 12, 2013)
#### 1.1.0
* [New] JSON export exports tags as well.
* [Improved] Nicer error message when there is a syntactical error in your config file.
* [Improved] Unicode support
#### 1.0.5
* [Improved] Backwards compatibility with `parsedatetime` 0.8.7
#### 1.0.4
* [Improved] Python 2.6 compatibility
* [Improved] Better utf-8 support
* [New] Python 3 compatibility
* [New] Respects the `XDG_CONFIG_HOME` environment variable for storing your configuration file (Thanks [evaryont](https://github.com/evaryont))
#### 1.0.3 (April 17, 2013)
* [Improved] Removed clint in favour of colorama
* [Fixed] Fixed a bug where showing tags failed when no tags are defined.
* [Fixed] Improvements to config parsing (Thanks [alapolloni](https://github.com/alapolloni))
* [Fixed] Fixes readline support on Windows
* [Fixed] Smaller fixes and typos
#### 1.0.1 (March 12, 2013)
* [Fixed] Requires parsedatetime 1.1.2 or newer
@ -13,11 +38,11 @@ Changelog
* [Fixed] A bug where jrnl would not add entries without timestamp
* [Fixed] Support for parsedatetime 1.x
### 0.3.2 (July 5, 2012)
#### 0.3.2 (July 5, 2012)
* [Improved] Converts `\n` to new lines (if using directly on a command line, make sure to wrap your entry with quotes).
### 0.3.1 (June 16, 2012)
#### 0.3.1 (June 16, 2012)
* [Improved] Supports deleting of last entry.
* [Fixed] Fixes a bug where --encrypt or --decrypt without a target file would not work.

View file

@ -1,4 +1,4 @@
jrnl
jrnl [![Build Status](https://travis-ci.org/maebert/jrnl.png?branch=master)](https://travis-ci.org/maebert/jrnl)
====
*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 syncing 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.
@ -33,7 +33,11 @@ Install _jrnl_ using pip:
pip install jrnl
Alternatively, install manually by cloning the repository:
Or, if you want the option to encrypt your journal,
pip install jrnl[encrypted]
To install `pycrypto` as well (Note: this requires a `gcc` compiler. You can also [install PyCyrypto manually](https://www.dlitz.net/software/pycrypto/) first)). Alternatively, install _jrnl_ manually by cloning the repository:
git clone git://github.com/maebert/jrnl.git
cd jrnl
@ -44,7 +48,7 @@ The first time you run `jrnl` you will be asked where your journal file should b
Usage
-----
_jrnl_ has to modes: __composing__ and __viewing__.
_jrnl_ has two modes: __composing__ and __viewing__.
### Viewing:
@ -104,7 +108,7 @@ With
jrnl --tags
you'll get a list of all tags you used in your journal, sorted by most frequent. Tags occuring several times in the same entry are only counted as one.
you'll get a list of all tags you used in your journal, sorted by most frequent. Tags occurring several times in the same entry are only counted as one.
### JSON export
@ -137,7 +141,7 @@ will replace your encrypted journal file by a Journal in plain text. You can als
Advanced usages
--------------
The first time launched, _jrnl_ will create a file called `.jrnl_config` in your home directory.
The first time launched, _jrnl_ will create a file configuration file at `~/.jrnl_config` or, if the `XDG_CONFIG_HOME` environment variable is set, `$XDG_CONFIG_HOME/jrnl`.
### .jrnl_config
@ -150,7 +154,7 @@ The configuration file is a simple JSON file with the following options.
- `tagsymbols`: Symbols to be interpreted as tags. (__See note below__)
- `default_hour` and `default_minute`: if you supply a date, such as `last thursday`, but no specific time, the entry will be created at this time
- `timeformat`: how to format the timestamps in your journal, see the [python docs](http://docs.python.org/library/time.html#time.strftime) for reference
- `highlight`: if `true` and you have [clint](http://www.nicosphere.net/clint-command-line-library-for-python/) installed, tags will be highlighted in cyan.
- `highlight`: if `true`, tags will be highlighted in cyan.
- `linewrap`: controls the width of the output. Set to `0` or `false` if you don't want to wrap long lines.
> __Note on `tagsymbols`:__ Although it seems intuitive to use the `#` character for tags, there's a drawback: on most shells, this is interpreted as a meta-character starting a comment. This means that if you type
@ -222,4 +226,5 @@ 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!)
- _jrnl_ relies on the `Crypto` package to encrypt journals, which has some known problems with installing within virtual environments.
- _jrnl_ relies on the `PyCrypto` package to encrypt journals, which has some known problems with installing on Windows and within virtual environments. If you have trouble installing __jrnl__, [install PyCyrypto manually](https://www.dlitz.net/software/pycrypto/) first.

View file

@ -15,16 +15,16 @@ class Entry:
def parse_tags(self):
fulltext = " ".join([self.title, self.body]).lower()
tags = re.findall(r"([%s]\w+)" % self.journal.config['tagsymbols'], fulltext)
tags = re.findall(ur'([{}]\w+)'.format(self.journal.config['tagsymbols']), fulltext, re.UNICODE)
self.tags = set(tags)
def __str__(self):
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
body = self.body.strip()
return "{title}{sep}{body}\n".format(
return u"{title}{sep}{body}\n".format(
title=title,
sep="\n" if self.body else "",
body=body
@ -50,7 +50,7 @@ class Entry:
# 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)
return "{title}{sep}{body}\n".format(
return u"{title}{sep}{body}\n".format(
title=title,
sep="\n" if has_body else "",
body=body if has_body else "",
@ -74,7 +74,7 @@ class Entry:
space = "\n"
md_head = "###"
return "{md} {date}, {title} {body} {space}".format(
return u"{md} {date}, {title} {body} {space}".format(
md=md_head,
date=date_str,
title=self.title,

View file

@ -1,30 +1,38 @@
#!/usr/bin/env python
# encoding: utf-8
from Entry import Entry
try: from . import Entry
except (SystemError, ValueError): import Entry
import codecs
import os
import parsedatetime.parsedatetime as pdt
try: import parsedatetime.parsedatetime_consts as pdt
except ImportError: import parsedatetime.parsedatetime as pdt
import re
from datetime import datetime
import time
try: import simplejson as json
except ImportError: import json
import sys
import readline, glob
import glob
try:
from Crypto.Cipher import AES
from Crypto.Random import random, atfork
crypto_installed = True
except ImportError:
pass
crypto_installed = False
if "win32" in sys.platform: import pyreadline as readline
else: import readline
import hashlib
import getpass
try:
import clint
import colorama
colorama.init()
except ImportError:
clint = None
colorama = None
import plistlib
import uuid
class Journal(object):
def __init__(self, **kwargs):
self.config = {
@ -41,23 +49,25 @@ class Journal(object):
self.config.update(kwargs)
# Set up date parser
consts = pdt.Constants()
consts.DOWParseStyle = -1 # "Monday" will be either today or the last Monday
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.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))
def _colorize(self, string):
if colorama:
return colorama.Fore.CYAN + string + colorama.Fore.RESET
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 crypto_installed:
sys.exit("Error: PyCrypto is not installed.")
if not cipher:
return ""
crypto = AES.new(self.key, AES.MODE_CBC, cipher[:16])
@ -66,35 +76,37 @@ class Journal(object):
except ValueError:
print("ERROR: Your journal file seems to be corrupted. You do have a backup, don't you?")
sys.exit(-1)
if plain[-1] != " ": # Journals are always padded
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
if not crypto_installed:
sys.exit("Error: PyCrypto is not installed.")
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 :)
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()
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']
journal = None
with open(filename) as f:
journal = f.read()
if self.config['encrypt']:
with open(filename, "rb") as f:
journal = f.read()
decrypted = None
attempts = 0
while decrypted is None:
@ -102,13 +114,16 @@ class Journal(object):
decrypted = self._decrypt(journal)
if decrypted is None:
attempts += 1
self.config['password'] = None # This password doesn't work.
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
else:
with codecs.open(filename, "r", "utf-8") as f:
journal = f.read()
return journal
def parse(self, journal):
@ -130,7 +145,7 @@ class Journal(object):
# 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:])
current_entry = 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.
@ -143,33 +158,36 @@ class Journal(object):
entry.parse_tags()
return entries
def __str__(self):
def __unicode__(self):
"""Prettyprints the journal's entries"""
sep = "\n"
pp = sep.join([e.pprint() for e in self.entries])
if self.config['highlight']: # highlight tags
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: self._colorize(match.group(0), 'cyan'),
pp)
lambda match: self._colorize(match.group(0)),
pp, re.UNICODE)
else:
pp = re.sub(r"([%s]\w+)" % self.config['tagsymbols'],
lambda match: self._colorize(match.group(0), 'cyan'),
pp = re.sub(ur"(?u)([{}]\w+)".format(self.config['tagsymbols']),
lambda match: self._colorize(match.group(0)),
pp)
return pp
def __repr__(self):
return "<Journal with %d entries>" % len(self.entries)
def write(self, filename = None):
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])
journal = "\n".join([unicode(e) for e in self.entries])
if self.config['encrypt']:
journal = self._encrypt(journal)
with open(filename, 'w') as journal_file:
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):
@ -227,10 +245,10 @@ class Journal(object):
date, flag = self.dateparse.parse(date)
if not flag: # Oops, unparsable.
if not flag: # Oops, unparsable.
return None
if flag is 1: # Date found, but no time. Use the default time.
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])
@ -252,7 +270,7 @@ class Journal(object):
# Split raw text into title and body
title_end = len(raw)
for separator in ["\n",". ","? ","! "]:
for separator in ["\n", ". ", "? ", "! "]:
sep_pos = raw.find(separator)
if 1 < sep_pos < title_end:
title_end = sep_pos
@ -261,17 +279,18 @@ class Journal(object):
if not date:
if title.find(":") > 0:
date = self.parse_date(title[:title.find(":")])
if date: # Parsed successfully, strip that from the raw text
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.
if not date: # Still nothing? Meh, just live in the moment.
date = self.parse_date("now")
entry = Entry(self, date, title, body)
entry = Entry.Entry(self, date, title, body)
self.entries.append(entry)
if sort:
self.sort()
return entry
class DayOne(Journal):
"""A special Journal handling DayOne files"""
def __init__(self, **kwargs):
@ -298,7 +317,6 @@ class DayOne(Journal):
# we're returning the obvious.
return self.entries
def write(self):
"""Writes only the entries that have been modified into plist files."""
for entry in self.entries:
@ -315,4 +333,3 @@ class DayOne(Journal):
'UUID': new_uuid
}
plistlib.writePlist(entry_plist, filename)

View file

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

View file

@ -4,9 +4,38 @@
try: import simplejson as json
except ImportError: import json
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 occurances."""
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=False))
return result
def to_json(journal):
"""Returns a JSON representation of the Journal."""
return json.dumps([e.to_dict() for e in journal.entries], indent=2)
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"""

View file

@ -6,6 +6,9 @@ import getpass
try: import simplejson as json
except ImportError: import json
import os
try: from . import util
except (SystemError, ValueError): import util
def module_exists(module_name):
"""Checks if a module exists and can be imported"""
@ -62,7 +65,7 @@ def install_jrnl(config_path='~/.jrnl_config'):
# 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')
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?
@ -77,8 +80,8 @@ def install_jrnl(config_path='~/.jrnl_config'):
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.")
if not module_exists("colorama"):
print("colorama not found. To turn on highlighting, install colorama and set highlight to true in your .jrnl_conf.")
default_config['highlight'] = False
open(default_config['journals']['default'], 'a').close() # Touch to make sure it's there

View file

@ -7,9 +7,16 @@
license: MIT, see LICENSE for more details.
"""
import Journal
import exporters
import install
try:
from . import Journal
from . import util
from . import exporters
from . import install
except (SystemError, ValueError):
import Journal
import util
import exporters
import install
import os
import tempfile
import subprocess
@ -18,13 +25,8 @@ import sys
try: import simplejson as json
except ImportError: import json
__title__ = 'jrnl'
__version__ = '1.0.0-rc1'
__author__ = 'Manuel Ebert, Stephan Gabler'
__license__ = 'MIT'
CONFIG_PATH = os.path.expanduser('~/.jrnl_config')
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():
@ -87,36 +89,19 @@ def encrypt(journal, filename=None):
journal.make_key(prompt="Enter new password:")
journal.config['encrypt'] = True
journal.write(filename)
print("Journal encrypted to {}.".format(filename or journal.config['journal']))
print("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)
print("Journal decrypted to {}.".format(filename or journal.config['journal']))
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}
if min(tag_counts)[0] == 0:
tag_counts = filter(lambda x: x[0] > 1, tag_counts)
print('[Removed tags that appear only once.]')
for n, tag in sorted(tag_counts, reverse=False):
print("{:20} : {}".format(tag, n))
print("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):
print("[Journal created at {}]".format(filename))
print("[Journal created at {0}]".format(filename))
open(filename, 'a').close()
def update_config(config, new_config, scope):
@ -134,7 +119,12 @@ def cli():
config = install.install_jrnl(CONFIG_PATH)
else:
with open(CONFIG_PATH) as f:
config = json.load(f)
try:
config = json.load(f)
except ValueError as e:
print("[There seems to be something wrong with your jrnl config at {}: {}]".format(CONFIG_PATH, e.message))
print("[Entry was NOT added to your journal]")
sys.exit(-1)
install.update_config(config, config_path=CONFIG_PATH)
original_config = config.copy()
@ -172,7 +162,7 @@ def cli():
if config['editor']:
raw = get_text_from_editor(config)
else:
raw = raw_input("[Compose Entry] ")
raw = util.py23_input("[Compose Entry] ")
if raw:
args.text = [raw]
else:
@ -181,9 +171,10 @@ def cli():
# Writing mode
if mode_compose:
raw = " ".join(args.text).strip()
entry = journal.new_entry(raw, args.date)
unicode_raw = raw.decode(sys.getfilesystemencoding())
entry = journal.new_entry(unicode_raw, args.date)
entry.starred = args.star
print("[Entry added to {} journal]").format(journal_name)
print("[Entry added to {0} journal]".format(journal_name))
journal.write()
# Reading mode
@ -193,11 +184,11 @@ def cli():
strict=args.strict,
short=args.short)
journal.limit(args.limit)
print(journal)
print(unicode(journal))
# Various export modes
elif args.tags:
print_tags(journal)
print(exporters.to_tag_list(journal))
elif args.json: # export to json
print(exporters.to_json(journal))

10
jrnl/util.py Normal file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env python
# encoding: utf-8
import sys
def py23_input(msg):
if sys.version_info[0] == 3:
try: return input(msg)
except SyntaxError: return ""
else:
return raw_input(msg)

View file

@ -1,2 +1,4 @@
clint >= 0.3.1
parsedatetime == 1.1.2
parsedatetime >= 1.1.2
colorama >= 0.2.5
pycrypto >= 2.6
argparse==1.2.1

View file

@ -43,23 +43,37 @@ except ImportError:
from distutils.core import setup
import os
import sys
import re
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__))
def get_version(filename="jrnl/__init__.py"):
with open(os.path.join(base_dir, filename)) as initfile:
for line in initfile.readlines():
m = re.match("__version__ *= *['\"](.*)['\"]", line)
if m:
return m.group(1)
conditional_dependencies = {
"pyreadline>=2.0": "win32" in sys.platform,
"argparse==1.2.1": sys.version.startswith("2.6")
}
setup(
name = "jrnl",
version = "1.0.1",
version = get_version(),
description = "A command line journal application that stores your journal in a plain text file",
packages = ['jrnl'],
install_requires = ["parsedatetime >= 1.1.2"],
install_requires = [
"parsedatetime>=1.1.2",
"colorama>=0.2.5"
] + [p for p, cond in conditional_dependencies.items() if cond],
extras_require = {
'encryption': ["pycrypto"],
'highlight': ["clint"]
"encrypted": "pycrypto>=2.6"
},
long_description=__doc__,
entry_points={
@ -68,7 +82,7 @@ setup(
],
},
classifiers=[
'Development Status :: 3 - Alpha',
'Development Status :: 5 - Production/Stable',
'Environment :: Console',
'Intended Audience :: End Users/Desktop',
'License :: OSI Approved :: MIT License',
@ -81,7 +95,7 @@ setup(
],
# metadata for upload to PyPI
author = "Manuel Ebert",
author_email = "manuel@herelabs.com",
author_email = "manuel@hey.com",
license = "MIT License",
keywords = "journal todo todo.txt jrnl".split(),
url = "http://maebert.github.com/jrnl",