From 6e89af96983a909886a4d00f77861102c071e6df Mon Sep 17 00:00:00 2001 From: mdecimus Date: Mon, 5 May 2025 12:56:34 +0200 Subject: [PATCH] CalDAV calendar-query and free-busy-query REPORT --- Cargo.lock | 1 + crates/dav-proto/Cargo.toml | 3 +- crates/dav-proto/src/parser/property.rs | 11 +- crates/dav-proto/src/schema/mod.rs | 28 +- crates/dav/src/calendar/freebusy.rs | 29 +- crates/dav/src/calendar/query.rs | 385 +++++----- crates/dav/src/calendar/update.rs | 10 +- crates/dav/src/card/query.rs | 28 +- crates/groupware/src/calendar/dates.rs | 181 ++++- crates/groupware/src/calendar/mod.rs | 19 + tests/Cargo.toml | 1 + tests/src/webdav/cal_query.rs | 977 ++++++++++++++++++++++++ tests/src/webdav/mod.rs | 9 +- tests/src/webdav/prop.rs | 8 +- 14 files changed, 1445 insertions(+), 245 deletions(-) create mode 100644 tests/src/webdav/cal_query.rs diff --git a/Cargo.lock b/Cargo.lock index 3320cfab..1fb64e18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7322,6 +7322,7 @@ dependencies = [ "base64 0.22.1", "biscuit", "bytes", + "calcard", "chrono", "common", "compact_str", diff --git a/crates/dav-proto/Cargo.toml b/crates/dav-proto/Cargo.toml index e97e6ef1..94f3629e 100644 --- a/crates/dav-proto/Cargo.toml +++ b/crates/dav-proto/Cargo.toml @@ -10,6 +10,7 @@ calcard = { path = "/Users/me/code/calcard", features = ["rkyv"] } mail-parser = "0.10.2" hyper = "1.6.0" rkyv = { version = "0.8.10", features = ["little_endian"] } +chrono = { version = "0.4.40", features = ["serde"], optional = true } [dev-dependencies] calcard = { path = "/Users/me/code/calcard", features = ["serde", "rkyv"] } @@ -18,5 +19,5 @@ serde_json = "1.0.138" chrono = { version = "0.4.40", features = ["serde"] } [features] -test_mode = [] +test_mode = ["chrono"] enterprise = [] diff --git a/crates/dav-proto/src/parser/property.rs b/crates/dav-proto/src/parser/property.rs index 13b1b2e6..d00ec135 100644 --- a/crates/dav-proto/src/parser/property.rs +++ b/crates/dav-proto/src/parser/property.rs @@ -383,13 +383,20 @@ impl Tokenizer<'_> { impl TimeRange { pub fn is_in_range(&self, match_overlap: bool, start: i64, end: i64) -> bool { + /*let c = println!( + "is_in_range ({match_overlap}): {} to {}, resource from {} to {}, result: {}", + chrono::DateTime::from_timestamp(self.start, 0).unwrap(), + chrono::DateTime::from_timestamp(self.end, 0).unwrap(), + chrono::DateTime::from_timestamp(start, 0).unwrap(), + chrono::DateTime::from_timestamp(end, 0).unwrap(), + result + );*/ if !match_overlap { // RFC4791#9.9: (start < DTEND AND end > DTSTART) self.start < end && self.end > start } else { // RFC4791#9.9: ((start < DUE) OR (start <= DTSTART)) AND ((end > DTSTART) OR (end >= DUE)) - let range = self.start..=self.end; - range.contains(&start) || range.contains(&end) + ((start < self.end) || (start <= self.start)) && (end > self.start || end >= self.end) } } diff --git a/crates/dav-proto/src/schema/mod.rs b/crates/dav-proto/src/schema/mod.rs index 0c92bcd1..4a3e2b9e 100644 --- a/crates/dav-proto/src/schema/mod.rs +++ b/crates/dav-proto/src/schema/mod.rs @@ -1454,11 +1454,27 @@ impl YesNo { impl TextMatch { pub fn matches(&self, text: &str) -> bool { - (match self.match_type { - MatchType::Equals => text == self.value, - MatchType::Contains => text.contains(&self.value), - MatchType::StartsWith => text.starts_with(&self.value), - MatchType::EndsWith => text.ends_with(&self.value), - }) ^ self.negate + match self.collation { + Collation::Octet => { + (match self.match_type { + MatchType::Equals => text == self.value, + MatchType::Contains => text.contains(&self.value), + MatchType::StartsWith => text.starts_with(&self.value), + MatchType::EndsWith => text.ends_with(&self.value), + }) ^ self.negate + } + _ => { + (match self.match_type { + MatchType::Equals => text.to_lowercase() == self.value.to_lowercase(), + MatchType::Contains => text.to_lowercase().contains(&self.value.to_lowercase()), + MatchType::StartsWith => { + text.to_lowercase().starts_with(&self.value.to_lowercase()) + } + MatchType::EndsWith => { + text.to_lowercase().ends_with(&self.value.to_lowercase()) + } + }) ^ self.negate + } + } } } diff --git a/crates/dav/src/calendar/freebusy.rs b/crates/dav/src/calendar/freebusy.rs index 7a100291..dd6c677e 100644 --- a/crates/dav/src/calendar/freebusy.rs +++ b/crates/dav/src/calendar/freebusy.rs @@ -27,7 +27,10 @@ use groupware::{calendar::CalendarEvent, hierarchy::DavHierarchy}; use http_proto::HttpResponse; use hyper::StatusCode; use jmap_proto::types::{acl::Acl, collection::Collection}; -use store::write::serialize::rkyv_deserialize; +use store::{ + ahash::AHashMap, + write::{now, serialize::rkyv_deserialize}, +}; use trc::AddContext; pub(crate) trait CalendarFreebusyRequestHandler: Sync + Send { @@ -86,8 +89,30 @@ impl CalendarFreebusyRequestHandler for Server { }; // Build FreeBusy component - let mut entries = Vec::new(); + let mut entries = Vec::with_capacity(6); if let Some(range) = request.range { + entries.push(ICalendarEntry { + name: ICalendarProperty::Dtstart, + params: vec![], + values: vec![ICalendarValue::PartialDateTime(Box::new( + PartialDateTime::from_utc_timestamp(range.start), + ))], + }); + entries.push(ICalendarEntry { + name: ICalendarProperty::Dtend, + params: vec![], + values: vec![ICalendarValue::PartialDateTime(Box::new( + PartialDateTime::from_utc_timestamp(range.end), + ))], + }); + entries.push(ICalendarEntry { + name: ICalendarProperty::Dtstamp, + params: vec![], + values: vec![ICalendarValue::PartialDateTime(Box::new( + PartialDateTime::from_utc_timestamp(now() as i64), + ))], + }); + let document_ids = resources .children(resource.document_id) .filter(|resource| { diff --git a/crates/dav/src/calendar/query.rs b/crates/dav/src/calendar/query.rs index 70a9a841..03c4e63c 100644 --- a/crates/dav/src/calendar/query.rs +++ b/crates/dav/src/calendar/query.rs @@ -4,6 +4,14 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::{ + DavError, + common::{ + CalendarFilter, DavQuery, + propfind::{PropFindItem, PropFindRequestHandler}, + uri::DavUriResource, + }, +}; use calcard::{ common::{PartialDateTime, timezone::Tz}, icalendar::{ @@ -26,21 +34,9 @@ use http_proto::HttpResponse; use hyper::StatusCode; use jmap_proto::types::{acl::Acl, collection::Collection}; use std::{fmt::Write, slice::Iter, str::FromStr}; -use store::{ - ahash::{AHashMap, AHashSet}, - write::serialize::rkyv_deserialize, -}; +use store::{ahash::AHashMap, write::serialize::rkyv_deserialize}; use trc::AddContext; -use crate::{ - DavError, - common::{ - CalendarFilter, DavQuery, - propfind::{PropFindItem, PropFindRequestHandler}, - uri::DavUriResource, - }, -}; - use super::freebusy::freebusy_in_range; pub(crate) trait CalendarQueryRequestHandler: Sync + Send { @@ -129,11 +125,23 @@ impl CalendarQueryRequestHandler for Server { } } -pub(crate) fn is_resource_in_time_range(resource: &DavResource, range: &TimeRange) -> bool { +pub(crate) fn is_resource_in_time_range(resource: &DavResource, filter: &TimeRange) -> bool { if let Some((start, end)) = resource.event_time_range() { - // Check if either the start or end of the resource is within the range - let range = range.start..=range.end; - range.contains(&start) || range.contains(&end) + /*let range_from = DateTime::from_timestamp(filter.start, 0).unwrap(); + let range_end = DateTime::from_timestamp(filter.end, 0).unwrap(); + let result = ((filter.start < end) || (filter.start <= start)) + && (filter.end > start || filter.end >= end); + + let c = println!( + "filter from {range_from} to {range_end}, resource is {} from {} to {}, result: {}", + resource.name, + DateTime::from_timestamp(start, 0).unwrap(), + DateTime::from_timestamp(end, 0).unwrap(), + result + );*/ + + ((filter.start < end) || (filter.start <= start)) + && (filter.end > start || filter.end >= end) } else { // If the resource does not have a time range, it is not in the range false @@ -215,7 +223,6 @@ pub fn try_parse_tz(tz: &Timezone) -> Option { pub(crate) struct CalendarQueryHandler { default_tz: Tz, - filtered_components: AHashSet, expanded_times: Vec>, } @@ -227,7 +234,6 @@ impl CalendarQueryHandler { ) -> Self { Self { default_tz, - filtered_components: AHashSet::new(), expanded_times: max_time_range .map(|max_time_range| { event @@ -256,11 +262,13 @@ impl CalendarQueryHandler { is_all = true; } Filter::Property { prop, op, comp } => { - let mut result = false; + let mut properties = find_components(ical, comp) + .flat_map(|(_, comp)| find_properties(comp, prop)) + .peekable(); - for (_, comp) in find_components(ical, comp) { - if let Some(entry) = find_property(comp, prop) { - result = match op { + let result = if properties.peek().is_some() { + properties.any(|entry| { + match op { FilterOp::Exists => true, FilterOp::Undefined => false, FilterOp::TextMatch(text_match) => { @@ -268,7 +276,7 @@ impl CalendarQueryHandler { for value in entry.values.iter() { if let Some(text) = value.as_text() { - if text_match.matches(&text.to_lowercase()) { + if text_match.matches(text) { matched_any = true; break; } @@ -300,15 +308,13 @@ impl CalendarQueryHandler { false } } - }; - - if result { - break; } - } - } + }) + } else { + matches!(op, FilterOp::Undefined) + }; - if result || matches!(op, FilterOp::Undefined) { + if result { matches_one = true; } else if is_all { return false; @@ -320,31 +326,31 @@ impl CalendarQueryHandler { op, comp, } => { - let mut result = false; + let mut parameters = find_components(ical, comp) + .flat_map(|(_, comp)| { + find_properties(comp, prop) + .filter_map(|entry| find_parameter(entry, param)) + }) + .peekable(); - for (_, comp) in find_components(ical, comp) { - if let Some(entry) = - find_property(comp, prop).and_then(|entry| find_parameter(entry, param)) - { - result = match op { - FilterOp::Exists => true, - FilterOp::Undefined => false, - FilterOp::TextMatch(text_match) => { - if let Some(text) = entry.as_text() { - text_match.matches(&text.to_lowercase()) - } else { - false - } + let result = if parameters.peek().is_some() { + parameters.any(|entry| match op { + FilterOp::Exists => true, + FilterOp::Undefined => false, + FilterOp::TextMatch(text_match) => { + if let Some(text) = entry.as_text() { + text_match.matches(text) + } else { + false } - FilterOp::TimeRange(_) => false, - }; - if result { - break; } - } - } + FilterOp::TimeRange(_) => false, + }) + } else { + matches!(op, FilterOp::Undefined) + }; - if result || matches!(op, FilterOp::Undefined) { + if result { matches_one = true; } else if is_all { return false; @@ -355,14 +361,13 @@ impl CalendarQueryHandler { FilterOp::Exists => find_components(ical, comp).next().is_some(), FilterOp::Undefined => find_components(ical, comp).next().is_none(), FilterOp::TimeRange(range) => { - let matching_comp_ids = find_components(ical, comp) - .map(|(id, comp)| (id as u16, &comp.component_type)) - .collect::>(); - if !matching_comp_ids.is_empty() { - let filtered_components = self - .expanded_times - .iter() - .filter(|event| { + if !matches!(comp.last(), Some(ICalendarComponentType::VAlarm)) { + let matching_comp_ids = find_components(ical, comp) + .map(|(id, comp)| (id as u16, &comp.component_type)) + .collect::>(); + + !matching_comp_ids.is_empty() + && self.expanded_times.iter().any(|event| { matching_comp_ids.get(&event.comp_id).is_some_and(|ct| { range.is_in_range( ct == &&ICalendarComponentType::VTodo, @@ -371,17 +376,32 @@ impl CalendarQueryHandler { ) }) }) - .map(|event| event.comp_id) - .collect::>(); - if self.filtered_components.is_empty() { - self.filtered_components = filtered_components; - } else { - self.filtered_components - .retain(|id| filtered_components.contains(id)); - } - !self.filtered_components.is_empty() } else { - false + let matching_comp_ids = event + .data + .alarms + .iter() + .map(|alarm| (alarm.comp_id.to_native(), alarm)) + .collect::>(); + + !matching_comp_ids.is_empty() + && self.expanded_times.iter().any(|event| { + matching_comp_ids.get(&event.comp_id).is_some_and(|ct| { + ct.alarms.iter().any(|alarm| { + alarm + .to_timestamp( + event.start, + event.end, + self.default_tz, + ) + .is_some_and(|timestamp| { + range.is_in_range( + false, timestamp, timestamp, + ) + }) + }) + }) + }) } } FilterOp::TextMatch(_) => false, @@ -403,7 +423,8 @@ impl CalendarQueryHandler { let mut out = String::with_capacity(event.size.to_native() as usize); let _v = [0.into()]; let mut component_iter: Iter<'_, rkyv::rend::u16_le> = _v.iter(); - let mut component_stack = Vec::with_capacity(4); + let mut component_stack: Vec<(&ArchivedICalendarComponent, Iter<'_, rkyv::rend::u16_le>)> = + Vec::with_capacity(4); if data.expand.is_some() { self.expanded_times @@ -420,14 +441,6 @@ impl CalendarQueryHandler { .get(component_id as usize) .unwrap(); - // Skip filtered components - if !self.filtered_components.is_empty() - && component.component_type.has_time_ranges() - && !self.filtered_components.contains(&component_id) - { - continue; - } - // Limit recurrence override if let Some(limit_recurrence) = &data.limit_recurrence { if component.is_recurrence_override() @@ -469,10 +482,12 @@ impl CalendarQueryHandler { data.properties .iter() .find(|prop| { - prop.component - .as_ref() - .is_none_or(|comp| comp == &component.component_type) - && prop.name.as_ref().is_none_or(|name| name == &entry.name) + prop.component.as_ref().is_none_or(|comp| { + comp == &component.component_type + || component_stack.iter().any(|(parent_comp, _)| { + comp == &parent_comp.component_type + }) + }) && prop.name.as_ref().is_none_or(|name| name == &entry.name) }) .map(|prop| (entry, !prop.no_value)) } @@ -481,112 +496,110 @@ impl CalendarQueryHandler { // Expand recurrences let component_name = component.component_type.as_str(); - if let Some(expand) = &data.expand { - let is_recurrent = component.is_recurrent(); - let is_recurrence_override = component.is_recurrence_override(); - if is_recurrent || is_recurrence_override { - let is_todo = component.component_type == ICalendarComponentType::VTodo; - let mut has_duration = false; - let entries = entries - .filter(|(entry, _)| match &entry.name { - ArchivedICalendarProperty::Dtstart - | ArchivedICalendarProperty::Dtend - | ArchivedICalendarProperty::Exdate - | ArchivedICalendarProperty::Exrule - | ArchivedICalendarProperty::Rdate - | ArchivedICalendarProperty::Rrule - | ArchivedICalendarProperty::RecurrenceId => false, - ArchivedICalendarProperty::Due - | ArchivedICalendarProperty::Completed - | ArchivedICalendarProperty::Created => is_recurrent, - ArchivedICalendarProperty::Duration => { - has_duration = true; - true - } - _ => true, - }) - .collect::>(); - for event in &self.expanded_times { - if event.comp_id == component_id - && expand.is_in_range(is_todo, event.start, event.end) - { - let _ = write!(&mut out, "BEGIN:{component_name}\r\n"); - - // Write DTSTART, DTEND and RECURRENCE-ID - let mut entry = ICalendarEntry { - name: ICalendarProperty::Dtstart, - params: vec![], - values: vec![ICalendarValue::PartialDateTime(Box::new( - PartialDateTime::from_utc_timestamp(event.start), - ))], - }; - let _ = entry.write_to(&mut out); - if is_recurrence_override { - entry.name = ICalendarProperty::RecurrenceId; - let _ = entry.write_to(&mut out); - } - if !has_duration { - entry.name = ICalendarProperty::Dtend; - entry.values = vec![ICalendarValue::PartialDateTime(Box::new( - PartialDateTime::from_utc_timestamp(event.end), - ))]; - let _ = entry.write_to(&mut out); - } - - // Write other component entries - for (entry, with_value) in &entries { - let _ = entry.write_to(&mut out, *with_value); - } - let _ = write!(&mut out, "END:{component_name}\r\n"); - } - } - continue; - } - } - - // Skip filtered components - if entries.peek().is_none() { - continue; - } - - let _ = write!(&mut out, "BEGIN:{component_name}\r\n"); - - if data.limit_freebusy.is_none() - || component.component_type != ICalendarComponentType::VFreebusy + if let Some(expand) = &data + .expand + .filter(|_| component.component_type.has_time_ranges()) { - for (entry, with_value) in entries { - let _ = entry.write_to(&mut out, with_value); - } - } else { - // Filter freebusy - let range = data.limit_freebusy.unwrap(); - for (entry, with_value) in entries { - if matches!(entry.name, ArchivedICalendarProperty::Freebusy) { - let mut fb_in_range = - freebusy_in_range(entry, &range, false, self.default_tz).peekable(); - if fb_in_range.peek().is_none() { - continue; - } else { - let _ = ICalendarEntry { - name: ICalendarProperty::Freebusy, - params: rkyv_deserialize(&entry.params) - .ok() - .unwrap_or_default(), - values: fb_in_range.collect(), - } - .write_to(&mut out); + let is_recurrent = component.is_recurrent(); + let is_recurrent_or_override = + is_recurrent || component.is_recurrence_override(); + let is_todo = component.component_type == ICalendarComponentType::VTodo; + let mut has_duration = false; + let entries = entries + .filter(|(entry, _)| match &entry.name { + ArchivedICalendarProperty::Dtstart + | ArchivedICalendarProperty::Dtend + | ArchivedICalendarProperty::Exdate + | ArchivedICalendarProperty::Exrule + | ArchivedICalendarProperty::Rdate + | ArchivedICalendarProperty::Rrule + | ArchivedICalendarProperty::RecurrenceId => false, + ArchivedICalendarProperty::Due + | ArchivedICalendarProperty::Completed + | ArchivedICalendarProperty::Created => is_recurrent, + ArchivedICalendarProperty::Duration => { + has_duration = true; + true } - } else { + _ => true, + }) + .collect::>(); + for event in &self.expanded_times { + if event.comp_id == component_id + && (!is_recurrent_or_override + || expand.is_in_range(is_todo, event.start, event.end)) + { + let _ = write!(&mut out, "BEGIN:{component_name}\r\n"); + + // Write DTSTART, DTEND and RECURRENCE-ID + let mut entry = ICalendarEntry { + name: ICalendarProperty::Dtstart, + params: vec![], + values: vec![ICalendarValue::PartialDateTime(Box::new( + PartialDateTime::from_utc_timestamp(event.start), + ))], + }; + let _ = entry.write_to(&mut out); + if is_recurrent_or_override { + entry.name = ICalendarProperty::RecurrenceId; + let _ = entry.write_to(&mut out); + } + if !has_duration { + entry.name = ICalendarProperty::Dtend; + entry.values = vec![ICalendarValue::PartialDateTime(Box::new( + PartialDateTime::from_utc_timestamp(event.end), + ))]; + let _ = entry.write_to(&mut out); + } + + // Write other component entries + for (entry, with_value) in &entries { + let _ = entry.write_to(&mut out, *with_value); + } + let _ = write!(&mut out, "END:{component_name}\r\n"); + } + } + } else if entries.peek().is_some() { + let _ = write!(&mut out, "BEGIN:{component_name}\r\n"); + + if data.limit_freebusy.is_none() + || component.component_type != ICalendarComponentType::VFreebusy + { + for (entry, with_value) in entries { let _ = entry.write_to(&mut out, with_value); } + } else { + // Filter freebusy + let range = data.limit_freebusy.unwrap(); + for (entry, with_value) in entries { + if matches!(entry.name, ArchivedICalendarProperty::Freebusy) { + let mut fb_in_range = + freebusy_in_range(entry, &range, false, self.default_tz) + .peekable(); + if fb_in_range.peek().is_none() { + continue; + } else { + let _ = ICalendarEntry { + name: ICalendarProperty::Freebusy, + params: rkyv_deserialize(&entry.params) + .ok() + .unwrap_or_default(), + values: fb_in_range.collect(), + } + .write_to(&mut out); + } + } else { + let _ = entry.write_to(&mut out, with_value); + } + } } - } - if !component.component_ids.is_empty() { - component_stack.push((component, component_iter)); - component_iter = component.component_ids.iter(); - } else { - let _ = write!(&mut out, "END:{component_name}\r\n"); + if !component.component_ids.is_empty() { + component_stack.push((component, component_iter)); + component_iter = component.component_ids.iter(); + } else if component.component_ids.is_empty() { + let _ = write!(&mut out, "END:{component_name}\r\n"); + } } } else if let Some((component, iter)) = component_stack.pop() { let _ = write!(&mut out, "END:{}\r\n", component.component_type.as_str()); @@ -623,11 +636,11 @@ fn find_components<'x>( } #[inline(always)] -fn find_property<'x>( +fn find_properties<'x>( comp: &'x ArchivedICalendarComponent, prop: &ICalendarProperty, -) -> Option<&'x ArchivedICalendarEntry> { - comp.entries.iter().find(|entry| &entry.name == prop) +) -> impl Iterator { + comp.entries.iter().filter(move |entry| &entry.name == prop) } #[inline(always)] diff --git a/crates/dav/src/calendar/update.rs b/crates/dav/src/calendar/update.rs index 97b523c0..ad39a85b 100644 --- a/crates/dav/src/calendar/update.rs +++ b/crates/dav/src/calendar/update.rs @@ -8,6 +8,7 @@ use std::collections::HashSet; use calcard::{ Entry, Parser, + common::timezone::Tz, icalendar::{ICalendar, ICalendarComponentType}, }; use common::{Server, auth::AccessToken}; @@ -184,7 +185,8 @@ impl CalendarUpdateRequestHandler for Server { .deserialize::() .caused_by(trc::location!())?; new_event.size = bytes.len() as u32; - new_event.data = CalendarEventData::new(ical, self.core.groupware.max_ical_instances); + new_event.data = + CalendarEventData::new(ical, Tz::Floating, self.core.groupware.max_ical_instances); // Prepare write batch let mut batch = BatchBuilder::new(); @@ -257,7 +259,11 @@ impl CalendarUpdateRequestHandler for Server { name: name.to_string(), parent_id: parent.document_id, }], - data: CalendarEventData::new(ical, self.core.groupware.max_ical_instances), + data: CalendarEventData::new( + ical, + Tz::Floating, + self.core.groupware.max_ical_instances, + ), size: bytes.len() as u32, ..Default::default() }; diff --git a/crates/dav/src/card/query.rs b/crates/dav/src/card/query.rs index 3b351c16..6babb2c2 100644 --- a/crates/dav/src/card/query.rs +++ b/crates/dav/src/card/query.rs @@ -120,8 +120,9 @@ pub(crate) fn vcard_query(card: &ArchivedVCard, filters: &AddressbookFilter) -> is_all = true; } Filter::Property { prop, op, .. } => { - let result = if let Some(entry) = find_property(card, prop) { - match op { + let mut properties = find_properties(card, prop).peekable(); + let result = if properties.peek().is_some() { + properties.any(|entry| match op { FilterOp::Exists => true, FilterOp::Undefined => false, FilterOp::TextMatch(text_match) => { @@ -129,7 +130,7 @@ pub(crate) fn vcard_query(card: &ArchivedVCard, filters: &AddressbookFilter) -> for value in entry.values.iter() { if let Some(text) = value.as_text() { - if text_match.matches(&text.to_lowercase()) { + if text_match.matches(text) { matched_any = true; break; } @@ -139,7 +140,7 @@ pub(crate) fn vcard_query(card: &ArchivedVCard, filters: &AddressbookFilter) -> matched_any } FilterOp::TimeRange(_) => false, - } + }) } else { matches!(op, FilterOp::Undefined) }; @@ -153,21 +154,22 @@ pub(crate) fn vcard_query(card: &ArchivedVCard, filters: &AddressbookFilter) -> Filter::Parameter { prop, param, op, .. } => { - let result = if let Some(entry) = - find_property(card, prop).and_then(|entry| find_parameter(entry, param)) - { - match op { + let mut properties = find_properties(card, prop) + .filter_map(|entry| find_parameter(entry, param)) + .peekable(); + let result = if properties.peek().is_some() { + properties.any(|entry| match op { FilterOp::Exists => true, FilterOp::Undefined => false, FilterOp::TextMatch(text_match) => { if let Some(text) = entry.as_text() { - text_match.matches(&text.to_lowercase()) + text_match.matches(text) } else { false } } FilterOp::TimeRange(_) => false, - } + }) } else { matches!(op, FilterOp::Undefined) }; @@ -186,13 +188,13 @@ pub(crate) fn vcard_query(card: &ArchivedVCard, filters: &AddressbookFilter) -> } #[inline(always)] -fn find_property<'x>( +fn find_properties<'x>( card: &'x ArchivedVCard, prop: &VCardPropertyWithGroup, -) -> Option<&'x ArchivedVCardEntry> { +) -> impl Iterator { card.entries .iter() - .find(|entry| entry.name == prop.name && entry.group == prop.group) + .filter(move |entry| entry.name == prop.name && entry.group == prop.group) } #[inline(always)] diff --git a/crates/groupware/src/calendar/dates.rs b/crates/groupware/src/calendar/dates.rs index 97cbbc4b..0e4caef5 100644 --- a/crates/groupware/src/calendar/dates.rs +++ b/crates/groupware/src/calendar/dates.rs @@ -4,17 +4,23 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use super::{ArchivedCalendarEventData, ArchivedTimezone, CalendarEventData, Timezone}; +use super::{ + Alarm, AlarmDelta, ArchivedAlarmDelta, ArchivedCalendarEventData, ArchivedTimezone, + CalendarEventData, Timezone, +}; use crate::calendar::ComponentTimeRange; use calcard::{ common::timezone::Tz, icalendar::{ - ICalendar, + ICalendar, ICalendarComponent, ICalendarParameter, ICalendarProperty, ICalendarValue, + Related, dates::{CalendarEvent, TimeOrDelta}, }, }; use chrono::{DateTime, TimeZone}; use dav_proto::schema::property::TimeRange; +use rkyv::time; +use std::str::FromStr; use store::{ ahash::AHashMap, write::{bitpack::BitpackIterator, key::KeySerializer}, @@ -22,11 +28,12 @@ use store::{ use utils::codec::leb128::Leb128Reader; impl CalendarEventData { - pub fn new(event: ICalendar, max_expansions: usize) -> Self { + pub fn new(ical: ICalendar, default_tz: Tz, max_expansions: usize) -> Self { let mut ranges = TimeRanges::default(); - let expanded = event.expand_dates(Tz::Floating, max_expansions); + let expanded = ical.expand_dates(default_tz, max_expansions); let mut groups: AHashMap<(u16, u16, u16, i32), Vec> = AHashMap::with_capacity(16); + let mut alarms = AHashMap::with_capacity(16); for event in expanded.events { let start_naive = event.start.naive_local(); @@ -53,8 +60,30 @@ impl CalendarEventData { ) } }; - ranges.update(start_timestamp_utc, start_timestamp_naive); - ranges.update(end_timestamp_utc, end_timestamp_naive); + + // Expand alarms + let mut min = std::cmp::min(start_timestamp_utc, end_timestamp_utc); + let mut max = std::cmp::max(start_timestamp_utc, end_timestamp_utc); + for alarm_delta in alarms.entry(event.comp_id).or_insert_with(|| { + ical.alarms_for_id(event.comp_id) + .filter_map(|alarm| alarm.expand_alarm()) + .collect::>() + .into_boxed_slice() + }) { + if let Some(alarm_time) = + alarm_delta.to_timestamp(start_timestamp_utc, end_timestamp_utc, default_tz) + { + if alarm_time < min { + min = alarm_time; + } + if alarm_time > max { + max = alarm_time; + } + } + } + + ranges.update_base_offset(start_timestamp_naive, end_timestamp_naive); + ranges.update_utc_min_max(min, max); groups .entry(( start_tz, @@ -73,14 +102,16 @@ impl CalendarEventData { // Bitpack instances let mut instance_offsets = Vec::with_capacity(instances.len()); for instance in instances { - instance_offsets.push((ranges.base_offset - instance) as u32); + debug_assert!(instance >= ranges.base_offset); + instance_offsets.push((instance - ranges.base_offset) as u32); } + KeySerializer::new(instance_offsets.len() * std::mem::size_of::()) .bitpack_sorted(&instance_offsets) .finalize() } else { KeySerializer::new(std::mem::size_of::()) - .write_leb128((ranges.base_offset - instances.first().unwrap()) as u32) + .write_leb128((instances.first().unwrap() - ranges.base_offset) as u32) .finalize() }; @@ -98,8 +129,19 @@ impl CalendarEventData { } CalendarEventData { - event, + event: ical, time_ranges: events.into_boxed_slice(), + alarms: alarms + .into_iter() + .filter_map(|(comp_id, alarms)| { + if !alarms.is_empty() { + Some(Alarm { comp_id, alarms }) + } else { + None + } + }) + .collect::>() + .into_boxed_slice(), base_offset: ranges.base_offset, base_time_utc: (ranges.min_time_utc - ranges.base_offset) as u32, duration: (ranges.max_time_utc - ranges.min_time_utc) as u32, @@ -119,9 +161,8 @@ impl ArchivedCalendarEventData { pub fn expand(&self, default_tz: Tz, limit: TimeRange) -> Option>> { let mut expansion = Vec::with_capacity(self.time_ranges.len()); let base_offset = self.base_offset.to_native(); - let expansion_limit = limit.start..=limit.end; - for range in self.time_ranges.iter() { + 'outer: for range in self.time_ranges.iter() { let instances = range.instances.as_ref(); let (offset_or_count, bytes_read) = instances.read_leb128::()?; @@ -157,14 +198,16 @@ impl ArchivedCalendarEventData { .single()? .timestamp(); - if expansion_limit.contains(&start) || expansion_limit.contains(&end) { + if ((start < limit.end) || (start <= limit.start)) + && (end > limit.start || end >= limit.end) + { expansion.push(CalendarEvent { comp_id, start, end, }); - } else if end > limit.end { - break; + } else if start > limit.end { + continue 'outer; } } } else { @@ -184,7 +227,9 @@ impl ArchivedCalendarEventData { .single()? .timestamp(); - if expansion_limit.contains(&start) || expansion_limit.contains(&end) { + if ((start < limit.end) || (start <= limit.start)) + && (end > limit.start || end >= limit.end) + { expansion.push(CalendarEvent { comp_id, start, @@ -198,7 +243,7 @@ impl ArchivedCalendarEventData { } } -#[derive(Default)] +#[derive(Default, Debug)] struct TimeRanges { max_time_utc: i64, min_time_utc: i64, @@ -206,18 +251,24 @@ struct TimeRanges { } impl TimeRanges { - pub fn update(&mut self, utc_timestamp: i64, naive_timestamp: i64) { - if utc_timestamp > self.max_time_utc { - self.max_time_utc = utc_timestamp; - } - if utc_timestamp < self.min_time_utc || self.max_time_utc == 0 { - self.min_time_utc = utc_timestamp; - } - let offset = std::cmp::min(utc_timestamp, naive_timestamp); + pub fn update_base_offset(&mut self, t1: i64, t2: i64) { + let offset = std::cmp::min(t1, t2); if offset < self.base_offset || self.base_offset == 0 { self.base_offset = offset; } } + + pub fn update_utc_min_max(&mut self, min: i64, max: i64) { + if min < self.min_time_utc || self.min_time_utc == 0 { + self.min_time_utc = min; + } + if max > self.max_time_utc { + self.max_time_utc = max; + } + if min < self.base_offset || self.base_offset == 0 { + self.base_offset = min; + } + } } impl ArchivedCalendarEventData { @@ -258,3 +309,85 @@ impl ArchivedTimezone { } } } + +pub trait ExpandAlarm { + fn expand_alarm(&self) -> Option; +} + +impl ExpandAlarm for ICalendarComponent { + fn expand_alarm(&self) -> Option { + for entry in self.entries.iter() { + if matches!(entry.name, ICalendarProperty::Trigger) { + let mut tz = None; + let mut trigger_start = true; + + for param in entry.params.iter() { + match param { + ICalendarParameter::Related(related) => { + trigger_start = matches!(related, Related::Start); + } + ICalendarParameter::Tzid(tz_id) => { + tz = Tz::from_str(tz_id).ok(); + } + _ => {} + } + } + + return match entry.values.first()? { + ICalendarValue::PartialDateTime(dt) => { + let tz = tz.unwrap_or(Tz::Floating); + + dt.to_date_time_with_tz(tz).map(|dt| { + let timestamp = dt.timestamp(); + if !dt.timezone().is_floating() { + AlarmDelta::FixedUtc(timestamp) + } else { + AlarmDelta::FixedFloating(timestamp) + } + }) + } + ICalendarValue::Duration(duration) => { + if trigger_start { + Some(AlarmDelta::Start(duration.as_seconds())) + } else { + Some(AlarmDelta::End(duration.as_seconds())) + } + } + _ => None, + }; + } + } + + None + } +} + +impl AlarmDelta { + pub fn to_timestamp(&self, start: i64, end: i64, default_tz: Tz) -> Option { + match self { + AlarmDelta::Start(delta) => Some(start + delta), + AlarmDelta::End(delta) => Some(end + delta), + AlarmDelta::FixedUtc(timestamp) => Some(*timestamp), + AlarmDelta::FixedFloating(timestamp) => default_tz + .from_local_datetime(&DateTime::from_timestamp(*timestamp, 0)?.naive_local()) + .single() + .map(|dt| dt.timestamp()), + } + } +} + +impl ArchivedAlarmDelta { + pub fn to_timestamp(&self, start: i64, end: i64, default_tz: Tz) -> Option { + match self { + ArchivedAlarmDelta::Start(delta) => Some(start + delta.to_native()), + ArchivedAlarmDelta::End(delta) => Some(end + delta.to_native()), + ArchivedAlarmDelta::FixedUtc(timestamp) => Some(timestamp.to_native()), + ArchivedAlarmDelta::FixedFloating(timestamp) => default_tz + .from_local_datetime( + &DateTime::from_timestamp(timestamp.to_native(), 0)?.naive_local(), + ) + .single() + .map(|dt| dt.timestamp()), + } + } +} diff --git a/crates/groupware/src/calendar/mod.rs b/crates/groupware/src/calendar/mod.rs index 3e7e3e0b..f458a5e5 100644 --- a/crates/groupware/src/calendar/mod.rs +++ b/crates/groupware/src/calendar/mod.rs @@ -83,11 +83,30 @@ pub struct CalendarEvent { pub struct CalendarEventData { pub event: ICalendar, pub time_ranges: Box<[ComponentTimeRange]>, + pub alarms: Box<[Alarm]>, pub base_offset: i64, pub base_time_utc: u32, pub duration: u32, } +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +#[rkyv(compare(PartialEq), derive(Debug))] +pub struct Alarm { + pub comp_id: u16, + pub alarms: Box<[AlarmDelta]>, +} + +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)] +#[rkyv(compare(PartialEq), derive(Debug))] +pub enum AlarmDelta { + Start(i64), + End(i64), + FixedUtc(i64), + FixedFloating(i64), +} + #[derive( rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, )] diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 3b5ec623..0b5b3a5f 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -28,6 +28,7 @@ imap = { path = "../crates/imap", features = ["test_mode"] } imap_proto = { path = "../crates/imap-proto" } dav = { path = "../crates/dav", features = ["test_mode"] } dav-proto = { path = "../crates/dav-proto", features = ["test_mode"] } +calcard = { path = "/Users/me/code/calcard", features = ["rkyv"] } groupware = { path = "../crates/groupware", features = ["test_mode"] } http = { path = "../crates/http", features = ["test_mode", "enterprise"] } http_proto = { path = "../crates/http-proto" } diff --git a/tests/src/webdav/cal_query.rs b/tests/src/webdav/cal_query.rs new file mode 100644 index 00000000..223937bb --- /dev/null +++ b/tests/src/webdav/cal_query.rs @@ -0,0 +1,977 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::WebDavTest; +use ahash::AHashSet; +use calcard::{ + common::timezone::Tz, + icalendar::{ICalendar, dates::CalendarEvent}, +}; +use dav_proto::schema::property::TimeRange; +use groupware::{ + DavResourceName, + calendar::{CalendarEventData, dates::ExpandAlarm}, +}; +use hyper::StatusCode; +use store::write::serialize::rkyv_unarchive; + +pub async fn test(test: &WebDavTest) { + println!("Running REPORT calendar-query tests..."); + let client = test.client("john"); + let cal_path = format!("{}/john/default/", DavResourceName::Cal.base_path()); + + #[allow(clippy::never_loop)] + for (num, ics) in [ + (1, ICAL_RFC_ABCD1_ICS), + (2, ICAL_RFC_ABCD2_ICS), + (3, ICAL_RFC_ABCD3_ICS), + (4, ICAL_RFC_ABCD4_ICS), + (5, ICAL_RFC_ABCD5_ICS), + (6, ICAL_RFC_ABCD6_ICS), + (7, ICAL_RFC_ABCD7_ICS), + (8, ICAL_RFC_ABCD8_ICS), + ] { + roundtrip_expansion(ics, false); + client + .request("PUT", &rfc_file_name(num), ics) + .await + .with_status(StatusCode::CREATED); + } + + // Test 1: Partial Retrieval of Events by Time Range + let response = client + .request("REPORT", &cal_path, REPORT_1) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([rfc_file_name(2).as_str(), rfc_file_name(3).as_str()]) + .into_propfind_response(None); + response + .properties(&rfc_file_name(2)) + .calendar_data() + .with_values([REPORT_1_EXPECTED_ABCD2.replace('\n', "\r\n").as_str()]); + response + .properties(&rfc_file_name(3)) + .calendar_data() + .with_values([REPORT_1_EXPECTED_ABCD3.replace('\n', "\r\n").as_str()]); + + // Test 2: Partial Retrieval of Recurring Events + let response = client + .request("REPORT", &cal_path, REPORT_2) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([rfc_file_name(2).as_str(), rfc_file_name(3).as_str()]) + .into_propfind_response(None); + response + .properties(&rfc_file_name(2)) + .calendar_data() + .with_values([REPORT_2_EXPECTED_ABCD2.replace('\n', "\r\n").as_str()]); + response + .properties(&rfc_file_name(3)) + .calendar_data() + .with_values([REPORT_2_EXPECTED_ABCD3.replace('\n', "\r\n").as_str()]); + + // Test 3: Expanded Retrieval of Recurring Events + let response = client + .request("REPORT", &cal_path, REPORT_3) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([rfc_file_name(2).as_str(), rfc_file_name(3).as_str()]) + .into_propfind_response(None); + response + .properties(&rfc_file_name(2)) + .calendar_data() + .with_values([REPORT_3_EXPECTED_ABCD2.replace('\n', "\r\n").as_str()]); + response + .properties(&rfc_file_name(3)) + .calendar_data() + .with_values([REPORT_3_EXPECTED_ABCD3.replace('\n', "\r\n").as_str()]); + + // Test 4: Partial Retrieval of Stored Free Busy Components + let response = client + .request("REPORT", &cal_path, REPORT_4) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([rfc_file_name(8).as_str()]) + .into_propfind_response(None); + response + .properties(&rfc_file_name(8)) + .calendar_data() + .with_values([REPORT_4_EXPECTED_ABCD8.replace('\n', "\r\n").as_str()]); + + // Test 5: Retrieval of To-Dos by Alarm Time Range + let response = client + .request("REPORT", &cal_path, REPORT_5) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([rfc_file_name(5).as_str()]) + .into_propfind_response(None); + response + .properties(&rfc_file_name(5)) + .calendar_data() + .with_values([ICAL_RFC_ABCD5_ICS.replace('\n', "\r\n").as_str()]); + + // Test 6: Retrieval of Event by UID + client + .request("REPORT", &cal_path, REPORT_6) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([rfc_file_name(3).as_str()]) + .into_propfind_response(None); + + // Test 7: Retrieval of Events by PARTSTAT + client + .request("REPORT", &cal_path, REPORT_7) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([rfc_file_name(3).as_str()]) + .into_propfind_response(None); + + // Test 8: Retrieval of Events Only + client + .request("REPORT", &cal_path, REPORT_8) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([ + rfc_file_name(1).as_str(), + rfc_file_name(2).as_str(), + rfc_file_name(3).as_str(), + ]) + .into_propfind_response(None); + + // Test 9: Retrieval of All Pending To-Dos + client + .request("REPORT", &cal_path, REPORT_9) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_hrefs([rfc_file_name(4).as_str(), rfc_file_name(5).as_str()]) + .into_propfind_response(None); + + // Test 10: Successful CALDAV:free-busy-query REPORT + assert_eq!( + remove_dtstamp( + client + .request("REPORT", &cal_path, REPORT_10) + .await + .with_status(StatusCode::OK) + .body + .as_ref() + .unwrap() + ), + remove_dtstamp(REPORT_10_RESPONSE) + ); + assert_eq!( + remove_dtstamp( + client + .request("REPORT", &cal_path, REPORT_11) + .await + .with_status(StatusCode::OK) + .body + .as_ref() + .unwrap() + ), + remove_dtstamp(REPORT_11_RESPONSE) + ); + + client.delete_default_containers().await; + test.assert_is_empty().await; +} + +#[test] +#[ignore] +fn ical_roundtrip_expansion() { + for entry in std::fs::read_dir("/Users/me/code/calcard/resources/ical").unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "ics") { + println!("Testing: {:?}", path); + let input = match String::from_utf8(std::fs::read(&path).unwrap()) { + Ok(input) => input, + Err(err) => { + // ISO-8859-1 + err.as_bytes() + .iter() + .map(|&b| b as char) + .collect::() + } + }; + roundtrip_expansion(&input, true); + } + } +} + +fn roundtrip_expansion(ics: &str, ignore_errors: bool) { + let ical = if let Ok(ical) = ICalendar::parse(ics) { + ical + } else if ignore_errors { + return; + } else { + panic!("Failed to parse ICalendar {}", ics); + }; + let expanded = ical.expand_dates(Tz::UTC, 100); + if !ignore_errors { + assert!(expanded.errors.is_empty()); + } + let mut min_utc = i64::MAX; + let mut max_utc = i64::MIN; + let mut events = expanded + .events + .into_iter() + .map(|e| { + let e = e.try_into_date_time().unwrap(); + let start = e.start.timestamp(); + let end = e.end.timestamp(); + let mut min = std::cmp::min(start, end); + let mut max = std::cmp::max(start, end); + + for alarm in ical.alarms_for_id(e.comp_id) { + if let Some(alarm_time) = alarm + .expand_alarm() + .and_then(|delta| delta.to_timestamp(start, end, Tz::UTC)) + { + if alarm_time < min { + min = alarm_time; + } + + if alarm_time > max { + max = alarm_time; + } + } + } + + if min < min_utc { + min_utc = min; + } + if max > max_utc { + max_utc = max; + } + CalendarEvent { + comp_id: e.comp_id, + start, + end, + } + }) + .collect::>(); + + // Verify min/max UTC timestamps + let event_data = CalendarEventData::new(ical, Tz::UTC, 100); + let from_time = event_data.base_time_utc as i64 + event_data.base_offset; + let to_time = from_time + event_data.duration as i64; + + if min_utc != i64::MAX { + assert_eq!( + from_time, + min_utc, + "diff: {}, failed for {}", + from_time - min_utc, + ics + ); + assert_eq!( + to_time, + max_utc, + "diff: {}, failed for {}", + to_time - max_utc, + ics + ); + } + + // Validate archive expansion + let expanded_bytes = rkyv::to_bytes::(&event_data).unwrap(); + let expanded_archive = rkyv_unarchive::(&expanded_bytes).unwrap(); + let mut events_archive = expanded_archive + .expand( + Tz::UTC, + TimeRange { + start: i64::MIN, + end: i64::MAX, + }, + ) + .unwrap(); + events.sort_by(|a, b| { + if a.comp_id == b.comp_id { + a.start.cmp(&b.start) + } else { + a.comp_id.cmp(&b.comp_id) + } + }); + events_archive.sort_by(|a, b| { + if a.comp_id == b.comp_id { + a.start.cmp(&b.start) + } else { + a.comp_id.cmp(&b.comp_id) + } + }); + + assert_eq!(events, events_archive); +} + +fn rfc_file_name(num: usize) -> String { + format!( + "{}/john/default/abcd{num}.ics", + DavResourceName::Cal.base_path() + ) +} + +const REPORT_1: &str = r#" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"#; + +const REPORT_1_EXPECTED_ABCD2: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060102T120000 +DURATION:PT1H +RRULE:FREQ=DAILY;COUNT=5 +SUMMARY:Event #2 +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060104T140000 +DURATION:PT1H +RECURRENCE-ID;TZID=US/Eastern:20060104T120000 +SUMMARY:Event #2 bis +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060106T140000 +DURATION:PT1H +RECURRENCE-ID;TZID=US/Eastern:20060106T120000 +SUMMARY:Event #2 bis bis +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +END:VCALENDAR +"#; + +const REPORT_1_EXPECTED_ABCD3: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060104T100000 +DURATION:PT1H +SUMMARY:Event #3 +UID:DC6C50A017428C5216A2F1CD@example.com +END:VEVENT +END:VCALENDAR +"#; + +const REPORT_2: &str = r#" + + + + + + + + + + + + + + +"#; + +const REPORT_2_EXPECTED_ABCD2: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTAMP:20060206T001121Z +DTSTART;TZID=US/Eastern:20060102T120000 +DURATION:PT1H +RRULE:FREQ=DAILY;COUNT=5 +SUMMARY:Event #2 +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +BEGIN:VEVENT +DTSTAMP:20060206T001121Z +DTSTART;TZID=US/Eastern:20060104T140000 +DURATION:PT1H +RECURRENCE-ID;TZID=US/Eastern:20060104T120000 +SUMMARY:Event #2 bis +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +END:VCALENDAR +"#; + +const REPORT_2_EXPECTED_ABCD3: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com +DTSTAMP:20060206T001220Z +DTSTART;TZID=US/Eastern:20060104T100000 +DURATION:PT1H +LAST-MODIFIED:20060206T001330Z +ORGANIZER:mailto:cyrus@example.com +SEQUENCE:1 +STATUS:TENTATIVE +SUMMARY:Event #3 +UID:DC6C50A017428C5216A2F1CD@example.com +X-ABC-GUID:E1CX5Dr-0007ym-Hz@example.com +END:VEVENT +END:VCALENDAR +"#; + +const REPORT_3: &str = r#" + + + + + + + + + + + + + + + + + +"#; + +const REPORT_3_EXPECTED_ABCD2: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +DTSTART:20060103T170000Z +RECURRENCE-ID:20060103T170000Z +DTSTAMP:20060206T001121Z +DURATION:PT1H +SUMMARY:Event #2 +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +BEGIN:VEVENT +DTSTART:20060104T190000Z +RECURRENCE-ID:20060104T190000Z +DTSTAMP:20060206T001121Z +DURATION:PT1H +SUMMARY:Event #2 bis +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +END:VCALENDAR +"#; + +const REPORT_3_EXPECTED_ABCD3: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +DTSTART:20060104T150000Z +ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com +DTSTAMP:20060206T001220Z +DURATION:PT1H +LAST-MODIFIED:20060206T001330Z +ORGANIZER:mailto:cyrus@example.com +SEQUENCE:1 +STATUS:TENTATIVE +SUMMARY:Event #3 +UID:DC6C50A017428C5216A2F1CD@example.com +X-ABC-GUID:E1CX5Dr-0007ym-Hz@example.com +END:VEVENT +END:VCALENDAR +"#; + +const REPORT_4: &str = r#" + + + + + + + + + + + + + + +"#; + +const REPORT_4_EXPECTED_ABCD8: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VFREEBUSY +ORGANIZER;CN="Bernard Desruisseaux":mailto:bernard@example.com +UID:76ef34-54a3d2@example.com +DTSTAMP:20050530T123421Z +DTSTART:20060101T000000Z +DTEND:20060108T000000Z +FREEBUSY;FBTYPE=BUSY-TENTATIVE:20060102T100000Z/20060102T120000Z +END:VFREEBUSY +END:VCALENDAR +"#; + +const REPORT_5: &str = r#" + + + + + + + + + + + + + + + +"#; + +const REPORT_6: &str = r#" + + + + + + + + + + DC6C50A017428C5216A2F1CD@example.com + + + + + +"#; + +const REPORT_7: &str = r#" + + + + + + + + + + mailto:lisa@example.com + + NEEDS-ACTION + + + + + + +"#; + +const REPORT_8: &str = r#" + + + + + + + + + + + +"#; + +const REPORT_9: &str = r#" + + + + + + + + + + + + + CANCELLED + + + + + +"#; + +const REPORT_10: &str = r#" + + + +"#; + +const REPORT_10_RESPONSE: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Stalwart Labs Ltd.//Stalwart Server//EN +BEGIN:VFREEBUSY +DTSTART:20060104T140000Z +DTEND:20060105T220000Z +FREEBUSY;FBTYPE=BUSY-TENTATIVE:20060104T150000Z/20060104T160000Z +FREEBUSY;FBTYPE=BUSY:20060105T170000Z/20060105T180000Z +FREEBUSY;FBTYPE=BUSY:20060104T190000Z/20060104T200000Z +FREEBUSY;FBTYPE=BUSY-UNAVAILABLE:20060105T100000Z/20060105T120000Z +END:VFREEBUSY +END:VCALENDAR +"#; + +const REPORT_11: &str = r#" + + + +"#; + +const REPORT_11_RESPONSE: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Stalwart Labs Ltd.//Stalwart Server//EN +BEGIN:VFREEBUSY +DTSTART:20060101T000000Z +DTEND:20060104T140000Z +DTSTAMP:20250505T105255Z +FREEBUSY;FBTYPE=BUSY:20060102T150000Z/20060102T160000Z +FREEBUSY;FBTYPE=BUSY:20060102T170000Z/20060102T180000Z;20060103T170000Z/20060103T180000Z +FREEBUSY;FBTYPE=BUSY-TENTATIVE:20060102T100000Z/20060102T120000Z +FREEBUSY:20060103T100000Z/20060103T120000Z +FREEBUSY:20060104T100000Z/20060104T120000Z +END:VFREEBUSY +END:VCALENDAR +"#; + +const ICAL_RFC_ABCD1_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTAMP:20060206T001102Z +DTSTART;TZID=US/Eastern:20060102T100000 +DURATION:PT1H +SUMMARY:Event #1 +Description:Go Steelers! +UID:74855313FA803DA593CD579A@example.com +END:VEVENT +END:VCALENDAR +"#; + +const ICAL_RFC_ABCD2_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTAMP:20060206T001121Z +DTSTART;TZID=US/Eastern:20060102T120000 +DURATION:PT1H +RRULE:FREQ=DAILY;COUNT=5 +SUMMARY:Event #2 +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +BEGIN:VEVENT +DTSTAMP:20060206T001121Z +DTSTART;TZID=US/Eastern:20060104T140000 +DURATION:PT1H +RECURRENCE-ID;TZID=US/Eastern:20060104T120000 +SUMMARY:Event #2 bis +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +BEGIN:VEVENT +DTSTAMP:20060206T001121Z +DTSTART;TZID=US/Eastern:20060106T140000 +DURATION:PT1H +RECURRENCE-ID;TZID=US/Eastern:20060106T120000 +SUMMARY:Event #2 bis bis +UID:00959BC664CA650E933C892C@example.com +END:VEVENT +END:VCALENDAR +"#; + +const ICAL_RFC_ABCD3_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com +DTSTAMP:20060206T001220Z +DTSTART;TZID=US/Eastern:20060104T100000 +DURATION:PT1H +LAST-MODIFIED:20060206T001330Z +ORGANIZER:mailto:cyrus@example.com +SEQUENCE:1 +STATUS:TENTATIVE +SUMMARY:Event #3 +UID:DC6C50A017428C5216A2F1CD@example.com +X-ABC-GUID:E1CX5Dr-0007ym-Hz@example.com +END:VEVENT +END:VCALENDAR +"#; + +const ICAL_RFC_ABCD4_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTODO +DTSTAMP:20060205T235335Z +DUE;VALUE=DATE:20060104 +STATUS:NEEDS-ACTION +SUMMARY:Task #1 +UID:DDDEEB7915FA61233B861457@example.com +BEGIN:VALARM +ACTION:AUDIO +TRIGGER;RELATED=START:-PT10M +END:VALARM +END:VTODO +END:VCALENDAR +"#; + +const ICAL_RFC_ABCD5_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTODO +DTSTAMP:20060205T235300Z +DUE;TZID=US/Eastern:20060106T120000 +LAST-MODIFIED:20060205T235308Z +SEQUENCE:1 +STATUS:NEEDS-ACTION +SUMMARY:Task #2 +UID:E10BA47467C5C69BB74E8720@example.com +BEGIN:VALARM +ACTION:AUDIO +TRIGGER;RELATED=START:-PT10M +END:VALARM +END:VTODO +END:VCALENDAR +"#; + +const ICAL_RFC_ABCD6_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTODO +COMPLETED:20051223T122322Z +DTSTAMP:20060205T235400Z +DUE;VALUE=DATE:20051225 +LAST-MODIFIED:20060205T235308Z +SEQUENCE:1 +STATUS:COMPLETED +SUMMARY:Task #3 +UID:E10BA47467C5C69BB74E8722@example.com +END:VTODO +END:VCALENDAR +"#; + +const ICAL_RFC_ABCD7_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTODO +DTSTAMP:20060205T235600Z +DUE;VALUE=DATE:20060101 +LAST-MODIFIED:20060205T235308Z +SEQUENCE:1 +STATUS:CANCELLED +SUMMARY:Task #4 +UID:E10BA47467C5C69BB74E8725@example.com +END:VTODO +END:VCALENDAR +"#; + +const ICAL_RFC_ABCD8_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VFREEBUSY +ORGANIZER;CN="Bernard Desruisseaux":mailto:bernard@example.com +UID:76ef34-54a3d2@example.com +DTSTAMP:20050530T123421Z +DTSTART:20060101T000000Z +DTEND:20060108T000000Z +FREEBUSY:20050531T230000Z/20050601T010000Z +FREEBUSY;FBTYPE=BUSY-TENTATIVE:20060102T100000Z/20060102T120000Z +FREEBUSY:20060103T100000Z/20060103T120000Z +FREEBUSY:20060104T100000Z/20060104T120000Z +FREEBUSY;FBTYPE=BUSY-UNAVAILABLE:20060105T100000Z/20060105T120000Z +FREEBUSY:20060106T100000Z/20060106T120000Z +END:VFREEBUSY +END:VCALENDAR +"#; + +fn remove_dtstamp(ics: &str) -> AHashSet { + let mut result = AHashSet::new(); + for line in ics.lines() { + if !line.starts_with("DTSTAMP:") { + result.insert(line.to_string()); + } + } + result +} diff --git a/tests/src/webdav/mod.rs b/tests/src/webdav/mod.rs index 72b990fb..1c23877c 100644 --- a/tests/src/webdav/mod.rs +++ b/tests/src/webdav/mod.rs @@ -46,6 +46,7 @@ use utils::config::Config; pub mod acl; pub mod basic; +pub mod cal_query; pub mod card_query; pub mod copy_move; pub mod lock; @@ -68,14 +69,6 @@ pub async fn webdav_tests() { ) .await; - /* - TODO: - - - Calendar Query - - Freebusy Query - - */ - basic::test(&handle).await; put_get::test(&handle).await; mkcol::test(&handle).await; diff --git a/tests/src/webdav/prop.rs b/tests/src/webdav/prop.rs index f6c14d04..427a0b13 100644 --- a/tests/src/webdav/prop.rs +++ b/tests/src/webdav/prop.rs @@ -796,6 +796,12 @@ impl DavPropertyResult<'_> { } self } + + pub fn calendar_data(&self) -> DavQueryResult<'_> { + self.get(DavProperty::CalDav(CalDavProperty::CalendarData( + Default::default(), + ))) + } } impl<'x> DavQueryResult<'x> { @@ -809,7 +815,7 @@ impl<'x> DavQueryResult<'x> { if values != expected_values { self.response.dump_response(); - panic!("Expected {expected_values:?} values, but got {values:?}",); + assert_eq!(values, expected_values,); } self }