#!/usr/bin/env perl
#===============================================================================
#
#         FILE: edit_host
#
#        USAGE: ./edit_host [-help] [-debug=N] [-[no]update] [-config=FILE]
#               [-[no]espeak_name] [-hostid=id] [-[no]regex] host_name
#
#  DESCRIPTION: A simple editor for the HPR hosts table
#
#      OPTIONS: ---
# REQUIREMENTS: ---
#         BUGS: ---
#        NOTES: DBI::MariaDB was here for a while then reverted to DBI::mysql.
#               Had to revert this script onn 2023-01-22.
#       AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com
#      VERSION: 0.0.7
#      CREATED: 2018-04-07 22:05:06
#     REVISION: 2023-01-22 14:06:48
#
#===============================================================================

use 5.010;
use strict;
use warnings;
use utf8;

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

use Config::General;
use File::Temp;
use File::Slurper qw{ read_text };
use SQL::Abstract;
use DBI;

use Data::Dumper;

#
# Version number (manually incremented)
#
our $VERSION = '0.0.7';

#
# 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";

#
# Declarations
#
my ( $dbh, $sth1, $h1, $rc );
my ( $host_name, %changes );

#
# 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'} );

#
# Collect options
#
my $DEBUG = ( $options{'debug'} ? $options{'debug'} : 0 );

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

my $updatedb = ( defined( $options{'updatedb'} ) ? $options{'updatedb'} : 0 );
my $regexp = ( defined( $options{'regexp'} ) ? $options{'regexp'} : 0 );

my $email   = ( defined( $options{'email'} )   ? $options{'email'}   : 0 );
my $profile = ( defined( $options{'profile'} ) ? $options{'profile'} : 0 );
my $espeak_name
    = ( defined( $options{'espeak_name'} ) ? $options{'espeak_name'} : 0 );

#
# There must be at least one field to change
#
die "Select one of -email, -profile and -espeak_name\n"
    unless ( $email || $profile || $espeak_name );

#
# Was a host id provided (through an option)?
#
my $hostid = $options{'hostid'};

#
# Deal with the two routes: one via the unique host id, and the other vai the
# less unique host name
#
unless ($hostid) {
    #
    # Get the arg
    #
    $host_name = shift;
    pod2usage( -msg => "Specify the host name\n", -exitval => 1 )
        unless $host_name;
}

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

#
# 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:database=$dbname;host=$dbhost;port=$dbport",
#    $dbuser, $dbpwd, { AutoCommit => 1 } )
#    or die $DBI::errstr;

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

#
# Prepare to read the database either for the hostid or the exact or
# approximate name
#
if ($hostid) {
    #
    # Simple hostid query
    #
    $sth1 = $dbh->prepare(q{SELECT * FROM hosts WHERE hostid = ?});
    $sth1->execute($hostid);
    if ( $dbh->err ) {
        die $dbh->errstr;
    }
}
else {
    #
    # Host name query
    #
    if ($regexp) {
        #
        # Regexp match requested. Count how many matches there are
        #
        $sth1 = $dbh->prepare(
            q{SELECT count(*) AS count FROM hosts WHERE host REGEXP ?});
        $sth1->execute($host_name);
        if ( $dbh->err ) {
            warn $dbh->errstr;
        }
        if ( $h1 = $sth1->fetchrow_hashref ) {
            die "Too many matches to regex $host_name\n"
                unless $h1->{count} == 1;
        }
        else {
            die "Unable to find host matching regex $host_name\n";
        }

        $sth1 = $dbh->prepare(q{SELECT * FROM hosts WHERE host REGEXP ?});
    }
    else {
        $sth1 = $dbh->prepare(q{SELECT * FROM hosts WHERE host = ?});
    }

    #
    # Execute the query
    #
    $sth1->execute($host_name);
    if ( $dbh->err ) {
        die $dbh->errstr;
    }
}

#
# Did we find it?
#
if ( $h1 = $sth1->fetchrow_hashref ) {
    #
    # Found. Save the hostid to simplify the update if we don't already have
    # it
    #
    $hostid //= $h1->{hostid};

    #
    # So what needs changing?
    #
    #<<< [perltidy messes up the following]
    if ($email) {
        $changes{email} = check_field( 'email',
            scalar( run_editor( $h1->{email}, ['+set paste'] ) ), 256, qr{(\n)} );
    }
    if ($profile) {
        $changes{profile} = run_editor( $h1->{profile}, ['+set paste'] );
    }
    if ($espeak_name) {
        $changes{espeak_name} = check_field( 'espeak_name',
            scalar( run_editor( $h1->{espeak_name} ) ), 256, qr{(\n)} );
    }
    #>>>
    print Dumper( \%changes ), "\n" if ( $DEBUG > 2 );

    #
    # Are we updating the database then?
    #
    if ($updatedb) {
        #
        # Was anything edited?
        #
        if (%changes) {
            #
            # Did the edits do anything? If not delete the element from the
            # hash since there's no point writing it to the database
            #
            for my $key ( keys(%changes) ) {
                if ( $changes{$key} eq $h1->{$key} ) {
                    print "No change made to $key, ignored\n";
                    delete( $changes{$key} );
                }
            }
            print Dumper( \%changes ), "\n" if ( $DEBUG > 2 );
        }

        #
        # If there's anything left apply the changes
        #
        if (%changes) {
            #
            # Go into transaction mode here so we can fail safely
            #
            $rc = $dbh->begin_work or die $dbh->errstr;

            my $sql = SQL::Abstract->new;
            my %where = ( hostid => $hostid );
            my ( $stmt, @bind ) = $sql->update( 'hosts', \%changes, \%where );
            #print "$stmt\n";
            #print join( ",", map {"'$_'"} @bind ), "\n";

            my $sth = $dbh->prepare($stmt);
            my $rv  = $sth->execute(@bind);

            #
            # Respond to any error by rolling back
            #
            if ( $dbh->err ) {
                warn $dbh->errstr;
                eval { $dbh->rollback };
                $rv = 0;
            }
            else {
                $dbh->commit;
            }
            $rv = 0 if ( $rv eq '0E0' );

            #
            # Update the log file
            #
            if ($rv) {
                print "Updated database\n";
            }
            else {
                print "Database not updated due to error\n";
            }

        }
        else {
            print "There was nothing to do\n";
        }
    }
    else {
        print "Database not updated\n";
    }
}
else {
    if ($hostid) {
        print "Unable to find host number $hostid\n";
    }
    else {
        print "Unable to find host name $host_name\n";
    }
}

exit;

#===  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: 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", "updatedb!", "config=s",
        "hostid=i", "regexp",  "email!",    "profile!",
        "espeak_name!",
    );

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

    return;
}

__END__


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

=head1 NAME

edit_host - edit one or more fields in the database for a given host

=head1 VERSION

This documentation refers to edit_host version 0.0.7


=head1 USAGE

    edit_host [-h] [-debug=N] [-[no]updatedb] [-hostid=ID] [-regexp]
    [-[no]email] [-[no]profile] [-[no]espeak_name] [-config=FILE] hostname

    edit_host -updatedb -espeak_name operat0r
    edit_host -updatedb -espeak_name -regexp oper
    edit_host -updatedb -email -espeak -host=225


=head1 REQUIRED ARGUMENTS

=over 4

=item B<hostname>

Unless the B<-hostid=ID> option is given (see the OPTIONS section) it is
necessary to provide a host name.

Unless the B<-regexp> option is provided (see the OPTIONS section) the
hostname must match exactly, otherwise it is regarded as a MySQL regular
expression.

=back

=head1 OPTIONS

=over 4

=item B<-[no]updatedb>

This option is required to make the script apply any changes that are made to
the database. By default no updates are applied (B<-noupdatedb>).

=item B<-regexp>

This option causes the B<hostname> argument to be interpreted as a regular
expression for the MySQL database. By default the argument is treated as if it
is an exact match.

=item B<-hostid=ID>

The host can be specified by the host ID through this route. If this used then
the B<hostname> argument is not required (and is ignored if given).

=item B<-[no]email>

This option, if given (as B<-email>), indicates that the 'email' field is to
be edited.  The Vim editor is invoked to make changes. The default is
B<-noemail> meaning that this field is not to be edited.

=item B<-[no]profile>

This option, if given (as B<-profile>), indicates that the 'profile' field is
to be edited.  The Vim editor is invoked to make changes. The default is
B<-noprofile> meaning that this field is not to be edited.

=item B<-[no]espeak_name>

This option, if given (as B<-espeak_name>), indicates that the 'espeak_name'
field is to be edited.  The Vim editor is invoked to make changes. The default
is B<-noespeak_name> meaning that this field is not to be edited.

=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 script B<edit_host> provides an editor interface to certain fields in
the B<hosts> table of the HPR database. The fields are:

=over 4

=item B<email>

A single line of up to 256 characters of text. The line is stored in a 'TEXT'
field but it makes no sense to make it too long even though an email address
can be arbitrarily long. The current maximum address length in the database is
44 characters.

=item B<profile>

A multi-line line of text of arbitrary length and content. The contents are
the host's profile in optional HTML format to be displayed on the page which
lists all of their contributions to HPR.

=item B<espeak_name>

A single line of up to 256 characters of text. The line is stored in a 'TEXT'
field but it makes no sense to make it too long. Its purpose is to provide the
'espeak' program with a form of the host name (or alias) which can be spoken
as the host requires. For example the host 'thelovebug' finds his name spoken
as "thel ove bug" and this can be corrected by storing 'TheLoveBug' in this
field.

=back

=head1 DIAGNOSTICS

=over 4

=item B<Select one of -email, -profile and -espeak_name>

At least one of these options is required. This a fatal error.

=item B<Specify the host name>

If no host name has been provided, and the B<-hostid=ID> option has not been
used the script is unable to determine the host to edit. This is a fatal
error.

=item B<Unable to find ...>

The configuration file containing details of the database cannot be found.
This is a fatal error.

=item B<DBI connect ... failed: Access denied for user ... >

The database connection has been denied. Check the configuration details (see
below). This a fatal error.

=item B<Too many matches to regex ...>

In B<-regex> mode a hostname has been provided that matches too many hosts in
the database. Try again with a less ambiguous name. This a fatal error.

=item B<Unable to find host matching regex ...>

In B<-regex> mode a hostname has been provided that matches no hosts in the
database. Try again. This a fatal error.

=item B<Edit failed>

If the Vim edit session fails in some way the script reports it this way. This
a fatal error.

=item B<Field '...' is too long (...); truncated to ...>

The string provided for the field is greater than the limit and has been
truncated. This is a warning.

=back

=head1 CONFIGURATION AND ENVIRONMENT

The script obtains the credentials it requires to open 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. To change this will require changing the script.

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
 File::Temp
 Getopt::Long
 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) 2018 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

