AtlatestRepositorysigil-caldav
sigil-caldav / tree / src / sigil / caldavical.sgl
1
;;; (sigil caldav ical) - iCalendar Parser and Generator2
;;;3
;;; Parses and generates iCalendar (RFC 5545) data. Provides an4
;;; ical-event struct for working with calendar events, and basic5
;;; recurrence expansion for DAILY, WEEKLY, and MONTHLY rules.7
(define-library (sigil caldav ical)8
(import (sigil core)9
(sigil math)10
(sigil string)11
(sigil struct)12
(sigil crypto)13
(sigil time))15
(export16
;; Event struct17
ical-event ical-event?18
ical-event-uid ical-event-summary ical-event-description19
ical-event-dtstart ical-event-dtend ical-event-location20
ical-event-status ical-event-organizer ical-event-attendees21
ical-event-created ical-event-last-modified22
ical-event-all-day? ical-event-url ical-event-rrule23
ical-event-recurrence-id24
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!29
set-ical-event-organizer! set-ical-event-attendees!30
set-ical-event-url! set-ical-event-rrule!32
;; Attendee helpers33
ical-attendee34
ical-attendee-email36
;; Parsing and generation37
ical-parse38
ical-generate39
generate-event-uid41
;; Date/time conversion42
parse-ical-datetime43
format-ical-datetime44
format-ical-local-datetime45
parse-ical-date46
format-ical-date48
;; Recurrence49
ical-expand-recurrence)51
(begin53
;; ============================================================54
;; Event Struct55
;; ============================================================57
(define-struct ical-event58
(uid default: "unknown" mutable: #t)59
(summary default: "" mutable: #t)60
(description default: #f mutable: #t)61
(dtstart default: #f mutable: #t)62
(dtend default: #f mutable: #t)63
(location default: #f mutable: #t)64
(status default: #f mutable: #t)65
(organizer default: #f mutable: #t)66
(attendees default: '() mutable: #t)67
(created default: #f mutable: #t)68
(last-modified default: #f mutable: #t)69
(all-day? default: #f mutable: #t)70
(url default: #f mutable: #t)71
(rrule default: #f mutable: #t)72
(recurrence-id default: #f mutable: #t)73
(calendar-name default: #f mutable: #t)74
(timezone default: #f mutable: #t))77
;; ============================================================78
;; UTC Date/Time Conversion79
;; ============================================================80
;;81
;; Direct UTC <-> Unix timestamp conversion without localtime/mktime82
;; to avoid timezone issues.84
(define (leap-year? y)85
(and (zero? (modulo y 4))86
(or (not (zero? (modulo y 100)))87
(zero? (modulo y 400)))))89
(define month-days #(0 31 28 31 30 31 30 31 31 30 31 30 31))91
(define (days-in-month y m)92
(if (and (= m 2) (leap-year? y))93
2994
(vector-ref month-days m)))96
;; Days from epoch (1970-01-01) to the start of a given year97
(define (days-to-year y)98
(let ((y1 (- y 1)))99
(- (+ (* 365 (- y 1970))100
(quotient y1 4)101
(- (quotient y1 100))102
(quotient y1 400))103
;; Subtract leap day counts for years before 1970104
(+ (quotient 1969 4)105
(- (quotient 1969 100))106
(quotient 1969 400)))))108
;; Convert UTC year/month/day/hour/minute/second to Unix timestamp109
(define (utc->timestamp y mo d h mi s)110
(let loop ((m 1) (days (days-to-year y)))111
(if (>= m mo)112
(+ (* (+ days (- d 1)) 86400)113
(* h 3600)114
(* mi 60)115
s)116
(loop (+ m 1) (+ days (days-in-month y m))))))118
;; Convert Unix timestamp to UTC components (year month day hour minute second)119
(define (timestamp->utc ts)120
(let* ((total-days (quotient ts 86400))121
(day-seconds (modulo ts 86400))122
(h (quotient day-seconds 3600))123
(mi (quotient (modulo day-seconds 3600) 60))124
(s (modulo day-seconds 60)))125
;; Find year126
(let year-loop ((y 1970) (remaining total-days))127
(let ((year-days (if (leap-year? y) 366 365)))128
(if (< remaining year-days)129
;; Find month130
(let month-loop ((m 1) (r remaining))131
(let ((md (days-in-month y m)))132
(if (< r md)133
(list y m (+ r 1) h mi s)134
(month-loop (+ m 1) (- r md)))))135
(year-loop (+ y 1) (- remaining year-days)))))))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 given timezone,141
;;; or the system timezone if no timezone is provided.142
;;;143
;;; ```scheme144
;;; (parse-ical-datetime "20240315T090000Z") ; => UTC 09:00145
;;; (parse-ical-datetime "20240315T090000") ; => local 09:00146
;;; (parse-ical-datetime "20240315T090000" "America/New_York") ; => Eastern 09:00147
;;; ```148
(define (parse-ical-datetime str . rest)149
(: string? -> number?)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))))158
(cond159
(utc?160
(utc->timestamp y mo d h mi s))161
(tz162
;; Use the event's timezone for correct cross-timezone parsing163
(list->time-in-tz (list s mi h d mo y) tz))164
(else165
;; Local time: delegate to mktime for correct DST handling166
(list->time (list s mi h d mo y))))))168
;;; Parse an iCalendar date string to a Unix timestamp.169
;;;170
;;; Returns midnight UTC on the given date.171
;;;172
;;; ```scheme173
;;; (parse-ical-date "20240315") ; => 1710460800174
;;; ```175
(define (parse-ical-date str)176
(: string? -> number?)177
(let ((y (string->number (substring str 0 4)))178
(mo (string->number (substring str 4 6)))179
(d (string->number (substring str 6 8))))180
(utc->timestamp y mo d 0 0 0)))182
;;; Format a Unix timestamp as an iCalendar UTC datetime string.183
;;;184
;;; ```scheme185
;;; (format-ical-datetime 1710493200) ; => "20240315T090000Z"186
;;; ```187
(define (format-ical-datetime ts)188
(: number? -> string?)189
(let ((parts (timestamp->utc ts)))190
(string-append (pad-digits (list-ref parts 0) 4)191
(pad-digits (list-ref parts 1) 2)192
(pad-digits (list-ref parts 2) 2)193
"T"194
(pad-digits (list-ref parts 3) 2)195
(pad-digits (list-ref parts 4) 2)196
(pad-digits (list-ref parts 5) 2)197
"Z")))199
;;; Format a Unix timestamp as an iCalendar local datetime string.200
;;;201
;;; Uses the system timezone to produce local date/time components.202
;;; No Z suffix — intended for use with TZID parameters.203
;;;204
;;; ```scheme205
;;; (format-ical-local-datetime 1710493200) ; => "20240315T110000" (in EET)206
;;; ```207
(define (format-ical-local-datetime ts)208
(: number? -> string?)209
(let* ((parts (time->list ts))210
;; time->list returns (second minute hour day month year ...)211
(s (list-ref parts 0))212
(mi (list-ref parts 1))213
(h (list-ref parts 2))214
(d (list-ref parts 3))215
(mo (list-ref parts 4))216
(y (list-ref parts 5)))217
(string-append (pad-digits y 4)218
(pad-digits mo 2)219
(pad-digits d 2)220
"T"221
(pad-digits h 2)222
(pad-digits mi 2)223
(pad-digits s 2))))225
;;; Format a Unix timestamp as an iCalendar date string.226
;;;227
;;; ```scheme228
;;; (format-ical-date 1710460800) ; => "20240315"229
;;; ```230
(define (format-ical-date ts)231
(: number? -> string?)232
(let ((parts (timestamp->utc ts)))233
(string-append (pad-digits (list-ref parts 0) 4)234
(pad-digits (list-ref parts 1) 2)235
(pad-digits (list-ref parts 2) 2))))237
(define (pad-digits n width)238
(let ((s (number->string n)))239
(if (< (string-length s) width)240
(string-append (string-repeat "0" (- width (string-length s))) s)241
s)))244
;; ============================================================245
;; iCalendar Parser246
;; ============================================================248
;; Unfold iCalendar line continuations.249
;; Lines starting with a space or tab are continuations of the previous line.250
(define (unfold-lines text)251
(let ((lines (string-split text "\n")))252
(let loop ((remaining lines) (current #f) (result '()))253
(if (null? remaining)254
(reverse (if current (cons current result) result))255
(let ((line (car remaining)))256
;; Strip trailing \r257
(let ((line (if (and (> (string-length line) 0)258
(char=? (string-ref line (- (string-length line) 1)) #\return))259
(substring line 0 (- (string-length line) 1))260
line)))261
(cond262
;; Continuation line (starts with space or tab)263
((and (> (string-length line) 0)264
current265
(or (char=? (string-ref line 0) #\space)266
(char=? (string-ref line 0) #\tab)))267
(loop (cdr remaining)268
(string-append current (substring line 1 (string-length line)))269
result))270
;; New line271
(else272
(loop (cdr remaining)273
line274
(if current (cons current result) result))))))))))276
;; Parse a content line into (name params value)277
;; A content line has the form: NAME;PARAM1=VAL1;PARAM2=VAL2:VALUE278
;; or simply: NAME:VALUE279
(define (parse-content-line line)280
(let ((colon-pos (find-property-colon line)))281
(if colon-pos282
(let* ((before (substring line 0 colon-pos))283
(value (substring line (+ colon-pos 1) (string-length line)))284
(semi-pos (string-find before ";")))285
(if semi-pos286
(list (string-upcase (substring before 0 semi-pos))287
(substring before (+ semi-pos 1) (string-length before))288
value)289
(list (string-upcase before) "" value)))290
(list line "" ""))))292
;; Find the colon separating property name/params from value.293
;; Must skip colons inside quoted parameter values.294
(define (find-property-colon line)295
(let ((len (string-length line)))296
(let loop ((i 0) (in-quotes? #f))297
(if (>= i len)298
#f299
(let ((c (string-ref line i)))300
(cond301
((char=? c #\") (loop (+ i 1) (not in-quotes?)))302
((and (char=? c #\:) (not in-quotes?)) i)303
(else (loop (+ i 1) in-quotes?))))))))305
;; Parse a DTSTART or DTEND property, checking params for VALUE=DATE.306
;; RFC 5545 requires local-form datetimes to be interpreted in the307
;; 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)311
(let ((tzid (extract-tzid params)))312
(cons (parse-ical-datetime value tzid) #f))))314
;; Extract TZID value from parameter string, e.g. "TZID=Europe/Athens"315
(define (extract-tzid params)316
(if (and (string? params)317
(string-contains? (string-upcase params) "TZID="))318
(let* ((upper (string-upcase params))319
(pos (string-find upper "TZID="))320
(rest (substring params (+ pos 5) (string-length params)))321
(semi (string-find rest ";")))322
(if semi323
(substring rest 0 semi)324
rest))325
#f))327
;; Extract a mailto: address, or return the raw value328
(define (parse-cal-address value)329
(if (string-starts-with? (string-downcase value) "mailto:")330
(substring value 7 (string-length value))331
value))333
;;; Create an attendee dict for use in ical-event attendees lists.334
;;;335
;;; ```scheme336
;;; (ical-attendee "[email protected]")337
;;; (ical-attendee "[email protected]" name: "Bob Smith")338
;;; ```339
(define (ical-attendee email (keys: (name #f) (partstat "NEEDS-ACTION") (rsvp #t) (role "REQ-PARTICIPANT")))340
(: string? (name: (maybe string?)) (partstat: string?) (rsvp: boolean?) (role: string?) -> dict?)341
(let ((d (dict email: email partstat: partstat rsvp: rsvp role: role)))342
(if name (dict-set d name: name) d)))344
;; Get the email from an attendee (string or dict)345
(define (ical-attendee-email att)346
(if (string? att) att (dict-ref att email:)))348
;; Parse iCal parameter string into an alist of key-value pairs.349
;; Input: "CN=Bob Smith;PARTSTAT=NEEDS-ACTION;RSVP=TRUE"350
(define (parse-ical-params params-str)351
(if (or (not params-str) (string=? params-str ""))352
'()353
(let ((parts (string-split params-str ";")))354
(filter-map355
(lambda (part)356
(let ((eq-pos (string-find part "=")))357
(if eq-pos358
(cons (string-upcase (substring part 0 eq-pos))359
(substring part (+ eq-pos 1) (string-length part)))360
#f)))361
parts))))363
;; Build an attendee dict from parsed iCal params and mailto value364
(define (build-attendee params value)365
(let* ((email (parse-cal-address value))366
(param-alist (parse-ical-params params))367
(cn (let ((p (assoc "CN" param-alist)))368
(if p (cdr p) #f)))369
(partstat (let ((p (assoc "PARTSTAT" param-alist)))370
(if p (cdr p) #f)))371
(rsvp (let ((p (assoc "RSVP" param-alist)))372
(if p (equal? (string-upcase (cdr p)) "TRUE") #f)))373
(role (let ((p (assoc "ROLE" param-alist)))374
(if p (cdr p) #f)))375
(d (dict email: email)))376
(let* ((d (if cn (dict-set d name: cn) d))377
(d (if partstat (dict-set d partstat: partstat) d))378
(d (if rsvp (dict-set d rsvp: rsvp) d))379
(d (if role (dict-set d role: role) d)))380
d)))382
;;; Parse an iCalendar string into a list of ical-event structs.383
;;;384
;;; Extracts all VEVENT components from the VCALENDAR. Properties385
;;; not directly mapped to struct fields are ignored.386
;;;387
;;; ```scheme388
;;; (define events (ical-parse ical-string))389
;;; (ical-event-summary (car events)) ; => "Team Meeting"390
;;; ```391
(define (ical-parse text)392
(: string? -> list?)393
(let ((lines (unfold-lines text)))394
(let loop ((remaining lines) (in-event? #f) (props '()) (events '()))395
(if (null? remaining)396
(reverse events)397
(let* ((parsed (parse-content-line (car remaining)))398
(name (car parsed))399
(params (cadr parsed))400
(value (caddr parsed)))401
(cond402
((equal? name "BEGIN")403
(if (equal? (string-upcase value) "VEVENT")404
(loop (cdr remaining) #t '() events)405
(loop (cdr remaining) in-event? props events)))406
((and in-event? (equal? name "END")407
(equal? (string-upcase value) "VEVENT"))408
(loop (cdr remaining) #f '()409
(cons (build-event props) events)))410
(in-event?411
(loop (cdr remaining) #t412
(cons (list name params value) props)413
events))414
(else415
(loop (cdr remaining) in-event? props events))))))))417
;; Build an ical-event from a list of (name params value) property triples418
(define (build-event props)419
(let ((ev (ical-event uid: "unknown")))420
(for-each421
(lambda (prop)422
(let ((name (car prop))423
(params (cadr prop))424
(value (caddr prop)))425
(cond426
((equal? name "UID")427
(set-ical-event-uid! ev value))428
((equal? name "SUMMARY")429
(set-ical-event-summary! ev value))430
((equal? name "DESCRIPTION")431
(set-ical-event-description! ev value))432
((equal? name "DTSTART")433
(let ((dt (parse-dt-value params value)))434
(set-ical-event-dtstart! ev (car dt))435
(when (cdr dt)436
(set-ical-event-all-day?! ev #t))437
(let ((tzid (extract-tzid params)))438
(when tzid439
(set-ical-event-timezone! ev tzid)))))440
((equal? name "DTEND")441
(let ((dt (parse-dt-value params value)))442
(set-ical-event-dtend! ev (car dt))))443
((equal? name "LOCATION")444
(set-ical-event-location! ev value))445
((equal? name "STATUS")446
(set-ical-event-status! ev value))447
((equal? name "ORGANIZER")448
(set-ical-event-organizer! ev (parse-cal-address value)))449
((equal? name "ATTENDEE")450
(set-ical-event-attendees! ev451
(cons (build-attendee params value)452
(ical-event-attendees ev))))453
((equal? name "CREATED")454
(set-ical-event-created! ev (parse-ical-datetime value)))455
((equal? name "LAST-MODIFIED")456
(set-ical-event-last-modified! ev (parse-ical-datetime value)))457
((equal? name "RRULE")458
(set-ical-event-rrule! ev value))459
((equal? name "RECURRENCE-ID")460
(let ((dt (parse-dt-value params value)))461
(set-ical-event-recurrence-id! ev (car dt)))))))462
props)463
ev))466
;; ============================================================467
;; iCalendar Generator468
;; ============================================================470
;; Fold a long content line at 75 octets per RFC 5545471
(define (fold-line line)472
(if (<= (string-length line) 75)473
line474
(let loop ((remaining line) (parts '()))475
(if (<= (string-length remaining) 75)476
(string-join (reverse (cons remaining parts)) "\r\n ")477
(loop (substring remaining 75 (string-length remaining))478
(cons (substring remaining 0 75) parts))))))480
;; Emit a content line, folded if necessary481
(define (emit-line name value)482
(fold-line (string-append name ":" value)))484
;;; Generate an iCalendar string from an ical-event struct.485
;;;486
;;; Produces a complete VCALENDAR containing a single VEVENT.487
;;;488
;;; ```scheme489
;;; (define ics (ical-generate event))490
;;; ;; => "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n..."491
;;; ```492
(define (ical-generate event)493
(: ical-event? -> string?)494
(let ((lines '()))495
(define (add! line) (set! lines (cons line lines)))497
(add! "BEGIN:VCALENDAR")498
(add! "VERSION:2.0")499
(add! "PRODID:-//Sigil//CalDAV Client//EN")500
(add! "BEGIN:VEVENT")501
(add! (emit-line "UID" (ical-event-uid event)))503
;; Date/time504
(let ((tz (ical-event-timezone event)))505
(when (ical-event-dtstart event)506
(cond507
((ical-event-all-day? event)508
(add! (emit-line "DTSTART;VALUE=DATE"509
(format-ical-date (ical-event-dtstart event)))))510
(tz511
(add! (emit-line (string-append "DTSTART;TZID=" tz)512
(format-ical-local-datetime (ical-event-dtstart event)))))513
(else514
(add! (emit-line "DTSTART"515
(format-ical-datetime (ical-event-dtstart event)))))))516
(when (ical-event-dtend event)517
(cond518
((ical-event-all-day? event)519
(add! (emit-line "DTEND;VALUE=DATE"520
(format-ical-date (ical-event-dtend event)))))521
(tz522
(add! (emit-line (string-append "DTEND;TZID=" tz)523
(format-ical-local-datetime (ical-event-dtend event)))))524
(else525
(add! (emit-line "DTEND"526
(format-ical-datetime (ical-event-dtend event))))))))528
;; Text properties529
(when (not (string-empty? (ical-event-summary event)))530
(add! (emit-line "SUMMARY" (ical-event-summary event))))531
(when (ical-event-description event)532
(add! (emit-line "DESCRIPTION" (ical-event-description event))))533
(when (ical-event-location event)534
(add! (emit-line "LOCATION" (ical-event-location event))))535
(when (ical-event-status event)536
(add! (emit-line "STATUS" (ical-event-status event))))538
;; People539
(when (ical-event-organizer event)540
(add! (emit-line "ORGANIZER"541
(string-append "mailto:" (ical-event-organizer event)))))542
(for-each543
(lambda (attendee)544
(if (string? attendee)545
;; Plain email string (backward compat)546
(add! (emit-line "ATTENDEE"547
(string-append "mailto:" attendee)))548
;; Dict with parameters549
(let* ((email (dict-ref attendee email:))550
(params '())551
(_ (let ((v (dict-ref attendee role: #f)))552
(when v (set! params (cons (string-append "ROLE=" v) params)))))553
(_ (let ((v (dict-ref attendee rsvp: #f)))554
(when v (set! params (cons "RSVP=TRUE" params)))))555
(_ (let ((v (dict-ref attendee partstat: #f)))556
(when v (set! params (cons (string-append "PARTSTAT=" v) params)))))557
(_ (let ((v (dict-ref attendee name: #f)))558
(when v (set! params (cons (string-append "CN=" v) params)))))559
(prop-name (if (null? params)560
"ATTENDEE"561
(string-append "ATTENDEE;" (string-join params ";")))))562
(add! (emit-line prop-name563
(string-append "mailto:" email))))))564
(ical-event-attendees event))566
;; Timestamps567
(when (ical-event-created event)568
(add! (emit-line "CREATED"569
(format-ical-datetime (ical-event-created event)))))570
(when (ical-event-last-modified event)571
(add! (emit-line "LAST-MODIFIED"572
(format-ical-datetime (ical-event-last-modified event)))))574
;; Recurrence575
(when (ical-event-rrule event)576
(add! (emit-line "RRULE" (ical-event-rrule event))))577
(when (ical-event-recurrence-id event)578
(add! (emit-line "RECURRENCE-ID"579
(format-ical-datetime (ical-event-recurrence-id event)))))581
(add! "END:VEVENT")582
(add! "END:VCALENDAR")584
(string-join (reverse lines) "\r\n")))587
;; ============================================================588
;; UID Generation589
;; ============================================================591
;;; Generate a unique event UID string.592
;;;593
;;; Produces a UUID-like identifier suitable for iCalendar UID fields.594
;;;595
;;; ```scheme596
;;; (generate-event-uid) ; => "a1b2c3d4-e5f6-7890-abcd-ef1234567890"597
;;; ```598
(define (generate-event-uid)599
(: -> string?)600
(let ((bytes (random-bytes 16)))601
(string-append602
(bytes->hex bytes 0 4) "-"603
(bytes->hex bytes 4 6) "-"604
(bytes->hex bytes 6 8) "-"605
(bytes->hex bytes 8 10) "-"606
(bytes->hex bytes 10 16))))608
(define (byte->hex b)609
(let ((s (number->string b 16)))610
(if (< (string-length s) 2)611
(string-append "0" s)612
s)))614
(define (bytes->hex bv start end)615
(let loop ((i start) (acc ""))616
(if (>= i end)617
acc618
(loop (+ i 1)619
(string-append acc (byte->hex (bytevector-u8-ref bv i)))))))622
;; ============================================================623
;; Recurrence Expansion624
;; ============================================================626
;; Parse an RRULE string into a dict-like alist627
;; e.g. "FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=10"628
(define (parse-rrule rrule-str)629
(let ((parts (string-split rrule-str ";")))630
(map (lambda (part)631
(let ((eq-pos (string-find part "=")))632
(if eq-pos633
(cons (substring part 0 eq-pos)634
(substring part (+ eq-pos 1) (string-length part)))635
(cons part ""))))636
parts)))638
(define (rrule-ref rrule key)639
(let ((pair (assoc key rrule)))640
(if pair (cdr pair) #f)))642
;; Day-of-week abbreviation to number (0=Sunday)643
(define (day-abbrev->number abbrev)644
(cond645
((equal? abbrev "SU") 0)646
((equal? abbrev "MO") 1)647
((equal? abbrev "TU") 2)648
((equal? abbrev "WE") 3)649
((equal? abbrev "TH") 4)650
((equal? abbrev "FR") 5)651
((equal? abbrev "SA") 6)652
(else #f)))654
;; Get the day of week (0=Sunday) for a Unix timestamp.655
;; Operates on the UTC value directly; for TZID-tagged events, callers656
;; should pass a wall-as-UTC instant via wall->wall-as-utc so the local657
;; calendar day is what gets classified.658
(define (timestamp-weekday ts)659
;; 1970-01-01 was a Thursday (4)660
(modulo (+ (quotient ts 86400) 4) 7))662
;; Add N days to a timestamp (UTC arithmetic).663
(define (add-days ts n)664
(+ ts (* n 86400)))666
;; Add N months to a UTC date, keeping the same day (clamped to month end)667
(define (add-months ts n)668
(let* ((parts (timestamp->utc ts))669
(y (list-ref parts 0))670
(m (list-ref parts 1))671
(d (list-ref parts 2))672
(h (list-ref parts 3))673
(mi (list-ref parts 4))674
(s (list-ref parts 5))675
(total-months (+ (* y 12) (- m 1) n))676
(new-y (quotient total-months 12))677
(new-m (+ (modulo total-months 12) 1))678
(max-d (days-in-month new-y new-m))679
(new-d (min d max-d)))680
(utc->timestamp new-y new-m new-d h mi s)))682
;; ----------------------------------------------------------------683
;; TZID-aware date arithmetic684
;;685
;; Recurring events tagged with a TZID anchor each occurrence to a686
;; fixed wall-clock in that tz. Walking the recurrence by adding687
;; 86400-second multiples in UTC drifts the wall-clock by the DST688
;; shift across spring/fall transitions. The helpers below walk by689
;; wall-clock components in the tz and re-resolve to UTC at each690
;; step via list->time-in-tz so the OS zoneinfo provides the691
;; correct offset for the occurrence's actual fire date.692
;;693
;; The conversion utc-instant->wall uses a single round-trip694
;; through list->time-in-tz to derive wall components without695
;; needing a `time->list-in-tz` native. The math:696
;; sys-wall = decompose(ts + sys-offset(ts))697
;; try-ts = compose(sys-wall, tz) ; treats sys-wall as wall-in-tz698
;; delta = try-ts - ts ; = sys-offset - tz-offset699
;; wall-in-tz = decompose((ts - delta) + sys-offset(ts - delta))700
;; = decompose(ts + tz-offset(ts)) QED701
;; ----------------------------------------------------------------703
;; Take the first 6 components of (time->list ts) — (s mi h d mo y).704
(define (time-wall-components ts)705
(let ((parts (time->list ts)))706
(list (list-ref parts 0) (list-ref parts 1) (list-ref parts 2)707
(list-ref parts 3) (list-ref parts 4) (list-ref parts 5))))709
;; Decompose a UTC instant into (s mi h d mo y) as observed in tz.710
(define (utc-instant->wall ts tz)711
(if (not tz)712
(time-wall-components ts)713
(let* ((sys-wall (time-wall-components ts))714
(try-ts (list->time-in-tz sys-wall tz))715
(delta (- try-ts ts)))716
(time-wall-components (- ts delta)))))718
;; Wall-clock (s mi h d mo y) -> Unix instant whose decomposition719
;; in 'utc->timestamp' would give those components. Used as a720
;; canonical key for date arithmetic — the "wall instant" is721
;; not a real UTC instant, just a positional encoding of the722
;; wall-clock that lets us add days/weeks/months with the723
;; existing arithmetic.724
(define (wall-as-utc-instant wall)725
(utc->timestamp (list-ref wall 5) (list-ref wall 4) (list-ref wall 3)726
(list-ref wall 2) (list-ref wall 1) (list-ref wall 0)))728
;; Inverse of wall-as-utc-instant: decompose to (s mi h d mo y).729
(define (utc-instant->wall-components t)730
(let ((parts (timestamp->utc t)))731
(list (list-ref parts 5) (list-ref parts 4) (list-ref parts 3)732
(list-ref parts 2) (list-ref parts 1) (list-ref parts 0))))734
;; Resolve wall components in tz back to a real UTC instant.735
(define (wall->utc wall tz)736
(if tz737
(list->time-in-tz wall tz)738
(list->time wall)))740
;; TZID-aware add-days. Returns a real UTC instant whose wall-clock741
;; in tz is the master's wall-clock advanced by n calendar days.742
(define (add-days-in-tz ts n tz)743
(if (not tz)744
(add-days ts n)745
(let* ((wall (utc-instant->wall ts tz))746
(anchor (wall-as-utc-instant wall))747
(advanced (+ anchor (* n 86400)))748
(new-wall (utc-instant->wall-components advanced)))749
(wall->utc new-wall tz))))751
;; TZID-aware add-months. Same idea, calendar-month math on the wall.752
(define (add-months-in-tz ts n tz)753
(if (not tz)754
(add-months ts n)755
(let* ((wall (utc-instant->wall ts tz))756
(s (list-ref wall 0))757
(mi (list-ref wall 1))758
(h (list-ref wall 2))759
(d (list-ref wall 3))760
(mo (list-ref wall 4))761
(y (list-ref wall 5))762
(total-months (+ (* y 12) (- mo 1) n))763
(new-y (quotient total-months 12))764
(new-mo (+ (modulo total-months 12) 1))765
(max-d (days-in-month new-y new-mo))766
(new-d (min d max-d)))767
(wall->utc (list s mi h new-d new-mo new-y) tz))))769
;; Day-of-week for a real UTC instant as observed in tz.770
(define (timestamp-weekday-in-tz ts tz)771
(if (not tz)772
(timestamp-weekday ts)773
(timestamp-weekday (wall-as-utc-instant (utc-instant->wall ts tz)))))775
;;; Expand a recurring event into individual occurrences within a date range.776
;;;777
;;; Supports FREQ=DAILY, FREQ=WEEKLY (with BYDAY), and FREQ=MONTHLY778
;;; (with BYMONTHDAY). Honors COUNT and UNTIL limits. INTERVAL defaults779
;;; to 1 if not specified.780
;;;781
;;; Returns a list of ical-event copies with adjusted dtstart/dtend782
;;; and recurrence-id set to the original dtstart.783
;;;784
;;; ```scheme785
;;; (define weekly (ical-event uid: "1" summary: "Standup"786
;;; dtstart: 1710489600 dtend: 1710493200787
;;; rrule: "FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=6"))788
;;; (length (ical-expand-recurrence weekly789
;;; start: 1710489600 end: 1711699200))790
;;; ```791
(define (ical-expand-recurrence event (keys: (start #f) (end #f)))792
(: ical-event? (start: (maybe number?)) (end: (maybe number?)) -> list?)793
(let ((rrule-str (ical-event-rrule event)))794
(if (not rrule-str)795
(list event)796
(let* ((rrule (parse-rrule rrule-str))797
(freq (rrule-ref rrule "FREQ"))798
(interval (let ((v (rrule-ref rrule "INTERVAL")))799
(if v (string->number v) 1)))800
(count (let ((v (rrule-ref rrule "COUNT")))801
(if v (string->number v) #f)))802
(until (let ((v (rrule-ref rrule "UNTIL")))803
(if v (parse-ical-datetime v) #f)))804
(ev-start (ical-event-dtstart event))805
(duration (if (ical-event-dtend event)806
(- (ical-event-dtend event) ev-start)807
3600))808
;; All-day events have date-only DTSTART (no TZID809
;; semantics); UTC-anchored events ignore TZID by spec.810
;; Only honor the tz for timed events whose DTSTART811
;; carried a TZID parameter.812
(tz (and (not (ical-event-all-day? event))813
(ical-event-timezone event))))814
(cond815
((equal? freq "DAILY")816
(expand-daily event ev-start duration interval count until start end tz))817
((equal? freq "WEEKLY")818
(let ((byday (rrule-ref rrule "BYDAY")))819
(expand-weekly event ev-start duration interval count until820
(if byday (string-split byday ",") '())821
start end tz)))822
((equal? freq "MONTHLY")823
(expand-monthly event ev-start duration interval count until start end tz))824
(else (list event)))))))826
;; Clone an event with a new dtstart/dtend827
(define (clone-event event new-start duration)828
(let ((ev (ical-event829
uid: (ical-event-uid event)830
summary: (ical-event-summary event)831
description: (ical-event-description event)832
dtstart: new-start833
dtend: (+ new-start duration)834
location: (ical-event-location event)835
status: (ical-event-status event)836
organizer: (ical-event-organizer event)837
attendees: (ical-event-attendees event)838
all-day?: (ical-event-all-day? event)839
url: (ical-event-url event)840
rrule: (ical-event-rrule event)841
recurrence-id: new-start842
calendar-name: (ical-event-calendar-name event)843
timezone: (ical-event-timezone event))))844
ev))846
;; Check if a timestamp is within the query range847
(define (in-range? ts start end)848
(and (or (not start) (>= ts start))849
(or (not end) (< ts end))))851
;; Maximum instances to generate (safety limit)852
(define MAX-INSTANCES 1000)854
(define (expand-daily event ev-start duration interval count until range-start range-end tz)855
(let loop ((ts ev-start) (n 0) (results '()))856
(cond857
((and count (>= n count)) (reverse results))858
((and until (> ts until)) (reverse results))859
((and range-end (> ts range-end)) (reverse results))860
((>= n MAX-INSTANCES) (reverse results))861
(else862
(let ((next (add-days-in-tz ts interval tz)))863
(if (in-range? ts range-start range-end)864
(loop next (+ n 1) (cons (clone-event event ts duration) results))865
(loop next (+ n 1) results)))))))867
(define (expand-weekly event ev-start duration interval count until byday range-start range-end tz)868
(let ((target-days (if (null? byday)869
(list (timestamp-weekday-in-tz ev-start tz))870
(let loop ((rest byday) (acc '()))871
(if (null? rest)872
(reverse acc)873
(let ((n (day-abbrev->number (car rest))))874
(loop (cdr rest)875
(if n (cons n acc) acc))))))))876
(let loop ((ts ev-start) (n 0) (results '()))877
(cond878
((and count (>= n count)) (reverse results))879
((and until (> ts until)) (reverse results))880
((and range-end (> ts range-end)) (reverse results))881
((>= n MAX-INSTANCES) (reverse results))882
(else883
;; Walk day by day within each week period884
(let week-loop ((day-ts ts) (day-offset 0) (n n) (results results))885
(cond886
((>= day-offset 7)887
;; Move to next week (skip by interval)888
(loop (add-days-in-tz ts (* interval 7) tz) n results))889
((and count (>= n count)) (reverse results))890
((and until (> day-ts until)) (reverse results))891
((and range-end (> day-ts range-end)) (reverse results))892
(else893
(if (memv (timestamp-weekday-in-tz day-ts tz) target-days)894
(if (in-range? day-ts range-start range-end)895
(week-loop (add-days-in-tz day-ts 1 tz) (+ day-offset 1) (+ n 1)896
(cons (clone-event event day-ts duration) results))897
(week-loop (add-days-in-tz day-ts 1 tz) (+ day-offset 1) (+ n 1) results))898
(week-loop (add-days-in-tz day-ts 1 tz) (+ day-offset 1) n results))))))))))900
(define (expand-monthly event ev-start duration interval count until range-start range-end tz)901
(let loop ((ts ev-start) (month-offset 0) (n 0) (results '()))902
(cond903
((and count (>= n count)) (reverse results))904
((and until (> ts until)) (reverse results))905
((and range-end (> ts range-end)) (reverse results))906
((>= n MAX-INSTANCES) (reverse results))907
(else908
(if (in-range? ts range-start range-end)909
(loop (add-months-in-tz ev-start (+ month-offset interval) tz)910
(+ month-offset interval) (+ n 1)911
(cons (clone-event event ts duration) results))912
(loop (add-months-in-tz ev-start (+ month-offset interval) tz)913
(+ month-offset interval) (+ n 1)914
results))))))916
))