refactor text-match to support collations

This commit is contained in:
Lennart
2025-11-02 17:48:35 +01:00
parent cd9993cd97
commit 32f43951ac
5 changed files with 118 additions and 46 deletions

View File

@@ -139,8 +139,10 @@ mod tests {
use rustical_ical::{CalendarObject, UtcDateTime}; use rustical_ical::{CalendarObject, UtcDateTime};
use crate::calendar::methods::report::calendar_query::{ use crate::calendar::methods::report::calendar_query::{
CompFilterable, TextMatchElement, TimeRangeElement, comp_filter::CompFilterElement, CompFilterable, TextMatchElement, TimeRangeElement,
comp_filter::CompFilterElement,
prop_filter::PropFilterElement, prop_filter::PropFilterElement,
text_match::{NegateCondition, TextCollation},
}; };
const ICS: &str = r"BEGIN:VCALENDAR const ICS: &str = r"BEGIN:VCALENDAR
@@ -217,8 +219,8 @@ END:VCALENDAR";
time_range: None, time_range: None,
text_match: Some(TextMatchElement { text_match: Some(TextMatchElement {
needle: "2.0".to_string(), needle: "2.0".to_string(),
collation: None, collation: TextCollation::default(),
negate_condition: None, negate_condition: NegateCondition::default(),
}), }),
param_filter: vec![], param_filter: vec![],
}, },
@@ -239,8 +241,8 @@ END:VCALENDAR";
name: "SUMMARY".to_string(), name: "SUMMARY".to_string(),
time_range: None, time_range: None,
text_match: Some(TextMatchElement { text_match: Some(TextMatchElement {
collation: None, collation: TextCollation::default(),
negate_condition: None, negate_condition: NegateCondition(false),
needle: "weekly".to_string(), needle: "weekly".to_string(),
}), }),
param_filter: vec![], param_filter: vec![],
@@ -326,8 +328,8 @@ END:VCALENDAR";
name: "TZID".to_string(), name: "TZID".to_string(),
time_range: None, time_range: None,
text_match: Some(TextMatchElement { text_match: Some(TextMatchElement {
collation: None, collation: TextCollation::AsciiCasemap,
negate_condition: None, negate_condition: NegateCondition::default(),
needle: "Europe/Berlin".to_string(), needle: "Europe/Berlin".to_string(),
}), }),
param_filter: vec![], param_filter: vec![],

View File

@@ -1,5 +1,8 @@
use crate::{ use crate::{
calendar::methods::report::calendar_query::comp_filter::{CompFilterElement, CompFilterable}, calendar::methods::report::calendar_query::{
TextMatchElement,
comp_filter::{CompFilterElement, CompFilterable},
},
calendar_object::CalendarObjectPropWrapperName, calendar_object::CalendarObjectPropWrapperName,
}; };
use rustical_dav::xml::PropfindType; use rustical_dav::xml::PropfindType;
@@ -29,18 +32,6 @@ pub struct ParamFilterElement {
pub(crate) name: String, pub(crate) name: String,
} }
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
pub struct TextMatchElement {
#[xml(ty = "attr")]
pub(crate) collation: Option<String>,
#[xml(ty = "attr")]
// "yes" or "no", default: "no"
pub(crate) negate_condition: Option<String>,
#[xml(ty = "text")]
pub(crate) needle: String,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq)] #[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)] #[allow(dead_code)]
// https://datatracker.ietf.org/doc/html/rfc4791#section-9.7 // https://datatracker.ietf.org/doc/html/rfc4791#section-9.7

View File

@@ -5,11 +5,14 @@ use rustical_store::CalendarStore;
mod comp_filter; mod comp_filter;
mod elements; mod elements;
mod prop_filter; mod prop_filter;
pub mod text_match;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use comp_filter::{CompFilterElement, CompFilterable}; pub use comp_filter::{CompFilterElement, CompFilterable};
pub use elements::*; pub use elements::*;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use prop_filter::{PropFilterElement, PropFilterable}; pub use prop_filter::{PropFilterElement, PropFilterable};
#[allow(unused_imports)]
pub use text_match::TextMatchElement;
pub async fn get_objects_calendar_query<C: CalendarStore>( pub async fn get_objects_calendar_query<C: CalendarStore>(
cal_query: &CalendarQueryRequest, cal_query: &CalendarQueryRequest,
@@ -36,7 +39,9 @@ mod tests {
ReportRequest, ReportRequest,
calendar_query::{ calendar_query::{
CalendarQueryRequest, FilterElement, ParamFilterElement, TextMatchElement, 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}, calendar_object::{CalendarData, CalendarObjectPropName, CalendarObjectPropWrapperName},
@@ -96,8 +101,8 @@ mod tests {
prop_filter: vec![PropFilterElement { prop_filter: vec![PropFilterElement {
name: "ATTENDEE".to_owned(), name: "ATTENDEE".to_owned(),
text_match: Some(TextMatchElement { text_match: Some(TextMatchElement {
collation: Some("i;ascii-casemap".to_owned()), collation: TextCollation::AsciiCasemap,
negate_condition: None, negate_condition: NegateCondition(false),
needle: "mailto:lisa@example.com".to_string() needle: "mailto:lisa@example.com".to_string()
}), }),
is_not_defined: None, is_not_defined: None,
@@ -105,8 +110,8 @@ mod tests {
is_not_defined: None, is_not_defined: None,
name: "PARTSTAT".to_owned(), name: "PARTSTAT".to_owned(),
text_match: Some(TextMatchElement { text_match: Some(TextMatchElement {
collation: Some("i;ascii-casemap".to_owned()), collation: TextCollation::AsciiCasemap,
negate_condition: None, negate_condition: NegateCondition(false),
needle: "NEEDS-ACTION".to_string() needle: "NEEDS-ACTION".to_string()
}), }),
}], }],

View File

@@ -64,28 +64,10 @@ impl PropFilterElement {
return true; return true;
} }
if let Some(TextMatchElement { if let Some(text_match) = &self.text_match
collation: _collation, && !text_match.match_property(property)
negate_condition,
needle,
}) = &self.text_match
{ {
let mut matches = property return false;
.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 // TODO: param-filter

View File

@@ -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<Self, rustical_xml::XmlError> {
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<Self, rustical_xml::XmlError> {
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"));
}
}