source: main/trunk/greenstone2/perllib/plugins/DirectoryPlugin.pm@ 21586

Last change on this file since 21586 was 21586, checked in by mdewsnip, 14 years ago

Changed DirectoryPlugin to use the infodbtype value from the arcinfo object in the processor, instead of being hard-wired to use GDBM. Part of making the code less GDBM-specific.

  • Property svn:executable set to *
  • Property svn:keywords set to Author Date Id Revision
File size: 20.7 KB
Line 
1###########################################################################
2#
3# DirectoryPlugin.pm --
4# A component of the Greenstone digital library software
5# from the New Zealand Digital Library Project at the
6# University of Waikato, New Zealand.
7#
8# Copyright (C) 1999 New Zealand Digital Library Project
9#
10# This program is free software; you can redistribute it and/or modify
11# it under the terms of the GNU General Public License as published by
12# the Free Software Foundation; either version 2 of the License, or
13# (at your option) any later version.
14#
15# This program is distributed in the hope that it will be useful,
16# but WITHOUT ANY WARRANTY; without even the implied warranty of
17# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18# GNU General Public License for more details.
19#
20# You should have received a copy of the GNU General Public License
21# along with this program; if not, write to the Free Software
22# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
23#
24###########################################################################
25
26# DirectoryPlugin is a plugin which recurses through directories processing
27# each file it finds - which basically means passing it down the plugin
28# pipeline
29
30package DirectoryPlugin;
31
32use PrintInfo;
33use plugin;
34use util;
35use metadatautil;
36
37use File::Basename;
38use strict;
39no strict 'refs';
40no strict 'subs';
41
42use Encode;
43
44BEGIN {
45 @DirectoryPlugin::ISA = ('PrintInfo');
46}
47
48my $arguments =
49 [ { 'name' => "block_exp",
50 'desc' => "{BasePlugin.block_exp}",
51 'type' => "regexp",
52 'deft' => &get_default_block_exp(),
53 'reqd' => "no" },
54 # this option has been deprecated. leave it here for now so we can warn people not to use it
55 { 'name' => "use_metadata_files",
56 'desc' => "{DirectoryPlugin.use_metadata_files}",
57 'type' => "flag",
58 'reqd' => "no",
59 'hiddengli' => "yes" },
60 { 'name' => "recheck_directories",
61 'desc' => "{DirectoryPlugin.recheck_directories}",
62 'type' => "flag",
63 'reqd' => "no" } ];
64
65my $options = { 'name' => "DirectoryPlugin",
66 'desc' => "{DirectoryPlugin.desc}",
67 'abstract' => "no",
68 'inherits' => "yes",
69 'args' => $arguments };
70
71sub new {
72 my ($class) = shift (@_);
73 my ($pluginlist,$inputargs,$hashArgOptLists) = @_;
74 push(@$pluginlist, $class);
75
76 push(@{$hashArgOptLists->{"ArgList"}},@{$arguments});
77 push(@{$hashArgOptLists->{"OptList"}},$options);
78
79 my $self = new PrintInfo($pluginlist, $inputargs, $hashArgOptLists);
80
81 if ($self->{'info_only'}) {
82 # don't worry about any options or initialisations etc
83 return bless $self, $class;
84 }
85
86 # we have left this option in so we can warn people who are still using it
87 if ($self->{'use_metadata_files'}) {
88 die "ERROR: DirectoryPlugin -use_metadata_files option has been deprecated. Please remove the option and add MetadataXMLPlug to your plugin list instead!\n";
89 }
90
91 $self->{'num_processed'} = 0;
92 $self->{'num_not_processed'} = 0;
93 $self->{'num_blocked'} = 0;
94 $self->{'num_archives'} = 0;
95
96 $self->{'subdir_extrametakeys'} = {};
97
98 return bless $self, $class;
99}
100
101# called once, at the start of processing
102sub init {
103 my $self = shift (@_);
104 my ($verbosity, $outhandle, $failhandle) = @_;
105
106 # verbosity is passed through from the processor
107 $self->{'verbosity'} = $verbosity;
108
109 # as are the outhandle and failhandle
110 $self->{'outhandle'} = $outhandle if defined $outhandle;
111 $self->{'failhandle'} = $failhandle;
112
113}
114
115# called once, after all passes have finished
116sub deinit {
117 my ($self) = @_;
118
119}
120
121# called at the beginning of each plugin pass (import has one, building has many)
122sub begin {
123 my $self = shift (@_);
124 my ($pluginfo, $base_dir, $processor, $maxdocs) = @_;
125
126 # Only lookup timestamp info for import.pl, and only if incremental is set
127 my $proc_package_name = ref $processor;
128 if ($proc_package_name !~ /buildproc$/ && $self->{'incremental'} == 1) {
129 # Get the infodbtype value for this collection from the arcinfo object
130 my $infodbtype = $processor->getoutputinfo()->{'infodbtype'};
131 my $output_dir = $processor->getoutputdir();
132## my $archives_inf = &util::filename_cat($output_dir,"archives.inf");
133 my $archives_inf = &dbutil::get_infodb_file_path($infodbtype, "archiveinf-doc", $output_dir);
134
135 if ( -e $archives_inf ) {
136 $self->{'inf_timestamp'} = -M $archives_inf;
137 }
138 }
139}
140
141sub remove_all {
142 my $self = shift (@_);
143 my ($pluginfo, $base_dir, $processor, $maxdocs) = @_;
144
145}
146
147
148sub remove_one {
149 my $self = shift (@_);
150 my ($file, $oids, $archivedir) = @_;
151 return undef; # this will never be called for directories (will it??)
152
153}
154
155
156# called at the end of each plugin pass
157sub end {
158 my ($self) = shift (@_);
159
160}
161
162
163
164# return 1 if this class might recurse using $pluginfo
165sub is_recursive {
166 my $self = shift (@_);
167
168 return 1;
169}
170
171sub get_default_block_exp {
172 my $self = shift (@_);
173
174 return '(?i)(CVS|\.svn|Thumbs\.db|OIDcount|~)$';
175}
176
177sub check_directory_path {
178
179 my $self = shift(@_);
180 my ($dirname) = @_;
181
182 return undef unless (-d $dirname);
183
184 return 0 if ($self->{'block_exp'} ne "" && $dirname =~ /$self->{'block_exp'}/);
185
186 my $outhandle = $self->{'outhandle'};
187
188 # check to make sure we're not reading the archives or index directory
189 my $gsdlhome = quotemeta($ENV{'GSDLHOME'});
190 if ($dirname =~ m/^$gsdlhome\/.*?\/import.*?\/(archives|index)$/) {
191 print $outhandle "DirectoryPlugin: $dirname appears to be a reference to a Greenstone collection, skipping.\n";
192 return 0;
193 }
194
195 # check to see we haven't got a cyclic path...
196 if ($dirname =~ m%(/.*){,41}%) {
197 print $outhandle "DirectoryPlugin: $dirname is 40 directories deep, is this a recursive path? if not increase constant in DirectoryPlugin.pm.\n";
198 return 0;
199 }
200
201 # check to see we haven't got a cyclic path...
202 if ($dirname =~ m%.*?import/(.+?)/import/\1.*%) {
203 print $outhandle "DirectoryPlugin: $dirname appears to be in a recursive loop...\n";
204 return 0;
205 }
206
207 return 1;
208}
209
210# this may be called more than once
211sub sort_out_associated_files {
212
213 my $self = shift (@_);
214 my ($block_hash) = @_;
215 if (!scalar (keys %{$block_hash->{'shared_fileroot'}})) {
216 return;
217 }
218
219 $self->{'assocfile_info'} = {} unless defined $self->{'assocfile_info'};
220 my $metadata = $self->{'assocfile_info'};
221 foreach my $prefix (keys %{$block_hash->{'shared_fileroot'}}) {
222 my $record = $block_hash->{'shared_fileroot'}->{$prefix};
223
224 my $tie_to = $record->{'tie_to'};
225 my $exts = $record->{'exts'};
226
227 if ((defined $tie_to) && (scalar (keys %$exts) > 0)) {
228 # set up fileblocks and assocfile_tobe
229 my $base_file = "$prefix$tie_to";
230 $metadata->{$base_file} = {} unless defined $metadata->{$base_file};
231 my $base_file_metadata = $metadata->{$base_file};
232
233 $base_file_metadata->{'gsdlassocfile_tobe'} = [] unless defined $base_file_metadata->{'gsdlassocfile_tobe'};
234 my $assoc_tobe = $base_file_metadata->{'gsdlassocfile_tobe'};
235 foreach my $e (keys %$exts) {
236 # block the file
237 $block_hash->{'file_blocks'}->{"$prefix$e"} = 1;
238 # set up as an associatd file
239 print STDERR " $self->{'plugin_type'}: Associating $prefix$e with $tie_to version\n";
240 my $mime_type = ""; # let system auto detect this
241 push(@$assoc_tobe,"$prefix$e:$mime_type:");
242
243 }
244 }
245 } # foreach record
246
247 $block_hash->{'shared_fileroot'} = undef;
248 $block_hash->{'shared_fileroot'} = {};
249
250}
251
252
253# do block exp OR special blocking ???
254
255sub file_is_blocked {
256 my $self = shift (@_);
257 my ($block_hash, $filename_full_path) = @_;
258
259 if (defined $block_hash->{'file_blocks'}->{$filename_full_path}) {
260 $self->{'num_blocked'} ++;
261 return 1;
262 }
263 # check Directory plugin's own block_exp
264 if ($self->{'block_exp'} ne "" && $filename_full_path =~ /$self->{'block_exp'}/) {
265 $self->{'num_blocked'} ++;
266 return 1; # blocked
267 }
268 return 0;
269}
270
271
272
273sub file_block_read {
274 my $self = shift (@_);
275 my ($pluginfo, $base_dir, $file, $block_hash, $metadata, $gli) = @_;
276
277 my $outhandle = $self->{'outhandle'};
278 my $verbosity = $self->{'verbosity'};
279
280 # Calculate the directory name and ensure it is a directory and
281 # that it is not explicitly blocked.
282 my $dirname = $file;
283 $dirname = &util::filename_cat ($base_dir, $file) if $base_dir =~ /\w/;
284
285 my $directory_ok = $self->check_directory_path($dirname);
286 return $directory_ok unless (defined $directory_ok && $directory_ok == 1);
287
288 print $outhandle "Global file scan checking directory: $dirname\n";
289
290 $block_hash->{'all_files'} = {} unless defined $block_hash->{'all_files'};
291 $block_hash->{'metadata_files'} = {} unless defined $block_hash->{'metadata_files'};
292
293 $block_hash->{'file_blocks'} = {} unless defined $block_hash->{'file_blocks'};
294 $block_hash->{'shared_fileroot'} = {} unless defined $block_hash->{'shared_fileroot'};
295
296 # Recur over directory contents.
297 my (@dir, $subfile);
298 #my $count = 0;
299
300 print $outhandle "DirectoryPlugin block: getting directory $dirname\n" if ($verbosity > 2);
301
302 # find all the files in the directory
303 if (!opendir (DIR, $dirname)) {
304 if ($gli) {
305 print STDERR "<ProcessingError n='$file' r='Could not read directory $dirname'>\n";
306 }
307 print $outhandle "DirectoryPlugin: WARNING - couldn't read directory $dirname\n";
308 return -1; # error in processing
309 }
310 @dir = readdir (DIR);
311 closedir (DIR);
312
313 for (my $i = 0; $i < scalar(@dir); $i++) {
314 my $subfile = $dir[$i];
315 my $this_file_base_dir = $base_dir;
316 next if ($subfile =~ m/^\.\.?$/);
317
318 # Recursively read each $subfile
319 print $outhandle "DirectoryPlugin block recurring: $subfile\n" if ($verbosity > 2);
320
321 #$count += &plugin::file_block_read ($pluginfo, $this_file_base_dir,
322 &plugin::file_block_read ($pluginfo, $this_file_base_dir,
323 &util::filename_cat($file, $subfile),
324 $block_hash, $metadata, $gli);
325
326 }
327 $self->sort_out_associated_files($block_hash);
328 #return $count;
329
330}
331
332# We don't do metadata_read
333sub metadata_read {
334 my $self = shift (@_);
335 my ($pluginfo, $base_dir, $file, $block_hash,
336 $extrametakeys, $extrametadata, $extrametafile,
337 $processor, $maxdocs, $gli) = @_;
338
339 return undef;
340}
341
342
343# return number of files processed, undef if can't process
344# Note that $base_dir might be "" and that $file might
345# include directories
346
347# This function passes around metadata hash structures. Metadata hash
348# structures are hashes that map from a (scalar) key (the metadata element
349# name) to either a scalar metadata value or a reference to an array of
350# such values.
351
352sub read {
353 my $self = shift (@_);
354 my ($pluginfo, $base_dir, $file, $block_hash, $in_metadata, $processor, $maxdocs, $total_count, $gli) = @_;
355
356 my $outhandle = $self->{'outhandle'};
357 my $verbosity = $self->{'verbosity'};
358
359 # Calculate the directory name and ensure it is a directory and
360 # that it is not explicitly blocked.
361 my $dirname;
362 if ($file eq "") {
363 $dirname = $base_dir;
364 } else {
365 $dirname = $file;
366 $dirname = &util::filename_cat ($base_dir, $file) if $base_dir =~ /\w/;
367 }
368
369 my $directory_ok = $self->check_directory_path($dirname);
370 return $directory_ok unless (defined $directory_ok && $directory_ok == 1);
371
372 if (($verbosity > 2) && ((scalar keys %$in_metadata) > 0)) {
373 print $outhandle "DirectoryPlugin: metadata passed in: ",
374 join(", ", keys %$in_metadata), "\n";
375 }
376
377
378 # Recur over directory contents.
379 my (@dir, $subfile);
380 my $count = 0;
381
382 print $outhandle "DirectoryPlugin read: getting directory $dirname\n" if ($verbosity > 2);
383
384 # find all the files in the directory
385 if (!opendir (DIR, $dirname)) {
386 if ($gli) {
387 print STDERR "<ProcessingError n='$file' r='Could not read directory $dirname'>\n";
388 }
389 print $outhandle "DirectoryPlugin: WARNING - couldn't read directory $dirname\n";
390 return -1; # error in processing
391 }
392 @dir = readdir (DIR);
393 closedir (DIR);
394
395 # Re-order the files in the list so any directories ending with .all are moved to the end
396 for (my $i = scalar(@dir) - 1; $i >= 0; $i--) {
397 if (-d &util::filename_cat($dirname, $dir[$i]) && $dir[$i] =~ /\.all$/) {
398 push(@dir, splice(@dir, $i, 1));
399 }
400 }
401
402 # setup the metadata structures. we do a metadata_read pass to see if there is any additional metadata, then pass it to read
403
404 my $additionalmetadata = 0; # is there extra metadata available?
405 my %extrametadata; # maps from filespec to extra metadata keys
406 my %extrametafile; # maps from filespec to the metadata.xml (or similar) file it came from
407 my @extrametakeys; # keys of %extrametadata in order read
408
409
410 my $os_dirsep = &util::get_os_dirsep();
411 my $dirsep = &util::get_dirsep();
412 my $base_dir_regexp = $base_dir;
413 $base_dir_regexp =~ s/\//$os_dirsep/g;
414 my $local_dirname = $dirname;
415 $local_dirname =~ s/^$base_dir_regexp($os_dirsep)//;
416 $local_dirname .= $dirsep;
417
418 if (defined $self->{'subdir_extrametakeys'}->{$local_dirname}) {
419 my $extrakeys = $self->{'subdir_extrametakeys'}->{$local_dirname};
420 foreach my $ek (@$extrakeys) {
421 my $extrakeys_re = $ek->{'re'};
422 my $extrakeys_md = $ek->{'md'};
423 my $extrakeys_mf = $ek->{'mf'};
424 push(@extrametakeys,$extrakeys_re);
425 $extrametadata{$extrakeys_re} = $extrakeys_md;
426 $extrametafile{$extrakeys_re} = $extrakeys_mf;
427 }
428 delete($self->{'subdir_extrametakeys'}->{$local_dirname});
429 }
430
431 # apply metadata pass for each of the files in the directory
432 my $num_files = scalar(@dir);
433 for (my $i = 0; $i < scalar(@dir); $i++) {
434 my $subfile = $dir[$i];
435 my $this_file_base_dir = $base_dir;
436 last if ($maxdocs != -1 && $count >= $maxdocs);
437 next if ($subfile =~ m/^\.\.?$/);
438 my $file_subfile = &util::filename_cat($file, $subfile);
439 my $full_filename = &util::filename_cat($this_file_base_dir, $file_subfile);
440 if ($self->file_is_blocked($block_hash,$full_filename)) {
441 print STDERR "DirectoryPlugin: file $full_filename was blocked for metadata_read\n" if ($verbosity > 2);
442 next;
443 }
444
445 # Recursively read each $subfile
446 print $outhandle "DirectoryPlugin metadata recurring: $subfile\n" if ($verbosity > 2);
447
448 $count += &plugin::metadata_read ($pluginfo, $this_file_base_dir,
449 $file_subfile,$block_hash,
450 \@extrametakeys, \%extrametadata,
451 \%extrametafile,
452 $processor, $maxdocs, $gli);
453 $additionalmetadata = 1;
454 }
455
456 # filter out any extrametakeys that mention subdirectories and store
457 # for later use (i.e. when that sub-directory is being processed)
458
459 foreach my $ek (@extrametakeys) {
460 my ($subdir_re,$extrakey_dir) = &File::Basename::fileparse($ek);
461
462 $extrakey_dir =~ s/\\\./\./g; # remove RE syntax for .
463 $extrakey_dir =~ s/\\\\/\\/g; # remove RE syntax for \
464
465 my $dirsep_re = &util::get_re_dirsep();
466
467 my $ek_non_re = $ek;
468 $ek_non_re =~ s/\\\./\./g; # remove RE syntax for .
469 $ek_non_re =~ s/\\\\/\\/g; # remove RE syntax for \
470 if ($ek_non_re =~ m/$dirsep_re/) { # specifies at least one directory
471 my $md = $extrametadata{$ek};
472 my $mf = $extrametafile{$ek};
473
474 my $subdir_extrametakeys = $self->{'subdir_extrametakeys'};
475
476 my $subdir_rec = { 're' => $subdir_re, 'md' => $md, 'mf' => $mf };
477
478 # when its looked up, it must be relative to the base dir
479 push(@{$subdir_extrametakeys->{"$local_dirname$extrakey_dir"}},$subdir_rec);
480 #push(@{$subdir_extrametakeys->{"$extrakey_dir"}},$subdir_rec);
481 }
482 }
483
484 # import each of the files in the directory
485 $count=0;
486 for (my $i = 0; $i <= scalar(@dir); $i++) {
487 # When every file in the directory has been done, pause for a moment (figuratively!)
488 # If the -recheck_directories argument hasn't been provided, stop now (default)
489 # Otherwise, re-read the contents of the directory to check for new files
490 # Any new files are added to the @dir list and are processed as normal
491 # This is necessary when documents to be indexed are specified in bibliographic DBs
492 # These files are copied/downloaded and stored in a new folder at import time
493 if ($i == $num_files) {
494 last unless $self->{'recheck_directories'};
495
496 # Re-read the files in the directory to see if there are any new files
497 last if (!opendir (DIR, $dirname));
498 my @dirnow = readdir (DIR);
499 closedir (DIR);
500
501 # We're only interested if there are more files than there were before
502 last if (scalar(@dirnow) <= scalar(@dir));
503
504 # Any new files are added to the end of @dir to get processed by the loop
505 my $j;
506 foreach my $subfilenow (@dirnow) {
507 for ($j = 0; $j < $num_files; $j++) {
508 last if ($subfilenow eq $dir[$j]);
509 }
510 if ($j == $num_files) {
511 # New file
512 push(@dir, $subfilenow);
513 }
514 }
515 # When the new files have been processed, check again
516 $num_files = scalar(@dir);
517 }
518
519 my $subfile = $dir[$i];
520 my $this_file_base_dir = $base_dir;
521 last if ($maxdocs != -1 && ($count + $total_count) >= $maxdocs);
522 next if ($subfile =~ /^\.\.?$/);
523
524 my $file_subfile = &util::filename_cat($file, $subfile);
525 my $full_filename
526 = &util::filename_cat($this_file_base_dir,$file_subfile);
527
528 if ($self->file_is_blocked($block_hash,$full_filename)) {
529 print STDERR "DirectoryPlugin: file $full_filename was blocked for read\n" if ($verbosity > 2);
530 next;
531 }
532 #print STDERR "processing $full_filename\n";
533 # Follow Windows shortcuts
534 if ($subfile =~ /(?i)\.lnk$/ && $ENV{'GSDLOS'} =~ /^windows$/i) {
535 require Win32::Shortcut;
536 my $shortcut = new Win32::Shortcut(&util::filename_cat($dirname, $subfile));
537 if ($shortcut) {
538 # The file to be processed is now the target of the shortcut
539 $this_file_base_dir = "";
540 $file = "";
541 $subfile = $shortcut->Path;
542 }
543 }
544
545 # check for a symlink pointing back to a leading directory
546 if (-d "$dirname/$subfile" && -l "$dirname/$subfile") {
547 # readlink gives a "fatal error" on systems that don't implement
548 # symlinks. This assumes the the -l test above would fail on those.
549 my $linkdest=readlink "$dirname/$subfile";
550 if (!defined ($linkdest)) {
551 # system error - file not found?
552 warn "DirectoryPlugin: symlink problem - $!";
553 } else {
554 # see if link points to current or a parent directory
555 if ($linkdest =~ m@^[\./\\]+$@ ||
556 index($dirname, $linkdest) != -1) {
557 warn "DirectoryPlugin: Ignoring recursive symlink ($dirname/$subfile -> $linkdest)\n";
558 next;
559 ;
560 }
561 }
562 }
563
564 print $outhandle "DirectoryPlugin: preparing metadata for $subfile\n" if ($verbosity > 2);
565
566 # Make a copy of $in_metadata to pass to $subfile
567 my $out_metadata = {};
568 &metadatautil::combine_metadata_structures($out_metadata, $in_metadata);
569
570 # check the assocfile_info
571 if (defined $self->{'assocfile_info'}->{$full_filename}) {
572 &metadatautil::combine_metadata_structures($out_metadata, $self->{'assocfile_info'}->{$full_filename});
573 }
574 ## encode the filename as perl5 doesn't handle unicode filenames
575
576 my $tmpfile = Encode::encode_utf8($subfile);
577 # Next add metadata read in XML files (if it is supplied)
578 if ($additionalmetadata == 1) {
579 foreach my $filespec (@extrametakeys) {
580
581 ## use the utf8 encoded filename to do the filename comparison
582 if ($tmpfile =~ /^$filespec$/) {
583 print $outhandle "File \"$subfile\" matches filespec \"$filespec\"\n"
584 if ($verbosity > 2);
585 my $mdref = $extrametadata{$filespec};
586 my $mfref = $extrametafile{$filespec};
587
588 # Add the list files where the metadata came from
589 # into the metadata table so we can track this
590 # This mechanism is similar to how gsdlassocfile works
591
592 my @metafile_pair = ();
593 foreach my $l (keys %$mfref) {
594 my $f = $mfref->{$l};
595 push (@metafile_pair, "$f : $l");
596 }
597
598 $mdref->{'gsdlmetafile'} = \@metafile_pair;
599
600 &metadatautil::combine_metadata_structures($out_metadata, $mdref);
601 }
602 }
603 }
604
605 if (defined $self->{'inf_timestamp'}) {
606 # Look to see if it's a completely new file
607
608 if (!$block_hash->{'new_files'}->{$full_filename}) {
609 # Not a new file, must be an existing file
610 # Let' see if it's newer than the last import.pl
611
612
613 if (! -d $full_filename) {
614 if (!$block_hash->{'reindex_files'}->{$full_filename}) {
615 # filename has been around for longer than inf_timestamp
616 print $outhandle "**** Skipping $subfile\n" if ($verbosity >3);
617 next;
618 }
619 else {
620 # Remove old folder in archives (might hash to something different)
621 # *** should be doing this on a Del one as well
622 # but leave folder name?? and ensure hashs to
623 # same again??
624
625 # Then let through as new doc??
626
627 # mark to doc-oids that rely on it for re-indexing
628 }
629 }
630 }
631 }
632
633 # Recursively read each $subfile
634 print $outhandle "DirectoryPlugin recurring: $subfile\n" if ($verbosity > 2);
635
636 $count += &plugin::read ($pluginfo, $this_file_base_dir,
637 $file_subfile, $block_hash,
638 $out_metadata, $processor, $maxdocs, ($total_count + $count), $gli);
639 }
640
641 return $count;
642}
643
644sub compile_stats {
645 my $self = shift(@_);
646 my ($stats) = @_;
647}
648
6491;
Note: See TracBrowser for help on using the repository browser.