dillonkearns/elm-ical/1.0.0

Ical.Parser

Parse iCal (RFC 5545) calendar strings into typed Elm data.

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 the examples/ folder for a full runnable script that fetches and parses a live Google Calendar feed.

Parsing is strict: malformed content lines, invalid dates and times, bad RRULE values, and a TZID without a matching VTIMEZONE all return Err. Parsed types are transparent record aliases so you can read fields directly.

Parsing

parse : String -> Result String Calendar

Parse an iCal string into a Calendar.

Ical.Parser.parse icsString
--> Ok { generatorProductId = "-//My App//EN", specVersion = "2.0", events = [...], ... }
type alias Calendar =
{ generatorProductId : String
, specVersion : String
, events : List Event
, journals : List Journal
, extraProperties : List RawProperty
}

A parsed iCal calendar. Access fields directly:

case Parser.parse icsString of
    Ok cal ->
        List.length cal.events


    Err err ->
        0
type alias Event =
{ uid : String
, stamp : Posix
, time : EventTime
, created : Maybe Posix
, lastModified : Maybe Posix
, summary : Maybe String
, description : Maybe String
, location : Maybe String
, organizer : Maybe Organizer
, status : Maybe Status
, transparency : Maybe Transparency
, recurrenceRules : List RecurrenceRule
, exclusions : List OccurrenceReference
, recurrenceDates : List OccurrenceReference
, recurrenceId : Maybe OccurrenceReference
, attendees : List Attendee
, alarms : List Alarm
, extraProperties : List RawProperty
}

A parsed iCal event.

The uid, stamp, and time fields are required by RFC 5545 Section 3.6.1. Parsing will fail if DTSTART is missing from a VEVENT.

The end inside time is always populated — from DTEND, DURATION (which are mutually exclusive per the spec), or the RFC default when neither is present:

  • DATE events default to ending on the following date
  • DATE-TIME events default to ending at the same instant they start

Event times

= AllDay
{ start : Date
, end : Date
}
| WithTime
{ start : ResolvedTime
, end : ResolvedTime
}
| FloatingTime
{ start : LocalDateTime
, end : LocalDateTime
}

The time span of an event. The variant determines what type of values start and end carry, enforcing the RFC 5545 requirement that DTSTART and DTEND share the same value type.

The end is always populated. When neither DTEND nor DURATION is present in the source data, the parser applies the RFC 5545 default: DATE events end on the following day, and DATE-TIME events end at the same instant they start.

  • AllDay: VALUE=DATE; the event spans whole days.
  • WithTime: a resolved instant in time (UTC or TZID resolved via VTIMEZONE).
  • FloatingTime: a local date-time with no timezone; interpreted in the viewer's local time.
= OnDate Date
| AtTime ResolvedTime
| AtFloatingTime LocalDateTime

A typed reference to a specific recurrence instance as used by EXDATE, RDATE, and RECURRENCE-ID.

The constructor matches the value type from the iCalendar data:

  • OnDate for VALUE=DATE
  • AtTime for resolved DATE-TIME values (UTC or TZID-backed)
  • AtFloatingTime for floating local DATE-TIME values
type alias ResolvedTime =
{ posix : Posix
, timeZoneName : Maybe String
, localDateTime : Maybe LocalDateTime
, timeZoneContext : Maybe TimeZoneContext
}

A resolved date-time instant.

The timeZoneName is Nothing for UTC datetimes, or a IANA Time Zone Database name (e.g. Just "America/New_York") for TZID-resolved datetimes.

For TZID values, localDateTime retains the original wall-clock datetime and timeZoneContext keeps the parsed timezone definition so recurrence expansion can stay in local calendar dates. Most consumers can ignore those fields and just use posix.

type alias LocalDateTime =
{ year : Int
, month : Month
, day : Int
, hour : Int
, minute : Int
, second : Int
}

A date-time without timezone information. Represents a "floating" local date-time that is interpreted in the viewer's local time.

Opaque timezone data retained for parsed TZID date-times so recurrence expansion can resolve future local wall-clock occurrences correctly.

Most code will only need ResolvedTime.posix and ResolvedTime.timeZoneName.

Enums and metadata

type alias Organizer =
{ email : String
, name : Maybe String
}

A parsed ORGANIZER property.

type alias RawProperty =
{ name : String
, parameters : List ( String, String )
, value : String
}

An uninterpreted property: name, parameters, and raw value string. Unknown properties and extension properties (X-*) are preserved here.

type alias Attendee =
{ email : String
, name : Maybe String
, role : AttendeeRole
, participationStatus : ParticipationStatus
, rsvp : Bool
}

A parsed ATTENDEE property.

= Chair
| Required
| Optional
| NonParticipant

Attendee role per RFC 5545 Section 3.2.16.

= NeedsAction
| Accepted
| Declined
| TentativeParticipation
| Delegated

Attendee participation status per RFC 5545 Section 3.2.12.

Alarms

type alias Alarm =
{ action : AlarmAction
, trigger : AlarmTrigger
, description : Maybe String
}

A parsed alarm (VALARM) attached to an event.

Each alarm has an action (Display or Audio), a trigger (when the alarm fires), and an optional description.

event.alarms
    |> List.map .trigger
    -- [ TriggerDuration (SecondsFromStart (-900)), ... ]
= Display
| Audio

The type of alarm.

= TriggerDuration AlarmTriggerRelative
| TriggerAbsolute Posix

When the alarm fires.

  • TriggerDuration — a relative offset from the event start or end.
  • TriggerAbsolute — a fixed UTC time (used with VALUE=DATE-TIME).
= SecondsFromStart Int
| SecondsFromEnd Int

A relative alarm trigger as a signed offset in seconds. Negative values mean before, positive values mean after.

-- 15 minutes before start
SecondsFromStart (-15 * 60)


-- at the moment the event ends
SecondsFromEnd 0

Journals

type alias Journal =
{ uid : String
, stamp : Posix
, time : Maybe JournalTime
, summary : Maybe String
, description : Maybe String
, status : Maybe JournalStatus
, created : Maybe Posix
, lastModified : Maybe Posix
, organizer : Maybe Organizer
, extraProperties : List RawProperty
}

A parsed iCal journal entry (RFC 5545 Section 3.6.3). Unlike events, journals have no time span — just an optional date or datetime stamp and text content. They represent dated notes or log entries.

= JournalDate Date
| JournalDateTime ResolvedTime
| JournalFloatingTime LocalDateTime

The time of a journal entry. Since journals have no time span (no DTEND or DURATION), this is a single point — either a date or a datetime.

Recurrence expansion

type alias Occurrence =
{ event : Event
, time : EventTime
}

A single occurrence of an event. For non-recurring events, there is one occurrence whose time matches event.time. For recurring events, each occurrence has its own time shifted to the recurrence date while preserving the original event's duration.

{ start : Date
, end : Date
}
-> List Event
-> List Occurrence

Expand events into concrete occurrences within a date range, merging RECURRENCE-ID overrides automatically. Override events (those with a recurrenceId) replace the matching occurrence from the master event that shares their uid.

case Parser.parse icsString of
    Ok cal ->
        Parser.expand { start = jan1, end = dec31 } cal.events


    Err err ->
        []

For a single event, wrap it in a list:

Parser.expand { start = jan1, end = dec31 } [ event ]
expandNext : Int -> Date -> List Event -> List Occurrence

Get the next N occurrences starting from a given date, merging RECURRENCE-ID overrides automatically.

case Parser.parse icsString of
    Ok cal ->
        Parser.expandNext 10 today cal.events


    Err err ->
        []

For a single event, wrap it in a list:

Parser.expandNext 5 today [ event ]