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