#!/usr/bin/perl -w # calendar-export.plx -*- Perl -*- # NOTE: Overall license of this file is GPLv3-only, due (in part) to Software # Freedom Law Center copyrights (see below). Kuhn's personal copyrights are # licensed GPLv3-or-later. # ==================================================================== # The sub's "safe_read_from_pipe" and read_from_process are: # Copyright (c) 2000-2004 CollabNet. All rights reserved. # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. The terms # are also available at http://subversion.tigris.org/license-1.html. # If newer versions of this license are posted there, you may use a # newer version instead, at your option. # # This software consists of voluntary contributions made by many # individuals. For exact contribution history, see the revision # history and logs, available at http://subversion.tigris.org/. # Note: bkuhn downloaded the license from # http://subversion.tigris.org/license-1.html on 2013-12-29 which said: # The license of Subversion 1.7 and later is at # http://svn.apache.org/repos/asf/subversion/trunk/LICENSE. # The license of Subversion 1.6 and earlier can be found at # http://svn.apache.org/repos/asf/subversion/tags/1.6.0/www/license-1.html. # Both license texts are now included, in APACHE-LICENSE and OLD-SVN-LICENSE, # respectively. # ==================================================================== # # Copyright © 2013 Bradley M. Kuhn # # This software's license gives you freedom; you can copy, convey, # propogate, redistribute and/or modify this program under the terms of # the GNU General Public License (GPL) as published by the Free # Software Foundation (FSF), either version 3 of the License, or (at your # option) any later version of the GPL published by the FSF. # # 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 in a file in the toplevel directory called # "GPLv3". If not, see . # ==================================================================== # The functions DoLog, BinarySearchForTZEntry, PrivatizeMergeAndTZIcalFile, # BuildTZList, MergeLists, PrivacyFilterICalFiles, and FilterEmacsToICal material # copyrighted and licensed as below: # Copyright © 2006 Software Freedom Law Center, Inc. # # This software gives you freedom; it is licensed to you under version 3 # of the GNU General Public License. # # This software is distributed WITHOUT ANY WARRANTY, without even the # implied warranties of MERCHANTABILITY and FITNESS FOR A PARTICULAR # PURPOSE. See the GNU General Public License for further details. # # You should have received a copy of the GNU General Public License, # version 3. If not, see use strict; use warnings; use POSIX ":sys_wait_h"; use Fcntl; # for sysopen use Carp; use Data::ICal; use File::Temp qw/:POSIX tempfile/; use DateTime::TimeZone; use Date::Manip; use DateTime::Format::ICal; my($CONFIG_FILE) = (@ARGV); if (@ARGV != 1) { print STDERR "usage: $0 \n"; exit 1; } ############################################################################### my $CALENDAR_LOCK_FILE = "$ENV{HOME}/.emacs-calendar-to-ics-lock"; my $LOCK_CLEANUP_CODE = sub { return (unlink($CALENDAR_LOCK_FILE) != 1) ? "Failed unlink of $CALENDAR_LOCK_FILE. Could cause trouble." : ""; }; ############################################################################### { my %messageHistory; sub DoLog ($$$;$) { my($type, $user, $message, $cleanupCode) = @_; use Date::Manip; my $NOW = ParseDate("now"); my $lastTime = $messageHistory{$message}; my $sendIt = 0; if (not defined $lastTime) { $sendIt = 1; } else { my $err; my $sinceLast = DateCalc($lastTime,"+ 10 minutes",\$err); $sendIt = 1 if ($NOW gt $sinceLast); } if ($sendIt) { my $fh = File::Temp->new(); $fh->unlink_on_destroy( 1 ); my $fname = $fh->filename; print $fh "Calendar Export Failure: $message\n"; $fh->close(); system('/home/bkuhn/bin/myosd', $fname); unless (-f "$ENV{HOME}/.silent-running") { open(ESPEAK, "-|", "/usr/bin/espeak", '-p', '45', '-s', '130', '-f', $fname, "--stdout"); open(PAPLAY, "|-", "/usr/bin/paplay"); my $data; while (read(ESPEAK, $data, 8) == 8) { print PAPLAY $data; } close PAPLAY; close ESPEAK; } system('/usr/bin/notify-send', '-u', 'critical', '-t', '300000', 'Failure', "Calendar export failure: $message"); $messageHistory{$message} = $NOW; } my $more; $more = &$cleanupCode if defined $cleanupCode and ref $cleanupCode; $message .= " $more" if (defined $more and $more !~ /^\s*$/); croak $message if $type eq "die"; warn $message; } sub DieLog ($;$) { DoLog("die", undef, $_[0], $_[1]); } sub WarnLog ($$) { DoLog("warn", $_[0], $_[1]); } } ############################################################################### # Start a child process safely without using /bin/sh. sub safe_read_from_pipe { unless (@_) { DieLog("$0: safe_read_from_pipe passed no arguments."); } my $pid = open(SAFE_READ, '-|'); unless (defined $pid) { DieLog("$0: cannot fork: $!"); } unless ($pid) { open(STDERR, ">&STDOUT") or DieLog("$0: cannot dup STDOUT: $!"); exec(@_) or DieLog("$0: cannot exec `@_': $!\n"); } my @output; while () { s/[\r\n]+$//; push(@output, $_); } close(SAFE_READ); my $result = $?; my $exit = $result >> 8; my $signal = $result & 127; my $cd = $result & 128 ? "with core dump" : ""; if ($signal or $cd) { DieLog("$0: pipe from `@_' failed $cd: exit=$exit signal=$signal\n"); } if (wantarray) { return ($result, @output); } else { return $result; } } ############################################################################### # Use safe_read_from_pipe to start a child process safely and return # the output if it succeeded or an error message followed by the output # if it failed. sub read_from_process { unless (@_) { DieLog("$0: read_from_process passed no arguments."); } my ($status, @output) = &safe_read_from_pipe(@_); if ($status) { return ("$0: `@_' failed with this output:", @output); } else { return @output; } } ############################################################################### sub BinarySearchForTZEntry { # $tzList is assumed to be sorted, $dateTime is my($tzList, $dateTime) = @_; my ($l, $u) = (0, @$tzList - 1); # lower, upper end of search interval my $i; # index of probe my $final = 0; while ($l <= $u) { $i = int(($l + $u)/2); my $compareVal = DateTime->compare($tzList->[$i]{date}, $dateTime); if ($compareVal < 0) { $l = $i+1; $final = $i; } elsif ($compareVal > 0) { $u = $i-1; } else { return $tzList->[$i]; # found, won't happen often } } return $tzList->[$final]; # not found, go down one lower } ############################################################################### # Take a list of keys and a list of values and insersperse them and # return the result sub MergeLists { my ($keys, $values) = @_; DieLog("Length mismatch", $LOCK_CLEANUP_CODE) unless @$keys == @$values; # Add the argument names to the values my @result; for (my $i = 0; $i < @$keys; $i++) { push @result, $keys->[$i] => $values->[$i]; } return @result; } ############################################################################### sub BuildTZList ($$$) { my($user, $pubEmacsFile, $privEmacsFile) = @_; my @tzList; foreach my $file ($pubEmacsFile, $privEmacsFile) { open(DATA, "<$file") or DieLog("unable to read $file: $!", $LOCK_CLEANUP_CODE); while (my $line = ) { if ($line =~ /^\s*;[;\s]*TZ\s*=([^\s,]+)\s*(?:,+\s*LOCA?T?I?O?N?\s*=\"([^"]+)\")? \s+(?:at|on)\s*(.*)\s+in\s+(\S+)\s*$/ix) { my($newTZstr, $location, $dateStartStr, $dateStartTZstr) = ($1, $2, $3, $4); my $newTZ; eval { $newTZ = DateTime::TimeZone->new( name => $newTZstr ); }; if ($@ or not defined $newTZ) { WarnLog($user, "Invalid time zone of \"$newTZstr\" in $line from $file: $@"); next; } my $dateStartTZ; eval { $dateStartTZ = DateTime::TimeZone->new( name => $dateStartTZstr ); }; if ($@ or not defined $dateStartTZ) { WarnLog($user, "Invalid time zone of \"$dateStartTZstr\" in $line from $file: $@"); next; } my(@data) = UnixDate("$dateStartStr", qw(%Y %m %d %H %M %S)); if (@data != 6) { WarnLog($user, "Unparseable date string of \"$dateStartStr\"" . "in $line from $file"); next; } my @args = MergeLists([qw( year month day hour minute second)], \@data); my $startDate; eval { $startDate = DateTime->new(@args, time_zone => $dateStartTZstr); }; if ($@ or not defined $startDate) { WarnLog($user, "Trouble parsing \"$dateStartStr $dateStartTZstr\" " . "in $line from $file\n\n" . "Most likely $dateStartTZstr was a bad time zone.: $@ "); next; } push(@tzList, { date => $startDate, newTZ => $newTZ, location => $location}); } } } # If we found nothing, everything is NYC if (@tzList == 0) { push(@tzList, { date => DateTime->new(year => 2006, month => 11, day => 03, hour => 11, minute => 00, second => 00, time_zone => "America/New_York"), newTZ => "America/New_York", location => undef }); } return sort { DateTime->compare($a->{date}, $b->{date}); } @tzList; } ############################################################################### sub FilterEmacsToICal ($$$$$) { my ($publicCalendarFile, $privateCalendarFile, $outputDir, $emacsSettings, $user) = @_; my @tzList = BuildTZList($emacsSettings->{reportProblems}, $publicCalendarFile, $privateCalendarFile); my($elispFH, $elispFile) = tempfile(); my $icsWillBePrivatizedFile = tmpnam(); my $icsPublicFile = tmpnam(); print $elispFH "(setq-default european-calendar-style t)\n" if $emacsSettings->{calendarStyle} =~ /european/i; print $elispFH <close(); my @emacsOutput = read_from_process($emacsSettings->{emacsBinary}, '--no-windows', '--batch', '--no-site-file', '-l', $elispFile); DieLog("Emacs process for exporting $privateCalendarFile and " . "$publicCalendarFile exited with non-zero exit status of " . "$? ($!), and output of:\n " . join("\n ", @emacsOutput), $LOCK_CLEANUP_CODE) if ($? != 0); my $goodCount =0; foreach my $line (@emacsOutput) { $goodCount++ if $line =~ /^\s*Wrote\s+($icsPublicFile|$icsWillBePrivatizedFile)\s*$/; } DieLog("Unexpected Emacs output: " . join("\n ", @emacsOutput), $LOCK_CLEANUP_CODE) if ($goodCount > 2); PrivatizeMergeAndTZIcalFile($icsWillBePrivatizedFile, $icsPublicFile, $outputDir, \@tzList, $user); PrivacyFilterICalFile($outputDir) if $emacsSettings->{privacyScrub}; DieLog("Unable to remove temporary files", $LOCK_CLEANUP_CODE) unless unlink($icsPublicFile, $icsWillBePrivatizedFile) == 2; } ############################################################################### sub PrivacyFilterICalFiles ($$) { my($icsDirectory) = @_; chdir $icsDirectory or die "unable to change to $icsDirectory: $!"; foreach my $file (<*.ics>) { my $newCalendar = Data::ICal->new(data => <new(filename => $file); my $entries = (defined $oldCalendar) ? $oldCalendar->entries : []; my $x =0; foreach my $entry (@{$entries}) { my @newSubEntries; foreach my $subEntry (@{$entry->{entries}}) { my $refVal = ref $subEntry; if (defined $refVal and $refVal =~ /Alarm/i) { # Don't put it in the list in the public version if is an alarm } else { push(@newSubEntries, $subEntry); } } $entry->{entries} = \@newSubEntries; my $classes = $entry->property('class'); my $class; foreach my $classProp (@{$classes}) { $class = $classProp->value; last if defined $class and $class =~ /^\s*(?:PUBLIC|PRIVATE|CONFIDENTIAL)\s*/i; } if (defined $class and $class =~ /CONFIDENTIAL/i) { foreach my $prop (qw/location summary description/) { my $propList = $entry->property($prop); $entry->add_property($prop => "Private") if (defined $propList and @{$propList} > 0); } } elsif (defined $class and $class =~ /PRIVATE/i){ # do not put this event in the output at all die "unable to scrub $file in $icsDirectory: $!" unless unlink($file) == 1; } $newCalendar->add_entry($entry); } open(SCRUBBED_CAL, ">$file") or DieLog("Unable to overwrite $file: $!", $LOCK_CLEANUP_CODE); print SCRUBBED_CAL $newCalendar->as_string; close SCRUBBED_CAL; DieLog("Error when writing $file: $!", $LOCK_CLEANUP_CODE) unless $? == 0; undef $newCalendar; } } ###################################################################### sub PrivatizeMergeAndTZIcalFile ($$$$$$) { my($icsPrivate, $icsPublic, $icsOutputDir, $tzList, $user, $errorUser) = @_; my %calendar; $calendar{private} = Data::ICal->new(filename => $icsPrivate); $calendar{public} = Data::ICal->new(filename => $icsPublic); my $type = "public"; foreach my $type (qw/public private/) { my $entries = (defined $calendar{$type}) ? $calendar{$type}->entries : []; foreach my $entry (@{$entries}) { my $newCalendar = Data::ICal->new(data => <add_property(class => "CONFIDENTIAL") if ($type eq "private"); # Let's shift some timezones around. foreach my $dateType (qw/DTSTART DTEND/) { my $datePropList = $entry->property($dateType); next unless @$datePropList > 0; WarnLog($errorUser, "Strange that the entry below for $icsOutputDir had more " . "than one $dateType:\n" . Data::Dumper->Dumper($entry) ) unless @$datePropList == 1; my $dateProp = $datePropList->[0]; # Only continue processing date if we have this property. (Duh) next unless defined $dateProp; my $params = $dateProp->parameters(); # Only continue if it is a DATE-TIME property. This is a bit of a # judgement call but I think it's the right one. When someone # creates an all-day event, we don't want to allow it to drift to # antoher day merely because the user has moved time zones. next unless defined $params and defined $params->{VALUE} and $params->{VALUE} =~ /DATE\-TIME/i; my $nyTime = DateTime::Format::ICal->parse_datetime($dateProp->value); my $newDate = DateTime::Format::ICal->parse_datetime($dateProp->value); $nyTime->set_time_zone("America/New_York") if $nyTime->time_zone->name =~ /floating/; my $val = BinarySearchForTZEntry($tzList, $nyTime); $newDate->set_time_zone($val->{newTZ}); $newDate->set_time_zone("America/Los_Angeles"); $newDate->set_time_zone("floating"); my $newICalDate = DateTime::Format::ICal->format_datetime($newDate); $dateProp->value($newICalDate); } $newCalendar->add_entry($entry); # Now, write out each event into a single ics file in $icsOutputDir. # This will overwrite existing events of the same name. my $uidList = $entry->property('UID'); DieLog("This entry has multiple UIDs: @{$uidList}", $LOCK_CLEANUP_CODE) unless @$uidList == 1; my $uid = $uidList->[0]->value; my $outputFile = File::Spec->catpath("", $icsOutputDir, "${uid}.ics"); open(SINGLE_EVENT_ICAL, ">", $outputFile) or DieLog("Unable to overwrite $outputFile: $!", $LOCK_CLEANUP_CODE); print SINGLE_EVENT_ICAL $newCalendar->as_string; close SINGLE_EVENT_ICAL; DieLog("Error ($?) while writing $outputFile ($?): $!", $LOCK_CLEANUP_CODE) unless $? == 0; undef $newCalendar; } } # Create specialized "Time Zone change" events to indicate the user's travel. foreach my $tzEntry (@$tzList) { $tzEntry->{date}->set_time_zone("floating"); } for (my $ii = 0; $ii < @$tzList; $ii++) { my $tzEntry = $tzList->[$ii]; next unless defined $tzEntry->{location} and $tzEntry->{location} !~ /^\s*NYC\s*$/i; my $startDate = DateTime::Format::ICal->format_datetime($tzEntry->{date}); my $nextDate = ($ii+1 < @$tzList) ? DateTime::Format::ICal->format_datetime($tzList->[$ii+1]{date}) : $startDate; $nextDate =~ s/T\d+Z?$//; $startDate =~ s/T\d+Z?$//; my $whereEvent = Data::ICal::Entry::Event->new(); my $desc = "$user Travel: ". $tzEntry->{location}; my $uid = "bkuhnScript" . '-' . sha1($desc . $startDate); $whereEvent->add_properties(summary => "$user Travel: ". $tzEntry->{location}, description => $tzEntry->{location}, dtstart => [ $startDate, { VALUE => 'DATE' } ], dtend => [ $nextDate,{ VALUE => 'DATE' } ], uid => $uid); my $newCalendar = Data::ICal->new(data => <add_entry($whereEvent); my $outputFile = File::Spec->catpath("", $icsOutputDir, "${uid}.ics"); open(SINGLE_EVENT_ICAL, ">", $outputFile) or DieLog("Unable to overwrite $outputFile: $!", $LOCK_CLEANUP_CODE); print SINGLE_EVENT_ICAL $newCalendar->as_string; close SINGLE_EVENT_ICAL; DieLog("Error ($?) while writing $outputFile ($?): $!", $LOCK_CLEANUP_CODE) unless $? == 0; undef $newCalendar; } return \%calendar; } ###################################################################### sub ReadConfig($) { my($configFile) = @_; open (CONFIG_FILE, "<", $configFile) or DieLog("unable to read $configFile ($?): $!"); my %config; while (my $line = ) { chomp $line; DieLog("Unable to parse $line in config file, $configFile", $LOCK_CLEANUP_CODE) unless $line =~ /^\s*([^:]+)\s*:\s*([^:]+)\s*$/; $config{$1} = $2; } close CONFIG_FILE; DieLog("Error reading $configFile ($?): $!", $LOCK_CLEANUP_CODE) if $? != 0; return \%config; } ###################################################################### system("/usr/bin/lockfile -r 8 $CALENDAR_LOCK_FILE"); unless ($? == 0) { print "\${color5}Calendar export failure: Cannot aquire lock on $CALENDAR_LOCK_FILE\n"; exit 1; } if (not -r $CONFIG_FILE) { print "\${color5}$CONFIG_FILE does not exist\n"; exit 1; } my $config = ReadConfig($CONFIG_FILE); $config->{scrubPrivate} = 0 if not defined $config->{scrubPrivate}; $config->{reportProblems} = $config->{user} if not defined $config->{reportProblems}; $config->{emacsBinary} = "/usr/bin/emacs" if not defined $config->{emacsBinary}; $config->{calendarStyle} = 'plain' if not defined $config->{calendarStyle}; DieLog("$config->{emacsBinary} doesn't appear to be executable for $config->{emacsBinary}") unless defined $config->{emacsBinary} and -x $config->{emacsBinary}; DieLog("$CONFIG_FILE doesn't specify a (readable) output directory via outputDir setting: $!") unless defined $config->{outputDir} and -d $config->{outputDir}; if (defined $config->{cleanOutputDirFirst} and $config->{cleanOutputDirFirst}) { chdir $config->{outputDir} or die "unable to change directory to $config->{outputDir} $? $!"; system("/bin/rm -f *.ics"); } foreach my $key (qw/publicDiary privateDiary/) { unless (defined $config->{$key} and -r $config->{$key}) { print "\${color5}$key file, $config->{$key} does not exist\n"; exit 1; } } FilterEmacsToICal($config->{publicDiary}, $config->{privateDiary}, $config->{outputDir}, $config, $config->{user}); &$LOCK_CLEANUP_CODE(); __END__ # Local variables: # compile-command: "perl -c calendar-export.plx" # End: