mirror of
https://github.com/jrnl-org/jrnl.git
synced 2025-05-10 16:48:31 +02:00
Plugin architecture
This commit is contained in:
parent
a1b5a4099e
commit
6fed042b8b
9 changed files with 287 additions and 188 deletions
10
jrnl/cli.py
10
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.")
|
||||
|
|
|
@ -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)
|
44
jrnl/plugins/__init__.py
Normal file
44
jrnl/plugins/__init__.py
Normal file
|
@ -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
|
38
jrnl/plugins/json_exporter.py
Normal file
38
jrnl/plugins/json_exporter.py
Normal file
|
@ -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)
|
44
jrnl/plugins/markdown_exporter.py
Normal file
44
jrnl/plugins/markdown_exporter.py
Normal file
|
@ -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
|
30
jrnl/plugins/tag_exporter.py
Normal file
30
jrnl/plugins/tag_exporter.py
Normal file
|
@ -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
|
62
jrnl/plugins/text_exporter.py
Normal file
62
jrnl/plugins/text_exporter.py
Normal file
|
@ -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)
|
14
jrnl/plugins/util.py
Normal file
14
jrnl/plugins/util.py
Normal file
|
@ -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
|
50
jrnl/plugins/xml_exporter.py
Normal file
50
jrnl/plugins/xml_exporter.py
Normal file
|
@ -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()
|
Loading…
Add table
Reference in a new issue