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