3 # Copyright (C) 2007 LibLime, 2012 C & P Bibliography Services
5 # This file is part of Koha.
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.
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.
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>.
23 use Koha::SearchEngine;
24 use Koha::SearchEngine::Search;
25 use Koha::SearchEngine::QueryBuilder;
26 use Koha::Util::Normalize qw(
36 C4::Matcher - find MARC records matching another one
40 my @matchers = C4::Matcher::GetMatcherList();
42 my $matcher = C4::Matcher->new($record_type);
43 $matcher->threshold($threshold);
44 $matcher->code($code);
45 $matcher->description($description);
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 => [] } ]);
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 => [] } ]);
55 my @matches = $matcher->get_matches($marc_record, $max_matches);
57 foreach $match (@matches) {
59 # matches already sorted in order of
61 print "record ID: $match->{'record_id'};
62 print "score: $match->{'score'};
66 my $matcher_description = $matcher->dump();
74 my @matchers = C4::Matcher::GetMatcherList();
76 Returns an array of hashrefs list all matchers
77 present in the database. Each hashref includes:
86 my $dbh = C4::Context->dbh;
88 my $sth = $dbh->prepare_cached("SELECT matcher_id, code, description FROM marc_matchers ORDER BY matcher_id");
91 while (my $row = $sth->fetchrow_hashref) {
99 my $matcher_id = C4::Matcher::GetMatcherId($code);
101 Returns the matcher_id of a code.
107 my $dbh = C4::Context->dbh;
109 my $matcher_id = $dbh->selectrow_array("SELECT matcher_id FROM marc_matchers WHERE code = ?", undef, $code);
117 my $matcher = C4::Matcher->new($record_type, $threshold);
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.
130 $self->{'id'} = undef;
133 $self->{'record_type'} = shift;
135 $self->{'record_type'} = 'biblio';
139 $self->{'threshold'} = shift;
141 $self->{'threshold'} = 1000;
144 $self->{'code'} = '';
145 $self->{'description'} = '';
147 $self->{'matchpoints'} = [];
148 $self->{'required_checks'} = [];
156 my $matcher = C4::Matcher->fetch($id);
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.
167 my $dbh = C4::Context->dbh();
169 my $sth = $dbh->prepare_cached("SELECT * FROM marc_matchers WHERE matcher_id = ?");
171 my $row = $sth->fetchrow_hashref;
173 return unless defined $row;
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'});
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;
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'});
200 $matchcheck->{'source_matchpoint'} = $source_matchpoint;
201 $matchcheck->{'target_matchpoint'} = $target_matchpoint;
202 push @{ $self->{'required_checks'} }, $matchcheck;
208 sub _fetch_matchpoint {
210 my $matchpoint_id = shift;
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;
217 $matchpoint->{'index'} = $row->{'search_index'};
218 $matchpoint->{'score'} = int($row->{'score'});
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) {
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'};
238 push @{ $matchpoint->{'components'} }, $component;
245 my $id = $matcher->store();
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
258 if (defined $self->{'id'}) {
260 $self->_del_matcher_components();
261 $self->_update_marc_matchers();
264 $self->_new_marc_matchers();
266 $self->_store_matcher_components();
267 return $self->{'id'};
270 sub _del_matcher_components {
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
283 sub _update_marc_matchers {
286 my $dbh = C4::Context->dbh();
287 my $sth = $dbh->prepare_cached("UPDATE marc_matchers
292 WHERE matcher_id = ?");
293 $sth->execute($self->{'code'}, $self->{'description'}, $self->{'record_type'}, $self->{'threshold'}, $self->{'id'});
296 sub _new_marc_matchers {
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'};
307 sub _store_matcher_components {
310 my $dbh = C4::Context->dbh();
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)
317 $sth->execute($matcher_id, $matchpoint_id);
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)
325 $sth->execute($matcher_id, $source_matchpoint_id, $target_matchpoint_id);
330 sub _store_matchpoint {
332 my $matchpoint = shift;
334 my $dbh = C4::Context->dbh();
336 my $matcher_id = $self->{'id'};
337 $sth = $dbh->prepare_cached("INSERT INTO matchpoints (matcher_id, search_index, score)
339 $sth->execute($matcher_id, $matchpoint->{'index'}, $matchpoint->{'score'}||0);
340 my $matchpoint_id = $dbh->{'mysql_insertid'};
342 foreach my $component (@{ $matchpoint->{'components'} }) {
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'});
354 my $matchpoint_component_id = $dbh->{'mysql_insertid'};
356 foreach my $norm (@{ $component->{'norms'} }) {
358 $sth = $dbh->prepare_cached("INSERT INTO matchpoint_component_norms
359 (matchpoint_component_id, sequence, norm_routine)
361 $sth->execute($matchpoint_component_id, $normseq, $norm);
364 return $matchpoint_id;
370 C4::Matcher->delete($id);
372 Deletes the matcher of the specified ID
379 my $matcher_id = shift;
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
388 $matcher->record_type('biblio');
389 my $record_type = $matcher->record_type();
397 @_ ? $self->{'record_type'} = shift : $self->{'record_type'};
402 $matcher->threshold(1000);
403 my $threshold = $matcher->threshold();
411 @_ ? $self->{'threshold'} = shift : $self->{'threshold'};
417 my $id = $matcher->_id();
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.
427 @_ ? $self->{'id'} = shift : $self->{'id'};
432 $matcher->code('ISBN');
433 my $code = $matcher->code();
441 @_ ? $self->{'code'} = shift : $self->{'code'};
446 $matcher->description('match on ISBN');
447 my $description = $matcher->description();
455 @_ ? $self->{'description'} = shift : $self->{'description'};
458 =head2 add_matchpoint
460 $matcher->add_matchpoint($index, $score, $matchcomponents);
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.
466 $matchcomponents should be a reference to an array of matchpoint
467 compoents, each of which should be a hash containing the following
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.
484 my ($index, $score, $matchcomponents) = @_;
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);
493 push @{ $self->{'matchpoints'} }, $matchpoint;
496 =head2 add_simple_matchpoint
498 $matcher->add_simple_matchpoint($index, $score, $source_tag,
499 $source_subfields, $source_offset,
500 $source_length, $source_normalizer);
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.
509 sub add_simple_matchpoint {
511 my ($index, $score, $source_tag, $source_subfields, $source_offset, $source_length, $source_normalizer) = @_;
513 $self->add_matchpoint($index, $score, [
514 { tag => $source_tag, subfields => $source_subfields,
515 offset => $source_offset, 'length' => $source_length,
516 norms => [ $source_normalizer ]
521 =head2 add_required_check
523 $match->add_required_check($source_matchpoint, $target_matchpoint);
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.
530 Unlike a regular matchpoint, only the first repeat of each tag
531 in the source and target match criteria are considered.
533 A typical example of a required check would be verifying that the
534 titles and publication dates match.
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.,
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.
553 sub add_required_check {
555 my ($source_matchpoint, $target_matchpoint) = @_;
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);
567 foreach my $input_component (@{ $target_matchpoint }) {
568 push @{ $matchcheck->{'target_matchpoint'}->{'components'} }, _parse_match_component($input_component);
570 push @{ $self->{'required_checks'} }, $matchcheck;
573 =head2 add_simple_required_check
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);
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.
585 sub add_simple_required_check {
587 my ($source_tag, $source_subfields, $source_offset, $source_length, $source_normalizer,
588 $target_tag, $target_subfields, $target_offset, $target_length, $target_normalizer) = @_;
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 ] } ]
600 my @matches = $matcher->get_matches($marc_record, $max_matches);
601 foreach $match (@matches) {
602 # matches already sorted in order of
604 print "record ID: $match->{'record_id'};
605 print "score: $match->{'score'};
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:
613 =item 1. Total score from its matching field must exceed the supplied threshold.
615 =item 2. It must pass all required checks.
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.
626 my ($source_record, $max_matches) = @_;
630 foreach my $matchpoint ( @{ $self->{'matchpoints'} } ) {
631 my @source_keys = _get_match_keys( $source_record, $matchpoint );
633 next if scalar(@source_keys) == 0;
635 @source_keys = C4::Koha::GetVariationsOfISBNs(@source_keys)
636 if ( $matchpoint->{index} =~ /^isbn$/i
637 && C4::Context->preference('AggressiveMatchOnISBN') );
639 @source_keys = C4::Koha::GetVariationsOfISSNs(@source_keys)
640 if ( $matchpoint->{index} =~ /^issn$/i
641 && C4::Context->preference('AggressiveMatchOnISSN') );
648 if ( $self->{'record_type'} eq 'biblio' ) {
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.
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 );
662 if ( defined $error ) {
663 warn "search failed ($query) $error";
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;
678 elsif ( $self->{'record_type'} eq 'authority' ) {
684 foreach my $key (@source_keys) {
685 push @marclist, $matchpoint->{'index'};
687 push @operator, 'exact';
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'
699 my ( $authresults, $total ) = $searcher->search_auth_compat( $search_query, 0, 20 );
701 foreach my $result (@$authresults) {
702 my $id = $result->{authid};
703 my $target_record = Koha::Authorities->find( $id )->record;
704 $matches->{$id}->{score} += $matchpoint->{'score'};
705 $matches->{$id}->{record} = $target_record;
710 # get rid of any that don't meet the threshold
711 $matches = { map { ($matches->{$_}->{score} >= $self->{'threshold'}) ? ($_ => $matches->{$_}) : () } keys %$matches };
714 if ($self->{'record_type'} eq 'biblio') {
716 # get rid of any that don't meet the required checks
719 _passes_required_checks( $source_record, $matches->{$_}->{'record'}, $self->{'required_checks'} )
720 ? ( $_ => $matches->{$_} )
725 foreach my $id ( keys %$matches ) {
728 score => $matches->{$id}->{score}
731 } elsif ($self->{'record_type'} eq 'authority') {
732 require C4::AuthoritiesMarc;
733 # get rid of any that don't meet the required checks
736 _passes_required_checks( $source_record, $matches->{$_}->{'record'}, $self->{'required_checks'} )
737 ? ( $_ => $matches->{$_} )
742 foreach my $id (keys %$matches) {
745 score => $matches->{$id}->{score}
750 $b->{'score'} cmp $a->{'score'} or
751 $b->{'record_id'} cmp $a->{'record_id'}
753 if (scalar(@results) > $max_matches) {
754 @results = @results[0..$max_matches-1];
761 $description = $matcher->dump();
763 Returns a reference to a structure containing all of the information
764 in the matcher object. This is mainly a convenience method to
765 aid setting up a HTML editing form.
774 $result->{'matcher_id'} = $self->{'id'};
775 $result->{'code'} = $self->{'code'};
776 $result->{'description'} = $self->{'description'};
777 $result->{'record_type'} = $self->{'record_type'};
779 $result->{'matchpoints'} = [];
780 foreach my $matchpoint (@{ $self->{'matchpoints'} }) {
781 push @{ $result->{'matchpoints'} }, $matchpoint;
783 $result->{'matchchecks'} = [];
784 foreach my $matchcheck (@{ $self->{'required_checks'} }) {
785 push @{ $result->{'matchchecks'} }, $matchcheck;
791 sub _passes_required_checks {
792 my ($source_record, $target_record, $matchchecks) = @_;
794 # no checks supplied == automatic pass
795 return 1 if $#{ $matchchecks } == -1;
797 foreach my $matchcheck (@{ $matchchecks }) {
798 my $source_key = join "", _get_match_keys($source_record, $matchcheck->{'source_matchpoint'});
799 my $target_key = join "", _get_match_keys($target_record, $matchcheck->{'target_matchpoint'});
800 return 0 unless $source_key eq $target_key;
805 sub _get_match_keys {
807 my $source_record = shift;
808 my $matchpoint = shift;
809 my $check_only_first_repeat = @_ ? shift : 0;
811 # If there is more than one component to the matchpoint (e.g.,
812 # matchpoint includes both 003 and 001), any repeats
813 # of the first component's tag are identified; repeats
814 # of the subsequent components' tags are appended to
815 # each parallel key dervied from the first component,
816 # up to the number of repeats of the first component's tag.
818 # For example, if the record has one 003 and two 001s, only
819 # one key is retrieved because there is only one 003. The key
820 # will consist of the contents of the first 003 and first 001.
822 # If there are two 003s and two 001s, there will be two keys:
823 # first 003 + first 001
824 # second 003 + second 001
827 for (my $i = 0; $i <= $#{ $matchpoint->{'components'} }; $i++) {
828 my $component = $matchpoint->{'components'}->[$i];
832 my $tag = $component->{'tag'};
833 if ($tag && $tag eq 'LDR'){
834 $fields[0] = $source_record->leader();
837 @fields = $source_record->field($tag);
840 FIELD: foreach my $field (@fields) {
842 last FIELD if $j > 0 and $check_only_first_repeat;
843 last FIELD if $i > 0 and $j > $#keys;
849 elsif ( $field->is_control_field() ) {
850 $string = $field->data();
851 } elsif ( defined $component->{subfields} && keys %{$component->{subfields}} ){
852 $string = $field->as_string(
853 join('', keys %{ $component->{ subfields } }), ' ' # ' ' as separator
856 $string = $field->as_string();
859 if ($component->{'length'}>0) {
860 $string= substr($string, $component->{'offset'}, $component->{'length'});
861 } elsif ($component->{'offset'}) {
862 $string= substr($string, $component->{'offset'});
865 my $norms = $component->{'norms'};
868 foreach my $norm ( @{ $norms } ) {
869 if ( grep { $norm eq $_ } valid_normalization_routines() ) {
870 if ( $norm eq 'remove_spaces' ) {
871 $key = remove_spaces($key);
873 elsif ( $norm eq 'upper_case' ) {
874 $key = upper_case($key);
876 elsif ( $norm eq 'lower_case' ) {
877 $key = lower_case($key);
879 elsif ( $norm eq 'legacy_default' ) {
880 $key = legacy_default($key);
882 elsif ( $norm eq 'ISBN' ) {
886 warn "Invalid normalization routine required ($norm)"
887 unless $norm eq 'none';
892 push @keys, $key if $key;
894 $keys[$j] .= " $key" if $key;
902 sub _parse_match_component {
903 my $input_component = shift;
906 $component->{'tag'} = $input_component->{'tag'};
907 $component->{'subfields'} = { map { $_ => 1 } split(//, $input_component->{'subfields'}) };
908 $component->{'offset'} = exists($input_component->{'offset'}) ? $input_component->{'offset'} : -1;
909 $component->{'length'} = $input_component->{'length'} ? $input_component->{'length'} : 0;
910 $component->{'norms'} = $input_component->{'norms'} ? $input_component->{'norms'} : [];
915 sub valid_normalization_routines {
931 Koha Development Team <http://koha-community.org/>
933 Galen Charlton <galen.charlton@liblime.com>