text-match: Support match types and unicode-casemap collation

This commit is contained in:
Lennart
2025-12-31 12:24:57 +01:00
parent 578ddde36d
commit 17ba8faef2
6 changed files with 74 additions and 14 deletions

View File

@@ -137,7 +137,7 @@ impl CompFilterable for CalendarObjectComponent {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use chrono::{TimeZone, Utc}; 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 rustical_ical::{CalendarObject, UtcDateTime};
use crate::calendar::methods::report::calendar_query::{ use crate::calendar::methods::report::calendar_query::{
@@ -217,6 +217,7 @@ END:VCALENDAR";
name: "VERSION".to_string(), name: "VERSION".to_string(),
time_range: None, time_range: None,
text_match: Some(TextMatchElement { text_match: Some(TextMatchElement {
match_type: MatchType::Contains,
needle: "2.0".to_string(), needle: "2.0".to_string(),
collation: TextCollation::default(), collation: TextCollation::default(),
negate_condition: NegateCondition::default(), negate_condition: NegateCondition::default(),
@@ -240,6 +241,7 @@ END:VCALENDAR";
name: "SUMMARY".to_string(), name: "SUMMARY".to_string(),
time_range: None, time_range: None,
text_match: Some(TextMatchElement { text_match: Some(TextMatchElement {
match_type: MatchType::Contains,
collation: TextCollation::default(), collation: TextCollation::default(),
negate_condition: NegateCondition(false), negate_condition: NegateCondition(false),
needle: "weekly".to_string(), needle: "weekly".to_string(),
@@ -327,6 +329,7 @@ END:VCALENDAR";
name: "TZID".to_string(), name: "TZID".to_string(),
time_range: None, time_range: None,
text_match: Some(TextMatchElement { text_match: Some(TextMatchElement {
match_type: MatchType::Contains,
collation: TextCollation::AsciiCasemap, collation: TextCollation::AsciiCasemap,
negate_condition: NegateCondition::default(), negate_condition: NegateCondition::default(),
needle: "Europe/Berlin".to_string(), needle: "Europe/Berlin".to_string(),

View File

@@ -36,7 +36,9 @@ mod tests {
calendar::methods::report::ReportRequest, calendar::methods::report::ReportRequest,
calendar_object::{CalendarData, CalendarObjectPropName, CalendarObjectPropWrapperName}, 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; use rustical_xml::XmlDocument;
#[test] #[test]
@@ -93,6 +95,7 @@ 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 {
match_type: MatchType::Contains,
collation: TextCollation::AsciiCasemap, collation: TextCollation::AsciiCasemap,
negate_condition: NegateCondition(false), negate_condition: NegateCondition(false),
needle: "mailto:lisa@example.com".to_string() needle: "mailto:lisa@example.com".to_string()
@@ -102,6 +105,7 @@ 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 {
match_type: MatchType::Contains,
collation: TextCollation::AsciiCasemap, collation: TextCollation::AsciiCasemap,
negate_condition: NegateCondition(false), negate_condition: NegateCondition(false),
needle: "NEEDS-ACTION".to_string() needle: "NEEDS-ACTION".to_string()

View File

@@ -54,6 +54,7 @@ impl Default for SupportedCollationSet {
fn default() -> Self { fn default() -> Self {
Self(vec![ Self(vec![
SupportedCollation(TextCollation::AsciiCasemap), SupportedCollation(TextCollation::AsciiCasemap),
SupportedCollation(TextCollation::UnicodeCasemap),
SupportedCollation(TextCollation::Octet), SupportedCollation(TextCollation::Octet),
]) ])
} }

View File

@@ -132,6 +132,7 @@ END:VCALENDAR
</supported-calendar-data> </supported-calendar-data>
<supported-collation-set xmlns="urn:ietf:params:xml:ns:caldav"> <supported-collation-set xmlns="urn:ietf:params:xml:ns:caldav">
<supported-collation xmlns="urn:ietf:params:xml:ns:caldav">i;ascii-casemap</supported-collation> <supported-collation xmlns="urn:ietf:params:xml:ns:caldav">i;ascii-casemap</supported-collation>
<supported-collation xmlns="urn:ietf:params:xml:ns:caldav">i;unicode-casemap</supported-collation>
<supported-collation xmlns="urn:ietf:params:xml:ns:caldav">i;octet</supported-collation> <supported-collation xmlns="urn:ietf:params:xml:ns:caldav">i;octet</supported-collation>
</supported-collation-set> </supported-collation-set>
<max-resource-size xmlns="DAV:">10000000</max-resource-size> <max-resource-size xmlns="DAV:">10000000</max-resource-size>

View File

@@ -1,23 +1,23 @@
use ical::property::Property; use ical::property::Property;
use rustical_xml::{ValueDeserialize, XmlDeserialize}; use rustical_xml::{ValueDeserialize, XmlDeserialize};
use std::borrow::Cow;
#[derive(Clone, Debug, PartialEq, Eq, Default)] #[derive(Clone, Debug, PartialEq, Eq, Default)]
pub enum TextCollation { pub enum TextCollation {
#[default] #[default]
AsciiCasemap, AsciiCasemap,
UnicodeCasemap,
Octet, Octet,
} }
impl TextCollation { impl TextCollation {
// Check whether a haystack contains a needle respecting the collation
#[must_use] #[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 { match self {
// https://datatracker.ietf.org/doc/html/rfc4790#section-9.2 // https://datatracker.ietf.org/doc/html/rfc4790#section-9.2
Self::AsciiCasemap => haystack Self::AsciiCasemap => Cow::from(value.to_ascii_uppercase()),
.to_ascii_uppercase() Self::UnicodeCasemap => Cow::from(value.to_uppercase()),
.contains(&needle.to_ascii_uppercase()), Self::Octet => Cow::from(value),
Self::Octet => haystack.contains(needle),
} }
} }
} }
@@ -26,6 +26,7 @@ impl AsRef<str> for TextCollation {
fn as_ref(&self) -> &str { fn as_ref(&self) -> &str {
match self { match self {
Self::AsciiCasemap => "i;ascii-casemap", Self::AsciiCasemap => "i;ascii-casemap",
Self::UnicodeCasemap => "i;unicode-casemap",
Self::Octet => "i;octet", Self::Octet => "i;octet",
} }
} }
@@ -35,6 +36,7 @@ impl ValueDeserialize for TextCollation {
fn deserialize(val: &str) -> Result<Self, rustical_xml::XmlError> { fn deserialize(val: &str) -> Result<Self, rustical_xml::XmlError> {
match val { match val {
"i;ascii-casemap" => Ok(Self::AsciiCasemap), "i;ascii-casemap" => Ok(Self::AsciiCasemap),
"i;unicode-casemap" => Ok(Self::UnicodeCasemap),
"i;octet" => Ok(Self::Octet), "i;octet" => Ok(Self::Octet),
_ => Err(rustical_xml::XmlError::InvalidVariant(format!( _ => Err(rustical_xml::XmlError::InvalidVariant(format!(
"Invalid collation: {val}" "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<Self, rustical_xml::XmlError> {
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)] #[derive(XmlDeserialize, Clone, Debug, PartialEq, Eq)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct TextMatchElement { pub struct TextMatchElement {
@@ -65,6 +106,8 @@ pub struct TextMatchElement {
pub collation: TextCollation, pub collation: TextCollation,
#[xml(ty = "attr", default = "Default::default")] #[xml(ty = "attr", default = "Default::default")]
pub negate_condition: NegateCondition, pub negate_condition: NegateCondition,
#[xml(ty = "attr", default = "Default::default")]
pub match_type: MatchType,
#[xml(ty = "text")] #[xml(ty = "text")]
pub needle: String, pub needle: String,
} }
@@ -76,12 +119,13 @@ impl TextMatchElement {
collation, collation,
negate_condition, negate_condition,
needle, needle,
match_type,
} = self; } = self;
let matches = property let matches = property
.value .value
.as_ref() .as_ref()
.is_some_and(|haystack| collation.match_text(needle, haystack)); .is_some_and(|haystack| match_type.match_text(collation, needle, haystack));
// XOR // XOR
negate_condition.0 ^ matches negate_condition.0 ^ matches
@@ -90,14 +134,20 @@ impl TextMatchElement {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::xml::MatchType;
use super::TextCollation; use super::TextCollation;
#[test] #[test]
fn test_collation() { fn test_collation() {
assert!(TextCollation::AsciiCasemap.match_text("GrüN", "grün")); assert!(!MatchType::Contains.match_text(&TextCollation::AsciiCasemap, "GrÜN", "grünsd"));
assert!(!TextCollation::AsciiCasemap.match_text("GrÜN", "grün")); assert!(MatchType::Contains.match_text(&TextCollation::AsciiCasemap, "GrüN", "grün"));
assert!(!TextCollation::Octet.match_text("GrÜN", "grün")); assert!(!MatchType::Contains.match_text(&TextCollation::Octet, "GrüN", "grün"));
assert!(TextCollation::Octet.match_text("hallo", "hallo")); assert!(MatchType::Contains.match_text(&TextCollation::UnicodeCasemap, "GrÜN", "grün"));
assert!(TextCollation::AsciiCasemap.match_text("HaLlo", "hAllo")); 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"));
} }
} }

View File

@@ -136,6 +136,7 @@ END:VCALENDAR
</CAL:supported-calendar-data> </CAL:supported-calendar-data>
<CAL:supported-collation-set> <CAL:supported-collation-set>
<CAL:supported-collation>i;ascii-casemap</CAL:supported-collation> <CAL:supported-collation>i;ascii-casemap</CAL:supported-collation>
<CAL:supported-collation>i;unicode-casemap</CAL:supported-collation>
<CAL:supported-collation>i;octet</CAL:supported-collation> <CAL:supported-collation>i;octet</CAL:supported-collation>
</CAL:supported-collation-set> </CAL:supported-collation-set>
<max-resource-size>10000000</max-resource-size> <max-resource-size>10000000</max-resource-size>