#!/usr/bin/env perl
#===============================================================================
#
#         FILE: make_email
#
#        USAGE: ./make_email [-debug=N] [-month=DATE] [-date=DATE]
#               [-start=START_TIME] [-end=END_TIME] [-output[=FILE]]
#               [-[no]force] [-config=FILE]
#
#  DESCRIPTION: Make 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 (Friday 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=DATE 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.
#
#      OPTIONS: ---
# REQUIREMENTS: ---
#         BUGS: ---
#
#        NOTES: Extensive rewrite. No longer needs a database and computes
#               review months and recording dates itself.
#
#       AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
#      VERSION: 0.3.4
#      CREATED: 2013-10-28 20:35:22
#     REVISION: 2025-04-07 18:15:30
#
#===============================================================================

use v5.36;
use utf8;
use feature qw{ try };
no warnings qw{ experimental::try };

use open ':std', ':encoding(UTF-8)'; # Make all IO UTF-8

use Cwd qw( abs_path );

use Getopt::Long;
BEGIN { $ENV{PERLDOC} = '-MPod::Text::Color'; }
use Pod::Usage qw(pod2usage);           # Use colour-capable Pod::Text

use Config::General;

use File::Copy;

use Date::Parse;

use DateTime;
use DateTime::TimeZone;
use DateTime::Format::Duration;

use Date::Calc qw{:all};

use Template;

use DBI;

use Data::Dumper;

#
# Version number (manually incremented)
#
our $VERSION = '0.3.4';

#
# Script name
#
( my $PROG = $0 ) =~ s|.*/||mx;

#-------------------------------------------------------------------------------
# Declarations
#-------------------------------------------------------------------------------
#
# Constants and other declarations
#
( my $basedir = abs_path($0) ) =~ s|/?[^/]*$||mx;
my $configfile = "$basedir/.${PROG}.cfg";

my ( %recdates );

#
# Run in the script's directory
#
chdir($basedir);

#
# The timezones we want to report. These were generated with
# DateTime::TimeZone->all_names(). Just uncomment the desired elements.
#
my @zones = (
    #{{{ --- time 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',
    #}}}
);

#
# Defaults for options
#
my $DEF_DEBUG = 0;

#
# 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[umentation] or -man
#
pod2usage(
    -msg       => "$PROG version $VERSION\n",
    -verbose   => 2,
    -exitval   => 1,
) if ( $options{'documentation'} );

#
# Collect options
#
my $DEBUG   = ( defined( $options{debug} ) ? $options{debug} : $DEF_DEBUG );
my $month   = $options{month};
my $date    = $options{date};
my $start   = $options{starttime};
my $end     = $options{endtime};
my $outfile = $options{output};
my $force   = ( defined( $options{force} ) ? $options{force} : 0 );

my $cfgfile
    = ( defined( $options{config} ) ? $options{config} : $configfile );

#
# Sanity checking the options
#
die "Unable to find config file '$cfgfile'\n" unless ( -e $cfgfile );
die "Use only one of -month=MONTH or -date=DATE\n"
    if (defined($month) && defined($date));

#-------------------------------------------------------------------------------
# Load configuration data
#-------------------------------------------------------------------------------
my $conf = Config::General->new(
    -ConfigFile      => $cfgfile,
    -InterPolateVars => 1,
    -ExtendedAccess  => 1
);
my %config = $conf->getall();
_debug( $DEBUG >= 2, '%config: ' . Dumper( \%config ) );

#
# Configuration file values for the email text with defaults and/or checks
#
my $cfg_server   = $config{email}->{server}   // 'chatter.skyehaven.net';
my $cfg_port     = $config{email}->{port}     // 64738;
my $cfg_room     = $config{email}->{room}     // 'Hacker Public Radio';
my $cfg_duration = $config{email}->{duration} // 2;                     # Hours
my $cfg_dayname  = $config{email}->{dayname}  // 'Friday';

#
# 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] += $cfg_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};
}

#
# Recording 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 );

#
# The template from the configuration file
#
my $cfg_template = $config{email}->{template};
die "Missing template file '$cfg_template'\n" unless (-e $cfg_template);

#
# Recording date cache filename in the configuration file
#
my $cfg_recdatefile = $config{recdates}->{name};
unless ($cfg_recdatefile) {
    warn "No recording date file defined in configuration";
    say STDERR "Continuing without this file";
}
elsif ( ! -e $cfg_recdatefile) {
    warn "Can't find recording date file '$cfg_recdatefile'";
    say STDERR "Continuing without this file";
    $cfg_recdatefile = undef;
}

#
# Load the recording dates
#
if ($cfg_recdatefile) {
    %recdates = load_cache($cfg_recdatefile);
}

_debug($DEBUG >= 2,
    '$start: ' . coalesce($start,''),
    '$end:   ' . coalesce($end,''),
    '--'
);

if ($DEBUG >= 1) {
    report_settings();
}

#-------------------------------------------------------------------------------
# Use -date=DATE, -month=DATE or default to today's date to compute or collect
# the review month and the recording date. Use Date::Calc format.
#
# If there's a -date=DATE option then it'll be an override for the recording
# date otherwise we'll compute it.
#-------------------------------------------------------------------------------
my @today = Today();
my @recordingdate;
my @reviewmonth;
my $monday = 1;         # Day of week number 1-7, Monday-Sunday
my $offset = day_offset($cfg_dayname)->{offset};

#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
if ( defined($date) ) {
    #
    # Parse and perform rudimentary validation on the argument. Don't change
    # the day in the date. Allow past dates it -force is on.
    #
    @recordingdate = convert_date( $date, 0, $force );

    #
    # Determine the review month
    #
    if (week_of_month(@recordingdate) >= 4) {
        #
        # Use the month of the date
        #
        @reviewmonth = ((@recordingdate)[0,1],1);
    }
    else {
        #
        # Go backwards a month from the month of the recording date
        #
        @reviewmonth = Add_Delta_YM((@recordingdate)[0,1],1,0,-1);
    }
}
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
elsif ( defined($month) ) {
    #
    # Parse the month out of the -month=DATE option. The day is forced to 1,
    # but cannot be omitted from the option. This defines the review month.
    #
    @reviewmonth = convert_date( $month, 1, $force );

    #
    # Compute the next meeting date from now (by finding the next first Monday
    # of the month then backing up two days to the desired day - default
    # Friday).
    #
    my @nextmonth = Add_Delta_YM(@reviewmonth,0,1);
    @recordingdate = make_recording_date( \@nextmonth, $monday, 1, $offset );
}
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
else {
    #
    # This is equivalent to the -month=DATE option except we use the current
    # month which we get from @today and save it as @reviewmonth. We add
    # a month to this to get the next month.
    #
    @reviewmonth = ((@today)[0,1],1);
    my @nextmonth = Add_Delta_YM(@reviewmonth,0,1);

    #
    # 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).
    #
    @recordingdate = make_recording_date( \@nextmonth, $monday, 1, $offset );
}

_debug($DEBUG >= 2, '$offset:        ' . $offset);
_debug($DEBUG >= 2, '@recordingdate: ' . join(',',@recordingdate));
_debug($DEBUG >= 2, '@reviewmonth:   ' . join(',',@reviewmonth));

#-------------------------------------------------------------------------------
# Transfer Date::Calc values into DateTime objects so
# we can get better formatting.
#-------------------------------------------------------------------------------
my ( $dtrevmonth, $dtstart, $dtend );
$dtrevmonth = dc_to_dt( \@reviewmonth );
$dtstart    = dc_to_dt( [ @recordingdate, @starttime ] );
$dtend      = dc_to_dt( [ @recordingdate, @endtime ] );

#
# 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 datetime-related values for the mail message body
#
my ( $revyear, $revmonthno, $revmonthname ) = (
    $dtrevmonth->year,
    $dtrevmonth->month,
    $dtrevmonth->month_name
);

my ( $year, $monthno, $monthname ) = (
    $dtstart->year,
    $dtstart->month,
    $dtstart->month_name
);

my ( $nicedate, $starttime, $endtime ) = (
    $dtstart->strftime("%A, %B %d %Y"),
    $dtstart->strftime("%R (%Z)"),
    $dtend->strftime("%R (%Z)"),
);

_debug($DEBUG >= 2,
    "----",
    "\$revyear: $revyear",
    "\$revmonthno: $revmonthno",
    "\$revmonthname: $revmonthname",
    "\$year: $year",
    "\$monthno: $monthno",
    "\$monthname: $monthname",
    "\$nicedate: $nicedate",
    "\$starttime: $starttime",
    "\$endtime: $endtime",
    "ISO date: ".$dtstart->ymd
);

#
# Build the subject line (with the recording date)
#
my $waittime = ( $days > 6 ? "in $days days"      : "next %A" );
my $next     = ( $days > 6 ? ''                   : 'next ' );
my $forspec  = ( $days > 6 ? "for $revmonthname " : "" );
my $subject  = $dtstart->strftime(
    "HPR Community News ${forspec}- $waittime on %FT%TZ");

_debug( $DEBUG >= 2, "\$subject: $subject" );

#-------------------------------------------------------------------------------
# Open the output file (or STDOUT) - we may need the year and month number to
# do it, if the file name contains '%s'.
#-------------------------------------------------------------------------------
my $outfh;
if ($outfile) {
    $outfile = sprintf( $outfile, sprintf( "%d-%02d", $revyear, $revmonthno ) )
        if ( $outfile =~ /%s/ );

    open( $outfh, ">:encoding(UTF-8)", $outfile )
        or die "Unable to open $outfile for writing: $!\n";
}
else {
    open( $outfh, ">&", \*STDOUT )
        or die "Unable to initialise for writing: $!\n";
}

#
# Build an array of timezone data for the template
#
my @timezones;
for my $tz (@zones) {
    push( @timezones, storeTZ( $dtstart, $dtend, $tz ) );
}

#-------------------------------------------------------------------------------
# Update the date cache now we have the date and time details we need.
#-------------------------------------------------------------------------------
( my $monthkey = $dtrevmonth->ymd ) =~ s/\d+$/01/;
my $datestamp = $dtstart->strftime("%F %T");

if (exists($recdates{$monthkey})) {
    #
    # It exists. Is it different?
    #
    unless ( $recdates{$monthkey} eq $datestamp ) {
        #
        # Save the new data (assuming it's correct)
        #
        $recdates{$monthkey} = $datestamp;
        update_cache( $cfg_recdatefile, \%recdates );
    }
}
else {
    #
    # Add a new record to the end of the cache file
    #
    append_cache( $cfg_recdatefile, sprintf( "%s,%s", $monthkey, $datestamp ) );
}

#-------------------------------------------------------------------------------
# Fill the template
#-------------------------------------------------------------------------------
my $tt = Template->new(
    {   ABSOLUTE => 1,
        ENCODING => 'utf8',
    }
);

my $vars = {
    server    => $cfg_server,
    port      => $cfg_port,
    room      => $cfg_room,
    subject   => $subject,
    timezones => \@timezones,
    utc       => {
        days     => $days,
        revmonth => $revmonthname,
        revyear  => $revyear,
        month    => $monthname,
        year     => $year,
        date     => $nicedate,
        start    => $starttime,
        end      => $endtime,
    },
};

my $document;
$tt->process( $cfg_template,
    $vars, \$document, { binmode => ':utf8' } )
    || die $tt->error(), "\n";

#
# Write to the output file
#
print $outfh $document;

#
# Report the output file name if there is one
#
if ($outfile) {
    say "Output is in $outfile";
}

exit;

#===  FUNCTION  ================================================================
#         NAME: load_cache
#      PURPOSE: Load the date cache into a hash
#   PARAMETERS: $cache_name     Name of file holding the cache
#      RETURNS: Contents of cache as a hash
#  DESCRIPTION: Opens the nominated file, parses each record, and adds the
#               data to a hash. The record should contain the following:
#               * 'YYYY-MM-01' the month for which the details are being
#               recorded
#               * ',' comma field separator
#               * 'YYYY-MM-DD HH:MM:SS' timestamp of the recording
#               The file is closed once it has been scanned.  The function
#               returns the completed hash.
#       THROWS: No exceptions
#     COMMENTS: None
#     SEE ALSO: N/A
#===============================================================================
sub load_cache {
    my ($cache_name) = @_;

    my ( $month, $datetime, %result );

    #
    # Open the file in read mode
    #
    open( my $dcfh, '<', $cache_name )
        or die "$PROG: failed to open '$cache_name': $!\n";

    while ( my $line = <$dcfh> ) {
        chomp($line);
        if ( ( $month, $datetime )
            = ( $line =~ /^(\d{4}-\d{2}-\d{2}),(.*)$/ ) )
        {
            $result{$month} = $datetime;
        }
        # TODO: Report any errors found in the file
    }

    close($dcfh)
        or warn "$PROG: failed to close '$cache_name': $!\n";

    return %result;
}

#===  FUNCTION  ================================================================
#         NAME: append_cache
#      PURPOSE: Append a new line to the cache
#   PARAMETERS: $cache_name     Name of file holding the cache
#               $line           New record to add
#      RETURNS: Nothing
#  DESCRIPTION: Opens the nominated file and appends the new record in $line.
#               The file is closed once it has been updated.
#       THROWS: No exceptions
#     COMMENTS: None
#     SEE ALSO: N/A
#===============================================================================
sub append_cache {
    my ( $cache_name, $line ) = @_;

    #
    # Open the file in append mode
    #
    open( my $dcfh, '>>', $cache_name )
        or die "$PROG: failed to open '$cache_name': $!\n";

    say $dcfh $line;

    close($dcfh)
        or warn "$PROG: failed to close '$cache_name': $!\n";
}

#===  FUNCTION  ================================================================
#         NAME: update_cache
#      PURPOSE: Make changes to an existing line in the cache
#   PARAMETERS: $cache_name     Name of file holding the cache
#               $rhash          Hashref holding the updated cache contents
#      RETURNS: Nothing
#  DESCRIPTION: Makes a backup of the nominated file. Opens, truncates it and
#               positions for writing (using 'seek'). The now empty file is
#               filled with data from the hash and closed.
#       THROWS: No exceptions
#     COMMENTS: Uses 'copy' from File::Copy
#     SEE ALSO: N/A
#===============================================================================
sub update_cache {
    my ( $cache_name, $rhash ) = @_;

    #
    # Copy the cache file to a backup
    #
    copy($cache_name,"${cache_name}~")
        or die "Unable to back up '$cache_name'\n";

    #
    # Open the original file in write mode
    #
    open( my $dcfh, '>', $cache_name )
        or die "$PROG: failed to open '$cache_name': $!\n";

    #
    # Truncate the file and seek to the start again
    #
    truncate($dcfh,0)
        or die "$PROG: failed to truncate '$cache_name': $!\n";
    seek($dcfh,0,0)
        or die "$PROG: failed to seek in '$cache_name': $!\n";

    #
    # Write the cache data to the file
    #
    for my $key (sort(keys(%$rhash))) {
        say $dcfh sprintf("%s,%s",$key, $rhash->{$key});
    }

    close($dcfh)
        or warn "$PROG: failed to close '$cache_name': $!\n";
}

#===  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, "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, "Server", coalesce($cfg_server,'undef');
    printf $fmt, "Port", coalesce($cfg_port,'undef');
    printf $fmt, "Room", coalesce($cfg_room,'undef');
    printf $fmt, "Template", coalesce($cfg_template,'undef');
    printf $fmt, "Recording date file", coalesce($cfg_recdatefile,'undef');
    print "D> ----\n";
}

#===  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: valid_month
#      PURPOSE: Determines if the requested month is valid, meaning the
#               current one or a future one
#   PARAMETERS: $refmonth       Arrayref holding the requested month
#      RETURNS: True if a past month, otherwise false
#  DESCRIPTION: The day of the requested month is forced to 1. Four Julian
#               dates are computed: the start of the requested month, the end of the
#               requested month, the current day and the start of the month
#               after this one. The month is a current or future
#               one if today's date is greater than the start of the month, or
#               the end of the month.
#       THROWS: No exceptions
#     COMMENTS: None
#     SEE ALSO: N/A
#===============================================================================
sub valid_month {
    my ($refmonth) = @_;

    my @month = @$refmonth;

    #
    # Force the date to first of the month
    #
    $month[2] = 1;

    #
    # Make Julian dates:
    # $jstartmonth - start of the requested month
    # $jendmonth - end of the requested month
    # $jtoday - today
    # $jstartnextmonth - the end of the current month plus 1 day
    #
    my $jstartmonth = Date_to_Days(@month);
    my $jendmonth = Date_to_Days(
        Add_Delta_Days( @month, Days_in_Month( @month[ 0, 1 ] ) )
    );
    my $jtoday = Date_to_Days( Today() );
    my $jstartnextmonth = Date_to_Days(
        Add_Delta_Days(
            ( ( Today() )[ 0, 1 ], 1 ),                 # Start of this month
            Days_in_Month( ( Today() )[ 0, 1 ] )        # Days this month
        )
    );

    _debug($DEBUG >= 2,
        "\$jstartmonth: $jstartmonth",
        "\$jendmonth: $jendmonth",
        "\$jtoday: $jtoday",
        "\$jstartnextmonth $jstartnextmonth",
        "----"
    );

    #
    # It's valid if this month or a later one
    #
    return ( $jtoday >= $jstartmonth && $jtoday <= $jendmonth ) ||
        ($jstartmonth >= $jstartnextmonth);

}

#===  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: Takes an arrayref which is expected to have a Date::Calc date
#               and time. If the referenced array is too short it has three
#               zero elements added to it and is checked again, aborting if
#               it's still the wrong length.
#       THROWS: No exceptions
#     COMMENTS: None
#     SEE ALSO: N/A
#===============================================================================
sub dc_to_dt {
    my ($refdt) = @_;

    #
    # Copy the incoming arrayref into an array to avoid writing data back
    #
    my @dt = @$refdt;

    #
    # Check we got a 6-element array and add a default time if not
    #
    if (scalar(@dt) < 6) {
        push(@dt,0,0,0);
    }

    #
    # Should be 6 elements now
    #
    die "Invalid Date::Calc date and time (@dt) in dc_to_dt\n"
        unless (scalar(@dt) == 6);

    #
    # Convert to DateTime to get access to formatting stuff, default to UTC.
    # (Note: %dtargs is a hash and we're using hash slicing to initialise it).
    #
    my ( %dtargs, $dt );
    @dtargs{ 'year', 'month', 'day', 'hour', 'minute', 'second', 'time_zone' }
        = ( @dt, 'UTC' );
    $dt = DateTime->new(%dtargs);

    #
    # Return the date and time as a DateTime object
    #
    return $dt;
}

#===  FUNCTION  ================================================================
#         NAME: week_of_month
#      PURPOSE: Determine the week number of the month a date is in
#   PARAMETERS: $year, $month, $day     fields of a Date::Calc date
#      RETURNS: The computed week number within the month, in the range 1-5
#  DESCRIPTION: This is the algorithm provided in example 5 of the RECIPES
#               section of the Date::Calc manpage. It uses the incoming day
#               number, added to the day number of the first day of the month,
#               subtracts 2 and divides by 7 then adds 1 to the result.
#       THROWS: No exceptions
#     COMMENTS: None
#     SEE ALSO: N/A
#===============================================================================
sub week_of_month {
    my ( $year, $month, $day ) = @_;

    return int( ( $day + Day_of_Week( $year, $month, 1 ) - 2 ) / 7 ) + 1;
}

#===  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
#               $ismonth        Boolean controlling whether the date is to be
#                               forced to the start of the month or not.
#               $force          Boolean defining whether to skip validating
#                               the date
#      RETURNS: The date or the start of the month from the textual date
#               ($textdate) in Date::Calc format
#  DESCRIPTION: Parses the date string and makes a Date::Calc date from the
#               result making the day part is 1 if $override is true.
#               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
#     SEE ALSO: N/A
#===============================================================================
sub convert_date {
    my ( $textdate, $ismonth, $allow_past ) = @_;

    my ( @today, @parsed, @dcdate );

    #
    # Reference date
    #
    @today = Today();

    #
    # Parse and perform rudimentary validation on the $textdate date.
    # Using Date::Parse routine 'strptime' which returns:
    # ($ss,$mm,$hh,$day,$month,$year,$zone) = strptime($date)
    #  0   1   2   3    4      5     6
    #
    # 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;

    @dcdate = (
        ( defined( $parsed[5] ) ? $parsed[5] + 1900 : $today[0] ),    # year
        ( defined( $parsed[4] ) ? $parsed[4] + 1    : $today[1] ),    # month
        ( $ismonth              ? 1                 : $parsed[3] )    # day
    );

    #
    # Unless we've overridden then perform checks:
    # - if we're handling a month call validmonth
    # - otherwise there should be a positive or zero difference in days
    #   between the target date and today's date
    # this is to prevent going backwards in time.
    #
    unless ($allow_past) {
        unless (
            ( $ismonth && ( valid_month( \@dcdate ) ) )
            ||
            ( !$ismonth && ( Date_to_Days(@dcdate) > Date_to_Days(@today) ) )
        )
        {
            warn "convert_date: Invalid date $textdate (in the past)\n";
            die "Use -force to create a back-dated calendar\n";
        }
    }

    return @dcdate;

}

#===  FUNCTION  ================================================================
#         NAME: make_recording_date
#      PURPOSE: Makes a recording date for a given review month
#   PARAMETERS: $refdate
#                       An arrayref to the reference date array (usually
#                       the start of the target month)
#               $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 Date::Calc list
#  DESCRIPTION: We want to compute a simple date with an offset, such as
#               "the Friday before the first Monday of the month". We do
#               this by computing a pre-offset date (first Monday of month)
#               then apply the offset (Friday before).
#       THROWS: No exceptions
#     COMMENTS: Derived from make_date, originally written for my HPR episode on
#               iCalendar. It then got used in reserve_cnews, and was
#               originally used here. It was really for generating a series of
#               dates or reserving a seires of episode slots, and wasn't
#               really suited for this script.
#               Rather than using a date array reference it might be better to
#               use a year, month, date sequence like Date::Calc does.
#     SEE ALSO: N/A
#===============================================================================
sub make_recording_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 );

    #
    # Apply any 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: Reference to 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: day_offset
#      PURPOSE: Given a day name computes day attributes including the
#               (negative) offset in days from the target release day (Monday)
#               to the recording date.
#   PARAMETERS: $dayname        Name of a day of the week
#      RETURNS: Hashref containing the full day name (dayname), the weekday
#               number (wday) and the integer offset (offset) from Monday to
#               the recording day, or undef.
#  DESCRIPTION: Uses the hash '%matches' keyed by regular expressions matching
#               day names. The argument '$cfg_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($cfg_dayname)->{dayname}' to
#               get the full name of the day if needed, or the offset as
#               'day_offset($cfg_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",     "date=s",
        "starttime=s", "endtime=s",
        "month=s",     "output:s",
        "config=s",    "force!",
    );
    # "mail!", "fromaddress=s", "toaddress=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 the text of an HPR Community News recording invitation
             email

=head1 VERSION

This documentation refers to make_email version 0.3.4


=head1 USAGE

    make_email [-help] [-documentation|-man]
        [-month=DATE] [-date=DATE] [-start=START_TIME] [-end=END_TIME]
        [-[no]force] [-config=FILE] [-debug=N]

=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:

    perldoc ./make_email

Run this in the directory where B<make_email> is kept.

=item B<-month=DATE>

Defines the month to be reviewed in the Community News recording for which the
email is being generated. 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<-date=DATE>

This option is a way of providing 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<dayname>) 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<START_TIME>
value must be a valid B<HH:MM> 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<duration>.

=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<END_TIME> value must be a valid B<HH:MM> time specification.

=item B<-output=FILE>

This option defines an output file to receive the mail message text. If the option is
omitted the notes are written to STDOUT, allowing them to be redirected if
required.

The output file name may contain one instance of the characters 'B<%s>'. This
denotes the point at which the year and the review month in the format
B<YYYY-MM> are inserted. For example if the script is being run for reviewing
February 2025 the option:

    -out=HPR_email_%s.txt

will cause the generation of the file:

    HPR_email_2025-02.txt

=item B<-[no]force>

Sometimes the recording of the Community News episode for a month takes place
on the first Friday of the next month. If an attempt is made to run this
script to make the email for such a recording in the month after the review
month, safety checks will prevent it. This option, which is normally off, will
allow the script to run and generate the mail message.

Only use this if an apparent anomaly with the dates is detected. This may
happen if the recording is to be made in the month after the month being
reviewed.

=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<CONFIGURATION AND ENVIRONMENT>.

=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


=back

=head1 DESCRIPTION

=head2 B<Overview>

The script makes an invitation email for the next Community News recording
with times per timezone. The message is structured by a Template Toolkit
template, so its contents can be adjusted without changing this script.

In normal operation the script computes the date of the next recording using
the algorithm "DAY 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.

Alternatively it can use the B<-date=DATE> option to define a non-default
recording date (and time if B<-start=START_TIME> is provided). This allows the
defaults in the configuration file to be overridden.

Only one of B<-month=DATE> and B<-date=DATE> is allowed.

The script creates the text of the body of an email message. It is necessary
to use it with an email client to construct the message to be sent to the HPR
mailing list, usually on the Monday prior to the weekend of the recording. The
script does not send the message itself.

=head2 B<Required files>

The B<make_email> script requires the presence of two files, which are
described in this section. These files are expected to be located in the same
directory used to store the script.

The script also requires a configuration file which is described in the
B<CONFIGURATION AND ENVIRONMENT> section below:

=over 4

=item B<Date Cache>

This is a file containing the recording dates of previous Community News
shows. The file is managed by B<make_email>, and it exists to aid the script
B<make_shownotes> when it is generating the HTML notes for distribution to the
volunteers who are contributing to the recording.

When B<make_shownotes> is generating notes for the volunteers it tries to work
out whether any comments were missed for that month, or whether any for the
current review month were read. It highlights any such cases to assist with
continuity. The date and time of the last recording are needed to achieve
this.

The file name is defined in the configuration file, and the default is
B<recording_dates.dat>.

The format of the file is described in the B<DATE CACHE> part of the
B<CONFIGURATION AND ENVIRONMENT> section below.

=item B<Template>

This is a file written in the Template Toolkit syntax which defines the format
of the email text. The name of this file is defined in the configuration file.
By default it is B<make_email_template.tpl>.

=back

=head1 DIAGNOSTICS

=over 8

=item B<Unable to find config file '...'>

The configuration file specified in B<-config=FILE> (or the default file)
could not be found.

=item B<Use only one of -month=MONTH or -date=DATE>

These options are mutually exclusive. See their specifications earlier in this
document.

=item B<Missing start/end time(s)>

One or both of the start and end times is missing, either from the configuration file or
from the command line options.

=item B<Missing template file '...'>

The template file specified in the configuration file could not be found.

=item B<No recording date file defined in configuration>

The script reads and writes a file, usually called B<recording_dates.dat>,
which is defined in the configuration file. This is used to record the date
and time of the recording for a given month for use by othger scripts. This
warning is reporting that no such file specification has been found in the
configuration file. The script will continue without it.

=item B<Can't find recording date file '...'>

The file for holding recording dates, which is defined in the configuration
file, cannot be found. The script will continue without it.

=item B<Unable to open '...' for writing: ...>

The script cannot open the nominated output file for writing.

=item B<Unable to initialise for writing: ...>

No output file has been nominated, so the script witll write to STDOUT. It has
failed to open this channel however.

=item B<...: failed to open '...': ...>

The date cache file could not be opened.

=item B<...: failed to close '...': ...>

Type: warning

The date cache file could not be closed.

=item B<Unable to back up '...'>

The script needs to append to the date cache and it is trying to make a backup
of it before it does so. This process has failed.

=item B<Invalid -date=DATE option '...'>

Type: fatal

An invalid date has been supplied via this option.

=item B<...: failed to truncate '...': ...> or
B<...: failed to seek in '...': ...>

Type: fatal

The script is attempting to update a line in the date cache, but has failed to
perform an operation on it.

=item B<Invalid time: ...>

Type: fatal

The script is checking one of the times provided and has found it to be
invalid.

=item B<Invalid Date::Calc date and time (...) in dc_to_dt>

Type: fatal

The script has failed to validate a date and time value (built from options
such as B<-date=DATE> and B<-start=TIME>).

=item B<Unable to parse date '...'>

Type: fatal

The script has failed to parse a date from B<-date=DATE> or B<-month=DATE>.

=item B<"convert_date: Invalid date ... (in the past)>

Type: fatal

The date specified in B<-date=DATE> or B<-month=DATE> is in the past. There is
a supplementary message: I<Use -force to create a back-dated calendar>

=item B<Various Template Toolkit messages>

The program can generate warning messages from the Template.

=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:

    <email>
        server    = MUMBLE_SERVER_NAME
        port      = MUMBLE_PORT
        room      = NAME_OF_ROOM
        dayname   = DAY_OF_WEEK_OF_RECORDING
        starttime = 16:00:00
        endtime   = 18:00:00
        duration  = 02 # hours
        template  = NAME_OF_TEMPLATE
    </email>

=head2 DATE CACHE

    <recdates>
        name = recording_dates.dat
    </recdates>

The program will update a cache of recording dates and times per month. This
is useful for the script B<make_shownotes> which needs to know about the
Community News show recording time so it can determine how to display
information about comments. See the details for this script.

The format of the lines in the file is:

    MONTH,TIMESTAMP

Note the separating comma. The month is an ISO8601 date where the day part is
always B<01>. The timestamp part is the date and time of the recording in the
format:

    YYYY-MM-DD HH:MM:SS

For example:

    2024-12-01,2025-01-03 15:00:00
    2025-01-01,2025-01-31 15:00:00
    2025-02-01,2025-02-28 16:00:00

The dates and times are derived from the configuration file defaults (and those
computed in the script), or the options given when running the script.

The contents of this cache are loaded into the B<make_email> script. If there
is no record for the month being processed, one is appended to the file. If
the details already exist they are updated unless they are the same as those
stored.

A backup of the file is made if the data in a record is being updated. This
file has the same name as the cache file with a B<~> character appended to it.

=head1 EXAMPLES

=over 4

=item B<Specify a future recording date>

    make_email -date=2025-08-01

Generates the text of an email for a specific date (expected to be in the
future). The first Monday of August 2025 is 2025-08-04. The date used in the
example is the Friday before that. The script will compute the month to be
reviewed (July 2025) from this date and write the email message text
accordingly, to STDOUT.

This would be a way of testing the generation of the email text for a future
date, as opposed to the next one. It will cause the writing of a record in the
data cache file, but, since this will be adjusted if there are any changes in
the future, this should not be a problem.

=item B<Specify and force a review month in the recent past>

    make_email -force -month=2025-03-01 -start=16:00 -out=HPR_email_%s.txt

Assume this is run in early April 2025 to review the episodes for March. The
recording date for that month will be computed to be 2025-04-04, with the
result being released on 2025-04-07.

Normally, generating email for March would not be allowed since it's in the
past, so B<-force> is needed to override the checks. The start day is the
default Friday, and the start time is set to 16:00. The message (email body)
is written to B<HPR_email_2025-03.txt>. The date cache
(B<recording_dates.dat>) will be updated with the line:

    2025-03-01,2025-04-04 16:00:00

=back

=head1 DEPENDENCIES

    Config::General
    Cwd
    DBI
    Data::Dumper
    Date::Calc
    Date::Parse
    DateTime
    DateTime::Format::Duration
    DateTime::TimeZone
    File::Copy
    Getopt::Long
    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 - 2025

=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
