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