]> git.evergreen-ils.org Git - contrib/Conifer.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Biblio.pm
LP#1955079: Staff catalog can't email records
[contrib/Conifer.git] / Open-ILS / src / perlmods / lib / OpenILS / Application / Search / Biblio.pm
1 package OpenILS::Application::Search::Biblio;
2 use base qw/OpenILS::Application/;
3 use strict; use warnings;
4
5
6 use OpenSRF::Utils::JSON;
7 use OpenILS::Utils::Fieldmapper;
8 use OpenILS::Utils::ModsParser;
9 use OpenSRF::Utils::SettingsClient;
10 use OpenILS::Utils::CStoreEditor q/:funcs/;
11 use OpenSRF::Utils::Cache;
12 use Encode;
13 use Email::Send;
14 use Email::Simple;
15
16 use OpenSRF::Utils::Logger qw/:logger/;
17
18 use Time::HiRes qw(time sleep);
19 use OpenSRF::EX qw(:try);
20 use Digest::MD5 qw(md5_hex);
21
22 use XML::LibXML;
23 use XML::LibXSLT;
24
25 use Data::Dumper;
26 $Data::Dumper::Indent = 0;
27
28 use OpenILS::Const qw/:const/;
29
30 use OpenILS::Application::AppUtils;
31 my $apputils = "OpenILS::Application::AppUtils";
32 my $U = $apputils;
33
34 my $pfx = "open-ils.search_";
35
36 my $cache;
37 my $cache_timeout;
38 my $superpage_size;
39 my $max_superpages;
40
41 sub initialize {
42     $cache = OpenSRF::Utils::Cache->new('global');
43     my $sclient = OpenSRF::Utils::SettingsClient->new();
44     $cache_timeout = $sclient->config_value(
45             "apps", "open-ils.search", "app_settings", "cache_timeout" ) || 300;
46
47     $superpage_size = $sclient->config_value(
48             "apps", "open-ils.search", "app_settings", "superpage_size" ) || 500;
49
50     $max_superpages = $sclient->config_value(
51             "apps", "open-ils.search", "app_settings", "max_superpages" ) || 20;
52
53     $logger->info("Search cache timeout is $cache_timeout, ".
54         " superpage_size is $superpage_size, max_superpages is $max_superpages");
55 }
56
57
58
59 # ---------------------------------------------------------------------------
60 # takes a list of record id's and turns the docs into friendly 
61 # mods structures. Creates one MODS structure for each doc id.
62 # ---------------------------------------------------------------------------
63 sub _records_to_mods {
64     my @ids = @_;
65     
66     my @results;
67     my @marcxml_objs;
68
69     my $session = OpenSRF::AppSession->create("open-ils.cstore");
70     my $request = $session->request(
71             "open-ils.cstore.direct.biblio.record_entry.search", { id => \@ids } );
72
73     while( my $resp = $request->recv ) {
74         my $content = $resp->content;
75         next if $content->id == OILS_PRECAT_RECORD;
76         my $u = OpenILS::Utils::ModsParser->new();  # FIXME: we really need a new parser for each object?
77         $u->start_mods_batch( $content->marc );
78         my $mods = $u->finish_mods_batch();
79         $mods->doc_id($content->id());
80         $mods->tcn($content->tcn_value);
81         push @results, $mods;
82     }
83
84     $session->disconnect();
85     return \@results;
86 }
87
88 __PACKAGE__->register_method(
89     method    => "record_id_to_mods",
90     api_name  => "open-ils.search.biblio.record.mods.retrieve",
91     argc      => 1,
92     signature => {
93         desc   => "Provide ID, we provide the MODS object with copy count.  " 
94                 . "Note: this method does NOT take an array of IDs like mods_slim.retrieve",    # FIXME: do it here too
95         params => [
96             { desc => 'Record ID', type => 'number' }
97         ],
98         return => {
99             desc => 'MODS object', type => 'object'
100         }
101     }
102 );
103
104 # converts a record into a mods object with copy counts attached
105 sub record_id_to_mods {
106
107     my( $self, $client, $org_id, $id ) = @_;
108
109     my $mods_list = _records_to_mods( $id );
110     my $mods_obj  = $mods_list->[0];
111     my $cmethod   = $self->method_lookup("open-ils.search.biblio.record.copy_count");
112     my ($count)   = $cmethod->run($org_id, $id);
113     $mods_obj->copy_count($count);
114
115     return $mods_obj;
116 }
117
118
119
120 __PACKAGE__->register_method(
121     method        => "record_id_to_mods_slim",
122     api_name      => "open-ils.search.biblio.record.mods_slim.retrieve",
123     argc          => 1,
124     authoritative => 1,
125     signature     => {
126         desc   => "Provide ID(s), we provide the MODS",
127         params => [
128             { desc => 'Record ID or array of IDs' }
129         ],
130         return => {
131             desc => 'MODS object(s), event on error'
132         }
133     }
134 );
135
136 # converts a record into a mods object with NO copy counts attached
137 sub record_id_to_mods_slim {
138     my( $self, $client, $id ) = @_;
139     return undef unless defined $id;
140
141     if(ref($id) and ref($id) eq 'ARRAY') {
142         return _records_to_mods( @$id );
143     }
144     my $mods_list = _records_to_mods( $id );
145     my $mods_obj  = $mods_list->[0];
146     return OpenILS::Event->new('BIBLIO_RECORD_ENTRY_NOT_FOUND') unless $mods_obj;
147     return $mods_obj;
148 }
149
150
151
152 __PACKAGE__->register_method(
153     method   => "record_id_to_mods_slim_batch",
154     api_name => "open-ils.search.biblio.record.mods_slim.batch.retrieve",
155     stream   => 1
156 );
157 sub record_id_to_mods_slim_batch {
158     my($self, $conn, $id_list) = @_;
159     $conn->respond(_records_to_mods($_)->[0]) for @$id_list;
160     return undef;
161 }
162
163
164 # Returns the number of copies attached to a record based on org location
165 __PACKAGE__->register_method(
166     method   => "record_id_to_copy_count",
167     api_name => "open-ils.search.biblio.record.copy_count",
168     signature => {
169         desc => q/Returns a copy summary for the given record for the context org
170             unit and all ancestor org units/,
171         params => [
172             {desc => 'Context org unit id', type => 'number'},
173             {desc => 'Record ID', type => 'number'}
174         ],
175         return => {
176             desc => q/summary object per org unit in the set, where the set
177                 includes the context org unit and all parent org units.  
178                 Object includes the keys "transcendant", "count", "org_unit", "depth", 
179                 "unshadow", "available".  Each is a count, except "org_unit" which is 
180                 the context org unit and "depth" which is the depth of the context org unit
181             /,
182             type => 'array'
183         }
184     }
185 );
186
187 __PACKAGE__->register_method(
188     method        => "record_id_to_copy_count",
189     api_name      => "open-ils.search.biblio.record.copy_count.staff",
190     authoritative => 1,
191     signature => {
192         desc => q/Returns a copy summary for the given record for the context org
193             unit and all ancestor org units/,
194         params => [
195             {desc => 'Context org unit id', type => 'number'},
196             {desc => 'Record ID', type => 'number'}
197         ],
198         return => {
199             desc => q/summary object per org unit in the set, where the set
200                 includes the context org unit and all parent org units.  
201                 Object includes the keys "transcendant", "count", "org_unit", "depth", 
202                 "unshadow", "available".  Each is a count, except "org_unit" which is 
203                 the context org unit and "depth" which is the depth of the context org unit
204             /,
205             type => 'array'
206         }
207     }
208 );
209
210 __PACKAGE__->register_method(
211     method   => "record_id_to_copy_count",
212     api_name => "open-ils.search.biblio.metarecord.copy_count",
213     signature => {
214         desc => q/Returns a copy summary for the given record for the context org
215             unit and all ancestor org units/,
216         params => [
217             {desc => 'Context org unit id', type => 'number'},
218             {desc => 'Record ID', type => 'number'}
219         ],
220         return => {
221             desc => q/summary object per org unit in the set, where the set
222                 includes the context org unit and all parent org units.  
223                 Object includes the keys "transcendant", "count", "org_unit", "depth", 
224                 "unshadow", "available".  Each is a count, except "org_unit" which is 
225                 the context org unit and "depth" which is the depth of the context org unit
226             /,
227             type => 'array'
228         }
229     }
230 );
231
232 __PACKAGE__->register_method(
233     method   => "record_id_to_copy_count",
234     api_name => "open-ils.search.biblio.metarecord.copy_count.staff",
235     signature => {
236         desc => q/Returns a copy summary for the given record for the context org
237             unit and all ancestor org units/,
238         params => [
239             {desc => 'Context org unit id', type => 'number'},
240             {desc => 'Record ID', type => 'number'}
241         ],
242         return => {
243             desc => q/summary object per org unit in the set, where the set
244                 includes the context org unit and all parent org units.  
245                 Object includes the keys "transcendant", "count", "org_unit", "depth", 
246                 "unshadow", "available".  Each is a count, except "org_unit" which is 
247                 the context org unit and "depth" which is the depth of the context org
248                 unit.  "depth" is always -1 when the count from a lasso search is
249                 performed, since depth doesn't mean anything in a lasso context.
250             /,
251             type => 'array'
252         }
253     }
254 );
255
256 sub record_id_to_copy_count {
257     my( $self, $client, $org_id, $record_id ) = @_;
258
259     return [] unless $record_id;
260
261     my $key = $self->api_name =~ /metarecord/ ? 'metarecord' : 'record';
262     my $staff = $self->api_name =~ /staff/ ? 't' : 'f';
263
264     my $data = $U->cstorereq(
265         "open-ils.cstore.json_query.atomic",
266         { from => ['asset.' . $key  . '_copy_count' => $org_id => $record_id => $staff] }
267     );
268
269     my @count;
270     for my $d ( @$data ) { # fix up the key name change required by stored-proc version
271         $$d{count} = delete $$d{visible};
272         push @count, $d;
273     }
274
275     return [ sort { $a->{depth} <=> $b->{depth} } @count ];
276 }
277
278 __PACKAGE__->register_method(
279     method   => "record_has_holdable_copy",
280     api_name => "open-ils.search.biblio.record.has_holdable_copy",
281     signature => {
282         desc => q/Returns a boolean indicating if a record has any holdable copies./,
283         params => [
284             {desc => 'Record ID', type => 'number'}
285         ],
286         return => {
287             desc => q/bool indicating if the record has any holdable copies/,
288             type => 'bool'
289         }
290     }
291 );
292
293 __PACKAGE__->register_method(
294     method   => "record_has_holdable_copy",
295     api_name => "open-ils.search.biblio.metarecord.has_holdable_copy",
296     signature => {
297         desc => q/Returns a boolean indicating if a record has any holdable copies./,
298         params => [
299             {desc => 'Record ID', type => 'number'}
300         ],
301         return => {
302             desc => q/bool indicating if the record has any holdable copies/,
303             type => 'bool'
304         }
305     }
306 );
307
308 sub record_has_holdable_copy {
309     my($self, $client, $record_id ) = @_;
310
311     return 0 unless $record_id;
312
313     my $key = $self->api_name =~ /metarecord/ ? 'metarecord' : 'record';
314
315     my $data = $U->cstorereq(
316         "open-ils.cstore.json_query.atomic",
317         { from => ['asset.' . $key . '_has_holdable_copy' => $record_id ] }
318     );
319
320     return ${@$data[0]}{'asset.' . $key . '_has_holdable_copy'} eq 't';
321
322 }
323
324 __PACKAGE__->register_method(
325     method   => "biblio_search_tcn",
326     api_name => "open-ils.search.biblio.tcn",
327     argc     => 1,
328     signature => {
329         desc   => "Retrieve related record ID(s) given a TCN",
330         params => [
331             { desc => 'TCN', type => 'string' },
332             { desc => 'Flag indicating to include deleted records', type => 'string' }
333         ],
334         return => {
335             desc => 'Results object like: { "count": $i, "ids": [...] }',
336             type => 'object'
337         }
338     }
339
340 );
341
342 sub biblio_search_tcn {
343
344     my( $self, $client, $tcn, $include_deleted ) = @_;
345
346     $tcn =~ s/^\s+|\s+$//og;
347
348     my $e = new_editor();
349     my $search = {tcn_value => $tcn};
350     $search->{deleted} = 'f' unless $include_deleted;
351     my $recs = $e->search_biblio_record_entry( $search, {idlist =>1} );
352     
353     return { count => scalar(@$recs), ids => $recs };
354 }
355
356
357 # --------------------------------------------------------------------------------
358
359 __PACKAGE__->register_method(
360     method   => "biblio_barcode_to_copy",
361     api_name => "open-ils.search.asset.copy.find_by_barcode",
362 );
363 sub biblio_barcode_to_copy { 
364     my( $self, $client, $barcode ) = @_;
365     my( $copy, $evt ) = $U->fetch_copy_by_barcode($barcode);
366     return $evt if $evt;
367     return $copy;
368 }
369
370 __PACKAGE__->register_method(
371     method   => "biblio_id_to_copy",
372     api_name => "open-ils.search.asset.copy.batch.retrieve",
373 );
374 sub biblio_id_to_copy { 
375     my( $self, $client, $ids ) = @_;
376     $logger->info("Fetching copies @$ids");
377     return $U->cstorereq(
378         "open-ils.cstore.direct.asset.copy.search.atomic", { id => $ids } );
379 }
380
381
382 __PACKAGE__->register_method(
383     method  => "biblio_id_to_uris",
384     api_name=> "open-ils.search.asset.uri.retrieve_by_bib",
385     argc    => 2, 
386     stream  => 1,
387     signature => q#
388         @param BibID Which bib record contains the URIs
389         @param OrgID Where to look for URIs
390         @param OrgDepth Range adjustment for OrgID
391         @return A stream or list of 'auri' objects
392     #
393
394 );
395 sub biblio_id_to_uris { 
396     my( $self, $client, $bib, $org, $depth ) = @_;
397     die "Org ID required" unless defined($org);
398     die "Bib ID required" unless defined($bib);
399
400     my @params;
401     push @params, $depth if (defined $depth);
402
403     my $ids = $U->cstorereq( "open-ils.cstore.json_query.atomic",
404         {   select  => { auri => [ 'id' ] },
405             from    => {
406                 acn => {
407                     auricnm => {
408                         field   => 'call_number',
409                         fkey    => 'id',
410                         join    => {
411                             auri    => {
412                                 field => 'id',
413                                 fkey => 'uri',
414                                 filter  => { active => 't' }
415                             }
416                         }
417                     }
418                 }
419             },
420             where   => {
421                 '+acn'  => {
422                     record      => $bib,
423                     owning_lib  => {
424                         in  => {
425                             select  => { aou => [ { column => 'id', transform => 'actor.org_unit_descendants', params => \@params, result_field => 'id' } ] },
426                             from    => 'aou',
427                             where   => { id => $org },
428                             distinct=> 1
429                         }
430                     }
431                 }
432             },
433             distinct=> 1,
434         }
435     );
436
437     my $uris = $U->cstorereq(
438         "open-ils.cstore.direct.asset.uri.search.atomic",
439         { id => [ map { (values %$_) } @$ids ] }
440     );
441
442     $client->respond($_) for (@$uris);
443
444     return undef;
445 }
446
447
448 __PACKAGE__->register_method(
449     method    => "copy_retrieve",
450     api_name  => "open-ils.search.asset.copy.retrieve",
451     argc      => 1,
452     signature => {
453         desc   => 'Retrieve a copy object based on the Copy ID',
454         params => [
455             { desc => 'Copy ID', type => 'number'}
456         ],
457         return => {
458             desc => 'Copy object, event on error'
459         }
460     }
461 );
462
463 sub copy_retrieve {
464     my( $self, $client, $cid ) = @_;
465     my( $copy, $evt ) = $U->fetch_copy($cid);
466     return $evt || $copy;
467 }
468
469 __PACKAGE__->register_method(
470     method   => "volume_retrieve",
471     api_name => "open-ils.search.asset.call_number.retrieve"
472 );
473 sub volume_retrieve {
474     my( $self, $client, $vid ) = @_;
475     my $e = new_editor();
476     my $vol = $e->retrieve_asset_call_number($vid) or return $e->event;
477     return $vol;
478 }
479
480 __PACKAGE__->register_method(
481     method        => "fleshed_copy_retrieve_batch",
482     api_name      => "open-ils.search.asset.copy.fleshed.batch.retrieve",
483     authoritative => 1,
484 );
485
486 sub fleshed_copy_retrieve_batch { 
487     my( $self, $client, $ids ) = @_;
488     $logger->info("Fetching fleshed copies @$ids");
489     return $U->cstorereq(
490         "open-ils.cstore.direct.asset.copy.search.atomic",
491         { id => $ids },
492         { flesh => 1, 
493           flesh_fields => { acp => [ qw/ circ_lib location status stat_cat_entries parts / ] }
494         });
495 }
496
497
498 __PACKAGE__->register_method(
499     method   => "fleshed_copy_retrieve",
500     api_name => "open-ils.search.asset.copy.fleshed.retrieve",
501 );
502
503 sub fleshed_copy_retrieve { 
504     my( $self, $client, $id ) = @_;
505     my( $c, $e) = $U->fetch_fleshed_copy($id);
506     return $e || $c;
507 }
508
509
510 __PACKAGE__->register_method(
511     method        => 'fleshed_by_barcode',
512     api_name      => "open-ils.search.asset.copy.fleshed2.find_by_barcode",
513     authoritative => 1,
514 );
515 sub fleshed_by_barcode {
516     my( $self, $conn, $barcode ) = @_;
517     my $e = new_editor();
518     my $copyid = $e->search_asset_copy(
519         {barcode => $barcode, deleted => 'f'}, {idlist=>1})->[0]
520         or return $e->event;
521     return fleshed_copy_retrieve2( $self, $conn, $copyid);
522 }
523
524
525 __PACKAGE__->register_method(
526     method        => "fleshed_copy_retrieve2",
527     api_name      => "open-ils.search.asset.copy.fleshed2.retrieve",
528     authoritative => 1,
529 );
530
531 sub fleshed_copy_retrieve2 { 
532     my( $self, $client, $id ) = @_;
533     my $e = new_editor();
534     my $copy = $e->retrieve_asset_copy(
535         [
536             $id,
537             {
538                 flesh        => 2,
539                 flesh_fields => {
540                     acp => [
541                         qw/ location status stat_cat_entry_copy_maps notes age_protect parts peer_record_maps /
542                     ],
543                     ascecm => [qw/ stat_cat stat_cat_entry /],
544                 }
545             }
546         ]
547     ) or return $e->event;
548
549     # For backwards compatibility
550     #$copy->stat_cat_entries($copy->stat_cat_entry_copy_maps);
551
552     if( $copy->status->id == OILS_COPY_STATUS_CHECKED_OUT ) {
553         $copy->circulations(
554             $e->search_action_circulation( 
555                 [   
556                     { target_copy => $copy->id },
557                     {
558                         order_by => { circ => 'xact_start desc' },
559                         limit => 1
560                     }
561                 ]
562             )
563         );
564     }
565
566     return $copy;
567 }
568
569
570 __PACKAGE__->register_method(
571     method        => 'flesh_copy_custom',
572     api_name      => 'open-ils.search.asset.copy.fleshed.custom',
573     authoritative => 1,
574 );
575
576 sub flesh_copy_custom {
577     my( $self, $conn, $copyid, $fields ) = @_;
578     my $e = new_editor();
579     my $copy = $e->retrieve_asset_copy(
580         [
581             $copyid,
582             { 
583                 flesh               => 1,
584                 flesh_fields    => { 
585                     acp => $fields,
586                 }
587             }
588         ]
589     ) or return $e->event;
590     return $copy;
591 }
592
593
594 __PACKAGE__->register_method(
595     method   => "biblio_barcode_to_title",
596     api_name => "open-ils.search.biblio.find_by_barcode",
597 );
598
599 sub biblio_barcode_to_title {
600     my( $self, $client, $barcode ) = @_;
601
602     my $title = $apputils->simple_scalar_request(
603         "open-ils.storage",
604         "open-ils.storage.biblio.record_entry.retrieve_by_barcode", $barcode );
605
606     return { ids => [ $title->id ], count => 1 } if $title;
607     return { count => 0 };
608 }
609
610 __PACKAGE__->register_method(
611     method        => 'title_id_by_item_barcode',
612     api_name      => 'open-ils.search.bib_id.by_barcode',
613     authoritative => 1,
614     signature => { 
615         desc   => 'Retrieve bib record id associated with the copy identified by the given barcode',
616         params => [
617             { desc => 'Item barcode', type => 'string' }
618         ],
619         return => {
620             desc => 'Bib record id.'
621         }
622     }
623 );
624
625 __PACKAGE__->register_method(
626     method        => 'title_id_by_item_barcode',
627     api_name      => 'open-ils.search.multi_home.bib_ids.by_barcode',
628     authoritative => 1,
629     signature => {
630         desc   => 'Retrieve bib record ids associated with the copy identified by the given barcode.  This includes peer bibs for Multi-Home items.',
631         params => [
632             { desc => 'Item barcode', type => 'string' }
633         ],
634         return => {
635             desc => 'Array of bib record ids.  First element is the native bib for the item.'
636         }
637     }
638 );
639
640
641 sub title_id_by_item_barcode {
642     my( $self, $conn, $barcode ) = @_;
643     my $e = new_editor();
644     my $copies = $e->search_asset_copy(
645         [
646             { deleted => 'f', barcode => $barcode },
647             {
648                 flesh => 2,
649                 flesh_fields => {
650                     acp => [ 'call_number' ],
651                     acn => [ 'record' ]
652                 }
653             }
654         ]
655     );
656
657     return $e->event unless @$copies;
658
659     if( $self->api_name =~ /multi_home/ ) {
660         my $multi_home_list = $e->search_biblio_peer_bib_copy_map(
661             [
662                 { target_copy => $$copies[0]->id }
663             ]
664         );
665         my @temp =  map { $_->peer_record } @{ $multi_home_list };
666         unshift @temp, $$copies[0]->call_number->record->id;
667         return \@temp;
668     } else {
669         return $$copies[0]->call_number->record->id;
670     }
671 }
672
673 __PACKAGE__->register_method(
674     method        => 'find_peer_bibs',
675     api_name      => 'open-ils.search.peer_bibs.test',
676     authoritative => 1,
677     signature => {
678         desc   => 'Tests to see if the specified record is a peer record.',
679         params => [
680             { desc => 'Biblio record entry Id', type => 'number' }
681         ],
682         return => {
683             desc => 'True if specified id can be found in biblio.peer_bib_copy_map.peer_record.',
684             type => 'bool'
685         }
686     }
687 );
688
689 __PACKAGE__->register_method(
690     method        => 'find_peer_bibs',
691     api_name      => 'open-ils.search.peer_bibs',
692     authoritative => 1,
693     signature => {
694         desc   => 'Return acps and mvrs for multi-home items linked to specified peer record.',
695         params => [
696             { desc => 'Biblio record entry Id', type => 'number' }
697         ],
698         return => {
699             desc => '{ records => Array of mvrs, items => array of acps }',
700         }
701     }
702 );
703
704
705 sub find_peer_bibs {
706     my( $self, $client, $doc_id ) = @_;
707     my $e = new_editor();
708
709     my $multi_home_list = $e->search_biblio_peer_bib_copy_map(
710         [
711             { peer_record => $doc_id },
712             {
713                 flesh => 2,
714                 flesh_fields => {
715                     bpbcm => [ 'target_copy', 'peer_type' ],
716                     acp => [ 'call_number', 'location', 'status', 'peer_record_maps' ]
717                 }
718             }
719         ]
720     );
721
722     if ($self->api_name =~ /test/) {
723         return scalar( @{$multi_home_list} ) > 0 ? 1 : 0;
724     }
725
726     if (scalar(@{$multi_home_list})==0) {
727         return [];
728     }
729
730     # create a unique hash of the primary record MVRs for foreign copies
731     # XXX PLEASE let's change to unAPI2 (supports foreign copies) in the TT opac?!?
732     my %rec_hash = map {
733         ($_->target_copy->call_number->record, _records_to_mods( $_->target_copy->call_number->record )->[0])
734     } @$multi_home_list;
735
736     # set the foreign_copy_maps field to an empty array
737     map { $rec_hash{$_}->foreign_copy_maps([]) } keys( %rec_hash );
738
739     # push the maps onto the correct MVRs
740     for (@$multi_home_list) {
741         push(
742             @{$rec_hash{ $_->target_copy->call_number->record }->foreign_copy_maps()},
743             $_
744         );
745     }
746
747     return [sort {$a->title cmp $b->title} values(%rec_hash)];
748 };
749
750 __PACKAGE__->register_method(
751     method   => "biblio_copy_to_mods",
752     api_name => "open-ils.search.biblio.copy.mods.retrieve",
753 );
754
755 # takes a copy object and returns it fleshed mods object
756 sub biblio_copy_to_mods {
757     my( $self, $client, $copy ) = @_;
758
759     my $volume = $U->cstorereq( 
760         "open-ils.cstore.direct.asset.call_number.retrieve",
761         $copy->call_number() );
762
763     my $mods = _records_to_mods($volume->record());
764     $mods = shift @$mods;
765     $volume->copies([$copy]);
766     push @{$mods->call_numbers()}, $volume;
767
768     return $mods;
769 }
770
771
772 =head1 NAME
773
774 OpenILS::Application::Search::Biblio
775
776 =head1 DESCRIPTION
777
778 =head2 API METHODS
779
780 =head3 open-ils.search.biblio.multiclass.query (arghash, query, docache)
781
782 For arghash and docache, see B<open-ils.search.biblio.multiclass>.
783
784 The query argument is a string, but built like a hash with key: value pairs.
785 Recognized search keys include: 
786
787  keyword (kw) - search keyword(s) *
788  author  (au) - search author(s)  *
789  name    (au) - same as author    *
790  title   (ti) - search title      *
791  subject (su) - search subject    *
792  series  (se) - search series     *
793  lang - limit by language (specify multiple langs with lang:l1 lang:l2 ...)
794  site - search at specified org unit, corresponds to actor.org_unit.shortname
795  pref_ou - extend search to specified org unit, corresponds to actor.org_unit.shortname
796  sort - sort type (title, author, pubdate)
797  dir  - sort direction (asc, desc)
798  available - if set to anything other than "false" or "0", limits to available items
799
800 * Searching keyword, author, title, subject, and series supports additional search 
801 subclasses, specified with a "|".  For example, C<title|proper:gone with the wind>.
802
803 For more, see B<config.metabib_field>.
804
805 =cut
806
807 foreach (qw/open-ils.search.biblio.multiclass.query
808             open-ils.search.biblio.multiclass.query.staff
809             open-ils.search.metabib.multiclass.query
810             open-ils.search.metabib.multiclass.query.staff/)
811 {
812 __PACKAGE__->register_method(
813     api_name  => $_,
814     method    => 'multiclass_query',
815     signature => {
816         desc   => 'Perform a search query.  The .staff version of the call includes otherwise hidden hits.',
817         params => [
818             {name => 'arghash', desc => 'Arg hash (see open-ils.search.biblio.multiclass)',         type => 'object'},
819             {name => 'query',   desc => 'Raw human-readable query (see perldoc '. __PACKAGE__ .')', type => 'string'},
820             {name => 'docache', desc => 'Flag for caching (see open-ils.search.biblio.multiclass)', type => 'object'},
821         ],
822         return => {
823             desc => 'Search results from query, like: { "count" : $count, "ids" : [ [ $id, $relevancy, $total ], ...] }',
824             type => 'object',       # TODO: update as miker's new elements are included
825         }
826     }
827 );
828 }
829
830 sub multiclass_query {
831     # arghash only really supports limit/offset anymore
832     my($self, $conn, $arghash, $query, $docache, $phys_loc) = @_;
833
834     if ($query) {
835         $query =~ s/\+/ /go;
836         $query =~ s/^\s+//go;
837         $query =~ s/\s+/ /go;
838         $arghash->{query} = $query
839     }
840
841     $logger->debug("initial search query => $query") if $query;
842
843     (my $method = $self->api_name) =~ s/\.query/.staged/o;
844     return $self->method_lookup($method)->dispatch($arghash, $docache, $phys_loc);
845
846 }
847
848 __PACKAGE__->register_method(
849     method    => 'cat_search_z_style_wrapper',
850     api_name  => 'open-ils.search.biblio.zstyle',
851     stream    => 1,
852     signature => q/@see open-ils.search.biblio.multiclass/
853 );
854
855 __PACKAGE__->register_method(
856     method    => 'cat_search_z_style_wrapper',
857     api_name  => 'open-ils.search.biblio.zstyle.staff',
858     stream    => 1,
859     signature => q/@see open-ils.search.biblio.multiclass/
860 );
861
862 sub cat_search_z_style_wrapper {
863     my $self = shift;
864     my $client = shift;
865     my $authtoken = shift;
866     my $args = shift;
867
868     my $cstore = OpenSRF::AppSession->connect('open-ils.cstore');
869
870     my $ou = $cstore->request(
871         'open-ils.cstore.direct.actor.org_unit.search',
872         { parent_ou => undef }
873     )->gather(1);
874
875     my $result = { service => 'native-evergreen-catalog', records => [] };
876     my $searchhash = { limit => $$args{limit}, offset => $$args{offset}, org_unit => $ou->id };
877
878     $$searchhash{searches}{title}{term}   = $$args{search}{title}   if $$args{search}{title};
879     $$searchhash{searches}{author}{term}  = $$args{search}{author}  if $$args{search}{author};
880     $$searchhash{searches}{subject}{term} = $$args{search}{subject} if $$args{search}{subject};
881     $$searchhash{searches}{keyword}{term} = $$args{search}{keyword} if $$args{search}{keyword};
882     $$searchhash{searches}{'identifier|isbn'}{term} = $$args{search}{isbn} if $$args{search}{isbn};
883     $$searchhash{searches}{'identifier|issn'}{term} = $$args{search}{issn} if $$args{search}{issn};
884     $$searchhash{searches}{'identifier|upc'}{term} = $$args{search}{upc} if $$args{search}{upc};
885
886     $$searchhash{searches}{keyword}{term} .= join ' ', $$searchhash{searches}{keyword}{term}, $$args{search}{tcn}       if $$args{search}{tcn};
887     $$searchhash{searches}{keyword}{term} .= join ' ', $$searchhash{searches}{keyword}{term}, $$args{search}{publisher} if $$args{search}{publisher};
888     $$searchhash{searches}{keyword}{term} .= join ' ', $$searchhash{searches}{keyword}{term}, $$args{search}{pubdate}   if $$args{search}{pubdate};
889     $$searchhash{searches}{keyword}{term} .= join ' ', $$searchhash{searches}{keyword}{term}, $$args{search}{item_type} if $$args{search}{item_type};
890
891     my $method = 'open-ils.search.biblio.multiclass.staged';
892     $method .= '.staff' if $self->api_name =~ /staff$/;
893
894     my ($list) = $self->method_lookup($method)->run( $searchhash );
895
896     if ($list->{count} > 0 and @{$list->{ids}}) {
897         $result->{count} = $list->{count};
898
899         my $records = $cstore->request(
900             'open-ils.cstore.direct.biblio.record_entry.search.atomic',
901             { id => [ map { ( $_->[0] ) } @{$list->{ids}} ] }
902         )->gather(1);
903
904         for my $rec ( @$records ) {
905             
906             my $u = OpenILS::Utils::ModsParser->new();
907                         $u->start_mods_batch( $rec->marc );
908                         my $mods = $u->finish_mods_batch();
909
910             push @{ $result->{records} }, { mvr => $mods, marcxml => $rec->marc, bibid => $rec->id };
911
912         }
913
914     }
915
916     $cstore->disconnect();
917     return $result;
918 }
919
920 # ----------------------------------------------------------------------------
921 # These are the main OPAC search methods
922 # ----------------------------------------------------------------------------
923
924 __PACKAGE__->register_method(
925     method    => 'the_quest_for_knowledge',
926     api_name  => 'open-ils.search.biblio.multiclass',
927     signature => {
928         desc => "Performs a multi class biblio or metabib search",
929         params => [
930             {
931                 desc => "A search hash with keys: "
932                       . "searches, org_unit, depth, limit, offset, format, sort, sort_dir.  "
933                       . "See perldoc " . __PACKAGE__ . " for more detail",
934                 type => 'object',
935             },
936             {
937                 desc => "A flag to enable/disable searching and saving results in cache (default OFF)",
938                 type => 'string',
939             }
940         ],
941         return => {
942             desc => 'An object of the form: '
943                   . '{ "count" : $count, "ids" : [ [ $id, $relevancy, $total ], ...] }',
944         }
945     }
946 );
947
948 =head3 open-ils.search.biblio.multiclass (search-hash, docache)
949
950 The search-hash argument can have the following elements:
951
952     searches: { "$class" : "$value", ...}           [REQUIRED]
953     org_unit: The org id to focus the search at
954     depth   : The org depth     
955     limit   : The search limit      default: 10
956     offset  : The search offset     default:  0
957     format  : The MARC format
958     sort    : What field to sort the results on? [ author | title | pubdate ]
959     sort_dir: What direction do we sort? [ asc | desc ]
960     tag_circulated_records : Boolean, if true, records that are in the user's visible checkout history
961         will be tagged with an additional value ("1") as the last value in the record ID array for
962         each record.  Requires the 'authtoken'
963     authtoken : Authentication token string;  When actions are performed that require a user login
964         (e.g. tagging circulated records), the authentication token is required
965
966 The searches element is required, must have a hashref value, and the hashref must contain at least one 
967 of the following classes as a key:
968
969     title
970     author
971     subject
972     series
973     keyword
974
975 The value paired with a key is the associated search string.
976
977 The docache argument enables/disables searching and saving results in cache (default OFF).
978
979 The return object, if successful, will look like:
980
981     { "count" : $count, "ids" : [ [ $id, $relevancy, $total ], ...] }
982
983 =cut
984
985 __PACKAGE__->register_method(
986     method    => 'the_quest_for_knowledge',
987     api_name  => 'open-ils.search.biblio.multiclass.staff',
988     signature => q/The .staff search includes hidden bibs, hidden items and bibs with no items.  Otherwise, @see open-ils.search.biblio.multiclass/
989 );
990 __PACKAGE__->register_method(
991     method    => 'the_quest_for_knowledge',
992     api_name  => 'open-ils.search.metabib.multiclass',
993     signature => q/@see open-ils.search.biblio.multiclass/
994 );
995 __PACKAGE__->register_method(
996     method    => 'the_quest_for_knowledge',
997     api_name  => 'open-ils.search.metabib.multiclass.staff',
998     signature => q/The .staff search includes hidden bibs, hidden items and bibs with no items.  Otherwise, @see open-ils.search.biblio.multiclass/
999 );
1000
1001 sub the_quest_for_knowledge {
1002     my( $self, $conn, $searchhash, $docache ) = @_;
1003
1004     return { count => 0 } unless $searchhash and
1005         ref $searchhash->{searches} eq 'HASH';
1006
1007     my $method = 'open-ils.storage.biblio.multiclass.search_fts';
1008     my $ismeta = 0;
1009     my @recs;
1010
1011     if($self->api_name =~ /metabib/) {
1012         $ismeta = 1;
1013         $method =~ s/biblio/metabib/o;
1014     }
1015
1016     # do some simple sanity checking
1017     if(!$searchhash->{searches} or
1018         ( !grep { /^(?:title|author|subject|series|keyword|identifier\|is[bs]n)/ } keys %{$searchhash->{searches}} ) ) {
1019         return { count => 0 };
1020     }
1021
1022     my $offset = $searchhash->{offset} ||  0;   # user value or default in local var now
1023     my $limit  = $searchhash->{limit}  || 10;   # user value or default in local var now
1024     my $end    = $offset + $limit - 1;
1025
1026     my $maxlimit = 5000;
1027     $searchhash->{offset} = 0;                  # possible user value overwritten in hash
1028     $searchhash->{limit}  = $maxlimit;          # possible user value overwritten in hash
1029
1030     return { count => 0 } if $offset > $maxlimit;
1031
1032     my @search;
1033     push( @search, ($_ => $$searchhash{$_})) for (sort keys %$searchhash);
1034     my $s = OpenSRF::Utils::JSON->perl2JSON(\@search);
1035     my $ckey = $pfx . md5_hex($method . $s);
1036
1037     $logger->info("bib search for: $s");
1038
1039     $searchhash->{limit} -= $offset;
1040
1041
1042     my $trim = 0;
1043     my $result = ($docache) ? search_cache($ckey, $offset, $limit) : undef;
1044
1045     if(!$result) {
1046
1047         $method .= ".staff" if($self->api_name =~ /staff/);
1048         $method .= ".atomic";
1049     
1050         for (keys %$searchhash) { 
1051             delete $$searchhash{$_} 
1052                 unless defined $$searchhash{$_}; 
1053         }
1054     
1055         $result = $U->storagereq( $method, %$searchhash );
1056         $trim = 1;
1057
1058     } else { 
1059         $docache = 0;   # results came FROM cache, so we don't write back
1060     }
1061
1062     return {count => 0} unless ($result && $$result[0]);
1063
1064     @recs = @$result;
1065
1066     my $count = ($ismeta) ? $result->[0]->[3] : $result->[0]->[2];
1067
1068     if($docache) {
1069         # If we didn't get this data from the cache, put it into the cache
1070         # then return the correct offset of records
1071         $logger->debug("putting search cache $ckey\n");
1072         put_cache($ckey, $count, \@recs);
1073     }
1074
1075     if($trim) {
1076         # if we have the full set of data, trim out 
1077         # the requested chunk based on limit and offset
1078         my @t;
1079         for ($offset..$end) {
1080             last unless $recs[$_];
1081             push(@t, $recs[$_]);
1082         }
1083         @recs = @t;
1084     }
1085
1086     return { ids => \@recs, count => $count };
1087 }
1088
1089
1090 __PACKAGE__->register_method(
1091     method    => 'staged_search',
1092     api_name  => 'open-ils.search.biblio.multiclass.staged',
1093     signature => {
1094         desc   => 'Staged search filters out unavailable items.  This means that it relies on an estimation strategy for determining ' .
1095                   'how big a "raw" search result chunk (i.e. a "superpage") to obtain prior to filtering.  See "estimation_strategy" in your SRF config.',
1096         params => [
1097             {
1098                 desc => "A search hash with keys: "
1099                       . "searches, limit, offset.  The others are optional, but the 'searches' key/value pair is required, with the value being a hashref.  "
1100                       . "See perldoc " . __PACKAGE__ . " for more detail",
1101                 type => 'object',
1102             },
1103             {
1104                 desc => "A flag to enable/disable searching and saving results in cache, including facets (default OFF)",
1105                 type => 'string',
1106             }
1107         ],
1108         return => {
1109             desc => 'Hash with keys: count, core_limit, superpage_size, superpage_summary, facet_key, ids.  '
1110                   . 'The superpage_summary value is a hashref that includes keys: estimated_hit_count, visible.',
1111             type => 'object',
1112         }
1113     }
1114 );
1115 __PACKAGE__->register_method(
1116     method    => 'staged_search',
1117     api_name  => 'open-ils.search.biblio.multiclass.staged.staff',
1118     signature => q/The .staff search includes hidden bibs, hidden items and bibs with no items.  Otherwise, @see open-ils.search.biblio.multiclass.staged/
1119 );
1120 __PACKAGE__->register_method(
1121     method    => 'staged_search',
1122     api_name  => 'open-ils.search.metabib.multiclass.staged',
1123     signature => q/@see open-ils.search.biblio.multiclass.staged/
1124 );
1125 __PACKAGE__->register_method(
1126     method    => 'staged_search',
1127     api_name  => 'open-ils.search.metabib.multiclass.staged.staff',
1128     signature => q/The .staff search includes hidden bibs, hidden items and bibs with no items.  Otherwise, @see open-ils.search.biblio.multiclass.staged/
1129 );
1130
1131 my $estimation_strategy;
1132 sub staged_search {
1133     my($self, $conn, $search_hash, $docache, $phys_loc) = @_;
1134
1135     $phys_loc ||= $U->get_org_tree->id;
1136
1137     my $IAmMetabib = ($self->api_name =~ /metabib/) ? 1 : 0;
1138
1139     my $method = $IAmMetabib?
1140         'open-ils.storage.metabib.multiclass.staged.search_fts':
1141         'open-ils.storage.biblio.multiclass.staged.search_fts';
1142
1143     $method .= '.staff' if $self->api_name =~ /staff$/;
1144     $method .= '.atomic';
1145                 
1146     if (!$search_hash->{query}) {
1147         return {count => 0} unless (
1148             $search_hash and 
1149             $search_hash->{searches} and 
1150             int(scalar( keys %{$search_hash->{searches}} )));
1151     }
1152
1153     my $search_duration;
1154     my $user_offset = $search_hash->{offset} ||  0; # user-specified offset
1155     my $user_limit  = $search_hash->{limit}  || 10;
1156     my $ignore_facet_classes  = $search_hash->{ignore_facet_classes};
1157     $user_offset = ($user_offset >= 0) ? $user_offset :  0;
1158     $user_limit  = ($user_limit  >= 0) ? $user_limit  : 10;
1159
1160
1161     # we're grabbing results on a per-superpage basis, which means the 
1162     # limit and offset should coincide with superpage boundaries
1163     $search_hash->{offset} = 0;
1164     $search_hash->{limit} = $superpage_size;
1165
1166     # force a well-known check_limit
1167     $search_hash->{check_limit} = $superpage_size; 
1168     # restrict total tested to superpage size * number of superpages
1169     $search_hash->{core_limit}  = $superpage_size * $max_superpages;
1170
1171     # Set the configured estimation strategy, defaults to 'inclusion'.
1172     unless ($estimation_strategy) {
1173         $estimation_strategy = OpenSRF::Utils::SettingsClient
1174             ->new
1175             ->config_value(
1176                 apps => 'open-ils.search', app_settings => 'estimation_strategy'
1177             ) || 'inclusion';
1178     }
1179     $search_hash->{estimation_strategy} = $estimation_strategy;
1180
1181     # pull any existing results from the cache
1182     my $key = search_cache_key($method, $search_hash);
1183     my $facet_key = $key.'_facets';
1184     my $cache_data = $cache->get_cache($key) || {};
1185
1186     # First, we want to make sure that someone else isn't currently trying to perform exactly
1187     # this same search.  The point is to allow just one instance of a search to fill the needs
1188     # of all concurrent, identical searches.  This will avoid spammy searches killing the
1189     # database without requiring admins to start locking some IP addresses out entirely.
1190     #
1191     # There's still a tiny race condition where 2 might run, but without sigificantly more code
1192     # and complexity, this is close to the best we can do.
1193
1194     if ($cache_data->{running}) { # someone is already doing the search...
1195         my $stop_looping = time() + $cache_timeout;
1196         while ( sleep(1) and time() < $stop_looping ) { # sleep for a second ... maybe they'll finish
1197             $cache_data = $cache->get_cache($key) || {};
1198             last if (!$cache_data->{running});
1199         }
1200     } elsif (!$cache_data->{0}) { # we're the first ... let's give it a try
1201         $cache->put_cache($key, { running => $$ }, $cache_timeout / 3);
1202     }
1203
1204     # keep retrieving results until we find enough to 
1205     # fulfill the user-specified limit and offset
1206     my $all_results = [];
1207     my $page; # current superpage
1208     my $current_page_summary = {};
1209     my $global_summary = {checked => 0, visible => 0, excluded => 0, deleted => 0, total => 0};
1210     my $new_ids = [];
1211
1212     for($page = 0; $page < $max_superpages; $page++) {
1213
1214         my $data = $cache_data->{$page};
1215         my $results;
1216         my $summary;
1217
1218         $logger->debug("staged search: analyzing superpage $page");
1219
1220         if($data) {
1221             # this window of results is already cached
1222             $logger->debug("staged search: found cached results");
1223             $summary = $data->{summary};
1224             $results = $data->{results};
1225
1226         } else {
1227             # retrieve the window of results from the database
1228             $logger->debug("staged search: fetching results from the database");
1229             $search_hash->{skip_check} = $page * $superpage_size;
1230             $search_hash->{return_query} = $page == 0 ? 1 : 0;
1231
1232             my $start = time;
1233             $results = $U->storagereq($method, %$search_hash);
1234             $search_duration = time - $start;
1235             $summary = shift(@$results) if $results;
1236
1237             unless($summary) {
1238                 $logger->info("search timed out: duration=$search_duration: params=".
1239                     OpenSRF::Utils::JSON->perl2JSON($search_hash));
1240                 return {count => 0};
1241             }
1242
1243             $logger->info("staged search: DB call took $search_duration seconds and returned ".scalar(@$results)." rows, including summary");
1244
1245             # Create backwards-compatible result structures
1246             if($IAmMetabib) {
1247                 $results = [map {[$_->{id}, $_->{badges}, $_->{popularity}, $_->{rel}, $_->{record}]} @$results];
1248             } else {
1249                 $results = [map {[$_->{id}, $_->{badges}, $_->{popularity}]} @$results];
1250             }
1251
1252             push @$new_ids, grep {defined($_)} map {$_->[0]} @$results;
1253             $results = [grep {defined $_->[0]} @$results];
1254             cache_staged_search_page($key, $page, $summary, $results) if $docache;
1255         }
1256
1257         tag_circulated_records($search_hash->{authtoken}, $results, $IAmMetabib) 
1258             if $search_hash->{tag_circulated_records} and $search_hash->{authtoken};
1259
1260         $current_page_summary = $summary;
1261
1262         # add the new set of results to the set under construction
1263         push(@$all_results, @$results);
1264
1265         my $current_count = scalar(@$all_results);
1266
1267         if ($page == 0) { # all summaries are the same, just get the first
1268             for (keys %$summary) {
1269                 $global_summary->{$_} = $summary->{$_};
1270             }
1271         }
1272
1273         # we've found all the possible hits
1274         last if $current_count == $summary->{visible};
1275
1276         # we've found enough results to satisfy the requested limit/offset
1277         last if $current_count >= ($user_limit + $user_offset);
1278
1279         # we've scanned all possible hits
1280         last if($summary->{checked} < $superpage_size);
1281     }
1282
1283     # Let other backends grab our data now that we're done.
1284     $cache_data = $cache->get_cache($key);
1285     if ($$cache_data{running} and $$cache_data{running} == $$) {
1286         delete $$cache_data{running};
1287         $cache->put_cache($key, $cache_data, $cache_timeout);
1288     }
1289
1290     my $setting_names = [ qw/
1291              opac.did_you_mean.max_suggestions
1292              opac.did_you_mean.low_result_threshold
1293              search.symspell.min_suggestion_use_threshold
1294              search.symspell.soundex.weight
1295              search.symspell.pg_trgm.weight
1296              search.symspell.keyboard_distance.weight/ ];
1297     my %suggest_settings = $U->ou_ancestor_setting_batch_insecure(
1298         $phys_loc, $setting_names
1299     );
1300
1301     # Defaults...
1302     $suggest_settings{$_} ||= {value=>undef} for @$setting_names;
1303
1304     # Pull this one off the front, it's not used for the function call
1305     my $max_suggestions_setting = shift @$setting_names;
1306     my $sugg_low_thresh_setting = shift @$setting_names;
1307     $max_suggestions_setting = $suggest_settings{$max_suggestions_setting}{value} // -1;
1308     my $suggest_low_threshold = $suggest_settings{$sugg_low_thresh_setting}{value} || 0;
1309
1310     if ($global_summary->{visible} <= $suggest_low_threshold and $max_suggestions_setting != 0) {
1311         # For now, we're doing one-class/one-term suggestions only
1312         my ($class, $term) = one_class_one_term($global_summary->{query_struct});
1313         if ($class && $term) { # check for suggestions!
1314             my $suggestion_verbosity = 4;
1315             if ($max_suggestions_setting == -1) { # special value that means "only best suggestion, and not always"
1316                 $max_suggestions_setting = 1;
1317                 $suggestion_verbosity = 0;
1318             }
1319
1320             my @settings_params = map { $suggest_settings{$_}{value} } @$setting_names;
1321             my $suggs = new_editor()->json_query({
1322                 from  => [
1323                     'search.symspell_lookup',
1324                         $term, $class,
1325                         $suggestion_verbosity,
1326                         1, # case transfer
1327                         @settings_params
1328                 ],
1329                 limit => $max_suggestions_setting
1330             });
1331             if (@$suggs and $$suggs[0]{suggestion} ne $term) {
1332                 $global_summary->{suggestions}{'one_class_one_term'} = {
1333                     class       => $class,
1334                     term        => $term,
1335                     suggestions  => $suggs
1336                 };
1337             }
1338         }
1339     }
1340
1341     my @results = grep {defined $_} @$all_results[$user_offset..($user_offset + $user_limit - 1)];
1342
1343     $conn->respond_complete(
1344         {
1345             global_summary    => $global_summary,
1346             count             => $global_summary->{visible},
1347             core_limit        => $search_hash->{core_limit},
1348             superpage         => $page,
1349             superpage_size    => $search_hash->{check_limit},
1350             superpage_summary => $current_page_summary,
1351             facet_key         => $facet_key,
1352             ids               => \@results
1353         }
1354     );
1355
1356     $logger->info("Completed canonicalized search is: $$global_summary{canonicalized_query}");
1357
1358     return cache_facets($facet_key, $new_ids, $IAmMetabib, $ignore_facet_classes) if $docache;
1359 }
1360
1361 sub one_class_one_term {
1362     my $qstruct = shift;
1363     my $node = $$qstruct{children};
1364
1365     my $class = undef;
1366     my $term = undef;
1367     while ($node) {
1368         last if (
1369             $$node{'|'}
1370             or @{$$node{'&'}} != 1
1371             or ($$node{'&'}[0]{fields} and @{$$node{'&'}[0]{fields}} > 0)
1372         );
1373
1374         $class ||= $$node{'&'}[0]{class};
1375         $term ||= $$node{'&'}[0]{content};
1376
1377         last if ($term);
1378
1379         $node = $$node{'&'}[0]{children};
1380     }
1381
1382     return ($class, $term);
1383 }
1384
1385 sub fetch_display_fields {
1386     my $self = shift;
1387     my $conn = shift;
1388     my $highlight_map = shift;
1389     my @records = @_;
1390
1391     unless (@records) {
1392         $conn->respond_complete;
1393         return;
1394     }
1395
1396     my $hl_map_string = "";
1397     if (ref($highlight_map) =~ /HASH/) {
1398         for my $tsq (keys %$highlight_map) {
1399             my $field_list = join(',', @{$$highlight_map{$tsq}});
1400             $hl_map_string .= ' || ' if $hl_map_string;
1401             $hl_map_string .= "hstore(($tsq)\:\:TEXT,'$field_list')";
1402         }
1403     }
1404
1405     my $e = new_editor();
1406
1407     for my $record ( @records ) {
1408         next unless ($record && $hl_map_string);
1409         $conn->respond(
1410             $e->json_query(
1411                 {from => ['search.highlight_display_fields', $record, $hl_map_string]}
1412             )
1413         );
1414     }
1415
1416     return undef;
1417 }
1418 __PACKAGE__->register_method(
1419     method    => 'fetch_display_fields',
1420     api_name  => 'open-ils.search.fetch.metabib.display_field.highlight',
1421     stream   => 1
1422 );
1423
1424
1425 sub tag_circulated_records {
1426     my ($auth, $results, $metabib) = @_;
1427     my $e = new_editor(authtoken => $auth);
1428     return $results unless $e->checkauth;
1429
1430     my $query = {
1431         select   => { acn => [{ column => 'record', alias => 'tagme' }] }, 
1432         from     => { auch => { acp => { join => 'acn' }} }, 
1433         where    => { usr => $e->requestor->id },
1434         distinct => 1
1435     };
1436
1437     if ($metabib) {
1438         $query = {
1439             select   => { mmrsm => [{ column => 'metarecord', alias => 'tagme' }] },
1440             from     => 'mmrsm',
1441             where    => { source => { in => $query } },
1442             distinct => 1
1443         };
1444     }
1445
1446     # Give me the distinct set of bib records that exist in the user's visible circulation history
1447     my $circ_recs = $e->json_query( $query );
1448
1449     # if the record appears in the circ history, push a 1 onto 
1450     # the rec array structure to indicate truthiness
1451     for my $rec (@$results) {
1452         push(@$rec, 1) if grep { $_->{tagme} eq $$rec[0] } @$circ_recs;
1453     }
1454
1455     $results
1456 }
1457
1458 # creates a unique token to represent the query in the cache
1459 sub search_cache_key {
1460     my $method = shift;
1461     my $search_hash = shift;
1462     my @sorted;
1463     for my $key (sort keys %$search_hash) {
1464         push(@sorted, ($key => $$search_hash{$key})) 
1465             unless $key eq 'limit'  or 
1466                    $key eq 'offset' or 
1467                    $key eq 'skip_check';
1468     }
1469     my $s = OpenSRF::Utils::JSON->perl2JSON(\@sorted);
1470     return $pfx . md5_hex($method . $s);
1471 }
1472
1473 sub retrieve_cached_facets {
1474     my $self   = shift;
1475     my $client = shift;
1476     my $key    = shift;
1477     my $limit    = shift;
1478
1479     return undef unless ($key and $key =~ /_facets$/);
1480
1481     eval {
1482         local $SIG{ALRM} = sub {die};
1483         alarm(10); # we'll sleep for as much as 10s
1484         do {
1485             die if $cache->get_cache($key . '_COMPLETE');
1486         } while (sleep(0.05));
1487         alarm(0);
1488     };
1489     alarm(0);
1490
1491     my $blob = $cache->get_cache($key) || {};
1492
1493     my $facets = {};
1494     if ($limit) {
1495        for my $f ( keys %$blob ) {
1496             my @sorted = map{ { $$_[1] => $$_[0] } } sort {$$b[0] <=> $$a[0] || $$a[1] cmp $$b[1]} map { [$$blob{$f}{$_}, $_] } keys %{ $$blob{$f} };
1497             @sorted = @sorted[0 .. $limit - 1] if (scalar(@sorted) > $limit);
1498             for my $s ( @sorted ) {
1499                 my ($k) = keys(%$s);
1500                 my ($v) = values(%$s);
1501                 $$facets{$f}{$k} = $v;
1502             }
1503         }
1504     } else {
1505         $facets = $blob;
1506     }
1507
1508     return $facets;
1509 }
1510
1511 __PACKAGE__->register_method(
1512     method   => "retrieve_cached_facets",
1513     api_name => "open-ils.search.facet_cache.retrieve",
1514     signature => {
1515         desc   => 'Returns facet data derived from a specific search based on a key '.
1516                   'generated by open-ils.search.biblio.multiclass.staged and friends.',
1517         params => [
1518             {
1519                 desc => "The facet cache key returned with the initial search as the facet_key hash value",
1520                 type => 'string',
1521             }
1522         ],
1523         return => {
1524             desc => 'Two level hash of facet values.  Top level key is the facet id defined on the config.metabib_field table.  '.
1525                     'Second level key is a string facet value.  Datum attached to each facet value is the number of distinct records, '.
1526                     'or metarecords for a metarecord search, which use that facet value and are visible to the search at the time of '.
1527                     'facet retrieval.  These counts are calculated for all superpages that have been checked for visibility.',
1528             type => 'object',
1529         }
1530     }
1531 );
1532
1533
1534 sub cache_facets {
1535     # add facets for this search to the facet cache
1536     my($key, $results, $metabib, $ignore) = @_;
1537     my $data = $cache->get_cache($key);
1538     $data ||= {};
1539
1540     return undef unless (@$results);
1541
1542     my $facets_function = $metabib ? 'search.facets_for_metarecord_set'
1543                                    : 'search.facets_for_record_set';
1544     my $results_str = '{' . join(',', @$results) . '}';
1545     my $ignore_str = ref($ignore) ? '{' . join(',', @$ignore) . '}'
1546                                   : '{}';
1547     my $query = {   
1548         from => [ $facets_function, $ignore_str, $results_str ]
1549     };
1550
1551     my $facets = OpenILS::Utils::CStoreEditor->new->json_query($query, {substream => 1});
1552
1553     for my $facet (@$facets) {
1554         next unless ($facet->{value});
1555         $data->{$facet->{id}}->{$facet->{value}} += $facet->{count};
1556     }
1557
1558     $logger->info("facet compilation: cached with key=$key");
1559
1560     $cache->put_cache($key, $data, $cache_timeout);
1561     $cache->put_cache($key.'_COMPLETE', 1, $cache_timeout);
1562 }
1563
1564 sub cache_staged_search_page {
1565     # puts this set of results into the cache
1566     my($key, $page, $summary, $results) = @_;
1567     my $data = $cache->get_cache($key);
1568     $data ||= {};
1569     $data->{$page} = {
1570         summary => $summary,
1571         results => $results
1572     };
1573
1574     $logger->info("staged search: cached with key=$key, superpage=$page, estimated=".
1575         ($summary->{estimated_hit_count} || "none") .
1576         ", visible=" . ($summary->{visible} || "none")
1577     );
1578
1579     $cache->put_cache($key, $data, $cache_timeout);
1580 }
1581
1582 sub search_cache {
1583
1584     my $key     = shift;
1585     my $offset  = shift;
1586     my $limit   = shift;
1587     my $start   = $offset;
1588     my $end     = $offset + $limit - 1;
1589
1590     $logger->debug("searching cache for $key : $start..$end\n");
1591
1592     return undef unless $cache;
1593     my $data = $cache->get_cache($key);
1594
1595     return undef unless $data;
1596
1597     my $count = $data->[0];
1598     $data = $data->[1];
1599
1600     return undef unless $offset < $count;
1601
1602     my @result;
1603     for( my $i = $offset; $i <= $end; $i++ ) {
1604         last unless my $d = $$data[$i];
1605         push( @result, $d );
1606     }
1607
1608     $logger->debug("search_cache found ".scalar(@result)." items for count=$count, start=$start, end=$end");
1609
1610     return \@result;
1611 }
1612
1613
1614 sub put_cache {
1615     my( $key, $count, $data ) = @_;
1616     return undef unless $cache;
1617     $logger->debug("search_cache putting ".
1618         scalar(@$data)." items at key $key with timeout $cache_timeout");
1619     $cache->put_cache($key, [ $count, $data ], $cache_timeout);
1620 }
1621
1622
1623 __PACKAGE__->register_method(
1624     method   => "biblio_mrid_to_modsbatch_batch",
1625     api_name => "open-ils.search.biblio.metarecord.mods_slim.batch.retrieve"
1626 );
1627
1628 sub biblio_mrid_to_modsbatch_batch {
1629     my( $self, $client, $mrids) = @_;
1630     # warn "Performing mrid_to_modsbatch_batch..."; # unconditional warn
1631     my @mods;
1632     my $method = $self->method_lookup("open-ils.search.biblio.metarecord.mods_slim.retrieve");
1633     for my $id (@$mrids) {
1634         next unless defined $id;
1635         my ($m) = $method->run($id);
1636         push @mods, $m;
1637     }
1638     return \@mods;
1639 }
1640
1641
1642 foreach (qw /open-ils.search.biblio.metarecord.mods_slim.retrieve
1643              open-ils.search.biblio.metarecord.mods_slim.retrieve.staff/)
1644     {
1645     __PACKAGE__->register_method(
1646         method    => "biblio_mrid_to_modsbatch",
1647         api_name  => $_,
1648         signature => {
1649             desc   => "Returns the mvr associated with a given metarecod. If none exists, it is created.  "
1650                     . "As usual, the .staff version of this method will include otherwise hidden records.",
1651             params => [
1652                 { desc => 'Metarecord ID', type => 'number' },
1653                 { desc => '(Optional) Search filters hash with possible keys: format, org, depth', type => 'object' }
1654             ],
1655             return => {
1656                 desc => 'MVR Object, event on error',
1657             }
1658         }
1659     );
1660 }
1661
1662 sub biblio_mrid_to_modsbatch {
1663     my( $self, $client, $mrid, $args) = @_;
1664
1665     # warn "Grabbing mvr for $mrid\n";    # unconditional warn
1666
1667     my ($mr, $evt) = _grab_metarecord($mrid);
1668     return $evt unless $mr;
1669
1670     my $mvr = biblio_mrid_check_mvr($self, $client, $mr) ||
1671               biblio_mrid_make_modsbatch($self, $client, $mr);
1672
1673     return $mvr unless ref($args);  
1674
1675     # Here we find the lead record appropriate for the given filters 
1676     # and use that for the title and author of the metarecord
1677     my $format = $$args{format};
1678     my $org    = $$args{org};
1679     my $depth  = $$args{depth};
1680
1681     return $mvr unless $format or $org or $depth;
1682
1683     my $method = "open-ils.storage.ordered.metabib.metarecord.records";
1684     $method = "$method.staff" if $self->api_name =~ /staff/o; 
1685
1686     my $rec = $U->storagereq($method, $format, $org, $depth, 1);
1687
1688     if( my $mods = $U->record_to_mvr($rec) ) {
1689
1690         $mvr->title( $mods->title );
1691         $mvr->author($mods->author);
1692         $logger->debug("mods_slim updating title and ".
1693             "author in mvr with ".$mods->title." : ".$mods->author);
1694     }
1695
1696     return $mvr;
1697 }
1698
1699 # converts a metarecord to an mvr
1700 sub _mr_to_mvr {
1701     my $mr = shift;
1702     my $perl = OpenSRF::Utils::JSON->JSON2perl($mr->mods());
1703     return Fieldmapper::metabib::virtual_record->new($perl);
1704 }
1705
1706 # checks to see if a metarecord has mods, if so returns true;
1707
1708 __PACKAGE__->register_method(
1709     method   => "biblio_mrid_check_mvr",
1710     api_name => "open-ils.search.biblio.metarecord.mods_slim.check",
1711     notes    => "Takes a metarecord ID or a metarecord object and returns true "
1712               . "if the metarecord already has an mvr associated with it."
1713 );
1714
1715 sub biblio_mrid_check_mvr {
1716     my( $self, $client, $mrid ) = @_;
1717     my $mr; 
1718
1719     my $evt;
1720     if(ref($mrid)) { $mr = $mrid; } 
1721     else { ($mr, $evt) = _grab_metarecord($mrid); }
1722     return $evt if $evt;
1723
1724     # warn "Checking mvr for mr " . $mr->id . "\n";   # unconditional warn
1725
1726     return _mr_to_mvr($mr) if $mr->mods();
1727     return undef;
1728 }
1729
1730 sub _grab_metarecord {
1731     my $mrid = shift;
1732     my $e = new_editor();
1733     my $mr = $e->retrieve_metabib_metarecord($mrid) or return ( undef, $e->event );
1734     return ($mr);
1735 }
1736
1737
1738 __PACKAGE__->register_method(
1739     method   => "biblio_mrid_make_modsbatch",
1740     api_name => "open-ils.search.biblio.metarecord.mods_slim.create",
1741     notes    => "Takes either a metarecord ID or a metarecord object. "
1742               . "Forces the creations of an mvr for the given metarecord. "
1743               . "The created mvr is returned."
1744 );
1745
1746 sub biblio_mrid_make_modsbatch {
1747     my( $self, $client, $mrid ) = @_;
1748
1749     my $e = new_editor();
1750
1751     my $mr;
1752     if( ref($mrid) ) {
1753         $mr = $mrid;
1754         $mrid = $mr->id;
1755     } else {
1756         $mr = $e->retrieve_metabib_metarecord($mrid) 
1757             or return $e->event;
1758     }
1759
1760     my $masterid = $mr->master_record;
1761     $logger->info("creating new mods batch for metarecord=$mrid, master record=$masterid");
1762
1763     my $ids = $U->storagereq(
1764         'open-ils.storage.ordered.metabib.metarecord.records.staff.atomic', $mrid);
1765     return undef unless @$ids;
1766
1767     my $master = $e->retrieve_biblio_record_entry($masterid)
1768         or return $e->event;
1769
1770     # start the mods batch
1771     my $u = OpenILS::Utils::ModsParser->new();
1772     $u->start_mods_batch( $master->marc );
1773
1774     # grab all of the sub-records and shove them into the batch
1775     my @ids = grep { $_ ne $masterid } @$ids;
1776     #my $subrecs = (@ids) ? $e->batch_retrieve_biblio_record_entry(\@ids) : [];
1777
1778     my $subrecs = [];
1779     if(@$ids) {
1780         for my $i (@$ids) {
1781             my $r = $e->retrieve_biblio_record_entry($i);
1782             push( @$subrecs, $r ) if $r;
1783         }
1784     }
1785
1786     for(@$subrecs) {
1787         $logger->debug("adding record ".$_->id." to mods batch for metarecord=$mrid");
1788         $u->push_mods_batch( $_->marc ) if $_->marc;
1789     }
1790
1791
1792     # finish up and send to the client
1793     my $mods = $u->finish_mods_batch();
1794     $mods->doc_id($mrid);
1795     $client->respond_complete($mods);
1796
1797
1798     # now update the mods string in the db
1799     my $string = OpenSRF::Utils::JSON->perl2JSON($mods->decast);
1800     $mr->mods($string);
1801
1802     $e = new_editor(xact => 1);
1803     $e->update_metabib_metarecord($mr) 
1804         or $logger->error("Error setting mods text on metarecord $mrid : " . Dumper($e->event));
1805     $e->finish;
1806
1807     return undef;
1808 }
1809
1810
1811 # converts a mr id into a list of record ids
1812
1813 foreach (qw/open-ils.search.biblio.metarecord_to_records
1814             open-ils.search.biblio.metarecord_to_records.staff/)
1815 {
1816     __PACKAGE__->register_method(
1817         method    => "biblio_mrid_to_record_ids",
1818         api_name  => $_,
1819         signature => {
1820             desc   => "Fetch record IDs corresponding to a meta-record ID, with optional search filters. "
1821                     . "As usual, the .staff version of this method will include otherwise hidden records.",
1822             params => [
1823                 { desc => 'Metarecord ID', type => 'number' },
1824                 { desc => '(Optional) Search filters hash with possible keys: format, org, depth', type => 'object' }
1825             ],
1826             return => {
1827                 desc => 'Results object like {count => $i, ids =>[...]}',
1828                 type => 'object'
1829             }
1830             
1831         }
1832     );
1833 }
1834
1835 sub biblio_mrid_to_record_ids {
1836     my( $self, $client, $mrid, $args ) = @_;
1837
1838     my $format = $$args{format};
1839     my $org    = $$args{org};
1840     my $depth  = $$args{depth};
1841
1842     my $method = "open-ils.storage.ordered.metabib.metarecord.records.atomic";
1843     $method =~ s/atomic/staff\.atomic/o if $self->api_name =~ /staff/o; 
1844     my $recs = $U->storagereq($method, $mrid, $format, $org, $depth);
1845
1846     return { count => scalar(@$recs), ids => $recs };
1847 }
1848
1849
1850 __PACKAGE__->register_method(
1851     method   => "biblio_record_to_marc_html",
1852     api_name => "open-ils.search.biblio.record.html"
1853 );
1854
1855 __PACKAGE__->register_method(
1856     method   => "biblio_record_to_marc_html",
1857     api_name => "open-ils.search.authority.to_html"
1858 );
1859
1860 # Persistent parsers and setting objects
1861 my $parser = XML::LibXML->new();
1862 my $xslt   = XML::LibXSLT->new();
1863 my $marc_sheet;
1864 my $slim_marc_sheet;
1865 my $settings_client = OpenSRF::Utils::SettingsClient->new();
1866
1867 sub biblio_record_to_marc_html {
1868     my($self, $client, $recordid, $slim, $marcxml) = @_;
1869
1870     my $sheet;
1871     my $dir = $settings_client->config_value("dirs", "xsl");
1872
1873     if($slim) {
1874         unless($slim_marc_sheet) {
1875             my $xsl = $settings_client->config_value(
1876                 "apps", "open-ils.search", "app_settings", 'marc_html_xsl_slim');
1877             if($xsl) {
1878                 $xsl = $parser->parse_file("$dir/$xsl");
1879                 $slim_marc_sheet = $xslt->parse_stylesheet($xsl);
1880             }
1881         }
1882         $sheet = $slim_marc_sheet;
1883     }
1884
1885     unless($sheet) {
1886         unless($marc_sheet) {
1887             my $xsl_key = ($slim) ? 'marc_html_xsl_slim' : 'marc_html_xsl';
1888             my $xsl = $settings_client->config_value(
1889                 "apps", "open-ils.search", "app_settings", 'marc_html_xsl');
1890             $xsl = $parser->parse_file("$dir/$xsl");
1891             $marc_sheet = $xslt->parse_stylesheet($xsl);
1892         }
1893         $sheet = $marc_sheet;
1894     }
1895
1896     my $record;
1897     unless($marcxml) {
1898         my $e = new_editor();
1899         if($self->api_name =~ /authority/) {
1900             $record = $e->retrieve_authority_record_entry($recordid)
1901                 or return $e->event;
1902         } else {
1903             $record = $e->retrieve_biblio_record_entry($recordid)
1904                 or return $e->event;
1905         }
1906         $marcxml = $record->marc;
1907     }
1908
1909     my $xmldoc = $parser->parse_string($marcxml);
1910     my $html = $sheet->transform($xmldoc);
1911     return $html->documentElement->toString();
1912 }
1913
1914 __PACKAGE__->register_method(
1915     method    => "send_event_email_output",
1916     api_name  => "open-ils.search.biblio.record.email.send_output",
1917 );
1918 sub send_event_email_output {
1919     my($self, $client, $auth, $event_id, $capkey, $capanswer) = @_;
1920     return undef unless $event_id;
1921
1922     my $captcha_pass = 0;
1923     my $real_answer;
1924     if ($capkey) {
1925         $real_answer = $cache->get_cache(md5_hex($capkey));
1926         $captcha_pass++ if ($real_answer eq $capanswer);
1927     }
1928
1929     my $e = new_editor(authtoken => $auth);
1930     return $e->die_event unless $captcha_pass || $e->checkauth;
1931
1932     my $event = $e->retrieve_action_trigger_event([$event_id,{flesh => 1, flesh_fields => { atev => ['template_output']}}]);
1933     return undef unless ($event and $event->template_output);
1934
1935     my $smtp = OpenSRF::Utils::SettingsClient
1936         ->new
1937         ->config_value('email_notify', 'smtp_server');
1938
1939     my $sender = Email::Send->new({mailer => 'SMTP'});
1940     $sender->mailer_args([Host => $smtp]);
1941
1942     my $stat;
1943     my $err;
1944
1945     my $email = Email::Simple->new($event->template_output->data);
1946
1947     for my $hfield (qw/From To Subject Bcc Cc Reply-To Sender/) {
1948         my @headers = $email->header($hfield);
1949         $email->header_set($hfield => map { encode("MIME-Header", $_) } @headers) if ($headers[0]);
1950     }
1951
1952     $email->header_set('MIME-Version' => '1.0');
1953     $email->header_set('Content-Type' => "text/plain; charset=UTF-8");
1954     $email->header_set('Content-Transfer-Encoding' => '8bit');
1955
1956     try {
1957         $stat = $sender->send($email);
1958     } catch Error with {
1959         $err = $stat = shift;
1960         $logger->error("send_event_email_output: Email failed with error: $err");
1961     };
1962
1963     if( !$err and $stat and $stat->type eq 'success' ) {
1964         $logger->info("send_event_email_output: successfully sent email");
1965         return 1;
1966     } else {
1967         $logger->warn("send_event_email_output: unable to send email: ".Dumper($stat));
1968         return 0;
1969     }
1970 }
1971
1972 __PACKAGE__->register_method(
1973     method    => "format_biblio_record_entry",
1974     api_name  => "open-ils.search.biblio.record.print.preview",
1975 );
1976
1977 __PACKAGE__->register_method(
1978     method    => "format_biblio_record_entry",
1979     api_name  => "open-ils.search.biblio.record.email.preview",
1980 );
1981
1982 __PACKAGE__->register_method(
1983     method    => "format_biblio_record_entry",
1984     api_name  => "open-ils.search.biblio.record.print",
1985     signature => {
1986         desc   => 'Returns a printable version of the specified bib record',
1987         params => [
1988             { desc => 'Biblio record entry ID or array of IDs', type => 'number' },
1989             { desc => 'Context library for holdings, if applicable', type => 'number' },
1990             { desc => 'Sort order, if applicable', type => 'string' },
1991             { desc => 'Sort direction, if applicable', type => 'string' },
1992             { desc => 'Definition Group Member id', type => 'number' },
1993         ],
1994         return => {
1995             desc => q/An action_trigger.event object or error event./,
1996             type => 'object',
1997         }
1998     }
1999 );
2000 __PACKAGE__->register_method(
2001     method    => "format_biblio_record_entry",
2002     api_name  => "open-ils.search.biblio.record.email",
2003     signature => {
2004         desc   => 'Emails an A/T templated version of the specified bib records to the authorized user',
2005         params => [
2006             { desc => 'Authentication token', type => 'string'},
2007             { desc => 'Biblio record entry ID or array of IDs', type => 'number' },
2008             { desc => 'Context library for holdings, if applicable', type => 'number' },
2009             { desc => 'Sort order, if applicable', type => 'string' },
2010             { desc => 'Sort direction, if applicable', type => 'string' },
2011             { desc => 'Definition Group Member id', type => 'number' },
2012             { desc => 'Whether to bypass auth due to captcha', type => 'bool' },
2013             { desc => 'Email address, if none for the user', type => 'string' },
2014             { desc => 'Subject, if customized', type => 'string' },
2015         ],
2016         return => {
2017             desc => q/Undefined on success, otherwise an error event./,
2018             type => 'object',
2019         }
2020     }
2021 );
2022
2023 sub format_biblio_record_entry {
2024     my ($self, $conn) = splice @_, 0, 2;
2025
2026     my $for_print = ($self->api_name =~ /print/);
2027     my $for_email = ($self->api_name =~ /email/);
2028     my $preview = ($self->api_name =~ /preview/);
2029
2030     my ($auth, $captcha_pass, $email, $subject);
2031     if ($for_email) {
2032         $auth = shift @_;
2033         if (@_ > 5) { # the stuff below is included in the params, safe to splice
2034             ($captcha_pass, $email, $subject) = splice @_, -3, 3;
2035         }
2036     }
2037     my ($bib_id, $holdings_context_org, $bib_sort, $sort_dir, $group_member) = @_;
2038     $holdings_context_org ||= $U->get_org_tree->id;
2039     $bib_sort ||= 'author';
2040     $sort_dir ||= 'ascending';
2041
2042     my $e; my $event_context_org; my $type = 'brief';
2043
2044     if ($for_print) {
2045         $event_context_org = $holdings_context_org;
2046         $e = new_editor(xact => 1);
2047     } elsif ($for_email) {
2048         $e = new_editor(authtoken => $auth, xact => 1);
2049         return $e->die_event unless $captcha_pass || $e->checkauth;
2050         $event_context_org = $e->requestor ? $e->requestor->home_ou : $holdings_context_org;
2051         $email ||= $e->requestor ? $e->requestor->email : '';
2052     }
2053
2054     if ($group_member) {
2055         $group_member = $e->retrieve_action_trigger_event_def_group_member($group_member);
2056         if ($group_member and $U->is_true($group_member->holdings)) {
2057             $type = 'full';
2058         }
2059     }
2060
2061     $holdings_context_org = $e->retrieve_actor_org_unit($holdings_context_org);
2062
2063     my $bib_ids;
2064     if (ref $bib_id ne 'ARRAY') {
2065         $bib_ids = [ $bib_id ];
2066     } else {
2067         $bib_ids = $bib_id;
2068     }
2069
2070     my $bucket = Fieldmapper::container::biblio_record_entry_bucket->new;
2071     $bucket->btype('temp');
2072     $bucket->name('format_biblio_record_entry ' . $U->create_uuid_string);
2073     if ($for_email) {
2074         $bucket->owner($e->requestor || 1) 
2075     } else {
2076         $bucket->owner(1);
2077     }
2078     my $bucket_obj = $e->create_container_biblio_record_entry_bucket($bucket);
2079
2080     for my $id (@$bib_ids) {
2081
2082         my $bib = $e->retrieve_biblio_record_entry([$id]) or return $e->die_event;
2083
2084         my $bucket_entry = Fieldmapper::container::biblio_record_entry_bucket_item->new;
2085         $bucket_entry->target_biblio_record_entry($bib);
2086         $bucket_entry->bucket($bucket_obj->id);
2087         $e->create_container_biblio_record_entry_bucket_item($bucket_entry);
2088     }
2089
2090     $e->commit;
2091
2092     my $usr_data = {
2093         type        => $type,
2094         email       => $email,
2095         subject     => $subject,
2096         context_org => $holdings_context_org->shortname,
2097         sort_by     => $bib_sort,
2098         sort_dir    => $sort_dir,
2099         preview     => $preview
2100     };
2101
2102     if ($for_print) {
2103
2104         return $U->fire_object_event(undef, 'biblio.format.record_entry.print', [ $bucket ], $event_context_org, undef, [ $usr_data ]);
2105
2106     } elsif ($for_email) {
2107
2108         return $U->fire_object_event(undef, 'biblio.format.record_entry.email', [ $bucket ], $event_context_org, undef, [ $usr_data ])
2109             if ($preview);
2110
2111         $U->create_events_for_hook('biblio.format.record_entry.email', $bucket, $event_context_org, undef, $usr_data, 1);
2112     }
2113
2114     return undef;
2115 }
2116
2117
2118 __PACKAGE__->register_method(
2119     method   => "retrieve_all_copy_statuses",
2120     api_name => "open-ils.search.config.copy_status.retrieve.all"
2121 );
2122
2123 sub retrieve_all_copy_statuses {
2124     my( $self, $client ) = @_;
2125     return new_editor()->retrieve_all_config_copy_status();
2126 }
2127
2128
2129 __PACKAGE__->register_method(
2130     method   => "copy_counts_per_org",
2131     api_name => "open-ils.search.biblio.copy_counts.retrieve"
2132 );
2133
2134 __PACKAGE__->register_method(
2135     method   => "copy_counts_per_org",
2136     api_name => "open-ils.search.biblio.copy_counts.retrieve.staff"
2137 );
2138
2139 sub copy_counts_per_org {
2140     my( $self, $client, $record_id ) = @_;
2141
2142     warn "Retreiveing copy copy counts for record $record_id and method " . $self->api_name . "\n";
2143
2144     my $method = "open-ils.storage.biblio.record_entry.global_copy_count.atomic";
2145     if($self->api_name =~ /staff/) { $method =~ s/atomic/staff\.atomic/; }
2146
2147     my $counts = $apputils->simple_scalar_request(
2148         "open-ils.storage", $method, $record_id );
2149
2150     $counts = [ sort {$a->[0] <=> $b->[0]} @$counts ];
2151     return $counts;
2152 }
2153
2154
2155 __PACKAGE__->register_method(
2156     method   => "copy_count_summary",
2157     api_name => "open-ils.search.biblio.copy_counts.summary.retrieve",
2158     notes    => "returns an array of these: "
2159               . "[ org_id, callnumber_prefix, callnumber_label, callnumber_suffix, <status1_count>, <status2_count>,...] "
2160               . "where statusx is a copy status name.  The statuses are sorted by ID.",
2161 );
2162         
2163
2164 sub copy_count_summary {
2165     my( $self, $client, $rid, $org, $depth ) = @_;
2166     $org   ||= 1;
2167     $depth ||= 0;
2168     my $data = $U->storagereq(
2169         'open-ils.storage.biblio.record_entry.status_copy_count.atomic', $rid, $org, $depth );
2170
2171     return [ sort {
2172         (($a->[1] ? $a->[1] . ' ' : '') . $a->[2] . ($a->[3] ? ' ' . $a->[3] : ''))
2173         cmp
2174         (($b->[1] ? $b->[1] . ' ' : '') . $b->[2] . ($b->[3] ? ' ' . $b->[3] : ''))
2175     } @$data ];
2176 }
2177
2178 __PACKAGE__->register_method(
2179     method   => "copy_location_count_summary",
2180     api_name => "open-ils.search.biblio.copy_location_counts.summary.retrieve",
2181     notes    => "returns an array of these: "
2182               . "[ org_id, callnumber_prefix, callnumber_label, callnumber_suffix, copy_location, <status1_count>, <status2_count>,...] "
2183               . "where statusx is a copy status name.  The statuses are sorted by ID.",
2184 );
2185
2186 sub copy_location_count_summary {
2187     my( $self, $client, $rid, $org, $depth ) = @_;
2188     $org   ||= 1;
2189     $depth ||= 0;
2190     my $data = $U->storagereq(
2191         'open-ils.storage.biblio.record_entry.status_copy_location_count.atomic', $rid, $org, $depth );
2192
2193     return [ sort {
2194         (($a->[1] ? $a->[1] . ' ' : '') . $a->[2] . ($a->[3] ? ' ' . $a->[3] : ''))
2195         cmp
2196         (($b->[1] ? $b->[1] . ' ' : '') . $b->[2] . ($b->[3] ? ' ' . $b->[3] : ''))
2197
2198         || $a->[4] cmp $b->[4]
2199     } @$data ];
2200 }
2201
2202 __PACKAGE__->register_method(
2203     method   => "copy_count_location_summary",
2204     api_name => "open-ils.search.biblio.copy_counts.location.summary.retrieve",
2205     notes    => "returns an array of these: "
2206               . "[ org_id, callnumber_prefix, callnumber_label, callnumber_suffix, <status1_count>, <status2_count>,...] "
2207               . "where statusx is a copy status name.  The statuses are sorted by ID."
2208 );
2209
2210 sub copy_count_location_summary {
2211     my( $self, $client, $rid, $org, $depth ) = @_;
2212     $org   ||= 1;
2213     $depth ||= 0;
2214     my $data = $U->storagereq(
2215         'open-ils.storage.biblio.record_entry.status_copy_location_count.atomic', $rid, $org, $depth );
2216     return [ sort {
2217         (($a->[1] ? $a->[1] . ' ' : '') . $a->[2] . ($a->[3] ? ' ' . $a->[3] : ''))
2218         cmp
2219         (($b->[1] ? $b->[1] . ' ' : '') . $b->[2] . ($b->[3] ? ' ' . $b->[3] : ''))
2220     } @$data ];
2221 }
2222
2223
2224 foreach (qw/open-ils.search.biblio.marc
2225             open-ils.search.biblio.marc.staff/)
2226 {
2227 __PACKAGE__->register_method(
2228     method    => "marc_search",
2229     api_name  => $_,
2230     signature => {
2231         desc   => 'Fetch biblio IDs based on MARC record criteria.  '
2232                 . 'As usual, the .staff version of the search includes otherwise hidden records',
2233         params => [
2234             {
2235                 desc => 'Search hash (required) with possible elements: searches, limit, offset, sort, sort_dir. ' .
2236                         'See perldoc ' . __PACKAGE__ . ' for more detail.',
2237                 type => 'object'
2238             },
2239             {desc => 'timeout (optional)',  type => 'number'}
2240         ],
2241         return => {
2242             desc => 'Results object like: { "count": $i, "ids": [...] }',
2243             type => 'object'
2244         }
2245     }
2246 );
2247 }
2248
2249 =head3 open-ils.search.biblio.marc (arghash, timeout)
2250
2251 As elsewhere the arghash is the required argument, and must be a hashref.  The keys are:
2252
2253     searches: complex query object  (required)
2254     org_unit: The org ID to focus the search at
2255     depth   : The org depth     
2256     limit   : integer search limit      default: 10
2257     offset  : integer search offset     default:  0
2258     sort    : What field to sort the results on? [ author | title | pubdate ]
2259     sort_dir: In what direction do we sort? [ asc | desc ]
2260
2261 Additional keys to refine search criteria:
2262
2263     audience : Audience
2264     language : Language (code)
2265     lit_form : Literary form
2266     item_form: Item form
2267     item_type: Item type
2268     format   : The MARC format
2269
2270 Please note that the specific strings to be used in the "addtional keys" will be entirely
2271 dependent on your loaded data.  
2272
2273 All keys except "searches" are optional.
2274 The "searches" value must be an arrayref of hashref elements, including keys "term" and "restrict".  
2275
2276 For example, an arg hash might look like:
2277
2278     $arghash = {
2279         searches => [
2280             {
2281                 term     => "harry",
2282                 restrict => [
2283                     {
2284                         tag => 245,
2285                         subfield => "a"
2286                     }
2287                     # ...
2288                 ]
2289             }
2290             # ...
2291         ],
2292         org_unit  => 1,
2293         limit     => 5,
2294         sort      => "author",
2295         item_type => "g"
2296     }
2297
2298 The arghash is eventually passed to the SRF call:
2299 L<open-ils.storage.biblio.full_rec.multi_search[.staff].atomic>
2300
2301 Presently, search uses the cache unconditionally.
2302
2303 =cut
2304
2305 # FIXME: that example above isn't actually tested.
2306 # FIXME: sort and limit added.  item_type not tested yet.
2307 # TODO: docache option?
2308 sub marc_search {
2309     my( $self, $conn, $args, $timeout ) = @_;
2310
2311     my $method = 'open-ils.storage.biblio.full_rec.multi_search';
2312     $method .= ".staff" if $self->api_name =~ /staff/;
2313     $method .= ".atomic";
2314
2315     my $limit = $args->{limit} || 10;
2316     my $offset = $args->{offset} || 0;
2317
2318     # allow caller to pass in a call timeout since MARC searches
2319     # can take longer than the default 60-second timeout.  
2320     # Default to 2 mins.  Arbitrarily cap at 5 mins.
2321     $timeout = 120 if !$timeout or $timeout > 300;
2322
2323     my @search;
2324     push( @search, ($_ => $$args{$_}) ) for (sort keys %$args);
2325     my $ckey = $pfx . md5_hex($method . OpenSRF::Utils::JSON->perl2JSON(\@search));
2326
2327     my $recs = search_cache($ckey, $offset, $limit);
2328
2329     if(!$recs) {
2330
2331         my $ses = OpenSRF::AppSession->create('open-ils.storage');
2332         my $req = $ses->request($method, %$args);
2333         my $resp = $req->recv($timeout);
2334
2335         if($resp and $recs = $resp->content) {
2336             put_cache($ckey, scalar(@$recs), $recs);
2337         } else {
2338             $recs = [];
2339         }
2340
2341         $ses->kill_me;
2342     }
2343
2344     my $count = 0;
2345     $count = $recs->[0]->[2] if $recs->[0] and $recs->[0]->[2];
2346     my @recs = map { $_->[0] } @$recs;
2347
2348     return { ids => \@recs, count => $count };
2349 }
2350
2351
2352 foreach my $isbn_method (qw/
2353     open-ils.search.biblio.isbn
2354     open-ils.search.biblio.isbn.staff
2355 /) {
2356 __PACKAGE__->register_method(
2357     method    => "biblio_search_isbn",
2358     api_name  => $isbn_method,
2359     signature => {
2360         desc   => 'Retrieve biblio IDs for a given ISBN. The .staff version of the call includes otherwise hidden hits.',
2361         params => [
2362             {desc => 'ISBN', type => 'string'}
2363         ],
2364         return => {
2365             desc => 'Results object like: { "count": $i, "ids": [...] }',
2366             type => 'object'
2367         }
2368     }
2369 );
2370 }
2371
2372 sub biblio_search_isbn { 
2373     my( $self, $client, $isbn ) = @_;
2374     $logger->debug("Searching ISBN $isbn");
2375     # the previous implementation of this method was essentially unlimited,
2376     # so we will set our limit very high and let multiclass.query provide any
2377     # actual limit
2378     # XXX: if making this unlimited is deemed important, we might consider
2379     # reworking 'open-ils.storage.id_list.biblio.record_entry.search.isbn',
2380     # which is functionally deprecated at this point, or a custom call to
2381     # 'open-ils.storage.biblio.multiclass.search_fts'
2382
2383     my $isbn_method = 'open-ils.search.biblio.multiclass.query';
2384     if ($self->api_name =~ m/.staff$/) {
2385         $isbn_method .= '.staff';
2386     }
2387
2388     my $method = $self->method_lookup($isbn_method);
2389     my ($search_result) = $method->run({'limit' => 1000000}, "identifier|isbn:$isbn");
2390     my @recs = map { $_->[0] } @{$search_result->{'ids'}};
2391     return { ids => \@recs, count => $search_result->{'count'} };
2392 }
2393
2394 __PACKAGE__->register_method(
2395     method   => "biblio_search_isbn_batch",
2396     api_name => "open-ils.search.biblio.isbn_list",
2397 );
2398
2399 # XXX: see biblio_search_isbn() for note concerning 'limit'
2400 sub biblio_search_isbn_batch { 
2401     my( $self, $client, $isbn_list ) = @_;
2402     $logger->debug("Searching ISBNs @$isbn_list");
2403     my @recs = (); my %rec_set = ();
2404     my $method = $self->method_lookup('open-ils.search.biblio.multiclass.query');
2405     foreach my $isbn ( @$isbn_list ) {
2406         my ($search_result) = $method->run({'limit' => 1000000}, "identifier|isbn:$isbn");
2407         my @recs_subset = map { $_->[0] } @{$search_result->{'ids'}};
2408         foreach my $rec (@recs_subset) {
2409             if (! $rec_set{ $rec }) {
2410                 $rec_set{ $rec } = 1;
2411                 push @recs, $rec;
2412             }
2413         }
2414     }
2415     return { ids => \@recs, count => int(scalar(@recs)) };
2416 }
2417
2418 foreach my $issn_method (qw/
2419     open-ils.search.biblio.issn
2420     open-ils.search.biblio.issn.staff
2421 /) {
2422 __PACKAGE__->register_method(
2423     method   => "biblio_search_issn",
2424     api_name => $issn_method,
2425     signature => {
2426         desc   => 'Retrieve biblio IDs for a given ISSN',
2427         params => [
2428             {desc => 'ISBN', type => 'string'}
2429         ],
2430         return => {
2431             desc => 'Results object like: { "count": $i, "ids": [...] }',
2432             type => 'object'
2433         }
2434     }
2435 );
2436 }
2437
2438 sub biblio_search_issn { 
2439     my( $self, $client, $issn ) = @_;
2440     $logger->debug("Searching ISSN $issn");
2441     # the previous implementation of this method was essentially unlimited,
2442     # so we will set our limit very high and let multiclass.query provide any
2443     # actual limit
2444     # XXX: if making this unlimited is deemed important, we might consider
2445     # reworking 'open-ils.storage.id_list.biblio.record_entry.search.issn',
2446     # which is functionally deprecated at this point, or a custom call to
2447     # 'open-ils.storage.biblio.multiclass.search_fts'
2448
2449     my $issn_method = 'open-ils.search.biblio.multiclass.query';
2450     if ($self->api_name =~ m/.staff$/) {
2451         $issn_method .= '.staff';
2452     }
2453
2454     my $method = $self->method_lookup($issn_method);
2455     my ($search_result) = $method->run({'limit' => 1000000}, "identifier|issn:$issn");
2456     my @recs = map { $_->[0] } @{$search_result->{'ids'}};
2457     return { ids => \@recs, count => $search_result->{'count'} };
2458 }
2459
2460
2461 __PACKAGE__->register_method(
2462     method    => "fetch_mods_by_copy",
2463     api_name  => "open-ils.search.biblio.mods_from_copy",
2464     argc      => 1,
2465     signature => {
2466         desc    => 'Retrieve MODS record given an attached copy ID',
2467         params  => [
2468             { desc => 'Copy ID', type => 'number' }
2469         ],
2470         returns => {
2471             desc => 'MODS record, event on error or uncataloged item'
2472         }
2473     }
2474 );
2475
2476 sub fetch_mods_by_copy {
2477     my( $self, $client, $copyid ) = @_;
2478     my ($record, $evt) = $apputils->fetch_record_by_copy( $copyid );
2479     return $evt if $evt;
2480     return OpenILS::Event->new('ITEM_NOT_CATALOGED') unless $record->marc;
2481     return $apputils->record_to_mvr($record);
2482 }
2483
2484
2485 # -------------------------------------------------------------------------------------
2486
2487 __PACKAGE__->register_method(
2488     method   => "cn_browse",
2489     api_name => "open-ils.search.callnumber.browse.target",
2490     notes    => "Starts a callnumber browse"
2491 );
2492
2493 __PACKAGE__->register_method(
2494     method   => "cn_browse",
2495     api_name => "open-ils.search.callnumber.browse.page_up",
2496     notes    => "Returns the previous page of callnumbers",
2497 );
2498
2499 __PACKAGE__->register_method(
2500     method   => "cn_browse",
2501     api_name => "open-ils.search.callnumber.browse.page_down",
2502     notes    => "Returns the next page of callnumbers",
2503 );
2504
2505
2506 # RETURNS array of arrays like so: label, owning_lib, record, id
2507 sub cn_browse {
2508     my( $self, $client, @params ) = @_;
2509     my $method;
2510
2511     $method = 'open-ils.storage.asset.call_number.browse.target.atomic' 
2512         if( $self->api_name =~ /target/ );
2513     $method = 'open-ils.storage.asset.call_number.browse.page_up.atomic'
2514         if( $self->api_name =~ /page_up/ );
2515     $method = 'open-ils.storage.asset.call_number.browse.page_down.atomic'
2516         if( $self->api_name =~ /page_down/ );
2517
2518     return $apputils->simplereq( 'open-ils.storage', $method, @params );
2519 }
2520 # -------------------------------------------------------------------------------------
2521
2522 __PACKAGE__->register_method(
2523     method        => "fetch_cn",
2524     api_name      => "open-ils.search.callnumber.retrieve",
2525     authoritative => 1,
2526     notes         => "retrieves a callnumber based on ID",
2527 );
2528
2529 sub fetch_cn {
2530     my( $self, $client, $id ) = @_;
2531
2532     my $e = new_editor();
2533     my( $cn, $evt ) = $apputils->fetch_callnumber( $id, 0, $e );
2534     return $evt if $evt;
2535     return $cn;
2536 }
2537
2538 __PACKAGE__->register_method(
2539     method        => "fetch_fleshed_cn",
2540     api_name      => "open-ils.search.callnumber.fleshed.retrieve",
2541     authoritative => 1,
2542     notes         => "retrieves a callnumber based on ID, fleshing prefix, suffix, and label_class",
2543 );
2544
2545 sub fetch_fleshed_cn {
2546     my( $self, $client, $id ) = @_;
2547
2548     my $e = new_editor();
2549     my( $cn, $evt ) = $apputils->fetch_callnumber( $id, 1, $e );
2550     return $evt if $evt;
2551     return $cn;
2552 }
2553
2554
2555 __PACKAGE__->register_method(
2556     method    => "fetch_copy_by_cn",
2557     api_name  => 'open-ils.search.copies_by_call_number.retrieve',
2558     signature => q/
2559         Returns an array of copy ID's by callnumber ID
2560         @param cnid The callnumber ID
2561         @return An array of copy IDs
2562     /
2563 );
2564
2565 sub fetch_copy_by_cn {
2566     my( $self, $conn, $cnid ) = @_;
2567     return $U->cstorereq(
2568         'open-ils.cstore.direct.asset.copy.id_list.atomic', 
2569         { call_number => $cnid, deleted => 'f' } );
2570 }
2571
2572 __PACKAGE__->register_method(
2573     method    => 'fetch_cn_by_info',
2574     api_name  => 'open-ils.search.call_number.retrieve_by_info',
2575     signature => q/
2576         @param label The callnumber label
2577         @param record The record the cn is attached to
2578         @param org The owning library of the cn
2579         @return The callnumber object
2580     /
2581 );
2582
2583
2584 sub fetch_cn_by_info {
2585     my( $self, $conn, $label, $record, $org ) = @_;
2586     return $U->cstorereq(
2587         'open-ils.cstore.direct.asset.call_number.search',
2588         { label => $label, record => $record, owning_lib => $org, deleted => 'f' });
2589 }
2590
2591
2592
2593 __PACKAGE__->register_method(
2594     method   => 'bib_extras',
2595     api_name => 'open-ils.search.biblio.lit_form_map.retrieve.all',
2596     ctype => 'lit_form'
2597 );
2598 __PACKAGE__->register_method(
2599     method   => 'bib_extras',
2600     api_name => 'open-ils.search.biblio.item_form_map.retrieve.all',
2601     ctype => 'item_form'
2602 );
2603 __PACKAGE__->register_method(
2604     method   => 'bib_extras',
2605     api_name => 'open-ils.search.biblio.item_type_map.retrieve.all',
2606     ctype => 'item_type',
2607 );
2608 __PACKAGE__->register_method(
2609     method   => 'bib_extras',
2610     api_name => 'open-ils.search.biblio.bib_level_map.retrieve.all',
2611     ctype => 'bib_level'
2612 );
2613 __PACKAGE__->register_method(
2614     method   => 'bib_extras',
2615     api_name => 'open-ils.search.biblio.audience_map.retrieve.all',
2616     ctype => 'audience'
2617 );
2618
2619 sub bib_extras {
2620     my $self = shift;
2621     $logger->warn("deprecation warning: " .$self->api_name);
2622
2623     my $e = new_editor();
2624
2625     my $ctype = $self->{ctype};
2626     my $ccvms = $e->search_config_coded_value_map({ctype => $ctype});
2627
2628     my @objs;
2629     for my $ccvm (@$ccvms) {
2630         my $obj = "Fieldmapper::config::${ctype}_map"->new;
2631         $obj->value($ccvm->value);
2632         $obj->code($ccvm->code);
2633         $obj->description($ccvm->description) if $obj->can('description');
2634         push(@objs, $obj);
2635     }
2636
2637     return \@objs;
2638 }
2639
2640
2641
2642 __PACKAGE__->register_method(
2643     method    => 'fetch_slim_record',
2644     api_name  => 'open-ils.search.biblio.record_entry.slim.retrieve',
2645     signature => {
2646         desc   => "Retrieves one or more biblio.record_entry without the attached marcxml",
2647         params => [
2648             { desc => 'Array of Record IDs', type => 'array' }
2649         ],
2650         return => { 
2651             desc => 'Array of biblio records, event on error'
2652         }
2653     }
2654 );
2655
2656 sub fetch_slim_record {
2657     my( $self, $conn, $ids ) = @_;
2658
2659     my $editor = new_editor();
2660     my @res;
2661     for( @$ids ) {
2662         return $editor->event unless
2663             my $r = $editor->retrieve_biblio_record_entry($_);
2664         $r->clear_marc;
2665         push(@res, $r);
2666     }
2667     return \@res;
2668 }
2669
2670 __PACKAGE__->register_method(
2671     method    => 'rec_hold_parts',
2672     api_name  => 'open-ils.search.biblio.record_hold_parts',
2673     signature => q/
2674        Returns a list of {label :foo, id : bar} objects for viable monograph parts for a given record
2675     /
2676 );
2677
2678 sub rec_hold_parts {
2679     my( $self, $conn, $args ) = @_;
2680
2681     my $rec        = $$args{record};
2682     my $mrec       = $$args{metarecord};
2683     my $pickup_lib = $$args{pickup_lib};
2684     my $e = new_editor();
2685
2686     my $query = {
2687         select => {bmp => ['id', 'label']},
2688         from => 'bmp',
2689         where => {
2690             id => {
2691                 in => {
2692                     select => {'acpm' => ['part']},
2693                     from => {acpm => {acp => {join => {acn => {join => 'bre'}}}}},
2694                     where => {
2695                         '+acp' => {'deleted' => 'f'},
2696                         '+bre' => {id => $rec}
2697                     },
2698                     distinct => 1,
2699                 }
2700             },
2701             deleted => 'f'
2702         },
2703         order_by =>[{class=>'bmp', field=>'label_sortkey'}]
2704     };
2705
2706     if(defined $pickup_lib) {
2707         my $hard_boundary = $U->ou_ancestor_setting_value($pickup_lib, OILS_SETTING_HOLD_HARD_BOUNDARY);
2708         if($hard_boundary) {
2709             my $orgs = $e->json_query({from => ['actor.org_unit_descendants' => $pickup_lib, $hard_boundary]});
2710             $query->{where}->{'+acp'}->{circ_lib} = [ map { $_->{id} } @$orgs ];
2711         }
2712     }
2713
2714     return $e->json_query($query);
2715 }
2716
2717
2718
2719
2720 __PACKAGE__->register_method(
2721     method    => 'rec_to_mr_rec_descriptors',
2722     api_name  => 'open-ils.search.metabib.record_to_descriptors',
2723     signature => q/
2724         specialized method...
2725         Given a biblio record id or a metarecord id, 
2726         this returns a list of metabib.record_descriptor
2727         objects that live within the same metarecord
2728         @param args Object of args including:
2729     /
2730 );
2731
2732 sub rec_to_mr_rec_descriptors {
2733     my( $self, $conn, $args ) = @_;
2734
2735     my $rec        = $$args{record};
2736     my $mrec       = $$args{metarecord};
2737     my $item_forms = $$args{item_forms};
2738     my $item_types = $$args{item_types};
2739     my $item_lang  = $$args{item_lang};
2740     my $pickup_lib = $$args{pickup_lib};
2741
2742     my $hard_boundary = $U->ou_ancestor_setting_value($pickup_lib, OILS_SETTING_HOLD_HARD_BOUNDARY) if (defined $pickup_lib);
2743
2744     my $e = new_editor();
2745     my $recs;
2746
2747     if( !$mrec ) {
2748         my $map = $e->search_metabib_metarecord_source_map({source => $rec});
2749         return $e->event unless @$map;
2750         $mrec = $$map[0]->metarecord;
2751     }
2752
2753     $recs = $e->search_metabib_metarecord_source_map({metarecord => $mrec});
2754     return $e->event unless @$recs;
2755
2756     my @recs = map { $_->source } @$recs;
2757     my $search = { record => \@recs };
2758     $search->{item_form} = $item_forms if $item_forms and @$item_forms;
2759     $search->{item_type} = $item_types if $item_types and @$item_types;
2760     $search->{item_lang} = $item_lang  if $item_lang;
2761
2762     my $desc = $e->search_metabib_record_descriptor($search);
2763
2764     my $query = {
2765         distinct => 1,
2766         select   => { 'bre' => ['id'] },
2767         from     => {
2768             'bre' => {
2769                 'acn' => {
2770                     'join' => {
2771                         'acp' => {"join" => {"acpl" => {}, "ccs" => {}}}
2772                       }
2773                   }
2774              }
2775         },
2776         where => {
2777             '+bre' => { id => \@recs },
2778             '+acp' => {
2779                 holdable => 't',
2780                 deleted  => 'f'
2781             },
2782             "+ccs" => { holdable => 't' },
2783             "+acpl" => { holdable => 't', deleted => 'f' }
2784         }
2785     };
2786
2787     if ($hard_boundary) { # 0 (or "top") is the same as no setting
2788         my $orgs = $e->json_query(
2789             { from => [ 'actor.org_unit_descendants' => $pickup_lib, $hard_boundary ] }
2790         ) or return $e->die_event;
2791
2792         $query->{where}->{"+acp"}->{circ_lib} = [ map { $_->{id} } @$orgs ];
2793     }
2794
2795     my $good_records = $e->json_query($query) or return $e->die_event;
2796
2797     my @keep;
2798     for my $d (@$desc) {
2799         if ( grep { $d->record == $_->{id} } @$good_records ) {
2800             push @keep, $d;
2801         }
2802     }
2803
2804     $desc = \@keep;
2805
2806     return { metarecord => $mrec, descriptors => $desc };
2807 }
2808
2809
2810 __PACKAGE__->register_method(
2811     method   => 'fetch_age_protect',
2812     api_name => 'open-ils.search.copy.age_protect.retrieve.all',
2813 );
2814
2815 sub fetch_age_protect {
2816     return new_editor()->retrieve_all_config_rule_age_hold_protect();
2817 }
2818
2819
2820 __PACKAGE__->register_method(
2821     method   => 'copies_by_cn_label',
2822     api_name => 'open-ils.search.asset.copy.retrieve_by_cn_label',
2823 );
2824
2825 __PACKAGE__->register_method(
2826     method   => 'copies_by_cn_label',
2827     api_name => 'open-ils.search.asset.copy.retrieve_by_cn_label.staff',
2828 );
2829
2830 sub copies_by_cn_label {
2831     my( $self, $conn, $record, $cn_parts, $circ_lib ) = @_;
2832     my $e = new_editor();
2833     my $cnp_id = $cn_parts->[0] eq '' ? -1 : $e->search_asset_call_number_prefix({label => $cn_parts->[0]}, {idlist=>1})->[0];
2834     my $cns_id = $cn_parts->[2] eq '' ? -1 : $e->search_asset_call_number_suffix({label => $cn_parts->[2]}, {idlist=>1})->[0];
2835     my $cns = $e->search_asset_call_number({record => $record, prefix => $cnp_id, label => $cn_parts->[1], suffix => $cns_id, deleted => 'f'}, {idlist=>1});
2836     return [] unless @$cns;
2837
2838     # show all non-deleted copies in the staff client ...
2839     if ($self->api_name =~ /staff$/o) {
2840         return $e->search_asset_copy({call_number => $cns, circ_lib => $circ_lib, deleted => 'f'}, {idlist=>1});
2841     }
2842
2843     # ... otherwise, grab the copies ...
2844     my $copies = $e->search_asset_copy(
2845         [ {call_number => $cns, circ_lib => $circ_lib, deleted => 'f', opac_visible => 't'},
2846           {flesh => 1, flesh_fields => { acp => [ qw/location status/] } }
2847         ]
2848     );
2849
2850     # ... and test for location and status visibility
2851     return [ map { ($U->is_true($_->location->opac_visible) && $U->is_true($_->status->opac_visible)) ? ($_->id) : () } @$copies ];
2852 }
2853
2854 __PACKAGE__->register_method(
2855     method   => 'bib_copies',
2856     api_name => 'open-ils.search.bib.copies',
2857     stream => 1
2858 );
2859 __PACKAGE__->register_method(
2860     method   => 'bib_copies',
2861     api_name => 'open-ils.search.bib.copies.staff',
2862     stream => 1
2863 );
2864
2865 sub bib_copies {
2866     my ($self, $client, $rec_id, $org, $depth, $limit, $offset, $pref_ou) = @_;
2867     my $is_staff = ($self->api_name =~ /staff/);
2868
2869     my $cstore = OpenSRF::AppSession->create('open-ils.cstore');
2870     my $req = $cstore->request(
2871         'open-ils.cstore.json_query', mk_copy_query(
2872         $rec_id, $org, $depth, $limit, $offset, $pref_ou, $is_staff));
2873
2874     my $resp;
2875     while ($resp = $req->recv) {
2876         my $copy = $resp->content;
2877
2878         if ($is_staff) {
2879             # last_circ is an IDL query so it cannot be queried directly
2880             # via JSON query.
2881             $copy->{last_circ} = 
2882                 new_editor()->retrieve_reporter_last_circ_date($copy->{id})
2883                 ->last_circ;
2884         }
2885
2886         $client->respond($copy);
2887     }
2888
2889     return undef;
2890 }
2891
2892 # TODO: this comes almost directly from WWW/EGCatLoader/Record.pm
2893 # Refactor to share
2894 sub mk_copy_query {
2895     my $rec_id = shift;
2896     my $org = shift;
2897     my $depth = shift;
2898     my $copy_limit = shift;
2899     my $copy_offset = shift;
2900     my $pref_ou = shift;
2901     my $is_staff = shift;
2902     my $base_query = shift;
2903
2904     my $query = $base_query || $U->basic_opac_copy_query(
2905         $rec_id, undef, undef, $copy_limit, $copy_offset, $is_staff
2906     );
2907
2908     if ($org) { # TODO: root org test
2909         # no need to add the org join filter if we're not actually filtering
2910         $query->{from}->{acp}->[1] = { aou => {
2911             fkey => 'circ_lib',
2912             field => 'id',
2913             filter => {
2914                 id => {
2915                     in => {
2916                         select => {aou => [{
2917                             column => 'id', 
2918                             transform => 'actor.org_unit_descendants',
2919                             result_field => 'id', 
2920                             params => [$depth]
2921                         }]},
2922                         from => 'aou',
2923                         where => {id => $org}
2924                     }
2925                 }
2926             }
2927         }};
2928
2929         if ($pref_ou) {
2930             # Make sure the pref OU is included in the results
2931             my $in = $query->{from}->{acp}->[1]->{aou}->{filter}->{id}->{in};
2932             delete $query->{from}->{acp}->[1]->{aou}->{filter}->{id};
2933             $query->{from}->{acp}->[1]->{aou}->{filter}->{'-or'} = [
2934                 {id => {in => $in}},
2935                 {id => $pref_ou}
2936             ];
2937         }
2938     };
2939
2940     # Unsure if we want these in the shared function, leaving here for now
2941     unshift(@{$query->{order_by}},
2942         { class => "aou", field => 'id',
2943           transform => 'evergreen.rank_ou', params => [$org, $pref_ou]
2944         }
2945     );
2946     push(@{$query->{order_by}},
2947         { class => "acp", field => 'id',
2948           transform => 'evergreen.rank_cp'
2949         }
2950     );
2951
2952     return $query;
2953 }
2954
2955 __PACKAGE__->register_method(
2956     method    => 'record_urls',
2957     api_name  => 'open-ils.search.biblio.record.resource_urls.retrieve',
2958     argc      => 1,
2959     stream    => 1,
2960     signature => {
2961         desc   => q/Returns bib record 856 URL content./,
2962         params => [
2963             {desc => 'Context org unit ID', type => 'number'},
2964             {desc => 'Record ID or Array of Record IDs', type => 'number or array'}
2965         ],
2966         return => {
2967             desc => 'Stream of URL objects, one collection object per record',
2968             type => 'object'
2969         }
2970     }
2971 );
2972
2973 sub record_urls {
2974     my ($self, $client, $org_id, $record_ids) = @_;
2975
2976     $record_ids = [$record_ids] unless ref $record_ids eq 'ARRAY';
2977
2978     my $e = new_editor();
2979
2980     for my $record_id (@$record_ids) {
2981
2982         my @urls;
2983
2984         # Start with scoped located URIs
2985         my $uris = $e->json_query({
2986             from => ['evergreen.located_uris_as_uris', $record_id, $org_id]});
2987
2988         for my $uri (@$uris) {
2989             push(@urls, {
2990                 href => $uri->{href},
2991                 label => $uri->{label},
2992                 note => $uri->{use_restriction}
2993             });
2994         }
2995
2996         # Logic copied from TPAC misc_utils.tts
2997         my $bib = $e->retrieve_biblio_record_entry($record_id)
2998             or return $e->event;
2999
3000         my $marc_doc = $U->marc_xml_to_doc($bib->marc);
3001
3002         for my $node ($marc_doc->findnodes('//*[@tag="856" and @ind1="4"]')) {
3003
3004             # asset.uri's
3005             next if $node->findnodes('./*[@code="9" or @code="w" or @code="n"]');
3006
3007             my $url = {};
3008             my ($label) = $node->findnodes('./*[@code="y"]');
3009             my ($notes) = $node->findnodes('./*[@code="z" or @code="3"]');
3010
3011             my $first = 1;
3012             for my $href_node ($node->findnodes('./*[@code="u"]')) {
3013                 next unless $href_node;
3014
3015                 # it's possible for multiple $u's to exist within 1 856 tag.
3016                 # in that case, honor the label/notes data for the first $u, but
3017                 # leave any subsequent $u's as unadorned href's.
3018                 # use href/link/note keys to be consistent with args.uri's
3019
3020                 my $href = $href_node->textContent;
3021                 push(@urls, {
3022                     href => $href,
3023                     label => ($first && $label) ?  $label->textContent : $href,
3024                     note => ($first && $notes) ? $notes->textContent : '',
3025                     ind2 => $node->getAttribute('ind2')
3026                 });
3027                 $first = 0;
3028             }
3029         }
3030
3031         $client->respond({id => $record_id, urls => \@urls});
3032     }
3033
3034     return undef;
3035 }
3036
3037 __PACKAGE__->register_method(
3038     method    => 'catalog_record_summary',
3039     api_name  => 'open-ils.search.biblio.record.catalog_summary',
3040     stream    => 1,
3041     max_bundle_count => 1,
3042     signature => {
3043         desc   => 'Stream of record data suitable for catalog display',
3044         params => [
3045             {desc => 'Context org unit ID', type => 'number'},
3046             {desc => 'Array of Record IDs', type => 'array'}
3047         ],
3048         return => { 
3049             desc => q/
3050                 Stream of record summary objects including id, record,
3051                 hold_count, copy_counts, display (metabib display
3052                 fields), attributes (metabib record attrs), plus
3053                 metabib_id and metabib_records for the metabib variant.
3054             /
3055         }
3056     }
3057 );
3058 __PACKAGE__->register_method(
3059     method    => 'catalog_record_summary',
3060     api_name  => 'open-ils.search.biblio.record.catalog_summary.staff',
3061     stream    => 1,
3062     max_bundle_count => 1,
3063     signature => q/see open-ils.search.biblio.record.catalog_summary/
3064 );
3065 __PACKAGE__->register_method(
3066     method    => 'catalog_record_summary',
3067     api_name  => 'open-ils.search.biblio.metabib.catalog_summary',
3068     stream    => 1,
3069     max_bundle_count => 1,
3070     signature => q/see open-ils.search.biblio.record.catalog_summary/
3071 );
3072
3073 __PACKAGE__->register_method(
3074     method    => 'catalog_record_summary',
3075     api_name  => 'open-ils.search.biblio.metabib.catalog_summary.staff',
3076     stream    => 1,
3077     max_bundle_count => 1,
3078     signature => q/see open-ils.search.biblio.record.catalog_summary/
3079 );
3080
3081
3082 sub catalog_record_summary {
3083     my ($self, $client, $org_id, $record_ids, $options) = @_;
3084     my $e = new_editor();
3085     $options ||= {};
3086     my $pref_ou = $options->{pref_ou};
3087
3088     my $is_meta = ($self->api_name =~ /metabib/);
3089     my $is_staff = ($self->api_name =~ /staff/);
3090
3091     my $holds_method = $is_meta ? 
3092         'open-ils.circ.mmr.holds.count' : 
3093         'open-ils.circ.bre.holds.count';
3094
3095     my $copy_method = $is_meta ? 
3096         'open-ils.search.biblio.metarecord.copy_count':
3097         'open-ils.search.biblio.record.copy_count';
3098
3099     $copy_method .= '.staff' if $is_staff;
3100
3101     $copy_method = $self->method_lookup($copy_method); # local method
3102
3103     my $holdable_method = $is_meta ?
3104         'open-ils.search.biblio.metarecord.has_holdable_copy':
3105         'open-ils.search.biblio.record.has_holdable_copy';
3106
3107     $holdable_method = $self->method_lookup($holdable_method); # local method
3108
3109     for my $rec_id (@$record_ids) {
3110
3111         my $response = $is_meta ? 
3112             get_one_metarecord_summary($self, $e, $org_id, $rec_id) :
3113             get_one_record_summary($self, $e, $org_id, $rec_id);
3114
3115         ($response->{copy_counts}) = $copy_method->run($org_id, $rec_id);
3116
3117         $response->{first_call_number} = get_first_call_number(
3118             $e, $rec_id, $org_id, $is_staff, $is_meta, $options);
3119
3120         if ($pref_ou) {
3121
3122             # If we already have the pref ou copy counts, avoid the extra fetch.
3123             my ($match) = 
3124                 grep {$_->{org_unit} eq $pref_ou} @{$response->{copy_counts}};
3125
3126             if (!$match) {
3127                 my ($counts) = $copy_method->run($pref_ou, $rec_id);
3128                 ($match) = grep {$_->{org_unit} eq $pref_ou} @$counts;
3129             }
3130
3131             $response->{pref_ou_copy_counts} = $match;
3132         }
3133
3134         $response->{hold_count} = 
3135             $U->simplereq('open-ils.circ', $holds_method, $rec_id);
3136
3137         if ($options->{flesh_copies}) {
3138             $response->{copies} = get_representative_copies(
3139                 $e, $rec_id, $org_id, $is_staff, $is_meta, $options);
3140         }
3141
3142         ($response->{has_holdable_copy}) = $holdable_method->run($rec_id);
3143
3144         $client->respond($response);
3145     }
3146
3147     return undef;
3148 }
3149
3150 # Returns a snapshot of copy information for a given record or metarecord,
3151 # sorted by pref org and search org.
3152 sub get_representative_copies {
3153     my ($e, $rec_id, $org_id, $is_staff, $is_meta, $options) = @_;
3154
3155     my @rec_ids;
3156     my $limit = $options->{copy_limit};
3157     my $copy_depth = $options->{copy_depth};
3158     my $copy_offset = $options->{copy_offset};
3159     my $pref_ou = $options->{pref_ou};
3160
3161     my $org_tree = $U->get_org_tree;
3162     if (!$org_id) { $org_id = $org_tree->id; }
3163     my $org = $U->find_org($org_tree, $org_id);
3164
3165     return [] unless $org;
3166
3167     my $func = 'unapi.biblio_record_entry_feed';
3168     my $includes = '{holdings_xml,acp,acnp,acns,circ}';
3169     my $limits = "acn=>$limit,acp=>$limit";
3170
3171     if ($is_meta) {
3172         $func = 'unapi.metabib_virtual_record_feed';
3173         $includes = '{holdings_xml,acp,acnp,acns,circ,mmr.unapi}';
3174         $limits .= ",bre=>$limit";
3175     }
3176
3177     my $xml_query = $e->json_query({from => [
3178         $func, '{'.$rec_id.'}', 'marcxml', 
3179         $includes, $org->shortname, $copy_depth, $limits,
3180         undef, undef,undef, undef, undef, 
3181         undef, undef, undef, $pref_ou
3182     ]})->[0];
3183
3184     my $xml = $xml_query->{$func};
3185
3186     my $doc = XML::LibXML->new->parse_string($xml);
3187
3188     my $copies = [];
3189     for my $volume ($doc->documentElement->findnodes('//*[local-name()="volume"]')) {
3190         my $label = $volume->getAttribute('label');
3191         my $prefix = $volume->getElementsByTagName('call_number_prefix')->[0]->getAttribute('label');
3192         my $suffix = $volume->getElementsByTagName('call_number_suffix')->[0]->getAttribute('label');
3193
3194         my $copies_node = $volume->findnodes('./*[local-name()="copies"]')->[0];
3195
3196         for my $copy ($copies_node->findnodes('./*[local-name()="copy"]')) {
3197
3198             my $status = $copy->getElementsByTagName('status')->[0]->textContent;
3199             my $location = $copy->getElementsByTagName('location')->[0]->textContent;
3200             my $circ_lib_sn = $copy->getElementsByTagName('circ_lib')->[0]->getAttribute('shortname');
3201             my $due_date = '';
3202
3203             my $current_circ = $copy->findnodes('./*[local-name()="current_circulation"]')->[0];
3204             if (my $circ = $current_circ->findnodes('./*[local-name()="circ"]')) {
3205                 $due_date = $circ->[0]->getAttribute('due_date');
3206             }
3207
3208             push(@$copies, {
3209                 call_number_label => $label,
3210                 call_number_prefix_label => $prefix,
3211                 call_number_suffix_label => $suffix,
3212                 circ_lib_sn => $circ_lib_sn,
3213                 copy_status => $status,
3214                 copy_location => $location,
3215                 due_date => $due_date
3216             });
3217         }
3218     }
3219
3220     return $copies;
3221 }
3222
3223 sub get_first_call_number {
3224     my ($e, $rec_id, $org_id, $is_staff, $is_meta, $options) = @_;
3225
3226     my $limit = $options->{copy_limit};
3227     $options->{copy_limit} = 1;
3228
3229     my $copies = get_representative_copies(
3230         $e, $rec_id, $org_id, $is_staff, $is_meta, $options);
3231
3232     $options->{copy_limit} = $limit;
3233
3234     return $copies->[0];
3235 }
3236
3237 sub get_one_rec_urls {
3238     my ($self, $e, $org_id, $bib_id) = @_;
3239
3240     my ($resp) = $self->method_lookup(
3241         'open-ils.search.biblio.record.resource_urls.retrieve')
3242         ->run($org_id, $bib_id);
3243
3244     return $resp->{urls};
3245 }
3246
3247 # Start with a bib summary and augment the data with additional
3248 # metarecord content.
3249 sub get_one_metarecord_summary {
3250     my ($self, $e, $org_id, $rec_id) = @_;
3251
3252     my $meta = $e->retrieve_metabib_metarecord($rec_id) or return {};
3253     my $maps = $e->search_metabib_metarecord_source_map({metarecord => $rec_id});
3254
3255     my $bre_id = $meta->master_record; 
3256
3257     my $response = get_one_record_summary($self, $e, $org_id, $bre_id);
3258     $response->{urls} = get_one_rec_urls($self, $e, $org_id, $bre_id);
3259
3260     $response->{metabib_id} = $rec_id;
3261     $response->{metabib_records} = [map {$_->source} @$maps];
3262
3263     my @other_bibs = map {$_->source} grep {$_->source != $bre_id} @$maps;
3264
3265     # Augment the record attributes with those of all of the records
3266     # linked to this metarecord.
3267     if (@other_bibs) {
3268         my $attrs = $e->search_metabib_record_attr_flat({id => \@other_bibs});
3269
3270         my $attributes = $response->{attributes};
3271
3272         for my $attr (@$attrs) {
3273             $attributes->{$attr->attr} = [] unless $attributes->{$attr->attr};
3274             push(@{$attributes->{$attr->attr}}, $attr->value) # avoid dupes
3275                 unless grep {$_ eq $attr->value} @{$attributes->{$attr->attr}};
3276         }
3277     }
3278
3279     return $response;
3280 }
3281
3282 sub get_one_record_summary {
3283     my ($self, $e, $org_id, $rec_id) = @_;
3284
3285     my $bre = $e->retrieve_biblio_record_entry([$rec_id, {
3286         flesh => 1,
3287         flesh_fields => {
3288             bre => [qw/compressed_display_entries mattrs creator editor/]
3289         }
3290     }]) or return {};
3291
3292     # Compressed display fields are pachaged as JSON
3293     my $display = {};
3294     $display->{$_->name} = OpenSRF::Utils::JSON->JSON2perl($_->value)
3295         foreach @{$bre->compressed_display_entries};
3296
3297     # Create an object of 'mraf' attributes.
3298     # Any attribute can be multi so dedupe and array-ify all of them.
3299     my $attributes = {};
3300     for my $attr (@{$bre->mattrs}) {
3301         $attributes->{$attr->attr} = {} unless $attributes->{$attr->attr};
3302         $attributes->{$attr->attr}->{$attr->value} = 1; # avoid dupes
3303     }
3304     $attributes->{$_} = [keys %{$attributes->{$_}}] for keys %$attributes;
3305
3306     # clear bulk
3307     $bre->clear_marc;
3308     $bre->clear_mattrs;
3309     $bre->clear_compressed_display_entries;
3310
3311     return {
3312         id => $rec_id,
3313         record => $bre,
3314         display => $display,
3315         attributes => $attributes,
3316         urls => get_one_rec_urls($self, $e, $org_id, $rec_id)
3317     };
3318 }
3319
3320 __PACKAGE__->register_method(
3321     method    => 'record_copy_counts_global',
3322     api_name  => 'open-ils.search.biblio.record.copy_counts.global.staff',
3323     signature => {
3324         desc   => q/Returns a count of copies and call numbers for each org
3325                     unit, including items attached to each org unit plus
3326                     a sum of counts for all descendants./,
3327         params => [
3328             {desc => 'Record ID', type => 'number'}
3329         ],
3330         return => {
3331             desc => 'Hash of org unit ID  => {copy: $count, call_number: $id}'
3332         }
3333     }
3334 );
3335
3336 sub record_copy_counts_global {
3337     my ($self, $client, $rec_id) = @_;
3338
3339     my $copies = new_editor()->json_query({
3340         select => {
3341             acp => [{column => 'id', alias => 'copy_id'}, 'circ_lib'],
3342             acn => [{column => 'id', alias => 'cn_id'}, 'owning_lib']
3343         },
3344         from => {acn => {acp => {type => 'left'}}},
3345         where => {
3346             '+acp' => {
3347                 '-or' => [
3348                     {deleted => 'f'},
3349                     {id => undef} # left join
3350                 ]
3351             },
3352             '+acn' => {deleted => 'f', record => $rec_id}
3353         }
3354     });
3355
3356     my $hash = {};
3357     my %seen_cn;
3358
3359     for my $copy (@$copies) {
3360         my $org = $copy->{circ_lib} || $copy->{owning_lib};
3361         $hash->{$org} = {copies => 0, call_numbers => 0} unless $hash->{$org};
3362         $hash->{$org}->{copies}++ if $copy->{circ_lib};
3363
3364         if (!$seen_cn{$copy->{cn_id}}) {
3365             $seen_cn{$copy->{cn_id}} = 1;
3366             $hash->{$org}->{call_numbers}++;
3367         }
3368     }
3369
3370     my $sum;
3371     $sum = sub {
3372         my $node = shift;
3373         my $h = $hash->{$node->id} || {copies => 0, call_numbers => 0};
3374         delete $h->{cn_id};
3375
3376         for my $child (@{$node->children}) {
3377             my $vals = $sum->($child);
3378             $h->{copies} += $vals->{copies};
3379             $h->{call_numbers} += $vals->{call_numbers};
3380         }
3381
3382         $hash->{$node->id} = $h;
3383
3384         return $h;
3385     };
3386
3387     $sum->($U->get_org_tree);
3388
3389     return $hash;
3390 }
3391
3392
3393 1;
3394