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
;; ============================================================