This module is an opensrf service that exposes bibliographic and authority records through the OAI2 protocol.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
Signed-off-by: Lucien van Wouw, IISG, Amsterdam.
Signed-off-by: Remington Steed <rjs7@calvin.edu>
Signed-off-by: Jane Sandberg <sandbej@linnbenton.edu>
Signed-off-by: Galen Charlton <gmc@equinoxOLI.org>
Signed-off-by: Mike Rylander <mrylander@gmail.com>
*.pyc
*.slo
*.class
+.idea/
aclocal.m4
autom4te.cache/
Require all granted
</Location>
+# Uncomment this section to enable the OAI2 provider service.
+#<Location /opac/extras/oai>
+# SetHandler perl-script
+# PerlHandler OpenILS::WWW::OAI
+# Options +ExecCGI
+# PerlSendHeader On
+# Require all granted
+#</Location>
+
# ----------------------------------------------------------------------------------
# Replace broken cover images with a transparent GIF by default
# ----------------------------------------------------------------------------------
<IDL xmlns="http://opensrf.org/spec/IDL/base/v1" xmlns:idl="http://opensrf.org/spec/IDL/base/v1" xmlns:oils_persist="http://open-ils.org/spec/opensrf/IDL/persistence/v1" xmlns:oils_obj="http://open-ils.org/spec/opensrf/IDL/objects/v1" xmlns:reporter="http://open-ils.org/spec/opensrf/IDL/reporter/v1" xmlns:sr="http://open-ils.org/spec/opensrf/IDL/simple-reporter/v1" xmlns:permacrud="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
<!-- Virtual classes -->
+
+ <class id="oai_biblio" controller="open-ils.cstore" oils_obj:fieldmapper="oai::biblio"
+ oils_persist:readonly="true" reporter:core="false" reporter:label="OAI2 record list"
+ oils_persist:tablename="oai.biblio">
+ <fields>
+ <field reporter:label="TCN Value\OAI identifier postfix" name="tcn" reporter:datatype="number"/>
+ <field reporter:label="Last edit date\OAI datestamp" name="datestamp" reporter:datatype="timestamp"/>
+ <field reporter:label="Is Deleted?" name="deleted" reporter:datatype="bool"/>
+ <field reporter:label="Setspec" name="set_spec" oils_persist:virtual="true"/>
+ </fields>
+ </class>
+ <class id="oai_authority" controller="open-ils.cstore" oils_obj:fieldmapper="oai::authority"
+ oils_persist:readonly="true" reporter:core="false" reporter:label="OAI2 record list"
+ oils_persist:tablename="oai.authority">
+ <fields>
+ <field reporter:label="TCN Value\OAI identifier postfix" name="tcn" reporter:datatype="number"/>
+ <field reporter:label="Last edit date\OAI datestamp" name="datestamp" reporter:datatype="timestamp"/>
+ <field reporter:label="Is Deleted?" name="deleted" reporter:datatype="bool"/>
+ <field reporter:label="Setspec" name="set_spec" oils_persist:virtual="true"/>
+ </fields>
+ </class>
+
<class id="mups" controller="open-ils.cstore" oils_obj:fieldmapper="money::user_payment_summary" oils_persist:virtual="true" reporter:label="User Payment Summary">
<fields>
<field name="usr" oils_persist:virtual="true" />
</app_settings>
</open-ils.cstore>
+ <open-ils.oai>
+ <keepalive>5</keepalive>
+ <stateless>1</stateless>
+ <language>perl</language>
+ <implementation>OpenILS::Application::OAI</implementation>
+ <max_requests>199</max_requests>
+ <unix_config>
+ <unix_sock>open-ils.oai_unix.sock</unix_sock>
+ <unix_pid>open-ils.oai_unix.pid</unix_pid>
+ <max_requests>1000</max_requests>
+ <unix_log>open-ils.oai_unix.log</unix_log>
+ <min_children>1</min_children>
+ <max_children>5</max_children>
+ <min_spare_children>1</min_spare_children>
+ <max_spare_children>2</max_spare_children>
+ </unix_config>
+ <app_settings>
+
+ <!-- Where necessary, override the default settings here in the app_settings element. -->
+
+ <!-- The OAI endpoint. The domain is the name of your proxy or frontend opac website. -->
+ <!-- <base_url>http://mydomain.org/opac/extras/oai</base_url> -->
+
+ <!-- <repository_name>My organization(s)</repository_name> -->
+ <!-- <admin_email>admin@mydomain.org</admin_email> -->
+
+ <!-- The maximum number of records in a ListRecords and ListIdentifiers response. -->
+ <!-- <max_count>50</max_count> -->
+
+ <!-- <granularity>YYYY-MM-DDThh:mm:ss</granularity> -->
+ <!-- <earliest_datestamp>0001-01-01</earliest_datestamp> -->
+ <!-- <deleted_record>yes</deleted_record> -->
+ <!-- <scheme>oai</scheme> -->
+ <!-- <repository_identifier>mydomain.org</repository_identifier> -->
+ <!-- <delimiter>:</delimiter> -->
+ <!-- <sample_identifier>oai:mydomain.org:12345</sample_identifier> -->
+ <!-- <list_sets>false</list_sets> -->
+
+ <!--
+ The metadataformat element contains the schema for the oai_dc and marcxml metadata formats.
+ Each schema needs a reference to an xslt document.
+ You can replace them with your custom xslt stylesheets.
+ Place those in the /<openils sysdir>/var/xsl folder.
+ You can also extend the OAI2 service further with new metadata schema.
+
+ Bibliographic and authority records share the same stylesheet.
+ Should you want to render them differently, use the
+ marc:datafield[@tag='901']/marc:subfield[@code='t']
+ value to identify the record type. -->
+
+ <!--
+ <metadataformat>
+ <oai_dc>
+ <namespace_uri>http://www.openarchives.org/OAI/2.0/oai_dc/</namespace_uri>
+ <schema_location>http://www.openarchives.org/OAI/2.0/oai_dc.xsd</schema_location>
+ <xslt>OAI2_OAIDC.xsl</xslt>
+ </oai_dc>
+ <marcxml>
+ <namespace_uri>http://www.loc.gov/MARC21/slim</namespace_uri>
+ <schema_location>http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd</schema_location>
+ <xslt>OAI2_MARC21slim.xsl</xslt>
+ </marcxml>
+ </metadataformat> -->
+
+ <!--
+ You can add different schema to the metadataformat element thus:
+ <mods>
+ <namespace_uri>http://www.loc.gov/mods/</namespace_uri>
+ <schema_location>http://www.loc.gov/standards/mods/mods.xsd</schema_location>
+ <xslt>my-custom-marc2mods.xsl</xslt>
+ </mods>
+ <my-metadata_prefix>
+ <namespace_uri>my-namespace_uri</namespace_uri>
+ <schema_location>my-schema_location</schema_location>
+ <xslt>my-marc2my-metadata.xsl</xslt>
+ </my-metadata_prefix>
+ -->
+
+ <!-- Change the way the asset copy values are mapped to which subfield codes: -->
+ <!--
+ <copies>
+ <a>location</a>
+ <b>owning_lib</b>
+ <c>callnumber</c>
+ <d>circlib</d>
+ <g>barcode</g>
+ <n>status</n>
+ </copies>
+ -->
+ <!-- Or add static values to the copies element like this:
+ <z>A value that always should for example be in the 852$z</z>
+ -->
+
+ <!-- Accept only 852$[barcode] values that match this regular expression. E.g.
+ <barcode_filter>^[A-Za-z0-9]+</barcode_filter>
+ only renders 852 datafields that contain barcodes values that begin with letters and numbers.
+ <!--
+ <barcode_filter><barcode_filter>
+ -->
+
+ <!-- Accept only 852$[status] values that match this regular expression. E.g.
+ <status_filter>^Available$</status_filter>
+ only renders 852 datafields that contain status code values that exactly match the string 'Available'.
+ <!--
+ <status_filter></status_filter>
+ -->
+
+ </app_settings>
+ </open-ils.oai>
+
<open-ils.pcrud>
<keepalive>6</keepalive>
<migratable>1</migratable>
<appname>open-ils.storage</appname>
<appname>open-ils.justintime</appname>
<appname>open-ils.cstore</appname>
- <appname>open-ils.collections</appname>
+ <appname>open-ils.collections</appname>
+ <appname>open-ils.oai</appname>
<appname>open-ils.qstore</appname>
<appname>open-ils.reporter</appname>
<appname>open-ils.reporter-store</appname>
<service>open-ils.courses</service>
<service>open-ils.curbside</service>
<service>open-ils.fielder</service>
+ <service>openils.oai</service>
<service>open-ils.pcrud</service>
<service>open-ils.permacrud</service>
<service>open-ils.reporter</service>
--- /dev/null
+# OpenILS::WWW::OAI manages OAI2 requests and responses.
+#
+# Copyright (c) 2014-2017 International Institute of Social History
+#
+# This program 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.
+#
+# This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
+#
+#
+# Author: Lucien van Wouw <lwo@iisg.nl>
+
+
+package OpenILS::Application::OAI;
+use strict; use warnings;
+
+use base qw/OpenILS::Application/;
+use OpenSRF::AppSession;
+use OpenSRF::EX qw(:try);
+use MARC::Record;
+use MARC::File::XML ( BinaryEncoding => 'UTF-8' );
+use OpenSRF::Utils::SettingsClient;
+use OpenSRF::Utils::Logger qw($logger);
+use XML::LibXML;
+use XML::LibXSLT;
+
+my (
+ $_parser,
+ $_xslt,
+ %record_xslt,
+ %metarecord_xslt,
+ %holdings_data_cache,
+ %authority_browse_axis_cache,
+ %copies,
+ $barcode_filter,
+ $status_filter
+);
+
+
+sub child_init {
+
+ # set the XML parser
+ $_parser = new XML::LibXML;
+
+ # and the xslt parser
+ $_xslt = new XML::LibXSLT;
+
+ # Load the metadataformats that are configured.
+ my $metadata_format = OpenSRF::Utils::SettingsClient->new->config_value(apps => 'open-ils.oai')->{'app_settings'}->{'metadataformat'};
+ if ( $metadata_format ) {
+ for my $schema ( keys %$metadata_format ) {
+ $logger->info('Loading schema ' . $schema) ;
+ $record_xslt{$schema}{namespace_uri} = $metadata_format->{$schema}->{namespace_uri};
+ $record_xslt{$schema}{schema_location} = $metadata_format->{$schema}->{schema_location};
+ $record_xslt{$schema}{xslt} = $_xslt->parse_stylesheet( $_parser->parse_file(
+ OpenSRF::Utils::SettingsClient->new->config_value( dirs => 'xsl' ) . '/' . $metadata_format->{$schema}->{xslt}
+ ) );
+ }
+ }
+
+ # Fall back on system defaults if oai_dc is not set in the configuration.
+ unless ( exists $record_xslt{oai_dc} ) {
+ $logger->info('Loading default oai_dc schema') ;
+ my $xslt = $_parser->parse_file(
+ OpenSRF::Utils::SettingsClient
+ ->new
+ ->config_value( dirs => 'xsl' ).
+ "/OAI2_OAIDC.xsl"
+ );
+ # and stash a transformer
+ $record_xslt{oai_dc}{xslt} = $_xslt->parse_stylesheet( $xslt );
+ $record_xslt{oai_dc}{namespace_uri} = 'http://www.openarchives.org/OAI/2.0/oai_dc/';
+ $record_xslt{oai_dc}{schema_location} = 'http://www.openarchives.org/OAI/2.0/oai_dc.xsd';
+ }
+
+ # If not defined, use the natural marcxml metadata setting
+ unless ( exists $record_xslt{marcxml}) {
+ $logger->info('Loading default marcxml schema') ;
+ my $xslt = $_parser->parse_file(
+ OpenSRF::Utils::SettingsClient
+ ->new
+ ->config_value( dirs => 'xsl' ).
+ "/OAI2_MARC21slim.xsl"
+ );
+ # and stash a transformer
+ $record_xslt{marcxml}{xslt} = $_xslt->parse_stylesheet( $xslt );
+ $record_xslt{marcxml}{namespace_uri} = 'http://www.loc.gov/MARC21/slim';
+ $record_xslt{marcxml}{docs} = 'http://www.loc.gov/MARC21/slim';
+ $record_xslt{marcxml}{schema_location} = 'http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd';
+ }
+
+ # Load the mapping of 852 holdings.
+ my $copies = OpenSRF::Utils::SettingsClient->new->config_value(apps => 'open-ils.oai')->{'app_settings'}->{'copies'} ;
+ if ( $copies ) {
+ foreach my $subfield_code (keys %$copies) {
+ my $value = $copies->{$subfield_code};
+ $logger->info('Set 852 map ' . $subfield_code . '=' . $value );
+ $copies{$subfield_code} = $value;
+ }
+ } else { # if not defined, fall back on these defaults.
+ %copies = (
+ a => 'location',
+ b => 'owning_lib',
+ c => 'callnumber',
+ d => 'circlib',
+ g => 'barcode',
+ n => 'status'
+ );
+ }
+
+ # Set the barcode filter and status filter
+ $barcode_filter = OpenSRF::Utils::SettingsClient->new->config_value(apps => 'open-ils.oai')->{'app_settings'}->{'barcode_filter'};
+ $status_filter = OpenSRF::Utils::SettingsClient->new->config_value(apps => 'open-ils.oai')->{'app_settings'}->{'status_filter'};
+
+ return 1;
+}
+
+
+sub list_record_formats {
+
+ my @list;
+ for my $type ( keys %record_xslt ) {
+ push @list,
+ { $type =>
+ { namespace_uri => $record_xslt{$type}{namespace_uri},
+ docs => $record_xslt{$type}{docs},
+ schema_location => $record_xslt{$type}{schema_location},
+ }
+ };
+ }
+
+ return \@list;
+}
+
+__PACKAGE__->register_method(
+ method => 'list_record_formats',
+ api_name => 'open-ils.oai.record.formats',
+ api_level => 1,
+ argc => 0,
+ signature =>
+ {
+ desc => 'Returns the list of valid record formats that oai understands.',
+ 'return' =>
+ {
+ desc => 'The format list.',
+ type => 'array'
+ }
+ }
+);
+
+
+sub oai_biblio_retrieve {
+
+ my $self = shift;
+ my $client = shift;
+ my $tcn = shift;
+ my $metadataPrefix = shift;
+
+ # holdings hold an array of call numbers, which hold an array of copies
+ # holdings => [ label: { library, [ copies: { barcode, location, status, circ_lib } ] } ]
+ my %holdings;
+
+ my $_storage = OpenSRF::AppSession->create( 'open-ils.cstore' );
+
+ # Retrieve the bibliographic record and it's copies
+ my $tree = $_storage->request(
+ "open-ils.cstore.direct.biblio.record_entry.retrieve",
+ $tcn,
+ { flesh => 5,
+ flesh_fields => {
+ bre => [qw/marc edit_date call_numbers/],
+ acn => [qw/edit_date copies owning_lib prefix suffix/],
+ acp => [qw/edit_date location status circ_lib parts/],
+ }
+ }
+ )->gather(1);
+
+ # Create a MARC::Record object with the marc.
+ my $marc = MARC::Record->new_from_xml( $tree->marc, 'UTF8', 'XML');
+
+ # Retrieve the MFHD where we can find them.
+ my %serials;
+ if ( substr($marc->leader, 7, 1) eq 's' ) { # serial
+ my $_search = OpenSRF::AppSession->create( 'open-ils.search' );
+ my $_serials = $_search->request('open-ils.search.serial.record.bib.retrieve', $tcn, 1, 0)->gather(1);
+ my $order = 0 ;
+ for my $sre (@$_serials) {
+ if ( $sre->location ) {
+ $order++ ;
+ my @svr = split( ' -- ', $sre->location );
+ my $cn_label = $svr[-1];
+ $serials{$order}{'label'} = $cn_label ;
+ my $display = @{$sre->basic_holdings_add} ? $sre->basic_holdings_add : $sre->basic_holdings;
+ $serials{$order}{'ser'} = join(', ', @{$display});
+ }
+ }
+ }
+
+ my $edit_date = $tree->edit_date ;
+
+ # Prepare a hash of all holdings and serials
+ for my $cn (@{$tree->call_numbers}) {
+
+ next unless ( $cn->deleted eq 'f' || !$cn->deleted );
+ my $_visible = 0;
+ for my $c (@{$cn->copies}) {
+ $_visible = _cp_is_visible($cn, $c);
+ last if ( $_visible );
+ }
+ next unless $_visible;
+
+ my $cn_label = $cn->label;
+ $holdings{$cn_label}{'owning_lib'} = $cn->owning_lib->shortname;
+
+ $edit_date = most_recent_date( $cn->edit_date, $edit_date );
+
+ for my $cp (@{$cn->copies}) {
+
+ next unless _cp_is_visible($cn, $cp);
+ $edit_date = most_recent_date( $cp->edit_date, $edit_date );
+
+ # find the corresponding serial.
+ # There is no way of knowing here if the barcode 852$p is a correct match.
+ my $order = 0 ;
+ my $ser;
+ foreach my $key (sort keys %serials) {
+ my $serial = $serials{$key};
+ if ( $serial->{'label'} eq $cn_label ) {
+ $ser = $serial->{'ser'};
+ $order = $key;
+ delete $serials{$key}; # in case we have several serial holdings with the same call number
+ last;
+ }
+ }
+ $holdings{$cn_label}{'order'} = $order ;
+
+ my $circlib = $cp->circ_lib->shortname ;
+ push @{$holdings{$cn->label}{'copies'}}, {
+ owning_lib => $cn->owning_lib->shortname,
+ callnumber => $cn->label,
+ barcode => $cp->barcode,
+ status => $cp->status->name,
+ location => $cp->location->name,
+ circlib => $circlib,
+ ser => $ser
+ };
+ }
+ }
+
+ ## Append the holdings and MFHD data to the marc record and apply the stylesheet.
+ if ( %holdings ) {
+
+ # Force record leader to 'a' as our data is always UTF8
+ # Avoids marc8_to_utf8 from being invoked with horrible results
+ # on the off-chance the record leader isn't correct
+ my $ldr = $marc->leader;
+ substr($ldr, 9, 1, 'a');
+ $marc->leader($ldr);
+
+ # Expects the record ID in the 001
+ $marc->delete_field($_) for ($marc->field('001'));
+ if (!$marc->field('001')) {
+ $marc->insert_fields_ordered(
+ MARC::Field->new( '001', $tcn )
+ );
+ }
+
+ # Our reference node to prepend nodes to.
+ my $reference = $marc->field('901');
+
+ $marc->delete_field($_) for ($marc->field('852')); # remove any legacy 852s
+ foreach my $cn (sort { $holdings{$a}->{'order'} <=> $holdings{$b}->{'order'}} keys %holdings) {
+ foreach my $cp (@{$holdings{$cn}->{'copies'}}) {
+ my $marc_852 = MARC::Field->new(
+ '852', '4', ' ', 0 => 'dummy'); # The dummy is necessary to prevent a validation error.
+ foreach my $subfield_code (sort keys %copies) {
+ my $_cp = $copies{$subfield_code} ;
+ $marc_852->add_subfields($subfield_code, $cp->{$_cp} || $_cp) if ($_cp);
+ }
+ $marc_852->delete_subfield(code => '0');
+ $marc->insert_fields_before($reference, $marc_852);
+ if ( $cp->{'ser'} ) {
+ my $marc_866_a = MARC::Field->new( '866', '4', ' ', 'a' => $cp->{'ser'});
+ $marc->insert_fields_after( $marc_852, $marc_866_a ) ;
+ }
+ }
+ }
+
+ }
+
+ my $xslt = $record_xslt{$metadataPrefix}{xslt} ;
+ my $xml = $xslt->transform( $_parser->parse_string( $marc->as_xml_record()) );
+ return $xslt->output_as_chars( $xml ) ;
+}
+
+
+sub most_recent_date {
+
+ my $date1 = substr(shift, 0, 19) ; # e.g. '2001-02-03T04:05:06+0000' becomes '2001-02-03T04:05:06'
+ my $date2 = substr(shift, 0, 19) ;
+ my $_date1 = $date1 ;
+ my $_date2 = $date2 ;
+
+ $date1 =~ s/[-T:\.\+]//g ; # '2001-02-03T04:05:06' becomes '20010203040506'
+ $date2 =~ s/[-T:\.\+]//g ;
+
+ return $_date1 if ( $date1 > $date2) ;
+ return $_date2 ;
+}
+
+
+sub _cp_is_visible {
+
+ my $cn = shift;
+ my $cp = shift;
+
+ my $visible = 0;
+ if ( ($cp->deleted eq 'f' || !$cp->deleted) &&
+ ( ! $barcode_filter || $cp->barcode =~ /$barcode_filter/ ) &&
+ $cp->location->opac_visible eq 't' &&
+ $cp->status->opac_visible eq 't' &&
+ $cp->opac_visible eq 't' &&
+ $cp->circ_lib->opac_visible eq 't' &&
+ $cn->owning_lib->opac_visible eq 't' &&
+ (! $status_filter || $cp->status->name =~ /$status_filter/ )
+ ) {
+ $visible = 1;
+ }
+
+ return $visible;
+}
+
+__PACKAGE__->register_method(
+ method => 'oai_biblio_retrieve',
+ api_name => 'open-ils.oai.biblio.retrieve',
+ api_level => 1,
+ argc => 1,
+ signature =>
+ {
+ desc => 'Returns the MARCXML representation of the requested bibliographic record.',
+ params =>
+ [
+ {
+ name => 'tcn',
+ desc => 'An OpenILS biblio::record_entry id.',
+ type => 'number'
+ },
+ {
+ name => 'metadataPrefix',
+ desc => 'The metadataPrefix of the schema.',
+ type => 'string'
+ }
+ ],
+ 'return' =>
+ {
+ desc => 'An string of the XML in the desired schema.',
+ type => 'string'
+ }
+ }
+);
+
+
+sub oai_authority_retrieve {
+
+ my $self = shift;
+ my $client = shift;
+ my $tcn = shift;
+ my $metadataPrefix = shift;
+
+ my $_storage = OpenSRF::AppSession->create( 'open-ils.cstore' );
+
+ # Retrieve the authority record
+ my $record = $_storage->request('open-ils.cstore.direct.authority.record_entry.retrieve', $tcn)->gather(1);
+ my $o = Fieldmapper::authority::record_entry->new($record) ;
+ my $marc = MARC::Record->new_from_xml( $o->marc, 'UTF8', 'XML');
+
+ # Expects the record ID in the 001
+ $marc->delete_field($_) for ($marc->field('001'));
+ if (!$marc->field('001')) {
+ $marc->insert_fields_ordered(
+ MARC::Field->new( '001', $tcn )
+ );
+ }
+
+ my $xslt = $record_xslt{$metadataPrefix}{xslt} ;
+ my $xml = $record_xslt{$metadataPrefix}{xslt}->transform(
+ $_parser->parse_string( $marc->as_xml_record())
+ );
+ return $record_xslt{$metadataPrefix}{xslt}->output_as_chars( $xml ) ;
+}
+
+
+__PACKAGE__->register_method(
+ method => 'oai_authority_retrieve',
+ api_name => 'open-ils.oai.authority.retrieve',
+ api_level => 1,
+ argc => 1,
+ signature =>
+ {
+ desc => 'Returns the MARCXML representation of the requested authority record.',
+ params =>
+ [
+ {
+ name => 'tcn',
+ desc => 'An OpenILS authority::record_entry id.',
+ type => 'number'
+ },
+ {
+ name => 'metadataPrefix',
+ desc => 'The metadataPrefix of the schema.',
+ type => 'string'
+ }
+ ],
+ 'return' =>
+ {
+ desc => 'An string of the XML in the desired schema.',
+ type => 'string'
+ }
+ }
+);
+
+
+sub oai_list_retrieve {
+
+ my $self = shift;
+ my $client = shift;
+ my $record_class = shift || 'biblio';
+ my $tcn = shift || 0;
+ my $from = shift;
+ my $until = shift;
+ my $set = shift ;
+ my $metadataPrefix = shift;
+ my $max_count = shift;
+ my $deleted_record = shift || 'yes';
+
+ my $query = {};
+ $query->{'tcn'} = ($max_count eq 1) ? $tcn : {'>=' => $tcn} ;
+ $query->{'set_spec'} = $set if ( $set ); # unsupported
+ $query->{'deleted'} = 'f' unless ( $deleted_record eq 'yes' );
+ $query->{'datestamp'} = {'>=', $from} if ( $from && !$until ) ;
+ $query->{'datestamp'} = {'<=', $until} if ( !$from && $until ) ;
+ $query->{'-and'} = [{'datestamp'=>{'>=' => $from}}, {'datestamp'=>{'<=' => $until}}] if ( $from && $until ) ;
+
+ my $_storage = OpenSRF::AppSession->create( 'open-ils.cstore' );
+ return $_storage->request('open-ils.cstore.direct.oai.' . $record_class . '.search.atomic',
+ $query,
+ {
+ limit => $max_count + 1
+ }
+ )->gather(1);
+}
+
+__PACKAGE__->register_method(
+ method => 'oai_list_retrieve',
+ api_name => 'open-ils.oai.list.retrieve',
+ api_level => 1,
+ argc => 1,
+ signature =>
+ {
+ desc => 'Returns a list of record identifiers.',
+ params =>
+ [
+ {
+ name => 'record_class',
+ desc => '\'biblio\' for bibliographic records or \'authority\' for authority records',
+ type => 'string'
+ }, {
+ name => 'tcn',
+ desc => 'An optional tcn number used as a cursor.',
+ type => 'number'
+ },
+ {
+ name => 'from',
+ desc => 'The datestamp the resultset range should begin with.',
+ type => 'string'
+ },
+ {
+ name => 'until',
+ desc => 'The datestamp the resultset range should end with.',
+ type => 'string'
+ },
+ {
+ name => 'set',
+ desc => 'A setspec.',
+ type => 'string'
+ },
+ {
+ name => 'metadataPrefix',
+ desc => 'The metadataPrefix of the schema.',
+ type => 'string'
+ },
+ {
+ name => 'offset',
+ desc => 'The start of the cursor position in the result set.',
+ type => 'number'
+ },
+ {
+ name => 'deleted_record',
+ desc => 'If set to \'no\' the response will only include active records.',
+ type => 'string'
+ }
+ ],
+ 'return' =>
+ {
+ desc => 'An OAI type record.',
+ type => 'array'
+ }
+ }
+);
+
+
+1;
\ No newline at end of file
--- /dev/null
+# OpenILS::WWW::OAI manages OAI2 requests and responses.
+#
+# Copyright (c) 2014-2017 International Institute of Social History
+#
+# This program 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.
+#
+# This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
+#
+#
+# Author: Lucien van Wouw <lwo@iisg.nl>
+
+
+package OpenILS::WWW::OAI;
+use strict; use warnings;
+use Apache2::Const -compile => qw(OK REDIRECT DECLINED NOT_FOUND :log);
+use CGI;
+use DateTime::Format::ISO8601;
+use HTTP::OAI;
+use HTTP::OAI::Metadata::OAI_Identifier;
+use HTTP::OAI::Repository qw/:validate/;
+use MARC::File::XML ( BinaryEncoding => 'UTF-8' );
+use MARC::Record;
+use MIME::Base64;
+use OpenSRF::EX qw(:try);
+use OpenSRF::Utils::Logger qw/$logger/;
+use XML::LibXML;
+use XML::LibXSLT;
+
+my (
+ $bootstrap,
+ $base_url,
+ $repository_identifier,
+ $repository_name,
+ $admin_email,
+ $earliest_datestamp,
+ $deleted_record,
+ $max_count,
+ $granularity,
+ $scheme,
+ $delimiter,
+ $sample_identifier,
+ $list_sets,
+ $oai_metadataformats,
+ $oai_sets,
+ $oai,
+ $parser,
+ $xslt
+);
+
+
+sub import {
+
+ my $self = shift;
+ $bootstrap = shift;
+}
+
+
+sub child_init {
+
+ OpenSRF::System->bootstrap_client( config_file => $bootstrap );
+
+ my $idl = OpenSRF::Utils::SettingsClient->new->config_value('IDL');
+ Fieldmapper->import(IDL => $idl);
+
+ $oai = OpenSRF::AppSession->create('open-ils.oai');
+ $parser = new XML::LibXML;
+ $xslt = new XML::LibXSLT;
+
+ my $app_settings = OpenSRF::Utils::SettingsClient->new->config_value(apps => 'open-ils.oai')->{'app_settings'};
+ $base_url = $app_settings->{'base_url'} || 'localhost';
+ $base_url =~/(.*)\/$/ ; # Keep all minus the trailing forward slash.
+ $repository_identifier = $app_settings->{'repository_identifier'} || 'localhost';
+ $repository_name = $app_settings->{'repository_name '} || 'A name';
+ $admin_email = $app_settings->{'admin_email'} || 'adminEmail@' . $repository_identifier ;
+ $earliest_datestamp = $app_settings->{'earliest_datestamp'} || '0001-01-01' ;
+ $deleted_record = $app_settings->{'deleted_record'} || 'yes' ;
+ $max_count = $app_settings->{'max_count'} || 50;
+ $granularity = $app_settings->{'granularity' } || 'YYYY-MM-DDThh:mm:ss';
+ $scheme = $app_settings->{'scheme'} || 'oai';
+ $delimiter = $app_settings->{'delimiter'} || ':';
+ $sample_identifier = $app_settings->{'sample_identifier'} || $scheme . $delimiter . $repository_identifier . $delimiter . '12345' ;
+ $list_sets = $app_settings->{'list_sets'} || 0;
+
+ if ( $list_sets ) {
+ _load_oaisets_authority();
+ _load_oaisets_biblio();
+ }
+ _load_oai_metadataformats();
+
+ return Apache2::Const::OK;
+}
+
+
+sub handler {
+
+ my $apache = shift;
+ return Apache2::Const::DECLINED if (-e $apache->filename);
+
+ unless (defined $oai) {
+ $logger->error('Application session variables not defined. Add \'PerlChildInitHandler OpenILS::WWW::OAI::child_init\' to the Apache virtual host configuration file.');
+ child_init();
+ }
+
+ my $cgi = new CGI;
+ my $record_class;
+ if ( $cgi->path_info =~ /\/(authority|biblio)/ ) {
+ $record_class = $1 ;
+ } else {
+ return Apache2::Const::NOT_FOUND ;
+ }
+
+ my %attr = $cgi->Vars();
+ my $requestURL = $base_url
+ . '/' . $record_class
+ . '?'
+ . join('&', map { "$_=$attr{$_}" } keys %attr);
+ $logger->info('Request url=' . $requestURL ) ;
+
+ my $response;
+ my @errors = validate_request( %attr );
+ if ( !@errors ) {
+
+ # Retrieve our parameters
+ my $verb = delete( $attr{verb} );
+ my $identifier = $attr{identifier};
+ my $metadataPrefix = $attr{metadataPrefix} ;
+ my $from = $attr{from};
+ my $until = $attr{'until'};
+ my $set = $attr{set};
+ my $resumptionToken = decode_base64($attr{resumptionToken} ) if $attr{resumptionToken};
+ my $offset = 0 ;
+ if ( $resumptionToken ) {
+ ($metadataPrefix, $from, $until, $set, $offset) = split( '\$', $resumptionToken );
+ }
+
+ # Is the set valid ?
+ if ( $set ) {
+ my $_set = $oai_sets->{$set};
+ if ( $_set && $_set->{id} && $_set->{record_class} eq $record_class) {
+ $set = $_set->{id} ;
+ } else {
+ push @errors, new HTTP::OAI::Error(code=>'noRecordsMatch', message=>"Set argument doesn't match any sets. The setSpec was '$set'") ;
+ }
+ }
+
+ # Are the from and until ranges aligned ?
+ if ( $from && $until ) {
+ my $_from = $from ;
+ my $_until = $until ;
+ $_from =~ s/[-T:\.\+Z]//g ; # '2001-02-03T04:05:06Z' becomes '20010203040506'
+ $_until =~ s/[-T:\.\+Z]//g ;
+ push @errors, new HTTP::OAI::Error(code=>'badArgument', message=>'Bad date values, must have from<=until') unless ($_from <= $_until);
+ }
+
+ # Is this metadataformat available ?
+ push @errors, new HTTP::OAI::Error(code=>'cannotDisseminateFormat', message=>'The metadata format identified by the value given for the metadataPrefix argument is not supported by the item or by the repository') unless ( ($verb eq 'ListMetadataFormats' || $verb eq 'ListSets' || $verb eq 'Identify') || $oai_metadataformats->{$metadataPrefix} );
+
+ if ( !@errors ) {
+
+ # Now prepare the response
+ if ( $verb eq 'ListRecords' ) {
+ $response = listRecords( $record_class, $requestURL, $from, $until, $set, $metadataPrefix, $offset);
+ }
+ elsif ( $verb eq 'ListMetadataFormats' ) {
+ $response = listMetadataFormats();
+ }
+ elsif ( $verb eq 'ListSets' ) {
+ $response = listSets( $record_class, $requestURL );
+ }
+ elsif ( $verb eq 'GetRecord' ) {
+ $response = getRecord( $record_class, $requestURL, $identifier, $metadataPrefix);
+ }
+ elsif ( $verb eq 'ListIdentifiers' ) {
+ $response = listIdentifiers( $record_class, $requestURL, $from, $until, $set, $metadataPrefix, $offset);
+ }
+ else { # Identify
+ $response = identify($record_class);
+ }
+ }
+ }
+
+ if ( @errors ) {
+ $response = HTTP::OAI::Response->new( requestURL => $requestURL );
+ $response->errors(@errors);
+ }
+
+ $cgi->header(-type=>'text/xml', -charset=>'utf-8');
+ $cgi->print($response->toDOM->toString());
+
+ return Apache2::Const::OK;
+}
+
+
+sub identify {
+
+ my $record_class = shift;
+
+ my $response = HTTP::OAI::Identify->new(
+ protocolVersion => '2.0',
+ baseURL => $base_url . '/' . $record_class,
+ repositoryName => $repository_name,
+ adminEmail => $admin_email,
+ MaxCount => $max_count,
+ granularity => $granularity,
+ earliestDatestamp => $earliest_datestamp,
+ deletedRecord => $deleted_record
+ );
+
+ $response->description(
+ HTTP::OAI::Metadata::OAI_Identifier->new(
+ 'scheme', $scheme,
+ 'repositoryIdentifier' , $repository_identifier,
+ 'delimiter', $delimiter,
+ 'sampleIdentifier', $sample_identifier
+ )
+ );
+
+ return $response;
+}
+
+
+sub listMetadataFormats {
+
+ my $response = HTTP::OAI::ListMetadataFormats->new();
+ foreach my $metadataPrefix (keys $oai_metadataformats) {
+ my $metadata_format = $oai_metadataformats->{$metadataPrefix} ;
+ $response->metadataFormat( HTTP::OAI::MetadataFormat->new(
+ metadataPrefix => $metadataPrefix,
+ schema => $metadata_format->{schema},
+ metadataNamespace => $metadata_format->{metadataNamespace}
+ ) );
+ }
+
+ return $response;
+}
+
+
+sub listSets {
+
+ my ($record_class, $requestURL ) = @_;
+
+ if ($oai_sets) {
+ my $response = HTTP::OAI::ListSets->new( );
+ foreach my $key (keys $oai_sets) {
+ my $set = $oai_sets->{$key} ;
+ if ( $set && $set->{setSpec} && $set->{record_class} eq $record_class ) {
+ $response->set(
+ HTTP::OAI::Set->new(
+ setSpec => $set->{setSpec},
+ setName => $set->{setName}
+ )
+ );
+ }
+ }
+ return $response;
+ } else {
+ my @errors = (new HTTP::OAI::Error(code=>'noSetHierarchy', message=>'The repository does not support sets.') ) ;
+ my $response = HTTP::OAI::Response->new( requestURL => $requestURL );
+ $response->errors(@errors);
+ return $response;
+ }
+}
+
+
+sub getRecord {
+
+ my ($record_class, $requestURL, $identifier, $metadataPrefix ) = @_;
+
+ my $response ;
+ my @errors;
+
+ # Do we have a valid identifier ?
+ my $regex_identifier = "^${scheme}${delimiter}${repository_identifier}${delimiter}([0-9]+)\$";
+ if ( $identifier =~ /$regex_identifier/i ) {
+ my $tcn = $1 ;
+
+ # Do we have a record ?
+ my $record = $oai->request('open-ils.oai.list.retrieve', $record_class, $tcn, undef, undef, undef, $metadataPrefix, 1, $deleted_record)->gather(1) ;
+ if (@$record) {
+ $response = HTTP::OAI::GetRecord->new();
+ my $o = "Fieldmapper::oai::$record_class"->new(@$record[0]);
+ $response->record(_record($record_class, $o, $metadataPrefix));
+ } else {
+ push @errors, new HTTP::OAI::Error(code=>'idDoesNotExist', message=>'The value of the identifier argument is unknown or illegal in this repository.') ;
+ }
+ }
+ else {
+ push @errors, new HTTP::OAI::Error(code=>'idDoesNotExist', message=>'The value of the identifier argument is unknown or illegal in this repository.') ;
+ }
+
+ if (@errors) {
+ $response = HTTP::OAI::Response->new( requestURL => $requestURL );
+ $response->errors(@errors);
+ }
+
+ return $response;
+}
+
+
+sub listIdentifiers {
+
+ my ($record_class, $requestURL, $from, $until, $set, $metadataPrefix, $offset ) = @_;
+ my $response;
+
+ my $r = $oai->request('open-ils.oai.list.retrieve', $record_class, $offset, $from, $until, $set, $metadataPrefix, $max_count, $deleted_record)->gather(1) ;
+ if (@$r) {
+ my $cursor = 0 ;
+ $response = HTTP::OAI::ListIdentifiers->new();
+ for my $record (@$r) {
+ my $o = "Fieldmapper::oai::$record_class"->new($record) ;
+ if ( $cursor++ == $max_count ) {
+ my $token = new HTTP::OAI::ResumptionToken( resumptionToken => encode_base64(join( '$', $metadataPrefix, $from, $until, $oai_sets->{$set}->{setSpec}, $o->tcn ), '' ) ) ;
+ $token->cursor($offset);
+ $response->resumptionToken($token) ;
+ } else {
+ $response->identifier( _header($record_class, $o)) ;
+ }
+ }
+ } else {
+ my @errors = (new HTTP::OAI::Error(code=>'noRecordsMatch', message=>'The combination of the values of the from, until, set, and metadataPrefix arguments results in an empty list.') ) ;
+ $response = HTTP::OAI::Response->new( requestURL => $requestURL );
+ $response->errors(@errors);
+ }
+
+ return $response ;
+}
+
+
+sub listRecords {
+
+ my ($record_class, $requestURL, $from, $until, $set, $metadataPrefix, $offset ) = @_;
+ my $response;
+
+ my $r = $oai->request('open-ils.oai.list.retrieve', $record_class, $offset, $from, $until, $set, $metadataPrefix, $max_count, $deleted_record)->gather(1) ;
+ if (@$r) {
+ my $cursor = 0 ;
+ $response = HTTP::OAI::ListRecords->new();
+ for my $record (@$r) {
+ my $o = "Fieldmapper::oai::$record_class"->new($record) ;
+ if ( $cursor++ == $max_count ) {
+ my $token = new HTTP::OAI::ResumptionToken( resumptionToken => encode_base64(join( '$', $metadataPrefix, $from, $until, $oai_sets->{$set}->{setSpec}, $o->tcn ), '' ) ) ;
+ $token->cursor($offset);
+ $response->resumptionToken($token) ;
+ } else {
+ $response->record(_record($record_class, $o, $metadataPrefix));
+ }
+ }
+ } else {
+ my @errors = (new HTTP::OAI::Error(code=>'noRecordsMatch', message=>'The combination of the values of the from, until, set, and metadataPrefix arguments results in an empty list.') ) ;
+ $response = HTTP::OAI::Response->new( requestURL => $requestURL );
+ $response->errors(@errors);
+ }
+
+ return $response ;
+}
+
+
+sub _header {
+
+ my ($record_class, $o) = @_;
+ my @set_spec;
+
+ my $status = 'deleted' if ($o->deleted eq 't');
+ my $s = $o->set_spec; # Here we get an array that was parsed as a string like "{1,2,3,4}"
+ $s =~ s/[{}]//g ; # We remove the {}
+ foreach (split(',', $s)) { # and turn this into an array.
+ my $_set = $oai_sets->{$_};
+ push @set_spec, $_set->{setSpec} if ( $_set && $_set->{record_class} eq $record_class) ;
+ }
+
+ return new HTTP::OAI::Header(
+ identifier => $scheme . $delimiter . $repository_identifier . $delimiter . $o->tcn,
+ datestamp => substr($o->datestamp, 0, 19) . 'Z',
+ status => $status,
+ setSpec => \@set_spec
+ )
+}
+
+
+sub _record {
+
+ my ($record_class, $o, $metadataPrefix ) = @_;
+
+ my $record = HTTP::OAI::Record->new();
+ $record->header( _header($record_class, $o) );
+
+ if ( $o->deleted eq 'f' ) {
+ my $md = new HTTP::OAI::Metadata() ;
+ my $xml = $oai->request('open-ils.oai.' . $record_class . '.retrieve', $o->tcn, $metadataPrefix)->gather(1) ;
+ $md->dom( $parser->parse_string('<metadata>' . $xml . '</metadata>') ); # Not sure why I need to add the metadata element,
+ $record->metadata( $md ); # because I expect ->metadata() would provide the wrapper for it.
+ }
+
+ return $record ;
+}
+
+
+# _load_oaisets_authority
+# Populate the $oai_sets hash with the sets for authority records.
+# oai_sets = {id\setSpec => {id, setSpec, setName, record_class = 'authority' }}
+sub _load_oaisets_authority {
+
+ my $ses = OpenSRF::AppSession->create('open-ils.cstore');
+ my $r = $ses->request('open-ils.cstore.direct.authority.browse_axis.search.atomic',
+ {code => {'!=' => undef } } )->gather(1);
+
+ for my $record (@$r) {
+ my $o = Fieldmapper::authority::browse_axis->new($record) ;
+ $oai_sets->{$o->code} = {
+ id => $o->code,
+ setSpec => $o->code,
+ setName => $o->description, # description is more verbose than $o->name
+ record_class => 'authority'
+ };
+ }
+}
+
+
+# _load_oaisets_biblio
+# Populate the $oai_sets hash with the sets for bibliographic records. Those are org_type records
+# oai_sets = {id\setSpec => {id, setSpec, setName, record_class = 'biblio' }}
+sub _load_oaisets_biblio {
+
+ my $node = shift;
+ my $types = shift;
+ my $parent = shift;
+
+ unless ( $node ) {
+ my $ses = OpenSRF::AppSession->create('open-ils.actor');
+ $node = $ses->request('open-ils.actor.org_tree.retrieve')->gather(1);
+ my $aout = $ses->request('open-ils.actor.org_types.retrieve')->gather(1);
+ $ses->disconnect;
+ return unless ($node) ;
+
+ my @_types;
+ foreach my $type (@$aout) {
+ $_types[int($type->id)] = $type;
+ }
+ $types = \@_types;
+ }
+
+ return unless ($node->opac_visible =~ /^[y1t]+/i);
+
+ my $spec = ($parent) ? $parent . ':' . $node->shortname : $node->shortname ;
+ $oai_sets->{$spec} = {id => $node->id, record_class => 'biblio' };
+ $oai_sets->{$node->id} = {setSpec => $spec, setName => $node->name, record_class => 'biblio' };
+
+ my $kids = $node->children;
+ _load_oaisets_biblio($_, $types, $spec) for (@$kids);
+}
+
+
+# _load_oai_metadataformats
+# Populate the $oai_metadataformats hash with the supported metadata formats:
+# oai_metadataformats = { metadataPrefix => { schema, metadataNamespace } }
+sub _load_oai_metadataformats {
+
+ my $list = $oai->request('open-ils.oai.record.formats')->gather(1);
+ for my $record_browse_format ( @$list ) {
+ my %h = %$record_browse_format ;
+ my $metadataPrefix = (keys %h)[0] ;
+ $oai_metadataformats->{$metadataPrefix} = {
+ schema => $h{$metadataPrefix}->{'namespace_uri'},
+ metadataNamespace => $h{$metadataPrefix}->{'schema_location'}
+ };
+ }
+}
+
+1;
--- /dev/null
+-- VIEWS for the oai service
+CREATE SCHEMA oai;
+
+-- The view presents a lean table with unique bre.tc-numbers for oai paging;
+CREATE VIEW oai.biblio AS
+ SELECT
+ bre.id AS tcn,
+ bre.edit_date AS datestamp,
+ bre.deleted AS deleted
+ FROM
+ biblio.record_entry bre
+ ORDER BY
+ bre.id;
+
+-- The view presents a lean table with unique are.tc-numbers for oai paging;
+CREATE VIEW oai.authority AS
+ SELECT
+ are.id AS tcn,
+ are.edit_date AS datestamp,
+ are.deleted AS deleted
+ FROM
+ authority.record_entry AS are
+ ORDER BY
+ are.id;
+
+-- If an edit date changes in the asset.call_number or asset.copy and you want this to persist to an OAI2 datestamp,
+-- then add these stored procedures and triggers:
+CREATE OR REPLACE FUNCTION oai.datestamp(rid BIGINT)
+ RETURNS VOID AS $$
+BEGIN
+ UPDATE biblio.record_entry AS bre
+ SET edit_date = now()
+ WHERE bre.id = rid;
+END
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION oai.call_number_datestamp()
+ RETURNS TRIGGER AS $$
+BEGIN
+ IF TG_OP = 'DELETE'
+ THEN
+ PERFORM oai.datestamp(OLD.record);
+ RETURN OLD;
+ END IF;
+
+ PERFORM oai.datestamp(NEW.record);
+ RETURN NEW;
+
+END
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION oai.copy_datestamp()
+ RETURNS TRIGGER AS $$
+BEGIN
+ IF TG_OP = 'DELETE'
+ THEN
+ PERFORM oai.datestamp((SELECT acn.record FROM asset.call_number as acn WHERE acn.id = OLD.call_number));
+ RETURN OLD;
+ END IF;
+
+ PERFORM oai.datestamp((SELECT acn.record FROM asset.call_number as acn WHERE acn.id = NEW.call_number));
+ RETURN NEW;
+
+END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER call_number_datestamp AFTER INSERT OR UPDATE OR DELETE ON asset.call_number FOR EACH ROW EXECUTE PROCEDURE oai.call_number_datestamp();
+CREATE TRIGGER copy_datestamp AFTER INSERT OR UPDATE OR DELETE ON asset.copy FOR EACH ROW EXECUTE PROCEDURE oai.copy_datestamp();
\ No newline at end of file
--- /dev/null
+<xsl:stylesheet version="1.0"
+ xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+ xmlns:marc="http://www.loc.gov/MARC21/slim">
+ <xsl:output omit-xml-declaration="yes"/>
+
+ <xsl:template match="node()|@*">
+ <xsl:copy>
+ <xsl:apply-templates select="node()|@*"/>
+ </xsl:copy>
+ </xsl:template>
+
+ <xsl:template match="*">
+ <xsl:element name="marc:{name()}" namespace="http://www.loc.gov/MARC21/slim">
+ <xsl:copy-of select="namespace::*"/>
+ <xsl:apply-templates select="node()|@*"/>
+ </xsl:element>
+ </xsl:template>
+</xsl:stylesheet>
\ No newline at end of file
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<xsl:stylesheet version="1.0" xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
+ <xsl:import href="MARC21slim2OAIDC.xsl"/>
+ <xsl:output omit-xml-declaration="yes"/>
+
+ <xsl:template match="/">
+ <oai_dc:dc xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"
+ xmlns:dc="http://purl.org/dc/elements/1.1/">
+ <xsl:apply-templates/>
+ </oai_dc:dc>
+ </xsl:template>
+
+</xsl:stylesheet>
\ No newline at end of file
--- /dev/null
+= oai-openils is an openSRF service
+
+This module is an optional service that exposes your catalog through the [OAI2 protocol](http://www.openarchives.org/OAI/openarchivesprotocol.html).
+
+== 1. Intended behaviour
+
+=== 1.1 Entry points
+There are two: one for bibliographic records and one for authority records:
+
+ http://your-domain/opac/extras/oai/authority
+ http://your-domain/opac/extras/oai/biblio
+
+=== 1.2 Setspec are not implemented
+
+This is a work in progress and not enabled. The aim is to have the owning library determine the set hierarchy. The Concerto
+test database for example has a record with tcn #1. This record is so popular it has copies attached to library units
+"Example Branch 1", "Example Branch 2", "Example Branch 3", "Example Bookmobile 1" which is a child of Branch 3 and
+"Example Branch 4". This entire kinship is expressed as sets like so:
+
+```xml
+<header>
+ ...
+ <setSpec>CONS</setSpec>
+ <setSpec>CONS:SYS1</setSpec>
+ <setSpec>CONS:SYS2</setSpec>
+ <setSpec>CONS:SYS1:BR1</setSpec>
+ <setSpec>CONS:SYS1:BR2</setSpec>
+ <setSpec>CONS:SYS2:BR3</setSpec>
+ <setSpec>CONS:SYS2:BR4</setSpec>
+ <setSpec>CONS:SYS2:BR3:BM1</setSpec>
+</header>
+```
+Likewise the setSpecs of authority records are derived from their browse axis ( Title, Author, Subject and Topic ).
+
+=== 1.3 OAI2 datestamp
+
+The edit date of the bibliographic and authority record is used as datestamp. If you want the date for editorial updates
+of bibliographic assets ( copies, call numbers ) reflected in the datestamp, then add the triggers shown below.
+
+=== 1.4 Bibliographic mapping of assets to 852 subfields
+
+Certain attributes asset are placed into 852 subfields so:
+
+| subfield code | asset resource |
+| --- | --- |
+| a | location |
+| b | owning_lib |
+| c | callnumber |
+| d | circlib |
+| g | barcode |
+| n | status |
+
+Thus the Concerto with tcn #1 will have it's 852 subfields expressed as:
+```xml
+<marc:datafield ind1="4" ind2=" " tag="852">
+ <marc:subfield code="a">Stacks</marc:subfield>
+ <marc:subfield code="b">BR4</marc:subfield>
+ <marc:subfield code="c">ML 60 R100</marc:subfield>
+ <marc:subfield code="d">BR4</marc:subfield>
+ <marc:subfield code="g">CONC70000435</marc:subfield>
+ <marc:subfield code="n">Checked out</marc:subfield>
+</marc:datafield>
+```
+This mapping can be customized and extended with static subfields:
+```xml
+ <marc:subfield code="q">A constant value</marc:subfield>
+```
+
+=== 1.5 Default configuration
+
+All default configuration is commented in the open-ils.oai app_settings element. See below for details on how to
+override defaults by removing the comments and substitute the values.
+
+== 2. Installation
+
+=== 2.1 Perl modules
+
+Lookup the Perl handler and the associated openils module:
+
+ - [Open-ILS/src/perlmods/lib/OpenILS/WWW/OAI.pm](Open-ILS/src/perlmods/lib/OpenILS/WWW/OAI.pm)
+ - [Open-ILS/src/perlmods/lib/OpenILS/Application/OAI.pm](Open-ILS/src/perlmods/lib/OpenILS/Application/OAI.pm)
+
+Place them in your codebase next to the other openils modules and let them thus become part of the build:
+
+ Open-ILS/src/perlmods/lib/OpenILS/Application/OAI.pm
+ Open-ILS/src/perlmods/lib/OpenILS/WWW/OAI.pm
+
+or copy the files (owned by the opensrf user) on your servers that host the openils services in the Perl library path:
+
+ /the perl library path/OpenILS/Application/OAI.pm
+ /the perl library path/OpenILS/WWW/OAI.pm
+
+=== 2.2 Declare the perl handler
+
+Declare the Perl handler in the Apache eg_startup file:
+
+```perl
+use OpenILS::WWW::OAI qw( <openils sysdir>conf/opensrf_core.xml );
+```
+
+And reference it in the Apache eg_vhost.conf file, apache 2.2:
+
+ <Location /opac/extras/oai>
+ SetHandler perl-script
+ PerlHandler OpenILS::WWW::OAI
+ Options +ExecCGI
+ PerlSendHeader On
+ allow from all
+ </Location>
+
+or apache 2.4
+
+ <Location /opac/extras/oai>
+ SetHandler perl-script
+ PerlHandler OpenILS::WWW::OAI
+ Options +ExecCGI
+ PerlSendHeader On
+ Require all granted
+ </Location>
+
+In the eg.conf file under 'PerlRequire /etc/apache2/eg_startup' add:
+```apache
+PerlChildInitHandler OpenILS::WWW::OAI::child_init
+
+```
+
+=== 2.3 The database and fieldmapper
+
+==== 2.3.1 Database
+
+The service requires a view and stored procedures: Open-ILS/src/sql/Pg/oai.sql
+
+Add the oai section to the database:
+```sql
+-- VIEWS for the oai service
+CREATE SCHEMA oai;
+
+
+-- The view presents a lean table with unique bre.tc-numbers for oai paging;
+CREATE VIEW oai.biblio AS
+ SELECT
+ bre.id AS tcn,
+ bre.edit_date AS datestamp,
+ bre.deleted AS deleted
+ FROM
+ biblio.record_entry bre
+ ORDER BY
+ bre.id;
+
+-- The view presents a lean table with unique are.tc-numbers for oai paging;
+CREATE VIEW oai.authority AS
+ SELECT
+ are.id AS tcn,
+ are.edit_date AS datestamp,
+ are.deleted AS deleted
+ FROM
+ authority.record_entry AS are
+ ORDER BY
+ are.id;
+```
+
+==== 2.3.2 Optional, setting the datestamp
+
+If you want the OAI2 datestamp to reflect changes in assets as well, add the following triggers
+ ```sql
+
+-- If an edit date changes in the asset.call_number or asset.copy and you want this to persist to an OAI2 datestamp,
+-- then add these stored procedures and triggers:
+CREATE OR REPLACE FUNCTION oai.datestamp(rid BIGINT)
+ RETURNS VOID AS $$
+BEGIN
+ UPDATE biblio.record_entry AS bre
+ SET edit_date = now()
+ WHERE bre.id = rid;
+END
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION oai.call_number_datestamp()
+ RETURNS TRIGGER AS $$
+BEGIN
+ IF TG_OP = 'DELETE'
+ THEN
+ PERFORM oai.datestamp(OLD.record);
+ RETURN OLD;
+ END IF;
+
+ PERFORM oai.datestamp(NEW.record);
+ RETURN NEW;
+
+END
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION oai.copy_datestamp()
+ RETURNS TRIGGER AS $$
+BEGIN
+ IF TG_OP = 'DELETE'
+ THEN
+ PERFORM oai.datestamp((SELECT acn.record FROM asset.call_number as acn WHERE acn.id = OLD.call_number));
+ RETURN OLD;
+ END IF;
+
+ PERFORM oai.datestamp((SELECT acn.record FROM asset.call_number as acn WHERE acn.id = NEW.call_number));
+ RETURN NEW;
+
+END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER call_number_datestamp AFTER INSERT OR UPDATE OR DELETE ON asset.call_number FOR EACH ROW EXECUTE PROCEDURE oai.call_number_datestamp();
+CREATE TRIGGER copy_datestamp AFTER INSERT OR UPDATE OR DELETE ON asset.copy FOR EACH ROW EXECUTE PROCEDURE oai.copy_datestamp();
+ ```
+
+==== 2.3.3 The fieldmapper
+
+Proceed by declaring the views in the fm_IDL.xml file so, as the example shows here [Open-ILS/examples/fm_ILD.xml](Open-ILS/examples/fm_IDL.xml):
+
+```xml
+<class id="oai_biblio" controller="open-ils.cstore" oils_obj:fieldmapper="oai::biblio"
+ oils_persist:readonly="true" reporter:core="false" reporter:label="OAI2 record list"
+ oils_persist:tablename="oai.biblio">
+ <fields>
+ <field reporter:label="TCN Value\OAI identifier postfix" name="tcn" reporter:datatype="number"/>
+ <field reporter:label="Last edit date\OAI datestamp" name="datestamp" reporter:datatype="timestamp"/>
+ <field reporter:label="Is Deleted?" name="deleted" reporter:datatype="bool"/>
+ <field reporter:label="Setspec" name="set_spec" oils_persist:virtual="true"/>
+ </fields>
+</class>
+<class id="oai_authority" controller="open-ils.cstore" oils_obj:fieldmapper="oai::authority"
+ oils_persist:readonly="true" reporter:core="false" reporter:label="OAI2 record list"
+ oils_persist:tablename="oai.authority">
+ <fields>
+ <field reporter:label="TCN Value\OAI identifier postfix" name="tcn" reporter:datatype="number"/>
+ <field reporter:label="Last edit date\OAI datestamp" name="datestamp" reporter:datatype="timestamp"/>
+ <field reporter:label="Is Deleted?" name="deleted" reporter:datatype="bool"/>
+ <field reporter:label="Setspec" name="set_spec" oils_persist:virtual="true"/>
+ </fields>
+</class>
+```
+
+=== 2.4 The xslt stylesheets
+
+Lookup the two documents here:
+
+ - [Open-ILS/xsl/OAI2_OAIDC.xsl](Open-ILS/xsl/OAI2_OAIDC.xsl)
+ - [Open-ILS/xsl/OAI2_MARC21slim.xsl](Open-ILS/xsl/OAI2_MARC21slim.xsl)
+
+Place the stylesheets in your codebase next to the other xsl documents and let them thus become part of the build.
+Or install them on your servers that host the openils services:
+
+ /<openils sysdir>/var/xsl/OAI2_OAIDC.xsl
+ /<openils sysdir>/var/xsl/OAI2_MARC21slim.xsl
+
+=== 2.5 Dependencies
+The openils-oai service depends on a running openils-supercat service.
+And the OAI2_OAIDC.xsl document uses the file [MARC21slim2OAIDC.xsl](Open-ILS/xsl/MARC21slim2OAIDC.xsl).
+The service and stylesheet are part of the out-of-the-box Evergreen distributions.
+
+But do install the ['HTTP::OAI' perl library from a CPAN repository](http://search.cpan.org/dist/HTTP-OAI/):
+
+ $ cpan HTTP::OAI
+
+
+== 3. Configuration
+
+=== 3.1 Declare the service
+
+Add the openils-oai service to your /<openils sysdir>/conf/opensrf.xml file.
+```xml
+....
+<open-ils.oai>
+ <keepalive>5</keepalive>
+ <stateless>1</stateless>
+ <language>perl</language>
+ <implementation>OpenILS::Application::OAI</implementation>
+ <max_requests>199</max_requests>
+ <unix_config>
+ <unix_sock>open-ils.oai_unix.sock</unix_sock>
+ <unix_pid>open-ils.oai_unix.pid</unix_pid>
+ <max_requests>1000</max_requests>
+ <unix_log>open-ils.oai_unix.log</unix_log>
+ <min_children>1</min_children>
+ <max_children>5</max_children>
+ <min_spare_children>1</min_spare_children>
+ <max_spare_children>2</max_spare_children>
+ </unix_config>
+ <app_settings>
+
+ <!-- Where necessary, override the default settings here in the app_settings element. -->
+
+ <!-- The OAI endpoint. The domain is the name of your proxy or frontend opac website. -->
+ <!-- <base_url>http://mydomain.org/opac/extras/oai</base_url> -->
+
+ <!-- <repository_name>My organization(s)</repository_name> -->
+ <!-- <admin_email>admin@mydomain.org</admin_email> -->
+
+ <!-- The maximum number of records in a ListRecords and ListIdentifiers response. -->
+ <!-- <max_count>50</max_count> -->
+
+ <!-- <granularity>YYYY-MM-DDThh:mm:ss</granularity> -->
+ <!-- <earliest_datestamp>0001-01-01</earliest_datestamp> -->
+ <!-- <deleted_record>yes</deleted_record> -->
+ <!-- <scheme>oai</scheme> -->
+ <!-- <repository_identifier>mydomain.org</repository_identifier> -->
+ <!-- <delimiter>:</delimiter> -->
+ <!-- <sample_identifier>oai:mydomain.org:12345</sample_identifier> -->
+ <!-- <list_sets>false</list_sets> -->
+
+ <!--
+ The metadataformat element contains the schema for the oai_dc and marcxml metadata formats.
+ Each schema needs a reference to an xslt document.
+ You can replace them with your custom xslt stylesheets.
+ Place those in the /<openils sysdir>/var/xsl folder.
+ You can also extend the OAI2 service further with new metadata schema.
+
+ Bibliographic and authority records share the same stylesheet.
+ Should you want to render them differently, use the
+ marc:datafield[@tag='901']/marc:subfield[@code='t']
+ value to identify the record type. -->
+
+ <!--
+ <metadataformat>
+ <oai_dc>
+ <namespace_uri>http://www.openarchives.org/OAI/2.0/oai_dc/</namespace_uri>
+ <schema_location>http://www.openarchives.org/OAI/2.0/oai_dc.xsd</schema_location>
+ <xslt>OAI2_OAIDC.xsl</xslt>
+ </oai_dc>
+ <marcxml>
+ <namespace_uri>http://www.loc.gov/MARC21/slim</namespace_uri>
+ <schema_location>http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd</schema_location>
+ <xslt>OAI2_MARC21slim.xsl</xslt>
+ </marcxml>
+ </metadataformat> -->
+
+ <!--
+ You can add different schema to the metadataformat element thus:
+ <mods>
+ <namespace_uri>http://www.loc.gov/mods/</namespace_uri>
+ <schema_location>http://www.loc.gov/standards/mods/mods.xsd</schema_location>
+ <xslt>my-custom-marc2mods.xsl</xslt>
+ </mods>
+ <my-metadata_prefix>
+ <namespace_uri>my-namespace_uri</namespace_uri>
+ <schema_location>my-schema_location</schema_location>
+ <xslt>my-marc2my-metadata.xsl</xslt>
+ </my-metadata_prefix>
+ -->
+
+ <!-- Change the way the asset copy values are mapped to which subfield codes: -->
+ <!--
+ <copies>
+ <a>location</a>
+ <b>owning_lib</b>
+ <c>callnumber</c>
+ <d>circlib</d>
+ <g>barcode</g>
+ <n>status</n>
+ </copies>
+ -->
+ <!-- Or add static values to the copies element like this:
+ <z>A value that always should for example be in the 852$z</z>
+ -->
+
+ <!-- Accept only 852$[barcode] values that match this regular expression. E.g.
+ <barcode_filter>^[A-Za-z0-9]+</barcode_filter>
+ only renders 852 datafields that contain barcodes values that begin with letters and numbers.
+ <!--
+ <barcode_filter><barcode_filter>
+ -->
+
+ <!-- Accept only 852$[status] values that match this regular expression. E.g.
+ <status_filter>^Available$</status_filter>
+ only renders 852 datafields that contain status code values that exactly match the string 'Available'.
+ <!--
+ <status_filter></status_filter>
+ -->
+
+ </app_settings>
+</open-ils.oai>
+```
+
+==== 3.2 Activate the service
+
+Refer to the service in the opensrf.xml's activeapps element:
+```xml
+....
+<activeapps>
+ <appname>open-ils.oai</appname>
+```
+
+==== 3.3 Register the service with the router
+
+Add the service to the public router with your /<openils sysdir>/conf/opensrf_core.xml
+```xml
+<config>
+ <opensrf>
+ <routers>
+ <router>
+ <name>router</name>
+ <domain>public.realm</domain>
+ <services>
+ <service>openils.oai</service>
+ ...
+```
+
+
+
+
+
+