Mimsy Were the Borogoves

Hacks: Articles about programming in Python, Perl, Swift, BASIC, and whatever else I happen to feel like hacking at.

Simple .ics iCalendar file creator

Jerry Stratton, August 3, 2022

Calendar text file sample

The text that calmaker will convert to an ics file.

Our high school class was very small, and so our high school reunions are small affairs. There were, for example, all of three events this year: a meet-and-greet, a dinner, and a lunch memorial. Even with such a small calendar, people still find a calendar file useful. But it’s not worth it to maintain the events in a calendar app (or, worse, create a calendar app). It’s easier just to keep the events in a simple text file.

So I made a Perl script to convert a simple text file of events into an iCalendar .ics file (Zip file, 10.7 KB). All it needs is a text file like this:

  • ## Lost Castle of the Astronomers
  • July 15
  • 12:00 PM
  • 5 hours
  • **Table 2**
  • The mountains of West Highland are dotted with the ruins of lost scholarly orders. The Astronomers, in the Deep Forest south of the Leather Road, have been silent for a hundred years, unheard from since the goblin wars that so devastated Highland. Only vague references remain to taunt treasure hunters and spell seekers.
  • The Deep Forest is a dangerous place, home to many strange creatures. Only adventurers of stout heart and cunning can hope to penetrate the forest and return alive.

Very simple, and it’s obvious what this text means. In fact, if you open this in pretty much any modern text editor it will be formatted to highlight the important bits and to keep the separate events readable as separate events, because this is very simple Markdown text.

It’s also easy enough for a Perl script to convert to a .ics file.

The script is meant to take files created for human purposes, not computer purposes. Except for the title of the event, which must always come first (and always be preceded by two hash marks—that is, a Markdown level-2 headline), the order of information doesn’t matter. If the script doesn’t recognize a piece of information, it assumes that it’s part of the description. It completely ignores blank lines.

Use the script like this:

  • calmaker Gods\ \&\ Monsters\ MiniCon.txt > minicon.ics

You can, of course, leave off the > minicon.ics to see the calendar file output to your Terminal screen.

The above event will be transformed into this:

  • BEGIN:VEVENT
  • CLASS:PUBLIC
  • SUMMARY;LANGUAGE=en-us:Lost Castle of the Astronomers
  • DESCRIPTION:The mountains of West Highland are dotted with the ruins of lost
  • scholarly orders. The Astronomers\, in the Deep Forest south of the Leather
  • Road\, have been silent for a hundred years\, unheard from since the goblin
  • wars that so devastated Highland. Only vague references remain to taunt tre
  • asure hunters and spell seekers.\n\nThe Deep Forest is a dangerous place\, h
  • ome to many strange creatures. Only adventurers of stout heart and cunning c
  • an hope to penetrate the forest and return alive.
  • LOCATION:Table 2
  • UID:calmaker-8035-gods-monsters-minicon-event-2
  • DTSTART;TZID=America/Chicago;VALUE=DATE-TIME:20220715T120000
  • DTEND;TZID=America/Chicago;VALUE=DATE-TIME:20220715T170000
  • END:VEVENT

The script recognizes URLs and date ranges, and even simple addresses.

  • ## Gods & Monsters MiniCon
  • **Padgett, Texas**
  • 161 N Division Avenue
  • Padgett, TX 12345
  • July 15-16
  • Welcome to the Gods & Monsters Mini-Con. Beyond here lie dragons!
  • https://godsmonsters.com/

This gets converted to

  • BEGIN:VEVENT
  • CLASS:PUBLIC
  • SUMMARY;LANGUAGE=en-us:Gods & Monsters MiniCon
  • DESCRIPTION:Welcome to the Gods & Monsters Mini-Con. Beyond here lie dragons
  • !
  • LOCATION:Padgett\, Texas\n161 N Division Avenue\, Padgett\, TX 12345
  • URL;VALUE=URI:https://godsmonsters.com/
  • UID:calmaker-8035-gods-monsters-minicon-event-1
  • DTSTART;TZID=America/Chicago;VALUE=DATE:20220715
  • DTEND;TZID=America/Chicago;VALUE=DATE:20220717
  • END:VEVENT

Because this is meant for very simple files, it makes some assumptions. I didn’t bother recognizing all time zones, for example. I’m only likely to ever need a handful of time zones, so the current version only has the four continental US zones:

[toggle code]

  • our %timezones = (
    • 'Central' => 'America/Chicago',
    • 'Eastern' => 'America/New_York',
    • 'Pacific' => 'America/Los_Angeles',
    • 'Mountain' => 'America/Denver',
  • );

Similarly, if there’s a single hash mark at the top of the file it gets used as the title of the calendar; if not, the filename gets used as the calendar name. And if no time zone is specified in the events text, there’s a default in the script: my own time zone. That’s where most of the events I’ll be creating .ics files for are likely to take place. You, of course, will want to change that default to wherever most of your events take place.

If you need to specify a timezone in the events text, just include it at the top of the file:

  • # Class Reunion
  • Eastern

The script assumes that events are taking place in the current year, but you can specify a year on the date line (“July 15, 2023” instead of “July 15”) or you can specify a default year at the top of the file

There are two potentially interesting things in this script. First, it isn’t very Perl-like in how it handles the event class. While Perl is much more freeform than other scripting languages—it’s first law is, after all, “There’s more than one way to do it”—it is generally accepted that class definitions go into their own file for re-use elsewhere.

However, I have no intention of re-using this class anywhere; if I do, I’ll move it out, but it’s simple enough that putting this class into its own separate file violates the second law of Perl: keep easy things easy.

So the Event class goes into a BEGIN section, which is basically treated as its own file by Perl; it has its own use lines and its own globals. Each have to go after the package line that names the class.

You can share variables between the main script and the package by defining them with our. It is very similar to the my and local keywords.

  • our $now = localtime;
  • our $defaultYear = $now->year;

These two variables are available throughout the script.

Simple use lines are shared throughout the script as well, without any special handling. I’m not aware of any means to share lines like use List::MoreUtils qw(first_index); throughout the script; since this is Perl, there probably is a way to do it, but the easy way is to simply put the line inside the package.

[toggle code]

  • BEGIN {
    • package Event;
    • use Time::Seconds;
    • use List::MoreUtils qw(first_index);
  • }

The second interesting feature of this script—and by interesting, I mean that I finally learned of its existence because I wrote this script—is the ability to place regular expression pattern modifiers inside a regular expression string variable. In Perl, a regular expression is flagged by marking its beginning and ending with slashes1:

[toggle code]

  • } elsif (/^slug: *([0-9a-z-]+)$/i) {
    • $event->setID($1);
  • } else {

Modifiers go after the final slash, and in that form they can’t be part of the variable. A regular expression in a variable normally looks like this:

[toggle code]

  • $idRE = '^slug: *([0-9a-z-]+)$';
  • } elsif (/$idRE/i) {
    • $event->setID($1);
  • } else {

Perl recognizes that this is a regular expression from the slashes, and uses the variable as the actual regex. But the case-insensitivity modifier is really part of the regular expression, and should be variable along with the expression. The slashes can’t be part of the variable, because then Perl won’t recognize the regular expression, which means that slash-modifier can’t be part of the variable.

This being Perl, however, there is of course more than one way of doing it. Pattern modifiers can also be placed within the slashes. An open parenthesis, a question mark, a series of pattern modifiers, and a close parenthesis will apply those pattern modifiers to the remainder of the regex.

[toggle code]

  • $idRE = '^(?i)slug: *([0-9a-z-]+)$';
  • } elsif (/$idRE/) {
    • $event->setID($1);
  • } else {

This form of pattern modifier specification can even be placed within parenthesized subparts of the regular expression, if you need a modifier only for one part of the regex.

The main loop of the script is simply a sieve of regular expressions that recognize bits of events.

[toggle code]

  • while (<>) {
    • chomp;
    • next if /^$/;
    • if (!$event && !/^## /) {
      • if (/^# (.*)$/) {
        • $calendar = $1;
      • } elsif (/^$timezoneRE$/) {
        • $timezone = $_;
        • $zonename = $timezones{$timezone};
      • } elsif (/^2[0-9]{3}$/) {
        • $defaultYear = $_;
      • } elsif (/$idRE/) {
        • $calendarID = "$baseID-$1";
      • } else {
        • die("Unknown calendar-wide option $_\n");
      • }

Before the script detects an event, it’s looking for the very few items it can use as part of the overall calendar: a title, a time zone, a default year, and a unique identifier (“slug”) for the calendar. The title of the overall calendar is a single pound symbol, a space, and then anything: it’s a Markdown level-1 headline. Timezones are recognized by being one of the zone keys in the timezone hash. And default years are recognized by being a four-digit number starting with a “2”.2

Once it sees an event headline, the script only looks for new events and their information. It does this by filtering the text through a series of regular expressions.

[toggle code]

  • } elsif (/^## (.*)$/) {
    • if ($event) {
      • $event->close();
    • } else {
      • startCalendar();
    • }
    • $event = new Event($1, $timezone);

Detecting an event headline is simple: it’s just two pound symbols, a space, and any text. If there’s already an event, that event gets closed; if there isn’t an event, this is the first one, and so the calendar is started up. Then, the new event is created with its title and the current timezone.

[toggle code]

  • } elsif (/^\*\*(.*)\*\*$/) {
    • $event->setLocation($1);

The location’s title is marked by two asterisks—Markdown bold—at the beginning and end of the text.

[toggle code]

  • } elsif (/^[1-9][0-9]* .+ (Avenue|Boulevard|Lane|Loop|Place|Road|Street)( [NS]?[EW]?)?$/) {
    • $event->addAddress($_);

The street address of the location is matched by looking for numbers at the start of the line and any text ending in Avenue, Boulevard, Lane, and so on. Obviously, there are more possibilities, and you’ll need to add them if you’re using them.

[toggle code]

  • } elsif (/^[A-Z][a-zA-Z ]+, [A-Z][A-Z] [0-9]{5}$/) {
    • $event->addAddress($_);

Similarly, the city, state, and zip code are detected by looking for a capitalized letter, then any letters as well as a space, a comma and a space, two capital letters, a space, and five digits. That is, something like “San Diego, CA 92102”. Depending on where you are or where your events take place, you may want to open up this regular expression to more possibilities.

[toggle code]

  • } elsif (/^https:\/\/[^ ]+$/) {
    • $event->setURL($_);

URLs must begin with “https://”. If your event is still using “http://”, you’ll want to change the “https” to “https?”. A question mark makes the preceding character optional.

[toggle code]

  • } elsif (/^([12]?[0-9])(:([0-9][0-9]))? ?([AP]M)$/i) {
    • $event->setTime($1, $3, $4);

A time is recognized as anything like “5:30 pm” or “10:15 AM”. It’s case-insensitive to allow for both uppercase and lowercase “am” and “pm”.

[toggle code]

  • } elsif (/^([1-9][0-9]*) (hour|minute)s?$/) {
    • my $duration = $1;
    • $duration *= 60 if $2 eq 'hour';
    • $event->setDuration($duration);
  • } elsif (/^([1-9][0-9]*) days?$/) {
    • $event->setDays($1);

A duration is any digit, a space, and either the word hours, minutes, or days. The “s” is optional in each case, so that you can type “1 hour” or “3 days” or even “2 day”.

[toggle code]

  • $dateRE = '(' . join('|', @months) . ') ([1-9][0-9]?)(-([1-9][0-9]))?(, (2[0-9]{3}))?';
  • } elsif (/^$dateRE$/) {
    • $event->setDate($1, $2, $4, $6);

The date regex uses a variable created from the list of months. It can be any month name, a one or two digit number (that is, the date in the month), and optionally a dash and another number to create a multi-day event.

  • July 15-16

This does exactly the same thing:

  • July 15
  • 2 days

And finally, anything that isn’t recognized is part of the description.

[toggle code]

  • } else {
    • $event->addDescription($_);

You can see how this works in the sample text file for a fictional roleplaying minicon. I’ve included that, and the .ics file it creates, in the calmaker zip archive (Zip file, 10.7 KB) so that you can see what it does on a real file.

I quoted Douglas Adams in 42 Astoundingly Useful Scripts and Automations for the Macintosh about being “rarely happier than when spending an entire day programming my computer to perform automatically a task that it would otherwise take me a good ten seconds to do by hand.”3 Creating a .ics file by hand would have taken more than ten seconds, but there’s still a lot of that sentiment in this script. It depends on how often I use this script. But there remains a joy in taking something in one format—especially something that is simple and readable by human eyes—and transforming it automatically into the kind of restricted format that computers need.

Douglas Adams on programming

There remains a joy in exploring the hidden crevasses of an interchange format such as icalendar, teasing out its benefits and working around its flaws, to create something that can be shared throughout a wide community. It is one of the many great joys of programming.

There are still things to do. I don’t like how the script handles calendar and event identifiers. These identifiers (X-WR-RELCALID and UID in the .ics file) make it possible to update events that already exist in the user’s calendar. They’re very useful. Without event identifiers, when a participant adds the calendar a second time they’ll get a second set of events and have to manually delete all of the events in the first set. With event identifiers, the calendar program will automatically replace the old set with the new set.

For any gathering of any reasonable size, they’re a necessity, because you’ll almost certainly be updating your event information. So currently the script allows you to specify a slug for the calendar and for each event. It looks for “slug:text” and uses that as the calendar or event slug, combining it with a base identifier built in to the script. Never change a slug once it’s been created! A changed slug doesn’t update the old versions of what it referred to.

I have no idea how to create a unique, unchanging identifier from the event information alone without defeating that purpose. If it were based on the title, date, and/or time, it would then change when the event’s title, date, or time changed, and would thus not replace the old event. So currently, the script defaults to incrementing an event number, starting at 1. The first event is “baseid-calendarid-event-1”, the second “baseid-calendarid-event-2”, and so on.

As long as any updated .ics file contains at least as many events as the previous .ics file, all of the events will be replaced when someone re-downloads the calendar file. If the updated file contains fewer events, however, the trailing events will probably not be deleted (it may depend on the calendar program used).

If you use webcal:// instead of http:// or https:// to link to the calendar file, the calendar program should check regularly for updates automatically.4

You may wish to change the base identifier to something that reflects your own domain name or name. For example, if you have a domain name of “example.com ”, you might change the base identifier to “example-com”:

  • $baseID = 'example-com';

And of course you may also wish to change the default time zone at the top of the file.

iCalendar .ics file creator (Zip file, 10.7 KB)

  1. You can use almost any character you want for marking the beginning and end of a regular expression. But that doesn’t change the point about how to add pattern modifiers.

  2. If you’re still running this script in 2999, it’s up to you to fix that Y3K bug.

  3. I have a well-deserved reputation for being something of a gadget freak, and am rarely happier than when spending an entire day programming my computer to perform automatically a task that it would otherwise take me a good ten seconds to do by hand. Ten seconds, I tell myself, is ten seconds. Time is valuable and ten seconds’ worth of it is well worth the investment of a day’s happy activity working out a way of saving it. — Douglas Adams (Last Chance to See)

  4. Your server will need to redirect http:// to https:// if the calendar file is being served over a secure connection. There is no webcals:// form of the URL, which means that the calendar application will make the request over an insecure http connection and need to be redirected to the secure connection if that’s where the file is.

  1. <- Premature optimization