This proposes ical-clj, a neutral iCalendar library for Clojure and
ClojureScript, with the coordinate dk.simongray/ical-clj and the root
namespace dk.simongray.ical. The name is free on Clojars. The proposal
comes from research alone on 12 October 2026, and none of its code runs
yet.
Clojure has no iCalendar parser that runs in ClojureScript. On the JVM there are only ical4j wrappers that generate files. In ClojureScript there are stale wrappers of rrule.js and ical.js. The library would read and write calendars as plain data, expand recurrences in their time zones and build scheduling messages. It would work like whatwg-url:
It leaves out every choice that the RFCs leave open. Those belong in the events library, which would build on it.
The library follows RFC 5545 with its verified errata, and these neighbours:
| RFC | What ical-clj does with it |
|---|---|
| 5545 iCalendar | all of it: content lines, every component, the value types, recurrence and time zones |
| 6868 parameter encoding | reads and writes ^n, ^^ and ^' (without them a parameter with a quote or a newline can't round-trip) |
| 7986 new properties | knows COLOR, IMAGE, CONFERENCE, NAME and the rest, which Google, Apple and Fastmail write |
| 9073 event publishing | knows PARTICIPANT, VLOCATION, VRESOURCE, STYLED-DESCRIPTION and STRUCTURED-DATA |
| 9074 alarms | knows ACKNOWLEDGED and PROXIMITY, and UID and RELATED-TO in a VALARM |
| 9253 relationships | knows LINK, CONCEPT, REFID and the new relation types |
| 7953 availability | knows VAVAILABILITY and AVAILABLE (for bookings) and expands their rules |
| 5546 iTIP | builds, checks and applies the messages of every method |
| 6047 iMIP | gives the content type and who should have sent a message |
| 7809 time zones by reference | reads calendars without VTIMEZONEs and can write them without |
| 7265 jCal | converts both ways, since the values already take jCal's forms |
"Knows" means a row in the registry: a property's default value type, where it may appear and how often. An extension costs rows rather than code. The round trip keeps whatever the registry doesn't know anyway.
Left out:
For later:
A calendar is plain data that prints as EDN:
This VEVENT:
BEGIN:VEVENT
UID:bestyrelse@gf.example
DTSTAMP:20261001T090000Z
DTSTART;TZID=Europe/Copenhagen:20261012T190000
DURATION:PT2H
RRULE:FREQ=MONTHLY;BYDAY=2MO
EXDATE;TZID=Europe/Copenhagen:20261214T190000
SUMMARY;LANGUAGE=da:Bestyrelsesmøde
ATTENDEE;CN=Simon Gray;PARTSTAT=ACCEPTED:mailto:simon@gf.example
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER:-PT15M
DESCRIPTION:Bestyrelsesmøde
END:VALARM
END:VEVENT
is this map:
{:name :vevent
:properties [{:name :uid :value "bestyrelse@gf.example"}
{:name :dtstamp :value "2026-10-01T09:00:00Z"}
{:name :dtstart
:params {:tzid "Europe/Copenhagen"}
:value "2026-10-12T19:00:00"}
{:name :duration :value "PT2H"}
{:name :rrule :value {:freq "MONTHLY" :byday ["2MO"]}}
{:name :exdate
:params {:tzid "Europe/Copenhagen"}
:value ["2026-12-14T19:00:00"]}
{:name :summary
:params {:language "da"}
:value "Bestyrelsesmøde"}
{:name :attendee
:params {:cn "Simon Gray" :partstat "ACCEPTED"}
:value "mailto:simon@gf.example"}
{:name :x-microsoft-cdo-busystatus :value "BUSY"}]
:components [{:name :valarm
:properties [{:name :action :value "DISPLAY"}
{:name :trigger :value "-PT15M"}
{:name :description :value "Bestyrelsesmøde"}]}]}
Why ISO strings rather than objects or maps of fields. They're checked and canonical, so two equal values are equal. They're the strings that java.time, Temporal, tick and JSCalendar all read as they are. They can hold a leap second (23:59:60) and a TZID that only the file defines, which java.time can't. They also make the conversion to jCal little more than renaming keys, and the JSCalendar conversion keeps what it doesn't map as jCal.
Why maps rather than jCal's arrays. Maps read better in Clojure and can leave out what's empty, as whatwg-url's URL maps do. Properties stay a vector, though. A map by name would lose their order and make repeated properties awkward (icalendar-rs lost repeated properties that way).
Lossless means that parsing what serialize wrote gives the same
calendar. Everything is kept in order: every component, property and
parameter, X- and IANA names, and a value that doesn't parse, as its
text. Writing never sorts, trims, adds defaults or re-escapes a value it
doesn't know. What may change is what RFC 5545 says doesn't matter: the
folding of lines, the case of names, quotes that aren't needed and the
spelling of numbers. The text isn't kept byte for byte.
| Namespace | What it holds |
|---|---|
dk.simongray.ical | parse, serialize, read a component, expand recurrences, validate |
dk.simongray.ical.rrule | recurrence rules alone: parse, serialize, occurrences |
dk.simongray.ical.tz | time zones: zone, offset, instant, local, vtimezone, with-timezones |
dk.simongray.ical.itip | scheduling: publish, request, reply, cancel and the other methods, messages, apply, newer?, validation-errors |
dk.simongray.ical.jcal | calendar->jcal and jcal->calendar |
dk.simongray.ical.impl.* | content lines, the registry, date arithmetic, the steps of expansion, the platform's zones (all ^:no-doc) |
The root namespace has:
parse and parse-all: the first calendar in a text, or all of them
(a file may hold several, as section 3.4 allows)serialize: the text of a calendar, folded at 75 octets, with CRLF
line endscomponents, properties, property and value: what a component
holds, by nameinstant: the inst of a date-time property, in its calendar's zonesend: the end of a component by section 3.6.1 (DTEND, DTSTART plus
DURATION, or the next day for a DATE)occurrences: a lazy sequence of the instances of a recurring
component, each a component with its RECURRENCE-IDexpand: a calendar whose recurring components are replaced by their
instances between two instants, as CalDAV's expand does (RFC 4791,
section 9.6.5)validation-errors and valid?(require '[dk.simongray.ical :as ical])
(def cal (ical/parse (slurp "bestyrelse.ics")))
(def event (first (ical/components cal :vevent)))
(ical/value event :summary)
;; => "Bestyrelsesmøde"
(ical/instant cal (ical/property event :dtstart))
;; => #inst "2026-10-12T17:00:00.000-00:00"
(->> (ical/occurrences cal event)
(take 3)
(map #(ical/value % :dtstart)))
;; => ("2026-10-12T19:00:00" "2026-11-09T19:00:00" "2027-01-11T19:00:00")
Functions that need zones take the calendar, since its VTIMEZONEs may define them. There's no global registry of zones, since ical4j's leaked memory and broke Android. Nor is there a setting for the whole JVM like ical4j's compatibility hints: leniency and the choice of zones are options of each call.
The engine is date arithmetic in cljc, grown from podcast-clj's
util.time. It reads all 14 rule parts with one algorithm that composes
them, and the table in section 3.3.10 (which parts expand a set and which
limit it) is data. The alternative shows in libical, which has separate
code for each frequency. Its BYSETPOS worked only for monthly and yearly
rules until version 4.
One function differs per platform. It gives the offset of an IANA zone at an instant: ZoneRules on the JVM, and in JavaScript the parts of a cached Intl.DateTimeFormat (which keep the seconds of old offsets, as java.time does). The rest is cljc built on it:
Which rules a TZID gets:
The platform wins over the file's VTIMEZONE because the file's copy is often stale or covers only a few years. So does python icalendar, and RFC 7809 notes that clients mostly ignore the VTIMEZONEs they get. Thunderbird, Outlook and ical.js prefer the file, so an option lets the file win. Either way the VTIMEZONE stays in the calendar.
Writing: Serializing a calendar adds nothing, but tz/with-timezones
adds a VTIMEZONE for each TZID that the calendar uses, made from the
platform's transitions over the years it spans. iTIP messages always get
them, since Outlook needs them.
The data drifts, not the code. The JDK and each browser carry their own copy of the time zone database, at different versions, and several releases in 2026 changed future offsets. On 15 December 2026, America/Vancouver is -08:00 on JDK 24 and Node 24 but -07:00 in Firefox 157 and Safari 26.3. The library won't carry a database of its own to hide this, as tick does in ClojureScript. Its tests compare the platforms only where both report the same database version.
No tick, and not Chronos yet. The reasons against tick still hold. A
java.time value can't hold a leap second or a zone that only a VTIMEZONE
defines, and plusMonths clamps where the RFC skips. In ClojureScript,
tick also ships js-joda with its own zone database. The research found
one more reason: ZonedDateTime's plusDays keeps the old offset in an
overlap, where iCalendar wants the first occurrence. Since the values are
ISO 8601 strings, tick reads them as they are. The inst that instant
returns is what t/instant takes.
Tempo is now Chronos (com.widdindustries/chronos 0.9.2, November 2025). It needs Temporal at runtime and has no offsets or transitions, which is all ical-clj would want from it. Temporal reached stage 4 in March 2026 and ships in Firefox 139, Chrome 144 and Node 26. Safari has it only in Technology Preview 249 (July 2026), not in a stable release. Look again once stable Safari has it, and then at Temporal itself rather than a wrapper: its getTimeZoneTransition would replace the bisection.
The itip namespace builds, checks and applies scheduling messages, and
sends nothing. Each message is a calendar with a METHOD, shaped by the
restriction tables in RFC 5546, section 3.
publish, request, reply, cancel,
add, refresh, counter and decline-counter each make a message
of that method from a calendar, with the VTIMEZONEs it needs. E.g.
(itip/reply cal "mailto:simon@gf.example" "ACCEPTED") keeps only
that attendee and sets its PARTSTAT.messages function compares the old
and the new version of a calendar, as one calendar user sees it. It
returns the messages to send, each with its recipients, e.g. a REQUEST
for the attendees and a CANCEL for those who were removed. SEQUENCE
goes up when DTSTART, DTEND, DURATION, DUE, RRULE, RDATE, EXDATE or
STATUS changed (section 2.1.4). This is the design of sabre/vobject's
ITip\Broker, which Nextcloud uses. Its bugs show what to do better:
compare typed values rather than text (an explicit INTERVAL=1 isn't a
change), compare calendar addresses in one normalised form, and update
one instance without replacing the rest.apply, e.g. with an attendee's PARTSTAT from a REPLY, unless it's
older than what's stored. The newer? function decides that by UID,
RECURRENCE-ID, SEQUENCE and DTSTAMP (section 2.1.5).content-type function gives e.g.
text/calendar; method=REQUEST; charset=UTF-8. The originator
function gives the address a message should come from (the ORGANIZER
of a request, the ATTENDEE of a reply). Comparing it with the mail's
verified sender is up to the app.Real files often break the RFC. iCloud's Danish holiday feed has a DTSTAMP that is a DATE, and Outlook names zones that no database has. So the parser is lenient, and like whatwg-url's it reports what it forgave rather than refusing.
parse
returns nil.validation-errors
returns has an :error keyword, a :level (:error for a MUST of the RFC,
:warning for a SHOULD) and a :path that get-in takes into the
calendar. Given text rather than a calendar, it also reports the lines
it dropped, by number. A calendar is valid? when there's no :error.(ical/validation-errors cal)
;; => [{:error :missing-property :property :dtstamp
;; :level :error :path [:components 0]}
;; {:error :unsynchronized-dtstart :level :warning
;; :path [:components 0 :properties 2]}]
The rules about which properties a component must have, or may have
once, are rows in the registry with the section they come from. For a
calendar with a METHOD, itip/validation-errors adds the restriction
tables of iTIP.
There's no official suite, so the tests gather what other implementations
check. A script downloads them at pinned commits into dev-resources/
(which Git ignores), as whatwg-url's dev/fetch.sh does. Fetching isn't
redistribution, so the copyleft sources are fine too.
| Source | What it gives | Licence |
|---|---|---|
| RFC 5545, section 3.8.5.3 | the recurrence examples, typed into an EDN table with erratum 3883 applied (the errata against the other examples were rejected) | IETF Trust |
| libical | 151 rules with DTSTART and every instance in plain text, written by a developer of Cyrus (the server behind Fastmail), plus 34 RSCALE rules for later | MPL-2.0 or LGPL-2.1 |
| ical.js | 21 files each paired with its jCal, which makes a parse and round-trip corpus | MPL-2.0 |
| rust-rrule | the RFC examples in America/New_York with their offsets | MIT or Apache-2.0 |
| dateutil | about 219 rules that RFC 5545 can express, with expected dates in Python | BSD-3 |
| sabre/vobject | 73 rules and 52 tests of overridden instances, in PHP | BSD-3 |
| recurring-ical-events | 97 calendars from bug reports | LGPL-3.0 |
| python icalendar, ical4j | files from Google, Exchange, Outlook, Apple iCal, Thunderbird, Lotus Notes and others | BSD-2, BSD-3 |
A one-off script turns the tests written in code (dateutil's and sabre's) into EDN, which is committed with its licence and source. Where DTSTART doesn't match its rule, libical's and dateutil's expectations leave DTSTART out, so those cases get it added. The suites of rrule.js, rrule-go and most of rust-rrule are ports of dateutil's, so they add nothing new.
The real files above are old. For current ones, throwaway accounts at Google, Outlook.com, iCloud and Fastmail can each export a calendar of made-up events with the hard cases: a recurring event with exceptions, an all-day event, attendees, alarms and a zone. Those files are free of anyone else's rights. Public feeds work too, e.g. Google's Danish holidays, but they change with every download, so they're kept as snapshots.
Generative tests check what the RFC promises:
serialize wrote gives the same calendar, and no folded
line is longer than 75 octets or splits a UTF-8 character (python
icalendar once folded between a backslash and its n)In podcast-clj, ical-clj would replace util/time/rrule.cljc, which
handles 8 of the 14 rule parts and counts days in UTC. Its rule serves
podcast:updateFrequency. (There's no liveSchedule in podcast-clj. Its
live items have a start and an end, and could be offered as an .ics of
VEVENTs.)
coerce namespace reads and writes the rule with rrule/parse
and rrule/serialize. UNTIL becomes a date-time string instead of an
inst, which changes podcast-clj's model.schedule/next-episode function takes rrule/occurrences in the
zone of the dtstart's offset. BYDAY=MO then means Monday where the
podcast is made, not in UTC.The events library keeps everything that the RFCs leave open, with ical-clj and tick as its only dependencies. It holds:
itip/apply and PARTSTAT
turned into yes, no and maybeical/occurrencesThe association app gets its calendar feed from serialize and its
invitations from itip. A booking is an event with the room as an
attendee (CUTYPE=ROOM), and a VAVAILABILITY can say when the fælleshus
may be booked. Turning events into free and busy time comes later.
parse, parse-all and serializeinstant and endoccurrences, expand and the rrule namespacetz/with-timezonesvalidation-errors for RFC 5545publish, request, reply and cancel, newer?, apply for
replies, and the iMIP dataThat covers phase 1 of the events note (an .ics per event, a webcal
feed, and invitations and replies by e-mail) and podcast-clj's switch.
The next release adds messages, the other iTIP methods and jCal.
It would come to 3,000 to 4,000 lines of cljc, close to whatwg-url's 3,100:
| Part | Lines |
|---|---|
| content lines, values and the registry (a third of it tables) | 900 |
| reading, writing and validation | 600 |
| recurrence | 900 |
| time zones | 500 |
| iTIP | 500 |
The tests would add about 1,500 lines besides the fetched data. The recurrence engine is the hard part. The rest follows the RFC closely.
dk.simongray.ical.vcard in this artifact,
or as vcard-clj on the same content-line code? The first is simpler,
while the second has the better name.Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |