#!/usr/bin/perl

=head1 NAME

chronicle - A static blog-compiler.

=cut

=head1 SYNOPSIS

  chronicle [options]


  Path Options:

   --comments       Specify the path to the optional comments directory.
   --config         Specify a configuration file to read.
   --database       Specify the path to the SQLite database to create/use.
   --input          Specify the input directory to use.
   --output         Specify the directory to write output to.
   --pattern        Specify the pattern of files to work with.
   --theme          Specify the theme to use.
   --theme-dir      Specify the path to the theme templates.
   --url-prefix     Specify the prefix to the generated blog.

  Counting Options:

   --comment-days=N    The maximum age a post may allow comments.
   --entry-count=N     Number of posts to show on the index.
   --rss-count=N       Number of posts to include on the RSS index feed.

  Optional Features:

   --author        Specify the author's email address.
   --blog-subtitle Set the title of the blog.
   --blog-title    Set the title of the blog.
   --force         Always regenerate pages.

  Help Options:

   --help         Show the help information for this script.
   --list-plugins List the available plugins.
   --list-themes  List the available themes.
   --manual       Read the manual for this script.
   --verbose      Show useful debugging information.
   --version      Show the version number and exit.

=cut

=head1 ABOUT

Chronicle is a blog-compiler which will convert a directory full of
plain-text blog-posts into a fully-featured HTML website containing
posts, tags, and archives.

All blog-posts from a given input directory are parsed into a SQLite
database which is then used to generate the output pages.

The SQLite database is assumed to persist, such that it will be updated
if new posts are written, or previous posts are updated.  However if
it is removed it will be recreated when needed.

=cut

=head1 DATABASE STRUCTURE

When C<chronicle> is first executed it will create an SQLite database
if it is not already present.  The database will contain two tables,
one for the posts, and one to store the tags associated with the posts,
if you choose to use tags in your entries.

The blog-entry table contains the following columns:

=over 8

=item mtime

The C<mtime> of the input file.

=item date

The date-header as self-reported in the blog-post.

=item body

The body of the blog-post itself.

=item title

The title of the blog-post itself.

=back

If you wish to add extra tables via a local plugin you're welcome to do so.

=cut

=head1 EXTENDING WITH PLUGINS

The main driver, chronicle, is responsible for only a few small jobs:

=over 8

=item Finding Blog Posts.

By default C<data/*.txt> are read, but you may adjust the input directory via the C<--input> command-line flag.  The pattern may be set with C<--pattern>.

B<NOTE> The pattern is applied recursively, if you wish to create sub-directories with your posts inside them for organizational purposes.

=item Inserting them into the SQLite database.

The header is read to look for things such as the post-date, the subject,
and the tags.  The body is imported literally, unless expanded and reformatted
via a plugin.

=item Executing plugins

Each registered plugin will be invoked in turn, allowing the various
output parts to be generated.

=back

The output is exclusively generated by the plugins bundled with the
code.

For example all of the tag-pages, located beneath C</tags/> in your
 generated site, are generated by the C<Chronicle::Plugin::Generate::Tags> module.

The core will call the following methods if present in plugins:

=over 8

=item on_db_create

This is called if the SQLite database does not exist and must be created.  This method can be used to add new columns or tables to the database, etc.

=item on_db_open

This is called when an existing SQLite database is opened, and we use it to set memory/sync options.

=item on_insert

This method is invoked as a blog entry is read to disk before it is inserted into the database for the first time - or when the item on disk has been changed and the database entry must be refreshed.

This method is the perfect place to handle format conversion, which is demonstrated in the following plugins:

=over 8

=item L<Chronicle::Plugin::Markdown>

=item L<Chronicle::Plugin::MultiMarkdown>

=item L<Chronicle::Plugin::Textile>

=back

Beyond format conversion this method is also good for expanding macros, or snippets of HTML.  (This is done by L<Chronicle::Plugin::YouTube> for example.)

=item on_initiate

This is called prior to any generation, with a reference to the configuration
options and the database handle used for storage.

=item on_generate

This is called to generate the output pages.  There is no logical difference between this method and C<on_initiate> except that the former plugin methods are guaranteed to have been called prior to C<on_generate> being invoked.

Again a reference to the configuration options, and the database handle is provided.

=back

Any plugin in the C<Chronicle::Plugin::> namespace will be loaded when the
script starts.

You might wish to disable plugins, and this can be done via command-line
flags such as C<--exclude-plugin=RSS,Verbose>.

=cut

=head1 THEMES

There are a small collection of themese bundled with the release, and it
is assumed you might write your own.

Themes are located beneath a particular directory, such that the files contained
in one are located at:

=for example begin

      $theme-dir/$theme-name

=for example end

These two names can be set via C<--theme-dir> and C<--theme> respectively.

Each theme will consist of a small number of L<HTML::Template> files.
In brief a theme is complete if it contains:

=over 8

=item C<archive.tmpl>

This is the file used to generate an archived month/year index.

=item C<archive_index.tmpl>

This is the file used to generate the top-level C</archive/> page.

=item C<entry.tmpl>

This is the file used to generate each individual blog-entry.

=item C<index.tmpl>

This is the file used to generate your front-page.

=item C<index.rss>

This is the file used to generate your RSS feed.

=item C<tag.tmpl>

This is the file used to generate the top-level C</tag/XX/> page.

=item C<tag_index.tmpl>

This is the file used to generate the top-level C</tag/> page.

=back

Each theme page will receive different data, as set by the appropriate
generation plugin, and any global C<Chronicle::Plugin::Snippets> plugins
which have been loaded.

=cut

=head1 FAQ

=over 8

=item How do I generate a new type of pages?

If you wish to generate a new hierarchy of pages then you should create
a new plugin to generate them.  The C<Chronicle::Plugin::Generate::RSS>
would be a good starting point.

=item How do I include some data in each page?

If you wish to make a piece of data available to B<all> output pages
then it must be generated first.

That is what the plugins beneath the C<Chronicle::Plugin::Snippets> hierarchy
do - They are invoked first and can update the global variables to make some
new data available to all the templates.

This is how the global tag-cloud, recent posts, and similar data is able
to be included in the sidebar in the default template.

=item How do I generate non-English month names?

Chronicle uses the L<Date::Language> module for outputing month-names
in the archive index.

By default "English" is used, but if you set the environmental variable
C<MONTHS> then the contents will be used.  For example you might enjoy
the Finnish month-names, which can be achieved via:

=for example begin

    MONTHS=Finnish chronicle ...

=for example end

=item How do I ignore draft-posts?

If you add the header C<draft: 1> to your pending post then it will
be excluded from the blog, via the L<Chronicle::Plugin::SkipDrafts>
plugin.

=item How do I schedule future-posts?

If you add a C<publish:> header, rather than a C<date:> header, to your
posts it will allow you to schedule the release of future posts via the
L<Chronicle::Plugin::PostSpooler> plugin.

=back

=cut

=head1 LICENSE

This module is free software; you can redistribute it and/or modify it
under the terms of either:

a) the GNU General Public License as published by the Free Software
Foundation; either version 2, or (at your option) any later version,
or

b) the Perl "Artistic License".

=cut

=head1 AUTHOR

Steve Kemp <steve@steve.org.uk>

=cut


use strict;
use warnings;


package Chronicle;
use Module::Pluggable::Ordered require => 1, inner => 0;

our $VERSION = "5.1.2";

use DBI;
use Date::Format;
use Date::Parse;
use Digest::MD5 qw(md5_hex);
use File::Basename;
use File::Find;
use File::Path;
use File::ShareDir;
use Getopt::Long;
use HTML::Template;
use Pod::Usage;


use Chronicle::Config::Reader;


#
#  Default options - These may be overridden by the command-line
# or via the configuration files:
#
#   /etc/chronicle/config
#   ~/.chronicle/config
#
#  NOTE: These filenames we deliberately chosen to avoid clashing
# with previous releases of chronicle.
#
our %CONFIG;
$CONFIG{ 'input' }        = "./data";
$CONFIG{ 'pattern' }      = "*.txt";
$CONFIG{ 'output' }       = "./output";
$CONFIG{ 'database' }     = "./blog.db";
$CONFIG{ 'comment-days' } = 10;
$CONFIG{ 'entry-count' }  = 10;
$CONFIG{ 'rss-count' }    = 10;
$CONFIG{ 'theme-dir' }    = File::ShareDir::dist_dir('App-Chronicle');
$CONFIG{ 'theme' }        = "default";
$CONFIG{ 'verbose' }      = 0;
$CONFIG{ 'top' }          = "/";
$CONFIG{ 'exclude-plugins' } =
  "Chronicle::Plugin::Archived,Chronicle::Plugin::Verbose";

our %DATABASE_SCHEMA = (
    blog => {
        columns =>
          [qw/ id file date title link mtime body truncatedbody template /],
        create => [
            'CREATE TABLE blog (id INTEGER PRIMARY KEY,file,date,title,link,mtime,body,truncatedbody,template )',
            'CREATE UNIQUE INDEX unique_title on blog (title)',
        ],
    },
    tags => { columns => [qw/ id name blog_id /],
              create =>
                ['CREATE TABLE tags (id INTEGER PRIMARY KEY, name, blog_id )'],
            },
    pages => {
        columns => [qw/ id filename title content template /],
        create => [],    # Chronicle::Plugin::StaticPages will do it
             },
);

#
#  Options here are passed to all templates
#
our %GLOBAL_TEMPLATE_VARS = ();


#
#  Read the global and per-user configuration file, if present.
#
my $cnf = Chronicle::Config::Reader->new();
$cnf->parseFile( \%CONFIG, "/etc/chronicle/config" );
$cnf->parseFile( \%CONFIG, $ENV{ 'HOME' } . "/.chronicle/config" );


#
#  Parse our command-line options
#
parseCommandLine();


#
#  If we have a configuration file then read it.
#
$cnf->parseFile( \%CONFIG, $CONFIG{ 'config' } )
  if ( defined $CONFIG{ 'config' } );



#
# Get the database handle, creating the database on-disk if necessary.
#
my $dbh = getDatabase();


#
#  Parse/update blog posts from our input directory.
#
updateDatabase($dbh);


#
#  Ensure we have an output directory.
#
File::Path::make_path( $CONFIG{ 'output' },
                       {  verbose => 0,
                          mode    => oct("755"),
                       } ) unless ( -d $CONFIG{ 'output' } );



#
#  Call on_initiate for all plugins which have not been excluded.
#
foreach my $plugin ( get_plugins_for_method("on_initiate") )
{
    $CONFIG{ 'verbose' } && print "Calling $plugin on_initiate()\n";
    $plugin->on_initiate( config => \%CONFIG, dbh => $dbh );
}


#
#  Call on_generate for all plugins which have not been excluded.
#
#  `on_generate` is logically identical to `on_initiate`, except
# the former plugins are guranteed to have been invoked first.
#
foreach my $plugin ( get_plugins_for_method("on_generate") )
{
    $CONFIG{ 'verbose' } && print "Calling $plugin on_generate()\n";
    $plugin->on_generate( config => \%CONFIG, dbh => $dbh );
}


#
#  Copy any static content from the theme-directory.
#
my $ts = $CONFIG{ 'theme-dir' } . "/" . $CONFIG{ 'theme' } . "/static";
if ( -d $ts )
{

    #
    #  This could be improved, but it will cope with subdirectories, etc,
    # so for the moment it will remain.
    #
    system("/bin/tar -C $ts -cpf - . | /bin/tar -C $CONFIG{'output'} -xf -");
}


#
#  Now we're done.
#
$dbh->disconnect();
exit(0);




=begin doc

Read each blog-post from beneath ./data/ - and if it is missing from the
database then insert it.

We also handle the case where the file on disk is newer than the database
version - in that case we remove the database version and update it to
contain the newer content.

=end doc

=cut

sub updateDatabase
{
    my ($dbh) = (@_);

    #
    #  Assume each entry is already present in the database.
    #
    my $sql =
      $dbh->prepare("SELECT id FROM blog WHERE ( file=? AND mtime=? )") or
      die "Failed to select post";


    #
    #  Look for posts.
    #

    foreach
      my $file ( get_post_files( $CONFIG{ 'input' }, $CONFIG{ 'pattern' } ) )
    {

        #
        # We want to find the mtime to see if it is newer than the DB-version.
        #
        my ( $dev,   $ino,     $mode, $nlink, $uid,
             $gid,   $rdev,    $size, $atime, $mtime,
             $ctime, $blksize, $blocks
           ) = stat($file);


        #
        #  Lookup the existing entry
        #
        $sql->execute( $file, $mtime ) or
          die "Failed to execute: " . $dbh->errstr();
        my $result = $sql->fetchrow_hashref();

        if ( !$result )
        {

            #
            #  The file is not in the database, or it is present with a
            # different modification time.
            #
            #  Parse the file and insert it.
            #
            insertPost( $dbh, $file, $mtime );
        }
    }

    $sql->finish();
}



=begin doc

Given a filename containing a blog post then insert that post into
the database.

We also update the tags.

=end doc

=cut

sub insertPost
{
    my ( $dbh, $filename, $mtime ) = (@_);

    $CONFIG{ 'verbose' } && print "Adding post to DB: $filename\n";

    #
    #  Is the entry present, but with a different mtime?
    #
    #  If so we need to delete the post, and the tags which are pointing
    # at it, otherwise we'll have orphaned tags.
    #
    my $sql = $dbh->prepare("SELECT id FROM blog WHERE file=?");
    $sql->execute($filename) or die "Failed to execute :" . $dbh->errstr();
    my $id;
    $sql->bind_columns( undef, \$id );

    while ( $sql->fetch() )
    {
        $CONFIG{ 'verbose' } && print "Replacing DB post: $id\n";

        #
        #  Delete the tags referring to this old post.
        #
        my $del_tags = $dbh->prepare("DELETE FROM tags WHERE blog_id=?") or
          die "Failed to prepare ";
        $del_tags->execute($id) or
          die "Failed to delete tags:" . $dbh->errstr();
        $del_tags->finish();

        #
        #  Now delete the entry
        #
        my $del_blog = $dbh->prepare("DELETE FROM blog WHERE id=?") or
          die "Failed to prepare ";
        $del_blog->execute($id) or
          die "Failed to delete blog:" . $dbh->errstr();
        $del_blog->finish();
    }
    $sql->finish();


    #
    #  Read the actual entry from disk.
    #
    my $inHeader = 1;
    open my $handle, "<:encoding(utf-8)", $filename or
      die "Failed to read $filename $!";

    #
    #  The meta-data which comes from the posts header.
    #
    my %meta;

    while ( my $line = <$handle> )
    {
        if ( $inHeader > 0 )
        {

            #
            #  If the line has the form of "key: value"
            #
            if ( $line =~ /^([^:]+):(.*)/ )
            {
                my $key = $1;
                my $val = $2;

                $key = lc($key);
                $key =~ s/^\s+|\s+$//g;
                $val =~ s/^\s+|\s+$//g;

                #
                #  "subject" is a synonym for "title".
                #
                $key = "title" if ( $key eq "subject" );

                #
                #  Update the value if there is one present,
                # and we've not already saved that one away.
                #
                $meta{ $key } = $val
                  if ( defined($val) && length($val) && !$meta{ $key } );

            }
            else
            {

                #
                #  Empty line == end of header
                #
                $inHeader = 0 if ( $line =~ /^$/ );
            }
        }
        else
        {
            $meta{ 'body' } .= $line;
        }
    }
    close($handle);


    # Ensure we have a title.
    defined $meta{ 'title' } or die "Missing `Title:' line in `$filename'";

    # initiate the truncated body.
    $meta{ 'truncatedbody' } = '';

    # initiate the template and change if there is a template is supplied.
    $meta{ 'template' } = "entry.tmpl" unless defined $meta{ 'template' };

    #
    #  Generate the link from the title of the post.
    #
    $meta{ 'link' } = $meta{ 'title' };

    #
    #  Remove the extension - regardless of what that might be.
    #
    $meta{ 'link' } =~ s/\.[^.]+$//;

    #
    #  Remove non-alphanumeric characters.
    #
    $meta{ 'link' } =~ s/[^a-z0-9]/_/gi;

    #
    #  Add the suffix for the page
    #
    my $suffix = $CONFIG{ 'entry_suffix' } || ".html";
    $meta{ 'link' } .= $suffix;

    #
    #  Lower-case
    #
    $meta{ 'link' } = lc( $meta{ 'link' } );

    #
    #  Let any plugins have access to the filename.
    #
    $meta{ 'file' } = $filename;

    #
    #  Are we going to skip this post?
    #
    my $skip = 0;

    #
    #  Update our meta-data via any loaded plugins.
    #
    foreach my $plugin ( get_plugins_for_method("on_insert") )
    {
        $CONFIG{ 'verbose' } && print "Calling $plugin - on_insert\n";
        my $m = $plugin->on_insert( config => \%CONFIG,
                                    dbh    => $dbh,
                                    data   => \%meta
                                  );

        if ( !$m )
        {

            #
            #  We'll skip any post if the insert plugin returned an
            # empty value.
            #
            $skip = 1;
        }
        else
        {

            #
            #  If we know we're going to skip this post then we'll
            # not update the meta-data, which will ensure that
            # future plugins won't have empty data-structures.
            #
            #  This isn't essential but it helps avoid warnings or
            # weirdness.
            #
            %meta = %$m;
        }
    }


    if ($skip)
    {
        $CONFIG{ 'verbose' } && print "Skipping post: $filename\n";
        return;
    }


    #
    #  Convert the date to a seconds past epoch.
    #
    if ( !$meta{ 'date' } )
    {
        die "Post is missing a date header - $filename\n";
    }
    else
    {
        $meta{ 'date' } = str2time( $meta{ 'date' } );
    }

    #
    #  Now insert
    #
    my $post_add = $dbh->prepare(
        "INSERT INTO blog (file,date,title,link,mtime,body,truncatedbody,template) VALUES( ?,?,?,?,?,?,?,?)"
      ) or
      die "Failed to prepare";

    $post_add->execute( $filename,
                        $meta{ 'date' },
                        $meta{ 'title' },
                        $meta{ 'link' },
                        $mtime,
                        $meta{ 'body' },
                        $meta{ 'truncatedbody' },
                        $meta{ 'template' }
      ) or
      die "Failed to insert:" . $dbh->errstr();

    my $blog_id = $dbh->func('last_insert_rowid');


    #
    #  Add any tags the post might contain.
    #
    if ( $meta{ 'tags' } )
    {
        my $tag_add =
          $dbh->prepare("INSERT INTO tags (blog_id, name) VALUES( ?,?)") or
          die "Failed to prepare";

        foreach my $tag ( split( /,/, $meta{ 'tags' } ) )
        {

            # strip leading and trailing space.
            $tag =~ s/^\s+//;
            $tag =~ s/\s+$//;

            # skip empty tags.
            next if ( !length($tag) );

            # Tags are always down-cased
            $tag = lc($tag);

            #
            #  Add the new tag to the post.
            #
            $tag_add->execute( $blog_id, $tag ) or
              die "Failed to execute:" . $dbh->errstr();
        }
    }
}


=begin doc

Given a database handle, check that all required tables and columns exist.
Returns a boolean indicating success.

=end doc

=cut

sub check_database_structure
{
    my ($dbh) = (@_);
    while ( my ( $table_name, $table_spec ) = each %DATABASE_SCHEMA )
    {
        for my $column ( @{ $table_spec->{ columns } } )
        {
            local $dbh->{ PrintError } = 0;
            $dbh->selectcol_arrayref("SELECT $column FROM $table_name LIMIT 1")
              or
              return;
        }
    }
    return 1;
}

=begin doc

Open a named file as an SQLite database

=end doc

=cut

sub get_database_handle
{
    my ($filename) = (@_);

    my $dbh =
      DBI->connect( "dbi:SQLite:dbname=$filename", "", "",
                    { AutoCommit => 1, RaiseError => 0 } ) or
      die "Could not open SQLite database: $DBI::errstr";
    $dbh->{ sqlite_unicode } = 1;
    return $dbh;
}

=begin doc

Create a database handle, if necessary creating the tables first.

=end doc

=cut

sub getDatabase
{

    #
    #  Is the database already present?
    #
    my $present = 0;

    #
    #  Ensure we have something specified.
    #
    die "No database configured - please use --database=/path/tocreate"
      unless ( $CONFIG{ 'database' } );

    #
    #  Does it exist?
    #
    $present = 1 if ( -e $CONFIG{ 'database' } );

    my $dbh = get_database_handle( $CONFIG{ 'database' } );

    # If it exists but fails the structure check, just delete it
    if ( $present and not check_database_structure($dbh) )
    {
        $dbh->disconnect;
        unlink $CONFIG{ 'database' };
        $dbh     = get_database_handle( $CONFIG{ 'database' } );
        $present = 0;
    }

    if ( !$present )
    {
        for my $table_spec ( values %DATABASE_SCHEMA )
        {
            $dbh->do($_) for @{ $table_spec->{ create } };
        }

        foreach my $plugin ( get_plugins_for_method("on_db_create") )
        {
            $CONFIG{ 'verbose' } && print "Calling $plugin - on_db_create\n";
            $plugin->on_db_create( config => \%CONFIG,
                                   dbh    => $dbh, );
        }

    }


    foreach my $plugin ( get_plugins_for_method("on_db_load") )
    {
        $CONFIG{ 'verbose' } && print "Calling $plugin - on_db_load\n";
        $plugin->on_db_load( config => \%CONFIG,
                             dbh    => $dbh, );
    }

    return ($dbh);
}



=begin doc

Fetch the blog post with the given ID

=end doc

=cut

sub getBlog
{
    my (%params) = (@_);

    #
    #  These are compulsary
    #
    my $dbh = $params{ 'dbh' } || die "Missing database handle";
    my $id  = $params{ 'id' }  || die "Missing ID";

    #
    #  This is optional, and present so that the date/time format
    # may be changed by the user in their configuration file.
    #
    my $config = $params{ 'config' } || undef;


    #
    #  Get the blog-post
    #
    my $sql = $dbh->prepare("SELECT * FROM blog WHERE id=?") or
      die "Failed to prepare";
    $sql->execute($id) or
      die "Failed to execute:" . $dbh->errstr();
    my $data = $sql->fetchrow_hashref();
    $sql->finish();

    #
    #  Get the tags, if any
    #
    my $tags =
      $dbh->prepare("SELECT name FROM tags WHERE blog_id=? ORDER by name ASC")
      or
      die "Failed to prepare";
    my $name;
    $tags->execute($id) or die "Failed to execute: " . $dbh->errstr();
    $tags->bind_columns( undef, \$name );
    while ( $tags->fetch() )
    {
        my $x = $data->{ 'tags' };
        push( @$x, { tag => $name } );
        $data->{ 'tags' } = $x;
    }
    $tags->finish();

    #
    #  Generate the date/time from mtime;
    #
    #  If the date is set then we use it, and get the time from the mtime
    #
    #  If the date is not set then we use the mtime for both date & time.
    #
    my $time;
    my $posted = $data->{ 'posted' } = $data->{ 'date' };
    if ( $data->{ 'date' } )
    {
        $time = $data->{ 'date' };
    }
    else
    {
        $time = $posted = $data->{ 'mtime' };
    }


    ##
    ##  Get the format-strings to use for time-formatting.
    ##
    my $time_fmt = "%H:%M:%S";
    if ($config)
    {
        $time_fmt = $config->{ "time_format" } || "%H:%M:%S";
    }
    $time_fmt = undef if ( $time_fmt !~ /%/ );


    ##
    ##  Ditto for the date-string.
    ##
    my $date_fmt = "%a, %e %b %Y";
    if ($config)
    {
        $date_fmt = $config->{ "date_format" } || "%a, %e %b %Y";
    }
    $date_fmt = undef if ( $date_fmt !~ /%/ );


    #
    #  The date of the entry - if we have a date-format that contains
    # at least one % character.
    #
    if ( $date_fmt && length($date_fmt) )
    {
        $data->{ 'date' } = time2str( $date_fmt, $time );
    }


    #
    #  The time of the entry - if we have a time-format that contains
    # at least one % character.
    #
    if ( $time_fmt && length($time_fmt) )
    {

        #
        #  The time from the import-date.
        #
        $data->{ 'time' } = time2str( $time_fmt, $time );

        #
        #  If that failed then from the modification-time.
        #
        if ( $data->{ 'time' } =~ /00:00:00/ )
        {
            $data->{ 'time' } = time2str( $time_fmt, $data->{ 'mtime' } );
        }
    }

    #
    #  For the RSS-Feed
    #
    $data->{ 'iso_8601' } = time2str( '%Y-%m-%dT', $time );
    $data->{ 'iso_8601' } .= $data->{ 'time' } . "-00:00";


    #
    #  If comments are enabled then populate the blog-post with them too.
    #
    if ( $CONFIG{ 'comments' } )
    {
        my $comments = getComments( $data->{ 'link' } );
        if ($comments)
        {
            $data->{ 'comments' } = $comments;

            my $count = scalar(@$comments);
            $data->{ 'comment_count' }  = $count;
            $data->{ 'comment_plural' } = 1
              if ( ( $count == 0 ) || ( $count > 1 ) );
        }

        #
        #  Comments are enabled at this point.
        #
        #  If the post is not "too old" then allow the theme-templates
        # to know that.
        #
        my $now = time;
        my $ago = $now - $posted;
        my $age = ( ( 60 * 60 * 24 ) * ( $CONFIG{ 'comment-days' } ) );

        if ( $ago < $age )
        {
            $data->{ 'comments_enabled' } = 1;
        }
        else
        {
            $data->{ 'comments_enabled' } = undef;
        }
    }

    return ($data);
}



=begin doc

Get the comments associated with a given post, if comments are
enabled and there are some present.

=end doc

=cut

sub getComments
{
    my ($title) = (@_);

    #
    #  If there is no comment-directory setup then return nothing.
    #
    return unless ( $CONFIG{ 'comments' } );

    #
    #  If there is a comment-directory setup, but it doesn't exist
    # then again we do nothing.
    #
    return unless ( -d $CONFIG{ 'comments' } );


    my $results;

    if ( $title =~ /^(.*)\.([^.]+)$/ )
    {
        $title = $1;
    }

    #
    #  Find each comment file.
    #
    my @entries;
    foreach
      my $file ( sort( glob( $CONFIG{ 'comments' } . "/" . $title . "*" ) ) )
    {
        push( @entries, $file );
    }

    #
    # Sort them into order.
    #
    @entries = sort {( stat($a) )[9] <=> ( stat($b) )[9]} @entries;

    #
    #  Now process them.
    #
    foreach my $file (@entries)
    {
        my $date    = "";
        my $name    = "";
        my $link    = "";
        my $body    = "";
        my $mail    = "";
        my $pubdate = "";

        if ( $file =~ /^(.*)\.([^.]+)$/ )
        {
            $date = $2;

            if ( $date =~ /(.*)-([0-9:]+)/ )
            {
                my $d = $1;
                my $t = $2;

                $d =~ s/-/ /g;

                $date = "Submitted at $t on $d";
            }
        }

        open my $comment, "<:encoding(utf-8)", $file or
          next;

        foreach my $line (<$comment>)
        {
            next if ( !defined($line) );

            chomp($line);

            next if ( $line =~ /^IP-Address:/ );
            next if ( $line =~ /^User-Agent:/ );

            if ( !length($name) && $line =~ /^Name: (.*)/i )
            {
                $name = $1;
            }
            elsif ( !length($mail) && $line =~ /^Mail: (.*)/i )
            {
                $mail = $1;
            }
            elsif ( !length($link) && $line =~ /^Link: (.*)/i )
            {
                $link = $1;
            }
            else
            {
                $body .= $line . "\n";
            }
        }
        close($comment);

        if ( length($name) &&
             length($mail) &&
             length($body) )
        {

            #
            #  Add a gravitar link to the comment in case the
            # theme wishes to use it.
            #
            my $default  = "";
            my $size     = 32;
            my $gravitar = "//www.gravatar.com/avatar.php?gravatar_id=" .
              md5_hex( lc $mail ) . ";size=" . $size;

            #
            # A comment which was submitted by the blog author might
            # have special theming.
            #
            my $author = 0;
            $author = 1
              if ( $CONFIG{ 'author' } &&
                   ( lc($mail) eq lc( $CONFIG{ 'author' } ) ) );

            #
            # Store the comment
            #
            push( @$results,
                  {  name     => $name,
                     author   => $author,
                     gravitar => $gravitar,
                     link     => $link,
                     mail     => $mail,
                     body     => $body,
                     date     => $date,
                  } );

        }
        else
        {
            $CONFIG{ 'verbose' } &&
              print
              "I didn't like length of \$name ($name), \$mail ($mail) or \$body ($body)\n";
        }
    }

    return ($results);
}



=begin doc

Load a L<HTML::Template> object, either by reference to the filename
(which is unqualified and assumed to be located beneath the given
theme-directory), or via a scalar reference.

Some of the plugins distributed with Chronicle will contain embedded
C<HTML::Template> snippets in their C<DATA> sections.  These include
L<Chronicle::Plugin::Generate::RSS> and L<Chronicle::Plugin::Generate::Sitemap>.

=end doc

=cut

sub load_template
{
    my ( $filename, $scalar ) = (@_);

    #
    #  Ensure we have a theme.
    #
    die "You must specify a theme with --theme"
      unless ( $CONFIG{ 'theme' } );

    #
    #  Ensure things exist.
    #
    die "The theme directory specified with 'theme-dir' doesn't exist"
      unless ( -d $CONFIG{ 'theme-dir' } );

    die
      "The theme '$CONFIG{'theme'}' doesn't exist beneath $CONFIG{'theme-dir'}!"
      unless ( -d $CONFIG{ 'theme-dir' } . "/" . $CONFIG{ 'theme' } );


    #
    #  HTML::Template options
    #
    my %options = ( path => [$CONFIG{ 'theme-dir' } . "/" . $CONFIG{ 'theme' }],
                    die_on_bad_params => 0,
                    loop_context_vars => 1,
                    global_vars       => 1,
                  );


    #
    #  The HTML::Template Instance.
    #
    my $tmpl;

    if ($filename)
    {

        my $path =
          $CONFIG{ 'theme-dir' } . "/" . $CONFIG{ 'theme' } . "/" . $filename;

        #
        #  If the template doesn't exist return undef.
        #
        return unless ( -e $path );

        #
        #  Load the template from the given path.
        #
        $tmpl = HTML::Template->new( open_mode => '<:encoding(UTF-8)',
                                     filename  => $path,
                                     %options,
                                   );
    }
    else
    {

        #
        #  Loading via a string-reference
        #
        $tmpl = HTML::Template->new( scalarref => \$scalar,
                                     %options, );

    }

    #
    #  Legacy options.
    #
    $tmpl->param( blog_title => $CONFIG{ 'blog_title' } )
      if ( $CONFIG{ 'blog_title' } );
    $tmpl->param( blog_subtitle => $CONFIG{ 'blog_subtitle' } )
      if ( $CONFIG{ 'blog_subtitle' } );

    #
    #  Global options, such as build-time, build-date, etc.
    #
    $tmpl->param( \%GLOBAL_TEMPLATE_VARS );

    return ($tmpl);
}



=begin doc

Parse the command-line options.

=end doc

=cut

sub parseCommandLine
{
    my $HELP   = 0;
    my $MANUAL = 0;

    #
    #  Parse options.
    #
    if (
        !GetOptions(

            # Help options
            "help",    \$HELP,
            "manual",  \$MANUAL,
            "verbose", \$CONFIG{ 'verbose' },
            "version", \$CONFIG{ 'version' },

            # theme support
            "theme=s",     \$CONFIG{ 'theme' },
            "theme-dir=s", \$CONFIG{ 'theme-dir' },
            "list-themes", \$CONFIG{ 'list-themes' },

            # paths
            "input=s",    \$CONFIG{ 'input' },
            "output=s",   \$CONFIG{ 'output' },
            "pattern=s",  \$CONFIG{ 'pattern' },
            "comments=s", \$CONFIG{ 'comments' },

            # limits
            "entry-count=s", \$CONFIG{ 'entry-count' },
            "rss-count=s",   \$CONFIG{ 'rss-count' },

            # optional
            "config=s",       \$CONFIG{ 'config' },
            "database=s",     \$CONFIG{ 'database' },
            "author=s",       \$CONFIG{ 'author' },
            "comment-days=s", \$CONFIG{ 'comment-days' },
            "force",          \$CONFIG{ 'force' },

            # plugins
            "list-plugins",      \$CONFIG{ 'list-plugins' },
            "exclude-plugins=s", \$CONFIG{ 'exclude-plugins' },

            # title
            "blog-title=s",    \$CONFIG{ 'blog_title' },
            "blog-subtitle=s", \$CONFIG{ 'blog_subtitle' },

            # prefix
            "url-prefix=s", \$CONFIG{ 'top' },

        ) )
    {
        exit;
    }

    pod2usage(1) if $HELP;
    pod2usage( -verbose => 2 ) if $MANUAL;

    #
    #  Show our version number, and terminate.
    #
    if ( $CONFIG{ 'version' } )
    {
        print "Chronicle $VERSION\n";
        exit(0);
    }

    #
    #  List themes.
    #
    if ( $CONFIG{ 'list-themes' } )
    {

        #
        #  Global themese
        #
        my $global = File::ShareDir::dist_dir('App-Chronicle');

        #
        #  The theme-directories we'll inspect
        #
        my @dirs = ();
        push( @dirs, $global );
        if ( $CONFIG{ 'theme-dir' } && ( $CONFIG{ 'theme-dir' } ne $global ) )
        {
            push( @dirs, $CONFIG{ 'theme-dir' } );
        }

        #
        #  For each global/local directory show the contents.
        #
        foreach my $dir (@dirs)
        {
            print "Themes beneath $dir\n";

            foreach my $ent ( glob( $dir . "/*" ) )
            {
                my $name = File::Basename::basename($ent);
                print "\t" . $name . "\n" if ( -d $ent );
            }
        }
        exit(0);
    }

    #
    #  List plugins
    #
    if ( $CONFIG{ 'list-plugins' } )
    {
        for my $plugin ( Chronicle->plugins_ordered() )
        {
            print $plugin . "\n";

            if ( $CONFIG{ 'verbose' } )
            {
                foreach my $method (
                    sort
                    qw! on_db_create on_db_load on_insert on_initiate on_generate  !
                  )
                {
                    if ( $plugin->can($method) )
                    {
                        print "\t$method\n";
                    }
                }
            }
        }
        exit 0;
    }
}



=begin doc

Return an array of plugins that implement the given method.

This result set will exclude anything that has been deliberately
excluded by the user.

=end doc

=cut

sub get_plugins_for_method
{
    my ($method) = (@_);

    my @plugins = ();

    #
    #  Call any on_initiate plugins we might have loaded.
    #
    for my $plugin ( Chronicle->plugins_ordered() )
    {
        my $skip = 0;

        if ( $CONFIG{ 'exclude-plugins' } )
        {
            foreach my $exclude ( split( /,/, $CONFIG{ 'exclude-plugins' } ) )
            {

                # strip leading and trailing space.
                $exclude =~ s/^\s+//;
                $exclude =~ s/\s+$//;

                # skip empty tags.
                next if ( !length($exclude) );

                if ( $plugin =~ /\Q$exclude\E/i )
                {
                    $CONFIG{ 'verbose' } && print "Skipping plugin: $plugin\n";
                    $skip = 1;
                }
            }
        }

        next if ($skip);
        next unless $plugin->can($method);

        push( @plugins, $plugin );
    }

    return (@plugins);
}


=begin doc

Return an array of entires from a given folder with a given suffix

=end doc

=cut

sub get_post_files
{

    my ( $dir, $suffix ) = @_;

    #  This allows for backward compatility for the '*.txt' for glob'ing
    # blog entries
    $suffix =~ s/\*\.//;

    my @text_files;
    my $tf_finder = sub {
        return if !-f;
        return if !/\.\Q$suffix\E\z/;
        push @text_files, $File::Find::name;
    };
    find( $tf_finder, $dir );
    return @text_files;
}
