File Coverage

blib/lib/DateTimeX/ISO8601/Interval.pm
Criterion Covered Total %
statement 220 230 95.6
branch 124 146 84.9
condition 46 61 75.4
subroutine 25 25 100.0
pod 11 11 100.0
total 426 473 90.0


line stmt bran cond sub pod time code
1             package DateTimeX::ISO8601::Interval;
2             BEGIN {
3 5     5   89580 $DateTimeX::ISO8601::Interval::AUTHORITY = 'cpan:BPHILLIPS';
4             }
5             $DateTimeX::ISO8601::Interval::VERSION = '0.003';
6             # ABSTRACT: Provides a means of parsing and manipulating ISO-8601 intervals and durations.
7              
8 5     5   41 use strict;
  5         7  
  5         178  
9 5     5   25 use warnings;
  5         7  
  5         173  
10 5     5   3428 use DateTime::Format::ISO8601;
  5         914318  
  5         285  
11 5     5   54 use DateTime::Duration;
  5         7  
  5         111  
12 5     5   19 use Params::Validate qw(:all);
  5         6  
  5         975  
13 5     5   27 use Carp qw(croak);
  5         8  
  5         321  
14             use overload (
15 57     57   1595 '""' => sub { shift->format }
16 5     5   21 );
  5         7  
  5         52  
17              
18             my $REPEAT = qr{R(\d*)};
19             my $UNIT = qr{(?:\d+)};
20             my $DURATION = qr[
21             P
22             (?:
23             (?:(${UNIT})Y)?
24             (?:(${UNIT})M)?
25             (?:(${UNIT})W)?
26             (?:(${UNIT})D)?
27             )
28             (?:
29             T
30             (?:(${UNIT})H)?
31             (?:(${UNIT})M)?
32             (?:(${UNIT})S)?
33             )?
34             ]x;
35              
36             sub _determine_precision {
37 47     47   77 my($date, $duration) = @_;
38 47 100 100     323 return $date =~ m{T} ? 'time' : ($duration && !$duration->clock_duration->is_zero ? 'time' : 'date');
    100          
39             }
40              
41              
42             sub parse {
43 49     49 1 41037 my $class = shift;
44 49         80 my $interval = shift;
45 49         99 my %args = @_;
46              
47 49 100       517 my $input = $interval or croak "Nothing found to parse";
48              
49 47 100       242 if($interval =~ s{^R(\d*)/}{}){
50 12 100       70 $args{repeat} = $1 ne '' ? $1 : -1;
51             }
52 47         227 my $parser = DateTime::Format::ISO8601->new;
53 47 100       3535 if($interval =~ s{^$DURATION/}{}){
    100          
    100          
    100          
54 1         7 $args{duration} = _duration_from_matches([$1,$2,$3,$4,$5,$6,$7], %args);
55 1         68 $args{precision} = _determine_precision($interval, $args{duration});
56 1         3 $args{end} = $parser->parse_datetime($interval);
57             } elsif($interval =~ s{/$DURATION$}{}){
58 15         113 $args{duration} = _duration_from_matches([$1,$2,$3,$4,$5,$6,$7], %args);
59 15         1256 $args{precision} = _determine_precision($interval, $args{duration});
60 15         1170 $args{start} = $parser->parse_datetime($interval);
61             } elsif($interval =~ m{^$DURATION$}){
62 5         42 $args{duration} = _duration_from_matches([$1,$2,$3,$4,$5,$6,$7], %args);
63             } elsif($interval =~ m{^(.+?)(?:--|/)(.+?)$}){
64 25         103 $args{start} = $parser->parse_datetime($1);
65 25         8737 $parser->set_base_datetime(object => $args{start});
66 25         22515 my $end = substr($1,0,length($2) * -1) . $2;
67 25         75 $args{precision} = _determine_precision($end);
68 25         93 $args{end} = $parser->parse_datetime($end);
69             }
70 47 100 66     14112 if(!$args{start} && !$args{end} && !$args{duration}){
      66        
71 1         163 croak "Invalid interval: $input";
72             }
73 46 100       1597 if($args{time_zone}){
74 10 100       52 if(DateTime::TimeZone->is_valid_name($args{time_zone})){
75 9         114776 for my $d (grep { defined } @args{'start','end'}) {
  18         41  
76 11         657 $d->set_time_zone($args{time_zone})
77             }
78             } else {
79 1         192 croak "Invalid time_zone: $args{time_zone}";
80             }
81             }
82 45         2963 delete @args{grep { !defined $args{$_} } keys %args};
  155         265  
83 45         207 return $class->new(%args);
84             }
85              
86             sub _duration_from_matches {
87 21     21   31 my $matches = shift;
88 21         47 my %args = @_;
89 21         73 my @positions = qw(years months weeks days hours minutes seconds);
90 21         24 my %params;
91 21         67 for my $i(0..$#positions) {
92 147 100       304 $params{$positions[$i]} = $matches->[$i] if $matches->[$i];
93             }
94 21   50     203 return DateTime::Duration->new(%params, end_of_month => $args{end_of_month} || 'limit');
95             }
96              
97              
98             sub new {
99 142     142 1 1597 my $class = shift;
100 142         3551 my %args = validate(
101             @_,
102             {
103             precision => { default => 'time' },
104             start => { optional => 1, isa => 'DateTime' },
105             end => { optional => 1, isa => 'DateTime' },
106             duration => { optional => 1, isa => 'DateTime::Duration' },
107             time_zone => { optional => 1, type => SCALAR | OBJECT },
108             abbreviate => { optional => 1, type => BOOLEAN, default => 0 },
109             repeat => {
110             optional => 1,
111             type => SCALAR,
112             regex => qr{^(-1|\d+)$},
113             default => 0
114             }
115             }
116             );
117              
118 142 50 33     7774 if(!$args{duration} && (!$args{start} || !$args{end})){
      66        
119 0         0 croak "Either a duration or a start or end parameter must be specified";
120             }
121              
122 142 100       6602 if($args{time_zone}){
123 9 50       27 if(!ref($args{time_zone})){
    0          
124 9 50       36 if(DateTime::TimeZone->is_valid_name($args{time_zone})){
125 9         550 $args{time_zone} = DateTime::TimeZone->new( name => $args{time_zone} );
126             } else {
127 0         0 croak "Invalid time_zone: $args{time_zone}";
128             }
129 0         0 } elsif(!eval { $args{time_zone}->isa('DateTime::TimeZone') }){
130 0         0 croak "Invalid time_zone: $args{time_zone}";
131             }
132             }
133              
134 142         1181 return bless \%args, $class;
135             }
136              
137              
138             sub start {
139 132     132 1 7603 my $self = shift;
140 132         888 my($input) = validate_pos(@_, { type => SCALAR | OBJECT, optional => 1 });
141              
142 132 100 66     485 if($input && !ref($input)){
143 3         10 $self->{precision} = _determine_precision($input);
144 3         25 my $parser = DateTime::Format::ISO8601->new;
145 3 50       155 $input = $parser->parse_datetime($input) or croak "invalid start date: $input";
146 3 50       911 if($self->{time_zone}){
147 0         0 $input->set_time_zone($self->{time_zone});
148             }
149             }
150              
151 132 100       254 if($input) {
152 3         73 $self->{start} = $input;
153 3 100       10 delete $self->{duration} if($self->{end});
154             }
155              
156 132   66     472 return $self->{start} || ($self->{end} ? ($self->{end} - $self->{duration}) : undef);
157             }
158              
159              
160             sub end {
161 47     47 1 6704 my $self = shift;
162              
163 47         309 my($input) = validate_pos(@_, { type => SCALAR | OBJECT, optional => 1 });
164              
165 47 100       154 if($input){
166 4 100       31 if(!ref($input)){
167 3         8 $self->{precision} = _determine_precision($input);
168 3         11 my $parser = DateTime::Format::ISO8601->new;
169 3 50       115 $input = $parser->parse_datetime($input) or croak "invalid end date: $input";
170 3 50       743 if($self->{time_zone}){
171 0         0 $input->set_time_zone($self->{time_zone});
172             }
173             } else {
174 1         2 $self->{precision} = 'time';
175             }
176             }
177              
178 47 100       81 if($input) {
179 4         88 $self->{end} = $input;
180 4 100       13 delete $self->{duration} if($self->{start});
181             }
182              
183 47 100       198 if(my $end = $self->{end}) {
184 45         1187 $end = $end->clone;
185 45 100       459 if($self->{precision} eq 'date') {
186             # if only specifying a date in an interval (i.e. 2013-12-01), the date/time equivalent
187             # is actually considered the full day (i.e. 2013-12-01T24:00:00)
188 8         34 $end += DateTime::Duration->new(days => 1);
189             }
190 45         4182 return $end;
191             } else {
192 2         5 return $self->start + $self->duration;
193             }
194             }
195              
196              
197             sub duration {
198 56     56 1 20175 my $self = shift;
199 56         875 my($duration) = validate_pos(@_, { isa => 'DateTime::Duration', optional => 1 });
200 55 100       188 if($duration){
201 2 100 66     6 if($self->{start} && $self->{end}){
202 1         210 croak "An explicit interval (with both start and end dates defined) can not have its duration changed";
203             } else {
204 1         32 $self->{duration} = $duration;
205             }
206             }
207 54 100       240 return $self->{duration} if $self->{duration};
208 29         121 my $dur = $self->{end} - $self->start;
209 29 100       13832 if($self->{precision} eq 'date'){
210 5         19 $dur += DateTime::Duration->new(days => 1);
211             }
212 29         547 return $dur;
213             }
214              
215              
216             sub repeat {
217 15     15 1 457 my $self = shift;
218              
219 15         167 my($repeat) = validate_pos(@_, { type => SCALAR, regex => qr{^(-1|\d+)$}, optional => 1 });
220              
221 15 100       71 if(defined $repeat){
222 1         2 $self->{repeat} = $_[0];
223             }
224 15         63 return $self->{repeat};
225             }
226              
227              
228             sub iterator {
229 12     12 1 1614 my $self = shift;
230 12         29 my %args = @_;
231              
232 12   100     59 my $counter = delete($args{skip}) || 0;
233 12 50       36 croak "Invalid 'skip' parameter (must be >= 0 if specified)" if $counter < 0;
234              
235 12 50 66     36 my $start = ($self->start || $args{after}) or croak "This interval has no starting point";
236 12         1290 my $duration = $self->duration;
237              
238 12 100       46 if(my $after = delete($args{after})){
239 3 50       212 croak "Invalid 'after' parameter (must be a finite DateTime object)" unless ( eval { $after->isa('DateTime') && $after->is_finite } );
  3 50       30  
240 3         26 $counter++ while($start + ($duration * $counter) < $after);
241             }
242              
243 12         29247 my $until = delete($args{until});
244 12 100       27 if($until){
245 2 50       57 croak "Invalid 'until' paramter (must be a DateTime object)" unless eval { $until->isa('DateTime') };
  2         10  
246 2 50       5 undef $until if $until->is_infinite; # ignore an infinite DateTime
247             }
248              
249 12   100     51 my $repeat = $self->repeat || 1;
250              
251 12         25 my $class = ref $self;
252              
253             return sub {
254 111   100 111   3736 my $steps = shift || 1;
255 111         153 $counter += ($steps - 1);
256 111 100 100     362 return if $repeat >= 0 && $counter >= $repeat;
257              
258 95         286 my $this = $start + ($duration * $counter++);
259 95         47319 my $next = $start + ($duration * $counter);
260              
261 95         51209 my $next_interval = $class->new( start => $this, end => $next );
262 95 100 100     192 if($until && $next_interval->contains($until)){
263 2         91 $repeat = 0; # this is the last one...
264 2         4 $next_interval = undef;
265             }
266 95         1558 return $next_interval;
267 12         91 };
268             }
269              
270              
271             sub contains {
272 40     40 1 3010 my $self = shift;
273 40         328 my($date) = validate_pos(@_, { type => SCALAR | OBJECT });
274 40 100 66     876 croak "Unable to determine if this interval contains $date without an explicit start or end date" if !$self->{start} && !$self->{end};
275              
276 39 100       987 if(!ref($date)){
277 2         7 my $parser = DateTime::Format::ISO8601->new;
278 2         74 $date = $parser->parse_datetime($date);
279 2 50       496 if(my $tz = $self->{time_zone}){
280 0         0 $date->set_time_zone($tz);
281             }
282             }
283 39 50       50 croak "Expecting a DateTime object" unless eval { $date->isa('DateTime') };
  39         139  
284 39 50 33     130 if($self->{time_zone} && $date->time_zone->is_floating){
285 0         0 $date = $date->clone;
286 0         0 $date->set_time_zone($self->{time_zone});
287             }
288 39   100     81 return $self->start <= $date && $self->end > $date;
289             }
290              
291              
292             sub abbreviate {
293 4     4 1 8 my $self = shift;
294 4 100       17 $self->{abbreviate} = @_ ? $_[0] : 1;
295 4         15 return $self;
296             }
297              
298              
299             sub format {
300 63     63 1 786 my $self = shift;
301 63   100     1047 my %args = validate(
302             @_,
303             {
304             abbreviate => {
305             optional => 1,
306             default => $self->{abbreviate} || 0,
307             type => BOOLEAN
308             }
309             }
310             );
311              
312 63         211 my @interval;
313              
314 63 100       182 if($self->{repeat}){
315 2 100       6 if($self->{repeat} > 0){
316 1         3 push @interval, 'R' . $self->{repeat};
317             } else {
318 1         2 push @interval, 'R';
319             }
320             }
321              
322 63 100       202 my $format = $self->{precision} eq 'date' ? 'yyyy-MM-dd' : 'yyyy-MM-ddTHH:mm:ss';
323 63         111 my($start, $end) = @{$self}{'start','end'};
  63         180  
324 63 100 100     257 if($self->{precision} ne 'date' && grep {$_ && $_->millisecond > 0} ($start, $end)){
  88 100       1821  
325 1         29 $format .= '.SSS';
326             }
327 63 100       1267 if(defined $start){
328 59         190 push @interval, $start->format_cldr($format) . $self->_timezone_offset($start);
329             } else {
330 4         11 push @interval, $self->_duration_stringify;
331             }
332              
333 63 100       6619 if(defined $end){
    100          
334 56         151 my $formatted_end = $end->format_cldr($format) . $self->_timezone_offset($end);
335 56 100 100     6195 if($start && $args{abbreviate}) {
336 4         215 my @parts = split(/(\D+)/, $formatted_end);
337 4         9 my $same = '';
338 4         8 foreach my $p(@parts) {
339 20 100       78 if($p =~ /^\D+$/){
    100          
340 8         12 $same .= $p;
341             } elsif( index($interval[-1], "$same$p") == 0){
342 8         10 $same .= $p;
343             } else {
344             last
345 4         9 }
346             }
347 4         14 $formatted_end = substr($formatted_end, length($same));
348             }
349 56         2168 push @interval, $formatted_end;
350             } elsif( defined $start ){ # only use duration as "end" if start was defined
351 4         10 push @interval, $self->_duration_stringify;
352             }
353              
354 63         541 return join '/', @interval;
355             }
356              
357              
358             sub set_time_zone {
359 1     1 1 2 my $self = shift;
360 1 50       4 my $tz = shift or croak "no time_zone specified";
361 1 50 33     2 if(!eval { $tz->isa('DateTime::TimeZone') } && DateTime::TimeZone->is_valid_name($tz)){
  1         13  
362 1         12399 $tz = DateTime::TimeZone->new( name => $tz );
363             }
364 1 50       60 if(!ref($tz)){
365 0         0 croak "invalid time zone: $tz";
366             }
367              
368 1         3 $self->{time_zone} = $tz;
369              
370 1 50       3 foreach my $f(grep { exists $self->{$_} && $self->{$_} } qw(start end)){
  2         56  
371 2         314 $self->{$f}->set_time_zone($tz);
372             }
373 1         159 return $self;
374             }
375              
376             sub _timezone_offset {
377 115     115   48632 my $self = shift;
378 115         139 my $date = shift;
379 115 100       592 return '' if $self->{precision} eq 'date';
380 78 100       190 return '' if $date->time_zone->is_floating;
381 74 100       593 return 'Z' if $date->time_zone->is_utc;
382 60         414 return $date->format_cldr('Z');
383             }
384              
385             sub _duration_stringify {
386 8     8   10 my $str = '';
387 8         10 my $self = shift;
388 8         19 my $d = $self->duration;
389 8         13 $str .= 'P';
390 8         13 foreach my $f(qw(years months weeks days)){
391 32 100       260 my $number = $d->$f or next;
392 23         482 my $unit = uc substr($f,0,1);
393 23         37 $str .= $number . $unit;
394             }
395 8         42 my $has_time = 0;
396 8         13 foreach my $f(qw(hours minutes seconds)){
397 24 100       195 my $number = $d->$f or next;
398 10         170 my $unit = uc substr($f,0,1);
399 10 100       18 $str .= 'T' unless $has_time++;
400 10         11 $str .= $number . $unit;
401             }
402 8         143 return $str;
403             }
404              
405             1;
406              
407             __END__