File Coverage

blib/lib/iCal/Parser.pm
Criterion Covered Total %
statement 209 212 98.5
branch 67 80 83.7
condition 41 59 69.4
subroutine 28 29 96.5
pod 4 17 23.5
total 349 397 87.9


line stmt bran cond sub pod time code
1             # $Id$
2             package iCal::Parser;
3 4     4   91510 use strict;
  4         9  
  4         247  
4              
5             our $VERSION='1.20';
6              
7             our @ISA = qw (Exporter);
8              
9 4     4   3806 use DateTime::Format::ICal;
  4         1031451  
  4         139  
10 4     4   53 use DateTime::TimeZone;
  4         12  
  4         91  
11 4     4   4275 use Text::vFile::asData;
  4         34107  
  4         40  
12 4     4   151 use File::Basename;
  4         8  
  4         451  
13 4     4   3593 use IO::File;
  4         41636  
  4         525  
14 4     4   3856 use IO::String;
  4         16272  
  4         11017  
15              
16             # mapping of ical entries to datatypes
17             our %TYPES=(dates=>{DTSTAMP=>1,DTSTART=>1,DTEND=>1,COMPLETED=>1,
18             'RECURRENCE-ID'=>1,EXDATE=>1,DUE=>1,
19             'LAST-MODIFIED'=>1,
20             },
21             durations=>{DURATION=>1},
22             arrays=>{EXDATE=>1,ATTENDEE=>1},
23             hash=>{'ATTENDEE'=>1, ORGANIZER=>1},
24             );
25              
26             our %defaults=(debug=>0,span=>undef,start=>undef,end=>undef,months=>60,tz=>'local');
27              
28             our $dfmt=DateTime::Format::ICal->new;
29             our $parser=Text::vFile::asData->new;
30             sub new {
31 31     31 1 781558 my ($class, %params) = @_;
32              
33 31         408 my $self=bless {%defaults, %params,
34             ical=>{cals=>[],events=>{},todos=>[]},
35             _today=>DateTime->now,_calid=>0,
36             }, $class;
37             #set range, allow passed in dates as DateTimes or strings
38 31   66     10538 my $start=$params{start}||DateTime->now->truncate(to=>'year');
39 31 100       4671 $start=$dfmt->parse_datetime($start) unless ref $start;
40 31   66     6157 my $end=$params{end}||$start->clone->add(months=>$self->{months});
41 31 100       20363 $end=$dfmt->parse_datetime($end) unless ref $end;
42 31   66     1316 $self->{span}||=DateTime::Span->new(start=>$start, end=>$end);
43              
44 31 50       11386 $self->{tz}=DateTime::TimeZone->new(name=>$self->{tz})
45             unless ref $self->{tz};
46              
47 31         40402 return ($self);
48             }
49             sub parse {
50 30     30 1 91 my $self=shift;
51              
52 30         74 foreach my $file (@_) {
53 34 100 50     362 my $fh=ref $file ? $file
54             : IO::File->new($file,'r') || die "Can\'t open $file, $!";
55 34         4292 my $data=$parser->parse($fh);
56 34         200935 undef $fh;
57              
58 34         890 $self->VCALENDAR($data->{objects}[0],$file);
59 34         351 $self->add_objects($data->{objects}[0]);
60 34         7066 $self->update_recurrences;
61             }
62 30         137 return $self->{ical};
63             }
64             sub parse_files {
65 2     2 1 12 return parse(@_);
66             }
67             sub parse_strings {
68 1     1 1 3 my $self=shift;
69 1         3 return $self->parse((map { IO::String->new($_) } @_));
  2         106  
70             }
71             sub calendar {
72 0     0 0 0 return shift->{ical};
73             }
74             sub VCALENDAR {
75 34     34 0 79 my($self,$cal,$file)=@_;
76              
77 34         71 my %props=();
78 34         98 $self->{recurrences}=[];
79 34         151 $self->map_properties(\%props,$cal);
80 34   66     288 $props{'X-WR-TIMEZONE'}||=$self->{tz}->name;
81 34         233 $props{index}=++$self->{_calid};
82 34   66     154 $props{'X-WR-RELCALID'}||=$self->{_calid};
83 34 100 66     1312 $props{'X-WR-CALNAME'}||= ref $file
84             ? "Calendar $self->{_calid}" : fileparse($file,qr{\.\w+});
85              
86 34         81 push @{$self->{ical}{cals}},\%props;
  34         153  
87             }
88             sub VTODO {
89 10     10 0 18 my($self,$todo)=@_;
90 10 50       35 return if $self->{no_todos};
91              
92 10         30 my $t={idref=>$self->_cur_calid};
93 10         28 $self->map_properties($t,$todo);
94 10   100     41 $t->{PRIORITY}||=99;
95              
96 10         29 $self->add_objects($todo,$t);
97 10         18 push @{ $self->{ical}{todos} }, $t;
  10         43  
98             }
99             sub VEVENT {
100 90     90 0 206 my($self,$event)=@_;
101 90 50       291 return if $self->{no_events};
102              
103 90         279 my %e=(idref=>$self->_cur_calid);
104              
105 90         333 $self->map_properties(\%e,$event);
106 90         306 $self->add_objects($event,\%e);
107              
108 90         151 my $start=$e{DTSTART};
109 90 100       500 return if $start > $self->{span}->end;
110              
111 86 50       21454 warn "Event: @e{qw(UID DTSTART SUMMARY)}\n"
112             if $self->{debug};
113              
114             # stolen from Text::vFile::asData example
115 86 100 100     245 $e{allday}=1 if _param($event,'DTSTART','VALUE')||'' eq 'DATE';
116              
117             #is it a rule that an event must contain either a duration or end?
118             # answer: no, it's not (cpan bug #25232)
119 86         172 my $end=$e{DTEND};
120 86 100       272 my $duration=$end ? $end-$start : delete $e{DURATION};
121 86 100 66     19451 $duration ||= DateTime::Duration->new(days=> $e{allday} ? 1 : 0);
122 86   66     507 $e{DTEND}||=$start+$duration;
123 86 100       19249 $e{hours}=_hours($duration) unless $e{allday};
124              
125             #build recurrence sets
126 86         122 my $set;
127 86 100       284 if (my $rid=$e{'RECURRENCE-ID'}) {
128 11 50       422 return if $start < $self->{span}->start;
129 11         2653 push @{ $self->{recurrences} }, \%e;
  11         33  
130 11         63 return;
131             }
132 75 100       277 if (my $recur=delete $e{RRULE}) {
    100          
133 20         96 $set=$dfmt->parse_recurrence(recurrence=>$recur, dtstart=>$start,
134             #cap infinite repeats
135             until =>$self->{span}->end);
136             } elsif ($end) {
137             # non-rrule event possibly spanning multiple days,
138             # expand into multiple events
139 43         1493 my $diff=$end-$start;
140 43 100 100     11661 if (!$e{allday} && $end->day > $start->day) {
141 8         91 $self->add_span(\%e);
142 8         80 return;
143             }
144 35 100       190 if ($diff->delta_days > 1) {
145             # note recurrence includes last date, and allday events
146             # end at 00 on the last (non-inclusive) day, so remove it
147             # from set
148             $set=DateTime::Set->from_recurrence
149             (start=>$start,end=>$end->clone->subtract(days=>1),
150             recurrence=>sub {
151 48     48   53219 return $_[0]->truncate(to=>'day')->add(days=>1)
152 5         38 });
153             # reset duration to "allday" event
154 5         12332 $duration=DateTime::Duration->new(days=>1);
155             }
156             }
157 67   66     3699783 $set||=DateTime::Set->from_datetimes(dates=>[$start]);
158              
159             # fix bug w/ recurrence containing no entries
160             # note that count returns "undef" for infinitely large sets.
161 67 100 66     9162 return if defined $set->count && $set->count==0;
162              
163 65 100       9794 if (my $dates=delete $e{'EXDATE'}) {
164             #mozilla/sunbird set exdate to T00..., so, get first start date
165             #and set times on exdates
166 4         26 my $d=$set->min;
167 8         1329 my $exset=DateTime::Set->from_datetimes
168             (dates=>[
169 4         164 map {$_->set(hour=>$d->hour,minute=>$d->minute,
170             second=>$d->second)
171             } @$dates]);
172 4         2833 $set=$set
173             ->complement(DateTime::Set->from_datetimes(dates=>$dates));
174             }
175 65 50       14088 $set=$set->intersection($self->{span}) if $self->{span};
176 65         196049 my $iter=$set->iterator;
177 65         3460 while (my $dt=$iter->next) {
178             #bug found by D. Sweet. Fix alarms on entries
179             #other than first
180 215         26827 my $new_event={%e,DTSTART=>$dt,DTEND=>$dt+$duration};
181 215 100       100418 $new_event->{VALARM}=_fix_alarms($new_event, $e{DTSTART})
182             if $new_event->{VALARM};
183 215         553 $self->add_event($new_event);
184             }
185             }
186             sub VALARM {
187 21     21 0 43 my($self,$alarm,$e)=@_;
188              
189 21         38 my %a=();
190             #handle "RELATED attribute
191 21   100     157 my $which=$alarm->{properties}{TRIGGER}[0]{param}{RELATED}||'START';
192              
193 21         56 $self->map_properties(\%a,$alarm);
194 21 100       166 $a{when}=ref $a{TRIGGER} eq 'DateTime::Duration'
195             ? $e->{"DT$which"}+delete $a{TRIGGER}
196             : delete $a{TRIGGER};
197              
198 21         11057 push @{$e->{VALARM}},\%a;
  21         116  
199             }
200             sub _fix_alarms {
201 84     84   194 my $e=shift;
202 84         84 my $orig_start=shift;
203              
204             # trigger already remove, generate diff
205 84         234 my $diff=$e->{DTSTART}-$orig_start;
206 84         19600 my @alarms=();
207 84         102 foreach my $old (@{ $e->{VALARM} }) {
  84         228  
208 104         333 my %a=%$old;
209 104         307 $a{when}=$a{when}->clone->add_duration($diff);
210 104         48749 push @alarms, \%a;
211             }
212 84         293 return \@alarms;
213             }
214             sub add_objects {
215 134     134 0 181 my $self=shift;
216 134         142 my $event=shift;
217              
218 134 100       377 return unless $event->{objects};
219 47         79 foreach my $o (@{ $event->{objects} }) {
  47         107  
220 121         1340 my $t=$o->{type};
221 121 50       784 $self->$t($o,@_) if $self->can($t);
222             }
223             }
224             sub _hours {
225 70     70   4828 my $duration=shift;
226              
227 70         106 my($days,$hours,$minutes)=@{$duration}{qw(days hours minutes)};
  70         164  
228 70   100     477 $days||=0; $hours||=0; $minutes||=0;
  70   50     263  
  70   100     292  
229 70         935 return sprintf "%.2f",($days*24*60+$hours*60+$minutes)/60.0;
230             }
231             sub convert_value {
232 832     832 0 1106 my($self,$type,$hash)=@_;
233              
234 832         1788 my $value=$hash->{value};
235 832 100       1371 return $value unless $value; #should protect from invalid datetimes
236              
237 825 100       1809 if ($type eq 'TRIGGER') {
238             #can be date or duration!
239 21 100       184 return $dfmt->parse_duration($value) if $value =~/^[-+]?P/;
240 1         5 return $dfmt->parse_datetime($value)->set_time_zone($self->{tz});
241             }
242 804 100       1697 if ($TYPES{hash}{$type}) {
243 25         60 my %h=(value=>$value);
244 25         42 map { $h{$_}=$hash->{param}{$_} } keys %{ $hash->{param} };
  21         56  
  25         145  
245 25         80 return \%h;
246             }
247 779 100       1703 return $dfmt->parse_duration($value) if $TYPES{durations}{$type};
248 751 100       2528 return $value unless $TYPES{dates}{$type};
249              
250             #mozilla calendar bug: negative dates on todos!
251 287 100       852 return undef if $value =~ /^-/;
252              
253             #handle dates which can be arrays (EXDATE)
254 285         637 my @dates=();
255 285         825 foreach my $s (split ',', $value) {
256             # I have a sample calendar "Employer Tax calendar"
257             # which has an allday event ending on 20040332!
258             # so, handle the exception
259 286         285 my $date;
260 286         425 eval {
261 286         986 $date=$dfmt->parse_datetime($s)->set_time_zone($self->{tz});
262             };
263 286 50 50     107903 push @dates, $date and next unless $@;
264 0 0 0     0 die $@ if $@ && $type ne 'DTEND';
265 0         0 push @dates,
266             $dfmt->parse_datetime(--$value)->set_time_zone($self->{tz});
267             }
268 285         989 return @dates;
269             }
270             sub get_value {
271 822     822 0 1097 my($self,$props,$key)=@_;
272              
273 822         1095 my @a=map {$self->convert_value($key,$_)} @{ $props->{$key} };
  832         1645  
  822         1587  
274 822 50       9962 return wantarray ? @a : $a[0];
275             }
276             sub _param {
277 86     86   152 my($event,$key,$param)=@_;
278 86         626 return $event->{properties}{$key}[0]{param}{$param};
279             }
280             #set $a from $b
281             sub map_properties {
282 155     155 0 239 my($self,$e,$event)=@_;
283              
284 155         271 my $props=$event->{properties};
285 155         613 foreach (keys %$props) {
286 822         1895 my @a=$self->get_value($props,$_);
287 822 100       1772 delete $e->{$_}, next unless defined $a[0];
288 820 100       3085 $e->{$_}=$TYPES{arrays}{$_} ? \@a : $a[0];
289             }
290             ;
291 155         430 delete $e->{SEQUENCE};
292             }
293             sub _cur_calid {
294 100     100   165 my $self=shift;
295 100         461 return $self->{ical}{cals}[-1]{'X-WR-RELCALID'};
296             }
297             sub find_day {
298 255     255 0 286 my($self,$d)=@_;
299              
300 255         447 my $h=$self->{ical}{events};
301             #warn sprintf "find %4d-%02d-%02d\n",$d->year,$d->month,$d->day
302             #if $self->{debug};
303 255         608 foreach my $i ($d->year,$d->month,$d->day) {
304 765   100     4609 $h->{$i}||={};
305 765         1241 $h=$h->{$i};
306             }
307 255         2570 return $h;
308             }
309             sub add_event {
310 244     244 0 326 my($self,$event)=@_;
311              
312 244         536 $self->find_day($event->{DTSTART})->{$event->{UID}}=$event;
313             }
314             sub update_recurrences {
315 34     34 0 51 my $self=shift;
316 34         46 foreach my $event (@{ $self->{recurrences} }) {
  34         570  
317 11         40 my $day=$self->find_day(delete $event->{'RECURRENCE-ID'});
318 11   100     113 my $old=delete $day->{$event->{UID}}||{};
319 11         141 $self->add_event({%$old,%$event});
320             }
321             }
322             sub add_span {
323 8     8 0 14 my($self,$event)=@_;
324 8         48 my %last=%$event;
325              
326             #when event spans days, only alarm on first entry
327 8         17 delete $last{VALARM};
328              
329 8         27 $last{DTSTART}=$event->{DTEND}->clone->truncate(to=>'day');
330 8         2355 $last{DTEND}=$event->{DTEND};
331 8         26 $event->{DTEND}=$event->{DTSTART}->clone->truncate(to=>'day')
332             ->add(days=>1);
333 8         6744 $last{hours}=_hours($last{DTEND}-$last{DTSTART});
334 8         46 $event->{hours}=_hours($event->{DTEND}-$event->{DTSTART});
335 8         29 my @a=();
336 8         56 my $min=$self->{span}->start;
337 8         274 my $max=$self->{span}->end;
338 8         203 for (my $d=$event->{DTEND}->clone;
339             $d < $last{DTSTART}; $d->add(days=>1)) {
340 2 50 33     158 if ($d >= $min && $d <= $max) {
341 2         779 my %t=%last;
342 2         8 $t{DTSTART}=$d->clone;
343 2         24 $t{DTEND}=$d->clone->add(days=>1);
344 2         1141 $t{hours}=_hours($t{DTEND}-$t{DTSTART});
345 2         11 push @a,\%t;
346             }
347             }
348 8         1669 my($start,$end)=($self->{span}->start,$self->{span}->end);
349 18 50       2960 map {$self->add_event($_)} grep {
  18         4091  
350 8         323 $_->{DTSTART} >= $start && $_->{DTEND} <= $end
351             } $event,@a,\%last;
352             }
353             1;
354             __END__