source: main/trunk/greenstone2/perllib/oaiinfo.pm@ 31208

Last change on this file since 31208 was 31208, checked in by ak19, 4 years ago

Kathy found that the lowercased dbutil modules are not used (jdbm.pm, for example) and have been wholly replaced by the uppercased versions in DBDrivers folder. (I thought we had both). So I've moved the recently added code in jdbm.pm to JDBM.pm and fixed it up. Also tiedied up oaiinfo.pm of some unwanted commented out code.

File size: 21.3 KB
Line 
1# This class based on arcinfo.pm
2package oaiinfo;
3
4use constant INFO_STATUS_INDEX => 0;
5use constant INFO_TIMESTAMP_INDEX => 1;
6
7use strict;
8
9use arcinfo;
10use dbutil;
11
12# QUESTIONS:
13# Should we use time or localtime(time) for timestamp? Just timestamp.
14# What format should the timestamp be in, or is the basic format used by perl sufficient? Basic.
15
16# File format read in: OID <tab> Date-timestamp <tab> Deletion-Status
17
18# Deletion status can be:
19# E = Doc with OID exists (has not been deleted from collection). Timestamp indicates last time of build
20# D = Doc with OID has been deleted. Timestamp indicates time of deletion
21# PD = Provisionally Deleted. Timestamp momentarily unaltered.
22
23# oaidb is "always incremental": always reflects the I/B/R/D status of archive info db,
24# before the indexing step of the build phase that alters the I/B/R/D contents of archive info db.
25# (I=index, B=been indexed, R=reindex; D=delete)
26
27sub new {
28 my $class = shift(@_);
29 my ($config_filename, $infodbtype) = @_;
30
31 my $self = {
32 'info'=>{} # map of {OID, array[deletion-status,timestamp]} pairs
33 };
34
35 if(!defined $infodbtype) {
36 $infodbtype = &dbutil::get_default_infodb_type();
37 }
38 $infodbtype = "gdbm" if ($infodbtype eq "gdbm-txtgz");
39 $self->{'infodbtype'} = $infodbtype;
40
41 # Create and store the db filenames we'll be working with (tmp and livedb)
42 my $etc_dir = &util::get_parent_folder($config_filename);
43
44 my $perform_firsttime_init = 0;
45 $self->{'oaidb_live_filepath'} = &dbutil::get_infodb_file_path($infodbtype, "oai-inf", $etc_dir, $perform_firsttime_init);
46 $self->{'oaidb_tmp_filepath'} = &dbutil::get_infodb_file_path($infodbtype, "oai-inf-tmp", $etc_dir, $perform_firsttime_init);
47 $self->{'etc_dir'} = $etc_dir;
48# print STDERR "############ LIVE DB: $self->{'oaidb_live_filepath'}\n";
49# print STDERR "############ TMP DB: $self->{'oaidb_tmp_filepath'}\n";
50
51 $self->{'oaidb_file_path'} = $self->{'oaidb_tmp_filepath'}; # db file we're working with
52
53 return bless $self, $class;
54}
55
56# this subroutine will work out the starting contents of the tmp-db (temporary oai db):
57# whether it should start off empty, or with the contents of any existing live-db,
58# or with the contents of any existing tmp-db.
59sub init_tmpdb {
60 my $self = shift(@_);
61 my ($removeold, $have_manifest) = @_;
62
63 # if we have a manifest file, then we pretend we are fully incremental for oaiinfo db.
64 # removeold implies proper full-rebuild, whereas keepold or incremental means incremental
65 if($have_manifest) { # if we have a manifest file, we're not doing removeold/full-rebuild either
66 $removeold = 0;
67 }
68
69 my $do_pd_step = ($removeold) ? 1 : 0;
70 # if $removeold, then proper full rebuild, will carry out step where all E will be marked as PD
71 # else some kind of incremental build, won't do the extra PD pass
72 # which is the step marking existing OIDs (E) as PD (provisionally deleted)
73
74 my $oaidb_live_filepath = $self->{'oaidb_live_filepath'};
75 my $oaidb_tmp_filepath = $self->{'oaidb_tmp_filepath'};
76 my $infodbtype = $self->{'infodbtype'};
77 # Note: the live db can only exist if the collection has been activated at least once before
78 my $livedb_exists = &FileUtils::fileExists($oaidb_live_filepath);
79 my $tmpdb_exists = &FileUtils::fileExists($oaidb_tmp_filepath);
80
81 my $initdb = 0;
82
83 # work out what operation we need to do
84 # work with empty tmpdb
85 # copy_livedb_to_tmpdb
86 # work with existing tmpdb (so existing tmpdb will be topped up)
87
88 # make_contents_of_tmpdb_empty
89 # make_contents_of_tmpdb_that_of_livedb
90 # continue_working_with_tmpdb ("contents_of_tmpdb_is_tmpdb")
91
92 # We're going to prepare the starting state of tmpdb next.
93 # It can start off empty, start off with the contents of livedb, or it can start off with the contents
94 # of the existing tmp db. Which of these three it is depends on the 3 factors: whether livedb exists,
95 # whether tmpdb exists and whether or not removeold is true.
96 # i.o.w. which of the 3 outcomes it is depends on the truth table built on the following 3 variables:
97 # LDB = LiveDB exists
98 # TDB = TmpDB exists
99 # RO = Removeold
100 # OUTCOMES:
101 # clean slate (create an empty tmpdb/make tmpdb empty)
102 # top up tmpDB (work with existing tmpdb)
103 # copy LiveDB to TmpDB (liveDB's contents become the contents of TmpDB, and we'll work with that)
104 #
105 # TRUTH TABLE:
106 # ---------------------------------------
107 # LDB TDB RO | Outcome
108 # ---------------------------------------
109 # 0 0 0 | clean-slate
110 # 0 0 1 | clean-slate
111 # 0 1 0 | top-up-tmpdb
112 # 0 1 1 | erase tmpdb, clean-slate
113 # 1 0 0 | copy livedb to tmpdb
114 # 1 0 1 | copy livedb to tmpdb
115 # 1 1 0 | top-up-tmpdb
116 # 1 1 1 | copy livedb to tmpd
117 # ---------------------------------------
118 #
119 # Dr Bainbridge worked out using Karnaugh maps that, from the above truth table:
120 # => clean-slate/empty-tmpdb = !LDB && (RO || !TDB)
121 # => top-up-tmpdb/work-with-existing-tmpdb = !RO && TDB
122 # => copy-livedb-to-tmpdb = LDB && (!TDB || RO)
123 # I had most of these tests, except that I hadn't (yet) merged the two clean slate instances
124 # of first-build-ever and make-contents-of-tmpdb-empty
125
126 #my $first_build_ever = (!$livedb_exists && !$tmpdb_exists);
127 #my $make_contents_of_tmpdb_empty = (!$livedb_exists && $tmpdb_exists && $removeold);
128 # Karnaugh map allows merging $first_build_ever and $make_contents_of_tmpdb_empty above
129 # into: my $work_with_empty_tmpdb = (!$livedb_exists && (!$tmpdb_exists || $removeold));
130 my $work_with_empty_tmpdb = (!$livedb_exists && (!$tmpdb_exists || $removeold));
131 my $make_contents_of_tmpdb_that_of_livedb = ($livedb_exists && (!$tmpdb_exists || $removeold));
132 my $work_with_existing_tmpdb = ($tmpdb_exists && !$removeold);
133
134 if($work_with_empty_tmpdb) { # we'll use an empty tmpdb
135
136 # If importing the collection for the very first time, neither db exists,
137 # so create an empty tmpdb.
138 #
139 # We also create an empty tmpdb when livedb doesn't exist and $removeold is true.
140 # This can happen if we've never run activate (so no livedb),
141 # yet had done some import (and perhaps building) followed by a full re-import now.
142 # Since there was no activate and we're doing a removeold/full-rebuild now, can just
143 # work with a new tmpdb, even though one already existed, its contents can be wiped out.
144 # In such a scenario, we'll be deleting tmpdb. Then there will be no livedb nor any tmpdb
145 # any more, so same situation as if importing the very first time when no oaidb exists either.
146
147 &dbutil::remove_db_file($self->{'infodbtype'}, $oaidb_tmp_filepath) if $tmpdb_exists; # remove the db file and any assoc files
148 $initdb = 1; # new tmpdb
149
150 # if the oai db is created the first time, it's like incremental and
151 # "keepold" (keepold means "only add, don't reprocess existing"). So
152 # no need to do the special passes dealing with "provisional deletes".
153 $do_pd_step = 0;
154
155 } elsif ($make_contents_of_tmpdb_that_of_livedb) {
156
157 # If the livedb exists and we're doing a full rebuild ($removeold is true),
158 # copy livedb to tmp regardless of if tmpdb already exists.
159 # Or if the livedb exists and tmpdb doesn't exist, it doesn't matter
160 # if we're incremental or not: also copy live to tmp and work with tmp.
161
162 # copy livedb to tmpdb
163 &dbutil::remove_db_file($self->{'infodbtype'}, $oaidb_tmp_filepath) if $tmpdb_exists; # remove the db file and any assoc files
164 &FileUtils::copyFiles($oaidb_live_filepath, $oaidb_tmp_filepath);
165
166 $initdb = 0; # tmpdb exists, since we just copied livedb to tmpdb, so need to init existing tmpdb
167
168 } else { # $work_with_existing_tmpdb, so we'll build on top of what's presently already in tmpdb
169 # (we'll be topping up the current tmpdb)
170
171 # !$removeold, meaning incremental
172 # If incremental and have a tmpdb already, regardless of whether livedb exists,
173 # then work with the existing tmpdb file, as this means we've been
174 # importing (perhaps followed by building) repeatedly without activating the
175 # last time but want to maintain the (incremental) changes in tmpdb.
176
177 $initdb = 0;
178
179 } # Dr Bainbridge drew up Karnaugh maps on the truth table, which proved that all cases
180 # are indeed covered above, so don't need any other catch-all else here
181
182 $self->{'oaidb_file_path'} = &dbutil::get_infodb_file_path($infodbtype, "oai-inf-tmp", $self->{'etc_dir'}, $initdb);
183 # final param follows jmt's $perform_firsttime_init in inexport.pm
184
185# print STDERR "@@@@@ oaidb: $self->{'oaidb_file_path'}\n";
186
187 return $do_pd_step;
188}
189
190sub get_filepath {
191 my $self = shift (@_);
192 return $self->{'oaidb_file_path'};
193}
194
195sub import_stage {
196 my $self = shift (@_);
197 my ($removeold, $have_manifest) = @_;
198
199 my $do_pd_step = $self->init_tmpdb($removeold, $have_manifest);
200 # returns 1 if the step to mark oaidb entries as PD is required
201 # if we're doing full rebuilding and it's NOT the first time creating the oai_inf db,
202 # then the tasks to do with PD (provisionally deleted) OAI OIDs should be carried out
203
204 $self->load_info();
205 $self->print_info(); # DEBUGGING
206
207 if ($do_pd_step) {
208 $self->mark_all_existing_as_provisionallydeleted();
209 $self->print_info(); # DEBUGGING
210
211 # save to db file now that we're done
212 $self->save_info();
213 }
214
215}
216
217sub building_stage_before_indexing() {
218 my $self = shift (@_);
219 my ($archivedir) = @_;
220
221 # load archive info db into memory
222 my $arcinfo_doc_filename = &dbutil::get_infodb_file_path($self->{'infodbtype'}, "archiveinf-doc", $archivedir);
223 my $arcinfo_src_filename = &dbutil::get_infodb_file_path($self->{'infodbtype'}, "archiveinf-src", $archivedir);
224 my $archive_info = new arcinfo ($self->{'infodbtype'});
225 $archive_info->load_info ($arcinfo_doc_filename);
226
227 #my $started_from_scratch = &FileUtils::fileTest($self->{'oaidb_tmp_filepath'}, '-z'); # 1 if tmpdb is empty
228 # -z test for file is empty http://www.perlmonks.org/?node_id=927447
229
230 # load the oaidb file's contents into memory.
231 $self->load_info();
232 $self->print_info(); # DEBUGGING
233
234 # process all the index, reindex and delete operations as indicated in arcinfo,
235 # all the while ensuring all PDs are changed back to E for OIDs that exist in both arcinfo and oaiinfo db.
236
237 foreach my $OID (keys $archive_info->{'info'}) {
238 my $arcinf_tuple = $archive_info->{'info'}->{$OID};
239 my $indexing_status = $arcinf_tuple->[arcinfo::INFO_STATUS_INDEX];
240 # use packageName::constant to refer to constants declared in another package,
241 # see http://perldoc.perl.org/constant.html
242
243 print STDERR "######## OID: $OID - status: $indexing_status\n";
244
245 if($indexing_status eq "I") {
246 $self->index($OID); # add new as E with current timestamp/or set existing as E with orig timestamp
247 } elsif($indexing_status eq "R") {
248 $self->reindex($OID); # update timestamp and ensure marked as E (if oid doesn't exist, add new)
249 } elsif($indexing_status eq "D") {
250 $self->delete($OID); # set as D with current timestamp
251 } elsif($indexing_status eq "B") { # B for "been indexed"
252 $self->been_indexed($OID); # will flip any PD to E if oid exists, else will add new entry for oid
253 # A new entry may be required if the collection had been built prior to turning this into
254 # an oaicollection. But what if we always maintain an oaidb? Still call $self->index() here.
255 } else {
256 print STDERR "### oaiinfo::building_stage_before_indexing(): Unrecognised indexing status $indexing_status\n";
257 }
258 }
259
260 # once all docs processed, go through oaiiinfo db changing any PDs to D along with current timestamp
261 # to indicate that they're deleted
262 $self->mark_all_provisionallydeleted_as_deleted();
263 $self->print_info();
264
265 # let's save to db file now that we're done
266 $self->save_info();
267
268}
269
270sub activate_collection { # move tmp db to live db
271 my $self = shift (@_);
272
273 my $oaidb_live_filepath = $self->{'oaidb_live_filepath'};
274 my $oaidb_tmp_filepath = $self->{'oaidb_tmp_filepath'};
275
276 my $livedb_exists = &FileUtils::fileExists($oaidb_live_filepath);
277 my $tmpdb_exists = &FileUtils::fileExists($oaidb_tmp_filepath);
278
279 if($tmpdb_exists) {
280 if($livedb_exists) {
281 #&dbutil::remove_db_file($self->{'infodbtype'}, s$oaidb_live_filepath); # remove the db file and any assoc files
282 &dbutil::rename_db_file_to($self->{'infodbtype'}, $oaidb_live_filepath, $oaidb_live_filepath.".bak"); # rename the db file and any assoc files
283 }
284 #&FileUtils::moveFiles($oaidb_tmp_filepath, $oaidb_live_filepath);
285 &dbutil::rename_db_file_to($self->{'infodbtype'}, $oaidb_tmp_filepath, $oaidb_live_filepath); # rename the db file and any assoc files
286
287 print STDERR "#### Should now have MOVED $self->{'oaidb_tmp_filepath'} to $self->{'oaidb_live_filepath'}\n";
288
289 } else {
290 print STDERR "@@@@@ In oaiinfo::activate_collection():\n";
291 print STDERR "@@@@@ No tmpdb at $self->{'oaidb_tmp_filepath'}\n";
292 print STDERR "@@@@@ to make 'live' by moving to $self->{'oaidb_live_filepath'}.\n";
293 }
294}
295
296##################### SPECIFIC TO PD-STEP ####################
297
298
299# mark all existing, E (non-deleted) OIDs as Provisionally Deleted (PD)
300# this subroutine doesn't save to oai-inf.DB
301# the caller should call save_info when they want to save to the db
302sub mark_all_existing_as_provisionallydeleted {
303 my $self = shift (@_);
304
305 print STDERR "@@@@@ oaiinfo::mark_all_E_as_PD(): Marking the E entries as PD\n";
306
307 foreach my $OID (keys $self->{'info'}) {
308 my $OID_info = $self->{'info'}->{$OID};
309 my $curr_status = $OID_info->[INFO_STATUS_INDEX];
310 if($curr_status eq "E") {
311 $OID_info->[INFO_STATUS_INDEX] = "PD";
312 }
313 }
314}
315
316# mark all OIDs that are Provisionally Deleted (PD) as deleted, and set to current timestamp
317# To be called at end of build. Again, the caller should save to DB by calling save_info.
318sub mark_all_provisionallydeleted_as_deleted {
319 my $self = shift (@_);
320
321 print STDERR "@@@@@ oaiinfo::mark_all_PD_as_D(): Marking the PD entries as D\n";
322
323 foreach my $OID (keys $self->{'info'}) {
324 my $OID_info = $self->{'info'}->{$OID};
325 my $curr_status = $OID_info->[INFO_STATUS_INDEX];
326 if($curr_status eq "PD") {
327 $self->set_info($OID, "D", $self->get_current_time());
328 }
329 }
330}
331
332
333##################### GENERAL, NOT SPECIFIC TO PD-STEP ####################
334
335sub print_info {
336 my $self = shift (@_);
337
338 print STDERR "###########################################################\n";
339 print STDERR "@@@@@ oaiinfo::print_info(): oaidb in memory contains: \n";
340
341 foreach my $OID (keys $self->{'info'}) {
342 print STDERR "OID: $OID";
343 print STDERR " status: " . $self->{'info'}->{$OID}->[INFO_STATUS_INDEX];
344 print STDERR " time: " . $self->{'info'}->{$OID}->[INFO_TIMESTAMP_INDEX];
345 print STDERR "\n";
346 }
347
348 print STDERR "###########################################################\n";
349}
350
351
352# Find the OID, if it exists, make its status=E for existing. Leave its timestamp alone.
353# If the OID doesn't yet exist, add it as a new entry with status=E and with current timestamp.
354sub index { # Add a new oid with current time and E. If the oid was already present, mark as E
355 my $self = shift (@_);
356 my ($OID) = @_;
357
358 my $OID_info = $self->{'info'}->{$OID};
359
360 if (defined $OID_info) { # if OID is present, this will change status back to E, timestamp unchanged
361 $OID_info->[INFO_STATUS_INDEX] = "E";
362
363 } else { # if OID is not present, then it's now added as existing from current time on
364 $self->set_info($OID, "E", $self->get_current_time());
365 }
366}
367
368# Upon reindexing a document with identifier OID, change its timestamp to current time
369# if a new OID, then add as new entry with status=E and current timestamp
370sub reindex { # update timestamp if oid is already present, if not (unlikely), add as new
371 my $self = shift (@_);
372 my ($OID) = @_;
373
374 my $OID_info = $self->{'info'}->{$OID};
375 $self->set_info($OID, "E", $self->get_current_time()); # Takes care of 3 things:
376 # if OID exists, updates modified time to indicate the doc has been reindexed
377 # if OID exists, ensures any status=PD is flipped back to E for this OID doc (as we know it exists);
378 # if the OID doesn't yet exist, adds a new OID entry with status=E and current timestamp.
379
380}
381
382# Does the same as index():
383# OIDs that have been indexed upon rebuild may still be new to the oaidb: GS2 collections
384# are not OAI collections by default, unlike GS3 collections. Imagine rebuilding a (GS2) collection
385# 5 times and then setting them to be an OAI collection. In that case, the doc OIDs in the collection
386# may not be in the oaidb yet. Unless, we decide (as is the present case) to always maintain an oaidb
387# (always creating an oaidb regardless of whether the collection has OAI support turned on or not).
388sub been_indexed {
389 my $self = shift (@_);
390 my ($OID) = @_;
391
392 $self->index($OID);
393}
394
395# Upon deleting a document with identifier OID,
396# set status to deleted and change its timestamp to current time
397sub delete {
398 my $self = shift (@_);
399 my ($OID) = @_;
400
401 # the following method will set to current time if no timestamp provided,
402 # But by explicit here, the code is easier to follow
403 $self->set_info($OID, "D", $self->get_current_time());
404
405}
406
407#############################################################
408sub get_current_time {
409 my $self = shift (@_);
410 return time; # current time
411
412 # localtime(time) returns an array of values (day, month, year, hour, min, seconds) or singular string
413 # return localtime; # same as localtime(time); # http://perldoc.perl.org/functions/localtime.html
414
415}
416
417sub _load_info_txt
418{
419 my $self = shift (@_);
420 my ($filename) = @_;
421
422 if (defined $filename && &FileUtils::fileExists($filename)) {
423 open (INFILE, $filename) ||
424 die "oaiinfo::load_info couldn't read $filename\n";
425
426 my ($line, @lineparts);
427 while (defined ($line = <INFILE>)) {
428 $line =~ s/\cM|\cJ//g; # remove end-of-line characters
429 @lineparts = split ("\t", $line);
430 if (scalar(@lineparts) >= 2) {
431 $self->set_info (@lineparts);
432 }
433 }
434 close (INFILE);
435 }
436
437}
438
439sub _load_info_db
440{
441 my $self = shift (@_);
442 my ($filename) = @_;
443
444 my $infodb_map = {};
445
446 &dbutil::read_infodb_file($self->{'infodbtype'}, $filename, $infodb_map);
447
448 foreach my $oid ( keys $infodb_map ) {
449 my $vals = $infodb_map->{$oid};
450 # interested in oid, timestamp, deletion status
451
452 my ($deletion_status) = ($vals=~/^<status>(.*)$/m);
453 my ($timestamp) = ($vals=~/^<timestamp>(.*)$/m);
454
455 $self->set_info ($oid, $deletion_status, $timestamp);
456 }
457}
458
459# if no filename is passed in (and you don't generally want to), then
460# it tries to load in <collection>/etc/oai-inf.<db> if it exists
461sub load_info {
462 my $self = shift (@_);
463 my ($filename) = @_;
464
465 $self->{'info'} = {};
466
467 $filename = $self->{'oaidb_file_path'} unless defined $filename;
468
469 if (&FileUtils::fileExists($filename)) {
470 if ($filename =~ m/\.inf$/) {
471 $self->_load_info_txt($filename);
472 }
473 else {
474 $self->_load_info_db($filename);
475 }
476 }
477
478}
479
480sub _save_info_txt {
481 my $self = shift (@_);
482 my ($filename) = @_;
483
484 my ($OID, $info);
485
486 open (OUTFILE, ">$filename") ||
487 die "oaiinfo::save_info couldn't write $filename\n";
488
489 foreach $info (@{$self->get_OID_list()}) {
490 if (defined $info) {
491 print OUTFILE join("\t", @$info), "\n";
492 }
493 }
494 close (OUTFILE);
495}
496
497# if no filename is passed in (and you don't generally want to), then
498# this subroutine tries to write to <collection>/etc/oai-inf.<db>.
499sub _save_info_db {
500 my $self = shift (@_);
501 my ($filename) = @_;
502
503 $filename = $self->{'oaidb_file_path'} unless defined $filename;
504 my $infodbtype = $self->{'infodbtype'};
505
506 # write out again. Open file for overwriting, not appending.
507 # Then write out data structure $self->{'info'} that's been maintaining the data in-memory.
508 my $infodb_handle = &dbutil::open_infodb_write_handle($infodbtype, $filename);
509 foreach my $oid ( keys $self->{'info'} ) {
510 my $OID_info = $self->{'info'}->{$oid};
511 my $val = "<status>".$OID_info->[INFO_STATUS_INDEX]."\n<timestamp>".$OID_info->[INFO_TIMESTAMP_INDEX]."\n";
512 &dbutil::write_infodb_rawentry($infodbtype,$infodb_handle,$oid,$val);
513 }
514 &dbutil::close_infodb_write_handle($infodbtype, $infodb_handle);
515}
516
517sub save_info {
518 my $self = shift (@_);
519 my ($filename) = @_;
520
521 if(defined $filename) {
522 if ($filename =~ m/(contents)|(\.inf)$/) {
523 $self->_save_info_txt($filename);
524 }
525 else {
526 $self->_save_info_db($filename);
527 }
528 } else {
529 $self->_save_info_db();
530 }
531}
532
533
534sub set_info { # sets existing or appends
535 my $self = shift (@_);
536 my ($OID, $del_status, $timestamp) = @_;
537 if(!defined $timestamp) { # get current date timestamp
538 $timestamp = $self->get_current_time();
539 }
540 $self->{'info'}->{$OID} = [$del_status, $timestamp];
541
542}
543
544
545# returns a list of the form [[OID, timestamp, deletion_status], ...]
546sub get_OID_list
547{
548 my $self = shift (@_);
549
550 my @list = ();
551
552 foreach my $OID (keys $self->{'info'}) {
553 my $OID_info = $self->{'info'}->{$OID};
554
555 push (@list, [$OID, $OID_info->[INFO_STATUS_INDEX],
556 $OID_info->[INFO_TIMESTAMP_INDEX]]);
557 }
558
559 return \@list;
560}
561
562
563# returns the number of entries so far, including deleted ones
564# http://stackoverflow.com/questions/1109095/how-can-i-find-the-number-of-keys-in-a-hash-in-perl
565sub size {
566 my $self = shift (@_);
567 return (scalar keys $self->{'info'});
568}
569
5701;
Note: See TracBrowser for help on using the repository browser.