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