Commitfa246e56Recorded2 Apr 2026Repositorysigil-caldav
Fix cross-timezone event parsing using TZID parameter
Message
parse-ical-datetime now accepts an optional timezone argument. When a TZID is present on DTSTART/DTEND, parse-dt-value extracts it and passes it through, so events in non-system timezones are parsed correctly.
Adds tests for cross-timezone parsing including DST transitions.
Changed
src/sigil/caldav/ical.sgl | 29 ++++++++++++++++++++---------
test/test-caldav.sgl | 35 ++++++++++++++++++++++++++++++++++-
2 files changed, 54 insertions(+), 10 deletions(-)Diff
src/sigil/caldav/ical.sglmodified
@@ -137,25 +137,33 @@
137
;;; Parse an iCalendar datetime string to a Unix timestamp. 138
;;; 139
;;; UTC datetimes (Z suffix) are interpreted as UTC.−140
;;; Local datetimes (no Z) are interpreted in the system timezone.+140
;;; Local datetimes (no Z) are interpreted in the given timezone,+141
;;; or the system timezone if no timezone is provided. 142
;;; 143
;;; ```scheme 144
;;; (parse-ical-datetime "20240315T090000Z") ; => UTC 09:00 145
;;; (parse-ical-datetime "20240315T090000") ; => local 09:00+146
;;; (parse-ical-datetime "20240315T090000" "America/New_York") ; => Eastern 09:00 147
;;; ```−146
(define (parse-ical-datetime str)+148
(define (parse-ical-datetime str . rest) 149
(: string? -> number?)−148
(let* ((utc? (string-ends-with? str "Z"))+150
(let* ((tz (if (null? rest) #f (car rest)))+151
(utc? (string-ends-with? str "Z")) 152
(y (string->number (substring str 0 4))) 153
(mo (string->number (substring str 4 6))) 154
(d (string->number (substring str 6 8))) 155
(h (string->number (substring str 9 11))) 156
(mi (string->number (substring str 11 13))) 157
(s (string->number (substring str 13 15))))−155
(if utc?−156
(utc->timestamp y mo d h mi s)−157
;; Local time: delegate to mktime for correct DST handling−158
(list->time (list s mi h d mo y)))))+158
(cond+159
(utc?+160
(utc->timestamp y mo d h mi s))+161
(tz+162
;; Use the event's timezone for correct cross-timezone parsing+163
(list->time-in-tz (list s mi h d mo y) tz))+164
(else+165
;; Local time: delegate to mktime for correct DST handling+166
(list->time (list s mi h d mo y)))))) 167
168
;;; Parse an iCalendar date string to a Unix timestamp. 169
;;;@@ -294,11 +302,14 @@
302
((and (char=? c #\:) (not in-quotes?)) i) 303
(else (loop (+ i 1) in-quotes?)))))))) 304
−297
;; Parse a DTSTART or DTEND property, checking params for VALUE=DATE+305
;; Parse a DTSTART or DTEND property, checking params for VALUE=DATE.+306
;; RFC 5545 requires local-form datetimes to be interpreted in the+307
;; accompanying TZID, not the system timezone. 308
(define (parse-dt-value params value) 309
(if (string-contains? (string-upcase params) "VALUE=DATE") 310
(cons (parse-ical-date value) #t)−301
(cons (parse-ical-datetime value) #f)))+311
(let ((tzid (extract-tzid params)))+312
(cons (parse-ical-datetime value tzid) #f)))) 313
314
;; Extract TZID value from parameter string, e.g. "TZID=Europe/Athens" 315
(define (extract-tzid params)test/test-caldav.sglmodified
@@ -62,7 +62,40 @@
62
(assert-equal 1710493200 (parse-ical-datetime "20240315T090000Z"))) 63
64
(test "epoch is correct"−65
(assert-equal 0 (parse-ical-datetime "19700101T000000Z"))))+65
(assert-equal 0 (parse-ical-datetime "19700101T000000Z")))+66
+67
(test "parses datetime with explicit timezone"+68
;; 11:00 America/New_York on 2026-03-26 is EDT (UTC-4) = 15:00 UTC+69
(let ((ts (parse-ical-datetime "20260326T110000" "America/New_York")))+70
(assert-equal "20260326T150000Z" (format-ical-datetime ts))))+71
+72
(test "parses datetime with different timezone than system"+73
;; 09:00 America/Los_Angeles on 2026-03-26 is PDT (UTC-7) = 16:00 UTC+74
(let ((ts (parse-ical-datetime "20260326T090000" "America/Los_Angeles")))+75
(assert-equal "20260326T160000Z" (format-ical-datetime ts))))+76
+77
(test "timezone param ignored for UTC datetimes"+78
;; Z suffix means UTC regardless of any timezone hint+79
(let ((ts (parse-ical-datetime "20260326T150000Z" "America/New_York")))+80
(assert-equal "20260326T150000Z" (format-ical-datetime ts))))+81
+82
(test "timezone handles DST correctly"+83
;; America/New_York: EST (UTC-5) in January, EDT (UTC-4) in July+84
(let ((winter (parse-ical-datetime "20260115T120000" "America/New_York"))+85
(summer (parse-ical-datetime "20260715T120000" "America/New_York")))+86
;; Winter: 12:00 EST = 17:00 UTC+87
(assert-equal "20260115T170000Z" (format-ical-datetime winter))+88
;; Summer: 12:00 EDT = 16:00 UTC+89
(assert-equal "20260715T160000Z" (format-ical-datetime summer))))+90
+91
(test "cross-timezone event parsed correctly via ical-parse"+92
(let* ((ical-text "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:[email protected]\r\nDTSTART;TZID=America/New_York:20260326T110000\r\nDTEND;TZID=America/New_York:20260326T120000\r\nSUMMARY:Cross-TZ Meeting\r\nEND:VEVENT\r\nEND:VCALENDAR")+93
(events (ical-parse ical-text))+94
(ev (car events)))+95
;; 11:00 EDT = 15:00 UTC, 12:00 EDT = 16:00 UTC+96
(assert-equal "20260326T150000Z" (format-ical-datetime (ical-event-dtstart ev)))+97
(assert-equal "20260326T160000Z" (format-ical-datetime (ical-event-dtend ev)))+98
(assert-equal "America/New_York" (ical-event-timezone ev))))) 99
100
101
;; ============================================================