Bug 14957: (QA follow-up) Rename 'merge' => 'overlay'
authorTomas Cohen Arazi <tomascohen@theke.io>
Fri, 26 Mar 2021 16:12:11 +0000 (13:12 -0300)
committerJonathan Druart <jonathan.druart@bugs.koha-community.org>
Tue, 26 Oct 2021 14:46:02 +0000 (16:46 +0200)
This patch acknowledges the fact that 'merging' has a different meaning
in Koha than the behavior this great patchset introduces. The more
idiomatic way of describing the behavior is to talk about 'overlay rules'.

This patch also:
- Fixes kohastructure.sql missing table encoding a collation
- Fixes the atomic update completely missing a table
- Moves the syspref entry to 'Importing', probably a rebase issue

Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>
Signed-off-by: Martin Renvoize <martin.renvoize@ptfs-europe.com>
Bug 14957: (QA follow-up) Fix syntax error in atomicupdate

Signed-off-by: Martin Renvoize <martin.renvoize@ptfs-europe.com>
Bug 14957: Fix imports in tests

Signed-off-by: Martin Renvoize <martin.renvoize@ptfs-europe.com>
Bug 14957: Fix imports in marc-overlay-rules.pl

Signed-off-by: Martin Renvoize <martin.renvoize@ptfs-europe.com>
Bug 14957: Fix syspref's values

It didn't switch off when set to "don't use"

Signed-off-by: Martin Renvoize <martin.renvoize@ptfs-europe.com>
JD amended patch: Fix license statement

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
21 files changed:
C4/Biblio.pm
Koha/Exceptions/MarcMergeRule.pm [deleted file]
Koha/Exceptions/MarcOverlayRule.pm [new file with mode: 0644]
Koha/MarcMergeRule.pm [deleted file]
Koha/MarcMergeRules.pm [deleted file]
Koha/MarcOverlayRule.pm [new file with mode: 0644]
Koha/MarcOverlayRules.pm [new file with mode: 0644]
admin/marc-merge-rules.pl [deleted file]
admin/marc-overlay-rules.pl [new file with mode: 0755]
installer/data/mysql/atomicupdate/bug_14957-marc-merge-rules.perl
installer/data/mysql/kohastructure.sql
installer/data/mysql/mandatory/sysprefs.sql
installer/data/mysql/mandatory/userpermissions.sql
koha-tmpl/intranet-tmpl/prog/en/includes/admin-menu.inc
koha-tmpl/intranet-tmpl/prog/en/includes/permissions.inc
koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt
koha-tmpl/intranet-tmpl/prog/en/modules/admin/marc-merge-rules.tt [deleted file]
koha-tmpl/intranet-tmpl/prog/en/modules/admin/marc-overlay-rules.tt [new file with mode: 0644]
koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/cataloguing.pref
t/db_dependent/Biblio/MarcMergeRules.t [deleted file]
t/db_dependent/Biblio/MarcOverlayRules.t [new file with mode: 0755]

index dcce8a9..84b40b1 100644 (file)
@@ -61,7 +61,7 @@ BEGIN {
         DelBiblio
         BiblioAutoLink
         LinkBibHeadingsToAuthorities
-        ApplyMarcMergeRules
+        ApplyMarcOverlayRules
         TransformMarcToKoha
         TransformHtmlToMarc
         TransformHtmlToXml
@@ -110,12 +110,12 @@ use Koha::Acquisition::Currencies;
 use Koha::Biblio::Metadatas;
 use Koha::Holds;
 use Koha::ItemTypes;
+use Koha::MarcOverlayRules;
 use Koha::Plugins;
 use Koha::SearchEngine;
 use Koha::SearchEngine::Indexer;
 use Koha::Libraries;
 use Koha::Util::MARC;
-use Koha::MarcMergeRules;
 
 =head1 NAME
 
@@ -334,9 +334,9 @@ The C<$options> argument is a hashref with additional parameters:
 
 =item C<context>
 
-This parameter is forwared to L</ApplyMarcMergeRules> where it is used for
-selecting the current rule set if Marc Merge Rules is enabled.
-See L</ApplyMarcMergeRules> for more details.
+This parameter is forwarded to L</ApplyMarcOverlayRules> where it is used for
+selecting the current rule set if MARCOverlayRules is enabled.
+See L</ApplyMarcOverlayRules> for more details.
 
 =item C<disable_autolink>
 
@@ -385,12 +385,17 @@ sub ModBiblio {
 
     _strip_item_fields($record, $frameworkcode);
 
-    # apply merge rules
-    if (C4::Context->preference('MARCMergeRules') && $biblionumber && defined $options && exists $options->{'context'}) {
-        $record = ApplyMarcMergeRules({
+    # apply overlay rules
+    if (   C4::Context->preference('MARCOverlayRules')
+        && $biblionumber
+        && defined $options
+        && exists $options->{'context'} )
+    {
+        $record = ApplyMarcOverlayRules(
+            {
                 biblionumber => $biblionumber,
-                record => $record,
-                context => $options->{'context'},
+                record       => $record,
+                context      => $options->{'context'},
             }
         );
     }
@@ -3261,9 +3266,9 @@ sub RemoveAllNsb {
     return $record;
 }
 
-=head2 ApplyMarcMergeRules
+=head2 ApplyMarcOverlayRules
 
-    my $record = ApplyMarcMergeRules($params)
+    my $record = ApplyMarcOverlayRules($params)
 
 Applies marc merge rules to a record.
 
@@ -3299,30 +3304,28 @@ fields with this field tag from C<record>.
 
 =cut
 
-sub ApplyMarcMergeRules {
+sub ApplyMarcOverlayRules {
     my ($params) = @_;
     my $biblionumber = $params->{biblionumber};
     my $incoming_record = $params->{record};
 
     if (!$biblionumber) {
-        carp 'ApplyMarcMergeRules called on undefined biblionumber';
+        carp 'ApplyMarcOverlayRules called on undefined biblionumber';
         return;
     }
     if (!$incoming_record) {
-        carp 'ApplyMarcMergeRules called on undefined record';
+        carp 'ApplyMarcOverlayRules called on undefined record';
         return;
     }
     my $old_record = GetMarcBiblio({ biblionumber => $biblionumber });
 
-    # Skip merge rules if called with no context
+    # Skip overlay rules if called with no context
     if ($old_record && defined $params->{context}) {
-        return Koha::MarcMergeRules->merge_records($old_record, $incoming_record, $params->{context});
+        return Koha::MarcOverlayRules->merge_records($old_record, $incoming_record, $params->{context});
     }
     return $incoming_record;
 }
 
-1;
-
 =head2 _after_biblio_action_hooks
 
 Helper method that takes care of calling all plugin hooks
@@ -3346,6 +3349,8 @@ sub _after_biblio_action_hooks {
     );
 }
 
+1;
+
 __END__
 
 =head1 AUTHOR
diff --git a/Koha/Exceptions/MarcMergeRule.pm b/Koha/Exceptions/MarcMergeRule.pm
deleted file mode 100644 (file)
index a3cd708..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-package Koha::Exceptions::MarcMergeRule;
-
-# This file is part of Koha.
-#
-# Koha is free software; you can redistribute it and/or modify it under the
-# terms of the GNU General Public License as published by the Free Software
-# Foundation; either version 3 of the License, or (at your option) any later
-# version.
-#
-# Koha is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
-# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along
-# with Koha; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-
-use Modern::Perl;
-
-use Exception::Class (
-
-    'Koha::Exceptions::MarcMergeRule' => {
-        description => 'Something went wrong!',
-    },
-    'Koha::Exceptions::MarcMergeRule::InvalidTagRegExp' => {
-        isa => 'Koha::Exceptions::MarcMergeRule',
-        description => 'Invalid regular expression for tag'
-    },
-    'Koha::Exceptions::MarcMergeRule::InvalidControlFieldActions' => {
-        isa => 'Koha::Exceptions::MarcMergeRule',
-        description => 'Invalid control field actions'
-    }
-);
-
-=head1 NAME
-
-Koha::Exceptions::MarcMergeRule - Base class for MarcMergeRule exceptions
-
-=head1 Exceptions
-
-=head2 Koha::Exceptions::MarcMergeRule
-
-Generic MarcMergeRule exception
-
-=head2 Koha::Exceptions::MarcMergeRule::InvalidTagRegExp
-
-Exception for rule validation when rule tag is an invalid regular expression
-
-=head2 Koha::Exceptions::MarcMergeRule::InvalidControlFieldActions
-
-Exception for rule validation for control field rules with invalid combination of actions
-
-=cut
-
-1;
diff --git a/Koha/Exceptions/MarcOverlayRule.pm b/Koha/Exceptions/MarcOverlayRule.pm
new file mode 100644 (file)
index 0000000..f359602
--- /dev/null
@@ -0,0 +1,55 @@
+package Koha::Exceptions::MarcOverlayRule;
+
+# This file is part of Koha.
+#
+# Koha is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3 of the License, or (at your option) any later
+# version.
+#
+# Koha is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with Koha; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+use Modern::Perl;
+
+use Exception::Class (
+
+    'Koha::Exceptions::MarcOverlayRule' => {
+        description => 'Something went wrong!',
+    },
+    'Koha::Exceptions::MarcOverlayRule::InvalidTagRegExp' => {
+        isa => 'Koha::Exceptions::MarcOverlayRule',
+        description => 'Invalid regular expression for tag'
+    },
+    'Koha::Exceptions::MarcOverlayRule::InvalidControlFieldActions' => {
+        isa => 'Koha::Exceptions::MarcOverlayRule',
+        description => 'Invalid control field actions'
+    }
+);
+
+=head1 NAME
+
+Koha::Exceptions::MarcOverlayRule - Base class for MarcOverlayRule exceptions
+
+=head1 Exceptions
+
+=head2 Koha::Exceptions::MarcOverlayRule
+
+Generic MarcOverlayRule exception
+
+=head2 Koha::Exceptions::MarcOverlayRule::InvalidTagRegExp
+
+Exception for rule validation when rule tag is an invalid regular expression
+
+=head2 Koha::Exceptions::MarcOverlayRule::InvalidControlFieldActions
+
+Exception for rule validation for control field rules with invalid combination of actions
+
+=cut
+
+1;
diff --git a/Koha/MarcMergeRule.pm b/Koha/MarcMergeRule.pm
deleted file mode 100644 (file)
index d1bdbba..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-package Koha::MarcMergeRule;
-
-# This file is part of Koha.
-#
-# Koha is free software; you can redistribute it and/or modify it under the
-# terms of the GNU General Public License as published by the Free Software
-# Foundation; either version 3 of the License, or (at your option) any later
-# version.
-#
-# Koha is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
-# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along
-# with Koha; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-
-use Modern::Perl;
-
-use parent qw(Koha::Object);
-
-my $cache = Koha::Caches->get_instance();
-
-=head1 NAME
-
-Koha::MarcMergeRule - Koha MarcMergeRule Object class
-
-=cut
-
-=head2 store
-
-Override C<store> to clear marc merge rules cache.
-
-=cut
-
-sub store {
-    my $self = shift @_;
-    $cache->clear_from_cache('marc_merge_rules');
-    $self->SUPER::store(@_);
-}
-
-=head2 delete
-
-Override C<delete> to clear marc merge rules cache.
-
-=cut
-
-sub delete {
-    my $self = shift @_;
-    $cache->clear_from_cache('marc_merge_rules');
-    $self->SUPER::delete(@_);
-}
-
-sub _type {
-    return 'MarcMergeRule';
-}
-
-1;
diff --git a/Koha/MarcMergeRules.pm b/Koha/MarcMergeRules.pm
deleted file mode 100644 (file)
index 555402f..0000000
+++ /dev/null
@@ -1,381 +0,0 @@
-package Koha::MarcMergeRules;
-
-# This file is part of Koha.
-#
-# Koha is free software; you can redistribute it and/or modify it under the
-# terms of the GNU General Public License as published by the Free Software
-# Foundation; either version 3 of the License, or (at your option) any later
-# version.
-#
-# Koha is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
-# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along
-# with Koha; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
-
-use Modern::Perl;
-use List::Util qw(first);
-use Koha::MarcMergeRule;
-use Carp;
-
-use Koha::Exceptions::MarcMergeRule;
-use Try::Tiny;
-use Scalar::Util qw(looks_like_number);
-
-use parent qw(Koha::Objects);
-
-my $cache = Koha::Caches->get_instance();
-
-=head1 NAME
-
-Koha::MarcMergeRules - Koha MarcMergeRules Object set class
-
-=head1 API
-
-=head2 Class Methods
-
-=head3 operations
-
-Returns a list of all valid operations.
-
-=cut
-
-sub operations {
-    return ('add', 'append', 'remove', 'delete');
-}
-
-=head3 context_rules
-
-    my $rules = Koha::MarcMergeRules->context_rules($context);
-
-Gets all MARC merge rules for the supplied C<$context> (hashref with { module => filter, ... } values).
-
-=cut
-
-sub context_rules {
-    my ($self, $context) = @_;
-
-    return unless %{$context};
-
-    my $rules = $cache->get_from_cache('marc_merge_rules', { unsafe => 1 });
-
-    if (!$rules) {
-        $rules = {};
-        my @rules_rows = $self->_resultset()->search(
-            undef,
-            {
-                order_by => { -desc => [qw/id/] }
-            }
-        );
-        foreach my $rule_row (@rules_rows) {
-            my %rule = $rule_row->get_columns();
-            my $operations = {};
-
-            foreach my $operation ($self->operations) {
-                $operations->{$operation} = { allow => $rule{$operation}, rule => $rule{id} };
-            }
-
-            # TODO: Remove unless check and validate on saving rules?
-            if ($rule{tag} eq '*') {
-                unless (exists $rules->{$rule{module}}->{$rule{filter}}->{'*'}) {
-                    $rules->{$rule{module}}->{$rule{filter}}->{'*'} = $operations;
-                }
-            }
-            elsif ($rule{tag} =~ /^(\d{3})$/) {
-                unless (exists $rules->{$rule{module}}->{$rule{filter}}->{tags}->{$rule{tag}}) {
-                    $rules->{$rule{module}}->{$rule{filter}}->{tags}->{$rule{tag}} = $operations;
-                }
-            }
-            else {
-                my $regexps = ($rules->{$rule{module}}->{$rule{filter}}->{regexps} //= []);
-                push @{$regexps}, [$rule{tag}, $operations];
-            }
-        }
-        $cache->set_in_cache('marc_merge_rules', $rules);
-    }
-
-    my $context_rules = undef;
-    foreach my $module_name (keys %{$context}) {
-        if (
-            exists $rules->{$module_name} &&
-            exists $rules->{$module_name}->{$context->{$module_name}}
-        ) {
-            $context_rules = $rules->{$module_name}->{$context->{$module_name}};
-            last;
-        }
-    }
-    if (!$context_rules) {
-        # No perms matching specific context conditions found, try wildcard value for each active context
-        foreach my $module_name (keys %{$context}) {
-            if (exists $rules->{$module_name}->{'*'}) {
-                $context_rules = $rules->{$module_name}->{'*'};
-                last;
-            }
-        }
-    }
-    return $context_rules;
-}
-
-=head3 merge_records
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($old_record, $incoming_record, $context);
-
-Merge C<$old_record> with C<$incoming_record> applying merge rules for C<$context>.
-Returns merged record C<$merged_record>. C<$old_record>, C<$incoming_record> and
-C<$merged_record> are all MARC::Record objects.
-
-=cut
-
-sub merge_records {
-    my ($self, $old_record, $incoming_record, $context) = @_;
-
-    my $rules = $self->context_rules($context);
-
-    # Default when no rules found is to overwrite with incoming record
-    return $incoming_record unless $rules;
-
-    my $fields_by_tag = sub {
-        my ($record) = @_;
-        my $fields = {};
-        foreach my $field ($record->fields()) {
-            $fields->{$field->tag()} //= [];
-            push @{$fields->{$field->tag()}}, $field;
-        }
-        return $fields;
-    };
-
-    my $hash_field_data = sub {
-        my ($field) = @_;
-        my $indicators = join("\x1E", map { $field->indicator($_) } (1, 2));
-        return $indicators . "\x1E" . join("\x1E", sort map { join "\x1E", @{$_} } $field->subfields());
-    };
-
-    my $diff_by_key = sub {
-        my ($a, $b) = @_;
-        my @removed;
-        my @intersecting;
-        my @added;
-        my %keys_index = map { $_ => undef } (keys %{$a}, keys %{$b});
-        foreach my $key (keys %keys_index) {
-            if ($a->{$key} && $b->{$key}) {
-                push @intersecting, $a->{$key};
-            }
-            elsif ($a->{$key}) {
-                push @removed, $a->{$key};
-            }
-            else {
-                push @added, $b->{$key};
-            }
-        }
-        return (\@removed, \@intersecting, \@added);
-    };
-
-    my $tag_rules = $rules->{tags} // {};
-    my $default_rule = $rules->{'*'} // {
-        add => { allow => 1, 'rule' => 0},
-        append => { allow => 1, 'rule' => 0},
-        delete => { allow => 1, 'rule' => 0},
-        remove => { allow => 1, 'rule' => 0},
-    };
-
-    # Precompile regexps
-    my @regexp_rules = map { { regexp => qr/^$_->[0]$/, actions => $_->[1] } } @{$rules->{regexps} // []};
-
-    my $get_matching_field_rule = sub {
-        my ($tag) = @_;
-        # Exact match takes precedence, then regexp, then wildcard/defaults
-        return $tag_rules->{$tag} //
-            %{(first { $tag =~ $_->{regexp} } @regexp_rules) // {}}{actions} //
-            $default_rule;
-    };
-
-    my %merged_record_fields;
-
-    my $current_fields = $fields_by_tag->($old_record);
-    my $incoming_fields = $fields_by_tag->($incoming_record);
-
-    # First we get all new incoming fields
-    my @new_field_tags = grep { !(exists $current_fields->{$_}) } keys %{$incoming_fields};
-    foreach my $tag (@new_field_tags) {
-        my $rule = $get_matching_field_rule->($tag);
-        if ($rule->{add}->{allow}) {
-            $merged_record_fields{$tag} //= [];
-            push @{$merged_record_fields{$tag}}, @{$incoming_fields->{$tag}};
-        }
-    }
-
-    # Then we get all fields no longer present in incoming fields
-    my @deleted_field_tags = grep { !(exists $incoming_fields->{$_}) } keys %{$current_fields};
-    foreach my $tag (@deleted_field_tags) {
-        my $rule = $get_matching_field_rule->($tag);
-        if (!$rule->{delete}->{allow}) {
-            $merged_record_fields{$tag} //= [];
-            push @{$merged_record_fields{$tag}}, @{$current_fields->{$tag}};
-        }
-    }
-
-    # Then we get the intersection of fields, present both in
-    # current and incoming record (possibly to be overwritten)
-    my @common_field_tags = grep { exists $incoming_fields->{$_} } keys %{$current_fields};
-    foreach my $tag (@common_field_tags) {
-        my $rule = $get_matching_field_rule->($tag);
-
-        # Special handling for control fields
-        if ($tag < 10) {
-            if (
-                $rule->{append}->{allow} &&
-                !$rule->{remove}->{allow}
-            ) {
-                # This should be highly unlikely since we have input validation to protect against this case
-                carp "Allowing \"append\" and skipping \"remove\" is not permitted for control fields, falling back to skipping both \"append\" and \"remove\"";
-                push @{$merged_record_fields{$tag}}, @{$current_fields->{$tag}};
-            }
-            elsif ($rule->{append}->{allow}) {
-                push @{$merged_record_fields{$tag}}, @{$incoming_fields->{$tag}};
-            }
-            else {
-                push @{$merged_record_fields{$tag}}, @{$current_fields->{$tag}};
-            }
-        }
-        else {
-            # Compute intersection and diff using field data
-            my $sort_weight = 0;
-            my %current_fields_by_data = map { $hash_field_data->($_) => [$sort_weight++, $_] } @{$current_fields->{$tag}};
-
-            # Always put incoming fields after current fields
-            my %incoming_fields_by_data = map { $hash_field_data->($_) => [$sort_weight++, $_] } @{$incoming_fields->{$tag}};
-
-            my ($current_fields_only, $common_fields, $incoming_fields_only) = $diff_by_key->(\%current_fields_by_data, \%incoming_fields_by_data);
-
-            my @merged_fields;
-
-            # First add common fields (intersection)
-            # Unchanged
-            if (@{$common_fields}) {
-                push @merged_fields, @{$common_fields};
-            }
-            # Removed
-            if (@{$current_fields_only}) {
-                if (!$rule->{remove}->{allow}) {
-                    push @merged_fields, @{$current_fields_only};
-                }
-            }
-            # Appended
-            if (@{$incoming_fields_only}) {
-                if ($rule->{append}->{allow}) {
-                    push @merged_fields, @{$incoming_fields_only};
-                }
-            }
-            $merged_record_fields{$tag} //= [];
-
-            # Sort ascending according to weight (original order)
-            push @{$merged_record_fields{$tag}}, map { $_->[1] } sort { $a->[0] <=> $b->[0] } @merged_fields;
-        }
-    }
-
-    my $merged_record = MARC::Record->new();
-
-    # Leader is always overwritten, or kept???
-    $merged_record->leader($incoming_record->leader());
-
-    if (%merged_record_fields) {
-        foreach my $tag (sort keys %merged_record_fields) {
-            $merged_record->append_fields(@{$merged_record_fields{$tag}});
-        }
-    }
-    return $merged_record;
-}
-
-sub _clear_caches {
-    $cache->clear_from_cache('marc_merge_rules');
-}
-
-=head2 find_or_create
-
-Override C<find_or_create> to clear marc merge rules cache.
-
-=cut
-
-sub find_or_create {
-    my $self = shift @_;
-    $self->_clear_caches();
-    return $self->SUPER::find_or_create(@_);
-}
-
-=head2 update
-
-Override C<update> to clear marc merge rules cache.
-
-=cut
-
-sub update {
-    my $self = shift @_;
-    $self->_clear_caches();
-    return $self->SUPER::update(@_);
-}
-
-=head2 delete
-
-Override C<delete> to clear marc merge rules cache.
-
-=cut
-
-sub delete {
-    my $self = shift @_;
-    $self->_clear_caches();
-    return $self->SUPER::delete(@_);
-}
-
-=head2 validate
-
-    Koha::MarcMergeRules->validate($rule_data);
-
-Validates C<$rule_data>. Throws C<Koha::Exceptions::MarcMergeRule::InvalidTagRegExp>
-if C<$rule_data->{tag}> contains an invalid regular expression. Throws
-C<Koha::Exceptions::MarcMergeRule::InvalidControlFieldActions> if contains invalid
-combination of actions for control fields. Otherwise returns true.
-
-=cut
-
-sub validate {
-    my ($self, $rule_data) = @_;
-
-    if(exists $rule_data->{tag}) {
-        if ($rule_data->{tag} ne '*') {
-            eval { qr/$rule_data->{tag}/ };
-            if ($@) {
-                Koha::Exceptions::MarcMergeRule::InvalidTagRegExp->throw(
-                    "Invalid tag regular expression"
-                );
-            }
-        }
-        # TODO: Regexp or '*' that match controlfield not currently detected
-        if (
-            looks_like_number($rule_data->{tag}) &&
-            $rule_data->{tag} < 10 &&
-            $rule_data->{append} &&
-            !$rule_data->{remove}
-        ) {
-            Koha::Exceptions::MarcMergeRule::InvalidControlFieldActions->throw(
-                "Combination of allow append and skip remove not permitted for control fields"
-            );
-        }
-    }
-    return 1;
-}
-
-sub _type {
-    return 'MarcMergeRule';
-}
-
-=head3 object_class
-
-=cut
-
-sub object_class {
-    return 'Koha::MarcMergeRule';
-}
-
-1;
diff --git a/Koha/MarcOverlayRule.pm b/Koha/MarcOverlayRule.pm
new file mode 100644 (file)
index 0000000..6c0265f
--- /dev/null
@@ -0,0 +1,58 @@
+package Koha::MarcOverlayRule;
+
+# This file is part of Koha.
+#
+# Koha is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# Koha is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Koha; if not, see <http://www.gnu.org/licenses>.
+
+use Modern::Perl;
+
+use parent qw(Koha::Object);
+
+my $cache = Koha::Caches->get_instance();
+
+=head1 NAME
+
+Koha::MarcOverlayRule - Koha MarcOverlayRule Object class
+
+=cut
+
+=head2 store
+
+Override C<store> to clear marc merge rules cache.
+
+=cut
+
+sub store {
+    my $self = shift @_;
+    $cache->clear_from_cache('marc_overlay_rules');
+    $self->SUPER::store(@_);
+}
+
+=head2 delete
+
+Override C<delete> to clear marc merge rules cache.
+
+=cut
+
+sub delete {
+    my $self = shift @_;
+    $cache->clear_from_cache('marc_overlay_rules');
+    $self->SUPER::delete(@_);
+}
+
+sub _type {
+    return 'MarcOverlayRule';
+}
+
+1;
diff --git a/Koha/MarcOverlayRules.pm b/Koha/MarcOverlayRules.pm
new file mode 100644 (file)
index 0000000..884da13
--- /dev/null
@@ -0,0 +1,381 @@
+package Koha::MarcOverlayRules;
+
+# This file is part of Koha.
+#
+# Koha is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# Koha is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Koha; if not, see <http://www.gnu.org/licenses>.
+
+use Modern::Perl;
+use List::Util qw(first);
+use Koha::MarcOverlayRule;
+use Carp;
+
+use Koha::Exceptions::MarcOverlayRule;
+use Try::Tiny;
+use Scalar::Util qw(looks_like_number);
+
+use parent qw(Koha::Objects);
+
+my $cache = Koha::Caches->get_instance();
+
+=head1 NAME
+
+Koha::MarcOverlayRules - Koha MarcOverlayRules Object set class
+
+=head1 API
+
+=head2 Class methods
+
+=head3 operations
+
+Returns a list of all valid operations.
+
+=cut
+
+sub operations {
+    return ('add', 'append', 'remove', 'delete');
+}
+
+=head3 context_rules
+
+    my $rules = Koha::MarcOverlayRules->context_rules($context);
+
+Gets all MARC overlay rules for the supplied C<$context> (hashref with { module => filter, ... } values).
+
+=cut
+
+sub context_rules {
+    my ($self, $context) = @_;
+
+    return unless %{$context};
+
+    my $rules = $cache->get_from_cache('marc_overlay_rules', { unsafe => 1 });
+
+    if (!$rules) {
+        $rules = {};
+        my @rules_rows = $self->_resultset()->search(
+            undef,
+            {
+                order_by => { -desc => [qw/id/] }
+            }
+        );
+        foreach my $rule_row (@rules_rows) {
+            my %rule = $rule_row->get_columns();
+            my $operations = {};
+
+            foreach my $operation ($self->operations) {
+                $operations->{$operation} = { allow => $rule{$operation}, rule => $rule{id} };
+            }
+
+            # TODO: Remove unless check and validate on saving rules?
+            if ($rule{tag} eq '*') {
+                unless (exists $rules->{$rule{module}}->{$rule{filter}}->{'*'}) {
+                    $rules->{$rule{module}}->{$rule{filter}}->{'*'} = $operations;
+                }
+            }
+            elsif ($rule{tag} =~ /^(\d{3})$/) {
+                unless (exists $rules->{$rule{module}}->{$rule{filter}}->{tags}->{$rule{tag}}) {
+                    $rules->{$rule{module}}->{$rule{filter}}->{tags}->{$rule{tag}} = $operations;
+                }
+            }
+            else {
+                my $regexps = ($rules->{$rule{module}}->{$rule{filter}}->{regexps} //= []);
+                push @{$regexps}, [$rule{tag}, $operations];
+            }
+        }
+        $cache->set_in_cache('marc_overlay_rules', $rules);
+    }
+
+    my $context_rules = undef;
+    foreach my $module_name (keys %{$context}) {
+        if (
+            exists $rules->{$module_name} &&
+            exists $rules->{$module_name}->{$context->{$module_name}}
+        ) {
+            $context_rules = $rules->{$module_name}->{$context->{$module_name}};
+            last;
+        }
+    }
+    if (!$context_rules) {
+        # No perms matching specific context conditions found, try wildcard value for each active context
+        foreach my $module_name (keys %{$context}) {
+            if (exists $rules->{$module_name}->{'*'}) {
+                $context_rules = $rules->{$module_name}->{'*'};
+                last;
+            }
+        }
+    }
+    return $context_rules;
+}
+
+=head3 merge_records
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($old_record, $incoming_record, $context);
+
+Overlay C<$old_record> with C<$incoming_record> applying overlay rules for C<$context>.
+Returns merged record C<$merged_record>. C<$old_record>, C<$incoming_record> and
+C<$merged_record> are all MARC::Record objects.
+
+=cut
+
+sub merge_records {
+    my ($self, $old_record, $incoming_record, $context) = @_;
+
+    my $rules = $self->context_rules($context);
+
+    # Default when no rules found is to overwrite with incoming record
+    return $incoming_record unless $rules;
+
+    my $fields_by_tag = sub {
+        my ($record) = @_;
+        my $fields = {};
+        foreach my $field ($record->fields()) {
+            $fields->{$field->tag()} //= [];
+            push @{$fields->{$field->tag()}}, $field;
+        }
+        return $fields;
+    };
+
+    my $hash_field_data = sub {
+        my ($field) = @_;
+        my $indicators = join("\x1E", map { $field->indicator($_) } (1, 2));
+        return $indicators . "\x1E" . join("\x1E", sort map { join "\x1E", @{$_} } $field->subfields());
+    };
+
+    my $diff_by_key = sub {
+        my ($a, $b) = @_;
+        my @removed;
+        my @intersecting;
+        my @added;
+        my %keys_index = map { $_ => undef } (keys %{$a}, keys %{$b});
+        foreach my $key (keys %keys_index) {
+            if ($a->{$key} && $b->{$key}) {
+                push @intersecting, $a->{$key};
+            }
+            elsif ($a->{$key}) {
+                push @removed, $a->{$key};
+            }
+            else {
+                push @added, $b->{$key};
+            }
+        }
+        return (\@removed, \@intersecting, \@added);
+    };
+
+    my $tag_rules = $rules->{tags} // {};
+    my $default_rule = $rules->{'*'} // {
+        add => { allow => 1, 'rule' => 0},
+        append => { allow => 1, 'rule' => 0},
+        delete => { allow => 1, 'rule' => 0},
+        remove => { allow => 1, 'rule' => 0},
+    };
+
+    # Precompile regexps
+    my @regexp_rules = map { { regexp => qr/^$_->[0]$/, actions => $_->[1] } } @{$rules->{regexps} // []};
+
+    my $get_matching_field_rule = sub {
+        my ($tag) = @_;
+        # Exact match takes precedence, then regexp, then wildcard/defaults
+        return $tag_rules->{$tag} //
+            %{(first { $tag =~ $_->{regexp} } @regexp_rules) // {}}{actions} //
+            $default_rule;
+    };
+
+    my %merged_record_fields;
+
+    my $current_fields = $fields_by_tag->($old_record);
+    my $incoming_fields = $fields_by_tag->($incoming_record);
+
+    # First we get all new incoming fields
+    my @new_field_tags = grep { !(exists $current_fields->{$_}) } keys %{$incoming_fields};
+    foreach my $tag (@new_field_tags) {
+        my $rule = $get_matching_field_rule->($tag);
+        if ($rule->{add}->{allow}) {
+            $merged_record_fields{$tag} //= [];
+            push @{$merged_record_fields{$tag}}, @{$incoming_fields->{$tag}};
+        }
+    }
+
+    # Then we get all fields no longer present in incoming fields
+    my @deleted_field_tags = grep { !(exists $incoming_fields->{$_}) } keys %{$current_fields};
+    foreach my $tag (@deleted_field_tags) {
+        my $rule = $get_matching_field_rule->($tag);
+        if (!$rule->{delete}->{allow}) {
+            $merged_record_fields{$tag} //= [];
+            push @{$merged_record_fields{$tag}}, @{$current_fields->{$tag}};
+        }
+    }
+
+    # Then we get the intersection of fields, present both in
+    # current and incoming record (possibly to be overwritten)
+    my @common_field_tags = grep { exists $incoming_fields->{$_} } keys %{$current_fields};
+    foreach my $tag (@common_field_tags) {
+        my $rule = $get_matching_field_rule->($tag);
+
+        # Special handling for control fields
+        if ($tag < 10) {
+            if (
+                $rule->{append}->{allow} &&
+                !$rule->{remove}->{allow}
+            ) {
+                # This should be highly unlikely since we have input validation to protect against this case
+                carp "Allowing \"append\" and skipping \"remove\" is not permitted for control fields, falling back to skipping both \"append\" and \"remove\"";
+                push @{$merged_record_fields{$tag}}, @{$current_fields->{$tag}};
+            }
+            elsif ($rule->{append}->{allow}) {
+                push @{$merged_record_fields{$tag}}, @{$incoming_fields->{$tag}};
+            }
+            else {
+                push @{$merged_record_fields{$tag}}, @{$current_fields->{$tag}};
+            }
+        }
+        else {
+            # Compute intersection and diff using field data
+            my $sort_weight = 0;
+            my %current_fields_by_data = map { $hash_field_data->($_) => [$sort_weight++, $_] } @{$current_fields->{$tag}};
+
+            # Always put incoming fields after current fields
+            my %incoming_fields_by_data = map { $hash_field_data->($_) => [$sort_weight++, $_] } @{$incoming_fields->{$tag}};
+
+            my ($current_fields_only, $common_fields, $incoming_fields_only) = $diff_by_key->(\%current_fields_by_data, \%incoming_fields_by_data);
+
+            my @merged_fields;
+
+            # First add common fields (intersection)
+            # Unchanged
+            if (@{$common_fields}) {
+                push @merged_fields, @{$common_fields};
+            }
+            # Removed
+            if (@{$current_fields_only}) {
+                if (!$rule->{remove}->{allow}) {
+                    push @merged_fields, @{$current_fields_only};
+                }
+            }
+            # Appended
+            if (@{$incoming_fields_only}) {
+                if ($rule->{append}->{allow}) {
+                    push @merged_fields, @{$incoming_fields_only};
+                }
+            }
+            $merged_record_fields{$tag} //= [];
+
+            # Sort ascending according to weight (original order)
+            push @{$merged_record_fields{$tag}}, map { $_->[1] } sort { $a->[0] <=> $b->[0] } @merged_fields;
+        }
+    }
+
+    my $merged_record = MARC::Record->new();
+
+    # Leader is always overwritten, or kept???
+    $merged_record->leader($incoming_record->leader());
+
+    if (%merged_record_fields) {
+        foreach my $tag (sort keys %merged_record_fields) {
+            $merged_record->append_fields(@{$merged_record_fields{$tag}});
+        }
+    }
+    return $merged_record;
+}
+
+sub _clear_caches {
+    $cache->clear_from_cache('marc_overlay_rules');
+}
+
+=head2 find_or_create
+
+Override C<find_or_create> to clear marc overlay rules cache.
+
+=cut
+
+sub find_or_create {
+    my $self = shift @_;
+    $self->_clear_caches();
+    return $self->SUPER::find_or_create(@_);
+}
+
+=head2 update
+
+Override C<update> to clear marc overlay rules cache.
+
+=cut
+
+sub update {
+    my $self = shift @_;
+    $self->_clear_caches();
+    return $self->SUPER::update(@_);
+}
+
+=head2 delete
+
+Override C<delete> to clear marc overlay rules cache.
+
+=cut
+
+sub delete {
+    my $self = shift @_;
+    $self->_clear_caches();
+    return $self->SUPER::delete(@_);
+}
+
+=head2 validate
+
+    Koha::MarcOverlayRules->validate($rule_data);
+
+Validates C<$rule_data>. Throws C<Koha::Exceptions::MarcOverlayRule::InvalidTagRegExp>
+if C<$rule_data->{tag}> contains an invalid regular expression. Throws
+C<Koha::Exceptions::MarcOverlayRule::InvalidControlFieldActions> if contains invalid
+combination of actions for control fields. Otherwise returns true.
+
+=cut
+
+sub validate {
+    my ($self, $rule_data) = @_;
+
+    if(exists $rule_data->{tag}) {
+        if ($rule_data->{tag} ne '*') {
+            eval { qr/$rule_data->{tag}/ };
+            if ($@) {
+                Koha::Exceptions::MarcOverlayRule::InvalidTagRegExp->throw(
+                    "Invalid tag regular expression"
+                );
+            }
+        }
+        # TODO: Regexp or '*' that match controlfield not currently detected
+        if (
+            looks_like_number($rule_data->{tag}) &&
+            $rule_data->{tag} < 10 &&
+            $rule_data->{append} &&
+            !$rule_data->{remove}
+        ) {
+            Koha::Exceptions::MarcOverlayRule::InvalidControlFieldActions->throw(
+                "Combination of allow append and skip remove not permitted for control fields"
+            );
+        }
+    }
+    return 1;
+}
+
+sub _type {
+    return 'MarcOverlayRule';
+}
+
+=head3 object_class
+
+=cut
+
+sub object_class {
+    return 'Koha::MarcOverlayRule';
+}
+
+1;
diff --git a/admin/marc-merge-rules.pl b/admin/marc-merge-rules.pl
deleted file mode 100755 (executable)
index 0c9a543..0000000
+++ /dev/null
@@ -1,162 +0,0 @@
-#!/usr/bin/perl
-
-# This file is part of Koha.
-#
-# Koha is free software; you can redistribute it and/or modify it
-# under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 3 of the License, or
-# (at your option) any later version.
-#
-# Koha is distributed in the hope that it will be useful, but
-# WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with Koha; if not, see <http://www.gnu.org/licenses>.
-
-use Modern::Perl;
-
-# standard or CPAN modules used
-use CGI qw ( -utf8 );
-use CGI::Cookie;
-use MARC::File::USMARC;
-use Try::Tiny;
-
-# Koha modules used
-use C4::Context;
-use C4::Koha;
-use C4::Auth;
-use C4::AuthoritiesMarc;
-use C4::Output;
-use C4::Biblio;
-use C4::ImportBatch;
-use C4::Matcher;
-use C4::BackgroundJob;
-use C4::Labels::Batch;
-use Koha::MarcMergeRules;
-use Koha::MarcMergeRule;
-use Koha::Patron::Categories; # TODO: Required? Try without use
-
-my $script_name = "/cgi-bin/koha/admin/marc-merge-rules.pl";
-
-my $input = new CGI;
-my $op = $input->param('op') || '';
-my $errors = [];
-
-my $rule_from_cgi = sub {
-    my ($cgi) = @_;
-
-    my %rule = map { $_ => scalar $cgi->param($_) } (
-        'tag',
-        'module',
-        'filter',
-        'add',
-        'append',
-        'remove',
-        'delete'
-    );
-
-    my $id = $cgi->param('id');
-    if ($id) {
-        $rule{id} = $id;
-    }
-
-    return \%rule;
-};
-
-my ($template, $loggedinuser, $cookie) = get_template_and_user(
-    {
-        template_name   => "admin/marc-merge-rules.tt",
-        query           => $input,
-        type            => "intranet",
-        authnotrequired => 0,
-        flagsrequired   => { parameters => 'manage_marc_merge_rules' },
-        debug           => 1,
-    }
-);
-
-$template->param(script_name => $script_name);
-
-my %cookies = parse CGI::Cookie($cookie);
-our $sessionID = $cookies{'CGISESSID'}->value;
-
-my $get_rules = sub {
-    # TODO: order?
-    return [map { { $_->get_columns() } } Koha::MarcMergeRules->_resultset->all];
-};
-my $rules;
-
-if ($op eq 'remove' || $op eq 'doremove') {
-    my @remove_ids = $input->multi_param('batchremove');
-    push @remove_ids, scalar $input->param('id') if $input->param('id');
-    if ($op eq 'remove') {
-        $template->{VARS}->{removeConfirm} = 1;
-        my %remove_ids = map { $_ => undef } @remove_ids;
-        $rules = $get_rules->();
-        for my $rule (@{$rules}) {
-            $rule->{'removemarked'} = 1 if exists $remove_ids{$rule->{id}};
-        }
-    }
-    elsif ($op eq 'doremove') {
-        my @remove_ids = $input->multi_param('batchremove');
-        push @remove_ids, scalar $input->param('id') if $input->param('id');
-        Koha::MarcMergeRules->search({ id => { in => \@remove_ids } })->delete();
-        $rules = $get_rules->();
-    }
-}
-elsif ($op eq 'edit') {
-    $template->{VARS}->{edit} = 1;
-    my $id = $input->param('id');
-    $rules = $get_rules->();
-    for my $rule(@{$rules}) {
-        if ($rule->{id} == $id) {
-            $rule->{'edit'} = 1;
-            last;
-        }
-    }
-}
-elsif ($op eq 'doedit' || $op eq 'add') {
-    my $rule_data = $rule_from_cgi->($input);
-    if (!@{$errors}) {
-        try {
-            Koha::MarcMergeRules->validate($rule_data);
-        }
-        catch {
-            die $_ unless blessed $_ && $_->can('rethrow');
-
-            if ($_->isa('Koha::Exceptions::MarcMergeRule::InvalidTagRegExp')) {
-                push @{$errors}, {
-                    type => 'error',
-                    code => 'invalid_tag_regexp',
-                    tag => $rule_data->{tag},
-                };
-            }
-            elsif ($_->isa('Koha::Exceptions::MarcMergeRule::InvalidControlFieldActions')) {
-                push @{$errors}, {
-                    type => 'error',
-                    code => 'invalid_control_field_actions',
-                    tag => $rule_data->{tag},
-                };
-            }
-            else {
-                $_->rethrow;
-            }
-        };
-        if (!@{$errors}) {
-            my $rule = Koha::MarcMergeRules->find_or_create($rule_data);
-            # Need to call set and store here in case we have an update
-            $rule->set($rule_data);
-            $rule->store();
-        }
-        $rules = $get_rules->();
-    }
-}
-else {
-    $rules = $get_rules->();
-}
-
-my $categorycodes = Koha::Patron::Categories->search_limited({}, {order_by => ['description']});
-$template->param( rules => $rules, categorycodes => $categorycodes, messages => $errors );
-
-output_html_with_http_headers $input, $cookie, $template->output;
diff --git a/admin/marc-overlay-rules.pl b/admin/marc-overlay-rules.pl
new file mode 100755 (executable)
index 0000000..dcc4f1d
--- /dev/null
@@ -0,0 +1,158 @@
+#!/usr/bin/perl
+
+# This file is part of Koha.
+#
+# Koha is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# Koha is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Koha; if not, see <http://www.gnu.org/licenses>.
+
+use Modern::Perl;
+
+# standard or CPAN modules used
+use CGI qw ( -utf8 );
+use CGI::Cookie;
+use MARC::File::USMARC;
+use Try::Tiny;
+
+# Koha modules used
+use C4::Context;
+use C4::Auth qw( get_template_and_user );
+use C4::Output qw( output_html_with_http_headers );
+use C4::ImportBatch;
+use C4::Matcher;
+use C4::BackgroundJob;
+use C4::Labels::Batch;
+use Koha::MarcOverlayRules;
+use Koha::Patron::Categories;
+
+my $script_name = "/cgi-bin/koha/admin/marc-overlay-rules.pl";
+
+my $input = new CGI;
+my $op = $input->param('op') || '';
+my $errors = [];
+
+my $rule_from_cgi = sub {
+    my ($cgi) = @_;
+
+    my %rule = map { $_ => scalar $cgi->param($_) } (
+        'tag',
+        'module',
+        'filter',
+        'add',
+        'append',
+        'remove',
+        'delete'
+    );
+
+    my $id = $cgi->param('id');
+    if ($id) {
+        $rule{id} = $id;
+    }
+
+    return \%rule;
+};
+
+my ($template, $loggedinuser, $cookie) = get_template_and_user(
+    {
+        template_name   => "admin/marc-overlay-rules.tt",
+        query           => $input,
+        type            => "intranet",
+        authnotrequired => 0,
+        flagsrequired   => { parameters => 'manage_marc_overlay_rules' },
+        debug           => 1,
+    }
+);
+
+$template->param(script_name => $script_name);
+
+my %cookies = parse CGI::Cookie($cookie);
+our $sessionID = $cookies{'CGISESSID'}->value;
+
+my $get_rules = sub {
+    # TODO: order?
+    return [map { { $_->get_columns() } } Koha::MarcOverlayRules->_resultset->all];
+};
+my $rules;
+
+if ($op eq 'remove' || $op eq 'doremove') {
+    my @remove_ids = $input->multi_param('batchremove');
+    push @remove_ids, scalar $input->param('id') if $input->param('id');
+    if ($op eq 'remove') {
+        $template->{VARS}->{removeConfirm} = 1;
+        my %remove_ids = map { $_ => undef } @remove_ids;
+        $rules = $get_rules->();
+        for my $rule (@{$rules}) {
+            $rule->{'removemarked'} = 1 if exists $remove_ids{$rule->{id}};
+        }
+    }
+    elsif ($op eq 'doremove') {
+        my @remove_ids = $input->multi_param('batchremove');
+        push @remove_ids, scalar $input->param('id') if $input->param('id');
+        Koha::MarcOverlayRules->search({ id => { in => \@remove_ids } })->delete();
+        $rules = $get_rules->();
+    }
+}
+elsif ($op eq 'edit') {
+    $template->{VARS}->{edit} = 1;
+    my $id = $input->param('id');
+    $rules = $get_rules->();
+    for my $rule(@{$rules}) {
+        if ($rule->{id} == $id) {
+            $rule->{'edit'} = 1;
+            last;
+        }
+    }
+}
+elsif ($op eq 'doedit' || $op eq 'add') {
+    my $rule_data = $rule_from_cgi->($input);
+    if (!@{$errors}) {
+        try {
+            Koha::MarcOverlayRules->validate($rule_data);
+        }
+        catch {
+            die $_ unless blessed $_ && $_->can('rethrow');
+
+            if ($_->isa('Koha::Exceptions::MarcOverlayRule::InvalidTagRegExp')) {
+                push @{$errors}, {
+                    type => 'error',
+                    code => 'invalid_tag_regexp',
+                    tag => $rule_data->{tag},
+                };
+            }
+            elsif ($_->isa('Koha::Exceptions::MarcOverlayRule::InvalidControlFieldActions')) {
+                push @{$errors}, {
+                    type => 'error',
+                    code => 'invalid_control_field_actions',
+                    tag => $rule_data->{tag},
+                };
+            }
+            else {
+                $_->rethrow;
+            }
+        };
+        if (!@{$errors}) {
+            my $rule = Koha::MarcOverlayRules->find_or_create($rule_data);
+            # Need to call set and store here in case we have an update
+            $rule->set($rule_data);
+            $rule->store();
+        }
+        $rules = $get_rules->();
+    }
+}
+else {
+    $rules = $get_rules->();
+}
+
+my $categorycodes = Koha::Patron::Categories->search_limited({}, {order_by => ['description']});
+$template->param( rules => $rules, categorycodes => $categorycodes, messages => $errors );
+
+output_html_with_http_headers $input, $cookie, $template->output;
index e6f1c69..85e40b9 100644 (file)
@@ -1,41 +1,51 @@
-$DBversion = 'XXX'; # will be replaced by the RM
-if( CheckVersion( $DBversion ) ) {
-    my $sql = q{
-      CREATE TABLE IF NOT EXISTS `marc_merge_rules` (
-        `id` int(11) NOT NULL auto_increment,
-        `tag` varchar(255) NOT NULL,
-        `module` varchar(127) NOT NULL,
-        `filter` varchar(255) NOT NULL,
-        `add` tinyint NOT NULL,
-        `append` tinyint NOT NULL,
-        `remove` tinyint NOT NULL,
-        `delete` tinyint NOT NULL,
-        PRIMARY KEY(`id`)
-      );
-    };
-    $dbh->do( $sql );
+$DBversion = 'XXX';    # will be replaced by the RM
+if ( CheckVersion($DBversion) ) {
+
+    unless (TableExists('marc_overlay_rules_modules')) {
+        $dbh->do(q{
+            CREATE TABLE `marc_overlay_rules_modules` (
+              `name` varchar(127) NOT NULL,
+              `description` varchar(255),
+              `specificity` int(11) NOT NULL UNIQUE,
+              PRIMARY KEY(`name`)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+        });
+    }
+
+    unless ( TableExists('marc_overlay_rules') ) {
+        $dbh->do(q{
+            CREATE TABLE IF NOT EXISTS `marc_overlay_rules` (
+              `id` int(11) NOT NULL auto_increment,
+              `tag` varchar(255) NOT NULL, -- can be regexp, so need > 3 chars
+              `module` varchar(127) NOT NULL,
+              `filter` varchar(255) NOT NULL,
+              `add`    TINYINT(1) NOT NULL DEFAULT 0,
+              `append` TINYINT(1) NOT NULL DEFAULT 0,
+              `remove` TINYINT(1) NOT NULL DEFAULT 0,
+              `delete` TINYINT(1) NOT NULL DEFAULT 0,
+              PRIMARY KEY(`id`),
+              CONSTRAINT `marc_overlay_rules_ibfk1` FOREIGN KEY (`module`) REFERENCES `marc_overlay_rules_modules` (`name`) ON DELETE CASCADE ON UPDATE CASCADE
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+        });
+    }
 
-    $sql = q{
+    $dbh->do(q{
       INSERT IGNORE INTO systempreferences (`variable`, `value`, `options`, `explanation`, `type`) VALUES (
-        'MARCMergeRules',
+        'MARCOverlayRules',
         '0',
         NULL,
-        'Use the MARC merge rules system to decide what actions to take for each field when modifying records.',
+        'Use the MARC record overlay rules system to decide what actions to take for each field when modifying records.',
         'YesNo'
       );
-    };
-    $dbh->do( $sql );
+    });
 
-    $sql = q{
+    $dbh->do(q{
       INSERT IGNORE INTO permissions (module_bit, code, description) VALUES (
         3,
-        'manage_marc_merge_rules',
-        'Manage MARC merge rules configuration'
+        'manage_marc_overlay_rules',
+        'Manage MARC overlay rules configuration'
       );
-    };
-    $dbh->do( $sql );
+    });
 
-    # Always end with this (adjust the bug info)
-    SetVersion( $DBversion );
-    print "Upgrade to $DBversion done (Bug 14957 - Write protecting MARC fields based on source of import)\n";
+    NewVersion( $DBversion, 14957, "Add a way to define overlay rules for incoming MARC records)\n" );
 }
index fbe01ea..9bc4add 100644 (file)
@@ -3386,11 +3386,11 @@ CREATE TABLE `marc_matchers` (
 /*!40101 SET character_set_client = @saved_cs_client */;
 
 --
--- Table structure for table `marc_merge_rules_modules`
+-- Table structure for table `marc_overlay_rules_modules`
 --
 
-DROP TABLE IF EXISTS `marc_merge_rules_modules`;
-CREATE TABLE `marc_merge_rules_modules` (
+DROP TABLE IF EXISTS `marc_overlay_rules_modules`;
+CREATE TABLE `marc_overlay_rules_modules` (
   `name` varchar(127) NOT NULL,
   `description` varchar(255),
   `specificity` int(11) NOT NULL UNIQUE,
@@ -3398,22 +3398,22 @@ CREATE TABLE `marc_merge_rules_modules` (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
 
 --
--- Table structure for table `marc_merge_rules`
+-- Table structure for table `marc_overlay_rules`
 --
 
-DROP TABLE IF EXISTS `marc_merge_rules`;
-CREATE TABLE IF NOT EXISTS `marc_merge_rules` (
-  `id` int(11) NOT NULL auto_increment,
-  `tag` varchar(255) NOT NULL, -- can be regexp, so need > 3 chars
+DROP TABLE IF EXISTS `marc_overlay_rules`;
+CREATE TABLE IF NOT EXISTS `marc_overlay_rules` (
+  `id`     int(11) NOT NULL auto_increment,
+  `tag`    varchar(255) NOT NULL, -- can be regexp, so need > 3 chars
   `module` varchar(127) NOT NULL,
   `filter` varchar(255) NOT NULL,
-  `add` tinyint NOT NULL,
-  `append` tinyint NOT NULL,
-  `remove` tinyint NOT NULL,
-  `delete` tinyint NOT NULL,
+  `add`    TINYINT(1) NOT NULL DEFAULT 0,
+  `append` TINYINT(1) NOT NULL DEFAULT 0,
+  `remove` TINYINT(1) NOT NULL DEFAULT 0,
+  `delete` TINYINT(1) NOT NULL DEFAULT 0,
   PRIMARY KEY(`id`),
-  CONSTRAINT `marc_merge_rules_ibfk1` FOREIGN KEY (`module`) REFERENCES `marc_merge_rules_modules` (`name`) ON DELETE CASCADE ON UPDATE CASCADE
-);
+  CONSTRAINT `marc_overlay_rules_ibfk1` FOREIGN KEY (`module`) REFERENCES `marc_overlay_rules_modules` (`name`) ON DELETE CASCADE ON UPDATE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
 
 --
 -- Table structure for table `marc_modification_template_actions`
index e15cc15..baa038e 100644 (file)
@@ -331,9 +331,9 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, `
 ('MarcFieldForModifierName','',NULL,'Where to store the name of the record''s last modifier','Free'),
 ('MarcFieldsToOrder','',NULL,'Set the mapping values for a new order line created from a MARC record in a staged file. In a YAML format.','textarea'),
 ('MarcItemFieldsToOrder','',NULL,'Set the mapping values for new item records created from a MARC record in a staged file. In a YAML format.','textarea'),
-('MARCMergeRules','0',NULL,'Use the MARC merge rules system to decide what actions to take for each field when modifying records.','YesNo'),
 ('MarkLostItemsAsReturned','batchmod,moredetail,cronjob,additem,pendingreserves,onpayment','claim_returned|batchmod|moredetail|cronjob|additem|pendingreserves|onpayment','Mark items as returned when flagged as lost','multiple'),
 ('MARCOrgCode','OSt','','Define MARC Organization Code for MARC21 records - http://www.loc.gov/marc/organizations/orgshome.html','free'),
+('MARCOverlayRules','0',NULL,'Use the MARC record overlay rules system to decide what actions to take for each field when modifying records.','YesNo'),
 ('MaxFine',NULL,'','Maximum fine a patron can have for all late returns at one moment. Single item caps are specified in the circulation rules matrix.','Integer'),
 ('MaxItemsToDisplayForBatchDel','1000',NULL,'Display up to a given number of items in a single item deletionbatch.','Integer'),
 ('MaxItemsToDisplayForBatchMod','1000',NULL,'Display up to a given number of items in a single item modification batch.','Integer'),
index 581aaf2..f070e50 100644 (file)
@@ -25,7 +25,7 @@ INSERT INTO permissions (module_bit, code, description) VALUES
    ( 3, 'manage_oai_sets', 'Manage OAI sets'),
    ( 3, 'manage_item_search_fields', 'Manage item search fields'),
    ( 3, 'manage_search_engine_config', 'Manage search engine configuration'),
-   ( 3, 'manage_marc_merge_rules', 'Manage MARC merge rules configuration'),
+   ( 3, 'manage_marc_overlay_rules', 'Manage MARC overlay rules configuration'),
    ( 3, 'manage_search_targets', 'Manage Z39.50 and SRU server configuration'),
    ( 3, 'manage_didyoumean', 'Manage Did you mean? configuration'),
    ( 3, 'manage_column_config', 'Manage column configuration'),
index 68a005e..bfdfee2 100644 (file)
             [% IF ( CAN_user_parameters_manage_search_engine_config ) %]
                 <li><a href="/cgi-bin/koha/admin/searchengine/elasticsearch/mappings.pl">Search engine configuration (Elasticsearch)</a></li>
             [% END %]
-            [% IF ( CAN_user_parameters_manage_marc_merge_rules ) %]
-                <li><a href="/cgi-bin/koha/admin/marc-merge-rules.pl">MARC merge rules</a></li>
+            [% IF ( CAN_user_parameters_manage_marc_overlay_rules ) %]
+                <li><a href="/cgi-bin/koha/admin/marc-overlay-rules.pl">MARC overlay rules</a></li>
             [% END %]
         </ul>
     [% END %]
index 76f39a1..cbbe30f 100644 (file)
             Manage search engine configuration
         </span>
         <span class="permissioncode">([% name | html %])</span>
-    [%- CASE 'manage_marc_merge_rules' -%]
-        <span class="sub_permission manage_marc_merge_rules_subpermission">
-          Manage MARC merge rules configuration
+    [%- CASE 'manage_marc_overlay_rules' -%]
+        <span class="sub_permission manage_marc_overlay_rules_subpermission">
+          Manage MARC overlay rules configuration
         </span>
         <span class="permissioncode">([% name | html %])</span>
     [%- CASE 'manage_search_targets' -%]
index fa3b3fb..a04e916 100644 (file)
                         <dt><a href="/cgi-bin/koha/admin/searchengine/elasticsearch/mappings.pl">Search engine configuration (Elasticsearch)</a></dt>
                         <dd>Manage indexes, facets, and their mappings to MARC fields and subfields</dd>
                     [% END %]
-                    <dt><a href="/cgi-bin/koha/admin/marc-merge-rules.pl">MARC merge rules</a></dt>
-                    <dd>Managed MARC field merge rules</dd>
+                    <dt><a href="/cgi-bin/koha/admin/marc-overlay-rules.pl">MARC overlay rules</a></dt>
+                    <dd>Managed MARC field overlay rules</dd>
                 </dl>
             [% END %]
 
diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/marc-merge-rules.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/marc-merge-rules.tt
deleted file mode 100644 (file)
index 950eadd..0000000
+++ /dev/null
@@ -1,519 +0,0 @@
-[% USE Koha %]
-[% INCLUDE 'doc-head-open.inc' %]
-<title>Koha &rsaquo; Administration &rsaquo; MARC merge rules</title>
-[% INCLUDE 'doc-head-close.inc' %]
-[% Asset.css("css/datatables.css") %]
-[% INCLUDE 'datatables.inc' %]
-
-<style type="text/css">
-    .required {
-        background-color: #C00;
-    }
-</style>
-
-<script type="text/javascript">
-function doSubmit(op, id) {
-    $('<input type="hidden"/>')
-    .attr('name', 'op')
-    .attr('value', op)
-    .appendTo('#marc-merge-rules-form');
-
-    if(id) {
-        $('<input type="hidden"/>')
-        .attr('name', 'id')
-        .attr('value', id)
-        .appendTo('#marc-merge-rules-form');
-    }
-
-    var valid = true;
-    if (op == 'add' || op == 'edit') {
-        var validate = [
-            $('#marc-merge-rules-form input[name="filter"]'),
-            $('#marc-merge-rules-form input[name="tag"]')
-        ];
-        for(var i = 0; i < validate.length; i++) {
-            if (validate[i].length) {
-                if(validate[i].val().length == 0) {
-                    validate[i].addClass('required');
-                    valid = false;
-                } else {
-                    validate[i].removeClass('required');
-                }
-            }
-        }
-    }
-
-    if (valid) {
-        $('#marc-merge-rules-form').submit();
-    }
-
-    return valid;
-}
-
-$(document).ready(function(){
-    $('#doremove').on('click', function(){
-        doSubmit('doremove');
-    });
-    $('#doedit').on('click', function(){
-        doSubmit('doedit', $("#doedit").attr('value'));
-    });
-    $('#add').on('click', function(){
-        doSubmit('add');
-        return false;
-    });
-    $('#btn_batchremove').on('click', function(){
-        doSubmit('remove');
-    });
-
-    /* Disable batch remove unless one or more checkboxes are checked */
-    $('input[name="batchremove"]').change(function() {
-        if($('input[name="batchremove"]:checked').length > 0) {
-            $('#btn_batchremove').removeAttr('disabled');
-        } else {
-            $('#btn_batchremove').attr('disabled', 'disabled');
-        }
-    });
-
-    $.fn.dataTable.ext.order['dom-input'] = function (settings, col) {
-        return this.api().column(col, { order: 'index' }).nodes()
-            .map(function (td, i) {
-                if($('input', td).val() != undefined) {
-                    return $('input', td).val();
-                } else if($('select', td).val() != undefined) {
-                    return $('option[selected="selected"]', td).val();
-                } else {
-                    return $(td).html();
-                }
-            });
-    }
-
-    $('#marc-merge-rules').dataTable($.extend(true, {}, dataTablesDefaults, {
-        "aoColumns": [
-            {"bSearchable": false, "bSortable": false},
-            {"sSortDataType": "dom-input"},
-            {"sSortDataType": "dom-input"},
-            {"bSearchable": false, "sSortDataType": "dom-input"},
-            {"bSearchable": false, "sSortDataType": "dom-input"},
-            {"bSearchable": false, "sSortDataType": "dom-input"},
-            {"bSearchable": false, "sSortDataType": "dom-input"},
-            {"bSearchable": false, "sSortDataType": "dom-input"},
-            {"bSearchable": false, "sSortDataType": "dom-input"},
-            {"bSearchable": false, "bSortable": false},
-            {"bSearchable": false, "bSortable": false}
-        ],
-        "pagingType": "simple"
-    }));
-
-    var merge_rules_presets = {};
-    merge_rules_presets[_("Protect")] = {
-      'add': 0,
-      'append': 0,
-      'remove': 0,
-      'delete': 0
-    };
-    merge_rules_presets[_("Overwrite")] = {
-      'add': 1,
-      'append': 1,
-      'remove': 1,
-      'delete': 1
-    };
-    merge_rules_presets[_("Add new")] = {
-      'add': 1,
-      'append': 0,
-      'remove': 0,
-      'delete': 0
-    };
-    merge_rules_presets[_("Add and append")] = {
-      'add': 1,
-      'append': 1,
-      'remove': 0,
-      'delete': 0
-    };
-    merge_rules_presets[_("Protect from deletion")] = {
-      'add': 1,
-      'append': 1,
-      'remove': 1,
-      'delete': 0
-    };
-
-    var merge_rules_label_to_value = {};
-    merge_rules_label_to_value[_("Add")] = 1;
-    merge_rules_label_to_value[_("Append")] = 1;
-    merge_rules_label_to_value[_("Remove")] = 1;
-    merge_rules_label_to_value[_("Delete")] = 1;
-    merge_rules_label_to_value[_("Skip")] = 0;
-
-    function hash_config(config) {
-      return JSON.stringify(config, Object.keys(config).sort());
-    }
-
-    var merge_rules_preset_map = {};
-    $.each(merge_rules_presets, function(preset, config) {
-      merge_rules_preset_map[hash_config(config)] = preset;
-    });
-
-    function operations_config_merge_rule_preset(config) {
-      return merge_rules_preset_map[hash_config(config)] || '';
-    }
-
-    /* Set preset values according to operation config */
-    $('.rule').each(function() {
-      var $this = $(this);
-      var operations_config = {};
-      $('.rule-operation-action', $this).each(function() {
-        var $operation = $(this);
-        operations_config[$operation.data('operation')] = merge_rules_label_to_value[$operation.text()];
-      });
-      $('.rule-preset', $this).text(
-        operations_config_merge_rule_preset(operations_config) || _("Custom")
-      );
-    });
-
-    /* Listen to operations config changes and set presets accordingly */
-    $('.rule-operation-action-edit select').change(function() {
-      var operations_config = {};
-      var $parent_row = $(this).closest('tr');
-      $('.rule-operation-action-edit select', $parent_row).each(function() {
-        var $this = $(this);
-        operations_config[$this.attr('name')] = parseInt($this.val());
-      });
-      $('select[name="preset"]', $parent_row).val(
-          operations_config_merge_rule_preset(operations_config)
-      );
-    });
-
-    /* Listen to preset changes and set operations config accordingly */
-    $('select[name="preset"]').change(function() {
-      var $this = $(this);
-      var $parent_row = $this.closest('tr');
-      var preset = $this.val();
-      if (preset) {
-        $.each(merge_rules_presets[preset], function(operation, action) {
-          $('select[name="' + operation + '"]', $parent_row).val(action);
-        });
-      }
-    });
-
-    var module_filter_options = {
-      source: {
-        '*': '*',
-        batchmod: "Batch record modification",
-        intranet: "Staff client MARC editor",
-        batchimport: "Staged MARC import",
-        z3950: "Z39.50",
-        bulkmarcimport: "bulkmarcimport.pl",
-        import_lexile: "import_lexile.pl"
-      },
-      categorycode: {
-        '*': '*',
-        [% FOREACH categorycode IN categorycodes %]
-          [% categorycode.categorycode | html %]: "[% categorycode.description | html %]",
-        [% END %]
-      }
-    };
-
-    //Kind of hack: Replace filter value with label when one exist
-    $('.rule-module').each(function() {
-      var $this = $(this);
-      var module = $this.text();
-      if (module in module_filter_options) {
-        let $filter = $this.siblings('.rule-filter');
-        if ($filter.text() in module_filter_options[module]) {
-          $filter.text(module_filter_options[module][$filter.text()]);
-        }
-      }
-    });
-
-    var $filter_container = $('#filter-container');
-
-    /* Listen to module changes and set filter input accordingly */
-    $('select[name="module"]').change(function() {
-      var $this = $(this);
-      var module_name = $this.val();
-
-      /* Remove current element if any */
-      $filter_container.empty();
-
-      var filter_elem = null;
-      if (module_name in module_filter_options) {
-        // Create select element
-        filter_elem = document.createElement('select');
-        for (var filter_value in module_filter_options[module_name]) {
-          var option = document.createElement('option');
-          option.value = filter_value;
-          option.text = module_filter_options[module_name][filter_value];
-          filter_elem.appendChild(option);
-        }
-      }
-      else {
-        // Create text input element
-        filter_elem = document.createElement('input');
-        filter_elem.type = 'text';
-        filter_elem.setAttribute('size', 5);
-      }
-      filter_elem.name = 'filter';
-      filter_elem.id = 'filter';
-      $filter_container.append(filter_elem);
-    }).change(); // Trigger change
-
-    // Hack: set value if editing rule
-    if ($filter_container.data('filter')) {
-      $('#filter').val($filter_container.data('filter'));
-    }
-
-});
-</script>
-</head>
-<body id="admin_marc-merge-rules" class="admin">
-[% INCLUDE 'header.inc' %]
-[% INCLUDE 'cat-search.inc' %]
-
-<div id="breadcrumbs"><a href="/cgi-bin/koha/mainpage.pl">Home</a> &rsaquo; <a href="/cgi-bin/koha/admin/admin-home.pl">Administration</a>
- &rsaquo; MARC merge rules
-</div>
-
-<div class="main container-fluid">
-<div class="row">
-<div class="col-sm-10 col-sm-push-2">
-
-<h1>Manage MARC merge rules</h1>
-
-[% FOR m IN messages %]
-  <div class="dialog [% m.type | html %]">
-    [% SWITCH m.code %]
-    [% CASE 'invalid_tag_regexp' %]
-      Invalid regular expression "[% m.tag | html %]".
-    [% CASE 'invalid_control_field_actions' %]
-      Invalid combination of actions for tag [% m.tag | html %]. Control field rules do not allow "Appended: Append" and "Removed: Skip".
-    [% CASE %]
-      [% m.code | html %]
-    [% END %]
-  </div>
-[% END %]
-
-[% UNLESS Koha.Preference( 'MARCMergeRules' ) %]
-    <div class="dialog message">
-        The <b>MARCMergeRules</b> preference is not set, don't forget to enable it for rules to take effect.
-    </div>
-[% END %]
-[% IF removeConfirm %]
-<div class="dialog alert">
-<h3>Remove rule?</h3>
-<p>Are you sure you want to remove the selected rule(s)?</p>
-
-<form action="[% script_name %]" method="GET">
-    <button type="submit" class="deny"><i class="fa fa-fw fa-remove"></i> No, do not remove</button>
-</form>
-    <button type="button" class="approve" id="doremove"><i class="fa fa-fw fa-check"></i> Yes, remove</button>
-</div>
-[% END %]
-
-<form action="[% script_name %]" method="POST" id="marc-merge-rules-form">
-<table id="marc-merge-rules">
-    <thead><tr>
-        <th>Rule</th>
-        <th>Module</th>
-        <th>Filter</th>
-        <th>Tag</th>
-        <th>Preset</th>
-        <th>Added <i id="info_added" data-toggle="tooltip" title="If a field matching the rule tag only exists in the incoming record" data-placement="right" class="fa fa-info-circle"></i></th>
-        <th>Appended <i id="info_appended" data-toggle="tooltip" title="If the original record has one or more fields matching with the rule tag, but one or more fields matching the rule tag differ in the incoming record" data-placement="right" class="fa fa-info-circle"></i></th>
-        <th>Removed <i id="info_removed" data-toggle="tooltip" title="If the original record has a field matching the rule tag, but the matching field is not in the incoming record" data-placement="right" class="fa fa-info-circle"></i></th>
-        <th>Deleted <i id="info_deleted" data-toggle="tooltip" title="If the original record has fields matching the rule tag, but no fields with this are found in the incoming record" data-placement="right" class="fa fa-info-circle"></i></th>
-        <th>Actions</th>
-        <th>&nbsp;</th>
-    </tr></thead>
-    [% UNLESS edit %]
-    <tfoot>
-        <tr class="rule-new">
-            <th>&nbsp;</th>
-            <th>
-                <select name="module">
-                    <option value="source">Source</option>
-                    <option value="categorycode">User category</option>
-                    <option value="userid">Username</option>
-                </select>
-            </th>
-            <th id="filter-container"></th>
-            <th><input type="text" size="5" name="tag"/></th>
-            <th>
-                <select name="preset">
-                    <option value="" selected>Custom</option>
-                    [% FOR preset IN [
-                            'Protect',
-                            'Overwrite',
-                            'Add new',
-                            'Add and append',
-                            'Protect from deletion'
-                        ]
-                    %]
-                        <option value="[% preset | html %]">[% preset | html %]</option>
-                    [% END %]
-                </select>
-            </th>
-            <th class="rule-operation-action-edit">
-                <select name="add">
-                    <option value="0">Skip</option>
-                    <option value="1">Add</option>
-                </select>
-            </th>
-            <th class="rule-operation-action-edit">
-                <select name="append">
-                    <option value="0">Skip</option>
-                    <option value="1">Append</option>
-                </select>
-            </th>
-            <th class="rule-operation-action-edit">
-                <select name="remove">
-                    <option value="0">Skip</option>
-                    <option value="1">Remove</option>
-                </select>
-            </th>
-            <th class="rule-operation-action-edit">
-                <select name="delete">
-                    <option value="0">Skip</option>
-                    <option value="1">Delete</option>
-                </select>
-            </th>
-            <th><button class="btn btn-default btn-xs" title="Add" id="add"><i class="fa fa-plus"></i> Add rule</button></th>
-            <th><button id="btn_batchremove" disabled="disabled" class="btn btn-default btn-xs" title="Batch remove"><i class="fa fa-trash"></i> Delete selected</button></th>
-        </tr>
-    </tfoot>
-    [% END %]
-    <tbody>
-        [% FOREACH rule IN rules %]
-            <tr id="[% rule.id %]" class="rule[% IF rule.edit %]-edit[% END %]">
-            [% IF rule.edit %]
-                <td>[% rule.id %]</td>
-                <td>
-                    <select name="module">
-                        [% IF rule.module == "source" %]
-                            <option value="source" selected="selected">Source</option>
-                        [% ELSE %]
-                            <option value="source">Source</option>
-                        [% END %]
-                        [% IF rule.module == "categorycode" %]
-                            <option value="categorycode" selected="selected">User category</option>
-                        [% ELSE %]
-                            <option value="categorycode">User category</option>
-                        [% END %]
-                        [% IF rule.module == "userid" %]
-                            <option value="userid" selected="selected">Username</option>
-                        [% ELSE %]
-                            <option value="userid">Username</option>
-                        [% END %]
-                    </select>
-                </td>
-                <td id="filter-container" data-filter="[% rule.filter | html %]"></td>
-                <td><input type="text" size="3" name="tag" value="[% rule.tag | html %]"/></td>
-                <th>
-                    <select name="preset">
-                        <option value="" selected>Custom</option>
-                        [% FOR preset IN [
-                                'Protect',
-                                'Overwrite',
-                                'Add new',
-                                'Add and append',
-                                'Protect from deletion'
-                            ]
-                        %]
-                            <option value="[% preset | html %]">[% preset | html %]</option>
-                        [% END %]
-                    </select>
-                </th>
-                <td class="rule-operation-action-edit">
-                    <select name="add">
-                        [% IF rule.add %]
-                            <option value="0">Skip</option>
-                            <option value="1" selected="selected">Add</option>
-                        [% ELSE %]
-                            <option value="0" selected="selected">Skip</option>
-                            <option value="1">Add</option>
-                        [% END %]
-                    </select>
-                </td>
-                <td class="rule-operation-action-edit">
-                    <select name="append">
-                        [% IF rule.append %]
-                            <option value="0">Skip</option>
-                            <option value="1" selected="selected">Append</option>
-                        [% ELSE %]
-                            <option value="0" selected="selected">Skip</option>
-                            <option value="1">Append</option>
-                        [% END %]
-                    </select>
-                </td>
-                <td class="rule-operation-action-edit">
-                    <select name="remove">
-                        [% IF rule.remove %]
-                            <option value="0">Skip</option>
-                            <option value="1" selected="selected">Remove</option>
-                        [% ELSE %]
-                            <option value="0" selected="selected">Skip</option>
-                            <option value="1">Remove</option>
-                        [% END %]
-                    </select>
-                </td>
-                <td class="rule-operation-action-edit">
-                    <select name="delete">
-                        [% IF rule.delete %]
-                            <option value="0">Skip</option>
-                            <option value="1" selected="selected">Delete</option>
-                        [% ELSE %]
-                            <option value="0" selected="selected">Skip</option>
-                            <option value="1">Delete</option>
-                        [% END %]
-                    </select>
-                </td>
-                <td class="actions">
-                    <button class="btn btn-default btn-xs" title="Save" id="doedit" value="[% rule.id | html %]"><i class="fa fa-check"></i> Save</button>
-                    <button type="submit" class="btn btn-default btn-xs" title="Cancel" ><i class="fa fa-times"></i> Cancel</button>
-                </td>
-                <td></td>
-            [% ELSE %]
-                <td>[% rule.id | html %]</td>
-                <td class="rule-module">[% rule.module | html %]</td>
-                <td class="rule-filter">[% rule.filter | html %]</td>
-                <td>[% rule.tag | html %]</td>
-                <td class="rule-preset"></td>
-                <td class="rule-operation-action" data-operation="add">[% IF rule.add %]Add[% ELSE %]Skip[% END %]</td>
-                <td class="rule-operation-action" data-operation="append">[% IF rule.append %]Append[% ELSE %]Skip[% END %]</td>
-                <td class="rule-operation-action" data-operation="remove">[% IF rule.remove %]Remove[% ELSE %]Skip[% END %]</td>
-                <td class="rule-operation-action" data-operation="delete">[% IF rule.delete %]Delete[% ELSE %]Skip[% END %]</td>
-                <td class="actions">
-                    <a href="?op=remove&id=[% rule.id %]" title="Delete" class="btn btn-default btn-xs"><i class="fa fa-trash"></i> Delete</a>
-                    <a href="?op=edit&id=[% rule.id %]" title="Edit" class="btn btn-default btn-xs"><i class="fa fa-pencil"></i> Edit</a>
-                </td>
-                <td>
-                    [% IF rule.removemarked %]
-                        <input type="checkbox" name="batchremove" value="[% rule.id | html %]" checked="checked"/>
-                    [% ELSE %]
-                        <input type="checkbox" name="batchremove" value="[% rule.id | html %]"/>
-                    [% END %]
-                </td>
-            [% END %]
-            </tr>
-        [% END %]
-    </tbody>
-</table>
-</form>
-
-<form action="[% script_name %]" method="post">
-<input type="hidden" name="op" value="redo-matching" />
-</form>
-
-</div>
-<!-- /.col-sm-10.col-sm-push-2 -->
-
-<div class="col-sm-2 col-sm-pull-10">
-    <aside>
-        [% INCLUDE 'admin-menu.inc' %]
-    </aside>
-</div>
-
-</div>
-<!-- /.row>
-</div>
-<!-- /main container-fluid -->
-
-[% INCLUDE 'intranet-bottom.inc' %]
diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/marc-overlay-rules.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/marc-overlay-rules.tt
new file mode 100644 (file)
index 0000000..b23d1d9
--- /dev/null
@@ -0,0 +1,519 @@
+[% USE Koha %]
+[% INCLUDE 'doc-head-open.inc' %]
+<title>Koha &rsaquo; Administration &rsaquo; MARC overlay rules</title>
+[% INCLUDE 'doc-head-close.inc' %]
+[% Asset.css("css/datatables.css") %]
+[% INCLUDE 'datatables.inc' %]
+
+<style type="text/css">
+    .required {
+        background-color: #C00;
+    }
+</style>
+
+<script type="text/javascript">
+function doSubmit(op, id) {
+    $('<input type="hidden"/>')
+    .attr('name', 'op')
+    .attr('value', op)
+    .appendTo('#marc-overlay-rules-form');
+
+    if(id) {
+        $('<input type="hidden"/>')
+        .attr('name', 'id')
+        .attr('value', id)
+        .appendTo('#marc-overlay-rules-form');
+    }
+
+    var valid = true;
+    if (op == 'add' || op == 'edit') {
+        var validate = [
+            $('#marc-overlay-rules-form input[name="filter"]'),
+            $('#marc-overlay-rules-form input[name="tag"]')
+        ];
+        for(var i = 0; i < validate.length; i++) {
+            if (validate[i].length) {
+                if(validate[i].val().length == 0) {
+                    validate[i].addClass('required');
+                    valid = false;
+                } else {
+                    validate[i].removeClass('required');
+                }
+            }
+        }
+    }
+
+    if (valid) {
+        $('#marc-overlay-rules-form').submit();
+    }
+
+    return valid;
+}
+
+$(document).ready(function(){
+    $('#doremove').on('click', function(){
+        doSubmit('doremove');
+    });
+    $('#doedit').on('click', function(){
+        doSubmit('doedit', $("#doedit").attr('value'));
+    });
+    $('#add').on('click', function(){
+        doSubmit('add');
+        return false;
+    });
+    $('#btn_batchremove').on('click', function(){
+        doSubmit('remove');
+    });
+
+    /* Disable batch remove unless one or more checkboxes are checked */
+    $('input[name="batchremove"]').change(function() {
+        if($('input[name="batchremove"]:checked').length > 0) {
+            $('#btn_batchremove').removeAttr('disabled');
+        } else {
+            $('#btn_batchremove').attr('disabled', 'disabled');
+        }
+    });
+
+    $.fn.dataTable.ext.order['dom-input'] = function (settings, col) {
+        return this.api().column(col, { order: 'index' }).nodes()
+            .map(function (td, i) {
+                if($('input', td).val() != undefined) {
+                    return $('input', td).val();
+                } else if($('select', td).val() != undefined) {
+                    return $('option[selected="selected"]', td).val();
+                } else {
+                    return $(td).html();
+                }
+            });
+    }
+
+    $('#marc-overlay-rules').dataTable($.extend(true, {}, dataTablesDefaults, {
+        "aoColumns": [
+            {"bSearchable": false, "bSortable": false},
+            {"sSortDataType": "dom-input"},
+            {"sSortDataType": "dom-input"},
+            {"bSearchable": false, "sSortDataType": "dom-input"},
+            {"bSearchable": false, "sSortDataType": "dom-input"},
+            {"bSearchable": false, "sSortDataType": "dom-input"},
+            {"bSearchable": false, "sSortDataType": "dom-input"},
+            {"bSearchable": false, "sSortDataType": "dom-input"},
+            {"bSearchable": false, "sSortDataType": "dom-input"},
+            {"bSearchable": false, "bSortable": false},
+            {"bSearchable": false, "bSortable": false}
+        ],
+        "pagingType": "simple"
+    }));
+
+    var overlay_rules_presets = {};
+    overlay_rules_presets[_("Protect")] = {
+      'add': 0,
+      'append': 0,
+      'remove': 0,
+      'delete': 0
+    };
+    overlay_rules_presets[_("Overwrite")] = {
+      'add': 1,
+      'append': 1,
+      'remove': 1,
+      'delete': 1
+    };
+    overlay_rules_presets[_("Add new")] = {
+      'add': 1,
+      'append': 0,
+      'remove': 0,
+      'delete': 0
+    };
+    overlay_rules_presets[_("Add and append")] = {
+      'add': 1,
+      'append': 1,
+      'remove': 0,
+      'delete': 0
+    };
+    overlay_rules_presets[_("Protect from deletion")] = {
+      'add': 1,
+      'append': 1,
+      'remove': 1,
+      'delete': 0
+    };
+
+    var overlay_rules_label_to_value = {};
+    overlay_rules_label_to_value[_("Add")] = 1;
+    overlay_rules_label_to_value[_("Append")] = 1;
+    overlay_rules_label_to_value[_("Remove")] = 1;
+    overlay_rules_label_to_value[_("Delete")] = 1;
+    overlay_rules_label_to_value[_("Skip")] = 0;
+
+    function hash_config(config) {
+      return JSON.stringify(config, Object.keys(config).sort());
+    }
+
+    var overlay_rules_preset_map = {};
+    $.each(overlay_rules_presets, function(preset, config) {
+      overlay_rules_preset_map[hash_config(config)] = preset;
+    });
+
+    function operations_config_overlay_rule_preset(config) {
+      return overlay_rules_preset_map[hash_config(config)] || '';
+    }
+
+    /* Set preset values according to operation config */
+    $('.rule').each(function() {
+      var $this = $(this);
+      var operations_config = {};
+      $('.rule-operation-action', $this).each(function() {
+        var $operation = $(this);
+        operations_config[$operation.data('operation')] = overlay_rules_label_to_value[$operation.text()];
+      });
+      $('.rule-preset', $this).text(
+        operations_config_overlay_rule_preset(operations_config) || _("Custom")
+      );
+    });
+
+    /* Listen to operations config changes and set presets accordingly */
+    $('.rule-operation-action-edit select').change(function() {
+      var operations_config = {};
+      var $parent_row = $(this).closest('tr');
+      $('.rule-operation-action-edit select', $parent_row).each(function() {
+        var $this = $(this);
+        operations_config[$this.attr('name')] = parseInt($this.val());
+      });
+      $('select[name="preset"]', $parent_row).val(
+          operations_config_overlay_rule_preset(operations_config)
+      );
+    });
+
+    /* Listen to preset changes and set operations config accordingly */
+    $('select[name="preset"]').change(function() {
+      var $this = $(this);
+      var $parent_row = $this.closest('tr');
+      var preset = $this.val();
+      if (preset) {
+        $.each(overlay_rules_presets[preset], function(operation, action) {
+          $('select[name="' + operation + '"]', $parent_row).val(action);
+        });
+      }
+    });
+
+    var module_filter_options = {
+      source: {
+        '*': '*',
+        batchmod: "Batch record modification",
+        intranet: "Staff client MARC editor",
+        batchimport: "Staged MARC import",
+        z3950: "Z39.50",
+        bulkmarcimport: "bulkmarcimport.pl",
+        import_lexile: "import_lexile.pl"
+      },
+      categorycode: {
+        '*': '*',
+        [% FOREACH categorycode IN categorycodes %]
+          [% categorycode.categorycode | html %]: "[% categorycode.description | html %]",
+        [% END %]
+      }
+    };
+
+    //Kind of hack: Replace filter value with label when one exist
+    $('.rule-module').each(function() {
+      var $this = $(this);
+      var module = $this.text();
+      if (module in module_filter_options) {
+        let $filter = $this.siblings('.rule-filter');
+        if ($filter.text() in module_filter_options[module]) {
+          $filter.text(module_filter_options[module][$filter.text()]);
+        }
+      }
+    });
+
+    var $filter_container = $('#filter-container');
+
+    /* Listen to module changes and set filter input accordingly */
+    $('select[name="module"]').change(function() {
+      var $this = $(this);
+      var module_name = $this.val();
+
+      /* Remove current element if any */
+      $filter_container.empty();
+
+      var filter_elem = null;
+      if (module_name in module_filter_options) {
+        // Create select element
+        filter_elem = document.createElement('select');
+        for (var filter_value in module_filter_options[module_name]) {
+          var option = document.createElement('option');
+          option.value = filter_value;
+          option.text = module_filter_options[module_name][filter_value];
+          filter_elem.appendChild(option);
+        }
+      }
+      else {
+        // Create text input element
+        filter_elem = document.createElement('input');
+        filter_elem.type = 'text';
+        filter_elem.setAttribute('size', 5);
+      }
+      filter_elem.name = 'filter';
+      filter_elem.id = 'filter';
+      $filter_container.append(filter_elem);
+    }).change(); // Trigger change
+
+    // Hack: set value if editing rule
+    if ($filter_container.data('filter')) {
+      $('#filter').val($filter_container.data('filter'));
+    }
+
+});
+</script>
+</head>
+<body id="admin_marc-overlay-rules" class="admin">
+[% INCLUDE 'header.inc' %]
+[% INCLUDE 'cat-search.inc' %]
+
+<div id="breadcrumbs"><a href="/cgi-bin/koha/mainpage.pl">Home</a> &rsaquo; <a href="/cgi-bin/koha/admin/admin-home.pl">Administration</a>
+ &rsaquo; MARC overlay rules
+</div>
+
+<div class="main container-fluid">
+<div class="row">
+<div class="col-sm-10 col-sm-push-2">
+
+<h1>Manage MARC overlay rules</h1>
+
+[% FOR m IN messages %]
+  <div class="dialog [% m.type | html %]">
+    [% SWITCH m.code %]
+    [% CASE 'invalid_tag_regexp' %]
+      Invalid regular expression "[% m.tag | html %]".
+    [% CASE 'invalid_control_field_actions' %]
+      Invalid combination of actions for tag [% m.tag | html %]. Control field rules do not allow "Appended: Append" and "Removed: Skip".
+    [% CASE %]
+      [% m.code | html %]
+    [% END %]
+  </div>
+[% END %]
+
+[% UNLESS Koha.Preference( 'MARCOverlayRules' ) %]
+    <div class="dialog message">
+        The <b>MARCOverlayRules</b> preference is not set, don't forget to enable it for rules to take effect.
+    </div>
+[% END %]
+[% IF removeConfirm %]
+<div class="dialog alert">
+<h3>Remove rule?</h3>
+<p>Are you sure you want to remove the selected rule(s)?</p>
+
+<form action="[% script_name %]" method="GET">
+    <button type="submit" class="deny"><i class="fa fa-fw fa-remove"></i> No, do not remove</button>
+</form>
+    <button type="button" class="approve" id="doremove"><i class="fa fa-fw fa-check"></i> Yes, remove</button>
+</div>
+[% END %]
+
+<form action="[% script_name %]" method="POST" id="marc-overlay-rules-form">
+<table id="marc-overlay-rules">
+    <thead><tr>
+        <th>Rule</th>
+        <th>Module</th>
+        <th>Filter</th>
+        <th>Tag</th>
+        <th>Preset</th>
+        <th>Added <i id="info_added" data-toggle="tooltip" title="If a field matching the rule tag only exists in the incoming record" data-placement="right" class="fa fa-info-circle"></i></th>
+        <th>Appended <i id="info_appended" data-toggle="tooltip" title="If the original record has one or more fields matching with the rule tag, but one or more fields matching the rule tag differ in the incoming record" data-placement="right" class="fa fa-info-circle"></i></th>
+        <th>Removed <i id="info_removed" data-toggle="tooltip" title="If the original record has a field matching the rule tag, but the matching field is not in the incoming record" data-placement="right" class="fa fa-info-circle"></i></th>
+        <th>Deleted <i id="info_deleted" data-toggle="tooltip" title="If the original record has fields matching the rule tag, but no fields with this are found in the incoming record" data-placement="right" class="fa fa-info-circle"></i></th>
+        <th>Actions</th>
+        <th>&nbsp;</th>
+    </tr></thead>
+    [% UNLESS edit %]
+    <tfoot>
+        <tr class="rule-new">
+            <th>&nbsp;</th>
+            <th>
+                <select name="module">
+                    <option value="source">Source</option>
+                    <option value="categorycode">User category</option>
+                    <option value="userid">Username</option>
+                </select>
+            </th>
+            <th id="filter-container"></th>
+            <th><input type="text" size="5" name="tag"/></th>
+            <th>
+                <select name="preset">
+                    <option value="" selected>Custom</option>
+                    [% FOR preset IN [
+                            'Protect',
+                            'Overwrite',
+                            'Add new',
+                            'Add and append',
+                            'Protect from deletion'
+                        ]
+                    %]
+                        <option value="[% preset | html %]">[% preset | html %]</option>
+                    [% END %]
+                </select>
+            </th>
+            <th class="rule-operation-action-edit">
+                <select name="add">
+                    <option value="0">Skip</option>
+                    <option value="1">Add</option>
+                </select>
+            </th>
+            <th class="rule-operation-action-edit">
+                <select name="append">
+                    <option value="0">Skip</option>
+                    <option value="1">Append</option>
+                </select>
+            </th>
+            <th class="rule-operation-action-edit">
+                <select name="remove">
+                    <option value="0">Skip</option>
+                    <option value="1">Remove</option>
+                </select>
+            </th>
+            <th class="rule-operation-action-edit">
+                <select name="delete">
+                    <option value="0">Skip</option>
+                    <option value="1">Delete</option>
+                </select>
+            </th>
+            <th><button class="btn btn-default btn-xs" title="Add" id="add"><i class="fa fa-plus"></i> Add rule</button></th>
+            <th><button id="btn_batchremove" disabled="disabled" class="btn btn-default btn-xs" title="Batch remove"><i class="fa fa-trash"></i> Delete selected</button></th>
+        </tr>
+    </tfoot>
+    [% END %]
+    <tbody>
+        [% FOREACH rule IN rules %]
+            <tr id="[% rule.id %]" class="rule[% IF rule.edit %]-edit[% END %]">
+            [% IF rule.edit %]
+                <td>[% rule.id %]</td>
+                <td>
+                    <select name="module">
+                        [% IF rule.module == "source" %]
+                            <option value="source" selected="selected">Source</option>
+                        [% ELSE %]
+                            <option value="source">Source</option>
+                        [% END %]
+                        [% IF rule.module == "categorycode" %]
+                            <option value="categorycode" selected="selected">User category</option>
+                        [% ELSE %]
+                            <option value="categorycode">User category</option>
+                        [% END %]
+                        [% IF rule.module == "userid" %]
+                            <option value="userid" selected="selected">Username</option>
+                        [% ELSE %]
+                            <option value="userid">Username</option>
+                        [% END %]
+                    </select>
+                </td>
+                <td id="filter-container" data-filter="[% rule.filter | html %]"></td>
+                <td><input type="text" size="3" name="tag" value="[% rule.tag | html %]"/></td>
+                <th>
+                    <select name="preset">
+                        <option value="" selected>Custom</option>
+                        [% FOR preset IN [
+                                'Protect',
+                                'Overwrite',
+                                'Add new',
+                                'Add and append',
+                                'Protect from deletion'
+                            ]
+                        %]
+                            <option value="[% preset | html %]">[% preset | html %]</option>
+                        [% END %]
+                    </select>
+                </th>
+                <td class="rule-operation-action-edit">
+                    <select name="add">
+                        [% IF rule.add %]
+                            <option value="0">Skip</option>
+                            <option value="1" selected="selected">Add</option>
+                        [% ELSE %]
+                            <option value="0" selected="selected">Skip</option>
+                            <option value="1">Add</option>
+                        [% END %]
+                    </select>
+                </td>
+                <td class="rule-operation-action-edit">
+                    <select name="append">
+                        [% IF rule.append %]
+                            <option value="0">Skip</option>
+                            <option value="1" selected="selected">Append</option>
+                        [% ELSE %]
+                            <option value="0" selected="selected">Skip</option>
+                            <option value="1">Append</option>
+                        [% END %]
+                    </select>
+                </td>
+                <td class="rule-operation-action-edit">
+                    <select name="remove">
+                        [% IF rule.remove %]
+                            <option value="0">Skip</option>
+                            <option value="1" selected="selected">Remove</option>
+                        [% ELSE %]
+                            <option value="0" selected="selected">Skip</option>
+                            <option value="1">Remove</option>
+                        [% END %]
+                    </select>
+                </td>
+                <td class="rule-operation-action-edit">
+                    <select name="delete">
+                        [% IF rule.delete %]
+                            <option value="0">Skip</option>
+                            <option value="1" selected="selected">Delete</option>
+                        [% ELSE %]
+                            <option value="0" selected="selected">Skip</option>
+                            <option value="1">Delete</option>
+                        [% END %]
+                    </select>
+                </td>
+                <td class="actions">
+                    <button class="btn btn-default btn-xs" title="Save" id="doedit" value="[% rule.id | html %]"><i class="fa fa-check"></i> Save</button>
+                    <button type="submit" class="btn btn-default btn-xs" title="Cancel" ><i class="fa fa-times"></i> Cancel</button>
+                </td>
+                <td></td>
+            [% ELSE %]
+                <td>[% rule.id | html %]</td>
+                <td class="rule-module">[% rule.module | html %]</td>
+                <td class="rule-filter">[% rule.filter | html %]</td>
+                <td>[% rule.tag | html %]</td>
+                <td class="rule-preset"></td>
+                <td class="rule-operation-action" data-operation="add">[% IF rule.add %]Add[% ELSE %]Skip[% END %]</td>
+                <td class="rule-operation-action" data-operation="append">[% IF rule.append %]Append[% ELSE %]Skip[% END %]</td>
+                <td class="rule-operation-action" data-operation="remove">[% IF rule.remove %]Remove[% ELSE %]Skip[% END %]</td>
+                <td class="rule-operation-action" data-operation="delete">[% IF rule.delete %]Delete[% ELSE %]Skip[% END %]</td>
+                <td class="actions">
+                    <a href="?op=remove&id=[% rule.id %]" title="Delete" class="btn btn-default btn-xs"><i class="fa fa-trash"></i> Delete</a>
+                    <a href="?op=edit&id=[% rule.id %]" title="Edit" class="btn btn-default btn-xs"><i class="fa fa-pencil"></i> Edit</a>
+                </td>
+                <td>
+                    [% IF rule.removemarked %]
+                        <input type="checkbox" name="batchremove" value="[% rule.id | html %]" checked="checked"/>
+                    [% ELSE %]
+                        <input type="checkbox" name="batchremove" value="[% rule.id | html %]"/>
+                    [% END %]
+                </td>
+            [% END %]
+            </tr>
+        [% END %]
+    </tbody>
+</table>
+</form>
+
+<form action="[% script_name %]" method="post">
+<input type="hidden" name="op" value="redo-matching" />
+</form>
+
+</div>
+<!-- /.col-sm-10.col-sm-push-2 -->
+
+<div class="col-sm-2 col-sm-pull-10">
+    <aside>
+        [% INCLUDE 'admin-menu.inc' %]
+    </aside>
+</div>
+
+</div>
+<!-- /.row>
+</div>
+<!-- /main container-fluid -->
+
+[% INCLUDE 'intranet-bottom.inc' %]
index 725f6b6..fb38906 100644 (file)
@@ -299,7 +299,12 @@ Cataloging:
                   1: "do"
                   0: "don't"
             - attempt to match aggressively by trying all variations of the ISSNs in the imported record as a phrase in the ISSN fields of already cataloged records.
-
+        -
+            - pref: MARCOverlayRules
+              choices:
+                  1: "Use"
+                  0: "Don't use"
+            - MARC overlay rules for incoming records, to decide which action to take for each field.
     Exporting:
         -
             - "Include the following fields when exporting BibTeX:"
@@ -325,10 +330,3 @@ Cataloging:
             - "All values of repeating tags and subfields will be printed with the given RIS tag."
             - "<br/>"
             - "Use of TY ( record type ) as a key will <em>replace</em> the default TY with the field value of your choosing."
-        -
-            - When importing records
-            - pref: MARCMergeRules
-              choices:
-                  yes: "use"
-                  no: "don't use"
-            - MARC merge rules to decide which action to take for each field.
diff --git a/t/db_dependent/Biblio/MarcMergeRules.t b/t/db_dependent/Biblio/MarcMergeRules.t
deleted file mode 100755 (executable)
index cf17806..0000000
+++ /dev/null
@@ -1,779 +0,0 @@
-#!/usr/bin/perl
-
-# This file is part of Koha.
-#
-# Koha is free software; you can redistribute it and/or modify it
-# under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 3 of the License, or
-# (at your option) any later version.
-#
-# Koha is distributed in the hope that it will be useful, but
-# WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with Koha; if not, see <http://www.gnu.org/licenses>.
-
-use Modern::Perl;
-use Try::Tiny;
-
-use MARC::Record;
-
-use C4::Context;
-use C4::Biblio;
-use Koha::Database; #??
-
-use Test::More tests => 23;
-use Test::MockModule;
-
-use Koha::MarcMergeRules;
-
-use t::lib::Mocks;
-
-my $schema = Koha::Database->schema;
-$schema->storage->txn_begin;
-
-t::lib::Mocks::mock_preference('MARCMergeRules', '1');
-
-# Create a record
-my $orig_record = MARC::Record->new();
-$orig_record->append_fields (
-    MARC::Field->new('250', '','', 'a' => '250 bottles of beer on the wall'),
-    MARC::Field->new('250', '','', 'a' => '256 bottles of beer on the wall'),
-    MARC::Field->new('500', '','', 'a' => 'One bottle of beer in the fridge'),
-);
-
-my $incoming_record = MARC::Record->new();
-$incoming_record->append_fields(
-    MARC::Field->new('250', '', '', 'a' => '256 bottles of beer on the wall'), # Unchanged
-    MARC::Field->new('250', '', '', 'a' => '251 bottles of beer on the wall'), # Appended
-    # MARC::Field->new('250', '', '', 'a' => '250 bottles of beer on the wall'), # Removed
-    # MARC::Field->new('500', '', '', 'a' => 'One bottle of beer in the fridge'), # Deleted
-    MARC::Field->new('501', '', '', 'a' => 'One cold bottle of beer in the fridge'), # Added
-    MARC::Field->new('501', '', '', 'a' => 'Two cold bottles of beer in the fridge'), # Added
-);
-
-# Test default behavior when MARCMergeRules is enabled, but no rules defined (overwrite)
-subtest 'Record fields has been overwritten when no merge rules are defined' => sub {
-    plan tests => 4;
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-
-    cmp_ok(scalar @all_fields, '==', 4, "Record has the expected number of fields");
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['256 bottles of beer on the wall', '251 bottles of beer on the wall'],
-        '"250" fields has been appended and removed'
-    );
-
-    my @fields = $merged_record->field('500');
-    cmp_ok(scalar @fields, '==', 0, '"500" field has been deleted');
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('501') ],
-        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
-        '"501" fields has been added'
-    );
-};
-
-my $rule =  Koha::MarcMergeRules->find_or_create({
-    tag => '*',
-    module => 'source',
-    filter => '*',
-    add => 0,
-    append => 0,
-    remove => 0,
-    delete => 0
-});
-
-subtest 'Record fields has been protected when matched merge all rule operations are set to "0"' => sub {
-    plan tests => 3;
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-    cmp_ok(scalar @all_fields, '==', 3, "Record has the expected number of fields");
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['250 bottles of beer on the wall', '256 bottles of beer on the wall'],
-        '"250" fields has retained their original value'
-    );
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('500') ],
-        ['One bottle of beer in the fridge'],
-        '"500" field has retained it\'s original value'
-    );
-};
-
-subtest 'Only new fields has been added when add = 1, append = 0, remove = 0, delete = 0' => sub {
-    plan tests => 4;
-
-    $rule->set(
-        {
-            'add' => 1,
-            'append' => 0,
-            'remove' => 0,
-            'delete' => 0,
-        }
-    );
-    $rule->store();
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-    cmp_ok(scalar @all_fields, '==', 5, "Record has the expected number of fields");
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['250 bottles of beer on the wall', '256 bottles of beer on the wall'],
-        '"250" fields retain their original value'
-    );
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('500') ],
-        ['One bottle of beer in the fridge'],
-        '"500" field retain it\'s original value'
-    );
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('501') ],
-        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
-        '"501" fields has been added'
-    );
-};
-
-subtest 'Only appended fields has been added when add = 0, append = 1, remove = 0, delete = 0' => sub {
-    plan tests => 3;
-
-    $rule->set(
-        {
-            'add' => 0,
-            'append' => 1,
-            'remove' => 0,
-            'delete' => 0,
-        }
-    );
-    $rule->store();
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-    cmp_ok(scalar @all_fields, '==', 4, "Record has the expected number of fields");
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['250 bottles of beer on the wall', '256 bottles of beer on the wall', '251 bottles of beer on the wall'],
-        '"251" field has been appended'
-    );
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('500') ],
-        ['One bottle of beer in the fridge'],
-        '"500" field has retained it\'s original value'
-    );
-
-};
-
-subtest 'Appended and added fields has been added when add = 1, append = 1, remove = 0, delete = 0' => sub {
-    plan tests => 4;
-
-    $rule->set(
-        {
-            'add' => 1,
-            'append' => 1,
-            'remove' => 0,
-            'delete' => 0,
-        }
-    );
-    $rule->store();
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-    cmp_ok(scalar @all_fields, '==', 6, "Record has the expected number of fields");
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['250 bottles of beer on the wall', '256 bottles of beer on the wall', '251 bottles of beer on the wall'],
-        '"251" field has been appended'
-    );
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('500') ],
-        ['One bottle of beer in the fridge'],
-        '"500" field has retained it\'s original value'
-    );
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('501') ],
-        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
-        '"501" fields has been added'
-    );
-};
-
-subtest 'Record fields has been only removed when add = 0, append = 0, remove = 1, delete = 0' => sub {
-    plan tests => 3;
-
-    $rule->set(
-        {
-            'add' => 0,
-            'append' => 0,
-            'remove' => 1,
-            'delete' => 0,
-        }
-    );
-    $rule->store();
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-    cmp_ok(scalar @all_fields, '==', 2, "Record has the expected number of fields");
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['256 bottles of beer on the wall'],
-        '"250" field has been removed'
-    );
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('500') ],
-        ['One bottle of beer in the fridge'],
-        '"500" field has retained it\'s original value'
-    );
-};
-
-subtest 'Record fields has been added and removed when add = 1, append = 0, remove = 1, delete = 0' => sub {
-    plan tests => 4;
-
-    $rule->set(
-        {
-            'add' => 1,
-            'append' => 0,
-            'remove' => 1,
-            'delete' => 0,
-        }
-    );
-    $rule->store();
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-    cmp_ok(scalar @all_fields, '==', 4, "Record has the expected number of fields");
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['256 bottles of beer on the wall'],
-        '"250" field has been removed'
-    );
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('500') ],
-        ['One bottle of beer in the fridge'],
-        '"500" field has retained it\'s original value'
-    );
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('501') ],
-        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
-        '"501" fields has been added'
-    );
-};
-
-subtest 'Record fields has been appended and removed when add = 0, append = 1, remove = 1, delete = 0' => sub {
-    plan tests => 3;
-
-    $rule->set(
-        {
-            'add' => 0,
-            'append' => 1,
-            'remove' => 1,
-            'delete' => 0,
-        }
-    );
-    $rule->store();
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-    cmp_ok(scalar @all_fields, '==', 3, "Record has the expected number of fields");
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['256 bottles of beer on the wall', '251 bottles of beer on the wall'],
-        '"250" fields has been appended and removed'
-    );
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('500') ],
-        ['One bottle of beer in the fridge'],
-        '"500" field has retained it\'s original value'
-    );
-};
-
-subtest 'Record fields has been added, appended and removed when add = 0, append = 1, remove = 1, delete = 0' => sub {
-    plan tests => 4;
-
-    $rule->set(
-        {
-            'add' => 1,
-            'append' => 1,
-            'remove' => 1,
-            'delete' => 0,
-        }
-    );
-    $rule->store();
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-    cmp_ok(scalar @all_fields, '==', 5, "Record has the expected number of fields");
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['256 bottles of beer on the wall', '251 bottles of beer on the wall'],
-        '"250" fields has been appended and removed'
-    );
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('500') ],
-        ['One bottle of beer in the fridge'],
-        '"500" field has retained it\'s original value'
-    );
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('501') ],
-        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
-        '"501" fields has been added'
-    );
-};
-
-subtest 'Record fields has been deleted when add = 0, append = 0, remove = 0, delete = 1' => sub {
-    plan tests => 2;
-
-    $rule->set(
-        {
-            'add' => 0,
-            'append' => 0,
-            'remove' => 0,
-            'delete' => 1,
-        }
-    );
-    $rule->store();
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-    cmp_ok(scalar @all_fields, '==', 2, "Record has the expected number of fields");
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['250 bottles of beer on the wall', '256 bottles of beer on the wall'],
-        '"250" fields has retained their original value'
-    );
-};
-
-subtest 'Record fields has been added and deleted when add = 1, append = 0, remove = 0, delete = 1' => sub {
-    plan tests => 3;
-
-    $rule->set(
-        {
-            'add' => 1,
-            'append' => 0,
-            'remove' => 0,
-            'delete' => 1,
-        }
-    );
-    $rule->store();
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-    cmp_ok(scalar @all_fields, '==', 4, "Record has the expected number of fields");
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['250 bottles of beer on the wall', '256 bottles of beer on the wall'],
-        '"250" fields has retained their original value'
-    );
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('501') ],
-        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
-        '"501" fields has been added'
-    );
-};
-
-subtest 'Record fields has been appended and deleted when add = 0, append = 1, remove = 0, delete = 1' => sub {
-    plan tests => 2;
-
-    $rule->set(
-        {
-            'add' => 0,
-            'append' => 1,
-            'remove' => 0,
-            'delete' => 1,
-        }
-    );
-    $rule->store();
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-    cmp_ok(scalar @all_fields, '==', 3, "Record has the expected number of fields");
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['250 bottles of beer on the wall', '256 bottles of beer on the wall', '251 bottles of beer on the wall'],
-        '"250" field has been appended'
-    );
-};
-
-subtest 'Record fields has been added, appended and deleted when add = 1, append = 1, remove = 0, delete = 1' => sub {
-    plan tests => 3;
-
-    $rule->set(
-        {
-            'add' => 1,
-            'append' => 1,
-            'remove' => 0,
-            'delete' => 1,
-        }
-    );
-    $rule->store();
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-    cmp_ok(scalar @all_fields, '==', 5, "Record has the expected number of fields");
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['250 bottles of beer on the wall', '256 bottles of beer on the wall', '251 bottles of beer on the wall'],
-        '"250" field has been appended'
-    );
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('501') ],
-        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
-        '"501" fields has been added'
-    );
-};
-
-subtest 'Record fields has been removed and deleted when add = 0, append = 0, remove = 1, delete = 1' => sub {
-    plan tests => 2;
-
-    $rule->set(
-        {
-            'add' => 0,
-            'append' => 0,
-            'remove' => 1,
-            'delete' => 1,
-        }
-    );
-    $rule->store();
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-    cmp_ok(scalar @all_fields, '==', 1, "Record has the expected number of fields");
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['256 bottles of beer on the wall'],
-        '"250" field has been removed'
-    );
-};
-
-subtest 'Record fields has been added, removed and deleted when add = 1, append = 0, remove = 1, delete = 1' => sub {
-    plan tests => 3;
-
-    $rule->set(
-        {
-            'add' => 1,
-            'append' => 0,
-            'remove' => 1,
-            'delete' => 1,
-        }
-    );
-    $rule->store();
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-    cmp_ok(scalar @all_fields, '==', 3, "Record has the expected number of fields");
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['256 bottles of beer on the wall'],
-        '"250" field has been removed'
-    );
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('501') ],
-        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
-        '"501" fields has been added'
-    );
-};
-
-subtest 'Record fields has been appended, removed and deleted when add = 0, append = 1, remove = 1, delete = 1' => sub {
-    plan tests => 2;
-
-    $rule->set(
-        {
-            'add' => 0,
-            'append' => 1,
-            'remove' => 1,
-            'delete' => 1,
-        }
-    );
-    $rule->store();
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-    cmp_ok(scalar @all_fields, '==', 2, "Record has the expected number of fields");
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['256 bottles of beer on the wall', '251 bottles of beer on the wall'],
-        '"250" fields has been appended and removed'
-    );
-};
-
-subtest 'Record fields has been overwritten when add = 1, append = 1, remove = 1, delete = 1' => sub {
-    plan tests => 4;
-
-    $rule->set(
-        {
-            'add' => 1,
-            'append' => 1,
-            'remove' => 1,
-            'delete' => 1,
-        }
-    );
-    $rule->store();
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-
-    cmp_ok(scalar @all_fields, '==', 4, "Record has the expected number of fields");
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['256 bottles of beer on the wall', '251 bottles of beer on the wall'],
-        '"250" fields has been appended and removed'
-    );
-
-    my @fields = $merged_record->field('500');
-    cmp_ok(scalar @fields, '==', 0, '"500" field has been deleted');
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('501') ],
-        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
-        '"501" fields has been added'
-    );
-};
-
-# Test rule tag specificity
-
-# Protect field 500 with more specific tag value
-my $skip_all_rule = Koha::MarcMergeRules->find_or_create({
-    tag => '500',
-    module => 'source',
-    filter => '*',
-    add => 0,
-    append => 0,
-    remove => 0,
-    delete => 0
-});
-
-subtest '"500" field has been protected when rule matching on tag "500" is add = 0, append = 0, remove = 0, delete = 0' => sub {
-    plan tests => 4;
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-
-    cmp_ok(scalar @all_fields, '==', 5, "Record has the expected number of fields");
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['256 bottles of beer on the wall', '251 bottles of beer on the wall'],
-        '"250" fields has been appended and removed'
-    );
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('500') ],
-        ['One bottle of beer in the fridge'],
-        '"500" field has retained it\'s original value'
-    );
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('501') ],
-        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
-        '"501" fields has been added'
-    );
-};
-
-# Test regexp matching
-subtest '"5XX" fields has been protected when rule matching on regexp "5\d{2}" is add = 0, append = 0, remove = 0, delete = 0' => sub {
-    plan tests => 3;
-
-    $skip_all_rule->set(
-        {
-            'tag' => '5\d{2}',
-        }
-    );
-    $skip_all_rule->store();
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-
-    cmp_ok(scalar @all_fields, '==', 3, "Record has the expected number of fields");
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['256 bottles of beer on the wall', '251 bottles of beer on the wall'],
-        '"250" fields has been appended and removed'
-    );
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('500') ],
-        ['One bottle of beer in the fridge'],
-        '"500" field has retained it\'s original value'
-    );
-};
-
-# Test module specificity, the 0 all rule should no longer be included in set of applied rules
-subtest 'Record fields has been overwritten when non wild card rule with filter match is add = 1, append = 1, remove = 1, delete = 1' => sub {
-    plan tests => 4;
-
-    $rule->set(
-        {
-            'filter' => 'test',
-        }
-    );
-    $rule->store();
-
-    my $merged_record = Koha::MarcMergeRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
-
-    my @all_fields = $merged_record->fields();
-
-    cmp_ok(scalar @all_fields, '==', 4, "Record has the expected number of fields");
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('250') ],
-        ['256 bottles of beer on the wall', '251 bottles of beer on the wall'],
-        '"250" fields has been appended and removed'
-    );
-
-    my @fields = $merged_record->field('500');
-    cmp_ok(scalar @fields, '==', 0, '"500" field has been deleted');
-
-    is_deeply(
-        [map { $_->subfield('a') } $merged_record->field('501') ],
-        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
-        '"501" fields has been added'
-    );
-};
-
-subtest 'An exception is thrown when append = 1, remove = 0 is set for control field rule' => sub {
-    plan tests => 2;
-    my $exception = try {
-        Koha::MarcMergeRules->validate({
-            'tag' => '008',
-            'append' => 1,
-            'remove' => 0,
-        });
-    }
-    catch {
-        return $_;
-    };
-    ok(defined $exception, "Exception was caught");
-    ok($exception->isa('Koha::Exceptions::MarcMergeRule::InvalidControlFieldActions'), "Exception is of correct class");
-};
-
-subtest 'An exception is thrown when rule tag is set to invalid regexp' => sub {
-    plan tests => 2;
-
-    my $exception = try {
-        Koha::MarcMergeRules->validate({
-            'tag' => '**'
-        });
-    }
-    catch {
-        return $_;
-    };
-    ok(defined $exception, "Exception was caught");
-    ok($exception->isa('Koha::Exceptions::MarcMergeRule::InvalidTagRegExp'), "Exception is of correct class");
-};
-
-$skip_all_rule->delete();
-
-subtest 'context option in ModBiblio is handled correctly' => sub {
-    plan tests => 6;
-    $rule->set(
-        {
-            tag => '250',
-            module => 'source',
-            filter => '*',
-            'add' => 0,
-            'append' => 0,
-            'remove' => 0,
-            'delete' => 0,
-        }
-    );
-    $rule->store();
-    my ($biblionumber) = AddBiblio($orig_record, '');
-
-    # Since marc merc rules are not run on save, only update
-    # saved record should be identical to orig_record
-    my $saved_record = GetMarcBiblio({ biblionumber => $biblionumber });
-
-    my @all_fields = $saved_record->fields();
-    # Koha also adds 999c field, therefore 4 not 3
-    cmp_ok(scalar @all_fields, '==', 4, 'Saved record has the expected number of fields');
-    is_deeply(
-        [map { $_->subfield('a') } $saved_record->field('250') ],
-        ['250 bottles of beer on the wall', '256 bottles of beer on the wall'],
-        'All "250" fields of saved record are identical to original record passed to AddBiblio'
-    );
-
-    is_deeply(
-        [map { $_->subfield('a') } $saved_record->field('500') ],
-        ['One bottle of beer in the fridge'],
-        'All "500" fields of saved record are identical to original record passed to AddBiblio'
-    );
-
-    $saved_record->append_fields(
-        MARC::Field->new('250', '', '', 'a' => '251 bottles of beer on the wall'), # Appended
-        MARC::Field->new('500', '', '', 'a' => 'One cold bottle of beer in the fridge'), # Appended
-    );
-
-    ModBiblio($saved_record, $biblionumber, '', { context => { 'source' => 'test' } });
-
-    my $updated_record = GetMarcBiblio({ biblionumber => $biblionumber });
-
-    @all_fields = $updated_record->fields();
-    cmp_ok(scalar @all_fields, '==', 5, 'Updated record has the expected number of fields');
-    is_deeply(
-        [map { $_->subfield('a') } $updated_record->field('250') ],
-        ['250 bottles of beer on the wall', '256 bottles of beer on the wall'],
-        '"250" fields have retained their original values'
-    );
-
-    is_deeply(
-        [map { $_->subfield('a') } $updated_record->field('500') ],
-        ['One bottle of beer in the fridge', 'One cold bottle of beer in the fridge'],
-        '"500" field has been appended'
-    );
-
-    # To trigger removal from search index etc
-    DelBiblio($biblionumber);
-};
-
-# Explicityly delete rule to trigger clearing of cache
-$rule->delete();
-
-$schema->storage->txn_rollback;
-
-1;
diff --git a/t/db_dependent/Biblio/MarcOverlayRules.t b/t/db_dependent/Biblio/MarcOverlayRules.t
new file mode 100755 (executable)
index 0000000..ddd5b7e
--- /dev/null
@@ -0,0 +1,777 @@
+#!/usr/bin/perl
+
+# This file is part of Koha.
+#
+# Koha is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# Koha is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Koha; if not, see <http://www.gnu.org/licenses>.
+
+use Modern::Perl;
+use Try::Tiny;
+
+use MARC::Record;
+
+use C4::Context;
+use C4::Biblio qw( AddBiblio ModBiblio DelBiblio GetMarcBiblio );
+use Koha::Database;
+
+use Test::More tests => 23;
+use Test::MockModule;
+
+use Koha::MarcOverlayRules;
+
+use t::lib::Mocks;
+
+my $schema = Koha::Database->schema;
+$schema->storage->txn_begin;
+
+t::lib::Mocks::mock_preference('MARCOverlayRules', '1');
+
+# Create a record
+my $orig_record = MARC::Record->new();
+$orig_record->append_fields (
+    MARC::Field->new('250', '','', 'a' => '250 bottles of beer on the wall'),
+    MARC::Field->new('250', '','', 'a' => '256 bottles of beer on the wall'),
+    MARC::Field->new('500', '','', 'a' => 'One bottle of beer in the fridge'),
+);
+
+my $incoming_record = MARC::Record->new();
+$incoming_record->append_fields(
+    MARC::Field->new('250', '', '', 'a' => '256 bottles of beer on the wall'), # Unchanged
+    MARC::Field->new('250', '', '', 'a' => '251 bottles of beer on the wall'), # Appended
+    # MARC::Field->new('250', '', '', 'a' => '250 bottles of beer on the wall'), # Removed
+    # MARC::Field->new('500', '', '', 'a' => 'One bottle of beer in the fridge'), # Deleted
+    MARC::Field->new('501', '', '', 'a' => 'One cold bottle of beer in the fridge'), # Added
+    MARC::Field->new('501', '', '', 'a' => 'Two cold bottles of beer in the fridge'), # Added
+);
+
+# Test default behavior when MARCOverlayRules is enabled, but no rules defined (overwrite)
+subtest 'Record fields has been overwritten when no merge rules are defined' => sub {
+    plan tests => 4;
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+
+    cmp_ok(scalar @all_fields, '==', 4, "Record has the expected number of fields");
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['256 bottles of beer on the wall', '251 bottles of beer on the wall'],
+        '"250" fields has been appended and removed'
+    );
+
+    my @fields = $merged_record->field('500');
+    cmp_ok(scalar @fields, '==', 0, '"500" field has been deleted');
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('501') ],
+        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
+        '"501" fields has been added'
+    );
+};
+
+my $rule =  Koha::MarcOverlayRules->find_or_create({
+    tag => '*',
+    module => 'source',
+    filter => '*',
+    add => 0,
+    append => 0,
+    remove => 0,
+    delete => 0
+});
+
+subtest 'Record fields has been protected when matched merge all rule operations are set to "0"' => sub {
+    plan tests => 3;
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+    cmp_ok(scalar @all_fields, '==', 3, "Record has the expected number of fields");
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['250 bottles of beer on the wall', '256 bottles of beer on the wall'],
+        '"250" fields has retained their original value'
+    );
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('500') ],
+        ['One bottle of beer in the fridge'],
+        '"500" field has retained it\'s original value'
+    );
+};
+
+subtest 'Only new fields has been added when add = 1, append = 0, remove = 0, delete = 0' => sub {
+    plan tests => 4;
+
+    $rule->set(
+        {
+            'add' => 1,
+            'append' => 0,
+            'remove' => 0,
+            'delete' => 0,
+        }
+    );
+    $rule->store();
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+    cmp_ok(scalar @all_fields, '==', 5, "Record has the expected number of fields");
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['250 bottles of beer on the wall', '256 bottles of beer on the wall'],
+        '"250" fields retain their original value'
+    );
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('500') ],
+        ['One bottle of beer in the fridge'],
+        '"500" field retain it\'s original value'
+    );
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('501') ],
+        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
+        '"501" fields has been added'
+    );
+};
+
+subtest 'Only appended fields has been added when add = 0, append = 1, remove = 0, delete = 0' => sub {
+    plan tests => 3;
+
+    $rule->set(
+        {
+            'add' => 0,
+            'append' => 1,
+            'remove' => 0,
+            'delete' => 0,
+        }
+    );
+    $rule->store();
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+    cmp_ok(scalar @all_fields, '==', 4, "Record has the expected number of fields");
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['250 bottles of beer on the wall', '256 bottles of beer on the wall', '251 bottles of beer on the wall'],
+        '"251" field has been appended'
+    );
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('500') ],
+        ['One bottle of beer in the fridge'],
+        '"500" field has retained it\'s original value'
+    );
+
+};
+
+subtest 'Appended and added fields has been added when add = 1, append = 1, remove = 0, delete = 0' => sub {
+    plan tests => 4;
+
+    $rule->set(
+        {
+            'add' => 1,
+            'append' => 1,
+            'remove' => 0,
+            'delete' => 0,
+        }
+    );
+    $rule->store();
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+    cmp_ok(scalar @all_fields, '==', 6, "Record has the expected number of fields");
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['250 bottles of beer on the wall', '256 bottles of beer on the wall', '251 bottles of beer on the wall'],
+        '"251" field has been appended'
+    );
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('500') ],
+        ['One bottle of beer in the fridge'],
+        '"500" field has retained it\'s original value'
+    );
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('501') ],
+        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
+        '"501" fields has been added'
+    );
+};
+
+subtest 'Record fields has been only removed when add = 0, append = 0, remove = 1, delete = 0' => sub {
+    plan tests => 3;
+
+    $rule->set(
+        {
+            'add' => 0,
+            'append' => 0,
+            'remove' => 1,
+            'delete' => 0,
+        }
+    );
+    $rule->store();
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+    cmp_ok(scalar @all_fields, '==', 2, "Record has the expected number of fields");
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['256 bottles of beer on the wall'],
+        '"250" field has been removed'
+    );
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('500') ],
+        ['One bottle of beer in the fridge'],
+        '"500" field has retained it\'s original value'
+    );
+};
+
+subtest 'Record fields has been added and removed when add = 1, append = 0, remove = 1, delete = 0' => sub {
+    plan tests => 4;
+
+    $rule->set(
+        {
+            'add' => 1,
+            'append' => 0,
+            'remove' => 1,
+            'delete' => 0,
+        }
+    );
+    $rule->store();
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+    cmp_ok(scalar @all_fields, '==', 4, "Record has the expected number of fields");
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['256 bottles of beer on the wall'],
+        '"250" field has been removed'
+    );
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('500') ],
+        ['One bottle of beer in the fridge'],
+        '"500" field has retained it\'s original value'
+    );
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('501') ],
+        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
+        '"501" fields has been added'
+    );
+};
+
+subtest 'Record fields has been appended and removed when add = 0, append = 1, remove = 1, delete = 0' => sub {
+    plan tests => 3;
+
+    $rule->set(
+        {
+            'add' => 0,
+            'append' => 1,
+            'remove' => 1,
+            'delete' => 0,
+        }
+    );
+    $rule->store();
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+    cmp_ok(scalar @all_fields, '==', 3, "Record has the expected number of fields");
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['256 bottles of beer on the wall', '251 bottles of beer on the wall'],
+        '"250" fields has been appended and removed'
+    );
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('500') ],
+        ['One bottle of beer in the fridge'],
+        '"500" field has retained it\'s original value'
+    );
+};
+
+subtest 'Record fields has been added, appended and removed when add = 0, append = 1, remove = 1, delete = 0' => sub {
+    plan tests => 4;
+
+    $rule->set(
+        {
+            'add' => 1,
+            'append' => 1,
+            'remove' => 1,
+            'delete' => 0,
+        }
+    );
+    $rule->store();
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+    cmp_ok(scalar @all_fields, '==', 5, "Record has the expected number of fields");
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['256 bottles of beer on the wall', '251 bottles of beer on the wall'],
+        '"250" fields has been appended and removed'
+    );
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('500') ],
+        ['One bottle of beer in the fridge'],
+        '"500" field has retained it\'s original value'
+    );
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('501') ],
+        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
+        '"501" fields has been added'
+    );
+};
+
+subtest 'Record fields has been deleted when add = 0, append = 0, remove = 0, delete = 1' => sub {
+    plan tests => 2;
+
+    $rule->set(
+        {
+            'add' => 0,
+            'append' => 0,
+            'remove' => 0,
+            'delete' => 1,
+        }
+    );
+    $rule->store();
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+    cmp_ok(scalar @all_fields, '==', 2, "Record has the expected number of fields");
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['250 bottles of beer on the wall', '256 bottles of beer on the wall'],
+        '"250" fields has retained their original value'
+    );
+};
+
+subtest 'Record fields has been added and deleted when add = 1, append = 0, remove = 0, delete = 1' => sub {
+    plan tests => 3;
+
+    $rule->set(
+        {
+            'add' => 1,
+            'append' => 0,
+            'remove' => 0,
+            'delete' => 1,
+        }
+    );
+    $rule->store();
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+    cmp_ok(scalar @all_fields, '==', 4, "Record has the expected number of fields");
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['250 bottles of beer on the wall', '256 bottles of beer on the wall'],
+        '"250" fields has retained their original value'
+    );
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('501') ],
+        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
+        '"501" fields has been added'
+    );
+};
+
+subtest 'Record fields has been appended and deleted when add = 0, append = 1, remove = 0, delete = 1' => sub {
+    plan tests => 2;
+
+    $rule->set(
+        {
+            'add' => 0,
+            'append' => 1,
+            'remove' => 0,
+            'delete' => 1,
+        }
+    );
+    $rule->store();
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+    cmp_ok(scalar @all_fields, '==', 3, "Record has the expected number of fields");
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['250 bottles of beer on the wall', '256 bottles of beer on the wall', '251 bottles of beer on the wall'],
+        '"250" field has been appended'
+    );
+};
+
+subtest 'Record fields has been added, appended and deleted when add = 1, append = 1, remove = 0, delete = 1' => sub {
+    plan tests => 3;
+
+    $rule->set(
+        {
+            'add' => 1,
+            'append' => 1,
+            'remove' => 0,
+            'delete' => 1,
+        }
+    );
+    $rule->store();
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+    cmp_ok(scalar @all_fields, '==', 5, "Record has the expected number of fields");
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['250 bottles of beer on the wall', '256 bottles of beer on the wall', '251 bottles of beer on the wall'],
+        '"250" field has been appended'
+    );
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('501') ],
+        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
+        '"501" fields has been added'
+    );
+};
+
+subtest 'Record fields has been removed and deleted when add = 0, append = 0, remove = 1, delete = 1' => sub {
+    plan tests => 2;
+
+    $rule->set(
+        {
+            'add' => 0,
+            'append' => 0,
+            'remove' => 1,
+            'delete' => 1,
+        }
+    );
+    $rule->store();
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+    cmp_ok(scalar @all_fields, '==', 1, "Record has the expected number of fields");
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['256 bottles of beer on the wall'],
+        '"250" field has been removed'
+    );
+};
+
+subtest 'Record fields has been added, removed and deleted when add = 1, append = 0, remove = 1, delete = 1' => sub {
+    plan tests => 3;
+
+    $rule->set(
+        {
+            'add' => 1,
+            'append' => 0,
+            'remove' => 1,
+            'delete' => 1,
+        }
+    );
+    $rule->store();
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+    cmp_ok(scalar @all_fields, '==', 3, "Record has the expected number of fields");
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['256 bottles of beer on the wall'],
+        '"250" field has been removed'
+    );
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('501') ],
+        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
+        '"501" fields has been added'
+    );
+};
+
+subtest 'Record fields has been appended, removed and deleted when add = 0, append = 1, remove = 1, delete = 1' => sub {
+    plan tests => 2;
+
+    $rule->set(
+        {
+            'add' => 0,
+            'append' => 1,
+            'remove' => 1,
+            'delete' => 1,
+        }
+    );
+    $rule->store();
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+    cmp_ok(scalar @all_fields, '==', 2, "Record has the expected number of fields");
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['256 bottles of beer on the wall', '251 bottles of beer on the wall'],
+        '"250" fields has been appended and removed'
+    );
+};
+
+subtest 'Record fields has been overwritten when add = 1, append = 1, remove = 1, delete = 1' => sub {
+    plan tests => 4;
+
+    $rule->set(
+        {
+            'add' => 1,
+            'append' => 1,
+            'remove' => 1,
+            'delete' => 1,
+        }
+    );
+    $rule->store();
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+
+    cmp_ok(scalar @all_fields, '==', 4, "Record has the expected number of fields");
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['256 bottles of beer on the wall', '251 bottles of beer on the wall'],
+        '"250" fields has been appended and removed'
+    );
+
+    my @fields = $merged_record->field('500');
+    cmp_ok(scalar @fields, '==', 0, '"500" field has been deleted');
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('501') ],
+        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
+        '"501" fields has been added'
+    );
+};
+
+# Test rule tag specificity
+
+# Protect field 500 with more specific tag value
+my $skip_all_rule = Koha::MarcOverlayRules->find_or_create({
+    tag => '500',
+    module => 'source',
+    filter => '*',
+    add => 0,
+    append => 0,
+    remove => 0,
+    delete => 0
+});
+
+subtest '"500" field has been protected when rule matching on tag "500" is add = 0, append = 0, remove = 0, delete = 0' => sub {
+    plan tests => 4;
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+
+    cmp_ok(scalar @all_fields, '==', 5, "Record has the expected number of fields");
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['256 bottles of beer on the wall', '251 bottles of beer on the wall'],
+        '"250" fields has been appended and removed'
+    );
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('500') ],
+        ['One bottle of beer in the fridge'],
+        '"500" field has retained it\'s original value'
+    );
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('501') ],
+        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
+        '"501" fields has been added'
+    );
+};
+
+# Test regexp matching
+subtest '"5XX" fields has been protected when rule matching on regexp "5\d{2}" is add = 0, append = 0, remove = 0, delete = 0' => sub {
+    plan tests => 3;
+
+    $skip_all_rule->set(
+        {
+            'tag' => '5\d{2}',
+        }
+    );
+    $skip_all_rule->store();
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+
+    cmp_ok(scalar @all_fields, '==', 3, "Record has the expected number of fields");
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['256 bottles of beer on the wall', '251 bottles of beer on the wall'],
+        '"250" fields has been appended and removed'
+    );
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('500') ],
+        ['One bottle of beer in the fridge'],
+        '"500" field has retained it\'s original value'
+    );
+};
+
+# Test module specificity, the 0 all rule should no longer be included in set of applied rules
+subtest 'Record fields has been overwritten when non wild card rule with filter match is add = 1, append = 1, remove = 1, delete = 1' => sub {
+    plan tests => 4;
+
+    $rule->set(
+        {
+            'filter' => 'test',
+        }
+    );
+    $rule->store();
+
+    my $merged_record = Koha::MarcOverlayRules->merge_records($orig_record, $incoming_record, { 'source' => 'test' });
+
+    my @all_fields = $merged_record->fields();
+
+    cmp_ok(scalar @all_fields, '==', 4, "Record has the expected number of fields");
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('250') ],
+        ['256 bottles of beer on the wall', '251 bottles of beer on the wall'],
+        '"250" fields has been appended and removed'
+    );
+
+    my @fields = $merged_record->field('500');
+    cmp_ok(scalar @fields, '==', 0, '"500" field has been deleted');
+
+    is_deeply(
+        [map { $_->subfield('a') } $merged_record->field('501') ],
+        ['One cold bottle of beer in the fridge', 'Two cold bottles of beer in the fridge'],
+        '"501" fields has been added'
+    );
+};
+
+subtest 'An exception is thrown when append = 1, remove = 0 is set for control field rule' => sub {
+    plan tests => 2;
+    my $exception = try {
+        Koha::MarcOverlayRules->validate({
+            'tag' => '008',
+            'append' => 1,
+            'remove' => 0,
+        });
+    }
+    catch {
+        return $_;
+    };
+    ok(defined $exception, "Exception was caught");
+    ok($exception->isa('Koha::Exceptions::MarcOverlayRule::InvalidControlFieldActions'), "Exception is of correct class");
+};
+
+subtest 'An exception is thrown when rule tag is set to invalid regexp' => sub {
+    plan tests => 2;
+
+    my $exception = try {
+        Koha::MarcOverlayRules->validate({
+            'tag' => '**'
+        });
+    }
+    catch {
+        return $_;
+    };
+    ok(defined $exception, "Exception was caught");
+    ok($exception->isa('Koha::Exceptions::MarcOverlayRule::InvalidTagRegExp'), "Exception is of correct class");
+};
+
+$skip_all_rule->delete();
+
+subtest 'context option in ModBiblio is handled correctly' => sub {
+    plan tests => 6;
+    $rule->set(
+        {
+            tag => '250',
+            module => 'source',
+            filter => '*',
+            'add' => 0,
+            'append' => 0,
+            'remove' => 0,
+            'delete' => 0,
+        }
+    );
+    $rule->store();
+    my ($biblionumber) = AddBiblio($orig_record, '');
+
+    # Since marc merc rules are not run on save, only update
+    # saved record should be identical to orig_record
+    my $saved_record = GetMarcBiblio({ biblionumber => $biblionumber });
+
+    my @all_fields = $saved_record->fields();
+    # Koha also adds 999c field, therefore 4 not 3
+    cmp_ok(scalar @all_fields, '==', 4, 'Saved record has the expected number of fields');
+    is_deeply(
+        [map { $_->subfield('a') } $saved_record->field('250') ],
+        ['250 bottles of beer on the wall', '256 bottles of beer on the wall'],
+        'All "250" fields of saved record are identical to original record passed to AddBiblio'
+    );
+
+    is_deeply(
+        [map { $_->subfield('a') } $saved_record->field('500') ],
+        ['One bottle of beer in the fridge'],
+        'All "500" fields of saved record are identical to original record passed to AddBiblio'
+    );
+
+    $saved_record->append_fields(
+        MARC::Field->new('250', '', '', 'a' => '251 bottles of beer on the wall'), # Appended
+        MARC::Field->new('500', '', '', 'a' => 'One cold bottle of beer in the fridge'), # Appended
+    );
+
+    ModBiblio($saved_record, $biblionumber, '', { context => { 'source' => 'test' } });
+
+    my $updated_record = GetMarcBiblio({ biblionumber => $biblionumber });
+
+    @all_fields = $updated_record->fields();
+    cmp_ok(scalar @all_fields, '==', 5, 'Updated record has the expected number of fields');
+    is_deeply(
+        [map { $_->subfield('a') } $updated_record->field('250') ],
+        ['250 bottles of beer on the wall', '256 bottles of beer on the wall'],
+        '"250" fields have retained their original values'
+    );
+
+    is_deeply(
+        [map { $_->subfield('a') } $updated_record->field('500') ],
+        ['One bottle of beer in the fridge', 'One cold bottle of beer in the fridge'],
+        '"500" field has been appended'
+    );
+
+    # To trigger removal from search index etc
+    DelBiblio($biblionumber);
+};
+
+# Explicityly delete rule to trigger clearing of cache
+$rule->delete();
+
+$schema->storage->txn_rollback;