mirror of
https://github.com/jrnl-org/jrnl.git
synced 2025-07-08 01:06:12 +02:00
New docs :)
This commit is contained in:
parent
bb5f8141ee
commit
c277241135
102 changed files with 3560 additions and 2623 deletions
|
@ -1,92 +0,0 @@
|
|||
#!/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()
|
||||
self.body = body.strip()
|
||||
self.tags = self.parse_tags()
|
||||
self.starred = starred
|
||||
|
||||
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 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
|
||||
)
|
366
jrnl/Journal.py
366
jrnl/Journal.py
|
@ -1,366 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# encoding: utf-8
|
||||
|
||||
try: from . import Entry
|
||||
except (SystemError, ValueError): import Entry
|
||||
try: from . import util
|
||||
except (SystemError, ValueError): import util
|
||||
import codecs
|
||||
import os
|
||||
try: import parsedatetime.parsedatetime_consts as pdt
|
||||
except ImportError: import parsedatetime.parsedatetime as pdt
|
||||
import re
|
||||
from datetime import datetime
|
||||
import time
|
||||
import sys
|
||||
try:
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto import Random
|
||||
crypto_installed = True
|
||||
except ImportError:
|
||||
crypto_installed = False
|
||||
import hashlib
|
||||
try:
|
||||
import colorama
|
||||
colorama.init()
|
||||
except ImportError:
|
||||
colorama = None
|
||||
import plistlib
|
||||
import pytz
|
||||
import uuid
|
||||
|
||||
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
|
||||
|
||||
journal_txt = self.open()
|
||||
self.entries = self.parse(journal_txt)
|
||||
self.sort()
|
||||
|
||||
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])
|
||||
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 not journal:
|
||||
journal = util.get_password(keychain=self.name, validator=validate_password)
|
||||
else:
|
||||
with codecs.open(filename, "r", "utf-8") as f:
|
||||
journal = f.read()
|
||||
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
|
||||
line = line.strip()
|
||||
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)
|
||||
|
||||
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: self._colorize(match.group(0)),
|
||||
pp, re.UNICODE)
|
||||
else:
|
||||
pp = re.sub(r"(?u)([{tags}]\w+)".format(tags=self.config['tagsymbols']),
|
||||
lambda match: self._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 = "\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
|
||||
|
||||
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
|
||||
title_end = len(raw)
|
||||
for separator in ["\n", ". ", "? ", "! "]:
|
||||
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()
|
||||
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)
|
||||
self.entries.append(entry)
|
||||
if sort:
|
||||
self.sort()
|
||||
return entry
|
||||
|
||||
|
||||
class DayOne(Journal):
|
||||
"""A special Journal handling DayOne files"""
|
||||
def __init__(self, **kwargs):
|
||||
self.entries = []
|
||||
super(DayOne, self).__init__(**kwargs)
|
||||
|
||||
def open(self):
|
||||
files = [os.path.join(self.config['journal'], "entries", f) for f in os.listdir(os.path.join(self.config['journal'], "entries"))]
|
||||
return files
|
||||
|
||||
def parse(self, filenames):
|
||||
"""Instead of parsing a string into an entry, this method will take a list
|
||||
of filenames, interpret each as a plist file and create a new entry from that."""
|
||||
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 = pytz.timezone(util.get_local_timezone())
|
||||
date = dict_entry['Creation Date']
|
||||
date = date + timezone.utcoffset(date)
|
||||
entry = self.new_entry(raw=dict_entry['Entry Text'], date=date, sort=False)
|
||||
entry.starred = dict_entry["Starred"]
|
||||
entry.uuid = dict_entry["UUID"]
|
||||
entry.tags = dict_entry.get("Tags", [])
|
||||
# We're using new_entry to create the Entry object, which adds the entry
|
||||
# to self.entries already. However, in the original Journal.__init__, this
|
||||
# method is expected to return a list of newly created entries, which is why
|
||||
# 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:
|
||||
# Assumption: since jrnl can not manipulate existing entries, all entries
|
||||
# that have a uuid will be old ones, and only the one that doesn't will
|
||||
# have a new one!
|
||||
if not hasattr(entry, "uuid"):
|
||||
utc_time = datetime.utcfromtimestamp(time.mktime(entry.date.timetuple()))
|
||||
new_uuid = uuid.uuid1().hex
|
||||
filename = os.path.join(self.config['journal'], "entries", new_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': util.get_local_timezone(),
|
||||
'UUID': new_uuid,
|
||||
'Tags': [tag.strip(self.config['tagsymbols']) for tag in entry.tags]
|
||||
}
|
||||
# print entry_plist
|
||||
|
||||
plistlib.writePlist(entry_plist, filename)
|
|
@ -1,17 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# encoding: utf-8
|
||||
|
||||
|
||||
"""
|
||||
jrnl is a simple journal application for your command line.
|
||||
"""
|
||||
|
||||
__title__ = 'jrnl'
|
||||
__version__ = '1.6.3'
|
||||
__author__ = 'Manuel Ebert'
|
||||
__license__ = 'MIT License'
|
||||
__copyright__ = 'Copyright 2013 Manuel Ebert'
|
||||
|
||||
from . import Journal
|
||||
from . import jrnl
|
||||
from .jrnl import cli
|
|
@ -1,110 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# encoding: utf-8
|
||||
|
||||
import os
|
||||
import string
|
||||
try: from slugify import slugify
|
||||
except ImportError: import slugify
|
||||
try: import simplejson as json
|
||||
except ImportError: import json
|
||||
try: from .util import u
|
||||
except (SystemError, ValueError): from util import u
|
||||
|
||||
|
||||
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)
|
|
@ -1,96 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# encoding: utf-8
|
||||
|
||||
import readline
|
||||
import glob
|
||||
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"""
|
||||
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': 80,
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
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.")
|
||||
|
||||
# Use highlighting:
|
||||
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
|
||||
|
||||
# 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
|
223
jrnl/jrnl.py
223
jrnl/jrnl.py
|
@ -1,223 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# encoding: utf-8
|
||||
|
||||
"""
|
||||
jrnl
|
||||
|
||||
license: MIT, see LICENSE for more details.
|
||||
"""
|
||||
|
||||
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
|
||||
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()
|
||||
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', 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('--delete-last', dest='delete_last', help='Deletes the last entry from your journal file.', 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.delete_last)):
|
||||
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 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:
|
||||
util.prompt('[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. """
|
||||
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 cli(manual_args=None):
|
||||
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)
|
||||
|
||||
args = parse_args(manual_args)
|
||||
|
||||
# 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:]
|
||||
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)
|
||||
|
||||
if mode_compose and not args.text:
|
||||
if config['editor']:
|
||||
raw = get_text_from_editor(config)
|
||||
else:
|
||||
raw = util.py23_input("[Compose Entry] ")
|
||||
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:
|
||||
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(journal.pprint())
|
||||
|
||||
# Various export modes
|
||||
elif args.short:
|
||||
print(journal.pprint(short=True))
|
||||
|
||||
elif args.tags:
|
||||
print(exporters.to_tag_list(journal))
|
||||
|
||||
elif args.export is not False:
|
||||
print(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.delete_last:
|
||||
last_entry = journal.entries.pop()
|
||||
util.prompt("[Deleted Entry:]")
|
||||
print(last_entry.pprint())
|
||||
journal.write()
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
119
jrnl/util.py
119
jrnl/util.py
|
@ -1,119 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# encoding: utf-8
|
||||
import sys
|
||||
import os
|
||||
from tzlocal import get_localzone
|
||||
import getpass as gp
|
||||
import keyring
|
||||
import pytz
|
||||
try: import simplejson as json
|
||||
except ImportError: import json
|
||||
import re
|
||||
|
||||
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 not result and pwd_from_keychain:
|
||||
set_keychain(keychain, None)
|
||||
attempt = 1
|
||||
while not result and attempt < max_attempts:
|
||||
prompt("Wrong password, try again.")
|
||||
password = getpass()
|
||||
result = validator(password)
|
||||
attempt += 1
|
||||
if result:
|
||||
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, "unicode_escape")
|
||||
|
||||
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 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 get_local_timezone():
|
||||
"""Returns the Olson identifier of the local timezone.
|
||||
In a happy world, tzlocal.get_localzone would do this, but there's a bug on OS X
|
||||
that prevents that right now: https://github.com/regebro/tzlocal/issues/6"""
|
||||
global __cached_tz
|
||||
if not __cached_tz and "darwin" in sys.platform:
|
||||
__cached_tz = os.popen("systemsetup -gettimezone").read().replace("Time Zone: ", "").strip()
|
||||
if not __cached_tz or __cached_tz not in pytz.all_timezones_set:
|
||||
link = os.readlink("/etc/localtime")
|
||||
# This is something like /usr/share/zoneinfo/America/Los_Angeles.
|
||||
# Find second / from right and take the substring
|
||||
__cached_tz = link[link.rfind('/', 0, link.rfind('/'))+1:]
|
||||
elif not __cached_tz:
|
||||
__cached_tz = str(get_localzone())
|
||||
if not __cached_tz or __cached_tz not in pytz.all_timezones_set:
|
||||
__cached_tz = "UTC"
|
||||
return __cached_tz
|
||||
|
||||
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)
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue