Bug 24151: Fix location on return
[koha.git] / C4 / Search.pm
1 package C4::Search;
2
3 # This file is part of Koha.
4 #
5 # Koha is free software; you can redistribute it and/or modify it
6 # under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 3 of the License, or
8 # (at your option) any later version.
9 #
10 # Koha is distributed in the hope that it will be useful, but
11 # WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with Koha; if not, see <http://www.gnu.org/licenses>.
17
18 use Modern::Perl;
19 require Exporter;
20 use C4::Context;
21 use C4::Biblio;    # GetMarcFromKohaField, GetBiblioData
22 use C4::Koha;      # getFacets
23 use Koha::DateUtils;
24 use Koha::Libraries;
25 use Lingua::Stem;
26 use C4::Search::PazPar2;
27 use XML::Simple;
28 use C4::XSLT;
29 use C4::Reserves;    # GetReserveStatus
30 use C4::Debug;
31 use C4::Charset;
32 use Koha::AuthorisedValues;
33 use Koha::ItemTypes;
34 use Koha::Libraries;
35 use Koha::Patrons;
36 use Koha::RecordProcessor;
37 use YAML;
38 use URI::Escape;
39 use Business::ISBN;
40 use MARC::Record;
41 use MARC::Field;
42 use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $DEBUG);
43
44 BEGIN {
45     $DEBUG = ($ENV{DEBUG}) ? 1 : 0;
46 }
47
48 =head1 NAME
49
50 C4::Search - Functions for searching the Koha catalog.
51
52 =head1 SYNOPSIS
53
54 See opac/opac-search.pl or catalogue/search.pl for example of usage
55
56 =head1 DESCRIPTION
57
58 This module provides searching functions for Koha's bibliographic databases
59
60 =head1 FUNCTIONS
61
62 =cut
63
64 @ISA    = qw(Exporter);
65 @EXPORT = qw(
66   &FindDuplicate
67   &SimpleSearch
68   &searchResults
69   &getRecords
70   &buildQuery
71   &GetDistinctValues
72   &enabled_staff_search_views
73 );
74
75 # make all your functions, whether exported or not;
76
77 =head2 FindDuplicate
78
79 ($biblionumber,$biblionumber,$title) = FindDuplicate($record);
80
81 This function attempts to find duplicate records using a hard-coded, fairly simplistic algorithm
82
83 =cut
84
85 sub FindDuplicate {
86     my ($record) = @_;
87     my $dbh = C4::Context->dbh;
88     my $result = TransformMarcToKoha( $record, '' );
89     my $sth;
90     my $query;
91
92     # search duplicate on ISBN, easy and fast..
93     # ... normalize first
94     if ( $result->{isbn} ) {
95         $result->{isbn} =~ s/\(.*$//;
96         $result->{isbn} =~ s/\s+$//;
97         $query = "isbn:$result->{isbn}";
98     }
99     else {
100
101         my $titleindex = 'ti,ext';
102         my $authorindex = 'au,ext';
103         my $op = 'and';
104
105         $result->{title} =~ s /\\//g;
106         $result->{title} =~ s /\"//g;
107         $result->{title} =~ s /\(//g;
108         $result->{title} =~ s /\)//g;
109
110         # FIXME: instead of removing operators, could just do
111         # quotes around the value
112         $result->{title} =~ s/(and|or|not)//g;
113         $query = "$titleindex:\"$result->{title}\"";
114         if   ( $result->{author} ) {
115             $result->{author} =~ s /\\//g;
116             $result->{author} =~ s /\"//g;
117             $result->{author} =~ s /\(//g;
118             $result->{author} =~ s /\)//g;
119
120             # remove valid operators
121             $result->{author} =~ s/(and|or|not)//g;
122             $query .= " $op $authorindex:\"$result->{author}\"";
123         }
124     }
125
126     my ( $error, $searchresults, undef ) = SimpleSearch($query); # FIXME :: hardcoded !
127     my @results;
128     if (!defined $error) {
129         foreach my $possible_duplicate_record (@{$searchresults}) {
130             my $marcrecord = new_record_from_zebra(
131                 'biblioserver',
132                 $possible_duplicate_record
133             );
134
135             my $result = TransformMarcToKoha( $marcrecord, '' );
136
137             # FIXME :: why 2 $biblionumber ?
138             if ($result) {
139                 push @results, $result->{'biblionumber'};
140                 push @results, $result->{'title'};
141             }
142         }
143     }
144     return @results;
145 }
146
147 =head2 SimpleSearch
148
149 ( $error, $results, $total_hits ) = SimpleSearch( $query, $offset, $max_results, [@servers], [%options] );
150
151 This function provides a simple search API on the bibliographic catalog
152
153 =over 2
154
155 =item C<input arg:>
156
157     * $query can be a simple keyword or a complete CCL query
158     * @servers is optional. Defaults to biblioserver as found in koha-conf.xml
159     * $offset - If present, represents the number of records at the beginning to omit. Defaults to 0
160     * $max_results - if present, determines the maximum number of records to fetch. undef is All. defaults to undef.
161     * %options is optional. (e.g. "skip_normalize" allows you to skip changing : to = )
162
163
164 =item C<Return:>
165
166     Returns an array consisting of three elements
167     * $error is undefined unless an error is detected
168     * $results is a reference to an array of records.
169     * $total_hits is the number of hits that would have been returned with no limit
170
171     If an error is returned the two other return elements are undefined. If error itself is undefined
172     the other two elements are always defined
173
174 =item C<usage in the script:>
175
176 =back
177
178 my ( $error, $marcresults, $total_hits ) = SimpleSearch($query);
179
180 if (defined $error) {
181     $template->param(query_error => $error);
182     warn "error: ".$error;
183     output_html_with_http_headers $input, $cookie, $template->output;
184     exit;
185 }
186
187 my $hits = @{$marcresults};
188 my @results;
189
190 for my $r ( @{$marcresults} ) {
191     my $marcrecord = MARC::File::USMARC::decode($r);
192     my $biblio = TransformMarcToKoha($marcrecord,q{});
193
194     #build the iarray of hashs for the template.
195     push @results, {
196         title           => $biblio->{'title'},
197         subtitle        => $biblio->{'subtitle'},
198         biblionumber    => $biblio->{'biblionumber'},
199         author          => $biblio->{'author'},
200         publishercode   => $biblio->{'publishercode'},
201         publicationyear => $biblio->{'publicationyear'},
202         };
203
204 }
205
206 $template->param(result=>\@results);
207
208 =cut
209
210 sub SimpleSearch {
211     my ( $query, $offset, $max_results, $servers, %options )  = @_;
212
213     return ( 'No query entered', undef, undef ) unless $query;
214     # FIXME hardcoded value. See catalog/search.pl & opac-search.pl too.
215     my @servers = defined ( $servers ) ? @$servers : ( 'biblioserver' );
216     my @zoom_queries;
217     my @tmpresults;
218     my @zconns;
219     my $results = [];
220     my $total_hits = 0;
221
222     # Initialize & Search Zebra
223     for ( my $i = 0 ; $i < @servers ; $i++ ) {
224         eval {
225             $zconns[$i] = C4::Context->Zconn( $servers[$i], 1 );
226             $query =~ s/:/=/g unless $options{skip_normalize};
227             $zoom_queries[$i] = new ZOOM::Query::CCL2RPN( $query, $zconns[$i]);
228             $tmpresults[$i] = $zconns[$i]->search( $zoom_queries[$i] );
229
230             # error handling
231             my $error =
232                 $zconns[$i]->errmsg() . " ("
233               . $zconns[$i]->errcode() . ") "
234               . $zconns[$i]->addinfo() . " "
235               . $zconns[$i]->diagset();
236
237             return ( $error, undef, undef ) if $zconns[$i]->errcode();
238         };
239         if ($@) {
240
241             # caught a ZOOM::Exception
242             my $error =
243                 $@->message() . " ("
244               . $@->code() . ") "
245               . $@->addinfo() . " "
246               . $@->diagset();
247             warn $error." for query: $query";
248             return ( $error, undef, undef );
249         }
250     }
251
252     _ZOOM_event_loop(
253         \@zconns,
254         \@tmpresults,
255         sub {
256             my ($i, $size) = @_;
257             my $first_record = defined($offset) ? $offset + 1 : 1;
258             my $hits = $tmpresults[ $i - 1 ]->size();
259             $total_hits += $hits;
260             my $last_record = $hits;
261             if ( defined $max_results && $offset + $max_results < $hits ) {
262                 $last_record = $offset + $max_results;
263             }
264
265             for my $j ( $first_record .. $last_record ) {
266                 my $record = eval {
267                   $tmpresults[ $i - 1 ]->record( $j - 1 )->raw()
268                   ;    # 0 indexed
269                 };
270                 push @{$results}, $record if defined $record;
271             }
272         }
273     );
274
275     foreach my $zoom_query (@zoom_queries) {
276         $zoom_query->destroy();
277     }
278
279     return ( undef, $results, $total_hits );
280 }
281
282 =head2 getRecords
283
284 ( undef, $results_hashref, \@facets_loop ) = getRecords (
285
286         $koha_query,       $simple_query, $sort_by_ref,    $servers_ref,
287         $results_per_page, $offset,       $branches,       $itemtypes,
288         $query_type,       $scan,         $opac
289     );
290
291 The all singing, all dancing, multi-server, asynchronous, scanning,
292 searching, record nabbing, facet-building
293
294 See verbose embedded documentation.
295
296 =cut
297
298 sub getRecords {
299     my (
300         $koha_query,       $simple_query, $sort_by_ref,    $servers_ref,
301         $results_per_page, $offset,       $branches,         $itemtypes,
302         $query_type,       $scan,         $opac
303     ) = @_;
304
305     my @servers = @$servers_ref;
306     my @sort_by = @$sort_by_ref;
307     $offset = 0 if $offset < 0;
308
309     # Initialize variables for the ZOOM connection and results object
310     my @zconns;
311     my @results;
312     my $results_hashref = ();
313
314     # TODO simplify this structure ( { branchcode => $branchname } is enought) and remove this parameter
315     $branches ||= { map { $_->branchcode => { branchname => $_->branchname } } Koha::Libraries->search };
316
317     # Initialize variables for the faceted results objects
318     my $facets_counter = {};
319     my $facets_info    = {};
320     my $facets         = getFacets();
321
322     my @facets_loop;    # stores the ref to array of hashes for template facets loop
323
324     ### LOOP THROUGH THE SERVERS
325     for ( my $i = 0 ; $i < @servers ; $i++ ) {
326         $zconns[$i] = C4::Context->Zconn( $servers[$i], 1 );
327
328 # perform the search, create the results objects
329 # if this is a local search, use the $koha-query, if it's a federated one, use the federated-query
330         my $query_to_use = ($servers[$i] =~ /biblioserver/) ? $koha_query : $simple_query;
331
332         #$query_to_use = $simple_query if $scan;
333         warn $simple_query if ( $scan and $DEBUG );
334
335         # Check if we've got a query_type defined, if so, use it
336         eval {
337             if ($query_type) {
338                 if ($query_type =~ /^ccl/) {
339                     $query_to_use =~ s/\:/\=/g;    # change : to = last minute (FIXME)
340                     $results[$i] = $zconns[$i]->search(new ZOOM::Query::CCL2RPN($query_to_use, $zconns[$i]));
341                 } elsif ($query_type =~ /^cql/) {
342                     $results[$i] = $zconns[$i]->search(new ZOOM::Query::CQL($query_to_use, $zconns[$i]));
343                 } elsif ($query_type =~ /^pqf/) {
344                     $results[$i] = $zconns[$i]->search(new ZOOM::Query::PQF($query_to_use, $zconns[$i]));
345                 } else {
346                     warn "Unknown query_type '$query_type'.  Results undetermined.";
347                 }
348             } elsif ($scan) {
349                     $results[$i] = $zconns[$i]->scan(  new ZOOM::Query::CCL2RPN($query_to_use, $zconns[$i]));
350             } else {
351                     $results[$i] = $zconns[$i]->search(new ZOOM::Query::CCL2RPN($query_to_use, $zconns[$i]));
352             }
353         };
354         if ($@) {
355             warn "WARNING: query problem with $query_to_use " . $@;
356         }
357
358         # Concatenate the sort_by limits and pass them to the results object
359         # Note: sort will override rank
360         my $sort_by;
361         foreach my $sort (@sort_by) {
362             if ( $sort eq "author_az" || $sort eq "author_asc" ) {
363                 $sort_by .= "1=1003 <i ";
364             }
365             elsif ( $sort eq "author_za" || $sort eq "author_dsc" ) {
366                 $sort_by .= "1=1003 >i ";
367             }
368             elsif ( $sort eq "popularity_asc" ) {
369                 $sort_by .= "1=9003 <i ";
370             }
371             elsif ( $sort eq "popularity_dsc" ) {
372                 $sort_by .= "1=9003 >i ";
373             }
374             elsif ( $sort eq "call_number_asc" ) {
375                 $sort_by .= "1=8007  <i ";
376             }
377             elsif ( $sort eq "call_number_dsc" ) {
378                 $sort_by .= "1=8007 >i ";
379             }
380             elsif ( $sort eq "pubdate_asc" ) {
381                 $sort_by .= "1=31 <i ";
382             }
383             elsif ( $sort eq "pubdate_dsc" ) {
384                 $sort_by .= "1=31 >i ";
385             }
386             elsif ( $sort eq "acqdate_asc" ) {
387                 $sort_by .= "1=32 <i ";
388             }
389             elsif ( $sort eq "acqdate_dsc" ) {
390                 $sort_by .= "1=32 >i ";
391             }
392             elsif ( $sort eq "title_az" || $sort eq "title_asc" ) {
393                 $sort_by .= "1=4 <i ";
394             }
395             elsif ( $sort eq "title_za" || $sort eq "title_dsc" ) {
396                 $sort_by .= "1=4 >i ";
397             }
398             else {
399                 warn "Ignoring unrecognized sort '$sort' requested" if $sort_by;
400             }
401         }
402         if ( $sort_by && !$scan && $results[$i] ) {
403             if ( $results[$i]->sort( "yaz", $sort_by ) < 0 ) {
404                 warn "WARNING sort $sort_by failed";
405             }
406         }
407     }    # finished looping through servers
408
409     # The big moment: asynchronously retrieve results from all servers
410         _ZOOM_event_loop(
411             \@zconns,
412             \@results,
413             sub {
414                 my ( $i, $size ) = @_;
415                 my $results_hash;
416
417                 # loop through the results
418                 $results_hash->{'hits'} = $size;
419                 my $times;
420                 if ( $offset + $results_per_page <= $size ) {
421                     $times = $offset + $results_per_page;
422                 }
423                 else {
424                     $times = $size;
425                 }
426
427                 for ( my $j = $offset ; $j < $times ; $j++ ) {
428                     my $record;
429
430                     ## Check if it's an index scan
431                     if ($scan) {
432                         my ( $term, $occ ) = $results[ $i - 1 ]->display_term($j);
433
434                  # here we create a minimal MARC record and hand it off to the
435                  # template just like a normal result ... perhaps not ideal, but
436                  # it works for now
437                         my $tmprecord = MARC::Record->new();
438                         $tmprecord->encoding('UTF-8');
439                         my $tmptitle;
440                         my $tmpauthor;
441
442                 # the minimal record in author/title (depending on MARC flavour)
443                         if ( C4::Context->preference("marcflavour") eq
444                             "UNIMARC" )
445                         {
446                             $tmptitle = MARC::Field->new(
447                                 '200', ' ', ' ',
448                                 a => $term,
449                                 f => $occ
450                             );
451                             $tmprecord->append_fields($tmptitle);
452                         }
453                         else {
454                             $tmptitle =
455                               MARC::Field->new( '245', ' ', ' ', a => $term, );
456                             $tmpauthor =
457                               MARC::Field->new( '100', ' ', ' ', a => $occ, );
458                             $tmprecord->append_fields($tmptitle);
459                             $tmprecord->append_fields($tmpauthor);
460                         }
461                         $results_hash->{'RECORDS'}[$j] =
462                           $tmprecord->as_usmarc();
463                     }
464
465                     # not an index scan
466                     else {
467                         $record = $results[ $i - 1 ]->record($j)->raw();
468                         # warn "RECORD $j:".$record;
469                         $results_hash->{'RECORDS'}[$j] = $record;
470                     }
471
472                 }
473                 $results_hashref->{ $servers[ $i - 1 ] } = $results_hash;
474
475                 # Fill the facets while we're looping, but only for the
476                 # biblioserver and not for a scan
477                 if ( !$scan && $servers[ $i - 1 ] =~ /biblioserver/ ) {
478                     $facets_counter = GetFacets( $results[ $i - 1 ] );
479                     $facets_info    = _get_facets_info( $facets );
480                 }
481
482                 # BUILD FACETS
483                 if ( $servers[ $i - 1 ] =~ /biblioserver/ ) {
484                     for my $link_value (
485                         sort { $a cmp $b } keys %$facets_counter
486                       )
487                     {
488                         my @this_facets_array;
489                         for my $one_facet (
490                             sort {
491                                 $facets_counter->{$link_value}
492                                   ->{$b} <=> $facets_counter->{$link_value}
493                                   ->{$a}
494                             } keys %{ $facets_counter->{$link_value} }
495                           )
496                         {
497 # Sanitize the link value : parenthesis, question and exclamation mark will cause errors with CCL
498                             my $facet_link_value = $one_facet;
499                             $facet_link_value =~ s/[()!?¡¿؟]/ /g;
500
501                             # fix the length that will display in the label,
502                             my $facet_label_value = $one_facet;
503                             my $facet_max_length  = C4::Context->preference(
504                                 'FacetLabelTruncationLength')
505                               || 20;
506                             $facet_label_value =
507                               substr( $one_facet, 0, $facet_max_length )
508                               . "..."
509                               if length($facet_label_value) >
510                                   $facet_max_length;
511
512                         # if it's a branch, label by the name, not the code,
513                             if ( $link_value =~ /branch/ ) {
514                                 if (   defined $branches
515                                     && ref($branches) eq "HASH"
516                                     && defined $branches->{$one_facet}
517                                     && ref( $branches->{$one_facet} ) eq
518                                     "HASH" )
519                                 {
520                                     $facet_label_value =
521                                       $branches->{$one_facet}
522                                       ->{'branchname'};
523                                 }
524                                 else {
525                                     $facet_label_value = "*";
526                                 }
527                             }
528
529                       # if it's a itemtype, label by the name, not the code,
530                             if ( $link_value =~ /itype/ ) {
531                                 if (   defined $itemtypes
532                                     && ref($itemtypes) eq "HASH"
533                                     && defined $itemtypes->{$one_facet}
534                                     && ref( $itemtypes->{$one_facet} ) eq
535                                     "HASH" )
536                                 {
537                                     $facet_label_value =
538                                       $itemtypes->{$one_facet}
539                                       ->{translated_description};
540                                 }
541                             }
542
543            # also, if it's a location code, use the name instead of the code
544                             if ( $link_value =~ /location/ ) {
545                                 # TODO Retrieve all authorised values at once, instead of 1 query per entry
546                                 my $av = Koha::AuthorisedValues->search({ category => 'LOC', authorised_value => $one_facet });
547                                 $facet_label_value = $av->count ? $av->next->opac_description : '';
548                             }
549
550                             # also, if it's a collection code, use the name instead of the code
551                             if ( $link_value =~ /ccode/ ) {
552                                 # TODO Retrieve all authorised values at once, instead of 1 query per entry
553                                 my $av = Koha::AuthorisedValues->search({ category => 'CCODE', authorised_value => $one_facet });
554                                 $facet_label_value = $av->count ? $av->next->opac_description : '';
555                             }
556
557             # but we're down with the whole label being in the link's title.
558                             push @this_facets_array,
559                               {
560                                 facet_count =>
561                                   $facets_counter->{$link_value}
562                                   ->{$one_facet},
563                                 facet_label_value => $facet_label_value,
564                                 facet_title_value => $one_facet,
565                                 facet_link_value  => $facet_link_value,
566                                 type_link_value   => $link_value,
567                               }
568                               if ($facet_label_value);
569                         }
570
571                         push @facets_loop,
572                           {
573                             type_link_value => $link_value,
574                             type_id         => $link_value . "_id",
575                             "type_label_"
576                               . $facets_info->{$link_value}->{'label_value'} =>
577                               1,
578                             facets     => \@this_facets_array,
579                           }
580                           unless (
581                             (
582                                 $facets_info->{$link_value}->{'label_value'} =~
583                                 /Libraries/
584                             )
585                             and ( Koha::Libraries->search->count == 1 )
586                           );
587                     }
588                 }
589             }
590         );
591
592     # This sorts the facets into alphabetical order
593     if (@facets_loop) {
594         foreach my $f (@facets_loop) {
595             $f->{facets} = [ sort { uc($a->{facet_label_value}) cmp uc($b->{facet_label_value}) } @{ $f->{facets} } ];
596         }
597     }
598
599     return ( undef, $results_hashref, \@facets_loop );
600 }
601
602 sub GetFacets {
603
604     my $rs = shift;
605     my $facets;
606
607     my $use_zebra_facets = C4::Context->config('use_zebra_facets') // 0;
608
609     if ( $use_zebra_facets ) {
610         $facets = _get_facets_from_zebra( $rs );
611     } else {
612         $facets = _get_facets_from_records( $rs );
613     }
614
615     return $facets;
616 }
617
618 sub _get_facets_from_records {
619
620     my $rs = shift;
621
622     my $facets_maxrecs = C4::Context->preference('maxRecordsForFacets') // 20;
623     my $facets_config  = getFacets();
624     my $facets         = {};
625     my $size           = $rs->size();
626     my $jmax           = $size > $facets_maxrecs
627                             ? $facets_maxrecs
628                             : $size;
629
630     for ( my $j = 0 ; $j < $jmax ; $j++ ) {
631
632         my $marc_record = new_record_from_zebra (
633                 'biblioserver',
634                 $rs->record( $j )->raw()
635         );
636
637         if ( ! defined $marc_record ) {
638             warn "ERROR DECODING RECORD - $@: " .
639                 $rs->record( $j )->raw();
640             next;
641         }
642
643         _get_facets_data_from_record( $marc_record, $facets_config, $facets );
644     }
645
646     return $facets;
647 }
648
649 =head2 _get_facets_data_from_record
650
651     C4::Search::_get_facets_data_from_record( $marc_record, $facets, $facets_counter );
652
653 Internal function that extracts facets information from a MARC::Record object
654 and populates $facets_counter for using in getRecords.
655
656 $facets is expected to be filled with C4::Koha::getFacets output (i.e. the configured
657 facets for Zebra).
658
659 =cut
660
661 sub _get_facets_data_from_record {
662
663     my ( $marc_record, $facets, $facets_counter ) = @_;
664
665     for my $facet (@$facets) {
666
667         my @used_datas = ();
668
669         foreach my $tag ( @{ $facet->{ tags } } ) {
670
671             # tag number is the first three digits
672             my $tag_num          = substr( $tag, 0, 3 );
673             # subfields are the remainder
674             my $subfield_letters = substr( $tag, 3 );
675
676             my @fields = $marc_record->field( $tag_num );
677             foreach my $field (@fields) {
678                 # If $field->indicator(1) eq 'z', it means it is a 'see from'
679                 # field introduced because of IncludeSeeFromInSearches, so skip it
680                 next if $field->indicator(1) eq 'z';
681
682                 my $data = $field->as_string( $subfield_letters, $facet->{ sep } );
683
684                 unless ( grep { $_ eq $data } @used_datas ) {
685                     push @used_datas, $data;
686                     $facets_counter->{ $facet->{ idx } }->{ $data }++;
687                 }
688             }
689         }
690     }
691 }
692
693 =head2 _get_facets_from_zebra
694
695     my $facets = _get_facets_from_zebra( $result_set )
696
697 Retrieves facets for a specified result set. It loops through the facets defined
698 in C4::Koha::getFacets and returns a hash with the following structure:
699
700    {  facet_idx => {
701             facet_value => count
702       },
703       ...
704    }
705
706 =cut
707
708 sub _get_facets_from_zebra {
709
710     my $rs = shift;
711
712     # save current elementSetName
713     my $elementSetName = $rs->option( 'elementSetName' );
714
715     my $facets_loop = getFacets();
716     my $facets_data  = {};
717     # loop through defined facets and fill the facets hashref
718     foreach my $facet ( @$facets_loop ) {
719
720         my $idx = $facet->{ idx };
721         my $sep = $facet->{ sep };
722         my $facet_values = _get_facet_from_result_set( $idx, $rs, $sep );
723         if ( $facet_values ) {
724             # we've actually got a result
725             $facets_data->{ $idx } = $facet_values;
726         }
727     }
728     # set elementSetName to its previous value to avoid side effects
729     $rs->option( elementSetName => $elementSetName );
730
731     return $facets_data;
732 }
733
734 =head2 _get_facet_from_result_set
735
736     my $facet_values =
737         C4::Search::_get_facet_from_result_set( $facet_idx, $result_set, $sep )
738
739 Internal function that extracts facet information for a specific index ($facet_idx) and
740 returns a hash containing facet values and count:
741
742     {
743         $facet_value => $count ,
744         ...
745     }
746
747 Warning: this function has the side effect of changing the elementSetName for the result
748 set. It is a helper function for the main loop, which takes care of backing it up for
749 restoring.
750
751 =cut
752
753 sub _get_facet_from_result_set {
754
755     my $facet_idx = shift;
756     my $rs        = shift;
757     my $sep       = shift;
758
759     my $internal_sep  = '<*>';
760     my $facetMaxCount = C4::Context->preference('FacetMaxCount') // 20;
761
762     return if ( ! defined $facet_idx || ! defined $rs );
763     # zebra's facet element, untokenized index
764     my $facet_element = 'zebra::facet::' . $facet_idx . ':0:' . $facetMaxCount;
765     # configure zebra results for retrieving the desired facet
766     $rs->option( elementSetName => $facet_element );
767     # get the facet record from result set
768     my $facet = $rs->record( 0 )->raw;
769     # if the facet has no restuls...
770     return if !defined $facet;
771     # TODO: benchmark DOM vs. SAX performance
772     my $facet_dom = XML::LibXML->load_xml(
773       string => ($facet)
774     );
775     my @terms = $facet_dom->getElementsByTagName('term');
776     return if ! @terms;
777
778     my $facets = {};
779     foreach my $term ( @terms ) {
780         my $facet_value = $term->textContent;
781         $facet_value =~ s/\Q$internal_sep\E/$sep/ if defined $sep;
782         $facets->{ $facet_value } = $term->getAttribute( 'occur' );
783     }
784
785     return $facets;
786 }
787
788 =head2 _get_facets_info
789
790     my $facets_info = C4::Search::_get_facets_info( $facets )
791
792 Internal function that extracts facets information and properly builds
793 the data structure needed to render facet labels.
794
795 =cut
796
797 sub _get_facets_info {
798
799     my $facets = shift;
800
801     my $facets_info = {};
802
803     for my $facet ( @$facets ) {
804         $facets_info->{ $facet->{ idx } }->{ label_value } = $facet->{ label };
805     }
806
807     return $facets_info;
808 }
809
810 sub pazGetRecords {
811     my (
812         $koha_query,       $simple_query, $sort_by_ref,    $servers_ref,
813         $results_per_page, $offset,       $branches,       $query_type,
814         $scan
815     ) = @_;
816     #NOTE: Parameter $branches is not used here !
817
818     my $paz = C4::Search::PazPar2->new(C4::Context->config('pazpar2url'));
819     $paz->init();
820     $paz->search($simple_query);
821     sleep 1;   # FIXME: WHY?
822
823     # do results
824     my $results_hashref = {};
825     my $stats = XMLin($paz->stat);
826     my $results = XMLin($paz->show($offset, $results_per_page, 'work-title:1'), forcearray => 1);
827
828     # for a grouped search result, the number of hits
829     # is the number of groups returned; 'bib_hits' will have
830     # the total number of bibs.
831     $results_hashref->{'biblioserver'}->{'hits'} = $results->{'merged'}->[0];
832     $results_hashref->{'biblioserver'}->{'bib_hits'} = $stats->{'hits'};
833
834     HIT: foreach my $hit (@{ $results->{'hit'} }) {
835         my $recid = $hit->{recid}->[0];
836
837         my $work_title = $hit->{'md-work-title'}->[0];
838         my $work_author;
839         if (exists $hit->{'md-work-author'}) {
840             $work_author = $hit->{'md-work-author'}->[0];
841         }
842         my $group_label = (defined $work_author) ? "$work_title / $work_author" : $work_title;
843
844         my $result_group = {};
845         $result_group->{'group_label'} = $group_label;
846         $result_group->{'group_merge_key'} = $recid;
847
848         my $count = 1;
849         if (exists $hit->{count}) {
850             $count = $hit->{count}->[0];
851         }
852         $result_group->{'group_count'} = $count;
853
854         for (my $i = 0; $i < $count; $i++) {
855             # FIXME -- may need to worry about diacritics here
856             my $rec = $paz->record($recid, $i);
857             push @{ $result_group->{'RECORDS'} }, $rec;
858         }
859
860         push @{ $results_hashref->{'biblioserver'}->{'GROUPS'} }, $result_group;
861     }
862
863     # pass through facets
864     my $termlist_xml = $paz->termlist('author,subject');
865     my $terms = XMLin($termlist_xml, forcearray => 1);
866     my @facets_loop = ();
867     #die Dumper($results);
868 #    foreach my $list (sort keys %{ $terms->{'list'} }) {
869 #        my @facets = ();
870 #        foreach my $facet (sort @{ $terms->{'list'}->{$list}->{'term'} } ) {
871 #            push @facets, {
872 #                facet_label_value => $facet->{'name'}->[0],
873 #            };
874 #        }
875 #        push @facets_loop, ( {
876 #            type_label => $list,
877 #            facets => \@facets,
878 #        } );
879 #    }
880
881     return ( undef, $results_hashref, \@facets_loop );
882 }
883
884 # TRUNCATION
885 sub _detect_truncation {
886     my ( $operand, $index ) = @_;
887     my ( @nontruncated, @righttruncated, @lefttruncated, @rightlefttruncated,
888         @regexpr );
889     $operand =~ s/^ //g;
890     my @wordlist = split( /\s/, $operand );
891     foreach my $word (@wordlist) {
892         if ( $word =~ s/^\*([^\*]+)\*$/$1/ ) {
893             push @rightlefttruncated, $word;
894         }
895         elsif ( $word =~ s/^\*([^\*]+)$/$1/ ) {
896             push @lefttruncated, $word;
897         }
898         elsif ( $word =~ s/^([^\*]+)\*$/$1/ ) {
899             push @righttruncated, $word;
900         }
901         elsif ( index( $word, "*" ) < 0 ) {
902             push @nontruncated, $word;
903         }
904         else {
905             push @regexpr, $word;
906         }
907     }
908     return (
909         \@nontruncated,       \@righttruncated, \@lefttruncated,
910         \@rightlefttruncated, \@regexpr
911     );
912 }
913
914 # STEMMING
915 sub _build_stemmed_operand {
916     my ($operand,$lang) = @_;
917     require Lingua::Stem::Snowball ;
918     my $stemmed_operand=q{};
919
920     # Stemmer needs language
921     return $operand unless $lang;
922
923     # If operand contains a digit, it is almost certainly an identifier, and should
924     # not be stemmed.  This is particularly relevant for ISBNs and ISSNs, which
925     # can contain the letter "X" - for example, _build_stemmend_operand would reduce
926     # "014100018X" to "x ", which for a MARC21 database would bring up irrelevant
927     # results (e.g., "23 x 29 cm." from the 300$c).  Bug 2098.
928     return $operand if $operand =~ /\d/;
929
930 # FIXME: the locale should be set based on the user's language and/or search choice
931     #warn "$lang";
932     # Make sure we only use the first two letters from the language code
933     $lang = lc(substr($lang, 0, 2));
934     # The language codes for the two variants of Norwegian will now be "nb" and "nn",
935     # none of which Lingua::Stem::Snowball can use, so we need to "translate" them
936     if ($lang eq 'nb' || $lang eq 'nn') {
937       $lang = 'no';
938     }
939     my $stemmer = Lingua::Stem::Snowball->new( lang => $lang,
940                                                encoding => "UTF-8" );
941
942     my @words = split( / /, $operand );
943     my @stems = $stemmer->stem(\@words);
944     for my $stem (@stems) {
945         $stemmed_operand .= "$stem";
946         $stemmed_operand .= "?"
947           unless ( $stem =~ /(and$|or$|not$)/ ) || ( length($stem) < 3 );
948         $stemmed_operand .= " ";
949     }
950     warn "STEMMED OPERAND: $stemmed_operand" if $DEBUG;
951     return $stemmed_operand;
952 }
953
954 # FIELD WEIGHTING
955 sub _build_weighted_query {
956
957 # FIELD WEIGHTING - This is largely experimental stuff. What I'm committing works
958 # pretty well but could work much better if we had a smarter query parser
959     my ( $operand, $stemmed_operand, $index ) = @_;
960     my $stemming      = C4::Context->preference("QueryStemming")     || 0;
961     my $weight_fields = C4::Context->preference("QueryWeightFields") || 0;
962     my $fuzzy_enabled = C4::Context->preference("QueryFuzzy")        || 0;
963     $operand =~ s/"/ /g;    # Bug 7518: searches with quotation marks don't work
964
965     my $weighted_query .= "(rk=(";    # Specifies that we're applying rank
966
967     # Keyword, or, no index specified
968     if ( ( $index eq 'kw' ) || ( !$index ) ) {
969         $weighted_query .=
970           "Title-cover,ext,r1=\"$operand\"";    # exact title-cover
971         $weighted_query .= " or ti,ext,r2=\"$operand\"";    # exact title
972         $weighted_query .= " or Title-cover,phr,r3=\"$operand\"";    # phrase title
973         $weighted_query .= " or ti,wrdl,r4=\"$operand\"";    # words in title
974           #$weighted_query .= " or any,ext,r4=$operand";               # exact any
975           #$weighted_query .=" or kw,wrdl,r5=\"$operand\"";            # word list any
976         $weighted_query .= " or wrdl,fuzzy,r8=\"$operand\""
977           if $fuzzy_enabled;    # add fuzzy, word list
978         $weighted_query .= " or wrdl,right-Truncation,r9=\"$stemmed_operand\""
979           if ( $stemming and $stemmed_operand )
980           ;                     # add stemming, right truncation
981         $weighted_query .= " or wrdl,r9=\"$operand\"";
982
983         # embedded sorting: 0 a-z; 1 z-a
984         # $weighted_query .= ") or (sort1,aut=1";
985     }
986
987     # Barcode searches should skip this process
988     elsif ( $index eq 'bc' ) {
989         $weighted_query .= "bc=\"$operand\"";
990     }
991
992     # Authority-number searches should skip this process
993     elsif ( $index eq 'an' ) {
994         $weighted_query .= "an=\"$operand\"";
995     }
996
997     # If the index is numeric, don't autoquote it.
998     elsif ( $index =~ /,st-numeric$/ ) {
999         $weighted_query .= " $index=$operand";
1000     }
1001
1002     # If the index already has more than one qualifier, wrap the operand
1003     # in quotes and pass it back (assumption is that the user knows what they
1004     # are doing and won't appreciate us mucking up their query
1005     elsif ( $index =~ ',' ) {
1006         $weighted_query .= " $index=\"$operand\"";
1007     }
1008
1009     #TODO: build better cases based on specific search indexes
1010     else {
1011         $weighted_query .= " $index,ext,r1=\"$operand\"";    # exact index
1012           #$weighted_query .= " or (title-sort-az=0 or $index,startswithnt,st-word,r3=$operand #)";
1013         $weighted_query .= " or $index,phr,r3=\"$operand\"";    # phrase index
1014         $weighted_query .= " or $index,wrdl,r6=\"$operand\"";    # word list index
1015         $weighted_query .= " or $index,wrdl,fuzzy,r8=\"$operand\""
1016           if $fuzzy_enabled;    # add fuzzy, word list
1017         $weighted_query .= " or $index,wrdl,rt,r9=\"$stemmed_operand\""
1018           if ( $stemming and $stemmed_operand );    # add stemming, right truncation
1019     }
1020
1021     $weighted_query .= "))";                       # close rank specification
1022     return $weighted_query;
1023 }
1024
1025 =head2 getIndexes
1026
1027 Return an array with available indexes.
1028
1029 =cut
1030
1031 sub getIndexes{
1032     my @indexes = (
1033                     # biblio indexes
1034                     'ab',
1035                     'Abstract',
1036                     'acqdate',
1037                     'allrecords',
1038                     'an',
1039                     'Any',
1040                     'at',
1041                     'arl',
1042                     'arp',
1043                     'au',
1044                     'aub',
1045                     'aud',
1046                     'audience',
1047                     'auo',
1048                     'aut',
1049                     'Author',
1050                     'Author-in-order ',
1051                     'Author-personal-bibliography',
1052                     'Authority-Number',
1053                     'authtype',
1054                     'bc',
1055                     'Bib-level',
1056                     'biblionumber',
1057                     'bio',
1058                     'biography',
1059                     'callnum',
1060                     'cfn',
1061                     'Chronological-subdivision',
1062                     'cn-bib-source',
1063                     'cn-bib-sort',
1064                     'cn-class',
1065                     'cn-item',
1066                     'cn-prefix',
1067                     'cn-suffix',
1068                     'cpn',
1069                     'Code-institution',
1070                     'Conference-name',
1071                     'Conference-name-heading',
1072                     'Conference-name-see',
1073                     'Conference-name-seealso',
1074                     'Content-type',
1075                     'Control-number',
1076                     'copydate',
1077                     'Corporate-name',
1078                     'Corporate-name-heading',
1079                     'Corporate-name-see',
1080                     'Corporate-name-seealso',
1081                     'Country-publication',
1082                     'ctype',
1083                     'curriculum',
1084                     'date-entered-on-file',
1085                     'Date-of-acquisition',
1086                     'Date-of-publication',
1087                     'Date-time-last-modified',
1088                     'Dewey-classification',
1089                     'Dissertation-information',
1090                     'diss',
1091                     'dtlm',
1092                     'EAN',
1093                     'extent',
1094                     'fic',
1095                     'fiction',
1096                     'Form-subdivision',
1097                     'format',
1098                     'Geographic-subdivision',
1099                     'he',
1100                     'Heading',
1101                     'Heading-use-main-or-added-entry',
1102                     'Heading-use-series-added-entry ',
1103                     'Heading-use-subject-added-entry',
1104                     'Host-item',
1105                     'id-other',
1106                     'ident',
1107                     'Identifier-standard',
1108                     'Illustration-code',
1109                     'Index-term-genre',
1110                     'Index-term-uncontrolled',
1111                     'Interest-age-level',
1112                     'Interest-grade-level',
1113                     'ISBN',
1114                     'isbn',
1115                     'ISSN',
1116                     'issn',
1117                     'itemtype',
1118                     'kw',
1119                     'Koha-Auth-Number',
1120                     'l-format',
1121                     'language',
1122                     'language-original',
1123                     'lc-card',
1124                     'LC-card-number',
1125                     'lcn',
1126                     'lex',
1127                     'lexile-number',
1128                     'llength',
1129                     'ln',
1130                     'ln-audio',
1131                     'ln-subtitle',
1132                     'Local-classification',
1133                     'Local-number',
1134                     'Match-heading',
1135                     'Match-heading-see-from',
1136                     'Material-type',
1137                     'mc-itemtype',
1138                     'mc-rtype',
1139                     'mus',
1140                     'name',
1141                     'Music-number',
1142                     'Name-geographic',
1143                     'Name-geographic-heading',
1144                     'Name-geographic-see',
1145                     'Name-geographic-seealso',
1146                     'nb',
1147                     'Note',
1148                     'notes',
1149                     'ns',
1150                     'nt',
1151                     'Other-control-number',
1152                     'pb',
1153                     'Personal-name',
1154                     'Personal-name-heading',
1155                     'Personal-name-see',
1156                     'Personal-name-seealso',
1157                     'pl',
1158                     'Place-publication',
1159                     'pn',
1160                     'popularity',
1161                     'pubdate',
1162                     'Publisher',
1163                     'Provider',
1164                     'pv',
1165                     'Reading-grade-level',
1166                     'Record-control-number',
1167                     'rcn',
1168                     'Record-type',
1169                     'rtype',
1170                     'se',
1171                     'See',
1172                     'See-also',
1173                     'sn',
1174                     'Stock-number',
1175                     'su',
1176                     'Subject',
1177                     'Subject-heading-thesaurus',
1178                     'Subject-name-personal',
1179                     'Subject-subdivision',
1180                     'Summary',
1181                     'Suppress',
1182                     'su-geo',
1183                     'su-na',
1184                     'su-to',
1185                     'su-ut',
1186                     'ut',
1187                     'Term-genre-form',
1188                     'Term-genre-form-heading',
1189                     'Term-genre-form-see',
1190                     'Term-genre-form-seealso',
1191                     'ti',
1192                     'Title',
1193                     'Title-cover',
1194                     'Title-series',
1195                     'Title-uniform',
1196                     'Title-uniform-heading',
1197                     'Title-uniform-see',
1198                     'Title-uniform-seealso',
1199                     'totalissues',
1200                     'yr',
1201
1202                     # items indexes
1203                     'acqsource',
1204                     'barcode',
1205                     'bc',
1206                     'branch',
1207                     'ccode',
1208                     'classification-source',
1209                     'cn-sort',
1210                     'coded-location-qualifier',
1211                     'copynumber',
1212                     'damaged',
1213                     'datelastborrowed',
1214                     'datelastseen',
1215                     'holdingbranch',
1216                     'homebranch',
1217                     'issues',
1218                     'item',
1219                     'itemnumber',
1220                     'itype',
1221                     'Local-classification',
1222                     'location',
1223                     'lost',
1224                     'materials-specified',
1225                     'mc-ccode',
1226                     'mc-itype',
1227                     'mc-loc',
1228                     'notforloan',
1229                     'Number-local-acquisition',
1230                     'onloan',
1231                     'price',
1232                     'renewals',
1233                     'replacementprice',
1234                     'replacementpricedate',
1235                     'reserves',
1236                     'restricted',
1237                     'stack',
1238                     'stocknumber',
1239                     'inv',
1240                     'uri',
1241                     'withdrawn',
1242
1243                     # subject related
1244                   );
1245
1246     return \@indexes;
1247 }
1248
1249 =head2 buildQuery
1250
1251 ( $error, $query,
1252 $simple_query, $query_cgi,
1253 $query_desc, $limit,
1254 $limit_cgi, $limit_desc,
1255 $query_type ) = buildQuery ( $operators, $operands, $indexes, $limits, $sort_by, $scan, $lang);
1256
1257 Build queries and limits in CCL, CGI, Human,
1258 handle truncation, stemming, field weighting, fuzziness, etc.
1259
1260 See verbose embedded documentation.
1261
1262
1263 =cut
1264
1265 sub buildQuery {
1266     my ( $operators, $operands, $indexes, $limits, $sort_by, $scan, $lang) = @_;
1267
1268     warn "---------\nEnter buildQuery\n---------" if $DEBUG;
1269
1270     my $query_desc;
1271
1272     # dereference
1273     my @operators = $operators ? @$operators : ();
1274     my @indexes   = $indexes   ? @$indexes   : ();
1275     my @operands  = $operands  ? @$operands  : ();
1276     my @limits    = $limits    ? @$limits    : ();
1277     my @sort_by   = $sort_by   ? @$sort_by   : ();
1278
1279     my $stemming         = C4::Context->preference("QueryStemming")        || 0;
1280     my $auto_truncation  = C4::Context->preference("QueryAutoTruncate")    || 0;
1281     my $weight_fields    = C4::Context->preference("QueryWeightFields")    || 0;
1282     my $fuzzy_enabled    = C4::Context->preference("QueryFuzzy")           || 0;
1283
1284     my $query        = $operands[0];
1285     my $simple_query = $operands[0];
1286
1287     # initialize the variables we're passing back
1288     my $query_cgi;
1289     my $query_type;
1290
1291     my $limit;
1292     my $limit_cgi;
1293     my $limit_desc;
1294
1295     my $cclq       = 0;
1296     my $cclindexes = getIndexes();
1297     if ( $query !~ /\s*(ccl=|pqf=|cql=)/ ) {
1298         while ( !$cclq && $query =~ /(?:^|\W)([\w-]+)(,[\w-]+)*[:=]/g ) {
1299             my $dx = lc($1);
1300             $cclq = grep { lc($_) eq $dx } @$cclindexes;
1301         }
1302         $query = "ccl=$query" if $cclq;
1303     }
1304
1305 # for handling ccl, cql, pqf queries in diagnostic mode, skip the rest of the steps
1306 # DIAGNOSTIC ONLY!!
1307     if ( $query =~ /^ccl=/ ) {
1308         my $q=$';
1309         # This is needed otherwise ccl= and &limit won't work together, and
1310         # this happens when selecting a subject on the opac-detail page
1311         @limits = grep {!/^$/} @limits;
1312         my $original_q = $q; # without available part
1313         unless ( grep { $_ eq 'available' } @limits ) {
1314             $q =~ s| and \( \(allrecords,AlwaysMatches=''\) and \(not-onloan-count,st-numeric >= 1\) and \(lost,st-numeric=0\) \)||;
1315             $original_q = $q;
1316         }
1317         if ( @limits ) {
1318             if ( grep { $_ eq 'available' } @limits ) {
1319                 $q .= q| and ( (allrecords,AlwaysMatches='') and (not-onloan-count,st-numeric >= 1) and (lost,st-numeric=0) )|;
1320                 @limits = grep {!/^available$/} @limits;
1321             }
1322             $q .= ' and '.join(' and ', @limits) if @limits;
1323         }
1324         return ( undef, $q, $q, "q=ccl=".uri_escape_utf8($q), $original_q, '', '', '', 'ccl' );
1325     }
1326     if ( $query =~ /^cql=/ ) {
1327         return ( undef, $', $', "q=cql=".uri_escape_utf8($'), $', '', '', '', 'cql' );
1328     }
1329     if ( $query =~ /^pqf=/ ) {
1330         $query_desc = $';
1331         $query_cgi = "q=pqf=".uri_escape_utf8($');
1332         return ( undef, $', $', $query_cgi, $query_desc, '', '', '', 'pqf' );
1333     }
1334
1335     # pass nested queries directly
1336     # FIXME: need better handling of some of these variables in this case
1337     # Nested queries aren't handled well and this implementation is flawed and causes users to be
1338     # unable to search for anything containing () commenting out, will be rewritten for 3.4.0
1339 #    if ( $query =~ /(\(|\))/ ) {
1340 #        return (
1341 #            undef,              $query, $simple_query, $query_cgi,
1342 #            $query,             $limit, $limit_cgi,    $limit_desc,
1343 #            'ccl'
1344 #        );
1345 #    }
1346
1347 # Form-based queries are non-nested and fixed depth, so we can easily modify the incoming
1348 # query operands and indexes and add stemming, truncation, field weighting, etc.
1349 # Once we do so, we'll end up with a value in $query, just like if we had an
1350 # incoming $query from the user
1351     else {
1352         $query = ""
1353           ; # clear it out so we can populate properly with field-weighted, stemmed, etc. query
1354         my $previous_operand
1355           ;    # a flag used to keep track if there was a previous query
1356                # if there was, we can apply the current operator
1357                # for every operand
1358         for ( my $i = 0 ; $i <= @operands ; $i++ ) {
1359
1360             # COMBINE OPERANDS, INDEXES AND OPERATORS
1361             if ( ($operands[$i] // '') ne '' ) {
1362                 $operands[$i]=~s/^\s+//;
1363
1364               # A flag to determine whether or not to add the index to the query
1365                 my $indexes_set;
1366
1367 # If the user is sophisticated enough to specify an index, turn off field weighting, and stemming handling
1368                 if ( $operands[$i] =~ /\w(:|=)/ || $scan ) {
1369                     $weight_fields    = 0;
1370                     $stemming         = 0;
1371                 } else {
1372                     $operands[$i] =~ s/\?/{?}/g; # need to escape question marks
1373                 }
1374                 my $operand = $operands[$i];
1375                 my $index   = $indexes[$i] || 'kw';
1376
1377                 # Add index-specific attributes
1378
1379                 #Afaik, this 'yr' condition will only ever be met in the staff client advanced search
1380                 #for "Publication date", since typing 'yr:YYYY' into the search box produces a CCL query,
1381                 #which is processed higher up in this sub. Other than that, year searches are typically
1382                 #handled as limits which are not processed her either.
1383
1384                 # Search ranges: Date of Publication, st-numeric
1385                 if ( $index =~ /(yr|st-numeric)/ ) {
1386                     #weight_fields/relevance search causes errors with date ranges
1387                     #In the case of YYYY-, it will only return records with a 'yr' of YYYY (not the range)
1388                     #In the case of YYYY-YYYY, it will return no results
1389                     $stemming = $auto_truncation = $weight_fields = $fuzzy_enabled = 0;
1390                 }
1391
1392                 # Date of Acquisition
1393                 elsif ( $index =~ /acqdate/ ) {
1394                     #stemming and auto_truncation would have zero impact since it already is YYYY-MM-DD format
1395                     #Weight_fields probably SHOULD be turned OFF, otherwise you'll get records floating to the
1396                       #top of the results just because they have lots of item records matching that date.
1397                     #Fuzzy actually only applies during _build_weighted_query, and is reset there anyway, so
1398                       #irrelevant here
1399                     $stemming = $auto_truncation = $weight_fields = $fuzzy_enabled = 0;
1400                 }
1401                 # ISBN,ISSN,Standard Number, don't need special treatment
1402                 elsif ( $index eq 'nb' || $index eq 'ns' || $index eq 'hi' ) {
1403                     (
1404                         $stemming,      $auto_truncation,
1405                         $weight_fields, $fuzzy_enabled
1406                     ) = ( 0, 0, 0, 0 );
1407
1408                     if ( $index eq 'nb' ) {
1409                         if ( C4::Context->preference("SearchWithISBNVariations") ) {
1410                             my @isbns = C4::Koha::GetVariationsOfISBN( $operand );
1411                             $operands[$i] = $operand =  '(nb=' . join(' OR nb=', @isbns) . ')';
1412                             $indexes[$i] = $index = 'kw';
1413                         }
1414                     }
1415                 }
1416
1417                 # Set default structure attribute (word list)
1418                 my $struct_attr = q{};
1419                 unless ( $indexes_set || $index =~ /,(st-|phr|ext|wrdl)/ || $index =~ /^(nb|ns)$/ ) {
1420                     $struct_attr = ",wrdl";
1421                 }
1422
1423                 # Some helpful index variants
1424                 my $index_plus       = $index . $struct_attr . ':';
1425                 my $index_plus_comma = $index . $struct_attr . ',';
1426
1427                 if ($auto_truncation){
1428                         unless ( $index =~ /,(st-|phr|ext)/ ) {
1429                                                 #FIXME only valid with LTR scripts
1430                                                 $operand=join(" ",map{
1431                                                                                         (index($_,"*")>0?"$_":"$_*")
1432                                                                                          }split (/\s+/,$operand));
1433                                                 warn $operand if $DEBUG;
1434                                         }
1435                                 }
1436
1437                 # Detect Truncation
1438                 my $truncated_operand;
1439                 my( $nontruncated, $righttruncated, $lefttruncated,
1440                     $rightlefttruncated, $regexpr
1441                 ) = _detect_truncation( $operand, $index );
1442                 warn
1443 "TRUNCATION: NON:>@$nontruncated< RIGHT:>@$righttruncated< LEFT:>@$lefttruncated< RIGHTLEFT:>@$rightlefttruncated< REGEX:>@$regexpr<"
1444                   if $DEBUG;
1445
1446                 # Apply Truncation
1447                 if (
1448                     scalar(@$righttruncated) + scalar(@$lefttruncated) +
1449                     scalar(@$rightlefttruncated) > 0 )
1450                 {
1451
1452                # Don't field weight or add the index to the query, we do it here
1453                     $indexes_set = 1;
1454                     undef $weight_fields;
1455                     my $previous_truncation_operand;
1456                     if (scalar @$nontruncated) {
1457                         $truncated_operand .= "$index_plus @$nontruncated ";
1458                         $previous_truncation_operand = 1;
1459                     }
1460                     if (scalar @$righttruncated) {
1461                         $truncated_operand .= "and " if $previous_truncation_operand;
1462                         $truncated_operand .= $index_plus_comma . "rtrn:@$righttruncated ";
1463                         $previous_truncation_operand = 1;
1464                     }
1465                     if (scalar @$lefttruncated) {
1466                         $truncated_operand .= "and " if $previous_truncation_operand;
1467                         $truncated_operand .= $index_plus_comma . "ltrn:@$lefttruncated ";
1468                         $previous_truncation_operand = 1;
1469                     }
1470                     if (scalar @$rightlefttruncated) {
1471                         $truncated_operand .= "and " if $previous_truncation_operand;
1472                         $truncated_operand .= $index_plus_comma . "rltrn:@$rightlefttruncated ";
1473                         $previous_truncation_operand = 1;
1474                     }
1475                 }
1476                 $operand = $truncated_operand if $truncated_operand;
1477                 warn "TRUNCATED OPERAND: >$truncated_operand<" if $DEBUG;
1478
1479                 # Handle Stemming
1480                 my $stemmed_operand;
1481                 $stemmed_operand = _build_stemmed_operand($operand, $lang)
1482                                                                                 if $stemming;
1483
1484                 warn "STEMMED OPERAND: >$stemmed_operand<" if $DEBUG;
1485
1486                 # Handle Field Weighting
1487                 my $weighted_operand;
1488                 if ($weight_fields) {
1489                     $weighted_operand = _build_weighted_query( $operand, $stemmed_operand, $index );
1490                     $operand = $weighted_operand;
1491                     $indexes_set = 1;
1492                 }
1493
1494                 warn "FIELD WEIGHTED OPERAND: >$weighted_operand<" if $DEBUG;
1495
1496                 ($query,$query_cgi,$query_desc,$previous_operand) = _build_initial_query({
1497                     query => $query,
1498                     query_cgi => $query_cgi,
1499                     query_desc => $query_desc,
1500                     operator => ($operators[ $i - 1 ]) ? $operators[ $i - 1 ] : '',
1501                     parsed_operand => $operand,
1502                     original_operand => $operands[$i] // '',
1503                     index => $index,
1504                     index_plus => $index_plus,
1505                     indexes_set => $indexes_set,
1506                     previous_operand => $previous_operand,
1507                 });
1508
1509             }    #/if $operands
1510         }    # /for
1511     }
1512     warn "QUERY BEFORE LIMITS: >$query<" if $DEBUG;
1513
1514     # add limits
1515     my %group_OR_limits;
1516     my $availability_limit;
1517     foreach my $this_limit (@limits) {
1518         next unless $this_limit;
1519         if ( $this_limit =~ /available/ ) {
1520 #
1521 ## 'available' is defined as (items.onloan is NULL) and (items.itemlost = 0)
1522 ## In English:
1523 ## all records not indexed in the onloan register (zebra) and all records with a value of lost equal to 0
1524             $availability_limit .=
1525 "( (allrecords,AlwaysMatches='') and (not-onloan-count,st-numeric >= 1) and (lost,st-numeric=0) )";
1526             $limit_cgi  .= "&limit=available";
1527             $limit_desc .= "";
1528         }
1529
1530         # group_OR_limits, prefixed by mc-
1531         # OR every member of the group
1532         elsif ( $this_limit =~ /mc/ ) {
1533             my ($k,$v) = split(/:/, $this_limit,2);
1534             if ( $k !~ /mc-i(tem)?type/ ) {
1535                 # in case the mc-ccode value has complicating chars like ()'s inside it we wrap in quotes
1536                 $this_limit =~ tr/"//d;
1537                 $this_limit = $k.":'".$v."'";
1538             }
1539
1540             $group_OR_limits{$k} .= " or " if $group_OR_limits{$k};
1541             $limit_desc      .= " or " if $group_OR_limits{$k};
1542             $group_OR_limits{$k} .= "$this_limit";
1543             $limit_cgi       .= "&limit=" . uri_escape_utf8($this_limit);
1544             $limit_desc      .= " $this_limit";
1545         }
1546
1547         # Regular old limits
1548         else {
1549             $limit .= " and " if $limit || $query;
1550             $limit      .= "$this_limit";
1551             $limit_cgi  .= "&limit=" . uri_escape_utf8($this_limit);
1552             if ($this_limit =~ /^branch:(.+)/) {
1553                 my $branchcode = $1;
1554                 my $library = Koha::Libraries->find( $branchcode );
1555                 if (defined $library) {
1556                     $limit_desc .= " branch:" . $library->branchname;
1557                 } else {
1558                     $limit_desc .= " $this_limit";
1559                 }
1560             } else {
1561                 $limit_desc .= " $this_limit";
1562             }
1563         }
1564     }
1565     foreach my $k (keys (%group_OR_limits)) {
1566         $limit .= " and " if ( $query || $limit );
1567         $limit .= "($group_OR_limits{$k})";
1568     }
1569     if ($availability_limit) {
1570         $limit .= " and " if ( $query || $limit );
1571         $limit .= "($availability_limit)";
1572     }
1573
1574     # Normalize the query and limit strings
1575     # This is flawed , means we can't search anything with : in it
1576     # if user wants to do ccl or cql, start the query with that
1577 #    $query =~ s/:/=/g;
1578     #NOTE: We use several several different regexps here as you can't have variable length lookback assertions
1579     $query =~ s/(?<=(ti|au|pb|su|an|kw|mc|nb|ns)):/=/g;
1580     $query =~ s/(?<=(wrdl)):/=/g;
1581     $query =~ s/(?<=(trn|phr)):/=/g;
1582     $query =~ s/(?<=(st-numeric)):/=/g;
1583     $query =~ s/(?<=(st-year)):/=/g;
1584     $query =~ s/(?<=(st-date-normalized)):/=/g;
1585
1586     # Removing warnings for later substitutions
1587     $query      //= q{};
1588     $query_desc //= q{};
1589     $query_cgi  //= q{};
1590     $limit      //= q{};
1591     $limit_desc //= q{};
1592     $limit =~ s/:/=/g;
1593     for ( $query, $query_desc, $limit, $limit_desc ) {
1594         s/  +/ /g;    # remove extra spaces
1595         s/^ //g;     # remove any beginning spaces
1596         s/ $//g;     # remove any ending spaces
1597         s/==/=/g;    # remove double == from query
1598     }
1599     $query_cgi =~ s/^&//; # remove unnecessary & from beginning of the query cgi
1600
1601     for ($query_cgi,$simple_query) {
1602         s/"//g;
1603     }
1604     # append the limit to the query
1605     $query .= " " . $limit;
1606
1607     # Warnings if DEBUG
1608     if ($DEBUG) {
1609         warn "QUERY:" . $query;
1610         warn "QUERY CGI:" . $query_cgi;
1611         warn "QUERY DESC:" . $query_desc;
1612         warn "LIMIT:" . $limit;
1613         warn "LIMIT CGI:" . $limit_cgi;
1614         warn "LIMIT DESC:" . $limit_desc;
1615         warn "---------\nLeave buildQuery\n---------";
1616     }
1617
1618     return (
1619         undef,              $query, $simple_query, $query_cgi,
1620         $query_desc,        $limit, $limit_cgi,    $limit_desc,
1621         $query_type
1622     );
1623 }
1624
1625 =head2 _build_initial_query
1626
1627   ($query, $query_cgi, $query_desc, $previous_operand) = _build_initial_query($initial_query_params);
1628
1629   Build a section of the initial query containing indexes, operators, and operands.
1630
1631 =cut
1632
1633 sub _build_initial_query {
1634     my ($params) = @_;
1635
1636     my $operator = "";
1637     if ($params->{previous_operand}){
1638         #If there is a previous operand, add a supplied operator or the default 'and'
1639         $operator = ($params->{operator}) ? " ".($params->{operator})." " : ' and ';
1640     }
1641
1642     #NOTE: indexes_set is typically set when doing truncation or field weighting
1643     my $operand = ($params->{indexes_set}) ? $params->{parsed_operand} : $params->{index_plus}.$params->{parsed_operand};
1644
1645     #e.g. "kw,wrdl:test"
1646     #e.g. " and kw,wrdl:test"
1647     $params->{query} .= $operator . $operand;
1648
1649     $params->{query_cgi} .= "&op=".uri_escape_utf8($operator) if $operator;
1650     $params->{query_cgi} .= "&idx=".uri_escape_utf8($params->{index}) if $params->{index};
1651     $params->{query_cgi} .= "&q=".uri_escape_utf8($params->{original_operand}) if $params->{original_operand};
1652
1653     #e.g. " and kw,wrdl: test"
1654     $params->{query_desc} .= $operator . ( $params->{index_plus} // q{} ) . " " . ( $params->{original_operand} // q{} );
1655
1656     $params->{previous_operand} = 1 unless $params->{previous_operand}; #If there is no previous operand, mark this as one
1657
1658     return ($params->{query}, $params->{query_cgi}, $params->{query_desc}, $params->{previous_operand});
1659 }
1660
1661 =head2 searchResults
1662
1663   my @search_results = searchResults($search_context, $searchdesc, $hits, 
1664                                      $results_per_page, $offset, $scan, 
1665                                      @marcresults);
1666
1667 Format results in a form suitable for passing to the template
1668
1669 =cut
1670
1671 # IMO this subroutine is pretty messy still -- it's responsible for
1672 # building the HTML output for the template
1673 sub searchResults {
1674     my ( $search_context, $searchdesc, $hits, $results_per_page, $offset, $scan, $marcresults, $xslt_variables ) = @_;
1675     my $dbh = C4::Context->dbh;
1676     my @newresults;
1677
1678     require C4::Items;
1679
1680     $search_context->{'interface'} = 'opac' if !$search_context->{'interface'} || $search_context->{'interface'} ne 'intranet';
1681     my ($is_opac, $hidelostitems);
1682     if ($search_context->{'interface'} eq 'opac') {
1683         $hidelostitems = C4::Context->preference('hidelostitems');
1684         $is_opac       = 1;
1685     }
1686
1687     my $record_processor = Koha::RecordProcessor->new({
1688         filters => 'ViewPolicy'
1689     });
1690
1691     #Build branchnames hash
1692     my %branches = map { $_->branchcode => $_->branchname } Koha::Libraries->search({}, { order_by => 'branchname' });
1693
1694 # FIXME - We build an authorised values hash here, using the default framework
1695 # though it is possible to have different authvals for different fws.
1696
1697     my $shelflocations =
1698       { map { $_->{authorised_value} => $_->{lib} } Koha::AuthorisedValues->get_descriptions_by_koha_field( { frameworkcode => '', kohafield => 'items.location' } ) };
1699
1700     # get notforloan authorised value list (see $shelflocations  FIXME)
1701     my $av = Koha::MarcSubfieldStructures->search({ frameworkcode => '', kohafield => 'items.notforloan', authorised_value => [ -and => {'!=' => undef }, {'!=' => ''}] });
1702     my $notforloan_authorised_value = $av->count ? $av->next->authorised_value : undef;
1703
1704     #Get itemtype hash
1705     my $itemtypes = Koha::ItemTypes->search_with_localization;
1706     my %itemtypes = map { $_->{itemtype} => $_ } @{ $itemtypes->unblessed };
1707
1708     #search item field code
1709     my ($itemtag, undef) = &GetMarcFromKohaField( "items.itemnumber" );
1710
1711     ## find column names of items related to MARC
1712     my %subfieldstosearch;
1713     my @columns = Koha::Database->new()->schema()->resultset('Item')->result_source->columns;
1714     for my $column ( @columns ) {
1715         my ( $tagfield, $tagsubfield ) =
1716           &GetMarcFromKohaField( "items." . $column );
1717         if ( defined $tagsubfield ) {
1718             $subfieldstosearch{$column} = $tagsubfield;
1719         }
1720     }
1721
1722     # handle which records to actually retrieve
1723     my $times;
1724     if ( $hits && $offset + $results_per_page <= $hits ) {
1725         $times = $offset + $results_per_page;
1726     }
1727     else {
1728         $times = $hits;  # FIXME: if $hits is undefined, why do we want to equal it?
1729     }
1730
1731     my $marcflavour = C4::Context->preference("marcflavour");
1732     # We get the biblionumber position in MARC
1733     my ($bibliotag,$bibliosubf)=GetMarcFromKohaField( 'biblio.biblionumber' );
1734
1735     # set stuff for XSLT processing here once, not later again for every record we retrieved
1736     my $xslfile;
1737     my $xslsyspref;
1738     if( $is_opac ){
1739         $xslsyspref = "OPACXSLTResultsDisplay";
1740         $xslfile = C4::Context->preference( $xslsyspref );
1741     } else {
1742         $xslsyspref = "XSLTResultsDisplay";
1743         $xslfile = C4::Context->preference( $xslsyspref ) || "default";
1744     }
1745     my $lang   = $xslfile ? C4::Languages::getlanguage()  : undef;
1746     my $sysxml = $xslfile ? C4::XSLT::get_xslt_sysprefs() : undef;
1747
1748     my $userenv = C4::Context->userenv;
1749     my $logged_in_user
1750         = ( defined $userenv and $userenv->{number} )
1751         ? Koha::Patrons->find( $userenv->{number} )
1752         : undef;
1753     my $patron_category_hide_lost_items = ($logged_in_user) ? $logged_in_user->category->hidelostitems : 0;
1754
1755     # loop through all of the records we've retrieved
1756     for ( my $i = $offset ; $i <= $times - 1 ; $i++ ) {
1757
1758         my $marcrecord;
1759         if ($scan) {
1760             # For Scan searches we built USMARC data
1761             $marcrecord = MARC::Record->new_from_usmarc( $marcresults->[$i]);
1762         } else {
1763             # Normal search, render from Zebra's output
1764             $marcrecord = new_record_from_zebra(
1765                 'biblioserver',
1766                 $marcresults->[$i]
1767             );
1768
1769             if ( ! defined $marcrecord ) {
1770                 warn "ERROR DECODING RECORD - $@: " . $marcresults->[$i];
1771                 next;
1772             }
1773         }
1774
1775         my $fw = $scan
1776              ? undef
1777              : $bibliotag < 10
1778                ? GetFrameworkCode($marcrecord->field($bibliotag)->data)
1779                : GetFrameworkCode($marcrecord->subfield($bibliotag,$bibliosubf));
1780
1781         SetUTF8Flag($marcrecord);
1782         my $oldbiblio = TransformMarcToKoha( $marcrecord, $fw );
1783         $oldbiblio->{result_number} = $i + 1;
1784
1785                 $oldbiblio->{normalized_upc}  = GetNormalizedUPC(       $marcrecord,$marcflavour);
1786                 $oldbiblio->{normalized_ean}  = GetNormalizedEAN(       $marcrecord,$marcflavour);
1787                 $oldbiblio->{normalized_oclc} = GetNormalizedOCLCNumber($marcrecord,$marcflavour);
1788                 $oldbiblio->{normalized_isbn} = GetNormalizedISBN(undef,$marcrecord,$marcflavour);
1789                 $oldbiblio->{content_identifier_exists} = 1 if ($oldbiblio->{normalized_isbn} or $oldbiblio->{normalized_oclc} or $oldbiblio->{normalized_ean} or $oldbiblio->{normalized_upc});
1790
1791                 # edition information, if any
1792         $oldbiblio->{edition} = $oldbiblio->{editionstatement};
1793
1794         my $itemtype = $oldbiblio->{itemtype} ? $itemtypes{$oldbiblio->{itemtype}} : undef;
1795         # add imageurl to itemtype if there is one
1796         $oldbiblio->{imageurl} = $itemtype ? getitemtypeimagelocation( $search_context->{'interface'}, $itemtype->{imageurl} ) : q{};
1797         # Build summary if there is one (the summary is defined in the itemtypes table)
1798         $oldbiblio->{description} = $itemtype ? $itemtype->{translated_description} : q{};
1799
1800         # FIXME: this is only used in the deprecated non-XLST opac results
1801         if ( !$xslfile && $is_opac && $itemtype && $itemtype->{summary} ) {
1802             my $summary = $itemtypes{ $oldbiblio->{itemtype} }->{summary};
1803             my @fields  = $marcrecord->fields();
1804
1805             my $newsummary;
1806             foreach my $line ( "$summary\n" =~ /(.*)\n/g ){
1807                 my $tags = {};
1808                 foreach my $tag ( $line =~ /\[(\d{3}[\w|\d])\]/ ) {
1809                     $tag =~ /(.{3})(.)/;
1810                     if($marcrecord->field($1)){
1811                         my @abc = $marcrecord->field($1)->subfield($2);
1812                         $tags->{$tag} = $#abc + 1 ;
1813                     }
1814                 }
1815
1816                 # We catch how many times to repeat this line
1817                 my $max = 0;
1818                 foreach my $tag (keys(%$tags)){
1819                     $max = $tags->{$tag} if($tags->{$tag} > $max);
1820                  }
1821
1822                 # we replace, and repeat each line
1823                 for (my $i = 0 ; $i < $max ; $i++){
1824                     my $newline = $line;
1825
1826                     foreach my $tag ( $newline =~ /\[(\d{3}[\w|\d])\]/g ) {
1827                         $tag =~ /(.{3})(.)/;
1828
1829                         if($marcrecord->field($1)){
1830                             my @repl = $marcrecord->field($1)->subfield($2);
1831                             my $subfieldvalue = $repl[$i];
1832                             $newline =~ s/\[$tag\]/$subfieldvalue/g;
1833                         }
1834                     }
1835                     $newsummary .= "$newline\n";
1836                 }
1837             }
1838
1839             $newsummary =~ s/\[(.*?)]//g;
1840             $newsummary =~ s/\n/<br\/>/g;
1841             $oldbiblio->{summary} = $newsummary;
1842         }
1843
1844         # Pull out the items fields
1845         my @fields = $marcrecord->field($itemtag);
1846         my $marcflavor = C4::Context->preference("marcflavour");
1847
1848         # adding linked items that belong to host records
1849         if ( C4::Context->preference('EasyAnalyticalRecords') ) {
1850             my $analyticsfield = '773';
1851             if ($marcflavor eq 'MARC21' || $marcflavor eq 'NORMARC') {
1852                 $analyticsfield = '773';
1853             } elsif ($marcflavor eq 'UNIMARC') {
1854                 $analyticsfield = '461';
1855             }
1856             foreach my $hostfield ( $marcrecord->field($analyticsfield)) {
1857                 my $hostbiblionumber = $hostfield->subfield("0");
1858                 my $linkeditemnumber = $hostfield->subfield("9");
1859                 if( $hostbiblionumber ) {
1860                     my $linkeditemmarc = C4::Items::GetMarcItem( $hostbiblionumber, $linkeditemnumber );
1861                     if ($linkeditemmarc) {
1862                         my $linkeditemfield = $linkeditemmarc->field($itemtag);
1863                         if ($linkeditemfield) {
1864                             push( @fields, $linkeditemfield );
1865                         }
1866                     }
1867                 }
1868             }
1869         }
1870
1871         # Setting item statuses for display
1872         my @available_items_loop;
1873         my @onloan_items_loop;
1874         my @other_items_loop;
1875
1876         my $available_items;
1877         my $onloan_items;
1878         my $other_items;
1879
1880         my $ordered_count         = 0;
1881         my $available_count       = 0;
1882         my $onloan_count          = 0;
1883         my $longoverdue_count     = 0;
1884         my $other_count           = 0;
1885         my $withdrawn_count        = 0;
1886         my $itemlost_count        = 0;
1887         my $hideatopac_count      = 0;
1888         my $itembinding_count     = 0;
1889         my $itemdamaged_count     = 0;
1890         my $item_in_transit_count = 0;
1891         my $can_place_holds       = 0;
1892         my $item_onhold_count     = 0;
1893         my $notforloan_count      = 0;
1894         my $items_count           = scalar(@fields);
1895         my $maxitems_pref = C4::Context->preference('maxItemsinSearchResults');
1896         my $maxitems = $maxitems_pref ? $maxitems_pref - 1 : 1;
1897         my @hiddenitems; # hidden itemnumbers based on OpacHiddenItems syspref
1898
1899         # loop through every item
1900         foreach my $field (@fields) {
1901             my $item;
1902
1903             # populate the items hash
1904             foreach my $code ( keys %subfieldstosearch ) {
1905                 $item->{$code} = $field->subfield( $subfieldstosearch{$code} );
1906             }
1907             $item->{description} = $itemtypes{ $item->{itype} }{translated_description} if $item->{itype};
1908
1909                 # OPAC hidden items
1910             if ($is_opac) {
1911                 # hidden because lost
1912                 if ($hidelostitems && $item->{itemlost}) {
1913                     $hideatopac_count++;
1914                     next;
1915                 }
1916                 # hidden based on OpacHiddenItems syspref
1917                 my @hi = C4::Items::GetHiddenItemnumbers({ items=> [ $item ], borcat => $search_context->{category} });
1918                 if (scalar @hi) {
1919                     push @hiddenitems, @hi;
1920                     $hideatopac_count++;
1921                     next;
1922                 }
1923             }
1924
1925             my $hbranch     = C4::Context->preference('StaffSearchResultsDisplayBranch');
1926             my $otherbranch = $hbranch eq 'homebranch' ? 'holdingbranch' : 'homebranch';
1927
1928             # set item's branch name, use HomeOrHoldingBranch syspref first, fall back to the other one
1929             if ($item->{$hbranch}) {
1930                 $item->{'branchname'} = $branches{$item->{$hbranch}};
1931             }
1932             elsif ($item->{$otherbranch}) {     # Last resort
1933                 $item->{'branchname'} = $branches{$item->{$otherbranch}};
1934             }
1935
1936             my $prefix =
1937                 ( $item->{$hbranch} ? $item->{$hbranch} . '--' : q{} )
1938               . ( $item->{location} ? $item->{location} : q{} )
1939               . ( $item->{itype}    ? $item->{itype}    : q{} )
1940               . ( $item->{itemcallnumber} ? $item->{itemcallnumber} : q{} );
1941 # For each grouping of items (onloan, available, unavailable), we build a key to store relevant info about that item
1942             if ( $item->{onloan}
1943                 and $logged_in_user
1944                 and !( $patron_category_hide_lost_items and $item->{itemlost} ) )
1945             {
1946                 $onloan_count++;
1947                 my $key = $prefix . $item->{onloan} . $item->{barcode};
1948                 $onloan_items->{$key}->{due_date} = $item->{onloan};
1949                 $onloan_items->{$key}->{count}++ if $item->{$hbranch};
1950                 $onloan_items->{$key}->{branchname}     = $item->{branchname};
1951                 $onloan_items->{$key}->{location}       = $shelflocations->{ $item->{location} };
1952                 $onloan_items->{$key}->{itemcallnumber} = $item->{itemcallnumber};
1953                 $onloan_items->{$key}->{description}    = $item->{description};
1954                 $onloan_items->{$key}->{imageurl} =
1955                   getitemtypeimagelocation( $search_context->{'interface'}, $itemtypes{ $item->{itype} }->{imageurl} );
1956
1957                 # if something's checked out and lost, mark it as 'long overdue'
1958                 if ( $item->{itemlost} ) {
1959                     $onloan_items->{$key}->{longoverdue}++;
1960                     $longoverdue_count++;
1961                 }
1962                 else {    # can place holds as long as item isn't lost
1963                     $can_place_holds = 1;
1964                 }
1965             }
1966
1967          # items not on loan, but still unavailable ( lost, withdrawn, damaged )
1968             else {
1969
1970                 my $itemtype = C4::Context->preference("item-level_itypes")? $item->{itype}: $oldbiblio->{itemtype};
1971                 $item->{notforloan} = 1 if !$item->{notforloan} &&
1972                     $itemtype && $itemtypes{ $itemtype }->{notforloan};
1973
1974                 # item is on order
1975                 if ( $item->{notforloan} < 0 ) {
1976                     $ordered_count++;
1977                 } elsif ( $item->{notforloan} > 0 ) {
1978                     $notforloan_count++;
1979                 }
1980
1981                 # is item in transit?
1982                 my $transfertwhen = '';
1983                 my ($transfertfrom, $transfertto);
1984
1985                 # is item on the reserve shelf?
1986                 my $reservestatus = '';
1987
1988                 unless ($item->{withdrawn}
1989                         || $item->{itemlost}
1990                         || $item->{damaged}
1991                         || $item->{notforloan}
1992                         || ( C4::Context->preference('MaxSearchResultsItemsPerRecordStatusCheck')
1993                         && $items_count > C4::Context->preference('MaxSearchResultsItemsPerRecordStatusCheck') ) ) {
1994
1995                     # A couple heuristics to limit how many times
1996                     # we query the database for item transfer information, sacrificing
1997                     # accuracy in some cases for speed;
1998                     #
1999                     # 1. don't query if item has one of the other statuses
2000                     # 2. don't check transit status if the bib has
2001                     #    more than 20 items
2002                     #
2003                     # FIXME: to avoid having the query the database like this, and to make
2004                     #        the in transit status count as unavailable for search limiting,
2005                     #        should map transit status to record indexed in Zebra.
2006                     #
2007                     ($transfertwhen, $transfertfrom, $transfertto) = C4::Circulation::GetTransfers($item->{itemnumber});
2008                     $reservestatus = C4::Reserves::GetReserveStatus( $item->{itemnumber} );
2009                 }
2010
2011                 # item is withdrawn, lost, damaged, not for loan, reserved or in transit
2012                 if (   $item->{withdrawn}
2013                     || $item->{itemlost}
2014                     || $item->{damaged}
2015                     || $item->{notforloan}
2016                     || $reservestatus eq 'Waiting'
2017                     || ($transfertwhen && $transfertwhen ne ''))
2018                 {
2019                     $withdrawn_count++        if $item->{withdrawn};
2020                     $itemlost_count++        if $item->{itemlost};
2021                     $itemdamaged_count++     if $item->{damaged};
2022                     $item_in_transit_count++ if $transfertwhen && $transfertwhen ne '';
2023                     $item_onhold_count++     if $reservestatus eq 'Waiting';
2024                     $item->{status} = ($item->{withdrawn}//q{}) . "-" . ($item->{itemlost}//q{}) . "-" . ($item->{damaged}//q{}) . "-" . ($item->{notforloan}//q{});
2025
2026                     # can place a hold on a item if
2027                     # not lost nor withdrawn
2028                     # not damaged unless AllowHoldsOnDamagedItems is true
2029                     # item is either for loan or on order (notforloan < 0)
2030                     $can_place_holds = 1
2031                       if (
2032                            !$item->{itemlost}
2033                         && !$item->{withdrawn}
2034                         && ( !$item->{damaged} || C4::Context->preference('AllowHoldsOnDamagedItems') )
2035                         && ( !$item->{notforloan} || $item->{notforloan} < 0 )
2036                       );
2037
2038                     $other_count++;
2039
2040                     my $key = $prefix . $item->{status};
2041                     foreach (qw(withdrawn itemlost damaged branchname itemcallnumber)) {
2042                         $other_items->{$key}->{$_} = $item->{$_};
2043                     }
2044                     $other_items->{$key}->{intransit} = ( $transfertwhen ne '' ) ? 1 : 0;
2045                     $other_items->{$key}->{onhold} = ($reservestatus) ? 1 : 0;
2046                     $other_items->{$key}->{notforloan} = GetAuthorisedValueDesc('','',$item->{notforloan},'','',$notforloan_authorised_value) if $notforloan_authorised_value and $item->{notforloan};
2047                     $other_items->{$key}->{count}++ if $item->{$hbranch};
2048                     $other_items->{$key}->{location} = $shelflocations->{ $item->{location} } if $item->{location};
2049                     $other_items->{$key}->{description} = $item->{description};
2050                     $other_items->{$key}->{imageurl} = getitemtypeimagelocation( $search_context->{'interface'}, $itemtypes{ $item->{itype}//q{} }->{imageurl} );
2051                 }
2052                 # item is available
2053                 else {
2054                     $can_place_holds = 1;
2055                     $available_count++;
2056                     $available_items->{$prefix}->{count}++ if $item->{$hbranch};
2057                     foreach (qw(branchname itemcallnumber description)) {
2058                         $available_items->{$prefix}->{$_} = $item->{$_};
2059                     }
2060                     $available_items->{$prefix}->{location} = $shelflocations->{ $item->{location} } if $item->{location};
2061                     $available_items->{$prefix}->{imageurl} = getitemtypeimagelocation( $search_context->{'interface'}, $itemtypes{ $item->{itype}//q{} }->{imageurl} );
2062                 }
2063             }
2064         }    # notforloan, item level and biblioitem level
2065
2066         # if all items are hidden, do not show the record
2067         if ($items_count > 0 && $hideatopac_count == $items_count) {
2068             next;
2069         }
2070
2071         my ( $availableitemscount, $onloanitemscount, $otheritemscount );
2072         for my $key ( sort keys %$onloan_items ) {
2073             (++$onloanitemscount > $maxitems) and last;
2074             push @onloan_items_loop, $onloan_items->{$key};
2075         }
2076         for my $key ( sort keys %$other_items ) {
2077             (++$otheritemscount > $maxitems) and last;
2078             push @other_items_loop, $other_items->{$key};
2079         }
2080         for my $key ( sort keys %$available_items ) {
2081             (++$availableitemscount > $maxitems) and last;
2082             push @available_items_loop, $available_items->{$key}
2083         }
2084
2085         # XSLT processing of some stuff
2086         # we fetched the sysprefs already before the loop through all retrieved record!
2087         if (!$scan && $xslfile) {
2088             $record_processor->options({
2089                 frameworkcode => $fw,
2090                 interface     => $search_context->{'interface'}
2091             });
2092
2093             $record_processor->process($marcrecord);
2094             $oldbiblio->{XSLTResultsRecord} = XSLTParse4Display($oldbiblio->{biblionumber}, $marcrecord, $xslsyspref, 1, \@hiddenitems, $sysxml, $xslfile, $lang, $xslt_variables);
2095         }
2096
2097         # if biblio level itypes are used and itemtype is notforloan, it can't be reserved either
2098         if (!C4::Context->preference("item-level_itypes")) {
2099             if ($itemtype && $itemtype->{notforloan}) {
2100                 $can_place_holds = 0;
2101             }
2102         }
2103         $oldbiblio->{norequests} = 1 unless $can_place_holds;
2104         $oldbiblio->{items_count}          = $items_count;
2105         $oldbiblio->{available_items_loop} = \@available_items_loop;
2106         $oldbiblio->{onloan_items_loop}    = \@onloan_items_loop;
2107         $oldbiblio->{other_items_loop}     = \@other_items_loop;
2108         $oldbiblio->{availablecount}       = $available_count;
2109         $oldbiblio->{availableplural}      = 1 if $available_count > 1;
2110         $oldbiblio->{onloancount}          = $onloan_count;
2111         $oldbiblio->{onloanplural}         = 1 if $onloan_count > 1;
2112         $oldbiblio->{othercount}           = $other_count;
2113         $oldbiblio->{otherplural}          = 1 if $other_count > 1;
2114         $oldbiblio->{withdrawncount}        = $withdrawn_count;
2115         $oldbiblio->{itemlostcount}        = $itemlost_count;
2116         $oldbiblio->{damagedcount}         = $itemdamaged_count;
2117         $oldbiblio->{intransitcount}       = $item_in_transit_count;
2118         $oldbiblio->{onholdcount}          = $item_onhold_count;
2119         $oldbiblio->{orderedcount}         = $ordered_count;
2120         $oldbiblio->{notforloancount}      = $notforloan_count;
2121
2122         if (C4::Context->preference("AlternateHoldingsField") && $items_count == 0) {
2123             my $fieldspec = C4::Context->preference("AlternateHoldingsField");
2124             my $subfields = substr $fieldspec, 3;
2125             my $holdingsep = C4::Context->preference("AlternateHoldingsSeparator") || ' ';
2126             my @alternateholdingsinfo = ();
2127             my @holdingsfields = $marcrecord->field(substr $fieldspec, 0, 3);
2128             my $alternateholdingscount = 0;
2129
2130             for my $field (@holdingsfields) {
2131                 my %holding = ( holding => '' );
2132                 my $havesubfield = 0;
2133                 for my $subfield ($field->subfields()) {
2134                     if ((index $subfields, $$subfield[0]) >= 0) {
2135                         $holding{'holding'} .= $holdingsep if (length $holding{'holding'} > 0);
2136                         $holding{'holding'} .= $$subfield[1];
2137                         $havesubfield++;
2138                     }
2139                 }
2140                 if ($havesubfield) {
2141                     push(@alternateholdingsinfo, \%holding);
2142                     $alternateholdingscount++;
2143                 }
2144             }
2145
2146             $oldbiblio->{'ALTERNATEHOLDINGS'} = \@alternateholdingsinfo;
2147             $oldbiblio->{'alternateholdings_count'} = $alternateholdingscount;
2148         }
2149
2150         $oldbiblio->{biblio_object} = Koha::Biblios->find( $oldbiblio->{biblionumber} );
2151
2152         push( @newresults, $oldbiblio );
2153     }
2154
2155     return @newresults;
2156 }
2157
2158 =head2 enabled_staff_search_views
2159
2160 %hash = enabled_staff_search_views()
2161
2162 This function returns a hash that contains three flags obtained from the system
2163 preferences, used to determine whether a particular staff search results view
2164 is enabled.
2165
2166 =over 2
2167
2168 =item C<Output arg:>
2169
2170     * $hash{can_view_MARC} is true only if the MARC view is enabled
2171     * $hash{can_view_ISBD} is true only if the ISBD view is enabled
2172     * $hash{can_view_labeledMARC} is true only if the Labeled MARC view is enabled
2173
2174 =item C<usage in the script:>
2175
2176 =back
2177
2178 $template->param ( C4::Search::enabled_staff_search_views );
2179
2180 =cut
2181
2182 sub enabled_staff_search_views
2183 {
2184         return (
2185                 can_view_MARC                   => C4::Context->preference('viewMARC'),                 # 1 if the staff search allows the MARC view
2186                 can_view_ISBD                   => C4::Context->preference('viewISBD'),                 # 1 if the staff search allows the ISBD view
2187                 can_view_labeledMARC    => C4::Context->preference('viewLabeledMARC'),  # 1 if the staff search allows the Labeled MARC view
2188         );
2189 }
2190
2191 =head2 z3950_search_args
2192
2193 $arrayref = z3950_search_args($matchpoints)
2194
2195 This function returns an array reference that contains the search parameters to be
2196 passed to the Z39.50 search script (z3950_search.pl). The array elements
2197 are hash refs whose keys are name and value, and whose values are the
2198 name of a search parameter, the value of that search parameter and the URL encoded
2199 value of that parameter.
2200
2201 The search parameter names are lccn, isbn, issn, title, author, dewey and subject.
2202
2203 The search parameter values are obtained from the bibliographic record whose
2204 data is in a hash reference in $matchpoints, as returned by Biblio::GetBiblioData().
2205
2206 If $matchpoints is a scalar, it is assumed to be an unnamed query descriptor, e.g.
2207 a general purpose search argument. In this case, the returned array contains only
2208 entry: the key is 'title' and the value is derived from $matchpoints.
2209
2210 If a search parameter value is undefined or empty, it is not included in the returned
2211 array.
2212
2213 The returned array reference may be passed directly to the template parameters.
2214
2215 =over 2
2216
2217 =item C<Output arg:>
2218
2219     * $array containing hash refs as described above
2220
2221 =item C<usage in the script:>
2222
2223 =back
2224
2225 $data = Biblio::GetBiblioData($bibno);
2226 $template->param ( MYLOOP => C4::Search::z3950_search_args($data) )
2227
2228 *OR*
2229
2230 $template->param ( MYLOOP => C4::Search::z3950_search_args($searchscalar) )
2231
2232 =cut
2233
2234 sub z3950_search_args {
2235     my $bibrec = shift;
2236
2237     my $isbn_string = ref( $bibrec ) ? $bibrec->{title} : $bibrec;
2238     my $isbn = Business::ISBN->new( $isbn_string );
2239
2240     if (defined $isbn && $isbn->is_valid)
2241     {
2242         if ( ref($bibrec) ) {
2243             $bibrec->{isbn} = $isbn_string;
2244             $bibrec->{title} = undef;
2245         } else {
2246             $bibrec = { isbn => $isbn_string };
2247         }
2248     }
2249     else {
2250         $bibrec = { title => $bibrec } if !ref $bibrec;
2251     }
2252     my $array = [];
2253     for my $field (qw/ lccn isbn issn title author dewey subject /)
2254     {
2255         push @$array, { name => $field, value => $bibrec->{$field} }
2256           if defined $bibrec->{$field};
2257     }
2258     return $array;
2259 }
2260
2261 =head2 GetDistinctValues($field);
2262
2263 C<$field> is a reference to the fields array
2264
2265 =cut
2266
2267 sub GetDistinctValues {
2268     my ($fieldname,$string)=@_;
2269     # returns a reference to a hash of references to branches...
2270     if ($fieldname=~/\./){
2271                         my ($table,$column)=split /\./, $fieldname;
2272                         my $dbh = C4::Context->dbh;
2273                         warn "select DISTINCT($column) as value, count(*) as cnt from $table group by lib order by $column " if $DEBUG;
2274                         my $sth = $dbh->prepare("select DISTINCT($column) as value, count(*) as cnt from $table ".($string?" where $column like \"$string%\"":"")."group by value order by $column ");
2275                         $sth->execute;
2276                         my $elements=$sth->fetchall_arrayref({});
2277                         return $elements;
2278    }
2279    else {
2280                 $string||= qq("");
2281                 my @servers=qw<biblioserver authorityserver>;
2282                 my (@zconns,@results);
2283         for ( my $i = 0 ; $i < @servers ; $i++ ) {
2284                 $zconns[$i] = C4::Context->Zconn( $servers[$i], 1 );
2285                         $results[$i] =
2286                       $zconns[$i]->scan(
2287                         ZOOM::Query::CCL2RPN->new( qq"$fieldname $string", $zconns[$i])
2288                       );
2289                 }
2290                 # The big moment: asynchronously retrieve results from all servers
2291                 my @elements;
2292         _ZOOM_event_loop(
2293             \@zconns,
2294             \@results,
2295             sub {
2296                 my ( $i, $size ) = @_;
2297                 for ( my $j = 0 ; $j < $size ; $j++ ) {
2298                     my %hashscan;
2299                     @hashscan{qw(value cnt)} =
2300                       $results[ $i - 1 ]->display_term($j);
2301                     push @elements, \%hashscan;
2302                 }
2303             }
2304         );
2305                 return \@elements;
2306    }
2307 }
2308
2309 =head2 _ZOOM_event_loop
2310
2311     _ZOOM_event_loop(\@zconns, \@results, sub {
2312         my ( $i, $size ) = @_;
2313         ....
2314     } );
2315
2316 Processes a ZOOM event loop and passes control to a closure for
2317 processing the results, and destroying the resultsets.
2318
2319 =cut
2320
2321 sub _ZOOM_event_loop {
2322     my ($zconns, $results, $callback) = @_;
2323     while ( ( my $i = ZOOM::event( $zconns ) ) != 0 ) {
2324         my $ev = $zconns->[ $i - 1 ]->last_event();
2325         if ( $ev == ZOOM::Event::ZEND ) {
2326             next unless $results->[ $i - 1 ];
2327             my $size = $results->[ $i - 1 ]->size();
2328             if ( $size > 0 ) {
2329                 $callback->($i, $size);
2330             }
2331         }
2332     }
2333
2334     foreach my $result (@$results) {
2335         $result->destroy();
2336     }
2337 }
2338
2339 =head2 new_record_from_zebra
2340
2341 Given raw data from a searchengine result set, return a MARC::Record object
2342
2343 This helper function is needed to take into account all the involved
2344 system preferences and configuration variables to properly create the
2345 MARC::Record object.
2346
2347 If we are using GRS-1, then the raw data we get from Zebra should be USMARC
2348 data. If we are using DOM, then it has to be MARCXML.
2349
2350 If we are using elasticsearch, it'll already be a MARC::Record and this
2351 function needs a new name.
2352
2353 =cut
2354
2355 sub new_record_from_zebra {
2356
2357     my $server   = shift;
2358     my $raw_data = shift;
2359     # Set the default indexing modes
2360     my $search_engine = C4::Context->preference("SearchEngine");
2361     if ($search_engine eq 'Elasticsearch') {
2362         return ref $raw_data eq 'MARC::Record' ? $raw_data : MARC::Record->new_from_xml( $raw_data, 'UTF-8' );
2363     }
2364     my $index_mode = ( $server eq 'biblioserver' )
2365                         ? C4::Context->config('zebra_bib_index_mode') // 'dom'
2366                         : C4::Context->config('zebra_auth_index_mode') // 'dom';
2367
2368     my $marc_record =  eval {
2369         if ( $index_mode eq 'dom' ) {
2370             MARC::Record->new_from_xml( $raw_data, 'UTF-8' );
2371         } else {
2372             MARC::Record->new_from_usmarc( $raw_data );
2373         }
2374     };
2375
2376     if ($@) {
2377         return;
2378     } else {
2379         return $marc_record;
2380     }
2381
2382 }
2383
2384 END { }    # module clean-up code here (global destructor)
2385
2386 1;
2387 __END__
2388
2389 =head1 AUTHOR
2390
2391 Koha Development Team <http://koha-community.org/>
2392
2393 =cut