]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Biblio.pm
LP#1955079: Use Email::MIME for emailing records in the OPAC
[working/Evergreen.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::MIME;
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 = _create_mime_email($event->template_output->data);
1946
1947     try {
1948         $stat = $sender->send($email);
1949     } catch Error with {
1950         $err = $stat = shift;
1951         $logger->error("send_event_email_output: Email failed with error: $err");
1952     };
1953
1954     if( !$err and $stat and $stat->type eq 'success' ) {
1955         $logger->info("send_event_email_output: successfully sent email");
1956         return 1;
1957     } else {
1958         $logger->warn("send_event_email_output: unable to send email: ".Dumper($stat));
1959         return 0;
1960     }
1961 }
1962
1963 sub _create_mime_email {
1964     my $template_output = shift;
1965     my $email = Email::MIME->new($template_output);
1966     for my $hfield (qw/From To Bcc Cc Reply-To Sender/) {
1967         my @headers = $email->header($hfield);
1968         $email->header_str_set($hfield => join(',', @headers)) if ($headers[0]);
1969     }
1970
1971     my @headers = $email->header('Subject');
1972     $email->header_str_set('Subject' => $headers[0]) if ($headers[0]);
1973
1974     $email->header_set('MIME-Version' => '1.0');
1975     $email->header_set('Content-Type' => "text/plain; charset=UTF-8");
1976     $email->header_set('Content-Transfer-Encoding' => '8bit');
1977     return $email;
1978 }
1979
1980 __PACKAGE__->register_method(
1981     method    => "format_biblio_record_entry",
1982     api_name  => "open-ils.search.biblio.record.print.preview",
1983 );
1984
1985 __PACKAGE__->register_method(
1986     method    => "format_biblio_record_entry",
1987     api_name  => "open-ils.search.biblio.record.email.preview",
1988 );
1989
1990 __PACKAGE__->register_method(
1991     method    => "format_biblio_record_entry",
1992     api_name  => "open-ils.search.biblio.record.print",
1993     signature => {
1994         desc   => 'Returns a printable version of the specified bib record',
1995         params => [
1996             { desc => 'Biblio record entry ID or array of IDs', type => 'number' },
1997             { desc => 'Context library for holdings, if applicable', type => 'number' },
1998             { desc => 'Sort order, if applicable', type => 'string' },
1999             { desc => 'Sort direction, if applicable', type => 'string' },
2000             { desc => 'Definition Group Member id', type => 'number' },
2001         ],
2002         return => {
2003             desc => q/An action_trigger.event object or error event./,
2004             type => 'object',
2005         }
2006     }
2007 );
2008 __PACKAGE__->register_method(
2009     method    => "format_biblio_record_entry",
2010     api_name  => "open-ils.search.biblio.record.email",
2011     signature => {
2012         desc   => 'Emails an A/T templated version of the specified bib records to the authorized user',
2013         params => [
2014             { desc => 'Authentication token', type => 'string'},
2015             { desc => 'Biblio record entry ID or array of IDs', type => 'number' },
2016             { desc => 'Context library for holdings, if applicable', type => 'number' },
2017             { desc => 'Sort order, if applicable', type => 'string' },
2018             { desc => 'Sort direction, if applicable', type => 'string' },
2019             { desc => 'Definition Group Member id', type => 'number' },
2020             { desc => 'Whether to bypass auth due to captcha', type => 'bool' },
2021             { desc => 'Email address, if none for the user', type => 'string' },
2022             { desc => 'Subject, if customized', type => 'string' },
2023         ],
2024         return => {
2025             desc => q/Undefined on success, otherwise an error event./,
2026             type => 'object',
2027         }
2028     }
2029 );
2030
2031 sub format_biblio_record_entry {
2032     my ($self, $conn) = splice @_, 0, 2;
2033
2034     my $for_print = ($self->api_name =~ /print/);
2035     my $for_email = ($self->api_name =~ /email/);
2036     my $preview = ($self->api_name =~ /preview/);
2037
2038     my ($auth, $captcha_pass, $email, $subject);
2039     if ($for_email) {
2040         $auth = shift @_;
2041         if (@_ > 5) { # the stuff below is included in the params, safe to splice
2042             ($captcha_pass, $email, $subject) = splice @_, -3, 3;
2043         }
2044     }
2045     my ($bib_id, $holdings_context_org, $bib_sort, $sort_dir, $group_member) = @_;
2046     $holdings_context_org ||= $U->get_org_tree->id;
2047     $bib_sort ||= 'author';
2048     $sort_dir ||= 'ascending';
2049
2050     my $e; my $event_context_org; my $type = 'brief';
2051
2052     if ($for_print) {
2053         $event_context_org = $holdings_context_org;
2054         $e = new_editor(xact => 1);
2055     } elsif ($for_email) {
2056         $e = new_editor(authtoken => $auth, xact => 1);
2057         return $e->die_event unless $captcha_pass || $e->checkauth;
2058         $event_context_org = $e->requestor ? $e->requestor->home_ou : $holdings_context_org;
2059         $email ||= $e->requestor ? $e->requestor->email : '';
2060     }
2061
2062     if ($group_member) {
2063         $group_member = $e->retrieve_action_trigger_event_def_group_member($group_member);
2064         if ($group_member and $U->is_true($group_member->holdings)) {
2065             $type = 'full';
2066         }
2067     }
2068
2069     $holdings_context_org = $e->retrieve_actor_org_unit($holdings_context_org);
2070
2071     my $bib_ids;
2072     if (ref $bib_id ne 'ARRAY') {
2073         $bib_ids = [ $bib_id ];
2074     } else {
2075         $bib_ids = $bib_id;
2076     }
2077
2078     my $bucket = Fieldmapper::container::biblio_record_entry_bucket->new;
2079     $bucket->btype('temp');
2080     $bucket->name('format_biblio_record_entry ' . $U->create_uuid_string);
2081     if ($for_email) {
2082         $bucket->owner($e->requestor || 1) 
2083     } else {
2084         $bucket->owner(1);
2085     }
2086     my $bucket_obj = $e->create_container_biblio_record_entry_bucket($bucket);
2087
2088     for my $id (@$bib_ids) {
2089
2090         my $bib = $e->retrieve_biblio_record_entry([$id]) or return $e->die_event;
2091
2092         my $bucket_entry = Fieldmapper::container::biblio_record_entry_bucket_item->new;
2093         $bucket_entry->target_biblio_record_entry($bib);
2094         $bucket_entry->bucket($bucket_obj->id);
2095         $e->create_container_biblio_record_entry_bucket_item($bucket_entry);
2096     }
2097
2098     $e->commit;
2099
2100     my $usr_data = {
2101         type        => $type,
2102         email       => $email,
2103         subject     => $subject,
2104         context_org => $holdings_context_org->shortname,
2105         sort_by     => $bib_sort,
2106         sort_dir    => $sort_dir,
2107         preview     => $preview
2108     };
2109
2110     if ($for_print) {
2111
2112         return $U->fire_object_event(undef, 'biblio.format.record_entry.print', [ $bucket ], $event_context_org, undef, [ $usr_data ]);
2113
2114     } elsif ($for_email) {
2115
2116         return $U->fire_object_event(undef, 'biblio.format.record_entry.email', [ $bucket ], $event_context_org, undef, [ $usr_data ])
2117             if ($preview);
2118
2119         $U->create_events_for_hook('biblio.format.record_entry.email', $bucket, $event_context_org, undef, $usr_data, 1);
2120     }
2121
2122     return undef;
2123 }
2124
2125
2126 __PACKAGE__->register_method(
2127     method   => "retrieve_all_copy_statuses",
2128     api_name => "open-ils.search.config.copy_status.retrieve.all"
2129 );
2130
2131 sub retrieve_all_copy_statuses {
2132     my( $self, $client ) = @_;
2133     return new_editor()->retrieve_all_config_copy_status();
2134 }
2135
2136
2137 __PACKAGE__->register_method(
2138     method   => "copy_counts_per_org",
2139     api_name => "open-ils.search.biblio.copy_counts.retrieve"
2140 );
2141
2142 __PACKAGE__->register_method(
2143     method   => "copy_counts_per_org",
2144     api_name => "open-ils.search.biblio.copy_counts.retrieve.staff"
2145 );
2146
2147 sub copy_counts_per_org {
2148     my( $self, $client, $record_id ) = @_;
2149
2150     warn "Retreiveing copy copy counts for record $record_id and method " . $self->api_name . "\n";
2151
2152     my $method = "open-ils.storage.biblio.record_entry.global_copy_count.atomic";
2153     if($self->api_name =~ /staff/) { $method =~ s/atomic/staff\.atomic/; }
2154
2155     my $counts = $apputils->simple_scalar_request(
2156         "open-ils.storage", $method, $record_id );
2157
2158     $counts = [ sort {$a->[0] <=> $b->[0]} @$counts ];
2159     return $counts;
2160 }
2161
2162
2163 __PACKAGE__->register_method(
2164     method   => "copy_count_summary",
2165     api_name => "open-ils.search.biblio.copy_counts.summary.retrieve",
2166     notes    => "returns an array of these: "
2167               . "[ org_id, callnumber_prefix, callnumber_label, callnumber_suffix, <status1_count>, <status2_count>,...] "
2168               . "where statusx is a copy status name.  The statuses are sorted by ID.",
2169 );
2170         
2171
2172 sub copy_count_summary {
2173     my( $self, $client, $rid, $org, $depth ) = @_;
2174     $org   ||= 1;
2175     $depth ||= 0;
2176     my $data = $U->storagereq(
2177         'open-ils.storage.biblio.record_entry.status_copy_count.atomic', $rid, $org, $depth );
2178
2179     return [ sort {
2180         (($a->[1] ? $a->[1] . ' ' : '') . $a->[2] . ($a->[3] ? ' ' . $a->[3] : ''))
2181         cmp
2182         (($b->[1] ? $b->[1] . ' ' : '') . $b->[2] . ($b->[3] ? ' ' . $b->[3] : ''))
2183     } @$data ];
2184 }
2185
2186 __PACKAGE__->register_method(
2187     method   => "copy_location_count_summary",
2188     api_name => "open-ils.search.biblio.copy_location_counts.summary.retrieve",
2189     notes    => "returns an array of these: "
2190               . "[ org_id, callnumber_prefix, callnumber_label, callnumber_suffix, copy_location, <status1_count>, <status2_count>,...] "
2191               . "where statusx is a copy status name.  The statuses are sorted by ID.",
2192 );
2193
2194 sub copy_location_count_summary {
2195     my( $self, $client, $rid, $org, $depth ) = @_;
2196     $org   ||= 1;
2197     $depth ||= 0;
2198     my $data = $U->storagereq(
2199         'open-ils.storage.biblio.record_entry.status_copy_location_count.atomic', $rid, $org, $depth );
2200
2201     return [ sort {
2202         (($a->[1] ? $a->[1] . ' ' : '') . $a->[2] . ($a->[3] ? ' ' . $a->[3] : ''))
2203         cmp
2204         (($b->[1] ? $b->[1] . ' ' : '') . $b->[2] . ($b->[3] ? ' ' . $b->[3] : ''))
2205
2206         || $a->[4] cmp $b->[4]
2207     } @$data ];
2208 }
2209
2210 __PACKAGE__->register_method(
2211     method   => "copy_count_location_summary",
2212     api_name => "open-ils.search.biblio.copy_counts.location.summary.retrieve",
2213     notes    => "returns an array of these: "
2214               . "[ org_id, callnumber_prefix, callnumber_label, callnumber_suffix, <status1_count>, <status2_count>,...] "
2215               . "where statusx is a copy status name.  The statuses are sorted by ID."
2216 );
2217
2218 sub copy_count_location_summary {
2219     my( $self, $client, $rid, $org, $depth ) = @_;
2220     $org   ||= 1;
2221     $depth ||= 0;
2222     my $data = $U->storagereq(
2223         'open-ils.storage.biblio.record_entry.status_copy_location_count.atomic', $rid, $org, $depth );
2224     return [ sort {
2225         (($a->[1] ? $a->[1] . ' ' : '') . $a->[2] . ($a->[3] ? ' ' . $a->[3] : ''))
2226         cmp
2227         (($b->[1] ? $b->[1] . ' ' : '') . $b->[2] . ($b->[3] ? ' ' . $b->[3] : ''))
2228     } @$data ];
2229 }
2230
2231
2232 foreach (qw/open-ils.search.biblio.marc
2233             open-ils.search.biblio.marc.staff/)
2234 {
2235 __PACKAGE__->register_method(
2236     method    => "marc_search",
2237     api_name  => $_,
2238     signature => {
2239         desc   => 'Fetch biblio IDs based on MARC record criteria.  '
2240                 . 'As usual, the .staff version of the search includes otherwise hidden records',
2241         params => [
2242             {
2243                 desc => 'Search hash (required) with possible elements: searches, limit, offset, sort, sort_dir. ' .
2244                         'See perldoc ' . __PACKAGE__ . ' for more detail.',
2245                 type => 'object'
2246             },
2247             {desc => 'timeout (optional)',  type => 'number'}
2248         ],
2249         return => {
2250             desc => 'Results object like: { "count": $i, "ids": [...] }',
2251             type => 'object'
2252         }
2253     }
2254 );
2255 }
2256
2257 =head3 open-ils.search.biblio.marc (arghash, timeout)
2258
2259 As elsewhere the arghash is the required argument, and must be a hashref.  The keys are:
2260
2261     searches: complex query object  (required)
2262     org_unit: The org ID to focus the search at
2263     depth   : The org depth     
2264     limit   : integer search limit      default: 10
2265     offset  : integer search offset     default:  0
2266     sort    : What field to sort the results on? [ author | title | pubdate ]
2267     sort_dir: In what direction do we sort? [ asc | desc ]
2268
2269 Additional keys to refine search criteria:
2270
2271     audience : Audience
2272     language : Language (code)
2273     lit_form : Literary form
2274     item_form: Item form
2275     item_type: Item type
2276     format   : The MARC format
2277
2278 Please note that the specific strings to be used in the "addtional keys" will be entirely
2279 dependent on your loaded data.  
2280
2281 All keys except "searches" are optional.
2282 The "searches" value must be an arrayref of hashref elements, including keys "term" and "restrict".  
2283
2284 For example, an arg hash might look like:
2285
2286     $arghash = {
2287         searches => [
2288             {
2289                 term     => "harry",
2290                 restrict => [
2291                     {
2292                         tag => 245,
2293                         subfield => "a"
2294                     }
2295                     # ...
2296                 ]
2297             }
2298             # ...
2299         ],
2300         org_unit  => 1,
2301         limit     => 5,
2302         sort      => "author",
2303         item_type => "g"
2304     }
2305
2306 The arghash is eventually passed to the SRF call:
2307 L<open-ils.storage.biblio.full_rec.multi_search[.staff].atomic>
2308
2309 Presently, search uses the cache unconditionally.
2310
2311 =cut
2312
2313 # FIXME: that example above isn't actually tested.
2314 # FIXME: sort and limit added.  item_type not tested yet.
2315 # TODO: docache option?
2316 sub marc_search {
2317     my( $self, $conn, $args, $timeout ) = @_;
2318
2319     my $method = 'open-ils.storage.biblio.full_rec.multi_search';
2320     $method .= ".staff" if $self->api_name =~ /staff/;
2321     $method .= ".atomic";
2322
2323     my $limit = $args->{limit} || 10;
2324     my $offset = $args->{offset} || 0;
2325
2326     # allow caller to pass in a call timeout since MARC searches
2327     # can take longer than the default 60-second timeout.  
2328     # Default to 2 mins.  Arbitrarily cap at 5 mins.
2329     $timeout = 120 if !$timeout or $timeout > 300;
2330
2331     my @search;
2332     push( @search, ($_ => $$args{$_}) ) for (sort keys %$args);
2333     my $ckey = $pfx . md5_hex($method . OpenSRF::Utils::JSON->perl2JSON(\@search));
2334
2335     my $recs = search_cache($ckey, $offset, $limit);
2336
2337     if(!$recs) {
2338
2339         my $ses = OpenSRF::AppSession->create('open-ils.storage');
2340         my $req = $ses->request($method, %$args);
2341         my $resp = $req->recv($timeout);
2342
2343         if($resp and $recs = $resp->content) {
2344             put_cache($ckey, scalar(@$recs), $recs);
2345         } else {
2346             $recs = [];
2347         }
2348
2349         $ses->kill_me;
2350     }
2351
2352     my $count = 0;
2353     $count = $recs->[0]->[2] if $recs->[0] and $recs->[0]->[2];
2354     my @recs = map { $_->[0] } @$recs;
2355
2356     return { ids => \@recs, count => $count };
2357 }
2358
2359
2360 foreach my $isbn_method (qw/
2361     open-ils.search.biblio.isbn
2362     open-ils.search.biblio.isbn.staff
2363 /) {
2364 __PACKAGE__->register_method(
2365     method    => "biblio_search_isbn",
2366     api_name  => $isbn_method,
2367     signature => {
2368         desc   => 'Retrieve biblio IDs for a given ISBN. The .staff version of the call includes otherwise hidden hits.',
2369         params => [
2370             {desc => 'ISBN', type => 'string'}
2371         ],
2372         return => {
2373             desc => 'Results object like: { "count": $i, "ids": [...] }',
2374             type => 'object'
2375         }
2376     }
2377 );
2378 }
2379
2380 sub biblio_search_isbn { 
2381     my( $self, $client, $isbn ) = @_;
2382     $logger->debug("Searching ISBN $isbn");
2383     # the previous implementation of this method was essentially unlimited,
2384     # so we will set our limit very high and let multiclass.query provide any
2385     # actual limit
2386     # XXX: if making this unlimited is deemed important, we might consider
2387     # reworking 'open-ils.storage.id_list.biblio.record_entry.search.isbn',
2388     # which is functionally deprecated at this point, or a custom call to
2389     # 'open-ils.storage.biblio.multiclass.search_fts'
2390
2391     my $isbn_method = 'open-ils.search.biblio.multiclass.query';
2392     if ($self->api_name =~ m/.staff$/) {
2393         $isbn_method .= '.staff';
2394     }
2395
2396     my $method = $self->method_lookup($isbn_method);
2397     my ($search_result) = $method->run({'limit' => 1000000}, "identifier|isbn:$isbn");
2398     my @recs = map { $_->[0] } @{$search_result->{'ids'}};
2399     return { ids => \@recs, count => $search_result->{'count'} };
2400 }
2401
2402 __PACKAGE__->register_method(
2403     method   => "biblio_search_isbn_batch",
2404     api_name => "open-ils.search.biblio.isbn_list",
2405 );
2406
2407 # XXX: see biblio_search_isbn() for note concerning 'limit'
2408 sub biblio_search_isbn_batch { 
2409     my( $self, $client, $isbn_list ) = @_;
2410     $logger->debug("Searching ISBNs @$isbn_list");
2411     my @recs = (); my %rec_set = ();
2412     my $method = $self->method_lookup('open-ils.search.biblio.multiclass.query');
2413     foreach my $isbn ( @$isbn_list ) {
2414         my ($search_result) = $method->run({'limit' => 1000000}, "identifier|isbn:$isbn");
2415         my @recs_subset = map { $_->[0] } @{$search_result->{'ids'}};
2416         foreach my $rec (@recs_subset) {
2417             if (! $rec_set{ $rec }) {
2418                 $rec_set{ $rec } = 1;
2419                 push @recs, $rec;
2420             }
2421         }
2422     }
2423     return { ids => \@recs, count => int(scalar(@recs)) };
2424 }
2425
2426 foreach my $issn_method (qw/
2427     open-ils.search.biblio.issn
2428     open-ils.search.biblio.issn.staff
2429 /) {
2430 __PACKAGE__->register_method(
2431     method   => "biblio_search_issn",
2432     api_name => $issn_method,
2433     signature => {
2434         desc   => 'Retrieve biblio IDs for a given ISSN',
2435         params => [
2436             {desc => 'ISBN', type => 'string'}
2437         ],
2438         return => {
2439             desc => 'Results object like: { "count": $i, "ids": [...] }',
2440             type => 'object'
2441         }
2442     }
2443 );
2444 }
2445
2446 sub biblio_search_issn { 
2447     my( $self, $client, $issn ) = @_;
2448     $logger->debug("Searching ISSN $issn");
2449     # the previous implementation of this method was essentially unlimited,
2450     # so we will set our limit very high and let multiclass.query provide any
2451     # actual limit
2452     # XXX: if making this unlimited is deemed important, we might consider
2453     # reworking 'open-ils.storage.id_list.biblio.record_entry.search.issn',
2454     # which is functionally deprecated at this point, or a custom call to
2455     # 'open-ils.storage.biblio.multiclass.search_fts'
2456
2457     my $issn_method = 'open-ils.search.biblio.multiclass.query';
2458     if ($self->api_name =~ m/.staff$/) {
2459         $issn_method .= '.staff';
2460     }
2461
2462     my $method = $self->method_lookup($issn_method);
2463     my ($search_result) = $method->run({'limit' => 1000000}, "identifier|issn:$issn");
2464     my @recs = map { $_->[0] } @{$search_result->{'ids'}};
2465     return { ids => \@recs, count => $search_result->{'count'} };
2466 }
2467
2468
2469 __PACKAGE__->register_method(
2470     method    => "fetch_mods_by_copy",
2471     api_name  => "open-ils.search.biblio.mods_from_copy",
2472     argc      => 1,
2473     signature => {
2474         desc    => 'Retrieve MODS record given an attached copy ID',
2475         params  => [
2476             { desc => 'Copy ID', type => 'number' }
2477         ],
2478         returns => {
2479             desc => 'MODS record, event on error or uncataloged item'
2480         }
2481     }
2482 );
2483
2484 sub fetch_mods_by_copy {
2485     my( $self, $client, $copyid ) = @_;
2486     my ($record, $evt) = $apputils->fetch_record_by_copy( $copyid );
2487     return $evt if $evt;
2488     return OpenILS::Event->new('ITEM_NOT_CATALOGED') unless $record->marc;
2489     return $apputils->record_to_mvr($record);
2490 }
2491
2492
2493 # -------------------------------------------------------------------------------------
2494
2495 __PACKAGE__->register_method(
2496     method   => "cn_browse",
2497     api_name => "open-ils.search.callnumber.browse.target",
2498     notes    => "Starts a callnumber browse"
2499 );
2500
2501 __PACKAGE__->register_method(
2502     method   => "cn_browse",
2503     api_name => "open-ils.search.callnumber.browse.page_up",
2504     notes    => "Returns the previous page of callnumbers",
2505 );
2506
2507 __PACKAGE__->register_method(
2508     method   => "cn_browse",
2509     api_name => "open-ils.search.callnumber.browse.page_down",
2510     notes    => "Returns the next page of callnumbers",
2511 );
2512
2513
2514 # RETURNS array of arrays like so: label, owning_lib, record, id
2515 sub cn_browse {
2516     my( $self, $client, @params ) = @_;
2517     my $method;
2518
2519     $method = 'open-ils.storage.asset.call_number.browse.target.atomic' 
2520         if( $self->api_name =~ /target/ );
2521     $method = 'open-ils.storage.asset.call_number.browse.page_up.atomic'
2522         if( $self->api_name =~ /page_up/ );
2523     $method = 'open-ils.storage.asset.call_number.browse.page_down.atomic'
2524         if( $self->api_name =~ /page_down/ );
2525
2526     return $apputils->simplereq( 'open-ils.storage', $method, @params );
2527 }
2528 # -------------------------------------------------------------------------------------
2529
2530 __PACKAGE__->register_method(
2531     method        => "fetch_cn",
2532     api_name      => "open-ils.search.callnumber.retrieve",
2533     authoritative => 1,
2534     notes         => "retrieves a callnumber based on ID",
2535 );
2536
2537 sub fetch_cn {
2538     my( $self, $client, $id ) = @_;
2539
2540     my $e = new_editor();
2541     my( $cn, $evt ) = $apputils->fetch_callnumber( $id, 0, $e );
2542     return $evt if $evt;
2543     return $cn;
2544 }
2545
2546 __PACKAGE__->register_method(
2547     method        => "fetch_fleshed_cn",
2548     api_name      => "open-ils.search.callnumber.fleshed.retrieve",
2549     authoritative => 1,
2550     notes         => "retrieves a callnumber based on ID, fleshing prefix, suffix, and label_class",
2551 );
2552
2553 sub fetch_fleshed_cn {
2554     my( $self, $client, $id ) = @_;
2555
2556     my $e = new_editor();
2557     my( $cn, $evt ) = $apputils->fetch_callnumber( $id, 1, $e );
2558     return $evt if $evt;
2559     return $cn;
2560 }
2561
2562
2563 __PACKAGE__->register_method(
2564     method    => "fetch_copy_by_cn",
2565     api_name  => 'open-ils.search.copies_by_call_number.retrieve',
2566     signature => q/
2567         Returns an array of copy ID's by callnumber ID
2568         @param cnid The callnumber ID
2569         @return An array of copy IDs
2570     /
2571 );
2572
2573 sub fetch_copy_by_cn {
2574     my( $self, $conn, $cnid ) = @_;
2575     return $U->cstorereq(
2576         'open-ils.cstore.direct.asset.copy.id_list.atomic', 
2577         { call_number => $cnid, deleted => 'f' } );
2578 }
2579
2580 __PACKAGE__->register_method(
2581     method    => 'fetch_cn_by_info',
2582     api_name  => 'open-ils.search.call_number.retrieve_by_info',
2583     signature => q/
2584         @param label The callnumber label
2585         @param record The record the cn is attached to
2586         @param org The owning library of the cn
2587         @return The callnumber object
2588     /
2589 );
2590
2591
2592 sub fetch_cn_by_info {
2593     my( $self, $conn, $label, $record, $org ) = @_;
2594     return $U->cstorereq(
2595         'open-ils.cstore.direct.asset.call_number.search',
2596         { label => $label, record => $record, owning_lib => $org, deleted => 'f' });
2597 }
2598
2599
2600
2601 __PACKAGE__->register_method(
2602     method   => 'bib_extras',
2603     api_name => 'open-ils.search.biblio.lit_form_map.retrieve.all',
2604     ctype => 'lit_form'
2605 );
2606 __PACKAGE__->register_method(
2607     method   => 'bib_extras',
2608     api_name => 'open-ils.search.biblio.item_form_map.retrieve.all',
2609     ctype => 'item_form'
2610 );
2611 __PACKAGE__->register_method(
2612     method   => 'bib_extras',
2613     api_name => 'open-ils.search.biblio.item_type_map.retrieve.all',
2614     ctype => 'item_type',
2615 );
2616 __PACKAGE__->register_method(
2617     method   => 'bib_extras',
2618     api_name => 'open-ils.search.biblio.bib_level_map.retrieve.all',
2619     ctype => 'bib_level'
2620 );
2621 __PACKAGE__->register_method(
2622     method   => 'bib_extras',
2623     api_name => 'open-ils.search.biblio.audience_map.retrieve.all',
2624     ctype => 'audience'
2625 );
2626
2627 sub bib_extras {
2628     my $self = shift;
2629     $logger->warn("deprecation warning: " .$self->api_name);
2630
2631     my $e = new_editor();
2632
2633     my $ctype = $self->{ctype};
2634     my $ccvms = $e->search_config_coded_value_map({ctype => $ctype});
2635
2636     my @objs;
2637     for my $ccvm (@$ccvms) {
2638         my $obj = "Fieldmapper::config::${ctype}_map"->new;
2639         $obj->value($ccvm->value);
2640         $obj->code($ccvm->code);
2641         $obj->description($ccvm->description) if $obj->can('description');
2642         push(@objs, $obj);
2643     }
2644
2645     return \@objs;
2646 }
2647
2648
2649
2650 __PACKAGE__->register_method(
2651     method    => 'fetch_slim_record',
2652     api_name  => 'open-ils.search.biblio.record_entry.slim.retrieve',
2653     signature => {
2654         desc   => "Retrieves one or more biblio.record_entry without the attached marcxml",
2655         params => [
2656             { desc => 'Array of Record IDs', type => 'array' }
2657         ],
2658         return => { 
2659             desc => 'Array of biblio records, event on error'
2660         }
2661     }
2662 );
2663
2664 sub fetch_slim_record {
2665     my( $self, $conn, $ids ) = @_;
2666
2667     my $editor = new_editor();
2668     my @res;
2669     for( @$ids ) {
2670         return $editor->event unless
2671             my $r = $editor->retrieve_biblio_record_entry($_);
2672         $r->clear_marc;
2673         push(@res, $r);
2674     }
2675     return \@res;
2676 }
2677
2678 __PACKAGE__->register_method(
2679     method    => 'rec_hold_parts',
2680     api_name  => 'open-ils.search.biblio.record_hold_parts',
2681     signature => q/
2682        Returns a list of {label :foo, id : bar} objects for viable monograph parts for a given record
2683     /
2684 );
2685
2686 sub rec_hold_parts {
2687     my( $self, $conn, $args ) = @_;
2688
2689     my $rec        = $$args{record};
2690     my $mrec       = $$args{metarecord};
2691     my $pickup_lib = $$args{pickup_lib};
2692     my $e = new_editor();
2693
2694     my $query = {
2695         select => {bmp => ['id', 'label']},
2696         from => 'bmp',
2697         where => {
2698             id => {
2699                 in => {
2700                     select => {'acpm' => ['part']},
2701                     from => {acpm => {acp => {join => {acn => {join => 'bre'}}}}},
2702                     where => {
2703                         '+acp' => {'deleted' => 'f'},
2704                         '+bre' => {id => $rec}
2705                     },
2706                     distinct => 1,
2707                 }
2708             },
2709             deleted => 'f'
2710         },
2711         order_by =>[{class=>'bmp', field=>'label_sortkey'}]
2712     };
2713
2714     if(defined $pickup_lib) {
2715         my $hard_boundary = $U->ou_ancestor_setting_value($pickup_lib, OILS_SETTING_HOLD_HARD_BOUNDARY);
2716         if($hard_boundary) {
2717             my $orgs = $e->json_query({from => ['actor.org_unit_descendants' => $pickup_lib, $hard_boundary]});
2718             $query->{where}->{'+acp'}->{circ_lib} = [ map { $_->{id} } @$orgs ];
2719         }
2720     }
2721
2722     return $e->json_query($query);
2723 }
2724
2725
2726
2727
2728 __PACKAGE__->register_method(
2729     method    => 'rec_to_mr_rec_descriptors',
2730     api_name  => 'open-ils.search.metabib.record_to_descriptors',
2731     signature => q/
2732         specialized method...
2733         Given a biblio record id or a metarecord id, 
2734         this returns a list of metabib.record_descriptor
2735         objects that live within the same metarecord
2736         @param args Object of args including:
2737     /
2738 );
2739
2740 sub rec_to_mr_rec_descriptors {
2741     my( $self, $conn, $args ) = @_;
2742
2743     my $rec        = $$args{record};
2744     my $mrec       = $$args{metarecord};
2745     my $item_forms = $$args{item_forms};
2746     my $item_types = $$args{item_types};
2747     my $item_lang  = $$args{item_lang};
2748     my $pickup_lib = $$args{pickup_lib};
2749
2750     my $hard_boundary = $U->ou_ancestor_setting_value($pickup_lib, OILS_SETTING_HOLD_HARD_BOUNDARY) if (defined $pickup_lib);
2751
2752     my $e = new_editor();
2753     my $recs;
2754
2755     if( !$mrec ) {
2756         my $map = $e->search_metabib_metarecord_source_map({source => $rec});
2757         return $e->event unless @$map;
2758         $mrec = $$map[0]->metarecord;
2759     }
2760
2761     $recs = $e->search_metabib_metarecord_source_map({metarecord => $mrec});
2762     return $e->event unless @$recs;
2763
2764     my @recs = map { $_->source } @$recs;
2765     my $search = { record => \@recs };
2766     $search->{item_form} = $item_forms if $item_forms and @$item_forms;
2767     $search->{item_type} = $item_types if $item_types and @$item_types;
2768     $search->{item_lang} = $item_lang  if $item_lang;
2769
2770     my $desc = $e->search_metabib_record_descriptor($search);
2771
2772     my $query = {
2773         distinct => 1,
2774         select   => { 'bre' => ['id'] },
2775         from     => {
2776             'bre' => {
2777                 'acn' => {
2778                     'join' => {
2779                         'acp' => {"join" => {"acpl" => {}, "ccs" => {}}}
2780                       }
2781                   }
2782              }
2783         },
2784         where => {
2785             '+bre' => { id => \@recs },
2786             '+acp' => {
2787                 holdable => 't',
2788                 deleted  => 'f'
2789             },
2790             "+ccs" => { holdable => 't' },
2791             "+acpl" => { holdable => 't', deleted => 'f' }
2792         }
2793     };
2794
2795     if ($hard_boundary) { # 0 (or "top") is the same as no setting
2796         my $orgs = $e->json_query(
2797             { from => [ 'actor.org_unit_descendants' => $pickup_lib, $hard_boundary ] }
2798         ) or return $e->die_event;
2799
2800         $query->{where}->{"+acp"}->{circ_lib} = [ map { $_->{id} } @$orgs ];
2801     }
2802
2803     my $good_records = $e->json_query($query) or return $e->die_event;
2804
2805     my @keep;
2806     for my $d (@$desc) {
2807         if ( grep { $d->record == $_->{id} } @$good_records ) {
2808             push @keep, $d;
2809         }
2810     }
2811
2812     $desc = \@keep;
2813
2814     return { metarecord => $mrec, descriptors => $desc };
2815 }
2816
2817
2818 __PACKAGE__->register_method(
2819     method   => 'fetch_age_protect',
2820     api_name => 'open-ils.search.copy.age_protect.retrieve.all',
2821 );
2822
2823 sub fetch_age_protect {
2824     return new_editor()->retrieve_all_config_rule_age_hold_protect();
2825 }
2826
2827
2828 __PACKAGE__->register_method(
2829     method   => 'copies_by_cn_label',
2830     api_name => 'open-ils.search.asset.copy.retrieve_by_cn_label',
2831 );
2832
2833 __PACKAGE__->register_method(
2834     method   => 'copies_by_cn_label',
2835     api_name => 'open-ils.search.asset.copy.retrieve_by_cn_label.staff',
2836 );
2837
2838 sub copies_by_cn_label {
2839     my( $self, $conn, $record, $cn_parts, $circ_lib ) = @_;
2840     my $e = new_editor();
2841     my $cnp_id = $cn_parts->[0] eq '' ? -1 : $e->search_asset_call_number_prefix({label => $cn_parts->[0]}, {idlist=>1})->[0];
2842     my $cns_id = $cn_parts->[2] eq '' ? -1 : $e->search_asset_call_number_suffix({label => $cn_parts->[2]}, {idlist=>1})->[0];
2843     my $cns = $e->search_asset_call_number({record => $record, prefix => $cnp_id, label => $cn_parts->[1], suffix => $cns_id, deleted => 'f'}, {idlist=>1});
2844     return [] unless @$cns;
2845
2846     # show all non-deleted copies in the staff client ...
2847     if ($self->api_name =~ /staff$/o) {
2848         return $e->search_asset_copy({call_number => $cns, circ_lib => $circ_lib, deleted => 'f'}, {idlist=>1});
2849     }
2850
2851     # ... otherwise, grab the copies ...
2852     my $copies = $e->search_asset_copy(
2853         [ {call_number => $cns, circ_lib => $circ_lib, deleted => 'f', opac_visible => 't'},
2854           {flesh => 1, flesh_fields => { acp => [ qw/location status/] } }
2855         ]
2856     );
2857
2858     # ... and test for location and status visibility
2859     return [ map { ($U->is_true($_->location->opac_visible) && $U->is_true($_->status->opac_visible)) ? ($_->id) : () } @$copies ];
2860 }
2861
2862 __PACKAGE__->register_method(
2863     method   => 'bib_copies',
2864     api_name => 'open-ils.search.bib.copies',
2865     stream => 1
2866 );
2867 __PACKAGE__->register_method(
2868     method   => 'bib_copies',
2869     api_name => 'open-ils.search.bib.copies.staff',
2870     stream => 1
2871 );
2872
2873 sub bib_copies {
2874     my ($self, $client, $rec_id, $org, $depth, $limit, $offset, $pref_ou) = @_;
2875     my $is_staff = ($self->api_name =~ /staff/);
2876
2877     my $cstore = OpenSRF::AppSession->create('open-ils.cstore');
2878     my $req = $cstore->request(
2879         'open-ils.cstore.json_query', mk_copy_query(
2880         $rec_id, $org, $depth, $limit, $offset, $pref_ou, $is_staff));
2881
2882     my $resp;
2883     while ($resp = $req->recv) {
2884         my $copy = $resp->content;
2885
2886         if ($is_staff) {
2887             # last_circ is an IDL query so it cannot be queried directly
2888             # via JSON query.
2889             $copy->{last_circ} = 
2890                 new_editor()->retrieve_reporter_last_circ_date($copy->{id})
2891                 ->last_circ;
2892         }
2893
2894         $client->respond($copy);
2895     }
2896
2897     return undef;
2898 }
2899
2900 # TODO: this comes almost directly from WWW/EGCatLoader/Record.pm
2901 # Refactor to share
2902 sub mk_copy_query {
2903     my $rec_id = shift;
2904     my $org = shift;
2905     my $depth = shift;
2906     my $copy_limit = shift;
2907     my $copy_offset = shift;
2908     my $pref_ou = shift;
2909     my $is_staff = shift;
2910     my $base_query = shift;
2911
2912     my $query = $base_query || $U->basic_opac_copy_query(
2913         $rec_id, undef, undef, $copy_limit, $copy_offset, $is_staff
2914     );
2915
2916     if ($org) { # TODO: root org test
2917         # no need to add the org join filter if we're not actually filtering
2918         $query->{from}->{acp}->[1] = { aou => {
2919             fkey => 'circ_lib',
2920             field => 'id',
2921             filter => {
2922                 id => {
2923                     in => {
2924                         select => {aou => [{
2925                             column => 'id', 
2926                             transform => 'actor.org_unit_descendants',
2927                             result_field => 'id', 
2928                             params => [$depth]
2929                         }]},
2930                         from => 'aou',
2931                         where => {id => $org}
2932                     }
2933                 }
2934             }
2935         }};
2936
2937         if ($pref_ou) {
2938             # Make sure the pref OU is included in the results
2939             my $in = $query->{from}->{acp}->[1]->{aou}->{filter}->{id}->{in};
2940             delete $query->{from}->{acp}->[1]->{aou}->{filter}->{id};
2941             $query->{from}->{acp}->[1]->{aou}->{filter}->{'-or'} = [
2942                 {id => {in => $in}},
2943                 {id => $pref_ou}
2944             ];
2945         }
2946     };
2947
2948     # Unsure if we want these in the shared function, leaving here for now
2949     unshift(@{$query->{order_by}},
2950         { class => "aou", field => 'id',
2951           transform => 'evergreen.rank_ou', params => [$org, $pref_ou]
2952         }
2953     );
2954     push(@{$query->{order_by}},
2955         { class => "acp", field => 'id',
2956           transform => 'evergreen.rank_cp'
2957         }
2958     );
2959
2960     return $query;
2961 }
2962
2963 __PACKAGE__->register_method(
2964     method    => 'record_urls',
2965     api_name  => 'open-ils.search.biblio.record.resource_urls.retrieve',
2966     argc      => 1,
2967     stream    => 1,
2968     signature => {
2969         desc   => q/Returns bib record 856 URL content./,
2970         params => [
2971             {desc => 'Context org unit ID', type => 'number'},
2972             {desc => 'Record ID or Array of Record IDs', type => 'number or array'}
2973         ],
2974         return => {
2975             desc => 'Stream of URL objects, one collection object per record',
2976             type => 'object'
2977         }
2978     }
2979 );
2980
2981 sub record_urls {
2982     my ($self, $client, $org_id, $record_ids) = @_;
2983
2984     $record_ids = [$record_ids] unless ref $record_ids eq 'ARRAY';
2985
2986     my $e = new_editor();
2987
2988     for my $record_id (@$record_ids) {
2989
2990         my @urls;
2991
2992         # Start with scoped located URIs
2993         my $uris = $e->json_query({
2994             from => ['evergreen.located_uris_as_uris', $record_id, $org_id]});
2995
2996         for my $uri (@$uris) {
2997             push(@urls, {
2998                 href => $uri->{href},
2999                 label => $uri->{label},
3000                 note => $uri->{use_restriction}
3001             });
3002         }
3003
3004         # Logic copied from TPAC misc_utils.tts
3005         my $bib = $e->retrieve_biblio_record_entry($record_id)
3006             or return $e->event;
3007
3008         my $marc_doc = $U->marc_xml_to_doc($bib->marc);
3009
3010         for my $node ($marc_doc->findnodes('//*[@tag="856" and @ind1="4"]')) {
3011
3012             # asset.uri's
3013             next if $node->findnodes('./*[@code="9" or @code="w" or @code="n"]');
3014
3015             my $url = {};
3016             my ($label) = $node->findnodes('./*[@code="y"]');
3017             my ($notes) = $node->findnodes('./*[@code="z" or @code="3"]');
3018
3019             my $first = 1;
3020             for my $href_node ($node->findnodes('./*[@code="u"]')) {
3021                 next unless $href_node;
3022
3023                 # it's possible for multiple $u's to exist within 1 856 tag.
3024                 # in that case, honor the label/notes data for the first $u, but
3025                 # leave any subsequent $u's as unadorned href's.
3026                 # use href/link/note keys to be consistent with args.uri's
3027
3028                 my $href = $href_node->textContent;
3029                 push(@urls, {
3030                     href => $href,
3031                     label => ($first && $label) ?  $label->textContent : $href,
3032                     note => ($first && $notes) ? $notes->textContent : '',
3033                     ind2 => $node->getAttribute('ind2')
3034                 });
3035                 $first = 0;
3036             }
3037         }
3038
3039         $client->respond({id => $record_id, urls => \@urls});
3040     }
3041
3042     return undef;
3043 }
3044
3045 __PACKAGE__->register_method(
3046     method    => 'catalog_record_summary',
3047     api_name  => 'open-ils.search.biblio.record.catalog_summary',
3048     stream    => 1,
3049     max_bundle_count => 1,
3050     signature => {
3051         desc   => 'Stream of record data suitable for catalog display',
3052         params => [
3053             {desc => 'Context org unit ID', type => 'number'},
3054             {desc => 'Array of Record IDs', type => 'array'}
3055         ],
3056         return => { 
3057             desc => q/
3058                 Stream of record summary objects including id, record,
3059                 hold_count, copy_counts, display (metabib display
3060                 fields), attributes (metabib record attrs), plus
3061                 metabib_id and metabib_records for the metabib variant.
3062             /
3063         }
3064     }
3065 );
3066 __PACKAGE__->register_method(
3067     method    => 'catalog_record_summary',
3068     api_name  => 'open-ils.search.biblio.record.catalog_summary.staff',
3069     stream    => 1,
3070     max_bundle_count => 1,
3071     signature => q/see open-ils.search.biblio.record.catalog_summary/
3072 );
3073 __PACKAGE__->register_method(
3074     method    => 'catalog_record_summary',
3075     api_name  => 'open-ils.search.biblio.metabib.catalog_summary',
3076     stream    => 1,
3077     max_bundle_count => 1,
3078     signature => q/see open-ils.search.biblio.record.catalog_summary/
3079 );
3080
3081 __PACKAGE__->register_method(
3082     method    => 'catalog_record_summary',
3083     api_name  => 'open-ils.search.biblio.metabib.catalog_summary.staff',
3084     stream    => 1,
3085     max_bundle_count => 1,
3086     signature => q/see open-ils.search.biblio.record.catalog_summary/
3087 );
3088
3089
3090 sub catalog_record_summary {
3091     my ($self, $client, $org_id, $record_ids, $options) = @_;
3092     my $e = new_editor();
3093     $options ||= {};
3094     my $pref_ou = $options->{pref_ou};
3095
3096     my $is_meta = ($self->api_name =~ /metabib/);
3097     my $is_staff = ($self->api_name =~ /staff/);
3098
3099     my $holds_method = $is_meta ? 
3100         'open-ils.circ.mmr.holds.count' : 
3101         'open-ils.circ.bre.holds.count';
3102
3103     my $copy_method = $is_meta ? 
3104         'open-ils.search.biblio.metarecord.copy_count':
3105         'open-ils.search.biblio.record.copy_count';
3106
3107     $copy_method .= '.staff' if $is_staff;
3108
3109     $copy_method = $self->method_lookup($copy_method); # local method
3110
3111     my $holdable_method = $is_meta ?
3112         'open-ils.search.biblio.metarecord.has_holdable_copy':
3113         'open-ils.search.biblio.record.has_holdable_copy';
3114
3115     $holdable_method = $self->method_lookup($holdable_method); # local method
3116
3117     for my $rec_id (@$record_ids) {
3118
3119         my $response = $is_meta ? 
3120             get_one_metarecord_summary($self, $e, $org_id, $rec_id) :
3121             get_one_record_summary($self, $e, $org_id, $rec_id);
3122
3123         ($response->{copy_counts}) = $copy_method->run($org_id, $rec_id);
3124
3125         $response->{first_call_number} = get_first_call_number(
3126             $e, $rec_id, $org_id, $is_staff, $is_meta, $options);
3127
3128         if ($pref_ou) {
3129
3130             # If we already have the pref ou copy counts, avoid the extra fetch.
3131             my ($match) = 
3132                 grep {$_->{org_unit} eq $pref_ou} @{$response->{copy_counts}};
3133
3134             if (!$match) {
3135                 my ($counts) = $copy_method->run($pref_ou, $rec_id);
3136                 ($match) = grep {$_->{org_unit} eq $pref_ou} @$counts;
3137             }
3138
3139             $response->{pref_ou_copy_counts} = $match;
3140         }
3141
3142         $response->{hold_count} = 
3143             $U->simplereq('open-ils.circ', $holds_method, $rec_id);
3144
3145         if ($options->{flesh_copies}) {
3146             $response->{copies} = get_representative_copies(
3147                 $e, $rec_id, $org_id, $is_staff, $is_meta, $options);
3148         }
3149
3150         ($response->{has_holdable_copy}) = $holdable_method->run($rec_id);
3151
3152         $client->respond($response);
3153     }
3154
3155     return undef;
3156 }
3157
3158 # Returns a snapshot of copy information for a given record or metarecord,
3159 # sorted by pref org and search org.
3160 sub get_representative_copies {
3161     my ($e, $rec_id, $org_id, $is_staff, $is_meta, $options) = @_;
3162
3163     my @rec_ids;
3164     my $limit = $options->{copy_limit};
3165     my $copy_depth = $options->{copy_depth};
3166     my $copy_offset = $options->{copy_offset};
3167     my $pref_ou = $options->{pref_ou};
3168
3169     my $org_tree = $U->get_org_tree;
3170     if (!$org_id) { $org_id = $org_tree->id; }
3171     my $org = $U->find_org($org_tree, $org_id);
3172
3173     return [] unless $org;
3174
3175     my $func = 'unapi.biblio_record_entry_feed';
3176     my $includes = '{holdings_xml,acp,acnp,acns,circ}';
3177     my $limits = "acn=>$limit,acp=>$limit";
3178
3179     if ($is_meta) {
3180         $func = 'unapi.metabib_virtual_record_feed';
3181         $includes = '{holdings_xml,acp,acnp,acns,circ,mmr.unapi}';
3182         $limits .= ",bre=>$limit";
3183     }
3184
3185     my $xml_query = $e->json_query({from => [
3186         $func, '{'.$rec_id.'}', 'marcxml', 
3187         $includes, $org->shortname, $copy_depth, $limits,
3188         undef, undef,undef, undef, undef, 
3189         undef, undef, undef, $pref_ou
3190     ]})->[0];
3191
3192     my $xml = $xml_query->{$func};
3193
3194     my $doc = XML::LibXML->new->parse_string($xml);
3195
3196     my $copies = [];
3197     for my $volume ($doc->documentElement->findnodes('//*[local-name()="volume"]')) {
3198         my $label = $volume->getAttribute('label');
3199         my $prefix = $volume->getElementsByTagName('call_number_prefix')->[0]->getAttribute('label');
3200         my $suffix = $volume->getElementsByTagName('call_number_suffix')->[0]->getAttribute('label');
3201
3202         my $copies_node = $volume->findnodes('./*[local-name()="copies"]')->[0];
3203
3204         for my $copy ($copies_node->findnodes('./*[local-name()="copy"]')) {
3205
3206             my $status = $copy->getElementsByTagName('status')->[0]->textContent;
3207             my $location = $copy->getElementsByTagName('location')->[0]->textContent;
3208             my $circ_lib_sn = $copy->getElementsByTagName('circ_lib')->[0]->getAttribute('shortname');
3209             my $due_date = '';
3210
3211             my $current_circ = $copy->findnodes('./*[local-name()="current_circulation"]')->[0];
3212             if (my $circ = $current_circ->findnodes('./*[local-name()="circ"]')) {
3213                 $due_date = $circ->[0]->getAttribute('due_date');
3214             }
3215
3216             push(@$copies, {
3217                 call_number_label => $label,
3218                 call_number_prefix_label => $prefix,
3219                 call_number_suffix_label => $suffix,
3220                 circ_lib_sn => $circ_lib_sn,
3221                 copy_status => $status,
3222                 copy_location => $location,
3223                 due_date => $due_date
3224             });
3225         }
3226     }
3227
3228     return $copies;
3229 }
3230
3231 sub get_first_call_number {
3232     my ($e, $rec_id, $org_id, $is_staff, $is_meta, $options) = @_;
3233
3234     my $limit = $options->{copy_limit};
3235     $options->{copy_limit} = 1;
3236
3237     my $copies = get_representative_copies(
3238         $e, $rec_id, $org_id, $is_staff, $is_meta, $options);
3239
3240     $options->{copy_limit} = $limit;
3241
3242     return $copies->[0];
3243 }
3244
3245 sub get_one_rec_urls {
3246     my ($self, $e, $org_id, $bib_id) = @_;
3247
3248     my ($resp) = $self->method_lookup(
3249         'open-ils.search.biblio.record.resource_urls.retrieve')
3250         ->run($org_id, $bib_id);
3251
3252     return $resp->{urls};
3253 }
3254
3255 # Start with a bib summary and augment the data with additional
3256 # metarecord content.
3257 sub get_one_metarecord_summary {
3258     my ($self, $e, $org_id, $rec_id) = @_;
3259
3260     my $meta = $e->retrieve_metabib_metarecord($rec_id) or return {};
3261     my $maps = $e->search_metabib_metarecord_source_map({metarecord => $rec_id});
3262
3263     my $bre_id = $meta->master_record; 
3264
3265     my $response = get_one_record_summary($self, $e, $org_id, $bre_id);
3266     $response->{urls} = get_one_rec_urls($self, $e, $org_id, $bre_id);
3267
3268     $response->{metabib_id} = $rec_id;
3269     $response->{metabib_records} = [map {$_->source} @$maps];
3270
3271     my @other_bibs = map {$_->source} grep {$_->source != $bre_id} @$maps;
3272
3273     # Augment the record attributes with those of all of the records
3274     # linked to this metarecord.
3275     if (@other_bibs) {
3276         my $attrs = $e->search_metabib_record_attr_flat({id => \@other_bibs});
3277
3278         my $attributes = $response->{attributes};
3279
3280         for my $attr (@$attrs) {
3281             $attributes->{$attr->attr} = [] unless $attributes->{$attr->attr};
3282             push(@{$attributes->{$attr->attr}}, $attr->value) # avoid dupes
3283                 unless grep {$_ eq $attr->value} @{$attributes->{$attr->attr}};
3284         }
3285     }
3286
3287     return $response;
3288 }
3289
3290 sub get_one_record_summary {
3291     my ($self, $e, $org_id, $rec_id) = @_;
3292
3293     my $bre = $e->retrieve_biblio_record_entry([$rec_id, {
3294         flesh => 1,
3295         flesh_fields => {
3296             bre => [qw/compressed_display_entries mattrs creator editor/]
3297         }
3298     }]) or return {};
3299
3300     # Compressed display fields are pachaged as JSON
3301     my $display = {};
3302     $display->{$_->name} = OpenSRF::Utils::JSON->JSON2perl($_->value)
3303         foreach @{$bre->compressed_display_entries};
3304
3305     # Create an object of 'mraf' attributes.
3306     # Any attribute can be multi so dedupe and array-ify all of them.
3307     my $attributes = {};
3308     for my $attr (@{$bre->mattrs}) {
3309         $attributes->{$attr->attr} = {} unless $attributes->{$attr->attr};
3310         $attributes->{$attr->attr}->{$attr->value} = 1; # avoid dupes
3311     }
3312     $attributes->{$_} = [keys %{$attributes->{$_}}] for keys %$attributes;
3313
3314     # clear bulk
3315     $bre->clear_marc;
3316     $bre->clear_mattrs;
3317     $bre->clear_compressed_display_entries;
3318
3319     return {
3320         id => $rec_id,
3321         record => $bre,
3322         display => $display,
3323         attributes => $attributes,
3324         urls => get_one_rec_urls($self, $e, $org_id, $rec_id)
3325     };
3326 }
3327
3328 __PACKAGE__->register_method(
3329     method    => 'record_copy_counts_global',
3330     api_name  => 'open-ils.search.biblio.record.copy_counts.global.staff',
3331     signature => {
3332         desc   => q/Returns a count of copies and call numbers for each org
3333                     unit, including items attached to each org unit plus
3334                     a sum of counts for all descendants./,
3335         params => [
3336             {desc => 'Record ID', type => 'number'}
3337         ],
3338         return => {
3339             desc => 'Hash of org unit ID  => {copy: $count, call_number: $id}'
3340         }
3341     }
3342 );
3343
3344 sub record_copy_counts_global {
3345     my ($self, $client, $rec_id) = @_;
3346
3347     my $copies = new_editor()->json_query({
3348         select => {
3349             acp => [{column => 'id', alias => 'copy_id'}, 'circ_lib'],
3350             acn => [{column => 'id', alias => 'cn_id'}, 'owning_lib']
3351         },
3352         from => {acn => {acp => {type => 'left'}}},
3353         where => {
3354             '+acp' => {
3355                 '-or' => [
3356                     {deleted => 'f'},
3357                     {id => undef} # left join
3358                 ]
3359             },
3360             '+acn' => {deleted => 'f', record => $rec_id}
3361         }
3362     });
3363
3364     my $hash = {};
3365     my %seen_cn;
3366
3367     for my $copy (@$copies) {
3368         my $org = $copy->{circ_lib} || $copy->{owning_lib};
3369         $hash->{$org} = {copies => 0, call_numbers => 0} unless $hash->{$org};
3370         $hash->{$org}->{copies}++ if $copy->{circ_lib};
3371
3372         if (!$seen_cn{$copy->{cn_id}}) {
3373             $seen_cn{$copy->{cn_id}} = 1;
3374             $hash->{$org}->{call_numbers}++;
3375         }
3376     }
3377
3378     my $sum;
3379     $sum = sub {
3380         my $node = shift;
3381         my $h = $hash->{$node->id} || {copies => 0, call_numbers => 0};
3382         delete $h->{cn_id};
3383
3384         for my $child (@{$node->children}) {
3385             my $vals = $sum->($child);
3386             $h->{copies} += $vals->{copies};
3387             $h->{call_numbers} += $vals->{call_numbers};
3388         }
3389
3390         $hash->{$node->id} = $h;
3391
3392         return $h;
3393     };
3394
3395     $sum->($U->get_org_tree);
3396
3397     return $hash;
3398 }
3399
3400
3401 1;
3402