events: VTIMEZONE iCal export — full DST-correct tz recurrence + 16 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m5s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m5s
A tz event now exports DTSTART;TZID=<name>:<local> (EXDATE/RDATE likewise; UNTIL stays UTC per RFC), and the VCALENDAR emits a VTIMEZONE per distinct zone with DAYLIGHT/STANDARD sub-components generated from the zone's transition rules (offsets + FREQ=YEARLY;BYMONTH;BYDAY) — London/Paris blocks match real-world definitions. Clients recur at fixed wall-clock time, DST-correct (prior caveat gone). Importer tolerates ;TZID= params. 376/376 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,9 +41,9 @@
|
||||
""
|
||||
(reduce (fn (acc p) (str acc sep p)) (first parts) (rest parts)))))
|
||||
|
||||
;; A UTC epoch-minute as an iCal basic-format UTC stamp.
|
||||
;; An epoch-minute as an iCal basic-format stamp (no zone suffix).
|
||||
(define
|
||||
ev-ical-dt
|
||||
ev-ical-dt-stamp
|
||||
(fn
|
||||
(t)
|
||||
(let
|
||||
@@ -55,7 +55,25 @@
|
||||
"T"
|
||||
(ev-ical-pad2 (quotient tod 60))
|
||||
(ev-ical-pad2 (modulo tod 60))
|
||||
"00Z"))))
|
||||
"00"))))
|
||||
|
||||
;; A UTC epoch-minute as a UTC stamp (trailing Z).
|
||||
(define ev-ical-dt (fn (t) (str (ev-ical-dt-stamp t) "Z")))
|
||||
|
||||
;; A local epoch-minute as a floating/local stamp (no Z) — used with TZID.
|
||||
(define ev-ical-dt-local ev-ical-dt-stamp)
|
||||
|
||||
;; A UTC offset in minutes as "+HHMM" / "-HHMM".
|
||||
(define
|
||||
ev-ical-offset
|
||||
(fn
|
||||
(mins)
|
||||
(let
|
||||
((a (abs mins)))
|
||||
(str
|
||||
(if (< mins 0) "-" "+")
|
||||
(ev-ical-pad2 (quotient a 60))
|
||||
(ev-ical-pad2 (modulo a 60))))))
|
||||
|
||||
;; A duration in minutes as an iCal DURATION value (PT#H#M).
|
||||
(define
|
||||
@@ -94,14 +112,8 @@
|
||||
(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).
|
||||
;; UNTIL converter: per RFC 5545, even a TZID DTSTART requires UNTIL in UTC, so
|
||||
;; a tz event converts its (local) UNTIL to UTC; a non-tz event passes through.
|
||||
(define
|
||||
ev-ical-conv
|
||||
(fn
|
||||
@@ -110,6 +122,74 @@
|
||||
((tz (get event :tz)))
|
||||
(if (nil? tz) (fn (t) t) (fn (t) (ev-tz-local->utc tz t))))))
|
||||
|
||||
;; ---- VTIMEZONE ----
|
||||
;; A tz event exports DTSTART;TZID=<name>:<local time> and the VCALENDAR carries
|
||||
;; a VTIMEZONE block defining the zone's DST rules, so a client recurs at a
|
||||
;; fixed WALL-CLOCK time (DST-correct) rather than fixed UTC.
|
||||
|
||||
;; A DST transition rule -> "FREQ=YEARLY;BYMONTH=<m>;BYDAY=<ord><WD>".
|
||||
(define
|
||||
ev-ical-vtz-rrule
|
||||
(fn
|
||||
(rule)
|
||||
(str
|
||||
"FREQ=YEARLY;BYMONTH="
|
||||
(get rule :month)
|
||||
";BYDAY="
|
||||
(get rule :ord)
|
||||
(ev-ical-wd (get rule :wd)))))
|
||||
|
||||
;; The transition's DTSTART (local time of the FROM offset) in a reference year.
|
||||
(define
|
||||
ev-ical-vtz-dtstart
|
||||
(fn
|
||||
(rule from-offset)
|
||||
(let
|
||||
((day (ev-resolve-nth-weekday 1970 (get rule :month) (get rule :ord) (get rule :wd))))
|
||||
(ev-ical-dt-local
|
||||
(+ (* (ev-days-from-civil 1970 (get rule :month) day) 1440)
|
||||
(get rule :time)
|
||||
from-offset)))))
|
||||
|
||||
;; The VTIMEZONE content lines for a zone (DAYLIGHT + STANDARD for :dst; a
|
||||
;; single STANDARD for :fixed).
|
||||
(define
|
||||
ev-ical-vtimezone
|
||||
(fn
|
||||
(tz)
|
||||
(if
|
||||
(= (get tz :kind) :dst)
|
||||
(let
|
||||
((std (get tz :std-offset))
|
||||
(dst (get tz :dst-offset))
|
||||
(sr (get tz :dst-start))
|
||||
(er (get tz :dst-end)))
|
||||
(list
|
||||
"BEGIN:VTIMEZONE"
|
||||
(str "TZID:" (get tz :name))
|
||||
"BEGIN:DAYLIGHT"
|
||||
(str "DTSTART:" (ev-ical-vtz-dtstart sr std))
|
||||
(str "TZOFFSETFROM:" (ev-ical-offset std))
|
||||
(str "TZOFFSETTO:" (ev-ical-offset dst))
|
||||
(str "RRULE:" (ev-ical-vtz-rrule sr))
|
||||
"END:DAYLIGHT"
|
||||
"BEGIN:STANDARD"
|
||||
(str "DTSTART:" (ev-ical-vtz-dtstart er dst))
|
||||
(str "TZOFFSETFROM:" (ev-ical-offset dst))
|
||||
(str "TZOFFSETTO:" (ev-ical-offset std))
|
||||
(str "RRULE:" (ev-ical-vtz-rrule er))
|
||||
"END:STANDARD"
|
||||
"END:VTIMEZONE"))
|
||||
(list
|
||||
"BEGIN:VTIMEZONE"
|
||||
(str "TZID:" (get tz :name))
|
||||
"BEGIN:STANDARD"
|
||||
"DTSTART:19700101T000000"
|
||||
(str "TZOFFSETFROM:" (ev-ical-offset (get tz :offset)))
|
||||
(str "TZOFFSETTO:" (ev-ical-offset (get tz :offset)))
|
||||
"END:STANDARD"
|
||||
"END:VTIMEZONE"))))
|
||||
|
||||
;; ---- RRULE ----
|
||||
(define
|
||||
ev-ical-rrule
|
||||
@@ -149,45 +229,84 @@
|
||||
|
||||
;; ---- VEVENT / VCALENDAR ----
|
||||
|
||||
;; The VEVENT content lines for an event (list of strings).
|
||||
;; The VEVENT content lines for an event (list of strings). A tz event uses
|
||||
;; DTSTART;TZID=<name>:<local> (matched by a VTIMEZONE at the VCALENDAR level)
|
||||
;; with EXDATE/RDATE in the same TZID-local form; UNTIL is always UTC. A non-tz
|
||||
;; event uses UTC `Z` stamps throughout.
|
||||
(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))
|
||||
((lines (list "BEGIN:VEVENT"))
|
||||
(conv (ev-ical-conv event))
|
||||
(tz (get event :tz)))
|
||||
(let
|
||||
((dtparam (if (nil? tz) "" (str ";TZID=" (get tz :name))))
|
||||
(fmt (if (nil? tz) ev-ical-dt ev-ical-dt-local)))
|
||||
(begin
|
||||
(append! lines (str "UID:" (get event :id)))
|
||||
(append! lines (str "SUMMARY:" (get event :id)))
|
||||
(append! lines (str "DTSTART" dtparam ":" (fmt (get event :dtstart))))
|
||||
(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))))
|
||||
(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"
|
||||
dtparam
|
||||
":"
|
||||
(ev-ical-join (map fmt (get event :exdate)) ","))))
|
||||
(when
|
||||
(and
|
||||
(not (nil? (get event :rdate)))
|
||||
(> (len (get event :rdate)) 0))
|
||||
(append!
|
||||
lines
|
||||
(str
|
||||
"RDATE"
|
||||
dtparam
|
||||
":"
|
||||
(ev-ical-join (map fmt (get event :rdate)) ","))))
|
||||
(append! lines "END:VEVENT")
|
||||
lines)))))
|
||||
|
||||
;; A full VCALENDAR (list of content lines) wrapping every event.
|
||||
;; Collect the distinct timezones used by a list of events (by :name).
|
||||
(define
|
||||
ev-ical-distinct-tzs
|
||||
(fn
|
||||
(events)
|
||||
(reduce
|
||||
(fn
|
||||
(acc ev)
|
||||
(let
|
||||
((tz (get ev :tz)))
|
||||
(if
|
||||
(or (nil? tz) (ev-ical-tz-seen? acc (get tz :name)))
|
||||
acc
|
||||
(append acc (list tz)))))
|
||||
(list)
|
||||
events)))
|
||||
|
||||
(define
|
||||
ev-ical-tz-seen?
|
||||
(fn
|
||||
(tzs name)
|
||||
(cond
|
||||
((empty? tzs) false)
|
||||
((= (get (first tzs) :name) name) true)
|
||||
(else (ev-ical-tz-seen? (rest tzs) name)))))
|
||||
|
||||
;; A full VCALENDAR (list of content lines): a VTIMEZONE block for each distinct
|
||||
;; zone the events reference, then every VEVENT.
|
||||
(define
|
||||
ev/events->ical-lines
|
||||
(fn
|
||||
@@ -195,6 +314,11 @@
|
||||
(let
|
||||
((lines (list "BEGIN:VCALENDAR" "VERSION:2.0" "PRODID:-//rose-ash//events-on-sx//EN")))
|
||||
(begin
|
||||
(for-each
|
||||
(fn
|
||||
(tz)
|
||||
(for-each (fn (l) (append! lines l)) (ev-ical-vtimezone tz)))
|
||||
(ev-ical-distinct-tzs events))
|
||||
(for-each
|
||||
(fn
|
||||
(ev)
|
||||
@@ -323,7 +447,8 @@
|
||||
(when
|
||||
(> (len kv) 1)
|
||||
(let
|
||||
((k (first kv)) (v (first (rest kv))))
|
||||
;; strip any property parameters (e.g. ";TZID=...") from the key
|
||||
((k (first (split (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)))
|
||||
|
||||
Reference in New Issue
Block a user