zone.rs
⎇
Raw
1//! Time zones: resolving TZIDs, evaluating VTIMEZONE rules, and converting
2//! wall-clock time to UTC.
3
4use std::collections::{HashMap, HashSet};
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 // Only the zones the object uses: each VTIMEZONE costs its rules.
108 let used: HashSet<&str> = cal
109 .components
110 .iter()
111 .flat_map(|c| c.entries.iter().filter_map(|e| e.tz_id()))
112 .collect();
113 let mut map = HashMap::new();
114 for c in cal
115 .components
116 .iter()
117 .filter(|c| c.component_type == ICalendarComponentType::VTimezone)
118 {
119 let Some(id) = text_prop(c, &ICalendarProperty::Tzid).filter(|id| used.contains(id))
120 else {
121 continue;
122 };
123 // A repeated TZID is invalid. The first definition wins.
124 if !map.contains_key(id)
125 && let Some(zone) = vtimezone(cal, c, id)
126 {
127 map.insert(id.to_string(), zone);
128 }
129 }
130 Zones {
131 by_tzid: map,
132 floating,
133 }
134 }
135
136 pub fn floating(&self) -> &Zone {
137 &self.floating
138 }
139
140 /// The zone a TZID parameter names. Without one, or for an unknown name
141 /// without a VTIMEZONE, this is the floating zone.
142 pub fn get(&self, tzid: Option<&str>) -> Zone {
143 let Some(id) = tzid else {
144 return self.floating.clone();
145 };
146 self.by_tzid
147 .get(id)
148 .cloned()
149 .or_else(|| by_name(id))
150 .or_else(|| fixed_guess(id))
151 .unwrap_or_else(|| self.floating.clone())
152 }
153}
154
155/// The zone of a `calendar-timezone` value: a VCALENDAR holding one
156/// VTIMEZONE.
157pub fn from_vtimezone(text: &str) -> Option<Zone> {
158 let cal = ICalendar::parse(text).ok()?;
159 let tz = cal
160 .components
161 .iter()
162 .find(|c| c.component_type == ICalendarComponentType::VTimezone)?;
163 vtimezone(&cal, tz, text_prop(tz, &ICalendarProperty::Tzid)?)
164}
165
166/// The zone a TZID names, without a VTIMEZONE to go by.
167pub fn named(tzid: &str) -> Option<Zone> {
168 by_name(tzid).or_else(|| fixed_guess(tzid))
169}
170
171/// An exact IANA name wins over the VTIMEZONE rules: clients send stale
172/// rules, and the user means the place. A zone guessed from another name must
173/// agree with the rules.
174fn vtimezone(cal: &ICalendar, tz: &ICalendarComponent, id: &str) -> Option<Zone> {
175 if let Ok(tz) = chrono_tz::Tz::from_str(id.trim()) {
176 return Some(Zone::Iana(tz));
177 }
178 let guess = by_name(id)
179 .or_else(|| other_text(tz, "X-LIC-LOCATION").and_then(by_name))
180 .or_else(
181 || match CalTz::from_ms_cdo_zone_id(other_text(tz, "X-MICROSOFT-CDO-TZID")?)? {
182 CalTz::Tz(tz) => Some(Zone::Iana(tz)),
183 _ => None,
184 },
185 );
186 match (
187 guess,
188 transitions(cal, tz).map(|t| Zone::Custom(Arc::new(t))),
189 ) {
190 (Some(guess), Some(rules)) if agree(&guess, &rules) => Some(guess),
191 (_, Some(rules)) => Some(rules),
192 (Some(guess), None) => Some(guess),
193 (None, None) => fixed_guess(id),
194 }
195}
196
197/// Same offsets in mid-January and mid-July of recent years.
198fn agree(a: &Zone, b: &Zone) -> bool {
199 (2020..=2022)
200 .flat_map(|y| [1, 7].map(|m| NaiveDate::from_ymd_opt(y, m, 15)))
201 .flatten()
202 .map(|d| d.and_time(Default::default()).and_utc().timestamp())
203 .all(|t| a.offset_at(t) == b.offset_at(t))
204}
205
206/// The zone an IANA, Windows or vendor-prefixed name stands for.
207pub fn by_name(name: &str) -> Option<Zone> {
208 let name = name.trim();
209 // Vendor-prefixed IDs such as `/mozilla.org/20050126_1/Europe/Berlin`.
210 std::iter::once(name)
211 .chain(name.match_indices('/').map(|(i, _)| &name[i + 1..]))
212 .find_map(|n| chrono_tz::Tz::from_str(n).ok())
213 .or_else(|| match CalTz::from_str(name) {
214 Ok(CalTz::Tz(tz)) if !is_fixed(tz) => Some(tz),
215 _ => None,
216 })
217 .map(Zone::Iana)
218}
219
220/// A fixed offset read from a label such as `(UTC+02:00) Athens`. Ignores DST,
221/// so it ranks below the VTIMEZONE rules.
222fn fixed_guess(name: &str) -> Option<Zone> {
223 match CalTz::from_str(name) {
224 Ok(CalTz::Fixed(o)) => Some(Zone::Fixed(o.local_minus_utc())),
225 Ok(CalTz::Tz(tz)) if is_fixed(tz) => Some(Zone::Iana(tz)),
226 _ => None,
227 }
228}
229
230/// calcard turns offset labels into `Etc/GMT-2` and the like.
231fn is_fixed(tz: chrono_tz::Tz) -> bool {
232 tz.name().starts_with("Etc/")
233}
234
235fn transitions(cal: &ICalendar, tz: &ICalendarComponent) -> Option<Transitions> {
236 // ponytail: rules past this year repeat the last offset.
237 let horizon = NaiveDate::from_ymd_opt(2200, 1, 1)?.and_time(Default::default());
238 let mut list = Vec::new();
239 for obs in tz
240 .component_ids
241 .iter()
242 .filter_map(|&id| cal.components.get(id as usize))
243 {
244 if !matches!(
245 obs.component_type,
246 ICalendarComponentType::Standard | ICalendarComponentType::Daylight
247 ) {
248 continue;
249 }
250 let (Some(from), Some(to), Some(start)) = (
251 offset(obs, &ICalendarProperty::Tzoffsetfrom),
252 offset(obs, &ICalendarProperty::Tzoffsetto),
253 obs.property(&ICalendarProperty::Dtstart)
254 .and_then(|e| e.values.first()?.as_partial_date_time()?.to_date_time()),
255 ) else {
256 continue;
257 };
258 let start = start.date_time;
259 let mut onsets = vec![start];
260 for rule in obs
261 .properties(&ICalendarProperty::Rrule)
262 .filter_map(|e| crate::expand::rule(e.values.first()?))
263 {
264 // UNTIL is UTC; onsets are wall-clock time before the change.
265 let until = rule.until.as_ref().and_then(|u| {
266 let u = u.to_date_time()?;
267 Some(match u.offset {
268 Some(o) => {
269 u.date_time + TimeDelta::seconds((from - o.local_minus_utc()).into())
270 }
271 None => u.date_time,
272 })
273 });
274 onsets.extend(
275 crate::expand::occurrences(
276 rule,
277 start + SHIFT,
278 until.map(|u| u + SHIFT),
279 horizon + SHIFT,
280 )
281 .take(MAX_ONSETS)
282 .map(|t| t - SHIFT),
283 );
284 }
285 for e in obs.properties(&ICalendarProperty::Rdate) {
286 onsets.extend(
287 e.values
288 .iter()
289 .filter_map(|v| Some(v.as_partial_date_time()?.to_date_time()?.date_time)),
290 );
291 }
292 list.extend(
293 onsets
294 .into_iter()
295 .map(|l| (l.and_utc().timestamp() - i64::from(from), from, to)),
296 );
297 }
298 list.sort_unstable();
299 Some(Transitions {
300 initial: list.first()?.1,
301 list: list.into_iter().map(|(at, _, to)| (at, to)).collect(),
302 })
303}
304
305/// rrule runs a rule that never matches on to year 9999. The calendar and the
306/// weekdays repeat every 400 years (146,097 days), so zone rules run this much
307/// later, close to that limit.
308const SHIFT: TimeDelta = TimeDelta::days(19 * 146_097);
309
310fn offset(c: &ICalendarComponent, prop: &ICalendarProperty) -> Option<i32> {
311 let v = c.property(prop)?.values.first()?.as_partial_date_time()?;
312 let secs = i32::from(v.tz_hour?) * 3600 + i32::from(v.tz_minute.unwrap_or(0)) * 60;
313 Some(if v.tz_minus { -secs } else { secs })
314}
315
316fn text_prop<'a>(c: &'a ICalendarComponent, prop: &ICalendarProperty) -> Option<&'a str> {
317 c.property(prop)?.values.first()?.as_text()
318}
319
320fn other_text<'a>(c: &'a ICalendarComponent, name: &str) -> Option<&'a str> {
321 c.entries
322 .iter()
323 .find(|e| matches!(&e.name, ICalendarProperty::Other(n) if n.eq_ignore_ascii_case(name)))?
324 .values
325 .first()?
326 .as_text()
327}
328