#!/usr/bin/env python3
#
# Short description of the program/script's operation/function.
#

import sys
import argparse
import os
import re
import subprocess
import time
import shutil
import filecmp

FILENAME = os.path.basename(sys.argv[0])

class ArgumentParserUsage(argparse.ArgumentParser):
    """Argparse override to print usage to stderr on argument error."""
    def error(self, message):
        sys.stderr.write("error: %s\n" % message)
        self.print_help(sys.stderr)
        sys.exit(2)

class JournalCtl:
    SUCCESS = 0
    ERR_NONE_FOUND = 1
    ERR_TEMPLATE_FAIL = 2
    ERR_SELECT_CANCEL = 3
    ERR_FILE_EXISTS = 4
    ERR_NO_SUCH_CMD = 5
    ERR_WRONG_ARGS = 6
    ERR_BAD_FRONT_MATTER = 7
    ERR_NOT_VALID = 8
    ERR_GIT = 9
    ERR_NO_FILE = 10

    READ_ONLY = "r"
    WRITE_ONLY = "w"

    TEMPLATER_CMD = "pyplater.py"
    TEMPLATE_PREFIX = "jctl-"
    SLUG_CMD = "ezstring"

    FRONT_MATTER_SEP = "---"
    FRONT_MATTER_VALUE_SEP = ": "
    FRONT_MATTER_END = "\n" + FRONT_MATTER_SEP + "\n"

    GIT_UNTRACKED = "??"
    GIT_MODIFIED = "M"

    def __init__(self):
        # set variables
        self.journal_dir = os.environ["HOME"] + "/proj/writing/journal"
        self.entry_dir = "_posts"
        self.editor = os.environ["EDITOR"]
        self.entry_ext = ".md" # if this has >1 full stop then you gotta fix get_entries()
        self.recent_num = 5
        self.commit_msg_new = "new entry"
        self.commit_msg_mod = "edited entry"
        self.commit_extra = "\n\nCommit message auto-generated by jctl"


        self.new_aliases = ["new", "n"]
        self.edit_aliases = ["edit", "e"]
        self.search_aliases = ["search", "s"]
        self.commit_aliases = ["commit", "c"]
        self.push_aliases = ["push", "p"]
        self.recent_aliases = ["recent", "r"]
        self.help_aliases = ["help", "h"]

        self.__parse_args()

        # ensure that required directories exist
        if not os.path.exists(self.journal_dir):
            self.error("journal directory '{}' does not exist".format(
                    self.journal_dir),
                JournalCtl.ERR_NO_FILE)
        if not os.path.exists("{}/{}".format(self.journal_dir, self.entry_dir)):
            self.error("entry directory '{}' does not exist in journal directory".format(
                    self.entry_dir),
                JournalCtl.ERR_NO_FILE)

    def exit(self, exit_code=0):
        """Deinitialise and exit."""
        sys.exit(exit_code)

    # Logging {{{
    def __log_message(self, message, pipe=sys.stdout):
        """Log a message to a specific pipe (defaulting to stdout)."""
        print(FILENAME + ": " + message, file=pipe)

    def log(self, message):
        """If verbose, log an event."""
        if not self.args.verbose:
            return
        self.__log_message(message)

    def error(self, message, exit_code=1):
        """Log an error and exit."""
        self.__log_message("error: " + message, sys.stderr)
        self.exit(exit_code)

    def usage(self, exit_code):
        """Print usage and exit depending on given exit code."""
        if exit_code == 0:
            pipe = sys.stdout
        else:
            # if argument was non-zero, print to STDERR instead
            pipe = sys.stderr

        self.parser.print_help(pipe)
        self.exit(exit_code)

    def message(self, message):
        """Print a message to stdout, regardless of verbosity."""
        print(message)
    # Logging }}}

    def __parse_args(self):
        self.parser = ArgumentParserUsage(
                description="Control program for a journal kept in Jekyll.")

        # add arguments
        self.parser.add_argument("command", help="command to run")
        self.parser.add_argument("arguments", nargs="*", help="argument(s) for command")
        self.parser.add_argument("-m", "--msg", help="commit message for entry name (left of colon)")
        self.parser.add_argument("-e", "--edit",
                help="commit: bring up Git commit edit dialogue",
                action="store_true")
        self.parser.add_argument("-v", "--verbose", help="be verbose",
                action="store_true")
        self.parser.add_argument("-b", "--base",
                help="base Jekyll directory to use (parent of _posts)")

        # parse & grab arguments
        self.args = self.parser.parse_args()
        self.arguments = self.args.arguments
        self.command = self.args.command
        self.commit_msg = self.args.msg
        self.edit_commit = self.args.edit

        if self.args.base:
            self.journal_dir = self.args.base

    def get_shell(self, args):
        """Run a shell command, returning the output."""
        was_successful = False

        # we run without a shell (default) so we don't need to shell escape
        # strange titles e.g. ones with punctuation in
        # also run in self.journal_dir
        proc = subprocess.Popen(args, stdout=subprocess.PIPE, cwd=self.journal_dir)
        out, err = proc.communicate()

        if proc.returncode == 0:
            was_successful = True

        return out.decode("utf-8").strip(), was_successful

    def run_interactive(self, args, cwd=None):
        """
        Run an interactive shell command and return the exit code.

        Blocks execution while the command is running.
        """
        # run it in self.journal_dir, or given directory
        if cwd:
            run_dir = cwd
        else:
            run_dir = self.journal_dir
        return subprocess.call(args, cwd=run_dir)

    def execute_cmd(self):
        """Try to run something based on the command given."""
        if self.command in self.new_aliases:
            self.cmd_new(self.arguments)
        elif self.command in self.edit_aliases:
            self.cmd_edit(self.arguments)
        elif self.command in self.search_aliases:
            self.cmd_search(self.arguments)
        elif self.command in self.commit_aliases:
            self.cmd_commit(self.arguments)
        elif self.command == "ne":
            self.arguments.insert(0, "entry")
            self.cmd_new(self.arguments)
        elif self.command == "nm":
            self.arguments.insert(0, "meal")
            self.cmd_new(self.arguments)
        elif self.command in self.push_aliases:
            self.cmd_push()
        elif self.command in self.recent_aliases:
            self.cmd_recent()
        elif self.command in self.help_aliases:
            print("Available commands: new, edit, search, commit, push, recent, help")
        else:
            self.error(
                    "No such command '{}'".format(self.command),
                    JournalCtl.ERR_NO_SUCH_CMD)

    def cmd_recent(self):
        # get the last X entries
        entries = sorted(self.get_entries())
        recent_entries = entries[-self.recent_num:]

        # now pretty-print them
        for entry in recent_entries:
            title = self.get_front_matter(entry, "title")

            # strip quotes (used when taking title from front matter)
            if (title.startswith("\"") and title.endswith("\"")) \
                    or (title.startswith("'") and title.endswith("'")):
                title = title[1:-1]

            print(title)

    def get_git_status(self):
        """
        Get and parse the output of `git status` into an easier format to
        manipulate.
        """
        repo_status = self.get_shell(["git", "status", "--porcelain"])[0]

        # split -> lines, strip leading/trailing whitespace & split on whitespace
        git_status = [ line.strip().split() for line in repo_status.split("\n") ]

        # (at this point we always have at least 1 element)
        if len(git_status[0]) == 0:
            # return empty list if no changes
            return []
        else:
            return git_status

    def cmd_commit(self, arguments):
        """
        Try to commit a number of files, depending on repo conditions and
        arguments.

        For now, it will commit 1 file at most.

        Current behaviour:
          * if exactly 1 untracked file:
              * commit using arguments[0] if present and end
          * else search for entries using arguments as keywords
              * title chooser (default value/pre-entered stuff?)
              * if exactly 1 match, commit it and end
              * else use number chooser
        """
        c_git_entry = None
        c_title = None
        c_type = None

        git_status = self.get_git_status()

        # test for simple conditions
        # Guaranteed to either exit or set c_git_entry, c_title and c_type .
        # If c_title isn't changed from the default it's automagically taken
        # from the entry when committing.
        if len(git_status) == 0:
            self.message("No dirty/untracked files to commit")
            self.exit(JournalCtl.ERR_NONE_FOUND)
        else:
            valid_entries = []

            if len(arguments) == 0:
                self.log("cmd_commit: no args, choosing from all dirty files")
                valid_entries = git_status
            else:
                # test arguments against available dirty files
                for line in git_status:
                    entry = self.get_entry_from_git_path(line[1])

                    # search for keywords in dirty files
                    # keywords are all arguments except last
                    if all(word in entry for word in arguments):
                        valid_entries.append(line)

            # choose 1 entry from matched entries
            if len(valid_entries) == 0:
                self.message("No dirty entries found for your query.")
                self.exit(JournalCtl.ERR_NONE_FOUND)
            else:
                index = self.interactive_number_chooser(
                        [self.get_entry_from_git_path(line[1])
                            for line in valid_entries])
                if index == -1:
                    # hit Ctrl-C / cancelled it
                    self.message("Selection cancelled, exiting")
                    self.exit(JournalCtl.ERR_SELECT_CANCEL)
                c_type = valid_entries[index][0]
                c_git_entry = valid_entries[index][1]

                # set entry title part of commit message after entry name
                if self.commit_msg:
                    c_title = self.commit_msg
                else:
                    # let commit_entry() work it out
                    c_title = None

        # commit file
        self.commit_entry(c_git_entry, c_type, c_title)

    def is_entry(self, entry):
        return os.path.isfile(self.journal_dir + "/" + self.get_entry_git_path(entry))

    def commit_entry(self, git_entry, change, title):
        entry = self.get_entry_from_git_path(git_entry)
        if not self.is_entry(entry):
            self.error("can't commit a non-entry file",
                    JournalCtl.ERR_WRONG_ARGS)

        entry_title = title
        if not entry_title:
            # empty/None title passed, get from entry front matter
            entry_title = self.get_front_matter(entry, "title")

        fmt_post = None
        # verify correct type of change
        if change == JournalCtl.GIT_UNTRACKED:
            self.log("commit: file is untracked")
            fmt_post = self.commit_msg_new
        elif change == JournalCtl.GIT_MODIFIED:
            self.log("commit: file is modified")
            fmt_post = self.commit_msg_mod
        else:
            # something else (are there any other types of changes?)
            self.error("didn't understand git file status (not '{}' or '{}')".
                    format(JournalCtl.GIT_MODIFIED, JournalCtl.GIT_UNTRACKED),
                    JournalCtl.ERR_NOT_VALID)

        # strip quotes (used when taking title from front matter)
        if (entry_title.startswith("\"") and entry_title.endswith("\"")) or \
                (entry_title.startswith("'") and entry_title.endswith("'")):
            entry_title = entry_title[1:-1]

        # form commit message
        commit_msg = "{}: {}".format(entry_title, fmt_post)

        # TODO: reset all before doing stuff -- be safe
        if self.edit_commit:
            # editing commit message interactively
            self.log("git add-ing entry")
            self.run_interactive([
                "git", "add",
                ":/:" + self.get_entry_git_path(entry)])
            self.message("Bringing up Git commit dialogue...")
            self.run_interactive([
                "git", "commit", "-e",
                "-m", commit_msg])
        else:
            # ask whether to commit
            self.message("About to commit:")
            self.message(" > {}".format(entry))
            self.message(" >   * {}".format(commit_msg))
            ret = self.__yn_prompt("OK to add & commit?")
            if ret == 0:
                self.log("git add-ing entry")
                self.run_interactive([
                    "git", "add",
                    ":/:" + self.get_entry_git_path(entry)])
                self.run_interactive([
                    "git", "commit",
                    "-m", commit_msg + self.commit_extra])
            else:
                self.message("Exiting...")

    def get_entry_git_path(self, entry):
        """
        Return the path of an entry starting from the journal Git repo root.

        Used for `git add`ing things.
        """
        return "{}/{}{}".format(self.entry_dir, entry, self.entry_ext)

    def get_entry_from_git_path(self, entry):
        """Return the entry pointed to by a Git path."""
        # TODO: not completely reliable, just good enough for now
        return entry[len(self.entry_dir)+1:-len(self.entry_ext)]

    def cmd_new(self, arguments):
        """
        Create a new entry using a file templater (by default, my Pyplater).

        Note that unlike my previous 'journal' template, the 'jctl-*' templates
        require that jctl provides the *full* filename. That way the templater
        doesn't get involved with journal placement.
        """
        # we need at least the template name (entry, exam, meal) & title
        if len(arguments) < 2:
            self.error("expected at least 2 arguments (got {})".format(
                len(arguments)), JournalCtl.ERR_WRONG_ARGS)

        # get title & name of new entry
        entry_title = arguments[1]
        slug, ret = self.get_shell([JournalCtl.SLUG_CMD, entry_title])
        entry_name = "{}-{}".format(time.strftime("%F"), slug)
        entry_file = self.get_entry_file(entry_name)

        # check that exact file does not exist already
        if os.path.isfile(entry_file):
            self.error("entry '{}' already exists".format(entry_name),
                    JournalCtl.ERR_FILE_EXISTS)

        # templater command
        template = JournalCtl.TEMPLATE_PREFIX + arguments[0]
        template_cmd = [
                JournalCtl.TEMPLATER_CMD,
                template,
                entry_file,
                entry_title,
                ]

        # if extra arguments, append them
        if len(arguments) > 2:
            for arg in arguments[2:]:
                template_cmd.append(arg)

        ret = self.run_interactive(template_cmd, cwd=os.getcwd())

        if ret == 0:
            self.log("templating succeeded")
        else:
            self.error("templating failed (error code {})".format(ret),
                    JournalCtl.ERR_TEMPLATE_FAIL)

        # I use date field as 'last edited' field, so update again when finished
        # (my Pyplater already fills it in, but only at the start)
        # since the previous operation may change the entry name, re-grab it)
        fixed_entry_name = self.update_time(entry_name)

        # ask to commit the new file
        self.cmd_commit([fixed_entry_name])

    def cmd_edit(self, arguments):
        if not arguments:
            self.error("command requires at least 1 argument",
                    JournalCtl.ERR_WRONG_ARGS)
        else:
            matches = self.find_entries(arguments)
            if len(matches) == 0:
                self.message("No entries found for your query.")
                self.exit(JournalCtl.ERR_NONE_FOUND)
            if len(matches) > 1:
                self.message("More than one entry found for your query.")
                # ask user which one to open
                index = self.interactive_number_chooser(matches, reverse=True)
                if index == -1:
                    # hit Ctrl-C / cancelled it
                    self.message("Selection cancelled, exiting")
                    self.exit(JournalCtl.ERR_SELECT_CANCEL)
                else:
                    # index is valid
                    e = matches[index]
                    self.edit_entry(e)
            else:
                self.log("one file found")
                self.edit_entry(matches[0])

    def interactive_number_chooser(self, options, reverse=False):
        """
        Show an interactive chooser for a list of options and return the number
        entered.

        options: an array of strings

        For valid input, return a positive integer for the option's index in
        the list.
        For other input (cancelled, empty line), return -1.

        """

        ret_bad_input = -1
        indent_spaces = 3
        start_num = 1
        valid_input = False

        if len(options) == 1:
            # there is one option. return its index :)
            return 0

        if reverse:
            options.reverse()

        print("Please enter the number corresponding to the entry you want to choose:")
        print()

        if reverse:
            for i, opt in reversed(list(enumerate(options,start_num))):
                print("{0:{1}d}) {2}".format(i, indent_spaces, opt))
        else:
            for i, opt in enumerate(options, start_num):
                print("{0:{1}d}) {2}".format(i, indent_spaces, opt))

        while not valid_input:
            try:
                ans = input(" > ")
            except KeyboardInterrupt:
                # new line so it looks better
                print()
                return ret_bad_input
            except EOFError:
                print("Ctrl-D pressed, exiting...")
                return ret_bad_input

            # special input values
            if ans == "" or ans == "quit" or ans == "q":
                print("Exiting selection...")
                return ret_bad_input

            try:
                int_ans = int(ans)
            except ValueError:
                print("ERROR: not an integer. Please try again.")
                continue

            index = int_ans - start_num
            if 0 <= index < len(options):
                valid_input = True
            else:
                print("ERROR: entry specified was out of range. Please try again.")

        if reverse:
            return index
        return index

    def find_entries(self, keywords):
        """
        Try to find entries with filenames matching *every* keyword
        (case-insensitive).

        keywords: an array of strings

        Return a list of matched entries, sorted from oldest (first) to newest
        (last/most recent).
        """
        entries = self.get_entries()

        # Make all arguments slugs like the entry names (since we're not using
        # titles, just 'filenames' essentially).
        # This means you can search in any of these ways:
        #
        #     jctl edit "Exact title, converted to slug using JournalCtl.SLUG_CMD"
        #     jctl edit Different keywords which can match in any order
        #     jctl edit "your-own-slug-converted-is-the-same"
        #
        keywords = [ self.get_shell([JournalCtl.SLUG_CMD, word])[0] for word in keywords ]

        # for every entry:
        #     if all keywords separately found in entry, entry is a match
        matches = []
        for entry in entries:
            # be case-insensitive
            if all(word.lower() in entry.lower() for word in keywords):
                matches.append(entry)

        if len(matches) == 0:
            self.log("no matches found for keywords")
            return []

        if len(matches) > 1:
            self.log("more than 1 match found for keywords")

        return sorted(matches)

    def cmd_search(self, arguments):
        """Search for keywords in full journal text."""

        if not arguments:
            # we didn't get any arguments
            self.error("need at least 1 argument to search for",
                    JournalCtl.ERR_WRONG_ARGS)

        # get matches for *all* keywords
        matches_all = self.search_entries(arguments)

        # pretty-print 'all' matches
        if len(matches_all) == 0:
            self.message("No matches found for your query")
            self.exit(JournalCtl.SUCCESS)
        elif len(matches_all) == 1:
            self.message("1 match found")
        elif len(matches_all) > 1:
            self.message("Many matches found")

        yn = self.__yn_prompt("Open a matched entry?")
        if yn == 0:
            index = self.interactive_number_chooser(matches_all, reverse=True)
            if index == -1:
                self.message("Selection cancelled, exiting")
                self.exit(JournalCtl.ERR_SELECT_CANCEL)
            else:
                self.edit_entry(matches_all[index])
        elif yn == 1:
            self.message("Matches found in entries:")
            for match in matches_all:
                print(" * {}".format(match))
        else:
            self.message("ERROR: response wasn't y/n, exiting...")

    def __yn_prompt(self, prompt_msg):
        """
        Prompt the user with a yes/no question.

        Return 0 for 'yes'.
        Return 1 for 'no'.
        Return -1 for invalid input.
        """
        ret_y = 0
        ret_n = 1
        ret_invalid = -1

        try:
            yn = input(prompt_msg + " (y/n) ").lower()
        except KeyboardInterrupt:
            # new line so it looks better
            print()
            return ret_invalid
        except EOFError:
            return ret_invalid
        if yn == "y" or yn == "yes":
            return ret_y
        elif yn == "n" or yn == "no":
            return ret_n
        else:
            return -1

    def search_entries(self, keywords):
        """
        Try to find entries matching given keywords in the text, where a
        valid match is *each of* of the keywords found in text.

        Return a sorted list of matches, oldest to newest.
        """

        entries = self.get_entries()

        # for every entry:
        #     if all keywords separately found in entry text, entry is a match
        matches = []
        for entry in entries:
            text = self.get_text_of(entry)
            # remember to check everything in lowercase
            if all(word.lower() in text.lower() for word in keywords):
                matches.append(entry)

        if len(matches) == 0:
            self.log("no matches found for keywords")
            return []

        if len(matches) > 1:
            self.log("more than 1 match found for keywords")

        return sorted(matches)

    def get_text_of(self, entry):
        """Return the contents of the specified entry."""
        return self.open_entry(entry).read()

    def open_entry(self, entry):
        """Return a read-only file handle to the specified entry."""
        filename = self.get_entry_file(entry)
        return open(filename, JournalCtl.READ_ONLY)

    def edit_entry(self, entry):
        entry_file = self.get_entry_file(entry)

        tmpfile = self.__generate_entry_tmpfile(entry)
        self.log("Opening entry '{}' using '{}'".format(entry, self.editor))
        self.run_interactive([self.editor, tmpfile])

        # we get here when the file has been closed
        if filecmp.cmp(entry_file, tmpfile):
            # files are identical: no changes were made
            self.message("No changes made")
        else:
            # move tmpfile to original entry
            shutil.move(tmpfile, entry_file)
            self.message("File has been changed")
            yn = self.__yn_prompt("Update timestamp?")
            if yn == 0:
                self.update_time(entry)
            else:
                self.message("Exiting...")


    def __generate_entry_tmpfile(self, entry):
        """
        Copy the given entry to a temporary file for entry editing and return
        the temporary file's name.
        """
        tmp_dir = self.get_shell(["mktemp", "-d"])[0]
        tmp_file = tmp_dir + "/" + entry + self.entry_ext

        shutil.copyfile(self.get_entry_file(entry), tmp_file)

        return tmp_file

    def get_entry_file(self, entry):
        """
        Return a constructed full path for a given 'basename' file name (the
        entry).

        Note that this constructed file does *not* need to exist, since we might
        be opening a new file -- thus other functions must do that checking
        where required.
        """
        entry_path = self.get_entry_git_path(entry)
        return "{}/{}".format(self.journal_dir, entry_path)

    def get_entries(self):
        """
        Return an unsorted list of all entry names.

        In jctl, most functions only deal with the 'slug' as an entry name (i.e.
        'YYYY-MM-DD-title-slug'). This function return the 'basename' of each
        entry, without full path *or the extension*.
        """
        return [
            os.path.splitext(entry)[0]
                for entry in os.listdir(
                        "{}/{}".format(self.journal_dir, self.entry_dir))
                if not entry.startswith(".")
               ]

    def get_all_front_matter(self, entry):
        """
        Retrieve an entry's front matter.

        Return a list holding the all the entry's front matter in the
        format:

            [
                ["title", "Example title"],
                ["date", "1970-01-01 00:00:00"],
                None
                ["empty_var", None],
            ]

        If a variable is not assigned a value, it will be included as in the
        empty_var row. Empty lines are kept, and indicated by a None element
        (rather than a list). If an error occurs, the function fails and errors
        out.
        """

        with self.open_entry(entry) as f:
            text = f.read()

        # FIXME: not the best way of setting begin index
        begin_index = 4
        end_index = text.find(JournalCtl.FRONT_MATTER_END)
        raw_front_matter = text[begin_index:end_index].strip().split("\n")

        front_matter = []
        for line in raw_front_matter:
            parts = line.split(JournalCtl.FRONT_MATTER_VALUE_SEP, 1)
            if len(parts) == 2:
                # correct
                pass
            elif len(parts) > 2:
                self.error("somehow split front matter line into >2 parts",
                        JournalCtl.ERR_BAD_FRONT_MATTER)
            elif len(parts) == 1 and parts[0] == "":
                self.log("empty line in front matter")
                parts = None
            elif len(parts) == 1 and parts[0] != "":
                self.log("front matter variable '{}' has no value".format(
                    parts[0]))
                parts.append(None)
            else:
                self.error("unknown error in front matter",
                        JournalCtl.ERR_BAD_FRONT_MATTER)

            front_matter.append(parts)

        return front_matter

    def get_front_matter(self, entry, fm_var):
        """
        Get the front matter variable specified.

        Try to return the front matter variable with name fm_var. If it isn't
        found, return None.
        """

        fm = self.get_all_front_matter(entry)
        for val in fm:
            if val[0] == fm_var:
                return val[1]

        # didn't find fm_var
        return None

    def get_entry_text(self, entry):
        with self.open_entry(entry) as f:
            text = f.read()

        begin_index = text.find(JournalCtl.FRONT_MATTER_END)
        entry_text = text[begin_index+len(JournalCtl.FRONT_MATTER_END):]
        return entry_text

    def update_time(self, entry):
        """
        Fix an entry using its metadata and update its time to the time now.
        """
        return self.fix_entry(entry, date=time.strftime("%F %T %z"))

    def fix_entry(self, entry, date=None):
        """
        Check that an entry is named correctly (considering its metadata
        in-file to be accurate) and fix the filename if required.

        If a date is provided, replace the file's 'date' field with it.

        Return the updated entry name (or the original one if unchanged).
        """
        front_matter = self.get_all_front_matter(entry)
        entry_text = self.get_entry_text(entry)

        entry_file = self.get_entry_file(entry)

        new_text = ""
        entry_title = None
        new_text += JournalCtl.FRONT_MATTER_SEP + "\n"
        for line in front_matter:
            if line == None:
                # empty line
                new_text += "\n"
                continue

            var = line[0]
            value = line[1]
            if var == "date":
                # get old date (used to check filename consistency)
                old_date = value
                if date != None:
                    # we were given a date to update to, so do it
                    value = date
                    self.message("Timestamp updated ({} -> {}).".format(
                        line[1], value))
            if var == "title":
                # grab the title to check consistency with later
                entry_title = value
            if value == None:
                # empty field, error out
                self.error("YAML variable '{}' has no value assigned",
                        JournalCtl.ERR_BAD_FRONT_MATTER)
            new_text += "{}: {}\n".format(var, value)
        new_text += JournalCtl.FRONT_MATTER_SEP + "\n"
        new_text += entry_text

        # check that we got a title
        if not entry_title:
            self.error("no title found in front matter",
                    JournalCtl.ERR_BAD_FRONT_MATTER)

        with open(entry_file, JournalCtl.WRITE_ONLY) as f:
            f.write(new_text)

        check_entry = date.split(" ")[0] + "-" \
                + self.get_shell([JournalCtl.SLUG_CMD, entry_title])[0]
        if entry != check_entry:
            self.message("Filename is inconsistent with date/title, fixing using metadata")
            new_file = self.get_entry_file(check_entry)
            shutil.move(entry_file, new_file)
            self.log("moved entry ({} -> {})".format(entry, check_entry))
            return check_entry
        else:
            return entry

    def cmd_push(self):
        self.message("Pushing...")
        ret = self.run_interactive(["git", "push"])
        if ret == 0:
            self.log("push succeeded")
        else:
            self.error("Push failed, error code {}".format(ret),
                    JournalCtl.ERR_GIT)



if __name__ == "__main__":
    jctl = JournalCtl()
    jctl.execute_cmd()

#!/usr/bin/env python3
#
# Short description of the program/script's operation/function.
#

import sys
import argparse
import os
import re
import subprocess
import time
import shutil
import filecmp

FILENAME = os.path.basename(sys.argv[0])

class ArgumentParserUsage(argparse.ArgumentParser):
    """Argparse override to print usage to stderr on argument error."""
    def error(self, message):
        sys.stderr.write("error: %s\n" % message)
        self.print_help(sys.stderr)
        sys.exit(2)

class JournalCtl:
    SUCCESS = 0
    ERR_NONE_FOUND = 1
    ERR_TEMPLATE_FAIL = 2
    ERR_SELECT_CANCEL = 3
    ERR_FILE_EXISTS = 4
    ERR_NO_SUCH_CMD = 5
    ERR_WRONG_ARGS = 6
    ERR_BAD_FRONT_MATTER = 7
    ERR_NOT_VALID = 8
    ERR_GIT = 9
    ERR_NO_FILE = 10

    READ_ONLY = "r"
    WRITE_ONLY = "w"

    TEMPLATER_CMD = "pyplater.py"
    TEMPLATE_PREFIX = "jctl-"
    SLUG_CMD = "ezstring"

    FRONT_MATTER_SEP = "---"
    FRONT_MATTER_VALUE_SEP = ": "
    FRONT_MATTER_END = "\n" + FRONT_MATTER_SEP + "\n"

    GIT_UNTRACKED = "??"
    GIT_MODIFIED = "M"

    def __init__(self):
        # set variables
        self.journal_dir = os.environ["HOME"] + "/proj/writing/journal"
        self.entry_dir = "_posts"
        self.editor = os.environ["EDITOR"]
        self.entry_ext = ".md" # if this has >1 full stop then you gotta fix get_entries()
        self.recent_num = 5
        self.commit_msg_new = "new entry"
        self.commit_msg_mod = "edited entry"
        self.commit_extra = "\n\nCommit message auto-generated by jctl"


        self.new_aliases = ["new", "n"]
        self.edit_aliases = ["edit", "e"]
        self.search_aliases = ["search", "s"]
        self.commit_aliases = ["commit", "c"]
        self.push_aliases = ["push", "p"]
        self.recent_aliases = ["recent", "r"]
        self.help_aliases = ["help", "h"]

        self.__parse_args()

        # ensure that required directories exist
        if not os.path.exists(self.journal_dir):
            self.error("journal directory '{}' does not exist".format(
                    self.journal_dir),
                JournalCtl.ERR_NO_FILE)
        if not os.path.exists("{}/{}".format(self.journal_dir, self.entry_dir)):
            self.error("entry directory '{}' does not exist in journal directory".format(
                    self.entry_dir),
                JournalCtl.ERR_NO_FILE)

    def exit(self, exit_code=0):
        """Deinitialise and exit."""
        sys.exit(exit_code)

    # Logging {{{
    def __log_message(self, message, pipe=sys.stdout):
        """Log a message to a specific pipe (defaulting to stdout)."""
        print(FILENAME + ": " + message, file=pipe)

    def log(self, message):
        """If verbose, log an event."""
        if not self.args.verbose:
            return
        self.__log_message(message)

    def error(self, message, exit_code=1):
        """Log an error and exit."""
        self.__log_message("error: " + message, sys.stderr)
        self.exit(exit_code)

    def usage(self, exit_code):
        """Print usage and exit depending on given exit code."""
        if exit_code == 0:
            pipe = sys.stdout
        else:
            # if argument was non-zero, print to STDERR instead
            pipe = sys.stderr

        self.parser.print_help(pipe)
        self.exit(exit_code)

    def message(self, message):
        """Print a message to stdout, regardless of verbosity."""
        print(message)
    # Logging }}}

    def __parse_args(self):
        self.parser = ArgumentParserUsage(
                description="Control program for a journal kept in Jekyll.")

        # add arguments
        self.parser.add_argument("command", help="command to run")
        self.parser.add_argument("arguments", nargs="*", help="argument(s) for command")
        self.parser.add_argument("-m", "--msg", help="commit message for entry name (left of colon)")
        self.parser.add_argument("-e", "--edit",
                help="commit: bring up Git commit edit dialogue",
                action="store_true")
        self.parser.add_argument("-v", "--verbose", help="be verbose",
                action="store_true")
        self.parser.add_argument("-b", "--base",
                help="base Jekyll directory to use (parent of _posts)")

        # parse & grab arguments
        self.args = self.parser.parse_args()
        self.arguments = self.args.arguments
        self.command = self.args.command
        self.commit_msg = self.args.msg
        self.edit_commit = self.args.edit

        if self.args.base:
            self.journal_dir = self.args.base

    def get_shell(self, args):
        """Run a shell command, returning the output."""
        was_successful = False

        # we run without a shell (default) so we don't need to shell escape
        # strange titles e.g. ones with punctuation in
        # also run in self.journal_dir
        proc = subprocess.Popen(args, stdout=subprocess.PIPE, cwd=self.journal_dir)
        out, err = proc.communicate()

        if proc.returncode == 0:
            was_successful = True

        return out.decode("utf-8").strip(), was_successful

    def run_interactive(self, args, cwd=None):
        """
        Run an interactive shell command and return the exit code.

        Blocks execution while the command is running.
        """
        # run it in self.journal_dir, or given directory
        if cwd:
            run_dir = cwd
        else:
            run_dir = self.journal_dir
        return subprocess.call(args, cwd=run_dir)

    def execute_cmd(self):
        """Try to run something based on the command given."""
        if self.command in self.new_aliases:
            self.cmd_new(self.arguments)
        elif self.command in self.edit_aliases:
            self.cmd_edit(self.arguments)
        elif self.command in self.search_aliases:
            self.cmd_search(self.arguments)
        elif self.command in self.commit_aliases:
            self.cmd_commit(self.arguments)
        elif self.command == "ne":
            self.arguments.insert(0, "entry")
            self.cmd_new(self.arguments)
        elif self.command == "nm":
            self.arguments.insert(0, "meal")
            self.cmd_new(self.arguments)
        elif self.command in self.push_aliases:
            self.cmd_push()
        elif self.command in self.recent_aliases:
            self.cmd_recent()
        elif self.command in self.help_aliases:
            print("Available commands: new, edit, search, commit, push, recent, help")
        else:
            self.error(
                    "No such command '{}'".format(self.command),
                    JournalCtl.ERR_NO_SUCH_CMD)

    def cmd_recent(self):
        # get the last X entries
        entries = sorted(self.get_entries())
        recent_entries = entries[-self.recent_num:]

        # now pretty-print them
        for entry in recent_entries:
            title = self.get_front_matter(entry, "title")

            # strip quotes (used when taking title from front matter)
            if (title.startswith("\"") and title.endswith("\"")) \
                    or (title.startswith("'") and title.endswith("'")):
                title = title[1:-1]

            print(title)

    def get_git_status(self):
        """
        Get and parse the output of `git status` into an easier format to
        manipulate.
        """
        repo_status = self.get_shell(["git", "status", "--porcelain"])[0]

        # split -> lines, strip leading/trailing whitespace & split on whitespace
        git_status = [ line.strip().split() for line in repo_status.split("\n") ]

        # (at this point we always have at least 1 element)
        if len(git_status[0]) == 0:
            # return empty list if no changes
            return []
        else:
            return git_status

    def cmd_commit(self, arguments):
        """
        Try to commit a number of files, depending on repo conditions and
        arguments.

        For now, it will commit 1 file at most.

        Current behaviour:
          * if exactly 1 untracked file:
              * commit using arguments[0] if present and end
          * else search for entries using arguments as keywords
              * title chooser (default value/pre-entered stuff?)
              * if exactly 1 match, commit it and end
              * else use number chooser
        """
        c_git_entry = None
        c_title = None
        c_type = None

        git_status = self.get_git_status()

        # test for simple conditions
        # Guaranteed to either exit or set c_git_entry, c_title and c_type .
        # If c_title isn't changed from the default it's automagically taken
        # from the entry when committing.
        if len(git_status) == 0:
            self.message("No dirty/untracked files to commit")
            self.exit(JournalCtl.ERR_NONE_FOUND)
        else:
            valid_entries = []

            if len(arguments) == 0:
                self.log("cmd_commit: no args, choosing from all dirty files")
                valid_entries = git_status
            else:
                # test arguments against available dirty files
                for line in git_status:
                    entry = self.get_entry_from_git_path(line[1])

                    # search for keywords in dirty files
                    # keywords are all arguments except last
                    if all(word in entry for word in arguments):
                        valid_entries.append(line)

            # choose 1 entry from matched entries
            if len(valid_entries) == 0:
                self.message("No dirty entries found for your query.")
                self.exit(JournalCtl.ERR_NONE_FOUND)
            else:
                index = self.interactive_number_chooser(
                        [self.get_entry_from_git_path(line[1])
                            for line in valid_entries])
                if index == -1:
                    # hit Ctrl-C / cancelled it
                    self.message("Selection cancelled, exiting")
                    self.exit(JournalCtl.ERR_SELECT_CANCEL)
                c_type = valid_entries[index][0]
                c_git_entry = valid_entries[index][1]

                # set entry title part of commit message after entry name
                if self.commit_msg:
                    c_title = self.commit_msg
                else:
                    # let commit_entry() work it out
                    c_title = None

        # commit file
        self.commit_entry(c_git_entry, c_type, c_title)

    def is_entry(self, entry):
        return os.path.isfile(self.journal_dir + "/" + self.get_entry_git_path(entry))

    def commit_entry(self, git_entry, change, title):
        entry = self.get_entry_from_git_path(git_entry)
        if not self.is_entry(entry):
            self.error("can't commit a non-entry file",
                    JournalCtl.ERR_WRONG_ARGS)

        entry_title = title
        if not entry_title:
            # empty/None title passed, get from entry front matter
            entry_title = self.get_front_matter(entry, "title")

        fmt_post = None
        # verify correct type of change
        if change == JournalCtl.GIT_UNTRACKED:
            self.log("commit: file is untracked")
            fmt_post = self.commit_msg_new
        elif change == JournalCtl.GIT_MODIFIED:
            self.log("commit: file is modified")
            fmt_post = self.commit_msg_mod
        else:
            # something else (are there any other types of changes?)
            self.error("didn't understand git file status (not '{}' or '{}')".
                    format(JournalCtl.GIT_MODIFIED, JournalCtl.GIT_UNTRACKED),
                    JournalCtl.ERR_NOT_VALID)

        # strip quotes (used when taking title from front matter)
        if (entry_title.startswith("\"") and entry_title.endswith("\"")) or \
                (entry_title.startswith("'") and entry_title.endswith("'")):
            entry_title = entry_title[1:-1]

        # form commit message
        commit_msg = "{}: {}".format(entry_title, fmt_post)

        # TODO: reset all before doing stuff -- be safe
        if self.edit_commit:
            # editing commit message interactively
            self.log("git add-ing entry")
            self.run_interactive([
                "git", "add",
                ":/:" + self.get_entry_git_path(entry)])
            self.message("Bringing up Git commit dialogue...")
            self.run_interactive([
                "git", "commit", "-e",
                "-m", commit_msg])
        else:
            # ask whether to commit
            self.message("About to commit:")
            self.message(" > {}".format(entry))
            self.message(" >   * {}".format(commit_msg))
            ret = self.__yn_prompt("OK to add & commit?")
            if ret == 0:
                self.log("git add-ing entry")
                self.run_interactive([
                    "git", "add",
                    ":/:" + self.get_entry_git_path(entry)])
                self.run_interactive([
                    "git", "commit",
                    "-m", commit_msg + self.commit_extra])
            else:
                self.message("Exiting...")

    def get_entry_git_path(self, entry):
        """
        Return the path of an entry starting from the journal Git repo root.

        Used for `git add`ing things.
        """
        return "{}/{}{}".format(self.entry_dir, entry, self.entry_ext)

    def get_entry_from_git_path(self, entry):
        """Return the entry pointed to by a Git path."""
        # TODO: not completely reliable, just good enough for now
        return entry[len(self.entry_dir)+1:-len(self.entry_ext)]

    def cmd_new(self, arguments):
        """
        Create a new entry using a file templater (by default, my Pyplater).

        Note that unlike my previous 'journal' template, the 'jctl-*' templates
        require that jctl provides the *full* filename. That way the templater
        doesn't get involved with journal placement.
        """
        # we need at least the template name (entry, exam, meal) & title
        if len(arguments) < 2:
            self.error("expected at least 2 arguments (got {})".format(
                len(arguments)), JournalCtl.ERR_WRONG_ARGS)

        # get title & name of new entry
        entry_title = arguments[1]
        slug, ret = self.get_shell([JournalCtl.SLUG_CMD, entry_title])
        entry_name = "{}-{}".format(time.strftime("%F"), slug)
        entry_file = self.get_entry_file(entry_name)

        # check that exact file does not exist already
        if os.path.isfile(entry_file):
            self.error("entry '{}' already exists".format(entry_name),
                    JournalCtl.ERR_FILE_EXISTS)

        # templater command
        template = JournalCtl.TEMPLATE_PREFIX + arguments[0]
        template_cmd = [
                JournalCtl.TEMPLATER_CMD,
                template,
                entry_file,
                entry_title,
                ]

        # if extra arguments, append them
        if len(arguments) > 2:
            for arg in arguments[2:]:
                template_cmd.append(arg)

        ret = self.run_interactive(template_cmd, cwd=os.getcwd())

        if ret == 0:
            self.log("templating succeeded")
        else:
            self.error("templating failed (error code {})".format(ret),
                    JournalCtl.ERR_TEMPLATE_FAIL)

        # I use date field as 'last edited' field, so update again when finished
        # (my Pyplater already fills it in, but only at the start)
        # since the previous operation may change the entry name, re-grab it)
        fixed_entry_name = self.update_time(entry_name)

        # ask to commit the new file
        self.cmd_commit([fixed_entry_name])

    def cmd_edit(self, arguments):
        if not arguments:
            self.error("command requires at least 1 argument",
                    JournalCtl.ERR_WRONG_ARGS)
        else:
            matches = self.find_entries(arguments)
            if len(matches) == 0:
                self.message("No entries found for your query.")
                self.exit(JournalCtl.ERR_NONE_FOUND)
            if len(matches) > 1:
                self.message("More than one entry found for your query.")
                # ask user which one to open
                index = self.interactive_number_chooser(matches, reverse=True)
                if index == -1:
                    # hit Ctrl-C / cancelled it
                    self.message("Selection cancelled, exiting")
                    self.exit(JournalCtl.ERR_SELECT_CANCEL)
                else:
                    # index is valid
                    e = matches[index]
                    self.edit_entry(e)
            else:
                self.log("one file found")
                self.edit_entry(matches[0])

    def interactive_number_chooser(self, options, reverse=False):
        """
        Show an interactive chooser for a list of options and return the number
        entered.

        options: an array of strings

        For valid input, return a positive integer for the option's index in
        the list.
        For other input (cancelled, empty line), return -1.

        """

        ret_bad_input = -1
        indent_spaces = 3
        start_num = 1
        valid_input = False

        if len(options) == 1:
            # there is one option. return its index :)
            return 0

        if reverse:
            options.reverse()

        print("Please enter the number corresponding to the entry you want to choose:")
        print()

        if reverse:
            for i, opt in reversed(list(enumerate(options,start_num))):
                print("{0:{1}d}) {2}".format(i, indent_spaces, opt))
        else:
            for i, opt in enumerate(options, start_num):
                print("{0:{1}d}) {2}".format(i, indent_spaces, opt))

        while not valid_input:
            try:
                ans = input(" > ")
            except KeyboardInterrupt:
                # new line so it looks better
                print()
                return ret_bad_input
            except EOFError:
                print("Ctrl-D pressed, exiting...")
                return ret_bad_input

            # special input values
            if ans == "" or ans == "quit" or ans == "q":
                print("Exiting selection...")
                return ret_bad_input

            try:
                int_ans = int(ans)
            except ValueError:
                print("ERROR: not an integer. Please try again.")
                continue

            index = int_ans - start_num
            if 0 <= index < len(options):
                valid_input = True
            else:
                print("ERROR: entry specified was out of range. Please try again.")

        if reverse:
            return index
        return index

    def find_entries(self, keywords):
        """
        Try to find entries with filenames matching *every* keyword
        (case-insensitive).

        keywords: an array of strings

        Return a list of matched entries, sorted from oldest (first) to newest
        (last/most recent).
        """
        entries = self.get_entries()

        # Make all arguments slugs like the entry names (since we're not using
        # titles, just 'filenames' essentially).
        # This means you can search in any of these ways:
        #
        #     jctl edit "Exact title, converted to slug using JournalCtl.SLUG_CMD"
        #     jctl edit Different keywords which can match in any order
        #     jctl edit "your-own-slug-converted-is-the-same"
        #
        keywords = [ self.get_shell([JournalCtl.SLUG_CMD, word])[0] for word in keywords ]

        # for every entry:
        #     if all keywords separately found in entry, entry is a match
        matches = []
        for entry in entries:
            # be case-insensitive
            if all(word.lower() in entry.lower() for word in keywords):
                matches.append(entry)

        if len(matches) == 0:
            self.log("no matches found for keywords")
            return []

        if len(matches) > 1:
            self.log("more than 1 match found for keywords")

        return sorted(matches)

    def cmd_search(self, arguments):
        """Search for keywords in full journal text."""

        if not arguments:
            # we didn't get any arguments
            self.error("need at least 1 argument to search for",
                    JournalCtl.ERR_WRONG_ARGS)

        # get matches for *all* keywords
        matches_all = self.search_entries(arguments)

        # pretty-print 'all' matches
        if len(matches_all) == 0:
            self.message("No matches found for your query")
            self.exit(JournalCtl.SUCCESS)
        elif len(matches_all) == 1:
            self.message("1 match found")
        elif len(matches_all) > 1:
            self.message("Many matches found")

        yn = self.__yn_prompt("Open a matched entry?")
        if yn == 0:
            index = self.interactive_number_chooser(matches_all, reverse=True)
            if index == -1:
                self.message("Selection cancelled, exiting")
                self.exit(JournalCtl.ERR_SELECT_CANCEL)
            else:
                self.edit_entry(matches_all[index])
        elif yn == 1:
            self.message("Matches found in entries:")
            for match in matches_all:
                print(" * {}".format(match))
        else:
            self.message("ERROR: response wasn't y/n, exiting...")

    def __yn_prompt(self, prompt_msg):
        """
        Prompt the user with a yes/no question.

        Return 0 for 'yes'.
        Return 1 for 'no'.
        Return -1 for invalid input.
        """
        ret_y = 0
        ret_n = 1
        ret_invalid = -1

        try:
            yn = input(prompt_msg + " (y/n) ").lower()
        except KeyboardInterrupt:
            # new line so it looks better
            print()
            return ret_invalid
        except EOFError:
            return ret_invalid
        if yn == "y" or yn == "yes":
            return ret_y
        elif yn == "n" or yn == "no":
            return ret_n
        else:
            return -1

    def search_entries(self, keywords):
        """
        Try to find entries matching given keywords in the text, where a
        valid match is *each of* of the keywords found in text.

        Return a sorted list of matches, oldest to newest.
        """

        entries = self.get_entries()

        # for every entry:
        #     if all keywords separately found in entry text, entry is a match
        matches = []
        for entry in entries:
            text = self.get_text_of(entry)
            # remember to check everything in lowercase
            if all(word.lower() in text.lower() for word in keywords):
                matches.append(entry)

        if len(matches) == 0:
            self.log("no matches found for keywords")
            return []

        if len(matches) > 1:
            self.log("more than 1 match found for keywords")

        return sorted(matches)

    def get_text_of(self, entry):
        """Return the contents of the specified entry."""
        return self.open_entry(entry).read()

    def open_entry(self, entry):
        """Return a read-only file handle to the specified entry."""
        filename = self.get_entry_file(entry)
        return open(filename, JournalCtl.READ_ONLY)

    def edit_entry(self, entry):
        entry_file = self.get_entry_file(entry)

        tmpfile = self.__generate_entry_tmpfile(entry)
        self.log("Opening entry '{}' using '{}'".format(entry, self.editor))
        self.run_interactive([self.editor, tmpfile])

        # we get here when the file has been closed
        if filecmp.cmp(entry_file, tmpfile):
            # files are identical: no changes were made
            self.message("No changes made")
        else:
            # move tmpfile to original entry
            shutil.move(tmpfile, entry_file)
            self.message("File has been changed")
            yn = self.__yn_prompt("Update timestamp?")
            if yn == 0:
                self.update_time(entry)
            else:
                self.message("Exiting...")


    def __generate_entry_tmpfile(self, entry):
        """
        Copy the given entry to a temporary file for entry editing and return
        the temporary file's name.
        """
        tmp_dir = self.get_shell(["mktemp", "-d"])[0]
        tmp_file = tmp_dir + "/" + entry + self.entry_ext

        shutil.copyfile(self.get_entry_file(entry), tmp_file)

        return tmp_file

    def get_entry_file(self, entry):
        """
        Return a constructed full path for a given 'basename' file name (the
        entry).

        Note that this constructed file does *not* need to exist, since we might
        be opening a new file -- thus other functions must do that checking
        where required.
        """
        entry_path = self.get_entry_git_path(entry)
        return "{}/{}".format(self.journal_dir, entry_path)

    def get_entries(self):
        """
        Return an unsorted list of all entry names.

        In jctl, most functions only deal with the 'slug' as an entry name (i.e.
        'YYYY-MM-DD-title-slug'). This function return the 'basename' of each
        entry, without full path *or the extension*.
        """
        return [
            os.path.splitext(entry)[0]
                for entry in os.listdir(
                        "{}/{}".format(self.journal_dir, self.entry_dir))
                if not entry.startswith(".")
               ]

    def get_all_front_matter(self, entry):
        """
        Retrieve an entry's front matter.

        Return a list holding the all the entry's front matter in the
        format:

            [
                ["title", "Example title"],
                ["date", "1970-01-01 00:00:00"],
                None
                ["empty_var", None],
            ]

        If a variable is not assigned a value, it will be included as in the
        empty_var row. Empty lines are kept, and indicated by a None element
        (rather than a list). If an error occurs, the function fails and errors
        out.
        """

        with self.open_entry(entry) as f:
            text = f.read()

        # FIXME: not the best way of setting begin index
        begin_index = 4
        end_index = text.find(JournalCtl.FRONT_MATTER_END)
        raw_front_matter = text[begin_index:end_index].strip().split("\n")

        front_matter = []
        for line in raw_front_matter:
            parts = line.split(JournalCtl.FRONT_MATTER_VALUE_SEP, 1)
            if len(parts) == 2:
                # correct
                pass
            elif len(parts) > 2:
                self.error("somehow split front matter line into >2 parts",
                        JournalCtl.ERR_BAD_FRONT_MATTER)
            elif len(parts) == 1 and parts[0] == "":
                self.log("empty line in front matter")
                parts = None
            elif len(parts) == 1 and parts[0] != "":
                self.log("front matter variable '{}' has no value".format(
                    parts[0]))
                parts.append(None)
            else:
                self.error("unknown error in front matter",
                        JournalCtl.ERR_BAD_FRONT_MATTER)

            front_matter.append(parts)

        return front_matter

    def get_front_matter(self, entry, fm_var):
        """
        Get the front matter variable specified.

        Try to return the front matter variable with name fm_var. If it isn't
        found, return None.
        """

        fm = self.get_all_front_matter(entry)
        for val in fm:
            if val[0] == fm_var:
                return val[1]

        # didn't find fm_var
        return None

    def get_entry_text(self, entry):
        with self.open_entry(entry) as f:
            text = f.read()

        begin_index = text.find(JournalCtl.FRONT_MATTER_END)
        entry_text = text[begin_index+len(JournalCtl.FRONT_MATTER_END):]
        return entry_text

    def update_time(self, entry):
        """
        Fix an entry using its metadata and update its time to the time now.
        """
        return self.fix_entry(entry, date=time.strftime("%F %T %z"))

    def fix_entry(self, entry, date=None):
        """
        Check that an entry is named correctly (considering its metadata
        in-file to be accurate) and fix the filename if required.

        If a date is provided, replace the file's 'date' field with it.

        Return the updated entry name (or the original one if unchanged).
        """
        front_matter = self.get_all_front_matter(entry)
        entry_text = self.get_entry_text(entry)

        entry_file = self.get_entry_file(entry)

        new_text = ""
        entry_title = None
        new_text += JournalCtl.FRONT_MATTER_SEP + "\n"
        for line in front_matter:
            if line == None:
                # empty line
                new_text += "\n"
                continue

            var = line[0]
            value = line[1]
            if var == "date":
                # get old date (used to check filename consistency)
                old_date = value
                if date != None:
                    # we were given a date to update to, so do it
                    value = date
                    self.message("Timestamp updated ({} -> {}).".format(
                        line[1], value))
            if var == "title":
                # grab the title to check consistency with later
                entry_title = value
            if value == None:
                # empty field, error out
                self.error("YAML variable '{}' has no value assigned",
                        JournalCtl.ERR_BAD_FRONT_MATTER)
            new_text += "{}: {}\n".format(var, value)
        new_text += JournalCtl.FRONT_MATTER_SEP + "\n"
        new_text += entry_text

        # check that we got a title
        if not entry_title:
            self.error("no title found in front matter",
                    JournalCtl.ERR_BAD_FRONT_MATTER)

        with open(entry_file, JournalCtl.WRITE_ONLY) as f:
            f.write(new_text)

        check_entry = date.split(" ")[0] + "-" \
                + self.get_shell([JournalCtl.SLUG_CMD, entry_title])[0]
        if entry != check_entry:
            self.message("Filename is inconsistent with date/title, fixing using metadata")
            new_file = self.get_entry_file(check_entry)
            shutil.move(entry_file, new_file)
            self.log("moved entry ({} -> {})".format(entry, check_entry))
            return check_entry
        else:
            return entry

    def cmd_push(self):
        self.message("Pushing...")
        ret = self.run_interactive(["git", "push"])
        if ret == 0:
            self.log("push succeeded")
        else:
            self.error("Push failed, error code {}".format(ret),
                    JournalCtl.ERR_GIT)



if __name__ == "__main__":
    jctl = JournalCtl()
    jctl.execute_cmd()

#!/usr/bin/env python3 # # Short description of the program/script's operation/function. # Argparse override to print usage to stderr on argument error. # set variables # if this has >1 full stop then you gotta fix get_entries() # ensure that required directories exist Deinitialise and exit. # Logging {{{ Log a message to a specific pipe (defaulting to stdout). If verbose, log an event. Log an error and exit. Print usage and exit depending on given exit code. # if argument was non-zero, print to STDERR instead Print a message to stdout, regardless of verbosity. # Logging }}} # add arguments # parse & grab arguments Run a shell command, returning the output. # we run without a shell (default) so we don't need to shell escape # strange titles e.g. ones with punctuation in # also run in self.journal_dir Run an interactive shell command and return the exit code.          Blocks execution while the command is running. # run it in self.journal_dir, or given directory Try to run something based on the command given. # get the last X entries # now pretty-print them # strip quotes (used when taking title from front matter) Get and parse the output of `git status` into an easier format to         manipulate. # split -> lines, strip leading/trailing whitespace & split on whitespace # (at this point we always have at least 1 element) # return empty list if no changes Try to commit a number of files, depending on repo conditions and         arguments.          For now, it will commit 1 file at most.          Current behaviour:           * if exactly 1 untracked file:               * commit using arguments[0] if present and end           * else search for entries using arguments as keywords               * title chooser (default value/pre-entered stuff?)               * if exactly 1 match, commit it and end               * else use number chooser # test for simple conditions # Guaranteed to either exit or set c_git_entry, c_title and c_type . # If c_title isn't changed from the default it's automagically taken # from the entry when committing. # test arguments against available dirty files # search for keywords in dirty files # keywords are all arguments except last # choose 1 entry from matched entries # hit Ctrl-C / cancelled it # set entry title part of commit message after entry name # let commit_entry() work it out # commit file # empty/None title passed, get from entry front matter # verify correct type of change # something else (are there any other types of changes?) # strip quotes (used when taking title from front matter) # form commit message # TODO: reset all before doing stuff -- be safe # editing commit message interactively # ask whether to commit Return the path of an entry starting from the journal Git repo root.          Used for `git add`ing things. Return the entry pointed to by a Git path. # TODO: not completely reliable, just good enough for now Create a new entry using a file templater (by default, my Pyplater).          Note that unlike my previous 'journal' template, the 'jctl-*' templates         require that jctl provides the *full* filename. That way the templater         doesn't get involved with journal placement. # we need at least the template name (entry, exam, meal) & title # get title & name of new entry # check that exact file does not exist already # templater command # if extra arguments, append them # I use date field as 'last edited' field, so update again when finished # (my Pyplater already fills it in, but only at the start) # since the previous operation may change the entry name, re-grab it) # ask to commit the new file # ask user which one to open # hit Ctrl-C / cancelled it # index is valid Show an interactive chooser for a list of options and return the number         entered.          options: an array of strings          For valid input, return a positive integer for the option's index in         the list.         For other input (cancelled, empty line), return -1. # there is one option. return its index :) # new line so it looks better # special input values Try to find entries with filenames matching *every* keyword         (case-insensitive).          keywords: an array of strings          Return a list of matched entries, sorted from oldest (first) to newest         (last/most recent). # Make all arguments slugs like the entry names (since we're not using # titles, just 'filenames' essentially). # This means you can search in any of these ways: # #     jctl edit "Exact title, converted to slug using JournalCtl.SLUG_CMD" #     jctl edit Different keywords which can match in any order #     jctl edit "your-own-slug-converted-is-the-same" # # for every entry: #     if all keywords separately found in entry, entry is a match # be case-insensitive Search for keywords in full journal text. # we didn't get any arguments # get matches for *all* keywords # pretty-print 'all' matches Prompt the user with a yes/no question.          Return 0 for 'yes'.         Return 1 for 'no'.         Return -1 for invalid input. # new line so it looks better Try to find entries matching given keywords in the text, where a         valid match is *each of* of the keywords found in text.          Return a sorted list of matches, oldest to newest. # for every entry: #     if all keywords separately found in entry text, entry is a match # remember to check everything in lowercase Return the contents of the specified entry. Return a read-only file handle to the specified entry. # we get here when the file has been closed # files are identical: no changes were made # move tmpfile to original entry Copy the given entry to a temporary file for entry editing and return         the temporary file's name. Return a constructed full path for a given 'basename' file name (the         entry).          Note that this constructed file does *not* need to exist, since we might         be opening a new file -- thus other functions must do that checking         where required. Return an unsorted list of all entry names.          In jctl, most functions only deal with the 'slug' as an entry name (i.e.         'YYYY-MM-DD-title-slug'). This function return the 'basename' of each         entry, without full path *or the extension*. Retrieve an entry's front matter.          Return a list holding the all the entry's front matter in the         format:              [                 ["title", "Example title"],                 ["date", "1970-01-01 00:00:00"],                 None                 ["empty_var", None],             ]          If a variable is not assigned a value, it will be included as in the         empty_var row. Empty lines are kept, and indicated by a None element         (rather than a list). If an error occurs, the function fails and errors         out. # FIXME: not the best way of setting begin index # correct Get the front matter variable specified.          Try to return the front matter variable with name fm_var. If it isn't         found, return None. # didn't find fm_var Fix an entry using its metadata and update its time to the time now. Check that an entry is named correctly (considering its metadata         in-file to be accurate) and fix the filename if required.          If a date is provided, replace the file's 'date' field with it.          Return the updated entry name (or the original one if unchanged). # empty line # get old date (used to check filename consistency) # we were given a date to update to, so do it # grab the title to check consistency with later # empty field, error out # check that we got a title