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, 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.components.iter().position(|c| {
315 matches!(
316 c.component_type,
317 ICalendarComponentType::VEvent
318 | ICalendarComponentType::VTodo
319 | ICalendarComponentType::VJournal
320 ) && !c.has_property(&ICalendarProperty::RecurrenceId)
321 });
322 let replaces_in_range = |rid: &ICalendarEntry| {
323 let Some(s) = rid
324 .values
325 .first()
326 .and_then(|v| v.as_partial_date_time())
327 .and_then(|p| stamp(&zones, p, rid.tz_id()))
328 else {
329 return false;
330 };
331 let (start, end) = master
332 .and_then(|m| original(cal, &zones, m, &s))
333 .unwrap_or((s.utc(), s.utc()));
334 match start == end {
335 true => range.contains(&start),
336 false => start < range.end && end > range.start,
337 }
338 };
339 let mut out = vec![root(cal)];
340 for &i in cal.components.first().map_or(&[][..], |r| &r.component_ids) {
341 let c = &cal.components[i as usize];
342 let keep = match c.property(&ICalendarProperty::RecurrenceId) {
343 None => true,
344 Some(rid) => {
345 exp.truncated
346 || used.contains(&(i as usize))
347 || replaces_in_range(rid)
348 || rid.parameter(&ICalendarParameterName::Range).is_some()
349 }
350 };
351 if keep {
352 let at = copy(cal, i as usize, None, &mut out);
353 out[0].component_ids.push(at);
354 }
355 }
356 ICalendar { components: out }
357}
358
359/// Drops the FREEBUSY periods outside `range` (RFC 4791, 9.6.7).
360fn limit_freebusy(cal: &mut ICalendar, range: &TimeRange, floating: &Zone) {
361 let zones = Zones::new(cal, floating.clone());
362 for c in cal
363 .components
364 .iter_mut()
365 .filter(|c| c.component_type == ICalendarComponentType::VFreebusy)
366 {
367 for e in c
368 .entries
369 .iter_mut()
370 .filter(|e| e.name == ICalendarProperty::Freebusy)
371 {
372 let tzid = e.tz_id().map(str::to_string);
373 e.values.retain(|v| {
374 period(&zones, tzid.as_deref(), v)
375 .is_some_and(|(s, e)| s < range.end && e > range.start)
376 });
377 }
378 c.entries
379 .retain(|e| e.name != ICalendarProperty::Freebusy || !e.values.is_empty());
380 }
381}
382
383/// The VCALENDAR with its properties and no components.
384fn root(cal: &ICalendar) -> ICalendarComponent {
385 ICalendarComponent {
386 component_type: ICalendarComponentType::VCalendar,
387 entries: cal
388 .components
389 .first()
390 .map(|c| c.entries.clone())
391 .unwrap_or_default(),
392 component_ids: Vec::new(),
393 }
394}
395
396/// Appends component `i` of `src` and its sub-components, narrowed by `sel`,
397/// to `out`. Returns the new index.
398fn copy(
399 src: &ICalendar,
400 i: usize,
401 sel: Option<&CompSelect>,
402 out: &mut Vec<ICalendarComponent>,
403) -> u32 {
404 let c = &src.components[i];
405 let entries = match sel.and_then(|s| s.props.as_ref()) {
406 None => c.entries.clone(),
407 Some(props) => c
408 .entries
409 .iter()
410 .filter_map(|e| {
411 let p = props
412 .iter()
413 .find(|p| p.name.eq_ignore_ascii_case(e.name.as_str()))?;
414 let mut e = e.clone();
415 if p.novalue {
416 e.values.clear();
417 }
418 Some(e)
419 })
420 .collect(),
421 };
422 let at = out.len();
423 out.push(ICalendarComponent {
424 component_type: c.component_type.clone(),
425 entries,
426 component_ids: Vec::new(),
427 });
428 for &child in &c.component_ids {
429 let child = child as usize;
430 let child_sel = match sel.map(|s| &s.comps) {
431 None | Some(None) => None,
432 Some(Some(list)) => {
433 let name = src.components[child].component_type.as_str();
434 match list.iter().find(|s| s.name.eq_ignore_ascii_case(name)) {
435 Some(s) => Some(s),
436 None => continue,
437 }
438 }
439 };
440 let id = copy(src, child, child_sel, out);
441 out[at].component_ids.push(id);
442 }
443 at as u32
444}
445
446/// The address-data of one card. The stored text is returned unchanged
447/// unless the request narrows it or asks for another version.
448pub fn address_data(raw: &str, req: &AddressData) -> String {
449 if *req == AddressData::default() {
450 return raw.to_string();
451 }
452 let Some(card) = parse_card(raw) else {
453 return raw.to_string();
454 };
455 let version = req.version.or(card.version()).unwrap_or_default();
456 if req.props.is_none() && Some(version) == card.version() {
457 return raw.to_string();
458 }
459 // calcard's 4.0 writer drops a `pref` type, so map before parsing.
460 let card = match version {
461 VCardVersion::V4_0 => parse_card(&apple_forms(raw, version)).unwrap_or(card),
462 _ => card,
463 };
464 let entries = match &req.props {
465 None => card.entries,
466 Some(props) => card
467 .entries
468 .into_iter()
469 .filter_map(|mut e| {
470 let p = props
471 .iter()
472 .find(|p| p.name.eq_ignore_ascii_case(e.name.as_str()))?;
473 if p.novalue {
474 e.values.clear();
475 }
476 Some(e)
477 })
478 .collect(),
479 };
480 let mut out = String::new();
481 let _ = VCard { entries }.write_to(&mut out, version);
482 raw_uris(&match version {
483 VCardVersion::V4_0 => out,
484 _ => apple_forms(&out, version),
485 })
486}
487
488/// Properties whose values are URIs without a `VALUE=URI` parameter.
489const URI_PROPS: [&str; 10] = [
490 "URL",
491 "GEO",
492 "ATTACH",
493 "CONFERENCE",
494 "TZURL",
495 "SOURCE",
496 "IMAGE",
497 "X-GOOGLE-CONFERENCE",
498 "X-MICROSOFT-SKYPETEAMSMEETINGURL",
499 "X-SOCIALPROFILE",
500];
501
502/// calcard's writers escape `,` and `;` in URI values as TEXT, which breaks
503/// the URI. A URI has no backslash to keep. A vCard PHOTO or KEY without a
504/// media type becomes `data:;base64,`, so the type comes from the first
505/// bytes or from `TYPE=PGP`.
506fn raw_uris(text: &str) -> String {
507 let eol = if text.contains("\r\n") { "\r\n" } else { "\n" };
508 let mut out = String::with_capacity(text.len());
509 for raw in crate::text::logical_lines(text) {
510 let orig = crate::text::unfold(raw);
511 let name = crate::text::prop(&orig);
512 let value = crate::text::value(&orig);
513 let uri = URI_PROPS.iter().any(|p| p.eq_ignore_ascii_case(&name))
514 || crate::text::param(&orig, "VALUE").is_some_and(|v| v.eq_ignore_ascii_case("uri"))
515 || ["PHOTO", "LOGO", "SOUND", "KEY"].contains(&name.as_str())
516 && value
517 .get(..5)
518 .is_some_and(|v| v.eq_ignore_ascii_case("data:"));
519 if !uri {
520 out.push_str(raw);
521 continue;
522 }
523 let head = crate::text::head(&orig);
524 let value = value.replace("\\,", ",").replace("\\;", ";");
525 const BARE: &str = "data:;base64,";
526 let data = value
527 .get(..BARE.len())
528 .filter(|p| p.eq_ignore_ascii_case(BARE))
529 .map(|_| &value[BARE.len()..]);
530 let pgp = name == "KEY"
531 && crate::text::param(&orig, "TYPE").is_some_and(|t| t.eq_ignore_ascii_case("PGP"));
532 let sniffed = data.and_then(|d| match d {
533 d if d.starts_with("iVBORw0KGgo") => Some("image/png"),
534 d if d.starts_with("/9j/") => Some("image/jpeg"),
535 d if d.starts_with("R0lGOD") => Some("image/gif"),
536 d if d.starts_with("UklG") && d.get(12..16) == Some("RUJQ") => Some("image/webp"),
537 _ if pgp => Some("application/pgp-keys"),
538 _ => None,
539 });
540 let new = match (sniffed, data) {
541 (Some(t), Some(d)) => format!("{head}:data:{t};base64,{d}"),
542 _ => format!("{head}:{value}"),
543 };
544 if new == orig {
545 out.push_str(raw);
546 } else {
547 out.push_str(&crate::text::fold(&new, eol));
548 }
549 }
550 out
551}
552
553/// A calendar object parsed so that writing it keeps its binary values.
554/// calcard reads a base64 value whose bytes are UTF-8 as TEXT and drops
555/// its ENCODING.
556pub fn parse(text: &str) -> Option<ICalendar> {
557 let mut cal = ICalendar::parse(text).ok()?;
558 let encoded = encoded(text);
559 if encoded.is_empty() {
560 return Some(cal);
561 }
562 for (i, c) in cal.components.iter_mut().enumerate() {
563 for e in &mut c.entries {
564 if let [ICalendarValue::Text(s)] = e.values.as_slice()
565 && encoded.contains(&(i, e.name.as_str().to_ascii_uppercase(), base64(s)))
566 {
567 e.values = vec![ICalendarValue::Binary(s.clone().into_bytes())];
568 }
569 }
570 }
571 Some(cal)
572}
573
574/// [`parse`] for a vCard. Only media and keys are binary; a base64 NOTE
575/// stays text.
576fn parse_card(text: &str) -> Option<VCard> {
577 let mut card = VCard::parse(text).ok()?;
578 let encoded = encoded(text);
579 if encoded.is_empty() {
580 return Some(card);
581 }
582 for e in &mut card.entries {
583 let name = e.name.as_str().to_ascii_uppercase();
584 if ["PHOTO", "LOGO", "SOUND", "KEY"].contains(&name.as_str())
585 && let [VCardValue::Text(s)] = e.values.as_slice()
586 && encoded.contains(&(0, name, base64(s)))
587 {
588 e.values = vec![VCardValue::Binary(Data {
589 content_type: None,
590 data: s.clone().into_bytes(),
591 })];
592 }
593 }
594 Some(card)
595}
596
597/// The lines in base64, the encoding calcard drops, by the position of
598/// their component in BEGIN order, name and value. With a CHARSET the
599/// value is text.
600fn encoded(text: &str) -> HashSet<(usize, String, String)> {
601 let mut out = HashSet::new();
602 if !text
603 .as_bytes()
604 .windows(9)
605 .any(|w| w.eq_ignore_ascii_case(b"ENCODING="))
606 {
607 return out;
608 }
609 let (mut open, mut next) = (Vec::new(), 0);
610 for l in crate::text::logical_lines(text)
611 .into_iter()
612 .map(crate::text::unfold)
613 {
614 match crate::text::prop(&l).as_str() {
615 "BEGIN" => {
616 open.push(next);
617 next += 1;
618 }
619 "END" => {
620 open.pop();
621 }
622 name if crate::text::param(&l, "ENCODING").is_some_and(|e| {
623 e.eq_ignore_ascii_case("BASE64") || e.eq_ignore_ascii_case("B")
624 }) && crate::text::param(&l, "CHARSET").is_none() =>
625 {
626 let value = crate::text::value(&l).split_whitespace().collect();
627 out.insert((open.last().copied().unwrap_or(0), name.to_string(), value));
628 }
629 _ => {}
630 }
631 }
632 out
633}
634
635fn base64(s: &str) -> String {
636 let data = Data {
637 content_type: None,
638 data: s.as_bytes().to_vec(),
639 };
640 data.to_unwrapped_string()["data:;base64,".len()..].to_string()
641}
642
643/// The version a GET asks for in its `Accept` header: 4.0 when named,
644/// else 3.0, which RFC 6352 makes the default and Apple Contacts needs.
645pub fn accepted_version(accept: Option<&str>) -> VCardVersion {
646 match accept.is_some_and(|a| a.contains("version=4.0")) {
647 true => VCardVersion::V4_0,
648 false => VCardVersion::V3_0,
649 }
650}
651
652/// vCard 4.0 groups and companies in the vCard 3.0 extensions Apple
653/// Contacts reads, and back: `KIND:group` and `MEMBER` as
654/// `X-ADDRESSBOOKSERVER-KIND` and `X-ADDRESSBOOKSERVER-MEMBER`, `KIND:org`
655/// as `X-ABSHOWAS:COMPANY`, and `PREF=1` as `TYPE=pref`. sabre/vobject
656/// converts the same way.
657fn apple_forms(text: &str, version: VCardVersion) -> String {
658 let v3 = version == VCardVersion::V3_0;
659 let eol = if text.contains("\r\n") { "\r\n" } else { "\n" };
660 let mut out = String::with_capacity(text.len());
661 // vCard 3.0 has no ranks: the best ranked line of a name becomes the pref.
662 let mut best: HashMap<String, u32> = HashMap::new();
663 if v3 {
664 for raw in crate::text::logical_lines(text) {
665 let line = crate::text::unfold(raw);
666 let name = crate::text::name(&line);
667 let name = name.rsplit('.').next().unwrap_or_default().to_string();
668 for p in crate::text::param_parts(&line) {
669 if let Some(n) = p
670 .split_once('=')
671 .filter(|(k, _)| k.eq_ignore_ascii_case("PREF"))
672 .and_then(|(_, v)| v.trim().parse::<u32>().ok())
673 {
674 let b = best.entry(name.clone()).or_insert(n);
675 *b = (*b).min(n);
676 }
677 }
678 }
679 }
680 for raw in crate::text::logical_lines(text) {
681 let line = crate::text::unfold(raw);
682 let start = crate::text::value_start(&line);
683 let head = crate::text::head(&line);
684 let head_name = head.split(';').next().unwrap_or_default();
685 let (group, name) = match head_name.rsplit_once('.') {
686 Some((g, n)) => (&head_name[..=g.len()], n.to_ascii_uppercase()),
687 None => ("", head_name.to_ascii_uppercase()),
688 };
689 let value = &line[start..];
690 let lower = value.trim().to_ascii_lowercase();
691 let renamed = match (v3, name.as_str(), lower.as_str()) {
692 (true, "KIND", "group") => Some(("X-ADDRESSBOOKSERVER-KIND", "group")),
693 (true, "KIND", "org") => Some(("X-ABSHOWAS", "COMPANY")),
694 (true, "KIND", "individual") => continue,
695 (true, "MEMBER", _) => Some(("X-ADDRESSBOOKSERVER-MEMBER", value)),
696 (false, "X-ADDRESSBOOKSERVER-KIND", "group") => Some(("KIND", "group")),
697 (false, "X-ABSHOWAS", "company") => Some(("KIND", "org")),
698 (false, "X-ADDRESSBOOKSERVER-MEMBER", _) => Some(("MEMBER", value)),
699 _ => None,
700 };
701 let params = pref_params(
702 crate::text::param_parts(&line),
703 v3,
704 best.get(&name).copied().unwrap_or(1),
705 );
706 let (name, value) = renamed.unwrap_or((&name, value));
707 let (params, value) = plain_forms(v3, &line, name, params, value);
708 let mut new = format!("{group}{name}");
709 for p in &params {
710 new.push(';');
711 new.push_str(p);
712 }
713 new.push(':');
714 new.push_str(&value);
715 if new == line {
716 out.push_str(raw);
717 } else {
718 out.push_str(&crate::text::fold(&new, eol));
719 }
720 }
721 out
722}
723
724/// vCard 4.0 values vCard 3.0 readers reject, and back: a `tel:` URI as the
725/// bare number, a `geo:` URI as `lat;lon`, and a birthday without year as
726/// Apple writes it, with `X-APPLE-OMIT-YEAR`.
727fn plain_forms(
728 v3: bool,
729 line: &str,
730 name: &str,
731 params: Vec<String>,
732 value: &str,
733) -> (Vec<String>, String) {
734 let key = |p: &str, k: &str| {
735 p.split_once('=')
736 .is_some_and(|(pk, _)| pk.trim().eq_ignore_ascii_case(k))
737 };
738 let param = |k: &str| crate::text::param(line, k);
739 let digits = |s: &str| s.bytes().all(|b| b.is_ascii_digit());
740 match (v3, name) {
741 (true, "TEL") => {
742 let uri = param("VALUE").is_some_and(|v| v.eq_ignore_ascii_case("uri"));
743 match value.get(..4).filter(|s| s.eq_ignore_ascii_case("tel:")) {
744 Some(_) if uri => (
745 params.into_iter().filter(|p| !key(p, "VALUE")).collect(),
746 value[4..].to_string(),
747 ),
748 _ => (params, value.to_string()),
749 }
750 }
751 (true, "BDAY") => {
752 let md = value.trim().strip_prefix("--").map(|v| v.replace('-', ""));
753 match md {
754 Some(md) if md.len() == 4 && digits(&md) => {
755 let mut params = params;
756 params.push("X-APPLE-OMIT-YEAR=1604".to_string());
757 (params, format!("1604-{}-{}", &md[..2], &md[2..]))
758 }
759 _ => (params, value.to_string()),
760 }
761 }
762 (true, "GEO") => match value.trim().get(..4) {
763 Some(g) if g.eq_ignore_ascii_case("geo:") => {
764 let point = value.trim()[4..].split(';').next().unwrap_or_default();
765 let point = point.replace("\\,", ",");
766 let mut coords = point.split(',');
767 let (lat, lon) = (
768 coords.next().unwrap_or_default(),
769 coords.next().unwrap_or_default(),
770 );
771 (params, format!("{lat};{lon}"))
772 }
773 _ => (params, value.to_string()),
774 },
775 (false, "GEO") if value.contains(';') && !value.contains(':') => (
776 params,
777 format!("geo:{}", value.trim().replacen(';', ",", 1)),
778 ),
779 (false, "BDAY") => {
780 let year = param("X-APPLE-OMIT-YEAR");
781 let date = value
782 .trim()
783 .split(['T', 't'])
784 .next()
785 .unwrap_or_default()
786 .replace('-', "");
787 match year {
788 Some(y) if date.len() == 8 && digits(&date) && date[..4] == y => (
789 params
790 .into_iter()
791 .filter(|p| !key(p, "X-APPLE-OMIT-YEAR"))
792 .collect(),
793 format!("--{}", &date[4..]),
794 ),
795 _ => (params, value.to_string()),
796 }
797 }
798 _ => (params, value.to_string()),
799 }
800}
801
802/// The lowest `PREF` as a `pref` type for vCard 3.0, and back for 4.0.
803fn pref_params(params: Vec<&str>, v3: bool, lowest: u32) -> Vec<String> {
804 let mut out = Vec::with_capacity(params.len());
805 let mut pref = false;
806 for p in params {
807 let (key, value) = p.split_once('=').unwrap_or((p, ""));
808 match (v3, key.to_ascii_uppercase().as_str()) {
809 (true, "PREF") => pref |= value.trim().parse() == Ok(lowest),
810 (false, "TYPE") => {
811 let (prefs, rest): (Vec<&str>, Vec<&str>) = value
812 .trim_matches('"')
813 .split(',')
814 .partition(|t| t.eq_ignore_ascii_case("pref"));
815 pref |= !prefs.is_empty();
816 if !rest.is_empty() {
817 out.push(format!("{key}={}", rest.join(",")));
818 }
819 }
820 _ => out.push(p.to_string()),
821 }
822 }
823 if pref {
824 out.push(if v3 { "TYPE=pref" } else { "PREF=1" }.to_string());
825 }
826 out
827}
828