#!/usr/bin/env perl #=============================================================================== # # FILE: make_shownotes # # USAGE: ./make_shownotes [-help] [-documentation] [-debug=N] # [-from=DATE] [-[no]comments] [-[no]markcomments] [-[no]ctext] # [-lastrecording=DATETIME] [-[no]silent] [-out=FILE] # [-episode=[N|auto]] [-[no]overwrite] [-mailnotes[=FILE]] # [-anyotherbusiness=FILE] [-template=FILE] [-config=FILE] # [-interlock=PASSWORD] # # DESCRIPTION: Builds shownotes for a Community News show from the HPR # database using a template. Writes the result to STDOUT or to # a file. Also writes to the database if requested. # # OPTIONS: --- # REQUIREMENTS: --- # BUGS: --- # NOTES: To view the entire documentation use: # # ./make_shownotes -documentation # # To create a PDF version of the documentation: # # pod2pdf make_shownotes --out=make_shownotes.pdf # # Where pod2pdf comes from App::pod2pdf # # AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com # VERSION: 0.2.2 # CREATED: 2014-04-24 16:08:30 # REVISION: 2023-06-30 23:52:06 # #=============================================================================== use 5.010; use strict; use warnings; use utf8; use Carp; use Getopt::Long; use Pod::Usage; use Config::General; use Date::Parse; use Date::Calc qw{:all}; use DateTime; use DateTime::Duration; use Template; use Template::Filters; Template::Filters->use_html_entities; # Use HTML::Entities in the template use HTML::Entities; use DBI; use Data::Dumper; # # Version number (manually incremented) # our $VERSION = '0.2.2'; # # Various constants # ( my $PROG = $0 ) =~ s|.*/||mx; #------------------------------------------------------------------------------- # Declarations #------------------------------------------------------------------------------- # # Constants and other declarations # my $basedir = "$ENV{HOME}/HPR/Community_News"; my $configfile = "$basedir/.hpr_db.cfg"; my $bpfile = "$basedir/shownote_template.tpl"; my $title_template = 'HPR Community News for %s %s'; # # Needed to allow an older episode to have its notes regenerated. This is an # 'apg'-generated password which is just hard to remember and requires some # thought to use. The idea is to prevent older shownote rewriting by accident. # my $interlock_password = 'lumRacboikac'; my $interlock_enabled = 0; my ( $dbh, $sth1, $h1 ); my ( @startdate, $hosts, $shows ); my ( @dc_lr, $dt_lr, @dc_lm, $dt_lm ); my ( $t_days, $missed_comments, $missed_count ); my ( $comments, $comment_count, $past_count, $ignore_count ); my ( %past, %current ); # # The normal recording time (UTC). Any change should be copied in the POD # documentation below. # TODO: Should this be in a configuration file? # my @deftime = (15, 00, 00); # # Earliest comment release time # my @releasetime = (19, 00, 00); # # Enable Unicode mode # binmode STDOUT, ":encoding(UTF-8)"; binmode STDERR, ":encoding(UTF-8)"; #------------------------------------------------------------------------------- # Options and arguments #------------------------------------------------------------------------------- # # Option defaults # my $DEFDEBUG = 0; # # Process options # my %options; Options( \%options ); # # Default help is just the USAGE section # pod2usage( -msg => "$PROG version $VERSION\n", -verbose => 0, -exitval => 1 ) if ( $options{'help'} ); # # Full documentation if requested with -documentation # pod2usage( -msg => "$PROG version $VERSION\n", -verbose => 2, -exitval => 1 ) if ( $options{'documentation'} ); # # Collect options # my $DEBUG = ( defined( $options{debug} ) ? $options{debug} : $DEFDEBUG ); my $cfgfile = ( defined( $options{config} ) ? $options{config} : $configfile ); my $silent = ( defined( $options{silent} ) ? $options{silent} : 0 ); my $show_comments = ( defined( $options{comments} ) ? $options{comments} : 0 ); my $mark_comments = ( defined( $options{markcomments} ) ? $options{markcomments} : 0 ); my $ctext = ( defined( $options{ctext} ) ? $options{ctext} : 0 ); my $lastrecording = $options{lastrecording}; my $outfile = $options{out}; my $episode = $options{episode}; my $overwrite = ( defined( $options{overwrite} ) ? $options{overwrite} : 0 ); my $template = ( defined( $options{template} ) ? $options{template} : $bpfile ); my $mailnotes = $options{mailnotes}; my $aobfile = $options{anyotherbusiness}; my $interlock = $options{interlock}; # # Sanity checks # die "Unable to find configuration file $cfgfile\n" unless ( -e $cfgfile ); if ( defined($episode) ) { if ( $episode =~ /^\d+$/ ) { die "Episode number must be greater than zero\n" unless ( $episode > 0 ); } else { die "Episode must be a number or 'auto'\n" unless ( $episode eq 'auto' ); } } die "Error: Unable to find template $template\n" unless -r $template; # # We accept '-mailnotes' meaning we want to use a default set of mail notes, # or '-mailnotes=FILE' which means the notes are in a file for inclusion. If # the option is omitted then we don't include mail notes (and the template is # expected to do the right thing). # if (defined($mailnotes)) { if ($mailnotes =~ /^$/) { # # The default mail inclusion is provided in a named BLOCK directive in # the template. The name is hard-wired here # # FIXME: there's a dependency between the script and the template here # which is inflexible. # $mailnotes = 'default_mail'; } else { die "Error: Unable to find includefile '$mailnotes'\n" unless -r $mailnotes; } } # # The -anyotherbusiness=FILE or -aob=FILE options provide an HTML file to be # added to the end of the notes. # if (defined($aobfile)) { die "Error: Unable to find includefile '$aobfile'\n" unless -r $aobfile; } # # Use the date provided or the default # if ( defined( $options{from} ) ) { # # Parse and perform rudimentary validation on the -from option # # my @parsed = strptime( $options{from} ); # die "Invalid -from=DATE option '$options{from}'\n" # unless ( defined( $parsed[3] ) # && defined( $parsed[4] ) # && defined( $parsed[5] ) ); # # $parsed[5] += 1900; # $parsed[4] += 1; # @startdate = @parsed[ 5, 4, 3 ]; @startdate = parse_to_dc($options{from}, undef); } else { # # Default to the current date # @startdate = Today(); } # # If -interlock=PASSWORD was provided is the password valid? # if ( defined($interlock) ) { $interlock_enabled = $interlock eq $interlock_password; emit( $silent, "Interlock ", ( $interlock_enabled ? "accepted" : "rejected" ), "\n" ); } #------------------------------------------------------------------------------- # Configuration file - load data #------------------------------------------------------------------------------- emit( $silent, "Configuration file: ", $cfgfile, "\n" ); my $conf = Config::General->new( -ConfigFile => $cfgfile, -InterPolateVars => 1, -ExtendedAccess => 1, ); my %config = $conf->getall(); #------------------------------------------------------------------------------- # Date setup #------------------------------------------------------------------------------- # # Transfer Date::Calc values into a hash for initialising a DateTime object. # Force the day to 1 # my ( @sd, $dt ); @sd = ( @startdate, 0, 0, 0 ); $sd[2] = 1; $dt = dc_to_dt(\@sd); emit( $silent, "Start of month: ", $dt->ymd, "\n" ); #------------------------------------------------------------------------------- # Connect to the database #------------------------------------------------------------------------------- my $dbhost = $config{database}->{host} // '127.0.0.1'; my $dbport = $config{database}->{port} // 3306; my $dbname = $config{database}->{name}; my $dbuser = $config{database}->{user}; my $dbpwd = $config{database}->{password}; $dbh = DBI->connect( "dbi:mysql:host=$dbhost;port=$dbport;database=$dbname", $dbuser, $dbpwd, { AutoCommit => 1 } ) or croak $DBI::errstr; # # Enable client-side UTF8 # $dbh->{mysql_enable_utf8} = 1; # # Set the local timezone to UTC for this connection # $dbh->do("set time_zone = '+00:00'") or carp $dbh->errstr; #------------------------------------------------------------------------------- # Open the output file (or STDOUT) - we may need the date to do it #------------------------------------------------------------------------------- my $outfh; if ($outfile) { $outfile = sprintf( $outfile, sprintf( "%d-%02d", $dt->year, $dt->month ) ) if ( $outfile =~ /%s/ ); emit( $silent, "Output: ", $outfile, "\n" ); open( $outfh, ">:encoding(UTF-8)", $outfile ) or croak "Unable to open $outfile for writing: $!"; } else { open( $outfh, ">&", \*STDOUT ) or croak "Unable to initialise for writing: $!"; } #------------------------------------------------------------------------------- # Check the episode specification if given #------------------------------------------------------------------------------- if ( defined($episode) ) { my $title = sprintf( $title_template, $dt->month_name, $dt->year ); emit( $silent, "\n" ); # # Is it a number? # if ( $episode =~ /^\d+$/ ) { emit( $silent, "Writing to numbered episode option selected\n" ); # # Does the number exist in the database? # $sth1 = $dbh->prepare(q{SELECT * FROM eps WHERE id = ?}); $sth1->execute($episode); if ( $dbh->err ) { carp $dbh->errstr; } if ( $h1 = $sth1->fetchrow_hashref() ) { # # Episode exists, do more checks # emit( $silent, "Found episode $episode\n" ); emit( $silent, "Title: ", $h1->{title}, "\n" ); die "Error: wrong show selected\n" unless ( $h1->{title} eq $title ); unless (validate_date($h1->{date}) || $interlock_enabled) { die "Error: show $episode has a date in the past\n"; } unless ($overwrite) { die "Error: show $episode already has notes\n" unless length( $h1->{notes} ) == 0; } } else { die "Error: episode $episode does not exist in the database\n"; } } else { # # The required episode is 'auto' (we already checked). Now we actually # find the episode number corresponding to the month we're processing. # We do this by searching for the relevant title in the database. # emit( $silent, "Searching for show title: '$title'\n" ); $sth1 = $dbh->prepare(q{SELECT * FROM eps WHERE title = ?}); $sth1->execute($title); if ( $dbh->err ) { carp $dbh->errstr; } if ( $h1 = $sth1->fetchrow_hashref() ) { # # Found the episode by title # $episode = $h1->{id}; emit( $silent, "Found episode $episode\n" ); emit( $silent, "Title: ", $h1->{title}, "\n" ); unless (validate_date($h1->{date}) || $interlock_enabled) { die "Error: show $episode has a date in the past\n"; } unless ($overwrite) { die "Error: show $episode already has notes\n" unless length( $h1->{notes} ) == 0; } } else { die 'Error: Unable to find an episode ' . "for the selected month's notes\n"; } } } #------------------------------------------------------------------------------- # If asked (-comments -markcomments) compute the last recording date #------------------------------------------------------------------------------- if ($show_comments && $mark_comments) { # # We're marking comments so need to find the date of the recording of the # last show. # # It's possible to specify date and time of the last recording via option # '-lastrecording=DATETIME' (in case we recorded early or something), but # it needs to be parsed. # if ( defined( $options{lastrecording} ) ) { # # Parse and perform rudimentary validation on the -lastrecording option # @dc_lr = parse_to_dc( $options{lastrecording}, \@deftime ); } else { # # Otherwise we assume the recording was on a Saturday and compute when # that was. We get back the date and time of the recording as # a Date::Calc object. # @dc_lr = find_last_recording( \@startdate, \@deftime ); } # # Convert the D::C datetime to a DateTime object # $dt_lr = dc_to_dt(\@dc_lr); # Report it for checking (since this algorithm is complex) emit( $silent, sprintf("* %-100s *\n", ( defined( $options{lastrecording} ) ? 'Given' : 'Found' ) . ' last recording date on ' . $dt_lr->datetime . ' time zone ' . $dt_lr->strftime('%Z') . ' (' . $dt_lr->epoch . ')' ) ); # # Also, we need to know the last month for comment marking # @dc_lm = find_last_month(\@startdate); $dt_lm = dc_to_dt(\@dc_lm); # Report it for checking (since this algorithm is complex) emit( $silent, sprintf("* %-100s *\n", 'Last month computed to be ' . $dt_lm->datetime . ' time zone ' . $dt_lm->strftime('%Z') . ' (' . $dt_lm->epoch . ')' ) ); # # Work out if the the recording date was before the end of the last # reviewed month. # $t_days = trailing_days(\@dc_lr, \@dc_lm); emit( $silent, sprintf("* %-100s *\n", 'Recording was in the reviewed month and not on the ' . 'last day, so comments may have been missed' ) ) if $t_days; _debug( $DEBUG > 2, '@dc_lr = (' . join(',',@dc_lr) .')' ); _debug( $DEBUG > 2, '$dt_lr->ymd = ' . $dt_lr->ymd ); _debug( $DEBUG > 2, '$dt_lr->hms = ' . $dt_lr->hms ); _debug( $DEBUG > 2, '@dc_lm = (' . join(',',@dc_lm) .')' ); } else { # # We now need a default for $dt_lr in all cases because the query has been # changed. # @dc_lr = find_last_recording( \@startdate, \@deftime ); $dt_lr = dc_to_dt(\@dc_lr); } #------------------------------------------------------------------------------- # Data collection #------------------------------------------------------------------------------- # # Prepare to get any new hosts for the required month. We let MySQL compute # the end of the month. Order by date of first show. # $sth1 = $dbh->prepare( q{SELECT ho.host, ho.hostid, md.mindate FROM hosts ho JOIN (SELECT hostid, MIN(date) mindate FROM eps GROUP BY hostid) AS md ON ho.hostid = md.hostid WHERE md.mindate >= ? AND md.mindate < last_day(?) + interval 1 day ORDER BY mindate} ); $sth1->execute( $dt->ymd, $dt->ymd ); if ( $dbh->err ) { carp $dbh->errstr; } # # Grab the data as an arrayref of hashrefs # $hosts = $sth1->fetchall_arrayref( {} ); # # Prepare to get the episodes for the required month. We let MySQL compute the # end of the month. We include every column here just in case they'll be # useful in the template, though this requires some aliasing. # 2015-04-05 The date field has been reformatted so that the 'date' plugin in # the form is happy with it. # $sth1 = $dbh->prepare( q{SELECT eps.id AS eps_id, -- eps.type, -- date_format(eps.date,'%a %Y-%m-%d') AS date, date_format(eps.date,'00:00:00 %d/%m/%Y') AS date, -- eps.date, eps.title, sec_to_time(eps.duration) as length, eps.summary, eps.notes, -- eps.host AS eps_host, eps.hostid AS eps_hostid, eps.series, eps.explicit, eps.license AS eps_license, eps.tags, eps.version, eps.valid AS eps_valid, ho.hostid AS ho_hostid, ho.host AS ho_host, ho.email, ho.profile, -- was website, ho.license AS ho_license, -- ho.repeat, ho.valid AS ho_valid FROM eps JOIN hosts ho ON eps.hostid = ho.hostid WHERE eps.date >= ? AND eps.date < last_day(?) + interval 1 day ORDER BY id} ); $sth1->execute( $dt->ymd, $dt->ymd ); if ( $dbh->err ) { carp $dbh->errstr; } # # Grab the data as an arrayref of hashrefs # $shows = $sth1->fetchall_arrayref( {} ); # # Collect the comments if requested # if ($show_comments) { # # Grab the comments for the selected period. These have weird \' sequences # in the author, title and text, which doesn't seem right, so we strip # them. Note that the end date in date ranges seems only to work as # 'last_day(?) + interval 1 day' presumably because without it the date is # interpreted as midnight the previous day (e.g. 2014-06-30 is early on # this day, not early on 2014-07-01 whereas adding 1 day gets this right). # # The logic here was rewritten 2015-03-04 and consists of: # - the sub-select collects the id numbers of all comments that have # occurred in the selected period # - because all comments relating to a given show have the same id number # this identifies all comment groups (comments relating to a given show) # with members in the period # - the main query then selects all comments in these groups and joins all # the required tables to them to get the other data we need; it computes # the boolean 'in_range' to indicate whether a comment within a group # should be displayed; it returns *all* comments so we can number them # - there might have been a way to generate a comment sequence number # within the SQL but it was decided not to and to do this in the script # # Update 2015-03-26: the sort order of the final result used # 'comment_identifier_id' which didn't seem to work reliably. Changed this # to 'episode' which seems to work fine. The use of the former was only # arrived at by guesswork. The guess was obviously wrong. # # Update 2015-06-04: the test for whether a comment is approved was # failing because it was in the wrong place. Also, the sub-select seemed # wrong, and running through EXPLAIN EXTENDED showed some flaws. # Redesigned this and the whole query to be more in keeping with the # algorithm sketched out above. This seems to have solved the problems. # # Update 2015-08-17: Still not right; somehow the 'in_range' check got # mangled and the process of counting comments where a recent one was made # to an old show was messed up. Dropped the two queries with a UNION and # went back to the check for comment_identifier_id in a sub-query. This # time the sub-query is cleverer and returns identifiers where the # `comment_timestamp` is in the target month *and* the episode date is in # the target month or before the start of the target month. This works for # July 2015 (where there's a comment made about an August show) and August # 2015 (where there's a comment made about a July show). # # Update 2023-05-02: Issues with comments received on the day of # recording. This matter has been addresed earlier in the year but not all # that well. The way this is all handled is not very clever so tried # adjusting the main comment query to return such comments since we had # one on 2023-04-29 which wasn't in the report. # #{{{ # # Graveyard of old failed queries... # ================================== # $sth1 = $dbh->prepare( # q{ # SELECT # cc.comment_identifier_id, # eps.id AS episode, #-- ci.identifier_url, # substr(ci.identifier_url,1,locate('/eps.php?id=',ci.identifier_url)+15) # AS identifier_url, # eps.title, # eps.date, # ho.host, # ho.hostid, # from_unixtime(cc.comment_timestamp,'%Y-%m-%dT%TZ') AS timestamp, # replace(cc.comment_author_name,'\\\\','') AS comment_author_name, # replace(cc.comment_title,'\\\\','') AS comment_title, # replace(cc.comment_text,'\\\\','') AS comment_text, # cc.comment_timestamp, # (CASE WHEN (cc.comment_timestamp >= unix_timestamp(?) # AND cc.comment_timestamp < unix_timestamp(last_day(?) + interval 1 day)) THEN # 1 ELSE 0 END) AS in_range # FROM c5t_comment cc # JOIN c5t_identifier ci ON ci.identifier_id = cc.comment_identifier_id # JOIN eps ON eps.id = # substr(ci.identifier_url,locate('/eps.php?id=',ci.identifier_url)+12) # JOIN hosts ho ON eps.hostid = ho.hostid # WHERE cc.comment_status = 0 # AND cc.comment_identifier_id IN ( # SELECT DISTINCT cc2.comment_identifier_id FROM c5t_comment cc2 # WHERE ( # cc2.comment_timestamp >= unix_timestamp(?) # AND cc2.comment_timestamp < unix_timestamp(last_day(?) + interval 1 day) # ) # ) # AND (eps.date < (last_day(?) + interval 1 day)) # ORDER BY eps.id ASC, comment_timestamp ASC # } # ); # # This one is nuts... # $sth1 = $dbh->prepare( q{ # ( # SELECT # cc.comment_identifier_id, # eps.id AS episode, # substr(ci.identifier_url,1,locate('/eps.php?id=',ci.identifier_url)+15) # AS identifier_url, # eps.title, # eps.date, # ho.host, # ho.hostid, # from_unixtime(cc.comment_timestamp,'%Y-%m-%dT%TZ') AS timestamp, # replace(cc.comment_author_name,'\\\\','') AS comment_author_name, # replace(cc.comment_title,'\\\\','') AS comment_title, # replace(cc.comment_text,'\\\\','') AS comment_text, # cc.comment_timestamp, # (CASE WHEN (cc.comment_timestamp >= unix_timestamp(?) # AND cc.comment_timestamp < unix_timestamp(last_day(?) + interval 1 day)) THEN # 1 ELSE 0 END) AS in_range # FROM c5t_comment cc # JOIN c5t_identifier ci ON ci.identifier_id = cc.comment_identifier_id # JOIN eps ON eps.id = # substr(ci.identifier_url,locate('/eps.php?id=',ci.identifier_url)+12,4) # JOIN hosts ho ON eps.hostid = ho.hostid # WHERE eps.date >= ? AND eps.date < (last_day(?) + interval 1 day) # ) # UNION # ( # SELECT # cc.comment_identifier_id, # eps.id AS episode, # substr(ci.identifier_url,1,locate('/eps.php?id=',ci.identifier_url)+15) # AS identifier_url, # eps.title, # eps.date, # ho.host, # ho.hostid, # from_unixtime(cc.comment_timestamp,'%Y-%m-%dT%TZ') AS timestamp, # replace(cc.comment_author_name,'\\\\','') AS comment_author_name, # replace(cc.comment_title,'\\\\','') AS comment_title, # replace(cc.comment_text,'\\\\','') AS comment_text, # cc.comment_timestamp, # (CASE WHEN (cc.comment_timestamp >= unix_timestamp(?) # AND cc.comment_timestamp < unix_timestamp(last_day(?) + interval 1 day)) THEN # 1 ELSE 0 END) AS in_range # FROM c5t_comment cc # JOIN c5t_identifier ci ON ci.identifier_id = cc.comment_identifier_id # JOIN eps ON eps.id = # substr(ci.identifier_url,locate('/eps.php?id=',ci.identifier_url)+12,4) # JOIN hosts ho ON eps.hostid = ho.hostid # WHERE (cc.comment_timestamp >= unix_timestamp(?) # AND cc.comment_timestamp < unix_timestamp(last_day(?) + interval 1 day)) # AND eps.date < (last_day(?) + interval 1 day) # ) # ORDER BY episode ASC, comment_timestamp ASC # }); # # This one worked fine - after much messing around admittedly: # $sth1 = $dbh->prepare( q{ # SELECT # cc.comment_identifier_id, # eps.id AS episode, # substr(ci.identifier_url,1,locate('/eps.php?id=',ci.identifier_url)+15) # AS identifier_url, # eps.title, # eps.date, # ho.host, # ho.hostid, # from_unixtime(cc.comment_timestamp,'%Y-%m-%dT%TZ') AS timestamp, # replace(cc.comment_author_name,'\\\\','') AS comment_author_name, # replace(cc.comment_title,'\\\\','') AS comment_title, # replace(cc.comment_text,'\\\\','') AS comment_text, # cc.comment_timestamp, # (CASE WHEN # ( # (cc.comment_timestamp >= unix_timestamp(?) # AND cc.comment_timestamp < unix_timestamp(last_day(?) + interval 1 day)) # AND eps.date < (last_day(?) + interval 1 day) # OR (cc.comment_timestamp < unix_timestamp(last_day(?) + interval 1 day) # AND (eps.date >= ? AND eps.date < (last_day(?) + interval 1 day))) # ) # THEN 1 ELSE 0 END) AS in_range # FROM c5t_comment cc # JOIN c5t_identifier ci ON ci.identifier_id = cc.comment_identifier_id # JOIN eps ON eps.id = # substr(ci.identifier_url,locate('/eps.php?id=',ci.identifier_url)+12,4) # JOIN hosts ho ON eps.hostid = ho.hostid # WHERE cc.comment_status = 0 # AND cc.comment_identifier_id IN # ( # SELECT DISTINCT # cc.comment_identifier_id # FROM c5t_comment cc # JOIN c5t_identifier ci ON ci.identifier_id = cc.comment_identifier_id # JOIN eps ON eps.id = # substr(ci.identifier_url,locate('/eps.php?id=',ci.identifier_url)+12,4) # WHERE # (eps.date >= ? AND eps.date < (last_day(?) + interval 1 day)) # OR ( # ( (cc.comment_timestamp >= unix_timestamp(?) # AND cc.comment_timestamp < unix_timestamp(last_day(?) + interval 1 day)) ) # AND (eps.date >= ? AND eps.date < (last_day(?) + interval 1 day) # OR (eps.date < ?)) # ) # ) # ORDER BY episode ASC, comment_timestamp ASC # }); #}}} #------------------------------------------------------------------------------- # Main comment query #------------------------------------------------------------------------------- $sth1 = $dbh->prepare( q{ SELECT eps.id AS episode, concat('https://hackerpublicradio.org/eps/hpr', lpad(eps_id,4,0),'/index.html') AS identifier_url, eps.title, eps.summary, eps.date, ho.host, ho.hostid, date_format(co.comment_timestamp,'%Y-%m-%dT%TZ') AS timestamp, co.comment_author_name, co.comment_title, co.comment_text, unix_timestamp(co.comment_timestamp) AS comment_timestamp_ut, unix_timestamp( cast(concat(date(co.comment_timestamp),' ',?) AS DATETIME) ) AS comment_released_ut, (CASE WHEN ( (co.comment_timestamp >= ? AND co.comment_timestamp < (last_day(?) + interval 1 day)) OR (co.comment_timestamp >= ?) AND eps.date < (last_day(?) + interval 1 day) OR (co.comment_timestamp < (last_day(?) + interval 1 day) AND (eps.date >= ? AND eps.date < (last_day(?) + interval 1 day))) ) THEN 1 ELSE 0 END) AS in_range FROM comments co JOIN eps ON eps.id = co.eps_id JOIN hosts ho ON eps.hostid = ho.hostid WHERE eps.id IN ( SELECT DISTINCT eps.id FROM eps JOIN comments co ON (eps.id = co.eps_id) WHERE (eps.date >= ? AND eps.date < (last_day(?) + interval 1 day)) OR (co.comment_timestamp >= ?) OR ( ( (co.comment_timestamp >= ? AND co.comment_timestamp < (last_day(?) + interval 1 day)) ) AND (eps.date >= ? AND eps.date < (last_day(?) + interval 1 day) OR (eps.date < ?)) ) ) ORDER BY episode ASC, comment_timestamp ASC }); $sth1->execute( $dt_lr->hms, ( $dt->ymd ) x 2, $dt_lr->ymd, ( $dt->ymd ) x 6, $dt_lr->ymd, ( $dt->ymd ) x 5 ); if ( $dbh->err ) { carp $dbh->errstr; } # # Grab the data as an arrayref of hashrefs # $comments = $sth1->fetchall_arrayref( {} ); #------------------------------------------------------------------------------- # Post-process the results of the query #------------------------------------------------------------------------------- # The comment structure needs some further work because it contains all # comments for a given show but no indexes we can use (because the query # didn't generate any - this is a lot easier in PostgreSQL (and possibly # SQLite)). We also need to know how many comments we have within the # target period, which is usually a smaller number than the number of # comments we got back. The query has marked the ones we want to count # (and display) using 'in_range'. # my ( $ep, $lastep, $index ) = ( 0, 0, 0 ); $comment_count = $past_count = $ignore_count = 0; for my $row (@$comments) { # # Give each comment an index whether in_range or not. The indexes # start from 1 when the episode number changes otherwise they # increment. # $ep = $row->{episode}; if ( $ep == $lastep ) { $index++; } else { $index = 1; } #print "$ep $index ", $row->{in_range}, "\n"; _debug( $DEBUG > 2, sprintf( "Index generation: episode=%s, index=%s, in_range=%s", $ep, $index, $row->{in_range} ) ); $lastep = $ep; # # Save the index for the template # $row->{index} = $index; # # Count the valid ones so the template doesn't have to to give a total # for this month # $comment_count++ if $row->{in_range}; # # Make the comment text cleaner by removing carriage returns (not sure # why they are there in the first place) # $row->{comment_text} =~ s/\r+//g; # # Dates from the database look like '2016-08-01', we compare them to # the month we're processing to see if the comments are attached to # old shows. # FIXME: test for 'in_range' before setting 'past' # if ( validate_date($row->{date},$dt->ymd) ) { $row->{past} = 0; } else { $row->{past} = 1; $past_count++ if $row->{in_range}; } } # # Now prune all of the comments which are not in_range to give the # template an easier job # @$comments = grep { $_->{in_range} } @$comments; # Explanation of the resulting structure {{{ # # Restructure the comments into two hashes keyed by episode number where # each comment to that episode is stored in sequence in an array. The two # hashes %past and %current hold comments for shows in the past and for # the current month. These can be dealt with separately in the template, # making the logic therein somewhat simpler and clearer. # # The hash (of arrays of hashes) can be visualised thus: # %past = { # '2457' => [ # { # 'past' => 1, # 'hostid' => 111, # 'identifier_url' => 'https://hackerpublicradio.org/eps.php?id=2457', # 'comment_timestamp_ut' => 1556192523, # 'date' => '2018-01-02', # 'comment_text' => [snip], # 'comment_title' => 'aren\'t you forgetting a hub?', # 'timestamp' => '2019-04-25T11:42:03Z', # 'in_range' => 1, # 'index' => 1, # 'host' => 'knightwise', # 'title' => 'Getting ready for my new Macbook Pro', # 'comment_author_name' => 'Bart', # 'episode' => 2457, # 'ignore' => 0 # } # ] # } #}}} # # Also, as we go, mark and count the comments in the %past hash which were # likely to have been read in the last show, so by this means simplify # what the template has to do. # for my $row (@$comments) { my $ep = $row->{episode}; if ($row->{past}) { if ( $show_comments && $mark_comments ) { #if ( $row->{comment_timestamp_ut} <= $dt_lr->epoch if ( $row->{comment_released_ut} <= $dt_lr->epoch && substr( $row->{date}, 0, 7 ) eq substr( $dt_lm->ymd, 0, 7 ) ) { $row->{ignore} = 1; $ignore_count++; } else { $row->{ignore} = 0; } } else { $row->{ignore} = 0; } if (exists($past{$ep})) { push(@{$past{$ep}},$row); } else { $past{$ep} = [$row]; } } else { if (exists($current{$ep})) { push(@{$current{$ep}},$row); } else { $current{$ep} = [$row]; } } } _debug ($DEBUG > 2, '%past: ' . Dumper(\%past), '%current: ' . Dumper(\%current) ); #------------------------------------------------------------------------------- # Make another data structure of missed coments *if* $t_days is true #------------------------------------------------------------------------------- # If $t_days is true then there might be comments from the previous month # that weren't covered in the recording. So we add them to the notes just # for the recording. Trouble is, if they exist they aren't in the comments # we have gathered, so we'll have to go and search for them with this # special query. # if ($t_days) { $sth1 = $dbh->prepare( q{ SELECT eps.id AS episode, concat('https://hackerpublicradio.org/eps/hpr', lpad(eps_id,4,0),'/index.html') AS identifier_url, eps.title, eps.summary, eps.date, ho.host, ho.hostid, date_format(co.comment_timestamp,'%Y-%m-%dT%TZ') AS timestamp, co.comment_author_name, co.comment_title, co.comment_text, unix_timestamp(co.comment_timestamp) AS comment_timestamp_ut FROM comments co JOIN eps ON eps.id = co.eps_id JOIN hosts ho ON eps.hostid = ho.hostid WHERE co.comment_timestamp >= ? AND co.comment_timestamp < (last_day(?)+ interval 1 day) ORDER BY episode ASC, comment_timestamp ASC }); # # Need the date and time of the last recording and the start of the # last month we reviewed to perform the query. # # $sth1->execute( $dt_lr->datetime . 'Z', $dt_lm->ymd ); $sth1->execute( $dt_lr->ymd, $dt_lm->ymd ); if ( $dbh->err ) { carp $dbh->errstr; } # # Grab the data as an arrayref of hashrefs # $missed_comments = $sth1->fetchall_arrayref( {} ); $missed_count = ( defined($missed_comments) ? scalar(@$missed_comments) : 0 ); # # Make the comment text cleaner by removing carriage returns (not sure # why they are there in the first place) # for my $ch (@$missed_comments) { $ch->{comment_text} =~ s/\r+//g; } _debug ($DEBUG > 2, '@missed_comments: ' . Dumper($missed_comments) ); # # After a change in design around 2023-05-02 there may be duplicates # in the %past hash and the @$missed_comments array. We need to hide # the former for now. They will not be hidden when $t_days is false # because we're not bothered about missed comments! # if ( $past_count > 0 ) { my @missed_episodes = map { $_->{episode} } @$missed_comments; _debug( $DEBUG > 2, '@missed_episodes: ' . Dumper( \@missed_episodes ) ); delete( @past{@missed_episodes} ); my $old_pc = $past_count; $past_count = scalar( keys(%past) ); $comment_count -= ($old_pc - $past_count); _debug( $DEBUG > 2, '%past (edited): ' . Dumper( \%past ), "\$past_count: $past_count", "\$comment_count: $comment_count" ); } } } #------------------------------------------------------------------------------- # Fill and print the template #------------------------------------------------------------------------------- my $tt = Template->new( { ABSOLUTE => 1, ENCODING => 'utf8', INCLUDE_PATH => $basedir, FILTERS => { # For HTML->ASCII in comments_only.tpl, decode HTML entities 'decode_entities' => \&my_decode_entities, }, } ); my $vars = { review_month => $dt->month_name, review_year => $dt->year, hosts => $hosts, shows => $shows, comment_count => $comment_count, past_count => $past_count, ignore_count => $ignore_count, missed_count => $missed_count, missed_comments => $missed_comments, comments => $comments, # legacy past => \%past, current => \%current, skip_comments => ( $show_comments ? 0 : 1 ), mark_comments => $mark_comments, ctext => $ctext, last_recording => ( $mark_comments ? $dt_lr->epoch : 0 ), last_month => ( $mark_comments ? sprintf( "%d-%02d", $dt_lm->year, $dt_lm->month ) : 0 ), includefile => $mailnotes, aob => defined($aobfile), aobfile => $aobfile, }; #print Dumper($vars),"\n"; my $document; $tt->process( $template, $vars, \$document, { binmode => ':utf8' } ) || die $tt->error(), "\n"; print $outfh $document; # # The episode number tests passed earlier on, so add the notes to the database # if so requested # if ($episode) { emit( $silent, "Writing shownotes to the database for episode $episode\n" ); $sth1 = $dbh->prepare(q{UPDATE eps SET notes = ? WHERE id = ?}); $sth1->execute( $document, $episode ); if ( $dbh->err ) { carp $dbh->errstr; } } $dbh->disconnect; exit; #=== FUNCTION ================================================================ # NAME: parse_to_dc # PURPOSE: Parse a textual date (and optional time) to a Date::Calc # datetime # PARAMETERS: $datetime Datetime as a string # $deftime Arrayref default time (as a Date::Calc array # or undef) # RETURNS: A Date::Calc date (and possibly time) as a list # DESCRIPTION: The $datetime argument is parsed ewith Date::Parse. The year # and month need to be adjusted. If a default time has been # supplied then the parsed time is checked and the default time # used if nothing was found, otherwise the parsed time is used # and a full 6-component time returned. # If the default time us undefined this means we don't care # about the time and so we just return the parsed date as # a 3-component list. # THROWS: No exceptions # COMMENTS: None # SEE ALSO: N/A #=============================================================================== sub parse_to_dc { my ( $datetime, $deftime ) = @_; # What strptime returns: # 0 1 2 3 4 5 6 # ($ss,$mm,$hh,$day,$month,$year,$zone) # my @parsed = strptime($datetime); die "Invalid DATE or DATETIME '$datetime'\n" unless ( defined( $parsed[3] ) && defined( $parsed[4] ) && defined( $parsed[5] ) ); $parsed[5] += 1900; $parsed[4] += 1; if ( defined($deftime) ) { # # If no time was supplied add a default one # unless ( defined( $parsed[2] ) && defined( $parsed[1] ) && defined( $parsed[0] ) ) { @parsed[ 2, 1, 0 ] = @$deftime; } # # Return a list # return ( @parsed[ 5, 4, 3, 2, 1, 0 ] ); } else { return ( @parsed[ 5, 4, 3 ] ); } } #=== FUNCTION ================================================================ # NAME: dc_to_dt # PURPOSE: Converts a Date::Calc datetime into a DateTime equivalent # PARAMETERS: $refdt Reference to an array holding a Date::Calc # date and time # RETURNS: Returns a DateTime object converted from the input # DESCRIPTION: # THROWS: No exceptions # COMMENTS: None # SEE ALSO: N/A #=============================================================================== sub dc_to_dt { my ($refdt) = @_; # # Check we got a 6-element array # if (scalar(@$refdt) != 6) { print "Invalid Date::Calc date and time (@$refdt)\n"; die "Aborted\n"; } # # Convert to DateTime to get access to formatting stuff # my ( %dtargs, $dt ); @dtargs{ 'year', 'month', 'day', 'hour', 'minute', 'second', 'time_zone' } = ( @$refdt, 'UTC' ); $dt = DateTime->new(%dtargs); # # Return the date as a DateTime object # return $dt; } #=== FUNCTION ================================================================ # NAME: find_last_recording # PURPOSE: Finds the recording date of the Community News episode # relating to the target month # PARAMETERS: $refdate Reference to an array holding a Date::Calc # date - the first of the selected month # $reftime Reference to an array holding a Date::Calc # time (UTC) # RETURNS: A Date::Calc object containing the date and time of the last # Community News recording in the UTC time zone # DESCRIPTION: We want to find the date of the last Community News recording # to determine whether a given comment preceded it. The scenario # is that we're using these notes while making such a recording # and want to know if comments occured before the last one. If # they did we should have read them during that show and don't # need to do so now. We want to pass the date generated here to # the template so it can compare it with comment dates. To # complete the story, the template will mark such comments, but # we'll turn of the marking before the notes are released - they # are just for use by the people recording the episode. # THROWS: No exceptions # COMMENTS: None # SEE ALSO: N/A #=============================================================================== sub find_last_recording { my ($refdate, $reftime) = @_; my $monday = 1; # Day of week number 1-7, Monday-Sunday # # Using the given date (the requested month), ensure it's the first day of # the month # my @lastmonth = @$refdate; $lastmonth[2] = 1; # # Work out the recording date in the target month # @lastmonth = Nth_Weekday_of_Month_Year( @$refdate[ 0, 1 ], $monday, 1 ); @lastmonth = Add_Delta_Days( @lastmonth, -2 ); # # Return the date as a DateTime object # return (@lastmonth,@$reftime); } #=== FUNCTION ================================================================ # NAME: find_last_month # PURPOSE: Finds the previous month for working out marks # PARAMETERS: $refdate Reference to an array holding a Date::Calc # date - the first of the selected month # RETURNS: A DateTime object containing the first day of the last month. # DESCRIPTION: We need the details of the last month because if we're marking # comments we have already read out in the previous Community # News show, but don't want to mark comments to shows before # that month, even if the comments fell within the target month. # This is a complex edge condition that wasn't appreciated in the # first implementation. # THROWS: No exceptions # COMMENTS: None # SEE ALSO: N/A #=============================================================================== sub find_last_month { my ($refdate) = @_; my $monday = 1; # Day of week number 1-7, Monday-Sunday my @starttime = ( 00, 00, 00 ); # UTC # # Using the given date (the requested month for the notes), ensure it's # the first day of the month # my @lastmonth = @$refdate; $lastmonth[2] = 1; # # Subtract one day to enter the previous month and force the first day of # the resulting month # @lastmonth = Add_Delta_Days( @lastmonth, -1 ); $lastmonth[2] = 1; # # Return the date as a DateTime object # return (@lastmonth,@starttime); } #=== FUNCTION ================================================================ # NAME: trailing_days # PURPOSE: Determines if the last month had 'trailing' days - those after # the recording date - during which unread comments could have # been posted. # PARAMETERS: $dc_lr reference to an array containing a Date::Calc # date of the last recording # $dc_lm reference to an array containing a Date::Calc # date of the first day of last month # RETURNS: A true/false result - 1 if there were trailing days, # 0 otherwise # DESCRIPTION: If the recording of a Community News show was during the month # being reviewed (e.g. March 2019; recording on 2019-03-30, # release on 2019-04-01) then a comment could have been made # after (or during!) the recording and probably would not have # been read during the show. We want to spot such a case and # highlight it in the *next* recording! # Yes, I know this is obsessive, but it needs a solution!!! # THROWS: No exceptions # COMMENTS: None # SEE ALSO: N/A #=============================================================================== sub trailing_days { my ( $dc_lr, $dc_lm ) = @_; my $offset; # # Are the last month and the recording month the same? # if ( $dc_lm->[1] eq $dc_lr->[1] ) { # # Compute the offset as Delta_Days((First day of last month + days in # month), recording date). A positive offset (not sure if we'd get # a negative one) means there's some of the month still to go. # $offset = Delta_Days( @$dc_lr[0..2], Add_Delta_Days( @$dc_lm[0..2], Days_in_Month( @$dc_lm[ 0, 1 ] ) ) ); return 1 if $offset gt 0; } return 0; } #=== FUNCTION ================================================================ # NAME: validate_date # PURPOSE: Checks the date found in the database ($date) is before the # reference date ($refdate) # PARAMETERS: $date Textual date from the database # $refdate Optional textual date to compare with. If # omitted then we use 'Today' # RETURNS: True (1) if the $date is later than the $refdate, false (0) # otherwise # DESCRIPTION: We need to check that the script is not being used to change # the notes for a Community News show in the past. This is # because sometimes the generated notes are edited after they # have been created to add other elements, and we do not want to # accidentally destroy such changes. We just compute the # difference between today and the date of the target episode. # If the difference in days is greater than 0 then it's OK. # We also want to be able to use this routine to check whether # comments relate to old shows or this month's. # THROWS: No exceptions # COMMENTS: None # SEE ALSO: N/A #=============================================================================== sub validate_date { my ($date, $refdate) = @_; my @refdate; unless (defined($date)) { warn "check_date: invalid argument\n"; return 0; } my @date = ($date =~ /^(\d{4})-(\d{2})-(\d{2})$/); if (defined($refdate)) { @refdate = ($refdate =~ /^(\d{4})-(\d{2})-(\d{2})$/); } else { @refdate = Today(); } return (Delta_Days(@refdate,@date) >= 0); } #=== FUNCTION ================================================================ # NAME: my_decode_entities # PURPOSE: Call 'HTML::Entities::decode_entities' as a filter in a template # PARAMETERS: $text The text string to process # RETURNS: The text string with all HTML entities decoded to Unicode # DESCRIPTION: This is a local filter to be called in a template. The name # it's called by is defined in the 'FILTERS' definition in the # call to Template::new. Maybe this function is redundant and # the real 'decode_entities' could have been called directly, # but this is slightly easier to understand (I think). # THROWS: No exceptions # COMMENTS: None # SEE ALSO: N/A #=============================================================================== sub my_decode_entities { my $text = shift; decode_entities($text); return $text; } #=== FUNCTION ================================================================ # NAME: emit # PURPOSE: Print text on STDERR unless silent mode has been selected # PARAMETERS: - Boolean indicating whether to be silent or not # - list of arguments to 'print' # RETURNS: Nothing # DESCRIPTION: This is a wrapper around 'print' to determine whether to send # a message to STDERR depending on a boolean. We need this to be # able to make the script silent when the -silent option is # selected # THROWS: No exceptions # COMMENTS: None # SEE ALSO: N/A #=============================================================================== sub emit { unless (shift) { print STDERR @_; } } #=== FUNCTION ================================================================ # NAME: _debug # PURPOSE: Prints debug reports # PARAMETERS: $active Boolean: 1 for print, 0 for no print # @messages List of messages to print # RETURNS: Nothing # DESCRIPTION: Outputs messages to STDERR if $active is true. For each # message it removes any trailing newline and then adds one in # the 'print' so the caller doesn't have to bother. Prepends # each message with 'D> ' to show it's a debug message. # THROWS: No exceptions # COMMENTS: None # SEE ALSO: N/A #=============================================================================== sub _debug { my ( $active, @messages ) = @_; return unless $active; foreach my $msg (@messages) { chomp($msg); print STDERR "D> $msg\n"; } } #=== FUNCTION ================================================================ # NAME: Options # PURPOSE: Processes command-line options # PARAMETERS: $optref Hash reference to hold the options # RETURNS: Undef # DESCRIPTION: # THROWS: no exceptions # COMMENTS: none # SEE ALSO: n/a #=============================================================================== sub Options { my ($optref) = @_; my @options = ( "help", "documentation|man", "debug=i", "from=s", "out=s", "template=s", "comments!", "markcomments|mc!", "ctext!", "lastrecording|lr=s", "silent!", "episode=s", "overwrite!", "mailnotes:s", "anyotherbusiness|aob=s", "config=s", "interlock=s" ); if ( !GetOptions( $optref, @options ) ) { pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 ); } return; } __END__ #%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% # Application Documentation #%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% #{{{ =head1 NAME make_shownotes - Make HTML show notes for the Hacker Public Radio Community News show =head1 VERSION This documentation refers to B version 0.2.2 =head1 USAGE make_shownotes [-help] [-documentation] [-from=DATE] [-[no]comments] [-[no]markcomments] [-[no]ctext] [-lastrecording=DATETIME] [-[no]silent] [-out=FILE] [-episode=[N|auto]] [-[no]overwrite] [-mailnotes[=FILE]] [-anyotherbusiness=FILE] [-template=FILE] [-config=FILE] [-interlock=PASSWORD] =head1 OPTIONS =over 8 =item B<-help> Displays a brief help message describing the usage of the program, and then exits. =item B<-documentation> Displays the entirety of the documentation (using a pager), and then exits. To generate a PDF version use: pod2pdf make_shownotes --out=make_shownotes.pdf =item B<-from=DATE> This option is used to indicate the month for which the shownotes are to be generated. The script is able to parse a variety of date formats, but it is recommended that ISO8601 YYYY-MM-DD format be used (for example 2014-06-30). The day part of the date must be present but is ignored and only the month and year parts are used. If this option is omitted the current month is used. =item B<-[no]comments> This option controls whether the comments pertaining to the selected month are included in the output. If the option is omitted then no comments are included (B<-nocomments>). =item B<-[no]markcomments> or B<-[no]mc> This option controls whether certain comments are marked in the HTML. The default is B<-nomarkcomments>. The option can be abbreviated to B<-mc> and B<-nomc>. The scenario is that we want to use the notes the script generates while making a Community News recording and we also want them to be the show notes in the database once the show has been released. Certain comments relating to shows earlier than this month were already discussed last month, because they were made before that show was recorded. We don't want to read them again during this show, so a means of marking them is needed. The script determines the date of the last recording (or it can be specified with the B<-lastrecording=DATETIME> option, or its abbreviation B<-lr=DATETIME>) and passes it to the template. The template can then compare this date with the dates of the relevant comments and take action to highlight those we don't want to re-read. It is up to the template to do what is necessary to highlight them. The idea is that we will turn off the marking before the notes are released - they are just for use by the people recording the episode. Another action is taken during the processing of comments when this option is on. On some months of the year the recording is made during the month itself because the first Monday of the next month is in the first few days of that month. For example, in March 2019 the date of recording is the 30th, and the show is released on April 1st. Between the recording and the release of the show there is time during which more comments could be submitted. Such comments should be in the notes for March (and these can be regenerated to make sure this is so) but they will not have been read on the March recording. The B script detects this problem and, if B<-markcomments> is set (and comments enabled) will show a list of any eligible comments in a red highlighted box. This is so that the volunteers recording the show can ensure they read comments that have slipped through this loophole. The display shows the entire comment including the contents, but disappears when the notes are refreshed with B<-nomarkcomments> (the default). In this mode the preamble warning about comments to be ignored used to be included, but now it is skipped if there are no such comments. This means one switch can serve two purposes. =item B<-lastrecording=DATETIME> or B<-lr=DATETIME> As mentioned for B<-markcomments>, the date of the last recording can be computed in the assumption that it's on the Saturday before the first Monday of the month at 15:00. However, on rare occasions it may be necessary to record on an earlier date and time, which cannot be computed. This value can be defined with this option. The format can be an ISO 8601 date followed by a 24-hour time, such as '2020-01-25 15:00'. If the time is omitted it defaults to 15:00. =item B<-[no]ctext> This option controls whether the comment text itself is listed with comments. This is controlled by the template, but the current default template only shows the text in the B section of the output. The default state is B<-noctext> in which the comment texts are not written. =item B<-[no]silent> This option controls whether the script reports details of its progress to STDERR. If the option is omitted the report is generated (B<-nosilent>). The script reports: the month it is working on, the name of the output file (if appropriate) and details of the process of writing notes to the database (if the B<-episode=[N|auto]> option is selected). =item B<-mailnotes[=FILE]> If desired, the show notes may include a section about recent discussions on the HPR mailing list. Obviously, this text will change every month, so this option provides a way in which an external file can be included in the show notes. The filename may be omitted which is a way in which a B directive can be placed in the template and used rather than the file. The B must be named B because this is the name the script uses in this circumstance. See B for an example of its use. The template must contain instructions to include the file or block. The file name is stored in a variable 'B' in the template. Directives of the following form may be added to achive this: [%- IF includefile.defined %] Constant header, preamble, etc [%- INCLUDE $includefile %] Other constant text or tags [%- END %] The first directive causes the whole block to be ignored if there is no B<-mailnotes> option. The use of the B directive means that the included file may contain Template directives itself if desired. See existing templates for examples of how this is done. =item B<-anyotherbusiness=FILE> or B<-aob=FILE> If desired the shownotes may contain an 'Any other business' section. This is implemented in a template thus: [% IF aob == 1 -%]

Any other business

[% INCLUDE $aobfile -%] [%- END %] The template variable B is set to 1 if a (valid) file has been provided, and the name of the file is in B. The included file is assumed to be HTML. =item B<-out=FILE> This option defines an output file to receive the show notes. If the option is omitted the notes are written to STDOUT, allowing them to be redirected if required. The output file name may contain the characters 'B<%s>'. This denotes the point at which the year and month in the format B are inserted. For example if the script is being run for July 2014 the option: -out=shownotes_%s.html will cause the generation of the file: shownotes_2014-07.html =item B<-episode=[N|auto]> This option provides a means of specifying an episode number in the database to receive the show notes. It either takes a number, or it takes the string 'B' which makes the script find the correct show number. First the episode number has to have been reserved in the database. This is done by running the script 'B'. This makes a reservation with the title "HPR Community News for ". Normally Community News slots are reserved several months in advance. Close to the date of the Community News show recording this script can be run to write show notes to the database. For example: ./make_shownotes -from=1-Dec-2014 -out=/dev/null \ -comm -tem=shownote_template5.tpl -ep=auto This will search for the episode with the title "HPR Community News for December 2014" and will add notes if the field is empty. Note that it is necessary to direct the output to /dev/null since the script needs to write a copy of the notes to STDOUT or to a file. In this case we request comments to be added to the notes, and we use the template file B which generates an HTML snippet suitable for the database. The writing of the notes to the database will fail if the field is not empty. See the B<-overwrite> option for how to force the notes to be written. If the B<-episode=[N|auto]> option is omitted no attempt is made to write to the database. =item B<-[no]overwrite> This option is only relevant in conjunction with the B<-episode=[N|auto]> option. If B<-overwrite> is chosen the new show notes will overwrite any notes already in the database. If B<-nooverwrite> is selected, or the option is omitted, no over writing will take place - it will only be possible to write notes to the database if the field is empty. =item B<-template=FILE> This option defines the template used to generate the notes. The template is written using the B