iCalendar - How Hard Could It Be?
At long last, I have a class schedule for my upcoming return to college. I noticed that my student calendar did not update after everything was locked into Workday, the university’s online system, and Workday itself did not offer a calendar export. I started to manually copy the events into my calendar, but I unfortunately had gotten the itch: surely there’s an automated way to do this!
I poked around and found a couple of unofficial sites from students at other universities that did what I was looking for… At least in theory. I ruled out one of the tools immediately since it required linking a Google account and didn’t yet have iCal export. The remaining two seemed to have worked for someone at some point, but did not work for my schedule. It’s hard to say for sure, but I think the ad-hoc structure of the schedule table that is presented by Workday might not be consistent between schools. There’s a basic class name, start/end date, and instructors field, but the schedule pattern itself certainly feels like it could be bespoke:
Monday/Wednesday/Friday | 10:45 AM - 11:35 AM | FOOBAR 102
Or, there can be a multi-pattern for classes with weirder scheduling, separated by a double newline:
08/17/2026 - 12/03/2026 | Monday/Wednesday/Friday | 12:55 PM - 1:45 PM | FOOBAR 206
08/17/2026 - 12/03/2026 | Thursday | 7:30 PM - 10:30 PM | ABC | No Classroom Required
In practice, while certainly brittle, parsing the data we need is pretty straightforward. It’s so
straightforward that I thought it would make sense to just build the damn thing myself. EZPZ. I
used the encoding/csv package to read through the spreadsheet (I will look into a spreadsheet ->
CSV conversion later, but that’s well-trodden territory), wrote some nasty strings.Split and
time.Parse glue to extract the details, and then I shoved everything into some structs. Now I just
need to transform that into the iCal format, how hard could it be?
In 2026, I am very cautious about dependencies. Every other news cycle involves a compromised
package, and between that, the massive dependency trees for most software, and the volume of AI slop
that is in circulation, I avoid importing packages with their own dependency trees. In Go, the
ecosystem is generally pretty disciplined about only taking on transient dependencies when it’s
very necessary. For instance, gomarkdown/markdown,
which I use for this blog, only depends on the standard library. This makes sense – most protocols
and standard formats are expected to be implemented by humans – but it is certainly not the way
that most software is built these days. Unfortunately for me, both iCal implementations I could find
depended on at least one transient, one of which being a testing utility library with three of its
own dependencies…
I’ve worked with iCal feeds through the Ruby icalendar gem before and I’ve debugged a handful of
problems with feed structure, so now I’ve really backed myself into a corner. It’s just a text-based
format, and I don’t need all of it. How hard could it be to implement what I need from scratch? I
opened up RFC 5545 in one tab and
iCalendar.org in another. In short…
We need to wrap the whole calendar in something that looks like this
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//your//product//name
CALSCALE:GREGORIAN
(calendar events go here)
END:VCALENDAR
Make sure that you’re using CRLF (\r\n) and not just UNIX LF (\n) for each new line. The only
dynamic field here is the PRODID. What’s that wacky format? Duh, it’s
ISO.9070.1991 as recommended by the iCal spec. On a
more serious note, this is the first place I really started to notice the age of the iCal standard.
I’ve never seen this ISO format anywhere else, and the standard being referenced is officially
“withdrawn.” Of course, with millions/billions of iCalendar clients in the wild, this field will
probably never change. Not that it really matters anyway, as it’s highly unlikely that a client
would ever attempt to parse a structured ID in this format.
As for events, the subset of the standard that we need is relatively minimal. Classes effectively fall into a weekly cadence, recurring on specific days of the week, and the recurrence rule structure supports that in a straightforward way. This means that we only need one event per start/end time for a given class.
BEGIN:VEVENT
UID:(unique ID for event)
SUMMARY:(title)
DESCRIPTION:(description)
DTSTAMP:YYYYMMDDTHHMMSSNNZ
DTSTART:YYYYMMDDTHHMMSSNN
DTEND:YYYYMMDDTHHMMSSNN
RRULE:(recurrence rule)
END:VEVENT
UID |
This is a globally unique identifier for the event. |
SUMMARY |
This is the event title. Must be folded at 75 octets. |
DESCRIPTION |
This is the event description. Must be folded at 75 octets. |
DTSTAMP |
This is the creation date of the event. |
DTSTART |
For our purposes, this is iCal timestamp of the start of the first occurrence (date AND time) |
DTEND |
For our purposes, this is iCal timestamp of the END of the FIRST occurrence (date AND time) |
RRULE |
Recurrence rule, more on that later |
Folding should occur for any line that exceeds 75 octets though the title and description are the
most likely to wrap in our subset of the spec, and maybe the RRULE. The other thing to note is the
timestamp format, which naturally does
not use ISO8601 formatting. Instead they have their own format with its own special nightmarish
time zone handling. I’ll discuss the timezones later in this document, but for now understand that
it’s easiest to use the UTC format (tacking on the Z) for the DTSTAMP, and leaving the Z
specifier off for the individual start/end times so that we have a “floating” time. Floating times
are generally bad since it forces the client to infer the timezone, however the timezone
specification is messy enough that I found this to be infinitely more straightforward.
Recurrence rules, if you need to parse them, can be an abject nightmare. Lucky for us, we just need to write a tiny subset for class schedules:
RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR;UNTIL=YYYYMMDDTHHMMSSNNZ
This is mercifully straightforward. The only thing to note is that the UNTIL denotes the upper
bounds of the event. This can be a little strange at first, but it finally clicked for me: DTSTART
and DTEND are the start/end time of the first occurrence. The RRULE is responsible for repeating
that same occurrence with different dates but the same times until the UNTIL time. The name
DTEND definitely threw me off.
But that’s the entire subset of the spec that is necessary to populate a calendar! Here’s a basic schedule:
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//workdayics//EN
CALSCALE:GREGORIAN
BEGIN:VEVENT
SUMMARY:FOO 21001 - FOO BAR I LAB
DESCRIPTION:Lorem ipsum.
Folded line.
UID:18aada2b-2236-bec0-baf2-50088d7d8dec
DTSTAMP:20260429T142800Z
DTSTART:20260817T140000
DTEND:20260817T164500
DESCRIPTION:
RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO;UNTIL=20261211T000000Z
END:VEVENT
BEGIN:VEVENT
SUMMARY:FIZZ 20404 - UNIVERSITY FIZZBUZZ II
DESCRIPTION:Escaped\: character
UID:18aada2b-2236-c2a8-8a46-a58d1a7a97a8
DTSTAMP:20260429T142800Z
DTSTART:20260817T125500
DTEND:20260817T134500
DESCRIPTION:RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR;UNTIL=20261211T000000Z
END:VEVENT
END:VCALENDAR
I threw out the timezone specification earlier, and you might be wondering why. Unfortunately, this
part of the spec is more egregiously outdated than even the strange product identifier structure.
You see, this spec predates the ubiquity of the timezone DB (tzdb) that almost all software in the
past twenty or so years has gravitated towards. As a developer working with the spec, that means you
can’t just specify the timezone in use by its well-known tzdb identifier, e.g.
America/Chicago. Instead, you’re responsible for defining the timezone in the calendar
itself. This isn’t the end of the world, and
I do intend to circle back to it, but it is certainly a drag. Furthermore, Go has its own special
snowflake implementation of the time zones via time.Location that might not be as easy as other
languages that just rely on the OS for timezone data.
Anyhow, that’s all I wanted to write about the spec today. I may follow up eventually to discuss the difficulty I had testing my iCal payloads, but for now I hope that maybe my notes can unblock someone that also needs such a limited portion of the iCal surface area. I’ll also have the tool I’ve written to perform the Workday -> iCal translation up on my Github in the near future.