//! The `calendar-data` and `address-data` a REPORT returns: the whole //! object, a part of it, or its recurrence set expanded (RFC 4791, 9.6; //! RFC 6352, 10.4). use std::collections::{HashMap, HashSet}; use calcard::common::{Data, PartialDateTime}; use calcard::icalendar::{ ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameter, ICalendarParameterName, ICalendarParameterValue, ICalendarProperty, ICalendarValue, ICalendarValueType, }; use calcard::vcard::{VCard, VCardValue, VCardVersion}; use chrono::{DateTime, Utc}; use xmltree::Element; use crate::expand::{expand, is_item, original, stamp}; use crate::filter::{TimeRange, time_range}; use crate::freebusy::period; use crate::report::Refused; use crate::xml::{CALDAV, CARDDAV, Name, elements}; use crate::zone::{Zone, Zones}; /// Instances one object may expand into in one response. const MAX_EXPANDED: usize = 10_000; /// Bytes one object may expand into in one response. const MAX_EXPANDED_BYTES: usize = 16 * 1024 * 1024; #[derive(Debug, Clone, Default, PartialEq)] pub struct CalendarData { /// `None` returns every component and property. pub comp: Option, pub expand: Option, pub limit_recurrence: Option, pub limit_freebusy: Option, } #[derive(Debug, Clone, PartialEq)] pub struct CompSelect { /// Upper case. pub name: String, /// `None` for `allprop`. pub props: Option>, /// `None` for `allcomp`. pub comps: Option>, } #[derive(Debug, Clone, PartialEq)] pub struct PropSelect { pub name: String, pub novalue: bool, } #[derive(Debug, Clone, Default, PartialEq)] pub struct AddressData { /// `None` for `allprop`. pub props: Option>, pub version: Option, } /// The expansion would exceed [`MAX_EXPANDED`] instances or /// [`MAX_EXPANDED_BYTES`]. #[derive(Debug, PartialEq, Eq)] pub struct TooManyInstances; pub fn calendar_request(e: &Element) -> Result { let content_type = e.attributes.get("content-type").map(String::as_str); let version = e.attributes.get("version").map(String::as_str); if content_type.is_some_and(|t| t != "text/calendar") || version.is_some_and(|v| v != "2.0") { return Err(Refused::Condition(Name::new( CALDAV, "supported-calendar-data", ))); } let mut d = CalendarData::default(); for c in elements(e).filter(|c| Name::of(c).ns == CALDAV) { match c.name.as_str() { "comp" => d.comp = Some(comp_select(c)?), "expand" => d.expand = Some(time_range(c)?), "limit-recurrence-set" => d.limit_recurrence = Some(time_range(c)?), "limit-freebusy-set" => d.limit_freebusy = Some(time_range(c)?), _ => {} } } Ok(d) } fn comp_select(e: &Element) -> Result { let name = e.attributes.get("name").ok_or(Refused::Invalid)?; let has = |local: &str| elements(e).any(|c| Name::of(c).is(CALDAV, local)); // Clients send an empty `` for the whole zone. let all = !["allprop", "prop", "allcomp", "comp"].into_iter().any(has); let props = (!all && !has("allprop")).then(|| prop_selects(e, CALDAV)); let comps = match all || has("allcomp") { true => None, false => Some( elements(e) .filter(|c| Name::of(c).is(CALDAV, "comp")) .map(comp_select) .collect::>()?, ), }; Ok(CompSelect { name: name.to_ascii_uppercase(), props, comps, }) } fn prop_selects(e: &Element, ns: &str) -> Vec { elements(e) .filter(|c| Name::of(c).is(ns, "prop")) .filter_map(|c| { Some(PropSelect { name: c.attributes.get("name")?.clone(), novalue: c.attributes.get("novalue").map(String::as_str) == Some("yes"), }) }) .collect() } pub fn address_request(e: &Element) -> Result { let unsupported = || Refused::Condition(Name::new(CARDDAV, "supported-address-data")); if e.attributes .get("content-type") .is_some_and(|t| t != "text/vcard") { return Err(unsupported()); } // RFC 6352, 10.4: without a version, 3.0. let version = match e.attributes.get("version").map(String::as_str) { None | Some("3.0") => Some(VCardVersion::V3_0), Some("4.0") => Some(VCardVersion::V4_0), Some(_) => return Err(unsupported()), }; let all = elements(e).any(|c| Name::of(c).is(CARDDAV, "allprop")); let props = prop_selects(e, CARDDAV); Ok(AddressData { props: (!all && !props.is_empty()).then_some(props), version, }) } /// The calendar-data of one object, and how many instances an `expand` /// produced. The stored text is returned unchanged unless the request /// narrows or expands it. pub fn calendar_data( raw: &str, req: &CalendarData, floating: &Zone, ) -> Result<(String, usize), TooManyInstances> { if *req == CalendarData::default() { return Ok((raw.to_string(), 0)); } let Some(mut cal) = parse(raw) else { return Ok((raw.to_string(), 0)); }; let mut instances = 0; if let Some(r) = &req.expand { (cal, instances) = expanded(&cal, r, floating, req.comp.as_ref())?; } if let Some(r) = &req.limit_recurrence { cal = limit_recurrence(&cal, r, floating); } if let Some(r) = &req.limit_freebusy { limit_freebusy(&mut cal, r, floating); } if let Some(sel) = &req.comp { let mut out = Vec::new(); copy(&cal, 0, Some(sel), &mut out); cal = ICalendar { components: out }; } Ok((write(&cal), instances)) } /// The text of `cal`. Use this instead of `to_string`, which breaks URIs. pub fn write(cal: &ICalendar) -> String { raw_uris(&cal.to_string()) } /// One component per instance in `range`, in UTC, with a RECURRENCE-ID and /// without recurrence rules (RFC 4791, 9.6.5). Dates stay dates. fn expanded( cal: &ICalendar, range: &TimeRange, floating: &Zone, sel: Option<&CompSelect>, ) -> Result<(ICalendar, usize), TooManyInstances> { let exp = expand(cal, range.clone(), floating.clone()); let count = exp.instances.len(); if exp.truncated || count > MAX_EXPANDED { return Err(TooManyInstances); } // Each instance copies its component, so many attendees times many // instances can be huge before anything is written. let mut sizes = HashMap::new(); let mut bytes = 0; for x in &exp.instances { // Measured as written: with the requested selection. bytes += *sizes.entry(x.component).or_insert_with(|| { let mut one = vec![root(cal)]; let at = copy(cal, x.component, None, &mut one); one[0].component_ids.push(at); let mut one = ICalendar { components: one }; if let Some(sel) = sel { let mut picked = Vec::new(); copy(&one, 0, Some(sel), &mut picked); one = ICalendar { components: picked }; } one.to_string().len() }); if bytes > MAX_EXPANDED_BYTES { return Err(TooManyInstances); } } let zones = Zones::new(cal, floating.clone()); let mut out = vec![root(cal)]; for x in exp.instances { let src = &cal.components[x.component]; let date = src .property(&ICalendarProperty::Dtstart) .and_then(|e| e.values.first()?.as_partial_date_time()) .is_some_and(|p| p.hour.is_none()); let at = copy(cal, x.component, None, &mut out); let c = &mut out[at as usize]; c.entries.retain(|e| { !matches!( e.name, ICalendarProperty::Rrule | ICalendarProperty::Rdate | ICalendarProperty::Exdate | ICalendarProperty::Exrule | ICalendarProperty::Dtstart | ICalendarProperty::Dtend | ICalendarProperty::Due | ICalendarProperty::RecurrenceId ) }); in_utc(&mut c.entries, &zones); let when = |name: ICalendarProperty, t: DateTime| { let (value, params) = if date { let day = floating.to_local(t).and_utc().timestamp(); let param = ICalendarParameter::new( ICalendarParameterName::Value, ICalendarParameterValue::Value(ICalendarValueType::Date), ); (PartialDateTime::from_date_timestamp(day), vec![param]) } else { (PartialDateTime::from_utc_timestamp(t.timestamp()), vec![]) }; ICalendarEntry { name, params, values: vec![ICalendarValue::PartialDateTime(Box::new(value))], } }; c.entries.push(when(ICalendarProperty::Dtstart, x.start)); if src.has_property(&ICalendarProperty::Dtend) { c.entries.push(when(ICalendarProperty::Dtend, x.end)); } if src.has_property(&ICalendarProperty::Due) { c.entries.push(when(ICalendarProperty::Due, x.end)); } if let Some(rid) = x.recurrence_id { c.entries.push(when(ICalendarProperty::RecurrenceId, rid)); } out[0].component_ids.push(at); } // A task or journal without DTSTART has no instances to expand. for &i in cal.components.first().map_or(&[][..], |r| &r.component_ids) { let c = &cal.components[i as usize]; if c.component_type != ICalendarComponentType::VTimezone && !c.has_property(&ICalendarProperty::Dtstart) { let at = copy(cal, i as usize, None, &mut out); in_utc(&mut out[at as usize].entries, &zones); out[0].component_ids.push(at); } } Ok((ICalendar { components: out }, count)) } /// Date-times in UTC: an expanded answer holds no VTIMEZONE. fn in_utc(entries: &mut [ICalendarEntry], zones: &Zones) { for e in entries { let tzid = e.tz_id().map(str::to_string); for v in &mut e.values { if let ICalendarValue::PartialDateTime(p) = v && p.hour.is_some() && let Some(s) = stamp(zones, p, tzid.as_deref()) { **p = PartialDateTime::from_utc_timestamp(s.utc().timestamp()); } } e.params.retain(|p| p.name != ICalendarParameterName::Tzid); } } /// The masters, and only the overrides that affect `range` (RFC 4791, /// 9.6.6). fn limit_recurrence(cal: &ICalendar, range: &TimeRange, floating: &Zone) -> ICalendar { if !cal .components .iter() .any(|c| c.has_property(&ICalendarProperty::RecurrenceId)) { return cal.clone(); } let exp = expand(cal, range.clone(), floating.clone()); let used: HashSet = exp.instances.iter().map(|x| x.component).collect(); let zones = Zones::new(cal, floating.clone()); // An override moved out of the range still removes an instance in it. let master = cal .components .iter() .position(|c| is_item(c) && !c.has_property(&ICalendarProperty::RecurrenceId)); let replaces_in_range = |rid: &ICalendarEntry| { let Some(s) = rid .values .first() .and_then(|v| v.as_partial_date_time()) .and_then(|p| stamp(&zones, p, rid.tz_id())) else { return false; }; let (start, end) = master .and_then(|m| original(cal, &zones, m, &s)) .unwrap_or((s.utc(), s.utc())); match start == end { true => range.contains(&start), false => start < range.end && end > range.start, } }; let mut out = vec![root(cal)]; for &i in cal.components.first().map_or(&[][..], |r| &r.component_ids) { let c = &cal.components[i as usize]; let keep = match c.property(&ICalendarProperty::RecurrenceId) { None => true, Some(rid) => { exp.truncated || used.contains(&(i as usize)) || replaces_in_range(rid) || rid.parameter(&ICalendarParameterName::Range).is_some() } }; if keep { let at = copy(cal, i as usize, None, &mut out); out[0].component_ids.push(at); } } ICalendar { components: out } } /// Drops the FREEBUSY periods outside `range` (RFC 4791, 9.6.7). fn limit_freebusy(cal: &mut ICalendar, range: &TimeRange, floating: &Zone) { let zones = Zones::new(cal, floating.clone()); for c in cal .components .iter_mut() .filter(|c| c.component_type == ICalendarComponentType::VFreebusy) { for e in c .entries .iter_mut() .filter(|e| e.name == ICalendarProperty::Freebusy) { let tzid = e.tz_id().map(str::to_string); e.values.retain(|v| { period(&zones, tzid.as_deref(), v) .is_some_and(|(s, e)| s < range.end && e > range.start) }); } c.entries .retain(|e| e.name != ICalendarProperty::Freebusy || !e.values.is_empty()); } } /// The VCALENDAR with its properties and no components. fn root(cal: &ICalendar) -> ICalendarComponent { ICalendarComponent { component_type: ICalendarComponentType::VCalendar, entries: cal .components .first() .map(|c| c.entries.clone()) .unwrap_or_default(), component_ids: Vec::new(), } } /// Appends component `i` of `src` and its sub-components, narrowed by `sel`, /// to `out`. Returns the new index. fn copy( src: &ICalendar, i: usize, sel: Option<&CompSelect>, out: &mut Vec, ) -> u32 { let c = &src.components[i]; let entries = match sel.and_then(|s| s.props.as_ref()) { None => c.entries.clone(), Some(props) => c .entries .iter() .filter_map(|e| { let p = props .iter() .find(|p| p.name.eq_ignore_ascii_case(e.name.as_str()))?; let mut e = e.clone(); if p.novalue { e.values.clear(); } Some(e) }) .collect(), }; let at = out.len(); out.push(ICalendarComponent { component_type: c.component_type.clone(), entries, component_ids: Vec::new(), }); for &child in &c.component_ids { let child = child as usize; let child_sel = match sel.map(|s| &s.comps) { None | Some(None) => None, Some(Some(list)) => { let name = src.components[child].component_type.as_str(); match list.iter().find(|s| s.name.eq_ignore_ascii_case(name)) { Some(s) => Some(s), None => continue, } } }; let id = copy(src, child, child_sel, out); out[at].component_ids.push(id); } at as u32 } /// The address-data of one card. The stored text is returned unchanged /// unless the request narrows it or asks for another version. pub fn address_data(raw: &str, req: &AddressData) -> String { if *req == AddressData::default() { return raw.to_string(); } let Some(card) = parse_card(raw) else { return raw.to_string(); }; let version = req.version.or(card.version()).unwrap_or_default(); if req.props.is_none() && Some(version) == card.version() { return raw.to_string(); } // calcard's 4.0 writer drops a `pref` type, so map before parsing. let card = match version { VCardVersion::V4_0 => parse_card(&apple_forms(raw, version)).unwrap_or(card), _ => card, }; let entries = match &req.props { None => card.entries, Some(props) => card .entries .into_iter() .filter_map(|mut e| { let p = props .iter() .find(|p| p.name.eq_ignore_ascii_case(e.name.as_str()))?; if p.novalue { e.values.clear(); } Some(e) }) .collect(), }; let mut out = String::new(); let _ = VCard { entries }.write_to(&mut out, version); raw_uris(&match version { VCardVersion::V4_0 => out, _ => apple_forms(&out, version), }) } /// Properties whose values are URIs without a `VALUE=URI` parameter. const URI_PROPS: [&str; 10] = [ "URL", "GEO", "ATTACH", "CONFERENCE", "TZURL", "SOURCE", "IMAGE", "X-GOOGLE-CONFERENCE", "X-MICROSOFT-SKYPETEAMSMEETINGURL", "X-SOCIALPROFILE", ]; /// calcard's writers escape `,` and `;` in URI values as TEXT, which breaks /// the URI. A URI has no backslash to keep. A vCard PHOTO or KEY without a /// media type becomes `data:;base64,`, so the type comes from the first /// bytes or from `TYPE=PGP`. fn raw_uris(text: &str) -> String { let eol = if text.contains("\r\n") { "\r\n" } else { "\n" }; let mut out = String::with_capacity(text.len()); for raw in crate::text::logical_lines(text) { let orig = crate::text::unfold(raw); let name = crate::text::prop(&orig); let value = crate::text::value(&orig); let uri = URI_PROPS.iter().any(|p| p.eq_ignore_ascii_case(&name)) || crate::text::param(&orig, "VALUE").is_some_and(|v| v.eq_ignore_ascii_case("uri")) || ["PHOTO", "LOGO", "SOUND", "KEY"].contains(&name.as_str()) && value .get(..5) .is_some_and(|v| v.eq_ignore_ascii_case("data:")); if !uri { out.push_str(raw); continue; } let head = crate::text::head(&orig); let value = value.replace("\\,", ",").replace("\\;", ";"); const BARE: &str = "data:;base64,"; let data = value .get(..BARE.len()) .filter(|p| p.eq_ignore_ascii_case(BARE)) .map(|_| &value[BARE.len()..]); let pgp = name == "KEY" && crate::text::param(&orig, "TYPE").is_some_and(|t| t.eq_ignore_ascii_case("PGP")); let sniffed = data.and_then(|d| match d { d if d.starts_with("iVBORw0KGgo") => Some("image/png"), d if d.starts_with("/9j/") => Some("image/jpeg"), d if d.starts_with("R0lGOD") => Some("image/gif"), d if d.starts_with("UklG") && d.get(12..16) == Some("RUJQ") => Some("image/webp"), _ if pgp => Some("application/pgp-keys"), _ => None, }); let new = match (sniffed, data) { (Some(t), Some(d)) => format!("{head}:data:{t};base64,{d}"), _ => format!("{head}:{value}"), }; if new == orig { out.push_str(raw); } else { out.push_str(&crate::text::fold(&new, eol)); } } out } /// A calendar object parsed so that writing it keeps its binary values. /// calcard reads a base64 value whose bytes are UTF-8 as TEXT and drops /// its ENCODING. pub fn parse(text: &str) -> Option { let mut cal = ICalendar::parse(text).ok()?; let encoded = encoded(text); if encoded.is_empty() { return Some(cal); } for (i, c) in cal.components.iter_mut().enumerate() { for e in &mut c.entries { if let [ICalendarValue::Text(s)] = e.values.as_slice() && encoded.contains(&(i, e.name.as_str().to_ascii_uppercase(), base64(s))) { e.values = vec![ICalendarValue::Binary(s.clone().into_bytes())]; } } } Some(cal) } /// [`parse`] for a vCard. Only media and keys are binary; a base64 NOTE /// stays text. fn parse_card(text: &str) -> Option { let mut card = VCard::parse(text).ok()?; let encoded = encoded(text); if encoded.is_empty() { return Some(card); } for e in &mut card.entries { let name = e.name.as_str().to_ascii_uppercase(); if ["PHOTO", "LOGO", "SOUND", "KEY"].contains(&name.as_str()) && let [VCardValue::Text(s)] = e.values.as_slice() && encoded.contains(&(0, name, base64(s))) { e.values = vec![VCardValue::Binary(Data { content_type: None, data: s.clone().into_bytes(), })]; } } Some(card) } /// The lines in base64, the encoding calcard drops, by the position of /// their component in BEGIN order, name and value. With a CHARSET the /// value is text. fn encoded(text: &str) -> HashSet<(usize, String, String)> { let mut out = HashSet::new(); if !text .as_bytes() .windows(9) .any(|w| w.eq_ignore_ascii_case(b"ENCODING=")) { return out; } let (mut open, mut next) = (Vec::new(), 0); for l in crate::text::logical_lines(text) .into_iter() .map(crate::text::unfold) { match crate::text::prop(&l).as_str() { "BEGIN" => { open.push(next); next += 1; } "END" => { open.pop(); } name if crate::text::param(&l, "ENCODING").is_some_and(|e| { e.eq_ignore_ascii_case("BASE64") || e.eq_ignore_ascii_case("B") }) && crate::text::param(&l, "CHARSET").is_none() => { let value = crate::text::value(&l).split_whitespace().collect(); out.insert((open.last().copied().unwrap_or(0), name.to_string(), value)); } _ => {} } } out } fn base64(s: &str) -> String { let data = Data { content_type: None, data: s.as_bytes().to_vec(), }; data.to_unwrapped_string()["data:;base64,".len()..].to_string() } /// The version a GET asks for in its `Accept` header: 4.0 when named, /// else 3.0, which RFC 6352 makes the default and Apple Contacts needs. pub fn accepted_version(accept: Option<&str>) -> VCardVersion { match accept.is_some_and(|a| a.contains("version=4.0")) { true => VCardVersion::V4_0, false => VCardVersion::V3_0, } } /// vCard 4.0 groups and companies in the vCard 3.0 extensions Apple /// Contacts reads, and back: `KIND:group` and `MEMBER` as /// `X-ADDRESSBOOKSERVER-KIND` and `X-ADDRESSBOOKSERVER-MEMBER`, `KIND:org` /// as `X-ABSHOWAS:COMPANY`, and `PREF=1` as `TYPE=pref`. sabre/vobject /// converts the same way. fn apple_forms(text: &str, version: VCardVersion) -> String { let v3 = version == VCardVersion::V3_0; let eol = if text.contains("\r\n") { "\r\n" } else { "\n" }; let mut out = String::with_capacity(text.len()); // vCard 3.0 has no ranks: the best ranked line of a name becomes the pref. let mut best: HashMap = HashMap::new(); if v3 { for raw in crate::text::logical_lines(text) { let line = crate::text::unfold(raw); let name = crate::text::name(&line); let name = name.rsplit('.').next().unwrap_or_default().to_string(); for p in crate::text::param_parts(&line) { if let Some(n) = p .split_once('=') .filter(|(k, _)| k.eq_ignore_ascii_case("PREF")) .and_then(|(_, v)| v.trim().parse::().ok()) { let b = best.entry(name.clone()).or_insert(n); *b = (*b).min(n); } } } } for raw in crate::text::logical_lines(text) { let line = crate::text::unfold(raw); let start = crate::text::value_start(&line); let head = crate::text::head(&line); let head_name = head.split(';').next().unwrap_or_default(); let (group, name) = match head_name.rsplit_once('.') { Some((g, n)) => (&head_name[..=g.len()], n.to_ascii_uppercase()), None => ("", head_name.to_ascii_uppercase()), }; let value = &line[start..]; let lower = value.trim().to_ascii_lowercase(); let renamed = match (v3, name.as_str(), lower.as_str()) { (true, "KIND", "group") => Some(("X-ADDRESSBOOKSERVER-KIND", "group")), (true, "KIND", "org") => Some(("X-ABSHOWAS", "COMPANY")), (true, "KIND", "individual") => continue, (true, "MEMBER", _) => Some(("X-ADDRESSBOOKSERVER-MEMBER", value)), (false, "X-ADDRESSBOOKSERVER-KIND", "group") => Some(("KIND", "group")), (false, "X-ABSHOWAS", "company") => Some(("KIND", "org")), (false, "X-ADDRESSBOOKSERVER-MEMBER", _) => Some(("MEMBER", value)), _ => None, }; let params = pref_params( crate::text::param_parts(&line), v3, best.get(&name).copied().unwrap_or(1), ); let (name, value) = renamed.unwrap_or((&name, value)); let (params, value) = plain_forms(v3, &line, name, params, value); let mut new = format!("{group}{name}"); for p in ¶ms { new.push(';'); new.push_str(p); } new.push(':'); new.push_str(&value); if new == line { out.push_str(raw); } else { out.push_str(&crate::text::fold(&new, eol)); } } out } /// vCard 4.0 values vCard 3.0 readers reject, and back: a `tel:` URI as the /// bare number, a `geo:` URI as `lat;lon`, and a birthday without year as /// Apple writes it, with `X-APPLE-OMIT-YEAR`. fn plain_forms( v3: bool, line: &str, name: &str, params: Vec, value: &str, ) -> (Vec, String) { let key = |p: &str, k: &str| { p.split_once('=') .is_some_and(|(pk, _)| pk.trim().eq_ignore_ascii_case(k)) }; let param = |k: &str| crate::text::param(line, k); let digits = |s: &str| s.bytes().all(|b| b.is_ascii_digit()); match (v3, name) { (true, "TEL") => { let uri = param("VALUE").is_some_and(|v| v.eq_ignore_ascii_case("uri")); match value.get(..4).filter(|s| s.eq_ignore_ascii_case("tel:")) { Some(_) if uri => ( params.into_iter().filter(|p| !key(p, "VALUE")).collect(), value[4..].to_string(), ), _ => (params, value.to_string()), } } (true, "BDAY") => { let md = value.trim().strip_prefix("--").map(|v| v.replace('-', "")); match md { Some(md) if md.len() == 4 && digits(&md) => { let mut params = params; params.push("X-APPLE-OMIT-YEAR=1604".to_string()); (params, format!("1604-{}-{}", &md[..2], &md[2..])) } _ => (params, value.to_string()), } } (true, "GEO") => match value.trim().get(..4) { Some(g) if g.eq_ignore_ascii_case("geo:") => { let point = value.trim()[4..].split(';').next().unwrap_or_default(); let point = point.replace("\\,", ","); let mut coords = point.split(','); let (lat, lon) = ( coords.next().unwrap_or_default(), coords.next().unwrap_or_default(), ); (params, format!("{lat};{lon}")) } _ => (params, value.to_string()), }, (false, "GEO") if value.contains(';') && !value.contains(':') => ( params, format!("geo:{}", value.trim().replacen(';', ",", 1)), ), (false, "BDAY") => { let year = param("X-APPLE-OMIT-YEAR"); let date = value .trim() .split(['T', 't']) .next() .unwrap_or_default() .replace('-', ""); match year { Some(y) if date.len() == 8 && digits(&date) && date[..4] == y => ( params .into_iter() .filter(|p| !key(p, "X-APPLE-OMIT-YEAR")) .collect(), format!("--{}", &date[4..]), ), _ => (params, value.to_string()), } } _ => (params, value.to_string()), } } /// The lowest `PREF` as a `pref` type for vCard 3.0, and back for 4.0. fn pref_params(params: Vec<&str>, v3: bool, lowest: u32) -> Vec { let mut out = Vec::with_capacity(params.len()); let mut pref = false; for p in params { let (key, value) = p.split_once('=').unwrap_or((p, "")); match (v3, key.to_ascii_uppercase().as_str()) { (true, "PREF") => pref |= value.trim().parse() == Ok(lowest), (false, "TYPE") => { let (prefs, rest): (Vec<&str>, Vec<&str>) = value .trim_matches('"') .split(',') .partition(|t| t.eq_ignore_ascii_case("pref")); pref |= !prefs.is_empty(); if !rest.is_empty() { out.push(format!("{key}={}", rest.join(","))); } } _ => out.push(p.to_string()), } } if pref { out.push(if v3 { "TYPE=pref" } else { "PREF=1" }.to_string()); } out }