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