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::PartialDateTime;
8use calcard::icalendar::{
9 ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameter,
10 ICalendarParameterName, ICalendarParameterValue, ICalendarProperty, ICalendarValue,
11 ICalendarValueType,
12};
13use calcard::vcard::{VCard, 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#[derive(Debug, Clone, Default, PartialEq)]
28pub 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)]
37pub 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)]
47pub struct PropSelect {
48 pub name: String,
49 pub novalue: bool,
50}
51
52#[derive(Debug, Clone, Default, PartialEq)]
53pub 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)]
61pub struct TooManyInstances;
62
63pub 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
85fn 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
105fn 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
117pub 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.
142pub 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.
173fn 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.
251fn 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).
268fn 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).
319fn 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.
343fn 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.
357fn 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.
407pub 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.
451fn 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.
492pub 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.
504fn 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 &params {
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`.
574fn 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.
633fn 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