From 6fed042b8b2a4f403a5c2060fa6cf0d9d5e1c0a8 Mon Sep 17 00:00:00 2001 From: Manuel Ebert Date: Sat, 27 Sep 2014 15:07:22 -0700 Subject: [PATCH] Plugin architecture --- jrnl/cli.py | 10 +- jrnl/exporters.py | 183 ------------------------------ jrnl/plugins/__init__.py | 44 +++++++ jrnl/plugins/json_exporter.py | 38 +++++++ jrnl/plugins/markdown_exporter.py | 44 +++++++ jrnl/plugins/tag_exporter.py | 30 +++++ jrnl/plugins/text_exporter.py | 62 ++++++++++ jrnl/plugins/util.py | 14 +++ jrnl/plugins/xml_exporter.py | 50 ++++++++ 9 files changed, 287 insertions(+), 188 deletions(-) delete mode 100644 jrnl/exporters.py create mode 100644 jrnl/plugins/__init__.py create mode 100644 jrnl/plugins/json_exporter.py create mode 100644 jrnl/plugins/markdown_exporter.py create mode 100644 jrnl/plugins/tag_exporter.py create mode 100644 jrnl/plugins/text_exporter.py create mode 100644 jrnl/plugins/util.py create mode 100644 jrnl/plugins/xml_exporter.py diff --git a/jrnl/cli.py b/jrnl/cli.py index 7fdb21a3..7dd18c9b 100644 --- a/jrnl/cli.py +++ b/jrnl/cli.py @@ -10,8 +10,8 @@ from __future__ import absolute_import, unicode_literals from . import Journal from . import util -from . import exporters from . import install +from . import plugins import jrnl import os import argparse @@ -19,7 +19,6 @@ import sys 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") @@ -39,7 +38,7 @@ def parse_args(args=None): 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', choices=['text', 'txt', 'markdown', 'md', 'json'], help='Export your journal. TYPE can be json, markdown, or text.', default=False, const=None) + exporting.add_argument('--export', metavar='TYPE', dest='export', choices=plugins.BaseExporter.PLUGIN_NAMES, help='Export your journal. TYPE can be json, markdown, or text.', default=False, const=None) exporting.add_argument('-o', metavar='OUTPUT', dest='output', help='Optionally specifies output file when using --export. If OUTPUT is a directory, exports each entry into an individual file instead.', 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) @@ -218,10 +217,11 @@ def run(manual_args=None): print(util.py2encode(journal.pprint(short=True))) elif args.tags: - print(util.py2encode(exporters.to_tag_list(journal))) + print(util.py2encode(plugins.get_exporter("tags").export(journal))) elif args.export is not False: - print(util.py2encode(exporters.export(journal, args.export, args.output))) + exporter = plugins.get_exporter(args.export) + print(exporter.export(journal, 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.") diff --git a/jrnl/exporters.py b/jrnl/exporters.py deleted file mode 100644 index 789a845e..00000000 --- a/jrnl/exporters.py +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env python -# encoding: utf-8 - -from __future__ import absolute_import, unicode_literals -import os -import json -from .util import u, slugify -import codecs -from xml.dom import minidom - - -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("{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": [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 = [] - 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(entry_to_md(e)) - 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, 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 format not in maps: - return "[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) - if output: - try: - with codecs.open(output, "w", "utf-8") 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, xml, md or txt.""" - make_filename = lambda entry: e.date.strftime("%Y-%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(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) diff --git a/jrnl/plugins/__init__.py b/jrnl/plugins/__init__.py new file mode 100644 index 00000000..f5129b3a --- /dev/null +++ b/jrnl/plugins/__init__.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals +import glob +import os +import importlib + + +class PluginMeta(type): + def __init__(cls, name, bases, attrs): + """Called when a Plugin derived class is imported""" + if not hasattr(cls, 'PLUGINS'): + cls.PLUGINS = [] + cls.PLUGIN_NAMES = [] + else: + cls.__register_plugin(cls) + + def __register_plugin(cls, plugin): + """Add the plugin to the plugin list and perform any registration logic""" + cls.PLUGINS.append(plugin) + cls.PLUGIN_NAMES.extend(plugin.names) + + +class BaseExporter(object): + __metaclass__ = PluginMeta + names = [] + + +class BaseImporter(object): + __metaclass__ = PluginMeta + names = [] + + +for module in glob.glob(os.path.dirname(__file__) + "/*.py"): + importlib.import_module("." + os.path.basename(module)[:-3], "jrnl.plugins") +del module + + +def get_exporter(format): + for exporter in BaseExporter.PLUGINS: + if hasattr(exporter, "names") and format in exporter.names: + return exporter + return None diff --git a/jrnl/plugins/json_exporter.py b/jrnl/plugins/json_exporter.py new file mode 100644 index 00000000..374e4e51 --- /dev/null +++ b/jrnl/plugins/json_exporter.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals +from .text_exporter import TextExporter +import json +from .util import get_tags_count + + +class JSONExporter(TextExporter): + """This Exporter can convert entries and journals into json.""" + names = ["json"] + extension = "json" + + @classmethod + def entry_to_dict(cls, entry): + return { + 'title': entry.title, + 'body': entry.body, + 'date': entry.date.strftime("%Y-%m-%d"), + 'time': entry.date.strftime("%H:%M"), + 'starred': entry.starred + } + + @classmethod + def export_entry(cls, entry): + """Returns a json representation of a single entry.""" + return json.dumps(cls.entry_to_dict(entry), indent=2) + "\n" + + @classmethod + def export_journal(cls, journal): + """Returns a json representation of an entire journal.""" + tags = get_tags_count(journal) + result = { + "tags": dict((tag, count) for count, tag in tags), + "entries": [cls.entry_to_dict(e) for e in journal.entries] + } + return json.dumps(result, indent=2) diff --git a/jrnl/plugins/markdown_exporter.py b/jrnl/plugins/markdown_exporter.py new file mode 100644 index 00000000..2ad660fc --- /dev/null +++ b/jrnl/plugins/markdown_exporter.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals +from .text_exporter import TextExporter + + +class MarkdownExporter(TextExporter): + """This Exporter can convert entries and journals into json.""" + names = ["md", "markdown"] + extension = "md" + + @classmethod + def export_entry(cls, entry): + """Returns a markdown representation of a single entry.""" + date_str = entry.date.strftime(entry.journal.config['timeformat']) + body_wrapper = "\n" if entry.body else "" + body = body_wrapper + entry.body + + return "{md} {date} {title} {body} {space}".format( + md="###", + date=date_str, + title=entry.title, + body=body, + space="" + ) + + @classmethod + def export_journal(cls, journal): + """Returns a json representation of an entire 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(cls.export_entry(e)) + result = "\n".join(out) + return result diff --git a/jrnl/plugins/tag_exporter.py b/jrnl/plugins/tag_exporter.py new file mode 100644 index 00000000..69029f61 --- /dev/null +++ b/jrnl/plugins/tag_exporter.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals +from .text_exporter import TextExporter +from .util import get_tags_count + + +class TagExporter(TextExporter): + """This Exporter can convert entries and journals into json.""" + names = ["tags"] + extension = "tags" + + @classmethod + def export_entry(cls, entry): + """Returns a markdown representation of a single entry.""" + return ", ".join(entry.tags) + + @classmethod + def export_journal(cls, journal): + """Returns a json representation of an entire journal.""" + 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("{0:20} : {1}".format(tag, n) for n, tag in sorted(tag_counts, reverse=True)) + return result diff --git a/jrnl/plugins/text_exporter.py b/jrnl/plugins/text_exporter.py new file mode 100644 index 00000000..ce474e8f --- /dev/null +++ b/jrnl/plugins/text_exporter.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals +import codecs +from . import BaseExporter +from ..util import u, slugify +import os + + +class TextExporter(BaseExporter): + """This Exporter can convert entries and journals into text files.""" + names = ["text", "txt"] + extension = "txt" + + @classmethod + def export_entry(cls, entry): + """Returns a unicode representation of a single entry.""" + return u.__unicode__() + + @classmethod + def export_journal(cls, journal): + """Returns a unicode representation of an entire journal.""" + return journal.pprint() + + @classmethod + def write_file(cls, journal, path): + """Exports a journal into a single file.""" + try: + with codecs.open(path, "w", "utf-8") as f: + f.write(cls.export_journal(journal)) + return "[Journal exported to {0}]".format(path) + except IOError as e: + return "[ERROR: {0} {1}]".format(e.filename, e.strerror) + + @classmethod + def make_filename(cls, entry): + return entry.date.strftime("%Y-%m-%d_{0}.{1}".format(slugify(u(entry.title)), cls.extension)) + + @classmethod + def write_files(cls, journal, path): + """Exports a journal into individual files for each entry.""" + for entry in journal.entries: + try: + full_path = os.path.join(path, cls.make_filename(entry)) + with codecs.open(full_path, "w", "utf-8") as f: + f.write(cls.export_entry(entry)) + except IOError as e: + return "[ERROR: {0} {1}]".format(e.filename, e.strerror) + return "[Journal exported to {0}]".format(path) + + @classmethod + def export(cls, journal, output=None): + """Exports to individual files if output is an existing path, or into + a single file if output is a file name, or returns the exporter's + representation as unicode if output is None.""" + if output and os.path.isdir(output): # multiple files + return cls.write_files(journal, output) + elif output: + return cls.write_file(journal, output) + else: + return cls.export_journal(journal) diff --git a/jrnl/plugins/util.py b/jrnl/plugins/util.py new file mode 100644 index 00000000..3054b389 --- /dev/null +++ b/jrnl/plugins/util.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +# encoding: utf-8 + + +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 diff --git a/jrnl/plugins/xml_exporter.py b/jrnl/plugins/xml_exporter.py new file mode 100644 index 00000000..3a031769 --- /dev/null +++ b/jrnl/plugins/xml_exporter.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals +from .json_exporter import JSONExporter +from .util import get_tags_count +from ..util import u +from xml.dom import minidom + + +class XMLExporter(JSONExporter): + """This Exporter can convert entries and journals into XML.""" + names = ["xml"] + extension = "xml" + + @classmethod + def export_entry(cls, entry, doc=None): + """Returns an XML representation of a single entry.""" + doc_el = doc or minidom.Document() + entry_el = doc_el.createElement('entry') + for key, value in cls.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 + + @classmethod + def export_journal(cls, journal): + """Returns an XML representation of an entire 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(cls.entry_to_xml(entry, doc)) + xml.appendChild(entries_el) + xml.appendChild(tags_el) + doc.appendChild(xml) + return doc.toprettyxml()