report.rs
| 1 | //! REPORT parsing, query filters, calendar-data rendering and free-busy. |
| 2 | |
| 3 | use chrono::{DateTime, Utc}; |
| 4 | use pimdav::calcard::icalendar::ICalendar; |
| 5 | use pimdav::calcard::vcard::VCard; |
| 6 | use pimdav::filter::{matches_calendar, matches_card}; |
| 7 | use pimdav::freebusy::{Busy, busy, merge}; |
| 8 | use pimdav::render::{AddressData, address_data, calendar_data}; |
| 9 | use pimdav::report::{Refused, Report, parse}; |
| 10 | use pimdav::xml::{CALDAV, Name}; |
| 11 | use pimdav::zone::{Zone, named}; |
| 12 | |
| 13 | fn utc(s: &str) -> DateTime<Utc> { |
| 14 | format!("{s}Z").parse().unwrap() |
| 15 | } |
| 16 | |
| 17 | fn cal(body: &str) -> ICalendar { |
| 18 | ICalendar::parse(format!( |
| 19 | "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n" |
| 20 | )) |
| 21 | .unwrap() |
| 22 | } |
| 23 | |
| 24 | fn query(filter: &str) -> String { |
| 25 | format!( |
| 26 | r#"<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"> |
| 27 | <d:prop><d:getetag/></d:prop> |
| 28 | <c:filter><c:comp-filter name="VCALENDAR">{filter}</c:comp-filter></c:filter> |
| 29 | </c:calendar-query>"# |
| 30 | ) |
| 31 | } |
| 32 | |
| 33 | /// Whether `body` matches `filter` inside the VCALENDAR comp-filter. |
| 34 | fn hit(body: &str, filter: &str) -> bool { |
| 35 | let Ok(Report::CalendarQuery { filter, .. }) = parse(query(filter).as_bytes()) else { |
| 36 | panic!("bad query {filter}"); |
| 37 | }; |
| 38 | matches_calendar(&cal(body), &filter, &Zone::Utc) |
| 39 | } |
| 40 | |
| 41 | fn range(comp: &str, start: &str, end: &str) -> String { |
| 42 | format!( |
| 43 | r#"<c:comp-filter name="{comp}"><c:time-range start="{start}" end="{end}"/></c:comp-filter>"# |
| 44 | ) |
| 45 | } |
| 46 | |
| 47 | #[test] |
| 48 | fn todo_time_ranges() { |
| 49 | let day = |comp| range(comp, "20260110T000000Z", "20260111T000000Z"); |
| 50 | let todo = |props: &str| format!("BEGIN:VTODO\r\nUID:t\r\n{props}END:VTODO\r\n"); |
| 51 | assert!(hit(&todo("DUE:20260110T170000Z\r\n"), &day("VTODO"))); |
| 52 | // RFC 4791 counts a DUE on the range end as inside. |
| 53 | assert!(hit(&todo("DUE:20260111T000000Z\r\n"), &day("VTODO"))); |
| 54 | assert!(!hit(&todo("DUE:20260111T000001Z\r\n"), &day("VTODO"))); |
| 55 | assert!(hit(&todo("COMPLETED:20260110T100000Z\r\n"), &day("VTODO"))); |
| 56 | assert!(hit(&todo("CREATED:20260101T000000Z\r\n"), &day("VTODO"))); |
| 57 | assert!(!hit(&todo("CREATED:20260201T000000Z\r\n"), &day("VTODO"))); |
| 58 | assert!(hit(&todo(""), &day("VTODO"))); |
| 59 | // DTSTART with DUE spanning the range. |
| 60 | assert!(hit( |
| 61 | &todo("DTSTART:20260101T000000Z\r\nDUE:20260201T000000Z\r\n"), |
| 62 | &day("VTODO") |
| 63 | )); |
| 64 | assert!(!hit(&todo("DTSTART:20260111T000000Z\r\n"), &day("VTODO"))); |
| 65 | } |
| 66 | |
| 67 | #[test] |
| 68 | fn alarms_fire_per_instance() { |
| 69 | let body = "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20260105T090000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY\r\n\ |
| 70 | BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT\r\n"; |
| 71 | let alarm = |start, end| { |
| 72 | format!( |
| 73 | r#"<c:comp-filter name="VEVENT">{}</c:comp-filter>"#, |
| 74 | range("VALARM", start, end) |
| 75 | ) |
| 76 | }; |
| 77 | assert!(hit(body, &alarm("20260110T084000Z", "20260110T085000Z"))); |
| 78 | assert!(!hit(body, &alarm("20260110T085000Z", "20260110T090000Z"))); |
| 79 | let from_end = body.replace("TRIGGER:-PT15M", "TRIGGER;RELATED=END:PT5M"); |
| 80 | assert!(hit( |
| 81 | &from_end, |
| 82 | &alarm("20260110T100000Z", "20260110T101000Z") |
| 83 | )); |
| 84 | } |
| 85 | |
| 86 | #[test] |
| 87 | fn freebusy_components_and_properties() { |
| 88 | let fb = "BEGIN:VFREEBUSY\r\nUID:f\r\nDTSTART:20260101T000000Z\r\nDTEND:20260102T000000Z\r\n\ |
| 89 | FREEBUSY:20260101T100000Z/PT1H\r\nEND:VFREEBUSY\r\n"; |
| 90 | assert!(hit( |
| 91 | fb, |
| 92 | &range("VFREEBUSY", "20260101T103000Z", "20260101T120000Z") |
| 93 | )); |
| 94 | assert!(!hit( |
| 95 | fb, |
| 96 | &range("VFREEBUSY", "20260101T120000Z", "20260101T130000Z") |
| 97 | )); |
| 98 | |
| 99 | let event = "BEGIN:VEVENT\r\nUID:e\r\nDTSTART;TZID=Europe/Berlin:20260101T100000\r\n\ |
| 100 | ATTENDEE;PARTSTAT=ACCEPTED;CN=\"Bob, B\":mailto:bob@example.com\r\nEND:VEVENT\r\n"; |
| 101 | let prop = |inner: &str| format!(r#"<c:comp-filter name="VEVENT">{inner}</c:comp-filter>"#); |
| 102 | let partstat = r#"<c:prop-filter name="ATTENDEE"><c:param-filter name="PARTSTAT"><c:text-match collation="i;octet">ACCEPTED</c:text-match></c:param-filter></c:prop-filter>"#; |
| 103 | assert!(hit(event, &prop(partstat))); |
| 104 | let cn = r#"<c:prop-filter name="ATTENDEE"><c:param-filter name="CN"><c:text-match>bob, b</c:text-match></c:param-filter></c:prop-filter>"#; |
| 105 | assert!(hit(event, &prop(cn))); |
| 106 | let missing = r#"<c:prop-filter name="LOCATION"><c:is-not-defined/></c:prop-filter>"#; |
| 107 | assert!(hit(event, &prop(missing))); |
| 108 | let dtstart = r#"<c:prop-filter name="DTSTART"><c:time-range start="20260101T090000Z" end="20260101T090001Z"/></c:prop-filter>"#; |
| 109 | assert!(hit(event, &prop(dtstart))); |
| 110 | let no_todo = r#"<c:comp-filter name="VTODO"><c:is-not-defined/></c:comp-filter>"#; |
| 111 | assert!(hit(event, no_todo)); |
| 112 | } |
| 113 | |
| 114 | #[test] |
| 115 | fn floating_times_use_the_query_zone() { |
| 116 | let body = |
| 117 | "BEGIN:VEVENT\r\nUID:f\r\nDTSTART:20260101T100000\r\nDURATION:PT1H\r\nEND:VEVENT\r\n"; |
| 118 | let filter = range("VEVENT", "20260101T090000Z", "20260101T093000Z"); |
| 119 | let Ok(Report::CalendarQuery { filter, .. }) = parse(query(&filter).as_bytes()) else { |
| 120 | panic!(); |
| 121 | }; |
| 122 | assert!(!matches_calendar(&cal(body), &filter, &Zone::Utc)); |
| 123 | let berlin = named("Europe/Berlin").unwrap(); |
| 124 | assert!(matches_calendar(&cal(body), &filter, &berlin)); |
| 125 | } |
| 126 | |
| 127 | #[test] |
| 128 | fn report_bodies() { |
| 129 | let body = br#"<d:sync-collection xmlns:d="DAV:"><d:sync-token/><d:sync-level>1</d:sync-level> |
| 130 | <d:limit><d:nresults>5</d:nresults></d:limit><d:prop><d:getetag/></d:prop></d:sync-collection>"#; |
| 131 | let Ok(Report::SyncCollection { token, limit, .. }) = parse(body) else { |
| 132 | panic!(); |
| 133 | }; |
| 134 | assert_eq!((token.as_str(), limit), ("", Some(5))); |
| 135 | |
| 136 | let body = br#"<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav"><c:filter><c:comp-filter name="VEVENT"/></c:filter></c:calendar-query>"#; |
| 137 | assert_eq!( |
| 138 | parse(body).unwrap_err(), |
| 139 | Refused::Condition(Name::new(CALDAV, "valid-filter")) |
| 140 | ); |
| 141 | let body = br#"<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav"><c:timezone-id>Nowhere/Else</c:timezone-id><c:filter><c:comp-filter name="VCALENDAR"/></c:filter></c:calendar-query>"#; |
| 142 | assert_eq!( |
| 143 | parse(body).unwrap_err(), |
| 144 | Refused::Condition(Name::new(CALDAV, "valid-timezone")) |
| 145 | ); |
| 146 | let body = br#"<c:free-busy-query xmlns:c="urn:ietf:params:xml:ns:caldav"><c:time-range start="20260101T000000Z"/></c:free-busy-query>"#; |
| 147 | assert_eq!(parse(body).unwrap_err(), Refused::Invalid); |
| 148 | assert!(matches!( |
| 149 | parse(b"<x:nope xmlns:x=\"urn:x\"/>"), |
| 150 | Err(Refused::Condition(_)) |
| 151 | )); |
| 152 | } |
| 153 | |
| 154 | #[test] |
| 155 | fn expand_keeps_dates_and_limit_keeps_used_overrides() { |
| 156 | let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ |
| 157 | BEGIN:VEVENT\r\nUID:d\r\nDTSTART;VALUE=DATE:20260101\r\nRRULE:FREQ=DAILY;COUNT=5\r\nEND:VEVENT\r\n\ |
| 158 | BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID;VALUE=DATE:20260102\r\nDTSTART;VALUE=DATE:20260102\r\nSUMMARY:moved\r\nEND:VEVENT\r\n\ |
| 159 | BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID;VALUE=DATE:20260104\r\nDTSTART;VALUE=DATE:20260104\r\nSUMMARY:later\r\nEND:VEVENT\r\n\ |
| 160 | END:VCALENDAR\r\n"; |
| 161 | let req = |inner: &str| { |
| 162 | let xml = format!( |
| 163 | r#"<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><c:calendar-data>{inner}</c:calendar-data></d:prop></c:calendar-multiget>"# |
| 164 | ); |
| 165 | let Ok(Report::CalendarMultiget { props, .. }) = parse(xml.as_bytes()) else { |
| 166 | panic!(); |
| 167 | }; |
| 168 | props.calendar.unwrap() |
| 169 | }; |
| 170 | let out = calendar_data( |
| 171 | body, |
| 172 | &req(r#"<c:expand start="20260102T000000Z" end="20260104T000000Z"/>"#), |
| 173 | &Zone::Utc, |
| 174 | ) |
| 175 | .unwrap(); |
| 176 | assert!(out.contains("DTSTART;VALUE=DATE:20260102"), "{out}"); |
| 177 | assert!(out.contains("RECURRENCE-ID;VALUE=DATE:20260103"), "{out}"); |
| 178 | assert!(!out.contains("RRULE") && !out.contains("20260104"), "{out}"); |
| 179 | |
| 180 | let out = calendar_data( |
| 181 | body, |
| 182 | &req(r#"<c:limit-recurrence-set start="20260102T000000Z" end="20260103T000000Z"/>"#), |
| 183 | &Zone::Utc, |
| 184 | ) |
| 185 | .unwrap(); |
| 186 | assert!( |
| 187 | out.contains("RRULE") && out.contains("moved") && !out.contains("later"), |
| 188 | "{out}" |
| 189 | ); |
| 190 | } |
| 191 | |
| 192 | #[test] |
| 193 | fn address_data_converts_versions() { |
| 194 | let card = |
| 195 | "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c\r\nFN:Bob\r\nTEL;TYPE=CELL:123\r\nEND:VCARD\r\n"; |
| 196 | let same = AddressData::default(); |
| 197 | assert_eq!(address_data(card, &same), card); |
| 198 | let v4 = AddressData { |
| 199 | props: None, |
| 200 | version: Some(pimdav::calcard::vcard::VCardVersion::V4_0), |
| 201 | }; |
| 202 | assert!(address_data(card, &v4).contains("VERSION:4.0")); |
| 203 | } |
| 204 | |
| 205 | #[test] |
| 206 | fn card_filters() { |
| 207 | let card = VCard::parse( |
| 208 | "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Zoë\r\nEMAIL:z@example.com\r\nEND:VCARD\r\n", |
| 209 | ) |
| 210 | .unwrap(); |
| 211 | let parse_filter = |f: &str| { |
| 212 | let xml = format!( |
| 213 | r#"<card:addressbook-query xmlns:card="urn:ietf:params:xml:ns:carddav">{f}</card:addressbook-query>"# |
| 214 | ); |
| 215 | let Ok(Report::AddressbookQuery { filter, .. }) = parse(xml.as_bytes()) else { |
| 216 | panic!("{f}"); |
| 217 | }; |
| 218 | filter |
| 219 | }; |
| 220 | let f = parse_filter( |
| 221 | r#"<card:filter><card:prop-filter name="FN"><card:text-match match-type="starts-with">ZO</card:text-match></card:prop-filter></card:filter>"#, |
| 222 | ); |
| 223 | assert!(matches_card(&card, &f)); |
| 224 | let f = parse_filter( |
| 225 | r#"<card:filter><card:prop-filter name="FN"><card:text-match collation="i;octet">zoë</card:text-match></card:prop-filter></card:filter>"#, |
| 226 | ); |
| 227 | assert!(!matches_card(&card, &f)); |
| 228 | let f = parse_filter( |
| 229 | r#"<card:filter test="allof"><card:prop-filter name="FN"/><card:prop-filter name="TEL"/></card:filter>"#, |
| 230 | ); |
| 231 | assert!(!matches_card(&card, &f)); |
| 232 | let f = parse_filter( |
| 233 | r#"<card:filter><card:prop-filter name="FN"/><card:prop-filter name="TEL"/></card:filter>"#, |
| 234 | ); |
| 235 | assert!(matches_card(&card, &f)); |
| 236 | } |
| 237 | |
| 238 | #[test] |
| 239 | fn busy_time() { |
| 240 | let body = "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20260101T100000Z\r\nDTEND:20260101T110000Z\r\nEND:VEVENT\r\n\ |
| 241 | BEGIN:VEVENT\r\nUID:b\r\nDTSTART:20260101T103000Z\r\nDTEND:20260101T120000Z\r\nEND:VEVENT\r\n\ |
| 242 | BEGIN:VEVENT\r\nUID:c\r\nDTSTART:20260101T130000Z\r\nDTEND:20260101T140000Z\r\nSTATUS:TENTATIVE\r\nEND:VEVENT\r\n\ |
| 243 | BEGIN:VEVENT\r\nUID:d\r\nDTSTART:20260101T150000Z\r\nDTEND:20260101T160000Z\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n\ |
| 244 | BEGIN:VEVENT\r\nUID:e\r\nDTSTART:20260101T170000Z\r\nDTEND:20260101T180000Z\r\nSTATUS:CANCELLED\r\nEND:VEVENT\r\n"; |
| 245 | let range = utc("2026-01-01T00:00:00")..utc("2026-01-01T13:30:00"); |
| 246 | let got = merge(busy(&cal(body), &range, &Zone::Utc)); |
| 247 | let got: Vec<_> = got.iter().map(|p| (p.kind, p.start, p.end)).collect(); |
| 248 | assert_eq!( |
| 249 | got, |
| 250 | [ |
| 251 | ( |
| 252 | Busy::Busy, |
| 253 | utc("2026-01-01T10:00:00"), |
| 254 | utc("2026-01-01T12:00:00") |
| 255 | ), |
| 256 | ( |
| 257 | Busy::Tentative, |
| 258 | utc("2026-01-01T13:00:00"), |
| 259 | utc("2026-01-01T13:30:00") |
| 260 | ), |
| 261 | ] |
| 262 | ); |
| 263 | } |
| 264 |