From 60a955de2047f9d4d1d122619cc66f34408e06f1 Mon Sep 17 00:00:00 2001 From: Manuel Ebert Date: Fri, 19 Aug 2016 23:20:31 +0000 Subject: [PATCH] Template exporting FTW --- features/data/templates/sample.template | 19 +++++++++ features/exporting.feature | 17 ++++++++ features/steps/core.py | 13 ++++-- jrnl/cli.py | 7 ++-- jrnl/plugins/__init__.py | 55 +++++++------------------ jrnl/plugins/jrnl_importer.py | 3 +- jrnl/{ => plugins}/template.py | 22 +++++++--- jrnl/plugins/template_exporter.py | 49 ++++++++++++++++++++++ jrnl/plugins/text_exporter.py | 5 +-- jrnl/plugins/util.py | 13 ++++++ jrnl/templates/json.template | 33 --------------- jrnl/templates/sample.template | 18 ++++++++ 12 files changed, 164 insertions(+), 90 deletions(-) create mode 100644 features/data/templates/sample.template rename jrnl/{ => plugins}/template.py (92%) create mode 100644 jrnl/plugins/template_exporter.py delete mode 100644 jrnl/templates/json.template create mode 100644 jrnl/templates/sample.template diff --git a/features/data/templates/sample.template b/features/data/templates/sample.template new file mode 100644 index 00000000..a356d823 --- /dev/null +++ b/features/data/templates/sample.template @@ -0,0 +1,19 @@ +--- +extension: txt +--- + +{% block journal %} +{% for entry in entries %} +{% include entry %} +{% endfor %} + +{% endblock %} + +{% block entry %} +{{ entry.title }} +{{ "-" * len(entry.title) }} + +{{ entry.body }} + +{% endblock %} +` diff --git a/features/exporting.feature b/features/exporting.feature index 31036674..78198aa5 100644 --- a/features/exporting.feature +++ b/features/exporting.feature @@ -27,6 +27,23 @@ Feature: Exporting a Journal and the output should be parsable as json and the json output should contain entries.0.uuid = "4BB1F46946AD439996C9B59DE7C4DDC1" + Scenario: Exporting using custom templates + Given we use the config "basic.yaml" + Given we load template "sample.template" + When we run "jrnl --export sample" + Then the output should be + """ + My first entry. + --------------- + + Everything is alright + + Life is good. + ------------- + + But I'm better. + """ + Scenario: Increasing Headings on Markdown export Given we use the config "markdown-headings-335.yaml" When we run "jrnl --export markdown" diff --git a/features/steps/core.py b/features/steps/core.py index b6462fc9..4813b252 100644 --- a/features/steps/core.py +++ b/features/steps/core.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from __future__ import absolute_import from behave import given, when, then -from jrnl import cli, install, Journal, util +from jrnl import cli, install, Journal, util, plugins from jrnl import __version__ from dateutil import parser as date_parser from collections import defaultdict @@ -13,8 +13,8 @@ import keyring class TestKeyring(keyring.backend.KeyringBackend): - """A test keyring that just stores its valies in a hash - """ + """A test keyring that just stores its valies in a hash""" + priority = 1 keys = defaultdict(dict) @@ -97,6 +97,13 @@ def run(context, command): context.exit_status = e.code +@given('we load template "{filename}"') +def load_template(context, filename): + full_path = os.path.join("features/data/templates", filename) + exporter = plugins.template_exporter.__exporter_from_file(full_path) + plugins.__exporter_types[exporter.names[0]] = exporter + + @when('we set the keychain password of "{journal}" to "{password}"') def set_keychain(context, journal, password): keyring.set_password('jrnl', journal, password) diff --git a/jrnl/cli.py b/jrnl/cli.py index 320d869b..49bbcb6a 100644 --- a/jrnl/cli.py +++ b/jrnl/cli.py @@ -13,7 +13,6 @@ from . import Journal from . import util from . import install from . import plugins -from .export import Exporter from .util import ERROR_COLOR, RESET_COLOR import jrnl import argparse @@ -44,9 +43,9 @@ def parse_args(args=None): exporting = parser.add_argument_group('Export / Import', 'Options for transmogrifying your journal') exporting.add_argument('-s', '--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=plugins.BaseExporter.PLUGIN_NAMES, help='Export your journal. TYPE can be {}.'.format(plugins.BaseExporter.get_plugin_types_string()), default=False, const=None) + exporting.add_argument('--export', metavar='TYPE', dest='export', choices=plugins.EXPORT_FORMATS, help='Export your journal. TYPE can be {}.'.format(plugins.util.oxford_list(plugins.EXPORT_FORMATS)), 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('--import', metavar='TYPE', dest='import_', choices=plugins.BaseImporter.PLUGIN_NAMES, help='Import entries into your journal. TYPE can be {}, and it defaults to jrnl if nothing else is specified.'.format(plugins.BaseImporter.get_plugin_types_string()), default=False, const='jrnl', nargs='?') + exporting.add_argument('--import', metavar='TYPE', dest='import_', choices=plugins.IMPORT_FORMATS, help='Import entries into your journal. TYPE can be {}, and it defaults to jrnl if nothing else is specified.'.format(plugins.util.oxford_list(plugins.IMPORT_FORMATS)), default=False, const='jrnl', nargs='?') exporting.add_argument('-i', metavar='INPUT', dest='input', help='Optionally specifies input file when using --import.', 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) @@ -246,7 +245,7 @@ def run(manual_args=None): print(util.py2encode(plugins.get_exporter("tags").export(journal))) elif args.export is not False: - exporter = Exporter(args.export) + exporter = plugins.get_exporter(args.export) print(exporter.export(journal, args.output)) elif args.encrypt is not False: diff --git a/jrnl/plugins/__init__.py b/jrnl/plugins/__init__.py index 93019af0..64d7b3ba 100644 --- a/jrnl/plugins/__init__.py +++ b/jrnl/plugins/__init__.py @@ -2,57 +2,34 @@ # encoding: utf-8 from __future__ import absolute_import, unicode_literals -import glob -import os -import importlib +from .text_exporter import TextExporter +from .jrnl_importer import JRNLImporter +from .json_exporter import JSONExporter +from .markdown_exporter import MarkdownExporter +from .tag_exporter import TagExporter +from .xml_exporter import XMLExporter +from .yaml_exporter import YAMLExporter +from .template_exporter import __all__ as template_exporters -class PluginMeta(type): +__exporters =[JSONExporter, MarkdownExporter, TagExporter, TextExporter, XMLExporter, YAMLExporter] + template_exporters +__importers =[JRNLImporter] - 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) - - def get_plugin_types_string(cls): - plugin_names = sorted(cls.PLUGIN_NAMES) - if not plugin_names: - return "(nothing)" - elif len(plugin_names) == 1: - return plugin_names[0] - elif len(plugin_names) == 2: - return plugin_names[0] + " or " + plugin_names[1] - else: - return ', '.join(plugin_names[:-1]) + ", or " + plugin_names[-1] - -# This looks a bit arcane, but is basically bilingual speak for defining a -# class with meta class 'PluginMeta' for both Python 2 and 3. -BaseExporter = PluginMeta(str('BaseExporter'), (), {'names': []}) -BaseImporter = PluginMeta(str('BaseImporter'), (), {'names': []}) - - -for module in glob.glob(os.path.dirname(__file__) + "/*.py"): - importlib.import_module("." + os.path.basename(module)[:-3], "jrnl.plugins") -del module +__exporter_types = dict([(name, plugin) for plugin in __exporters for name in plugin.names]) +__importer_types = dict([(name, plugin) for plugin in __importers for name in plugin.names]) +EXPORT_FORMATS = sorted(__exporter_types.keys()) +IMPORT_FORMATS = sorted(__importer_types.keys()) def get_exporter(format): - for exporter in BaseExporter.PLUGINS: + for exporter in __exporters: if hasattr(exporter, "names") and format in exporter.names: return exporter return None def get_importer(format): - for importer in BaseImporter.PLUGINS: + for importer in __importers: if hasattr(importer, "names") and format in importer.names: return importer return None diff --git a/jrnl/plugins/jrnl_importer.py b/jrnl/plugins/jrnl_importer.py index 6563233b..85615e75 100644 --- a/jrnl/plugins/jrnl_importer.py +++ b/jrnl/plugins/jrnl_importer.py @@ -4,10 +4,9 @@ from __future__ import absolute_import, unicode_literals import codecs import sys -from . import BaseImporter from .. import util -class JRNLImporter(BaseImporter): +class JRNLImporter(object): """This plugin imports entries from other jrnl files.""" names = ["jrnl"] diff --git a/jrnl/template.py b/jrnl/plugins/template.py similarity index 92% rename from jrnl/template.py rename to jrnl/plugins/template.py index 4cfc1268..0b4c533d 100644 --- a/jrnl/template.py +++ b/jrnl/plugins/template.py @@ -1,5 +1,6 @@ import re import asteval +import yaml VAR_RE = r"[_a-zA-Z][a-zA-Z0-9_]*" EXPRESSION_RE = r"[\[\]():.a-zA-Z0-9_]*" @@ -18,6 +19,15 @@ class Template(object): self.clean_template = None self.blocks = {} + @classmethod + def from_file(cls, filename): + with open(filename) as f: + front_matter, body = f.read().strip("-\n").split("---", 2) + front_matter = yaml.load(front_matter) + template = cls(body) + template.__dict__.update(front_matter) + return template + def render(self, **vars): if self.clean_template is None: self._get_blocks() @@ -52,7 +62,7 @@ class Template(object): block_type = None if not stack: return self._expand_vars(template, **vars) - + for pos, indent, typ in stack: nesting += indent if nesting == 1 and last_nesting == 0: @@ -68,7 +78,7 @@ class Template(object): result += self._save_block(template[start:pos], **vars) start = pos last_nesting = nesting - + result += self._expand_vars(template[stack[-1][0]:], **vars) return result @@ -82,7 +92,7 @@ class Template(object): end_block = list(re.finditer(END_BLOCK_RE, template, re.M))[-1] expression = start_block.groups()[0] sub_template = self._strip_single_nl(template[start_block.end():end_block.start()]) - + safe_eval = self._eval_context(vars) if safe_eval(expression): return self._expand(sub_template) @@ -94,15 +104,15 @@ class Template(object): if strip_r and template[-1] == "\n": template = template[:-1] return template - + def _expand_loops(self, template, **vars): start_block = re.search(FOR_RE, template, re.M) end_block = list(re.finditer(END_BLOCK_RE, template, re.M))[-1] var_name, iterator = start_block.groups() sub_template = self._strip_single_nl(template[start_block.end():end_block.start()], strip_r=False) - + safe_eval = self._eval_context(vars) - + result = '' items = safe_eval(iterator) for idx, var in enumerate(items): diff --git a/jrnl/plugins/template_exporter.py b/jrnl/plugins/template_exporter.py new file mode 100644 index 00000000..6a5fa86b --- /dev/null +++ b/jrnl/plugins/template_exporter.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals + +from .text_exporter import TextExporter +from .template import Template +import os +from glob import glob + + +class GenericTemplateExporter(TextExporter): + """This Exporter can convert entries and journals into text files.""" + + @classmethod + def export_entry(cls, entry): + """Returns a unicode representation of a single entry.""" + vars = { + 'entry': entry, + 'tags': entry.tags + } + return cls.template.render_block("entry", **vars) + + @classmethod + def export_journal(cls, journal): + """Returns a unicode representation of an entire journal.""" + vars = { + 'journal': journal, + 'entries': journal.entries, + 'tags': journal.tags + } + return cls.template.render_block("journal", **vars) + + +def __exporter_from_file(template_file): + """Create a template class from a file""" + name = os.path.basename(template_file).replace(".template", "") + template = Template.from_file(template_file) + return type("{}Exporter".format(name.title()), (GenericTemplateExporter, ), { + "names": [name], + "extension": template.extension, + "template": template + }) + +__all__ = [] + +# Factory pattern to create Exporter classes for all available templates +for template_file in glob("jrnl/templates/*.template"): + __all__.append(__exporter_from_file(template_file)) diff --git a/jrnl/plugins/text_exporter.py b/jrnl/plugins/text_exporter.py index 6a904436..dbb54d04 100644 --- a/jrnl/plugins/text_exporter.py +++ b/jrnl/plugins/text_exporter.py @@ -3,13 +3,12 @@ from __future__ import absolute_import, unicode_literals import codecs -from . import BaseExporter from ..util import u, slugify import os -from ..util import WARNING_COLOR, ERROR_COLOR, RESET_COLOR +from ..util import ERROR_COLOR, RESET_COLOR -class TextExporter(BaseExporter): +class TextExporter(object): """This Exporter can convert entries and journals into text files.""" names = ["text", "txt"] extension = "txt" diff --git a/jrnl/plugins/util.py b/jrnl/plugins/util.py index 3054b389..0a642cb2 100644 --- a/jrnl/plugins/util.py +++ b/jrnl/plugins/util.py @@ -12,3 +12,16 @@ def get_tags_count(journal): # 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 oxford_list(lst): + """Return Human-readable list of things obeying the object comma)""" + lst = sorted(lst) + if not lst: + return "(nothing)" + elif len(lst) == 1: + return lst[0] + elif len(lst) == 2: + return lst[0] + " or " + lst[1] + else: + return ', '.join(lst[:-1]) + ", or " + lst[-1] diff --git a/jrnl/templates/json.template b/jrnl/templates/json.template deleted file mode 100644 index 863047d4..00000000 --- a/jrnl/templates/json.template +++ /dev/null @@ -1,33 +0,0 @@ ---- -extension: json ---- - -{% block journal %} -{ - "tags": { - {% for tag in tags %} - "{{ tag.name }}": {{ tag.count }}{% if not __last_iteration %},{% endif %} - {% endfor %} - }, - "entries": [ - {% for entry in entries %} - {% include entry %}{% if not __last_iteration %},{% endif %} - {% endfor %} - ] -} -{% endblock %} - -{% block entry %} -{ - "title": "{{ entry.title }}", - "body": "{{ entry.body.replace("\n", "\\n") }}", - "date": "{{ entry.date.strftime('%Y-%m-%d') }}", - "time": "{{ entry.date.strftime('%H:%M') }}", - {% if entry.uuid %} - "uuid": "{{ entry.uuid }}", - {% endif %} - "starred": {{ "true" if entry.starred else "false" }} -} -{% endblock %} - -Hey There diff --git a/jrnl/templates/sample.template b/jrnl/templates/sample.template new file mode 100644 index 00000000..983d6af3 --- /dev/null +++ b/jrnl/templates/sample.template @@ -0,0 +1,18 @@ +--- +extension: txt +--- + +{% block journal %} +{% for entry in entries %} +{% include entry %} +{% endfor %} + +{% endblock %} + +{% block entry %} +{{ entry.title }} +{{ "-" * len(entry.title) }} + +{{ entry.body }} + +{% endblock %}