#!/usr/bin/env perl #=============================================================================== # # FILE: make_email # # USAGE: ./make_email [-debug=N] [-month=DATE] [-from=ADDRESS] # [-to=ADDRESS] [-[no]mail] [-date=DATE] [-start=START_TIME] # [-end=END_TIME] [-config=FILE] [-dbconfig=FILE] # # DESCRIPTION: Make and send an invitation email for the next Community News # with times per timezone. # # The configuration file (.make_email.cfg) defines the name of # the email template and the defaults used when generating the # message. The date of the recording is computed from the # current month (Saturday before the first Monday of the month # when the show will be posted). It can also be specified # through the -date=DATE option. # # The month the email relates to can be changed through the # -month=DATA option, though this is rarely used. Use a date of # the format 'YYYY-MM-DD' here. The day is ignored but the year # and month are used in the computation. The month specified # must be in the future. # # The database configuration file defines the database to be # used to compute the date and show number. Use .hpr_db.cfg for # the local MariaDB copy (for testing) and .hpr_livedb.cfg for # the live database (over the ssh tunnel, which must have been # opened already). # # OPTIONS: --- # REQUIREMENTS: --- # BUGS: --- # # NOTES: Does not send the email at present. Needs work # 2022-02-28: DBD::MariaDB has vanished, had to revert to MySQL # again # # AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com # VERSION: 0.2.7 # CREATED: 2013-10-28 20:35:22 # REVISION: 2024-05-24 18:53:17 # #=============================================================================== use 5.010; use strict; use warnings; use Getopt::Long; use Pod::Usage; use Config::General; use Date::Parse; use DateTime; use DateTime::TimeZone; use DateTime::Format::Duration; use Date::Calc qw{:all}; use Template; use Mail::Mailer; use DBI; use Data::Dumper; # # Version number (manually incremented) # our $VERSION = '0.2.7'; # # Script name # ( my $PROG = $0 ) =~ s|.*/||mx; #------------------------------------------------------------------------------- # Declarations #------------------------------------------------------------------------------- # # Constants and other declarations # my $basedir = "$ENV{HOME}/HPR/Community_News"; my $configfile1 = "$basedir/.${PROG}.cfg"; my $configfile2 = "$basedir/.hpr_db.cfg"; my ( $dbh, $sth1, $sth2, $sth3, $h1, $h2, $rv ); # # The timezones we want to report. These were generated with # DateTime::TimeZone->all_names(). Just uncomment the desired elements. # my @zones = ( #{{{ #'Africa/Abidjan', #'Africa/Accra', #'Africa/Addis_Ababa', #'Africa/Algiers', #'Africa/Asmara', #'Africa/Bamako', #'Africa/Bangui', #'Africa/Banjul', #'Africa/Bissau', #'Africa/Blantyre', #'Africa/Brazzaville', #'Africa/Bujumbura', #'Africa/Cairo', #'Africa/Casablanca', #'Africa/Ceuta', #'Africa/Conakry', #'Africa/Dakar', #'Africa/Dar_es_Salaam', #'Africa/Djibouti', #'Africa/Douala', #'Africa/El_Aaiun', #'Africa/Freetown', #'Africa/Gaborone', #'Africa/Harare', #'Africa/Johannesburg', #'Africa/Kampala', #'Africa/Khartoum', #'Africa/Kigali', #'Africa/Kinshasa', #'Africa/Lagos', #'Africa/Libreville', #'Africa/Lome', #'Africa/Luanda', #'Africa/Lubumbashi', #'Africa/Lusaka', #'Africa/Malabo', #'Africa/Maputo', #'Africa/Maseru', #'Africa/Mbabane', #'Africa/Mogadishu', #'Africa/Monrovia', #'Africa/Nairobi', #'Africa/Ndjamena', #'Africa/Niamey', #'Africa/Nouakchott', #'Africa/Ouagadougou', #'Africa/Porto-Novo', #'Africa/Sao_Tome', #'Africa/Tripoli', #'Africa/Tunis', #'Africa/Windhoek', #'America/Adak', #'America/Anchorage', #'America/Antigua', #'America/Araguaina', #'America/Argentina/Buenos_Aires', #'America/Argentina/Catamarca', #'America/Argentina/Cordoba', #'America/Argentina/Jujuy', #'America/Argentina/La_Rioja', #'America/Argentina/Mendoza', #'America/Argentina/Rio_Gallegos', #'America/Argentina/Salta', #'America/Argentina/San_Juan', #'America/Argentina/San_Luis', #'America/Argentina/Tucuman', #'America/Argentina/Ushuaia', #'America/Asuncion', #'America/Atikokan', #'America/Bahia', #'America/Bahia_Banderas', #'America/Barbados', #'America/Belem', #'America/Belize', #'America/Blanc-Sablon', #'America/Boa_Vista', #'America/Bogota', #'America/Boise', #'America/Cambridge_Bay', #'America/Campo_Grande', #'America/Cancun', #'America/Caracas', #'America/Cayenne', #'America/Cayman', 'America/Chicago', #'America/Chihuahua', #'America/Costa_Rica', #'America/Creston', #'America/Cuiaba', #'America/Curacao', #'America/Danmarkshavn', #'America/Dawson', #'America/Dawson_Creek', #'America/Denver', #'America/Detroit', #'America/Edmonton', #'America/Eirunepe', #'America/El_Salvador', #'America/Fortaleza', #'America/Glace_Bay', #'America/Godthab', #'America/Goose_Bay', #'America/Grand_Turk', #'America/Guatemala', #'America/Guayaquil', #'America/Guyana', #'America/Halifax', #'America/Havana', #'America/Hermosillo', #'America/Indiana/Indianapolis', #'America/Indiana/Knox', #'America/Indiana/Marengo', #'America/Indiana/Petersburg', #'America/Indiana/Tell_City', #'America/Indiana/Vevay', #'America/Indiana/Vincennes', #'America/Indiana/Winamac', #'America/Inuvik', #'America/Iqaluit', #'America/Jamaica', #'America/Juneau', #'America/Kentucky/Louisville', #'America/Kentucky/Monticello', #'America/La_Paz', #'America/Lima', 'America/Los_Angeles', #'America/Maceio', #'America/Managua', #'America/Manaus', #'America/Martinique', #'America/Matamoros', #'America/Mazatlan', #'America/Menominee', #'America/Merida', #'America/Metlakatla', #'America/Mexico_City', #'America/Miquelon', #'America/Moncton', #'America/Monterrey', #'America/Montevideo', #'America/Montreal', #'America/Nassau', 'America/New_York', #'America/Nipigon', #'America/Nome', #'America/Noronha', #'America/North_Dakota/Beulah', #'America/North_Dakota/Center', #'America/North_Dakota/New_Salem', #'America/Ojinaga', #'America/Panama', #'America/Pangnirtung', #'America/Paramaribo', #'America/Phoenix', #'America/Port-au-Prince', #'America/Port_of_Spain', #'America/Porto_Velho', #'America/Puerto_Rico', #'America/Rainy_River', #'America/Rankin_Inlet', #'America/Recife', #'America/Regina', #'America/Resolute', #'America/Rio_Branco', #'America/Santa_Isabel', #'America/Santarem', #'America/Santiago', #'America/Santo_Domingo', #'America/Sao_Paulo', #'America/Scoresbysund', #'America/Sitka', #'America/St_Johns', #'America/Swift_Current', #'America/Tegucigalpa', #'America/Thule', #'America/Thunder_Bay', #'America/Tijuana', #'America/Toronto', #'America/Vancouver', #'America/Whitehorse', #'America/Winnipeg', #'America/Yakutat', #'America/Yellowknife', #'Antarctica/Casey', #'Antarctica/Davis', #'Antarctica/DumontDUrville', #'Antarctica/Macquarie', #'Antarctica/Mawson', #'Antarctica/Palmer', #'Antarctica/Rothera', #'Antarctica/Syowa', #'Antarctica/Vostok', #'Asia/Aden', #'Asia/Almaty', #'Asia/Amman', #'Asia/Anadyr', #'Asia/Aqtau', #'Asia/Aqtobe', #'Asia/Ashgabat', #'Asia/Baghdad', #'Asia/Bahrain', #'Asia/Baku', #'Asia/Bangkok', #'Asia/Beirut', #'Asia/Bishkek', #'Asia/Brunei', #'Asia/Choibalsan', #'Asia/Chongqing', #'Asia/Colombo', #'Asia/Damascus', #'Asia/Dhaka', #'Asia/Dili', #'Asia/Dubai', #'Asia/Dushanbe', #'Asia/Gaza', #'Asia/Harbin', #'Asia/Hebron', #'Asia/Ho_Chi_Minh', 'Asia/Hong_Kong', #'Asia/Hovd', #'Asia/Irkutsk', #'Asia/Jakarta', #'Asia/Jayapura', #'Asia/Jerusalem', #'Asia/Kabul', #'Asia/Kamchatka', #'Asia/Karachi', #'Asia/Kashgar', #'Asia/Kathmandu', #'Asia/Khandyga', #'Asia/Kolkata', #'Asia/Krasnoyarsk', #'Asia/Kuala_Lumpur', #'Asia/Kuching', #'Asia/Kuwait', #'Asia/Macau', #'Asia/Magadan', #'Asia/Makassar', #'Asia/Manila', #'Asia/Muscat', #'Asia/Nicosia', #'Asia/Novokuznetsk', #'Asia/Novosibirsk', #'Asia/Omsk', #'Asia/Oral', #'Asia/Phnom_Penh', #'Asia/Pontianak', #'Asia/Pyongyang', #'Asia/Qatar', #'Asia/Qyzylorda', #'Asia/Rangoon', #'Asia/Riyadh', #'Asia/Sakhalin', #'Asia/Samarkand', #'Asia/Seoul', #'Asia/Shanghai', #'Asia/Singapore', #'Asia/Taipei', #'Asia/Tashkent', #'Asia/Tbilisi', #'Asia/Tehran', #'Asia/Thimphu', #'Asia/Tokyo', #'Asia/Ulaanbaatar', #'Asia/Urumqi', #'Asia/Ust-Nera', #'Asia/Vientiane', #'Asia/Vladivostok', #'Asia/Yakutsk', #'Asia/Yekaterinburg', #'Asia/Yerevan', #'Atlantic/Azores', #'Atlantic/Bermuda', #'Atlantic/Canary', #'Atlantic/Cape_Verde', #'Atlantic/Faroe', #'Atlantic/Madeira', #'Atlantic/Reykjavik', #'Atlantic/South_Georgia', #'Atlantic/St_Helena', #'Atlantic/Stanley', #'Australia/Adelaide', 'Australia/Brisbane', #'Australia/Broken_Hill', #'Australia/Currie', #'Australia/Darwin', #'Australia/Eucla', #'Australia/Hobart', #'Australia/Lindeman', #'Australia/Lord_Howe', #'Australia/Melbourne', 'Australia/Perth', 'Australia/Sydney', #'CET', #'CST6CDT', #'EET', #'EST', #'EST5EDT', 'Europe/Amsterdam', #'Europe/Andorra', #'Europe/Athens', #'Europe/Belgrade', #'Europe/Berlin', #'Europe/Brussels', #'Europe/Bucharest', #'Europe/Budapest', #'Europe/Chisinau', #'Europe/Copenhagen', #'Europe/Dublin', #'Europe/Gibraltar', #'Europe/Helsinki', #'Europe/Istanbul', #'Europe/Kaliningrad', #'Europe/Kiev', #'Europe/Lisbon', 'Europe/London', #'Europe/Luxembourg', #'Europe/Madrid', #'Europe/Malta', #'Europe/Minsk', #'Europe/Monaco', #'Europe/Moscow', #'Europe/Oslo', #'Europe/Paris', #'Europe/Prague', #'Europe/Riga', #'Europe/Rome', #'Europe/Samara', #'Europe/Simferopol', #'Europe/Sofia', #'Europe/Stockholm', #'Europe/Tallinn', #'Europe/Tirane', #'Europe/Uzhgorod', #'Europe/Vienna', #'Europe/Vilnius', #'Europe/Volgograd', #'Europe/Warsaw', #'Europe/Zaporozhye', 'Europe/Zurich', #'HST', #'Indian/Antananarivo', #'Indian/Chagos', #'Indian/Christmas', #'Indian/Cocos', #'Indian/Comoro', #'Indian/Kerguelen', #'Indian/Mahe', #'Indian/Maldives', #'Indian/Mauritius', #'Indian/Mayotte', #'Indian/Reunion', #'MET', #'MST', #'MST7MDT', #'PST8PDT', #'Pacific/Apia', 'Pacific/Auckland', #'Pacific/Chatham', #'Pacific/Chuuk', #'Pacific/Easter', #'Pacific/Efate', #'Pacific/Enderbury', #'Pacific/Fakaofo', #'Pacific/Fiji', #'Pacific/Funafuti', #'Pacific/Galapagos', #'Pacific/Gambier', #'Pacific/Guadalcanal', #'Pacific/Guam', #'Pacific/Honolulu', #'Pacific/Kiritimati', #'Pacific/Kosrae', #'Pacific/Kwajalein', #'Pacific/Majuro', #'Pacific/Marquesas', #'Pacific/Midway', #'Pacific/Nauru', #'Pacific/Niue', #'Pacific/Norfolk', #'Pacific/Noumea', #'Pacific/Pago_Pago', #'Pacific/Palau', #'Pacific/Pitcairn', #'Pacific/Pohnpei', #'Pacific/Port_Moresby', #'Pacific/Rarotonga', #'Pacific/Saipan', #'Pacific/Tahiti', #'Pacific/Tarawa', #'Pacific/Tongatapu', #'Pacific/Wake', #'Pacific/Wallis', 'UTC', #'WET', #}}} ); # # Enable Unicode mode # binmode STDOUT, ":encoding(UTF-8)"; binmode STDERR, ":encoding(UTF-8)"; # # Defaults for options # my $DEF_DEBUG = 0; my $DEF_FROM = 'Dave.Morriss@gmail.com'; my $DEF_TO = 'perloid@autistici.org'; # # Options and arguments # my %options; Options( \%options ); # # Default help # pod2usage( -msg => "$PROG version $VERSION\n", -verbose => 0, -exitval => 1 ) if ( $options{'help'} ); # # Full documentation if requested with -doc # pod2usage( -msg => "$PROG version $VERSION\n", -verbose => 2, -exitval => 1, # -noperldoc => 0, ) if ( $options{'documentation'} ); # # Collect options # my $DEBUG = ( defined( $options{debug} ) ? $options{debug} : $DEF_DEBUG ); my $month = $options{month}; my $mail = ( defined( $options{mail} ) ? $options{mail} : 0 ); my $from_address = ( defined( $options{fromaddress} ) ? $options{fromaddress} : $DEF_FROM ); my $to_address = ( defined( $options{toaddress} ) ? $options{toaddress} : $DEF_TO ); my $date = $options{date}; my $start = $options{starttime}; my $end = $options{endtime}; # This value is in the configuration file and can't be overridden. The planned # end time can be specified however. #my $duration = $options{duration}; my $cfgfile = ( defined( $options{config} ) ? $options{config} : $configfile1 ); my $dbcfgfile = ( defined( $options{dbconfig} ) ? $options{dbconfig} : $configfile2 ); # # Use the 'testfile' mailer if option -nomail was chosen. This writes the file # 'mailer.testfile' and sends no message # my $mailertype = ( $mail ? 'sendmail' : 'testfile' ); # # Sanity checking the options # die "Unable to find $cfgfile\n" unless ( -e $cfgfile ); die "Use only one of -month=MONTH or -date=DATE\n" if (defined($month) && defined($date)); #die "Use only one of -endtime=TIME or -duration=HOURS\n" # if (defined($end) && defined($duration)); #------------------------------------------------------------------------------- # Load script and database configuration data #------------------------------------------------------------------------------- my $conf = new Config::General( -ConfigFile => $cfgfile, -InterPolateVars => 1, -ExtendedAccess => 1 ); my %config = $conf->getall(); #print Dumper( \%config ), "\n"; # # Load database configuration data # my $dbconf = new Config::General( -ConfigFile => $dbcfgfile, -InterPolateVars => 1, -ExtendedAccess => 1 ); my %dbconfig = $dbconf->getall(); #print Dumper( \%dbconfig ), "\n"; # # Configuration file values with defaults and/or checks # my $server = $config{email}->{server} // 'chatter.skyehaven.net'; my $port = $config{email}->{port} // 64738; my $room = $config{email}->{room} // 'Hacker Public Radio'; my $duration = $config{email}->{duration} // 2; my $dayname = $config{email}->{dayname} // 'Sunday'; # # If we had a start time specified then check it and ensure the end time makes # sense. Otherwise use the configuration file defaults, but don't check them. # if ($start) { # # Check start time and add a seconds field if needed # $start = validate_time($start); # # The end time usually needs to be 2 hours from the start if not # specified. The actual duration is specified in the configuration file # (with a default above). # unless ($end) { my @end = split( ':', $start ); $end[0] += $duration; $end = join(':',@end); } # # Check and add a seconds field if needed # $end = validate_time($end); } else { $start = $config{email}->{starttime}; $end = $config{email}->{endtime}; } # # Start and end times from options or the configuration file # my @starttime = split( ':', $start ); my @endtime = split( ':', $end ); die "Missing start/end time(s)\n" unless ( @starttime && @endtime ); my $template = $config{email}->{template}; die "Missing template file $template\n" unless (-e $template); _debug($DEBUG >= 2, '$start: ' . coalesce($start,''), '$end: ' . coalesce($end,''), '--' ); if ($DEBUG >= 1) { report_settings(); } #------------------------------------------------------------------------------- # Connect to the database # 2021-12-24: moved to MariaDB # 2022-02-28: the MariaDB driver has gone away apparently. Reverted to MySQL # again #------------------------------------------------------------------------------- my $dbhost = $dbconfig{database}->{host} // '127.0.0.1'; my $dbport = $dbconfig{database}->{port} // 3306; my $dbname = $dbconfig{database}->{name}; my $dbuser = $dbconfig{database}->{user}; my $dbpwd = $dbconfig{database}->{password}; #$dbh = DBI->connect( "DBI:MariaDB:database=$dbname;host=$dbhost;port=$dbport", # $dbuser, $dbpwd, { AutoCommit => 1 } ) # or croak $DBI::errstr; $dbh = DBI->connect( "dbi:mysql:host=$dbhost;port=$dbport;database=$dbname", $dbuser, $dbpwd, { AutoCommit => 1 } ) or die $DBI::errstr; # # Enable client-side UTF8 # $dbh->{mysql_enable_utf8} = 1; # # Date and time values using Date::Calc format # my @today = Today(); my @startdate; my @startmonth; my @reviewdate; my $monday = 1; # Day of week number 1-7, Monday-Sunday my $offset = day_offset($dayname)->{offset}; #------------------------------------------------------------------------------- # Work out the start date from the -date=DATE option, the -month=DATE option # or the current date. #------------------------------------------------------------------------------- # # If there's an argument then it'll be an override for the start date # otherwise we'll compute it. # if ( defined($date) ) { # # Parse and perform rudimentary validation on the argument # my @parsed = strptime($date); die "Invalid -date=DATE option '$date'\n" unless ( defined( $parsed[3] ) && defined( $parsed[4] ) && defined( $parsed[5] ) ); $parsed[5] += 1900; $parsed[4] += 1; @startdate = @parsed[ 5, 4, 3 ]; die "Date is in the past '$date'; aborting\n" unless ( Date_to_Days(@startdate) > Date_to_Days(@today) ); } elsif ( defined($month) ) { # # Parse the month out of the -month=DATE argument # my @parsed = strptime($month); die "Invalid -month=DATE option '$month'\n" unless ( defined( $parsed[3] ) && defined( $parsed[4] ) && defined( $parsed[5] ) ); $parsed[5] += 1900; $parsed[4] += 1; @startmonth = @parsed[ 5, 4, 3 ]; die "Date is in the past '$month'; aborting\n" unless ( Date_to_Days(@startmonth) > Date_to_Days(@today) ); # # Compute the next meeting date from now (by finding the next first Monday # of the month then backing up two days to the Saturday). # @startdate = make_date( \@startmonth, $monday, 1, $offset ); } else { # # Compute the next meeting date from now (by finding the next first Monday # of the month then backing up a number of days to the required date). # @startdate = make_date( \@today, $monday, 1, $offset ); } _debug($DEBUG >= 2, '@startdate: ' . join(',',@startdate)); # # The month being reviewed is sometimes the same month and sometimes the month # before. # if ( $startdate[1] eq $today[1] ) { @reviewdate = @startdate; } else { @reviewdate = Add_Delta_YM( @startdate, 0, -1 ); } _debug($DEBUG >= 2, '@reviewdate: ' . join(',',@reviewdate)); # # Transfer Date::Calc values into hashes for initialising DateTime objects so # we can play time zone games # my ( %dtargs, $dtstart, $dtend ); @dtargs{ 'year', 'month', 'day', 'hour', 'minute', 'second', 'time_zone' } = ( @startdate, @starttime, 'UTC' ); $dtstart = DateTime->new(%dtargs); @dtargs{ 'hour', 'minute', 'second' } = (@endtime); $dtend = DateTime->new(%dtargs); # # Compute the number of days until the recording # my $dtnow = DateTime->now( time_zone => 'UTC' ); my $dtoffset = $dtstart->delta_days($dtnow); my $dtf = DateTime::Format::Duration->new( pattern => '%e' ); my $days = $dtf->format_duration($dtoffset); # # Formatted dates for the mail message body # my ( $year, $monthname, $nicedate, $starttime, $endtime ) = ( $dtstart->strftime("%Y"), Month_to_Text( $reviewdate[1] ), $dtstart->strftime("%A, %B %d %Y"), $dtstart->strftime("%R (%Z)"), $dtend->strftime("%R (%Z)"), ); _debug($DEBUG >= 2, "\$year: $year", "\$monthname: $monthname", "\$nicedate: $nicedate", "\$starttime: $starttime", "\$endtime: $endtime" ); # # Build the subject line # my $waittime = ( $days > 6 ? "in $days days" : "next %A" ); my $next = ( $days > 6 ? '' : 'next ' ); my $forspec = ( $days > 6 ? "for $monthname " : "" ); my $subject = $dtstart->strftime( "HPR Community News ${forspec}- $waittime on %FT%TZ"); _debug( $DEBUG >= 2, "\$subject: $subject" ); # # Prepare to send mail # my $mailer = Mail::Mailer->new($mailertype); # # Generate the headers we need # $mailer->open( { To => $to_address, From => $from_address, Subject => $subject, } ); # # Build an array of timezone data for the template # my @timezones; for my $tz (@zones) { push( @timezones, storeTZ( $dtstart, $dtend, $tz ) ); } #------------------------------------------------------------------------------- # Find the number of the show with the notes. Take care because the recording # date might not be on the weekend before the show is released. #------------------------------------------------------------------------------- my $isodate = $dtstart->ymd; $sth1 = $dbh->prepare(q{ SELECT id FROM eps WHERE date > ? AND date_format(date,"%W") = 'Monday' AND title LIKE 'HPR Community News%' ORDER BY date LIMIT 1 }); $sth1->execute($isodate); if ( $dbh->err ) { warn $dbh->errstr; } _debug( $DEBUG >= 2, "\$isodate: $isodate" ); unless ( $h1 = $sth1->fetchrow_hashref ) { warn "Unable to find a reserved show on the specified date - cannot continue\n"; exit 1; } my $shownotes = $h1->{id}; _debug( $DEBUG >= 2, "\$shownotes (slot): $shownotes" ); $sth1->finish; $dbh->disconnect; #------------------------------------------------------------------------------- # Fill the template #------------------------------------------------------------------------------- my $tt = Template->new( { ABSOLUTE => 1, ENCODING => 'utf8', } ); my $vars = { # subject => $subject, # from => $from_address, # to => $to_address, server => $server, port => $port, room => $room, timezones => \@timezones, utc => { days => $days, month => $monthname, year => $year, date => $nicedate, start => $starttime, end => $endtime, }, shownotes => $shownotes, }; my $document; $tt->process( $template, $vars, \$document, { binmode => ':utf8' } ) || die $tt->error(), "\n"; # # Add the template-generated body to the mail message # print $mailer $document; # # Send the message # $mailer->close or die "Couldn't send message: $!\n"; unless ($mail) { print "Message was not sent since -nomail was selected (or defaulted).\n"; print "Look in 'mailer.testfile' for the output\n"; } exit; #=== FUNCTION ================================================================ # NAME: report_settings # PURPOSE: Report settings from options (or defaults) # PARAMETERS: None # RETURNS: Nothing # DESCRIPTION: # THROWS: No exceptions # COMMENTS: None # SEE ALSO: N/A #=============================================================================== sub report_settings { my $fmt = "D> %-14s = %s\n"; print "D> Settings from options or default values:\n"; printf $fmt, "Month", coalesce($month,'undef'); printf $fmt, "Mail", coalesce($mail,'undef'); printf $fmt, "From", coalesce($from_address,'undef'); printf $fmt, "To", coalesce($to_address,'undef'); printf $fmt, "Meeting date", coalesce($date,'undef'); printf $fmt, "Start time", join(':',@starttime); printf $fmt, "End time", join(':',@endtime); printf $fmt, "Config file", coalesce($cfgfile,'undef'); printf $fmt, "DB config file", coalesce($dbcfgfile,'undef'); printf $fmt, "Server", coalesce($server,'undef'); printf $fmt, "Port", coalesce($port,'undef'); printf $fmt, "Room", coalesce($room,'undef'); printf $fmt, "Template", coalesce($template,'undef'); print "D> ----\n"; } #=== FUNCTION ================================================================ # NAME: compute_endtime # PURPOSE: Given a start time and a duration computes the end time # PARAMETERS: $rdate arrayref for the date # $rstime arrayref for the start time # $rduration arrayref for the duration [HH,MM,SS] # RETURNS: The end time as a string (HH:MM:SS) # DESCRIPTION: # THROWS: No exceptions # COMMENTS: Decided not to implement this, may do so in future. # SEE ALSO: N/A #=============================================================================== #sub compute_endtime { #} #=== FUNCTION ================================================================ # NAME: validate_time # PURPOSE: Validates a time in HH:MM:SS format # PARAMETERS: $time Time string # RETURNS: The input string with any missing fields added in # DESCRIPTION: The input time needs to be in the format HH:MM[:SS] where # a missing seconds value is replaced with '00'. A regex check # on the format is performed, and if that passes a check is made # on the time values (using 'check_time'). # THROWS: No exceptions # COMMENTS: None # SEE ALSO: N/A #=============================================================================== sub validate_time { my ($time) = (@_); if ( defined($time) ) { if ( ( my @fields ) = ( $time =~ /(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/ ) ) { @fields = map { defined($_) ? sprintf('%02d',$_) : "00" } @fields; $time = join( ':', @fields ); } else { die "Invalid time: $time\n"; } unless ( check_time(split(':',$time)) ) { die "Invalid time: $time\n"; } } return $time; } #=== FUNCTION ================================================================ # NAME: make_date # PURPOSE: Make the event date for recurrence # PARAMETERS: $refdate # An arrayref to the reference date array (usually # today's date) # $dow Day of week for the event date (1-7, 1=Monday) # $n The nth day of the week in the given month required # for the event date # $offset Number of days to offset the computed date # RETURNS: The resulting date as a list for Date::Calc # DESCRIPTION: We want to compute a simple date with an offset, such as # "the Saturday before the first Monday of the month". We do # this by computing a pre-offset date (first Monday of month) # then apply the offset (Saturday before). # THROWS: No exceptions # COMMENTS: This function was originally written for my HPR episode on # iCalendar. # TODO Needs more testing to be considered truly universal # SEE ALSO: #=============================================================================== sub make_date { my ( $refdate, $dow, $n, $offset ) = @_; # # Compute the required date: the "$n"th day of week "$dow" in the year and # month in @$refdate. This could be a date in the past. # my @date = Nth_Weekday_of_Month_Year( @$refdate[ 0, 1 ], $dow, $n ); # # If the computed date plus the offset is before the base date advance # a month # if ( Day_of_Year(@date) + $offset < Day_of_Year(@$refdate) ) { # # Add a month and recompute # @date = Add_Delta_YM( @date, 0, 1 ); @date = Nth_Weekday_of_Month_Year( @date[ 0, 1 ], $dow, $n ); } # # Apply the day offset # @date = Add_Delta_Days( @date, $offset ) if $offset; # # Return a list # return (@date); } #=== FUNCTION ================================================================ # NAME: storeTZ # PURPOSE: Store start/end times for a timezone # PARAMETERS: $start DateTime object containing the starting # datetime as UTC # $end DateTime object containing the ending datetime # as UTC # $tz The textual time zone (need this to be valid) # RETURNS: A hash containing the timezone name and start and end times # DESCRIPTION: Relies on DateTime::TimeZone to do the work to turn a UTC time # into a time in a different time zone. Uses the DateTime # strftime method to format the dates and times for printing. # THROWS: No exceptions # COMMENTS: None # SEE ALSO: N/A #=============================================================================== sub storeTZ { my ( $start, $end, $tz ) = @_; my %result; # # Adjust time zone # $start->set_time_zone($tz); $end->set_time_zone($tz); # # Print time zone and start/end times in that zone # $result{name} = "$tz"; $result{start} = $start->strftime("%H:%S %a, %b %d %Y"); $result{end} = $end->strftime("%H:%S %a, %b %d %Y"); return \%result; } #=== FUNCTION ================================================================ # NAME: printTZ # PURPOSE: Print start/end times for a timezone # PARAMETERS: $fh File handle for writing # $start DateTime object containing the starting # datetime as UTC # $end DateTime object containing the ending datetime # as UTC # $tz The textual time zone (need this to be valid) # RETURNS: Nothing # DESCRIPTION: Relies on DateTime::TimeZone to do the work to turn a UTC time # into a time in a different time zone. Uses the DateTime # strftime method to format the dates and times for printing. # THROWS: No exceptions # COMMENTS: None # SEE ALSO: #=============================================================================== sub printTZ { my ( $fh, $start, $end, $tz ) = @_; # # Adjust time zone # $start->set_time_zone($tz); $end->set_time_zone($tz); # # Print time zone and start/end times in that zone # print $fh "$tz\n"; print $fh $start->strftime("Start: %H:%S %a, %b %d %Y\n"); print $fh $end->strftime("End: %H:%S %a, %b %d %Y\n\n"); } #=== FUNCTION ================================================================ # NAME: day_offset # PURPOSE: Given a day name computes day attributes including the # (negative) offset in days from the target Monday to the # recording date. # PARAMETERS: $dayname Name of a day of the week # RETURNS: Hashref containing the full day name, the weekday number and # the integer offset from Monday to the recording day, or undef. # DESCRIPTION: Uses the hash '%matches' keyed by regular expressions matching # day names. The argument '$dayname' is matched against each # regex in turn and if it matches the sub-hash is returned. This # allows the caller to use 'day_offset($dayname)->{dayname}' to # get the full name of the day if needed, or the offset as # 'day_offset($dayname)->{offset}'. If there is no match # 'undef' is returned. # THROWS: No exceptions # COMMENTS: None # SEE ALSO: N/A #=============================================================================== sub day_offset { my ( $dayname ) = @_; my %matches = ( qr{^(?i)Sun(day)?$} => { dayname => 'Sunday', wday => 7, offset => -1, }, qr{^(?i)Sat(urday)?$} => { dayname => 'Saturday', wday => 6, offset => -2, }, qr{^(?i)Fri(day)?$} => { dayname => 'Friday', wday => 5, offset => -3, }, qr{^(?i)Thu(rsday)?$} => { dayname => 'Thursday', wday => 4, offset => -4, }, qr{^(?i)Wed(nesday)?$} => { dayname => 'Wednesday', wday => 3, offset => -5, }, qr{^(?i)Tue(sday)?$} => { dayname => 'Tuesday', wday => 2, offset => -6, }, qr{^(?i)Mon(day)?$} => { dayname => 'Monday', wday => 1, offset => -7, }, ); my $match; foreach my $re (keys(%matches)) { if ($dayname =~ $re) { $match = $matches{$re}; last; } } return $match; } #=== FUNCTION ================================================================ # NAME: _debug # PURPOSE: Prints debug reports # PARAMETERS: $active Boolean: 1 for print, 0 for no print # $messages... Arbitrary list of messages to print # RETURNS: Nothing # DESCRIPTION: Outputs messages if $active is true. It removes any trailing # newline from each one and then adds one in the 'print' to the # caller doesn't have to bother. Prepends each message with 'D>' # to show it's a debug message. # THROWS: No exceptions # COMMENTS: Differs from other functions of the same name # SEE ALSO: N/A #=============================================================================== sub _debug { my $active = shift; my $message; return unless $active; while ($message = shift) { chomp($message); print STDERR "D> $message\n"; } } #=== FUNCTION ================================================================ # NAME: coalesce # PURPOSE: To find the first defined argument and return it # PARAMETERS: Arbitrary number of arguments # RETURNS: The first defined argument or undef if there are none # DESCRIPTION: # THROWS: No exceptions # COMMENTS: None # SEE ALSO: N/A #=============================================================================== sub coalesce { foreach (@_) { return $_ if defined($_); } return undef; ## no critic } #=== FUNCTION ================================================================ # NAME: Options # PURPOSE: Processes command-line options # PARAMETERS: $optref Hash reference to hold the options # RETURNS: Undef # DESCRIPTION: # THROWS: no exceptions # COMMENTS: none # SEE ALSO: n/a #=============================================================================== sub Options { my ($optref) = @_; my @options = ( "help", "documentation|man", "debug=i", "mail!", "fromaddress=s", "toaddress=s", "date=s", "starttime=s", "endtime=s", "month=s", "config=s", "dbconfig=s", ); # "duration=s", if ( !GetOptions( $optref, @options ) ) { pod2usage( -msg => "Version $VERSION\n", -verbose => 0, -exitval => 1 ); } return; } __END__ #%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% # Application Documentation #%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% #{{{ =head1 NAME make_email - generates an HPR Community News recording invitation email =head1 VERSION This documentation refers to make_email version 0.2.7 =head1 USAGE make_email [-help] [-documentation] [-debug=N] [-month=DATE] [-[no]mail] [-from=FROM_ADDRESS] [-to=TO_ADDRESS] [-date=DATE] [-start=START_TIME] [-end=END_TIME] [-config=FILE] [-dbconfig=FILE] ./make_email -dbconf=$HOME/HPR/.hpr_livedb.cfg -date=2022-12-27 =head1 OPTIONS =over 8 =item B<-help> Prints a brief help message describing the usage of the program, and then exits. =item B<-documentation> B<-man> Prints the entire embedded documentation for the program, then exits. Another way to see the full documentation use: B =item B<-debug=N> Enables debugging mode when N > 0 (zero is the default, no debugging output). The levels are: Values are: =over 4 =item 1 Reports all of the settings taken from the configuration file, the provided command line options or their default values. The report is generated early on in the processing of these values. Use B<-debug=2> for information about the next stages. =item 2 Reports the following (as well as the data for level 1): =over 4 =item . Details of the start date chosen =item . Details of the year, name of month, readable date, and recording start and end times. =item . The subject line chosen for the email. =item . The date of the show being searched for in the database. =item . The number of the show found in the database. =back =back =item B<-month=DATE> Defines the month for which the email will be generated using a date in that month. Normally (without this option) the current month is chosen and the date of recording computed within it. The month specified here is provided as a ISO8601 date such as 2014-03-08 (meaning March 2014) or 1-Jan-2017 (meaning January 2017). Only the year and month parts are used but a valid day must be present. =item B<-[no]mail> ** NOTE ** The sending of mail does not work at present, and B<-nomail> should always be used. Causes mail to be sent (B<-mail>) or not sent (B<-nomail>). If the mail is sent then it is sent via the local MTA (in the assumption that there is one). If this option is omitted, the default is B<-nomail>, in which case the message is appended to the file B in the current directory. =item B<-from=FROM_ADDRESS> ** NOTE ** The sending of mail does not work at present. This option defines the address from which the message is to be sent. This address is used in the message header; the message envelope will contain the I sender. =item B<-to=TO_ADDRESS> ** NOTE ** The sending of mail does not work at present. This option defines the address to which the message is to be sent. =item B<-date=DATE> This is an option provides a non-default date for the recording. Normally the script computes the next scheduled date based on the algorithm "DAY_OF_WEEK before the first Monday of the next month" (where DAY_OF_WEEK is the value defined in the configuration file as B) starting from the current date or the start of the month given in the B<-month=DATE> option. If for any reason a different date is required then this may be specified via this option. The recording date should be given as an ISO8601 date (such as 2014-03-08). =item B<-start=START_TIME> The default start time is defined in the configuration file, but if it is necessary to change it, this option can be used to do it. The B value must be a valid B time specification. A change to the start time in the configuration file also implies that the end time should change. If the B<-start=START_TIME> option is present but B<-end=END_TIME> is not, then the end time is computed by adding a number of hours to the start time this number is defined in the configuration file as B. =item B<-end=END_TIME> The default end time is defined in the configuration file, but if it is necessary to change it temporarily, this option can be used to do it. The B value must be a valid B time specification. =item B<-config=FILE> This option defines a configuration file other than the default B<.make_email.cfg>. The file must be formatted as described below in the section I. =item B<-dbconfig=FILE> This option defines a database configuration file other than the default B<.hpr_db.cfg>. The file must be formatted as described below in the section I. The default file is configured to open a local copy of the HPR database. An alternative is B<.hpr_livedb.cfg> which assumes an SSH tunnel to the live database and attempts to connect to it. Use the script I to open the SSH tunnel. =back =head1 DESCRIPTION Makes and sends(*) an invitation email for the next Community News with times per timezone. The message is structured by a Template Toolkit template, so its content can be adjusted without changing this script. In normal operation the script computes the date of the next recording using the algorithm "Saturday before the first Monday of the next month" starting from the current date or the start of the month (and year) given in the B<-month=DATE> option. It uses the recording date (B<-date=DATE> option) to access the MySQL database to find the date on which the show will be released. It does that so the notes on that show can be viewed by the volunteers recording the show. These notes are expanded to be usable during the recording, with comments relating to earlier shows being displayed in full, and any comments missed in the last recording highlighted. Comments made to shows during the past month can be seen as the shows are visited and discussed. The email generated by the script is sent to the HPR mailing list, usually on the Monday prior to the weekend of the recording. Notes: * Mail sending does not work at present. =head1 DIAGNOSTICS =over 8 =item B The configuration file specified in B<-config=FILE> (or the default file) could not be found. =item B These options are mutually exclusive. See their specifications earlier in this document. =item B One or both of the start and end times is missing, either from the configuration file or from the command line options. =item B The template file specified in the configuration file could not be found. =item B The program can generate warning messages from the database. =item B An invalid date has been supplied via this option. =item B The date specified in B<-date=DATE> is in the past. =item B An invalid date has been supplied via this option. =item B The month specified in B<-month=DATE> is in the past. =item B The program can generate warning messages from the Template. =item B The email mesage has been constructed but could not be sent. See the error returned by the mail subsystem for more information. =back =head1 CONFIGURATION AND ENVIRONMENT =head2 EMAIL CONFIGURATION The program obtains the settings it requires for preparing the email from a configuration file, which by default is called B<.make_email.cfg>. This file needs to contain the following data: server = MUMBLE_SERVER_NAME port = MUMBLE_PORT room = NAME_OF_ROOM dayname = DAY_OF_WEEK_OF_RECORDING starttime = 18:00:00 endtime = 20:00:00 duration = 02 # hours template = NAME_OF_TEMPLATE =head2 DATABASE CONFIGURATION The program obtains the credentials it requires for connecting to the HPR database by loading them from a configuration file. The default file is called B<.hpr_db.cfg> and should contain the following data: host = 127.0.0.1 port = PORT name = DBNAME user = USER password = PASSWORD The file B<.hpr_livedb.cfg> should be available to allow access to the database over an SSH tunnel which has been previously opened. =head1 DEPENDENCIES DBI Date::Calc Date::Parse DateTime DateTime::Format::Duration DateTime::TimeZone Getopt::Long Mail::Mailer Pod::Usage Template =head1 BUGS AND LIMITATIONS There are no known bugs in this script. Please report problems to Dave Morriss (Dave.Morriss@gmail.com) Patches are welcome. =head1 AUTHOR Dave Morriss (Dave.Morriss@gmail.com) 2013 - 2024 =head1 LICENCE AND COPYRIGHT Copyright (c) Dave Morriss (Dave.Morriss@gmail.com). All rights reserved. This program is free software. You can redistribute it and/or modify it under the same terms as perl itself. =cut #}}} # [zo to open fold, zc to close] # vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker