Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m12s
ical.sx serializes events to VEVENT/VCALENDAR text for import by standard clients: UTC basic-format stamps, DURATION (PT#H#M), full RRULE (FREQ/INTERVAL/COUNT/UNTIL/BYDAY incl. monthly ordinals 2TU/-1FR/BYMONTHDAY) plus EXDATE/RDATE. Line-oriented (ev/event->ical-lines / ev/events->ical-lines) with ev/ical-render joining CRLF for the wire format. 332/332 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
192 lines
5.1 KiB
Plaintext
192 lines
5.1 KiB
Plaintext
;; lib/events/ical.sx — iCalendar (RFC 5545) export.
|
|
;;
|
|
;; Serializes events to VEVENT / VCALENDAR text so a rose-ash calendar can be
|
|
;; imported by any standard client (Google/Apple/Outlook). Datetimes are UTC
|
|
;; epoch-minutes, emitted as basic-format UTC stamps (YYYYMMDDTHHMM00Z). The
|
|
;; full RRULE / EXDATE / RDATE model maps directly to the standard properties.
|
|
;;
|
|
;; Export is line-oriented: `ev/event->ical-lines` returns the VEVENT as a list
|
|
;; of content lines (no folding/CRLF — easy to assert on); `ev/ical-render`
|
|
;; joins lines with CRLF, the on-the-wire format. Requires calendar.sx.
|
|
|
|
;; ---- formatting helpers ----
|
|
|
|
(define ev-ical-pad2 (fn (n) (if (< n 10) (str "0" n) (str n))))
|
|
|
|
(define
|
|
ev-ical-pad4
|
|
(fn
|
|
(n)
|
|
(cond
|
|
((< n 10) (str "000" n))
|
|
((< n 100) (str "00" n))
|
|
((< n 1000) (str "0" n))
|
|
(else (str n)))))
|
|
|
|
(define
|
|
ev-ical-nth
|
|
(fn
|
|
(xs i)
|
|
(if
|
|
(= i 0)
|
|
(first xs)
|
|
(ev-ical-nth (rest xs) (- i 1)))))
|
|
|
|
(define
|
|
ev-ical-join
|
|
(fn
|
|
(parts sep)
|
|
(if
|
|
(empty? parts)
|
|
""
|
|
(reduce (fn (acc p) (str acc sep p)) (first parts) (rest parts)))))
|
|
|
|
;; A UTC epoch-minute as an iCal basic-format UTC stamp.
|
|
(define
|
|
ev-ical-dt
|
|
(fn
|
|
(t)
|
|
(let
|
|
((civ (ev-dt->civil t)) (tod (ev-dt-tod t)))
|
|
(str
|
|
(ev-ical-pad4 (ev-civ-y civ))
|
|
(ev-ical-pad2 (ev-civ-m civ))
|
|
(ev-ical-pad2 (ev-civ-d civ))
|
|
"T"
|
|
(ev-ical-pad2 (quotient tod 60))
|
|
(ev-ical-pad2 (modulo tod 60))
|
|
"00Z"))))
|
|
|
|
;; A duration in minutes as an iCal DURATION value (PT#H#M).
|
|
(define
|
|
ev-ical-duration
|
|
(fn
|
|
(mins)
|
|
(let
|
|
((h (quotient mins 60)) (m (modulo mins 60)))
|
|
(cond
|
|
((and (> h 0) (> m 0)) (str "PT" h "H" m "M"))
|
|
((> h 0) (str "PT" h "H"))
|
|
(else (str "PT" m "M"))))))
|
|
|
|
(define
|
|
ev-ical-wd
|
|
(fn (w) (ev-ical-nth (list "MO" "TU" "WE" "TH" "FR" "SA" "SU") w)))
|
|
|
|
(define
|
|
ev-ical-freq
|
|
(fn
|
|
(f)
|
|
(cond
|
|
((= f :daily) "DAILY")
|
|
((= f :weekly) "WEEKLY")
|
|
((= f :monthly) "MONTHLY")
|
|
(else "DAILY"))))
|
|
|
|
;; One BYDAY token: a weekly weekday number -> "MO"; a monthly ordinal weekday
|
|
;; {:ord :wd} -> "2TU" / "-1FR".
|
|
(define
|
|
ev-ical-byday-token
|
|
(fn
|
|
(e)
|
|
(if
|
|
(dict? e)
|
|
(str (get e :ord) (ev-ical-wd (get e :wd)))
|
|
(ev-ical-wd e))))
|
|
|
|
;; ---- RRULE ----
|
|
(define
|
|
ev-ical-rrule
|
|
(fn
|
|
(rrule)
|
|
(let
|
|
((parts (list (str "FREQ=" (ev-ical-freq (get rrule :freq))))))
|
|
(begin
|
|
(when
|
|
(and
|
|
(not (nil? (get rrule :interval)))
|
|
(> (get rrule :interval) 1))
|
|
(append! parts (str "INTERVAL=" (get rrule :interval))))
|
|
(when
|
|
(not (nil? (get rrule :count)))
|
|
(append! parts (str "COUNT=" (get rrule :count))))
|
|
(when
|
|
(not (nil? (get rrule :until)))
|
|
(append! parts (str "UNTIL=" (ev-ical-dt (get rrule :until)))))
|
|
(when
|
|
(not (nil? (get rrule :byday)))
|
|
(append!
|
|
parts
|
|
(str
|
|
"BYDAY="
|
|
(ev-ical-join (map ev-ical-byday-token (get rrule :byday)) ","))))
|
|
(when
|
|
(not (nil? (get rrule :bymonthday)))
|
|
(append!
|
|
parts
|
|
(str
|
|
"BYMONTHDAY="
|
|
(ev-ical-join
|
|
(map (fn (d) (str d)) (get rrule :bymonthday))
|
|
","))))
|
|
(str "RRULE:" (ev-ical-join parts ";"))))))
|
|
|
|
;; ---- VEVENT / VCALENDAR ----
|
|
|
|
;; The VEVENT content lines for an event (list of strings).
|
|
(define
|
|
ev/event->ical-lines
|
|
(fn
|
|
(event)
|
|
(let
|
|
((lines (list "BEGIN:VEVENT")))
|
|
(begin
|
|
(append! lines (str "UID:" (get event :id)))
|
|
(append! lines (str "SUMMARY:" (get event :id)))
|
|
(append! lines (str "DTSTART:" (ev-ical-dt (get event :dtstart))))
|
|
(append!
|
|
lines
|
|
(str "DURATION:" (ev-ical-duration (get event :duration))))
|
|
(when
|
|
(not (nil? (get event :rrule)))
|
|
(append! lines (ev-ical-rrule (get event :rrule))))
|
|
(when
|
|
(and
|
|
(not (nil? (get event :exdate)))
|
|
(> (len (get event :exdate)) 0))
|
|
(append!
|
|
lines
|
|
(str
|
|
"EXDATE:"
|
|
(ev-ical-join (map ev-ical-dt (get event :exdate)) ","))))
|
|
(when
|
|
(and
|
|
(not (nil? (get event :rdate)))
|
|
(> (len (get event :rdate)) 0))
|
|
(append!
|
|
lines
|
|
(str
|
|
"RDATE:"
|
|
(ev-ical-join (map ev-ical-dt (get event :rdate)) ","))))
|
|
(append! lines "END:VEVENT")
|
|
lines))))
|
|
|
|
;; A full VCALENDAR (list of content lines) wrapping every event.
|
|
(define
|
|
ev/events->ical-lines
|
|
(fn
|
|
(events)
|
|
(let
|
|
((lines (list "BEGIN:VCALENDAR" "VERSION:2.0" "PRODID:-//rose-ash//events-on-sx//EN")))
|
|
(begin
|
|
(for-each
|
|
(fn
|
|
(ev)
|
|
(for-each (fn (l) (append! lines l)) (ev/event->ical-lines ev)))
|
|
events)
|
|
(append! lines "END:VCALENDAR")
|
|
lines))))
|
|
|
|
;; Render content lines to the on-the-wire iCalendar text (CRLF-separated).
|
|
(define ev/ical-render (fn (lines) (ev-ical-join lines "\r\n")))
|