dealing with >=, <= and date stuff in NoZebra
[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!~m/phr|ext/){
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       next if ($_ =~/(and|or|not)/); # don't remove operators 
569                         $operand=~ s/\P{IsAlpha}$_\P{IsAlpha}/ /i;
570                         $operand=~ s/^$_\P{IsAlpha}/ /i;
571                         $operand=~ s/\P{IsAlpha}$_$/ /i;
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                 $operand =~s/^ //g;
584                 my @wordlist= split (/\s/,$operand);
585                 foreach my $word (@wordlist){
586                         if ($word=~s/^\*([^\*]+)\*$/$1/){
587         push @rightlefttruncated,$word;
588                         } 
589                         elsif($word=~s/^\*([^\*]+)$/$1/){
590                                 push @lefttruncated,$word;
591                         
592                         } 
593                         elsif ($word=~s/^([^\*]+)\*$/$1/){
594                                 push @righttruncated,$word;
595                         } 
596                         elsif (index($word,"*")<0){
597                                 push @nontruncated,$word;
598                         }
599                         else {
600                                 push @regexpr,$word;
601                         
602                         }
603                 }
604         #}
605         return (\@nontruncated,\@righttruncated,\@lefttruncated,\@rightlefttruncated,\@regexpr);
606 }
607
608 sub _build_stemmed_operand {
609         my ($operand) = @_;
610         my $stemmed_operand;
611         #$operand =~ s/^(and |or |not )//i;
612         # STEMMING FIXME: may need to refine the field weighting so stemmed operands don't 
613         # disrupt the query ranking, this needs more testing
614         # FIXME: the locale should be set based on the user's language and/or search choice
615         my $stemmer = Lingua::Stem->new( -locale => 'EN-US' );
616         # FIXME: these should be stored in the db so the librarian can modify the behavior
617         $stemmer->add_exceptions(
618                         {
619                                 'and' => 'and',
620                 'or'  => 'or',
621                 'not' => 'not',
622                         }
623                     
624                 );
625         my @words = split( / /, $operand );
626         my $stems = $stemmer->stem(@words);
627         foreach my $stem (@$stems) {
628                         $stemmed_operand .= "$stem";
629                         $stemmed_operand .= "?" unless ( $stem =~ /(and$|or$|not$)/ ) || ( length($stem) < 3 );
630                         $stemmed_operand .= " ";
631         }
632         #warn "STEMMED OPERAND: $stemmed_operand";
633         return $stemmed_operand;
634 }
635
636 sub _build_weighted_query {
637         # FIELD WEIGHTING - This is largely experimental stuff. What I'm committing works
638         # pretty well but will work much better when we have an actual query parser
639         my ($operand,$stemmed_operand,$index) = @_;
640     my $stemming      = C4::Context->preference("QueryStemming")     || 0;
641     my $weight_fields = C4::Context->preference("QueryWeightFields") || 0;
642     my $fuzzy_enabled = C4::Context->preference("QueryFuzzy") || 0;
643
644     my $weighted_query .= " (rk=(";     # Specifies that we're applying rank
645         # keyword has different weight properties
646         if ( ( $index =~ /kw/ ) || ( !$index ) ) {
647         # a simple way to find out if this query uses an index
648                 if ( $operand =~ /(\=|\:)/ ) {
649                         $weighted_query .= " $operand";
650                 }
651                 else {
652                         $weighted_query .=" Title-cover,ext,r1=\"$operand\"";   # title cover as exact
653                         $weighted_query .=" or ti,ext,r2=\"$operand\"";             # exact title elsewhere
654                         $weighted_query .= " or ti,phr,r3=\"$operand\"";          # index as phrase
655                         #$weighted_query .= " or any,ext,r4=$operand";         # index as exact
656                         #$weighted_query .=" or kw,wrdl,r5=\"$operand\"";            # all the words in the query (wordlist)
657                         $weighted_query .= " or wrd,fuzzy,r8=\"$operand\"" if $fuzzy_enabled; # add fuzzy
658                         $weighted_query .= " or wrd,right-Truncation,r9=\"$stemmed_operand\"" if ($stemming and $stemmed_operand); # add stemming
659                         # embedded sorting: 0 a-z; 1 z-a
660                         #$weighted_query .= ") or (sort1,aut=1";
661                 }
662                     
663         }
664         #TODO: build better cases based on specific search indexes
665         #elsif ( $index =~ /au/ ) {
666         #       $weighted_query .=" $index,ext,r1=$operand";    # index label as exact
667         #       #$weighted_query .= " or (title-sort-az=0 or $index,startswithnt,st-word,r3=$operand #)";
668         #       $weighted_query .=" or $index,phr,r3=$operand";    # index as phrase
669         #       $weighted_query .= " or $index,rt,wrd,r3=$operand";
670         #}
671         #elsif ( $index =~ /ti/ ) {
672         #       $weighted_query .=" Title-cover,ext,r1=$operand"; # index label as exact
673         #       $weighted_query .= " or Title-series,ext,r2=$operand";
674         #       #$weighted_query .= " or ti,ext,r2=$operand";
675         #       #$weighted_query .= " or ti,phr,r3=$operand";
676         #       #$weighted_query .= " or ti,wrd,r3=$operand";
677         #       $weighted_query .=" or (title-sort-az=0 or Title-cover,startswithnt,st-word,r3=$operand #)";
678         #       $weighted_query .=" or (title-sort-az=0 or Title-cover,phr,r6=$operand)";
679                 #$weighted_query .= " or Title-cover,wrd,r5=$operand";
680                 #$weighted_query .= " or ti,ext,r6=$operand";
681                 #$weighted_query .= " or ti,startswith,phr,r7=$operand";
682                 #$weighted_query .= " or ti,phr,r8=$operand";
683                 #$weighted_query .= " or ti,wrd,r9=$operand";
684                 #$weighted_query .= " or ti,ext,r2=$operand";         # index as exact
685                 #$weighted_query .= " or ti,phr,r3=$operand";              # index as  phrase
686                 #$weighted_query .= " or any,ext,r4=$operand";         # index as exact
687                 #$weighted_query .= " or kw,wrd,r5=$operand";         # index as exact
688         #}
689         else {
690                 $weighted_query .=" $index,ext,r1=$operand";    # index label as exact
691                 #$weighted_query .= " or $index,ext,r2=$operand";            # index as exact
692                 $weighted_query .=" or $index,phr,r3=$operand";    # index as phrase
693                 $weighted_query .= " or $index,rt,wrd,r3=$operand";
694                 $weighted_query .=" or $index,wrd,r5=$operand";    # index as word right-truncated
695                 $weighted_query .= " or $index,wrd,fuzzy,r8=$operand" if $fuzzy_enabled;
696         }
697         $weighted_query .= "))";    # close rank specification
698         return $weighted_query;
699 }
700
701 # build the query itself
702 sub buildQuery {
703     my ( $operators, $operands, $indexes, $limits, $sort_by ) = @_;
704
705     my @operators = @$operators if $operators;
706     my @indexes   = @$indexes   if $indexes;
707     my @operands  = @$operands  if $operands;
708     my @limits    = @$limits    if $limits;
709     my @sort_by   = @$sort_by   if $sort_by;
710
711         my $stemming      = C4::Context->preference("QueryStemming")     || 0;
712         my $weight_fields = C4::Context->preference("QueryWeightFields") || 0;
713         my $fuzzy_enabled = C4::Context->preference("QueryFuzzy") || 0;
714
715     my $human_search_desc;      # a human-readable query
716     my $machine_search_desc;    #a machine-readable query
717         #warn "OPERATORS: >@operators< INDEXES: >@indexes< OPERANDS: >@operands< LIMITS: >@limits< SORTS: >@sort_by<";
718         my $query = $operands[0];
719 # STEP I: determine if this is a form-based / simple query or if it's complex (if complex,
720 # we can't handle field weighting, stemming until a formal query parser is written
721
722 # check if this is a known query language query, if it is, return immediately,
723 # the user is responsible for constructing valid syntax:
724     if ( $query =~ /^ccl=/ ) {
725         return ( undef, $', $', $', 'ccl' );
726     }
727     if ( $query =~ /^cql=/ ) {
728         return ( undef, $', $', $', 'cql' );
729     }
730     if ( $query =~ /^pqf=/ ) {
731         return ( undef, $', $', $', 'pqf' );
732     }
733     if ( $query =~ /(\(|\))/ ) {    # sorry, too complex, assume CCL
734         return ( undef, $query, $query, $query, 'ccl' );
735     }
736
737 # form-based queries are limited to non-nested at a specific depth, so we can easily
738 # modify the incoming query operands and indexes to do stemming and field weighting
739 # Once we do so, we'll end up with a value in $query, just like if we had an
740 # incoming $query from the user
741     else {
742         $query = ""; # clear it out so we can populate properly with field-weighted stemmed query
743         my $previous_operand;    # a flag used to keep track if there was a previous query
744                                                 # if there was, we can apply the current operator
745                 # for every operand
746         for ( my $i = 0 ; $i <= @operands ; $i++ ) {
747
748                         # COMBINE OPERANDS, INDEXES AND OPERATORS
749                         if ( $operands[$i] ) {
750                 my $operand = $operands[$i];
751                 my $index   = $indexes[$i];
752                                 # if there's no index, don't use one, it will throw a CCL error
753                                 my $index_plus; $index_plus = "$index:" if $index;
754                                 my $index_plus_comma; $index_plus_comma="$index," if $index;
755
756                                 # Remove Stopwords      
757                                 $operand = _remove_stopwords($operand,$index);
758
759                                 # Handle Truncation
760                                 my ($nontruncated,$righttruncated,$lefttruncated,$rightlefttruncated,$regexpr);
761                                 ($nontruncated,$righttruncated,$lefttruncated,$rightlefttruncated,$regexpr) = _add_truncation($operand,$index);
762                                 warn "TRUNCATION: NON:@$nontruncated RIGHT:@$righttruncated LEFT:@$lefttruncated RIGHTLEFT:@$rightlefttruncated REGEX:@$regexpr";
763
764                                 # Handle Stemming
765                         my $stemmed_operand;
766                                 $stemmed_operand = _build_stemmed_operand($operand) if $stemming;
767
768                                 # Handle Field Weighting
769                                 my $weighted_operand;
770                 $weighted_operand = _build_weighted_query($operand,$stemmed_operand,$index) if $weight_fields;
771
772                                 # proves we're operating in multi-leaf mode
773                                 # $weighted_operand = "$weighted_operand and $weighted_operand";
774                                 $operand = $weighted_operand if $weight_fields;
775
776                 # If there's a previous operand, we need to add an operator
777                 if ($previous_operand) {
778                     if ( $operators[ $i - 1 ] ) {
779                                                 $human_search_desc .="  $operators[$i-1] $index_plus $operands[$i]";
780                                                 $query .= " $operators[$i-1] $index_plus $operand";
781                     }
782                     # the default operator is and
783                     else {
784                         $query             .= " and $index_plus $operand";
785                         $human_search_desc .= " and $index_plus $operands[$i]";
786                     }
787                 }
788                                 # There's no previous operand - FIXME: completely ignoring our $query, no field weighting, no stemming
789                                 # FIXME: also, doesn't preserve original order
790                 else { 
791                                         # if there are terms to fit with truncation
792                                         if (scalar(@$righttruncated)+scalar(@$lefttruncated)+scalar(@$rightlefttruncated)>0){
793                                                 # add the non-truncated ones first
794                                                 $query.= "$index_plus @$nontruncated " if (scalar(@$nontruncated)>0);
795                                                 if (scalar(@$righttruncated)>0){
796                                                         $query .= "and $index_plus_comma"."rtrn:@$righttruncated ";
797                                                 }            
798                                                 if (scalar(@$lefttruncated)>0){
799                                                         $query .= "and $index_plus_comma"."ltrn:@$lefttruncated ";
800                                                 }            
801                                                 if (scalar(@$rightlefttruncated)>0){
802                                                         $query .= "and $index_plus_comma"."rltrn:@$rightlefttruncated ";
803                                                 }
804                         $query=~s/^and//; # FIXME: this is cheating :-)
805                         $human_search_desc .= $query;
806                                         } else {           
807                         $query             .= " $index_plus $operand";
808                         $human_search_desc .= " $index_plus $operands[$i]";
809                                         }            
810                     $previous_operand = 1;
811                 }
812             }    #/if $operands
813         }    # /for
814     }
815
816     # add limits
817     my $limit_query;
818     my $limit_search_desc;
819     foreach my $limit (@limits) {
820
821         # FIXME: not quite right yet ... will work on this soon -- JF
822         my $type = $1 if $limit =~ m/([^:]+):([^:]*)/;
823         if ( $limit =~ /available/ ) {
824             $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))";
825             #$limit_search_desc.=" and available";
826         }
827         elsif ( ($limit_query) && ( index( $limit_query, $type, 0 ) > 0 ) ) {
828             if ( $limit_query !~ /\(/ ) {
829                 $limit_query =
830                     substr( $limit_query, 0, index( $limit_query, $type, 0 ) )
831                   . "("
832                   . substr( $limit_query, index( $limit_query, $type, 0 ) )
833                   . " or $limit )"
834                   if $limit;
835                 $limit_search_desc =
836                   substr( $limit_search_desc, 0,
837                     index( $limit_search_desc, $type, 0 ) )
838                   . "("
839                   . substr( $limit_search_desc,
840                     index( $limit_search_desc, $type, 0 ) )
841                   . " or $limit )"
842                   if $limit;
843             }
844             else {
845                 chop $limit_query;
846                 chop $limit_search_desc;
847                 $limit_query       .= " or $limit )" if $limit;
848                 $limit_search_desc .= " or $limit )" if $limit;
849             }
850         }
851         elsif ( ($limit_query) && ( $limit =~ /mc/ ) ) {
852             $limit_query       .= " or $limit" if $limit;
853             $limit_search_desc .= " or $limit" if $limit;
854         }
855
856         # these are treated as AND
857         elsif ($limit_query) {
858            if ($limit =~ /branch/){
859                         $limit_query       .= " ) and ( $limit" if $limit;
860                         $limit_search_desc .= " ) and ( $limit" if $limit;
861                 }else{
862                         $limit_query       .= " or $limit" if $limit;
863                         $limit_search_desc .= " or $limit" if $limit;
864                 }
865         }
866
867         # otherwise, there is nothing but the limit
868         else {
869             $limit_query       .= "$limit" if $limit;
870             $limit_search_desc .= "$limit" if $limit;
871         }
872     }
873
874     # if there's also a query, we need to AND the limits to it
875     if ( ($limit_query) && ($query) ) {
876         $limit_query       = " and (" . $limit_query . ")";
877         $limit_search_desc = " and ($limit_search_desc)" if $limit_search_desc;
878
879     }
880         #warn "LIMIT: $limit_query";
881     $query             .= $limit_query;
882     $human_search_desc .= $limit_search_desc;
883
884     # now normalize the strings
885     $query =~ s/  / /g;    # remove extra spaces
886     $query =~ s/^ //g;     # remove any beginning spaces
887     $query =~ s/:/=/g;     # causes probs for server
888     $query =~ s/==/=/g;    # remove double == from query
889
890     my $federated_query = $human_search_desc;
891     $federated_query =~ s/  / /g;
892     $federated_query =~ s/^ //g;
893     $federated_query =~ s/:/=/g;
894     my $federated_query_opensearch = $federated_query;
895
896 #     my $federated_query_RPN = new ZOOM::Query::CCL2RPN( $query , C4::Context->ZConn('biblioserver'));
897
898     $human_search_desc =~ s/  / /g;
899     $human_search_desc =~ s/^ //g;
900     my $koha_query = $query;
901
902     #warn "QUERY:".$koha_query;
903     #warn "SEARCHDESC:".$human_search_desc;
904     #warn "FEDERATED QUERY:".$federated_query;
905     return ( undef, $human_search_desc, $koha_query, $federated_query );
906 }
907
908 # IMO this subroutine is pretty messy still -- it's responsible for
909 # building the HTML output for the template
910 sub searchResults {
911     my ( $searchdesc, $hits, $results_per_page, $offset, @marcresults ) = @_;
912
913     my $dbh = C4::Context->dbh;
914     my $toggle;
915     my $even = 1;
916     my @newresults;
917     my $span_terms_hashref;
918     for my $span_term ( split( / /, $searchdesc ) ) {
919         $span_term =~ s/(.*=|\)|\(|\+|\.)//g;
920         $span_terms_hashref->{$span_term}++;
921     }
922
923     #Build brancnames hash
924     #find branchname
925     #get branch information.....
926     my %branches;
927     my $bsth =
928       $dbh->prepare("SELECT branchcode,branchname FROM branches")
929       ;    # FIXME : use C4::Koha::GetBranches
930     $bsth->execute();
931     while ( my $bdata = $bsth->fetchrow_hashref ) {
932         $branches{ $bdata->{'branchcode'} } = $bdata->{'branchname'};
933     }
934
935     #Build itemtype hash
936     #find itemtype & itemtype image
937     my %itemtypes;
938     $bsth =
939       $dbh->prepare("SELECT itemtype,description,imageurl,summary,notforloan FROM itemtypes");
940     $bsth->execute();
941     while ( my $bdata = $bsth->fetchrow_hashref ) {
942         $itemtypes{ $bdata->{'itemtype'} }->{description} =
943           $bdata->{'description'};
944         $itemtypes{ $bdata->{'itemtype'} }->{imageurl} = $bdata->{'imageurl'};
945         $itemtypes{ $bdata->{'itemtype'} }->{summary} = $bdata->{'summary'};
946         $itemtypes{ $bdata->{'itemtype'} }->{notforloan} = $bdata->{'notforloan'};
947     }
948
949     #search item field code
950     my $sth =
951       $dbh->prepare(
952 "select tagfield from marc_subfield_structure where kohafield like 'items.itemnumber'"
953       );
954     $sth->execute;
955     my ($itemtag) = $sth->fetchrow;
956
957     ## find column names of items related to MARC
958     my $sth2 = $dbh->prepare("SHOW COLUMNS from items");
959     $sth2->execute;
960     my %subfieldstosearch;
961     while ( ( my $column ) = $sth2->fetchrow ) {
962         my ( $tagfield, $tagsubfield ) =
963           &GetMarcFromKohaField( "items." . $column, "" );
964         $subfieldstosearch{$column} = $tagsubfield;
965     }
966     my $times;
967
968     if ( $hits && $offset + $results_per_page <= $hits ) {
969         $times = $offset + $results_per_page;
970     }
971     else {
972         $times = $hits;
973     }
974
975     for ( my $i = $offset ; $i <= $times - 1 ; $i++ ) {
976         my $marcrecord;
977         $marcrecord = MARC::File::USMARC::decode( $marcresults[$i] );
978         my $oldbiblio = TransformMarcToKoha( $dbh, $marcrecord, '' );
979         # add image url if there is one
980         if ( $itemtypes{ $oldbiblio->{itemtype} }->{imageurl} =~ /^http:/ ) {
981             $oldbiblio->{imageurl} =
982               $itemtypes{ $oldbiblio->{itemtype} }->{imageurl};
983             $oldbiblio->{description} =
984               $itemtypes{ $oldbiblio->{itemtype} }->{description};
985         }
986         else {
987             $oldbiblio->{imageurl} =
988               getitemtypeimagesrc() . "/"
989               . $itemtypes{ $oldbiblio->{itemtype} }->{imageurl}
990               if ( $itemtypes{ $oldbiblio->{itemtype} }->{imageurl} );
991             $oldbiblio->{description} =
992               $itemtypes{ $oldbiblio->{itemtype} }->{description};
993         }
994         #
995         # build summary if there is one (the summary is defined in itemtypes table
996         #
997         if ($itemtypes{ $oldbiblio->{itemtype} }->{summary}) {
998             my $summary = $itemtypes{ $oldbiblio->{itemtype} }->{summary};
999             my @fields = $marcrecord->fields();
1000             foreach my $field (@fields) {
1001                 my $tag = $field->tag();
1002                 my $tagvalue = $field->as_string();
1003                 $summary =~ s/\[(.?.?.?.?)$tag\*(.*?)]/$1$tagvalue$2\[$1$tag$2]/g;
1004                 unless ($tag<10) {
1005                     my @subf = $field->subfields;
1006                     for my $i (0..$#subf) {
1007                         my $subfieldcode = $subf[$i][0];
1008                         my $subfieldvalue = $subf[$i][1];
1009                         my $tagsubf = $tag.$subfieldcode;
1010                         $summary =~ s/\[(.?.?.?.?)$tagsubf(.*?)]/$1$subfieldvalue$2\[$1$tagsubf$2]/g;
1011                     }
1012                 }
1013             }
1014             $summary =~ s/\[(.*?)]//g;
1015             $summary =~ s/\n/<br>/g;
1016             $oldbiblio->{summary} = $summary;
1017         }
1018         # add spans to search term in results for search term highlighting
1019         foreach my $term ( keys %$span_terms_hashref ) {
1020             my $old_term = $term;
1021             if ( length($term) > 3 ) {
1022                 $term =~ s/(.*=|\)|\(|\+|\.|\?|\[|\])//g;
1023                                 $term =~ s/\\//g;
1024                                 $term =~ s/\*//g;
1025
1026                 #FIXME: is there a better way to do this?
1027                 $oldbiblio->{'title'} =~ s/$term/<span class=term>$&<\/span>/gi;
1028                 $oldbiblio->{'subtitle'} =~
1029                   s/$term/<span class=term>$&<\/span>/gi;
1030
1031                 $oldbiblio->{'author'} =~ s/$term/<span class=term>$&<\/span>/gi;
1032                 $oldbiblio->{'publishercode'} =~ s/$term/<span class=term>$&<\/span>/gi;
1033                 $oldbiblio->{'place'} =~ s/$term/<span class=term>$&<\/span>/gi;
1034                 $oldbiblio->{'pages'} =~ s/$term/<span class=term>$&<\/span>/gi;
1035                 $oldbiblio->{'notes'} =~ s/$term/<span class=term>$&<\/span>/gi;
1036                 $oldbiblio->{'size'}  =~ s/$term/<span class=term>$&<\/span>/gi;
1037             }
1038         }
1039
1040         if ( $i % 2 ) {
1041             $toggle = "#ffffcc";
1042         }
1043         else {
1044             $toggle = "white";
1045         }
1046         $oldbiblio->{'toggle'} = $toggle;
1047         my @fields = $marcrecord->field($itemtag);
1048         my @items_loop;
1049         my $items;
1050         my $ordered_count     = 0;
1051         my $onloan_count      = 0;
1052         my $wthdrawn_count    = 0;
1053         my $itemlost_count    = 0;
1054         my $norequests        = 1;
1055
1056         #
1057         # check the loan status of the item : 
1058         # it is not stored in the MARC record, for pref (zebra reindexing)
1059         # reason. Thus, we have to get the status from a specific SQL query
1060         #
1061         my $sth_issue = $dbh->prepare("
1062             SELECT date_due,returndate 
1063             FROM issues 
1064             WHERE itemnumber=? AND returndate IS NULL");
1065         my $items_count=scalar(@fields);
1066         foreach my $field (@fields) {
1067             my $item;
1068             foreach my $code ( keys %subfieldstosearch ) {
1069                 $item->{$code} = $field->subfield( $subfieldstosearch{$code} );
1070             }
1071             $sth_issue->execute($item->{itemnumber});
1072             $item->{due_date} = format_date($sth_issue->fetchrow);
1073             $item->{onloan} = 1 if $item->{due_date};
1074             # at least one item can be reserved : suppose no
1075             $norequests = 1;
1076             if ( $item->{wthdrawn} ) {
1077                 $wthdrawn_count++;
1078                 $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{unavailable}=1;
1079                 $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{wthdrawn}=1;
1080             }
1081             elsif ( $item->{itemlost} ) {
1082                 $itemlost_count++;
1083                 $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{unavailable}=1;
1084                 $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{itemlost}=1;
1085             }
1086             unless ( $item->{notforloan}) {
1087                 # OK, this one can be issued, so at least one can be reserved
1088                 $norequests = 0;
1089             }
1090             if ( ( $item->{onloan} ) && ( $item->{onloan} != '0000-00-00' ) )
1091             {
1092                 $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{unavailable}=1;
1093                 $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{onloancount} = 1;
1094                 $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{due_date} = $item->{due_date};
1095                 $onloan_count++;
1096             }
1097             if ( $item->{'homebranch'} ) {
1098                 $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{count}++;
1099             }
1100
1101             # Last resort
1102             elsif ( $item->{'holdingbranch'} ) {
1103                 $items->{ $item->{'holdingbranch'} }->{count}++;
1104             }
1105             $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{itemcallnumber} =                $item->{itemcallnumber};
1106             $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{location} =                $item->{location};
1107             $items->{ $item->{'homebranch'}.'--'.$item->{'itemcallnumber'} }->{branchcode} =               $item->{homebranch};
1108         }    # notforloan, item level and biblioitem level
1109
1110         # last check for norequest : if itemtype is notforloan, it can't be reserved either, whatever the items
1111         $norequests = 1 if $itemtypes{$oldbiblio->{itemtype}}->{notforloan};
1112
1113         for my $key ( sort keys %$items ) {
1114             my $this_item = {
1115                 branchname     => $branches{$items->{$key}->{branchcode}},
1116                 branchcode     => $items->{$key}->{branchcode},
1117                 count          => $items->{$key}->{count},
1118                 itemcallnumber => $items->{$key}->{itemcallnumber},
1119                 location => $items->{$key}->{location},
1120                 onloancount      => $items->{$key}->{onloancount},
1121                 due_date         => $items->{$key}->{due_date},
1122                 wthdrawn      => $items->{$key}->{wthdrawn},
1123                 lost         => $items->{$key}->{itemlost},
1124             };
1125             push @items_loop, $this_item;
1126         }
1127         $oldbiblio->{norequests}    = $norequests;
1128         $oldbiblio->{items_count}    = $items_count;
1129         $oldbiblio->{items_loop}    = \@items_loop;
1130         $oldbiblio->{onloancount}   = $onloan_count;
1131         $oldbiblio->{wthdrawncount} = $wthdrawn_count;
1132         $oldbiblio->{itemlostcount} = $itemlost_count;
1133         $oldbiblio->{orderedcount}  = $ordered_count;
1134         $oldbiblio->{isbn}          =~ s/-//g; # deleting - in isbn to enable amazon content 
1135         push( @newresults, $oldbiblio );
1136     }
1137     return @newresults;
1138 }
1139
1140
1141
1142 #----------------------------------------------------------------------
1143 #
1144 # Non-Zebra GetRecords#
1145 #----------------------------------------------------------------------
1146
1147 =head2 NZgetRecords
1148
1149   NZgetRecords has the same API as zera getRecords, even if some parameters are not managed
1150
1151 =cut
1152
1153 sub NZgetRecords {
1154     my (
1155         $koha_query,     $federated_query,  $sort_by_ref,
1156         $servers_ref,    $results_per_page, $offset,
1157         $expanded_facet, $branches,         $query_type,
1158         $scan
1159     ) = @_;
1160     my $result = NZanalyse($koha_query);
1161     return (undef,NZorder($result,@$sort_by_ref[0],$results_per_page,$offset),undef);
1162 }
1163
1164 =head2 NZanalyse
1165
1166   NZanalyse : get a CQL string as parameter, and returns a list of biblionumber;title,biblionumber;title,...
1167   the list is builded from inverted index in nozebra SQL table
1168   note that title is here only for convenience : the sorting will be very fast when requested on title
1169   if the sorting is requested on something else, we will have to reread all results, and that may be longer.
1170
1171 =cut
1172
1173 sub NZanalyse {
1174     my ($string,$server) = @_;
1175     # $server contains biblioserver or authorities, depending on what we search on.
1176     warn "querying : $string on $server";
1177     $server='biblioserver' unless $server;
1178     # if we have a ", replace the content to discard temporarily any and/or/not inside
1179     my $commacontent;
1180     if ($string =~/"/) {
1181         $string =~ s/"(.*?)"/__X__/;
1182         $commacontent = $1;
1183 #         print "commacontent : $commacontent\n";
1184     }
1185     # split the query string in 3 parts : X AND Y means : $left="X", $operand="AND" and $right="Y"
1186     # then, call again NZanalyse with $left and $right
1187     # (recursive until we find a leaf (=> something without and/or/not)
1188     $string =~ /(.*)( and | or | not | AND | OR | NOT )(.*)/;
1189     my $left = $1;
1190     my $right = $3;
1191     my $operand = lc($2);
1192     # it's not a leaf, we have a and/or/not
1193     if ($operand) {
1194         # reintroduce comma content if needed
1195         $right =~ s/__X__/"$commacontent"/ if $commacontent;
1196         $left =~ s/__X__/"$commacontent"/ if $commacontent;
1197 #         warn "node : $left / $operand / $right\n";
1198         my $leftresult = NZanalyse($left,$server);
1199         my $rightresult = NZanalyse($right,$server);
1200         # OK, we have the results for right and left part of the query
1201         # depending of operand, intersect, union or exclude both lists
1202         # to get a result list
1203         if ($operand eq ' and ') {
1204             my @leftresult = split /;/, $leftresult;
1205 #             my @rightresult = split /;/,$leftresult;
1206             my $finalresult;
1207             # parse the left results, and if the biblionumber exist in the right result, save it in finalresult
1208             # the result is stored twice, to have the same weight for AND than OR.
1209             # example : TWO : 61,61,64,121 (two is twice in the biblio #61) / TOWER : 61,64,130
1210             # result : 61,61,61,61,64,64 for two AND tower : 61 has more weight than 64
1211             foreach (@leftresult) {
1212                 if ($rightresult =~ "$_;") {
1213                     $finalresult .= "$_;$_;";
1214                 }
1215             }
1216             return $finalresult;
1217         } elsif ($operand eq ' or ') {
1218             # just merge the 2 strings
1219             return $leftresult.$rightresult;
1220         } elsif ($operand eq ' not ') {
1221             my @leftresult = split /;/, $leftresult;
1222 #             my @rightresult = split /;/,$leftresult;
1223             my $finalresult;
1224             foreach (@leftresult) {
1225                 unless ($rightresult =~ "$_;") {
1226                     $finalresult .= "$_;";
1227                 }
1228             }
1229             return $finalresult;
1230         } else {
1231             # this error is impossible, because of the regexp that isolate the operand, but just in case...
1232             die "error : operand unknown : $operand for $string";
1233         }
1234     # it's a leaf, do the real SQL query and return the result
1235     } else {
1236         $string =~  s/__X__/"$commacontent"/ if $commacontent;
1237         $string =~ s/-|\.|\?|,|;|!|'|\(|\)|\[|\]|{|}|"|&|\+|\*|\// /g;
1238 #         warn "leaf : $string\n";
1239         # parse the string in in operator/operand/value again
1240         $string =~ /(.*)(>=|<=)(.*)/;
1241         my $left = $1;
1242         my $operator = $2;
1243         my $right = $3;
1244         unless ($operator) {
1245             $string =~ /(.*)(>|<|=)(.*)/;
1246             $left = $1;
1247             $operator = $2;
1248             $right = $3;
1249         }
1250         my $results;
1251         # automatic replace for short operators
1252         $left='title' if $left =~ '^ti';
1253         $left='author' if $left =~ '^au';
1254         $left='publisher' if $left =~ '^pb';
1255         $left='subject' if $left =~ '^su';
1256         $left='koha-Auth-Number' if $left =~ '^an';
1257         $left='keyword' if $left =~ '^kw';
1258         if ($operator) {
1259             #do a specific search
1260             my $dbh = C4::Context->dbh;
1261             $operator='LIKE' if $operator eq '=' and $right=~ /%/;
1262             my $sth = $dbh->prepare("SELECT biblionumbers,value FROM nozebra WHERE server=? AND indexname=? AND value $operator ?");
1263             warn "$left / $operator / $right\n";
1264             # split each word, query the DB and build the biblionumbers result
1265             foreach (split / /,$right) {
1266                 my ($biblionumbers,$value);
1267                 next unless $_;
1268                 warn "EXECUTE : $server, $left, $_";
1269                 $sth->execute($server, $left, $_);
1270                 while (my ($line,$value) = $sth->fetchrow) {
1271                     # if we are dealing with a numeric value, use only numeric results (in case of >=, <=, > or <)
1272                     # otherwise, fill the result
1273                     $biblionumbers .= $line unless ($right =~ /\d/ && $value =~ /\D/);
1274                     warn "result : $value ". ($right =~ /\d/) . "==".(!$value =~ /\d/) ;#= $line";
1275                 }
1276                 # do a AND with existing list if there is one, otherwise, use the biblionumbers list as 1st result list
1277                 if ($results) {
1278                     my @leftresult = split /;/, $biblionumbers;
1279                     my $temp;
1280                     foreach my $entry (@leftresult) { # $_ contains biblionumber,title-weight
1281                         # remove weight at the end
1282                         my $cleaned = $entry;
1283                         $cleaned =~ s/-\d*$//;
1284                         # if the entry already in the hash, take it & increase weight
1285 #                         warn "===== $cleaned =====";
1286                         if ($results =~ "$cleaned") {
1287                             $temp .= "$entry;$entry;";
1288 #                             warn "INCLUDING $entry";
1289                         }
1290                     }
1291                     $results = $temp;
1292                 } else {
1293                     $results = $biblionumbers;
1294                 }
1295             }
1296         } else {
1297             #do a complete search (all indexes)
1298             my $dbh = C4::Context->dbh;
1299             my $sth = $dbh->prepare("SELECT biblionumbers FROM nozebra WHERE server=? AND value LIKE ?");
1300             # split each word, query the DB and build the biblionumbers result
1301             foreach (split / /,$string) {
1302                 next if C4::Context->stopwords->{uc($_)}; # skip if stopword
1303                 #warn "search on all indexes on $_";
1304                 my $biblionumbers;
1305                 next unless $_;
1306                 $sth->execute($server, $_);
1307                 while (my $line = $sth->fetchrow) {
1308                     $biblionumbers .= $line;
1309                 }
1310                 # do a AND with existing list if there is one, otherwise, use the biblionumbers list as 1st result list
1311                 if ($results) {
1312 #                 warn "RES for $_ = $biblionumbers";
1313                     my @leftresult = split /;/, $biblionumbers;
1314                     my $temp;
1315                     foreach my $entry (@leftresult) { # $_ contains biblionumber,title-weight
1316                         # remove weight at the end
1317                         my $cleaned = $entry;
1318                         $cleaned =~ s/-\d*$//;
1319                         # if the entry already in the hash, take it & increase weight
1320 #                         warn "===== $cleaned =====";
1321                         if ($results =~ "$cleaned") {
1322                             $temp .= "$entry;$entry;";
1323 #                             warn "INCLUDING $entry";
1324                         }
1325                     }
1326                     $results = $temp;
1327                 } else {
1328 #                 warn "NEW RES for $_ = $biblionumbers";
1329                     $results = $biblionumbers;
1330                 }
1331             }
1332         }
1333 #         warn "return : $results for LEAF : $string";
1334         return $results;
1335     }
1336 }
1337
1338 =head2 NZorder
1339
1340   $finalresult = NZorder($biblionumbers, $ordering,$results_per_page,$offset);
1341   
1342   TODO :: Description
1343
1344 =cut
1345
1346
1347 sub NZorder {
1348     my ($biblionumbers, $ordering,$results_per_page,$offset) = @_;
1349     # order title asc by default
1350 #     $ordering = '1=36 <i' unless $ordering;
1351     $results_per_page=20 unless $results_per_page;
1352     $offset = 0 unless $offset;
1353     my $dbh = C4::Context->dbh;
1354     #
1355     # order by POPULARITY
1356     #
1357     if ($ordering =~ /popularity/) {
1358         my %result;
1359         my %popularity;
1360         # popularity is not in MARC record, it's builded from a specific query
1361         my $sth = $dbh->prepare("select sum(issues) from items where biblionumber=?");
1362         foreach (split /;/,$biblionumbers) {
1363             my ($biblionumber,$title) = split /,/,$_;
1364             $result{$biblionumber}=GetMarcBiblio($biblionumber);
1365             $sth->execute($biblionumber);
1366             my $popularity= $sth->fetchrow ||0;
1367             # hint : the key is popularity.title because we can have
1368             # many results with the same popularity. In this cas, sub-ordering is done by title
1369             # we also have biblionumber to avoid bug for 2 biblios with the same title & popularity
1370             # (un-frequent, I agree, but we won't forget anything that way ;-)
1371             $popularity{sprintf("%10d",$popularity).$title.$biblionumber} = $biblionumber;
1372         }
1373         # sort the hash and return the same structure as GetRecords (Zebra querying)
1374         my $result_hash;
1375         my $numbers=0;
1376         if ($ordering eq 'popularity_dsc') { # sort popularity DESC
1377             foreach my $key (sort {$b cmp $a} (keys %popularity)) {
1378                 $result_hash->{'RECORDS'}[$numbers++] = $result{$popularity{$key}}->as_usmarc();
1379             }
1380         } else { # sort popularity ASC
1381             foreach my $key (sort (keys %popularity)) {
1382                 $result_hash->{'RECORDS'}[$numbers++] = $result{$popularity{$key}}->as_usmarc();
1383             }
1384         }
1385         my $finalresult=();
1386         $result_hash->{'hits'} = $numbers;
1387         $finalresult->{'biblioserver'} = $result_hash;
1388         return $finalresult;
1389     #
1390     # ORDER BY author
1391     #
1392     } elsif ($ordering =~/author/){
1393         my %result;
1394         foreach (split /;/,$biblionumbers) {
1395             my ($biblionumber,$title) = split /,/,$_;
1396             my $record=GetMarcBiblio($biblionumber);
1397             my $author;
1398             if (C4::Context->preference('marcflavour') eq 'UNIMARC') {
1399                 $author=$record->subfield('200','f');
1400                 $author=$record->subfield('700','a') unless $author;
1401             } else {
1402                 $author=$record->subfield('100','a');
1403             }
1404             # hint : the result is sorted by title.biblionumber because we can have X biblios with the same title
1405             # and we don't want to get only 1 result for each of them !!!
1406             $result{$author.$biblionumber}=$record;
1407         }
1408         # sort the hash and return the same structure as GetRecords (Zebra querying)
1409         my $result_hash;
1410         my $numbers=0;
1411         if ($ordering eq 'author_za') { # sort by author desc
1412             foreach my $key (sort (keys %result)) {
1413                 $result_hash->{'RECORDS'}[$numbers++] = $result{$key}->as_usmarc();
1414             }
1415         } else { # sort by author ASC
1416             foreach my $key (sort { $a cmp $b } (keys %result)) {
1417                 $result_hash->{'RECORDS'}[$numbers++] = $result{$key}->as_usmarc();
1418             }
1419         }
1420         my $finalresult=();
1421         $result_hash->{'hits'} = $numbers;
1422         $finalresult->{'biblioserver'} = $result_hash;
1423         return $finalresult;
1424     #
1425     # ORDER BY callnumber
1426     #
1427     } elsif ($ordering =~/callnumber/){
1428         my %result;
1429         foreach (split /;/,$biblionumbers) {
1430             my ($biblionumber,$title) = split /,/,$_;
1431             my $record=GetMarcBiblio($biblionumber);
1432             my $callnumber;
1433             my ($callnumber_tag,$callnumber_subfield)=GetMarcFromKohaField($dbh,'items.itemcallnumber');
1434             ($callnumber_tag,$callnumber_subfield)= GetMarcFromKohaField('biblioitems.callnumber') unless $callnumber_tag;
1435             if (C4::Context->preference('marcflavour') eq 'UNIMARC') {
1436                 $callnumber=$record->subfield('200','f');
1437             } else {
1438                 $callnumber=$record->subfield('100','a');
1439             }
1440             # hint : the result is sorted by title.biblionumber because we can have X biblios with the same title
1441             # and we don't want to get only 1 result for each of them !!!
1442             $result{$callnumber.$biblionumber}=$record;
1443         }
1444         # sort the hash and return the same structure as GetRecords (Zebra querying)
1445         my $result_hash;
1446         my $numbers=0;
1447         if ($ordering eq 'call_number_dsc') { # sort by title desc
1448             foreach my $key (sort (keys %result)) {
1449                 $result_hash->{'RECORDS'}[$numbers++] = $result{$key}->as_usmarc();
1450             }
1451         } else { # sort by title ASC
1452             foreach my $key (sort { $a cmp $b } (keys %result)) {
1453                 $result_hash->{'RECORDS'}[$numbers++] = $result{$key}->as_usmarc();
1454             }
1455         }
1456         my $finalresult=();
1457         $result_hash->{'hits'} = $numbers;
1458         $finalresult->{'biblioserver'} = $result_hash;
1459         return $finalresult;
1460     } elsif ($ordering =~ /pubdate/){ #pub year
1461         my %result;
1462         foreach (split /;/,$biblionumbers) {
1463             my ($biblionumber,$title) = split /,/,$_;
1464             my $record=GetMarcBiblio($biblionumber);
1465             my ($publicationyear_tag,$publicationyear_subfield)=GetMarcFromKohaField($dbh,'biblioitems.publicationyear');
1466             my $publicationyear=$record->subfield($publicationyear_tag,$publicationyear_subfield);
1467             # hint : the result is sorted by title.biblionumber because we can have X biblios with the same title
1468             # and we don't want to get only 1 result for each of them !!!
1469             $result{$publicationyear.$biblionumber}=$record;
1470         }
1471         # sort the hash and return the same structure as GetRecords (Zebra querying)
1472         my $result_hash;
1473         my $numbers=0;
1474         if ($ordering eq 'pubdate_dsc') { # sort by pubyear desc
1475             foreach my $key (sort (keys %result)) {
1476                 $result_hash->{'RECORDS'}[$numbers++] = $result{$key}->as_usmarc();
1477             }
1478         } else { # sort by pub year ASC
1479             foreach my $key (sort { $b cmp $a } (keys %result)) {
1480                 $result_hash->{'RECORDS'}[$numbers++] = $result{$key}->as_usmarc();
1481             }
1482         }
1483         my $finalresult=();
1484         $result_hash->{'hits'} = $numbers;
1485         $finalresult->{'biblioserver'} = $result_hash;
1486         return $finalresult;
1487     #
1488     # ORDER BY title
1489     #
1490     } elsif ($ordering =~ /title/) { 
1491         # the title is in the biblionumbers string, so we just need to build a hash, sort it and return
1492         my %result;
1493         foreach (split /;/,$biblionumbers) {
1494             my ($biblionumber,$title) = split /,/,$_;
1495             # hint : the result is sorted by title.biblionumber because we can have X biblios with the same title
1496             # and we don't want to get only 1 result for each of them !!!
1497             # hint & speed improvement : we can order without reading the record
1498             # so order, and read records only for the requested page !
1499             $result{$title.$biblionumber}=$biblionumber;
1500         }
1501         # sort the hash and return the same structure as GetRecords (Zebra querying)
1502         my $result_hash;
1503         my $numbers=0;
1504         if ($ordering eq 'title_za') { # sort by title desc
1505             foreach my $key (sort (keys %result)) {
1506                 $result_hash->{'RECORDS'}[$numbers++] = $result{$key};
1507             }
1508         } else { # sort by title ASC
1509             foreach my $key (sort { $b cmp $a } (keys %result)) {
1510                 $result_hash->{'RECORDS'}[$numbers++] = $result{$key};
1511             }
1512         }
1513         # limit the $results_per_page to result size if it's more
1514         $results_per_page = $numbers-1 if $numbers < $results_per_page;
1515         # for the requested page, replace biblionumber by the complete record
1516         # speed improvement : avoid reading too much things
1517         for (my $counter=$offset;$counter<=$offset+$results_per_page;$counter++) {
1518             $result_hash->{'RECORDS'}[$counter] = GetMarcBiblio($result_hash->{'RECORDS'}[$counter])->as_usmarc;
1519         }
1520         my $finalresult=();
1521         $result_hash->{'hits'} = $numbers;
1522         $finalresult->{'biblioserver'} = $result_hash;
1523         return $finalresult;
1524     } else {
1525     #
1526     # order by ranking
1527     #
1528         # we need 2 hashes to order by ranking : the 1st one to count the ranking, the 2nd to order by ranking
1529         my %result;
1530         my %count_ranking;
1531         foreach (split /;/,$biblionumbers) {
1532             my ($biblionumber,$title) = split /,/,$_;
1533             $title =~ /(.*)-(\d)/;
1534             # get weight 
1535             my $ranking =$2;
1536             # note that we + the ranking because ranking is calculated on weight of EACH term requested.
1537             # if we ask for "two towers", and "two" has weight 2 in biblio N, and "towers" has weight 4 in biblio N
1538             # biblio N has ranking = 6
1539             $count_ranking{$biblionumber} += $ranking;
1540         }
1541         # build the result by "inverting" the count_ranking hash
1542         # 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
1543 #         warn "counting";
1544         foreach (keys %count_ranking) {
1545             $result{sprintf("%10d",$count_ranking{$_}).'-'.$_} = $_;
1546         }
1547         # sort the hash and return the same structure as GetRecords (Zebra querying)
1548         my $result_hash;
1549         my $numbers=0;
1550             foreach my $key (sort {$b cmp $a} (keys %result)) {
1551                 $result_hash->{'RECORDS'}[$numbers++] = $result{$key};
1552             }
1553         # limit the $results_per_page to result size if it's more
1554         $results_per_page = $numbers-1 if $numbers < $results_per_page;
1555         # for the requested page, replace biblionumber by the complete record
1556         # speed improvement : avoid reading too much things
1557         for (my $counter=$offset;$counter<=$offset+$results_per_page;$counter++) {
1558             $result_hash->{'RECORDS'}[$counter] = GetMarcBiblio($result_hash->{'RECORDS'}[$counter])->as_usmarc;
1559         }
1560         my $finalresult=();
1561         $result_hash->{'hits'} = $numbers;
1562         $finalresult->{'biblioserver'} = $result_hash;
1563         return $finalresult;
1564     }
1565 }
1566 =head2 ModBiblios
1567
1568 ($countchanged,$listunchanged) = ModBiblios($listbiblios, $tagsubfield,$initvalue,$targetvalue,$test);
1569
1570 this function changes all the values $initvalue in subfield $tag$subfield in any record in $listbiblios
1571 test parameter if set donot perform change to records in database.
1572
1573 =over 2
1574
1575 =item C<input arg:>
1576
1577     * $listbiblios is an array ref to marcrecords to be changed
1578     * $tagsubfield is the reference of the subfield to change.
1579     * $initvalue is the value to search the record for
1580     * $targetvalue is the value to set the subfield to
1581     * $test is to be set only not to perform changes in database.
1582
1583 =item C<Output arg:>
1584     * $countchanged counts all the changes performed.
1585     * $listunchanged contains the list of all the biblionumbers of records unchanged.
1586
1587 =item C<usage in the script:>
1588
1589 =back
1590
1591 my ($countchanged, $listunchanged) = EditBiblios($results->{RECORD}, $tagsubfield,$initvalue,$targetvalue);;
1592 #If one wants to display unchanged records, you should get biblios foreach @$listunchanged 
1593 $template->param(countchanged => $countchanged, loopunchanged=>$listunchanged);
1594
1595 =cut
1596
1597 sub ModBiblios{
1598   my ($listbiblios,$tagsubfield,$initvalue,$targetvalue,$test)=@_;
1599   my $countmatched;
1600   my @unmatched;
1601   my ($tag,$subfield)=($1,$2) if ($tagsubfield=~/^(\d{1,3})([a-z0-9A-Z@])?$/); 
1602   if ((length($tag)<3)&& $subfield=~/0-9/){
1603     $tag=$tag.$subfield;
1604     undef $subfield;
1605   } 
1606   my ($bntag,$bnsubf) = GetMarcFromKohaField('biblio.biblionumber');
1607   my ($itemtag,$itemsubf) = GetMarcFromKohaField('items.itemnumber');
1608   foreach my $usmarc (@$listbiblios){
1609     my $record; 
1610     $record=eval{MARC::Record->new_from_usmarc($usmarc)};
1611     my $biblionumber;
1612     if ($@){
1613       # usmarc is not a valid usmarc May be a biblionumber
1614       if ($tag eq $itemtag){
1615         my $bib=GetBiblioFromItemNumber($usmarc);   
1616         $record=GetMarcItem($bib->{'biblionumber'},$usmarc) ;   
1617         $biblionumber=$bib->{'biblionumber'};
1618       } else {   
1619         $record=GetMarcBiblio($usmarc);   
1620         $biblionumber=$usmarc;
1621       }   
1622     }  else {
1623       if ($bntag >= 010){
1624         $biblionumber = $record->subfield($bntag,$bnsubf);
1625       }else {
1626         $biblionumber=$record->field($bntag)->data;
1627       }
1628     }  
1629     #GetBiblionumber is to be written.
1630     #Could be replaced by TransformMarcToKoha (But Would be longer)
1631     if ($record->field($tag)){
1632       my $modify=0;  
1633       foreach my $field ($record->field($tag)){
1634         if ($subfield){
1635           if ($field->delete_subfield('code' =>$subfield,'match'=>qr($initvalue))){
1636             $countmatched++;
1637             $modify=1;      
1638             $field->update($subfield,$targetvalue) if ($targetvalue);
1639           }
1640         } else {
1641           if ($tag >= 010){
1642             if ($field->delete_field($field)){
1643               $countmatched++;
1644               $modify=1;      
1645             }
1646           } else {
1647             $field->data=$targetvalue if ($field->data=~qr($initvalue));
1648           }     
1649         }    
1650       }
1651 #       warn $record->as_formatted;
1652       if ($modify){
1653         ModBiblio($record,$biblionumber,GetFrameworkCode($biblionumber)) unless ($test);
1654       } else {
1655         push @unmatched, $biblionumber;   
1656       }      
1657     } else {
1658       push @unmatched, $biblionumber;
1659     }
1660   }
1661   return ($countmatched,\@unmatched);
1662 }
1663
1664 END { }    # module clean-up code here (global destructor)
1665
1666 1;
1667 __END__
1668
1669 =head1 AUTHOR
1670
1671 Koha Developement team <info@koha.org>
1672
1673 =cut