diff --git a/CHANGELOG.md b/CHANGELOG.md index cb89a41a..7f39fbf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Changelog ### 1.8 (May 22, 2014) +* __1.8.4__ Improved: using external editors (thanks to @chrissexton) +* __1.8.3__ Fixed: export to text files and improves help (thanks to @igniteflow and @mpe) +* __1.8.2__ Better integration with environment variables (thanks to @ajaam and @matze) +* __1.8.1__ Minor bug fixes * __1.8.0__ Official support for python 3.4 ### 1.7 (December 22, 2013) diff --git a/docs/export.rst b/docs/export.rst index e017951d..8b16348e 100644 --- a/docs/export.rst +++ b/docs/export.rst @@ -48,6 +48,15 @@ Text export Pretty-prints your entire journal. +XML export +----------- + +:: + + jrnl --export xml + +Why anyone would want to export stuff to XML is beyond me, but here you go. + Export to files --------------- diff --git a/jrnl/Entry.py b/jrnl/Entry.py index 461014fe..841c3525 100644 --- a/jrnl/Entry.py +++ b/jrnl/Entry.py @@ -79,26 +79,3 @@ class Entry: def __ne__(self, other): return not self.__eq__(other) - def to_dict(self): - return { - 'title': self.title, - 'body': self.body, - '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 else "" - body = body_wrapper + self.body - space = "\n" - md_head = "###" - - return u"{md} {date}, {title} {body} {space}".format( - md=md_head, - date=date_str, - title=self.title, - body=body, - space=space - ) diff --git a/jrnl/__init__.py b/jrnl/__init__.py index 2812d8d2..260d7f10 100644 --- a/jrnl/__init__.py +++ b/jrnl/__init__.py @@ -8,7 +8,7 @@ jrnl is a simple journal application for your command line. from __future__ import absolute_import __title__ = 'jrnl' -__version__ = '1.8.1' +__version__ = '1.8.4' __author__ = 'Manuel Ebert' __license__ = 'MIT License' __copyright__ = 'Copyright 2013 - 2014 Manuel Ebert' diff --git a/jrnl/cli.py b/jrnl/cli.py index c20f6d8b..fbd1057f 100644 --- a/jrnl/cli.py +++ b/jrnl/cli.py @@ -153,17 +153,6 @@ def run(manual_args=None): 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 = DayOneJournal.DayOne(**config) - else: - util.prompt(u"[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" @@ -183,6 +172,17 @@ def run(manual_args=None): else: mode_compose = False + # 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 = DayOneJournal.DayOne(**config) + else: + util.prompt(u"[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) + # Writing mode if mode_compose: raw = " ".join(args.text).strip() diff --git a/jrnl/exporters.py b/jrnl/exporters.py index 83499f87..c54d2260 100644 --- a/jrnl/exporters.py +++ b/jrnl/exporters.py @@ -6,6 +6,7 @@ import os import json from .util import u, slugify import codecs +from xml.dom import minidom def get_tags_count(journal): @@ -13,13 +14,13 @@ def get_tags_count(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) - ] + 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) @@ -32,15 +33,82 @@ def to_tag_list(journal): result += "\n".join(u"{0:20} : {1}".format(tag, n) for n, tag in sorted(tag_counts, reverse=True)) return result + +def entry_to_dict(entry): + return { + 'title': entry.title, + 'body': entry.body, + 'date': entry.date.strftime("%Y-%m-%d"), + 'time': entry.date.strftime("%H:%M"), + 'starred': entry.starred + } + + 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] + "entries": [entry_to_dict(e) for e in journal.entries] } return json.dumps(result, indent=2) + +def entry_to_xml(entry, doc=None): + """Turns an entry into an XML representation. + If doc is not given, it will return a full XML document. + Otherwise, it will only return a new 'entry' elemtent for + a given doc.""" + doc_el = doc or minidom.Document() + entry_el = doc_el.createElement('entry') + for key, value in entry_to_dict(entry).items(): + elem = doc_el.createElement(key) + elem.appendChild(doc_el.createTextNode(u(value))) + entry_el.appendChild(elem) + if not doc: + doc_el.appendChild(entry_el) + return doc_el.toprettyxml() + else: + return entry_el + + +def to_xml(journal): + """Returns a XML representation of the Journal.""" + tags = get_tags_count(journal) + doc = minidom.Document() + xml = doc.createElement('journal') + tags_el = doc.createElement('tags') + entries_el = doc.createElement('entries') + for tag in tags: + tag_el = doc.createElement('tag') + tag_el.setAttribute('name', tag[1]) + count_node = doc.createTextNode(u(tag[0])) + tag.appendChild(count_node) + tags_el.appendChild(tag) + for entry in journal.entries: + entries_el.appendChild(entry_to_xml(entry, doc)) + xml.appendChild(entries_el) + xml.appendChild(tags_el) + doc.appendChild(xml) + return doc.toprettyxml() + + +def entry_to_md(entry): + date_str = entry.date.strftime(entry.journal.config['timeformat']) + body_wrapper = "\n\n" if entry.body else "" + body = body_wrapper + entry.body + space = "\n" + md_head = "###" + + return u"{md} {date}, {title} {body} {space}".format( + md=md_head, + date=date_str, + title=entry.title, + body=body, + space=space + ) + + def to_md(journal): """Returns a markdown representation of the Journal""" out = [] @@ -58,25 +126,30 @@ def to_md(journal): 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. + format should be one of json, xml, 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, + "xml": to_xml, "txt": to_txt, "text": to_txt, "md": to_md, "markdown": to_md } - if output and os.path.isdir(output): # multiple files + if format not in maps: + return u"[ERROR: can't export to '{0}'. Valid options are 'md', 'txt', 'xml', and 'json']".format(format) + if output and os.path.isdir(output): # multiple files return write_files(journal, output, format) else: content = maps[format](journal) @@ -84,24 +157,27 @@ def export(journal, format, output=None): try: with codecs.open(output, "w", "utf-8") as f: f.write(content) - return "[Journal exported to {0}]".format(output) + return u"[Journal exported to {0}]".format(output) except IOError as e: - return "[ERROR: {0} {1}]".format(e.filename, e.strerror) + return u"[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.""" + Format should be either json, xml, 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) + content = json.dumps(entry_to_dict(e), indent=2) + "\n" + elif format in ('md', 'markdown'): + content = entry_to_md(e) + elif format in 'xml': + content = entry_to_xml(e) + elif format in ('txt', 'text'): + content = e.__unicode__() with codecs.open(full_path, "w", "utf-8") as f: f.write(content) - return "[Journal exported individual files in {0}]".format(path) + return u"[Journal exported individual files in {0}]".format(path) diff --git a/jrnl/util.py b/jrnl/util.py index 315666b3..ecddddce 100644 --- a/jrnl/util.py +++ b/jrnl/util.py @@ -2,10 +2,8 @@ # 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 @@ -24,12 +22,14 @@ 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() @@ -49,9 +49,11 @@ def get_password(validator, keychain=None, max_attempts=3): 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: @@ -61,40 +63,51 @@ def set_keychain(journal_name, password): 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") + if PY3: + return str(s) + elif isinstance(s, basestring) and type(s) is not unicode: + return unicode(s.encode('string-escape'), "unicode_escape") + return unicode(s) + 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 + config = None try: return json.loads(json_str) except ValueError as e: @@ -113,8 +126,9 @@ def load_and_fix_json(json_path): 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") + tmpfile = os.path.join(tempfile.mktemp(prefix="jrnl")) with codecs.open(tmpfile, 'w', "utf-8") as f: if template: f.write(template) @@ -126,10 +140,12 @@ def get_text_from_editor(config, template=""): 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 @@ -141,6 +157,7 @@ def slugify(string): slug = re.sub(r'[-\s]+', '-', no_punctuation) return u(slug) + def int2byte(i): """Converts an integer to a byte. This is equivalent to chr() in Python 2 and bytes((i,)) in Python 3.""" @@ -151,4 +168,3 @@ def byte2int(b): """Converts a byte to an integer. This is equivalent to ord(bs[0]) on Python 2 and bs[0] on Python 3.""" return ord(b)if PY2 else b -