Commit710ce708Recorded23 Mar 2026Repositorysigil-caldav
Add timezone and calendar-name support to ical-event
Message
Add timezone field to ical-event struct for TZID-aware event creation. When set, ical-generate emits DTSTART;TZID=<tz>:<local-time> instead of UTC Z format, preventing recurring events from shifting after DST changes. Also add calendar-name field for tracking which calendar an event belongs to.
- Add format-ical-local-datetime for timezone-aware datetime output
- Extract TZID from parsed iCal DTSTART params
- Add timezone: keyword to caldav-event-create!
- Preserve both fields through clone-event (recurrence expansion)
- Fix local datetime test that only passed in UTC timezone
Changed
src/sigil/caldav.sgl | 3 +++
src/sigil/caldav/event.sgl | 8 +++++---
src/sigil/caldav/ical.sgl | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
test/test-caldav.sgl | 38 +++++++++++++++++++++++++++++++++++---
4 files changed, 118 insertions(+), 21 deletions(-)Diff
src/sigil/caldav.sglmodified
@@ -53,6 +53,8 @@
53
ical-event-created ical-event-last-modified 54
ical-event-all-day? ical-event-url ical-event-rrule 55
ical-event-recurrence-id+56
ical-event-calendar-name set-ical-event-calendar-name!+57
ical-event-timezone set-ical-event-timezone! 58
set-ical-event-summary! set-ical-event-description! 59
set-ical-event-dtstart! set-ical-event-dtend! 60
set-ical-event-location! set-ical-event-status!@@ -62,6 +64,7 @@
64
ical-parse ical-generate 65
generate-event-uid 66
parse-ical-datetime format-ical-datetime+67
format-ical-local-datetime 68
parse-ical-date format-ical-date 69
ical-expand-recurrence 70
src/sigil/caldav/event.sglmodified
@@ -180,13 +180,14 @@
180
(all-day? #f) 181
(rrule #f) 182
(organizer #f)−183
(attendees '())))+183
(attendees '())+184
(timezone #f))) 185
(: caldav-client? (calendar-href: string?) (summary: string?) 186
(dtstart: number?) (dtend: (maybe number?)) 187
(description: (maybe string?)) (location: (maybe string?)) 188
(status: (maybe string?)) (all-day?: boolean?) 189
(rrule: (maybe string?)) (organizer: (maybe string?))−189
(attendees: list?) -> ical-event?)+190
(attendees: list?) (timezone: (maybe string?)) -> ical-event?) 191
(unless calendar-href 192
(error "caldav-event-create!: calendar-href: is required")) 193
(unless dtstart@@ -204,7 +205,8 @@
205
all-day?: all-day? 206
rrule: rrule 207
organizer: organizer−207
attendees: attendees))+208
attendees: attendees+209
timezone: timezone)) 210
(ical-text (ical-generate event)) 211
(event-url (string-append calendar-href uid ".ics")) 212
(response (caldav-put client event-url ical-textsrc/sigil/caldav/ical.sglmodified
@@ -21,6 +21,8 @@
21
ical-event-created ical-event-last-modified 22
ical-event-all-day? ical-event-url ical-event-rrule 23
ical-event-recurrence-id+24
ical-event-calendar-name set-ical-event-calendar-name!+25
ical-event-timezone set-ical-event-timezone! 26
set-ical-event-summary! set-ical-event-description! 27
set-ical-event-dtstart! set-ical-event-dtend! 28
set-ical-event-location! set-ical-event-status!@@ -39,6 +41,7 @@
41
;; Date/time conversion 42
parse-ical-datetime 43
format-ical-datetime+44
format-ical-local-datetime 45
parse-ical-date 46
format-ical-date 47
@@ -66,7 +69,9 @@
69
(all-day? default: #f mutable: #t) 70
(url default: #f mutable: #t) 71
(rrule default: #f mutable: #t)−69
(recurrence-id default: #f mutable: #t))+72
(recurrence-id default: #f mutable: #t)+73
(calendar-name default: #f mutable: #t)+74
(timezone default: #f mutable: #t)) 75
76
77
;; ============================================================@@ -184,6 +189,32 @@
189
(pad-digits (list-ref parts 5) 2) 190
"Z"))) 191
+192
;;; Format a Unix timestamp as an iCalendar local datetime string.+193
;;;+194
;;; Uses the system timezone to produce local date/time components.+195
;;; No Z suffix — intended for use with TZID parameters.+196
;;;+197
;;; ```scheme+198
;;; (format-ical-local-datetime 1710493200) ; => "20240315T110000" (in EET)+199
;;; ```+200
(define (format-ical-local-datetime ts)+201
(: number? -> string?)+202
(let* ((parts (time->list ts))+203
;; time->list returns (second minute hour day month year ...)+204
(s (list-ref parts 0))+205
(mi (list-ref parts 1))+206
(h (list-ref parts 2))+207
(d (list-ref parts 3))+208
(mo (list-ref parts 4))+209
(y (list-ref parts 5)))+210
(string-append (pad-digits y 4)+211
(pad-digits mo 2)+212
(pad-digits d 2)+213
"T"+214
(pad-digits h 2)+215
(pad-digits mi 2)+216
(pad-digits s 2))))+217
218
;;; Format a Unix timestamp as an iCalendar date string. 219
;;; 220
;;; ```scheme@@ -270,6 +301,19 @@
301
(cons (parse-ical-date value) #t) 302
(cons (parse-ical-datetime value) #f))) 303
+304
;; Extract TZID value from parameter string, e.g. "TZID=Europe/Athens"+305
(define (extract-tzid params)+306
(if (and (string? params)+307
(string-contains? (string-upcase params) "TZID="))+308
(let* ((upper (string-upcase params))+309
(pos (string-find upper "TZID="))+310
(rest (substring params (+ pos 5) (string-length params)))+311
(semi (string-find rest ";")))+312
(if semi+313
(substring rest 0 semi)+314
rest))+315
#f))+316
317
;; Extract a mailto: address, or return the raw value 318
(define (parse-cal-address value) 319
(if (string-starts-with? (string-downcase value) "mailto:")@@ -379,7 +423,10 @@
423
(let ((dt (parse-dt-value params value))) 424
(set-ical-event-dtstart! ev (car dt)) 425
(when (cdr dt)−382
(set-ical-event-all-day?! ev #t))))+426
(set-ical-event-all-day?! ev #t))+427
(let ((tzid (extract-tzid params)))+428
(when tzid+429
(set-ical-event-timezone! ev tzid))))) 430
((equal? name "DTEND") 431
(let ((dt (parse-dt-value params value))) 432
(set-ical-event-dtend! ev (car dt))))@@ -444,18 +491,29 @@
491
(add! (emit-line "UID" (ical-event-uid event))) 492
493
;; Date/time−447
(when (ical-event-dtstart event)−448
(if (ical-event-all-day? event)−449
(add! (emit-line "DTSTART;VALUE=DATE"−450
(format-ical-date (ical-event-dtstart event))))−451
(add! (emit-line "DTSTART"−452
(format-ical-datetime (ical-event-dtstart event))))))−453
(when (ical-event-dtend event)−454
(if (ical-event-all-day? event)−455
(add! (emit-line "DTEND;VALUE=DATE"−456
(format-ical-date (ical-event-dtend event))))−457
(add! (emit-line "DTEND"−458
(format-ical-datetime (ical-event-dtend event))))))+494
(let ((tz (ical-event-timezone event)))+495
(when (ical-event-dtstart event)+496
(cond+497
((ical-event-all-day? event)+498
(add! (emit-line "DTSTART;VALUE=DATE"+499
(format-ical-date (ical-event-dtstart event)))))+500
(tz+501
(add! (emit-line (string-append "DTSTART;TZID=" tz)+502
(format-ical-local-datetime (ical-event-dtstart event)))))+503
(else+504
(add! (emit-line "DTSTART"+505
(format-ical-datetime (ical-event-dtstart event)))))))+506
(when (ical-event-dtend event)+507
(cond+508
((ical-event-all-day? event)+509
(add! (emit-line "DTEND;VALUE=DATE"+510
(format-ical-date (ical-event-dtend event)))))+511
(tz+512
(add! (emit-line (string-append "DTEND;TZID=" tz)+513
(format-ical-local-datetime (ical-event-dtend event)))))+514
(else+515
(add! (emit-line "DTEND"+516
(format-ical-datetime (ical-event-dtend event)))))))) 517
518
;; Text properties 519
(when (not (string-empty? (ical-event-summary event)))@@ -668,7 +726,9 @@
726
all-day?: (ical-event-all-day? event) 727
url: (ical-event-url event) 728
rrule: (ical-event-rrule event)−671
recurrence-id: new-start)))+729
recurrence-id: new-start+730
calendar-name: (ical-event-calendar-name event)+731
timezone: (ical-event-timezone event)))) 732
ev)) 733
734
;; Check if a timestamp is within the query rangetest/test-caldav.sglmodified
@@ -2,6 +2,7 @@
2
3
(import (sigil test) 4
(sigil string)+5
(sigil time) 6
(sigil caldav ical) 7
(sigil caldav client) 8
(sigil sxml reader))@@ -17,9 +18,11 @@
18
(let ((ts (parse-ical-datetime "20240315T090000Z"))) 19
(assert-equal "20240315T090000Z" (format-ical-datetime ts)))) 20
−20
(test "parses local datetime (treated as UTC)"−21
(let ((ts (parse-ical-datetime "20240315T090000")))−22
(assert-equal "20240315T090000Z" (format-ical-datetime ts))))+21
(test "parses local datetime in system timezone"+22
(let* ((ts-local (parse-ical-datetime "20240315T090000"))+23
(ts-utc (parse-ical-datetime "20240315T090000Z")))+24
;; Local time differs from UTC by the timezone offset+25
(assert-equal (- ts-utc (time-utc-offset)) ts-local))) 26
27
(test "round-trips midnight" 28
(let ((ts (parse-ical-datetime "20240101T000000Z")))@@ -189,6 +192,35 @@
192
(att2 (car (ical-event-attendees ev2)))) 193
(assert-equal "[email protected]" (ical-attendee-email att2))))) 194
+195
(test "generates event with TZID when timezone is set"+196
(let* ((ev (ical-event uid: "tz-test-1"+197
summary: "Local Meeting"+198
dtstart: (parse-ical-datetime "20260323T150000Z")+199
dtend: (parse-ical-datetime "20260323T160000Z")+200
timezone: "Europe/Athens"))+201
(text (ical-generate ev)))+202
(assert-true (string-contains? text "DTSTART;TZID=Europe/Athens:"))+203
(assert-true (string-contains? text "DTEND;TZID=Europe/Athens:"))+204
;; Should NOT have bare DTSTART: or Z suffix for times+205
(assert-false (string-contains? text "DTSTART:20260323"))+206
(assert-false (string-contains? text "DTEND:20260323"))))+207
+208
(test "generates UTC format when no timezone set"+209
(let* ((ev (ical-event uid: "no-tz-1"+210
summary: "UTC Event"+211
dtstart: (parse-ical-datetime "20260323T150000Z")+212
dtend: (parse-ical-datetime "20260323T160000Z")))+213
(text (ical-generate ev)))+214
(assert-true (string-contains? text "DTSTART:20260323T150000Z"))+215
(assert-true (string-contains? text "DTEND:20260323T160000Z"))))+216
+217
(test "parses TZID from DTSTART params"+218
(let* ((ical-text "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:[email protected]\r\nDTSTART;TZID=Europe/Athens:20260323T170000\r\nDTEND;TZID=Europe/Athens:20260323T180000\r\nSUMMARY:Athens Meeting\r\nEND:VEVENT\r\nEND:VCALENDAR")+219
(events (ical-parse ical-text))+220
(ev (car events)))+221
(assert-equal "Europe/Athens" (ical-event-timezone ev))+222
(assert-equal "Athens Meeting" (ical-event-summary ev))))+223
224
(test "round-trip: parse then generate preserves key fields" 225
(let* ((events (ical-parse simple-ical)) 226
(ev (car events))