zone.rs
⎇
Raw
1//! Time zones: resolving TZIDs, evaluating VTIMEZONE rules, and converting
2//! wall-clock time to UTC.
3
4use std::collections::HashMap;
5use std::str::FromStr;
6use std::sync::Arc;
7
8use calcard::common::timezone::Tz as CalTz;
9use calcard::icalendar::{
10 ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarProperty,
11};
12use chrono::{DateTime, NaiveDate, NaiveDateTime, Offset, TimeDelta, TimeZone, Utc};
13
14const DAY: i64 = 86_400;
15
16/// Maximum onsets per VTIMEZONE observance.
17const MAX_ONSETS: usize = 5_000;
18
19/// A zone in which wall-clock values are interpreted.
20#[derive(Debug, Clone)]
21pub 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)]
32pub struct Transitions {
33 initial: i32,
34 /// `(unix seconds, offset from then on)`, sorted.
35 list: Vec<(i64, i32)>,
36}
37
38impl 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.
82pub(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
90pub(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.
99pub struct Zones {
100 by_tzid: HashMap<String, Zone>,
101 floating: Zone,
102}
103
104impl 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.
148fn 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.
172fn 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
180fn 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.
195fn 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.
204fn is_fixed(tz: chrono_tz::Tz) -> bool {
205 tz.name().starts_with("Etc/")
206}
207
208fn 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
269fn 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
275fn text<'a>(c: &'a ICalendarComponent, prop: &ICalendarProperty) -> Option<&'a str> {
276 c.property(prop)?.values.first()?.as_text()
277}
278
279fn 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