Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
branch: master
Fetching contributors…

Cannot retrieve contributors at this time

executable file 924 lines (770 sloc) 22.463 kB
#!/usr/bin/perl
# TODO: syntax in XSLT to check required constants are SET
# License: GPL
# Copyright: University of Southampton 2011
# Author: Christopher Gutteridge; http://id.ecs.soton.ac.uk/person/1248
use FindBin;
use lib "$FindBin::Bin/../perl_lib";
use strict;
use warnings;
use Getopt::Long;
my %options = (
config=>[], set=>[], delineator=>[],
include=>[], process=>[], carry=>[],
noise=>1, in=>[], out=>undef,
xslt=>undef, format=>undef, worksheet=>undef,
"skip-rows"=>undef,"skip-cols"=>undef,
);
Getopt::Long::Configure("permute");
my $show_help;
my $show_version;
my $verbose;
my $quiet;
GetOptions(
'help|?' => \$show_help,
'version' => \$show_version,
'verbose+' => \$verbose,
'quiet' => \$quiet,
'in=s' => $options{"in"},
'out=s' => \$options{"out"},
'xslt=s' => \$options{"xslt"},
'format=s' => \$options{"format"},
'worksheet=i' => \$options{"worksheet"},
'skip-rows=i' => \$options{"skip-rows"},
'skip-cols=i' => \$options{"skip-cols"},
'config=s' => $options{"config"},
'include=s' => $options{"include"},
'set=s' => $options{"set"},
'delineator=s' => $options{"delineator"},
'carry=s' => $options{"carry"},
'process=s' => $options{"process"},
) || show_usage();
#use Data::Dumper;print Dumper( \%options );exit;
show_version() if $show_version;
show_help() if $show_help;
show_usage() if( scalar @ARGV != 0 );
$options{noise} = 0 if( $quiet );
$options{noise} = 1+$verbose if( $verbose );
my $grinder = new Grinder( %options );
$grinder->grind();
exit;
############################################################
sub show_version
{
print STDERR "Grinder: version XYZ\n";
exit;
}
sub show_usage
{
print STDERR <<END;
Usage: $0 [OPTION]... [--in filename|url] [--out filename] [--xslt filename]
Usage: $0 [OPTION]... [--config filename]
$0 --help for more information.
END
exit 1;
}
sub show_help
{
print STDERR <<END;
Usage: $0 [OPTION]... [--in filename] [--out filename] [--xslt filename]
Usage: $0 [OPTION]... [--config filename]
--config <filename> Load options from a config file, although command
line options override.
--in <filename|url> File to load spreadsheet from. Default "-" (stdin),
or if it starts with http: or https: then it is
treaded as a URL.
--format <format> Format of spreadsheet.
csv (default) - Comma separated values
tsv - Tab separated values
psv - Pipe character separated values (eg. biztalk)
excel - Excel document
excelx - Excel document
colon - Colon-separated (eg. passwd)
--worksheet <number> For multi sheet files, which sheet (default 1)
--skip-rows <number> Ignore the this number of rows before looking for the
headings.
--skip-cols <number> Ignore this number of columns on every line.
--out <filename> File to output result to. Default "-" (stdout)
--xslt <filename> If specified, run the XML generated throught this
XSLT transform and output that.
--set <x>=<y> Set a variable in the intermediate stage XML, sets
<set name='x'>y</set>. This overrides values set in
config file(s) but is overridden by values set
using *SET in the input data.
--delineator <x>=<y> Set a character <y> to use to split data in cells
in a column with heading <x>.
--carry <x> Set a column which should have the value carried
over to following rows if it is empty in those
rows.
--process <x>=<y>,... Add attributes to the XML cell with heading <x>.
attributes include "tag" and "md5" and "sha1",
"mbox_sha1sum", "ftag", "fname", "mediawiki".
You can use a * in the <x> section to match
zero or more characters.
--include <filename> Include the XML from <filename> at the top of the XML
output from the XSLT. Has no affect if xslt is not
set. Also assumes that the XSLT will add a
<!--TOP--> to the XML it produces that can be used
as the hook to insert the <filename> data.
--verbose Include more debug information. May be repeated for
even more information.
--quiet Supress even normal warnings (but not errors).
--help Show this help and exit.
--version Show the Grinder version and exit.
END
exit;
}
############################################################
############################################################
############################################################
package Grinder;
use strict;
use warnings;
# set = {} or [] or $
# delineator = {} or [] or $
# carry = {} or [] or $
# process = {} or [] or $
# include = [] or $
# in (default -)
# out (default -)
# xslt
# format (default csv)
# worksheet (default 1), excel & excelx formats only
# skip-rows
# skip-cols
# config = [] or $
# noise (default 1)
sub new
{
my( $class, %options ) = @_;
my $self = bless {
set => {},
delineator => {},
carry => {},
process => {},
include => [],
}, $class;
# normal options
foreach my $opt_key ( keys %options )
{
next if( $opt_key =~ m/^(set|delineator|process|carry)$/ );
if( $opt_key eq "include" )
{
if( ref( $options{$opt_key} ) eq "" )
{
push @{$self->{$opt_key}}, $options{$opt_key};
}
else
{
push @{$self->{$opt_key}}, @{$options{$opt_key}};
}
next;
}
$self->{$opt_key} = $options{$opt_key};
}
if( $options{config} )
{
foreach my $config_file ( @{$options{config}} )
{
my $config_fh = $self->open_input_file( $config_file, "UTF-8" );
my $line_no = 0;
while( my $line = readline( $config_fh ) )
{
$line_no++;
chomp $line;
next if( $line =~ m/^\s*$/ );
next if( $line =~ m/^\s*#/ );
if( $line =~ m/^\s*([^:]+):\s*(.*)$/ )
{
my( $left, $right ) = ( $1, $2 );
$right=~s/\s*$//;
if( $left =~ m/^(set|delineator|process|carry)$/ )
{
my( $id, $value ) = split( /=/, $right );
$value = 1 if !defined $value;
$value =~ s/<space>/ /g; #ugh!
$self->{$left}->{$id} = $value;
}
elsif( $left eq "include" || $left eq "in" )
{
push @{$self->{$left}}, $right;
}
elsif( !defined $options{$left} )
{
# second definition in a config over-writes
# but does not over-write passed-in option
$self->{$left} = $right;
}
next;
}
$self->message( "Syntax error in config file '$config_file' at line #$line_no.", 1 );
$self->message( "Line #$line_no: $line", 2 );
}
close( $config_fh );
}
}
# passed-in options overrides config for process type fields
foreach my $opt_key ( keys %options )
{
my $v = $options{$opt_key};
if( $opt_key =~ m/^(set|delineator|process|carry)$/ )
{
$v = [ $v ] if( ref( $v ) eq "" );
if( ref( $v ) eq "ARRAY" )
{
foreach my $touple ( @{$v} )
{
my( $id, $value ) = split( /=/, $touple );
$value = 1 if !defined $value;
$self->{$opt_key}->{$id} = $value;
}
}
else
{
foreach my $id ( keys %{$v} )
{
$self->{$opt_key}->{$id} = $v->{$id};
}
}
next;
}
}
$self->{format} = "csv" unless defined $self->{format};
$self->{noise} = 1 unless defined $self->{noise};
$self->{in} = "-" unless defined $self->{in};
$self->{out} = "-" unless defined $self->{out};
$self->{tmp_dir} = "/tmp" unless defined $self->{tmp_dir};
$self->{xslt_proc} = '/usr/bin/xsltproc @@XSL@@ @@XML@@' unless defined $self->{xslt_proc};
return $self;
}
sub open_input_file
{
my( $self, $filename, $charset ) = @_;
my $fh;
if( $filename eq "-" )
{
$fh = *STDIN;
binmode( $fh, ":encoding($charset)" );
}
else
{
open( $fh, "<:encoding($charset)", $filename ) || $self->error( "Failed to open '$filename' for reading: $!" );
}
$self->message( "Opened '$filename' for reading.", 2 );
return $fh;
}
sub open_output_file
{
my( $self, $filename ) = @_;
my $fh;
if( $filename eq "-" )
{
$fh = *STDOUT;
binmode( $fh, ":utf8" );
}
else
{
open( $fh, ">:utf8", $filename ) || $self->error( "Failed to open '$filename' for writing: $!" );
}
$self->message( "Opened '$filename' for writing.", 2 );
return $fh;
}
sub close_file
{
my( $self, $file_handle, $filename ) = @_;
if( !close( $file_handle ) )
{
$self->message( "Could not close a file handle on $filename: $!", 1 );
}
$self->message( "Closed '$filename'.", 2 );
}
sub unlink_file
{
my( $self, $filename ) = @_;
if( !unlink( $filename ) )
{
$self->message( "Could not unlink $filename: $!", 1 );
}
$self->message( "Unlinked '$filename'.", 2 );
}
sub debug
{
my( $self ) = @_;
use Data::Dumper;
print STDERR Dumper( $self );
}
sub error
{
my( $self, $msg ) = @_;
print STDERR "Grinder Error: $msg\n";
exit 1;
}
sub message
{
my( $self, $msg, $priority ) = @_;
$priority = 1 unless defined $priority;
if( $priority <= $self->{noise} )
{
print STDERR "$msg\n";
}
}
sub grind
{
my( $self ) = @_;
my $xml_file;
if( defined $self->{xslt} && $self->{xslt} ne "" )
{
$xml_file = $self->{tmp_dir}."/grinder.$$.xml";
}
else
{
$xml_file = $self->{out};
}
my $xml_out_fh = $self->open_output_file( $xml_file );
$self->{parse} = { rows=>0, set=>{} };
my @lt = localtime;
my @gmt = gmtime;
$lt[5]+=1900; $lt[4]+=1;
$gmt[5]+=1900; $gmt[4]+=1;
my $offset = ($lt[1]+$lt[2]*60)-($gmt[1]+$gmt[2]*60);
$self->{parse}->{set}->{_timestamp} = sprintf( '%04d-%02d-%02dT%02d:%02d:%02d%s%02d:%02d',
$lt[5],$lt[4],$lt[3],$lt[2],$lt[1],$lt[0], $offset<0?"":"+", $offset/60, $offset % 60);
foreach my $k ( keys %{ $self->{set} } ) { $self->{parse}->{set}->{$k} = $self->{set}->{$k}; }
my $ins = $self->{in};
if( ref($ins) ne "ARRAY" ) { $ins = [$ins]; }
foreach my $in ( @{$ins} )
{
if( $in =~ m/[; ]/ )
{
my @parts = split( /[ ;]/, $in );
$in = { file=>shift @parts };
foreach my $part ( @parts )
{
my( $k, $v ) = split( '=', $part );
$in->{$k} = $v;
}
}
}
my $xmlns = { ""=>"http://purl.org/openorg/grinder/ns/" };
foreach my $in ( @{$ins} )
{
if( ref( $in ) eq "HASH" )
{
if( defined $in->{namespace} )
{
$in->{prefix} = $in->{namespace};
$xmlns->{$in->{prefix}} = "http://purl.org/openorg/grinder/ns/".$in->{namespace}."/";
}
}
}
# Output Header
$self->send_xml_header( $xml_out_fh, $xmlns );
# could take extra options to over-ride current ones, esp in & out
foreach my $in ( @{$ins} )
{
my $in_fh;
my $in_file = $in;
my $format = $self->{format};
my $charset = "UTF-8";
my $worksheet = $self->{worksheet};
my $table_n = 0;
$self->{parse}->{"skip-rows"} = $self->{"skip-rows"};
$self->{parse}->{"skip-cols"} = $self->{"skip-cols"};
my $prefix = "";
if( ref( $in ) eq "HASH" )
{
$in_file = $in->{file};
$format = $in->{format} if( defined $in->{format} );
$prefix = $in->{prefix} if( defined $in->{prefix} );
$charset = $in->{charset} if( defined $in->{charset} );
$worksheet = $in->{worksheet} if( defined $in->{worksheet} );
$self->{parse}->{"skip-rows"} = $in->{"skip-rows"} if( defined $in->{"skip-rows"} );
$self->{parse}->{"skip-cols"} = $in->{"skip-cols"} if( defined $in->{"skip-cols"} );
}
$self->{parse}->{"filename"} = $in_file;
if( $in =~ m/^https?:/ )
{
$in_file = $self->{tmp_dir}."/grinder.in$$.xml";
if( ! eval 'use LWP::UserAgent; 1;' )
{
$self->error( "Failed to load Perl Module: LWP::UserAgent: $@" );
}
my $ua = LWP::UserAgent->new;
my $req = HTTP::Request->new( GET => $in );
my $res = $ua->request( $req );
if( !$res )
{
$self->error( "Failed to load URL '".$in_file."': ".$res->status_line );
}
my $in_file_out_fh = $self->open_output_file( $in_file ); # shake it all about
print { $in_file_out_fh } $res->content;
$self->close_file( $in_file_out_fh, $in_file );
}
$in_fh = $self->open_input_file( $in_file, $charset );
delete $self->{parse}->{fields};
delete $self->{parse}->{fields_names};
if( $format eq "excel" ) { $self->process_excel( $in_fh, $xml_out_fh, $prefix, $worksheet ); }
elsif( $format eq "excelx" ) { $self->process_excelx( $in_file, $xml_out_fh, $prefix, $worksheet ); }
elsif( $format eq "csv" ) { $self->process_csv( $in_fh, $xml_out_fh, $prefix ); }
elsif( $format eq "tsv" ) { $self->process_tsv( $in_fh, $xml_out_fh, $prefix ); }
elsif( $format eq "colon" ) { $self->process_colon( $in_fh, $xml_out_fh, $prefix ); }
elsif( $format eq "psv" ) { $self->process_psv( $in_fh, $xml_out_fh, $prefix ); }
else { $self->error( "Unknown format '$format'" ); }
$self->close_file( $in_fh, $in_file );
# delete tmp file if input was from a URL
if( $in =~ m/^https?:/ )
{
$self->unlink_file( $in_file );
}
}
# Output end of XML file
for my $set_id ( keys %{$self->{parse}->{set}} )
{
my $value = $self->{parse}->{set}->{$set_id};
$value =~ s/&/&amp;/g;
$value =~ s/>/&gt;/g;
$value =~ s/</&lt;/g;
$value =~ s/"/&quot;/g;
print { $xml_out_fh } " <set name='$set_id'>$value</set>\n";
}
$self->send_xml_footer( $xml_out_fh );
$self->close_file( $xml_out_fh, $xml_file );
# If no XSLT then we are done
if( !defined $self->{xslt} || $self->{xslt} eq "" )
{
exit;
}
# Read include files
my @lines = ();
foreach my $inc_file ( @{$self->{include}} )
{
my $inc_fh = $self->open_input_file( $inc_file, "UTF-8" );
while( my $line = readline( $inc_fh ) ) { push @lines, $line; }
$self->close_file( $inc_fh, $inc_file );
}
my $include = join( '', @lines );
# Process XSLT
my $cmd = $self->{xslt_proc};
$cmd =~ s/\@\@XML\@\@/$xml_file/g;
$cmd =~ s/\@\@XSL\@\@/$self->{xslt}/g;
my $xsltproc;
open( $xsltproc, "-|:utf8", $cmd ) || $self->error( "Failed to exec '$cmd': $!" );
$self->message( "Executed '$cmd' for writing.", 2 );
my $out_fh = $self->open_output_file( $self->{out} );
while( my $line = readline( $xsltproc ) )
{
$line =~ s/<!--TOP-->/$include/g;
print { $out_fh } $line;
}
$self->close_file( $out_fh, $self->{out} );
$self->close_file( $xsltproc, $cmd );
$self->unlink_file( $xml_file );
}
sub process_row
{
my( $self, $out, $cells, $prefix ) = @_;
if( defined $self->{parse}->{"skip-rows"} && $self->{parse}->{"skip-rows"} > 0 )
{
$self->{parse}->{"skip-rows"}--;
return;
}
if( defined $self->{parse}->{"skip-cols"} && $self->{parse}->{"skip-cols"} )
{
for( 1..$self->{parse}->{"skip-cols"} ) { shift @{$cells}; }
}
# Skip empty rows
my $empty = 1;
foreach my $cell ( @{$cells} )
{
if( defined $cell && $cell ne "" ) { $empty = 0; last; }
}
return if( $empty );
# *STAR directive fields
if( defined $cells->[0] && substr( $cells->[0],0,1 ) eq "*" )
{
if( $cells->[0] eq "*SET" )
{
$self->{parse}->{set}->{$cells->[1]} = $cells->[2];
}
elsif( $cells->[0] =~ m/^\*COMMENT/ )
{
;
}
else
{
$self->message( "Unknown * directive: ".$cells->[0] );
}
return;
}
# Read headings
if( !defined $self->{parse}->{fields} )
{
my $fields = {};
foreach my $cell ( @{$cells} )
{
if( !defined $cell )
{
push @{$self->{parse}->{fields}}, undef;
next;
}
$cell =~ s/^\s+//;
$cell =~ s/\s+$//;
my $name = $cell;
$cell =~ s/\s/-/g;
$cell =~ s/[^-_a-zA-Z0-9]//g;
$cell = lc $cell;
if( $cell eq "" )
{
push @{$self->{parse}->{fields}}, undef;
next;
}
$cell =~ s/^[^A-Z0-9]*//i;
$cell =~ s/--+/-/g;
if( $cell eq "" ) { $cell = "empty-heading"; }
if( $cell =~ m/^\d/ ) { $cell = "n".$cell; }
if( defined $fields->{$cell} )
{
my $n = 2;
while( defined $fields->{"$cell-$n"} ) { $n++; }
$cell = "$cell-$n";
}
push @{$self->{parse}->{fields}}, $cell;
$self->{parse}->{field_names}->{$cell} = $name;
$fields->{$cell} = 1;
}
return;
}
my $p = $prefix.":";
if( $prefix eq "" ) { $p = ""; }
my $filename = $self->{parse}->{filename};
$filename =~ s/&/&amp;/g;
$filename =~ s/>/&gt;/g;
$filename =~ s/</&lt;/g;
$filename =~ s/"/&quot;/g;
print { $out } " <${p}row filename='$filename'>\n";
my $this_row = {};
for my $i ( 0..(scalar @{$self->{parse}->{fields}} - 1 ) )
{
my $field = $self->{parse}->{fields}->[$i];
if( !defined $field ) { $field = "COL-".($i+1); }
my $value = $cells->[$i];
if( $self->{carry}->{$p.$field} && (!defined $value || $value =~ m/^\s*$/) )
{
$value = $self->{parse}->{prev_row}->{$p.$field};
}
$this_row->{$p.$field} = $value;
next if !defined $value;
$value =~ s/^\s+//;
$value =~ s/\s+$//;
my @values;
if( $self->{delineator}->{$p.$field} )
{
my $del = $self->{delineator}->{$p.$field};
@values = split( /\s*$del\s*/, $value );
}
else
{
@values = ( $value );
}
VALUE: foreach my $value ( @values )
{
my $attrs = "";
EXP: foreach my $key ( keys %{$self->{process}} )
{
my $exp = $key;
$exp =~ s/\*/\.*/g;
if( "$p$field" !~ m/^$exp$/ ) { next EXP; }
TYPE: foreach my $type ( split /,/, $self->{process}->{$key} )
{
if( $type eq "fname" )
{
my $tagname = $self->{parse}->{field_names}->{$field};
$attrs.=" fname='$tagname'";
next;
}
if( $type eq "ftag" )
{
my $tagname = $self->{parse}->{field_names}->{$field};
$tagname =~ s/\b([a-z])/\u$1/g;
$tagname =~ s/[^a-zA-Z0-9-_]//g;
$attrs.=" ftag='$tagname'";
next;
}
if( $value eq "" ) { next TYPE; }
if( $type eq "md5" )
{
use Digest::MD5 qw(md5 md5_hex md5_base64);;
use utf8;
my $bytes = $value;
utf8::encode($bytes);
$attrs.=" md5='".md5_hex($bytes)."'";
}
elsif( $type eq "sha1" )
{
use Digest::SHA1 qw(sha1_hex);
$attrs.=" sha1='".sha1_hex($value)."'";
}
elsif( $type eq "mbox_sha1sum" )
{
use Digest::SHA1 qw(sha1_hex);
$attrs.=" mbox_sha1sum='".sha1_hex("mailto:$value")."'";
}
elsif( $type eq "mediawiki" )
{
my $tagname = ucfirst $value;
$tagname =~ s/ /_/g;
$attrs.=" mediawiki='$tagname'";
}
elsif( $type eq "tag" )
{
my $tagname = $value;
$tagname =~ s/\b([a-z])/\u$1/g;
$tagname =~ s/[^a-zA-Z0-9-_]//g;
$attrs.=" tag='$tagname'";
}
else
{
die "Unknown process type: '$type'";
}
}
}
$value =~ s/&/&amp;/g;
$value =~ s/>/&gt;/g;
$value =~ s/</&lt;/g;
$value =~ s/"/&quot;/g;
print { $out } " <${p}$field$attrs>$value</${p}$field>\n";
}
$self->{parse}->{rows}++;
}
print { $out } " </${p}row>\n";
$self->{parse}->{prev_row} = $this_row;
}
sub send_xml_footer
{
my( $self, $out ) = @_;
print { $out } <<END;
</grinder-data>
END
}
sub send_xml_header
{
my( $self, $out, $xmlns ) = @_;
print { $out } <<END;
<?xml version="1.0" encoding='utf-8'?>
<grinder-data
END
foreach my $prefix ( keys %{$xmlns} )
{
my $attr = "xmlns:$prefix";
if( $prefix eq "" ) { $attr = "xmlns"; }
print { $out } " $attr=\"".$xmlns->{$prefix}."\"\n";
}
print { $out } ">\n";
}
sub process_excel
{
my( $self, $in, $out, $prefix, $worksheet_number ) = @_;
if( ! eval 'use Spreadsheet::ParseExcel; 1;' )
{
$self->error( "Failed to load Perl Module: Spreadsheet::ParseExcel: $@" );
}
my $parser = Spreadsheet::ParseExcel->new();
my $workbook = $parser->parse( $in );
if ( !defined $workbook )
{
$self->error( "Failed to parse Excel file: ".$parser->error() );
}
$self->process_excel_workbook_object( $workbook, $out, $prefix, $worksheet_number );
}
sub process_excelx
{
my( $self, $in_fn, $out, $prefix, $worksheet_number ) = @_;
if( ! eval 'use Spreadsheet::XLSX; 1;' )
{
$self->error( "Failed to load Perl Module: Spreadsheet::XLSX: $@" );
}
if( ! eval 'use Text::Iconv; 1;' )
{
$self->error( "Failed to load Perl Module: Text::Iconv: $@" );
}
my $converter = Text::Iconv -> new ("utf-8", "windows-1251");
no warnings;
my $workbook = Spreadsheet::XLSX->new( $in_fn, $converter );
use warnings;
if ( !defined $workbook )
{
$self->error( "Failed to parse ExcelX file $in_fn." );
}
$self->process_excel_workbook_object( $workbook, $out, $prefix, $worksheet_number );
}
sub process_excel_workbook_object
{
my( $self, $workbook, $out, $prefix, $worksheet_number ) = @_;
$worksheet_number = 1 unless defined $worksheet_number;
my @worksheets = $workbook->worksheets();
if( !defined $worksheets[$worksheet_number-1] )
{
$self->error( "Workbook does not have a worksheet #$worksheet_number" );
}
my $worksheet = $worksheets[$worksheet_number-1];
my ( $row_min, $row_max ) = $worksheet->row_range();
my ( $col_min, $col_max ) = $worksheet->col_range();
for my $row ( $row_min .. $row_max )
{
my @cells = ();
for my $col ( 0 .. $col_max )
{
my $cell = $worksheet->get_cell( $row, $col );
my $v;
if( $cell ) { $v = $cell->value; }
push @cells, $v;
}
$self->process_row( $out, \@cells, $prefix );
}
}
sub process_tsv
{
my( $self, $in, $out, $prefix ) = @_;
while( my $line = readline( $in ) )
{
chomp $line;
$self->process_row( $out, [ split /\t/, $line ], $prefix );
}
}
sub process_psv
{
my( $self, $in, $out, $prefix ) = @_;
while( my $line = readline( $in ) )
{
chomp $line;
$self->process_row( $out, [ split /\|/, $line ], $prefix );
}
}
sub process_colon
{
my( $self, $in, $out, $prefix ) = @_;
while( my $line = readline( $in ) )
{
chomp $line;
$self->process_row( $out, [ split /:/, $line ], $prefix );
}
}
sub process_csv
{
my( $self, $in, $out, $prefix ) = @_;
if( ! eval 'use Text::CSV; 1;' )
{
$self->error( "Failed to load Perl Module: Text::CSV" );
}
my $csv = Text::CSV->new ({ binary => 1});
while (my $row = $csv->getline( $in ))
{
if( !$row )
{
my $err = $csv->error_input;
$self->message( "Failed to parse line: $err", 1 );
next;
}
$self->process_row( $out, $row, $prefix );
}
}
Jump to Line
Something went wrong with that request. Please try again.