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 1ed53bc..f716c60 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 @@ -1,28 +1,49 @@ -use ical::generator::IcalEvent; -use rustical_ical::{CalendarObject, CalendarObjectComponent, CalendarObjectType}; - use crate::calendar::methods::report::calendar_query::{ - CompFilterElement, PropFilterElement, TimeRangeElement, + TimeRangeElement, + prop_filter::{PropFilterElement, PropFilterable}, }; +use rustical_ical::{CalendarObject, CalendarObjectComponent, CalendarObjectType}; +use rustical_xml::XmlDeserialize; -pub trait CompFilterable { +#[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_prop_filter(&self, prop_filter: &PropFilterElement) -> 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; + 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) => return false, + (true, true) + // We don't match + | (false, false) => return false, // We shall not be and indeed we aren't (true, false) => return true, - // We don't match - (false, false) => return false, _ => {} } @@ -33,31 +54,11 @@ pub trait CompFilterable { } for prop_filter in &comp_filter.prop_filter { - if !self.match_prop_filter(prop_filter) { + if !prop_filter.match_component(self) { return false; } } - // let subcomponents = self.get_subcomponents(); - // for sub_comp_filter in &comp_filter.comp_filter { - // if sub_comp_filter.is_not_defined.is_some() { - // // If is_not_defined: Filter shuold match for all - // // Confusing logic but matching also means not being the component that - // // shouldn't be defined - // if subcomponents - // .iter() - // .any(|sub| !sub.matches(sub_comp_filter)) - // { - // return false; - // } - // } else { - // // otherwise if no component matches return false - // if !subcomponents.iter().any(|sub| sub.matches(sub_comp_filter)) { - // return false; - // } - // } - // } - comp_filter .comp_filter .iter() @@ -71,14 +72,10 @@ impl CompFilterable for CalendarObject { } fn match_time_range(&self, _time_range: &TimeRangeElement) -> bool { + // VCALENDAR has no concept of time range false } - fn match_prop_filter(&self, _prop_filter: &PropFilterElement) -> bool { - // TODO - true - } - fn match_subcomponents(&self, comp_filter: &CompFilterElement) -> bool { self.get_data().matches(comp_filter) } @@ -90,12 +87,18 @@ impl CompFilterable for CalendarObjectComponent { } fn match_time_range(&self, time_range: &TimeRangeElement) -> bool { - // TODO - true - } - - fn match_prop_filter(&self, _prop_filter: &PropFilterElement) -> bool { - // TODO + 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 } @@ -104,3 +107,179 @@ impl CompFilterable for CalendarObjectComponent { 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, + }; + + 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: None, + negate_condition: None, + }), + 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: None, + negate_condition: None, + 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" + ); + } +} 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..fa86b0c 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,7 @@ -use crate::calendar_object::CalendarObjectPropWrapperName; +use crate::{ + calendar::methods::report::calendar_query::comp_filter::{CompFilterElement, CompFilterable}, + calendar_object::CalendarObjectPropWrapperName, +}; use rustical_dav::xml::PropfindType; use rustical_ical::{CalendarObject, UtcDateTime}; use rustical_store::calendar_store::CalendarQuery; @@ -30,106 +33,12 @@ pub struct ParamFilterElement { #[allow(dead_code)] pub struct TextMatchElement { #[xml(ty = "attr")] - pub(crate) collation: String, + pub(crate) collation: Option, #[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 - } + #[xml(ty = "text")] + pub(crate) needle: String, } #[derive(XmlDeserialize, Clone, Debug, PartialEq)] @@ -143,7 +52,7 @@ pub struct FilterElement { impl FilterElement { 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 248e47c..16c6e59 100644 --- a/crates/caldav/src/calendar/methods/report/calendar_query/mod.rs +++ b/crates/caldav/src/calendar/methods/report/calendar_query/mod.rs @@ -4,7 +4,12 @@ use rustical_store::CalendarStore; mod comp_filter; mod elements; +mod prop_filter; +#[allow(unused_imports)] +pub use comp_filter::{CompFilterElement, CompFilterable}; pub use elements::*; +#[allow(unused_imports)] +pub use prop_filter::{PropFilterElement, PropFilterable}; pub async fn get_objects_calendar_query( cal_query: &CalendarQueryRequest, @@ -30,8 +35,8 @@ mod tests { calendar::methods::report::{ ReportRequest, calendar_query::{ - CalendarQueryRequest, CompFilterElement, FilterElement, ParamFilterElement, - PropFilterElement, TextMatchElement, + CalendarQueryRequest, FilterElement, ParamFilterElement, TextMatchElement, + comp_filter::CompFilterElement, prop_filter::PropFilterElement, }, }, calendar_object::{CalendarData, CalendarObjectPropName, CalendarObjectPropWrapperName}, @@ -91,16 +96,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: Some("i;ascii-casemap".to_owned()), + negate_condition: None, + 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: Some("i;ascii-casemap".to_owned()), + negate_condition: None, + 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..c431496 --- /dev/null +++ b/crates/caldav/src/calendar/methods/report/calendar_query/prop_filter.rs @@ -0,0 +1,123 @@ +use ical::{ + generator::{IcalCalendar, IcalEvent}, + parser::{ + Component, + ical::component::{IcalJournal, IcalTodo}, + }, + property::Property, +}; +use rustical_ical::{CalendarObject, CalendarObjectComponent}; +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(_time_range) = &self.time_range { + // TODO: implement + return true; + } + + if let Some(TextMatchElement { + collation: _collation, + negate_condition, + needle, + }) = &self.text_match + { + 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; + } + } + + // 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 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), + } + } +}