moon-ical

    iCalendar (RFC 5545) parsing, recurrence expansion, and a minimal CalDAV server in pure MoonBit

    icalendar
    calendar
    caldav
    rrule
    rfc5545
    Download zip
    Version
    0.2.0
    License
    Apache-2.0
    Last updated
    5 hours ago
    Downloads
    5

    Dependencies

    #moon-ical

    A pure MoonBit iCalendar (RFC 5545) library and minimal CalDAV (RFC 4791) server. It covers the full path from .ics input through typed events and recurrence expansion to a DAV calendar that stock clients can discover.

    #Features

    • iCalendar unfolding, quoted parameters, TEXT escaping, nested components, and preservation of unknown properties.
    • UTC, floating, TZID, and all-day values; feed-embedded VTIMEZONE takes precedence over the documented common fixed-offset table.
    • DAILY, WEEKLY, MONTHLY, and YEARLY recurrence with INTERVAL, COUNT, UNTIL, ordinal BYDAY, positive/negative BYMONTHDAY, BYMONTH, BYSETPOS, and WKST.
    • Effective-series merging for EXDATE, moved/cancelled RECURRENCE-ID, and RANGE=THISANDFUTURE.
    • RFC-style CRLF serialization with UTF-8-safe 75-octet folding.
    • Native CalDAV server: vdir storage, content-addressed ETags, discovery, PROPFIND, calendar-query/multiget REPORT, MKCALENDAR, and conditional PUT/DELETE.

    #Install

    moon add justinwongcn/moon-ical@0.1.0

    The repository is now developing 0.2.0. The published 0.1.0 remains the installable release until the new API and end-to-end checks are complete.

    #Library usage

    ///|
    test {
    let events = @moon_ical.parse_events(
    "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:demo\r\n" +
    "DTSTART:20260901T090000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\n" +
    "END:VEVENT\r\nEND:VCALENDAR\r\n",
    )
    let occurrences = @moon_ical.expand_series(events)
    assert_eq(occurrences.length(), 3)
    assert_eq(occurrences[2].start.to_string(), "2026-09-03T09:00:00Z")
    }

    Focused subpackages remain available under ical/text, ical/model, ical/rrule, ical/serialize, and ical/caldav. The root package re-exports the complete parse, recurrence, and serialization workflows; lower-level text, date-time, XML, HTTP-precondition, and CalDAV response primitives stay in their own subpackages.

    For 0.2.0, the root facade intentionally drops low-level re-exports from 0.1.0. Existing advanced code can import ical/text, ical/model, ical/serialize, or ical/caldav directly; complete workflows continue to use the root package.

    #Run

    moon run demo --target native moon run demo --target native -- https://example.com/calendar.ics moon run cmd/serve --target native -- ./caldata 8437

    The calendar home is http://127.0.0.1:8437/cal/; discovery starts at /.well-known/caldav. The first release is local/plain HTTP. Put TLS and authentication in a reverse proxy before exposing it outside a trusted host.

    #Verification

    moon check --target all --deny-warn moon test --target all --deny-warn moon fmt --check moon info python tools/s5_acceptance.py python tools/s6_acceptance.py

    Current results: 150 wasm, 137 wasm-gc, 150 JavaScript, and 155 native tests; 21 HTTP storage and 11 live CalDAV curl checks also pass.

    #Client interoperability

    Client or driverDiscoveryRead/listCreate/update/deleteResult
    Real curl over TCPYesYesYes32/32 live checks pass
    Thunderbird 155.0.1 (Windows)YesYesYesPassed against localhost: seed read, create, in-place update, and delete verified in vdir
    DAVx5Not runNot runNot runAndroid device required
    Apple CalendarNot runNot runNot runmacOS/iOS device required

    Rows are marked passed only after an actual client session; curl coverage is not presented as client-interoperability evidence.

    #0.1.0 release scope (frozen)

    The hackathon release is feature-frozen at the reusable iCalendar library, minimal CalDAV server, live curl acceptance suites, and verified Thunderbird CRUD interoperability. DAVx5 and Apple Calendar remain evidence candidates, not release requirements. Cloud-account synchronization for Google Calendar, iCloud, Yahoo, and Outlook/Microsoft Graph is post-hackathon work.

    #Boundaries

    • No complete IANA tzdb. Named zones use feed VTIMEZONE then a common fixed-offset table; recurring wall time keeps the resolved offset across DST.
    • No CalDAV scheduling (iTIP/iMIP), ACL system, built-in TLS, or CalDAV client.
    • No provider adapters or cloud-account synchronization for Google Calendar, iCloud, Yahoo, or Outlook/Microsoft Graph.
    • Request bodies require Content-Length; chunked uploads receive 411.
    • Sub-daily RRULE frequencies and BYWEEKNO, BYYEARDAY, BYHOUR, BYMINUTE, and BYSECOND are explicit parse errors.

    The architecture and milestone record are in docs/development.html; third-party corpus provenance is recorded in THIRD-PARTY-NOTICES.md. This is an independent MoonBit implementation, licensed under Apache-2.0.

    Byday

    One BYDAY entry: a weekday, optionally qualified by an ordinal.

    TU is any Tuesday (ordinal = 0), 1FR the first Friday of the interval, -2MO the second-to-last Monday. RFC 5545 bounds the ordinal to ±53; expansion of qualified weekdays arrives with S8.

    Component

    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.

    Event

    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.

    ExpandError

    Why an expansion can fail even though the rule parsed.

    Freq

    How often a recurrence repeats: the FREQ clause of RFC 5545 §3.3.10.

    Only the four calendar grades are representable, on purpose. The finer HOURLY, MINUTELY, and SECONDLY grades fall outside this project's boundary (docs/development.html §08), so [@rrule.parse_rule] rejects them with an explicit error instead of silently narrowing or dropping them.

    IcalDateTime

    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).

    Occurrence

    One effective occurrence after RRULE expansion, EXDATE removal, and RECURRENCE-ID override merging.

    ParseError

    A line-level iCalendar parse failure.

    BadLine is raised by the content-line and component layers: line_no is 1-based over logical (unfolded) lines and line keeps the offending text, so a caller can point a user at the exact place a feed broke instead of just reporting "invalid iCalendar". BadRule is raised by value-level parsers such as @rrule.parse_rule, where a whole RRULE value is the unit of failure and no line context applies.

    Rule

    A parsed RRULE value (RFC 5545 §3.3.10): the syntax of a recurrence rule, not its expansion.

    Every clause within the project's support boundary is kept in a typed field, whichever milestone will expand it — the first-tier clauses (INTERVAL / COUNT / UNTIL / plain BYDAY / positive BYMONTHDAY / BYMONTH) with S3, the second-tier ones (ordinal BYDAY, negative BYMONTHDAY, BYSETPOS, WKST) with S8. Clauses outside the boundary never reach a Rule: [@rrule.parse_rule] rejects them loudly.

    Defaults follow the RFC: interval = 1 and wkst = Monday when their clauses are absent, and every BY* list is empty when absent.

    expand

    Expand a parsed [Rule] against its DTSTART into the occurrence date-times of the series, in chronological order (RFC 5545 §3.3.10).

    Supports the calendar-grade RFC 5545 rules in this project: DAILY / WEEKLY / MONTHLY / YEARLY, ordinal BYDAY, positive and negative BYMONTHDAY, BYSETPOS, BYMONTH, and WKST. The semantic checks that need the DTSTART (COUNT + UNTIL exclusivity, UNTIL value-type agreement) happen here.

    Occurrences are wall-clock arithmetic: every occurrence carries the DTSTART's clock time, UTC offset, zone spelling, and all-day flag unchanged (see [@model.IcalDateTime::on_date]), so a series crossing a DST change keeps the DTSTART offset — the documented ZoneTable boundary, not a silent guess. A DTSTART that does not match the rule is not forced into the series: the first occurrence is the first matching date, exactly as the reference corpus behaves.

    COUNT counts occurrences from the first match (RFC 5545 counts the DTSTART only when it matches); UNTIL bounds the series inclusively, compared by instant. limit caps how many occurrences are returned — the safety valve for unbounded rules — and never truncates a COUNT/UNTIL rule that ends within it.

    Example

    fn test_example() raise {
    let rule = @rrule.parse_rule("FREQ=DAILY;COUNT=3")
    let dtstart = @model.parse_single_date_time(
    "20260901T090000Z",
    @model.ZoneTable::empty(),
    )
    let occurrences = @rrule.expand(rule, dtstart)
    assert_eq(occurrences.length(), 3)
    assert_eq(occurrences[2].to_string(), "2026-09-03T09:00:00Z")
    }

    expand_series

    Expand and merge one UID's VEVENT series. A cancelled override removes its instance; a moved override replaces that instance; RANGE=THISANDFUTURE shifts the selected instance and every later generated instance by the same wall-clock delta.

    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_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_rule

    fn parse_rule(rule : String) ->
    Rule
    raise

    Parse an RRULE property value (RFC 5545 §3.3.10) into a [Rule].

    This is the syntax layer only. Every clause inside the project's support boundary (docs/development.html §08) is parsed into a typed field; the clauses outside it — the HOURLY / MINUTELY / SECONDLY frequencies and the BYWEEKNO / BYYEARDAY / BYHOUR / BYMINUTE / BYSECOND clauses — are rejected with an explicit [@text.ParseError::BadRule] rather than silently dropped.

    The RRULE: property-name prefix may be present or absent, so both a bare property value and a whole content line parse. Clause names and values match case-insensitively (real feeds mix cases). Each clause may appear at most once, and FREQ must appear exactly once.

    Two deliberate deviations from the RFC grammar, in the direction of tolerating real feeds: FREQ is not required to be the first clause, and no DTSTART-dependent semantic checks (COUNT + UNTIL exclusivity, UNTIL value-type agreement) happen here — those belong to expansion (S3), which is the only place that knows the DTSTART.

    Example

    fn test_example() raise {
    let rule = @rrule.parse_rule("FREQ=WEEKLY;BYDAY=TU,TH;COUNT=10")
    assert_true(rule.freq is @rrule.Freq::Weekly)
    assert_eq(rule.count, Some(10))
    assert_eq(rule.byday.length(), 2)
    }

    serialize_component

    fn serialize_component(root :
    Component
    ) -> String

    Serialize a single root component; see [serialize_components].

    serialize_components

    fn serialize_components(roots : Array[
    Component
    ]) -> String

    Serialize a whole component forest back into iCalendar text: BEGIN:KIND, every property line, every child component, END:KIND, CRLF throughout (RFC 5545 §3.1), and every physical line within [FOLD_LIMIT] octets.

    Property values are written in their escaped wire form, exactly as they were parsed — this layer folds, it does not re-escape — so a feed that was parsed into a tree and written back out loses nothing. Property and parameter names were normalised to upper case at parse time and stay that way; parameter values whose content would read back as structure (, ; : ") are re-quoted.

    Example

    fn test_example() raise {
    let roots = @model.parse_components([
    "BEGIN:VCALENDAR", "SUMMARY:Sync", "END:VCALENDAR",
    ])
    assert_eq(
    @serialize.serialize_components(roots),
    "BEGIN:VCALENDAR\r\nSUMMARY:Sync\r\nEND:VCALENDAR\r\n",
    )
    }

    Source Files