This library reads and writes iCalendar in Clojure and ClojureScript. It's the format of .ics files, calendar feeds and meeting invitations. A calendar is plain data, and the library expands its recurring events in their own time zones and builds invitations and replies.
The proposal explains the design and the reasons for it. The library was developed with assistance from frontier LLMs.
The library needs Clojure 1.11+ and Java 11+. Add the latest release from
Clojars to the :deps of
your deps.edn:
dk.simongray/ical-clj {:mvn/version "0.1.0"}
For changes that aren't released yet, use the SHA of the latest commit on
master instead. In shadow-cljs a Git dependency only works through
deps.edn, so set :deps true in your shadow-cljs.edn too.
Parse a calendar with ical/parse:
(require '[dk.simongray.ical :as ical])
(def cal
(ical/parse "BEGIN:VCALENDAR\r
VERSION:2.0\r
PRODID:-//Grundejerforeningen//DA\r
BEGIN:VEVENT\r
UID:bestyrelse@gf.example\r
DTSTAMP:20261001T090000Z\r
DTSTART;TZID=Europe/Copenhagen:20261012T190000\r
DURATION:PT2H\r
RRULE:FREQ=MONTHLY;BYDAY=2MO\r
SUMMARY;LANGUAGE=da:Bestyrelsesmøde\r
END:VEVENT\r
END:VCALENDAR\r
"))
(def event (first (ical/components cal :vevent)))
(ical/property event :dtstart)
;; => {:name :dtstart, :params {:tzid "Europe/Copenhagen"},
;; :value "2026-10-12T19:00:00"}
A component is a map of its :name, its :properties and its
:components. A property has a :name, its :params and a :value.
Values take the forms of jCal:
a date-time is a string like the one above and a recurrence rule is a
map of its parts. Text comes unescaped, and numbers are numbers. Get a
value with ical/value:
(ical/value event :rrule)
;; => {:freq "MONTHLY", :byday ["2MO"]}
To turn a calendar back into text, use ical/serialize. For a stream of
several calendars, use ical/parse-all. Both parse functions also take
the bytes of a file (a byte array, or a Uint8Array in JavaScript), which
is safer than its text, since a fold can split a character in two.
To list the instances of a recurring event, call ical/occurrences. It
returns a lazy sequence of events, each with a RECURRENCE-ID:
(->> (ical/occurrences cal event)
(take 3)
(map #(ical/value % :dtstart)))
;; => ("2026-10-12T19:00:00" "2026-11-09T19:00:00" "2026-12-14T19:00:00")
An event with the same UID and an instance's RECURRENCE-ID takes the
place of that instance. With RANGE=THISANDFUTURE, it moves the later
instances too. To get the instances between two dates, as a calendar
feed or a CalDAV server would, use ical/expand. For a recurrence rule on
its own, the rrule namespace
parses, writes and expands it:
(require '[dk.simongray.ical.rrule :as rrule])
(take 3 (rrule/occurrences (rrule/parse "FREQ=MONTHLY;BYMONTHDAY=31")
"2026-01-31"))
;; => ("2026-01-31" "2026-03-31" "2026-05-31")
To check a rule on its own, e.g. one that a form put together, ask for its validation errors:
(rrule/validation-errors {:freq "WEEKLY" :count 4 :until "2026-12-31"})
;; => [{:error :count-and-until, :level :error}]
As RFC 5545 says, a monthly rule from the 31st skips the shorter months. With RSCALE=GREGORIAN and SKIP=BACKWARD, as RFC 7529 has it, the rule takes their last day instead. A time that doesn't exist, because the clocks go forward, moves forward by the length of the gap. DTSTART is always the first instance.
Get the instant of a date-time with ical/instant, which resolves its
TZID within the calendar:
(ical/instant cal (ical/property event :dtstart))
;; => #inst "2026-10-12T17:00:00.000-00:00"
The tz namespace finds the zone that a
TZID names. It tries the platform's database first, then the Windows
names such as W. Europe Standard Time, and then the calendar's own
VTIMEZONEs:
(require '[dk.simongray.ical.tz :as tz])
(tz/local (tz/zone "W. Europe Standard Time") #inst "2026-10-12T17:00:00Z")
;; => "2026-10-12T19:00:00"
A calendar that you send to others should define every zone it uses.
Add the missing VTIMEZONEs with tz/with-timezones.
To find when something is booked, e.g. a room, ask ical/free-busy for
the busy time of its calendar between two dates. It answers with a
VFREEBUSY, as a CalDAV server would. A VAVAILABILITY
(RFC 7953) in the calendar
makes the time outside its AVAILABLE times busy too, e.g. outside the
hours when the room can be booked.
The itip namespace builds the messages
of iTIP, such as an organizer's
REQUEST and an attendee's REPLY, and applies those you receive to a
stored calendar. Sending them is up to you. For e-mail,
itip/content-type gives the MIME type, and itip/originator the
address that a message should come from:
(require '[dk.simongray.ical.itip :as itip])
(def reply (itip/reply cal "mailto:simon@gf.example" "ACCEPTED"))
(itip/content-type reply)
;; => "text/calendar; method=REPLY; charset=UTF-8"
When an event changes, itip/messages compares the old and the new
version and returns the messages to send, each with its recipient: e.g.
a REQUEST for the attendees and a CANCEL for those who were removed. Every
message keeps to the restrictions of its method in RFC 5546, which
itip/validation-errors checks.
The parser takes what it can from a broken file. To check a calendar,
ask for its validation errors, each with a :path into the calendar:
(ical/validation-errors (ical/parse "BEGIN:VCALENDAR\r
BEGIN:VEVENT\r
DTSTART:20261012T100000Z\r
DTEND:20261012T090000Z\r
END:VEVENT\r
END:VCALENDAR\r
"))
;; => [{:error :missing-property, :level :error, :path [], :property :prodid}
;; {:error :missing-property, :level :error, :path [], :property :version}
;; {:error :missing-property, :level :error, :path [:components 0], :property :dtstamp}
;; {:error :missing-property, :level :error, :path [:components 0], :property :uid}
;; {:error :end-before-start, :level :error, :path [:components 0 :properties 1]}]
To check for errors and ignore the warnings, use ical/valid?.
.cljc and gives the same
results everywhere, as long as the platforms have the same version of
the time zone database.dev/fetch.sh # once, for the test data and the tables' sources
clojure -X:test # the tests on the JVM
npm install # once, for the Node tests
clojure -M:cljs compile test # the tests in Node
clojure -M:cljs compile browser-test # the tests in a browser, from target/browser-test
clojure -X:tables # the tables from CLDR and RFC 5546
clojure -M:bench -m dk.simongray.ical.bench # benchmarks on the JVM
clojure -M:cljs release bench && node target/bench.js # benchmarks in Node
The fetch script downloads the test data of
libical,
ical.js and
python icalendar into
dev-resources/ (which Git ignores), at the commits that the library
follows. The examples of RFC 5545 and the tests of dateutil and
sabre/vobject are in the tests themselves, the latter two made by the
scripts in dev/.
The public namespaces are the root, rrule, tz, itip and jcal. The
internal ones are under impl:
impl.lines, content lines and their foldingimpl.values, the value typesimpl.parser and impl.serializer, which read and write componentsimpl.registry, what the RFCs say about each property and componentimpl.restrictions, what each method of iTIP allows in a messageimpl.civil and impl.expansion, date arithmetic and recurrence rulesimpl.recurrence, recurrence sets and their instancesimpl.zones and impl.windows, time zones and Windows zone namesimpl.validation, validation errorsThe ical-clj project is licensed under the MIT licence. The
table of Windows zone names in impl/windows.cljc is made from CLDR,
under the Unicode License v3.
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 |