events: iCalendar import + occurrence-exact round-trip + 19 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 38s

ical.sx parses VEVENT/VCALENDAR text back into events (ev/ical-lines->event,
ev/parse-vcalendar): DTSTART/DURATION/RRULE (ordinal BYDAY, BYMONTHDAY, UNTIL/
COUNT/INTERVAL) + EXDATE/RDATE. Round-trip is occurrence-exact — export->import
expands to the identical occurrence set. Completes bidirectional interop.
360/360 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 17:28:26 +00:00
parent 94aaf0e433
commit 3913bc368c
5 changed files with 251 additions and 7 deletions

View File

@@ -189,3 +189,153 @@
;; Render content lines to the on-the-wire iCalendar text (CRLF-separated).
(define ev/ical-render (fn (lines) (ev-ical-join lines "\r\n")))
;; ---- import (parse VEVENT/VCALENDAR back into events) ----
;; Inverse of the export above: parse iCalendar content lines into event dicts
;; (ev-event-full shape). Capacity is not an iCal property, so imported events
;; default to capacity 0 — set it after import if needed.
;; "20260601T180000Z" -> UTC epoch-minutes.
(define
ev-ical-parse-dt
(fn
(s)
(ev-dt
(string->number (substring s 0 4))
(string->number (substring s 4 6))
(string->number (substring s 6 8))
(string->number (substring s 9 11))
(string->number (substring s 11 13)))))
;; "30M" / "" -> minutes.
(define
ev-ical-parse-min
(fn
(s)
(if (= (string-length s) 0) 0 (string->number (first (split s "M"))))))
;; "PT1H30M" / "PT1H" / "PT30M" -> minutes.
(define
ev-ical-parse-duration
(fn
(s)
(let
((body (substring s 2 (string-length s))))
(let
((hparts (split body "H")))
(if
(> (len hparts) 1)
(+ (* 60 (string->number (first hparts))) (ev-ical-parse-min (first (rest hparts))))
(ev-ical-parse-min body))))))
(define
ev-ical-wd->num
(fn
(tok)
(cond
((= tok "MO") 0)
((= tok "TU") 1)
((= tok "WE") 2)
((= tok "TH") 3)
((= tok "FR") 4)
((= tok "SA") 5)
((= tok "SU") 6)
(else 0))))
;; "MO" -> 0 ; "2TU" -> {:ord 2 :wd 1} ; "-1FR" -> {:ord -1 :wd 4}
(define
ev-ical-parse-byday-token
(fn
(tok)
(let
((n (string-length tok)))
(if
(= n 2)
(ev-ical-wd->num tok)
{:ord (string->number (substring tok 0 (- n 2)))
:wd (ev-ical-wd->num (substring tok (- n 2) n))}))))
(define
ev-ical-parse-freq
(fn
(v)
(cond
((= v "DAILY") :daily)
((= v "WEEKLY") :weekly)
((= v "MONTHLY") :monthly)
(else :daily))))
;; "FREQ=WEEKLY;INTERVAL=2;UNTIL=...;BYDAY=MO,WE" -> rrule dict.
(define
ev-ical-parse-rrule
(fn
(val)
(let
((rr {}))
(begin
(for-each
(fn
(p)
(let
((kv (split p "=")))
(let
((k (first kv)) (v (first (rest kv))))
(cond
((= k "FREQ") (dict-set! rr :freq (ev-ical-parse-freq v)))
((= k "INTERVAL") (dict-set! rr :interval (string->number v)))
((= k "COUNT") (dict-set! rr :count (string->number v)))
((= k "UNTIL") (dict-set! rr :until (ev-ical-parse-dt v)))
((= k "BYDAY") (dict-set! rr :byday (map ev-ical-parse-byday-token (split v ","))))
((= k "BYMONTHDAY") (dict-set! rr :bymonthday (map string->number (split v ","))))
(else nil)))))
(split val ";"))
rr))))
;; Parse a VEVENT's content lines into an event dict.
(define
ev/ical-lines->event
(fn
(lines)
(let
((ev {:capacity 0 :rrule nil}) (exd (list)) (rd (list)))
(begin
(for-each
(fn
(line)
(let
((kv (split line ":")))
(when
(> (len kv) 1)
(let
((k (first kv)) (v (first (rest kv))))
(cond
((= k "UID") (dict-set! ev :id (string->symbol v)))
((= k "DTSTART") (dict-set! ev :dtstart (ev-ical-parse-dt v)))
((= k "DURATION") (dict-set! ev :duration (ev-ical-parse-duration v)))
((= k "RRULE") (dict-set! ev :rrule (ev-ical-parse-rrule v)))
((= k "EXDATE") (set! exd (map ev-ical-parse-dt (split v ","))))
((= k "RDATE") (set! rd (map ev-ical-parse-dt (split v ","))))
(else nil))))))
lines)
(dict-set! ev :exdate exd)
(dict-set! ev :rdate rd)
ev))))
;; Split a VCALENDAR line list into per-VEVENT line groups.
(define
ev-ical-group-vevents
(fn
(lines cur in acc)
(cond
((empty? lines) acc)
((= (first lines) "BEGIN:VEVENT") (ev-ical-group-vevents (rest lines) (list) true acc))
((= (first lines) "END:VEVENT") (ev-ical-group-vevents (rest lines) (list) false (append acc (list cur))))
(in (ev-ical-group-vevents (rest lines) (append cur (list (first lines))) true acc))
(else (ev-ical-group-vevents (rest lines) cur false acc)))))
;; Parse a VCALENDAR line list into a list of events.
(define
ev/parse-vcalendar
(fn
(lines)
(map ev/ical-lines->event (ev-ical-group-vevents lines (list) false (list)))))