mirror of
https://github.com/jrnl-org/jrnl.git
synced 2025-05-10 16:48:31 +02:00
Template exporting FTW
This commit is contained in:
parent
ce113af4e0
commit
4b3dc38e05
12 changed files with 164 additions and 90 deletions
19
features/data/templates/sample.template
Normal file
19
features/data/templates/sample.template
Normal file
|
@ -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 %}
|
||||||
|
`
|
|
@ -27,6 +27,23 @@ Feature: Exporting a Journal
|
||||||
and the output should be parsable as json
|
and the output should be parsable as json
|
||||||
and the json output should contain entries.0.uuid = "4BB1F46946AD439996C9B59DE7C4DDC1"
|
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
|
Scenario: Increasing Headings on Markdown export
|
||||||
Given we use the config "markdown-headings-335.yaml"
|
Given we use the config "markdown-headings-335.yaml"
|
||||||
When we run "jrnl --export markdown"
|
When we run "jrnl --export markdown"
|
||||||
|
|
|
@ -2,7 +2,7 @@ from __future__ import unicode_literals
|
||||||
from __future__ import absolute_import
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from behave import given, when, then
|
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 jrnl import __version__
|
||||||
from dateutil import parser as date_parser
|
from dateutil import parser as date_parser
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
@ -13,8 +13,8 @@ import keyring
|
||||||
|
|
||||||
|
|
||||||
class TestKeyring(keyring.backend.KeyringBackend):
|
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
|
priority = 1
|
||||||
keys = defaultdict(dict)
|
keys = defaultdict(dict)
|
||||||
|
|
||||||
|
@ -97,6 +97,13 @@ def run(context, command):
|
||||||
context.exit_status = e.code
|
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}"')
|
@when('we set the keychain password of "{journal}" to "{password}"')
|
||||||
def set_keychain(context, journal, password):
|
def set_keychain(context, journal, password):
|
||||||
keyring.set_password('jrnl', journal, password)
|
keyring.set_password('jrnl', journal, password)
|
||||||
|
|
|
@ -13,7 +13,6 @@ from . import Journal
|
||||||
from . import util
|
from . import util
|
||||||
from . import install
|
from . import install
|
||||||
from . import plugins
|
from . import plugins
|
||||||
from .export import Exporter
|
|
||||||
from .util import ERROR_COLOR, RESET_COLOR
|
from .util import ERROR_COLOR, RESET_COLOR
|
||||||
import jrnl
|
import jrnl
|
||||||
import argparse
|
import argparse
|
||||||
|
@ -44,9 +43,9 @@ def parse_args(args=None):
|
||||||
exporting = parser.add_argument_group('Export / Import', 'Options for transmogrifying your journal')
|
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('-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('--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('-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('-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('--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('--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)))
|
print(util.py2encode(plugins.get_exporter("tags").export(journal)))
|
||||||
|
|
||||||
elif args.export is not False:
|
elif args.export is not False:
|
||||||
exporter = Exporter(args.export)
|
exporter = plugins.get_exporter(args.export)
|
||||||
print(exporter.export(journal, args.output))
|
print(exporter.export(journal, args.output))
|
||||||
|
|
||||||
elif args.encrypt is not False:
|
elif args.encrypt is not False:
|
||||||
|
|
|
@ -2,57 +2,34 @@
|
||||||
# encoding: utf-8
|
# encoding: utf-8
|
||||||
|
|
||||||
from __future__ import absolute_import, unicode_literals
|
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):
|
__exporter_types = dict([(name, plugin) for plugin in __exporters for name in plugin.names])
|
||||||
"""Called when a Plugin derived class is imported"""
|
__importer_types = dict([(name, plugin) for plugin in __importers for name in plugin.names])
|
||||||
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
|
|
||||||
|
|
||||||
|
EXPORT_FORMATS = sorted(__exporter_types.keys())
|
||||||
|
IMPORT_FORMATS = sorted(__importer_types.keys())
|
||||||
|
|
||||||
def get_exporter(format):
|
def get_exporter(format):
|
||||||
for exporter in BaseExporter.PLUGINS:
|
for exporter in __exporters:
|
||||||
if hasattr(exporter, "names") and format in exporter.names:
|
if hasattr(exporter, "names") and format in exporter.names:
|
||||||
return exporter
|
return exporter
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_importer(format):
|
def get_importer(format):
|
||||||
for importer in BaseImporter.PLUGINS:
|
for importer in __importers:
|
||||||
if hasattr(importer, "names") and format in importer.names:
|
if hasattr(importer, "names") and format in importer.names:
|
||||||
return importer
|
return importer
|
||||||
return None
|
return None
|
||||||
|
|
|
@ -4,10 +4,9 @@
|
||||||
from __future__ import absolute_import, unicode_literals
|
from __future__ import absolute_import, unicode_literals
|
||||||
import codecs
|
import codecs
|
||||||
import sys
|
import sys
|
||||||
from . import BaseImporter
|
|
||||||
from .. import util
|
from .. import util
|
||||||
|
|
||||||
class JRNLImporter(BaseImporter):
|
class JRNLImporter(object):
|
||||||
"""This plugin imports entries from other jrnl files."""
|
"""This plugin imports entries from other jrnl files."""
|
||||||
names = ["jrnl"]
|
names = ["jrnl"]
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import re
|
import re
|
||||||
import asteval
|
import asteval
|
||||||
|
import yaml
|
||||||
|
|
||||||
VAR_RE = r"[_a-zA-Z][a-zA-Z0-9_]*"
|
VAR_RE = r"[_a-zA-Z][a-zA-Z0-9_]*"
|
||||||
EXPRESSION_RE = r"[\[\]():.a-zA-Z0-9_]*"
|
EXPRESSION_RE = r"[\[\]():.a-zA-Z0-9_]*"
|
||||||
|
@ -18,6 +19,15 @@ class Template(object):
|
||||||
self.clean_template = None
|
self.clean_template = None
|
||||||
self.blocks = {}
|
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):
|
def render(self, **vars):
|
||||||
if self.clean_template is None:
|
if self.clean_template is None:
|
||||||
self._get_blocks()
|
self._get_blocks()
|
||||||
|
@ -52,7 +62,7 @@ class Template(object):
|
||||||
block_type = None
|
block_type = None
|
||||||
if not stack:
|
if not stack:
|
||||||
return self._expand_vars(template, **vars)
|
return self._expand_vars(template, **vars)
|
||||||
|
|
||||||
for pos, indent, typ in stack:
|
for pos, indent, typ in stack:
|
||||||
nesting += indent
|
nesting += indent
|
||||||
if nesting == 1 and last_nesting == 0:
|
if nesting == 1 and last_nesting == 0:
|
||||||
|
@ -68,7 +78,7 @@ class Template(object):
|
||||||
result += self._save_block(template[start:pos], **vars)
|
result += self._save_block(template[start:pos], **vars)
|
||||||
start = pos
|
start = pos
|
||||||
last_nesting = nesting
|
last_nesting = nesting
|
||||||
|
|
||||||
result += self._expand_vars(template[stack[-1][0]:], **vars)
|
result += self._expand_vars(template[stack[-1][0]:], **vars)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@ -82,7 +92,7 @@ class Template(object):
|
||||||
end_block = list(re.finditer(END_BLOCK_RE, template, re.M))[-1]
|
end_block = list(re.finditer(END_BLOCK_RE, template, re.M))[-1]
|
||||||
expression = start_block.groups()[0]
|
expression = start_block.groups()[0]
|
||||||
sub_template = self._strip_single_nl(template[start_block.end():end_block.start()])
|
sub_template = self._strip_single_nl(template[start_block.end():end_block.start()])
|
||||||
|
|
||||||
safe_eval = self._eval_context(vars)
|
safe_eval = self._eval_context(vars)
|
||||||
if safe_eval(expression):
|
if safe_eval(expression):
|
||||||
return self._expand(sub_template)
|
return self._expand(sub_template)
|
||||||
|
@ -94,15 +104,15 @@ class Template(object):
|
||||||
if strip_r and template[-1] == "\n":
|
if strip_r and template[-1] == "\n":
|
||||||
template = template[:-1]
|
template = template[:-1]
|
||||||
return template
|
return template
|
||||||
|
|
||||||
def _expand_loops(self, template, **vars):
|
def _expand_loops(self, template, **vars):
|
||||||
start_block = re.search(FOR_RE, template, re.M)
|
start_block = re.search(FOR_RE, template, re.M)
|
||||||
end_block = list(re.finditer(END_BLOCK_RE, template, re.M))[-1]
|
end_block = list(re.finditer(END_BLOCK_RE, template, re.M))[-1]
|
||||||
var_name, iterator = start_block.groups()
|
var_name, iterator = start_block.groups()
|
||||||
sub_template = self._strip_single_nl(template[start_block.end():end_block.start()], strip_r=False)
|
sub_template = self._strip_single_nl(template[start_block.end():end_block.start()], strip_r=False)
|
||||||
|
|
||||||
safe_eval = self._eval_context(vars)
|
safe_eval = self._eval_context(vars)
|
||||||
|
|
||||||
result = ''
|
result = ''
|
||||||
items = safe_eval(iterator)
|
items = safe_eval(iterator)
|
||||||
for idx, var in enumerate(items):
|
for idx, var in enumerate(items):
|
49
jrnl/plugins/template_exporter.py
Normal file
49
jrnl/plugins/template_exporter.py
Normal file
|
@ -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))
|
|
@ -3,13 +3,12 @@
|
||||||
|
|
||||||
from __future__ import absolute_import, unicode_literals
|
from __future__ import absolute_import, unicode_literals
|
||||||
import codecs
|
import codecs
|
||||||
from . import BaseExporter
|
|
||||||
from ..util import u, slugify
|
from ..util import u, slugify
|
||||||
import os
|
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."""
|
"""This Exporter can convert entries and journals into text files."""
|
||||||
names = ["text", "txt"]
|
names = ["text", "txt"]
|
||||||
extension = "txt"
|
extension = "txt"
|
||||||
|
|
|
@ -12,3 +12,16 @@ def get_tags_count(journal):
|
||||||
# To be read: [for entry in journal.entries: for tag in set(entry.tags): tag]
|
# 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])
|
tag_counts = set([(tags.count(tag), tag) for tag in tags])
|
||||||
return tag_counts
|
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]
|
||||||
|
|
|
@ -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
|
|
18
jrnl/templates/sample.template
Normal file
18
jrnl/templates/sample.template
Normal file
|
@ -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 %}
|
Loading…
Add table
Reference in a new issue