#!/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, $rdfh ); # # 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, $rdfh); } _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 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) 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<-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 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. =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 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 The B 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 section below: =over 4 =item B This is a file containing the recording dates of previous Community News shows. The file is managed by B, and it exists to aid the script B when it is generating the HTML notes for distribution to the volunteers who are contributing to the recording. When B 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. The format of the file is described in the B part of the B section below. =item B