fixes to enable truncation to play nicely with
[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 under the
6 # terms of the GNU General Public License as published by the Free Software
7 # Foundation; either version 2 of the License, or (at your option) any later
8 # version.
9 #
10 # Koha is distributed in the hope that it will be useful, but WITHOUT ANY
11 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License along with
15 # Koha; if not, write to the Free Software Foundation, Inc., 59 Temple Place,
16 # Suite 330, Boston, MA  02111-1307 USA
17
18 use strict;
19 require Exporter;
20 use C4::Context;
21 use C4::Biblio;    # GetMarcFromKohaField
22 use C4::Koha;      # getFacets
23 use Lingua::Stem;
24 use C4::Date;
25
26 use vars qw($VERSION @ISA @EXPORT @EXPORT_OK %EXPORT_TAGS);
27
28 # set the version for version checking
29 $VERSION = 3.00;
30
31 =head1 NAME
32
33 C4::Search - Functions for searching the Koha catalog.
34
35 =head1 SYNOPSIS
36
37 see opac/opac-search.pl or catalogue/search.pl for example of usage
38
39 =head1 DESCRIPTION
40
41 This module provides the searching facilities for the Koha into a zebra catalog.
42
43 =head1 FUNCTIONS
44
45 =cut
46
47 @ISA    = qw(Exporter);
48 @EXPORT = qw(
49   &SimpleSearch
50   &findseealso
51   &FindDuplicate
52   &searchResults
53   &getRecords
54   &buildQuery
55   &NZgetRecords
56   &ModBiblios
57 );
58
59 # make all your functions, whether exported or not;
60
61 =head2 findseealso($dbh,$fields);
62
63 C<$dbh> is a link to the DB handler.
64
65 use C4::Context;
66 my $dbh =C4::Context->dbh;
67
68 C<$fields> is a reference to the fields array
69
70 This function modify the @$fields array and add related fields to search on.
71
72 =cut
73
74 sub findseealso {
75     my ( $dbh, $fields ) = @_;
76     my $tagslib = GetMarcStructure( 1 );
77     for ( my $i = 0 ; $i <= $#{$fields} ; $i++ ) {
78         my ($tag)      = substr( @$fields[$i], 1, 3 );
79         my ($subfield) = substr( @$fields[$i], 4, 1 );
80         @$fields[$i] .= ',' . $tagslib->{$tag}->{$subfield}->{seealso}
81           if ( $tagslib->{$tag}->{$subfield}->{seealso} );
82     }
83 }
84
85 =head2 FindDuplicate
86
87 ($biblionumber,$biblionumber,$title) = FindDuplicate($record);
88
89 =cut
90
91 sub FindDuplicate {
92     my ($record) = @_;
93     my $dbh = C4::Context->dbh;
94     my $result = TransformMarcToKoha( $dbh, $record, '' );
95     my $sth;
96     my $query;
97     my $search;
98     my $type;
99     my ( $biblionumber, $title );
100
101     # search duplicate on ISBN, easy and fast..
102     # ... normalize first
103     if ( $result->{isbn} ) {
104         $result->{isbn} =~ s/\(.*$//;
105         $result->{isbn} =~ s/\s+$//; 
106     }
107     #$search->{'avoidquerylog'}=1;
108     if ( $result->{isbn} ) {
109         $query = "isbn=$result->{isbn}";
110     }
111     else {
112         $result->{title} =~ s /\\//g;
113         $result->{title} =~ s /\"//g;
114         $result->{title} =~ s /\(//g;
115         $result->{title} =~ s /\)//g;
116                 # remove valid operators
117                 $result->{title} =~ s/(and|or|not)//g;
118         $query = "ti,ext=$result->{title}";
119         $query .= " and mt=$result->{itemtype}" if ($result->{itemtype});    
120         if ($result->{author}){
121           $result->{author} =~ s /\\//g;
122           $result->{author} =~ s /\"//g;
123           $result->{author} =~ s /\(//g;
124           $result->{author} =~ s /\)//g;
125                   # remove valid operators
126                   $result->{author} =~ s/(and|or|not)//g;
127           $query .= " and au,ext=$result->{author}";
128         }     
129     }
130     my ($error,$searchresults) =
131       SimpleSearch($query); # FIXME :: hardcoded !
132     my @results;
133     foreach my $possible_duplicate_record (@$searchresults) {
134         my $marcrecord =
135           MARC::Record->new_from_usmarc($possible_duplicate_record);
136         my $result = TransformMarcToKoha( $dbh, $marcrecord, '' );
137         
138         # FIXME :: why 2 $biblionumber ?
139         if ($result){
140           push @results, $result->{'biblionumber'};
141           push @results, $result->{'title'};
142         }
143     }
144     return @results;  
145 }
146
147 =head2 SimpleSearch
148
149 ($error,$results) = SimpleSearch($query,@servers);
150
151 this function performs a simple search on the catalog using zoom.
152
153 =over 2
154
155 =item C<input arg:>
156
157     * $query could be a simple keyword or a complete CCL query wich is depending on your ccl file.
158     * @servers is optionnal. default one is read on koha.xml
159
160 =item C<Output arg:>
161     * $error is a string which containt the description error if there is one. Else it's empty.
162     * \@results is an array of marc record.
163
164 =item C<usage in the script:>
165
166 =back
167
168 my ($error, $marcresults) = SimpleSearch($query);
169
170 if (defined $error) {
171     $template->param(query_error => $error);
172     warn "error: ".$error;
173     output_html_with_http_headers $input, $cookie, $template->output;
174     exit;
175 }
176
177 my $hits = scalar @$marcresults;
178 my @results;
179
180 for(my $i=0;$i<$hits;$i++) {
181     my %resultsloop;
182     my $marcrecord = MARC::File::USMARC::decode($marcresults->[$i]);
183     my $biblio = TransformMarcToKoha(C4::Context->dbh,$marcrecord,'');
184
185     #build the hash for the template.
186     $resultsloop{highlight}       = ($i % 2)?(1):(0);
187     $resultsloop{title}           = $biblio->{'title'};
188     $resultsloop{subtitle}        = $biblio->{'subtitle'};
189     $resultsloop{biblionumber}    = $biblio->{'biblionumber'};
190     $resultsloop{author}          = $biblio->{'author'};
191     $resultsloop{publishercode}   = $biblio->{'publishercode'};
192     $resultsloop{publicationyear} = $biblio->{'publicationyear'};
193
194     push @results, \%resultsloop;
195 }
196 $template->param(result=>\@results);
197
198 =cut
199
200 sub SimpleSearch {
201     my $query   = shift;
202     if (C4::Context->preference('NoZebra')) {
203         my $result = NZorder(NZanalyse($query))->{'biblioserver'}->{'RECORDS'};
204         return (undef,$result);
205     } else {
206         my @servers = @_;
207         my @results;
208         my @tmpresults;
209         my @zconns;
210         return ( "No query entered", undef ) unless $query;
211     
212         #@servers = (C4::Context->config("biblioserver")) unless @servers;
213         @servers =
214         ("biblioserver") unless @servers
215         ;    # FIXME hardcoded value. See catalog/search.pl & opac-search.pl too.
216     
217         # Connect & Search
218         for ( my $i = 0 ; $i < @servers ; $i++ ) {
219             $zconns[$i] = C4::Context->Zconn( $servers[$i], 1 );
220             $tmpresults[$i] =
221             $zconns[$i]
222             ->search( new ZOOM::Query::CCL2RPN( $query, $zconns[$i] ) );
223     
224             # getting error message if one occured.
225             my $error =
226                 $zconns[$i]->errmsg() . " ("
227             . $zconns[$i]->errcode() . ") "
228             . $zconns[$i]->addinfo() . " "
229             . $zconns[$i]->diagset();
230     
231             return ( $error, undef ) if $zconns[$i]->errcode();
232         }
233         my $hits;
234         my $ev;
235         while ( ( my $i = ZOOM::event( \@zconns ) ) != 0 ) {
236             $ev = $zconns[ $i - 1 ]->last_event();
237             if ( $ev == ZOOM::Event::ZEND ) {
238                 $hits = $tmpresults[ $i - 1 ]->size();
239             }
240             if ( $hits > 0 ) {
241                 for ( my $j = 0 ; $j < $hits ; $j++ ) {
242                     my $record = $tmpresults[ $i - 1 ]->record($j)->raw();
243                     push @results, $record;
244                 }
245             }
246         }
247         return ( undef, \@results );
248     }
249 }
250
251 # performs the search
252 sub getRecords {
253     my (
254         $koha_query,     $federated_query,  $sort_by_ref,
255         $servers_ref,    $results_per_page, $offset,
256         $expanded_facet, $branches,         $query_type,
257         $scan
258     ) = @_;
259 #     warn "Query : $koha_query";
260     my @servers = @$servers_ref;
261     my @sort_by = @$sort_by_ref;
262
263     # create the zoom connection and query object
264     my $zconn;
265     my @zconns;
266     my @results;
267     my $results_hashref = ();
268
269     ### FACETED RESULTS
270     my $facets_counter = ();
271     my $facets_info    = ();
272     my $facets         = getFacets();
273
274     #### INITIALIZE SOME VARS USED CREATE THE FACETED RESULTS
275     my @facets_loop;    # stores the ref to array of hashes for template
276     for ( my $i = 0 ; $i < @servers ; $i++ ) {
277         $zconns[$i] = C4::Context->Zconn( $servers[$i], 1 );
278
279 # perform the search, create the results objects
280 # if this is a local search, use the $koha-query, if it's a federated one, use the federated-query
281         my $query_to_use;
282         if ( $servers[$i] =~ /biblioserver/ ) {
283             $query_to_use = $koha_query;
284         }
285         else {
286             $query_to_use = $federated_query;
287         }
288
289         # check if we've got a query_type defined
290         eval {
291             if ($query_type)
292             {
293                 if ( $query_type =~ /^ccl/ ) {
294                     $query_to_use =~
295                       s/\:/\=/g;    # change : to = last minute (FIXME)
296
297                     #                 warn "CCL : $query_to_use";
298                     $results[$i] =
299                       $zconns[$i]->search(
300                         new ZOOM::Query::CCL2RPN( $query_to_use, $zconns[$i] )
301                       );
302                 }
303                 elsif ( $query_type =~ /^cql/ ) {
304
305                     #                 warn "CQL : $query_to_use";
306                     $results[$i] =
307                       $zconns[$i]->search(
308                         new ZOOM::Query::CQL( $query_to_use, $zconns[$i] ) );
309                 }
310                 elsif ( $query_type =~ /^pqf/ ) {
311
312                     #                 warn "PQF : $query_to_use";
313                     $results[$i] =
314                       $zconns[$i]->search(
315                         new ZOOM::Query::PQF( $query_to_use, $zconns[$i] ) );
316                 }
317             }
318             else {
319                 if ($scan) {
320
321                     #                 warn "preparing to scan";
322                     $results[$i] =
323                       $zconns[$i]->scan(
324                         new ZOOM::Query::CCL2RPN( $query_to_use, $zconns[$i] )
325                       );
326                 }
327                 else {
328
329                     #             warn "LAST : $query_to_use";
330                     $results[$i] =
331                       $zconns[$i]->search(
332                         new ZOOM::Query::CCL2RPN( $query_to_use, $zconns[$i] )
333                       );
334                 }
335             }
336         };
337         if ($@) {
338             warn "WARNING: query problem with $query_to_use " . $@;
339         }
340
341         # concatenate the sort_by limits and pass them to the results object
342         my $sort_by;
343         foreach my $sort (@sort_by) {
344             if ($sort eq "author_az") {
345                 $sort_by.="1=1003 <i ";
346             }
347             elsif ($sort eq "author_za") {
348                 $sort_by.="1=1003 >i ";
349             }
350                         elsif ($sort eq "popularity_asc") {
351                                 $sort_by.="1=9003 <i ";
352                         }
353                         elsif ($sort eq "popularity_dsc") {
354                 $sort_by.="1=9003 >i ";
355             }
356                         elsif ($sort eq "call_number_asc") {
357                 $sort_by.="1=20  <i ";
358             }
359                         elsif ($sort eq "call_number_dsc") {
360                 $sort_by.="1=20 >i ";
361             }
362                         elsif ($sort eq "pubdate_asc") {
363                 $sort_by.="1=31 <i ";
364             }
365                         elsif ($sort eq "pubdate_dsc") {
366                 $sort_by.="1=31 >i ";
367             }
368                         elsif ($sort eq "acqdate_asc") {
369                 $sort_by.="1=32 <i ";
370             }
371                         elsif ($sort eq "acqdate_dsc") {
372                 $sort_by.="1=32 >i ";
373             }
374                         elsif ($sort eq "title_az") {
375                 $sort_by.="1=4 <i ";
376             }
377                         elsif ($sort eq "title_za") {
378                 $sort_by.="1=4 >i ";
379             }
380         }
381                 if ($sort_by) {
382                         if ( $results[$i]->sort( "yaz", $sort_by ) < 0) {
383                         warn "WARNING sort $sort_by failed";
384                         }
385                 }
386     }
387     while ( ( my $i = ZOOM::event( \@zconns ) ) != 0 ) {
388         my $ev = $zconns[ $i - 1 ]->last_event();
389         if ( $ev == ZOOM::Event::ZEND ) {
390             my $size = $results[ $i - 1 ]->size();
391             if ( $size > 0 ) {
392                 my $results_hash;
393                 #$results_hash->{'server'} = $servers[$i-1];
394                 # loop through the results
395                 $results_hash->{'hits'} = $size;
396                 my $times;
397                 if ( $offset + $results_per_page <= $size ) {
398                     $times = $offset + $results_per_page;
399                 }
400                 else {
401                     $times = $size;
402                 }
403                 for ( my $j = $offset ; $j < $times ; $j++ )
404                 {   #(($offset+$count<=$size) ? ($offset+$count):$size) ; $j++){
405                     my $records_hash;
406                     my $record;
407                     my $facet_record;
408                     ## This is just an index scan
409                     if ($scan) {
410                         my ( $term, $occ ) = $results[ $i - 1 ]->term($j);
411
412                  # here we create a minimal MARC record and hand it off to the
413                  # template just like a normal result ... perhaps not ideal, but
414                  # it works for now
415                         my $tmprecord = MARC::Record->new();
416                         $tmprecord->encoding('UTF-8');
417                         my $tmptitle;
418
419           # srote the minimal record in author/title (depending on MARC flavour)
420                         if ( C4::Context->preference("marcflavour") eq
421                             "UNIMARC" )
422                         {
423                             $tmptitle = MARC::Field->new(
424                                 '200', ' ', ' ',
425                                 a => $term,
426                                 f => $occ
427                             );
428                         }
429                         else {
430                             $tmptitle = MARC::Field->new(
431                                 '245', ' ', ' ',
432                                 a => $term,
433                                 b => $occ
434                             );
435                         }
436                         $tmprecord->append_fields($tmptitle);
437                         $results_hash->{'RECORDS'}[$j] =
438                           $tmprecord->as_usmarc();
439                     }
440                     else {
441                         $record = $results[ $i - 1 ]->record($j)->raw();
442
443                         #warn "RECORD $j:".$record;
444                         $results_hash->{'RECORDS'}[$j] =
445                           $record;    # making a reference to a hash
446                                       # Fill the facets while we're looping
447                         $facet_record = MARC::Record->new_from_usmarc($record);
448
449                         #warn $servers[$i-1].$facet_record->title();
450                         for ( my $k = 0 ; $k <= @$facets ; $k++ ) {
451                             if ( $facets->[$k] ) {
452                                 my @fields;
453                                 for my $tag ( @{ $facets->[$k]->{'tags'} } ) {
454                                     push @fields, $facet_record->field($tag);
455                                 }
456                                 for my $field (@fields) {
457                                     my @subfields = $field->subfields();
458                                     for my $subfield (@subfields) {
459                                         my ( $code, $data ) = @$subfield;
460                                         if ( $code eq
461                                             $facets->[$k]->{'subfield'} )
462                                         {
463                                             $facets_counter->{ $facets->[$k]
464                                                   ->{'link_value'} }->{$data}++;
465                                         }
466                                     }
467                                 }
468                                 $facets_info->{ $facets->[$k]->{'link_value'} }
469                                   ->{'label_value'} =
470                                   $facets->[$k]->{'label_value'};
471                                 $facets_info->{ $facets->[$k]->{'link_value'} }
472                                   ->{'expanded'} = $facets->[$k]->{'expanded'};
473                             }
474                         }
475                     }
476                 }
477                 $results_hashref->{ $servers[ $i - 1 ] } = $results_hash;
478             }
479
480             #print "connection ", $i-1, ": $size hits";
481             #print $results[$i-1]->record(0)->render() if $size > 0;
482             # BUILD FACETS
483             for my $link_value (
484                 sort { $facets_counter->{$b} <=> $facets_counter->{$a} }
485                 keys %$facets_counter
486               )
487             {
488                 my $expandable;
489                 my $number_of_facets;
490                 my @this_facets_array;
491                 for my $one_facet (
492                     sort {
493                         $facets_counter->{$link_value}
494                           ->{$b} <=> $facets_counter->{$link_value}->{$a}
495                     } keys %{ $facets_counter->{$link_value} }
496                   )
497                 {
498                     $number_of_facets++;
499                     if (   ( $number_of_facets < 6 )
500                         || ( $expanded_facet eq $link_value )
501                         || ( $facets_info->{$link_value}->{'expanded'} ) )
502                     {
503
504                        # sanitize the link value ), ( will cause errors with CCL
505                         my $facet_link_value = $one_facet;
506                         $facet_link_value =~ s/(\(|\))/ /g;
507
508                         # fix the length that will display in the label
509                         my $facet_label_value = $one_facet;
510                         $facet_label_value = substr( $one_facet, 0, 20 ) . "..."
511                           unless length($facet_label_value) <= 20;
512
513                        # well, if it's a branch, label by the name, not the code
514                         if ( $link_value =~ /branch/ ) {
515                             $facet_label_value =
516                               $branches->{$one_facet}->{'branchname'};
517                         }
518
519                  # but we're down with the whole label being in the link's title
520                         my $facet_title_value = $one_facet;
521
522                         push @this_facets_array,
523                           (
524                             {
525                                 facet_count =>
526                                   $facets_counter->{$link_value}->{$one_facet},
527                                 facet_label_value => $facet_label_value,
528                                 facet_title_value => $facet_title_value,
529                                 facet_link_value  => $facet_link_value,
530                                 type_link_value   => $link_value,
531                             },
532                           );
533                     }
534                 }
535                 unless ( $facets_info->{$link_value}->{'expanded'} ) {
536                     $expandable = 1
537                       if ( ( $number_of_facets > 6 )
538                         && ( $expanded_facet ne $link_value ) );
539                 }
540                 push @facets_loop,
541                   (
542                     {
543                         type_link_value => $link_value,
544                         type_id         => $link_value . "_id",
545                         type_label      =>
546                           $facets_info->{$link_value}->{'label_value'},
547                         facets     => \@this_facets_array,
548                         expandable => $expandable,
549                         expand     => $link_value,
550                     }
551                   );
552             }
553         }
554     }
555     return ( undef, $results_hashref, \@facets_loop );
556 }
557
558 sub _remove_stopwords {
559         my ($operand,$index) = @_;
560         # if the index contains more than one qualifier, but not phrase:    
561         if (index($index,"phr")<0 && index($index,",")>0){
562         # operand may be a wordlist deleting stopwords
563         # remove stopwords from operand : parse all stopwords & remove them (case insensitive)
564         #       we use IsAlpha unicode definition, to deal correctly with diacritics.
565         #       otherwise, a french word like "leçon" is splitted in "le" "çon", le is an empty word, we get "çon"
566         #       and don't find anything...
567                 foreach (keys %{C4::Context->stopwords}) {
568                         $operand=~ s/\P{IsAlpha}$_\P{IsAlpha}/ /i;
569                         $operand=~ s/^$_\P{IsAlpha}/ /i;
570                         $operand=~ s/\P{IsAlpha}$_$/ /i;
571                     
572                 }
573         }
574         return $operand;
575 }
576
577 sub _add_truncation {
578         my ($operand,$index) = @_;
579         my (@nontruncated,@righttruncated,@lefttruncated,@rightlefttruncated,@regexpr);
580         # if the index contains more than one qualifier, but not phrase, add truncation qualifiers
581         #if (index($index,"phr")<0 && index($index,",")>0){
582         warn "ADDING TRUNCATION QUALIFIERS";
583                 my @wordlist= split (/\s/,$operand);
584                 foreach my $word (@wordlist){
585                         warn "WORD: $word";
586                         if (index($word,"*")==0 && index($word,"*",1)==length($word)-2){
587                                 $word=~s/\*//;
588                                 push @rightlefttruncated,$word;
589                         } 
590                         elsif(index($word,"*")==0 && index($word,"*",1)<0){
591                                 $word=~s/\*//;
592                                 push @lefttruncated,$word;
593                         
594                         } 
595                         elsif (index($word,"*")==length($word)-1){
596                                 $word=~s/\*//;
597                                 push @righttruncated,$word;
598                         } 
599                         elsif (index($word,"*")<0){
600                                 push @nontruncated,$word;
601                         }
602                         else {
603                                 push @regexpr,$word;
604                         
605                         }
606                 }
607         #}
608         return (\@nontruncated,\@righttruncated,\@lefttruncated,\@rightlefttruncated,\@regexpr);
609 }
610
611 sub _build_stemmed_operand {
612         my ($operand) = @_;
613         my $stemmed_operand;
614         #$operand =~ s/^(and |or |not )//i;
615         # STEMMING FIXME: may need to refine the field weighting so stemmed operands don't 
616         # disrupt the query ranking, this needs more testing
617         # FIXME: the locale should be set based on the user's language and/or search choice
618         my $stemmer = Lingua::Stem->new( -locale => 'EN-US' );
619         # FIXME: these should be stored in the db so the librarian can modify the behavior
620         $stemmer->add_exceptions(
621                         {
622                                 'and' => 'and',
623                 'or'  => 'or',
624                 'not' => 'not',
625                         }
626                     
627                 );
628         my @words = split( / /, $operand );
629         my $stems = $stemmer->stem(@words);
630         foreach my $stem (@$stems) {
631                         $stemmed_operand .= "$stem";
632                         $stemmed_operand .= "?" unless ( $stem =~ /(and$|or$|not$)/ ) || ( length($stem) < 3 );
633                         $stemmed_operand .= " ";
634         }
635         warn "STEMMED OPERAND: $stemmed_operand";
636         return $stemmed_operand;
637 }
638
639 sub _build_weighted_query {
640         # FIELD WEIGHTING - This is largely experimental stuff. What I'm committing works
641         # pretty well but will work much better when we have an actual query parser
642         my ($operand,$stemmed_operand,$index) = @_;
643     my $stemming      = C4::Context->preference("QueryStemming")     || 0;
644     my $weight_fields = C4::Context->preference("QueryWeightFields") || 0;
645     my $fuzzy_enabled = C4::Context->preference("QueryFuzzy") || 0;
646
647     my $weighted_query .= " (rk=(";     # Specifies that we're applying rank
648         # keyword has different weight properties
649         if ( ( $index =~ /kw/ ) || ( !$index ) ) {
650         # a simple way to find out if this query uses an index
651                 if ( $operand =~ /(\=|\:)/ ) {
652                         $weighted_query .= " $operand";
653                 }
654                 else {
655                         $weighted_query .=" Title-cover,ext,r1=\"$operand\"";   # title cover as exact
656                         $weighted_query .=" or ti,ext,r2=\"$operand\"";             # exact title elsewhere
657                         $weighted_query .= " or ti,phr,r3=\"$operand\"";          # index as phrase
658                         #$weighted_query .= " or any,ext,r4=$operand";         # index as exact
659                         #$weighted_query .=" or kw,wrdl,r5=\"$operand\"";            # all the words in the query (wordlist)
660                         $weighted_query .= " or wrd,fuzzy,r8=\"$operand\"" if $fuzzy_enabled; # add fuzzy
661                         $weighted_query .= " or wrd,right-Truncation,r9=\"$stemmed_operand\"" if ($stemming and $stemmed_operand); # add stemming
662                         # embedded sorting: 0 a-z; 1 z-a
663                         #$weighted_query .= ") or (sort1,aut=1";
664                 }
665                     
666         }
667         #TODO: build better cases based on specific search indexes
668         #elsif ( $index =~ /au/ ) {
669         #       $weighted_query .=" $index,ext,r1=$operand";    # index label as exact
670         #       #$weighted_query .= " or (title-sort-az=0 or $index,startswithnt,st-word,r3=$operand #)";
671         #       $weighted_query .=" or $index,phr,r3=$operand";    # index as phrase
672         #       $weighted_query .= " or $index,rt,wrd,r3=$operand";
673         #}
674         #elsif ( $index =~ /ti/ ) {
675         #       $weighted_query .=" Title-cover,ext,r1=$operand"; # index label as exact
676         #       $weighted_query .= " or Title-series,ext,r2=$operand";
677         #       #$weighted_query .= " or ti,ext,r2=$operand";
678         #       #$weighted_query .= " or ti,phr,r3=$operand";
679         #       #$weighted_query .= " or ti,wrd,r3=$operand";
680         #       $weighted_query .=" or (title-sort-az=0 or Title-cover,startswithnt,st-word,r3=$operand #)";
681         #       $weighted_query .=" or (title-sort-az=0 or Title-cover,phr,r6=$operand)";
682                 #$weighted_query .= " or Title-cover,wrd,r5=$operand";
683                 #$weighted_query .= " or ti,ext,r6=$operand";
684                 #$weighted_query .= " or ti,startswith,phr,r7=$operand";
685                 #$weighted_query .= " or ti,phr,r8=$operand";
686                 #$weighted_query .= " or ti,wrd,r9=$operand";
687                 #$weighted_query .= " or ti,ext,r2=$operand";         # index as exact
688                 #$weighted_query .= " or ti,phr,r3=$operand";              # index as  phrase
689                 #$weighted_query .= " or any,ext,r4=$operand";         # index as exact
690                 #$weighted_query .= " or kw,wrd,r5=$operand";         # index as exact
691         #}
692         else {
693                 $weighted_query .=" $index,ext,r1=$operand";    # index label as exact
694                 #$weighted_query .= " or $index,ext,r2=$operand";            # index as exact
695                 $weighted_query .=" or $index,phr,r3=$operand";    # index as phrase
696                 $weighted_query .= " or $index,rt,wrd,r3=$operand";
697                 $weighted_query .=" or $index,wrd,r5=$operand";    # index as word right-truncated
698                 $weighted_query .= " or $index,wrd,fuzzy,r8=$operand" if $fuzzy_enabled;
699         }
700         $weighted_query .= "))";    # close rank specification
701         return $weighted_query;
702 }
703
704 # build the query itself
705 sub buildQuery {
706     my ( $operators, $operands, $indexes, $limits, $sort_by ) = @_;
707
708     my @operators = @$operators if $operators;
709     my @indexes   = @$indexes   if $indexes;
710     my @operands  = @$operands  if $operands;
711     my @limits    = @$limits    if $limits;
712     my @sort_by   = @$sort_by   if $sort_by;
713
714         my $stemming      = C4::Context->preference("QueryStemming")     || 0;
715         my $weight_fields = C4::Context->preference("QueryWeightFields") || 0;
716         my $fuzzy_enabled = C4::Context->preference("QueryFuzzy") || 0;
717
718     my $human_search_desc;      # a human-readable query
719     my $machine_search_desc;    #a machine-readable query
720         #warn "OPERATORS: >@operators< INDEXES: >@indexes< OPERANDS: >@operands< LIMITS: >@limits< SORTS: >@sort_by<";
721         my $query = $operands[0];
722 # STEP I: determine if this is a form-based / simple query or if it's complex (if complex,
723 # we can't handle field weighting, stemming until a formal query parser is written
724
725 # check if this is a known query language query, if it is, return immediately,
726 # the user is responsible for constructing valid syntax:
727     if ( $query =~ /^ccl=/ ) {
728         return ( undef, $', $', $', 'ccl' );
729     }
730     if ( $query =~ /^cql=/ ) {
731         return ( undef, $', $', $', 'cql' );
732     }
733     if ( $query =~ /^pqf=/ ) {
734         return ( undef, $', $', $', 'pqf' );
735     }
736     if ( $query =~ /(\(|\))/ ) {    # sorry, too complex, assume CCL
737         return ( undef, $query, $query, $query, 'ccl' );
738     }
739
740 # form-based queries are limited to non-nested at a specific depth, so we can easily
741 # modify the incoming query operands and indexes to do stemming and field weighting
742 # Once we do so, we'll end up with a value in $query, just like if we had an
743 # incoming $query from the user
744     else {
745         $query = ""; # clear it out so we can populate properly with field-weighted stemmed query
746         my $previous_operand;    # a flag used to keep track if there was a previous query
747                                                 # if there was, we can apply the current operator
748                 # for every operand
749         for ( my $i = 0 ; $i <= @operands ; $i++ ) {
750
751                         # COMBINE OPERANDS, INDEXES AND OPERATORS
752                         if ( $operands[$i] ) {
753                 my $operand = $operands[$i];
754                 my $index   = $indexes[$i];
755                                 # if there's no index, don't use one, it will throw a CCL error
756                                 my $index_plus; $index_plus = "$index:" if $index;
757                                 my $index_plus_comma; $index_plus_comma="$index_plus," if $index;
758
759                                 # Remove Stopwords      
760                                 $operand = _remove_stopwords($operand,$index);
761
762                                 # Handle Truncation
763                                 my ($nontruncated,$righttruncated,$lefttruncated,$rightlefttruncated,$regexpr);
764                                 ($nontruncated,$righttruncated,$lefttruncated,$rightlefttruncated,$regexpr) = _add_truncation($operand,$index);
765                                 #warn "TRUNCATION: NON:@$nontruncated RIGHT:@$righttruncated LEFT:@$lefttruncated RIGHTLEFT:@$rightlefttruncated REGEX:@$regexpr";
766
767                                 # Handle Stemming
768                         my $stemmed_operand;
769                                 $stemmed_operand = _build_stemmed_operand($operand) if $stemming;
770
771                                 # Handle Field Weighting
772                                 my $weighted_operand;
773                 $weighted_operand = _build_weighted_query($operand,$stemmed_operand,$index) if $weight_fields;
774
775                                 # proves we're operating in multi-leaf mode
776                                 # $weighted_operand = "$weighted_operand and $weighted_operand";
777                                 $operand = $weighted_operand if $weight_fields;
778
779                 # If there's a previous operand, we need to add an operator
780                 if ($previous_operand) {
781                     if ( $operators[ $i - 1 ] ) {
782                                                 $human_search_desc .="  $operators[$i-1] $index_plus $operands[$i]";
783                                                 $query .= " $operators[$i-1] $index_plus $operand";
784                     }
785                     # the default operator is and
786                     else {
787                         $query             .= " and $index_plus $operand";
788                         $human_search_desc .= " and $index_plus $operands[$i]";
789                     }
790                 }
791                                 # There's no previous operand - FIXME: completely ignoring our $query, no field weighting, no stemming
792                                 # FIXME: also, doesn't preserve original order
793                 else { 
794                                         # if there are terms to fit with truncation
795                                         if (scalar(@$righttruncated)+scalar(@$lefttruncated)+scalar(@$rightlefttruncated)>0){
796                                                 # add the non-truncated ones first
797                                                 $query.= "$index_plus @$nontruncated " if (scalar(@$nontruncated)>0);
798                                                 if (scalar(@$righttruncated)>0){
799                                                         $query .= "and $index_plus_comma"."rtrn:@$righttruncated ";
800                                                 }            
801                                                 if (scalar(@$lefttruncated)>0){
802                                                         $query .= "and $index_plus_comma"."ltrn:@$lefttruncated ";
803                                                 }            
804                                                 if (scalar(@$rightlefttruncated)>0){
805                                                         $query .= "and $index_plus_comma"."rltrn:@$rightlefttruncated ";
806                                                 }
807                         $query=~s/^and//; # FIXME: this is cheating :-)
808                         $human_search_desc .= $query;
809                                         } else {           
810                         $query             .= " $index_plus $operand";
811                         $human_search_desc .= "  $index_plus $operands[$i]";
812                                         }            
813                     $previous_operand = 1;
814                 }
815             }    #/if $operands
816         }    # /for
817     }
818
819     # add limits
820     my $limit_query;
821     my $limit_search_desc;
822     foreach my $limit (@limits) {
823
824         # FIXME: not quite right yet ... will work on this soon -- JF
825         my $type = $1 if $limit =~ m/([^:]+):([^:]*)/;
826         if ( $limit =~ /available/ ) {
827             $limit_query .= " (($query and datedue=0000-00-00) or ($query and datedue=0000-00-00 not lost=1) or ($query and datedue=0000-00-00 not lost=2))";
828             #$limit_search_desc.=" and available";
829         }
830         elsif ( ($limit_query) && ( index( $limit_query, $type, 0 ) > 0 ) ) {
831             if ( $limit_query !~ /\(/ ) {
832                 $limit_query =
833                     substr( $limit_query, 0, index( $limit_query, $type, 0 ) )
834                   . "("
835                   . substr( $limit_query, index( $limit_query, $type, 0 ) )
836                   . " or $limit )"
837                   if $limit;
838                 $limit_search_desc =
839                   substr( $limit_search_desc, 0,
840                     index( $limit_search_desc, $type, 0 ) )
841                   . "("
842                   . substr( $limit_search_desc,
843                     index( $limit_search_desc, $type, 0 ) )
844                   . " or $limit )"
845                   if $limit;
846             }
847             else {
848                 chop $limit_query;
849                 chop $limit_search_desc;
850                 $limit_query       .= " or $limit )" if $limit;
851                 $limit_search_desc .= " or $limit )" if $limit;
852             }
853         }
854         elsif ( ($limit_query) && ( $limit =~ /mc/ ) ) {
855             $limit_query       .= " or $limit" if $limit;
856             $limit_search_desc .= " or $limit" if $limit;
857         }
858
859         # these are treated as AND
860         elsif ($limit_query) {
861            if ($limit =~ /branch/){
862                         $limit_query       .= " ) and ( $limit" if $limit;
863                         $limit_search_desc .= " ) and ( $limit" if $limit;
864                 }else{
865                         $limit_query       .= " or $limit" if $limit;
866                         $limit_search_desc .= " or $limit" if $limit;
867                 }
868         }
869
870         # otherwise, there is nothing but the limit
871         else {
872             $limit_query       .= "$limit" if $limit;
873             $limit_search_desc .= "$limit" if $limit;
874         }
875     }
876
877     # if there's also a query, we need to AND the limits to it
878     if ( ($limit_query) && ($query) ) {
879         $limit_query       = " and (" . $limit_query . ")";
880         $limit_search_desc = " and ($limit_search_desc)" if $limit_search_desc;
881
882     }
883         #warn "LIMIT: $limit_query";
884     $query             .= $limit_query;
885     $human_search_desc .= $limit_search_desc;
886
887     # now normalize the strings
888     $query =~ s/  / /g;    # remove extra spaces
889     $query =~ s/^ //g;     # remove any beginning spaces
890     $query =~ s/:/=/g;     # causes probs for server
891     $query =~ s/==/=/g;    # remove double == from query
892
893     my $federated_query = $human_search_desc;
894     $federated_query =~ s/  / /g;
895     $federated_query =~ s/^ //g;
896     $federated_query =~ s/:/=/g;
897     my $federated_query_opensearch = $federated_query;
898
899 #     my $federated_query_RPN = new ZOOM::Query::CCL2RPN( $query , C4::Context->ZConn('biblioserver'));
900
901     $human_search_desc =~ s/  / /g;
902     $human_search_desc =~ s/^ //g;
903     my $koha_query = $query;
904
905     #warn "QUERY:".$koha_query;
906     #warn "SEARCHDESC:".$human_search_desc;
907     #warn "FEDERATED QUERY:".$federated_query;
908     return ( undef, $human_search_desc, $koha_query, $federated_query );
909 }
910
911 # IMO this subroutine is pretty messy still -- it's responsible for
912 # building the HTML output for the template
913 sub searchResults {
914     my ( $searchdesc, $hits, $results_per_page, $offset, @marcresults ) = @_;
915
916     my $dbh = C4::Context->dbh;
917     my $toggle;
918     my $even = 1;
919     my @newresults;
920     my $span_terms_hashref;
921     for my $span_term ( split( / /, $searchdesc ) ) {
922         $span_term =~ s/(.*=|\)|\(|\+|\.)//g;
923         $span_terms_hashref->{$span_term}++;
924     }
925
926     #Build brancnames hash
927     #find branchname
928     #get branch information.....
929     my %branches;
930     my $bsth =
931       $dbh->prepare("SELECT branchcode,branchname FROM branches")
932       ;    # FIXME : use C4::Koha::GetBranches
933     $bsth->execute();
934     while ( my $bdata = $bsth->fetchrow_hashref ) {
935         $branches{ $bdata->{'branchcode'} } = $bdata->{'branchname'};
936     }
937
938     #Build itemtype hash
939     #find itemtype & itemtype image
940     my %itemtypes;
941     $bsth =
942       $dbh->prepare("SELECT itemtype,description,imageurl,summary,notforloan FROM itemtypes");
943     $bsth->execute();
944     while ( my $bdata = $bsth->fetchrow_hashref ) {
945         $itemtypes{ $bdata->{'itemtype'} }->{description} =
946           $bdata->{'description'};
947         $itemtypes{ $bdata->{'itemtype'} }->{imageurl} = $bdata->{'imageurl'};
948         $itemtypes{ $bdata->{'itemtype'} }->{summary} = $bdata->{'summary'};
949         $itemtypes{ $bdata->{'itemtype'} }->{notforloan} = $bdata->{'notforloan'};
950     }
951
952     #search item field code
953     my $sth =
954       $dbh->prepare(
955 "select tagfield from marc_subfield_structure where kohafield like 'items.itemnumber'"
956       );
957     $sth->execute;
958     my ($itemtag) = $sth->fetchrow;
959
960     ## find column names of items related to MARC
961     my $sth2 = $dbh->prepare("SHOW COLUMNS from items");
962     $sth2->execute;
963     my %subfieldstosearch;
964     while ( ( my $column ) = $sth2->fetchrow ) {
965         my ( $tagfield, $tagsubfield ) =
966           &GetMarcFromKohaField( "items." . $column, "" );
967         $subfieldstosearch{$column} = $tagsubfield;
968     }
969     my $times;
970
971     if ( $hits && $offset + $results_per_page <= $hits ) {
972         $times = $offset + $results_per_page;
973     }
974     else {
975         $times = $hits;
976     }
977
978     for ( my $i = $offset ; $i <= $times - 1 ; $i++ ) {
979         my $marcrecord;
980         $marcrecord = MARC::File::USMARC::decode( $marcresults[$i] );
981         my $oldbiblio = TransformMarcToKoha( $dbh, $marcrecord, '' );
982         # add image url if there is one
983         if ( $itemtypes{ $oldbiblio->{itemtype} }->{imageurl} =~ /^http:/ ) {
984             $oldbiblio->{imageurl} =
985               $itemtypes{ $oldbiblio->{itemtype} }->{imageurl};
986             $oldbiblio->{description} =
987               $itemtypes{ $oldbiblio->{itemtype} }->{description};
988         }
989         else {
990             $oldbiblio->{imageurl} =
991               getitemtypeimagesrc() . "/"
992               . $itemtypes{ $oldbiblio->{itemtype} }->{imageurl}
993               if ( $itemtypes{ $oldbiblio->{itemtype} }->{imageurl} );
994             $oldbiblio->{description} =
995               $itemtypes{ $oldbiblio->{itemtype} }->{description};
996         }
997         #
998         # build summary if there is one (the summary is defined in itemtypes table
999         #
1000         if ($itemtypes{ $oldbiblio->{itemtype} }->{summary}) {
1001             my $summary = $itemtypes{ $oldbiblio->{itemtype} }->{summary};
1002             my @fields = $marcrecord->fields();
1003             foreach my $field (@fields) {
1004                 my $tag = $field->tag();
1005                 my $tagvalue = $field->as_string();
1006                 $summary =~ s/\[(.?.?.?.?)$tag\*(.*?)]/$1$tagvalue$2\[$1$tag$2]/g;
1007                 unless ($tag<10) {
1008                     my @subf = $field->subfields;
1009                     for my $i (0..$#subf) {
1010                         my $subfieldcode = $subf[$i][0];
1011                         my $subfieldvalue = $subf[$i][1];
1012                         my $tagsubf = $tag.$subfieldcode;
1013                         $summary =~ s/\[(.?.?.?.?)$tagsubf(.*?)]/$1$subfieldvalue$2\[$1$tagsubf$2]/g;
1014                     }
1015                 }
1016             }
1017             $summary =~ s/\[(.*?)]//g;
1018             $summary =~ s/\n/<br>/g;
1019             $oldbiblio->{summary} = $summary;
1020         }
1021         # add spans to search term in results for search term highlighting
1022         foreach my $term ( keys %$span_terms_hashref ) {
1023             my $old_term = $term;
1024             if ( length($term) > 3 ) {
1025                 $term =~ s/(.*=|\)|\(|\+|\.|\?|\[|\])//g;
1026                                 $term =~ s/\\//g;
1027                                 $term =~ s/\*//g;
1028
1029                 #FIXME: is there a better way to do this?
1030                 $oldbiblio->{'title'} =~ s/$term/<span class=term>$&<\/span>/gi;
1031                 $oldbiblio->{'subtitle'} =~
1032                   s/$term/<span class=term>$&<\/span>/gi;
1033
1034                 $oldbiblio->{'author'} =~ s/$term/<span class=term>$&<\/span>/gi;
1035                 $oldbiblio->{'publishercode'} =~ s/$term/<span class=term>$&<\/span>/gi;
1036                 $oldbiblio->{'place'} =~ s/$term/<span class=term>$&<\/span>/gi;
1037                 $oldbiblio->{'pages'} =~ s/$term/<span class=term>$&<\/span>/gi;
1038                 $oldbiblio->{'notes'} =~ s/$term/<span class=term>$&<\/span>/gi;
1039                 $oldbiblio->{'size'}  =~ s/$term/<span class=term>$&<\/span>/gi;
1040             }
1041         }
1042
1043         if ( $i % 2 ) {
1044             $toggle = "#ffffcc";
1045         }
1046         else {
1047             $toggle = "white";
1048         }
1049         $oldbiblio->{'toggle'} = $toggle;
1050         my @fields = $marcrecord->field($itemtag);
1051         my @items_loop;
1052         my $items;
1053         my $ordered_count     = 0;
1054         my $onloan_count      = 0;
1055         my $wthdrawn_count    = 0;
1056         my $itemlost_count    = 0;
1057         my $norequests        = 1;
1058
1059         #
1060         # check the loan status of the item : 
1061         # it is not stored in the MARC record, for pref (zebra reindexing)
1062         # reason. Thus, we have to get the status from a specific SQL query
1063         #
1064         my $sth_issue = $dbh->prepare("
1065             SELECT date_due,returndate 
1066             FROM issues 
1067             WHERE itemnumber=? AND returndate IS NULL");
1068         my $items_count=scalar(@fields);
1069         foreach my $field (@fields) {
1070             my $item;
1071             foreach my $code ( keys %subfieldstosearch ) {
1072                 $item->{$code} = $field->subfield( $subfieldstosearch{$code} );
1073             }
1074             $sth_issue->execute($item->{itemnumber});
1075             $item->{due_date} = format_date($sth_issue->fetchrow);
1076             $item->{onloan} = 1 if $item->{due_date};
1077             # at least one item can be reserved : suppose no
1078             $norequests = 1;
1079             if ( $item->{wthdrawn} ) {
1080                 $wthdrawn_count++;
1081                 $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{unavailable}=1;
1082                 $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{wthdrawn}=1;
1083             }
1084             elsif ( $item->{itemlost} ) {
1085                 $itemlost_count++;
1086                 $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{unavailable}=1;
1087                 $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{itemlost}=1;
1088             }
1089             unless ( $item->{notforloan}) {
1090                 # OK, this one can be issued, so at least one can be reserved
1091                 $norequests = 0;
1092             }
1093             if ( ( $item->{onloan} ) && ( $item->{onloan} != '0000-00-00' ) )
1094             {
1095                 $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{unavailable}=1;
1096                 $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{onloancount} = 1;
1097                 $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{due_date} = $item->{due_date};
1098                 $onloan_count++;
1099             }
1100             if ( $item->{'homebranch'} ) {
1101                 $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{count}++;
1102             }
1103
1104             # Last resort
1105             elsif ( $item->{'holdingbranch'} ) {
1106                 $items->{ $item->{'holdingbranch'} }->{count}++;
1107             }
1108             $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{itemcallnumber} =                $item->{itemcallnumber};
1109             $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{location} =                $item->{location};
1110             $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{branchcode} =               $item->{homebranch};
1111         }    # notforloan, item level and biblioitem level
1112
1113         # last check for norequest : if itemtype is notforloan, it can't be reserved either, whatever the items
1114         $norequests = 1 if $itemtypes{$oldbiblio->{itemtype}}->{notforloan};
1115
1116         for my $key ( sort keys %$items ) {
1117             my $this_item = {
1118                 branchname     => $branches{$items->{$key}->{branchcode}},
1119                 branchcode     => $items->{$key}->{branchcode},
1120                 count          => $items->{$key}->{count},
1121                 itemcallnumber => $items->{$key}->{itemcallnumber},
1122                 location => $items->{$key}->{location},
1123                 onloancount      => $items->{$key}->{onloancount},
1124                 due_date         => $items->{$key}->{due_date},
1125                 wthdrawn      => $items->{$key}->{wthdrawn},
1126                 lost         => $items->{$key}->{itemlost},
1127             };
1128             push @items_loop, $this_item;
1129         }
1130         $oldbiblio->{norequests}    = $norequests;
1131         $oldbiblio->{items_count}    = $items_count;
1132         $oldbiblio->{items_loop}    = \@items_loop;
1133         $oldbiblio->{onloancount}   = $onloan_count;
1134         $oldbiblio->{wthdrawncount} = $wthdrawn_count;
1135         $oldbiblio->{itemlostcount} = $itemlost_count;
1136         $oldbiblio->{orderedcount}  = $ordered_count;
1137         $oldbiblio->{isbn}          =~ s/-//g; # deleting - in isbn to enable amazon content 
1138         push( @newresults, $oldbiblio );
1139     }
1140     return @newresults;
1141 }
1142
1143
1144
1145 #----------------------------------------------------------------------
1146 #
1147 # Non-Zebra GetRecords#
1148 #----------------------------------------------------------------------
1149
1150 =head2 NZgetRecords
1151
1152   NZgetRecords has the same API as zera getRecords, even if some parameters are not managed
1153
1154 =cut
1155
1156 sub NZgetRecords {
1157     my (
1158         $koha_query,     $federated_query,  $sort_by_ref,
1159         $servers_ref,    $results_per_page, $offset,
1160         $expanded_facet, $branches,         $query_type,
1161         $scan
1162     ) = @_;
1163     my $result = NZanalyse($koha_query);
1164     return (undef,NZorder($result,@$sort_by_ref[0],$results_per_page,$offset),undef);
1165 }
1166
1167 =head2 NZanalyse
1168
1169   NZanalyse : get a CQL string as parameter, and returns a list of biblionumber;title,biblionumber;title,...
1170   the list is builded from inverted index in nozebra SQL table
1171   note that title is here only for convenience : the sorting will be very fast when requested on title
1172   if the sorting is requested on something else, we will have to reread all results, and that may be longer.
1173
1174 =cut
1175
1176 sub NZanalyse {
1177     my ($string,$server) = @_;
1178     # $server contains biblioserver or authorities, depending on what we search on.
1179     #warn "querying : $string on $server";
1180     $server='biblioserver' unless $server;
1181     # if we have a ", replace the content to discard temporarily any and/or/not inside
1182     my $commacontent;
1183     if ($string =~/"/) {
1184         $string =~ s/"(.*?)"/__X__/;
1185         $commacontent = $1;
1186 #         print "commacontent : $commacontent\n";
1187     }
1188     # split the query string in 3 parts : X AND Y means : $left="X", $operand="AND" and $right="Y"
1189     # then, call again NZanalyse with $left and $right
1190     # (recursive until we find a leaf (=> something without and/or/not)
1191     $string =~ /(.*)( and | or | not | AND | OR | NOT )(.*)/;
1192     my $left = $1;
1193     my $right = $3;
1194     my $operand = lc($2);
1195     # it's not a leaf, we have a and/or/not
1196     if ($operand) {
1197         # reintroduce comma content if needed
1198         $right =~ s/__X__/"$commacontent"/ if $commacontent;
1199         $left =~ s/__X__/"$commacontent"/ if $commacontent;
1200 #         warn "node : $left / $operand / $right\n";
1201         my $leftresult = NZanalyse($left,$server);
1202         my $rightresult = NZanalyse($right,$server);
1203         # OK, we have the results for right and left part of the query
1204         # depending of operand, intersect, union or exclude both lists
1205         # to get a result list
1206         if ($operand eq ' and ') {
1207             my @leftresult = split /;/, $leftresult;
1208 #             my @rightresult = split /;/,$leftresult;
1209             my $finalresult;
1210             # parse the left results, and if the biblionumber exist in the right result, save it in finalresult
1211             # the result is stored twice, to have the same weight for AND than OR.
1212             # example : TWO : 61,61,64,121 (two is twice in the biblio #61) / TOWER : 61,64,130
1213             # result : 61,61,61,61,64,64 for two AND tower : 61 has more weight than 64
1214             foreach (@leftresult) {
1215                 if ($rightresult =~ "$_;") {
1216                     $finalresult .= "$_;$_;";
1217                 }
1218             }
1219             return $finalresult;
1220         } elsif ($operand eq ' or ') {
1221             # just merge the 2 strings
1222             return $leftresult.$rightresult;
1223         } elsif ($operand eq ' not ') {
1224             my @leftresult = split /;/, $leftresult;
1225 #             my @rightresult = split /;/,$leftresult;
1226             my $finalresult;
1227             foreach (@leftresult) {
1228                 unless ($rightresult =~ "$_;") {
1229                     $finalresult .= "$_;";
1230                 }
1231             }
1232             return $finalresult;
1233         } else {
1234             # this error is impossible, because of the regexp that isolate the operand, but just in case...
1235             die "error : operand unknown : $operand for $string";
1236         }
1237     # it's a leaf, do the real SQL query and return the result
1238     } else {
1239         $string =~  s/__X__/"$commacontent"/ if $commacontent;
1240         $string =~ s/-|\.|\?|,|;|!|'|\(|\)|\[|\]|{|}|"|<|>|&|\+|\*|\// /g;
1241 #         warn "leaf : $string\n";
1242         # parse the string in in operator/operand/value again
1243         $string =~ /(.*)(=|>|>=|<|<=)(.*)/;
1244         my $left = $1;
1245         my $operator = $2;
1246         my $right = $3;
1247         my $results;
1248         # automatic replace for short operators
1249         $left='title' if $left eq 'ti';
1250         $left='author' if $left eq 'au';
1251         $left='publisher' if $left eq 'pb';
1252         $left='subject' if $left eq 'su';
1253         $left='koha-Auth-Number' if $left eq 'an';
1254         $left='keyword' if $left eq 'kw';
1255         if ($operator) {
1256             #do a specific search
1257             my $dbh = C4::Context->dbh;
1258             $operator='LIKE' if $operator eq '=' and $right=~ /%/;
1259             my $sth = $dbh->prepare("SELECT biblionumbers FROM nozebra WHERE server=? AND indexname=? AND value $operator ?");
1260             # warn "$left / $operator / $right\n";
1261             # split each word, query the DB and build the biblionumbers result
1262             foreach (split / /,$right) {
1263                 my $biblionumbers;
1264                 next unless $_;
1265 #                 warn "EXECUTE : $server, $left, $_";
1266                 $sth->execute($server, $left, $_);
1267                 while (my $line = $sth->fetchrow) {
1268                     $biblionumbers .= $line;
1269 #                     warn "result : $line";
1270                 }
1271                 # do a AND with existing list if there is one, otherwise, use the biblionumbers list as 1st result list
1272                 if ($results) {
1273                     my @leftresult = split /;/, $biblionumbers;
1274                     my $temp;
1275                     foreach (@leftresult) {
1276                         if ($results =~ "$_;") {
1277                             $temp .= "$_;$_;";
1278                         }
1279                     }
1280                     $results = $temp;
1281                 } else {
1282                     $results = $biblionumbers;
1283                 }
1284             }
1285         } else {
1286             #do a complete search (all indexes)
1287             my $dbh = C4::Context->dbh;
1288             my $sth = $dbh->prepare("SELECT biblionumbers FROM nozebra WHERE server=? AND value LIKE ?");
1289             # split each word, query the DB and build the biblionumbers result
1290             foreach (split / /,$string) {
1291                 next if C4::Context->stopwords->{uc($_)}; # skip if stopword
1292                 #warn "search on all indexes on $_";
1293                 my $biblionumbers;
1294                 next unless $_;
1295                 $sth->execute($server, $_);
1296                 while (my $line = $sth->fetchrow) {
1297                     $biblionumbers .= $line;
1298                 }
1299                 # do a AND with existing list if there is one, otherwise, use the biblionumbers list as 1st result list
1300                 if ($results) {
1301                     my @leftresult = split /;/, $biblionumbers;
1302                     my $temp;
1303                     foreach (@leftresult) {
1304                         if ($results =~ "$_;") {
1305                             $temp .= "$_;$_;";
1306                         }
1307                     }
1308                     $results = $temp;
1309                 } else {
1310                     $results = $biblionumbers;
1311                 }
1312             }
1313         }
1314 #         warn "return : $results for LEAF : $string";
1315         return $results;
1316     }
1317 }
1318
1319 =head2 NZorder
1320
1321   $finalresult = NZorder($biblionumbers, $ordering,$results_per_page,$offset);
1322   
1323   TODO :: Description
1324
1325 =cut
1326
1327
1328 sub NZorder {
1329     my ($biblionumbers, $ordering,$results_per_page,$offset) = @_;
1330     # order title asc by default
1331 #     $ordering = '1=36 <i' unless $ordering;
1332     $results_per_page=20 unless $results_per_page;
1333     $offset = 0 unless $offset;
1334     my $dbh = C4::Context->dbh;
1335     #
1336     # order by POPULARITY
1337     #
1338     if ($ordering =~ /1=9523/) {
1339         my %result;
1340         my %popularity;
1341         # popularity is not in MARC record, it's builded from a specific query
1342         my $sth = $dbh->prepare("select sum(issues) from items where biblionumber=?");
1343         foreach (split /;/,$biblionumbers) {
1344             my ($biblionumber,$title) = split /,/,$_;
1345             $result{$biblionumber}=GetMarcBiblio($biblionumber);
1346             $sth->execute($biblionumber);
1347             my $popularity= $sth->fetchrow ||0;
1348             # hint : the key is popularity.title because we can have
1349             # many results with the same popularity. In this cas, sub-ordering is done by title
1350             # we also have biblionumber to avoid bug for 2 biblios with the same title & popularity
1351             # (un-frequent, I agree, but we won't forget anything that way ;-)
1352             $popularity{sprintf("%10d",$popularity).$title.$biblionumber} = $biblionumber;
1353         }
1354         # sort the hash and return the same structure as GetRecords (Zebra querying)
1355         my $result_hash;
1356         my $numbers=0;
1357         if ($ordering eq '1=9523 >i') { # sort popularity DESC
1358             foreach my $key (sort {$b cmp $a} (keys %popularity)) {
1359                 $result_hash->{'RECORDS'}[$numbers++] = $result{$popularity{$key}}->as_usmarc();
1360             }
1361         } else { # sort popularity ASC
1362             foreach my $key (sort (keys %popularity)) {
1363                 $result_hash->{'RECORDS'}[$numbers++] = $result{$popularity{$key}}->as_usmarc();
1364             }
1365         }
1366         my $finalresult=();
1367         $result_hash->{'hits'} = $numbers;
1368         $finalresult->{'biblioserver'} = $result_hash;
1369         return $finalresult;
1370     #
1371     # ORDER BY author
1372     #
1373     } elsif ($ordering eq '1=1003 <i'){
1374         my %result;
1375         foreach (split /;/,$biblionumbers) {
1376             my ($biblionumber,$title) = split /,/,$_;
1377             my $record=GetMarcBiblio($biblionumber);
1378             my $author;
1379             if (C4::Context->preference('marcflavour') eq 'UNIMARC') {
1380                 $author=$record->subfield('200','f');
1381                 $author=$record->subfield('700','a') unless $author;
1382             } else {
1383                 $author=$record->subfield('100','a');
1384             }
1385             # hint : the result is sorted by title.biblionumber because we can have X biblios with the same title
1386             # and we don't want to get only 1 result for each of them !!!
1387             $result{$author.$biblionumber}=$record;
1388         }
1389         # sort the hash and return the same structure as GetRecords (Zebra querying)
1390         my $result_hash;
1391         my $numbers=0;
1392         if ($ordering eq '1=1003 <i') { # sort by author desc
1393             foreach my $key (sort (keys %result)) {
1394                 $result_hash->{'RECORDS'}[$numbers++] = $result{$key}->as_usmarc();
1395             }
1396         } else { # sort by author ASC
1397             foreach my $key (sort { $a cmp $b } (keys %result)) {
1398                 $result_hash->{'RECORDS'}[$numbers++] = $result{$key}->as_usmarc();
1399             }
1400         }
1401         my $finalresult=();
1402         $result_hash->{'hits'} = $numbers;
1403         $finalresult->{'biblioserver'} = $result_hash;
1404         return $finalresult;
1405     #
1406     # ORDER BY callnumber
1407     #
1408     } elsif ($ordering eq '1=20 <i'){
1409         my %result;
1410         foreach (split /;/,$biblionumbers) {
1411             my ($biblionumber,$title) = split /,/,$_;
1412             my $record=GetMarcBiblio($biblionumber);
1413             my $callnumber;
1414             my ($callnumber_tag,$callnumber_subfield)=GetMarcFromKohaField($dbh,'items.itemcallnumber');
1415             ($callnumber_tag,$callnumber_subfield)= GetMarcFromKohaField('biblioitems.callnumber') unless $callnumber_tag;
1416             if (C4::Context->preference('marcflavour') eq 'UNIMARC') {
1417                 $callnumber=$record->subfield('200','f');
1418             } else {
1419                 $callnumber=$record->subfield('100','a');
1420             }
1421             # hint : the result is sorted by title.biblionumber because we can have X biblios with the same title
1422             # and we don't want to get only 1 result for each of them !!!
1423             $result{$callnumber.$biblionumber}=$record;
1424         }
1425         # sort the hash and return the same structure as GetRecords (Zebra querying)
1426         my $result_hash;
1427         my $numbers=0;
1428         if ($ordering eq '1=1003 <i') { # sort by title desc
1429             foreach my $key (sort (keys %result)) {
1430                 $result_hash->{'RECORDS'}[$numbers++] = $result{$key}->as_usmarc();
1431             }
1432         } else { # sort by title ASC
1433             foreach my $key (sort { $a cmp $b } (keys %result)) {
1434                 $result_hash->{'RECORDS'}[$numbers++] = $result{$key}->as_usmarc();
1435             }
1436         }
1437         my $finalresult=();
1438         $result_hash->{'hits'} = $numbers;
1439         $finalresult->{'biblioserver'} = $result_hash;
1440         return $finalresult;
1441     } elsif ($ordering =~ /1=31/){ #pub year
1442         my %result;
1443         foreach (split /;/,$biblionumbers) {
1444             my ($biblionumber,$title) = split /,/,$_;
1445             my $record=GetMarcBiblio($biblionumber);
1446             my ($publicationyear_tag,$publicationyear_subfield)=GetMarcFromKohaField($dbh,'biblioitems.publicationyear');
1447             my $publicationyear=$record->subfield($publicationyear_tag,$publicationyear_subfield);
1448             # hint : the result is sorted by title.biblionumber because we can have X biblios with the same title
1449             # and we don't want to get only 1 result for each of them !!!
1450             $result{$publicationyear.$biblionumber}=$record;
1451         }
1452         # sort the hash and return the same structure as GetRecords (Zebra querying)
1453         my $result_hash;
1454         my $numbers=0;
1455         if ($ordering eq '1=31 <i') { # sort by pubyear desc
1456             foreach my $key (sort (keys %result)) {
1457                 $result_hash->{'RECORDS'}[$numbers++] = $result{$key}->as_usmarc();
1458             }
1459         } else { # sort by pub year ASC
1460             foreach my $key (sort { $b cmp $a } (keys %result)) {
1461                 $result_hash->{'RECORDS'}[$numbers++] = $result{$key}->as_usmarc();
1462             }
1463         }
1464         my $finalresult=();
1465         $result_hash->{'hits'} = $numbers;
1466         $finalresult->{'biblioserver'} = $result_hash;
1467         return $finalresult;
1468     #
1469     # ORDER BY title
1470     #
1471     } elsif ($ordering =~ /1=4/) { 
1472         # the title is in the biblionumbers string, so we just need to build a hash, sort it and return
1473         my %result;
1474         foreach (split /;/,$biblionumbers) {
1475             my ($biblionumber,$title) = split /,/,$_;
1476             # hint : the result is sorted by title.biblionumber because we can have X biblios with the same title
1477             # and we don't want to get only 1 result for each of them !!!
1478             # hint & speed improvement : we can order without reading the record
1479             # so order, and read records only for the requested page !
1480             $result{$title.$biblionumber}=$biblionumber;
1481         }
1482         # sort the hash and return the same structure as GetRecords (Zebra querying)
1483         my $result_hash;
1484         my $numbers=0;
1485         if ($ordering eq '1=4 <i') { # sort by title desc
1486             foreach my $key (sort (keys %result)) {
1487                 $result_hash->{'RECORDS'}[$numbers++] = $result{$key};
1488             }
1489         } else { # sort by title ASC
1490             foreach my $key (sort { $b cmp $a } (keys %result)) {
1491                 $result_hash->{'RECORDS'}[$numbers++] = $result{$key};
1492             }
1493         }
1494         # limit the $results_per_page to result size if it's more
1495         $results_per_page = $numbers-1 if $numbers < $results_per_page;
1496         # for the requested page, replace biblionumber by the complete record
1497         # speed improvement : avoid reading too much things
1498         for (my $counter=$offset;$counter<=$offset+$results_per_page;$counter++) {
1499             $result_hash->{'RECORDS'}[$counter] = GetMarcBiblio($result_hash->{'RECORDS'}[$counter])->as_usmarc;
1500         }
1501         my $finalresult=();
1502         $result_hash->{'hits'} = $numbers;
1503         $finalresult->{'biblioserver'} = $result_hash;
1504         return $finalresult;
1505     } else {
1506     #
1507     # order by ranking
1508     #
1509         # we need 2 hashes to order by ranking : the 1st one to count the ranking, the 2nd to order by ranking
1510         my %result;
1511         my %count_ranking;
1512         foreach (split /;/,$biblionumbers) {
1513             my ($biblionumber,$title) = split /,/,$_;
1514             $title =~ /(.*)-(\d)/;
1515             # get weight 
1516             my $ranking =$2;
1517             # note that we + the ranking because ranking is calculated on weight of EACH term requested.
1518             # if we ask for "two towers", and "two" has weight 2 in biblio N, and "towers" has weight 4 in biblio N
1519             # biblio N has ranking = 6
1520             $count_ranking{$biblionumber} += $ranking;
1521         }
1522         # build the result by "inverting" the count_ranking hash
1523         # hing : as usual, we don't order by ranking only, to avoid having only 1 result for each rank. We build an hash on concat(ranking,biblionumber) instead
1524 #         warn "counting";
1525         foreach (keys %count_ranking) {
1526             $result{sprintf("%10d",$count_ranking{$_}).'-'.$_} = $_;
1527         }
1528         # sort the hash and return the same structure as GetRecords (Zebra querying)
1529         my $result_hash;
1530         my $numbers=0;
1531             foreach my $key (sort {$b cmp $a} (keys %result)) {
1532                 $result_hash->{'RECORDS'}[$numbers++] = $result{$key};
1533             }
1534         # limit the $results_per_page to result size if it's more
1535         $results_per_page = $numbers-1 if $numbers < $results_per_page;
1536         # for the requested page, replace biblionumber by the complete record
1537         # speed improvement : avoid reading too much things
1538         for (my $counter=$offset;$counter<=$offset+$results_per_page;$counter++) {
1539             $result_hash->{'RECORDS'}[$counter] = GetMarcBiblio($result_hash->{'RECORDS'}[$counter])->as_usmarc;
1540         }
1541         my $finalresult=();
1542         $result_hash->{'hits'} = $numbers;
1543         $finalresult->{'biblioserver'} = $result_hash;
1544         return $finalresult;
1545     }
1546 }
1547 =head2 ModBiblios
1548
1549 ($countchanged,$listunchanged) = ModBiblios($listbiblios, $tagsubfield,$initvalue,$targetvalue,$test);
1550
1551 this function changes all the values $initvalue in subfield $tag$subfield in any record in $listbiblios
1552 test parameter if set donot perform change to records in database.
1553
1554 =over 2
1555
1556 =item C<input arg:>
1557
1558     * $listbiblios is an array ref to marcrecords to be changed
1559     * $tagsubfield is the reference of the subfield to change.
1560     * $initvalue is the value to search the record for
1561     * $targetvalue is the value to set the subfield to
1562     * $test is to be set only not to perform changes in database.
1563
1564 =item C<Output arg:>
1565     * $countchanged counts all the changes performed.
1566     * $listunchanged contains the list of all the biblionumbers of records unchanged.
1567
1568 =item C<usage in the script:>
1569
1570 =back
1571
1572 my ($countchanged, $listunchanged) = EditBiblios($results->{RECORD}, $tagsubfield,$initvalue,$targetvalue);;
1573 #If one wants to display unchanged records, you should get biblios foreach @$listunchanged 
1574 $template->param(countchanged => $countchanged, loopunchanged=>$listunchanged);
1575
1576 =cut
1577
1578 sub ModBiblios{
1579   my ($listbiblios,$tagsubfield,$initvalue,$targetvalue,$test)=@_;
1580   my $countmatched;
1581   my @unmatched;
1582   my ($tag,$subfield)=($1,$2) if ($tagsubfield=~/^(\d{1,3})([a-z0-9A-Z@])?$/); 
1583   if ((length($tag)<3)&& $subfield=~/0-9/){
1584     $tag=$tag.$subfield;
1585     undef $subfield;
1586   } 
1587   my ($bntag,$bnsubf) = GetMarcFromKohaField('biblio.biblionumber');
1588   my ($itemtag,$itemsubf) = GetMarcFromKohaField('items.itemnumber');
1589   foreach my $usmarc (@$listbiblios){
1590     my $record; 
1591     $record=eval{MARC::Record->new_from_usmarc($usmarc)};
1592     my $biblionumber;
1593     if ($@){
1594       # usmarc is not a valid usmarc May be a biblionumber
1595       if ($tag eq $itemtag){
1596         my $bib=GetBiblioFromItemNumber($usmarc);   
1597         $record=GetMarcItem($bib->{'biblionumber'},$usmarc) ;   
1598         $biblionumber=$bib->{'biblionumber'};
1599       } else {   
1600         $record=GetMarcBiblio($usmarc);   
1601         $biblionumber=$usmarc;
1602       }   
1603     }  else {
1604       if ($bntag >= 010){
1605         $biblionumber = $record->subfield($bntag,$bnsubf);
1606       }else {
1607         $biblionumber=$record->field($bntag)->data;
1608       }
1609     }  
1610     #GetBiblionumber is to be written.
1611     #Could be replaced by TransformMarcToKoha (But Would be longer)
1612     if ($record->field($tag)){
1613       my $modify=0;  
1614       foreach my $field ($record->field($tag)){
1615         if ($subfield){
1616           if ($field->delete_subfield('code' =>$subfield,'match'=>qr($initvalue))){
1617             $countmatched++;
1618             $modify=1;      
1619             $field->update($subfield,$targetvalue) if ($targetvalue);
1620           }
1621         } else {
1622           if ($tag >= 010){
1623             if ($field->delete_field($field)){
1624               $countmatched++;
1625               $modify=1;      
1626             }
1627           } else {
1628             $field->data=$targetvalue if ($field->data=~qr($initvalue));
1629           }     
1630         }    
1631       }
1632 #       warn $record->as_formatted;
1633       if ($modify){
1634         ModBiblio($record,$biblionumber,GetFrameworkCode($biblionumber)) unless ($test);
1635       } else {
1636         push @unmatched, $biblionumber;   
1637       }      
1638     } else {
1639       push @unmatched, $biblionumber;
1640     }
1641   }
1642   return ($countmatched,\@unmatched);
1643 }
1644
1645 END { }    # module clean-up code here (global destructor)
1646
1647 1;
1648 __END__
1649
1650 =head1 AUTHOR
1651
1652 Koha Developement team <info@koha.org>
1653
1654 =cut