1711 lines
56 KiB
Plaintext
1711 lines
56 KiB
Plaintext
|
#!/usr/bin/env perl
|
||
|
#===============================================================================
|
||
|
#
|
||
|
# FILE: summarise_mail
|
||
|
#
|
||
|
# USAGE: ./summarise_mail [-help] [-debug=N] [-from=DATE] [-to=DATE]
|
||
|
# [-out=FILE] [-template=FILE] [-[no]silent] [-[no]checknew]
|
||
|
# [-initialise=N]
|
||
|
#
|
||
|
# DESCRIPTION: Generates a summary of the HPR mailing list for the Community
|
||
|
# News
|
||
|
#
|
||
|
# OPTIONS: ---
|
||
|
# REQUIREMENTS: ---
|
||
|
# BUGS: ---
|
||
|
# NOTES: ---
|
||
|
# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
|
||
|
# VERSION: 0.0.5
|
||
|
# CREATED: 2015-02-15 15:06:11
|
||
|
# REVISION: 2015-10-04 20:21:44
|
||
|
#
|
||
|
#-------------------------------------------------------------------------------
|
||
|
# TODO
|
||
|
#
|
||
|
# - Add a means of reporting all of the message bodies in a thread. They
|
||
|
# ideally need formatting and should be printable so that a template can
|
||
|
# generate plain text versions for use by the Community News hosts when
|
||
|
# reviewing the last month's messages. This is quite a large task but maybe
|
||
|
# there are mail formatters that can do it.
|
||
|
#
|
||
|
#===============================================================================
|
||
|
|
||
|
use 5.010;
|
||
|
use strict;
|
||
|
use warnings;
|
||
|
use utf8;
|
||
|
|
||
|
use Getopt::Long;
|
||
|
use Pod::Usage;
|
||
|
use Config::General;
|
||
|
|
||
|
use LWP::UserAgent;
|
||
|
use Mail::Box::Manager;
|
||
|
|
||
|
use Date::Parse;
|
||
|
use Date::Calc qw{:all};
|
||
|
use DateTime;
|
||
|
use DateTime::TimeZone;
|
||
|
|
||
|
use Template;
|
||
|
use Template::Filters;
|
||
|
Template::Filters->use_html_entities; # Use HTML::Entities in the template
|
||
|
|
||
|
use Data::Dumper;
|
||
|
|
||
|
#
|
||
|
# Version number (manually incremented)
|
||
|
#
|
||
|
our $VERSION = '0.0.5';
|
||
|
|
||
|
#
|
||
|
# Script and directory names
|
||
|
#
|
||
|
( my $PROG = $0 ) =~ s|.*/||mx;
|
||
|
( my $DIR = $0 ) =~ s|/?[^/]*$||mx;
|
||
|
$DIR = '.' unless $DIR;
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
# Declarations
|
||
|
#-------------------------------------------------------------------------------
|
||
|
#
|
||
|
# Constants and other declarations
|
||
|
#
|
||
|
my $basedir = "$ENV{HOME}/HPR/Community_News";
|
||
|
my $configfile = "$basedir/.$PROG.cfg";
|
||
|
my $bpfile = "$basedir/mailnote_template.tpl";
|
||
|
|
||
|
my (%dtargs, $dt, $tz, $rd, $mid,
|
||
|
$irt, $ref, $date, @parsed, %msg,
|
||
|
%id, $dt_from, $dt_to, %threads, $total,
|
||
|
);
|
||
|
|
||
|
my %tags = (
|
||
|
'from' => 'From',
|
||
|
'to' => 'To',
|
||
|
'cc' => 'CC',
|
||
|
'date' => 'Date',
|
||
|
'subject' => 'Subject',
|
||
|
'archived-at' => 'Link',
|
||
|
);
|
||
|
|
||
|
#
|
||
|
# Enable Unicode mode
|
||
|
#
|
||
|
binmode STDOUT, ":encoding(UTF-8)";
|
||
|
binmode STDERR, ":encoding(UTF-8)";
|
||
|
|
||
|
#
|
||
|
# Load configuration data
|
||
|
#
|
||
|
my $conf = Config::General->new(
|
||
|
-ConfigFile => $configfile,
|
||
|
-InterPolateVars => 1,
|
||
|
-ExtendedAccess => 1,
|
||
|
) or die "Unable to open $configfile\n";
|
||
|
|
||
|
my %config = $conf->getall() or die "Unable to process $configfile\n";
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
# Options and arguments
|
||
|
#-------------------------------------------------------------------------------
|
||
|
#
|
||
|
# Process options
|
||
|
#
|
||
|
my %options;
|
||
|
Options( \%options );
|
||
|
|
||
|
#
|
||
|
# Default help
|
||
|
#
|
||
|
pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 )
|
||
|
if ( $options{'help'} );
|
||
|
|
||
|
#
|
||
|
# Collect options
|
||
|
#
|
||
|
my $debug = ( defined( $options{debug} ) ? $options{debug} : 0 );
|
||
|
my $initialise = ( defined( $options{initialise} ) ? 1 : 0 );
|
||
|
my $start = $options{initialise};
|
||
|
my $silent = ( defined( $options{silent} ) ? $options{silent} : 0 );
|
||
|
my $checknew = ( defined( $options{checknew} ) ? $options{checknew} : 0 );
|
||
|
my $outfile = $options{out};
|
||
|
|
||
|
my @fromdate;
|
||
|
if ( defined( $options{from} ) ) {
|
||
|
@fromdate = parseDate( $options{from} );
|
||
|
}
|
||
|
|
||
|
my @todate;
|
||
|
if ( defined( $options{to} ) ) {
|
||
|
@todate = parseDate( $options{to} );
|
||
|
}
|
||
|
|
||
|
my $template
|
||
|
= ( defined( $options{template} ) ? $options{template} : $bpfile );
|
||
|
|
||
|
#
|
||
|
# Check for illegal option combinations
|
||
|
#
|
||
|
die "Use only one of -initialise and -checknew\n"
|
||
|
if ( $initialise && $checknew );
|
||
|
|
||
|
#
|
||
|
# Sanity checks
|
||
|
#
|
||
|
die "Error: Unable to find template $template\n" unless -r $template;
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
# Default the from date to today and compute a to date if one wasn't given
|
||
|
#-------------------------------------------------------------------------------
|
||
|
if ( scalar(@fromdate) == 0 ) {
|
||
|
@fromdate = Today();
|
||
|
}
|
||
|
|
||
|
if ( scalar(@todate) == 0 ) {
|
||
|
@todate = Add_Delta_Days( @fromdate[ 0, 1 ],
|
||
|
1, Days_in_Month( @fromdate[ 0, 1 ] ) );
|
||
|
}
|
||
|
|
||
|
#
|
||
|
# We need DateTime dates for comparison
|
||
|
#
|
||
|
emit( $silent, "From: ", ISO8601_Date(@fromdate), "\n" );
|
||
|
emit( $silent, "To: ", ISO8601_Date(@todate), "\n" );
|
||
|
|
||
|
$dt_from = calcToDT(@fromdate);
|
||
|
$dt_to = calcToDT(@todate);
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
# Open the output file (or STDOUT) - we may need the date to do it
|
||
|
#-------------------------------------------------------------------------------
|
||
|
my $outfh;
|
||
|
if ($outfile) {
|
||
|
$outfile = sprintf( $outfile,
|
||
|
sprintf( "%d-%02d", $dt_from->year, $dt_from->month ) )
|
||
|
if ( $outfile =~ /%s/ );
|
||
|
emit( $silent, "Output: ", $outfile, "\n" );
|
||
|
|
||
|
open( $outfh, ">:encoding(UTF-8)", $outfile )
|
||
|
or die "Unable to open $outfile for writing: $!";
|
||
|
}
|
||
|
else {
|
||
|
open( $outfh, ">&", \*STDOUT )
|
||
|
or die "Unable to initialise for writing: $!";
|
||
|
}
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
# Check the configuration file
|
||
|
#-------------------------------------------------------------------------------
|
||
|
checkConfig( \%config );
|
||
|
|
||
|
#
|
||
|
# The cached mail file
|
||
|
#
|
||
|
my $mailfile = join( '/', @{ $config{cache} }{ 'directory', 'filename' } );
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
# If asked to initialise the cache check it makes sense, otherwise look to see
|
||
|
# if we're to update an existing cache
|
||
|
#-------------------------------------------------------------------------------
|
||
|
if ($initialise) {
|
||
|
if ( -e $mailfile ) {
|
||
|
die "The mail cache $mailfile exists.\n"
|
||
|
. "Delete it before initialising\n";
|
||
|
}
|
||
|
unless (initialiseCache( \%config, $start )) {
|
||
|
print "Failed to initialise the cache; can't continue\n";
|
||
|
exit;
|
||
|
}
|
||
|
}
|
||
|
elsif ($checknew) {
|
||
|
unless ( -e $mailfile ) {
|
||
|
die "The mail cache $mailfile does not exist\n"
|
||
|
. "Initialise it before checking for updates\n";
|
||
|
}
|
||
|
unless (updateCache( \%config )) {
|
||
|
print "Failed to update the cache; continuing with old mail\n";
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#
|
||
|
# Open the mail file (as a folder in MBOX format)
|
||
|
#
|
||
|
my $mgr = Mail::Box::Manager->new;
|
||
|
my $folder = $mgr->open($mailfile);
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
# Walk through the messages in the folder collecting message details into
|
||
|
# a hash structure indexed by the UTC timestamp.
|
||
|
#-------------------------------------------------------------------------------
|
||
|
foreach my $message ( $folder->messages ) {
|
||
|
#
|
||
|
# TODO This doesn't work for a reason I don't understand. Needs to be
|
||
|
# fixed
|
||
|
#
|
||
|
#$message->unfold();
|
||
|
|
||
|
#
|
||
|
# Parse and convert the message date. The @parsed array contains
|
||
|
# $ss,$mm,$hh,$day,$month,$year,$zone. The year and month need adjustment.
|
||
|
# The timezone value is number of seconds from UTC.
|
||
|
#
|
||
|
$date = $message->get('Date');
|
||
|
@parsed = strptime($date);
|
||
|
$parsed[5] += 1900;
|
||
|
$parsed[4] += 1;
|
||
|
|
||
|
#
|
||
|
# Convert the parsed date to a DateTime
|
||
|
#
|
||
|
@dtargs{ 'second', 'minute', 'hour', 'day', 'month', 'year' } = (@parsed);
|
||
|
$dt = DateTime->new(%dtargs);
|
||
|
$tz = DateTime::TimeZone->new(
|
||
|
name => DateTime::TimeZone->offset_as_string( $parsed[6] ) );
|
||
|
$dt->set_time_zone($tz);
|
||
|
|
||
|
#
|
||
|
# Stash mail parameters
|
||
|
#
|
||
|
$rd = $dt->utc_rd_as_seconds();
|
||
|
$mid = formatID( $message->get('Message-ID'), 0 );
|
||
|
$irt = formatID( $message->get('In-Reply-To'), 1 );
|
||
|
|
||
|
my @from = $message->from;
|
||
|
my @to = $message->to;
|
||
|
my @cc = $message->cc;
|
||
|
|
||
|
$msg{$rd} = {
|
||
|
# '_date' => $date,
|
||
|
# '_from' => \@ffrom,
|
||
|
# '_to' => \@fto,
|
||
|
# '_cc' => \@fcc,
|
||
|
'_mid' => $message->get('Message-ID'),
|
||
|
'_irt' => $message->get('In-Reply-To'),
|
||
|
'_rdate' => $rd,
|
||
|
'date' => $dt->ymd . ' ' . $dt->hms . ' ' . $dt->time_zone->name,
|
||
|
'from' => formatEmail( \@from ),
|
||
|
'to' => formatEmail( \@to ),
|
||
|
'cc' => formatEmail( \@cc ),
|
||
|
'subject' => $message->get('Subject'),
|
||
|
'archived-at' => trimHeader( $message->get('Archived-At') ),
|
||
|
'message-id' => $mid,
|
||
|
'in-reply-to' => $irt,
|
||
|
'references' => formatReferences( $message->get('References') ),
|
||
|
'parent' => undef,
|
||
|
'children' => [],
|
||
|
};
|
||
|
|
||
|
#
|
||
|
# Stash the message id for easier linking
|
||
|
#
|
||
|
$id{$mid} = $rd;
|
||
|
}
|
||
|
|
||
|
$folder->close;
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
# Link the messages according to their internal back-references. Rescue any
|
||
|
# broken threads caused by people not using their mail clients properly.
|
||
|
#-------------------------------------------------------------------------------
|
||
|
linkMessages( \%msg, \%id, ( $debug == 0 ) );
|
||
|
repairThreads( \%msg, ( $debug == 0 ) );
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
# Generate the hash of message thread details that start in the selected
|
||
|
# period. Prepare the template and generate the final document
|
||
|
#-------------------------------------------------------------------------------
|
||
|
$total = buildThreadSummary( $dt_from, $dt_to, \%config, \%msg, \%threads,
|
||
|
( $debug == 0 ) );
|
||
|
|
||
|
my $tt = Template->new(
|
||
|
{ ABSOLUTE => 1,
|
||
|
ENCODING => 'utf8',
|
||
|
INCLUDE_PATH => $basedir,
|
||
|
}
|
||
|
);
|
||
|
my $vars = {
|
||
|
total => $total,
|
||
|
threads => \%threads,
|
||
|
};
|
||
|
my $document;
|
||
|
$tt->process( $template, $vars, \$document, { binmode => ':utf8' } )
|
||
|
|| die $tt->error(), "\n";
|
||
|
print $outfh $document;
|
||
|
close($outfh);
|
||
|
|
||
|
exit;
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: checkConfig
|
||
|
# PURPOSE: Check the configuration, aborting if it's messed up and
|
||
|
# missing necessary items
|
||
|
# PARAMETERS: $config configuration hash
|
||
|
# RETURNS: Nothing
|
||
|
# DESCRIPTION: Checks that all of the required items are present in the
|
||
|
# $config hash. These have been parsed from the configuration
|
||
|
# file usually called '.summarise_mail.cfg'. It only performs
|
||
|
# existence checks, nothing more fancy.
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub checkConfig {
|
||
|
my ($config) = @_;
|
||
|
|
||
|
die "Configuration file problem\n"
|
||
|
unless ( defined( $config->{gmane}->{url} )
|
||
|
&& defined( $config->{gmane}->{template1} )
|
||
|
&& defined( $config->{gmane}->{thread} )
|
||
|
&& defined( $config->{gmane}->{template2} )
|
||
|
&& defined( $config->{gmane}->{lookahead} )
|
||
|
&& defined( $config->{cache}->{directory} )
|
||
|
&& defined( $config->{cache}->{filename} )
|
||
|
&& defined( $config->{cache}->{regex} ) );
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: initialiseCache
|
||
|
# PURPOSE: Load a new cache from Gmane
|
||
|
# PARAMETERS: $config Configuration hashref
|
||
|
# $start Start number to download
|
||
|
# RETURNS: If the GET succeeded then 1 otherwise 0
|
||
|
# DESCRIPTION: Creates a new cache file of mail downloaded from Gmane. The
|
||
|
# file is in MBOX format but has had Gmane's obscuration
|
||
|
# algorithms applied to it, messing up certain elements like
|
||
|
# message-ids. The download begins with message $start and
|
||
|
# finishes with $start+LOOKAHEAD where the lookahead value is
|
||
|
# defined in the configuration file; usually 100.
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub initialiseCache {
|
||
|
my ( $config, $start ) = @_;
|
||
|
|
||
|
#
|
||
|
# How much to look ahead in Gmane; defined in the configuration
|
||
|
#
|
||
|
my $lookahead = $config->{gmane}->{lookahead};
|
||
|
|
||
|
#
|
||
|
# The name of the mail cache and its directory are in the configuration
|
||
|
#
|
||
|
my $mailfile
|
||
|
= join( '/', @{ $config->{cache} }{ 'directory', 'filename' } );
|
||
|
|
||
|
#
|
||
|
# Make the URL for download
|
||
|
#
|
||
|
my $url = sprintf( $config->{gmane}->{template1},
|
||
|
$start, $start + $lookahead + 1 );
|
||
|
print "URL: $url\n";
|
||
|
|
||
|
#
|
||
|
# Set up the HTTP GET
|
||
|
#
|
||
|
my $ua = LWP::UserAgent->new;
|
||
|
$ua->agent('HPR-Agent/0.1');
|
||
|
$ua->timeout(10);
|
||
|
|
||
|
#
|
||
|
# Get the data (if any)
|
||
|
#
|
||
|
my $response = $ua->get($url);
|
||
|
|
||
|
if ( $response->is_success ) {
|
||
|
#
|
||
|
# The GET succeeded, see what came back
|
||
|
#
|
||
|
if ( length( ${ $response->content_ref } ) > 0 ) {
|
||
|
#
|
||
|
# We got some new data. Append it to the rest
|
||
|
#
|
||
|
print "Mail messages found\n";
|
||
|
open( my $fh, ">", $mailfile )
|
||
|
or die "Unable to open $mailfile for writing\n";
|
||
|
print $fh $response->decoded_content;
|
||
|
close($fh);
|
||
|
}
|
||
|
else {
|
||
|
print "No messages found\n";
|
||
|
}
|
||
|
|
||
|
return 1;
|
||
|
}
|
||
|
else {
|
||
|
#
|
||
|
# The GET failed in a nasty way
|
||
|
#
|
||
|
warn $response->status_line;
|
||
|
|
||
|
return 0;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: updateCache
|
||
|
# PURPOSE: Get the latest mail messages from Gmane and save them in the
|
||
|
# cache
|
||
|
# PARAMETERS: $config configuration hash
|
||
|
# RETURNS: If the GET succeeded then 1 otherwise 0
|
||
|
# DESCRIPTION: Adds more messages downloaded from Gmane to the end of the
|
||
|
# current cache file. It needs to work out the number of the
|
||
|
# last message in the cache, which it does with a regex, looking
|
||
|
# at 'Archived-At:' headers. Then it adds the lookahead value
|
||
|
# from the configuration file to that and downloads a maximum of
|
||
|
# that number of messages using the Gmane API. These messages
|
||
|
# are appended to the existing MBOX file. There is no means of
|
||
|
# expiring older messages, and perhaps there should be.
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub updateCache {
|
||
|
my ($config) = @_;
|
||
|
|
||
|
#
|
||
|
# How much to look ahead in Gmane; defined in the configuration
|
||
|
#
|
||
|
my $lookahead = $config->{gmane}->{lookahead};
|
||
|
|
||
|
#
|
||
|
# The regex to parse the permalink URL is in the configuration
|
||
|
#
|
||
|
my $re = qr{$config->{cache}->{regex}};
|
||
|
|
||
|
#
|
||
|
# The name of the mail cache and its directory are in the configuration
|
||
|
#
|
||
|
my $mailfile
|
||
|
= join( '/', @{ $config->{cache} }{ 'directory', 'filename' } );
|
||
|
|
||
|
#
|
||
|
# Open the mail file
|
||
|
#
|
||
|
my $mgr = Mail::Box::Manager->new;
|
||
|
my $folder = $mgr->open($mailfile);
|
||
|
|
||
|
#
|
||
|
# Walk all the messages, parsing the permalink URLs to get the start and
|
||
|
# end
|
||
|
#
|
||
|
my $plink;
|
||
|
my ( $msgno, $start, $end ) = ( 0, 0, 0 );
|
||
|
foreach my $message ( $folder->messages ) {
|
||
|
$plink = $message->get('Archived-At');
|
||
|
if ( ($msgno) = ( $plink =~ /$re/ ) ) {
|
||
|
if ( $start eq 0 ) {
|
||
|
$start = $msgno;
|
||
|
}
|
||
|
else {
|
||
|
$end = $msgno;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
$folder->close;
|
||
|
|
||
|
#
|
||
|
# Build the new URL to get more messages, starting from the last one we
|
||
|
# have plus 1 for $lookahead+1 messages
|
||
|
#
|
||
|
my $url = sprintf( $config->{gmane}->{template1},
|
||
|
$end + 1, $end + $lookahead + 1 );
|
||
|
print "URL: $url\n";
|
||
|
|
||
|
#
|
||
|
# Set up the HTTP GET
|
||
|
#
|
||
|
my $ua = LWP::UserAgent->new;
|
||
|
$ua->agent('HPR-Agent/0.1');
|
||
|
$ua->timeout(10);
|
||
|
|
||
|
#
|
||
|
# Get the data (if any)
|
||
|
#
|
||
|
my $response = $ua->get($url);
|
||
|
|
||
|
if ( $response->is_success ) {
|
||
|
#
|
||
|
# The GET succeeded, see what came back
|
||
|
#
|
||
|
if ( length( ${ $response->content_ref } ) > 0 ) {
|
||
|
#
|
||
|
# We got some new data. Append it to the rest
|
||
|
#
|
||
|
print "New messages found\n";
|
||
|
open( my $fh, ">>", $mailfile )
|
||
|
or die "Unable to open $mailfile for appending\n";
|
||
|
print $fh $response->decoded_content;
|
||
|
close($fh);
|
||
|
}
|
||
|
else {
|
||
|
print "No new messages\n";
|
||
|
}
|
||
|
|
||
|
return 1;
|
||
|
}
|
||
|
else {
|
||
|
#
|
||
|
# The GET failed in a nasty way
|
||
|
#
|
||
|
warn $response->status_line;
|
||
|
|
||
|
return 0;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: parseDate
|
||
|
# PURPOSE: Parse and check a date
|
||
|
# PARAMETERS: $date Date string
|
||
|
# RETURNS: Date::Calc date or 'undef' if $date is undefined
|
||
|
# DESCRIPTION: Parses a date string using 'strptime' and converts it to
|
||
|
# a Date::Calc date list
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub parseDate {
|
||
|
my ($date) = @_;
|
||
|
|
||
|
if ( defined($date) ) {
|
||
|
#
|
||
|
# Parse and format the date
|
||
|
#
|
||
|
my @parsed = strptime($date);
|
||
|
@parsed[ 4, 5 ] = ( $parsed[4] + 1, $parsed[5] + 1900 );
|
||
|
|
||
|
return ( @parsed[ 5, 4, 3 ] );
|
||
|
}
|
||
|
else {
|
||
|
return; # implicit undef
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: ISO8601_Date
|
||
|
# PURPOSE: Format a Date::Calc date in ISO8601 format
|
||
|
# PARAMETERS: @date - a date in the Date::Calc format
|
||
|
# RETURNS: Text string containing a YYYY-MM-DD date
|
||
|
# DESCRIPTION: Just a convenience to allow a simple call like
|
||
|
# $str = ISO8601_Date(@date)
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub ISO8601_Date {
|
||
|
my (@date) = (@_)[ 0, 1, 2 ];
|
||
|
|
||
|
if ( check_date(@date) ) {
|
||
|
return sprintf( "%04d-%02d-%02d", @date );
|
||
|
}
|
||
|
else {
|
||
|
return "*Invalid Date*";
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: calcToDT
|
||
|
# PURPOSE: Convert a Date::Calc date to a UTC DateTime date
|
||
|
# PARAMETERS: $date Date::Calc date
|
||
|
# RETURNS: A DateTime date in the UTC timezone
|
||
|
# DESCRIPTION: Reformats a Date::Calc date to a DateTime date based on UTC
|
||
|
# (for date comparison)
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub calcToDT {
|
||
|
my (@date) = (@_)[ 0, 1, 2 ];
|
||
|
|
||
|
#
|
||
|
# Transfer Date::Calc values into a hash for initialising a DateTime
|
||
|
# object.
|
||
|
#
|
||
|
my ( %dtargs, $dt );
|
||
|
@dtargs{ 'year', 'month', 'day', 'time_zone' } = ( @date, 'UTC' );
|
||
|
$dt = DateTime->new(%dtargs);
|
||
|
return $dt;
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: formatEmail
|
||
|
# PURPOSE: Trims an array of anonymised Gmane email addresss in
|
||
|
# Mail::Address objects
|
||
|
# PARAMETERS: $remails The array of Mail::Address objects
|
||
|
# RETURNS: An array containing the trimmed addresses formatted as text
|
||
|
# DESCRIPTION: These addresses look like "Ken Fallon
|
||
|
# <ken-sq8tSfcSOvWzQB+pC5nmwQ@public.gmane.org>". The display
|
||
|
# name part (Ken Fallon) is anonymised if it contains an
|
||
|
# address-like specification. The local-part of the address is
|
||
|
# anonymised and the domain replaced by a Gmane domain. The
|
||
|
# front part of the local-part is unaffected. We want to build
|
||
|
# an address from the display name (if there is one), and the
|
||
|
# front of the local-part. So the above becomes:
|
||
|
# "Ken Fallon <ken@...>"
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub formatEmail {
|
||
|
my ($remails) = @_;
|
||
|
|
||
|
my ( $phrase, $address, $comment, @results );
|
||
|
|
||
|
foreach my $email ( @{$remails} ) {
|
||
|
#
|
||
|
# Parse out the components
|
||
|
#
|
||
|
( $phrase, $address, $comment )
|
||
|
= ( $email->phrase, $email->address, $email->comment );
|
||
|
|
||
|
#
|
||
|
# Assume Gmane obscured any email address wherever it is. Strip out
|
||
|
# the obscuring part to make it more readable
|
||
|
#
|
||
|
foreach my $item ( $phrase, $address, $comment ) {
|
||
|
if ( $item =~ /^"?([^-]+).+@.+$/ ) {
|
||
|
$item = "$1\@...";
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#
|
||
|
# Let the module reassemble the address in the right format, and add
|
||
|
# it to the result list
|
||
|
#
|
||
|
my $obj = Mail::Address->new( $phrase, $address, $comment );
|
||
|
push( @results, $obj->format );
|
||
|
}
|
||
|
|
||
|
return \@results;
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: formatID
|
||
|
# PURPOSE: Reformat a Message-ID: or In-Reply-To: value to undo the
|
||
|
# effects of Gmane's mail address obscuration which is applied
|
||
|
# to one but not the other.
|
||
|
# PARAMETERS: $header The header for processing
|
||
|
# $gmane Boolean to indicate whether we need to strip
|
||
|
# a Gmane obscuration part
|
||
|
# RETURNS: The processed header
|
||
|
# DESCRIPTION: If $gmane is true recognises the possible Gmane obscuration
|
||
|
# patterns and extracts the important elements from the ID,
|
||
|
# otherwise just strips the '@' and what follows. This is
|
||
|
# because Gmane (presumably) detects the '@' in every header
|
||
|
# type, assumes it's an address and applies its obscuring
|
||
|
# technique to it. Shame they apply such a sledgehammer
|
||
|
# algorithm.
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub formatID {
|
||
|
my ( $header, $gmane ) = @_;
|
||
|
|
||
|
return $header unless defined($header);
|
||
|
|
||
|
$header =~ s/(^<|>$)//g;
|
||
|
if ($gmane) {
|
||
|
if ( $header =~ /^(.+)-[^-]+@.+$/ ) {
|
||
|
$header = "$1";
|
||
|
}
|
||
|
elsif ( $header =~ /^(.+)@.+$/ ) {
|
||
|
$header = "$1";
|
||
|
}
|
||
|
}
|
||
|
else {
|
||
|
$header =~ s/@.+$//;
|
||
|
}
|
||
|
|
||
|
return $header;
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: formatReferences
|
||
|
# PURPOSE: Reformat a list of references in the References: header
|
||
|
# PARAMETERS: $header The header for processing
|
||
|
# RETURNS: The processed header as an arrayref
|
||
|
# DESCRIPTION: Removes the '<>' around each reference, but also removes the
|
||
|
# bit after and including the '@' since we need to compare it
|
||
|
# with the ID values we have had to tidy after Gmane got to
|
||
|
# them.
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub formatReferences {
|
||
|
my ($header) = @_;
|
||
|
|
||
|
return $header unless defined($header);
|
||
|
|
||
|
my ( @refs, @results );
|
||
|
|
||
|
@refs = split( /\s+/, $header );
|
||
|
foreach my $ref (@refs) {
|
||
|
$ref =~ s/(^<|>$)//g;
|
||
|
$ref =~ s/@.+$//;
|
||
|
push( @results, $ref );
|
||
|
}
|
||
|
|
||
|
return \@results;
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: trimHeader
|
||
|
# PURPOSE: Trims an arbitrary header which is enclosed in "<..>"
|
||
|
# PARAMETERS: $header The header for trimming
|
||
|
# RETURNS: The trimmed header
|
||
|
# DESCRIPTION: Certain Gmane headers like "Message-Id" are enclosed in "<>"
|
||
|
# marks. We want to strip these and return the result
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub trimHeader {
|
||
|
my ($header) = @_;
|
||
|
|
||
|
return $header unless defined($header);
|
||
|
|
||
|
$header =~ s/(^<|>$)//g;
|
||
|
|
||
|
return $header;
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: coalesce
|
||
|
# PURPOSE: To find the first defined argument and return it
|
||
|
# PARAMETERS: Arbitrary number of arguments
|
||
|
# RETURNS: The first defined argument or undef if there are none
|
||
|
# DESCRIPTION: Modelled on the SQL function of the same name. It takes a list
|
||
|
# of arguments, scans it for the first one that is not undefined
|
||
|
# and returns it. If an argument is defined and it's an arrayref
|
||
|
# then the referenced array is returned comma-delimited. This
|
||
|
# allows calls such as "coalesce($var,'undef')" which returns
|
||
|
# the value of $var if it's defined, and 'undef' if not and
|
||
|
# doesn't break anything along the way.
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub coalesce {
|
||
|
foreach (@_) {
|
||
|
if ( defined($_) ) {
|
||
|
if ( ref($_) eq 'ARRAY' ) {
|
||
|
return join( ',', @{$_} );
|
||
|
}
|
||
|
else {
|
||
|
return $_;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
return; # implicit undef
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: empty
|
||
|
# PURPOSE: Determine whether the argument contains data
|
||
|
# PARAMETERS: $arg Argument
|
||
|
# RETURNS: 0 if data found, otherwise 1
|
||
|
# DESCRIPTION: If an argument is defined and it's an arrayref then the array
|
||
|
# must contain elements otherwise it's regarded as empty. An
|
||
|
# undefined argument is empty of course.
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub empty {
|
||
|
my ($arg) = @_;
|
||
|
|
||
|
if ( defined($arg) ) {
|
||
|
if ( ref($arg) eq 'ARRAY' ) {
|
||
|
return scalar(@$arg) == 0;
|
||
|
}
|
||
|
else {
|
||
|
return 0;
|
||
|
}
|
||
|
}
|
||
|
else {
|
||
|
return 1;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: linkMessages
|
||
|
# PURPOSE: Link the collected messages using the 'In-Reply-To:' and
|
||
|
# 'References' headers
|
||
|
# PARAMETERS: $msg Hashref containing the messages
|
||
|
# $id Hashref containing message-id to message key
|
||
|
# links
|
||
|
# $silent Boolean determining whether to emit error
|
||
|
# messages
|
||
|
# RETURNS: Nothing
|
||
|
# DESCRIPTION: We want to build a structure where messages contain forward
|
||
|
# "child" links to other messages. So, given a message, we look
|
||
|
# for backward links in the 'In-Reply-To:' and 'References'
|
||
|
# headers, go to the referenced message(s) and make the current
|
||
|
# one a child. We don't do anything with multiple 'References',
|
||
|
# though we could.
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub linkMessages {
|
||
|
my ( $msg, $id, $silent ) = @_;
|
||
|
|
||
|
my $irt;
|
||
|
|
||
|
foreach my $key ( sort( keys( %{$msg} ) ) ) {
|
||
|
$irt = $msg->{$key}->{'in-reply-to'};
|
||
|
|
||
|
if ( defined($irt) ) {
|
||
|
#
|
||
|
# There's an In-Reply-To: header
|
||
|
#
|
||
|
if ( defined( $id->{$irt} ) ) {
|
||
|
#
|
||
|
# The parent of this message is the key of the back reference and
|
||
|
# we add this key to the children of the parent of this message
|
||
|
#
|
||
|
$msg->{$key}->{parent} = $id->{$irt};
|
||
|
push(
|
||
|
@{ $msg->{ $msg->{$key}->{parent} }->{children} },
|
||
|
$id->{ $msg->{$key}->{'message-id'} }
|
||
|
);
|
||
|
}
|
||
|
else {
|
||
|
emit( $silent,
|
||
|
"Unable to find Message-ID: $irt (in message $key)\n" );
|
||
|
}
|
||
|
}
|
||
|
elsif ( defined( $msg->{$key}->{'references'} ) ) {
|
||
|
#
|
||
|
# There's no In-Reply-To: but we have References:, where the last one
|
||
|
# is the equivalent. We update the parent message
|
||
|
#
|
||
|
$ref = $msg->{$key}->{'references'}->[-1];
|
||
|
if ( defined($ref) && defined( $id->{$ref} ) ) {
|
||
|
#
|
||
|
# The parent of this message is the key of the back reference and
|
||
|
# we add this key to the children of the parent of this message
|
||
|
#
|
||
|
$msg->{$key}->{parent} = $id->{$ref};
|
||
|
push(
|
||
|
@{ $msg->{ $msg->{$key}->{parent} }->{children} },
|
||
|
$id->{ $msg->{$key}->{'message-id'} }
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: repairThreads
|
||
|
# PURPOSE: Repair broken threads where possible
|
||
|
# PARAMETERS: $mhash Hashref of all messages
|
||
|
# $silent Boolean determining whether to emit error
|
||
|
# messages
|
||
|
# RETURNS: Nothing
|
||
|
# DESCRIPTION: We look for messages with a subject beginning with 'Re:' which
|
||
|
# don't have any back links. These look like broken threads
|
||
|
# where the sender has not used their mail client's ability to
|
||
|
# generate an 'In-Reply-To:' and/or 'References' header. We try
|
||
|
# to find the thread root by looking for the subject in all of
|
||
|
# the messages and then make the linkages.
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub repairThreads {
|
||
|
my ( $mhash, $silent ) = @_;
|
||
|
|
||
|
foreach my $key ( sort( keys( %{$mhash} ) ) ) {
|
||
|
if ( $mhash->{$key}->{subject} =~ /^Re:/ ) {
|
||
|
if (!( defined( $mhash->{$key}->{'in-reply-to'} )
|
||
|
|| defined( $mhash->{$key}->{'references'} )
|
||
|
)
|
||
|
)
|
||
|
{
|
||
|
#
|
||
|
# We have a message with a subject beginning with 'Re:' which
|
||
|
# doesn't have any back links. Looks like a broken thread.
|
||
|
# Find the thread root by looking for the subject.
|
||
|
#
|
||
|
my $parent = findParent( $mhash, $key );
|
||
|
|
||
|
#
|
||
|
# The parent has to be defined to be meaningful
|
||
|
#
|
||
|
if ( defined($parent) ) {
|
||
|
#
|
||
|
# Make this message's parent the message we found in two
|
||
|
# ways, via the 'parent' link and the 'in-reply-to'
|
||
|
# header.
|
||
|
#
|
||
|
$mhash->{$key}->{parent} = $parent;
|
||
|
$mhash->{$key}->{'in-reply-to'}
|
||
|
= $mhash->{$parent}->{'message-id'};
|
||
|
|
||
|
#
|
||
|
# Add this message to the parents children array, making
|
||
|
# sure it's sorted.
|
||
|
#
|
||
|
push( @{ $mhash->{$parent}->{children} }, $key );
|
||
|
$mhash->{$parent}->{children}
|
||
|
= [ sort( @{ $mhash->{$parent}->{children} } ) ];
|
||
|
|
||
|
#print "Parent:\n";
|
||
|
#dumpMessage( $mhash->{$parent} );
|
||
|
#print "Child:\n";
|
||
|
#dumpMessage( $mhash->{$key} );
|
||
|
}
|
||
|
else {
|
||
|
emit( $silent, "Couldn't find parent of message $key\n" );
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: findParent
|
||
|
# PURPOSE: Given a particular element in the message hash find its parent
|
||
|
# by processing the subject
|
||
|
# PARAMETERS: $mhash Hashref of all messages
|
||
|
# $key Key into the message hash
|
||
|
# RETURNS: The key of the parent
|
||
|
# DESCRIPTION: Tries to join an "orphaned" message with its parent by looking
|
||
|
# for another instance of the subject. First the subject of this
|
||
|
# message must begin with "Re:". This string is removed in order
|
||
|
# to perform a search for the message this one is in reference
|
||
|
# to. The search is conducted through all of the stored messages
|
||
|
# and the matching message must not have a parent or any back
|
||
|
# references itself to be acceptable. This is a fairly vague set
|
||
|
# of criteria but it does work for a number of unlinked
|
||
|
# messages. [Note: The regex used in the search has been modified
|
||
|
# to be case insensitive having encountered an instance where
|
||
|
# someone reconstructed the subject line in different case.]
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub findParent {
|
||
|
my ( $mhash, $key ) = @_;
|
||
|
|
||
|
my $start_subj = $mhash->{$key}->{subject};
|
||
|
my $end_subj;
|
||
|
my $parent;
|
||
|
|
||
|
if ( $start_subj =~ /^Re:/ ) {
|
||
|
( $end_subj = $start_subj ) =~ s/^Re:\s*//;
|
||
|
foreach my $k ( keys( %{$mhash} ) ) {
|
||
|
#if ( $msg{$k}->{subject} eq $end_subj ) {
|
||
|
if ( $k ne $key
|
||
|
&& $msg{$k}->{subject} =~ /(?:^Re:\s*)?(?i)$end_subj/ )
|
||
|
{
|
||
|
if (!( defined( $msg{$k}->{'in-reply-to'} )
|
||
|
|| defined( $msg{$k}->{'references'} )
|
||
|
)
|
||
|
)
|
||
|
{
|
||
|
$parent = $k;
|
||
|
last;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
return $parent;
|
||
|
}
|
||
|
else {
|
||
|
#
|
||
|
# Doesn't look like a message with a parent
|
||
|
#
|
||
|
return; # implicit undef
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: buildThreadSummary
|
||
|
# PURPOSE: Build a summary of the thread roots
|
||
|
# PARAMETERS: $dt_from The from date as a DateTime object
|
||
|
# $dt_to The to date as a DateTime object
|
||
|
# $config Hashref of the configuration data
|
||
|
# $mhash Hashref of all messages
|
||
|
# $threads Hashref to contain the resulting thread data
|
||
|
# $silent Boolean determining whether to emit error
|
||
|
# messages
|
||
|
# RETURNS: Total number of messages in all eligible threads
|
||
|
# DESCRIPTION: We have built a structure containing all of the message
|
||
|
# threads we've seen. However, we're only interested in the
|
||
|
# threads for a certain period, so we need to work them out
|
||
|
# here. A thread root is a message which has no parents, and
|
||
|
# we're interested in those which were written in our time
|
||
|
# period. We collect the details we want for the final display
|
||
|
# and build a weblink to the thread on Gmane. We also count the
|
||
|
# number of messages per thread and the grand total. The
|
||
|
# reulting structure is what we'll pass to the template we use
|
||
|
# to generate the insertion for the Community News notes (and
|
||
|
# other stuff).
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub buildThreadSummary {
|
||
|
my ( $dt_from, $dt_to, $config, $mhash, $threads, $silent ) = @_;
|
||
|
|
||
|
my ( $count, $total );
|
||
|
|
||
|
#
|
||
|
# Use the UTC seconds for comparison
|
||
|
#
|
||
|
my $from = $dt_from->utc_rd_as_seconds();
|
||
|
my $to = $dt_to->utc_rd_as_seconds();
|
||
|
|
||
|
#
|
||
|
# Adjust the regex from the config file for parsing the stored links.
|
||
|
# Remove the '<>' enclosing it.
|
||
|
#
|
||
|
my $re = $config->{cache}->{regex};
|
||
|
$re =~ s/^<|>$//g;
|
||
|
$re = qr{$re};
|
||
|
|
||
|
#
|
||
|
# Find each thread root which has messages in the date range
|
||
|
#
|
||
|
$total = 0;
|
||
|
foreach my $key ( sort( keys( %{$mhash} ) ) ) {
|
||
|
if ( !defined( $mhash->{$key}->{parent} )
|
||
|
&& eligibleThread( $from, $to, $mhash, $key ) )
|
||
|
# && ( $key ge $from && $key lt $to ) )
|
||
|
{
|
||
|
#
|
||
|
# Stash the headers we want
|
||
|
#
|
||
|
foreach my $k (qw{ from to cc date subject archived-at }) {
|
||
|
$threads{$key}->{$k} = $mhash->{$key}->{$k};
|
||
|
}
|
||
|
|
||
|
#
|
||
|
# Make a weblink to the Gmane thread
|
||
|
# TODO What if the regex doesn't match?
|
||
|
#
|
||
|
if ( $mhash->{$key}->{'archived-at'} =~ /$re/ ) {
|
||
|
$threads{$key}->{thread}
|
||
|
= sprintf( $config->{gmane}->{template2}, $1 );
|
||
|
}
|
||
|
else {
|
||
|
emit( $silent,
|
||
|
"Unable to match the Gmane thread (in message $key)\n" );
|
||
|
}
|
||
|
|
||
|
#
|
||
|
# Count the messages in the thread and keep a grand total
|
||
|
#
|
||
|
$count = 0;
|
||
|
threadLength( $from, $to, $mhash, $key, \$count );
|
||
|
$threads{$key}->{count} = $count;
|
||
|
$total += $count;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return $total;
|
||
|
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: eligibleThread
|
||
|
# PURPOSE: Determines whether a given thread is eligible to be included
|
||
|
# in a date range
|
||
|
# PARAMETERS: $from The from date as UTC seconds
|
||
|
# $to The to date as UTC seconds
|
||
|
# $mhash Hashref of all messages
|
||
|
# $key Key into the message hash for this particular
|
||
|
# thread
|
||
|
# RETURNS: True (1) if the thread is eligible, otherwise false (0)
|
||
|
# DESCRIPTION: Checks the top level message (thread root) for elegibility
|
||
|
# (date in range) then recursively descends the thread checking
|
||
|
# each message in the same manner as it goes. The results are
|
||
|
# ORed together so that if any message is in range the entire
|
||
|
# thread is.
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub eligibleThread {
|
||
|
my ( $from, $to, $mhash, $key ) = @_;
|
||
|
|
||
|
my $res = ( $key ge $from && $key lt $to );
|
||
|
|
||
|
foreach my $k ( @{ $mhash->{$key}->{children} } ) {
|
||
|
$res ||= ( $k ge $from && $k lt $to )
|
||
|
|| eligibleThread( $from, $to, $mhash, $k );
|
||
|
}
|
||
|
|
||
|
return $res;
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: threadLength
|
||
|
# PURPOSE: Count the number of eligible messages in a thread
|
||
|
# PARAMETERS: $from The from date as UTC seconds
|
||
|
# $to The to date as UTC seconds
|
||
|
# $mhash Hashref of all messages
|
||
|
# $key Key into the message hash
|
||
|
# $len Scalar ref containing accumulated length
|
||
|
# RETURNS: Nothing
|
||
|
# DESCRIPTION: Recursively descends through a thread (a tree of linked
|
||
|
# messages) counting the elements but only if they are in range.
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub threadLength {
|
||
|
my ( $from, $to, $mhash, $key, $len ) = @_;
|
||
|
|
||
|
$$len++ if ( $key ge $from && $key lt $to );
|
||
|
|
||
|
foreach my $k ( @{ $mhash->{$key}->{children} } ) {
|
||
|
threadLength( $from, $to, $mhash, $k, $len );
|
||
|
}
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: printThread
|
||
|
# PURPOSE: To walk a message thread and print it
|
||
|
# PARAMETERS: $mhash Hashref of all messages
|
||
|
# $key Key into the message hash
|
||
|
# $level Integer denoting recursion level
|
||
|
# $html 1 for HTML output, 0 for text
|
||
|
# RETURNS: Nothing
|
||
|
# DESCRIPTION: Recursively descend through a thread (a tree of linked
|
||
|
# messages) printing each one. If $html is true the message is
|
||
|
# printed in HTML, otherwise it's plain text with indentation.
|
||
|
# The level of indentation is controlled by $level which
|
||
|
# increments as the tree is recursed.
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub printThread {
|
||
|
my ( $mhash, $key, $level, $html ) = @_;
|
||
|
|
||
|
$level = 0 unless $level;
|
||
|
$html = 0 unless $html;
|
||
|
|
||
|
printMessage( $mhash->{$key}, $level, $html );
|
||
|
|
||
|
foreach my $k ( @{ $mhash->{$key}->{children} } ) {
|
||
|
printThread( $mhash, $k, $level + 1, $html );
|
||
|
}
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: printMessage
|
||
|
# PURPOSE: To print a message for debug purposes
|
||
|
# PARAMETERS: $msg Hashref containing message attributes
|
||
|
# $level Integer for indenting
|
||
|
# $html 1 for HTML output, 0 for text
|
||
|
# RETURNS: Nothing
|
||
|
# DESCRIPTION: Print a message. The details held consist only of a set of
|
||
|
# headers, and these are printed as a label and a value. If
|
||
|
# $html is true the output is HTML (an unnumbered list) and only
|
||
|
# headers with values are reported. If $html is false then plain
|
||
|
# text is used, empty values are reported and indentation is
|
||
|
# used, controlled by the value of $level. In plain text mode
|
||
|
# each item ends with a line of hyphens.
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub printMessage {
|
||
|
my ( $msg, $level, $html ) = @_;
|
||
|
|
||
|
$level = 0 unless $level;
|
||
|
$html = 0 unless $html;
|
||
|
|
||
|
# TODO Don't do it this way, use the global definition since it's used
|
||
|
# elsewhere
|
||
|
state %tags;
|
||
|
%tags = (
|
||
|
'from' => 'From',
|
||
|
'to' => 'To',
|
||
|
'cc' => 'CC',
|
||
|
'date' => 'Date',
|
||
|
'subject' => 'Subject',
|
||
|
'archived-at' => 'Link',
|
||
|
);
|
||
|
|
||
|
print "<ul>" if $html;
|
||
|
|
||
|
foreach my $k (qw{ from to cc date subject archived-at }) {
|
||
|
if ($html) {
|
||
|
if ( !empty( $msg->{$k} ) ) {
|
||
|
printf "<li>%-8s %s</li>\n", "$tags{$k}:",
|
||
|
coalesce( $msg->{$k}, '' );
|
||
|
}
|
||
|
}
|
||
|
else {
|
||
|
printf "%s%-8s %s\n", ' ' x $level, "$tags{$k}:",
|
||
|
coalesce( $msg->{$k}, '' );
|
||
|
}
|
||
|
}
|
||
|
if ($html) {
|
||
|
print "</ul>\n";
|
||
|
}
|
||
|
else {
|
||
|
print '-' x 80, "\n";
|
||
|
}
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: dumpMessage
|
||
|
# PURPOSE: To print all parts of a message hash for debugging
|
||
|
# PARAMETERS: $msg Hashref containing message attributes
|
||
|
# RETURNS: Nothing
|
||
|
# DESCRIPTION: Produces a dump of a message for debugging purposes.
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub dumpMessage {
|
||
|
my ($msg) = @_;
|
||
|
|
||
|
foreach my $k ( sort( keys( %{$msg} ) ) ) {
|
||
|
printf "%12s: %s\n", $k, coalesce( $msg->{$k}, '' );
|
||
|
}
|
||
|
print '-' x 80, "\n";
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: emit
|
||
|
# PURPOSE: Print text on STDERR unless silent mode has been selected
|
||
|
# PARAMETERS: - Boolean indicating whether to be silent or not
|
||
|
# - list of arguments to 'print'
|
||
|
# RETURNS: Nothing
|
||
|
# DESCRIPTION: This is a wrapper around 'print' to determine whether to send
|
||
|
# a message to STDERR depending on a boolean. We need this to be
|
||
|
# able to make the script silent when the -silent option is
|
||
|
# selected
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub emit {
|
||
|
unless (shift) {
|
||
|
print STDERR @_;
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: Options
|
||
|
# PURPOSE: Processes command-line options
|
||
|
# PARAMETERS: $optref Hash reference to hold the options
|
||
|
# RETURNS: Undef
|
||
|
# DESCRIPTION:
|
||
|
# THROWS: no exceptions
|
||
|
# COMMENTS: none
|
||
|
# SEE ALSO: n/a
|
||
|
#===============================================================================
|
||
|
sub Options {
|
||
|
my ($optref) = @_;
|
||
|
|
||
|
my @options = (
|
||
|
"help", "debug=i", "out=s", "template=s",
|
||
|
"silent!", "checknew!", "initialise=i", "from=s",
|
||
|
"to=s",
|
||
|
);
|
||
|
|
||
|
if ( !GetOptions( $optref, @options ) ) {
|
||
|
pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 );
|
||
|
}
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
__END__
|
||
|
|
||
|
#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||
|
# Application Documentation
|
||
|
#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||
|
#{{{
|
||
|
|
||
|
|
||
|
=head1 NAME
|
||
|
|
||
|
summarise_mail - Generate a summary of messages on the HPR mailing list
|
||
|
|
||
|
=head1 VERSION
|
||
|
|
||
|
This documentation refers to summarise_mail version 0.0.5
|
||
|
|
||
|
|
||
|
=head1 USAGE
|
||
|
|
||
|
./summarise_mail [-help] [-debug=N] [-from=DATE] [-to=DATE] [-out=FILE]
|
||
|
[-template=FILE] [-[no]silent] [-[no]checknew] [-initialise=N]
|
||
|
|
||
|
|
||
|
=head1 OPTIONS
|
||
|
|
||
|
=over 8
|
||
|
|
||
|
=item B<-help>
|
||
|
|
||
|
Prints a brief help message describing the usage of the program, and then exits.
|
||
|
|
||
|
=item B<-initialise=N>
|
||
|
|
||
|
This option builds a new mailbox cache. The value B<N> defines the starting
|
||
|
message to be downloaded from the Gmane site. The number defaults to 1, though
|
||
|
this is not advisable since it potentially overloads the Gmane server. The
|
||
|
number of messages downloaded is limited by the B<lookahead> value in the
|
||
|
configuration file (see below).
|
||
|
|
||
|
If a cache file already exists the script will not run. It is necessary to
|
||
|
delete this file before initialising a new one.
|
||
|
|
||
|
After the cache is initialised the script will process the messages it
|
||
|
contains.
|
||
|
|
||
|
The B<-initialise=N> and the B<-[no]checknew> options are mutually exclusive.
|
||
|
|
||
|
=item B<-[no]checknew>
|
||
|
|
||
|
This option defines whether the script will check Gmane for updates to the
|
||
|
mailing list. If updates are found then they will be incorporated into the
|
||
|
cached mailbox file.
|
||
|
|
||
|
If omitted no check is made (B<-nochecknew>).
|
||
|
|
||
|
The B<-initialise=N> and the B<-[no]checknew> options are mutually exclusive.
|
||
|
|
||
|
=item B<-from=DATE>
|
||
|
|
||
|
Specifies the starting date for the mail summary.
|
||
|
|
||
|
The date format should be B<DD-Mon-YYYY> (e.g. 12-Jun-2014), B<DD-MM-YYYY>
|
||
|
(e.g. 12-06-2014) or B<YYYY-MM-DD> (e.g. 2014-06-12).
|
||
|
|
||
|
If this option is omitted the current date is used.
|
||
|
|
||
|
=item B<-to=DATE>
|
||
|
|
||
|
Specifies the ending date for the mail summary.
|
||
|
|
||
|
The date format should be B<DD-Mon-YYYY> (e.g. 12-Jun-2014), B<DD-MM-YYYY>
|
||
|
(e.g. 12-06-2014) or B<YYYY-MM-DD> (e.g. 2014-06-12).
|
||
|
|
||
|
If this option is omitted the last day of the month defined by the B<-from>
|
||
|
date is used.
|
||
|
|
||
|
=item B<-template=FILE>
|
||
|
|
||
|
This option defines the template that will be used to format the report
|
||
|
generated by the script.
|
||
|
|
||
|
If the option is not provided, the script will use a template called
|
||
|
B<mailnote_template.tpl> in the directory which holds the script. This
|
||
|
template generates HTML suitable for incorporation into the Community News
|
||
|
show notes.
|
||
|
|
||
|
=item B<-out=FILE>
|
||
|
|
||
|
This option defines the output file for the report. If the option is omitted
|
||
|
the report is written to STDOUT, allowing it to be redirected if required.
|
||
|
|
||
|
The output file name may contain the characters 'B<%s>'. This denotes the point
|
||
|
at which the year and month in the format B<YYYY-MM> are inserted. For example
|
||
|
if the script is being run for July 2014 the option:
|
||
|
|
||
|
-out=mailreport_%s.html
|
||
|
|
||
|
will cause the generation of the file:
|
||
|
|
||
|
mailreport_2014-07.html
|
||
|
|
||
|
=item B<-[no]silent>
|
||
|
|
||
|
This option controls whether the script reports minimal details of what it is
|
||
|
doing to STDERR. If the option is omitted the report is generated (B<-nosilent>).
|
||
|
|
||
|
The script reports: the starting and ending dates it is working on and the name of the output file
|
||
|
(if appropriate).
|
||
|
|
||
|
=item B<-debug=N>
|
||
|
|
||
|
Runs the script in debug mode if the value of B<N> is greater than 0. The
|
||
|
default setting is zero.
|
||
|
|
||
|
In debug mode the script reports various pieces of information about the
|
||
|
parsing of the mail messages and building of threads.
|
||
|
|
||
|
=back
|
||
|
|
||
|
|
||
|
=head1 DESCRIPTION
|
||
|
|
||
|
=head2 Overview
|
||
|
|
||
|
This script generates a summary of the mail sent to the HPR mailing list over
|
||
|
a period.
|
||
|
|
||
|
Messages to the mailing list are copied to the free B<Gmane> service and the
|
||
|
script uses this to prepare its report. The Gmane service offers
|
||
|
a downloadable interface at
|
||
|
http://download.gmane.org/gmane.network.syndication.podcast.hacker-public-radio
|
||
|
where selected messages can be collected in B<MBOX> format.
|
||
|
|
||
|
=head2 Methodology
|
||
|
|
||
|
The script operates in three phases:
|
||
|
|
||
|
=over 4
|
||
|
|
||
|
=item -
|
||
|
|
||
|
Messages are collected from Gmane and stored in a local cache (a file in MBOX
|
||
|
format). This is expected to be done once, and thereafter new messages are
|
||
|
appended to this file.
|
||
|
|
||
|
=item -
|
||
|
|
||
|
New messages are appended to the cache file. This is done by determining the
|
||
|
last message in the cache then requesting the next block of messages from
|
||
|
Gmane. The downloaded messages are then added to the file.
|
||
|
|
||
|
=item -
|
||
|
|
||
|
The cache is processed to generate the report. Usually the script is given
|
||
|
a start date (and optional end date, though this defaults to the end of the
|
||
|
month in the start date).
|
||
|
|
||
|
The algorithm is as follows:
|
||
|
|
||
|
=over 4
|
||
|
|
||
|
=item -
|
||
|
|
||
|
The script parses out the important headers of all messages in the cache.
|
||
|
|
||
|
=item -
|
||
|
|
||
|
The script walks the header structure in time order joining messages based on
|
||
|
their 'In-reference-to:' and 'References:' headers. Each message gets its
|
||
|
parent recorded as a pointer, and its children as an array of pointers. If
|
||
|
a reference is to a message that is not in the cache then it is not recorded.
|
||
|
|
||
|
=item -
|
||
|
|
||
|
At this point all messages without a parent are either the thread roots or
|
||
|
cases where someone has not replied properly. The script performs a further
|
||
|
join when an "orphaned" message has the same subject as another with a 'Re:'
|
||
|
on the front.
|
||
|
|
||
|
=item -
|
||
|
|
||
|
Now all messages without parents are the best that can be done to detect
|
||
|
thread roots. These are used to make the report.
|
||
|
|
||
|
=item -
|
||
|
|
||
|
The messages that cannot be threaded are those where the sender does not use
|
||
|
'Reply' or where they reply to a digest. Digests are not threaded because they
|
||
|
are blobs of multiple messages.
|
||
|
|
||
|
=item -
|
||
|
|
||
|
Some messages may be threaded erroneously. This happens when a sender, rather
|
||
|
than generating a new message, replies to an existing message but changes the
|
||
|
'Subject:' line. There is not much that can be done to correct this.
|
||
|
|
||
|
=back
|
||
|
|
||
|
=back
|
||
|
|
||
|
=head2 Report Template
|
||
|
|
||
|
The script uses the B<Template> module to format the report. See the default
|
||
|
template B<mailnote_template.tpl> for an example.
|
||
|
|
||
|
The template is passed two variables:
|
||
|
|
||
|
=over 4
|
||
|
|
||
|
=item B<total>
|
||
|
|
||
|
This is a scalar variable containing a number: the count of messages in the
|
||
|
specified range.
|
||
|
|
||
|
=item B<threads>
|
||
|
|
||
|
This is a hash indexed by the message date converted to UTC date then to the
|
||
|
number of seconds since the Unix epoch. Each hash value is a hashref
|
||
|
containing the following fields:
|
||
|
|
||
|
Field Contents
|
||
|
----- --------
|
||
|
from The contents of the 'From:' header
|
||
|
to The contents of the 'To:' header
|
||
|
cc The contents of the 'Cc:' header
|
||
|
date The contents of the 'Date:' header
|
||
|
subject The contents of the 'Subject:' header
|
||
|
archived-at The contents of the 'Archived-at:' header (the Gmane
|
||
|
permalink URL)
|
||
|
count The number of messages in the thread
|
||
|
|
||
|
These messages are the roots of all of the detectable threads in the period.
|
||
|
Sorting them by the main hash key results in the threads being ordered by
|
||
|
their timestamp.
|
||
|
|
||
|
=back
|
||
|
|
||
|
=head1 DIAGNOSTICS
|
||
|
|
||
|
A list of every error and warning message that the application can generate
|
||
|
(even the ones that will "never happen"), with a full explanation of each
|
||
|
problem, one or more likely causes, and any suggested remedies. If the
|
||
|
application generates exit status codes (e.g. under Unix) then list the exit
|
||
|
status associated with each error.
|
||
|
|
||
|
|
||
|
=head1 CONFIGURATION AND ENVIRONMENT
|
||
|
|
||
|
The script obtains the information it requires to find the mail details from
|
||
|
B<Gmane> from a configuration file. The name of the file it expects is
|
||
|
B<.summarise_mail.cfg> in the
|
||
|
directory holding the script. To change this will require changing the script.
|
||
|
|
||
|
The configuration file format is as follows:
|
||
|
|
||
|
<gmane>
|
||
|
url = http://download.gmane.org/gmane.network.syndication.podcast.hacker-public-radio
|
||
|
template = "$url/%d/%d"
|
||
|
lookahead = 100
|
||
|
</gmane>
|
||
|
|
||
|
<cache>
|
||
|
directory = /home/dave/Community_News/mail_cache
|
||
|
filename = gmane.mbox
|
||
|
regex = "<http://permalink\.gmane\.org/gmane\.network\.syndication\.podcast\.hacker-public-radio/(\d+)>"
|
||
|
</cache>
|
||
|
|
||
|
The elements of this configuration file are explained below. First the
|
||
|
B<gmane> section:
|
||
|
|
||
|
=over 4
|
||
|
|
||
|
=item B<url>
|
||
|
|
||
|
This is the base URL on the Gmane site. It is used to construct URLs for
|
||
|
collecting mail messages. This should not require any changes.
|
||
|
|
||
|
=item B<template>
|
||
|
|
||
|
This is a template which is used to generate the actual URL used to download
|
||
|
messages. The script replaces each '%d' with a number. The first is the
|
||
|
starting message number, and the second the ending number plus one.
|
||
|
|
||
|
This should not require any changes.
|
||
|
|
||
|
=item B<lookahead>
|
||
|
|
||
|
This defines the maximum number of messages to be collected. This number is
|
||
|
added to the first number used when contructing the URL from the B<template>
|
||
|
above. Normally just a few messages are downloaded at a time, but when the
|
||
|
script is used to initialise the mailbox cache file this number of messages may
|
||
|
be downloaded.
|
||
|
|
||
|
This value can be changed if desired, but it is set at 100 in order to avoid
|
||
|
overloading the Gmane servers.
|
||
|
|
||
|
=back
|
||
|
|
||
|
The B<cache> section contains the following:
|
||
|
|
||
|
=over 4
|
||
|
|
||
|
=item B<directory>
|
||
|
|
||
|
This defines the full path to the directory holding the mail cache file.
|
||
|
|
||
|
This may be changed to reflect a change of location.
|
||
|
|
||
|
=item B<filename>
|
||
|
|
||
|
This defines the name of the mail cache. It is expected to be found in the
|
||
|
directory defined above.
|
||
|
|
||
|
This may be changed to reflect a change of name.
|
||
|
|
||
|
=item B<regex>
|
||
|
|
||
|
Individual messages in the cache each hold an URL which provides a permanent
|
||
|
link to the message on the Gmane site. The script uses the regular expression
|
||
|
defined here to collect the message number from this URL. This is how the
|
||
|
script determines the number of the oldest message in the cache when looking
|
||
|
for new messages.
|
||
|
|
||
|
This expression should only be changed with great caution.
|
||
|
|
||
|
=back
|
||
|
|
||
|
=head1 DEPENDENCIES
|
||
|
|
||
|
Getopt::Long
|
||
|
Pod::Usage
|
||
|
Config::General
|
||
|
LWP::UserAgent
|
||
|
Mail::Box::Manager
|
||
|
Date::Parse
|
||
|
Date::Calc
|
||
|
DateTime
|
||
|
DateTime::TimeZone
|
||
|
Template
|
||
|
Template::Filters
|
||
|
Data::Dumper
|
||
|
|
||
|
|
||
|
=head1 BUGS AND LIMITATIONS
|
||
|
|
||
|
There are no known bugs in this module.
|
||
|
Please report problems to Dave Morriss (Dave.Morriss@gmail.com)
|
||
|
Patches are welcome.
|
||
|
|
||
|
=head1 AUTHOR
|
||
|
|
||
|
Dave Morriss (Dave.Morriss@gmail.com)
|
||
|
|
||
|
|
||
|
=head1 LICENCE AND COPYRIGHT
|
||
|
|
||
|
Copyright (c) 2014, 2015 Dave Morriss (Dave.Morriss@gmail.com). All rights reserved.
|
||
|
|
||
|
This module is free software; you can redistribute it and/or
|
||
|
modify it under the same terms as Perl itself. See perldoc perlartistic.
|
||
|
|
||
|
This program is distributed in the hope that it will be useful,
|
||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||
|
|
||
|
=cut
|
||
|
|
||
|
#}}}
|
||
|
|
||
|
# [zo to open fold, zc to close]
|
||
|
|
||
|
# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker
|