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