diff --git a/vlib/encoding/cbor/generic.v b/vlib/encoding/cbor/generic.v index 78e7c6c1288078..ed7368ddc3832c 100644 --- a/vlib/encoding/cbor/generic.v +++ b/vlib/encoding/cbor/generic.v @@ -587,7 +587,7 @@ fn (mut u Unpacker) unpack_into[T](mut out T) ! { // session is encoded as the correct instant rather than as wall-clock // digits without an offset. fn format_rfc3339_nano(t time.Time) string { - utc := if t.is_local { t.local_to_utc() } else { t } + utc := if t.is_local || t.location() != none { t.local_to_utc() } else { t } return '${utc.year:04d}-${utc.month:02d}-${utc.day:02d}T${utc.hour:02d}:${utc.minute:02d}:${utc.second:02d}.${utc.nanosecond:09d}Z' } diff --git a/vlib/encoding/cbor/tests/time_test.v b/vlib/encoding/cbor/tests/time_test.v index 96240e3d67b775..45920e2ef05c57 100644 --- a/vlib/encoding/cbor/tests/time_test.v +++ b/vlib/encoding/cbor/tests/time_test.v @@ -5,6 +5,7 @@ module main import encoding.cbor import encoding.hex +import os import time fn h(s string) []u8 { @@ -37,3 +38,28 @@ fn test_time_decode_tag1_float() { // Sub-second component is non-zero (~500ms). assert got.nanosecond > 0 } + +fn test_time_encode_tag0_zoned_subsecond_uses_utc_instant() { + old_zoneinfo := os.getenv_opt('ZONEINFO') + os.setenv('ZONEINFO', os.join_path(@VEXEROOT, 'vlib', 'time', 'tzdata', 'zoneinfo.zip'), true) + defer { + if old := old_zoneinfo { + os.setenv('ZONEINFO', old, true) + } else { + os.unsetenv('ZONEINFO') + } + } + loc := time.load_location('Asia/Shanghai')! + t := time.unix_nanosecond(1_704_067_200, 1).in(loc)! + bytes := cbor.encode[time.Time](t, cbor.EncodeOpts{})! + v := cbor.decode[cbor.Value](bytes, cbor.DecodeOpts{})! + assert v is cbor.Tag + if v is cbor.Tag { + assert v.number == 0 + content := v.content() + assert content is cbor.Text + if content is cbor.Text { + assert content.value == '2024-01-01T00:00:00.000000001Z' + } + } +} diff --git a/vlib/time/README.md b/vlib/time/README.md index 39a5ea9efa2d8f..03a072e132d6b3 100644 --- a/vlib/time/README.md +++ b/vlib/time/README.md @@ -69,6 +69,39 @@ Omitted `month` and `day` values default to `1`, and out-of-range values panic. Use `t.is_zero()` to check whether a `time.Time` is still its zero value before formatting or serializing it. +IANA time zone data can be loaded by name and used to convert Unix timestamps +or UTC `Time` values to that location's calendar time. `load_location` searches +`ZONEINFO`, system zoneinfo paths, and V's installed `zoneinfo.zip`. Programs +that need embedded time zone data (no system zoneinfo, portable binaries) can +import `time.tzdata`. + +```v +import time +import time.tzdata as _ + +shanghai := time.load_location('Asia/Shanghai')! +local := time.unix(1_704_067_200).in(shanghai)! +assert local.format_ss() == '2024-01-01 08:00:00' +assert local.unix() == 1_704_067_200 +assert (local.zone()!).offset == 28_800 +``` + +IANA-zoned values keep `unix` as the absolute UTC epoch instant. Calendar +fields (`year`, `hour`, ...) are wall time in that location. Prefer +`t.location()` / `t.zone()` over the older `is_local` flag: `is_local` only +marks system-local wall time with a fixed process offset and is left `false` +on IANA-zoned `Time` values. + +The bundled `vlib/time/tzdata/zoneinfo.zip` is a store-only (uncompressed) zip +of IANA zoneinfo files for offline use via `import time.tzdata`. Refresh it +from a full system zoneinfo tree when updating tzdata. On macOS, prefer +`/usr/share/zoneinfo.default` over `/usr/share/zoneinfo`, because the latter may +contain truncated files with fixed POSIX tails. For example: + +```sh +cd /usr/share/zoneinfo.default && zip -0 -r /path/to/v/vlib/time/tzdata/zoneinfo.zip . +``` + Another very useful feature of the `time` module is the stop watch, for when you want to measure short time periods, elapsed while you executed other tasks. [See](https://play.vlang.io/?query=f6c008bc34): diff --git a/vlib/time/format.v b/vlib/time/format.v index 39ce7907013d9d..841beea032ba94 100644 --- a/vlib/time/format.v +++ b/vlib/time/format.v @@ -184,7 +184,7 @@ pub fn (t Time) format_rfc3339() string { } t_ := time_with_unix(t) - if t_.is_local { + if t_.is_local || t_.location() != none { utc_time := t_.local_to_utc() int_to_byte_array_no_pad(utc_time.year, mut buf, 4) int_to_byte_array_no_pad(utc_time.month, mut buf, 7) @@ -217,7 +217,7 @@ pub fn (t Time) format_rfc3339_micro() string { } t_ := time_with_unix(t) - if t_.is_local { + if t_.is_local || t_.location() != none { utc_time := t_.local_to_utc() int_to_byte_array_no_pad(utc_time.year, mut buf, 4) int_to_byte_array_no_pad(utc_time.month, mut buf, 7) @@ -250,7 +250,7 @@ pub fn (t Time) format_rfc3339_nano() string { } t_ := time_with_unix(t) - if t_.is_local { + if t_.is_local || t_.location() != none { utc_time := t_.local_to_utc() int_to_byte_array_no_pad(utc_time.year, mut buf, 4) int_to_byte_array_no_pad(utc_time.month, mut buf, 7) @@ -720,9 +720,10 @@ pub fn (t Time) get_fmt_str(fmt_dlmtr FormatDelimiter, fmt_time FormatTime, fmt_ @[deprecated: 'use `http_header_string()` instead'] @[deprecated_after: '2026-09-30'] pub fn (t Time) utc_string() string { - day_str := t.weekday_str() - month_str := t.smonth() - utc_string := '${day_str}, ${t.day} ${month_str} ${t.year} ${t.hour:02d}:${t.minute:02d}:${t.second:02d} UTC' + t_ := if t.location() != none { t.local_to_utc() } else { t } + day_str := t_.weekday_str() + month_str := t_.smonth() + utc_string := '${day_str}, ${t_.day} ${month_str} ${t_.year} ${t_.hour:02d}:${t_.minute:02d}:${t_.second:02d} UTC' return utc_string } @@ -750,10 +751,15 @@ pub fn (t Time) write_http_header(dst &u8, dst_len int) ! { if dst_len < http_date_len { return error('time.write_http_header: dst_len must be >= 29') } - day_str := long_days[iclamp(0, t.day_of_week() - 1, 6)] // read in place: no substr + if t.location() != none { + unsafe { t.local_to_utc().write_http_header(dst, dst_len)! } + return + } + t_ := t + day_str := long_days[iclamp(0, t_.day_of_week() - 1, 6)] // read in place: no substr // months_string is indexed in place (no smonth() substr allocation); out-of-range // months keep smonth()'s historical '---' fallback. - mi := if t.month >= 1 && t.month <= 12 { (t.month - 1) * 3 } else { -1 } + mi := if t_.month >= 1 && t_.month <= 12 { (t_.month - 1) * 3 } else { -1 } m0 := if mi >= 0 { months_string[mi] } else { `-` } m1 := if mi >= 0 { months_string[mi + 1] } else { `-` } m2 := if mi >= 0 { months_string[mi + 2] } else { `-` } @@ -761,11 +767,11 @@ pub fn (t Time) write_http_header(dst &u8, dst_len int) ! { mut buf := [day_str[0], day_str[1], day_str[2], `,`, ` `, `0`, `0`, ` `, m0, m1, m2, ` `, `0`, `0`, `0`, `0`, ` `, `0`, `0`, `:`, `0`, `0`, `:`, `0`, `0`, ` `, `G`, `M`, `T`]! unsafe { - int_to_ptr_byte_array_no_pad(t.day, &buf[5], 2) - int_to_ptr_byte_array_no_pad(t.year, &buf[12], 4) - int_to_ptr_byte_array_no_pad(t.hour, &buf[17], 2) - int_to_ptr_byte_array_no_pad(t.minute, &buf[20], 2) - int_to_ptr_byte_array_no_pad(t.second, &buf[23], 2) + int_to_ptr_byte_array_no_pad(t_.day, &buf[5], 2) + int_to_ptr_byte_array_no_pad(t_.year, &buf[12], 4) + int_to_ptr_byte_array_no_pad(t_.hour, &buf[17], 2) + int_to_ptr_byte_array_no_pad(t_.minute, &buf[20], 2) + int_to_ptr_byte_array_no_pad(t_.second, &buf[23], 2) // plain byte loop instead of vmemcpy: compiles on every backend (JS has // no vmemcpy) and C compilers turn it into a memcpy at -prod anyway for i in 0 .. 29 { diff --git a/vlib/time/operator.v b/vlib/time/operator.v index 66dcdb0bd4cbf9..01a4a0e816e582 100644 --- a/vlib/time/operator.v +++ b/vlib/time/operator.v @@ -3,6 +3,12 @@ module time // operator `==` returns true if provided time is equal to time @[inline] pub fn (t1 Time) == (t2 Time) bool { + if _ := t1.loc { + return t1.nanosecond == t2.nanosecond && t1.unix() == t2.unix() + } + if _ := t2.loc { + return t1.nanosecond == t2.nanosecond && t1.unix() == t2.unix() + } return t1.nanosecond == t2.nanosecond && t1.is_local == t2.is_local && t1.local_unix() == t2.local_unix() } diff --git a/vlib/time/time.c.v b/vlib/time/time.c.v index 883eb813d9d4c5..9040905e83dde4 100644 --- a/vlib/time/time.c.v +++ b/vlib/time/time.c.v @@ -3,6 +3,8 @@ // that can be found in the LICENSE file. module time +import strings + #include // C.timeval represents a C time value. @@ -57,6 +59,9 @@ pub fn utc() Time { } fn time_with_unix(t Time) Time { + if _ := t.loc { + return t + } if t.unix != 0 { return t } @@ -137,16 +142,73 @@ fn convert_ctime(t C.tm, nanosecond int) Time { // strftime returns the formatted time using `strftime(3)`. pub fn (t Time) strftime(fmt string) string { + mut strftime_fmt := fmt + mut strftime_unix := t.unix + mut zone_name := '' + mut zone_offset := '' + mut unix_time := '' + if loc := t.location() { + zone := loc.zone_at(t.unix) or { Zone{} } + zone_name = zone.name + zone_offset = strftime_zone_offset(zone.offset) + unix_time = t.unix.str() + strftime_fmt = strftime_location_format(fmt) + strftime_unix = t.local_unix() + } mut tm := &C.tm{} $if windows { - tm = C.gmtime(voidptr(&t.unix)) + tm = C.gmtime(voidptr(&strftime_unix)) } $else { - C.gmtime_r(voidptr(&t.unix), tm) + C.gmtime_r(voidptr(&strftime_unix), tm) } mut buf := [1024]char{} - fmt_c := unsafe { &char(fmt.str) } + fmt_c := unsafe { &char(strftime_fmt.str) } C.strftime(&buf[0], usize(sizeof(buf)), fmt_c, tm) - return unsafe { cstring_to_vstring(&buf[0]) } + res := unsafe { cstring_to_vstring(&buf[0]) } + if zone_name != '' || zone_offset != '' || unix_time != '' { + return res.replace(strftime_zone_name_placeholder, zone_name).replace(strftime_zone_offset_placeholder, + zone_offset).replace(strftime_unix_placeholder, unix_time) + } + return res +} + +const strftime_zone_name_placeholder = '@V_STRFTIME_ZONE_NAME@' +const strftime_zone_offset_placeholder = '@V_STRFTIME_ZONE_OFFSET@' +const strftime_unix_placeholder = '@V_STRFTIME_UNIX@' + +fn strftime_location_format(fmt string) string { + mut out := strings.new_builder(fmt.len) + for i := 0; i < fmt.len; i++ { + if fmt[i] != `%` || i + 1 >= fmt.len { + out.write_u8(fmt[i]) + continue + } + i++ + match fmt[i] { + `Z` { + out.write_string(strftime_zone_name_placeholder) + } + `z` { + out.write_string(strftime_zone_offset_placeholder) + } + `s` { + out.write_string(strftime_unix_placeholder) + } + else { + out.write_u8(`%`) + out.write_u8(fmt[i]) + } + } + } + return out.str() +} + +fn strftime_zone_offset(offset int) string { + sign := if offset < 0 { '-' } else { '+' } + abs_offset := if offset < 0 { -offset } else { offset } + hours := abs_offset / seconds_per_hour + minutes := (abs_offset % seconds_per_hour) / seconds_per_minute + return '${sign}${hours:02}${minutes:02}' } // some *nix system functions (e.g. `C.poll()`, C.epoll_wait()) accept an `int` diff --git a/vlib/time/time.js.v b/vlib/time/time.js.v index 88f32230f14b3f..0f2001f5e0a530 100644 --- a/vlib/time/time.js.v +++ b/vlib/time/time.js.v @@ -47,6 +47,9 @@ pub fn sleep(dur Duration) { } fn time_with_unix(t Time) Time { + if _ := t.loc { + return t + } if t.unix != 0 { return t } diff --git a/vlib/time/time.v b/vlib/time/time.v index df7d536119bab0..32b2873db2193f 100644 --- a/vlib/time/time.v +++ b/vlib/time/time.v @@ -38,6 +38,7 @@ pub const days_before = [ @[markused] pub struct Time { unix i64 + loc ?Location pub: year int month int @@ -143,12 +144,29 @@ pub fn (t Time) smonth() string { // unix returns the UNIX time with second resolution. @[inline] pub fn (t Time) unix() i64 { + if _ := t.loc { + return t.unix + } return time_with_unix(t.local_to_utc()).unix } // local_unix returns the UNIX local time with second resolution. +// For IANA locations this is the absolute unix instant plus the zone offset +// active at that instant (wall-clock seconds since epoch in that location). @[inline] pub fn (t Time) local_unix() i64 { + if loc := t.loc { + // zone_at only fails for empty/corrupt locations; use the first zone + // offset rather than silently treating the instant as UTC. + zone_offset := loc.offset_at(t.unix) or { + if loc.zones.len > 0 { + loc.zones[0].offset + } else { + 0 + } + } + return t.unix + i64(zone_offset) + } return time_with_unix(t).unix } @@ -185,7 +203,8 @@ pub fn (t Time) add(duration_in_nanosecond Duration) Time { // ... so instead, handle the addition manually in parts ¯\_(ツ)_/¯ mut increased_time_nanosecond := i64(t.nanosecond) + duration_in_nanosecond.nanoseconds() // increased_time_second - mut increased_time_second := t.local_unix() + (increased_time_nanosecond / second) + base_unix := if _ := t.loc { t.unix } else { t.local_unix() } + mut increased_time_second := base_unix + (increased_time_nanosecond / second) increased_time_nanosecond = increased_time_nanosecond % second if increased_time_nanosecond < 0 { increased_time_second-- @@ -193,6 +212,18 @@ pub fn (t Time) add(duration_in_nanosecond Duration) Time { } res := unix_nanosecond(increased_time_second, int(increased_time_nanosecond)) + if loc := t.loc { + return loc.unix_nanosecond_to_local(res.unix, res.nanosecond) or { + // Keep the location even if projection fails so later operations + // still treat this value as an IANA-zoned instant. + Time{ + ...res + loc: loc + unix: res.unix + } + } + } + if t.is_local { // we need to reset unix to 0, because we don't know the offset // and we can't calculate it without it without causing infinite recursion @@ -441,6 +472,9 @@ pub fn offset() int { // local_to_utc converts the receiver `t` to the corresponding UTC time, if it contains local time. // If the receiver already does contain UTC time, it returns it unchanged. pub fn (t Time) local_to_utc() Time { + if _ := t.loc { + return unix_nanosecond(t.unix, t.nanosecond) + } if !t.is_local { return t } @@ -453,6 +487,9 @@ pub fn (t Time) local_to_utc() Time { // utc_to_local converts the receiver `u` to the corresponding local time, if it contains UTC time. // If the receiver already does contain local time, it returns it unchanged. pub fn (u Time) utc_to_local() Time { + if _ := u.loc { + return u + } if u.is_local { return u } @@ -483,5 +520,5 @@ pub fn (t Time) as_utc() Time { // is_utc returns true, when the receiver `t` is a UTC time, and false otherwise. // See also #Time.utc_to_local . pub fn (t Time) is_utc() bool { - return !t.is_local + return !t.is_local && t.location() == none } diff --git a/vlib/time/time_windows.c.v b/vlib/time/time_windows.c.v index 8048c7b62dc2e7..111aab8ac3b0d0 100644 --- a/vlib/time/time_windows.c.v +++ b/vlib/time/time_windows.c.v @@ -37,7 +37,7 @@ fn C.GetSystemTimeAsFileTime(lpSystemTimeAsFileTime &C._FILETIME) fn C.FileTimeToSystemTime(lpFileTime &C._FILETIME, lpSystemTime &SystemTime) -fn C.SystemTimeToTzSpecificLocalTime(lpTimeZoneInformation &C.TIME_ZONE_INFORMATION, lpUniversalTime &SystemTime, +fn C.SystemTimeToTzSpecificLocalTime(lpTimeZoneInformation voidptr, lpUniversalTime &SystemTime, lpLocalTime &SystemTime) fn C.localtime_s(t &C.time_t, tm &C.tm) diff --git a/vlib/time/tzdata/tzdata.c.v b/vlib/time/tzdata/tzdata.c.v new file mode 100644 index 00000000000000..865e9ba335f396 --- /dev/null +++ b/vlib/time/tzdata/tzdata.c.v @@ -0,0 +1,83 @@ +module tzdata + +import time + +const embedded_zoneinfo = $embed_file('zoneinfo.zip') + +fn init() { + time.register_zoneinfo_loader(load_from_embedded_zoneinfo) +} + +fn load_from_embedded_zoneinfo(name string) ![]u8 { + return read_uncompressed_zip_entry(embedded_zoneinfo.to_bytes(), name) +} + +fn read_uncompressed_zip_entry(data []u8, name string) ![]u8 { + eocd := find_zip_end_of_central_directory(data)! + entries := read_zip_u16(data, eocd + 10) + mut pos := int(read_zip_u32(data, eocd + 16)) + for _ in 0 .. entries { + if pos + 46 > data.len || read_zip_u32(data, pos) != 0x0201_4b50 { + return error('invalid embedded zoneinfo.zip') + } + method := read_zip_u16(data, pos + 10) + size := int(read_zip_u32(data, pos + 24)) + name_len := int(read_zip_u16(data, pos + 28)) + extra_len := int(read_zip_u16(data, pos + 30)) + comment_len := int(read_zip_u16(data, pos + 32)) + local_header := int(read_zip_u32(data, pos + 42)) + if pos + 46 + name_len > data.len { + return error('invalid embedded zoneinfo.zip') + } + entry_name := data[pos + 46..pos + 46 + name_len].bytestr() + pos += 46 + name_len + extra_len + comment_len + if entry_name != name { + continue + } + if method != 0 { + return error('unsupported compressed time zone entry "${name}"') + } + return read_zip_file_data(data, local_header, name, size) + } + return error('unknown time zone location "${name}"') +} + +fn read_zip_file_data(data []u8, pos int, name string, size int) ![]u8 { + if pos + 30 > data.len || read_zip_u32(data, pos) != 0x0403_4b50 { + return error('invalid embedded time zone entry "${name}"') + } + name_len := int(read_zip_u16(data, pos + 26)) + extra_len := int(read_zip_u16(data, pos + 28)) + data_start := pos + 30 + name_len + extra_len + data_end := data_start + size + if data_end > data.len { + return error('truncated embedded time zone entry "${name}"') + } + return data[data_start..data_end].clone() +} + +fn find_zip_end_of_central_directory(data []u8) !int { + max_comment_len := 65_535 + min_pos := if data.len > max_comment_len + 22 { data.len - max_comment_len - 22 } else { 0 } + for pos := data.len - 22; pos >= min_pos; pos-- { + if read_zip_u32(data, pos) == 0x0605_4b50 { + return pos + } + } + return error('invalid embedded zoneinfo.zip') +} + +fn read_zip_u16(data []u8, offset int) int { + if offset + 2 > data.len { + return 0 + } + return int(u16(data[offset]) | (u16(data[offset + 1]) << 8)) +} + +fn read_zip_u32(data []u8, offset int) u32 { + if offset + 4 > data.len { + return 0 + } + return u32(data[offset]) | (u32(data[offset + 1]) << 8) | (u32(data[offset + 2]) << 16) | (u32(data[ + offset + 3]) << 24) +} diff --git a/vlib/time/tzdata/tzdata.js.v b/vlib/time/tzdata/tzdata.js.v new file mode 100644 index 00000000000000..06c2cad5077259 --- /dev/null +++ b/vlib/time/tzdata/tzdata.js.v @@ -0,0 +1,4 @@ +module tzdata + +fn init() { +} diff --git a/vlib/time/tzdata/tzdata.wasm.v b/vlib/time/tzdata/tzdata.wasm.v new file mode 100644 index 00000000000000..06c2cad5077259 --- /dev/null +++ b/vlib/time/tzdata/tzdata.wasm.v @@ -0,0 +1,4 @@ +module tzdata + +fn init() { +} diff --git a/vlib/time/tzdata/tzdata_test.v b/vlib/time/tzdata/tzdata_test.v new file mode 100644 index 00000000000000..e4b36c61da8f74 --- /dev/null +++ b/vlib/time/tzdata/tzdata_test.v @@ -0,0 +1,11 @@ +import time +import time.tzdata as _ + +fn test_import_tzdata_registers_embedded_loader() { + loc := time.load_location('Asia/Shanghai')! + t := loc.unix_to_local(1_704_067_200)! + assert t.year == 2024 + assert t.month == 1 + assert t.day == 1 + assert t.hour == 8 +} diff --git a/vlib/time/tzdata/zoneinfo.zip b/vlib/time/tzdata/zoneinfo.zip new file mode 100644 index 00000000000000..c7246c5ff09329 Binary files /dev/null and b/vlib/time/tzdata/zoneinfo.zip differ diff --git a/vlib/time/zoneinfo.c.v b/vlib/time/zoneinfo.c.v new file mode 100644 index 00000000000000..1a9b58f1dcda33 --- /dev/null +++ b/vlib/time/zoneinfo.c.v @@ -0,0 +1,687 @@ +@[has_globals] +module time + +import compress.szip +import os + +const zoneinfo_unix_sources = [ + '/usr/share/zoneinfo', + '/usr/share/lib/zoneinfo', + '/usr/lib/locale/TZ', + '/etc/zoneinfo', +]! + +const zoneinfo_vroot_zip = os.join_path(@VEXEROOT, 'vlib', 'time', 'tzdata', 'zoneinfo.zip') + +pub type ZoneinfoLoaderFn = fn (name string) ![]u8 + +__global zoneinfo_loaders = []ZoneinfoLoaderFn{} + +// register_zoneinfo_loader registers a fallback loader for IANA time zone data. +// Registered loaders are used after ZONEINFO, system zoneinfo paths, and V's +// installed zoneinfo.zip have been tried. +pub fn register_zoneinfo_loader(loader ZoneinfoLoaderFn) { + zoneinfo_loaders << loader +} + +// Zone describes one time zone rule in an IANA location. +pub struct Zone { +pub: + name string + offset int + is_dst bool +} + +struct ZoneTransition { + when i64 + index int +} + +// Location contains parsed IANA time zone data. +// +// A `Time` with a non-none `location()` carries IANA zone rules. Prefer that +// over the older `is_local` flag, which only means "system local wall time +// with a fixed process offset" and does not model DST transitions by name. +pub struct Location { +pub: + name string +mut: + zones []Zone + transitions []ZoneTransition + posix PosixZoneRule + has_posix bool +} + +struct PosixZoneRule { + std_name string + std_offset int + dst_name string + dst_offset int + start PosixRule + end PosixRule + has_dst bool +} + +enum PosixRuleKind { + month_week_day + julian_no_leap + day_of_year +} + +struct PosixRule { + kind PosixRuleKind + month int + week int + weekday int + day int + seconds int +} + +// load_location loads an IANA time zone location from ZONEINFO, system zoneinfo +// paths, V's installed zoneinfo.zip, or registered fallback loaders. +pub fn load_location(name string) !&Location { + if name == '' || name.contains('..') || name.starts_with('/') || name.starts_with('\\') { + return error('invalid time zone location "${name}"') + } + if name == 'Local' { + return local_location() + } + if name in ['UTC', 'Etc/UTC', 'Etc/GMT', 'GMT'] { + return &Location{ + name: name + zones: [Zone{ + name: 'UTC' + offset: 0 + }] + } + } + data := load_zoneinfo_data(name)! + return parse_tzif_location(name, data)! +} + +// zone_at returns the active zone rule for `unix_time`. +pub fn (loc &Location) zone_at(unix_time i64) !Zone { + if loc.zones.len == 0 { + return error('time zone location "${loc.name}" has no zone rules') + } + if loc.has_posix + && (loc.transitions.len == 0 || unix_time >= loc.transitions[loc.transitions.len - 1].when) { + return loc.posix.zone_at(unix_time) + } + if loc.transitions.len == 0 { + return loc.zones[0] + } + if unix_time < loc.transitions[0].when { + return loc.zones[loc.first_standard_zone_index()] + } + mut lo := 0 + mut hi := loc.transitions.len + for lo < hi { + mid := lo + (hi - lo) / 2 + if loc.transitions[mid].when <= unix_time { + lo = mid + 1 + } else { + hi = mid + } + } + tx := loc.transitions[lo - 1] + if tx.index < 0 || tx.index >= loc.zones.len { + return error('time zone location "${loc.name}" has an invalid transition') + } + return loc.zones[tx.index] +} + +// offset_at returns the UTC offset in seconds for `unix_time` in the location. +pub fn (loc &Location) offset_at(unix_time i64) !int { + return (loc.zone_at(unix_time)!).offset +} + +// unix_to_local resolves a Unix timestamp to a Time in the location. +pub fn (loc &Location) unix_to_local(unix_time i64) !Time { + return loc.unix_nanosecond_to_local(unix_time, 0) +} + +// unix_nanosecond_to_local resolves a Unix timestamp and nanosecond to a Time +// in the location. +// The returned `Time` keeps `unix` as the absolute UTC epoch instant; the +// calendar fields are the wall time in `loc`. `is_local` stays false. +pub fn (loc &Location) unix_nanosecond_to_local(unix_time i64, nanosecond int) !Time { + zone := loc.zone_at(unix_time)! + local := unix_nanosecond(unix_time + i64(zone.offset), nanosecond) + return Time{ + loc: *loc + unix: unix_time + year: local.year + month: local.month + day: local.day + hour: local.hour + minute: local.minute + second: local.second + nanosecond: nanosecond + } +} + +// in resolves `t` as a Time in `loc`. +pub fn (t Time) in(loc &Location) !Time { + return loc.unix_nanosecond_to_local(t.unix(), t.nanosecond) +} + +// location returns the IANA location associated with `t`, if any. +pub fn (t Time) location() ?Location { + return t.loc +} + +// zone returns the active zone rule for `t`, if it has an IANA location. +pub fn (t Time) zone() !Zone { + loc := t.loc or { return error('time has no IANA location') } + return loc.zone_at(t.unix()) +} + +fn (loc &Location) first_standard_zone_index() int { + for i, zone in loc.zones { + if !zone.is_dst { + return i + } + } + return 0 +} + +fn load_zoneinfo_data(name string) ![]u8 { + zoneinfo := os.getenv('ZONEINFO') + if zoneinfo != '' { + if data := load_zoneinfo_from_source(zoneinfo, name) { + return data + } + } + for source in platform_zoneinfo_sources() { + if data := load_zoneinfo_from_source(source, name) { + return data + } + } + for loader in zoneinfo_loaders { + if data := loader(name) { + return data + } + } + return error('unknown time zone location "${name}"') +} + +fn platform_zoneinfo_sources() []string { + mut sources := []string{} + $if !windows { + for source in zoneinfo_unix_sources { + sources << source + } + } + sources << zoneinfo_vroot_zip + return sources +} + +fn load_zoneinfo_from_source(source string, name string) ![]u8 { + if os.is_dir(source) { + return os.read_bytes(os.join_path(source, name)) + } + if os.is_file(source) { + return read_zoneinfo_zip_entry(source, name) + } + return error('time zone source "${source}" does not exist') +} + +fn read_zoneinfo_zip_entry(zip_path string, name string) ![]u8 { + mut zip := szip.open(zip_path, .no_compression, .read_only)! + defer { + zip.close() + } + // miniz open_entry can succeed with size 0 for missing names; treat that + // as unknown so callers keep searching other sources. + zip.open_entry(name) or { return error('unknown time zone location "${name}"') } + defer { + zip.close_entry() + } + size := int(zip.size()) + if size <= 0 { + return error('unknown time zone location "${name}"') + } + mut data := []u8{len: size} + zip.read_entry_buf(data.data, size)! + return data +} + +fn parse_tzif_location(name string, data []u8) !&Location { + mut pos := 0 + header := read_tzif_header(data, pos)! + pos += 44 + if header.version == `2` || header.version == `3` || header.version == `4` { + size := tzif_data_size(header, 4)! + if pos + size > data.len { + return error('truncated TZif data for "${name}"') + } + pos += size + header2 := read_tzif_header(data, pos)! + pos += 44 + return parse_tzif_data(name, data, pos, header2, 8)! + } + return parse_tzif_data(name, data, pos, header, 4)! +} + +struct TzifHeader { + version u8 + ttisgmt int + ttisstd int + leap int + time int + typ int + character int +} + +fn read_tzif_header(data []u8, offset int) !TzifHeader { + if offset + 44 > data.len || data[offset..offset + 4].bytestr() != 'TZif' { + return error('invalid TZif data') + } + header := TzifHeader{ + version: data[offset + 4] + ttisgmt: read_be_i32(data, offset + 20) + ttisstd: read_be_i32(data, offset + 24) + leap: read_be_i32(data, offset + 28) + time: read_be_i32(data, offset + 32) + typ: read_be_i32(data, offset + 36) + character: read_be_i32(data, offset + 40) + } + if header.ttisgmt < 0 || header.ttisstd < 0 || header.leap < 0 || header.time < 0 + || header.typ < 0 || header.character < 0 { + return error('invalid TZif header counts') + } + return header +} + +fn tzif_data_size(header TzifHeader, time_size int) !int { + size := i64(header.time) * i64(time_size) + i64(header.time) + i64(header.typ) * 6 + + i64(header.character) + i64(header.leap) * i64(time_size + 4) + i64(header.ttisstd) + + i64(header.ttisgmt) + if size > i64(max_int) { + return error('TZif data is too large') + } + return int(size) +} + +fn parse_tzif_data(name string, data []u8, start int, header TzifHeader, time_size int) !&Location { + if header.typ <= 0 { + return error('time zone location "${name}" has no zone rules') + } + size := tzif_data_size(header, time_size)! + if start + size > data.len { + return error('truncated TZif data for "${name}"') + } + mut pos := start + mut times := []i64{len: header.time} + for i in 0 .. header.time { + times[i] = if time_size == 8 { + read_be_i64(data, pos) + } else { + i64(read_be_i32(data, pos)) + } + pos += time_size + } + mut indices := []int{len: header.time} + for i in 0 .. header.time { + indices[i] = int(data[pos]) + pos++ + } + mut zone_offsets := []int{len: header.typ} + mut zone_is_dst := []bool{len: header.typ} + mut zone_abbr_indices := []int{len: header.typ} + for i in 0 .. header.typ { + zone_offsets[i] = read_be_i32(data, pos) + zone_is_dst[i] = data[pos + 4] != 0 + zone_abbr_indices[i] = int(data[pos + 5]) + pos += 6 + } + abbreviations := data[pos..pos + header.character] + pos += header.character + mut zones := []Zone{cap: header.typ} + for i in 0 .. header.typ { + zones << Zone{ + name: tzif_abbreviation(abbreviations, zone_abbr_indices[i]) + offset: zone_offsets[i] + is_dst: zone_is_dst[i] + } + } + mut transitions := []ZoneTransition{cap: header.time} + for i in 0 .. header.time { + if indices[i] >= zones.len { + return error('invalid TZif transition in "${name}"') + } + transitions << ZoneTransition{ + when: times[i] + index: indices[i] + } + } + pos += header.leap * (time_size + 4) + header.ttisstd + header.ttisgmt + posix, has_posix := parse_posix_tail(data, pos) + return &Location{ + name: name + zones: zones + transitions: transitions + posix: posix + has_posix: has_posix + } +} + +fn parse_posix_tail(data []u8, start int) (PosixZoneRule, bool) { + if start >= data.len || data[start] != `\n` { + return PosixZoneRule{}, false + } + mut end := start + 1 + for end < data.len && data[end] != `\n` { + end++ + } + if end <= start + 1 { + return PosixZoneRule{}, false + } + rule := parse_posix_zone_rule(data[start + 1..end].bytestr()) or { + return PosixZoneRule{}, false + } + return rule, true +} + +fn parse_posix_zone_rule(text string) !PosixZoneRule { + std_name, mut pos := parse_posix_name(text, 0)! + std_offset, next_pos := parse_posix_offset(text, pos)! + pos = next_pos + if pos == text.len { + return PosixZoneRule{ + std_name: std_name + std_offset: std_offset + } + } + dst_name, dst_name_end := parse_posix_name(text, pos)! + pos = dst_name_end + mut dst_offset := std_offset + seconds_per_hour + if pos < text.len && text[pos] != `,` { + parsed_dst_offset, offset_end := parse_posix_offset(text, pos)! + dst_offset = parsed_dst_offset + pos = offset_end + } + if pos >= text.len || text[pos] != `,` { + return error('unsupported POSIX time zone rule "${text}"') + } + parts := text[pos + 1..].split(',') + if parts.len != 2 { + return error('unsupported POSIX time zone rule "${text}"') + } + return PosixZoneRule{ + std_name: std_name + std_offset: std_offset + dst_name: dst_name + dst_offset: dst_offset + start: parse_posix_rule(parts[0])! + end: parse_posix_rule(parts[1])! + has_dst: true + } +} + +fn parse_posix_name(text string, start int) !(string, int) { + if start >= text.len { + return error('missing POSIX time zone name') + } + if text[start] == `<` { + mut pos := start + 1 + for pos < text.len && text[pos] != `>` { + pos++ + } + if pos >= text.len { + return error('unterminated POSIX time zone name') + } + return text[start + 1..pos], pos + 1 + } + mut pos := start + for pos < text.len && ((text[pos] >= `A` && text[pos] <= `Z`) + || (text[pos] >= `a` && text[pos] <= `z`)) { + pos++ + } + if pos == start { + return error('missing POSIX time zone name') + } + return text[start..pos], pos +} + +fn parse_posix_offset(text string, start int) !(int, int) { + mut pos := start + mut sign := -1 + if pos < text.len && text[pos] == `-` { + sign = 1 + pos++ + } else if pos < text.len && text[pos] == `+` { + pos++ + } + hour_value, hour_end := parse_posix_number(text, pos)! + pos = hour_end + mut minute_value := 0 + mut second_value := 0 + if pos < text.len && text[pos] == `:` { + minute_value, pos = parse_posix_number(text, pos + 1)! + } + if pos < text.len && text[pos] == `:` { + second_value, pos = parse_posix_number(text, pos + 1)! + } + return sign * (hour_value * seconds_per_hour + minute_value * seconds_per_minute + second_value), pos +} + +fn parse_posix_rule(text string) !PosixRule { + rule_parts := text.split('/') + if rule_parts.len == 0 || rule_parts.len > 2 { + return error('unsupported POSIX time zone date rule "${text}"') + } + date := rule_parts[0] + seconds := if rule_parts.len > 1 { + parse_posix_time(rule_parts[1])! + } else { + 2 * seconds_per_hour + } + if date.starts_with('M') { + date_parts := date[1..].split('.') + if date_parts.len != 3 { + return error('unsupported POSIX time zone date rule "${text}"') + } + month := parse_posix_number_text(date_parts[0])! + week := parse_posix_number_text(date_parts[1])! + weekday := parse_posix_number_text(date_parts[2])! + if month < 1 || month > 12 || week < 1 || week > 5 || weekday < 0 || weekday > 6 { + return error('unsupported POSIX time zone date rule "${text}"') + } + return PosixRule{ + kind: .month_week_day + month: month + week: week + weekday: weekday + seconds: seconds + } + } + if date.starts_with('J') { + day := parse_posix_number_text(date[1..])! + if day < 1 || day > 365 { + return error('unsupported POSIX time zone date rule "${text}"') + } + return PosixRule{ + kind: .julian_no_leap + day: day + seconds: seconds + } + } + day := parse_posix_number_text(date)! + if day < 0 || day > 365 { + return error('unsupported POSIX time zone date rule "${text}"') + } + return PosixRule{ + kind: .day_of_year + day: day + seconds: seconds + } +} + +fn posix_rule_month_day(year int, rule PosixRule) (int, int) { + match rule.kind { + .month_week_day { + return rule.month, posix_month_week_day(year, rule) + } + .julian_no_leap { + mut ordinal := rule.day + if is_leap_year(year) && ordinal >= 60 { + ordinal++ + } + return month_day_from_year_day(year, ordinal) + } + .day_of_year { + return month_day_from_year_day(year, rule.day + 1) + } + } +} + +fn month_day_from_year_day(year int, ordinal int) (int, int) { + mut day := ordinal + for month in 1 .. 13 { + days := month_days[month - 1] + if month == 2 && is_leap_year(year) { 1 } else { 0 } + if day <= days { + return month, day + } + day -= days + } + return 12, 31 +} + +fn parse_posix_time(text string) !int { + if text == '' { + return error('unsupported POSIX time "${text}"') + } + mut pos := 0 + mut sign := 1 + if text[pos] == `-` { + sign = -1 + pos++ + } else if text[pos] == `+` { + pos++ + } + hour_value, hour_end := parse_posix_number(text, pos)! + pos = hour_end + mut minute_value := 0 + mut second_value := 0 + if pos < text.len && text[pos] == `:` { + minute_value, pos = parse_posix_number(text, pos + 1)! + } + if pos < text.len && text[pos] == `:` { + second_value, pos = parse_posix_number(text, pos + 1)! + } + if pos != text.len || minute_value > 59 || second_value > 59 { + return error('unsupported POSIX time "${text}"') + } + seconds := hour_value * seconds_per_hour + minute_value * seconds_per_minute + second_value + if seconds > 167 * seconds_per_hour { + return error('unsupported POSIX time "${text}"') + } + return sign * seconds +} + +fn parse_posix_number(text string, start int) !(int, int) { + mut pos := start + for pos < text.len && text[pos] >= `0` && text[pos] <= `9` { + pos++ + } + if pos == start { + return error('missing POSIX number') + } + return text[start..pos].int(), pos +} + +fn parse_posix_number_text(text string) !int { + value, pos := parse_posix_number(text, 0)! + if pos != text.len { + return error('missing POSIX number') + } + return value +} + +fn (rule PosixZoneRule) zone_at(unix_time i64) Zone { + if !rule.has_dst { + return Zone{ + name: rule.std_name + offset: rule.std_offset + } + } + year := unix(unix_time).year + start := rule.transition_utc(year, rule.start, rule.std_offset) + end := rule.transition_utc(year, rule.end, rule.dst_offset) + is_dst := if start <= end { + unix_time >= start && unix_time < end + } else { + unix_time >= start || unix_time < end + } + if is_dst { + return Zone{ + name: rule.dst_name + offset: rule.dst_offset + is_dst: true + } + } + return Zone{ + name: rule.std_name + offset: rule.std_offset + } +} + +fn (rule PosixZoneRule) transition_utc(year int, date_rule PosixRule, offset_before int) i64 { + month, day := posix_rule_month_day(year, date_rule) + local := time_fields_to_unix(Time{ + year: year + month: month + day: day + }) + return local + i64(date_rule.seconds) - i64(offset_before) +} + +fn posix_month_week_day(year int, rule PosixRule) int { + first_weekday := day_of_week(year, rule.month, 1) % 7 + mut day := 1 + ((rule.weekday - first_weekday + 7) % 7) + (rule.week - 1) * 7 + days := days_in_month(rule.month, year) or { 31 } + if rule.week == 5 { + for day + 7 <= days { + day += 7 + } + if day > days { + day -= 7 + } + } + return day +} + +fn tzif_abbreviation(data []u8, start int) string { + if start < 0 || start >= data.len { + return '' + } + mut end := start + for end < data.len && data[end] != 0 { + end++ + } + return data[start..end].bytestr() +} + +fn read_be_i32(data []u8, offset int) int { + mut value := u32(0) + for i in 0 .. 4 { + value = (value << 8) | u32(data[offset + i]) + } + if value & 0x8000_0000 != 0 { + return -int((~value) + 1) + } + return int(value) +} + +fn read_be_i64(data []u8, offset int) i64 { + mut value := u64(0) + for i in 0 .. 8 { + value = (value << 8) | u64(data[offset + i]) + } + if value & 0x8000_0000_0000_0000 != 0 { + return -i64((~value) + 1) + } + return i64(value) +} diff --git a/vlib/time/zoneinfo.js.v b/vlib/time/zoneinfo.js.v new file mode 100644 index 00000000000000..dbe4d9876072ec --- /dev/null +++ b/vlib/time/zoneinfo.js.v @@ -0,0 +1,64 @@ +module time + +pub type ZoneinfoLoaderFn = fn (name string) ![]u8 + +// register_zoneinfo_loader registers a fallback loader for IANA time zone data. +pub fn register_zoneinfo_loader(loader ZoneinfoLoaderFn) { +} + +// Zone describes one time zone rule in an IANA location. +pub struct Zone { +pub: + name string + offset int + is_dst bool +} + +// Location contains parsed IANA time zone data. +pub struct Location { +pub: + name string +mut: + zones []Zone +} + +// load_location loads an IANA time zone location. +pub fn load_location(name string) !&Location { + return error('IANA time zone locations are not supported by the JS backend') +} + +// zone_at returns the active zone rule for `unix_time`. +pub fn (loc &Location) zone_at(unix_time i64) !Zone { + return error('IANA time zone locations are not supported by the JS backend') +} + +// offset_at returns the UTC offset in seconds for `unix_time` in the location. +pub fn (loc &Location) offset_at(unix_time i64) !int { + return error('IANA time zone locations are not supported by the JS backend') +} + +// unix_to_local resolves a Unix timestamp to a Time in the location. +pub fn (loc &Location) unix_to_local(unix_time i64) !Time { + return error('IANA time zone locations are not supported by the JS backend') +} + +// unix_nanosecond_to_local resolves a Unix timestamp and nanosecond to a Time +// in the location. +pub fn (loc &Location) unix_nanosecond_to_local(unix_time i64, nanosecond int) !Time { + return error('IANA time zone locations are not supported by the JS backend') +} + +// in resolves `t` as a Time in `loc`. +pub fn (t Time) in(loc &Location) !Time { + return error('IANA time zone locations are not supported by the JS backend') +} + +// location returns the IANA location associated with `t`, if any. +pub fn (t Time) location() ?Location { + return t.loc +} + +// zone returns the active zone rule for `t`, if it has an IANA location. +pub fn (t Time) zone() !Zone { + return error('IANA time zone locations are not supported by the JS backend') +} diff --git a/vlib/time/zoneinfo.wasm.v b/vlib/time/zoneinfo.wasm.v new file mode 100644 index 00000000000000..89972431336fbb --- /dev/null +++ b/vlib/time/zoneinfo.wasm.v @@ -0,0 +1,64 @@ +module time + +pub type ZoneinfoLoaderFn = fn (name string) ![]u8 + +// register_zoneinfo_loader registers a fallback loader for IANA time zone data. +pub fn register_zoneinfo_loader(loader ZoneinfoLoaderFn) { +} + +// Zone describes one time zone rule in an IANA location. +pub struct Zone { +pub: + name string + offset int + is_dst bool +} + +// Location contains parsed IANA time zone data. +pub struct Location { +pub: + name string +mut: + zones []Zone +} + +// load_location loads an IANA time zone location. +pub fn load_location(name string) !&Location { + return error('IANA time zone locations are not supported by the WASM backend') +} + +// zone_at returns the active zone rule for `unix_time`. +pub fn (loc &Location) zone_at(unix_time i64) !Zone { + return error('IANA time zone locations are not supported by the WASM backend') +} + +// offset_at returns the UTC offset in seconds for `unix_time` in the location. +pub fn (loc &Location) offset_at(unix_time i64) !int { + return error('IANA time zone locations are not supported by the WASM backend') +} + +// unix_to_local resolves a Unix timestamp to a Time in the location. +pub fn (loc &Location) unix_to_local(unix_time i64) !Time { + return error('IANA time zone locations are not supported by the WASM backend') +} + +// unix_nanosecond_to_local resolves a Unix timestamp and nanosecond to a Time +// in the location. +pub fn (loc &Location) unix_nanosecond_to_local(unix_time i64, nanosecond int) !Time { + return error('IANA time zone locations are not supported by the WASM backend') +} + +// in resolves `t` as a Time in `loc`. +pub fn (t Time) in(loc &Location) !Time { + return error('IANA time zone locations are not supported by the WASM backend') +} + +// location returns the IANA location associated with `t`, if any. +pub fn (t Time) location() ?Location { + return t.loc +} + +// zone returns the active zone rule for `t`, if it has an IANA location. +pub fn (t Time) zone() !Zone { + return error('IANA time zone locations are not supported by the WASM backend') +} diff --git a/vlib/time/zoneinfo_internal_test.v b/vlib/time/zoneinfo_internal_test.v new file mode 100644 index 00000000000000..f0a7b3f8a09cfe --- /dev/null +++ b/vlib/time/zoneinfo_internal_test.v @@ -0,0 +1,89 @@ +module time + +fn test_parse_posix_tail_without_dst() { + rule := parse_posix_zone_rule('CST-8')! + zone := rule.zone_at(2_524_608_000) + assert zone.name == 'CST' + assert zone.offset == 28_800 + assert zone.is_dst == false +} + +fn test_parse_posix_tail_julian_and_day_of_year_rules() { + rule := parse_posix_zone_rule('<+00>0<+01>-1,0/0,J365/23')! + start_of_year := rule.zone_at(2_524_608_000) // 2050-01-01 00:00 UTC + end_of_year := rule.zone_at(2_556_057_600) // 2050-12-31 00:00 UTC + assert start_of_year.name == '+01' + assert start_of_year.offset == 3_600 + assert start_of_year.is_dst == true + assert end_of_year.name == '+01' + assert end_of_year.offset == 3_600 + assert end_of_year.is_dst == true +} + +fn test_posix_last_weekday_stays_in_month() { + rule := parse_posix_zone_rule('GMT0BST,M3.5.0/1,M10.5.0')! + after_transition := time_fields_to_unix(Time{ + year: 2038 + month: 3 + day: 28 + hour: 2 + }) + zone := rule.zone_at(after_transition) + assert zone.name == 'BST' + assert zone.offset == 3_600 + assert zone.is_dst == true +} + +fn test_parse_posix_rule_rejects_invalid_numbers() { + if _ := parse_posix_zone_rule('EST5EDT,Mx.2.0,M11.1.0') { + assert false + } else { + assert err.msg().contains('POSIX') + } + if _ := parse_posix_zone_rule('EST5EDT,M13.2.0,M11.1.0') { + assert false + } else { + assert err.msg().contains('POSIX') + } + if _ := parse_posix_zone_rule('EST5EDT,M3.2.0/2:99,M11.1.0') { + assert false + } else { + assert err.msg().contains('POSIX') + } +} + +fn test_parse_posix_negative_transition_time_with_minutes() { + rule := parse_posix_zone_rule('<-02>2<-01>,M3.5.0/-1:30,M10.5.0/0')! + transition := rule.transition_utc(2050, rule.start, rule.std_offset) + expected := time_fields_to_unix(Time{ + year: 2050 + month: 3 + day: 27 + hour: 0 + minute: 30 + }) + assert transition == expected +} + +fn test_tzif_rejects_negative_counts() { + mut data := []u8{len: 44} + copy(mut data[0..4], 'TZif'.bytes()) + data[40] = 0xff + if _ := parse_tzif_location('Bad/Zone', data) { + assert false + } else { + assert err.msg().contains('invalid TZif header counts') + } +} + +fn test_tzif_v4_uses_64bit_data_and_posix_tail() { + mut data := load_zoneinfo_from_source(zoneinfo_vroot_zip, 'Europe/London')! + data[4] = `4` + loc := parse_tzif_location('Europe/London', data)! + winter := loc.zone_at(2_524_608_000)! // 2050-01-01 00:00 UTC + summer := loc.zone_at(2_540_246_400)! // 2050-07-01 00:00 UTC + assert winter.name == 'GMT' + assert winter.offset == 0 + assert summer.name == 'BST' + assert summer.offset == 3_600 +} diff --git a/vlib/time/zoneinfo_nix.c.v b/vlib/time/zoneinfo_nix.c.v new file mode 100644 index 00000000000000..b6494b3f40a6c1 --- /dev/null +++ b/vlib/time/zoneinfo_nix.c.v @@ -0,0 +1,81 @@ +module time + +import os + +fn local_location() !&Location { + tz := os.getenv('TZ') + if tz != '' { + if tz.starts_with(':') { + path := tz[1..] + if path != '' { + if data := os.read_bytes(path) { + return parse_tzif_location('Local', data) or { fixed_local_location() } + } + if !os.is_abs_path(path) { + return load_location(path) or { fixed_local_location() } + } + } + } else if tz != 'Local' { + return load_location(tz) or { + if rule := parse_posix_zone_rule(tz) { + return location_from_posix_rule('Local', rule) + } + fixed_local_location() + } + } + } + localtime := '/etc/localtime' + if os.is_link(localtime) { + target := os.readlink(localtime) or { '' } + if target != '' { + if name := zoneinfo_name_from_path(target) { + return load_location(name) or { fixed_local_location() } + } + } + } + // Regular file or symlink whose target is not under .../zoneinfo/... + if os.exists(localtime) { + if data := os.read_bytes(localtime) { + return parse_tzif_location('Local', data) or { fixed_local_location() } + } + } + return fixed_local_location() +} + +fn fixed_local_location() &Location { + return &Location{ + name: 'Local' + zones: [Zone{ + name: 'Local' + offset: offset() + }] + } +} + +fn location_from_posix_rule(name string, rule PosixZoneRule) &Location { + mut zones := [ + Zone{ + name: rule.std_name + offset: rule.std_offset + }, + ] + if rule.has_dst { + zones << Zone{ + name: rule.dst_name + offset: rule.dst_offset + is_dst: true + } + } + return &Location{ + name: name + zones: zones + posix: rule + has_posix: true + } +} + +fn zoneinfo_name_from_path(path string) ?string { + marker := '/zoneinfo/' + index := path.index(marker) or { return none } + return path[index + marker.len..] +} diff --git a/vlib/time/zoneinfo_test.v b/vlib/time/zoneinfo_test.v new file mode 100644 index 00000000000000..5afcfc254c47f7 --- /dev/null +++ b/vlib/time/zoneinfo_test.v @@ -0,0 +1,306 @@ +import os +import time + +fn testsuite_begin() { + os.setenv('ZONEINFO', os.join_path(@VEXEROOT, 'vlib', 'time', 'tzdata', 'zoneinfo.zip'), true) +} + +fn test_load_location_utc() { + loc := time.load_location('UTC')! + assert loc.offset_at(1_704_067_200)! == 0 + t := loc.unix_to_local(1_704_067_200)! + assert t.unix() == 1_704_067_200 + t_loc := t.location() or { panic('missing location') } + assert t_loc.name == 'UTC' + assert (t.zone()!).name == 'UTC' + assert t.year == 2024 + assert t.month == 1 + assert t.day == 1 + assert t.hour == 0 +} + +fn test_load_location_from_embedded_zoneinfo_zip() { + loc := time.load_location('Asia/Shanghai')! + assert loc.offset_at(1_704_067_200)! == 28_800 + t := loc.unix_to_local(1_704_067_200)! + assert t.unix() == 1_704_067_200 + t_loc := t.location() or { panic('missing location') } + assert t_loc.name == 'Asia/Shanghai' + assert (t.zone()!).offset == 28_800 + assert t.year == 2024 + assert t.month == 1 + assert t.day == 1 + assert t.hour == 8 + assert t.minute == 0 + assert t.second == 0 +} + +fn test_load_location_from_zoneinfo_env_zip() { + zoneinfo_zip := os.join_path(@VEXEROOT, 'vlib', 'time', 'tzdata', 'zoneinfo.zip') + os.setenv('ZONEINFO', zoneinfo_zip, true) + loc := time.load_location('Pacific/Niue')! + t := loc.unix_to_local(1_704_067_200)! + assert t.year == 2023 + assert t.month == 12 + assert t.day == 31 + assert t.hour == 13 + assert t.unix() == 1_704_067_200 +} + +fn test_bundled_zoneinfo_has_future_posix_rules() { + zoneinfo_zip := os.join_path(@VEXEROOT, 'vlib', 'time', 'tzdata', 'zoneinfo.zip') + os.setenv('ZONEINFO', zoneinfo_zip, true) + loc := time.load_location('America/Vancouver')! + winter := loc.zone_at(1_893_456_000)! // 2030-01-01 00:00 UTC + summer := loc.zone_at(1_900_000_000)! // 2030-03-17 17:46:40 UTC + assert winter.name == 'PST' + assert winter.offset == -28_800 + assert winter.is_dst == false + assert summer.name == 'PDT' + assert summer.offset == -25_200 + assert summer.is_dst == true +} + +fn test_load_location_with_dst_transition() { + loc := time.load_location('America/New_York')! + before := loc.unix_to_local(1_710_053_940)! + after := loc.unix_to_local(1_710_054_000)! + assert loc.offset_at(1_705_320_000)! == -18_000 + assert loc.offset_at(1_721_044_800)! == -14_400 + before_zone := before.zone()! + assert before_zone.name == 'EST' + assert before_zone.is_dst == false + assert before.year == 2024 + assert before.month == 3 + assert before.day == 10 + assert before.hour == 1 + assert before.minute == 59 + assert after.year == 2024 + assert after.month == 3 + assert after.day == 10 + assert after.hour == 3 + assert after.minute == 0 + after_zone := after.zone()! + assert after_zone.name == 'EDT' + assert after_zone.is_dst == true +} + +fn test_load_location_local() { + loc := time.load_location('Local')! + t := time.utc().in(loc)! + t_loc := t.location() or { panic('missing location') } + assert t_loc.name == 'Local' || t_loc.name.len > 0 + assert t.zone()!.name.len > 0 +} + +fn test_time_in_keeps_nanosecond() { + loc := time.load_location('Asia/Shanghai')! + utc_time := time.unix_nanosecond(1_704_067_200, 123_456_789) + local := utc_time.in(loc)! + assert local.unix() == 1_704_067_200 + local_loc := local.location() or { panic('missing location') } + assert local_loc.name == 'Asia/Shanghai' + assert local.year == 2024 + assert local.month == 1 + assert local.day == 1 + assert local.hour == 8 + assert local.nanosecond == 123_456_789 +} + +fn test_location_time_utc_formats_use_instant() { + loc := time.load_location('Asia/Shanghai')! + local := time.unix_nanosecond(1_704_067_200, 123_456_789).in(loc)! + assert local.year == 2024 + assert local.hour == 8 + assert local.format_rfc3339() == '2024-01-01T00:00:00.123Z' + assert local.format_rfc3339_micro() == '2024-01-01T00:00:00.123456Z' + assert local.format_rfc3339_nano() == '2024-01-01T00:00:00.123456789Z' + assert local.http_header_string() == 'Mon, 01 Jan 2024 00:00:00 GMT' +} + +fn test_location_time_strftime_uses_wall_clock() { + loc := time.load_location('Asia/Shanghai')! + local := time.unix(1_704_067_200).in(loc)! + assert local.strftime('%Y-%m-%d %H:%M:%S') == '2024-01-01 08:00:00' +} + +fn test_location_time_strftime_uses_location_zone_directives() { + loc := time.load_location('America/New_York')! + local := time.unix(1_710_054_000).in(loc)! + assert local.strftime('%Y-%m-%d %H:%M:%S %Z %z') == '2024-03-10 03:00:00 EDT -0400' + assert local.strftime('%s') == '1710054000' + assert local.strftime('%%Z %Z') == '%Z EDT' +} + +fn test_location_time_is_not_utc() { + loc := time.load_location('Asia/Shanghai')! + local := time.unix(1_704_067_200).in(loc)! + assert local.is_utc() == false + assert time.unix(1_704_067_200).is_utc() == true +} + +fn test_location_time_add_keeps_instant_and_location() { + loc := time.load_location('Asia/Shanghai')! + local := time.unix_nanosecond(1_704_067_200, 123_456_789).in(loc)! + added := local.add(1 * time.second) + added_loc := added.location() or { panic('missing location') } + assert added_loc.name == 'Asia/Shanghai' + assert added.unix() == 1_704_067_201 + assert added.year == 2024 + assert added.month == 1 + assert added.day == 1 + assert added.hour == 8 + assert added.minute == 0 + assert added.second == 1 + assert added.nanosecond == 123_456_789 +} + +fn test_location_time_add_seconds_keeps_unix_epoch() { + loc := time.load_location('Asia/Shanghai')! + local := time.unix(0).in(loc)! + added := local.add_seconds(1) + added_loc := added.location() or { panic('missing location') } + assert added_loc.name == 'Asia/Shanghai' + assert added.unix() == 1 + assert added.year == 1970 + assert added.month == 1 + assert added.day == 1 + assert added.hour == 8 + assert added.minute == 0 + assert added.second == 1 +} + +fn test_location_time_equals_same_instant() { + loc := time.load_location('Asia/Shanghai')! + utc_time := time.unix_nanosecond(1_704_067_200, 123_456_789) + local := utc_time.in(loc)! + assert utc_time == local +} + +fn test_invalid_location_name() { + if _ := time.load_location('../UTC') { + assert false + } else { + assert err.msg().contains('invalid time zone location') + } + if _ := time.load_location('/UTC') { + assert false + } else { + assert err.msg().contains('invalid time zone location') + } + if _ := time.load_location('') { + assert false + } else { + assert err.msg().contains('invalid time zone location') + } +} + +fn test_load_location_southern_hemisphere_dst() { + loc := time.load_location('America/Santiago')! + // Southern-hemisphere DST: offset is larger (less negative) in summer. + summer := loc.zone_at(1_709_251_200)! // 2024-02-29 21:00 UTC + winter := loc.zone_at(1_719_792_000)! // 2024-06-30 20:00 UTC + assert summer.is_dst == true + assert summer.offset == -10_800 + assert winter.is_dst == false + assert winter.offset == -14_400 + summer_local := loc.unix_to_local(1_709_251_200)! + assert summer_local.year == 2024 + assert summer_local.month == 2 + assert summer_local.day == 29 + assert summer_local.hour == 21 +} + +fn test_load_location_posix_future_dst() { + // Beyond the last packed transition, rules come from the POSIX TZ tail. + loc := time.load_location('Europe/London')! + winter := loc.zone_at(2_524_608_000)! // 2050-01-01 00:00 UTC + summer := loc.zone_at(2_540_246_400)! // 2050-07-01 00:00 UTC + assert winter.offset == 0 + assert winter.is_dst == false + assert winter.name == 'GMT' + assert summer.offset == 3_600 + assert summer.is_dst == true + assert summer.name == 'BST' + summer_local := loc.unix_to_local(2_540_246_400)! + assert summer_local.year == 2050 + assert summer_local.month == 7 + assert summer_local.day == 1 + assert summer_local.hour == 1 +} + +fn test_load_location_non_m_posix_future_rule() { + // Morocco uses a POSIX tail with day-of-year and Julian-no-leap rules. + loc := time.load_location('Africa/Casablanca')! + start_of_year := loc.zone_at(2_524_608_000)! // 2050-01-01 00:00 UTC + end_of_year := loc.zone_at(2_556_057_600)! // 2050-12-31 00:00 UTC + assert start_of_year.name == '+01' + assert start_of_year.offset == 3_600 + assert start_of_year.is_dst == true + assert end_of_year.name == '+01' + assert end_of_year.offset == 3_600 + assert end_of_year.is_dst == true +} + +fn test_fixed_offset_etc_gmt() { + // Note: Etc/GMT+N uses POSIX sign (opposite of civil intuition). + loc := time.load_location('Etc/GMT+5')! + assert loc.offset_at(1_704_067_200)! == -18_000 + t := loc.unix_to_local(1_704_067_200)! + assert t.year == 2023 + assert t.month == 12 + assert t.day == 31 + assert t.hour == 19 +} + +fn test_location_time_is_not_is_local() { + loc := time.load_location('Asia/Shanghai')! + local := time.unix(1_704_067_200).in(loc)! + assert local.is_local == false + assert local.location() != none + // Same absolute instant as UTC; equality compares the unix epoch. + assert local == time.unix(1_704_067_200) +} + +fn test_unknown_location_name() { + if _ := time.load_location('Not/ARealZone') { + assert false + } else { + assert err.msg().contains('unknown time zone location') + } +} + +fn test_load_location_local_ignores_tz_local() { + old_tz := os.getenv_opt('TZ') + os.setenv('TZ', 'Local', true) + defer { + if old := old_tz { + os.setenv('TZ', old, true) + } else { + os.unsetenv('TZ') + } + } + loc := time.load_location('Local')! + assert loc.name.len > 0 +} + +fn test_load_location_local_posix_tz() { + old_tz := os.getenv_opt('TZ') + os.setenv('TZ', 'EST5EDT,M3.2.0,M11.1.0', true) + defer { + if old := old_tz { + os.setenv('TZ', old, true) + } else { + os.unsetenv('TZ') + } + } + loc := time.load_location('Local')! + winter := loc.zone_at(1_704_067_200)! // 2024-01-01 00:00 UTC + summer := loc.zone_at(1_719_792_000)! // 2024-06-30 20:00 UTC + assert winter.name == 'EST' + assert winter.offset == -18_000 + assert winter.is_dst == false + assert summer.name == 'EDT' + assert summer.offset == -14_400 + assert summer.is_dst == true +} diff --git a/vlib/time/zoneinfo_windows.c.v b/vlib/time/zoneinfo_windows.c.v new file mode 100644 index 00000000000000..ae5dc78edf48f5 --- /dev/null +++ b/vlib/time/zoneinfo_windows.c.v @@ -0,0 +1,292 @@ +module time + +struct TimeZoneInformation { +pub mut: + bias i32 + standard_name [32]u16 + standard_date SystemTime + standard_bias i32 + daylight_name [32]u16 + daylight_date SystemTime + daylight_bias i32 +} + +fn C.GetTimeZoneInformation(&TimeZoneInformation) u32 + +fn local_location() !&Location { + mut info := TimeZoneInformation{} + C.GetTimeZoneInformation(&info) + std_name := unsafe { string_from_wide(&u16(&info.standard_name[0])) } + dst_name := unsafe { string_from_wide(&u16(&info.daylight_name[0])) } + abbr := windows_abbr(std_name) + std_abbr := if abbr.std != '' { abbr.std } else { windows_abbr_from_name(std_name) } + dst_abbr := if abbr.dst != '' { abbr.dst } else { windows_abbr_from_name(dst_name) } + if info.daylight_date.month == 0 || info.standard_date.month == 0 || dst_name == '' { + return &Location{ + name: 'Local' + zones: [ + Zone{ + name: if std_abbr == '' { 'Local' } else { std_abbr } + offset: -int(info.bias) * seconds_per_minute + }, + ] + } + } + std_offset := -int(info.bias + info.standard_bias) * seconds_per_minute + dst_offset := -int(info.bias + info.daylight_bias) * seconds_per_minute + std_zone := Zone{ + name: if std_abbr == '' { 'Standard' } else { std_abbr } + offset: std_offset + } + dst_zone := Zone{ + name: if dst_abbr == '' { 'Daylight' } else { dst_abbr } + offset: dst_offset + is_dst: true + } + mut loc := &Location{ + name: 'Local' + zones: [std_zone, dst_zone] + posix: windows_posix_rule(std_zone, dst_zone, info) + has_posix: true + } + current_year := utc().year + for year in current_year - 100 .. current_year + 101 { + dst_start := windows_transition_utc(year, info.daylight_date, std_offset) + std_start := windows_transition_utc(year, info.standard_date, dst_offset) + if dst_start < std_start { + loc.transitions << ZoneTransition{ + when: dst_start + index: 1 + } + loc.transitions << ZoneTransition{ + when: std_start + index: 0 + } + } else { + loc.transitions << ZoneTransition{ + when: std_start + index: 0 + } + loc.transitions << ZoneTransition{ + when: dst_start + index: 1 + } + } + } + return loc +} + +fn windows_posix_rule(std_zone Zone, dst_zone Zone, info TimeZoneInformation) PosixZoneRule { + return PosixZoneRule{ + std_name: std_zone.name + std_offset: std_zone.offset + dst_name: dst_zone.name + dst_offset: dst_zone.offset + start: windows_system_time_rule(info.daylight_date) + end: windows_system_time_rule(info.standard_date) + has_dst: true + } +} + +fn windows_system_time_rule(st SystemTime) PosixRule { + return PosixRule{ + kind: .month_week_day + month: int(st.month) + week: int(st.day) + weekday: int(st.day_of_week) + seconds: int(st.hour) * seconds_per_hour + int(st.minute) * seconds_per_minute + + int(st.second) + } +} + +fn windows_transition_utc(year int, st SystemTime, offset_before int) i64 { + day := windows_month_week_day(year, int(st.month), int(st.day), int(st.day_of_week)) + local := time_fields_to_unix(Time{ + year: year + month: int(st.month) + day: day + hour: int(st.hour) + minute: int(st.minute) + second: int(st.second) + }) + return local - i64(offset_before) +} + +fn windows_month_week_day(year int, month int, week int, weekday int) int { + first_weekday := day_of_week(year, month, 1) % 7 + mut day := 1 + ((weekday - first_weekday + 7) % 7) + (week - 1) * 7 + days := days_in_month(month, year) or { 31 } + if week == 5 { + for day + 7 <= days { + day += 7 + } + if day > days { + day -= 7 + } + } + return day +} + +struct WindowsAbbr { + std string + dst string +} + +fn windows_abbr(name string) WindowsAbbr { + return windows_abbrs[name] or { WindowsAbbr{} } +} + +fn windows_abbr_from_name(name string) string { + mut out := []u8{} + for c in name { + if c >= `A` && c <= `Z` { + out << u8(c) + } + } + if out.len == 0 { + return name + } + return out.bytestr() +} + +const windows_abbrs = { + 'Egypt Standard Time': WindowsAbbr{'EET', 'EEST'} + 'Morocco Standard Time': WindowsAbbr{'+00', '+01'} + 'South Africa Standard Time': WindowsAbbr{'SAST', 'SAST'} + 'South Sudan Standard Time': WindowsAbbr{'CAT', 'CAT'} + 'Sudan Standard Time': WindowsAbbr{'CAT', 'CAT'} + 'W. Central Africa Standard Time': WindowsAbbr{'WAT', 'WAT'} + 'E. Africa Standard Time': WindowsAbbr{'EAT', 'EAT'} + 'Sao Tome Standard Time': WindowsAbbr{'GMT', 'GMT'} + 'Libya Standard Time': WindowsAbbr{'EET', 'EET'} + 'Namibia Standard Time': WindowsAbbr{'CAT', 'CAT'} + 'Aleutian Standard Time': WindowsAbbr{'HST', 'HDT'} + 'Alaskan Standard Time': WindowsAbbr{'AKST', 'AKDT'} + 'Tocantins Standard Time': WindowsAbbr{'-03', '-03'} + 'Paraguay Standard Time': WindowsAbbr{'-04', '-03'} + 'Bahia Standard Time': WindowsAbbr{'-03', '-03'} + 'SA Pacific Standard Time': WindowsAbbr{'-05', '-05'} + 'Argentina Standard Time': WindowsAbbr{'-03', '-03'} + 'Eastern Standard Time (Mexico)': WindowsAbbr{'EST', 'EST'} + 'Venezuela Standard Time': WindowsAbbr{'-04', '-04'} + 'SA Eastern Standard Time': WindowsAbbr{'-03', '-03'} + 'Central Standard Time': WindowsAbbr{'CST', 'CDT'} + 'Central Brazilian Standard Time': WindowsAbbr{'-04', '-04'} + 'Mountain Standard Time': WindowsAbbr{'MST', 'MDT'} + 'Greenland Standard Time': WindowsAbbr{'-02', '-01'} + 'Turks And Caicos Standard Time': WindowsAbbr{'EST', 'EDT'} + 'Central America Standard Time': WindowsAbbr{'CST', 'CST'} + 'Atlantic Standard Time': WindowsAbbr{'AST', 'ADT'} + 'Cuba Standard Time': WindowsAbbr{'CST', 'CDT'} + 'US Eastern Standard Time': WindowsAbbr{'EST', 'EDT'} + 'SA Western Standard Time': WindowsAbbr{'-04', '-04'} + 'Pacific Standard Time': WindowsAbbr{'PST', 'PDT'} + 'Mountain Standard Time (Mexico)': WindowsAbbr{'MST', 'MST'} + 'Central Standard Time (Mexico)': WindowsAbbr{'CST', 'CST'} + 'Saint Pierre Standard Time': WindowsAbbr{'-03', '-02'} + 'Montevideo Standard Time': WindowsAbbr{'-03', '-03'} + 'Eastern Standard Time': WindowsAbbr{'EST', 'EDT'} + 'US Mountain Standard Time': WindowsAbbr{'MST', 'MST'} + 'Haiti Standard Time': WindowsAbbr{'EST', 'EDT'} + 'Magallanes Standard Time': WindowsAbbr{'-03', '-03'} + 'Canada Central Standard Time': WindowsAbbr{'CST', 'CST'} + 'Pacific SA Standard Time': WindowsAbbr{'-04', '-03'} + 'E. South America Standard Time': WindowsAbbr{'-03', '-03'} + 'Newfoundland Standard Time': WindowsAbbr{'NST', 'NDT'} + 'Pacific Standard Time (Mexico)': WindowsAbbr{'PST', 'PDT'} + 'Yukon Standard Time': WindowsAbbr{'MST', 'MST'} + 'Jordan Standard Time': WindowsAbbr{'+03', '+03'} + 'Arabic Standard Time': WindowsAbbr{'+03', '+03'} + 'Azerbaijan Standard Time': WindowsAbbr{'+04', '+04'} + 'SE Asia Standard Time': WindowsAbbr{'+07', '+07'} + 'Altai Standard Time': WindowsAbbr{'+07', '+07'} + 'Middle East Standard Time': WindowsAbbr{'EET', 'EEST'} + 'Central Asia Standard Time': WindowsAbbr{'+06', '+06'} + 'India Standard Time': WindowsAbbr{'IST', 'IST'} + 'Transbaikal Standard Time': WindowsAbbr{'+09', '+09'} + 'Sri Lanka Standard Time': WindowsAbbr{'+0530', '+0530'} + 'Syria Standard Time': WindowsAbbr{'+03', '+03'} + 'Bangladesh Standard Time': WindowsAbbr{'+06', '+06'} + 'Arabian Standard Time': WindowsAbbr{'+04', '+04'} + 'West Bank Standard Time': WindowsAbbr{'EET', 'EEST'} + 'W. Mongolia Standard Time': WindowsAbbr{'+07', '+07'} + 'North Asia East Standard Time': WindowsAbbr{'+08', '+08'} + 'Israel Standard Time': WindowsAbbr{'IST', 'IDT'} + 'Afghanistan Standard Time': WindowsAbbr{'+0430', '+0430'} + 'Russia Time Zone 11': WindowsAbbr{'+12', '+12'} + 'Pakistan Standard Time': WindowsAbbr{'PKT', 'PKT'} + 'Nepal Standard Time': WindowsAbbr{'+0545', '+0545'} + 'North Asia Standard Time': WindowsAbbr{'+07', '+07'} + 'Magadan Standard Time': WindowsAbbr{'+11', '+11'} + 'N. Central Asia Standard Time': WindowsAbbr{'+07', '+07'} + 'Omsk Standard Time': WindowsAbbr{'+06', '+06'} + 'North Korea Standard Time': WindowsAbbr{'KST', 'KST'} + 'Qyzylorda Standard Time': WindowsAbbr{'+05', '+05'} + 'Myanmar Standard Time': WindowsAbbr{'+0630', '+0630'} + 'Arab Standard Time': WindowsAbbr{'+03', '+03'} + 'Sakhalin Standard Time': WindowsAbbr{'+11', '+11'} + 'Korea Standard Time': WindowsAbbr{'KST', 'KST'} + 'China Standard Time': WindowsAbbr{'CST', 'CST'} + 'Singapore Standard Time': WindowsAbbr{'+08', '+08'} + 'Russia Time Zone 10': WindowsAbbr{'+11', '+11'} + 'Taipei Standard Time': WindowsAbbr{'CST', 'CST'} + 'West Asia Standard Time': WindowsAbbr{'+05', '+05'} + 'Georgian Standard Time': WindowsAbbr{'+04', '+04'} + 'Iran Standard Time': WindowsAbbr{'+0330', '+0330'} + 'Tokyo Standard Time': WindowsAbbr{'JST', 'JST'} + 'Tomsk Standard Time': WindowsAbbr{'+07', '+07'} + 'Ulaanbaatar Standard Time': WindowsAbbr{'+08', '+08'} + 'Vladivostok Standard Time': WindowsAbbr{'+10', '+10'} + 'Yakutsk Standard Time': WindowsAbbr{'+09', '+09'} + 'Ekaterinburg Standard Time': WindowsAbbr{'+05', '+05'} + 'Caucasus Standard Time': WindowsAbbr{'+04', '+04'} + 'Azores Standard Time': WindowsAbbr{'-01', '+00'} + 'Cape Verde Standard Time': WindowsAbbr{'-01', '-01'} + 'Greenwich Standard Time': WindowsAbbr{'GMT', 'GMT'} + 'Cen. Australia Standard Time': WindowsAbbr{'ACST', 'ACDT'} + 'E. Australia Standard Time': WindowsAbbr{'AEST', 'AEST'} + 'AUS Central Standard Time': WindowsAbbr{'ACST', 'ACST'} + 'Aus Central W. Standard Time': WindowsAbbr{'+0845', '+0845'} + 'Tasmania Standard Time': WindowsAbbr{'AEST', 'AEDT'} + 'Lord Howe Standard Time': WindowsAbbr{'+1030', '+11'} + 'W. Australia Standard Time': WindowsAbbr{'AWST', 'AWST'} + 'AUS Eastern Standard Time': WindowsAbbr{'AEST', 'AEDT'} + 'UTC-11': WindowsAbbr{'-11', '-11'} + 'Dateline Standard Time': WindowsAbbr{'-12', '-12'} + 'UTC-02': WindowsAbbr{'-02', '-02'} + 'UTC-08': WindowsAbbr{'-08', '-08'} + 'UTC-09': WindowsAbbr{'-09', '-09'} + 'UTC+12': WindowsAbbr{'+12', '+12'} + 'UTC+13': WindowsAbbr{'+13', '+13'} + 'UTC': WindowsAbbr{'UTC', 'UTC'} + 'Astrakhan Standard Time': WindowsAbbr{'+04', '+04'} + 'W. Europe Standard Time': WindowsAbbr{'CET', 'CEST'} + 'GTB Standard Time': WindowsAbbr{'EET', 'EEST'} + 'Central Europe Standard Time': WindowsAbbr{'CET', 'CEST'} + 'E. Europe Standard Time': WindowsAbbr{'EET', 'EEST'} + 'Turkey Standard Time': WindowsAbbr{'+03', '+03'} + 'Kaliningrad Standard Time': WindowsAbbr{'EET', 'EET'} + 'FLE Standard Time': WindowsAbbr{'EET', 'EEST'} + 'GMT Standard Time': WindowsAbbr{'GMT', 'BST'} + 'Belarus Standard Time': WindowsAbbr{'+03', '+03'} + 'Russian Standard Time': WindowsAbbr{'MSK', 'MSK'} + 'Romance Standard Time': WindowsAbbr{'CET', 'CEST'} + 'Russia Time Zone 3': WindowsAbbr{'+04', '+04'} + 'Saratov Standard Time': WindowsAbbr{'+04', '+04'} + 'Volgograd Standard Time': WindowsAbbr{'MSK', 'MSK'} + 'Central European Standard Time': WindowsAbbr{'CET', 'CEST'} + 'Mauritius Standard Time': WindowsAbbr{'+04', '+04'} + 'Samoa Standard Time': WindowsAbbr{'+13', '+13'} + 'New Zealand Standard Time': WindowsAbbr{'NZST', 'NZDT'} + 'Bougainville Standard Time': WindowsAbbr{'+11', '+11'} + 'Chatham Islands Standard Time': WindowsAbbr{'+1245', '+1345'} + 'Easter Island Standard Time': WindowsAbbr{'-06', '-05'} + 'Fiji Standard Time': WindowsAbbr{'+12', '+12'} + 'Central Pacific Standard Time': WindowsAbbr{'+11', '+11'} + 'Hawaiian Standard Time': WindowsAbbr{'HST', 'HST'} + 'Line Islands Standard Time': WindowsAbbr{'+14', '+14'} + 'Marquesas Standard Time': WindowsAbbr{'-0930', '-0930'} + 'Norfolk Standard Time': WindowsAbbr{'+11', '+12'} + 'West Pacific Standard Time': WindowsAbbr{'+10', '+10'} + 'Tonga Standard Time': WindowsAbbr{'+13', '+13'} +}