File Coverage

blib/lib/Biblio/Refbase.pm
Criterion Covered Total %
statement 258 315 81.9
branch 75 132 56.8
condition 17 42 40.4
subroutine 58 60 96.6
pod 16 16 100.0
total 424 565 75.0


line stmt bran cond sub pod time code
1             package Biblio::Refbase;
2              
3 5     5   133905 use 5.006;
  5         22  
  5         212  
4              
5 5     5   33 use strict;
  5         11  
  5         184  
6 5     5   31 use warnings;
  5         15  
  5         314  
7              
8             our $VERSION = '0.04';
9              
10             $VERSION = eval $VERSION;
11              
12 5     5   30 use Carp;
  5         10  
  5         447  
13 5     5   934682 use HTTP::Request::Common;
  5         236677  
  5         596  
14 5     5   5199 use HTTP::Status ':constants';
  5         24621  
  5         3006  
15 5     5   6057 use LWP::UserAgent;
  5         510411  
  5         203  
16 5     5   65 use URI;
  5         11  
  5         139  
17 5     5   5478 use URI::QueryParam;
  5         7609  
  5         176  
18              
19 5     5   80 use constant REFBASE_LOGIN => 'user_login.php';
  5         10  
  5         3333  
20 5     5   40 use constant REFBASE_IMPORT => 'import_modify.php';
  5         10  
  5         290  
21 5     5   28 use constant REFBASE_SHOW => 'show.php';
  5         8  
  5         218  
22 5     5   25 use constant REFBASE_ERROR => 'error.php';
  5         9  
  5         342  
23              
24 5     5   56 use constant REFBASE_DEFAULT_URL => 'http://localhost/';
  5         9  
  5         226  
25 5     5   26 use constant REFBASE_DEFAULT_USER => 'user@refbase.net';
  5         8  
  5         239  
26 5     5   30 use constant REFBASE_DEFAULT_PASSWORD => 'start';
  5         9  
  5         218  
27 5     5   25 use constant REFBASE_DEFAULT_RELOGIN => 1;
  5         9  
  5         235  
28 5     5   24 use constant REFBASE_DEFAULT_FORMAT => 'ASCII';
  5         9  
  5         205  
29              
30 5     5   26 use constant REFBASE_MSG_NO_HITS => 'Nothing found';
  5         10  
  5         204  
31 5     5   26 use constant REFBASE_MSG_FORBIDDEN => 'you have no permission';
  5         10  
  5         292  
32 5     5   32 use constant REFBASE_MSG_ERROR => 'The following error occurred';
  5         7  
  5         198  
33 5     5   25 use constant REFBASE_MSG_QUERY_ERROR => 'Your query:';
  5         9  
  5         345  
34              
35 5         351 use constant REFBASE_EXPORT_FORMATS => (
36             'ADS',
37             'Atom XML',
38             'BibTeX',
39             'Endnote',
40             'ISI',
41             'MODS XML',
42             'OAI_DC XML',
43             'ODF XML',
44             'RIS',
45             'SRW_DC XML',
46             'SRW_MODS XML',
47             'Word XML',
48 5     5   27 );
  5         9  
49              
50 5         363 use constant REFBASE_CITATION_FORMATS => (
51             'ASCII',
52             'HTML',
53             'LaTeX',
54             'LaTeX .bbl',
55             'Markdown',
56             'PDF',
57             'RTF',
58 5     5   32 );
  5         10  
59              
60 5         427 use constant REFBASE_CITATION_STYLES => (
61             'APA',
62             'AMA',
63             'MLA',
64             'Chicago',
65             'Harvard 1',
66             'Harvard 2',
67             'Harvard 3',
68             'Vancouver',
69             'Ann Glaciol',
70             'Deep Sea Res',
71             'J Glaciol',
72             'Mar Biol',
73             'MEPS',
74             'Polar Biol',
75             'Text Citation',
76 5     5   26 );
  5         8  
77              
78 5         20286 use constant REFBASE_QUERY_PARAMS => (
79             'author',
80             'title',
81             'type',
82             'year',
83             'publication',
84             'abbrev_journal',
85             'keywords',
86             'abstract',
87             'thesis',
88             'area',
89             'notes',
90             'location',
91             'serial',
92             'date',
93             'contribution_id',
94             'where',
95 5     5   28 );
  5         95  
96              
97              
98              
99             #
100             # constructor
101             #
102              
103             sub new {
104 3     3 1 225 my $class = shift;
105 3 50       19 unshift @_, 'url' if @_ % 2;
106 3         16 my %conf = @_;
107              
108 3         11 my $self = bless {}, $class;
109              
110 3         9 for (qw'url user password relogin format style order rows records ua') {
111 30 100       92 if (defined(my $value = delete $conf{$_})) {
112 3         12 $self->$_($value);
113             }
114             }
115              
116 3         293 my $version = eval '$' . $class . '::VERSION';
117 3         30 my $client = 'cli-' . join '-', split /::/, $class;
118 3 50       61 $client .= '-' . $version if defined $version;
119 3         17 $self->{_client} = $client;
120              
121 3 50       17 unless ($self->ua) {
122 3 50       13 unless (defined $conf{agent}) {
123 3         8 my $agent = $class;
124 3 50       22 $agent .= ' ' . $version if defined $version;
125 3         11 $conf{agent} = $agent;
126             }
127 3 50       17 $conf{env_proxy} = 1 unless exists $conf{env_proxy};
128 3         42 $self->ua(LWP::UserAgent->new(%conf));
129             }
130              
131 3         29 return $self;
132             }
133              
134              
135              
136             #
137             # accessors
138             #
139              
140             sub url {
141 13     13 1 2958 shift->_accessor('url', @_);
142             }
143              
144             sub user {
145 12     12 1 3049 shift->_accessor('user', @_);
146             }
147              
148             sub password {
149 13     13 1 2947 shift->_accessor('password', @_);
150             }
151              
152             sub relogin {
153 12     12 1 7287 shift->_accessor('relogin', @_);
154             }
155              
156             sub format {
157 11     11 1 3652 my $self = shift;
158 11 100       37 if (@_) {
159 3         6 my $format = shift;
160 3         8 _check_format($format);
161 2         4 $self->{format} = $format;
162 2         7 return $self;
163             }
164 8         83 $self->{format};
165             }
166              
167             sub style {
168 11     11 1 2596 my $self = shift;
169 11 100       35 if (@_) {
170 3         6 my $style = shift;
171 3         7 _check_style($style);
172 2         4 $self->{style} = $style;
173 2         8 return $self;
174             }
175 8         43 $self->{style};
176             }
177              
178             sub order {
179 10     10 1 3091 shift->_accessor('order', @_);
180             }
181              
182             sub rows {
183 9     9 1 3448 shift->_accessor('rows', @_);
184             }
185              
186             sub records {
187 10     10 1 12321 shift->_accessor('records', @_);
188             }
189              
190             sub ua {
191 16     16 1 70478 my $self = shift;
192 16 100       70 if (@_) {
193 6         11 my $ua = shift;
194 6 100 66     254 croak q{Accessor 'ua' requires an object based on 'LWP::UserAgent'}
195             unless ref $ua and $ua->isa('LWP::UserAgent');
196 4         16 $self->{ua} = $ua;
197 4         48 return $self;
198             }
199 10         139 $self->{ua};
200             }
201              
202              
203              
204             #
205             # public instance methods
206             #
207              
208             sub search {
209 3     3 1 1120137 my $self = shift;
210 3         13 my %args = @_;
211              
212 3         15 my $account = $self->_account_args(\%args);
213 3         15 my $search = $self->_search_args(\%args);
214              
215 3 100       55 if (%args) {
216 1         276 croak q{Unknown arguments provided to 'search' method:}
217             . join("\n ", '', sort keys %args) . "\n";
218             }
219              
220 2         9 return $self->_search($account, $search);
221             }
222              
223             sub upload {
224 4     4 1 9124 my $self = shift;
225 4 100       125 unshift @_, 'content' if @_ % 2;
226 4         43 my %args = @_;
227              
228 4         15 my $account = $self->_account_args(\%args);
229 4         17 my $upload = $self->_upload_args(\%args);
230              
231 3         8 my $show = delete $args{show};
232 3 50       96 $show = %args unless defined $show;
233              
234 3         14 my $search = $self->_search_args(\%args);
235              
236 3 50       12 if (%args) {
237 3         439 croak q{Unknown arguments provided to 'upload' method:}
238             . join("\n ", '', sort keys %args) . "\n";
239             }
240              
241 0         0 my $url = $account->{url} . REFBASE_IMPORT;
242              
243 0 0       0 my $request = $upload->{uploadFile}
244             ? POST $url, Content_Type => 'form-data', Content => $upload
245             : POST $url, $upload;
246              
247 0         0 my $response = $self->_request($account, $request);
248              
249 0 0       0 unless ($response->is_error) {
250 0 0       0 if (defined(my $location = $response->header('location'))) {
  0 0       0  
251 0 0       0 if ($location =~ /^(${\REFBASE_SHOW}\?)/o) {
  0         0  
252 0         0 my $q = URI->new($location)->query_form_hash;
253 0   0     0 my ($rows) = ($q->{headerMsg} || '') =~ /(\d+)/;
254 0   0     0 my $records = $q->{records} || '';
255 0 0       0 if ($show) {
256 0   0     0 $search->{records} ||= $q->{records};
257 0   0     0 $search->{rows} ||= $rows;
258 0         0 $response = $self->_search($account, $search);
259             }
260             else {
261 0 0       0 my $content = $q->{headerMsg} ? $q->{headerMsg} . ' ' : '';
262 0 0       0 $content .= $q->{records} if $q->{records};
263 0         0 $response->code(HTTP_OK);
264 0         0 $response->message('');
265 0         0 $response->content($content);
266             }
267 0         0 $response->records($records)->rows($rows);
268             }
269             else {
270 0         0 $response->code(HTTP_NOT_IMPLEMENTED);
271 0         0 $response->message('Unexpected redirect location');
272             }
273             }
274             elsif (index(${$response->content_ref}, scalar REFBASE_MSG_FORBIDDEN) == 0) {
275 0         0 $response->code(HTTP_FORBIDDEN);
276 0         0 $response->message('');
277             }
278             else {
279 0         0 $response->code(HTTP_NOT_IMPLEMENTED);
280 0         0 $response->message('Unexpected response');
281             }
282             }
283 0         0 return $response
284             }
285              
286             sub ping {
287 1     1 1 975 my $self = shift;
288 1 50       7 unshift @_, 'url' if @_ % 2;
289              
290             # use 'simple_request' instead of 'head' so redirections won't be followed
291             # thus a redirection (e.g. to error page) will fail, too
292 1         3 return $self->ua->simple_request(
293             HEAD $self->_account_args({ @_ })->{url}
294             )->is_success;
295             }
296              
297              
298              
299             #
300             # public static methods
301             #
302              
303             sub formats {
304 3     3 1 2083 return sort +REFBASE_CITATION_FORMATS, REFBASE_EXPORT_FORMATS;
305             }
306              
307             sub styles {
308 3     3 1 1817 return sort +REFBASE_CITATION_STYLES;
309             }
310              
311              
312              
313             #
314             # static fields and helper functions
315             #
316              
317             # format and style mappings/parameters in static fields
318              
319             my %_formats = map {
320             _normalize_format_name($_) => {
321             exportFormat => $_,
322             submit => 'Export',
323             exportType => 'file'
324             }
325             } REFBASE_EXPORT_FORMATS;
326              
327             for (REFBASE_CITATION_FORMATS) {
328             $_formats{_normalize_format_name($_)} = {
329             citeType => $_ ,
330             submit => 'Cite'
331             };
332             }
333              
334             my %_styles = map {
335             _normalize_style_name($_) => $_
336             } REFBASE_CITATION_STYLES;
337              
338             # storage for user sessions
339              
340             my %_sessions;
341              
342             # normalization functions for format and style names
343              
344             sub _normalize_format_name {
345 103     103   316 (my $name = lc shift) =~ s/\s+xml$//;
346 103         655 $name =~ s/ \./_/;
347 103         538 return $name;
348             }
349              
350             sub _normalize_style_name {
351 77     77   297 (my $name = lc shift) =~ s/\s+//g;
352 77         268 return $name;
353             }
354              
355             # functions for checking format and style and getting parameters
356              
357             sub _check_format {
358 9     9   14 my $name = shift;
359 9 100 66     112 return unless defined $name and length $name;
360 8 100       66 if (defined(my $format = $_formats{_normalize_format_name($name)})) {
361 7         23 return $format;
362             }
363 1         6 croak "Format '$name' not available.\n"
364             . 'Available formats:'
365             . join("\n ", '', formats()) . "\n";
366             }
367              
368             sub _check_style {
369 9     9   17 my $name = shift;
370 9 100 66     173 return unless defined $name and length $name;
371 2 100       12 if (defined(my $style = $_styles{_normalize_style_name($name)})) {
372 1         2 return $style;
373             }
374 1         6 croak "Citation style '$name' not available.\n"
375             . 'Available styles:'
376             . join("\n ", '', styles()) . "\n";
377             }
378              
379              
380              
381             #
382             # private instance methods
383             #
384              
385             # accessor helper method
386              
387             sub _accessor {
388 79     79   180 my $self = shift;
389 79         106 my $field = shift;
390 79 100       281 if (@_) {
391 17         43 $self->{$field} = shift;
392 17         56 return $self;
393             }
394 62         460 return $self->{$field};
395             }
396              
397             # perform a search query
398              
399             sub _search {
400 2     2   3 my ($self, $account, $param) = @_;
401              
402 2         16 my $request = POST $account->{url} . REFBASE_SHOW, $param;
403 2         2372 my $response = $self->_request($account, $request, 1);
404              
405 2 100       28 if ($response->is_success) {
406             # idea: could parse number of hits from content when using ASCII format
407             # or explicitly re-send query with format=ASCII and rows=1
408             # then set $response->rows, too!
409 1 50       21 $response->hits(index(${$response->content_ref}, scalar REFBASE_MSG_NO_HITS) == 0 ? 0 : 1);
  1         9  
410             }
411 2         47 return $response;
412             }
413              
414             # HTTP request and error handling
415              
416             sub _request {
417 3     3   191 my ($self, $account, $request, $redirect) = @_;
418 3         9 my $relogin = $account->{relogin};
419 3         6 my $failed = 0;
420             {
421             # catch possible login failure
422 3         7 eval { $request->header( cookie => $self->_session($account) ) };
  3         8  
  3         32  
423 3 100       137 my $response = $@ ? $@ : $self->ua->simple_request($request);
424              
425 3 100       676024 if (defined(my $location = $response->header('location'))) {
    100          
426 1 50       47 if ($location =~ /^${\REFBASE_LOGIN}(\?|$)/o) {
  1 50       44  
    50          
427             # handle redirection to login page
428              
429             # undefine stored session string
430 0         0 $self->_session($account, undef);
431              
432 0 0       0 if ($relogin > $failed++) {
    0          
433 0         0 redo;
434             }
435             elsif ($relogin < 1) {
436 0         0 $response->code(HTTP_REQUEST_TIMEOUT);
437 0         0 $response->message('Relogin required but disabled');
438             }
439             else {
440 0         0 $response->code(HTTP_UNAUTHORIZED);
441 0         0 $response->message("Relogin failed (tried $relogin times)");
442             }
443             }
444 1         26 elsif ($location =~ /^${\REFBASE_ERROR}(\?|$)/o) {
445             # turn redirection to error page into HTTP error
446 0         0 my $q = URI->new($location)->query_form_hash;
447 0 0       0 my $content = $q->{headerMsg} ? $q->{headerMsg} . ' ' : '';
448 0 0       0 $content .= $q->{errorMsg} if $q->{errorMsg};
449 0         0 $response->code(HTTP_INTERNAL_SERVER_ERROR);
450 0         0 $response->message('');
451 0         0 $response->content($content);
452             }
453             elsif ($redirect) {
454             # follow the redirection with redirect count subtracted by 1
455 1         11 return $self->_request($account, GET($account->{url} . $location), $redirect - 1);
456             }
457             }
458             elsif ($response->is_success) {
459 1 50       205 if (index(${$response->content_ref}, scalar REFBASE_MSG_ERROR) == 0) {
  1 50       13  
  1         27  
460             # inconsistency in refbase: POST to show.php in search method
461             # does not return redirection to error.php when MySQL database fails
462 0         0 $response->code(HTTP_INTERNAL_SERVER_ERROR);
463 0         0 $response->message('');
464             }
465             elsif (index(${$response->content_ref}, scalar REFBASE_MSG_QUERY_ERROR) == 0) {
466             # inconsistency in refbase: GET from search.php (redirected by a POST)
467             # does not return redirection to error.php when SQL query is broken
468 0         0 $response->code(HTTP_BAD_REQUEST);
469 0         0 $response->message('');
470             }
471             }
472 2         102 return bless $response, 'Biblio::Refbase::Response';
473             }
474             }
475              
476             # user authentication, session handling and relogin
477              
478             sub _session {
479 3     3   5 my $self = shift;
480 3         6 my $account = shift;
481 3         8 my $url = $account->{url};
482 3         7 my $user = $account->{user};
483 3 50       12 if (@_) {
484 0         0 $_sessions{$url}->{$user} = shift;
485 0         0 return $self;
486             }
487 3 100       18 unless ($_sessions{$url}->{$user}) {
488 2         13 my $response = $self->ua->simple_request(POST $url . REFBASE_LOGIN, {
489             loginEmail => $user,
490             loginPassword => $account->{password},
491             });
492 2 100 66     263139 if ($response->is_redirect and my $cookie = $response->header('set-cookie')) {
493 1         77 $_sessions{$url}->{$user} = (split /;/, $cookie)[0];
494             }
495             else {
496 1 50       23 unless ($response->is_error) {
497 1         18 $response->code(HTTP_UNAUTHORIZED);
498 1         18 $response->message('Login request denied');
499             # wipe out HTML page
500 1         26 $response->content('');
501             }
502             # raise an exception
503 1         30 die $response;
504             }
505             }
506 2         19 return $_sessions{$url}->{$user};
507             }
508              
509             # setup account configuration from arguments hash, dynamic and static defaults
510              
511             sub _account_args {
512 8     8   21 my ($self, $args) = @_;
513              
514 8   50     53 my $url = delete $args->{url} || $self->url || REFBASE_DEFAULT_URL;
515 8 50       31 $url .= '/' if substr($url, -1) ne '/';
516 8 50       43 $url = 'http://' . $url unless $url =~ m{^https?://};
517              
518 8         17 my $relogin = delete $args->{relogin};
519 8 50       34 $relogin = $self->relogin unless defined $relogin;
520 8 50 33     35 $relogin = defined $relogin && $relogin =~ /(\d+)/ ? int $1 : REFBASE_DEFAULT_RELOGIN;
521              
522             return {
523 8   50     46 url => $url,
      50        
524             user => delete $args->{user} || $self->user || REFBASE_DEFAULT_USER,
525             password => delete $args->{password} || $self->password || REFBASE_DEFAULT_PASSWORD,
526             relogin => $relogin,
527             };
528             }
529              
530             # mapping of module's argument names to refbase names
531              
532             my %_names = (
533             records => 'records',
534             order => 'citeOrder',
535             rows => 'showRows',
536             start => 'startRecord',
537             query => 'queryType',
538             view => 'viewType',
539             );
540              
541             # setup search parameters from arguments hash, dynamic and static defaults
542              
543             sub _search_args {
544 6     6   11 my ($self, $args) = @_;
545 6         12 my %param;
546              
547 6         48 for (REFBASE_QUERY_PARAMS) {
548 96 50       407 if (defined(my $value = delete $args->{$_})) {
549 0         0 $param{$_} = $value;
550             }
551             }
552 6 50       104 $param{serial} = '.+' unless %param;
553              
554 6         24 my $format = $self->_format(delete $args->{format});
555 6         42 @param{keys %$format} = values %$format;
556              
557 6 50 33     31 if (not exists $args->{style} or defined(my $style = delete $args->{style})) {
558 6 50       23 if (defined($style = $self->_style($style))) {
559 0         0 $param{citeStyle} = $style;
560             }
561             }
562              
563 6         16 for (qw'records order rows') {
564 18 100 66     96 if (my $value = delete $args->{$_} || $self->$_) {
565 1         5 $param{$_names{$_}} = $value;
566             }
567             }
568 6         17 for (qw'start query view') {
569 18 50       60 if (my $value = delete $args->{$_}) {
570 0         0 $param{$_names{$_}} = $value;
571             }
572             }
573              
574 6 50       25 if (delete $args->{showquery}) {
575 0         0 $param{showquery} = 1;
576             }
577 6 50       18 if (defined(my $showlinks = delete $args->{showLinks})) {
578 0 0       0 $param{showLinks} = 0 if $showlinks eq '0';
579             }
580 6         17 $param{client} = $self->{_client};
581              
582 6         21 return \%param;
583             }
584              
585             # setup upload parameters from arguments hash, dynamic and static defaults
586              
587             sub _upload_args {
588 4     4   8 my ($self, $args) = @_;
589 4         7 my %param;
590              
591 4 100       23 if (defined(my $content = delete $args->{content})) {
    100          
592 2         19 $param{uploadFile} = [ undef, 'filename', Content => $content ];
593 2         7 $param{formType} = 'import';
594             }
595             elsif (defined(my $source_ids = delete $args->{source_ids})) {
596 1 50       5 $param{sourceIDs} = ref $source_ids eq 'ARRAY'
597             ? join ' ', @$source_ids
598             : $source_ids;
599 1         3 $param{formType} = 'importID';
600             }
601             else {
602 1         264 croak q{upload requires either record content supplied by parameter 'content' or }
603             . q{a list of record IDs in parameter 'source_ids'};
604             }
605 3 50       12 if (delete $args->{skipbad}) {
606 0         0 $param{skipBadRecords} = 1;
607             }
608 3 50       11 if (defined(my $only = delete $args->{only})) {
609 0         0 $param{importRecords} = $only;
610 0         0 $param{importRecordsRadio} = 'only';
611             }
612 3         10 $param{client} = $self->{_client};
613              
614 3         11 return \%param;
615             }
616              
617             # get the format and style parameters
618              
619             sub _format {
620 6     6   15 my ($self, $name) = @_;
621 6   50     45 return _check_format($name || $self->format || REFBASE_DEFAULT_FORMAT);
622             }
623              
624             sub _style {
625 6     6   15 my ($self, $name) = @_;
626 6   33     31 return _check_style($name || $self->style);
627             }
628              
629              
630              
631             # extension to HTTP::Response
632              
633             package Biblio::Refbase::Response;
634              
635             # todo: investigation required on adding a DESTROY method
636              
637 5     5   98 use base 'HTTP::Response';
  5         11  
  5         1561  
638              
639             sub _accessor {
640 2     2   7 my $self = shift;
641 2         7 my $field = '_BRR_' . shift;
642 2 100       23 if (@_) {
643 1         4 $self->{$field} = shift;
644 1         4 return $self;
645             }
646 1         6 return $self->{$field};
647             }
648              
649 2     2   10940 sub hits { shift->_accessor('hits', @_) }
650              
651 0     0     sub rows { shift->_accessor('rows', @_) }
652              
653 0     0     sub records { shift->_accessor('records', @_) }
654              
655              
656              
657             1;
658              
659             __END__