///|
/// One calendar event, mapped from a `VEVENT` component (RFC 5545 ยง3.6.1).
///
/// This is a typed *view* over the component tree, not a replacement for it:
/// unknown properties stay in the `Component` and nothing is dropped. Every
/// field is optional because RFC 5545 makes every `VEVENT` property optional;
/// a property that is *present but malformed* raises [`@text.ParseError`]
/// instead of being silently skipped, so a broken feed is loud rather than
/// subtly wrong. A property that is simply absent is `None` โ absence is not
/// breakage.
///
/// Not interpreted yet: `DURATION` as the alternative end to `DTEND` (needs
/// ISO 8601 duration parsing, planned with the recurrence work), and merging
/// override events into an expanded series (S8). `RECURRENCE-ID` is parsed
/// and kept so a moved or cancelled single instance is already visible.
pub struct Event {
uid : String?
summary : String?
location : String?
description : String?
dtstart : IcalDateTime?
dtend : IcalDateTime?
/// The raw `RRULE` value exactly as it appeared on the wire; the rule is
/// parsed and expanded by later milestones (S2/S3/S8).
rrule : String?
/// The series instance this event overrides, when this is an override
/// event rather than a series definition.
recurrence_id : IcalDateTime?
/// `RANGE=THISANDFUTURE` on RECURRENCE-ID, when present.
recurrence_range : String?
/// Event status, notably `CANCELLED` on an overridden instance.
status : String?
/// Every `EXDATE` value across repeated property lines, in wire order.
exdates : Array[IcalDateTime]
} derive(Debug)
///|
pub extend Event with @moonbitlang/core/debug.Debug::{to_repr}
///|
/// An all-day event is one whose `DTSTART` is a DATE value.
pub fn Event::all_day(self : Event) -> Bool {
match self.dtstart {
Some(dt) => dt.all_day
None => false
}
}
///|
pub impl Show for Event with fn output(self, logger) {
let uid = self.uid.unwrap_or("-")
let summary = self.summary.unwrap_or("")
match self.dtstart {
Some(dt) => logger.write_string("VEVENT \{uid}: \{summary} @ \{dt}")
None => logger.write_string("VEVENT \{uid}: \{summary}")
}
}
///|
pub extend Event with Show::{to_string, output}
///|
/// The `VEVENT` children of this component as typed events, in wire order.
///
/// `zones` resolves `TZID=` parameters. Pass the table the way [`parse_events`]
/// builds it โ [`ZoneTable::with_builtin`] over [`build_zone_table`] applied to
/// the same feed โ so a feed's own `VTIMEZONE` wins over the built-in guesses.
///
/// Only direct children are considered: a `VEVENT` nests directly inside a
/// `VCALENDAR`, never deeper.
pub fn Component::events(
self : Component,
zones~ : ZoneTable,
) -> Array[Event] raise {
let out : Array[Event] = []
for child in self.children {
if child.kind == "VEVENT" {
out.push(event_from_component(child, zones))
}
}
out
}
///|
/// Parse a complete iCalendar document into its lossless component trees.
/// Unknown components and properties remain available for inspection and
/// serialization.
pub fn parse_calendar(input : String) -> Array[Component] raise {
parse_components(@text.unfold(input))
}
///|
/// Parse a whole iCalendar feed into typed events: unfold the text, build
/// the component tree, resolve zones (the feed's own `VTIMEZONE` first,
/// built-in guesses second), then map every `VEVENT`.
///
/// # Example
/// ```mbt nocheck
/// fn test_example() raise {
/// let events = @model.parse_events(
/// "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Sync\r\nEND:VEVENT\r\nEND:VCALENDAR",
/// )
/// assert_eq(events.length(), 1)
/// assert_eq(events[0].summary, Some("Sync"))
/// }
/// ```
pub fn parse_events(input : String) -> Array[Event] raise {
let roots = parse_calendar(input)
let zones = ZoneTable::with_builtin(build_zone_table(roots))
let out : Array[Event] = []
for root in roots {
for event in root.events(zones~) {
out.push(event)
}
}
out
}
///|
/// Map one `VEVENT` component onto an [`Event`]. A malformed date property
/// raises; a missing one stays `None`.
fn event_from_component(
component : Component,
zones : ZoneTable,
) -> Event raise {
let dtstart = component
.property("DTSTART")
.map(line => parse_date_time(line, zones))
let dtend = component
.property("DTEND")
.map(line => parse_date_time(line, zones))
let recurrence_id = component
.property("RECURRENCE-ID")
.map(line => parse_date_time(line, zones))
let recurrence_range = component
.property("RECURRENCE-ID")
.bind(line => line.param("RANGE"))
let exdates : Array[IcalDateTime] = []
for line in component.properties_named("EXDATE") {
let tzid = line.param("TZID")
let value_is_date = match line.param("VALUE") {
Some(v) => String::to_upper(v) == "DATE"
None => false
}
for value in split_values(line.value) {
exdates.push(
parse_date_time_value(value, zones, tzid~, value_is_date~, line_no=0),
)
}
}
{
uid: text_of(component, "UID"),
summary: text_of(component, "SUMMARY"),
location: text_of(component, "LOCATION"),
description: text_of(component, "DESCRIPTION"),
dtstart,
dtend,
rrule: component.property("RRULE").map(line => line.value),
recurrence_id,
recurrence_range,
status: text_of(component, "STATUS"),
exdates,
}
}
///|
/// Unescaped display text of the first property with this name, if any.
fn text_of(component : Component, name : String) -> String? {
match component.property(name) {
Some(line) => Some(@text.unescape_value(line.value))
None => None
}
}
///|
/// Split a comma-separated property value into its parts, as `EXDATE` and
/// `RDATE` lists are written.
fn split_values(value : String) -> Array[String] {
let parts : Array[String] = []
for part in value.view().split(",".view()) {
parts.push(part.to_owned())
}
parts
}