//! Time zones: resolving TZIDs, evaluating VTIMEZONE rules, and converting //! wall-clock time to UTC. use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; use calcard::common::timezone::Tz as CalTz; use calcard::icalendar::{ ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarProperty, }; use chrono::{DateTime, NaiveDate, NaiveDateTime, Offset, TimeDelta, TimeZone, Utc}; const DAY: i64 = 86_400; /// Maximum onsets per VTIMEZONE observance. const MAX_ONSETS: usize = 5_000; /// A zone in which wall-clock values are interpreted. #[derive(Debug, Clone)] pub enum Zone { Utc, /// Seconds east of UTC. Fixed(i32), Iana(chrono_tz::Tz), /// A VTIMEZONE whose name maps to no IANA zone. Custom(Arc), } /// The UTC offset changes of a VTIMEZONE. #[derive(Debug)] pub struct Transitions { initial: i32, /// `(unix seconds, offset from then on)`, sorted. list: Vec<(i64, i32)>, } impl Zone { /// Seconds east of UTC at the instant `utc` (unix seconds). pub fn offset_at(&self, utc: i64) -> i32 { match self { Zone::Utc => 0, Zone::Fixed(o) => *o, Zone::Iana(tz) => DateTime::from_timestamp(utc, 0).map_or(0, |t| { tz.offset_from_utc_datetime(&t.naive_utc()) .fix() .local_minus_utc() }), Zone::Custom(t) => match t.list.partition_point(|&(at, _)| at <= utc) { 0 => t.initial, i => t.list[i - 1].1, }, } } /// A skipped wall time takes the offset before the gap, a repeated one its /// first occurrence (RFC 5545, 3.3.5). pub fn to_utc(&self, local: NaiveDateTime) -> DateTime { let l = local.and_utc().timestamp(); // ponytail: assumes at most one offset change within a day of `local`. let (before, after) = (self.offset_at(l - DAY), self.offset_at(l + DAY)); let (u1, u2) = (l - i64::from(before), l - i64::from(after)); let u = match (self.offset_at(u1) == before, self.offset_at(u2) == after) { (true, true) => u1.min(u2), (false, true) => u2, _ => u1, }; DateTime::from_timestamp(u, 0).unwrap_or(if u < 0 { DateTime::::MIN_UTC } else { DateTime::::MAX_UTC }) } pub fn to_local(&self, utc: DateTime) -> NaiveDateTime { let offset = TimeDelta::seconds(self.offset_at(utc.timestamp()).into()); add_local(utc.naive_utc(), offset) } } /// Saturating, since durations and time ranges come from clients. pub(crate) fn add(t: DateTime, d: TimeDelta) -> DateTime { t.checked_add_signed(d).unwrap_or(if d < TimeDelta::zero() { DateTime::::MIN_UTC } else { DateTime::::MAX_UTC }) } pub(crate) fn add_local(t: NaiveDateTime, d: TimeDelta) -> NaiveDateTime { t.checked_add_signed(d).unwrap_or(if d < TimeDelta::zero() { NaiveDateTime::MIN } else { NaiveDateTime::MAX }) } /// The zones one iCalendar object refers to. pub struct Zones { by_tzid: HashMap, floating: Zone, } impl Zones { /// `floating` interprets values without a zone, dates included. pub fn new(cal: &ICalendar, floating: Zone) -> Self { let by_tzid = cal .components .iter() .filter(|c| c.component_type == ICalendarComponentType::VTimezone) .filter_map(|c| { let id = text_prop(c, &ICalendarProperty::Tzid)?; Some((id.to_string(), vtimezone(cal, c, id)?)) }); let mut map = HashMap::new(); // A repeated TZID is invalid. The first definition wins. for (id, zone) in by_tzid { map.entry(id).or_insert(zone); } Zones { by_tzid: map, floating, } } pub fn floating(&self) -> &Zone { &self.floating } /// The zone a TZID parameter names. Without one, or for an unknown name /// without a VTIMEZONE, this is the floating zone. pub fn get(&self, tzid: Option<&str>) -> Zone { let Some(id) = tzid else { return self.floating.clone(); }; self.by_tzid .get(id) .cloned() .or_else(|| by_name(id)) .or_else(|| fixed_guess(id)) .unwrap_or_else(|| self.floating.clone()) } } /// The zone of a `calendar-timezone` value: a VCALENDAR holding one /// VTIMEZONE. pub fn from_vtimezone(text: &str) -> Option { let cal = ICalendar::parse(text).ok()?; let tz = cal .components .iter() .find(|c| c.component_type == ICalendarComponentType::VTimezone)?; vtimezone(&cal, tz, text_prop(tz, &ICalendarProperty::Tzid)?) } /// The zone a TZID names, without a VTIMEZONE to go by. pub fn named(tzid: &str) -> Option { by_name(tzid).or_else(|| fixed_guess(tzid)) } /// An exact IANA name wins over the VTIMEZONE rules: clients send stale /// rules, and the user means the place. A zone guessed from another name must /// agree with the rules. fn vtimezone(cal: &ICalendar, tz: &ICalendarComponent, id: &str) -> Option { if let Ok(tz) = chrono_tz::Tz::from_str(id.trim()) { return Some(Zone::Iana(tz)); } let guess = by_name(id) .or_else(|| other_text(tz, "X-LIC-LOCATION").and_then(by_name)) .or_else( || match CalTz::from_ms_cdo_zone_id(other_text(tz, "X-MICROSOFT-CDO-TZID")?)? { CalTz::Tz(tz) => Some(Zone::Iana(tz)), _ => None, }, ); match ( guess, transitions(cal, tz).map(|t| Zone::Custom(Arc::new(t))), ) { (Some(guess), Some(rules)) if agree(&guess, &rules) => Some(guess), (_, Some(rules)) => Some(rules), (Some(guess), None) => Some(guess), (None, None) => fixed_guess(id), } } /// Same offsets in mid-January and mid-July of recent years. fn agree(a: &Zone, b: &Zone) -> bool { (2020..=2022) .flat_map(|y| [1, 7].map(|m| NaiveDate::from_ymd_opt(y, m, 15))) .flatten() .map(|d| d.and_time(Default::default()).and_utc().timestamp()) .all(|t| a.offset_at(t) == b.offset_at(t)) } /// The zone an IANA, Windows or vendor-prefixed name stands for. pub fn by_name(name: &str) -> Option { let name = name.trim(); // Vendor-prefixed IDs such as `/mozilla.org/20050126_1/Europe/Berlin`. std::iter::once(name) .chain(name.match_indices('/').map(|(i, _)| &name[i + 1..])) .find_map(|n| chrono_tz::Tz::from_str(n).ok()) .or_else(|| match CalTz::from_str(name) { Ok(CalTz::Tz(tz)) if !is_fixed(tz) => Some(tz), _ => None, }) .map(Zone::Iana) } /// A fixed offset read from a label such as `(UTC+02:00) Athens`. Ignores DST, /// so it ranks below the VTIMEZONE rules. fn fixed_guess(name: &str) -> Option { match CalTz::from_str(name) { Ok(CalTz::Fixed(o)) => Some(Zone::Fixed(o.local_minus_utc())), Ok(CalTz::Tz(tz)) if is_fixed(tz) => Some(Zone::Iana(tz)), _ => None, } } /// calcard turns offset labels into `Etc/GMT-2` and the like. fn is_fixed(tz: chrono_tz::Tz) -> bool { tz.name().starts_with("Etc/") } fn transitions(cal: &ICalendar, tz: &ICalendarComponent) -> Option { // ponytail: rules past this year repeat the last offset. let horizon = NaiveDate::from_ymd_opt(2200, 1, 1)?.and_time(Default::default()); let mut list = Vec::new(); for obs in tz .component_ids .iter() .filter_map(|&id| cal.components.get(id as usize)) { if !matches!( obs.component_type, ICalendarComponentType::Standard | ICalendarComponentType::Daylight ) { continue; } let (Some(from), Some(to), Some(start)) = ( offset(obs, &ICalendarProperty::Tzoffsetfrom), offset(obs, &ICalendarProperty::Tzoffsetto), obs.property(&ICalendarProperty::Dtstart) .and_then(|e| e.values.first()?.as_partial_date_time()?.to_date_time()), ) else { continue; }; let start = start.date_time; let mut onsets = vec![start]; for rule in obs .properties(&ICalendarProperty::Rrule) .filter_map(|e| crate::expand::rule(e.values.first()?)) { // UNTIL is UTC; onsets are wall-clock time before the change. let until = rule.until.as_ref().and_then(|u| { let u = u.to_date_time()?; Some(match u.offset { Some(o) => { u.date_time + TimeDelta::seconds((from - o.local_minus_utc()).into()) } None => u.date_time, }) }); onsets.extend(crate::expand::occurrences(rule, start, until, horizon).take(MAX_ONSETS)); } for e in obs.properties(&ICalendarProperty::Rdate) { onsets.extend( e.values .iter() .filter_map(|v| Some(v.as_partial_date_time()?.to_date_time()?.date_time)), ); } list.extend( onsets .into_iter() .map(|l| (l.and_utc().timestamp() - i64::from(from), from, to)), ); } list.sort_unstable(); Some(Transitions { initial: list.first()?.1, list: list.into_iter().map(|(at, _, to)| (at, to)).collect(), }) } fn offset(c: &ICalendarComponent, prop: &ICalendarProperty) -> Option { let v = c.property(prop)?.values.first()?.as_partial_date_time()?; let secs = i32::from(v.tz_hour?) * 3600 + i32::from(v.tz_minute.unwrap_or(0)) * 60; Some(if v.tz_minus { -secs } else { secs }) } fn text_prop<'a>(c: &'a ICalendarComponent, prop: &ICalendarProperty) -> Option<&'a str> { c.property(prop)?.values.first()?.as_text() } fn other_text<'a>(c: &'a ICalendarComponent, name: &str) -> Option<&'a str> { c.entries .iter() .find(|e| matches!(&e.name, ICalendarProperty::Other(n) if n.eq_ignore_ascii_case(name)))? .values .first()? .as_text() }