safer MR mapping
[Evergreen.git] / Open-ILS / src / extras / import / marc2bre.pl
1 #!/usr/bin/perl
2 use strict;
3 use warnings;
4
5 use lib '/openils/lib/perl5/';
6
7 use Error qw/:try/;
8 use OpenILS::Utils::Fieldmapper;
9 use Digest::MD5 qw/md5_hex/;
10 use OpenSRF::Utils::JSON;
11 use Data::Dumper;
12 use Unicode::Normalize;
13 use Encode;
14
15 use FileHandle;
16 use Time::HiRes qw/time/;
17 use Getopt::Long;
18 use MARC::Batch;
19 use MARC::File::XML ( BinaryEncoding => 'utf-8' );
20 use MARC::Charset;
21 use DBI;
22
23 #MARC::Charset->ignore_errors(1);
24
25 my ($id_field, $id_subfield, $recid, $user, $config, $idlfile, $marctype, $tcn_offset, $tcn_mapfile, $tcn_dumpfile, $used_id_file, $used_tcn_file, $enc, @files, @trash_fields, @req_fields, $use901, $quiet, $tcn_field, $tcn_subfield) =
26         ('', 'a', 0, 1, '/openils/conf/opensrf_core.xml', '/openils/conf/fm_IDL.xml', 'USMARC', 0);
27
28 my ($db_driver,$db_host,$db_name,$db_user,$db_pw) =
29         ('Pg','localhost','evergreen','postgres','postgres');
30
31 GetOptions(
32         'marctype=s'    => \$marctype, # format of MARC files being processed defaults to USMARC, often set to XML
33         'startid=i'     => \$recid, # id number to start with when auto-assigning id numbers, defaults to highest id in database + 1
34         'idfield=s'     => \$id_field, # field containing the record's desired internal id, NOT tcn
35         'idsubfield=s'  => \$id_subfield, # subfield of above record id field
36         'tcnfield=s'    => \$tcn_field, # field containing the record's desired tcn, NOT the internal id
37         'tcnsubfield=s' => \$tcn_subfield, # subfield of above record tcn field
38         'tcnoffset=i'   => \$tcn_offset, # optionally skip characters at beginning of supplied tcn (e.g. to remove '(Sirsi)')
39         'user=s'        => \$user, # set creator/editor values for records in database
40         'encoding=s'    => \$enc, # set assumed MARC encoding for MARC::Charset
41         'keyfile=s'     => \$tcn_mapfile, # DEPRECATED, use tcn_mapfile instead
42         'tcn_mapfile=s' => \$tcn_mapfile, # external file which allows for matching specific record tcns to specific record ids, format = one id_number|tcn_number combo per line
43         'tcnfile=s'     => \$tcn_dumpfile, # DEPRECATED, use tcn_dumpfile instead
44         'tcn_dumpfile=s'        => \$tcn_dumpfile, # allows specification of a dumpfile for all used tcn values
45         'config=s'      => \$config, # location of OpenSRF core config file, defaults to /openils/conf/opensrf_core.xml
46         'file=s'        => \@files, # files to process (or you can simple list the files as unnamed arguments, i.e. @ARGV)
47         'required_fields=s'     => \@req_fields, # skip any records missing these fields
48         'trash=s'       => \@trash_fields, # fields to remove from all processed records
49         'xml_idl=s'     => \$idlfile, # location of XML IDL file, defaults to /openils/conf/fm_IDL.xml
50         'dontuse=s'     => \$used_id_file, # DEPRECATED, use used_id_file instead
51         'used_id_file=s'        => \$used_id_file, # external file which prevents id collisions by specifying ids already in use in the database, format = one id number per line
52         'used_tcn_file=s'       => \$used_tcn_file, # external file which prevents tcn collisions by specifying tcns already in use in the database, format = one tcn number per line
53         "db_driver=s"   => \$db_driver, # database driver type, usually 'Pg'
54         "db_host=s"     => \$db_host, # database hostname
55         "db_name=s"     => \$db_name, # database name
56         "db_user=s"     => \$db_user, # database username
57         "db_pw=s"       => \$db_pw, # database password
58         'use901'        => \$use901, # use values from previously created 901 fields and skip all other processing
59         'quiet'         => \$quiet # do not output progress count
60 );
61
62 @trash_fields = split(/,/,join(',',@trash_fields));
63 @req_fields = split(/,/,join(',',@req_fields));
64
65 if ($enc) {
66         MARC::Charset->ignore_errors(1);
67         MARC::Charset->assume_encoding($enc);
68 }
69
70 if (uc($marctype) eq 'XML') {
71         'open'->use(':utf8');
72 } else {
73         bytes->use();
74 }
75
76 @files = @ARGV if (!@files);
77
78 my @ses;
79 my @req;
80 my %processing_cache;
81
82 my $dsn = "dbi:$db_driver:host=$db_host;dbname=$db_name";
83
84 if (!$recid) {
85     my $table = 'biblio_record_entry';
86     $table = 'biblio.record_entry' if ($db_driver eq 'Pg');
87
88         my $dbh = DBI->connect($dsn,$db_user,$db_pw);
89         my $sth = $dbh->prepare("SELECT MAX(id) + 1 FROM $table");
90
91         $sth->execute;
92         $sth->bind_col(1, \$recid);
93         $sth->fetch;
94         $sth->finish;
95         $dbh->disconnect;
96 }
97
98 my %tcn_source_map = (
99         a  => 'Sirsi_Auto',
100         o  => 'OCLC',
101         i  => 'ISxN',
102         l  => 'LCCN',
103         s  => 'System',
104         g  => 'Gutenberg',
105         z  => 'Unknown',
106 );
107
108 Fieldmapper->import(IDL => $idlfile);
109
110 my %tcn_map;
111 if ($tcn_mapfile) {
112         open F, $tcn_mapfile or die "Couldn't open key file $tcn_mapfile";
113         while (<F>) {
114                 if ( /^(\d+)\|(\S+)/o ) {
115                         $tcn_map{$1} = $2;
116                 }
117         }
118         close(F);
119 }
120
121 my %used_recids;
122 if ($used_id_file) {
123         open F, $used_id_file or die "Couldn't open used-id file $used_id_file";
124         while (<F>) {
125                 chomp;
126                 s/^\s*//;
127                 s/\s*$//;
128                 $used_recids{$_} = 1;
129         }
130         close(F);
131 }
132
133 my %used_tcns;
134 if ($used_tcn_file) {
135         open F, $used_tcn_file or die "Couldn't open used-tcn file $used_tcn_file";
136         while (<F>) {
137                 chomp;
138                 s/^\s*//;
139                 s/\s*$//;
140                 $used_tcns{$_} = 1;
141         }
142         close(F);
143 }
144
145 select STDERR; $| = 1;
146 select STDOUT; $| = 1;
147
148 my $batch = new MARC::Batch ( $marctype, @files );
149 $batch->strict_off();
150 $batch->warnings_off();
151
152 my $starttime = time;
153 my $rec;
154 my $count = 0;
155 PROCESS: while ( try { $rec = $batch->next } otherwise { $rec = -1 } ) {
156         next if ($rec == -1);
157
158         $count++;
159
160         # Skip records that don't contain a required field (like '245', for example)
161         foreach my $req_field (@req_fields) {
162                 if (!$rec->field("$req_field")) {
163                         warn "\n!!! Record $count missing required field $req_field, skipping record.\n";
164                         next PROCESS;
165                 }
166         }
167
168         my $id;
169         my $tcn_value = '';
170         my $tcn_source = '';
171         # If $use901 is set, use it for the id, the tcn, and the tcn source without ANY further processing (i.e. no error checking)
172         if ($use901) {
173                 $rec->delete_field($_) for ($rec->field(@trash_fields));
174                 $tcn_value = $rec->subfield('901' => 'a');
175                 $tcn_source = $rec->subfield('901' => 'b');
176                 $id = $rec->subfield('901' => 'c');
177         } else {
178                 # This section of code deals with the record's 'id', which is a system-level, numeric, internal identifier
179                 # It is often convenient but not necessary to carry over the internal ids from your previous ILS, so here is where that happens
180                 if ($id_field) {
181                         my $field = $rec->field($id_field);
182                         if ($field) {
183                                 if ($field->is_control_field) {
184                                         $id = $field->data;
185                                 } else {
186                                         $id = $field->subfield($id_subfield);
187                                 }
188                                 # ensure internal record ids are numeric only
189                                 $id =~ s/\D+//gso if $id;
190                         }
191
192                         # catch problem ids
193                         if (!$id) {
194                                 warn "\n!!! Record $count has missing or invalid id field $id_field, assinging new id.\n";
195                                 $id = '';
196                         } elsif (exists $used_recids{$id}) {
197                                 warn "\n!!! Record $count has a duplicate id in field $id_field, assinging new id.\n";
198                                 $id = '';
199                         } else {
200                                 $used_recids{$id} = 1;
201                         }
202                 }
203
204                 # id field not specified or found to be invalid, assign auto id
205                 if (!$id) {
206                         while (exists $used_recids{$recid}) {
207                                 $recid++;
208                         }
209                         $used_recids{$recid} = 1;
210                         $id = $recid;
211                         $recid++;
212                 }
213
214                 # This section of code deals with the record's 'tcn', or title control number, which is a record-level, possibly alpha-numeric, sometimes user-supplied value
215                 if ($tcn_field) {
216                         if ($tcn_mapfile) {
217                                 if (my $tcn = $tcn_map{$id}) {
218                                         $rec->delete_field( $_ ) for ($rec->field($tcn_field));
219                                         $rec->append_fields( MARC::Field->new( $tcn_field, '', '', $tcn_subfield, $tcn ) );
220                                 } else {
221                                         warn "\n!!! ID $id not found in tcn_mapfile, skipping record.\n";
222                                         $count++;
223                                         next;
224                                 }
225                         }
226
227                         my $field = $rec->field($tcn_field);
228                         if ($field) {
229                                 if ($field->is_control_field) {
230                                         $tcn_value = $field->data;
231                                 } else {
232                                         $tcn_value = $field->subfield($tcn_subfield);
233                                 }
234                                 # $tcn_offset is another Sirsi influence, as it will allow you to remove '(Sirsi)'
235                                 # from exported tcns, but was added more generically to perhaps support other use cases
236                                 if ($tcn_value) { 
237                                         $tcn_value = substr($tcn_value, $tcn_offset);
238                                 } else {
239                                         $tcn_value = '';
240                                 }
241                         }
242                 }
243
244                 # turn our id and tcn into a 901 field, and also create a tcn and/or figure out the tcn source
245                 my $field901;
246                 ($field901, $tcn_value, $tcn_source) = preprocess($rec, $tcn_value, $id);
247                 # delete the old identifier and trash fields
248                 $rec->delete_field($_) for ($rec->field('901', $tcn_field, $id_field, @trash_fields));
249                 $rec->append_fields($field901);
250         }
251
252         (my $xml = $rec->as_xml_record()) =~ s/\n//sog;
253         $xml =~ s/^<\?xml.+\?\s*>//go;
254         $xml =~ s/>\s+</></go;
255         $xml =~ s/\p{Cc}//go;
256         $xml = entityize($xml,'D');
257         $xml =~ s/[\x00-\x1f]//go;
258
259         my $bib = new Fieldmapper::biblio::record_entry;
260         $bib->id($id);
261         $bib->active('t');
262         $bib->deleted('f');
263         $bib->marc($xml);
264         $bib->creator($user);
265         $bib->create_date('now');
266         $bib->editor($user);
267         $bib->edit_date('now');
268         $bib->tcn_source($tcn_source);
269         $bib->tcn_value($tcn_value);
270         $bib->last_xact_id('IMPORT-'.$starttime);
271
272         print OpenSRF::Utils::JSON->perl2JSON($bib)."\n";
273         $used_tcns{$tcn_value} = 1;
274
275         if (!$quiet && !($count % 50)) {
276                 print STDERR "\r$count\t". $count / (time - $starttime);
277         }
278 }
279
280 if ($tcn_dumpfile) {
281     open TCN_DUMPFILE, '>', $tcn_dumpfile;
282     print TCN_DUMPFILE "$_\n" for (keys %used_tcns);
283 }
284
285
286 sub preprocess {
287         my $rec = shift;
288         my $tcn_value = shift;
289         my $id = shift;
290
291         my $tcn_source = '';
292         # in the following code, $tcn_number represents the portion of the tcn following the source code-letter
293         my $tcn_number = '';
294         my $warn = 0;
295         my $passed_tcn = '';
296
297         # this preprocess subroutine is optimized for Sirsi-created tcns, that is, those with a single letter
298         # followed by some digits (and maybe 'x' in older systems).  If using user supplied tcns, try to identify
299         # the source here, otherwise set to 'z' ('Unknown')
300         if ($tcn_value =~ /([a-z])([0-9xX]+)/) {
301                 $tcn_source = $1;
302                 $tcn_number = $2;
303         } else {
304                 $tcn_source = 'z';
305         }
306         
307         # save and warn if a passed in TCN is replaced  
308         if ($tcn_value && exists $used_tcns{$tcn_value}) {
309                 $passed_tcn = $tcn_value;
310                 $tcn_value = '';
311                 $tcn_number = '';
312                 $tcn_source = '';
313                 $warn = 1;
314         } 
315
316         # we didn't have a user supplied tcn, or it was a duplicate, so let's derive one from commonly unique record fields
317         if (!$tcn_value) {
318                 my $f = $rec->field('001');
319                 $tcn_value = despace($f->data) if ($f);
320         }
321
322         if (!$tcn_value || exists $used_tcns{$tcn_value}) {
323                 my $f = $rec->field('000');
324                 if ($f) {
325                         $tcn_number = despace($f->data);
326                         $tcn_source = 'g'; # only Project Gutenberg seems to use this
327                         $tcn_value = $tcn_source.$tcn_number;
328                 }
329         }
330
331     if (!$tcn_value || exists $used_tcns{$tcn_value}) {
332         my $f = $rec->field('020');
333                 if ($f) {       
334                         $tcn_number = despace($f->subfield('a'));
335                         $tcn_source = 'i';
336                         $tcn_value = $tcn_source.$tcn_number;
337                 }
338     }
339
340     if (!$tcn_value || exists $used_tcns{$tcn_value}) {
341         my $f = $rec->field('022');
342                 if ($f) {       
343                         $tcn_number = despace($f->subfield('a'));
344                         $tcn_source = 'i';
345                         $tcn_value = $tcn_source.$tcn_number;
346                 }
347     }
348
349     if (!$tcn_value || exists $used_tcns{$tcn_value}) {
350         my $f = $rec->field('010');
351                 if ($f) {       
352                         $tcn_number = despace($f->subfield('a'));
353                         $tcn_source = 'l';
354                         $tcn_value = $tcn_source.$tcn_number;
355                 }
356     }
357
358     if (!$tcn_value || exists $used_tcns{$tcn_value}) {
359                 $tcn_source = 's';
360                 $tcn_number = $id;
361                 $tcn_value = $tcn_source.$tcn_number;
362     }
363
364         # special case to catch possibly passed in full OCLC numbers and those derived from the 001 field
365         if ($tcn_value =~ /^oc(m|n)(\d+)$/o) {
366                 $tcn_source = 'o';
367                 $tcn_number = $2;
368                 $tcn_value = $tcn_source.$tcn_number;
369         }
370
371         # expand $tcn_source from code letter to full name
372         $tcn_source = do { $tcn_source_map{$tcn_source} || 'Unknown' };
373
374         if ($warn) {
375                 warn "\n!!! TCN $passed_tcn is already in use, using TCN ($tcn_value) derived from $tcn_source ID.\n";
376         }
377
378         my $field901 = MARC::Field->new(
379                 '901' => ('', ''),
380                 a => $tcn_value,
381                 b => $tcn_source,
382                 c => $id
383         );
384
385         return ($field901, $tcn_value, $tcn_source);
386 }
387
388 sub entityize {
389         my $stuff = shift;
390         my $form = shift;
391
392         if ($form and $form eq 'D') {
393                 $stuff = NFD($stuff);
394         } else {
395                 $stuff = NFC($stuff);
396         }
397
398         $stuff =~ s/([\x{0080}-\x{fffd}])/sprintf('&#x%X;',ord($1))/sgoe;
399         return $stuff;
400 }
401
402 sub despace {
403         my $value = shift;
404
405         # remove all leading/trailing spaces and trucate at first internal space if present
406         $value =~ s/\s*$//o;
407         $value =~ s/^\s*//o;
408         $value =~ s/^(\S+).*$/$1/o;
409
410         return $value;
411 }