package Biblio::Isis; use strict; use Carp; use File::Glob qw(:globally :nocase); use Data::Dumper; BEGIN { use Exporter (); use vars qw ($VERSION @ISA @EXPORT @EXPORT_OK %EXPORT_TAGS); $VERSION = 0.11; @ISA = qw (Exporter); #Give a hoot don't pollute, do not export more than needed by default @EXPORT = qw (); @EXPORT_OK = qw (); %EXPORT_TAGS = (); } =head1 NAME Biblio::Isis - Read CDS/ISIS, WinISIS and IsisMarc database =head1 SYNOPSIS use Biblio::Isis; my $isis = new Biblio::Isis( isisdb => './cds/cds', ); for(my $mfn = 1; $mfn <= $isis->count; $mfn++) { print $isis->to_ascii($mfn),"\n"; } =head1 DESCRIPTION This module will read ISIS databases created by DOS CDS/ISIS, WinIsis or IsisMarc. It can be used as perl-only alternative to OpenIsis module which seems to depriciate it's old C bindings for perl. It can create hash values from data in ISIS database (using C), ASCII dump (using C) or just hash with field names and packed values (like C<^asomething^belse>). Unique feature of this module is ability to C records. It will also skip zero sized fields (OpenIsis has a bug in XS bindings, so fields which are zero sized will be filled with random junk from memory). It also has support for identifiers (only if ISIS database is created by IsisMarc), see C. This module will always be slower than OpenIsis module which use C library. However, since it's written in perl, it's platform independent (so you don't need C compiler), and can be easily modified. I hope that it creates data structures which are easier to use than ones created by OpenIsis, so reduced time in other parts of the code should compensate for slower performance of this module (speed of reading ISIS database is rarely an issue). =head1 METHODS =cut # my $ORDN; # Nodes Order # my $ORDF; # Leafs Order # my $N; # Number of Memory buffers for nodes # my $K; # Number of buffers for first level index # my $LIV; # Current number of Index Levels # my $POSRX; # Pointer to Root Record in N0x # my $NMAXPOS; # Next Available position in N0x # my $FMAXPOS; # Next available position in L0x # my $ABNORMAL; # Formal BTree normality indicator # # some binary reads # =head2 new Open ISIS database my $isis = new Biblio::Isis( isisdb => './cds/cds', read_fdt => 1, include_deleted => 1, hash_filter => sub { my $v = shift; $v =~ s#foo#bar#g; }, debug => 1, ); Options are described below: =over 5 =item isisdb This is full or relative path to ISIS database files which include common prefix of C<.MST>, and C<.XRF> and optionally C<.FDT> (if using C option) files. In this example it uses C<./cds/cds.MST> and related files. =item read_fdt Boolean flag to specify if field definition table should be read. It's off by default. =item include_deleted Don't skip logically deleted records in ISIS. =item hash_filter Filter code ref which will be used before data is converted to hash. =item debug Dump a B of debugging output. =back =cut sub new { my $class = shift; my $self = {}; bless($self, $class); croak "new needs database name (isisdb) as argument!" unless ({@_}->{isisdb}); foreach my $v (qw{isisdb debug include_deleted hash_filter}) { $self->{$v} = {@_}->{$v}; } my @isis_files = grep(/\.(FDT|MST|XRF|CNT)$/i,glob($self->{isisdb}."*")); foreach my $f (@isis_files) { my $ext = $1 if ($f =~ m/\.(\w\w\w)$/); $self->{lc($ext)."_file"} = $f; } my @must_exist = qw(mst xrf); push @must_exist, "fdt" if ($self->{read_fdt}); foreach my $ext (@must_exist) { croak "missing ",uc($ext)," file in ",$self->{isisdb} unless ($self->{$ext."_file"}); } print STDERR "## using files: ",join(" ",@isis_files),"\n" if ($self->{debug}); # if you want to read .FDT file use read_fdt argument when creating class! if ($self->{read_fdt} && -e $self->{fdt_file}) { # read the $db.FDT file for tags my $fieldzone=0; open(my $fileFDT, $self->{fdt_file}) || croak "can't read '$self->{fdt_file}': $!"; binmode($fileFDT); while (<$fileFDT>) { chomp; if ($fieldzone) { my $name=substr($_,0,30); my $tag=substr($_,50,3); $name =~ s/\s+$//; $tag =~ s/\s+$//; $self->{'TagName'}->{$tag}=$name; } if (/^\*\*\*/) { $fieldzone=1; } } close($fileFDT); } # Get the Maximum MFN from $db.MST open($self->{'fileMST'}, $self->{mst_file}) || croak "can't open '$self->{mst_file}': $!"; binmode($self->{'fileMST'}); # MST format: (* = 32 bit signed) # CTLMFN* always 0 # NXTMFN* MFN to be assigned to the next record created # NXTMFB* last block allocated to master file # NXTMFP offset to next available position in last block # MFTYPE always 0 for user db file (1 for system) seek($self->{'fileMST'},4,0) || croak "can't seek to offset 0 in MST: $!"; my $buff; read($self->{'fileMST'}, $buff, 4) || croak "can't read NXTMFN from MST: $!"; $self->{'NXTMFN'}=unpack("V",$buff) || croak "NXTNFN is zero"; print STDERR Dumper($self),"\n" if ($self->{debug}); # open files for later open($self->{'fileXRF'}, $self->{xrf_file}) || croak "can't open '$self->{xrf_file}': $!"; binmode($self->{'fileXRF'}); $self ? return $self : return undef; } =head2 count Return number of records in database print $isis->count; =cut sub count { my $self = shift; return $self->{'NXTMFN'} - 1; } =head2 fetch Read record with selected MFN my $rec = $isis->fetch(55); Returns hash with keys which are field names and values are unpacked values for that field like this: $rec = { '210' => [ '^aNew York^cNew York University press^dcop. 1988' ], '990' => [ '2140', '88', 'HAY' ], }; =cut sub fetch { my $self = shift; my $mfn = shift || croak "fetch needs MFN as argument!"; # is mfn allready in memory? my $old_mfn = $self->{'current_mfn'} || -1; return $self->{record} if ($mfn == $old_mfn); print STDERR "## fetch: $mfn\n" if ($self->{debug}); # XXX check this? my $mfnpos=($mfn+int(($mfn-1)/127))*4; print STDERR "## seeking to $mfnpos in file '$self->{xrf_file}'\n" if ($self->{debug}); seek($self->{'fileXRF'},$mfnpos,0); my $buff; # delete old record delete $self->{record}; # read XRFMFB abd XRFMFP read($self->{'fileXRF'}, $buff, 4); my $pointer=unpack("V",$buff) || croak "pointer is null"; # check for logically deleted record if ($pointer & 0x80000000) { print STDERR "## record $mfn is logically deleted\n" if ($self->{debug}); $self->{deleted} = $mfn; return unless $self->{include_deleted}; # abs $pointer = ($pointer ^ 0xffffffff) + 1; } my $XRFMFB = int($pointer/2048); my $XRFMFP = $pointer - ($XRFMFB*2048); # (XRFMFB - 1) * 512 + XRFMFP # why do i have to do XRFMFP % 1024 ? my $blk_off = (($XRFMFB - 1) * 512) + ($XRFMFP % 512); print STDERR "## pointer: $pointer XRFMFB: $XRFMFB XRFMFP: $XRFMFP offset: $blk_off\n" if ($self->{'debug'}); # Get Record Information seek($self->{'fileMST'},$blk_off,0) || croak "can't seek to $blk_off: $!"; read($self->{'fileMST'}, $buff, 4) || croak "can't read 4 bytes at offset $blk_off from MST file: $!"; my $value=unpack("V",$buff); print STDERR "## offset for rowid $value is $blk_off (blk $XRFMFB off $XRFMFP)\n" if ($self->{debug}); if ($value!=$mfn) { if ($value == 0) { print STDERR "## record $mfn is physically deleted\n" if ($self->{debug}); $self->{deleted} = $mfn; return; } carp "Error: MFN ".$mfn." not found in MST file, found $value"; return; } read($self->{'fileMST'}, $buff, 14); my ($MFRL,$MFBWB,$MFBWP,$BASE,$NVF,$STATUS) = unpack("vVvvvv", $buff); print STDERR "## MFRL: $MFRL MFBWB: $MFBWB MFBWP: $MFBWP BASE: $BASE NVF: $NVF STATUS: $STATUS\n" if ($self->{debug}); warn "MFRL $MFRL is not even number" unless ($MFRL % 2 == 0); warn "BASE is not 18+6*NVF" unless ($BASE == 18 + 6 * $NVF); # Get Directory Format my @FieldPOS; my @FieldLEN; my @FieldTAG; read($self->{'fileMST'}, $buff, 6 * $NVF); my $rec_len = 0; for (my $i = 0 ; $i < $NVF ; $i++) { my ($TAG,$POS,$LEN) = unpack("vvv", substr($buff,$i * 6, 6)); print STDERR "## TAG: $TAG POS: $POS LEN: $LEN\n" if ($self->{debug}); # The TAG does not exists in .FDT so we set it to 0. # # XXX This is removed from perl version; .FDT file is updated manually, so # you will often have fields in .MST file which aren't in .FDT. On the other # hand, IsisMarc doesn't use .FDT files at all! #if (! $self->{TagName}->{$TAG}) { # $TAG=0; #} push @FieldTAG,$TAG; push @FieldPOS,$POS; push @FieldLEN,$LEN; $rec_len += $LEN; } # Get Variable Fields read($self->{'fileMST'},$buff,$rec_len); print STDERR "## rec_len: $rec_len poc: ",tell($self->{'fileMST'})."\n" if ($self->{debug}); for (my $i = 0 ; $i < $NVF ; $i++) { # skip zero-sized fields next if ($FieldLEN[$i] == 0); push @{$self->{record}->{$FieldTAG[$i]}}, substr($buff,$FieldPOS[$i],$FieldLEN[$i]); } $self->{'current_mfn'} = $mfn; print STDERR Dumper($self),"\n" if ($self->{debug}); return $self->{'record'}; } =head2 to_ascii Returns ASCII output of record with specified MFN print $isis->to_ascii(42); This outputs something like this: 210 ^aNew York^cNew York University press^dcop. 1988 990 2140 990 88 990 HAY If C is specified when calling C it will display field names from C<.FDT> file instead of numeric tags. =cut sub to_ascii { my $self = shift; my $mfn = shift || croak "need MFN"; my $rec = $self->fetch($mfn); my $out = "0\t$mfn"; foreach my $f (sort keys %{$rec}) { my $fn = $self->tag_name($f); $out .= "\n$fn\t".join("\n$fn\t",@{$self->{record}->{$f}}); } $out .= "\n"; return $out; } =head2 to_hash Read record with specified MFN and convert it to hash my $hash = $isis->to_hash($mfn); It has ability to convert characters (using C) from ISIS database before creating structures enabling character re-mapping or quick fix-up of data. This function returns hash which is like this: $hash = { '210' => [ { 'c' => 'New York University press', 'a' => 'New York', 'd' => 'cop. 1988' } ], '990' => [ '2140', '88', 'HAY' ], }; You can later use that hash to produce any output from ISIS data. If database is created using IsisMarc, it will also have to special fields which will be used for identifiers, C and C like this: '200' => [ { 'i1' => '1', 'i2' => ' ' 'a' => 'Goa', 'f' => 'Valdo D\'Arienzo', 'e' => 'tipografie e tipografi nel XVI secolo', } ], This method will also create additional field C<000> with MFN. =cut sub to_hash { my $self = shift; my $mfn = shift || confess "need mfn!"; # init record to include MFN as field 000 my $rec = { '000' => [ $mfn ] }; my $row = $self->fetch($mfn); foreach my $k (keys %{$row}) { foreach my $l (@{$row->{$k}}) { # filter output $l = $self->{'hash_filter'}->($l) if ($self->{'hash_filter'}); my $val; # has identifiers? ($val->{'i1'},$val->{'i2'}) = ($1,$2) if ($l =~ s/^([01 #])([01 #])\^/\^/); # has subfields? if ($l =~ m/\^/) { foreach my $t (split(/\^/,$l)) { next if (! $t); $val->{substr($t,0,1)} = substr($t,1); } } else { $val = $l; } push @{$rec->{$k}}, $val; } } return $rec; } =head2 tag_name Return name of selected tag print $isis->tag_name('200'); =cut sub tag_name { my $self = shift; my $tag = shift || return; return $self->{'TagName'}->{$tag} || $tag; } =head2 read_cnt Read content of C<.CNT> file and return hash containing it. print Dumper($isis->read_cnt); This function is not used by module (C<.CNT> files are not required for this module to work), but it can be useful to examine your index (while debugging for example). =cut sub read_cnt { my $self = shift; croak "missing CNT file in ",$self->{isisdb} unless ($self->{cnt_file}); # Get the index information from $db.CNT open(my $fileCNT, $self->{cnt_file}) || croak "can't read '$self->{cnt_file}': $!"; binmode($fileCNT); my $buff; read($fileCNT, $buff, 26) || croak "can't read first table from CNT: $!"; $self->unpack_cnt($buff); read($fileCNT, $buff, 26) || croak "can't read second table from CNT: $!"; $self->unpack_cnt($buff); close($fileCNT); return $self->{cnt}; } =head2 unpack_cnt Unpack one of two 26 bytes fixed length record in C<.CNT> file. Here is definition of record: off key description size 0: IDTYPE BTree type s 2: ORDN Nodes Order s 4: ORDF Leafs Order s 6: N Number of Memory buffers for nodes s 8: K Number of buffers for first level index s 10: LIV Current number of Index Levels s 12: POSRX Pointer to Root Record in N0x l 16: NMAXPOS Next Available position in N0x l 20: FMAXPOS Next available position in L0x l 24: ABNORMAL Formal BTree normality indicator s length: 26 bytes This will fill C<$self> object under C with hash. It's used by C. =cut sub unpack_cnt { my $self = shift; my @flds = qw(ORDN ORDF N K LIV POSRX NMAXPOS FMAXPOS ABNORMAL); my $buff = shift || return; my @arr = unpack("vvvvvvVVVv", $buff); print STDERR "unpack_cnt: ",join(" ",@arr),"\n" if ($self->{'debug'}); my $IDTYPE = shift @arr; foreach (@flds) { $self->{cnt}->{$IDTYPE}->{$_} = abs(shift @arr); } } 1; =head1 BUGS Some parts of CDS/ISIS documentation are not detailed enough to exmplain some variations in input databases which has been tested with this module. When I was in doubt, I assumed that OpenIsis's implementation was right (except for obvious bugs). However, every effort has been made to test this module with as much databases (and programs that create them) as possible. I would be very greatful for success or failure reports about usage of this module with databases from programs other than WinIsis and IsisMarc. I had tested this against ouput of one C-based application, but I don't know any details about it's version. =head1 AUTHOR Dobrica Pavlinusic CPAN ID: DPAVLIN dpavlin@rot13.org http://www.rot13.org/~dpavlin/ This module is based heavily on code from C library to read ISIS files V0.1.1 written in php and (c) 2000 Franck Martin and released under LGPL. =head1 COPYRIGHT This program is free software; you can redistribute it and/or modify it under the same terms as Perl itself. The full text of the license can be found in the LICENSE file included with this module. =head1 SEE ALSO OpenIsis web site L perl4lib site L