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