···77import gtz
88import splitter
991010+/// A parsed iCal calendar.
1111+///
1212+/// Contains the calendar version, producer ID, the detected timezone used for
1313+/// resolving floating-time events, and a list of parsed events.
1414+///
1015pub type Calendar {
1116 Calendar(
1717+ /// The iCal version, typically `"2.0"`.
1218 version: String,
1919+ /// The producer identifier, e.g. `"-//Apple Inc.//macOS 14.0//EN"`.
1320 prodid: String,
2121+ /// The timezone used for resolving floating-time events. Either extracted
2222+ /// from the `X-WR-TIMEZONE` property, the value passed to
2323+ /// `parse_with_timezone`, or `"UTC"` as a fallback.
1424 timezone: String,
2525+ /// All parsed VEVENT components.
1526 events: List(Event),
1627 )
1728}
18293030+/// A single calendar event (VEVENT).
3131+///
3232+/// Common properties are extracted into named fields. All original properties
3333+/// are preserved in `raw` for access to anything not explicitly parsed.
3434+///
1935pub type Event {
2036 Event(
3737+ /// The globally unique identifier for this event.
2138 uid: String,
3939+ /// The event title. Empty string if not present.
2240 summary: String,
4141+ /// The start time as an unambiguous timestamp. Returns `unix_epoch` if
4242+ /// missing or unparseable.
2343 dtstart: timestamp.Timestamp,
4444+ /// The end time as an unambiguous timestamp. Returns `unix_epoch` if
4545+ /// missing or unparseable.
2446 dtend: timestamp.Timestamp,
4747+ /// True when the event uses date-only values (`VALUE=DATE`), indicating
4848+ /// an all-day event.
2549 is_all_day: Bool,
5050+ /// All original properties for this event, including DTSTART, DTEND,
5151+ /// LOCATION, DESCRIPTION, ATTENDEE, etc.
2652 raw: List(Property),
2753 )
2854}
29555656+/// A single iCal property line, consisting of a name, optional parameters,
5757+/// and a value.
5858+///
5959+/// For example: `DTSTART;TZID=Europe/Stockholm:20230101T100000` becomes:
6060+/// `Property("DTSTART", [Parameter("TZID", "Europe/Stockholm")], "20230101T100000")`
6161+///
3062pub type Property {
3163 Property(name: String, params: List(Parameter), value: String)
3264}
33656666+/// A key-value parameter attached to a property.
6767+///
6868+/// Parameters appear before the colon in a property line, separated by
6969+/// semicolons. For example, `TZID=Europe/Stockholm` in:
7070+/// `DTSTART;TZID=Europe/Stockholm:20230101T100000`
7171+///
3472pub type Parameter {
3573 Parameter(name: String, value: String)
3674}
37757676+/// An error that can occur during parsing.
3877pub type Error {
7878+ /// The input could not be parsed. The string contains a description of
7979+ /// what went wrong.
3980 ParseError(String)
4081}
4182···69110 )
70111}
71112113113+/// Parse an iCal string into a `Calendar`.
114114+///
115115+/// Floating-time events (those without a `Z` suffix or `TZID` parameter) are
116116+/// resolved using the `X-WR-TIMEZONE` property if present, otherwise UTC.
117117+///
118118+/// ```gleam
119119+/// let assert Ok(calendar) = gcal.parse(ical_string)
120120+/// calendar.events
121121+/// ```
122122+///
72123pub fn parse(input: String) -> Result(Calendar, Error) {
73124 parse_with_timezone(input, "")
74125}
75126127127+/// Parse an iCal string into a `Calendar`, using the given timezone for
128128+/// floating-time events.
129129+///
130130+/// Floating-time events are those without a `Z` suffix and without a `TZID`
131131+/// parameter. This function resolves them to the provided timezone instead
132132+/// of using `X-WR-TIMEZONE` or UTC.
133133+///
134134+/// Events with an explicit `TZID` parameter are always resolved using that
135135+/// timezone, regardless of this argument.
136136+///
137137+/// ```gleam
138138+/// let assert Ok(calendar) =
139139+/// gcal.parse_with_timezone(ical_string, "Europe/Stockholm")
140140+/// ```
141141+///
76142pub fn parse_with_timezone(
77143 input: String,
78144 tz: String,
···253319 |> string.replace("\\\\", "\\")
254320}
255321322322+/// Find a property by name in an event's raw properties.
323323+///
324324+/// Returns `Ok(Property)` if found, `Error(Nil)` otherwise.
325325+///
326326+/// ```gleam
327327+/// let assert Ok(location_prop) = gcal.get_property(event, "LOCATION")
328328+/// location_prop.value
329329+/// ```
330330+///
256331pub fn get_property(event: Event, name: String) -> Result(Property, Nil) {
257332 list.find(event.raw, fn(prop) { prop.name == name })
258333}
259334335335+/// Find a parameter value by name in a property's parameters.
336336+///
337337+/// Returns `Ok(String)` with the parameter value if found, `Error(Nil)`
338338+/// otherwise.
339339+///
340340+/// ```gleam
341341+/// let assert Ok(prop) = gcal.get_property(event, "DTSTART")
342342+/// let assert Ok(tzid) = gcal.get_parameter(prop, "TZID")
343343+/// ```
344344+///
260345pub fn get_parameter(prop: Property, name: String) -> Result(String, Nil) {
261346 list.find_map(prop.params, fn(param) {
262347 case param.name == name {
···368453 }
369454}
370455456456+/// Parse a datetime property value into a `Timestamp`.
457457+///
458458+/// Handles three iCal datetime formats:
459459+/// - UTC: `20230101T100000Z`
460460+/// - Timezone-aware: `20230101T100000` with `TZID=Europe/Stockholm` parameter
461461+/// - Date-only: `VALUE=DATE:20230101` (treated as midnight UTC)
462462+/// - Floating: `20230101T100000` (resolved using `fallback_tz`, or UTC)
463463+///
464464+/// Returns `timestamp.unix_epoch` for empty or unparseable values.
465465+///
371466@internal
372467pub fn parse_datetime(
373468 prop: Property,