render.rs
| 1 | //! The `calendar-data` and `address-data` a REPORT returns: the whole |
| 2 | //! object, a part of it, or its recurrence set expanded (RFC 4791, 9.6; |
| 3 | //! RFC 6352, 10.4). |
| 4 | |
| 5 | use std::collections::{HashMap, HashSet}; |
| 6 | |
| 7 | use calcard::common::PartialDateTime; |
| 8 | use calcard::icalendar::{ |
| 9 | ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameter, |
| 10 | ICalendarParameterName, ICalendarParameterValue, ICalendarProperty, ICalendarValue, |
| 11 | ICalendarValueType, |
| 12 | }; |
| 13 | use calcard::vcard::{VCard, VCardVersion}; |
| 14 | use chrono::{DateTime, Utc}; |
| 15 | use xmltree::Element; |
| 16 | |
| 17 | use crate::expand::{expand, original, stamp}; |
| 18 | use crate::filter::{TimeRange, time_range}; |
| 19 | use crate::freebusy::period; |
| 20 | use crate::report::Refused; |
| 21 | use crate::xml::{CALDAV, CARDDAV, Name, elements}; |
| 22 | use crate::zone::{Zone, Zones}; |
| 23 | |
| 24 | /// Instances one object may expand into in one response. |
| 25 | const MAX_EXPANDED: usize = 10_000; |
| 26 | |
| 27 | #[derive(Debug, Clone, Default, PartialEq)] |
| 28 | pub struct CalendarData { |
| 29 | /// `None` returns every component and property. |
| 30 | pub comp: Option<CompSelect>, |
| 31 | pub expand: Option<TimeRange>, |
| 32 | pub limit_recurrence: Option<TimeRange>, |
| 33 | pub limit_freebusy: Option<TimeRange>, |
| 34 | } |
| 35 | |
| 36 | #[derive(Debug, Clone, PartialEq)] |
| 37 | pub struct CompSelect { |
| 38 | /// Upper case. |
| 39 | pub name: String, |
| 40 | /// `None` for `allprop`. |
| 41 | pub props: Option<Vec<PropSelect>>, |
| 42 | /// `None` for `allcomp`. |
| 43 | pub comps: Option<Vec<CompSelect>>, |
| 44 | } |
| 45 | |
| 46 | #[derive(Debug, Clone, PartialEq)] |
| 47 | pub struct PropSelect { |
| 48 | pub name: String, |
| 49 | pub novalue: bool, |
| 50 | } |
| 51 | |
| 52 | #[derive(Debug, Clone, Default, PartialEq)] |
| 53 | pub struct AddressData { |
| 54 | /// `None` for `allprop`. |
| 55 | pub props: Option<Vec<PropSelect>>, |
| 56 | pub version: Option<VCardVersion>, |
| 57 | } |
| 58 | |
| 59 | /// The expansion would exceed [`MAX_EXPANDED`] instances. |
| 60 | #[derive(Debug, PartialEq, Eq)] |
| 61 | pub struct TooManyInstances; |
| 62 | |
| 63 | pub fn calendar_request(e: &Element) -> Result<CalendarData, Refused> { |
| 64 | let content_type = e.attributes.get("content-type").map(String::as_str); |
| 65 | let version = e.attributes.get("version").map(String::as_str); |
| 66 | if content_type.is_some_and(|t| t != "text/calendar") || version.is_some_and(|v| v != "2.0") { |
| 67 | return Err(Refused::Condition(Name::new( |
| 68 | CALDAV, |
| 69 | "supported-calendar-data", |
| 70 | ))); |
| 71 | } |
| 72 | let mut d = CalendarData::default(); |
| 73 | for c in elements(e).filter(|c| Name::of(c).ns == CALDAV) { |
| 74 | match c.name.as_str() { |
| 75 | "comp" => d.comp = Some(comp_select(c)?), |
| 76 | "expand" => d.expand = Some(time_range(c)?), |
| 77 | "limit-recurrence-set" => d.limit_recurrence = Some(time_range(c)?), |
| 78 | "limit-freebusy-set" => d.limit_freebusy = Some(time_range(c)?), |
| 79 | _ => {} |
| 80 | } |
| 81 | } |
| 82 | Ok(d) |
| 83 | } |
| 84 | |
| 85 | fn comp_select(e: &Element) -> Result<CompSelect, Refused> { |
| 86 | let name = e.attributes.get("name").ok_or(Refused::Invalid)?; |
| 87 | let has = |local: &str| elements(e).any(|c| Name::of(c).is(CALDAV, local)); |
| 88 | let props = (!has("allprop")).then(|| prop_selects(e, CALDAV)); |
| 89 | let comps = match has("allcomp") { |
| 90 | true => None, |
| 91 | false => Some( |
| 92 | elements(e) |
| 93 | .filter(|c| Name::of(c).is(CALDAV, "comp")) |
| 94 | .map(comp_select) |
| 95 | .collect::<Result<_, _>>()?, |
| 96 | ), |
| 97 | }; |
| 98 | Ok(CompSelect { |
| 99 | name: name.to_ascii_uppercase(), |
| 100 | props, |
| 101 | comps, |
| 102 | }) |
| 103 | } |
| 104 | |
| 105 | fn prop_selects(e: &Element, ns: &str) -> Vec<PropSelect> { |
| 106 | elements(e) |
| 107 | .filter(|c| Name::of(c).is(ns, "prop")) |
| 108 | .filter_map(|c| { |
| 109 | Some(PropSelect { |
| 110 | name: c.attributes.get("name")?.clone(), |
| 111 | novalue: c.attributes.get("novalue").map(String::as_str) == Some("yes"), |
| 112 | }) |
| 113 | }) |
| 114 | .collect() |
| 115 | } |
| 116 | |
| 117 | pub fn address_request(e: &Element) -> Result<AddressData, Refused> { |
| 118 | let unsupported = || Refused::Condition(Name::new(CARDDAV, "supported-address-data")); |
| 119 | if e.attributes |
| 120 | .get("content-type") |
| 121 | .is_some_and(|t| t != "text/vcard") |
| 122 | { |
| 123 | return Err(unsupported()); |
| 124 | } |
| 125 | // RFC 6352, 10.4: without a version, 3.0. |
| 126 | let version = match e.attributes.get("version").map(String::as_str) { |
| 127 | None | Some("3.0") => Some(VCardVersion::V3_0), |
| 128 | Some("4.0") => Some(VCardVersion::V4_0), |
| 129 | Some(_) => return Err(unsupported()), |
| 130 | }; |
| 131 | let all = elements(e).any(|c| Name::of(c).is(CARDDAV, "allprop")); |
| 132 | let props = prop_selects(e, CARDDAV); |
| 133 | Ok(AddressData { |
| 134 | props: (!all && !props.is_empty()).then_some(props), |
| 135 | version, |
| 136 | }) |
| 137 | } |
| 138 | |
| 139 | /// The calendar-data of one object, and how many instances an `expand` |
| 140 | /// produced. The stored text is returned unchanged unless the request |
| 141 | /// narrows or expands it. |
| 142 | pub fn calendar_data( |
| 143 | raw: &str, |
| 144 | req: &CalendarData, |
| 145 | floating: &Zone, |
| 146 | ) -> Result<(String, usize), TooManyInstances> { |
| 147 | if *req == CalendarData::default() { |
| 148 | return Ok((raw.to_string(), 0)); |
| 149 | } |
| 150 | let Ok(mut cal) = ICalendar::parse(raw) else { |
| 151 | return Ok((raw.to_string(), 0)); |
| 152 | }; |
| 153 | let mut instances = 0; |
| 154 | if let Some(r) = &req.expand { |
| 155 | (cal, instances) = expanded(&cal, r, floating)?; |
| 156 | } |
| 157 | if let Some(r) = &req.limit_recurrence { |
| 158 | cal = limit_recurrence(&cal, r, floating); |
| 159 | } |
| 160 | if let Some(r) = &req.limit_freebusy { |
| 161 | limit_freebusy(&mut cal, r, floating); |
| 162 | } |
| 163 | if let Some(sel) = &req.comp { |
| 164 | let mut out = Vec::new(); |
| 165 | copy(&cal, 0, Some(sel), &mut out); |
| 166 | cal = ICalendar { components: out }; |
| 167 | } |
| 168 | Ok((cal.to_string(), instances)) |
| 169 | } |
| 170 | |
| 171 | /// One component per instance in `range`, in UTC, with a RECURRENCE-ID and |
| 172 | /// without recurrence rules (RFC 4791, 9.6.5). Dates stay dates. |
| 173 | fn expanded( |
| 174 | cal: &ICalendar, |
| 175 | range: &TimeRange, |
| 176 | floating: &Zone, |
| 177 | ) -> Result<(ICalendar, usize), TooManyInstances> { |
| 178 | let exp = expand(cal, range.clone(), floating.clone()); |
| 179 | let count = exp.instances.len(); |
| 180 | if exp.truncated || count > MAX_EXPANDED { |
| 181 | return Err(TooManyInstances); |
| 182 | } |
| 183 | let zones = Zones::new(cal, floating.clone()); |
| 184 | let mut out = vec![root(cal)]; |
| 185 | for x in exp.instances { |
| 186 | let src = &cal.components[x.component]; |
| 187 | let date = src |
| 188 | .property(&ICalendarProperty::Dtstart) |
| 189 | .and_then(|e| e.values.first()?.as_partial_date_time()) |
| 190 | .is_some_and(|p| p.hour.is_none()); |
| 191 | let at = copy(cal, x.component, None, &mut out); |
| 192 | let c = &mut out[at as usize]; |
| 193 | c.entries.retain(|e| { |
| 194 | !matches!( |
| 195 | e.name, |
| 196 | ICalendarProperty::Rrule |
| 197 | | ICalendarProperty::Rdate |
| 198 | | ICalendarProperty::Exdate |
| 199 | | ICalendarProperty::Exrule |
| 200 | | ICalendarProperty::Dtstart |
| 201 | | ICalendarProperty::Dtend |
| 202 | | ICalendarProperty::Due |
| 203 | | ICalendarProperty::RecurrenceId |
| 204 | ) |
| 205 | }); |
| 206 | in_utc(&mut c.entries, &zones); |
| 207 | let when = |name: ICalendarProperty, t: DateTime<Utc>| { |
| 208 | let (value, params) = if date { |
| 209 | let day = floating.to_local(t).and_utc().timestamp(); |
| 210 | let param = ICalendarParameter::new( |
| 211 | ICalendarParameterName::Value, |
| 212 | ICalendarParameterValue::Value(ICalendarValueType::Date), |
| 213 | ); |
| 214 | (PartialDateTime::from_date_timestamp(day), vec![param]) |
| 215 | } else { |
| 216 | (PartialDateTime::from_utc_timestamp(t.timestamp()), vec![]) |
| 217 | }; |
| 218 | ICalendarEntry { |
| 219 | name, |
| 220 | params, |
| 221 | values: vec![ICalendarValue::PartialDateTime(Box::new(value))], |
| 222 | } |
| 223 | }; |
| 224 | c.entries.push(when(ICalendarProperty::Dtstart, x.start)); |
| 225 | if src.has_property(&ICalendarProperty::Dtend) { |
| 226 | c.entries.push(when(ICalendarProperty::Dtend, x.end)); |
| 227 | } |
| 228 | if src.has_property(&ICalendarProperty::Due) { |
| 229 | c.entries.push(when(ICalendarProperty::Due, x.end)); |
| 230 | } |
| 231 | if let Some(rid) = x.recurrence_id { |
| 232 | c.entries.push(when(ICalendarProperty::RecurrenceId, rid)); |
| 233 | } |
| 234 | out[0].component_ids.push(at); |
| 235 | } |
| 236 | // A task or journal without DTSTART has no instances to expand. |
| 237 | for &i in cal.components.first().map_or(&[][..], |r| &r.component_ids) { |
| 238 | let c = &cal.components[i as usize]; |
| 239 | if c.component_type != ICalendarComponentType::VTimezone |
| 240 | && !c.has_property(&ICalendarProperty::Dtstart) |
| 241 | { |
| 242 | let at = copy(cal, i as usize, None, &mut out); |
| 243 | in_utc(&mut out[at as usize].entries, &zones); |
| 244 | out[0].component_ids.push(at); |
| 245 | } |
| 246 | } |
| 247 | Ok((ICalendar { components: out }, count)) |
| 248 | } |
| 249 | |
| 250 | /// Date-times in UTC: an expanded answer holds no VTIMEZONE. |
| 251 | fn in_utc(entries: &mut [ICalendarEntry], zones: &Zones) { |
| 252 | for e in entries { |
| 253 | let tzid = e.tz_id().map(str::to_string); |
| 254 | for v in &mut e.values { |
| 255 | if let ICalendarValue::PartialDateTime(p) = v |
| 256 | && p.hour.is_some() |
| 257 | && let Some(s) = stamp(zones, p, tzid.as_deref()) |
| 258 | { |
| 259 | **p = PartialDateTime::from_utc_timestamp(s.utc().timestamp()); |
| 260 | } |
| 261 | } |
| 262 | e.params.retain(|p| p.name != ICalendarParameterName::Tzid); |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | /// The masters, and only the overrides that affect `range` (RFC 4791, |
| 267 | /// 9.6.6). |
| 268 | fn limit_recurrence(cal: &ICalendar, range: &TimeRange, floating: &Zone) -> ICalendar { |
| 269 | let exp = expand(cal, range.clone(), floating.clone()); |
| 270 | let used: HashSet<usize> = exp.instances.iter().map(|x| x.component).collect(); |
| 271 | let zones = Zones::new(cal, floating.clone()); |
| 272 | // An override moved out of the range still removes an instance in it. |
| 273 | let master = cal.components.iter().position(|c| { |
| 274 | matches!( |
| 275 | c.component_type, |
| 276 | ICalendarComponentType::VEvent |
| 277 | | ICalendarComponentType::VTodo |
| 278 | | ICalendarComponentType::VJournal |
| 279 | ) && !c.has_property(&ICalendarProperty::RecurrenceId) |
| 280 | }); |
| 281 | let replaces_in_range = |rid: &ICalendarEntry| { |
| 282 | let Some(s) = rid |
| 283 | .values |
| 284 | .first() |
| 285 | .and_then(|v| v.as_partial_date_time()) |
| 286 | .and_then(|p| stamp(&zones, p, rid.tz_id())) |
| 287 | else { |
| 288 | return false; |
| 289 | }; |
| 290 | let (start, end) = master |
| 291 | .and_then(|m| original(cal, &zones, m, &s)) |
| 292 | .unwrap_or((s.utc(), s.utc())); |
| 293 | match start == end { |
| 294 | true => range.contains(&start), |
| 295 | false => start < range.end && end > range.start, |
| 296 | } |
| 297 | }; |
| 298 | let mut out = vec![root(cal)]; |
| 299 | for &i in cal.components.first().map_or(&[][..], |r| &r.component_ids) { |
| 300 | let c = &cal.components[i as usize]; |
| 301 | let keep = match c.property(&ICalendarProperty::RecurrenceId) { |
| 302 | None => true, |
| 303 | Some(rid) => { |
| 304 | exp.truncated |
| 305 | || used.contains(&(i as usize)) |
| 306 | || replaces_in_range(rid) |
| 307 | || rid.parameter(&ICalendarParameterName::Range).is_some() |
| 308 | } |
| 309 | }; |
| 310 | if keep { |
| 311 | let at = copy(cal, i as usize, None, &mut out); |
| 312 | out[0].component_ids.push(at); |
| 313 | } |
| 314 | } |
| 315 | ICalendar { components: out } |
| 316 | } |
| 317 | |
| 318 | /// Drops the FREEBUSY periods outside `range` (RFC 4791, 9.6.7). |
| 319 | fn limit_freebusy(cal: &mut ICalendar, range: &TimeRange, floating: &Zone) { |
| 320 | let zones = Zones::new(cal, floating.clone()); |
| 321 | for c in cal |
| 322 | .components |
| 323 | .iter_mut() |
| 324 | .filter(|c| c.component_type == ICalendarComponentType::VFreebusy) |
| 325 | { |
| 326 | for e in c |
| 327 | .entries |
| 328 | .iter_mut() |
| 329 | .filter(|e| e.name == ICalendarProperty::Freebusy) |
| 330 | { |
| 331 | let tzid = e.tz_id().map(str::to_string); |
| 332 | e.values.retain(|v| { |
| 333 | period(&zones, tzid.as_deref(), v) |
| 334 | .is_some_and(|(s, e)| s < range.end && e > range.start) |
| 335 | }); |
| 336 | } |
| 337 | c.entries |
| 338 | .retain(|e| e.name != ICalendarProperty::Freebusy || !e.values.is_empty()); |
| 339 | } |
| 340 | } |
| 341 | |
| 342 | /// The VCALENDAR with its properties and no components. |
| 343 | fn root(cal: &ICalendar) -> ICalendarComponent { |
| 344 | ICalendarComponent { |
| 345 | component_type: ICalendarComponentType::VCalendar, |
| 346 | entries: cal |
| 347 | .components |
| 348 | .first() |
| 349 | .map(|c| c.entries.clone()) |
| 350 | .unwrap_or_default(), |
| 351 | component_ids: Vec::new(), |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | /// Appends component `i` of `src` and its sub-components, narrowed by `sel`, |
| 356 | /// to `out`. Returns the new index. |
| 357 | fn copy( |
| 358 | src: &ICalendar, |
| 359 | i: usize, |
| 360 | sel: Option<&CompSelect>, |
| 361 | out: &mut Vec<ICalendarComponent>, |
| 362 | ) -> u32 { |
| 363 | let c = &src.components[i]; |
| 364 | let entries = match sel.and_then(|s| s.props.as_ref()) { |
| 365 | None => c.entries.clone(), |
| 366 | Some(props) => c |
| 367 | .entries |
| 368 | .iter() |
| 369 | .filter_map(|e| { |
| 370 | let p = props |
| 371 | .iter() |
| 372 | .find(|p| p.name.eq_ignore_ascii_case(e.name.as_str()))?; |
| 373 | let mut e = e.clone(); |
| 374 | if p.novalue { |
| 375 | e.values.clear(); |
| 376 | } |
| 377 | Some(e) |
| 378 | }) |
| 379 | .collect(), |
| 380 | }; |
| 381 | let at = out.len(); |
| 382 | out.push(ICalendarComponent { |
| 383 | component_type: c.component_type.clone(), |
| 384 | entries, |
| 385 | component_ids: Vec::new(), |
| 386 | }); |
| 387 | for &child in &c.component_ids { |
| 388 | let child = child as usize; |
| 389 | let child_sel = match sel.map(|s| &s.comps) { |
| 390 | None | Some(None) => None, |
| 391 | Some(Some(list)) => { |
| 392 | let name = src.components[child].component_type.as_str(); |
| 393 | match list.iter().find(|s| s.name.eq_ignore_ascii_case(name)) { |
| 394 | Some(s) => Some(s), |
| 395 | None => continue, |
| 396 | } |
| 397 | } |
| 398 | }; |
| 399 | let id = copy(src, child, child_sel, out); |
| 400 | out[at].component_ids.push(id); |
| 401 | } |
| 402 | at as u32 |
| 403 | } |
| 404 | |
| 405 | /// The address-data of one card. The stored text is returned unchanged |
| 406 | /// unless the request narrows it or asks for another version. |
| 407 | pub fn address_data(raw: &str, req: &AddressData) -> String { |
| 408 | if *req == AddressData::default() { |
| 409 | return raw.to_string(); |
| 410 | } |
| 411 | let Ok(card) = VCard::parse(raw) else { |
| 412 | return raw.to_string(); |
| 413 | }; |
| 414 | let version = req.version.or(card.version()).unwrap_or_default(); |
| 415 | if req.props.is_none() && Some(version) == card.version() { |
| 416 | return raw.to_string(); |
| 417 | } |
| 418 | // calcard's 4.0 writer drops a `pref` type, so map before parsing. |
| 419 | let card = match version { |
| 420 | VCardVersion::V4_0 => VCard::parse(apple_forms(raw, version)).unwrap_or(card), |
| 421 | _ => card, |
| 422 | }; |
| 423 | let entries = match &req.props { |
| 424 | None => card.entries, |
| 425 | Some(props) => card |
| 426 | .entries |
| 427 | .into_iter() |
| 428 | .filter_map(|mut e| { |
| 429 | let p = props |
| 430 | .iter() |
| 431 | .find(|p| p.name.eq_ignore_ascii_case(e.name.as_str()))?; |
| 432 | if p.novalue { |
| 433 | e.values.clear(); |
| 434 | } |
| 435 | Some(e) |
| 436 | }) |
| 437 | .collect(), |
| 438 | }; |
| 439 | let mut out = String::new(); |
| 440 | let _ = VCard { entries }.write_to(&mut out, version); |
| 441 | match version { |
| 442 | VCardVersion::V4_0 => raw_data_uris(&out), |
| 443 | _ => apple_forms(&out, version), |
| 444 | } |
| 445 | } |
| 446 | |
| 447 | /// calcard's 4.0 writer escapes the comma of a `data:` URI as TEXT, which |
| 448 | /// breaks the URI. Binary values have no backslash to keep. A vCard 3 PHOTO |
| 449 | /// without TYPE becomes `data:;base64,`, so the media type comes from the |
| 450 | /// first bytes. |
| 451 | fn raw_data_uris(text: &str) -> String { |
| 452 | let eol = if text.contains("\r\n") { "\r\n" } else { "\n" }; |
| 453 | let mut out = String::with_capacity(text.len()); |
| 454 | for raw in crate::text::logical_lines(text) { |
| 455 | let line = crate::text::unfold(raw); |
| 456 | let start = crate::text::value_start(&line); |
| 457 | let name = crate::text::name(&line); |
| 458 | let name = name.rsplit('.').next().unwrap_or_default(); |
| 459 | let uri = ["PHOTO", "LOGO", "SOUND", "KEY"].contains(&name) |
| 460 | && line[start..] |
| 461 | .get(..5) |
| 462 | .is_some_and(|v| v.eq_ignore_ascii_case("data:")); |
| 463 | if !uri { |
| 464 | out.push_str(raw); |
| 465 | continue; |
| 466 | } |
| 467 | let line = line.replace("\\,", ","); |
| 468 | let (head, value) = line.split_at(crate::text::value_start(&line)); |
| 469 | const BARE: &str = "data:;base64,"; |
| 470 | let data = value |
| 471 | .get(..BARE.len()) |
| 472 | .filter(|p| p.eq_ignore_ascii_case(BARE)) |
| 473 | .map(|_| &value[BARE.len()..]); |
| 474 | let sniffed = data.and_then(|d| match d { |
| 475 | d if d.starts_with("iVBORw0KGgo") => Some("image/png"), |
| 476 | d if d.starts_with("/9j/") => Some("image/jpeg"), |
| 477 | d if d.starts_with("R0lGOD") => Some("image/gif"), |
| 478 | d if d.starts_with("UklG") && d.get(12..16) == Some("RUJQ") => Some("image/webp"), |
| 479 | _ => None, |
| 480 | }); |
| 481 | let line = match (sniffed, data) { |
| 482 | (Some(t), Some(d)) => format!("{head}data:{t};base64,{d}"), |
| 483 | _ => line.clone(), |
| 484 | }; |
| 485 | out.push_str(&crate::text::fold(&line, eol)); |
| 486 | } |
| 487 | out |
| 488 | } |
| 489 | |
| 490 | /// The version a GET asks for in its `Accept` header: 4.0 when named, |
| 491 | /// else 3.0, which RFC 6352 makes the default and Apple Contacts needs. |
| 492 | pub fn accepted_version(accept: Option<&str>) -> VCardVersion { |
| 493 | match accept.is_some_and(|a| a.contains("version=4.0")) { |
| 494 | true => VCardVersion::V4_0, |
| 495 | false => VCardVersion::V3_0, |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | /// vCard 4.0 groups and companies in the vCard 3.0 extensions Apple |
| 500 | /// Contacts reads, and back: `KIND:group` and `MEMBER` as |
| 501 | /// `X-ADDRESSBOOKSERVER-KIND` and `X-ADDRESSBOOKSERVER-MEMBER`, `KIND:org` |
| 502 | /// as `X-ABSHOWAS:COMPANY`, and `PREF=1` as `TYPE=pref`. sabre/vobject |
| 503 | /// converts the same way. |
| 504 | fn apple_forms(text: &str, version: VCardVersion) -> String { |
| 505 | let v3 = version == VCardVersion::V3_0; |
| 506 | let eol = if text.contains("\r\n") { "\r\n" } else { "\n" }; |
| 507 | let mut out = String::with_capacity(text.len()); |
| 508 | // vCard 3.0 has no ranks: the best ranked line of a name becomes the pref. |
| 509 | let mut best: HashMap<String, u32> = HashMap::new(); |
| 510 | if v3 { |
| 511 | for raw in crate::text::logical_lines(text) { |
| 512 | let line = crate::text::unfold(raw); |
| 513 | let name = crate::text::name(&line); |
| 514 | let name = name.rsplit('.').next().unwrap_or_default().to_string(); |
| 515 | for p in crate::text::param_parts(&line) { |
| 516 | if let Some(n) = p |
| 517 | .split_once('=') |
| 518 | .filter(|(k, _)| k.eq_ignore_ascii_case("PREF")) |
| 519 | .and_then(|(_, v)| v.trim().parse::<u32>().ok()) |
| 520 | { |
| 521 | let b = best.entry(name.clone()).or_insert(n); |
| 522 | *b = (*b).min(n); |
| 523 | } |
| 524 | } |
| 525 | } |
| 526 | } |
| 527 | for raw in crate::text::logical_lines(text) { |
| 528 | let line = crate::text::unfold(raw); |
| 529 | let start = crate::text::value_start(&line); |
| 530 | let head = crate::text::head(&line); |
| 531 | let head_name = head.split(';').next().unwrap_or_default(); |
| 532 | let (group, name) = match head_name.rsplit_once('.') { |
| 533 | Some((g, n)) => (&head_name[..=g.len()], n.to_ascii_uppercase()), |
| 534 | None => ("", head_name.to_ascii_uppercase()), |
| 535 | }; |
| 536 | let value = &line[start..]; |
| 537 | let lower = value.trim().to_ascii_lowercase(); |
| 538 | let renamed = match (v3, name.as_str(), lower.as_str()) { |
| 539 | (true, "KIND", "group") => Some(("X-ADDRESSBOOKSERVER-KIND", "group")), |
| 540 | (true, "KIND", "org") => Some(("X-ABSHOWAS", "COMPANY")), |
| 541 | (true, "KIND", "individual") => continue, |
| 542 | (true, "MEMBER", _) => Some(("X-ADDRESSBOOKSERVER-MEMBER", value)), |
| 543 | (false, "X-ADDRESSBOOKSERVER-KIND", "group") => Some(("KIND", "group")), |
| 544 | (false, "X-ABSHOWAS", "company") => Some(("KIND", "org")), |
| 545 | (false, "X-ADDRESSBOOKSERVER-MEMBER", _) => Some(("MEMBER", value)), |
| 546 | _ => None, |
| 547 | }; |
| 548 | let params = pref_params( |
| 549 | crate::text::param_parts(&line), |
| 550 | v3, |
| 551 | best.get(&name).copied().unwrap_or(1), |
| 552 | ); |
| 553 | let (name, value) = renamed.unwrap_or((&name, value)); |
| 554 | let (params, value) = plain_forms(v3, &line, name, params, value); |
| 555 | let mut new = format!("{group}{name}"); |
| 556 | for p in ¶ms { |
| 557 | new.push(';'); |
| 558 | new.push_str(p); |
| 559 | } |
| 560 | new.push(':'); |
| 561 | new.push_str(&value); |
| 562 | if new == line { |
| 563 | out.push_str(raw); |
| 564 | } else { |
| 565 | out.push_str(&crate::text::fold(&new, eol)); |
| 566 | } |
| 567 | } |
| 568 | out |
| 569 | } |
| 570 | |
| 571 | /// vCard 4.0 values vCard 3.0 readers reject, and back: a `tel:` URI as the |
| 572 | /// bare number, and a birthday without year as Apple writes it, with |
| 573 | /// `X-APPLE-OMIT-YEAR`. |
| 574 | fn plain_forms( |
| 575 | v3: bool, |
| 576 | line: &str, |
| 577 | name: &str, |
| 578 | params: Vec<String>, |
| 579 | value: &str, |
| 580 | ) -> (Vec<String>, String) { |
| 581 | let key = |p: &str, k: &str| { |
| 582 | p.split_once('=') |
| 583 | .is_some_and(|(pk, _)| pk.trim().eq_ignore_ascii_case(k)) |
| 584 | }; |
| 585 | let param = |k: &str| crate::text::param(line, k); |
| 586 | let digits = |s: &str| s.bytes().all(|b| b.is_ascii_digit()); |
| 587 | match (v3, name) { |
| 588 | (true, "TEL") => { |
| 589 | let uri = param("VALUE").is_some_and(|v| v.eq_ignore_ascii_case("uri")); |
| 590 | match value.get(..4).filter(|s| s.eq_ignore_ascii_case("tel:")) { |
| 591 | Some(_) if uri => ( |
| 592 | params.into_iter().filter(|p| !key(p, "VALUE")).collect(), |
| 593 | value[4..].to_string(), |
| 594 | ), |
| 595 | _ => (params, value.to_string()), |
| 596 | } |
| 597 | } |
| 598 | (true, "BDAY") => { |
| 599 | let md = value.trim().strip_prefix("--").map(|v| v.replace('-', "")); |
| 600 | match md { |
| 601 | Some(md) if md.len() == 4 && digits(&md) => { |
| 602 | let mut params = params; |
| 603 | params.push("X-APPLE-OMIT-YEAR=1604".to_string()); |
| 604 | (params, format!("1604-{}-{}", &md[..2], &md[2..])) |
| 605 | } |
| 606 | _ => (params, value.to_string()), |
| 607 | } |
| 608 | } |
| 609 | (false, "BDAY") => { |
| 610 | let year = param("X-APPLE-OMIT-YEAR"); |
| 611 | let date = value |
| 612 | .trim() |
| 613 | .split(['T', 't']) |
| 614 | .next() |
| 615 | .unwrap_or_default() |
| 616 | .replace('-', ""); |
| 617 | match year { |
| 618 | Some(y) if date.len() == 8 && digits(&date) && date[..4] == y => ( |
| 619 | params |
| 620 | .into_iter() |
| 621 | .filter(|p| !key(p, "X-APPLE-OMIT-YEAR")) |
| 622 | .collect(), |
| 623 | format!("--{}", &date[4..]), |
| 624 | ), |
| 625 | _ => (params, value.to_string()), |
| 626 | } |
| 627 | } |
| 628 | _ => (params, value.to_string()), |
| 629 | } |
| 630 | } |
| 631 | |
| 632 | /// The lowest `PREF` as a `pref` type for vCard 3.0, and back for 4.0. |
| 633 | fn pref_params(params: Vec<&str>, v3: bool, lowest: u32) -> Vec<String> { |
| 634 | let mut out = Vec::with_capacity(params.len()); |
| 635 | let mut pref = false; |
| 636 | for p in params { |
| 637 | let (key, value) = p.split_once('=').unwrap_or((p, "")); |
| 638 | match (v3, key.to_ascii_uppercase().as_str()) { |
| 639 | (true, "PREF") => pref |= value.trim().parse() == Ok(lowest), |
| 640 | (false, "TYPE") => { |
| 641 | let (prefs, rest): (Vec<&str>, Vec<&str>) = value |
| 642 | .trim_matches('"') |
| 643 | .split(',') |
| 644 | .partition(|t| t.eq_ignore_ascii_case("pref")); |
| 645 | pref |= !prefs.is_empty(); |
| 646 | if !rest.is_empty() { |
| 647 | out.push(format!("{key}={}", rest.join(","))); |
| 648 | } |
| 649 | } |
| 650 | _ => out.push(p.to_string()), |
| 651 | } |
| 652 | } |
| 653 | if pref { |
| 654 | out.push(if v3 { "TYPE=pref" } else { "PREF=1" }.to_string()); |
| 655 | } |
| 656 | out |
| 657 | } |
| 658 |