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 dc0e2e5..cc34a88 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
@@ -137,7 +137,7 @@ impl CompFilterable for CalendarObjectComponent {
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};
- use rustical_dav::xml::{NegateCondition, TextCollation, TextMatchElement};
+ use rustical_dav::xml::{MatchType, NegateCondition, TextCollation, TextMatchElement};
use rustical_ical::{CalendarObject, UtcDateTime};
use crate::calendar::methods::report::calendar_query::{
@@ -217,6 +217,7 @@ END:VCALENDAR";
name: "VERSION".to_string(),
time_range: None,
text_match: Some(TextMatchElement {
+ match_type: MatchType::Contains,
needle: "2.0".to_string(),
collation: TextCollation::default(),
negate_condition: NegateCondition::default(),
@@ -240,6 +241,7 @@ END:VCALENDAR";
name: "SUMMARY".to_string(),
time_range: None,
text_match: Some(TextMatchElement {
+ match_type: MatchType::Contains,
collation: TextCollation::default(),
negate_condition: NegateCondition(false),
needle: "weekly".to_string(),
@@ -327,6 +329,7 @@ END:VCALENDAR";
name: "TZID".to_string(),
time_range: None,
text_match: Some(TextMatchElement {
+ match_type: MatchType::Contains,
collation: TextCollation::AsciiCasemap,
negate_condition: NegateCondition::default(),
needle: "Europe/Berlin".to_string(),
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 6e23e78..11d081b 100644
--- a/crates/caldav/src/calendar/methods/report/calendar_query/mod.rs
+++ b/crates/caldav/src/calendar/methods/report/calendar_query/mod.rs
@@ -36,7 +36,9 @@ mod tests {
calendar::methods::report::ReportRequest,
calendar_object::{CalendarData, CalendarObjectPropName, CalendarObjectPropWrapperName},
};
- use rustical_dav::xml::{NegateCondition, PropElement, TextCollation, TextMatchElement};
+ use rustical_dav::xml::{
+ MatchType, NegateCondition, PropElement, TextCollation, TextMatchElement,
+ };
use rustical_xml::XmlDocument;
#[test]
@@ -93,6 +95,7 @@ mod tests {
prop_filter: vec![PropFilterElement {
name: "ATTENDEE".to_owned(),
text_match: Some(TextMatchElement {
+ match_type: MatchType::Contains,
collation: TextCollation::AsciiCasemap,
negate_condition: NegateCondition(false),
needle: "mailto:lisa@example.com".to_string()
@@ -102,6 +105,7 @@ mod tests {
is_not_defined: None,
name: "PARTSTAT".to_owned(),
text_match: Some(TextMatchElement {
+ match_type: MatchType::Contains,
collation: TextCollation::AsciiCasemap,
negate_condition: NegateCondition(false),
needle: "NEEDS-ACTION".to_string()
diff --git a/crates/caldav/src/calendar/prop.rs b/crates/caldav/src/calendar/prop.rs
index efe3085..e87222b 100644
--- a/crates/caldav/src/calendar/prop.rs
+++ b/crates/caldav/src/calendar/prop.rs
@@ -54,6 +54,7 @@ impl Default for SupportedCollationSet {
fn default() -> Self {
Self(vec![
SupportedCollation(TextCollation::AsciiCasemap),
+ SupportedCollation(TextCollation::UnicodeCasemap),
SupportedCollation(TextCollation::Octet),
])
}
diff --git a/crates/caldav/src/calendar/snapshots/rustical_caldav__calendar__tests__propfind-2.snap b/crates/caldav/src/calendar/snapshots/rustical_caldav__calendar__tests__propfind-2.snap
index 4ea5e6b..4710238 100644
--- a/crates/caldav/src/calendar/snapshots/rustical_caldav__calendar__tests__propfind-2.snap
+++ b/crates/caldav/src/calendar/snapshots/rustical_caldav__calendar__tests__propfind-2.snap
@@ -132,6 +132,7 @@ END:VCALENDAR
i;ascii-casemap
+ i;unicode-casemap
i;octet
10000000
diff --git a/crates/dav/src/xml/text_match.rs b/crates/dav/src/xml/text_match.rs
index b79c634..3c88e18 100644
--- a/crates/dav/src/xml/text_match.rs
+++ b/crates/dav/src/xml/text_match.rs
@@ -1,23 +1,23 @@
use ical::property::Property;
use rustical_xml::{ValueDeserialize, XmlDeserialize};
+use std::borrow::Cow;
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub enum TextCollation {
#[default]
AsciiCasemap,
+ UnicodeCasemap,
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 {
+ pub fn normalise<'a>(&self, value: &'a str) -> Cow<'a, str> {
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),
+ Self::AsciiCasemap => Cow::from(value.to_ascii_uppercase()),
+ Self::UnicodeCasemap => Cow::from(value.to_uppercase()),
+ Self::Octet => Cow::from(value),
}
}
}
@@ -26,6 +26,7 @@ impl AsRef for TextCollation {
fn as_ref(&self) -> &str {
match self {
Self::AsciiCasemap => "i;ascii-casemap",
+ Self::UnicodeCasemap => "i;unicode-casemap",
Self::Octet => "i;octet",
}
}
@@ -35,6 +36,7 @@ impl ValueDeserialize for TextCollation {
fn deserialize(val: &str) -> Result {
match val {
"i;ascii-casemap" => Ok(Self::AsciiCasemap),
+ "i;unicode-casemap" => Ok(Self::UnicodeCasemap),
"i;octet" => Ok(Self::Octet),
_ => Err(rustical_xml::XmlError::InvalidVariant(format!(
"Invalid collation: {val}"
@@ -58,6 +60,45 @@ impl ValueDeserialize for NegateCondition {
}
}
+#[derive(Clone, Debug, PartialEq, Eq, Default)]
+pub enum MatchType {
+ Equals,
+ #[default]
+ Contains,
+ StartsWith,
+ EndsWith,
+}
+
+impl MatchType {
+ pub fn match_text(&self, collation: &TextCollation, needle: &str, haystack: &str) -> bool {
+ let haystack = collation.normalise(haystack);
+ let needle = collation.normalise(needle);
+
+ match &self {
+ Self::Equals => haystack == needle,
+ Self::Contains => haystack.contains(needle.as_ref()),
+ Self::StartsWith => haystack.starts_with(needle.as_ref()),
+ Self::EndsWith => haystack.ends_with(needle.as_ref()),
+ }
+ }
+}
+
+impl ValueDeserialize for MatchType {
+ fn deserialize(val: &str) -> Result {
+ Ok(match val {
+ "equals" => Self::Equals,
+ "contains" => Self::Contains,
+ "starts-with" => Self::StartsWith,
+ "ends-with" => Self::EndsWith,
+ _ => {
+ return Err(rustical_xml::XmlError::InvalidVariant(format!(
+ "Invalid match-type parameter: {val}"
+ )));
+ }
+ })
+ }
+}
+
#[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)]
pub struct TextMatchElement {
@@ -65,6 +106,8 @@ pub struct TextMatchElement {
pub collation: TextCollation,
#[xml(ty = "attr", default = "Default::default")]
pub negate_condition: NegateCondition,
+ #[xml(ty = "attr", default = "Default::default")]
+ pub match_type: MatchType,
#[xml(ty = "text")]
pub needle: String,
}
@@ -76,12 +119,13 @@ impl TextMatchElement {
collation,
negate_condition,
needle,
+ match_type,
} = self;
let matches = property
.value
.as_ref()
- .is_some_and(|haystack| collation.match_text(needle, haystack));
+ .is_some_and(|haystack| match_type.match_text(collation, needle, haystack));
// XOR
negate_condition.0 ^ matches
@@ -90,14 +134,20 @@ impl TextMatchElement {
#[cfg(test)]
mod tests {
+ use crate::xml::MatchType;
+
use super::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"));
+ assert!(!MatchType::Contains.match_text(&TextCollation::AsciiCasemap, "GrÜN", "grünsd"));
+ assert!(MatchType::Contains.match_text(&TextCollation::AsciiCasemap, "GrüN", "grün"));
+ assert!(!MatchType::Contains.match_text(&TextCollation::Octet, "GrüN", "grün"));
+ assert!(MatchType::Contains.match_text(&TextCollation::UnicodeCasemap, "GrÜN", "grün"));
+ assert!(MatchType::Contains.match_text(&TextCollation::AsciiCasemap, "GrüN", "grün"));
+ assert!(MatchType::Contains.match_text(&TextCollation::AsciiCasemap, "GrüN", "grün"));
+ assert!(MatchType::StartsWith.match_text(&TextCollation::Octet, "hello", "hello you"));
+ assert!(MatchType::EndsWith.match_text(&TextCollation::Octet, "joe", "joe mama"));
+ assert!(MatchType::Equals.match_text(&TextCollation::UnicodeCasemap, "GrÜN", "grün"));
}
}
diff --git a/src/integration_tests/caldav/snapshots/rustical__integration_tests__caldav__calendar__propfind_body.snap b/src/integration_tests/caldav/snapshots/rustical__integration_tests__caldav__calendar__propfind_body.snap
index 86b6945..fa54706 100644
--- a/src/integration_tests/caldav/snapshots/rustical__integration_tests__caldav__calendar__propfind_body.snap
+++ b/src/integration_tests/caldav/snapshots/rustical__integration_tests__caldav__calendar__propfind_body.snap
@@ -136,6 +136,7 @@ END:VCALENDAR
i;ascii-casemap
+ i;unicode-casemap
i;octet
10000000