627 lines
18 KiB
Plaintext
627 lines
18 KiB
Plaintext
|
#!/usr/bin/env perl
|
||
|
#===============================================================================
|
||
|
#
|
||
|
# FILE: query2tt2
|
||
|
#
|
||
|
# USAGE: ./query2tt2 [-help] [-debug=N] [-config=FILE] [-query=FILE]
|
||
|
# [-template=FILE]
|
||
|
# [-dbarg=ARG1 [-dbarg=ARG2] ...]
|
||
|
# [-define KEY1=VALUE1 [-define KEY2=VALUE2] ...
|
||
|
# [-define KEYn=VALUEn]] [QUERY]
|
||
|
#
|
||
|
# DESCRIPTION: Built for use with the Hacker Public Radio database, but could
|
||
|
# be used in any context with a MariaDB database.
|
||
|
# Runs a query given as the only argument (or in a file).
|
||
|
# Caution is needed since *any* query will be run, not just
|
||
|
# SELECT commands. The result of the query is output in
|
||
|
# a specified format defined by a template on STDOUT. The query
|
||
|
# can have arguments provided by '-dbarg=ARG' to be used in '?'
|
||
|
# placeholders in the SQL. The template can receive variables
|
||
|
# through the option '-define KEY=VALUE'. A configuration file
|
||
|
# is needed, though there is a default ('.hpr_db.cfg'), which
|
||
|
# accesses the local snapshot.
|
||
|
#
|
||
|
# OPTIONS: ---
|
||
|
# REQUIREMENTS: ---
|
||
|
# BUGS: ---
|
||
|
# NOTES: Had to revert to MySQL because of a problem with DBD::MariaDB
|
||
|
# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
|
||
|
# VERSION: 0.0.4
|
||
|
# CREATED: 2021-06-18 13:24:49
|
||
|
# REVISION: 2024-01-19 17:15:45
|
||
|
#
|
||
|
#===============================================================================
|
||
|
|
||
|
use 5.010;
|
||
|
use strict;
|
||
|
use warnings;
|
||
|
use utf8;
|
||
|
use open ':encoding(UTF-8)';
|
||
|
|
||
|
# Using experimental features, some of which require warnings to be turned off
|
||
|
use feature qw{ say try };
|
||
|
no warnings qw{
|
||
|
experimental::try
|
||
|
};
|
||
|
|
||
|
use Getopt::Long;
|
||
|
use Pod::Usage;
|
||
|
|
||
|
use Config::General;
|
||
|
#use Try::Tiny;
|
||
|
use File::Slurper qw{ read_text };
|
||
|
use Hash::Merge;
|
||
|
use Template;
|
||
|
use DBI;
|
||
|
|
||
|
use Data::Dumper;
|
||
|
|
||
|
#
|
||
|
# Version number (manually incremented)
|
||
|
#
|
||
|
our $VERSION = '0.0.4';
|
||
|
|
||
|
#
|
||
|
# 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/Database";
|
||
|
my $configfile = "$basedir/.hpr_db.cfg";
|
||
|
|
||
|
my ( $dbh, $sth1 );
|
||
|
my ( $query, $result, @names, $document );
|
||
|
|
||
|
#
|
||
|
# Default template iterates through all rows in the 'result' matrix and for
|
||
|
# each row displays the field name (key) from array 'names', and its value.
|
||
|
# There's a blank line after each row.
|
||
|
#
|
||
|
my $def_template = <<'ENDTPL';
|
||
|
[% FOREACH row IN result -%]
|
||
|
[% FOREACH key IN names -%]
|
||
|
[% key %]: [% row.$key %]
|
||
|
[% END -%]
|
||
|
|
||
|
[% END -%]
|
||
|
ENDTPL
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
# There should be no need to edit anything after this point
|
||
|
#-------------------------------------------------------------------------------
|
||
|
|
||
|
#
|
||
|
# Enable Unicode mode
|
||
|
#
|
||
|
#binmode STDOUT, ":encoding(UTF-8)";
|
||
|
#binmode STDERR, ":encoding(UTF-8)";
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
# Options and arguments
|
||
|
#-------------------------------------------------------------------------------
|
||
|
my %options;
|
||
|
Options( \%options );
|
||
|
|
||
|
#
|
||
|
# Default help
|
||
|
#
|
||
|
pod2usage( -msg => "Version $VERSION\n", -exitval => 1 )
|
||
|
if ( $options{'help'} );
|
||
|
|
||
|
#
|
||
|
# Full documentation if requested with -doc
|
||
|
#
|
||
|
pod2usage(
|
||
|
-msg => "$PROG version $VERSION\n",
|
||
|
-verbose => 2,
|
||
|
-exitval => 1,
|
||
|
-noperldoc => 0,
|
||
|
) if ( $options{'doc'} );
|
||
|
|
||
|
|
||
|
#
|
||
|
# Collect options
|
||
|
#
|
||
|
my $DEBUG = ( $options{'debug'} ? $options{'debug'} : 0 );
|
||
|
|
||
|
my $cfgfile
|
||
|
= ( defined( $options{config} ) ? $options{config} : $configfile );
|
||
|
|
||
|
my $queryfile = $options{query};
|
||
|
my $template = $options{template};
|
||
|
|
||
|
my @dbargs = _dbargs( \%options );
|
||
|
my %defs = _define( \%options );
|
||
|
_debug( $DEBUG >= 3, '@dbargs: ' . join( ',', @dbargs ) );
|
||
|
_debug( $DEBUG >= 3, '%defs: ' . Dumper(\%defs) );
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
# Option checks and defaults
|
||
|
#-------------------------------------------------------------------------------
|
||
|
die "Unable to find configuration file $cfgfile\n" unless ( -e $cfgfile );
|
||
|
_debug( $DEBUG >= 3, '$cfgfile: ' . $cfgfile );
|
||
|
|
||
|
#
|
||
|
# Query is an argument string or is in a file
|
||
|
#
|
||
|
if ($queryfile) {
|
||
|
die "Unable to find query file $queryfile\n" unless ( -e $queryfile );
|
||
|
$query = read_text($queryfile);
|
||
|
}
|
||
|
else {
|
||
|
$query = shift;
|
||
|
pod2usage( -msg => "Please specify a SQL query\n", -exitval => 1 )
|
||
|
unless $query;
|
||
|
}
|
||
|
_debug( $DEBUG >= 3, '$query: ' . Dumper(\$query) );
|
||
|
|
||
|
#
|
||
|
# Template is the default pre-defined string or a filename
|
||
|
#
|
||
|
if ($template) {
|
||
|
die "Unable to find template $template\n" unless ( -e $template );
|
||
|
}
|
||
|
else {
|
||
|
$template = \$def_template;
|
||
|
}
|
||
|
_debug(
|
||
|
$DEBUG >= 3,
|
||
|
'$template: '
|
||
|
. (ref($template) eq ''
|
||
|
? "filename $template"
|
||
|
: "reference to string\n$$template")
|
||
|
);
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
# Load database configuration data
|
||
|
#-------------------------------------------------------------------------------
|
||
|
my $conf = Config::General->new(
|
||
|
-ConfigFile => $cfgfile,
|
||
|
-InterPolateVars => 1,
|
||
|
-ExtendedAccess => 1
|
||
|
);
|
||
|
my %config = $conf->getall();
|
||
|
|
||
|
#-------------------------------------------------------------------------------
|
||
|
# 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 die $DBI::errstr;
|
||
|
|
||
|
#
|
||
|
# Enable client-side UTF8
|
||
|
#
|
||
|
$dbh->{mysql_enable_utf8} = 1;
|
||
|
|
||
|
#
|
||
|
# Set up the query
|
||
|
#
|
||
|
$sth1 = $dbh->prepare($query) or die $DBI::errstr;
|
||
|
if ( $dbh->err ) {
|
||
|
die $dbh->errstr;
|
||
|
}
|
||
|
|
||
|
#
|
||
|
# Perform the query
|
||
|
#
|
||
|
try {
|
||
|
$sth1->execute(@dbargs);
|
||
|
if ( $dbh->err ) {
|
||
|
die $dbh->errstr;
|
||
|
}
|
||
|
}
|
||
|
catch ($e) {
|
||
|
#
|
||
|
# The 'die' above was triggered. The error is in $_.
|
||
|
#
|
||
|
my $pcount = grep {/\?/} split( '', $query );
|
||
|
my $acount = scalar(@dbargs);
|
||
|
print STDERR "Failed to execute query.\n";
|
||
|
print STDERR "Placeholder/Argument mismatch: $pcount/$acount\n";
|
||
|
exit;
|
||
|
};
|
||
|
|
||
|
#
|
||
|
# Grab everything from the query as an arrayref of hashrefs
|
||
|
#
|
||
|
$result = $sth1->fetchall_arrayref( {} );
|
||
|
_debug( $DEBUG >= 3, '$result: ' . Dumper($result) );
|
||
|
|
||
|
#
|
||
|
# Collect field names
|
||
|
#
|
||
|
@names = @{$sth1->{NAME}};
|
||
|
_debug( $DEBUG >= 3, '@names: ' . Dumper(\@names) );
|
||
|
|
||
|
#
|
||
|
# Set up the template
|
||
|
#
|
||
|
my $tt = Template->new(
|
||
|
{ ABSOLUTE => 1,
|
||
|
ENCODING => 'utf8',
|
||
|
INCLUDE_PATH => $basedir,
|
||
|
}
|
||
|
);
|
||
|
|
||
|
#
|
||
|
# Send collected data to the template
|
||
|
#
|
||
|
my $vars = { names => \@names, result => $result, };
|
||
|
if (%defs) {
|
||
|
#
|
||
|
# If we have definitions add them to $vars
|
||
|
#
|
||
|
my $merge = Hash::Merge->new('LEFT_PRECEDENT');
|
||
|
my %merged = %{ $merge->merge( $vars, \%defs ) };
|
||
|
$vars = \%merged;
|
||
|
}
|
||
|
_debug( $DEBUG >= 3, '$vars: ' . Dumper($vars) );
|
||
|
|
||
|
$tt->process( $template, $vars, \$document, { binmode => ':utf8' } )
|
||
|
|| die $tt->error(), "\n";
|
||
|
print $document;
|
||
|
|
||
|
exit;
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: _debug
|
||
|
# PURPOSE: Prints debug reports
|
||
|
# PARAMETERS: $active Boolean: 1 for print, 0 for no print
|
||
|
# $message Message to print
|
||
|
# RETURNS: Nothing
|
||
|
# DESCRIPTION: Outputs a message if $active is true. It removes any trailing
|
||
|
# newline and then adds one in the 'print' to the caller doesn't
|
||
|
# have to bother. Prepends the message with 'D> ' to show it's
|
||
|
# a debug message.
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub _debug {
|
||
|
my ( $active, $message ) = @_;
|
||
|
|
||
|
chomp($message);
|
||
|
print STDERR "D> $message\n" if $active;
|
||
|
}
|
||
|
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: _dbargs
|
||
|
# PURPOSE: Collects database arguments for the main query
|
||
|
# PARAMETERS: $opts hash reference holding the options
|
||
|
# RETURNS: An array holding all of the arguments
|
||
|
# DESCRIPTION: If there are -dbargs options they will be an array in the hash
|
||
|
# returned by Getopt::Long. We return the array to the caller.
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO: N/A
|
||
|
#===============================================================================
|
||
|
sub _dbargs {
|
||
|
my ($opts) = @_;
|
||
|
|
||
|
my @args;
|
||
|
|
||
|
if ( defined( $opts->{dbargs} ) ) {
|
||
|
@args = @{ $opts->{dbargs} };
|
||
|
}
|
||
|
|
||
|
return (@args);
|
||
|
}
|
||
|
|
||
|
#=== FUNCTION ================================================================
|
||
|
# NAME: _define
|
||
|
# PURPOSE: Handles multiple instances of the same option '-define x=42'
|
||
|
# PARAMETERS: $opts hash reference holding the options
|
||
|
# RETURNS: A hash containing all of the named items (e.g. { 'x' => 42 })
|
||
|
# DESCRIPTION: If there are -define options they will be a hashref in the hash
|
||
|
# returned by Getopt::Long. We return the internal hash to the
|
||
|
# caller. Doesn't handle the issue that we don't want the keys
|
||
|
# 'names' and 'result', though perhaps it should.
|
||
|
# THROWS: No exceptions
|
||
|
# COMMENTS: None
|
||
|
# SEE ALSO:
|
||
|
#===============================================================================
|
||
|
sub _define {
|
||
|
my ($opts) = @_;
|
||
|
|
||
|
my %defs;
|
||
|
|
||
|
if ( defined( $opts->{define} ) ) {
|
||
|
%defs = %{ $opts->{define} };
|
||
|
}
|
||
|
|
||
|
return (%defs);
|
||
|
}
|
||
|
|
||
|
#=== 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", "doc", "debug=i", "config=s",
|
||
|
"query=s", "template=s", "dbargs=s@", "define=s%",
|
||
|
);
|
||
|
|
||
|
if ( !GetOptions( $optref, @options ) ) {
|
||
|
pod2usage( -msg => "Version $VERSION\n", -exitval => 1 );
|
||
|
}
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
__END__
|
||
|
|
||
|
#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||
|
# Application Documentation
|
||
|
#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||
|
#{{{
|
||
|
|
||
|
=head1 NAME
|
||
|
|
||
|
query2tt2 - A script for formatting a report from database query using a template
|
||
|
|
||
|
=head1 VERSION
|
||
|
|
||
|
This documentation refers to query2tt2 version 0.0.4
|
||
|
|
||
|
=head1 USAGE
|
||
|
|
||
|
query2tt2 [-help] [-debug=N] [-config=FILE] [-query=FILE]
|
||
|
[-template=FILE] [QUERY]
|
||
|
|
||
|
query2tt2 -help
|
||
|
|
||
|
query2tt2 -query=tag_query_580-589.sql
|
||
|
|
||
|
query2tt2 -config=.hpr_livedb.cfg -template=query2tt2_taglist.tpl \
|
||
|
'select id,summary,tags from eps where id between 580 AND 589 AND (length(summary) = 0 or length(tags) = 0) ORDER BY id'
|
||
|
|
||
|
query2tt2 -config=.hpr_livedb.cfg -query=hosts_showcount.sql \
|
||
|
-dbargs '2021-01-01' -dbargs '2021-12-31' \
|
||
|
-def year=2021 -template=~/HPR/Community_News/hosts_list.tpl
|
||
|
|
||
|
|
||
|
=head1 OPTIONS
|
||
|
|
||
|
=over 4
|
||
|
|
||
|
=item B<-help>
|
||
|
|
||
|
Prints a brief help message describing the usage of the program, and then exits.
|
||
|
|
||
|
=item B<-doc>
|
||
|
|
||
|
Displays the entirety of the documentation (using a pager), and then exits. To
|
||
|
generate a PDF version use:
|
||
|
|
||
|
pod2pdf query2tt2 --out=query2tt2.pdf
|
||
|
|
||
|
=item B<-debug=N>
|
||
|
|
||
|
Selects a level of debugging. Debug information consists of a line or series
|
||
|
of lines prefixed with the characters 'D>':
|
||
|
|
||
|
=over 4
|
||
|
|
||
|
=item B<0>
|
||
|
|
||
|
No debug output is generated: this is the default
|
||
|
|
||
|
=item B<3>
|
||
|
|
||
|
Prints all data structures from options or from the database
|
||
|
|
||
|
=back
|
||
|
|
||
|
(The debug levels need work!)
|
||
|
|
||
|
=item B<-config=FILE>
|
||
|
|
||
|
This option allows an alternative configuration file to be used. This file
|
||
|
defines the location of the database, its port, its name and the username and
|
||
|
password to be used to access it. This feature was added to allow the script
|
||
|
to access alternative databases or the live HPR database over an SSH tunnel.
|
||
|
|
||
|
See the CONFIGURATION AND ENVIRONMENT section below for the file format.
|
||
|
|
||
|
If the option is omitted the default file is used: B<.hpr_db.cfg>
|
||
|
|
||
|
=item B<-query=FILE>
|
||
|
|
||
|
The script needs an SQL query to be applied to the database. This may be
|
||
|
supplied as a file, in which case this option gives the name of the file.
|
||
|
|
||
|
Alternatively the query can be given as a delimited string on the command
|
||
|
line.
|
||
|
|
||
|
If neither method is used the script aborts with an error message.
|
||
|
|
||
|
=item B<-dbarg=ARG> [ B<-dbarg=ARG> ... ]
|
||
|
|
||
|
The query can have place holders ('?') in it and the corresponding values can
|
||
|
be passed to the script through the B<-dbarg=ARG> option. The option can be
|
||
|
repeated as many times as required and the order of B<ARG> values is
|
||
|
preserved.
|
||
|
|
||
|
=item B<-template=FILE>
|
||
|
|
||
|
The results of the query are fed to the Template Toolkit system for
|
||
|
reformatting. This option provides the name of the template definition file.
|
||
|
If this option is omitted then the script uses a very simple internal template
|
||
|
which is roughly equivalent to the effect in MySQL/MariaDB of ending a query
|
||
|
with I<\G>.
|
||
|
|
||
|
See below in the B<DESCRIPTION> section for the constraints imposed on the
|
||
|
contents of the template.
|
||
|
|
||
|
Output from the template is written to STDOUT.
|
||
|
|
||
|
=item B<-define KEY1=VALUE1> [ B<-define KEY2=VALUE2> ... B<-define KEYn=VALUEn> ]
|
||
|
|
||
|
The Template Toolkit (TT2) template may receive values from the command line
|
||
|
using this option. The argument to the B<-define> option is a B<key=value>
|
||
|
pair. Keys should be unique otherwise they will overwrite one another. The
|
||
|
keys will become TT2 variables and the values will be assigned to them.
|
||
|
|
||
|
=back
|
||
|
|
||
|
=head1 DESCRIPTION
|
||
|
|
||
|
The purpose of the script is to run a query against the HPR database (a local
|
||
|
copy or the live one on the server over an SSH tunnel). The database choice is
|
||
|
made via a configuration file. The default file points to the local database,
|
||
|
but the alternative (discussed later) accesses the live database.
|
||
|
|
||
|
The data returned from the query is then passed through a Template Toolkit
|
||
|
template so that it can be formatted. There are many ways in which this can be
|
||
|
done. A default template is built into the script which displays the data in
|
||
|
a very simple form.
|
||
|
|
||
|
A knowledge of the Template Toolkit package is required to write templates.
|
||
|
|
||
|
The template receives two data structures:
|
||
|
|
||
|
=over 4
|
||
|
|
||
|
=item B<names>
|
||
|
|
||
|
This is an array of the field (column) names used in the query in the order
|
||
|
they are referenced. This is to help with writing out fields in the same order
|
||
|
as the query, if this is required.
|
||
|
|
||
|
=item B<result>
|
||
|
|
||
|
This is an array of hashes returned from the query. Relational databases
|
||
|
return sets which are effectively tables or matrices of information. Perl
|
||
|
represents this structure as an array of hashes where each array element
|
||
|
corresponds to a row in the returned table, and each hash contains the fields
|
||
|
or columns. Perl does not guarantee hash key ordering, so the B<names> array
|
||
|
(above) is provided to ensure order is preserved.
|
||
|
|
||
|
=back
|
||
|
|
||
|
=head1 DIAGNOSTICS
|
||
|
|
||
|
=over 4
|
||
|
|
||
|
=item B<Unable to find configuration file ...>
|
||
|
|
||
|
The nominated (or default) configuration file could not be found.
|
||
|
|
||
|
=item B<Unable to find query file ...>
|
||
|
|
||
|
The nominated query file could not be found.
|
||
|
|
||
|
=item B<Couldn't open ...: ...>
|
||
|
|
||
|
The nominated query file could not be opened.
|
||
|
|
||
|
=item B<Unable to find template file ...>
|
||
|
|
||
|
The nominated template file could not be found.
|
||
|
|
||
|
=item B<various database errors>
|
||
|
|
||
|
An error has occurred while performing a database operation.
|
||
|
|
||
|
=item B<Failed to execure query.>
|
||
|
|
||
|
There is a mismatch between the number of placeholders in the query ('?'
|
||
|
characters) and the number of arguments provided through the B<-dbargs=ARG>
|
||
|
option. The script will attempt to analyse whether there are too many or too
|
||
|
few arguments
|
||
|
|
||
|
There is a mismatch between the number of placeholders in the query ('?'
|
||
|
characters) and the number of arguments provided through the B<-dbargs=ARG>
|
||
|
option. The script will attempt to analyse whether there are too many or too
|
||
|
few arguments
|
||
|
|
||
|
=item B<Template Toolkit error>
|
||
|
|
||
|
An error has occurred while processing the template.
|
||
|
|
||
|
=back
|
||
|
|
||
|
=head1 CONFIGURATION AND ENVIRONMENT
|
||
|
|
||
|
The script obtains the credentials it requires to open the MariaDB database
|
||
|
from a configuration file. The name of the file it expects is B<.hpr_db.cfg>
|
||
|
in the directory holding the script. This configuration file can be overridden
|
||
|
using the B<-config=FILE> option as described above.
|
||
|
|
||
|
The configuration file format is as follows:
|
||
|
|
||
|
<database>
|
||
|
host = 127.0.0.1
|
||
|
port = PORT
|
||
|
name = DATABASE
|
||
|
user = USERNAME
|
||
|
password = PASSWORD
|
||
|
</database>
|
||
|
|
||
|
=head1 DEPENDENCIES
|
||
|
|
||
|
Config::General
|
||
|
DBI
|
||
|
Data::Dumper
|
||
|
File::Slurper
|
||
|
Getopt::Long
|
||
|
Hash::Merge
|
||
|
Pod::Usage
|
||
|
Template
|
||
|
|
||
|
=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) 2021, 2022, 2024 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
|