#!/usr/bin/perl -w # # ical2ics_la.pl # (C) 2002 by Florian Schaefer # extensions (C) 2009 by John Heidemann # $Id: f03da987694b256bc2cab946c2287e161cebdf83 $ # # full copyright is at the end of the program # =head1 NAME ical2ics_la.pl - convert Sanjay Ghemawat's ical calendar files to IETF .ics (iCAL) =head1 SYNOPSIS ical2ics_la.pl [-d STRING] [-m mergefile] [infile] [outfile] =head1 DESCRIPTION This little script tries to convert ical calender files produced by Sanjay Ghemawat's ical to the iCAL (aka .ics aka iCalendar as defined by RFC-2445) format which can be read by GNOME Evolution or other compatible programs. By default the program reads standard input and writes to standard output. This program is based on a version by Florian Schaefer (thanks!) to convert from read-ical's ical files to Mozilla's Calendar, two different variants of these input and output formats. Changes to support ical and Evolution may have caused regressions for those programs (I cannot test them). The earlier version of this program had known problems with recurring events. This version has much better recurrent event handling and works for nearly all of the cases I found in my 15 years of ical, including weekly, bi-weekly, multi-day weekly, monthly, Nth Monday of the month, and annual recurring events, with exceptions. Whew. This version also will die when it finds things it doesn't know how to convert, allowing you to know what it doesn't. The experienced user (and are there any ical users left for whom that doesn't apply?) will then correct the code or at least comment out the C statement to allow things to continue. In addition to this testier error handling, this version fixes some bugs and many warnings. Hopefully the new bugs do not outweigh those removed. =head1 OPTIONS =over =item B<-d STRING> Change default description to STRING =item B<-m MERGEFILENAME> Merge the outfile with mergefile (the syntax has to be similar to the one created by this script), may be the outfile itself. =item B<-v> Enable verbose output. =item B<--help> Show help. =item B<--man> Show full manual. =back =cut use strict; use Pod::Usage; use Getopt::Long; # # Note, in this document # ical == Ghewmat's ical, # while # ics == the iCAL(endar) Internet standard # use POSIX qw(strftime); use Time::Local; my(@uidl) =""; $uidl[0]=0; my $mergefile = undef; my $description = undef; # "Imported from Palm Pilot."; my($infile, $outfile); # names of the streams my $verbose = 0; # # Converts a date from dd/mm/yyyy format to yyyymmdd format # sub create_date { my ($raw) = @_; $raw =~ s/^([0-9]*)\/([0-9]*)\/([0-9]*)$/$3/; if (length($raw) > 4) {chop($raw);} $raw .= sprintf("%02i%02i", $2, $1); return ($raw); } # # Converts a time in minutes since midnight into hhmm format # sub create_time { my ($mins) = @_; my $hour = sprintf("%02i",$mins/60); my $min = sprintf("%02i",($mins-60*$hour)); return ("$hour$min"."00"); } # # convert from ical to ics day names # could be a space-separated list, or a singleton # sub daynumbers_to_daynames { my($daynames) = @_; $daynames =~ s/1/SU/; $daynames =~ s/2/MO/; $daynames =~ s/3/TU/; $daynames =~ s/4/WE/; $daynames =~ s/5/TH/; $daynames =~ s/6/FR/; $daynames =~ s/7/SA/; $daynames =~ s/ /,/g; return $daynames; }; sub yyyymmdd_to_dow { my($yyyymmdd) = @_; my ($yyyy, $mm, $dd) = ($yyyymmdd =~ /^(\d{4})(\d{2})(\d{2})$/); die "bogus yyyymmdd: $yyyymmdd" if (!defined($dd)); while ($yyyy < 1910) { # timelocal only works with things in the Unix epoch # but 1900 has the same dow as 1973, so adopt $yyyy = $yyyy + 73; }; my $epoch = timelocal(0, 0, 12, $dd, $mm-1, $yyyy); # timelocal month 0=Jan. my $dow = (localtime($epoch))[6] + 1; # localtime 0=Sun return daynumbers_to_daynames($dow); } # # The function manages to exclude dates on repeated events # # Input is a comma-joined list of dates. # If null or undef, return is empty string. # # Valid exclusions in evolution: # RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO;WKST=SU # EXDATE;VALUE=DATE:20090622 # (with multiple lines) # # or # RRULE:FREQ=WEEKLY;UNTIL=20090625;INTERVAL=1;BYDAY=TH;WKST=SU # SEQUENCE:1 # EXDATE:20090611T080000 # # Output is the first form # sub exclude_dates { my ($exdate) = @_; return '' if (!defined($exdate) || $exdate eq ''); my $output =""; foreach (split(/,/, $exdate)) { $output .= "EXDATE;VALUE=DATE:$_\n"; }; return $output; } # quote text as per rfc2445 sectoin 4.3.11 sub quote_text { my($q) = @_; $q =~ s/\\/\\\\/g; $q =~ s/,/\\,/g; $q =~ s/;/\\;/g; $q =~ s/\n/\\n/g; return $q; } # wrap lines to 75 chars as per rfc2445 section 4.1 sub linewrap { my($t) = @_; my $o = ''; foreach (split(/\n/, $t)) { if (length($_) < 75) { $o .= $_ . "\n"; next; }; # split it my $leader = ""; while (length($_) > 70) { $o .= $leader . substr($_, 0, 70) . "\n"; $_ = substr($_, 70); $leader = " "; }; $o .= $leader . $_ . "\n"; }; return $o; } # # This is the main part: &convert gets an array with the lines # of one event and the number of lines. It does all the fancy # work. # sub convert { my ($lines, @raw) = @_; my ($uid, $cont, $start, $length); my($finishdate, $extrastart) = (0, 0); my $exdate = ""; $start = ""; my $text_chars_to_go = undef; my $multiline_text = undef; my $date; # # This loop tries to get all relevant information # for (my $pos = 0; $pos < $lines; $pos++) { # ical text fields if ($text_chars_to_go) { if (length($raw[$pos]) < $text_chars_to_go) { # middle lines $multiline_text .= $raw[$pos]; $text_chars_to_go -= length($raw[$pos]); next; } else { # last line $multiline_text .= substr($raw[$pos], 0, $text_chars_to_go); my $rest = substr($raw[$pos], $text_chars_to_go); # die "confusion on last line of multline text: $text_chars_to_go to go but, line $raw[$pos]\n" # if ($text_chars_to_go > 0 && $rest !~ /^\]\]\s$/); $cont = quote_text($multiline_text); $multiline_text = $text_chars_to_go = undef; next; }; }; if ($raw[$pos] =~ /^Text \[(\d+) \[(.*)/ms) { $text_chars_to_go = $1; $multiline_text = substr($2, 0, $text_chars_to_go); $text_chars_to_go -= length($multiline_text); if ($text_chars_to_go <= 0) { $cont = quote_text($multiline_text); $multiline_text = $text_chars_to_go = undef; # paranoia }; next; }; if ($raw[$pos] =~ /^Contents \[([^\]]*)$/) { $multiline_text = $1; next; } if ($multiline_text) { if ($raw[$pos] =~ /([^\]]*)\]/) { $cont = quote_text($multiline_text . $1); undef $multiline_text; } else { $multiline_text .= $raw[$pos]; }; next; } if ($raw[$pos] =~ /^Deleted /) { # # find dates to be excluded from a repeating event # my $rawdate = $raw[$pos]; $rawdate =~ s/ End//; $rawdate =~ s/Deleted //; $exdate .= &create_date($rawdate).","; next; } # # and finally split the info into two pieces. # # Two cases: # Start [900] # Length [120] # and when in a Dates field: # Start 5/10/1994 # Finish 31/7/1995 End # next if ($raw[$pos] =~ /^\]$/); # end of record next if ($raw[$pos] =~ /^\]\]$/); # end of multi-line Text my($item, $value) = ($raw[$pos] =~ /^(\S+)\s+\[?(\S.*)$/); if (!defined($value)) { die "undefined value on $item, line $raw[$pos]\n"; } else { chomp($value); $value =~ s/\]$//; }; if ($item eq "PilotRecordId") { $uid = $value; # palm } elsif ($item eq "Uid") { $uid = $value; # ical } elsif ($item eq "Contents") { $cont = $value; # palm } elsif ($item eq "Start") { if ($value =~ /\//) { # this is a start line inside a Dates block ($extrastart) = ($value =~ /^([\/0-9]+)/); $extrastart = &create_date($extrastart); } else { # this is the outside the Dates start line $start = $value; }; } elsif ($item eq "Finish") { if ($value =~ /\//) { # this is a finish line inside a Dates block # just ignore it ($finishdate) = ($value =~ /^([\/0-9]+)/); $finishdate = &create_date($finishdate); } else { die "unexpected finish on line $raw[$pos]\n"; }; } elsif ($item eq "Length") { $length = $value; } elsif ($item eq "Dates") { $date = $value; } elsif (grep {$item eq $_} qw( Owner Remind Hilite Appt Todo Done Note Alarms )) { # ignored from my ical: Owner Remind Hilite Appt Todo Done } else { die "unknown item $item on line $raw[$pos]\n"; }; } # my ical has some bogus events. skip them: if (defined($date) && $date eq 'Empty End') { warn "skipping event $cont because of bogus empty-end date\n"; return ''; }; if (!defined($cont)) { warn "event has no contents\n"; $cont = "unknown event"; }; # # the header of each event # my $out =""; $out .= "\nBEGIN:VEVENT\n"; $out .= "UID:$uid\n"; $out .= "SUMMARY:$cont\n"; $out .= "CLASS:PRIVATE\n"; $out .= "DESCRIPTION:$description\n" if ($description); # # Parse the many date forms # if ($date =~ /^\d{8}T\d{6}$/) { # hmmm, a converted date that slipped through. Let it go. } elsif ($date =~ /^Single ([\/0-9]+) End/) { # simple case $date = create_date($1); } elsif ($date =~ /^WeekDays\s+([0-9 ]+)\s+Months\s+([0-9 ]+)/) { my($daynames, $monthnames) = ($1, $2); #$date =~ /^WeekDays\s+([0-9 ]+)\s+Months\s+([0-9 ]+)/); die "unhandeled WeekDays/Months problem on $date\n" if ($monthnames ne '1 2 3 4 5 6 7 8 9 10 11 12'); $out .= "RRULE:"; $out .= "FREQ=WEEKLY;INTERVAL=1"; if ($finishdate) { $out .= ";UNTIL=$finishdate"; } $out .= ";BYDAY=" . daynumbers_to_daynames($daynames); $out .= "\n" . exclude_dates($exdate); # # The date of the first event is in another field # $date = $extrastart; } elsif ($date =~ /^Days\s+([\/0-9]+)\s+(\d+)/) { # # It is a repeated event with days (or weeks) # my($this_start) = $1; my($interval) = $2; $date = create_date($this_start); # propagate down if ($interval >= 7 && $interval % 7 == 0) { # # 7 days, let's make 1 week out of it # (or a multiple) # # Dates [Days 4/1/1900 7 # Start 13/9/2007 # => # RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TH;WKST=SU # my($interval) = $interval / 7; $out .= "RRULE:"; $out .= "FREQ=WEEKLY;INTERVAL=$interval"; $out .= ";BYDAY=" . yyyymmdd_to_dow($date) . ";WKST=SU"; if ($finishdate) { $out .= ";UNTIL=$finishdate"; }; } elsif ($interval == 1) { # # seems like a real event for a dayly recurrence # # $out .= "X-MOZILLA-RECUR-DEFAULT-UNITS:days\nX-MOZILLA-RECUR-DEFAULT-INTERVAL:$interval\n"; $out .= "RRULE:"; $out .= "FREQ=DAILY"; if ($finishdate) { $out .= ";UNTIL=$finishdate;"; } $out .= ";INTERVAL=$interval"; } else { die "confusing Days entry $date\n"; }; $out .= "\n" . exclude_dates($exdate); } elsif ($date =~ /^Months\s+([\/0-9]+)\s+(\d+)(\s+End)?/) { # # It is a repeated event - the same with months # # Dates [Months 25/12/1994 12 End # => RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=12 # # NO, that rule is parsed by evolution, but is not editable. # Evolution generates: # RRULE:FREQ=YEARLY;WKST=SU # with the day specified as: # DTSTART;VALUE=DATE:19380704 # DTEND;VALUE=DATE:19380705 # my($this_start) = $1; my($interval) = $2; $date = create_date($this_start); # propagate down $out .= "RRULE:"; if ($interval == 12) { # # 12 month make one year # $out .= "FREQ=YEARLY;INTERVAL=1"; if ($finishdate) { $out .= ";UNTIL=$finishdate"; } my($month_number) = ($date =~ /^\d{4}(\d{2})/); $month_number =~ s/^0//g; $out .= ";BYMONTH=$month_number"; } elsif ($interval == 1) { # xxx: I never saw this (johnh) ## $out .= "X-MOZILLA-RECUR-DEFAULT-UNITS:years\nX-MOZILLA-RECUR-DEFAULT-INTERVAL:$interval\n"; $out .= ";FREQ=MONTHLY"; if ($finishdate) { $out .= ";UNTIL=$finishdate"; } $out .= ";INTERVAL=$interval\n"; } else { die "confusing Months entry $date\n"; }; $out .= "\n" . exclude_dates($exdate); } elsif ($date =~ /^ComplexMonths\s+(\d+)\s+(\d+)\s+([\/0-9]+)\s+(Forward|Backward) ByWeek\s+(\d+)/) { # # we're having fun now! # # Dates [ComplexMonths 12 3 17/2/1997 Forward ByWeek 2 # => 3rd Monday every Feb # RRULE:FREQ=MONTHLY;INTERVAL=12;BYDAY=MO;BYSETPOS=3;WKST=SU # # Dates [ComplexMonths 12 1 26/5/1997 Backward ByWeek 2 # => last Monday every May # RRULE:FREQ=MONTHLY;INTERVAL=12;BYDAY=MO;BYSETPOS=-1;WKST=SU # # Dates [ComplexMonths 1 1 2/10/2000 Forward ByWeek 2 # => 1st Monday every month # # Dates [ComplexMonths 2 3 21/11/2000 Forward ByWeek 3 # => 3rd Tuesday every 2nd Month # RRULE:FREQ=MONTHLY;INTERVAL=2;UNTIL=20041116;BYDAY=TU;BYSETPOS=3;WKST=SU # my($interval, $ordinal, $this_start, $direction, $day_of_week) = ($1, $2, $3, $4, $5); my $complex_date = $date; # save for errors $date = create_date($this_start); # propagate down if ($direction eq 'Forward') { # happy } elsif ($direction eq 'Backward') { $ordinal *= -1; } else { die "bogus direction $direction in $complex_date\n"; }; $out .= "RRULE:" . "FREQ=MONTHLY" . ";INTERVAL=$interval" . ($finishdate ? ";UNTIL=$finishdate" : '') . ";BYDAY=" . daynumbers_to_daynames($day_of_week) . ";BYSETPOS=$ordinal" . ";WKST=SU"; $out .= "\n" . exclude_dates($exdate); } else { die "cannot parse Dates line: $date\n"; }; if ($start) { # # Now just print the start and end time. # my $final_start = ($extrastart != 0 ? $extrastart : $date); $out = $out."DTSTART:".$final_start."T".&create_time($start)."\n"; $out = $out."DTEND:".$final_start."T".&create_time($start+$length)."\n"; } else { # # There is no time for the beginning of the event defined, I assume # that the event should last all day. # $out = $out."DTSTART;VALUE=DATE:".$date."\n"; } # # The RFC requires me to include the time of the object creation # $date = strftime "%Y%m%dT%H%M%S", localtime; $out = $out."DTSTAMP:".$date."\n"; $out = $out."END:VEVENT\n"; # # Remember the UID for later merging # $uidl[0]++; $uidl[$uidl[0]]=$uid; return ($out); } # # This sub takes an older ical file and merges it to the new one. # It does this by comparing the stored UID's and adding all entries # whose ID hasn't been found. As you can see, the new events have # got a higher priority and can delete newer entries from the old file # sub merge_entries { my($mergefile) = @_; my $out = ""; # this string will be added later my $event = ""; # the current event being processed my $record = 0; # is true if loop is in an event object my $nextoneuid = 0; # marks the line for the UID my $uidfound = 0; # is true if UID is already in new file open MERGEFILE, $mergefile or die "Can't open '$mergefile' for input $!"; foreach my $line () { if ($line =~ /BEGIN:VCALENDAR$/) { $event = ""; $record = 1; } if ($record == 1) {$event.=$line}; if ($nextoneuid == 1) { $nextoneuid = 0; $uidfound = $line; $uidfound =~ s/^ ://; chop($uidfound); } # This way of finding the UID depends on having two lines # for field descriptor and value and is therefore Mozilla # Calendar specific. if ($line =~ /^UID$/) {$nextoneuid=1}; if ($line =~ /^END:VCALENDAR$/) { $record = 0; my $found = 0; for (my $c=1; $c < $uidl[0]+1; $c++) { $found=1 if ($uidl[$c] eq $uidfound); } if ($found == 0) { $out .= $event; } } } close (MERGEFILE); return($out); } # # Here the input file is read and each event is then given to # &convert for further processing. # sub read_palmfile { my ($instream, $outstream) = @_; # Next two lines were //=, but I got multiple complaints # of people stuck with perl-5.8 $instream = (defined($instream)) ? $instream : ''; $outstream = (defined($outstream)) ? $outstream : ''; if (($instream ne "") && ($instream ne "-")) { open STDIN, $instream or die "Can't open '$instream' for input: $!";} if ($outstream ne "") { open STDOUT, ">$outstream" or die "Can't open '$outstream' for output $!";;} my $line = ""; my $pos = 0; my @note; # overall header print "BEGIN:VCALENDAR\nVERSION:2.0\n" . "PRODID:PRODID:-//netego.de/NONSGML ical2ics_la.pl//EN\n"; foreach $line () { if ($line =~ /^(Note|Appt) \[/) { if ($pos != 0) { print linewrap(convert($pos,@note)); } $pos = 0; $note[$pos] = $line; $pos++; } elsif ($pos != 0) { $note[$pos] = $line; $pos++; } } # close (INFILE); print linewrap(convert($pos, @note)); print &merge_entries($mergefile) if ($mergefile); print "\nEND:VCALENDAR\n"; } # # Well, the first sub has to be called somewhere. ;-) # Most of the stuff below is just option parsing. # Getopt::Long::Configure ("bundling"); pod2usage(2) if ($#ARGV >= 0 && $ARGV[0] eq '-?'); &GetOptions( 'h|help|?' => sub { pod2usage(1); }, 'man' => sub { pod2usage(-verbose => 2); }, # 'd|debug+' => \$debug, 'd|description=s' => \$description, 'm|mergefile=s'=> \$mergefile, 'v|verbose+' => \$verbose) or pod2usage(2); if ($#ARGV == -1) { $infile = $outfile = '-'; } elsif ($#ARGV == 0) { $infile = $ARGV[0]; $outfile = '-'; } elsif ($#ARGV == 1) { $infile = $ARGV[0]; $outfile = $ARGV[1]; } else { pod2usage(2); }; if (defined($mergefile)) { # mergefile and outfile mustn't be the same, work # with a backup copy instead my $rnd = int(rand(100000)); # xxx: should use File::Copy my($status) = `cp $mergefile /tmp/ical2ics-$rnd.tmp`; $mergefile="/tmp/ical2ics-$rnd.tmp"; } &read_palmfile($infile,$outfile,$mergefile); unlink $mergefile if (defined($mergefile)); exit 0; # ChangeLog (for ical2ics.pl; not maintained for ical2ics_la.pl) # # 25.07.2002: # - Switched from the old "X ;MEMBER" fields to the new "X-MOZILLA" properties # - Events that last all day don't use "MEMBER=AllDay" any more, I just give a # DTSTART date and no DTEND # # 24.05.2002: # - New option for merging ('-d') with existing data # # 13.05.2002: # - Added DTSTAMP property # # 25.04.2002: # - The bug in libical and Calendar concerning the EXDATE property have been # fixed, therefore I removed the RECURRENCE-ID alternative. # - Added the -d option # # 24.04.2002: # - First official release =head1 BUGS AND CREDITS =head2 CHANGES Changed 4-Sep-09 to avoid the //= construct so it runs on perl-5.8, not just perl-5.10. Patch added 2012-06-08 from Andrew Pam. He says: "... to make it work with my .calendar file from ical v2.3.3 which has multi-line "Contents" fields plus new "Todo" and "Done" fields.". Not extensively tested by me. =head2 INSTALLATION AND RELEASE Releases of the extended version, such as there may be, can be found at F. The original program requested: please report bugs or send comments to F. (And I have told him about my changes.) Florian's original version is at F and F. =head2 COPYRIGHT AND AUTHOR Original author: Florian Schaefer F, April 2002. Original copyright: Feel free to distribute this file under the terms of the GPL. Extensions in F by John Heidemann F. I added "la" (for Los Angeles) to decrease confusion about what version is what. The extended version is released with Florian's blessing (thanks!). Current copyright (a more explicit version of the original copyright): This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License, version 2, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. =cut # LocalWords: ical Sanjay Ghemawat's iCAL ics iCalendar Schaefer ical's GPL # LocalWords: Mozilla's