File Coverage

blib/lib/MoneyWorks.pm
Criterion Covered Total %
statement 66 226 29.2
branch 6 92 6.5
condition 1 26 3.8
subroutine 22 45 48.8
pod 14 16 87.5
total 109 405 26.9


line stmt bran cond sub pod time code
1 1     1   1294 use 5.006;
  1         2  
2              
3             package MoneyWorks;
4              
5             our $VERSION = '0.10'; # Update MoneyWorks.pod, too.
6              
7             use #
8 1     1   4 strict; use #
  1         1  
  1         14  
9 1     1   3 warnings; no #
  1         7  
  1         26  
10 1     1   2 warnings qw 'utf8 parenthesis regexp once qw';
  1         1  
  1         32  
11 1     1   4 use warnings'register;
  1         1  
  1         87  
12              
13 1     1   3 use Carp 'croak';
  1         1  
  1         43  
14 1     1   2 use Exporter 5.57 'import';
  1         13  
  1         23  
15 1     1   397 use IPC::Open3;
  1         2834  
  1         41  
16 1     1   5 use Scalar::Util 'blessed';
  1         1  
  1         54  
17 1     1   3 use Symbol 'geniosym';
  1         2  
  1         59  
18              
19             our @EXPORT = qw( mw_cli_quote mw_str_quote);
20             our %EXPORT_TAGS = ( all => \@EXPORT );
21             BEGIN {
22 1     1   2 *IMPORT = \&import;
23 1         31 undef *import;
24             }
25              
26             our @BinPaths = (
27             '/Applications/MoneyWorks Gold.app/Contents/MacOS/MoneyWorks Gold',
28             '/usr/bin/moneyworks',
29             'C:/Program Files/MoneyWorks Gold/MoneyWorks Gold.exe',
30             );
31              
32             #our %Fields; # defined further down, to keep it out of the whey
33              
34 1     1   3 no constant 1.03 ();
  1         15  
  1         25  
35             use constant::lexical {
36             # publicly accessible fields
37 1         7 _rego => 0,
38             _user => 1,
39             _pswd => 2,
40             _file => 3,
41             _bina => 4,
42             _live => 5,
43              
44             # behind the scenes
45             _hndl => 6,
46             _prid => 7,
47 1     1   397 };
  1         1299  
48              
49             sub new {
50 1     1 0 12053 my ($class,%args) = @_;
51 1         1 my $self = [];
52 1         3 $self->[_rego] = delete $args{rego};
53 1         1 $self->[_user] = delete $args{user};
54 1         1 $self->[_pswd] = delete $args{password};
55 1         2 $self->[_file] = delete $args{file};
56              
57 1 50       3 unless( $self->[_bina] = delete $args{bin} ) {
58             # find the executable if we can
59 1         3 for(@BinPaths) {
60 3 50       91 -e and $self->[_bina] = $_, last;
61             }
62             }
63            
64 1 50       4 $self->[_live] = exists $args{keep_alive} ? delete $args{keep_alive} : 1;
65 1         5 bless $self, $class;;
66             }
67              
68 0     0 1 0 sub rego { unshift @_, _rego; goto &_accessor }
  0         0  
69 0     0 1 0 sub user { unshift @_, _user; goto &_accessor }
  0         0  
70 0     0 1 0 sub password { unshift @_, _pswd; goto &_accessor }
  0         0  
71 0     0 1 0 sub file { unshift @_, _file; goto &_accessor }
  0         0  
72 1     1 1 2 sub bin { unshift @_, _bina; goto &_accessor }
  1         3  
73 0     0 1 0 sub keep_alive{ unshift @_, _live; goto &_accessor }
  0         0  
74             sub _accessor {
75 1 50   1   7 @_ > 2 ? ( $_[1][$_[0]] = $_[2], $_[1]->close, $_[2] ) : $_[1][$_[0]]
76             }
77              
78             sub command {
79 0     0 1 0 my $self = shift;
80 0         0 my $command = shift;
81              
82 0 0       0 croak "Commands cannot contain line breaks" if $command =~ /[\r\n]/;
83 0 0       0 warnings'warnif(__PACKAGE__,"Command contains null chars")
84             if $command =~ y/\0//d;
85              
86             # Used by the SIGPIPE handle
87 0         0 my $tries;
88 0         0 MoneyWorks_COMMAND:
89              
90             my($rh,$wh,$maybe_open_file);
91 0         0 my $tmp; # For single-process mode: the stderr handle (which is not even
92             # used) needs to last till the end of the sub to avoid giving the
93             # child proc a SIGPIPE
94              
95 0         0 my $live = $self->[_live];
96 0 0       0 if($live) { # keep-alive
97             # fetch the handles, creating them if necessary
98 0   0     0 ($rh, $wh) = @{ $self->[_hndl] ||= do{
  0         0  
99             # start the process
100 0         0 my $pid = _open($self, my($wh,$rh), my $eh = geniosym);
101 0         0 $self->[_prid] = $pid;
102 0         0 ++$maybe_open_file;
103              
104             # return the handles
105 0         0 [$rh,$wh,$eh] # $eh is not used but we hang on to it to avoid SIGPIPING
106             } }; # the child process.
107             }
108             else { # single command (the easy way)
109 0         0 _open( $self, $wh, $rh, $tmp = geniosym );
110 0         0 ++$maybe_open_file;
111             }
112              
113 0         0 local $\ = "\n";
114 0         0 select +( select($wh), $|=1 )[0];
115              
116             # open a file if necessary
117 0 0 0     0 if($maybe_open_file and defined $self->[_file]) {
118             # avoid problems with files named -e
119 0         0 (my $file = $self->[_file]) =~ s|^-|./-|;
120            
121             # prepare the open file command
122 0         0 my $command = "open file=".mw_cli_quote($file);
123 0         0 my($u,$p) = @$self[_user,_pswd];
124 1     1   509 no warnings 'uninitialized';
  1         6  
  1         1189  
125 0 0 0     0 defined $u && length $u and
126             $command .= " login=".mw_cli_quote("$u:$p");
127              
128 0         0 my $retry;
129             local $SIG{PIPE} = sub {
130 0 0   0   0 $tries++ < 3 and $self->close, $retry = 1;
131 0         0 };
132             # send the command
133 0         0 print $wh $command;
134              
135             # See whether there was a SIGPIPE
136 0 0       0 goto MoneyWorks_COMMAND if $retry;
137              
138             # check result
139 0         0 my $headers = _read_headers($rh);
140 0 0       0 $$headers{Status} eq 'OK'||_croak($self,$headers);
141             }
142              
143             # send the command
144 0         0 print $wh $command;
145              
146             # parse output headers
147 0         0 my $headers = _read_headers($rh);
148              
149             # check status
150 0 0       0 $$headers{Status} eq 'OK' or _croak($self,$headers);
151              
152             # return data
153 0 0       0 if(exists $$headers{'Content-Length'}) { # omitted when the empty string
154 0         0 my $data; # is returned
155 0         0 read $rh, $data, $$headers{'Content-Length'};
156              
157 0         0 $data;
158             }
159 0         0 else { '' }
160             }
161              
162             my @bad_env_vars = qw(PATH IFS CDPATH ENV BASH_ENV);
163              
164             sub _open {
165 0     0   0 my $self = shift;
166              
167             # insanity check
168 0 0       0 defined $self->[_bina]
169             or croak "MoneyWorks could not be run: no path specified";
170              
171             # remove unsafe env vars temporarily
172 0 0       0 local(@ENV{@bad_env_vars}), delete @ENV{@bad_env_vars} if ${^TAINT};
173              
174 0         0 my $rego = $self->[_rego];
175 0 0       0 open3(@_, $self->[_bina], '-h', $rego ? ('-r', $rego) : ())
    0          
176             or croak "MoneyWorks ($self->[_bina]) could not be run: $!";
177             }
178              
179             # From: Rowan Daniell
180             # Subject: Re: Concerning HTTP-style output
181             # Date: Mon, 4 May 2009 09:02:46 +1200
182             #
183             # > The ‘-h’ mode output does not look exactly like HTTP to me :-). It
184             # > seems a lot simpler (which is good). Can I confirm with you that
185             # > the format is as follows? (I’m trying to make sure that my programs
186             # > don’t break in the future because I didn’t take all possibilities.
187             # > of the syntax into account.) Each header is a word followed by a
188             # > colon and a space (chr 32), and then the header’s value verbatim
189             # > (no escapes, quotes or line breaks as per HTTP) followed by a line
190             # > feed (chr 10). A blank line ("\n\n") indicates the end of the
191             # > header. Is this correct? And is the line break character the same
192             # > on both platforms?
193             #
194             # Yes. That is all correct.
195              
196             # Except that after that exchange I found that \r\n is the line break char.
197              
198             sub _read_headers {
199 0     0   0 my $handle = shift;
200 0         0 local $/ = "\r\n";
201              
202 0         0 my %headers;
203             my $past_first;
204 0         0 while(my $line = <$handle>) {
205 0 0       0 $line =~ s/\r\n\z//
206             or croak "Mangled output from MoneyWorks (no CRLF): $line";
207             # When run under root, MoneyWorks sometimes puts
208             # "Address already in use\n" (without the \r) at the beginning of
209             # its output.
210 0 0       0 $past_first++ or $line =~ s/^Address already in use\n//;
211 0 0       0 length $line or last;
212 0 0       0 $line =~ s/^([^:]+): // or croak "Mangled output from MoneyWorks: $line";
213 0         0 $headers{$1} = $line;
214             }
215 0         0 return \%headers;
216             }
217              
218             sub _croak { # Extracts error message from headers hash
219 0     0   0 my $self = shift;
220 0         0 my $h = shift;
221 0         0 my $msg;
222 0 0       0 if(exists $$h{Diagnostic}) {
223 0         0 ($msg = $$h{Diagnostic}) =~ s/^\[ERROR] //;
224 0 0       0 $msg .= ": " if exists $$h{Error};
225             }
226 0 0       0 $msg .= $$h{Error} if exists $$h{Error};
227 0         0 $self->close;
228 0         0 croak("Moneyworks error: $msg");
229             }
230              
231             sub version {
232 0     0 1 0 shift->command('version');
233             }
234              
235             sub eval {
236 0     0 1 0 my($self,$expr) = @_;
237 0         0 $expr =~ y/\r\n/ /;
238 0         0 shift->command('evaluate expr=' . mw_cli_quote($expr));
239             }
240              
241             sub import {
242 1     1   6 my($self,%args) = @_;
243 1 50 33     54 defined blessed $self and $self->isa(__PACKAGE__) or goto &IMPORT;
244              
245 0         0 my $data_arg;
246             my $map_arg;
247              
248 0 0       0 if(exists $args{map}) {
249 0 0       0 ($map_arg = delete $args{map}) =~ /[\r\n]/
250             and croak "Import map file names cannot contain line breaks";
251             # This is a MoneyWorks limitation. The syntax doesn’t allow it.
252 0         0 $map_arg = mw_cli_quote($map_arg);
253 0 0       0 if(exists $args{data_file}) {
254 0         0 $data_arg = 'file=' . mw_cli_quote(delete $args{data_file});
255             }
256             else {
257 0         0 my $data = delete $args{data};
258 0         0 $data =~ s/(?:\r\n?|\n)\z//;
259 0 0       0 if($data =~ /[\n\r]/) {
260             # write the data to a temporary file and use that
261 0         0 require File::Temp;
262 0         0 my($fh,$filename)
263             = File'Temp'tempfile(uc suffix => '.txt', uc unlink => 1);
264 0         0 local $\;
265 0         0 print $fh $data;
266 0 0       0 close $fh or croak "Couldn't close temp file for import: $!";
267 0         0 $data_arg = 'file=' . mw_cli_quote($filename);
268             }
269 0         0 else { $data_arg = 'data=' . mw_cli_quote($data) }
270             }
271             }
272             else {
273 0         0 croak "The map arg to import is not optional";
274              
275             =cut
276              
277             This may be added later. There are currently serious problems with it.
278              
279             # fetch the fields for the table
280             my $table = lc delete $args{table};
281             exists $Fields{$table} or croak "Unrecognised table: $table";
282             my $fields = $Fields{$table};
283              
284             # create a temporary file
285             require File::Temp;
286             my($fh,$filename)
287             = File'Temp'tempfile(uc suffix => '.txt', uc unlink => 1);
288              
289             # for each record
290             for( map +{%$_}, @{ delete $args{data} } ) { # copy each hash to avoid
291             my $line = ''; # modifying what belongs to the caller
292              
293             # add fields to $line
294             for my $f(@$fields) {
295             no warnings 'uninitialized';
296             (my $val = delete $_->{$f}) =~ /[\t\r\n]/
297             and croak "Field values cannot contain tabs or line breaks";
298             $line .= "$val\t";
299             }
300              
301             # croak if fields are left over
302             local $" = ' ';
303             %$_ and croak "Invalid fields: @{ keys %$_ }";
304              
305             # remove trailing tab
306             chop $line;
307              
308             # print to temp file
309             local $\ = "\n";
310             print $fh $line;
311             }
312              
313             close $fh or croak "Couldn't close temp file for import: $!";
314              
315             $data_arg = 'file=' . mw_cli_quote($filename);
316              
317             # find the map
318             my $f;
319             # I pilfered this code from Unicode::Collate (and
320             # modified it slightly).
321             for (@INC) {
322             $f = "$_/MoneyWorks/maps/$table.impo";
323             last if open $fh, $f;
324             $f = undef;
325             }
326             defined $f or
327             croak "MoneyWorks: Can't locate MoneyWorks/maps/$table.impo" .
328             " in \@INC (\@INC contains @INC).\n";
329             $map_arg = mw_cli_quote($f);
330              
331             =cut
332              
333             }
334              
335 0         0 my $ret = $self->command("import $data_arg map=$map_arg");
336 0 0       0 return unless defined wantarray;
337              
338 0         0 my %ret;
339 0         0 for(split /;\s*/, $ret) {
340 0         0 @_ = split /:\s*/;
341 0         0 $ret{$_[0]} = $_[1];
342             }
343 0         0 \%ret;
344             }
345              
346             my %all_fields;
347              
348             sub export {
349 0     0 1 0 my($self,%args) = @_;
350              
351             # determine what the rettype will be
352 0         0 my $using_hash = exists $args{key};
353 0         0 my $key = delete $args{key};
354              
355             # get the list of fields
356 0         0 my $table = delete $args{table};
357 0         0 my $qtable = mw_cli_quote($table);
358 0         0 my $fields = delete $args{fields};
359 0 0 0     0 defined $fields or $fields = $all_fields{lc $table} ||= [
360             split "\t", (
361             $self->command(
362             "export table=$qtable search='='"
363             ) =~ /([^\r\n]+)/
364             )[0]
365             ];
366              
367             # determine whether the key needs to be added to the list of fields
368 0         0 my $key_is_in_fields;
369 0 0       0 if($using_hash) {
370 0         0 for(@$fields) {
371 0 0       0 $_ eq $key and ++$key_is_in_fields, last;
372             }
373 0 0       0 $key_is_in_fields or push @$fields, $key;
374             }
375              
376             # prepare the command
377 0         0 my $command =
378             'export'
379             .' table=' . mw_cli_quote($table)
380             .' format=' . mw_cli_quote(
381             join('\t', map "[$_]", @$fields).'\n'
382             );
383             exists $args{search} and $command .=
384 0 0       0 ' search=' . mw_cli_quote(delete $args{search});
385              
386             # send the command
387 0         0 my $output = $self->command($command);
388              
389             # parse the output
390 0 0       0 my $ret = $using_hash
391             ? {}
392             : [];
393 0         0 for my $line(split /\n/, $output) {
394 0         0 my %record;
395 0         0 @record{ @$fields } = split /\t/, $line;
396             $using_hash
397 0 0       0 ? $$ret{$record{$key}} = \%record
398             : push @$ret, \%record;
399 0 0 0     0 delete $record{$key} if $using_hash && ! $key_is_in_fields;
400             }
401              
402             # return
403 0         0 $ret;
404             }
405              
406             # ~~~ report
407              
408 0     0 1 0 sub pid { shift->[_prid] }
409              
410             sub close {
411 1     1 1 1 my $self = shift;
412 1         2 my $pid = delete $$self[_prid];
413 1 50       47604 return unless my $handles = delete $$self[_hndl];
414 0 0 0     0 close $handles->[1]
415             or $! and croak "Error while terminating MoneyWorks: $!";
416 0         0 waitpid $pid, 0;
417             ()
418 0         0 }
419              
420             # ---------------- Ties ----------------- #
421              
422             sub tie {
423 0     0 0 0 tie my %h, 'MoneyWorks::_table_tie', @_;
424 0         0 \%h;
425             }
426              
427             sub TIEHASH {
428 0     0   0 my($package,%args) = @_;
429 0         0 my $table = delete $args{table};
430 0         0 my $key = delete $args{key};
431 0         0 my $self = $package->new(%args);
432 0         0 MoneyWorks::_table_tie->new($self, $table, $key);
433             }
434              
435             {
436             use constant::lexical {
437 1         5 parent => 0, cached => 1, table => 2, key => 3, row => 4
438 1     1   5 };
  1         1  
439              
440             sub MoneyWorks::_table_tie::new {
441 0     0   0 my($class,$parent,$table,$key) = @_;
442 0         0 return bless [$parent,undef,$table,$key], $class;
443             }
444             *MoneyWorks::_table_tie::TIEHASH = *MoneyWorks::_table_tie::new;
445             sub MoneyWorks::_table_tie::FETCH {
446 0     0   0 my($self,$row) = @_;
447 0 0       0 return unless $self->EXISTS($row);
448 0         0 CORE::tie
449             my %row, 'MoneyWorks::_row_tie', @$self[parent,table,key], $row;
450 0         0 \%row;
451             }
452             sub MoneyWorks::_table_tie::EXISTS {
453 0     0   0 my($self,$row) = @_;
454 0   0     0 $self->[parent]->command(
455             (
456             $self->[cached] ||=
457             'export'
458             .' table=' . MoneyWorks::mw_cli_quote($self->[table])
459             .' format="1"'
460             .' search='
461             ) . MoneyWorks::mw_cli_quote(
462             "Replace($self->[key],`\@`,`\1`)=Replace("
463             . MoneyWorks'mw_str_quote($row)
464             .",`\@`,`\1`)"
465             )
466             );
467             }
468              
469             sub MoneyWorks::_row_tie::TIEHASH {
470 0     0   0 my($class,$parent,$table,$key,$row) = @_;
471 0         0 return bless [$parent,undef,$table,$key,$row], $class;
472             }
473             sub MoneyWorks::_row_tie::FETCH {
474 0     0   0 my($self,$field) = @_;
475             $self->[parent]->eval(
476             'Find('
477             . mw_str_quote("$self->[table].$field") . ','
478             . (
479             $self->[cached]
480 0   0     0 ||= do {
481 0         0 (my $row = $$self[row]) =~ y/\@/\1/;
482 0         0 mw_str_quote(
483             "Replace($self->[key],`\@`,`\1`)=" . mw_str_quote($row)
484             )
485             }
486             )
487             .')'
488             );
489             }
490             }
491              
492             # ------------------ Functions ---------------- #
493              
494              
495             sub mw_cli_quote($) {
496 0     0 1 0 my $str = shift;
497 0 0       0 warnings'warnif
498             __PACKAGE__,"Argument to mw_cli_quote contains line breaks"
499             if $str =~ /[\r\n]/;
500 0         0 my $delim = chr 0x7f;
501 0         0 while(index $str, $delim, != -1) {
502 0 0       0 --vec $delim, 0, 8, == 31
503             and croak "Can't quote $str; no delimiters available"
504             }
505 0         0 "$delim$str$delim";
506             }
507              
508             {
509             my %escapes = (
510             '"' => '\"',
511             ' ' => '\t',
512             "\n" => '\n',
513             "\r" => '\r',
514             '\\' => '\\\\',
515             );
516             sub mw_str_quote($) {
517 0     0 1 0 my $str = shift;
518 0 0       0 if($str =~ /`/) {
519 0         0 $str =~ s/(["\t\n\r\\])/$escapes{$1}/g;
520 0         0 return qq/"$str"/;
521             }
522             else {
523 0         0 $str =~ s/([\t\n\r\\])/$escapes{$1}/g;
524 0         0 return "`$str`";
525             }
526             }
527             }
528              
529             # ------------ Misc stuff -------------- #
530              
531             sub DESTROY {
532 1     1   112 shift->close;
533             }
534              
535             =cut
536              
537             BEGIN {
538             %Fields = (
539             product => [qw/ Code Supplier SuppliersCode Description Comment Category1 Category2 Category3 Category4 SalesAcct StockAcct COGAcct SellUnit SellPrice SellPriceB SellPriceC SellPriceD SellPriceE SellPriceF QtyBrkSellPriceA1 QtyBrkSellPriceA2 QtyBrkSellPriceA3 QtyBrkSellPriceA4 QtyBrkSellPriceB1 QtyBrkSellPriceB2 QtyBrkSellPriceB3 QtyBrkSellPriceB4 QtyBreak1 QtyBreak2 QtyBreak3 QtyBreak4 BuyUnit BuyPrice ConversionFactor SellDiscount SellDiscountMode ReorderLevel Type Colour UserNum UserText Plussage BuyWeight StockTakeQty StockTakeValue StockTakeNewQty BarCode BuyPriceCurrency Custom1 Custom2 Custom3 Custom4 LeadTimeDays SellWeight Flags MinBuildQty NormalBuildQty /],
540             );
541             name => [qw/ Code Name Contact Position Address1 Address2 Address3 Address4 Delivery1 Delivery2 Delivery3 Delivery4 Phone Fax Category1 Category2 Category3 Category4 CustomerType SupplierType DebtorTerms CreditorTerms Bank AccountName BankBranch TheirRef CreditLimit Discount Comment RecAccount PayAccount Colour Salesperson TaxCode PostCode State BankAccountNumber PaymentMethod DDI eMail Mobile AfterHours Contact2 Position2 DDI2 eMail2 Mobile2 AfterHours2 WebURL ProductPricing SplitAcct1 SplitAcct2 SplitPercent Hold UserNum UserText CustPromptPaymentTerms CustPromptPaymentDiscount SuppPromptPaymentTerms SuppPromptPaymentDiscount
542             Currency CreditCardNum CreditCardExpiry CreditCardName TaxNumber Custom1 Custom2 Custom3 Custom4 DeliveryPostcode DeliveryState AddressCountry DeliveryCountry ReceiptMethod /],
543             }
544              
545             =cut
546              
547 1     1   982 !!*!*!!*!*!!*!*!!*!*!!*!*!!*!*!!*!*!!*!*!!*!*!!*!*!!*!*!!*!*!!*!*!!*!
  1         748  
  1         50