justinwongcn/moon-ical/ical/model does not have a README file

    Component

    pub struct Component {
    kind : String
    properties : Array[
    ContentLine
    ]
    children : Array[Component]
    } derive(Eq,
    Debug
    )

    One iCalendar component: its kind, its own properties in wire order, and any nested components. Nesting is real (VALARM lives inside VEVENT, VEVENT inside VCALENDAR), so the model keeps a tree instead of a flat list, and properties stay in the order the feed sent them.

    Component::equal

    fn Component::equal(Component, Component) -> Bool

    Component::events

    fn Component::events(self : Component, zones~ : ZoneTable) -> Array[Event] raise

    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.

    Component::not_equal

    fn Component::not_equal(x : Component, y : Component) -> Bool

    Component::properties_named

    Every property with this name — EXDATE and RDATE may be repeated, and a single one may hold a comma-separated list.

    Component::property

    The first property with this name, if the component carries one.

    Event

    pub struct Event {
    uid : String?
    summary : String?
    location : String?
    description : String?
    dtstart : IcalDateTime?
    dtend : IcalDateTime?
    rrule : String?
    recurrence_id : IcalDateTime?
    recurrence_range : String?
    status : String?
    exdates : Array[IcalDateTime]
    } derive(
    Debug
    )

    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.
    impl Show for Event

    Event::all_day

    fn Event::all_day(self : Event) -> Bool

    An all-day event is one whose DTSTART is a DATE value.

    Event::output

    fn Event::output(self : Event, logger : &Logger) -> Unit

    Event::to_repr

    Event::to_string

    fn Event::to_string(self : Event) -> String

    IcalDateTime

    pub struct IcalDateTime {
    wall :
    PlainDateTime

    utc_offset : Int?
    zone_id : String
    all_day : Bool
    } derive(Eq,
    Debug
    )

    A parsed iCalendar date-time value (DTSTART, DTEND, DUE, UNTIL, EXDATE, ...).

    iCalendar writes wall-clock readings rather than instants, so the wall time is kept next to the offset that says where it should be read:

    • DTSTART:20260908T013000Z → utc_offset = Some(0)
    • DTSTART;TZID=Asia/Shanghai:20260908T093000 → utc_offset = Some(28800)
    • DTSTART:20260908T093000 → utc_offset = None (floating: the feed declared no zone, so it means 09:30 wherever it is read)
    • DTSTART;VALUE=DATE:20260908 → all_day = true, wall time at midnight

    moonbitlang/x/time supplies the calendar arithmetic; the offset stays a plain Int instead of a Zone because named zones are resolved through a fixed-offset table (see [ZoneTable] and seam S2 in docs/upstream-seams.md).

    IcalDateTime::compare

    fn IcalDateTime::compare(self : IcalDateTime, other : IcalDateTime) -> Int

    Negative when self happens before other, zero for the same instant, positive afterwards. All-day values compare by their calendar day.

    IcalDateTime::equal

    IcalDateTime::instant_seconds

    fn IcalDateTime::instant_seconds(self : IcalDateTime) -> Int64

    Seconds since the Unix epoch for this value.

    A floating value is treated as UTC so that floating and explicitly-zoned times can still be sorted in one pass; the feed gave nothing better to go on.

    IcalDateTime::is_floating

    fn IcalDateTime::is_floating(self : IcalDateTime) -> Bool

    IcalDateTime::is_utc

    fn IcalDateTime::is_utc(self : IcalDateTime) -> Bool

    IcalDateTime::not_equal

    fn IcalDateTime::not_equal(x : IcalDateTime, y : IcalDateTime) -> Bool

    IcalDateTime::on_date

    The same clock reading as self, on another calendar day: the clock time, UTC offset, zone spelling, and all-day flag all carry over unchanged.

    This is how a recurrence expansion moves a DTSTART through its occurrences. The offset is deliberately frozen at self's value: the fixed-offset ZoneTable has no DST history (seam S2 in docs/upstream-seams.md), so a series crossing a DST change keeps the DTSTART offset rather than guessing — the boundary README documents this. Raises if date is not a valid calendar day.

    IcalDateTime::output

    fn IcalDateTime::output(self : IcalDateTime, logger : &Logger) -> Unit

    IcalDateTime::shift_seconds

    fn IcalDateTime::shift_seconds(self : IcalDateTime, seconds : Int64) -> IcalDateTime raise

    Shift this wall-clock value by an exact number of seconds while preserving its zone spelling, fixed offset, and all-day marker.

    IcalDateTime::to_string

    fn IcalDateTime::to_string(self : IcalDateTime) -> String

    ZoneTable

    pub struct ZoneTable {
    entries : Map[String, Int]
    } derive(
    Debug
    )

    A zone-name to UTC-offset table used to resolve TZID= parameters.

    Offsets are seconds east of UTC. iCalendar defines TZID case-sensitively, but real feeds are sloppy (Google emits Asia/Shanghai, some Outlook exports ASIA/SHANGHAI), so keys are normalised before comparison.

    The table holds fixed offsets only. Honouring a named zone's daylight-saving history per date needs an IANA time-zone database, which moonbitlang/x/time does not ship and no mooncakes package provides — see seam S2 in docs/upstream-seams.md. The practical consequence is stated in README: a February and a July event in Europe/Berlin resolve to the same offset here (+01:00), while a feed that embeds its own VTIMEZONE gets the offset its author actually declared.

    ZoneTable::builtin_common

    fn ZoneTable::builtin_common() -> ZoneTable

    Offsets for the named zones that actually show up in the feeds we test against. Standard-time values only, on purpose (see the ZoneTable doc).

    ZoneTable::empty

    fn ZoneTable::empty() -> ZoneTable

    An empty table: every TZID fails to resolve until zones are added.

    ZoneTable::insert

    fn ZoneTable::insert(self : ZoneTable, tzid : String, seconds : Int) -> Unit

    Record a zone. Later inserts win, which is what lets a feed's own VTIMEZONE override a built-in guess for the same name.

    ZoneTable::lookup

    fn ZoneTable::lookup(self : ZoneTable, tzid : String) -> Int?

    Look a zone up, case-insensitively; the most recently inserted match wins.

    ZoneTable::with_builtin

    fn ZoneTable::with_builtin(feed : ZoneTable) -> ZoneTable

    The built-in zones, overridden by anything the feed declared itself. This is the table parse_date_time wants: a feed that ships a VTIMEZONE knows better than our guess, and everything else still resolves.

    build_zone_table

    fn build_zone_table(roots : Array[Component]) -> ZoneTable

    Collect TZID -> TZOFFSETTO from a feed's own VTIMEZONE components, so a private zone name (/google.com/ntp/Asia/Shanghai, China Standard Time) resolves without an IANA database.

    Only the STANDARD sub-component is read, and an offset that will not parse is skipped rather than aborting the whole feed: one broken VTIMEZONE should not hide the events that reference it.

    Example

    fn test_example() raise {
    let roots = @model.parse_components([
    "BEGIN:VCALENDAR", "BEGIN:VTIMEZONE", "TZID:MY-ZONE", "BEGIN:STANDARD", "TZOFFSETTO:+0930",
    "END:STANDARD", "END:VTIMEZONE", "END:VCALENDAR",
    ])
    let table = @model.build_zone_table(roots)
    assert_eq(table.lookup("my-zone"), Some(9 * 3600 + 30 * 60))
    }

    parse_calendar

    fn parse_calendar(input : String) -> Array[Component] raise

    Parse a complete iCalendar document into its lossless component trees. Unknown components and properties remain available for inspection and serialization.

    parse_components

    fn parse_components(lines : Array[String]) -> Array[Component] raise

    Build a component tree from already-unfolded logical lines.

    Unknown components are preserved rather than dropped, so an unusual feed loses no data. Unbalanced BEGIN/END, a mismatched END name, and a property outside any component all raise [@text.ParseError] carrying the offending line number.

    Example

    fn test_example() raise {
    let roots = @model.parse_components([
    "BEGIN:VCALENDAR", "BEGIN:VEVENT", "SUMMARY:Sync", "END:VEVENT", "END:VCALENDAR",
    ])
    assert_eq(roots.length(), 1)
    assert_eq(roots[0].children[0].properties[0].value, "Sync")
    }

    parse_date_time

    fn parse_date_time(line :
    ContentLine
    , zones : ZoneTable, line_no? : Int) -> IcalDateTime raise

    Parse a DTSTART / DTEND / DUE property into an [IcalDateTime].

    zones resolves a TZID= parameter; pass [ZoneTable::with_builtin] over [build_zone_table] when reading a feed that ships its own VTIMEZONE. An unresolvable TZID is an error rather than a silent fallback to UTC, because guessing would move every event in that calendar by hours.

    Example

    fn test_example() raise {
    let line = @text.parse_content_line(
    "DTSTART;TZID=Asia/Shanghai:20260908T093000", 1,
    )
    let dt = @model.parse_date_time(line, @model.ZoneTable::builtin_common())
    assert_eq(dt.utc_offset, Some(8 * 3600))
    assert_eq(dt.to_string(), "2026-09-08T09:30:00+08:00")
    }

    parse_date_time_value

    fn parse_date_time_value(raw : String, zones : ZoneTable, tzid? : String?, value_is_date? : Bool, line_no? : Int) -> IcalDateTime raise

    Parse one date-time value together with the context its property line carried. EXDATE and RDATE pack several comma-separated values into one line, and the line's TZID= / VALUE=DATE parameters apply to every one of them — split the values, pass each through here with the same context.

    parse_events

    fn parse_events(input : String) -> Array[Event] raise

    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

    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"))
    }

    parse_single_date_time

    fn parse_single_date_time(text : String, zones : ZoneTable, line_no? : Int) -> IcalDateTime raise

    Parse a bare date-time text, as found in RRULE's UNTIL= and in an EXDATE list, where no property carries a TZID.

    RFC 5545 requires UNTIL to be UTC when DTSTART is UTC, so a trailing Z is read here; anything else is floating, exactly like a DTSTART written without a designator.

    parse_utc_offset

    fn parse_utc_offset(text : String, line_no~ : Int, raw~ : String) -> Int raise

    Parse an iCalendar UTC-offset value: +0800, -0500, +0530, or a bare +08. Raises [ParseError] on anything else, because a half-read offset silently shifts every event in a calendar.

    Example

    fn test_example() raise {
    assert_eq(
    @model.parse_utc_offset("+0530", line_no=1, raw="+0530"),
    5 * 3600 + 30 * 60,
    )
    assert_eq(
    @model.parse_utc_offset("-0800", line_no=1, raw="-0800"),
    -(8 * 3600),
    )
    }