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 index 7cae2ce..51f6bcf 100644 --- a/crates/caldav/src/calendar/methods/report/calendar_query/comp_filter.rs +++ b/crates/caldav/src/calendar/methods/report/calendar_query/comp_filter.rs @@ -139,8 +139,10 @@ mod tests { use rustical_ical::{CalendarObject, UtcDateTime}; use crate::calendar::methods::report::calendar_query::{ - CompFilterable, TextMatchElement, TimeRangeElement, comp_filter::CompFilterElement, + CompFilterable, TextMatchElement, TimeRangeElement, + comp_filter::CompFilterElement, prop_filter::PropFilterElement, + text_match::{NegateCondition, TextCollation}, }; const ICS: &str = r"BEGIN:VCALENDAR @@ -217,8 +219,8 @@ END:VCALENDAR"; time_range: None, text_match: Some(TextMatchElement { needle: "2.0".to_string(), - collation: None, - negate_condition: None, + collation: TextCollation::default(), + negate_condition: NegateCondition::default(), }), param_filter: vec![], }, @@ -239,8 +241,8 @@ END:VCALENDAR"; name: "SUMMARY".to_string(), time_range: None, text_match: Some(TextMatchElement { - collation: None, - negate_condition: None, + collation: TextCollation::default(), + negate_condition: NegateCondition(false), needle: "weekly".to_string(), }), param_filter: vec![], @@ -326,8 +328,8 @@ END:VCALENDAR"; name: "TZID".to_string(), time_range: None, text_match: Some(TextMatchElement { - collation: None, - negate_condition: None, + collation: TextCollation::AsciiCasemap, + negate_condition: NegateCondition::default(), needle: "Europe/Berlin".to_string(), }), param_filter: vec![], 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 fa86b0c..0404394 100644 --- a/crates/caldav/src/calendar/methods/report/calendar_query/elements.rs +++ b/crates/caldav/src/calendar/methods/report/calendar_query/elements.rs @@ -1,5 +1,8 @@ use crate::{ - calendar::methods::report::calendar_query::comp_filter::{CompFilterElement, CompFilterable}, + calendar::methods::report::calendar_query::{ + TextMatchElement, + comp_filter::{CompFilterElement, CompFilterable}, + }, calendar_object::CalendarObjectPropWrapperName, }; use rustical_dav::xml::PropfindType; @@ -29,18 +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: Option, - #[xml(ty = "attr")] - // "yes" or "no", default: "no" - pub(crate) negate_condition: Option, - #[xml(ty = "text")] - pub(crate) needle: String, -} - #[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[allow(dead_code)] // https://datatracker.ietf.org/doc/html/rfc4791#section-9.7 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 16c6e59..9e12a3f 100644 --- a/crates/caldav/src/calendar/methods/report/calendar_query/mod.rs +++ b/crates/caldav/src/calendar/methods/report/calendar_query/mod.rs @@ -5,11 +5,14 @@ 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, @@ -36,7 +39,9 @@ mod tests { ReportRequest, calendar_query::{ CalendarQueryRequest, FilterElement, ParamFilterElement, TextMatchElement, - comp_filter::CompFilterElement, prop_filter::PropFilterElement, + comp_filter::CompFilterElement, + prop_filter::PropFilterElement, + text_match::{NegateCondition, TextCollation}, }, }, calendar_object::{CalendarData, CalendarObjectPropName, CalendarObjectPropWrapperName}, @@ -96,8 +101,8 @@ mod tests { prop_filter: vec![PropFilterElement { name: "ATTENDEE".to_owned(), text_match: Some(TextMatchElement { - collation: Some("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, @@ -105,8 +110,8 @@ mod tests { is_not_defined: None, name: "PARTSTAT".to_owned(), text_match: Some(TextMatchElement { - collation: Some("i;ascii-casemap".to_owned()), - negate_condition: None, + collation: TextCollation::AsciiCasemap, + negate_condition: NegateCondition(false), needle: "NEEDS-ACTION".to_string() }), }], 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 index c90139a..9da7480 100644 --- a/crates/caldav/src/calendar/methods/report/calendar_query/prop_filter.rs +++ b/crates/caldav/src/calendar/methods/report/calendar_query/prop_filter.rs @@ -64,28 +64,10 @@ impl PropFilterElement { return true; } - if let Some(TextMatchElement { - collation: _collation, - negate_condition, - needle, - }) = &self.text_match + if let Some(text_match) = &self.text_match + && !text_match.match_property(property) { - let mut matches = property - .value - .as_ref() - .is_some_and(|haystack| haystack.contains(needle)); - match negate_condition.as_deref() { - None | Some("no") => {} - Some("yes") => { - matches = !matches; - } - // Invalid value - _ => return false, - } - - if !matches { - return false; - } + return false; } // TODO: param-filter 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..b8c4f27 --- /dev/null +++ b/crates/caldav/src/calendar/methods/report/calendar_query/text_match.rs @@ -0,0 +1,92 @@ +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 + 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 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 { + 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")); + } +}