mirror of
https://github.com/jrnl-org/jrnl.git
synced 2025-05-17 19:48:31 +02:00
Updated docs from master
This commit is contained in:
parent
0bcfdb2bde
commit
af070b1ca3
12 changed files with 1164 additions and 6 deletions
105
build/lib/jrnl/Entry.py
Normal file
105
build/lib/jrnl/Entry.py
Normal file
|
@ -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 "<Entry '{0}' on {1}>".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
|
||||||
|
)
|
433
build/lib/jrnl/Journal.py
Normal file
433
build/lib/jrnl/Journal.py
Normal file
|
@ -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 "<Journal with {0} entries>".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
|
||||||
|
|
18
build/lib/jrnl/__init__.py
Normal file
18
build/lib/jrnl/__init__.py
Normal file
|
@ -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
|
239
build/lib/jrnl/cli.py
Normal file
239
build/lib/jrnl/cli.py
Normal file
|
@ -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()
|
106
build/lib/jrnl/exporters.py
Normal file
106
build/lib/jrnl/exporters.py
Normal file
|
@ -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)
|
92
build/lib/jrnl/install.py
Normal file
92
build/lib/jrnl/install.py
Normal file
|
@ -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
|
143
build/lib/jrnl/util.py
Normal file
143
build/lib/jrnl/util.py
Normal file
|
@ -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)
|
||||||
|
|
|
@ -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).
|
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
|
Composing Entries
|
||||||
-----------------
|
-----------------
|
||||||
|
|
|
@ -42,11 +42,11 @@ class Entry:
|
||||||
if not short and self.journal.config['linewrap']:
|
if not short and self.journal.config['linewrap']:
|
||||||
title = textwrap.fill(date_str + " " + self.title, self.journal.config['linewrap'])
|
title = textwrap.fill(date_str + " " + self.title, self.journal.config['linewrap'])
|
||||||
body = "\n".join([
|
body = "\n".join([
|
||||||
textwrap.fill(line+" ",
|
textwrap.fill((line + " ") if (len(line) == 0) else line,
|
||||||
self.journal.config['linewrap'],
|
self.journal.config['linewrap'],
|
||||||
initial_indent="| ",
|
initial_indent="| ",
|
||||||
subsequent_indent="| ",
|
subsequent_indent="| ",
|
||||||
drop_whitespace=False).replace(' ', ' ')
|
drop_whitespace=False)
|
||||||
for line in self.body.strip().splitlines()
|
for line in self.body.strip().splitlines()
|
||||||
])
|
])
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -25,6 +25,7 @@ import pytz
|
||||||
import uuid
|
import uuid
|
||||||
import tzlocal
|
import tzlocal
|
||||||
|
|
||||||
|
|
||||||
class Journal(object):
|
class Journal(object):
|
||||||
def __init__(self, name='default', **kwargs):
|
def __init__(self, name='default', **kwargs):
|
||||||
self.config = {
|
self.config = {
|
||||||
|
@ -318,6 +319,7 @@ class Journal(object):
|
||||||
entry.modified = not any(entry == old_entry for old_entry in self.entries)
|
entry.modified = not any(entry == old_entry for old_entry in self.entries)
|
||||||
self.entries = mod_entries
|
self.entries = mod_entries
|
||||||
|
|
||||||
|
|
||||||
class DayOne(Journal):
|
class DayOne(Journal):
|
||||||
"""A special Journal handling DayOne files"""
|
"""A special Journal handling DayOne files"""
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
|
@ -342,7 +344,7 @@ class DayOne(Journal):
|
||||||
title, body = (raw[:sep.end()], raw[sep.end():]) if sep else (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 = Entry.Entry(self, date, title, body, starred=dict_entry["Starred"])
|
||||||
entry.uuid = dict_entry["UUID"]
|
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.entries.append(entry)
|
||||||
self.sort()
|
self.sort()
|
||||||
|
|
||||||
|
@ -353,7 +355,7 @@ class DayOne(Journal):
|
||||||
if not hasattr(entry, "uuid"):
|
if not hasattr(entry, "uuid"):
|
||||||
entry.uuid = uuid.uuid1().hex
|
entry.uuid = uuid.uuid1().hex
|
||||||
utc_time = datetime.utcfromtimestamp(time.mktime(entry.date.timetuple()))
|
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 = {
|
entry_plist = {
|
||||||
'Creation Date': utc_time,
|
'Creation Date': utc_time,
|
||||||
'Starred': entry.starred if hasattr(entry, 'starred') else False,
|
'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._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]
|
self.entries[:] = [e for e in self.entries if e.uuid in edited_uuids]
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ jrnl is a simple journal application for your command line.
|
||||||
from __future__ import absolute_import
|
from __future__ import absolute_import
|
||||||
|
|
||||||
__title__ = 'jrnl'
|
__title__ = 'jrnl'
|
||||||
__version__ = '1.7.10'
|
__version__ = '1.7.12'
|
||||||
__author__ = 'Manuel Ebert'
|
__author__ = 'Manuel Ebert'
|
||||||
__license__ = 'MIT License'
|
__license__ = 'MIT License'
|
||||||
__copyright__ = 'Copyright 2013 - 2014 Manuel Ebert'
|
__copyright__ = 'Copyright 2013 - 2014 Manuel Ebert'
|
||||||
|
|
13
jrnl/cli.py
13
jrnl/cli.py
|
@ -26,6 +26,7 @@ PYCRYPTO = install.module_exists("Crypto")
|
||||||
def parse_args(args=None):
|
def parse_args(args=None):
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument('-v', '--version', dest='version', action="store_true", help="prints version information and exits")
|
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 = 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="*")
|
composing.add_argument('text', metavar='', nargs="*")
|
||||||
|
@ -87,6 +88,14 @@ def touch_journal(filename):
|
||||||
util.prompt("[Journal created at {0}]".format(filename))
|
util.prompt("[Journal created at {0}]".format(filename))
|
||||||
open(filename, 'a').close()
|
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):
|
def update_config(config, new_config, scope, force_local=False):
|
||||||
"""Updates a config dict with new values - either global if scope is None
|
"""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 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)
|
config = util.load_and_fix_json(CONFIG_PATH)
|
||||||
install.upgrade_config(config, config_path=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()
|
original_config = config.copy()
|
||||||
# check if the configuration is supported by available modules
|
# check if the configuration is supported by available modules
|
||||||
if config['encrypt'] and not PYCRYPTO:
|
if config['encrypt'] and not PYCRYPTO:
|
||||||
|
|
Loading…
Add table
Reference in a new issue