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