dillonkearns/elm-ical/1.0.0

elm-ical

Generate and parse a practical subset of iCalendar (RFC 5545) in Elm.

elm-ical is currently optimized for:

  • generating VCALENDAR / VEVENT feeds from typed Elm values
  • parsing VEVENT feeds strictly into typed Elm data
  • expanding the common RRULE subset directly in Elm

It is not a complete RFC 5545 implementation yet. The current scope is calendar/event feeds plus the recurrence and timezone pieces needed to work with them reliably.

Design Goals

  • Make impossible states impossible. Generation types are opaque with builders that normalize invalid inputs. Parsed types use union types for enums and enforce spec constraints like matching DTSTART/DTEND value types.
  • Human-friendly names over spec jargon. Field names like summary, start, and generatorProductId instead of SUMMARY, DTSTART, and PRODID.
  • Strict parsing. Malformed input is an error, not a silent recovery. Calendars are generated by software, so spec compliance is expected.
  • Practical scope. Supports the subset of RFC 5545 needed for real-world calendar feeds, not every theoretical construct.

Generating

import Date
import Ical.Generator as Generator
import Ical.Recurrence as Recurrence
import Time


teamCalendar : Time.Posix -> String
teamCalendar now =
    let
        weeklySync : Generator.Event
        weeklySync =
            Generator.event
                { id = "weekly-sync"
                , stamp = now
                , time =
                    Generator.timedEvent
                        { start = mar18at10am
                        , end = mar18at11am
                        }
                , summary = "Weekly Team Sync"
                }
                |> Generator.withRecurrenceRule
                    (Generator.rule (Recurrence.Weekly { every = 1, weekStart = Time.Mon }))

        offsite : Generator.Event
        offsite =
            Generator.event
                { id = "offsite-q2"
                , stamp = now
                , time =
                    Generator.allDayRange
                        { start = Date.fromCalendarDate 2021 Time.Jun 14
                        , end = Date.fromCalendarDate 2021 Time.Jun 16
                        }
                , summary = "Q2 Team Offsite"
                }
                |> Generator.withLocation "Moscone Center, 747 Howard St, San Francisco, CA 94103"
    in
    Generator.generate
        (Generator.config
            { id = "//mycompany//team//EN"
            , domain = "mycompany.com"
            }
            |> Generator.withName "Engineering Team"
        )
        { events = [ weeklySync, offsite ]
        , journals = []
        }

mar18at10am : Time.Posix
mar18at10am =
    Time.millisToPosix 1616065200000

mar18at11am : Time.Posix
mar18at11am =
    Time.millisToPosix 1616068800000

Parsing

import Date
import Ical.Parser as Parser


holidayFeedToString : String -> String
holidayFeedToString icsString =
    case Parser.parse icsString of
        Ok cal ->
            cal.events
                |> List.filterMap toHoliday
                |> List.sortBy
                    (.date >> Date.toRataDie)
                |> List.map
                    (\h ->
                        Date.format "EEE, MMM d" h.date
                            ++ " - "
                            ++ h.name
                    )
                |> String.join "\n"

        Err err ->
            "Parse error: " ++ err


toHoliday :
    Parser.Event
    -> Maybe { name : String, date : Date.Date }
toHoliday event =
    case ( event.summary, event.time ) of
        ( Just name, Parser.AllDay { start } ) ->
            Just { name = name, date = start }

        _ ->
            Nothing

-- holidayFeedToString usHolidaysFeed
--
--   Thu, Jan 1 - New Year's Day
--   Mon, Jan 19 - MLK Day
--   ...

See examples/ for a full runnable script that fetches and parses a live Google Calendar feed.

What You Get

  • Strict parsing. Malformed content lines, invalid dates/times/durations/RRULE values, and invalid DTEND/DURATION combinations return Err. A TZID parameter without a matching VTIMEZONE definition is a hard parse error.
  • Typed event times. Parsed events are AllDay, WithTime, or FloatingTime. The end is always populated (from DTEND, DURATION, or the RFC default).
  • TZID-aware parsing. DATE-TIME values with a TZID parameter are resolved through matching VTIMEZONE data.
  • Typed enums. STATUS, TRANSP, attendee role, and participation status are decoded into Elm union types. Shared types like Status and Transparency live in the Ical module.
  • Unknown-property preservation. Unhandled and extension properties are kept in extraProperties.
  • Invalid states prevented. Generation-side types like EventTime and Rule are opaque with builder functions that normalize inputs (e.g. reversed start/end dates are swapped, negative intervals are clamped to 1).

Important Semantics

  • Generator.allDay creates a single-day event; Generator.allDayRange uses an inclusive end date for multi-day spans.
  • Ical.Parser.EventTime.AllDay uses RFC-style exclusive end semantics when parsing.
  • If a parsed VEVENT omits both DTEND and DURATION, the parser applies the RFC default:
    • all-day events end on the following date
    • timed and floating events end at the same instant they start
  • Recurrence expansion keeps all-day and floating events in their natural date space. For TZID-resolved timed events, date-based recurrence rules use the local wall-clock date from the matching VTIMEZONE definition, so BYDAY, BYMONTHDAY, EXDATE, RDATE, and RECURRENCE-ID line up with local calendar dates. Sub-daily FREQ values still operate on resolved instants.

Supported Today

Generation (Ical.Generator)

  • Calendar properties: VERSION, PRODID, NAME (non-standard but widely used), DESCRIPTION, URL
  • Event properties: DTSTART, DTEND, DTSTAMP, UID, SUMMARY, DESCRIPTION, LOCATION, ORGANIZER, X-ALT-DESC, STATUS, TRANSP, CREATED, LAST-MODIFIED, RRULE, ATTENDEE
  • Event times:
    • all-day events
    • UTC date-time events
    • floating local date-times (no timezone)
  • VALARM generation: DISPLAY and AUDIO alarms with trigger offsets relative to start or end. EMAIL alarms are omitted — they are rarely supported by modern calendar clients.
  • VJOURNAL generation: journal/note entries with optional date or datetime, status (DRAFT/FINAL/CANCELLED), and description
  • Recurrence rules via opaque Rule builder:
    • FREQ, INTERVAL, COUNT, UNTIL, BYDAY, BYMONTHDAY, BYMONTH, BYSETPOS, BYHOUR, BYMINUTE, BYSECOND, BYYEARDAY, BYWEEKNO, WKST
    • Frequency carries every (interval) and weekStart (for Weekly), so they're scoped to where they apply
    • BYMONTH uses Time.Month values, so invalid months are impossible at the type level

Parsing (Ical.Parser)

  • Calendar properties: PRODID, VERSION, plus unknown calendar properties in extraProperties
  • Event properties: DTSTART, DTEND, DTSTAMP, DURATION, UID, SUMMARY, DESCRIPTION, LOCATION, ORGANIZER, STATUS, TRANSP, CREATED, LAST-MODIFIED, RRULE, EXDATE, RDATE, RECURRENCE-ID, ATTENDEE
  • Event times:
    • all-day dates
    • UTC date-times
    • floating local date-times
    • TZID date-times resolved through matching VTIMEZONE
  • Attendees: CN, ROLE, PARTSTAT, RSVP
  • VJOURNAL parsing: journal entries with optional DTSTART (date, datetime, or floating), STATUS (DRAFT/FINAL/CANCELLED), SUMMARY, DESCRIPTION, ORGANIZER
  • VALARM parsing: alarm sub-components with ACTION (DISPLAY/AUDIO), TRIGGER (duration relative to start/end, or absolute DATE-TIME), DESCRIPTION

Shared types (Ical)

  • Status (Tentative, Confirmed, Cancelled)
  • Transparency (Opaque, Transparent)
  • JournalStatus (Draft, Final, CancelledJournal)

Recurrence Subset

  • Frequencies: SECONDLY, MINUTELY, HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY
  • Rule parts: INTERVAL, COUNT, UNTIL, BYDAY, BYMONTHDAY, BYMONTH, BYSETPOS, BYHOUR, BYMINUTE, BYSECOND, BYYEARDAY, BYWEEKNO, WKST
  • Exception dates: EXDATE for all-day, floating, and resolved date-time events
  • Additional recurrence dates: RDATE
  • RECURRENCE-ID override merging
  • Expansion helpers: Ical.Parser.expand and Ical.Parser.expandNext (both merge RECURRENCE-ID overrides automatically)

Not Supported Yet

  • Generating TZID date-times or VTIMEZONE components
  • VTODO and VFREEBUSY

Known Limitations

  • VTIMEZONE support is intended for practical real-world feeds, but it is still not a complete implementation of every valid observance rule shape allowed by RFC 5545. The common yearly pattern (FREQ=YEARLY;BYMONTH=...;BYDAY=...) is supported, along with RDATE, UNTIL, and historical observance chains.