//! Query filters: `calendar-query` (RFC 4791, 9.7) and `addressbook-query` //! (RFC 6352, 10.5). Parsing and evaluation. use std::borrow::Cow; use std::ops::Range; use calcard::icalendar::{ ICalendar, ICalendarComponentType, ICalendarEntry, ICalendarParameterName, ICalendarParameterValue, ICalendarProperty, ICalendarRelated, ICalendarValue, }; use calcard::vcard::{VCard, VCardVersion}; use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc}; use unicode_normalization::UnicodeNormalization; use xmltree::Element; use crate::expand::{expand, stamp}; use crate::freebusy::periods; use crate::report::Refused; use crate::xml::{CALDAV, CARDDAV, Name, child, elements, text}; use crate::zone::{Zone, Zones, add}; pub type TimeRange = Range>; #[derive(Debug, Clone, PartialEq)] pub struct CompFilter { /// Upper case. pub name: String, pub not_defined: bool, pub time_range: Option, pub props: Vec, pub comps: Vec, } #[derive(Debug, Clone, PartialEq)] pub struct PropFilter { pub name: String, pub not_defined: bool, pub time_range: Option, pub text: Option, pub params: Vec, } #[derive(Debug, Clone, PartialEq)] pub struct ParamFilter { pub name: String, pub not_defined: bool, pub text: Option, } #[derive(Debug, Clone, PartialEq)] pub struct CardFilter { /// `test="allof"`. The default is `anyof`. pub all: bool, pub props: Vec, } #[derive(Debug, Clone, PartialEq)] pub struct CardPropFilter { pub name: String, pub all: bool, pub not_defined: bool, pub texts: Vec, pub params: Vec, } #[derive(Debug, Clone, PartialEq)] pub struct TextMatch { pub text: String, pub collation: Collation, pub match_type: MatchType, pub negate: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Collation { Octet, AsciiCasemap, UnicodeCasemap, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MatchType { Equals, Contains, StartsWith, EndsWith, } impl Collation { fn fold<'a>(self, s: &'a str) -> Cow<'a, str> { match self { Collation::Octet => Cow::Borrowed(s), Collation::AsciiCasemap => Cow::Owned(s.to_ascii_lowercase()), // Lowercase after NFKD, so compatibility forms such as U+FB01 // (the "fi" ligature) fold too. Collation::UnicodeCasemap => { Cow::Owned(s.nfkd().flat_map(char::to_lowercase).collect()) } } } } impl TextMatch { /// `ns` picks the defaults: CalDAV folds ASCII only and CardDAV Unicode. fn parse(e: &Element, ns: &str) -> Result { let collation = match e.attributes.get("collation").map(String::as_str) { None if ns == CARDDAV => Collation::UnicodeCasemap, None => Collation::AsciiCasemap, Some("i;octet") => Collation::Octet, Some("i;ascii-casemap") => Collation::AsciiCasemap, Some("i;unicode-casemap") => Collation::UnicodeCasemap, Some(_) => return Err(Refused::Condition(Name::new(ns, "supported-collation"))), }; let match_type = match e.attributes.get("match-type").map(String::as_str) { None | Some("contains") => MatchType::Contains, Some("equals") => MatchType::Equals, Some("starts-with") => MatchType::StartsWith, Some("ends-with") => MatchType::EndsWith, Some(_) => return Err(Refused::Invalid), }; Ok(TextMatch { text: text(e), collation, match_type, negate: e.attributes.get("negate-condition").map(String::as_str) == Some("yes"), }) } pub fn matches(&self, value: &str) -> bool { let (v, t) = (self.collation.fold(value), self.collation.fold(&self.text)); let hit = match self.match_type { MatchType::Equals => v == t, MatchType::Contains => v.contains(&*t), MatchType::StartsWith => v.starts_with(&*t), MatchType::EndsWith => v.ends_with(&*t), }; hit != self.negate } } // --------------------------------------------------------------------------- // Parsing // --------------------------------------------------------------------------- /// The `` of a calendar-query. pub fn calendar_filter(e: &Element) -> Result { let root = comp_filter(child(e, CALDAV, "comp-filter").ok_or(Refused::Invalid)?)?; if root.name != "VCALENDAR" { return Err(Refused::Condition(Name::new(CALDAV, "valid-filter"))); } Ok(root) } fn comp_filter(e: &Element) -> Result { let mut f = CompFilter { name: name_attr(e)?.to_ascii_uppercase(), not_defined: false, time_range: None, props: Vec::new(), comps: Vec::new(), }; for c in elements(e).filter(|c| Name::of(c).ns == CALDAV) { match c.name.as_str() { "is-not-defined" => f.not_defined = true, "time-range" => f.time_range = Some(time_range(c)?), "prop-filter" => f.props.push(prop_filter(c)?), "comp-filter" => f.comps.push(comp_filter(c)?), _ => {} } } Ok(f) } fn prop_filter(e: &Element) -> Result { let mut f = PropFilter { name: name_attr(e)?.to_string(), not_defined: false, time_range: None, text: None, params: Vec::new(), }; for c in elements(e).filter(|c| Name::of(c).ns == CALDAV) { match c.name.as_str() { "is-not-defined" => f.not_defined = true, "time-range" => f.time_range = Some(time_range(c)?), "text-match" => f.text = Some(TextMatch::parse(c, CALDAV)?), "param-filter" => f.params.push(param_filter(c, CALDAV)?), _ => {} } } Ok(f) } fn param_filter(e: &Element, ns: &str) -> Result { let mut f = ParamFilter { name: name_attr(e)?.to_string(), not_defined: false, text: None, }; for c in elements(e).filter(|c| Name::of(c).ns == ns) { match c.name.as_str() { "is-not-defined" => f.not_defined = true, "text-match" => f.text = Some(TextMatch::parse(c, ns)?), _ => {} } } Ok(f) } /// The `` of an addressbook-query. pub fn card_filter(e: &Element) -> Result { let props = elements(e) .filter(|c| Name::of(c).is(CARDDAV, "prop-filter")) .map(card_prop_filter) .collect::>()?; Ok(CardFilter { all: all_of(e)?, props, }) } fn card_prop_filter(e: &Element) -> Result { let mut f = CardPropFilter { name: name_attr(e)?.to_string(), all: all_of(e)?, not_defined: false, texts: Vec::new(), params: Vec::new(), }; for c in elements(e).filter(|c| Name::of(c).ns == CARDDAV) { match c.name.as_str() { "is-not-defined" => f.not_defined = true, "text-match" => f.texts.push(TextMatch::parse(c, CARDDAV)?), "param-filter" => f.params.push(param_filter(c, CARDDAV)?), _ => {} } } Ok(f) } fn all_of(e: &Element) -> Result { match e.attributes.get("test").map(String::as_str) { None | Some("anyof") => Ok(false), Some("allof") => Ok(true), Some(_) => Err(Refused::Invalid), } } fn name_attr(e: &Element) -> Result<&str, Refused> { e.attributes .get("name") .map(String::as_str) .ok_or(Refused::Invalid) } /// A `time-range` or `expand` element. A missing bound is open. pub fn time_range(e: &Element) -> Result { let at = |k: &str| { e.attributes .get(k) .map(|v| { NaiveDateTime::parse_from_str(v, "%Y%m%dT%H%M%SZ") .map(|t| t.and_utc()) .map_err(|_| Refused::Invalid) }) .transpose() }; let start = at("start")?.unwrap_or(DateTime::::MIN_UTC); let end = at("end")?.unwrap_or(DateTime::::MAX_UTC); if start >= end { return Err(Refused::Invalid); } Ok(start..end) } // --------------------------------------------------------------------------- // Calendar evaluation // --------------------------------------------------------------------------- /// Whether a calendar object matches `filter`. `floating` interprets values /// without a zone. pub fn matches_calendar(cal: &ICalendar, filter: &CompFilter, floating: &Zone) -> bool { let ctx = Ctx { cal, zones: Zones::new(cal, floating.clone()), floating, }; ctx.comp(None, filter) } struct Ctx<'a> { cal: &'a ICalendar, zones: Zones, floating: &'a Zone, } impl Ctx<'_> { fn comp(&self, parent: Option, f: &CompFilter) -> bool { let children: Vec = match parent { None => (!self.cal.components.is_empty()) .then_some(0) .into_iter() .collect(), Some(p) => self.cal.components[p] .component_ids .iter() .map(|&i| i as usize) .collect(), }; let mut found = children.into_iter().filter(|&i| { self.cal.components[i] .component_type .as_str() .eq_ignore_ascii_case(&f.name) }); if f.not_defined { return found.next().is_none(); } found.any(|i| { f.time_range.as_ref().is_none_or(|r| self.overlaps(i, r)) && f.props.iter().all(|p| self.prop(i, p)) && f.comps.iter().all(|c| self.comp(Some(i), c)) }) } /// The time-range rules of RFC 4791, 9.9. fn overlaps(&self, i: usize, r: &TimeRange) -> bool { let c = &self.cal.components[i]; let has = |p: ICalendarProperty| c.has_property(&p); let time = |p: ICalendarProperty| { let e = c.property(&p)?; stamp( &self.zones, e.values.first()?.as_partial_date_time()?, e.tz_id(), ) .map(|s| s.utc()) }; match c.component_type { ICalendarComponentType::VEvent | ICalendarComponentType::VTodo | ICalendarComponentType::VJournal if has(ICalendarProperty::Dtstart) => { let todo = c.component_type == ICalendarComponentType::VTodo; let (due, duration) = ( has(ICalendarProperty::Due), has(ICalendarProperty::Duration), ); // One second wider, so that the exact rules below decide the // instances that only touch the range. let second = TimeDelta::seconds(1); let exp = expand( self.cal, add(r.start, -second)..add(r.end, second), self.floating.clone(), ); // Unknown instances may overlap, so the object stays in. if exp.truncated { return true; } exp.instances.iter().filter(|x| x.component == i).any(|x| { let (s, e) = (x.start, x.end); match (todo, due, duration) { (true, _, true) => r.start <= e && (r.end > s || r.end >= e), (true, true, _) => { (r.start < e || r.start <= s) && (r.end > s || r.end >= e) } (true, ..) => r.start <= s && r.end > s, _ if s == e => r.start <= s && r.end > s, _ => r.start < e && r.end > s, } }) } ICalendarComponentType::VTodo => match ( time(ICalendarProperty::Due), time(ICalendarProperty::Completed), time(ICalendarProperty::Created), ) { (Some(due), _, _) => r.start < due && r.end >= due, (None, Some(done), Some(made)) => { (r.start <= made || r.start <= done) && (r.end >= made || r.end >= done) } (None, Some(done), None) => r.start <= done && r.end >= done, (None, None, Some(made)) => r.end > made, (None, None, None) => true, }, ICalendarComponentType::VFreebusy => { let busy: Vec<_> = c .properties(&ICalendarProperty::Freebusy) .flat_map(|e| periods(&self.zones, e)) .collect(); if !busy.is_empty() { return busy.iter().any(|(s, e)| r.start < *e && r.end > *s); } match ( time(ICalendarProperty::Dtstart), time(ICalendarProperty::Dtend), ) { (Some(s), Some(e)) => r.start <= e && r.end > s, _ => false, } } ICalendarComponentType::VAlarm => self.alarm_overlaps(i, r), _ => false, } } /// Whether a trigger of the alarm, repetitions included, falls into `r`. /// A relative trigger fires once per instance of the parent component. fn alarm_overlaps(&self, alarm: usize, r: &TimeRange) -> bool { let a = &self.cal.components[alarm]; let Some(trigger) = a.property(&ICalendarProperty::Trigger) else { return false; }; let repeat = a .property(&ICalendarProperty::Repeat) .and_then(|e| e.values.first()?.as_integer()) .unwrap_or(0) .clamp(0, 1000) as i32; let every = match a .property(&ICalendarProperty::Duration) .and_then(|e| e.values.first()) { Some(ICalendarValue::Duration(d)) => d.to_time_delta().unwrap_or_default(), _ => TimeDelta::zero(), }; let hit = |base: DateTime| { (0..=repeat).any(|k| { let t = add(base, every.checked_mul(k).unwrap_or(TimeDelta::MAX)); r.start <= t && r.end > t }) }; match trigger.values.first() { Some(ICalendarValue::PartialDateTime(p)) => { stamp(&self.zones, p, trigger.tz_id()).is_some_and(|s| hit(s.utc())) } Some(ICalendarValue::Duration(d)) => { let Some(offset) = d.to_time_delta() else { return false; }; let Some(parent) = self .cal .components .iter() .position(|c| c.component_ids.contains(&(alarm as u32))) else { return false; }; let from_end = trigger.parameter(&ICalendarParameterName::Related) == Some(&ICalendarParameterValue::Related(ICalendarRelated::End)); let reach = offset .abs() .checked_add(&every.checked_mul(repeat).unwrap_or(TimeDelta::MAX)) .unwrap_or(TimeDelta::MAX) .checked_add(&TimeDelta::seconds(1)) .unwrap_or(TimeDelta::MAX); let exp = expand( self.cal, add(r.start, -reach)..add(r.end, reach), self.floating.clone(), ); if exp.truncated { return true; } exp.instances .iter() .filter(|x| x.component == parent) .any(|x| hit(add(if from_end { x.end } else { x.start }, offset))) } _ => false, } } fn prop(&self, i: usize, f: &PropFilter) -> bool { let mut found = self.cal.components[i] .entries .iter() .filter(|e| e.name.as_str().eq_ignore_ascii_case(&f.name)); if f.not_defined { return found.next().is_none(); } found.any(|e| { let l = ical_line(e); f.time_range.as_ref().is_none_or(|r| { e.values .iter() .filter_map(|v| stamp(&self.zones, v.as_partial_date_time()?, e.tz_id())) .any(|s| r.start <= s.utc() && r.end > s.utc()) }) && f.text.as_ref().is_none_or(|t| t.matches(&l.value)) && f.params.iter().all(|p| param_matches(&l, p)) }) } } // --------------------------------------------------------------------------- // Card evaluation // --------------------------------------------------------------------------- pub fn matches_card(card: &VCard, f: &CardFilter) -> bool { if f.props.is_empty() { return true; } let v4 = card.version() == Some(VCardVersion::V4_0); let lines: Vec = card .entries .iter() .map(|e| { let mut s = String::new(); let _ = e.write_to(&mut s, v4); line(&s) }) .collect(); let hit = |p: &CardPropFilter| card_prop(&lines, p); if f.all { f.props.iter().all(hit) } else { f.props.iter().any(hit) } } /// Each text-match and param-filter is tested against every instance of the /// property; `test` combines their results. fn card_prop(lines: &[Line], f: &CardPropFilter) -> bool { let found: Vec<&Line> = lines .iter() .filter(|l| l.name.eq_ignore_ascii_case(&f.name)) .collect(); if f.not_defined { return found.is_empty(); } if found.is_empty() { return false; } let mut tests = f .texts .iter() .map(|t| found.iter().any(|l| t.matches(&l.value))) .chain( f.params .iter() .map(|p| found.iter().any(|l| param_matches(l, p))), ) .peekable(); if tests.peek().is_none() { return true; } if f.all { tests.all(|b| b) } else { tests.any(|b| b) } } fn param_matches(l: &Line, f: &ParamFilter) -> bool { match l .params .iter() .find(|(n, _)| n.eq_ignore_ascii_case(&f.name)) { None => f.not_defined, Some(_) if f.not_defined => false, Some((_, values)) => f .text .as_ref() .is_none_or(|t| values.iter().any(|v| t.matches(v))), } } // --------------------------------------------------------------------------- // Content lines // --------------------------------------------------------------------------- /// A property as text, the way text-match sees it: the group dropped, the /// parameter values unquoted, the value unescaped. struct Line { name: String, params: Vec<(String, Vec)>, value: String, } fn ical_line(e: &ICalendarEntry) -> Line { let mut s = String::new(); let _ = e.write_to(&mut s); line(&s) } /// Parses one content line as calcard's writer produces it. fn line(written: &str) -> Line { let unfolded = written.replace("\r\n ", "").replace("\r\n\t", ""); let unfolded = unfolded.trim_end_matches(['\r', '\n']); let head = split_unquoted(unfolded, ':')[0]; let value = unfolded.get(head.len() + 1..).unwrap_or_default(); let mut head = split_unquoted(head, ';').into_iter(); let name = head.next().unwrap_or_default(); let name = name.rsplit('.').next().unwrap_or(name).to_ascii_uppercase(); let params = head .filter_map(|p| p.split_once('=')) .map(|(k, v)| { let values = split_unquoted(v, ',') .into_iter() .map(|v| v.trim_matches('"').to_string()) .collect(); (k.to_ascii_uppercase(), values) }) .collect(); Line { name, params, value: unescape(value), } } fn split_unquoted(s: &str, sep: char) -> Vec<&str> { let mut out = Vec::new(); let (mut quoted, mut from) = (false, 0); for (i, c) in s.char_indices() { if c == '"' { quoted = !quoted; } else if c == sep && !quoted { out.push(&s[from..i]); from = i + 1; } } out.push(&s[from..]); out } fn unescape(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut chars = s.chars(); while let Some(c) = chars.next() { if c != '\\' { out.push(c); continue; } match chars.next() { Some('n' | 'N') => out.push('\n'), Some(c) => out.push(c), None => out.push('\\'), } } out }