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