Fix release notes typo
[koha.git] / opac / oai.pl
1 #!/usr/bin/perl
2
3 # Copyright Biblibre 2008
4 #
5 # This file is part of Koha.
6 #
7 # Koha is free software; you can redistribute it and/or modify it under the
8 # terms of the GNU General Public License as published by the Free Software
9 # Foundation; either version 2 of the License, or (at your option) any later
10 # version.
11 #
12 # Koha is distributed in the hope that it will be useful, but WITHOUT ANY
13 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
14 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License along
17 # with Koha; if not, write to the Free Software Foundation, Inc.,
18 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
19
20
21 use strict;
22 use warnings;
23
24 use CGI qw/:standard -oldstyle_urls/;
25 use vars qw( $GZIP );
26 use C4::Context;
27
28
29 BEGIN {
30     eval { require PerlIO::gzip };
31     $GZIP = ($@) ? 0 : 1;
32 }
33
34 unless ( C4::Context->preference('OAI-PMH') ) {
35     print
36         header(
37             -type       => 'text/plain; charset=utf-8',
38             -charset    => 'utf-8',
39             -status     => '404 OAI-PMH service is disabled',
40         ),
41         "OAI-PMH service is disabled";
42     exit;
43 }
44
45 my @encodings = http('HTTP_ACCEPT_ENCODING');
46 if ( $GZIP && grep { defined($_) && $_ eq 'gzip' } @encodings ) {
47     print header(
48         -type               => 'text/xml; charset=utf-8',
49         -charset            => 'utf-8',
50         -Content-Encoding   => 'gzip',
51     );
52     binmode( STDOUT, ":gzip" );
53 }
54 else {
55     print header(
56         -type       => 'text/xml; charset=utf-8',
57         -charset    => 'utf-8',
58     );
59 }
60
61 binmode STDOUT, ':encoding(UTF-8)';
62 my $repository = C4::OAI::Repository->new();
63
64 # __END__ Main Prog
65
66
67 #
68 # Extends HTTP::OAI::ResumptionToken
69 # A token is identified by:
70 # - metadataPrefix
71 # - from
72 # - until
73 # - offset
74 #
75 package C4::OAI::ResumptionToken;
76
77 use strict;
78 use warnings;
79 use HTTP::OAI;
80
81 use base ("HTTP::OAI::ResumptionToken");
82
83
84 sub new {
85     my ($class, %args) = @_;
86
87     my $self = $class->SUPER::new(%args);
88
89     my ($metadata_prefix, $offset, $from, $until, $set);
90     if ( $args{ resumptionToken } ) {
91         ($metadata_prefix, $offset, $from, $until, $set)
92             = split( '/', $args{resumptionToken} );
93     }
94     else {
95         $metadata_prefix = $args{ metadataPrefix };
96         $from = $args{ from } || '1970-01-01';
97         $until = $args{ until };
98         unless ( $until) {
99             my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday) = gmtime( time );
100             $until = sprintf( "%.4d-%.2d-%.2d", $year+1900, $mon+1,$mday );
101         }
102         $offset = $args{ offset } || 0;
103         $set = $args{set};
104     }
105
106     $self->{ metadata_prefix } = $metadata_prefix;
107     $self->{ offset          } = $offset;
108     $self->{ from            } = $from;
109     $self->{ until           } = $until;
110     $self->{ set             } = $set;
111
112     $self->resumptionToken(
113         join( '/', $metadata_prefix, $offset, $from, $until, $set ) );
114     $self->cursor( $offset );
115
116     return $self;
117 }
118
119 # __END__ C4::OAI::ResumptionToken
120
121
122
123 package C4::OAI::Identify;
124
125 use strict;
126 use warnings;
127 use HTTP::OAI;
128 use C4::Context;
129
130 use base ("HTTP::OAI::Identify");
131
132 sub new {
133     my ($class, $repository) = @_;
134
135     my ($baseURL) = $repository->self_url() =~ /(.*)\?.*/;
136     my $self = $class->SUPER::new(
137         baseURL             => $baseURL,
138         repositoryName      => C4::Context->preference("LibraryName"),
139         adminEmail          => C4::Context->preference("KohaAdminEmailAddress"),
140         MaxCount            => C4::Context->preference("OAI-PMH:MaxCount"),
141         granularity         => 'YYYY-MM-DD',
142         earliestDatestamp   => '0001-01-01',
143         deletedRecord       => 'no',
144     );
145
146     # FIXME - alas, the description element is not so simple; to validate
147     # against the OAI-PMH schema, it cannot contain just a string,
148     # but one or more elements that validate against another XML schema.
149     # For now, simply omitting it.
150     # $self->description( "Koha OAI Repository" );
151
152     $self->compression( 'gzip' );
153
154     return $self;
155 }
156
157 # __END__ C4::OAI::Identify
158
159
160
161 package C4::OAI::ListMetadataFormats;
162
163 use strict;
164 use warnings;
165 use HTTP::OAI;
166
167 use base ("HTTP::OAI::ListMetadataFormats");
168
169 sub new {
170     my ($class, $repository) = @_;
171
172     my $self = $class->SUPER::new();
173
174     if ( $repository->{ conf } ) {
175         foreach my $name ( @{ $repository->{ koha_metadata_format } } ) {
176             my $format = $repository->{ conf }->{ format }->{ $name };
177             $self->metadataFormat( HTTP::OAI::MetadataFormat->new(
178                 metadataPrefix    => $format->{metadataPrefix},
179                 schema            => $format->{schema},
180                 metadataNamespace => $format->{metadataNamespace}, ) );
181         }
182     }
183     else {
184         $self->metadataFormat( HTTP::OAI::MetadataFormat->new(
185             metadataPrefix    => 'oai_dc',
186             schema            => 'http://www.openarchives.org/OAI/2.0/oai_dc.xsd',
187             metadataNamespace => 'http://www.openarchives.org/OAI/2.0/oai_dc/'
188         ) );
189         $self->metadataFormat( HTTP::OAI::MetadataFormat->new(
190             metadataPrefix    => 'marcxml',
191             schema            => 'http://www.loc.gov/MARC21/slim http://www.loc.gov/ standards/marcxml/schema/MARC21slim.xsd',
192             metadataNamespace => 'http://www.loc.gov/MARC21/slim http://www.loc.gov/ standards/marcxml/schema/MARC21slim'
193         ) );
194     }
195
196     return $self;
197 }
198
199 # __END__ C4::OAI::ListMetadataFormats
200
201
202
203 package C4::OAI::Record;
204
205 use strict;
206 use warnings;
207 use HTTP::OAI;
208 use HTTP::OAI::Metadata::OAI_DC;
209
210 use base ("HTTP::OAI::Record");
211
212 sub new {
213     my ($class, $repository, $marcxml, $timestamp, $setSpecs, %args) = @_;
214
215     my $self = $class->SUPER::new(%args);
216
217     $timestamp =~ s/ /T/, $timestamp .= 'Z';
218     $self->header( new HTTP::OAI::Header(
219         identifier  => $args{identifier},
220         datestamp   => $timestamp,
221     ) );
222
223     foreach my $setSpec (@$setSpecs) {
224         $self->header->setSpec($setSpec);
225     }
226
227     my $parser = XML::LibXML->new();
228     my $record_dom = $parser->parse_string( $marcxml );
229     my $format =  $args{metadataPrefix};
230     if ( $format ne 'marcxml' ) {
231         my %args = (
232             OPACBaseURL => "'" . C4::Context->preference('OPACBaseURL') . "'"
233         );
234         $record_dom = $repository->stylesheet($format)->transform($record_dom, %args);
235     }
236     $self->metadata( HTTP::OAI::Metadata->new( dom => $record_dom ) );
237
238     return $self;
239 }
240
241 # __END__ C4::OAI::Record
242
243
244
245 package C4::OAI::GetRecord;
246
247 use strict;
248 use warnings;
249 use HTTP::OAI;
250 use C4::OAI::Sets;
251
252 use base ("HTTP::OAI::GetRecord");
253
254
255 sub new {
256     my ($class, $repository, %args) = @_;
257
258     my $self = HTTP::OAI::GetRecord->new(%args);
259
260     my $dbh = C4::Context->dbh;
261     my $sth = $dbh->prepare("
262         SELECT marcxml, timestamp
263         FROM   biblioitems
264         WHERE  biblionumber=? " );
265     my $prefix = $repository->{koha_identifier} . ':';
266     my ($biblionumber) = $args{identifier} =~ /^$prefix(.*)/;
267     $sth->execute( $biblionumber );
268     my ($marcxml, $timestamp);
269     unless ( ($marcxml, $timestamp) = $sth->fetchrow ) {
270         return HTTP::OAI::Response->new(
271             requestURL  => $repository->self_url(),
272             errors      => [ new HTTP::OAI::Error(
273                 code    => 'idDoesNotExist',
274                 message => "There is no biblio record with this identifier",
275                 ) ] ,
276         );
277     }
278
279     my $oai_sets = GetOAISetsBiblio($biblionumber);
280     my @setSpecs;
281     foreach (@$oai_sets) {
282         push @setSpecs, $_->{spec};
283     }
284
285     #$self->header( HTTP::OAI::Header->new( identifier  => $args{identifier} ) );
286     $self->record( C4::OAI::Record->new(
287         $repository, $marcxml, $timestamp, \@setSpecs, %args ) );
288
289     return $self;
290 }
291
292 # __END__ C4::OAI::GetRecord
293
294
295
296 package C4::OAI::ListIdentifiers;
297
298 use strict;
299 use warnings;
300 use HTTP::OAI;
301 use C4::OAI::Sets;
302
303 use base ("HTTP::OAI::ListIdentifiers");
304
305
306 sub new {
307     my ($class, $repository, %args) = @_;
308
309     my $self = HTTP::OAI::ListIdentifiers->new(%args);
310
311     my $token = new C4::OAI::ResumptionToken( %args );
312     my $dbh = C4::Context->dbh;
313     my $set;
314     if(defined $token->{'set'}) {
315         $set = GetOAISetBySpec($token->{'set'});
316     }
317     my $max = $repository->{koha_max_count};
318     my $sql = "
319         SELECT biblioitems.biblionumber, biblioitems.timestamp
320         FROM biblioitems
321     ";
322     $sql .= " JOIN oai_sets_biblios ON biblioitems.biblionumber = oai_sets_biblios.biblionumber " if defined $set;
323     $sql .= " WHERE DATE(timestamp) >= ? AND DATE(timestamp) <= ? ";
324     $sql .= " AND oai_sets_biblios.set_id = ? " if defined $set;
325     $sql .= "
326         LIMIT " . ($max+1) . "
327         OFFSET $token->{offset}
328     ";
329     my $sth = $dbh->prepare( $sql );
330     my @bind_params = ($token->{'from'}, $token->{'until'});
331     push @bind_params, $set->{'id'} if defined $set;
332     $sth->execute( @bind_params );
333
334     my $count = 0;
335     while ( my ($biblionumber, $timestamp) = $sth->fetchrow ) {
336         $count++;
337         if ( $count > $max ) {
338             $self->resumptionToken(
339                 new C4::OAI::ResumptionToken(
340                     metadataPrefix  => $token->{metadata_prefix},
341                     from            => $token->{from},
342                     until           => $token->{until},
343                     offset          => $token->{offset} + $max,
344                     set             => $token->{set}
345                 )
346             );
347             last;
348         }
349         $timestamp =~ s/ /T/, $timestamp .= 'Z';
350         $self->identifier( new HTTP::OAI::Header(
351             identifier => $repository->{ koha_identifier} . ':' . $biblionumber,
352             datestamp  => $timestamp,
353         ) );
354     }
355
356     # Return error if no results
357     unless ($count) {
358         return HTTP::OAI::Response->new(
359             requestURL => $repository->self_url(),
360             errors     => [ new HTTP::OAI::Error( code => 'noRecordsMatch' ) ],
361         );
362     }
363
364     return $self;
365 }
366
367 # __END__ C4::OAI::ListIdentifiers
368
369 package C4::OAI::Description;
370
371 use strict;
372 use warnings;
373 use HTTP::OAI;
374 use HTTP::OAI::SAXHandler qw/ :SAX /;
375
376 sub new {
377     my ( $class, %args ) = @_;
378
379     my $self = {};
380
381     if(my $setDescription = $args{setDescription}) {
382         $self->{setDescription} = $setDescription;
383     }
384     if(my $handler = $args{handler}) {
385         $self->{handler} = $handler;
386     }
387
388     bless $self, $class;
389     return $self;
390 }
391
392 sub set_handler {
393     my ( $self, $handler ) = @_;
394
395     $self->{handler} = $handler if $handler;
396
397     return $self;
398 }
399
400 sub generate {
401     my ( $self ) = @_;
402
403     g_data_element($self->{handler}, 'http://www.openarchives.org/OAI/2.0/', 'setDescription', {}, $self->{setDescription});
404
405     return $self;
406 }
407
408 # __END__ C4::OAI::Description
409
410 package C4::OAI::ListSets;
411
412 use strict;
413 use warnings;
414 use HTTP::OAI;
415 use C4::OAI::Sets;
416
417 use base ("HTTP::OAI::ListSets");
418
419 sub new {
420     my ( $class, $repository, %args ) = @_;
421
422     my $self = HTTP::OAI::ListSets->new(%args);
423
424     my $token = C4::OAI::ResumptionToken->new(%args);
425     my $sets = GetOAISets;
426     my $pos = 0;
427     foreach my $set (@$sets) {
428         if ($pos < $token->{offset}) {
429             $pos++;
430             next;
431         }
432         my @descriptions;
433         foreach my $desc (@{$set->{'descriptions'}}) {
434             push @descriptions, C4::OAI::Description->new(
435                 setDescription => $desc,
436             );
437         }
438         $self->set(
439             HTTP::OAI::Set->new(
440                 setSpec => $set->{'spec'},
441                 setName => $set->{'name'},
442                 setDescription => \@descriptions,
443             )
444         );
445         $pos++;
446         last if ($pos + 1 - $token->{offset}) > $repository->{koha_max_count};
447     }
448
449     $self->resumptionToken(
450         new C4::OAI::ResumptionToken(
451             metadataPrefix => $token->{metadata_prefix},
452             offset         => $pos
453         )
454     ) if ( $pos > $token->{offset} );
455
456     return $self;
457 }
458
459 # __END__ C4::OAI::ListSets;
460
461 package C4::OAI::ListRecords;
462
463 use strict;
464 use warnings;
465 use HTTP::OAI;
466 use C4::OAI::Sets;
467
468 use base ("HTTP::OAI::ListRecords");
469
470
471 sub new {
472     my ($class, $repository, %args) = @_;
473
474     my $self = HTTP::OAI::ListRecords->new(%args);
475
476     my $token = new C4::OAI::ResumptionToken( %args );
477     my $dbh = C4::Context->dbh;
478     my $set;
479     if(defined $token->{'set'}) {
480         $set = GetOAISetBySpec($token->{'set'});
481     }
482     my $max = $repository->{koha_max_count};
483     my $sql = "
484         SELECT biblioitems.biblionumber, biblioitems.marcxml, biblioitems.timestamp
485         FROM biblioitems
486     ";
487     $sql .= " JOIN oai_sets_biblios ON biblioitems.biblionumber = oai_sets_biblios.biblionumber " if defined $set;
488     $sql .= " WHERE DATE(timestamp) >= ? AND DATE(timestamp) <= ? ";
489     $sql .= " AND oai_sets_biblios.set_id = ? " if defined $set;
490     $sql .= "
491         LIMIT " . ($max + 1) . "
492         OFFSET $token->{offset}
493     ";
494
495     my $sth = $dbh->prepare( $sql );
496     my @bind_params = ($token->{'from'}, $token->{'until'});
497     push @bind_params, $set->{'id'} if defined $set;
498     $sth->execute( @bind_params );
499
500     my $count = 0;
501     while ( my ($biblionumber, $marcxml, $timestamp) = $sth->fetchrow ) {
502         $count++;
503         if ( $count > $max ) {
504             $self->resumptionToken(
505                 new C4::OAI::ResumptionToken(
506                     metadataPrefix  => $token->{metadata_prefix},
507                     from            => $token->{from},
508                     until           => $token->{until},
509                     offset          => $token->{offset} + $max,
510                     set             => $token->{set}
511                 )
512             );
513             last;
514         }
515         my $oai_sets = GetOAISetsBiblio($biblionumber);
516         my @setSpecs;
517         foreach (@$oai_sets) {
518             push @setSpecs, $_->{spec};
519         }
520         $self->record( C4::OAI::Record->new(
521             $repository, $marcxml, $timestamp, \@setSpecs,
522             identifier      => $repository->{ koha_identifier } . ':' . $biblionumber,
523             metadataPrefix  => $token->{metadata_prefix}
524         ) );
525     }
526
527     # Return error if no results
528     unless ($count) {
529         return HTTP::OAI::Response->new(
530             requestURL => $repository->self_url(),
531             errors     => [ new HTTP::OAI::Error( code => 'noRecordsMatch' ) ],
532         );
533     }
534
535     return $self;
536 }
537
538 # __END__ C4::OAI::ListRecords
539
540
541
542 package C4::OAI::Repository;
543
544 use base ("HTTP::OAI::Repository");
545
546 use strict;
547 use warnings;
548
549 use HTTP::OAI;
550 use HTTP::OAI::Repository qw/:validate/;
551
552 use XML::SAX::Writer;
553 use XML::LibXML;
554 use XML::LibXSLT;
555 use YAML::Syck qw( LoadFile );
556 use CGI qw/:standard -oldstyle_urls/;
557
558 use C4::Context;
559 use C4::Biblio;
560
561
562 sub new {
563     my ($class, %args) = @_;
564     my $self = $class->SUPER::new(%args);
565
566     $self->{ koha_identifier      } = C4::Context->preference("OAI-PMH:archiveID");
567     $self->{ koha_max_count       } = C4::Context->preference("OAI-PMH:MaxCount");
568     $self->{ koha_metadata_format } = ['oai_dc', 'marcxml'];
569     $self->{ koha_stylesheet      } = { }; # Build when needed
570
571     # Load configuration file if defined in OAI-PMH:ConfFile syspref
572     if ( my $file = C4::Context->preference("OAI-PMH:ConfFile") ) {
573         $self->{ conf } = LoadFile( $file );
574         my @formats = keys %{ $self->{conf}->{format} };
575         $self->{ koha_metadata_format } =  \@formats;
576     }
577
578     # Check for grammatical errors in the request
579     my @errs = validate_request( CGI::Vars() );
580
581     # Is metadataPrefix supported by the respository?
582     my $mdp = param('metadataPrefix') || '';
583     if ( $mdp && !grep { $_ eq $mdp } @{$self->{ koha_metadata_format }} ) {
584         push @errs, new HTTP::OAI::Error(
585             code    => 'cannotDisseminateFormat',
586             message => "Dissemination as '$mdp' is not supported",
587         );
588     }
589
590     my $response;
591     if ( @errs ) {
592         $response = HTTP::OAI::Response->new(
593             requestURL  => self_url(),
594             errors      => \@errs,
595         );
596     }
597     else {
598         my %attr = CGI::Vars();
599         my $verb = delete( $attr{verb} );
600         if ( $verb eq 'ListSets' ) {
601             $response = C4::OAI::ListSets->new($self, %attr);
602         }
603         elsif ( $verb eq 'Identify' ) {
604             $response = C4::OAI::Identify->new( $self );
605         }
606         elsif ( $verb eq 'ListMetadataFormats' ) {
607             $response = C4::OAI::ListMetadataFormats->new( $self );
608         }
609         elsif ( $verb eq 'GetRecord' ) {
610             $response = C4::OAI::GetRecord->new( $self, %attr );
611         }
612         elsif ( $verb eq 'ListRecords' ) {
613             $response = C4::OAI::ListRecords->new( $self, %attr );
614         }
615         elsif ( $verb eq 'ListIdentifiers' ) {
616             $response = C4::OAI::ListIdentifiers->new( $self, %attr );
617         }
618     }
619
620     $response->set_handler( XML::SAX::Writer->new( Output => *STDOUT ) );
621     $response->generate;
622
623     bless $self, $class;
624     return $self;
625 }
626
627
628 sub stylesheet {
629     my ( $self, $format ) = @_;
630
631     my $stylesheet = $self->{ koha_stylesheet }->{ $format };
632     unless ( $stylesheet ) {
633         my $xsl_file = $self->{ conf }
634                        ? $self->{ conf }->{ format }->{ $format }->{ xsl_file }
635                        : ( C4::Context->config('intrahtdocs') .
636                          '/prog/en/xslt/' .
637                          C4::Context->preference('marcflavour') .
638                          'slim2OAIDC.xsl' );
639         my $parser = XML::LibXML->new();
640         my $xslt = XML::LibXSLT->new();
641         my $style_doc = $parser->parse_file( $xsl_file );
642         $stylesheet = $xslt->parse_stylesheet( $style_doc );
643         $self->{ koha_stylesheet }->{ $format } = $stylesheet;
644     }
645
646     return $stylesheet;
647 }
648
649
650
651 =head1 NAME
652
653 C4::OAI::Repository - Handles OAI-PMH requests for a Koha database.
654
655 =head1 SYNOPSIS
656
657   use C4::OAI::Repository;
658
659   my $repository = C4::OAI::Repository->new();
660
661 =head1 DESCRIPTION
662
663 This object extend HTTP::OAI::Repository object.
664 It accepts OAI-PMH HTTP requests and returns result.
665
666 This OAI-PMH server can operate in a simple mode and extended one.
667
668 In simple mode, repository configuration comes entirely from Koha system
669 preferences (OAI-PMH:archiveID and OAI-PMH:MaxCount) and the server returns
670 records in marcxml or dublin core format. Dublin core records are created from
671 koha marcxml records tranformed with XSLT. Used XSL file is located in
672 koha-tmpl/intranet-tmpl/prog/en/xslt directory and choosed based on marcflavour,
673 respecively MARC21slim2OAIDC.xsl for MARC21 and  MARC21slim2OAIDC.xsl for
674 UNIMARC.
675
676 In extende mode, it's possible to parameter other format than marcxml or Dublin
677 Core. A new syspref OAI-PMH:ConfFile specify a YAML configuration file which
678 list available metadata formats and XSL file used to create them from marcxml
679 records. If this syspref isn't set, Koha OAI server works in simple mode. A
680 configuration file koha-oai.conf can look like that:
681
682   ---
683   format:
684     vs:
685       metadataPrefix: vs
686       metadataNamespace: http://veryspecial.tamil.fr/vs/format-pivot/1.1/vs
687       schema: http://veryspecial.tamil.fr/vs/format-pivot/1.1/vs.xsd
688       xsl_file: /usr/local/koha/xslt/vs.xsl
689     marcxml:
690       metadataPrefix: marxml
691       metadataNamespace: http://www.loc.gov/MARC21/slim http://www.loc.gov/standards/marcxml/schema/MARC21slim
692       schema: http://www.loc.gov/MARC21/slim http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd
693     oai_dc:
694       metadataPrefix: oai_dc
695       metadataNamespace: http://www.openarchives.org/OAI/2.0/oai_dc/
696       schema: http://www.openarchives.org/OAI/2.0/oai_dc.xsd
697       xsl_file: /usr/local/koha/koha-tmpl/intranet-tmpl/xslt/UNIMARCslim2OAIDC.xsl
698
699 =cut
700
701
702