File Coverage

blib/lib/Mediawiki/Blame.pm
Criterion Covered Total %
statement 46 48 95.8
branch n/a
condition n/a
subroutine 16 16 100.0
pod n/a
total 62 64 96.8


line stmt bran cond sub pod time code
1             package Mediawiki::Blame;
2 2     2   47672 use 5.008;
  2         9  
  2         89  
3 2     2   2477 use utf8;
  2         25  
  2         14  
4 2     2   72 use strict;
  2         9  
  2         79  
5 2     2   11 use warnings;
  2         4  
  2         82  
6 2     2   1846 use Algorithm::Annotate qw();
  2         24535  
  2         55  
7 2     2   26 use Carp qw(croak);
  2         3  
  2         2650  
8 2     2   5743 use Class::Spiffy qw(-base field const);
  2         20239  
  2         22  
9 2     2   4290 use DateTime qw();
  2         691249  
  2         92  
10 2     2   2776 use DateTime::Format::ISO8601 qw();
  2         143259  
  2         330  
11 2     2   4721 use LWP::UserAgent qw();
  2         203176  
  2         69  
12 2     2   6150 use Mediawiki::Blame::Revision qw();
  2         9  
  2         53  
13 2     2   1486 use Mediawiki::Blame::Line qw();
  2         5  
  2         52  
14 2     2   14 use Params::Validate qw(validate_with SCALAR);
  2         4  
  2         143  
15 2     2   42884 use Regexp::Common qw(number URI);
  2         15400  
  2         14  
16 2     2   121683 use Readonly qw(Readonly);
  2         13408  
  2         269  
17 2     2   1250 use XML::Twig qw();
  0            
  0            
18             our $VERSION = '0.0.3';
19              
20             field 'export';
21             field 'page';
22             field 'ua_timeout';
23             field '_revisions'; # hashref whose keys are r_ids and values are hashrefs
24             field '_initial'; # r_id of the initial revision
25             field '_lwp'; # LWP instance
26              
27             sub new {
28             my $class = shift;
29             my $self = {};
30             bless $self, $class;
31              
32             validate_with(
33             params => \@_,
34             on_fail => sub {
35             chomp (my $p = shift);
36             croak $p;
37             },
38             spec => {
39             export => {
40             regex => qr/\A $RE{URI} \z/msx
41             },
42             page => {
43             type => SCALAR,
44             },
45             },
46             );
47              
48             my %P = @_; # params as hash
49              
50             $self->export($P{export});
51             $self->page($P{page});
52              
53             {
54             my $lwp_name;
55             eval q{
56             use LWPx::ParanoidAgent qw();
57             };
58             if ($@) {
59             $lwp_name = 'LWP::UserAgent';
60             } else {
61             $lwp_name = 'LWPx::ParanoidAgent';
62             };
63              
64             $self->_lwp($lwp_name->new);
65             $self->_lwp->agent(
66             "Mediawiki::Blame/$VERSION (http://search.cpan.org/dist/Mediawiki-Blame/)"
67             );
68             push @{ $self->_lwp->requests_redirectable }, 'POST';
69             };
70              
71             $self->ua_timeout(30); # seconds
72             $self->_revisions({});
73              
74             $self->_xml_to_revisions(
75             $self->_post(
76             $self->_post_params({
77             after => 1980, # one revision after 1980, i.e. the initial
78             limit => 1,
79             })
80             )
81             );
82              
83             $self->_initial(
84             [$self->revisions]->[0]->r_id
85             );
86              
87             $self->_revisions({}); # reset
88              
89             return $self;
90             };
91              
92             sub _is_now_or_a_datetime {
93             my $p = shift;
94             if ($p eq 'now') {
95             return 1;
96             };
97             _is_a_datetime($p);
98             return 1;
99             };
100              
101             sub _is_a_datetime {
102             eval {
103             DateTime::Format::ISO8601->parse_datetime(shift)
104             };
105             if ($@) {
106             croak substr $@, 0, (index $@, ' at '); # clean up stacktrace
107             };
108             return 1;
109             };
110              
111             sub _is_greater_or_equal_to_2 {
112             my $p = shift;
113             return ($p =~ /\A $RE{num}{int} \z/msx and $p >= 2);
114             };
115              
116             sub _offset {
117             my $self = shift;
118             my $P = shift; # hashref
119              
120             for my $k ('before', 'after') {
121             if (exists $P->{$k}) {
122             Readonly my $STRF => '%FT%TZ'; # 2007-07-23T21:43:56Z
123             if (($k eq 'before') and ($P->{$k} eq 'now')) {
124             return DateTime->now->strftime($STRF);
125             };
126             return DateTime::Format::ISO8601
127             ->parse_datetime($P->{$k})
128             ->strftime($STRF);
129             };
130             };
131             };
132              
133             sub _post_params {
134             my $self = shift;
135             my $P = shift; # hashref
136              
137             my $offset = $self->_offset($P);
138              
139             my %post_params = (
140             pages => $self->page,
141             offset => $offset,
142             );
143              
144             if (exists $P->{before}) {
145             $post_params{dir} = 'desc';
146             };
147              
148             if (exists $P->{limit}) {
149             $post_params{limit} = $P->{limit};
150             };
151              
152             return \%post_params;
153             };
154              
155             sub fetch {
156             my $self = shift;
157              
158             validate_with(
159             params => \@_,
160             on_fail => sub {
161             chomp (my $p = shift);
162             croak $p;
163             },
164             spec => {
165             before => {
166             optional => 1,
167             callbacks => {
168             'is now or a datetime' => \&_is_now_or_a_datetime,
169             },
170             },
171             after => {
172             optional => 1,
173             callbacks => {
174             'is a datetime' => \&_is_a_datetime,
175             },
176             },
177             limit => {
178             optional => 1,
179             callbacks => {
180             'is greater or equal to 2' => \&_is_greater_or_equal_to_2,
181             },
182             },
183             },
184             );
185              
186             my %P = @_; # params as hash
187              
188             if (exists $P{before} and exists $P{after}) {
189             croak 'before and after mutually exclusive';
190             };
191              
192             if (!exists $P{before} and !exists $P{after}) {
193             croak 'either before or after needed';
194             };
195              
196             my ($revision_counter, $revision_duplicates)
197             = $self->_xml_to_revisions(
198             $self->_post(
199             $self->_post_params(\%P)
200             )
201             );
202              
203             return ($revision_counter, $revision_duplicates);
204             };
205              
206             sub _xml_to_revisions {
207             my $self = shift;
208             my $xml = shift;
209              
210             my $revision_counter = 0;
211             my $revision_duplicates = 0;
212              
213             eval {
214             XML::Twig->new(twig_handlers => {'revision' => sub {
215             my $twig = shift;
216             my $elt = shift;
217              
218             $revision_counter++;
219              
220             my $r_id = $elt->first_child_text('id');
221              
222             if (exists $self->_revisions->{$r_id}) {
223             $revision_duplicates++;
224             } else {
225             my $contrib_node = $elt->first_child('contributor');
226              
227             my $contributor;
228             if ($contrib_node->first_child_text('username')) {
229             $contributor
230             = $contrib_node->first_child_text('username');
231             } else {
232             $contributor
233             = $contrib_node->first_child_text('ip');
234             };
235              
236             $self->_revisions->{$elt->first_child_text('id')} = [
237             $elt->first_child_text('timestamp'),
238             $contributor,
239             [
240             split /(?<=\n)/, # at line breaks, but don't remove
241             $elt->first_child_text('text')
242             ],
243             ];
244             };
245             $twig->purge;
246             }})->parse($xml)->purge
247             };
248              
249             if ($@) {
250             # XML::Parser dies, not croaks with some especially dirty error message,
251             # so I have to do a good scrubbing
252             my $e = $@;
253             $e = substr $e, 1; # remove leading "\n"
254              
255             croak 'XML parsing failed: '
256             . substr $e, 0, ( # clean up stacktrace
257             index $e, ' at ', 1+( # next ' at ' (discard at this position)
258             index $e, ' at ' # first ' at ' (keep it)
259             )
260             );
261             };
262              
263             return ($revision_counter, $revision_duplicates);
264             };
265              
266             sub _post {
267             my $self = shift;
268             my $post_params = shift; # hashref
269              
270             $self->_lwp->timeout($self->ua_timeout);
271              
272             my $response = $self->_lwp->post($self->export, $post_params);
273             if (not $response->is_success) {
274             croak 'POST request to ' . $self->export . ' failed: '
275             . $response->status_line;
276             };
277              
278             return $response->decoded_content;
279             };
280              
281             sub revisions {
282             my $self = shift;
283              
284             my @r;
285             foreach my $r_id (sort {$a <=> $b} keys %{ $self->_revisions }) {
286             push @r, Mediawiki::Blame::Revision->_new(
287             $r_id,
288             @{ $self->_revisions->{$r_id} } # 3 elements
289             );
290             };
291              
292             return @r;
293             };
294              
295             sub blame {
296             my $self = shift;
297              
298             validate_with(
299             params => \@_,
300             on_fail => sub {
301             chomp (my $p = shift);
302             croak $p;
303             },
304             spec => {
305             revision => {
306             optional => 1,
307             callbacks => {
308             'is a valid r_id' => sub {
309             return exists $self->_revisions->{shift()};
310             },
311             },
312             },
313             },
314             );
315              
316             my %P = @_; # params as hash
317              
318             my @r_ids = sort {$a <=> $b} keys %{ $self->_revisions };
319             my $last_r_id;
320             if ($P{revision}) {
321             $last_r_id = $P{revision};
322             } else {
323             $last_r_id = $r_ids[-1];
324             };
325              
326             my $ann = Algorithm::Annotate->new;
327             for my $r_id (grep {$_ <= $last_r_id} @r_ids) {
328             $ann->add(
329             $r_id,
330             $self->_revisions->{$r_id}[2] # text
331             );
332             };
333              
334             my @last_revision_text = @{ $self->_revisions->{$last_r_id}[2] };
335             my $first_revision = $r_ids[0];
336              
337             return map {
338             my $id = $ann->result->[$_];
339             if ($id == $first_revision and $id != $self->_initial) {
340             Mediawiki::Blame::Line->_new(
341             undef,
342             $self->_revisions->{$id}->[0],
343             undef,
344             $last_revision_text[$_],
345             );
346             } else {
347             Mediawiki::Blame::Line->_new(
348             $id,
349             $self->_revisions->{$id}->[0],
350             $self->_revisions->{$id}->[1],
351             $last_revision_text[$_],
352             );
353             };
354             } 0..$#last_revision_text;
355             };
356              
357             1;
358             __END__