#!/usr/bin/perl -w # # What's Going On. Given one or more .ics files on the command line, # produces a text report showing what's happening in the next six weeks. # # This was my main reason for working on this -- I've been producing things # like this by hand for a mailing list of friends on and off for the past # six years, and was too lazy to automate the damn thing. # # To see it in action, try fetching the london.pm calendar from # http://london.pm.org/ical.ics, then run "perl wgo ical.ics" # # Nik Clayton, use strict; use lib '../lib'; use Text::vFile::asData; use DateTime; use DateTime::Span; use DateTime::Format::ICal; use IO::File; # Could use Lingua::*::Inflect, but it's overkill for a quick demo # 1 2 3 4 5 6 7 8 9 10 my @inflect = qw(st nd rd th th th th th th th th th th th th th th th th th st nd rd th th th th th th th st); # Get the current time, and the time six weeks from now. Then build a # DateTime::Span. We'll test every event we find to see if it, or any of # it's recurrences, are inside this span. If they are, we need to include # them in the output. # # Yes, start time and end time should be parameters to the script. my $start_dt = DateTime->now(); my $end_dt = $start_dt + DateTime::Duration->new(weeks => 6); my $span = DateTime::Span->from_datetimes(start => $start_dt, end => $end_dt); my @cals = (); # List of refs returned by parse() my %events = (); # Events we're interested in. Key is # date/time formatted as an ICal string, # value is a list of events happening at # that time. foreach(@ARGV) { push @cals, Text::vFile::asData->new->parse(IO::File->new($_)); } foreach my $cal (@cals) { foreach my $obj (@{ $cal->{objects}->[0]->{objects} }) { next if $obj->{type} ne 'VEVENT'; # Not interested in non events my %e = (); # Info about this event my $p = $obj->{properties}; # The event's properties next if ! defined $p->{SUMMARY}->[0]->{value}; $e{start} = DateTime::Format::ICal->parse_datetime($p->{DTSTART}->[0]->{value}); if(defined $p->{DTEND}->[0]->{value}) { $e{end} = DateTime::Format::ICal->parse_datetime($p->{DTEND}->[0]->{value}); # iCal represents all-day events by using ;VALUE=DATE and setting # DTEND=end_date + 1 $e{end}->subtract( days => 1 ) if $p->{DTEND}[0]{param}{VALUE} && $p->{DTEND}[0]{param}{VALUE} eq 'DATE' } else { $e{duration} = DateTime::Format::ICal->parse_duration($p->{DURATION}[0]{value} || "PT1S" ); $e{end} = $e{start} + $e{duration}; } $e{span} = DateTime::Span->from_datetimes(start => $e{start}, end => $e{end}); $e{summary} = $p->{SUMMARY}->[0]->{value}; $e{desc} = $p->{DESCRIPTION}->[0]->{value}; $e{desc} ||= ''; $e{summary} =~ s/\\(.)/$1/g; # Backslash escape removal $e{desc} =~ s/\\(.)/$1/g; # Handle event recurrences. If there's a recurrence rule (RRULE), then # use that. if(exists $p->{RRULE}) { # $e{recur} = DateTime::Set $e{recur} = DateTime::Format::ICal->parse_recurrence(recurrence => $p->{RRULE}->[0]->{value}, dtstart => $e{start}); } else { # If the event is a multi-day event then synthesise copies of the # event for every day on which it occurs. # # Why? Suppose you're looking at a 5 day span, and you have an # event that starts on day 2 and finishes on day 4 (3 days in total). # The ->iterator() considers that this is one occurence of the event, # rather than 3. So create recurrences so the iterator behaves in a # more useful fashion. $e{recur} = DateTime::Set->from_recurrence( recurrence => sub { $_[0]->truncate(to => 'day')->add(days => 1); }, span => $e{span}); $e{recur} = $e{recur}->union(DateTime::Set->from_datetimes(dates => [$e{start}])); } # Synthesise a flag that indicates that this is an all-day event. Not # sure if this is the correct way of detecting this, but it works so # far... $e{allday} = exists $p->{DTSTART}->[0]->{param}->{VALUE} and $p->{DTSTART}->[0]->{param}->{VALUE} eq 'DATE' ? 1 : 0; # Check to see if it's in the time span we're interested in if($e{recur}->intersects($span)) { my $int_set = $e{recur}->intersection($span); # Change the event's recurrence details so that only the events # inside the time span we're interested in are listed. $e{recur} = $int_set; my $iter = $int_set->iterator(); while(my $dt = $iter->next()) { $e{dt} = $dt; # Save a copy of the event (es = event saved), and store it in # the list of event refs keyed off the ICal date my %es = %e; push @{$events{ $dt->ymd() }}, \%es; } } } } my $e; # Hash ref of event info my $last_ed = ''; # Last event date we processed foreach my $ed (sort keys %events) { # $ed = event date ('yyyy-mm-dd') foreach $e (sort by_type_time @{$events{$ed}}) { # Build the first part of the output line. For the first event of the # day include the date, month, and day abbreviation. For subsequent # events on the same day this is the empty string. This makes for # nicer output, as it's more obvious which events share a day. if($ed ne $last_ed) { # First event of the day $e->{startstring} = sprintf("%2d %s (%s)", $e->{dt}->day(), $e->{dt}->month_abbr(), $e->{dt}->day_abbr()); } else { # Second and subsequent events $e->{startstring} = ''; } $last_ed = $ed; # If it's not an all day event then the first part of the summary is # the time the event occurs $e->{summary} = sprintf("%02d:%02d - %02d:%02d, %s", $e->{span}->start()->hour(), $e->{span}->start()->minute(), $e->{span}->end()->hour(), $e->{span}->end()->minute(), $e->{summary}) unless $e->{allday}; # If this is the second or subsequent appearance of a multi-day event, # replace the description (if it exists) with a pointer to the first # occurence of the description if($e->{recur}->min()->ymd() ne $ed and $e->{desc} ne '') { $e->{desc} = sprintf("See %d%s %s entry for details.", $e->{recur}->min()->day(), $inflect[$e->{recur}->min()->day() - 1], $e->{recur}->min()->month_abbr()); } $e->{summary} = punctuate($e->{summary}) . ' ' . punctuate($e->{desc}) if $e->{desc} ne ''; write; } } exit; sub by_type_time { # For sorting lists of events # Two events on the same day? All day events come first return -1 if $a->{allday} and ! $b->{allday}; return 1 if $b->{allday} and ! $a->{allday}; # If they're both all day events, sort by summary text return $a->{summary} cmp $b->{summary} if $a->{allday} and $b->{allday}; # Otherwise, sort by start time return $a->{dt} <=> $b->{dt}; } # Try and make sure a string ends with sensible punctuation sub punctuate { my $str = shift; return '' if ! defined $str; $str =~ s/\s*$//g; $str .= '.' unless $str =~ /[\.\?!]$/; return $str; } format STDOUT = @<<<<<<<<<<< | ^<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< $e->{startstring}, $e->{summary} ~ | ^<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< $e->{summary} ~ | ^<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< $e->{summary} ~ | ^<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< $e->{summary} ~ | ^<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< $e->{summary} ~ | ^<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< $e->{summary} ~ | ^<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< $e->{summary} | .