Bug 30477: Add new UNIMARC installer translation files
[koha.git] / C4 / Matcher.pm
1 package C4::Matcher;
2
3 # Copyright (C) 2007 LibLime, 2012 C & P Bibliography Services
4 #
5 # This file is part of Koha.
6 #
7 # Koha is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # Koha is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with Koha; if not, see <http://www.gnu.org/licenses>.
19
20 use Modern::Perl;
21
22
23 use Koha::SearchEngine;
24 use Koha::SearchEngine::Search;
25 use Koha::SearchEngine::QueryBuilder;
26 use Koha::Util::Normalize qw(
27     ISBN
28     legacy_default
29     lower_case
30     remove_spaces
31     upper_case
32 );
33
34 =head1 NAME
35
36 C4::Matcher - find MARC records matching another one
37
38 =head1 SYNOPSIS
39
40   my @matchers = C4::Matcher::GetMatcherList();
41
42   my $matcher = C4::Matcher->new($record_type);
43   $matcher->threshold($threshold);
44   $matcher->code($code);
45   $matcher->description($description);
46
47   $matcher->add_simple_matchpoint('isbn', 1000, '020', 'a', -1, 0, '');
48   $matcher->add_simple_matchpoint('Date', 1000, '008', '', 7, 4, '');
49   $matcher->add_matchpoint('isbn', 1000, [ { tag => '020', subfields => 'a', norms => [] } ]);
50
51   $matcher->add_simple_required_check('245', 'a', -1, 0, '', '245', 'a', -1, 0, '');
52   $matcher->add_required_check([ { tag => '245', subfields => 'a', norms => [] } ],
53                                [ { tag => '245', subfields => 'a', norms => [] } ]);
54
55   my @matches = $matcher->get_matches($marc_record, $max_matches);
56
57   foreach $match (@matches) {
58
59       # matches already sorted in order of
60       # decreasing score
61       print "record ID: $match->{'record_id'};
62       print "score:     $match->{'score'};
63
64   }
65
66   my $matcher_description = $matcher->dump();
67
68 =head1 FUNCTIONS
69
70 =cut
71
72 =head2 GetMatcherList
73
74   my @matchers = C4::Matcher::GetMatcherList();
75
76 Returns an array of hashrefs list all matchers
77 present in the database.  Each hashref includes:
78
79  * matcher_id
80  * code
81  * description
82
83 =cut
84
85 sub GetMatcherList {
86     my $dbh = C4::Context->dbh;
87     
88     my $sth = $dbh->prepare_cached("SELECT matcher_id, code, description FROM marc_matchers ORDER BY matcher_id");
89     $sth->execute();
90     my @results = ();
91     while (my $row = $sth->fetchrow_hashref) {
92         push @results, $row;
93     } 
94     return @results;
95 }
96
97 =head2 GetMatcherId
98
99   my $matcher_id = C4::Matcher::GetMatcherId($code);
100
101 Returns the matcher_id of a code.
102
103 =cut
104
105 sub GetMatcherId {
106     my ($code) = @_;
107     my $dbh = C4::Context->dbh;
108
109     my $matcher_id = $dbh->selectrow_array("SELECT matcher_id FROM marc_matchers WHERE code = ?", undef, $code);
110     return $matcher_id;
111 }
112
113 =head1 METHODS
114
115 =head2 new
116
117   my $matcher = C4::Matcher->new($record_type, $threshold);
118
119 Creates a new Matcher.  C<$record_type> indicates which search
120 database to use, e.g., 'biblio' or 'authority' and defaults to
121 'biblio', while C<$threshold> is the minimum score required for a match
122 and defaults to 1000.
123
124 =cut
125
126 sub new {
127     my $class = shift;
128     my $self = {};
129
130     $self->{'id'} = undef;
131
132     if ($#_ > -1) {
133         $self->{'record_type'} = shift;
134     } else {
135         $self->{'record_type'} = 'biblio';
136     }
137
138     if ($#_ > -1) {
139         $self->{'threshold'} = shift;
140     } else {
141         $self->{'threshold'} = 1000;
142     }
143
144     $self->{'code'} = '';
145     $self->{'description'} = '';
146
147     $self->{'matchpoints'} = [];
148     $self->{'required_checks'} = [];
149
150     bless $self, $class;
151     return $self;
152 }
153
154 =head2 fetch
155
156   my $matcher = C4::Matcher->fetch($id);
157
158 Creates a matcher object from the version stored
159 in the database.  If a matcher with the given
160 id does not exist, returns undef.
161
162 =cut
163
164 sub fetch {
165     my $class = shift;
166     my $id = shift;
167     my $dbh = C4::Context->dbh();
168
169     my $sth = $dbh->prepare_cached("SELECT * FROM marc_matchers WHERE matcher_id = ?");
170     $sth->execute($id);
171     my $row = $sth->fetchrow_hashref;
172     $sth->finish();
173     return unless defined $row;
174
175     my $self = {};
176     $self->{'id'} = $row->{'matcher_id'};
177     $self->{'record_type'} = $row->{'record_type'};
178     $self->{'code'} = $row->{'code'};
179     $self->{'description'} = $row->{'description'};
180     $self->{'threshold'} = int($row->{'threshold'});
181     bless $self, $class;
182
183     # matchpoints
184     $self->{'matchpoints'} = [];
185     $sth = $dbh->prepare_cached("SELECT * FROM matcher_matchpoints WHERE matcher_id = ? ORDER BY matchpoint_id");
186     $sth->execute($self->{'id'});
187     while (my $row = $sth->fetchrow_hashref) {
188         my $matchpoint = $self->_fetch_matchpoint($row->{'matchpoint_id'});
189         push @{ $self->{'matchpoints'} }, $matchpoint;
190     }
191
192     # required checks
193     $self->{'required_checks'} = [];
194     $sth = $dbh->prepare_cached("SELECT * FROM matchchecks WHERE matcher_id = ? ORDER BY matchcheck_id");
195     $sth->execute($self->{'id'});
196     while (my $row = $sth->fetchrow_hashref) {
197         my $source_matchpoint = $self->_fetch_matchpoint($row->{'source_matchpoint_id'});
198         my $target_matchpoint = $self->_fetch_matchpoint($row->{'target_matchpoint_id'});
199         my $matchcheck = {};
200         $matchcheck->{'source_matchpoint'} = $source_matchpoint;
201         $matchcheck->{'target_matchpoint'} = $target_matchpoint;
202         push @{ $self->{'required_checks'} }, $matchcheck;
203     }
204
205     return $self;
206 }
207
208 sub _fetch_matchpoint {
209     my $self = shift;
210     my $matchpoint_id = shift;
211     
212     my $dbh = C4::Context->dbh;
213     my $sth = $dbh->prepare_cached("SELECT * FROM matchpoints WHERE matchpoint_id = ?");
214     $sth->execute($matchpoint_id);
215     my $row = $sth->fetchrow_hashref;
216     my $matchpoint = {};
217     $matchpoint->{'index'} = $row->{'search_index'};
218     $matchpoint->{'score'} = int($row->{'score'});
219     $sth->finish();
220
221     $matchpoint->{'components'} = [];
222     $sth = $dbh->prepare_cached("SELECT * FROM matchpoint_components WHERE matchpoint_id = ? ORDER BY sequence");
223     $sth->execute($matchpoint_id);
224     while ($row = $sth->fetchrow_hashref) {
225         my $component = {};
226         $component->{'tag'} = $row->{'tag'};
227         $component->{'subfields'} = { map { $_ => 1 } split(//,  $row->{'subfields'}) };
228         $component->{'offset'} = int($row->{'offset'});
229         $component->{'length'} = int($row->{'length'});
230         $component->{'norms'} = [];
231         my $sth2 = $dbh->prepare_cached("SELECT * 
232                                          FROM matchpoint_component_norms 
233                                          WHERE matchpoint_component_id = ? ORDER BY sequence");
234         $sth2->execute($row->{'matchpoint_component_id'});
235         while (my $row2 = $sth2->fetchrow_hashref) {
236             push @{ $component->{'norms'} }, $row2->{'norm_routine'};
237         }
238         push @{ $matchpoint->{'components'} }, $component;
239     }
240     return $matchpoint;
241 }
242
243 =head2 store
244
245   my $id = $matcher->store();
246
247 Stores matcher in database.  The return value is the ID 
248 of the marc_matchers row.  If the matcher was 
249 previously retrieved from the database via the fetch()
250 method, the DB representation of the matcher
251 is replaced.
252
253 =cut
254
255 sub store {
256     my $self = shift;
257
258     if (defined $self->{'id'}) {
259         # update
260         $self->_del_matcher_components();
261         $self->_update_marc_matchers();
262     } else {
263         # create new
264         $self->_new_marc_matchers();
265     }
266     $self->_store_matcher_components();
267     return $self->{'id'};
268 }
269
270 sub _del_matcher_components {
271     my $self = shift;
272
273     my $dbh = C4::Context->dbh();
274     my $sth = $dbh->prepare_cached("DELETE FROM matchpoints WHERE matcher_id = ?");
275     $sth->execute($self->{'id'});
276     $sth = $dbh->prepare_cached("DELETE FROM matchchecks WHERE matcher_id = ?");
277     $sth->execute($self->{'id'});
278     # foreign key delete cascades take care of deleting relevant rows
279     # from matcher_matchpoints, matchpoint_components, and
280     # matchpoint_component_norms
281 }
282
283 sub _update_marc_matchers {
284     my $self = shift;
285
286     my $dbh = C4::Context->dbh();
287     my $sth = $dbh->prepare_cached("UPDATE marc_matchers 
288                                     SET code = ?,
289                                         description = ?,
290                                         record_type = ?,
291                                         threshold = ?
292                                     WHERE matcher_id = ?");
293     $sth->execute($self->{'code'}, $self->{'description'}, $self->{'record_type'}, $self->{'threshold'}, $self->{'id'});
294 }
295
296 sub _new_marc_matchers {
297     my $self = shift;
298
299     my $dbh = C4::Context->dbh();
300     my $sth = $dbh->prepare_cached("INSERT INTO marc_matchers
301                                     (code, description, record_type, threshold)
302                                     VALUES (?, ?, ?, ?)");
303     $sth->execute($self->{'code'}, $self->{'description'}, $self->{'record_type'}, $self->{'threshold'});
304     $self->{'id'} = $dbh->{'mysql_insertid'};
305 }
306
307 sub _store_matcher_components {
308     my $self = shift;
309
310     my $dbh = C4::Context->dbh();
311     my $sth;
312     my $matcher_id = $self->{'id'};
313     foreach my $matchpoint (@{ $self->{'matchpoints'}}) {
314         my $matchpoint_id = $self->_store_matchpoint($matchpoint);
315         $sth = $dbh->prepare_cached("INSERT INTO matcher_matchpoints (matcher_id, matchpoint_id)
316                                      VALUES (?, ?)");
317         $sth->execute($matcher_id, $matchpoint_id);
318     }
319     foreach my $matchcheck (@{ $self->{'required_checks'} }) {
320         my $source_matchpoint_id = $self->_store_matchpoint($matchcheck->{'source_matchpoint'});
321         my $target_matchpoint_id = $self->_store_matchpoint($matchcheck->{'target_matchpoint'});
322         $sth = $dbh->prepare_cached("INSERT INTO matchchecks
323                                      (matcher_id, source_matchpoint_id, target_matchpoint_id)
324                                      VALUES (?, ?, ?)");
325         $sth->execute($matcher_id, $source_matchpoint_id,  $target_matchpoint_id);
326     }
327
328 }
329
330 sub _store_matchpoint {
331     my $self = shift;
332     my $matchpoint = shift;
333
334     my $dbh = C4::Context->dbh();
335     my $sth;
336     my $matcher_id = $self->{'id'};
337     $sth = $dbh->prepare_cached("INSERT INTO matchpoints (matcher_id, search_index, score)
338                                  VALUES (?, ?, ?)");
339     $sth->execute($matcher_id, $matchpoint->{'index'}, $matchpoint->{'score'}||0);
340     my $matchpoint_id = $dbh->{'mysql_insertid'};
341     my $seqnum = 0;
342     foreach my $component (@{ $matchpoint->{'components'} }) {
343         $seqnum++;
344         $sth = $dbh->prepare_cached("INSERT INTO matchpoint_components 
345                                      (matchpoint_id, sequence, tag, subfields, offset, length)
346                                      VALUES (?, ?, ?, ?, ?, ?)");
347         $sth->bind_param(1, $matchpoint_id);
348         $sth->bind_param(2, $seqnum);
349         $sth->bind_param(3, $component->{'tag'});
350         $sth->bind_param(4, join "", sort keys %{ $component->{'subfields'} });
351         $sth->bind_param(5, $component->{'offset'}||0);
352         $sth->bind_param(6, $component->{'length'});
353         $sth->execute();
354         my $matchpoint_component_id = $dbh->{'mysql_insertid'};
355         my $normseq = 0;
356         foreach my $norm (@{ $component->{'norms'} }) {
357             $normseq++;
358             $sth = $dbh->prepare_cached("INSERT INTO matchpoint_component_norms
359                                          (matchpoint_component_id, sequence, norm_routine)
360                                          VALUES (?, ?, ?)");
361             $sth->execute($matchpoint_component_id, $normseq, $norm);
362         }
363     }
364     return $matchpoint_id;
365 }
366
367
368 =head2 delete
369
370   C4::Matcher->delete($id);
371
372 Deletes the matcher of the specified ID
373 from the database.
374
375 =cut
376
377 sub delete {
378     my $class = shift;
379     my $matcher_id = shift;
380
381     my $dbh = C4::Context->dbh;
382     my $sth = $dbh->prepare("DELETE FROM marc_matchers WHERE matcher_id = ?");
383     $sth->execute($matcher_id); # relying on cascading deletes to clean up everything
384 }
385
386 =head2 record_type
387
388   $matcher->record_type('biblio');
389   my $record_type = $matcher->record_type();
390
391 Accessor method.
392
393 =cut
394
395 sub record_type {
396     my $self = shift;
397     @_ ? $self->{'record_type'} = shift : $self->{'record_type'};
398 }
399
400 =head2 threshold
401
402   $matcher->threshold(1000);
403   my $threshold = $matcher->threshold();
404
405 Accessor method.
406
407 =cut
408
409 sub threshold {
410     my $self = shift;
411     @_ ? $self->{'threshold'} = shift : $self->{'threshold'};
412 }
413
414 =head2 _id
415
416   $matcher->_id(123);
417   my $id = $matcher->_id();
418
419 Accessor method.  Note that using this method
420 to set the DB ID of the matcher should not be
421 done outside of the editing CGI.
422
423 =cut
424
425 sub _id {
426     my $self = shift;
427     @_ ? $self->{'id'} = shift : $self->{'id'};
428 }
429
430 =head2 code
431
432   $matcher->code('ISBN');
433   my $code = $matcher->code();
434
435 Accessor method.
436
437 =cut
438
439 sub code {
440     my $self = shift;
441     @_ ? $self->{'code'} = shift : $self->{'code'};
442 }
443
444 =head2 description
445
446   $matcher->description('match on ISBN');
447   my $description = $matcher->description();
448
449 Accessor method.
450
451 =cut
452
453 sub description {
454     my $self = shift;
455     @_ ? $self->{'description'} = shift : $self->{'description'};
456 }
457
458 =head2 add_matchpoint
459
460   $matcher->add_matchpoint($index, $score, $matchcomponents);
461
462 Adds a matchpoint that may include multiple components.  The $index
463 parameter identifies the index that will be searched, while $score
464 is the weight that will be added if a match is found.
465
466 $matchcomponents should be a reference to an array of matchpoint
467 compoents, each of which should be a hash containing the following 
468 keys:
469     tag
470     subfields
471     offset
472     length
473     norms
474
475 The normalization_rules value should in turn be a reference to an
476 array, each element of which should be a reference to a 
477 normalization subroutine (under C4::Normalize) to be applied
478 to the source string.
479
480 =cut
481     
482 sub add_matchpoint {
483     my $self = shift;
484     my ($index, $score, $matchcomponents) = @_;
485
486     my $matchpoint = {};
487     $matchpoint->{'index'} = $index;
488     $matchpoint->{'score'} = $score;
489     $matchpoint->{'components'} = [];
490     foreach my $input_component (@{ $matchcomponents }) {
491         push @{ $matchpoint->{'components'} }, _parse_match_component($input_component);
492     }
493     push @{ $self->{'matchpoints'} }, $matchpoint;
494 }
495
496 =head2 add_simple_matchpoint
497
498   $matcher->add_simple_matchpoint($index, $score, $source_tag,
499                             $source_subfields, $source_offset, 
500                             $source_length, $source_normalizer);
501
502
503 Adds a simple matchpoint rule -- after composing a key based on the source tag and subfields,
504 normalized per the normalization fuction, search the index.  All records retrieved
505 will receive the assigned score.
506
507 =cut
508
509 sub add_simple_matchpoint {
510     my $self = shift;
511     my ($index, $score, $source_tag, $source_subfields, $source_offset, $source_length, $source_normalizer) = @_;
512
513     $self->add_matchpoint($index, $score, [
514                           { tag => $source_tag, subfields => $source_subfields,
515                             offset => $source_offset, 'length' => $source_length,
516                             norms => [ $source_normalizer ]
517                           }
518                          ]);
519 }
520
521 =head2 add_required_check
522
523   $match->add_required_check($source_matchpoint, $target_matchpoint);
524
525 Adds a required check definition.  A required check means that in 
526 order for a match to be considered valid, the key derived from the
527 source (incoming) record must match the key derived from the target
528 (already in DB) record.
529
530 Unlike a regular matchpoint, only the first repeat of each tag 
531 in the source and target match criteria are considered.
532
533 A typical example of a required check would be verifying that the
534 titles and publication dates match.
535
536 $source_matchpoint and $target_matchpoint are each a reference to
537 an array of hashes, where each hash follows the same definition
538 as the matchpoint component specification in add_matchpoint, i.e.,
539
540     tag
541     subfields
542     offset
543     length
544     norms
545
546 The normalization_rules value should in turn be a reference to an
547 array, each element of which should be a reference to a 
548 normalization subroutine (under C4::Normalize) to be applied
549 to the source string.
550
551 =cut
552
553 sub add_required_check {
554     my $self = shift;
555     my ($source_matchpoint, $target_matchpoint) = @_;
556
557     my $matchcheck = {};
558     $matchcheck->{'source_matchpoint'}->{'index'} = '';
559     $matchcheck->{'source_matchpoint'}->{'score'} = 0;
560     $matchcheck->{'source_matchpoint'}->{'components'} = [];
561     $matchcheck->{'target_matchpoint'}->{'index'} = '';
562     $matchcheck->{'target_matchpoint'}->{'score'} = 0;
563     $matchcheck->{'target_matchpoint'}->{'components'} = [];
564     foreach my $input_component (@{ $source_matchpoint }) {
565         push @{ $matchcheck->{'source_matchpoint'}->{'components'} }, _parse_match_component($input_component);
566     }
567     foreach my $input_component (@{ $target_matchpoint }) {
568         push @{ $matchcheck->{'target_matchpoint'}->{'components'} }, _parse_match_component($input_component);
569     }
570     push @{ $self->{'required_checks'} }, $matchcheck;
571 }
572
573 =head2 add_simple_required_check
574
575   $matcher->add_simple_required_check($source_tag, $source_subfields,
576                 $source_offset, $source_length, $source_normalizer, 
577                 $target_tag, $target_subfields, $target_offset, 
578                 $target_length, $target_normalizer);
579
580 Adds a required check, which requires that the normalized keys made from the source and targets
581 must match for a match to be considered valid.
582
583 =cut
584
585 sub add_simple_required_check {
586     my $self = shift;
587     my ($source_tag, $source_subfields, $source_offset, $source_length, $source_normalizer,
588         $target_tag, $target_subfields, $target_offset, $target_length, $target_normalizer) = @_;
589
590     $self->add_required_check(
591       [ { tag => $source_tag, subfields => $source_subfields, offset => $source_offset, 'length' => $source_length,
592           norms => [ $source_normalizer ] } ],
593       [ { tag => $target_tag, subfields => $target_subfields, offset => $target_offset, 'length' => $target_length,
594           norms => [ $target_normalizer ] } ]
595     );
596 }
597
598 =head2 get_matches
599
600   my @matches = $matcher->get_matches($marc_record, $max_matches);
601   foreach $match (@matches) {
602       # matches already sorted in order of
603       # decreasing score
604       print "record ID: $match->{'record_id'};
605       print "score:     $match->{'score'};
606   }
607
608 Identifies all of the records matching the given MARC record.  For a record already 
609 in the database to be considered a match, it must meet the following criteria:
610
611 =over 2
612
613 =item 1. Total score from its matching field must exceed the supplied threshold.
614
615 =item 2. It must pass all required checks.
616
617 =back
618
619 Only the top $max_matches matches are returned.  The returned array is sorted
620 in order of decreasing score, i.e., the best match is first.
621
622 =cut
623
624 sub get_matches {
625     my $self = shift;
626     my ($source_record, $max_matches) = @_;
627
628     my $matches = {};
629
630     foreach my $matchpoint ( @{ $self->{'matchpoints'} } ) {
631         my @source_keys = _get_match_keys( $source_record, $matchpoint );
632
633         next if scalar(@source_keys) == 0;
634
635         @source_keys = C4::Koha::GetVariationsOfISBNs(@source_keys)
636           if ( $matchpoint->{index} =~ /^isbn$/i
637             && C4::Context->preference('AggressiveMatchOnISBN') );
638
639         @source_keys = C4::Koha::GetVariationsOfISSNs(@source_keys)
640           if ( $matchpoint->{index} =~ /^issn$/i
641             && C4::Context->preference('AggressiveMatchOnISSN') );
642
643         # build query
644         my $query;
645         my $error;
646         my $searchresults;
647         my $total_hits;
648         if ( $self->{'record_type'} eq 'biblio' ) {
649
650             my $phr = ( C4::Context->preference('AggressiveMatchOnISBN') || C4::Context->preference('AggressiveMatchOnISSN') )  ? ',phr' : q{};
651             $query = join( " OR ",
652                 map { "$matchpoint->{'index'}$phr=\"$_\"" } @source_keys );
653                 #NOTE: double-quote the values so you don't get a "Embedded truncation not supported" error when a term has a ? in it.
654
655             # Use state variables to avoid recreating the objects every time.
656             # With Elasticsearch this also avoids creating a massive amount of
657             # ES connectors that would eventually run out of file descriptors.
658             state $searcher = Koha::SearchEngine::Search->new({index => $Koha::SearchEngine::BIBLIOS_INDEX});
659             ( $error, $searchresults, $total_hits ) =
660               $searcher->simple_search_compat( $query, 0, $max_matches, undef, skip_normalize => 1 );
661
662             if ( defined $error ) {
663                 warn "search failed ($query) $error";
664             }
665             else {
666                 foreach my $matched ( @{$searchresults} ) {
667                     my $target_record = C4::Search::new_record_from_zebra( 'biblioserver', $matched );
668                     my ( $biblionumber_tag, $biblionumber_subfield ) = C4::Biblio::GetMarcFromKohaField( "biblio.biblionumber" );
669                     my $id = ( $biblionumber_tag > 10 ) ?
670                         $target_record->field($biblionumber_tag)->subfield($biblionumber_subfield) :
671                         $target_record->field($biblionumber_tag)->data();
672                     $matches->{$id}->{score} += $matchpoint->{score};
673                     $matches->{$id}->{record} = $target_record;
674                 }
675             }
676
677         }
678         elsif ( $self->{'record_type'} eq 'authority' ) {
679             my @marclist;
680             my @and_or;
681             my @excluding = [];
682             my @operator;
683             my @value;
684             foreach my $key (@source_keys) {
685                 push @marclist, $matchpoint->{'index'};
686                 push @and_or,   'or';
687                 push @operator, 'exact';
688                 push @value,    $key;
689             }
690             # Use state variables to avoid recreating the objects every time.
691             # With Elasticsearch this also avoids creating a massive amount of
692             # ES connectors that would eventually run out of file descriptors.
693             state $builder  = Koha::SearchEngine::QueryBuilder->new({index => $Koha::SearchEngine::AUTHORITIES_INDEX});
694             state $searcher = Koha::SearchEngine::Search->new({index => $Koha::SearchEngine::AUTHORITIES_INDEX});
695             my $search_query = $builder->build_authorities_query_compat(
696                 \@marclist, \@and_or, \@excluding, \@operator,
697                 \@value, undef, 'AuthidAsc'
698             );
699             my ( $authresults, $total ) = $searcher->search_auth_compat( $search_query, 0, 20 );
700
701             foreach my $result (@$authresults) {
702                 my $id = $result->{authid};
703                 $matches->{$id}->{score} += $matchpoint->{'score'};
704                 $matches->{$id}->{record} = $id;
705             }
706         }
707     }
708
709     # get rid of any that don't meet the threshold
710     $matches = { map { ($matches->{$_}->{score} >= $self->{'threshold'}) ? ($_ => $matches->{$_}) : () } keys %$matches };
711
712     my @results = ();
713     if ($self->{'record_type'} eq 'biblio') {
714         require C4::Biblio;
715         # get rid of any that don't meet the required checks
716         $matches = {
717             map {
718                 _passes_required_checks( $source_record, $matches->{$_}->{'record'}, $self->{'required_checks'} )
719                   ? ( $_ => $matches->{$_} )
720                   : ()
721             } keys %$matches
722         };
723
724         foreach my $id ( keys %$matches ) {
725             push @results, {
726                 record_id => $id,
727                 score     => $matches->{$id}->{score}
728             };
729         }
730     } elsif ($self->{'record_type'} eq 'authority') {
731         require C4::AuthoritiesMarc;
732         foreach my $id (keys %$matches) {
733             push @results, {
734                 record_id => $id,
735                 score     => $matches->{$id}->{score}
736             };
737         }
738     }
739     @results = sort {
740         $b->{'score'} cmp $a->{'score'} or
741         $b->{'record_id'} cmp $a->{'record_id'}
742     } @results;
743     if (scalar(@results) > $max_matches) {
744         @results = @results[0..$max_matches-1];
745     }
746     return @results;
747 }
748
749 =head2 dump
750
751   $description = $matcher->dump();
752
753 Returns a reference to a structure containing all of the information
754 in the matcher object.  This is mainly a convenience method to
755 aid setting up a HTML editing form.
756
757 =cut
758
759 sub dump {
760     my $self = shift;
761    
762     my $result = {};
763
764     $result->{'matcher_id'} = $self->{'id'};
765     $result->{'code'} = $self->{'code'};
766     $result->{'description'} = $self->{'description'};
767     $result->{'record_type'} = $self->{'record_type'};
768
769     $result->{'matchpoints'} = [];
770     foreach my $matchpoint (@{ $self->{'matchpoints'} }) {
771         push @{  $result->{'matchpoints'} }, $matchpoint;
772     }
773     $result->{'matchchecks'} = [];
774     foreach my $matchcheck (@{ $self->{'required_checks'} }) {
775         push @{  $result->{'matchchecks'} }, $matchcheck;
776     }
777
778     return $result;
779 }
780
781 sub _passes_required_checks {
782     my ($source_record, $target_record, $matchchecks) = @_;
783
784     # no checks supplied == automatic pass
785     return 1 if $#{ $matchchecks } == -1;
786
787     foreach my $matchcheck (@{ $matchchecks }) {
788         my $source_key = join "", _get_match_keys($source_record, $matchcheck->{'source_matchpoint'});
789         my $target_key = join "", _get_match_keys($target_record, $matchcheck->{'target_matchpoint'});
790         return 0 unless $source_key eq $target_key;
791     }
792     return 1;
793 }
794
795 sub _get_match_keys {
796
797     my $source_record = shift;
798     my $matchpoint = shift;
799     my $check_only_first_repeat = @_ ? shift : 0;
800
801     # If there is more than one component to the matchpoint (e.g.,
802     # matchpoint includes both 003 and 001), any repeats
803     # of the first component's tag are identified; repeats
804     # of the subsequent components' tags are appended to
805     # each parallel key dervied from the first component,
806     # up to the number of repeats of the first component's tag.
807     #
808     # For example, if the record has one 003 and two 001s, only
809     # one key is retrieved because there is only one 003.  The key
810     # will consist of the contents of the first 003 and first 001.
811     #
812     # If there are two 003s and two 001s, there will be two keys:
813     #    first 003 + first 001
814     #    second 003 + second 001
815
816     my @keys = ();
817     for (my $i = 0; $i <= $#{ $matchpoint->{'components'} }; $i++) {
818         my $component = $matchpoint->{'components'}->[$i];
819         my $j = -1;
820
821         my @fields = ();
822         my $tag = $component->{'tag'};
823         if ($tag && $tag eq 'LDR'){
824             $fields[0] = $source_record->leader();
825         }
826         else {
827             @fields = $source_record->field($tag);
828         }
829
830         FIELD: foreach my $field (@fields) {
831             $j++;
832             last FIELD if $j > 0 and $check_only_first_repeat;
833             last FIELD if $i > 0 and $j > $#keys;
834
835             my $string;
836             if ( ! ref $field ){
837                 $string = "$field";
838             }
839             elsif ( $field->is_control_field() ) {
840                 $string = $field->data();
841             } else {
842                 $string = $field->as_string(
843                     join('', keys %{ $component->{ subfields } }), ' ' # ' ' as separator
844                 );
845             }
846
847             if ($component->{'length'}>0) {
848                 $string= substr($string, $component->{'offset'}, $component->{'length'});
849             } elsif ($component->{'offset'}) {
850                 $string= substr($string, $component->{'offset'});
851             }
852
853             my $norms = $component->{'norms'};
854             my $key = $string;
855
856             foreach my $norm ( @{ $norms } ) {
857                 if ( grep { $norm eq $_ } valid_normalization_routines() ) {
858                     if ( $norm eq 'remove_spaces' ) {
859                         $key = remove_spaces($key);
860                     }
861                     elsif ( $norm eq 'upper_case' ) {
862                         $key = upper_case($key);
863                     }
864                     elsif ( $norm eq 'lower_case' ) {
865                         $key = lower_case($key);
866                     }
867                     elsif ( $norm eq 'legacy_default' ) {
868                         $key = legacy_default($key);
869                     }
870                     elsif ( $norm eq 'ISBN' ) {
871                         $key = ISBN($key);
872                     }
873                 } else {
874                     warn "Invalid normalization routine required ($norm)"
875                         unless $norm eq 'none';
876                 }
877             }
878
879             if ($i == 0) {
880                 push @keys, $key if $key;
881             } else {
882                 $keys[$j] .= " $key" if $key;
883             }
884         }
885     }
886     return @keys;
887 }
888
889
890 sub _parse_match_component {
891     my $input_component = shift;
892
893     my $component = {};
894     $component->{'tag'} = $input_component->{'tag'};
895     $component->{'subfields'} = { map { $_ => 1 } split(//, $input_component->{'subfields'}) };
896     $component->{'offset'} = exists($input_component->{'offset'}) ? $input_component->{'offset'} : -1;
897     $component->{'length'} = $input_component->{'length'} ? $input_component->{'length'} : 0;
898     $component->{'norms'} =  $input_component->{'norms'} ? $input_component->{'norms'} : [];
899
900     return $component;
901 }
902
903 sub valid_normalization_routines {
904
905     return (
906         'remove_spaces',
907         'upper_case',
908         'lower_case',
909         'legacy_default',
910         'ISBN'
911     );
912 }
913
914 1;
915 __END__
916
917 =head1 AUTHOR
918
919 Koha Development Team <http://koha-community.org/>
920
921 Galen Charlton <galen.charlton@liblime.com>
922
923 =cut