File Coverage

blib/lib/XML/OPDS.pm
Criterion Covered Total %
statement 132 143 92.3
branch 24 34 70.5
condition 3 5 60.0
subroutine 22 23 95.6
pod 10 10 100.0
total 191 215 88.8


line stmt bran cond sub pod time code
1             package XML::OPDS;
2              
3 5     5   481368 use strict;
  5         41  
  5         183  
4 5     5   28 use warnings FATAL => 'all';
  5         8  
  5         227  
5 5     5   2589 use Types::Standard qw/Str Object ArrayRef InstanceOf Maybe Int/;
  5         340897  
  5         67  
6 5     5   9262 use Moo;
  5         48497  
  5         31  
7 5     5   10436 use DateTime;
  5         2199170  
  5         241  
8 5     5   2754 use DateTime::Format::RFC3339;
  5         15261  
  5         131  
9 5     5   1986 use XML::Atom;
  5         241379  
  5         303  
10 5     5   2036 use XML::Atom::Feed;
  5         245601  
  5         145  
11 5     5   44 use XML::Atom::Entry;
  5         10  
  5         133  
12 5     5   2200 use XML::OPDS::Navigation;
  5         73  
  5         187  
13 5     5   2034 use XML::OPDS::Acquisition;
  5         15  
  5         181  
14 5     5   2054 use XML::OPDS::OpenSearch::Query;
  5         16  
  5         8214  
15              
16             =head1 NAME
17              
18             XML::OPDS - OPDS (Open Publication Distribution System) feed creation
19              
20             =head1 VERSION
21              
22             Version 0.06
23              
24             =cut
25              
26             our $VERSION = '0.06';
27              
28              
29             =head1 DESCRIPTION
30              
31             This module facilitates the creation of OPDS feeds.
32              
33             The specifications can be found at L<http://opds-spec.org/> while the
34             validator is at L<http://opds-validator.appspot.com/>.
35              
36             The idea is that it should be enough to pass the navigation links and
37             the title entries with some data, and have the feed back.
38              
39             The OPDS feeds are basically Atom feeds, hence this module uses
40             L<XML::Atom> under the hood.
41              
42             Some features are not supported yet, but patches are welcome. Also
43             keep in mind that the applications which are supposed to talk to your
44             server have a level of support which varies from decent to "keep
45             crashing".
46              
47             This is still very much a work in progress, but it's already
48             generating valid and usable catalogs.
49              
50             =head1 SYNOPSIS
51              
52             use XML::OPDS;
53             my $feed = XML::OPDS->new(prefix => 'http://amusewiki.org');
54             # add two links, self and start are mandatory.
55             $feed->add_to_navigations(
56             rel => 'self',
57             title => 'Root',
58             href => '/',
59             );
60             $feed->add_to_navigations(
61             rel => 'start',
62             title => 'Root',
63             href => '/',
64             );
65             # add a navigation for the title list, marking as leaf, where the
66             # download links can be retrieved.
67             $feed->add_to_navigations(
68             title => 'Titles',
69             description => 'texts sorted by title',
70             href => '/titles',
71             acquisition => 1,
72             );
73             # and render
74             print $feed->render;
75             # you can reuse the object for leaf feeds (i.e. the acquistion
76             # feeds), pushing another self navigation, which will replace the
77             # previous one.
78             $feed->add_to_navigations(
79             rel => 'self',
80             title => 'Titles',
81             description => 'texts sorted by title',
82             href => '/titles',
83              
84             );
85             # or, implicitely setting the self rel and cleaning the navigation
86             # stash, keeping the meta
87             $feed->add_to_navigations_new_level(
88             title => 'Titles',
89             acquisition => 1,
90             href => '/titles',
91             );
92             $feed->add_to_acquisitions(
93             href => '/my/title',
94             title => 'My title',
95             files => [ '/my/title.epub' ],
96             );
97             # and here we have an acquisition feed, because of the presence of
98             # the acquisition.
99             print $feed->render;
100              
101             =head1 ENCODING
102              
103             Even if the module wants characters as input (decoded strings, not
104             bytes), the output XML is an UTF-8 encoded string.
105              
106             =head1 SETTERS/ACCESSORS
107              
108             =head2 navigations
109              
110             Arrayref of L<XML::OPDS::Navigation> objects. An object with a rel
111             C<self> (the feed itself) and one with the rel C<start> (the root
112             feed) are mandatory. If not present, the module will crash while
113             rendering the feed.
114              
115             =head2 acquisitions
116              
117             Arrayref of L<XML::OPDS::Acquisition> objects. If one or more objects
118             are present, the feed will become an acquistion feed.
119              
120             =head2 author
121              
122             The producer of the feed. Defaults to this class name and version.
123              
124             =head2 author_uri
125              
126             The uri of the author. Defaults to L<http://amusewiki.org> (which is
127             the home of this class).
128              
129             =head2 prefix
130              
131             Default to the empty string. On instances of this class, by itself has
132             no effect. However, when calling C<add_to_acquisitions> and
133             C<add_to_navigations>, it will be passed to the constructors of those
134             objects.
135              
136             This is usually the hostname of the OPDS server. So you need just to
137             pass, e.g. 'http://amusewiki.org' and have all the links prefixed by
138             that (no slash mangling or adding is performed). If you are going to
139             pass the full urls, leave it at the default.
140              
141             =head2 updated
142              
143             Default to current timestamp. When calling C<create_navigation> or
144             C<create_acquistion>, use this timestamp as default.
145              
146             =head2 logo
147              
148             The feed logo. If prefix is set, prepend it.
149              
150             =head2 icon
151              
152             The feed icon. If prefix is set, prepend it.
153              
154             =head1 METHODS
155              
156             =head2 render
157              
158             Return the generated xml.
159              
160             =head2 atom
161              
162             Return the L<XML::Atom::Feed> object.
163              
164             =head2 create_navigation(%args)
165              
166             Create a L<XML::OPDS::Navigation> object inheriting the prefix.
167              
168             =head2 create_acquisition(%args)
169              
170             Create a L<XML::OPDS::Acquisition> object inheriting the prefix.
171              
172             =head2 add_to_navigations(%args)
173              
174             Call C<create_navigation> and add it to the C<navigations> stack.
175              
176             =head2 add_to_navigations_new_level(%args)
177              
178             Like C<add_to_navigations>, but it's meant to be used for
179             C<rel:self> elements.
180              
181             The C<rel:self> attribute is injected in the arguments which are
182             passed to C<create_navigation>.
183              
184             If a navigation with the attribute C<rel> set to C<self> was already
185             present in the stack, the new one will become the new C<self>, while
186             the old one will become an C<up> rel.
187              
188             Also, this will remove any existing navigation with the C<rel>
189             attribute set to C<subsection>, given that you are creating a new
190             level.
191              
192             This is designed to play well with chained actions (so you can reuse
193             the object, stack selfs, and the result will be correct).
194              
195             =head2 add_to_acquisitions(%args)
196              
197             Call C<create_acquisition> and add it to the C<acquisition> stack.
198              
199             =head1 INTERNAL METHODS
200              
201             =head2 navigation_entries
202              
203             Return a list of L<XML::OPDS::Navigation> objects excluding unique
204             relationships like C<self>, C<start>, C<up>, C<previous>, C<next>,
205             C<first>, C<last>.
206              
207             =head2 navigation_hash
208              
209             Return an hashref, where the keys are the C<rel> attributes of the
210             navigation objects. The value is an object if the navigation is meant
211             to be unique, or an arrayref of objects if not so.
212              
213             =head2 is_acquisition
214              
215             Return true if there are acquisition objects stacked.
216              
217             =cut
218              
219             has navigations => (is => 'rw',
220             isa => ArrayRef[InstanceOf['XML::OPDS::Navigation']],
221             default => sub { [] });
222             has acquisitions => (is => 'rw',
223             isa => ArrayRef[InstanceOf['XML::OPDS::Acquisition']],
224             default => sub { [] });
225             has author => (is => 'rw', isa => Str, default => sub { __PACKAGE__ . ' ' . $VERSION });
226             has author_uri => (is => 'rw', isa => Str, default => sub { 'http://amusewiki.org' });
227             has prefix => (is => 'rw', isa => Str, default => sub { '' });
228             has updated => (is => 'rw', isa => Object, default => sub { DateTime->now });
229             has icon => (is => 'rw', isa => Str, default => sub { '' });
230             has logo => (is => 'rw', isa => Str, default => sub { '' });
231              
232             has _dt_formatter => (is => 'ro', isa => Object,
233             default => sub { DateTime::Format::RFC3339->new });
234             has _fh => (is => 'ro',
235             isa => Object,
236             default => sub {
237             XML::Atom::Namespace->new(fh => 'http://purl.org/syndication/history/1.0');
238             });
239              
240             # opensearch accessors
241              
242             has _os => (is => 'ro',
243             isa => Object,
244             default => sub {
245             XML::Atom::Namespace->new(opensearch => 'http://a9.com/-/spec/opensearch/1.1/');
246             });
247              
248              
249             =head1 OPENSEARCH RESULTS
250              
251             The following attributes can be set if you are building an Atom
252             response for OpenSearch. See L<XML::OPDS::OpenSearch::Query> for a
253             concrete example.
254              
255             =head2 search_result_pager
256              
257             A L<Data::Page> object with the specification of the pages.
258              
259             =head2 search_result_terms
260              
261             A string with the query for which you are serving the results.
262              
263             =head2 search_result_queries
264              
265             Additional Query elements, should be an arrayref of
266             L<XML::OPDS::OpenSearch::Query> objects.
267              
268             =cut
269              
270             has search_result_pager => (is => 'rw',
271             isa => InstanceOf['Data::Page']);
272              
273             has search_result_terms => (is => 'rw',
274             isa => Str);
275              
276             has search_result_queries => (is => 'rw',
277             isa => ArrayRef[InstanceOf['XML::OPDS::OpenSearch::Query']],
278             default => sub { [] },
279             );
280              
281             sub navigation_entries {
282 0     0 1 0 my $self = shift;
283 0         0 my $hash = $self->navigation_hash;
284 0         0 my @others;
285 0         0 foreach my $k (sort keys %$hash) {
286 0         0 my $entries = $hash->{$k};
287             # exclude the uniques
288 0 0       0 if (ref($entries) eq 'ARRAY') {
289 0         0 push @others, @$entries;
290             }
291             }
292 0         0 return @others;
293             }
294              
295             sub navigation_hash {
296 19     19 1 33 my $self = shift;
297 19         436 my $navs = $self->navigations;
298 19 50 33     194 die "Missing navigations" unless $navs && @$navs;
299 19         32 my %out;
300 19         107 my %uniques = (
301             start => 1,
302             self => 1,
303             up => 1,
304             next => 1,
305             previous => 1,
306             first => 1,
307             last => 1,
308             search => 1,
309             crawlable => 1,
310             );
311 19         40 foreach my $nav (@$navs) {
312 50         764 my $rel = $nav->rel;
313             # uniques
314 50 100       290 if ($uniques{$rel}) {
315 47         100 $out{$rel} = $nav;
316             }
317             else {
318 3   100     14 $out{$rel} ||= [];
319 3         4 push @{$out{$rel}}, $nav;
  3         9  
320             }
321             }
322 19         55 return \%out;
323             }
324              
325             sub is_acquisition {
326 19 50   19 1 373 if (my $acquisitions = shift->acquisitions) {
327 19         199 return scalar(@$acquisitions);
328             }
329             else {
330 0         0 return 0;
331             }
332             }
333              
334             sub _is_paged {
335 17     17   26 my $self = shift;
336 17         29 my $partial = 0;
337 17         25 foreach my $nav (@{$self->navigations}) {
  17         260  
338 41 100       785 if ($nav->rel =~ m/\A(next|previous|first|last)\z/) {
339 2         12 $partial = 1;
340 2         4 last;
341             }
342             }
343 17         122 return $partial;
344             }
345              
346             sub atom {
347 19     19 1 1924 my $self = shift;
348 19         159 my $feed = XML::Atom::Feed->new(Version => 1.0);
349 19         2326 my $navs = $self->navigation_hash;
350 19         48 my $main = delete $navs->{self};
351 19 50       45 die "Missing self navigation element!" unless $main;
352 19         63 $feed->id($main->identifier);
353 19         3287 $feed->add_link($main->as_link);
354 19         707 my @nav_entries;
355 19         255 foreach my $rel (sort keys %$navs) {
356             # use only the unique
357 30         476 my $nav = delete $navs->{$rel};
358 30 100       71 if (ref($nav) eq 'ARRAY') {
359 2         7 push @nav_entries, @$nav;
360             }
361             else {
362 28         58 $feed->add_link($nav->as_link);
363             }
364             }
365 19         938 $feed->title($main->title);
366 19         2268 $feed->updated($self->_dt_formatter->format_datetime($main->updated));
367 19 100       5679 if (my $icon = $self->icon) {
368 2         45 $feed->icon($self->prefix . $icon);
369             }
370 19 100       574 if (my $logo = $self->logo) {
371 2         40 $feed->logo($self->prefix . $logo);
372             }
373 19 50       568 if (my $author_name = $self->author) {
374 19         206 my $author = XML::Atom::Person->new(Version => 1.0);
375 19         1296 $author->name($author_name);
376 19 50       2005 if (my $author_uri = $self->author_uri) {
377 19         161 $author->uri($author_uri);
378             }
379 19         1591 $feed->author($author);
380             }
381              
382             # opensearch element
383             # http://www.opensearch.org/Specifications/OpenSearch/1.1#OpenSearch_response_elements
384 19 100       3817 if (my $pager = $self->search_result_pager) {
385 13         128 $feed->set($self->_os, totalResults => $pager->total_entries);
386 13         1401 $feed->set($self->_os, startIndex => $pager->first);
387 13         2241 $feed->set($self->_os, itemsPerPage => $pager->entries_per_page);
388 13 100       1487 if (my $term = $self->search_result_terms) {
389 12         277 my $query = XML::OPDS::OpenSearch::Query->new(
390             role => 'request',
391             searchTerms => $term,
392             );
393 12         838 $feed->add($self->_os, Query => undef, $query->attributes_hashref);
394             }
395             }
396 19         1615 foreach my $query (@{ $self->search_result_queries }) {
  19         346  
397 12         102 $feed->add($self->_os, Query => undef, $query->attributes_hashref);
398             }
399 19 100       2106 if ($self->is_acquisition) {
400             # if it's an acquisition feed, stuff the links in the feed,
401             # but filter out the subsections. And probably other stuff as well.
402 17         40 foreach my $link (@nav_entries) {
403 0         0 my %rels = (related => 1, alternate => 1);
404 0 0       0 $feed->add_link($link->as_link) if $rels{$link->rel};
405             }
406 17 100       40 unless ($self->_is_paged) {
407 15         63 $feed->set($self->_fh, complete => undef);
408             }
409 17         1388 foreach my $entry (@{$self->acquisitions}) {
  17         314  
410 18         217 $feed->add_entry($entry->as_entry);
411             }
412             }
413             else {
414             # othewise use the links to create entries
415 2         6 foreach my $entry (@nav_entries) {
416 3         65 $feed->add_entry($entry->as_entry);
417             }
418             }
419 19         1146 return $feed;
420             }
421              
422             sub render {
423 16     16 1 14142 shift->atom->as_xml;
424             }
425              
426             sub create_navigation {
427 14     14 1 25 my $self = shift;
428 14         226 return XML::OPDS::Navigation->new(prefix => $self->prefix,
429             updated => $self->updated,
430             @_);
431             }
432              
433             sub add_to_navigations {
434 8     8 1 34 my $self = shift;
435 8         20 my $navigation = $self->create_navigation(@_);
436 8         800 push @{$self->navigations}, $navigation;
  8         131  
437 8         52 return $navigation;
438             }
439              
440             sub add_to_navigations_new_level {
441 6     6 1 2673 my $self = shift;
442 6         23 my $navigation = $self->create_navigation(rel => 'self', @_);
443             # turn the previous self in an "up" link.
444 6 50       978 if ($navigation->rel eq 'self') {
445             # new level, so remove the subsections
446 6         93 my @existing = grep { $_->rel ne 'subsection' } @{$self->navigations};
  5         143  
  6         101  
447             # promote the existing self to "up"
448 6         53 foreach my $previous (grep { $_->rel eq 'self' } @existing) {
  4         74  
449 1         17 $previous->rel('up');
450             }
451             # reset
452 6         130 $self->navigations(\@existing);
453             }
454 6         241 push @{$self->navigations}, $navigation;
  6         93  
455 6         40 return $navigation;
456             }
457              
458             sub create_acquisition {
459 5     5 1 9 my $self = shift;
460 5         99 return XML::OPDS::Acquisition->new(prefix => $self->prefix,
461             updated => $self->updated,
462             @_);
463             }
464              
465             sub add_to_acquisitions {
466 5     5 1 8289 my $self = shift;
467 5         19 my $acquisition = $self->create_acquisition(@_);
468 5         868 push @{$self->acquisitions}, $acquisition;
  5         103  
469 5         73 return $acquisition;
470             }
471              
472              
473             =head1 AUTHOR
474              
475             Marco Pessotto, C<< <melmothx at gmail.com> >>
476              
477             =head1 BUGS
478              
479             Please report any bugs or feature requests to C<bug-xml-opds at rt.cpan.org>, or through
480             the web interface at L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=XML-OPDS>. I will be notified, and then you'll
481             automatically be notified of progress on your bug as I make changes.
482              
483             =head1 SUPPORT
484              
485             You can find documentation for this module with the perldoc command.
486              
487             perldoc XML::OPDS
488              
489              
490             You can also look for information at:
491              
492             =over 4
493              
494             =item * RT: CPAN's request tracker (report bugs here)
495              
496             L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=XML-OPDS>
497              
498             =item * MetaCPAN
499              
500             L<http://metacpan.org/pod/XML::OPDS>
501              
502             =back
503              
504             =head1 LICENSE AND COPYRIGHT
505              
506             Copyright 2016 Marco Pessotto.
507              
508             This program is free software; you can redistribute it and/or modify it
509             under the terms of either: the GNU General Public License as published
510             by the Free Software Foundation; or the Artistic License.
511              
512             See L<http://dev.perl.org/licenses/> for more information.
513              
514              
515             =cut
516              
517             1; # End of XML::OPDS