Bug 12430: Use releance ranking without QueryWeightFields
[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                 #Use relevance ranking when not using a weighted query (which adds relevance ranking of its own)
1498
1499                 #N.B. Truncation is mutually exclusive with Weighted Queries,
1500                 #so even if QueryWeightFields is turned on, QueryAutoTruncate will turn it off, thus
1501                 #the need for this relevance wrapper.
1502                 $operand = "(rk=($operand))" unless $weight_fields;
1503
1504                 ($query,$query_cgi,$query_desc,$previous_operand) = _build_initial_query({
1505                     query => $query,
1506                     query_cgi => $query_cgi,
1507                     query_desc => $query_desc,
1508                     operator => ($operators[ $i - 1 ]) ? $operators[ $i - 1 ] : '',
1509                     parsed_operand => $operand,
1510                     original_operand => $operands[$i] // '',
1511                     index => $index,
1512                     index_plus => $index_plus,
1513                     indexes_set => $indexes_set,
1514                     previous_operand => $previous_operand,
1515                 });
1516
1517             }    #/if $operands
1518         }    # /for
1519     }
1520     warn "QUERY BEFORE LIMITS: >$query<" if $DEBUG;
1521
1522     # add limits
1523     my %group_OR_limits;
1524     my $availability_limit;
1525     foreach my $this_limit (@limits) {
1526         next unless $this_limit;
1527         if ( $this_limit =~ /available/ ) {
1528 #
1529 ## 'available' is defined as (items.onloan is NULL) and (items.itemlost = 0)
1530 ## In English:
1531 ## all records not indexed in the onloan register (zebra) and all records with a value of lost equal to 0
1532             $availability_limit .=
1533 "( (allrecords,AlwaysMatches='') and (not-onloan-count,st-numeric >= 1) and (lost,st-numeric=0) )";
1534             $limit_cgi  .= "&limit=available";
1535             $limit_desc .= "";
1536         }
1537
1538         # group_OR_limits, prefixed by mc-
1539         # OR every member of the group
1540         elsif ( $this_limit =~ /mc/ ) {
1541             my ($k,$v) = split(/:/, $this_limit,2);
1542             if ( $k !~ /mc-i(tem)?type/ ) {
1543                 # in case the mc-ccode value has complicating chars like ()'s inside it we wrap in quotes
1544                 $this_limit =~ tr/"//d;
1545                 $this_limit = $k.':"'.$v.'"';
1546             }
1547
1548             $group_OR_limits{$k} .= " or " if $group_OR_limits{$k};
1549             $limit_desc      .= " or " if $group_OR_limits{$k};
1550             $group_OR_limits{$k} .= "$this_limit";
1551             $limit_cgi       .= "&limit=" . uri_escape_utf8($this_limit);
1552             $limit_desc      .= " $this_limit";
1553         }
1554
1555         # Regular old limits
1556         else {
1557             $limit .= " and " if $limit || $query;
1558             $limit      .= "$this_limit";
1559             $limit_cgi  .= "&limit=" . uri_escape_utf8($this_limit);
1560             if ($this_limit =~ /^branch:(.+)/) {
1561                 my $branchcode = $1;
1562                 my $library = Koha::Libraries->find( $branchcode );
1563                 if (defined $library) {
1564                     $limit_desc .= " branch:" . $library->branchname;
1565                 } else {
1566                     $limit_desc .= " $this_limit";
1567                 }
1568             } else {
1569                 $limit_desc .= " $this_limit";
1570             }
1571         }
1572     }
1573     foreach my $k (keys (%group_OR_limits)) {
1574         $limit .= " and " if ( $query || $limit );
1575         $limit .= "($group_OR_limits{$k})";
1576     }
1577     if ($availability_limit) {
1578         $limit .= " and " if ( $query || $limit );
1579         $limit .= "($availability_limit)";
1580     }
1581
1582     # Normalize the query and limit strings
1583     # This is flawed , means we can't search anything with : in it
1584     # if user wants to do ccl or cql, start the query with that
1585 #    $query =~ s/:/=/g;
1586     #NOTE: We use several several different regexps here as you can't have variable length lookback assertions
1587     $query =~ s/(?<=(ti|au|pb|su|an|kw|mc|nb|ns)):/=/g;
1588     $query =~ s/(?<=(wrdl)):/=/g;
1589     $query =~ s/(?<=(trn|phr)):/=/g;
1590     $query =~ s/(?<=(st-numeric)):/=/g;
1591     $query =~ s/(?<=(st-year)):/=/g;
1592     $query =~ s/(?<=(st-date-normalized)):/=/g;
1593
1594     # Removing warnings for later substitutions
1595     $query      //= q{};
1596     $query_desc //= q{};
1597     $query_cgi  //= q{};
1598     $limit      //= q{};
1599     $limit_desc //= q{};
1600     $limit =~ s/:/=/g;
1601     for ( $query, $query_desc, $limit, $limit_desc ) {
1602         s/  +/ /g;    # remove extra spaces
1603         s/^ //g;     # remove any beginning spaces
1604         s/ $//g;     # remove any ending spaces
1605         s/==/=/g;    # remove double == from query
1606     }
1607     $query_cgi =~ s/^&//; # remove unnecessary & from beginning of the query cgi
1608
1609     for ($query_cgi,$simple_query) {
1610         s/"//g;
1611     }
1612     # append the limit to the query
1613     $query .= " " . $limit;
1614
1615     # Warnings if DEBUG
1616     if ($DEBUG) {
1617         warn "QUERY:" . $query;
1618         warn "QUERY CGI:" . $query_cgi;
1619         warn "QUERY DESC:" . $query_desc;
1620         warn "LIMIT:" . $limit;
1621         warn "LIMIT CGI:" . $limit_cgi;
1622         warn "LIMIT DESC:" . $limit_desc;
1623         warn "---------\nLeave buildQuery\n---------";
1624     }
1625
1626     return (
1627         undef,              $query, $simple_query, $query_cgi,
1628         $query_desc,        $limit, $limit_cgi,    $limit_desc,
1629         $query_type
1630     );
1631 }
1632
1633 =head2 _build_initial_query
1634
1635   ($query, $query_cgi, $query_desc, $previous_operand) = _build_initial_query($initial_query_params);
1636
1637   Build a section of the initial query containing indexes, operators, and operands.
1638
1639 =cut
1640
1641 sub _build_initial_query {
1642     my ($params) = @_;
1643
1644     my $operator = "";
1645     if ($params->{previous_operand}){
1646         #If there is a previous operand, add a supplied operator or the default 'and'
1647         $operator = ($params->{operator}) ? " ".($params->{operator})." " : ' and ';
1648     }
1649
1650     #NOTE: indexes_set is typically set when doing truncation or field weighting
1651     my $operand = ($params->{indexes_set}) ? $params->{parsed_operand} : $params->{index_plus}.$params->{parsed_operand};
1652
1653     #e.g. "kw,wrdl:test"
1654     #e.g. " and kw,wrdl:test"
1655     $params->{query} .= $operator . $operand;
1656
1657     $params->{query_cgi} .= "&op=".uri_escape_utf8($operator) if $operator;
1658     $params->{query_cgi} .= "&idx=".uri_escape_utf8($params->{index}) if $params->{index};
1659     $params->{query_cgi} .= "&q=".uri_escape_utf8($params->{original_operand}) if $params->{original_operand};
1660
1661     #e.g. " and kw,wrdl: test"
1662     $params->{query_desc} .= $operator . ( $params->{index_plus} // q{} ) . " " . ( $params->{original_operand} // q{} );
1663
1664     $params->{previous_operand} = 1 unless $params->{previous_operand}; #If there is no previous operand, mark this as one
1665
1666     return ($params->{query}, $params->{query_cgi}, $params->{query_desc}, $params->{previous_operand});
1667 }
1668
1669 =head2 searchResults
1670
1671   my @search_results = searchResults($search_context, $searchdesc, $hits, 
1672                                      $results_per_page, $offset, $scan, 
1673                                      @marcresults);
1674
1675 Format results in a form suitable for passing to the template
1676
1677 =cut
1678
1679 # IMO this subroutine is pretty messy still -- it's responsible for
1680 # building the HTML output for the template
1681 sub searchResults {
1682     my ( $search_context, $searchdesc, $hits, $results_per_page, $offset, $scan, $marcresults, $xslt_variables ) = @_;
1683     my $dbh = C4::Context->dbh;
1684     my @newresults;
1685
1686     require C4::Items;
1687
1688     $search_context->{'interface'} = 'opac' if !$search_context->{'interface'} || $search_context->{'interface'} ne 'intranet';
1689     my ($is_opac, $hidelostitems);
1690     if ($search_context->{'interface'} eq 'opac') {
1691         $hidelostitems = C4::Context->preference('hidelostitems');
1692         $is_opac       = 1;
1693     }
1694
1695     my $record_processor = Koha::RecordProcessor->new({
1696         filters => 'ViewPolicy'
1697     });
1698
1699     #Build branchnames hash
1700     my %branches = map { $_->branchcode => $_->branchname } Koha::Libraries->search({}, { order_by => 'branchname' });
1701
1702 # FIXME - We build an authorised values hash here, using the default framework
1703 # though it is possible to have different authvals for different fws.
1704
1705     my $shelflocations =
1706       { map { $_->{authorised_value} => $_->{lib} } Koha::AuthorisedValues->get_descriptions_by_koha_field( { frameworkcode => '', kohafield => 'items.location' } ) };
1707
1708     # get notforloan authorised value list (see $shelflocations  FIXME)
1709     my $av = Koha::MarcSubfieldStructures->search({ frameworkcode => '', kohafield => 'items.notforloan', authorised_value => [ -and => {'!=' => undef }, {'!=' => ''}] });
1710     my $notforloan_authorised_value = $av->count ? $av->next->authorised_value : undef;
1711
1712     #Get itemtype hash
1713     my $itemtypes = Koha::ItemTypes->search_with_localization;
1714     my %itemtypes = map { $_->{itemtype} => $_ } @{ $itemtypes->unblessed };
1715
1716     #search item field code
1717     my ($itemtag, undef) = &GetMarcFromKohaField( "items.itemnumber" );
1718
1719     ## find column names of items related to MARC
1720     my %subfieldstosearch;
1721     my @columns = Koha::Database->new()->schema()->resultset('Item')->result_source->columns;
1722     for my $column ( @columns ) {
1723         my ( $tagfield, $tagsubfield ) =
1724           &GetMarcFromKohaField( "items." . $column );
1725         if ( defined $tagsubfield ) {
1726             $subfieldstosearch{$column} = $tagsubfield;
1727         }
1728     }
1729
1730     # handle which records to actually retrieve
1731     my $times;
1732     if ( $hits && $offset + $results_per_page <= $hits ) {
1733         $times = $offset + $results_per_page;
1734     }
1735     else {
1736         $times = $hits;  # FIXME: if $hits is undefined, why do we want to equal it?
1737     }
1738
1739     my $marcflavour = C4::Context->preference("marcflavour");
1740     # We get the biblionumber position in MARC
1741     my ($bibliotag,$bibliosubf)=GetMarcFromKohaField( 'biblio.biblionumber' );
1742
1743     # set stuff for XSLT processing here once, not later again for every record we retrieved
1744     my $xslfile;
1745     my $xslsyspref;
1746     if( $is_opac ){
1747         $xslsyspref = "OPACXSLTResultsDisplay";
1748         $xslfile = C4::Context->preference( $xslsyspref );
1749     } else {
1750         $xslsyspref = "XSLTResultsDisplay";
1751         $xslfile = C4::Context->preference( $xslsyspref ) || "default";
1752     }
1753     my $lang   = $xslfile ? C4::Languages::getlanguage()  : undef;
1754     my $sysxml = $xslfile ? C4::XSLT::get_xslt_sysprefs() : undef;
1755
1756     my $userenv = C4::Context->userenv;
1757     my $logged_in_user
1758         = ( defined $userenv and $userenv->{number} )
1759         ? Koha::Patrons->find( $userenv->{number} )
1760         : undef;
1761     my $patron_category_hide_lost_items = ($logged_in_user) ? $logged_in_user->category->hidelostitems : 0;
1762
1763     # loop through all of the records we've retrieved
1764     for ( my $i = $offset ; $i <= $times - 1 ; $i++ ) {
1765
1766         my $marcrecord;
1767         if ($scan) {
1768             # For Scan searches we built USMARC data
1769             $marcrecord = MARC::Record->new_from_usmarc( $marcresults->[$i]);
1770         } else {
1771             # Normal search, render from Zebra's output
1772             $marcrecord = new_record_from_zebra(
1773                 'biblioserver',
1774                 $marcresults->[$i]
1775             );
1776
1777             if ( ! defined $marcrecord ) {
1778                 warn "ERROR DECODING RECORD - $@: " . $marcresults->[$i];
1779                 next;
1780             }
1781         }
1782
1783         my $fw = $scan
1784              ? undef
1785              : $bibliotag < 10
1786                ? GetFrameworkCode($marcrecord->field($bibliotag)->data)
1787                : GetFrameworkCode($marcrecord->subfield($bibliotag,$bibliosubf));
1788
1789         SetUTF8Flag($marcrecord);
1790         my $oldbiblio = TransformMarcToKoha( $marcrecord, $fw );
1791         $oldbiblio->{result_number} = $i + 1;
1792
1793                 $oldbiblio->{normalized_upc}  = GetNormalizedUPC(       $marcrecord,$marcflavour);
1794                 $oldbiblio->{normalized_ean}  = GetNormalizedEAN(       $marcrecord,$marcflavour);
1795                 $oldbiblio->{normalized_oclc} = GetNormalizedOCLCNumber($marcrecord,$marcflavour);
1796                 $oldbiblio->{normalized_isbn} = GetNormalizedISBN(undef,$marcrecord,$marcflavour);
1797                 $oldbiblio->{content_identifier_exists} = 1 if ($oldbiblio->{normalized_isbn} or $oldbiblio->{normalized_oclc} or $oldbiblio->{normalized_ean} or $oldbiblio->{normalized_upc});
1798
1799                 # edition information, if any
1800         $oldbiblio->{edition} = $oldbiblio->{editionstatement};
1801
1802         my $itemtype = $oldbiblio->{itemtype} ? $itemtypes{$oldbiblio->{itemtype}} : undef;
1803         # add imageurl to itemtype if there is one
1804         $oldbiblio->{imageurl} = $itemtype ? getitemtypeimagelocation( $search_context->{'interface'}, $itemtype->{imageurl} ) : q{};
1805         # Build summary if there is one (the summary is defined in the itemtypes table)
1806         $oldbiblio->{description} = $itemtype ? $itemtype->{translated_description} : q{};
1807
1808         # FIXME: this is only used in the deprecated non-XLST opac results
1809         if ( !$xslfile && $is_opac && $itemtype && $itemtype->{summary} ) {
1810             my $summary = $itemtypes{ $oldbiblio->{itemtype} }->{summary};
1811             my @fields  = $marcrecord->fields();
1812
1813             my $newsummary;
1814             foreach my $line ( "$summary\n" =~ /(.*)\n/g ){
1815                 my $tags = {};
1816                 foreach my $tag ( $line =~ /\[(\d{3}[\w|\d])\]/ ) {
1817                     $tag =~ /(.{3})(.)/;
1818                     if($marcrecord->field($1)){
1819                         my @abc = $marcrecord->field($1)->subfield($2);
1820                         $tags->{$tag} = $#abc + 1 ;
1821                     }
1822                 }
1823
1824                 # We catch how many times to repeat this line
1825                 my $max = 0;
1826                 foreach my $tag (keys(%$tags)){
1827                     $max = $tags->{$tag} if($tags->{$tag} > $max);
1828                  }
1829
1830                 # we replace, and repeat each line
1831                 for (my $i = 0 ; $i < $max ; $i++){
1832                     my $newline = $line;
1833
1834                     foreach my $tag ( $newline =~ /\[(\d{3}[\w|\d])\]/g ) {
1835                         $tag =~ /(.{3})(.)/;
1836
1837                         if($marcrecord->field($1)){
1838                             my @repl = $marcrecord->field($1)->subfield($2);
1839                             my $subfieldvalue = $repl[$i];
1840                             $newline =~ s/\[$tag\]/$subfieldvalue/g;
1841                         }
1842                     }
1843                     $newsummary .= "$newline\n";
1844                 }
1845             }
1846
1847             $newsummary =~ s/\[(.*?)]//g;
1848             $newsummary =~ s/\n/<br\/>/g;
1849             $oldbiblio->{summary} = $newsummary;
1850         }
1851
1852         # Pull out the items fields
1853         my @fields = $marcrecord->field($itemtag);
1854         my $marcflavor = C4::Context->preference("marcflavour");
1855
1856         # adding linked items that belong to host records
1857         if ( C4::Context->preference('EasyAnalyticalRecords') ) {
1858             my $analyticsfield = '773';
1859             if ($marcflavor eq 'MARC21' || $marcflavor eq 'NORMARC') {
1860                 $analyticsfield = '773';
1861             } elsif ($marcflavor eq 'UNIMARC') {
1862                 $analyticsfield = '461';
1863             }
1864             foreach my $hostfield ( $marcrecord->field($analyticsfield)) {
1865                 my $hostbiblionumber = $hostfield->subfield("0");
1866                 my $linkeditemnumber = $hostfield->subfield("9");
1867                 if( $hostbiblionumber ) {
1868                     my $linkeditemmarc = C4::Items::GetMarcItem( $hostbiblionumber, $linkeditemnumber );
1869                     if ($linkeditemmarc) {
1870                         my $linkeditemfield = $linkeditemmarc->field($itemtag);
1871                         if ($linkeditemfield) {
1872                             push( @fields, $linkeditemfield );
1873                         }
1874                     }
1875                 }
1876             }
1877         }
1878
1879         # Setting item statuses for display
1880         my @available_items_loop;
1881         my @onloan_items_loop;
1882         my @other_items_loop;
1883
1884         my $available_items;
1885         my $onloan_items;
1886         my $other_items;
1887
1888         my $ordered_count         = 0;
1889         my $available_count       = 0;
1890         my $onloan_count          = 0;
1891         my $longoverdue_count     = 0;
1892         my $other_count           = 0;
1893         my $withdrawn_count        = 0;
1894         my $itemlost_count        = 0;
1895         my $hideatopac_count      = 0;
1896         my $itembinding_count     = 0;
1897         my $itemdamaged_count     = 0;
1898         my $item_in_transit_count = 0;
1899         my $can_place_holds       = 0;
1900         my $item_onhold_count     = 0;
1901         my $notforloan_count      = 0;
1902         my $items_count           = scalar(@fields);
1903         my $maxitems_pref = C4::Context->preference('maxItemsinSearchResults');
1904         my $maxitems = $maxitems_pref ? $maxitems_pref - 1 : 1;
1905         my @hiddenitems; # hidden itemnumbers based on OpacHiddenItems syspref
1906
1907         # loop through every item
1908         foreach my $field (@fields) {
1909             my $item;
1910
1911             # populate the items hash
1912             foreach my $code ( keys %subfieldstosearch ) {
1913                 $item->{$code} = $field->subfield( $subfieldstosearch{$code} );
1914             }
1915             $item->{description} = $itemtypes{ $item->{itype} }{translated_description} if $item->{itype};
1916
1917                 # OPAC hidden items
1918             if ($is_opac) {
1919                 # hidden because lost
1920                 if ($hidelostitems && $item->{itemlost}) {
1921                     $hideatopac_count++;
1922                     next;
1923                 }
1924                 # hidden based on OpacHiddenItems syspref
1925                 my @hi = C4::Items::GetHiddenItemnumbers({ items=> [ $item ], borcat => $search_context->{category} });
1926                 if (scalar @hi) {
1927                     push @hiddenitems, @hi;
1928                     $hideatopac_count++;
1929                     next;
1930                 }
1931             }
1932
1933             my $hbranch     = C4::Context->preference('StaffSearchResultsDisplayBranch');
1934             my $otherbranch = $hbranch eq 'homebranch' ? 'holdingbranch' : 'homebranch';
1935
1936             # set item's branch name, use HomeOrHoldingBranch syspref first, fall back to the other one
1937             if ($item->{$hbranch}) {
1938                 $item->{'branchname'} = $branches{$item->{$hbranch}};
1939             }
1940             elsif ($item->{$otherbranch}) {     # Last resort
1941                 $item->{'branchname'} = $branches{$item->{$otherbranch}};
1942             }
1943
1944             my $prefix =
1945                 ( $item->{$hbranch} ? $item->{$hbranch} . '--' : q{} )
1946               . ( $item->{location} ? $item->{location} : q{} )
1947               . ( $item->{itype}    ? $item->{itype}    : q{} )
1948               . ( $item->{itemcallnumber} ? $item->{itemcallnumber} : q{} );
1949 # For each grouping of items (onloan, available, unavailable), we build a key to store relevant info about that item
1950             if ( $item->{onloan}
1951                 and $logged_in_user
1952                 and !( $patron_category_hide_lost_items and $item->{itemlost} ) )
1953             {
1954                 $onloan_count++;
1955                 my $key = $prefix . $item->{onloan} . $item->{barcode};
1956                 $onloan_items->{$key}->{due_date} = $item->{onloan};
1957                 $onloan_items->{$key}->{count}++ if $item->{$hbranch};
1958                 $onloan_items->{$key}->{branchname}     = $item->{branchname};
1959                 $onloan_items->{$key}->{location}       = $shelflocations->{ $item->{location} };
1960                 $onloan_items->{$key}->{itemcallnumber} = $item->{itemcallnumber};
1961                 $onloan_items->{$key}->{description}    = $item->{description};
1962                 $onloan_items->{$key}->{imageurl} =
1963                   getitemtypeimagelocation( $search_context->{'interface'}, $itemtypes{ $item->{itype} }->{imageurl} );
1964
1965                 # if something's checked out and lost, mark it as 'long overdue'
1966                 if ( $item->{itemlost} ) {
1967                     $onloan_items->{$key}->{longoverdue}++;
1968                     $longoverdue_count++;
1969                 }
1970                 else {    # can place holds as long as item isn't lost
1971                     $can_place_holds = 1;
1972                 }
1973             }
1974
1975          # items not on loan, but still unavailable ( lost, withdrawn, damaged )
1976             else {
1977
1978                 my $itemtype = C4::Context->preference("item-level_itypes")? $item->{itype}: $oldbiblio->{itemtype};
1979                 $item->{notforloan} = 1 if !$item->{notforloan} &&
1980                     $itemtype && $itemtypes{ $itemtype }->{notforloan};
1981
1982                 # item is on order
1983                 if ( $item->{notforloan} < 0 ) {
1984                     $ordered_count++;
1985                 } elsif ( $item->{notforloan} > 0 ) {
1986                     $notforloan_count++;
1987                 }
1988
1989                 # is item in transit?
1990                 my $transfertwhen = '';
1991                 my ($transfertfrom, $transfertto);
1992
1993                 # is item on the reserve shelf?
1994                 my $reservestatus = '';
1995
1996                 unless ($item->{withdrawn}
1997                         || $item->{itemlost}
1998                         || $item->{damaged}
1999                         || $item->{notforloan}
2000                         || ( C4::Context->preference('MaxSearchResultsItemsPerRecordStatusCheck')
2001                         && $items_count > C4::Context->preference('MaxSearchResultsItemsPerRecordStatusCheck') ) ) {
2002
2003                     # A couple heuristics to limit how many times
2004                     # we query the database for item transfer information, sacrificing
2005                     # accuracy in some cases for speed;
2006                     #
2007                     # 1. don't query if item has one of the other statuses
2008                     # 2. don't check transit status if the bib has
2009                     #    more than 20 items
2010                     #
2011                     # FIXME: to avoid having the query the database like this, and to make
2012                     #        the in transit status count as unavailable for search limiting,
2013                     #        should map transit status to record indexed in Zebra.
2014                     #
2015                     ($transfertwhen, $transfertfrom, $transfertto) = C4::Circulation::GetTransfers($item->{itemnumber});
2016                     $reservestatus = C4::Reserves::GetReserveStatus( $item->{itemnumber} );
2017                 }
2018
2019                 # item is withdrawn, lost, damaged, not for loan, reserved or in transit
2020                 if (   $item->{withdrawn}
2021                     || $item->{itemlost}
2022                     || $item->{damaged}
2023                     || $item->{notforloan}
2024                     || $reservestatus eq 'Waiting'
2025                     || ($transfertwhen && $transfertwhen ne ''))
2026                 {
2027                     $withdrawn_count++        if $item->{withdrawn};
2028                     $itemlost_count++        if $item->{itemlost};
2029                     $itemdamaged_count++     if $item->{damaged};
2030                     $item_in_transit_count++ if $transfertwhen && $transfertwhen ne '';
2031                     $item_onhold_count++     if $reservestatus eq 'Waiting';
2032                     $item->{status} = ($item->{withdrawn}//q{}) . "-" . ($item->{itemlost}//q{}) . "-" . ($item->{damaged}//q{}) . "-" . ($item->{notforloan}//q{});
2033
2034                     # can place a hold on a item if
2035                     # not lost nor withdrawn
2036                     # not damaged unless AllowHoldsOnDamagedItems is true
2037                     # item is either for loan or on order (notforloan < 0)
2038                     $can_place_holds = 1
2039                       if (
2040                            !$item->{itemlost}
2041                         && !$item->{withdrawn}
2042                         && ( !$item->{damaged} || C4::Context->preference('AllowHoldsOnDamagedItems') )
2043                         && ( !$item->{notforloan} || $item->{notforloan} < 0 )
2044                       );
2045
2046                     $other_count++;
2047
2048                     my $key = $prefix . $item->{status};
2049                     foreach (qw(withdrawn itemlost damaged branchname itemcallnumber)) {
2050                         $other_items->{$key}->{$_} = $item->{$_};
2051                     }
2052                     $other_items->{$key}->{intransit} = ( $transfertwhen ne '' ) ? 1 : 0;
2053                     $other_items->{$key}->{onhold} = ($reservestatus) ? 1 : 0;
2054                     $other_items->{$key}->{notforloan} = GetAuthorisedValueDesc('','',$item->{notforloan},'','',$notforloan_authorised_value) if $notforloan_authorised_value and $item->{notforloan};
2055                     $other_items->{$key}->{count}++ if $item->{$hbranch};
2056                     $other_items->{$key}->{location} = $shelflocations->{ $item->{location} } if $item->{location};
2057                     $other_items->{$key}->{description} = $item->{description};
2058                     $other_items->{$key}->{imageurl} = getitemtypeimagelocation( $search_context->{'interface'}, $itemtypes{ $item->{itype}//q{} }->{imageurl} );
2059                 }
2060                 # item is available
2061                 else {
2062                     $can_place_holds = 1;
2063                     $available_count++;
2064                     $available_items->{$prefix}->{count}++ if $item->{$hbranch};
2065                     foreach (qw(branchname itemcallnumber description)) {
2066                         $available_items->{$prefix}->{$_} = $item->{$_};
2067                     }
2068                     $available_items->{$prefix}->{location} = $shelflocations->{ $item->{location} } if $item->{location};
2069                     $available_items->{$prefix}->{imageurl} = getitemtypeimagelocation( $search_context->{'interface'}, $itemtypes{ $item->{itype}//q{} }->{imageurl} );
2070                 }
2071             }
2072         }    # notforloan, item level and biblioitem level
2073
2074         # if all items are hidden, do not show the record
2075         if ($items_count > 0 && $hideatopac_count == $items_count) {
2076             next;
2077         }
2078
2079         my ( $availableitemscount, $onloanitemscount, $otheritemscount );
2080         for my $key ( sort keys %$onloan_items ) {
2081             (++$onloanitemscount > $maxitems) and last;
2082             push @onloan_items_loop, $onloan_items->{$key};
2083         }
2084         for my $key ( sort keys %$other_items ) {
2085             (++$otheritemscount > $maxitems) and last;
2086             push @other_items_loop, $other_items->{$key};
2087         }
2088         for my $key ( sort keys %$available_items ) {
2089             (++$availableitemscount > $maxitems) and last;
2090             push @available_items_loop, $available_items->{$key}
2091         }
2092
2093         # XSLT processing of some stuff
2094         # we fetched the sysprefs already before the loop through all retrieved record!
2095         if (!$scan && $xslfile) {
2096             $record_processor->options({
2097                 frameworkcode => $fw,
2098                 interface     => $search_context->{'interface'}
2099             });
2100
2101             $record_processor->process($marcrecord);
2102             $oldbiblio->{XSLTResultsRecord} = XSLTParse4Display($oldbiblio->{biblionumber}, $marcrecord, $xslsyspref, 1, \@hiddenitems, $sysxml, $xslfile, $lang, $xslt_variables);
2103         }
2104
2105         # if biblio level itypes are used and itemtype is notforloan, it can't be reserved either
2106         if (!C4::Context->preference("item-level_itypes")) {
2107             if ($itemtype && $itemtype->{notforloan}) {
2108                 $can_place_holds = 0;
2109             }
2110         }
2111         $oldbiblio->{norequests} = 1 unless $can_place_holds;
2112         $oldbiblio->{items_count}          = $items_count;
2113         $oldbiblio->{available_items_loop} = \@available_items_loop;
2114         $oldbiblio->{onloan_items_loop}    = \@onloan_items_loop;
2115         $oldbiblio->{other_items_loop}     = \@other_items_loop;
2116         $oldbiblio->{availablecount}       = $available_count;
2117         $oldbiblio->{availableplural}      = 1 if $available_count > 1;
2118         $oldbiblio->{onloancount}          = $onloan_count;
2119         $oldbiblio->{onloanplural}         = 1 if $onloan_count > 1;
2120         $oldbiblio->{othercount}           = $other_count;
2121         $oldbiblio->{otherplural}          = 1 if $other_count > 1;
2122         $oldbiblio->{withdrawncount}        = $withdrawn_count;
2123         $oldbiblio->{itemlostcount}        = $itemlost_count;
2124         $oldbiblio->{damagedcount}         = $itemdamaged_count;
2125         $oldbiblio->{intransitcount}       = $item_in_transit_count;
2126         $oldbiblio->{onholdcount}          = $item_onhold_count;
2127         $oldbiblio->{orderedcount}         = $ordered_count;
2128         $oldbiblio->{notforloancount}      = $notforloan_count;
2129
2130         if (C4::Context->preference("AlternateHoldingsField") && $items_count == 0) {
2131             my $fieldspec = C4::Context->preference("AlternateHoldingsField");
2132             my $subfields = substr $fieldspec, 3;
2133             my $holdingsep = C4::Context->preference("AlternateHoldingsSeparator") || ' ';
2134             my @alternateholdingsinfo = ();
2135             my @holdingsfields = $marcrecord->field(substr $fieldspec, 0, 3);
2136             my $alternateholdingscount = 0;
2137
2138             for my $field (@holdingsfields) {
2139                 my %holding = ( holding => '' );
2140                 my $havesubfield = 0;
2141                 for my $subfield ($field->subfields()) {
2142                     if ((index $subfields, $$subfield[0]) >= 0) {
2143                         $holding{'holding'} .= $holdingsep if (length $holding{'holding'} > 0);
2144                         $holding{'holding'} .= $$subfield[1];
2145                         $havesubfield++;
2146                     }
2147                 }
2148                 if ($havesubfield) {
2149                     push(@alternateholdingsinfo, \%holding);
2150                     $alternateholdingscount++;
2151                 }
2152             }
2153
2154             $oldbiblio->{'ALTERNATEHOLDINGS'} = \@alternateholdingsinfo;
2155             $oldbiblio->{'alternateholdings_count'} = $alternateholdingscount;
2156         }
2157
2158         $oldbiblio->{biblio_object} = Koha::Biblios->find( $oldbiblio->{biblionumber} );
2159
2160         push( @newresults, $oldbiblio );
2161     }
2162
2163     return @newresults;
2164 }
2165
2166 =head2 enabled_staff_search_views
2167
2168 %hash = enabled_staff_search_views()
2169
2170 This function returns a hash that contains three flags obtained from the system
2171 preferences, used to determine whether a particular staff search results view
2172 is enabled.
2173
2174 =over 2
2175
2176 =item C<Output arg:>
2177
2178     * $hash{can_view_MARC} is true only if the MARC view is enabled
2179     * $hash{can_view_ISBD} is true only if the ISBD view is enabled
2180     * $hash{can_view_labeledMARC} is true only if the Labeled MARC view is enabled
2181
2182 =item C<usage in the script:>
2183
2184 =back
2185
2186 $template->param ( C4::Search::enabled_staff_search_views );
2187
2188 =cut
2189
2190 sub enabled_staff_search_views
2191 {
2192         return (
2193                 can_view_MARC                   => C4::Context->preference('viewMARC'),                 # 1 if the staff search allows the MARC view
2194                 can_view_ISBD                   => C4::Context->preference('viewISBD'),                 # 1 if the staff search allows the ISBD view
2195                 can_view_labeledMARC    => C4::Context->preference('viewLabeledMARC'),  # 1 if the staff search allows the Labeled MARC view
2196         );
2197 }
2198
2199 =head2 z3950_search_args
2200
2201 $arrayref = z3950_search_args($matchpoints)
2202
2203 This function returns an array reference that contains the search parameters to be
2204 passed to the Z39.50 search script (z3950_search.pl). The array elements
2205 are hash refs whose keys are name and value, and whose values are the
2206 name of a search parameter, the value of that search parameter and the URL encoded
2207 value of that parameter.
2208
2209 The search parameter names are lccn, isbn, issn, title, author, dewey and subject.
2210
2211 The search parameter values are obtained from the bibliographic record whose
2212 data is in a hash reference in $matchpoints, as returned by Biblio::GetBiblioData().
2213
2214 If $matchpoints is a scalar, it is assumed to be an unnamed query descriptor, e.g.
2215 a general purpose search argument. In this case, the returned array contains only
2216 entry: the key is 'title' and the value is derived from $matchpoints.
2217
2218 If a search parameter value is undefined or empty, it is not included in the returned
2219 array.
2220
2221 The returned array reference may be passed directly to the template parameters.
2222
2223 =over 2
2224
2225 =item C<Output arg:>
2226
2227     * $array containing hash refs as described above
2228
2229 =item C<usage in the script:>
2230
2231 =back
2232
2233 $data = Biblio::GetBiblioData($bibno);
2234 $template->param ( MYLOOP => C4::Search::z3950_search_args($data) )
2235
2236 *OR*
2237
2238 $template->param ( MYLOOP => C4::Search::z3950_search_args($searchscalar) )
2239
2240 =cut
2241
2242 sub z3950_search_args {
2243     my $bibrec = shift;
2244
2245     my $isbn_string = ref( $bibrec ) ? $bibrec->{title} : $bibrec;
2246     my $isbn = Business::ISBN->new( $isbn_string );
2247
2248     if (defined $isbn && $isbn->is_valid)
2249     {
2250         if ( ref($bibrec) ) {
2251             $bibrec->{isbn} = $isbn_string;
2252             $bibrec->{title} = undef;
2253         } else {
2254             $bibrec = { isbn => $isbn_string };
2255         }
2256     }
2257     else {
2258         $bibrec = { title => $bibrec } if !ref $bibrec;
2259     }
2260     my $array = [];
2261     for my $field (qw/ lccn isbn issn title author dewey subject /)
2262     {
2263         push @$array, { name => $field, value => $bibrec->{$field} }
2264           if defined $bibrec->{$field};
2265     }
2266     return $array;
2267 }
2268
2269 =head2 GetDistinctValues($field);
2270
2271 C<$field> is a reference to the fields array
2272
2273 =cut
2274
2275 sub GetDistinctValues {
2276     my ($fieldname,$string)=@_;
2277     # returns a reference to a hash of references to branches...
2278     if ($fieldname=~/\./){
2279                         my ($table,$column)=split /\./, $fieldname;
2280                         my $dbh = C4::Context->dbh;
2281                         warn "select DISTINCT($column) as value, count(*) as cnt from $table group by lib order by $column " if $DEBUG;
2282                         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 ");
2283                         $sth->execute;
2284                         my $elements=$sth->fetchall_arrayref({});
2285                         return $elements;
2286    }
2287    else {
2288                 $string||= qq("");
2289                 my @servers=qw<biblioserver authorityserver>;
2290                 my (@zconns,@results);
2291         for ( my $i = 0 ; $i < @servers ; $i++ ) {
2292                 $zconns[$i] = C4::Context->Zconn( $servers[$i], 1 );
2293                         $results[$i] =
2294                       $zconns[$i]->scan(
2295                         ZOOM::Query::CCL2RPN->new( qq"$fieldname $string", $zconns[$i])
2296                       );
2297                 }
2298                 # The big moment: asynchronously retrieve results from all servers
2299                 my @elements;
2300         _ZOOM_event_loop(
2301             \@zconns,
2302             \@results,
2303             sub {
2304                 my ( $i, $size ) = @_;
2305                 for ( my $j = 0 ; $j < $size ; $j++ ) {
2306                     my %hashscan;
2307                     @hashscan{qw(value cnt)} =
2308                       $results[ $i - 1 ]->display_term($j);
2309                     push @elements, \%hashscan;
2310                 }
2311             }
2312         );
2313                 return \@elements;
2314    }
2315 }
2316
2317 =head2 _ZOOM_event_loop
2318
2319     _ZOOM_event_loop(\@zconns, \@results, sub {
2320         my ( $i, $size ) = @_;
2321         ....
2322     } );
2323
2324 Processes a ZOOM event loop and passes control to a closure for
2325 processing the results, and destroying the resultsets.
2326
2327 =cut
2328
2329 sub _ZOOM_event_loop {
2330     my ($zconns, $results, $callback) = @_;
2331     while ( ( my $i = ZOOM::event( $zconns ) ) != 0 ) {
2332         my $ev = $zconns->[ $i - 1 ]->last_event();
2333         if ( $ev == ZOOM::Event::ZEND ) {
2334             next unless $results->[ $i - 1 ];
2335             my $size = $results->[ $i - 1 ]->size();
2336             if ( $size > 0 ) {
2337                 $callback->($i, $size);
2338             }
2339         }
2340     }
2341
2342     foreach my $result (@$results) {
2343         $result->destroy();
2344     }
2345 }
2346
2347 =head2 new_record_from_zebra
2348
2349 Given raw data from a searchengine result set, return a MARC::Record object
2350
2351 This helper function is needed to take into account all the involved
2352 system preferences and configuration variables to properly create the
2353 MARC::Record object.
2354
2355 If we are using GRS-1, then the raw data we get from Zebra should be USMARC
2356 data. If we are using DOM, then it has to be MARCXML.
2357
2358 If we are using elasticsearch, it'll already be a MARC::Record and this
2359 function needs a new name.
2360
2361 =cut
2362
2363 sub new_record_from_zebra {
2364
2365     my $server   = shift;
2366     my $raw_data = shift;
2367     # Set the default indexing modes
2368     my $search_engine = C4::Context->preference("SearchEngine");
2369     if ($search_engine eq 'Elasticsearch') {
2370         return ref $raw_data eq 'MARC::Record' ? $raw_data : MARC::Record->new_from_xml( $raw_data, 'UTF-8' );
2371     }
2372     my $index_mode = ( $server eq 'biblioserver' )
2373                         ? C4::Context->config('zebra_bib_index_mode') // 'dom'
2374                         : C4::Context->config('zebra_auth_index_mode') // 'dom';
2375
2376     my $marc_record =  eval {
2377         if ( $index_mode eq 'dom' ) {
2378             MARC::Record->new_from_xml( $raw_data, 'UTF-8' );
2379         } else {
2380             MARC::Record->new_from_usmarc( $raw_data );
2381         }
2382     };
2383
2384     if ($@) {
2385         return;
2386     } else {
2387         return $marc_record;
2388     }
2389
2390 }
2391
2392 END { }    # module clean-up code here (global destructor)
2393
2394 1;
2395 __END__
2396
2397 =head1 AUTHOR
2398
2399 Koha Development Team <http://koha-community.org/>
2400
2401 =cut