1 package OpenILS::Application::Search::Browse;
2 use base qw/OpenILS::Application/;
3 use strict; use warnings;
5 # Most of this code is copied directly from ../../WWW/EGCatLoader/Browse.pm
6 # and modified to be API-compatible.
8 use Digest::MD5 qw/md5_hex/;
9 use Apache2::Const -compile => qw/OK/;
11 use List::Util qw/first/;
13 use OpenSRF::Utils::Logger qw/$logger/;
14 use OpenILS::Utils::CStoreEditor qw/:funcs/;
15 use OpenILS::Utils::Fieldmapper;
16 use OpenILS::Utils::Normalize qw/search_normalize/;
17 use OpenILS::Application::AppUtils;
18 use OpenSRF::Utils::JSON;
19 use OpenSRF::Utils::Cache;
20 use OpenSRF::Utils::SettingsClient;
22 my $U = 'OpenILS::Application::AppUtils';
26 sub initialize { return 1; }
29 if (not defined $browse_cache) {
30 my $conf = new OpenSRF::Utils::SettingsClient;
32 $browse_timeout = $conf->config_value(
33 "apps", "open-ils.search", "app_settings", "cache_timeout"
35 $browse_cache = new OpenSRF::Utils::Cache("global");
39 __PACKAGE__->register_method(
41 api_name => "open-ils.search.browse.staff",
44 desc => q/Bib + authority browse/,
47 name => 'Browse Parameters',
48 desc => q/Hash of arguments:
50 -- title, author, subject, series
54 -- context org unit ID
56 -- copy location filter ID
58 -- return this many results
67 __PACKAGE__->register_method(
69 api_name => "open-ils.search.browse",
72 desc => q/See open-ils.search.browse.staff/
77 my ($self, $client, $params) = @_;
79 $params->{staff} = 1 if $self->api_name =~ /staff/;
80 my ($cache_key, @params) = prepare_browse_parameters($params);
82 my $results = $browse_cache->get_cache($cache_key);
86 new_editor()->json_query({from => ['metabib.browse', @params]});
88 $browse_cache->put_cache($cache_key, $results, $browse_timeout);
92 my ($warning, $alternative) =
93 leading_article_test($params->{browse_class}, $params->{term});
95 for my $result (@$results) {
96 $result->{leading_article_warning} = $warning;
97 $result->{leading_article_alternative} = $alternative;
98 flesh_browse_results([$result]);
99 $client->respond($result);
106 # Returns cache key and a list of parameters for DB proc metabib.browse().
107 sub prepare_browse_parameters {
110 no warnings 'uninitialized';
113 $params->{browse_class},
116 $params->{copy_location_group},
117 $params->{staff} ? 't' : 'f',
119 $params->{limit} || 10
123 "oils_browse_" . md5_hex(OpenSRF::Utils::JSON->perl2JSON(\@params)),
128 sub leading_article_test {
129 my ($browse_class, $bterm) = @_;
131 my $flag_name = "opac.browse.warnable_regexp_per_class";
132 my $flag = new_editor()->retrieve_config_global_flag($flag_name);
134 return unless $flag->enabled eq 't';
140 eval { $map = OpenSRF::Utils::JSON->JSON2perl($flag->value); };
142 $logger->warn("cgf '$flag_name' enabled but value is invalid JSON? $@");
146 # Don't crash over any of the things that could go wrong in here:
148 if ($map->{$browse_class}) {
149 if ($bterm =~ qr/$map->{$browse_class}/i) {
151 ($alternative = $bterm) =~ s/$map->{$browse_class}//;
157 $logger->warn("cgf '$flag_name' has valid JSON in value, but: $@");
160 return ($warning, $alternative);
163 # flesh_browse_results() attaches data from authority records. It
164 # changes $results and returns 1 for success, undef for failure
165 # $results must be an arrayref of result rows from the DB's metabib.browse()
166 sub flesh_browse_results {
169 for my $authority_field_name ( qw/authorities sees/ ) {
170 for my $r (@$results) {
171 # Turn comma-seprated strings of numbers in "authorities" and "sees"
172 # columns into arrays.
173 if ($r->{$authority_field_name}) {
174 $r->{$authority_field_name} = [split /,/, $r->{$authority_field_name}];
176 $r->{$authority_field_name} = [];
178 $r->{"list_$authority_field_name"} = [ @{$r->{$authority_field_name} } ];
181 # Group them in one arrray, not worrying about dupes because we're about
182 # to use them in an IN () comparison in a SQL query.
183 my @auth_ids = map { @{$_->{$authority_field_name}} } @$results;
186 # Get all linked authority records themselves
187 my $linked = new_editor()->json_query({
189 are => [qw/id marc control_set/],
190 aalink => [{column => "target", transform => "array_agg",
197 fkey => "id", field => "source"
201 where => {"+are" => {id => \@auth_ids}}
204 map_authority_headings_to_results(
205 $linked, $results, \@auth_ids, $authority_field_name);
212 sub map_authority_headings_to_results {
213 my ($linked, $results, $auth_ids, $authority_field_name) = @_;
215 # Use the linked authority records' control sets to find and pick
216 # out non-main-entry headings. Build the headings and make a
217 # combined data structure for the template's use.
218 my %linked_headings_by_auth_id = map {
219 $_->{id} => find_authority_headings_and_notes($_)
222 # Avoid sending the full MARC blobs to the caller.
223 delete $_->{marc} for @$linked;
225 # Graft this authority heading data onto our main result set at the
226 # named column, either "authorities" or "sees".
227 foreach my $row (@$results) {
228 $row->{$authority_field_name} = [
229 map { $linked_headings_by_auth_id{$_} } @{$row->{$authority_field_name}}
233 # Get linked-bib counts for each of those authorities, and put THAT
234 # information into place in the data structure.
235 my $counts = new_editor()->json_query({
238 {column => "id", transform => "count",
239 alias => "count", aggregate => 1},
248 $U->unique_unnested_numbers(map { $_->{target} } @$linked)
254 my %auth_counts = map { $_->{authority} => $_->{count} } @$counts;
256 # Soooo nesty! We look for places where we'll need a count of bibs
257 # linked to an authority record, and put it there for the template to find.
258 for my $row (@$results) {
259 for my $auth (@{$row->{$authority_field_name}}) {
260 if ($auth->{headings}) {
261 for my $outer_heading (@{$auth->{headings}}) {
262 for my $heading_blob (@{(values %$outer_heading)[0]}) {
263 if ($heading_blob->{target}) {
264 $heading_blob->{target_count} =
265 $auth_counts{$heading_blob->{target}};
275 # TOOD consider locale-aware caching
277 my $control_set = shift;
279 my $acs = new_editor()
280 ->search_authority_control_set_authority_field(
281 {control_set => $control_set}
284 return { map { $_->id => $_ } @$acs };
287 sub find_authority_headings_and_notes {
290 my $acsaf_table = get_acsaf($row->{control_set});
292 $row->{headings} = [];
296 $record = new_from_xml MARC::Record($row->{marc});
300 $logger->warn("Problem with MARC from authority record #" .
301 $row->{id} . ": $@");
302 return $row; # We're called in map(), so we must move on without
306 extract_public_general_notes($record, $row);
308 # extract headings from the main authority record along with their
310 my $parsed_headings = new_editor()->json_query({
311 from => ['authority.extract_headings', $row->{marc}]
313 my %heading_type_map = ();
314 if ($parsed_headings) {
315 foreach my $h (@$parsed_headings) {
316 $heading_type_map{$h->{normalized_heading}} =
317 $h->{purpose} eq 'variant' ? 'variant' :
318 $h->{purpose} eq 'related' ? $h->{related_type} :
323 # By applying grep in this way, we get acsaf objects that *have* and
324 # therefore *aren't* main entries, which is what we want.
325 foreach my $acsaf (values(%$acsaf_table)) {
326 my @fields = $record->field($acsaf->tag);
327 my %sf_lookup = map { $_ => 1 } split("", $acsaf->display_sf_list);
330 foreach my $field (@fields) {
331 my $h = { main_entry => ( $acsaf->main_entry ? 0 : 1 ),
332 heading => get_authority_heading($field, \%sf_lookup, $acsaf->joiner) };
334 my $norm = search_normalize($h->{heading});
335 if (exists $heading_type_map{$norm}) {
336 $h->{type} = $heading_type_map{$norm};
338 # XXX I was getting "target" from authority.authority_linking, but
339 # that makes no sense: that table can only tell you that one
340 # authority record as a whole points at another record. It does
341 # not record when a specific *field* in one authority record
342 # points to another record (not that it makes much sense for
343 # one authority record to have links to multiple others, but I can't
344 # say there definitely aren't cases for that).
346 if ($field->subfield('0') || "") =~ /(^|\))(\d+)$/;
348 # The target is the row id if this is a main entry...
349 $h->{target} = $row->{id} if $h->{main_entry};
354 push @{$row->{headings}}, {$acsaf->id => \@headings} if @headings;
361 # Break out any Public General Notes (field 680) for display. These are
362 # sometimes (erroneously?) called "scope notes." I say erroneously,
363 # tentatively, because LoC doesn't seem to document a "scope notes"
364 # field for authority records, while it does so for classification
365 # records, which are something else. But I am not a librarian.
366 sub extract_public_general_notes {
367 my ($record, $row) = @_;
369 # Make a list of strings, each string being a concatentation of any
370 # subfields 'i', '5', or 'a' from one field 680, in order of appearance.
375 map { $_->[1] } grep { $_->[0] =~ /[i5a]/ } $_->subfields
377 } $record->field('680')
381 sub get_authority_heading {
382 my ($field, $sf_lookup, $joiner) = @_;
388 map { $_->[1] } grep { $sf_lookup->{$_->[0]} } $field->subfields