diff --git a/crates/caldav/src/calendar/methods/report/calendar_query/comp_filter.rs b/crates/caldav/src/calendar/methods/report/calendar_query/comp_filter.rs new file mode 100644 index 0000000..51f6bcf --- /dev/null +++ b/crates/caldav/src/calendar/methods/report/calendar_query/comp_filter.rs @@ -0,0 +1,345 @@ +use crate::calendar::methods::report::calendar_query::{ + TimeRangeElement, + prop_filter::{PropFilterElement, PropFilterable}, +}; +use ical::parser::ical::component::IcalTimeZone; +use rustical_ical::{CalendarObject, CalendarObjectComponent, CalendarObjectType}; +use rustical_xml::XmlDeserialize; + +#[derive(XmlDeserialize, Clone, Debug, PartialEq)] +#[allow(dead_code)] +// https://datatracker.ietf.org/doc/html/rfc4791#section-9.7.1 +pub struct CompFilterElement { + #[xml(ns = "rustical_dav::namespace::NS_CALDAV")] + pub(crate) is_not_defined: Option<()>, + #[xml(ns = "rustical_dav::namespace::NS_CALDAV")] + pub(crate) time_range: Option, + #[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)] + pub(crate) prop_filter: Vec, + #[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)] + pub(crate) comp_filter: Vec, + + #[xml(ty = "attr")] + pub(crate) name: String, +} + +pub trait CompFilterable: PropFilterable + Sized { + fn get_comp_name(&self) -> &'static str; + + fn match_time_range(&self, time_range: &TimeRangeElement) -> bool; + + fn match_subcomponents(&self, comp_filter: &CompFilterElement) -> bool; + + // https://datatracker.ietf.org/doc/html/rfc4791#section-9.7.1 + // The scope of the + // CALDAV:comp-filter XML element is the calendar object when used as + // a child of the CALDAV:filter XML element. The scope of the + // CALDAV:comp-filter XML element is the enclosing calendar component + // when used as a child of another CALDAV:comp-filter XML element + fn matches(&self, comp_filter: &CompFilterElement) -> bool { + let name_matches = self.get_comp_name() == comp_filter.name; + match (comp_filter.is_not_defined.is_some(), name_matches) { + // We are the component that's not supposed to be defined + (true, true) + // We don't match + | (false, false) => return false, + // We shall not be and indeed we aren't + (true, false) => return true, + _ => {} + } + + if let Some(time_range) = comp_filter.time_range.as_ref() + && !self.match_time_range(time_range) + { + return false; + } + + for prop_filter in &comp_filter.prop_filter { + if !prop_filter.match_component(self) { + return false; + } + } + + comp_filter + .comp_filter + .iter() + .all(|filter| self.match_subcomponents(filter)) + } +} + +impl CompFilterable for CalendarObject { + fn get_comp_name(&self) -> &'static str { + "VCALENDAR" + } + + fn match_time_range(&self, _time_range: &TimeRangeElement) -> bool { + // VCALENDAR has no concept of time range + false + } + + fn match_subcomponents(&self, comp_filter: &CompFilterElement) -> bool { + let mut matches = self + .get_vtimezones() + .values() + .map(|tz| tz.matches(comp_filter)) + .chain([self.get_data().matches(comp_filter)]); + + if comp_filter.is_not_defined.is_some() { + matches.all(|x| x) + } else { + matches.any(|x| x) + } + } +} + +impl CompFilterable for IcalTimeZone { + fn get_comp_name(&self) -> &'static str { + "VTIMEZONE" + } + + fn match_time_range(&self, _time_range: &TimeRangeElement) -> bool { + false + } + + fn match_subcomponents(&self, _comp_filter: &CompFilterElement) -> bool { + true + } +} + +impl CompFilterable for CalendarObjectComponent { + fn get_comp_name(&self) -> &'static str { + CalendarObjectType::from(self).as_str() + } + + fn match_time_range(&self, time_range: &TimeRangeElement) -> bool { + if let Some(start) = &time_range.start + && let Some(last_occurence) = self.get_last_occurence().unwrap_or(None) + && **start > last_occurence.utc() + { + return false; + } + if let Some(end) = &time_range.end + && let Some(first_occurence) = self.get_first_occurence().unwrap_or(None) + && **end < first_occurence.utc() + { + return false; + } + true + } + + fn match_subcomponents(&self, _comp_filter: &CompFilterElement) -> bool { + // TODO: Properly check subcomponents + true + } +} + +#[cfg(test)] +mod tests { + use chrono::{TimeZone, Utc}; + use rustical_ical::{CalendarObject, UtcDateTime}; + + use crate::calendar::methods::report::calendar_query::{ + CompFilterable, TextMatchElement, TimeRangeElement, + comp_filter::CompFilterElement, + prop_filter::PropFilterElement, + text_match::{NegateCondition, TextCollation}, + }; + + const ICS: &str = r"BEGIN:VCALENDAR +CALSCALE:GREGORIAN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/Berlin +X-LIC-LOCATION:Europe/Berlin +END:VTIMEZONE + +BEGIN:VEVENT +UID:318ec6503573d9576818daf93dac07317058d95c +DTSTAMP:20250502T132758Z +DTSTART;TZID=Europe/Berlin:20250506T090000 +DTEND;TZID=Europe/Berlin:20250506T092500 +SEQUENCE:2 +SUMMARY:weekly stuff +TRANSP:OPAQUE +RRULE:FREQ=WEEKLY;COUNT=4;INTERVAL=2;BYDAY=TU,TH,SU +END:VEVENT +END:VCALENDAR"; + + #[test] + fn test_comp_filter_matching() { + let object = CalendarObject::from_ics(ICS.to_string(), None).unwrap(); + + let comp_filter = CompFilterElement { + is_not_defined: Some(()), + name: "VCALENDAR".to_string(), + time_range: None, + prop_filter: vec![], + comp_filter: vec![], + }; + assert!(!object.matches(&comp_filter), "filter: wants no VCALENDAR"); + + let comp_filter = CompFilterElement { + is_not_defined: None, + name: "VCALENDAR".to_string(), + time_range: None, + prop_filter: vec![], + comp_filter: vec![CompFilterElement { + name: "VTODO".to_string(), + is_not_defined: None, + time_range: None, + prop_filter: vec![], + comp_filter: vec![], + }], + }; + assert!(!object.matches(&comp_filter), "filter matches VTODO"); + + let comp_filter = CompFilterElement { + is_not_defined: None, + name: "VCALENDAR".to_string(), + time_range: None, + prop_filter: vec![], + comp_filter: vec![CompFilterElement { + name: "VEVENT".to_string(), + is_not_defined: None, + time_range: None, + prop_filter: vec![], + comp_filter: vec![], + }], + }; + assert!(object.matches(&comp_filter), "filter matches VEVENT"); + + let comp_filter = CompFilterElement { + is_not_defined: None, + name: "VCALENDAR".to_string(), + time_range: None, + prop_filter: vec![ + PropFilterElement { + is_not_defined: None, + name: "VERSION".to_string(), + time_range: None, + text_match: Some(TextMatchElement { + needle: "2.0".to_string(), + collation: TextCollation::default(), + negate_condition: NegateCondition::default(), + }), + param_filter: vec![], + }, + PropFilterElement { + is_not_defined: Some(()), + name: "STUFF".to_string(), + time_range: None, + text_match: None, + param_filter: vec![], + }, + ], + comp_filter: vec![CompFilterElement { + name: "VEVENT".to_string(), + is_not_defined: None, + time_range: None, + prop_filter: vec![PropFilterElement { + is_not_defined: None, + name: "SUMMARY".to_string(), + time_range: None, + text_match: Some(TextMatchElement { + collation: TextCollation::default(), + negate_condition: NegateCondition(false), + needle: "weekly".to_string(), + }), + param_filter: vec![], + }], + comp_filter: vec![], + }], + }; + assert!( + object.matches(&comp_filter), + "Some prop filters on VCALENDAR and VEVENT" + ); + } + #[test] + fn test_comp_filter_time_range() { + let object = CalendarObject::from_ics(ICS.to_string(), None).unwrap(); + + let comp_filter = CompFilterElement { + is_not_defined: None, + name: "VCALENDAR".to_string(), + time_range: None, + prop_filter: vec![], + comp_filter: vec![CompFilterElement { + name: "VEVENT".to_string(), + is_not_defined: None, + time_range: Some(TimeRangeElement { + start: Some(UtcDateTime( + Utc.with_ymd_and_hms(2025, 4, 1, 0, 0, 0).unwrap(), + )), + end: Some(UtcDateTime( + Utc.with_ymd_and_hms(2025, 8, 1, 0, 0, 0).unwrap(), + )), + }), + prop_filter: vec![], + comp_filter: vec![], + }], + }; + assert!( + object.matches(&comp_filter), + "event should lie in time range" + ); + + let comp_filter = CompFilterElement { + is_not_defined: None, + name: "VCALENDAR".to_string(), + time_range: None, + prop_filter: vec![], + comp_filter: vec![CompFilterElement { + name: "VEVENT".to_string(), + is_not_defined: None, + time_range: Some(TimeRangeElement { + start: Some(UtcDateTime( + Utc.with_ymd_and_hms(2024, 4, 1, 0, 0, 0).unwrap(), + )), + end: Some(UtcDateTime( + Utc.with_ymd_and_hms(2024, 8, 1, 0, 0, 0).unwrap(), + )), + }), + prop_filter: vec![], + comp_filter: vec![], + }], + }; + assert!( + !object.matches(&comp_filter), + "event should not lie in time range" + ); + } + + #[test] + fn test_match_timezone() { + let object = CalendarObject::from_ics(ICS.to_string(), None).unwrap(); + + let comp_filter = CompFilterElement { + is_not_defined: None, + name: "VCALENDAR".to_string(), + time_range: None, + prop_filter: vec![], + comp_filter: vec![CompFilterElement { + name: "VTIMEZONE".to_string(), + is_not_defined: None, + time_range: None, + prop_filter: vec![PropFilterElement { + is_not_defined: None, + name: "TZID".to_string(), + time_range: None, + text_match: Some(TextMatchElement { + collation: TextCollation::AsciiCasemap, + negate_condition: NegateCondition::default(), + needle: "Europe/Berlin".to_string(), + }), + param_filter: vec![], + }], + comp_filter: vec![], + }], + }; + assert!( + object.matches(&comp_filter), + "Timezone should be Europe/Berlin" + ); + } +} diff --git a/crates/caldav/src/calendar/methods/report/calendar_query/elements.rs b/crates/caldav/src/calendar/methods/report/calendar_query/elements.rs index 37a8648..8a8857d 100644 --- a/crates/caldav/src/calendar/methods/report/calendar_query/elements.rs +++ b/crates/caldav/src/calendar/methods/report/calendar_query/elements.rs @@ -1,4 +1,10 @@ -use crate::calendar_object::CalendarObjectPropWrapperName; +use crate::{ + calendar::methods::report::calendar_query::{ + TextMatchElement, + comp_filter::{CompFilterElement, CompFilterable}, + }, + calendar_object::CalendarObjectPropWrapperName, +}; use rustical_dav::xml::PropfindType; use rustical_ical::{CalendarObject, UtcDateTime}; use rustical_store::calendar_store::CalendarQuery; @@ -26,112 +32,6 @@ pub struct ParamFilterElement { pub(crate) name: String, } -#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)] -#[allow(dead_code)] -pub struct TextMatchElement { - #[xml(ty = "attr")] - pub(crate) collation: String, - #[xml(ty = "attr")] - // "yes" or "no", default: "no" - pub(crate) negate_condition: Option, -} - -#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)] -#[allow(dead_code)] -// https://www.rfc-editor.org/rfc/rfc4791#section-9.7.2 -pub struct PropFilterElement { - #[xml(ns = "rustical_dav::namespace::NS_CALDAV")] - pub(crate) is_not_defined: Option<()>, - #[xml(ns = "rustical_dav::namespace::NS_CALDAV")] - pub(crate) time_range: Option, - #[xml(ns = "rustical_dav::namespace::NS_CALDAV")] - pub(crate) text_match: Option, - #[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)] - pub(crate) param_filter: Vec, - - #[xml(ty = "attr")] - pub(crate) name: String, -} - -#[derive(XmlDeserialize, Clone, Debug, PartialEq)] -#[allow(dead_code)] -// https://datatracker.ietf.org/doc/html/rfc4791#section-9.7.1 -pub struct CompFilterElement { - #[xml(ns = "rustical_dav::namespace::NS_CALDAV")] - pub(crate) is_not_defined: Option<()>, - #[xml(ns = "rustical_dav::namespace::NS_CALDAV")] - pub(crate) time_range: Option, - #[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)] - pub(crate) prop_filter: Vec, - #[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)] - pub(crate) comp_filter: Vec, - - #[xml(ty = "attr")] - pub(crate) name: String, -} - -impl CompFilterElement { - // match the VCALENDAR part - pub fn matches_root(&self, cal_object: &CalendarObject) -> bool { - let comp_vcal = self.name == "VCALENDAR"; - match (self.is_not_defined, comp_vcal) { - // Client wants VCALENDAR to not exist but we are a VCALENDAR - (Some(()), true) | - // Client is asking for something different than a vcalendar - (None, false) => return false, - _ => {} - } - - if self.time_range.is_some() { - // should be applied on VEVENT/VTODO but not on VCALENDAR - return false; - } - - // TODO: Implement prop-filter at some point - - // Apply sub-comp-filters on VEVENT/VTODO/VJOURNAL component - if self - .comp_filter - .iter() - .all(|filter| filter.matches(cal_object)) - { - return true; - } - - false - } - - // match the VEVENT/VTODO/VJOURNAL part - pub fn matches(&self, cal_object: &CalendarObject) -> bool { - let comp_name_matches = self.name == cal_object.get_component_name(); - match (self.is_not_defined, comp_name_matches) { - // Client wants VCALENDAR to not exist but we are a VCALENDAR - (Some(()), true) | - // Client is asking for something different than a vcalendar - (None, false) => return false, - _ => {} - } - - // TODO: Implement prop-filter (and comp-filter?) at some point - - if let Some(time_range) = &self.time_range { - if let Some(start) = &time_range.start - && let Some(last_occurence) = cal_object.get_last_occurence().unwrap_or(None) - && **start > last_occurence.utc() - { - return false; - } - if let Some(end) = &time_range.end - && let Some(first_occurence) = cal_object.get_first_occurence().unwrap_or(None) - && **end < first_occurence.utc() - { - return false; - } - } - true - } -} - #[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[allow(dead_code)] // https://datatracker.ietf.org/doc/html/rfc4791#section-9.7 @@ -142,8 +42,9 @@ pub struct FilterElement { } impl FilterElement { + #[must_use] pub fn matches(&self, cal_object: &CalendarObject) -> bool { - self.comp_filter.matches_root(cal_object) + cal_object.matches(&self.comp_filter) } } diff --git a/crates/caldav/src/calendar/methods/report/calendar_query/mod.rs b/crates/caldav/src/calendar/methods/report/calendar_query/mod.rs index 7490fc7..9e12a3f 100644 --- a/crates/caldav/src/calendar/methods/report/calendar_query/mod.rs +++ b/crates/caldav/src/calendar/methods/report/calendar_query/mod.rs @@ -2,8 +2,17 @@ use crate::Error; use rustical_ical::CalendarObject; use rustical_store::CalendarStore; +mod comp_filter; mod elements; +mod prop_filter; +pub mod text_match; +#[allow(unused_imports)] +pub use comp_filter::{CompFilterElement, CompFilterable}; pub use elements::*; +#[allow(unused_imports)] +pub use prop_filter::{PropFilterElement, PropFilterable}; +#[allow(unused_imports)] +pub use text_match::TextMatchElement; pub async fn get_objects_calendar_query( cal_query: &CalendarQueryRequest, @@ -29,8 +38,10 @@ mod tests { calendar::methods::report::{ ReportRequest, calendar_query::{ - CalendarQueryRequest, CompFilterElement, FilterElement, ParamFilterElement, - PropFilterElement, TextMatchElement, + CalendarQueryRequest, FilterElement, ParamFilterElement, TextMatchElement, + comp_filter::CompFilterElement, + prop_filter::PropFilterElement, + text_match::{NegateCondition, TextCollation}, }, }, calendar_object::{CalendarData, CalendarObjectPropName, CalendarObjectPropWrapperName}, @@ -90,16 +101,18 @@ mod tests { prop_filter: vec![PropFilterElement { name: "ATTENDEE".to_owned(), text_match: Some(TextMatchElement { - collation: "i;ascii-casemap".to_owned(), - negate_condition: None + collation: TextCollation::AsciiCasemap, + negate_condition: NegateCondition(false), + needle: "mailto:lisa@example.com".to_string() }), is_not_defined: None, param_filter: vec![ParamFilterElement { is_not_defined: None, name: "PARTSTAT".to_owned(), text_match: Some(TextMatchElement { - collation: "i;ascii-casemap".to_owned(), - negate_condition: None + collation: TextCollation::AsciiCasemap, + negate_condition: NegateCondition(false), + needle: "NEEDS-ACTION".to_string() }), }], time_range: None diff --git a/crates/caldav/src/calendar/methods/report/calendar_query/prop_filter.rs b/crates/caldav/src/calendar/methods/report/calendar_query/prop_filter.rs new file mode 100644 index 0000000..9da7480 --- /dev/null +++ b/crates/caldav/src/calendar/methods/report/calendar_query/prop_filter.rs @@ -0,0 +1,127 @@ +use std::collections::HashMap; + +use ical::{ + generator::{IcalCalendar, IcalEvent}, + parser::{ + Component, + ical::component::{IcalJournal, IcalTimeZone, IcalTodo}, + }, + property::Property, +}; +use rustical_ical::{CalDateTime, CalendarObject, CalendarObjectComponent, UtcDateTime}; +use rustical_xml::XmlDeserialize; + +use crate::calendar::methods::report::calendar_query::{ + ParamFilterElement, TextMatchElement, TimeRangeElement, +}; + +#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)] +#[allow(dead_code)] +// https://www.rfc-editor.org/rfc/rfc4791#section-9.7.2 +pub struct PropFilterElement { + #[xml(ns = "rustical_dav::namespace::NS_CALDAV")] + pub(crate) is_not_defined: Option<()>, + #[xml(ns = "rustical_dav::namespace::NS_CALDAV")] + pub(crate) time_range: Option, + #[xml(ns = "rustical_dav::namespace::NS_CALDAV")] + pub(crate) text_match: Option, + #[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)] + pub(crate) param_filter: Vec, + + #[xml(ty = "attr")] + pub(crate) name: String, +} + +impl PropFilterElement { + pub fn match_component(&self, comp: &impl PropFilterable) -> bool { + let property = comp.get_property(&self.name); + let property = match (self.is_not_defined.is_some(), property) { + // We are the component that's not supposed to be defined + (true, Some(_)) + // We don't match + | (false, None) => return false, + // We shall not be and indeed we aren't + (true, None) => return true, + (false, Some(property)) => property + }; + + if let Some(TimeRangeElement { start, end }) = &self.time_range { + // TODO: Respect timezones + let Ok(timestamp) = CalDateTime::parse_prop(property, &HashMap::default()) else { + return false; + }; + let timestamp = timestamp.utc(); + if let Some(UtcDateTime(start)) = start + && start > ×tamp + { + return false; + } + if let Some(UtcDateTime(end)) = end + && end < ×tamp + { + return false; + } + return true; + } + + if let Some(text_match) = &self.text_match + && !text_match.match_property(property) + { + return false; + } + + // TODO: param-filter + + true + } +} + +pub trait PropFilterable { + fn get_property(&self, name: &str) -> Option<&Property>; +} + +impl PropFilterable for CalendarObject { + fn get_property(&self, name: &str) -> Option<&Property> { + Self::get_property(self, name) + } +} + +impl PropFilterable for IcalEvent { + fn get_property(&self, name: &str) -> Option<&Property> { + Component::get_property(self, name) + } +} + +impl PropFilterable for IcalTodo { + fn get_property(&self, name: &str) -> Option<&Property> { + Component::get_property(self, name) + } +} + +impl PropFilterable for IcalJournal { + fn get_property(&self, name: &str) -> Option<&Property> { + Component::get_property(self, name) + } +} + +impl PropFilterable for IcalCalendar { + fn get_property(&self, name: &str) -> Option<&Property> { + Component::get_property(self, name) + } +} + +impl PropFilterable for IcalTimeZone { + fn get_property(&self, name: &str) -> Option<&Property> { + Component::get_property(self, name) + } +} + +impl PropFilterable for CalendarObjectComponent { + fn get_property(&self, name: &str) -> Option<&Property> { + match self { + Self::Event(event, _) => PropFilterable::get_property(&event.event, name), + Self::Todo(todo, _) => PropFilterable::get_property(todo, name), + Self::Journal(journal, _) => PropFilterable::get_property(journal, name), + } + } +} diff --git a/crates/caldav/src/calendar/methods/report/calendar_query/text_match.rs b/crates/caldav/src/calendar/methods/report/calendar_query/text_match.rs new file mode 100644 index 0000000..9d8af6f --- /dev/null +++ b/crates/caldav/src/calendar/methods/report/calendar_query/text_match.rs @@ -0,0 +1,103 @@ +use ical::property::Property; +use rustical_xml::{ValueDeserialize, XmlDeserialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub enum TextCollation { + #[default] + AsciiCasemap, + Octet, +} + +impl TextCollation { + // Check whether a haystack contains a needle respecting the collation + #[must_use] + pub fn match_text(&self, needle: &str, haystack: &str) -> bool { + match self { + // https://datatracker.ietf.org/doc/html/rfc4790#section-9.2 + Self::AsciiCasemap => haystack + .to_ascii_uppercase() + .contains(&needle.to_ascii_uppercase()), + Self::Octet => haystack.contains(needle), + } + } +} + +impl AsRef for TextCollation { + fn as_ref(&self) -> &str { + match self { + Self::AsciiCasemap => "i;ascii-casemap", + Self::Octet => "i;octet", + } + } +} + +impl ValueDeserialize for TextCollation { + fn deserialize(val: &str) -> Result { + match val { + "i;ascii-casemap" => Ok(Self::AsciiCasemap), + "i;octet" => Ok(Self::Octet), + _ => Err(rustical_xml::XmlError::InvalidVariant(format!( + "Invalid collation: {val}" + ))), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct NegateCondition(pub bool); + +impl ValueDeserialize for NegateCondition { + fn deserialize(val: &str) -> Result { + match val { + "yes" => Ok(Self(true)), + "no" => Ok(Self(false)), + _ => Err(rustical_xml::XmlError::InvalidVariant(format!( + "Invalid negate-condition parameter: {val}" + ))), + } + } +} + +#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)] +#[allow(dead_code)] +pub struct TextMatchElement { + #[xml(ty = "attr", default = "Default::default")] + pub collation: TextCollation, + #[xml(ty = "attr", default = "Default::default")] + pub(crate) negate_condition: NegateCondition, + #[xml(ty = "text")] + pub(crate) needle: String, +} + +impl TextMatchElement { + #[must_use] + pub fn match_property(&self, property: &Property) -> bool { + let Self { + collation, + negate_condition, + needle, + } = self; + + let matches = property + .value + .as_ref() + .is_some_and(|haystack| collation.match_text(needle, haystack)); + + // XOR + negate_condition.0 ^ matches + } +} + +#[cfg(test)] +mod tests { + use crate::calendar::methods::report::calendar_query::text_match::TextCollation; + + #[test] + fn test_collation() { + assert!(TextCollation::AsciiCasemap.match_text("GrüN", "grün")); + assert!(!TextCollation::AsciiCasemap.match_text("GrÜN", "grün")); + assert!(!TextCollation::Octet.match_text("GrÜN", "grün")); + assert!(TextCollation::Octet.match_text("hallo", "hallo")); + assert!(TextCollation::AsciiCasemap.match_text("HaLlo", "hAllo")); + } +} diff --git a/crates/caldav/src/calendar/methods/report/mod.rs b/crates/caldav/src/calendar/methods/report/mod.rs index 2703f01..4d7ef6b 100644 --- a/crates/caldav/src/calendar/methods/report/mod.rs +++ b/crates/caldav/src/calendar/methods/report/mod.rs @@ -27,7 +27,7 @@ use sync_collection::handle_sync_collection; use tracing::instrument; mod calendar_multiget; -mod calendar_query; +pub mod calendar_query; mod sync_collection; #[derive(XmlDeserialize, XmlDocument, Clone, Debug, PartialEq)] diff --git a/crates/caldav/src/calendar/prop.rs b/crates/caldav/src/calendar/prop.rs index fa74bf6..cf3a0a7 100644 --- a/crates/caldav/src/calendar/prop.rs +++ b/crates/caldav/src/calendar/prop.rs @@ -3,6 +3,8 @@ use rustical_ical::CalendarObjectType; use rustical_xml::{XmlDeserialize, XmlSerialize}; use strum_macros::VariantArray; +use crate::calendar::methods::report::calendar_query::text_match::TextCollation; + #[derive(Debug, Clone, XmlSerialize, XmlDeserialize, PartialEq, Eq, From, Into)] pub struct SupportedCalendarComponent { #[xml(ty = "attr")] @@ -36,6 +38,28 @@ impl From for Vec { } } +#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, PartialEq, Eq, From, Into)] +pub struct SupportedCollation(#[xml(ty = "text")] pub TextCollation); + +#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, PartialEq, Eq)] +pub struct SupportedCollationSet( + #[xml( + ns = "rustical_dav::namespace::NS_CALDAV", + flatten, + rename = "supported-collation" + )] + pub Vec, +); + +impl Default for SupportedCollationSet { + fn default() -> Self { + Self(vec![ + SupportedCollation(TextCollation::AsciiCasemap), + SupportedCollation(TextCollation::Octet), + ]) + } +} + #[derive(Debug, Clone, XmlSerialize, PartialEq, Eq)] pub struct CalendarData { #[xml(ty = "attr")] diff --git a/crates/caldav/src/calendar/resource.rs b/crates/caldav/src/calendar/resource.rs index bec5de3..2c891eb 100644 --- a/crates/caldav/src/calendar/resource.rs +++ b/crates/caldav/src/calendar/resource.rs @@ -1,6 +1,6 @@ use super::prop::{SupportedCalendarComponentSet, SupportedCalendarData}; use crate::Error; -use crate::calendar::prop::ReportMethod; +use crate::calendar::prop::{ReportMethod, SupportedCollationSet}; use chrono::{DateTime, Utc}; use derive_more::derive::{From, Into}; use ical::IcalParser; @@ -39,6 +39,8 @@ pub enum CalendarProp { SupportedCalendarComponentSet(SupportedCalendarComponentSet), #[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)] SupportedCalendarData(SupportedCalendarData), + #[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)] + SupportedCollationSet(SupportedCollationSet), #[xml(ns = "rustical_dav::namespace::NS_DAV")] MaxResourceSize(i64), #[xml(skip_deserializing)] @@ -156,6 +158,9 @@ impl Resource for CalendarResource { CalendarPropName::SupportedCalendarData => { CalendarProp::SupportedCalendarData(SupportedCalendarData::default()) } + CalendarPropName::SupportedCollationSet => { + CalendarProp::SupportedCollationSet(SupportedCollationSet::default()) + } CalendarPropName::MaxResourceSize => CalendarProp::MaxResourceSize(10_000_000), CalendarPropName::SupportedReportSet => { CalendarProp::SupportedReportSet(SupportedReportSet::all()) @@ -244,6 +249,7 @@ impl Resource for CalendarResource { } CalendarProp::TimezoneServiceSet(_) | CalendarProp::SupportedCalendarData(_) + | CalendarProp::SupportedCollationSet(_) | CalendarProp::MaxResourceSize(_) | CalendarProp::SupportedReportSet(_) | CalendarProp::Source(_) @@ -283,6 +289,7 @@ impl Resource for CalendarResource { } CalendarPropName::TimezoneServiceSet | CalendarPropName::SupportedCalendarData + | CalendarPropName::SupportedCollationSet | CalendarPropName::MaxResourceSize | CalendarPropName::SupportedReportSet | CalendarPropName::Source diff --git a/crates/caldav/src/calendar/test_files/propfind.outputs b/crates/caldav/src/calendar/test_files/propfind.outputs index 881ee37..9330cdf 100644 --- a/crates/caldav/src/calendar/test_files/propfind.outputs +++ b/crates/caldav/src/calendar/test_files/propfind.outputs @@ -11,6 +11,7 @@ + @@ -160,6 +161,10 @@ END:VCALENDAR + + i;ascii-casemap + i;octet + 10000000 diff --git a/crates/ical/src/icalendar/object.rs b/crates/ical/src/icalendar/object.rs index d31a0a0..d720d20 100644 --- a/crates/ical/src/icalendar/object.rs +++ b/crates/ical/src/icalendar/object.rs @@ -148,6 +148,34 @@ impl CalendarObjectComponent { } Ok(Self::Journal(main_journal, overrides)) } + + pub fn get_first_occurence(&self) -> Result, Error> { + match &self { + Self::Event(main_event, overrides) => Ok(overrides + .iter() + .chain(std::iter::once(main_event)) + .map(super::event::EventObject::get_dtstart) + .collect::, _>>()? + .into_iter() + .flatten() + .min()), + _ => Ok(None), + } + } + + pub fn get_last_occurence(&self) -> Result, Error> { + match &self { + Self::Event(main_event, overrides) => Ok(overrides + .iter() + .chain(std::iter::once(main_event)) + .map(super::event::EventObject::get_last_occurence) + .collect::, _>>()? + .into_iter() + .flatten() + .max()), + _ => Ok(None), + } + } } #[derive(Debug, Clone)] @@ -268,31 +296,11 @@ impl CalendarObject { } pub fn get_first_occurence(&self) -> Result, Error> { - match &self.data { - CalendarObjectComponent::Event(main_event, overrides) => Ok(overrides - .iter() - .chain(std::iter::once(main_event)) - .map(super::event::EventObject::get_dtstart) - .collect::, _>>()? - .into_iter() - .flatten() - .min()), - _ => Ok(None), - } + self.data.get_first_occurence() } pub fn get_last_occurence(&self) -> Result, Error> { - match &self.data { - CalendarObjectComponent::Event(main_event, overrides) => Ok(overrides - .iter() - .chain(std::iter::once(main_event)) - .map(super::event::EventObject::get_last_occurence) - .collect::, _>>()? - .into_iter() - .flatten() - .max()), - _ => Ok(None), - } + self.data.get_last_occurence() } pub fn expand_recurrence( @@ -313,4 +321,11 @@ impl CalendarObject { _ => Ok(self.get_ics().to_string()), } } + + #[must_use] + pub fn get_property(&self, name: &str) -> Option<&Property> { + self.properties + .iter() + .find(|property| property.name == name) + } } diff --git a/crates/xml/derive/src/field.rs b/crates/xml/derive/src/field.rs index f73e116..47c1d54 100644 --- a/crates/xml/derive/src/field.rs +++ b/crates/xml/derive/src/field.rs @@ -313,7 +313,7 @@ impl Field { } }), (FieldType::Text, false) => Some(quote! { - writer.write_event(Event::Text(BytesText::new(&self.#target_field_index)))?; + writer.write_event(Event::Text(BytesText::new(self.#target_field_index.as_ref())))?; }), (FieldType::Tag, true) => { let field_name = self.xml_name();