Compare commits
3 Commits
loops/maud
...
loops/even
| Author | SHA1 | Date | |
|---|---|---|---|
| 3913bc368c | |||
| 94aaf0e433 | |||
| ddc6635fa8 |
@@ -275,3 +275,55 @@
|
||||
((ev/would-time-conflict? b store actor occ)
|
||||
{:status :time-conflict :actor actor :occ-key (ev-occ-key occ)})
|
||||
(else (ev/book-occ! b store actor occ)))))
|
||||
|
||||
;; ---- whole-series operations ----
|
||||
;; Apply a booking action to every occurrence of one event in [ws, we) — e.g.
|
||||
;; "RSVP to the whole weekly class". Returns a list of (occ-key status) results,
|
||||
;; one per occurrence (empty if the event id is unknown).
|
||||
(define
|
||||
ev/book-series!
|
||||
(fn
|
||||
(b store actor event-id ws we)
|
||||
(let
|
||||
((ev (ev/event-by-id store event-id)))
|
||||
(if
|
||||
(nil? ev)
|
||||
(list)
|
||||
(map
|
||||
(fn (occ) (list (ev-occ-key occ) (get (ev/book-occ! b store actor occ) :status)))
|
||||
(ev-expand ev ws we))))))
|
||||
|
||||
;; Cancel `actor` from every occurrence of one event in [ws, we).
|
||||
(define
|
||||
ev/cancel-series!
|
||||
(fn
|
||||
(b store actor event-id ws we)
|
||||
(let
|
||||
((ev (ev/event-by-id store event-id)))
|
||||
(if
|
||||
(nil? ev)
|
||||
(list)
|
||||
(map
|
||||
(fn (occ) (list (ev-occ-key occ) (get (ev/cancel! b (ev-occ-key occ) actor) :status)))
|
||||
(ev-expand ev ws we))))))
|
||||
|
||||
;; How many statuses in a series-result list equal `status`.
|
||||
(define
|
||||
ev/series-count
|
||||
(fn
|
||||
(results status)
|
||||
(len (filter (fn (r) (= (first (rest r)) status)) results))))
|
||||
|
||||
;; The occurrences of one event in [ws, we) that `actor` is booked into.
|
||||
(define
|
||||
ev/series-booked
|
||||
(fn
|
||||
(b store actor event-id ws we)
|
||||
(let
|
||||
((ev (ev/event-by-id store event-id)))
|
||||
(if
|
||||
(nil? ev)
|
||||
(list)
|
||||
(filter
|
||||
(fn (occ) (ev-actor-booked? b (ev-occ-key occ) actor))
|
||||
(ev-expand ev ws we))))))
|
||||
|
||||
@@ -19,6 +19,7 @@ PRELOADS=(
|
||||
lib/datalog/magic.sx
|
||||
lib/events/calendar.sx
|
||||
lib/events/timezone.sx
|
||||
lib/events/ical.sx
|
||||
lib/events/availability.sx
|
||||
lib/persist/event.sx
|
||||
lib/persist/backend.sx
|
||||
@@ -49,6 +50,7 @@ PRELOADS=(
|
||||
SUITES=(
|
||||
"calendar:lib/events/tests/calendar.sx:(ev-calendar-tests-run!)"
|
||||
"timezone:lib/events/tests/timezone.sx:(ev-timezone-tests-run!)"
|
||||
"ical:lib/events/tests/ical.sx:(ev-ical-tests-run!)"
|
||||
"availability:lib/events/tests/availability.sx:(ev-availability-tests-run!)"
|
||||
"api:lib/events/tests/api.sx:(ev-api-tests-run!)"
|
||||
"booking:lib/events/tests/booking.sx:(ev-booking-tests-run!)"
|
||||
|
||||
341
lib/events/ical.sx
Normal file
341
lib/events/ical.sx
Normal file
@@ -0,0 +1,341 @@
|
||||
;; lib/events/ical.sx — iCalendar (RFC 5545) export.
|
||||
;;
|
||||
;; Serializes events to VEVENT / VCALENDAR text so a rose-ash calendar can be
|
||||
;; imported by any standard client (Google/Apple/Outlook). Datetimes are UTC
|
||||
;; epoch-minutes, emitted as basic-format UTC stamps (YYYYMMDDTHHMM00Z). The
|
||||
;; full RRULE / EXDATE / RDATE model maps directly to the standard properties.
|
||||
;;
|
||||
;; Export is line-oriented: `ev/event->ical-lines` returns the VEVENT as a list
|
||||
;; of content lines (no folding/CRLF — easy to assert on); `ev/ical-render`
|
||||
;; joins lines with CRLF, the on-the-wire format. Requires calendar.sx.
|
||||
|
||||
;; ---- formatting helpers ----
|
||||
|
||||
(define ev-ical-pad2 (fn (n) (if (< n 10) (str "0" n) (str n))))
|
||||
|
||||
(define
|
||||
ev-ical-pad4
|
||||
(fn
|
||||
(n)
|
||||
(cond
|
||||
((< n 10) (str "000" n))
|
||||
((< n 100) (str "00" n))
|
||||
((< n 1000) (str "0" n))
|
||||
(else (str n)))))
|
||||
|
||||
(define
|
||||
ev-ical-nth
|
||||
(fn
|
||||
(xs i)
|
||||
(if
|
||||
(= i 0)
|
||||
(first xs)
|
||||
(ev-ical-nth (rest xs) (- i 1)))))
|
||||
|
||||
(define
|
||||
ev-ical-join
|
||||
(fn
|
||||
(parts sep)
|
||||
(if
|
||||
(empty? parts)
|
||||
""
|
||||
(reduce (fn (acc p) (str acc sep p)) (first parts) (rest parts)))))
|
||||
|
||||
;; A UTC epoch-minute as an iCal basic-format UTC stamp.
|
||||
(define
|
||||
ev-ical-dt
|
||||
(fn
|
||||
(t)
|
||||
(let
|
||||
((civ (ev-dt->civil t)) (tod (ev-dt-tod t)))
|
||||
(str
|
||||
(ev-ical-pad4 (ev-civ-y civ))
|
||||
(ev-ical-pad2 (ev-civ-m civ))
|
||||
(ev-ical-pad2 (ev-civ-d civ))
|
||||
"T"
|
||||
(ev-ical-pad2 (quotient tod 60))
|
||||
(ev-ical-pad2 (modulo tod 60))
|
||||
"00Z"))))
|
||||
|
||||
;; A duration in minutes as an iCal DURATION value (PT#H#M).
|
||||
(define
|
||||
ev-ical-duration
|
||||
(fn
|
||||
(mins)
|
||||
(let
|
||||
((h (quotient mins 60)) (m (modulo mins 60)))
|
||||
(cond
|
||||
((and (> h 0) (> m 0)) (str "PT" h "H" m "M"))
|
||||
((> h 0) (str "PT" h "H"))
|
||||
(else (str "PT" m "M"))))))
|
||||
|
||||
(define
|
||||
ev-ical-wd
|
||||
(fn (w) (ev-ical-nth (list "MO" "TU" "WE" "TH" "FR" "SA" "SU") w)))
|
||||
|
||||
(define
|
||||
ev-ical-freq
|
||||
(fn
|
||||
(f)
|
||||
(cond
|
||||
((= f :daily) "DAILY")
|
||||
((= f :weekly) "WEEKLY")
|
||||
((= f :monthly) "MONTHLY")
|
||||
(else "DAILY"))))
|
||||
|
||||
;; One BYDAY token: a weekly weekday number -> "MO"; a monthly ordinal weekday
|
||||
;; {:ord :wd} -> "2TU" / "-1FR".
|
||||
(define
|
||||
ev-ical-byday-token
|
||||
(fn
|
||||
(e)
|
||||
(if
|
||||
(dict? e)
|
||||
(str (get e :ord) (ev-ical-wd (get e :wd)))
|
||||
(ev-ical-wd e))))
|
||||
|
||||
;; ---- RRULE ----
|
||||
(define
|
||||
ev-ical-rrule
|
||||
(fn
|
||||
(rrule)
|
||||
(let
|
||||
((parts (list (str "FREQ=" (ev-ical-freq (get rrule :freq))))))
|
||||
(begin
|
||||
(when
|
||||
(and
|
||||
(not (nil? (get rrule :interval)))
|
||||
(> (get rrule :interval) 1))
|
||||
(append! parts (str "INTERVAL=" (get rrule :interval))))
|
||||
(when
|
||||
(not (nil? (get rrule :count)))
|
||||
(append! parts (str "COUNT=" (get rrule :count))))
|
||||
(when
|
||||
(not (nil? (get rrule :until)))
|
||||
(append! parts (str "UNTIL=" (ev-ical-dt (get rrule :until)))))
|
||||
(when
|
||||
(not (nil? (get rrule :byday)))
|
||||
(append!
|
||||
parts
|
||||
(str
|
||||
"BYDAY="
|
||||
(ev-ical-join (map ev-ical-byday-token (get rrule :byday)) ","))))
|
||||
(when
|
||||
(not (nil? (get rrule :bymonthday)))
|
||||
(append!
|
||||
parts
|
||||
(str
|
||||
"BYMONTHDAY="
|
||||
(ev-ical-join
|
||||
(map (fn (d) (str d)) (get rrule :bymonthday))
|
||||
","))))
|
||||
(str "RRULE:" (ev-ical-join parts ";"))))))
|
||||
|
||||
;; ---- VEVENT / VCALENDAR ----
|
||||
|
||||
;; The VEVENT content lines for an event (list of strings).
|
||||
(define
|
||||
ev/event->ical-lines
|
||||
(fn
|
||||
(event)
|
||||
(let
|
||||
((lines (list "BEGIN:VEVENT")))
|
||||
(begin
|
||||
(append! lines (str "UID:" (get event :id)))
|
||||
(append! lines (str "SUMMARY:" (get event :id)))
|
||||
(append! lines (str "DTSTART:" (ev-ical-dt (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))))
|
||||
(when
|
||||
(and
|
||||
(not (nil? (get event :exdate)))
|
||||
(> (len (get event :exdate)) 0))
|
||||
(append!
|
||||
lines
|
||||
(str
|
||||
"EXDATE:"
|
||||
(ev-ical-join (map ev-ical-dt (get event :exdate)) ","))))
|
||||
(when
|
||||
(and
|
||||
(not (nil? (get event :rdate)))
|
||||
(> (len (get event :rdate)) 0))
|
||||
(append!
|
||||
lines
|
||||
(str
|
||||
"RDATE:"
|
||||
(ev-ical-join (map ev-ical-dt (get event :rdate)) ","))))
|
||||
(append! lines "END:VEVENT")
|
||||
lines))))
|
||||
|
||||
;; A full VCALENDAR (list of content lines) wrapping every event.
|
||||
(define
|
||||
ev/events->ical-lines
|
||||
(fn
|
||||
(events)
|
||||
(let
|
||||
((lines (list "BEGIN:VCALENDAR" "VERSION:2.0" "PRODID:-//rose-ash//events-on-sx//EN")))
|
||||
(begin
|
||||
(for-each
|
||||
(fn
|
||||
(ev)
|
||||
(for-each (fn (l) (append! lines l)) (ev/event->ical-lines ev)))
|
||||
events)
|
||||
(append! lines "END:VCALENDAR")
|
||||
lines))))
|
||||
|
||||
;; Render content lines to the on-the-wire iCalendar text (CRLF-separated).
|
||||
(define ev/ical-render (fn (lines) (ev-ical-join lines "\r\n")))
|
||||
|
||||
;; ---- import (parse VEVENT/VCALENDAR back into events) ----
|
||||
;; Inverse of the export above: parse iCalendar content lines into event dicts
|
||||
;; (ev-event-full shape). Capacity is not an iCal property, so imported events
|
||||
;; default to capacity 0 — set it after import if needed.
|
||||
|
||||
;; "20260601T180000Z" -> UTC epoch-minutes.
|
||||
(define
|
||||
ev-ical-parse-dt
|
||||
(fn
|
||||
(s)
|
||||
(ev-dt
|
||||
(string->number (substring s 0 4))
|
||||
(string->number (substring s 4 6))
|
||||
(string->number (substring s 6 8))
|
||||
(string->number (substring s 9 11))
|
||||
(string->number (substring s 11 13)))))
|
||||
|
||||
;; "30M" / "" -> minutes.
|
||||
(define
|
||||
ev-ical-parse-min
|
||||
(fn
|
||||
(s)
|
||||
(if (= (string-length s) 0) 0 (string->number (first (split s "M"))))))
|
||||
|
||||
;; "PT1H30M" / "PT1H" / "PT30M" -> minutes.
|
||||
(define
|
||||
ev-ical-parse-duration
|
||||
(fn
|
||||
(s)
|
||||
(let
|
||||
((body (substring s 2 (string-length s))))
|
||||
(let
|
||||
((hparts (split body "H")))
|
||||
(if
|
||||
(> (len hparts) 1)
|
||||
(+ (* 60 (string->number (first hparts))) (ev-ical-parse-min (first (rest hparts))))
|
||||
(ev-ical-parse-min body))))))
|
||||
|
||||
(define
|
||||
ev-ical-wd->num
|
||||
(fn
|
||||
(tok)
|
||||
(cond
|
||||
((= tok "MO") 0)
|
||||
((= tok "TU") 1)
|
||||
((= tok "WE") 2)
|
||||
((= tok "TH") 3)
|
||||
((= tok "FR") 4)
|
||||
((= tok "SA") 5)
|
||||
((= tok "SU") 6)
|
||||
(else 0))))
|
||||
|
||||
;; "MO" -> 0 ; "2TU" -> {:ord 2 :wd 1} ; "-1FR" -> {:ord -1 :wd 4}
|
||||
(define
|
||||
ev-ical-parse-byday-token
|
||||
(fn
|
||||
(tok)
|
||||
(let
|
||||
((n (string-length tok)))
|
||||
(if
|
||||
(= n 2)
|
||||
(ev-ical-wd->num tok)
|
||||
{:ord (string->number (substring tok 0 (- n 2)))
|
||||
:wd (ev-ical-wd->num (substring tok (- n 2) n))}))))
|
||||
|
||||
(define
|
||||
ev-ical-parse-freq
|
||||
(fn
|
||||
(v)
|
||||
(cond
|
||||
((= v "DAILY") :daily)
|
||||
((= v "WEEKLY") :weekly)
|
||||
((= v "MONTHLY") :monthly)
|
||||
(else :daily))))
|
||||
|
||||
;; "FREQ=WEEKLY;INTERVAL=2;UNTIL=...;BYDAY=MO,WE" -> rrule dict.
|
||||
(define
|
||||
ev-ical-parse-rrule
|
||||
(fn
|
||||
(val)
|
||||
(let
|
||||
((rr {}))
|
||||
(begin
|
||||
(for-each
|
||||
(fn
|
||||
(p)
|
||||
(let
|
||||
((kv (split p "=")))
|
||||
(let
|
||||
((k (first kv)) (v (first (rest kv))))
|
||||
(cond
|
||||
((= k "FREQ") (dict-set! rr :freq (ev-ical-parse-freq v)))
|
||||
((= k "INTERVAL") (dict-set! rr :interval (string->number v)))
|
||||
((= k "COUNT") (dict-set! rr :count (string->number v)))
|
||||
((= k "UNTIL") (dict-set! rr :until (ev-ical-parse-dt v)))
|
||||
((= k "BYDAY") (dict-set! rr :byday (map ev-ical-parse-byday-token (split v ","))))
|
||||
((= k "BYMONTHDAY") (dict-set! rr :bymonthday (map string->number (split v ","))))
|
||||
(else nil)))))
|
||||
(split val ";"))
|
||||
rr))))
|
||||
|
||||
;; Parse a VEVENT's content lines into an event dict.
|
||||
(define
|
||||
ev/ical-lines->event
|
||||
(fn
|
||||
(lines)
|
||||
(let
|
||||
((ev {:capacity 0 :rrule nil}) (exd (list)) (rd (list)))
|
||||
(begin
|
||||
(for-each
|
||||
(fn
|
||||
(line)
|
||||
(let
|
||||
((kv (split line ":")))
|
||||
(when
|
||||
(> (len kv) 1)
|
||||
(let
|
||||
((k (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)))
|
||||
((= k "DURATION") (dict-set! ev :duration (ev-ical-parse-duration v)))
|
||||
((= k "RRULE") (dict-set! ev :rrule (ev-ical-parse-rrule v)))
|
||||
((= k "EXDATE") (set! exd (map ev-ical-parse-dt (split v ","))))
|
||||
((= k "RDATE") (set! rd (map ev-ical-parse-dt (split v ","))))
|
||||
(else nil))))))
|
||||
lines)
|
||||
(dict-set! ev :exdate exd)
|
||||
(dict-set! ev :rdate rd)
|
||||
ev))))
|
||||
|
||||
;; Split a VCALENDAR line list into per-VEVENT line groups.
|
||||
(define
|
||||
ev-ical-group-vevents
|
||||
(fn
|
||||
(lines cur in acc)
|
||||
(cond
|
||||
((empty? lines) acc)
|
||||
((= (first lines) "BEGIN:VEVENT") (ev-ical-group-vevents (rest lines) (list) true acc))
|
||||
((= (first lines) "END:VEVENT") (ev-ical-group-vevents (rest lines) (list) false (append acc (list cur))))
|
||||
(in (ev-ical-group-vevents (rest lines) (append cur (list (first lines))) true acc))
|
||||
(else (ev-ical-group-vevents (rest lines) cur false acc)))))
|
||||
|
||||
;; Parse a VCALENDAR line list into a list of events.
|
||||
(define
|
||||
ev/parse-vcalendar
|
||||
(fn
|
||||
(lines)
|
||||
(map ev/ical-lines->event (ev-ical-group-vevents lines (list) false (list)))))
|
||||
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"lang": "events",
|
||||
"total_passed": 311,
|
||||
"total_passed": 360,
|
||||
"total_failed": 0,
|
||||
"total": 311,
|
||||
"total": 360,
|
||||
"suites": [
|
||||
{"name":"calendar","passed":51,"failed":0,"total":51},
|
||||
{"name":"timezone","passed":17,"failed":0,"total":17},
|
||||
{"name":"ical","passed":40,"failed":0,"total":40},
|
||||
{"name":"availability","passed":22,"failed":0,"total":22},
|
||||
{"name":"api","passed":32,"failed":0,"total":32},
|
||||
{"name":"api","passed":41,"failed":0,"total":41},
|
||||
{"name":"booking","passed":82,"failed":0,"total":82},
|
||||
{"name":"booking-notify","passed":11,"failed":0,"total":11},
|
||||
{"name":"ticket","passed":31,"failed":0,"total":31},
|
||||
@@ -16,5 +17,5 @@
|
||||
{"name":"federation","passed":29,"failed":0,"total":29},
|
||||
{"name":"integration","passed":8,"failed":0,"total":8}
|
||||
],
|
||||
"generated": "2026-06-07T13:59:09+00:00"
|
||||
"generated": "2026-06-07T17:28:07+00:00"
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# events scoreboard
|
||||
|
||||
**311 / 311 passing** (0 failure(s)).
|
||||
**360 / 360 passing** (0 failure(s)).
|
||||
|
||||
| Suite | Passed | Total | Status |
|
||||
|-------|--------|-------|--------|
|
||||
| calendar | 51 | 51 | ok |
|
||||
| timezone | 17 | 17 | ok |
|
||||
| ical | 40 | 40 | ok |
|
||||
| availability | 22 | 22 | ok |
|
||||
| api | 32 | 32 | ok |
|
||||
| api | 41 | 41 | ok |
|
||||
| booking | 82 | 82 | ok |
|
||||
| booking-notify | 11 | 11 | ok |
|
||||
| ticket | 31 | 31 | ok |
|
||||
|
||||
@@ -319,6 +319,65 @@
|
||||
(ev/would-time-conflict? b store (quote zed) ob)
|
||||
false))))))
|
||||
|
||||
;; ---- whole-series booking ----
|
||||
(define
|
||||
ev-api-sr-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((b (persist/open))
|
||||
(store
|
||||
(ev/schedule
|
||||
(ev/empty)
|
||||
(quote yoga)
|
||||
(ev-dt 2026 6 1 18 0)
|
||||
60
|
||||
{:freq :weekly :byday (list 0 2) :count 4}
|
||||
20))
|
||||
(ws (ev-date 2026 6 1))
|
||||
(we (ev-date 2026 7 1)))
|
||||
(do
|
||||
(let
|
||||
((res (ev/book-series! b store (quote nia) (quote yoga) ws we)))
|
||||
(do
|
||||
(ev-api-check! "series booking covers all four occurrences" (len res) 4)
|
||||
(ev-api-check! "all occurrences booked" (ev/series-count res :booked) 4)
|
||||
(ev-api-check!
|
||||
"actor is now booked into the whole series"
|
||||
(len (ev/series-booked b store (quote nia) (quote yoga) ws we))
|
||||
4)))
|
||||
;; re-booking the series is idempotent
|
||||
(ev-api-check!
|
||||
"re-booking the series is idempotent"
|
||||
(ev/series-count (ev/book-series! b store (quote nia) (quote yoga) ws we) :already)
|
||||
4)
|
||||
;; cancel the whole series
|
||||
(let
|
||||
((res (ev/cancel-series! b store (quote nia) (quote yoga) ws we)))
|
||||
(do
|
||||
(ev-api-check! "series cancel reports four cancellations" (ev/series-count res :cancelled) 4)
|
||||
(ev-api-check!
|
||||
"actor booked into nothing after series cancel"
|
||||
(len (ev/series-booked b store (quote nia) (quote yoga) ws we))
|
||||
0)))
|
||||
;; capacity interacts per-occurrence: fill one occurrence first
|
||||
(let
|
||||
((b2 (persist/open))
|
||||
(s2
|
||||
(ev/schedule (ev/empty) (quote clinic) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1)))
|
||||
(do
|
||||
(ev/book-occ! b2 s2 (quote x) (ev-occ (quote clinic) (ev-dt 2026 6 2 9 0) 30))
|
||||
(let
|
||||
((res (ev/book-series! b2 s2 (quote nia) (quote clinic) (ev-date 2026 6 1) (ev-date 2026 6 10))))
|
||||
(do
|
||||
(ev-api-check! "series booking succeeds on free occurrences" (ev/series-count res :booked) 2)
|
||||
(ev-api-check! "series booking hits :full where capacity is taken" (ev/series-count res :full) 1)))))
|
||||
;; unknown event id
|
||||
(ev-api-check!
|
||||
"series booking an unknown event yields no results"
|
||||
(ev/book-series! b store (quote nia) (quote nope) ws we)
|
||||
(list))))))
|
||||
|
||||
(define
|
||||
ev-api-tests-run!
|
||||
(fn
|
||||
@@ -329,4 +388,5 @@
|
||||
(set! ev-api-failures (list))
|
||||
(ev-api-run-all!)
|
||||
(ev-api-cf-run-all!)
|
||||
(ev-api-sr-run-all!)
|
||||
{:failures ev-api-failures :total (+ ev-api-pass ev-api-fail) :passed ev-api-pass :failed ev-api-fail})))
|
||||
|
||||
279
lib/events/tests/ical.sx
Normal file
279
lib/events/tests/ical.sx
Normal file
@@ -0,0 +1,279 @@
|
||||
;; lib/events/tests/ical.sx — iCalendar (RFC 5545) export.
|
||||
|
||||
(define ev-ic-pass 0)
|
||||
(define ev-ic-fail 0)
|
||||
(define ev-ic-failures (list))
|
||||
|
||||
(define
|
||||
ev-ic-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-ic-pass (+ ev-ic-pass 1))
|
||||
(do
|
||||
(set! ev-ic-fail (+ ev-ic-fail 1))
|
||||
(append!
|
||||
ev-ic-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; Find the value of a "KEY:value" line in a VEVENT line list (or nil).
|
||||
(define
|
||||
ev-ic-line
|
||||
(fn
|
||||
(lines key)
|
||||
(cond
|
||||
((empty? lines) nil)
|
||||
((ev-ic-prefix? (first lines) (str key ":")) (first lines))
|
||||
(else (ev-ic-line (rest lines) key)))))
|
||||
|
||||
(define
|
||||
ev-ic-prefix?
|
||||
(fn
|
||||
(s p)
|
||||
(and (>= (len s) (len p)) (= (substring s 0 (len p)) p))))
|
||||
|
||||
(define
|
||||
ev-ic-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((lines (ev/event->ical-lines (ev-event (quote one) (ev-dt 2026 6 10 14 0) 60 nil 1))))
|
||||
(do
|
||||
(ev-ic-check! "VEVENT opens" (first lines) "BEGIN:VEVENT")
|
||||
(ev-ic-check! "VEVENT closes" (ev-ic-line lines "END") "END:VEVENT")
|
||||
(ev-ic-check!
|
||||
"UID is the event id"
|
||||
(ev-ic-line lines "UID")
|
||||
"UID:one")
|
||||
(ev-ic-check!
|
||||
"DTSTART is a UTC basic-format stamp"
|
||||
(ev-ic-line lines "DTSTART")
|
||||
"DTSTART:20260610T140000Z")
|
||||
(ev-ic-check!
|
||||
"DURATION of 60m is PT1H"
|
||||
(ev-ic-line lines "DURATION")
|
||||
"DURATION:PT1H")
|
||||
(ev-ic-check!
|
||||
"a one-off event has no RRULE"
|
||||
(ev-ic-line lines "RRULE")
|
||||
nil)))
|
||||
(ev-ic-check!
|
||||
"30m duration is PT30M"
|
||||
(ev-ic-line
|
||||
(ev/event->ical-lines
|
||||
(ev-event
|
||||
(quote e)
|
||||
(ev-dt 2026 1 1 9 0)
|
||||
30
|
||||
nil
|
||||
1))
|
||||
"DURATION")
|
||||
"DURATION:PT30M")
|
||||
(ev-ic-check!
|
||||
"90m duration is PT1H30M"
|
||||
(ev-ic-line
|
||||
(ev/event->ical-lines
|
||||
(ev-event
|
||||
(quote e)
|
||||
(ev-dt 2026 1 1 9 0)
|
||||
90
|
||||
nil
|
||||
1))
|
||||
"DURATION")
|
||||
"DURATION:PT1H30M")
|
||||
(let
|
||||
((lines (ev/event->ical-lines (ev-event-full (quote yoga) (ev-dt 2026 6 1 18 0) 90 {:interval 2 :freq :weekly :until (ev-dt 2026 6 30 23 0) :byday (list 0 2)} 20 (list (ev-dt 2026 6 8 18 0)) (list (ev-dt 2026 6 20 18 0))))))
|
||||
(do
|
||||
(ev-ic-check!
|
||||
"weekly RRULE serializes interval/until/byday in order"
|
||||
(ev-ic-line lines "RRULE")
|
||||
"RRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20260630T230000Z;BYDAY=MO,WE")
|
||||
(ev-ic-check!
|
||||
"EXDATE line"
|
||||
(ev-ic-line lines "EXDATE")
|
||||
"EXDATE:20260608T180000Z")
|
||||
(ev-ic-check!
|
||||
"RDATE line"
|
||||
(ev-ic-line lines "RDATE")
|
||||
"RDATE:20260620T180000Z")))
|
||||
(ev-ic-check!
|
||||
"daily COUNT RRULE"
|
||||
(ev-ic-line
|
||||
(ev/event->ical-lines
|
||||
(ev-event
|
||||
(quote d)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
{:freq :daily :count 5}
|
||||
1))
|
||||
"RRULE")
|
||||
"RRULE:FREQ=DAILY;COUNT=5")
|
||||
(ev-ic-check!
|
||||
"monthly nth-weekday BYDAY (2nd Tuesday)"
|
||||
(ev-ic-line
|
||||
(ev/event->ical-lines
|
||||
(ev-event
|
||||
(quote b)
|
||||
(ev-dt 2026 1 13 9 0)
|
||||
60
|
||||
{:freq :monthly :byday (list {:ord 2 :wd 1})}
|
||||
5))
|
||||
"RRULE")
|
||||
"RRULE:FREQ=MONTHLY;BYDAY=2TU")
|
||||
(ev-ic-check!
|
||||
"monthly last-Friday BYDAY"
|
||||
(ev-ic-line
|
||||
(ev/event->ical-lines
|
||||
(ev-event
|
||||
(quote b)
|
||||
(ev-dt 2026 1 30 9 0)
|
||||
60
|
||||
{:freq :monthly :byday (list {:ord -1 :wd 4})}
|
||||
5))
|
||||
"RRULE")
|
||||
"RRULE:FREQ=MONTHLY;BYDAY=-1FR")
|
||||
(ev-ic-check!
|
||||
"monthly BYMONTHDAY (incl. negative)"
|
||||
(ev-ic-line
|
||||
(ev/event->ical-lines
|
||||
(ev-event
|
||||
(quote b)
|
||||
(ev-dt 2026 1 15 9 0)
|
||||
60
|
||||
{:bymonthday (list 15 -1) :freq :monthly}
|
||||
5))
|
||||
"RRULE")
|
||||
"RRULE:FREQ=MONTHLY;BYMONTHDAY=15,-1")
|
||||
(ev-ic-check!
|
||||
"all seven weekday tokens map correctly"
|
||||
(ev-ic-line
|
||||
(ev/event->ical-lines
|
||||
(ev-event
|
||||
(quote w)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
{:freq :weekly :byday (list 0 1 2 3 4 5 6)}
|
||||
1))
|
||||
"RRULE")
|
||||
"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU")
|
||||
(let
|
||||
((cal (ev/events->ical-lines (list (ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 nil 1) (ev-event (quote b) (ev-dt 2026 6 2 9 0) 30 nil 1)))))
|
||||
(do
|
||||
(ev-ic-check! "VCALENDAR opens" (first cal) "BEGIN:VCALENDAR")
|
||||
(ev-ic-check!
|
||||
"VCALENDAR declares VERSION"
|
||||
(ev-ic-line cal "VERSION")
|
||||
"VERSION:2.0")
|
||||
(ev-ic-check!
|
||||
"two events -> two VEVENT blocks"
|
||||
(len (filter (fn (l) (= l "BEGIN:VEVENT")) cal))
|
||||
2)
|
||||
(ev-ic-check!
|
||||
"VCALENDAR has exactly one closing line"
|
||||
(len (filter (fn (l) (= l "END:VCALENDAR")) cal))
|
||||
1)))
|
||||
(ev-ic-check!
|
||||
"render joins lines with CRLF"
|
||||
(ev/ical-render
|
||||
(list "BEGIN:VCALENDAR" "VERSION:2.0" "END:VCALENDAR"))
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR"))))
|
||||
|
||||
;; ---- import + round-trip ----
|
||||
|
||||
;; The occurrence starts an event expands to over a fixed window.
|
||||
(define
|
||||
ev-ic-starts
|
||||
(fn
|
||||
(ev)
|
||||
(map (fn (o) (get o :start)) (ev-expand ev (ev-date 2026 1 1) (ev-date 2027 1 1)))))
|
||||
|
||||
;; Round-trip an event through export then import; true if both expand alike.
|
||||
(define
|
||||
ev-ic-roundtrips?
|
||||
(fn
|
||||
(ev)
|
||||
(= (ev-ic-starts ev) (ev-ic-starts (ev/ical-lines->event (ev/event->ical-lines ev))))))
|
||||
|
||||
(define
|
||||
ev-ic-rt-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
;; ---- field parsers ----
|
||||
(ev-ic-check! "parse DTSTART" (ev-ical-parse-dt "20260601T180000Z") (ev-dt 2026 6 1 18 0))
|
||||
(ev-ic-check! "parse DURATION PT1H30M" (ev-ical-parse-duration "PT1H30M") 90)
|
||||
(ev-ic-check! "parse DURATION PT1H" (ev-ical-parse-duration "PT1H") 60)
|
||||
(ev-ic-check! "parse DURATION PT30M" (ev-ical-parse-duration "PT30M") 30)
|
||||
(ev-ic-check! "parse plain BYDAY token" (ev-ical-parse-byday-token "MO") 0)
|
||||
(ev-ic-check! "parse ordinal BYDAY token" (ev-ical-parse-byday-token "2TU") {:ord 2 :wd 1})
|
||||
(ev-ic-check! "parse last-weekday BYDAY token" (ev-ical-parse-byday-token "-1FR") {:ord -1 :wd 4})
|
||||
|
||||
;; ---- imported event basic fields ----
|
||||
(let
|
||||
((ev (ev/ical-lines->event (ev/event->ical-lines (ev-event (quote yoga) (ev-dt 2026 6 1 18 0) 90 nil 1)))))
|
||||
(do
|
||||
(ev-ic-check! "imported id is a symbol" (get ev :id) (quote yoga))
|
||||
(ev-ic-check! "imported dtstart" (get ev :dtstart) (ev-dt 2026 6 1 18 0))
|
||||
(ev-ic-check! "imported duration" (get ev :duration) 90)))
|
||||
|
||||
;; ---- round-trips preserve the occurrence set ----
|
||||
(ev-ic-check!
|
||||
"round-trip: one-off event"
|
||||
(ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 6 10 14 0) 60 nil 1))
|
||||
true)
|
||||
(ev-ic-check!
|
||||
"round-trip: daily COUNT"
|
||||
(ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 5} 1))
|
||||
true)
|
||||
(ev-ic-check!
|
||||
"round-trip: weekly interval/until/byday + exdate + rdate"
|
||||
(ev-ic-roundtrips?
|
||||
(ev-event-full
|
||||
(quote a)
|
||||
(ev-dt 2026 6 1 18 0)
|
||||
90
|
||||
{:freq :weekly :interval 2 :byday (list 0 2) :until (ev-dt 2026 6 30 23 0)}
|
||||
20
|
||||
(list (ev-dt 2026 6 8 18 0))
|
||||
(list (ev-dt 2026 6 20 18 0))))
|
||||
true)
|
||||
(ev-ic-check!
|
||||
"round-trip: monthly nth-weekday"
|
||||
(ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 1 13 9 0) 60 {:freq :monthly :byday (list {:ord 2 :wd 1})} 1))
|
||||
true)
|
||||
(ev-ic-check!
|
||||
"round-trip: monthly bymonthday"
|
||||
(ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 1 15 9 0) 60 {:freq :monthly :bymonthday (list 15 -1)} 1))
|
||||
true)
|
||||
|
||||
;; ---- parse a VCALENDAR with several events ----
|
||||
(let
|
||||
((cal
|
||||
(ev/events->ical-lines
|
||||
(list
|
||||
(ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1)
|
||||
(ev-event (quote b) (ev-dt 2026 6 2 10 0) 60 nil 1)))))
|
||||
(let
|
||||
((events (ev/parse-vcalendar cal)))
|
||||
(do
|
||||
(ev-ic-check! "VCALENDAR parses both events" (len events) 2)
|
||||
(ev-ic-check! "first event id" (get (first events) :id) (quote a))
|
||||
(ev-ic-check! "second event id" (get (first (rest events)) :id) (quote b))
|
||||
(ev-ic-check!
|
||||
"parsed events expand correctly"
|
||||
(ev-ic-starts (first events))
|
||||
(ev-ic-starts (ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1)))))))))
|
||||
|
||||
(define
|
||||
ev-ical-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-ic-pass 0)
|
||||
(set! ev-ic-fail 0)
|
||||
(set! ev-ic-failures (list))
|
||||
(ev-ic-run-all!)
|
||||
(ev-ic-rt-run-all!)
|
||||
{:failures ev-ic-failures :total (+ ev-ic-pass ev-ic-fail) :passed ev-ic-pass :failed ev-ic-fail})))
|
||||
@@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/events/conformance.sh` → **311/311** (Phases 1-4 + 10 ext: …timezones+DST, e2e delivery pipeline, cross-event conflict-checked booking)
|
||||
`bash lib/events/conformance.sh` → **360/360** (Phases 1-4 + 13 ext: …iCalendar export+import round-trip, whole-series booking)
|
||||
|
||||
## Ground rules
|
||||
|
||||
@@ -88,6 +88,27 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ──
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-06-07 — iCalendar import / round-trip (extension). `ical.sx` now parses
|
||||
VEVENT/VCALENDAR text back into events (`ev/ical-lines->event`,
|
||||
`ev/parse-vcalendar`): DTSTART/DURATION/RRULE (incl. ordinal BYDAY, BYMONTHDAY,
|
||||
UNTIL/COUNT/INTERVAL) and EXDATE/RDATE. Round-trip is occurrence-exact —
|
||||
export→import expands to the identical occurrence set (tested across one-off /
|
||||
daily-count / weekly+exdate+rdate / monthly-ordinal / bymonthday). Completes
|
||||
bidirectional interop. +19 tests, 360/360 green.
|
||||
- 2026-06-07 — Whole-series booking (extension). `ev/book-series!` /
|
||||
`ev/cancel-series!` apply a booking/cancel to every occurrence of one event
|
||||
in a window (e.g. RSVP the whole weekly class), returning per-occurrence
|
||||
(occ-key status) results; capacity is still enforced per occurrence (some
|
||||
:booked, some :full). Idempotent re-book (all :already). `ev/series-count`
|
||||
(tally a status), `ev/series-booked` (which occurrences the actor holds).
|
||||
+9 tests, 341/341 green. This was the last flagged feature — surface saturated.
|
||||
- 2026-06-07 — iCalendar (RFC 5545) export (extension). `ical.sx` serializes
|
||||
events to VEVENT / VCALENDAR text for import by standard clients. UTC
|
||||
basic-format stamps (YYYYMMDDTHHMM00Z), DURATION (PT#H#M), and the full RRULE
|
||||
model (FREQ/INTERVAL/COUNT/UNTIL/BYDAY incl. monthly ordinals "2TU"/"-1FR"/
|
||||
BYMONTHDAY) plus EXDATE/RDATE. Line-oriented: `ev/event->ical-lines` /
|
||||
`ev/events->ical-lines` return content lines; `ev/ical-render` joins with
|
||||
CRLF (wire format). +21 tests, 332/332 green.
|
||||
- 2026-06-07 — Cross-event conflict-checked booking (extension). Capacity is
|
||||
per-event, but `ev/book-checked!` also prevents an attendee double-booking
|
||||
THEMSELVES across different events: it consults the actor's persist-derived
|
||||
|
||||
Reference in New Issue
Block a user