From 3df69aa5053db6837f5e74623fe6ded0a1bcefba Mon Sep 17 00:00:00 2001
From: Matthias Meusburger <matthias.meusburger@biblibre.com>
Date: Thu, 20 Jul 2023 16:29:49 +0200
Subject: [PATCH] Bug 35659: OAI Harvester

Test plan:

 - Run updatedatabase.pl

 - Apply the DBIC update patch

 - Setup an OAI repository in Administration -> OAI repositories (admin/oaiservers.pl)

   If the repository dataformat is in oai_dc, you can use the sample XSLT files:
   For Unimarc:
   OAIDCtoUNIMARCXML.xsl for bibliographic records
   AuthOAIDCtoUNIMARCXML.xsl for authorities

   For Marc21:
   OAIDCtoMARC21XML.xsl for bibliographic records
   AuthOAIDCtoMARC21XML.xsl for authorities

   If the repository dataformat is in marc-xml, you can use
   OAIMarcxml2KohaMarcxml.xsl for both Unimarc and Marc21.

 - Some repositories to test with:

   Bibliographic records, oai_dc:
   Endpoint: http://staroai.theses.fr/OAIHandler
   Set: ddc:260
   XSLT: <path_to_src>/koha-tmpl/intranet-tmpl/prog/en/xslt/OAIDCtoUNIMARCXML.xsl
   or
   XSLT: <path_to_src>/koha-tmpl/intranet-tmpl/prog/en/xslt/OAIDCtoMARC21XML.xsl

   Authorities, oai_dc:
   Endpoint: https://www.idref.fr/OAI/oai.jsp
   Set: a
   XSLT: <path_to_src>/koha-tmpl/intranet-tmpl/prog/en/xslt/AuthOAIDCtoUNIMARCXML.xsl
   or
   XSLT: <path_to_src>/koha-tmpl/intranet-tmpl/prog/en/xslt/AuthOAIDCtoMARC21XML.xsl

   Authorities, marc-xml, Unimarc:
   Endpoint: https://www.idref.fr/OAI/oai.jsp
   Set: a
   XSLT: <path_to_src>/koha-tmpl/intranet-tmpl/prog/en/xslt/OAIMarcxml2KohaMarcxml.xsl

 - Note: marc-xml harvesting will not work with HTTP::OAI::Harvester version 3.xx
   Version 4.xx is needed.
   See Bug 17704 for updating Koha default version to 4.xx

 - If you want to receive a report email, fill the OAI-PMH:HarvestEmailReport
   syspref. The model used (OAI_HARVEST_REPORT) can be edited in tools -> Notices and slips.

 - start a harvest with the following command:
   misc/cronjobs/harvest_oai.pl

    This script starts an OAI Harvest

    This script has the following parameters :
        -h --help: this message
        -v --verbose (enable output to stdout)
        -r --repository: id of the OAI repository
        -d --days: number of days to harvest from (optional)
        -l --list: list the OAI repositories
        -f --force: force harvesting (ignore records datestamps) (optional)

    Example: misc/cronjobs/harvest_oai.pl -r 1 -d 10 -v -f

 - Check the logs in the Log Viewer (tools/viewlog.pl)
   (CronjobLog syspref must be activated)

Sponsored-by: KohaLa
Signed-off-by: Michal Denar <black23@gmail.com>
Signed-off-by: Julian Maurice <julian.maurice@biblibre.com>
Signed-off-by: Pedro Amorim <pedro.amorim@ptfs-europe.com>
---
 Koha/Import/Oaipmh/Authorities.pm             |  51 +++
 Koha/Import/Oaipmh/Authority.pm               |  41 ++
 Koha/Import/Oaipmh/Biblio.pm                  |  40 ++
 Koha/Import/Oaipmh/Biblios.pm                 |  51 +++
 Koha/OAI/Client/Harvester.pm                  | 372 ++++++++++++++++++
 Koha/OaiServer.pm                             |  45 +++
 Koha/OaiServers.pm                            |  57 +++
 admin/oaiservers.pl                           | 122 ++++++
 .../data/mysql/atomicupdate/Bug-35659.pl      |  85 ++++
 .../mysql/en/mandatory/sample_notices.yml     |  23 ++
 installer/data/mysql/kohastructure.sql        |  58 +++
 installer/data/mysql/mandatory/sysprefs.sql   |   1 +
 .../data/mysql/mandatory/userpermissions.sql  |   2 +-
 .../prog/en/includes/admin-menu.inc           |   1 +
 .../prog/en/includes/oai-admin-search.inc     |  43 ++
 .../prog/en/includes/permissions.inc          |   2 +-
 .../prog/en/modules/admin/admin-home.tt       |   2 +
 .../prog/en/modules/admin/oaiservers.tt       | 229 +++++++++++
 .../admin/preferences/web_services.pref       |   5 +
 .../prog/en/xslt/AuthOAIDCtoMARC21XML.xsl     |  35 ++
 .../prog/en/xslt/AuthOAIDCtoUNIMARCXML.xsl    |  42 ++
 .../prog/en/xslt/OAIDCtoMARC21XML.xsl         | 367 +++++++++++++++++
 .../prog/en/xslt/OAIDCtoUNIMARCXML.xsl        | 341 ++++++++++++++++
 .../prog/en/xslt/OAIMarcxml2KohaMarcxml.xsl   |  23 ++
 misc/cronjobs/harvest_oai.pl                  | 110 ++++++
 t/db_dependent/Koha/OAIHarvester.t            |  78 ++++
 t/db_dependent/Koha/OAIServers.t              |  95 +++++
 27 files changed, 2319 insertions(+), 2 deletions(-)
 create mode 100644 Koha/Import/Oaipmh/Authorities.pm
 create mode 100644 Koha/Import/Oaipmh/Authority.pm
 create mode 100644 Koha/Import/Oaipmh/Biblio.pm
 create mode 100644 Koha/Import/Oaipmh/Biblios.pm
 create mode 100644 Koha/OAI/Client/Harvester.pm
 create mode 100644 Koha/OaiServer.pm
 create mode 100644 Koha/OaiServers.pm
 create mode 100755 admin/oaiservers.pl
 create mode 100755 installer/data/mysql/atomicupdate/Bug-35659.pl
 create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/oai-admin-search.inc
 create mode 100644 koha-tmpl/intranet-tmpl/prog/en/modules/admin/oaiservers.tt
 create mode 100644 koha-tmpl/intranet-tmpl/prog/en/xslt/AuthOAIDCtoMARC21XML.xsl
 create mode 100644 koha-tmpl/intranet-tmpl/prog/en/xslt/AuthOAIDCtoUNIMARCXML.xsl
 create mode 100644 koha-tmpl/intranet-tmpl/prog/en/xslt/OAIDCtoMARC21XML.xsl
 create mode 100644 koha-tmpl/intranet-tmpl/prog/en/xslt/OAIDCtoUNIMARCXML.xsl
 create mode 100644 koha-tmpl/intranet-tmpl/prog/en/xslt/OAIMarcxml2KohaMarcxml.xsl
 create mode 100755 misc/cronjobs/harvest_oai.pl
 create mode 100755 t/db_dependent/Koha/OAIHarvester.t
 create mode 100755 t/db_dependent/Koha/OAIServers.t

diff --git a/Koha/Import/Oaipmh/Authorities.pm b/Koha/Import/Oaipmh/Authorities.pm
new file mode 100644
index 0000000000..90372a8609
--- /dev/null
+++ b/Koha/Import/Oaipmh/Authorities.pm
@@ -0,0 +1,51 @@
+package Koha::Import::Oaipmh::Authorities;
+
+# 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
+
+use Modern::Perl;
+
+use Koha::Import::Oaipmh::Authority;
+
+use base qw(Koha::Objects);
+
+=head1 NAME
+
+Koha::Import::Oaipmh::Authorities
+
+This object represents a collection of OAI-PMH records being imported as authorities
+
+=head1 API
+
+=head2 Methods
+
+
+=head3 type
+
+=cut
+
+sub _type {
+    return 'ImportOaipmhAuthority';
+}
+
+=head3 object_class
+
+Koha::Object class
+
+=cut
+
+sub object_class {
+    return 'Koha::Import::Oaipmh::Authority';
+}
+1;
diff --git a/Koha/Import/Oaipmh/Authority.pm b/Koha/Import/Oaipmh/Authority.pm
new file mode 100644
index 0000000000..fe90c432a2
--- /dev/null
+++ b/Koha/Import/Oaipmh/Authority.pm
@@ -0,0 +1,41 @@
+package Koha::Import::Oaipmh::Authority;
+
+# 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
+
+use Modern::Perl;
+
+use base qw(Koha::Object);
+
+=head1 NAME
+
+Koha::Import::Oaipmh::Authority
+
+This object represents an OAI-PMH record being imported as an authority
+
+=head1 API
+
+=head2 Class Methods
+
+=cut
+
+=head3 _type
+
+=cut
+
+sub _type {
+    return 'ImportOaipmhAuthority';
+}
+
+1;
diff --git a/Koha/Import/Oaipmh/Biblio.pm b/Koha/Import/Oaipmh/Biblio.pm
new file mode 100644
index 0000000000..d59e5a6449
--- /dev/null
+++ b/Koha/Import/Oaipmh/Biblio.pm
@@ -0,0 +1,40 @@
+package Koha::Import::Oaipmh::Biblio;
+
+# 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
+
+use Modern::Perl;
+
+use base qw(Koha::Object);
+
+=head1 NAME
+
+Koha::Import::Oaipmh::Biblio
+
+This object represents an OAI-PMH record being imported as a bibliographic record
+
+=head1 API
+
+=head2 Methods
+
+
+=head3 _type
+
+=cut
+
+sub _type {
+    return 'ImportOaipmhBiblio';
+}
+
+1;
diff --git a/Koha/Import/Oaipmh/Biblios.pm b/Koha/Import/Oaipmh/Biblios.pm
new file mode 100644
index 0000000000..9ae111e96b
--- /dev/null
+++ b/Koha/Import/Oaipmh/Biblios.pm
@@ -0,0 +1,51 @@
+package Koha::Import::Oaipmh::Biblios;
+
+# 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
+
+use Modern::Perl;
+
+use Koha::Import::Oaipmh::Biblio;
+
+use base qw(Koha::Objects);
+
+=head1 NAME
+
+Koha::Import::Oaipmh::Biblios
+
+This object represents a collection of OAI-PMH records being imported as bibliographic records
+
+=head1 API
+
+=head2 Methods
+
+
+=head3 type
+
+=cut
+
+sub _type {
+    return 'ImportOaipmhBiblio';
+}
+
+=head3 object_class
+
+Koha::Object class
+
+=cut
+
+sub object_class {
+    return 'Koha::Import::Oaipmh::Biblio';
+}
+1;
diff --git a/Koha/OAI/Client/Harvester.pm b/Koha/OAI/Client/Harvester.pm
new file mode 100644
index 0000000000..f800fb1dc1
--- /dev/null
+++ b/Koha/OAI/Client/Harvester.pm
@@ -0,0 +1,372 @@
+# Copyright BibLibre 2023
+#
+# 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>.
+
+package Koha::OAI::Client::Harvester;
+
+=head1 NAME
+
+Koha::OAI::Client::Harvester - OAI Harvester
+
+=head1 DESCRIPTION
+
+Koha::OAI::Client::Harvester contains OAI-PMH harvester main functions
+
+=cut
+
+use utf8;
+use open qw( :std :utf8 );
+
+use C4::Biblio qw( AddBiblio GetFrameworkCode ModBiblio DelBiblio );
+use C4::AuthoritiesMarc qw (AddAuthority GuessAuthTypeCode ModAuthority DelAuthority );
+use C4::Log qw( cronlogaction );
+use HTTP::OAI;
+use HTTP::OAI::Metadata::OAI_DC;
+use Koha::DateUtils qw( dt_from_string );
+use Koha::OaiServers;
+use Koha::Import::Oaipmh::Biblio;
+use Koha::Import::Oaipmh::Biblios;
+use Koha::Import::Oaipmh::Authority;
+use Koha::Import::Oaipmh::Authorities;
+use Koha::XSLT::Base;
+use MARC::File::XML;
+use MARC::Record;
+use Modern::Perl;
+use DateTime;
+use DateTime::Format::Strptime;
+use Try::Tiny qw( catch try );
+use Date::Calc qw(
+    Add_Delta_Days
+    Today
+);
+
+my $strp = DateTime::Format::Strptime->new(
+    pattern   => '%FT%TZ',
+    time_zone => 'floating',
+);
+
+our $verbose = 0;
+our $server;
+our $force       = 0;
+our $days        = 0;
+our $xslt_engine = Koha::XSLT::Base->new;
+
+=head2 new
+
+$harvester = Koha::OAI::Client::Harvester->new( { server => $server, verbose => $verbose, days => $days, force => $force } );
+
+New instance of Koha::OAI::Client::Harvester
+
+C<$server> An OAI repository (Koha::OaiServer)
+
+C<$verbose> print log messages to stdout when enabled
+
+C<$days> number of days to harvest from (optional)
+
+C<$force> force harvesting (ignore records datestamps)
+
+=cut
+
+sub new {
+
+    my ( $self, $args ) = @_;
+
+    $server  = $args->{'server'};
+    $verbose = $args->{'verbose'};
+    $days    = $args->{'days'};
+    $force   = $args->{'force'};
+    return $self;
+}
+
+=head2 init
+
+Starts harvesting
+
+=cut
+
+sub init {
+    my ( $self, $args ) = @_;
+
+    printlog("Starting OAI Harvest from repository");
+
+    my $h = HTTP::OAI::Harvester->new(
+        repository => HTTP::OAI::Identify->new(
+            baseURL => $server->endpoint,
+            version => '2.0',
+        )
+    );
+
+    printlog( "Using HTTP::OAI::Harvester version " . $HTTP::OAI::Harvester::VERSION );
+
+    printlog( "Using endpoint "
+            . $server->endpoint
+            . ", set "
+            . $server->oai_set
+            . ", dataformat "
+            . $server->dataformat
+            . ", recordtype "
+            . $server->recordtype );
+
+    try {
+        my $lmdf    = $h->ListMetadataFormats();
+        my $lmdflog = "";
+        for ( $lmdf->metadataFormat ) {
+            $lmdflog .= $_->metadataPrefix . " ";
+        }
+        printlog("Available metadata formats for this repository: $lmdflog");
+    } catch {
+        printlog("ListMetadataFormats failed");
+    };
+
+    if ( $server->add_xslt ) {
+        printlog( "Using XSLT file " . $server->add_xslt );
+    } else {
+        printlog("Not using an XSLT file");
+    }
+
+    my $start_date = '';
+    my $today_date = '';
+
+    if ($days) {
+
+        # Change this to yyyy-mm-dd
+        my $dt_today = dt_from_string();
+        my ( $nowyear, $nowmonth, $nowday ) = Today();
+        my @date     = Add_Delta_Days( $nowyear, $nowmonth, $nowday, -$days );
+        my $dt_start = DateTime->new( year => $date[0], month => $date[1], day => $date[2] );
+        $start_date = $dt_start->ymd();
+        printlog("Harvesting from $start_date");
+    }
+
+    printlog("Asking for records");
+    my $response = $h->ListRecords(
+        metadataPrefix => $server->dataformat,
+        set            => $server->oai_set,
+        from           => $start_date,
+    );
+    if ( $response->is_error ) {
+        printlog( "Error requesting ListRecords: " . $response->code . " " . $response->message );
+        exit;
+    }
+    printlog( "Request URL: " . $response->requestURL );
+
+    my %stats;
+    my @statuses = qw(added updated deleted in_error skipped total);
+    foreach my $status (@statuses) {
+        $stats{$status} = 0;
+    }
+
+    printlog("Starting processing results");
+    while ( my $oai_record = $response->next ) {
+        $stats{'total'}++;
+        my $status = $self->processRecord($oai_record);
+        $stats{$status}++;
+    }
+
+    my $results = '';
+    foreach my $status (@statuses) {
+        $results .= $stats{$status} . " $status\n";
+    }
+    printlog( "Harvest results:\n" . $results );
+
+    if ( C4::Context->preference("OAI-PMH:HarvestEmailReport") ) {
+
+        my $letter = C4::Letters::GetPreparedLetter(
+            (
+                module      => 'catalogue',
+                letter_code => 'OAI_HARVEST_REPORT',
+                substitute  => {
+                    servername => $server->servername,
+                    endpoint   => $server->endpoint,
+                    set        => $server->oai_set,
+                    dataformat => $server->dataformat,
+                    recordtype => $server->recordtype,
+                    added      => $stats{'added'},
+                    updated    => $stats{'updated'},
+                    deleted    => $stats{'deleted'},
+                    skipped    => $stats{'skipped'},
+                    in_error   => $stats{'in_error'},
+                    total      => $stats{'total'}
+                },
+            )
+        ) or printlog("OAI_HARVEST_REPORT letter not found");
+
+        my $success = C4::Letters::EnqueueLetter(
+            {
+                letter     => $letter,
+                to_address => C4::Context->preference("OAI-PMH:HarvestEmailReport"), message_transport_type => 'email'
+            }
+        );
+        if ($success) {
+            printlog("Email report enqueued");
+        } else {
+            printlog("Unable to enqueue report email");
+        }
+    }
+
+    printlog("Ending OAI Harvest from repository");
+}
+
+=head2 processRecord
+
+This method processes an incoming OAI record
+
+=cut
+
+sub processRecord {
+    my $self       = shift;
+    my $oai_record = shift;
+    my $status     = '';
+    unless ( $oai_record->identifier ) {
+        printlog("No identifier found");
+        $status = 'skipped';
+        return $status;
+    }
+
+    my $to_delete;
+    my $outputUnimarc;
+    my $marcrecord;
+    if ( $oai_record->status && $oai_record->status eq "deleted" ) {
+        $to_delete = 1;
+    } else {
+        if ( $server->add_xslt ) {
+            $outputUnimarc = $xslt_engine->transform( $oai_record->metadata->dom, $server->add_xslt );
+            my $err = $xslt_engine->err;
+            if ($err) {
+                printlog("XSLT::Base error: $err");
+                $status = 'in_error';
+                return $status;
+            }
+        } else {
+            $outputUnimarc = $oai_record->metadata->dom;
+        }
+        $marcrecord = MARC::Record->new_from_xml( $outputUnimarc, 'UTF-8' );
+    }
+    my $imported_record;
+    if ( $server->recordtype eq "biblio" ) {
+        $imported_record = Koha::Import::Oaipmh::Biblios->find(
+            {
+                repository => $server->endpoint,
+                identifier => $oai_record->identifier,
+                recordtype => $server->recordtype,
+            }
+        );
+    } else {
+        $imported_record = Koha::Import::Oaipmh::Authorities->find(
+            {
+                repository => $server->endpoint,
+                identifier => $oai_record->identifier,
+                recordtype => $server->recordtype,
+            }
+        );
+    }
+
+    if ($imported_record) {
+        if ($to_delete) {
+            if ( $server->recordtype eq "biblio" ) {
+                my $biblionumber = $imported_record->biblionumber;
+                my $error        = DelBiblio($biblionumber);
+                if ($error) {
+                    printlog(
+                        "Record " . $oai_record->identifier . " not deleted, biblionumber: $biblionumber ($error)" );
+                    $status = 'in_error';
+                } else {
+                    $imported_record->delete;
+                    printlog( "Record " . $oai_record->identifier . " deleted, biblionumber: $biblionumber" );
+                    $status = 'deleted';
+                }
+            } else {
+                my $authid = $imported_record->authid;
+                DelAuthority( { authid => $authid } );
+                $imported_record->delete;
+                printlog( "Record " . $oai_record->identifier . " deleted, authid: $authid" );
+                $status = 'deleted';
+            }
+        } else {
+            my $existing_dt = $strp->parse_datetime( $imported_record->datestamp );
+            my $incoming_dt = $strp->parse_datetime( $oai_record->datestamp );
+
+            if ( $force || !$incoming_dt || !$existing_dt || $incoming_dt > $existing_dt ) {
+                if ( $server->recordtype eq "biblio" ) {
+                    my $biblionumber = $imported_record->biblionumber;
+                    my $result       = ModBiblio( $marcrecord, $biblionumber, GetFrameworkCode($biblionumber) );
+                    printlog( "Record " . $oai_record->identifier . " updated, biblionumber: $biblionumber" );
+                } else {
+                    my $authid = $imported_record->authid;
+                    my $result = ModAuthority( $authid, $marcrecord, GuessAuthTypeCode($marcrecord) );
+                    printlog( "Record " . $oai_record->identifier . " updated, authid: $authid" );
+                }
+                $imported_record->update(
+                    {
+                        datestamp => $imported_record->datestamp,
+                    }
+                );
+                $status = 'updated';
+            } else {
+                printlog( "Record "
+                        . $oai_record->identifier
+                        . " skipped (incoming record was not newer than existing record)" );
+                $status = 'skipped';
+            }
+        }
+    } elsif ( !$to_delete ) {
+        if ( $server->recordtype eq "biblio" ) {
+            my ( $biblionumber, $biblioitemnumber ) = AddBiblio($marcrecord);
+            printlog( $oai_record->identifier . " added, biblionumber: $biblionumber" );
+            Koha::Import::Oaipmh::Biblio->new(
+                {
+                    repository   => $server->endpoint,
+                    identifier   => $oai_record->identifier,
+                    biblionumber => $biblionumber,
+                    recordtype   => $server->recordtype,
+                    datestamp    => $oai_record->datestamp,
+                }
+            )->store;
+        } else {
+            my $authid = AddAuthority( $marcrecord, "", GuessAuthTypeCode($marcrecord) );
+            printlog( $oai_record->identifier . " added, authid: $authid" );
+            Koha::Import::Oaipmh::Authority->new(
+                {
+                    repository => $server->endpoint,
+                    identifier => $oai_record->identifier,
+                    authid     => $authid,
+                    recordtype => $server->recordtype,
+                    datestamp  => $oai_record->datestamp,
+                }
+            )->store;
+        }
+        $status = 'added';
+    } else {
+        printlog( "Record " . $oai_record->identifier . " skipped (record not present or already deleted)" );
+        $status = 'skipped';
+    }
+    return $status;
+}
+
+=head2 printlog
+
+This method adds a cronlog and prints to stdout if verbose is enabled
+
+=cut
+
+sub printlog {
+    my $message = shift;
+    $message = $server->servername . ": " . $message;
+    print $message . "\n" if ($verbose);
+    cronlogaction( { info => $message } );
+}
+
+1;
diff --git a/Koha/OaiServer.pm b/Koha/OaiServer.pm
new file mode 100644
index 0000000000..fa97c1f0b7
--- /dev/null
+++ b/Koha/OaiServer.pm
@@ -0,0 +1,45 @@
+package Koha::OaiServer;
+
+# 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 Koha::Database;
+
+use base qw(Koha::Object);
+
+=head1 NAME
+
+Koha::OaiServer - Koha OaiServer Object class
+
+=head1 API
+
+=head2 Class Methods
+
+=cut
+
+=head3 _type
+
+Return type of Object relating to Schema ResultSet
+
+=cut
+
+sub _type {
+    return 'Oaiserver';
+}
+
+1;
diff --git a/Koha/OaiServers.pm b/Koha/OaiServers.pm
new file mode 100644
index 0000000000..b752419c5d
--- /dev/null
+++ b/Koha/OaiServers.pm
@@ -0,0 +1,57 @@
+package Koha::OaiServers;
+
+# 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 Koha::Database;
+
+use Koha::OaiServer;
+
+use base qw(Koha::Objects);
+
+=head1 NAME
+
+Koha::OaiServers - Koha OaiServer Object set class
+
+=head1 API
+
+=head2 Class Methods
+
+=cut
+
+=head3 _type
+
+Return type of object, relating to Schema ResultSet
+
+=cut
+
+sub _type {
+    return 'Oaiserver';
+}
+
+=head3 object_class
+
+Return object class
+
+=cut
+
+sub object_class {
+    return 'Koha::OaiServer';
+}
+
+1;
diff --git a/admin/oaiservers.pl b/admin/oaiservers.pl
new file mode 100755
index 0000000000..cdbe26dcb3
--- /dev/null
+++ b/admin/oaiservers.pl
@@ -0,0 +1,122 @@
+#!/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>.
+
+# This script is used to maintain the OAI servers table.
+# Parameter $op is operation: list, new, edit, add_validated, delete_confirmed.
+# add_validated saves a validated record and goes to list view.
+# delete_confirmed deletes a record and goes to list view.
+
+use Modern::Perl;
+use CGI qw ( -utf8 );
+use C4::Context;
+use C4::Auth qw( get_template_and_user );
+use C4::Output qw( output_html_with_http_headers );
+use Koha::Database;
+use Koha::OaiServers;
+
+# Initialize CGI, template, database
+
+my $input       = CGI->new;
+my $op          = $input->param('op') || 'list';
+my $id          = $input->param('id') || 0;
+my $type        = $input->param('type') || '';
+my $searchfield = '';
+
+my ( $template, $loggedinuser, $cookie ) = get_template_and_user(
+    {
+        template_name => "admin/oaiservers.tt",
+        query         => $input,
+        type          => "intranet",
+        flagsrequired => { parameters => 'manage_search_targets' },
+    }
+);
+my $script_name = "/cgi-bin/koha/admin/oaiservers.pl";
+my $path        = C4::Context->config('intrahtdocs') . "/prog/";
+
+$template->param( script_name => $script_name, xslt_path => $path );
+
+my $schema = Koha::Database->new()->schema();
+
+# Main code
+# First process a confirmed delete, or save a validated record
+
+if ( $op eq 'delete_confirmed' && $id ) {
+    my $server = Koha::OaiServers->find($id);
+    if ($server) {
+        $server->delete;
+        $template->param( msg_deleted => 1, msg_add => $server->servername );
+    } else {
+        $template->param( msg_notfound => 1, msg_add => $id );
+    }
+    $id = 0;
+} elsif ( $op eq 'add_validated' ) {
+    my @fields = qw/endpoint oai_set dataformat
+        recordtype servername
+        add_xslt/;
+    my $formdata = _form_data_hashref( $input, \@fields );
+    if ($id) {
+        my $server = Koha::OaiServers->find($id);
+        if ($server) {
+            $server->set($formdata)->store;
+            $template->param( msg_updated => 1, msg_add => $formdata->{servername} );
+        } else {
+            $template->param( msg_notfound => 1, msg_add => $id );
+        }
+        $id = 0;
+    } else {
+        Koha::OaiServer->new($formdata)->store;
+        $template->param( msg_added => 1, msg_add => $formdata->{servername} );
+    }
+} else {
+
+    #use searchfield only in remaining operations
+    $searchfield = $input->param('searchfield') || '';
+}
+
+# Now list multiple records, or edit one record
+
+my $data = [];
+if ( $op eq 'add' || $op eq 'edit' ) {
+    $data = ServerSearch( $schema, $id, $searchfield ) if $searchfield || $id;
+    delete $data->[0]->{id} if @$data && $op eq 'add';    #cloning record
+    $template->param(
+        add_form => 1, server => @$data ? $data->[0] : undef,
+        op => $op, type => $op eq 'add' ? lc $type : ''
+    );
+} else {
+    $data = ServerSearch( $schema, $id, $searchfield );
+    $template->param(
+        loop => \@$data, searchfield => $searchfield, id => $id,
+        op   => 'list'
+    );
+}
+output_html_with_http_headers $input, $cookie, $template->output;
+
+# End of main code
+
+sub ServerSearch {    #find server(s) by id or name
+    my ( $schema, $id, $searchstring ) = @_;
+
+    return Koha::OaiServers->search(
+        $id ? { id => $id } : { servername => { like => $searchstring . '%' } },
+    )->unblessed;
+}
+
+sub _form_data_hashref {
+    my ( $input, $fieldref ) = @_;
+    return { map { ( $_ => scalar $input->param($_) // '' ) } @$fieldref };
+}
diff --git a/installer/data/mysql/atomicupdate/Bug-35659.pl b/installer/data/mysql/atomicupdate/Bug-35659.pl
new file mode 100755
index 0000000000..96721d3291
--- /dev/null
+++ b/installer/data/mysql/atomicupdate/Bug-35659.pl
@@ -0,0 +1,85 @@
+use Modern::Perl;
+
+return {
+    bug_number  => "35659",
+    description => "OAI-PMH Harvester",
+    up          => sub {
+        my ($args) = @_;
+        my ( $dbh, $out ) = @$args{qw(dbh out)};
+        $dbh->do(
+            q{
+            CREATE TABLE IF NOT EXISTS `oaiservers` (
+              `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'unique identifier assigned by Koha',
+              `endpoint` varchar(255) NOT NULL COMMENT 'OAI endpoint (host + port + path)',
+              `oai_set` varchar(255) DEFAULT NULL COMMENT 'OAI set to harvest',
+              `servername` longtext NOT NULL COMMENT 'name given to the target by the library',
+              `dataformat` enum('oai_dc','marc-xml', 'marcxml') NOT NULL DEFAULT 'oai_dc' COMMENT 'data format',
+              `recordtype` enum('authority','biblio') NOT NULL DEFAULT 'biblio' COMMENT 'server contains bibliographic or authority records',
+              `add_xslt` longtext DEFAULT NULL COMMENT 'zero or more paths to XSLT files to be processed on the search results',
+              PRIMARY KEY (`id`)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+        }
+        );
+        say $out "Added new table 'oaiservers'";
+
+        $dbh->do(
+            q{
+            CREATE TABLE IF NOT EXISTS `import_oaipmh_biblios` (
+              `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'unique identifier assigned by Koha',
+              `biblionumber` int(11) NOT NULL COMMENT 'unique identifier assigned to each koha record',
+              `identifier` varchar(255) NOT NULL COMMENT 'OAI record identifier',
+              `repository` varchar(255) NOT NULL COMMENT 'OAI repository',
+              `recordtype` enum('authority','biblio') NOT NULL DEFAULT 'biblio' COMMENT 'is the record bibliographic or authority',
+              `datestamp` varchar(255) DEFAULT NULL COMMENT 'OAI set to harvest',
+              `last_modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+              PRIMARY KEY (id),
+              KEY biblionumber (biblionumber),
+              CONSTRAINT FK_import_oaipmh_biblios_1 FOREIGN KEY (biblionumber) REFERENCES biblio (biblionumber) ON DELETE CASCADE ON UPDATE NO ACTION
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+        }
+        );
+
+        $dbh->do(
+            q{
+            CREATE TABLE IF NOT EXISTS `import_oaipmh_authorities` (
+              `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'unique identifier assigned by Koha',
+              `authid` bigint(20) unsigned NOT NULL COMMENT 'unique identifier assigned to each koha record',
+              `identifier` varchar(255) NOT NULL COMMENT 'OAI record identifier',
+              `repository` varchar(255) NOT NULL COMMENT 'OAI repository',
+              `recordtype` enum('authority','biblio') NOT NULL DEFAULT 'biblio' COMMENT 'is the record bibliographic or authority',
+              `datestamp` varchar(255) DEFAULT NULL COMMENT 'OAI set to harvest',
+              `last_modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+              PRIMARY KEY (id),
+              KEY authid (authid),
+              CONSTRAINT FK_import_oaipmh_authorities_1 FOREIGN KEY (authid) REFERENCES auth_header (authid) ON DELETE CASCADE ON UPDATE NO ACTION
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+        }
+        );
+        say $out "Added new tables 'import_oaipmh_biblios' and import_oaipmh_authorities";
+
+        $dbh->do(
+            q{
+            UPDATE `permissions` SET description='Manage Z39.50 and SRU servers, OAI repositories configuration' WHERE code='manage_search_targets';
+        }
+        );
+        say $out "Updated manage_search_targets permission description";
+
+        $dbh->do(
+            q{
+            INSERT IGNORE INTO systempreferences (variable, value, options, explanation, type) VALUES
+            ('OAI-PMH:HarvestEmailReport','','','If set, a report email will be sent to this address after an OAI harvest','Free');
+        }
+        );
+        say $out "Added OAI-PMH:HarvestEmailReport system preference";
+
+        $dbh->do(
+            q{
+            INSERT IGNORE INTO letter
+            (module, code, branchcode, name, is_html, title, content, message_transport_type, lang)
+            VALUES ('catalogue','OAI_HARVEST_REPORT','','OAI Harvest Report',0,'OAI Harvest report for [% servername %]','OAI Harvest report for [% servername %]:\n\nEndpoint: [% endpoint %]\nSet: [% set %]\nData format: [% dataformat %]\nRecord type: [% recordtype %]\n\n[% added %] records added\n[% updated %] records updated\n[% deleted %] records deleted\n[% skipped %] records skipped\n[% in_error %] records in error\n[% total %] total','email','default');
+        }
+        );
+        say $out "Added OAI_HARVEST_REPORT letter";
+
+    },
+};
diff --git a/installer/data/mysql/en/mandatory/sample_notices.yml b/installer/data/mysql/en/mandatory/sample_notices.yml
index 9675b3eb4f..6451df8974 100644
--- a/installer/data/mysql/en/mandatory/sample_notices.yml
+++ b/installer/data/mysql/en/mandatory/sample_notices.yml
@@ -2467,3 +2467,26 @@ tables:
             - "Your hold on [% hold.biblio.title %] ([% hold.biblio.id %]) has been confirmed."
             - "You will be notified by the library when your item is available for pickup."
             - "Thank you!"
+
+        - module: catalogue
+          code: OAI_HARVEST_REPORT
+          branchcode: ""
+          name: "OAI Harvest Report"
+          is_html: 0
+          title: "OAI Harvest report for [% servername %]"
+          message_transport_type: email
+          lang: default
+          content:
+            - "OAI Harvest report for [% servername %]:"
+            - ""
+            - "Endpoint: [% endpoint %]"
+            - "Set: [% set %]"
+            - "Data format: [% dataformat %]"
+            - "Record type: [% recordtype %]"
+            - ""
+            - "[% added %] records added"
+            - "[% updated %] records updated"
+            - "[% deleted %] records deleted"
+            - "[% skipped %] records skipped"
+            - "[% in_error %] records in error"
+            - "[% total %] total"
diff --git a/installer/data/mysql/kohastructure.sql b/installer/data/mysql/kohastructure.sql
index 3828776962..676ffd5c48 100644
--- a/installer/data/mysql/kohastructure.sql
+++ b/installer/data/mysql/kohastructure.sql
@@ -4819,6 +4819,64 @@ CREATE TABLE `oai_sets_mappings` (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
 /*!40101 SET character_set_client = @saved_cs_client */;
 
+--
+-- Table structure for table `oaiservers`
+--
+
+DROP TABLE IF EXISTS `oaiservers`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `oaiservers` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'unique identifier assigned by Koha',
+  `endpoint` varchar(255) NOT NULL COMMENT 'OAI endpoint (host + port + path)',
+  `oai_set` varchar(255) DEFAULT NULL COMMENT 'OAI set to harvest',
+  `servername` longtext NOT NULL COMMENT 'name given to the target by the library',
+  `dataformat` enum('oai_dc','marc-xml', 'marcxml') NOT NULL DEFAULT 'oai_dc' COMMENT 'data format',
+  `recordtype` enum('authority','biblio') NOT NULL DEFAULT 'biblio' COMMENT 'server contains bibliographic or authority records',
+  `add_xslt` longtext DEFAULT NULL COMMENT 'zero or more paths to XSLT files to be processed on the search results',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+--
+-- Table structure for table `import_oaipmh_biblios`
+--
+
+DROP TABLE IF EXISTS `import_oaipmh_biblios`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `import_oaipmh_biblios` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'unique identifier assigned by Koha',
+  `biblionumber` int(11) NOT NULL COMMENT 'unique identifier assigned to each koha record',
+  `identifier` varchar(255) NOT NULL COMMENT 'OAI record identifier',
+  `repository` varchar(255) NOT NULL COMMENT 'OAI repository',
+  `recordtype` enum('authority','biblio') NOT NULL DEFAULT 'biblio' COMMENT 'is the record bibliographic or authority',
+  `datestamp` varchar(255) DEFAULT NULL COMMENT 'OAI set to harvest',
+  `last_modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  KEY biblionumber (biblionumber),
+  CONSTRAINT FK_import_oaipmh_biblios_1 FOREIGN KEY (biblionumber) REFERENCES biblio (biblionumber) ON DELETE CASCADE ON UPDATE NO ACTION
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+--
+-- Table structure for table `import_oaipmh_authorities`
+--
+
+DROP TABLE IF EXISTS `import_oaipmh_authorities`;
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `import_oaipmh_authorities` (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'unique identifier assigned by Koha',
+  `authid` bigint(20) unsigned NOT NULL COMMENT 'unique identifier assigned to each koha record',
+  `identifier` varchar(255) NOT NULL COMMENT 'OAI record identifier',
+  `repository` varchar(255) NOT NULL COMMENT 'OAI repository',
+  `recordtype` enum('authority','biblio') NOT NULL DEFAULT 'biblio' COMMENT 'is the record bibliographic or authority',
+  `datestamp` varchar(255) DEFAULT NULL COMMENT 'OAI set to harvest',
+  `last_modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (id),
+  KEY authid (authid),
+  CONSTRAINT FK_import_oaipmh_authorities_1 FOREIGN KEY (authid) REFERENCES auth_header (authid) ON DELETE CASCADE ON UPDATE NO ACTION
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
 --
 -- Table structure for table `oauth_access_tokens`
 --
diff --git a/installer/data/mysql/mandatory/sysprefs.sql b/installer/data/mysql/mandatory/sysprefs.sql
index 0b264fb928..2d41cb9b84 100644
--- a/installer/data/mysql/mandatory/sysprefs.sql
+++ b/installer/data/mysql/mandatory/sysprefs.sql
@@ -439,6 +439,7 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, `
 ('OAI-PMH:AutoUpdateSetsEmbedItemData', '0', '', 'Embed item information when automatically updating OAI sets. Requires OAI-PMH:AutoUpdateSets syspref to be enabled', 'YesNo'),
 ('OAI-PMH:ConfFile','',NULL,'If empty, Koha OAI Server operates in normal mode, otherwise it operates in extended mode.','File'),
 ('OAI-PMH:DeletedRecord','persistent','Koha\'s deletedbiblio table will never be deleted (persistent), might be deleted (transient), or will never have any data in it (no)','transient|persistent|no','Choice'),
+('OAI-PMH:HarvestEmailReport','','','If set, a report email will be sent to this address after an OAI harvest','Free'),
 ('OAI-PMH:MaxCount','50',NULL,'OAI-PMH maximum number of records by answer to ListRecords and ListIdentifiers queries','Integer'),
 ('OnSiteCheckoutAutoCheck','0','','Enable/Do not enable onsite checkout by default if last checkout was an onsite checkout','YesNo'),
 ('OnSiteCheckouts','0','','Enable/Disable the on-site checkouts feature','YesNo'),
diff --git a/installer/data/mysql/mandatory/userpermissions.sql b/installer/data/mysql/mandatory/userpermissions.sql
index 729c075548..a4dbed1247 100644
--- a/installer/data/mysql/mandatory/userpermissions.sql
+++ b/installer/data/mysql/mandatory/userpermissions.sql
@@ -29,7 +29,7 @@ INSERT INTO permissions (module_bit, code, description) VALUES
    ( 3, 'manage_item_search_fields', 'Manage item search fields'),
    ( 3, 'manage_search_engine_config', 'Manage search engine configuration'),
    ( 3, 'manage_marc_overlay_rules', 'Manage MARC overlay rules configuration'),
-   ( 3, 'manage_search_targets', 'Manage Z39.50 and SRU server configuration'),
+   ( 3, 'manage_search_targets', 'Manage Z39.50 and SRU servers, OAI repositories configuration'),
    ( 3, 'manage_didyoumean', 'Manage Did you mean? configuration'),
    ( 3, 'manage_column_config', 'Manage column configuration'),
    ( 3, 'manage_sms_providers', 'Manage SMS cellular providers'),
diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/admin-menu.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/admin-menu.inc
index c7caffc014..184d27cfa0 100644
--- a/koha-tmpl/intranet-tmpl/prog/en/includes/admin-menu.inc
+++ b/koha-tmpl/intranet-tmpl/prog/en/includes/admin-menu.inc
@@ -149,6 +149,7 @@
             [% END %]
             [% IF ( CAN_user_parameters_manage_search_targets ) %]
                 <li><a href="/cgi-bin/koha/admin/z3950servers.pl">Z39.50/SRU servers</a></li>
+                <li><a href="/cgi-bin/koha/admin/oaiservers.pl">OAI repositories</a></li>
             [% END %]
             [% IF ( CAN_user_parameters_manage_smtp_servers ) %]
                 <li><a href="/cgi-bin/koha/admin/smtp_servers.pl">SMTP servers</a></li>
diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/oai-admin-search.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/oai-admin-search.inc
new file mode 100644
index 0000000000..49feedd580
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/en/includes/oai-admin-search.inc
@@ -0,0 +1,43 @@
+[% USE Koha %]
+<!-- oai-admin-search.inc -->
+<div id="header_search" role="tablist">
+    <div class="tab-content">
+        <div id="oai_search" role="tabpanel" class="tab-pane active">
+            <form action="/cgi-bin/koha/admin/oaiservers.pl" method="post">
+                <div class="form-title">
+                    <label class="control-label">Search OAI repositories</label>
+                </div>
+
+                <div class="form-content">
+                    <input class="head-searchbox form-control" type="text" name="searchfield" id="searchfield" value="[% searchfield | html %]" placeholder="OAI repository search" />
+                </div>
+
+                <button type="submit"><i class="fa fa-arrow-right"></i></button>
+            </form>
+        </div>
+
+        [% IF ( CAN_user_circulate_circulate_remaining_permissions ) %]
+            [% INCLUDE 'patron-search-box.inc' %]
+        [% END %]
+        [% IF ( CAN_user_catalogue ) %]
+            [% INCLUDE 'catalogue-search-box.inc' %]
+        [% END %]
+
+    </div><!-- /.tab-content -->
+    <ul class="nav nav-tabs" role="tablist">
+        <li role="presentation" class="active">
+            <a title="Search OAI repositories" href="#oai_search" aria-controls="z3950_search" role="tab" aria-expanded="true" data-toggle="tab" class="keep_text"><i class="fa fa-search"></i> <span class="tab-title">Search OAI repositories</span></a>
+        </li>
+        [% IF ( CAN_user_circulate_circulate_remaining_permissions ) %]
+            <li role="presentation">
+                <a title="Check out" href="#circ_search" aria-controls="circ_search" role="tab" data-toggle="tab" class="keep_text"><i class="fa fa-upload"></i> <span class="tab-title">Check out</span></a>
+            </li>
+        [% END %]
+        [% IF ( CAN_user_catalogue ) %]
+            <li role="presentation">
+                <a title="Search catalog" href="#catalog_search" aria-controls="catalog_search" role="tab" data-toggle="tab" class="keep_text"><i class="fa fa-fw fa-search"></i> <span class="tab-title">Search catalog</span></a>
+            </li>
+        [% END %]
+    </ul>
+</div><!-- /#header_search -->
+<!-- /oai-admin-search.inc -->
diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/permissions.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/permissions.inc
index 742e6e1c35..15ad115181 100644
--- a/koha-tmpl/intranet-tmpl/prog/en/includes/permissions.inc
+++ b/koha-tmpl/intranet-tmpl/prog/en/includes/permissions.inc
@@ -244,7 +244,7 @@
         <span class="permissioncode">([% name | html %])</span>
     [%- CASE 'manage_search_targets' -%]
         <span class="sub_permission manage_search_targets_subpermission">
-            Manage Z39.50 and SRU server configuration
+            Manage Z39.50 and SRU servers, OAI repositories configuration
         </span>
         <span class="permissioncode">([% name | html %])</span>
     [%- CASE 'manage_didyoumean' -%]
diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt
index 1320214b65..7b1696bd24 100644
--- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt
+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt
@@ -249,6 +249,8 @@
                     [% IF ( CAN_user_parameters_manage_search_targets ) %]
                         <dt><a href="/cgi-bin/koha/admin/z3950servers.pl">Z39.50/SRU servers</a></dt>
                         <dd>Define which external servers to query for MARC data</dd>
+                        <dt><a href="/cgi-bin/koha/admin/oaiservers.pl">OAI repositories</a></dt>
+                        <dd>Define which OAI repositories to harvest data from</dd>
                     [% END %]
                     [% IF ( CAN_user_parameters_manage_smtp_servers ) %]
                         <dt><a href="/cgi-bin/koha/admin/smtp_servers.pl">SMTP servers</a></dt>
diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/oaiservers.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/oaiservers.tt
new file mode 100644
index 0000000000..7f95726ab9
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/oaiservers.tt
@@ -0,0 +1,229 @@
+[% USE raw %]
+[% USE Asset %]
+[% SET footerjs = 1 %]
+[% INCLUDE 'doc-head-open.inc' %]
+
+<title>
+    [% IF op == 'edit' %]
+        Modify OAI repository [% server.servername | html %] &rsaquo; [% END %]
+
+    [% IF op ==  'add' %]
+        New OAI repository &rsaquo; [% END %]
+
+    OAI repositories &rsaquo; Administration &rsaquo; Koha
+</title>
+[% INCLUDE 'doc-head-close.inc' %]
+</head>
+
+<body id="admin_oaiservers" class="admin">
+[% WRAPPER 'header.inc' %]
+    [% INCLUDE 'oai-admin-search.inc' %]
+[% END %]
+
+[% WRAPPER 'sub-header.inc' %]
+    [% WRAPPER breadcrumbs %]
+        [% WRAPPER breadcrumb_item %]
+            <a href="/cgi-bin/koha/admin/admin-home.pl">Administration</a>
+        [% END %]
+
+        [% IF op == 'edit' || op == 'add' %]
+            [% WRAPPER breadcrumb_item %]
+                <a href="/cgi-bin/koha/admin/oaiservers.pl">OAI repositories</a>
+           [% END %]
+        [% END %]
+
+        [% IF op == 'edit' %]
+            [% WRAPPER breadcrumb_item bc_active= 1 %]
+                <span>Modify OAI repository [% server.servername | html %]</span>
+           [% END %]
+
+        [% ELSIF op == 'add' %]
+            [% WRAPPER breadcrumb_item bc_active= 1 %]
+                <span>New OAI repository</span>
+           [% END %]
+
+        [% ELSE %]
+            [% WRAPPER breadcrumb_item bc_active= 1 %]
+                <span>OAI repositories</span>
+           [% END %]
+        [% END %]
+    [% END #/ WRAPPER breadcrumbs %]
+[% END #/ WRAPPER sub-header.inc %]
+
+<div class="main container-fluid">
+    <div class="row">
+        <div class="col-sm-10 col-sm-push-2">
+            <main>
+
+[% IF msg_deleted %]
+    <div class="dialog alert">OAI repository deleted ([% msg_add | html %])</div>
+[% ELSIF msg_updated %]
+    <div class="dialog message">OAI repository updated ([% msg_add | html %])</div>
+[% ELSIF msg_added %]
+    <div class="dialog message">OAI repository added ([% msg_add | html %])</div>
+[% ELSIF msg_notfound %]
+    <div class="dialog alert">Error: Server with ID [% msg_add | html %] not found</div>
+[% END %]
+
+[% IF ( add_form ) %]
+    <form action="[% script_name | html %]" name="Aform" method="post" id="serverentry">
+        <input type="hidden" name="op" value="add_validated" />
+        [% IF op == 'edit' %]
+            <h1>Modify OAI repository</h1>
+            <input type="hidden" name="id" value="[% server.id | html %]" />
+        [% ELSE %]
+            <h1>New OAI repository</h1>
+        [% END %]
+        <fieldset class="rows">
+        <ol>
+        <li><label for="name" class="required">Repository name: </label>
+            [% IF server.servername %]
+                <input type="text" name="servername" id="servername" size="65" maxlength="100" value="[% server.servername | html %]" required="required" />
+            [% ELSE %]
+                <input type="text" name="servername" id="servername" class="focus" size="65" maxlength="100" value="[% server.servername | html %]" required="required" />
+            [% END %]
+             <span class="required">Required</span>
+        </li>
+
+        <li><label for="endpoint" class="required">Endpoint: </label> <input type="text" name="endpoint" id="endpoint" size="30" value="[% server.endpoint | html %]" required="required" /> <span class="required">Required</span>
+        </li>
+        <li><label for="oai_set">Set: </label> <input type="text" name="oai_set" id="oai_set" value="[% server.oai_set | html %]" />
+        </li>
+        <li>
+            <label for="dataformat">Data format: </label>
+        <select name="dataformat" id="dataformat">
+            <option value="oai_dc">Dublin Core (oai_dc)</option>
+            <option value="marc-xml">Marcxml (marc-xml)</option>
+            <option value="marcxml">Marcxml (marcxml)</option>
+        </select>
+            <div class="hint">OAI can send records in various formats. Choose one.</div>
+        </li>
+
+        <li><label for="recordtype">Record type: </label>
+        <select name="recordtype" id="recordtype">
+            <option value="biblio">Bibliographic</option>
+            <option value="authority">Authority</option>
+        </select>
+        </li>
+        <li>
+        <label for="add_xslt">XSLT file for transforming results: </label>
+        <input type="text" name="add_xslt" id="add_xslt" size="100" placeholder="[% xslt_path | html %]" value="[% server.add_xslt | html %]"/>
+        </li>
+
+        </ol>
+        </fieldset>
+
+        <fieldset class="action"><input type="submit" class="btn btn-primary" value="Save" /> <a class="cancel" href="/cgi-bin/koha/admin/oaiservers.pl">Cancel</a></fieldset>
+    </form>
+[% END %]
+
+[% IF op == 'list' %]
+    <div id="toolbar" class="btn-toolbar">
+        <a id="newserver" class="btn btn-default" href="/cgi-bin/koha/admin/oaiservers.pl?op=add"><i class="fa fa-plus"></i> New OAI repository</a>
+    </div>
+    <h1>OAI repositories administration</h1>
+    [% IF id %]
+        You searched for record [% id | html %]
+    [% ELSIF searchfield %]
+        You searched for [% searchfield | html %]
+    [% END %]
+
+    <div class="page-section">
+    <table id="serverst">
+
+        <thead><tr><th>Id</th><th>Repository name</th><th>Endpoint</th><th>Set</th><th>Data format</th><th>Record type</th><th class="noExport">Actions</th>
+        </tr></thead>
+        <tbody>
+        [% FOREACH loo IN loop %]
+            <tr>
+            <td>[% loo.id | html %]</td>
+            <td><a href="/cgi-bin/koha/admin/oaiservers.pl?op=edit&amp;id=[% loo.id | uri %]">[% loo.servername | html %]</a></td><td>[% loo.endpoint | html %]</td><td>[% loo.oai_set | html %]</td>
+            <td>[% loo.dataformat | html %]</td>
+            <td>[% IF ( loo.recordtype == 'biblio' )  %]
+                <span>Bibliographic</span>
+                [% ELSIF ( loo.recordtype == 'authority' ) %]
+                <span>Authority</span>
+                [% END %]
+            </td>
+            <td>
+                <div class="btn-group dropup">
+                    <a class="btn btn-default btn-xs dropdown-toggle" id="reportactions[% savedreport.id | html %]" role="button" data-toggle="dropdown" href="#">
+                        Actions <b class="caret"></b>
+                    </a>
+                    <ul class="dropdown-menu pull-right" role="menu" aria-labelledby="reportactions[% savedreport.id | html %]">
+                        <li><a href="/cgi-bin/koha/admin/oaiservers.pl?op=edit&amp;id=[% loo.id | uri %]"><i class="fa-solid fa-pencil" aria-hidden="true"></i> Edit</a></li>
+                        <li><a href="/cgi-bin/koha/admin/oaiservers.pl?op=add&amp;id=[% loo.id | uri %]"><i class="fa fa-copy"></i> Copy</a></li>
+                        <li><a href="/cgi-bin/koha/admin/oaiservers.pl?op=delete_confirmed&amp;id=[% loo.id | uri %]" class="delete" data-servername="[% loo.servername | html %]"><i class="fa fa-trash-can"></i> Delete</a></li>
+                    </ul>
+                </div>
+            </td>
+            </tr>
+        [% END %]
+        </tbody>
+    </table>
+    </div> <!-- /.page-section -->
+[% END %]
+
+            </main>
+        </div> <!-- /.col-sm-10.col-sm-push-2 -->
+
+        <div class="col-sm-2 col-sm-pull-10">
+            <aside>
+                [% INCLUDE 'admin-menu.inc' %]
+            </aside>
+        </div> <!-- /.col-sm-2.col-sm-pull-10 -->
+     </div> <!-- /.row -->
+
+[% MACRO jsinclude BLOCK %]
+    [% Asset.js("js/admin-menu.js") | $raw %]
+    [% IF op == 'list' %]
+        [% INCLUDE 'datatables.inc' %]
+    [% END %]
+
+    <script>
+        [% IF ( add_form ) %]
+            $(document).ready(function(){
+                // Update selects for dataformat and recordtype
+                [% IF server %]
+                    $("#dataformat").val('[% server.dataformat | html %]');
+                    $("#recordtype").val('[% server.recordtype | html %]');
+                [% END %]
+                $( "#serverentry" ).validate({
+                    rules: {
+                        servername: { required: true },
+                        host: { required: true },
+                        port: {
+                            number: true
+                        },
+                        rank: { number: true },
+                    }
+                });
+                $("#serverentry").submit(function( event ) {
+                    // enable recordtype to include field in post
+                    $('#recordtype').prop('disabled',false);
+                });
+                $("#servername").on("blur",function(){
+                    toUC(this);
+                });
+            });
+        [% ELSE %]
+            $(document).ready(function() {
+                $("#serverst").dataTable($.extend(true, {}, dataTablesDefaults, {
+                    "aoColumnDefs": [
+                        { "aTargets": [-1], "bSortable": false, "bSearchable": false },
+                    ],
+                    "sPaginationType": "full"
+                }));
+                $(".delete").on("click",function(e){
+                    var servername = $(this).data("servername");
+                    if( confirm( _("Are you sure you want to delete repository %s?").format(servername) ) ) {
+                        return true;
+                    } else {
+                        e.preventDefault();
+                    }
+                });
+            });
+        [% END %]
+    </script>
+[% END %]
+[% INCLUDE 'intranet-bottom.inc' %]
diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/web_services.pref b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/web_services.pref
index d5c7c1feb2..da14efec0e 100644
--- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/web_services.pref
+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/web_services.pref
@@ -91,6 +91,11 @@ Web services:
                   transient: might be emptied or truncated at some point (transient)
                   no: will never have any data in it (no)
             - "."
+        -
+            - If set, a report email will be sent to this address after an OAI harvest
+            - pref: "OAI-PMH:HarvestEmailReport"
+              class: Text
+
     ILS-DI:
         -
             - pref: ILS-DI
diff --git a/koha-tmpl/intranet-tmpl/prog/en/xslt/AuthOAIDCtoMARC21XML.xsl b/koha-tmpl/intranet-tmpl/prog/en/xslt/AuthOAIDCtoMARC21XML.xsl
new file mode 100644
index 0000000000..e2dbe4d4d6
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/en/xslt/AuthOAIDCtoMARC21XML.xsl
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xsl:stylesheet version="1.0"
+    xmlns:dc="http://purl.org/dc/elements/1.1/"
+    xmlns:dcterms="http://purl.org/dc/terms/1.1"
+    xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/
+        http://www.openarchives.org/OAI/2.0/oai_dc.xsd"
+    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+    xmlns="http://www.loc.gov/MARC21/slim"  exclude-result-prefixes="dc dcterms oai_dc">
+
+    <xsl:output method="xml" encoding="UTF-8" indent="yes"/>
+
+    <xsl:template match="/">
+        <collection xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.loc.gov/MARC21/slim http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd" >
+            <xsl:apply-templates />
+        </collection>
+    </xsl:template>
+
+    <xsl:template match="oai_dc:dc">
+        <record>
+
+            <xsl:variable name="FamilyName" select="dc:FamilyName"/>
+            <xsl:variable name="GivenName" select="dc:GivenName"/>
+
+            <datafield tag="100" ind1="0" ind2=" ">
+                <subfield code="a">
+                    <xsl:value-of select="concat($FamilyName,', ',$GivenName)"/>
+                </subfield>
+            </datafield>
+
+        </record>
+   </xsl:template>
+
+</xsl:stylesheet>
diff --git a/koha-tmpl/intranet-tmpl/prog/en/xslt/AuthOAIDCtoUNIMARCXML.xsl b/koha-tmpl/intranet-tmpl/prog/en/xslt/AuthOAIDCtoUNIMARCXML.xsl
new file mode 100644
index 0000000000..9dcd285c96
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/en/xslt/AuthOAIDCtoUNIMARCXML.xsl
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xsl:stylesheet version="1.0"
+    xmlns:dc="http://purl.org/dc/elements/1.1/"
+    xmlns:dcterms="http://purl.org/dc/terms/1.1"
+    xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/
+        http://www.openarchives.org/OAI/2.0/oai_dc.xsd"
+    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+    xmlns="http://www.loc.gov/MARC21/slim"  exclude-result-prefixes="dc dcterms oai_dc">
+
+    <xsl:output method="xml" encoding="UTF-8" indent="yes"/>
+
+    <xsl:template match="/">
+        <collection xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.loc.gov/MARC21/slim http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd" >
+            <xsl:apply-templates />
+        </collection>
+    </xsl:template>
+
+    <xsl:template match="oai_dc:dc">
+        <record>
+
+            <xsl:for-each select="dc:FamilyName">
+                <datafield tag="200" ind1="0" ind2=" ">
+                    <subfield code="a">
+                        <xsl:value-of select="."/>
+                    </subfield>
+                </datafield>
+            </xsl:for-each>
+
+            <xsl:for-each select="dc:GivenName">
+                <datafield tag="200" ind1="0" ind2=" ">
+                    <subfield code="b">
+                        <xsl:value-of select="."/>
+                    </subfield>
+                </datafield>
+            </xsl:for-each>
+
+        </record>
+   </xsl:template>
+
+</xsl:stylesheet>
diff --git a/koha-tmpl/intranet-tmpl/prog/en/xslt/OAIDCtoMARC21XML.xsl b/koha-tmpl/intranet-tmpl/prog/en/xslt/OAIDCtoMARC21XML.xsl
new file mode 100644
index 0000000000..4e3d13a436
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/en/xslt/OAIDCtoMARC21XML.xsl
@@ -0,0 +1,367 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xsl:stylesheet version="1.0"
+    xmlns:dc="http://purl.org/dc/elements/1.1/"
+    xmlns:dcterms="http://purl.org/dc/terms/1.1"
+    xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/
+        http://www.openarchives.org/OAI/2.0/oai_dc.xsd"
+    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+    xmlns="http://www.loc.gov/MARC21/slim"  exclude-result-prefixes="dc dcterms oai_dc">
+
+    <xsl:import href="MARC21slimUtils.xsl"/>
+    <xsl:output method="xml" encoding="UTF-8" indent="yes"/>
+
+
+    <xsl:template match="/">
+        <collection xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.loc.gov/MARC21/slim http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd" >
+            <xsl:apply-templates />
+        </collection>
+    </xsl:template>
+
+    <xsl:template name="OAI-PMH">
+             <xsl:for-each select = "ListRecords/record/metadata/oai_dc:dc">
+                <xsl:apply-templates  />
+             </xsl:for-each>
+             <xsl:for-each select = "GetRecord/record/metadata/oai_dc:dc">
+                <xsl:apply-templates  />
+             </xsl:for-each>
+    </xsl:template>
+
+    <xsl:template match="text()" />
+    <xsl:template match="oai_dc:dc">
+        <record>
+            <xsl:element name="leader">
+                <xsl:variable name="type" select="dc:type"/>
+                <xsl:variable name="leader06">
+                    <xsl:choose>
+                        <xsl:when test="$type='collection'">p</xsl:when>
+                        <xsl:when test="$type='dataset'">m</xsl:when>
+                        <xsl:when test="$type='event'">r</xsl:when>
+                        <xsl:when test="$type='image'">k</xsl:when>
+                        <xsl:when test="$type='interactive resource'">m</xsl:when>
+                        <xsl:when test="$type='service'">m</xsl:when>
+                        <xsl:when test="$type='software'">m</xsl:when>
+                        <xsl:when test="$type='sound'">i</xsl:when>
+                        <xsl:when test="$type='text'">a</xsl:when>
+                        <xsl:otherwise>a</xsl:otherwise>
+                    </xsl:choose>
+                </xsl:variable>
+                <xsl:variable name="leader07">
+                    <xsl:choose>
+                        <xsl:when test="$type='collection'">c</xsl:when>
+                        <xsl:otherwise>m</xsl:otherwise>
+                    </xsl:choose>
+                </xsl:variable>
+                <xsl:value-of select="concat('      ',$leader06,$leader07,'         3u     ')"/>
+            </xsl:element>
+
+            <datafield tag="042" ind1=" " ind2=" ">
+                <subfield code="a">dc</subfield>
+            </datafield>
+
+
+
+            <xsl:for-each select="dc:creator">
+                <xsl:choose>
+                    <xsl:when test="(.!='') and (position()=1)">
+                        <xsl:call-template name="persname_template">
+                            <xsl:with-param name="string" select="." />
+                            <xsl:with-param name="field" select="'100'" />
+                            <xsl:with-param name="ind1" select = "'1'" />
+                            <xsl:with-param name="ind2" select = "'0'" />
+                            <xsl:with-param name="type" select="'author'" />
+                        </xsl:call-template>
+                    </xsl:when>
+                    <xsl:otherwise>
+                        <xsl:if test=".!=''">
+                            <xsl:call-template name="persname_template">
+                                <xsl:with-param name="string" select="." />
+                                <xsl:with-param name="field" select="'700'" />
+                                <xsl:with-param name="ind1" select = "'1'" />
+                                <xsl:with-param name="ind2" select = "'0'" />
+                                <xsl:with-param name="type" select="'author'" />
+                            </xsl:call-template>
+                        </xsl:if>
+                    </xsl:otherwise>
+                </xsl:choose>
+            </xsl:for-each>
+
+
+            <xsl:for-each select="dc:title[1]">
+                <datafield tag="245" ind1="0" ind2="0">
+                    <subfield code="a">
+                        <xsl:value-of select="."/>
+                    </subfield>
+                </datafield>
+            </xsl:for-each>
+
+            <xsl:for-each select="dc:title[position()>1]">
+                <xsl:if test=".!=''">
+                    <datafield tag="246" ind1="3" ind2="3">
+                        <subfield code="a">
+                            <xsl:value-of select="."/>
+                        </subfield>
+                    </datafield>
+                </xsl:if>
+            </xsl:for-each>
+
+            <xsl:choose>
+                <xsl:when test="dc:publisher">
+                    <xsl:if test="translate(dc:publisher/.,'.,:;','')!=''">
+                        <datafield tag="260" ind1=" " ind2=" ">
+                        <xsl:choose>
+                            <xsl:when test="dc:date">
+                                <subfield code="b"><xsl:value-of select="dc:publisher[1]"/>, </subfield>
+                                <xsl:if test="translate(dc:date[1]/., '.,:;','')!=''">
+                                    <subfield code="c"><xsl:value-of select="dc:date[1]" />.</subfield>
+                                </xsl:if>
+                            </xsl:when>
+                            <xsl:otherwise>
+                                <subfield code="b"><xsl:value-of select="dc:publisher[1]"/>.</subfield>
+                            </xsl:otherwise>
+                        </xsl:choose>
+                        </datafield>
+                    </xsl:if>
+                </xsl:when>
+                <xsl:otherwise>
+                    <xsl:if test="translate(dc:date[1],'.,:;','')!=''">
+                        <datafield tag="260" ind1=" " ind2=" ">
+                            <subfield code="c"><xsl:value-of select="dc:date[1]" />.</subfield>
+                        </datafield>
+                    </xsl:if>
+                </xsl:otherwise>
+            </xsl:choose>
+
+            <xsl:for-each select="dc:coverage">
+                <xsl:choose>
+                    <xsl:when test="translate(., '0123456789-.?','')=''">
+                        <!--Likely;this is a date-->
+                        <datafield tag="500" ind1=" " ind2=" ">
+                            <subfield code="a"><xsl:value-of select="."/></subfield>
+                        </datafield>
+                    </xsl:when>
+                    <xsl:otherwise>
+                        <!--likely a geographic subject, we will print this later-->
+                    </xsl:otherwise>
+                </xsl:choose>
+            </xsl:for-each>
+
+            <xsl:for-each select="dc:identifier">
+                <xsl:if test="position()!=last()">
+                    <datafield tag="500" ind1=" " ind2=" ">
+                        <subfield code="a"><xsl:value-of select="." /></subfield>
+                    </datafield>
+                </xsl:if>
+            </xsl:for-each>
+
+            <xsl:for-each select="dc:description">
+                <datafield tag="520" ind1=" " ind2=" ">
+                    <subfield code="a">
+                        <xsl:value-of select="normalize-space(.)"/>
+                    </subfield>
+                </datafield>
+            </xsl:for-each>
+
+
+            <xsl:for-each select="dc:rights">
+                <datafield tag="540" ind1=" " ind2=" ">
+                    <subfield code="a">
+                        <xsl:value-of select="."/>
+                    </subfield>
+                </datafield>
+            </xsl:for-each>
+
+
+            <xsl:for-each select="dc:language">
+                <datafield tag="546" ind1=" " ind2=" ">
+                    <subfield code="a">
+                        <xsl:value-of select="."/>
+                    </subfield>
+                </datafield>
+            </xsl:for-each>
+
+
+
+            <xsl:for-each select="dc:subject">
+                <datafield tag="690" ind1=" " ind2=" ">
+                    <subfield code="a">
+                        <xsl:value-of select="."/>
+                    </subfield>
+                </datafield>
+            </xsl:for-each>
+
+            <xsl:for-each select="dc:coverage">
+                <xsl:choose>
+                    <xsl:when test="translate(., '0123456789-.?','')=''">
+                        <!--Likely; this is a date-->
+                    </xsl:when>
+                    <xsl:otherwise>
+                        <!--likely a geographic subject-->
+                        <datafield tag="691" ind1=" " ind2=" ">
+                            <subfield code="a"><xsl:value-of select="." /></subfield>
+                        </datafield>
+                    </xsl:otherwise>
+                </xsl:choose>
+            </xsl:for-each>
+
+
+            <xsl:for-each select="dc:type">
+                <datafield tag="655" ind1="7" ind2=" ">
+                    <subfield code="a">
+                        <xsl:value-of select="."/>
+                    </subfield>
+                    <subfield code="2">local</subfield>
+                </datafield>
+            </xsl:for-each>
+
+
+
+            <xsl:for-each select="dc:contributer">
+                    <xsl:call-template name="persname_template">
+                        <xsl:with-param name="string" select="." />
+                        <xsl:with-param name="field" select="'100'" />
+                        <xsl:with-param name="ind1" select = "'1'" />
+                        <xsl:with-param name="ind2" select = "'0'" />
+                        <xsl:with-param name="type" select="'contributor'" />
+                    </xsl:call-template>
+            </xsl:for-each>
+
+            <xsl:for-each select="dc:source">
+                <datafield tag="786" ind1="0" ind2=" ">
+                    <subfield code="n">
+                        <xsl:value-of select="."/>
+                    </subfield>
+                </datafield>
+            </xsl:for-each>
+
+            <xsl:for-each select="dc:relation">
+                <datafield tag="787" ind1="0" ind2=" ">
+                    <subfield code="n">
+                        <xsl:value-of select="."/>
+                    </subfield>
+                </datafield>
+            </xsl:for-each>
+
+            <xsl:if test="dc:identifier">
+                <datafield tag="856" ind1="4" ind2="1">
+                    <subfield code="u"><xsl:value-of select="dc:identifier[last()]" /></subfield>
+                    <subfield code="z">Connect to this object online.</subfield>
+                </datafield>
+            </xsl:if>
+        </record>
+
+    </xsl:template>
+<xsl:template name="persname_template">
+      <xsl:param name="string" />
+      <xsl:param name="field" />
+      <xsl:param name="ind1" />
+      <xsl:param name="ind2" />
+      <xsl:param name="type" />
+      <datafield>
+         <xsl:attribute name="tag">
+            <xsl:value-of select="$field" />
+         </xsl:attribute>
+         <xsl:attribute name="ind1">
+            <xsl:value-of select="$ind1" />
+         </xsl:attribute>
+         <xsl:attribute name="ind2">
+            <xsl:value-of select="$ind2" />
+         </xsl:attribute>
+
+         <!-- Sample input: Brightman, Samuel C. (Samuel Charles), 1911-1992 -->
+         <!-- Sample output: $aBrightman, Samuel C. $q(Samuel Charles), $d1911-. -->
+         <!-- will handle names with dashes e.g. Bourke-White, Margaret -->
+
+         <!-- CAPTURE PRIMARY NAME BY LOOKING FOR A PAREN OR A DASH OR NEITHER -->
+         <xsl:choose>
+            <!-- IF A PAREN, STOP AT AN OPENING PAREN -->
+            <xsl:when test="contains($string, '(')!=0">
+               <subfield code="a">
+                  <xsl:value-of select="substring-before($string, '(')" />
+               </subfield>
+            </xsl:when>
+            <!-- IF A DASH, CHECK IF IT'S A DATE OR PART OF THE NAME -->
+            <xsl:when test="contains($string, '-')!=0">
+               <xsl:variable name="name_1" select="substring-before($string, '-')" />
+               <xsl:choose>
+                  <!-- IF IT'S A DATE REMOVE IT -->
+                  <xsl:when test="translate(substring($name_1, (string-length($name_1)), 1), '0123456789', '9999999999') = '9'">
+                     <xsl:variable name="name" select="substring($name_1, 1, (string-length($name_1)-6))" />
+                     <subfield code="a">
+                        <xsl:value-of select="$name" />
+                     </subfield>
+                  </xsl:when>
+                  <!-- IF IT'S NOT A DATE, CHECK WHETHER THERE IS A DATE LATER -->
+                  <xsl:otherwise>
+                     <xsl:variable name="remainder" select="substring-after($string, '-')" />
+                     <xsl:choose>
+                        <!-- IF THERE'S A DASH, ASSUME IT'S A DATE AND REMOVE IT -->
+                        <xsl:when test="contains($remainder, '-')!=0">
+                           <xsl:variable name="tmp" select="substring-before($remainder, '-')" />
+                           <xsl:variable name="name_2" select="substring($tmp, 1, (string-length($tmp)-6))" />
+                           <subfield code="a">
+                              <xsl:value-of select="$name_1" />-<xsl:value-of select="$name_2" />
+                           </subfield>
+                        </xsl:when>
+                        <!-- IF THERE'S NO DASH IN THE REMAINDER, OUTPUT IT -->
+                        <xsl:otherwise>
+                           <subfield code="a">
+                              <xsl:value-of select="$string" />
+                           </subfield>
+                        </xsl:otherwise>
+                     </xsl:choose>
+                  </xsl:otherwise>
+               </xsl:choose>
+            </xsl:when>
+            <!-- NO DASHES, NO PARENS, JUST OUTPUT THE NAME -->
+            <xsl:otherwise>
+               <subfield code="a">
+                  <xsl:value-of select="$string" />
+               </subfield>
+            </xsl:otherwise>
+         </xsl:choose>
+
+         <!-- CAPTURE SECONDARY NAME IN PARENS FOR SUBFIELD Q -->
+         <xsl:if test="contains($string, '(')!=0">
+            <xsl:variable name="subq_tmp" select="substring-after($string, '(')" />
+            <xsl:variable name="subq" select="substring-before($subq_tmp, ')')" />
+            <subfield code="q">
+               (<xsl:value-of select="$subq" />)
+            </subfield>
+         </xsl:if>
+
+         <!-- CAPTURE DATE FOR SUBFIELD D, ASSUME DATE IS LAST ITEM IN FIELD -->
+         <!-- Note: does not work if name has a dash in it -->
+         <xsl:if test="contains($string, '-')!=0">
+            <xsl:variable name="date_tmp" select="substring-before($string, '-')" />
+            <xsl:variable name="remainder" select="substring-after($string, '-')" />
+            <xsl:choose>
+               <!-- CHECK SECOND HALF FOR ANOTHER DASH; IF PRESENT, ASSUME THAT IS DATE -->
+               <xsl:when test="contains($remainder, '-')!=0">
+                  <xsl:variable name="tmp" select="substring-before($remainder, '-')" />
+                  <xsl:variable name="date_1" select="substring($remainder, (string-length($tmp)-3))" />
+                  <!-- CHECK WHETHER IT HAS A NUMBER BEFORE IT AND IF SO, OUTPUT IT AS DATE -->
+                  <xsl:if test="translate(substring($date_1, 1, 1), '0123456789', '9999999999') = '9'">
+                     <subfield code="d">
+                        <xsl:value-of select="$date_1" />.
+                     </subfield>
+                  </xsl:if>
+               </xsl:when>
+               <!-- OTHERWISE THIS IS THE ONLY DASH SO TAKE IT -->
+               <xsl:otherwise>
+                  <xsl:variable name="date_2" select="substring($string, (string-length($date_tmp)-3))" />
+                  <!-- CHECK WHETHER IT HAS A NUMBER BEFORE IT AND IF SO, OUTPUT IT AS DATE -->
+                  <xsl:if test="translate(substring($date_2, 1, 1), '0123456789', '9999999999') = '9'">
+                     <subfield code="d">
+                        <xsl:value-of select="$date_2" />.
+                     </subfield>
+                  </xsl:if>
+               </xsl:otherwise>
+            </xsl:choose>
+         </xsl:if>
+         <subfield code="e"><xsl:value-of select="$type" /></subfield>
+      </datafield>
+   </xsl:template>
+
+</xsl:stylesheet>
diff --git a/koha-tmpl/intranet-tmpl/prog/en/xslt/OAIDCtoUNIMARCXML.xsl b/koha-tmpl/intranet-tmpl/prog/en/xslt/OAIDCtoUNIMARCXML.xsl
new file mode 100644
index 0000000000..2abf4f7d53
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/en/xslt/OAIDCtoUNIMARCXML.xsl
@@ -0,0 +1,341 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xsl:stylesheet version="1.0"
+    xmlns:dc="http://purl.org/dc/elements/1.1/"
+    xmlns:dcterms="http://purl.org/dc/terms/1.1"
+    xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/
+        http://www.openarchives.org/OAI/2.0/oai_dc.xsd"
+    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+    xmlns="http://www.loc.gov/MARC21/slim"  exclude-result-prefixes="dc dcterms oai_dc">
+
+    <xsl:import href="MARC21slimUtils.xsl"/>
+    <xsl:output method="xml" encoding="UTF-8" indent="yes"/>
+
+
+    <xsl:template match="/">
+        <collection xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.loc.gov/MARC21/slim http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd" >
+            <xsl:apply-templates />
+        </collection>
+    </xsl:template>
+
+    <xsl:template name="OAI-PMH">
+             <xsl:for-each select = "ListRecords/record/metadata/oai_dc:dc">
+                <xsl:apply-templates  />
+             </xsl:for-each>
+             <xsl:for-each select = "GetRecord/record/metadata/oai_dc:dc">
+                <xsl:apply-templates  />
+             </xsl:for-each>
+    </xsl:template>
+
+    <xsl:template match="text()" />
+    <xsl:template match="oai_dc:dc">
+        <record>
+
+            <xsl:for-each select="dc:creator">
+                <xsl:call-template name="persname_template">
+                    <xsl:with-param name="string" select="." />
+                    <xsl:with-param name="field" select="'700'" />
+                    <xsl:with-param name="ind1" select = "'1'" />
+                    <xsl:with-param name="ind2" select = "'0'" />
+                    <xsl:with-param name="type" select="'author'" />
+                </xsl:call-template>
+            </xsl:for-each>
+
+
+            <xsl:for-each select="dc:title[1]">
+                <datafield tag="200" ind1="0" ind2=" ">
+                    <subfield code="a">
+                        <xsl:value-of select="."/>
+                    </subfield>
+                </datafield>
+            </xsl:for-each>
+
+            <xsl:for-each select="dc:title[position()>1]">
+                <xsl:if test=".!=''">
+                    <datafield tag="532" ind1="3" ind2="0">
+                        <subfield code="a">
+                            <xsl:value-of select="."/>
+                        </subfield>
+                    </datafield>
+                </xsl:if>
+            </xsl:for-each>
+
+            <xsl:choose>
+                <xsl:when test="dc:publisher">
+                    <xsl:if test="translate(dc:publisher/.,'.,:;','')!=''">
+                        <datafield tag="214" ind1=" " ind2=" ">
+                        <xsl:choose>
+                            <xsl:when test="dc:date">
+                                <subfield code="c"><xsl:value-of select="dc:publisher[1]"/>, </subfield>
+                                <xsl:if test="translate(dc:date[1]/., '.,:;','')!=''">
+                                    <subfield code="d"><xsl:value-of select="dc:date[1]" />.</subfield>
+                                </xsl:if>
+                            </xsl:when>
+                            <xsl:otherwise>
+                                <subfield code="c"><xsl:value-of select="dc:publisher[1]"/>.</subfield>
+                            </xsl:otherwise>
+                        </xsl:choose>
+                        </datafield>
+                    </xsl:if>
+                </xsl:when>
+                <xsl:otherwise>
+                    <xsl:if test="translate(dc:date[1],'.,:;','')!=''">
+                        <datafield tag="214" ind1=" " ind2=" ">
+                            <subfield code="d"><xsl:value-of select="dc:date[1]" />.</subfield>
+                        </datafield>
+                    </xsl:if>
+                </xsl:otherwise>
+            </xsl:choose>
+
+
+            <xsl:for-each select="dc:contributor">
+                <datafield tag="701" ind1=" " ind2=" ">
+                    <subfield code="a">
+                        <xsl:value-of select="."/>
+                    </subfield>
+                </datafield>
+            </xsl:for-each>
+
+
+            <xsl:for-each select="dc:coverage">
+                <xsl:choose>
+                    <xsl:when test="translate(., '0123456789-.?','')=''">
+                        <!--Likely;this is a date-->
+                        <datafield tag="215" ind1=" " ind2=" ">
+                            <subfield code="a"><xsl:value-of select="."/></subfield>
+                        </datafield>
+                    </xsl:when>
+                    <xsl:otherwise>
+                        <!--likely a geographic subject, we will print this later-->
+                    </xsl:otherwise>
+                </xsl:choose>
+            </xsl:for-each>
+
+
+            <xsl:for-each select="dc:description">
+                <datafield tag="330" ind1=" " ind2=" ">
+                    <subfield code="a">
+                        <xsl:value-of select="normalize-space(.)"/>
+                    </subfield>
+                </datafield>
+            </xsl:for-each>
+
+
+            <!-- no match -->
+            <xsl:for-each select="dc:rights">
+                <datafield tag="371" ind1=" " ind2=" ">
+                    <subfield code="a">
+                        <xsl:value-of select="."/>
+                    </subfield>
+                </datafield>
+            </xsl:for-each>
+
+
+            <xsl:for-each select="dc:language">
+                <datafield tag="101" ind1=" " ind2=" ">
+                    <subfield code="a">
+                        <xsl:value-of select="."/>
+                    </subfield>
+                </datafield>
+            </xsl:for-each>
+
+
+            <xsl:for-each select="dc:subject">
+                <datafield tag="610" ind1=" " ind2=" ">
+                    <subfield code="a">
+                        <xsl:value-of select="."/>
+                    </subfield>
+                </datafield>
+            </xsl:for-each>
+
+
+            <!-- Resource type will most likely need a specific instance-based mapping -->
+            <!--
+            <xsl:for-each select="dc:type">
+                <datafield tag="099" ind1=" " ind2=" ">
+                    <subfield code="t">
+                        <xsl:value-of select="."/>
+                    </subfield>
+                </datafield>
+            </xsl:for-each>
+            -->
+
+
+            <!-- no match -->
+            <xsl:for-each select="dc:coverage">
+                <xsl:choose>
+                    <xsl:when test="translate(., '0123456789-.?','')=''">
+                        <!--Likely; this is a date-->
+                    </xsl:when>
+                    <xsl:otherwise>
+                        <!--likely a geographic subject-->
+                        <datafield tag="691" ind1=" " ind2=" ">
+                            <subfield code="a"><xsl:value-of select="." /></subfield>
+                        </datafield>
+                    </xsl:otherwise>
+                </xsl:choose>
+            </xsl:for-each>
+
+
+            <!-- no match -->
+            <xsl:for-each select="dc:type">
+                <datafield tag="655" ind1="7" ind2=" ">
+                    <subfield code="a">
+                        <xsl:value-of select="."/>
+                    </subfield>
+                    <subfield code="2">local</subfield>
+                </datafield>
+            </xsl:for-each>
+
+
+            <xsl:for-each select="dc:contributer">
+                    <xsl:call-template name="persname_template">
+                        <xsl:with-param name="string" select="." />
+                        <xsl:with-param name="field" select="'702'" />
+                        <xsl:with-param name="ind1" select = "'1'" />
+                        <xsl:with-param name="ind2" select = "'0'" />
+                        <xsl:with-param name="type" select="'contributor'" />
+                    </xsl:call-template>
+            </xsl:for-each>
+
+            <!-- no match -->
+            <xsl:for-each select="dc:source">
+                <datafield tag="786" ind1="0" ind2=" ">
+                    <subfield code="n">
+                        <xsl:value-of select="."/>
+                    </subfield>
+                </datafield>
+            </xsl:for-each>
+
+            <!-- TODO: 455 456 470 488-->
+            <xsl:for-each select="dc:relation">
+                <datafield tag="488" ind1="0" ind2=" ">
+                    <subfield code="e">
+                        <xsl:value-of select="."/>
+                    </subfield>
+                </datafield>
+            </xsl:for-each>
+
+            <xsl:if test="dc:identifier">
+                <datafield tag="856" ind1="4" ind2="1">
+                    <subfield code="u"><xsl:value-of select="dc:identifier[last()]" /></subfield>
+                    <subfield code="z">Connect to this object online.</subfield>
+                </datafield>
+            </xsl:if>
+        </record>
+
+    </xsl:template>
+<xsl:template name="persname_template">
+      <xsl:param name="string" />
+      <xsl:param name="field" />
+      <xsl:param name="ind1" />
+      <xsl:param name="ind2" />
+      <xsl:param name="type" />
+      <datafield>
+         <xsl:attribute name="tag">
+            <xsl:value-of select="$field" />
+         </xsl:attribute>
+         <xsl:attribute name="ind1">
+            <xsl:value-of select="$ind1" />
+         </xsl:attribute>
+         <xsl:attribute name="ind2">
+            <xsl:value-of select="$ind2" />
+         </xsl:attribute>
+
+         <!-- Sample input: Brightman, Samuel C. (Samuel Charles), 1911-1992 -->
+         <!-- Sample output: $aBrightman, Samuel C. $q(Samuel Charles), $d1911-. -->
+         <!-- will handle names with dashes e.g. Bourke-White, Margaret -->
+
+         <!-- CAPTURE PRIMARY NAME BY LOOKING FOR A PAREN OR A DASH OR NEITHER -->
+         <xsl:choose>
+            <!-- IF A PAREN, STOP AT AN OPENING PAREN -->
+            <xsl:when test="contains($string, '(')!=0">
+               <subfield code="a">
+                  <xsl:value-of select="substring-before($string, '(')" />
+               </subfield>
+            </xsl:when>
+            <!-- IF A DASH, CHECK IF IT'S A DATE OR PART OF THE NAME -->
+            <xsl:when test="contains($string, '-')!=0">
+               <xsl:variable name="name_1" select="substring-before($string, '-')" />
+               <xsl:choose>
+                  <!-- IF IT'S A DATE REMOVE IT -->
+                  <xsl:when test="translate(substring($name_1, (string-length($name_1)), 1), '0123456789', '9999999999') = '9'">
+                     <xsl:variable name="name" select="substring($name_1, 1, (string-length($name_1)-6))" />
+                     <subfield code="a">
+                        <xsl:value-of select="$name" />
+                     </subfield>
+                  </xsl:when>
+                  <!-- IF IT'S NOT A DATE, CHECK WHETHER THERE IS A DATE LATER -->
+                  <xsl:otherwise>
+                     <xsl:variable name="remainder" select="substring-after($string, '-')" />
+                     <xsl:choose>
+                        <!-- IF THERE'S A DASH, ASSUME IT'S A DATE AND REMOVE IT -->
+                        <xsl:when test="contains($remainder, '-')!=0">
+                           <xsl:variable name="tmp" select="substring-before($remainder, '-')" />
+                           <xsl:variable name="name_2" select="substring($tmp, 1, (string-length($tmp)-6))" />
+                           <subfield code="a">
+                              <xsl:value-of select="$name_1" />-<xsl:value-of select="$name_2" />
+                           </subfield>
+                        </xsl:when>
+                        <!-- IF THERE'S NO DASH IN THE REMAINDER, OUTPUT IT -->
+                        <xsl:otherwise>
+                           <subfield code="a">
+                              <xsl:value-of select="$string" />
+                           </subfield>
+                        </xsl:otherwise>
+                     </xsl:choose>
+                  </xsl:otherwise>
+               </xsl:choose>
+            </xsl:when>
+            <!-- NO DASHES, NO PARENS, JUST OUTPUT THE NAME -->
+            <xsl:otherwise>
+               <subfield code="a">
+                  <xsl:value-of select="$string" />
+               </subfield>
+            </xsl:otherwise>
+         </xsl:choose>
+
+         <!-- CAPTURE SECONDARY NAME IN PARENS FOR SUBFIELD Q -->
+         <xsl:if test="contains($string, '(')!=0">
+            <xsl:variable name="subq_tmp" select="substring-after($string, '(')" />
+            <xsl:variable name="subq" select="substring-before($subq_tmp, ')')" />
+            <subfield code="q">
+               (<xsl:value-of select="$subq" />)
+            </subfield>
+         </xsl:if>
+
+         <!-- CAPTURE DATE FOR SUBFIELD D, ASSUME DATE IS LAST ITEM IN FIELD -->
+         <!-- Note: does not work if name has a dash in it -->
+         <xsl:if test="contains($string, '-')!=0">
+            <xsl:variable name="date_tmp" select="substring-before($string, '-')" />
+            <xsl:variable name="remainder" select="substring-after($string, '-')" />
+            <xsl:choose>
+               <!-- CHECK SECOND HALF FOR ANOTHER DASH; IF PRESENT, ASSUME THAT IS DATE -->
+               <xsl:when test="contains($remainder, '-')!=0">
+                  <xsl:variable name="tmp" select="substring-before($remainder, '-')" />
+                  <xsl:variable name="date_1" select="substring($remainder, (string-length($tmp)-3))" />
+                  <!-- CHECK WHETHER IT HAS A NUMBER BEFORE IT AND IF SO, OUTPUT IT AS DATE -->
+                  <xsl:if test="translate(substring($date_1, 1, 1), '0123456789', '9999999999') = '9'">
+                     <subfield code="d">
+                        <xsl:value-of select="$date_1" />.
+                     </subfield>
+                  </xsl:if>
+               </xsl:when>
+               <!-- OTHERWISE THIS IS THE ONLY DASH SO TAKE IT -->
+               <xsl:otherwise>
+                  <xsl:variable name="date_2" select="substring($string, (string-length($date_tmp)-3))" />
+                  <!-- CHECK WHETHER IT HAS A NUMBER BEFORE IT AND IF SO, OUTPUT IT AS DATE -->
+                  <xsl:if test="translate(substring($date_2, 1, 1), '0123456789', '9999999999') = '9'">
+                     <subfield code="d">
+                        <xsl:value-of select="$date_2" />.
+                     </subfield>
+                  </xsl:if>
+               </xsl:otherwise>
+            </xsl:choose>
+         </xsl:if>
+         <subfield code="e"><xsl:value-of select="$type" /></subfield>
+      </datafield>
+   </xsl:template>
+
+</xsl:stylesheet>
diff --git a/koha-tmpl/intranet-tmpl/prog/en/xslt/OAIMarcxml2KohaMarcxml.xsl b/koha-tmpl/intranet-tmpl/prog/en/xslt/OAIMarcxml2KohaMarcxml.xsl
new file mode 100644
index 0000000000..67669afdb0
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/en/xslt/OAIMarcxml2KohaMarcxml.xsl
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:marc="http://www.loc.gov/MARC21/slim">
+  <xsl:output method="xml" version="1.0" encoding="UTF-8" indent="yes"/>
+
+ <!-- keep comments -->
+  <xsl:template match="*[name()='metadata']">
+    <xsl:apply-templates />
+  </xsl:template>
+
+  <xsl:template match="*">
+    <!-- remove element prefix -->
+    <xsl:element name="{local-name()}">
+      <!-- process attributes -->
+      <xsl:for-each select="@*">
+        <!-- remove attribute prefix -->
+        <xsl:attribute name="{local-name()}">
+          <xsl:value-of select="."/>
+        </xsl:attribute>
+      </xsl:for-each>
+      <xsl:apply-templates/>
+    </xsl:element>
+  </xsl:template>
+</xsl:stylesheet>
diff --git a/misc/cronjobs/harvest_oai.pl b/misc/cronjobs/harvest_oai.pl
new file mode 100755
index 0000000000..2cc212652d
--- /dev/null
+++ b/misc/cronjobs/harvest_oai.pl
@@ -0,0 +1,110 @@
+#!/usr/bin/perl
+#
+# Copyright 2023 BibLibre
+#
+# 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 warnings;
+use strict;
+use utf8;
+
+use Getopt::Long qw( GetOptions );
+use Koha::Script -cron;
+use Koha::OAI::Client::Harvester;
+use Koha::OaiServers;
+use C4::Log qw( cronlogaction );
+use Try::Tiny qw( catch try );
+
+my $command_line_options = join( " ", @ARGV );
+
+my ( $help, $verbose, $id, $days, $list, $force );
+
+GetOptions(
+    'h|help'         => \$help,
+    'v|verbose'      => \$verbose,
+    'r|repository:i' => \$id,
+    'd|days:i'       => \$days,
+    'l|list'         => \$list,
+    'f|force'        => \$force,
+);
+my $usage = << 'ENDUSAGE';
+
+This script starts an OAI Harvest
+
+This script has the following parameters :
+    -h --help: this message
+    -v --verbose
+    -r --repository: id of the OAI repository
+    -d --days: number of days to harvest from (optional)
+    -l --list: list the OAI repositories
+    -f --force: force harvesting (ignore records datestamps)
+
+ENDUSAGE
+
+if ($help) {
+    print $usage;
+    exit;
+}
+
+if ($list) {
+    my $servers = Koha::OaiServers->search( {}, { order_by => { -asc => 'id' } } )->unblessed;
+    print "The following repositories are available: \n\n";
+    foreach my $server (@$servers) {
+        print $server->{'id'} . ": "
+            . $server->{'servername'}
+            . ", endpoint: "
+            . $server->{'endpoint'}
+            . ", set: "
+            . $server->{'oai_set'}
+            . ", recordtype: "
+            . $server->{'recordtype'} . "\n";
+    }
+    print "\n";
+    exit;
+}
+
+if ( !$id ) {
+    print "The repository parameter is mandatory.\n";
+    print $usage . "\n";
+}
+
+my $server = Koha::OaiServers->find($id);
+
+unless ($server) {
+    print "OAI Server $id unknown\n";
+    exit;
+}
+
+my $script_handler = Koha::Script->new( { script => $0 } );
+
+try {
+    $script_handler->lock_exec;
+} catch {
+    my $message = "Skipping execution of $0";
+    print "$message\n" if $verbose;
+    cronlogaction( { info => $message } );
+    exit;
+};
+
+cronlogaction( { action => 'Start', info => "Starting OAI Harvest" } );
+cronlogaction( { info   => "Command line: $command_line_options" } );
+
+my $harvester =
+    Koha::OAI::Client::Harvester->new( { server => $server, verbose => $verbose, days => $days, force => $force } );
+$harvester->init();
+
+cronlogaction( { action => 'End', info => "Ending OAI Harvest" } );
+exit(0);
diff --git a/t/db_dependent/Koha/OAIHarvester.t b/t/db_dependent/Koha/OAIHarvester.t
new file mode 100755
index 0000000000..e42b9a08f4
--- /dev/null
+++ b/t/db_dependent/Koha/OAIHarvester.t
@@ -0,0 +1,78 @@
+#!/usr/bin/perl
+
+# Copyright 2024 BibLibre
+#
+# 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 Test::More tests => 5;
+use Test::Exception;
+
+use t::lib::TestBuilder;
+use HTTP::OAI;
+use HTTP::OAI::Metadata::OAI_DC;
+use HTTP::OAI::Record;
+use HTTP::OAI::Encapsulation;
+use Koha::Database;
+use Koha::OaiServer;
+use Koha::OaiServers;
+use Koha::OAI::Client::Harvester;
+
+my $schema  = Koha::Database->new->schema;
+my $builder = t::lib::TestBuilder->new;
+
+$schema->storage->txn_begin;
+
+my $new_oai_1 = Koha::OaiServer->new(
+    {
+        endpoint   => 'my_host1.org',
+        oai_set    => 'set1',
+        servername => 'my_test_1',
+        dataformat => 'oai_dc',
+        recordtype => 'biblio',
+        add_xslt   => '',
+    }
+)->store;
+
+my $harvester = Koha::OAI::Client::Harvester->new( { server => $new_oai_1, verbose => 1, days => 1, force => 1 } );
+
+my $record =
+    '<metadata xmlns="http://www.openarchives.org/OAI/2.0/"><oai_dc:dc xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"><dc:title>Pièces diverses </dc:title><dc:identifier>ARCH/0320 [cote]</dc:identifier><dc:relation>FR-920509801 [RCR établissement]</dc:relation><dc:relation>Central obrera boliviana [Fonds ou collection]</dc:relation><dc:format>1 carton</dc:format><dc:date>1971/2000</dc:date><dc:type>Archives et manuscrits</dc:type></oai_dc:dc></metadata>';
+
+my $r = HTTP::OAI::Record->new();
+$r->header->datestamp('2017-05-10T09:18:13Z');
+$r->metadata( HTTP::OAI::Metadata->new( dom => $record ) );
+
+my $status = $harvester->processRecord($r);
+is( $status, 'skipped', 'Record with no identifier is skipped' );
+
+$r->header->identifier('oai:myarchive.org:oid-233');
+
+$status = $harvester->processRecord($r);
+is( $status, 'added', 'Record is added' );
+
+$status = $harvester->processRecord($r);
+is( $status, 'updated', 'When force is used, record is updated' );
+
+$harvester = Koha::OAI::Client::Harvester->new( { server => $new_oai_1, verbose => 1, days => 1, force => 0 } );
+$status    = $harvester->processRecord($r);
+is( $status, 'skipped', 'When force is not used, record is skipped (already up to date)' );
+
+$r->header->datestamp('2018-05-10T09:18:13Z');
+$status = $harvester->processRecord($r);
+is( $status, 'updated', 'When force is not used, record is updated if newer' );
+
+$schema->storage->txn_rollback;
diff --git a/t/db_dependent/Koha/OAIServers.t b/t/db_dependent/Koha/OAIServers.t
new file mode 100755
index 0000000000..9f135b77db
--- /dev/null
+++ b/t/db_dependent/Koha/OAIServers.t
@@ -0,0 +1,95 @@
+#!/usr/bin/perl
+
+# Copyright 2015 Koha Development team
+#
+# 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 Test::More tests => 2;
+use Test::Exception;
+
+use t::lib::TestBuilder;
+use Koha::Database;
+use Koha::OaiServer;
+use Koha::OaiServers;
+
+my $schema  = Koha::Database->new->schema;
+my $builder = t::lib::TestBuilder->new;
+
+subtest 'new, find and delete tests' => sub {
+    plan tests => 4;
+    $schema->storage->txn_begin;
+    my $nb_of_oais = Koha::OaiServers->search->count;
+    my $new_oai_1  = Koha::OaiServer->new(
+        {
+            endpoint   => 'my_host1.org',
+            oai_set    => 'set1',
+            servername => 'my_test_1',
+            dataformat => 'oai_dc',
+            recordtype => 'biblio',
+        }
+    )->store;
+    my $new_oai_2 = Koha::OaiServer->new(
+        {
+            endpoint   => 'my_host2.org',
+            oai_set    => 'set2',
+            servername => 'my_test_2',
+            dataformat => 'marcxml',
+            recordtype => 'authority',
+        }
+    )->store;
+
+    like( $new_oai_1->id, qr|^\d+$|, 'Adding a new oai server should have set the id' );
+    is( Koha::OaiServers->search->count, $nb_of_oais + 2, 'The 2 oai servers should have been added' );
+
+    my $retrieved_oai_1 = Koha::OaiServers->find( $new_oai_1->id );
+    is(
+        $retrieved_oai_1->servername, $new_oai_1->servername,
+        'Find a oai server by id should return the correct oai server'
+    );
+
+    $retrieved_oai_1->delete;
+    is( Koha::OaiServers->search->count, $nb_of_oais + 1, 'Delete should have deleted the oai server' );
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'Check NOT NULL without default values' => sub {
+    plan tests => 5;
+    $schema->storage->txn_begin;
+    local $SIG{__WARN__} = sub { };    # TODO Needed it for suppressing DBIx warns
+
+    my $server = Koha::OaiServer->new(
+        {
+            oai_set    => 'set3',
+            dataformat => 'marcxml',
+            recordtype => 'biblio',
+        }
+    );
+
+    throws_ok { $server->store } 'DBIx::Class::Exception', 'Exception on empty endpoint';
+    like( $@->{msg}, qr/'endpoint' doesn't have a default value/, 'Verified that DBIx blamed endpoint' );
+
+    $server->endpoint('endpoint_added');
+    throws_ok { $server->store } 'DBIx::Class::Exception', 'Exception on empty servername';
+    like( $@->{msg}, qr/'servername' doesn't have a default value/, 'Verified that DBIx blamed servername' );
+
+    $server->servername('servername_added');
+
+    lives_ok { $server->store } 'No exceptions anymore';
+
+    $schema->storage->txn_rollback;
+};
-- 
2.39.2