Bug 26922: Regression tests
[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] = ZOOM::Query::CCL2RPN->new( $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(ZOOM::Query::CCL2RPN->new($query_to_use, $zconns[$i]));
341                 } elsif ($query_type =~ /^cql/) {
342                     $results[$i] = $zconns[$i]->search(ZOOM::Query::CQL->new($query_to_use, $zconns[$i]));
343                 } elsif ($query_type =~ /^pqf/) {
344                     $results[$i] = $zconns[$i]->search(ZOOM::Query::PQF->new($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(  ZOOM::Query::CCL2RPN->new($query_to_use, $zconns[$i]));
350             } else {
351                     $results[$i] = $zconns[$i]->search(ZOOM::Query::CCL2RPN->new($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                 $data =~ s/\s*(?<!\p{Uppercase})[.\-,;]*\s*$//;
684
685                 unless ( grep { $_ eq $data } @used_datas ) {
686                     push @used_datas, $data;
687                     $facets_counter->{ $facet->{ idx } }->{ $data }++;
688                 }
689             }
690         }
691     }
692 }
693
694 =head2 _get_facets_from_zebra
695
696     my $facets = _get_facets_from_zebra( $result_set )
697
698 Retrieves facets for a specified result set. It loops through the facets defined
699 in C4::Koha::getFacets and returns a hash with the following structure:
700
701    {  facet_idx => {
702             facet_value => count
703       },
704       ...
705    }
706
707 =cut
708
709 sub _get_facets_from_zebra {
710
711     my $rs = shift;
712
713     # save current elementSetName
714     my $elementSetName = $rs->option( 'elementSetName' );
715
716     my $facets_loop = getFacets();
717     my $facets_data  = {};
718     # loop through defined facets and fill the facets hashref
719     foreach my $facet ( @$facets_loop ) {
720
721         my $idx = $facet->{ idx };
722         my $sep = $facet->{ sep };
723         my $facet_values = _get_facet_from_result_set( $idx, $rs, $sep );
724         if ( $facet_values ) {
725             # we've actually got a result
726             $facets_data->{ $idx } = $facet_values;
727         }
728     }
729     # set elementSetName to its previous value to avoid side effects
730     $rs->option( elementSetName => $elementSetName );
731
732     return $facets_data;
733 }
734
735 =head2 _get_facet_from_result_set
736
737     my $facet_values =
738         C4::Search::_get_facet_from_result_set( $facet_idx, $result_set, $sep )
739
740 Internal function that extracts facet information for a specific index ($facet_idx) and
741 returns a hash containing facet values and count:
742
743     {
744         $facet_value => $count ,
745         ...
746     }
747
748 Warning: this function has the side effect of changing the elementSetName for the result
749 set. It is a helper function for the main loop, which takes care of backing it up for
750 restoring.
751
752 =cut
753
754 sub _get_facet_from_result_set {
755
756     my $facet_idx = shift;
757     my $rs        = shift;
758     my $sep       = shift;
759
760     my $internal_sep  = '<*>';
761     my $facetMaxCount = C4::Context->preference('FacetMaxCount') // 20;
762
763     return if ( ! defined $facet_idx || ! defined $rs );
764     # zebra's facet element, untokenized index
765     my $facet_element = 'zebra::facet::' . $facet_idx . ':0:' . $facetMaxCount;
766     # configure zebra results for retrieving the desired facet
767     $rs->option( elementSetName => $facet_element );
768     # get the facet record from result set
769     my $facet = $rs->record( 0 )->raw;
770     # if the facet has no restuls...
771     return if !defined $facet;
772     # TODO: benchmark DOM vs. SAX performance
773     my $facet_dom = XML::LibXML->load_xml(
774       string => ($facet)
775     );
776     my @terms = $facet_dom->getElementsByTagName('term');
777     return if ! @terms;
778
779     my $facets = {};
780     foreach my $term ( @terms ) {
781         my $facet_value = $term->textContent;
782         $facet_value =~ s/\s*(?<!\p{Uppercase})[.\-,;]*\s*$//;
783         $facet_value =~ s/\Q$internal_sep\E/$sep/ if defined $sep;
784         $facets->{ $facet_value } += $term->getAttribute( 'occur' );
785     }
786
787     return $facets;
788 }
789
790 =head2 _get_facets_info
791
792     my $facets_info = C4::Search::_get_facets_info( $facets )
793
794 Internal function that extracts facets information and properly builds
795 the data structure needed to render facet labels.
796
797 =cut
798
799 sub _get_facets_info {
800
801     my $facets = shift;
802
803     my $facets_info = {};
804
805     for my $facet ( @$facets ) {
806         $facets_info->{ $facet->{ idx } }->{ label_value } = $facet->{ label };
807     }
808
809     return $facets_info;
810 }
811
812 sub pazGetRecords {
813     my (
814         $koha_query,       $simple_query, $sort_by_ref,    $servers_ref,
815         $results_per_page, $offset,       $branches,       $query_type,
816         $scan
817     ) = @_;
818     #NOTE: Parameter $branches is not used here !
819
820     my $paz = C4::Search::PazPar2->new(C4::Context->config('pazpar2url'));
821     $paz->init();
822     $paz->search($simple_query);
823     sleep 1;   # FIXME: WHY?
824
825     # do results
826     my $results_hashref = {};
827     my $stats = XMLin($paz->stat);
828     my $results = XMLin($paz->show($offset, $results_per_page, 'work-title:1'), forcearray => 1);
829
830     # for a grouped search result, the number of hits
831     # is the number of groups returned; 'bib_hits' will have
832     # the total number of bibs.
833     $results_hashref->{'biblioserver'}->{'hits'} = $results->{'merged'}->[0];
834     $results_hashref->{'biblioserver'}->{'bib_hits'} = $stats->{'hits'};
835
836     HIT: foreach my $hit (@{ $results->{'hit'} }) {
837         my $recid = $hit->{recid}->[0];
838
839         my $work_title = $hit->{'md-work-title'}->[0];
840         my $work_author;
841         if (exists $hit->{'md-work-author'}) {
842             $work_author = $hit->{'md-work-author'}->[0];
843         }
844         my $group_label = (defined $work_author) ? "$work_title / $work_author" : $work_title;
845
846         my $result_group = {};
847         $result_group->{'group_label'} = $group_label;
848         $result_group->{'group_merge_key'} = $recid;
849
850         my $count = 1;
851         if (exists $hit->{count}) {
852             $count = $hit->{count}->[0];
853         }
854         $result_group->{'group_count'} = $count;
855
856         for (my $i = 0; $i < $count; $i++) {
857             # FIXME -- may need to worry about diacritics here
858             my $rec = $paz->record($recid, $i);
859             push @{ $result_group->{'RECORDS'} }, $rec;
860         }
861
862         push @{ $results_hashref->{'biblioserver'}->{'GROUPS'} }, $result_group;
863     }
864
865     # pass through facets
866     my $termlist_xml = $paz->termlist('author,subject');
867     my $terms = XMLin($termlist_xml, forcearray => 1);
868     my @facets_loop = ();
869     #die Dumper($results);
870 #    foreach my $list (sort keys %{ $terms->{'list'} }) {
871 #        my @facets = ();
872 #        foreach my $facet (sort @{ $terms->{'list'}->{$list}->{'term'} } ) {
873 #            push @facets, {
874 #                facet_label_value => $facet->{'name'}->[0],
875 #            };
876 #        }
877 #        push @facets_loop, ( {
878 #            type_label => $list,
879 #            facets => \@facets,
880 #        } );
881 #    }
882
883     return ( undef, $results_hashref, \@facets_loop );
884 }
885
886 # TRUNCATION
887 sub _detect_truncation {
888     my ( $operand, $index ) = @_;
889     my ( @nontruncated, @righttruncated, @lefttruncated, @rightlefttruncated,
890         @regexpr );
891     $operand =~ s/^ //g;
892     my @wordlist = split( /\s/, $operand );
893     foreach my $word (@wordlist) {
894         if ( $word =~ s/^\*([^\*]+)\*$/$1/ ) {
895             push @rightlefttruncated, $word;
896         }
897         elsif ( $word =~ s/^\*([^\*]+)$/$1/ ) {
898             push @lefttruncated, $word;
899         }
900         elsif ( $word =~ s/^([^\*]+)\*$/$1/ ) {
901             push @righttruncated, $word;
902         }
903         elsif ( index( $word, "*" ) < 0 ) {
904             push @nontruncated, $word;
905         }
906         else {
907             push @regexpr, $word;
908         }
909     }
910     return (
911         \@nontruncated,       \@righttruncated, \@lefttruncated,
912         \@rightlefttruncated, \@regexpr
913     );
914 }
915
916 # STEMMING
917 sub _build_stemmed_operand {
918     my ($operand,$lang) = @_;
919     require Lingua::Stem::Snowball ;
920     my $stemmed_operand=q{};
921
922     # Stemmer needs language
923     return $operand unless $lang;
924
925     # If operand contains a digit, it is almost certainly an identifier, and should
926     # not be stemmed.  This is particularly relevant for ISBNs and ISSNs, which
927     # can contain the letter "X" - for example, _build_stemmend_operand would reduce
928     # "014100018X" to "x ", which for a MARC21 database would bring up irrelevant
929     # results (e.g., "23 x 29 cm." from the 300$c).  Bug 2098.
930     return $operand if $operand =~ /\d/;
931
932 # FIXME: the locale should be set based on the user's language and/or search choice
933     #warn "$lang";
934     # Make sure we only use the first two letters from the language code
935     $lang = lc(substr($lang, 0, 2));
936     # The language codes for the two variants of Norwegian will now be "nb" and "nn",
937     # none of which Lingua::Stem::Snowball can use, so we need to "translate" them
938     if ($lang eq 'nb' || $lang eq 'nn') {
939       $lang = 'no';
940     }
941     my $stemmer = Lingua::Stem::Snowball->new( lang => $lang,
942                                                encoding => "UTF-8" );
943
944     my @words = split( / /, $operand );
945     my @stems = $stemmer->stem(\@words);
946     for my $stem (@stems) {
947         $stemmed_operand .= "$stem";
948         $stemmed_operand .= "?"
949           unless ( $stem =~ /(and$|or$|not$)/ ) || ( length($stem) < 3 );
950         $stemmed_operand .= " ";
951     }
952     warn "STEMMED OPERAND: $stemmed_operand" if $DEBUG;
953     return $stemmed_operand;
954 }
955
956 # FIELD WEIGHTING
957 sub _build_weighted_query {
958
959 # FIELD WEIGHTING - This is largely experimental stuff. What I'm committing works
960 # pretty well but could work much better if we had a smarter query parser
961     my ( $operand, $stemmed_operand, $index ) = @_;
962     my $stemming      = C4::Context->preference("QueryStemming")     || 0;
963     my $weight_fields = C4::Context->preference("QueryWeightFields") || 0;
964     my $fuzzy_enabled = C4::Context->preference("QueryFuzzy")        || 0;
965     $operand =~ s/"/ /g;    # Bug 7518: searches with quotation marks don't work
966
967     my $weighted_query .= "(rk=(";    # Specifies that we're applying rank
968
969     # Keyword, or, no index specified
970     if ( ( $index eq 'kw' ) || ( !$index ) ) {
971         $weighted_query .=
972           "Title-cover,ext,r1=\"$operand\"";    # exact title-cover
973         $weighted_query .= " or ti,ext,r2=\"$operand\"";    # exact title
974         $weighted_query .= " or Title-cover,phr,r3=\"$operand\"";    # phrase title
975         $weighted_query .= " or ti,wrdl,r4=\"$operand\"";    # words in title
976           #$weighted_query .= " or any,ext,r4=$operand";               # exact any
977           #$weighted_query .=" or kw,wrdl,r5=\"$operand\"";            # word list any
978         $weighted_query .= " or wrdl,fuzzy,r8=\"$operand\""
979           if $fuzzy_enabled;    # add fuzzy, word list
980         $weighted_query .= " or wrdl,right-Truncation,r9=\"$stemmed_operand\""
981           if ( $stemming and $stemmed_operand )
982           ;                     # add stemming, right truncation
983         $weighted_query .= " or wrdl,r9=\"$operand\"";
984
985         # embedded sorting: 0 a-z; 1 z-a
986         # $weighted_query .= ") or (sort1,aut=1";
987     }
988
989     # Barcode searches should skip this process
990     elsif ( $index eq 'bc' ) {
991         $weighted_query .= "bc=\"$operand\"";
992     }
993
994     # Authority-number searches should skip this process
995     elsif ( $index eq 'an' ) {
996         $weighted_query .= "an=\"$operand\"";
997     }
998
999     # If the index is numeric, don't autoquote it.
1000     elsif ( $index =~ /,st-numeric$/ ) {
1001         $weighted_query .= " $index=$operand";
1002     }
1003
1004     # If the index already has more than one qualifier, wrap the operand
1005     # in quotes and pass it back (assumption is that the user knows what they
1006     # are doing and won't appreciate us mucking up their query
1007     elsif ( $index =~ ',' ) {
1008         $weighted_query .= " $index=\"$operand\"";
1009     }
1010
1011     #TODO: build better cases based on specific search indexes
1012     else {
1013         $weighted_query .= " $index,ext,r1=\"$operand\"";    # exact index
1014           #$weighted_query .= " or (title-sort-az=0 or $index,startswithnt,st-word,r3=$operand #)";
1015         $weighted_query .= " or $index,phr,r3=\"$operand\"";    # phrase index
1016         $weighted_query .= " or $index,wrdl,r6=\"$operand\"";    # word list index
1017         $weighted_query .= " or $index,wrdl,fuzzy,r8=\"$operand\""
1018           if $fuzzy_enabled;    # add fuzzy, word list
1019         $weighted_query .= " or $index,wrdl,rt,r9=\"$stemmed_operand\""
1020           if ( $stemming and $stemmed_operand );    # add stemming, right truncation
1021     }
1022
1023     $weighted_query .= "))";                       # close rank specification
1024     return $weighted_query;
1025 }
1026
1027 =head2 getIndexes
1028
1029 Return an array with available indexes.
1030
1031 =cut
1032
1033 sub getIndexes{
1034     my @indexes = (
1035                     # biblio indexes
1036                     'ab',
1037                     'Abstract',
1038                     'acqdate',
1039                     'allrecords',
1040                     'an',
1041                     'Any',
1042                     'at',
1043                     'arl',
1044                     'arp',
1045                     'au',
1046                     'aub',
1047                     'aud',
1048                     'audience',
1049                     'auo',
1050                     'aut',
1051                     'Author',
1052                     'Author-in-order ',
1053                     'Author-personal-bibliography',
1054                     'Authority-Number',
1055                     'authtype',
1056                     'bc',
1057                     'Bib-level',
1058                     'biblionumber',
1059                     'bio',
1060                     'biography',
1061                     'callnum',
1062                     'cfn',
1063                     'Chronological-subdivision',
1064                     'cn-bib-source',
1065                     'cn-bib-sort',
1066                     'cn-class',
1067                     'cn-item',
1068                     'cn-prefix',
1069                     'cn-suffix',
1070                     'cpn',
1071                     'Code-institution',
1072                     'Conference-name',
1073                     'Conference-name-heading',
1074                     'Conference-name-see',
1075                     'Conference-name-seealso',
1076                     'Content-type',
1077                     'Control-number',
1078                     'copydate',
1079                     'Corporate-name',
1080                     'Corporate-name-heading',
1081                     'Corporate-name-see',
1082                     'Corporate-name-seealso',
1083                     'Country-publication',
1084                     'ctype',
1085                     'curriculum',
1086                     'date-entered-on-file',
1087                     'Date-of-acquisition',
1088                     'Date-of-publication',
1089                     'Date-time-last-modified',
1090                     'Dewey-classification',
1091                     'Dissertation-information',
1092                     'diss',
1093                     'dtlm',
1094                     'EAN',
1095                     'extent',
1096                     'fic',
1097                     'fiction',
1098                     'Form-subdivision',
1099                     'format',
1100                     'Geographic-subdivision',
1101                     'he',
1102                     'Heading',
1103                     'Heading-use-main-or-added-entry',
1104                     'Heading-use-series-added-entry ',
1105                     'Heading-use-subject-added-entry',
1106                     'Host-item',
1107                     'id-other',
1108                     'ident',
1109                     'Identifier-standard',
1110                     'Illustration-code',
1111                     'Index-term-genre',
1112                     'Index-term-uncontrolled',
1113                     'Interest-age-level',
1114                     'Interest-grade-level',
1115                     'ISBN',
1116                     'isbn',
1117                     'ISSN',
1118                     'issn',
1119                     'itemtype',
1120                     'kw',
1121                     'Koha-Auth-Number',
1122                     'l-format',
1123                     'language',
1124                     'language-original',
1125                     'lc-card',
1126                     'LC-card-number',
1127                     'lcn',
1128                     'lex',
1129                     'lexile-number',
1130                     'llength',
1131                     'ln',
1132                     'ln-audio',
1133                     'ln-subtitle',
1134                     'Local-classification',
1135                     'Local-number',
1136                     'Match-heading',
1137                     'Match-heading-see-from',
1138                     'Material-type',
1139                     'mc-itemtype',
1140                     'mc-rtype',
1141                     'mus',
1142                     'name',
1143                     'Music-number',
1144                     'Name-geographic',
1145                     'Name-geographic-heading',
1146                     'Name-geographic-see',
1147                     'Name-geographic-seealso',
1148                     'nb',
1149                     'Note',
1150                     'notes',
1151                     'ns',
1152                     'nt',
1153                     'Other-control-number',
1154                     'pb',
1155                     'Personal-name',
1156                     'Personal-name-heading',
1157                     'Personal-name-see',
1158                     'Personal-name-seealso',
1159                     'pl',
1160                     'Place-publication',
1161                     'pn',
1162                     'popularity',
1163                     'pubdate',
1164                     'Publisher',
1165                     'Provider',
1166                     'pv',
1167                     'Reading-grade-level',
1168                     'Record-control-number',
1169                     'rcn',
1170                     'Record-type',
1171                     'rtype',
1172                     'se',
1173                     'See',
1174                     'See-also',
1175                     'sn',
1176                     'Stock-number',
1177                     'su',
1178                     'Subject',
1179                     'Subject-heading-thesaurus',
1180                     'Subject-name-personal',
1181                     'Subject-subdivision',
1182                     'Summary',
1183                     'Suppress',
1184                     'su-geo',
1185                     'su-na',
1186                     'su-to',
1187                     'su-ut',
1188                     'ut',
1189                     'Term-genre-form',
1190                     'Term-genre-form-heading',
1191                     'Term-genre-form-see',
1192                     'Term-genre-form-seealso',
1193                     'ti',
1194                     'Title',
1195                     'Title-cover',
1196                     'Title-series',
1197                     'Title-uniform',
1198                     'Title-uniform-heading',
1199                     'Title-uniform-see',
1200                     'Title-uniform-seealso',
1201                     'totalissues',
1202                     'yr',
1203
1204                     # items indexes
1205                     'acqsource',
1206                     'barcode',
1207                     'bc',
1208                     'branch',
1209                     'ccode',
1210                     'classification-source',
1211                     'cn-sort',
1212                     'coded-location-qualifier',
1213                     'copynumber',
1214                     'damaged',
1215                     'datelastborrowed',
1216                     'datelastseen',
1217                     'holdingbranch',
1218                     'homebranch',
1219                     'issues',
1220                     'item',
1221                     'itemnumber',
1222                     'itype',
1223                     'Local-classification',
1224                     'location',
1225                     'lost',
1226                     'materials-specified',
1227                     'mc-ccode',
1228                     'mc-itype',
1229                     'mc-loc',
1230                     'notforloan',
1231                     'Number-local-acquisition',
1232                     'onloan',
1233                     'price',
1234                     'renewals',
1235                     'replacementprice',
1236                     'replacementpricedate',
1237                     'reserves',
1238                     'restricted',
1239                     'stack',
1240                     'stocknumber',
1241                     'inv',
1242                     'uri',
1243                     'withdrawn',
1244
1245                     # subject related
1246                   );
1247
1248     return \@indexes;
1249 }
1250
1251 =head2 buildQuery
1252
1253 ( $error, $query,
1254 $simple_query, $query_cgi,
1255 $query_desc, $limit,
1256 $limit_cgi, $limit_desc,
1257 $query_type ) = buildQuery ( $operators, $operands, $indexes, $limits, $sort_by, $scan, $lang);
1258
1259 Build queries and limits in CCL, CGI, Human,
1260 handle truncation, stemming, field weighting, fuzziness, etc.
1261
1262 See verbose embedded documentation.
1263
1264
1265 =cut
1266
1267 sub buildQuery {
1268     my ( $operators, $operands, $indexes, $limits, $sort_by, $scan, $lang) = @_;
1269     warn "---------\nEnter buildQuery\n---------" if $DEBUG;
1270
1271     my $query_desc;
1272
1273     # dereference
1274     my @operators = $operators ? @$operators : ();
1275     my @indexes   = $indexes   ? @$indexes   : ();
1276     my @operands  = $operands  ? @$operands  : ();
1277     my @limits    = $limits    ? @$limits    : ();
1278     my @sort_by   = $sort_by   ? @$sort_by   : ();
1279
1280     my $stemming         = C4::Context->preference("QueryStemming")        || 0;
1281     my $auto_truncation  = C4::Context->preference("QueryAutoTruncate")    || 0;
1282     my $weight_fields    = C4::Context->preference("QueryWeightFields")    || 0;
1283     my $fuzzy_enabled    = C4::Context->preference("QueryFuzzy")           || 0;
1284
1285     my $query        = $operands[0];
1286     my $simple_query = $operands[0];
1287
1288     # initialize the variables we're passing back
1289     my $query_cgi;
1290     my $query_type;
1291
1292     my $limit;
1293     my $limit_cgi;
1294     my $limit_desc;
1295
1296     my $cclq       = 0;
1297     my $cclindexes = getIndexes();
1298     if ( $query !~ /\s*(ccl=|pqf=|cql=)/ ) {
1299         while ( !$cclq && $query =~ /(?:^|\W)([\w-]+)(,[\w-]+)*[:=]/g ) {
1300             my $dx = lc($1);
1301             $cclq = grep { lc($_) eq $dx } @$cclindexes;
1302         }
1303         $query = "ccl=$query" if $cclq;
1304     }
1305
1306 # for handling ccl, cql, pqf queries in diagnostic mode, skip the rest of the steps
1307 # DIAGNOSTIC ONLY!!
1308     if ( $query =~ /^ccl=/ ) {
1309         my $q=$';
1310         # This is needed otherwise ccl= and &limit won't work together, and
1311         # this happens when selecting a subject on the opac-detail page
1312         @limits = grep {!/^$/} @limits;
1313         my $original_q = $q; # without available part
1314         unless ( grep { $_ eq 'available' } @limits ) {
1315             $q =~ s| and \( \(allrecords,AlwaysMatches=''\) and \(not-onloan-count,st-numeric >= 1\) and \(lost,st-numeric=0\) \)||;
1316             $original_q = $q;
1317         }
1318         if ( @limits ) {
1319             if ( grep { $_ eq 'available' } @limits ) {
1320                 $q .= q| and ( (allrecords,AlwaysMatches='') and (not-onloan-count,st-numeric >= 1) and (lost,st-numeric=0) )|;
1321                 @limits = grep {!/^available$/} @limits;
1322             }
1323             $q .= ' and '.join(' and ', @limits) if @limits;
1324         }
1325         return ( undef, $q, $q, "q=ccl=".uri_escape_utf8($q), $original_q, '', '', '', 'ccl' );
1326     }
1327     if ( $query =~ /^cql=/ ) {
1328         return ( undef, $', $', "q=cql=".uri_escape_utf8($'), $', '', '', '', 'cql' );
1329     }
1330     if ( $query =~ /^pqf=/ ) {
1331         $query_desc = $';
1332         $query_cgi = "q=pqf=".uri_escape_utf8($');
1333         return ( undef, $', $', $query_cgi, $query_desc, '', '', '', 'pqf' );
1334     }
1335
1336     # pass nested queries directly
1337     # FIXME: need better handling of some of these variables in this case
1338     # Nested queries aren't handled well and this implementation is flawed and causes users to be
1339     # unable to search for anything containing () commenting out, will be rewritten for 3.4.0
1340 #    if ( $query =~ /(\(|\))/ ) {
1341 #        return (
1342 #            undef,              $query, $simple_query, $query_cgi,
1343 #            $query,             $limit, $limit_cgi,    $limit_desc,
1344 #            'ccl'
1345 #        );
1346 #    }
1347
1348 # Form-based queries are non-nested and fixed depth, so we can easily modify the incoming
1349 # query operands and indexes and add stemming, truncation, field weighting, etc.
1350 # Once we do so, we'll end up with a value in $query, just like if we had an
1351 # incoming $query from the user
1352     else {
1353         $query = ""
1354           ; # clear it out so we can populate properly with field-weighted, stemmed, etc. query
1355         my $previous_operand
1356           ;    # a flag used to keep track if there was a previous query
1357                # if there was, we can apply the current operator
1358                # for every operand
1359         for ( my $i = 0 ; $i <= @operands ; $i++ ) {
1360
1361             # COMBINE OPERANDS, INDEXES AND OPERATORS
1362             if ( ($operands[$i] // '') ne '' ) {
1363                 $operands[$i]=~s/^\s+//;
1364
1365               # A flag to determine whether or not to add the index to the query
1366                 my $indexes_set;
1367
1368 # If the user is sophisticated enough to specify an index, turn off field weighting, and stemming handling
1369                 if ( $operands[$i] =~ /\w(:|=)/ || $scan ) {
1370                     $weight_fields    = 0;
1371                     $stemming         = 0;
1372                 } else {
1373                     $operands[$i] =~ s/\?/{?}/g; # need to escape question marks
1374                 }
1375                 my $operand = $operands[$i];
1376                 my $index   = $indexes[$i] || 'kw';
1377
1378                 # Add index-specific attributes
1379
1380                 #Afaik, this 'yr' condition will only ever be met in the staff interface advanced search
1381                 #for "Publication date", since typing 'yr:YYYY' into the search box produces a CCL query,
1382                 #which is processed higher up in this sub. Other than that, year searches are typically
1383                 #handled as limits which are not processed her either.
1384
1385                 # Search ranges: Date of Publication, st-numeric
1386                 if ( $index =~ /(yr|st-numeric)/ ) {
1387                     #weight_fields/relevance search causes errors with date ranges
1388                     #In the case of YYYY-, it will only return records with a 'yr' of YYYY (not the range)
1389                     #In the case of YYYY-YYYY, it will return no results
1390                     $stemming = $auto_truncation = $weight_fields = $fuzzy_enabled = 0;
1391                 }
1392
1393                 # Date of Acquisition
1394                 elsif ( $index =~ /acqdate/ ) {
1395                     #stemming and auto_truncation would have zero impact since it already is YYYY-MM-DD format
1396                     #Weight_fields probably SHOULD be turned OFF, otherwise you'll get records floating to the
1397                       #top of the results just because they have lots of item records matching that date.
1398                     #Fuzzy actually only applies during _build_weighted_query, and is reset there anyway, so
1399                       #irrelevant here
1400                     $stemming = $auto_truncation = $weight_fields = $fuzzy_enabled = 0;
1401                 }
1402                 # ISBN,ISSN,Standard Number, don't need special treatment
1403                 elsif ( $index eq 'nb' || $index eq 'ns' || $index eq 'hi' ) {
1404                     (
1405                         $stemming,      $auto_truncation,
1406                         $weight_fields, $fuzzy_enabled
1407                     ) = ( 0, 0, 0, 0 );
1408
1409                     if ( $index eq 'nb' ) {
1410                         if ( C4::Context->preference("SearchWithISBNVariations") ) {
1411                             my @isbns = C4::Koha::GetVariationsOfISBN( $operand );
1412                             $operands[$i] = $operand =  '(nb=' . join(' OR nb=', @isbns) . ')';
1413                             $indexes[$i] = $index = 'kw';
1414                         }
1415                     }
1416                 }
1417
1418                 # Set default structure attribute (word list)
1419                 my $struct_attr = q{};
1420                 unless ( $indexes_set || $index =~ /,(st-|phr|ext|wrdl)/ || $index =~ /^(nb|ns)$/ ) {
1421                     $struct_attr = ",wrdl";
1422                 }
1423
1424                 # Some helpful index variants
1425                 my $index_plus       = $index . $struct_attr . ':';
1426                 my $index_plus_comma = $index . $struct_attr . ',';
1427
1428                 if ($auto_truncation){
1429                         unless ( $index =~ /,(st-|phr|ext)/ ) {
1430                                                 #FIXME only valid with LTR scripts
1431                                                 $operand=join(" ",map{
1432                                                                                         (index($_,"*")>0?"$_":"$_*")
1433                                                                                          }split (/\s+/,$operand));
1434                                                 warn $operand if $DEBUG;
1435                                         }
1436                                 }
1437
1438                 # Detect Truncation
1439                 my $truncated_operand;
1440                 my( $nontruncated, $righttruncated, $lefttruncated,
1441                     $rightlefttruncated, $regexpr
1442                 ) = _detect_truncation( $operand, $index );
1443                 warn
1444 "TRUNCATION: NON:>@$nontruncated< RIGHT:>@$righttruncated< LEFT:>@$lefttruncated< RIGHTLEFT:>@$rightlefttruncated< REGEX:>@$regexpr<"
1445                   if $DEBUG;
1446
1447                 # Apply Truncation
1448                 if (
1449                     scalar(@$righttruncated) + scalar(@$lefttruncated) +
1450                     scalar(@$rightlefttruncated) > 0 )
1451                 {
1452
1453                # Don't field weight or add the index to the query, we do it here
1454                     $indexes_set = 1;
1455                     undef $weight_fields;
1456                     my $previous_truncation_operand;
1457                     if (scalar @$nontruncated) {
1458                         $truncated_operand .= "$index_plus @$nontruncated ";
1459                         $previous_truncation_operand = 1;
1460                     }
1461                     if (scalar @$righttruncated) {
1462                         $truncated_operand .= "and " if $previous_truncation_operand;
1463                         $truncated_operand .= $index_plus_comma . "rtrn:@$righttruncated ";
1464                         $previous_truncation_operand = 1;
1465                     }
1466                     if (scalar @$lefttruncated) {
1467                         $truncated_operand .= "and " if $previous_truncation_operand;
1468                         $truncated_operand .= $index_plus_comma . "ltrn:@$lefttruncated ";
1469                         $previous_truncation_operand = 1;
1470                     }
1471                     if (scalar @$rightlefttruncated) {
1472                         $truncated_operand .= "and " if $previous_truncation_operand;
1473                         $truncated_operand .= $index_plus_comma . "rltrn:@$rightlefttruncated ";
1474                         $previous_truncation_operand = 1;
1475                     }
1476                 }
1477                 $operand = $truncated_operand if $truncated_operand;
1478                 warn "TRUNCATED OPERAND: >$truncated_operand<" if $DEBUG;
1479
1480                 # Handle Stemming
1481                 my $stemmed_operand;
1482                 $stemmed_operand = _build_stemmed_operand($operand, $lang)
1483                                                                                 if $stemming;
1484
1485                 warn "STEMMED OPERAND: >$stemmed_operand<" if $DEBUG;
1486
1487                 # Handle Field Weighting
1488                 my $weighted_operand;
1489                 if ($weight_fields) {
1490                     $weighted_operand = _build_weighted_query( $operand, $stemmed_operand, $index );
1491                     $operand = $weighted_operand;
1492                     $indexes_set = 1;
1493                 }
1494
1495                 warn "FIELD WEIGHTED OPERAND: >$weighted_operand<" if $DEBUG;
1496
1497                 ($query,$query_cgi,$query_desc,$previous_operand) = _build_initial_query({
1498                     query => $query,
1499                     query_cgi => $query_cgi,
1500                     query_desc => $query_desc,
1501                     operator => ($operators[ $i - 1 ]) ? $operators[ $i - 1 ] : '',
1502                     parsed_operand => $operand,
1503                     original_operand => $operands[$i] // '',
1504                     index => $index,
1505                     index_plus => $index_plus,
1506                     indexes_set => $indexes_set,
1507                     previous_operand => $previous_operand,
1508                 });
1509
1510             }    #/if $operands
1511         }    # /for
1512     }
1513     warn "QUERY BEFORE LIMITS: >$query<" if $DEBUG;
1514
1515     # add limits
1516     my %group_OR_limits;
1517     my $availability_limit;
1518     foreach my $this_limit (@limits) {
1519         next unless $this_limit;
1520         if ( $this_limit =~ /available/ ) {
1521 #
1522 ## 'available' is defined as (items.onloan is NULL) and (items.itemlost = 0)
1523 ## In English:
1524 ## all records not indexed in the onloan register (zebra) and all records with a value of lost equal to 0
1525             $availability_limit .=
1526 "( (allrecords,AlwaysMatches='') and (not-onloan-count,st-numeric >= 1) and (lost,st-numeric=0) )";
1527             $limit_cgi  .= "&limit=available";
1528             $limit_desc .= "";
1529         }
1530
1531         # group_OR_limits, prefixed by mc-
1532         # OR every member of the group
1533         elsif ( $this_limit =~ /mc/ ) {
1534             my ($k,$v) = split(/:/, $this_limit,2);
1535             if ( $k !~ /mc-i(tem)?type/ ) {
1536                 # in case the mc-ccode value has complicating chars like ()'s inside it we wrap in quotes
1537                 $this_limit =~ tr/"//d;
1538                 $this_limit = $k.':"'.$v.'"';
1539             }
1540
1541             $group_OR_limits{$k} .= " or " if $group_OR_limits{$k};
1542             $limit_desc      .= " or " if $group_OR_limits{$k};
1543             $group_OR_limits{$k} .= "$this_limit";
1544             $limit_cgi       .= "&limit=" . uri_escape_utf8($this_limit);
1545             $limit_desc      .= " $this_limit";
1546         }
1547
1548         # Regular old limits
1549         else {
1550             $limit .= " and " if $limit || $query;
1551             $limit      .= "$this_limit";
1552             $limit_cgi  .= "&limit=" . uri_escape_utf8($this_limit);
1553             if ($this_limit =~ /^branch:(.+)/) {
1554                 my $branchcode = $1;
1555                 my $library = Koha::Libraries->find( $branchcode );
1556                 if (defined $library) {
1557                     $limit_desc .= " branch:" . $library->branchname;
1558                 } else {
1559                     $limit_desc .= " $this_limit";
1560                 }
1561             } else {
1562                 $limit_desc .= " $this_limit";
1563             }
1564         }
1565     }
1566     foreach my $k (keys (%group_OR_limits)) {
1567         $limit .= " and " if ( $query || $limit );
1568         $limit .= "($group_OR_limits{$k})";
1569     }
1570     if ($availability_limit) {
1571         $limit .= " and " if ( $query || $limit );
1572         $limit .= "($availability_limit)";
1573     }
1574
1575     # Normalize the query and limit strings
1576     # This is flawed , means we can't search anything with : in it
1577     # if user wants to do ccl or cql, start the query with that
1578 #    $query =~ s/:/=/g;
1579     #NOTE: We use several several different regexps here as you can't have variable length lookback assertions
1580     $query =~ s/(?<=(ti|au|pb|su|an|kw|mc|nb|ns)):/=/g;
1581     $query =~ s/(?<=(wrdl)):/=/g;
1582     $query =~ s/(?<=(trn|phr)):/=/g;
1583     $query =~ s/(?<=(st-numeric)):/=/g;
1584     $query =~ s/(?<=(st-year)):/=/g;
1585     $query =~ s/(?<=(st-date-normalized)):/=/g;
1586
1587     # Removing warnings for later substitutions
1588     $query      //= q{};
1589     $query_desc //= q{};
1590     $query_cgi  //= q{};
1591     $limit      //= q{};
1592     $limit_desc //= q{};
1593     $limit =~ s/:/=/g;
1594     for ( $query, $query_desc, $limit, $limit_desc ) {
1595         s/  +/ /g;    # remove extra spaces
1596         s/^ //g;     # remove any beginning spaces
1597         s/ $//g;     # remove any ending spaces
1598         s/==/=/g;    # remove double == from query
1599     }
1600     $query_cgi =~ s/^&//; # remove unnecessary & from beginning of the query cgi
1601
1602     for ($query_cgi,$simple_query) {
1603         s/"//g;
1604     }
1605     # append the limit to the query
1606     $query .= " " . $limit;
1607
1608     # Warnings if DEBUG
1609     if ($DEBUG) {
1610         warn "QUERY:" . $query;
1611         warn "QUERY CGI:" . $query_cgi;
1612         warn "QUERY DESC:" . $query_desc;
1613         warn "LIMIT:" . $limit;
1614         warn "LIMIT CGI:" . $limit_cgi;
1615         warn "LIMIT DESC:" . $limit_desc;
1616         warn "---------\nLeave buildQuery\n---------";
1617     }
1618
1619     return (
1620         undef,              $query, $simple_query, $query_cgi,
1621         $query_desc,        $limit, $limit_cgi,    $limit_desc,
1622         $query_type
1623     );
1624 }
1625
1626 =head2 _build_initial_query
1627
1628   ($query, $query_cgi, $query_desc, $previous_operand) = _build_initial_query($initial_query_params);
1629
1630   Build a section of the initial query containing indexes, operators, and operands.
1631
1632 =cut
1633
1634 sub _build_initial_query {
1635     my ($params) = @_;
1636
1637     my $operator = "";
1638     if ($params->{previous_operand}){
1639         #If there is a previous operand, add a supplied operator or the default 'and'
1640         $operator = ($params->{operator}) ? " ".($params->{operator})." " : ' and ';
1641     }
1642
1643     #NOTE: indexes_set is typically set when doing truncation or field weighting
1644     my $operand = ($params->{indexes_set}) ? $params->{parsed_operand} : $params->{index_plus}.$params->{parsed_operand};
1645
1646     #e.g. "kw,wrdl:test"
1647     #e.g. " and kw,wrdl:test"
1648     $params->{query} .= $operator . $operand;
1649
1650     $params->{query_cgi} .= "&op=".uri_escape_utf8($operator) if $operator;
1651     $params->{query_cgi} .= "&idx=".uri_escape_utf8($params->{index}) if $params->{index};
1652     $params->{query_cgi} .= "&q=".uri_escape_utf8($params->{original_operand}) if $params->{original_operand};
1653
1654     #e.g. " and kw,wrdl: test"
1655     $params->{query_desc} .= $operator . ( $params->{index_plus} // q{} ) . " " . ( $params->{original_operand} // q{} );
1656
1657     $params->{previous_operand} = 1 unless $params->{previous_operand}; #If there is no previous operand, mark this as one
1658
1659     return ($params->{query}, $params->{query_cgi}, $params->{query_desc}, $params->{previous_operand});
1660 }
1661
1662 =head2 searchResults
1663
1664   my @search_results = searchResults($search_context, $searchdesc, $hits, 
1665                                      $results_per_page, $offset, $scan, 
1666                                      @marcresults);
1667
1668 Format results in a form suitable for passing to the template
1669
1670 =cut
1671
1672 # IMO this subroutine is pretty messy still -- it's responsible for
1673 # building the HTML output for the template
1674 sub searchResults {
1675     my ( $search_context, $searchdesc, $hits, $results_per_page, $offset, $scan, $marcresults, $xslt_variables ) = @_;
1676     my $dbh = C4::Context->dbh;
1677     my @newresults;
1678
1679     require C4::Items;
1680
1681     $search_context->{'interface'} = 'opac' if !$search_context->{'interface'} || $search_context->{'interface'} ne 'intranet';
1682     my ($is_opac, $hidelostitems);
1683     if ($search_context->{'interface'} eq 'opac') {
1684         $hidelostitems = C4::Context->preference('hidelostitems');
1685         $is_opac       = 1;
1686     }
1687
1688     my $record_processor = Koha::RecordProcessor->new({
1689         filters => 'ViewPolicy'
1690     });
1691
1692     #Build branchnames hash
1693     my %branches = map { $_->branchcode => $_->branchname } Koha::Libraries->search({}, { order_by => 'branchname' });
1694
1695 # FIXME - We build an authorised values hash here, using the default framework
1696 # though it is possible to have different authvals for different fws.
1697
1698     my $shelflocations =
1699       { map { $_->{authorised_value} => $_->{lib} } Koha::AuthorisedValues->get_descriptions_by_koha_field( { frameworkcode => '', kohafield => 'items.location' } ) };
1700
1701     # get notforloan authorised value list (see $shelflocations  FIXME)
1702     my $av = Koha::MarcSubfieldStructures->search({ frameworkcode => '', kohafield => 'items.notforloan', authorised_value => [ -and => {'!=' => undef }, {'!=' => ''}] });
1703     my $notforloan_authorised_value = $av->count ? $av->next->authorised_value : undef;
1704
1705     #Get itemtype hash
1706     my $itemtypes = Koha::ItemTypes->search_with_localization;
1707     my %itemtypes = map { $_->{itemtype} => $_ } @{ $itemtypes->unblessed };
1708
1709     #search item field code
1710     my ($itemtag, undef) = &GetMarcFromKohaField( "items.itemnumber" );
1711
1712     ## find column names of items related to MARC
1713     my %subfieldstosearch;
1714     my @columns = Koha::Database->new()->schema()->resultset('Item')->result_source->columns;
1715     for my $column ( @columns ) {
1716         my ( $tagfield, $tagsubfield ) =
1717           &GetMarcFromKohaField( "items." . $column );
1718         if ( defined $tagsubfield ) {
1719             $subfieldstosearch{$column} = $tagsubfield;
1720         }
1721     }
1722
1723     # handle which records to actually retrieve
1724     my $times;
1725     if ( $hits && $offset + $results_per_page <= $hits ) {
1726         $times = $offset + $results_per_page;
1727     }
1728     else {
1729         $times = $hits;  # FIXME: if $hits is undefined, why do we want to equal it?
1730     }
1731
1732     my $marcflavour = C4::Context->preference("marcflavour");
1733     # We get the biblionumber position in MARC
1734     my ($bibliotag,$bibliosubf)=GetMarcFromKohaField( 'biblio.biblionumber' );
1735
1736     # set stuff for XSLT processing here once, not later again for every record we retrieved
1737     my $xslfile;
1738     my $xslsyspref;
1739     if( $is_opac ){
1740         $xslsyspref = "OPACXSLTResultsDisplay";
1741         $xslfile = C4::Context->preference( $xslsyspref );
1742     } else {
1743         $xslsyspref = "XSLTResultsDisplay";
1744         $xslfile = C4::Context->preference( $xslsyspref ) || "default";
1745     }
1746     my $lang   = $xslfile ? C4::Languages::getlanguage()  : undef;
1747     my $sysxml = $xslfile ? C4::XSLT::get_xslt_sysprefs() : undef;
1748
1749     my $userenv = C4::Context->userenv;
1750     my $logged_in_user
1751         = ( defined $userenv and $userenv->{number} )
1752         ? Koha::Patrons->find( $userenv->{number} )
1753         : undef;
1754     my $patron_category_hide_lost_items = ($logged_in_user) ? $logged_in_user->category->hidelostitems : 0;
1755
1756     # loop through all of the records we've retrieved
1757     for ( my $i = $offset ; $i <= $times - 1 ; $i++ ) {
1758
1759         my $marcrecord;
1760         if ($scan) {
1761             # For Scan searches we built USMARC data
1762             $marcrecord = MARC::Record->new_from_usmarc( $marcresults->[$i]);
1763         } else {
1764             # Normal search, render from Zebra's output
1765             $marcrecord = new_record_from_zebra(
1766                 'biblioserver',
1767                 $marcresults->[$i]
1768             );
1769
1770             if ( ! defined $marcrecord ) {
1771                 warn "ERROR DECODING RECORD - $@: " . $marcresults->[$i];
1772                 next;
1773             }
1774         }
1775
1776         my $fw = $scan
1777              ? undef
1778              : $bibliotag < 10
1779                ? GetFrameworkCode($marcrecord->field($bibliotag)->data)
1780                : GetFrameworkCode($marcrecord->subfield($bibliotag,$bibliosubf));
1781
1782         SetUTF8Flag($marcrecord);
1783         my $oldbiblio = TransformMarcToKoha( $marcrecord, $fw );
1784         $oldbiblio->{result_number} = $i + 1;
1785
1786                 $oldbiblio->{normalized_upc}  = GetNormalizedUPC(       $marcrecord,$marcflavour);
1787                 $oldbiblio->{normalized_ean}  = GetNormalizedEAN(       $marcrecord,$marcflavour);
1788                 $oldbiblio->{normalized_oclc} = GetNormalizedOCLCNumber($marcrecord,$marcflavour);
1789                 $oldbiblio->{normalized_isbn} = GetNormalizedISBN(undef,$marcrecord,$marcflavour);
1790                 $oldbiblio->{content_identifier_exists} = 1 if ($oldbiblio->{normalized_isbn} or $oldbiblio->{normalized_oclc} or $oldbiblio->{normalized_ean} or $oldbiblio->{normalized_upc});
1791
1792                 # edition information, if any
1793         $oldbiblio->{edition} = $oldbiblio->{editionstatement};
1794
1795         my $itemtype = $oldbiblio->{itemtype} ? $itemtypes{$oldbiblio->{itemtype}} : undef;
1796         # add imageurl to itemtype if there is one
1797         $oldbiblio->{imageurl} = $itemtype ? getitemtypeimagelocation( $search_context->{'interface'}, $itemtype->{imageurl} ) : q{};
1798         # Build summary if there is one (the summary is defined in the itemtypes table)
1799         $oldbiblio->{description} = $itemtype ? $itemtype->{translated_description} : q{};
1800
1801         # FIXME: this is only used in the deprecated non-XLST opac results
1802         if ( !$xslfile && $is_opac && $itemtype && $itemtype->{summary} ) {
1803             my $summary = $itemtypes{ $oldbiblio->{itemtype} }->{summary};
1804             my @fields  = $marcrecord->fields();
1805
1806             my $newsummary;
1807             foreach my $line ( "$summary\n" =~ /(.*)\n/g ){
1808                 my $tags = {};
1809                 foreach my $tag ( $line =~ /\[(\d{3}[\w|\d])\]/ ) {
1810                     $tag =~ /(.{3})(.)/;
1811                     if($marcrecord->field($1)){
1812                         my @abc = $marcrecord->field($1)->subfield($2);
1813                         $tags->{$tag} = $#abc + 1 ;
1814                     }
1815                 }
1816
1817                 # We catch how many times to repeat this line
1818                 my $max = 0;
1819                 foreach my $tag (keys(%$tags)){
1820                     $max = $tags->{$tag} if($tags->{$tag} > $max);
1821                  }
1822
1823                 # we replace, and repeat each line
1824                 for (my $i = 0 ; $i < $max ; $i++){
1825                     my $newline = $line;
1826
1827                     foreach my $tag ( $newline =~ /\[(\d{3}[\w|\d])\]/g ) {
1828                         $tag =~ /(.{3})(.)/;
1829
1830                         if($marcrecord->field($1)){
1831                             my @repl = $marcrecord->field($1)->subfield($2);
1832                             my $subfieldvalue = $repl[$i];
1833                             $newline =~ s/\[$tag\]/$subfieldvalue/g;
1834                         }
1835                     }
1836                     $newsummary .= "$newline\n";
1837                 }
1838             }
1839
1840             $newsummary =~ s/\[(.*?)]//g;
1841             $newsummary =~ s/\n/<br\/>/g;
1842             $oldbiblio->{summary} = $newsummary;
1843         }
1844
1845         # Pull out the items fields
1846         my @fields = $marcrecord->field($itemtag);
1847         my $marcflavor = C4::Context->preference("marcflavour");
1848
1849         # adding linked items that belong to host records
1850         if ( C4::Context->preference('EasyAnalyticalRecords') ) {
1851             my $analyticsfield = '773';
1852             if ($marcflavor eq 'MARC21' || $marcflavor eq 'NORMARC') {
1853                 $analyticsfield = '773';
1854             } elsif ($marcflavor eq 'UNIMARC') {
1855                 $analyticsfield = '461';
1856             }
1857             foreach my $hostfield ( $marcrecord->field($analyticsfield)) {
1858                 my $hostbiblionumber = $hostfield->subfield("0");
1859                 my $linkeditemnumber = $hostfield->subfield("9");
1860                 if( $hostbiblionumber ) {
1861                     my $linkeditemmarc = C4::Items::GetMarcItem( $hostbiblionumber, $linkeditemnumber );
1862                     if ($linkeditemmarc) {
1863                         my $linkeditemfield = $linkeditemmarc->field($itemtag);
1864                         if ($linkeditemfield) {
1865                             push( @fields, $linkeditemfield );
1866                         }
1867                     }
1868                 }
1869             }
1870         }
1871
1872         # Setting item statuses for display
1873         my @available_items_loop;
1874         my @onloan_items_loop;
1875         my @other_items_loop;
1876
1877         my $available_items;
1878         my $onloan_items;
1879         my $other_items;
1880
1881         my $ordered_count         = 0;
1882         my $available_count       = 0;
1883         my $onloan_count          = 0;
1884         my $longoverdue_count     = 0;
1885         my $other_count           = 0;
1886         my $withdrawn_count        = 0;
1887         my $itemlost_count        = 0;
1888         my $hideatopac_count      = 0;
1889         my $itembinding_count     = 0;
1890         my $itemdamaged_count     = 0;
1891         my $item_in_transit_count = 0;
1892         my $can_place_holds       = 0;
1893         my $item_onhold_count     = 0;
1894         my $notforloan_count      = 0;
1895         my $items_count           = scalar(@fields);
1896         my $maxitems_pref = C4::Context->preference('maxItemsinSearchResults');
1897         my $maxitems = $maxitems_pref ? $maxitems_pref - 1 : 1;
1898         my @hiddenitems; # hidden itemnumbers based on OpacHiddenItems syspref
1899
1900         # loop through every item
1901         foreach my $field (@fields) {
1902             my $item;
1903
1904             # populate the items hash
1905             foreach my $code ( keys %subfieldstosearch ) {
1906                 $item->{$code} = $field->subfield( $subfieldstosearch{$code} );
1907             }
1908             $item->{description} = $itemtypes{ $item->{itype} }{translated_description} if $item->{itype};
1909
1910                 # OPAC hidden items
1911             if ($is_opac) {
1912                 # hidden because lost
1913                 if ($hidelostitems && $item->{itemlost}) {
1914                     $hideatopac_count++;
1915                     next;
1916                 }
1917                 # hidden based on OpacHiddenItems syspref
1918                 my @hi = C4::Items::GetHiddenItemnumbers({ items=> [ $item ], borcat => $search_context->{category} });
1919                 if (scalar @hi) {
1920                     push @hiddenitems, @hi;
1921                     $hideatopac_count++;
1922                     next;
1923                 }
1924             }
1925
1926             my $hbranch     = C4::Context->preference('StaffSearchResultsDisplayBranch');
1927             my $otherbranch = $hbranch eq 'homebranch' ? 'holdingbranch' : 'homebranch';
1928
1929             # set item's branch name, use HomeOrHoldingBranch syspref first, fall back to the other one
1930             if ($item->{$hbranch}) {
1931                 $item->{'branchname'} = $branches{$item->{$hbranch}};
1932             }
1933             elsif ($item->{$otherbranch}) {     # Last resort
1934                 $item->{'branchname'} = $branches{$item->{$otherbranch}};
1935             }
1936
1937             my $prefix =
1938                 ( $item->{$hbranch} ? $item->{$hbranch} . '--' : q{} )
1939               . ( $item->{location} ? $item->{location} : q{} )
1940               . ( $item->{itype}    ? $item->{itype}    : q{} )
1941               . ( $item->{itemcallnumber} ? $item->{itemcallnumber} : q{} );
1942 # For each grouping of items (onloan, available, unavailable), we build a key to store relevant info about that item
1943             if ( $item->{onloan}
1944                 and $logged_in_user
1945                 and !( $patron_category_hide_lost_items and $item->{itemlost} ) )
1946             {
1947                 $onloan_count++;
1948                 my $key = $prefix . $item->{onloan} . $item->{barcode};
1949                 $onloan_items->{$key}->{due_date} = $item->{onloan};
1950                 $onloan_items->{$key}->{count}++ if $item->{$hbranch};
1951                 $onloan_items->{$key}->{branchname}     = $item->{branchname};
1952                 $onloan_items->{$key}->{location}       = $shelflocations->{ $item->{location} };
1953                 $onloan_items->{$key}->{itemcallnumber} = $item->{itemcallnumber};
1954                 $onloan_items->{$key}->{description}    = $item->{description};
1955                 $onloan_items->{$key}->{imageurl} =
1956                   getitemtypeimagelocation( $search_context->{'interface'}, $itemtypes{ $item->{itype} }->{imageurl} );
1957
1958                 # if something's checked out and lost, mark it as 'long overdue'
1959                 if ( $item->{itemlost} ) {
1960                     $onloan_items->{$key}->{longoverdue}++;
1961                     $longoverdue_count++;
1962                 }
1963                 else {    # can place holds as long as item isn't lost
1964                     $can_place_holds = 1;
1965                 }
1966             }
1967
1968          # items not on loan, but still unavailable ( lost, withdrawn, damaged )
1969             else {
1970
1971                 my $itemtype = C4::Context->preference("item-level_itypes")? $item->{itype}: $oldbiblio->{itemtype};
1972                 $item->{notforloan} = 1 if !$item->{notforloan} &&
1973                     $itemtype && $itemtypes{ $itemtype }->{notforloan};
1974
1975                 # item is on order
1976                 if ( $item->{notforloan} < 0 ) {
1977                     $ordered_count++;
1978                 } elsif ( $item->{notforloan} > 0 ) {
1979                     $notforloan_count++;
1980                 }
1981
1982                 # is item in transit?
1983                 my $transfertwhen = '';
1984                 my ($transfertfrom, $transfertto);
1985
1986                 # is item on the reserve shelf?
1987                 my $reservestatus = '';
1988
1989                 unless ($item->{withdrawn}
1990                         || $item->{itemlost}
1991                         || $item->{damaged}
1992                         || $item->{notforloan}
1993                         || ( C4::Context->preference('MaxSearchResultsItemsPerRecordStatusCheck')
1994                         && $items_count > C4::Context->preference('MaxSearchResultsItemsPerRecordStatusCheck') ) ) {
1995
1996                     # A couple heuristics to limit how many times
1997                     # we query the database for item transfer information, sacrificing
1998                     # accuracy in some cases for speed;
1999                     #
2000                     # 1. don't query if item has one of the other statuses
2001                     # 2. don't check transit status if the bib has
2002                     #    more than 20 items
2003                     #
2004                     # FIXME: to avoid having the query the database like this, and to make
2005                     #        the in transit status count as unavailable for search limiting,
2006                     #        should map transit status to record indexed in Zebra.
2007                     #
2008                     ($transfertwhen, $transfertfrom, $transfertto) = C4::Circulation::GetTransfers($item->{itemnumber});
2009                     $reservestatus = C4::Reserves::GetReserveStatus( $item->{itemnumber} );
2010                 }
2011
2012                 # item is withdrawn, lost, damaged, not for loan, reserved or in transit
2013                 if (   $item->{withdrawn}
2014                     || $item->{itemlost}
2015                     || $item->{damaged}
2016                     || $item->{notforloan}
2017                     || $reservestatus eq 'Waiting'
2018                     || ($transfertwhen && $transfertwhen ne ''))
2019                 {
2020                     $withdrawn_count++        if $item->{withdrawn};
2021                     $itemlost_count++        if $item->{itemlost};
2022                     $itemdamaged_count++     if $item->{damaged};
2023                     $item_in_transit_count++ if $transfertwhen && $transfertwhen ne '';
2024                     $item_onhold_count++     if $reservestatus eq 'Waiting';
2025                     $item->{status} = ($item->{withdrawn}//q{}) . "-" . ($item->{itemlost}//q{}) . "-" . ($item->{damaged}//q{}) . "-" . ($item->{notforloan}//q{});
2026
2027                     # can place a hold on a item if
2028                     # not lost nor withdrawn
2029                     # not damaged unless AllowHoldsOnDamagedItems is true
2030                     # item is either for loan or on order (notforloan < 0)
2031                     $can_place_holds = 1
2032                       if (
2033                            !$item->{itemlost}
2034                         && !$item->{withdrawn}
2035                         && ( !$item->{damaged} || C4::Context->preference('AllowHoldsOnDamagedItems') )
2036                         && ( !$item->{notforloan} || $item->{notforloan} < 0 )
2037                       );
2038
2039                     $other_count++;
2040
2041                     my $key = $prefix . $item->{status};
2042                     foreach (qw(withdrawn itemlost damaged branchname itemcallnumber)) {
2043                         $other_items->{$key}->{$_} = $item->{$_};
2044                     }
2045                     $other_items->{$key}->{intransit} = ( $transfertwhen ne '' ) ? 1 : 0;
2046                     $other_items->{$key}->{onhold} = ($reservestatus) ? 1 : 0;
2047                     $other_items->{$key}->{notforloan} = GetAuthorisedValueDesc('','',$item->{notforloan},'','',$notforloan_authorised_value) if $notforloan_authorised_value and $item->{notforloan};
2048                     $other_items->{$key}->{count}++ if $item->{$hbranch};
2049                     $other_items->{$key}->{location} = $shelflocations->{ $item->{location} } if $item->{location};
2050                     $other_items->{$key}->{description} = $item->{description};
2051                     $other_items->{$key}->{imageurl} = getitemtypeimagelocation( $search_context->{'interface'}, $itemtypes{ $item->{itype}//q{} }->{imageurl} );
2052                 }
2053                 # item is available
2054                 else {
2055                     $can_place_holds = 1;
2056                     $available_count++;
2057                     $available_items->{$prefix}->{count}++ if $item->{$hbranch};
2058                     foreach (qw(branchname itemcallnumber description)) {
2059                         $available_items->{$prefix}->{$_} = $item->{$_};
2060                     }
2061                     $available_items->{$prefix}->{location} = $shelflocations->{ $item->{location} } if $item->{location};
2062                     $available_items->{$prefix}->{imageurl} = getitemtypeimagelocation( $search_context->{'interface'}, $itemtypes{ $item->{itype}//q{} }->{imageurl} );
2063                 }
2064             }
2065         }    # notforloan, item level and biblioitem level
2066
2067         # if all items are hidden, do not show the record
2068         if ($items_count > 0 && $hideatopac_count == $items_count) {
2069             next;
2070         }
2071
2072         my ( $availableitemscount, $onloanitemscount, $otheritemscount );
2073         for my $key ( sort keys %$onloan_items ) {
2074             (++$onloanitemscount > $maxitems) and last;
2075             push @onloan_items_loop, $onloan_items->{$key};
2076         }
2077         for my $key ( sort keys %$other_items ) {
2078             (++$otheritemscount > $maxitems) and last;
2079             push @other_items_loop, $other_items->{$key};
2080         }
2081         for my $key ( sort keys %$available_items ) {
2082             (++$availableitemscount > $maxitems) and last;
2083             push @available_items_loop, $available_items->{$key}
2084         }
2085
2086         # XSLT processing of some stuff
2087         # we fetched the sysprefs already before the loop through all retrieved record!
2088         if (!$scan && $xslfile) {
2089             $record_processor->options({
2090                 frameworkcode => $fw,
2091                 interface     => $search_context->{'interface'}
2092             });
2093
2094             $record_processor->process($marcrecord);
2095             $oldbiblio->{XSLTResultsRecord} = XSLTParse4Display($oldbiblio->{biblionumber}, $marcrecord, $xslsyspref, 1, \@hiddenitems, $sysxml, $xslfile, $lang, $xslt_variables);
2096         }
2097
2098         # if biblio level itypes are used and itemtype is notforloan, it can't be reserved either
2099         if (!C4::Context->preference("item-level_itypes")) {
2100             if ($itemtype && $itemtype->{notforloan}) {
2101                 $can_place_holds = 0;
2102             }
2103         }
2104         $oldbiblio->{norequests} = 1 unless $can_place_holds;
2105         $oldbiblio->{items_count}          = $items_count;
2106         $oldbiblio->{available_items_loop} = \@available_items_loop;
2107         $oldbiblio->{onloan_items_loop}    = \@onloan_items_loop;
2108         $oldbiblio->{other_items_loop}     = \@other_items_loop;
2109         $oldbiblio->{availablecount}       = $available_count;
2110         $oldbiblio->{availableplural}      = 1 if $available_count > 1;
2111         $oldbiblio->{onloancount}          = $onloan_count;
2112         $oldbiblio->{onloanplural}         = 1 if $onloan_count > 1;
2113         $oldbiblio->{othercount}           = $other_count;
2114         $oldbiblio->{otherplural}          = 1 if $other_count > 1;
2115         $oldbiblio->{withdrawncount}        = $withdrawn_count;
2116         $oldbiblio->{itemlostcount}        = $itemlost_count;
2117         $oldbiblio->{damagedcount}         = $itemdamaged_count;
2118         $oldbiblio->{intransitcount}       = $item_in_transit_count;
2119         $oldbiblio->{onholdcount}          = $item_onhold_count;
2120         $oldbiblio->{orderedcount}         = $ordered_count;
2121         $oldbiblio->{notforloancount}      = $notforloan_count;
2122
2123         if (C4::Context->preference("AlternateHoldingsField") && $items_count == 0) {
2124             my $fieldspec = C4::Context->preference("AlternateHoldingsField");
2125             my $subfields = substr $fieldspec, 3;
2126             my $holdingsep = C4::Context->preference("AlternateHoldingsSeparator") || ' ';
2127             my @alternateholdingsinfo = ();
2128             my @holdingsfields = $marcrecord->field(substr $fieldspec, 0, 3);
2129             my $alternateholdingscount = 0;
2130
2131             for my $field (@holdingsfields) {
2132                 my %holding = ( holding => '' );
2133                 my $havesubfield = 0;
2134                 for my $subfield ($field->subfields()) {
2135                     if ((index $subfields, $$subfield[0]) >= 0) {
2136                         $holding{'holding'} .= $holdingsep if (length $holding{'holding'} > 0);
2137                         $holding{'holding'} .= $$subfield[1];
2138                         $havesubfield++;
2139                     }
2140                 }
2141                 if ($havesubfield) {
2142                     push(@alternateholdingsinfo, \%holding);
2143                     $alternateholdingscount++;
2144                 }
2145             }
2146
2147             $oldbiblio->{'ALTERNATEHOLDINGS'} = \@alternateholdingsinfo;
2148             $oldbiblio->{'alternateholdings_count'} = $alternateholdingscount;
2149         }
2150
2151         $oldbiblio->{biblio_object} = Koha::Biblios->find( $oldbiblio->{biblionumber} );
2152
2153         push( @newresults, $oldbiblio );
2154     }
2155
2156     return @newresults;
2157 }
2158
2159 =head2 enabled_staff_search_views
2160
2161 %hash = enabled_staff_search_views()
2162
2163 This function returns a hash that contains three flags obtained from the system
2164 preferences, used to determine whether a particular staff search results view
2165 is enabled.
2166
2167 =over 2
2168
2169 =item C<Output arg:>
2170
2171     * $hash{can_view_MARC} is true only if the MARC view is enabled
2172     * $hash{can_view_ISBD} is true only if the ISBD view is enabled
2173     * $hash{can_view_labeledMARC} is true only if the Labeled MARC view is enabled
2174
2175 =item C<usage in the script:>
2176
2177 =back
2178
2179 $template->param ( C4::Search::enabled_staff_search_views );
2180
2181 =cut
2182
2183 sub enabled_staff_search_views
2184 {
2185         return (
2186                 can_view_MARC                   => C4::Context->preference('viewMARC'),                 # 1 if the staff search allows the MARC view
2187                 can_view_ISBD                   => C4::Context->preference('viewISBD'),                 # 1 if the staff search allows the ISBD view
2188                 can_view_labeledMARC    => C4::Context->preference('viewLabeledMARC'),  # 1 if the staff search allows the Labeled MARC view
2189         );
2190 }
2191
2192 =head2 z3950_search_args
2193
2194 $arrayref = z3950_search_args($matchpoints)
2195
2196 This function returns an array reference that contains the search parameters to be
2197 passed to the Z39.50 search script (z3950_search.pl). The array elements
2198 are hash refs whose keys are name and value, and whose values are the
2199 name of a search parameter, the value of that search parameter and the URL encoded
2200 value of that parameter.
2201
2202 The search parameter names are lccn, isbn, issn, title, author, dewey and subject.
2203
2204 The search parameter values are obtained from the bibliographic record whose
2205 data is in a hash reference in $matchpoints, as returned by Biblio::GetBiblioData().
2206
2207 If $matchpoints is a scalar, it is assumed to be an unnamed query descriptor, e.g.
2208 a general purpose search argument. In this case, the returned array contains only
2209 entry: the key is 'title' and the value is derived from $matchpoints.
2210
2211 If a search parameter value is undefined or empty, it is not included in the returned
2212 array.
2213
2214 The returned array reference may be passed directly to the template parameters.
2215
2216 =over 2
2217
2218 =item C<Output arg:>
2219
2220     * $array containing hash refs as described above
2221
2222 =item C<usage in the script:>
2223
2224 =back
2225
2226 $data = Biblio::GetBiblioData($bibno);
2227 $template->param ( MYLOOP => C4::Search::z3950_search_args($data) )
2228
2229 *OR*
2230
2231 $template->param ( MYLOOP => C4::Search::z3950_search_args($searchscalar) )
2232
2233 =cut
2234
2235 sub z3950_search_args {
2236     my $bibrec = shift;
2237
2238     my $isbn_string = ref( $bibrec ) ? $bibrec->{title} : $bibrec;
2239     my $isbn = Business::ISBN->new( $isbn_string );
2240
2241     if (defined $isbn && $isbn->is_valid)
2242     {
2243         if ( ref($bibrec) ) {
2244             $bibrec->{isbn} = $isbn_string;
2245             $bibrec->{title} = undef;
2246         } else {
2247             $bibrec = { isbn => $isbn_string };
2248         }
2249     }
2250     else {
2251         $bibrec = { title => $bibrec } if !ref $bibrec;
2252     }
2253     my $array = [];
2254     for my $field (qw/ lccn isbn issn title author dewey subject /)
2255     {
2256         push @$array, { name => $field, value => $bibrec->{$field} }
2257           if defined $bibrec->{$field};
2258     }
2259     return $array;
2260 }
2261
2262 =head2 GetDistinctValues($field);
2263
2264 C<$field> is a reference to the fields array
2265
2266 =cut
2267
2268 sub GetDistinctValues {
2269     my ($fieldname,$string)=@_;
2270     # returns a reference to a hash of references to branches...
2271     if ($fieldname=~/\./){
2272                         my ($table,$column)=split /\./, $fieldname;
2273                         my $dbh = C4::Context->dbh;
2274                         warn "select DISTINCT($column) as value, count(*) as cnt from $table group by lib order by $column " if $DEBUG;
2275                         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 ");
2276                         $sth->execute;
2277                         my $elements=$sth->fetchall_arrayref({});
2278                         return $elements;
2279    }
2280    else {
2281                 $string||= qq("");
2282                 my @servers=qw<biblioserver authorityserver>;
2283                 my (@zconns,@results);
2284         for ( my $i = 0 ; $i < @servers ; $i++ ) {
2285                 $zconns[$i] = C4::Context->Zconn( $servers[$i], 1 );
2286                         $results[$i] =
2287                       $zconns[$i]->scan(
2288                         ZOOM::Query::CCL2RPN->new( qq"$fieldname $string", $zconns[$i])
2289                       );
2290                 }
2291                 # The big moment: asynchronously retrieve results from all servers
2292                 my @elements;
2293         _ZOOM_event_loop(
2294             \@zconns,
2295             \@results,
2296             sub {
2297                 my ( $i, $size ) = @_;
2298                 for ( my $j = 0 ; $j < $size ; $j++ ) {
2299                     my %hashscan;
2300                     @hashscan{qw(value cnt)} =
2301                       $results[ $i - 1 ]->display_term($j);
2302                     push @elements, \%hashscan;
2303                 }
2304             }
2305         );
2306                 return \@elements;
2307    }
2308 }
2309
2310 =head2 _ZOOM_event_loop
2311
2312     _ZOOM_event_loop(\@zconns, \@results, sub {
2313         my ( $i, $size ) = @_;
2314         ....
2315     } );
2316
2317 Processes a ZOOM event loop and passes control to a closure for
2318 processing the results, and destroying the resultsets.
2319
2320 =cut
2321
2322 sub _ZOOM_event_loop {
2323     my ($zconns, $results, $callback) = @_;
2324     while ( ( my $i = ZOOM::event( $zconns ) ) != 0 ) {
2325         my $ev = $zconns->[ $i - 1 ]->last_event();
2326         if ( $ev == ZOOM::Event::ZEND ) {
2327             next unless $results->[ $i - 1 ];
2328             my $size = $results->[ $i - 1 ]->size();
2329             if ( $size > 0 ) {
2330                 $callback->($i, $size);
2331             }
2332         }
2333     }
2334
2335     foreach my $result (@$results) {
2336         $result->destroy();
2337     }
2338 }
2339
2340 =head2 new_record_from_zebra
2341
2342 Given raw data from a searchengine result set, return a MARC::Record object
2343
2344 This helper function is needed to take into account all the involved
2345 system preferences and configuration variables to properly create the
2346 MARC::Record object.
2347
2348 If we are using GRS-1, then the raw data we get from Zebra should be USMARC
2349 data. If we are using DOM, then it has to be MARCXML.
2350
2351 If we are using elasticsearch, it'll already be a MARC::Record and this
2352 function needs a new name.
2353
2354 =cut
2355
2356 sub new_record_from_zebra {
2357
2358     my $server   = shift;
2359     my $raw_data = shift;
2360     # Set the default indexing modes
2361     my $search_engine = C4::Context->preference("SearchEngine");
2362     if ($search_engine eq 'Elasticsearch') {
2363         return ref $raw_data eq 'MARC::Record' ? $raw_data : MARC::Record->new_from_xml( $raw_data, 'UTF-8' );
2364     }
2365     my $index_mode = ( $server eq 'biblioserver' )
2366                         ? C4::Context->config('zebra_bib_index_mode') // 'dom'
2367                         : C4::Context->config('zebra_auth_index_mode') // 'dom';
2368
2369     my $marc_record =  eval {
2370         if ( $index_mode eq 'dom' ) {
2371             MARC::Record->new_from_xml( $raw_data, 'UTF-8' );
2372         } else {
2373             MARC::Record->new_from_usmarc( $raw_data );
2374         }
2375     };
2376
2377     if ($@) {
2378         return;
2379     } else {
2380         return $marc_record;
2381     }
2382
2383 }
2384
2385 END { }    # module clean-up code here (global destructor)
2386
2387 1;
2388 __END__
2389
2390 =head1 AUTHOR
2391
2392 Koha Development Team <http://koha-community.org/>
2393
2394 =cut