File Coverage

blib/lib/iCal/Parser.pm
Criterion Covered Total %
statement 210 213 98.5
branch 70 82 85.3
condition 43 62 69.3
subroutine 28 29 96.5
pod 4 17 23.5
total 355 403 88.0


line stmt bran cond sub pod time code
1             # $Id$
2             package iCal::Parser;
3 7     7   581941 use strict;
  7         17  
  7         553  
4              
5             our $VERSION='1.21';
6              
7             our @ISA = qw (Exporter);
8              
9 7     7   5562 use DateTime::Format::ICal;
  7         3877166  
  7         396  
10 7     7   83 use DateTime::TimeZone;
  7         16  
  7         194  
11 7     7   5498 use Text::vFile::asData;
  7         44203  
  7         56  
12 7     7   439 use File::Basename;
  7         13  
  7         815  
13 7     7   4540 use IO::File;
  7         69853  
  7         1105  
14 7     7   4835 use IO::String;
  7         18571  
  7         20545  
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 35     35 1 541762 my ($class, %params) = @_;
32              
33 35         467 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 35   66     10724 my $start=$params{start}||DateTime->now->truncate(to=>'year');
39 35 100       1347 $start=$dfmt->parse_datetime($start) unless ref $start;
40 35   66     11324 my $end=$params{end}||$start->clone->add(months=>$self->{months});
41 35 100       23765 $end=$dfmt->parse_datetime($end) unless ref $end;
42 35   66     2215 $self->{span}||=DateTime::Span->new(start=>$start, end=>$end);
43              
44             $self->{tz}=DateTime::TimeZone->new(name=>$self->{tz})
45 35 50       11104 unless ref $self->{tz};
46              
47 35         38157 return ($self);
48             }
49             sub parse {
50 34     34 1 301 my $self=shift;
51              
52 34         88 foreach my $file (@_) {
53 38 100 50     340 my $fh=ref $file ? $file
54             : IO::File->new($file,'r') || die "Can\'t open $file, $!";
55 38         3976 my $data=$parser->parse($fh);
56 38         162712 undef $fh;
57              
58 38         246 $self->VCALENDAR($data->{objects}[0],$file);
59 38         135 $self->add_objects($data->{objects}[0]);
60 38         1823 $self->update_recurrences;
61             }
62 34         173 return $self->{ical};
63             }
64             sub parse_files {
65 2     2 1 8 return parse(@_);
66             }
67             sub parse_strings {
68 5     5 1 103 my $self=shift;
69 5         15 return $self->parse((map { IO::String->new($_) } @_));
  6         124  
70             }
71             sub calendar {
72 0     0 0 0 return shift->{ical};
73             }
74             sub VCALENDAR {
75 38     38 0 89 my($self,$cal,$file)=@_;
76              
77 38         96 my %props=();
78 38         102 $self->{recurrences}=[];
79 38         174 $self->map_properties(\%props,$cal);
80 38   66     347 $props{'X-WR-TIMEZONE'}||=$self->{tz}->name;
81 38         274 $props{index}=++$self->{_calid};
82 38   66     165 $props{'X-WR-RELCALID'}||=$self->{_calid};
83 38 100 66     1415 $props{'X-WR-CALNAME'}||= ref $file
84             ? "Calendar $self->{_calid}" : fileparse($file,qr{\.\w+});
85              
86 38         148 push @{$self->{ical}{cals}},\%props;
  38         142  
87             }
88             sub VTODO {
89 10     10 0 14 my($self,$todo)=@_;
90 10 50       25 return if $self->{no_todos};
91              
92 10         20 my $t={idref=>$self->_cur_calid};
93 10         22 $self->map_properties($t,$todo);
94 10   100     59 $t->{PRIORITY}||=99;
95              
96 10         23 $self->add_objects($todo,$t);
97 10         14 push @{ $self->{ical}{todos} }, $t;
  10         33  
98             }
99             sub VEVENT {
100 94     94 0 181 my($self,$event)=@_;
101 94 50       236 return if $self->{no_events};
102              
103 94         253 my %e=(idref=>$self->_cur_calid);
104              
105 94         242 $self->map_properties(\%e,$event);
106 94         303 $self->add_objects($event,\%e);
107              
108 94         243 my $start=$e{DTSTART};
109 94 100       423 return if $start > $self->{span}->end;
110              
111             warn "Event: @e{qw(UID DTSTART SUMMARY)}\n"
112 90 50       19461 if $self->{debug};
113              
114             # stolen from Text::vFile::asData example
115 90 100 100     247 $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 90         160 my $end=$e{DTEND};
120 90 100       250 my $duration=$end ? $end-$start : delete $e{DURATION};
121 90 100 66     18127 $duration ||= DateTime::Duration->new(days=> $e{allday} ? 1 : 0);
122 90   66     532 $e{DTEND}||=$start+$duration;
123 90 100       20443 $e{hours}=_hours($duration) unless $e{allday};
124              
125             #build recurrence sets
126 90         133 my $set;
127 90 100       248 if (my $rid=$e{'RECURRENCE-ID'}) {
128 11 50       335 return if $start < $self->{span}->start;
129 11         2136 push @{ $self->{recurrences} }, \%e;
  11         38  
130 11         57 return;
131             }
132 79 100       293 if (my $recur=delete $e{RRULE}) {
    100          
133             $set=$dfmt->parse_recurrence(recurrence=>$recur, dtstart=>$start,
134             #cap infinite repeats
135 20         114 until =>$self->{span}->end);
136             } elsif ($end) {
137             # non-rrule event possibly spanning multiple days,
138             # expand into multiple events
139 47 100 100     1473 if (!$e{allday} && ($end->ymd cmp $start->ymd) > 0) {
140 9         180 $self->add_span(\%e);
141 9         54 return;
142             }
143 38         211 my $diff=$end-$start;
144 38 100       9103 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   54118 return $_[0]->truncate(to=>'day')->add(days=>1)
152 5         38 });
153             # reset duration to "allday" event
154 5         6254 $duration=DateTime::Duration->new(days=>1);
155             }
156             }
157 70   66     3441217 $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 70 100 66     8628 return if defined $set->count && $set->count==0;
162              
163 68 100       8589 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         40 my $d=$set->min;
167             my $exset=DateTime::Set->from_datetimes
168             (dates=>[
169 4         202 map {$_->set(hour=>$d->hour,minute=>$d->minute,
  8         1974  
170             second=>$d->second)
171             } @$dates]);
172 4         3121 $set=$set
173             ->complement(DateTime::Set->from_datetimes(dates=>$dates));
174             }
175 68 50       22017 $set=$set->intersection($self->{span}) if $self->{span};
176 68         88265 my $iter=$set->iterator;
177 68         2899 while (my $dt=$iter->next) {
178             #bug found by D. Sweet. Fix alarms on entries
179             #other than first
180 218         25037 my $new_event={%e,DTSTART=>$dt,DTEND=>$dt+$duration};
181             $new_event->{VALARM}=_fix_alarms($new_event, $e{DTSTART})
182 218 100       135902 if $new_event->{VALARM};
183 218         603 $self->add_event($new_event);
184             }
185             }
186             sub VALARM {
187 21     21 0 34 my($self,$alarm,$e)=@_;
188              
189 21         41 my %a=();
190             #handle "RELATED attribute
191 21   100     147 my $which=$alarm->{properties}{TRIGGER}[0]{param}{RELATED}||'START';
192              
193 21         59 $self->map_properties(\%a,$alarm);
194             $a{when}=ref $a{TRIGGER} eq 'DateTime::Duration'
195             ? $e->{"DT$which"}+delete $a{TRIGGER}
196 21 100       159 : delete $a{TRIGGER};
197              
198 21         12434 push @{$e->{VALARM}},\%a;
  21         106  
199             }
200             sub _fix_alarms {
201 84     84   107 my $e=shift;
202 84         73 my $orig_start=shift;
203              
204             # trigger already remove, generate diff
205 84         252 my $diff=$e->{DTSTART}-$orig_start;
206 84         22678 my @alarms=();
207 84         164 foreach my $old (@{ $e->{VALARM} }) {
  84         218  
208 104         344 my %a=%$old;
209 104         313 $a{when}=$a{when}->clone->add_duration($diff);
210 104         59189 push @alarms, \%a;
211             }
212 84         276 return \@alarms;
213             }
214             sub add_objects {
215 142     142 0 177 my $self=shift;
216 142         140 my $event=shift;
217              
218 142 100       371 return unless $event->{objects};
219 51         65 foreach my $o (@{ $event->{objects} }) {
  51         118  
220 125         1274 my $t=$o->{type};
221 125 50       764 $self->$t($o,@_) if $self->can($t);
222             }
223             }
224             sub _hours {
225 76     76   4648 my $duration=shift;
226              
227 76         98 my($days,$hours,$minutes)=@{$duration}{qw(days hours minutes)};
  76         167  
228 76   100     384 $days||=0; $hours||=0; $minutes||=0;
  76   50     245  
  76   100     167  
229 76         1098 return sprintf "%.2f",($days*24*60+$hours*60+$minutes)/60.0;
230             }
231             sub convert_value {
232 844     844 0 891 my($self,$type,$hash)=@_;
233              
234 844         1244 my $value=$hash->{value};
235 844 100       1459 return $value unless $value; #should protect from invalid datetimes
236              
237             # Trim common types that do not allow whitespaces
238             # (and that would make parse_datetime to fail, for instance)
239 837 100 66     3868 $value =~ s/^\s+|\s+$//g if $TYPES{dates}{$type} || $TYPES{durations}{$type};
240              
241 837 100       1459 if ($type eq 'TRIGGER') {
242             #can be date or duration!
243 21 100       172 return $dfmt->parse_duration($value) if $value =~/^[-+]?P/;
244 1         5 return $dfmt->parse_datetime($value)->set_time_zone($self->{tz});
245             }
246 816 100       1345 if ($TYPES{hash}{$type}) {
247 25         62 my %h=(value=>$value);
248 25         31 map { $h{$_}=$hash->{param}{$_} } keys %{ $hash->{param} };
  21         42  
  25         66  
249 25         74 return \%h;
250             }
251 791 100       1358 return $dfmt->parse_duration($value) if $TYPES{durations}{$type};
252 763 100       2102 return $value unless $TYPES{dates}{$type};
253              
254             #mozilla calendar bug: negative dates on todos!
255 295 100       675 return undef if $value =~ /^-/;
256              
257             #handle dates which can be arrays (EXDATE)
258 293         417 my @dates=();
259 293         863 foreach my $s (split ',', $value) {
260             # I have a sample calendar "Employer Tax calendar"
261             # which has an allday event ending on 20040332!
262             # so, handle the exception
263 294         267 my $date;
264 294         314 eval {
265 294         974 $date=$dfmt->parse_datetime($s)->set_time_zone($self->{tz});
266             };
267 294 50 50     112163 push @dates, $date and next unless $@;
268 0 0 0     0 die $@ if $@ && $type ne 'DTEND';
269             push @dates,
270 0         0 $dfmt->parse_datetime(--$value)->set_time_zone($self->{tz});
271             }
272 293         1000 return @dates;
273             }
274             sub get_value {
275 834     834 0 923 my($self,$props,$key)=@_;
276              
277 834         696 my @a=map {$self->convert_value($key,$_)} @{ $props->{$key} };
  844         1425  
  834         1555  
278 834 50       7199 return wantarray ? @a : $a[0];
279             }
280             sub _param {
281 90     90   173 my($event,$key,$param)=@_;
282 90         649 return $event->{properties}{$key}[0]{param}{$param};
283             }
284             #set $a from $b
285             sub map_properties {
286 163     163 0 209 my($self,$e,$event)=@_;
287              
288 163         212 my $props=$event->{properties};
289 163         637 foreach (keys %$props) {
290 834         1393 my @a=$self->get_value($props,$_);
291 834 100       1550 delete $e->{$_}, next unless defined $a[0];
292 832 100       2260 $e->{$_}=$TYPES{arrays}{$_} ? \@a : $a[0];
293             }
294             ;
295 163         371 delete $e->{SEQUENCE};
296             }
297             sub _cur_calid {
298 104     104   124 my $self=shift;
299 104         364 return $self->{ical}{cals}[-1]{'X-WR-RELCALID'};
300             }
301             sub find_day {
302 259     259 0 284 my($self,$d)=@_;
303              
304 259         413 my $h=$self->{ical}{events};
305             #warn sprintf "find %4d-%02d-%02d\n",$d->year,$d->month,$d->day
306             #if $self->{debug};
307 259         604 foreach my $i ($d->year,$d->month,$d->day) {
308 777   100     6386 $h->{$i}||={};
309 777         1031 $h=$h->{$i};
310             }
311 259         2584 return $h;
312             }
313             sub add_event {
314 248     248 0 371 my($self,$event)=@_;
315              
316 248         521 $self->find_day($event->{DTSTART})->{$event->{UID}}=$event;
317             }
318             sub update_recurrences {
319 38     38 0 61 my $self=shift;
320 38         53 foreach my $event (@{ $self->{recurrences} }) {
  38         505  
321 11         40 my $day=$self->find_day(delete $event->{'RECURRENCE-ID'});
322 11   100     123 my $old=delete $day->{$event->{UID}}||{};
323 11         130 $self->add_event({%$old,%$event});
324             }
325             }
326             sub add_span {
327 9     9 0 15 my($self,$event)=@_;
328 9         56 my %last=%$event;
329              
330             #when event spans days, only alarm on first entry
331 9         19 delete $last{VALARM};
332              
333 9         39 $last{DTSTART}=$event->{DTEND}->clone->truncate(to=>'day');
334 9         2385 $last{DTEND}=$event->{DTEND};
335 9         32 $event->{DTEND}=$event->{DTSTART}->clone->truncate(to=>'day')
336             ->add(days=>1);
337 9         8651 $last{hours}=_hours($last{DTEND}-$last{DTSTART});
338 9         46 $event->{hours}=_hours($event->{DTEND}-$event->{DTSTART});
339 9         34 my @a=();
340 9         46 my $min=$self->{span}->start;
341 9         298 my $max=$self->{span}->end;
342 9         167 for (my $d=$event->{DTEND}->clone;
343             $d < $last{DTSTART}; $d->add(days=>1)) {
344 2 50 33     120 if ($d >= $min && $d <= $max) {
345 2         711 my %t=%last;
346 2         10 $t{DTSTART}=$d->clone;
347 2         21 $t{DTEND}=$d->clone->add(days=>1);
348 2         1461 $t{hours}=_hours($t{DTEND}-$t{DTSTART});
349 2         17 push @a,\%t;
350             }
351             }
352 9         2165 my($start,$end)=($self->{span}->start,$self->{span}->end);
353 19         2723 map {$self->add_event($_)} grep {
354 9 100       333 $_->{DTSTART} >= $start && $_->{DTEND} <= $end
  20         3497  
355             } $event,@a,\%last;
356             }
357             1;
358             __END__
359              
360             =head1 NAME
361              
362             iCal::Parser - Parse iCalendar files into a data structure
363              
364             =head1 SYNOPSIS
365              
366             use iCal::Parser;
367              
368             my $parser=iCal::Parser->new();
369             my $hash=$parser->parse($file);
370              
371             $parser->parse($another_file);
372             my $combined=$parser->calendar;
373              
374             my $combined=iCal::Parser->new->parse(@files);
375             my $combined=iCal::Parser->new->parse_files(@files);
376             my $combined=iCal::Parser->new->parse_strings(@strings);
377              
378             =head1 DESCRIPTION
379              
380             This module processes iCalendar (vCalendar 2.0) files as specified in RFC 2445
381             into a data structure.
382             It handles recurrences (C<RRULE>s), exclusions (C<EXDATE>s), event updates
383             (events with a C<RECURRENCE-ID>), and nested data structures (C<ATTENDEES> and
384             C<VALARM>s). It currently ignores the C<VTIMEZONE>, C<VJOURNAL> and
385             C<VFREEBUSY> entry types.
386              
387             The data structure returned is a hash like the following:
388              
389             {
390             calendars=>[\%cal, ...],
391             events=>{yyyy=>{mm=>{dd}=>{UID=>\%event}}
392             todos=>[\%todo, ...]
393             }
394              
395             That is, it contains an array of calendar hashes, a hash of events key by
396             C<year=E<gt>month=E<gt>day=E<gt>eventUID>, and an array of todos.
397              
398             Calendars, events and todos are "rolled up" version os the hashes returned from
399             L<Text::vFile::asData>, with dates replaced by C<DateTime> objects.
400              
401             During parsing, events in the input calendar are expanded out into multiple
402             events, one per day covered by the event, as follows:
403              
404             =over 4
405              
406             =item *
407              
408             If the event is a one day "all day" event (in ical, the event is 24hrs long,
409             starts at midnight on the day and ends a midnight of the next day),
410             it contains no C<hour> field and the C<allday> field is set to C<1>.
411              
412             =item *
413              
414             If the event is a recurrence (C<RRULE>), one event per day is created as
415             per the C<RRULE> specification.
416              
417             =item *
418              
419             If the event spans more than one day (the start and end dates are on different
420             days, but does not contain an C<RRULE>),
421             it is expanded into multiple events, the first events end time is set
422             to midnight, subsequent events are set to start at midnight and end at
423             midnight the following day (same as an "allday" event, but the C<allday> field
424             is not set), and the last days event is set to run from midnight to the
425             end time of the original multi-day event.
426              
427             =item *
428              
429             If the event is an update (it contains a C<RECURRENCE-ID>), the original
430             event is updated. If the referenced event does not exist (e.g., it was
431             deleted after the update), then the event is added as a new event.
432              
433             =back
434              
435              
436             An example of each hash is below.
437              
438             =head2 Calendar Hash
439              
440             {
441             'X-WR-CALNAME' => 'Test',
442             'index' => 1,
443             'X-WR-RELCALID' => '7CCE8555-3516-11D9-8A43-000D93C45D90',
444             'PRODID' => '-//Apple Computer\\, Inc//iCal 1.5//EN',
445             'CALSCALE' => 'GREGORIAN',
446             'X-WR-TIMEZONE' => 'America/New_York',
447             'X-WR-CALDESC' => 'My Test Calendar',
448             'VERSION' => '2.0'
449             }
450              
451             =head2 Event Hash
452              
453             Note that C<hours> and C<allday> are mutually exclusive in the actual data.
454             The C<idref> field contains the C<id> of the calendar the event
455             came from, which is useful if the hash was created from multiple calendars.
456              
457             {
458             'SUMMARY' => 'overnight',
459             'hours' => '15.00',
460             'allday' => 1,
461             'UID' => '95CCBF98-3685-11D9-8CA5-000D93C45D90',
462             'idref' => '7CCE8555-3516-11D9-8A43-000D93C45D90',
463             'DTSTAMP' => \%DateTime,
464             'DTEND' => \%DateTime,
465             'DTSTART' => \%DateTime
466             'ATTENDEE' => [
467             {
468             'CN' => 'Jay',
469             'value' => 'mailto:jayl@my.server'
470             },
471             ],
472             'VALARM' => [
473             {
474             'when' => \%DateTime,
475             'SUMMARY' => 'Alarm notification',
476             'ACTION' => 'EMAIL',
477             'DESCRIPTION' => 'This is an event reminder',
478             'ATTENDEE' => [
479             {
480             'value' => 'mailto:cpan@my.server'
481             }
482             ]
483             }
484             ],
485             }
486              
487             =head2 Todo Hash
488              
489             {
490             'URL' => 'mailto:me',
491             'SUMMARY' => 'todo 1',
492             'UID' => 'B78E68F2-35E7-11D9-9E64-000D93C45D90',
493             'idref' => '7CCE8555-3516-11D9-8A43-000D93C45D90',
494             'STATUS' => 'COMPLETED',
495             'COMPLETED' => \%DateTime,
496             'DTSTAMP' => \%DateTime,
497             'PRIORITY' => '9',
498             'DTSTART' => \%DateTime,
499             'DUE' => \%DateTime,
500             'DESCRIPTION' => 'not much',
501             'VALARM' => [
502             {
503             'when' => \%DateTime,
504             'ATTACH' => 'file://localhost/my-file',
505             'ACTION' => 'PROCEDURE'
506             }
507             ],
508             },
509              
510             =head1 Methods
511              
512             =head2 new(%opt_args)
513              
514             =head3 Optional Arguments
515              
516             =over 4
517              
518             =item start {yyymmdd|DateTime}
519              
520             Only include events on or after C<yyymmdd>. Defaults to Jan of this year.
521              
522             =item end {yyyymmdd|DateTime}
523              
524             Only include events before C<yyymmdd>.
525              
526             =item no_events
527              
528             Don't include events in the output (todos only).
529              
530             =item no_todos
531              
532             Don't include todos in the output (events only).
533              
534             =item months n
535              
536             L<DateTime::Set>s (used for calculating recurrences) are limited to
537             approximately 200 entries. If an C<end> date is not specified, the
538             C<to> date is set to the C<start> date plus this many months.
539             The default is 60.
540              
541             =item tz (string|DateTime::TimeZone)
542              
543             Use tz as timezone for date values.
544             The default is 'local', which will adjust the parsed dates to the current timezone.
545              
546             =item debug
547              
548             Set to non-zero for some debugging output during processing.
549              
550             =back
551              
552             =head2 parse({file|file_handle}+)
553              
554             Parse the input files or opened file handles and return the generated hash.
555              
556             This function can be called mutitple times and the calendars will be
557             merge into the hash, each event tagged with the unique id of its calendar.
558              
559             =head2 parse_files({file|file_handle}+)
560              
561             Alias for C<parse()>
562              
563             =head2 parse_strings(string+)
564              
565             Parse the input strings (each assumed to be a valid iCalendar) and return
566             the generated hash.
567              
568             =head1 AUTHOR
569              
570             Rick Frankel, cpan@rickster.com
571              
572             =head1 COPYRIGHT
573              
574             This program is free software; you can redistribute
575             it and/or modify it under the same terms as Perl itself.
576              
577             The full text of the license can be found in the
578             LICENSE file included with this module.
579              
580              
581             =head1 SEE ALSO
582              
583             L<Text::vFile::asData>, L<DateTime::Set>, L<DateTime::Span>,
584             L<iCal::Parser::SAX>