File Coverage

blib/lib/MoneyWorks.pm
Criterion Covered Total %
statement 71 231 30.7
branch 7 94 7.4
condition 1 26 3.8
subroutine 24 47 51.0
pod 14 16 87.5
total 117 414 28.2


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