#!/usr/bin/env perl
#===============================================================================
#
#         FILE: fix_urls
#
#        USAGE: ./fix_urls [-help] [-doc] [-debug=N] [-dry-run] [-config=FILE]
#                       [-limit=N] tablename
#
#  DESCRIPTION: Scans the HPR database to find URLs which do not have the
#               'https:' scheme and correct them.
#
#      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.5
#      CREATED: 2021-12-29 13:57:28
#     REVISION: 2022-02-28 10:51:27
#
#===============================================================================

use v5.16;
use strict;
use warnings;
use utf8;
use feature qw{ postderef say signatures state };
no warnings qw{ experimental::postderef experimental::signatures };

use Getopt::Long;
use Pod::Usage;

use Config::General;
use DBI;

use SQL::Abstract;

use Log::Handler;
use Log::Handler::Output::File;

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/Database";
my $configfile = "$basedir/.hpr_db.cfg";
my $logfile    = "$basedir/${PROG}.log";

my $dbh;

                                #<<<  do not let perltidy change formatting
#
# The database tables we'll search and how to do what we need.
#
# main_key - the name of a table (with a hashref as the value)
#       index           the database field we're using to find a row
#       fields          fields we may need to edit (an arrayref)
#       logfields       fields we will log (an arrayref)
#       mainQ           the query that finds all rows needing work
#       rowQ            a query for finding a row using the index value
#
my %tables = (
    'comments' => {
        index     => 'id',
        fields    => [qw{comment_title comment_text}],
        logfields => [qw{eps_id comment_title}],
        mainQ     => q{
            select id
                from comments
                where comment_text regexp 'http://[^[:space:]]'
                    or comment_title regexp 'http://[^[:space:]]'
            order by id},
        rowQ => q{select * from comments where id = ?},
    },
    'eps' => {
        index     => 'id',
        fields    => [qw{title summary notes}],
        logfields => [qw{title date}],
        mainQ     => q{
            select id
                from eps
                where title regexp 'http://[^[:space:]]'
                    or summary regexp 'http://[^[:space:]]'
                    or notes regexp 'http://[^[:space:]]'
            order by id
            },
        rowQ => q{select * from eps where id = ?},
    },
    'hosts' => {
        index     => 'hostid',
        fields    => [qw{profile}],
        logfields => [qw{host email}],
        mainQ     => q{
            select hostid
                from hosts
                where profile regexp 'http://[^[:space:]]'
            order by hostid},
        rowQ => q{select * from hosts where hostid = ?},
    },
    'miniseries' => {
        index     => 'id',
        fields    => [qw{description}],
        logfields => [qw{name}],
        mainQ     => q{
            select id
                from miniseries
                where description regexp 'http://[^[:space:]]'
            order by id},
        rowQ => q{select * from miniseries where id = ?},
    },
);
                                #>>>
my @table_names = keys(%tables);

#
# Enable Unicode mode
#
binmode STDOUT, ":encoding(UTF-8)";
binmode STDERR, ":encoding(UTF-8)";

#-------------------------------------------------------------------------------
# Options and arguments
#-------------------------------------------------------------------------------
my $DEF_DEBUG = 0;
my $DEF_LIMIT = 0;

#
# Process options
#
my %options;
Options( \%options );

#
# Default help
#
pod2usage( -msg => "$PROG version $VERSION\n", -verbose => 0, -exitval => 1 )
    if ( $options{'help'} );

#
# Full documentation if requested with -doc
#
pod2usage(
    -msg       => "$PROG version $VERSION\n",
    -verbose   => 2,
    -exitval   => 1,
    -noperldoc => 0,
) if ( $options{'doc'} );

#
# Collect options
#
my $DEBUG = ( defined( $options{debug} ) ? $options{debug} : $DEF_DEBUG );

my $cfgfile
    = ( defined( $options{config} ) ? $options{config} : $configfile );

my $dry_run = ( defined( $options{'dry-run'} ) ? $options{'dry-run'} : 0 );
my $verbose = ( defined( $options{'verbose'} ) ? $options{'verbose'} : 0 );

my $limit = ( defined( $options{limit} ) ? $options{limit} : $DEF_LIMIT );
$limit = abs($limit);

#
# Sanity check
#
die "Unable to find config file '$cfgfile'\n" unless ( -e $cfgfile );

#
# Table choice
#
my $table = shift;
die "Database table not specified\n" unless $table;
die "Not a valid table name: $table\n"
    unless ( grep { $_ =~ /^$table$/ } @table_names );

#
# Load configuration data
#
my $conf = new Config::General(
    -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:MariaDB:host=$dbhost;port=$dbport;database=$dbname",
#    $dbuser, $dbpwd, { AutoCommit => 1, RaiseError => 1 } )
#    or die $DBI::errstr;

$dbh = DBI->connect( "dbi:mysql:host=$dbhost;port=$dbport;database=$dbname",
    $dbuser, $dbpwd, { AutoCommit => 1 } )
    or die $DBI::errstr;

#
# Enable client-side UTF8
#
$dbh->{mysql_enable_utf8} = 1;


#-------------------------------------------------------------------------------
# Set up logging keeping the default log layout except for the date
#-------------------------------------------------------------------------------
my $log = Log::Handler->new();

$log->add(
    file => {
        timeformat => "%Y-%m-%d %H:%M:%S",
        filename   => $logfile,
        maxlevel   => 7,
        minlevel   => 0,
        utf8       => 1,
    }
);

$log->info("Configuration file $cfgfile");
$log->info("Processing table $table");
$log->info("Dry-run mode") if ($dry_run);

process_table( $dbh, \%tables, $table, $dry_run, $limit );

exit;

#===  FUNCTION  ================================================================
#         NAME: process_table
#      PURPOSE: Processes a table to change any instances of 'http://' to
#               'https://'
#   PARAMETERS: $dbh            open database handle
#               $rtables        reference to the %tables hash
#               $table          name of the table being processed
#               $dry_run        Boolean showing whether this is dry run or not
#               $limit          number of updates to apply, 0 = no limit
#      RETURNS:
#  DESCRIPTION:
#       THROWS: No exceptions
#     COMMENTS: None
#     SEE ALSO: N/A
#===============================================================================
sub process_table {
    my ( $dbh, $rtables, $table, $dry_run, $limit ) = @_;

    my ( $sth1,       $sth2,  $h );
    my ( $work_query, @work,  $row_query, @fields, @logfields, $new, $index );
    my ( $sql,        $stmt,  @bind,      %fieldvals, %where );
    my ( $workcount,  $count, $updates,   $logfmt );

    #
    # Prepare to build SQL
    #
    $sql = SQL::Abstract->new;

    $count   = 0;
    $updates = 0;

    #
    # Find any rows in need of work as an array/list of the index values. The
    # selectall_arrayref returns a reference to an array containing arrayrefs,
    # so the 'map' flattens that structure.
    #
    $work_query = $rtables->{$table}->{mainQ};
    @work       = map { $_->[0] } @{ $dbh->selectall_arrayref($work_query) };

    $workcount = scalar(@work);
    printf "Number of rows requiring work: %d\n", $workcount if $verbose;

    _debug( $DEBUG >= 1, "Number of rows requiring work: $workcount" )
        unless $verbose;
    _debug( $DEBUG >= 2,
        "Rows requiring work: " . join( ",", @work ) . "\n" );

    #
    # If there's nothing to do say so and leave
    #
    unless (@work) {
        print "Nothing to do to table '$table'!\n";
        return;
    }

    #
    # Pull configuration values from the hash
    #
    $row_query = $rtables->{$table}->{rowQ};
    @fields    = @{ $rtables->{$table}->{fields} };
    @logfields = @{ $rtables->{$table}->{logfields} };
    $index     = $rtables->{$table}->{index};

    _debug( $DEBUG >= 3, "\$row_query = $row_query" );
    _debug( $DEBUG >= 3, "\@fields = " . join( ",", @fields ) );
    _debug( $DEBUG >= 3, "\$index = $index" );

    #
    # Prepare for logging by making a format string for sprintf
    #
    $logfmt = 'Updated row with ';
    $logfmt .= join( ", ", map {"$_ = '%s'"} $index, @logfields );

    _debug( $DEBUG >= 3, "\$logfmt = $logfmt" );

    #
    # Set up query for the next eligible row
    #
    $sth1 = $dbh->prepare($row_query) or die $DBI::errstr;
    if ( $dbh->err ) {
        die $dbh->errstr;
    }

    #
    # Loop through rows needing work
    #
    foreach my $pkey (@work) {
        #
        # The row is indexed by the per-table key
        #
        $sth1->execute($pkey) or die $DBI::errstr;
        if ( $dbh->err ) {
            die $dbh->errstr;
        }

        #
        # Grab the row (there must be only one with this index)
        #
        if ( $h = $sth1->fetchrow_hashref ) {
            #
            # Set up the 'where' options for SQL::Abstract
            #
            %where = ( $index => { '=', $pkey } );
            _debug( $DEBUG >= 3, Dumper( \%where ) );

            #
            # Work on the fields we know might contain HTML
            #
            for my $field (@fields) {
                #
                # Perform the change
                #
                ( $new = $h->{$field} ) =~ s{\bhttp://(\S)}{https://$1}ig;

                #
                # Set up SQL::Abstract parameters
                #
                $fieldvals{$field} = $new;
            }

            #
            # Use SQL::Abstract to make the statement and the bind parameters
            #
            ( $stmt, @bind ) = $sql->update( $table, \%fieldvals, \%where );

            #
            # Do the change or report it depending on dry-run mode
            #
            unless ($dry_run) {
                $sth2 = $dbh->prepare($stmt) or die $DBI::errstr;
                $sth2->execute(@bind)        or die $DBI::errstr;
                $log->info( sprintf( $logfmt, $pkey, @{$h}{@logfields} ) );
                printf $logfmt. "\n", $pkey, @{$h}{@logfields} if $verbose;
                $updates++;
            }
            else {
                print "No change made in dry-run mode\n";
                if ($verbose) {
                    print "SQL: $stmt\n";
                    print "Bind> ", join( "\nBind> ", @bind ), "\n";
                    print '-' x 80, "\n";
                }
            }
        }

        #
        # Apply the limit if appropriate
        #
        $count++;
        unless ( $limit == 0 ) {
            last if ( $count >= $limit );
        }

    }

    unless ($dry_run) {
        $log->info("Number of updates = $updates");
        if ($verbose) {
            print "Number of updates = $updates\n";
            printf "Remaining rows needing attention: %d\n",
                $workcount - $updates;
        }
    }

}

#===  FUNCTION  ================================================================
#         NAME: concat
#      PURPOSE: Reimplementation of join but with any undefined or empty
#               arguments removed
#   PARAMETERS: $sep            The string to be used to separate elements in
#                               the result
#               [variable args] Any number of arguments to be joined together
#                               with the separator
#      RETURNS: The concatenated arguments
#  DESCRIPTION: Giving 'join' an array that may contain undefined elements will
#               result in empty results in the output string and error
#               messages as the undefined elements are processed. Giving it
#               empty string elements will result in dangling separators in
#               the output. This routine removes the undefined and empty
#               elements before joining the rest.
#       THROWS: No exceptions
#     COMMENTS: None
#     SEE ALSO:
#===============================================================================
sub concat {
    my $sep = shift;

    my @args = grep { defined($_) && length($_) > 0 } @_;

    return join( $sep, @args );
}

#===  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 "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",     "doc",      "debug=i", "verbose!",
        "dry-run!", "config=s", "limit=i",
    );

    if ( !GetOptions( $optref, @options ) ) {
        pod2usage( -msg => "$PROG version $VERSION\n", -exitval => 1 );
    }

    return;
}

__END__

#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
#  Application Documentation
#%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
#{{{


=head1 NAME

fix_urls - Fixes 'http://' urls in the HPR database

=head1 VERSION

This documentation refers to fix_urls version 0.0.5


=head1 USAGE

    ./fix_urls [-help] [-doc] [-debug=N] [-[no]dry-run] [-[no]verbose]
        [-config=FILE] [-limit=N] tablename

    fix_urls -help
    fix_urls -doc

    fix_urls -limit=10 -dry-run comments
    fix_urls -limit=10 comments
    fix_urls -limit=10 -verbose comments

    fix_urls -config=.hpr_livedb.cfg -debug=1 -dry-run -limit=1 comments

=head1 REQUIRED ARGUMENTS

=over 4

=item B<tablename>

The mandatory argument required by the script is the name of the table to
process. The choices are:

    comments
    eps
    hosts
    miniseries

=back

=head1 OPTIONS

=over 8

=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 fix_urls --out=fix_urls.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<1>

Displays the number of updates required in a table.

=item B<2>

As for level 1, and also displays the primary key values of all rows requiring
work in the table.

=item B<3>

As for level 2, and also displays some internal values for verification.

=back

=item B<-[no]dry-run>

Controls whether the program runs in a mode where it performs database
updates. When enabled the details of the updates to be performed are shown,
otherwise the  updates are applied. The default B<-nodry-run> allows the
program to perform the changes.

=item B<-[no]verbose>

Normally very little is reported by the script, although details of which rows
have been changed are logged. When B<-verbose> is selected more information
about the number of rows needing work, the updates performed (or which would
have been performed) and how many changes were made is reported.

=item B<-limit=N>

This option allows the number of rows in the chosen table to be limited during
a B<-dry-run> pass or an update pass. If omitted, or if a value of zero is
given, then all eligible rows are processed.

=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 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>

=back

=head1 DESCRIPTION

The B<fix_urls> script performs edits on fields in tables in the HPR database.
As written, the purpose is to change all occurrences of 'http://' to
'https://', though it could be used for other tasks. It is not designed to be
easily changeable from one to another, but the code can be changed to do this
if needed.

A single table is processed in each run, and the number of rows may be limited
if required by using the B<-limit=N> option.

The eligible tables are defined in a hash structure B<%tables> which defines
the fields to be processed and the queries needed to search for all rows
requiring work and to get a particular row to work on. It also defines which
fields are to be reported in the log file.

A log file is appended to when the script is run, which has the name
B<fix_urls.log> in the same directory as the script.

=head1 DIAGNOSTICS

=over 4

=item B<Unable to find config file '...'>

Type: fatal

The configuration file in the B<-config=FILE> option cannot be found.

=item B<Database table not specified>

Type: fatal

The mandatory table name argument was not provided.

=item B<Not a valid table name: ...>

Type: fatal

The mandatory table name argument specified an unknown table name.

=item B<[DBI error messages]>

Type: fatal

Generated when a database interface error has been detected, such as failure
to connect to the database or failure to prepare or execute a query.

=back

=head1 CONFIGURATION AND ENVIRONMENT

The script obtains the credentials it requires to open a local copy of the HPR
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
    Getopt::Long
    Log::Handler
    Pod::Usage
    SQL::Abstract

=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-2020 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.

=cut

#}}}

# [zo to open fold, zc to close]

# vim: syntax=perl:ts=8:sw=4:et:ai:tw=78:fo=tcrqn21:fdm=marker

