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