hpr-tools/Show_Submission/edit_shownotes

882 lines
29 KiB
Plaintext
Raw Permalink Normal View History

#!/usr/bin/env perl
#===============================================================================
#
# FILE: edit_shownotes
#
# USAGE: ./edit_shownotes [-help] [-debug=N] [-field=NAME] -episode=N
#
# DESCRIPTION: Perform edits on metadata in show notes
#
# OPTIONS: ---
# REQUIREMENTS: ---
# BUGS: ---
# NOTES: 2022-12-20: originally written to update the shownotes.txt
# file as well as shownotes.json. Now we are phasing out the
# former and using just the JSON, so the former file is not
# being edited.
# AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
# VERSION: 0.0.5
# CREATED: 2022-12-07 13:05:40
# REVISION: 2024-04-27 22:41:24
#
#===============================================================================
use v5.36;
use strict;
use warnings;
use utf8;
use feature qw{ postderef say signatures state try };
no warnings
qw{ experimental::postderef experimental::signatures experimental::try };
use Getopt::Long;
use Pod::Usage qw{pod2usage};
use Term::ANSIColor;
use File::Temp;
use File::Slurper qw{ read_text write_text };
use File::Copy;
use JSON;
use Log::Handler;
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/Show_Submission";
my $cache = "$basedir/shownotes";
my $logdir = "$basedir/logs";
my $logfile = "$logdir/${PROG}.log";
my ( $fh, $showno, $showid );
my ( $jsonfile, $jsonfile_bk, $json_text, $JSON_content );
my ( $before, $field_contents );
#my ( $txtfile, $txtfile_bk, $txtstring, @txtbuffer );
#
# Text colours
#
my $red = color('red');
my $green = color('green');
my $yellow = color('yellow');
my $magenta = color('magenta');
my $bold = color('bold');
my $reset = color('reset');
#
# How to find (and edit) the fields in shownotes.{txt,json}.
# The fields are 'tags', 'title' and 'summary'.
# If the JSON file is being edited the primary field offered to the editor is
# the first in the array (e.g. 'episode.Tags') and the other one simply echoes
# it.
# NOTE: 2022-12-20 - stopped modifying the shownotes.txt file
#
# If the TXT file is being updated (from the primary JSON version edited
# earlier) we find it in the array holding the text file using the 'regex'
# value, and build a new line using the 'label' value.
# This will be cleaned up when the TXT variant is dropped.
#
my %fields = (
'tags' => {
'json' => [ 'episode.Tags', 'metadata.POST.tags' ],
# 'txt' => { regex => qr{^Tags:}, label => 'Tags', },
},
'title' => {
'json' => [ 'episode.Title', 'metadata.POST.title' ],
# 'txt' => { regex => qr{^Title:}, label => 'Title', },
},
'summary' => {
'json' => [ 'episode.Summary', 'metadata.POST.summary' ],
# 'txt' => { regex => qr{^Summary:}, label => 'Summary', },
},
);
#
# Enable Unicode mode
#
binmode STDOUT, ":encoding(UTF-8)";
binmode STDERR, ":encoding(UTF-8)";
#-------------------------------------------------------------------------------
# Options and arguments
#-------------------------------------------------------------------------------
my $DEF_DEBUG = 0;
my $DEF_FIELD = 'tags';
#
# Process options
#
my %options;
Options( \%options );
#
# Default help
#
pod2usage(
-msg => "$PROG version $VERSION\n",
-verbose => 0,
-exitval => 1
) if ( $options{'help'} );
#
# Detailed help
#
pod2usage(
-msg => "$PROG version $VERSION\n",
-verbose => 2,
-exitval => 1,
) if ( $options{'documentation'} );
#
# Collect options
#
my $DEBUG = ( defined( $options{debug} ) ? $options{debug} : $DEF_DEBUG );
my $field = ( defined( $options{field} ) ? $options{field} : $DEF_FIELD );
my $test_dir = $options{test_dir};
$showno = $options{episode};
pod2usage(
-msg => "$PROG version $VERSION\nMissing mandatory option -episode\n",
-verbose => 0,
-exitval => 1
) unless $showno;
#
# Make an id in 'hpr1234' format
#
$showid = sprintf('hpr%04d',$showno);
#
# Check that the -field=FIELD option is valid
#
die "Invalid field specification: $field\n"
unless ($field =~ /^(tags|title|summary)$/);
#
# The files we'll parse and/or edit (and that we'll backup to). Check the
# former exist.
# If we've received a test directory we'll run in test mode
#
if ($test_dir) {
#
# We need a directory here so remove any file component.
#
die "Missing path $test_dir\n" unless ( -e $test_dir );
unless (-d $test_dir) {
#
# Trim a file path to what is likely to be a directory only, and test
# it exists. Add a trailing '/' if none.
#
$test_dir =~ s|/?[^/]*$||mx;
die "Missing directory $test_dir\n" unless ( -d $test_dir );
$test_dir .= '/' unless ($test_dir =~ qr{/$});
}
#
# Look in the test place
#
$jsonfile = "$test_dir/shownotes.json";
# $txtfile = "$test_dir/shownotes.txt";
$jsonfile_bk = "$test_dir/shownotes.json.orig";
# $txtfile_bk = "$test_dir/shownotes.txt.orig";
}
else {
#
# Look in the default place
#
$jsonfile = "$cache/$showid/shownotes.json";
# $txtfile = "$cache/$showid/shownotes.txt";
$jsonfile_bk = "$cache/$showid/shownotes.json.orig";
# $txtfile_bk = "$cache/$showid/shownotes.txt.orig";
}
die colored( "Unable to find JSON file $jsonfile", 'red' ) . "\n"
unless ( -e $jsonfile );
#die colored( "Unable to find text file $txtfile", 'red' ) . "\n"
# unless ( -e $txtfile );
#-------------------------------------------------------------------------------
# Set up logging keeping the default log layout except for the date. The format
# is "%T [%L] %m" where '%T' is the timestamp, '%L' is the log level and '%m is
# the message. We fiddle with this to implement a 'Test' mode.
#-------------------------------------------------------------------------------
my $log = Log::Handler->new();
$log->add(
file => {
timeformat => "%Y/%m/%d %H:%M:%S",
message_layout => (defined($test_dir) ? "%T [%L] TEST %m" : "%T [%L] %m"),
filename => $logfile,
minlevel => 0,
maxlevel => 7,
}
);
#-------------------------------------------------------------------------------
# Read the JSON input file and parse it into $JSON_content (hashref)
#-------------------------------------------------------------------------------
my $json = JSON->new->utf8;
open( $fh, '<:encoding(UTF-8)', $jsonfile );
$json_text = <$fh>;
close($fh);
#
# Careful! Bad JSON can crash the script here!
#
try {
$JSON_content = decode_json($json_text);
}
catch ($e) {
die colored( "Failed to decode the JSON in $jsonfile", 'red' ) . "\n"
}
$log->info( $showno, "Collecting $field from JSON" );
_debug( $DEBUG > 2, Dumper($JSON_content) );
#_debug( $DEBUG > 2, "\$field: $field" );
#_debug( $DEBUG > 2, Dumper(\%fields) );
#-------------------------------------------------------------------------------
# Get the contents of the primary JSON field. The group will all be the same,
# as will the text versions, so we edit the primary and propagate to the
# others.
#-------------------------------------------------------------------------------
$log->info( $showno,
"Collecting from JSON with path '$fields{$field}->{json}->[0]'" );
#
# Use the path in the %fields hash to get the prinmary node in the JSON hash.
# We are using 'get_JSON' rather than going to the field directly because
# we're hunting for different parts of the JSON structure depending on the
# -field=NAME option and using a jq-like path stored in %fields.
#
$field_contents = get_JSON( $JSON_content, $fields{$field}->{json}->[0] );
#say( defined($field_contents) ? $field_contents : 'undefined' );
die colored( "No contents in field '$field'. Can't continue", 'red' ) . "\n"
unless ( defined($field_contents) );
$log->info( $showno, "Contents of field: $field_contents" );
$before = $field_contents;
#
# Run the editor on what we collected
#
if ( $field eq 'tags' ) {
$field_contents
= check_field( $field, run_editor($field_contents), 200, qr{(\n)} );
}
elsif ( $field eq 'title' ) {
$field_contents
= check_field( $field, run_editor($field_contents), 100, qr{(\n)} );
}
elsif ( $field eq 'summary' ) {
$field_contents
= check_field( $field, run_editor($field_contents), 100, qr{(\n)} );
}
die colored( "Nothing was changed. Exiting", 'red' ) . "\n"
if ( $field_contents eq $before );
$log->info( $showno, "New contents of field: $field_contents" );
#-------------------------------------------------------------------------------
# Load the text version of the metadata
# NOTE: 2022-12-20 - stopped modifying the shownotes.txt file
#-------------------------------------------------------------------------------
#open( $fh, '<:encoding(UTF-8)', $txtfile );
#@txtbuffer = <$fh>;
#close($fh);
#-------------------------------------------------------------------------------
# Make the changes to all the relevant points in the two data structures.
# NOTE: 2022-12-20 - stopped modifying the shownotes.txt file
#-------------------------------------------------------------------------------
put_JSON( $JSON_content, $fields{$field}->{json}->[0], $field_contents );
put_JSON( $JSON_content, $fields{$field}->{json}->[1], $field_contents );
#_debug( $DEBUG > 1, Dumper($JSON_content) );
$log->info( $showno, "Updated JSON '$field' fields" );
#@txtbuffer = update_txt( \@txtbuffer, $fields{$field}->{txt}->{regex},
# $fields{$field}->{txt}->{label} . ":\t$field_contents\n" );
#_debug( $DEBUG > 1, "Text: " . Dumper( \@txtbuffer ) );
#$log->info( $showno, "Updated text '$field' fields" );
#-------------------------------------------------------------------------------
# Turn the data structures back into files, backing up the originals and
# writing the new.
#-------------------------------------------------------------------------------
unless ( -e $jsonfile_bk ) {
copy ($jsonfile, $jsonfile_bk) or die "Unable to backup $jsonfile\n";
$log->info( $showno, "JSON backup created" );
}
$json_text = encode_json($JSON_content);
write_text($jsonfile,$json_text);
$log->info( $showno, "JSON file updated" );
#unless ( -e $txtfile_bk ) {
# copy ($txtfile, $txtfile_bk) or die "Unable to backup $txtfile\n";
# $log->info( $showno, "Text backup created" );
#}
#$txtstring = join('',@txtbuffer);
#write_text($txtfile,$txtstring);
#
#$log->info( $showno, "Text file updated" );
exit;
#=== FUNCTION ================================================================
# NAME: get_JSON
# PURPOSE: Traverse a data structure using dotted notation and return the
# value at the end point
# PARAMETERS: $hash hashref to traverse
# $path a path in the form of '.key1.key2.[0]' which
# should walk to the key 'key1', then 'key2'
# within it, and open the array expected to be
# there and get element 0.
# RETURNS: The value found or undef
# DESCRIPTION: Uses 'traverse_JSON' to find the $path in the $hash, returning
# a pointer (reference) to it. This can then be de-referenced to
# return the item being referenced - unless it's undefined in
# which case that value is returned. Being undefined means that
# the path did not match the structure of the hash.
# THROWS: No exceptions
# COMMENTS: None
# SEE ALSO: N/A
#===============================================================================
sub get_JSON {
my ( $hash, $path ) = @_;
my $pointer = traverse_JSON( $hash, $path );
#
# If undef then need to return undef, not try and deref it!
#
if (defined($pointer)) {
return $$pointer;
}
return;
}
#=== FUNCTION ================================================================
# NAME: put_JSON
# PURPOSE: Traverse a data structure using dotted notation and update the
# value at the end point
# PARAMETERS: $hash hashref to traverse
# $path a path in the form of '.key1.key2.[0]' which
# should walk to the key 'key1', then 'key2'
# within it, and open the array expected to be
# there and rewrite element 0.
# $new_value the new value to place at the point in the
# hash
# RETURNS: The previous value if found, or undef
# DESCRIPTION: Uses 'traverse_JSON' to find the $path in the $hash, returning
# a pointer (reference) to it. This can then be de-referenced to
# return the item being referenced - unless it's undefined in
# which case that value is returned. Being undefined means that
# the path did not match the structure of the hash. Finally, if
# the reference is valid then it's used to save the contents of
# $new_value.
# THROWS: No exceptions
# COMMENTS: None
# SEE ALSO: N/A
#===============================================================================
sub put_JSON {
my ( $hash, $path, $new_value ) = @_;
my $pointer = traverse_JSON( $hash, $path );
my $old_value;
#
# If $pointer is defined then dereference it as the return value. Also,
# use it to write the new value.
#
if (defined($pointer)) {
$old_value = $$pointer;
$$pointer = $new_value;
}
return $old_value;
}
#=== FUNCTION ================================================================
# NAME: traverse_JSON
# PURPOSE: Traverse a data structure using dotted notation
# PARAMETERS: $hash hashref to traverse
# $path a path in the form of '.key1.key2.[0]' which
# should walk to the key 'key1', then 'key2'
# within it, and open the array expected to be
# there and return a pointer to element 0.
# RETURNS: A reference to the end point of the path, or undef if not
# found.
# DESCRIPTION: Given a path like '.metadata.POST.title' (where the leading
# '.' is optional because it's ignored), this is used to
# traverse a Perl version of a JSON data structure to return
# a *pointer* (reference) to a node.
#
# This is done by processing the path step by step and using
# 'name' elements as hash keys to get to the next level or
# return a terminal hash element. We also cater for '.[0]'
# format path elements which means to use the zeroth member of
# an array. Thus, if the tags were represented as an array rather
# than a scalar CSV string, we'd be able to use a path like:
# 'episode.Tags.[0]' to return the first tag.
#
# The data structure traversal is performed by using a pointer
# to a part of it, and since nested hashes are constructed by
# using hashrefs as hash element values, we get back references
# by default.
#
# Once the terminal node has been reached it may be a scalar
# (very likely in this application), so we make and return
# a reference to it so that the caller can use this reference in
# a standard way to return or change the contents.
#
# This function is meant to be more generic than this
# application requires. The JSON we're dealing with here doesn't
# have arrays (at the moment). This description is
# application-specifc however!
# THROWS: No exceptions
# COMMENTS: None
# SEE ALSO: N/A
#===============================================================================
sub traverse_JSON {
my ( $hash, $path ) = @_;
my ( $pointer, $index );
my @elements = split( '\.', $path );
#
# Because $hash is a hashref we can just copy it
#
$pointer = $hash;
#
# Traverse the hash using the path
#
foreach my $element (@elements) {
next unless defined($element);
#
# Plain names are keys or scalar end nodes, bracketed things are
# arrays (which can be scalars of course)
#
unless ( ($index) = ( $element =~ /\[([^]])\]/ ) ) {
# Hash key
if ( exists( $pointer->{$element} ) ) {
if ( ref( $pointer->{$element} ) eq '' ) {
#
# It's not a refererence so make a reference to it
#
$pointer = \$pointer->{$element};
}
else {
#
# It's a reference
#
$pointer = $pointer->{$element};
}
}
else {
#
# Doesn't exist!
#
return; #undef
}
}
else {
# Array
if ( exists( $pointer->[$index] ) ) {
if ( ref( $pointer->[$index] ) eq '' ) {
#
# It's not a refererence so make a reference to it
#
$pointer = \$pointer->[$index];
}
else {
#
# It's a reference
#
$pointer = $pointer->[$index];
}
}
else {
#
# Doesn't exist!
#
return; # undef
}
}
}
return $pointer;
}
#=== FUNCTION ================================================================
# NAME: update_txt
# PURPOSE: Update the data structure from 'shownotes.txt' in the light of
# requested changes.
# PARAMETERS: $array arrayref to the contents of the shownotes.txt
# file
# $regex regular expression to find the line that needs
# to be changed (there's just one main one).
# $new_value new value to replace the original line
# RETURNS: Modified array
# DESCRIPTION: Finds the element in the arrayref ($array) which matches the
# regular expression ($regex). When found it is replaced by the
# new value ($new_value). The modified buffer is returned as
# a list. Uses $result to determine the value of each line since
# it is not permitted to change the $_ variable in the 'map'
# statement.
# THROWS: No exceptions
# COMMENTS: None
# SEE ALSO: N/A
#===============================================================================
sub update_txt {
my ( $array, $regex, $new_value ) = @_;
my $result;
my @newbuffer
= map { $result = ( $_ =~ /$regex/ ? $new_value : $_ ); $result }
@$array;
return @newbuffer;
}
#=== FUNCTION ================================================================
# NAME: run_editor
# PURPOSE: Run an interactive vim editor on a string
# PARAMETERS: $string String to edit
# $options An arrayref containing options for vim
# (optional) Example '+set paste'. Each option
# (such as '-c startinsert') needs to be
# a separate array element.
# RETURNS: Edited string
# DESCRIPTION: Makes a temporary file with File::Temp ensuring that the file
# is in utf8 mode. Writes the edit string to the file and invokes
# the 'vim' editor on it. The resulting file is then read back
# into a string and returned to the caller, again taking care to
# retain utf8 mode.
# THROWS: No exceptions
# COMMENTS: File::Slurp and UTF-8 don't go well together. Moved to
# File::Slurper instead
# SEE ALSO: N/A
#===============================================================================
sub run_editor {
my ( $string, $options ) = @_;
#
# Build an arguments array for 'system'
#
my @args;
push( @args, @$options ) if $options;
#
# Make a temporary file
#
my $tfh = File::Temp->new;
binmode $tfh, ":encoding(UTF-8)";
my $tfn = $tfh->filename;
print $tfh $string if $string;
$tfh->close;
#
# Add the filename to the arguments
#
push( @args, $tfn );
die "Edit failed\n"
unless ( system( ( 'vim', @args ) ) == 0 );
return read_text($tfn);
}
#=== FUNCTION ================================================================
# NAME: check_field
# PURPOSE: Checks the a field is not too long and doesn't contain certain
# characters
# PARAMETERS: $name name of field
# $field string to be checked
# $maxlen maximum string length
# $regex regex containing illegal characters to be removed
# RETURNS: The input string truncated and with any illegal characters
# removed.
# DESCRIPTION: Runs a substitution on the string then truncates the result if
# it is too long.
# THROWS: No exceptions
# COMMENTS: None
# SEE ALSO: N/A
#===============================================================================
sub check_field {
my ( $name, $field, $maxlen, $regex ) = @_;
return unless $field;
$field =~ s/$regex//g;
if ( length($field) > $maxlen ) {
warn "Field '$name' too long ("
. length($field)
. "); truncated to "
. $maxlen . "\n";
$field = substr( $field, 0, $maxlen );
}
return $field;
}
#=== FUNCTION ================================================================
# NAME: coalesce
# PURPOSE: To find the first defined argument and return it
# PARAMETERS: Arbitrary number of arguments
# RETURNS: The first defined argument or undef if there are none
# DESCRIPTION:
# THROWS: No exceptions
# COMMENTS: None
# SEE ALSO: N/A
#===============================================================================
sub coalesce {
foreach (@_) {
return $_ if defined($_);
}
return undef; ## no critic
}
#=== FUNCTION ================================================================
# NAME: _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: 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|manpage",
"debug=i", "field=s",
"episode=i", "test_dir=s",
);
if ( !GetOptions( $optref, @options ) ) {
pod2usage(
-msg => "$PROG version $VERSION\n",
-verbose => 0,
-exitval => 1
);
}
return;
}
__END__
#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
# Application Documentation
#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
#{{{
=head1 NAME
edit_shownotes - edit JSON and text shownote metadata
=head1 VERSION
This documentation refers to edit_shownotes version 0.0.5
=head1 USAGE
./edit_shownotes [-help] [-documentation|manpage] [-debug=N]
[-field=FIELD] -episode=N
./edit_shownotes -field=tags -episode=3754
./edit_shownotes -field=summary -episode=3762 \
-test_dir=$PWD/tests/shownotes/hpr3762
=head1 OPTIONS
=over 8
=item B<-help>
Prints a brief help message describing the usage of the program, and then exits.
=item B<-manpage> or B<-documentation>
Prints the entire documentation for the script.
=item B<-field=FIELD>
This optional parameter defaults to B<-field=tags>. The only permitted fields
are 'tags', 'title' and 'summary'.
=item B<-episode=N>
This mandatory option specifies the number of the HPR episode that is being
parsed. It needs to be numeric even though it is often used with an 'hpr'
prefix internally. Leading zeroes are not required (when these are
appropriate).
=item B<-test_dir=PATH>
This option is for testing. Normally the script determines the path to the
file(s) it will parse and update. It actually finds the field specified by the
B<-field-FIELD> option in the JSON file B<shownotes.json> and calls the editor
to change this, then propagates any changes to the other JSON instance and the
one in B<shownotes.txt>.
This option has been provided to make it simpler to make copies of the files
relating to an incoming show and use them to test changes to the script.
The current workflow expects the "live" files in
B<$HOME/HPR/Show_Submission/shownotes/hprSHOW>, but there is another area
where show files are placed for testing:
B<$HOME/HPR/Show_Submission/tests/shownotes/hprSHOW>.
It is simply a matter of making a copy and of referencing the directory path
to this option. Changes will be made to this copy and log records written, but
these will contain the word 'TEST' to show that the script is being run in
this mode.
=item B<-debug=N>
Causes certain debugging information to be displayed.
0 (the default) no debug output
1 N/A
2 TBA
3 TBA
=back
=head1 DESCRIPTION
This script is meant to be a component in a workflow dealing with the notes
submitted by HPR hosts with the shows they are uploading.
The data from the web form which is used by hosts submitting shows is received
in two formats, with fixed names.
The file B<shownotes.txt> contains the form data in a label/value format, but
also uses PHP dumps to show some of the metadata. It is no longer used by the
current workflow, but is retained for historical reasons.
The alternative file format is JSON, which is stored in B<shownotes.json>.
This contains the same data as the other file but in a more standardised
format.
This script is designed to allow the editing of three specific fields in these
two files. This is because these fields are not used in the show note
preparation workflow (though they are referenced), but any changes can be
propagated back to the HPR server to allow workflows there to use the
corrected version(s).
The fields which can be edited are duplicated in the two files. One of these
is edited, and if appropriate, is propagated to the other duplicates. This
simplifies the editing of the text format, and the JSON format, which is not
simple to edit in a conventional way.
=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
A full explanation of any configuration system(s) used by the application,
including the names and locations of any configuration files, and the
meaning of any environment variables or properties that can be set. These
descriptions must also include details of any configuration language used
=head1 DEPENDENCIES
Data::Dumper
File::Slurper
File::Temp
Getopt::Long
JSON
Log::Handler
Pod::Usage
Term::ANSIColor
=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) 2022 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