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/// The first calendar item of an object in brief.
182#[derive(Debug, Clone, PartialEq)]
183pub struct Brief {
184 pub summary: Option<String>,
185 pub start: Option<DateTime<Utc>>,
186 pub all_day: bool,
187 pub recurring: bool,
188}
189
190pub fn brief(cal: &ICalendar, floating: &Zone) -> Option<Brief> {
191 let i = component_for(cal, None, floating)?;
192 let c = &cal.components[i];
193 let zones = Zones::new(cal, floating.clone());
194 let start = c.property(&ICalendarProperty::Dtstart);
195 Some(Brief {
196 summary: text(c, &ICalendarProperty::Summary),
197 start: start.and_then(|e| instant(&zones, e)),
198 all_day: start
199 .and_then(|e| e.values.first()?.as_partial_date_time())
200 .is_some_and(|d| d.hour.is_none()),
201 recurring: c.has_property(&ICalendarProperty::Rrule)
202 || c.has_property(&ICalendarProperty::Rdate),
203 })
204}
205
206#[derive(Debug, Clone, PartialEq)]
207pub struct Labeled {
208 pub label: Option<String>,
209 pub value: String,
210}
211
212#[derive(Debug, Clone, PartialEq, Default)]
213pub struct Card {
214 pub uid: Option<String>,
215 /// FN, else the name, the organization or the first email.
216 pub full_name: String,
217 pub org: Option<String>,
218 pub title: Option<String>,
219 pub emails: Vec<Labeled>,
220 pub phones: Vec<Labeled>,
221 pub addresses: Vec<Labeled>,
222 pub urls: Vec<Labeled>,
223 pub birthday: Option<String>,
224 pub anniversary: Option<String>,
225 pub note: Option<String>,
226 pub is_group: bool,
227 /// Member UIDs, without `urn:uuid:`.
228 pub members: Vec<String>,
229 /// An inline PHOTO. A URL photo does not count: the server never
230 /// fetches it.
231 pub has_photo: bool,
232}
233
234/// What a vCard 3 or 4 says, Apple's group and label forms included.
235pub fn card(vcard: &str) -> Card {
236 let lines: Vec<String> = logical_lines(vcard).into_iter().map(unfold).collect();
237 let prop = |l: &str| {
238 let n = name(l);
239 n.rsplit_once('.').map_or(n.clone(), |(_, n)| n.to_string())
240 };
241 let group = |l: &str| name(l).rsplit_once('.').map(|(g, _)| g.to_string());
242 // Apple names a value by a sibling X-ABLABEL line, e.g.
243 // `_$!<Mobile>!$_` or a free text.
244 let label = |l: &str| {
245 let apple = group(l).and_then(|g| {
246 lines
247 .iter()
248 .find(|o| group(o).as_deref() == Some(g.as_str()) && prop(o) == "X-ABLABEL")
249 .map(|o| {
250 let v = unescape_text(value(o));
251 let v = v.trim_start_matches("_$!<").trim_end_matches(">!$_");
252 v.to_ascii_lowercase()
253 })
254 });
255 apple.or_else(|| {
256 let types: Vec<String> = param_parts(l)
257 .into_iter()
258 .filter_map(|p| {
259 let (k, v) = p.split_once('=')?;
260 k.trim().eq_ignore_ascii_case("TYPE").then_some(v)
261 })
262 .flat_map(|v| v.trim_matches('"').split(','))
263 .map(|t| t.trim().to_ascii_lowercase())
264 .filter(|t| !["internet", "pref", "voice", "x400"].contains(&t.as_str()))
265 .filter(|t| !t.is_empty())
266 .collect();
267 (!types.is_empty()).then(|| types.join(", "))
268 })
269 };
270 let first = |p: &str| {
271 lines
272 .iter()
273 .find(|l| prop(l) == p)
274 .map(|l| unescape_text(value(l)).trim().to_string())
275 .filter(|v| !v.is_empty())
276 };
277 let all = |p: &str, render: &dyn Fn(&str) -> String| {
278 lines
279 .iter()
280 .filter(|l| prop(l) == p)
281 .filter_map(|l| {
282 let v = render(value(l));
283 (!v.trim().is_empty()).then(|| Labeled {
284 label: label(l),
285 value: v.trim().to_string(),
286 })
287 })
288 .collect::<Vec<_>>()
289 };
290 let plain = |v: &str| unescape_text(v);
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 components = |v: &str| split_structured(v);
300 let adr = |v: &str| {
301 // PO box, extended, street, locality, region, code, country.
302 let p = components(v);
303 let at = |i: usize| {
304 p.get(i)
305 .map(String::as_str)
306 .unwrap_or("")
307 .trim()
308 .to_string()
309 };
310 let city = [at(5), at(3)]
311 .into_iter()
312 .filter(|s| !s.is_empty())
313 .collect::<Vec<_>>()
314 .join(" ");
315 [at(0), at(1), at(2), city, at(4), at(6)]
316 .into_iter()
317 .filter(|s| !s.is_empty())
318 .collect::<Vec<_>>()
319 .join("\n")
320 };
321 let kind = first("KIND").or_else(|| first("X-ADDRESSBOOKSERVER-KIND"));
322 let is_group = kind.is_some_and(|k| k.eq_ignore_ascii_case("group"));
323 let members = lines
324 .iter()
325 .filter(|l| {
326 let p = prop(l);
327 p == "MEMBER" || p == "X-ADDRESSBOOKSERVER-MEMBER"
328 })
329 .map(|l| {
330 let v = value(l).trim();
331 v.strip_prefix("urn:uuid:").unwrap_or(v).to_string()
332 })
333 .collect();
334 let has_photo = lines.iter().any(|l| {
335 prop(l) == "PHOTO"
336 && (value(l).trim_start().starts_with("data:")
337 || param(l, "ENCODING").is_some_and(|e| {
338 e.eq_ignore_ascii_case("b") || e.eq_ignore_ascii_case("base64")
339 }))
340 });
341 let n = first("N").map(|_| {
342 let raw = lines.iter().find(|l| prop(l) == "N").expect("found above");
343 let p = components(value(raw));
344 let at = |i: usize| {
345 p.get(i)
346 .map(String::as_str)
347 .unwrap_or("")
348 .trim()
349 .to_string()
350 };
351 [at(3), at(1), at(2), at(0), at(4)]
352 .into_iter()
353 .filter(|s| !s.is_empty())
354 .collect::<Vec<_>>()
355 .join(" ")
356 });
357 let org = first("ORG").map(|_| {
358 let raw = lines
359 .iter()
360 .find(|l| prop(l) == "ORG")
361 .expect("found above");
362 components(value(raw))
363 .into_iter()
364 .map(|s| s.trim().to_string())
365 .filter(|s| !s.is_empty())
366 .collect::<Vec<_>>()
367 .join(", ")
368 });
369 let emails = all("EMAIL", &mailto);
370 let full_name = first("FN")
371 .or_else(|| n.clone().filter(|n| !n.is_empty()))
372 .or_else(|| org.clone().filter(|o| !o.is_empty()))
373 .or_else(|| emails.first().map(|e| e.value.clone()))
374 .unwrap_or_default();
375 Card {
376 uid: first("UID").map(|u| u.strip_prefix("urn:uuid:").map(str::to_string).unwrap_or(u)),
377 full_name,
378 org: org.filter(|o| !o.is_empty()),
379 title: first("TITLE"),
380 emails,
381 phones: all("TEL", &tel),
382 addresses: all("ADR", &adr),
383 urls: all("URL", &plain),
384 birthday: lines
385 .iter()
386 .find(|l| prop(l) == "BDAY")
387 .and_then(|l| date(value(l))),
388 anniversary: lines
389 .iter()
390 .find(|l| prop(l) == "ANNIVERSARY" || prop(l) == "X-ANNIVERSARY")
391 .and_then(|l| date(value(l))),
392 note: first("NOTE"),
393 is_group,
394 members,
395 has_photo,
396 }
397}
398
399/// `1980-03-15`, or `--03-15` without a year, from the date forms vCard 3
400/// and 4 use.
401fn date(v: &str) -> Option<String> {
402 let v = v.trim().split(['T', 't']).next()?;
403 match v.strip_prefix("--") {
404 Some(md) => {
405 let md = md.replace('-', "");
406 (md.len() == 4).then(|| format!("--{}-{}", &md[..2], &md[2..]))
407 }
408 None => {
409 let d = v.replace('-', "");
410 (d.len() == 8 && d.bytes().all(|b| b.is_ascii_digit()))
411 .then(|| format!("{}-{}-{}", &d[..4], &d[4..6], &d[6..]))
412 }
413 }
414}
415
416/// The parts of a structured value (N, ADR, ORG): split at `;` that are not
417/// escaped, then unescaped.
418fn split_structured(v: &str) -> Vec<String> {
419 let mut parts = Vec::new();
420 let mut cur = String::new();
421 let mut chars = v.chars();
422 while let Some(c) = chars.next() {
423 match c {
424 '\\' => {
425 cur.push(c);
426 if let Some(n) = chars.next() {
427 cur.push(n);
428 }
429 }
430 ';' => parts.push(unescape_text(&std::mem::take(&mut cur))),
431 _ => cur.push(c),
432 }
433 }
434 parts.push(unescape_text(&cur));
435 parts
436}
437