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_prop(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/// The zone of a `calendar-timezone` value: a VCALENDAR holding one
146/// VTIMEZONE.
147pub fn from_vtimezone(text: &str) -> Option<Zone> {
148 let cal = ICalendar::parse(text).ok()?;
149 let tz = cal
150 .components
151 .iter()
152 .find(|c| c.component_type == ICalendarComponentType::VTimezone)?;
153 vtimezone(&cal, tz, text_prop(tz, &ICalendarProperty::Tzid)?)
154}
155
156/// The zone a TZID names, without a VTIMEZONE to go by.
157pub fn named(tzid: &str) -> Option<Zone> {
158 by_name(tzid).or_else(|| fixed_guess(tzid))
159}
160
161/// An exact IANA name wins over the VTIMEZONE rules: clients send stale
162/// rules, and the user means the place. A zone guessed from another name must
163/// agree with the rules.
164fn vtimezone(cal: &ICalendar, tz: &ICalendarComponent, id: &str) -> Option<Zone> {
165 if let Ok(tz) = chrono_tz::Tz::from_str(id.trim()) {
166 return Some(Zone::Iana(tz));
167 }
168 let guess = by_name(id)
169 .or_else(|| other_text(tz, "X-LIC-LOCATION").and_then(by_name))
170 .or_else(
171 || match CalTz::from_ms_cdo_zone_id(other_text(tz, "X-MICROSOFT-CDO-TZID")?)? {
172 CalTz::Tz(tz) => Some(Zone::Iana(tz)),
173 _ => None,
174 },
175 );
176 match (
177 guess,
178 transitions(cal, tz).map(|t| Zone::Custom(Arc::new(t))),
179 ) {
180 (Some(guess), Some(rules)) if agree(&guess, &rules) => Some(guess),
181 (_, Some(rules)) => Some(rules),
182 (Some(guess), None) => Some(guess),
183 (None, None) => fixed_guess(id),
184 }
185}
186
187/// Same offsets in mid-January and mid-July of recent years.
188fn agree(a: &Zone, b: &Zone) -> bool {
189 (2020..=2022)
190 .flat_map(|y| [1, 7].map(|m| NaiveDate::from_ymd_opt(y, m, 15)))
191 .flatten()
192 .map(|d| d.and_time(Default::default()).and_utc().timestamp())
193 .all(|t| a.offset_at(t) == b.offset_at(t))
194}
195
196fn by_name(name: &str) -> Option<Zone> {
197 let name = name.trim();
198 // Vendor-prefixed IDs such as `/mozilla.org/20050126_1/Europe/Berlin`.
199 std::iter::once(name)
200 .chain(name.match_indices('/').map(|(i, _)| &name[i + 1..]))
201 .find_map(|n| chrono_tz::Tz::from_str(n).ok())
202 .or_else(|| match CalTz::from_str(name) {
203 Ok(CalTz::Tz(tz)) if !is_fixed(tz) => Some(tz),
204 _ => None,
205 })
206 .map(Zone::Iana)
207}
208
209/// A fixed offset read from a label such as `(UTC+02:00) Athens`. Ignores DST,
210/// so it ranks below the VTIMEZONE rules.
211fn fixed_guess(name: &str) -> Option<Zone> {
212 match CalTz::from_str(name) {
213 Ok(CalTz::Fixed(o)) => Some(Zone::Fixed(o.local_minus_utc())),
214 Ok(CalTz::Tz(tz)) if is_fixed(tz) => Some(Zone::Iana(tz)),
215 _ => None,
216 }
217}
218
219/// calcard turns offset labels into `Etc/GMT-2` and the like.
220fn is_fixed(tz: chrono_tz::Tz) -> bool {
221 tz.name().starts_with("Etc/")
222}
223
224fn transitions(cal: &ICalendar, tz: &ICalendarComponent) -> Option<Transitions> {
225 // ponytail: rules past this year repeat the last offset.
226 let horizon = NaiveDate::from_ymd_opt(2200, 1, 1)?.and_time(Default::default());
227 let mut list = Vec::new();
228 for obs in tz
229 .component_ids
230 .iter()
231 .filter_map(|&id| cal.components.get(id as usize))
232 {
233 if !matches!(
234 obs.component_type,
235 ICalendarComponentType::Standard | ICalendarComponentType::Daylight
236 ) {
237 continue;
238 }
239 let (Some(from), Some(to), Some(start)) = (
240 offset(obs, &ICalendarProperty::Tzoffsetfrom),
241 offset(obs, &ICalendarProperty::Tzoffsetto),
242 obs.property(&ICalendarProperty::Dtstart)
243 .and_then(|e| e.values.first()?.as_partial_date_time()?.to_date_time()),
244 ) else {
245 continue;
246 };
247 let start = start.date_time;
248 let mut onsets = vec![start];
249 for rule in obs
250 .properties(&ICalendarProperty::Rrule)
251 .filter_map(|e| crate::expand::rule(e.values.first()?))
252 {
253 // UNTIL is UTC; onsets are wall-clock time before the change.
254 let until = rule.until.as_ref().and_then(|u| {
255 let u = u.to_date_time()?;
256 Some(match u.offset {
257 Some(o) => {
258 u.date_time + TimeDelta::seconds((from - o.local_minus_utc()).into())
259 }
260 None => u.date_time,
261 })
262 });
263 onsets.extend(crate::expand::occurrences(rule, start, until, horizon).take(MAX_ONSETS));
264 }
265 for e in obs.properties(&ICalendarProperty::Rdate) {
266 onsets.extend(
267 e.values
268 .iter()
269 .filter_map(|v| Some(v.as_partial_date_time()?.to_date_time()?.date_time)),
270 );
271 }
272 list.extend(
273 onsets
274 .into_iter()
275 .map(|l| (l.and_utc().timestamp() - i64::from(from), from, to)),
276 );
277 }
278 list.sort_unstable();
279 Some(Transitions {
280 initial: list.first()?.1,
281 list: list.into_iter().map(|(at, _, to)| (at, to)).collect(),
282 })
283}
284
285fn offset(c: &ICalendarComponent, prop: &ICalendarProperty) -> Option<i32> {
286 let v = c.property(prop)?.values.first()?.as_partial_date_time()?;
287 let secs = i32::from(v.tz_hour?) * 3600 + i32::from(v.tz_minute.unwrap_or(0)) * 60;
288 Some(if v.tz_minus { -secs } else { secs })
289}
290
291fn text_prop<'a>(c: &'a ICalendarComponent, prop: &ICalendarProperty) -> Option<&'a str> {
292 c.property(prop)?.values.first()?.as_text()
293}
294
295fn other_text<'a>(c: &'a ICalendarComponent, name: &str) -> Option<&'a str> {
296 c.entries
297 .iter()
298 .find(|e| matches!(&e.name, ICalendarProperty::Other(n) if n.eq_ignore_ascii_case(name)))?
299 .values
300 .first()?
301 .as_text()
302}
303