File Coverage

blib/lib/DateTime/Format/ICal.pm
Criterion Covered Total %
statement 158 168 94.0
branch 88 110 80.0
condition 35 45 77.7
subroutine 16 17 94.1
pod 9 10 90.0
total 306 350 87.4


line stmt bran cond sub pod time code
1             package DateTime::Format::ICal;
2              
3 5     5   102889 use strict;
  5         12  
  5         221  
4              
5 5     5   71 use vars qw ($VERSION);
  5         11  
  5         371  
6              
7             $VERSION = '0.09';
8              
9 5     5   10546 use DateTime;
  5         1244981  
  5         174  
10 5     5   6755 use DateTime::Span;
  5         293654  
  5         167  
11 5     5   5404 use DateTime::Event::ICal;
  5         81297  
  5         360  
12              
13 5     5   60 use Params::Validate qw( validate_with SCALAR );
  5         11  
  5         12383  
14              
15             sub new
16             {
17 0     0 0 0 my $class = shift;
18              
19 0         0 return bless {}, $class;
20             }
21              
22             # key is string length
23             my %valid_formats =
24             ( 15 =>
25             { params => [ qw( year month day hour minute second ) ],
26             regex => qr/^(\d\d\d\d)(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)$/,
27             },
28             13 =>
29             { params => [ qw( year month day hour minute ) ],
30             regex => qr/^(\d\d\d\d)(\d\d)(\d\d)T(\d\d)(\d\d)$/,
31             },
32             11 =>
33             { params => [ qw( year month day hour ) ],
34             regex => qr/^(\d\d\d\d)(\d\d)(\d\d)T(\d\d)$/,
35             },
36             8 =>
37             { params => [ qw( year month day ) ],
38             regex => qr/^(\d\d\d\d)(\d\d)(\d\d)$/,
39             },
40             );
41              
42             sub parse_datetime
43             {
44 22     22 1 17920 my ( $self, $date ) = @_;
45              
46             # save for error messages
47 22         42 my $original = $date;
48              
49 22         39 my %p;
50 22 100       152 if ( $date =~ s/^TZID=([^:]+):// )
    100          
51             {
52 2         7 $p{time_zone} = $1;
53             }
54             # Z at end means UTC
55             elsif ( $date =~ s/Z$// )
56             {
57 8         26 $p{time_zone} = 'UTC';
58             }
59             else
60             {
61 12         45 $p{time_zone} = 'floating';
62             }
63              
64 22 50       114 my $format = $valid_formats{ length $date }
65             or die "Invalid iCal datetime string ($original)\n";
66              
67 22         222 @p{ @{ $format->{params} } } = $date =~ /$format->{regex}/;
  22         207  
68              
69 22         176 return DateTime->new(%p);
70             }
71              
72             sub parse_duration
73             {
74 11     11 1 18387 my ( $self, $dur ) = @_;
75              
76 11         53 my @units = qw( weeks days hours minutes seconds );
77              
78 11         100 $dur =~ m{ ([\+\-])? # Sign
79             P # 'P' for period? This is our magic character)
80             (?:
81             (?:(\d+)W)? # Weeks
82             (?:(\d+)D)? # Days
83             )?
84             (?: T # Time prefix
85             (?:(\d+)H)? # Hours
86             (?:(\d+)M)? # Minutes
87             (?:(\d+)S)? # Seconds
88             )?
89             }x;
90              
91 11         28 my $sign = $1;
92              
93 11         17 my %units;
94 11 100       51 $units{weeks} = $2 if defined $2;
95 11 100       42 $units{days} = $3 if defined $3;
96 11 100       48 $units{hours} = $4 if defined $4;
97 11 100       34 $units{minutes} = $5 if defined $5;
98 11 100       62 $units{seconds} = $6 if defined $6;
99              
100 11 100       38 die "Invalid ICal duration string ($dur)\n"
101             unless %units;
102              
103 10 100 100     48 if ( defined $sign && $sign eq '-' )
104             {
105             # $_ *= -1 foreach values %units; - does not work in 5.00503
106 1         10 $units{$_} *= -1 foreach keys %units;
107             }
108              
109 10         65 return DateTime::Duration->new(%units);
110             }
111              
112             sub parse_period
113             {
114 3     3 1 1188 my ( $self, $period ) = @_;
115              
116 3         26 my ( $start, $end ) = $period =~ /^((?:TZID=[^:]+:)?.*?)\/(.*)/;
117              
118 3 50 33     21 die "Invalid ICal period string ($period)\n"
119             unless $start && $end;
120              
121 3         13 $start = $self->parse_datetime( $start );
122              
123 3 100       21324 if ( $end =~ /[\+\-]P/i ) {
124 2         17 $end = $start + $self->parse_duration( $end );
125             }
126             else
127             {
128 1         5 $end = $self->parse_datetime( $end );
129             }
130              
131 3 50       2107 die "Invalid ICal period: end before start ($period)\n"
132             if $start > $end;
133              
134 3         273 return DateTime::Span->new( start => $start, end => $end );
135             }
136              
137             sub parse_recurrence
138             {
139 4     4 1 1586 my $self = shift;
140 4         103 my %p = validate_with( params => \@_,
141             spec => { recurrence => { type => SCALAR } },
142             allow_extra => 1,
143             );
144              
145 4         30 my $recurrence = delete $p{recurrence};
146              
147             # recurrence may start with RRULE:
148 4         12 $recurrence =~ s/^(?:RRULE|EXRULE)://i;
149              
150             # parser: adapted from code written for Date::Set by jesse
151             # RRULEs look like 'FREQ=foo;INTERVAL=bar;' etc.
152 4         19 foreach ( split /;/, $recurrence )
153             {
154 13         430 my ( $name, $value ) = split /=/;
155              
156 13         25 $name = lc $name;
157              
158             # BY parameters should be arrays. everything else should be strings
159 13 100       52 if ( $name eq 'until' )
    100          
160             {
161 1         4 $p{$name} = __PACKAGE__->parse_datetime( $value );
162             }
163             elsif ( $name =~ /^by/i )
164             {
165 7         44 $p{$name} = [ split /,/, lc( $value ) ];
166             }
167             else
168             {
169 5         16 $p{$name} = lc( $value );
170             }
171             }
172              
173 4         40 return DateTime::Event::ICal->recur(%p);
174             }
175              
176             sub format_datetime
177             {
178 30     30 1 4260733 my ( $self, $dt ) = @_;
179              
180 30         111 my $tz = $dt->time_zone;
181              
182 30 100 100     218 unless ( $tz->is_floating ||
      100        
183             $tz->is_utc ||
184             $tz->is_olson )
185             {
186 1         27 $dt = $dt->clone->set_time_zone('UTC');
187 1         222 $tz = $dt->time_zone;
188             }
189              
190 30         371 my $base =
191             sprintf( '%04d%02d%02dT%02d%02d%02d',
192             $dt->year, $dt->month, $dt->day,
193             $dt->hour, $dt->minute, $dt->second );
194              
195 30 100       697 return $base if $tz->is_floating;
196              
197 17 100       92 return $base . 'Z' if $tz->is_utc;
198              
199 4         51 return 'TZID=' . $tz->name . ':' . $base;
200             }
201              
202             sub format_duration
203             {
204 11     11 1 37689 my ( $self, $duration ) = @_;
205              
206 11 50       57 die "Cannot represent years or months in an iCal duration\n"
207             if $duration->delta_months;
208              
209             # simple string for 0-length durations
210 11 100 100     277 return '+PT0S'
      100        
211             unless $duration->delta_days ||
212             $duration->delta_minutes ||
213             $duration->delta_seconds;
214              
215 10 100       147 my $ical = $duration->is_positive ? '+' : '-';
216 10         278 $ical .= 'P';
217              
218 10 100       33 if ( $duration->delta_days )
219             {
220 4 50       30 $ical .= $duration->weeks . 'W' if $duration->weeks;
221 4 50       230 $ical .= $duration->days . 'D' if $duration->days;
222             }
223              
224 10 100 100     542 if ( $duration->delta_minutes || $duration->delta_seconds )
225             {
226 8         62 $ical .= 'T';
227              
228 8 100       27 $ical .= $duration->hours . 'H' if $duration->hours;
229 8 100       1769 $ical .= $duration->minutes . 'M' if $duration->minutes;
230 8 100       1112 $ical .= $duration->seconds . 'S' if $duration->seconds;
231             }
232              
233 10         1208 return $ical;
234             }
235              
236              
237             sub format_period
238             {
239 3     3 1 726 my ( $self, $span ) = @_;
240              
241 3         16 return $self->format_datetime( $span->start ) . '/' .
242             $self->format_datetime( $span->end ) ;
243             }
244              
245             sub format_period_with_duration
246             {
247 2     2 1 1424 my ( $self, $span ) = @_;
248              
249 2         9 return $self->format_datetime( $span->start ) . '/' .
250             $self->format_duration( $span->duration ) ;
251             }
252              
253              
254             sub _split_datetime_tz
255             {
256 16     16   32 my ( $self, $dt ) = @_;
257              
258 16         51 my $tz = $dt->time_zone;
259              
260 16 50 100     192 unless ( $tz->is_floating ||
      66        
261             $tz->is_utc ||
262             $tz->is_olson )
263             {
264 0         0 $dt = $dt->clone->set_time_zone('UTC');
265 0         0 $tz = $dt->time_zone;
266             }
267              
268 16 50 33     186 my $base =
269             ( $dt->hour || $dt->min || $dt->sec ?
270             sprintf( '%04d%02d%02dT%02d%02d%02d',
271             $dt->year, $dt->month, $dt->day,
272             $dt->hour, $dt->minute, $dt->second ) :
273             sprintf( '%04d%02d%02d', $dt->year, $dt->month, $dt->day )
274             );
275              
276 16 100       557 return ($base, '') if $tz->is_floating;
277 5 100       31 return ($base, 'UTC') if $tz->is_utc;
278 3         25 return ($base, $tz->name);
279             }
280              
281             sub format_recurrence
282             {
283 22     22 1 4557930 my ( $self, $set, @more ) = @_;
284 22         42 my @result;
285              
286             # normalize param to either DT::Set or DT::SpanSet
287             # DT list => convert to DT::Set
288             # DT::Span list => convert to DT::SpanSet
289              
290 22 100       1393 if ( $set->isa('DateTime') )
    50          
291             {
292 10         71 $set = DateTime::Set->from_datetimes( dates => [ $set, @more ] );
293             }
294             elsif ( $set->isa('DateTime::Span') )
295             {
296 0         0 $set = DateTime::SpanSet->from_spans( spans => [ $set, @more ] );
297             }
298              
299             # is it a recurrence?
300 22 100       3053 if ( $set->{set}->is_too_complex )
301             {
302             # DT::Set recurrence => DTSTART;timezone:date CRLF
303             # RRULE:params CRLF
304             # note: add more lines if necessary:
305             # union = more RRULE/RDATE lines
306             # complement = more EXRULE/EXDATE lines
307             # intersection = ?
308             # note: timezone is specified by DTSTART only.
309              
310             # TODO: add support to DT::Event::Recurrence objects
311              
312 10 100 100     129 if ( $set->can( 'get_ical' ) && defined $set->get_ical )
313             {
314 8         764 my %ical = $set->get_ical;
315 8         75 for ( @{ $ical{include} } )
  8         30  
316             {
317 19 50       80 next unless $_;
318 19 100       434 if ( ref( $_ ) )
319             {
320 6         35 push @result, $self->format_recurrence( $_ );
321             }
322             else
323             {
324 13         32 push @result, $_;
325             }
326             }
327 8 100       137 if ( $ical{exclude} )
328             {
329 3         6 my @exclude;
330 3         9 for ( @{ $ical{exclude} } )
  3         10  
331             {
332 4 50       13 next unless $_;
333 4 100       139 if ( ref( $_ ) )
334             {
335 3         11 push @exclude, $self->format_recurrence( $_ );
336             }
337             else
338             {
339 1         4 push @exclude, $_;
340             }
341             }
342 3         35 s/^RDATE/EXDATE/ for @exclude;
343 3         15 s/^RRULE/EXRULE/ for @exclude;
344 3         12 push @result, @exclude;
345             }
346             }
347             else
348             {
349 2         29 die "format_recurrence() - Format not implemented for this unbounded set";
350             }
351              
352             # end: format recurrence
353             }
354             else
355             {
356             # DT::Set => RDATE:datetime,datetime,datetime CRLF
357             # DT::SpanSet => RDATE;VALUE=PERIOD:period,period CRLF
358             #
359             # not supported => RDATE;VALUE=DATE:date,date,date CRLF
360             #
361             # DT::Set w/tz => RDATE;timezone:date,date CRLF
362             # DT::SpanSet w/tz => RDATE;VALUE=PERIOD;timezone:period,period CRLF
363              
364 12         103 my $iterator = $set->iterator;
365 12         673 my $last_type = 'DateTime';
366 12         27 my $last_tz = 'invalid';
367 12         14 my $item;
368              
369 12         60 while( $item = $iterator->next )
370             {
371 16 100       3481 if( $item->isa('DateTime') )
    50          
372             {
373 14         49 my ($base,$tz) = $self->_split_datetime_tz( $item );
374 14 100 66     127 if( $last_tz eq $tz &&
375             $last_type eq 'DateTime' )
376             {
377 1         5 $result[-1] .= ',' . $base;
378 1 50       7 $result[-1] .= 'Z' if $tz eq 'UTC';
379             }
380             else
381             {
382 13         28 push @result, 'RDATE';
383 13 100 100     57 $result[-1] .= ';TZID='.$tz if $tz ne '' && $tz ne 'UTC';
384 13         37 $result[-1] .= ':' . $base;
385 13 100       44 $result[-1] .= 'Z' if $tz eq 'UTC';
386 13         21 $last_tz = $tz;
387 13         55 $last_type = 'DateTime';
388             }
389             }
390             elsif( $item->isa('DateTime::Span') )
391             {
392 2         10 my $item_start = $item->start;
393 2         106 my $item_end = $item->end;
394 2 100       84 if ( $item_start == $item_end )
395             {
396 1         71 $item = $item_start;
397             # item looks like a datetime
398 1         8 redo;
399             }
400 1         66 my ($start,$tz) = $self->_split_datetime_tz( $item_start );
401 1         12 $item_end->set_time_zone( $tz );
402 1         535 my ($end,undef) = $self->_split_datetime_tz( $item_end );
403 1 50 33     12 if( $last_tz eq $tz &&
404             $last_type eq 'DateTime::Span' )
405             {
406 0         0 $result[-1] .= ',' . $start;
407 0 0       0 $result[-1] .= 'Z' if $tz eq 'UTC';
408 0         0 $result[-1] .= '/' . $end;
409 0 0       0 $result[-1] .= 'Z' if $tz eq 'UTC';
410             }
411             else
412             {
413 1         3 push @result, 'RDATE;VALUE=PERIOD';
414 1 50 33     15 $result[-1] .= ';TZID='.$tz if $tz ne '' && $tz ne 'UTC';
415 1         4 $result[-1] .= ':' . $start;
416 1 50       6 $result[-1] .= 'Z' if $tz eq 'UTC';
417 1         4 $result[-1] .= '/' . $end;
418 1 50       4 $result[-1] .= 'Z' if $tz eq 'UTC';
419 1         2 $last_tz = $tz;
420 1         11 $last_type = 'DateTime::Span';
421             }
422             }
423             else
424             {
425 0         0 die 'unexpected data type "'.ref($item).'" in set';
426             }
427             }
428              
429             # end: format list of dates
430             }
431 20 50       207 return join( "\n", @result ) if ! wantarray;
432 20         180 return @result;
433             }
434              
435             1;
436              
437             __END__