Article jul2007.tar

Updating a Public Calendar Automatically

Randal L. Schwartz

I maintain a "public" calendar, so that my friends and associates can see where I'll be. The calendar started as part of my ~/.plan file in my home directory on my first Internet-connected host at Teleport more than a decade ago. For those of you too young to remember, the dot-plan file was revealed by executing finger on the person, specifying both the username and the host. By placing my schedule in my plan file, you could find out where I'd be.

During the passing years, the use of finger became more worrisome and eventually fell out of favor, especially since the Web was a bit more secure and a lot more familiar. To accommodate, I simply symlinked my dot-plan file into a corresponding URL on my Web server, yielding the somewhat awkward and anachronistically named URL of www.stonehenge.com/merlyn/dot-plan.txt. I still edit it by updating ~/.plan with my favorite text editor.

Inside my ~/.plan file, my upcoming schedule occupies the last half and currently looks something like:

Future plans:

07 to 19 May 2007: Buffalo (NY) working for buffalo.edu
21 May 2007: Portland (OR) speaking at Advanced PLUG about PostgreSQL
23 to 26 May 2007: Portland (OR) working for geekcruises.com
26 May to 03 Jun 2007: MacMania 6 out of Seattle for Alaska 
   (www.geekcruises.com)
24 Jun to 01 Jul 2007: Houston (TX) for YAPC::NA::2007

Now, up until yesterday, I was manually editing that portion of the file, trying to keep it in sync with my laptop's calendar (using Mac OS X's iCal application). As I went through the sporadic recognition of "oh my gosh, it's probably out of sync again", followed by a painful scrutiny of each of my personal calendar items to see whether they should be published, followed by cutting and pasting those items into the editor window, I thought you know, there must be a better way.

But I couldn't just publish my entire calendar. I have private items in there; you really don't need to know when I'm getting a haircut, for example. And it's not enough to separate it by category, because some of my items have private aspects and public aspects. For example, you're certainly welcome to know that I'm in Houston on certain days but not my hotel arrangements or flight times.

So I came up with an interesting compromise. Within the description of each calendar item, I insert as the first line a string beginning with DOTPLAN:. If that item is present, then the corresponding dates should be published to my public calendar, together with the remainder of the string as the entire identification of the event. With this strategy, I could give the summary information for the public calendar right alongside the private details in the same description field. For example, my YAPC::NA description begins:

DOTPLAN: Houston (TX) for YAPC::NA::2007

To transfer this information automatically, I next wrote some code that would go through the text files created by iCal. They're theoretically in a standard iCalendar format, but the tools on the CPAN didn't parse them nicely. So I just went for brute force. I added DOTPLAN: to the description of a couple of events in a couple of my calendars, including an event that has a start time, and then figured out how it would look in the resulting calendar files. Because I made some wild guesses on that, I'm not particularly proud of the code, and it might even be somewhat buggy, I'm not going to show that here, except to represent it symbolically:

my %events;

while (<>) {
  ## magic here
  push @{$events{$start}}, [$end => $description];
}

Presuming that magic here means we're skipping over lines that are uninteresting and that somehow we come up with a start date, end date, and description, I'm building a hash of arrayrefs to hold all items that begin on the same date. $start and $end here look like YYYYMMDD with a 4-digit year, 2-digit month, and 2-digit day of month.

The next step is to show these events sorted by start date:

for my $start (sort keys %events) {
  for my $event (@{$events{$start}}) {
    my ($end, $desc) = @$event;
    print range($start, $end), ": $desc\n";
  }
}

The date strings sort naturally in the proper order. For each start date, I'll loop over all events, displaying each event. The call to range here is to turn two dates into a meaningful range, removing any redundancy. If the years differ, both dates are shown in their entirety. However, if only the days differ, then I don't want to repeat the month; and if the months differ, I don't want to repeat the year. For this, I came up with the following code:

sub range {
  my $start = shift;
  my $end = shift;
  $_ = ymd2dmy($_) for $start, $end;
  my $range = "$start to $end";
  $range =~ s/^(.*?)(.*) to (.*)\2$/$1 to $3$2/;
  $range =~ s/^ to //;         # single day
  $range;
}

The call to ymd2dmy turns the 20070714 into "14 Jul 2007", using the following subroutine:

sub ymd2dmy {
  my $ymd = shift;
  my ($y, $m, $d) = unpack "A4 A2 A2", $ymd;
  $m = (qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec))[$m - 1];
  "$d $m $y";
}

Next, the $range string is set to include both strings in full. The first substitution on $range tries to note common items from both the start and end dates, ripping them out of the start date. If the start date is the same as the end date, the resulting string begins with to, which the next replacement cleans up. Yes, I'm sure there were less clever and more clear ways of doing this, but this works.

Running this code produces the text that I want in my dot-plan, but I still have two problems to solve. First, I want to edit only a portion of the dot-plan file, leaving the rest of the text intact. Second, the code that generates these lines runs on my laptop, but I really wanted to update the ~/.plan file on my server.

Again, it's just a small matter of programming. To update just a portion of the file, I simply use in-place editing mode. First, I set $^I:

$^I = "~";

And then I establish the list of files to edit (just one, here):

@ARGV = glob '~/.plan';

The loop prints anything that I'm not changing but replaces the stuff I am changing:

while (<>) {
  if (my $line = /^Future plans/..eof()) {
    print "$_\n", <DATA> if $line == 1;
  } else {
    print;
  }
}

Note the use of the .. operator here. When the loop first sees Future plans, $line will start being 1, 2, 3, and so on. If $line is true, then we're in the tail part of the file and shouldn't copy anything. Otherwise, the lone print copies the existing data.

For the first line only (the line beginning with Future plans), I copy the line and append a blank line, and then add the result of reading the DATA filehandle. And that's where I'll put my updated data:

__END__
07 to 19 May 2007: Buffalo (NY) working for buffalo.edu
21 May 2007: Portland (OR) speaking at Advanced PLUG about PostgreSQL
23 to 26 May 2007: Portland (OR) working for geekcruises.com
26 May to 03 Jun 2007: MacMania 6 out of Seattle for Alaska 
   (www.geekcruises.com)
24 Jun to 01 Jul 2007: Houston (TX) for YAPC::NA::2007

I now have a Perl script that when executed, updates my dot-plan file as needed. How do I get this Perl script over to the server? This is the final cool step: I execute an ssh command that launches a remote perl invocation:

open SSHPIPE, "|ssh my.server.example.com perl" or die "$!";
print SSHPIPE <<'END_PROG';
$^I = "~";
@ARGV = glob '~/.plan';
while (<>) {
  if (my $line = /^Future plans/..eof()) {
    print "$_\n", <DATA> if $line == 1;
  } else {
    print;
  }
}
__END__
END_PROG

So, I launch the remote Perl and feed it the first part of the program. I then execute the remainder of my local calendar scraper:

my %events;

while (<>) {
  ## magic here
  push @{$events{$start}}, [$end => $description];
}

for my $start (sort keys %events) {
  for my $event (@{$events{$start}}) {
    my ($end, $desc) = @$event;
    print SSHPIPE range($start, $end), ": $desc\n";
  }
}

Note the change to the print to print it to the ssh pipe. And finally, I close the pipe, and the remote Perl code can begin:

close SSHPIPE;

And there you have it: a local calendar scraper that pushes the results to a remote calendar, using a pipe to a remote Perl and some cool in-place editing. And amazingly enough, I got this running within an hour or so, which means it'll take me only, well, three years to earn that time back! Oh well. Until next time, enjoy!

Randal L. Schwartz is a two-decade veteran of the software industry -- skilled in software design, system administration, security, technical writing, and training. He has coauthored the "must-have" standards: Programming Perl, Learning Perl, Learning Perl for Win32 Systems, and Effective Perl Programming. He's also a frequent contributor to the Perl newsgroups, and has moderated comp.lang.perl.announce since its inception. Since 1985, Randal has owned and operated Stonehenge Consulting Services, Inc.