]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Browse.pm
Bib record browser with 'see also', etc from linked authority headings
[working/Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / WWW / EGCatLoader / Browse.pm
1 package OpenILS::WWW::EGCatLoader;
2
3 use strict;
4 use warnings;
5
6 use OpenSRF::Utils::Logger qw/$logger/;
7 use OpenILS::Utils::CStoreEditor qw/:funcs/;
8 use OpenILS::Utils::Fieldmapper;
9 use OpenILS::Utils::Normalize qw/search_normalize/;
10 use OpenILS::Application::AppUtils;
11 use OpenSRF::Utils::JSON;
12 use OpenSRF::Utils::Cache;
13 use OpenSRF::Utils::SettingsClient;
14
15 use Digest::MD5 qw/md5_hex/;
16 use Apache2::Const -compile => qw/OK/;
17 use MARC::Record;
18 #use Data::Dumper;
19 #$Data::Dumper::Indent = 0;
20
21 my $U = 'OpenILS::Application::AppUtils';
22 my $browse_cache;
23 my $browse_timeout;
24
25 # Plain procedural functions start here.
26 #
27 sub _init_browse_cache {
28     if (not defined $browse_cache) {
29         my $conf = new OpenSRF::Utils::SettingsClient;
30
31         $browse_timeout = $conf->config_value(
32             "apps", "open-ils.search", "app_settings", "cache_timeout"
33         ) || 300;
34         $browse_cache = new OpenSRF::Utils::Cache("global");
35     }
36 }
37
38 sub _get_authority_heading {
39     my ($field, $sf_lookup) = @_;
40
41     return join(
42         " ",
43         map { $_->[1] } grep { $sf_lookup->{$_->[0]} } $field->subfields
44     );
45 }
46
47 # Object methods start here.
48 #
49
50 # Returns cache key and a list of parameters for DB proc metabib.browse().
51 sub prepare_browse_parameters {
52     my ($self) = @_;
53
54     no warnings 'uninitialized';
55
56     # XXX TODO add config.global_flag rows for browse limit-limit and
57     # browse offset-limit?
58
59     my $limit = int($self->cgi->param('blimit') || 10);
60     my $offset = int($self->cgi->param('boffset') || 0);
61     my $force_backward = scalar($self->cgi->param('bback'));
62
63     my @params = (
64         scalar($self->cgi->param('qtype')),
65         scalar($self->cgi->param('bterm')),
66         $self->ctx->{copy_location_group_org} ||
67             $self->ctx->{aou_tree}->()->id,
68         $self->ctx->{copy_location_group},
69         $self->ctx->{is_staff} ? 't' : 'f',
70         scalar($self->cgi->param('bpivot')),
71         $force_backward ? 't' : 'f'
72     );
73
74     # We do need $limit, $offset, and $force_backward as part of the
75     # cache key, but we also need to keep them separate from other
76     # parameters for purposes of paging link generation.
77     return (
78         "oils_browse_" . md5_hex(
79             OpenSRF::Utils::JSON->perl2JSON(
80                 [@params, $limit, $offset, $force_backward]
81             )
82         ),
83         $limit, $offset, $force_backward, @params
84     );
85 }
86
87 # Break out any Public General Notes (field 680) for display and
88 # hyperlinking. These are sometimes (erroneously?) called "scope notes."
89 # I say erroneously, tentatively, because LoC doesn't seem to document
90 # a "scope notes" field for authority records, while it does so for
91 # classification records, which are something else. But I am not a
92 # librarian.
93 sub extract_public_general_notes {
94     my ($self, $record, $row) = @_;
95
96     my @notes;
97     foreach my $note ($record->field('680')) {
98         my @note;
99         my $last_heading;
100
101         foreach my $subfield ($note->subfields) {
102             my ($code, $value) = @$subfield;
103
104             if ($code eq 'i') {
105                 push @note, $value;
106             } elsif ($code eq '5') {
107                 if ($last_heading) {
108                     my $org = $self->ctx->{get_aou_by_shortname}->($value);
109                     $last_heading->{org_id} = $org->id if $org;
110                 }
111                 push @note, { institution => $value };
112             } elsif ($code eq 'a') {
113                 $last_heading = {
114                     heading => $value, bterm => search_normalize($value)
115                 };
116                 push @note, $last_heading;
117             }
118         }
119
120         push @notes, \@note if @note;
121     }
122
123     $row->{notes} = \@notes;
124 }
125
126 sub find_authority_headings_and_notes {
127     my ($self, $row) = @_;
128
129     my $acsaf_table =
130         $self->ctx->{get_authority_fields}->($row->{control_set});
131
132     $row->{headings} = [];
133
134     my $record;
135     eval {
136         $record = new_from_xml MARC::Record($row->{marc});
137     };
138     if ($@) {
139         $logger->warn("Problem with MARC from authority record #" .
140             $row->{id} . ": $@");
141         return $row;    # We're called in map(), so we must move on without
142                         # a fuss.
143     }
144
145     $self->extract_public_general_notes($record, $row);
146
147     # By applying grep in this way, we get acsaf objects that *have* and
148     # therefore *aren't* main entries, which is what we want.
149     foreach my $acsaf (grep { $_->main_entry } values(%$acsaf_table)) {
150         my @fields = $record->field($acsaf->tag);
151         my %sf_lookup = map { $_ => 1 } split("", $acsaf->display_sf_list);
152         my @headings;
153
154         foreach my $field (@fields) {
155             my $h = { heading => _get_authority_heading($field, \%sf_lookup) };
156
157             # XXX I was getting "target" from authority.authority_linking, but
158             # that makes no sense: that table can only tell you that one
159             # authority record as a whole points at another record.  It does
160             # not record when a specific *field* in one authority record
161             # points to another record (not that it makes much sense for
162             # one authority record to have links to multiple others, but I can't
163             # say there definitely aren't cases for that).
164             $h->{target} = $2
165                 if ($field->subfield('0') || "") =~ /(^|\))(\d+)$/;
166
167             push @headings, $h;
168         }
169
170         push @{$row->{headings}}, {$acsaf->id => \@headings} if @headings;
171     }
172
173     return $row;
174 }
175
176 sub map_authority_headings_to_results {
177     my ($self, $linked, $results, $auth_ids) = @_;
178
179     # Use the linked authority records' control sets to find and pick
180     # out non-main-entry headings. Build the headings and make a
181     # combined data structure for the template's use.
182     my %linked_headings_by_auth_id = map {
183         $_->{id} => $self->find_authority_headings_and_notes($_)
184     } @$linked;
185
186     # Graft this authority heading data onto our main result set at the
187     # "authorities" column.
188     foreach my $row (@$results) {
189         $row->{authorities} = [
190             map { $linked_headings_by_auth_id{$_} } @{$row->{authorities}}
191         ];
192     }
193
194     # Get linked-bib counts for each of those authorities, and put THAT
195     # information into place in the data structure.
196     my $counts = $self->editor->json_query({
197         select => {
198             abl => [
199                 {column => "id", transform => "count",
200                     alias => "count", aggregate => 1},
201                 "authority"
202             ]
203         },
204         from => {abl => {}},
205         where => {
206             "+abl" => {
207                 authority => [
208                     @$auth_ids,
209                     $U->unique_unnested_numbers(map { $_->{target} } @$linked)
210                 ]
211             }
212         }
213     }) or return;
214
215     my %auth_counts = map { $_->{authority} => $_->{count} } @$counts;
216
217     # Soooo nesty!  We look for places where we'll need a count of bibs
218     # linked to an authority record, and put it there for the template to find.
219     for my $row (@$results) {
220         for my $auth (@{$row->{authorities}}) {
221             if ($auth->{headings}) {
222                 for my $outer_heading (@{$auth->{headings}}) {
223                     for my $heading_blob (@{(values %$outer_heading)[0]}) {
224                         if ($heading_blob->{target}) {
225                             $heading_blob->{target_count} =
226                                 $auth_counts{$heading_blob->{target}};
227                         }
228                     }
229                 }
230             }
231         }
232     }
233 }
234
235 # flesh_browse_results() attaches data from authority records. It
236 # changes $results and returns 1 for success, undef for failure (in which
237 # case $self->editor->event should always point to the reason for failure).
238 # $results must be an arrayref of result rows from the DB's metabib.browse()
239 sub flesh_browse_results {
240     my ($self, $results) = @_;
241
242     # Turn comma-seprated strings of numbers in "authorities" column
243     # into arrays.
244     $_->{authorities} = [split /,/, $_->{authorities}] foreach @$results;
245
246     # Group them in one arrray, not worrying about dupes because we're about
247     # to use them in an IN () comparison in a SQL query.
248     my @auth_ids = map { @{$_->{authorities}} } @$results;
249
250     if (@auth_ids) {
251         # Get all linked authority records themselves
252         my $linked = $self->editor->json_query({
253             select => {
254                 are => [qw/id marc control_set/],
255                 aalink => [{column => "target", transform => "array_agg",
256                     aggregate => 1}]
257             },
258             from => {
259                 are => {
260                     aalink => {
261                         type => "left",
262                         fkey => "id", field => "source"
263                     }
264                 }
265             },
266             where => {"+are" => {id => \@auth_ids}}
267         }) or return;
268
269         $self->map_authority_headings_to_results($linked, $results, \@auth_ids);
270     }
271
272     return 1;
273 }
274
275 sub load_browse_impl {
276     my ($self, $limit, $offset, $force_backward, @params) = @_;
277
278     my $inner_limit = ($offset >= 0 and not $force_backward) ?
279         $limit + 1 : $limit;
280
281     my $results = $self->editor->json_query({
282         from => [
283             "metabib.browse", (@params, $inner_limit, $offset)
284         ]
285     });
286
287     if (not $results) {  # DB error, not empty result set.
288         $logger->warn(
289             "error in browse (direct): " . $self->editor->event->{textcode}
290         );
291         $self->ctx->{browse_error} = 1;
292
293         return;
294     } elsif (not $self->flesh_browse_results($results)) {
295         $logger->warn(
296             "error in browse (flesh): " . $self->editor->event->{textcode}
297         );
298         $self->ctx->{browse_error} = 1;
299
300         return;
301     }
302
303     return $results;
304 }
305
306 # $results can be modified by this function.  This would be simpler
307 # but for the moving pivot concept that helps us avoid paging with
308 # large offsets (slow).
309 sub infer_browse_paging {
310     my ($self, $results, $limit, $offset, $force_backward) = @_;
311
312     # (All these comments assume a default limit of 10).  For typical
313     # not-backwards requests not at the end of the result set, we
314     # should have an eleventh result that tells us what's next.
315     while (scalar @$results > $limit) {
316         $self->ctx->{forward_pivot} = (pop @$results)->{browse_entry};
317         $self->ctx->{more_forward} = 1;
318     }
319
320     # If we're going backwards by pivot id, we don't have an eleventh
321     # result to tell us we can page forward, but we can assume we can
322     # go forward because duh, we followed a link backward to get here.
323     if ($force_backward and $self->cgi->param('bpivot')) {
324         $self->ctx->{forward_pivot} = scalar($self->cgi->param('bpivot'));
325         $self->ctx->{more_forward} = 1;
326     }
327
328     # The pivot that the user can use for going backwards is the first
329     # of the result set.
330     if (@$results) {
331         $self->ctx->{back_pivot} = $results->[0]->{browse_entry};
332     }
333
334     # The result of these tests relate to basic limit/offset paging.
335
336     # This comparison for setting more_forward does not fold into
337     # those for setting more_back.
338     if ($offset < 0 || $force_backward) {
339         $self->ctx->{more_forward} = 1;
340     }
341
342     if ($offset > 0 or (!$force_backward and $self->cgi->param('bpivot'))) {
343         $self->ctx->{more_back} = 1;
344     } elsif (scalar @$results < $limit) {
345         $self->ctx->{more_back} = 0;
346     } elsif (not($self->cgi->param('bterm') eq '0' and
347         not defined $self->cgi->param('bpivot'))) {   # paging links
348         $self->ctx->{more_back} = 1;
349     }
350 }
351
352 sub leading_article_test {
353     my ($self, $qtype, $bterm) = @_;
354
355     my $flag_name = "opac.browse.warnable_regexp_per_class";
356     my $flag = $self->ctx->{get_cgf}->($flag_name);
357
358     return unless $flag->enabled eq 't';
359
360     my $map;
361
362     eval { $map = OpenSRF::Utils::JSON->JSON2perl($flag->value); };
363     if ($@) {
364         $logger->warn("cgf '$flag_name' enabled but value is invalid JSON? $@");
365         return;
366     }
367
368     # Don't crash over any of the things that could go wrong in here:
369     eval {
370         if ($map->{$qtype}) {
371             if ($bterm =~ qr/$map->{$qtype}/i) {
372                 $self->ctx->{browse_leading_article_warning} = 1;
373             }
374         }
375     };
376     if ($@) {
377         $logger->warn("cgf '$flag_name' has valid JSON in value, but: $@");
378     }
379 }
380
381 sub load_browse {
382     my ($self) = @_;
383
384     _init_browse_cache();
385
386     $self->ctx->{more_forward} = 0;
387     $self->ctx->{more_back} = 0;
388
389     if ($self->cgi->param('qtype') and defined $self->cgi->param('bterm')) {
390
391         $self->leading_article_test(
392             $self->cgi->param('qtype'),
393             $self->cgi->param('bterm')
394         );
395
396         my ($cache_key, $limit, $offset, $force_backward, @params) =
397             $self->prepare_browse_parameters;
398
399         my $results = $browse_cache->get_cache($cache_key);
400         if (not $results) {
401             $results = $self->load_browse_impl(
402                 $limit, $offset, $force_backward, @params
403             );
404             if ($results) {
405                 $browse_cache->put_cache($cache_key, $results, $browse_timeout);
406             }
407         }
408
409         if ($results) {
410             $self->infer_browse_paging(
411                 $results, $limit, $offset, $force_backward
412             );
413             $self->ctx->{browse_results} = $results;
414         }
415
416         # We don't need an else clause to send the user a 5XX error or
417         # anything. Errors will have been logged, and $ctx will be
418         # prepared so a template can show a nicer error to the user.
419     }
420
421     return Apache2::Const::OK;
422 }
423
424 1;