diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2d584ed
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+# Ignore vim backup and swap files
+*~
+*.swp
diff --git a/Comment_system/.process_comments.cfg b/Comment_system/.process_comments.cfg
new file mode 100644
index 0000000..b6612fd
--- /dev/null
+++ b/Comment_system/.process_comments.cfg
@@ -0,0 +1,25 @@
+#
+# Main configuration file for 'process_comments' using the local database
+#
+# /home/cendjm/HPR/Comment_system/.process_comments.cfg
+# 2023-02-27 16:42:50
+#
+
+#
+# Settings used in all configuration files
+#
+<>
+
+#
+# Local database
+#
+<>
+
+#
+# Fake CMS authentication
+#
+
+ user = "dummy:dummy"
+
+
+# vim: syntax=cfg:ts=8:sw=4:tw=150:et:ai:
diff --git a/Comment_system/.process_comments_live.cfg b/Comment_system/.process_comments_live.cfg
new file mode 100644
index 0000000..cfb0aa8
--- /dev/null
+++ b/Comment_system/.process_comments_live.cfg
@@ -0,0 +1,25 @@
+#
+# Main configuration file for 'process_comments' using the live database
+#
+# /home/cendjm/HPR/Comment_system/.process_comments_live.cfg
+# 2023-02-27 16:48:08
+#
+
+#
+# Settings used in all configuration files
+#
+<>
+
+#
+# Local database
+#
+<>
+
+#
+# CMS authentication
+#
+
+ <>
+
+
+# vim: syntax=cfg:ts=8:sw=4:tw=150:et:ai:
diff --git a/Comment_system/.process_comments_settings.cfg b/Comment_system/.process_comments_settings.cfg
new file mode 100644
index 0000000..2a8466a
--- /dev/null
+++ b/Comment_system/.process_comments_settings.cfg
@@ -0,0 +1,42 @@
+#
+# Settings for 'process_comments'
+#
+# /home/cendjm/HPR/Comment_system/.process_comments_settings.cfg
+# 2023-02-28 20:37:58
+#
+
+ PROG = process_comments
+
+ #
+ # Defaults
+ #
+ basedir = "$HOME/HPR/Comment_system"
+ configfile = "$basedir/.hpr_db.cfg"
+ logfile = "$basedir/logs/${PROG}.log"
+ template = "$basedir/${PROG}.tpl"
+
+ #
+ # Mail message stash area
+ #
+ maildrop = "$HOME/HPR/CommentDrop"
+ processed = "$maildrop/processed"
+ rejected = "$maildrop/rejected"
+ banned = "$maildrop/banned"
+
+ #
+ # JSON stash area
+ #
+ jsondir = "$basedir/json"
+ jprocessed = "$jsondir/processed"
+ jrejected = "$jsondir/rejected"
+ jbanned = "$jsondir/banned"
+
+ #
+ # How to tell the server the comment's processed
+ #
+ callback_template = \
+ "https://hub.hackerpublicradio.org/cms/comment_process.php?key=%s&action=%s"
+
+
+
+# vim: syntax=cfg:ts=8:sw=4:tw=150:et:ai:
diff --git a/Comment_system/manage_comment_spool b/Comment_system/manage_comment_spool
new file mode 100755
index 0000000..839f542
--- /dev/null
+++ b/Comment_system/manage_comment_spool
@@ -0,0 +1,166 @@
+#!/bin/bash -
+#===============================================================================
+#
+# FILE: manage_comment_spool
+#
+# USAGE: ./manage_comment_spool [subject] [message-id]
+#
+# DESCRIPTION: Deals with comments in the spool area where they are dropped
+# by Thunderbird. This script is also designed to be run out of
+# Thunderbird when it turns on or off the LED on the Blinkt!
+# (using MQTT) and de-duplicates comments if necessary.
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: ---
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.0.3
+# CREATED: 2023-07-14 15:38:33
+# REVISION: 2023-12-24 16:00:05
+#
+#===============================================================================
+
+set -o nounset # Treat unset variables as an error
+
+SCRIPT=${0##*/}
+
+VERSION="0.0.3"
+
+STDOUT="/dev/fd/2"
+
+#=== FUNCTION ================================================================
+# NAME: alert
+# DESCRIPTION: Turn a LED on the Blinkt! host to an RGB colour
+# PARAMETERS: 1 - LED number 0..7
+# 2 - RGB colour as 'R,G,B' values, default '0,0,0'
+# RETURNS: 1 on error, otherwise 0
+#===============================================================================
+function alert () {
+ local LED="${1}"
+ local RGB="${2:-0,0,0}"
+
+ local BHOST="192.168.0.63"
+
+ mosquitto_pub -h $BHOST -t pimoroni/blinkt -m "rgb,$LED,$RGB"
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: _usage
+# DESCRIPTION: Report usage
+# PARAMETERS: None
+# RETURNS: Nothing
+#===============================================================================
+_usage () {
+ cat >$STDOUT <<-endusage
+Usage: ./${SCRIPT} [-h] [-s] [subject] [message-id]
+
+Version: $VERSION
+
+Script to be invoked via Thunderbird to manage and report on the comment spool
+area
+
+Options:
+ -h Print this help
+ -s Silent mode, output less text about actions
+
+Arguments:
+ subject
+ message-id
+These are optional and are only provided when called by Thunderbir
+
+Examples
+ ./${SCRIPT} -h
+
+endusage
+ exit
+}
+
+#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+#
+# Option defaults
+#
+SILENT=0 # not silent by default
+
+#
+# Process options
+#
+while getopts :hs opt
+do
+ case "${opt}" in
+ h) _usage;;
+ s) SILENT=1;;
+ ?) echo "$SCRIPT: Invalid option; aborting"; exit 1;;
+ esac
+done
+shift $((OPTIND - 1))
+
+#
+# Constants
+#
+BASENAME="$HOME/HPR/Comment_system"
+LOGDIR="$BASENAME/logs"
+LOG="$LOGDIR/$SCRIPT.log"
+SPOOLDIR="$HOME/HPR/CommentDrop"
+
+# The LED to light
+LED=1
+
+# Whether we're doing alerts
+ALERTING=1
+
+#
+# We expect to be called with two arguments if called from Thunderbird,
+# otherwise we'll make empty defaults.
+#
+if [[ $# -eq 2 ]]; then
+ subject="$1"
+ message_id="$2"
+else
+ subject=
+ message_id=
+fi
+
+#
+# Check the spool directory
+#
+declare -a EMAIL
+mapfile -t EMAIL < <(find "$SPOOLDIR" -maxdepth 1 -name "*.eml" -printf '%p\n')
+
+#
+# Clear out files which end in '-1.eml' (or any single digit number), and tidy
+# the array as well.
+#
+i=0
+for m in "${EMAIL[@]}"; do
+ if [[ "$m" =~ -[1-9].eml$ ]]; then
+ unset "EMAIL[$i]"
+ rm -f "$m"
+ fi
+ ((i++))
+done
+
+#
+# If we have comments left we turn on the LED, otherwise we turn it off
+#
+comments="${#EMAIL[@]}"
+if [[ $comments -eq 0 ]]; then
+ [ "$SILENT" == 0 ] && echo "Nothing found"
+ [ "$ALERTING" == 1 ] && alert $LED
+ exit
+else
+ [ "$SILENT" == 0 ] && echo "Found $comments $(ngettext comment comments "$comments")"
+ [ "$ALERTING" == 1 ] && alert $LED '0,255,128'
+fi
+
+#
+# Log the call, but only if there were comments. This includes the two
+# arguments passed by the filter, the subject and message-id.
+#
+echo "$SCRIPT $(date +'%F %H:%M:%S') '$$' '$subject' '$message_id'" >> "$LOG"
+
+exit
+
+# vim: syntax=sh:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
diff --git a/Comment_system/process_comments b/Comment_system/process_comments
new file mode 100755
index 0000000..5752303
--- /dev/null
+++ b/Comment_system/process_comments
@@ -0,0 +1,2343 @@
+#!/usr/bin/env perl
+#===============================================================================
+#
+# FILE: process_comments
+#
+# USAGE: ./process_comments [-help] [-docmentation|manpage] [-debug=N]
+# [-[no]dry-run] [-verbose ...] [-[no]verify] [-[no]live]
+# [-[no]json] [-config=FILE]
+#
+# DESCRIPTION: Read new comments received as mail messages (or as JSON files)
+# and perform moderation actions on them before possibly adding
+# them to the HPR database.
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: 2023-12-23 enabled the experimental::try feature and removed
+# the use of 'Try::Tiny'. Revert by removing "use feature 'try'"
+# and 'no warnings "experimental::try"'. Remove the '($e)' from
+# each 'catch' and end the 'catch' braces with a semicolon. Add
+# back 'use Try::Tiny'.
+#
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.3.3
+# CREATED: 2017-09-04 18:06:49
+# REVISION: 2024-01-10 13:02:51
+#
+#===============================================================================
+
+use v5.36; # say and try/catch
+use feature 'try';
+use strict;
+use warnings;
+no warnings "experimental::try";
+use utf8;
+
+use Carp;
+use Getopt::Long;
+use Pod::Usage;
+
+use Config::General;
+use List::Util qw{max};
+use HTML::Parser;
+use HTML::Restrict;
+use HTML::Entities;
+use File::Find::Rule;
+use File::Copy;
+use File::Slurper 'read_text';
+#use IO::All;
+use JSON;
+
+use Mail::Address;
+use Mail::Field;
+use Mail::Internet;
+use MIME::Parser;
+use Encode qw(decode encode);
+
+use DateTime::Format::ISO8601;
+use IO::Prompter;
+use Template;
+use SQL::Abstract;
+use LWP::UserAgent;
+
+#use TryCatch; # Failing 2020-05-31 due to a problem in Devel::Declare
+#use Try::Tiny; # Using in-built experimental try/catch
+use Log::Handler;
+
+use DBI;
+
+use Data::Dumper;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.3.3';
+
+#
+# Script and directory names
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+( my $DIR = $0 ) =~ s|/?[^/]*$||mx;
+$DIR = '.' unless $DIR;
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+#
+# Constants and other declarations
+#
+my $basedir = "$ENV{HOME}/HPR/Comment_system";
+my $configfile = "$basedir/.${PROG}.cfg";
+
+my ( $dbh, $sth1, $h1 );
+
+my ( @ignore, @files );
+my ($comment, %updates, $shortfile);
+
+#
+# The MIME part we're looking for
+#
+my $target_mimetype = 'application/json';
+
+#
+# All of the keys in the JSON we're interested in (when checking and adding to
+# the database)
+#
+my @json_keys = (
+ "eps_id", "comment_timestamp",
+ "comment_author_name", "comment_title",
+ "comment_text",
+);
+
+#
+# Keys we need to clean
+#
+my @cleanable_keys = (
+ "comment_author_name", "comment_title",
+ "comment_text",
+);
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#-------------------------------------------------------------------------------
+# Options and arguments
+#-------------------------------------------------------------------------------
+my $DEF_DEBUG = 0;
+my $DEF_LIVE = 0; # Gets edited on borg
+
+#
+# Process options
+#
+my %options;
+Options( \%options );
+
+#
+# Default help
+#
+pod2usage( -msg => "$PROG version $VERSION\n", -verbose => 0, -exitval => 1 )
+ if ( $options{'help'} );
+
+#
+# Full documentation if requested with -doc
+#
+pod2usage(
+ -msg => "$PROG version $VERSION\n",
+ -verbose => 2,
+ -exitval => 1,
+ -noperldoc => 0,
+) if ( $options{'documentation'} );
+
+#
+# Collect options
+#
+my $DEBUG = ( defined( $options{debug} ) ? $options{debug} : $DEF_DEBUG );
+my $cfgfile
+ = ( defined( $options{config} ) ? $options{config} : $configfile );
+my $live = ( defined( $options{live} ) ? $options{live} : $DEF_LIVE );
+my $verify = ( defined( $options{verify} ) ? $options{verify} : 0 );
+my $dry_run = ( defined( $options{'dry-run'} ) ? $options{'dry-run'} : 0 );
+my $verbose = ( defined( $options{verbose} ) ? $options{verbose} : 0 );
+my $jsonmode = ( defined( $options{json} ) ? $options{json} : 0 );
+
+#
+# Report on the options if requested
+#
+if ($DEBUG > 0) {
+ _debug(1, 'Options');
+ _debug(1,
+ '-config=' . $cfgfile,
+ ($live eq 0 ? '-nolive ' : '-live'),
+ ($verify eq 0 ? '-noverify ' : '-verify'),
+ ($dry_run eq 0 ? '-nodry-run ' : '-dry-run'),
+ '-verbose=[' . $verbose . ']',
+ ($jsonmode eq 0 ? '-nojson ' : '-json'),
+ );
+ _debug(1, '');
+}
+
+#
+# Sanity checks
+#
+die "Unable to find configuration file: $cfgfile\n" unless ( -e $cfgfile );
+
+#-------------------------------------------------------------------------------
+# Configuration file - load data
+#-------------------------------------------------------------------------------
+my $conf = Config::General->new(
+ -ConfigFile => $cfgfile,
+ -InterPolateVars => 1,
+ -InterPolateEnv => 1,
+ -ExtendedAccess => 1,
+);
+my %config = $conf->getall();
+
+_debug($DEBUG > 2, 'Configuration settings:', Dumper(\%config));
+
+my $logfile = $config{settings}->{logfile};
+my $template = $config{settings}->{template};
+
+my $maildrop = $config{settings}->{maildrop};
+my $processed = $config{settings}->{processed};
+my $rejected = $config{settings}->{rejected};
+my $banned = $config{settings}->{banned};
+
+my $jsondir = $config{settings}->{jsondir};
+my $jprocessed = $config{settings}->{jprocessed};
+my $jrejected = $config{settings}->{jrejected};
+my $jbanned = $config{settings}->{jbanned};
+
+my $callback_template = $config{settings}->{callback_template};
+
+my ($cb_user, $cb_password) = split(":",$config{cms}->{user});
+
+#-------------------------------------------------------------------------------
+# Moderation responses and the arguments associated with them (from the
+# configuration data).
+#-------------------------------------------------------------------------------
+#
+# key => [ 3 element array ]
+#
+# - key is the menu element from do_moderation
+# - the array contains:
+# - text to be used in messages
+# - anonymous subroutine which returns the directory into which to place
+# the file; call it with the $jsonmode variable
+# - action keyword to send back to the server
+#
+my %moderation_keys = (
+ 'Approve' => [
+ 'processed',
+ sub { ( $_[0] ? $jprocessed : $processed ) },
+ 'approve'
+ ],
+ 'Ban' => [
+ 'banned',
+ sub { ( $_[0] ? $jbanned : $banned ) },
+ 'block'
+ ],
+ 'Reject' => [
+ 'rejected',
+ sub { ( $_[0] ? $jrejected : $rejected ) },
+ 'delete'
+ ],
+);
+
+#
+# Make a RE from the keys
+#
+my $mkeys = join('|',keys(%moderation_keys));
+my $mkeys_re = qr{^$mkeys$};
+
+#-------------------------------------------------------------------------------
+# Connect to the database
+#-------------------------------------------------------------------------------
+my $dbhost = $config{database}->{host} // '127.0.0.1';
+my $dbport = $config{database}->{port} // 3306;
+my $dbname = $config{database}->{name};
+my $dbuser = $config{database}->{user};
+my $dbpwd = $config{database}->{password};
+$dbh = DBI->connect( "dbi:mysql:host=$dbhost;port=$dbport;database=$dbname",
+ $dbuser, $dbpwd, { AutoCommit => 1 } )
+ or croak $DBI::errstr;
+
+#$dbh = DBI->connect( "DBI:MariaDB:database=$dbname;host=$dbhost;port=$dbport",
+# $dbuser, $dbpwd, { AutoCommit => 1 } )
+# or croak $DBI::errstr;
+
+#
+# Enable client-side UTF8
+#
+$dbh->{mysqlenable_utf8} = 1;
+
+#
+# Set the local timezone to UTC for this connection
+#
+$dbh->do("set time_zone = '+00:00'") or carp $dbh->errstr;
+
+#-------------------------------------------------------------------------------
+# Set up logging keeping the default log layout except for the date
+#-------------------------------------------------------------------------------
+my $log = Log::Handler->new();
+
+$log->add(
+ file => {
+ timeformat => "%F %H:%M:%S (%s)",
+ filename => $logfile,
+ maxlevel => 7,
+ minlevel => 0
+ }
+);
+
+#-------------------------------------------------------------------------------
+# If mode JSON is selected with -json option we'll check the defined 'json'
+# directory, otherwise we'll look for work in the maildrop.
+#-------------------------------------------------------------------------------
+if ($jsonmode) {
+ #
+ # Look for JSON files
+ #
+ @ignore = ( '*~', '.*.swp', 'banned', 'processed', 'rejected' );
+ my $jsonfileRE = qr{(\.(?i:json)|[^.]+)$};
+ #<<<
+ @files = File::Find::Rule
+ ->file()
+ ->name($jsonfileRE)
+ ->not( File::Find::Rule->new->name(@ignore) )
+ ->maxdepth(1)
+ ->in($jsondir);
+ #>>>
+
+ if ( $DEBUG > 1 ) {
+ _debug(1,
+ "Files found in $jsondir",
+ Dumper( \@files ),
+ );
+ }
+
+ #
+ # There may be nothing there
+ #
+ die "No JSON files found; nothing to do\n" unless (@files);
+
+ #
+ # Convert the JSON files into the %updates hash
+ #
+ do_JSON_process( \@files, \%updates, $log, $dry_run, $verbose );
+
+}
+else {
+ #
+ # Look for mail files
+ #
+ @ignore = ( '*~', '.*.swp', 'banned', 'processed', 'rejected' );
+
+ my $mailfileRE = qr{(\.(?i:eml)|[^.]+)$};
+ #<<<
+ @files = File::Find::Rule
+ ->file()
+ ->name($mailfileRE)
+ ->not( File::Find::Rule->new->name(@ignore) )
+ ->maxdepth(1)
+ ->in($maildrop);
+ #>>>
+
+ if ( $DEBUG > 1 ) {
+ _debug(1,
+ "Files found in $maildrop",
+ Dumper( \@files ),
+ );
+ }
+
+ #
+ # There may be nothing there
+ #
+ unless (@files) {
+ warn "No mail found; nothing to do\n";
+ exit;
+ }
+
+ #
+ # Convert the mail messages into the %updates hash
+ #
+ do_mail_process( \@files, \%updates, $log, $dry_run, $verbose );
+
+}
+
+#-------------------------------------------------------------------------------
+# Check that comments aren't already in the database
+#-------------------------------------------------------------------------------
+do_database_check( $dbh, \%updates, $log, $verify, $dry_run );
+
+#-------------------------------------------------------------------------------
+# Perform the moderation pass
+#-------------------------------------------------------------------------------
+do_moderation( $dbh, \%updates, $template, $log, $verify, $dry_run );
+
+#
+# Dump the data structure if requested
+#
+_debug($DEBUG > 1, '%updates: ' . Dumper(\%updates));
+
+#-------------------------------------------------------------------------------
+# For the comments which are valid and approved, add them to the database
+#-------------------------------------------------------------------------------
+print '-' x 80, "\n" if $verbose > 0;
+do_update( $dbh, \%updates, $log, $dry_run, $verbose );
+
+#-------------------------------------------------------------------------------
+# Walk the captured data reporting what happened (would happen) moving the
+# files to the appropriate places and telling the server to do things to the
+# originals (if appropriate)
+#-------------------------------------------------------------------------------
+foreach my $file ( sort_by_timestamp( \%updates ) ) {
+ #
+ # Deal with each file depending on the selected action (from the set
+ # 'Approve', 'Ban' and 'Reject')
+ #
+ if ( $updates{$file}->{action} =~ $mkeys_re ) {
+ #
+ # Get the elements we need for the function arguments below.
+ # NOTE: Apologies! This is obscure. The action key will be 'Approve',
+ # 'Ban' or 'Reject', and the lookup will return the name of the
+ # destination (for messages), an anonymous subroutine reference which determines
+ # the directory, and a keyword to send to the web server to dispose of
+ # the comment. The subref must be called with $jsonmode as the
+ # argument to get the destination which is different depending on
+ # whether we're in mail or JSON mode. This really needs to be made
+ # simpler and easier to understand when this code is revisited!
+ #
+ my ( $name, $destsub, $serverac )
+ = ( @{ $moderation_keys{ $updates{$file}->{action} } } );
+ my $dest = &$destsub($jsonmode);
+
+ #
+ # Print moderation settings
+ #
+ _debug($DEBUG > 1,
+ '$name: ' . $name,
+ '$dest: ' . $dest,
+ '$serverac: ' . $serverac,
+ );
+
+ #
+ # Report what we're doing with the file and do it if appropriate
+ #
+ report_action( $updates{$file}, $name, $dest, $log,
+ $dry_run, $verbose );
+
+ #
+ # Tell the server what to do, but only if this is the live instance
+ # and we're not in dry-run mode
+ #
+ if ($live) {
+ unless (
+ call_back(
+ $callback_template, $updates{$file}->{JSON}->{key},
+ $serverac, $cb_user, $cb_password, $log, $dry_run, $verbose
+ )
+ )
+ {
+ print "Server update failed\n" if $verbose > 0;
+ }
+ }
+ else {
+ unless ($dry_run) {
+ print "Not live; no server action taken\n" if $verbose > 0;
+ }
+ }
+ }
+ elsif ( $updates{$file}->{action} eq 'Ignore' ) {
+ #
+ # The action here is 'Ignore' which means just leave it alone. Since
+ # only the moderator can choose this, or the failure to find the
+ # episode in the database, we know it's not a broken mail message or
+ # bad JSON.
+ #
+ if ( $verbose > 0 ) {
+ print "File '$file' ignored\n";
+ print_errors( $updates{$file}->{errors} );
+ }
+ }
+ elsif ( $updates{$file}->{skip} > 0 ) {
+ #
+ # The comment was found to be already in the database so we are
+ # skipping it. We want to report this and move the file to the
+ # 'processed' directory.
+ #
+ $shortfile = $updates{$file}->{shortfile};
+
+ unless ($dry_run) {
+ if ( $verbose > 0 ) {
+ print "Moving '$shortfile' to 'processed'\n";
+ print "Reason: comment is already in the database\n";
+ print_errors( $updates{$file}->{errors} );
+ }
+ $log->info("Moving $file to 'processed'");
+ unless ( moveFile( $file, ($jsonmode ? $jprocessed : $processed) ) ) {
+ warn "Unable to move $file\n";
+ $log->error("Failed to move $file");
+ }
+ }
+ else {
+ if ( $verbose > 0 ) {
+ print "Would move '$shortfile' to 'processed' (dry run)\n";
+ print "Reason: comment is already in the database\n";
+ print_errors( $updates{$file}->{errors} );
+ }
+ }
+ }
+ else {
+ #
+ # We aborted this one early in the processing and validation, so it's
+ # really messed up. We're going to reject it outright with error
+ # messages. We can't tell the server because we don't have the
+ # key to do it.
+ #
+ $shortfile = $updates{$file}->{shortfile};
+
+ unless ($dry_run) {
+ if ( $verbose > 0 ) {
+ print "Moving $shortfile to 'rejected'\n";
+ print "Reason: failed validation\n";
+ print_errors( $updates{$file}->{errors} );
+ }
+ $log->info("Moving $file to 'rejected'");
+ unless ( moveFile( $file, ($jsonmode ? $jrejected : $rejected) ) ) {
+ warn "Unable to move $file\n";
+ $log->error("Failed to move $file");
+ }
+ }
+ else {
+ if ( $verbose > 0 ) {
+ print "Would move $shortfile to 'rejected' (dry run)\n";
+ print "Reason: failed validation\n";
+ print_errors( $updates{$file}->{errors} );
+ }
+ }
+ }
+
+ print '.' x 80, "\n" if $verbose > 0;
+}
+
+exit;
+
+#=== FUNCTION ================================================================
+# NAME: do_JSON_process
+# PURPOSE: Process any pending JSON files found in a directory search
+# PARAMETERS: $files arrayref containing the JSON files found
+# $updates hashref to contain the details collected from
+# the JSON files
+# $log log handle
+# $dry_run Boolean flagging dry-run mode
+# $verbose Integer level of verbosity
+# RETURNS: Nothing
+# DESCRIPTION: The files containing JSON comments have been found earlier and
+# passed to this function. They are processed in sorted order.
+# The JSON is decoded into a hash and is validated, and the
+# elements cleaned up. The JSON is stored for later in the
+# $updates structure. If all tests and checks are passed the
+# entry for this file in the $updates structure is marked as
+# valid. If anything fails the entry is marked as invalid for
+# the next stages of processing.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub do_JSON_process {
+ my ( $files, $updates, $log, $dry_run, $verbose ) = @_;
+
+ my ( $shortfile, $dt );
+ my $json = JSON->new;
+
+ #
+ # Examine the files we found
+ #
+ foreach my $file ( sort(@$files) ) {
+ print ">> $file\n" if $verbose > 0;
+ $log->notice("Processing $file") unless $dry_run;
+
+ #
+ # Initialise for this file, marking it as invalid by default.
+ #
+ $updates->{$file} = {};
+ $updates->{$file}->{valid} = 0;
+ $updates->{$file}->{moderated} = 0;
+ $updates->{$file}->{errors} = [];
+ $updates->{$file}->{action} = '';
+ $updates->{$file}->{file} = $file; # For convenience
+ $updates->{$file}->{shortfile} = $shortfile = abbreviate_filename($file);
+
+ #
+ # Open the nominated file and load its contents into a scalar. Because
+ # 'read_text' does its own internal error handling we need try/catch to
+ # intercept it and provide our own.
+ #
+ my $json_text;
+ try {
+ $json_text = read_text($file);
+ }
+
+ catch ($e){
+ warn "Failed to read JSON file $file\n";
+ next;
+ }
+
+ #
+ # Decode and process the JSON, returning FALSE if it fails so we get
+ # a log entry (unless it's dry run mode)
+ #
+ unless (
+ convert_JSON(
+ $json, $json_text, $updates, $file,
+ $log, $dry_run, $verbose
+ )
+ )
+ {
+ $log->warning("JSON decode failure; aborting '$shortfile'")
+ unless $dry_run;
+ }
+
+ }
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: do_mail_process
+# PURPOSE: Process any pending email found in a directory search
+# PARAMETERS: $files arrayref containing the mail files found
+# $updates hashref to contain the details collected from
+# the mail files
+# $log log handle
+# $dry_run Boolean flagging dry-run mode
+# $verbose Integer level of verbosity
+# RETURNS: Nothing
+# DESCRIPTION: The files containing email have been found earlier and passed
+# to this function. They are processed in sorted order. Each
+# file is parsed as a mail file and the various destination
+# addresses collected. (TODO Check these against permitted
+# addresses). Each message is expected to be in MIME format with
+# an attachment, which is extracted. The target mimetype
+# (application/json) is checked for (TODO This is a global and
+# shouldn't be). The JSON it contains is decoded into a hash and
+# is validated, and the elements cleaned up. The JSON is stored
+# for later in the $updates structure. If all tests and checks
+# are passed the entry for this file in the $updates structure
+# is marked as valid. If anything fails the entry is marked as
+# invalid for the next stages of processing.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub do_mail_process {
+ my ( $files, $updates, $log, $dry_run, $verbose ) = @_;
+
+ my ( $shortfile, $dt );
+
+ #
+ # Prepare for parsing mail and JSON
+ #
+ my $parser = MIME::Parser->new;
+ $parser->output_under("/tmp");
+
+ my $json = JSON->new;
+
+ #
+ # Examine the files we found
+ #
+ foreach my $file ( sort(@$files) ) {
+ print ">> $file\n" if $verbose > 0;
+ $log->notice("Processing $file") unless $dry_run;
+
+ #
+ # Initialise for this file, marking it as invalid by default.
+ #
+ $updates->{$file} = {};
+ $updates->{$file}->{valid} = 0;
+ $updates->{$file}->{moderated} = 0;
+ $updates->{$file}->{errors} = [];
+ $updates->{$file}->{action} = '';
+ $updates->{$file}->{file} = $file; # For convenience
+ $updates->{$file}->{shortfile} = $shortfile = abbreviate_filename($file);
+
+ #
+ # Open the current file and load its contents into a Mail::Internet object
+ # TODO: Consider using IO::All
+ #
+ open( my $mfh, '<', $file )
+ or die "Failed to open input file '$file' : $!\n";
+
+ my $mi_obj = Mail::Internet->new($mfh);
+
+ close($mfh)
+ or warn "Failed to close input file '$file' : $!\n";
+
+ #
+ # This should be to comments@hackerpublicradio.org in one of these headers
+ #
+ my @addresses = (
+ Mail::Address->parse( $mi_obj->head->get('Resent-to') ),
+ Mail::Address->parse( $mi_obj->head->get('Resent-cc') )
+ );
+ if ( !@addresses ) {
+ @addresses = (
+ Mail::Address->parse( $mi_obj->head->get('To') ),
+ Mail::Address->parse( $mi_obj->head->get('Cc') )
+ );
+ }
+
+ #
+ # Report the headers we're interested in if requested
+ #
+ if ($verbose > 1) {
+ foreach my $addr (@addresses) {
+ my $dest = lc( $addr->address );
+ print "To: $dest\n";
+ }
+ print '~ ' x 40,"\n";
+ }
+
+ #
+ # TODO Check the message is actually for us
+ #
+
+ my ( $ct, $cte, @body, $body, $results );
+
+ #
+ # Parse the message in the assumption it's MIME. Report any errors and
+ # warnings.
+ #
+ my $entity = $parser->parse_data( $mi_obj->as_string() );
+ unless ($entity) {
+ $results = $parser->results;
+ foreach my $pmsg ( $results->msgs ) {
+ print STDERR "$pmsg\n";
+ }
+ }
+
+ #
+ # If we got multiple MIME parts then look for the first 'application/json'
+ # element and save it as the body we'll work on.
+ #
+ if ( scalar( $entity->parts ) > 0 ) {
+ #
+ # MIME message
+ #
+ if ( $DEBUG > 1 ) {
+ _debug(1, 'Message is MIME with multiple parts');
+ foreach my $ent ( $entity->parts ) {
+ _debug(1, "Type: " . $ent->mime_type);
+ }
+ }
+
+ #
+ # Get the first MIME part of the type we want and turn it into an
+ # array.
+ # TODO: Might it be better to make a string here since later we simply
+ # convert the array to a string again.
+ #
+ foreach my $ent ( $entity->parts ) {
+ if ( $ent->mime_type eq $target_mimetype ) {
+ $ct = $ent->mime_type;
+ #
+ # FIXME: Using 'bodyhandle' is supposed to decode the entity
+ # body, but this is untested. Also, we have to use 'as_string'
+ # then split on "\n" and stick the newlines back on the array
+ # elements, which seems a bit weird. Have to monitor this to
+ # see how it behaves.
+ #
+ #@body = @{ $ent->body() };
+ @body = map {"$_\n"}
+ split( "\n", $ent->bodyhandle->as_string );
+ _debug($DEBUG > 1,
+ 'Length of body = ' . scalar(@body) . ' lines'
+ );
+
+ #
+ # If we got a non-empty body then exit the loop
+ #
+ last if @body;
+ }
+ }
+
+ #
+ # We found no suitable part so there's nothing to process here. We'll
+ # let the later phases detect this though
+ #
+ unless (@body) {
+ saveError( "MIME message has no valid '$target_mimetype' element",
+ $updates->{$file}->{errors} , $log, $dry_run, $verbose );
+ next;
+ }
+ }
+ else {
+ #
+ # The message has no MIME parts
+ #
+ saveError( "Message has no attachment",
+ $updates->{$file}->{errors}, $log, $dry_run, $verbose );
+ next;
+ }
+
+ #
+ # Display the body unless we're not very verbose
+ #
+ $body = join( "", @body );
+ print "$body\n" if $verbose > 2;
+
+ #
+ # Decode and process the JSON, returning FALSE if it fails so we get
+ # a log entry (unless it's dry run mode)
+ #
+ unless (
+ convert_JSON(
+ $json, $body, $updates, $file,
+ $log, $dry_run, $verbose
+ )
+ )
+ {
+ $log->warning("JSON decode failure; aborting '$shortfile'")
+ unless $dry_run;
+ }
+
+ }
+ continue {
+ print '-' x 80, "\n" if $verbose > 0;
+ }
+
+ return;
+}
+
+#=== FUNCTION ================================================================
+# NAME: do_database_check
+# PURPOSE: Look to see if the collected comments are already in the
+# database
+# PARAMETERS: $dbh database handle
+# $updates structure (hashref) holding collected
+# information about the files and comments in
+# them
+# $log log handle
+# $verify Boolean flagging the need to verify
+# TODO: Is this right?
+# $dry_run Boolean flagging dry-run mode
+# RETURNS: Nothing
+# DESCRIPTION: Looks for each comment in %$updates in the database. The
+# search criteria are the comment timestamp (in database format)
+# and the comment author name. The timestamp is expected to have
+# been converted from full ISO format (2024-01-10T11:16:11Z) to
+# the format MySQL likes (2024-01-10 11:16:11). the original
+# date is in the element 'orig_timestamp'. If there is a match
+# in the database then the comment is to be skipped, and we mark
+# the element appropriately and display and log a message. If no
+# match we can proceed without skipping;
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub do_database_check {
+ my ( $dbh, $updates, $log, $verify, $dry_run ) = @_;
+
+ my ( $sth1, $h1, $shortfile, $message );
+
+ #
+ # Look for a comment with details matching an incoming one. If found
+ # someone else has approved it so we need to skip it.
+ #
+ $sth1 = $dbh->prepare(
+ q{SELECT id FROM comments
+ WHERE comment_timestamp = ?
+ AND comment_author_name = ?}
+ ) or die $DBI::errstr;
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+
+ foreach my $file ( sort_by_timestamp($updates) ) {
+ #
+ # Make a shortcut to the JSON part, and collect the short form of the
+ # filename.
+ #
+ my $ptr = $updates->{$file}->{JSON};
+ #my $errors = $updates->{$file}->{errors};
+ $shortfile = $updates{$file}->{shortfile};
+
+ #
+ # Look for the comment in the database
+ #
+ $sth1->execute( $ptr->{comment_timestamp},
+ $ptr->{comment_author_name} );
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+
+ #
+ # Did we find it?
+ #
+ if ( $h1 = $sth1->fetchrow_hashref() ) {
+ #
+ # Found in the database. Mark this comment to be skipped and save
+ # an error report
+ #
+ #$updates->{$file}->{valid} = 0;
+ $updates->{$file}->{skip} = 1;
+
+ $message = "Comment in file '$shortfile' is already in the database";
+ print "$message\n" if $verbose > 0;
+ $log->warn($message) unless $dry_run;
+ }
+ else {
+ #
+ # Not found
+ #
+ $updates->{$file}->{skip} = 0;
+ }
+
+ }
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: do_moderation
+# PURPOSE: Moderate all the valid comments in the 'updates' structure
+# PARAMETERS: $dbh database handle
+# $updates structure (hashref) holding collected
+# information about the files and comments in
+# them
+# $tt_template path to the template we're using
+# $log log handle
+# $verify Boolean flagging the need to verify
+# $dry_run Boolean flagging dry-run mode
+# RETURNS: Nothing
+# DESCRIPTION: Performs the moderation pass on the accumulated comments in
+# the $updates structure. This is a hash (referenced by
+# $updates) containing the filename as the key. Each of these
+# lead to a hashref containing the JSON data (in a hashref), an
+# arrayref of error messages and various flags denoting whether
+# the comment is valid, has been moderated and what action the
+# moderator took.
+# The flags and error messages are manipulated here depending on
+# what choice is made during the moderation process.
+# The contents of the JSON hash (or a subset thereof) and the
+# data about the show returned from a database query are
+# displayed for the moderator using a template, and a menu of
+# 'Approve', 'Ban', 'Reject', Ignore' is presented.
+# On exit the structure referenced by $updates has been modified
+# to signal what should be done with each of the comments.
+# THROWS: No exceptions
+# COMMENTS: TODO: References globals and should not.
+# SEE ALSO: N/A
+#===============================================================================
+sub do_moderation {
+ my ( $dbh, $updates, $tt_template, $log, $verify, $dry_run ) = @_;
+
+ my ( $sth1, $h1, $cdt, $age );
+ my $agelimit = 60 * 60 * 24;
+
+ #
+ # We'll get details of the show each comment is associated with using this
+ # query. It will be passed to the TT² template.
+ #
+ $sth1 = $dbh->prepare(
+ q{SELECT e.id, e.date, e.title, h.host FROM eps e
+ JOIN hosts h ON e.hostid = h.hostid
+ WHERE e.id = ?}
+ ) or die $DBI::errstr;
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+
+ #
+ # We'll display each comment with a TT² template
+ #
+ my $tt = Template->new(
+ { ABSOLUTE => 1,
+ ENCODING => 'utf8',
+ INCLUDE_PATH => $basedir,
+ }
+ );
+ my $vars = {};
+ my $choice;
+
+ #
+ # Process the valid comments, and not marked as 'skip'
+ #
+ foreach my $file ( sort_by_timestamp($updates) ) {
+ if ( $updates->{$file}->{valid} > 0 && $updates->{$file}->{skip} == 0 ) {
+ #
+ # Make shortcuts
+ #
+ my $ptr = $updates->{$file}->{JSON};
+ my $errors = $updates->{$file}->{errors};
+
+ #
+ # Check the age of the comment if we're verifying, and ignore if
+ # less than the age limit. The original timestamp should have been
+ # reserved even though we had to reformat for MySQL/MariaDB.
+ #
+ if ($verify) {
+ $cdt = DateTime::Format::ISO8601->parse_datetime(
+ $ptr->{orig_timestamp}
+ );
+ $age = DateTime->now->epoch - $cdt->epoch;
+ if ($age < $agelimit) {
+ print "Choice 'Ignore' auto-selected due to comment age\n";
+ $updates->{$file}->{action} = 'Ignore';
+ $log->info("Ignored comment in $file")
+ unless $dry_run;
+ $updates->{$file}->{moderated} = 0;
+ next;
+ }
+ }
+
+ #
+ # Get the show details from the database
+ #
+ $sth1->execute( $ptr->{eps_id} );
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+
+ #
+ # Do we know this episode?
+ # NOTE: If the database we're using is the clone this might fail
+ # because it's out of step with the live one
+ #
+ if ( $h1 = $sth1->fetchrow_hashref() ) {
+ #
+ # Populate the variables for the template
+ #
+ $vars->{file} = $file;
+ $vars->{db} = $h1;
+ $vars->{comment} = $ptr;
+
+ #
+ # Call the template and print the result
+ #
+ my $document;
+ $tt->process( $tt_template, $vars, \$document,
+ { binmode => ':utf8' } )
+ || die $tt->error(), "\n";
+ print $document;
+
+ #
+ # Prompt for moderation. Note: these prompts are used to flag
+ # the choice in the %updates hash, so don't change them unless
+ # you also change them elsewhere.
+ #
+ $choice = prompt(
+ 'Choose...',
+ -menu => [ 'Approve', 'Ban', 'Reject', 'Ignore' ],
+ '>'
+ );
+ if ($choice) {
+ print "Choice '$choice' selected\n";
+ if ( $choice eq 'Approve' ) {
+ $updates->{$file}->{action} = $choice;
+ $log->info("Approved comment in $file")
+ unless $dry_run;
+ $updates->{$file}->{moderated} = 1;
+ }
+ elsif ( $choice eq 'Ban' ) {
+ $updates->{$file}->{action} = $choice;
+ saveError(
+ "Comment in '$file' banned by the moderator",
+ $errors,
+ $log, $dry_run, 0
+ );
+ $updates->{$file}->{valid} = 0;
+ $updates->{$file}->{moderated} = 1;
+ }
+ elsif ( $choice eq 'Reject' ) {
+ $updates->{$file}->{action} = $choice;
+ saveError(
+ "Comment in '$file' rejected by the moderator",
+ $errors,
+ $log, $dry_run, 0
+ );
+ $updates->{$file}->{valid} = 0;
+ $updates->{$file}->{moderated} = 1;
+ }
+ elsif ( $choice eq 'Ignore' ) {
+ $updates->{$file}->{action} = $choice;
+ $log->info("Ignored comment in $file")
+ unless $dry_run;
+ $updates->{$file}->{moderated} = 0;
+ }
+ }
+ else {
+ print "\n";
+ undef $choice; # Seem to need this after aborting 'prompt'
+ print "Ignored this comment\n";
+ $log->info("Ignored comment in $file (default action)")
+ unless $dry_run;
+ $updates->{$file}->{moderated} = 0;
+ }
+ #print "\n";
+ }
+ else {
+ #
+ # We couldn't find the episode in the database
+ #
+ printf "Episode %s is not known\n", $ptr->{eps_id};
+ $updates->{$file}->{action} = 'Ignore';
+ saveError(
+ "Episode referenced in '$file' is not known",
+ $errors,
+ $log, $dry_run, 0
+ );
+ $updates->{$file}->{moderated} = 0;
+ }
+ }
+ }
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: do_update
+# PURPOSE: Add valid, approved comments to the database (or pretend to do
+# so if in dry-run mode)
+# PARAMETERS: $dbh database handle
+# $updates structure (hashref) holding collected
+# information about the files and comments in
+# them
+# $log log handle
+# $dry_run Boolean flagging dry-run mode
+# $verbose Integer level of verbosity
+# RETURNS: Nothing
+# DESCRIPTION: Performs a database update for all valid approved comments in
+# the structure referenced by $updates unless in dry-run mode
+# where what would be done will be reported.
+# THROWS: No exceptions
+# COMMENTS: TODO: References globals and should not.
+# SEE ALSO: N/A
+#===============================================================================
+sub do_update {
+ my ( $dbh, $updates, $log, $dry_run, $verbose ) = @_;
+
+ my $sql = SQL::Abstract->new;
+ my ( $stmt, @bind, $show, $count );
+
+ $count = 0;
+
+ foreach my $file ( sort_by_timestamp($updates) ) {
+ if ( $updates->{$file}->{valid} > 0
+ && $updates->{$file}->{action} eq 'Approve' )
+ {
+ $count++;
+
+ $show = $updates->{$file}->{JSON}->{eps_id};
+
+ my $shortfile = $updates{$file}->{shortfile};
+
+ #
+ # Build the statement
+ # NOTE: The JSON keys we wish to use match the column names in the
+ # 'comments' table. The second argument to 'insert' needs to be
+ # a hashref, so we use @json_keys to list the keys we need, with
+ # the whole expression returning an anonymous hash (hashref)
+ #
+ ( $stmt, @bind )
+ = $sql->insert( 'comments',
+ { %{ $updates->{$file}->{JSON} }{@json_keys} } );
+
+ #
+ # Display the statement we constructed if requested
+ #
+ _debug($DEBUG > 1,
+ "$stmt\n'",
+ join( "','", @bind )
+ );
+
+ #
+ # Take action or report potential changes
+ #
+ unless ($dry_run) {
+ #
+ # Apply the SQL
+ #
+ $dbh->do( $stmt, undef, @bind );
+ if ( $dbh->err ) {
+ #
+ # Something broke during the database update. Warn about
+ # it, save the error and make the action 'Ignore' so the
+ # file is kept for further investigation (or to be
+ # edited). Marking the file as invalid may be overkill.
+ #
+ warn $dbh->errstr;
+ saveError(
+ $dbh->errstr,
+ $updates{$file}->{errors},
+ $log, $dry_run, $verbose
+ );
+# $updates{$file}->{valid} = 0;
+ $updates->{$file}->{action} = 'Ignore';
+ }
+ else {
+ $log->info("$file: inserted comment for show $show");
+
+ if ( $verbose > 0 ) {
+ print "Updated show $show from file $shortfile\n";
+ }
+ }
+ }
+ else {
+ print
+ "Would have added comment for show $show from file $shortfile\n"
+ if $verbose > 0;
+ }
+
+ }
+ }
+
+ if ($verbose > 0 && $count > 0) {
+ print "\n", '-' x 80, "\n";
+ }
+}
+
+#=== FUNCTION ================================================================
+# NAME: convert_JSON
+# PURPOSE: Converts the JSON from a file or an email into an internal
+# data structure
+# PARAMETERS: $json JSON object
+# $jtext JSON in text format
+# $updates hashref to contain the details collected from
+# the JSON and elsewhere
+# $file the file we're processing (hash key)
+# $log log handle
+# $dry_run Boolean flagging dry-run mode
+# $verbose Integer level of verbosity
+# RETURNS:
+# DESCRIPTION:
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub convert_JSON {
+ my ( $json, $jtext, $updates, $file, $log, $dry_run, $verbose ) = @_;
+
+ my ($comment, $dt, $shortfile);
+
+ $shortfile = $updates{$file}->{shortfile};
+
+ #
+ # Decode the JSON into a hashref
+ #
+ try {
+ $comment = $json->decode($jtext);
+ }
+
+ #
+ # If the JSON decode failed we have a problem
+ #
+ catch ($e){
+ saveError(
+ "JSON in file '$shortfile' failed to decode",
+ $updates->{$file}->{errors},
+ $log, $dry_run, $verbose
+ );
+ return 0;
+ }
+
+ #
+ # Validate the comment fields: do we have all we expected?
+ #
+ unless (validate_comment($comment)) {
+ saveError(
+ "JSON in file '$shortfile' failed to validate",
+ $updates->{$file}->{errors},
+ $log, $dry_run, $verbose
+ );
+ return 0;
+ }
+
+ #
+ # Perform some cleaning
+ #
+ foreach my $key (@cleanable_keys) {
+ $comment->{$key} = clean_comment_field( $comment->{$key} );
+ }
+
+ #
+ # ISO 8601 timestamps cause MySQL problems (or have started to) so we
+ # parse them and convert to an acceptable format. We keep the original
+ # format in $verify is set and we have to check its age.
+ #
+ try {
+ $dt = DateTime::Format::ISO8601->parse_datetime(
+ $comment->{comment_timestamp} );
+ }
+ catch ($e){
+ die "Failed to parse comment timestamp\n";
+ }
+ $comment->{orig_timestamp} = $comment->{comment_timestamp};
+ $comment->{comment_timestamp} = $dt->ymd . ' ' . $dt->hms;
+
+ #
+ # Stash the JSON for later
+ #
+ $updates->{$file}->{JSON} = $comment;
+
+ #
+ # This file is valid we think
+ #
+ $updates->{$file}->{valid} = 1;
+ $log->info("$file seems valid") unless $dry_run;
+
+ return 1;
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: report_action
+# PURPOSE: Does all of the necessary reporting as the email or JSON files
+# are being acted upon
+# PARAMETERS: $struct hashref holding collected comment data for
+# a given mail file
+# $destname name of destination
+# $dest destination directory
+# $log log handle
+# $dry_run Boolean flagging dry-run mode
+# $verbose Integer level of verbosity
+# RETURNS: Nothing
+# DESCRIPTION: In dry run mode reports what would have been done along with
+# any errors, but only in verbose mode. Otherwise reports the
+# action (in verbose mode) and logs it too. It also moves the
+# mail file to the requested directory.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub report_action {
+ my ( $struct, $destname, $dest, $log, $dry_run, $verbose ) = @_;
+
+ #
+ # Get values with a hashref slice
+ #
+ my ( $file, $shortfile ) = @$struct{ 'file', 'shortfile' };
+
+ unless ($dry_run) {
+ if ($verbose > 0) {
+ print "Moving '$shortfile' to '$destname'\n";
+ print_errors($struct->{$file}->{errors});
+ }
+ $log->info("Moving '$file' to '$destname'");
+ unless ( moveFile( $file, $dest ) ) {
+ warn "Unable to move $shortfile\n";
+ $log->error("Failed to move $shortfile");
+ }
+ }
+ else {
+ if ($verbose > 0) {
+ print "Would move '$shortfile' to '$destname' (dry run)\n";
+ print_errors($struct->{$file}->{errors});
+ }
+ }
+
+ return;
+}
+
+#=== FUNCTION ================================================================
+# NAME: call_back
+# PURPOSE: Invokes a PHP interface on the HPR server through which
+# a comment can be marked as fully processed
+# PARAMETERS: $format sprintf template for building the URL
+# $key the key from the JSON
+# $action the action to be taken
+# $user the username from the configuration
+# $pass the password from the configuration
+# $log log handle
+# $dry_run Boolean flagging dry-run mode
+# $verbose Integer level of verbosity
+# RETURNS: True (1) if the call performed as it should, otherwise false
+# (0)
+# DESCRIPTION: Checks that the correct parameters have been supplied, then
+# builds the URL we're going to use from the format string, key
+# and action. If in dry-run mode this URL is reported and
+# nothing more. Otherwise the URL is queried with an HTTP 'GET'.
+# If the response shows success then the action is considered to
+# have been taken. The returned message is displayed (in verbose
+# mode) and success is logged. If the 'GET' failed then
+# a warning message is generated along with a warning log entry.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub call_back {
+ my ( $format, $key, $action, $user, $pass, $log, $dry_run, $verbose ) = @_;
+
+ my ( $ua, $url, $response, $code, $message, $content );
+
+ #
+ # Check calling parameters
+ #
+ unless ($key) {
+ warn "Invalid call to 'call_back' subroutine; missing key\n";
+ return 0;
+ }
+
+ unless ( $action =~ /^(approve|block|delete)$/ ) {
+ warn "Invalid call to 'call_back' subroutine; invalid action\n";
+ return 0;
+ }
+
+ #
+ # Build the URL
+ #
+ $url = sprintf( $format, $key, $action );
+
+ #
+ # Perform the call to clean up the JSON files, etc, or just say what would
+ # have been done
+ #
+ unless ($dry_run) {
+ print "Making the server call:\nGET $url\n" if $verbose > 0;
+
+ $ua = LWP::UserAgent->new;
+ $ua->timeout(180); # Module default is 180 sec = 3 mins
+ $ua->credentials("hub.hackerpublicradio.org:443", "cms", $user, $pass);
+
+ $response = $ua->get($url);
+ if ( $response->is_success ) {
+ #
+ # Server call succeeded
+ #
+ $code = $response->code;
+ $message = $response->message;
+ $content = $response->decoded_content;
+ if ($verbose > 0) {
+ print "Success: $code/$message\n";
+ print "Content: $content\n" if $content;
+ }
+ $log->info("Successful server message $key/$action: " .
+ $response->status_line);
+ return 1;
+ }
+ else {
+ #
+ # Server call failed
+ #
+ warn $response->status_line . "\n";
+ $log->error("Failed server message $key/$action: " .
+ $response->status_line);
+ # Try to log as much as possible
+ $log->error( $response->decoded_content )
+ if $response->decoded_content;
+ return 0;
+ }
+ }
+ else {
+ #
+ # Always a success in dry-run mode
+ #
+ print "Would have made the server call:\nGET $url\n" if $verbose > 0;
+ return 1;
+ }
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: print_errors
+# PURPOSE: Prints the contents of the error array if there's anything in
+# it (i.e. not empty)
+# PARAMETERS: $errors arrayref possibly containing errors
+# RETURNS: Nothing
+# DESCRIPTION: Simplifies printing in the case when there's nothing to print
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub print_errors {
+ my ($errors) = @_;
+
+ return unless defined($errors);
+
+ print "Errors: ", join( "\n", @{$errors} ), "\n"
+ if @{$errors};
+}
+
+#=== FUNCTION ================================================================
+# NAME: saveError
+# PURPOSE: Saves an error message in an error structure and logs it if in
+# live mode
+# PARAMETERS: $message error message text
+# $struct structure (arrayref) to hold the text
+# $log log handle
+# $dry_run Boolean flagging dry-run mode
+# $verbose Integer level of verbosity
+# RETURNS: Nothing
+# DESCRIPTION: Simplifies the saving of an error message and logging it
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub saveError {
+ my ( $message, $struct, $log, $dry_run, $verbose ) = @_;
+
+ $log->warn($message) unless $dry_run;
+ push( @{$struct}, $message );
+ print "$message\n" if $verbose > 0;
+}
+
+#=== FUNCTION ================================================================
+# NAME: validate_comment
+# PURPOSE: Checks that all of the required fields are present in the
+# JSON comment
+# PARAMETERS: $comment - a pointer to the JSON hash
+# RETURNS: True (1) if valid, otherwise false (0)
+# DESCRIPTION:
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub validate_comment {
+ my ($comment) = @_;
+
+ foreach my $key (@json_keys) {
+ return 0 unless defined($comment->{$key});
+ }
+
+ return 1;
+}
+
+#=== FUNCTION ================================================================
+# NAME: count_tags
+# PURPOSE: Counts the number of start tags in what might be HTML
+# PARAMETERS: $string String to examine
+# RETURNS: An integer count of the number of start tags found
+# DESCRIPTION: Uses HTML::Parser to parse the input string. A handler is
+# defined which accumulates start and end tags in an array. The
+# number of start tags found is returned to the caller.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub count_tags {
+ my ($string) = @_;
+
+ my @accum;
+ chomp($string);
+ return 0 unless $string;
+
+ my $p = HTML::Parser->new(
+ api_version => 3,
+ handlers => {
+ start => [ \@accum, "event,text" ],
+ end => [ \@accum, "event,text" ]
+ }
+ );
+ $p->parse($string);
+ $p->eof;
+
+ return scalar(@accum);
+}
+
+#=== FUNCTION ================================================================
+# NAME: clean_comment_field
+# PURPOSE: Cleans a string field from the comment
+# PARAMETERS: $field - the field to be cleaned
+# RETURNS: The cleaned string
+# DESCRIPTION: We want to remove all HTML. We also want to add HTML entities
+# where these may be needed. If there are already entities in
+# the data we will decode them first then re-encode so we don't
+# get double encoding.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub clean_comment_field {
+ my ($field) = @_;
+
+ #
+ # Remove all HTML
+ #
+ my $hr = HTML::Restrict->new();
+ $field = $hr->process($field);
+
+ #
+ # Entities. Make sure all existing entities are decoded first, then encode
+ # the result. Note that the unsafe characters are the reverse of what's
+ # listed. The second argument to encode_entities uses regular expression
+ # character class syntax (as if in [] in a RE). The set is all non-plain
+ # ASCII exclusing newline, carriage return and '<>&'.
+ #
+ decode_entities($field);
+ $field
+ = encode_entities( $field, '^\n\r\x20-\x25\x27-\x3b\x3d\x3f-\x7e' );
+
+ return $field;
+}
+
+#=== FUNCTION ================================================================
+# NAME: moveFile
+# PURPOSE: Moves a file (renames it) taking account of whether the name
+# exists at the destination
+# PARAMETERS: $file file to move
+# $dest destination of the file
+# RETURNS: True (1) if the move succeeded, otherwise false (0)
+# DESCRIPTION: A wrapper around the File::Copy 'move' function. If the target
+# file exists then a version number is appended to the name so
+# that the original is not clobbered.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub moveFile {
+ my ( $file, $dest ) = @_;
+
+ my ( $target, $basename, $prefix, $vsn );
+
+ return 0 unless -e $file;
+
+ #
+ # Handle a destination directory
+ #
+ if ( -d $dest ) {
+ ( $basename = $file ) =~ s|.*/||mx;
+ $target = "$dest/$basename";
+ }
+ else {
+ $target = $dest;
+ }
+
+ #
+ # If the target exists we'll have to do some work, otherwise just do the
+ # move
+ #
+ if ( -e $target ) {
+ if ( ( $prefix, $vsn ) = ( $target =~ /(\.)(\d+)$/ ) ) {
+ while ( -e "$prefix$vsn" ) {
+ $vsn++;
+ $target = "$prefix$vsn";
+ }
+ return move( $file, $target );
+ }
+ else {
+ $vsn = 1;
+ $prefix = $target;
+ $target = "$prefix.$vsn";
+ while ( -e "$prefix.$vsn" ) {
+ $vsn++;
+ $target = "$prefix.$vsn";
+ }
+ return move( $file, $target );
+ }
+ }
+ else {
+ return move( $file, $dest );
+ }
+}
+
+#=== FUNCTION ================================================================
+# NAME: abbreviate_filename
+# PURPOSE: Abbreviates a long pathname by shrinking the directory names.
+# PARAMETERS: $filename The name of the file
+# RETURNS: A string containing the abbreviated name
+# DESCRIPTION: Splits the filename, assembles the directories as just their
+# first character, and then appends the filename. A bit
+# primitive but it works.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub abbreviate_filename {
+ my ($filename) = @_;
+
+ my @elements = split('/',$filename);
+ my @shortened = map {substr($_,0,1)} @elements[0..$#elements-1];
+
+ return join('/',@shortened,$elements[-1]);
+}
+
+#=== FUNCTION ================================================================
+# NAME: sort_by_timestamp
+# PURPOSE: Sorts the main hash by timestamp rather than the normal sort
+# by key
+# PARAMETERS: $struct hashref pointing to the main structure
+# RETURNS: A list of hash keys in order of timestamp
+# DESCRIPTION: Generates a list of keys by sorting based on the comment
+# timestamp in the JSON hash. Performs the sort by doing
+# a string comparison since the dates are in ISO8601
+# 'YYYY-MM-DDTHH:MM:SSZ' format (e.g. 2017-10-06T06:22:55Z).
+# This seems to be safe.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub sort_by_timestamp {
+ my ($struct) = @_;
+
+ my @result = sort {
+ $struct->{$a}->{JSON}->{comment_timestamp}
+ cmp $struct->{$b}->{JSON}->{comment_timestamp}
+ } keys(%$struct);
+
+ return @result;
+}
+
+#=== FUNCTION ================================================================
+# NAME: _debug
+# PURPOSE: Prints debug reports
+# PARAMETERS: $active Boolean: 1 for print, 0 for no print
+# @messages List of messages to print
+# RETURNS: Nothing
+# DESCRIPTION: Outputs messages to STDERR if $active is true. For each
+# message it removes any trailing newline and then adds one in
+# the 'print' so the caller doesn't have to bother. Prepends
+# each message with 'D> ' to show it's a debug message.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub _debug {
+ my ( $active, @messages ) = @_;
+
+ return unless $active;
+ foreach my $msg (@messages) {
+ chomp($msg);
+ print STDERR "D> $msg\n";
+ }
+}
+
+#=== FUNCTION ================================================================
+# NAME: Options
+# PURPOSE: Processes command-line options
+# PARAMETERS: $optref Hash reference to hold the options
+# RETURNS: Undef
+# DESCRIPTION:
+# THROWS: no exceptions
+# COMMENTS: none
+# SEE ALSO: n/a
+#===============================================================================
+sub Options {
+ my ($optref) = @_;
+
+ my @options = (
+ "help", "documentation|manpage",
+ "debug=i", "dry-run!",
+ "verbose+", "verify!",
+ "live!", "json!",
+ "config=s",
+ );
+
+ if ( !GetOptions( $optref, @options ) ) {
+ pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 );
+ }
+
+ return;
+}
+
+__END__
+
+
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+# Application Documentation
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+#{{{
+
+=head1 NAME
+
+process_comments
+
+=over 4
+
+Process incoming comment files as email messages or JSON files
+
+=back
+
+=head1 VERSION
+
+This documentation refers to process_comments version 0.3.3
+
+
+=head1 USAGE
+
+ ./process_comments [-help] [-doc] [-debug=N] [-[no]dry-run]
+ [-verbose ...] [-[no]live] [-[no]json] [-config=FILE]
+
+ ./process_comments -dry-run
+ ./process_comments -debug=3 -dry-run
+ ./process_comments -verbose -verbose
+ ./process_comments -help
+ ./process_comments -documentation
+ ./process_comments -json
+ ./process_comments -verb -live -config=.process_comments_live.cfg
+
+=head1 OPTIONS
+
+=over 8
+
+=item B<-help>
+
+Prints a brief help message describing the usage of the program, and then exits.
+
+=item B<-documentation> or B<-manpage>
+
+Prints the entire embedded documentation for the program, then exits.
+
+=item B<-debug=N>
+
+Enables debugging mode when N > 0 (zero is the default). The levels are:
+
+=over 4
+
+=item B<1>
+
+Reports on the options chosen. Running with the B<-dry-run> option prevents
+problems with comments if any options have been incorrectly used.
+
+=item B<2>
+
+Prints all of the information described at the previous level.
+
+Prints the names of files found in the mail or JSON spool areas.
+
+Prints the internal details of the email, listing the MIME parts (if there are any).
+
+Prints the length of the MIME part matching the desired type, in lines.
+
+Prints the entirety of the internal structure holding details of the mail file
+and the comment it contains. This follows the moderation pass.
+
+Prints the SQL that has been constructed to update the database.
+
+=item B<3>
+
+Prints all of the information described at the previous levels.
+
+Prints the contents of the B<%config> hash built from the configuration file.
+
+=back
+
+=item B<-[no]dry-run>
+
+Controls the program's B mode. It is off by default. In dry-run mode
+the program reports what it would do but makes no changes. When off the
+program makes all the changes it is designed to perform.
+
+=item B<-verbose>
+
+This option may be repeated. For each repetition the level of verbosity is
+increased. By default no verbosity is in effect and the program prints out the
+minimal amount of information.
+
+Verbosity levels:
+
+=over 4
+
+=item B<1>
+
+Prints the name of each mail (or JSON) file as it's processed.
+
+Prints any error messages during message validation, which are also being
+logged (unless in dry-run mode) and saved for reporting later.
+
+Prints a notification if the comment is added to the database (or that this
+would have happened in dry-run mode).
+
+Prints messages about the moving of each mail (or JSON) file from the
+processing area, along with any errors accumulated for that file. In dry-run
+mode simply indicates what would have happened.
+
+Prints the response code received from the server when invoking the interface
+for updating comment files there. If in dry-run mode the message produced
+merely indicates what would have happened.
+
+If validation failed earlier on then further information is produced about the
+final actions taken on these files.
+
+=item B<2>
+
+Prints the addresses each mail message is being sent to (unless in JSON mode).
+
+=item B<3>
+
+Prints the JSON contents of each mail message (or of each JSON file).
+
+=back
+
+=item B<-[no]verify>
+
+This option controls whether the script imposes a delay on comments. The idea
+is that if the comment feature is used to rant on a subject or to pass
+misinformation, delaying them will help to defuse the situation.
+
+The default state is B<-noverify>; a delay is not imposed. Selecting
+B<-verify> means that comments have to be at least 24 hours old before they
+are processed. The length of the delay cannot currently be changed without
+altering the script.
+
+This feature of the script has hardly been used and is perhaps against the
+spirit of HPR, so it will probably be removed in a future version.
+
+=item B<-[no]live>
+
+This option determines whether the program runs in live mode or not. The
+default varies depending on which system it is being run on.
+
+IT SHOULD NOT USUALLY BE NECESSARY TO USE THIS!
+
+In live mode the program makes changes to the live database and sends messages
+to the live web interface when a comment has been processed. With live mode
+off the program assumes it is writing to a clone of the database and it does
+not inform the webserver that a comment has been processed.
+
+The default for the copy of the program on borg is that live mode is ON.
+Otherwise the default is that live mode is OFF. The setting is determined by
+the sed script called B on borg. This needs to be run whenever
+a new version of the program is released. This is done as follows:
+
+ sed -i -f fixup.sed process_comments
+
+=item B<-[no]json>
+
+This option selects JSON mode, which makes the script behave in a different
+way from the default mode (B<-nojson> or MAIL mode) where it processes email
+containing comments.
+
+In JSON mode the script looks in a sub-directory called B where it
+expects to find JSON files. The normal way in which these files arrive in this
+directory is by using B to copy them from the HPR server (the directory
+is B). This is a provision in case the normal route of
+sending out email messages has failed for some reason. It also saves the user
+from setting up the mail handling infrastructure that would otherwise be
+needed.
+
+In JSON mode the mail handling logic is not invoked, files are searched for in
+the B directory and each file is processed, moderation is requested and
+the comment is added to the database. In `B<-live>` mode the server is informed
+that the comment has been processed.
+
+The B directory needs to have three sub-directories: B,
+B and B. The script will place the processed files into
+these sub-directories according to the moderation choice made. This makes it
+easier to see what actions were taken and helps avoid repeated processing of
+the same comment.
+
+=item B<-config=FILE>
+
+This option defines a configuration file other than the default
+B<.hpr_db.cfg>. The file must be formatted as described below in the section
+B.
+
+=back
+
+=head1 DESCRIPTION
+
+A script to process new comments, moderate them and add them to the HPR
+database.
+
+In the new HPR comment system (released September 2017) a new web form is
+presented in association with each show. The form can be used to submit
+a comment on the show in question and takes some standard fields: the name of
+the commenter, the title of the comment and the body of the comment itself.
+
+Once the comment has been submitted its contents are formatted as a JSON
+object and are sent as a mail attachment to the address
+B.
+
+Recipients of these mail messages can then perform actions on the comments
+they contain to cause them to be added to the HPR database. These actions are:
+approve the comment, block it (because it is inappropriate or some form of
+Spam and we want to prevent any further messages from the associated IP
+address), or reject it (delete it). There is also an ignore option which skips
+the current comment in this run of the script so it can be revisited later.
+
+This script can process an entire email message which has been saved to a file
+or a file containing the JSON object (as in the email attachment). When
+processing email it is expected that it will be found in a maildrop directory,
+and when finished the messages will be placed in sub-directories according to
+what actions were carried out. A similar logic is used for JSON files; they
+are expected to be in a drop area and are moved to sub-directories after
+processing.
+
+The directories used as spool areas are defined in the configuration file(s)
+described below.
+
+=head2 CHANGES
+
+=head3 2024
+
+An alternative method of comment approval has been added using PHP on the
+server B. This script was originally
+designed around receiving email messages containing JSON or collecting JSON
+files. The assumption was that it was the sole agent processing these messages
+or files and would therefore be the only way that comments could enter the
+database.
+
+With the new server route it became possible for an emailed comment to be
+received when that comment was already in the database.
+
+As of version B<0.3.2> the script has been enhanced to check whether comments
+received as mail messages (or JSON files) have already been processed. If this
+is found to be the case the incoming data is skipped and stored in the
+'B' directory (because the comment has been approved elsewhere).
+
+There is still scope for confusion - if for example, a comment has been
+rejected on the server, the script will see it as a potential addition to the
+database. The moderators need to coordinate their decisions!
+
+The ultimate solution will be to have just one way of moderating and
+installing comments which is available to any of the "Janitors".
+
+=head2 MAIL HANDLING
+
+One way of handling incoming mail is to use a mail client which is capable of
+saving messages sent to the above address in the spool area mentioned earlier.
+For example, Thunderbird can do this by use of a filter and a plugin. Other
+MUA's will have similar capabilities.
+
+When this script is run on the mail spool area it will process all of the
+files it finds. For each file it will check its validity in various ways,
+display the comment then offer a moderation menu. The moderation options are
+described below.
+
+=head3 APPROVE
+
+If a comment is approved then it will be added to the database, the associated
+mail file will be moved to a sub-directory (by default called 'B'),
+and the HPR server will be notified of this action.
+
+=head3 BAN
+
+If a comment is banned then it will not be added to the database. The mail
+file will be moved to the sub-directory 'B' and the HPR server will be
+informed that the IP address associated with the comment should be placed on
+a black list.
+
+=head3 REJECT
+
+If a comment is rejected it is not written to the database, the mail file is
+moved to the sub-directory 'B' and the HPR server informed that the
+comment can be deleted.
+
+=head3 IGNORE
+
+If a comment is ignored it is simply left in the mail spool and no further
+processing done on it. It will be eligible for processing again when the
+script is next run.
+
+=head2 JSON FILE HANDLING
+
+As described under the description of the B<-[no]json> option, the script
+allows the processing of a multiple JSON files, each containing a single
+comment. These files are expected to be in another spool area (defined int he
+configuration file). The JSON is checked and all of the comment fields are
+validated, then the moderation process is begun.
+
+Moderation in this case consists of the same steps as described above except
+that no mail file actions are taken and the JSON file is moved to
+a sub-directory after processing.
+
+=head1 DIAGNOSTICS
+
+=over 4
+
+=item B
+
+Type: fatal
+
+The nominated configuration file referenced in B<-config=FILE> was not found.
+
+=item B
+
+Type: fatal
+
+No mail files were found in the mail spool area requiring processing.
+
+=item B
+
+Type: fatal
+
+No JSON files were found in the JSON spool area requiring processing.
+
+=item B
+
+Type: fatal
+
+A JSON file in the spool area could not be read with a JSON parser.
+
+=item B
+
+Type: fatal
+
+The timestamp must be converted to a format compatible with MySQL/MariaDB but
+during this process the parse failed.
+
+=item B
+
+Type: fatal
+
+A mail file in the spool area could not be opened.
+
+=item B
+
+Type: warning
+
+A mail file could not be moved to the relevant sub-directory.
+
+=item B
+
+Type: warning
+
+A mail file in the spool area could not be closed.
+
+=item B
+
+Type: fatal, warning
+
+An action on the database has been flagged as an error.
+
+=item B
+
+Type: fatal
+
+An action relating to the template used for the display of the comment has
+been flagged as an error.
+
+=item B
+
+Type: warning
+
+The routine 'call_back' was called incorrectly. The key was missing.
+
+=item B
+
+Type: warning
+
+The routine 'call_back' was called incorrectly. The action was invalid.
+
+=item B
+
+Type: warning
+
+While attempting to send an action to the remote server with the 'call_back'
+subroutine an error message was received.
+
+=back
+
+=head1 CONFIGURATION AND ENVIRONMENT
+
+=head2 CONFIGURATION
+
+The configuration file design was changed for version B<0.2.7> and beyond.
+
+The script expects a configuration file to provide it with various program
+settings, database credentials and HPR CMS credentials.
+
+The default configuration file name is B<.process_comments.cfg> and is for use
+with the local clone of the hpr database.
+
+This file has the general structure:
+
+ <>
+
+ <>
+
+
+ user = "dummy:dummy"
+
+
+It uses B 'B' directives to include the file
+B<.process_comments_settings.cfg> which holds the program settings. It then
+includes the file B<.hpr_db.cfg> which containss credentials for the cloned
+database. Finally it defines dummy credentials for the HPR CMS since this
+feature is not used by default.
+
+The more useful configuration is in B<.process_comments_live.cfg>:
+
+ <>
+
+ <>
+
+
+ <>
+
+
+The basic format of the main configuration data after the inclusions have been
+performed is then:
+
+
+ # Holds program settings
+
+
+ # Holds database settings
+
+
+ # Holds CMS credentials
+
+
+=head3 CONTENTS OF B<.process_comments_settings.cfg>:
+
+This section is enclosed in '' tags:
+
+
+ PROG = process_comments
+
+ #
+ # Defaults
+ #
+ basedir = "$HOME/HPR/Comment_system"
+ configfile = "$basedir/.hpr_db.cfg"
+ logfile = "$basedir/logs/${PROG}.log"
+ template = "$basedir/${PROG}.tpl"
+
+ #
+ # Mail message stash area
+ #
+ maildrop = "$HOME/HPR/CommentDrop"
+ processed = "$maildrop/processed"
+ rejected = "$maildrop/rejected"
+ banned = "$maildrop/banned"
+
+ #
+ # JSON stash area
+ #
+ jsondir = "$basedir/json"
+ jprocessed = "$jsondir/processed"
+ jrejected = "$jsondir/rejected"
+ jbanned = "$jsondir/banned"
+
+ #
+ # How to tell the server the comment's processed
+ #
+ callback_template = \
+ "https://hackerpublicradio.org/comment_process.php?key=%s&action=%s"
+
+
+
+=head3 CONTENTS OF B<.hpr_db.cfg> AND B<.hpr_livedb.cfg>
+
+The default file providing this part is just for use with the default
+database. The section is enclosed in '' tags:
+
+
+ host = 127.0.0.1
+ port = PORT
+ name = DATABASE
+ user = USERNAME
+ password = PASSWORD
+
+
+These settings can alternatively be used to connect to an SSH tunnel which has
+been connected from a remote system (like borg) to the live database.
+Assuming the port chosen for this is 3307 something like the following could
+be used:
+
+
+ host = 127.0.0.1
+ port = 3307
+ name = hpr_hpr
+ user = hpr_hpr
+ password = "**censored**"
+
+
+A typical Bash script for opening a tunnel might be:
+
+ #!/bin/bash
+ SSHPORT=22
+ LOCALPORT=3307
+ REMOTEPORT=3306
+ ssh -p ${SSHPORT} -f -N -L localhost:${LOCALPORT}:localhost:${REMOTEPORT} hpr@hackerpublicradio.org
+
+=head3 CONTENTS OF B<.hpradmin_curlrc>
+
+This is a file used by the 'curl' command to contain credentials to access the
+CMS on the HPR server. The format needs to be:
+
+ user = hpradmin:**censored**
+
+This is one of the formats allowed by 'curl' and is also compatible with the
+B module used by this script.
+
+=head2 TEMPLATE
+
+The program displays the comment that is currently being processed for
+moderation. It uses a template along with the Perl B module to do
+this. By default this template is called B. This can be
+changed by changing the line in the "settings" configuration file which sets
+the 'template' variable.
+
+The template is provided with the following data:
+
+ file a scalar containing the name of the file being processed
+
+ db a hash containing the details of the show to which the
+ comment relates, returned from a database query:
+ id the episode number
+ date the date of the episode
+ title the episode title
+ host the host name
+
+ comment a hash containing the fields from the comment:
+ eps_id the episode number
+ comment_timestamp date and time of the comment
+ comment_author_name comment author
+ comment_title comment title
+ comment_text comment text
+ justification justification for posting (if
+ relevant)
+ key unique comment key
+
+=head1 DEPENDENCIES
+
+ Carp
+ Config::General
+ DBI
+ Data::Dumper
+ DateTime::Format::ISO8601
+ Encode
+ File::Copy
+ File::Find::Rule
+ File::Slurper
+ Getopt::Long
+ HTML::Entities
+ HTML::Restrict
+ IO::Prompter
+ JSON
+ LWP::UserAgent
+ List::Util
+ Log::Handler
+ MIME::Parser
+ Mail::Address
+ Mail::Field
+ Mail::Internet
+ Pod::Usage
+ SQL::Abstract
+ Template
+
+The Perl B option is used instead of the original
+B package.
+
+=head1 BUGS AND LIMITATIONS
+
+There are no known bugs in this module.
+Please report problems to Dave Morriss (Dave.Morriss@gmail.com)
+Patches are welcome.
+
+=head1 AUTHOR
+
+Dave Morriss (Dave.Morriss@gmail.com)
+
+=head1 LICENCE AND COPYRIGHT
+
+Copyright (c) 2017 - 2024 Dave Morriss (Dave.Morriss@gmail.com). All rights
+reserved.
+
+This module is free software; you can redistribute it and/or
+modify it under the same terms as Perl itself. See perldoc perlartistic.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+=cut
+
+#}}}
+
+# [zo to open fold, zc to close]
+
+# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
diff --git a/Comment_system/process_comments.tpl b/Comment_system/process_comments.tpl
new file mode 100644
index 0000000..81b7b7f
--- /dev/null
+++ b/Comment_system/process_comments.tpl
@@ -0,0 +1,21 @@
+[%# process_comments.tpl 2017-09-12 -%]
+[%- USE wrap -%]
+
+Comment to moderate ([% file %]):
+################################################################################
+Show: [% db.id %] by [% db.host %] released on [% db.date %]
+ entitled "[% db.title %]"
+
+Author: [% comment.comment_author_name %]
+Date: [% comment.comment_timestamp %]
+Title: [% comment.comment_title %]
+[% IF comment.justification.defined && comment.justification != 'Current Comment' -%]
+Justification: [% comment.justification %]
+[% END -%]
+
+Text:
+[% comment.comment_text FILTER wrap(80) %]
+################################################################################
+[%#
+ # vim: syntax=tt2:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
+-%]
diff --git a/Community_News/.summarise_mail.cfg b/Community_News/.summarise_mail.cfg
new file mode 100644
index 0000000..4de474c
--- /dev/null
+++ b/Community_News/.summarise_mail.cfg
@@ -0,0 +1,13 @@
+
+ url = http://download.gmane.org/gmane.network.syndication.podcast.hacker-public-radio
+ template1 = "$url/%d/%d"
+ lookahead = 100
+ thread = http://comments.gmane.org/gmane.network.syndication.podcast.hacker-public-radio
+ template2 = "$thread/%d"
+
+
+
+ directory = /home/cendjm/HPR/Community_News/mail_cache
+ filename = gmane.mbox
+ regex = ""
+
diff --git a/Community_News/aob_template.mkd_ b/Community_News/aob_template.mkd_
new file mode 100644
index 0000000..942b881
--- /dev/null
+++ b/Community_News/aob_template.mkd_
@@ -0,0 +1,9 @@
+### Example section
+
+- Bulleted list item 1
+
+- Bulleted list item 2
+
+[%#
+vim: syntax=markdown:ts=8:sw=4:ai:et:tw=78:fo=tcqn:fdm=marker:com-=b\:-
+-%]
diff --git a/Community_News/build_AOB b/Community_News/build_AOB
new file mode 100755
index 0000000..acd80cc
--- /dev/null
+++ b/Community_News/build_AOB
@@ -0,0 +1,252 @@
+#!/bin/bash -
+#===============================================================================
+#
+# FILE: build_AOB
+#
+# USAGE: ./build_AOB [date]
+#
+# DESCRIPTION: Build the AOB files for a particular month
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: ---
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.0.12
+# CREATED: 2021-04-15 17:36:22
+# REVISION: 2024-03-15 09:50:02
+#
+#===============================================================================
+
+set -o nounset # Treat unset variables as an error
+
+SCRIPT=${0##*/}
+BASEDIR=${0%/*}
+
+VERSION="0.0.12"
+
+STDOUT="/dev/fd/2"
+
+#
+# Make sure we're in the working directory
+#
+cd "$BASEDIR" || exit 1
+
+#
+# Load library functions
+#
+LIB="$HOME/bin/function_lib.sh"
+[ -e "$LIB" ] || { echo "$SCRIPT: Unable to source functions"; exit 1; }
+# shellcheck disable=SC1090
+source "$LIB"
+
+# {{{ -- Functions usage and _DEBUG
+#=== FUNCTION ================================================================
+# NAME: _usage
+# DESCRIPTION: Report usage
+# PARAMETERS: None
+# RETURNS: Nothing
+#===============================================================================
+_usage () {
+ cat >$STDOUT <<-endusage
+Usage: ./${SCRIPT} [-h] [-D] [date]
+
+Version: $VERSION
+
+Converts the AOB in Markdown format for a particular month to HTML and to text
+
+Options:
+ -h Print this help
+ -D Select debug mode (works the same; more output)
+
+Arguments (optional):
+ date Specifies the month to build the AOB for. The default
+ is the current month. The format can be YYYY-MM (e.g.
+ 2022-05) or any date format that the 'date' command
+ can parse, so 2022-04-01 or 01-Apr-2022 and so on. If
+ the date cannot be parsed an error will be reported.
+
+Examples
+ ./${SCRIPT} -h
+ ./${SCRIPT} -D 01-February-2021
+ ./${SCRIPT} 2021-02
+
+endusage
+ exit
+}
+
+#=== FUNCTION ================================================================
+# NAME: _DEBUG
+# DESCRIPTION: Writes a message if in DEBUG mode
+# PARAMETERS: List of messages
+# RETURNS: Nothing
+#===============================================================================
+_DEBUG () {
+ [ "$DEBUG" == 0 ] && return
+ for msg in "$@"; do
+ printf 'D> %s\n' "$msg"
+ done
+}
+
+# }}}
+
+#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+#
+# Base and database directories
+#
+PARENT="$HOME/HPR"
+BASEDIR="$PARENT/Community_News"
+cd "$BASEDIR" || {
+ echo "Failed to cd to $BASEDIR";
+ exit 1;
+}
+# IADIR="$PARENT/InternetArchive"
+
+#
+# Option defaults
+#
+DEBUG=0
+
+#
+# Process options
+#
+while getopts :hdD opt
+do
+ case "${opt}" in
+ h) _usage;;
+ D) DEBUG=1;;
+ ?) echo "$SCRIPT: Invalid option; aborting"; exit 1;;
+ esac
+done
+shift $((OPTIND - 1))
+
+#
+# Handle the optional argument
+#
+if [[ $# -eq 1 ]]; then
+ startdate="$1"
+ # Normalise a YYYY-MM date so 'date' will not complain
+ if [[ $startdate =~ ^[0-9]{4}-[0-9]{2}$ ]]; then
+ startdate+='-01'
+ fi
+ # Validate the date and standardise it if it's OK
+ tmp="$(date -d "$startdate" +%Y-%m)" || {
+ echo "Use a date such as $(date +%Y-%m)"
+ exit 1
+ }
+ startdate="$tmp"
+else
+ startdate="$(date +%Y-%m)"
+fi
+
+_DEBUG "Date used: $startdate"
+
+#
+# We added a new field in 2022, 'item_last_updated' which is taken from the IA
+# (which we discovered was being maintained). It is a Unix date field, but the
+# view 'episodes_view' converts it.
+#
+# TODO: Since query3 was added it has made query1 and query2 obsolete. We
+# generate a per-month table with query3 which is turned into HTML using awk
+# and used in the AOB report. The code below that uses these queries and their
+# results could now be removed (or commented out).
+#
+#query1="select count(*) from episodes where id between 871 and 2429 and with_derived = 1"
+
+##query1="select count(*) from episodes_view where id between 871 and 2429 and \
+##item_last_updated between '${startdate}-01' and \
+##date('${startdate}-01','+1 month') and with_derived = 1"
+##
+##query2='select count(*) from episodes where id between 871 and 2429 and with_derived = 0'
+##
+##query3=$(cat <"$TMP1" 7<<'ENDAWK'
+##BEGIN{
+## total = 0
+## remainder = (2429 - 871 + 1)
+## print ""
+## print "Month Month count "\
+## "Running total Remainder "
+##}
+##{
+## total = total + $2
+## remainder = remainder - $2
+## printf "%s %s %s %s \n",
+## $1,$2,total,remainder
+##}
+##END{
+## print "
"
+##}
+##ENDAWK
+##cat >>"$TMP1" <Table updated: $(date --utc +'%F %T')
+##ENDDATE
+##_DEBUG "Table" "$(cat "$TMP1")"
+
+#
+# Build the output files
+#
+# if tpage --define "uploads=$UPLOADS" --define "remaining=$REMAINING" \
+# --define "table=$TMP1" "$AOBMKD" |\
+# pandoc -f markdown-smart -t html5 -o "${AOBMKD%mkd}html"; then
+#
+if pandoc -f markdown-smart -t html5 "$AOBMKD" -o "${AOBMKD%mkd}html"; then
+ echo "Converted $AOBMKD to HTML"
+else
+ echo "Conversion of $AOBMKD to HTML failed"
+fi
+
+exit
+
+# vim: syntax=sh:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21:fdm=marker
diff --git a/Community_News/comments_only.tpl b/Community_News/comments_only.tpl
new file mode 100644
index 0000000..7425933
--- /dev/null
+++ b/Community_News/comments_only.tpl
@@ -0,0 +1,121 @@
+[%# comments_only.tpl 2018-11-05 -%]
+[%# Textual comment summary for Community News. -%]
+[%# This one partitions comments into past and current. -%]
+[%# It requires make_shownotes > V0.0.28 -%]
+[%- USE date -%]
+[%- USE wrap -%]
+[%- DEFAULT mark_comments = 0
+ aob = 0 -%]
+[% TAGS outline -%]
+%% IF mark_comments == 1 && missed_comments.size > 0
+=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
+/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\
+=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
+
+Missed comments last month
+--------------------------
+
+Note to Volunteers: These are comments for shows last month that were not read
+in the last show because they arrived after the recording.
+
+%% FOREACH comment IN missed_comments
+================================================================================
+hpr[% comment.episode %] ([% comment.date %]) "[% comment.title %]" by [% comment.host %].
+ ------------------------------------------------------------------------------
+ From: [% comment.comment_author_name -%] on [% date.format(comment.comment_timestamp_ut,'%Y-%m-%d','UTC') -%]:
+[%- IF comment.comment_title.length > 0 -%]
+ "[% comment.comment_title %]"
+[%- ELSE -%]
+ "[no title]"
+[%- END %]
+ ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+[% wrap(comment.comment_text, 80, ' ', ' ') FILTER decode_entities %]
+
+%% END
+=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
+/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\
+=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
+%% END
+
+Comments this month
+-------------------
+
+%% IF comment_count > 0
+There [%- comment_count == 1 ? "is $comment_count comment" : "are $comment_count comments" -%] in total.
+
+%% IF past_count > 0
+Past shows
+----------
+
+There [% past_count == 1 ? "is $past_count comment" : "are $past_count comments" %] on [% past.size %] previous [% past.size == 1 ? "show" : "shows" %]:
+%% FOREACH ep IN past.keys.sort
+%% arr = past.$ep
+
+================================================================================
+hpr[% arr.0.episode %] ([% arr.0.date %]) "[% arr.0.title %]" by [% arr.0.host %].
+%% FOREACH row IN arr
+ ------------------------------------------------------------------------------
+ Comment [% row.index %]: [% row.comment_author_name -%] on [% date.format(row.comment_timestamp_ut,'%Y-%m-%d','UTC') -%]:
+[%- IF row.comment_title.length > 0 -%]
+ "[% row.comment_title FILTER decode_entities %]"
+[%- ELSE -%]
+ "[no title]"
+[%- END %]
+ ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+[% IF mark_comments == 1 && ((row.comment_timestamp_ut <= last_recording) && (arr.0.date.substr(0,7) == last_month)) -%]
+[% wrap(row.comment_text, 80, '| ', '| ') FILTER decode_entities %]
+[% ELSE -%]
+[% wrap(row.comment_text, 80, ' ', ' ') FILTER decode_entities %]
+[% END -%]
+%% END
+%% END
+
+
+=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
+/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\
+=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
+
+%% END
+
+%% cc = (comment_count - past_count)
+%% IF cc > 0
+This month's shows
+------------------
+
+There [% cc == 1 ? "is $cc comment" : "are $cc comments" %] on [% current.size %] of this month's shows:
+%% FOREACH ep IN current.keys.sort
+%% arr = current.$ep
+
+================================================================================
+hpr[% arr.0.episode %] ([% arr.0.date %]) "[% arr.0.title %]" by [% arr.0.host %].
+%% FOREACH row IN arr
+ ------------------------------------------------------------------------------
+ Comment [% row.index %]: [% row.comment_author_name -%] on [% date.format(row.comment_timestamp_ut,'%Y-%m-%d','UTC') -%]:
+[%- IF row.comment_title.length > 0 -%]
+ "[% row.comment_title FILTER decode_entities %]"
+[%- ELSE -%]
+ "[no title]"
+[%- END %]
+ ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+[% wrap(row.comment_text, 80, ' ', ' ') FILTER decode_entities %]
+%% END
+%% END
+%% END
+
+%% ELSE
+There were no comments this month.
+%% END
+
+[%# Any other business? -%]
+[% IF aob == 1 -%]
+=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
+/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\
+=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
+Any other business
+------------------
+
+[% INCLUDE $aobfile -%]
+[%- END %]
+[%#
+ # vim: syntax=tt2:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
+-%]
diff --git a/Community_News/mailnote_template.tpl b/Community_News/mailnote_template.tpl
new file mode 100644
index 0000000..959ac7a
--- /dev/null
+++ b/Community_News/mailnote_template.tpl
@@ -0,0 +1,21 @@
+[%# mailnote_template.tpl 2015-06-12
+ # This is the main (and default) template used by the script
+ # 'summarise_mail'. It generates an HTML snippet which simply lists all of
+ # the message threads passed to it in the 'threads' hash and reports the
+ # total. This HTML is then inserted into the notes generated by
+ # the 'make_shownotes' script.
+-%]
+[%- aa = 'archived-at' -%]
+
+ [%- FOREACH key IN threads.keys.sort %]
+ From: [% threads.$key.from.0 FILTER html_entity %]
+ Date: [% threads.$key.date %]
+ Subject: [% threads.$key.subject FILTER html_entity %]
+ Link: [% threads.$key.thread %]
+ Messages: [% threads.$key.count %] [% key != threads.keys.sort.last ? ' ' : '' %]
+ [%- END %]
+
+Total messages this month: [% total %]
+[%#
+ # vim: syntax=tt2:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
+-%]
diff --git a/Community_News/mailnote_template2.tpl b/Community_News/mailnote_template2.tpl
new file mode 100644
index 0000000..7e413d2
--- /dev/null
+++ b/Community_News/mailnote_template2.tpl
@@ -0,0 +1,22 @@
+[%# mailnote_template2.tpl
+ # This is an alternative template for use with the 'summarise_mail' script.
+ # It generates a plain text version of the mail threads and is intended to
+ # be used by the Community News hosts when reading through the month's
+ # message threads.
+-%]
+[%- aa = 'archived-at' -%]
+[%- FOREACH key IN threads.keys.sort -%]
+From: [% threads.$key.from.0 %]
+Date: [% threads.$key.date %]
+Subject: [% threads.$key.subject %]
+Link: [% threads.$key.thread %]
+Messages: [% threads.$key.count %]
+--------------------------------------------------------------------------------
+
+[%- END -%]
+
+Total messages this month: [% total %]
+[%#
+ # vim: syntax=tt2:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
+-%]
+
diff --git a/Community_News/make_email b/Community_News/make_email
new file mode 100755
index 0000000..52ce412
--- /dev/null
+++ b/Community_News/make_email
@@ -0,0 +1,1578 @@
+#!/usr/bin/env perl
+#===============================================================================
+#
+# FILE: make_email
+#
+# USAGE: ./make_email [-debug=N] [-month=DATE] [-from=ADDRESS]
+# [-to=ADDRESS] [-[no]mail] [-date=DATE] [-start=START_TIME]
+# [-end=END_TIME] [-config=FILE] [-dbconfig=FILE]
+#
+# DESCRIPTION: Make and send an invitation email for the next Community News
+# with times per timezone.
+#
+# The configuration file (.make_email.cfg) defines the name of
+# the email template and the defaults used when generating the
+# message. The date of the recording is computed from the
+# current month (Saturday before the first Monday of the month
+# when the show will be posted). It can also be specified
+# through the -date=DATE option.
+#
+# The month the email relates to can be changed through the
+# -month=DATA option, though this is rarely used. Use a date of
+# the format 'YYYY-MM-DD' here. The day is ignored but the year
+# and month are used in the computation. The month specified
+# must be in the future.
+#
+# The database configuration file defines the database to be
+# used to compute the date and show number. Use .hpr_db.cfg for
+# the local MariaDB copy (for testing) and .hpr_livedb.cfg for
+# the live database (over the ssh tunnel, which must have been
+# opened already).
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+#
+# NOTES: Does not send the email at present. Needs work
+# 2022-02-28: DBD::MariaDB has vanished, had to revert to MySQL
+# again
+#
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.2.7
+# CREATED: 2013-10-28 20:35:22
+# REVISION: 2024-05-24 18:53:17
+#
+#===============================================================================
+
+use 5.010;
+use strict;
+use warnings;
+
+use Getopt::Long;
+use Pod::Usage;
+
+use Config::General;
+
+use Date::Parse;
+
+use DateTime;
+use DateTime::TimeZone;
+use DateTime::Format::Duration;
+
+use Date::Calc qw{:all};
+
+use Template;
+
+use Mail::Mailer;
+
+use DBI;
+
+use Data::Dumper;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.2.7';
+
+#
+# Script name
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+#
+# Constants and other declarations
+#
+my $basedir = "$ENV{HOME}/HPR/Community_News";
+my $configfile1 = "$basedir/.${PROG}.cfg";
+my $configfile2 = "$basedir/.hpr_db.cfg";
+
+my ( $dbh, $sth1, $sth2, $sth3, $h1, $h2, $rv );
+
+#
+# The timezones we want to report. These were generated with
+# DateTime::TimeZone->all_names(). Just uncomment the desired elements.
+#
+my @zones = (
+ #{{{
+ #'Africa/Abidjan',
+ #'Africa/Accra',
+ #'Africa/Addis_Ababa',
+ #'Africa/Algiers',
+ #'Africa/Asmara',
+ #'Africa/Bamako',
+ #'Africa/Bangui',
+ #'Africa/Banjul',
+ #'Africa/Bissau',
+ #'Africa/Blantyre',
+ #'Africa/Brazzaville',
+ #'Africa/Bujumbura',
+ #'Africa/Cairo',
+ #'Africa/Casablanca',
+ #'Africa/Ceuta',
+ #'Africa/Conakry',
+ #'Africa/Dakar',
+ #'Africa/Dar_es_Salaam',
+ #'Africa/Djibouti',
+ #'Africa/Douala',
+ #'Africa/El_Aaiun',
+ #'Africa/Freetown',
+ #'Africa/Gaborone',
+ #'Africa/Harare',
+ #'Africa/Johannesburg',
+ #'Africa/Kampala',
+ #'Africa/Khartoum',
+ #'Africa/Kigali',
+ #'Africa/Kinshasa',
+ #'Africa/Lagos',
+ #'Africa/Libreville',
+ #'Africa/Lome',
+ #'Africa/Luanda',
+ #'Africa/Lubumbashi',
+ #'Africa/Lusaka',
+ #'Africa/Malabo',
+ #'Africa/Maputo',
+ #'Africa/Maseru',
+ #'Africa/Mbabane',
+ #'Africa/Mogadishu',
+ #'Africa/Monrovia',
+ #'Africa/Nairobi',
+ #'Africa/Ndjamena',
+ #'Africa/Niamey',
+ #'Africa/Nouakchott',
+ #'Africa/Ouagadougou',
+ #'Africa/Porto-Novo',
+ #'Africa/Sao_Tome',
+ #'Africa/Tripoli',
+ #'Africa/Tunis',
+ #'Africa/Windhoek',
+ #'America/Adak',
+ #'America/Anchorage',
+ #'America/Antigua',
+ #'America/Araguaina',
+ #'America/Argentina/Buenos_Aires',
+ #'America/Argentina/Catamarca',
+ #'America/Argentina/Cordoba',
+ #'America/Argentina/Jujuy',
+ #'America/Argentina/La_Rioja',
+ #'America/Argentina/Mendoza',
+ #'America/Argentina/Rio_Gallegos',
+ #'America/Argentina/Salta',
+ #'America/Argentina/San_Juan',
+ #'America/Argentina/San_Luis',
+ #'America/Argentina/Tucuman',
+ #'America/Argentina/Ushuaia',
+ #'America/Asuncion',
+ #'America/Atikokan',
+ #'America/Bahia',
+ #'America/Bahia_Banderas',
+ #'America/Barbados',
+ #'America/Belem',
+ #'America/Belize',
+ #'America/Blanc-Sablon',
+ #'America/Boa_Vista',
+ #'America/Bogota',
+ #'America/Boise',
+ #'America/Cambridge_Bay',
+ #'America/Campo_Grande',
+ #'America/Cancun',
+ #'America/Caracas',
+ #'America/Cayenne',
+ #'America/Cayman',
+ 'America/Chicago',
+ #'America/Chihuahua',
+ #'America/Costa_Rica',
+ #'America/Creston',
+ #'America/Cuiaba',
+ #'America/Curacao',
+ #'America/Danmarkshavn',
+ #'America/Dawson',
+ #'America/Dawson_Creek',
+ #'America/Denver',
+ #'America/Detroit',
+ #'America/Edmonton',
+ #'America/Eirunepe',
+ #'America/El_Salvador',
+ #'America/Fortaleza',
+ #'America/Glace_Bay',
+ #'America/Godthab',
+ #'America/Goose_Bay',
+ #'America/Grand_Turk',
+ #'America/Guatemala',
+ #'America/Guayaquil',
+ #'America/Guyana',
+ #'America/Halifax',
+ #'America/Havana',
+ #'America/Hermosillo',
+ #'America/Indiana/Indianapolis',
+ #'America/Indiana/Knox',
+ #'America/Indiana/Marengo',
+ #'America/Indiana/Petersburg',
+ #'America/Indiana/Tell_City',
+ #'America/Indiana/Vevay',
+ #'America/Indiana/Vincennes',
+ #'America/Indiana/Winamac',
+ #'America/Inuvik',
+ #'America/Iqaluit',
+ #'America/Jamaica',
+ #'America/Juneau',
+ #'America/Kentucky/Louisville',
+ #'America/Kentucky/Monticello',
+ #'America/La_Paz',
+ #'America/Lima',
+ 'America/Los_Angeles',
+ #'America/Maceio',
+ #'America/Managua',
+ #'America/Manaus',
+ #'America/Martinique',
+ #'America/Matamoros',
+ #'America/Mazatlan',
+ #'America/Menominee',
+ #'America/Merida',
+ #'America/Metlakatla',
+ #'America/Mexico_City',
+ #'America/Miquelon',
+ #'America/Moncton',
+ #'America/Monterrey',
+ #'America/Montevideo',
+ #'America/Montreal',
+ #'America/Nassau',
+ 'America/New_York',
+ #'America/Nipigon',
+ #'America/Nome',
+ #'America/Noronha',
+ #'America/North_Dakota/Beulah',
+ #'America/North_Dakota/Center',
+ #'America/North_Dakota/New_Salem',
+ #'America/Ojinaga',
+ #'America/Panama',
+ #'America/Pangnirtung',
+ #'America/Paramaribo',
+ #'America/Phoenix',
+ #'America/Port-au-Prince',
+ #'America/Port_of_Spain',
+ #'America/Porto_Velho',
+ #'America/Puerto_Rico',
+ #'America/Rainy_River',
+ #'America/Rankin_Inlet',
+ #'America/Recife',
+ #'America/Regina',
+ #'America/Resolute',
+ #'America/Rio_Branco',
+ #'America/Santa_Isabel',
+ #'America/Santarem',
+ #'America/Santiago',
+ #'America/Santo_Domingo',
+ #'America/Sao_Paulo',
+ #'America/Scoresbysund',
+ #'America/Sitka',
+ #'America/St_Johns',
+ #'America/Swift_Current',
+ #'America/Tegucigalpa',
+ #'America/Thule',
+ #'America/Thunder_Bay',
+ #'America/Tijuana',
+ #'America/Toronto',
+ #'America/Vancouver',
+ #'America/Whitehorse',
+ #'America/Winnipeg',
+ #'America/Yakutat',
+ #'America/Yellowknife',
+ #'Antarctica/Casey',
+ #'Antarctica/Davis',
+ #'Antarctica/DumontDUrville',
+ #'Antarctica/Macquarie',
+ #'Antarctica/Mawson',
+ #'Antarctica/Palmer',
+ #'Antarctica/Rothera',
+ #'Antarctica/Syowa',
+ #'Antarctica/Vostok',
+ #'Asia/Aden',
+ #'Asia/Almaty',
+ #'Asia/Amman',
+ #'Asia/Anadyr',
+ #'Asia/Aqtau',
+ #'Asia/Aqtobe',
+ #'Asia/Ashgabat',
+ #'Asia/Baghdad',
+ #'Asia/Bahrain',
+ #'Asia/Baku',
+ #'Asia/Bangkok',
+ #'Asia/Beirut',
+ #'Asia/Bishkek',
+ #'Asia/Brunei',
+ #'Asia/Choibalsan',
+ #'Asia/Chongqing',
+ #'Asia/Colombo',
+ #'Asia/Damascus',
+ #'Asia/Dhaka',
+ #'Asia/Dili',
+ #'Asia/Dubai',
+ #'Asia/Dushanbe',
+ #'Asia/Gaza',
+ #'Asia/Harbin',
+ #'Asia/Hebron',
+ #'Asia/Ho_Chi_Minh',
+ 'Asia/Hong_Kong',
+ #'Asia/Hovd',
+ #'Asia/Irkutsk',
+ #'Asia/Jakarta',
+ #'Asia/Jayapura',
+ #'Asia/Jerusalem',
+ #'Asia/Kabul',
+ #'Asia/Kamchatka',
+ #'Asia/Karachi',
+ #'Asia/Kashgar',
+ #'Asia/Kathmandu',
+ #'Asia/Khandyga',
+ #'Asia/Kolkata',
+ #'Asia/Krasnoyarsk',
+ #'Asia/Kuala_Lumpur',
+ #'Asia/Kuching',
+ #'Asia/Kuwait',
+ #'Asia/Macau',
+ #'Asia/Magadan',
+ #'Asia/Makassar',
+ #'Asia/Manila',
+ #'Asia/Muscat',
+ #'Asia/Nicosia',
+ #'Asia/Novokuznetsk',
+ #'Asia/Novosibirsk',
+ #'Asia/Omsk',
+ #'Asia/Oral',
+ #'Asia/Phnom_Penh',
+ #'Asia/Pontianak',
+ #'Asia/Pyongyang',
+ #'Asia/Qatar',
+ #'Asia/Qyzylorda',
+ #'Asia/Rangoon',
+ #'Asia/Riyadh',
+ #'Asia/Sakhalin',
+ #'Asia/Samarkand',
+ #'Asia/Seoul',
+ #'Asia/Shanghai',
+ #'Asia/Singapore',
+ #'Asia/Taipei',
+ #'Asia/Tashkent',
+ #'Asia/Tbilisi',
+ #'Asia/Tehran',
+ #'Asia/Thimphu',
+ #'Asia/Tokyo',
+ #'Asia/Ulaanbaatar',
+ #'Asia/Urumqi',
+ #'Asia/Ust-Nera',
+ #'Asia/Vientiane',
+ #'Asia/Vladivostok',
+ #'Asia/Yakutsk',
+ #'Asia/Yekaterinburg',
+ #'Asia/Yerevan',
+ #'Atlantic/Azores',
+ #'Atlantic/Bermuda',
+ #'Atlantic/Canary',
+ #'Atlantic/Cape_Verde',
+ #'Atlantic/Faroe',
+ #'Atlantic/Madeira',
+ #'Atlantic/Reykjavik',
+ #'Atlantic/South_Georgia',
+ #'Atlantic/St_Helena',
+ #'Atlantic/Stanley',
+ #'Australia/Adelaide',
+ 'Australia/Brisbane',
+ #'Australia/Broken_Hill',
+ #'Australia/Currie',
+ #'Australia/Darwin',
+ #'Australia/Eucla',
+ #'Australia/Hobart',
+ #'Australia/Lindeman',
+ #'Australia/Lord_Howe',
+ #'Australia/Melbourne',
+ 'Australia/Perth',
+ 'Australia/Sydney',
+ #'CET',
+ #'CST6CDT',
+ #'EET',
+ #'EST',
+ #'EST5EDT',
+ 'Europe/Amsterdam',
+ #'Europe/Andorra',
+ #'Europe/Athens',
+ #'Europe/Belgrade',
+ #'Europe/Berlin',
+ #'Europe/Brussels',
+ #'Europe/Bucharest',
+ #'Europe/Budapest',
+ #'Europe/Chisinau',
+ #'Europe/Copenhagen',
+ #'Europe/Dublin',
+ #'Europe/Gibraltar',
+ #'Europe/Helsinki',
+ #'Europe/Istanbul',
+ #'Europe/Kaliningrad',
+ #'Europe/Kiev',
+ #'Europe/Lisbon',
+ 'Europe/London',
+ #'Europe/Luxembourg',
+ #'Europe/Madrid',
+ #'Europe/Malta',
+ #'Europe/Minsk',
+ #'Europe/Monaco',
+ #'Europe/Moscow',
+ #'Europe/Oslo',
+ #'Europe/Paris',
+ #'Europe/Prague',
+ #'Europe/Riga',
+ #'Europe/Rome',
+ #'Europe/Samara',
+ #'Europe/Simferopol',
+ #'Europe/Sofia',
+ #'Europe/Stockholm',
+ #'Europe/Tallinn',
+ #'Europe/Tirane',
+ #'Europe/Uzhgorod',
+ #'Europe/Vienna',
+ #'Europe/Vilnius',
+ #'Europe/Volgograd',
+ #'Europe/Warsaw',
+ #'Europe/Zaporozhye',
+ 'Europe/Zurich',
+ #'HST',
+ #'Indian/Antananarivo',
+ #'Indian/Chagos',
+ #'Indian/Christmas',
+ #'Indian/Cocos',
+ #'Indian/Comoro',
+ #'Indian/Kerguelen',
+ #'Indian/Mahe',
+ #'Indian/Maldives',
+ #'Indian/Mauritius',
+ #'Indian/Mayotte',
+ #'Indian/Reunion',
+ #'MET',
+ #'MST',
+ #'MST7MDT',
+ #'PST8PDT',
+ #'Pacific/Apia',
+ 'Pacific/Auckland',
+ #'Pacific/Chatham',
+ #'Pacific/Chuuk',
+ #'Pacific/Easter',
+ #'Pacific/Efate',
+ #'Pacific/Enderbury',
+ #'Pacific/Fakaofo',
+ #'Pacific/Fiji',
+ #'Pacific/Funafuti',
+ #'Pacific/Galapagos',
+ #'Pacific/Gambier',
+ #'Pacific/Guadalcanal',
+ #'Pacific/Guam',
+ #'Pacific/Honolulu',
+ #'Pacific/Kiritimati',
+ #'Pacific/Kosrae',
+ #'Pacific/Kwajalein',
+ #'Pacific/Majuro',
+ #'Pacific/Marquesas',
+ #'Pacific/Midway',
+ #'Pacific/Nauru',
+ #'Pacific/Niue',
+ #'Pacific/Norfolk',
+ #'Pacific/Noumea',
+ #'Pacific/Pago_Pago',
+ #'Pacific/Palau',
+ #'Pacific/Pitcairn',
+ #'Pacific/Pohnpei',
+ #'Pacific/Port_Moresby',
+ #'Pacific/Rarotonga',
+ #'Pacific/Saipan',
+ #'Pacific/Tahiti',
+ #'Pacific/Tarawa',
+ #'Pacific/Tongatapu',
+ #'Pacific/Wake',
+ #'Pacific/Wallis',
+ 'UTC',
+ #'WET',
+ #}}}
+);
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#
+# Defaults for options
+#
+my $DEF_DEBUG = 0;
+my $DEF_FROM = 'Dave.Morriss@gmail.com';
+my $DEF_TO = 'perloid@autistici.org';
+
+#
+# Options and arguments
+#
+my %options;
+Options( \%options );
+
+#
+# Default help
+#
+pod2usage( -msg => "$PROG version $VERSION\n", -verbose => 0, -exitval => 1 )
+ if ( $options{'help'} );
+
+#
+# Full documentation if requested with -doc
+#
+pod2usage(
+ -msg => "$PROG version $VERSION\n",
+ -verbose => 2,
+ -exitval => 1,
+# -noperldoc => 0,
+) if ( $options{'documentation'} );
+
+#
+# Collect options
+#
+my $DEBUG = ( defined( $options{debug} ) ? $options{debug} : $DEF_DEBUG );
+my $month = $options{month};
+my $mail = ( defined( $options{mail} ) ? $options{mail} : 0 );
+my $from_address = (
+ defined( $options{fromaddress} ) ? $options{fromaddress} : $DEF_FROM );
+my $to_address
+ = ( defined( $options{toaddress} ) ? $options{toaddress} : $DEF_TO );
+my $date = $options{date};
+my $start = $options{starttime};
+my $end = $options{endtime};
+
+# This value is in the configuration file and can't be overridden. The planned
+# end time can be specified however.
+#my $duration = $options{duration};
+
+my $cfgfile
+ = ( defined( $options{config} ) ? $options{config} : $configfile1 );
+my $dbcfgfile
+ = ( defined( $options{dbconfig} ) ? $options{dbconfig} : $configfile2 );
+
+#
+# Use the 'testfile' mailer if option -nomail was chosen. This writes the file
+# 'mailer.testfile' and sends no message
+#
+my $mailertype = ( $mail ? 'sendmail' : 'testfile' );
+
+#
+# Sanity checking the options
+#
+die "Unable to find $cfgfile\n" unless ( -e $cfgfile );
+die "Use only one of -month=MONTH or -date=DATE\n"
+ if (defined($month) && defined($date));
+#die "Use only one of -endtime=TIME or -duration=HOURS\n"
+# if (defined($end) && defined($duration));
+
+#-------------------------------------------------------------------------------
+# Load script and database configuration data
+#-------------------------------------------------------------------------------
+my $conf = new Config::General(
+ -ConfigFile => $cfgfile,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %config = $conf->getall();
+#print Dumper( \%config ), "\n";
+
+#
+# Load database configuration data
+#
+my $dbconf = new Config::General(
+ -ConfigFile => $dbcfgfile,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %dbconfig = $dbconf->getall();
+#print Dumper( \%dbconfig ), "\n";
+
+#
+# Configuration file values with defaults and/or checks
+#
+my $server = $config{email}->{server} // 'chatter.skyehaven.net';
+my $port = $config{email}->{port} // 64738;
+my $room = $config{email}->{room} // 'Hacker Public Radio';
+my $duration = $config{email}->{duration} // 2;
+my $dayname = $config{email}->{dayname} // 'Sunday';
+
+#
+# If we had a start time specified then check it and ensure the end time makes
+# sense. Otherwise use the configuration file defaults, but don't check them.
+#
+if ($start) {
+ #
+ # Check start time and add a seconds field if needed
+ #
+ $start = validate_time($start);
+
+ #
+ # The end time usually needs to be 2 hours from the start if not
+ # specified. The actual duration is specified in the configuration file
+ # (with a default above).
+ #
+ unless ($end) {
+ my @end = split( ':', $start );
+ $end[0] += $duration;
+ $end = join(':',@end);
+ }
+
+ #
+ # Check and add a seconds field if needed
+ #
+ $end = validate_time($end);
+}
+else {
+ $start = $config{email}->{starttime};
+ $end = $config{email}->{endtime};
+}
+
+#
+# Start and end times from options or the configuration file
+#
+my @starttime = split( ':', $start );
+my @endtime = split( ':', $end );
+die "Missing start/end time(s)\n" unless ( @starttime && @endtime );
+
+my $template = $config{email}->{template};
+die "Missing template file $template\n" unless (-e $template);
+
+_debug($DEBUG >= 2,
+ '$start: ' . coalesce($start,''),
+ '$end: ' . coalesce($end,''),
+ '--'
+);
+
+if ($DEBUG >= 1) {
+ report_settings();
+}
+
+#-------------------------------------------------------------------------------
+# Connect to the database
+# 2021-12-24: moved to MariaDB
+# 2022-02-28: the MariaDB driver has gone away apparently. Reverted to MySQL
+# again
+#-------------------------------------------------------------------------------
+my $dbhost = $dbconfig{database}->{host} // '127.0.0.1';
+my $dbport = $dbconfig{database}->{port} // 3306;
+my $dbname = $dbconfig{database}->{name};
+my $dbuser = $dbconfig{database}->{user};
+my $dbpwd = $dbconfig{database}->{password};
+#$dbh = DBI->connect( "DBI:MariaDB:database=$dbname;host=$dbhost;port=$dbport",
+# $dbuser, $dbpwd, { AutoCommit => 1 } )
+# or croak $DBI::errstr;
+
+$dbh = DBI->connect( "dbi:mysql:host=$dbhost;port=$dbport;database=$dbname",
+ $dbuser, $dbpwd, { AutoCommit => 1 } )
+ or die $DBI::errstr;
+
+#
+# Enable client-side UTF8
+#
+$dbh->{mysql_enable_utf8} = 1;
+
+#
+# Date and time values using Date::Calc format
+#
+my @today = Today();
+my @startdate;
+my @startmonth;
+my @reviewdate;
+my $monday = 1; # Day of week number 1-7, Monday-Sunday
+my $offset = day_offset($dayname)->{offset};
+
+#-------------------------------------------------------------------------------
+# Work out the start date from the -date=DATE option, the -month=DATE option
+# or the current date.
+#-------------------------------------------------------------------------------
+#
+# If there's an argument then it'll be an override for the start date
+# otherwise we'll compute it.
+#
+if ( defined($date) ) {
+ #
+ # Parse and perform rudimentary validation on the argument
+ #
+ my @parsed = strptime($date);
+ die "Invalid -date=DATE option '$date'\n"
+ unless ( defined( $parsed[3] )
+ && defined( $parsed[4] )
+ && defined( $parsed[5] ) );
+
+ $parsed[5] += 1900;
+ $parsed[4] += 1;
+ @startdate = @parsed[ 5, 4, 3 ];
+ die "Date is in the past '$date'; aborting\n"
+ unless ( Date_to_Days(@startdate) > Date_to_Days(@today) );
+}
+elsif ( defined($month) ) {
+ #
+ # Parse the month out of the -month=DATE argument
+ #
+ my @parsed = strptime($month);
+ die "Invalid -month=DATE option '$month'\n"
+ unless ( defined( $parsed[3] )
+ && defined( $parsed[4] )
+ && defined( $parsed[5] ) );
+
+ $parsed[5] += 1900;
+ $parsed[4] += 1;
+ @startmonth = @parsed[ 5, 4, 3 ];
+ die "Date is in the past '$month'; aborting\n"
+ unless ( Date_to_Days(@startmonth) > Date_to_Days(@today) );
+
+ #
+ # Compute the next meeting date from now (by finding the next first Monday
+ # of the month then backing up two days to the Saturday).
+ #
+ @startdate = make_date( \@startmonth, $monday, 1, $offset );
+}
+else {
+ #
+ # Compute the next meeting date from now (by finding the next first Monday
+ # of the month then backing up a number of days to the required date).
+ #
+ @startdate = make_date( \@today, $monday, 1, $offset );
+}
+
+_debug($DEBUG >= 2, '@startdate: ' . join(',',@startdate));
+
+#
+# The month being reviewed is sometimes the same month and sometimes the month
+# before.
+#
+if ( $startdate[1] eq $today[1] ) {
+ @reviewdate = @startdate;
+}
+else {
+ @reviewdate = Add_Delta_YM( @startdate, 0, -1 );
+}
+
+_debug($DEBUG >= 2, '@reviewdate: ' . join(',',@reviewdate));
+
+#
+# Transfer Date::Calc values into hashes for initialising DateTime objects so
+# we can play time zone games
+#
+my ( %dtargs, $dtstart, $dtend );
+@dtargs{ 'year', 'month', 'day', 'hour', 'minute', 'second', 'time_zone' }
+ = ( @startdate, @starttime, 'UTC' );
+$dtstart = DateTime->new(%dtargs);
+@dtargs{ 'hour', 'minute', 'second' } = (@endtime);
+$dtend = DateTime->new(%dtargs);
+
+#
+# Compute the number of days until the recording
+#
+my $dtnow = DateTime->now( time_zone => 'UTC' );
+my $dtoffset = $dtstart->delta_days($dtnow);
+my $dtf = DateTime::Format::Duration->new( pattern => '%e' );
+my $days = $dtf->format_duration($dtoffset);
+
+#
+# Formatted dates for the mail message body
+#
+my ( $year, $monthname, $nicedate, $starttime, $endtime ) = (
+ $dtstart->strftime("%Y"), Month_to_Text( $reviewdate[1] ),
+ $dtstart->strftime("%A, %B %d %Y"), $dtstart->strftime("%R (%Z)"),
+ $dtend->strftime("%R (%Z)"),
+);
+
+_debug($DEBUG >= 2,
+ "\$year: $year",
+ "\$monthname: $monthname",
+ "\$nicedate: $nicedate",
+ "\$starttime: $starttime",
+ "\$endtime: $endtime"
+);
+
+#
+# Build the subject line
+#
+my $waittime = ( $days > 6 ? "in $days days" : "next %A" );
+my $next = ( $days > 6 ? '' : 'next ' );
+my $forspec = ( $days > 6 ? "for $monthname " : "" );
+my $subject = $dtstart->strftime(
+ "HPR Community News ${forspec}- $waittime on %FT%TZ");
+
+_debug( $DEBUG >= 2, "\$subject: $subject" );
+
+#
+# Prepare to send mail
+#
+my $mailer = Mail::Mailer->new($mailertype);
+
+#
+# Generate the headers we need
+#
+$mailer->open(
+ { To => $to_address,
+ From => $from_address,
+ Subject => $subject,
+ }
+);
+
+#
+# Build an array of timezone data for the template
+#
+my @timezones;
+for my $tz (@zones) {
+ push( @timezones, storeTZ( $dtstart, $dtend, $tz ) );
+}
+
+#-------------------------------------------------------------------------------
+# Find the number of the show with the notes. Take care because the recording
+# date might not be on the weekend before the show is released.
+#-------------------------------------------------------------------------------
+my $isodate = $dtstart->ymd;
+$sth1 = $dbh->prepare(q{
+ SELECT id FROM eps
+ WHERE date > ?
+ AND date_format(date,"%W") = 'Monday'
+ AND title LIKE 'HPR Community News%'
+ ORDER BY date
+ LIMIT 1
+});
+$sth1->execute($isodate);
+if ( $dbh->err ) {
+ warn $dbh->errstr;
+}
+
+_debug( $DEBUG >= 2, "\$isodate: $isodate" );
+
+unless ( $h1 = $sth1->fetchrow_hashref ) {
+ warn "Unable to find a reserved show on the specified date - cannot continue\n";
+ exit 1;
+}
+
+my $shownotes = $h1->{id};
+
+_debug( $DEBUG >= 2, "\$shownotes (slot): $shownotes" );
+
+$sth1->finish;
+$dbh->disconnect;
+
+#-------------------------------------------------------------------------------
+# Fill the template
+#-------------------------------------------------------------------------------
+my $tt = Template->new(
+ { ABSOLUTE => 1,
+ ENCODING => 'utf8',
+ }
+);
+
+my $vars = {
+# subject => $subject,
+# from => $from_address,
+# to => $to_address,
+ server => $server,
+ port => $port,
+ room => $room,
+ timezones => \@timezones,
+ utc => {
+ days => $days,
+ month => $monthname,
+ year => $year,
+ date => $nicedate,
+ start => $starttime,
+ end => $endtime,
+ },
+ shownotes => $shownotes,
+};
+
+my $document;
+$tt->process( $template,
+ $vars, \$document, { binmode => ':utf8' } )
+ || die $tt->error(), "\n";
+
+#
+# Add the template-generated body to the mail message
+#
+print $mailer $document;
+
+#
+# Send the message
+#
+$mailer->close
+ or die "Couldn't send message: $!\n";
+
+unless ($mail) {
+ print "Message was not sent since -nomail was selected (or defaulted).\n";
+ print "Look in 'mailer.testfile' for the output\n";
+}
+
+exit;
+
+
+#=== FUNCTION ================================================================
+# NAME: report_settings
+# PURPOSE: Report settings from options (or defaults)
+# PARAMETERS: None
+# RETURNS: Nothing
+# DESCRIPTION:
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub report_settings {
+ my $fmt = "D> %-14s = %s\n";
+ print "D> Settings from options or default values:\n";
+ printf $fmt, "Month", coalesce($month,'undef');
+ printf $fmt, "Mail", coalesce($mail,'undef');
+ printf $fmt, "From", coalesce($from_address,'undef');
+ printf $fmt, "To", coalesce($to_address,'undef');
+ printf $fmt, "Meeting date", coalesce($date,'undef');
+ printf $fmt, "Start time", join(':',@starttime);
+ printf $fmt, "End time", join(':',@endtime);
+ printf $fmt, "Config file", coalesce($cfgfile,'undef');
+ printf $fmt, "DB config file", coalesce($dbcfgfile,'undef');
+ printf $fmt, "Server", coalesce($server,'undef');
+ printf $fmt, "Port", coalesce($port,'undef');
+ printf $fmt, "Room", coalesce($room,'undef');
+ printf $fmt, "Template", coalesce($template,'undef');
+ print "D> ----\n";
+}
+
+#=== FUNCTION ================================================================
+# NAME: compute_endtime
+# PURPOSE: Given a start time and a duration computes the end time
+# PARAMETERS: $rdate arrayref for the date
+# $rstime arrayref for the start time
+# $rduration arrayref for the duration [HH,MM,SS]
+# RETURNS: The end time as a string (HH:MM:SS)
+# DESCRIPTION:
+# THROWS: No exceptions
+# COMMENTS: Decided not to implement this, may do so in future.
+# SEE ALSO: N/A
+#===============================================================================
+#sub compute_endtime {
+#}
+
+#=== FUNCTION ================================================================
+# NAME: validate_time
+# PURPOSE: Validates a time in HH:MM:SS format
+# PARAMETERS: $time Time string
+# RETURNS: The input string with any missing fields added in
+# DESCRIPTION: The input time needs to be in the format HH:MM[:SS] where
+# a missing seconds value is replaced with '00'. A regex check
+# on the format is performed, and if that passes a check is made
+# on the time values (using 'check_time').
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub validate_time {
+ my ($time) = (@_);
+
+ if ( defined($time) ) {
+ if ( ( my @fields )
+ = ( $time =~ /(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/ ) )
+ {
+ @fields = map { defined($_) ? sprintf('%02d',$_) : "00" } @fields;
+ $time = join( ':', @fields );
+ }
+ else {
+ die "Invalid time: $time\n";
+ }
+
+ unless ( check_time(split(':',$time)) ) {
+ die "Invalid time: $time\n";
+ }
+ }
+
+ return $time;
+}
+
+#=== FUNCTION ================================================================
+# NAME: make_date
+# PURPOSE: Make the event date for recurrence
+# PARAMETERS: $refdate
+# An arrayref to the reference date array (usually
+# today's date)
+# $dow Day of week for the event date (1-7, 1=Monday)
+# $n The nth day of the week in the given month required
+# for the event date
+# $offset Number of days to offset the computed date
+# RETURNS: The resulting date as a list for Date::Calc
+# DESCRIPTION: We want to compute a simple date with an offset, such as
+# "the Saturday before the first Monday of the month". We do
+# this by computing a pre-offset date (first Monday of month)
+# then apply the offset (Saturday before).
+# THROWS: No exceptions
+# COMMENTS: This function was originally written for my HPR episode on
+# iCalendar.
+# TODO Needs more testing to be considered truly universal
+# SEE ALSO:
+#===============================================================================
+sub make_date {
+ my ( $refdate, $dow, $n, $offset ) = @_;
+
+ #
+ # Compute the required date: the "$n"th day of week "$dow" in the year and
+ # month in @$refdate. This could be a date in the past.
+ #
+ my @date = Nth_Weekday_of_Month_Year( @$refdate[ 0, 1 ], $dow, $n );
+
+ #
+ # If the computed date plus the offset is before the base date advance
+ # a month
+ #
+ if ( Day_of_Year(@date) + $offset < Day_of_Year(@$refdate) ) {
+ #
+ # Add a month and recompute
+ #
+ @date = Add_Delta_YM( @date, 0, 1 );
+ @date = Nth_Weekday_of_Month_Year( @date[ 0, 1 ], $dow, $n );
+ }
+
+ #
+ # Apply the day offset
+ #
+ @date = Add_Delta_Days( @date, $offset ) if $offset;
+
+ #
+ # Return a list
+ #
+ return (@date);
+}
+
+#=== FUNCTION ================================================================
+# NAME: storeTZ
+# PURPOSE: Store start/end times for a timezone
+# PARAMETERS: $start DateTime object containing the starting
+# datetime as UTC
+# $end DateTime object containing the ending datetime
+# as UTC
+# $tz The textual time zone (need this to be valid)
+# RETURNS: A hash containing the timezone name and start and end times
+# DESCRIPTION: Relies on DateTime::TimeZone to do the work to turn a UTC time
+# into a time in a different time zone. Uses the DateTime
+# strftime method to format the dates and times for printing.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub storeTZ {
+ my ( $start, $end, $tz ) = @_;
+
+ my %result;
+
+ #
+ # Adjust time zone
+ #
+ $start->set_time_zone($tz);
+ $end->set_time_zone($tz);
+
+ #
+ # Print time zone and start/end times in that zone
+ #
+ $result{name} = "$tz";
+ $result{start} = $start->strftime("%H:%S %a, %b %d %Y");
+ $result{end} = $end->strftime("%H:%S %a, %b %d %Y");
+
+ return \%result;
+}
+
+#=== FUNCTION ================================================================
+# NAME: printTZ
+# PURPOSE: Print start/end times for a timezone
+# PARAMETERS: $fh File handle for writing
+# $start DateTime object containing the starting
+# datetime as UTC
+# $end DateTime object containing the ending datetime
+# as UTC
+# $tz The textual time zone (need this to be valid)
+# RETURNS: Nothing
+# DESCRIPTION: Relies on DateTime::TimeZone to do the work to turn a UTC time
+# into a time in a different time zone. Uses the DateTime
+# strftime method to format the dates and times for printing.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO:
+#===============================================================================
+sub printTZ {
+ my ( $fh, $start, $end, $tz ) = @_;
+
+ #
+ # Adjust time zone
+ #
+ $start->set_time_zone($tz);
+ $end->set_time_zone($tz);
+
+ #
+ # Print time zone and start/end times in that zone
+ #
+ print $fh "$tz\n";
+ print $fh $start->strftime("Start: %H:%S %a, %b %d %Y\n");
+ print $fh $end->strftime("End: %H:%S %a, %b %d %Y\n\n");
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: day_offset
+# PURPOSE: Given a day name computes day attributes including the
+# (negative) offset in days from the target Monday to the
+# recording date.
+# PARAMETERS: $dayname Name of a day of the week
+# RETURNS: Hashref containing the full day name, the weekday number and
+# the integer offset from Monday to the recording day, or undef.
+# DESCRIPTION: Uses the hash '%matches' keyed by regular expressions matching
+# day names. The argument '$dayname' is matched against each
+# regex in turn and if it matches the sub-hash is returned. This
+# allows the caller to use 'day_offset($dayname)->{dayname}' to
+# get the full name of the day if needed, or the offset as
+# 'day_offset($dayname)->{offset}'. If there is no match
+# 'undef' is returned.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub day_offset {
+ my ( $dayname ) = @_;
+
+ my %matches = (
+ qr{^(?i)Sun(day)?$} => {
+ dayname => 'Sunday',
+ wday => 7,
+ offset => -1,
+ },
+ qr{^(?i)Sat(urday)?$} => {
+ dayname => 'Saturday',
+ wday => 6,
+ offset => -2,
+ },
+ qr{^(?i)Fri(day)?$} => {
+ dayname => 'Friday',
+ wday => 5,
+ offset => -3,
+ },
+ qr{^(?i)Thu(rsday)?$} => {
+ dayname => 'Thursday',
+ wday => 4,
+ offset => -4,
+ },
+ qr{^(?i)Wed(nesday)?$} => {
+ dayname => 'Wednesday',
+ wday => 3,
+ offset => -5,
+ },
+ qr{^(?i)Tue(sday)?$} => {
+ dayname => 'Tuesday',
+ wday => 2,
+ offset => -6,
+ },
+ qr{^(?i)Mon(day)?$} => {
+ dayname => 'Monday',
+ wday => 1,
+ offset => -7,
+ },
+ );
+ my $match;
+
+ foreach my $re (keys(%matches)) {
+ if ($dayname =~ $re) {
+ $match = $matches{$re};
+ last;
+ }
+ }
+
+ return $match;
+}
+
+#=== FUNCTION ================================================================
+# NAME: _debug
+# PURPOSE: Prints debug reports
+# PARAMETERS: $active Boolean: 1 for print, 0 for no print
+# $messages... Arbitrary list of messages to print
+# RETURNS: Nothing
+# DESCRIPTION: Outputs messages if $active is true. It removes any trailing
+# newline from each one and then adds one in the 'print' to the
+# caller doesn't have to bother. Prepends each message with 'D>'
+# to show it's a debug message.
+# THROWS: No exceptions
+# COMMENTS: Differs from other functions of the same name
+# SEE ALSO: N/A
+#===============================================================================
+sub _debug {
+ my $active = shift;
+
+ my $message;
+ return unless $active;
+
+ while ($message = shift) {
+ chomp($message);
+ print STDERR "D> $message\n";
+ }
+}
+
+#=== FUNCTION ================================================================
+# NAME: coalesce
+# PURPOSE: To find the first defined argument and return it
+# PARAMETERS: Arbitrary number of arguments
+# RETURNS: The first defined argument or undef if there are none
+# DESCRIPTION:
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub coalesce {
+ foreach (@_) {
+ return $_ if defined($_);
+ }
+ return undef; ## no critic
+}
+
+#=== FUNCTION ================================================================
+# NAME: Options
+# PURPOSE: Processes command-line options
+# PARAMETERS: $optref Hash reference to hold the options
+# RETURNS: Undef
+# DESCRIPTION:
+# THROWS: no exceptions
+# COMMENTS: none
+# SEE ALSO: n/a
+#===============================================================================
+sub Options {
+ my ($optref) = @_;
+
+ my @options = (
+ "help", "documentation|man",
+ "debug=i", "mail!",
+ "fromaddress=s", "toaddress=s",
+ "date=s", "starttime=s",
+ "endtime=s", "month=s",
+ "config=s", "dbconfig=s",
+ );
+ # "duration=s",
+
+ if ( !GetOptions( $optref, @options ) ) {
+ pod2usage( -msg => "Version $VERSION\n", -verbose => 0, -exitval => 1 );
+ }
+
+ return;
+}
+
+__END__
+
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+# Application Documentation
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+#{{{
+
+=head1 NAME
+
+make_email - generates an HPR Community News recording invitation email
+
+=head1 VERSION
+
+This documentation refers to make_email version 0.2.7
+
+
+=head1 USAGE
+
+ make_email [-help] [-documentation] [-debug=N] [-month=DATE] [-[no]mail]
+ [-from=FROM_ADDRESS] [-to=TO_ADDRESS] [-date=DATE] [-start=START_TIME]
+ [-end=END_TIME] [-config=FILE] [-dbconfig=FILE]
+
+ ./make_email -dbconf=$HOME/HPR/.hpr_livedb.cfg -date=2022-12-27
+
+=head1 OPTIONS
+
+=over 8
+
+=item B<-help>
+
+Prints a brief help message describing the usage of the program, and then exits.
+
+=item B<-documentation> B<-man>
+
+Prints the entire embedded documentation for the program, then exits.
+
+Another way to see the full documentation use:
+
+B
+
+=item B<-debug=N>
+
+Enables debugging mode when N > 0 (zero is the default, no debugging output).
+The levels are:
+
+Values are:
+
+=over 4
+
+=item 1
+
+Reports all of the settings taken from the configuration file, the provided
+command line options or their default values. The report is generated early on
+in the processing of these values. Use B<-debug=2> for information about the
+next stages.
+
+=item 2
+
+Reports the following (as well as the data for level 1):
+
+=over 4
+
+=item .
+
+Details of the start date chosen
+
+=item .
+
+Details of the year, name of month, readable date, and recording start and end
+times.
+
+=item .
+
+The subject line chosen for the email.
+
+=item .
+
+The date of the show being searched for in the database.
+
+=item .
+
+The number of the show found in the database.
+
+=back
+
+=back
+
+=item B<-month=DATE>
+
+Defines the month for which the email will be generated using a date in that
+month. Normally (without this option) the current month is chosen and the date
+of recording computed within it. The month specified here is provided as
+a ISO8601 date such as 2014-03-08 (meaning March 2014) or 1-Jan-2017 (meaning
+January 2017). Only the year and month parts are used but a valid day must be
+present.
+
+=item B<-[no]mail>
+
+** NOTE ** The sending of mail does not work at present, and B<-nomail> should
+always be used.
+
+Causes mail to be sent (B<-mail>) or not sent (B<-nomail>). If the mail is
+sent then it is sent via the local MTA (in the assumption that there is one).
+If this option is omitted, the default is B<-nomail>, in which case the
+message is appended to the file B in the current directory.
+
+=item B<-from=FROM_ADDRESS>
+
+** NOTE ** The sending of mail does not work at present.
+
+This option defines the address from which the message is to be sent. This
+address is used in the message header; the message envelope will contain the
+I sender.
+
+=item B<-to=TO_ADDRESS>
+
+** NOTE ** The sending of mail does not work at present.
+
+This option defines the address to which the message is to be sent.
+
+=item B<-date=DATE>
+
+This is an option provides a non-default date for the recording. Normally the
+script computes the next scheduled date based on the algorithm "DAY_OF_WEEK
+before the first Monday of the next month" (where DAY_OF_WEEK is the value
+defined in the configuration file as B) starting from the current
+date or the start of the month given in the B<-month=DATE> option. If for any
+reason a different date is required then this may be specified via this
+option.
+
+The recording date should be given as an ISO8601 date (such as 2014-03-08).
+
+=item B<-start=START_TIME>
+
+The default start time is defined in the configuration file, but if it is
+necessary to change it, this option can be used to do it. The B
+value must be a valid B time specification.
+
+A change to the start time in the configuration file also implies that the end
+time should change. If the B<-start=START_TIME> option is present but
+B<-end=END_TIME> is not, then the end time is computed by adding a number of
+hours to the start time this number is defined in the configuration file as
+B.
+
+=item B<-end=END_TIME>
+
+The default end time is defined in the configuration file, but if it is
+necessary to change it temporarily, this option can be used to do it. The
+B value must be a valid B time specification.
+
+=item B<-config=FILE>
+
+This option defines a configuration file other than the default
+B<.make_email.cfg>. The file must be formatted as described below in the
+section I.
+
+=item B<-dbconfig=FILE>
+
+This option defines a database configuration file other than the default
+B<.hpr_db.cfg>. The file must be formatted as described below in the section
+I.
+
+The default file is configured to open a local copy of the HPR database. An
+alternative is B<.hpr_livedb.cfg> which assumes an SSH tunnel to the live
+database and attempts to connect to it. Use the script I to open
+the SSH tunnel.
+
+=back
+
+=head1 DESCRIPTION
+
+Makes and sends(*) an invitation email for the next Community News with times per
+timezone. The message is structured by a Template Toolkit template, so its
+content can be adjusted without changing this script.
+
+In normal operation the script computes the date of the next recording using
+the algorithm "Saturday before the first Monday of the next month" starting
+from the current date or the start of the month (and year) given in the
+B<-month=DATE> option.
+
+It uses the recording date (B<-date=DATE> option) to access the MySQL database
+to find the date on which the show will be released. It does that so the notes
+on that show can be viewed by the volunteers recording the show. These notes
+are expanded to be usable during the recording, with comments relating to
+earlier shows being displayed in full, and any comments missed in the last
+recording highlighted. Comments made to shows during the past month can be
+seen as the shows are visited and discussed.
+
+The email generated by the script is sent to the HPR mailing list, usually on
+the Monday prior to the weekend of the recording.
+
+Notes:
+* Mail sending does not work at present.
+
+=head1 DIAGNOSTICS
+
+=over 8
+
+=item B
+
+The configuration file specified in B<-config=FILE> (or the default file)
+could not be found.
+
+=item B
+
+These options are mutually exclusive. See their specifications earlier in this
+document.
+
+=item B
+
+One or both of the start and end times is missing, either from the configuration file or
+from the command line options.
+
+=item B
+
+The template file specified in the configuration file could not be found.
+
+=item B
+
+The program can generate warning messages from the database.
+
+=item B
+
+An invalid date has been supplied via this option.
+
+=item B
+
+The date specified in B<-date=DATE> is in the past.
+
+=item B
+
+An invalid date has been supplied via this option.
+
+=item B
+
+The month specified in B<-month=DATE> is in the past.
+
+=item B
+
+The program can generate warning messages from the Template.
+
+=item B
+
+The email mesage has been constructed but could not be sent. See the error
+returned by the mail subsystem for more information.
+
+=back
+
+=head1 CONFIGURATION AND ENVIRONMENT
+
+=head2 EMAIL CONFIGURATION
+
+The program obtains the settings it requires for preparing the email from
+a configuration file, which by default is called B<.make_email.cfg>. This file
+needs to contain the following data:
+
+
+ server = MUMBLE_SERVER_NAME
+ port = MUMBLE_PORT
+ room = NAME_OF_ROOM
+ dayname = DAY_OF_WEEK_OF_RECORDING
+ starttime = 18:00:00
+ endtime = 20:00:00
+ duration = 02 # hours
+ template = NAME_OF_TEMPLATE
+
+
+=head2 DATABASE CONFIGURATION
+
+The program obtains the credentials it requires for connecting to the HPR
+database by loading them from a configuration file. The default file is called
+B<.hpr_db.cfg> and should contain the following data:
+
+
+ host = 127.0.0.1
+ port = PORT
+ name = DBNAME
+ user = USER
+ password = PASSWORD
+
+
+The file B<.hpr_livedb.cfg> should be available to allow access to the
+database over an SSH tunnel which has been previously opened.
+
+=head1 DEPENDENCIES
+
+ DBI
+ Date::Calc
+ Date::Parse
+ DateTime
+ DateTime::Format::Duration
+ DateTime::TimeZone
+ Getopt::Long
+ Mail::Mailer
+ Pod::Usage
+ Template
+
+=head1 BUGS AND LIMITATIONS
+
+There are no known bugs in this script.
+Please report problems to Dave Morriss (Dave.Morriss@gmail.com)
+Patches are welcome.
+
+=head1 AUTHOR
+
+Dave Morriss (Dave.Morriss@gmail.com) 2013 - 2024
+
+=head1 LICENCE AND COPYRIGHT
+
+Copyright (c) Dave Morriss (Dave.Morriss@gmail.com). All rights reserved.
+
+This program is free software. You can redistribute it and/or modify it under
+the same terms as perl itself.
+
+=cut
+
+#}}}
+
+# [zo to open fold, zc to close]
+
+# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
diff --git a/Community_News/make_meeting b/Community_News/make_meeting
new file mode 100755
index 0000000..e792790
--- /dev/null
+++ b/Community_News/make_meeting
@@ -0,0 +1,484 @@
+#!/usr/bin/env perl
+#===============================================================================
+#
+# FILE: make_meeting
+#
+# USAGE: ./make_meeting
+#
+# DESCRIPTION: Makes a recurrent iCalendar meeting to be loaded into
+# a calendar. This is apparently necessary when the 'RRULE'
+# recurrence description is not adequate.
+#
+# OPTIONS: None
+# REQUIREMENTS: Needs modules Getopt::Long, Data::ICal, Date::Parse and
+# Date::Calc
+# BUGS: ---
+# NOTES: Based on a script distributed with the HPR episode "iCalendar
+# Hacking"
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# LICENCE: Copyright (c) year 2012-2024 Dave Morriss
+# VERSION: 0.2.2
+# CREATED: 2012-10-13 15:34:01
+# REVISION: 2024-05-24 22:45:56
+#
+#===============================================================================
+
+use 5.010;
+use strict;
+use warnings;
+
+use Getopt::Long;
+
+use Data::ICal;
+use Data::ICal::Entry::Event;
+use Data::ICal::Entry::Todo;
+
+use Date::Parse;
+use Date::Calc qw{:all};
+use Date::ICal;
+
+#use Data::Dumper;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.2.2';
+
+#
+# Script name
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+( my $DIR = $0 ) =~ s|/?[^/]*$||mx;
+$DIR = '.' unless $DIR;
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+my ( @startdate, @rdate, @events );
+
+#
+# Attributes for the calendar message
+#
+#my $server = 'ch1.teamspeak.cc';
+#my $port = 64747;
+my $server = 'chatter.skyehaven.net';
+my $port = 64738;
+
+#-------------------------------------------------------------------------------
+# Options and arguments
+#-------------------------------------------------------------------------------
+my $DEF_COUNT = 12;
+#my $DEF_SUMMARY = 'Send out CNews email';
+
+#
+# Process options
+#
+my %options;
+Options( \%options );
+
+#
+# Default help
+#
+usage() if ( $options{'help'} );
+
+#
+# Collect options
+#
+my $count = ( defined( $options{count} ) ? $options{count} : $DEF_COUNT );
+my $reminder = ( defined( $options{reminder} ) ? $options{reminder} : 0 );
+my $force = ( defined( $options{force} ) ? $options{force} : 0 );
+
+#my $reminder_summary = ( defined( $options{summary} ) ? $options{summary} :
+# $DEF_SUMMARY );
+
+#
+# Two reminders: 8 days ahead reminder to check with Ken, 5 days ahead
+# reminder to send out the email.
+#
+my %reminders = (
+ email => [ -5, 'Send out CNews email' ],
+ check => [ -8, 'Check CNews date with Ken' ],
+);
+
+#
+# Use the date provided or the default
+#
+if ( defined( $options{from} ) ) {
+ #
+ # Parse the date, convert to start of month and (optionally) validate it
+ #
+ @startdate = convert_date( $options{from}, $force );
+}
+else {
+ #
+ # Use the current date
+ #
+ @startdate = Today();
+}
+
+#
+# Date and time values
+#
+# TODO: These should be in a configuration file, and should ideally be capable
+# of having a time zone defined (default UTC, as now).
+#
+my $monday = 1; # Day of week number 1-7, Monday-Sunday
+
+my @starttime = ( 13, 00, 00 ); # UTC
+my @endtime = ( 15, 00, 00 );
+
+my @todostart = ( 9, 00, 00 ); # UTC
+my @todoend = ( 17, 00, 00 );
+
+#
+# Format of an ISO UTC datetime
+#
+my $fmt = "%02d%02d%02dT%02d%02d%02dZ";
+
+#
+# Constants for the event
+#
+my $calname = 'HPR Community News';
+my $timezone = 'UTC';
+my $location = "$server port: $port";
+my $summary = 'HPR Community News Recording Dates';
+my $description = <new();
+
+#
+# Some calendar properties
+#
+$calendar->add_properties(
+ 'X-WR-CALNAME' => $calname,
+ 'X-WR-TIMEZONE' => $timezone,
+);
+
+#
+# Create the event object
+#
+my $vevent = Data::ICal::Entry::Event->new();
+
+#
+# Add some event properties
+#
+$vevent->add_properties(
+ summary => $summary,
+ location => $location,
+ description => $description,
+ dtstart => sprintf( $fmt, @startdate, @starttime ),
+ dtend => sprintf( $fmt, @startdate, @endtime ),
+);
+
+#
+# Add recurring dates. (Note that this generates RDATE entries rather than
+# 1 entry with multiple dates; this is because this module doesn't seem to
+# have the ability to generate the concatenated entry. The two modes of
+# expressing the repeated dates seem to be equivalent.)
+#
+for my $i ( 1 .. $count ) {
+ #
+ # Recording date computation from the start of the month
+ #
+ @rdate = make_date( \@rdate, $monday, 1, -1 );
+
+ #
+ # Save the current recording date to make an array of arrayrefs
+ #
+ push( @events, [@rdate] );
+
+ #
+ # Add this date to the multi-date event
+ #
+ $vevent->add_property( rdate =>
+ [ sprintf( $fmt, @rdate, @starttime ), { value => 'DATE-TIME' } ],
+ );
+
+ #
+ # Increment the meeting date for the next one. If we're early in the month
+ # by one day otherwise to the beginning of the next month. This is
+ # necessary because otherwise make_date will skip months.
+ #
+ if ( $rdate[2] < 7 ) {
+ @rdate = Add_Delta_Days( @rdate, 1 );
+ }
+ else {
+ @rdate = ( ( Add_Delta_YM( @rdate, 0, 1 ) )[ 0 .. 1 ], 1 );
+ }
+}
+
+#
+# Add the event into the calendar
+#
+$calendar->add_entry($vevent);
+
+#
+# Are we to add reminders?
+#
+if ($reminder) {
+ #
+ # Loop through the cache of recording dates
+ #
+ for my $i ( 0 .. $count - 1 ) {
+ #
+ # Loop through the reminders hash
+ #
+ for my $key (keys(%reminders)) {
+ #
+ # A new Todo entry each iteration
+ #
+ my $vtodo = Data::ICal::Entry::Todo->new();
+
+ #
+ # Get a recording date from the cache and subtract 5 days from it to
+ # get the preceding Monday
+ #
+ @rdate = @{ $events[$i] };
+ @rdate = Add_Delta_Days( @rdate, $reminders{$key}->[0] );
+
+ #
+ # Add the date as the date part of the Todo
+ #
+ $vtodo->add_properties(
+ summary => $reminders{$key}->[1],
+ status => 'INCOMPLETE',
+ dtstart => Date::ICal->new(
+ ical => sprintf( $fmt, @rdate, @todostart )
+ )->ical,
+ due => Date::ICal->new(
+ ical => sprintf( $fmt, @rdate, @todoend )
+ )->ical,
+ );
+
+ #
+ # Add to the calendar
+ #
+ $calendar->add_entry($vtodo);
+
+ }
+ }
+
+}
+
+#
+# Print the result
+#
+print $calendar->as_string;
+
+exit;
+
+#=== FUNCTION ================================================================
+# NAME: convert_date
+# PURPOSE: Convert a textual date (ideally YYYY-MM-DD) to a Date::Calc
+# date for the start of the given month.
+# PARAMETERS: $textdate date in text form
+# $force Boolean defining whether to skip validating
+# the date
+# RETURNS: The start of the month in the textual date in Date::Calc
+# format
+# DESCRIPTION: Parses the date string and makes a Date::Calc date from the
+# result where the day part is 1. Optionally checks that the
+# date isn't in the past, though $force = 1 ignores this check.
+# THROWS: No exceptions
+# COMMENTS: Requires Date::Calc and Date::Parse
+# Note the validation 'die' has a non-generic message
+# SEE ALSO: N/A
+#===============================================================================
+sub convert_date {
+ my ( $textdate, $force ) = @_;
+
+ my ( @today, @parsed, @startdate );
+
+ #
+ # Reference date
+ #
+ @today = Today();
+
+ #
+ # Parse and perform rudimentary validation on the $textdate date. Function
+ # 'strptime' returns "($ss,$mm,$hh,$day,$month,$year,$zone,$century)".
+ #
+ # The Date::Calc date $startdate[0] gets the returned year or the current
+ # year if no year was parsed, $startdate[1] gets the parsed month or the
+ # current month if no month was parsed, and $startdate[2] gets a day of 1.
+ #
+ @parsed = strptime($textdate);
+ die "Unable to parse date '$textdate'\n" unless @parsed;
+
+ @startdate = (
+ ( defined( $parsed[5] ) ? $parsed[5] + 1900 : $today[0] ), # year
+ ( defined( $parsed[4] ) ? $parsed[4] + 1 : $today[1] ), 1
+ );
+
+ #
+ # Unless we've overridden the check there should be a positive or zero
+ # difference in days between the target date and today's date to prevent
+ # going backwards in time.
+ #
+ unless ($force) {
+ unless ( Delta_Days( @today[ 0, 1 ], 1, @startdate ) ge 0 ) {
+ warn "Invalid date $textdate (in the past)\n";
+ die "Use -force to create a back-dated calendar\n";
+ }
+ }
+
+ return @startdate;
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: make_date
+# PURPOSE: Make the event date for recurrence
+# PARAMETERS: $refdate An arrayref to the reference date array
+# (usually today's date)
+# $dow Day of week for the event date (1-7, 1=Monday)
+# $n The nth day of the week ($dow) in the given
+# month required for the event date ($dow=1,
+# $n=1 means first Monday)
+# $offset Number of days to offset the computed date
+# RETURNS: The resulting date as a list for Date::Calc
+# DESCRIPTION: We want to compute a simple date with an offset, such as
+# "the Sunday before the first Monday of the month". We do
+# this by computing a pre-offset date (first Monday of month)
+# then apply the offset (Sunday before).
+# THROWS: No exceptions
+# COMMENTS: TODO Needs more testing to be considered truly universal
+# SEE ALSO:
+#===============================================================================
+sub make_date {
+ my ( $refdate, $dow, $n, $offset ) = @_;
+
+ #
+ # Compute the required date: the "$n"th day of week "$dow" in the year and
+ # month in @$refdate. This could be a date in the past.
+ #
+ my @date = Nth_Weekday_of_Month_Year( @$refdate[ 0, 1 ], $dow, $n );
+
+ #
+ # If the computed date plus the offset is before the base date advance
+ # a month
+ #
+ if ( Day_of_Year(@date) + $offset < Day_of_Year(@$refdate) ) {
+ #
+ # Add a month and recompute
+ #
+ @date = Add_Delta_YM( @date, 0, 1 );
+ @date = Nth_Weekday_of_Month_Year( @date[ 0, 1 ], $dow, $n );
+ }
+
+ #
+ # Apply the day offset
+ #
+ @date = Add_Delta_Days( @date, $offset ) if $offset;
+
+ #
+ # Return a list
+ #
+ return (@date);
+}
+
+#=== FUNCTION ================================================================
+# NAME: ISO8601_Date
+# PURPOSE: Format a Date::Calc date in ISO8601 format
+# PARAMETERS: @date - a date in the Date::Calc format
+# RETURNS: Text string containing a YYYY-MM-DD date
+# DESCRIPTION: Just a convenience to allow a simple call like
+# $str = ISO8601_Date(@date)
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub ISO8601_Date {
+ my (@date) = (@_)[ 0, 1, 2 ];
+
+ if ( check_date(@date) ) {
+ return sprintf( "%04d-%02d-%02d", @date );
+ }
+ else {
+ return "*Invalid Date*";
+ }
+}
+
+#=== FUNCTION ================================================================
+# NAME: usage
+# PURPOSE: Display a usage message and exit
+# PARAMETERS: None
+# RETURNS: To command line level with exit value 1
+# DESCRIPTION: Builds the usage message using global values
+# THROWS: no exceptions
+# COMMENTS: none
+# SEE ALSO: n/a
+#===============================================================================
+sub usage {
+ print STDERR <use_html_entities; # Use HTML::Entities in the template
+
+use HTML::Entities;
+
+use DBI;
+
+use Data::Dumper;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.2.2';
+
+#
+# Various constants
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+#
+# Constants and other declarations
+#
+my $basedir = "$ENV{HOME}/HPR/Community_News";
+my $configfile = "$basedir/.hpr_db.cfg";
+my $bpfile = "$basedir/shownote_template.tpl";
+
+my $title_template = 'HPR Community News for %s %s';
+
+#
+# Needed to allow an older episode to have its notes regenerated. This is an
+# 'apg'-generated password which is just hard to remember and requires some
+# thought to use. The idea is to prevent older shownote rewriting by accident.
+#
+my $interlock_password = 'lumRacboikac';
+my $interlock_enabled = 0;
+
+my ( $dbh, $sth1, $h1 );
+my ( @startdate, $hosts, $shows );
+my ( @dc_lr, $dt_lr, @dc_lm, $dt_lm );
+my ( $t_days, $missed_comments, $missed_count );
+my ( $comments, $comment_count, $past_count, $ignore_count );
+my ( %past, %current );
+
+#
+# The normal recording time (UTC). Any change should be copied in the POD
+# documentation below.
+# TODO: Should this be in a configuration file?
+#
+my @deftime = (15, 00, 00);
+
+#
+# Earliest comment release time
+#
+my @releasetime = (19, 00, 00);
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#-------------------------------------------------------------------------------
+# Options and arguments
+#-------------------------------------------------------------------------------
+#
+# Option defaults
+#
+my $DEFDEBUG = 0;
+
+#
+# Process options
+#
+my %options;
+Options( \%options );
+
+#
+# Default help is just the USAGE section
+#
+pod2usage( -msg => "$PROG version $VERSION\n", -verbose => 0, -exitval => 1 )
+ if ( $options{'help'} );
+
+#
+# Full documentation if requested with -documentation
+#
+pod2usage( -msg => "$PROG version $VERSION\n", -verbose => 2, -exitval => 1 )
+ if ( $options{'documentation'} );
+
+#
+# Collect options
+#
+my $DEBUG = ( defined( $options{debug} ) ? $options{debug} : $DEFDEBUG );
+my $cfgfile
+ = ( defined( $options{config} ) ? $options{config} : $configfile );
+my $silent = ( defined( $options{silent} ) ? $options{silent} : 0 );
+my $show_comments
+ = ( defined( $options{comments} ) ? $options{comments} : 0 );
+my $mark_comments
+ = ( defined( $options{markcomments} ) ? $options{markcomments} : 0 );
+my $ctext = ( defined( $options{ctext} ) ? $options{ctext} : 0 );
+my $lastrecording = $options{lastrecording};
+my $outfile = $options{out};
+my $episode = $options{episode};
+my $overwrite = ( defined( $options{overwrite} ) ? $options{overwrite} : 0 );
+my $template
+ = ( defined( $options{template} ) ? $options{template} : $bpfile );
+my $mailnotes = $options{mailnotes};
+my $aobfile = $options{anyotherbusiness};
+my $interlock = $options{interlock};
+
+#
+# Sanity checks
+#
+die "Unable to find configuration file $cfgfile\n" unless ( -e $cfgfile );
+if ( defined($episode) ) {
+ if ( $episode =~ /^\d+$/ ) {
+ die "Episode number must be greater than zero\n"
+ unless ( $episode > 0 );
+ }
+ else {
+ die "Episode must be a number or 'auto'\n"
+ unless ( $episode eq 'auto' );
+ }
+}
+
+die "Error: Unable to find template $template\n" unless -r $template;
+
+#
+# We accept '-mailnotes' meaning we want to use a default set of mail notes,
+# or '-mailnotes=FILE' which means the notes are in a file for inclusion. If
+# the option is omitted then we don't include mail notes (and the template is
+# expected to do the right thing).
+#
+if (defined($mailnotes)) {
+ if ($mailnotes =~ /^$/) {
+ #
+ # The default mail inclusion is provided in a named BLOCK directive in
+ # the template. The name is hard-wired here
+ #
+ # FIXME: there's a dependency between the script and the template here
+ # which is inflexible.
+ #
+ $mailnotes = 'default_mail';
+ }
+ else {
+ die "Error: Unable to find includefile '$mailnotes'\n" unless -r $mailnotes;
+ }
+}
+
+#
+# The -anyotherbusiness=FILE or -aob=FILE options provide an HTML file to be
+# added to the end of the notes.
+#
+if (defined($aobfile)) {
+ die "Error: Unable to find includefile '$aobfile'\n" unless -r $aobfile;
+}
+
+#
+# Use the date provided or the default
+#
+if ( defined( $options{from} ) ) {
+ #
+ # Parse and perform rudimentary validation on the -from option
+ #
+# my @parsed = strptime( $options{from} );
+# die "Invalid -from=DATE option '$options{from}'\n"
+# unless ( defined( $parsed[3] )
+# && defined( $parsed[4] )
+# && defined( $parsed[5] ) );
+#
+# $parsed[5] += 1900;
+# $parsed[4] += 1;
+# @startdate = @parsed[ 5, 4, 3 ];
+ @startdate = parse_to_dc($options{from}, undef);
+}
+else {
+ #
+ # Default to the current date
+ #
+ @startdate = Today();
+}
+
+#
+# If -interlock=PASSWORD was provided is the password valid?
+#
+if ( defined($interlock) ) {
+ $interlock_enabled = $interlock eq $interlock_password;
+ emit( $silent, "Interlock ",
+ ( $interlock_enabled ? "accepted" : "rejected" ), "\n" );
+}
+
+#-------------------------------------------------------------------------------
+# Configuration file - load data
+#-------------------------------------------------------------------------------
+emit( $silent, "Configuration file: ", $cfgfile, "\n" );
+my $conf = Config::General->new(
+ -ConfigFile => $cfgfile,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1,
+);
+my %config = $conf->getall();
+
+#-------------------------------------------------------------------------------
+# Date setup
+#-------------------------------------------------------------------------------
+#
+# Transfer Date::Calc values into a hash for initialising a DateTime object.
+# Force the day to 1
+#
+my ( @sd, $dt );
+@sd = ( @startdate, 0, 0, 0 );
+$sd[2] = 1;
+$dt = dc_to_dt(\@sd);
+
+emit( $silent, "Start of month: ", $dt->ymd, "\n" );
+
+#-------------------------------------------------------------------------------
+# Connect to the database
+#-------------------------------------------------------------------------------
+my $dbhost = $config{database}->{host} // '127.0.0.1';
+my $dbport = $config{database}->{port} // 3306;
+my $dbname = $config{database}->{name};
+my $dbuser = $config{database}->{user};
+my $dbpwd = $config{database}->{password};
+$dbh = DBI->connect( "dbi:mysql:host=$dbhost;port=$dbport;database=$dbname",
+ $dbuser, $dbpwd, { AutoCommit => 1 } )
+ or croak $DBI::errstr;
+
+#
+# Enable client-side UTF8
+#
+$dbh->{mysql_enable_utf8} = 1;
+
+#
+# Set the local timezone to UTC for this connection
+#
+$dbh->do("set time_zone = '+00:00'") or carp $dbh->errstr;
+
+#-------------------------------------------------------------------------------
+# Open the output file (or STDOUT) - we may need the date to do it
+#-------------------------------------------------------------------------------
+my $outfh;
+if ($outfile) {
+ $outfile
+ = sprintf( $outfile, sprintf( "%d-%02d", $dt->year, $dt->month ) )
+ if ( $outfile =~ /%s/ );
+ emit( $silent, "Output: ", $outfile, "\n" );
+
+ open( $outfh, ">:encoding(UTF-8)", $outfile )
+ or croak "Unable to open $outfile for writing: $!";
+}
+else {
+ open( $outfh, ">&", \*STDOUT )
+ or croak "Unable to initialise for writing: $!";
+}
+
+#-------------------------------------------------------------------------------
+# Check the episode specification if given
+#-------------------------------------------------------------------------------
+if ( defined($episode) ) {
+ my $title = sprintf( $title_template, $dt->month_name, $dt->year );
+
+ emit( $silent, "\n" );
+
+ #
+ # Is it a number?
+ #
+ if ( $episode =~ /^\d+$/ ) {
+ emit( $silent, "Writing to numbered episode option selected\n" );
+
+ #
+ # Does the number exist in the database?
+ #
+ $sth1 = $dbh->prepare(q{SELECT * FROM eps WHERE id = ?});
+ $sth1->execute($episode);
+ if ( $dbh->err ) {
+ carp $dbh->errstr;
+ }
+ if ( $h1 = $sth1->fetchrow_hashref() ) {
+ #
+ # Episode exists, do more checks
+ #
+ emit( $silent, "Found episode $episode\n" );
+ emit( $silent, "Title: ", $h1->{title}, "\n" );
+ die "Error: wrong show selected\n"
+ unless ( $h1->{title} eq $title );
+
+ unless (validate_date($h1->{date}) || $interlock_enabled) {
+ die "Error: show $episode has a date in the past\n";
+ }
+
+ unless ($overwrite) {
+ die "Error: show $episode already has notes\n"
+ unless length( $h1->{notes} ) == 0;
+ }
+ }
+ else {
+ die "Error: episode $episode does not exist in the database\n";
+ }
+ }
+ else {
+ #
+ # The required episode is 'auto' (we already checked). Now we actually
+ # find the episode number corresponding to the month we're processing.
+ # We do this by searching for the relevant title in the database.
+ #
+ emit( $silent, "Searching for show title: '$title'\n" );
+ $sth1 = $dbh->prepare(q{SELECT * FROM eps WHERE title = ?});
+ $sth1->execute($title);
+ if ( $dbh->err ) {
+ carp $dbh->errstr;
+ }
+ if ( $h1 = $sth1->fetchrow_hashref() ) {
+ #
+ # Found the episode by title
+ #
+ $episode = $h1->{id};
+ emit( $silent, "Found episode $episode\n" );
+ emit( $silent, "Title: ", $h1->{title}, "\n" );
+
+ unless (validate_date($h1->{date}) || $interlock_enabled) {
+ die "Error: show $episode has a date in the past\n";
+ }
+
+ unless ($overwrite) {
+ die "Error: show $episode already has notes\n"
+ unless length( $h1->{notes} ) == 0;
+ }
+ }
+ else {
+ die 'Error: Unable to find an episode '
+ . "for the selected month's notes\n";
+ }
+ }
+}
+
+#-------------------------------------------------------------------------------
+# If asked (-comments -markcomments) compute the last recording date
+#-------------------------------------------------------------------------------
+if ($show_comments && $mark_comments) {
+ #
+ # We're marking comments so need to find the date of the recording of the
+ # last show.
+ #
+ # It's possible to specify date and time of the last recording via option
+ # '-lastrecording=DATETIME' (in case we recorded early or something), but
+ # it needs to be parsed.
+ #
+ if ( defined( $options{lastrecording} ) ) {
+ #
+ # Parse and perform rudimentary validation on the -lastrecording option
+ #
+ @dc_lr = parse_to_dc( $options{lastrecording}, \@deftime );
+
+ }
+ else {
+ #
+ # Otherwise we assume the recording was on a Saturday and compute when
+ # that was. We get back the date and time of the recording as
+ # a Date::Calc object.
+ #
+ @dc_lr = find_last_recording( \@startdate, \@deftime );
+ }
+
+ #
+ # Convert the D::C datetime to a DateTime object
+ #
+ $dt_lr = dc_to_dt(\@dc_lr);
+
+ # Report it for checking (since this algorithm is complex)
+ emit(
+ $silent,
+ sprintf("* %-100s *\n",
+ ( defined( $options{lastrecording} ) ? 'Given' : 'Found' ) .
+ ' last recording date on ' .
+ $dt_lr->datetime . ' time zone ' . $dt_lr->strftime('%Z') .
+ ' (' . $dt_lr->epoch . ')'
+ )
+ );
+
+ #
+ # Also, we need to know the last month for comment marking
+ #
+ @dc_lm = find_last_month(\@startdate);
+ $dt_lm = dc_to_dt(\@dc_lm);
+
+ # Report it for checking (since this algorithm is complex)
+ emit(
+ $silent,
+ sprintf("* %-100s *\n",
+ 'Last month computed to be ' .
+ $dt_lm->datetime . ' time zone ' . $dt_lm->strftime('%Z') .
+ ' (' . $dt_lm->epoch . ')'
+ )
+ );
+
+ #
+ # Work out if the the recording date was before the end of the last
+ # reviewed month.
+ #
+ $t_days = trailing_days(\@dc_lr, \@dc_lm);
+ emit(
+ $silent,
+ sprintf("* %-100s *\n",
+ 'Recording was in the reviewed month and not on the ' .
+ 'last day, so comments may have been missed'
+ )
+ ) if $t_days;
+
+ _debug( $DEBUG > 2, '@dc_lr = (' . join(',',@dc_lr) .')' );
+ _debug( $DEBUG > 2, '$dt_lr->ymd = ' . $dt_lr->ymd );
+ _debug( $DEBUG > 2, '$dt_lr->hms = ' . $dt_lr->hms );
+ _debug( $DEBUG > 2, '@dc_lm = (' . join(',',@dc_lm) .')' );
+}
+else {
+ #
+ # We now need a default for $dt_lr in all cases because the query has been
+ # changed.
+ #
+ @dc_lr = find_last_recording( \@startdate, \@deftime );
+ $dt_lr = dc_to_dt(\@dc_lr);
+}
+
+#-------------------------------------------------------------------------------
+# Data collection
+#-------------------------------------------------------------------------------
+#
+# Prepare to get any new hosts for the required month. We let MySQL compute
+# the end of the month. Order by date of first show.
+#
+$sth1 = $dbh->prepare(
+ q{SELECT ho.host, ho.hostid, md.mindate
+ FROM hosts ho
+ JOIN (SELECT hostid, MIN(date) mindate FROM eps GROUP BY hostid) AS md
+ ON ho.hostid = md.hostid
+ WHERE md.mindate >= ? AND md.mindate < last_day(?) + interval 1 day
+ ORDER BY mindate}
+);
+$sth1->execute( $dt->ymd, $dt->ymd );
+if ( $dbh->err ) {
+ carp $dbh->errstr;
+}
+
+#
+# Grab the data as an arrayref of hashrefs
+#
+$hosts = $sth1->fetchall_arrayref( {} );
+
+#
+# Prepare to get the episodes for the required month. We let MySQL compute the
+# end of the month. We include every column here just in case they'll be
+# useful in the template, though this requires some aliasing.
+# 2015-04-05 The date field has been reformatted so that the 'date' plugin in
+# the form is happy with it.
+#
+$sth1 = $dbh->prepare(
+ q{SELECT eps.id AS eps_id,
+-- eps.type,
+-- date_format(eps.date,'%a %Y-%m-%d') AS date,
+ date_format(eps.date,'00:00:00 %d/%m/%Y') AS date,
+-- eps.date,
+ eps.title,
+ sec_to_time(eps.duration) as length,
+ eps.summary,
+ eps.notes,
+-- eps.host AS eps_host,
+ eps.hostid AS eps_hostid,
+ eps.series,
+ eps.explicit,
+ eps.license AS eps_license,
+ eps.tags,
+ eps.version,
+ eps.valid AS eps_valid,
+ ho.hostid AS ho_hostid,
+ ho.host AS ho_host,
+ ho.email,
+ ho.profile, -- was website,
+ ho.license AS ho_license,
+-- ho.repeat,
+ ho.valid AS ho_valid
+ FROM eps
+ JOIN hosts ho ON eps.hostid = ho.hostid
+ WHERE eps.date >= ?
+ AND eps.date < last_day(?) + interval 1 day
+ ORDER BY id}
+);
+$sth1->execute( $dt->ymd, $dt->ymd );
+if ( $dbh->err ) {
+ carp $dbh->errstr;
+}
+
+#
+# Grab the data as an arrayref of hashrefs
+#
+$shows = $sth1->fetchall_arrayref( {} );
+
+#
+# Collect the comments if requested
+#
+if ($show_comments) {
+
+ #
+ # Grab the comments for the selected period. These have weird \' sequences
+ # in the author, title and text, which doesn't seem right, so we strip
+ # them. Note that the end date in date ranges seems only to work as
+ # 'last_day(?) + interval 1 day' presumably because without it the date is
+ # interpreted as midnight the previous day (e.g. 2014-06-30 is early on
+ # this day, not early on 2014-07-01 whereas adding 1 day gets this right).
+ #
+ # The logic here was rewritten 2015-03-04 and consists of:
+ # - the sub-select collects the id numbers of all comments that have
+ # occurred in the selected period
+ # - because all comments relating to a given show have the same id number
+ # this identifies all comment groups (comments relating to a given show)
+ # with members in the period
+ # - the main query then selects all comments in these groups and joins all
+ # the required tables to them to get the other data we need; it computes
+ # the boolean 'in_range' to indicate whether a comment within a group
+ # should be displayed; it returns *all* comments so we can number them
+ # - there might have been a way to generate a comment sequence number
+ # within the SQL but it was decided not to and to do this in the script
+ #
+ # Update 2015-03-26: the sort order of the final result used
+ # 'comment_identifier_id' which didn't seem to work reliably. Changed this
+ # to 'episode' which seems to work fine. The use of the former was only
+ # arrived at by guesswork. The guess was obviously wrong.
+ #
+ # Update 2015-06-04: the test for whether a comment is approved was
+ # failing because it was in the wrong place. Also, the sub-select seemed
+ # wrong, and running through EXPLAIN EXTENDED showed some flaws.
+ # Redesigned this and the whole query to be more in keeping with the
+ # algorithm sketched out above. This seems to have solved the problems.
+ #
+ # Update 2015-08-17: Still not right; somehow the 'in_range' check got
+ # mangled and the process of counting comments where a recent one was made
+ # to an old show was messed up. Dropped the two queries with a UNION and
+ # went back to the check for comment_identifier_id in a sub-query. This
+ # time the sub-query is cleverer and returns identifiers where the
+ # `comment_timestamp` is in the target month *and* the episode date is in
+ # the target month or before the start of the target month. This works for
+ # July 2015 (where there's a comment made about an August show) and August
+ # 2015 (where there's a comment made about a July show).
+ #
+ # Update 2023-05-02: Issues with comments received on the day of
+ # recording. This matter has been addresed earlier in the year but not all
+ # that well. The way this is all handled is not very clever so tried
+ # adjusting the main comment query to return such comments since we had
+ # one on 2023-04-29 which wasn't in the report.
+ #
+ #{{{
+ #
+ # Graveyard of old failed queries...
+ # ==================================
+# $sth1 = $dbh->prepare(
+# q{
+# SELECT
+# cc.comment_identifier_id,
+# eps.id AS episode,
+#-- ci.identifier_url,
+# substr(ci.identifier_url,1,locate('/eps.php?id=',ci.identifier_url)+15)
+# AS identifier_url,
+# eps.title,
+# eps.date,
+# ho.host,
+# ho.hostid,
+# from_unixtime(cc.comment_timestamp,'%Y-%m-%dT%TZ') AS timestamp,
+# replace(cc.comment_author_name,'\\\\','') AS comment_author_name,
+# replace(cc.comment_title,'\\\\','') AS comment_title,
+# replace(cc.comment_text,'\\\\','') AS comment_text,
+# cc.comment_timestamp,
+# (CASE WHEN (cc.comment_timestamp >= unix_timestamp(?)
+# AND cc.comment_timestamp < unix_timestamp(last_day(?) + interval 1 day)) THEN
+# 1 ELSE 0 END) AS in_range
+# FROM c5t_comment cc
+# JOIN c5t_identifier ci ON ci.identifier_id = cc.comment_identifier_id
+# JOIN eps ON eps.id =
+# substr(ci.identifier_url,locate('/eps.php?id=',ci.identifier_url)+12)
+# JOIN hosts ho ON eps.hostid = ho.hostid
+# WHERE cc.comment_status = 0
+# AND cc.comment_identifier_id IN (
+# SELECT DISTINCT cc2.comment_identifier_id FROM c5t_comment cc2
+# WHERE (
+# cc2.comment_timestamp >= unix_timestamp(?)
+# AND cc2.comment_timestamp < unix_timestamp(last_day(?) + interval 1 day)
+# )
+# )
+# AND (eps.date < (last_day(?) + interval 1 day))
+# ORDER BY eps.id ASC, comment_timestamp ASC
+# }
+# );
+#
+# This one is nuts...
+# $sth1 = $dbh->prepare( q{
+# (
+# SELECT
+# cc.comment_identifier_id,
+# eps.id AS episode,
+# substr(ci.identifier_url,1,locate('/eps.php?id=',ci.identifier_url)+15)
+# AS identifier_url,
+# eps.title,
+# eps.date,
+# ho.host,
+# ho.hostid,
+# from_unixtime(cc.comment_timestamp,'%Y-%m-%dT%TZ') AS timestamp,
+# replace(cc.comment_author_name,'\\\\','') AS comment_author_name,
+# replace(cc.comment_title,'\\\\','') AS comment_title,
+# replace(cc.comment_text,'\\\\','') AS comment_text,
+# cc.comment_timestamp,
+# (CASE WHEN (cc.comment_timestamp >= unix_timestamp(?)
+# AND cc.comment_timestamp < unix_timestamp(last_day(?) + interval 1 day)) THEN
+# 1 ELSE 0 END) AS in_range
+# FROM c5t_comment cc
+# JOIN c5t_identifier ci ON ci.identifier_id = cc.comment_identifier_id
+# JOIN eps ON eps.id =
+# substr(ci.identifier_url,locate('/eps.php?id=',ci.identifier_url)+12,4)
+# JOIN hosts ho ON eps.hostid = ho.hostid
+# WHERE eps.date >= ? AND eps.date < (last_day(?) + interval 1 day)
+# )
+# UNION
+# (
+# SELECT
+# cc.comment_identifier_id,
+# eps.id AS episode,
+# substr(ci.identifier_url,1,locate('/eps.php?id=',ci.identifier_url)+15)
+# AS identifier_url,
+# eps.title,
+# eps.date,
+# ho.host,
+# ho.hostid,
+# from_unixtime(cc.comment_timestamp,'%Y-%m-%dT%TZ') AS timestamp,
+# replace(cc.comment_author_name,'\\\\','') AS comment_author_name,
+# replace(cc.comment_title,'\\\\','') AS comment_title,
+# replace(cc.comment_text,'\\\\','') AS comment_text,
+# cc.comment_timestamp,
+# (CASE WHEN (cc.comment_timestamp >= unix_timestamp(?)
+# AND cc.comment_timestamp < unix_timestamp(last_day(?) + interval 1 day)) THEN
+# 1 ELSE 0 END) AS in_range
+# FROM c5t_comment cc
+# JOIN c5t_identifier ci ON ci.identifier_id = cc.comment_identifier_id
+# JOIN eps ON eps.id =
+# substr(ci.identifier_url,locate('/eps.php?id=',ci.identifier_url)+12,4)
+# JOIN hosts ho ON eps.hostid = ho.hostid
+# WHERE (cc.comment_timestamp >= unix_timestamp(?)
+# AND cc.comment_timestamp < unix_timestamp(last_day(?) + interval 1 day))
+# AND eps.date < (last_day(?) + interval 1 day)
+# )
+# ORDER BY episode ASC, comment_timestamp ASC
+# });
+#
+# This one worked fine - after much messing around admittedly:
+# $sth1 = $dbh->prepare( q{
+# SELECT
+# cc.comment_identifier_id,
+# eps.id AS episode,
+# substr(ci.identifier_url,1,locate('/eps.php?id=',ci.identifier_url)+15)
+# AS identifier_url,
+# eps.title,
+# eps.date,
+# ho.host,
+# ho.hostid,
+# from_unixtime(cc.comment_timestamp,'%Y-%m-%dT%TZ') AS timestamp,
+# replace(cc.comment_author_name,'\\\\','') AS comment_author_name,
+# replace(cc.comment_title,'\\\\','') AS comment_title,
+# replace(cc.comment_text,'\\\\','') AS comment_text,
+# cc.comment_timestamp,
+# (CASE WHEN
+# (
+# (cc.comment_timestamp >= unix_timestamp(?)
+# AND cc.comment_timestamp < unix_timestamp(last_day(?) + interval 1 day))
+# AND eps.date < (last_day(?) + interval 1 day)
+# OR (cc.comment_timestamp < unix_timestamp(last_day(?) + interval 1 day)
+# AND (eps.date >= ? AND eps.date < (last_day(?) + interval 1 day)))
+# )
+# THEN 1 ELSE 0 END) AS in_range
+# FROM c5t_comment cc
+# JOIN c5t_identifier ci ON ci.identifier_id = cc.comment_identifier_id
+# JOIN eps ON eps.id =
+# substr(ci.identifier_url,locate('/eps.php?id=',ci.identifier_url)+12,4)
+# JOIN hosts ho ON eps.hostid = ho.hostid
+# WHERE cc.comment_status = 0
+# AND cc.comment_identifier_id IN
+# (
+# SELECT DISTINCT
+# cc.comment_identifier_id
+# FROM c5t_comment cc
+# JOIN c5t_identifier ci ON ci.identifier_id = cc.comment_identifier_id
+# JOIN eps ON eps.id =
+# substr(ci.identifier_url,locate('/eps.php?id=',ci.identifier_url)+12,4)
+# WHERE
+# (eps.date >= ? AND eps.date < (last_day(?) + interval 1 day))
+# OR (
+# ( (cc.comment_timestamp >= unix_timestamp(?)
+# AND cc.comment_timestamp < unix_timestamp(last_day(?) + interval 1 day)) )
+# AND (eps.date >= ? AND eps.date < (last_day(?) + interval 1 day)
+# OR (eps.date < ?))
+# )
+# )
+# ORDER BY episode ASC, comment_timestamp ASC
+# });
+ #}}}
+
+ #-------------------------------------------------------------------------------
+ # Main comment query
+ #-------------------------------------------------------------------------------
+ $sth1 = $dbh->prepare( q{
+ SELECT
+ eps.id AS episode,
+ concat('https://hackerpublicradio.org/eps/hpr',
+ lpad(eps_id,4,0),'/index.html') AS identifier_url,
+ eps.title,
+ eps.summary,
+ eps.date,
+ ho.host,
+ ho.hostid,
+ date_format(co.comment_timestamp,'%Y-%m-%dT%TZ') AS timestamp,
+ co.comment_author_name,
+ co.comment_title,
+ co.comment_text,
+ unix_timestamp(co.comment_timestamp) AS comment_timestamp_ut,
+ unix_timestamp(
+ cast(concat(date(co.comment_timestamp),' ',?) AS DATETIME)
+ ) AS comment_released_ut,
+ (CASE WHEN
+ (
+ (co.comment_timestamp >= ?
+ AND co.comment_timestamp < (last_day(?) + interval 1 day))
+ OR (co.comment_timestamp >= ?)
+ AND eps.date < (last_day(?) + interval 1 day)
+ OR (co.comment_timestamp < (last_day(?) + interval 1 day)
+ AND (eps.date >= ? AND eps.date < (last_day(?) + interval 1 day)))
+ )
+ THEN 1 ELSE 0 END) AS in_range
+ FROM comments co
+ JOIN eps ON eps.id = co.eps_id
+ JOIN hosts ho ON eps.hostid = ho.hostid
+ WHERE eps.id IN
+ (
+ SELECT DISTINCT
+ eps.id
+ FROM eps
+ JOIN comments co ON (eps.id = co.eps_id)
+ WHERE
+ (eps.date >= ? AND eps.date < (last_day(?) + interval 1 day))
+ OR (co.comment_timestamp >= ?)
+ OR (
+ ( (co.comment_timestamp >= ?
+ AND co.comment_timestamp < (last_day(?) + interval 1 day)) )
+ AND (eps.date >= ? AND eps.date < (last_day(?) + interval 1 day)
+ OR (eps.date < ?))
+ )
+ )
+ ORDER BY episode ASC, comment_timestamp ASC
+ });
+
+ $sth1->execute(
+ $dt_lr->hms,
+ ( $dt->ymd ) x 2,
+ $dt_lr->ymd,
+ ( $dt->ymd ) x 6,
+ $dt_lr->ymd,
+ ( $dt->ymd ) x 5
+ );
+ if ( $dbh->err ) {
+ carp $dbh->errstr;
+ }
+
+ #
+ # Grab the data as an arrayref of hashrefs
+ #
+ $comments = $sth1->fetchall_arrayref( {} );
+
+ #-------------------------------------------------------------------------------
+ # Post-process the results of the query
+ #-------------------------------------------------------------------------------
+ # The comment structure needs some further work because it contains all
+ # comments for a given show but no indexes we can use (because the query
+ # didn't generate any - this is a lot easier in PostgreSQL (and possibly
+ # SQLite)). We also need to know how many comments we have within the
+ # target period, which is usually a smaller number than the number of
+ # comments we got back. The query has marked the ones we want to count
+ # (and display) using 'in_range'.
+ #
+ my ( $ep, $lastep, $index ) = ( 0, 0, 0 );
+ $comment_count = $past_count = $ignore_count = 0;
+ for my $row (@$comments) {
+ #
+ # Give each comment an index whether in_range or not. The indexes
+ # start from 1 when the episode number changes otherwise they
+ # increment.
+ #
+ $ep = $row->{episode};
+ if ( $ep == $lastep ) {
+ $index++;
+ }
+ else {
+ $index = 1;
+ }
+ #print "$ep $index ", $row->{in_range}, "\n";
+ _debug(
+ $DEBUG > 2,
+ sprintf(
+ "Index generation: episode=%s, index=%s, in_range=%s",
+ $ep, $index, $row->{in_range}
+ )
+ );
+ $lastep = $ep;
+
+ #
+ # Save the index for the template
+ #
+ $row->{index} = $index;
+
+ #
+ # Count the valid ones so the template doesn't have to to give a total
+ # for this month
+ #
+ $comment_count++ if $row->{in_range};
+
+ #
+ # Make the comment text cleaner by removing carriage returns (not sure
+ # why they are there in the first place)
+ #
+ $row->{comment_text} =~ s/\r+//g;
+
+ #
+ # Dates from the database look like '2016-08-01', we compare them to
+ # the month we're processing to see if the comments are attached to
+ # old shows.
+ # FIXME: test for 'in_range' before setting 'past'
+ #
+ if ( validate_date($row->{date},$dt->ymd) ) {
+ $row->{past} = 0;
+ }
+ else {
+ $row->{past} = 1;
+ $past_count++ if $row->{in_range};
+ }
+ }
+
+ #
+ # Now prune all of the comments which are not in_range to give the
+ # template an easier job
+ #
+ @$comments = grep { $_->{in_range} } @$comments;
+
+ # Explanation of the resulting structure {{{
+ #
+ # Restructure the comments into two hashes keyed by episode number where
+ # each comment to that episode is stored in sequence in an array. The two
+ # hashes %past and %current hold comments for shows in the past and for
+ # the current month. These can be dealt with separately in the template,
+ # making the logic therein somewhat simpler and clearer.
+ #
+ # The hash (of arrays of hashes) can be visualised thus:
+ # %past = {
+ # '2457' => [
+ # {
+ # 'past' => 1,
+ # 'hostid' => 111,
+ # 'identifier_url' => 'https://hackerpublicradio.org/eps.php?id=2457',
+ # 'comment_timestamp_ut' => 1556192523,
+ # 'date' => '2018-01-02',
+ # 'comment_text' => [snip],
+ # 'comment_title' => 'aren\'t you forgetting a hub?',
+ # 'timestamp' => '2019-04-25T11:42:03Z',
+ # 'in_range' => 1,
+ # 'index' => 1,
+ # 'host' => 'knightwise',
+ # 'title' => 'Getting ready for my new Macbook Pro',
+ # 'comment_author_name' => 'Bart',
+ # 'episode' => 2457,
+ # 'ignore' => 0
+ # }
+ # ]
+ # }
+ #}}}
+
+ #
+ # Also, as we go, mark and count the comments in the %past hash which were
+ # likely to have been read in the last show, so by this means simplify
+ # what the template has to do.
+ #
+ for my $row (@$comments) {
+ my $ep = $row->{episode};
+ if ($row->{past}) {
+ if ( $show_comments && $mark_comments ) {
+ #if ( $row->{comment_timestamp_ut} <= $dt_lr->epoch
+ if ( $row->{comment_released_ut} <= $dt_lr->epoch
+ && substr( $row->{date}, 0, 7 ) eq
+ substr( $dt_lm->ymd, 0, 7 ) )
+ {
+ $row->{ignore} = 1;
+ $ignore_count++;
+ }
+ else {
+ $row->{ignore} = 0;
+ }
+ }
+ else {
+ $row->{ignore} = 0;
+ }
+
+ if (exists($past{$ep})) {
+ push(@{$past{$ep}},$row);
+ }
+ else {
+ $past{$ep} = [$row];
+ }
+ }
+ else {
+ if (exists($current{$ep})) {
+ push(@{$current{$ep}},$row);
+ }
+ else {
+ $current{$ep} = [$row];
+ }
+ }
+ }
+
+ _debug ($DEBUG > 2,
+ '%past: ' . Dumper(\%past),
+ '%current: ' . Dumper(\%current)
+ );
+
+ #-------------------------------------------------------------------------------
+ # Make another data structure of missed coments *if* $t_days is true
+ #-------------------------------------------------------------------------------
+ # If $t_days is true then there might be comments from the previous month
+ # that weren't covered in the recording. So we add them to the notes just
+ # for the recording. Trouble is, if they exist they aren't in the comments
+ # we have gathered, so we'll have to go and search for them with this
+ # special query.
+ #
+ if ($t_days) {
+ $sth1 = $dbh->prepare( q{
+ SELECT
+ eps.id AS episode,
+ concat('https://hackerpublicradio.org/eps/hpr',
+ lpad(eps_id,4,0),'/index.html') AS identifier_url,
+ eps.title,
+ eps.summary,
+ eps.date,
+ ho.host,
+ ho.hostid,
+ date_format(co.comment_timestamp,'%Y-%m-%dT%TZ') AS timestamp,
+ co.comment_author_name,
+ co.comment_title,
+ co.comment_text,
+ unix_timestamp(co.comment_timestamp) AS comment_timestamp_ut
+ FROM comments co
+ JOIN eps ON eps.id = co.eps_id
+ JOIN hosts ho ON eps.hostid = ho.hostid
+ WHERE
+ co.comment_timestamp >= ?
+ AND co.comment_timestamp < (last_day(?)+ interval 1 day)
+ ORDER BY episode ASC, comment_timestamp ASC
+ });
+
+ #
+ # Need the date and time of the last recording and the start of the
+ # last month we reviewed to perform the query.
+ #
+ # $sth1->execute( $dt_lr->datetime . 'Z', $dt_lm->ymd );
+ $sth1->execute( $dt_lr->ymd, $dt_lm->ymd );
+ if ( $dbh->err ) {
+ carp $dbh->errstr;
+ }
+
+ #
+ # Grab the data as an arrayref of hashrefs
+ #
+ $missed_comments = $sth1->fetchall_arrayref( {} );
+ $missed_count = (
+ defined($missed_comments)
+ ? scalar(@$missed_comments)
+ : 0
+ );
+
+ #
+ # Make the comment text cleaner by removing carriage returns (not sure
+ # why they are there in the first place)
+ #
+ for my $ch (@$missed_comments) {
+ $ch->{comment_text} =~ s/\r+//g;
+ }
+
+ _debug ($DEBUG > 2,
+ '@missed_comments: ' . Dumper($missed_comments)
+ );
+
+ #
+ # After a change in design around 2023-05-02 there may be duplicates
+ # in the %past hash and the @$missed_comments array. We need to hide
+ # the former for now. They will not be hidden when $t_days is false
+ # because we're not bothered about missed comments!
+ #
+ if ( $past_count > 0 ) {
+ my @missed_episodes = map { $_->{episode} } @$missed_comments;
+
+ _debug( $DEBUG > 2,
+ '@missed_episodes: ' . Dumper( \@missed_episodes ) );
+
+ delete( @past{@missed_episodes} );
+
+ my $old_pc = $past_count;
+ $past_count = scalar( keys(%past) );
+ $comment_count -= ($old_pc - $past_count);
+
+ _debug(
+ $DEBUG > 2,
+ '%past (edited): ' . Dumper( \%past ),
+ "\$past_count: $past_count",
+ "\$comment_count: $comment_count"
+ );
+ }
+ }
+}
+
+#-------------------------------------------------------------------------------
+# Fill and print the template
+#-------------------------------------------------------------------------------
+my $tt = Template->new(
+ { ABSOLUTE => 1,
+ ENCODING => 'utf8',
+ INCLUDE_PATH => $basedir,
+ FILTERS => {
+ # For HTML->ASCII in comments_only.tpl, decode HTML entities
+ 'decode_entities' => \&my_decode_entities,
+ },
+ }
+);
+
+my $vars = {
+ review_month => $dt->month_name,
+ review_year => $dt->year,
+ hosts => $hosts,
+ shows => $shows,
+ comment_count => $comment_count,
+ past_count => $past_count,
+ ignore_count => $ignore_count,
+ missed_count => $missed_count,
+ missed_comments => $missed_comments,
+ comments => $comments, # legacy
+ past => \%past,
+ current => \%current,
+ skip_comments => ( $show_comments ? 0 : 1 ),
+ mark_comments => $mark_comments,
+ ctext => $ctext,
+ last_recording => ( $mark_comments ? $dt_lr->epoch : 0 ),
+ last_month => (
+ $mark_comments ? sprintf( "%d-%02d", $dt_lm->year, $dt_lm->month ) : 0
+ ),
+ includefile => $mailnotes,
+ aob => defined($aobfile),
+ aobfile => $aobfile,
+};
+
+#print Dumper($vars),"\n";
+
+my $document;
+$tt->process( $template, $vars, \$document, { binmode => ':utf8' } )
+ || die $tt->error(), "\n";
+print $outfh $document;
+
+#
+# The episode number tests passed earlier on, so add the notes to the database
+# if so requested
+#
+if ($episode) {
+ emit( $silent,
+ "Writing shownotes to the database for episode $episode\n" );
+ $sth1 = $dbh->prepare(q{UPDATE eps SET notes = ? WHERE id = ?});
+ $sth1->execute( $document, $episode );
+ if ( $dbh->err ) {
+ carp $dbh->errstr;
+ }
+}
+
+$dbh->disconnect;
+
+exit;
+
+#=== FUNCTION ================================================================
+# NAME: parse_to_dc
+# PURPOSE: Parse a textual date (and optional time) to a Date::Calc
+# datetime
+# PARAMETERS: $datetime Datetime as a string
+# $deftime Arrayref default time (as a Date::Calc array
+# or undef)
+# RETURNS: A Date::Calc date (and possibly time) as a list
+# DESCRIPTION: The $datetime argument is parsed ewith Date::Parse. The year
+# and month need to be adjusted. If a default time has been
+# supplied then the parsed time is checked and the default time
+# used if nothing was found, otherwise the parsed time is used
+# and a full 6-component time returned.
+# If the default time us undefined this means we don't care
+# about the time and so we just return the parsed date as
+# a 3-component list.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub parse_to_dc {
+ my ( $datetime, $deftime ) = @_;
+
+ # What strptime returns:
+ # 0 1 2 3 4 5 6
+ # ($ss,$mm,$hh,$day,$month,$year,$zone)
+ #
+ my @parsed = strptime($datetime);
+ die "Invalid DATE or DATETIME '$datetime'\n"
+ unless ( defined( $parsed[3] )
+ && defined( $parsed[4] )
+ && defined( $parsed[5] ) );
+
+ $parsed[5] += 1900;
+ $parsed[4] += 1;
+
+ if ( defined($deftime) ) {
+ #
+ # If no time was supplied add a default one
+ #
+ unless ( defined( $parsed[2] )
+ && defined( $parsed[1] )
+ && defined( $parsed[0] ) )
+ {
+ @parsed[ 2, 1, 0 ] = @$deftime;
+ }
+
+ #
+ # Return a list
+ #
+ return ( @parsed[ 5, 4, 3, 2, 1, 0 ] );
+ }
+ else {
+ return ( @parsed[ 5, 4, 3 ] );
+ }
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: dc_to_dt
+# PURPOSE: Converts a Date::Calc datetime into a DateTime equivalent
+# PARAMETERS: $refdt Reference to an array holding a Date::Calc
+# date and time
+# RETURNS: Returns a DateTime object converted from the input
+# DESCRIPTION:
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub dc_to_dt {
+ my ($refdt) = @_;
+
+ #
+ # Check we got a 6-element array
+ #
+ if (scalar(@$refdt) != 6) {
+ print "Invalid Date::Calc date and time (@$refdt)\n";
+ die "Aborted\n";
+ }
+
+ #
+ # Convert to DateTime to get access to formatting stuff
+ #
+ my ( %dtargs, $dt );
+ @dtargs{ 'year', 'month', 'day', 'hour', 'minute', 'second', 'time_zone' }
+ = ( @$refdt, 'UTC' );
+ $dt = DateTime->new(%dtargs);
+
+ #
+ # Return the date as a DateTime object
+ #
+ return $dt;
+}
+
+#=== FUNCTION ================================================================
+# NAME: find_last_recording
+# PURPOSE: Finds the recording date of the Community News episode
+# relating to the target month
+# PARAMETERS: $refdate Reference to an array holding a Date::Calc
+# date - the first of the selected month
+# $reftime Reference to an array holding a Date::Calc
+# time (UTC)
+# RETURNS: A Date::Calc object containing the date and time of the last
+# Community News recording in the UTC time zone
+# DESCRIPTION: We want to find the date of the last Community News recording
+# to determine whether a given comment preceded it. The scenario
+# is that we're using these notes while making such a recording
+# and want to know if comments occured before the last one. If
+# they did we should have read them during that show and don't
+# need to do so now. We want to pass the date generated here to
+# the template so it can compare it with comment dates. To
+# complete the story, the template will mark such comments, but
+# we'll turn of the marking before the notes are released - they
+# are just for use by the people recording the episode.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub find_last_recording {
+ my ($refdate, $reftime) = @_;
+
+ my $monday = 1; # Day of week number 1-7, Monday-Sunday
+
+ #
+ # Using the given date (the requested month), ensure it's the first day of
+ # the month
+ #
+ my @lastmonth = @$refdate;
+ $lastmonth[2] = 1;
+
+ #
+ # Work out the recording date in the target month
+ #
+ @lastmonth = Nth_Weekday_of_Month_Year( @$refdate[ 0, 1 ], $monday, 1 );
+ @lastmonth = Add_Delta_Days( @lastmonth, -2 );
+
+ #
+ # Return the date as a DateTime object
+ #
+ return (@lastmonth,@$reftime);
+}
+
+#=== FUNCTION ================================================================
+# NAME: find_last_month
+# PURPOSE: Finds the previous month for working out marks
+# PARAMETERS: $refdate Reference to an array holding a Date::Calc
+# date - the first of the selected month
+# RETURNS: A DateTime object containing the first day of the last month.
+# DESCRIPTION: We need the details of the last month because if we're marking
+# comments we have already read out in the previous Community
+# News show, but don't want to mark comments to shows before
+# that month, even if the comments fell within the target month.
+# This is a complex edge condition that wasn't appreciated in the
+# first implementation.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub find_last_month {
+ my ($refdate) = @_;
+
+ my $monday = 1; # Day of week number 1-7, Monday-Sunday
+ my @starttime = ( 00, 00, 00 ); # UTC
+
+ #
+ # Using the given date (the requested month for the notes), ensure it's
+ # the first day of the month
+ #
+ my @lastmonth = @$refdate;
+ $lastmonth[2] = 1;
+
+ #
+ # Subtract one day to enter the previous month and force the first day of
+ # the resulting month
+ #
+ @lastmonth = Add_Delta_Days( @lastmonth, -1 );
+ $lastmonth[2] = 1;
+
+ #
+ # Return the date as a DateTime object
+ #
+ return (@lastmonth,@starttime);
+}
+
+#=== FUNCTION ================================================================
+# NAME: trailing_days
+# PURPOSE: Determines if the last month had 'trailing' days - those after
+# the recording date - during which unread comments could have
+# been posted.
+# PARAMETERS: $dc_lr reference to an array containing a Date::Calc
+# date of the last recording
+# $dc_lm reference to an array containing a Date::Calc
+# date of the first day of last month
+# RETURNS: A true/false result - 1 if there were trailing days,
+# 0 otherwise
+# DESCRIPTION: If the recording of a Community News show was during the month
+# being reviewed (e.g. March 2019; recording on 2019-03-30,
+# release on 2019-04-01) then a comment could have been made
+# after (or during!) the recording and probably would not have
+# been read during the show. We want to spot such a case and
+# highlight it in the *next* recording!
+# Yes, I know this is obsessive, but it needs a solution!!!
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub trailing_days {
+ my ( $dc_lr, $dc_lm ) = @_;
+
+ my $offset;
+
+ #
+ # Are the last month and the recording month the same?
+ #
+ if ( $dc_lm->[1] eq $dc_lr->[1] ) {
+ #
+ # Compute the offset as Delta_Days((First day of last month + days in
+ # month), recording date). A positive offset (not sure if we'd get
+ # a negative one) means there's some of the month still to go.
+ #
+ $offset
+ = Delta_Days( @$dc_lr[0..2],
+ Add_Delta_Days( @$dc_lm[0..2], Days_in_Month( @$dc_lm[ 0, 1 ] ) ) );
+ return 1 if $offset gt 0;
+ }
+
+ return 0;
+}
+
+#=== FUNCTION ================================================================
+# NAME: validate_date
+# PURPOSE: Checks the date found in the database ($date) is before the
+# reference date ($refdate)
+# PARAMETERS: $date Textual date from the database
+# $refdate Optional textual date to compare with. If
+# omitted then we use 'Today'
+# RETURNS: True (1) if the $date is later than the $refdate, false (0)
+# otherwise
+# DESCRIPTION: We need to check that the script is not being used to change
+# the notes for a Community News show in the past. This is
+# because sometimes the generated notes are edited after they
+# have been created to add other elements, and we do not want to
+# accidentally destroy such changes. We just compute the
+# difference between today and the date of the target episode.
+# If the difference in days is greater than 0 then it's OK.
+# We also want to be able to use this routine to check whether
+# comments relate to old shows or this month's.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub validate_date {
+ my ($date, $refdate) = @_;
+
+ my @refdate;
+
+ unless (defined($date)) {
+ warn "check_date: invalid argument\n";
+ return 0;
+ }
+
+ my @date = ($date =~ /^(\d{4})-(\d{2})-(\d{2})$/);
+ if (defined($refdate)) {
+ @refdate = ($refdate =~ /^(\d{4})-(\d{2})-(\d{2})$/);
+ }
+ else {
+ @refdate = Today();
+ }
+
+ return (Delta_Days(@refdate,@date) >= 0);
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: my_decode_entities
+# PURPOSE: Call 'HTML::Entities::decode_entities' as a filter in a template
+# PARAMETERS: $text The text string to process
+# RETURNS: The text string with all HTML entities decoded to Unicode
+# DESCRIPTION: This is a local filter to be called in a template. The name
+# it's called by is defined in the 'FILTERS' definition in the
+# call to Template::new. Maybe this function is redundant and
+# the real 'decode_entities' could have been called directly,
+# but this is slightly easier to understand (I think).
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub my_decode_entities {
+ my $text = shift;
+
+ decode_entities($text);
+ return $text;
+}
+
+#=== FUNCTION ================================================================
+# NAME: emit
+# PURPOSE: Print text on STDERR unless silent mode has been selected
+# PARAMETERS: - Boolean indicating whether to be silent or not
+# - list of arguments to 'print'
+# RETURNS: Nothing
+# DESCRIPTION: This is a wrapper around 'print' to determine whether to send
+# a message to STDERR depending on a boolean. We need this to be
+# able to make the script silent when the -silent option is
+# selected
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub emit {
+ unless (shift) {
+ print STDERR @_;
+ }
+}
+
+#=== FUNCTION ================================================================
+# NAME: _debug
+# PURPOSE: Prints debug reports
+# PARAMETERS: $active Boolean: 1 for print, 0 for no print
+# @messages List of messages to print
+# RETURNS: Nothing
+# DESCRIPTION: Outputs messages to STDERR if $active is true. For each
+# message it removes any trailing newline and then adds one in
+# the 'print' so the caller doesn't have to bother. Prepends
+# each message with 'D> ' to show it's a debug message.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub _debug {
+ my ( $active, @messages ) = @_;
+
+ return unless $active;
+ foreach my $msg (@messages) {
+ chomp($msg);
+ print STDERR "D> $msg\n";
+ }
+}
+
+#=== FUNCTION ================================================================
+# NAME: Options
+# PURPOSE: Processes command-line options
+# PARAMETERS: $optref Hash reference to hold the options
+# RETURNS: Undef
+# DESCRIPTION:
+# THROWS: no exceptions
+# COMMENTS: none
+# SEE ALSO: n/a
+#===============================================================================
+sub Options {
+ my ($optref) = @_;
+
+ my @options = (
+ "help", "documentation|man",
+ "debug=i", "from=s",
+ "out=s", "template=s",
+ "comments!", "markcomments|mc!",
+ "ctext!", "lastrecording|lr=s",
+ "silent!", "episode=s",
+ "overwrite!", "mailnotes:s",
+ "anyotherbusiness|aob=s", "config=s",
+ "interlock=s"
+ );
+
+ if ( !GetOptions( $optref, @options ) ) {
+ pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 );
+ }
+
+ return;
+}
+
+__END__
+
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+# Application Documentation
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+#{{{
+
+=head1 NAME
+
+make_shownotes - Make HTML show notes for the Hacker Public Radio Community News show
+
+=head1 VERSION
+
+This documentation refers to B version 0.2.2
+
+
+=head1 USAGE
+
+ make_shownotes [-help] [-documentation] [-from=DATE] [-[no]comments]
+ [-[no]markcomments] [-[no]ctext] [-lastrecording=DATETIME]
+ [-[no]silent] [-out=FILE] [-episode=[N|auto]] [-[no]overwrite]
+ [-mailnotes[=FILE]] [-anyotherbusiness=FILE] [-template=FILE]
+ [-config=FILE] [-interlock=PASSWORD]
+
+=head1 OPTIONS
+
+=over 8
+
+=item B<-help>
+
+Displays a brief help message describing the usage of the program, and then exits.
+
+=item B<-documentation>
+
+Displays the entirety of the documentation (using a pager), and then exits. To
+generate a PDF version use:
+
+ pod2pdf make_shownotes --out=make_shownotes.pdf
+
+=item B<-from=DATE>
+
+This option is used to indicate the month for which the shownotes are to be
+generated. The script is able to parse a variety of date formats, but it is
+recommended that ISO8601 YYYY-MM-DD format be used (for example 2014-06-30).
+
+The day part of the date must be present but is ignored and only the month and
+year parts are used.
+
+If this option is omitted the current month is used.
+
+=item B<-[no]comments>
+
+This option controls whether the comments pertaining to the selected month are
+included in the output. If the option is omitted then no comments are included
+(B<-nocomments>).
+
+=item B<-[no]markcomments> or B<-[no]mc>
+
+This option controls whether certain comments are marked in the HTML. The
+default is B<-nomarkcomments>. The option can be abbreviated to B<-mc> and
+B<-nomc>.
+
+The scenario is that we want to use the notes the script generates while
+making a Community News recording and we also want them to be the show notes
+in the database once the show has been released.
+
+Certain comments relating to shows earlier than this month were already
+discussed last month, because they were made before that show was recorded. We
+don't want to read them again during this show, so a means of marking them is
+needed.
+
+The script determines the date of the last recording (or it can be specified
+with the B<-lastrecording=DATETIME> option, or its abbreviation
+B<-lr=DATETIME>) and passes it to the template. The template can then compare
+this date with the dates of the relevant comments and take action to highlight
+those we don't want to re-read. It is up to the template to do what is
+necessary to highlight them.
+
+The idea is that we will turn off the marking before the notes are released
+- they are just for use by the people recording the episode.
+
+Another action is taken during the processing of comments when this option is
+on. On some months of the year the recording is made during the month itself
+because the first Monday of the next month is in the first few days of that
+month. For example, in March 2019 the date of recording is the 30th, and the
+show is released on April 1st. Between the recording and the release of the
+show there is time during which more comments could be submitted.
+
+Such comments should be in the notes for March (and these can be regenerated
+to make sure this is so) but they will not have been read on the March
+recording. The B script detects this problem and, if
+B<-markcomments> is set (and comments enabled) will show a list of any
+eligible comments in a red highlighted box. This is so that the volunteers
+recording the show can ensure they read comments that have slipped through
+this loophole. The display shows the entire comment including the contents,
+but disappears when the notes are refreshed with B<-nomarkcomments> (the
+default).
+
+In this mode the preamble warning about comments to be ignored used to be
+included, but now it is skipped if there are no such comments. This means one
+switch can serve two purposes.
+
+=item B<-lastrecording=DATETIME> or B<-lr=DATETIME>
+
+As mentioned for B<-markcomments>, the date of the last recording can be
+computed in the assumption that it's on the Saturday before the first Monday
+of the month at 15:00. However, on rare occasions it may be necessary to
+record on an earlier date and time, which cannot be computed. This value can
+be defined with this option.
+
+The format can be an ISO 8601 date followed by a 24-hour time, such as
+'2020-01-25 15:00'. If the time is omitted it defaults to 15:00.
+
+=item B<-[no]ctext>
+
+This option controls whether the comment text itself is listed with comments.
+This is controlled by the template, but the current default template only
+shows the text in the B section of the output. The default
+state is B<-noctext> in which the comment texts are not written.
+
+=item B<-[no]silent>
+
+This option controls whether the script reports details of its progress
+to STDERR. If the option is omitted the report is generated (B<-nosilent>).
+
+The script reports: the month it is working on, the name of the output file
+(if appropriate) and details of the process of writing notes to the database
+(if the B<-episode=[N|auto]> option is selected).
+
+=item B<-mailnotes[=FILE]>
+
+If desired, the show notes may include a section about recent discussions on
+the HPR mailing list. Obviously, this text will change every month, so this
+option provides a way in which an external file can be included in the show
+notes.
+
+The filename may be omitted which is a way in which a B directive can
+be placed in the template and used rather than the file. The B must be
+named B because this is the name the script uses in this
+circumstance. See B for an example of its use.
+
+The template must contain instructions to include the file or block. The file
+name is stored in a variable 'B' in the template. Directives of
+the following form may be added to achive this:
+
+ [%- IF includefile.defined %]
+ Constant header, preamble, etc
+ [%- INCLUDE $includefile %]
+ Other constant text or tags
+ [%- END %]
+
+The first directive causes the whole block to be ignored if there is no
+B<-mailnotes> option. The use of the B directive means that the
+included file may contain Template directives itself if desired.
+
+See existing templates for examples of how this is done.
+
+=item B<-anyotherbusiness=FILE> or B<-aob=FILE>
+
+If desired the shownotes may contain an 'Any other business' section. This is
+implemented in a template thus:
+
+ [% IF aob == 1 -%]
+ Any other business
+ [% INCLUDE $aobfile -%]
+ [%- END %]
+
+The template variable B is set to 1 if a (valid) file has been provided,
+and the name of the file is in B.
+
+The included file is assumed to be HTML.
+
+=item B<-out=FILE>
+
+This option defines an output file to receive the show notes. If the option is
+omitted the notes are written to STDOUT, allowing them to be redirected if
+required.
+
+The output file name may contain the characters 'B<%s>'. This denotes the point
+at which the year and month in the format B are inserted. For example
+if the script is being run for July 2014 the option:
+
+ -out=shownotes_%s.html
+
+will cause the generation of the file:
+
+ shownotes_2014-07.html
+
+=item B<-episode=[N|auto]>
+
+This option provides a means of specifying an episode number in the database to
+receive the show notes.
+
+It either takes a number, or it takes the string 'B' which makes the
+script find the correct show number.
+
+First the episode number has to have been reserved in the database. This is
+done by running the script 'B'. This makes a reservation with
+the title "HPR Community News for ". Normally Community News
+slots are reserved several months in advance.
+
+Close to the date of the Community News show recording this script can be run
+to write show notes to the database. For example:
+
+ ./make_shownotes -from=1-Dec-2014 -out=/dev/null \
+ -comm -tem=shownote_template5.tpl -ep=auto
+
+This will search for the episode with the title "HPR Community News for
+December 2014" and will add notes if the field is empty. Note that it is
+necessary to direct the output to /dev/null since the script needs to write
+a copy of the notes to STDOUT or to a file. In this case we request comments
+to be added to the notes, and we use the template file
+B which generates an HTML snippet suitable for the
+database.
+
+The writing of the notes to the database will fail if the field is not empty.
+See the B<-overwrite> option for how to force the notes to be written.
+
+If the B<-episode=[N|auto]> option is omitted no attempt is made to write to
+the database.
+
+=item B<-[no]overwrite>
+
+This option is only relevant in conjunction with the B<-episode=[N|auto]>
+option. If B<-overwrite> is chosen the new show notes will overwrite any notes
+already in the database. If B<-nooverwrite> is selected, or the option is
+omitted, no over writing will take place - it will only be possible to write
+notes to the database if the field is empty.
+
+=item B<-template=FILE>
+
+This option defines the template used to generate the notes. The template is
+written using the B toolkit language.
+
+If the option is omitted then the script uses the file
+B in the same directory as the script. If this file
+does not exist then the script will exit with an error message.
+
+For convenience B is a soft link which points to the
+file which is the current default. This allows the development of versions
+without changing the usual way this script is run.
+
+=item B<-config=FILE>
+
+This option allows an alternative configuration file to be used. This file
+defines the location of the database, its port, its name and the username and
+password to be used to access it. This feature was added to allow the script
+to access alternative databases or the live database over an SSH tunnel.
+
+See the CONFIGURATION AND ENVIRONMENT section below for the file format.
+
+If the option is omitted the default file is used: B<.hpr_db.cfg>
+
+=item B<-interlock=PASSWORD>
+
+This option was added to handle the case where the notes for a Community News
+episode have been posted after the show was recorded, but, since the recording
+date was not the last day of the month further comments could be added after
+upload. Logically these comments belong in the previous month's shownotes, so
+we'd need to add them retrospecively.
+
+Up until the addition of this option the script would not allow the
+regeneration of the notes. This option requires a password to enable the
+feature, but the password is in a constant inside the script. This means that
+it's difficult to run in this mode by accident, but not particulary difficult
+if it's really needed.
+
+Take care not to run in this mode if the notes have been edited after they
+were generated!
+
+=back
+
+=head1 DESCRIPTION
+
+=head2 Overview
+
+This script generates notes for the next Hacker Public Radio I
+show. It does this by collecting various details of activity from the HPR
+database and passing them to a template. The default template is called
+B and this generates HTML, but any suitable textual
+format could be generated if required, by using a different template.
+
+=head2 Data Gathering
+
+Four types of information are collected by the script:
+
+=over 4
+
+=item -
+
+Details of new hosts who have released new shows in the selected month
+
+=item -
+
+Details of shows which have been released in the selected month
+
+=item -
+
+Details of topics on the mailing list in the past month can be included. This
+is only done if the B<-mailnotes=FILE> option is used. This option must
+reference a file of HTML, which may contain Template directives if required.
+
+=item -
+
+Comments which have been submitted to the HPR website in the selected month.
+These need to be related to shows in the current period or in the past.
+Comments made about shows which have not yet been released (but are visible on
+the website) are not included even though they are made in the current month.
+
+Comments are only gathered if the B<-comments> option is selected.
+
+=back
+
+=head2 Report Generation
+
+The four components listed above are formatted in the following way by the
+default template.
+
+=over 4
+
+=item B
+
+These are formatted as a list of links to the hostid with the host's name.
+
+=item B
+
+These are formatted into an HTML table containing the show number, title and
+host name. The show title is a link to the show page on the HPR website. The
+host name is a link to the host page on the website.
+
+=item B
+
+If there have been significant topics on the mailing list in the month in
+question then these can be summarised in this section. This is done by
+preparing an external HTML file and referring to it with the
+B<-mailnotes=FILE> option. If this is done then the file is included into the
+template.
+
+See the explanation of the B<-mailnotes> option for more details.
+
+=item B
+
+These are formatted with tags separated by horizontal lines.
+A shows the author name and title and a displays a link to
+the show and the show's host and the show title is also included. The body of
+the article contains the comment text with line breaks.
+
+=back
+
+=head2 Variable, Field and Hash names
+
+If you wish to write your own template refer to the following lists for the
+names of items. Also refer to the default template B
+for the techniques used there. (Note that B is a link
+to the current default template, such as B).
+
+The hash and field names available to the template are as follows
+
+=over 4
+
+=item B
+
+ Variable Name Details
+ ------------- -------
+ review_month The month name of the report date
+ review_year The year of the report date
+ comment_count The number of comments in total
+ past_count The number of comments on old shows
+ skip_comments Set when -comments is omitted
+ mark_comments Set when -markcomments is used
+ ctext Set when the comment bodies in the 'Past shows'
+ section are to be shown
+ last_recording The date the last recording was made
+ (computed if -markcomments is selected) in
+ Unixtime format
+ last_month The month prior to the month for which the notes are
+ being generated (computed if -markcomments is
+ selected) in 'YYYY-MM' format
+
+=item B
+
+The name of the hash in the template is B. The hash might be empty if
+there are no new hosts in the month. See the default template for how to
+handle this.
+
+ Field Name Details
+ ---------- -------
+ host Name of host
+ hostid Host id number
+
+=item B
+
+The name of the hash in the template is B. Note that there are more
+fields available than are used in the default template. Note also that certain
+field names are aliases to avoid clashes (e.g. eps_hostid and ho_hostid).
+
+ Field Name Details
+ ---------- -------
+ eps_id Episode number
+ date Episode date
+ title Episode title
+ length Episode duration
+ summary Episode summary
+ notes Episode show notes
+ eps_hostid The numerical host id from the 'eps' table
+ series The series number from the 'eps' table
+ explicit The explicit marker for the show
+ eps_license The license for the show
+ tags The show's tags as a comma-delimited string
+ version ?Obsolete?
+ eps_valid The valid value from the 'eps' table
+ ho_hostid The host id number form the 'hosts' table
+ ho_host The host name
+ email The hosts's email address (true address - caution)
+ profile The host's profile
+ ho_license The default license for the host
+ ho_valid The valid value from the 'hosts' table
+
+=item B
+
+The variable B contains the path to the file (which may only be
+located in the same directory as the script).
+
+=item B
+
+Two hashes are created for comments. The hash named B contains comments
+to shows before the current month, and B contains comments to this
+month's shows. Note that these hashes are only populated if the B<-comments>
+option is provided. Both hashes have the same structure.
+
+ Field Name Details
+ ---------- -------
+ episode Episode number
+ identifier_url Full show URL
+ title Episode title
+ date Episode date
+ host Host name
+ hostid Host id number
+ timestamp Comment timestamp in ISO8601 format
+ comment_author_name Name of the commenter
+ comment_title Title of comment
+ comment_text Text of the comment
+ comment_timestamp_ut Comment timestamp in Unixtime format
+ in_range Boolean (0/1) denoting whether the comment was made
+ in the target month
+ index The numerical index of the comment for a given show
+
+The purpose of the B value is to denote whether a comment was made
+in the target month. This is used in the script to split the comments into the
+B and B hashes. It is therefore of little use in the template,
+but is retained in case it might be useful. The B value can be used in
+the template to refer to the comment, make linking URLs etc. It is generated
+by the script (unfortunately it couldn't be done in the SQL).
+
+=back
+
+=head2 Filters
+
+A filter called B is available to the template. The reason
+for creating this was when the HTML of a comment is being listed as text
+(Unicode actually). Since comment text is stored in the database as HTML with
+entities when appropriate this is needed to prevent the plain text showing
+I<&> and the like verbatim. It is currently used in B.
+
+=head1 DIAGNOSTICS
+
+=over 4
+
+=item B
+
+The nominated configuration file in B<-config=FILE> (or the default file)
+cannot be found.
+
+=item B
+
+The B<-episode=N> option must use a positive number.
+
+=item B
+
+The B<-episode=> option must be followed by a number or the word 'auto'
+
+=item B
+
+The include file referred to in the error message is missing.
+
+=item B
+
+The template file referred to in the error message is missing.
+
+=item B
+
+The date provided through the B<-from=DATE> option is invalid. Use an ISO8601
+date in the format YYYY-MM-DD.
+
+=item B
+
+The file specified in the B<-out=FILE> option cannot be written to. This may
+be because you do not have permission to write to the file or directory.
+Further information about why this failed should be included in the message.
+
+=item B
+
+The script was unable to open STDOUT for writing the report. Further
+information about why this failed should be included in the message.
+
+=item B
+
+The B<-episode=N> option has been selected and the script is checking the
+numbered show but has not found a Community News title.
+
+=item B
+
+The B<-episode=> option has been selected and a Community News show entry has
+been found in the database. However, this entry is for today's show or is in
+the past, which is not permitted. It is possible to override this restriction
+by using the B<-interlock=PASSWORD> option. See the relevant documentation for
+details.
+
+=item B
+
+The B<-episode=> option has been selected and a Community News show entry has
+been found in the database. However, this entry already has notes associated
+with it and the B<-overwrite> option has not been specified.
+
+=item B
+
+The B<-episode=N> option has been selected but the script cannot find this
+episode number in the database.
+
+=item B
+
+The B<-episode=auto> option has been selected but the script cannot find the
+episode for the month being processed.
+
+Possible reasons for this are that the show has not been reserved in the
+database or that the title is not as expected. Use B to reserve
+the slot. The title should be "HPR Community News for ".
+
+=back
+
+=head1 CONFIGURATION AND ENVIRONMENT
+
+The script obtains the credentials it requires to open the HPR database from
+a configuration file. The name of the file it expects is B<.hpr_db.cfg> in the
+directory holding the script. To change this will require changing the script.
+
+The configuration file format is as follows:
+
+
+ host = 127.0.0.1
+ port = PORT
+ name = DATABASE
+ user = USERNAME
+ password = PASSWORD
+
+
+=head1 DEPENDENCIES
+
+ Carp
+ Config::General
+ Date::Calc
+ Date::Parse
+ DateTime
+ DateTime::Duration
+ DBI
+ Getopt::Long
+ Pod::Usage
+ Template
+ Template::Filters
+
+=head1 BUGS AND LIMITATIONS
+
+There are no known bugs in this module.
+Please report problems to Dave Morriss (Dave.Morriss@gmail.com)
+Patches are welcome.
+
+=head1 AUTHOR
+
+Dave Morriss (Dave.Morriss@gmail.com)
+
+
+=head1 LICENCE AND COPYRIGHT
+
+Copyright (c) 2014-2019 Dave Morriss (Dave.Morriss@gmail.com). All rights reserved.
+
+This module is free software; you can redistribute it and/or
+modify it under the same terms as Perl itself. See perldoc perlartistic.
+
+This program is distributed in the hope that it will be useful
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+=cut
+
+#}}}
+
+# [zo to open fold, zc to close or za to toggle]
+
+# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
diff --git a/Community_News/reserve_cnews b/Community_News/reserve_cnews
new file mode 100755
index 0000000..5a38865
--- /dev/null
+++ b/Community_News/reserve_cnews
@@ -0,0 +1,864 @@
+#!/usr/bin/env perl
+#===============================================================================
+#
+# FILE: reserve_cnews
+#
+# USAGE: ./reserve_cnews [-from[=DATE]] [-count=COUNT] [-[no]dry-run]
+# [-[no]silent] [-config=FILE] [-help] [-debug=N]
+#
+# DESCRIPTION: Reserve a series of slots from a given date for the Community
+# News shows by computing the dates for the reservations and
+# then working out the show numbers from there.
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: ---
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.0.14
+# CREATED: 2014-04-29 22:16:00
+# REVISION: 2023-04-10 16:05:36
+#
+#===============================================================================
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+
+use Getopt::Long;
+use Pod::Usage;
+
+use Config::General;
+
+use Date::Parse;
+use Date::Calc qw{:all};
+
+use DBI;
+
+use Data::Dumper;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.0.14';
+
+#
+# Script name
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+( my $DIR = $0 ) =~ s|/?[^/]*$||mx;
+$DIR = '.' unless $DIR;
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+#
+# Constants and other declarations
+#
+my $basedir = "$ENV{HOME}/HPR/Community_News";
+my $configfile = "$basedir/.hpr_db.cfg";
+
+my $hostname = 'HPR Volunteers';
+my $seriesname = 'HPR Community News';
+my $tags = 'Community News';
+
+my $titlefmt = 'HPR Community News for %s %d';
+my $summaryfmt = 'HPR Volunteers talk about shows released and comments '
+ . 'posted in %s %d';
+
+my ( $dbh, $sth1, $sth2, $sth3, $h1, $h2, $rv );
+my (@startdate, @rdate, @lastmonth, $show,
+ $hostid, $series, $title, $summary
+);
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#-------------------------------------------------------------------------------
+# Options and arguments
+#-------------------------------------------------------------------------------
+my $DEFDEBUG = 0;
+my $DEF_COUNT = 12;
+
+#
+# Process options
+#
+my %options;
+Options( \%options );
+
+#
+# Default help
+#
+pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 )
+ if ( $options{'help'} );
+
+#
+# Collect options
+#
+my $DEBUG = ( $options{'debug'} ? $options{'debug'} : $DEFDEBUG );
+my $cfgfile
+ = ( defined( $options{config} ) ? $options{config} : $configfile );
+my $dry_run = ( defined( $options{'dry-run'} ) ? $options{'dry-run'} : 0 );
+my $silent = ( defined( $options{silent} ) ? $options{silent} : 0 );
+my $count = ( defined( $options{count} ) ? $options{count} : $DEF_COUNT );
+my $from = $options{from};
+
+_debug( $DEBUG >= 1, 'Host name: ' . $hostname );
+_debug( $DEBUG >= 1, 'Series name: ' . $seriesname );
+_debug( $DEBUG >= 1, 'Tags: ' . $tags );
+
+#-------------------------------------------------------------------------------
+# Configuration file - load data
+#-------------------------------------------------------------------------------
+my $conf = new Config::General(
+ -ConfigFile => $cfgfile,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %config = $conf->getall();
+
+#-------------------------------------------------------------------------------
+# Connect to the database
+#-------------------------------------------------------------------------------
+my $dbhost = $config{database}->{host} // '127.0.0.1';
+my $dbport = $config{database}->{port} // 3306;
+my $dbname = $config{database}->{name};
+my $dbuser = $config{database}->{user};
+my $dbpwd = $config{database}->{password};
+$dbh = DBI->connect( "dbi:mysql:host=$dbhost;port=$dbport;database=$dbname",
+ $dbuser, $dbpwd, { AutoCommit => 1 } )
+ or die $DBI::errstr;
+
+#
+# Enable client-side UTF8
+#
+$dbh->{mysql_enable_utf8} = 1;
+
+#-------------------------------------------------------------------------------
+# Find the latest show for reference purposes
+#-------------------------------------------------------------------------------
+$sth1 = $dbh->prepare(
+# q{SELECT id, date FROM eps
+# WHERE DATEDIFF(date,CURDATE()) <= 0 AND DATEDIFF(date,CURDATE()) >= -2
+# ORDER BY date DESC LIMIT 1}
+ q{SELECT id, date FROM eps
+ WHERE DATEDIFF(date,CURDATE()) BETWEEN -2 AND 0
+ ORDER BY date DESC LIMIT 1}
+);
+$sth1->execute;
+if ( $dbh->err ) {
+ warn $dbh->errstr;
+}
+
+$h1 = $sth1->fetchrow_hashref;
+
+my $ref_date = $h1->{date};
+my $ref_show = $h1->{id};
+
+#-------------------------------------------------------------------------------
+# Find the required hostid
+#-------------------------------------------------------------------------------
+$sth1 = $dbh->prepare(q{SELECT hostid FROM hosts WHERE host = ?});
+$sth1->execute($hostname);
+if ( $dbh->err ) {
+ warn $dbh->errstr;
+}
+
+unless ( $h1 = $sth1->fetchrow_hashref ) {
+ warn "Unable to find host '$hostname' - cannot continue\n";
+ exit 1;
+}
+
+$hostid = $h1->{hostid};
+
+#-------------------------------------------------------------------------------
+# Find the required series
+#-------------------------------------------------------------------------------
+$sth1 = $dbh->prepare(q{SELECT id FROM miniseries WHERE name = ?});
+$sth1->execute($seriesname);
+if ( $dbh->err ) {
+ warn $dbh->errstr;
+}
+
+unless ( $h1 = $sth1->fetchrow_hashref ) {
+ warn "Unable to find series '$seriesname' - cannot continue\n";
+ exit 1;
+}
+
+$series = $h1->{id};
+
+_debug( $DEBUG >= 2, 'Reference date: ' . $ref_date );
+_debug( $DEBUG >= 2, 'Reference show: ' . $ref_show );
+_debug( $DEBUG >= 2, 'Host id: ' . $hostid );
+_debug( $DEBUG >= 2, 'Series id: ' . $series );
+
+#-------------------------------------------------------------------------------
+# The start date comes from the -from=DATE option, the database or is defaulted
+#-------------------------------------------------------------------------------
+#
+# Use the date provided or the default
+#
+if ( ! defined( $from ) ) {
+ #
+ # Compute the first of the current month
+ #
+ _debug($DEBUG >= 3, "From date: Default");
+ @startdate = ( ( Today() )[ 0 .. 1 ], 1 );
+}
+elsif ( $from =~ /^$/ ) {
+ _debug($DEBUG >= 3, "From date: Database");
+ @startdate = get_next_date( $dbh, $series );
+}
+else {
+ #
+ # Parse the date, convert to start of month
+ #
+ _debug($DEBUG >= 3, "From date: Explicit");
+ @startdate = convert_date( $from, 0 );
+}
+_debug($DEBUG >= 3,"Start date: " . ISO8601_Date(@startdate));
+
+#-------------------------------------------------------------------------------
+# Set up for date manipulation
+#-------------------------------------------------------------------------------
+my @cdate = @startdate;
+my $monday = 1; # Day of week number 1-7, Monday-Sunday
+print "Start date: ", ISO8601_Date(@startdate), "\n" unless ($silent);
+
+#
+# The reference show, taken from the database
+#
+my @ref_date = split( /-/, $ref_date );
+print "Reference show: hpr$ref_show on ", ISO8601_Date(@ref_date), "\n\n"
+ unless ($silent);
+
+#
+# Prepare some SQL (Note stopgap fix for the INSERT statement associated with $sth3)
+#
+$sth1 = $dbh->prepare(q{SELECT id FROM eps where id = ?});
+$sth2 = $dbh->prepare(q{SELECT id, date FROM eps where title = ?});
+$sth3 = $dbh->prepare(
+ q{
+ INSERT INTO eps (id,date,hostid,title,summary,series,tags,
+ duration,notes,downloads)
+ VALUES(?,?,?,?,?,?,?,0,'',0)
+}
+);
+
+#
+# Compute a series of dates from the start date
+#
+for my $i ( 1 .. $count ) {
+ #
+ # Determine the next first Monday of the month and the show number that
+ # goes with it
+ #
+ @rdate = make_date( \@cdate, $monday, 1, 0 );
+ $show = $ref_show + Delta_Business_Days( @ref_date, @rdate );
+ _debug($DEBUG >= 3,"Date: " . ISO8601_Date(@rdate) . " Show: $show");
+
+ #
+ # Make the text strings for this month
+ #
+ @lastmonth = Add_Delta_YM( @rdate, 0, -1 );
+ $title
+ = sprintf( $titlefmt, Month_to_Text( $lastmonth[1] ), $lastmonth[0] );
+ $summary
+ = sprintf( $summaryfmt, Month_to_Text( $lastmonth[1] ),
+ $lastmonth[0] );
+ _debug($DEBUG >= 3,"Title: $title");
+ _debug($DEBUG >= 3,"Summary: $summary");
+
+ #
+ # Do we already have a show with this title?
+ #
+ $rv = $sth2->execute($title);
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+ if ( $rv > 0 ) {
+ $h2 = $sth2->fetchrow_hashref;
+ unless ($silent) {
+ printf
+ "Skipping; an episode already exists with title '%s' (hpr%s, %s)\n",
+ $title, $h2->{id}, $h2->{date};
+ }
+ @cdate = Add_Delta_YM( @cdate, 0, 1 );
+ next;
+ }
+
+ #
+ # Is this show number taken?
+ #
+ $rv = $sth1->execute($show);
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+ if ( $rv > 0 ) {
+ #
+ # Find a free slot
+ #
+ print "Slot $show for '$title' is allocated. " unless ($silent);
+ until ( $rv == 0 && ( Day_of_Week(@rdate) < 6 ) ) {
+ $show++ if ( Day_of_Week(@rdate) < 6 );
+ @rdate = Add_Delta_Days( @rdate, 1 );
+ $rv = $sth1->execute($show);
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+ }
+ print "Next free slot is $show\n" unless ($silent);
+ }
+
+ #
+ # Reserve the slot or pretend to
+ #
+ unless ($dry_run) {
+ $rv = $sth3->execute( $show, ISO8601_Date(@rdate), $hostid,
+ $title, $summary, $series, $tags );
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+ if ( $rv > 0 ) {
+ printf "Reserved show hpr%d on %s for '%s'\n",
+ $show, ISO8601_Date(@rdate), $title
+ unless ($silent);
+ }
+ else {
+ print "Error reserving slot for '$title'\n" unless ($silent);
+ }
+ }
+ else {
+ printf "Show hpr%d on %s for '%s' not reserved - dry run\n",
+ $show, ISO8601_Date(@rdate), $title
+ unless ($silent);
+ }
+
+ @cdate = Add_Delta_YM( @cdate, 0, 1 );
+}
+
+for my $sth ( $sth1, $sth2, $sth3 ) {
+ $sth->finish;
+}
+
+$dbh->disconnect;
+
+exit;
+
+#=== FUNCTION ================================================================
+# NAME: convert_date
+# PURPOSE: Convert a textual date (ideally YYYY-MM-DD) to a Date::Calc
+# date for the start of the given month.
+# PARAMETERS: $textdate date in text form
+# $force Boolean defining whether to skip validating
+# the date
+# RETURNS: The start of the month in the textual date in Date::Calc
+# format
+# DESCRIPTION: Parses the date string and makes a Date::Calc date from the
+# result where the day part is 1. Optionally checks that the
+# date isn't in the past, though $force = 1 ignores this check.
+# THROWS: No exceptions
+# COMMENTS: Requires Date::Calc and Date::Parse
+# Note the validation 'die' has a non-generic message
+# SEE ALSO: N/A
+#===============================================================================
+sub convert_date {
+ my ( $textdate, $force ) = @_;
+
+ my ( @today, @parsed, @startdate );
+
+ #
+ # Reference date
+ #
+ @today = Today();
+
+ #
+ # Parse and perform rudimentary validation on the $textdate date. Function
+ # 'strptime' returns "($ss,$mm,$hh,$day,$month,$year,$zone,$century)".
+ #
+ # The Date::Calc date $startdate[0] gets the returned year or the current
+ # year if no year was parsed, $startdate[1] gets the parsed month or the
+ # current month if no month was parsed, and $startdate[2] gets a day of 1.
+ #
+ @parsed = strptime($textdate);
+ die "Unable to parse date '$textdate'\n" unless @parsed;
+
+ @startdate = (
+ ( defined( $parsed[5] ) ? $parsed[5] + 1900 : $today[0] ), # year
+ ( defined( $parsed[4] ) ? $parsed[4] + 1 : $today[1] ), 1
+ );
+
+ #
+ # Unless we've overridden the check there should be a positive or zero
+ # difference in days between the target date and today's date to prevent
+ # going backwards in time.
+ #
+ unless ($force) {
+ unless ( Delta_Days( @today[ 0, 1 ], 1, @startdate ) ge 0 ) {
+ warn "Invalid date $textdate (in the past)\n";
+ die "Use -force to create a back-dated calendar\n";
+ }
+ }
+
+ return @startdate;
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: get_next_date
+# PURPOSE: Find the next unused date from the database
+# PARAMETERS: $dbh Database handle
+# $series The id of the Community News series (from
+# a previous query)
+# RETURNS: The start of the month of the next free date in Date::Calc
+# format
+# DESCRIPTION: Finds the latest reservation in the database. Uses the date
+# associated with this reservation, converts to Date::Calc
+# format, adds a month to it and ensures it's the first Monday
+# of that month (in case a non-standard reservation had been
+# made)
+# THROWS: No exceptions
+# COMMENTS: TODO: do we need the show number of the latest reservation?
+# SEE ALSO: N/A
+#===============================================================================
+sub get_next_date {
+ my ( $dbh, $series ) = @_;
+
+ my ( $sth, $h );
+ my ( $id, $lastdate, @startdate );
+
+ #
+ # Find the last reservation in the database
+ #
+ $sth = $dbh->prepare( q{
+ SELECT id, date
+ FROM eps WHERE series = ?
+ ORDER BY id DESC LIMIT 1;
+ }
+ );
+ $sth->execute($series);
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+
+ #
+ # Get the values returned
+ #
+ $h = $sth->fetchrow_hashref;
+ $id = $h->{id};
+ $lastdate = $h->{date};
+
+ #
+ # Convert the date to Date::Calc format, increment by a month and ensure
+ # it's the first Monday of the month (in case the last reservation is not
+ # on the right day for some reason - such as the day being reserved by
+ # some other mechanism)
+ #
+ @startdate = convert_date( $lastdate, 0 );
+ @startdate = Add_Delta_YM( @startdate, 0, 1 );
+ @startdate = make_date( \@startdate, 1, 1, 0 );
+
+ return @startdate;
+}
+
+#=== FUNCTION ================================================================
+# NAME: make_date
+# PURPOSE: Make the event date for recurrence
+# PARAMETERS: $refdate
+# An arrayref to the reference date array (usually
+# today's date)
+# $dow Day of week for the event date (1-7, 1=Monday)
+# $n The nth day of the week in the given month required
+# for the event date ($dow=1, $n=1 means first Monday)
+# $offset Number of days to offset the computed date
+# RETURNS: The resulting date as a list for Date::Calc
+# DESCRIPTION: We want to compute a simple date with an offset, such as
+# "the Saturday before the first Monday of the month". We do
+# this by computing a pre-offset date (first Monday of month)
+# then apply the offset (Saturday before).
+# THROWS: No exceptions
+# COMMENTS: TODO Needs more testing to be considered truly universal
+# SEE ALSO:
+#===============================================================================
+sub make_date {
+ my ( $refdate, $dow, $n, $offset ) = @_;
+
+ #
+ # Compute the required date: the "$n"th day of week "$dow" in the year and
+ # month in @$refdate. This could be a date in the past.
+ #
+ my @date = Nth_Weekday_of_Month_Year( @$refdate[ 0, 1 ], $dow, $n );
+
+ #
+ # If the computed date plus the offset is before the base date advance
+ # a month
+ #
+ if ( Day_of_Year(@date) + $offset < Day_of_Year(@$refdate) ) {
+ #
+ # Add a month and recompute
+ #
+ @date = Add_Delta_YM( @date, 0, 1 );
+ @date = Nth_Weekday_of_Month_Year( @date[ 0, 1 ], $dow, $n );
+ }
+
+ #
+ # Apply the day offset
+ #
+ @date = Add_Delta_Days( @date, $offset ) if $offset;
+
+ #
+ # Return a list
+ #
+ return (@date);
+}
+
+#=== FUNCTION ================================================================
+# NAME: Delta_Business_Days
+# PURPOSE: Computes the number of weekdays between two dates
+# PARAMETERS: @date1 - first date in Date::Calc format
+# @date2 - second date in Date::Calc format
+# RETURNS: The business day offset
+# DESCRIPTION: This is a direct copy of the routine of the same name on the
+# Date::Calc manpage.
+# THROWS: No exceptions
+# COMMENTS: Lifted from the manpage for Date::Calc
+# SEE ALSO: N/A
+#===============================================================================
+sub Delta_Business_Days {
+ my (@date1) = (@_)[ 0, 1, 2 ];
+ my (@date2) = (@_)[ 3, 4, 5 ];
+ my ( $minus, $result, $dow1, $dow2, $diff, $temp );
+
+ $minus = 0;
+ $result = Delta_Days( @date1, @date2 );
+ if ( $result != 0 ) {
+ if ( $result < 0 ) {
+ $minus = 1;
+ $result = -$result;
+ $dow1 = Day_of_Week(@date2);
+ $dow2 = Day_of_Week(@date1);
+ }
+ else {
+ $dow1 = Day_of_Week(@date1);
+ $dow2 = Day_of_Week(@date2);
+ }
+ $diff = $dow2 - $dow1;
+ $temp = $result;
+ if ( $diff != 0 ) {
+ if ( $diff < 0 ) {
+ $diff += 7;
+ }
+ $temp -= $diff;
+ $dow1 += $diff;
+ if ( $dow1 > 6 ) {
+ $result--;
+ if ( $dow1 > 7 ) {
+ $result--;
+ }
+ }
+ }
+ if ( $temp != 0 ) {
+ $temp /= 7;
+ $result -= ( $temp << 1 );
+ }
+ }
+ if ($minus) { return -$result; }
+ else { return $result; }
+}
+
+#=== FUNCTION ================================================================
+# NAME: ISO8601_Date
+# PURPOSE: Format a Date::Calc date in ISO8601 format
+# PARAMETERS: @date - a date in the Date::Calc format
+# RETURNS: Text string containing a YYYY-MM-DD date
+# DESCRIPTION: Just a convenience to allow a simple call like
+# $str = ISO8601_Date(@date)
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub ISO8601_Date {
+ my (@date) = (@_)[ 0, 1, 2 ];
+
+ if ( check_date(@date) ) {
+ return sprintf( "%04d-%02d-%02d", @date );
+ }
+ else {
+ return "*Invalid Date*";
+ }
+}
+
+#=== FUNCTION ================================================================
+# NAME: _debug
+# PURPOSE: Prints debug reports
+# PARAMETERS: $active Boolean: 1 for print, 0 for no print
+# $message Message to print
+# RETURNS: Nothing
+# DESCRIPTION: Outputs a message if $active is true. It removes any trailing
+# newline and then adds one in the 'print' to the caller doesn't
+# have to bother. Prepends the message with 'D> ' to show it's
+# a debug message.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub _debug {
+ my ( $active, $message ) = @_;
+
+ chomp($message);
+ print "D> $message\n" if $active;
+}
+
+#=== FUNCTION ================================================================
+# NAME: Options
+# PURPOSE: Processes command-line options
+# PARAMETERS: $optref Hash reference to hold the options
+# RETURNS: Undef
+# DESCRIPTION:
+# THROWS: no exceptions
+# COMMENTS: none
+# SEE ALSO: n/a
+#===============================================================================
+sub Options {
+ my ($optref) = @_;
+
+ my @options = (
+ "help", "debug=i", "config=s", "from:s",
+ "count=i", "dry-run!", "silent!",
+ );
+
+ if ( !GetOptions( $optref, @options ) ) {
+ pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 );
+ }
+
+ return;
+}
+
+__END__
+
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+# Application Documentation
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+#{{{
+
+=head1 NAME
+
+reserve_cnews - reserve Community News shows in the HPR database
+
+=head1 VERSION
+
+This documentation refers to B version 0.0.14
+
+=head1 USAGE
+
+ ./reserve_cnews [-help] [-from[=DATE]] [-count=COUNT]
+ [-[no]dry-run] [-[no]silent] [-config=FILE] [-debug=N]
+
+ Examples:
+
+ ./reserve_cnews -help
+ ./reserve_cnews
+ ./reserve_cnews -from=1-June-2014 -dry-run
+ ./reserve_cnews -from=15-Aug-2015 -count=6
+ ./reserve_cnews -from=2015-12-06 -count=1 -silent
+ ./reserve_cnews -from -count=1
+ ./reserve_cnews -from -count=2 -debug=4
+ ./reserve_cnews -config=.hpr_livedb.cfg -from=1-March-2019 -dry-run
+
+=head1 OPTIONS
+
+=over 8
+
+=item B<-help>
+
+Prints a brief help message describing the usage of the program, and then exits.
+
+=item B<-from=DATE> or B<-from>
+
+This option defines the starting date from which reservations are to be
+created. The program ignores the day part, though it must be provided, and
+replaces it with the first day of the month.
+
+The date format should be B (e.g. 12-Jun-2014), B
+(e.g. 12-06-2014) or B (e.g. 2014-06-12).
+
+If this option is omitted the current date is used.
+
+If the B part is omitted the script will search the database for the
+reservation with the latest date and will use it as the starting point to
+generate B<-count=COUNT> (or the default 12) reservations.
+
+=item B<-count=COUNT>
+
+This option defines the number of slots to reserve.
+
+If this option is omitted then 12 slots are reserved.
+
+=item B<-[no]dry-run>
+
+This option in the form B<-dry-run> causes the program omit the step of adding
+reservations to the database. In the form B<-nodry-run> or if omitted, the
+program will perform the update(s).
+
+=item B<-[no]silent>
+
+This option in the form B<-silent> causes the program omit the reporting of
+what it has done. In the form B<-nosilent> or if omitted, the program will
+report what it is doing.
+
+=item B<-config=FILE>
+
+This option defines a configuration file other than the default
+I<.hpr_db.cfg>. The file must be formatted as described below in the section
+I.
+
+=item B<-debug=N>
+
+Sets the level of debugging. The default is 0: no debugging.
+
+Values are:
+
+=over 4
+
+=item 1
+
+Produces details of some of the built-in values used.
+
+=item 2
+
+Produces any output defined for lower levels as well as details of the values
+taken from the database for use when reserving the show(s).
+
+=item 3
+
+Produces any output defined for lower levels as well as:
+
+=over 4
+
+=item .
+
+Details of how the `-from` date is being interpreted: default, computed from
+the database or explicit. The actual date being used is reported.
+
+=item .
+
+Details of all dates chosen and their associated sho numbers using the
+algorithm "first Monday of the month".
+
+=item .
+
+The show title chosen for each reservation is displayed as well as the summary.
+
+=back
+
+=back
+
+=back
+
+=head1 DESCRIPTION
+
+Hacker Public Radio produces a Community News show every month. The show is
+recorded on the Saturday before the first Monday of the month, and should be
+released as soon as possible afterwards.
+
+This program reserves future slots in the database for upcoming shows. It
+computes the date of the first Monday of all of the months in the requested
+sequence then determines which show number matches that date. It writes rows
+into the I table containing the episode number, the host
+identifier ('HPR Admins') and the reason for the reservation.
+
+It is possible that an HPR host has already requested the slot that this
+program determines it should reserve. When this happens the program increments
+the episode number and checks again, and repeats this process until a free
+slot is discovered.
+
+It is also possible that a reservation has previously been made in the
+I table. When this case occurs the program ignores this
+particular reservation.
+
+=head1 DIAGNOSTICS
+
+=over 8
+
+=item B
+
+The date element of the B<-from=DATE> option is not valid. See the description
+of this option for details of what formats are acceptable.
+
+=item B
+
+The program can generate warning messages from the database.
+
+=item B
+
+The script needs to find the id number relating to the host that will be used
+for Community News episodes. It does this by looking in the hosts table for
+the name "HPR Volunteers". If this cannot be found, perhaps because it has
+been changed, then the script cannot continue. The remedy is to change the
+variable $hostname to match the new name.
+
+=item B
+
+The script needs to find the id number relating to the series that will be
+used for Community News episodes. It does this by looking in the miniseries
+table for the name "HPR Community News". If this cannot be found, perhaps
+because it has been changed, then the script cannot continue. The remedy is to
+change the variable $seriesname to match the new name.
+
+=back
+
+
+=head1 CONFIGURATION AND ENVIRONMENT
+
+The program obtains the credentials it requires for connecting to the HPR
+database by loading them from a configuration file. The file is called
+B<.hpr_db.cfg> and should contain the following data:
+
+
+ host = 127.0.0.1
+ port = PORT
+ name = DBNAME
+ user = USER
+ password = PASSWORD
+
+
+=head1 DEPENDENCIES
+
+ Config::General
+ Data::Dumper
+ Date::Calc
+ Date::Parse
+ DBI
+ Getopt::Long
+ Pod::Usage
+
+=head1 BUGS AND LIMITATIONS
+
+There are no known bugs in this module.
+Please report problems to Dave Morriss (Dave.Morriss@gmail.com)
+Patches are welcome.
+
+=head1 AUTHOR
+
+Dave Morriss (Dave.Morriss@gmail.com)
+
+=head1 LICENCE AND COPYRIGHT
+
+Copyright (c) 2014 - 2023 Dave Morriss (Dave.Morriss@gmail.com). All
+rights reserved.
+
+This module is free software; you can redistribute it and/or
+modify it under the same terms as Perl itself. See perldoc perlartistic.
+
+=cut
+
+#}}}
+
+# [zo to open fold, zc to close]
+
+# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
+
diff --git a/Community_News/shownote_template.tpl b/Community_News/shownote_template.tpl
new file mode 120000
index 0000000..8d015a9
--- /dev/null
+++ b/Community_News/shownote_template.tpl
@@ -0,0 +1 @@
+shownote_template11.tpl
\ No newline at end of file
diff --git a/Community_News/shownote_template10.tpl b/Community_News/shownote_template10.tpl
new file mode 100644
index 0000000..a5902c4
--- /dev/null
+++ b/Community_News/shownote_template10.tpl
@@ -0,0 +1,164 @@
+[%# shownote_template10.tpl 2018-11-05 -%]
+[%# HTML snippet for insertion into the database -%]
+[%# This one uses the new format for the mailing list data, and partitions -%]
+[%# comments into past and current. It also marks comments that don't need -%]
+[%# to be read when -markcomments is selected. It requires make_shownotes > V0.0.28 -%]
+[%- USE date -%]
+[%- prefix = "http://hackerpublicradio.org"
+ correspondents = "$prefix/correspondents.php"
+ mailthreads = "$prefix/pipermail/hpr_hackerpublicradio.org/$review_year-$review_month/thread.html" -%]
+[%- DEFAULT skip_comments = 0
+ mark_comments = 0 -%]
+[%- IF mark_comments == 1 %]
+
+[%- END %]
+[%# For the '-mailnotes' option without a file we generate our own inclusion. -%]
+[%# We pretend 'default_mail' is a filename in the calling script. Messy. -%]
+[% BLOCK default_mail -%]
+[% mailthreads %]
+[% END -%]
+
+New hosts
+
+[% IF hosts.size > 0 -%]
+Welcome to our new host[%- hosts.size > 1 ? 's' : '' -%]:
+[%- count = 0 %]
+[%# List the new hosts. If a name contains a comma quote it. -%]
+[%- FOREACH row IN hosts %]
+ [%- count = count + 1 %]
+ [%- hostname = (row.host.search(',') ? row.host.replace('^(.*)$','"$1"') : row.host) %]
+ [% hostname %]
+ [%- count < hosts.size ? ', ' : '.' %]
+[%- END %]
+[% ELSE -%]
+There were no new hosts this month.
+[% END -%]
+
+
+Last Month's Shows
+[%# The 'summary' attribute is deprecated in HTML5 but is included here to -%]
+[%# prevent errors being reported in the note checker -%]
+
+
+[%# ---------------------------------------------------------------------------------------- -%]
+[%# Skip comments if told to by the caller -%]
+[%- IF skip_comments == 0 -%]
+Comments this month
+
+[% IF comment_count > 0 -%]
+[%- IF mark_comments == 1 -%]
+Note to Volunteers : Comments marked in green were read in the last
+Community News show and should be ignored in this one.
+[%- END -%]
+These are comments which have been made during the past month, either to shows
+released during the month or to past shows.
+There [%- comment_count == 1 ? "is $comment_count comment" : "are $comment_count comments" -%] in total.
+[% IF past_count > 0 -%]
+There [% past_count == 1 ? "is $past_count comment" : "are $past_count comments" %] on
+[% past.size %] previous [% past.size == 1 ? "show" : "shows" %]:
+
+[%- FOREACH ep IN past.keys.sort -%]
+[%- arr = past.$ep -%]
+hpr[% arr.0.episode %]
+([% arr.0.date %]) "[% arr.0.title %] "
+by [% arr.0.host %] .
+
+
+[%- FOREACH row IN arr -%]
+[%- IF mark_comments == 1 && ((row.comment_timestamp_ut <= last_recording) && (arr.0.date.substr(0,7) == last_month)) -%]
+
+[%- ELSE %]
+
+[%- END %]
+Comment [% row.index %] :
+[% row.comment_author_name FILTER html_entity -%] on [% date.format(row.comment_timestamp_ut,'%Y-%m-%d','UTC') -%]:
+[%- IF row.comment_title.length > 0 %]
+"[% row.comment_title %]"
+[%- ELSE -%]
+"[no title]"
+[%- END -%]
+
+[%- END -%]
+
+
+[%- END -%]
+
+[%- END %]
+[%# ---------------------------------------------------------------------------------------- -%]
+[% cc = (comment_count - past_count) -%]
+[% IF cc > 0 -%]
+There [% cc == 1 ? "is $cc comment" : "are $cc comments" %] on [% current.size %] of this month's shows:
+
+[%- FOREACH ep IN current.keys.sort -%]
+[%- arr = current.$ep -%]
+hpr[% arr.0.episode %]
+([% arr.0.date %]) "[% arr.0.title %] "
+by [% arr.0.host %] .
+
+
+[%- FOREACH row IN arr -%]
+Comment [% row.index %] :
+[% row.comment_author_name FILTER html_entity -%] on [% date.format(row.comment_timestamp_ut,'%Y-%m-%d','UTC') -%]:
+[%- IF row.comment_title.length > 0 %]
+"[% row.comment_title %]"
+[%- ELSE -%]
+"[no title]"
+[%- END -%]
+
+[%- END -%]
+
+
+[%- END -%]
+
+[%- END %]
+[%- ELSE %]
+There were no comments this month.
+[%- END %]
+[%- END %]
+
+[%# ---------------------------------------------------------------------------------------- -%]
+[%- IF includefile.defined -%]
+Mailing List discussions
+
+Policy decisions surrounding HPR are taken by the community as a whole. This
+discussion takes place on the Mail List which is open to all HPR listeners and
+contributors. The discussions are open and available on the HPR server under
+Mailman .
+
+The threaded discussions this month can be found here:
+[% INCLUDE $includefile -%]
+[%- END %]
+
+[%# ---------------------------------------------------------------------------------------- -%]
+[%# Any other business? -%]
+[% IF aob == 1 -%]
+Any other business
+[% INCLUDE $aobfile -%]
+[%- END %]
+[%#
+ # vim: syntax=tt2:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
+-%]
+
diff --git a/Community_News/shownote_template11.tpl b/Community_News/shownote_template11.tpl
new file mode 100644
index 0000000..1668ee0
--- /dev/null
+++ b/Community_News/shownote_template11.tpl
@@ -0,0 +1,234 @@
+[%# shownote_template11.tpl 2024-05-07 -%]
+[%# HTML snippet for insertion into the database -%]
+[%# This one uses the new format for the mailing list data, and partitions -%]
+[%# comments into past and current. It also marks comments that don't need -%]
+[%# to be read when -markcomments is selected. It requires make_shownotes >= V0.0.30 -%]
+[%- USE date -%]
+[%- USE pad4 = format('%04d') -%]
+[%- correspondents = "https://hackerpublicradio.org/correspondents"
+ mailbase="https://lists.hackerpublicradio.com/pipermail/hpr"
+ mailthreads = "$mailbase/$review_year-$review_month/thread.html" -%]
+[%- DEFAULT skip_comments = 0
+ mark_comments = 0
+ ctext = 0
+ ignore_count = 0
+ missed_count = 0
+ past_count = 0
+-%]
+[%# Embedded CSS. The 'table' and 'hr' settings are always there but the rest is only for if -%]
+[%# we are marking comments -%]
+
+[%# For the '-mailnotes' option without a file we generate our own inclusion. -%]
+[%# We pretend 'default_mail' is a filename in the calling script. Messy. -%]
+[% BLOCK default_mail -%]
+[% mailthreads %]
+[% END -%]
+
+New hosts
+
+[% IF hosts.size > 0 -%]
+Welcome to our new host[%- hosts.size > 1 ? 's' : '' -%]:
+[%- count = 0 %]
+[%# List the new hosts. If a name contains a comma quote it. -%]
+[%- FOREACH row IN hosts %]
+ [%- count = count + 1 %]
+ [%- hostname = (row.host.search(',') ? row.host.replace('^(.*)$','"$1"') : row.host) %]
+ [% hostname %]
+ [%- count < hosts.size ? ', ' : '.' %]
+[%- END %]
+[% ELSE -%]
+There were no new hosts this month.
+[% END -%]
+
+
+Last Month's Shows
+[%# The id 't01' is in the HPR CSS but might give trouble on the IA -%]
+
+
+[%# ---------------------------------------------------------------------------------------- -%]
+[%# Skip comments if told to by the caller -%]
+[%- IF skip_comments == 0 -%]
+[%# Handle any missed comments if mark_comments is true -%]
+[%- IF mark_comments == 1 && missed_count > 0 -%]
+
+
Missed comment[%- missed_comments.size > 1 ? 's' : '' -%] last month
+
Note to Volunteers : These are comments for shows last month that were not read in the last show because they arrived on or after the recording day. This section will be removed before these notes are released.
+
+[%- FOREACH comment IN missed_comments -%]
+hpr[% comment.episode %]
+([% comment.date %]) "[% comment.title %] " by [% comment.host %] .
+Summary: "[% comment.summary %] "
+From: [% comment.comment_author_name FILTER html_entity -%] on [% date.format(comment.comment_timestamp_ut,'%Y-%m-%d','UTC') -%]:
+[%- IF comment.comment_title.length > 0 %]
+"[% comment.comment_title %]"
+[%- ELSE -%]
+"[no title]"
+[%- END -%]
+ [% comment.comment_text FILTER html_line_break %]
+
+[%- END -%]
+
+[%- END -%]
+[%# ---------------------------------------------------------------------------------------- -%]
+Comments this month
+
+[% IF comment_count > 0 -%]
+[%- IF mark_comments == 1 && ignore_count > 0 -%]
+Note to Volunteers : Comments marked in green were read in the last
+Community News show and should be ignored in this one.
+[%- END -%]
+These are comments which have been made during the past month, either to shows released during the month or to past shows.
+There [%- comment_count == 1 ? "is $comment_count comment" : "are $comment_count comments" -%] in total.
+[% IF past_count > 0 -%]
+Past shows
+There [% past_count == 1 ? "is $past_count comment" : "are $past_count comments" %] on
+[% past.size %] previous [% past.size == 1 ? "show" : "shows" %]:
+
+[%# Loop through by episode then by comment relating to that episode -%]
+[%- FOREACH ep IN past.keys.sort -%]
+[%- arr = past.$ep -%]
+hpr[% arr.0.episode %]
+([% arr.0.date %]) "[% arr.0.title %] "
+by [% arr.0.host %] .
+[%- IF mark_comments == 1 || ctext == 1 -%]
+Summary: "[% arr.0.summary %] "
+[%- END %]
+
+
+[%- FOREACH row IN arr -%]
+[%# IF mark_comments == 1 && ((row.comment_timestamp_ut <= last_recording) && (arr.0.date.substr(0,7) == last_month)) -%]
+[%# IF mark_comments == 1 && ((row.comment_released_ut <= last_recording) && (arr.0.date.substr(0,7) == last_month)) -%]
+[%- IF mark_comments == 1 && row.ignore == 1 -%]
+
+[%- ELSE %]
+
+[%- END %]
+Comment [% row.index %] :
+[% row.comment_author_name FILTER html_entity -%] on [% date.format(row.comment_timestamp_ut,'%Y-%m-%d','UTC') -%]:
+[%- IF row.comment_title.length > 0 %]
+"[% row.comment_title %]"
+[%- ELSE -%]
+"[no title]"
+[%- END -%]
+[%# Add the comment body in too if ctext is true -%]
+[%- IF ctext == 1 %]
+ [% row.comment_text FILTER html_line_break %]
+
+[%- ELSE -%]
+
+[%- END -%]
+[%- END -%]
+
+
+[%- END -%]
+
+[%- IF mark_comments == 1 || ctext == 1 -%]
+Updated on [% date.format(date.now,'%Y-%m-%d %H:%M:%S') %]
+[%- END -%]
+[%- END %]
+[%# ---------------------------------------------------------------------------------------- -%]
+[% cc = (comment_count - past_count) -%]
+[% IF cc > 0 -%]
+This month's shows
+There [% cc == 1 ? "is $cc comment" : "are $cc comments" %] on [% current.size %] of this month's shows:
+
+[%- FOREACH ep IN current.keys.sort -%]
+[%- arr = current.$ep -%]
+hpr[% arr.0.episode %]
+([% arr.0.date %]) "[% arr.0.title %] "
+by [% arr.0.host %] .
+
+
+[%- FOREACH row IN arr -%]
+Comment [% row.index %] :
+[% row.comment_author_name FILTER html_entity -%] on [% date.format(row.comment_timestamp_ut,'%Y-%m-%d','UTC') -%]:
+[%- IF row.comment_title.length > 0 %]
+"[% row.comment_title %]"
+[%- ELSE -%]
+"[no title]"
+[%- END -%]
+
+[%- END -%]
+
+
+[%- END -%]
+
+[%- END %]
+[%- ELSE %]
+There were no comments this month.
+[%- END %]
+[%- END %]
+
+[%# ---------------------------------------------------------------------------------------- -%]
+[%- IF includefile.defined -%]
+Mailing List discussions
+
+Policy decisions surrounding HPR are taken by the community as a whole. This
+discussion takes place on the Mail List which is open to all HPR listeners and
+contributors. The discussions are open and available on the HPR server under
+Mailman .
+
+The threaded discussions this month can be found here:
+[% INCLUDE $includefile -%]
+[%- END %]
+
+[%# ---------------------------------------------------------------------------------------- -%]
+Events Calendar
+With the kind permission of LWN.net we are linking to
+The LWN.net Community Calendar .
+Quoting the site:
+This is the LWN.net community event calendar, where we track
+events of interest to people using and developing Linux and free software.
+Clicking on individual events will take you to the appropriate web
+page.
+
+[%# ---------------------------------------------------------------------------------------- -%]
+[%# Any other business? -%]
+[% IF aob == 1 -%]
+Any other business
+[% INCLUDE $aobfile -%]
+[%- END %]
+[%#
+ # vim: syntax=tt2:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
+-%]
+
diff --git a/Community_News/shownote_template2.tpl b/Community_News/shownote_template2.tpl
new file mode 100644
index 0000000..fd133c7
--- /dev/null
+++ b/Community_News/shownote_template2.tpl
@@ -0,0 +1,95 @@
+[%# shownote_template.tpl -%]
+[%- correspondents = "http://hackerpublicradio.org/correspondents.php" %]
+[%- DEFAULT skip_comments = 0 %]
+
+
+
+HPR Community News for [% review_month %]
+
+New hosts
+
+[% IF hosts.size > 0 -%]
+Welcome to our new hosts:
+[%- count = 0 %]
+[%- FOREACH row IN hosts %]
+ [%- count = count + 1 %]
+ [% row.host %]
+ [%- count < hosts.size ? ', ' : '.' %]
+[%- END %]
+[% ELSE -%]
+There were no new hosts this month.
+[% END -%]
+
+
+Last Month's Shows
+
+
+[%- IF includefile.defined %]
+Mailing List discussions
+
+Policy decisions surrounding HPR are taken by the community as a whole. This discussion takes
+place on the Mail List which is open to all
+HPR listeners and contributors. The discussions are open and available on the
+Gmane
+archive.
+
+
+Discussed this month were:
+[%- INCLUDE $includefile %]
+
+[%- END %]
+
+[%# Skip comments if told to by the caller %]
+[%- IF skip_comments == 0 %]
+Comments this month
+
+[% IF comments.size > 0 -%]
+There are [% comments.size %] comments:
+
+[%- last_ep = 0 %]
+[%- FOREACH row IN comments %]
+[%- IF last_ep != row.episode %]
+
+[%- END %]
+[%- last_ep = row.episode %]
+hpr[% row.episode %] [% row.comment_author_name FILTER html_entity -%]:
+[%- IF row.comment_title.length > 0 %]
+"[% row.comment_title FILTER html_entity %]",
+[%- ELSE -%]
+"[no title]",
+[%- END -%]
+relating to the show hpr[% row.episode %]
+([% row.date %]) "[% row.title FILTER html_entity %] "
+by [% row.host %] .
+[%- END %]
+
+[%- ELSE %]
+There were no comments this month.
+[%- END %]
+[%- END %]
+
+
+
+[%#
+ # vim: syntax=tt2:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
+-%]
+
diff --git a/Community_News/shownote_template3.tpl b/Community_News/shownote_template3.tpl
new file mode 100644
index 0000000..641677e
--- /dev/null
+++ b/Community_News/shownote_template3.tpl
@@ -0,0 +1,102 @@
+[%# shownote_template3.tpl -%]
+[%- USE date -%]
+[%- correspondents = "http://hackerpublicradio.org/correspondents.php" -%]
+[%- DEFAULT skip_comments = 0 -%]
+
+
+
+HPR Community News for [% review_month %]
+
+New hosts
+
+[% IF hosts.size > 0 -%]
+Welcome to our new hosts:
+[%- count = 0 %]
+[%- FOREACH row IN hosts %]
+ [%- count = count + 1 %]
+ [% row.host %]
+ [%- count < hosts.size ? ', ' : '.' %]
+[%- END %]
+[% ELSE -%]
+There were no new hosts this month.
+[% END -%]
+
+
+Last Month's Shows
+
+
+[%- IF includefile.defined -%]
+Mailing List discussions
+
+Policy decisions surrounding HPR are taken by the community as a whole. This discussion takes
+place on the Mail List which is open to all
+HPR listeners and contributors. The discussions are open and available on the
+Gmane
+archive.
+
+
+Discussed this month were:
+[%- INCLUDE $includefile %]
+
+[%- END -%]
+
+[%# Skip comments if told to by the caller -%]
+[%- IF skip_comments == 0 -%]
+Comments this month
+
+[% IF comments.size > 0 -%]
+There are [% comments.size %] comments:
+
+[%- last_ep = 0 -%]
+[%- FOREACH row IN comments -%]
+[%= IF last_ep != row.episode =%]
+[%= IF last_ep != 0 %]
+
+[%= END %]
+hpr[% row.episode %]
+([% row.date %]) "[% row.title FILTER html_entity %] "
+by [% row.host %] .
+
+[%- END -%]
+[%- last_ep = row.episode %]
+[% row.comment_author_name FILTER html_entity -%] on [% date.format(row.comment_timestamp,'%Y-%m-%d') -%]:
+[%- IF row.comment_title.length > 0 -%]
+"[% row.comment_title FILTER html_entity %]"
+[%- ELSE -%]
+"[no title]"
+[%- END -%]
+
+[%- END -%]
+
+
+
+[%- ELSE %]
+There were no comments this month.
+[%- END %]
+[%- END %]
+
+
+
+[%#
+ # vim: syntax=tt2:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
+-%]
+
diff --git a/Community_News/shownote_template4.tpl b/Community_News/shownote_template4.tpl
new file mode 100644
index 0000000..8b3a60b
--- /dev/null
+++ b/Community_News/shownote_template4.tpl
@@ -0,0 +1,121 @@
+[%# shownote_template4.tpl -%]
+[%- correspondents = "http://hackerpublicradio.org/correspondents.php" %]
+[%- DEFAULT skip_comments = 0 %]
+
+
+
+HPR Community News for [% review_month %]
+
+New hosts
+
+[% IF hosts.size > 0 -%]
+Welcome to our new [% hosts.size == 1 ? 'host' : 'hosts' %]:
+[%- count = 0 %]
+[%- FOREACH row IN hosts %]
+ [%- count = count + 1 %]
+ [% row.host %]
+ [%- count < hosts.size ? ', ' : '.' %]
+[%- END %]
+[% ELSE -%]
+There were no new hosts this month.
+[% END -%]
+
+
+Last Month's Shows
+
+
+[%- IF includefile.defined %]
+Mailing List discussions
+
+Policy decisions surrounding HPR are taken by the community as a whole. This discussion takes
+place on the Mail List which is open to all
+HPR listeners and contributors. The discussions are open and available on the
+Gmane
+archive.
+
+
+Discussed this month were:
+[%- INCLUDE $includefile %]
+
+[%- END %]
+
+[%# Skip comments if told to by the caller %]
+[%- IF skip_comments == 0 %]
+Comments this month
+
+[% IF comments.size > 0 -%]
+There are [% comments.size %] comments:
+
+
+
+
+ Show
+ Title
+ Host
+ From
+ Subject
+
+
+
+ [%- FOREACH row IN comments %]
+
+ [% row.episode %]
+ [% row.title FILTER html_entity %]
+ [% row.host %]
+ [% row.comment_author_name FILTER html_entity %]
+
+ [%- IF row.comment_title.length > 0 %]
+ [% row.comment_title FILTER html_entity %]
+ [%- ELSE %]
+ -
+ [%- END %]
+
+ [%- END %]
+
+
+
+
+
+[%- FOREACH row IN comments %]
+hpr[% row.episode %]
+[% row.comment_author_name FILTER html_entity %]:
+[%- IF row.comment_title.length > 0 %]
+"[% row.comment_title FILTER html_entity %]",
+[%- ELSE %]
+"[no title]",
+[%- END %]
+relating to the show hpr[% row.episode %]
+([% row.date %]) "[% row.title FILTER html_entity %] "
+by [% row.host %] .
+[%- END %]
+
+[%- ELSE %]
+There were no comments this month.
+[%- END %]
+[%- END %]
+
+
+
+[%#
+ # vim: syntax=tt2:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
+-%]
+
diff --git a/Community_News/shownote_template5.tpl b/Community_News/shownote_template5.tpl
new file mode 100644
index 0000000..677c07e
--- /dev/null
+++ b/Community_News/shownote_template5.tpl
@@ -0,0 +1,100 @@
+[%# shownote_template5.tpl 2016-07-23 -%]
+[%# HTML snippet for insertion into the database -%]
+[%- USE date -%]
+[%- correspondents = "http://hackerpublicradio.org/correspondents.php" -%]
+[%- DEFAULT skip_comments = 0 -%]
+[%# HPR Community News for {% review_month %} -%]
+
+New hosts
+
+[% IF hosts.size > 0 -%]
+Welcome to our new host[%- hosts.size > 1 ? 's' : '' -%]:
+[%- count = 0 %]
+[%- FOREACH row IN hosts %]
+ [%- count = count + 1 %]
+ [% row.host %]
+ [%- count < hosts.size ? ', ' : '.' %]
+[%- END %]
+[% ELSE -%]
+There were no new hosts this month.
+[% END -%]
+
+
+Last Month's Shows
+[%# The 'summary' attribute is deprecated in HTML5 but is included here to -%]
+[%# prevent errors being reported in the note checker -%]
+
+
+[%- IF includefile.defined -%]
+Mailing List discussions
+
+Policy decisions surrounding HPR are taken by the community as a whole. This discussion takes
+place on the Mail List which is open to all
+HPR listeners and contributors. The discussions are open and available on the
+Gmane
+archive.
+
+The main threads this month were:
+[% INCLUDE $includefile -%]
+[%- END %]
+
+[%# Skip comments if told to by the caller -%]
+[%- IF skip_comments == 0 -%]
+Comments this month
+
+[% IF comment_count > 0 -%]
+These are comments which have been made during the past month, either to shows
+released during the month or to past shows.
+There [%- comment_count == 1 ? "is $comment_count comment" : "are $comment_count comments" -%]:
+
+[%- last_ep = 0 -%]
+[%- FOREACH row IN comments -%]
+[%= IF last_ep != row.episode =%]
+[%= IF last_ep != 0 %]
+
+[%= END %]
+hpr[% row.episode %]
+([% row.date %]) "[% row.title %] "
+by [% row.host %] .
+
+[%- END -%]
+[%- last_ep = row.episode %]
+[%= IF row.in_range =%]
+Comment [% row.index %] :
+[% row.comment_author_name FILTER html_entity -%] on [% date.format(row.comment_timestamp,'%Y-%m-%d') -%]:
+[%- IF row.comment_title.length > 0 %]
+"[% row.comment_title FILTER html_entity %]"
+[%- ELSE -%]
+"[no title]"
+[%- END -%]
+
+[%- END -%]
+[%- END -%]
+
+
+
+[%- ELSE %]
+There were no comments this month.
+[%- END %]
+[%- END %]
+[%#
+ # vim: syntax=tt2:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
+-%]
+
diff --git a/Community_News/shownote_template6.tpl b/Community_News/shownote_template6.tpl
new file mode 100644
index 0000000..c680b1a
--- /dev/null
+++ b/Community_News/shownote_template6.tpl
@@ -0,0 +1,107 @@
+[%# shownote_template6.tpl 2016-08-17 -%]
+[%# HTML snippet for insertion into the database -%]
+[%# This one uses the new format for the mailing list data -%]
+[%- USE date -%]
+[%- prefix = "http://hackerpublicradio.org"
+ correspondents = "$prefix/correspondents.php"
+ mailthreads = "$prefix/pipermail/hpr_hackerpublicradio.org/$review_year-$review_month/thread.html" -%]
+[%- DEFAULT skip_comments = 0 -%]
+[%# For the '-mailnotes' option without a file we generate our own inclusion. -%]
+[%# We pretend 'default_mail' is a filename in the calling script. Messy. -%]
+[% BLOCK default_mail -%]
+[% mailthreads %]
+[% END -%]
+
+New hosts
+
+[% IF hosts.size > 0 -%]
+Welcome to our new host[%- hosts.size > 1 ? 's' : '' -%]:
+[%- count = 0 %]
+[%- FOREACH row IN hosts %]
+ [%- count = count + 1 %]
+ [% row.host %]
+ [%- count < hosts.size ? ', ' : '.' %]
+[%- END %]
+[% ELSE -%]
+There were no new hosts this month.
+[% END -%]
+
+
+Last Month's Shows
+[%# The 'summary' attribute is deprecated in HTML5 but is included here to -%]
+[%# prevent errors being reported in the note checker -%]
+
+
+[%- IF includefile.defined -%]
+Mailing List discussions
+
+Policy decisions surrounding HPR are taken by the community as a whole. This discussion takes
+place on the Mail List which is open to all
+HPR listeners and contributors. The discussions are open and available on the
+Gmane
+archive and the Mailman archive.
+
+The threaded discussions this month can be found here:
+[% INCLUDE $includefile -%]
+[%- END %]
+
+[%# Skip comments if told to by the caller -%]
+[%- IF skip_comments == 0 -%]
+Comments this month
+
+[% IF comment_count > 0 -%]
+These are comments which have been made during the past month, either to shows
+released during the month or to past shows.
+There [%- comment_count == 1 ? "is $comment_count comment" : "are $comment_count comments" -%]:
+
+[%- last_ep = 0 -%]
+[%- FOREACH row IN comments -%]
+[%= IF last_ep != row.episode =%]
+[%= IF last_ep != 0 %]
+
+[%= END %]
+[% row.past ? "[hpr$row.episode]" : "hpr$row.episode" %]
+([% row.date %]) "[% row.title %] "
+by [% row.host %] .
+
+[%- END -%]
+[%- last_ep = row.episode %]
+[%= IF row.in_range =%]
+Comment [% row.index %] :
+[% row.comment_author_name FILTER html_entity -%] on [% date.format(row.comment_timestamp,'%Y-%m-%d') -%]:
+[%- IF row.comment_title.length > 0 %]
+"[% row.comment_title FILTER html_entity %]"
+[%- ELSE -%]
+"[no title]"
+[%- END -%]
+
+[%- END -%]
+[%- END -%]
+
+
+
+[%- ELSE %]
+There were no comments this month.
+[%- END %]
+[%- END %]
+[%#
+ # vim: syntax=tt2:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
+-%]
+
diff --git a/Community_News/shownote_template7.tpl b/Community_News/shownote_template7.tpl
new file mode 100644
index 0000000..7cd8a12
--- /dev/null
+++ b/Community_News/shownote_template7.tpl
@@ -0,0 +1,138 @@
+[%# shownote_template7.tpl 2016-10-02 -%]
+[%# HTML snippet for insertion into the database -%]
+[%# This one uses the new format for the mailing list data, and partitions -%]
+[%# comments into past and current. It requires make_shownotes > V0.0.21 -%]
+[%- USE date -%]
+[%- prefix = "http://hackerpublicradio.org"
+ correspondents = "$prefix/correspondents.php"
+ mailthreads = "$prefix/pipermail/hpr_hackerpublicradio.org/$review_year-$review_month/thread.html" -%]
+[%- DEFAULT skip_comments = 0 -%]
+[%# For the '-mailnotes' option without a file we generate our own inclusion. -%]
+[%# We pretend 'default_mail' is a filename in the calling script. Messy. -%]
+[% BLOCK default_mail -%]
+[% mailthreads %]
+[% END -%]
+
+New hosts
+
+[% IF hosts.size > 0 -%]
+Welcome to our new host[%- hosts.size > 1 ? 's' : '' -%]:
+[%- count = 0 %]
+[%- FOREACH row IN hosts %]
+ [%- count = count + 1 %]
+ [%- hostname = (row.host.search(',') ? row.host.replace('^(.*)$','"$1"') : row.host) %]
+ [% hostname %]
+ [%- count < hosts.size ? ', ' : '.' %]
+[%- END %]
+[% ELSE -%]
+There were no new hosts this month.
+[% END -%]
+
+
+Last Month's Shows
+[%# The 'summary' attribute is deprecated in HTML5 but is included here to -%]
+[%# prevent errors being reported in the note checker -%]
+
+
+[%- IF includefile.defined -%]
+Mailing List discussions
+
+Policy decisions surrounding HPR are taken by the community as a whole. This
+discussion takes place on the Mail List which is open to all HPR listeners and
+contributors. The discussions are open and available in the archives run
+externally by Gmane
+(see below) and on the HPR server under Mailman .
+
+Note: since the summer of 2016 Gmane has changed location and is currently
+being reestablished. At the moment the HPR archive is not available there.
+The threaded discussions this month can be found here:
+[% INCLUDE $includefile -%]
+[%- END %]
+
+[%# Skip comments if told to by the caller -%]
+[%- IF skip_comments == 0 -%]
+Comments this month
+
+[% IF comment_count > 0 -%]
+These are comments which have been made during the past month, either to shows
+released during the month or to past shows.
+There [%- comment_count == 1 ? "is $comment_count comment" : "are $comment_count comments" -%] in total.
+[% IF past_count > 0 -%]
+There [% past_count == 1 ? "is $past_count comment" : "are $past_count comments" %] on
+[% past.size %] previous [% past.size == 1 ? "show" : "shows" %]:
+
+[%- FOREACH ep IN past.keys.sort -%]
+[%- arr = past.$ep -%]
+hpr[% arr.0.episode %]
+([% arr.0.date %]) "[% arr.0.title %] "
+by [% arr.0.host %] .
+
+
+[%- FOREACH row IN arr -%]
+Comment [% row.index %] :
+[% row.comment_author_name FILTER html_entity -%] on [% date.format(row.comment_timestamp,'%Y-%m-%d') -%]:
+[%- IF row.comment_title.length > 0 %]
+"[% row.comment_title FILTER html_entity %]"
+[%- ELSE -%]
+"[no title]"
+[%- END -%]
+
+[%- END -%]
+
+
+[%- END -%]
+
+[%- END %]
+[%# ---------------------------------------------------------------------------------------- -%]
+[% cc = (comment_count - past_count) -%]
+[% IF cc > 0 -%]
+There [% cc == 1 ? "is $cc comment" : "are $cc comments" %] on [% current.size %] of this month's shows:
+
+[%- FOREACH ep IN current.keys.sort -%]
+[%- arr = current.$ep -%]
+hpr[% arr.0.episode %]
+([% arr.0.date %]) "[% arr.0.title %] "
+by [% arr.0.host %] .
+
+
+[%- FOREACH row IN arr -%]
+Comment [% row.index %] :
+[% row.comment_author_name FILTER html_entity -%] on [% date.format(row.comment_timestamp,'%Y-%m-%d') -%]:
+[%- IF row.comment_title.length > 0 %]
+"[% row.comment_title FILTER html_entity %]"
+[%- ELSE -%]
+"[no title]"
+[%- END -%]
+
+[%- END -%]
+
+
+[%- END -%]
+
+[%- END %]
+[%- ELSE %]
+There were no comments this month.
+[%- END %]
+[%- END %]
+[%#
+ # vim: syntax=tt2:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
+-%]
+
diff --git a/Community_News/shownote_template8.tpl b/Community_News/shownote_template8.tpl
new file mode 100644
index 0000000..be6a517
--- /dev/null
+++ b/Community_News/shownote_template8.tpl
@@ -0,0 +1,164 @@
+[%# shownote_template8.tpl 2017-09-10 -%]
+[%# HTML snippet for insertion into the database -%]
+[%# This one uses the new format for the mailing list data, and partitions -%]
+[%# comments into past and current. It also marks comments that don't need -%]
+[%# to be read when -markcomments is selected. It requires make_shownotes > V0.0.22 -%]
+[%- USE date -%]
+[%- prefix = "http://hackerpublicradio.org"
+ correspondents = "$prefix/correspondents.php"
+ mailthreads = "$prefix/pipermail/hpr_hackerpublicradio.org/$review_year-$review_month/thread.html" -%]
+[%- DEFAULT skip_comments = 0
+ mark_comments = 0 -%]
+[%- IF mark_comments == 1 %]
+
+[%- END %]
+[%# For the '-mailnotes' option without a file we generate our own inclusion. -%]
+[%# We pretend 'default_mail' is a filename in the calling script. Messy. -%]
+[% BLOCK default_mail -%]
+[% mailthreads %]
+[% END -%]
+
+New hosts
+
+[% IF hosts.size > 0 -%]
+Welcome to our new host[%- hosts.size > 1 ? 's' : '' -%]:
+[%- count = 0 %]
+[%# List the new hosts. If a name contains a comma quote it. -%]
+[%- FOREACH row IN hosts %]
+ [%- count = count + 1 %]
+ [%- hostname = (row.host.search(',') ? row.host.replace('^(.*)$','"$1"') : row.host) %]
+ [% hostname %]
+ [%- count < hosts.size ? ', ' : '.' %]
+[%- END %]
+[% ELSE -%]
+There were no new hosts this month.
+[% END -%]
+
+
+Last Month's Shows
+[%# The 'summary' attribute is deprecated in HTML5 but is included here to -%]
+[%# prevent errors being reported in the note checker -%]
+
+
+[%- IF includefile.defined -%]
+Mailing List discussions
+
+Policy decisions surrounding HPR are taken by the community as a whole. This
+discussion takes place on the Mail List which is open to all HPR listeners and
+contributors. The discussions are open and available in the archives run
+externally by Gmane
+(see below) and on the HPR server under Mailman .
+
+Note: since the summer of 2016 Gmane has changed location and is currently
+being reestablished. At the moment the HPR archive is not available there.
+The threaded discussions this month can be found here:
+[% INCLUDE $includefile -%]
+[%- END %]
+
+[%# Skip comments if told to by the caller -%]
+[%- IF skip_comments == 0 -%]
+Comments this month
+
+[% IF comment_count > 0 -%]
+[%- IF mark_comments == 1 -%]
+Note to Volunteers : Comments marked in green were read in the last
+Community News show and should be ignored in this one.
+[%- END -%]
+These are comments which have been made during the past month, either to shows
+released during the month or to past shows.
+There [%- comment_count == 1 ? "is $comment_count comment" : "are $comment_count comments" -%] in total.
+[% IF past_count > 0 -%]
+There [% past_count == 1 ? "is $past_count comment" : "are $past_count comments" %] on
+[% past.size %] previous [% past.size == 1 ? "show" : "shows" %]:
+
+[%- FOREACH ep IN past.keys.sort -%]
+[%- arr = past.$ep -%]
+hpr[% arr.0.episode %]
+([% arr.0.date %]) "[% arr.0.title %] "
+by [% arr.0.host %] .
+
+
+[%- FOREACH row IN arr -%]
+[%- IF mark_comments == 1 && (row.comment_timestamp_ut <= last_recording) -%]
+
+[%- ELSE %]
+
+[%- END %]
+Comment [% row.index %] :
+[% row.comment_author_name FILTER html_entity -%] on [% date.format(row.comment_timestamp_ut,'%Y-%m-%d') -%]:
+[%- IF row.comment_title.length > 0 %]
+"[% row.comment_title FILTER html_entity %]"
+[%- ELSE -%]
+"[no title]"
+[%- END -%]
+
+[%- END -%]
+
+
+[%- END -%]
+
+[%- END %]
+[%# ---------------------------------------------------------------------------------------- -%]
+[% cc = (comment_count - past_count) -%]
+[% IF cc > 0 -%]
+There [% cc == 1 ? "is $cc comment" : "are $cc comments" %] on [% current.size %] of this month's shows:
+
+[%- FOREACH ep IN current.keys.sort -%]
+[%- arr = current.$ep -%]
+hpr[% arr.0.episode %]
+([% arr.0.date %]) "[% arr.0.title %] "
+by [% arr.0.host %] .
+
+
+[%- FOREACH row IN arr -%]
+Comment [% row.index %] :
+[% row.comment_author_name FILTER html_entity -%] on [% date.format(row.comment_timestamp,'%Y-%m-%d') -%]:
+[%- IF row.comment_title.length > 0 %]
+"[% row.comment_title FILTER html_entity %]"
+[%- ELSE -%]
+"[no title]"
+[%- END -%]
+
+[%- END -%]
+
+
+[%- END -%]
+
+[%- END %]
+[%- ELSE %]
+There were no comments this month.
+[%- END %]
+[%- END %]
+[%# ---------------------------------------------------------------------------------------- -%]
+[%# Any other business? -%]
+[% IF aob == 1 -%]
+Any other business
+[% INCLUDE $aobfile -%]
+[%- END %]
+[%#
+ # vim: syntax=tt2:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
+-%]
+
diff --git a/Community_News/shownote_template9.tpl b/Community_News/shownote_template9.tpl
new file mode 100644
index 0000000..eafc93d
--- /dev/null
+++ b/Community_News/shownote_template9.tpl
@@ -0,0 +1,164 @@
+[%# shownote_template9.tpl 2017-12-20 -%]
+[%# HTML snippet for insertion into the database -%]
+[%# This one uses the new format for the mailing list data, and partitions -%]
+[%# comments into past and current. It also marks comments that don't need -%]
+[%# to be read when -markcomments is selected. It requires make_shownotes > V0.0.22 -%]
+[%- USE date -%]
+[%- prefix = "http://hackerpublicradio.org"
+ correspondents = "$prefix/correspondents.php"
+ mailthreads = "$prefix/pipermail/hpr_hackerpublicradio.org/$review_year-$review_month/thread.html" -%]
+[%- DEFAULT skip_comments = 0
+ mark_comments = 0 -%]
+[%- IF mark_comments == 1 %]
+
+[%- END %]
+[%# For the '-mailnotes' option without a file we generate our own inclusion. -%]
+[%# We pretend 'default_mail' is a filename in the calling script. Messy. -%]
+[% BLOCK default_mail -%]
+[% mailthreads %]
+[% END -%]
+
+New hosts
+
+[% IF hosts.size > 0 -%]
+Welcome to our new host[%- hosts.size > 1 ? 's' : '' -%]:
+[%- count = 0 %]
+[%# List the new hosts. If a name contains a comma quote it. -%]
+[%- FOREACH row IN hosts %]
+ [%- count = count + 1 %]
+ [%- hostname = (row.host.search(',') ? row.host.replace('^(.*)$','"$1"') : row.host) %]
+ [% hostname %]
+ [%- count < hosts.size ? ', ' : '.' %]
+[%- END %]
+[% ELSE -%]
+There were no new hosts this month.
+[% END -%]
+
+
+Last Month's Shows
+[%# The 'summary' attribute is deprecated in HTML5 but is included here to -%]
+[%# prevent errors being reported in the note checker -%]
+
+
+[%# ---------------------------------------------------------------------------------------- -%]
+[%# Skip comments if told to by the caller -%]
+[%- IF skip_comments == 0 -%]
+Comments this month
+
+[% IF comment_count > 0 -%]
+[%- IF mark_comments == 1 -%]
+Note to Volunteers : Comments marked in green were read in the last
+Community News show and should be ignored in this one.
+[%- END -%]
+These are comments which have been made during the past month, either to shows
+released during the month or to past shows.
+There [%- comment_count == 1 ? "is $comment_count comment" : "are $comment_count comments" -%] in total.
+[% IF past_count > 0 -%]
+There [% past_count == 1 ? "is $past_count comment" : "are $past_count comments" %] on
+[% past.size %] previous [% past.size == 1 ? "show" : "shows" %]:
+
+[%- FOREACH ep IN past.keys.sort -%]
+[%- arr = past.$ep -%]
+hpr[% arr.0.episode %]
+([% arr.0.date %]) "[% arr.0.title %] "
+by [% arr.0.host %] .
+
+
+[%- FOREACH row IN arr -%]
+[%- IF mark_comments == 1 && (row.comment_timestamp_ut <= last_recording) -%]
+
+[%- ELSE %]
+
+[%- END %]
+Comment [% row.index %] :
+[% row.comment_author_name FILTER html_entity -%] on [% date.format(row.comment_timestamp_ut,'%Y-%m-%d') -%]:
+[%- IF row.comment_title.length > 0 %]
+"[% row.comment_title FILTER html_entity %]"
+[%- ELSE -%]
+"[no title]"
+[%- END -%]
+
+[%- END -%]
+
+
+[%- END -%]
+
+[%- END %]
+[%# ---------------------------------------------------------------------------------------- -%]
+[% cc = (comment_count - past_count) -%]
+[% IF cc > 0 -%]
+There [% cc == 1 ? "is $cc comment" : "are $cc comments" %] on [% current.size %] of this month's shows:
+
+[%- FOREACH ep IN current.keys.sort -%]
+[%- arr = current.$ep -%]
+hpr[% arr.0.episode %]
+([% arr.0.date %]) "[% arr.0.title %] "
+by [% arr.0.host %] .
+
+
+[%- FOREACH row IN arr -%]
+Comment [% row.index %] :
+[% row.comment_author_name FILTER html_entity -%] on [% date.format(row.comment_timestamp_ut,'%Y-%m-%d') -%]:
+[%- IF row.comment_title.length > 0 %]
+"[% row.comment_title FILTER html_entity %]"
+[%- ELSE -%]
+"[no title]"
+[%- END -%]
+
+[%- END -%]
+
+
+[%- END -%]
+
+[%- END %]
+[%- ELSE %]
+There were no comments this month.
+[%- END %]
+[%- END %]
+
+[%# ---------------------------------------------------------------------------------------- -%]
+[%- IF includefile.defined -%]
+Mailing List discussions
+
+Policy decisions surrounding HPR are taken by the community as a whole. This
+discussion takes place on the Mail List which is open to all HPR listeners and
+contributors. The discussions are open and available on the HPR server under
+Mailman .
+
+The threaded discussions this month can be found here:
+[% INCLUDE $includefile -%]
+[%- END %]
+
+[%# ---------------------------------------------------------------------------------------- -%]
+[%# Any other business? -%]
+[% IF aob == 1 -%]
+Any other business
+[% INCLUDE $aobfile -%]
+[%- END %]
+[%#
+ # vim: syntax=tt2:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
+-%]
+
diff --git a/Community_News/summarise_mail b/Community_News/summarise_mail
new file mode 100755
index 0000000..ffbd7e9
--- /dev/null
+++ b/Community_News/summarise_mail
@@ -0,0 +1,1710 @@
+#!/usr/bin/env perl
+#===============================================================================
+#
+# FILE: summarise_mail
+#
+# USAGE: ./summarise_mail [-help] [-debug=N] [-from=DATE] [-to=DATE]
+# [-out=FILE] [-template=FILE] [-[no]silent] [-[no]checknew]
+# [-initialise=N]
+#
+# DESCRIPTION: Generates a summary of the HPR mailing list for the Community
+# News
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: ---
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.0.5
+# CREATED: 2015-02-15 15:06:11
+# REVISION: 2015-10-04 20:21:44
+#
+#-------------------------------------------------------------------------------
+# TODO
+#
+# - Add a means of reporting all of the message bodies in a thread. They
+# ideally need formatting and should be printable so that a template can
+# generate plain text versions for use by the Community News hosts when
+# reviewing the last month's messages. This is quite a large task but maybe
+# there are mail formatters that can do it.
+#
+#===============================================================================
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+
+use Getopt::Long;
+use Pod::Usage;
+use Config::General;
+
+use LWP::UserAgent;
+use Mail::Box::Manager;
+
+use Date::Parse;
+use Date::Calc qw{:all};
+use DateTime;
+use DateTime::TimeZone;
+
+use Template;
+use Template::Filters;
+Template::Filters->use_html_entities; # Use HTML::Entities in the template
+
+use Data::Dumper;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.0.5';
+
+#
+# Script and directory names
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+( my $DIR = $0 ) =~ s|/?[^/]*$||mx;
+$DIR = '.' unless $DIR;
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+#
+# Constants and other declarations
+#
+my $basedir = "$ENV{HOME}/HPR/Community_News";
+my $configfile = "$basedir/.$PROG.cfg";
+my $bpfile = "$basedir/mailnote_template.tpl";
+
+my (%dtargs, $dt, $tz, $rd, $mid,
+ $irt, $ref, $date, @parsed, %msg,
+ %id, $dt_from, $dt_to, %threads, $total,
+);
+
+my %tags = (
+ 'from' => 'From',
+ 'to' => 'To',
+ 'cc' => 'CC',
+ 'date' => 'Date',
+ 'subject' => 'Subject',
+ 'archived-at' => 'Link',
+);
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#
+# Load configuration data
+#
+my $conf = Config::General->new(
+ -ConfigFile => $configfile,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1,
+) or die "Unable to open $configfile\n";
+
+my %config = $conf->getall() or die "Unable to process $configfile\n";
+
+#-------------------------------------------------------------------------------
+# Options and arguments
+#-------------------------------------------------------------------------------
+#
+# Process options
+#
+my %options;
+Options( \%options );
+
+#
+# Default help
+#
+pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 )
+ if ( $options{'help'} );
+
+#
+# Collect options
+#
+my $debug = ( defined( $options{debug} ) ? $options{debug} : 0 );
+my $initialise = ( defined( $options{initialise} ) ? 1 : 0 );
+my $start = $options{initialise};
+my $silent = ( defined( $options{silent} ) ? $options{silent} : 0 );
+my $checknew = ( defined( $options{checknew} ) ? $options{checknew} : 0 );
+my $outfile = $options{out};
+
+my @fromdate;
+if ( defined( $options{from} ) ) {
+ @fromdate = parseDate( $options{from} );
+}
+
+my @todate;
+if ( defined( $options{to} ) ) {
+ @todate = parseDate( $options{to} );
+}
+
+my $template
+ = ( defined( $options{template} ) ? $options{template} : $bpfile );
+
+#
+# Check for illegal option combinations
+#
+die "Use only one of -initialise and -checknew\n"
+ if ( $initialise && $checknew );
+
+#
+# Sanity checks
+#
+die "Error: Unable to find template $template\n" unless -r $template;
+
+#-------------------------------------------------------------------------------
+# Default the from date to today and compute a to date if one wasn't given
+#-------------------------------------------------------------------------------
+if ( scalar(@fromdate) == 0 ) {
+ @fromdate = Today();
+}
+
+if ( scalar(@todate) == 0 ) {
+ @todate = Add_Delta_Days( @fromdate[ 0, 1 ],
+ 1, Days_in_Month( @fromdate[ 0, 1 ] ) );
+}
+
+#
+# We need DateTime dates for comparison
+#
+emit( $silent, "From: ", ISO8601_Date(@fromdate), "\n" );
+emit( $silent, "To: ", ISO8601_Date(@todate), "\n" );
+
+$dt_from = calcToDT(@fromdate);
+$dt_to = calcToDT(@todate);
+
+#-------------------------------------------------------------------------------
+# Open the output file (or STDOUT) - we may need the date to do it
+#-------------------------------------------------------------------------------
+my $outfh;
+if ($outfile) {
+ $outfile = sprintf( $outfile,
+ sprintf( "%d-%02d", $dt_from->year, $dt_from->month ) )
+ if ( $outfile =~ /%s/ );
+ emit( $silent, "Output: ", $outfile, "\n" );
+
+ open( $outfh, ">:encoding(UTF-8)", $outfile )
+ or die "Unable to open $outfile for writing: $!";
+}
+else {
+ open( $outfh, ">&", \*STDOUT )
+ or die "Unable to initialise for writing: $!";
+}
+
+#-------------------------------------------------------------------------------
+# Check the configuration file
+#-------------------------------------------------------------------------------
+checkConfig( \%config );
+
+#
+# The cached mail file
+#
+my $mailfile = join( '/', @{ $config{cache} }{ 'directory', 'filename' } );
+
+#-------------------------------------------------------------------------------
+# If asked to initialise the cache check it makes sense, otherwise look to see
+# if we're to update an existing cache
+#-------------------------------------------------------------------------------
+if ($initialise) {
+ if ( -e $mailfile ) {
+ die "The mail cache $mailfile exists.\n"
+ . "Delete it before initialising\n";
+ }
+ unless (initialiseCache( \%config, $start )) {
+ print "Failed to initialise the cache; can't continue\n";
+ exit;
+ }
+}
+elsif ($checknew) {
+ unless ( -e $mailfile ) {
+ die "The mail cache $mailfile does not exist\n"
+ . "Initialise it before checking for updates\n";
+ }
+ unless (updateCache( \%config )) {
+ print "Failed to update the cache; continuing with old mail\n";
+ }
+}
+
+#
+# Open the mail file (as a folder in MBOX format)
+#
+my $mgr = Mail::Box::Manager->new;
+my $folder = $mgr->open($mailfile);
+
+#-------------------------------------------------------------------------------
+# Walk through the messages in the folder collecting message details into
+# a hash structure indexed by the UTC timestamp.
+#-------------------------------------------------------------------------------
+foreach my $message ( $folder->messages ) {
+ #
+ # TODO This doesn't work for a reason I don't understand. Needs to be
+ # fixed
+ #
+ #$message->unfold();
+
+ #
+ # Parse and convert the message date. The @parsed array contains
+ # $ss,$mm,$hh,$day,$month,$year,$zone. The year and month need adjustment.
+ # The timezone value is number of seconds from UTC.
+ #
+ $date = $message->get('Date');
+ @parsed = strptime($date);
+ $parsed[5] += 1900;
+ $parsed[4] += 1;
+
+ #
+ # Convert the parsed date to a DateTime
+ #
+ @dtargs{ 'second', 'minute', 'hour', 'day', 'month', 'year' } = (@parsed);
+ $dt = DateTime->new(%dtargs);
+ $tz = DateTime::TimeZone->new(
+ name => DateTime::TimeZone->offset_as_string( $parsed[6] ) );
+ $dt->set_time_zone($tz);
+
+ #
+ # Stash mail parameters
+ #
+ $rd = $dt->utc_rd_as_seconds();
+ $mid = formatID( $message->get('Message-ID'), 0 );
+ $irt = formatID( $message->get('In-Reply-To'), 1 );
+
+ my @from = $message->from;
+ my @to = $message->to;
+ my @cc = $message->cc;
+
+ $msg{$rd} = {
+ # '_date' => $date,
+ # '_from' => \@ffrom,
+ # '_to' => \@fto,
+ # '_cc' => \@fcc,
+ '_mid' => $message->get('Message-ID'),
+ '_irt' => $message->get('In-Reply-To'),
+ '_rdate' => $rd,
+ 'date' => $dt->ymd . ' ' . $dt->hms . ' ' . $dt->time_zone->name,
+ 'from' => formatEmail( \@from ),
+ 'to' => formatEmail( \@to ),
+ 'cc' => formatEmail( \@cc ),
+ 'subject' => $message->get('Subject'),
+ 'archived-at' => trimHeader( $message->get('Archived-At') ),
+ 'message-id' => $mid,
+ 'in-reply-to' => $irt,
+ 'references' => formatReferences( $message->get('References') ),
+ 'parent' => undef,
+ 'children' => [],
+ };
+
+ #
+ # Stash the message id for easier linking
+ #
+ $id{$mid} = $rd;
+}
+
+$folder->close;
+
+#-------------------------------------------------------------------------------
+# Link the messages according to their internal back-references. Rescue any
+# broken threads caused by people not using their mail clients properly.
+#-------------------------------------------------------------------------------
+linkMessages( \%msg, \%id, ( $debug == 0 ) );
+repairThreads( \%msg, ( $debug == 0 ) );
+
+#-------------------------------------------------------------------------------
+# Generate the hash of message thread details that start in the selected
+# period. Prepare the template and generate the final document
+#-------------------------------------------------------------------------------
+$total = buildThreadSummary( $dt_from, $dt_to, \%config, \%msg, \%threads,
+ ( $debug == 0 ) );
+
+my $tt = Template->new(
+ { ABSOLUTE => 1,
+ ENCODING => 'utf8',
+ INCLUDE_PATH => $basedir,
+ }
+);
+my $vars = {
+ total => $total,
+ threads => \%threads,
+};
+my $document;
+$tt->process( $template, $vars, \$document, { binmode => ':utf8' } )
+ || die $tt->error(), "\n";
+print $outfh $document;
+close($outfh);
+
+exit;
+
+#=== FUNCTION ================================================================
+# NAME: checkConfig
+# PURPOSE: Check the configuration, aborting if it's messed up and
+# missing necessary items
+# PARAMETERS: $config configuration hash
+# RETURNS: Nothing
+# DESCRIPTION: Checks that all of the required items are present in the
+# $config hash. These have been parsed from the configuration
+# file usually called '.summarise_mail.cfg'. It only performs
+# existence checks, nothing more fancy.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub checkConfig {
+ my ($config) = @_;
+
+ die "Configuration file problem\n"
+ unless ( defined( $config->{gmane}->{url} )
+ && defined( $config->{gmane}->{template1} )
+ && defined( $config->{gmane}->{thread} )
+ && defined( $config->{gmane}->{template2} )
+ && defined( $config->{gmane}->{lookahead} )
+ && defined( $config->{cache}->{directory} )
+ && defined( $config->{cache}->{filename} )
+ && defined( $config->{cache}->{regex} ) );
+
+ return;
+}
+
+#=== FUNCTION ================================================================
+# NAME: initialiseCache
+# PURPOSE: Load a new cache from Gmane
+# PARAMETERS: $config Configuration hashref
+# $start Start number to download
+# RETURNS: If the GET succeeded then 1 otherwise 0
+# DESCRIPTION: Creates a new cache file of mail downloaded from Gmane. The
+# file is in MBOX format but has had Gmane's obscuration
+# algorithms applied to it, messing up certain elements like
+# message-ids. The download begins with message $start and
+# finishes with $start+LOOKAHEAD where the lookahead value is
+# defined in the configuration file; usually 100.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub initialiseCache {
+ my ( $config, $start ) = @_;
+
+ #
+ # How much to look ahead in Gmane; defined in the configuration
+ #
+ my $lookahead = $config->{gmane}->{lookahead};
+
+ #
+ # The name of the mail cache and its directory are in the configuration
+ #
+ my $mailfile
+ = join( '/', @{ $config->{cache} }{ 'directory', 'filename' } );
+
+ #
+ # Make the URL for download
+ #
+ my $url = sprintf( $config->{gmane}->{template1},
+ $start, $start + $lookahead + 1 );
+ print "URL: $url\n";
+
+ #
+ # Set up the HTTP GET
+ #
+ my $ua = LWP::UserAgent->new;
+ $ua->agent('HPR-Agent/0.1');
+ $ua->timeout(10);
+
+ #
+ # Get the data (if any)
+ #
+ my $response = $ua->get($url);
+
+ if ( $response->is_success ) {
+ #
+ # The GET succeeded, see what came back
+ #
+ if ( length( ${ $response->content_ref } ) > 0 ) {
+ #
+ # We got some new data. Append it to the rest
+ #
+ print "Mail messages found\n";
+ open( my $fh, ">", $mailfile )
+ or die "Unable to open $mailfile for writing\n";
+ print $fh $response->decoded_content;
+ close($fh);
+ }
+ else {
+ print "No messages found\n";
+ }
+
+ return 1;
+ }
+ else {
+ #
+ # The GET failed in a nasty way
+ #
+ warn $response->status_line;
+
+ return 0;
+ }
+}
+
+#=== FUNCTION ================================================================
+# NAME: updateCache
+# PURPOSE: Get the latest mail messages from Gmane and save them in the
+# cache
+# PARAMETERS: $config configuration hash
+# RETURNS: If the GET succeeded then 1 otherwise 0
+# DESCRIPTION: Adds more messages downloaded from Gmane to the end of the
+# current cache file. It needs to work out the number of the
+# last message in the cache, which it does with a regex, looking
+# at 'Archived-At:' headers. Then it adds the lookahead value
+# from the configuration file to that and downloads a maximum of
+# that number of messages using the Gmane API. These messages
+# are appended to the existing MBOX file. There is no means of
+# expiring older messages, and perhaps there should be.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub updateCache {
+ my ($config) = @_;
+
+ #
+ # How much to look ahead in Gmane; defined in the configuration
+ #
+ my $lookahead = $config->{gmane}->{lookahead};
+
+ #
+ # The regex to parse the permalink URL is in the configuration
+ #
+ my $re = qr{$config->{cache}->{regex}};
+
+ #
+ # The name of the mail cache and its directory are in the configuration
+ #
+ my $mailfile
+ = join( '/', @{ $config->{cache} }{ 'directory', 'filename' } );
+
+ #
+ # Open the mail file
+ #
+ my $mgr = Mail::Box::Manager->new;
+ my $folder = $mgr->open($mailfile);
+
+ #
+ # Walk all the messages, parsing the permalink URLs to get the start and
+ # end
+ #
+ my $plink;
+ my ( $msgno, $start, $end ) = ( 0, 0, 0 );
+ foreach my $message ( $folder->messages ) {
+ $plink = $message->get('Archived-At');
+ if ( ($msgno) = ( $plink =~ /$re/ ) ) {
+ if ( $start eq 0 ) {
+ $start = $msgno;
+ }
+ else {
+ $end = $msgno;
+ }
+ }
+ }
+ $folder->close;
+
+ #
+ # Build the new URL to get more messages, starting from the last one we
+ # have plus 1 for $lookahead+1 messages
+ #
+ my $url = sprintf( $config->{gmane}->{template1},
+ $end + 1, $end + $lookahead + 1 );
+ print "URL: $url\n";
+
+ #
+ # Set up the HTTP GET
+ #
+ my $ua = LWP::UserAgent->new;
+ $ua->agent('HPR-Agent/0.1');
+ $ua->timeout(10);
+
+ #
+ # Get the data (if any)
+ #
+ my $response = $ua->get($url);
+
+ if ( $response->is_success ) {
+ #
+ # The GET succeeded, see what came back
+ #
+ if ( length( ${ $response->content_ref } ) > 0 ) {
+ #
+ # We got some new data. Append it to the rest
+ #
+ print "New messages found\n";
+ open( my $fh, ">>", $mailfile )
+ or die "Unable to open $mailfile for appending\n";
+ print $fh $response->decoded_content;
+ close($fh);
+ }
+ else {
+ print "No new messages\n";
+ }
+
+ return 1;
+ }
+ else {
+ #
+ # The GET failed in a nasty way
+ #
+ warn $response->status_line;
+
+ return 0;
+ }
+}
+
+#=== FUNCTION ================================================================
+# NAME: parseDate
+# PURPOSE: Parse and check a date
+# PARAMETERS: $date Date string
+# RETURNS: Date::Calc date or 'undef' if $date is undefined
+# DESCRIPTION: Parses a date string using 'strptime' and converts it to
+# a Date::Calc date list
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub parseDate {
+ my ($date) = @_;
+
+ if ( defined($date) ) {
+ #
+ # Parse and format the date
+ #
+ my @parsed = strptime($date);
+ @parsed[ 4, 5 ] = ( $parsed[4] + 1, $parsed[5] + 1900 );
+
+ return ( @parsed[ 5, 4, 3 ] );
+ }
+ else {
+ return; # implicit undef
+ }
+}
+
+#=== FUNCTION ================================================================
+# NAME: ISO8601_Date
+# PURPOSE: Format a Date::Calc date in ISO8601 format
+# PARAMETERS: @date - a date in the Date::Calc format
+# RETURNS: Text string containing a YYYY-MM-DD date
+# DESCRIPTION: Just a convenience to allow a simple call like
+# $str = ISO8601_Date(@date)
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub ISO8601_Date {
+ my (@date) = (@_)[ 0, 1, 2 ];
+
+ if ( check_date(@date) ) {
+ return sprintf( "%04d-%02d-%02d", @date );
+ }
+ else {
+ return "*Invalid Date*";
+ }
+}
+
+#=== FUNCTION ================================================================
+# NAME: calcToDT
+# PURPOSE: Convert a Date::Calc date to a UTC DateTime date
+# PARAMETERS: $date Date::Calc date
+# RETURNS: A DateTime date in the UTC timezone
+# DESCRIPTION: Reformats a Date::Calc date to a DateTime date based on UTC
+# (for date comparison)
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub calcToDT {
+ my (@date) = (@_)[ 0, 1, 2 ];
+
+ #
+ # Transfer Date::Calc values into a hash for initialising a DateTime
+ # object.
+ #
+ my ( %dtargs, $dt );
+ @dtargs{ 'year', 'month', 'day', 'time_zone' } = ( @date, 'UTC' );
+ $dt = DateTime->new(%dtargs);
+ return $dt;
+}
+
+#=== FUNCTION ================================================================
+# NAME: formatEmail
+# PURPOSE: Trims an array of anonymised Gmane email addresss in
+# Mail::Address objects
+# PARAMETERS: $remails The array of Mail::Address objects
+# RETURNS: An array containing the trimmed addresses formatted as text
+# DESCRIPTION: These addresses look like "Ken Fallon
+# ". The display
+# name part (Ken Fallon) is anonymised if it contains an
+# address-like specification. The local-part of the address is
+# anonymised and the domain replaced by a Gmane domain. The
+# front part of the local-part is unaffected. We want to build
+# an address from the display name (if there is one), and the
+# front of the local-part. So the above becomes:
+# "Ken Fallon "
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub formatEmail {
+ my ($remails) = @_;
+
+ my ( $phrase, $address, $comment, @results );
+
+ foreach my $email ( @{$remails} ) {
+ #
+ # Parse out the components
+ #
+ ( $phrase, $address, $comment )
+ = ( $email->phrase, $email->address, $email->comment );
+
+ #
+ # Assume Gmane obscured any email address wherever it is. Strip out
+ # the obscuring part to make it more readable
+ #
+ foreach my $item ( $phrase, $address, $comment ) {
+ if ( $item =~ /^"?([^-]+).+@.+$/ ) {
+ $item = "$1\@...";
+ }
+ }
+
+ #
+ # Let the module reassemble the address in the right format, and add
+ # it to the result list
+ #
+ my $obj = Mail::Address->new( $phrase, $address, $comment );
+ push( @results, $obj->format );
+ }
+
+ return \@results;
+}
+
+#=== FUNCTION ================================================================
+# NAME: formatID
+# PURPOSE: Reformat a Message-ID: or In-Reply-To: value to undo the
+# effects of Gmane's mail address obscuration which is applied
+# to one but not the other.
+# PARAMETERS: $header The header for processing
+# $gmane Boolean to indicate whether we need to strip
+# a Gmane obscuration part
+# RETURNS: The processed header
+# DESCRIPTION: If $gmane is true recognises the possible Gmane obscuration
+# patterns and extracts the important elements from the ID,
+# otherwise just strips the '@' and what follows. This is
+# because Gmane (presumably) detects the '@' in every header
+# type, assumes it's an address and applies its obscuring
+# technique to it. Shame they apply such a sledgehammer
+# algorithm.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub formatID {
+ my ( $header, $gmane ) = @_;
+
+ return $header unless defined($header);
+
+ $header =~ s/(^<|>$)//g;
+ if ($gmane) {
+ if ( $header =~ /^(.+)-[^-]+@.+$/ ) {
+ $header = "$1";
+ }
+ elsif ( $header =~ /^(.+)@.+$/ ) {
+ $header = "$1";
+ }
+ }
+ else {
+ $header =~ s/@.+$//;
+ }
+
+ return $header;
+}
+
+#=== FUNCTION ================================================================
+# NAME: formatReferences
+# PURPOSE: Reformat a list of references in the References: header
+# PARAMETERS: $header The header for processing
+# RETURNS: The processed header as an arrayref
+# DESCRIPTION: Removes the '<>' around each reference, but also removes the
+# bit after and including the '@' since we need to compare it
+# with the ID values we have had to tidy after Gmane got to
+# them.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub formatReferences {
+ my ($header) = @_;
+
+ return $header unless defined($header);
+
+ my ( @refs, @results );
+
+ @refs = split( /\s+/, $header );
+ foreach my $ref (@refs) {
+ $ref =~ s/(^<|>$)//g;
+ $ref =~ s/@.+$//;
+ push( @results, $ref );
+ }
+
+ return \@results;
+}
+
+#=== FUNCTION ================================================================
+# NAME: trimHeader
+# PURPOSE: Trims an arbitrary header which is enclosed in "<..>"
+# PARAMETERS: $header The header for trimming
+# RETURNS: The trimmed header
+# DESCRIPTION: Certain Gmane headers like "Message-Id" are enclosed in "<>"
+# marks. We want to strip these and return the result
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub trimHeader {
+ my ($header) = @_;
+
+ return $header unless defined($header);
+
+ $header =~ s/(^<|>$)//g;
+
+ return $header;
+}
+
+#=== FUNCTION ================================================================
+# NAME: coalesce
+# PURPOSE: To find the first defined argument and return it
+# PARAMETERS: Arbitrary number of arguments
+# RETURNS: The first defined argument or undef if there are none
+# DESCRIPTION: Modelled on the SQL function of the same name. It takes a list
+# of arguments, scans it for the first one that is not undefined
+# and returns it. If an argument is defined and it's an arrayref
+# then the referenced array is returned comma-delimited. This
+# allows calls such as "coalesce($var,'undef')" which returns
+# the value of $var if it's defined, and 'undef' if not and
+# doesn't break anything along the way.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub coalesce {
+ foreach (@_) {
+ if ( defined($_) ) {
+ if ( ref($_) eq 'ARRAY' ) {
+ return join( ',', @{$_} );
+ }
+ else {
+ return $_;
+ }
+ }
+ }
+ return; # implicit undef
+}
+
+#=== FUNCTION ================================================================
+# NAME: empty
+# PURPOSE: Determine whether the argument contains data
+# PARAMETERS: $arg Argument
+# RETURNS: 0 if data found, otherwise 1
+# DESCRIPTION: If an argument is defined and it's an arrayref then the array
+# must contain elements otherwise it's regarded as empty. An
+# undefined argument is empty of course.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub empty {
+ my ($arg) = @_;
+
+ if ( defined($arg) ) {
+ if ( ref($arg) eq 'ARRAY' ) {
+ return scalar(@$arg) == 0;
+ }
+ else {
+ return 0;
+ }
+ }
+ else {
+ return 1;
+ }
+}
+
+#=== FUNCTION ================================================================
+# NAME: linkMessages
+# PURPOSE: Link the collected messages using the 'In-Reply-To:' and
+# 'References' headers
+# PARAMETERS: $msg Hashref containing the messages
+# $id Hashref containing message-id to message key
+# links
+# $silent Boolean determining whether to emit error
+# messages
+# RETURNS: Nothing
+# DESCRIPTION: We want to build a structure where messages contain forward
+# "child" links to other messages. So, given a message, we look
+# for backward links in the 'In-Reply-To:' and 'References'
+# headers, go to the referenced message(s) and make the current
+# one a child. We don't do anything with multiple 'References',
+# though we could.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub linkMessages {
+ my ( $msg, $id, $silent ) = @_;
+
+ my $irt;
+
+ foreach my $key ( sort( keys( %{$msg} ) ) ) {
+ $irt = $msg->{$key}->{'in-reply-to'};
+
+ if ( defined($irt) ) {
+ #
+ # There's an In-Reply-To: header
+ #
+ if ( defined( $id->{$irt} ) ) {
+ #
+ # The parent of this message is the key of the back reference and
+ # we add this key to the children of the parent of this message
+ #
+ $msg->{$key}->{parent} = $id->{$irt};
+ push(
+ @{ $msg->{ $msg->{$key}->{parent} }->{children} },
+ $id->{ $msg->{$key}->{'message-id'} }
+ );
+ }
+ else {
+ emit( $silent,
+ "Unable to find Message-ID: $irt (in message $key)\n" );
+ }
+ }
+ elsif ( defined( $msg->{$key}->{'references'} ) ) {
+ #
+ # There's no In-Reply-To: but we have References:, where the last one
+ # is the equivalent. We update the parent message
+ #
+ $ref = $msg->{$key}->{'references'}->[-1];
+ if ( defined($ref) && defined( $id->{$ref} ) ) {
+ #
+ # The parent of this message is the key of the back reference and
+ # we add this key to the children of the parent of this message
+ #
+ $msg->{$key}->{parent} = $id->{$ref};
+ push(
+ @{ $msg->{ $msg->{$key}->{parent} }->{children} },
+ $id->{ $msg->{$key}->{'message-id'} }
+ );
+ }
+ }
+ }
+
+ return;
+}
+
+#=== FUNCTION ================================================================
+# NAME: repairThreads
+# PURPOSE: Repair broken threads where possible
+# PARAMETERS: $mhash Hashref of all messages
+# $silent Boolean determining whether to emit error
+# messages
+# RETURNS: Nothing
+# DESCRIPTION: We look for messages with a subject beginning with 'Re:' which
+# don't have any back links. These look like broken threads
+# where the sender has not used their mail client's ability to
+# generate an 'In-Reply-To:' and/or 'References' header. We try
+# to find the thread root by looking for the subject in all of
+# the messages and then make the linkages.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub repairThreads {
+ my ( $mhash, $silent ) = @_;
+
+ foreach my $key ( sort( keys( %{$mhash} ) ) ) {
+ if ( $mhash->{$key}->{subject} =~ /^Re:/ ) {
+ if (!( defined( $mhash->{$key}->{'in-reply-to'} )
+ || defined( $mhash->{$key}->{'references'} )
+ )
+ )
+ {
+ #
+ # We have a message with a subject beginning with 'Re:' which
+ # doesn't have any back links. Looks like a broken thread.
+ # Find the thread root by looking for the subject.
+ #
+ my $parent = findParent( $mhash, $key );
+
+ #
+ # The parent has to be defined to be meaningful
+ #
+ if ( defined($parent) ) {
+ #
+ # Make this message's parent the message we found in two
+ # ways, via the 'parent' link and the 'in-reply-to'
+ # header.
+ #
+ $mhash->{$key}->{parent} = $parent;
+ $mhash->{$key}->{'in-reply-to'}
+ = $mhash->{$parent}->{'message-id'};
+
+ #
+ # Add this message to the parents children array, making
+ # sure it's sorted.
+ #
+ push( @{ $mhash->{$parent}->{children} }, $key );
+ $mhash->{$parent}->{children}
+ = [ sort( @{ $mhash->{$parent}->{children} } ) ];
+
+ #print "Parent:\n";
+ #dumpMessage( $mhash->{$parent} );
+ #print "Child:\n";
+ #dumpMessage( $mhash->{$key} );
+ }
+ else {
+ emit( $silent, "Couldn't find parent of message $key\n" );
+ }
+ }
+ }
+ }
+
+ return;
+}
+
+#=== FUNCTION ================================================================
+# NAME: findParent
+# PURPOSE: Given a particular element in the message hash find its parent
+# by processing the subject
+# PARAMETERS: $mhash Hashref of all messages
+# $key Key into the message hash
+# RETURNS: The key of the parent
+# DESCRIPTION: Tries to join an "orphaned" message with its parent by looking
+# for another instance of the subject. First the subject of this
+# message must begin with "Re:". This string is removed in order
+# to perform a search for the message this one is in reference
+# to. The search is conducted through all of the stored messages
+# and the matching message must not have a parent or any back
+# references itself to be acceptable. This is a fairly vague set
+# of criteria but it does work for a number of unlinked
+# messages. [Note: The regex used in the search has been modified
+# to be case insensitive having encountered an instance where
+# someone reconstructed the subject line in different case.]
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub findParent {
+ my ( $mhash, $key ) = @_;
+
+ my $start_subj = $mhash->{$key}->{subject};
+ my $end_subj;
+ my $parent;
+
+ if ( $start_subj =~ /^Re:/ ) {
+ ( $end_subj = $start_subj ) =~ s/^Re:\s*//;
+ foreach my $k ( keys( %{$mhash} ) ) {
+ #if ( $msg{$k}->{subject} eq $end_subj ) {
+ if ( $k ne $key
+ && $msg{$k}->{subject} =~ /(?:^Re:\s*)?(?i)$end_subj/ )
+ {
+ if (!( defined( $msg{$k}->{'in-reply-to'} )
+ || defined( $msg{$k}->{'references'} )
+ )
+ )
+ {
+ $parent = $k;
+ last;
+ }
+ }
+ }
+ return $parent;
+ }
+ else {
+ #
+ # Doesn't look like a message with a parent
+ #
+ return; # implicit undef
+ }
+}
+
+#=== FUNCTION ================================================================
+# NAME: buildThreadSummary
+# PURPOSE: Build a summary of the thread roots
+# PARAMETERS: $dt_from The from date as a DateTime object
+# $dt_to The to date as a DateTime object
+# $config Hashref of the configuration data
+# $mhash Hashref of all messages
+# $threads Hashref to contain the resulting thread data
+# $silent Boolean determining whether to emit error
+# messages
+# RETURNS: Total number of messages in all eligible threads
+# DESCRIPTION: We have built a structure containing all of the message
+# threads we've seen. However, we're only interested in the
+# threads for a certain period, so we need to work them out
+# here. A thread root is a message which has no parents, and
+# we're interested in those which were written in our time
+# period. We collect the details we want for the final display
+# and build a weblink to the thread on Gmane. We also count the
+# number of messages per thread and the grand total. The
+# reulting structure is what we'll pass to the template we use
+# to generate the insertion for the Community News notes (and
+# other stuff).
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub buildThreadSummary {
+ my ( $dt_from, $dt_to, $config, $mhash, $threads, $silent ) = @_;
+
+ my ( $count, $total );
+
+ #
+ # Use the UTC seconds for comparison
+ #
+ my $from = $dt_from->utc_rd_as_seconds();
+ my $to = $dt_to->utc_rd_as_seconds();
+
+ #
+ # Adjust the regex from the config file for parsing the stored links.
+ # Remove the '<>' enclosing it.
+ #
+ my $re = $config->{cache}->{regex};
+ $re =~ s/^<|>$//g;
+ $re = qr{$re};
+
+ #
+ # Find each thread root which has messages in the date range
+ #
+ $total = 0;
+ foreach my $key ( sort( keys( %{$mhash} ) ) ) {
+ if ( !defined( $mhash->{$key}->{parent} )
+ && eligibleThread( $from, $to, $mhash, $key ) )
+# && ( $key ge $from && $key lt $to ) )
+ {
+ #
+ # Stash the headers we want
+ #
+ foreach my $k (qw{ from to cc date subject archived-at }) {
+ $threads{$key}->{$k} = $mhash->{$key}->{$k};
+ }
+
+ #
+ # Make a weblink to the Gmane thread
+ # TODO What if the regex doesn't match?
+ #
+ if ( $mhash->{$key}->{'archived-at'} =~ /$re/ ) {
+ $threads{$key}->{thread}
+ = sprintf( $config->{gmane}->{template2}, $1 );
+ }
+ else {
+ emit( $silent,
+ "Unable to match the Gmane thread (in message $key)\n" );
+ }
+
+ #
+ # Count the messages in the thread and keep a grand total
+ #
+ $count = 0;
+ threadLength( $from, $to, $mhash, $key, \$count );
+ $threads{$key}->{count} = $count;
+ $total += $count;
+ }
+ }
+
+ return $total;
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: eligibleThread
+# PURPOSE: Determines whether a given thread is eligible to be included
+# in a date range
+# PARAMETERS: $from The from date as UTC seconds
+# $to The to date as UTC seconds
+# $mhash Hashref of all messages
+# $key Key into the message hash for this particular
+# thread
+# RETURNS: True (1) if the thread is eligible, otherwise false (0)
+# DESCRIPTION: Checks the top level message (thread root) for elegibility
+# (date in range) then recursively descends the thread checking
+# each message in the same manner as it goes. The results are
+# ORed together so that if any message is in range the entire
+# thread is.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub eligibleThread {
+ my ( $from, $to, $mhash, $key ) = @_;
+
+ my $res = ( $key ge $from && $key lt $to );
+
+ foreach my $k ( @{ $mhash->{$key}->{children} } ) {
+ $res ||= ( $k ge $from && $k lt $to )
+ || eligibleThread( $from, $to, $mhash, $k );
+ }
+
+ return $res;
+}
+
+#=== FUNCTION ================================================================
+# NAME: threadLength
+# PURPOSE: Count the number of eligible messages in a thread
+# PARAMETERS: $from The from date as UTC seconds
+# $to The to date as UTC seconds
+# $mhash Hashref of all messages
+# $key Key into the message hash
+# $len Scalar ref containing accumulated length
+# RETURNS: Nothing
+# DESCRIPTION: Recursively descends through a thread (a tree of linked
+# messages) counting the elements but only if they are in range.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub threadLength {
+ my ( $from, $to, $mhash, $key, $len ) = @_;
+
+ $$len++ if ( $key ge $from && $key lt $to );
+
+ foreach my $k ( @{ $mhash->{$key}->{children} } ) {
+ threadLength( $from, $to, $mhash, $k, $len );
+ }
+
+ return;
+}
+
+#=== FUNCTION ================================================================
+# NAME: printThread
+# PURPOSE: To walk a message thread and print it
+# PARAMETERS: $mhash Hashref of all messages
+# $key Key into the message hash
+# $level Integer denoting recursion level
+# $html 1 for HTML output, 0 for text
+# RETURNS: Nothing
+# DESCRIPTION: Recursively descend through a thread (a tree of linked
+# messages) printing each one. If $html is true the message is
+# printed in HTML, otherwise it's plain text with indentation.
+# The level of indentation is controlled by $level which
+# increments as the tree is recursed.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub printThread {
+ my ( $mhash, $key, $level, $html ) = @_;
+
+ $level = 0 unless $level;
+ $html = 0 unless $html;
+
+ printMessage( $mhash->{$key}, $level, $html );
+
+ foreach my $k ( @{ $mhash->{$key}->{children} } ) {
+ printThread( $mhash, $k, $level + 1, $html );
+ }
+
+ return;
+}
+
+#=== FUNCTION ================================================================
+# NAME: printMessage
+# PURPOSE: To print a message for debug purposes
+# PARAMETERS: $msg Hashref containing message attributes
+# $level Integer for indenting
+# $html 1 for HTML output, 0 for text
+# RETURNS: Nothing
+# DESCRIPTION: Print a message. The details held consist only of a set of
+# headers, and these are printed as a label and a value. If
+# $html is true the output is HTML (an unnumbered list) and only
+# headers with values are reported. If $html is false then plain
+# text is used, empty values are reported and indentation is
+# used, controlled by the value of $level. In plain text mode
+# each item ends with a line of hyphens.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub printMessage {
+ my ( $msg, $level, $html ) = @_;
+
+ $level = 0 unless $level;
+ $html = 0 unless $html;
+
+ # TODO Don't do it this way, use the global definition since it's used
+ # elsewhere
+ state %tags;
+ %tags = (
+ 'from' => 'From',
+ 'to' => 'To',
+ 'cc' => 'CC',
+ 'date' => 'Date',
+ 'subject' => 'Subject',
+ 'archived-at' => 'Link',
+ );
+
+ print "" if $html;
+
+ foreach my $k (qw{ from to cc date subject archived-at }) {
+ if ($html) {
+ if ( !empty( $msg->{$k} ) ) {
+ printf "%-8s %s \n", "$tags{$k}:",
+ coalesce( $msg->{$k}, '' );
+ }
+ }
+ else {
+ printf "%s%-8s %s\n", ' ' x $level, "$tags{$k}:",
+ coalesce( $msg->{$k}, '' );
+ }
+ }
+ if ($html) {
+ print " \n";
+ }
+ else {
+ print '-' x 80, "\n";
+ }
+
+ return;
+}
+
+#=== FUNCTION ================================================================
+# NAME: dumpMessage
+# PURPOSE: To print all parts of a message hash for debugging
+# PARAMETERS: $msg Hashref containing message attributes
+# RETURNS: Nothing
+# DESCRIPTION: Produces a dump of a message for debugging purposes.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub dumpMessage {
+ my ($msg) = @_;
+
+ foreach my $k ( sort( keys( %{$msg} ) ) ) {
+ printf "%12s: %s\n", $k, coalesce( $msg->{$k}, '' );
+ }
+ print '-' x 80, "\n";
+ return;
+}
+
+#=== FUNCTION ================================================================
+# NAME: emit
+# PURPOSE: Print text on STDERR unless silent mode has been selected
+# PARAMETERS: - Boolean indicating whether to be silent or not
+# - list of arguments to 'print'
+# RETURNS: Nothing
+# DESCRIPTION: This is a wrapper around 'print' to determine whether to send
+# a message to STDERR depending on a boolean. We need this to be
+# able to make the script silent when the -silent option is
+# selected
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub emit {
+ unless (shift) {
+ print STDERR @_;
+ }
+ return;
+}
+
+#=== FUNCTION ================================================================
+# NAME: Options
+# PURPOSE: Processes command-line options
+# PARAMETERS: $optref Hash reference to hold the options
+# RETURNS: Undef
+# DESCRIPTION:
+# THROWS: no exceptions
+# COMMENTS: none
+# SEE ALSO: n/a
+#===============================================================================
+sub Options {
+ my ($optref) = @_;
+
+ my @options = (
+ "help", "debug=i", "out=s", "template=s",
+ "silent!", "checknew!", "initialise=i", "from=s",
+ "to=s",
+ );
+
+ if ( !GetOptions( $optref, @options ) ) {
+ pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 );
+ }
+
+ return;
+}
+
+__END__
+
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+# Application Documentation
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+#{{{
+
+
+=head1 NAME
+
+summarise_mail - Generate a summary of messages on the HPR mailing list
+
+=head1 VERSION
+
+This documentation refers to summarise_mail version 0.0.5
+
+
+=head1 USAGE
+
+ ./summarise_mail [-help] [-debug=N] [-from=DATE] [-to=DATE] [-out=FILE]
+ [-template=FILE] [-[no]silent] [-[no]checknew] [-initialise=N]
+
+
+=head1 OPTIONS
+
+=over 8
+
+=item B<-help>
+
+Prints a brief help message describing the usage of the program, and then exits.
+
+=item B<-initialise=N>
+
+This option builds a new mailbox cache. The value B defines the starting
+message to be downloaded from the Gmane site. The number defaults to 1, though
+this is not advisable since it potentially overloads the Gmane server. The
+number of messages downloaded is limited by the B value in the
+configuration file (see below).
+
+If a cache file already exists the script will not run. It is necessary to
+delete this file before initialising a new one.
+
+After the cache is initialised the script will process the messages it
+contains.
+
+The B<-initialise=N> and the B<-[no]checknew> options are mutually exclusive.
+
+=item B<-[no]checknew>
+
+This option defines whether the script will check Gmane for updates to the
+mailing list. If updates are found then they will be incorporated into the
+cached mailbox file.
+
+If omitted no check is made (B<-nochecknew>).
+
+The B<-initialise=N> and the B<-[no]checknew> options are mutually exclusive.
+
+=item B<-from=DATE>
+
+Specifies the starting date for the mail summary.
+
+The date format should be B (e.g. 12-Jun-2014), B
+(e.g. 12-06-2014) or B (e.g. 2014-06-12).
+
+If this option is omitted the current date is used.
+
+=item B<-to=DATE>
+
+Specifies the ending date for the mail summary.
+
+The date format should be B (e.g. 12-Jun-2014), B
+(e.g. 12-06-2014) or B (e.g. 2014-06-12).
+
+If this option is omitted the last day of the month defined by the B<-from>
+date is used.
+
+=item B<-template=FILE>
+
+This option defines the template that will be used to format the report
+generated by the script.
+
+If the option is not provided, the script will use a template called
+B in the directory which holds the script. This
+template generates HTML suitable for incorporation into the Community News
+show notes.
+
+=item B<-out=FILE>
+
+This option defines the output file for the report. If the option is omitted
+the report is written to STDOUT, allowing it to be redirected if required.
+
+The output file name may contain the characters 'B<%s>'. This denotes the point
+at which the year and month in the format B are inserted. For example
+if the script is being run for July 2014 the option:
+
+ -out=mailreport_%s.html
+
+will cause the generation of the file:
+
+ mailreport_2014-07.html
+
+=item B<-[no]silent>
+
+This option controls whether the script reports minimal details of what it is
+doing to STDERR. If the option is omitted the report is generated (B<-nosilent>).
+
+The script reports: the starting and ending dates it is working on and the name of the output file
+(if appropriate).
+
+=item B<-debug=N>
+
+Runs the script in debug mode if the value of B is greater than 0. The
+default setting is zero.
+
+In debug mode the script reports various pieces of information about the
+parsing of the mail messages and building of threads.
+
+=back
+
+
+=head1 DESCRIPTION
+
+=head2 Overview
+
+This script generates a summary of the mail sent to the HPR mailing list over
+a period.
+
+Messages to the mailing list are copied to the free B service and the
+script uses this to prepare its report. The Gmane service offers
+a downloadable interface at
+http://download.gmane.org/gmane.network.syndication.podcast.hacker-public-radio
+where selected messages can be collected in B format.
+
+=head2 Methodology
+
+The script operates in three phases:
+
+=over 4
+
+=item -
+
+Messages are collected from Gmane and stored in a local cache (a file in MBOX
+format). This is expected to be done once, and thereafter new messages are
+appended to this file.
+
+=item -
+
+New messages are appended to the cache file. This is done by determining the
+last message in the cache then requesting the next block of messages from
+Gmane. The downloaded messages are then added to the file.
+
+=item -
+
+The cache is processed to generate the report. Usually the script is given
+a start date (and optional end date, though this defaults to the end of the
+month in the start date).
+
+The algorithm is as follows:
+
+=over 4
+
+=item -
+
+The script parses out the important headers of all messages in the cache.
+
+=item -
+
+The script walks the header structure in time order joining messages based on
+their 'In-reference-to:' and 'References:' headers. Each message gets its
+parent recorded as a pointer, and its children as an array of pointers. If
+a reference is to a message that is not in the cache then it is not recorded.
+
+=item -
+
+At this point all messages without a parent are either the thread roots or
+cases where someone has not replied properly. The script performs a further
+join when an "orphaned" message has the same subject as another with a 'Re:'
+on the front.
+
+=item -
+
+Now all messages without parents are the best that can be done to detect
+thread roots. These are used to make the report.
+
+=item -
+
+The messages that cannot be threaded are those where the sender does not use
+'Reply' or where they reply to a digest. Digests are not threaded because they
+are blobs of multiple messages.
+
+=item -
+
+Some messages may be threaded erroneously. This happens when a sender, rather
+than generating a new message, replies to an existing message but changes the
+'Subject:' line. There is not much that can be done to correct this.
+
+=back
+
+=back
+
+=head2 Report Template
+
+The script uses the B module to format the report. See the default
+template B for an example.
+
+The template is passed two variables:
+
+=over 4
+
+=item B
+
+This is a scalar variable containing a number: the count of messages in the
+specified range.
+
+=item B
+
+This is a hash indexed by the message date converted to UTC date then to the
+number of seconds since the Unix epoch. Each hash value is a hashref
+containing the following fields:
+
+ Field Contents
+ ----- --------
+ from The contents of the 'From:' header
+ to The contents of the 'To:' header
+ cc The contents of the 'Cc:' header
+ date The contents of the 'Date:' header
+ subject The contents of the 'Subject:' header
+ archived-at The contents of the 'Archived-at:' header (the Gmane
+ permalink URL)
+ count The number of messages in the thread
+
+These messages are the roots of all of the detectable threads in the period.
+Sorting them by the main hash key results in the threads being ordered by
+their timestamp.
+
+=back
+
+=head1 DIAGNOSTICS
+
+A list of every error and warning message that the application can generate
+(even the ones that will "never happen"), with a full explanation of each
+problem, one or more likely causes, and any suggested remedies. If the
+application generates exit status codes (e.g. under Unix) then list the exit
+status associated with each error.
+
+
+=head1 CONFIGURATION AND ENVIRONMENT
+
+The script obtains the information it requires to find the mail details from
+B from a configuration file. The name of the file it expects is
+B<.summarise_mail.cfg> in the
+directory holding the script. To change this will require changing the script.
+
+The configuration file format is as follows:
+
+
+ url = http://download.gmane.org/gmane.network.syndication.podcast.hacker-public-radio
+ template = "$url/%d/%d"
+ lookahead = 100
+
+
+
+ directory = /home/dave/Community_News/mail_cache
+ filename = gmane.mbox
+ regex = ""
+
+
+The elements of this configuration file are explained below. First the
+B section:
+
+=over 4
+
+=item B
+
+This is the base URL on the Gmane site. It is used to construct URLs for
+collecting mail messages. This should not require any changes.
+
+=item B
+
+This is a template which is used to generate the actual URL used to download
+messages. The script replaces each '%d' with a number. The first is the
+starting message number, and the second the ending number plus one.
+
+This should not require any changes.
+
+=item B
+
+This defines the maximum number of messages to be collected. This number is
+added to the first number used when contructing the URL from the B
+above. Normally just a few messages are downloaded at a time, but when the
+script is used to initialise the mailbox cache file this number of messages may
+be downloaded.
+
+This value can be changed if desired, but it is set at 100 in order to avoid
+overloading the Gmane servers.
+
+=back
+
+The B section contains the following:
+
+=over 4
+
+=item B
+
+This defines the full path to the directory holding the mail cache file.
+
+This may be changed to reflect a change of location.
+
+=item B
+
+This defines the name of the mail cache. It is expected to be found in the
+directory defined above.
+
+This may be changed to reflect a change of name.
+
+=item B
+
+Individual messages in the cache each hold an URL which provides a permanent
+link to the message on the Gmane site. The script uses the regular expression
+defined here to collect the message number from this URL. This is how the
+script determines the number of the oldest message in the cache when looking
+for new messages.
+
+This expression should only be changed with great caution.
+
+=back
+
+=head1 DEPENDENCIES
+
+ Getopt::Long
+ Pod::Usage
+ Config::General
+ LWP::UserAgent
+ Mail::Box::Manager
+ Date::Parse
+ Date::Calc
+ DateTime
+ DateTime::TimeZone
+ Template
+ Template::Filters
+ Data::Dumper
+
+
+=head1 BUGS AND LIMITATIONS
+
+There are no known bugs in this module.
+Please report problems to Dave Morriss (Dave.Morriss@gmail.com)
+Patches are welcome.
+
+=head1 AUTHOR
+
+Dave Morriss (Dave.Morriss@gmail.com)
+
+
+=head1 LICENCE AND COPYRIGHT
+
+Copyright (c) 2014, 2015 Dave Morriss (Dave.Morriss@gmail.com). All rights reserved.
+
+This module is free software; you can redistribute it and/or
+modify it under the same terms as Perl itself. See perldoc perlartistic.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+=cut
+
+#}}}
+
+# [zo to open fold, zc to close]
+
+# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
diff --git a/Community_News/tag_contributors.tpl b/Community_News/tag_contributors.tpl
new file mode 100644
index 0000000..7f7f907
--- /dev/null
+++ b/Community_News/tag_contributors.tpl
@@ -0,0 +1,44 @@
+[%# tag_contributors.tpl
+ A file to contain tag/summary contributor data. Made as a separate file to
+ be invoked with a PROCESS directive in the main Markdown file. If the
+ statements are placed there they look like some kind of Markdown stuff and
+ mess up Vim's formatting.
+-%]
+[% MACRO host(id) GET "http://hackerpublicradio.org/correspondents.php?hostid=$id" -%]
+[% kenfallon = host(30) -%]
+[% windigo = host(215) -%]
+[% perloid = host(225) -%]
+[% nybill = host(235) -%]
+[% tonyhughes = host(338) -%]
+[% bjb = host(357) -%]
+[% ahuka = host(198) -%]
+[% baffled = "Kirk Reiser" -%]
+[% claudiom = host(152) -%]
+[% archer72 = host(318) -%]
+[% crvs = host(385) -%]
+[% danielpersson = host(382) -%]
+[% roan = host(293) -%]
+[%# NOTE: Add variables as '$var' -%]
+[% contributors = [
+ "[Archer72]($archer72)"
+ "[Rho`n]($roan)"
+]
+-%]
+[% everyone = [
+ "[Ahuka]($ahuka)"
+ "[archer72]($archer72)"
+ "[bjb]($bjb)"
+ "[ClaudioM]($claudiom)"
+ "[crvs]($crvs)"
+ "[Daniel Persson]($danielpersson)"
+ "[Dave Morriss]($perloid)"
+ "[Ken Fallon]($kenfallon)"
+ "[Kirk Reiser]($baffled)"
+ "[NYbill]($nybill)"
+ "[Rho`n]($roan)"
+ "[Tony Hughes]($tonyhughes)"
+ "[Windigo]($windigo)"
+
+]
+-%]
+
diff --git a/Database/.find_series.yml b/Database/.find_series.yml
new file mode 100644
index 0000000..cc905be
--- /dev/null
+++ b/Database/.find_series.yml
@@ -0,0 +1,230 @@
+ignore:
+ - 'aka'
+ - 'all'
+ - 'amp'
+ - 'an'
+ - 'and'
+ - 'app'
+ - 'are'
+ - 'art'
+ - 'as'
+ - 'at'
+ - 'ayn'
+ - 'bad'
+ - 'bag'
+ - 'bbs'
+ - 'be'
+ - 'ben'
+ - 'big'
+ - 'bit'
+ - 'box'
+ - 'bug'
+ - 'by'
+ - 'car'
+ - 'cd'
+ - 'cje'
+ - 'cu'
+ - 'cut'
+ - 'dad'
+ - 'dan'
+ - 'day'
+ - 'do'
+ - 'doe'
+ - 'dso'
+ - 'ec'
+ - 'ed'
+ - 'eee'
+ - 'egg'
+ - 'eol'
+ - 'ep'
+ - 'era'
+ - 'eve'
+ - 'fab'
+ - 'fav'
+ - 'feb'
+ - 'fix'
+ - 'for'
+ - 'fun'
+ - 'gd'
+ - 'gen'
+ - 'get'
+ - 'gmc'
+ - 'go'
+ - 'got'
+ - 'gsm'
+ - 'guy'
+ - 'har'
+ - 'has'
+ - 'his'
+ - 'how'
+ - 'hpr'
+ - 'ian'
+ - 'ilf'
+ - 'im'
+ - 'in'
+ - 'ink'
+ - 'ip'
+ - 'ipv'
+ - 'is'
+ - 'it'
+ - 'its'
+ - 'jan'
+ - 'jon'
+ - 'jwp'
+ - 'ken'
+ - 'la'
+ - 'lee'
+ - 'lot'
+ - 'low'
+ - 'lug'
+ - 'man'
+ - 'map'
+ - 'may'
+ - 'me'
+ - 'mf'
+ - 'mod'
+ - 'mp'
+ - 'mrs'
+ - 'my'
+ - 'new'
+ - 'nix'
+ - 'no'
+ - 'non'
+ - 'not'
+ - 'now'
+ - 'of'
+ - 'off'
+ - 'oh'
+ - 'old'
+ - 'on'
+ - 'one'
+ - 'or'
+ - 'os'
+ - 'oss'
+ - 'ota'
+ - 'our'
+ - 'out'
+ - 'own'
+ - 'pam'
+ - 'pat'
+ - 'pay'
+ - 'pc'
+ - 'pe'
+ - 'pis'
+ - 'pre'
+ - 'prn'
+ - 'pt'
+ - 'qsk'
+ - 'rds'
+ - 'rf'
+ - 'rfa'
+ - 'rm'
+ - 'rob'
+ - 'run'
+ - 'rxy'
+ - 'sap'
+ - 'sdf'
+ - 'set'
+ - 'sex'
+ - 'sfl'
+ - 'sfs'
+ - 'she'
+ - 'sky'
+ - 'so'
+ - 'son'
+ - 'tab'
+ - 'tag'
+ - 'ted'
+ - 'th'
+ - 'the'
+ - 'tip'
+ - 'to'
+ - 'tom'
+ - 'too'
+ - 'tv'
+ - 'two'
+ - 'up'
+ - 'us'
+ - 'use'
+ - 'van'
+ - 'vol'
+ - 'vs'
+ - 'war'
+ - 'way'
+ - 'we'
+ - 'wep'
+ - 'who'
+ - 'why'
+ - 'win'
+ - 'wow'
+ - 'wtf'
+ - 'xd'
+ - 'xdc'
+ - 'xgo'
+ - 'xp'
+ - 'you'
+ - 'with'
+ - '--'
+ - '--tz'
+ - 'updating'
+ - 'hosts'
+ - 'future'
+ - 'favorite'
+ - 'making'
+ - 'screen'
+ - 'should'
+ - 'next'
+ - 'word'
+ - 'city'
+ - 'others'
+ - 'some'
+ - 'needs'
+ - 'other'
+ - 'call'
+ - 'alternative'
+ - 'road'
+ - 'month'
+ - 'july'
+ - 'care'
+ - 'quick'
+ - 'pt.'
+ - 'over'
+ - 'under'
+ - 'made'
+ - 'without'
+ - '//'
+ - 'more'
+ - 'when'
+ - 'p-'
+ - 'behind'
+ - 'using'
+ - 'after'
+ - 'about'
+ - 'part'
+ - 'show'
+ - 'public'
+ - 'from'
+ - 'your'
+ - 'this'
+ - 'david'
+ - 'tony'
+ - 'chris'
+ - 'jonathan'
+ - 'kulp'
+ - 'john'
+ - 'klaatu'
+ - 'into'
+ - 'whitman'
+ - 'pokey'
+ - 'xokes'
+ - 'drachenblut'
+ - 'scott'
+ - 'nybill'
+ - 'conder'
+ - 'jake'
+ - 'leclanche'
+ - 'stuart'
+ - 'they'
+ - 'josh'
+ - 'jezra'
+ - 'smith'
diff --git a/Database/clean_csv_tags b/Database/clean_csv_tags
new file mode 100755
index 0000000..9867883
--- /dev/null
+++ b/Database/clean_csv_tags
@@ -0,0 +1,324 @@
+#!/usr/bin/env perl
+#===============================================================================
+#
+# FILE: clean_csv_tags
+#
+# USAGE: ./clean_csv_tags
+#
+# DESCRIPTION: Make sure tags in the eps.tags field of the HPR database
+# conform to CSV format.
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: ---
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.0.2
+# CREATED: 2017-01-30 15:32:04
+# REVISION: 2019-10-06 21:50:52
+#
+#===============================================================================
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+
+use Carp;
+use Getopt::Long;
+use Config::General;
+use Text::CSV_XS;
+use List::MoreUtils qw{uniq};
+use SQL::Abstract;
+use DBI;
+
+use Data::Dumper;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.0.2';
+
+#
+# Script and directory names
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+( my $DIR = $0 ) =~ s|/?[^/]*$||mx;
+$DIR = '.' unless $DIR;
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+#
+# Constants and other declarations
+#
+my $basedir = "$ENV{HOME}/HPR/Database";
+my $configfile = "$basedir/.hpr_db.cfg";
+
+my ( $dbh, $sth1, $h1, $rv );
+my ( %eps_tags, %diffs );
+my $status;
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#-------------------------------------------------------------------------------
+# Options and arguments
+#-------------------------------------------------------------------------------
+#
+# Process options
+#
+my %options;
+Options( \%options );
+
+Usage() if ( $options{'help'} );
+
+#
+# Collect options
+#
+my $cfgfile
+ = ( defined( $options{config} ) ? $options{config} : $configfile );
+
+my $aq = ( defined( $options{aq} ) ? $options{aq} : 0 );
+my $verbose = ( defined( $options{verbose} ) ? $options{verbose} : 0 );
+my $dry_run = ( defined( $options{'dry-run'} ) ? $options{'dry-run'} : 1 );
+
+#
+# Load configuration data
+#
+my $conf = new Config::General(
+ -ConfigFile => $cfgfile,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %config = $conf->getall();
+
+#-------------------------------------------------------------------------------
+# Connect to the database
+#-------------------------------------------------------------------------------
+my $dbhost = $config{database}->{host} // '127.0.0.1';
+my $dbport = $config{database}->{port} // 3306;
+my $dbname = $config{database}->{name};
+my $dbuser = $config{database}->{user};
+my $dbpwd = $config{database}->{password};
+$dbh = DBI->connect( "dbi:mysql:host=$dbhost;port=$dbport;database=$dbname",
+ $dbuser, $dbpwd, { AutoCommit => 1 } )
+ or croak $DBI::errstr;
+
+#
+# Enable client-side UTF8
+#
+$dbh->{mysql_enable_utf8} = 1;
+
+#-------------------------------------------------------------------------------
+# Collect and process the id numbers and tags from the 'eps' table
+#-------------------------------------------------------------------------------
+%eps_tags = %{ collect_eps_tags( $dbh ) };
+
+#
+# Dump all id numbers and tags if the verbose level is high enough
+#
+if ( $verbose >= 2 ) {
+ my $csv = Text::CSV_XS->new( { always_quote => $aq } );
+
+ print "\nTags collected from the 'eps' table\n\n";
+ foreach my $id ( sort { $a <=> $b } keys(%eps_tags) ) {
+ $status = $csv->combine( @{ $eps_tags{$id} } );
+ printf "%04d: %s\n", $id, $csv->string();
+ }
+}
+
+if ($dry_run) {
+ print "\nNo changes made, dry-run mode\n";
+ exit;
+}
+
+#-------------------------------------------------------------------------------
+# Turn all the saved and cleaned tags into CSV strings again and save them
+# back to the database. TODO: find differences and only write those back
+#-------------------------------------------------------------------------------
+#
+# Force quoting everywhere
+#
+my $csv = Text::CSV_XS->new( { always_quote => $aq } );
+
+#
+# Loop through the hash in order of show number
+#
+for my $id ( sort keys %eps_tags ) {
+ #
+ # Put the array fields back together
+ #
+ $status = $csv->combine( @{ $eps_tags{$id} } );
+
+ #
+ # Write them to the database
+ #
+ $dbh->do( q{UPDATE eps SET tags = ? WHERE id = ?},
+ undef, $csv->string(), $id );
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+
+}
+
+exit;
+
+#=== FUNCTION ================================================================
+# NAME: collect_eps_tags
+# PURPOSE: Collects the tags from the eps.tags field
+# PARAMETERS: $dbh Database handle
+# RETURNS: A reference to the hash created by collecting all the tags
+# DESCRIPTION: Read the 'id' and tags' fields from the database. Parse the
+# tags as CSV data, flagging any errors. Trim each one and store
+# them in a hash keyed on the id number. The list of tags is
+# stored as an array in sorted order after ensuring there are
+# no duplicates.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub collect_eps_tags {
+ my ( $dbh ) = @_;
+
+ my ( $status, @fields, %hash );
+ my ( $sth, $h );
+
+ #
+ # For parsing the field as CSV
+ # NOTE: Unexplained error in [E. E. "Doc" Smith] (show 2462). Works with
+ # double replaced by single quote, but doesn't work if quotes escaped (by
+ # doubling) whether all tags are quoted or not. With 'auto_diag' enabled
+ # get the error:
+ # CSV_XS ERROR: 2034 - EIF - Loose unescaped quote @ rec 1632 pos 40 field 3
+ #
+ # NOTE: Adding 'allow_loose_quotes' avoids the issue
+ #
+ my $csv = Text::CSV_XS->new(
+ { binary => 1, auto_diag => 1, allow_loose_quotes => 1 } );
+
+ #
+ # Query the eps table for all the id and tags
+ #
+ $sth = $dbh->prepare(
+ q{SELECT id,tags FROM eps
+ WHERE length(tags) > 0
+ ORDER BY id}
+ ) or die $DBI::errstr;
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+
+ $sth->execute;
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+
+ #
+ # Loop through what we got
+ #
+ while ( $h = $sth->fetchrow_hashref ) {
+ #
+ # Parse the tag list
+ #
+ $status = $csv->parse( $h->{tags} );
+ unless ($status) {
+ #
+ # Report and skip any errors
+ #
+ print "Parse error on episode ", $h->{id}, "\n";
+ print $csv->error_input(), "\n";
+ next;
+ }
+ @fields = $csv->fields();
+
+ next unless (@fields);
+
+ #
+ # Trim all tags (don't alter $_ when doing it)
+ #
+ @fields = map {
+ my $t = $_;
+ $t =~ s/(^\s+|\s+$)//g;
+ $t;
+ } @fields;
+
+ #
+ # De-duplicate
+ #
+ @fields = uniq(@fields);
+
+ #print "$h->{id}: ",join(",",@fields),"\n";
+
+ #
+ # Save the id and its tags, sorted for comparison, with empty elements
+ # removed too
+ #
+ $hash{ $h->{id} } = [ sort grep {!/^$/} @fields ];
+
+ }
+
+ #print Dumper(\%hash),"\n";
+
+ return \%hash;
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: Usage
+# PURPOSE: Display a usage message and exit
+# PARAMETERS: None
+# RETURNS: To command line level with exit value 1
+# DESCRIPTION: Builds the usage message using global values
+# THROWS: no exceptions
+# COMMENTS: none
+# SEE ALSO: n/a
+#===============================================================================
+sub Usage {
+ print STDERR < "\N{U+20AC}",
+# q{ÀÀ} => "\N{U+00C0}",
+# q{ÁÃ} => "\N{U+00C1}",
+# q{‚‚} => "\N{U+201A}",
+# q{ÂÂ} => "\N{U+00C2}",
+# q{ƒÆ’} => "\N{U+0192}",
+# q{ÃÃ} => "\N{U+00C3}",
+# q{„„} => "\N{U+201E}",
+# q{ÄÄ} => "\N{U+00C4}",
+# q{……} => "\N{U+2026}",
+# q{ÅÃ…} => "\N{U+00C5}",
+# q{†â€} => "\N{U+2020}",
+# q{ÆÆ} => "\N{U+00C6}",
+# q{‡â€¡} => "\N{U+2021}",
+# q{ÇÇ} => "\N{U+00C7}",
+# q{ˆË†} => "\N{U+02C6}",
+# q{ÈÈ} => "\N{U+00C8}",
+# q{‰â€°} => "\N{U+2030}",
+# q{ÉÉ} => "\N{U+00C9}",
+# q{ŠÅ} => "\N{U+0160}",
+# q{ÊÊ} => "\N{U+00CA}",
+# q{‹â€¹} => "\N{U+2039}",
+# q{ËË} => "\N{U+00CB}",
+# q{ŒÅ’} => "\N{U+0152}",
+# q{ÌÃŒ} => "\N{U+00CC}",
+# q{ÍÃ} => "\N{U+00CD}",
+# q{ŽÅ½} => "\N{U+017D}",
+# q{ÎÃŽ} => "\N{U+00CE}",
+# q{ÏÃ} => "\N{U+00CF}",
+# q{ÐÃ} => "\N{U+00D0}",
+# q{‘‘} => "\N{U+2018}",
+# q{ÑÑ} => "\N{U+00D1}",
+# q{Չ۪} => "\N{U+2019}",
+# q{ÒÃ’} => "\N{U+00D2}",
+# q{““} => "\N{U+201C}",
+# q{ÓÓ} => "\N{U+00D3}",
+# q{”â€} => "\N{U+201D}",
+# q{ÔÔ} => "\N{U+00D4}",
+# q{•â€¢} => "\N{U+2022}",
+# q{ÕÕ} => "\N{U+00D5}",
+# q{––} => "\N{U+2013}",
+# q{ÖÖ} => "\N{U+00D6}",
+# q{——} => "\N{U+2014}",
+# q{××} => "\N{U+00D7}",
+# q{˜Ëœ} => "\N{U+02DC}",
+# q{ØØ} => "\N{U+00D8}",
+# q{™â„¢} => "\N{U+2122}",
+# q{ÙÙ} => "\N{U+00D9}",
+# q{šÅ¡} => "\N{U+0161}",
+# q{ÚÚ} => "\N{U+00DA}",
+# q{݉ۼ} => "\N{U+203A}",
+# q{ÛÛ} => "\N{U+00DB}",
+# q{œÅ“} => "\N{U+0153}",
+# q{ÜÃœ} => "\N{U+00DC}",
+# q{ÝÃ} => "\N{U+00DD}",
+# q{žÅ¾} => "\N{U+017E}",
+# q{ÞÞ} => "\N{U+00DE}",
+# q{ŸÅ¸} => "\N{U+0178}",
+# q{ßß} => "\N{U+00DF}",
+# q{Â} => "\N{U+00A0}",
+# q{àÃ} => "\N{U+00E0}",
+# q{¡Â¡} => "\N{U+00A1}",
+# q{áá} => "\N{U+00E1}",
+# q{¢Â¢} => "\N{U+00A2}",
+# q{ââ} => "\N{U+00E2}",
+# q{£Â£} => "\N{U+00A3}",
+# q{ãã} => "\N{U+00E3}",
+# q{¤Â¤} => "\N{U+00A4}",
+# q{ää} => "\N{U+00E4}",
+# q{¥Â¥} => "\N{U+00A5}",
+# q{åÃ¥} => "\N{U+00E5}",
+# q{¦Â¦} => "\N{U+00A6}",
+# q{ææ} => "\N{U+00E6}",
+# q{§Â§} => "\N{U+00A7}",
+# q{çç} => "\N{U+00E7}",
+# q{¨Â¨} => "\N{U+00A8}",
+# q{èè} => "\N{U+00E8}",
+# q{©Â©} => "\N{U+00A9}",
+# q{éé} => "\N{U+00E9}",
+# q{ªÂª} => "\N{U+00AA}",
+# q{êê} => "\N{U+00EA}",
+# q{«Â«} => "\N{U+00AB}",
+# q{ëë} => "\N{U+00EB}",
+# q{¬Â¬} => "\N{U+00AC}",
+# q{ìì} => "\N{U+00EC}",
+# q{Â} => "\N{U+00AD}",
+# q{íÃ} => "\N{U+00ED}",
+# q{®Â®} => "\N{U+00AE}",
+# q{îî} => "\N{U+00EE}",
+# q{¯Â¯} => "\N{U+00AF}",
+# q{ïï} => "\N{U+00EF}",
+# q{°Â°} => "\N{U+00B0}",
+# q{ðð} => "\N{U+00F0}",
+# q{±Â±} => "\N{U+00B1}",
+# q{ññ} => "\N{U+00F1}",
+# q{²Â²} => "\N{U+00B2}",
+# q{òò} => "\N{U+00F2}",
+# q{³Â³} => "\N{U+00B3}",
+# q{óó} => "\N{U+00F3}",
+# q{´Â´} => "\N{U+00B4}",
+# q{ôô} => "\N{U+00F4}",
+# q{µÂµ} => "\N{U+00B5}",
+# q{õõ} => "\N{U+00F5}",
+# q{¶Â¶} => "\N{U+00B6}",
+# q{öö} => "\N{U+00F6}",
+# q{·Â·} => "\N{U+00B7}",
+# q{÷÷} => "\N{U+00F7}",
+# q{¸Â¸} => "\N{U+00B8}",
+# q{øø} => "\N{U+00F8}",
+# q{¹Â¹} => "\N{U+00B9}",
+# q{ùù} => "\N{U+00F9}",
+# q{ºÂº} => "\N{U+00BA}",
+# q{úú} => "\N{U+00FA}",
+# q{»Â»} => "\N{U+00BB}",
+# q{ûû} => "\N{U+00FB}",
+# q{¼Â¼} => "\N{U+00BC}",
+# q{üü} => "\N{U+00FC}",
+# q{½Â½} => "\N{U+00BD}",
+# q{ýý} => "\N{U+00FD}",
+# q{¾Â¾} => "\N{U+00BE}",
+# q{þþ} => "\N{U+00FE}",
+# q{¿Â¿} => "\N{U+00BF}",
+# q{ÿÿ} => "\N{U+00FF}",
+#);
+
+#
+# Build a regex from all of the hash keys
+#
+#my $regex = join('|',sort(keys(%map_latin1)));
+#$regex=qr{$regex};
+
+#}}}
+
+#
+# Enable Unicode output mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#-------------------------------------------------------------------------------
+# Options and arguments {{{
+#-------------------------------------------------------------------------------
+#
+# Process options
+#
+my %options;
+Options( \%options );
+
+#
+# Default help
+#
+pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 )
+ if ( $options{'help'} );
+
+#
+# Full documentation if requested with -doc
+#
+pod2usage( -msg => "$PROG version $VERSION\n", -verbose => 2, -exitval => 1 )
+ if ( $options{'doc'} );
+
+#
+# Collect options
+#
+my $cfgfile
+ = ( defined( $options{config} ) ? $options{config} : $configfile );
+my $dry_run = ( defined( $options{'dry-run'} ) ? $options{'dry-run'} : 0 );
+my $verbose = ( defined( $options{verbose} ) ? $options{verbose} : 0 );
+my $field = $options{field};
+my $skip = $options{skip} // 0;
+my $limit = $options{limit} // 0;
+
+# }}}
+
+#
+# Sanity checks
+#
+die "Unable to find $cfgfile\n" unless ( -e $cfgfile );
+if ($field) {
+ $field = lc($field);
+ die "Invalid value for -field=FIELD\n"
+ unless ( $field =~ /title|summary|tags|notes/ );
+}
+else {
+ $field = 'title';
+}
+
+#-------------------------------------------------------------------------------
+# Load configuration data
+#-------------------------------------------------------------------------------
+my $conf = new Config::General(
+ -ConfigFile => $cfgfile,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %config = $conf->getall();
+
+#-------------------------------------------------------------------------------
+# Connect to the database
+#-------------------------------------------------------------------------------
+my $dbhost = $config{database}->{host} // '127.0.0.1';
+my $dbport = $config{database}->{port} // 3306;
+my $dbname = $config{database}->{name};
+my $dbuser = $config{database}->{user};
+my $dbpwd = $config{database}->{password};
+$dbh = DBI->connect( "dbi:mysql:host=$dbhost;port=$dbport;database=$dbname",
+ $dbuser, $dbpwd, { AutoCommit => 1 } )
+ or die $DBI::errstr;
+
+#
+# Enable client-side UTF8
+#
+$dbh->{mysql_enable_utf8} = 1;
+
+#-------------------------------------------------------------------------------
+# Set up logging keeping the default log layout except for the date
+#-------------------------------------------------------------------------------
+my $log = Log::Handler->new();
+
+$log->add(
+ file => {
+ timeformat => "%Y-%m-%d %H:%M:%S",
+ filename => $logfile,
+ maxlevel => 7,
+ minlevel => 0,
+ utf8 => 1,
+ }
+);
+
+#
+# Log the settings being used
+#
+$log->info("---- Running version $VERSION");
+$log->info("Configuration file $cfgfile");
+$log->info("Processing field '$field'");
+$log->info("Skipping $skip non-ASCII rows") if $skip;
+$log->info("Update limit is $limit") if $limit;
+$log->info("Dry-run mode") if ($dry_run);
+
+#
+# Adjust limit
+#
+$limit += $skip if $skip;
+
+#-------------------------------------------------------------------------------
+# Perform a scan of episodes for the chosen field which contains non-ASCII
+#-------------------------------------------------------------------------------
+$sql = sprintf(
+ q{SELECT id,%s FROM eps WHERE %s <> CONVERT(%s USING ASCII) ORDER BY id},
+ $field, $field, $field
+);
+
+$sth1 = $dbh->prepare($sql) or die $DBI::errstr;
+
+$sth1->execute;
+if ( $dbh->err ) {
+ warn $dbh->errstr;
+}
+
+#
+# Prepare SQL::Abstract and the SQL template for the updates
+#
+my $sqla = SQL::Abstract->new;
+
+my $stmt1 = sprintf(
+ q{UPDATE eps SET %s = CONVERT(BINARY CONVERT(%s USING latin1) USING utf8)},
+ $field, $field
+);
+
+#-------------------------------------------------------------------------------
+# Loop through what we get from the main query, attempting to convert each field
+#-------------------------------------------------------------------------------
+$viewed = 0;
+while ( $h1 = $sth1->fetchrow_hashref ) {
+ $viewed++;
+ next if $viewed <= $skip;
+
+ #
+ # Prepare the 'WHERE' part of the SQL
+ #
+ my %where = ( id => $h1->{id} );
+ my ( $stmt2, @bind ) = $sqla->where( \%where );
+ my $stmt = "${stmt1}${stmt2}";
+
+ #
+ # In dry-run mode just report what would have been done, otherwise try and
+ # make the change.
+ #
+ if ($dry_run) {
+ if ($verbose) {
+ printf "[%04d] %s\n", $h1->{id},
+ (
+ $field eq 'notes'
+ ? ''
+ : $h1->{$field}
+ );
+ }
+
+ say "SQL: ${stmt}";
+ say "Arguments: ",join( ',', @bind );
+ }
+ else {
+ $sth2 = $dbh->prepare($stmt) or die $DBI::errstr;
+
+ #
+ # The SQL could generate an error which we'll try and intercept
+ #
+ try {
+ $sth2->execute(@bind)
+ or die $DBI::errstr;
+ $log->info("Updated $field field for row $h1->{id}");
+ }
+ catch ($e) {
+ $log->info("Failed to update $field field for row $h1->{id}");
+ $log->info("Error: $e");
+ }
+ }
+
+}
+continue {
+ if ($limit) {
+ if ($viewed >= $limit) {
+ $log->info("Update limit reached");
+ last;
+ };
+ }
+}
+
+exit;
+
+#=== FUNCTION ================================================================
+# NAME: Options
+# PURPOSE: Processes command-line options
+# PARAMETERS: $optref Hash reference to hold the options
+# RETURNS: Undef
+# DESCRIPTION:
+# THROWS: no exceptions
+# COMMENTS: none
+# SEE ALSO: n/a
+#===============================================================================
+sub Options {
+ my ($optref) = @_;
+
+ my @options = (
+ "help", "doc", "dry-run!", "verbose!",
+ "config=s", "field=s", "skip=i", "limit=i",
+ );
+
+ if ( !GetOptions( $optref, @options ) ) {
+ pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 );
+ }
+
+ return;
+}
+
+__END__
+
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+# Application Documentation
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+#{{{
+
+=head1 NAME
+
+convert_latin1 - a script to convert fields in the HPR database to UTF-8
+
+=head1 VERSION
+
+This documentation refers to convert_latin1 version 0.1.2
+
+
+=head1 USAGE
+
+ ./convert_latin1 [-help] [-doc] [-config=FILE] [-[no]dry-run]
+ [-[no]verbose] [-field=FIELDNAME] [-skip=N] [-limit=N]
+
+ ./convert_latin1 -config=.hpr_livedb.cfg -verb -field=title
+ ./convert_latin1 -config=.hpr_livedb.cfg -verb -dry-run -field=notes
+ -limit=10
+
+
+=head1 OPTIONS
+
+=over 8
+
+=item B<-help>
+
+Prints a brief help message describing the usage of the program, and then exits.
+
+=item B<-doc>
+
+Displays the entirety of the documentation (using a pager), and then exits. To
+generate a PDF version use:
+
+ pod2pdf convert_latin1 --out=convert_latin1.pdf
+
+=item B<-config=FILE>
+
+This option allows an alternative configuration file to be used. This file
+defines the location of the database, its port, its name and the username and
+password to be used to access it. This feature was added to allow the script
+to access alternative databases or the live database over an SSH tunnel.
+
+See the CONFIGURATION AND ENVIRONMENT section below for the file format.
+
+If the option is omitted the default file is used: B<.hpr_db.cfg>
+
+=item B<-[no]dry-run>
+
+Controls whether the program runs in a mode where it performs database
+updates. When enabled the details of the updates to be performed are shown,
+otherwise the updates are applied. The default B<-nodry-run> allows the
+program to perform the changes.
+
+=item B<-[no]verbose>
+
+Normally very little is reported by the script, although details of errors
+are reported. When B<-verbose> is selected more information
+about the number of rows needing work, the updates performed (or which would
+have been performed) and how many changes were made is reported.
+
+=item B<-field=FIELDNAME>
+
+This option defines the database field name to be converted. The permitted
+names are B, B, B and B and the table is asumed
+to be B. If the option is not provided the default field B will be
+used.
+
+=item B<-skip=N>
+
+This option defines the number of database rows to skip when processing the
+selected field. If omitted then no rows are skipped. The option is useful to
+allow the work to be split into manageable batches, in conjunction with the
+B<-limit=N> option below.
+
+=item B<-limit=N>
+
+This option defines the number of database rows to work on when processing the
+selected field. If omitted then all rows are processed (after any skip defined
+with te B<-skip=N> option). The option is useful to allow the work to split
+into manageable batches, in conjunction with the B<-skip=N> option above.
+
+=back
+
+=head1 DESCRIPTION
+
+=head2 OVERVIEW
+
+The script is designed to repair the HPR MySQL (MariaDB) database which holds
+show metadata. The database was created with 'latin1' encoding, and was later
+changed to use UTF-8. However, no action was taken to ensure the PHP software
+managing the database also used UTF-8. This meant that the 'latin1' encoded data
+was still being generated as Unicode UTF-8 data was being added, and was being
+rendered in the expected way, while there was little or no UTF-8 data being
+stored.
+
+The PHP deficiencies were rectified in April 2023 but this meant that all
+non-ASCII characters stored in the database before that were rendered
+incorrectly. The solution was to convert all 'latin1' non-ASCII data into
+UTF-8, and that is what this script does.
+
+Detecting non ASCII in database fields was performed with the following SQL:
+
+ SELECT id,field FROM eps WHERE field <> CONVERT(field USING ASCII) ORDER BY id
+
+This is used to generate a list of all rows which might need conversion to
+UTF-8. However, the test is only whether there is non-ASCII data in the row.
+
+Ideally, the conversion could have been performed entirely within the database
+with SQL such as the following (for each field):
+
+ UPDATE eps SET field = CONVERT(binary CONVERT(field USING latin1) USING utf8)
+ WHERE field <> CONVERT(field USING ASCII);
+
+However, the conversion to UTF-8 fails when the field already contains such
+characters, stopping the query.
+
+MySQL and MariaDB are capable of trapping errors (like using B in
+various programming languages), but only in stored procedures. It was felt to
+be undesirable to create stored procedures on the HPR database since this was
+only possible through B which is due to be phased out.
+
+This script was written to enable the catching of errors instead.
+
+=head2 SCRIPT DESIGN
+
+The main loop returns all rows with non-ASCII characters in the field being
+processed. For each row an 'UPDATE' query is performed using the 'id' field
+(episode number) to select it:
+
+ UPDATE eps SET field = CONVERT(BINARY CONVERT(field USING latin1) USING utf8)
+ WHERE id = value
+
+This is performed inside a B statement so that if the query fails
+it does not stop the script. Successes and failures are logged.
+
+This algorithm is fairly slow, particularly for the 'notes' field which has
+the most (nearly 600) non-ASCII rows. However, it seems to work as desired.
+
+The B<-skip=N> and B<-limit=N> options allow control over the conversion
+process such that the work can be done in batches.
+
+Note that the log file used by the script is called B. It
+is appended to on every run. The file name can only be changed by editing the
+script.
+
+=head1 DIAGNOSTICS
+
+A list of every error and warning message that the application can generate
+(even the ones that will "never happen"), with a full explanation of each
+problem, one or more likely causes, and any suggested remedies. If the
+application generates exit status codes (e.g. under Unix) then list the exit
+status associated with each error.
+
+
+=head1 CONFIGURATION AND ENVIRONMENT
+
+The script obtains the credentials it requires to open the HPR database from
+a configuration file. The name of the file it expects is B<.hpr_db.cfg> in the
+directory holding the script. This can be changed by use of the
+B<-configuration=FILE> option as described above.
+
+The configuration file format is as follows:
+
+
+ host = 127.0.0.1
+ port = PORT
+ name = DATABASE
+ user = USERNAME
+ password = PASSWORD
+
+
+=head1 DEPENDENCIES
+
+ Config::General
+ DBI
+ Data::Dumper
+ Getopt::Long
+ Log::Handler
+ Log::Handler::Output::File
+ Pod::Usage
+ SQL::Abstract
+
+The script uses the experimental B feature and disables the warning that
+this feature generates. Note that this feature is only available in Perl
+versions at 5.34.0 or above (the script was developed under v5.36.0).
+
+=head1 BUGS AND LIMITATIONS
+
+There are no known bugs in this module.
+Please report problems to Dave Morriss (Dave.Morriss@gmail.com) Patches are
+welcome.
+
+=head1 AUTHOR
+
+Dave Morriss (Dave.Morriss@gmail.com)
+
+=head1 LICENCE AND COPYRIGHT
+
+Copyright (c) 2023 Dave Morriss (Dave.Morriss@gmail.com). All rights reserved.
+
+This module is free software; you can redistribute it and/or
+modify it under the same terms as Perl itself. See perldoc perlartistic.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+=cut
+
+#}}}
+
+# [zo to open fold, zc to close]
+
+# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
+
diff --git a/Database/copy_mysql_pg b/Database/copy_mysql_pg
new file mode 100755
index 0000000..71374bb
--- /dev/null
+++ b/Database/copy_mysql_pg
@@ -0,0 +1,1438 @@
+#!/usr/bin/env perl
+#===============================================================================
+#
+# FILE: copy_mysql_pg
+#
+# USAGE: ./copy_mysql_pg
+#
+# DESCRIPTION: Copies HPR show data from the MariaDB database to an
+# experimental PostgreSQL database
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: ---
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.0.4
+# CREATED: 2017-03-15 18:50:08
+# REVISION: 2017-10-17 22:37:08
+#
+#===============================================================================
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+
+use Getopt::Long;
+use Pod::Usage;
+
+use Config::General;
+use List::MoreUtils qw{uniq apply};
+
+use Text::CSV;
+use DBI;
+
+use Data::Dumper;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.0.4';
+
+#
+# Script and directory names
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+( my $DIR = $0 ) =~ s|/?[^/]*$||mx;
+$DIR = '.' unless $DIR;
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+#
+# Constants and other declarations
+#
+my $basedir = "$ENV{HOME}/HPR/Database";
+my $configfile1 = "$basedir/.hpr_db.cfg";
+my $configfile2 = "$basedir/.hpr_pg.cfg";
+
+my $email_template = 'host_%s@hackerpublicradio.org';
+
+my ( $dbh1, $sth1, $h1, $rv1 );
+my ( $dbh2, $sth2, $h2, $rv2 );
+
+my (@phase_choices);
+my ( %eps_tags, %data );
+
+my @phases = (
+ 'episodes', 'hosts', 'eh_xref', 'series', 'es_xref', 'tags',
+ 'comments', 'twat', 'epilogue'
+);
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#
+# Load database configuration data
+#
+my $conf1 = Config::General->new(
+ -ConfigFile => $configfile1,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %config1 = $conf1->getall();
+
+my $conf2 = Config::General->new(
+ -ConfigFile => $configfile2,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %config2 = $conf2->getall();
+
+#-------------------------------------------------------------------------------
+# Options and arguments
+#-------------------------------------------------------------------------------
+my $DEF_DEBUG = 0;
+
+#
+# Process options
+#
+my %options;
+Options( \%options );
+
+#
+# Default help
+#
+pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 )
+ if ( $options{'help'} );
+
+#
+# Collect options
+#
+my $DEBUG = ( defined( $options{debug} ) ? $options{debug} : $DEF_DEBUG );
+my $verbose = ( defined( $options{verbose} ) ? $options{verbose} : 0 );
+my $dry_run = ( defined( $options{'dry-run'} ) ? $options{'dry-run'} : 0 );
+
+#
+# This option is a list, provided as a CSV
+#
+my $phase_choices = $options{phases};
+if ( defined($phase_choices) ) {
+ #
+ # We have a list which we'll parse, validate, sort, make unique and filter
+ #
+ my $lcsv = Text::CSV_XS->new( { binary => 1, } );
+ if ( $lcsv->parse($phase_choices) ) {
+ # Sort fields
+ @phase_choices = uniq( sort { $a cmp $b } $lcsv->fields() );
+ # Trim leading and trailing spaces
+ @phase_choices = apply { $_ =~ s/(^\s*|\s*$)// } @phase_choices;
+
+ # Make a list of invalid keywords
+ my %tmp = map { $_ => 1 } @phases;
+ my @bad = grep { not exists $tmp{$_} } @phase_choices;
+
+ # Deal with all errors
+ die "Invalid list; no elements\n" if scalar(@phase_choices) == 0;
+ die "Invalid list; too many elements\n"
+ if scalar(@phase_choices) > scalar(@phases);
+ die "Invalid list elements: ", join( ",", @bad ) . "\n"
+ if scalar(@bad) > 0;
+ }
+ else {
+ die "Failed to parse -list='$phase_choices'\n"
+ . $lcsv->error_diag() . "\n";
+ }
+}
+else {
+ #
+ # By default we do all phases
+ #
+ @phase_choices = @phases;
+}
+
+#-------------------------------------------------------------------------------
+# Connect to the MariaDB database
+#-------------------------------------------------------------------------------
+my $dbtype1 = $config1{database}->{type} // 'mysql';
+my $dbhost1 = $config1{database}->{host} // '127.0.0.1';
+my $dbport1 = $config1{database}->{port} // 3306;
+my $dbname1 = $config1{database}->{name};
+my $dbuser1 = $config1{database}->{user};
+my $dbpwd1 = $config1{database}->{password};
+$dbh1
+ = DBI->connect( "dbi:$dbtype1:host=$dbhost1;port=$dbport1;database=$dbname1",
+ $dbuser1, $dbpwd1, { AutoCommit => 1 } )
+ or die $DBI::errstr;
+
+#
+# Enable client-side UTF8
+#
+$dbh1->{mysql_enable_utf8} = 1;
+
+#-------------------------------------------------------------------------------
+# Connect to the PostgreSQL database
+#-------------------------------------------------------------------------------
+my $dbtype2 = $config2{database}->{type} // 'Pg';
+my $dbhost2 = $config2{database}->{host} // '127.0.0.1';
+my $dbport2 = $config2{database}->{port} // 5432;
+my $dbname2 = $config2{database}->{name};
+my $dbuser2 = $config2{database}->{user};
+my $dbpwd2 = $config2{database}->{password};
+$dbh2 = DBI->connect( "dbi:$dbtype2:host=$dbhost2;database=$dbname2;port=$dbport2",
+ $dbuser2, $dbpwd2, { PrintError => 0, AutoCommit => 1 } )
+ or die $DBI::errstr;
+
+#
+# Enable client-side UTF8
+#
+$dbh2->{pg_enable_utf8} = 1;
+
+my %choices = map { $_ => 1 } @phase_choices;
+
+#
+# Perform phases in order, omitting those that are not in the list
+#
+for my $phase (@phases) {
+ #---------------------------------------------------------------------------
+ # Copy the 'eps' table to 'episodes'
+ #---------------------------------------------------------------------------
+ if ( $phase eq 'episodes' && exists( $choices{$phase} ) ) {
+ print "Build episodes table\n" if ( $verbose > 0 );
+ if ( check_table( $dbh2, 'episodes' ) ) {
+ build_episodes_table( $dbh1, $dbh2, $verbose ) unless $dry_run;
+ }
+ else {
+ print "** Table 'episodes' is not empty\n";
+ }
+ }
+ #---------------------------------------------------------------------------
+ # Copy the 'hosts' table to 'hosts'
+ #---------------------------------------------------------------------------
+ elsif ( $phase eq 'hosts' && exists( $choices{$phase} ) ) {
+ print "Build hosts table\n" if ( $verbose > 0 );
+ if ( check_table( $dbh2, 'hosts' ) ) {
+ build_hosts_table( $dbh1, $dbh2, $verbose ) unless $dry_run;
+ }
+ else {
+ print "** Table 'hosts' is not empty\n";
+ }
+ }
+ #---------------------------------------------------------------------------
+ # Generate the 'episodes_hosts_xref' table
+ #---------------------------------------------------------------------------
+ elsif ( $phase eq 'eh_xref' && exists( $choices{$phase} ) ) {
+ print "Build episodes_hosts_xref table\n" if ( $verbose > 0 );
+ if ( check_table( $dbh2, 'episodes_hosts_xref' ) ) {
+ build_episodes_hosts_xref_table( $dbh1, $dbh2, $verbose )
+ unless $dry_run;
+ }
+ else {
+ print "** Table 'episodes_hosts_xref' is not empty\n";
+ }
+ }
+ #---------------------------------------------------------------------------
+ # Copy the 'miniseries' table to 'series'
+ #---------------------------------------------------------------------------
+ elsif ( $phase eq 'series' && exists( $choices{$phase} ) ) {
+ print "Build series table\n" if ( $verbose > 0 );
+ if ( check_table( $dbh2, 'series' ) ) {
+ build_series_table( $dbh1, $dbh2, $verbose ) unless $dry_run;
+ }
+ else {
+ print "** Table 'series' is not empty\n";
+ }
+ }
+ #---------------------------------------------------------------------------
+ # Generate the 'episodes_series_xref' table
+ #---------------------------------------------------------------------------
+ elsif ( $phase eq 'es_xref' && exists( $choices{$phase} ) ) {
+ print "Build episodes_series_xref table\n" if ( $verbose > 0 );
+ if ( check_table( $dbh2, 'episodes_series_xref' ) ) {
+ build_episodes_series_xref_table( $dbh1, $dbh2, $verbose )
+ unless $dry_run;
+ }
+ else {
+ print "** Table 'episodes_series_xref' is not empty\n";
+ }
+ }
+ #---------------------------------------------------------------------------
+ # Collect and store the id numbers and tags from the 'eps' table, then add
+ # them to the PostgreSQL tables.
+ #---------------------------------------------------------------------------
+ elsif ( $phase eq 'tags' && exists( $choices{$phase} ) ) {
+ print "Build tags and episodes_tags_xref tables\n" if ( $verbose > 0 );
+ if ( check_table( $dbh2, 'tags' )
+ && check_table( $dbh2, 'episodes_tags_xref' ) )
+ {
+ unless ($dry_run) {
+ %eps_tags = %{ collect_eps_tags( $dbh1, $verbose ) };
+
+ if (%eps_tags) {
+ build_tags_table( $dbh2, $verbose, \%eps_tags );
+ }
+ }
+ }
+ else {
+ print "** Tables 'tags' and/or 'episodes_tags_xref' are not empty\n";
+ }
+ }
+ #---------------------------------------------------------------------------
+ # Copy the 'comments' table to 'comments'
+ #---------------------------------------------------------------------------
+ elsif ( $phase eq 'comments' && exists( $choices{$phase} ) ) {
+ print "Build comments table\n" if ( $verbose > 0 );
+ if (check_table($dbh2,'comments')) {
+ build_comments_table( $dbh1, $dbh2, $verbose ) unless $dry_run;
+ }
+ else {
+ print "** Table 'comments' is not empty\n";
+ }
+ }
+ #---------------------------------------------------------------------------
+ # Perform the 'twat' actions
+ #---------------------------------------------------------------------------
+ elsif ( $phase eq 'twat' && exists( $choices{$phase} ) ) {
+ print "Perform twat actions\n" if ( $verbose > 0 );
+
+ #
+ # Incorporate the TwaT tables (assuming they exist)
+ #
+ if (check_table($dbh2,'twat_hosts')) {
+ load_twat_hosts($dbh1,$dbh2,$verbose) unless $dry_run;
+ }
+ else {
+ print "** Table 'twat_hosts' is not empty\n";
+ }
+
+ if (check_table($dbh2,'twat_episodes')) {
+ load_twat_episodes($dbh1,$dbh2,$verbose) unless $dry_run;
+ }
+ else {
+ print "** Table 'twat_episodes' is not empty\n";
+ }
+ }
+ #---------------------------------------------------------------------------
+ # Perform the 'epilogue' actions
+ #---------------------------------------------------------------------------
+ elsif ( $phase eq 'epilogue' && exists( $choices{$phase} ) ) {
+ print "Perform epilogue actions\n" if ( $verbose > 0 );
+
+ #
+ # Determine the first show date per host, assuming that's when they
+ # were added to the database
+ #
+ compute_host_date_added($dbh2) unless $dry_run;
+ }
+}
+
+exit;
+
+#=== FUNCTION ================================================================
+# NAME: build_episodes_table
+# PURPOSE: Copy the data from the MariaDB 'eps' table to the Pg
+# 'episodes' table
+# PARAMETERS: $dbh1 Handle for the MariaDB database
+# $dbh2 Handle for the Pg database
+# $verbose Verbosity level
+# RETURNS: Nothing
+# DESCRIPTION:
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub build_episodes_table {
+ my ( $dbh1, $dbh2, $verbose ) = @_;
+
+ my ( $sth1, $h1, $sth2, $count );
+
+ $sth1 = $dbh1->prepare('SELECT * FROM eps') or die $DBI::errstr;
+ if ( $dbh1->err ) {
+ warn $dbh1->errstr;
+ }
+
+ $sth2 = $dbh2->prepare('INSERT INTO episodes VALUES (?,?,?,?,?,?,?,?,?)')
+ or die $DBI::errstr;
+ if ( $dbh2->err ) {
+ warn $dbh2->errstr;
+ }
+
+ #
+ # Query MariaDB for the entire 'eps' table
+ #
+ $sth1->execute;
+ if ( $dbh1->err ) {
+ die $dbh1->errstr;
+ }
+
+ #
+ # Loop though 'eps' table rows writing them to the PostgreSQL 'episodes'
+ # table
+ #
+ $count = 0;
+ while ( $h1 = $sth1->fetchrow_hashref ) {
+ $count++;
+ $sth2->execute(
+ $h1->{id}, $h1->{date},
+ $h1->{title}, nullif( $h1->{summary}, '^\s*$' ),
+ $h1->{notes}, $h1->{explicit},
+ $h1->{license}, $h1->{duration},
+ $h1->{downloads},
+ );
+ if ( $dbh2->err ) {
+ die $dbh2->errstr;
+ }
+ }
+
+ print "Copied $count records\n" if ($verbose > 0);
+
+ #
+ # Set the sequence to the correct value
+ #
+ alter_seq($dbh2,'episodes','episode_seq');
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: build_hosts_table
+# PURPOSE: Copy the data from the Mariadb 'hosts' table to the Pg 'hosts'
+# table
+# PARAMETERS: $dbh1 Handle for the MariaDB table
+# $dbh2 Handle for the Pg database
+# $verbose Verbosity level
+# RETURNS: Nothing
+# DESCRIPTION:
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub build_hosts_table {
+ my ( $dbh1, $dbh2, $verbose ) = @_;
+
+ my ( $sth1, $h1, $sth2, $rv, $count );
+
+ $sth1 = $dbh1->prepare('SELECT * FROM hosts') or die $DBI::errstr;
+ if ( $dbh1->err ) {
+ warn $dbh1->errstr;
+ }
+
+ $sth2 = $dbh2->prepare(
+ q{INSERT INTO hosts
+ (id, host, email, profile, license, local_image, gpg, valid)
+ VALUES (?,?,?,?,?,?,?,?)}
+ ) or die $DBI::errstr;
+ if ( $dbh2->err ) {
+ warn $dbh2->errstr;
+ }
+
+ $sth1->execute;
+ if ( $dbh1->err ) {
+ die $dbh1->errstr;
+ }
+
+ $count = 0;
+ while ( $h1 = $sth1->fetchrow_hashref ) {
+ $count++;
+ $sth2->execute(
+ $h1->{hostid},
+ $h1->{host},
+ default_email(
+ $h1->{email}, '^(\s*|admin@hackerpublicradio.org)$',
+ $email_template, $h1->{hostid}
+ ),
+ nullif( $h1->{profile}, '^\s*$' ),
+ $h1->{license},
+ $h1->{local_image},
+ nullif( $h1->{gpg}, '^\s*$' ),
+ $h1->{valid},
+ );
+ if ( $dbh2->err ) {
+ die $dbh2->errstr;
+ }
+ }
+
+ print "Copied $count records\n" if ( $verbose > 0 );
+
+ #
+ # Set the sequence to the correct value
+ #
+ alter_seq( $dbh2, 'hosts', 'host_seq' );
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: build_episodes_hosts_xref_table
+# PURPOSE: Generates the cross reference table by examining the 'eps' and
+# 'hosts' tables in the MariaDB database.
+# PARAMETERS: $dbh1 Handle for the MariaDB table
+# $dbh2 Handle for the Pg database
+# $verbose Verbosity level
+# RETURNS: Nothing
+# DESCRIPTION:
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub build_episodes_hosts_xref_table {
+ my ( $dbh1, $dbh2, $verbose ) = @_;
+
+ my ( $sth1, $h1, $sth2, $count );
+
+ $sth1
+ = $dbh1->prepare(
+ 'SELECT e.id,h.hostid FROM eps e, hosts h WHERE e.hostid = h.hostid')
+ or die $DBI::errstr;
+ if ( $dbh1->err ) {
+ warn $dbh1->errstr;
+ }
+
+ $sth2 = $dbh2->prepare('INSERT INTO episodes_hosts_xref VALUES (?,?)')
+ or die $DBI::errstr;
+ if ( $dbh2->err ) {
+ warn $dbh2->errstr;
+ }
+
+ $sth1->execute;
+ if ( $dbh1->err ) {
+ die $dbh1->errstr;
+ }
+
+ $count = 0;
+ while ( $h1 = $sth1->fetchrow_hashref ) {
+ $count++;
+ $sth2->execute( $h1->{id}, $h1->{hostid}, );
+ if ( $dbh2->err ) {
+ die $dbh2->errstr;
+ }
+ }
+
+ print "Copied $count records\n" if ($verbose > 0);
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: build_series_table
+# PURPOSE: Copy the data from the Mariadb 'miniseries' table to the Pg
+# 'series' table
+# PARAMETERS: $dbh1 Handle for the MariaDB database
+# $dbh2 Handle for the Pg database
+# $verbose Verbosity level
+# RETURNS: Nothing
+# DESCRIPTION:
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub build_series_table {
+ my ( $dbh1, $dbh2, $verbose ) = @_;
+
+ my ( $sth1, $h1, $sth2, $count );
+
+ $sth1 = $dbh1->prepare('SELECT * FROM miniseries') or die $DBI::errstr;
+ if ( $dbh1->err ) {
+ warn $dbh1->errstr;
+ }
+
+ $sth2 = $dbh2->prepare('INSERT INTO series VALUES (?,?,?,?,?,?)')
+ or die $DBI::errstr;
+ if ( $dbh2->err ) {
+ warn $dbh2->errstr;
+ }
+
+ #
+ # Query MariaDB for the entire 'miniseries' table
+ #
+ $sth1->execute;
+ if ( $dbh1->err ) {
+ die $dbh1->errstr;
+ }
+
+ #
+ # Loop though 'miniseries' table rows writing them to the PostgreSQL
+ # 'series' table
+ #
+ $count = 0;
+ while ( $h1 = $sth1->fetchrow_hashref ) {
+ $count++;
+ $sth2->execute(
+ $h1->{id}, $h1->{name},
+ $h1->{description}, $h1->{private},
+ nullif( $h1->{image}, '^\s*$' ),
+ $h1->{valid},
+ );
+ if ( $dbh2->err ) {
+ die $dbh2->errstr;
+ }
+ }
+
+ print "Copied $count records\n" if ($verbose > 0);
+
+ #
+ # Set the sequence to the correct value
+ #
+ alter_seq($dbh2,'series','series_seq');
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: build_episodes_series_xref_table
+# PURPOSE: Generates the cross reference table by examining the 'eps' and
+# 'series' tables in the MariaDB database.
+# PARAMETERS: $dbh1 Handle for the MariaDB table
+# $dbh2 Handle for the Pg database
+# $verbose Verbosity level
+# RETURNS: Nothing
+# DESCRIPTION:
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub build_episodes_series_xref_table {
+ my ( $dbh1, $dbh2, $verbose ) = @_;
+
+ my ( $sth1, $h1, $sth2, $count );
+
+ $sth1
+ = $dbh1->prepare(
+ 'SELECT e.id AS epid, m.id AS msid FROM eps e, miniseries m WHERE e.series = m.id')
+ or die $DBI::errstr;
+ if ( $dbh1->err ) {
+ warn $dbh1->errstr;
+ }
+
+ $sth2 = $dbh2->prepare('INSERT INTO episodes_series_xref VALUES (?,?)')
+ or die $DBI::errstr;
+ if ( $dbh2->err ) {
+ warn $dbh2->errstr;
+ }
+
+ $sth1->execute;
+ if ( $dbh1->err ) {
+ die $dbh1->errstr;
+ }
+
+ $count = 0;
+ while ( $h1 = $sth1->fetchrow_hashref ) {
+ $count++;
+ $sth2->execute( $h1->{epid}, $h1->{msid}, );
+ if ( $dbh2->err ) {
+ die $dbh2->errstr;
+ }
+ }
+
+ print "Copied $count records\n" if ($verbose > 0);
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: build_tags_table
+# PURPOSE: Using the data structure built from the MariaDB database
+# populate the many-to-many table in the Pg database
+# PARAMETERS: $dbh Handle for the Pg database
+# $verbose Verbosity level
+# $tag_hash Reference to a hash of episode ids and tags
+# for each episode
+# RETURNS: Nothing
+# DESCRIPTION:
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub build_tags_table {
+ my ( $dbh, $verbose, $tag_hash ) = @_;
+
+ my ( $sth1, $h1, $sth2, $sth3, $rv, $tags, $tid, $count1, $count2 );
+
+ #
+ # Query to find if a tag already exists
+ #
+ $sth1 = $dbh->prepare(q{SELECT * FROM tags WHERE tag = ?});
+
+ #
+ # Query to add a new tag
+ #
+ $sth2 = $dbh->prepare(q{INSERT INTO tags (tag) VALUES(?)});
+
+ #
+ # Query to add a new joining row
+ #
+ $sth3 = $dbh->prepare(q{INSERT INTO episodes_tags_xref VALUES(?,?)});
+
+ $count1 = $count2 = 0;
+ foreach my $id ( sort { $a <=> $b } keys( %{$tag_hash} ) ) {
+ #
+ # Get the array of tags for this episode id
+ #
+ $tags = $tag_hash->{$id};
+
+ #
+ # Loop through the array of tags (using an integer so we can index the
+ # current tag)
+ #
+ for my $i ( 0 .. $#$tags ) {
+ #
+ # Look to see if this tag exists
+ #
+ $sth1->execute( $tags->[$i] );
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+
+ #
+ # If it's already in the table just store the id for later
+ # otherwise add a new entry
+ #
+ if ( $h1 = $sth1->fetchrow_hashref ) {
+ $tid = $h1->{id};
+ }
+ else {
+ #
+ # Add the tag to 'tags'
+ #
+ $count1++;
+ $rv = $sth2->execute( $tags->[$i] );
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+ $rv = 0 if ( $rv eq '0E0' );
+
+ #
+ # Get the id number of the inserted tag
+ #
+ $tid = $dbh->last_insert_id( undef, undef, undef, undef,
+ { sequence => 'tag_seq' } );
+ }
+
+ $count2++;
+ $rv = $sth3->execute( $id, $tid );
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+ $rv = 0 if ( $rv eq '0E0' );
+ }
+ }
+
+ if ($verbose > 0) {
+ print "Added $count1 tags\n";
+ print "Added $count2 cross references\n";
+ }
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: collect_eps_tags
+# PURPOSE: Collects the tags from the eps.tags field
+# PARAMETERS: $dbh Database handle
+# $verbose Verbosity level
+# RETURNS: A reference to the hash created by collecting all the tags
+# DESCRIPTION: Queries the MariaDB 'eps' table for all of the rows containing
+# tags, returning the comma-separated list with the id number.
+# Each CSV list is then parsed and the result turned into a hash
+# keyed on the id number and containing a sorted array of tags.
+# If the level of verbosity is greater than 2 the tags has is
+# dumped (ironically, as a CSV list!).
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub collect_eps_tags {
+ my ( $dbh, $verbose ) = @_;
+
+ my ( $status, @fields, %hash );
+ my ( $sth, $h );
+
+ #
+ # For parsing the field as CSV
+ #
+ my $csv = Text::CSV_XS->new;
+
+ #
+ # Query the MariaDB 'eps' table for all the id and tags
+ #
+ $sth = $dbh->prepare(
+ q{SELECT id,tags FROM eps
+ WHERE length(tags) > 0
+ ORDER BY id}
+ ) or die $DBI::errstr;
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+
+ $sth->execute;
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+
+ #
+ # Loop through what we got
+ #
+ while ( $h = $sth->fetchrow_hashref ) {
+ #
+ # Parse the tag list
+ #
+ $status = $csv->parse( $h->{tags} );
+ unless ($status) {
+ #
+ # Report any errors
+ #
+ print "Parse error on episode ", $h->{id}, "\n";
+ print $csv->error_input(), "\n";
+ next;
+ }
+ @fields = $csv->fields();
+
+ next unless (@fields);
+
+ #
+ # Trim all tags (don't alter $_ when doing it)
+ #
+ @fields = map {
+ my $t = $_;
+ $t =~ s/(^\s+|\s+$)//g;
+ $t;
+ } @fields;
+
+ #print "$h->{id}: ",join(",",@fields),"\n";
+
+ #
+ # Save the id and its tags, sorted for comparison
+ #
+ $hash{ $h->{id} } = [ sort @fields ];
+
+ }
+
+ #
+ # Dump all id numbers and tags if the verbose level is high enough
+ #
+ if ( $verbose >= 2 ) {
+ print "\nTags collected from the 'eps' table\n\n";
+ foreach my $id ( sort { $a <=> $b } keys(%hash) ) {
+ printf "%04d: %s\n", $id, join( ",", @{ $hash{$id} } );
+ }
+ }
+
+ return \%hash;
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: build_comments_table
+# PURPOSE: Copy the data from the Mariadb 'comments' table to the Pg
+# 'comments' table
+# PARAMETERS: $dbh1 Handle for the MariaDB database
+# $dbh2 Handle for the Pg database
+# $verbose Verbosity level
+# RETURNS: Nothing
+# DESCRIPTION:
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub build_comments_table {
+ my ( $dbh1, $dbh2, $verbose ) = @_;
+
+ my ( $sth1, $h1, $sth2, $count );
+
+ $sth1 = $dbh1->prepare('SELECT * FROM comments') or die $DBI::errstr;
+ if ( $dbh1->err ) {
+ warn $dbh1->errstr;
+ }
+
+ $sth2 = $dbh2->prepare('INSERT INTO comments VALUES (?,?,?,?,?,?,?)')
+ or die $DBI::errstr;
+ if ( $dbh2->err ) {
+ warn $dbh2->errstr;
+ }
+
+ #
+ # Query MariaDB for the entire 'comments' table
+ #
+ $sth1->execute;
+ if ( $dbh1->err ) {
+ die $dbh1->errstr;
+ }
+
+ #
+ # Loop though 'comments' table rows writing them to the PostgreSQL
+ # 'comments' table
+ #
+ $count = 0;
+ while ( $h1 = $sth1->fetchrow_hashref ) {
+ $count++;
+ $sth2->execute(
+ $h1->{id},
+ $h1->{eps_id},
+ $h1->{comment_timestamp},
+ nullif( $h1->{comment_author_name}, '^\s*$' ),
+ nullif( $h1->{comment_title}, '^\s*$' ),
+ $h1->{comment_text},
+ $h1->{last_changed},
+ );
+ if ( $dbh2->err ) {
+ die $dbh2->errstr;
+ }
+ }
+
+ print "Copied $count records\n" if ($verbose > 0);
+
+ #
+ # Set the sequence to the correct value
+ #
+ alter_seq($dbh2,'comments','comment_seq');
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: load_twat_hosts
+# PURPOSE: Copy the temporary twat_hosts table from the MySQL database
+# into the PostgreSQL equivalent ready for merging with the HPR
+# data
+# PARAMETERS: $dbh1 Handle for the MariaDB database
+# $dbh2 Handle for the Pg database
+# $verbose Verbosity level
+# RETURNS: Nothing
+# DESCRIPTION:
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub load_twat_hosts {
+ my ( $dbh1, $dbh2, $verbose ) = @_;
+
+ my ( $sth1, $h1, $sth2, $count );
+
+ #
+ # Copy the 'twat_hosts' table
+ #
+ $sth1 = $dbh1->prepare('SELECT * FROM twat_hosts') or die $DBI::errstr;
+ if ( $dbh1->err ) {
+ warn $dbh1->errstr;
+ }
+
+ #
+ # Omit the 'id' here because it's an addition for PostgreSQL
+ #
+ $sth2
+ = $dbh2->prepare(
+ 'INSERT INTO twat_hosts (host,email,website,repeat) VALUES (?,?,?,?)')
+ or die $DBI::errstr;
+ if ( $dbh2->err ) {
+ warn $dbh2->errstr;
+ }
+
+ #
+ # Query MariaDB for the entire 'comments' table
+ #
+ $sth1->execute;
+ if ( $dbh1->err ) {
+ die $dbh1->errstr;
+ }
+
+ #
+ # Loop though 'twat_hosts' table rows writing them to the PostgreSQL
+ # 'twat_hosts' table
+ #
+ $count = 0;
+ while ( $h1 = $sth1->fetchrow_hashref ) {
+ $count++;
+ $sth2->execute(
+ $h1->{host},
+ nullif( $h1->{email}, '^\s*$' ),
+ nullif( $h1->{website}, '^\s*$' ),
+ $h1->{repeat},
+ );
+ if ( $dbh2->err ) {
+ die $dbh2->errstr;
+ }
+ }
+
+ print "Copied $count records\n" if ( $verbose > 0 );
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: load_twat_episodes
+# PURPOSE: Copy the temporary twat_episodes table from the MySQL database
+# into the PostgreSQL equivalent ready for merging with the HPR
+# data
+# PARAMETERS: $dbh1 Handle for the MariaDB database
+# $dbh2 Handle for the Pg database
+# $verbose Verbosity level
+# RETURNS: Nothing
+# DESCRIPTION:
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub load_twat_episodes {
+ my ( $dbh1, $dbh2, $verbose ) = @_;
+
+ my ( $sth1, $h1, $sth2, $count );
+
+ #
+ # Copy the 'twat_episodes' table
+ #
+ $sth1 = $dbh1->prepare('SELECT * FROM twat_episodes') or die $DBI::errstr;
+ if ( $dbh1->err ) {
+ warn $dbh1->errstr;
+ }
+
+ #
+ # Omit the 'id' here because it's an addition for PostgreSQL
+ #
+ $sth2
+ = $dbh2->prepare(
+ 'INSERT INTO twat_episodes VALUES (?,?,?,?,?,?)')
+ or die $DBI::errstr;
+ if ( $dbh2->err ) {
+ warn $dbh2->errstr;
+ }
+
+ #
+ # Query MariaDB for the entire 'comments' table
+ #
+ $sth1->execute;
+ if ( $dbh1->err ) {
+ die $dbh1->errstr;
+ }
+
+ #
+ # Loop though 'twat_hosts' table rows writing them to the PostgreSQL
+ # 'twat_hosts' table
+ #
+ $count = 0;
+ while ( $h1 = $sth1->fetchrow_hashref ) {
+ $count++;
+ $sth2->execute(
+ $h1->{ep_num},
+ $h1->{date},
+ $h1->{host},
+ $h1->{topic},
+ nullif( $h1->{writeup}, '^\s*$' ),
+ $h1->{url},
+ );
+ if ( $dbh2->err ) {
+ die $dbh2->errstr;
+ }
+ }
+
+ print "Copied $count records\n" if ( $verbose > 0 );
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: compute_host_date_added
+# PURPOSE: Determine the 'hosts.date_added' field once the database is
+# fully populated.
+# PARAMETERS: $dbh Handle for the PostgreSQL database
+# RETURNS: Nothing
+# DESCRIPTION:
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub compute_host_date_added {
+ my ($dbh) = @_;
+
+ my $rv;
+
+ #
+ # Allocate date_added values where possible
+ #
+ $rv = $dbh->do(
+ q{
+ UPDATE hosts
+ SET date_added = sq.date_added
+ FROM (
+ SELECT h.id,min(e.release_date) AS date_added
+ FROM episodes e
+ JOIN episodes_hosts_xref eh ON (e.id = eh.episodes_id)
+ JOIN hosts h ON (h.id = eh.hosts_id)
+ GROUP BY h.id
+ ORDER by min(e.release_date)) AS sq
+ WHERE hosts.id = sq.id
+ }
+ );
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+ $rv = 0 if ( $rv eq '0E0' );
+
+ print "Added $rv dates to the 'date_added' column\n";
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: alter_seq
+# PURPOSE: Ensure the PostgreSQL sequence associated with a table has the
+# correct value.
+# PARAMETERS: $dbh Handle for the PostgreSQL database
+# $table Table name for the query
+# $sequence Sequence name
+# RETURNS: Nothing
+# DESCRIPTION:
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub alter_seq {
+ my ( $dbh, $table, $sequence ) = @_;
+
+ my ( $sth, $h, $rv, $maxid );
+
+ #
+ # Find the maximum id number in the table
+ #
+ $sth = $dbh->prepare("SELECT max(id) as maxid FROM $table")
+ or die $DBI::errstr;
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+
+ $sth->execute;
+ if ( $dbh->err ) {
+ die $dbh->errstr;
+ }
+
+ #
+ # Save the maximum
+ #
+ if ( $h = $sth->fetchrow_hashref ) {
+ $maxid = $h->{maxid};
+ $sth->finish;
+ }
+
+ #
+ # Reset the sequence one more than the maximum
+ #
+ $maxid++;
+ $rv = $dbh->do("ALTER SEQUENCE $sequence RESTART WITH $maxid");
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+ $rv = 0 if ( $rv eq '0E0' );
+
+ warn "Failed to reset $sequence\n" unless (defined($rv));
+
+ return;
+}
+
+#=== FUNCTION ================================================================
+# NAME: check_table
+# PURPOSE: Check that a given PostgreSQL table is empty
+# PARAMETERS: $dbh Handle for the PostgreSQL database
+# $table Name of table
+# RETURNS: True if empty, otherwise false
+# DESCRIPTION: Simply perform a query on the nominated table which counts
+# rows. If the table does not exist a DBI method will fail (the
+# execute?), so we treat this as a 'no empty' to make the caller
+# take error action.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub check_table {
+ my ( $dbh, $table ) = @_;
+
+ my ( $sth, $h, $count );
+
+ $sth = $dbh->prepare("SELECT count(*) AS count FROM $table")
+ or die $DBI::errstr;
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ return 0;
+ }
+
+ $sth->execute;
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ return 0;
+ }
+
+ if ( $h = $sth->fetchrow_hashref ) {
+ $count = $h->{count};
+ $sth->finish;
+ }
+
+ return $count == 0;
+}
+
+#=== FUNCTION ================================================================
+# NAME: default_email
+# PURPOSE: Make a default email address for hosts with none
+# PARAMETERS: $email Original email address
+# $regex Regular expression to check the email against
+# $template Template for building the default
+# $hostid Host id number to use in the default
+# RETURNS: The email address to be used
+# DESCRIPTION: If the email address matches a regular expression then
+# generate a default from the template and the host id,
+# otherwise just return the address untouched.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub default_email {
+ my ( $email, $regex, $template, $hostid ) = @_;
+
+ return (
+ $email =~ $regex
+ ? sprintf( $template, $hostid )
+ : $email
+ );
+}
+
+#=== FUNCTION ================================================================
+# NAME: nullif
+# PURPOSE: Tests a value and makes it 'undef' (equivalent to NULL in the
+# database) if it matches a regular expression.
+# PARAMETERS: $value Value to test
+# $regex Regular expression to match against
+# RETURNS: 'undef' if the values match, otherwise the original value
+# DESCRIPTION: This is very simple, just a wrapper around the test.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub nullif {
+ my ( $value, $regex ) = @_;
+
+ return $value unless defined($value);
+ return ( $value =~ $regex ? undef : $value );
+}
+
+#=== FUNCTION ================================================================
+# NAME: Options
+# PURPOSE: Processes command-line options
+# PARAMETERS: $optref Hash reference to hold the options
+# RETURNS: Undef
+# DESCRIPTION:
+# THROWS: no exceptions
+# COMMENTS: none
+# SEE ALSO: n/a
+#===============================================================================
+sub Options {
+ my ($optref) = @_;
+
+ my @options = ( "help", "debug=i", "dry-run!", "verbose+", "phases=s" );
+
+ if ( !GetOptions( $optref, @options ) ) {
+ pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 );
+ }
+
+ return;
+}
+
+__END__
+
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+# Application Documentation
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+#{{{
+
+=head1 NAME
+
+copy_mysql_pg - copy the HPR database from MySQL to PostgreSQL
+
+=head1 VERSION
+
+This documentation refers to B version 0.0.4
+
+
+=head1 USAGE
+
+ copy_mysql_pg -verbose
+ copy_mysql_pg -verbose -verbose
+ copy_mysql_pg -verbose \
+ -phase='episodes,hosts,eh_xref,series,es_xref,tags,comments,epilogue'
+
+=head1 OPTIONS
+
+=over 8
+
+=item B<-help>
+
+Prints a brief help message describing the usage of the program, and then exits.
+
+=item B<-debug=N>
+
+Selects a level of debugging. Debug information consists of a line or series
+of lines prefixed with the characters 'D>':
+
+=over 4
+
+=item B<0>
+
+No debug output is generated: this is the default
+
+=back
+
+=item B<-[no]dry-run>
+
+When enabled (B<-dry-run>) the script will report what it would do, but will
+make no changes to the target database. In the default state (B<-nodry-run>)
+then changes are made.
+
+=item B<-verbose>
+
+Makes the script verbose resulting in the production of more information about
+what it is doing.
+
+The option may be repeated to increase the level of verbosity. The levels are:
+
+=over 4
+
+=item B<0>
+
+No output is generated (apart from errors and warnings if appropriate). This
+is the default level.
+
+=item B<1>
+
+A message is generated per phase to indicate which actions are taking place.
+This includes a report of the number of rows copied from the MySQL database to
+the PostgreSQL one.
+
+=item B<2>
+
+Following the process of collecting the CSV tags from the MySQL 'episodes' table
+these are reported as a list per episode. This output will be long!
+
+=back
+
+=item B<-phase=CSV_LIST>
+
+This option allows the phases of the copying process to be selected
+individually. The argument B is a list of phase names, which have to
+be typed exactly. The order is not important since the script will scan its
+version of the list of phases in order and will check to see if each has been
+selected.
+
+The phase names are:
+
+=over 4
+
+=item B
+
+Causes the B table to be filled.
+
+=item B
+
+Causes the B table to be filled.
+
+=item B
+
+Causes the B table to be filled.
+
+=item B
+
+Causes the B table to be filled.
+
+=item B
+
+Causes the B table to be filled.
+
+=item B
+
+Causes the B and the B tables to be filled.
+
+=item B
+
+Causes the B table to be filled.
+
+=item B
+
+Runs various tasks that can only be carried out after the database has been
+populated.
+
+=back
+
+=back
+
+=head1 DESCRIPTION
+
+A full description of the application and its features.
+May include numerous subsections (i.e. =head2, =head3, etc.)
+
+
+=head1 DIAGNOSTICS
+
+A list of every error and warning message that the application can generate
+(even the ones that will "never happen"), with a full explanation of each
+problem, one or more likely causes, and any suggested remedies. If the
+application generates exit status codes (e.g. under Unix) then list the exit
+status associated with each error.
+
+
+=head1 CONFIGURATION AND ENVIRONMENT
+
+A full explanation of any configuration system(s) used by the application,
+including the names and locations of any configuration files, and the
+meaning of any environment variables or properties that can be set. These
+descriptions must also include details of any configuration language used
+
+
+=head1 DEPENDENCIES
+
+A list of all the other modules that this module relies upon, including any
+restrictions on versions, and an indication whether these required modules are
+part of the standard Perl distribution, part of the module's distribution,
+or must be installed separately.
+
+
+=head1 INCOMPATIBILITIES
+
+A list of any modules that this module cannot be used in conjunction with.
+This may be due to name conflicts in the interface, or competition for
+system or program resources, or due to internal limitations of Perl
+(for example, many modules that use source code filters are mutually
+incompatible).
+
+
+=head1 BUGS AND LIMITATIONS
+
+A list of known problems with the module, together with some indication
+whether they are likely to be fixed in an upcoming release.
+
+Also a list of restrictions on the features the module does provide:
+data types that cannot be handled, performance issues and the circumstances
+in which they may arise, practical limitations on the size of data sets,
+special cases that are not (yet) handled, etc.
+
+The initial template usually just has:
+
+There are no known bugs in this module.
+Please report problems to ()
+Patches are welcome.
+
+=head1 AUTHOR
+
+ ()
+
+
+=head1 LICENCE AND COPYRIGHT
+
+Copyright (c) (). All rights reserved.
+
+Followed by whatever licence you wish to release it under.
+For Perl code that is often just:
+
+This module is free software; you can redistribute it and/or
+modify it under the same terms as Perl itself. See perldoc perlartistic.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+=cut
+
+#}}}
+
+# [zo to open fold, zc to close]
+
+# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
+
diff --git a/Database/create_series b/Database/create_series
new file mode 100755
index 0000000..50bbea0
--- /dev/null
+++ b/Database/create_series
@@ -0,0 +1,469 @@
+#!/usr/bin/env perl
+#===============================================================================
+#
+# FILE: create_series
+#
+# USAGE: ./create_series -name=NAME -description=DESC [-[no]private]
+# [-image=IMAGE] [-[no]valid] [-[no]updatedb] [-config=FILE] [-help]
+#
+# DESCRIPTION: Create a new series in the HPR database
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: ---
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.0.4
+# CREATED: 2015-01-15 16:09:09
+# REVISION: 2022-04-12 21:37:02
+#
+#===============================================================================
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+
+use Getopt::Long;
+use Pod::Usage;
+
+use Config::General;
+
+use Try::Tiny;
+use IO::Prompter;
+
+use DBI;
+
+use Data::Dumper;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.0.4';
+
+#
+# Script and directory names
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+( my $DIR = $0 ) =~ s|/?[^/]*$||mx;
+$DIR = '.' unless $DIR;
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+#
+# Constants and other declarations
+#
+my $basedir = "$ENV{HOME}/HPR/Database";
+my $configfile = "$basedir/.hpr_db.cfg";
+
+my ( $dbh, $sth1, $h1, $rv, $rc );
+my ( $answer, $id );
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#-------------------------------------------------------------------------------
+# Options and arguments
+#-------------------------------------------------------------------------------
+#
+# Process options
+#
+my %options;
+Options( \%options );
+
+#
+# Default help
+#
+pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 )
+ if ( $options{'help'} );
+
+#
+# Collect options
+#
+my $cfgfile
+ = ( defined( $options{config} ) ? $options{config} : $configfile );
+
+my $updatedb = ( defined( $options{'updatedb'} ) ? $options{'updatedb'} : 0 );
+
+my $name = $options{'name'};
+my $description = $options{'description'};
+my $private = ( defined( $options{'private'} ) ? $options{'private'} : 0 );
+my $image = ( defined( $options{'image'} ) ? $options{'image'} : '' );
+my $valid = ( defined( $options{'valid'} ) ? $options{'valid'} : 1 );
+
+die "Options -name and -description are mandatory\n"
+ unless ( $name && $description );
+
+#
+# Sanity check
+#
+die "Unable to find $cfgfile\n" unless ( -e $cfgfile );
+
+#
+# Check we have the right values
+#
+printf "Planning to add the following series:\n" .
+ "Name: %s\n" .
+ "Description: %s\n" .
+ "Private: %s\n" .
+ "Image: '%s'\n" .
+ "Valid: %s\n",
+ $name,
+ $description,
+ ( $private ? 'Yes' : 'No' ),
+ $image,
+ ( $valid ? 'Yes' : 'No');
+
+print "Note that -updatedb has not been set, so no changes will be made.\n"
+ unless ($updatedb);
+
+#
+# Ask for confirmation, failing gracefully if there's a problem
+#
+try {
+ $answer = prompt(
+ -in => *STDIN,
+ -out => *STDERR,
+ -prompt => 'Is this correct? ',
+ -style => 'red',
+ -yn
+ );
+}
+catch {
+ warn "Problem collecting answer $_";
+ $answer = 0;
+};
+
+unless ($answer) {
+ print "Exiting...\n";
+ exit;
+}
+
+#-------------------------------------------------------------------------------
+# Configuration file - load data
+#-------------------------------------------------------------------------------
+my $conf = Config::General->new(
+ -ConfigFile => $cfgfile,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %config = $conf->getall();
+
+#-------------------------------------------------------------------------------
+# Connect to the database
+#-------------------------------------------------------------------------------
+my $dbhost = $config{database}->{host} // '127.0.0.1';
+my $dbport = $config{database}->{port} // 3306;
+my $dbname = $config{database}->{name};
+my $dbuser = $config{database}->{user};
+my $dbpwd = $config{database}->{password};
+
+# 2022-04-12 The MariaDB driver was there and then it wasn't!
+#
+#$dbh = DBI->connect( "DBI:MariaDB:database=$dbname;host=$dbhost;port=$dbport",
+# $dbuser, $dbpwd, { AutoCommit => 1 } )
+# or die $DBI::errstr;
+
+$dbh = DBI->connect( "dbi:mysql:database=$dbname;host=$dbhost;port=$dbport",
+ $dbuser, $dbpwd, { AutoCommit => 1 } )
+ or die $DBI::errstr;
+
+#
+# Enable client-side UTF8
+#
+$dbh->{mysql_enable_utf8} = 1;
+
+#
+# Does a series with this name already exist?
+#
+$sth1 = $dbh->prepare(q{
+ SELECT id AS count FROM miniseries WHERE name = ?
+});
+$sth1->execute($name);
+if ( $dbh->err ) {
+ warn $dbh->errstr;
+}
+if ( $h1 = $sth1->fetchrow_hashref ) {
+ print "A series with the name '$name' already exists\n";
+ exit;
+}
+
+#
+# Should be OK to create the series if we get here, so long as we've been
+# asked to do so.
+#
+if ($updatedb) {
+ #
+ # Go into transaction mode here so we can fail safely
+ #
+ $rc = $dbh->begin_work or die $dbh->errstr;
+
+ #
+ # Perform the INSERT
+ #
+ $rv = $dbh->do(q{
+ INSERT INTO miniseries (name,description,private,image,valid)
+ VALUES(?,?,?,?,?)
+ },
+ undef,
+ $name,
+ $description,
+ $private,
+ $image,
+ $valid
+ );
+
+ #
+ # Respond to any error by rolling back
+ #
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ eval{ $dbh->rollback };
+ $rv = 0;
+ }
+ else {
+ $dbh->commit;
+ }
+ $rv = 0 if ( $rv eq '0E0' );
+
+ #
+ # Report any success
+ #
+ if ($rv) {
+ #
+ # Find out what id we just generated and report it if found
+ #
+ $id = $dbh->last_insert_id();
+ if ($id) {
+ print "Series added with id $id\n";
+ } else {
+ print "Series added\n";
+ }
+ }
+ else {
+ print "Series not added due to error\n";
+ }
+
+} else {
+ print "Option -noupdatedb chosen, database not updated\n";
+}
+
+#
+# We've finished with the database
+#
+$dbh->disconnect;
+
+exit;
+
+#=== FUNCTION ================================================================
+# NAME: Options
+# PURPOSE: Processes command-line options
+# PARAMETERS: $optref Hash reference to hold the options
+# RETURNS: Undef
+# DESCRIPTION:
+# THROWS: no exceptions
+# COMMENTS: none
+# SEE ALSO: n/a
+#===============================================================================
+sub Options {
+ my ($optref) = @_;
+
+ my @options = (
+ "help", "config=s", "updatedb!", "name=s",
+ "description=s", "private!", "image:s", "valid!"
+ );
+
+ if ( !GetOptions( $optref, @options ) ) {
+ pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 );
+ }
+
+ return;
+}
+
+__END__
+
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+# Application Documentation
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+#{{{
+
+=head1 NAME
+
+create_series - create a new series in the 'miniseries' table in the HPR DB
+
+=head1 VERSION
+
+This documentation refers to create_series version 0.0.4
+
+
+=head1 USAGE
+
+ create_series -name=NAME -description=DESC [-[no]private]
+ [-image=IMAGE] [-[no]valid] [-[no]updatedb] [-config=FILE] [-help]
+
+ desc="An overview of this open-source graphics program, "
+ desc+="with a focus on photographic issues."
+ create_series -update -name='GIMP' -description="$desc"
+
+=head1 OPTIONS
+
+=over 4
+
+=item B<-[no]updatedb>
+
+This option is required to make the script apply any changes that are made to
+the database. By default no updates are applied (B<-noupdatedb>).
+
+=item B<-name=NAME>
+
+This mandatory option defines the title for the new series. The limit on the
+length of the name is 100 characters and the script will reject anything
+longer than this.
+
+=item B<-description=DESC>
+
+This mandatory option defines the description for the new series. There is no limit on the
+length of this field, but it may be difficult to enter very large amounts of
+text here. One solution might be to prepare the text in a file and use
+a command substitution to enter it.
+
+ create_series -update -name='GIMP' -description="$(cat GIMP.txt)"
+
+=item B<-[no]private>
+
+Series can be private or public. Selecting B<-private> creates a new private
+series, whereas B<-noprivate> creates a public series. The default is to
+create a public one.
+
+=item B<-image=IMAGE>
+
+The image field in the database is not currently used. Three series have
+a short text string in this field, but no data in the field seems to be used
+anywhere. It would be possible to add data to this field in the database when
+creating a series, and this option is available to do so, but by default
+an empty string is inserted. Note that the database design does not allow this
+field to be NULL for unknown reasons.
+
+=item B<-[no]valid>
+
+Series can be valid or invalid. Selecting B<-valid> creates a new valid
+series, whereas B<-novalid> creates an invalid series. The default is to
+create a valid one.
+
+Series marked invalid are not displayed, but there are none in this state at
+the moment.
+
+=item B<-config=FILE>
+
+This option allows an alternative configuration file to be used. This file
+defines the location of the database, its port, its name and the username and
+password to be used to access it. This feature was added to allow the script
+to access alternative databases or the live database over an SSH tunnel.
+
+See the CONFIGURATION AND ENVIRONMENT section below for the file format.
+
+If the option is omitted the default file is used: B<.hpr_db.cfg>
+
+=back
+
+=head1 DESCRIPTION
+
+The script collects the necessary attributes for a new series in the HPR
+database, displays them for validation and if requested, adds them to the
+database.
+
+Every series must have a name and a description. The settings for I,
+I and I have defaults as described above. The values and
+defaults are shown as follows and the user is prompted to decide whether to
+proceed with series creation or not:
+
+ Planning to add the following series:
+ Name: GIMP
+ Description: An overview of this open-source graphics program, with a focus on photographic issues.
+ Private: No
+ Image: ''
+ Valid: Yes
+ Is this correct?
+
+Answering 'Y' to this prompt will result in creation (assuming this is
+possible).
+
+Upon creation the script reports the B value assigned to the series. This
+is useful to know when adding episodes to the series.
+
+=head1 DIAGNOSTICS
+
+=over 4
+
+=item B
+
+Both of these options must be present when creating a new series. This is
+a fatal error.
+
+=item B
+
+The database connection has been denied. Check the configuration details (see
+below). This a fatal error.
+
+=back
+
+=head1 CONFIGURATION AND ENVIRONMENT
+
+The script obtains the credentials it requires to open the HPR database from
+a configuration file. The name of the file it expects is B<.hpr_db.cfg> in the
+directory holding the script. This configuration file can be overridden using
+the B<-config=FILE> option as described above.
+
+The configuration file format is as follows:
+
+
+ host = 127.0.0.1
+ port = PORT
+ name = DATABASE
+ user = USERNAME
+ password = PASSWORD
+
+
+=head1 DEPENDENCIES
+
+ Config::General
+ DBI
+ Data::Dumper
+ Getopt::Long
+ IO::Prompter
+ Pod::Usage
+ Try::Tiny
+
+=head1 BUGS AND LIMITATIONS
+
+There are no known bugs in this module.
+Please report problems to Dave Morriss (Dave.Morriss@gmail.com)
+Patches are welcome.
+
+=head1 AUTHOR
+
+Dave Morriss (Dave.Morriss@gmail.com)
+
+=head1 LICENCE AND COPYRIGHT
+
+Copyright (c) 2015-2020 Dave Morriss (Dave.Morriss@gmail.com). All rights reserved.
+
+This module is free software; you can redistribute it and/or
+modify it under the same terms as Perl itself. See perldoc perlartistic.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+=cut
+
+#}}}
+
+# [zo to open fold, zc to close]
+
+# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
+
diff --git a/Database/double_host.sql b/Database/double_host.sql
new file mode 100644
index 0000000..2077a0a
--- /dev/null
+++ b/Database/double_host.sql
@@ -0,0 +1,18 @@
+/*---------------------------------------------------------------------------
+ * Written a while ago. I think it finds hosts called "'host1' and 'host2'"
+ * and pulls the individual hosts out of the pair so that they can be
+ * installed into the table as separate hosts
+ ---------------------------------------------------------------------------- */
+select ho.hostid, ho.host, n1.hostid, n1.host, n1.host1, n2.hostid, n2.host, n2.host2
+from hosts ho
+left join (select hostid, host, left(host,instr(host,' and ')-1) as host1
+ from hosts
+ where host like '% and %') as n1
+on n1.host1 = ho.host
+left join (select hostid, host, substring(host,instr(host,' and ')+5) as host2
+ from hosts
+ where host like '% and %') as n2
+on n2.host2 = ho.host
+where n1.host1 is not null
+or n2.host2 is not null
+;
diff --git a/Database/edit_episode b/Database/edit_episode
new file mode 100755
index 0000000..9940369
--- /dev/null
+++ b/Database/edit_episode
@@ -0,0 +1,832 @@
+#!/usr/bin/env perl
+#===============================================================================
+#
+# FILE: edit_episode
+#
+# USAGE: ./edit_episode [-h] [-debug=N] [-config=FILE] [-[no]update]
+# [-[no]title] [-[no]summary] [-[no]tags] [-[no]notes]
+# [-[no]ctitle] [-[no]ctext] [-cnumber=N] shownumber
+#
+# DESCRIPTION: A simple command-line editor for the HPR database
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: Had to revert to MySQL due to a problem with DBD::MariaDB
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.1.3
+# CREATED: 2015-06-17 23:17:50
+# REVISION: 2022-02-16 20:07:45
+#
+#===============================================================================
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+
+use Getopt::Long;
+use Pod::Usage;
+
+use Config::General;
+use File::Temp;
+use File::Slurper qw{ read_text };
+use SQL::Abstract;
+use DBI;
+
+use Data::Dumper;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.1.3';
+
+#
+# Script and directory names
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+( my $DIR = $0 ) =~ s|/?[^/]*$||mx;
+$DIR = '.' unless $DIR;
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+#
+# Constants and other declarations
+#
+my $basedir = "$ENV{HOME}/HPR/Database";
+my $configfile = "$basedir/.hpr_db.cfg";
+
+#
+# Declarations
+#
+my ( $dbh, $sth1, $h1, $rc );
+my (%changes);
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#
+# Options and arguments
+#
+my %options;
+Options( \%options );
+
+#
+# Default help
+#
+pod2usage( -msg => "Version $VERSION\n", -exitval => 1 )
+ if ( $options{'help'} );
+
+#
+# Collect options
+#
+my $DEBUG = ( $options{'debug'} ? $options{'debug'} : 0 );
+
+my $cfgfile
+ = ( defined( $options{config} ) ? $options{config} : $configfile );
+
+my $updatedb = ( defined( $options{'updatedb'} ) ? $options{'updatedb'} : 0 );
+
+my $title = ( defined( $options{'title'} ) ? $options{'title'} : 0 );
+my $summary = ( defined( $options{'summary'} ) ? $options{'summary'} : 0 );
+my $tags = ( defined( $options{'tags'} ) ? $options{'tags'} : 0 );
+my $notes = ( defined( $options{'notes'} ) ? $options{'notes'} : 0 );
+my $ctitle = ( defined( $options{'ctitle'} ) ? $options{'ctitle'} : 0 );
+my $ctext = ( defined( $options{'ctext'} ) ? $options{'ctext'} : 0 );
+my $cnumber = $options{'cnumber'};
+
+die "Select one of -title, -summary, -tags, -notes, -ctitle and -ctext\n"
+ unless ( $title || $summary || $tags || $notes || $ctitle || $ctext );
+
+die "Needs a comment number (-cnumber=N)\n"
+ if ( ( $ctitle || $ctext ) && ( !$cnumber ) );
+
+#
+# Get the arg
+#
+my $show = shift;
+pod2usage( -msg => "Specify the show number\n", -exitval => 1 ) unless $show;
+
+#
+# Sanity check
+#
+die "Unable to find $cfgfile\n" unless ( -e $cfgfile );
+
+#
+# Load configuration data
+#
+my $conf = new Config::General(
+ -ConfigFile => $cfgfile,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %config = $conf->getall();
+
+#
+# Connect to the database
+#
+my $dbhost = $config{database}->{host} // '127.0.0.1';
+my $dbport = $config{database}->{port} // 3306;
+my $dbname = $config{database}->{name};
+my $dbuser = $config{database}->{user};
+my $dbpwd = $config{database}->{password};
+
+#$dbh = DBI->connect( "DBI:MariaDB:database=$dbname;host=$dbhost;port=$dbport",
+# $dbuser, $dbpwd, { AutoCommit => 1 } )
+# or die $DBI::errstr;
+
+$dbh = DBI->connect( "dbi:mysql:database=$dbname;host=$dbhost;port=$dbport",
+ $dbuser, $dbpwd, { AutoCommit => 1 } )
+ or die $DBI::errstr;
+
+#
+# Enable client-side UTF8
+#
+$dbh->{mysql_enable_utf8} = 1;
+
+#
+# Prepare to read the database for the selected episode and count the number
+# of comments it has in case we need to know later.
+#
+$sth1 = $dbh->prepare(q{
+ SELECT
+ e.*, count(c.id) as comment_count
+ FROM eps e
+ LEFT JOIN comments c ON e.id = c.eps_id
+ GROUP BY e.id
+ HAVING e.id = ?
+});
+$sth1->execute($show);
+if ( $dbh->err ) {
+ warn $dbh->errstr;
+}
+
+#
+# Did we find the episode?
+#
+if ( $h1 = $sth1->fetchrow_hashref ) {
+ #
+ # Found, so do the episode details need changing?
+ #
+ if ( $title || $summary || $tags || $notes ) {
+ change_episode( $dbh, $h1, $show, $updatedb, $title, $summary, $tags,
+ $notes );
+ }
+
+ #
+ # Are we to change comment details?
+ #
+ if ( $ctitle || $ctext ) {
+ if ( $h1->{comment_count} > 0 ) {
+ change_comment( $dbh, $h1, $show, $cnumber, $updatedb, $ctitle,
+ $ctext );
+ }
+ else {
+ print "This show has no comments\n";
+ }
+ }
+}
+else {
+ print "Unable to find show number $show\n";
+}
+
+#$dbh->disconnect;
+
+exit;
+
+#=== FUNCTION ================================================================
+# NAME: change_episode
+# PURPOSE: Make changes to a row in the 'eps' table for a show
+# PARAMETERS: $dbh open handle of the MySQL database
+# $h handle of the query that returned the episode
+# record and comment count
+# $show show number being updated
+# $updatedb Boolean; true when changes are to be made
+# $title Boolean; true when the episode title is to be
+# changed
+# $summary Boolean; true when the episode summary is to be
+# changed
+# $tags Boolean; true when the episode tags are to be
+# changed
+# $notes Boolean; true when the episode notes are to be
+# changed
+# RETURNS: Nothing
+# DESCRIPTION: The episode has been found in the database. The requested
+# changes are applied. If after comparing old with new changes
+# are found they are applied, otherwise nothing is done.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub change_episode {
+ my ( $dbh, $h, $show, $updatedb, $title, $summary, $tags, $notes ) = @_;
+
+ my (%changes);
+
+ #<<< [perltidy messes up the following]
+ if ($title) {
+ $changes{title} = check_field( 'title',
+ scalar( run_editor( $h->{title} ) ), 100, qr{(\n)} );
+ }
+ if ($summary) {
+ $changes{summary} = check_field( 'summary',
+ scalar( run_editor( $h->{summary} ) ), 100, qr{(\n)} );
+ }
+ if ($tags) {
+ $changes{tags} = check_field( 'tags',
+ scalar( run_editor( $h->{tags} ) ), 200, qr{(\n)} );
+ }
+ if ($notes) {
+ $changes{notes} = run_editor( $h->{notes}, ['+set filetype=html'] );
+ }
+ #>>>
+ print Dumper( \%changes ), "\n" if ( $DEBUG > 2 );
+
+ #
+ # Are we updating the database then?
+ #
+ if ($updatedb) {
+ #
+ # Was anything edited?
+ #
+ if (%changes) {
+ #
+ # Did the edits do anything? If not delete the element from the
+ # hash since there's no point writing it to the database
+ #
+ for my $key ( keys(%changes) ) {
+ if ( $changes{$key} eq $h->{$key} ) {
+ print "No change made to $key, ignored\n";
+ delete( $changes{$key} );
+ }
+ }
+ print Dumper( \%changes ), "\n" if ( $DEBUG > 2 );
+ }
+
+ #
+ # If there's anything left apply the changes
+ #
+ if (%changes) {
+ #
+ # Go into transaction mode here so we can fail safely
+ #
+ $rc = $dbh->begin_work or die $dbh->errstr;
+
+ my $sql = SQL::Abstract->new;
+ my %where = ( id => $show );
+ my ( $stmt, @bind ) = $sql->update( 'eps', \%changes, \%where );
+
+ my $sth = $dbh->prepare($stmt);
+ my $rv = $sth->execute(@bind);
+
+ #
+ # Respond to any error by rolling back
+ #
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ eval { $dbh->rollback };
+ $rv = 0;
+ }
+ else {
+ $dbh->commit;
+ }
+ $rv = 0 if ( $rv eq '0E0' );
+
+ #
+ # Report the update
+ #
+ if ($rv) {
+ my $ccount = scalar( keys(%changes) );
+ printf "Updated database (%d %s to the eps row)\n",
+ $ccount, _plural( 'change', $ccount );
+ }
+ else {
+ print "Episode not updated due to error\n";
+ }
+
+ }
+ else {
+ #
+ # No changes were found
+ #
+ print "There was nothing to do\n";
+ }
+ }
+ else {
+ print "Option -noupdatedb chosen database not updated\n";
+ }
+
+ return;
+}
+
+#=== FUNCTION ================================================================
+# NAME: change_comment
+# PURPOSE: Make changes to a comment relating to a show
+# PARAMETERS: $dbh open handle of the MySQL database
+# $h handle of the query that returned the episode
+# record and comment count
+# $show show number being updated
+# $cnumber comment number within show
+# $updatedb Boolean; true when changes are to be made
+# $ctitle Boolean; true when the comment title is to be
+# changed
+# $ctext Boolean; true when the comment text is to be
+# changed
+# RETURNS: Nothing
+# DESCRIPTION: The episode has been found in the database and the number of
+# comments determined. We know there are more than zero comments
+# otherwise this routine woulkd not have been called. We check
+# that the requested comment number is in range here (if could
+# have been done before invocation). We query the target comment
+# and modify one or both of the requested fields. If, after
+# comparing old with new, changes are found, they are applied,
+# otherwise nothing is done.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub change_comment {
+ my ( $dbh, $h, $show, $cnumber, $updatedb, $ctitle, $ctext ) = @_;
+
+ my ( $sth1, $h1 );
+ my (%changes);
+
+ if ( $cnumber <= $h->{comment_count} ) {
+ #
+ # Get the requested comment
+ #
+ $sth1 = $dbh->prepare(
+ q{
+ SELECT *
+ FROM comments
+ WHERE eps_id = ?
+ ORDER BY comment_timestamp
+ LIMIT 1
+ OFFSET ?
+ }
+ );
+ $sth1->execute( $show, $cnumber - 1 );
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+
+ #
+ # If found perform changes, otherwise it wasn't found (not sure how
+ # that's possible but you never know)
+ #
+ if ( $h1 = $sth1->fetchrow_hashref ) {
+ if ($ctitle) {
+ $changes{comment_title} = run_editor( $h1->{comment_title} );
+ }
+ if ($ctext) {
+ $changes{comment_text} = run_editor( $h1->{comment_text} );
+ }
+ print Dumper( \%changes ), "\n" if ( $DEBUG > 2 );
+
+ #
+ # Are we updating the database then?
+ #
+ if ($updatedb) {
+ #
+ # Was anything edited?
+ #
+ if (%changes) {
+ #
+ # Did the edits do anything? If not delete the element from the
+ # hash since there's no point writing it to the database
+ #
+ for my $key ( keys(%changes) ) {
+ if ( $changes{$key} eq $h1->{$key} ) {
+ print "No change made to $key, ignored\n";
+ delete( $changes{$key} );
+ }
+ }
+ print Dumper( \%changes ), "\n" if ( $DEBUG > 2 );
+ }
+
+ #
+ # If there's anything left apply the changes
+ #
+ if (%changes) {
+ #
+ # Go into transaction mode here so we can fail safely
+ #
+ $rc = $dbh->begin_work or die $dbh->errstr;
+
+ my $sql = SQL::Abstract->new;
+ my %where = ( id => $h1->{id} );
+ my ( $stmt, @bind )
+ = $sql->update( 'comments', \%changes, \%where );
+
+ my $sth = $dbh->prepare($stmt);
+ my $rv = $sth->execute(@bind);
+
+ #
+ # Respond to any error by rolling back
+ #
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ eval { $dbh->rollback };
+ $rv = 0;
+ }
+ else {
+ $dbh->commit;
+ }
+ $rv = 0 if ( $rv eq '0E0' );
+
+ #
+ # Report the update
+ #
+ if ($rv) {
+ my $ccount = scalar(keys(%changes));
+ printf "Updated database (%d %s to the comments row)\n",
+ $ccount, _plural( 'change', $ccount );
+ }
+ else {
+ print "Comment not updated due to error\n";
+ }
+
+ }
+ else {
+ print "There was nothing to do\n";
+ }
+ }
+ else {
+ print "Option -noupdatedb chosen database not updated\n";
+ }
+ }
+ }
+ else {
+ print "Requested comment is out of range\n";
+ }
+
+ return;
+}
+
+#=== FUNCTION ================================================================
+# NAME: run_editor
+# PURPOSE: Run an interactive vim editor on a string
+# PARAMETERS: $string String to edit
+# $options An arrayref containing options for vim
+# (optional) Example '+set paste'. Each option
+# (such as '-c startinsert') needs to be
+# a separate array element.
+# RETURNS: Edited string
+# DESCRIPTION: Makes a temporary file with File::Temp ensuring that the file
+# is in utf8 mode. Writes the edit string to the file and invokes
+# the 'vim' editor on it. The resulting file is then read back
+# into a string and returned to the caller, again taking care to
+# retain utf8 mode.
+# THROWS: No exceptions
+# COMMENTS: File::Slurp and UTF-8 don't go well together. Moved to
+# File::Slurper instead
+# SEE ALSO: N/A
+#===============================================================================
+sub run_editor {
+ my ( $string, $options ) = @_;
+
+ #
+ # Build an arguments array for 'system'
+ #
+ my @args;
+ push( @args, @$options ) if $options;
+
+ #
+ # Make a temporary file
+ #
+ my $tfh = File::Temp->new;
+ binmode $tfh, ":encoding(UTF-8)";
+ my $tfn = $tfh->filename;
+ print $tfh $string if $string;
+ $tfh->close;
+
+ #
+ # Add the filename to the arguments
+ #
+ push( @args, $tfn );
+
+ die "Edit failed\n"
+ unless ( system( ( 'vim', @args ) ) == 0 );
+
+ return read_text($tfn);
+}
+
+#=== FUNCTION ================================================================
+# NAME: check_field
+# PURPOSE: Checks the a field is not too long and doesn't contain certain
+# characters
+# PARAMETERS: $name name of field
+# $field string to be checked
+# $maxlen maximum string length
+# $regex regex containing illegal characters to be removed
+# RETURNS: The input string truncated and with any illegal characters
+# removed.
+# DESCRIPTION: Runs a substitution on the string then truncates the result if
+# it is too long.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub check_field {
+ my ( $name, $field, $maxlen, $regex ) = @_;
+
+ return unless $field;
+
+ $field =~ s/$regex//g;
+ if ( length($field) > $maxlen ) {
+ warn "Field '$name' too long ("
+ . length($field)
+ . "); truncated to "
+ . $maxlen . "\n";
+ $field = substr( $field, 0, $maxlen );
+ }
+ return $field;
+}
+
+#=== FUNCTION ================================================================
+# NAME: _plural
+# PURPOSE: Add an 's' to a word depending on a number
+# PARAMETERS: $word word to pluralise
+# $count number being used in message
+# RETURNS: The word in a plural form or not
+# DESCRIPTION: Just hides the expression that adds an 's' or not behind
+# a function call
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub _plural {
+ my ( $word, $count ) = @_;
+
+ return $word . ( abs($count) != 1 ? 's' : '' );
+}
+
+#=== FUNCTION ================================================================
+# NAME: Options
+# PURPOSE: Processes command-line options
+# PARAMETERS: $optref Hash reference to hold the options
+# RETURNS: Undef
+# DESCRIPTION:
+# THROWS: no exceptions
+# COMMENTS: none
+# SEE ALSO: n/a
+#===============================================================================
+sub Options {
+ my ($optref) = @_;
+
+ my @options = (
+ "help", "debug=i", "config=s", "updatedb!", "title!",
+ "summary!", "tags!", "notes!", "ctitle!", "ctext!", "cnumber=i",
+ );
+
+ if ( !GetOptions( $optref, @options ) ) {
+ pod2usage( -msg => "Version $VERSION\n", -exitval => 1 );
+ }
+
+ return;
+}
+
+__END__
+
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+# Application Documentation
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+#{{{
+
+=head1 NAME
+
+edit_episode - edit one or more fields in the database for a given HPR show
+
+=head1 VERSION
+
+This documentation refers to edit_episode version 0.1.3
+
+
+=head1 USAGE
+
+ edit_episode [-help] [-debug=N] [-config=FILE] [-[no]updatedb] [-[no]title]
+ [-[no]summary] [-[no]tags] [-[no]notes] [-[no]ctitle] [-[no]ctext]
+ [-cnumber=N] shownumber
+
+ edit_episode -updatedb -title 1234
+ edit_episode -updatedb -title -summary 2000
+ edit_episode -updatedb -tags 2050
+ edit_episode -updatedb -notes 2045
+ edit_episode -updatedb -ctext -cnumber=1 2813
+
+
+=head1 REQUIRED ARGUMENTS
+
+=over 4
+
+=item B
+
+The script must be provided with a single show number to operate on.
+
+=back
+
+=head1 OPTIONS
+
+=over 4
+
+=item B<-[no]updatedb>
+
+This option is required to make the script apply any changes that are made to
+the database. By default no updates are applied (B<-noupdatedb>).
+
+=item B<-[no]title>
+
+This option, if given (as B<-title>) indicates that the 'title' field of the
+row for the selected episode is to be edited. The Vim editor is invoked to
+make changes. The default is B<-notitle> meaning that this field is not to be
+edited.
+
+=item B<-[no]summary>
+
+This option, if given (as B<-summary>) indicates that the 'summary' field of the
+row for the selected episode is to be edited. The Vim editor is invoked to
+make changes. The default is B<-nosummary> meaning that this field is not to be
+edited.
+
+=item B<-[no]tags>
+
+This option, if given (as B<-tags>) indicates that the 'tags' field of the
+row for the selected episode is to be edited. The Vim editor is invoked to
+make changes. The default is B<-notags> meaning that this field is not to be
+edited.
+
+=item B<-[no]notes>
+
+This option, if given (as B<-notes>) indicates that the 'notes' field of the
+row for the selected episode is to be edited. The Vim editor is invoked to
+make changes. The default is B<-nonotes> meaning that this field is not to be
+edited.
+
+=item B<-[no]ctitle>
+
+This option, if given (as B<-ctitle>) indicates that the 'title' field of the
+row for the selected comment is to be edited. The Vim editor is invoked to
+make changes. The default is B<-noctitle> meaning that this field is not to be
+edited.
+
+=item B<-[no]ctext>
+
+This option, if given (as B<-ctext>) indicates that the 'comment_text' field
+of the row for the selected comment is to be edited. The Vim editor is invoked
+to make changes. The default is B<-noctext> meaning that this field is not to
+be edited.
+
+=item B<-cnumber=N>
+
+If comment fields are being edited then a comment index is required. The
+comments are numbered starting from 1 and are sorted in the submission
+timestamp order. This number must be in the range 1..N where I is the
+number of comments on this particular show.
+
+=item B<-config=FILE>
+
+This option allows an alternative configuration file to be used. This file
+defines the location of the database, its port, its name and the username and
+password to be used to access it. This feature was added to allow the script
+to access alternative databases or the live database over an SSH tunnel.
+
+See the CONFIGURATION AND ENVIRONMENT section below for the file format.
+
+If the option is omitted the default file is used: B<.hpr_db.cfg>
+
+=back
+
+At least one of the options B<-title>, B<-summary>, B<-tags> and B<-notes>
+must be provided otherwise the script will abort with an error.
+
+=head1 DESCRIPTION
+
+The script B provides an editor interface to certain fields in
+the HPR database. The fields are:
+
+=over 4
+
+=item B
+
+ A single line of up to 100 characters of text. The line is rendered as an
+ "" tag on the web page and is incorporated into the RSS feed, so it
+ must only contain characters legal in these contexts.
+
+=item B
+
+ A single line of up to 100 characters of text. The line is rendered as an
+ "" tag on the web page and is incorporated into the RSS feed, so it
+ must only contain characters legal in these contexts.
+
+=item B
+
+ A single line of up to 200 characters of text. The field holds tags
+ relevant to the content of the episode in CSV format.
+
+=item B
+
+ A block of HTML which is to be included inside "" tags making up
+ the show notes on the web page for the episode.
+
+=item B
+
+ A single line of text. The title is stored in a 'text' field in the
+ database and could be of any length, but will not be rendered correctly
+ if it exceeds 100 characters.
+
+=item B
+
+ A block of text (NOT HTML) which is the body of the comment. There are no
+ limit contraints here although the code that initiaslly accepts a comment
+ does impose a limit. Thus it would be unwise to make this field too large.
+
+=back
+
+=head1 DIAGNOSTICS
+
+=over 4
+
+=item B
+
+At least one of these options is required. This a fatal error.
+
+=item B
+
+If one of B<-ctitle> and B<-ctext> is provided then a comment number is needed.
+
+=item B
+
+The show number has been omitted. This a fatal error.
+
+=item B
+
+The database connection has been denied. Check the configuration details (see
+below). This a fatal error.
+
+=item B
+
+If the Vim edit session fails in some way the script reports it this way.
+
+=back
+
+=head1 CONFIGURATION AND ENVIRONMENT
+
+The script obtains the credentials it requires to open the HPR database from
+a configuration file. The name of the file it expects is B<.hpr_db.cfg> in the
+directory holding the script. This configuration file can be overridden using
+the B<-config=FILE> option as described above.
+
+The configuration file format is as follows:
+
+
+ host = 127.0.0.1
+ port = PORT
+ name = DATABASE
+ user = USERNAME
+ password = PASSWORD
+
+
+=head1 DEPENDENCIES
+
+ Config::General
+ DBI
+ Data::Dumper
+ File::Slurp
+ File::Temp
+ Getopt::Long
+ Pod::Usage
+ SQL::Abstract
+
+=head1 BUGS AND LIMITATIONS
+
+There are no known bugs in this module.
+Please report problems to Dave Morriss (Dave.Morriss@gmail.com)
+Patches are welcome.
+
+=head1 AUTHOR
+
+Dave Morriss (Dave.Morriss@gmail.com)
+
+
+=head1 LICENCE AND COPYRIGHT
+
+Copyright (c) 2015-2019 Dave Morriss (Dave.Morriss@gmail.com). All rights reserved.
+
+This module is free software; you can redistribute it and/or
+modify it under the same terms as Perl itself. See perldoc perlartistic.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+=cut
+
+#}}}
+
+# [zo to open fold, zc to close]
+
+# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
diff --git a/Database/edit_host b/Database/edit_host
new file mode 100755
index 0000000..251d9e7
--- /dev/null
+++ b/Database/edit_host
@@ -0,0 +1,648 @@
+#!/usr/bin/env perl
+#===============================================================================
+#
+# FILE: edit_host
+#
+# USAGE: ./edit_host [-help] [-debug=N] [-[no]update] [-config=FILE]
+# [-[no]espeak_name] [-hostid=id] [-[no]regex] host_name
+#
+# DESCRIPTION: A simple editor for the HPR hosts table
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: DBI::MariaDB was here for a while then reverted to DBI::mysql.
+# Had to revert this script onn 2023-01-22.
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.0.7
+# CREATED: 2018-04-07 22:05:06
+# REVISION: 2023-01-22 14:06:48
+#
+#===============================================================================
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+
+use Getopt::Long;
+use Pod::Usage;
+
+use Config::General;
+use File::Temp;
+use File::Slurper qw{ read_text };
+use SQL::Abstract;
+use DBI;
+
+use Data::Dumper;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.0.7';
+
+#
+# Script and directory names
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+( my $DIR = $0 ) =~ s|/?[^/]*$||mx;
+$DIR = '.' unless $DIR;
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+#
+# Constants and other declarations
+#
+my $basedir = "$ENV{HOME}/HPR/Database";
+my $configfile = "$basedir/.hpr_db.cfg";
+
+#
+# Declarations
+#
+my ( $dbh, $sth1, $h1, $rc );
+my ( $host_name, %changes );
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#
+# Options and arguments
+#
+my %options;
+Options( \%options );
+
+#
+# Default help
+#
+pod2usage( -msg => "Version $VERSION\n", -exitval => 1 )
+ if ( $options{'help'} );
+
+#
+# Collect options
+#
+my $DEBUG = ( $options{'debug'} ? $options{'debug'} : 0 );
+
+my $cfgfile
+ = ( defined( $options{config} ) ? $options{config} : $configfile );
+
+my $updatedb = ( defined( $options{'updatedb'} ) ? $options{'updatedb'} : 0 );
+my $regexp = ( defined( $options{'regexp'} ) ? $options{'regexp'} : 0 );
+
+my $email = ( defined( $options{'email'} ) ? $options{'email'} : 0 );
+my $profile = ( defined( $options{'profile'} ) ? $options{'profile'} : 0 );
+my $espeak_name
+ = ( defined( $options{'espeak_name'} ) ? $options{'espeak_name'} : 0 );
+
+#
+# There must be at least one field to change
+#
+die "Select one of -email, -profile and -espeak_name\n"
+ unless ( $email || $profile || $espeak_name );
+
+#
+# Was a host id provided (through an option)?
+#
+my $hostid = $options{'hostid'};
+
+#
+# Deal with the two routes: one via the unique host id, and the other vai the
+# less unique host name
+#
+unless ($hostid) {
+ #
+ # Get the arg
+ #
+ $host_name = shift;
+ pod2usage( -msg => "Specify the host name\n", -exitval => 1 )
+ unless $host_name;
+}
+
+#
+# Sanity check
+#
+die "Unable to find $cfgfile\n" unless ( -e $cfgfile );
+
+#
+# Load configuration data
+#
+my $conf = new Config::General(
+ -ConfigFile => $cfgfile,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %config = $conf->getall();
+
+#
+# Connect to the database
+#
+my $dbhost = $config{database}->{host} // '127.0.0.1';
+my $dbport = $config{database}->{port} // 3306;
+my $dbname = $config{database}->{name};
+my $dbuser = $config{database}->{user};
+my $dbpwd = $config{database}->{password};
+
+#$dbh = DBI->connect( "DBI:MariaDB:database=$dbname;host=$dbhost;port=$dbport",
+# $dbuser, $dbpwd, { AutoCommit => 1 } )
+# or die $DBI::errstr;
+
+$dbh = DBI->connect( "dbi:mysql:host=$dbhost;port=$dbport;database=$dbname",
+ $dbuser, $dbpwd, { AutoCommit => 1 } )
+ or die $DBI::errstr;
+
+#
+# Prepare to read the database either for the hostid or the exact or
+# approximate name
+#
+if ($hostid) {
+ #
+ # Simple hostid query
+ #
+ $sth1 = $dbh->prepare(q{SELECT * FROM hosts WHERE hostid = ?});
+ $sth1->execute($hostid);
+ if ( $dbh->err ) {
+ die $dbh->errstr;
+ }
+}
+else {
+ #
+ # Host name query
+ #
+ if ($regexp) {
+ #
+ # Regexp match requested. Count how many matches there are
+ #
+ $sth1 = $dbh->prepare(
+ q{SELECT count(*) AS count FROM hosts WHERE host REGEXP ?});
+ $sth1->execute($host_name);
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+ if ( $h1 = $sth1->fetchrow_hashref ) {
+ die "Too many matches to regex $host_name\n"
+ unless $h1->{count} == 1;
+ }
+ else {
+ die "Unable to find host matching regex $host_name\n";
+ }
+
+ $sth1 = $dbh->prepare(q{SELECT * FROM hosts WHERE host REGEXP ?});
+ }
+ else {
+ $sth1 = $dbh->prepare(q{SELECT * FROM hosts WHERE host = ?});
+ }
+
+ #
+ # Execute the query
+ #
+ $sth1->execute($host_name);
+ if ( $dbh->err ) {
+ die $dbh->errstr;
+ }
+}
+
+#
+# Did we find it?
+#
+if ( $h1 = $sth1->fetchrow_hashref ) {
+ #
+ # Found. Save the hostid to simplify the update if we don't already have
+ # it
+ #
+ $hostid //= $h1->{hostid};
+
+ #
+ # So what needs changing?
+ #
+ #<<< [perltidy messes up the following]
+ if ($email) {
+ $changes{email} = check_field( 'email',
+ scalar( run_editor( $h1->{email}, ['+set paste'] ) ), 256, qr{(\n)} );
+ }
+ if ($profile) {
+ $changes{profile} = run_editor( $h1->{profile}, ['+set paste'] );
+ }
+ if ($espeak_name) {
+ $changes{espeak_name} = check_field( 'espeak_name',
+ scalar( run_editor( $h1->{espeak_name} ) ), 256, qr{(\n)} );
+ }
+ #>>>
+ print Dumper( \%changes ), "\n" if ( $DEBUG > 2 );
+
+ #
+ # Are we updating the database then?
+ #
+ if ($updatedb) {
+ #
+ # Was anything edited?
+ #
+ if (%changes) {
+ #
+ # Did the edits do anything? If not delete the element from the
+ # hash since there's no point writing it to the database
+ #
+ for my $key ( keys(%changes) ) {
+ if ( $changes{$key} eq $h1->{$key} ) {
+ print "No change made to $key, ignored\n";
+ delete( $changes{$key} );
+ }
+ }
+ print Dumper( \%changes ), "\n" if ( $DEBUG > 2 );
+ }
+
+ #
+ # If there's anything left apply the changes
+ #
+ if (%changes) {
+ #
+ # Go into transaction mode here so we can fail safely
+ #
+ $rc = $dbh->begin_work or die $dbh->errstr;
+
+ my $sql = SQL::Abstract->new;
+ my %where = ( hostid => $hostid );
+ my ( $stmt, @bind ) = $sql->update( 'hosts', \%changes, \%where );
+ #print "$stmt\n";
+ #print join( ",", map {"'$_'"} @bind ), "\n";
+
+ my $sth = $dbh->prepare($stmt);
+ my $rv = $sth->execute(@bind);
+
+ #
+ # Respond to any error by rolling back
+ #
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ eval { $dbh->rollback };
+ $rv = 0;
+ }
+ else {
+ $dbh->commit;
+ }
+ $rv = 0 if ( $rv eq '0E0' );
+
+ #
+ # Update the log file
+ #
+ if ($rv) {
+ print "Updated database\n";
+ }
+ else {
+ print "Database not updated due to error\n";
+ }
+
+ }
+ else {
+ print "There was nothing to do\n";
+ }
+ }
+ else {
+ print "Database not updated\n";
+ }
+}
+else {
+ if ($hostid) {
+ print "Unable to find host number $hostid\n";
+ }
+ else {
+ print "Unable to find host name $host_name\n";
+ }
+}
+
+exit;
+
+#=== FUNCTION ================================================================
+# NAME: run_editor
+# PURPOSE: Run an interactive vim editor on a string
+# PARAMETERS: $string String to edit
+# $options An arrayref containing options for vim
+# (optional) Example '+set paste'. Each option
+# (such as '-c startinsert') needs to be
+# a separate array element.
+# RETURNS: Edited string
+# DESCRIPTION: Makes a temporary file with File::Temp ensuring that the file
+# is in utf8 mode. Writes the edit string to the file and invokes
+# the 'vim' editor on it. The resulting file is then read back
+# into a string and returned to the caller, again taking care to
+# retain utf8 mode.
+# THROWS: No exceptions
+# COMMENTS: File::Slurp and UTF-8 don't go well together. Moved to
+# File::Slurper instead
+# SEE ALSO: N/A
+#===============================================================================
+sub run_editor {
+ my ( $string, $options ) = @_;
+
+ #
+ # Build an arguments array for 'system'
+ #
+ my @args;
+ push( @args, @$options ) if $options;
+
+ #
+ # Make a temporary file
+ #
+ my $tfh = File::Temp->new;
+ binmode $tfh, ":encoding(UTF-8)";
+ my $tfn = $tfh->filename;
+ print $tfh $string if $string;
+ $tfh->close;
+
+ #
+ # Add the filename to the arguments
+ #
+ push( @args, $tfn );
+
+ die "Edit failed\n"
+ unless ( system( ( 'vim', @args ) ) == 0 );
+
+ return read_text($tfn);
+}
+
+#=== FUNCTION ================================================================
+# NAME: check_field
+# PURPOSE: Checks the a field is not too long and doesn't contain certain
+# characters
+# PARAMETERS: $name name of field
+# $field string to be checked
+# $maxlen maximum string length
+# $regex regex containing illegal characters to be removed
+# RETURNS: The input string truncated and with any illegal characters
+# removed.
+# DESCRIPTION: Runs a substitution on the string then truncates the result if
+# it is too long.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub check_field {
+ my ( $name, $field, $maxlen, $regex ) = @_;
+
+ return unless $field;
+
+ $field =~ s/$regex//g;
+ if ( length($field) > $maxlen ) {
+ warn "Field '$name' too long ("
+ . length($field)
+ . "); truncated to "
+ . $maxlen . "\n";
+ $field = substr( $field, 0, $maxlen );
+ }
+ return $field;
+}
+
+#=== FUNCTION ================================================================
+# NAME: Options
+# PURPOSE: Processes command-line options
+# PARAMETERS: $optref Hash reference to hold the options
+# RETURNS: Undef
+# DESCRIPTION:
+# THROWS: no exceptions
+# COMMENTS: none
+# SEE ALSO: n/a
+#===============================================================================
+sub Options {
+ my ($optref) = @_;
+
+ my @options = (
+ "help", "debug=i", "updatedb!", "config=s",
+ "hostid=i", "regexp", "email!", "profile!",
+ "espeak_name!",
+ );
+
+ if ( !GetOptions( $optref, @options ) ) {
+ pod2usage( -msg => "Version $VERSION\n", -exitval => 1 );
+ }
+
+ return;
+}
+
+__END__
+
+
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+# Application Documentation
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+#{{{
+
+=head1 NAME
+
+edit_host - edit one or more fields in the database for a given host
+
+=head1 VERSION
+
+This documentation refers to edit_host version 0.0.7
+
+
+=head1 USAGE
+
+ edit_host [-h] [-debug=N] [-[no]updatedb] [-hostid=ID] [-regexp]
+ [-[no]email] [-[no]profile] [-[no]espeak_name] [-config=FILE] hostname
+
+ edit_host -updatedb -espeak_name operat0r
+ edit_host -updatedb -espeak_name -regexp oper
+ edit_host -updatedb -email -espeak -host=225
+
+
+=head1 REQUIRED ARGUMENTS
+
+=over 4
+
+=item B
+
+Unless the B<-hostid=ID> option is given (see the OPTIONS section) it is
+necessary to provide a host name.
+
+Unless the B<-regexp> option is provided (see the OPTIONS section) the
+hostname must match exactly, otherwise it is regarded as a MySQL regular
+expression.
+
+=back
+
+=head1 OPTIONS
+
+=over 4
+
+=item B<-[no]updatedb>
+
+This option is required to make the script apply any changes that are made to
+the database. By default no updates are applied (B<-noupdatedb>).
+
+=item B<-regexp>
+
+This option causes the B argument to be interpreted as a regular
+expression for the MySQL database. By default the argument is treated as if it
+is an exact match.
+
+=item B<-hostid=ID>
+
+The host can be specified by the host ID through this route. If this used then
+the B argument is not required (and is ignored if given).
+
+=item B<-[no]email>
+
+This option, if given (as B<-email>), indicates that the 'email' field is to
+be edited. The Vim editor is invoked to make changes. The default is
+B<-noemail> meaning that this field is not to be edited.
+
+=item B<-[no]profile>
+
+This option, if given (as B<-profile>), indicates that the 'profile' field is
+to be edited. The Vim editor is invoked to make changes. The default is
+B<-noprofile> meaning that this field is not to be edited.
+
+=item B<-[no]espeak_name>
+
+This option, if given (as B<-espeak_name>), indicates that the 'espeak_name'
+field is to be edited. The Vim editor is invoked to make changes. The default
+is B<-noespeak_name> meaning that this field is not to be edited.
+
+=item B<-config=FILE>
+
+This option allows an alternative configuration file to be used. This file
+defines the location of the database, its port, its name and the username and
+password to be used to access it. This feature was added to allow the script
+to access alternative databases or the live database over an SSH tunnel.
+
+See the CONFIGURATION AND ENVIRONMENT section below for the file format.
+
+If the option is omitted the default file is used: B<.hpr_db.cfg>
+
+=back
+
+=head1 DESCRIPTION
+
+The script B provides an editor interface to certain fields in
+the B table of the HPR database. The fields are:
+
+=over 4
+
+=item B
+
+A single line of up to 256 characters of text. The line is stored in a 'TEXT'
+field but it makes no sense to make it too long even though an email address
+can be arbitrarily long. The current maximum address length in the database is
+44 characters.
+
+=item B
+
+A multi-line line of text of arbitrary length and content. The contents are
+the host's profile in optional HTML format to be displayed on the page which
+lists all of their contributions to HPR.
+
+=item B
+
+A single line of up to 256 characters of text. The line is stored in a 'TEXT'
+field but it makes no sense to make it too long. Its purpose is to provide the
+'espeak' program with a form of the host name (or alias) which can be spoken
+as the host requires. For example the host 'thelovebug' finds his name spoken
+as "thel ove bug" and this can be corrected by storing 'TheLoveBug' in this
+field.
+
+=back
+
+=head1 DIAGNOSTICS
+
+=over 4
+
+=item B
+
+At least one of these options is required. This a fatal error.
+
+=item B
+
+If no host name has been provided, and the B<-hostid=ID> option has not been
+used the script is unable to determine the host to edit. This is a fatal
+error.
+
+=item B
+
+The configuration file containing details of the database cannot be found.
+This is a fatal error.
+
+=item B
+
+The database connection has been denied. Check the configuration details (see
+below). This a fatal error.
+
+=item B
+
+In B<-regex> mode a hostname has been provided that matches too many hosts in
+the database. Try again with a less ambiguous name. This a fatal error.
+
+=item B
+
+In B<-regex> mode a hostname has been provided that matches no hosts in the
+database. Try again. This a fatal error.
+
+=item B
+
+If the Vim edit session fails in some way the script reports it this way. This
+a fatal error.
+
+=item B
+
+The string provided for the field is greater than the limit and has been
+truncated. This is a warning.
+
+=back
+
+=head1 CONFIGURATION AND ENVIRONMENT
+
+The script obtains the credentials it requires to open the HPR database from
+a configuration file. The name of the file it expects is B<.hpr_db.cfg> in the
+directory holding the script. To change this will require changing the script.
+
+The configuration file format is as follows:
+
+
+ host = 127.0.0.1
+ port = PORT
+ name = DATABASE
+ user = USERNAME
+ password = PASSWORD
+
+
+=head1 DEPENDENCIES
+
+ Config::General
+ DBI
+ Data::Dumper
+ File::Slurper
+ File::Temp
+ Getopt::Long
+ Pod::Usage
+ SQL::Abstract
+
+=head1 BUGS AND LIMITATIONS
+
+There are no known bugs in this module.
+Please report problems to Dave Morriss (Dave.Morriss@gmail.com)
+Patches are welcome.
+
+=head1 AUTHOR
+
+Dave Morriss (Dave.Morriss@gmail.com)
+
+=head1 LICENCE AND COPYRIGHT
+
+Copyright (c) 2018 Dave Morriss (Dave.Morriss@gmail.com). All rights reserved.
+
+This module is free software; you can redistribute it and/or
+modify it under the same terms as Perl itself. See perldoc perlartistic.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+=cut
+
+#}}}
+
+# [zo to open fold, zc to close]
+
+# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
+
diff --git a/Database/edit_series b/Database/edit_series
new file mode 100755
index 0000000..8b1d8e5
--- /dev/null
+++ b/Database/edit_series
@@ -0,0 +1,666 @@
+#!/usr/bin/env perl
+#===============================================================================
+#
+# FILE: edit_series
+#
+# USAGE: ./edit_series [-help] [-debug=N] [-[no]update] [-config=FILE]
+# [-series_id=id] [-[no]regex] [-[no]description] [-[no]private]
+# [-[no]valid] series_name
+#
+# DESCRIPTION: A simple editor for the HPR miniseries table
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: Based on edit_host
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.0.3
+# CREATED: 2020-06-21 17:58:19
+# REVISION: 2021-06-23 22:11:13
+#
+#===============================================================================
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+
+use Getopt::Long;
+use Pod::Usage;
+
+use Config::General;
+use File::Temp;
+use File::Slurper qw{ read_text };
+use SQL::Abstract;
+use DBI;
+
+use Data::Dumper;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.0.3';
+
+#
+# Script and directory names
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+( my $DIR = $0 ) =~ s|/?[^/]*$||mx;
+$DIR = '.' unless $DIR;
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+#
+# Constants and other declarations
+#
+my $basedir = "$ENV{HOME}/HPR/Database";
+my $configfile = "$basedir/.hpr_db.cfg";
+
+#
+# Declarations
+#
+my ( $dbh, $sth1, $h1, $rc );
+my ( $series_name, %changes );
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#
+# Options and arguments
+#
+my %options;
+Options( \%options );
+
+#
+# Default help
+#
+pod2usage( -msg => "Version $VERSION\n", -exitval => 1 )
+ if ( $options{'help'} );
+
+#
+# Collect options
+#
+my $DEBUG = ( $options{'debug'} ? $options{'debug'} : 0 );
+
+my $cfgfile
+ = ( defined( $options{config} ) ? $options{config} : $configfile );
+
+my $updatedb = ( defined( $options{'updatedb'} ) ? $options{'updatedb'} : 0 );
+my $regexp = ( defined( $options{'regexp'} ) ? $options{'regexp'} : 0 );
+
+my $description = $options{'description'};
+my $private = $options{'private'};
+my $valid = $options{'valid'};
+
+#
+# There must be at least one field to change. We check for the definition here
+# because the values returned may be zero or one or the variable may be
+# undefined.
+#
+die "Select one of -[no]description, -[no]private and -[no]valid\n"
+ unless ( defined($description) || defined($private) || defined($valid) );
+
+#
+# Was a series id provided (through an option)?
+#
+my $series_id = $options{'series_id'};
+
+#
+# Deal with the two routes: one via the unique series id, and the other vai the
+# less unique series name
+#
+unless ($series_id) {
+ #
+ # Get the arg
+ #
+ $series_name = shift;
+ pod2usage( -msg => "Specify the series name\n", -exitval => 1 )
+ unless $series_name;
+}
+
+#
+# Sanity check
+#
+die "Unable to find $cfgfile\n" unless ( -e $cfgfile );
+
+#
+# Load configuration data
+#
+my $conf = new Config::General(
+ -ConfigFile => $cfgfile,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %config = $conf->getall();
+
+#
+# Connect to the database
+#
+my $dbhost = $config{database}->{host} // '127.0.0.1';
+my $dbport = $config{database}->{port} // 3306;
+my $dbname = $config{database}->{name};
+my $dbuser = $config{database}->{user};
+my $dbpwd = $config{database}->{password};
+
+# 2022-04-12 The MariaDB driver was there one minute and then it wasn't!
+#
+#$dbh = DBI->connect( "DBI:MariaDB:database=$dbname;host=$dbhost;port=$dbport",
+# $dbuser, $dbpwd, { AutoCommit => 1 } )
+# or die $DBI::errstr;
+
+$dbh = DBI->connect( "dbi:mysql:database=$dbname;host=$dbhost;port=$dbport",
+ $dbuser, $dbpwd, { AutoCommit => 1 } )
+ or die $DBI::errstr;
+
+#
+# Enable client-side UTF8
+#
+$dbh->{mysql_enable_utf8} = 1;
+
+#
+# Prepare to read the database either for the series_id or the exact or
+# approximate name
+#
+if ($series_id) {
+ #
+ # Simple series_id query
+ #
+ $sth1 = $dbh->prepare(q{SELECT * FROM miniseries WHERE id = ?});
+ $sth1->execute($series_id);
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+}
+else {
+ #
+ # Series name query
+ #
+ if ($regexp) {
+ #
+ # Regexp match requested. Count how many matches there are
+ #
+ $sth1 = $dbh->prepare(
+ q{SELECT count(*) AS count FROM miniseries WHERE name REGEXP ?});
+ $sth1->execute($series_name);
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+ if ( $h1 = $sth1->fetchrow_hashref ) {
+ my $matches = $h1->{count};
+ if ($matches > 1) {
+ die "Too many matches to regex '$series_name' ($matches)\n";
+ } elsif ($matches == 0) {
+ die "No matches to regex '$series_name'\n";
+ }
+ }
+ else {
+ die "Unable to find series matching regex '$series_name'\n";
+ }
+
+ $sth1 = $dbh->prepare(q{SELECT * FROM miniseries WHERE name REGEXP ?});
+ }
+ else {
+ $sth1 = $dbh->prepare(q{SELECT * FROM miniseries WHERE name = ?});
+ }
+
+ #
+ # Execute the query
+ #
+ $sth1->execute($series_name);
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+}
+
+#
+# Did we find it?
+#
+if ( $h1 = $sth1->fetchrow_hashref ) {
+ #
+ # Found. Save the series_id to simplify the update if we don't already have
+ # it
+ #
+ $series_id //= $h1->{id};
+
+ #
+ # Report on the series details
+ #
+ printf "Series details\n" .
+ "Id: %s\n" .
+ "Name: %s\n" .
+ "Description: %s\n" .
+ "Private: %s\n" .
+ "Image: '%s'\n" .
+ "Valid: %s\n",
+ $h1->{id},
+ $h1->{name},
+ ( length( $h1->{description} ) > 80
+ ? substr( $h1->{description}, 0, 80 ) . '...'
+ : $h1->{description} ),
+ $h1->{private},
+ $h1->{image},
+ $h1->{valid};
+
+ #
+ # So what needs changing?
+ #
+ #<<< [perltidy messes up the following]
+ if ($description) {
+ $changes{description} = check_field( 'description',
+ scalar( run_editor( $h1->{description}, ['+set paste'] ) ), 1500, qr{(\n)} );
+ }
+ if (defined($private)) {
+ $changes{private} = $private if ($h1->{private} ne $private);
+ }
+ if (defined($valid)) {
+ $changes{valid} = $valid if ($h1->{valid} ne $valid);
+ }
+ #>>>
+ print Dumper( \%changes ), "\n" if ( $DEBUG > 2 );
+
+ #
+ # Are we updating the database then?
+ #
+ if ($updatedb) {
+ #
+ # Was anything edited?
+ #
+ if (%changes) {
+ #
+ # Did the edits do anything? If not delete the element from the
+ # hash since there's no point writing it to the database
+ #
+ for my $key ( keys(%changes) ) {
+ if ( $changes{$key} eq $h1->{$key} ) {
+ print "No change made to $key, ignored\n";
+ delete( $changes{$key} );
+ }
+ }
+ print Dumper( \%changes ), "\n" if ( $DEBUG > 2 );
+ }
+
+ #
+ # If there's anything left apply the changes
+ #
+ if (%changes) {
+ #
+ # Go into transaction mode here so we can fail safely
+ #
+ $rc = $dbh->begin_work or die $dbh->errstr;
+
+ my $sql = SQL::Abstract->new;
+ my %where = ( id => $series_id );
+ my ( $stmt, @bind )
+ = $sql->update( 'miniseries', \%changes, \%where );
+ #print "$stmt\n";
+ #print join( ",", map {"'$_'"} @bind ), "\n";
+
+ my $sth = $dbh->prepare($stmt);
+ my $rv = $sth->execute(@bind);
+
+ #
+ # Respond to any error by rolling back
+ #
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ eval { $dbh->rollback };
+ $rv = 0;
+ }
+ else {
+ $dbh->commit;
+ }
+ $rv = 0 if ( $rv eq '0E0' );
+
+ #
+ # Update the log file
+ #
+ if ($rv) {
+ print "Updated database\n";
+ print "Changed fields: ",
+ join( ", ", sort( keys(%changes) ) ), "\n";
+ }
+ else {
+ print "Series not updated due to error\n";
+ }
+
+ }
+ else {
+ print "There was nothing to do\n";
+ }
+ }
+ else {
+ print "Database not updated\n";
+ }
+}
+else {
+ if ($series_id) {
+ print "Unable to find series number $series_id\n";
+ }
+ else {
+ print "Unable to find series name $series_name\n";
+ }
+}
+
+exit;
+
+#=== FUNCTION ================================================================
+# NAME: run_editor
+# PURPOSE: Run an interactive vim editor on a string
+# PARAMETERS: $string String to edit
+# $options An arrayref containing options for vim
+# (optional). Example '+set paste'. Each option
+# (such as '-c startinsert') needs to be
+# a separate array element.
+# RETURNS: Edited string
+# DESCRIPTION: Makes a temporary file with File::Temp ensuring that the file
+# is in utf8 mode. Writes the edit string to the file and invokes
+# the 'vim' editor on it. The resulting file is then read back
+# into a string and returned to the caller, again taking care to
+# retain utf8 mode.
+# THROWS: No exceptions
+# COMMENTS: File::Slurp and UTF-8 don't go well together. Moved to
+# File::Slurper instead
+# SEE ALSO: N/A
+#===============================================================================
+sub run_editor {
+ my ( $string, $options ) = @_;
+
+ #
+ # Build an arguments array for 'system'
+ #
+ my @args;
+ push( @args, @$options ) if $options;
+
+ #
+ # Make a temporary file
+ #
+ my $tfh = File::Temp->new;
+ binmode $tfh, ":encoding(UTF-8)";
+ my $tfn = $tfh->filename;
+ print $tfh $string if $string;
+ $tfh->close;
+
+ #
+ # Add the filename to the arguments
+ #
+ push( @args, $tfn );
+
+ die "Edit failed\n"
+ unless ( system( ( 'vim', @args ) ) == 0 );
+
+ return read_text($tfn);
+}
+
+#=== FUNCTION ================================================================
+# NAME: check_field
+# PURPOSE: Checks the a field is not too long and doesn't contain certain
+# characters
+# PARAMETERS: $name name of field
+# $field string to be checked
+# $maxlen maximum string length
+# $regex regex containing illegal characters to be removed
+# RETURNS: The input string truncated and with any illegal characters
+# removed.
+# DESCRIPTION: Runs a substitution on the string then truncates the result if
+# it is too long.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub check_field {
+ my ( $name, $field, $maxlen, $regex ) = @_;
+
+ return unless $field;
+
+ $field =~ s/$regex//g;
+ if ( length($field) > $maxlen ) {
+ warn "Field '$name' too long ("
+ . length($field)
+ . "); truncated to "
+ . $maxlen . "\n";
+ $field = substr( $field, 0, $maxlen );
+ }
+ return $field;
+}
+
+#=== FUNCTION ================================================================
+# NAME: Options
+# PURPOSE: Processes command-line options
+# PARAMETERS: $optref Hash reference to hold the options
+# RETURNS: Undef
+# DESCRIPTION:
+# THROWS: no exceptions
+# COMMENTS: none
+# SEE ALSO: n/a
+#===============================================================================
+sub Options {
+ my ($optref) = @_;
+
+ my @options = (
+ "help", "debug=i", "updatedb!", "config=s",
+ "series_id=i", "regexp", "description!", "private!",
+ "valid!",
+ );
+
+ if ( !GetOptions( $optref, @options ) ) {
+ pod2usage( -msg => "Version $VERSION\n", -exitval => 1 );
+ }
+
+ return;
+}
+
+__END__
+
+
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+# Application Documentation
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+#{{{
+
+=head1 NAME
+
+edit_series - edit one or more fields in the database for a given series
+
+=head1 VERSION
+
+This documentation refers to edit_series version 0.0.3
+
+
+=head1 USAGE
+
+ edit_series [-h] [-debug=N] [-[no]updatedb] [-series_id=ID] [-regexp]
+ [-[no]description] [-[no]private] [-[no]valid] [-config=FILE] series_name
+
+ edit_series -updatedb -description GIMP
+ edit_series -updatedb -description -regexp Awk
+ edit_series -updatedb -noprivate -valid -series_id=102
+
+
+=head1 REQUIRED ARGUMENTS
+
+=over 4
+
+=item B
+
+Unless the B<-series_id=ID> option is given (see the OPTIONS section) it is
+necessary to provide a series name.
+
+Unless the B<-regexp> option is provided (see the OPTIONS section) the
+series name must match exactly, otherwise it is regarded as a MySQL regular
+expression.
+
+=back
+
+=head1 OPTIONS
+
+=over 4
+
+=item B<-[no]updatedb>
+
+This option is required to make the script apply any changes that are made to
+the database. By default no updates are applied (B<-noupdatedb>).
+
+=item B<-regexp>
+
+This option causes the B argument to be interpreted as a regular
+expression for the MySQL database. By default the argument is treated as if it
+is an exact match.
+
+=item B<-series_id=ID>
+
+The series can be specified by the series ID through this route. If this used then
+the B argument is not required (and is ignored if given).
+
+=item B<-[no]description>
+
+This option, if given (as B<-description>), indicates that the 'description'
+field is to be edited. The Vim editor is invoked to make changes. The default
+is B<-nodescription> meaning that this field is not to be edited.
+
+=item B<-[no]private>
+
+This option, if given (as B<-private>), indicates that the 'private' field is
+to be set to 'true'. If given as B<-noprivate> this field is set to 'false'.
+If omitted altogether then the field is not changed.
+
+=item B<-[no]valid>
+
+This option, if given (as B<-valid>), indicates that the 'valid' field is to
+be set to 'true'. If given as B<-novalid> this field is set to 'false'.
+If omitted altogether then the field is not changed.
+
+=item B<-config=FILE>
+
+This option allows an alternative configuration file to be used. This file
+defines the location of the database, its port, its name and the username and
+password to be used to access it. This feature was added to allow the script
+to access alternative databases or the live database over an SSH tunnel.
+
+See the CONFIGURATION AND ENVIRONMENT section below for the file format.
+
+If the option is omitted the default file is used: B<.hpr_db.cfg>
+
+=back
+
+=head1 DESCRIPTION
+
+The script B provides an editor interface to certain fields in
+the B table of the HPR database. The fields are:
+
+=over 4
+
+=item B
+
+This field is stored in a 'TEXT' field in the database. It is possible for
+the field to contain HTML, and some series do. In general it is better to keep
+this field short since it is displayed in its entirety before the notes for
+each show in the series.
+
+=back
+
+=head1 DIAGNOSTICS
+
+=over 4
+
+=item B
+
+At least one of these options is required. This a fatal error.
+
+=item B
+
+If no series name has been provided, and the B<-series_id=ID> option has not been
+used the script is unable to determine the series to edit. This is a fatal
+error.
+
+=item B
+
+The configuration file containing details of the database cannot be found.
+This is a fatal error.
+
+=item B
+
+The database connection has been denied. Check the configuration details (see
+below). This a fatal error.
+
+=item B
+
+In B<-regex> mode a series name has been provided that matches too many series in
+the database. Try again with a less ambiguous name. This a fatal error.
+
+=item B
+
+In B<-regex> mode a series name has been provided that matches no qseries in the
+database. Try again. This a fatal error.
+
+=item B
+
+If the Vim edit session fails in some way the script reports it this way. This
+a fatal error.
+
+=item B
+
+The string provided for the field is greater than the limit and has been
+truncated. This is a warning.
+
+=back
+
+=head1 CONFIGURATION AND ENVIRONMENT
+
+The script obtains the credentials it requires to open the HPR database from
+a configuration file. The name of the file it expects is B<.hpr_db.cfg> in the
+directory holding the script. To change this will require changing the script.
+
+The configuration file format is as follows:
+
+
+ host = 127.0.0.1
+ port = PORT
+ name = DATABASE
+ user = USERNAME
+ password = PASSWORD
+
+
+=head1 DEPENDENCIES
+
+ Config::General
+ DBI
+ Data::Dumper
+ File::Slurper
+ File::Temp
+ Getopt::Long
+ Pod::Usage
+ SQL::Abstract
+
+=head1 BUGS AND LIMITATIONS
+
+There are no known bugs in this module.
+Please report problems to Dave Morriss (Dave.Morriss@gmail.com)
+Patches are welcome.
+
+=head1 AUTHOR
+
+Dave Morriss (Dave.Morriss@gmail.com)
+
+=head1 LICENCE AND COPYRIGHT
+
+Copyright (c) 2020 Dave Morriss (Dave.Morriss@gmail.com). All rights reserved.
+
+This module is free software; you can redistribute it and/or
+modify it under the same terms as Perl itself. See perldoc perlartistic.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+
+=cut
+
+#}}}
+
+# [zo to open fold, zc to close]
+
+# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
+
diff --git a/Database/edit_tsu_blank b/Database/edit_tsu_blank
new file mode 100755
index 0000000..0c47c4c
--- /dev/null
+++ b/Database/edit_tsu_blank
@@ -0,0 +1,293 @@
+#!/bin/bash -
+#===============================================================================
+#
+# FILE: edit_tsu_blank
+#
+# USAGE: ./edit_tsu_blank
+#
+# DESCRIPTION: Edit a template for generating a tag and summary update email.
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: Now obsolete but retained for reference
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.0.11
+# CREATED: 2016-06-16 10:58:32
+# REVISION: 2021-09-16 12:51:18
+#
+#===============================================================================
+
+set -o nounset # Treat unset variables as an error
+
+SCRIPT=${0##*/}
+
+VERSION="0.0.11"
+
+#
+# Load library functions
+#
+LIB="$HOME/bin/function_lib.sh"
+[ -e "$LIB" ] || { echo "$SCRIPT: Unable to source functions"; exit 1; }
+# shellcheck source=/home/cendjm/bin/function_lib.sh
+source "$LIB"
+
+#=== FUNCTION ================================================================
+# NAME: find_work
+# DESCRIPTION: Using 'grep' to count the number of un-edited lines in certain
+# files make a list of their names and the number of edits for
+# display in a 'select'.
+# PARAMETERS: 1 - the name of the directory holding the files
+# 2 - the prefix of each file to identify them exactly
+# 3 - the name of an array to hold the list
+# RETURNS: Nothing (uses a nameref argument)
+#===============================================================================
+find_work () {
+ local bd="${1:?Usage: find_work basedir prefix array}"
+ local pf="${2:?Usage: find_work basedir prefix array}"
+ local -n result="${3:?Usage: find_work basedir prefix array}"
+ local -a work
+ local elem count
+
+ # Load all filenames into an array
+ mapfile -t work < <(grep -E -c "^(summary|tags): *$" "$bd/$pf"*[^~])
+
+ # Add names containing work to the result array
+ for elem in "${work[@]}"; do
+ count="${elem##*:}"
+ if [[ $count -gt 0 ]]; then
+ printf -v count '%2d' "$count"
+ # Add colour and a reset for the yellow to be added later
+ result+=( "${elem%%:*}${reset} (${red}$count${reset} edits)" )
+ fi
+ done
+}
+
+#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+#
+# Check arguments
+#
+if [[ $# -ne 0 ]]; then
+ echo "($SCRIPT Version $VERSION)"
+ echo "Usage: $SCRIPT"
+ exit
+fi
+
+#
+# Directories and files
+#
+BASEDIR="$HOME/HPR/Database"
+TSU="$BASEDIR/tsu"
+VALIDATOR="$BASEDIR/validate_edits.awk"
+STATUSFILE="$BASEDIR/tag_summary_actions.csv"
+
+PREFIX="tag_summary_updates_"
+
+#
+# Sanity checks
+#
+[ -d "$BASEDIR" ] || { echo "Unable to find directory $BASEDIR"; exit 1; }
+[ -d "$TSU" ] || { echo "Unable to find directory $TSU"; exit 1; }
+
+[ -e "$VALIDATOR" ] || { echo "File $VALIDATOR not found"; exit 1; }
+[ -e "$STATUSFILE" ] || { echo "File $STATUSFILE not found"; exit 1; }
+
+#
+# Colour codes
+#
+define_colours
+
+#
+# Using a function find which files have un-edited parts and save their names
+# and the number of edits in an array
+#
+declare -a choices
+find_work "$TSU" "${PREFIX}" choices
+
+#
+# There could be no files with edits
+#
+if [[ ${#choices[@]} -eq 0 ]]; then
+ echo "${red}There are no files in need of editing!${reset}"
+ exit 1
+fi
+
+#
+# Prompt for a choice from the array of files, removing the path from each
+# choice for readability. (Sadly 'select' changed its behaviour in Bash
+# 5 necessitating this). Add a yellow colour code before the resulting
+# filename; there's already a reset after the name.
+#
+PS3="Enter a number: "
+echo "Files in need of editing:"
+# select choice in "${choices[@]##*/}"
+select choice in "${choices[@]/${TSU}\//${yellow}}"
+do
+ break
+done
+
+retval=$?
+if [[ $retval -ne 0 ]]; then
+ echo "${red}Selection aborted${reset}"
+ exit 1
+fi
+
+#
+# Since we removed the full path in the 'select' list 'choice' contains that
+# name rather than the file path, so we need to make it such a path. We also
+# need to remove the string "(X edits)" from the end, and all the colour codes
+# we added earlier, then edit the file.
+#
+# We include Vim settings for the text width and filetype, and perform
+# a search for the next field that needs work (using 'silent!' to stop nasty
+# error messages if there's nothing that matches).
+#
+# We use an Awk script to determine if the file contains any shows which have
+# already been updated by another person. We use the file $STATUSFILE which
+# gets updated every time a report is generated.
+#
+
+# Strip colour codes
+choice="${choice//$yellow/}"
+choice="${choice//$red/}"
+choice="${choice//$reset/}"
+
+#
+# Run the Awk validator, and if OK edit the file, otherwise try to explain
+# what's wrong.
+#
+choice="$TSU/${choice%% *}"
+if awk --assign "csv=$STATUSFILE" -f "$VALIDATOR" "$choice"; then
+ csum1=$(md5sum < "$choice")
+ echo "Editing $choice"
+ vim +"set tw=100 ft=text" -c 'silent! /^\(summary\|tags\):\s*$' "$choice"
+ csum2=$(md5sum < "$choice")
+
+ if [[ $csum1 == "$csum2" ]]; then
+ echo "${yellow}No change was made${reset}"
+ exit
+ fi
+else
+ echo "${red}Errors found checking the file${reset}"
+ echo "Show(s) in this file have already been updated in the database."
+ echo "Somebody else has probably sent in an update for show(s) in the range."
+ echo "The file ${yellow}${choice}${reset}"
+ echo "has been edited automatically to comment out the updated show(s) and"
+ echo "is now ready for editing in the usual way (rerun this script to do it)."
+ echo "----"
+ echo "(This error may also be caused by an internal fault when running"
+ echo "awk. Check the file to be certain.)"
+ exit 1
+fi
+
+#
+# Perform a check on what is now in the file looking for lines that are too
+# long or with a bad show number
+#
+echo "${yellow}Checking show numbers and lengths of summaries and tags${reset}"
+re="^([A-Za-z]+): *(.*) *$"
+count=0; errors=0
+while read -r line; do
+ ((count++))
+ if [[ $line =~ $re ]]; then
+ key="${BASH_REMATCH[1]}"
+ value="${BASH_REMATCH[2]}"
+ case $key in
+ show)
+ if [[ ! $value =~ [0-9]{1,4} ]]; then
+ ((errors++))
+ printf '**Error**\n%02d: %s\n' "$count" "$line"
+ echo "${blue}The show value must be a number (${#value})${reset}"
+ fi
+ ;;
+
+ summary)
+ if [[ ${#value} -gt 100 ]]; then
+ ((errors++))
+ printf '**Error**\n%02d: %s\n' "$count" "$line"
+ echo "${blue}Value too long (${#value}, should be 100 max)${reset}"
+ fi
+ ;;
+
+ tags)
+ if [[ ${#value} -gt 200 ]]; then
+ ((errors++))
+ printf '**Error**\n%02d: %s\n' "$count" "$line"
+ echo "${blue}Value too long (${#value}, should be 200 max)${reset}"
+ fi
+ ;;
+
+ esac
+ fi
+done < "$choice"
+
+#
+# Report a summary of the check
+#
+if [[ $errors -eq 0 ]]; then
+ echo "${green}No errors found${reset}"
+else
+ echo "${red}Found $errors errors${reset}"
+fi
+
+#
+# Make temporary files and set traps to delete them
+#
+TMP1=$(mktemp) || {
+ echo "$SCRIPT: ${red}creation of temporary file failed!${reset}"
+ exit 1
+}
+trap 'cleanup_temp $TMP1' SIGHUP SIGINT SIGPIPE SIGTERM EXIT
+
+#
+# Make a temporary Awk script
+#
+cat > "$TMP1" <<'ENDAWK'
+BEGIN {
+ shows = total = finished = todo = 0
+}
+
+/^show:/ { shows++ }
+/^tags:\s*$/ { total++; todo++ }
+/^tags:\s*\S+/ { total++; finished++ }
+/^summary:\s*$/ { total++; todo++ }
+/^summary:\s*\S+/ { total++; finished++ }
+
+END {
+ printf "shows=%d\n",shows
+ printf "total=%d\n",total
+ printf "finished=%d\n",finished
+ printf "todo=%d\n",todo
+ printf "left=%2.1f%%\n",(todo/total)*100
+}
+ENDAWK
+
+#
+# Run the Awk script and make variables
+#
+declare shows total todo finished left
+eval "$(awk -f "$TMP1" "$choice")"
+
+# todo="$(grep -E -c "^(summary|tags):\s*$" "$choice")"
+# total="$(grep -E -c "^(summary|tags):" "$choice")"
+# completed="$(grep -E -c "^(summary|tags): *\w+" "$choice")"
+
+#
+# Is there still work to do on this file?
+#
+echo "${yellow}File statistics:${reset}"
+printf '%s%-19s %s%s\n' "${purple}" "Total shows:" "$shows" "${reset}"
+printf '%s%-19s %s%s\n' "${purple}" "Additions required:" "$total" "${reset}"
+printf '%s%-19s %s%s\n' "${purple}" "Already done:" "$finished" "${reset}"
+printf '%s%-19s %s%s\n' "${purple}" "Percent left:" "$left" "${reset}"
+case $todo in
+ 0) echo "${green}All required work on this file has been done${reset}";;
+ 1) echo "${red}There is still $todo tag/summary to add${reset}";;
+ *) echo "${red}There are still $todo tags/summaries to add${reset}"
+esac
+
+exit
+
+# vim: syntax=sh:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
+
diff --git a/Database/find_double_hosts b/Database/find_double_hosts
new file mode 100755
index 0000000..f3f381d
--- /dev/null
+++ b/Database/find_double_hosts
@@ -0,0 +1,425 @@
+#!/usr/bin/env perl
+#===============================================================================
+#
+# FILE: find_double_hosts
+#
+# USAGE: ./find_double_hosts
+#
+# DESCRIPTION: Find HPR shows with two hosts (host is "A and B"), find the
+# hosts if possible and flag updates to the database to
+# represent the dual nature.
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: ---
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.0.2
+# CREATED: 2017-10-13 19:17:51
+# REVISION: 2017-10-13 19:19:43
+#
+#===============================================================================
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+
+use Config::General;
+use DBI;
+
+use Data::Dumper;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.0.2';
+
+#
+# Script and directory names
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+( my $DIR = $0 ) =~ s|/?[^/]*$||mx;
+$DIR = '.' unless $DIR;
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+#
+# Constants and other declarations
+#
+my $basedir = "$ENV{HOME}/HPR/Database";
+my $configfile1 = "$basedir/.hpr_db.cfg";
+my $configfile2 = "$basedir/.hpr_pg.cfg";
+
+my $email_template = 'host_%s@hackerpublicradio.org';
+my $default_licence = 'CC-BY-SA';
+
+my ( $dbh1, $dbh2, $sth1, $h1, $rv1, $sth2, $h2, $rv2, $sth3, $h3, $rv3, $sth4, $h4, $rv4 );
+
+my ( %doubles, @h, %hosts, $unknown, $default_email );
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#
+# Load database configuration data
+#
+my $conf1 = Config::General->new(
+ -ConfigFile => $configfile1,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %config1 = $conf1->getall();
+
+my $conf2 = Config::General->new(
+ -ConfigFile => $configfile2,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %config2 = $conf2->getall();
+
+#-------------------------------------------------------------------------------
+# Connect to the MariaDB database
+#-------------------------------------------------------------------------------
+my $dbtype1 = $config1{database}->{type} // 'mysql';
+my $dbhost1 = $config1{database}->{host} // '127.0.0.1';
+my $dbport1 = $config1{database}->{port} // 3306;
+my $dbname1 = $config1{database}->{name};
+my $dbuser1 = $config1{database}->{user};
+my $dbpwd1 = $config1{database}->{password};
+$dbh1
+ = DBI->connect( "dbi:$dbtype1:host=$dbhost1;port=$dbport1;database=$dbname1",
+ $dbuser1, $dbpwd1, { AutoCommit => 1 } )
+ or die $DBI::errstr;
+
+#
+# Enable client-side UTF8
+#
+$dbh1->{mysql_enable_utf8} = 1;
+
+#-------------------------------------------------------------------------------
+# Connect to the PostgreSQL database
+#-------------------------------------------------------------------------------
+my $dbtype2 = $config2{database}->{type} // 'Pg';
+my $dbhost2 = $config2{database}->{host} // '127.0.0.1';
+my $dbport2 = $config2{database}->{port} // 5432;
+my $dbname2 = $config2{database}->{name};
+my $dbuser2 = $config2{database}->{user};
+my $dbpwd2 = $config2{database}->{password};
+$dbh2 = DBI->connect( "dbi:$dbtype2:host=$dbhost2;database=$dbname2;port=$dbport2",
+ $dbuser2, $dbpwd2, { PrintError => 0, AutoCommit => 1 } )
+ or die $DBI::errstr;
+
+#
+# Enable client-side UTF8
+#
+$dbh2->{pg_enable_utf8} = 1;
+
+#-------------------------------------------------------------------------------
+# Query preparation
+#-------------------------------------------------------------------------------
+#
+# MariaDB query to find double hosts
+#
+my $sql1 = q{
+ SELECT hostid, host FROM hosts
+ WHERE host regexp '[[:<:]]and[[:>:]]'
+ ORDER BY hostid
+};
+
+$sth1 = $dbh1->prepare($sql1) or die $DBI::errstr;
+if ( $dbh1->err ) {
+ warn $dbh1->errstr;
+}
+
+#
+# MariaDB query to find the host by name
+#
+$sth2 = $dbh1->prepare(q{SELECT hostid FROM hosts WHERE host REGEXP ?})
+ or die $DBI::errstr;
+if ( $dbh1->err ) {
+ warn $dbh1->errstr;
+}
+
+#
+# PostgreSQL query to register an unknown host
+#
+$sth3
+ = $dbh2->prepare(q{INSERT INTO hosts (host,email,license) VALUES (?,?,?)})
+ or die $DBI::errstr;
+if ( $dbh2->err ) {
+ warn $dbh2->errstr;
+}
+
+#
+# PostgreSQL query to find shows with particular host ids
+#
+$sth4 = $dbh2->prepare(
+ q{
+ SELECT e.id AS eps_id
+ FROM episodes e
+ JOIN episodes_hosts_xref eh ON (e.id = eh.episodes_id)
+ JOIN hosts h ON (h.id = eh.hosts_id)
+ WHERE h.id = ?
+ }
+) or die $DBI::errstr;
+if ( $dbh2->err ) {
+ warn $dbh2->errstr;
+}
+
+#-------------------------------------------------------------------------------
+# Find all the "double hosts"
+#-------------------------------------------------------------------------------
+#
+# Query MariaDB for the target hosts
+#
+$sth1->execute;
+if ( $dbh1->err ) {
+ die $dbh1->errstr;
+}
+
+#
+# Loop through the list of double hostnames and parse them out. Save the
+# originals in the %doubles hash and the parsed names in the %hosts hash.
+#
+while ( $h1 = $sth1->fetchrow_hashref ) {
+ #
+ # Each hash value is a hash containing the original id, and, in a sub-hash
+ # the replacement ids
+ #
+ $doubles{$h1->{host}} = {
+ double => $h1->{hostid},
+ singles => {},
+ };
+
+ #
+ # Parse the double host string
+ #
+ @h = ( $h1->{host} =~ /^(.+)\s+and\s+(.+)$/ );
+
+ printf "%-4d %s", $h1->{hostid}, $h1->{host};
+ print " [", join( ",", @h ), "]\n";
+
+ #
+ # Initialise the entries for %doubles and %hosts
+ #
+ for my $host (@h) {
+ $doubles{$h1->{host}}->{singles}->{$host} = undef;
+ unless ( exists( $hosts{$host} ) ) {
+ $hosts{$host} = 0;
+ }
+ }
+}
+
+print '-' x 80,"\n";
+
+#-------------------------------------------------------------------------------
+# Find the single hosts in the 'hosts' table
+#-------------------------------------------------------------------------------
+#
+# Scan the list of individual hosts and find them in the 'hosts' table
+#
+$unknown = 0;
+foreach my $host ( sort(keys(%hosts)) ) {
+ $rv2 = $sth2->execute("^$host\$");
+ if ( $dbh1->err ) {
+ die $dbh1->errstr;
+ }
+ $rv2 = 0 if ( $rv2 eq '0E0' );
+
+ if ($rv2) {
+ $h2 = $sth2->fetchrow_hashref;
+ print "Found id for $host: ", $h2->{hostid}, "\n";
+ $hosts{$host} = $h2->{hostid};
+ save_hostid(\%doubles,$host,$h2->{hostid});
+ }
+ else {
+ print "Can't find $host\n";
+ $unknown++;
+ }
+}
+
+#print Dumper(\%hosts),"\n";
+
+print '-' x 80,"\n";
+
+#-------------------------------------------------------------------------------
+# Allocate all unknown hosts a host id in the PostgreSQL database, and give an
+# unique email address.
+#-------------------------------------------------------------------------------
+if ( $unknown > 0 ) {
+ print "Registering $unknown hosts\n";
+
+ foreach my $host ( sort( keys(%hosts) ) ) {
+ if ( $hosts{$host} == 0 ) {
+ $rv3 = $sth3->execute( $host, undef, $default_licence );
+ if ( $dbh2->err ) {
+ die $dbh2->errstr;
+ }
+
+ #
+ # Write a row to the 'hosts' table and save the id number
+ # generated
+ #
+ my $newid = $dbh2->last_insert_id( undef, undef, undef, undef,
+ { sequence => 'host_seq' } );
+ $hosts{$host} = $newid;
+ save_hostid(\%doubles,$host,$newid);
+ print "Host $host added with id $newid\n";
+
+ #
+ # Give the new host entry a default email address
+ #
+ $default_email = sprintf($email_template,$newid);
+ $rv3 = $dbh2->do( 'UPDATE hosts SET email = ? WHERE id = ?',
+ undef, $default_email, $newid );
+ if ( $dbh2->err ) {
+ warn $dbh2->errstr;
+ }
+ $rv3 = 0 if ( $rv3 eq '0E0' );
+
+ warn "Failed to set email address $default_email for $host\n"
+ unless ( defined($rv3) );
+
+ }
+ }
+}
+
+print '-' x 80,"\n";
+
+#-------------------------------------------------------------------------------
+# Now %doubles contains all the original names and host ids and %hosts
+# contains the parsed out names and their ids. We can look for shows
+# attributed to the first set and re-attribute them to the second set.
+#-------------------------------------------------------------------------------
+print "Changing host associations for shows with two hosts\n";
+foreach my $double ( sort( keys(%doubles) ) ) {
+ print "Processing $double\n";
+ my ( $doubleid, @newids ) = (
+ $doubles{$double}->{double},
+ values( %{ $doubles{$double}->{singles} } )
+ );
+ print " Original id: $doubleid\n";
+ print " Replacements: ", join( ", ", @newids ), "\n";
+
+ #
+ # Find shows marked as belonging to this double-host
+ #
+ $sth4->execute($doubleid);
+ if ( $dbh2->err ) {
+ die $dbh2->errstr;
+ }
+
+ #
+ # Process all the shows
+ #
+ while ( $h4 = $sth4->fetchrow_hashref ) {
+ my $eps_id = $h4->{eps_id};
+ print " Show $eps_id is ascribed to host $doubleid\n";
+
+ $dbh2->begin_work();
+
+ #
+ # Delete the xref link for the double host
+ #
+ $rv4
+ = $dbh2->do(
+ 'DELETE FROM episodes_hosts_xref WHERE episodes_id = ?',
+ undef, $eps_id );
+ if ( $dbh2->err ) {
+ warn $dbh2->errstr;
+ }
+ $rv4 = 0 if ( $rv4 eq '0E0' );
+
+ if ( defined($rv4) ) {
+ print " Deleted entry from episodes_hosts_xref for $eps_id\n";
+ }
+ else {
+ warn "Problem deleting from episodes_hosts_xref for $eps_id\n";
+ }
+
+ #
+ # Add links for the single hosts
+ #
+ foreach my $hid (@newids) {
+ $rv4 = $dbh2->do( 'INSERT INTO episodes_hosts_xref VALUES (?,?)',
+ undef, $eps_id, $hid );
+ if ( $dbh2->err ) {
+ warn $dbh2->errstr;
+ }
+ $rv4 = 0 if ( $rv4 eq '0E0' );
+
+ if ( defined($rv4) ) {
+ print " Added entry to episodes_hosts_xref values ",
+ "$eps_id,$hid\n";
+ }
+ else {
+ warn "Problem adding to episodes_hosts_xref values "
+ . "$eps_id,$hid\n";
+ }
+
+ }
+
+ #
+ # Commit the delete/inserts above
+ #
+ $dbh2->commit();
+
+ }
+ print '~' x 80, "\n";
+
+ #
+ # Delete the double host (NOTE: This will fail due to referential
+ # integrity if the DELETE above failed, so there is scope for debris to be
+ # left around)
+ #
+ $rv4 = $dbh2->do( 'DELETE FROM hosts WHERE id = ?', undef, $doubleid );
+ if ( $dbh2->err ) {
+ warn $dbh2->errstr;
+ }
+ $rv4 = 0 if ( $rv4 eq '0E0' );
+
+ if ( defined($rv4) ) {
+ print " Deleted entry from hosts for id $doubleid ($double)\n";
+ }
+ else {
+ warn "Problem deleting from hosts for id $doubleid ($double)\n";
+ }
+
+}
+
+print '-' x 80,"\n";
+
+exit;
+
+#=== FUNCTION ================================================================
+# NAME: save_hostid
+# PURPOSE: Saves the host id after searching for the key in the %doubles
+# hash
+# PARAMETERS: $doubles hashref to %doubles
+# $host host key
+# $hostid host id number
+# RETURNS: Nothing
+# DESCRIPTION: Searches the %doubles hash for particular keys in the
+# 'singles' sub-hash. If found saves the corresponding host id
+# there.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub save_hostid {
+ my ( $doubles, $host, $hostid ) = @_;
+
+ foreach my $key ( keys(%$doubles) ) {
+ if ( exists( $doubles->{$key}->{singles}->{$host} ) ) {
+ $doubles->{$key}->{singles}->{$host} = $hostid;
+ }
+ }
+}
+
+# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
+
diff --git a/Database/find_series b/Database/find_series
new file mode 100755
index 0000000..4431c9b
--- /dev/null
+++ b/Database/find_series
@@ -0,0 +1,397 @@
+#!/usr/bin/env perl
+#===============================================================================
+#
+# FILE: find_series
+#
+# USAGE: ./find_series
+#
+# DESCRIPTION: Gathers information from the HPR database to assist with the
+# process of placing episodes into series groups
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: ---
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.0.5
+# CREATED: 2014-04-25 17:11:21
+# REVISION: 2016-06-15 15:51:55
+#
+#===============================================================================
+
+use v5.16;
+use strict;
+use warnings;
+use utf8;
+
+use Config::General;
+use List::MoreUtils qw(uniq);
+use YAML::XS qw{LoadFile};
+
+use DBI;
+
+use Data::Dumper;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.0.5';
+
+#
+# Various constants
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+( my $DIR = $0 ) =~ s|/?[^/]*$||mx;
+$DIR = '.' unless $DIR;
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+#
+# Constants and other declarations
+#
+my $basedir = "$ENV{HOME}/HPR/Database";
+my $configfile = "$basedir/.hpr_db.cfg";
+my $ignorefile = "$basedir/.$PROG.yml";
+
+my $file_template = "${PROG}_%d.out";
+
+my ( $dbh, $sth1, $h1 );
+my ( @ignore, $title, @words, $pair );
+my ( %eps, %tags, @taglist, %single_words, %double_words );
+my ( $phase, $outfile, $outfh );
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#
+# Load YAML ignore list
+#
+my $yaml = LoadFile($ignorefile);
+@ignore = uniq( @{ $yaml->{ignore} } );
+
+#
+# Load database configuration data
+#
+my $conf = Config::General->new(
+ -ConfigFile => $configfile,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %config = $conf->getall();
+
+#-------------------------------------------------------------------------------
+# Connect to the database
+#-------------------------------------------------------------------------------
+my $dbhost = $config{database}->{host} // '127.0.0.1';
+my $dbport = $config{database}->{port} // 3306;
+my $dbname = $config{database}->{name};
+my $dbuser = $config{database}->{user};
+my $dbpwd = $config{database}->{password};
+$dbh = DBI->connect( "dbi:mysql:host=$dbhost;port=$dbport;database=$dbname",
+ $dbuser, $dbpwd, { AutoCommit => 1 } )
+ or die $DBI::errstr;
+
+#
+# Enable client-side UTF8
+#
+$dbh->{mysql_enable_utf8} = 1;
+
+#
+# Prepare to collect episode titles with the series it's related to
+#
+$sth1 = $dbh->prepare(
+ q{SELECT
+ eps.id AS eps_id,
+ eps.date,
+ eps.title,
+ eps.duration,
+ eps.summary,
+ eps.notes,
+ eps.hostid,
+ eps.series,
+ eps.explicit,
+ eps.license,
+ eps.tags,
+ eps.version,
+ eps.downloads,
+ eps.valid AS eps_valid,
+ ms.id AS ms_id,
+ ms.name,
+ ms.description,
+ ms.private,
+ ms.image,
+ ms.valid AS ms_valid
+ FROM eps
+ JOIN miniseries ms ON eps.series = ms.id
+ WHERE eps.valid = 1}
+);
+$sth1->execute;
+if ( $dbh->err ) {
+ warn $dbh->errstr;
+}
+
+#
+# Generate an output file for phase 1
+#
+$phase = 1;
+newOutput( \$outfh, $file_template, $phase++ );
+print $outfh "Show (Series) Title\n";
+
+#-------------------------------------------------------------------------------
+# Walk through the episode/series list from the database and build structures
+# from them.
+#-------------------------------------------------------------------------------
+# The structures built are:
+#
+# %eps - a hash indexed by the episode number with an arrayref as the
+# value. The arrayref starts with the episode title and is followed by the
+# important words from the title in lower case. The term "important" means
+# that the word must consist of the allowed characters and not be in the
+# 'ignore' list.
+#
+# %tags - a hash indexed by the episode number with a CSV string of tags
+# associated with the episode as the value.
+#
+# %single_words - a hash indexed by one of the words collected from the title
+# (see %eps above). The value of each element is complex. It consists of an
+# arrayref, the first element of which is a count of the succeeding elements.
+# The next N elements are arrayrefs each of which contains two elements: an
+# episode number and an episode title. Here is an example in Data::Dumper
+# format:
+#
+# 'pre-ibm' => [
+# 2,
+# [
+# 687,
+# 'pre-IBM PC computer history 1'
+# ],
+# [
+# 691,
+# 'pre-IBM PC computer history 2'
+# ]
+# ],
+#
+#-------------------------------------------------------------------------------
+while ( $h1 = $sth1->fetchrow_hashref ) {
+ #
+ # Report what we found
+ #
+ printf $outfh "%4d: (%-2d) %s\n", $h1->{eps_id}, $h1->{ms_id},
+ $h1->{title};
+
+ #
+ # Skip this episode if it already has a series
+ #
+ next if $h1->{series} > 0;
+
+ #
+ # Save this episode for later
+ #
+ $eps{ $h1->{eps_id} } = [ $h1->{title} ];
+
+ #
+ # Save tag details
+ #
+ $tags{ $h1->{eps_id} } = $h1->{tags};
+
+ #
+ # Strip any trailing full stop, and chop the title into words
+ #
+ ( $title = $h1->{title} ) =~ s/\.$//;
+ @words = split( /\s+/, $title );
+
+ #
+ # Remove all unwanted characters and force to lowercase (use 'fc' for case
+ # folding since it's aware of character sets)
+ #
+ foreach my $word (@words) {
+ $word =~ s{[^a-zA-Z./_-]}{}g;
+ $word = fc($word);
+ }
+
+ #
+ # Clean up the word list after removing unwanted characters
+ #
+ @words = grep { $_ !~ /^-*$/ } @words;
+
+ #
+ # Extract pairs of words before they're made unique and make a hash
+ # pointing to the episodes they originated from
+ #
+ for (my $i = 0; $i < $#words; $i++) {
+ $pair = "$words[$i] $words[$i+1]";
+
+ if ( exists( $double_words{$pair} ) ) {
+ $double_words{$pair}->[0] += 1;
+ push( @{ $double_words{$pair} },
+ [ $h1->{eps_id}, $h1->{title} ] );
+ }
+ else {
+ $double_words{$pair} = [ 1, [ $h1->{eps_id}, $h1->{title} ] ];
+ }
+ }
+
+ #
+ # Make the word list unique
+ #
+ @words = uniq(@words);
+
+ #
+ # Walk the tidied single word list
+ #
+ foreach my $word (@words) {
+ #
+ # Ignore very short words and words in the ignore list
+ #
+ next if length($word) < 2;
+ next if grep( /^$word$/, @ignore );
+
+ #
+ # Save this word in the episodes hash
+ #
+ push( @{ $eps{ $h1->{eps_id} } }, $word );
+
+ #
+ # If the word is not known initialise the entry containing an arrayref
+ # with a counter and another arrayref with the saved episode number
+ # and title. If it's known, increment the counter and stash the
+ # episode details as another arrayref.
+ #
+ if ( exists( $single_words{$word} ) ) {
+ $single_words{$word}->[0] += 1;
+ push( @{ $single_words{$word} },
+ [ $h1->{eps_id}, $h1->{title} ] );
+ }
+ else {
+ $single_words{$word} = [ 1, [ $h1->{eps_id}, $h1->{title} ] ];
+ }
+ }
+}
+
+#
+# We've finished with the database
+#
+$dbh->disconnect;
+
+#-------------------------------------------------------------------------------
+# Done the first pass, prepare for the next
+#-------------------------------------------------------------------------------
+newOutput( \$outfh, $file_template, $phase++ );
+
+#
+# Process the saved data in increasing order of the frequency. Print the word
+# and its frequency and follow that by the stashed episode details in the
+# order we saw them
+#
+foreach my $key (
+ sort { $single_words{$a}->[0] <=> $single_words{$b}->[0] }
+ sort( keys(%single_words) )
+ )
+{
+ if ( $single_words{$key}->[0] > 3 ) {
+ printf $outfh "%15s: %s\n", $key, $single_words{$key}->[0];
+ for ( my $i = 1; $i <= $single_words{$key}->[0]; $i++ ) {
+ printf $outfh "%17s%4d: %s", ' ', @{ $single_words{$key}->[$i] };
+ @taglist
+ = split( /\s*,\s*/, $tags{ $single_words{$key}->[$i]->[0] } );
+ if (@taglist) {
+ print $outfh " [", join( ",", @taglist ), "]\n";
+ }
+ else {
+ print $outfh "\n";
+ }
+ }
+ print $outfh "\n";
+ }
+}
+
+#-------------------------------------------------------------------------------
+# Done the second pass, prepare for the next
+#-------------------------------------------------------------------------------
+newOutput( \$outfh, $file_template, $phase++ );
+
+#
+# Look through the collected data from the point of view of the episode, list
+# all the (relevant) words in the title in order and report their frequencies
+#
+for my $key ( sort { $a <=> $b } keys(%eps) ) {
+ printf $outfh "%4d: %s\n", $key, $eps{$key}->[0];
+ for ( my $i = 1; $i < scalar( @{ $eps{$key} } ); $i++ ) {
+ my $word = $eps{$key}->[$i];
+ printf $outfh " %15s %d\n", $word, $single_words{$word}->[0];
+ }
+ print $outfh "\n";
+}
+
+#-------------------------------------------------------------------------------
+# Done the third pass, prepare for the next
+#-------------------------------------------------------------------------------
+newOutput( \$outfh, $file_template, $phase++ );
+
+#
+# So the pairs of words we collected earlier might show something interesting.
+# Let's see.
+#
+foreach my $key (
+ sort { $double_words{$a}->[0] <=> $double_words{$b}->[0] }
+ sort( keys(%double_words) )
+ )
+{
+ if ( $double_words{$key}->[0] > 3 ) {
+ printf $outfh "%15s: %s\n", $key, $double_words{$key}->[0];
+ for ( my $i = 1; $i <= $double_words{$key}->[0]; $i++ ) {
+ printf $outfh "%17s%4d: %s", ' ', @{ $double_words{$key}->[$i] };
+ @taglist
+ = split( /\s*,\s*/, $tags{ $double_words{$key}->[$i]->[0] } );
+ if (@taglist) {
+ print $outfh " [", join( ",", @taglist ), "]\n";
+ }
+ else {
+ print $outfh "\n";
+ }
+ }
+ print $outfh "\n";
+ }
+}
+
+
+close($outfh);
+
+exit;
+
+#=== FUNCTION ================================================================
+# NAME: newOutput
+# PURPOSE: Generate a new output file
+# PARAMETERS: $fh a scalar ref pointing to a variable to hold a
+# file handle
+# $template a string suitable for sprintf for defining the
+# name of the output file
+# $phase an integer to be incorporated into the output
+# file name
+# RETURNS: Nothing
+# DESCRIPTION: Closes any existing file and opens a new one with the same
+# file handle. The name of the file is derived from the template
+# and the phase number.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub newOutput {
+ my ( $fh, $template, $phase ) = @_;
+
+ if ($$fh) {
+ close($$fh) if ( tell($$fh) > -1 );
+ }
+
+ my $outfile = sprintf( $template, $phase );
+ open( $$fh, '>:encoding(UTF-8)', $outfile )
+ or die "Unable to open $outfile\n";
+
+ return;
+}
+
+# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
diff --git a/Database/fix_urls b/Database/fix_urls
new file mode 100755
index 0000000..c2edf96
--- /dev/null
+++ b/Database/fix_urls
@@ -0,0 +1,708 @@
+#!/usr/bin/env perl
+#===============================================================================
+#
+# FILE: fix_urls
+#
+# USAGE: ./fix_urls [-help] [-doc] [-debug=N] [-dry-run] [-config=FILE]
+# [-limit=N] tablename
+#
+# DESCRIPTION: Scans the HPR database to find URLs which do not have the
+# 'https:' scheme and correct them.
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: Had to revert to MySQL because of a problem with DBD::MariaDB
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.0.5
+# CREATED: 2021-12-29 13:57:28
+# REVISION: 2022-02-28 10:51:27
+#
+#===============================================================================
+
+use v5.16;
+use strict;
+use warnings;
+use utf8;
+use feature qw{ postderef say signatures state };
+no warnings qw{ experimental::postderef experimental::signatures };
+
+use Getopt::Long;
+use Pod::Usage;
+
+use Config::General;
+use DBI;
+
+use SQL::Abstract;
+
+use Log::Handler;
+use Log::Handler::Output::File;
+
+use Data::Dumper;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.0.5';
+
+#
+# Script and directory names
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+( my $DIR = $0 ) =~ s|/?[^/]*$||mx;
+$DIR = '.' unless $DIR;
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+#
+# Constants and other declarations
+#
+my $basedir = "$ENV{HOME}/HPR/Database";
+my $configfile = "$basedir/.hpr_db.cfg";
+my $logfile = "$basedir/${PROG}.log";
+
+my $dbh;
+
+ #<<< do not let perltidy change formatting
+#
+# The database tables we'll search and how to do what we need.
+#
+# main_key - the name of a table (with a hashref as the value)
+# index the database field we're using to find a row
+# fields fields we may need to edit (an arrayref)
+# logfields fields we will log (an arrayref)
+# mainQ the query that finds all rows needing work
+# rowQ a query for finding a row using the index value
+#
+my %tables = (
+ 'comments' => {
+ index => 'id',
+ fields => [qw{comment_title comment_text}],
+ logfields => [qw{eps_id comment_title}],
+ mainQ => q{
+ select id
+ from comments
+ where comment_text regexp 'http://[^[:space:]]'
+ or comment_title regexp 'http://[^[:space:]]'
+ order by id},
+ rowQ => q{select * from comments where id = ?},
+ },
+ 'eps' => {
+ index => 'id',
+ fields => [qw{title summary notes}],
+ logfields => [qw{title date}],
+ mainQ => q{
+ select id
+ from eps
+ where title regexp 'http://[^[:space:]]'
+ or summary regexp 'http://[^[:space:]]'
+ or notes regexp 'http://[^[:space:]]'
+ order by id
+ },
+ rowQ => q{select * from eps where id = ?},
+ },
+ 'hosts' => {
+ index => 'hostid',
+ fields => [qw{profile}],
+ logfields => [qw{host email}],
+ mainQ => q{
+ select hostid
+ from hosts
+ where profile regexp 'http://[^[:space:]]'
+ order by hostid},
+ rowQ => q{select * from hosts where hostid = ?},
+ },
+ 'miniseries' => {
+ index => 'id',
+ fields => [qw{description}],
+ logfields => [qw{name}],
+ mainQ => q{
+ select id
+ from miniseries
+ where description regexp 'http://[^[:space:]]'
+ order by id},
+ rowQ => q{select * from miniseries where id = ?},
+ },
+);
+ #>>>
+my @table_names = keys(%tables);
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#-------------------------------------------------------------------------------
+# Options and arguments
+#-------------------------------------------------------------------------------
+my $DEF_DEBUG = 0;
+my $DEF_LIMIT = 0;
+
+#
+# Process options
+#
+my %options;
+Options( \%options );
+
+#
+# Default help
+#
+pod2usage( -msg => "$PROG version $VERSION\n", -verbose => 0, -exitval => 1 )
+ if ( $options{'help'} );
+
+#
+# Full documentation if requested with -doc
+#
+pod2usage(
+ -msg => "$PROG version $VERSION\n",
+ -verbose => 2,
+ -exitval => 1,
+ -noperldoc => 0,
+) if ( $options{'doc'} );
+
+#
+# Collect options
+#
+my $DEBUG = ( defined( $options{debug} ) ? $options{debug} : $DEF_DEBUG );
+
+my $cfgfile
+ = ( defined( $options{config} ) ? $options{config} : $configfile );
+
+my $dry_run = ( defined( $options{'dry-run'} ) ? $options{'dry-run'} : 0 );
+my $verbose = ( defined( $options{'verbose'} ) ? $options{'verbose'} : 0 );
+
+my $limit = ( defined( $options{limit} ) ? $options{limit} : $DEF_LIMIT );
+$limit = abs($limit);
+
+#
+# Sanity check
+#
+die "Unable to find config file '$cfgfile'\n" unless ( -e $cfgfile );
+
+#
+# Table choice
+#
+my $table = shift;
+die "Database table not specified\n" unless $table;
+die "Not a valid table name: $table\n"
+ unless ( grep { $_ =~ /^$table$/ } @table_names );
+
+#
+# Load configuration data
+#
+my $conf = new Config::General(
+ -ConfigFile => $cfgfile,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %config = $conf->getall();
+
+#-------------------------------------------------------------------------------
+# Connect to the database
+#-------------------------------------------------------------------------------
+my $dbhost = $config{database}->{host} // '127.0.0.1';
+my $dbport = $config{database}->{port} // 3306;
+my $dbname = $config{database}->{name};
+my $dbuser = $config{database}->{user};
+my $dbpwd = $config{database}->{password};
+#$dbh = DBI->connect( "DBI:MariaDB:host=$dbhost;port=$dbport;database=$dbname",
+# $dbuser, $dbpwd, { AutoCommit => 1, RaiseError => 1 } )
+# or die $DBI::errstr;
+
+$dbh = DBI->connect( "dbi:mysql:host=$dbhost;port=$dbport;database=$dbname",
+ $dbuser, $dbpwd, { AutoCommit => 1 } )
+ or die $DBI::errstr;
+
+#
+# Enable client-side UTF8
+#
+$dbh->{mysql_enable_utf8} = 1;
+
+
+#-------------------------------------------------------------------------------
+# Set up logging keeping the default log layout except for the date
+#-------------------------------------------------------------------------------
+my $log = Log::Handler->new();
+
+$log->add(
+ file => {
+ timeformat => "%Y-%m-%d %H:%M:%S",
+ filename => $logfile,
+ maxlevel => 7,
+ minlevel => 0,
+ utf8 => 1,
+ }
+);
+
+$log->info("Configuration file $cfgfile");
+$log->info("Processing table $table");
+$log->info("Dry-run mode") if ($dry_run);
+
+process_table( $dbh, \%tables, $table, $dry_run, $limit );
+
+exit;
+
+#=== FUNCTION ================================================================
+# NAME: process_table
+# PURPOSE: Processes a table to change any instances of 'http://' to
+# 'https://'
+# PARAMETERS: $dbh open database handle
+# $rtables reference to the %tables hash
+# $table name of the table being processed
+# $dry_run Boolean showing whether this is dry run or not
+# $limit number of updates to apply, 0 = no limit
+# RETURNS:
+# DESCRIPTION:
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub process_table {
+ my ( $dbh, $rtables, $table, $dry_run, $limit ) = @_;
+
+ my ( $sth1, $sth2, $h );
+ my ( $work_query, @work, $row_query, @fields, @logfields, $new, $index );
+ my ( $sql, $stmt, @bind, %fieldvals, %where );
+ my ( $workcount, $count, $updates, $logfmt );
+
+ #
+ # Prepare to build SQL
+ #
+ $sql = SQL::Abstract->new;
+
+ $count = 0;
+ $updates = 0;
+
+ #
+ # Find any rows in need of work as an array/list of the index values. The
+ # selectall_arrayref returns a reference to an array containing arrayrefs,
+ # so the 'map' flattens that structure.
+ #
+ $work_query = $rtables->{$table}->{mainQ};
+ @work = map { $_->[0] } @{ $dbh->selectall_arrayref($work_query) };
+
+ $workcount = scalar(@work);
+ printf "Number of rows requiring work: %d\n", $workcount if $verbose;
+
+ _debug( $DEBUG >= 1, "Number of rows requiring work: $workcount" )
+ unless $verbose;
+ _debug( $DEBUG >= 2,
+ "Rows requiring work: " . join( ",", @work ) . "\n" );
+
+ #
+ # If there's nothing to do say so and leave
+ #
+ unless (@work) {
+ print "Nothing to do to table '$table'!\n";
+ return;
+ }
+
+ #
+ # Pull configuration values from the hash
+ #
+ $row_query = $rtables->{$table}->{rowQ};
+ @fields = @{ $rtables->{$table}->{fields} };
+ @logfields = @{ $rtables->{$table}->{logfields} };
+ $index = $rtables->{$table}->{index};
+
+ _debug( $DEBUG >= 3, "\$row_query = $row_query" );
+ _debug( $DEBUG >= 3, "\@fields = " . join( ",", @fields ) );
+ _debug( $DEBUG >= 3, "\$index = $index" );
+
+ #
+ # Prepare for logging by making a format string for sprintf
+ #
+ $logfmt = 'Updated row with ';
+ $logfmt .= join( ", ", map {"$_ = '%s'"} $index, @logfields );
+
+ _debug( $DEBUG >= 3, "\$logfmt = $logfmt" );
+
+ #
+ # Set up query for the next eligible row
+ #
+ $sth1 = $dbh->prepare($row_query) or die $DBI::errstr;
+ if ( $dbh->err ) {
+ die $dbh->errstr;
+ }
+
+ #
+ # Loop through rows needing work
+ #
+ foreach my $pkey (@work) {
+ #
+ # The row is indexed by the per-table key
+ #
+ $sth1->execute($pkey) or die $DBI::errstr;
+ if ( $dbh->err ) {
+ die $dbh->errstr;
+ }
+
+ #
+ # Grab the row (there must be only one with this index)
+ #
+ if ( $h = $sth1->fetchrow_hashref ) {
+ #
+ # Set up the 'where' options for SQL::Abstract
+ #
+ %where = ( $index => { '=', $pkey } );
+ _debug( $DEBUG >= 3, Dumper( \%where ) );
+
+ #
+ # Work on the fields we know might contain HTML
+ #
+ for my $field (@fields) {
+ #
+ # Perform the change
+ #
+ ( $new = $h->{$field} ) =~ s{\bhttp://(\S)}{https://$1}ig;
+
+ #
+ # Set up SQL::Abstract parameters
+ #
+ $fieldvals{$field} = $new;
+ }
+
+ #
+ # Use SQL::Abstract to make the statement and the bind parameters
+ #
+ ( $stmt, @bind ) = $sql->update( $table, \%fieldvals, \%where );
+
+ #
+ # Do the change or report it depending on dry-run mode
+ #
+ unless ($dry_run) {
+ $sth2 = $dbh->prepare($stmt) or die $DBI::errstr;
+ $sth2->execute(@bind) or die $DBI::errstr;
+ $log->info( sprintf( $logfmt, $pkey, @{$h}{@logfields} ) );
+ printf $logfmt. "\n", $pkey, @{$h}{@logfields} if $verbose;
+ $updates++;
+ }
+ else {
+ print "No change made in dry-run mode\n";
+ if ($verbose) {
+ print "SQL: $stmt\n";
+ print "Bind> ", join( "\nBind> ", @bind ), "\n";
+ print '-' x 80, "\n";
+ }
+ }
+ }
+
+ #
+ # Apply the limit if appropriate
+ #
+ $count++;
+ unless ( $limit == 0 ) {
+ last if ( $count >= $limit );
+ }
+
+ }
+
+ unless ($dry_run) {
+ $log->info("Number of updates = $updates");
+ if ($verbose) {
+ print "Number of updates = $updates\n";
+ printf "Remaining rows needing attention: %d\n",
+ $workcount - $updates;
+ }
+ }
+
+}
+
+#=== FUNCTION ================================================================
+# NAME: concat
+# PURPOSE: Reimplementation of join but with any undefined or empty
+# arguments removed
+# PARAMETERS: $sep The string to be used to separate elements in
+# the result
+# [variable args] Any number of arguments to be joined together
+# with the separator
+# RETURNS: The concatenated arguments
+# DESCRIPTION: Giving 'join' an array that may contain undefined elements will
+# result in empty results in the output string and error
+# messages as the undefined elements are processed. Giving it
+# empty string elements will result in dangling separators in
+# the output. This routine removes the undefined and empty
+# elements before joining the rest.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO:
+#===============================================================================
+sub concat {
+ my $sep = shift;
+
+ my @args = grep { defined($_) && length($_) > 0 } @_;
+
+ return join( $sep, @args );
+}
+
+#=== FUNCTION ================================================================
+# NAME: _debug
+# PURPOSE: Prints debug reports
+# PARAMETERS: $active Boolean: 1 for print, 0 for no print
+# $message Message to print
+# RETURNS: Nothing
+# DESCRIPTION: Outputs a message if $active is true. It removes any trailing
+# newline and then adds one in the 'print' to the caller doesn't
+# have to bother. Prepends the message with 'D> ' to show it's
+# a debug message.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub _debug {
+ my ( $active, $message ) = @_;
+
+ chomp($message);
+ print "D> $message\n" if $active;
+}
+
+#=== FUNCTION ================================================================
+# NAME: Options
+# PURPOSE: Processes command-line options
+# PARAMETERS: $optref Hash reference to hold the options
+# RETURNS: Undef
+# DESCRIPTION:
+# THROWS: no exceptions
+# COMMENTS: none
+# SEE ALSO: n/a
+#===============================================================================
+sub Options {
+ my ($optref) = @_;
+
+ my @options = (
+ "help", "doc", "debug=i", "verbose!",
+ "dry-run!", "config=s", "limit=i",
+ );
+
+ if ( !GetOptions( $optref, @options ) ) {
+ pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 );
+ }
+
+ return;
+}
+
+__END__
+
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+# Application Documentation
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+#{{{
+
+
+=head1 NAME
+
+fix_urls - Fixes 'http://' urls in the HPR database
+
+=head1 VERSION
+
+This documentation refers to fix_urls version 0.0.5
+
+
+=head1 USAGE
+
+ ./fix_urls [-help] [-doc] [-debug=N] [-[no]dry-run] [-[no]verbose]
+ [-config=FILE] [-limit=N] tablename
+
+ fix_urls -help
+ fix_urls -doc
+
+ fix_urls -limit=10 -dry-run comments
+ fix_urls -limit=10 comments
+ fix_urls -limit=10 -verbose comments
+
+ fix_urls -config=.hpr_livedb.cfg -debug=1 -dry-run -limit=1 comments
+
+=head1 REQUIRED ARGUMENTS
+
+=over 4
+
+=item B
+
+The mandatory argument required by the script is the name of the table to
+process. The choices are:
+
+ comments
+ eps
+ hosts
+ miniseries
+
+=back
+
+=head1 OPTIONS
+
+=over 8
+
+=item B<-help>
+
+Prints a brief help message describing the usage of the program, and then exits.
+
+=item B<-doc>
+
+Displays the entirety of the documentation (using a pager), and then exits. To
+generate a PDF version use:
+
+ pod2pdf fix_urls --out=fix_urls.pdf
+
+=item B<-debug=N>
+
+Selects a level of debugging. Debug information consists of a line or series
+of lines prefixed with the characters 'D>':
+
+=over 4
+
+=item B<0>
+
+No debug output is generated: this is the default
+
+=item B<1>
+
+Displays the number of updates required in a table.
+
+=item B<2>
+
+As for level 1, and also displays the primary key values of all rows requiring
+work in the table.
+
+=item B<3>
+
+As for level 2, and also displays some internal values for verification.
+
+=back
+
+=item B<-[no]dry-run>
+
+Controls whether the program runs in a mode where it performs database
+updates. When enabled the details of the updates to be performed are shown,
+otherwise the updates are applied. The default B<-nodry-run> allows the
+program to perform the changes.
+
+=item B<-[no]verbose>
+
+Normally very little is reported by the script, although details of which rows
+have been changed are logged. When B<-verbose> is selected more information
+about the number of rows needing work, the updates performed (or which would
+have been performed) and how many changes were made is reported.
+
+=item B<-limit=N>
+
+This option allows the number of rows in the chosen table to be limited during
+a B<-dry-run> pass or an update pass. If omitted, or if a value of zero is
+given, then all eligible rows are processed.
+
+=item B<-config=FILE>
+
+This option allows an alternative configuration file to be used. This file
+defines the location of the database, its port, its name and the username and
+password to be used to access it. This feature was added to allow the script
+to access alternative databases or the live database over an SSH tunnel.
+
+See the CONFIGURATION AND ENVIRONMENT section below for the file format.
+
+If the option is omitted the default file is used: B<.hpr_db.cfg>
+
+=back
+
+=head1 DESCRIPTION
+
+The B script performs edits on fields in tables in the HPR database.
+As written, the purpose is to change all occurrences of 'http://' to
+'https://', though it could be used for other tasks. It is not designed to be
+easily changeable from one to another, but the code can be changed to do this
+if needed.
+
+A single table is processed in each run, and the number of rows may be limited
+if required by using the B<-limit=N> option.
+
+The eligible tables are defined in a hash structure B<%tables> which defines
+the fields to be processed and the queries needed to search for all rows
+requiring work and to get a particular row to work on. It also defines which
+fields are to be reported in the log file.
+
+A log file is appended to when the script is run, which has the name
+B in the same directory as the script.
+
+=head1 DIAGNOSTICS
+
+=over 4
+
+=item B
+
+Type: fatal
+
+The configuration file in the B<-config=FILE> option cannot be found.
+
+=item B
+
+Type: fatal
+
+The mandatory table name argument was not provided.
+
+=item B
+
+Type: fatal
+
+The mandatory table name argument specified an unknown table name.
+
+=item B<[DBI error messages]>
+
+Type: fatal
+
+Generated when a database interface error has been detected, such as failure
+to connect to the database or failure to prepare or execute a query.
+
+=back
+
+=head1 CONFIGURATION AND ENVIRONMENT
+
+The script obtains the credentials it requires to open a local copy of the HPR
+database from a configuration file. The name of the file it expects is
+B<.hpr_db.cfg> in the directory holding the script. This configuration file
+can be overridden using the B<-config=FILE> option as described above.
+
+The configuration file format is as follows:
+
+
+ host = 127.0.0.1
+ port = PORT
+ name = DATABASE
+ user = USERNAME
+ password = PASSWORD
+
+
+=head1 DEPENDENCIES
+
+ Config::General
+ DBI
+ Data::Dumper
+ Getopt::Long
+ Log::Handler
+ Pod::Usage
+ SQL::Abstract
+
+=head1 BUGS AND LIMITATIONS
+
+There are no known bugs in this module.
+Please report problems to ()
+Patches are welcome.
+
+=head1 AUTHOR
+
+ ()
+
+=head1 LICENCE AND COPYRIGHT
+
+Copyright (c) 2021-2020 Dave Morriss (Dave.Morriss@gmail.com). All rights reserved.
+
+This module is free software; you can redistribute it and/or
+modify it under the same terms as Perl itself. See perldoc perlartistic.
+
+=cut
+
+#}}}
+
+# [zo to open fold, zc to close]
+
+# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
+
diff --git a/Database/generate_tag_reports b/Database/generate_tag_reports
new file mode 100755
index 0000000..2992c9f
--- /dev/null
+++ b/Database/generate_tag_reports
@@ -0,0 +1,75 @@
+#!/bin/bash -
+#===============================================================================
+#
+# FILE: generate_tag_reports
+#
+# USAGE: ./generate_tag_reports
+#
+# DESCRIPTION: Runs 'report_missing_tags' and 'make tags' to generate the
+# pages for the HPR website: report_missing_tags.php and
+# tags.php
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: ---
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.0.5
+# CREATED: 2021-01-02 13:02:47
+# REVISION: 2022-09-09 08:05:47
+#
+#===============================================================================
+
+set -o nounset # Treat unset variables as an error
+
+SCRIPT=${0##*/}
+VERSION="0.0.5"
+
+BASEDIR="$HOME/HPR/Database"
+cd "$BASEDIR" || { echo "$SCRIPT: Failed to cd to $BASEDIR"; exit 1; }
+
+REPORT="$BASEDIR/make_tag_index"
+LIVECFG="$BASEDIR/.hpr_livedb.cfg" # soft link
+# PHPREP="$BASEDIR/report_missing_tags.php"
+# CSVREP1="$BASEDIR/tag_summary_actions.csv"
+# CSVREP2="$BASEDIR/tags_shows.csv"
+# JSONREP="$BASEDIR/tag_data.json"
+# HTMLTAGS="$BASEDIR/tags.html"
+PHPTAGS="$BASEDIR/tags.php"
+
+#
+# Sanity checks
+#
+[ -e "$REPORT" ] || { echo "$SCRIPT: missing script $REPORT"; exit 1; }
+[ -e "$LIVECFG" ] || { echo "$SCRIPT: missing file $LIVECFG"; exit 1; }
+
+if ! tunnel_is_open; then
+ echo "Open the tunnel to run this script"
+ exit 1
+fi
+
+#
+# Run the main report with the default template (make_tag_index.tpl)
+#
+if $REPORT -config="$LIVECFG" -out="$PHPTAGS"; then
+ echo "$SCRIPT v$VERSION"
+ [ -e "$PHPTAGS" ] && echo "Generated $PHPTAGS"
+else
+ echo "$SCRIPT: failed to run $REPORT"
+ exit 1
+fi
+
+#
+# Generate the standalone tag list from the output of the report script
+#
+# if [[ -e $HTMLTAGS ]]; then
+# make tags
+# [ -e "$PHPTAGS" ] && echo "Generated $PHPTAGS"
+# else
+# echo "$SCRIPT: missing file $HTMLTAGS"
+# fi
+
+exit
+
+# vim: syntax=sh:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
+
diff --git a/Database/host_image b/Database/host_image
new file mode 100755
index 0000000..07f99a2
--- /dev/null
+++ b/Database/host_image
@@ -0,0 +1,179 @@
+#!/usr/bin/env perl
+#===============================================================================
+#
+# FILE: host_image
+#
+# USAGE: ./host_image
+#
+# DESCRIPTION: Collects Gravatar images for HPR hosts. This version simply
+# cycles through the list of hosts from the local copy of the
+# database and attempts to collect the Gravatar for every one
+# that has an email address and isn't marked as having a local
+# image (provided via the show upload form) in the database.
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: ---
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.0.2
+# CREATED: 2016-08-31 16:52:52
+# REVISION: 2021-10-15 21:02:52
+#
+#===============================================================================
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+
+use Config::General;
+use Digest::MD5 qw{md5_hex};
+#use Digest::MD5::File qw{file_md5_hex};
+use LWP::Simple;
+use DBI;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.0.2';
+
+#
+# Script name
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+( my $DIR = $0 ) =~ s|/?[^/]*$||mx;
+$DIR = '.' unless $DIR;
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+#
+# Constants and other declarations
+#
+my $basedir = "$ENV{HOME}/HPR/Database";
+my $configfile = "$basedir/.hpr_db.cfg";
+my $imgpath = "$basedir/www/images/hosts/%s.png";
+my $urlformat = 'https://secure.gravatar.com/avatar/%s.png?d=404&s=90';
+
+my ( $dbh, $sth1, $h1, $rv );
+my ( $host, $hostid, $email, $grav_url, $img, $res );
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#
+# Load configuration data
+#
+my $conf = new Config::General(
+ -ConfigFile => $configfile,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %config = $conf->getall();
+
+#-------------------------------------------------------------------------------
+# Connect to the database
+# 2021-10-15: moved to MariaDB
+#-------------------------------------------------------------------------------
+my $dbhost = $config{database}->{host} // '127.0.0.1';
+my $dbport = $config{database}->{port} // 3306;
+my $dbname = $config{database}->{name};
+my $dbuser = $config{database}->{user};
+my $dbpwd = $config{database}->{password};
+
+$dbh = DBI->connect( "DBI:MariaDB:host=$dbhost;port=$dbport;database=$dbname",
+ $dbuser, $dbpwd, { AutoCommit => 1, RaiseError => 1 } )
+ or die $DBI::errstr;
+
+#
+# Enable client-side UTF8 (MySQL only)
+#
+# $dbh->{mysql_enable_utf8} = 1;
+
+#-------------------------------------------------------------------------------
+# Prepare SQL for finding hosts
+#-------------------------------------------------------------------------------
+$sth1 = $dbh->prepare(
+ q{SELECT host, hostid, email FROM hosts
+ WHERE valid = '1' AND local_image = '0'
+ ORDER BY hostid ASC}
+);
+$sth1->execute;
+if ( $dbh->err ) {
+ die $dbh->errstr;
+}
+
+#-------------------------------------------------------------------------------
+# Loop through the hosts gathering gravatars
+#-------------------------------------------------------------------------------
+while ( $h1 = $sth1->fetchrow_hashref ) {
+ $host = $h1->{host};
+ $hostid = $h1->{hostid};
+ $email = $h1->{email};
+
+ #
+ # We need an email address
+ #
+ next unless ($email);
+
+ $res = fetch( $hostid, $host, $email, $urlformat, $imgpath );
+}
+
+$sth1->finish;
+
+$dbh->disconnect;
+
+exit;
+
+#=== FUNCTION ================================================================
+# NAME: fetch
+# PURPOSE: Perform the fetching and saving of a gravatar image
+# PARAMETERS: $hostid - host number from database
+# $host - host name from database
+# $email - email address from database
+# $urlformat - template for building the gravatar URL
+# $imgpath - template for building the file path
+# RETURNS: Nothing
+# DESCRIPTION: Uses LWP to collect the gravatar image using the URL
+# constructed from a template and the email address, writes it
+# to the constructed file path.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub fetch {
+ my ( $hostid, $host, $email, $urlformat, $imgpath ) = @_;
+
+ #
+ # Build the URL and the image file path
+ #
+ my $grav_url = sprintf( $urlformat, md5_hex( lc($email) ) );
+ my $img = sprintf( $imgpath, $hostid );
+
+ printf "%3d: %s (%s) %s %s - ", $hostid, $host, $email, $grav_url, $img;
+
+ #
+ # Collect the gravatar if there is one
+ #
+ my $res = getstore( $grav_url, $img );
+
+ #
+ # Remove any garbage
+ #
+ if ( $res != 200 ) {
+ print "Failed ($res)\n";
+ unlink($img);
+ return 0;
+ }
+ else {
+ print "OK\n";
+ return 1;
+ }
+}
+
+# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
+
diff --git a/Database/hosts_eps.sql b/Database/hosts_eps.sql
new file mode 100644
index 0000000..157dc09
--- /dev/null
+++ b/Database/hosts_eps.sql
@@ -0,0 +1,107 @@
+--
+-- Set up a many-to-many relationship between tables hosts and eps
+-- -----------------------------------------------------------------------------
+--
+
+--
+-- Make a table called 'new_hosts' containing a copy of the hosts table
+-- with the same structure but no data. Shrink the hostid column to int(5).
+--
+DROP TABLE IF EXISTS new_hosts;
+CREATE TABLE IF NOT EXISTS new_hosts (
+ hostid int(5) NOT NULL AUTO_INCREMENT,
+ PRIMARY KEY (hostid)
+) ENGINE=InnoDB
+ SELECT * FROM hosts WHERE 0 = 1;
+
+-- without the "double host" entries (host='Host1 and Host2')
+-- SELECT * FROM hosts where host NOT LIKE '% and %';
+
+SHOW warnings;
+
+--
+-- Make a table called 'new_eps' containing a copy of the eps table without
+-- the host-related columns but no data.
+--
+DROP TABLE IF EXISTS new_eps;
+CREATE TABLE IF NOT EXISTS new_eps (
+ id int(5) NOT NULL,
+ PRIMARY KEY (id)
+) ENGINE=InnoDB
+ SELECT
+ id,
+ date,
+ title,
+ summary,
+ notes,
+ series,
+ explicit,
+ license,
+ tags,
+ version,
+ valid
+ FROM eps WHERE 0 = 1;
+
+SHOW warnings;
+
+--
+-- Table structure for the mapping table 'hosts_eps'
+--
+DROP TABLE IF EXISTS hosts_eps;
+CREATE TABLE IF NOT EXISTS hosts_eps (
+ host_id int(5) NOT NULL
+ REFERENCES new_hosts(hostid),
+ eps_id int(5) NOT NULL
+ REFERENCES new_eps(id),
+ PRIMARY KEY hosts_eps_pk (host_id,eps_id)
+) ENGINE=InnoDB;
+
+SHOW warnings;
+
+--
+-- Populate the hosts_eps table
+--
+/*
+INSERT INTO hosts_eps (host_id, eps_id)
+ SELECT ho.hostid, eps.id
+ FROM hosts ho
+ JOIN eps ON ho.hostid = eps.hostid
+ ORDER BY ho.hostid, eps.id;
+
+SHOW warnings;
+*/
+
+--
+-- Make a view to simplify access to new_hosts and new_eps
+--
+DROP VIEW IF EXISTS hosts_with_eps;
+CREATE VIEW hosts_with_eps AS
+ SELECT
+ nh.hostid,
+ nh.host,
+ nh.email,
+ nh.profile,
+ nh.license AS host_license,
+ nh.local_image,
+ nh.valid AS host_valid,
+ ne.id,
+ ne.date,
+ ne.title,
+ ne.summary,
+ ne.notes,
+ ne.series,
+ ne.explicit,
+ ne.license AS eps_license,
+ ne.tags,
+ ne.version,
+ ne.valid AS eps_valid
+ FROM new_hosts nh
+ JOIN hosts_eps he ON (nh.hostid = he.host_id)
+ JOIN new_eps ne ON (he.eps_id = ne.id)
+ ORDER BY nh.hostid, ne.id;
+
+SHOW warnings;
+
+/*
+vim: syntax=sql ai tw=75:
+*/
diff --git a/Database/hpr_schema.pgsql b/Database/hpr_schema.pgsql
new file mode 100644
index 0000000..a5f194b
--- /dev/null
+++ b/Database/hpr_schema.pgsql
@@ -0,0 +1,336 @@
+/* =============================================================================
+ * PostgreSQL Schema - designs for a new HPR database
+ *
+ * File: hpr_schema.pgsql
+ * Created: 2017-03-15
+ * Updated: 2017-10-16
+ * =============================================================================
+ */
+
+/* ------------------------------------------------------------------------------
+ * Drop everything to start with. The order is important because of the
+ * relations between tables. Also, some items are dependent and go with the
+ * tables.
+ * ------------------------------------------------------------------------------
+ */
+DROP TABLE IF EXISTS comments CASCADE;
+DROP TABLE IF EXISTS episodes CASCADE;
+DROP TABLE IF EXISTS episodes_hosts_xref CASCADE;
+DROP TABLE IF EXISTS episodes_series_xref CASCADE;
+DROP TABLE IF EXISTS episodes_tags_xref CASCADE;
+DROP TABLE IF EXISTS hosts CASCADE;
+DROP TABLE IF EXISTS licenses CASCADE;
+DROP TABLE IF EXISTS series CASCADE;
+DROP TABLE IF EXISTS tags CASCADE;
+
+-- DROP INDEX IF EXISTS episode_release_date_key;
+
+DROP SEQUENCE IF EXISTS comment_seq;
+DROP SEQUENCE IF EXISTS episode_seq;
+DROP SEQUENCE IF EXISTS host_seq;
+DROP SEQUENCE IF EXISTS license_seq;
+DROP SEQUENCE IF EXISTS series_seq;
+DROP SEQUENCE IF EXISTS tag_seq;
+
+-- DROP VIEW IF EXISTS eht_view;
+
+/* ------------------------------------------------------------------------------
+ * Table 'licenses' - licenses relating to episodes (needed because 'hosts'
+ * references it)
+ * ------------------------------------------------------------------------------
+ */
+CREATE SEQUENCE license_seq;
+
+ALTER TABLE license_seq
+ OWNER TO hpradmin;
+
+CREATE TABLE licenses (
+ id integer default nextval('license_seq') PRIMARY KEY,
+ short_name varchar(11) NOT NULL UNIQUE,
+ long_name varchar(40) NOT NULL,
+ url varchar(80) NOT NULL
+);
+
+ALTER TABLE licenses
+ OWNER TO hpradmin;
+
+/*
+ * Load the table since it's quite short. Don't set the id to ensure the
+ * sequence is updated properly.
+ */
+INSERT INTO licenses (short_name, long_name, url) VALUES
+('CC-0', 'Public Domain Dedication', 'http://creativecommons.org/publicdomain/zero/1.0/'),
+('CC-BY', 'Attribution', 'http://creativecommons.org/licenses/by/4.0'),
+('CC-BY-SA', 'Attribution-ShareAlike', 'http://creativecommons.org/licenses/by-sa/3.0'),
+('CC-BY-ND', 'Attribution-NoDerivs', 'http://creativecommons.org/licenses/by-nd/4.0'),
+('CC-BY-NC', 'Attribution-NonCommercial', 'http://creativecommons.org/licenses/by-nc/4.0'),
+('CC-BY-NC-SA', 'Attribution-NonCommercial-ShareAlike', 'http://creativecommons.org/licenses/by-nc-sa/4.0'),
+('CC-BY-NC-ND', 'Attribution-NonCommercial-NoDerivs', 'http://creativecommons.org/licenses/by-nc-nd/4.0');
+
+/* ------------------------------------------------------------------------------
+ * Table 'episodes' - HPR shows
+ * ------------------------------------------------------------------------------ */
+CREATE SEQUENCE episode_seq;
+
+ALTER TABLE episode_seq
+ OWNER TO hpradmin;
+
+CREATE TABLE episodes (
+ id integer default nextval('episode_seq') PRIMARY KEY,
+ release_date date NOT NULL,
+ title varchar(100) NOT NULL,
+ summary varchar(100),
+ notes text NOT NULL,
+ explicit smallint NOT NULL DEFAULT '1',
+ license varchar(11) NOT NULL DEFAULT 'CC-BY-SA'
+ REFERENCES licenses (short_name),
+ duration integer NOT NULL DEFAULT 0,
+ downloads integer NOT NULL DEFAULT 0
+);
+
+ALTER TABLE episodes
+ OWNER TO hpradmin;
+
+CREATE INDEX episode_release_date_key
+ ON episodes
+ USING btree
+ (release_date);
+
+/* ------------------------------------------------------------------------------
+ * Table 'hosts' - hosts contributing shows
+ * ------------------------------------------------------------------------------ */
+CREATE SEQUENCE host_seq;
+
+ALTER TABLE host_seq
+ OWNER TO hpradmin;
+
+CREATE TABLE hosts (
+ id integer default nextval('host_seq') PRIMARY KEY,
+ host varchar(1024) UNIQUE NOT NULL,
+-- email varchar(1024) CHECK (email <> ''),
+ email varchar(1024) UNIQUE NOT NULL,
+ profile text,
+ license varchar(11) NOT NULL DEFAULT 'CC-BY-SA'
+ REFERENCES licenses (short_name),
+ local_image smallint NOT NULL DEFAULT '0',
+ gpg text,
+ valid smallint NOT NULL DEFAULT '1',
+ date_added date
+);
+
+ALTER TABLE hosts
+ OWNER TO hpradmin;
+
+/* ------------------------------------------------------------------------------
+ * Table 'episodes_hosts_xref' - joining table between 'episodes' and 'hosts'
+ * ------------------------------------------------------------------------------ */
+CREATE TABLE episodes_hosts_xref (
+ episodes_id integer REFERENCES episodes(id)
+ ON DELETE RESTRICT,
+ hosts_id integer REFERENCES hosts(id)
+ ON DELETE CASCADE,
+ PRIMARY KEY (episodes_id, hosts_id)
+);
+
+ALTER TABLE episodes_hosts_xref
+ OWNER TO hpradmin;
+
+/* ------------------------------------------------------------------------------
+ * Table 'tags' - tags relating to episodes
+ * ------------------------------------------------------------------------------ */
+CREATE SEQUENCE tag_seq;
+
+ALTER TABLE tag_seq
+ OWNER TO hpradmin;
+
+CREATE TABLE tags (
+ id integer default nextval('tag_seq') PRIMARY KEY,
+ tag varchar(1024) NOT NULL
+);
+
+ALTER TABLE tags
+ OWNER TO hpradmin;
+
+/* ------------------------------------------------------------------------------
+ * Table 'episodes_tags_xref' - joining table between 'episodes' and 'tags'
+ * ------------------------------------------------------------------------------ */
+CREATE TABLE episodes_tags_xref (
+ episodes_id integer REFERENCES episodes(id)
+ ON DELETE RESTRICT,
+ tags_id integer REFERENCES tags(id)
+ ON DELETE CASCADE,
+ PRIMARY KEY (episodes_id, tags_id)
+);
+
+ALTER TABLE episodes_tags_xref
+ OWNER TO hpradmin;
+
+/* ------------------------------------------------------------------------------
+ * Table 'series' - series grouping for episodes
+ * ------------------------------------------------------------------------------ */
+CREATE SEQUENCE series_seq;
+
+ALTER TABLE series_seq
+ OWNER TO hpradmin;
+
+CREATE TABLE series (
+ id integer default nextval('series_seq') PRIMARY KEY,
+ name varchar(100) NOT NULL,
+ description text NOT NULL,
+ private smallint NOT NULL DEFAULT '0',
+ image text,
+ valid smallint NOT NULL DEFAULT '1'
+);
+
+ALTER TABLE series
+ OWNER TO hpradmin;
+
+/* ------------------------------------------------------------------------------
+ * Table 'episodes_series_xref' - joining table between 'episodes' and 'series'
+ * ------------------------------------------------------------------------------ */
+CREATE TABLE episodes_series_xref (
+ episodes_id integer REFERENCES episodes(id)
+ ON DELETE RESTRICT,
+ series_id integer REFERENCES series(id)
+ ON DELETE CASCADE,
+ PRIMARY KEY (episodes_id, series_id)
+);
+
+ALTER TABLE episodes_series_xref
+ OWNER TO hpradmin;
+
+/* ------------------------------------------------------------------------------
+ * Table 'comments' - comments relating to episodes
+ * ------------------------------------------------------------------------------ */
+CREATE SEQUENCE comment_seq;
+
+ALTER TABLE comment_seq
+ OWNER TO hpradmin;
+
+CREATE TABLE comments (
+ id integer default nextval('comment_seq') PRIMARY KEY,
+ eps_id integer REFERENCES episodes(id)
+ ON DELETE RESTRICT,
+ comment_timestamp timestamp without time zone NOT NULL,
+ comment_author_name varchar(1024),
+ comment_title varchar(1024),
+ comment_text text,
+ last_changed timestamp without time zone NOT NULL
+ DEFAULT timezone('UTC'::text, now())
+);
+
+ALTER TABLE comments
+ OWNER TO hpradmin;
+
+/* ------------------------------------------------------------------------------
+ * Tables from "Today with a Techie" for further processing
+ * ------------------------------------------------------------------------------ */
+CREATE SEQUENCE twat_hosts_seq;
+
+ALTER TABLE twat_hosts_seq
+ OWNER TO hpradmin;
+
+CREATE TABLE twat_hosts (
+ id integer default nextval('twat_hosts_seq') PRIMARY KEY,
+ host varchar(1024) NOT NULL,
+ email varchar(1024),
+ website varchar(1024),
+ repeat integer NOT NULL
+);
+
+ALTER TABLE twat_hosts
+ OWNER TO hpradmin;
+
+
+CREATE SEQUENCE twat_episodes_seq;
+
+ALTER TABLE twat_episodes_seq
+ OWNER TO hpradmin;
+
+CREATE TABLE twat_episodes (
+ ep_num integer default nextval('twat_episodes_seq') PRIMARY KEY,
+ date integer NOT NULL,
+ host varchar(1024) NOT NULL,
+ topic varchar(1024) NOT NULL,
+ writeup text,
+ url varchar(1024) NOT NULL
+);
+
+ALTER TABLE twat_episodes
+ OWNER TO hpradmin;
+
+/* ------------------------------------------------------------------------------
+ * Experimental views
+ * ------------------------------------------------------------------------------ */
+--
+-- eh_view
+--
+CREATE OR REPLACE VIEW eh_view AS
+SELECT
+ ep.id,
+ ep.release_date,
+ ep.title,
+ (SELECT string_agg(host, ', ' ORDER BY host)
+ FROM hosts h2,
+ episodes_hosts_xref eh2
+ WHERE eh2.hosts_id = h2.id
+ GROUP BY eh2.episodes_id
+ HAVING eh2.episodes_id = ep.id) AS hosts
+FROM episodes ep
+GROUP BY ep.id
+ORDER BY ep.id;
+
+ALTER TABLE eh_view
+ OWNER TO hpradmin;
+
+--
+-- eht_view
+--
+CREATE OR REPLACE VIEW eht_view AS
+SELECT e.*,
+ h.host,
+ t.tag,
+ (SELECT string_agg(tag, ', ')
+ FROM tags t2,
+ episodes_tags_xref et2
+ WHERE et2.tags_id = t2.id
+ GROUP BY et2.episodes_id
+ HAVING et2.episodes_id = e.id) AS tags
+FROM episodes e,
+ hosts h,
+ episodes_hosts_xref eh,
+ episodes_tags_xref et,
+ tags t
+WHERE e.id = eh.episodes_id
+ AND h.id = eh.hosts_id
+ AND e.id = et.episodes_id
+ AND et.tags_id = t.id
+GROUP BY e.id,
+ h.host,
+ t.tag
+ORDER BY e.id;
+
+-- CREATE OR REPLACE VIEW eht_view AS
+-- SELECT
+-- e.*,
+-- h.host,
+-- t.tag,
+-- (SELECT string_agg(tag, ', ')
+ -- FROM tags t2, episodes_tags_xref et2
+ -- WHERE et2.tags_id = t2.id
+ -- GROUP BY et2.episodes_id
+ -- HAVING et2.episodes_id = e.id) AS tags
+-- FROM episodes e, hosts h, episodes_hosts_xref eh, episodes_tags_xref et, tags t
+-- WHERE e.id = eh.episodes_id
+-- AND h.id = eh.hosts_id
+-- AND e.id = et.episodes_id
+-- AND et.tags_id = t.id
+-- GROUP BY e.id,h.host,t.tag
+-- ORDER BY e.id;
+
+ALTER TABLE eht_view
+ OWNER TO hpradmin;
+
+-- Footer ---------------------------------------------------------------------
+
+-- vim: syntax=pgsql:ts=8:sw=4:ai:tw=78:et:fo=tcrqn21:nu:rnu
diff --git a/Database/load_downloads b/Database/load_downloads
new file mode 100755
index 0000000..d0e676a
--- /dev/null
+++ b/Database/load_downloads
@@ -0,0 +1,118 @@
+#!/usr/bin/env perl
+#===============================================================================
+#
+# FILE: load_downloads
+#
+# USAGE: ./load_downloads infile
+#
+# DESCRIPTION: Loads episode downloads from a file into the 'eps' table
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: ---
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.0.1
+# CREATED: 2014-08-30 17:46:47
+# REVISION: 2014-08-30 17:46:52
+#
+#===============================================================================
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+
+use Config::General;
+use Text::CSV_XS;
+
+use DBI;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.0.1';
+
+#
+# Script name
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+( my $DIR = $0 ) =~ s|/?[^/]*$||mx;
+$DIR = '.' unless $DIR;
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+#
+# Constants and other declarations
+#
+my $basedir = "$ENV{HOME}/HPR/Database";
+my $configfile = "$basedir/.hpr_db.cfg";
+
+my ( $dbh, $sth1, $sth2, $sth3, $sth4, $h1, $h2, $rv );
+my ( $infile, $row );
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#
+# Check the input file
+#
+$infile = shift;
+die "Usage: $PROG input_file\n" unless $infile;
+die "Unable to find/read file '$infile'\n" unless -r $infile;
+
+#
+# Load configuration data
+#
+my $conf = new Config::General(
+ -ConfigFile => $configfile,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %config = $conf->getall();
+
+#-------------------------------------------------------------------------------
+# Connect to the database
+#-------------------------------------------------------------------------------
+my $dbhost = $config{database}->{host};
+my $dbname = $config{database}->{name};
+my $dbuser = $config{database}->{user};
+my $dbpwd = $config{database}->{password};
+$dbh = DBI->connect( "dbi:mysql:host=$dbhost;database=$dbname",
+ $dbuser, $dbpwd, { AutoCommit => 1 } )
+ or die $DBI::errstr;
+
+#
+# Enable client-side UTF8
+#
+$dbh->{mysql_enable_utf8} = 1;
+
+$sth1 = $dbh->prepare(q{UPDATE eps SET downloads = ? WHERE id = ?});
+
+#
+# Open the input file
+#
+open( my $in, "<", $infile ) or die "Unable to open $infile: $!\n";
+
+my $csv = Text::CSV_XS->new;
+
+#
+# Process all lines as CSV
+#
+while ( $row = $csv->getline($in) ) {
+ $sth1->execute( $row->[1], $row->[0] );
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+}
+
+close($in);
+
+exit;
+
+# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
+
diff --git a/Database/make_tag_index b/Database/make_tag_index
new file mode 100755
index 0000000..51fb4c4
--- /dev/null
+++ b/Database/make_tag_index
@@ -0,0 +1,518 @@
+#!/usr/bin/env perl
+#===============================================================================
+#
+# FILE: make_tag_index
+#
+# USAGE: ./make_tag_index [-help] [-debug=N] [-out=FILE] [-config=FILE]
+#
+# DESCRIPTION: Make tag lookup pages for the HPR website
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: ---
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.0.2
+# CREATED: 2022-09-08 11:52:53
+# REVISION: 2022-09-10 14:59:38
+#
+#===============================================================================
+
+use v5.16;
+use strict;
+use warnings;
+use utf8;
+use feature qw{ postderef say signatures state };
+no warnings qw{ experimental::postderef experimental::signatures };
+
+use Getopt::Long;
+use Pod::Usage;
+
+use Config::General;
+
+use Template;
+use Template::Filters;
+Template::Filters->use_html_entities; # Use HTML::Entities in the template
+
+use Text::CSV_XS;
+
+use DBI;
+
+use Data::Dumper;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.0.2';
+
+#
+# Script and directory names
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+( my $DIR = $0 ) =~ s|/?[^/]*$||mx;
+$DIR = '.' unless $DIR;
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+#
+# Constants and other declarations
+#
+my $basedir = "$ENV{HOME}/HPR/Database";
+my $configfile = "$basedir/.hpr_db.cfg";
+my $template = "$basedir/$PROG.tpl";
+
+my ( $dbh, $sth1, $h1 );
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#-------------------------------------------------------------------------------
+# Options and arguments
+#-------------------------------------------------------------------------------
+my $DEF_DEBUG = 0;
+
+#
+# Process options
+#
+my %options;
+Options( \%options );
+
+#
+# Default help
+#
+pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 )
+ if ( $options{'help'} );
+
+#
+# Full documentation if requested with -doc
+#
+pod2usage( -msg => "$PROG version $VERSION\n", -verbose => 2, -exitval => 1 )
+ if ( $options{'doc'} );
+
+#
+# Collect options
+#
+my $DEBUG = ( defined( $options{debug} ) ? $options{debug} : $DEF_DEBUG );
+my $cfgfile
+ = ( defined( $options{config} ) ? $options{config} : $configfile );
+
+my $templatefile = $options{template};
+my $outfile = $options{out};
+
+#-------------------------------------------------------------------------------
+# Template is the default pre-defined string or a filename
+#-------------------------------------------------------------------------------
+if ($templatefile) {
+ die "Unable to find template $templatefile\n" unless ( -e $templatefile );
+}
+else {
+ $templatefile = $template;
+}
+
+#-------------------------------------------------------------------------------
+# Open the output file (or STDOUT)
+#-------------------------------------------------------------------------------
+my $outfh;
+if ($outfile) {
+ open( $outfh, ">:encoding(UTF-8)", $outfile )
+ or die "Unable to open $outfile for writing: $!";
+}
+else {
+ open( $outfh, ">&", \*STDOUT )
+ or die "Unable to initialise for writing: $!";
+}
+
+#
+# Sanity check
+#
+die "Unable to find $cfgfile\n" unless ( -e $cfgfile );
+
+#
+# Load configuration data
+#
+my $conf = new Config::General(
+ -ConfigFile => $cfgfile,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %config = $conf->getall();
+
+#-------------------------------------------------------------------------------
+# Connect to the database
+#-------------------------------------------------------------------------------
+my $dbhost = $config{database}->{host} // '127.0.0.1';
+my $dbport = $config{database}->{port} // 3306;
+my $dbname = $config{database}->{name};
+my $dbuser = $config{database}->{user};
+my $dbpwd = $config{database}->{password};
+$dbh = DBI->connect( "dbi:mysql:host=$dbhost;port=$dbport;database=$dbname",
+ $dbuser, $dbpwd, { AutoCommit => 1 } )
+ or die $DBI::errstr;
+
+#
+# Enable client-side UTF8
+#
+$dbh->{mysql_enable_utf8} = 1;
+
+#-------------------------------------------------------------------------------
+# Perform a scan of episodes for tags and accumulate them in a hash
+#-------------------------------------------------------------------------------
+$sth1 = $dbh->prepare(q{SELECT id,title,tags FROM eps WHERE length(tags) > 0})
+ or die $DBI::errstr;
+
+$sth1->execute;
+if ( $dbh->err ) {
+ warn $dbh->errstr;
+}
+
+my ( $status, @fields, %tag_ids, $lastkey, @tagindex, %showtitles );
+
+my $csv = Text::CSV_XS->new(
+ { binary => 1,
+ auto_diag => 1,
+ escape_char => "\\",
+ allow_loose_quotes => 1
+ }
+);
+
+#
+# Loop through the episodes returned by the query
+#
+while ( $h1 = $sth1->fetchrow_hashref ) {
+ #
+ # Stash the show title with the show number
+ #
+ $showtitles{ $h1->{id} } = $h1->{title};
+
+ #
+ # Parse the tag list for the current episode
+ #
+ $status = $csv->parse( $h1->{tags} );
+ unless ($status) {
+ #
+ # Report any errors
+ #
+ print "Parse error on episode ", $h1->{id}, "\n";
+ print $csv->error_input(), "\n";
+ next;
+ }
+ @fields = $csv->fields();
+
+ #
+ # Not sure why there are no tags but if not ignore this episode
+ #
+ next unless (@fields);
+
+ #
+ # Trim and lowercase all tags
+ #
+ @fields = map {
+ my $t = $_;
+ $t =~ s/(^\s+|\s+$)//g;
+ lc($t)
+ } @fields;
+
+ #
+ # Loop through the tags. For each tag add the associated episode id to the
+ # %tag_ids hash. The key to this hash is the lower case tag and the value
+ # is an array of episode numbers.
+ #
+ foreach my $tag (@fields) {
+ if ( defined( $tag_ids{$tag} ) ) {
+ #
+ # Add to the existing array
+ #
+ push( @{ $tag_ids{$tag} }, $h1->{id} );
+ }
+ else {
+ #
+ # Create the episode array
+ #
+ $tag_ids{$tag} = [ $h1->{id} ];
+ }
+ }
+}
+
+#
+# Dumps the whole tags table. Warning!
+#
+_debug( $DEBUG > 2, '%tag_ids: ' . Dumper( \%tag_ids ) );
+
+#-------------------------------------------------------------------------------
+# Make an alphabetic index of the tags
+#-------------------------------------------------------------------------------
+$lastkey = '';
+foreach my $tag ( sort( keys(%tag_ids) ) ) {
+ if ( substr( $tag, 0, 1 ) ne $lastkey ) {
+ $lastkey = substr( $tag, 0, 1 );
+ push( @tagindex, $tag );
+ }
+}
+
+_debug( $DEBUG > 1, '@tagindex: ' . Dumper( \@tagindex ) );
+
+#-------------------------------------------------------------------------------
+# Fill and print the template
+#-------------------------------------------------------------------------------
+my $tt = Template->new(
+ { ABSOLUTE => 1,
+ ENCODING => 'utf8',
+ INCLUDE_PATH => $basedir,
+ OUTPUT_PATH => '.',
+ }
+);
+my $vars = {
+ tag_ids => \%tag_ids,
+ tagindex => \@tagindex,
+ titles => \%showtitles,
+};
+my $document;
+$tt->process( $templatefile, $vars, \$document, { binmode => ':utf8' } )
+ || die $tt->error(), "\n";
+
+print $outfh $document;
+close($outfh);
+
+$dbh->disconnect;
+
+exit;
+
+#=== FUNCTION ================================================================
+# NAME: _debug
+# PURPOSE: Prints debug reports
+# PARAMETERS: $active Boolean: 1 for print, 0 for no print
+# $message Message to print
+# RETURNS: Nothing
+# DESCRIPTION: Outputs a message if $active is true. It removes any trailing
+# newline and then adds one in the 'print' to the caller doesn't
+# have to bother. Prepends the message with 'D> ' to show it's
+# a debug message.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub _debug {
+ my ( $active, $message ) = @_;
+
+ chomp($message);
+ print "D> $message\n" if $active;
+}
+
+#=== FUNCTION ================================================================
+# NAME: Options
+# PURPOSE: Processes command-line options
+# PARAMETERS: $optref Hash reference to hold the options
+# RETURNS: Undef
+# DESCRIPTION:
+# THROWS: no exceptions
+# COMMENTS: none
+# SEE ALSO: n/a
+#===============================================================================
+sub Options {
+ my ($optref) = @_;
+
+ my @options
+ = ( "help", "doc", "debug=i", "template=s", "out=s", "config=s", );
+
+ if ( !GetOptions( $optref, @options ) ) {
+ pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 );
+ }
+
+ return;
+}
+
+__END__
+
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+# Application Documentation
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+#{{{
+
+=head1 NAME
+
+make_tag_index - Generate a tag index from the tags in the database
+
+=head1 VERSION
+
+This documentation refers to make_tag_index version 0.0.2
+
+=head1 USAGE
+
+ ./make_tag_index [-help] [-doc] [-debug=N] [-template=FILE] [-out=FILE]
+ [-config=FILE]
+
+ ./make_tag_index -help
+ ./make_tag_index -doc
+ ./make_tag_index -out=tags.php
+ ./make_tag_index -template=MTI_1.tpl -out=tags.php
+ ./make_tag_index -config=$HOME/HPR/.hpr_livedb.cfg -out=tags.php
+
+=head1 OPTIONS
+
+=over 8
+
+=item B<-help>
+
+Prints a brief help message describing the usage of the program, and then exits.
+
+=item B<-doc>
+
+Displays the entirety of the documentation (using a pager), and then exits. To
+generate a PDF version use:
+
+ pod2pdf make_tag_index --out=make_tag_index.pdf
+
+
+=item B<-debug=N>
+
+Causes certain debugging information to be displayed.
+
+ 0 (the default) no debug output
+ 1 N/A
+ 2 dumps @tagindex an array containing tags and show numbers for the index
+ 3 dumps %tag_ids the data used to build the entire tag list (warning!)
+
+=item B<-out=FILE>
+
+This option defines an output file to receive the report. If the option is
+omitted the report is written to STDOUT, allowing it to be redirected if
+required.
+
+=item B<-config=FILE>
+
+This option allows an alternative configuration file to be used. This file
+defines the location of the database, its port, its name and the username and
+password to be used to access it. This feature was added to allow the script
+to access alternative databases or the live database over an SSH tunnel.
+
+See the CONFIGURATION AND ENVIRONMENT section below for the file format.
+
+If the option is omitted the default file is used: B<.hpr_db.cfg>
+
+=item B<-template=FILE>
+
+This option defines the template used to generate the tag index. The template
+is written using the B toolkit language.
+
+If the option is omitted then the script uses the file
+B in the same directory as the script. If this file
+does not exist then the script will exit with an error message.
+
+=back
+
+=head1 DESCRIPTION
+
+The script reads all episodes in the HPR database. Each row contains a 'tags'
+field which contains tags as a comma-separated list. This list is parsed and
+stored in a Perl hash. The hash is keyed by the lower-case tag and the value
+part of each hash element contains a Perl arrayref containing a list of show
+numbers. The tag/show hash is called B<%tag_ids>. There are over 5800 tags in
+the system in September 2022.
+
+An array called B<@tagindex> is also created which holds the first tag of each
+group starting with the same character. So, with a particular tag population,
+the 'a' group might start with 'aaron newcomb', 'b' with 'b+ tree' and so
+forth.
+
+A further hash called B<%showtitles> is indexed by show number and holds the
+title of the show. This has been added in preparation for producing a tag
+index pages which have better accessibility features.
+
+=head1 DIAGNOSTICS
+
+=over 4
+
+=item B
+
+Type: fatal
+
+The template specified does not exist.
+
+=item B
+
+Type: fatal
+
+The nominated output file cannot be written to.
+
+=item B;
+
+Type: fatal
+
+Trying to write to STDOUT has failed.
+
+=item B
+
+Type: fatal
+
+The nominated configuration file cannot be found
+
+=item B
+
+Type: fatal
+
+Failure while opening the database or preparing a query.
+
+=item B
+
+Type: fatal
+
+The template could not be processed
+
+=back
+
+=head1 CONFIGURATION AND ENVIRONMENT
+
+The script obtains the credentials it requires to open the HPR database from
+a configuration file. The name of the file it expects is B<.hpr_db.cfg> in the
+directory holding the script. To change this will require changing the script.
+
+The configuration file format is as follows:
+
+
+ host = 127.0.0.1
+ port = PORT
+ name = DATABASE
+ user = USERNAME
+ password = PASSWORD
+
+
+=head1 DEPENDENCIES
+
+Config::General
+DBI
+Data::Dumper
+Getopt::Long
+Pod::Usage
+Template
+Template::Filters
+Text::CSV_XS
+
+=head1 BUGS AND LIMITATIONS
+
+There are no known bugs in this module.
+Please report problems to Dave Morriss (Dave.Morriss@gmail.com)
+Patches are welcome.
+
+=head1 AUTHOR
+
+Dave Morriss (Dave.Morriss@gmail.com)
+
+=head1 LICENCE AND COPYRIGHT
+
+Copyright (c) 2022 Dave Morriss (Dave.Morriss@gmail.com). All rights reserved.
+
+This module is free software; you can redistribute it and/or
+modify it under the same terms as Perl itself. See perldoc perlartistic.
+
+=cut
+
+#}}}
+
+# [zo to open fold, zc to close]
+
+# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
+
diff --git a/Database/make_tag_index.tpl b/Database/make_tag_index.tpl
new file mode 100644
index 0000000..3886ac5
--- /dev/null
+++ b/Database/make_tag_index.tpl
@@ -0,0 +1,85 @@
+[%# make_tag_index.tpl 2022-09-14 -%]
+[%# version: 0.0.3 -%]
+[%# Default page summarising tag data from the database, generates PHP -%]
+[%- USE date -%]
+[%- DEFAULT title = 'Tag summary' -%]
+
+
+
+
+
+[% title %]
+Page generated on [% date.format(date.now,'%Y-%m-%d at %H:%M:%S UTC','en_GB',1) %]
+This section summarises all of the tags currently used throughout the
+database. The tags are in alphabetical order and each is followed by links to
+the show numbers where it is used so you can see the context the author used
+it in. There are currently [% tag_ids.size %] unique tags in the system.
+Alphabetical index
+This is an index to the initial letters of the tags below.
+
+[%# tagindex contains the first tag in an alphabetic list that has a different
+first letter from the previous one. We use it to build an alphabetic table of
+anchors linking to the blocks of tags starting with that character. -%]
+[%- FOREACH index IN tagindex %]
+[% index.substr(0,1) %]
+[%- END %]
+
+
+[%# BLOCK tags -%]
+
+[%# tag_ids is a hash keyed by tags, each containing an array of episode
+numbers. If a tag matches the one in 'index' place an anchor to it for the
+alphabetic index above. %]
+[%- index = tagindex.shift %]
+[%- FOREACH pair IN tag_ids.pairs %]
+[%- IF pair.key == index %]
+
+🡱 Go to index
+Tags beginning with '[% index.substr(0,1) %]'
+
+
+[%- index = tagindex.shift %]
+[%- ELSE %]
+
+[%- END %]
+[% pair.key FILTER html_entity %] :
+ [%- count = 0 %]
+ [%- FOREACH id IN pair.value.nsort %]
+ [%- count = count + 1 %]
+ [% id %]
+ [%- count < pair.value.size ? ', ' : '' %]
+ [%- END %]
+
+[%- END %]
+
+[%# END -%]
+
+
+Go to TOP of page
+
+
+
+
+
+
+
+[%#
+ vim: syntax=tt2:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
+-%]
diff --git a/Database/make_tsu_blank b/Database/make_tsu_blank
new file mode 100755
index 0000000..d44201c
--- /dev/null
+++ b/Database/make_tsu_blank
@@ -0,0 +1,332 @@
+#!/bin/bash -
+#===============================================================================
+#
+# FILE: make_tsu_blank
+#
+# USAGE: ./make_tsu_blank [-h] [-D] start count
+#
+# DESCRIPTION: Make a template for generating a tag and summary update email.
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: Now obsolete but retained for reference purposes
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.0.8
+# CREATED: 2016-05-28 16:21:22
+# REVISION: 2021-06-23 13:03:31
+#
+#===============================================================================
+
+set -o nounset # Treat unset variables as an error
+
+SCRIPT=${0##*/}
+
+VERSION="0.0.8"
+
+STDOUT="/dev/fd/2"
+
+#
+# Load library functions
+#
+LIB="$HOME/bin/function_lib.sh"
+[ -e "$LIB" ] || { echo "$SCRIPT: Unable to source functions"; exit 1; }
+# shellcheck source=/home/cendjm/bin/function_lib.sh
+source "$LIB"
+
+#
+# Colour codes
+#
+define_colours
+
+#
+# We need the SSH tunnel (The script to test this and to open it, open_tunnel,
+# are in ~/bin. This needs to be set up if running this stuff somewhere else)
+#
+if ! tunnel_is_open; then
+ echo "$SCRIPT: ${red}The SSH tunnel must be open to do this${reset}"
+ exit 1
+fi
+
+#=== FUNCTION ================================================================
+# NAME: _usage
+# DESCRIPTION: Report usage
+# PARAMETERS: None
+# RETURNS: Nothing
+#===============================================================================
+_usage () {
+ cat >$STDOUT <<-endusage
+Usage: ./${SCRIPT} [-h] [-d] [-D] start count
+
+Version: $VERSION
+
+Generates a file of tag and summary updates for shows in the given range which
+can be edited and submitted to tags@hackerpublicradio.org in order to update
+the relevant shows.
+
+Options:
+ -h Print this help
+ -D Select debug mode (works the same; more output)
+
+Arguments:
+ start starting show number
+ count number of shows (shouldn't exceed 20)
+
+Examples
+ ./${SCRIPT} -h
+ ./${SCRIPT} -D 700 10
+
+endusage
+ exit
+}
+
+#=== FUNCTION ================================================================
+# NAME: _DEBUG
+# DESCRIPTION: Writes a message if in DEBUG mode
+# PARAMETERS: List of messages
+# RETURNS: Nothing
+#===============================================================================
+_DEBUG () {
+ [ "$DEBUG" == 0 ] && return
+ for msg in "$@"; do
+ printf 'D> %s\n' "$msg"
+ done
+}
+
+#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+#
+# Option defaults
+#
+DEBUG=0
+
+#
+# Process options
+#
+while getopts :hdD opt
+do
+ case "${opt}" in
+ h) _usage;;
+ D) DEBUG=1;;
+ ?) echo "$SCRIPT: Invalid option; aborting"; exit 1;;
+ esac
+done
+shift $((OPTIND - 1))
+
+#
+# Directories and files
+#
+BASEDIR="$HOME/HPR/Database"
+TSU="$BASEDIR/tsu"
+
+PREFIX="tag_summary_updates_"
+GENERATOR="$BASEDIR/query2tt2"
+LIVECFG="$BASEDIR/.hpr_livedb.cfg"
+TEMPLATE="$BASEDIR/query2tt2_taglist.tpl"
+
+#
+# Sanity checks
+#
+[ -d "$BASEDIR" ] || { echo "Unable to find directory $BASEDIR"; exit 1; }
+[ -d "$TSU" ] || { echo "Unable to find directory $TSU"; exit 1; }
+
+for item in $GENERATOR $LIVECFG $TEMPLATE; do
+ [ -e "$item" ] || {
+ echo "Unable to find component: $item"
+ exit 1
+ }
+done
+
+#
+# Maximum number of shows to scan. This is advisory since we might want to
+# scan 40 and only get 3 which need work!
+#
+LIMIT=20
+
+#
+# Check arguments
+#
+if [[ $# -ne 2 ]]; then
+ _usage
+fi
+
+#
+# Validate arguments and make the END variable
+#
+START="$1"
+COUNT="$2"
+
+RE='^[0-9]+$'
+if ! [[ $START =~ $RE ]]; then
+ echo "${red}Invalid starting value: $1${reset}"
+ exit 1
+fi
+if ! [[ $COUNT =~ $RE ]]; then
+ echo "${red}Invalid count value: $2${reset}"
+ exit 1
+fi
+
+#
+# Deal with leading zeroes if any by forcing such numbers to base 10
+#
+START=$((10#$START))
+COUNT=$((10#$COUNT))
+((END = START + COUNT - 1))
+
+_DEBUG "Start: $START" "Count: $COUNT" "End: $END"
+
+#
+# Argument sanity checks
+#
+if [[ $COUNT -gt $LIMIT ]]; then
+ echo "${yellow}Range: $START..$END ($COUNT)${reset}"
+ echo "${yellow}You are asking for a count greater than 20.${reset}"
+ echo "${red}Beware! This could be unmanageable!${reset}"
+ if ! yes_no 'Are you sure you want this? %s ' 'N'; then
+ echo "${red}Request ignored. Please try again.${reset}"
+ exit
+ fi
+fi
+
+#
+# Generate the output file path
+#
+printf -v OUTFILE "%s/%s%04d-%04d.txt" "$TSU" "$PREFIX" "$START" "$END"
+
+_DEBUG "Output: $OUTFILE"
+
+#
+# Does the output file exist? If so, can we detect any work having been done
+# to it?
+#
+overwrite=0
+if [[ -e $OUTFILE ]]; then
+ if [[ -s $OUTFILE ]]; then
+ echo "${yellow}${OUTFILE/$HOME/\~} already exists.${reset}"
+ if grep -E -q "^(summary|tags): ?\w+" "$OUTFILE"; then
+ echo -n "${yellow}** Work has been done on this file"
+ missing=$(grep -E -c "^(summary|tags): *$" "$OUTFILE")
+ if ((missing)); then
+ echo " (there are still tags/summaries to be added).${reset}"
+ else
+ echo ".${reset}"
+ fi
+ else
+ echo "${yellow}This file has not had tags or summaries added.${reset}"
+ fi
+
+ if ! yes_no 'Are you sure you want to replace it? %s ' 'N'; then
+ echo "${red}File not overwritten${reset}"
+ exit
+ else
+ overwrite=1
+ fi
+ else
+ #
+ # This shouldn't happen. An empty file caused by a failed query or
+ # because there's nothing to do should be cleared away immediately
+ # rather than here where the file has been left hanging around.
+ #
+ echo "${yellow}${OUTFILE/$HOME/\~} exists but is empty. Deleting it.${reset}"
+ rm -f "$OUTFILE"
+ fi
+fi
+
+_DEBUG "Overwrite: $overwrite"
+
+#
+# If we're overwriting no collision check otherwise check check check!
+#
+if [[ $overwrite -eq 0 ]]; then
+ #
+ # Check for collisions.
+ #
+ # Look for individual files already created, taking the FROM and TO values
+ # from their names. Look to see if the range START-END is in the range FROM-TO
+ # or the other way round. Print all collisions. Any found mean the script
+ # can't continue.
+ #
+ # Note that we have to force numbers to base 10 in case they have leading
+ # zeroes (and will therefore be treated as octal).
+ #
+ collisions=0
+ FILERE="${PREFIX}([0-9]{4})-([0-9]{4})\\.txt$"
+ for f in "$TSU"/"${PREFIX}"*; do
+ if [[ $f =~ $FILERE ]]; then
+ FROM="${BASH_REMATCH[1]}"
+ FROM=$((10#$FROM))
+ TO="${BASH_REMATCH[2]}"
+ TO=$((10#$TO))
+ if [[ (( $START -ge $FROM && $START -le $TO ) ||\
+ ( $END -ge $FROM && $END -le $TO )) || \
+ (( $FROM -ge $START && $FROM -le $END ) ||\
+ ( $TO -ge $START && $TO -le $END )) ]]; then
+ printf \
+ '%sCollision: range %04d-%04d overlaps the range %04d-%04d (in '%s')%s\n' \
+ "${red}" "$START" "$END" "$FROM" "$TO" "${f##*/}" "${reset}"
+ ((collisions++))
+ fi
+ fi
+ done
+
+ if [[ $collisions -gt 0 ]]; then
+ echo "${red}Found $collisions collisions; aborting${reset}"
+ exit 1
+ fi
+fi
+
+#
+# Define the SQL.
+# 2021-06-20: Now we make a simpler query and rely on a script and template to
+# format everything.
+#
+SQL=$(cat < "$OUTFILE"
+RES=$?
+
+#
+# Die if the query failed, and clear up the empty output file if found
+#
+[ $RES -eq 0 ] || {
+ echo "${red}Query failed; aborting${reset}"
+ if [[ -e $OUTFILE && ! -s $OUTFILE ]]; then
+ rm -f "$OUTFILE"
+ fi
+ exit 1
+}
+
+#
+# An empty file could be "successfully" created. If so we delete it
+#
+if [[ -s $OUTFILE ]]; then
+ #
+ # Report the file created.
+ #
+ # 2021-06-20: The original sed call is not needed any more because the
+ # script we ran made the file in the form we want.
+ #
+ echo "${green}Output is in ${OUTFILE/$HOME/\~}${reset}"
+else
+ rm -f "$OUTFILE"
+ echo "${yellow}No episodes need work in that range${reset}"
+fi
+
+exit
+
+# vim: syntax=sh:ts=8:sw=4:ai:et:tw=78:fo=tcrqn21
diff --git a/Database/new_hosts_in_last_year.sql b/Database/new_hosts_in_last_year.sql
new file mode 100644
index 0000000..19d3a0a
--- /dev/null
+++ b/Database/new_hosts_in_last_year.sql
@@ -0,0 +1,32 @@
+/* -----------------------------------------------------------------------------
+ * Find all new hosts who joined in the last year (ignoring those with queued
+ * shows in the future)
+ *
+ */
+SELECT h.hostid,
+ h.host,
+ min(e.date) AS joindate,
+ count(e.id) AS COUNT
+FROM eps e
+JOIN hosts h ON e.hostid = h.hostid
+GROUP BY h.hostid
+HAVING min(e.date) >= (curdate() - INTERVAL 364 DAY)
+AND min(e.date) <= curdate()
+ORDER BY min(e.date);
+
+/* -----------------------------------------------------------------------------
+* Total shows produced by the new hosts in the past year
+*/
+SELECT sum(COUNT) AS total_shows
+FROM
+ (SELECT h.hostid,
+ h.host,
+ min(e.date) AS joindate,
+ count(e.id) AS COUNT
+ FROM eps e
+ JOIN hosts h ON e.hostid = h.hostid
+ GROUP BY h.hostid
+ HAVING min(e.date) >= (curdate() - INTERVAL 364 DAY)
+ AND min(e.date) <= curdate()
+ ORDER BY min(e.date)) src;
+
diff --git a/Database/normalise_tags.sql b/Database/normalise_tags.sql
new file mode 100644
index 0000000..36804dc
--- /dev/null
+++ b/Database/normalise_tags.sql
@@ -0,0 +1,114 @@
+/*
+ * Define a function to return a particular element from a comma-delimited
+ * string. There is nothing already present in MySQL to do this.
+ *
+ * Create a table to hold the split tags, storing them in lower- and
+ * upper-case form.
+ *
+ * Define a procedure to do the work of visiting every row in the 'eps' table
+ * to extract the tags and place them in the 'tags' table with the episode id
+ * they are associated with. This could be run on a periodic basis ('call
+ * NormaliseEpisodeTags()') preceded by the statement 'DELETE FROM tags;'. The
+ * 'lctags' column needs to be created with the statement 'UPDATE tags SET
+ * lctag = LOWER(tag);'.
+ *
+ * With the 'tags' table filled then it can be queried for tag information as
+ * shown in the examples below.
+ *
+ * 1. To count tag frequencies (case insensitive) and show the top 50:
+ *
+ * SELECT tag,lctag,COUNT(tag) AS freq FROM tags GROUP BY tag ORDER BY COUNT(tag) DESC LIMIT 50;
+ *
+ * 2. To return the episode numbers of shows tagged with a particular word:
+ *
+ * SELECT e.id,e.date,e.title,h.host FROM eps e JOIN hosts h ON e.hostid = h.hostid
+ * WHERE e.id IN (SELECT id FROM tags WHERE lctag = 'linux');
+ *
+ * ----------------------------------------------------------------------------
+ * (These ideas were based upon the discussions at
+ * https://stackoverflow.com/questions/17942508/sql-split-values-to-multiple-rows)
+ * ----------------------------------------------------------------------------
+ */
+
+DELIMITER $$
+
+/*
+ * Create function 'strSplit'
+ *
+ * Arguments:
+ * x - string to work on
+ * delim - delimiter to split on
+ * pos - starting position
+ *
+ */
+DROP FUNCTION IF EXISTS strSplit;
+
+CREATE FUNCTION strSplit(x VARCHAR(65000), delim VARCHAR(12), pos INTEGER)
+ RETURNS VARCHAR(65000)
+BEGIN
+ DECLARE output VARCHAR(65000);
+ SET output = TRIM(
+ REPLACE(
+ SUBSTRING(
+ SUBSTRING_INDEX(x, delim, pos),
+ LENGTH(SUBSTRING_INDEX(x, delim, pos - 1)) + 1
+ ),
+ delim,
+ ''
+ )
+ );
+ IF output = '' THEN
+ SET output = null;
+ END IF;
+ RETURN output;
+END $$
+
+/*
+ * Create procedure 'NormaliseEpisodeTags'
+ *
+ */
+DROP PROCEDURE IF EXISTS NormaliseEpisodeTags;
+
+CREATE PROCEDURE NormaliseEpisodeTags()
+BEGIN
+ DECLARE i INTEGER;
+
+ SET i = 1;
+ REPEAT
+ INSERT INTO tags (id, tag, lctag)
+ SELECT id, strSplit(tags, ',', i), lower(strSplit(tags, ',', i))
+ FROM eps
+ WHERE strSplit(tags, ',', i) IS NOT NULL;
+ SET i = i + 1;
+ UNTIL ROW_COUNT() = 0
+ END REPEAT;
+END $$
+
+DELIMITER ;
+
+/*
+ * Create table 'tags'
+ *
+ */
+DROP TABLE IF EXISTS tags;
+
+CREATE TABLE tags (
+ id int(5) NOT NULL,
+ tag varchar(200),
+ lctag varchar(200)
+);
+
+-- DROP INDEX tags_all ON tags;
+CREATE UNIQUE INDEX tags_all ON tags (id,tag,lctag);
+
+-- DROP INDEX tags_id ON tags;
+CREATE INDEX tags_id ON tags (id);
+
+-- DROP INDEX tags_tag ON tags;
+CREATE INDEX tags_tag ON tags (tag);
+
+-- DROP INDEX tags_lctag ON tags;
+CREATE INDEX tags_lctag ON tags (lctag);
+
+
+-- vim: syntax=sql:ts=8:ai:tw=78:et:fo=tcrqn21:comments+=b\:--
diff --git a/Database/process_mail_tags b/Database/process_mail_tags
new file mode 100755
index 0000000..5815cd7
--- /dev/null
+++ b/Database/process_mail_tags
@@ -0,0 +1,1472 @@
+#!/usr/bin/env perl
+#===============================================================================
+#
+# FILE: process_mail_tags
+#
+# USAGE: ./process_mail_tags [-help] [-[no]dry-run] [-[no]silent]
+# [-threshold=N] [-config=FILE] [-json=FILE] [-debug=N]
+#
+# DESCRIPTION: Parses mail files found in a directory, finding show, summary
+# and tag information. Optionally writes this in JSON format to
+# a file and, if requested, updates the shows in the HPR database.
+#
+# OPTIONS: ---
+# REQUIREMENTS: ---
+# BUGS: ---
+# NOTES: The project (which got called TSU) is finished. We have
+# processed all the shows in the HPR database missing tags and
+# summaries thanks to many helpers.
+# The script has just been updated to fix a problem spotted in
+# its last usage. See the Journal for October 2021 for the
+# details and for another problem noticed but not fixed.
+# We're keeping this version around for reference in case we
+# build another project of a similar sort - and we may!
+# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
+# VERSION: 0.1.8
+# CREATED: 2015-07-26 15:17:16
+# REVISION: 2021-10-16 21:55:15
+#
+#===============================================================================
+
+use 5.010;
+use strict;
+use warnings;
+use utf8;
+
+use Getopt::Long;
+use Pod::Usage;
+
+use Config::General;
+use File::Find::Rule;
+use File::Copy;
+use Text::CSV::Encoded;
+use DBI;
+
+use List::Util qw{any};
+use JSON;
+use SQL::Abstract;
+
+use Mail::Address;
+use Mail::Field;
+use Mail::Internet;
+use MIME::Parser;
+use MIME::QuotedPrint;
+use Encode qw(decode encode);
+
+#use Lingua::EN::Inflexion qw/ inflect /;
+
+use Log::Handler;
+
+use Data::Dumper;
+
+#
+# Version number (manually incremented)
+#
+our $VERSION = '0.1.8';
+
+#
+# Script and directory names
+#
+( my $PROG = $0 ) =~ s|.*/||mx;
+( my $DIR = $0 ) =~ s|/?[^/]*$||mx;
+$DIR = '.' unless $DIR;
+
+#-------------------------------------------------------------------------------
+# Declarations
+#-------------------------------------------------------------------------------
+#
+# Constants and other declarations
+#
+my $basedir = "$ENV{HOME}/HPR/Database";
+my $configfile = "$basedir/.hpr_db.cfg";
+my $logfile = "$basedir/${PROG}.log";
+
+my $maildrop = "$ENV{HOME}/HPR/MailSpool";
+my $processed = "$maildrop/processed";
+my $rejected = "$maildrop/rejected";
+
+my ( $dbh, $sth1, $h1, $rc );
+#my ( $minshow, $maxshow, $key, $lastkey, $value, $show );
+my ( $minshow, $maxshow );
+my ( %updates, %final, @errors );
+
+#
+# RE for pre-processing the mail message body where we don't want to be too
+# specific. This one doesn't care what the key is and doesn't capture
+# anything.
+#
+my $keyval_re1 = qr{^\s*.+\s*:\s*.*\s*$};
+
+#
+# RE for matching lines in the mail message. Expected to be in the format
+# 'key:value' where the 'key' is one of a set of three.
+#
+my $knownkeys = qr{(?i)show|summary|tags};
+my $keyval_re2 = qr{^\s*($knownkeys)\s*:\s*(.*)\s*$};
+
+#
+# Enable Unicode mode
+#
+binmode STDOUT, ":encoding(UTF-8)";
+binmode STDERR, ":encoding(UTF-8)";
+
+#
+# File names to ignore
+#
+my @ignore = ( '*~', '.*.swp', 'processed', 'rejected' );
+
+#-------------------------------------------------------------------------------
+# Options and arguments
+#-------------------------------------------------------------------------------
+my $DEF_DEBUG = 0;
+my $DEF_THRESHOLD = 30;
+
+#
+# Process options
+#
+my %options;
+Options( \%options );
+
+#
+# Default help
+#
+pod2usage( -msg => "$PROG version $VERSION\n", -verbose => 0, -exitval => 1 )
+ if ( $options{'help'} );
+
+#
+# Full documentation if requested with -doc
+#
+pod2usage(
+ -msg => "$PROG version $VERSION\n",
+ -verbose => 2,
+ -exitval => 1,
+ -noperldoc => 0,
+) if ( $options{'doc'} );
+
+#
+# Collect options
+#
+my $DEBUG = ( defined( $options{debug} ) ? $options{debug} : $DEF_DEBUG );
+
+my $cfgfile
+ = ( defined( $options{config} ) ? $options{config} : $configfile );
+
+my $silent = ( defined( $options{silent} ) ? $options{silent} : 0 );
+my $dry_run = ( defined( $options{'dry-run'} ) ? $options{'dry-run'} : 0 );
+my $threshold = (
+ defined( $options{threshold} ) ? $options{threshold} : $DEF_THRESHOLD );
+my $json = $options{'json'};
+
+#
+# Check values
+#
+$threshold = $DEF_THRESHOLD if ($threshold < 0 || $threshold > 100);
+
+#
+# Sanity check
+#
+die "Unable to find $cfgfile\n" unless ( -e $cfgfile );
+
+#
+# Load configuration data
+#
+my $conf = new Config::General(
+ -ConfigFile => $cfgfile,
+ -InterPolateVars => 1,
+ -ExtendedAccess => 1
+);
+my %config = $conf->getall();
+
+#-------------------------------------------------------------------------------
+# Connect to the database
+#-------------------------------------------------------------------------------
+my $dbhost = $config{database}->{host} // '127.0.0.1';
+my $dbport = $config{database}->{port} // 3306;
+my $dbname = $config{database}->{name};
+my $dbuser = $config{database}->{user};
+my $dbpwd = $config{database}->{password};
+$dbh = DBI->connect( "DBI:MariaDB:host=$dbhost;port=$dbport;database=$dbname",
+ $dbuser, $dbpwd, { AutoCommit => 1, RaiseError => 1 } )
+ or die $DBI::errstr;
+
+#-------------------------------------------------------------------------------
+# Set up logging keeping the default log layout except for the date
+#-------------------------------------------------------------------------------
+my $log = Log::Handler->new();
+
+$log->add(
+ file => {
+ timeformat => "%Y/%m/%d %H:%M:%S",
+ filename => $logfile,
+ maxlevel => 7,
+ minlevel => 0
+ }
+);
+
+#-------------------------------------------------------------------------------
+# Look for work in the maildrop
+#-------------------------------------------------------------------------------
+my $mailfileRE = qr{(\.(?i:eml)|[^.]+)$};
+ #<<<
+my @files = File::Find::Rule
+ ->file()
+ ->name($mailfileRE)
+ ->not( File::Find::Rule->new->name(@ignore) )
+ ->maxdepth(1)
+ ->in($maildrop);
+ #>>>
+
+if ( $DEBUG > 2 ) {
+ print "D> Files found in $maildrop\n";
+ print 'D> ', Dumper( \@files ), "\n";
+}
+
+#
+# There may be nothing there
+#
+unless (@files) {
+ warn "No mail found; nothing to do\n";
+ exit;
+}
+
+#-------------------------------------------------------------------------------
+# Find today's show number
+#-------------------------------------------------------------------------------
+#$sth1 = $dbh->prepare('SELECT id FROM eps WHERE date = curdate()')
+$sth1 = $dbh->prepare(
+ q{SELECT id FROM eps
+ WHERE DATEDIFF(date,CURDATE()) <= 0
+ ORDER BY id DESC LIMIT 1}
+) or die $DBI::errstr;
+if ( $dbh->err ) {
+ warn $dbh->errstr;
+}
+
+$sth1->execute;
+if ( $dbh->err ) {
+ warn $dbh->errstr;
+}
+
+$h1 = $sth1->fetchrow_hashref();
+( $minshow, $maxshow ) = ( 1, $h1->{id}+$threshold );
+
+if ( $DEBUG > 0 ) {
+ print "D> Show number limits\n";
+ print "D> Show min/max: $minshow/$maxshow\n";
+}
+
+#-------------------------------------------------------------------------------
+# Prepare to process MIME messages
+#-------------------------------------------------------------------------------
+my $parser = new MIME::Parser;
+$parser->output_under("/tmp");
+
+#-------------------------------------------------------------------------------
+# Process the files we found
+#-------------------------------------------------------------------------------
+foreach my $file ( sort(@files) ) {
+ print ">> $file\n" unless $silent;
+
+ #
+ # Open the current file and load its contents into a Mail::Internet object
+ #
+ open( my $mfh, '<', $file )
+ or die "$PROG : failed to open input file '$file' : $!\n";
+
+ my $mi_obj = new Mail::Internet($mfh);
+
+ close($mfh)
+ or warn "$PROG : failed to close input file '$file' : $!\n";
+
+ #
+ # This should be to tags@hackerpublicradio.org in one of these headers
+ #
+ my @addresses = (
+ Mail::Address->parse( $mi_obj->head->get('Resent-to') ),
+ Mail::Address->parse( $mi_obj->head->get('Resent-cc') )
+ );
+ if ( !@addresses ) {
+ @addresses = (
+ Mail::Address->parse( $mi_obj->head->get('To') ),
+ Mail::Address->parse( $mi_obj->head->get('Cc') )
+ );
+ }
+
+ #
+ # Report the headers we're interested in
+ #
+ foreach my $addr (@addresses) {
+ my $dest = lc( $addr->address );
+ print "To: $dest\n" unless $silent;
+ }
+ print '~ ' x 40,"\n" unless $silent;
+
+ #
+ # TODO Check the message is actually for us
+ #
+
+ my ( $ct, $cte, @body, $results );
+
+ #
+ # Parse the message in the assumption it's MIME (with MIME::Parser,
+ # returning MIME::Entity objects). Report any errors and warnings.
+ #
+ my $entity = $parser->parse_data( $mi_obj->as_string() );
+ unless ($entity) {
+ $results = $parser->results;
+ foreach my $pmsg ( $results->msgs ) {
+ print STDERR "$pmsg\n";
+ }
+ }
+
+ #
+ # If we got multiple MIME parts then look for the first 'text/plain'
+ # element and save it as the body we'll work on. Otherwise the whole
+ # Mail::Internet body is what we want.
+ # ----
+ # NOTE: 2021-09-27 as an experiment we'll collect all the text/plain
+ # attachments into @body and then process them. It's dangerous in that
+ # junk in the first entity (the standard message body in a MIME message)
+ # will screw up a valid following attachment. We strip signatures so they
+ # will not be an issue.
+ # ----
+ #
+ if ( scalar( $entity->parts ) > 0 ) {
+ #
+ # MIME message
+ #
+ if ( $DEBUG > 2 ) {
+ print "D> Message is MIME with multiple parts\n";
+ foreach my $ent ( $entity->parts ) {
+ print "D> Type: ", $ent->mime_type, "\n";
+ }
+ }
+
+ foreach my $ent ( $entity->parts ) {
+ if ( $ent->mime_type eq 'text/plain' ) {
+ print "D> Processing text/plain part\n" if ( $DEBUG > 2 );
+
+ #$ct = $ent->mime_type;
+ $ct = $ent->effective_type;
+
+ $ent->remove_sig();
+
+ #
+ # FIXME: Using 'bodyhandle' is supposed to decode the entity
+ # body, but this is untested. Also, we have to use 'as_string'
+ # then split on "\n" and stick the newlines back on the array
+ # elements, which seems a bit weird. Have to monitor this to
+ # see how it behaves.
+ # Note: 2021-09-27 collecting the body in @ebody and appending
+ # it to @body as an experiment.
+ #
+ #@body = @{ $ent->body() };
+ my @ebody
+ = map {"$_\n"} split( "\n", $ent->bodyhandle->as_string );
+ print "D> Length of body=", scalar(@ebody), "\n"
+ if ( $DEBUG > 2 );
+
+ #
+ # If we got a non-empty body then exit the loop
+ #
+ # last if @body;
+
+ #
+ # Experimentally add the body contents to the collected
+ # contents
+ #
+ push(@body,@ebody) if @ebody;
+ }
+ }
+
+ #
+ # We found no suitable part so there's nothing to process here. We'll
+ # let the later phases detect this though
+ #
+ unless (@body) {
+ warn "MIME message has no valid text/plain elements\n";
+ }
+ }
+ else {
+ #
+ # Simple (non-MIME) message
+ #
+ if ( $DEBUG > 2 ) {
+ print "D> Message is simple with a single part\n";
+ }
+
+ #
+ # Look to see if we have quoted-printable data in the body
+ # TODO: Detect content types we can't deal with.
+ #
+ $ct = $mi_obj->head->get('Content-type');
+ $cte = $mi_obj->head->get('Content-transfer-encoding');
+
+ #
+ # Grab the body after removing any signature
+ #
+ $mi_obj->remove_sig();
+ @body = @{ $mi_obj->body() };
+
+ #
+ # Decode any QP we found. Note that if we went the other route because
+ # it's a multipart MIME message, the use of 'bodyhandle' should have
+ # decoded any QP.
+ #
+ if ( defined($cte) && $cte =~ /^quoted-printable/ ) {
+ print "D> Decoding QP\n" if ( $DEBUG > 2 );
+ @body = @{ process_qp( \@body ) };
+ }
+ }
+
+ #
+ # Display the body unless we're being silent
+ #
+ unless ($silent) {
+ print '-' x 80, "\n";
+ print join( "", @body ), "\n";
+ print '-' x 80, "\n";
+ }
+
+ #
+ # Initialise for this message
+ #
+ $updates{$file} = {};
+ $updates{$file}->{valid} = 0;
+
+ #
+ # Rationalise the body, removing unnecessary stuff and dealing with
+ # continuation lines
+ #
+ my @buffer = processBody(\@body, $keyval_re1);
+ print "D> \@buffer contents after 'processBody'\n", Dumper( \@buffer ), "\n"
+ if ( $DEBUG > 2 );
+
+ #--------------------------------------------------------------------------
+ # Search the message body for updates
+ #--------------------------------------------------------------------------
+ parseBuffer( $file, \@buffer, $keyval_re2, \%updates, \@errors );
+
+}
+
+#
+# Print the captured data if requested
+#
+if ( $DEBUG > 1 ) {
+ print "D> Data captured from the mail file(s), first pass\n";
+ print "D> ", Dumper( \%updates ), "\n";
+}
+
+#
+# The queries we'll use next
+#
+$sth1 = $dbh->prepare(
+ q{SELECT id,length(tags) l_tags,length(summary) l_summary
+ FROM eps WHERE id = ?}
+) or die $DBI::errstr;
+if ( $dbh->err ) {
+ warn $dbh->errstr;
+}
+
+#-------------------------------------------------------------------------------
+# Walk the captured data doing some more checks
+#-------------------------------------------------------------------------------
+foreach my $file ( sort( keys(%updates) ) ) {
+ #
+ # Check the shows we got against (our copy of) the database
+ #
+ if ( $updates{$file}->{valid} > 0 ) {
+ #
+ # Look at numeric keys (ignoring the 'valid' key)
+ #
+ foreach my $sh ( grep {/^\d+$/} keys( %{ $updates{$file} } ) ) {
+ #
+ # Look up the show number and get the tag and summary details
+ #
+ $sth1->execute($sh);
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ }
+ $h1 = $sth1->fetchrow_hashref();
+
+ #
+ # If we have tags to apply do we have tags already?
+ #
+ if ( exists( $updates{$file}->{$sh}->{tags} ) ) {
+ #print " Found tags\n";
+ if ( $h1->{l_tags} > 0 ) {
+ printf "** Warning ** File %s, show %s - trying to add "
+ . "tags that already exist!\n", $file, $sh
+ unless $silent;
+ $log->warn("$sh: Trying to add duplicate tags")
+ unless $dry_run;
+ delete( $updates{$file}->{$sh}->{tags} );
+ }
+ }
+
+ #
+ # If we have a summary to apply do we have a summary already?
+ #
+ if ( exists( $updates{$file}->{$sh}->{summary} ) ) {
+ #print " Found summary\n";
+ if ( $h1->{l_summary} > 0 ) {
+ printf
+ "** Warning ** File %s, show %s - trying to add "
+ . "a summary that already exists!\n", $file, $sh
+ unless $silent;
+ $log->warn("$sh: Trying to add a duplicate summary")
+ unless $dry_run;
+ delete( $updates{$file}->{$sh}->{summary} );
+ }
+ }
+
+ #
+ # If there are (now) no tags or keys for this show now delete the
+ # show
+ #
+ unless ( scalar( keys( %{ $updates{$file}->{$sh} } ) ) > 0 ) {
+ delete( $updates{$file}->{$sh} );
+ }
+
+ #
+ # If there are (now) no shows relating to the file, mark the file
+ # as invalid
+ #
+ unless ( scalar( keys( %{ $updates{$file} } ) ) > 1 ) {
+ $updates{$file}->{valid} = 0;
+ }
+
+ }
+ }
+
+ #
+ # Deal with each file depending on whether it contained any work
+ #
+ if ( $updates{$file}->{valid} > 0 ) {
+ unless ($dry_run) {
+ print "Moving $file to 'processed'\n" unless $silent;
+ $log->info("Moving $file to 'processed'");
+ warn "Unable to move $file\n"
+ unless ( moveFile( $file, $processed ) );
+ }
+ else {
+ print "Would move $file to 'processed' (dry run)\n"
+ unless $silent;
+ }
+ delete( $updates{$file}->{valid} );
+ }
+ else {
+ unless ($dry_run) {
+ print "Moving $file to 'rejected'\n" unless $silent;
+ $log->info("Moving $file to 'rejected'");
+ warn "Unable to move $file\n"
+ unless ( moveFile( $file, $rejected ) );
+ }
+ else {
+ print "Would move $file to 'rejected' (dry run)\n" unless $silent;
+ }
+ delete( $updates{$file} );
+ }
+}
+
+#$sth1->finish();
+
+#
+# Print the collected data after validation, if requested
+#
+if ( $DEBUG > 1 ) {
+ print "D> Captured data after being validated against the database\n";
+ print "D> ", Dumper( \%updates ), "\n";
+}
+
+#-------------------------------------------------------------------------------
+# Pick out just the show details for the final stage, de-duplicating if
+# necessary
+#-------------------------------------------------------------------------------
+foreach my $file ( sort( keys(%updates) ) ) {
+ foreach my $sh ( sort( keys( %{ $updates{$file} } ) ) ) {
+ if ( exists( $final{$sh} ) ) {
+ foreach my $key ( keys( %{ $updates{$file}->{$sh} } ) ) {
+ if ( exists( $final{$sh}->{$key} ) ) {
+ print "** Duplicates on show $sh key '$key'\n"
+ unless $silent;
+ $log->warn("Duplicates on show $sh key '$key'")
+ unless $dry_run;
+ }
+ else {
+ $final{$sh}->{$key} = $updates{$file}->{$sh}->{$key};
+ }
+ }
+ }
+ else {
+ $final{$sh} = $updates{$file}->{$sh};
+ }
+ }
+}
+
+#
+# Print the final data
+#
+if ( $DEBUG > 1 ) {
+ print "D> Final data form\n";
+ print "D> ", Dumper( \%final ), "\n";
+}
+
+#
+# Output JSON if requested
+#
+if ( defined($json) ) {
+ DumpJSON( $json, \%final );
+}
+
+#-------------------------------------------------------------------------------
+# Make changes to the database if so requested
+#-------------------------------------------------------------------------------
+unless ($dry_run) {
+ my ( $stmt, @bind );
+
+ #
+ # We will dynamically build SQL as we go
+ #
+ my $sql = SQL::Abstract->new;
+
+ #
+ # Loop though the collected show updates making the changes to the
+ # database
+ #
+ foreach my $sh ( sort( keys(%final) ) ) {
+ #
+ # For these updates the use of 'begin_work' will turn off Autocommit up to
+ # the next commit or rollback, so it needs to be in the loop
+ #
+ $rc = $dbh->begin_work or die $dbh->errstr;
+
+ ( $stmt, @bind ) = $sql->update( 'eps', $final{$sh}, { id => $sh } );
+
+ #
+ # Display the statement we constructed if requested
+ #
+ print "D> $stmt\n'", join( "','", @bind ), "'\n\n" if ( $DEBUG > 2 );
+
+ #
+ # Apply the SQL. Any failure results in a rollback and skipping to the
+ # next show, otherwise we commit
+ #
+ $dbh->do( $stmt, undef, @bind );
+ if ( $dbh->err ) {
+ warn $dbh->errstr;
+ eval{ $dbh->rollback };
+ next;
+ }
+ else {
+ $dbh->commit;
+ }
+
+ $log->info( "$sh:" . join( ":", keys( %{ $final{$sh} } ) ) );
+
+ print "Updated show $sh\n" unless $silent;
+
+ }
+
+}
+
+#
+# We need these if we're not in AutoCommit mode it seems
+#
+#$sth1->finish();
+#$dbh->disconnect();
+
+exit;
+
+#=== FUNCTION ================================================================
+# NAME: processBody
+# PURPOSE: Processes the lines of the body of the email (or an
+# attachment)
+# PARAMETERS: $body Reference to the body array
+# $regex Regex to detect 'key:value' lines
+# RETURNS: A cleaned up array for further processing
+# DESCRIPTION: Simply removes comments and blank lines, then performs
+# a 'chomp' on the result. Searches for continuation lines and
+# attaches them to the previous 'key:value' line. Then removes
+# all the continuation lines and returns the result.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub processBody {
+ my ( $body, $regex ) = @_;
+
+ my ( @buffer, $line, $save );
+
+ #
+ # Strip comments and blank lines while copying the body
+ #
+ @buffer = grep { !/^(#|\s*$)/ } @$body;
+
+ #
+ # Chomp everything in our local copy, catering for MSDOS line endings if
+ # found
+ #
+ local $/ = "\r\n";
+ chomp(@buffer);
+
+ #
+ # Find what look like continuation lines and join them to the previous
+ # line that is a 'key:value' line.
+ #
+ $save = 0;
+ for ( my $i = 1; $i <= $#buffer; $i++ ) {
+ $line = $buffer[$i];
+ if ( $line =~ $regex ) {
+ $save = $i;
+ }
+ else {
+ $buffer[$save] .= " $line";
+ }
+ }
+
+ #
+ # Strip away the continuation lines
+ #
+ @buffer = grep { /$regex/ } @buffer;
+
+ #
+ # Return the processed array
+ #
+ return @buffer;
+}
+
+#=== FUNCTION ================================================================
+# NAME: parseBuffer
+# PURPOSE: Parses the buffer built from the email body collecting the
+# valid 'key:value' data info the "stash".
+# PARAMETERS: $file The name of the mail file being processed
+# $buffer Reference to the processed body array
+# $regex Regular expression for parsing 'key:value'
+# $stash Reference to the hash containing the
+# accumulating data parsed from the email
+# $errors Reference to the error buffer
+# RETURNS: Nothing
+# DESCRIPTION: Replaces the old in-line code.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub parseBuffer {
+ my ( $file, $buffer, $regex, $stash, $errors ) = @_;
+
+ my ( $key, $value, $show );
+
+ for my $line (@$buffer) {
+ #
+ # Deal with DOS-style line terminators
+ #
+ $line =~ s/\r$//g;
+ $line =~ s/\s*$//g;
+
+ #
+ # Parse out the key and value from a 'key:value' line
+ #
+ if ( ( $key, $value ) = ( $line =~ $regex ) ) {
+ #
+ # Make the key lower case (so people can use 'Tags', for example,
+ # and not be rejected)
+ #
+ $key = lc($key);
+
+ if ( $key eq 'show' ) {
+ #
+ # It's a show. If it's not just numbers ignore it and signal
+ # that subsequent keys are to be ignored.
+ #
+ unless ( $value =~ /\d+/ ) {
+ $show = $value = undef;
+ push( @$errors,
+ "Invalid show specification: '$line'; skipped" );
+ next;
+ }
+
+ #
+ # Spot a show transition. Validate it and print any
+ # accumulated errors
+ #
+ if ( defined($show) && $show != int($value) ) {
+ $stash->{$file}->{valid} = 1;
+ if (@$errors) {
+ printErrors( $show, $errors, !$silent );
+ }
+ }
+
+ #
+ # Store the integer value rather than a string with leading
+ # zeroes
+ #
+ $show = int($value);
+
+ #
+ # Save the show if it's in range
+ #
+ unless ( $show < $minshow || $show > $maxshow ) {
+ putStash( $stash, $file, $show );
+ }
+ else {
+ $show = $value = undef;
+ push( @$errors, "Show number $show out of range" );
+ next;
+ }
+ }
+ elsif ( $key eq 'summary' ) {
+ next unless $show;
+ $value = formatSummary($value, $errors);
+ if ($value) {
+ unless (
+ putStash( $stash, $file, $show, $key, $value, $errors ) )
+ {
+ print "** Error saving '$key:$value' for show $show\n"
+ unless $silent;
+ }
+ }
+ }
+ elsif ( $key eq 'tags' ) {
+ next unless $show;
+ $value = formatTags($value, $errors);
+ if ($value) {
+ unless (
+ putStash( $stash, $file, $show, $key, $value, $errors ) )
+ {
+ print "** Error saving '$key:$value' for show $show\n"
+ unless $silent;
+ }
+ }
+ }
+ }
+ else {
+ print "** Warning ** Unrecognised line:\n$line\n";
+ }
+
+ #
+ # If the show is defined by this stage then the file is valid
+ #
+ $stash->{$file}->{valid} = 1 if defined($show);
+
+ }
+ continue {
+ if (@$errors) {
+ printErrors( $show, $errors, !$silent );
+ }
+ }
+}
+
+#=== FUNCTION ================================================================
+# NAME: printErrors
+# PURPOSE: Prints the contents of an array of error messages then clears
+# it
+# PARAMETERS: $show The show number (for information)
+# $errors Reference to the array of errors
+# $print Boolean: 1 = print, 0 don't print, just flush
+# RETURNS: Nothing
+# DESCRIPTION: Error messages accumulated elsewhere are printed if requested
+# and the error buffer flushed.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub printErrors {
+ my ( $show, $errors, $print ) = @_;
+
+ $print = 1 unless defined($print);
+ if ($print) {
+ @$errors = map {'** Error ** ' . $_} @$errors;
+ print "Show: $show\n", join( "\n", @$errors ), "\n";
+ }
+ @$errors = ();
+
+ return;
+}
+
+#=== FUNCTION ================================================================
+# NAME: putStash
+# PURPOSE: Puts values into the 'stash' structure for a given file and
+# for a given show within it
+# PARAMETERS: $stash Reference to the hash containing the
+# accumulating data
+# $file Name of the file being processed - used as
+# a hash key
+# $show Number of the show being processed (converted
+# to an integer) - used as a hash key
+# $key The key to be stashed below the show
+# $value The value to be stored with the key
+# $errors Reference to an array for errors
+# RETURNS: True (1) if the storage action succeeded, false (0) otherwise.
+# DESCRIPTION: Hides a lot of the complexity of storing items in the 'stash'
+# structure. Note that the way in which this function is used is
+# a little tricky. If called without values for $key, $value and
+# $errors it simply creates an empty hashref under
+# $stash->{$file}->{$show} ready to be filled with keys and
+# values. It is called in this way once in the main code.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub putStash {
+ my ( $stash, $file, $show, $key, $value, $errors ) = @_;
+
+ if ( exists( $stash->{$file}->{$show} ) ) {
+ if ( exists( $stash->{$file}->{$show}->{$key} ) ) {
+ push(
+ @$errors,
+ "The $key already exist" . ( $key eq 'tags' ? "" : "s")
+ . " for show $show"
+ ) if $errors;
+ return 0;
+ }
+ else {
+ $stash->{$file}->{$show}->{$key} = $value;
+ return 1;
+ }
+ }
+ else {
+ $stash->{$file}->{$show} = {};
+ return 1;
+ }
+}
+
+#=== FUNCTION ================================================================
+# NAME: formatSummary
+# PURPOSE: Trims and cleans up a summary string
+# PARAMETERS: $summary String containing the summary
+# $errors Reference to an array for errors
+# RETURNS: The reformatted summary
+# DESCRIPTION: The summary length must not exceed 100 characters, and we want
+# to reduce multiple whitespace down to the minimum.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub formatSummary {
+ my ($summary, $errors) = @_;
+
+ $summary =~ s/(^\s*|\s*$)//g;
+ $summary =~ s/\s+/ /g;
+ if ( length($summary) > 100 ) {
+ push( @$errors,"Summary is too long; truncated" );
+ $summary = substr( $summary, 0, 100 );
+ }
+
+ return $summary;
+}
+
+#=== FUNCTION ================================================================
+# NAME: formatTags
+# PURPOSE: Normalises a series of tags presented in CSV format
+# PARAMETERS: $tags String containing tags in CSV form
+# $errors Reference to an array for errors
+# RETURNS: The formatted tags as a string
+# DESCRIPTION: Parses the incoming CSV, breaks it down into fields then
+# recombines the result. This seems to be the only way to turn
+# quoted fields into the unquoted ones we want. On the way in
+# the need for good quality of CSV is relaxed a little, and on
+# the way out the need to quote is enforced.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub formatTags {
+ my ( $tags, $errors ) = @_;
+
+ my $result;
+ my $csv = Text::CSV::Encoded->new(
+ { always_quote => 0, # Don't quote everything
+ quote_char => '"', # Use double quotes
+ allow_whitespace => 1, # Parse and strip spaces around
+ # delimiters
+ quote_space => 0, # Prevent internal spaces triggering
+ # quotes
+ encoding_in => "utf8",
+ encoding_out => "utf8",
+ }
+ );
+
+ #
+ # Parse the CSV. Return nothing if it wouldn't validate. Drop all null
+ # items and rebuild as a string.
+ #
+ if ( $csv->parse($tags) ) {
+ my @flds = $csv->fields();
+ @flds = grep { !/^$/ } @flds;
+
+ $csv->combine(@flds);
+ $result = $csv->string();
+
+ if ( length($result) > 200 ) {
+ push( @$errors, "Tags are too long after quoting; removed" );
+ return;
+ }
+ else {
+ return $result;
+ }
+ }
+ else {
+ #
+ # Save the error details from the CSV parse. In list context
+ # 'error_diag' returns the error number, the error message and (in
+ # some circumstances?) the byte index in the current record being
+ # parsed (1-based), the index of the record parsed by the CSV
+ # instance and the field number of what the parser thinks it's
+ # currently parsing.
+ # TODO: make this more resilient.
+ #
+ push( @$errors,
+ "Invalid CSV data found in tags: "
+ . sprintf( "%d: %s [%d,%d,%d]", ( $csv->error_diag() ) ) );
+ # warn "Invalid CSV data found\n";
+ # $csv->error_diag();
+ return;
+ }
+}
+
+#=== FUNCTION ================================================================
+# NAME: moveFile
+# PURPOSE: Moves a file (renames it) taking account of whether the name
+# exists at the destination
+# PARAMETERS: $file file to move
+# $dest destination of the file
+# RETURNS: True (1) if the move succeeded, otherwise false (0)
+# DESCRIPTION: A wrapper around the File::Copy 'move' function. If the target
+# file exists then a version number is appended to the name so
+# that the original is not clobbered.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub moveFile {
+ my ( $file, $dest ) = @_;
+
+ my ( $target, $basename, $prefix, $vsn );
+
+ return 0 unless -e $file;
+
+ #
+ # Handle a destination directory
+ #
+ if ( -d $dest ) {
+ ( $basename = $file ) =~ s|.*/||mx;
+ $target = "$dest/$basename";
+ }
+ else {
+ $target = $dest;
+ }
+
+ #
+ # If the target exists we'll have to do some work, otherwise just do the
+ # move
+ #
+ if ( -e $target ) {
+ if ( ( $prefix, $vsn ) = ( $target =~ /(\.)(\d+)$/ ) ) {
+ while ( -e "$prefix$vsn" ) {
+ $vsn++;
+ $target = "$prefix$vsn";
+ }
+ return move( $file, $target );
+ }
+ else {
+ $vsn = 1;
+ $prefix = $target;
+ $target = "$prefix.$vsn";
+ while ( -e "$prefix.$vsn" ) {
+ $vsn++;
+ $target = "$prefix.$vsn";
+ }
+ return move( $file, $target );
+ }
+ }
+ else {
+ return move( $file, $dest );
+ }
+}
+
+#=== FUNCTION ================================================================
+# NAME: process_qp
+# PURPOSE: Process quoted-printable encoded text in the mail message body
+# PARAMETERS: $body Arrayref containing the lines of the body
+# RETURNS: Reference to an array of processed lines
+# DESCRIPTION: Removes newlines from the body array and any `\r` sequences.
+# It then loops through the lines looking for eny ending in '='
+# and joins them with the next (removing the '=' first). It then
+# deletes the second part of the line. Then it feeds the array
+# through the `decode_qp` routine and adds the newline back on
+# the end. TODO: this works for the present case but may blow up
+# with more challenging cases.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub process_qp {
+ my ($body) = @_;
+
+ #
+ # Trim trailing newlines
+ #
+ chomp( my @body = @$body );
+
+ #
+ # Find split lines and join them
+ #
+ my $i = 0;
+ my $max = $#body;
+ while ( $i <= $max ) {
+ $body[$i] =~ s/\r//;
+ if ( $body[$i] =~ /=$/ ) {
+ $body[$i] =~ s/=$//;
+ $body[$i] .= $body[ $i + 1 ];
+ splice( @body, $i + 1, 1 );
+ $max--;
+ }
+ else {
+ $i++;
+ }
+ }
+
+ #
+ # Decode all quoted-printable stuff from QP and then from UTF-8
+ #
+ @body = map { decode( 'UTF-8', decode_qp($_) ) . "\n" } @body;
+
+ return \@body;
+}
+
+#=== FUNCTION ================================================================
+# NAME: LoadJSON
+# PURPOSE: Load a JSON file
+# PARAMETERS: $file Name of the file containing JSON
+# RETURNS: The resulting JSON as a scalar or a reference (as determined
+# by the JSON module)
+# DESCRIPTION: Opens the nominated file and collects its contents into an
+# array. Joins all of the rows of the array into a string and
+# then converts the result to JSON.
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO:
+#===============================================================================
+sub LoadJSON {
+ my ($file) = @_;
+
+ open( my $fh, '<', $file )
+ or die "Unable to open $file for reading: $!\n";
+ my @jtxt = <$fh>;
+ close($fh);
+
+ my $jtxt = join( '', @jtxt );
+ my $json = JSON->new->utf8;
+
+ return $json->decode($jtxt);
+}
+
+#=== FUNCTION ================================================================
+# NAME: DumpJSON
+# PURPOSE: Dump JSON to a file
+# PARAMETERS: $file Name of file to contain JSON
+# $data Data to be encoded into JSON
+# RETURNS: Nothing
+# DESCRIPTION: Opens the nominated file and writes the JSON-encoded data to
+# it
+# THROWS: No exceptions
+# COMMENTS: None
+# SEE ALSO: N/A
+#===============================================================================
+sub DumpJSON {
+ my ( $file, $data ) = @_;
+
+ my $json = JSON->new->utf8->pretty;
+ open( my $fh, '>', $file )
+ or die "Unable to open $file for writing: $!\n";
+ print $fh $json->encode($data);
+ close($fh);
+
+ return;
+}
+
+#=== FUNCTION ================================================================
+# NAME: Options
+# PURPOSE: Processes command-line options
+# PARAMETERS: $optref Hash reference to hold the options
+# RETURNS: Undef
+# DESCRIPTION:
+# THROWS: no exceptions
+# COMMENTS: none
+# SEE ALSO: n/a
+#===============================================================================
+sub Options {
+ my ($optref) = @_;
+
+ my @options = (
+ "help", "doc", "debug=i", "dry-run!",
+ "silent!", "json=s", "threshold=i", "config=s",
+ );
+
+ if ( !GetOptions( $optref, @options ) ) {
+ pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 );
+ }
+
+ return;
+}
+
+__END__
+
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+# Application Documentation
+#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+#{{{
+
+
+=head1 NAME
+
+process_mail_tags - parse email for updates to the HPR database
+
+=head1 VERSION
+
+This documentation refers to B version 0.1.8
+
+=head1 USAGE
+
+ ./process_mail_tags [-help] [-doc] [-debug=N] [-[no]dry-run] [-[no]silent]
+ [-threshold=N] [-config=FILE] [-json=FILE]
+
+ Examples:
+
+ ./process_mail_tags -dry-run
+ ./process_mail_tags -silent -json=temp.json
+
+=head1 OPTIONS
+
+=over 8
+
+=item B<-help>
+
+Prints a brief help message describing the usage of the program, and then exits.
+
+=item B<-doc>
+
+Displays the entirety of the documentation (using a pager), and then exits. To
+generate a PDF version use:
+
+ pod2pdf process_mail_tags --out=process_mail_tags.pdf
+
+=item B<-debug=N>
+
+Selects a level of debugging. Debug information consists of a line or series
+of lines prefixed with the characters 'D>':
+
+=over 4
+
+=item B<0>
+
+No debug output is generated: this is the default
+
+=item B<1>
+
+Displays the minimum and maximum show numbers which will be used to validate
+incoming data.
+
+=item B<2>
+
+Dumps the structure containing the information parsed from the mail files
+found by the script. Also prints this structure after verification against the
+database.
+
+As well as this the script displays the information for the lower debug
+levels.
+
+=item B<3>
+
+Dumps the array of files found by the script and displays the SQL statements
+built to update the database. Note that the SQL is only displayed in
+B<-nodry-run> mode.
+
+As well as this the script displays the information for the lower debug levels
+
+=back
+
+=item B<-[no]dry-run>
+
+Controls whether the program runs in a mode where it performs all the steps,
+omitting the stage where the mail files are moved and the database is updated.
+The default B<-nodry-run> allows the program to perform the changes.
+
+=item B<-[no]silent>
+
+Controls how much output the program produces. The default, B<-nosilent>,
+results in the program writing details of the email it is processing and what
+it is doing with the files it is reading.
+
+=item B<-threshold=N>
+
+The program checks that each show number it is given is in range. The bottom
+of the range is always 1. The top of the range is computed by looking in the
+database for the last show released and adding a 'threshold' value to it. The
+default value is 30 to allow looking forward into future shows when adding
+summaries or tags.
+
+This threshold can be changed through this option. It is limited to a range of
+0 to 100.
+
+=item B<-config=FILE>
+
+TBA
+
+=item B<-json=FILE>
+
+If specified causes the program to write JSON data to the nominated file
+containing the show number, summary (if given) and tag list (if given) which
+has been parsed from the mail messages that have been processed successfully.
+
+JSON output is generated even if the B<-dry-run> option has been selected.
+This is a useful way of determining what will be changed when the program is
+allowed to alter the database.
+
+=back
+
+=head1 DESCRIPTION
+
+This script processes email which has been saved into files, searching for
+updates to episodes in the HPR database. These updates are for episodes which
+are missing summaries and/or tags, and are being requested from volunteer
+members of the HPR community, who have the option of sending them in via
+email. Other routes for making these changes are planned.
+
+The shows which need summaries and/or tags are listed in an HTML page at
+I. This page is
+generated by another script called B.
+
+The email messages which drive the update process must be simple ASCII
+messages (no attachments, HTML, quoted-printable, etc, though see below) and
+must contain the following elements for each show:
+
+ show:
+ summary:
+ tags:
+
+This format may include comment lines beginning with a '#' as well as blank
+lines (for legibility). The 'show' line is mandatory but either of the other
+two lines may be omitted if the show already has tags or a summary.
+
+The script is now capable of handling very simple MIME messages. It needs an
+empty body element with one text/plain attachment. If more attachments are
+added then they are ignored. The script searches for the first text/plain part
+amongst all of the parts. This aspect may be developed in a later release to
+allow multiple text/plain attachments.
+
+=head1 DIAGNOSTICS
+
+=over 4
+
+=item B<[DBI error messages]>
+
+Type: fatal or warning
+
+Generated when a database interface error has been detected, such as failure
+to connect to the database or failure to prepare or execute a query.
+
+=item B
+
+Type: fatal
+
+The script has failed to open one of the saved mail files it has found.
+
+=item B
+
+Type: warning
+
+The script found no saved mail files and exited.
+
+=item B
+
+Type: warning
+
+The script has failed to close a mail file.
+
+=item B
+
+Type: warning
+
+While processing a saved mail file in MIME format the script failed to find
+any I part to work on. This message will not be processed further.
+
+=item B
+
+Type: warning
+
+While attempting to move a saved mail file from the main spool area to either
+the I or I directory the move command failed. The script
+will have left the file where it was.
+
+=item B