diff --git a/jrnl/Journal.py b/jrnl/Journal.py index f107e09e..c36b3760 100644 --- a/jrnl/Journal.py +++ b/jrnl/Journal.py @@ -15,6 +15,18 @@ import logging log = logging.getLogger(__name__) +class Tag(object): + def __init__(self, name, count=0): + self.name = name + self.count = count + + def __str__(self): + return self.name + + def __repr__(self): + return "".format(self.name) + + class Journal(object): def __init__(self, name='default', **kwargs): self.config = { @@ -141,6 +153,18 @@ class Journal(object): if n: self.entries = self.entries[-n:] + @property + def tags(self): + """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 self.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(tag, count=count) for count, tag in sorted(tag_counts)] + def filter(self, tags=[], start_date=None, end_date=None, starred=False, strict=False, short=False): """Removes all entries from the journal that don't match the filter. diff --git a/jrnl/cli.py b/jrnl/cli.py index e6a1e3ec..f7285725 100644 --- a/jrnl/cli.py +++ b/jrnl/cli.py @@ -13,7 +13,8 @@ from . import Journal from . import util from . import install from . import plugins -from .util import WARNING_COLOR, ERROR_COLOR, RESET_COLOR +from .export import Exporter +from .util import ERROR_COLOR, RESET_COLOR import jrnl import argparse import sys @@ -188,7 +189,7 @@ def run(manual_args=None): template = "" if config['template']: try: - template = open(config['template']).read() + template = open(config['template']).read() except: util.prompt("[Could not read template at '']".format(config['template'])) sys.exit(1) @@ -244,7 +245,7 @@ def run(manual_args=None): print(util.py2encode(plugins.get_exporter("tags").export(journal))) elif args.export is not False: - exporter = plugins.get_exporter(args.export) + exporter = Exporter(args.export) print(exporter.export(journal, args.output)) elif args.encrypt is not False: diff --git a/jrnl/export.py b/jrnl/export.py new file mode 100644 index 00000000..b16d8124 --- /dev/null +++ b/jrnl/export.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals +from .util import ERROR_COLOR, RESET_COLOR +from .util import slugify, u +from .template import Template +import os +import codecs + + +class Exporter(object): + """This Exporter can convert entries and journals into text files.""" + def __init__(self, format): + with open("jrnl/templates/" + format + ".template") as f: + front_matter, body = f.read().strip("-\n").split("---", 2) + self.template = Template(body) + + def export_entry(self, entry): + """Returns a unicode representation of a single entry.""" + return entry.__unicode__() + + def _get_vars(self, journal): + return { + 'journal': journal, + 'entries': journal.entries, + 'tags': journal.tags + } + + def export_journal(self, journal): + """Returns a unicode representation of an entire journal.""" + print("EXPORTING") + return self.template.render_block("journal", **self._get_vars(journal)) + + def write_file(self, journal, path): + """Exports a journal into a single file.""" + try: + with codecs.open(path, "w", "utf-8") as f: + f.write(self.export_journal(journal)) + return "[Journal exported to {0}]".format(path) + except IOError as e: + return "[{2}ERROR{3}: {0} {1}]".format(e.filename, e.strerror, ERROR_COLOR, RESET_COLOR) + + def make_filename(self, entry): + return entry.date.strftime("%Y-%m-%d_{0}.{1}".format(slugify(u(entry.title)), self.extension)) + + def write_files(self, journal, path): + """Exports a journal into individual files for each entry.""" + for entry in journal.entries: + try: + full_path = os.path.join(path, self.make_filename(entry)) + with codecs.open(full_path, "w", "utf-8") as f: + f.write(self.export_entry(entry)) + except IOError as e: + return "[{2}ERROR{3}: {0} {1}]".format(e.filename, e.strerror, ERROR_COLOR, RESET_COLOR) + return "[Journal exported to {0}]".format(path) + + def export(self, journal, format="text", 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 self.write_files(journal, output) + elif output: # single file + return self.write_file(journal, output) + else: + return self.export_journal(journal) diff --git a/jrnl/template.py b/jrnl/template.py new file mode 100644 index 00000000..4cfc1268 --- /dev/null +++ b/jrnl/template.py @@ -0,0 +1,113 @@ +import re +import asteval + +VAR_RE = r"[_a-zA-Z][a-zA-Z0-9_]*" +EXPRESSION_RE = r"[\[\]():.a-zA-Z0-9_]*" +PRINT_RE = r"{{ *(.+?) *}}" +START_BLOCK_RE = r"{% *(if|for) +(.+?) *%}" +END_BLOCK_RE = r"{% *end(for|if) *%}" +FOR_RE = r"{{% *for +({varname}) +in +([^%]+) *%}}".format(varname=VAR_RE, expression=EXPRESSION_RE) +IF_RE = r"{% *if +(.+?) *%}" +BLOCK_RE = r"{% *block +(.+?) *%}((?:.|\n)+?){% *endblock *%}" +INCLUDE_RE = r"{% *include +(.+?) *%}" + + +class Template(object): + def __init__(self, template): + self.template = template + self.clean_template = None + self.blocks = {} + + def render(self, **vars): + if self.clean_template is None: + self._get_blocks() + return self._expand(self.clean_template, **vars) + + def render_block(self, block, **vars): + if self.clean_template is None: + self._get_blocks() + return self._expand(self.blocks[block], **vars) + + def _eval_context(self, vars): + e = asteval.Interpreter(symtable=vars, use_numpy=False, writer=None) + e.symtable['__last_iteration'] = vars.get("__last_iteration", False) + return e + + def _get_blocks(self): + def s(match): + name, contents = match.groups() + self.blocks[name] = self._strip_single_nl(contents) + return "" + self.clean_template = re.sub(BLOCK_RE, s, self.template, flags=re.MULTILINE) + + def _expand(self, template, **vars): + stack = sorted( + [(m.start(), 1, m.groups()[0]) for m in re.finditer(START_BLOCK_RE, template)] + + [(m.end(), -1, m.groups()[0]) for m in re.finditer(END_BLOCK_RE, template)] + ) + + last_nesting, nesting = 0, 0 + start = 0 + result = "" + 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: + block_type = typ + result += self._expand_vars(template[start:pos], **vars) + start = pos + elif nesting == 0 and last_nesting == 1: + if block_type == "if": + result += self._expand_cond(template[start:pos], **vars) + elif block_type == "for": + result += self._expand_loops(template[start:pos], **vars) + elif block_type == "block": + result += self._save_block(template[start:pos], **vars) + start = pos + last_nesting = nesting + + result += self._expand_vars(template[stack[-1][0]:], **vars) + return result + + def _expand_vars(self, template, **vars): + safe_eval = self._eval_context(vars) + expanded = re.sub(INCLUDE_RE, lambda m: self.render_block(m.groups()[0], **vars), template) + return re.sub(PRINT_RE, lambda m: str(safe_eval(m.groups()[0])), expanded) + + def _expand_cond(self, template, **vars): + start_block = re.search(IF_RE, template, re.M) + 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) + return "" + + def _strip_single_nl(self, template, strip_r=True): + if template[0] == "\n": + template = template[1:] + 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): + vars[var_name] = var + vars['__last_iteration'] = idx == len(items) - 1 + result += self._expand(sub_template, **vars) + del vars[var_name] + return self._strip_single_nl(result) diff --git a/jrnl/templates/json.template b/jrnl/templates/json.template new file mode 100644 index 00000000..2666ef97 --- /dev/null +++ b/jrnl/templates/json.template @@ -0,0 +1,34 @@ +--- +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 }}", + "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/tst/2015-09-10_i-am-back-from-burning-man.md b/tst/2015-09-10_i-am-back-from-burning-man.md new file mode 100644 index 00000000..49261462 --- /dev/null +++ b/tst/2015-09-10_i-am-back-from-burning-man.md @@ -0,0 +1,17 @@ +title: I am back from Burning Man. +date: 2015-09-10 09:00 +stared: False +tags: + There's a lot to process. + +During the two weeks before the burn, I had sever anxiety issues - very physical symptoms; my chest felt constricted, my heart was pounding hard, my throat block, I was squashed between the walls. I think this anxiety has been building up since at least Australia, but now things got out of control. + +In direct comparison, this year's burn was maybe a little less exciting than last year's. But I took two very important things from it: + +1) a few days in, with no phone reception and nothing to plan or worry about, all of my anxiety symptoms vanished, and I felt light and free. It's not entirely intrinsic to me. Feeling better is possible, and it's close. +2) I started cultivating a wonderful friendship with Stef - who is currently dating Simon and Laurel, as a couple. Recap: Simon and Laurel were hitting on Lucy and me pretty hard at a dinner quite exactly a year ago. Small world. + +I started a therapy before the burn, and yesterday was my second session. We dug through some of the anxiety that was related to my parents visiting SF. Here’s a realisation: my parents, especially my mother, never really valued academic achievements, and even half jokingly, half dismissively called me a “Streber” when I brought home straight As. I assume that this has to do something with her feeling inferior to my dad, but at any rate this was probably the point where I realised that their world and mine were different, and whatever I wanted to achieve I had to achieve myself. + +I didn’t ask them for help until the day I broke up with Beth, two years ago. + \ No newline at end of file diff --git a/tst/2015-09-13_what-an-exciting-life.md b/tst/2015-09-13_what-an-exciting-life.md new file mode 100644 index 00000000..5d02c783 --- /dev/null +++ b/tst/2015-09-13_what-an-exciting-life.md @@ -0,0 +1,8 @@ +title: What an exciting life! +date: 2015-09-13 09:00 +stared: False +tags: kari + I spent the evening with @Kari, singing Dresden Dolls, drinking wine, falling a little bit in love, having sex on a piano, getting unintentionally electrocuted by her Hitachi, waking up next to a beautiful person. + +And during the Burn I got a lot closer to Stef, too. Oh, life! + \ No newline at end of file diff --git a/tst/2015-09-19_a-part-of-me-wants-to-submit-itself-to-the-scintillating-haze-that-a-glass-of-wine-on-a-sun-drenched-afternoon-promises.md b/tst/2015-09-19_a-part-of-me-wants-to-submit-itself-to-the-scintillating-haze-that-a-glass-of-wine-on-a-sun-drenched-afternoon-promises.md new file mode 100644 index 00000000..ca49e7c5 --- /dev/null +++ b/tst/2015-09-19_a-part-of-me-wants-to-submit-itself-to-the-scintillating-haze-that-a-glass-of-wine-on-a-sun-drenched-afternoon-promises.md @@ -0,0 +1,6 @@ +title: a part of me wants to submit itself to the scintillating haze that a glass of wine on a sun drenched afternoon promises. +date: 2015-09-19 13:00 +stared: False +tags: + Another part wants to fight for clarity of mind. + \ No newline at end of file