diff options
| author | Jeroen van Rijn <Kelimion@users.noreply.github.com> | 2024-10-22 10:18:38 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-10-22 10:18:38 +0200 |
| commit | 00ec862b63cb731928736e7c83a3c823b9aed24b (patch) | |
| tree | 09381d36aeb502d0e56f7f456c4beb003e525b6c | |
| parent | 7c1922b0a7ed210fe9ce2c1c70c320fde7858c83 (diff) | |
| parent | d8696badb6a40e8d1a8b337e8ad3d89f9e4ab5a3 (diff) | |
Merge pull request #4335 from colrdavidson/datetime_tz
Add Timezone Support to Odin
| -rw-r--r-- | core/os/os_darwin.odin | 6 | ||||
| -rw-r--r-- | core/os/os_freebsd.odin | 7 | ||||
| -rw-r--r-- | core/os/os_haiku.odin | 6 | ||||
| -rw-r--r-- | core/os/os_linux.odin | 6 | ||||
| -rw-r--r-- | core/os/os_netbsd.odin | 6 | ||||
| -rw-r--r-- | core/os/os_openbsd.odin | 6 | ||||
| -rw-r--r-- | core/sys/windows/icu.odin | 14 | ||||
| -rw-r--r-- | core/time/datetime/constants.odin | 45 | ||||
| -rw-r--r-- | core/time/datetime/datetime.odin | 6 | ||||
| -rw-r--r-- | core/time/time.odin | 4 | ||||
| -rw-r--r-- | core/time/timezone/tz_unix.odin | 89 | ||||
| -rw-r--r-- | core/time/timezone/tz_windows.odin | 295 | ||||
| -rw-r--r-- | core/time/timezone/tzdate.odin | 339 | ||||
| -rw-r--r-- | core/time/timezone/tzif.odin | 652 | ||||
| -rw-r--r-- | tests/core/time/test_core_time.odin | 212 |
15 files changed, 1662 insertions, 31 deletions
diff --git a/core/os/os_darwin.odin b/core/os/os_darwin.odin index c7e1750d6..616e167a1 100644 --- a/core/os/os_darwin.odin +++ b/core/os/os_darwin.odin @@ -1026,7 +1026,7 @@ absolute_path_from_handle :: proc(fd: Handle) -> (path: string, err: Error) { } @(require_results) -absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) { +absolute_path_from_relative :: proc(rel: string, allocator := context.allocator) -> (path: string, err: Error) { rel := rel if rel == "" { rel = "." @@ -1041,9 +1041,7 @@ absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) { } defer _unix_free(rawptr(path_ptr)) - path = strings.clone(string(path_ptr)) - - return path, nil + return strings.clone(string(path_ptr), allocator) } access :: proc(path: string, mask: int) -> bool { diff --git a/core/os/os_freebsd.odin b/core/os/os_freebsd.odin index f617cf973..837e79f4d 100644 --- a/core/os/os_freebsd.odin +++ b/core/os/os_freebsd.odin @@ -789,7 +789,7 @@ absolute_path_from_handle :: proc(fd: Handle) -> (string, Error) { } @(require_results) -absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) { +absolute_path_from_relative :: proc(rel: string, allocator := context.allocator) -> (path: string, err: Error) { rel := rel if rel == "" { rel = "." @@ -804,10 +804,7 @@ absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) { } defer _unix_free(rawptr(path_ptr)) - - path = strings.clone(string(path_ptr)) - - return path, nil + return strings.clone(string(path_ptr), allocator) } access :: proc(path: string, mask: int) -> (bool, Error) { diff --git a/core/os/os_haiku.odin b/core/os/os_haiku.odin index 0d2c334be..4ad370724 100644 --- a/core/os/os_haiku.odin +++ b/core/os/os_haiku.odin @@ -431,7 +431,7 @@ absolute_path_from_handle :: proc(fd: Handle) -> (string, Error) { } @(require_results) -absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) { +absolute_path_from_relative :: proc(rel: string, allocator := context.allocator) -> (path: string, err: Error) { rel := rel if rel == "" { rel = "." @@ -447,9 +447,7 @@ absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) { defer _unix_free(path_ptr) path_cstr := cstring(path_ptr) - path = strings.clone(string(path_cstr)) - - return path, nil + return strings.clone(string(path_cstr), allocator) } access :: proc(path: string, mask: int) -> (bool, Error) { diff --git a/core/os/os_linux.odin b/core/os/os_linux.odin index e9039ba20..fe8215135 100644 --- a/core/os/os_linux.odin +++ b/core/os/os_linux.odin @@ -917,7 +917,7 @@ absolute_path_from_handle :: proc(fd: Handle) -> (string, Error) { } @(require_results) -absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) { +absolute_path_from_relative :: proc(rel: string, allocator := context.allocator) -> (path: string, err: Error) { rel := rel if rel == "" { rel = "." @@ -932,9 +932,7 @@ absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) { } defer _unix_free(rawptr(path_ptr)) - path = strings.clone(string(path_ptr)) - - return path, nil + return strings.clone(string(path_ptr), allocator) } access :: proc(path: string, mask: int) -> (bool, Error) { diff --git a/core/os/os_netbsd.odin b/core/os/os_netbsd.odin index 493527803..e3ba760a4 100644 --- a/core/os/os_netbsd.odin +++ b/core/os/os_netbsd.odin @@ -844,7 +844,7 @@ absolute_path_from_handle :: proc(fd: Handle) -> (path: string, err: Error) { } @(require_results) -absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) { +absolute_path_from_relative :: proc(rel: string, allocator := context.allocator) -> (path: string, err: Error) { rel := rel if rel == "" { rel = "." @@ -859,9 +859,7 @@ absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) { } defer _unix_free(rawptr(path_ptr)) - path = strings.clone(string(path_ptr)) - - return path, nil + return strings.clone(string(path_ptr), allocator) } access :: proc(path: string, mask: int) -> (bool, Error) { diff --git a/core/os/os_openbsd.odin b/core/os/os_openbsd.odin index 62872d9dc..3c377968c 100644 --- a/core/os/os_openbsd.odin +++ b/core/os/os_openbsd.odin @@ -758,7 +758,7 @@ absolute_path_from_handle :: proc(fd: Handle) -> (string, Error) { } @(require_results) -absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) { +absolute_path_from_relative :: proc(rel: string, allocator := context.allocator) -> (path: string, err: Error) { rel := rel if rel == "" { rel = "." @@ -773,9 +773,7 @@ absolute_path_from_relative :: proc(rel: string) -> (path: string, err: Error) { } defer _unix_free(rawptr(path_ptr)) - path = strings.clone(string(path_ptr)) - - return path, nil + return strings.clone(string(path_ptr), allocator) } access :: proc(path: string, mask: int) -> (bool, Error) { diff --git a/core/sys/windows/icu.odin b/core/sys/windows/icu.odin new file mode 100644 index 000000000..6ed8c9b40 --- /dev/null +++ b/core/sys/windows/icu.odin @@ -0,0 +1,14 @@ +#+build windows +package sys_windows + +foreign import "system:icu.lib" + +UError :: enum i32 { + U_ZERO_ERROR = 0, +} + +@(default_calling_convention="system") +foreign icu { + ucal_getWindowsTimeZoneID :: proc(id: wstring, len: i32, winid: wstring, winidCapacity: i32, status: ^UError) -> i32 --- + ucal_getDefaultTimeZone :: proc(result: wstring, cap: i32, status: ^UError) -> i32 --- +} diff --git a/core/time/datetime/constants.odin b/core/time/datetime/constants.odin index 5f336ef4a..e24709e49 100644 --- a/core/time/datetime/constants.odin +++ b/core/time/datetime/constants.odin @@ -77,12 +77,55 @@ Time :: struct { nano: i32, } +TZ_Record :: struct { + time: i64, + utc_offset: i64, + shortname: string, + dst: bool, +} + +TZ_Date_Kind :: enum { + No_Leap, + Leap, + Month_Week_Day, +} + +TZ_Transition_Date :: struct { + type: TZ_Date_Kind, + + month: u8, + week: u8, + day: u16, + + time: i64, +} + +TZ_RRule :: struct { + has_dst: bool, + + std_name: string, + std_offset: i64, + std_date: TZ_Transition_Date, + + dst_name: string, + dst_offset: i64, + dst_date: TZ_Transition_Date, +} + +TZ_Region :: struct { + name: string, + records: []TZ_Record, + shortnames: []string, + rrule: TZ_RRule, +} + /* A type representing datetime. */ DateTime :: struct { using date: Date, using time: Time, + tz: ^TZ_Region, } /* @@ -130,4 +173,4 @@ Weekday :: enum i8 { } @(private) -MONTH_DAYS :: [?]i8{-1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
\ No newline at end of file +MONTH_DAYS :: [?]i8{-1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} diff --git a/core/time/datetime/datetime.odin b/core/time/datetime/datetime.odin index fc9780e3b..2cd90b0e7 100644 --- a/core/time/datetime/datetime.odin +++ b/core/time/datetime/datetime.odin @@ -76,7 +76,7 @@ datetime, an error is returned. components_to_datetime :: proc "contextless" (#any_int year, #any_int month, #any_int day, #any_int hour, #any_int minute, #any_int second: i64, #any_int nanos := i64(0)) -> (datetime: DateTime, err: Error) { date := components_to_date(year, month, day) or_return time := components_to_time(hour, minute, second, nanos) or_return - return {date, time}, .None + return {date, time, nil}, .None } /* @@ -88,7 +88,7 @@ object will always have the time equal to `00:00:00.000`. */ ordinal_to_datetime :: proc "contextless" (ordinal: Ordinal) -> (datetime: DateTime, err: Error) { d := ordinal_to_date(ordinal) or_return - return {Date(d), {}}, .None + return {Date(d), {}, nil}, .None } /* @@ -433,4 +433,4 @@ unsafe_ordinal_to_date :: proc "contextless" (ordinal: Ordinal) -> (date: Date) day := i8(ordinal - unsafe_date_to_ordinal(Date{year, month, 1}) + 1) return {year, month, day} -}
\ No newline at end of file +} diff --git a/core/time/time.odin b/core/time/time.odin index 98639b36a..b488f951c 100644 --- a/core/time/time.odin +++ b/core/time/time.odin @@ -930,7 +930,7 @@ If the datetime represents a time outside of a valid range, `false` is returned as the second return value. See `Time` for the representable range. */ compound_to_time :: proc "contextless" (datetime: dt.DateTime) -> (t: Time, ok: bool) { - unix_epoch := dt.DateTime{{1970, 1, 1}, {0, 0, 0, 0}} + unix_epoch := dt.DateTime{{1970, 1, 1}, {0, 0, 0, 0}, nil} delta, err := dt.sub(datetime, unix_epoch) if err != .None { return @@ -958,7 +958,7 @@ datetime_to_time :: proc{components_to_time, compound_to_time} Convert time into datetime. */ time_to_datetime :: proc "contextless" (t: Time) -> (dt.DateTime, bool) { - unix_epoch := dt.DateTime{{1970, 1, 1}, {0, 0, 0, 0}} + unix_epoch := dt.DateTime{{1970, 1, 1}, {0, 0, 0, 0}, nil} datetime, err := dt.add(unix_epoch, dt.Delta{ nanos = t._nsec }) if err != .None { diff --git a/core/time/timezone/tz_unix.odin b/core/time/timezone/tz_unix.odin new file mode 100644 index 000000000..990e78d41 --- /dev/null +++ b/core/time/timezone/tz_unix.odin @@ -0,0 +1,89 @@ +#+build darwin, linux, freebsd, openbsd, netbsd +#+private +package timezone + +import "core:os" +import "core:strings" +import "core:path/filepath" +import "core:time/datetime" + +local_tz_name :: proc(allocator := context.allocator) -> (name: string, success: bool) { + local_str, ok := os.lookup_env("TZ", allocator) + if !ok { + orig_localtime_path := "/etc/localtime" + path, err := os.absolute_path_from_relative(orig_localtime_path, allocator) + if err != nil { + // If we can't find /etc/localtime, fallback to UTC + if err == .ENOENT { + str, err2 := strings.clone("UTC", allocator) + if err2 != nil { return } + return str, true + } + + return + } + defer delete(path, allocator) + + // FreeBSD makes me sad. + // This is a hackaround, because FreeBSD copies rather than softlinks their local timezone file, + // *sometimes* and then stores the original name of the timezone in /var/db/zoneinfo instead + if path == orig_localtime_path { + data := os.read_entire_file("/var/db/zoneinfo", allocator) or_return + return strings.trim_right_space(string(data)), true + } + + // Looking for tz path (ex fmt: "UTC", "Etc/UTC" or "America/Los_Angeles") + path_dir, path_file := filepath.split(path) + if path_dir == "" { + return + } + upper_path_dir, upper_path_chunk := filepath.split(path_dir[:len(path_dir)-1]) + if upper_path_dir == "" { + return + } + + if strings.contains(upper_path_chunk, "zoneinfo") { + region_str, err := strings.clone(path_file, allocator) + if err != nil { return } + return region_str, true + } else { + region_str, err := filepath.join({upper_path_chunk, path_file}, allocator = allocator) + if err != nil { return } + return region_str, true + } + } + + if local_str == "" { + delete(local_str, allocator) + + str, err := strings.clone("UTC", allocator) + if err != nil { return } + return str, true + } + + return local_str, true +} + +_region_load :: proc(_reg_str: string, allocator := context.allocator) -> (out_reg: ^datetime.TZ_Region, success: bool) { + reg_str := _reg_str + if reg_str == "UTC" { + return nil, true + } + + if reg_str == "local" { + local_name := local_tz_name(allocator) or_return + if local_name == "UTC" { + delete(local_name, allocator) + return nil, true + } + + reg_str = local_name + } + defer if _reg_str == "local" { delete(reg_str, allocator) } + + db_path := "/usr/share/zoneinfo" + region_path := filepath.join({db_path, reg_str}, allocator) + defer delete(region_path, allocator) + + return load_tzif_file(region_path, reg_str, allocator) +} diff --git a/core/time/timezone/tz_windows.odin b/core/time/timezone/tz_windows.odin new file mode 100644 index 000000000..f1604b939 --- /dev/null +++ b/core/time/timezone/tz_windows.odin @@ -0,0 +1,295 @@ +#+build windows +#+private +package timezone + +import "core:strings" +import "core:sys/windows" +import "core:time/datetime" + +TZ_Abbrev :: struct { + std: string, + dst: string, +} + +tz_abbrevs := map[string]TZ_Abbrev { + "Egypt Standard Time" = {"EET", "EEST"}, // Africa/Cairo + "Morocco Standard Time" = {"+00", "+01"}, // Africa/Casablanca + "South Africa Standard Time" = {"SAST", "SAST"}, // Africa/Johannesburg + "South Sudan Standard Time" = {"CAT", "CAT"}, // Africa/Juba + "Sudan Standard Time" = {"CAT", "CAT"}, // Africa/Khartoum + "W. Central Africa Standard Time" = {"WAT", "WAT"}, // Africa/Lagos + "E. Africa Standard Time" = {"EAT", "EAT"}, // Africa/Nairobi + "Sao Tome Standard Time" = {"GMT", "GMT"}, // Africa/Sao_Tome + "Libya Standard Time" = {"EET", "EET"}, // Africa/Tripoli + "Namibia Standard Time" = {"CAT", "CAT"}, // Africa/Windhoek + "Aleutian Standard Time" = {"HST", "HDT"}, // America/Adak + "Alaskan Standard Time" = {"AKST", "AKDT"}, // America/Anchorage + "Tocantins Standard Time" = {"-03", "-03"}, // America/Araguaina + "Paraguay Standard Time" = {"-04", "-03"}, // America/Asuncion + "Bahia Standard Time" = {"-03", "-03"}, // America/Bahia + "SA Pacific Standard Time" = {"-05", "-05"}, // America/Bogota + "Argentina Standard Time" = {"-03", "-03"}, // America/Buenos_Aires + "Eastern Standard Time (Mexico)" = {"EST", "EST"}, // America/Cancun + "Venezuela Standard Time" = {"-04", "-04"}, // America/Caracas + "SA Eastern Standard Time" = {"-03", "-03"}, // America/Cayenne + "Central Standard Time" = {"CST", "CDT"}, // America/Chicago + "Central Brazilian Standard Time" = {"-04", "-04"}, // America/Cuiaba + "Mountain Standard Time" = {"MST", "MDT"}, // America/Denver + "Greenland Standard Time" = {"-03", "-02"}, // America/Godthab + "Turks And Caicos Standard Time" = {"EST", "EDT"}, // America/Grand_Turk + "Central America Standard Time" = {"CST", "CST"}, // America/Guatemala + "Atlantic Standard Time" = {"AST", "ADT"}, // America/Halifax + "Cuba Standard Time" = {"CST", "CDT"}, // America/Havana + "US Eastern Standard Time" = {"EST", "EDT"}, // America/Indianapolis + "SA Western Standard Time" = {"-04", "-04"}, // America/La_Paz + "Pacific Standard Time" = {"PST", "PDT"}, // America/Los_Angeles + "Mountain Standard Time (Mexico)" = {"MST", "MST"}, // America/Mazatlan + "Central Standard Time (Mexico)" = {"CST", "CST"}, // America/Mexico_City + "Saint Pierre Standard Time" = {"-03", "-02"}, // America/Miquelon + "Montevideo Standard Time" = {"-03", "-03"}, // America/Montevideo + "Eastern Standard Time" = {"EST", "EDT"}, // America/New_York + "US Mountain Standard Time" = {"MST", "MST"}, // America/Phoenix + "Haiti Standard Time" = {"EST", "EDT"}, // America/Port-au-Prince + "Magallanes Standard Time" = {"-03", "-03"}, // America/Punta_Arenas + "Canada Central Standard Time" = {"CST", "CST"}, // America/Regina + "Pacific SA Standard Time" = {"-04", "-03"}, // America/Santiago + "E. South America Standard Time" = {"-03", "-03"}, // America/Sao_Paulo + "Newfoundland Standard Time" = {"NST", "NDT"}, // America/St_Johns + "Pacific Standard Time (Mexico)" = {"PST", "PDT"}, // America/Tijuana + "Yukon Standard Time" = {"MST", "MST"}, // America/Whitehorse + "Central Asia Standard Time" = {"+06", "+06"}, // Asia/Almaty + "Jordan Standard Time" = {"+03", "+03"}, // Asia/Amman + "Arabic Standard Time" = {"+03", "+03"}, // Asia/Baghdad + "Azerbaijan Standard Time" = {"+04", "+04"}, // Asia/Baku + "SE Asia Standard Time" = {"+07", "+07"}, // Asia/Bangkok + "Altai Standard Time" = {"+07", "+07"}, // Asia/Barnaul + "Middle East Standard Time" = {"EET", "EEST"}, // Asia/Beirut + "India Standard Time" = {"IST", "IST"}, // Asia/Calcutta + "Transbaikal Standard Time" = {"+09", "+09"}, // Asia/Chita + "Sri Lanka Standard Time" = {"+0530", "+0530"}, // Asia/Colombo + "Syria Standard Time" = {"+03", "+03"}, // Asia/Damascus + "Bangladesh Standard Time" = {"+06", "+06"}, // Asia/Dhaka + "Arabian Standard Time" = {"+04", "+04"}, // Asia/Dubai + "West Bank Standard Time" = {"EET", "EEST"}, // Asia/Hebron + "W. Mongolia Standard Time" = {"+07", "+07"}, // Asia/Hovd + "North Asia East Standard Time" = {"+08", "+08"}, // Asia/Irkutsk + "Israel Standard Time" = {"IST", "IDT"}, // Asia/Jerusalem + "Afghanistan Standard Time" = {"+0430", "+0430"}, // Asia/Kabul + "Russia Time Zone 11" = {"+12", "+12"}, // Asia/Kamchatka + "Pakistan Standard Time" = {"PKT", "PKT"}, // Asia/Karachi + "Nepal Standard Time" = {"+0545", "+0545"}, // Asia/Katmandu + "North Asia Standard Time" = {"+07", "+07"}, // Asia/Krasnoyarsk + "Magadan Standard Time" = {"+11", "+11"}, // Asia/Magadan + "N. Central Asia Standard Time" = {"+07", "+07"}, // Asia/Novosibirsk + "Omsk Standard Time" = {"+06", "+06"}, // Asia/Omsk + "North Korea Standard Time" = {"KST", "KST"}, // Asia/Pyongyang + "Qyzylorda Standard Time" = {"+05", "+05"}, // Asia/Qyzylorda + "Myanmar Standard Time" = {"+0630", "+0630"}, // Asia/Rangoon + "Arab Standard Time" = {"+03", "+03"}, // Asia/Riyadh + "Sakhalin Standard Time" = {"+11", "+11"}, // Asia/Sakhalin + "Korea Standard Time" = {"KST", "KST"}, // Asia/Seoul + "China Standard Time" = {"CST", "CST"}, // Asia/Shanghai + "Singapore Standard Time" = {"+08", "+08"}, // Asia/Singapore + "Russia Time Zone 10" = {"+11", "+11"}, // Asia/Srednekolymsk + "Taipei Standard Time" = {"CST", "CST"}, // Asia/Taipei + "West Asia Standard Time" = {"+05", "+05"}, // Asia/Tashkent + "Georgian Standard Time" = {"+04", "+04"}, // Asia/Tbilisi + "Iran Standard Time" = {"+0330", "+0330"}, // Asia/Tehran + "Tokyo Standard Time" = {"JST", "JST"}, // Asia/Tokyo + "Tomsk Standard Time" = {"+07", "+07"}, // Asia/Tomsk + "Ulaanbaatar Standard Time" = {"+08", "+08"}, // Asia/Ulaanbaatar + "Vladivostok Standard Time" = {"+10", "+10"}, // Asia/Vladivostok + "Yakutsk Standard Time" = {"+09", "+09"}, // Asia/Yakutsk + "Ekaterinburg Standard Time" = {"+05", "+05"}, // Asia/Yekaterinburg + "Caucasus Standard Time" = {"+04", "+04"}, // Asia/Yerevan + "Azores Standard Time" = {"-01", "+00"}, // Atlantic/Azores + "Cape Verde Standard Time" = {"-01", "-01"}, // Atlantic/Cape_Verde + "Greenwich Standard Time" = {"GMT", "GMT"}, // Atlantic/Reykjavik + "Cen. Australia Standard Time" = {"ACST", "ACDT"}, // Australia/Adelaide + "E. Australia Standard Time" = {"AEST", "AEST"}, // Australia/Brisbane + "AUS Central Standard Time" = {"ACST", "ACST"}, // Australia/Darwin + "Aus Central W. Standard Time" = {"+0845", "+0845"}, // Australia/Eucla + "Tasmania Standard Time" = {"AEST", "AEDT"}, // Australia/Hobart + "Lord Howe Standard Time" = {"+1030", "+11"}, // Australia/Lord_Howe + "W. Australia Standard Time" = {"AWST", "AWST"}, // Australia/Perth + "AUS Eastern Standard Time" = {"AEST", "AEDT"}, // Australia/Sydney + "UTC-11" = {"-11", "-11"}, // Etc/GMT+11 + "Dateline Standard Time" = {"-12", "-12"}, // Etc/GMT+12 + "UTC-02" = {"-02", "-02"}, // Etc/GMT+2 + "UTC-08" = {"-08", "-08"}, // Etc/GMT+8 + "UTC-09" = {"-09", "-09"}, // Etc/GMT+9 + "UTC+12" = {"+12", "+12"}, // Etc/GMT-12 + "UTC+13" = {"+13", "+13"}, // Etc/GMT-13 + "UTC" = {"UTC", "UTC"}, // Etc/UTC + "Astrakhan Standard Time" = {"+04", "+04"}, // Europe/Astrakhan + "W. Europe Standard Time" = {"CET", "CEST"}, // Europe/Berlin + "GTB Standard Time" = {"EET", "EEST"}, // Europe/Bucharest + "Central Europe Standard Time" = {"CET", "CEST"}, // Europe/Budapest + "E. Europe Standard Time" = {"EET", "EEST"}, // Europe/Chisinau + "Turkey Standard Time" = {"+03", "+03"}, // Europe/Istanbul + "Kaliningrad Standard Time" = {"EET", "EET"}, // Europe/Kaliningrad + "FLE Standard Time" = {"EET", "EEST"}, // Europe/Kiev + "GMT Standard Time" = {"GMT", "BST"}, // Europe/London + "Belarus Standard Time" = {"+03", "+03"}, // Europe/Minsk + "Russian Standard Time" = {"MSK", "MSK"}, // Europe/Moscow + "Romance Standard Time" = {"CET", "CEST"}, // Europe/Paris + "Russia Time Zone 3" = {"+04", "+04"}, // Europe/Samara + "Saratov Standard Time" = {"+04", "+04"}, // Europe/Saratov + "Volgograd Standard Time" = {"MSK", "MSK"}, // Europe/Volgograd + "Central European Standard Time" = {"CET", "CEST"}, // Europe/Warsaw + "Mauritius Standard Time" = {"+04", "+04"}, // Indian/Mauritius + "Samoa Standard Time" = {"+13", "+13"}, // Pacific/Apia + "New Zealand Standard Time" = {"NZST", "NZDT"}, // Pacific/Auckland + "Bougainville Standard Time" = {"+11", "+11"}, // Pacific/Bougainville + "Chatham Islands Standard Time" = {"+1245", "+1345"}, // Pacific/Chatham + "Easter Island Standard Time" = {"-06", "-05"}, // Pacific/Easter + "Fiji Standard Time" = {"+12", "+12"}, // Pacific/Fiji + "Central Pacific Standard Time" = {"+11", "+11"}, // Pacific/Guadalcanal + "Hawaiian Standard Time" = {"HST", "HST"}, // Pacific/Honolulu + "Line Islands Standard Time" = {"+14", "+14"}, // Pacific/Kiritimati + "Marquesas Standard Time" = {"-0930", "-0930"}, // Pacific/Marquesas + "Norfolk Standard Time" = {"+11", "+12"}, // Pacific/Norfolk + "West Pacific Standard Time" = {"+10", "+10"}, // Pacific/Port_Moresby + "Tonga Standard Time" = {"+13", "+13"}, // Pacific/Tongatapu +} + +iana_to_windows_tz :: proc(iana_name: string, allocator := context.allocator) -> (name: string, success: bool) { + wintz_name_buffer: [128]u16 + status: windows.UError + + iana_name_wstr := windows.utf8_to_wstring(iana_name, allocator) + defer free(iana_name_wstr, allocator) + + wintz_name_len := windows.ucal_getWindowsTimeZoneID(iana_name_wstr, -1, raw_data(wintz_name_buffer[:]), len(wintz_name_buffer), &status) + if status != .U_ZERO_ERROR { + return + } + + wintz_name, err := windows.utf16_to_utf8(wintz_name_buffer[:wintz_name_len], allocator) + if err != nil { + return + } + + return wintz_name, true +} + +local_tz_name :: proc(allocator := context.allocator) -> (name: string, success: bool) { + iana_name_buffer: [128]u16 + status: windows.UError + + zone_str_len := windows.ucal_getDefaultTimeZone(raw_data(iana_name_buffer[:]), len(iana_name_buffer), &status) + if status != .U_ZERO_ERROR { + return + } + + iana_name, err := windows.utf16_to_utf8(iana_name_buffer[:zone_str_len], allocator) + if err != nil { + return + } + + return iana_name, true +} + +REG_TZI_FORMAT :: struct #packed { + bias: windows.LONG, + std_bias: windows.LONG, + dst_bias: windows.LONG, + std_date: windows.SYSTEMTIME, + dst_date: windows.SYSTEMTIME, +} + +generate_rrule_from_tzi :: proc(tzi: ^REG_TZI_FORMAT, abbrevs: TZ_Abbrev, allocator := context.allocator) -> (rrule: datetime.TZ_RRule, ok: bool) { + std_name, err := strings.clone(abbrevs.std, allocator) + if err != nil { return } + defer if err != nil { delete(std_name, allocator) } + + dst_name: string + dst_name, err = strings.clone(abbrevs.dst, allocator) + if err != nil { return } + defer if err != nil { delete(dst_name, allocator) } + + return datetime.TZ_RRule{ + has_dst = true, + + std_name = std_name, + std_offset = -(i64(tzi.bias) + i64(tzi.std_bias)) * 60, + dst_date = datetime.TZ_Transition_Date{ + type = .Month_Week_Day, + month = u8(tzi.std_date.month), + week = u8(tzi.std_date.day), + day = tzi.std_date.day_of_week, + time = (i64(tzi.std_date.hour) * 60 * 60) + (i64(tzi.std_date.minute) * 60) + i64(tzi.std_date.second), + }, + + dst_name = dst_name, + dst_offset = -(i64(tzi.bias) + i64(tzi.dst_bias)) * 60, + std_date = datetime.TZ_Transition_Date{ + type = .Month_Week_Day, + month = u8(tzi.dst_date.month), + week = u8(tzi.dst_date.day), + day = tzi.dst_date.day_of_week, + time = (i64(tzi.dst_date.hour) * 60 * 60) + (i64(tzi.dst_date.minute) * 60) + i64(tzi.dst_date.second), + }, + }, true +} + +_region_load :: proc(reg_str: string, allocator := context.allocator) -> (out_reg: ^datetime.TZ_Region, success: bool) { + wintz_name: string + iana_name: string + + if reg_str == "local" { + ok := false + + iana_name = local_tz_name(allocator) or_return + wintz_name, ok = iana_to_windows_tz(iana_name, allocator) + if !ok { + delete(iana_name, allocator) + return + } + } else { + wintz_name = iana_to_windows_tz(reg_str, allocator) or_return + iana_name = strings.clone(reg_str, allocator) + } + defer delete(wintz_name, allocator) + defer delete(iana_name, allocator) + + abbrevs := tz_abbrevs[wintz_name] or_return + if abbrevs.std == "UTC" && abbrevs.dst == abbrevs.std { + return nil, true + } + + key_base := `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Time Zones` + tz_key := strings.join({key_base, wintz_name}, "\\", allocator = allocator) + defer delete(tz_key, allocator) + + tz_key_wstr := windows.utf8_to_wstring(tz_key, allocator) + defer free(tz_key_wstr, allocator) + + key: windows.HKEY + res := windows.RegOpenKeyExW(windows.HKEY_LOCAL_MACHINE, tz_key_wstr, 0, windows.KEY_READ, &key) + if res != 0 { return } + defer windows.RegCloseKey(key) + + tzi: REG_TZI_FORMAT + size := u32(size_of(REG_TZI_FORMAT)) + + res = windows.RegGetValueW(key, nil, windows.L("TZI"), windows.RRF_RT_ANY, nil, &tzi, &size) + if res != 0 { + return + } + + rrule := generate_rrule_from_tzi(&tzi, abbrevs, allocator) or_return + + region_name, err := strings.clone(iana_name, allocator) + if err != nil { return } + defer if err != nil { delete(region_name, allocator) } + + region: ^datetime.TZ_Region + region, err = new_clone(datetime.TZ_Region{ + name = region_name, + rrule = rrule, + }, allocator) + if err != nil { return } + + return region, true +} diff --git a/core/time/timezone/tzdate.odin b/core/time/timezone/tzdate.odin new file mode 100644 index 000000000..8f83d1bf4 --- /dev/null +++ b/core/time/timezone/tzdate.odin @@ -0,0 +1,339 @@ +package timezone + +import "core:fmt" +import "core:slice" +import "core:time" +import "core:time/datetime" + +region_load :: proc(reg: string, allocator := context.allocator) -> (out_reg: ^datetime.TZ_Region, ok: bool) { + return _region_load(reg, allocator) +} + +region_load_from_file :: proc(file_path, reg: string, allocator := context.allocator) -> (out_reg: ^datetime.TZ_Region, ok: bool) { + return load_tzif_file(file_path, reg, allocator) +} + +region_load_from_buffer :: proc(buffer: []u8, reg: string, allocator := context.allocator) -> (out_reg: ^datetime.TZ_Region, ok: bool) { + return parse_tzif(buffer, reg, allocator) +} + +rrule_destroy :: proc(rrule: datetime.TZ_RRule, allocator := context.allocator) { + delete(rrule.std_name, allocator) + delete(rrule.dst_name, allocator) +} + +region_destroy :: proc(region: ^datetime.TZ_Region, allocator := context.allocator) { + if region == nil { + return + } + + for name in region.shortnames { + delete(name, allocator) + } + delete(region.shortnames, allocator) + delete(region.records, allocator) + delete(region.name, allocator) + rrule_destroy(region.rrule, allocator) + free(region, allocator) +} + + +@private +region_get_nearest :: proc(region: ^datetime.TZ_Region, tm: time.Time) -> (out: datetime.TZ_Record, success: bool) { + if len(region.records) == 0 { + return process_rrule(region.rrule, tm) + } + + n := len(region.records) + left, right := 0, n + + tm_sec := time.to_unix_seconds(tm) + last_time := region.records[len(region.records)-1].time + if tm_sec > last_time { + return process_rrule(region.rrule, tm) + } + + for left < right { + mid := int(uint(left+right) >> 1) + if region.records[mid].time < tm_sec { + left = mid + 1 + } else { + right = mid + } + } + + idx := max(0, left-1) + return region.records[idx], true +} + +@private +month_to_seconds :: proc(month: int, is_leap: bool) -> i64 { + month_seconds := []i64{ + 0, 31 * 86_400, 59 * 86_400, 90 * 86_400, + 120 * 86_400, 151 * 86_400, 181 * 86_400, 212 * 86_400, + 243 * 86_400, 273 * 86_400, 304 * 86_400, 334 * 86_400, + } + + t := month_seconds[month] + if is_leap && month >= 2 { + t += 86_400 + } + return t +} + +@private +trans_date_to_seconds :: proc(year: i64, td: datetime.TZ_Transition_Date) -> (secs: i64, ok: bool) { + is_leap := datetime.is_leap_year(year) + DAY_SEC :: 86_400 + + year_start := datetime.DateTime{{year, 1, 1}, {0, 0, 0, 0}, nil} + year_start_time := time.datetime_to_time(year_start) or_return + + t := i64(time.to_unix_seconds(year_start_time)) + + switch td.type { + case .Month_Week_Day: + t += month_to_seconds(int(td.month) - 1, is_leap) + + weekday := ((t + (4 * DAY_SEC)) %% (7 * DAY_SEC)) / DAY_SEC + days := i64(td.day) - weekday + if days < 0 { days += 7 } + + month_daycount, err := datetime.last_day_of_month(year, td.month) + if err != nil { return } + + week := td.week + if week == 5 && days + 28 >= i64(month_daycount) { + week = 4 + } + + t += DAY_SEC * (days + (7 * i64(week - 1))) + t += td.time + + return t, true + + // Both of these should result in 0 -> 365 days (in seconds) + case .No_Leap: + day := i64(td.day) + + // if before Feb 29th || not a leap year + if day < 60 || !is_leap { + day -= 1 + } + t += DAY_SEC * day + + return t, true + + case .Leap: + t += DAY_SEC * i64(td.day) + + return t, true + + case: + return + } + + return +} + +@private +process_rrule :: proc(rrule: datetime.TZ_RRule, tm: time.Time) -> (out: datetime.TZ_Record, success: bool) { + if !rrule.has_dst { + return datetime.TZ_Record{ + time = time.to_unix_seconds(tm), + utc_offset = rrule.std_offset, + shortname = rrule.std_name, + dst = false, + }, true + } + + y, _, _ := time.date(tm) + std_secs := trans_date_to_seconds(i64(y), rrule.std_date) or_return + dst_secs := trans_date_to_seconds(i64(y), rrule.dst_date) or_return + + records := []datetime.TZ_Record{ + { + time = std_secs, + utc_offset = rrule.std_offset, + shortname = rrule.std_name, + dst = false, + }, + { + time = dst_secs, + utc_offset = rrule.dst_offset, + shortname = rrule.dst_name, + dst = true, + }, + } + record_sort_proc :: proc(i, j: datetime.TZ_Record) -> bool { + return i.time > j.time + } + slice.sort_by(records, record_sort_proc) + + tm_sec := time.to_unix_seconds(tm) + for record in records { + if tm_sec < record.time { + return record, true + } + } + + return records[len(records)-1], true +} + +datetime_to_utc :: proc(dt: datetime.DateTime) -> (out: datetime.DateTime, success: bool) #optional_ok { + if dt.tz == nil { + return dt, true + } + + tm := time.datetime_to_time(dt) or_return + record := region_get_nearest(dt.tz, tm) or_return + + secs := time.time_to_unix(tm) + adj_time := time.unix(secs - record.utc_offset, 0) + adj_dt := time.time_to_datetime(adj_time) or_return + return adj_dt, true +} + +/* +Converts a datetime on one timezone to another timezone + +Inputs: +- dt: The input datetime +- tz: The timezone to convert to + +NOTE: tz will be referenced in the result datetime, so it must stay alive/allocated as long as it is used +Returns: +- out: The converted datetime +- success: `false` if the datetime was invalid +*/ +datetime_to_tz :: proc(dt: datetime.DateTime, tz: ^datetime.TZ_Region) -> (out: datetime.DateTime, success: bool) #optional_ok { + dt := dt + if dt.tz == tz { + return dt, true + } + if dt.tz != nil { + dt = datetime_to_utc(dt) + } + if tz == nil { + return dt, true + } + + tm := time.datetime_to_time(dt) or_return + record := region_get_nearest(tz, tm) or_return + + secs := time.time_to_unix(tm) + adj_time := time.unix(secs + record.utc_offset, 0) + adj_dt := time.time_to_datetime(adj_time) or_return + adj_dt.tz = tz + + return adj_dt, true +} + +/* +Gets the timezone abbreviation/shortname for a given date. +(ex: "PDT") + +Inputs: +- dt: The datetime containing the date, time, and timezone pointer for the lookup + +NOTE: The lifetime of name matches the timezone it was pulled from. +Returns: +- name: The timezone abbreviation +- success: returns `false` if the passed datetime is invalid +*/ +shortname :: proc(dt: datetime.DateTime) -> (name: string, success: bool) #optional_ok { + tm := time.datetime_to_time(dt) or_return + if dt.tz == nil { return "UTC", true } + + record := region_get_nearest(dt.tz, tm) or_return + return record.shortname, true +} + +/* +Gets the timezone abbreviation/shortname for a given date. +(ex: "PDT") + +WARNING: This is unsafe because it doesn't check if your datetime is valid or if your region contains a valid record. + +Inputs: +- dt: The input datetime + +NOTE: The lifetime of name matches the timezone it was pulled from. +Returns: +- name: The timezone abbreviation +*/ +shortname_unsafe :: proc(dt: datetime.DateTime) -> string { + if dt.tz == nil { return "UTC" } + + tm, _ := time.datetime_to_time(dt) + record, _ := region_get_nearest(dt.tz, tm) + return record.shortname +} + +/* +Checks DST for a given date. + +Inputs: +- dt: The input datetime + +Returns: +- is_dst: returns `true` if dt is in daylight savings time, `false` if not +- success: returns `false` if the passed datetime is invalid +*/ +dst :: proc(dt: datetime.DateTime) -> (is_dst: bool, success: bool) #optional_ok { + tm := time.datetime_to_time(dt) or_return + if dt.tz == nil { return false, true } + + record := region_get_nearest(dt.tz, tm) or_return + return record.dst, true +} + +/* +Checks DST for a given date. + +WARNING: This is unsafe because it doesn't check if your datetime is valid or if your region contains a valid record. + +Inputs: +- dt: The input datetime + +Returns: +- is_dst: returns `true` if dt is in daylight savings time, `false` if not +*/ +dst_unsafe :: proc(dt: datetime.DateTime) -> bool { + if dt.tz == nil { return false } + + tm, _ := time.datetime_to_time(dt) + record, _ := region_get_nearest(dt.tz, tm) + return record.dst +} + +datetime_to_str :: proc(dt: datetime.DateTime, allocator := context.allocator) -> string { + if dt.tz == nil { + _, ok := time.datetime_to_time(dt) + if !ok { + return "" + } + + return fmt.aprintf("%02d-%02d-%04d @ %02d:%02d:%02d UTC", dt.month, dt.day, dt.year, dt.hour, dt.minute, dt.second, allocator = allocator) + + } else { + tm, ok := time.datetime_to_time(dt) + if !ok { + return "" + } + + record, ok2 := region_get_nearest(dt.tz, tm) + if !ok2 { + return "" + } + + hour := dt.hour + am_pm_str := "AM" + if hour > 12 { + am_pm_str = "PM" + hour -= 12 + } + + return fmt.aprintf("%02d-%02d-%04d @ %02d:%02d:%02d %s %s", dt.month, dt.day, dt.year, hour, dt.minute, dt.second, am_pm_str, record.shortname, allocator = allocator) + } +} diff --git a/core/time/timezone/tzif.odin b/core/time/timezone/tzif.odin new file mode 100644 index 000000000..609cbda73 --- /dev/null +++ b/core/time/timezone/tzif.odin @@ -0,0 +1,652 @@ +package timezone + +import "base:intrinsics" + +import "core:slice" +import "core:strings" +import "core:os" +import "core:strconv" +import "core:time/datetime" + +// Implementing RFC8536 [https://datatracker.ietf.org/doc/html/rfc8536] + +TZIF_MAGIC :: u32be(0x545A6966) // 'TZif' +TZif_Version :: enum u8 { + V1 = 0, + V2 = '2', + V3 = '3', + V4 = '4', +} +BIG_BANG_ISH :: -0x800000000000000 + +TZif_Header :: struct #packed { + magic: u32be, + version: TZif_Version, + reserved: [15]u8, + isutcnt: u32be, + isstdcnt: u32be, + leapcnt: u32be, + timecnt: u32be, + typecnt: u32be, + charcnt: u32be, +} + +Sun_Shift :: enum u8 { + Standard = 0, + DST = 1, +} + +Local_Time_Type :: struct #packed { + utoff: i32be, + dst: Sun_Shift, + idx: u8, +} + +Leapsecond_Record :: struct #packed { + occur: i64be, + corr: i32be, +} + +@private +tzif_data_block_size :: proc(hdr: ^TZif_Header, version: TZif_Version) -> (block_size: int, ok: bool) { + time_size : int + + if version == .V1 { + time_size = 4 + } else if version == .V2 || version == .V3 || version == .V4 { + time_size = 8 + } else { + return + } + + return (int(hdr.timecnt) * time_size) + + int(hdr.timecnt) + + int(hdr.typecnt * size_of(Local_Time_Type)) + + int(hdr.charcnt) + + (int(hdr.leapcnt) * (time_size + 4)) + + int(hdr.isstdcnt) + + int(hdr.isutcnt), true +} + + +load_tzif_file :: proc(filename: string, region_name: string, allocator := context.allocator) -> (out: ^datetime.TZ_Region, ok: bool) { + tzif_data := os.read_entire_file_from_filename(filename, allocator) or_return + defer delete(tzif_data, allocator) + return parse_tzif(tzif_data, region_name, allocator) +} + +@private +is_alphabetic :: proc(ch: u8) -> bool { + // ('A' -> 'Z') || ('a' -> 'z') + return (ch > 0x40 && ch < 0x5B) || (ch > 0x60 && ch < 0x7B) +} + +@private +is_numeric :: proc(ch: u8) -> bool { + // ('0' -> '9') + return (ch > 0x2F && ch < 0x3A) +} + +@private +is_alphanumeric :: proc(ch: u8) -> bool { + return is_alphabetic(ch) || is_numeric(ch) +} + +@private +is_valid_quoted_char :: proc(ch: u8) -> bool { + return is_alphabetic(ch) || is_numeric(ch) || ch == '+' || ch == '-' +} + +@private +parse_posix_tz_shortname :: proc(str: string) -> (out: string, idx: int, ok: bool) { + was_quoted := false + quoted := false + i := 0 + + for ; i < len(str); i += 1 { + ch := str[i] + + if !quoted && ch == '<' { + quoted = true + was_quoted = true + continue + } + + if quoted && ch == '>' { + quoted = false + break + } + + if !is_valid_quoted_char(ch) && ch != ',' { + return + } + + if !quoted && !is_alphabetic(ch) { + break + } + } + + // If we didn't see the trailing quote + if was_quoted && quoted { + return + } + + out_str: string + end_idx := i + if was_quoted { + end_idx += 1 + out_str = str[1:i] + } else { + out_str = str[:i] + } + + return out_str, end_idx, true +} + +@private +parse_posix_tz_offset :: proc(str: string) -> (out_sec: i64, idx: int, ok: bool) { + str := str + + sign : i64 = 1 + start_idx := 0 + i := 0 + if str[i] == '+' { + i += 1 + sign = 1 + start_idx = 1 + } else if str[i] == '-' { + i += 1 + sign = -1 + start_idx = 1 + } + + got_more_time := false + for ; i < len(str); i += 1 { + if is_numeric(str[i]) { + continue + } + + if str[i] == ':' { + got_more_time = true + break + } + + break + } + + ret_sec : i64 = 0 + hours := strconv.parse_int(str[start_idx:i], 10) or_return + if hours > 167 || hours < -167 { + return + } + ret_sec += i64(hours) * (60 * 60) + if !got_more_time { + return ret_sec * sign, i, true + } + + i += 1 + start_idx = i + + got_more_time = false + for ; i < len(str); i += 1 { + if is_numeric(str[i]) { + continue + } + + if str[i] == ':' { + got_more_time = true + break + } + + break + } + + mins_str := str[start_idx:i] + if len(mins_str) != 2 { + return + } + + mins := strconv.parse_int(mins_str, 10) or_return + if mins > 59 || mins < 0 { + return + } + ret_sec += i64(mins) * 60 + if !got_more_time { + return ret_sec * sign, i, true + } + + i += 1 + start_idx = i + + for ; i < len(str); i += 1 { + if !is_numeric(str[i]) { + break + } + } + secs_str := str[start_idx:i] + if len(secs_str) != 2 { + return + } + + secs := strconv.parse_int(secs_str, 10) or_return + if secs > 59 || secs < 0 { + return + } + ret_sec += i64(secs) + return ret_sec * sign, i, true +} + +@private +skim_digits :: proc(str: string) -> (out: string, idx: int, ok: bool) { + i := 0 + for ; i < len(str); i += 1 { + ch := str[i] + if ch == '.' || ch == '/' || ch == ',' { + break + } + + if !is_numeric(ch) { + return + } + } + + return str[:i], i, true +} + +TWO_AM :: 2 * 60 * 60 +parse_posix_rrule :: proc(str: string) -> (out: datetime.TZ_Transition_Date, idx: int, ok: bool) { + str := str + if len(str) < 2 { return } + + i := 0 + // No leap + if str[i] == 'J' { + i += 1 + + day_str, off := skim_digits(str[i:]) or_return + i += off + + day := strconv.parse_int(day_str, 10) or_return + if day < 1 || day > 365 { return } + + offset : i64 = TWO_AM + if len(str) != i && str[i] == '/' { + i += 1 + + offset, off = parse_posix_tz_offset(str[i:]) or_return + i += off + } + + if len(str) != i && str[i] == ',' { + i += 1 + } + + return datetime.TZ_Transition_Date{ + type = .No_Leap, + day = u16(day), + time = offset, + }, i, true + + // Leap + } else if is_numeric(str[i]) { + day_str, off := skim_digits(str[i:]) or_return + i += off + + day := strconv.parse_int(day_str, 10) or_return + if day < 0 || day > 365 { return } + + offset : i64 = TWO_AM + if len(str) != i && str[i] == '/' { + i += 1 + + offset, off = parse_posix_tz_offset(str[i:]) or_return + i += off + } + + if len(str) != i && str[i] == ',' { + i += 1 + } + + return datetime.TZ_Transition_Date{ + type = .Leap, + day = u16(day), + time = offset, + }, i, true + + } else if str[i] == 'M' { + i += 1 + + month_str, week_str, day_str: string + off := 0 + + month_str, off = skim_digits(str[i:]) or_return + i += off + 1 + + week_str, off = skim_digits(str[i:]) or_return + i += off + 1 + + day_str, off = skim_digits(str[i:]) or_return + i += off + + month := strconv.parse_int(month_str, 10) or_return + if month < 1 || month > 12 { return } + + week := strconv.parse_int(week_str, 10) or_return + if week < 1 || week > 5 { return } + + day := strconv.parse_int(day_str, 10) or_return + if day < 0 || day > 6 { return } + + offset : i64 = TWO_AM + if len(str) != i && str[i] == '/' { + i += 1 + + offset, off = parse_posix_tz_offset(str[i:]) or_return + i += off + } + + if len(str) != i && str[i] == ',' { + i += 1 + } + + return datetime.TZ_Transition_Date{ + type = .Month_Week_Day, + month = u8(month), + week = u8(week), + day = u16(day), + time = offset, + }, i, true + } + + return +} + +parse_posix_tz :: proc(posix_tz: string, allocator := context.allocator) -> (out: datetime.TZ_RRule, ok: bool) { + // TZ string contain at least 3 characters for the STD name, and 1 for the offset + if len(posix_tz) < 4 { + return + } + + str := posix_tz + + std_name, idx := parse_posix_tz_shortname(str) or_return + str = str[idx:] + + std_offset, idx2 := parse_posix_tz_offset(str) or_return + std_offset *= -1 + str = str[idx2:] + + std_name_str, err := strings.clone(std_name, allocator) + if err != nil { return } + defer if !ok { delete(std_name_str, allocator) } + + if len(str) == 0 { + return datetime.TZ_RRule{ + has_dst = false, + std_name = std_name_str, + std_offset = std_offset, + std_date = datetime.TZ_Transition_Date{ + type = .Leap, + day = 0, + time = TWO_AM, + }, + }, true + } + + dst_name: string + dst_offset := std_offset + (1 * 60 * 60) + if str[0] != ',' { + dst_name, idx = parse_posix_tz_shortname(str) or_return + str = str[idx:] + + if str[0] != ',' { + dst_offset, idx = parse_posix_tz_offset(str) or_return + dst_offset *= -1 + str = str[idx:] + } + } + if str[0] != ',' { return } + str = str[1:] + + std_td, idx3 := parse_posix_rrule(str) or_return + str = str[idx3:] + + dst_td, idx4 := parse_posix_rrule(str) or_return + str = str[idx4:] + + dst_name_str: string + dst_name_str, err = strings.clone(dst_name, allocator) + if err != nil { return } + + return datetime.TZ_RRule{ + has_dst = true, + + std_name = std_name_str, + std_offset = std_offset, + std_date = std_td, + + dst_name = dst_name_str, + dst_offset = dst_offset, + dst_date = dst_td, + }, true +} + +parse_tzif :: proc(_buffer: []u8, region_name: string, allocator := context.allocator) -> (out: ^datetime.TZ_Region, ok: bool) { + context.allocator = allocator + + buffer := _buffer + + // TZif is crufty. Skip the initial header. + + v1_hdr := slice.to_type(buffer, TZif_Header) or_return + if v1_hdr.magic != TZIF_MAGIC { + return + } + if v1_hdr.typecnt == 0 || v1_hdr.charcnt == 0 { + return + } + if v1_hdr.isutcnt != 0 && v1_hdr.isutcnt != v1_hdr.typecnt { + return + } + if v1_hdr.isstdcnt != 0 && v1_hdr.isstdcnt != v1_hdr.typecnt { + return + } + + // We don't bother supporting v1, it uses u32 timestamps + if v1_hdr.version == .V1 { + return + } + // We only support v2 and v3 + if v1_hdr.version != .V2 && v1_hdr.version != .V3 { + return + } + + // Skip the initial v1 block too. + first_block_size, _ := tzif_data_block_size(&v1_hdr, .V1) + if len(buffer) <= size_of(v1_hdr) + first_block_size { + return + } + buffer = buffer[size_of(v1_hdr)+first_block_size:] + + // Ok, time to parse real things + real_hdr := slice.to_type(buffer, TZif_Header) or_return + if real_hdr.magic != TZIF_MAGIC { + return + } + if real_hdr.typecnt == 0 || real_hdr.charcnt == 0 { + return + } + if real_hdr.isutcnt != 0 && real_hdr.isutcnt != real_hdr.typecnt { + return + } + if real_hdr.isstdcnt != 0 && real_hdr.isstdcnt != real_hdr.typecnt { + return + } + + // Grab the real data block + real_block_size, _ := tzif_data_block_size(&real_hdr, v1_hdr.version) + if len(buffer) <= size_of(real_hdr) + real_block_size { + return + } + buffer = buffer[size_of(real_hdr):] + + time_size := 8 + transition_times := slice.reinterpret([]i64be, buffer[:int(real_hdr.timecnt)*size_of(i64be)]) + for time in transition_times { + if time < BIG_BANG_ISH { + return + } + } + buffer = buffer[int(real_hdr.timecnt)*time_size:] + + transition_types := buffer[:int(real_hdr.timecnt)] + for type in transition_types { + if int(type) > int(real_hdr.typecnt - 1) { + return + } + } + buffer = buffer[int(real_hdr.timecnt):] + + local_time_types := slice.reinterpret([]Local_Time_Type, buffer[:int(real_hdr.typecnt)*size_of(Local_Time_Type)]) + for ltt in local_time_types { + // UT offset should be > -25 hours and < 26 hours + if int(ltt.utoff) < -89999 || int(ltt.utoff) > 93599 { + return + } + + if ltt.dst != .DST && ltt.dst != .Standard { + return + } + + if int(ltt.idx) > int(real_hdr.charcnt - 1) { + return + } + } + + buffer = buffer[int(real_hdr.typecnt) * size_of(Local_Time_Type):] + timezone_string_table := buffer[:real_hdr.charcnt] + buffer = buffer[real_hdr.charcnt:] + + leapsecond_records := slice.reinterpret([]Leapsecond_Record, buffer[:int(real_hdr.leapcnt)*size_of(Leapsecond_Record)]) + if len(leapsecond_records) > 0 { + if leapsecond_records[0].occur < 0 { + return + } + } + buffer = buffer[(int(real_hdr.leapcnt) * size_of(Leapsecond_Record)):] + + standard_wall_tags := buffer[:int(real_hdr.isstdcnt)] + buffer = buffer[int(real_hdr.isstdcnt):] + + ut_tags := buffer[:int(real_hdr.isutcnt)] + + for stdwall_tag, idx in standard_wall_tags { + ut_tag := ut_tags[idx] + + if (stdwall_tag != 0 && stdwall_tag != 1) { + return + } + if (ut_tag != 0 && ut_tag != 1) { + return + } + + if ut_tag == 1 && stdwall_tag != 1 { + return + } + } + buffer = buffer[int(real_hdr.isutcnt):] + + // Start of footer + if buffer[0] != '\n' { + return + } + buffer = buffer[1:] + + if buffer[0] == ':' { + return + } + + end_idx := 0 + for ch in buffer { + if ch == '\n' { + break + } + + if ch == 0 { + return + } + end_idx += 1 + } + footer_str := string(buffer[:end_idx]) + + // UTC is a special case, we don't need to alloc + if len(local_time_types) == 1 { + name := cstring(raw_data(timezone_string_table[local_time_types[0].idx:])) + if name != "UTC" { + return + } + + return nil, true + } + + ltt_names, err := make([dynamic]string, 0, len(local_time_types), allocator) + if err != nil { return } + defer if err != nil { + for name in ltt_names { + delete(name, allocator) + } + delete(ltt_names) + } + + for ltt in local_time_types { + name := cstring(raw_data(timezone_string_table[ltt.idx:])) + ltt_name: string + + ltt_name, err = strings.clone_from_cstring_bounded(name, len(timezone_string_table), allocator) + if err != nil { return } + + append(<t_names, ltt_name) + } + + records: []datetime.TZ_Record + records, err = make([]datetime.TZ_Record, len(transition_times), allocator) + if err != nil { return } + defer if err != nil { delete(records, allocator) } + + for trans_time, idx in transition_times { + trans_idx := transition_types[idx] + ltt := local_time_types[trans_idx] + + records[idx] = datetime.TZ_Record{ + time = i64(trans_time), + utc_offset = i64(ltt.utoff), + shortname = ltt_names[trans_idx], + dst = bool(ltt.dst), + } + } + + rrule, ok2 := parse_posix_tz(footer_str, allocator) + if !ok2 { return } + defer if err != nil { + delete(rrule.std_name, allocator) + delete(rrule.dst_name, allocator) + } + + region_name_out: string + region_name_out, err = strings.clone(region_name, allocator) + if err != nil { return } + defer if err != nil { delete(region_name_out, allocator) } + + region: ^datetime.TZ_Region + region, err = new_clone(datetime.TZ_Region{ + records = records, + shortnames = ltt_names[:], + name = region_name_out, + rrule = rrule, + }, allocator) + if err != nil { + return + } + + return region, true +} diff --git a/tests/core/time/test_core_time.odin b/tests/core/time/test_core_time.odin index 424111aa3..93bc73789 100644 --- a/tests/core/time/test_core_time.odin +++ b/tests/core/time/test_core_time.odin @@ -3,6 +3,7 @@ package test_core_time import "core:testing" import "core:time" import dt "core:time/datetime" +import tz "core:time/timezone" is_leap_year :: time.is_leap_year @@ -349,3 +350,214 @@ date_component_roundtrip_test :: proc(t: ^testing.T, moment: dt.DateTime) { moment.year, moment.month, moment.day, moment.hour, moment.minute, moment.second, YYYY, MM, DD, hh, mm, ss, ) } + +datetime_eq :: proc(dt1: dt.DateTime, dt2: dt.DateTime) -> bool { + return ( + dt1.year == dt2.year && dt1.month == dt2.month && dt1.day == dt2.day && + dt1.hour == dt2.hour && dt1.minute == dt2.minute && dt1.second == dt2.second + ) +} + +@test +test_convert_timezone_roundtrip :: proc(t: ^testing.T) { + dst_dt, _ := dt.components_to_datetime(2024, 10, 4, 23, 47, 0) + std_dt, _ := dt.components_to_datetime(2024, 11, 4, 23, 47, 0) + + local_tz, local_load_ok := tz.region_load("local") + testing.expectf(t, local_load_ok, "Failed to load local timezone") + defer tz.region_destroy(local_tz) + + edm_tz, edm_load_ok := tz.region_load("America/Edmonton") + testing.expectf(t, edm_load_ok, "Failed to load America/Edmonton timezone") + defer tz.region_destroy(edm_tz) + + shuffle_tz :: proc(start_dt: dt.DateTime, test_tz: ^dt.TZ_Region) -> dt.DateTime { + tz_dt := tz.datetime_to_tz(start_dt, test_tz) + utc_dt := tz.datetime_to_utc(tz_dt) + return utc_dt + } + + testing.expectf(t, datetime_eq(dst_dt, shuffle_tz(dst_dt, local_tz)), "Failed to convert to/from local dst timezone") + testing.expectf(t, datetime_eq(std_dt, shuffle_tz(std_dt, local_tz)), "Failed to convert to/from local std timezone") + testing.expectf(t, datetime_eq(dst_dt, shuffle_tz(dst_dt, edm_tz)), "Failed to convert to/from Edmonton dst timezone") + testing.expectf(t, datetime_eq(std_dt, shuffle_tz(std_dt, edm_tz)), "Failed to convert to/from Edmonton std timezone") +} + +@test +test_check_timezone_metadata :: proc(t: ^testing.T) { + dst_dt, _ := dt.components_to_datetime(2024, 10, 4, 23, 47, 0) + std_dt, _ := dt.components_to_datetime(2024, 11, 4, 23, 47, 0) + + pac_tz, pac_load_ok := tz.region_load("America/Los_Angeles") + testing.expectf(t, pac_load_ok, "Failed to load America/Los_Angeles timezone") + defer tz.region_destroy(pac_tz) + + pac_dst_dt := tz.datetime_to_tz(dst_dt, pac_tz) + pac_std_dt := tz.datetime_to_tz(std_dt, pac_tz) + testing.expectf(t, tz.shortname_unsafe(pac_dst_dt) == "PDT", "Invalid timezone shortname") + testing.expectf(t, tz.shortname_unsafe(pac_std_dt) == "PST", "Invalid timezone shortname") + testing.expectf(t, tz.dst_unsafe(pac_std_dt) == false, "Expected daylight savings == false, got true") + testing.expectf(t, tz.dst_unsafe(pac_dst_dt) == true, "Expected daylight savings == true, got false") + + pac_dst_name, ok := tz.shortname(pac_dst_dt) + testing.expectf(t, ok == true, "Invalid datetime") + testing.expectf(t, pac_dst_name == "PDT", "Invalid timezone shortname") + + pac_std_name, ok2 := tz.shortname(pac_std_dt) + testing.expectf(t, ok2 == true, "Invalid datetime") + testing.expectf(t, pac_std_name == "PST", "Invalid timezone shortname") + + pac_is_dst, ok3 := tz.dst(pac_dst_dt) + testing.expectf(t, ok3 == true, "Invalid datetime") + testing.expectf(t, pac_is_dst == true, "Expected daylight savings == false, got true") + + pac_is_dst, ok3 = tz.dst(pac_std_dt) + testing.expectf(t, ok3 == true, "Invalid datetime") + testing.expectf(t, pac_is_dst == false, "Expected daylight savings == false, got true") +} + +rrule_eq :: proc(r1, r2: dt.TZ_RRule) -> (eq: bool) { + if r1.has_dst != r2.has_dst { return } + + if r1.std_name != r2.std_name { return } + if r1.std_offset != r2.std_offset { return } + if r1.std_date != r2.std_date { return } + + if r1.dst_name != r2.dst_name { return } + if r1.dst_offset != r2.dst_offset { return } + if r1.dst_date != r2.dst_date { return } + + return true +} + +@test +test_check_timezone_posix_tz :: proc(t: ^testing.T) { + correct_simple_rrule := dt.TZ_RRule{ + has_dst = false, + + std_name = "UTC", + std_offset = -(5 * 60 * 60), + std_date = dt.TZ_Transition_Date{ + type = .Leap, + day = 0, + time = 2 * 60 * 60, + }, + } + + simple_rrule, simple_rrule_ok := tz.parse_posix_tz("UTC+5") + testing.expectf(t, simple_rrule_ok, "Failed to parse posix tz") + defer tz.rrule_destroy(simple_rrule) + testing.expectf(t, rrule_eq(simple_rrule, correct_simple_rrule), "POSIX TZ parsed incorrectly") + + correct_est_rrule := dt.TZ_RRule{ + has_dst = true, + + std_name = "EST", + std_offset = -(5 * 60 * 60), + std_date = dt.TZ_Transition_Date{ + type = .Month_Week_Day, + month = 3, + week = 2, + day = 0, + time = 2 * 60 * 60, + }, + + dst_name = "EDT", + dst_offset = -(4 * 60 * 60), + dst_date = dt.TZ_Transition_Date{ + type = .Month_Week_Day, + month = 11, + week = 1, + day = 0, + time = 2 * 60 * 60, + }, + } + + est_rrule, est_rrule_ok := tz.parse_posix_tz("EST+5EDT,M3.2.0/2,M11.1.0/2") + testing.expectf(t, est_rrule_ok, "Failed to parse posix tz") + defer tz.rrule_destroy(est_rrule) + testing.expectf(t, rrule_eq(est_rrule, correct_est_rrule), "POSIX TZ parsed incorrectly") + + correct_ist_rrule := dt.TZ_RRule{ + has_dst = true, + + std_name = "IST", + std_offset = (2 * 60 * 60), + std_date = dt.TZ_Transition_Date{ + type = .Month_Week_Day, + month = 3, + week = 4, + day = 4, + time = 26 * 60 * 60, + }, + + dst_name = "IDT", + dst_offset = (3 * 60 * 60), + dst_date = dt.TZ_Transition_Date{ + type = .Month_Week_Day, + month = 10, + week = 5, + day = 0, + time = 2 * 60 * 60, + }, + } + + ist_rrule, ist_rrule_ok := tz.parse_posix_tz("IST-2IDT,M3.4.4/26,M10.5.0") + testing.expectf(t, ist_rrule_ok, "Failed to parse posix tz") + defer tz.rrule_destroy(ist_rrule) + testing.expectf(t, rrule_eq(ist_rrule, correct_ist_rrule), "POSIX TZ parsed incorrectly") + + correct_warst_rrule := dt.TZ_RRule{ + has_dst = true, + + std_name = "WART", + std_offset = -(4 * 60 * 60), + std_date = dt.TZ_Transition_Date{ + type = .No_Leap, + day = 1, + time = 0 * 60 * 60, + }, + + dst_name = "WARST", + dst_offset = -(3 * 60 * 60), + dst_date = dt.TZ_Transition_Date{ + type = .No_Leap, + day = 365, + time = 25 * 60 * 60, + }, + } + + warst_rrule, warst_rrule_ok := tz.parse_posix_tz("WART4WARST,J1/0,J365/25") + testing.expectf(t, warst_rrule_ok, "Failed to parse posix tz") + defer tz.rrule_destroy(warst_rrule) + testing.expectf(t, rrule_eq(warst_rrule, correct_warst_rrule), "POSIX TZ parsed incorrectly") + + correct_wgt_rrule := dt.TZ_RRule{ + has_dst = true, + + std_name = "WGT", + std_offset = -(3 * 60 * 60), + std_date = dt.TZ_Transition_Date{ + type = .Month_Week_Day, + month = 3, + week = 5, + day = 0, + time = -2 * 60 * 60, + }, + + dst_name = "WGST", + dst_offset = -(2 * 60 * 60), + dst_date = dt.TZ_Transition_Date{ + type = .Month_Week_Day, + month = 10, + week = 5, + day = 0, + time = -1 * 60 * 60, + }, + } + + wgt_rrule, wgt_rrule_ok := tz.parse_posix_tz("WGT3WGST,M3.5.0/-2,M10.5.0/-1") + testing.expectf(t, wgt_rrule_ok, "Failed to parse posix tz") + defer tz.rrule_destroy(wgt_rrule) + testing.expectf(t, rrule_eq(wgt_rrule, correct_wgt_rrule), "POSIX TZ parsed incorrectly") +} |