Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 41s
Bug: tz events store wall-clock LOCAL times but export stamped them with a Z (UTC) suffix, so a London 18:00 event falsely read as 18:00 UTC. ev-ical-conv now converts a tz event's DTSTART/UNTIL/EXDATE/RDATE local->UTC before formatting (London summer 18:00 -> 170000Z; Paris -> 160000Z); non-tz events unchanged. Caveat: UTC RRULE drifts from wall-clock-stable tz recurrence across a DST boundary (VTIMEZONE deferred). 366/366 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
358 lines
10 KiB
Plaintext
358 lines
10 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))))
|
|
|
|
;; A datetime converter for an event: tz-aware events store wall-clock LOCAL
|
|
;; times, so export converts them to UTC (the `Z` stamps are absolute);
|
|
;; non-tz events pass through unchanged.
|
|
;; CAVEAT: a UTC RRULE recurs at a fixed UTC offset, whereas a tz event's
|
|
;; expansion stays wall-clock-stable across DST — so for a tz recurrence that
|
|
;; crosses a DST boundary the exported series drifts by the offset change
|
|
;; after the boundary. DTSTART and each individual stamp are correct; full
|
|
;; fidelity would need a VTIMEZONE block (deferred).
|
|
(define
|
|
ev-ical-conv
|
|
(fn
|
|
(event)
|
|
(let
|
|
((tz (get event :tz)))
|
|
(if (nil? tz) (fn (t) t) (fn (t) (ev-tz-local->utc tz t))))))
|
|
|
|
;; ---- RRULE ----
|
|
(define
|
|
ev-ical-rrule
|
|
(fn
|
|
(rrule conv)
|
|
(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 (conv (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")) (conv (ev-ical-conv event)))
|
|
(begin
|
|
(append! lines (str "UID:" (get event :id)))
|
|
(append! lines (str "SUMMARY:" (get event :id)))
|
|
(append! lines (str "DTSTART:" (ev-ical-dt (conv (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) conv)))
|
|
(when
|
|
(and
|
|
(not (nil? (get event :exdate)))
|
|
(> (len (get event :exdate)) 0))
|
|
(append!
|
|
lines
|
|
(str
|
|
"EXDATE:"
|
|
(ev-ical-join (map (fn (d) (ev-ical-dt (conv d))) (get event :exdate)) ","))))
|
|
(when
|
|
(and
|
|
(not (nil? (get event :rdate)))
|
|
(> (len (get event :rdate)) 0))
|
|
(append!
|
|
lines
|
|
(str
|
|
"RDATE:"
|
|
(ev-ical-join (map (fn (d) (ev-ical-dt (conv d))) (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")))
|
|
|
|
;; ---- 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)))))
|