@@ -320,6 +320,68 @@ impl LibDavCalDavClient {
320320 }
321321}
322322
323+ /// Build the server-side `calendar-query` REPORT that filters a collection
324+ /// by VEVENT time range.
325+ ///
326+ /// Per RFC 4791 the `time-range` element must sit inside
327+ /// `comp-filter name="VEVENT"`, nested in `comp-filter name="VCALENDAR"` —
328+ /// servers (e.g. Nextcloud) silently ignore filters at the wrong level.
329+ /// `start`/`end` must be iCalendar UTC basic format (`YYYYMMDDTHHMMSSZ`).
330+ fn build_time_range_query < ' a > (
331+ calendar_href : & ' a str ,
332+ start : & ' a str ,
333+ end : & ' a str ,
334+ ) -> Result < libdav:: caldav:: ListCalendarResources < ' a > > {
335+ libdav:: caldav:: ListCalendarResources :: new ( calendar_href)
336+ . with_component_and_time_range ( "VEVENT" , Some ( start) , Some ( end) )
337+ . map_err ( |e| Error :: Validation ( format ! ( "invalid time-range filter: {e}" ) ) )
338+ }
339+
340+ struct GetExpandedCalendarResources < ' a > {
341+ inner : libdav:: caldav:: GetCalendarResources < ' a > ,
342+ start : & ' a str ,
343+ end : & ' a str ,
344+ }
345+
346+ impl < ' a > GetExpandedCalendarResources < ' a > {
347+ fn new ( collection_href : & ' a str , hrefs : & [ String ] , start : & ' a str , end : & ' a str ) -> Self {
348+ Self {
349+ inner : libdav:: caldav:: GetCalendarResources :: new ( collection_href) . with_hrefs ( hrefs) ,
350+ start,
351+ end,
352+ }
353+ }
354+ }
355+
356+ impl libdav:: requests:: DavRequest for GetExpandedCalendarResources < ' _ > {
357+ type Error < E > = libdav:: dav:: WebDavError < E > ;
358+ type ParseError = libdav:: requests:: ParseResponseError ;
359+ type Response = libdav:: caldav:: GetCalendarResourcesResponse ;
360+
361+ fn prepare_request (
362+ & self ,
363+ ) -> std:: result:: Result < libdav:: requests:: PreparedRequest , http:: Error > {
364+ let mut request = self . inner . prepare_request ( ) ?;
365+ request. body = request. body . replacen (
366+ "<C:calendar-data/>" ,
367+ & format ! (
368+ r#"<C:calendar-data><C:expand start="{}" end="{}"/></C:calendar-data>"# ,
369+ self . start, self . end
370+ ) ,
371+ 1 ,
372+ ) ;
373+ Ok ( request)
374+ }
375+
376+ fn parse_response (
377+ & self ,
378+ parts : & http:: response:: Parts ,
379+ body : & [ u8 ] ,
380+ ) -> std:: result:: Result < Self :: Response , Self :: ParseError > {
381+ self . inner . parse_response ( parts, body)
382+ }
383+ }
384+
323385async fn resolve_uri_addresses ( uri : & http:: Uri ) -> Result < Vec < SocketAddr > > {
324386 let host = uri
325387 . host ( )
@@ -445,16 +507,61 @@ impl CalDavClient for LibDavCalDavClient {
445507 async fn list_events (
446508 & self ,
447509 calendar_href : & str ,
448- _range : Option < TimeRange > ,
510+ range : Option < TimeRange > ,
449511 ) -> Result < Vec < EventSummary > > {
450- // Fetch all calendar resources (iCal data + etags)
451- let response = self
452- . protocol (
453- "fetch calendar resources" ,
454- self . inner
455- . request ( libdav:: caldav:: GetCalendarResources :: new ( calendar_href) ) ,
456- )
457- . await ?;
512+ // With a range, ask the server which resources match first
513+ // (calendar-query REPORT with a VCALENDAR > VEVENT time-range
514+ // filter), then fetch only those via calendar-multiget. Without a
515+ // range, fetch everything in the collection.
516+ let matching_resources = match & range {
517+ Some ( r) => {
518+ let start = crate :: time_filter:: to_ical_utc ( & r. start ) ?;
519+ let end = crate :: time_filter:: to_ical_utc ( & r. end ) ?;
520+ let listed = self
521+ . protocol (
522+ "query calendar time range" ,
523+ self . inner
524+ . request ( build_time_range_query ( calendar_href, & start, & end) ?) ,
525+ )
526+ . await ?;
527+ if listed. resources . is_empty ( ) {
528+ return Ok ( Vec :: new ( ) ) ;
529+ }
530+ Some ( (
531+ start,
532+ end,
533+ listed
534+ . resources
535+ . into_iter ( )
536+ . map ( |resource| resource. href )
537+ . collect :: < Vec < _ > > ( ) ,
538+ ) )
539+ } ,
540+ None => None ,
541+ } ;
542+
543+ let response = match & matching_resources {
544+ Some ( ( start, end, hrefs) ) => {
545+ self . protocol (
546+ "fetch expanded calendar resources" ,
547+ self . inner . request ( GetExpandedCalendarResources :: new (
548+ calendar_href,
549+ hrefs,
550+ start,
551+ end,
552+ ) ) ,
553+ )
554+ . await ?
555+ } ,
556+ None => {
557+ self . protocol (
558+ "fetch calendar resources" ,
559+ self . inner
560+ . request ( libdav:: caldav:: GetCalendarResources :: new ( calendar_href) ) ,
561+ )
562+ . await ?
563+ } ,
564+ } ;
458565
459566 let mut events = Vec :: new ( ) ;
460567 for resource in & response. resources {
@@ -695,7 +802,115 @@ mod tests {
695802 hyper_util:: rt:: TokioIo ,
696803 } ;
697804
698- use super :: * ;
805+ use { super :: * , libdav:: requests:: DavRequest } ;
806+
807+ #[ test]
808+ fn time_range_query_nests_time_range_under_vcalendar_vevent ( ) {
809+ let query =
810+ build_time_range_query ( "/cal/personal/" , "20260101T000000Z" , "20260201T000000Z" )
811+ . unwrap ( ) ;
812+ let prepared = query. prepare_request ( ) . unwrap ( ) ;
813+
814+ assert_eq ! (
815+ prepared. method,
816+ http:: Method :: from_bytes( b"REPORT" ) . unwrap( )
817+ ) ;
818+ assert ! ( prepared. body. contains( concat!(
819+ r#"<C:comp-filter name="VCALENDAR">"# ,
820+ r#"<C:comp-filter name="VEVENT">"# ,
821+ r#"<C:time-range start="20260101T000000Z" end="20260201T000000Z"/>"# ,
822+ r#"</C:comp-filter></C:comp-filter>"# ,
823+ ) ) ) ;
824+ }
825+
826+ #[ test]
827+ fn expanded_resource_query_uses_requested_range ( ) {
828+ let request = GetExpandedCalendarResources :: new (
829+ "/cal/personal/" ,
830+ & [ "/cal/personal/series.ics" . to_string ( ) ] ,
831+ "20260201T000000Z" ,
832+ "20260301T000000Z" ,
833+ ) ;
834+ let prepared = request. prepare_request ( ) . unwrap ( ) ;
835+
836+ assert ! ( prepared. body. contains( concat!(
837+ r#"<C:calendar-data><C:expand start="20260201T000000Z" "# ,
838+ r#"end="20260301T000000Z"/></C:calendar-data>"# ,
839+ ) ) ) ;
840+ assert ! (
841+ prepared
842+ . body
843+ . contains( "<D:href>/cal/personal/series.ics</D:href>" )
844+ ) ;
845+ }
846+
847+ #[ test]
848+ fn expanded_resource_response_returns_recurring_occurrence_in_range ( ) {
849+ let request = GetExpandedCalendarResources :: new (
850+ "/cal/personal/" ,
851+ & [ "/cal/personal/series.ics" . to_string ( ) ] ,
852+ "20260201T000000Z" ,
853+ "20260301T000000Z" ,
854+ ) ;
855+ let response = Response :: builder ( )
856+ . status ( http:: StatusCode :: MULTI_STATUS )
857+ . body ( ( ) )
858+ . unwrap ( ) ;
859+ let ( parts, ( ) ) = response. into_parts ( ) ;
860+ let body = br#"<?xml version="1.0" encoding="utf-8"?>
861+ <multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
862+ <response>
863+ <href>/cal/personal/series.ics</href>
864+ <propstat>
865+ <prop>
866+ <getetag>"series-etag"</getetag>
867+ <C:calendar-data>BEGIN:VCALENDAR
868+ VERSION:2.0
869+ BEGIN:VEVENT
870+ UID:daily-series@example.test
871+ RECURRENCE-ID:20260205T090000Z
872+ SUMMARY:Daily standup
873+ DTSTART:20260205T090000Z
874+ DTEND:20260205T093000Z
875+ END:VEVENT
876+ END:VCALENDAR
877+ </C:calendar-data>
878+ </prop>
879+ <status>HTTP/1.1 200 OK</status>
880+ </propstat>
881+ </response>
882+ </multistatus>"# ;
883+
884+ let fetched = request. parse_response ( & parts, body) . unwrap ( ) ;
885+ let content = fetched. resources [ 0 ] . content . as_ref ( ) . unwrap ( ) ;
886+ let events =
887+ crate :: ical:: parse_events ( & content. data , & fetched. resources [ 0 ] . href , & content. etag )
888+ . unwrap ( ) ;
889+
890+ assert_eq ! ( events. len( ) , 1 ) ;
891+ assert_eq ! ( events[ 0 ] . uid. as_deref( ) , Some ( "daily-series@example.test" ) ) ;
892+ assert_eq ! ( events[ 0 ] . start. as_deref( ) , Some ( "2026-02-05T09:00:00" ) ) ;
893+ }
894+
895+ #[ test]
896+ fn time_range_query_uses_utc_z_timestamps_from_iso_input ( ) {
897+ let start = crate :: time_filter:: to_ical_utc ( "2026-01-01T02:00:00+02:00" ) . unwrap ( ) ;
898+ let end = crate :: time_filter:: to_ical_utc ( "2026-02-01" ) . unwrap ( ) ;
899+ let query = build_time_range_query ( "/cal/personal/" , & start, & end) . unwrap ( ) ;
900+ let prepared = query. prepare_request ( ) . unwrap ( ) ;
901+
902+ assert ! (
903+ prepared
904+ . body
905+ . contains( r#"<C:time-range start="20260101T000000Z" end="20260201T000000Z"/>"# )
906+ ) ;
907+ }
908+
909+ #[ test]
910+ fn time_range_query_rejects_non_utc_timestamps ( ) {
911+ let result = build_time_range_query ( "/cal/personal/" , "2026-01-01T00:00:00" , "2026-02-01" ) ;
912+ assert ! ( matches!( result, Err ( Error :: Validation ( _) ) ) ) ;
913+ }
699914
700915 #[ tokio:: test]
701916 async fn pinned_resolver_never_performs_a_second_hostname_lookup ( ) {
0 commit comments