#!/usr/bin/env perl #=============================================================================== # # FILE: edit_series # # USAGE: ./edit_series [-help] [-debug=N] [-[no]update] [-config=FILE] # [-series_id=id] [-[no]regex] [-[no]description] [-[no]private] # [-[no]valid] series_name # # DESCRIPTION: A simple editor for the HPR miniseries table # # OPTIONS: --- # REQUIREMENTS: --- # BUGS: --- # NOTES: Based on edit_host # AUTHOR: Dave Morriss (djm), Dave.Morriss@gmail.com # VERSION: 0.0.3 # CREATED: 2020-06-21 17:58:19 # REVISION: 2021-06-23 22:11:13 # #=============================================================================== 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.3'; # # 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 ( $series_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 $description = $options{'description'}; my $private = $options{'private'}; my $valid = $options{'valid'}; # # There must be at least one field to change. We check for the definition here # because the values returned may be zero or one or the variable may be # undefined. # die "Select one of -[no]description, -[no]private and -[no]valid\n" unless ( defined($description) || defined($private) || defined($valid) ); # # Was a series id provided (through an option)? # my $series_id = $options{'series_id'}; # # Deal with the two routes: one via the unique series id, and the other vai the # less unique series name # unless ($series_id) { # # Get the arg # $series_name = shift; pod2usage( -msg => "Specify the series name\n", -exitval => 1 ) unless $series_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}; # 2022-04-12 The MariaDB driver was there one minute and then it wasn't! # #$dbh = DBI->connect( "DBI:MariaDB:database=$dbname;host=$dbhost;port=$dbport", # $dbuser, $dbpwd, { AutoCommit => 1 } ) # or die $DBI::errstr; $dbh = DBI->connect( "dbi:mysql:database=$dbname;host=$dbhost;port=$dbport", $dbuser, $dbpwd, { AutoCommit => 1 } ) or die $DBI::errstr; # # Enable client-side UTF8 # $dbh->{mysql_enable_utf8} = 1; # # Prepare to read the database either for the series_id or the exact or # approximate name # if ($series_id) { # # Simple series_id query # $sth1 = $dbh->prepare(q{SELECT * FROM miniseries WHERE id = ?}); $sth1->execute($series_id); if ( $dbh->err ) { warn $dbh->errstr; } } else { # # Series name query # if ($regexp) { # # Regexp match requested. Count how many matches there are # $sth1 = $dbh->prepare( q{SELECT count(*) AS count FROM miniseries WHERE name REGEXP ?}); $sth1->execute($series_name); if ( $dbh->err ) { warn $dbh->errstr; } if ( $h1 = $sth1->fetchrow_hashref ) { my $matches = $h1->{count}; if ($matches > 1) { die "Too many matches to regex '$series_name' ($matches)\n"; } elsif ($matches == 0) { die "No matches to regex '$series_name'\n"; } } else { die "Unable to find series matching regex '$series_name'\n"; } $sth1 = $dbh->prepare(q{SELECT * FROM miniseries WHERE name REGEXP ?}); } else { $sth1 = $dbh->prepare(q{SELECT * FROM miniseries WHERE name = ?}); } # # Execute the query # $sth1->execute($series_name); if ( $dbh->err ) { warn $dbh->errstr; } } # # Did we find it? # if ( $h1 = $sth1->fetchrow_hashref ) { # # Found. Save the series_id to simplify the update if we don't already have # it # $series_id //= $h1->{id}; # # Report on the series details # printf "Series details\n" . "Id: %s\n" . "Name: %s\n" . "Description: %s\n" . "Private: %s\n" . "Image: '%s'\n" . "Valid: %s\n", $h1->{id}, $h1->{name}, ( length( $h1->{description} ) > 80 ? substr( $h1->{description}, 0, 80 ) . '...' : $h1->{description} ), $h1->{private}, $h1->{image}, $h1->{valid}; # # So what needs changing? # #<<< [perltidy messes up the following] if ($description) { $changes{description} = check_field( 'description', scalar( run_editor( $h1->{description}, ['+set paste'] ) ), 1500, qr{(\n)} ); } if (defined($private)) { $changes{private} = $private if ($h1->{private} ne $private); } if (defined($valid)) { $changes{valid} = $valid if ($h1->{valid} ne $valid); } #>>> 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 = ( id => $series_id ); my ( $stmt, @bind ) = $sql->update( 'miniseries', \%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"; print "Changed fields: ", join( ", ", sort( keys(%changes) ) ), "\n"; } else { print "Series not updated due to error\n"; } } else { print "There was nothing to do\n"; } } else { print "Database not updated\n"; } } else { if ($series_id) { print "Unable to find series number $series_id\n"; } else { print "Unable to find series name $series_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", "series_id=i", "regexp", "description!", "private!", "valid!", ); if ( !GetOptions( $optref, @options ) ) { pod2usage( -msg => "Version $VERSION\n", -exitval => 1 ); } return; } __END__ #%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% # Application Documentation #%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% #{{{ =head1 NAME edit_series - edit one or more fields in the database for a given series =head1 VERSION This documentation refers to edit_series version 0.0.3 =head1 USAGE edit_series [-h] [-debug=N] [-[no]updatedb] [-series_id=ID] [-regexp] [-[no]description] [-[no]private] [-[no]valid] [-config=FILE] series_name edit_series -updatedb -description GIMP edit_series -updatedb -description -regexp Awk edit_series -updatedb -noprivate -valid -series_id=102 =head1 REQUIRED ARGUMENTS =over 4 =item B<series_name> Unless the B<-series_id=ID> option is given (see the OPTIONS section) it is necessary to provide a series name. Unless the B<-regexp> option is provided (see the OPTIONS section) the series name 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<series_name> 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<-series_id=ID> The series can be specified by the series ID through this route. If this used then the B<series_name> argument is not required (and is ignored if given). =item B<-[no]description> This option, if given (as B<-description>), indicates that the 'description' field is to be edited. The Vim editor is invoked to make changes. The default is B<-nodescription> meaning that this field is not to be edited. =item B<-[no]private> This option, if given (as B<-private>), indicates that the 'private' field is to be set to 'true'. If given as B<-noprivate> this field is set to 'false'. If omitted altogether then the field is not changed. =item B<-[no]valid> This option, if given (as B<-valid>), indicates that the 'valid' field is to be set to 'true'. If given as B<-novalid> this field is set to 'false'. If omitted altogether then the field is not changed. =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_series> provides an editor interface to certain fields in the B<miniseries> table of the HPR database. The fields are: =over 4 =item B<description> This field is stored in a 'TEXT' field in the database. It is possible for the field to contain HTML, and some series do. In general it is better to keep this field short since it is displayed in its entirety before the notes for each show in the series. =back =head1 DIAGNOSTICS =over 4 =item B<Select one of -description, -private and -valid> At least one of these options is required. This a fatal error. =item B<Specify the series name> If no series name has been provided, and the B<-series_id=ID> option has not been used the script is unable to determine the series 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 series name has been provided that matches too many series in the database. Try again with a less ambiguous name. This a fatal error. =item B<Unable to find series matching regex ...> In B<-regex> mode a series name has been provided that matches no qseries 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) 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. 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