package Tie::iCal; use strict; require Exporter; our $VERSION = 0.13; our @ISA = qw(Exporter); use Tie::File; =head1 NAME Tie::iCal - Tie iCal files to Perl hashes. =head1 VERSION This document describes version 0.13 released 29th January 2005. =head1 SYNOPSIS use Tie::iCal; tie %my_events, 'Tie::iCal', "mycalendar.ics" or die "Failed to tie file!\n"; tie %your_events, 'Tie::iCal', "yourcalendar.ics" or die "Failed to tie file!\n"; $my_events{"A-NEW-UNIQUE-ID"} = [ 'VEVENT', { 'SUMMARY' => 'Bastille Day Party', 'DTSTAMP' => '19970714T170000Z', 'DTEND' => '19970715T035959Z', } ]; tie %our_events, 'Tie::iCal', "ourcalendar.ics" or die "Failed to tie file!\n"; # assuming %my_events and %your_events # have no common keys (unless that's your intention) # while (my($uid,$event) = each(%my_events)) { $our_events{$uid} = $event; } while (my($uid,$event) = each(%your_events)) { $our_events{$uid} = $event; } untie %our_events; untie %your_events; untie %my_events; =head1 DEPENDENCIES Tie::File =head1 DESCRIPTION Tie::iCal represents an RFC2445 iCalendar file as a Perl hash. Each key in the hash represents an iCalendar component like VEVENT, VTODO or VJOURNAL. Each component in the file must have a unique UID property as specified in the RFC 2445. A file containing non-unique UIDs can be converted to have only unique UIDs (see samples/uniquify.pl). The module makes very little effort in understanding what each iCalendar property means and concentrates on the format of the iCalendar file only. =head1 FILE LOCKING The Tie::iCal object returned by tie can also be used to access the underlying Tie::File object. This is accessable via the 'A' class variable. This may be useful for file locking. my $ical = tie %events, 'Tie::iCal', "mycalendar.ics"; $ical->{A}->flock; =head1 DATES The iCalendar specification uses a special format for dates. This module makes no effort in trying to interpret dates in this format. You should look at the Date::ICal module that can convert between Unix epoch dates and iCalendar date strings. =cut sub TIEHASH { my ($p, $f, %O) = @_; tie my @a, 'Tie::File', $f, recsep => "\r\n" or die "failed to open ical file\n"; $O{A} = \@a; # file array $O{i} = 0; # current file index for FIRSTKEY and NEXTKEY $O{C} = (); # uid to index cache bless \%O => $p; } sub FETCH { my $self = shift; my $uid = shift; my $index = $self->seekUid($uid); return defined $index ? $self->toHash($index) : undef; } sub EXISTS { my $self = shift; my $uid = shift; my $index = $self->seekUid($uid); return defined $index ? 1 : 0; } sub FIRSTKEY { my $self = shift; $self->{i} = 0; for my $line (@{$self->{A}}) { if ($line =~ m/^UID/) { if ($self->unfold($self->{i}) =~ /^UID.*:(.*)$/) { $self->{C}->{$1} = $self->{i}; # cache in any case return $1; } else { warn("FISRTKEY: discovered illegal UID property format, should be like UID;...:..., ignoring for now\n"); } } $self->{i}++; } } # copy of FIRSTKEY but with extra condition $self->{i} > last index # sub NEXTKEY { my $self = shift; my $i = $self->{i}; $self->{i} = 0; for my $line (@{$self->{A}}) { if ($line =~ m/^UID/ && $self->{i} > $i) { if ($self->unfold($self->{i}) =~ /^UID.*:(.*)$/) { $self->{C}->{$1} = $self->{i}; # cache in any case return $1; } else { warn("NEXTKEY: discovered illegal UID property format, should be like UID;...:..., ignoring for now\n"); } } $self->{i}++; } return undef; } sub SCALAR { my $self = shift; my $count = 0; for my $line (@{$self->{A}}) { $count++ if $line =~ m/^UID/; } return $count; } sub ceil { return int($_[0]) + (int($_[0]) != $_[0]); } sub fold { my $MAXLENGTH = 75; my @A; foreach my $string (@_) { my @B = unpack("A$MAXLENGTH" x (&ceil(length($string)/$MAXLENGTH)), $string); push @A, $B[0], map { ' '.$_ } @B[1..$#B]; } return @A; } sub STORE { my $self = shift; my $uid = shift; my $c = shift; die "event must be array!\n" if ref $c ne 'ARRAY'; $self->DELETE($uid); push @{$self->{A}}, fold($self->toiCal($uid, $c)); } sub DELETE { my $self = shift; my $uid = shift; my $index = $self->seekUid($uid); return defined $index ? $self->removeComponent($index) : 0; } sub CLEAR { my $self = shift; @{$self->{A}} = (); } sub DESTROY { my $self = shift; untie $self->{A}; } sub debug { my $self = shift; print(STDERR shift, "\n") if $self->{debug}; } sub unfold { my $self = shift; my $index = shift; my $result = ${$self->{A}}[$index]; my $i = 1; until (${$self->{A}}[$index + $i] !~ /^ (.*)$/s) { $result .= $1; $i++; } $self->debug("unfolded index $index to $result"); return $result; } sub seekUid { my $self = shift; my $uid = shift; my $index; # check cache # if (exists $self->{C}->{$uid}) { $self->debug("found cached index for $uid, checking.."); $index = $self->{C}->{$uid}; if ($self->unfold($index) =~ /^UID.*:(.*)$/) { if ($1 eq $uid) { $self->debug("found key $uid in cache"); return $index; } else { $self->debug("could not find key $uid in cache, deleting"); delete $self->{C}->{$uid}; } } else { warn("seekUid: discovered illegal UID property format, should be like UID;...:..., ignoring for now\n"); } } # not in cache then lets search the file # $index = 0; for my $line (@{$self->{A}}) { if ($line =~ m/^UID/) { if ($self->unfold($index) =~ /^UID.*:(.*)$/) { $self->{C}->{$1} = $index; # cache in any case if ($1 eq $uid) { $self->debug("found key $uid"); return $index; } } else { warn("discovered illegal UID property format, should be like UID;...:..., ignoring for now\n"); } } $index++; } # doesn't exist! # return undef; } sub removeComponent { my $self = shift; my $index = shift; my $i; $i = 0; $i++ until ${$self->{A}}[$index - $i] =~ /^BEGIN:(\w+)$/; my $si = $index - $i; my $component = $1; $i = 0; $i++ until ${$self->{A}}[$index + $i] =~ /^END:$component/; my $fi = $index + $i; $self->debug("component $component found between [$si, $fi]"); splice @{$self->{A}}, $si, $fi - $si + 1; } =head1 How Tie::iCal interprets iCal files Tie::iCal interprets files by mapping iCal components into Perl hash keys and iCal content lines into various Perl arrays and hashes. =head2 Components An iCal component such as VEVENT, VTODO or VJOURNAL maps to a hash key:- BEGIN:VEVENT UID:a_unique_uid NAME1:VALUE1 .. END:VEVENT corresponds to $events{'a_unique_uid'} = ['VEVENT', {'NAME1' => 'VALUE1'}] =head2 Subcomponents An iCal subcomponent such as VALARM maps to a list of hash keys:- BEGIN:VALARM TRIGGER;VALUE=DURATION:-PT1S TRIGGER;VALUE=DURATION:-PT1S END:VALARM BEGIN:VALARM X-TIE-ICAL;VALUE=ANOTHER:HERE X-TIE-ICAL:HERE2 X-TIE-ICAL-NAME:HERE2 END:VALARM corresponds to 'VALARM' => [ { 'TRIGGER' => [ [{'VALUE' => 'DURATION'},'-PT1S'], [{'VALUE' => 'DURATION'},'-PT1S'] ] }, { 'X-TIE-ICAL' => [ [{'VALUE' => 'ANOTHER'},'HERE'], ['HERE2'] ], 'X-TIE-ICAL-NAME' => 'HERE2' } ] To see how individual content lines are formed see below. =head2 Content Lines Once unfolded, a content line may look like:- NAME;PARAM1=PVAL1;PARAM2=PVAL2;...:VALUE1,VALUE2,... having an equivalent perl data structure like: - 'NAME' => [{'PARAM1'=>'PVAL1', 'PARAM2'=>'PVAL2', ..}, 'VALUE1', 'VALUE2', ..] or NAME:VALUE1,VALUE2,... having an equivalent perl data structure like: - 'NAME' => ['VALUE1', 'VALUE2', ..] or NAME:VALUE having an equivalent perl data structure like: - 'NAME' => 'VALUE' Multiple contentlines with same name, i.e. FREEBUSY, ATTENDEE:- NAME;PARAM10=PVAL10;PARAM20=PVAL20;...:VALUE10,VALUE20,... NAME;PARAM11=PVAL11;PARAM21=PVAL21;...:VALUE11,VALUE21,... ... having an equivalent perl data structure like: - 'NAME' => [ [{'PARAM10'=>'PVAL10', 'PARAM20'=>'PVAL20', ..}, 'VALUE10', 'VALUE20', ..], [{'PARAM11'=>'PVAL11', 'PARAM21'=>'PVAL21', ..}, 'VALUE11', 'VALUE21', ..], ... ] or NAME:VALUE10,VALUE20,... NAME:VALUE11,VALUE21,... ... having an equivalent perl data structure like: - 'NAME' => [ ['VALUE10', 'VALUE20', ..], ['VALUE11', 'VALUE21', ..], ... ] or in a mixed form, i.e. NAME:VALUE10,VALUE20,... NAME;PARAM11=PVAL11;PARAM21=PVAL21:VALUE11,VALUE21,... NAME:VALUE12,VALUE22,... ... having an equivalent perl data structure like: - 'NAME' => [ ['VALUE10', 'VALUE20', ..], [{'PARAM11'=>'PVAL11', 'PARAM21'=>'PVAL21', ..}, 'VALUE11', 'VALUE21', ..], ['VALUE12', 'VALUE22', ..], ... ] =cut sub toiCal { my $self = shift; my $uid = shift; my $c = shift; my $excludeComponent = shift; my @lines; my ($component, $e) = $excludeComponent ? (undef, $c) : @$c; push @lines, "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Numen Inest/NONSGML Tie::iCal $VERSION//EN", "BEGIN:$component", "UID:$uid" if ! $excludeComponent; foreach my $name (keys %$e) { if ($name eq 'RRULE') { if (ref($$e{$name}) ne 'HASH') { warn "RRULE property should be expressed as a hash, ignoring..\n"; } else { my @rrule; foreach my $k (keys %{$$e{$name}}) { push @rrule, ref(${$$e{$name}}{$k}) eq 'ARRAY' ? "$k=".join(',', @{${$$e{$name}}{$k}}) : "$k=".${$$e{$name}}{$k}; } push @lines, "$name:".join(';',@rrule); } } elsif (ref(\$$e{$name}) eq 'SCALAR') { push @lines, "$name:$$e{$name}"; } elsif (ref($$e{$name}) eq 'ARRAY') { if (@{$$e{$name}} && !grep({ref($_) ne 'HASH'} @{$$e{$name}})) { # strict list of hashes => we have a subcomponent push @lines, "BEGIN:$name"; foreach my $sc (@{$$e{$name}}) { push @lines, $self->toiCal(undef, $sc, 1); } push @lines, "END:$name"; } elsif (@{$$e{$name}} && !grep({ref($_) ne 'ARRAY'} @{$$e{$name}})) { # strict list of arrays => we have several content lines foreach my $cl (@{$$e{$name}}) { if (ref(${$cl}[0]) eq 'HASH') { # we have params my ($params, @values) = @{$cl}; push @lines, "$name;".join(";", map { "$_=$$params{$_}" } keys(%$params)).":".join(',',@values); } else { # we only have values push @lines, "$name:".join(',',@{$cl}); } } } else { my ($params, @values) = @{$$e{$name}}; push @lines, "$name;".join(";", map { "$_=$$params{$_}" } keys(%$params)).":".join(',',@values); } } else { warn "ignoring unimplemented ",ref(\${$e}{$name})," -> ",$name."\n"; } } push @lines, "END:$component", "END:VCALENDAR" if ! $excludeComponent; return @lines; } # taken from Text::ParseWords without single quote as quote char # and keep flag # sub parse_line { # We will be testing undef strings no warnings; use re 'taint'; # if it's tainted, leave it as such my($delimiter, $line) = @_; my($word, @pieces); while (length($line)) { $line =~ s/^(["]) # a $quote ((?:\\.|(?!\1)[^\\])*) # and $quoted text \1 # followed by the same quote | # --OR-- ^((?:\\.|[^\\"])*?) # an $unquoted text (\Z(?!\n)|(?-x:$delimiter)|(?!^)(?=["])) # plus EOL, delimiter, or quote //xs or return; # extended layout my($quote, $quoted, $unquoted, $delim) = ($1, $2, $3, $4); return() unless( defined($quote) || length($unquoted) || length($delim)); $quoted = "$quote$quoted$quote"; $word .= defined $quote ? $quoted : $unquoted; if (length($delim)) { push(@pieces, $word); undef $word; } if (!length($line)) { push(@pieces, $word); } } return(@pieces); } sub toHash { my $self = shift; my $index = shift; my $excludeComponent = shift; my $i; $i = 0; $i++ until ${$self->{A}}[$index - $i] =~ /^BEGIN:(\w+)$/; my $si = $index - $i; my $component = $1; $i = 0; $i++ until ${$self->{A}}[$index + $i] =~ /^END:$component/; my $fi = $index + $i; $self->debug("component $component found between [$si, $fi]"); my %e; my $subComponent = ''; for my $i ($si+1..$fi-1) { next if ${$self->{A}}[$i] =~ m/^UID/; if (${$self->{A}}[$i] =~ m/^\w+/) { my $contentLine = $self->unfold($i); if ($subComponent ne '') { # we are in a subcomponent $subComponent = '' if $contentLine =~ /^END:$subComponent$/; next; } elsif ($contentLine =~ /^BEGIN:(\w+)$/) { # we have found a subcomponent $subComponent = $1; push @{$e{$subComponent}}, $self->toHash($i, 1); } elsif ($contentLine =~ /^[\w-]+;.*$/s) { # we have params my ($nameAndParamString, @valueFragments) = &parse_line(':', $contentLine); my @values = &parse_line(',', join(':', @valueFragments)); my ($name, @params) = &parse_line(';', $nameAndParamString); my %params = map { my ($p, $v) = split(/=/, $_); $p => $v } @params; if (exists $e{$name}) { if (!(@{$e{$name}} && !grep({ref($_) ne 'ARRAY'} @{$e{$name}}))) { # not a strict list of arrays $self->debug("found singleton data, converting to list.."); $e{$name} = [$e{$name}, [{%params}, @values]]; } else { push @{$e{$name}}, [{%params}, @values]; } } else { $e{$name} = [{%params}, @values]; } } elsif ($contentLine =~ /^[\w-]+:.*$/s) { # we don't have params my ($name, @valueFragments) = &parse_line(':', $contentLine); my @values; if ($name eq 'RRULE') { my @params = &parse_line(';', join(':', @valueFragments)); my %params = map { my ($p, $v) = split(/=/, $_); $p => $v =~ /,/ ? [split(/,/,$v)] : $v } @params; push @values, {%params}; } else { @values = &parse_line(',', join(':', @valueFragments)); } if (exists $e{$name}) { if (!(@{$e{$name}} && !grep({ref($_) ne 'ARRAY'} @{$e{$name}}))) { # not a strict list of arrays $self->debug("found singleton data, converting to list.."); $e{$name} = [$e{$name}, [@values]]; } else { push @{$e{$name}}, [@values]; } } else { $e{$name} = @values == 1 ? $values[0] : [@values]; } } else { # what do we have? warn("discovered illegal property format, should be like NAME;...:..., ignoring for now\n"); } } } return $excludeComponent ? \%e : [$component, \%e] ; } =head1 BUGS Property names are assumed not to be folded, i.e. DESCR IPTION:blah blah.. RRULE property does not support parameters. Property names that begin with UID can potentially confuse this module. Subcomponents such as VALARM must exist after any UID property. Deleting events individually may leave non-RFC2445 compliant empty VCALENDAR objects. =head1 AUTHOR Blair Sutton, <mailto:bsdz@cpan.org>, L<http://www.numeninest.com/> =head1 COPYRIGHT Copyright (c) 2005 Blair Sutton. All rights reserved. This program is free software; you can redistribute it and/or modify it under the same terms as Perl itself. =head1 SEE ALSO L<perl>, L<Tie::File>, L<Date::ICal> =cut 1;