Add CalDAV calendar client package (sigil-caldav)
New package providing a CalDAV (RFC 4791) client for interacting with calendar servers like Fastmail, Nextcloud, and Radicale. Includes iCalendar (RFC 5545) parsing and generation with basic recurrence expansion for DAILY, WEEKLY, and MONTHLY rules.
Modules: - (sigil caldav client) - connection, auth, discovery, WebDAV transport - (sigil caldav ical) - iCalendar parser/generator, UTC datetime handling - (sigil caldav calendar) - calendar listing, creation, deletion - (sigil caldav event) - event CRUD with time-range queries - (sigil caldav) - umbrella re-export module
CHANGELOG.md | 5 ++
package.sgl | 18 +++++
src/sigil/caldav.sgl | 77 +++++++++++++++++++
src/sigil/caldav/calendar.sgl | 164 ++++++++++++++++++++++++++++++++++++++++
src/sigil/caldav/client.sgl | 314 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/caldav/event.sgl | 241 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/caldav/ical.sgl | 666 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/test-caldav.sgl | 316 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 1801 insertions(+)CHANGELOG.mdadded
# sigil-caldav## 0.8.0- Initial release with CalDAV client, iCalendar parsing/generation, calendar and event operationspackage.sgladded
;;; sigil-caldav - CalDAV Calendar Client Library;;;;;; Provides a CalDAV (RFC 4791) client for interacting with;;; calendar servers like Fastmail, Nextcloud, and Radicale.(package name: "sigil-caldav" version: "0.8.0" description: "CalDAV calendar client library for Sigil" url: "https://codeberg.org/sigil/sigil" license: "BSD-3-Clause" authors: (list "David Wilson <[email protected]>") dependencies: (list (from-workspace name: "sigil-stdlib") (from-workspace name: "sigil-http") (from-workspace name: "sigil-sxml") (from-workspace name: "sigil-crypto")))src/sigil/caldav.sgladded
;;; (sigil caldav) - CalDAV Calendar Client Library;;;;;; Provides a CalDAV (RFC 4791) client for interacting with calendar;;; servers like Fastmail, Nextcloud, and Radicale. Includes iCalendar;;; (RFC 5545) parsing and generation with basic recurrence expansion.;;;;;; ```scheme;;; (import (sigil caldav));;;;;; (define client (caldav-connect;;; url: "https://caldav.fastmail.com";;; username: "[email protected]";;; password: "app-password"));;;;;; ;; List calendars;;; (map (lambda (c) (dict-ref c name:));;; (caldav-calendars client));;;;;; ;; Query this week's events;;; (define cal (car (caldav-calendars client)));;; (caldav-events client;;; calendar-href: (dict-ref cal href:);;; start: (current-second);;; end: (+ (current-second) (* 7 86400)));;;;;; ;; Create an event;;; (caldav-event-create! client;;; calendar-href: (dict-ref cal href:);;; summary: "Meeting with Alice";;; dtstart: 1710493200;;; dtend: 1710496800;;; description: "Discuss project roadmap");;; ```(define-library (sigil caldav) (import (sigil caldav client) (sigil caldav ical) (sigil caldav calendar) (sigil caldav event)) (export ;; Client (from sigil caldav client) caldav-client caldav-client? caldav-client-base-url caldav-client-auth-header caldav-client-principal-url caldav-client-calendar-home caldav-connect ;; iCalendar (from sigil caldav ical) ical-event ical-event? ical-event-uid ical-event-summary ical-event-description ical-event-dtstart ical-event-dtend ical-event-location ical-event-status ical-event-organizer ical-event-attendees ical-event-created ical-event-last-modified ical-event-all-day? ical-event-url ical-event-rrule ical-event-recurrence-id set-ical-event-summary! set-ical-event-description! set-ical-event-dtstart! set-ical-event-dtend! set-ical-event-location! set-ical-event-status! set-ical-event-url! set-ical-event-rrule! ical-parse ical-generate generate-event-uid parse-ical-datetime format-ical-datetime parse-ical-date format-ical-date ical-expand-recurrence ;; Calendar (from sigil caldav calendar) caldav-calendars caldav-calendar-by-name caldav-calendar-create! caldav-calendar-delete! ;; Event (from sigil caldav event) caldav-events caldav-event-get caldav-event-create! caldav-event-update! caldav-event-delete!))src/sigil/caldav/calendar.sgladded
;;; (sigil caldav calendar) - Calendar Listing and Management;;;;;; List, find, create, and delete calendars on a CalDAV server.(define-library (sigil caldav calendar) (import (sigil core) (sigil string) (sigil sxml) (sigil http client) (sigil caldav client)) (export caldav-calendars caldav-calendar-by-name caldav-calendar-create! caldav-calendar-delete!) (begin ;; ============================================================ ;; Calendar Listing ;; ============================================================ ;;; List all calendars on the server. ;;; ;;; Returns a list of dicts with keys: `href:`, `name:`, ;;; `description:`, `color:`, `ctag:`. ;;; ;;; ```scheme ;;; (define cals (caldav-calendars client)) ;;; (map (lambda (c) (dict-ref c name:)) cals) ;;; ``` (define (caldav-calendars client) (: caldav-client? -> list?) (let* ((home (caldav-client-calendar-home client)) (response (caldav-propfind client home '(d:propfind (@ (xmlns:d "DAV:") (xmlns:cal "urn:ietf:params:caldav") (xmlns:cs "http://calendarserver.org/ns/") (xmlns:ic "http://apple.com/ns/ical/")) (d:prop (d:displayname) (d:resourcetype) (cal:calendar-description) (ic:calendar-color) (cs:getctag))) depth: "1")) (entries (parse-multistatus response))) ;; Filter to only calendar collections (have d:resourcetype ;; containing cal:calendar), and skip the home collection itself (let loop ((remaining entries) (calendars '())) (if (null? remaining) (reverse calendars) (let ((entry (car remaining))) (if (calendar-resource? entry) (loop (cdr remaining) (cons (normalize-calendar entry) calendars)) (loop (cdr remaining) calendars))))))) ;; Check if a multistatus entry represents a calendar collection (define (calendar-resource? entry) (let ((rt (dict-ref entry (string->keyword "d:resourcetype") #f))) (and rt (pair? rt) (let loop ((items rt)) (cond ((null? items) #f) ((and (pair? (car items)) (let ((tag (symbol->string (car (car items))))) (or (equal? tag "cal:calendar") (string-contains? tag "calendar")))) #t) (else (loop (cdr items)))))))) ;; Normalize a multistatus entry into a clean calendar dict (define (normalize-calendar entry) (dict href: (dict-ref entry href: "") name: (or (dict-ref entry (string->keyword "d:displayname") #f) "") description: (dict-ref entry (string->keyword "cal:calendar-description") #f) color: (dict-ref entry (string->keyword "ic:calendar-color") #f) ctag: (dict-ref entry (string->keyword "cs:getctag") #f))) ;; ============================================================ ;; Calendar Lookup ;; ============================================================ ;;; Find a calendar by display name. ;;; ;;; Returns the calendar dict, or `#f` if not found. ;;; ;;; ```scheme ;;; (caldav-calendar-by-name client "Work") ;;; ;; => #{ href: "/dav/calendars/..." name: "Work" ... } ;;; ``` (define (caldav-calendar-by-name client name) (: caldav-client? string? -> any?) (let loop ((calendars (caldav-calendars client))) (cond ((null? calendars) #f) ((equal? (dict-ref (car calendars) name: #f) name) (car calendars)) (else (loop (cdr calendars)))))) ;; ============================================================ ;; Calendar Management ;; ============================================================ ;;; Create a new calendar on the server. ;;; ;;; Returns the href of the new calendar. ;;; ;;; ```scheme ;;; (caldav-calendar-create! client "Projects" ;;; description: "Project deadlines") ;;; ``` (define (caldav-calendar-create! client name (keys: (description #f) (color #f))) (: caldav-client? string? (description: (maybe string?)) (color: (maybe string?)) -> string?) (let* ((slug (string-downcase (string-replace name " " "-"))) (cal-url (string-append (caldav-client-calendar-home client) slug "/")) (body (string-append "<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" (sxml->xml `(cal:mkcalendar (@ (xmlns:d "DAV:") (xmlns:cal "urn:ietf:params:caldav")) (d:set (d:prop (d:displayname ,name) ,@(if description `((cal:calendar-description ,description)) '()))))))) (response (http-request 'MKCALENDAR (resolve-url (caldav-client-base-url client) cal-url) headers: (dict authorization: (caldav-client-auth-header client) content-type: "application/xml; charset=utf-8") body: body))) (unless response (error "caldav-calendar-create!: request failed")) (let ((status (http-response-status response))) (unless (or (= status 201) (= status 204)) (error "caldav-calendar-create!: unexpected status" status))) cal-url)) ;;; Delete a calendar from the server. ;;; ;;; The `calendar-href` is the href from a calendar dict. ;;; ;;; ```scheme ;;; (caldav-calendar-delete! client "/dav/calendars/user/projects/") ;;; ``` (define (caldav-calendar-delete! client calendar-href) (: caldav-client? string? -> void?) (let ((response (caldav-delete-resource client calendar-href))) (unless response (error "caldav-calendar-delete!: request failed")) (let ((status (http-response-status response))) (unless (or (= status 200) (= status 204)) (error "caldav-calendar-delete!: unexpected status" status))))) ))src/sigil/caldav/client.sgladded
;;; (sigil caldav client) - CalDAV Client Core;;;;;; Handles CalDAV server discovery, authentication, and provides;;; the WebDAV transport layer (PROPFIND, REPORT, PUT, DELETE) for;;; all CalDAV operations.(define-library (sigil caldav client) (import (sigil core) (sigil string) (sigil struct) (sigil crypto) (sigil http client) (sigil sxml) (sigil sxml reader)) (export ;; Client struct caldav-client caldav-client? caldav-client-base-url caldav-client-auth-header caldav-client-principal-url caldav-client-calendar-home ;; Connection caldav-connect ;; Transport (used by calendar.sgl and event.sgl) caldav-propfind caldav-report caldav-put caldav-delete-resource parse-multistatus resolve-url) (begin ;; ============================================================ ;; Client Record ;; ============================================================ (define-struct caldav-client (base-url string?) (auth-header string?) (principal-url default: #f mutable: #t) (calendar-home default: #f mutable: #t)) ;; ============================================================ ;; URL Resolution ;; ============================================================ ;; Resolve a potentially relative href against a base URL (define (resolve-url base-url href) (cond ;; Already absolute ((string-starts-with? href "http://") href) ((string-starts-with? href "https://") href) ;; Relative path - combine with base (else (let ((parsed (parse-url base-url))) (let ((port (url-port parsed)) (scheme (url-scheme parsed))) (string-append scheme "://" (url-host parsed) (if (and port (not (and (equal? scheme "https") (= port 443))) (not (and (equal? scheme "http") (= port 80)))) (string-append ":" (number->string port)) "") (if (string-starts-with? href "/") href (string-append "/" href)))))))) ;; ============================================================ ;; WebDAV Transport ;; ============================================================ ;; Common headers for CalDAV requests (define (auth-headers client (keys: (content-type "application/xml; charset=utf-8") (depth #f))) (let ((h (dict authorization: (caldav-client-auth-header client) content-type: content-type))) (if depth (dict-set h depth: depth) h))) ;;; Send a PROPFIND request and return parsed SXML response. ;;; ;;; The body should be an SXML tree for the PROPFIND XML body. ;;; Returns the parsed XML response as SXML. (define (caldav-propfind client url body (keys: (depth "0"))) (: caldav-client? string? any? (depth: string?) -> any?) (let* ((xml-body (string-append "<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" (sxml->xml body))) (response (http-request 'PROPFIND (resolve-url (caldav-client-base-url client) url) headers: (auth-headers client depth: depth) body: xml-body))) (unless response (error "caldav-propfind: request failed" url)) (let ((status (http-response-status response))) (unless (= status 207) (error "caldav-propfind: unexpected status" status url)) (xml->sxml (http-response-body response))))) ;;; Send a REPORT request and return parsed SXML response. (define (caldav-report client url body (keys: (depth "1"))) (: caldav-client? string? any? (depth: string?) -> any?) (let* ((xml-body (string-append "<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" (sxml->xml body))) (response (http-request 'REPORT (resolve-url (caldav-client-base-url client) url) headers: (auth-headers client depth: depth) body: xml-body))) (unless response (error "caldav-report: request failed" url)) (let ((status (http-response-status response))) (unless (= status 207) (error "caldav-report: unexpected status" status url)) (xml->sxml (http-response-body response))))) ;;; PUT a resource (iCalendar data) to a CalDAV URL. ;;; ;;; Returns the HTTP response. (define (caldav-put client url body (keys: (content-type "text/calendar; charset=utf-8") (if-none-match #f))) (: caldav-client? string? string? (content-type: string?) (if-none-match: any?) -> any?) (let ((headers (auth-headers client content-type: content-type))) (let ((headers (if if-none-match (dict-set headers if-none-match: if-none-match) headers))) (http-request 'PUT (resolve-url (caldav-client-base-url client) url) headers: headers body: body)))) ;;; DELETE a resource from a CalDAV server. (define (caldav-delete-resource client url) (: caldav-client? string? -> any?) (http-request 'DELETE (resolve-url (caldav-client-base-url client) url) headers: (dict authorization: (caldav-client-auth-header client)))) ;; ============================================================ ;; Multistatus Response Parsing ;; ============================================================ ;;; Parse a 207 Multi-Status SXML response into a list of entries. ;;; ;;; Each entry is a dict with `href:` and property values extracted ;;; from successful (200) propstat responses. (define (parse-multistatus sxml) (: list? -> list?) (let ((responses (find-elements sxml 'd:response))) (map parse-response-element responses))) ;; Parse a single d:response element into a dict (define (parse-response-element response) (let* ((href (or (find-text-content response 'd:href) "")) (propstats (find-elements response 'd:propstat)) (props (dict href: href))) ;; Only extract properties from successful propstats (let loop ((remaining propstats) (props props)) (if (null? remaining) props (let* ((ps (car remaining)) (status-text (or (find-text-content ps 'd:status) ""))) (if (string-contains? status-text "200") (let ((prop-elem (find-element ps 'd:prop))) (if prop-elem (loop (cdr remaining) (extract-properties prop-elem props)) (loop (cdr remaining) props))) (loop (cdr remaining) props))))))) ;; Extract property values from a d:prop element into a dict (define (extract-properties prop-elem props) (let loop ((children (sxml-content prop-elem)) (props props)) (if (null? children) props (let ((child (car children))) (if (sxml-element? child) (let* ((tag (symbol->string (sxml-tag child))) (key (string->keyword tag)) (value (element-value child))) (loop (cdr children) (dict-set props key value))) (loop (cdr children) props)))))) ;; Get the value of an element: text content or the element itself ;; for complex children (define (element-value elem) (let ((content (sxml-content elem))) (cond ((null? content) "") ((and (= (length content) 1) (string? (car content))) (car content)) (else content)))) ;; Find all child elements with a given tag at any depth (define (find-elements sxml tag) (let ((results '())) (define (walk node) (when (and (pair? node) (sxml-element? node)) (when (eq? (sxml-tag node) tag) (set! results (cons node results))) (for-each walk (sxml-content node)))) (walk sxml) (reverse results))) ;; Find the first child element with a given tag (define (find-element sxml tag) (let ((results (find-elements sxml tag))) (if (null? results) #f (car results)))) ;; Find the text content of the first element with a given tag (define (find-text-content sxml tag) (let ((elem (find-element sxml tag))) (and elem (let ((content (sxml-content elem))) (and (pair? content) (string? (car content)) (car content)))))) ;; ============================================================ ;; Discovery ;; ============================================================ ;;; Connect to a CalDAV server and discover endpoints. ;;; ;;; Performs CalDAV service discovery: finds the user's principal ;;; URL and calendar home set. Authenticates with HTTP Basic ;;; (username + password) or Bearer token. ;;; ;;; ```scheme ;;; (caldav-connect ;;; url: "https://caldav.fastmail.com" ;;; username: "[email protected]" ;;; password: "app-password") ;;; ;;; (caldav-connect ;;; url: "https://caldav.example.com" ;;; token: "bearer-token-here") ;;; ``` (define (caldav-connect (keys: (url #f) (username #f) (password #f) (token #f))) (: (url: string?) (username: any?) (password: any?) (token: any?) -> caldav-client?) (unless url (error "caldav-connect: url: is required")) (unless (or token (and username password)) (error "caldav-connect: token: or username: + password: required")) (let* ((auth-header (if token (string-append "Bearer " token) (string-append "Basic " (base64-encode (string-append username ":" password))))) (client (caldav-client base-url: url auth-header: auth-header))) ;; Step 1: Find current-user-principal (let* ((principal-response (caldav-propfind client url '(d:propfind (@ (xmlns:d "DAV:")) (d:prop (d:current-user-principal))))) (entries (parse-multistatus principal-response)) (principal-href (and (pair? entries) (let ((prop (dict-ref (car entries) (string->keyword "d:current-user-principal") #f))) (and prop (pair? prop) (find-text-in-tree prop)))))) (unless principal-href (error "caldav-connect: could not discover principal URL")) (set-caldav-client-principal-url! client (resolve-url url principal-href)) ;; Step 2: Find calendar-home-set (let* ((home-response (caldav-propfind client (caldav-client-principal-url client) '(d:propfind (@ (xmlns:d "DAV:") (xmlns:cal "urn:ietf:params:caldav")) (d:prop (cal:calendar-home-set))))) (home-entries (parse-multistatus home-response)) (home-href (and (pair? home-entries) (let ((prop (dict-ref (car home-entries) (string->keyword "cal:calendar-home-set") #f))) (and prop (pair? prop) (find-text-in-tree prop)))))) (unless home-href (error "caldav-connect: could not discover calendar home")) (set-caldav-client-calendar-home! client (resolve-url url home-href)) client)))) ;; Find the first text string in a nested SXML tree ;; Used to extract href text from nested elements like ;; (d:current-user-principal (d:href "/principal/")) (define (find-text-in-tree tree) (cond ((string? tree) tree) ((pair? tree) (let loop ((items tree)) (if (null? items) #f (or (find-text-in-tree (car items)) (loop (cdr items)))))) (else #f))) ))src/sigil/caldav/event.sgladded
;;; (sigil caldav event) - Event CRUD Operations;;;;;; Query, read, create, update, and delete calendar events;;; on a CalDAV server.(define-library (sigil caldav event) (import (sigil core) (sigil string) (sigil http client) (sigil caldav client) (sigil caldav ical)) (export caldav-events caldav-event-get caldav-event-create! caldav-event-update! caldav-event-delete!) (begin ;; ============================================================ ;; Event Querying ;; ============================================================ ;;; Query events from a calendar, optionally filtered by date range. ;;; ;;; Returns a list of ical-event structs with `url:` populated. ;;; Pass `start:` and `end:` as Unix timestamps to filter by time. ;;; Set `expand-recurrence?:` to `#t` to expand recurring events. ;;; ;;; ```scheme ;;; (caldav-events client ;;; calendar-href: "/dav/calendars/user/default/" ;;; start: 1710460800 ;;; end: 1711065600) ;;; ``` (define (caldav-events client (keys: (calendar-href #f) (start #f) (end #f) (expand-recurrence? #f))) (: caldav-client? (calendar-href: string?) (start: (maybe number?)) (end: (maybe number?)) (expand-recurrence?: boolean?) -> list?) (unless calendar-href (error "caldav-events: calendar-href: is required")) (let* ((time-range-filter (if (or start end) `((cal:time-range (@ ,@(if start `((start ,(format-ical-datetime start))) '()) ,@(if end `((end ,(format-ical-datetime end))) '())))) '())) (body `(cal:calendar-query (@ (xmlns:d "DAV:") (xmlns:cal "urn:ietf:params:caldav")) (d:prop (d:getetag) (cal:calendar-data)) (cal:filter (cal:comp-filter (@ (name "VCALENDAR")) (cal:comp-filter (@ (name "VEVENT")) ,@time-range-filter))))) (response (caldav-report client calendar-href body)) (entries (parse-multistatus response))) (let ((events (extract-events-from-entries entries client))) (if expand-recurrence? (expand-all-events events start end) events)))) ;; Extract ical-event structs from multistatus entries (define (extract-events-from-entries entries client) (let loop ((remaining entries) (events '())) (if (null? remaining) (reverse events) (let* ((entry (car remaining)) (href (dict-ref entry href: #f)) (cal-data (dict-ref entry (string->keyword "cal:calendar-data") #f))) (if (and href cal-data (string? cal-data)) (let ((parsed (ical-parse cal-data))) ;; Set URL on each event (for-each (lambda (ev) (set-ical-event-url! ev (resolve-url (caldav-client-base-url client) href))) parsed) (loop (cdr remaining) (append (reverse parsed) events))) (loop (cdr remaining) events)))))) ;; Expand recurrence for all events that have RRULE (define (expand-all-events events start end) (let loop ((remaining events) (result '())) (if (null? remaining) (reverse result) (let ((ev (car remaining))) (if (ical-event-rrule ev) (loop (cdr remaining) (append (reverse (ical-expand-recurrence ev start: start end: end)) result)) (loop (cdr remaining) (cons ev result))))))) ;; ============================================================ ;; Single Event Access ;; ============================================================ ;;; Get a single event by its URL. ;;; ;;; Returns an ical-event struct, or `#f` if not found. ;;; ;;; ```scheme ;;; (caldav-event-get client event-url) ;;; ``` (define (caldav-event-get client event-url) (: caldav-client? string? -> any?) (let ((response (http-get (resolve-url (caldav-client-base-url client) event-url) headers: (dict authorization: (caldav-client-auth-header client))))) (if (and response (= (http-response-status response) 200)) (let ((events (ical-parse (http-response-body response)))) (when (pair? events) (set-ical-event-url! (car events) (resolve-url (caldav-client-base-url client) event-url))) (if (pair? events) (car events) #f)) #f))) ;; ============================================================ ;; Event Creation ;; ============================================================ ;;; Create a new calendar event. ;;; ;;; Returns the created ical-event struct with its URL populated. ;;; ;;; ```scheme ;;; (caldav-event-create! client ;;; calendar-href: "/dav/calendars/user/default/" ;;; summary: "Team Meeting" ;;; dtstart: 1710493200 ;;; dtend: 1710496800 ;;; description: "Weekly sync" ;;; location: "Conference Room A") ;;; ``` (define (caldav-event-create! client (keys: (calendar-href #f) (summary "") (dtstart #f) (dtend #f) (description #f) (location #f) (status #f) (all-day? #f) (rrule #f))) (: caldav-client? (calendar-href: string?) (summary: string?) (dtstart: number?) (dtend: (maybe number?)) (description: (maybe string?)) (location: (maybe string?)) (status: (maybe string?)) (all-day?: boolean?) (rrule: (maybe string?)) -> ical-event?) (unless calendar-href (error "caldav-event-create!: calendar-href: is required")) (unless dtstart (error "caldav-event-create!: dtstart: is required")) (let* ((uid (generate-event-uid)) (event (ical-event uid: uid summary: summary dtstart: dtstart dtend: dtend description: description location: location status: status all-day?: all-day? rrule: rrule)) (ical-text (ical-generate event)) (event-url (string-append calendar-href uid ".ics")) (response (caldav-put client event-url ical-text if-none-match: "*"))) (unless response (error "caldav-event-create!: request failed")) (let ((resp-status (http-response-status response))) (unless (or (= resp-status 201) (= resp-status 204)) (error "caldav-event-create!: unexpected status" resp-status))) (set-ical-event-url! event (resolve-url (caldav-client-base-url client) event-url)) event)) ;; ============================================================ ;; Event Update ;; ============================================================ ;;; Update an existing event on the server. ;;; ;;; Takes a modified ical-event struct (with its url: set) and ;;; PUTs the updated iCalendar data to the server. ;;; ;;; ```scheme ;;; (set-ical-event-summary! event "Updated Meeting") ;;; (caldav-event-update! client event) ;;; ``` (define (caldav-event-update! client event) (: caldav-client? ical-event? -> ical-event?) (let ((event-url (ical-event-url event))) (unless event-url (error "caldav-event-update!: event has no URL")) (let* ((ical-text (ical-generate event)) (response (caldav-put client event-url ical-text))) (unless response (error "caldav-event-update!: request failed")) (let ((resp-status (http-response-status response))) (unless (or (= resp-status 200) (= resp-status 204)) (error "caldav-event-update!: unexpected status" resp-status))) event))) ;; ============================================================ ;; Event Deletion ;; ============================================================ ;;; Delete an event from the server. ;;; ;;; ```scheme ;;; (caldav-event-delete! client (ical-event-url event)) ;;; ``` (define (caldav-event-delete! client event-url) (: caldav-client? string? -> void?) (let ((response (caldav-delete-resource client event-url))) (unless response (error "caldav-event-delete!: request failed")) (let ((status (http-response-status response))) (unless (or (= status 200) (= status 204)) (error "caldav-event-delete!: unexpected status" status))))) ))src/sigil/caldav/ical.sgladded
;;; (sigil caldav ical) - iCalendar Parser and Generator;;;;;; Parses and generates iCalendar (RFC 5545) data. Provides an;;; ical-event struct for working with calendar events, and basic;;; recurrence expansion for DAILY, WEEKLY, and MONTHLY rules.(define-library (sigil caldav ical) (import (sigil core) (sigil math) (sigil string) (sigil struct) (sigil crypto)) (export ;; Event struct ical-event ical-event? ical-event-uid ical-event-summary ical-event-description ical-event-dtstart ical-event-dtend ical-event-location ical-event-status ical-event-organizer ical-event-attendees ical-event-created ical-event-last-modified ical-event-all-day? ical-event-url ical-event-rrule ical-event-recurrence-id set-ical-event-summary! set-ical-event-description! set-ical-event-dtstart! set-ical-event-dtend! set-ical-event-location! set-ical-event-status! set-ical-event-url! set-ical-event-rrule! ;; Parsing and generation ical-parse ical-generate generate-event-uid ;; Date/time conversion parse-ical-datetime format-ical-datetime parse-ical-date format-ical-date ;; Recurrence ical-expand-recurrence) (begin ;; ============================================================ ;; Event Struct ;; ============================================================ (define-struct ical-event (uid default: "unknown" mutable: #t) (summary default: "" mutable: #t) (description default: #f mutable: #t) (dtstart default: #f mutable: #t) (dtend default: #f mutable: #t) (location default: #f mutable: #t) (status default: #f mutable: #t) (organizer default: #f mutable: #t) (attendees default: '() mutable: #t) (created default: #f mutable: #t) (last-modified default: #f mutable: #t) (all-day? default: #f mutable: #t) (url default: #f mutable: #t) (rrule default: #f mutable: #t) (recurrence-id default: #f mutable: #t)) ;; ============================================================ ;; UTC Date/Time Conversion ;; ============================================================ ;; ;; Direct UTC <-> Unix timestamp conversion without localtime/mktime ;; to avoid timezone issues. (define (leap-year? y) (and (zero? (modulo y 4)) (or (not (zero? (modulo y 100))) (zero? (modulo y 400))))) (define month-days #(0 31 28 31 30 31 30 31 31 30 31 30 31)) (define (days-in-month y m) (if (and (= m 2) (leap-year? y)) 29 (vector-ref month-days m))) ;; Days from epoch (1970-01-01) to the start of a given year (define (days-to-year y) (let ((y1 (- y 1))) (- (+ (* 365 (- y 1970)) (quotient y1 4) (- (quotient y1 100)) (quotient y1 400)) ;; Subtract leap day counts for years before 1970 (+ (quotient 1969 4) (- (quotient 1969 100)) (quotient 1969 400))))) ;; Convert UTC year/month/day/hour/minute/second to Unix timestamp (define (utc->timestamp y mo d h mi s) (let loop ((m 1) (days (days-to-year y))) (if (>= m mo) (+ (* (+ days (- d 1)) 86400) (* h 3600) (* mi 60) s) (loop (+ m 1) (+ days (days-in-month y m)))))) ;; Convert Unix timestamp to UTC components (year month day hour minute second) (define (timestamp->utc ts) (let* ((total-days (quotient ts 86400)) (day-seconds (modulo ts 86400)) (h (quotient day-seconds 3600)) (mi (quotient (modulo day-seconds 3600) 60)) (s (modulo day-seconds 60))) ;; Find year (let year-loop ((y 1970) (remaining total-days)) (let ((year-days (if (leap-year? y) 366 365))) (if (< remaining year-days) ;; Find month (let month-loop ((m 1) (r remaining)) (let ((md (days-in-month y m))) (if (< r md) (list y m (+ r 1) h mi s) (month-loop (+ m 1) (- r md))))) (year-loop (+ y 1) (- remaining year-days))))))) ;;; Parse an iCalendar datetime string to a Unix timestamp. ;;; ;;; Supports both UTC datetimes (with Z suffix) and local datetimes. ;;; Local datetimes are interpreted as UTC. ;;; ;;; ```scheme ;;; (parse-ical-datetime "20240315T090000Z") ; => 1710493200 ;;; (parse-ical-datetime "20240315T090000") ; => 1710493200 ;;; ``` (define (parse-ical-datetime str) (: string? -> number?) (let ((y (string->number (substring str 0 4))) (mo (string->number (substring str 4 6))) (d (string->number (substring str 6 8))) (h (string->number (substring str 9 11))) (mi (string->number (substring str 11 13))) (s (string->number (substring str 13 15)))) (utc->timestamp y mo d h mi s))) ;;; Parse an iCalendar date string to a Unix timestamp. ;;; ;;; Returns midnight UTC on the given date. ;;; ;;; ```scheme ;;; (parse-ical-date "20240315") ; => 1710460800 ;;; ``` (define (parse-ical-date str) (: string? -> number?) (let ((y (string->number (substring str 0 4))) (mo (string->number (substring str 4 6))) (d (string->number (substring str 6 8)))) (utc->timestamp y mo d 0 0 0))) ;;; Format a Unix timestamp as an iCalendar UTC datetime string. ;;; ;;; ```scheme ;;; (format-ical-datetime 1710493200) ; => "20240315T090000Z" ;;; ``` (define (format-ical-datetime ts) (: number? -> string?) (let ((parts (timestamp->utc ts))) (string-append (pad-digits (list-ref parts 0) 4) (pad-digits (list-ref parts 1) 2) (pad-digits (list-ref parts 2) 2) "T" (pad-digits (list-ref parts 3) 2) (pad-digits (list-ref parts 4) 2) (pad-digits (list-ref parts 5) 2) "Z"))) ;;; Format a Unix timestamp as an iCalendar date string. ;;; ;;; ```scheme ;;; (format-ical-date 1710460800) ; => "20240315" ;;; ``` (define (format-ical-date ts) (: number? -> string?) (let ((parts (timestamp->utc ts))) (string-append (pad-digits (list-ref parts 0) 4) (pad-digits (list-ref parts 1) 2) (pad-digits (list-ref parts 2) 2)))) (define (pad-digits n width) (let ((s (number->string n))) (if (< (string-length s) width) (string-append (string-repeat "0" (- width (string-length s))) s) s))) ;; ============================================================ ;; iCalendar Parser ;; ============================================================ ;; Unfold iCalendar line continuations. ;; Lines starting with a space or tab are continuations of the previous line. (define (unfold-lines text) (let ((lines (string-split text "\n"))) (let loop ((remaining lines) (current #f) (result '())) (if (null? remaining) (reverse (if current (cons current result) result)) (let ((line (car remaining))) ;; Strip trailing \r (let ((line (if (and (> (string-length line) 0) (char=? (string-ref line (- (string-length line) 1)) #\return)) (substring line 0 (- (string-length line) 1)) line))) (cond ;; Continuation line (starts with space or tab) ((and (> (string-length line) 0) current (or (char=? (string-ref line 0) #\space) (char=? (string-ref line 0) #\tab))) (loop (cdr remaining) (string-append current (substring line 1 (string-length line))) result)) ;; New line (else (loop (cdr remaining) line (if current (cons current result) result)))))))))) ;; Parse a content line into (name params value) ;; A content line has the form: NAME;PARAM1=VAL1;PARAM2=VAL2:VALUE ;; or simply: NAME:VALUE (define (parse-content-line line) (let ((colon-pos (find-property-colon line))) (if colon-pos (let* ((before (substring line 0 colon-pos)) (value (substring line (+ colon-pos 1) (string-length line))) (semi-pos (string-find before ";"))) (if semi-pos (list (string-upcase (substring before 0 semi-pos)) (substring before (+ semi-pos 1) (string-length before)) value) (list (string-upcase before) "" value))) (list line "" "")))) ;; Find the colon separating property name/params from value. ;; Must skip colons inside quoted parameter values. (define (find-property-colon line) (let ((len (string-length line))) (let loop ((i 0) (in-quotes? #f)) (if (>= i len) #f (let ((c (string-ref line i))) (cond ((char=? c #\") (loop (+ i 1) (not in-quotes?))) ((and (char=? c #\:) (not in-quotes?)) i) (else (loop (+ i 1) in-quotes?)))))))) ;; Parse a DTSTART or DTEND property, checking params for VALUE=DATE (define (parse-dt-value params value) (if (string-contains? (string-upcase params) "VALUE=DATE") (cons (parse-ical-date value) #t) (cons (parse-ical-datetime value) #f))) ;; Extract a mailto: address, or return the raw value (define (parse-cal-address value) (if (string-starts-with? (string-downcase value) "mailto:") (substring value 7 (string-length value)) value)) ;;; Parse an iCalendar string into a list of ical-event structs. ;;; ;;; Extracts all VEVENT components from the VCALENDAR. Properties ;;; not directly mapped to struct fields are ignored. ;;; ;;; ```scheme ;;; (define events (ical-parse ical-string)) ;;; (ical-event-summary (car events)) ; => "Team Meeting" ;;; ``` (define (ical-parse text) (: string? -> list?) (let ((lines (unfold-lines text))) (let loop ((remaining lines) (in-event? #f) (props '()) (events '())) (if (null? remaining) (reverse events) (let* ((parsed (parse-content-line (car remaining))) (name (car parsed)) (params (cadr parsed)) (value (caddr parsed))) (cond ((equal? name "BEGIN") (if (equal? (string-upcase value) "VEVENT") (loop (cdr remaining) #t '() events) (loop (cdr remaining) in-event? props events))) ((and in-event? (equal? name "END") (equal? (string-upcase value) "VEVENT")) (loop (cdr remaining) #f '() (cons (build-event props) events))) (in-event? (loop (cdr remaining) #t (cons (list name params value) props) events)) (else (loop (cdr remaining) in-event? props events)))))))) ;; Build an ical-event from a list of (name params value) property triples (define (build-event props) (let ((ev (ical-event uid: "unknown"))) (for-each (lambda (prop) (let ((name (car prop)) (params (cadr prop)) (value (caddr prop))) (cond ((equal? name "UID") (set-ical-event-uid! ev value)) ((equal? name "SUMMARY") (set-ical-event-summary! ev value)) ((equal? name "DESCRIPTION") (set-ical-event-description! ev value)) ((equal? name "DTSTART") (let ((dt (parse-dt-value params value))) (set-ical-event-dtstart! ev (car dt)) (when (cdr dt) (set-ical-event-all-day?! ev #t)))) ((equal? name "DTEND") (let ((dt (parse-dt-value params value))) (set-ical-event-dtend! ev (car dt)))) ((equal? name "LOCATION") (set-ical-event-location! ev value)) ((equal? name "STATUS") (set-ical-event-status! ev value)) ((equal? name "ORGANIZER") (set-ical-event-organizer! ev (parse-cal-address value))) ((equal? name "ATTENDEE") (set-ical-event-attendees! ev (cons (parse-cal-address value) (ical-event-attendees ev)))) ((equal? name "CREATED") (set-ical-event-created! ev (parse-ical-datetime value))) ((equal? name "LAST-MODIFIED") (set-ical-event-last-modified! ev (parse-ical-datetime value))) ((equal? name "RRULE") (set-ical-event-rrule! ev value)) ((equal? name "RECURRENCE-ID") (let ((dt (parse-dt-value params value))) (set-ical-event-recurrence-id! ev (car dt))))))) props) ev)) ;; ============================================================ ;; iCalendar Generator ;; ============================================================ ;; Fold a long content line at 75 octets per RFC 5545 (define (fold-line line) (if (<= (string-length line) 75) line (let loop ((remaining line) (parts '())) (if (<= (string-length remaining) 75) (string-join (reverse (cons remaining parts)) "\r\n ") (loop (substring remaining 75 (string-length remaining)) (cons (substring remaining 0 75) parts)))))) ;; Emit a content line, folded if necessary (define (emit-line name value) (fold-line (string-append name ":" value))) ;;; Generate an iCalendar string from an ical-event struct. ;;; ;;; Produces a complete VCALENDAR containing a single VEVENT. ;;; ;;; ```scheme ;;; (define ics (ical-generate event)) ;;; ;; => "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n..." ;;; ``` (define (ical-generate event) (: ical-event? -> string?) (let ((lines '())) (define (add! line) (set! lines (cons line lines))) (add! "BEGIN:VCALENDAR") (add! "VERSION:2.0") (add! "PRODID:-//Sigil//CalDAV Client//EN") (add! "BEGIN:VEVENT") (add! (emit-line "UID" (ical-event-uid event))) ;; Date/time (when (ical-event-dtstart event) (if (ical-event-all-day? event) (add! (emit-line "DTSTART;VALUE=DATE" (format-ical-date (ical-event-dtstart event)))) (add! (emit-line "DTSTART" (format-ical-datetime (ical-event-dtstart event)))))) (when (ical-event-dtend event) (if (ical-event-all-day? event) (add! (emit-line "DTEND;VALUE=DATE" (format-ical-date (ical-event-dtend event)))) (add! (emit-line "DTEND" (format-ical-datetime (ical-event-dtend event)))))) ;; Text properties (when (not (string-empty? (ical-event-summary event))) (add! (emit-line "SUMMARY" (ical-event-summary event)))) (when (ical-event-description event) (add! (emit-line "DESCRIPTION" (ical-event-description event)))) (when (ical-event-location event) (add! (emit-line "LOCATION" (ical-event-location event)))) (when (ical-event-status event) (add! (emit-line "STATUS" (ical-event-status event)))) ;; People (when (ical-event-organizer event) (add! (emit-line "ORGANIZER" (string-append "mailto:" (ical-event-organizer event))))) (for-each (lambda (attendee) (add! (emit-line "ATTENDEE" (string-append "mailto:" attendee)))) (ical-event-attendees event)) ;; Timestamps (when (ical-event-created event) (add! (emit-line "CREATED" (format-ical-datetime (ical-event-created event))))) (when (ical-event-last-modified event) (add! (emit-line "LAST-MODIFIED" (format-ical-datetime (ical-event-last-modified event))))) ;; Recurrence (when (ical-event-rrule event) (add! (emit-line "RRULE" (ical-event-rrule event)))) (when (ical-event-recurrence-id event) (add! (emit-line "RECURRENCE-ID" (format-ical-datetime (ical-event-recurrence-id event))))) (add! "END:VEVENT") (add! "END:VCALENDAR") (string-join (reverse lines) "\r\n"))) ;; ============================================================ ;; UID Generation ;; ============================================================ ;;; Generate a unique event UID string. ;;; ;;; Produces a UUID-like identifier suitable for iCalendar UID fields. ;;; ;;; ```scheme ;;; (generate-event-uid) ; => "a1b2c3d4-e5f6-7890-abcd-ef1234567890" ;;; ``` (define (generate-event-uid) (: -> string?) (let ((bytes (random-bytes 16))) (string-append (bytes->hex bytes 0 4) "-" (bytes->hex bytes 4 6) "-" (bytes->hex bytes 6 8) "-" (bytes->hex bytes 8 10) "-" (bytes->hex bytes 10 16)))) (define (byte->hex b) (let ((s (number->string b 16))) (if (< (string-length s) 2) (string-append "0" s) s))) (define (bytes->hex bv start end) (let loop ((i start) (acc "")) (if (>= i end) acc (loop (+ i 1) (string-append acc (byte->hex (bytevector-u8-ref bv i))))))) ;; ============================================================ ;; Recurrence Expansion ;; ============================================================ ;; Parse an RRULE string into a dict-like alist ;; e.g. "FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=10" (define (parse-rrule rrule-str) (let ((parts (string-split rrule-str ";"))) (map (lambda (part) (let ((eq-pos (string-find part "="))) (if eq-pos (cons (substring part 0 eq-pos) (substring part (+ eq-pos 1) (string-length part))) (cons part "")))) parts))) (define (rrule-ref rrule key) (let ((pair (assoc key rrule))) (if pair (cdr pair) #f))) ;; Day-of-week abbreviation to number (0=Sunday) (define (day-abbrev->number abbrev) (cond ((equal? abbrev "SU") 0)Showing the first 500 of 667 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.
test/test-caldav.sgladded
;;; Tests for CalDAV client library (offline, no live server needed)(import (sigil test) (sigil string) (sigil caldav ical) (sigil caldav client) (sigil sxml reader));; ============================================================;; iCalendar DateTime Conversion;; ============================================================(test-group "ical-datetime" (test "parses UTC datetime" (let ((ts (parse-ical-datetime "20240315T090000Z"))) (assert-equal "20240315T090000Z" (format-ical-datetime ts)))) (test "parses local datetime (treated as UTC)" (let ((ts (parse-ical-datetime "20240315T090000"))) (assert-equal "20240315T090000Z" (format-ical-datetime ts)))) (test "round-trips midnight" (let ((ts (parse-ical-datetime "20240101T000000Z"))) (assert-equal "20240101T000000Z" (format-ical-datetime ts)))) (test "round-trips end of day" (let ((ts (parse-ical-datetime "20241231T235959Z"))) (assert-equal "20241231T235959Z" (format-ical-datetime ts)))) (test "handles leap year date" (let ((ts (parse-ical-datetime "20240229T120000Z"))) (assert-equal "20240229T120000Z" (format-ical-datetime ts)))) (test "parses date-only" (let ((ts (parse-ical-date "20240315"))) (assert-equal "20240315" (format-ical-date ts)))) (test "round-trips date-only Jan 1" (let ((ts (parse-ical-date "20240101"))) (assert-equal "20240101" (format-ical-date ts)))) (test "round-trips date-only Dec 31" (let ((ts (parse-ical-date "20241231"))) (assert-equal "20241231" (format-ical-date ts)))) (test "known timestamp value" ;; 2024-03-15 09:00:00 UTC = 1710493200 (assert-equal 1710493200 (parse-ical-datetime "20240315T090000Z"))) (test "epoch is correct" (assert-equal 0 (parse-ical-datetime "19700101T000000Z"))));; ============================================================;; iCalendar Parsing;; ============================================================(define simple-ical "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:[email protected]\r\nDTSTART:20240315T090000Z\r\nDTEND:20240315T100000Z\r\nSUMMARY:Team Meeting\r\nEND:VEVENT\r\nEND:VCALENDAR")(define detailed-ical "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:[email protected]\r\nDTSTART:20240315T090000Z\r\nDTEND:20240315T100000Z\r\nSUMMARY:Project Review\r\nDESCRIPTION:Quarterly review of all projects\r\nLOCATION:Conference Room B\r\nSTATUS:CONFIRMED\r\nORGANIZER:mailto:[email protected]\r\nATTENDEE:mailto:[email protected]\r\nATTENDEE:mailto:[email protected]\r\nCREATED:20240301T120000Z\r\nLAST-MODIFIED:20240310T080000Z\r\nEND:VEVENT\r\nEND:VCALENDAR")(define allday-ical "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:[email protected]\r\nDTSTART;VALUE=DATE:20240315\r\nDTEND;VALUE=DATE:20240316\r\nSUMMARY:Company Holiday\r\nEND:VEVENT\r\nEND:VCALENDAR")(define folded-ical "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:[email protected]\r\nDTSTART:20240315T090000Z\r\nDTEND:20240315T100000Z\r\nSUMMARY:This is a very long summary that should be folded across\r\n multiple lines in the iCalendar format\r\nEND:VEVENT\r\nEND:VCALENDAR")(define multi-event-ical "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:[email protected]\r\nDTSTART:20240315T090000Z\r\nSUMMARY:Event One\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:[email protected]\r\nDTSTART:20240316T140000Z\r\nSUMMARY:Event Two\r\nEND:VEVENT\r\nEND:VCALENDAR")(test-group "ical-parse" (test "parses simple event" (let ((events (ical-parse simple-ical))) (assert-equal 1 (length events)) (let ((ev (car events))) (assert-equal "[email protected]" (ical-event-uid ev)) (assert-equal "Team Meeting" (ical-event-summary ev)) (assert-equal (parse-ical-datetime "20240315T090000Z") (ical-event-dtstart ev)) (assert-equal (parse-ical-datetime "20240315T100000Z") (ical-event-dtend ev)) (assert-false (ical-event-all-day? ev))))) (test "parses detailed event" (let* ((events (ical-parse detailed-ical)) (ev (car events))) (assert-equal "[email protected]" (ical-event-uid ev)) (assert-equal "Project Review" (ical-event-summary ev)) (assert-equal "Quarterly review of all projects" (ical-event-description ev)) (assert-equal "Conference Room B" (ical-event-location ev)) (assert-equal "CONFIRMED" (ical-event-status ev)) (assert-equal "[email protected]" (ical-event-organizer ev)) (assert-equal '("[email protected]" "[email protected]") (ical-event-attendees ev)) (assert-true (ical-event-created ev)) (assert-true (ical-event-last-modified ev)))) (test "parses all-day event" (let* ((events (ical-parse allday-ical)) (ev (car events))) (assert-equal "[email protected]" (ical-event-uid ev)) (assert-equal "Company Holiday" (ical-event-summary ev)) (assert-true (ical-event-all-day? ev)) (assert-equal (parse-ical-date "20240315") (ical-event-dtstart ev)) (assert-equal (parse-ical-date "20240316") (ical-event-dtend ev)))) (test "handles folded lines" (let* ((events (ical-parse folded-ical)) (ev (car events))) (assert-equal "[email protected]" (ical-event-uid ev)) (assert-true (> (string-length (ical-event-summary ev)) 50)))) (test "parses multiple events" (let ((events (ical-parse multi-event-ical))) (assert-equal 2 (length events)) (assert-equal "Event One" (ical-event-summary (car events))) (assert-equal "Event Two" (ical-event-summary (cadr events))))));; ============================================================;; iCalendar Generation;; ============================================================(test-group "ical-generate" (test "generates valid iCalendar text" (let* ((ev (ical-event uid: "gen-test-1" summary: "Test Event" dtstart: (parse-ical-datetime "20240315T090000Z") dtend: (parse-ical-datetime "20240315T100000Z"))) (text (ical-generate ev))) (assert-true (string? text)) (assert-true (> (string-length text) 0)) ;; Check key content lines are present (assert-true (string-contains? text "BEGIN:VCALENDAR")) (assert-true (string-contains? text "VERSION:2.0")) (assert-true (string-contains? text "BEGIN:VEVENT")) (assert-true (string-contains? text "UID:gen-test-1")) (assert-true (string-contains? text "SUMMARY:Test Event")) (assert-true (string-contains? text "DTSTART:20240315T090000Z")) (assert-true (string-contains? text "DTEND:20240315T100000Z")) (assert-true (string-contains? text "END:VEVENT")) (assert-true (string-contains? text "END:VCALENDAR")))) (test "generates all-day event with VALUE=DATE" (let* ((ev (ical-event uid: "allday-gen-1" summary: "Holiday" dtstart: (parse-ical-date "20240315") dtend: (parse-ical-date "20240316") all-day?: #t)) (text (ical-generate ev))) (assert-true (string-contains? text "DTSTART;VALUE=DATE:20240315")) (assert-true (string-contains? text "DTEND;VALUE=DATE:20240316")))) (test "includes optional fields when set" (let* ((ev (ical-event uid: "opt-1" summary: "Full Event" dtstart: (parse-ical-datetime "20240315T090000Z") description: "A detailed description" location: "Room 42" status: "CONFIRMED" organizer: "[email protected]" attendees: '("[email protected]"))) (text (ical-generate ev))) (assert-true (string-contains? text "DESCRIPTION:A detailed description")) (assert-true (string-contains? text "LOCATION:Room 42")) (assert-true (string-contains? text "STATUS:CONFIRMED")) (assert-true (string-contains? text "ORGANIZER:mailto:[email protected]")) (assert-true (string-contains? text "ATTENDEE:mailto:[email protected]")))) (test "round-trip: parse then generate preserves key fields" (let* ((events (ical-parse simple-ical)) (ev (car events)) (text (ical-generate ev)) (events2 (ical-parse text)) (ev2 (car events2))) (assert-equal (ical-event-uid ev) (ical-event-uid ev2)) (assert-equal (ical-event-summary ev) (ical-event-summary ev2)) (assert-equal (ical-event-dtstart ev) (ical-event-dtstart ev2)) (assert-equal (ical-event-dtend ev) (ical-event-dtend ev2)))));; ============================================================;; UID Generation;; ============================================================(test-group "generate-event-uid" (test "produces non-empty string" (let ((uid (generate-event-uid))) (assert-true (string? uid)) (assert-true (> (string-length uid) 0)))) (test "produces unique values" (let ((uid1 (generate-event-uid)) (uid2 (generate-event-uid))) (assert-false (equal? uid1 uid2)))));; ============================================================;; Recurrence Expansion;; ============================================================(test-group "ical-expand-recurrence" (test "non-recurring event returns itself" (let ((ev (ical-event uid: "norec" dtstart: 1710493200 dtend: 1710496800))) (let ((expanded (ical-expand-recurrence ev))) (assert-equal 1 (length expanded))))) (test "daily recurrence with COUNT" (let ((ev (ical-event uid: "daily-1" dtstart: 1710493200 dtend: 1710496800 rrule: "FREQ=DAILY;COUNT=5"))) (let ((expanded (ical-expand-recurrence ev))) (assert-equal 5 (length expanded)) ;; Each event should be 1 day apart (assert-equal (+ 1710493200 86400) (ical-event-dtstart (cadr expanded)))))) (test "weekly recurrence with BYDAY" (let ((ev (ical-event uid: "weekly-1" dtstart: 1710144000 ;; 2024-03-11 Monday 08:00 UTC dtend: 1710147600 rrule: "FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=6"))) (let ((expanded (ical-expand-recurrence ev))) (assert-equal 6 (length expanded))))) (test "monthly recurrence with COUNT" (let ((ev (ical-event uid: "monthly-1" dtstart: 1710493200 dtend: 1710496800 rrule: "FREQ=MONTHLY;COUNT=3"))) (let ((expanded (ical-expand-recurrence ev))) (assert-equal 3 (length expanded))))) (test "daily recurrence respects date range" (let* ((start 1710493200) (ev (ical-event uid: "range-1" dtstart: start dtend: (+ start 3600) rrule: "FREQ=DAILY;COUNT=30"))) (let ((expanded (ical-expand-recurrence ev start: start end: (+ start (* 7 86400))))) ;; Should only include events within the 7-day range (assert-true (<= (length expanded) 7)) (assert-true (> (length expanded) 0))))));; ============================================================;; Multistatus Response Parsing;; ============================================================(test-group "parse-multistatus" (test "parses single response" (let* ((xml "<d:multistatus xmlns:d=\"DAV:\"><d:response><d:href>/cal/1.ics</d:href><d:propstat><d:prop><d:displayname>Test Calendar</d:displayname></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>") (sxml (xml->sxml xml)) (entries (parse-multistatus sxml))) (assert-equal 1 (length entries)) (assert-equal "/cal/1.ics" (dict-ref (car entries) href:)))) (test "parses multiple responses" (let* ((xml "<d:multistatus xmlns:d=\"DAV:\"><d:response><d:href>/cal/1.ics</d:href><d:propstat><d:prop><d:displayname>One</d:displayname></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response><d:response><d:href>/cal/2.ics</d:href><d:propstat><d:prop><d:displayname>Two</d:displayname></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>") (sxml (xml->sxml xml)) (entries (parse-multistatus sxml))) (assert-equal 2 (length entries)) (assert-equal "/cal/1.ics" (dict-ref (car entries) href:)) (assert-equal "/cal/2.ics" (dict-ref (cadr entries) href:)))) (test "extracts displayname property" (let* ((xml "<d:multistatus xmlns:d=\"DAV:\"><d:response><d:href>/cal/</d:href><d:propstat><d:prop><d:displayname>Work</d:displayname></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>") (sxml (xml->sxml xml)) (entries (parse-multistatus sxml)) (entry (car entries))) (assert-equal "Work" (dict-ref entry (string->keyword "d:displayname") #f)))) (test "skips 404 propstat" (let* ((xml "<d:multistatus xmlns:d=\"DAV:\"><d:response><d:href>/cal/</d:href><d:propstat><d:prop><d:displayname>Work</d:displayname></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><d:missing-prop/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response></d:multistatus>") (sxml (xml->sxml xml)) (entries (parse-multistatus sxml)) (entry (car entries))) ;; displayname from 200 propstat should be present (assert-equal "Work" (dict-ref entry (string->keyword "d:displayname") #f)) ;; missing-prop from 404 propstat should not be present (assert-false (dict-ref entry (string->keyword "d:missing-prop") #f)))));; ============================================================;; URL Resolution;; ============================================================(test-group "resolve-url" (test "absolute URL passes through" (assert-equal "https://example.com/path" (resolve-url "https://cal.example.com" "https://example.com/path"))) (test "relative path resolves against base" (assert-equal "https://cal.example.com/dav/calendars/" (resolve-url "https://cal.example.com" "/dav/calendars/"))) (test "base URL with port" (assert-equal "https://cal.example.com:8443/dav/" (resolve-url "https://cal.example.com:8443" "/dav/"))))(run-tests)