zone.rs
| 1 | //! Time zones: resolving TZIDs, evaluating VTIMEZONE rules, and converting |
| 2 | //! wall-clock time to UTC. |
| 3 | |
| 4 | use std::collections::HashMap; |
| 5 | use std::str::FromStr; |
| 6 | use std::sync::Arc; |
| 7 | |
| 8 | use calcard::common::timezone::Tz as CalTz; |
| 9 | use calcard::icalendar::{ |
| 10 | ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarProperty, |
| 11 | }; |
| 12 | use chrono::{DateTime, NaiveDate, NaiveDateTime, Offset, TimeDelta, TimeZone, Utc}; |
| 13 | |
| 14 | const DAY: i64 = 86_400; |
| 15 | |
| 16 | /// Maximum onsets per VTIMEZONE observance. |
| 17 | const MAX_ONSETS: usize = 5_000; |
| 18 | |
| 19 | /// A zone in which wall-clock values are interpreted. |
| 20 | #[derive(Debug, Clone)] |
| 21 | pub enum Zone { |
| 22 | Utc, |
| 23 | /// Seconds east of UTC. |
| 24 | Fixed(i32), |
| 25 | Iana(chrono_tz::Tz), |
| 26 | /// A VTIMEZONE whose name maps to no IANA zone. |
| 27 | Custom(Arc<Transitions>), |
| 28 | } |
| 29 | |
| 30 | /// The UTC offset changes of a VTIMEZONE. |
| 31 | #[derive(Debug)] |
| 32 | pub struct Transitions { |
| 33 | initial: i32, |
| 34 | /// `(unix seconds, offset from then on)`, sorted. |
| 35 | list: Vec<(i64, i32)>, |
| 36 | } |
| 37 | |
| 38 | impl Zone { |
| 39 | /// Seconds east of UTC at the instant `utc` (unix seconds). |
| 40 | pub fn offset_at(&self, utc: i64) -> i32 { |
| 41 | match self { |
| 42 | Zone::Utc => 0, |
| 43 | Zone::Fixed(o) => *o, |
| 44 | Zone::Iana(tz) => DateTime::from_timestamp(utc, 0).map_or(0, |t| { |
| 45 | tz.offset_from_utc_datetime(&t.naive_utc()) |
| 46 | .fix() |
| 47 | .local_minus_utc() |
| 48 | }), |
| 49 | Zone::Custom(t) => match t.list.partition_point(|&(at, _)| at <= utc) { |
| 50 | 0 => t.initial, |
| 51 | i => t.list[i - 1].1, |
| 52 | }, |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | /// A skipped wall time takes the offset before the gap, a repeated one its |
| 57 | /// first occurrence (RFC 5545, 3.3.5). |
| 58 | pub fn to_utc(&self, local: NaiveDateTime) -> DateTime<Utc> { |
| 59 | let l = local.and_utc().timestamp(); |
| 60 | // ponytail: assumes at most one offset change within a day of `local`. |
| 61 | let (before, after) = (self.offset_at(l - DAY), self.offset_at(l + DAY)); |
| 62 | let (u1, u2) = (l - i64::from(before), l - i64::from(after)); |
| 63 | let u = match (self.offset_at(u1) == before, self.offset_at(u2) == after) { |
| 64 | (true, true) => u1.min(u2), |
| 65 | (false, true) => u2, |
| 66 | _ => u1, |
| 67 | }; |
| 68 | DateTime::from_timestamp(u, 0).unwrap_or(if u < 0 { |
| 69 | DateTime::<Utc>::MIN_UTC |
| 70 | } else { |
| 71 | DateTime::<Utc>::MAX_UTC |
| 72 | }) |
| 73 | } |
| 74 | |
| 75 | pub fn to_local(&self, utc: DateTime<Utc>) -> NaiveDateTime { |
| 76 | let offset = TimeDelta::seconds(self.offset_at(utc.timestamp()).into()); |
| 77 | add_local(utc.naive_utc(), offset) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | /// Saturating, since durations and time ranges come from clients. |
| 82 | pub(crate) fn add(t: DateTime<Utc>, d: TimeDelta) -> DateTime<Utc> { |
| 83 | t.checked_add_signed(d).unwrap_or(if d < TimeDelta::zero() { |
| 84 | DateTime::<Utc>::MIN_UTC |
| 85 | } else { |
| 86 | DateTime::<Utc>::MAX_UTC |
| 87 | }) |
| 88 | } |
| 89 | |
| 90 | pub(crate) fn add_local(t: NaiveDateTime, d: TimeDelta) -> NaiveDateTime { |
| 91 | t.checked_add_signed(d).unwrap_or(if d < TimeDelta::zero() { |
| 92 | NaiveDateTime::MIN |
| 93 | } else { |
| 94 | NaiveDateTime::MAX |
| 95 | }) |
| 96 | } |
| 97 | |
| 98 | /// The zones one iCalendar object refers to. |
| 99 | pub struct Zones { |
| 100 | by_tzid: HashMap<String, Zone>, |
| 101 | floating: Zone, |
| 102 | } |
| 103 | |
| 104 | impl Zones { |
| 105 | /// `floating` interprets values without a zone, dates included. |
| 106 | pub fn new(cal: &ICalendar, floating: Zone) -> Self { |
| 107 | let by_tzid = cal |
| 108 | .components |
| 109 | .iter() |
| 110 | .filter(|c| c.component_type == ICalendarComponentType::VTimezone) |
| 111 | .filter_map(|c| { |
| 112 | let id = text(c, &ICalendarProperty::Tzid)?; |
| 113 | Some((id.to_string(), vtimezone(cal, c, id)?)) |
| 114 | }); |
| 115 | let mut map = HashMap::new(); |
| 116 | // A repeated TZID is invalid. The first definition wins. |
| 117 | for (id, zone) in by_tzid { |
| 118 | map.entry(id).or_insert(zone); |
| 119 | } |
| 120 | Zones { |
| 121 | by_tzid: map, |
| 122 | floating, |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | pub fn floating(&self) -> &Zone { |
| 127 | &self.floating |
| 128 | } |
| 129 | |
| 130 | /// The zone a TZID parameter names. Without one, or for an unknown name |
| 131 | /// without a VTIMEZONE, this is the floating zone. |
| 132 | pub fn get(&self, tzid: Option<&str>) -> Zone { |
| 133 | let Some(id) = tzid else { |
| 134 | return self.floating.clone(); |
| 135 | }; |
| 136 | self.by_tzid |
| 137 | .get(id) |
| 138 | .cloned() |
| 139 | .or_else(|| by_name(id)) |
| 140 | .or_else(|| fixed_guess(id)) |
| 141 | .unwrap_or_else(|| self.floating.clone()) |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | /// An exact IANA name wins over the VTIMEZONE rules: clients send stale |
| 146 | /// rules, and the user means the place. A zone guessed from another name must |
| 147 | /// agree with the rules. |
| 148 | fn vtimezone(cal: &ICalendar, tz: &ICalendarComponent, id: &str) -> Option<Zone> { |
| 149 | if let Ok(tz) = chrono_tz::Tz::from_str(id.trim()) { |
| 150 | return Some(Zone::Iana(tz)); |
| 151 | } |
| 152 | let guess = by_name(id) |
| 153 | .or_else(|| other_text(tz, "X-LIC-LOCATION").and_then(by_name)) |
| 154 | .or_else( |
| 155 | || match CalTz::from_ms_cdo_zone_id(other_text(tz, "X-MICROSOFT-CDO-TZID")?)? { |
| 156 | CalTz::Tz(tz) => Some(Zone::Iana(tz)), |
| 157 | _ => None, |
| 158 | }, |
| 159 | ); |
| 160 | match ( |
| 161 | guess, |
| 162 | transitions(cal, tz).map(|t| Zone::Custom(Arc::new(t))), |
| 163 | ) { |
| 164 | (Some(guess), Some(rules)) if agree(&guess, &rules) => Some(guess), |
| 165 | (_, Some(rules)) => Some(rules), |
| 166 | (Some(guess), None) => Some(guess), |
| 167 | (None, None) => fixed_guess(id), |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | /// Same offsets in mid-January and mid-July of recent years. |
| 172 | fn agree(a: &Zone, b: &Zone) -> bool { |
| 173 | (2020..=2022) |
| 174 | .flat_map(|y| [1, 7].map(|m| NaiveDate::from_ymd_opt(y, m, 15))) |
| 175 | .flatten() |
| 176 | .map(|d| d.and_time(Default::default()).and_utc().timestamp()) |
| 177 | .all(|t| a.offset_at(t) == b.offset_at(t)) |
| 178 | } |
| 179 | |
| 180 | fn by_name(name: &str) -> Option<Zone> { |
| 181 | let name = name.trim(); |
| 182 | // Vendor-prefixed IDs such as `/mozilla.org/20050126_1/Europe/Berlin`. |
| 183 | std::iter::once(name) |
| 184 | .chain(name.match_indices('/').map(|(i, _)| &name[i + 1..])) |
| 185 | .find_map(|n| chrono_tz::Tz::from_str(n).ok()) |
| 186 | .or_else(|| match CalTz::from_str(name) { |
| 187 | Ok(CalTz::Tz(tz)) if !is_fixed(tz) => Some(tz), |
| 188 | _ => None, |
| 189 | }) |
| 190 | .map(Zone::Iana) |
| 191 | } |
| 192 | |
| 193 | /// A fixed offset read from a label such as `(UTC+02:00) Athens`. Ignores DST, |
| 194 | /// so it ranks below the VTIMEZONE rules. |
| 195 | fn fixed_guess(name: &str) -> Option<Zone> { |
| 196 | match CalTz::from_str(name) { |
| 197 | Ok(CalTz::Fixed(o)) => Some(Zone::Fixed(o.local_minus_utc())), |
| 198 | Ok(CalTz::Tz(tz)) if is_fixed(tz) => Some(Zone::Iana(tz)), |
| 199 | _ => None, |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | /// calcard turns offset labels into `Etc/GMT-2` and the like. |
| 204 | fn is_fixed(tz: chrono_tz::Tz) -> bool { |
| 205 | tz.name().starts_with("Etc/") |
| 206 | } |
| 207 | |
| 208 | fn transitions(cal: &ICalendar, tz: &ICalendarComponent) -> Option<Transitions> { |
| 209 | // ponytail: rules past this year repeat the last offset. |
| 210 | let horizon = NaiveDate::from_ymd_opt(2200, 1, 1)?.and_time(Default::default()); |
| 211 | let mut list = Vec::new(); |
| 212 | for obs in tz |
| 213 | .component_ids |
| 214 | .iter() |
| 215 | .filter_map(|&id| cal.components.get(id as usize)) |
| 216 | { |
| 217 | if !matches!( |
| 218 | obs.component_type, |
| 219 | ICalendarComponentType::Standard | ICalendarComponentType::Daylight |
| 220 | ) { |
| 221 | continue; |
| 222 | } |
| 223 | let (Some(from), Some(to), Some(start)) = ( |
| 224 | offset(obs, &ICalendarProperty::Tzoffsetfrom), |
| 225 | offset(obs, &ICalendarProperty::Tzoffsetto), |
| 226 | obs.property(&ICalendarProperty::Dtstart) |
| 227 | .and_then(|e| e.values.first()?.as_partial_date_time()?.to_date_time()), |
| 228 | ) else { |
| 229 | continue; |
| 230 | }; |
| 231 | let start = start.date_time; |
| 232 | let mut onsets = vec![start]; |
| 233 | for rule in obs |
| 234 | .properties(&ICalendarProperty::Rrule) |
| 235 | .filter_map(|e| crate::expand::rule(e.values.first()?)) |
| 236 | { |
| 237 | // UNTIL is UTC; onsets are wall-clock time before the change. |
| 238 | let until = rule.until.as_ref().and_then(|u| { |
| 239 | let u = u.to_date_time()?; |
| 240 | Some(match u.offset { |
| 241 | Some(o) => { |
| 242 | u.date_time + TimeDelta::seconds((from - o.local_minus_utc()).into()) |
| 243 | } |
| 244 | None => u.date_time, |
| 245 | }) |
| 246 | }); |
| 247 | onsets.extend(crate::expand::occurrences(rule, start, until, horizon).take(MAX_ONSETS)); |
| 248 | } |
| 249 | for e in obs.properties(&ICalendarProperty::Rdate) { |
| 250 | onsets.extend( |
| 251 | e.values |
| 252 | .iter() |
| 253 | .filter_map(|v| Some(v.as_partial_date_time()?.to_date_time()?.date_time)), |
| 254 | ); |
| 255 | } |
| 256 | list.extend( |
| 257 | onsets |
| 258 | .into_iter() |
| 259 | .map(|l| (l.and_utc().timestamp() - i64::from(from), from, to)), |
| 260 | ); |
| 261 | } |
| 262 | list.sort_unstable(); |
| 263 | Some(Transitions { |
| 264 | initial: list.first()?.1, |
| 265 | list: list.into_iter().map(|(at, _, to)| (at, to)).collect(), |
| 266 | }) |
| 267 | } |
| 268 | |
| 269 | fn offset(c: &ICalendarComponent, prop: &ICalendarProperty) -> Option<i32> { |
| 270 | let v = c.property(prop)?.values.first()?.as_partial_date_time()?; |
| 271 | let secs = i32::from(v.tz_hour?) * 3600 + i32::from(v.tz_minute.unwrap_or(0)) * 60; |
| 272 | Some(if v.tz_minus { -secs } else { secs }) |
| 273 | } |
| 274 | |
| 275 | fn text<'a>(c: &'a ICalendarComponent, prop: &ICalendarProperty) -> Option<&'a str> { |
| 276 | c.property(prop)?.values.first()?.as_text() |
| 277 | } |
| 278 | |
| 279 | fn other_text<'a>(c: &'a ICalendarComponent, name: &str) -> Option<&'a str> { |
| 280 | c.entries |
| 281 | .iter() |
| 282 | .find(|e| matches!(&e.name, ICalendarProperty::Other(n) if n.eq_ignore_ascii_case(name)))? |
| 283 | .values |
| 284 | .first()? |
| 285 | .as_text() |
| 286 | } |
| 287 |