filter.rs
| 1 | //! Query filters: `calendar-query` (RFC 4791, 9.7) and `addressbook-query` |
| 2 | //! (RFC 6352, 10.5). Parsing and evaluation. |
| 3 | |
| 4 | use std::borrow::Cow; |
| 5 | use std::ops::Range; |
| 6 | |
| 7 | use calcard::icalendar::{ |
| 8 | ICalendar, ICalendarComponentType, ICalendarEntry, ICalendarParameterName, |
| 9 | ICalendarParameterValue, ICalendarProperty, ICalendarRelated, ICalendarValue, |
| 10 | }; |
| 11 | use calcard::vcard::{VCard, VCardVersion}; |
| 12 | use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc}; |
| 13 | use unicode_normalization::UnicodeNormalization; |
| 14 | use xmltree::Element; |
| 15 | |
| 16 | use crate::expand::{expand, stamp}; |
| 17 | use crate::freebusy::periods; |
| 18 | use crate::report::Refused; |
| 19 | use crate::xml::{CALDAV, CARDDAV, Name, child, elements, text}; |
| 20 | use crate::zone::{Zone, Zones, add}; |
| 21 | |
| 22 | pub type TimeRange = Range<DateTime<Utc>>; |
| 23 | |
| 24 | #[derive(Debug, Clone, PartialEq)] |
| 25 | pub struct CompFilter { |
| 26 | /// Upper case. |
| 27 | pub name: String, |
| 28 | pub not_defined: bool, |
| 29 | pub time_range: Option<TimeRange>, |
| 30 | pub props: Vec<PropFilter>, |
| 31 | pub comps: Vec<CompFilter>, |
| 32 | } |
| 33 | |
| 34 | #[derive(Debug, Clone, PartialEq)] |
| 35 | pub struct PropFilter { |
| 36 | pub name: String, |
| 37 | pub not_defined: bool, |
| 38 | pub time_range: Option<TimeRange>, |
| 39 | pub text: Option<TextMatch>, |
| 40 | pub params: Vec<ParamFilter>, |
| 41 | } |
| 42 | |
| 43 | #[derive(Debug, Clone, PartialEq)] |
| 44 | pub struct ParamFilter { |
| 45 | pub name: String, |
| 46 | pub not_defined: bool, |
| 47 | pub text: Option<TextMatch>, |
| 48 | } |
| 49 | |
| 50 | #[derive(Debug, Clone, PartialEq)] |
| 51 | pub struct CardFilter { |
| 52 | /// `test="allof"`. The default is `anyof`. |
| 53 | pub all: bool, |
| 54 | pub props: Vec<CardPropFilter>, |
| 55 | } |
| 56 | |
| 57 | #[derive(Debug, Clone, PartialEq)] |
| 58 | pub struct CardPropFilter { |
| 59 | pub name: String, |
| 60 | pub all: bool, |
| 61 | pub not_defined: bool, |
| 62 | pub texts: Vec<TextMatch>, |
| 63 | pub params: Vec<ParamFilter>, |
| 64 | } |
| 65 | |
| 66 | #[derive(Debug, Clone, PartialEq)] |
| 67 | pub struct TextMatch { |
| 68 | pub text: String, |
| 69 | pub collation: Collation, |
| 70 | pub match_type: MatchType, |
| 71 | pub negate: bool, |
| 72 | } |
| 73 | |
| 74 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 75 | pub enum Collation { |
| 76 | Octet, |
| 77 | AsciiCasemap, |
| 78 | UnicodeCasemap, |
| 79 | } |
| 80 | |
| 81 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 82 | pub enum MatchType { |
| 83 | Equals, |
| 84 | Contains, |
| 85 | StartsWith, |
| 86 | EndsWith, |
| 87 | } |
| 88 | |
| 89 | impl Collation { |
| 90 | fn fold<'a>(self, s: &'a str) -> Cow<'a, str> { |
| 91 | match self { |
| 92 | Collation::Octet => Cow::Borrowed(s), |
| 93 | Collation::AsciiCasemap => Cow::Owned(s.to_ascii_lowercase()), |
| 94 | // Lowercase after NFKD, so compatibility forms such as U+FB01 |
| 95 | // (the "fi" ligature) fold too. |
| 96 | Collation::UnicodeCasemap => { |
| 97 | Cow::Owned(s.nfkd().flat_map(char::to_lowercase).collect()) |
| 98 | } |
| 99 | } |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | impl TextMatch { |
| 104 | /// `ns` picks the defaults: CalDAV folds ASCII only and CardDAV Unicode. |
| 105 | fn parse(e: &Element, ns: &str) -> Result<Self, Refused> { |
| 106 | let collation = match e.attributes.get("collation").map(String::as_str) { |
| 107 | None if ns == CARDDAV => Collation::UnicodeCasemap, |
| 108 | None => Collation::AsciiCasemap, |
| 109 | Some("i;octet") => Collation::Octet, |
| 110 | Some("i;ascii-casemap") => Collation::AsciiCasemap, |
| 111 | Some("i;unicode-casemap") => Collation::UnicodeCasemap, |
| 112 | Some(_) => return Err(Refused::Condition(Name::new(ns, "supported-collation"))), |
| 113 | }; |
| 114 | let match_type = match e.attributes.get("match-type").map(String::as_str) { |
| 115 | None | Some("contains") => MatchType::Contains, |
| 116 | Some("equals") => MatchType::Equals, |
| 117 | Some("starts-with") => MatchType::StartsWith, |
| 118 | Some("ends-with") => MatchType::EndsWith, |
| 119 | Some(_) => return Err(Refused::Invalid), |
| 120 | }; |
| 121 | Ok(TextMatch { |
| 122 | text: text(e), |
| 123 | collation, |
| 124 | match_type, |
| 125 | negate: e.attributes.get("negate-condition").map(String::as_str) == Some("yes"), |
| 126 | }) |
| 127 | } |
| 128 | |
| 129 | pub fn matches(&self, value: &str) -> bool { |
| 130 | let (v, t) = (self.collation.fold(value), self.collation.fold(&self.text)); |
| 131 | let hit = match self.match_type { |
| 132 | MatchType::Equals => v == t, |
| 133 | MatchType::Contains => v.contains(&*t), |
| 134 | MatchType::StartsWith => v.starts_with(&*t), |
| 135 | MatchType::EndsWith => v.ends_with(&*t), |
| 136 | }; |
| 137 | hit != self.negate |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // --------------------------------------------------------------------------- |
| 142 | // Parsing |
| 143 | // --------------------------------------------------------------------------- |
| 144 | |
| 145 | /// The `<C:filter>` of a calendar-query. |
| 146 | pub fn calendar_filter(e: &Element) -> Result<CompFilter, Refused> { |
| 147 | let root = comp_filter(child(e, CALDAV, "comp-filter").ok_or(Refused::Invalid)?)?; |
| 148 | if root.name != "VCALENDAR" { |
| 149 | return Err(Refused::Condition(Name::new(CALDAV, "valid-filter"))); |
| 150 | } |
| 151 | Ok(root) |
| 152 | } |
| 153 | |
| 154 | fn comp_filter(e: &Element) -> Result<CompFilter, Refused> { |
| 155 | let mut f = CompFilter { |
| 156 | name: name_attr(e)?.to_ascii_uppercase(), |
| 157 | not_defined: false, |
| 158 | time_range: None, |
| 159 | props: Vec::new(), |
| 160 | comps: Vec::new(), |
| 161 | }; |
| 162 | for c in elements(e).filter(|c| Name::of(c).ns == CALDAV) { |
| 163 | match c.name.as_str() { |
| 164 | "is-not-defined" => f.not_defined = true, |
| 165 | "time-range" => f.time_range = Some(time_range(c)?), |
| 166 | "prop-filter" => f.props.push(prop_filter(c)?), |
| 167 | "comp-filter" => f.comps.push(comp_filter(c)?), |
| 168 | _ => {} |
| 169 | } |
| 170 | } |
| 171 | Ok(f) |
| 172 | } |
| 173 | |
| 174 | fn prop_filter(e: &Element) -> Result<PropFilter, Refused> { |
| 175 | let mut f = PropFilter { |
| 176 | name: name_attr(e)?.to_string(), |
| 177 | not_defined: false, |
| 178 | time_range: None, |
| 179 | text: None, |
| 180 | params: Vec::new(), |
| 181 | }; |
| 182 | for c in elements(e).filter(|c| Name::of(c).ns == CALDAV) { |
| 183 | match c.name.as_str() { |
| 184 | "is-not-defined" => f.not_defined = true, |
| 185 | "time-range" => f.time_range = Some(time_range(c)?), |
| 186 | "text-match" => f.text = Some(TextMatch::parse(c, CALDAV)?), |
| 187 | "param-filter" => f.params.push(param_filter(c, CALDAV)?), |
| 188 | _ => {} |
| 189 | } |
| 190 | } |
| 191 | Ok(f) |
| 192 | } |
| 193 | |
| 194 | fn param_filter(e: &Element, ns: &str) -> Result<ParamFilter, Refused> { |
| 195 | let mut f = ParamFilter { |
| 196 | name: name_attr(e)?.to_string(), |
| 197 | not_defined: false, |
| 198 | text: None, |
| 199 | }; |
| 200 | for c in elements(e).filter(|c| Name::of(c).ns == ns) { |
| 201 | match c.name.as_str() { |
| 202 | "is-not-defined" => f.not_defined = true, |
| 203 | "text-match" => f.text = Some(TextMatch::parse(c, ns)?), |
| 204 | _ => {} |
| 205 | } |
| 206 | } |
| 207 | Ok(f) |
| 208 | } |
| 209 | |
| 210 | /// The `<CR:filter>` of an addressbook-query. |
| 211 | pub fn card_filter(e: &Element) -> Result<CardFilter, Refused> { |
| 212 | let props = elements(e) |
| 213 | .filter(|c| Name::of(c).is(CARDDAV, "prop-filter")) |
| 214 | .map(card_prop_filter) |
| 215 | .collect::<Result<_, _>>()?; |
| 216 | Ok(CardFilter { |
| 217 | all: all_of(e)?, |
| 218 | props, |
| 219 | }) |
| 220 | } |
| 221 | |
| 222 | fn card_prop_filter(e: &Element) -> Result<CardPropFilter, Refused> { |
| 223 | let mut f = CardPropFilter { |
| 224 | name: name_attr(e)?.to_string(), |
| 225 | all: all_of(e)?, |
| 226 | not_defined: false, |
| 227 | texts: Vec::new(), |
| 228 | params: Vec::new(), |
| 229 | }; |
| 230 | for c in elements(e).filter(|c| Name::of(c).ns == CARDDAV) { |
| 231 | match c.name.as_str() { |
| 232 | "is-not-defined" => f.not_defined = true, |
| 233 | "text-match" => f.texts.push(TextMatch::parse(c, CARDDAV)?), |
| 234 | "param-filter" => f.params.push(param_filter(c, CARDDAV)?), |
| 235 | _ => {} |
| 236 | } |
| 237 | } |
| 238 | Ok(f) |
| 239 | } |
| 240 | |
| 241 | fn all_of(e: &Element) -> Result<bool, Refused> { |
| 242 | match e.attributes.get("test").map(String::as_str) { |
| 243 | None | Some("anyof") => Ok(false), |
| 244 | Some("allof") => Ok(true), |
| 245 | Some(_) => Err(Refused::Invalid), |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | fn name_attr(e: &Element) -> Result<&str, Refused> { |
| 250 | e.attributes |
| 251 | .get("name") |
| 252 | .map(String::as_str) |
| 253 | .ok_or(Refused::Invalid) |
| 254 | } |
| 255 | |
| 256 | /// A `time-range` or `expand` element. A missing bound is open. |
| 257 | pub fn time_range(e: &Element) -> Result<TimeRange, Refused> { |
| 258 | let at = |k: &str| { |
| 259 | e.attributes |
| 260 | .get(k) |
| 261 | .map(|v| { |
| 262 | NaiveDateTime::parse_from_str(v, "%Y%m%dT%H%M%SZ") |
| 263 | .map(|t| t.and_utc()) |
| 264 | .map_err(|_| Refused::Invalid) |
| 265 | }) |
| 266 | .transpose() |
| 267 | }; |
| 268 | let start = at("start")?.unwrap_or(DateTime::<Utc>::MIN_UTC); |
| 269 | let end = at("end")?.unwrap_or(DateTime::<Utc>::MAX_UTC); |
| 270 | if start >= end { |
| 271 | return Err(Refused::Invalid); |
| 272 | } |
| 273 | Ok(start..end) |
| 274 | } |
| 275 | |
| 276 | // --------------------------------------------------------------------------- |
| 277 | // Calendar evaluation |
| 278 | // --------------------------------------------------------------------------- |
| 279 | |
| 280 | /// Whether a calendar object matches `filter`. `floating` interprets values |
| 281 | /// without a zone. |
| 282 | pub fn matches_calendar(cal: &ICalendar, filter: &CompFilter, floating: &Zone) -> bool { |
| 283 | let ctx = Ctx { |
| 284 | cal, |
| 285 | zones: Zones::new(cal, floating.clone()), |
| 286 | floating, |
| 287 | }; |
| 288 | ctx.comp(None, filter) |
| 289 | } |
| 290 | |
| 291 | struct Ctx<'a> { |
| 292 | cal: &'a ICalendar, |
| 293 | zones: Zones, |
| 294 | floating: &'a Zone, |
| 295 | } |
| 296 | |
| 297 | impl Ctx<'_> { |
| 298 | fn comp(&self, parent: Option<usize>, f: &CompFilter) -> bool { |
| 299 | let children: Vec<usize> = match parent { |
| 300 | None => (!self.cal.components.is_empty()) |
| 301 | .then_some(0) |
| 302 | .into_iter() |
| 303 | .collect(), |
| 304 | Some(p) => self.cal.components[p] |
| 305 | .component_ids |
| 306 | .iter() |
| 307 | .map(|&i| i as usize) |
| 308 | .collect(), |
| 309 | }; |
| 310 | let mut found = children.into_iter().filter(|&i| { |
| 311 | self.cal.components[i] |
| 312 | .component_type |
| 313 | .as_str() |
| 314 | .eq_ignore_ascii_case(&f.name) |
| 315 | }); |
| 316 | if f.not_defined { |
| 317 | return found.next().is_none(); |
| 318 | } |
| 319 | found.any(|i| { |
| 320 | f.time_range.as_ref().is_none_or(|r| self.overlaps(i, r)) |
| 321 | && f.props.iter().all(|p| self.prop(i, p)) |
| 322 | && f.comps.iter().all(|c| self.comp(Some(i), c)) |
| 323 | }) |
| 324 | } |
| 325 | |
| 326 | /// The time-range rules of RFC 4791, 9.9. |
| 327 | fn overlaps(&self, i: usize, r: &TimeRange) -> bool { |
| 328 | let c = &self.cal.components[i]; |
| 329 | let has = |p: ICalendarProperty| c.has_property(&p); |
| 330 | let time = |p: ICalendarProperty| { |
| 331 | let e = c.property(&p)?; |
| 332 | stamp( |
| 333 | &self.zones, |
| 334 | e.values.first()?.as_partial_date_time()?, |
| 335 | e.tz_id(), |
| 336 | ) |
| 337 | .map(|s| s.utc()) |
| 338 | }; |
| 339 | match c.component_type { |
| 340 | ICalendarComponentType::VEvent |
| 341 | | ICalendarComponentType::VTodo |
| 342 | | ICalendarComponentType::VJournal |
| 343 | if has(ICalendarProperty::Dtstart) => |
| 344 | { |
| 345 | let todo = c.component_type == ICalendarComponentType::VTodo; |
| 346 | let (due, duration) = ( |
| 347 | has(ICalendarProperty::Due), |
| 348 | has(ICalendarProperty::Duration), |
| 349 | ); |
| 350 | // One second wider, so that the exact rules below decide the |
| 351 | // instances that only touch the range. |
| 352 | let second = TimeDelta::seconds(1); |
| 353 | let exp = expand( |
| 354 | self.cal, |
| 355 | add(r.start, -second)..add(r.end, second), |
| 356 | self.floating.clone(), |
| 357 | ); |
| 358 | // Unknown instances may overlap, so the object stays in. |
| 359 | if exp.truncated { |
| 360 | return true; |
| 361 | } |
| 362 | exp.instances.iter().filter(|x| x.component == i).any(|x| { |
| 363 | let (s, e) = (x.start, x.end); |
| 364 | match (todo, due, duration) { |
| 365 | (true, _, true) => r.start <= e && (r.end > s || r.end >= e), |
| 366 | (true, true, _) => { |
| 367 | (r.start < e || r.start <= s) && (r.end > s || r.end >= e) |
| 368 | } |
| 369 | (true, ..) => r.start <= s && r.end > s, |
| 370 | _ if s == e => r.start <= s && r.end > s, |
| 371 | _ => r.start < e && r.end > s, |
| 372 | } |
| 373 | }) |
| 374 | } |
| 375 | ICalendarComponentType::VTodo => match ( |
| 376 | time(ICalendarProperty::Due), |
| 377 | time(ICalendarProperty::Completed), |
| 378 | time(ICalendarProperty::Created), |
| 379 | ) { |
| 380 | (Some(due), _, _) => r.start < due && r.end >= due, |
| 381 | (None, Some(done), Some(made)) => { |
| 382 | (r.start <= made || r.start <= done) && (r.end >= made || r.end >= done) |
| 383 | } |
| 384 | (None, Some(done), None) => r.start <= done && r.end >= done, |
| 385 | (None, None, Some(made)) => r.end > made, |
| 386 | (None, None, None) => true, |
| 387 | }, |
| 388 | ICalendarComponentType::VFreebusy => { |
| 389 | let busy: Vec<_> = c |
| 390 | .properties(&ICalendarProperty::Freebusy) |
| 391 | .flat_map(|e| periods(&self.zones, e)) |
| 392 | .collect(); |
| 393 | if !busy.is_empty() { |
| 394 | return busy.iter().any(|(s, e)| r.start < *e && r.end > *s); |
| 395 | } |
| 396 | match ( |
| 397 | time(ICalendarProperty::Dtstart), |
| 398 | time(ICalendarProperty::Dtend), |
| 399 | ) { |
| 400 | (Some(s), Some(e)) => r.start <= e && r.end > s, |
| 401 | _ => false, |
| 402 | } |
| 403 | } |
| 404 | ICalendarComponentType::VAlarm => self.alarm_overlaps(i, r), |
| 405 | _ => false, |
| 406 | } |
| 407 | } |
| 408 | |
| 409 | /// Whether a trigger of the alarm, repetitions included, falls into `r`. |
| 410 | /// A relative trigger fires once per instance of the parent component. |
| 411 | fn alarm_overlaps(&self, alarm: usize, r: &TimeRange) -> bool { |
| 412 | let a = &self.cal.components[alarm]; |
| 413 | let Some(trigger) = a.property(&ICalendarProperty::Trigger) else { |
| 414 | return false; |
| 415 | }; |
| 416 | let repeat = a |
| 417 | .property(&ICalendarProperty::Repeat) |
| 418 | .and_then(|e| e.values.first()?.as_integer()) |
| 419 | .unwrap_or(0) |
| 420 | .clamp(0, 1000) as i32; |
| 421 | let every = match a |
| 422 | .property(&ICalendarProperty::Duration) |
| 423 | .and_then(|e| e.values.first()) |
| 424 | { |
| 425 | Some(ICalendarValue::Duration(d)) => d.to_time_delta().unwrap_or_default(), |
| 426 | _ => TimeDelta::zero(), |
| 427 | }; |
| 428 | let hit = |base: DateTime<Utc>| { |
| 429 | (0..=repeat).any(|k| { |
| 430 | let t = add(base, every.checked_mul(k).unwrap_or(TimeDelta::MAX)); |
| 431 | r.start <= t && r.end > t |
| 432 | }) |
| 433 | }; |
| 434 | match trigger.values.first() { |
| 435 | Some(ICalendarValue::PartialDateTime(p)) => { |
| 436 | stamp(&self.zones, p, trigger.tz_id()).is_some_and(|s| hit(s.utc())) |
| 437 | } |
| 438 | Some(ICalendarValue::Duration(d)) => { |
| 439 | let Some(offset) = d.to_time_delta() else { |
| 440 | return false; |
| 441 | }; |
| 442 | let Some(parent) = self |
| 443 | .cal |
| 444 | .components |
| 445 | .iter() |
| 446 | .position(|c| c.component_ids.contains(&(alarm as u32))) |
| 447 | else { |
| 448 | return false; |
| 449 | }; |
| 450 | let from_end = trigger.parameter(&ICalendarParameterName::Related) |
| 451 | == Some(&ICalendarParameterValue::Related(ICalendarRelated::End)); |
| 452 | let reach = offset |
| 453 | .abs() |
| 454 | .checked_add(&every.checked_mul(repeat).unwrap_or(TimeDelta::MAX)) |
| 455 | .unwrap_or(TimeDelta::MAX) |
| 456 | .checked_add(&TimeDelta::seconds(1)) |
| 457 | .unwrap_or(TimeDelta::MAX); |
| 458 | let exp = expand( |
| 459 | self.cal, |
| 460 | add(r.start, -reach)..add(r.end, reach), |
| 461 | self.floating.clone(), |
| 462 | ); |
| 463 | if exp.truncated { |
| 464 | return true; |
| 465 | } |
| 466 | exp.instances |
| 467 | .iter() |
| 468 | .filter(|x| x.component == parent) |
| 469 | .any(|x| hit(add(if from_end { x.end } else { x.start }, offset))) |
| 470 | } |
| 471 | _ => false, |
| 472 | } |
| 473 | } |
| 474 | |
| 475 | fn prop(&self, i: usize, f: &PropFilter) -> bool { |
| 476 | let mut found = self.cal.components[i] |
| 477 | .entries |
| 478 | .iter() |
| 479 | .filter(|e| e.name.as_str().eq_ignore_ascii_case(&f.name)); |
| 480 | if f.not_defined { |
| 481 | return found.next().is_none(); |
| 482 | } |
| 483 | found.any(|e| { |
| 484 | let l = ical_line(e); |
| 485 | f.time_range.as_ref().is_none_or(|r| { |
| 486 | e.values |
| 487 | .iter() |
| 488 | .filter_map(|v| stamp(&self.zones, v.as_partial_date_time()?, e.tz_id())) |
| 489 | .any(|s| r.start <= s.utc() && r.end > s.utc()) |
| 490 | }) && f.text.as_ref().is_none_or(|t| t.matches(&l.value)) |
| 491 | && f.params.iter().all(|p| param_matches(&l, p)) |
| 492 | }) |
| 493 | } |
| 494 | } |
| 495 | |
| 496 | // --------------------------------------------------------------------------- |
| 497 | // Card evaluation |
| 498 | // --------------------------------------------------------------------------- |
| 499 | |
| 500 | pub fn matches_card(card: &VCard, f: &CardFilter) -> bool { |
| 501 | if f.props.is_empty() { |
| 502 | return true; |
| 503 | } |
| 504 | let v4 = card.version() == Some(VCardVersion::V4_0); |
| 505 | let lines: Vec<Line> = card |
| 506 | .entries |
| 507 | .iter() |
| 508 | .map(|e| { |
| 509 | let mut s = String::new(); |
| 510 | let _ = e.write_to(&mut s, v4); |
| 511 | line(&s) |
| 512 | }) |
| 513 | .collect(); |
| 514 | let hit = |p: &CardPropFilter| card_prop(&lines, p); |
| 515 | if f.all { |
| 516 | f.props.iter().all(hit) |
| 517 | } else { |
| 518 | f.props.iter().any(hit) |
| 519 | } |
| 520 | } |
| 521 | |
| 522 | /// Each text-match and param-filter is tested against every instance of the |
| 523 | /// property; `test` combines their results. |
| 524 | fn card_prop(lines: &[Line], f: &CardPropFilter) -> bool { |
| 525 | let found: Vec<&Line> = lines |
| 526 | .iter() |
| 527 | .filter(|l| l.name.eq_ignore_ascii_case(&f.name)) |
| 528 | .collect(); |
| 529 | if f.not_defined { |
| 530 | return found.is_empty(); |
| 531 | } |
| 532 | if found.is_empty() { |
| 533 | return false; |
| 534 | } |
| 535 | let mut tests = f |
| 536 | .texts |
| 537 | .iter() |
| 538 | .map(|t| found.iter().any(|l| t.matches(&l.value))) |
| 539 | .chain( |
| 540 | f.params |
| 541 | .iter() |
| 542 | .map(|p| found.iter().any(|l| param_matches(l, p))), |
| 543 | ) |
| 544 | .peekable(); |
| 545 | if tests.peek().is_none() { |
| 546 | return true; |
| 547 | } |
| 548 | if f.all { |
| 549 | tests.all(|b| b) |
| 550 | } else { |
| 551 | tests.any(|b| b) |
| 552 | } |
| 553 | } |
| 554 | |
| 555 | fn param_matches(l: &Line, f: &ParamFilter) -> bool { |
| 556 | match l |
| 557 | .params |
| 558 | .iter() |
| 559 | .find(|(n, _)| n.eq_ignore_ascii_case(&f.name)) |
| 560 | { |
| 561 | None => f.not_defined, |
| 562 | Some(_) if f.not_defined => false, |
| 563 | Some((_, values)) => f |
| 564 | .text |
| 565 | .as_ref() |
| 566 | .is_none_or(|t| values.iter().any(|v| t.matches(v))), |
| 567 | } |
| 568 | } |
| 569 | |
| 570 | // --------------------------------------------------------------------------- |
| 571 | // Content lines |
| 572 | // --------------------------------------------------------------------------- |
| 573 | |
| 574 | /// A property as text, the way text-match sees it: the group dropped, the |
| 575 | /// parameter values unquoted, the value unescaped. |
| 576 | struct Line { |
| 577 | name: String, |
| 578 | params: Vec<(String, Vec<String>)>, |
| 579 | value: String, |
| 580 | } |
| 581 | |
| 582 | fn ical_line(e: &ICalendarEntry) -> Line { |
| 583 | let mut s = String::new(); |
| 584 | let _ = e.write_to(&mut s); |
| 585 | line(&s) |
| 586 | } |
| 587 | |
| 588 | /// Parses one content line as calcard's writer produces it. |
| 589 | fn line(written: &str) -> Line { |
| 590 | let unfolded = written.replace("\r\n ", "").replace("\r\n\t", ""); |
| 591 | let unfolded = unfolded.trim_end_matches(['\r', '\n']); |
| 592 | let head = split_unquoted(unfolded, ':')[0]; |
| 593 | let value = unfolded.get(head.len() + 1..).unwrap_or_default(); |
| 594 | let mut head = split_unquoted(head, ';').into_iter(); |
| 595 | let name = head.next().unwrap_or_default(); |
| 596 | let name = name.rsplit('.').next().unwrap_or(name).to_ascii_uppercase(); |
| 597 | let params = head |
| 598 | .filter_map(|p| p.split_once('=')) |
| 599 | .map(|(k, v)| { |
| 600 | let values = split_unquoted(v, ',') |
| 601 | .into_iter() |
| 602 | .map(|v| v.trim_matches('"').to_string()) |
| 603 | .collect(); |
| 604 | (k.to_ascii_uppercase(), values) |
| 605 | }) |
| 606 | .collect(); |
| 607 | Line { |
| 608 | name, |
| 609 | params, |
| 610 | value: unescape(value), |
| 611 | } |
| 612 | } |
| 613 | |
| 614 | fn split_unquoted(s: &str, sep: char) -> Vec<&str> { |
| 615 | let mut out = Vec::new(); |
| 616 | let (mut quoted, mut from) = (false, 0); |
| 617 | for (i, c) in s.char_indices() { |
| 618 | if c == '"' { |
| 619 | quoted = !quoted; |
| 620 | } else if c == sep && !quoted { |
| 621 | out.push(&s[from..i]); |
| 622 | from = i + 1; |
| 623 | } |
| 624 | } |
| 625 | out.push(&s[from..]); |
| 626 | out |
| 627 | } |
| 628 | |
| 629 | fn unescape(s: &str) -> String { |
| 630 | let mut out = String::with_capacity(s.len()); |
| 631 | let mut chars = s.chars(); |
| 632 | while let Some(c) = chars.next() { |
| 633 | if c != '\\' { |
| 634 | out.push(c); |
| 635 | continue; |
| 636 | } |
| 637 | match chars.next() { |
| 638 | Some('n' | 'N') => out.push('\n'), |
| 639 | Some(c) => out.push(c), |
| 640 | None => out.push('\\'), |
| 641 | } |
| 642 | } |
| 643 | out |
| 644 | } |
| 645 |