Generate and parse a practical subset of iCalendar (RFC 5545) in Elm.
elm-ical is currently optimized for:
VCALENDAR / VEVENT feeds from typed Elm valuesVEVENT feeds strictly into typed Elm dataRRULE subset directly in ElmIt 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.
summary, start, and generatorProductId instead of SUMMARY, DTSTART, and PRODID.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 1616068800000import 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.
DTEND/DURATION combinations return Err. A TZID parameter without a matching VTIMEZONE definition is a hard parse error.AllDay, WithTime, or FloatingTime. The end is always populated (from DTEND, DURATION, or the RFC default).DATE-TIME values with a TZID parameter are resolved through matching VTIMEZONE data.STATUS, TRANSP, attendee role, and participation status are decoded into Elm union types. Shared types like Status and Transparency live in the Ical module.extraProperties.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).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.VEVENT omits both DTEND and DURATION, the parser applies the RFC default: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.Ical.Generator)VERSION, PRODID, NAME (non-standard but widely used), DESCRIPTION, URLDTSTART, DTEND, DTSTAMP, UID, SUMMARY, DESCRIPTION, LOCATION, ORGANIZER, X-ALT-DESC, STATUS, TRANSP, CREATED, LAST-MODIFIED, RRULE, ATTENDEEVALARM 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 descriptionRule builder:FREQ, INTERVAL, COUNT, UNTIL, BYDAY, BYMONTHDAY, BYMONTH, BYSETPOS, BYHOUR, BYMINUTE, BYSECOND, BYYEARDAY, BYWEEKNO, WKSTFrequency carries every (interval) and weekStart (for Weekly), so they're scoped to where they applyBYMONTH uses Time.Month values, so invalid months are impossible at the type levelIcal.Parser)PRODID, VERSION, plus unknown calendar properties in extraPropertiesDTSTART, DTEND, DTSTAMP, DURATION, UID, SUMMARY, DESCRIPTION, LOCATION, ORGANIZER, STATUS, TRANSP, CREATED, LAST-MODIFIED, RRULE, EXDATE, RDATE, RECURRENCE-ID, ATTENDEETZID date-times resolved through matching VTIMEZONECN, ROLE, PARTSTAT, RSVPVJOURNAL parsing: journal entries with optional DTSTART (date, datetime, or floating), STATUS (DRAFT/FINAL/CANCELLED), SUMMARY, DESCRIPTION, ORGANIZERVALARM parsing: alarm sub-components with ACTION (DISPLAY/AUDIO), TRIGGER (duration relative to start/end, or absolute DATE-TIME), DESCRIPTIONIcal)Status (Tentative, Confirmed, Cancelled)Transparency (Opaque, Transparent)JournalStatus (Draft, Final, CancelledJournal)SECONDLY, MINUTELY, HOURLY, DAILY, WEEKLY, MONTHLY, YEARLYINTERVAL, COUNT, UNTIL, BYDAY, BYMONTHDAY, BYMONTH, BYSETPOS, BYHOUR, BYMINUTE, BYSECOND, BYYEARDAY, BYWEEKNO, WKSTEXDATE for all-day, floating, and resolved date-time eventsRDATERECURRENCE-ID override mergingIcal.Parser.expand and Ical.Parser.expandNext (both merge RECURRENCE-ID overrides automatically)TZID date-times or VTIMEZONE componentsVTODO and VFREEBUSYVTIMEZONE 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.