Merge branch 'opac-tt-poc' of ssh://senator@yeti.esilibrary.com/home/evergreen/evergr...
[working/Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / Application / Search / Z3950.pm
1 package OpenILS::Application::Search::Z3950;
2 use strict; use warnings;
3 use base qw/OpenILS::Application/;
4
5 use OpenILS::Utils::ZClient;
6 use MARC::Record;
7 use MARC::File::XML;
8 use Unicode::Normalize;
9 use XML::LibXML;
10
11 use OpenILS::Event;
12 use OpenSRF::EX qw(:try);
13 use OpenILS::Utils::ModsParser;
14 use OpenSRF::Utils::SettingsClient;
15 use OpenILS::Application::AppUtils;
16 use OpenSRF::Utils::Logger qw/$logger/;
17 use OpenILS::Utils::CStoreEditor q/:funcs/;
18
19 my $output = "usmarc"; 
20 my $U = 'OpenILS::Application::AppUtils'; 
21
22 my $sclient;
23 my %services;
24 my $default_service;
25
26
27 __PACKAGE__->register_method(
28     method    => 'do_class_search',
29     api_name  => 'open-ils.search.z3950.search_class',
30     stream    => 1,
31     signature => q/
32         Performs a class based Z search.  The classes available
33         are defined by the 'attr' fields in the config for the
34         requested service.
35         @param auth The login session key
36         @param shash The search hash : { attr : value, attr2: value, ...}
37         @param service The service to connect to
38         @param username The username to use when connecting to the service
39         @param password The password to use when connecting to the service
40     /
41 );
42
43 __PACKAGE__->register_method(
44     method    => 'do_service_search',
45     api_name  => 'open-ils.search.z3950.search_service',
46     signature => q/
47         @param auth The login session key
48         @param query The Z3950 search string to use
49         @param service The service to connect to
50         @param username The username to use when connecting to the service
51         @param password The password to use when connecting to the service
52     /
53 );
54
55
56 __PACKAGE__->register_method(
57     method    => 'do_service_search',
58     api_name  => 'open-ils.search.z3950.search_raw',
59     signature => q/
60         @param auth The login session key
61         @param args An object of search params which must include:
62             host, port, db and query.  
63             optional fields include username and password
64     /
65 );
66
67
68 __PACKAGE__->register_method(
69     method    => "query_services",
70     api_name  => "open-ils.search.z3950.retrieve_services",
71     signature => q/
72         Returns a list of service names that we have config
73         data for
74     /
75 );
76
77
78
79 # -------------------------------------------------------------------
80 # What services do we have config info for?
81 # -------------------------------------------------------------------
82 sub query_services {
83     my( $self, $client, $auth ) = @_;
84     my $e = new_editor(authtoken=>$auth);
85     return $e->event unless $e->checkauth;
86     return $e->event unless $e->allowed('REMOTE_Z3950_QUERY');
87
88     return fetch_service_defs();
89 }
90
91 # -------------------------------------------------------------------
92 # What services do we have config info for?
93 # -------------------------------------------------------------------
94 sub fetch_service_defs {
95
96     my $hash = $sclient->config_value('z3950', 'services');
97
98     # overlay config file values with in-db values
99     my $e = new_editor();
100     if($e->can('search_config_z3950_source')) {
101
102         my $sources = $e->search_config_z3950_source(
103             [ { name => { '!=' => undef } },
104               { flesh => 1, flesh_fields => { czs => ['attrs'] } } ]
105         );
106
107         for my $s ( @$sources ) {
108             $$hash{ $s->name } = {
109                 name => $s->name,
110                 label => $s->label,
111                 host => $s->host,
112                 port => $s->port,
113                 db => $s->db,
114                 record_format => $s->record_format,
115                 transmission_format => $s->transmission_format,
116                 auth => $s->auth,
117             };
118
119             for my $a ( @{ $s->attrs } ) {
120                 $$hash{ $a->source }{attrs}{ $a->name } = {
121                     name => $a->name,
122                     label => $a->label,
123                     code => $a->code,
124                     format => $a->format,
125                     source => $a->source,
126                     truncation => $a->truncation,
127                 };
128             }
129         }
130     }
131
132     # Define the set of native catalog services
133     # XXX There are i18n problems here, but let's get the staff client working first
134     # XXX Move into the DB?
135     $hash->{'native-evergreen-catalog'} = {
136         attrs => {
137             title => {code => 'title', label => 'Title'},
138             author => {code => 'author', label => 'Author'},
139             subject => {code => 'subject', label => 'Subject'},
140             keyword => {code => 'keyword', label => 'Keyword'},
141             tcn => {code => 'tcn', label => 'TCN'},
142             isbn => {code => 'isbn', label => 'ISBN'},
143             issn => {code => 'issn', label => 'ISSN'},
144             publisher => {code => 'publisher', label => 'Publisher'},
145             pubdate => {code => 'pubdate', label => 'Pub Date'},
146             item_type => {code => 'item_type', label => 'Item Type'},
147         }
148     };
149
150     %services = %$hash; # cache these internally so we can actually use the db-configured sources
151     return $hash;
152 }
153
154
155
156 # -------------------------------------------------------------------
157 # Load the pre-defined Z server configs
158 # -------------------------------------------------------------------
159 sub child_init {
160     $sclient = OpenSRF::Utils::SettingsClient->new();
161     $default_service = $sclient->config_value("z3950", "default" );
162 }
163
164
165 # -------------------------------------------------------------------
166 # High-level class based search. 
167 # -------------------------------------------------------------------
168 sub do_class_search {
169
170     fetch_service_defs() unless (scalar(keys(%services)));
171
172     my $self = shift;
173     my $conn = shift;
174     my $auth = shift;
175     my $args = shift;
176
177     if (!ref($$args{service})) {
178         $$args{service} = [$$args{service}];
179         $$args{username} = [$$args{username}];
180         $$args{password} = [$$args{password}];
181     }
182
183     $$args{async} = 1;
184
185     my @connections;
186     my @results;
187     my @services; 
188     for (my $i = 0; $i < @{$$args{service}}; $i++) {
189         my %tmp_args = %$args;
190         $tmp_args{service} = $$args{service}[$i];
191         $tmp_args{username} = $$args{username}[$i];
192         $tmp_args{password} = $$args{password}[$i];
193
194         $logger->debug("z3950: service: $tmp_args{service}, async: $tmp_args{async}");
195
196         if ($tmp_args{service} eq 'native-evergreen-catalog') { 
197             my $method = $self->method_lookup('open-ils.search.biblio.zstyle.staff'); 
198             $conn->respond( 
199                 $self->method_lookup('open-ils.search.biblio.zstyle.staff')->run($auth, \%tmp_args) 
200             ); 
201
202         } else { 
203
204             $tmp_args{query} = compile_query('and', $tmp_args{service}, $tmp_args{search}); 
205     
206             my $res = do_service_search( $self, $conn, $auth, \%tmp_args ); 
207     
208             if ($U->event_code($res)) { 
209                 $conn->respond($res) if $U->event_code($res); 
210
211             } else { 
212                 push @services, $tmp_args{service}; 
213                 push @results, $res->{result}; 
214                 push @connections, $res->{connection}; 
215             } 
216         }
217
218         $logger->debug("z3950: Result object: $results[$i], Connection object: $connections[$i]");
219     }
220
221     $logger->debug("z3950: Connections created");
222
223     return undef unless (@connections);
224     my @records;
225
226     # local catalog search is not processed with other z39 results;
227     $$args{service} = [grep {$_ ne 'native-evergreen-catalog'} @{$$args{service}}];
228
229     @connections = grep {defined $_} @connections;
230     return undef unless @connections;
231
232     while ((my $index = OpenILS::Utils::ZClient::event( \@connections )) != 0) {
233         my $ev = $connections[$index - 1]->last_event();
234         $logger->debug("z3950: Received event $ev");
235         if ($ev == OpenILS::Utils::ZClient::EVENT_END()) {
236             my $munged = process_results( $results[$index - 1], $$args{limit}, $$args{offset}, $$args{service}[$index -1] );
237             $$munged{service} = $$args{service}[$index - 1];
238             $conn->respond($munged);
239         }
240     }
241
242     $logger->debug("z3950: Search Complete");
243     return undef;
244 }
245
246
247 # -------------------------------------------------------------------
248 # This handles the host settings, but expects a fully formed z query
249 # -------------------------------------------------------------------
250 sub do_service_search {
251
252     fetch_service_defs() unless (scalar(keys(%services)));
253
254     my $self = shift;
255     my $conn = shift;
256     my $auth = shift;
257     my $args = shift;
258     
259     my $info = $services{$$args{service}};
260
261     $$args{host} = $$info{host};
262     $$args{port} = $$info{port};
263     $$args{db} = $$info{db};
264     $logger->debug("z3950: do_search...");
265
266     return do_search( $self, $conn, $auth, $args );
267 }
268
269
270
271 # -------------------------------------------------------------------
272 # This is the low level search method.  All config and query
273 # data must be provided to this method
274 # -------------------------------------------------------------------
275 sub do_search {
276
277     fetch_service_defs() unless (scalar(keys(%services)));
278
279     my $self = shift;
280     my $conn = shift;
281     my $auth = shift;
282     my $args = shift;
283
284     my $host = $$args{host} or return undef;
285     my $port = $$args{port} or return undef;
286     my $db = $$args{db} or return undef;
287     my $query = $$args{query} or return undef;
288     my $async = $$args{async} || 0;
289
290     my $limit = $$args{limit} || 10;
291     my $offset = $$args{offset} || 0;
292
293     my $username = $$args{username} || "";
294     my $password = $$args{password} || "";
295
296     my $tformat = $services{$args->{service}}->{transmission_format} || $output;
297
298     my $editor = new_editor(authtoken => $auth);
299     return $editor->event unless $editor->checkauth;
300     return $editor->event unless $editor->allowed('REMOTE_Z3950_QUERY');
301
302     $logger->info("z3950: connecting to server $host:$port:$db as $username");
303
304     my $connection = OpenILS::Utils::ZClient->new(
305         $host, $port,
306         databaseName => $db, 
307         user => $username,
308         password => $password,
309         async => $async,
310         preferredRecordSyntax => $tformat, 
311     );
312
313     if( ! $connection ) {
314         $logger->error("z3950: Unable to connect to Z server: ".
315             "$host:$port:$db:$username:$password");
316         return OpenILS::Event->new('Z3950_LOGIN_FAILED') unless $connection;
317     }
318
319     my $start = time;
320     my $results;
321     my $err;
322
323     $logger->info("z3950: query => $query");
324
325     try {
326         $results = $connection->search_pqf( $query );
327     } catch Error with { $err = shift; };
328
329     return OpenILS::Event->new(
330         'Z3950_BAD_QUERY', payload => $query, debug => "$err") if $err;
331
332     return OpenILS::Event->new('Z3950_SEARCH_FAILED', 
333         debug => $connection->errcode." => ".$connection->errmsg." : query = $query") unless $results;
334
335     $logger->info("z3950: search [$query] took ".(time - $start)." seconds");
336
337     return {result => $results, connection => $connection} if ($async);
338
339     my $munged = process_results($results, $limit, $offset, $$args{service});
340     $munged->{query} = $query;
341
342     return $munged;
343 }
344
345
346 # -------------------------------------------------------------------
347 # Takes a result batch and returns the hitcount and a list of xml
348 # and mvr objects
349 # -------------------------------------------------------------------
350 sub process_results {
351
352     fetch_service_defs() unless (scalar(keys(%services)));
353
354     my $results = shift;
355     my $limit = shift || 10;
356     my $offset = shift || 0;
357     my $service = shift;
358
359     my $rformat = $services{$service}->{record_format};
360     my $tformat = $services{$service}->{transmission_format} || $output;
361
362     $results->option(elementSetName => $rformat);
363     $results->option(preferredRecordSyntax => $tformat);
364     $logger->info("z3950: using record format '$rformat' and transmission format '$tformat'");
365
366     my @records;
367     my $res = {};
368     my $count = $$res{count} = $results->size;
369
370     $logger->info("z3950: search returned $count hits");
371
372     my $tend = $limit + $offset;
373
374     my $end = ($tend <= $count) ? $tend : $count;
375
376     for($offset..$end - 1) {
377
378         my $err;
379         my $mods;
380         my $marc;
381         my $marcs;
382         my $marcxml;
383
384         $logger->info("z3950: fetching record $_");
385
386         try {
387
388             my $rec = $results->record($_);
389
390             if ($tformat eq 'usmarc') {
391                 $marc = MARC::Record->new_from_usmarc($rec->raw());
392             } elsif ($tformat eq 'xml') {
393                 $marc = MARC::Record->new_from_xml($rec->raw());
394             } else {
395                 die "Unsupported record transmission format $tformat"
396             }
397
398             $marcs = $U->entityize($marc->as_xml_record);
399             $marcs = $U->strip_ctrl_chars($marcs);
400             my $doc = XML::LibXML->new->parse_string($marcs);
401             $marcxml = $U->entityize($doc->documentElement->toString);
402             $marcxml = $U->strip_ctrl_chars($marcxml);
403     
404             my $u = OpenILS::Utils::ModsParser->new();
405             $u->start_mods_batch( $marcxml );
406             $mods = $u->finish_mods_batch();
407     
408
409         } catch Error with { $err = shift; };
410
411         push @records, { 'mvr' => $mods, 'marcxml' => $marcxml } unless $err;
412         $logger->error("z3950: bad XML : $err") if $err;
413
414         if( $err ) {
415             warn "\n\n$marcs\n\n";
416         }
417     }
418     
419     $res->{records} = \@records;
420     return $res;
421 }
422
423
424
425 # -------------------------------------------------------------------
426 # Compiles the class based search query
427 # -------------------------------------------------------------------
428 sub compile_query {
429
430     fetch_service_defs() unless (scalar(keys(%services)));
431
432     my $separator = shift;
433     my $service = shift;
434     my $hash = shift;
435
436     my $count = scalar(keys %$hash);
437
438     my $str = "";
439     $str .= "\@$separator " for (1..$count-1);
440     
441     # -------------------------------------------------------------------
442     # "code" is the bib-1 "use attribute", "format" is the bib-1 
443     # "structure attribute"
444     # -------------------------------------------------------------------
445     for( keys %$hash ) {
446         next unless ( exists $services{$service}->{attrs}->{$_} );
447         $str .= '@attr 1=' . $services{$service}->{attrs}->{$_}->{code} . # add the use attribute
448             ' @attr 4=' . $services{$service}->{attrs}->{$_}->{format}; # add the structure attribute
449         if (exists $services{$service}->{attrs}->{$_}->{truncation}
450                 && $services{$service}->{attrs}->{$_}->{truncation} >= 0) {
451             $str .= ' @attr 5=' . $services{$service}->{attrs}->{$_}->{truncation};
452         }
453         $str .= " \"" . $$hash{$_} . "\" "; # add the search term
454     }
455     return $str;
456 }
457
458 1;
459 # vim:et:ts=4:sw=4: