///|
/// 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, Int32]
} 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, Int32] = {
"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 : Int32, minutes : Int32) -> Int32 {
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 : Int32,
) -> 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) -> Int32? {
self.entries.get(normalize_zone_id(tzid))
}
///|
/// Parse an iCalendar UTC-offset value (RFC 5545 §3.3.14): `+0800`,
/// `-0500`, `+0530`, `+053030`, or a bare `+08` (a deliberate lenience —
/// the RFC grammar requires the minutes). Raises [`ParseError`] on
/// anything else: an offset whose shape or components are not read whole
/// would silently shift every event in a calendar, which is how the
/// pre-S12 half-read of `+05:30` lost half an hour.
///
/// # 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~ : Int32,
raw~ : String,
) -> Int32 raise {
let v = text.view()
let n = v.length()
// Sign, two-digit hour, optional two-digit minute, optional two-digit
// second — and nothing else. The exact length is the whole check: a
// colon form or a stray tail used to be read by its first five
// characters with the rest silently dropped.
if n != 3 && n != 5 && n != 7 {
raise @text.ParseError::BadLine(
line_no~,
line=raw,
message="UTC offset must be +hh, +hhmm, or +hhmmss: \{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) }
let seconds = if n == 7 { digits(v, 5, 2) } else { Some(0) }
match (hours, minutes, seconds) {
(Some(h), Some(m), Some(s)) =>
// RFC 5545 §3.3.14 bounds every component: hour at 23, minute and
// second at 59. An hour past 23 is not an offset any zone declares;
// accepting it would shift a whole calendar by imaginary hours.
if h > 23 || m > 59 || s > 59 {
raise @text.ParseError::BadLine(
line_no~,
line=raw,
message="UTC offset component out of range: \{text}",
)
} else {
sign * (h * 3600 + m * 60 + s)
}
_ =>
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 => ()
}
}
}
}
}
}
}