///|
/// A zone-name to UTC-offset table used to resolve `TZID=` parameters.
///
/// Offsets are seconds east of UTC. iCalendar defines `TZID` case-sensitively,
/// but real feeds are sloppy (Google emits `Asia/Shanghai`, some Outlook
/// exports `ASIA/SHANGHAI`), so keys are normalised before comparison.
///
/// The table holds *fixed* offsets only. Honouring a named zone's
/// daylight-saving history per date needs an IANA time-zone database, which
/// `moonbitlang/x/time` does not ship and no mooncakes package provides — see
/// seam S2 in docs/upstream-seams.md. The practical consequence is stated in
/// README: a February and a July event in `Europe/Berlin` resolve to the same
/// offset here (+01:00), while a feed that embeds its own `VTIMEZONE` gets the
/// offset its author actually declared.
pub struct ZoneTable {
entries : Map[String, Int]
} derive(Debug)
///|
pub extend ZoneTable with @moonbitlang/core/debug.Debug::{to_repr}
///|
/// An empty table: every `TZID` fails to resolve until zones are added.
pub fn ZoneTable::empty() -> ZoneTable {
{ entries: Map([]), }
}
///|
/// Offsets for the named zones that actually show up in the feeds we test
/// against. Standard-time values only, on purpose (see the `ZoneTable` doc).
pub fn ZoneTable::builtin_common() -> ZoneTable {
let zones : Map[String, Int] = {
"UTC": 0,
"GMT": 0,
"Z": 0,
"ASIA/SHANGHAI": offset(8, 0),
"ASIA/CHONGQING": offset(8, 0),
"ASIA/HONG_KONG": offset(8, 0),
"ASIA/TAIPEI": offset(8, 0),
"ASIA/SINGAPORE": offset(8, 0),
"ASIA/URUMQI": offset(6, 0),
"ASIA/TOKYO": offset(9, 0),
"ASIA/SEOUL": offset(9, 0),
"ASIA/KOLKATA": offset(5, 30),
"ASIA/DUBAI": offset(4, 0),
"AUSTRALIA/SYDNEY": offset(10, 0),
"EUROPE/LONDON": offset(0, 0),
"EUROPE/BERLIN": offset(1, 0),
"EUROPE/PARIS": offset(1, 0),
"EUROPE/BELGRADE": offset(1, 0),
"EUROPE/MOSCOW": offset(3, 0),
"AMERICA/NEW_YORK": offset(-5, 0),
"AMERICA/CHICAGO": offset(-6, 0),
"AMERICA/DENVER": offset(-7, 0),
"AMERICA/LOS_ANGELES": offset(-8, 0),
"AMERICA/SAO_PAULO": offset(-3, 0),
"AFRICA/JOHANNESBURG": offset(2, 0),
}
{ entries: zones, }
}
///|
/// Seconds east of UTC from an hour/minute pair, where a negative hour makes
/// the whole offset negative (`offset(-5, 30)` is `-0530`, not `-0430`).
fn offset(hours : Int, minutes : Int) -> Int {
let sign = if hours < 0 { -1 } else { 1 }
sign * (hours.abs() * 3600 + minutes.abs() * 60)
}
///|
/// Normalise a zone name for table keys and lookups.
fn normalize_zone_id(tzid : String) -> String {
tzid.view().trim(chars=" \t".view()).to_upper().to_owned()
}
///|
/// Record a zone. Later inserts win, which is what lets a feed's own
/// `VTIMEZONE` override a built-in guess for the same name.
pub fn ZoneTable::insert(
self : ZoneTable,
tzid : String,
seconds : Int,
) -> Unit {
self.entries[normalize_zone_id(tzid)] = seconds
}
///|
/// The built-in zones, overridden by anything the feed declared itself. This is
/// the table `parse_date_time` wants: a feed that ships a `VTIMEZONE` knows
/// better than our guess, and everything else still resolves.
pub fn ZoneTable::with_builtin(feed : ZoneTable) -> ZoneTable {
let table = ZoneTable::builtin_common()
for name, seconds in feed.entries {
table.entries[name] = seconds
}
table
}
///|
/// Look a zone up, case-insensitively; the most recently inserted match wins.
pub fn ZoneTable::lookup(self : ZoneTable, tzid : String) -> Int? {
self.entries.get(normalize_zone_id(tzid))
}
///|
/// Parse an iCalendar UTC-offset value: `+0800`, `-0500`, `+0530`, or a bare
/// `+08`. Raises [`ParseError`] on anything else, because a half-read offset
/// silently shifts every event in a calendar.
///
/// # Example
/// ```mbt nocheck
/// fn test_example() raise {
/// assert_eq(
/// @model.parse_utc_offset("+0530", line_no=1, raw="+0530"),
/// 5 * 3600 + 30 * 60,
/// )
/// assert_eq(
/// @model.parse_utc_offset("-0800", line_no=1, raw="-0800"),
/// -(8 * 3600),
/// )
/// }
/// ```
pub fn parse_utc_offset(
text : String,
line_no~ : Int,
raw~ : String,
) -> Int raise {
let v = text.view()
let n = v.length()
if n < 3 {
raise @text.ParseError::BadLine(
line_no~,
line=raw,
message="UTC offset too short: \{text}",
)
}
let sign = if v[0] == '-' {
-1
} else if v[0] == '+' {
1
} else {
raise @text.ParseError::BadLine(
line_no~,
line=raw,
message="UTC offset needs a sign: \{text}",
)
}
let hours = digits(v, 1, 2)
let minutes = if n == 5 { digits(v, 3, 2) } else { Some(0) }
match (hours, minutes) {
(Some(h), Some(m)) => sign * (h * 3600 + m * 60)
_ =>
raise @text.ParseError::BadLine(
line_no~,
line=raw,
message="malformed UTC offset: \{text}",
)
}
}
///|
/// Collect `TZID -> TZOFFSETTO` from a feed's own `VTIMEZONE` components, so a
/// private zone name (`/google.com/ntp/Asia/Shanghai`, `China Standard Time`)
/// resolves without an IANA database.
///
/// Only the `STANDARD` sub-component is read, and an offset that will not
/// parse is skipped rather than aborting the whole feed: one broken VTIMEZONE
/// should not hide the events that reference it.
///
/// # Example
/// ```mbt nocheck
/// fn test_example() raise {
/// let roots = @model.parse_components([
/// "BEGIN:VCALENDAR", "BEGIN:VTIMEZONE", "TZID:MY-ZONE", "BEGIN:STANDARD", "TZOFFSETTO:+0930",
/// "END:STANDARD", "END:VTIMEZONE", "END:VCALENDAR",
/// ])
/// let table = @model.build_zone_table(roots)
/// assert_eq(table.lookup("my-zone"), Some(9 * 3600 + 30 * 60))
/// }
/// ```
pub fn build_zone_table(roots : Array[Component]) -> ZoneTable {
let table = ZoneTable::empty()
for root in roots {
for child in root.children {
if child.kind == "VTIMEZONE" {
register_vtimezone(table, child)
}
}
}
table
}
///|
fn register_vtimezone(table : ZoneTable, vtz : Component) -> Unit {
match vtz.property("TZID") {
None => ()
Some(tzid) =>
for child in vtz.children {
if child.kind == "STANDARD" {
match child.property("TZOFFSETTO") {
None => ()
Some(offset_line) => {
let value = offset_line.value
let parsed = Some(parse_utc_offset(value, line_no=0, raw=value)) catch {
_ => None
}
match parsed {
Some(seconds) => table.insert(tzid.value, seconds)
None => ()
}
}
}
}
}
}
}