view.rs
⎇
Raw
1//! Plain summaries of calendar objects and contacts, for a user interface.
2
3use std::collections::HashMap;
4
5use calcard::icalendar::{
6 ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameterName,
7 ICalendarProperty, ICalendarValue,
8};
9use chrono::{DateTime, TimeDelta, Utc};
10
11use crate::expand::{Instance, expand};
12use crate::itip::Is;
13use crate::text::{group, logical_lines, param, param_parts, prop, unescape_text, unfold, value};
14use crate::zone::{Zone, Zones, add};
15
16/// Parameter words that say nothing a reader needs.
17const NOT_LABELS: [&str; 8] = [
18 "internet",
19 "pref",
20 "voice",
21 "x400",
22 "quoted-printable",
23 "base64",
24 "8bit",
25 "7bit",
26];
27
28#[derive(Debug, Clone, PartialEq)]
29pub struct Person {
30 pub name: Option<String>,
31 pub address: String,
32}
33
34#[derive(Debug, Clone, PartialEq)]
35pub struct Attendee {
36 pub person: Person,
37 pub partstat: Option<String>,
38 pub role: Option<String>,
39 pub is_owner: bool,
40}
41
42#[derive(Debug, Clone, PartialEq, Default)]
43pub struct EventInfo {
44 pub component: String,
45 pub summary: Option<String>,
46 pub description: Option<String>,
47 pub location: Option<String>,
48 pub url: Option<String>,
49 pub status: Option<String>,
50 pub transparent: bool,
51 pub all_day: bool,
52 pub categories: Vec<String>,
53 /// The RRULE of the series the component belongs to.
54 pub rrule: Option<String>,
55 pub organizer: Option<Person>,
56 pub attendees: Vec<Attendee>,
57}
58
59impl EventInfo {
60 /// The owner's PARTSTAT, when they are an attendee.
61 pub fn partstat(&self) -> Option<&str> {
62 let a = self.attendees.iter().find(|a| a.is_owner)?;
63 Some(a.partstat.as_deref().unwrap_or("NEEDS-ACTION"))
64 }
65}
66
67pub fn is_item(c: &ICalendarComponent) -> bool {
68 matches!(
69 c.component_type,
70 ICalendarComponentType::VEvent
71 | ICalendarComponentType::VTodo
72 | ICalendarComponentType::VJournal
73 )
74}
75
76fn text(c: &ICalendarComponent, prop: &ICalendarProperty) -> Option<String> {
77 let v = c.property(prop)?.values.first()?.as_text()?.trim();
78 (!v.is_empty()).then(|| v.to_string())
79}
80
81fn person(e: &ICalendarEntry) -> Option<Person> {
82 let address = e.values.first()?.as_text()?.trim().to_string();
83 let name = e
84 .parameter(&ICalendarParameterName::Cn)
85 .and_then(|v| v.as_text())
86 .map(|v| v.trim().to_string())
87 .filter(|v| !v.is_empty());
88 Some(Person { name, address })
89}
90
91/// What component `index` of `cal` says. `owner` recognises the calendar
92/// owner's addresses.
93pub fn event_info(cal: &ICalendar, index: usize, owner: Is) -> EventInfo {
94 let Some(c) = cal.components.get(index) else {
95 return EventInfo::default();
96 };
97 let master = cal
98 .components
99 .iter()
100 .find(|m| {
101 m.component_type == c.component_type
102 && is_item(m)
103 && !m.has_property(&ICalendarProperty::RecurrenceId)
104 })
105 .unwrap_or(c);
106 let param = |e: &ICalendarEntry, n: &ICalendarParameterName| {
107 e.parameter(n)
108 .and_then(|v| v.as_text())
109 .map(str::to_ascii_uppercase)
110 };
111 EventInfo {
112 component: c.component_type.as_str().to_string(),
113 summary: text(c, &ICalendarProperty::Summary),
114 description: text(c, &ICalendarProperty::Description),
115 location: text(c, &ICalendarProperty::Location),
116 url: text(c, &ICalendarProperty::Url),
117 status: text(c, &ICalendarProperty::Status).map(|s| s.to_ascii_uppercase()),
118 transparent: text(c, &ICalendarProperty::Transp)
119 .is_some_and(|t| t.eq_ignore_ascii_case("TRANSPARENT")),
120 all_day: c
121 .property(&ICalendarProperty::Dtstart)
122 .and_then(|e| e.values.first()?.as_partial_date_time())
123 .is_some_and(|d| d.hour.is_none()),
124 categories: c
125 .properties(&ICalendarProperty::Categories)
126 .flat_map(|e| e.values.iter().filter_map(|v| v.as_text()))
127 .map(|v| v.trim().to_string())
128 .filter(|v| !v.is_empty())
129 .collect(),
130 rrule: master
131 .property(&ICalendarProperty::Rrule)
132 .and_then(|e| match e.values.first()? {
133 ICalendarValue::RecurrenceRule(r) => Some(r.to_string()),
134 _ => None,
135 }),
136 organizer: c.property(&ICalendarProperty::Organizer).and_then(person),
137 attendees: c
138 .properties(&ICalendarProperty::Attendee)
139 .filter_map(|e| {
140 let p = person(e)?;
141 Some(Attendee {
142 is_owner: owner(&p.address),
143 partstat: param(e, &ICalendarParameterName::Partstat),
144 role: param(e, &ICalendarParameterName::Role),
145 person: p,
146 })
147 })
148 .collect(),
149 }
150}
151
152/// The component of `cal` that describes the instance at `recurrence_id`:
153/// its override, else the master. Without an instance, the master.
154pub fn component_for(
155 cal: &ICalendar,
156 recurrence_id: Option<DateTime<Utc>>,
157 floating: &Zone,
158) -> Option<usize> {
159 let zones = Zones::new(cal, floating.clone());
160 let items = || {
161 cal.components
162 .iter()
163 .enumerate()
164 .filter(|(_, c)| is_item(c))
165 };
166 if let Some(rid) = recurrence_id {
167 let found = items().find(|(_, c)| {
168 c.property(&ICalendarProperty::RecurrenceId)
169 .and_then(|e| instant(&zones, e))
170 .is_some_and(|t| t == rid)
171 });
172 if let Some((i, _)) = found {
173 return Some(i);
174 }
175 }
176 items()
177 .find(|(_, c)| !c.has_property(&ICalendarProperty::RecurrenceId))
178 .or_else(|| items().next())
179 .map(|(i, _)| i)
180}
181
182/// The instance at `recurrence_id`, else the first of a non-recurring
183/// object. `index` is the component [`component_for`] picked.
184pub fn instance_for(
185 cal: &ICalendar,
186 index: usize,
187 recurrence_id: Option<DateTime<Utc>>,
188 floating: &Zone,
189) -> Option<Instance> {
190 let c = cal.components.get(index)?;
191 let zones = Zones::new(cal, floating.clone());
192 let window = match recurrence_id {
193 // A THISANDFUTURE override moves the later instances by its offset.
194 Some(rid) if !c.has_property(&ICalendarProperty::RecurrenceId) => {
195 let offsets: Vec<TimeDelta> = cal
196 .components
197 .iter()
198 .filter_map(|o| {
199 let r = o.property(&ICalendarProperty::RecurrenceId)?;
200 r.parameter(&ICalendarParameterName::Range)?;
201 let start = instant(&zones, o.property(&ICalendarProperty::Dtstart)?)?;
202 Some(start - instant(&zones, r)?)
203 })
204 .collect();
205 let low = offsets
206 .iter()
207 .copied()
208 .min()
209 .unwrap_or_default()
210 .min(TimeDelta::zero());
211 let high = offsets
212 .iter()
213 .copied()
214 .max()
215 .unwrap_or_default()
216 .max(TimeDelta::zero());
217 // A day each side: the move keeps wall-clock time, so a DST change
218 // between the instances shifts it by up to an hour.
219 let day = TimeDelta::days(1);
220 add(rid, low - day)..add(rid, high + day)
221 }
222 // An override, or an object that does not recur, starts at its DTSTART.
223 _ => {
224 let at = instant(&zones, c.property(&ICalendarProperty::Dtstart)?)?;
225 at..add(at, TimeDelta::seconds(1))
226 }
227 };
228 expand(cal, window, floating.clone())
229 .instances
230 .into_iter()
231 .find(|i| recurrence_id.is_none() || i.recurrence_id == recurrence_id)
232}
233
234/// A date or date-time property as a UTC instant. Floating times and dates
235/// are read in the floating zone of `zones`.
236pub fn instant(zones: &Zones, e: &ICalendarEntry) -> Option<DateTime<Utc>> {
237 let v = e.values.first()?.as_partial_date_time()?;
238 let dt = v.to_date_time()?;
239 Some(match dt.offset {
240 Some(o) if v.hour.is_some() => {
241 (dt.date_time - chrono::TimeDelta::seconds(o.local_minus_utc().into())).and_utc()
242 }
243 _ if v.hour.is_none() => zones.floating().to_utc(dt.date_time),
244 _ => zones.get(e.tz_id()).to_utc(dt.date_time),
245 })
246}
247
248#[derive(Debug, Clone, PartialEq)]
249pub struct Labeled {
250 pub label: Option<String>,
251 pub value: String,
252}
253
254#[derive(Debug, Clone, PartialEq, Default)]
255pub struct Card {
256 pub uid: Option<String>,
257 /// FN, else the name, the organization or the first email.
258 pub full_name: String,
259 pub org: Option<String>,
260 pub title: Option<String>,
261 pub emails: Vec<Labeled>,
262 pub phones: Vec<Labeled>,
263 pub addresses: Vec<Labeled>,
264 pub urls: Vec<Labeled>,
265 pub birthday: Option<String>,
266 pub anniversary: Option<String>,
267 pub note: Option<String>,
268 pub is_group: bool,
269 /// Member UIDs, without `urn:uuid:`.
270 pub members: Vec<String>,
271 /// An inline PHOTO. A URL photo does not count: the server never
272 /// fetches it.
273 pub has_photo: bool,
274}
275
276/// What a vCard 3 or 4 says, Apple's group and label forms included.
277pub fn card(vcard: &str) -> Card {
278 let lines: Vec<String> = logical_lines(vcard).into_iter().map(unfold).collect();
279 // Apple names a value by a sibling X-ABLABEL line, e.g.
280 // `_$!<Mobile>!$_` or a free text. The first label of a group counts.
281 let mut labels: HashMap<String, String> = HashMap::new();
282 for o in lines.iter().filter(|o| prop(o) == "X-ABLABEL") {
283 if let Some(g) = group(o) {
284 labels.entry(g).or_insert_with(|| {
285 let v = unescape_text(value(o));
286 let v = v.trim_start_matches("_$!<").trim_end_matches(">!$_");
287 v.to_ascii_lowercase()
288 });
289 }
290 }
291 let label = |l: &str| {
292 let apple = group(l).and_then(|g| labels.get(&g).cloned());
293 apple.or_else(|| {
294 let types: Vec<String> = param_parts(l)
295 .into_iter()
296 // vCard 2.1 writes bare types, and bare encodings next to
297 // them: `ADR;HOME;QUOTED-PRINTABLE:`.
298 .filter_map(|p| match p.split_once('=') {
299 Some((k, v)) => k.trim().eq_ignore_ascii_case("TYPE").then_some(v),
300 None => Some(p),
301 })
302 .flat_map(|v| v.trim_matches('"').split(','))
303 .map(|t| t.trim().to_ascii_lowercase())
304 .filter(|t| !NOT_LABELS.contains(&t.as_str()))
305 .filter(|t| !t.is_empty())
306 .collect();
307 (!types.is_empty()).then(|| types.join(", "))
308 })
309 };
310 let first = |p: &str| {
311 lines
312 .iter()
313 .find(|l| prop(l) == p)
314 .map(|l| unescape_text(value(l)).trim().to_string())
315 .filter(|v| !v.is_empty())
316 };
317 let all = |p: &str, render: &dyn Fn(&str) -> String| {
318 lines
319 .iter()
320 .filter(|l| prop(l) == p)
321 .filter_map(|l| {
322 let v = render(value(l));
323 (!v.trim().is_empty()).then(|| Labeled {
324 label: label(l),
325 value: v.trim().to_string(),
326 })
327 })
328 .collect::<Vec<_>>()
329 };
330 let plain = |v: &str| unescape_text(v);
331 let mailto = |v: &str| {
332 let v = unescape_text(v);
333 v.strip_prefix("mailto:").map(str::to_string).unwrap_or(v)
334 };
335 let tel = |v: &str| {
336 let v = unescape_text(v);
337 v.strip_prefix("tel:").map(str::to_string).unwrap_or(v)
338 };
339 let components = |v: &str| split_structured(v);
340 let adr = |v: &str| {
341 // PO box, extended, street, locality, region, code, country.
342 let p = components(v);
343 let at = |i: usize| {
344 p.get(i)
345 .map(String::as_str)
346 .unwrap_or("")
347 .trim()
348 .to_string()
349 };
350 let city = [at(5), at(3)]
351 .into_iter()
352 .filter(|s| !s.is_empty())
353 .collect::<Vec<_>>()
354 .join(" ");
355 [at(0), at(1), at(2), city, at(4), at(6)]
356 .into_iter()
357 .filter(|s| !s.is_empty())
358 .collect::<Vec<_>>()
359 .join("\n")
360 };
361 let kind = first("KIND").or_else(|| first("X-ADDRESSBOOKSERVER-KIND"));
362 let is_group = kind.is_some_and(|k| k.eq_ignore_ascii_case("group"));
363 let members = lines
364 .iter()
365 .filter(|l| {
366 let p = prop(l);
367 p == "MEMBER" || p == "X-ADDRESSBOOKSERVER-MEMBER"
368 })
369 .map(|l| {
370 let v = value(l).trim();
371 v.strip_prefix("urn:uuid:").unwrap_or(v).to_string()
372 })
373 .collect();
374 let has_photo = lines.iter().any(|l| {
375 prop(l) == "PHOTO"
376 && (value(l).trim_start().starts_with("data:")
377 || param(l, "ENCODING").is_some_and(|e| {
378 e.eq_ignore_ascii_case("b") || e.eq_ignore_ascii_case("base64")
379 }))
380 });
381 let n = first("N").map(|_| {
382 let raw = lines.iter().find(|l| prop(l) == "N").expect("found above");
383 let p = components(value(raw));
384 let at = |i: usize| {
385 p.get(i)
386 .map(String::as_str)
387 .unwrap_or("")
388 .trim()
389 .to_string()
390 };
391 [at(3), at(1), at(2), at(0), at(4)]
392 .into_iter()
393 .filter(|s| !s.is_empty())
394 .collect::<Vec<_>>()
395 .join(" ")
396 });
397 let org = first("ORG").map(|_| {
398 let raw = lines
399 .iter()
400 .find(|l| prop(l) == "ORG")
401 .expect("found above");
402 components(value(raw))
403 .into_iter()
404 .map(|s| s.trim().to_string())
405 .filter(|s| !s.is_empty())
406 .collect::<Vec<_>>()
407 .join(", ")
408 });
409 let emails = all("EMAIL", &mailto);
410 let full_name = first("FN")
411 .or_else(|| n.clone().filter(|n| !n.is_empty()))
412 .or_else(|| org.clone().filter(|o| !o.is_empty()))
413 .or_else(|| emails.first().map(|e| e.value.clone()))
414 .unwrap_or_default();
415 let [birthday, anniversary] = crate::contact::special_dates(&lines).map(|d| {
416 d.map(|(year, month, day)| match year {
417 Some(y) => format!("{y:04}-{month:02}-{day:02}"),
418 None => format!("--{month:02}-{day:02}"),
419 })
420 });
421 Card {
422 uid: first("UID").map(|u| u.strip_prefix("urn:uuid:").map(str::to_string).unwrap_or(u)),
423 full_name,
424 org: org.filter(|o| !o.is_empty()),
425 title: first("TITLE"),
426 emails,
427 phones: all("TEL", &tel),
428 addresses: all("ADR", &adr),
429 urls: all("URL", &plain),
430 birthday,
431 anniversary,
432 note: first("NOTE"),
433 is_group,
434 members,
435 has_photo,
436 }
437}
438
439/// The parts of a structured value (N, ADR, ORG): split at `;` that are not
440/// escaped, then unescaped.
441fn split_structured(v: &str) -> Vec<String> {
442 let mut parts = Vec::new();
443 let mut cur = String::new();
444 let mut chars = v.chars();
445 while let Some(c) = chars.next() {
446 match c {
447 '\\' => {
448 cur.push(c);
449 if let Some(n) = chars.next() {
450 cur.push(n);
451 }
452 }
453 ';' => parts.push(unescape_text(&std::mem::take(&mut cur))),
454 _ => cur.push(c),
455 }
456 }
457 parts.push(unescape_text(&cur));
458 parts
459}
460