Packages
tz
0.10.0
0.28.2
0.28.1
0.28.0
0.27.3
0.27.2
0.27.1
0.27.0
0.26.6
0.26.5
0.26.4
0.26.3
0.26.2
0.26.1
0.26.0
0.25.1
0.25.0
0.24.0
0.23.0
0.22.0
0.21.1
0.21.0
0.20.1
0.20.0
0.19.0
0.17.0
0.16.2
0.16.1
0.16.0
0.15.0
0.14.0
0.13.0
0.12.0
0.11.0
0.10.0
0.9.0
0.8.2
0.8.1
0.8.0
0.7.3
0.7.2
0.7.1
0.7.0
0.6.0
0.5.0
0.4.0
0.3.0
0.2.0
0.1.0
Time zone support for Elixir
Current section
Files
Jump to
Current section
Files
lib/iana_file_parser.ex
defmodule Tz.IanaFileParser do
@moduledoc false
# https://data.iana.org/time-zones/tzdb/tz-how-to.html
@build_periods_with_ongoing_dst_changes_until_year Application.get_env(:tz, :build_time_zone_periods_with_ongoing_dst_changes_until_year, 5 + NaiveDateTime.utc_now().year)
def parse(file_path) do
records =
File.stream!(file_path)
|> strip_comments()
|> strip_empty()
|> trim()
|> Enum.to_list()
|> parse_strings_into_maps()
zones = Enum.filter(records, & &1.record_type == :zone)
rules = Enum.filter(records, & &1.record_type == :rule)
links = Enum.filter(records, & &1.record_type == :link)
{
denormalized_zone_data(zones),
denormalized_rule_data(rules, @build_periods_with_ongoing_dst_changes_until_year),
links,
Enum.filter(rules, & &1.ongoing_switch)
|> Enum.group_by(& &1.name)
}
end
defp strip_comments(stream) do
stream
|> Stream.filter(&(!Regex.match?(~r/^[\s]*#/, &1)))
|> Stream.map(&Regex.replace(~r/[\s]*#.+/, &1, ""))
end
defp strip_empty(stream) do
Stream.filter(stream, &(!Regex.match?(~r/^[\s]*\n$/, &1)))
end
defp trim(stream) do
Stream.map(stream, &String.trim(&1))
end
defp parse_strings_into_maps(strings, state \\ %{current_zone_name: nil})
defp parse_strings_into_maps([], _), do: []
defp parse_strings_into_maps([string | tail], state) do
%{current_zone_name: current_zone_name} = state
{maps, state} =
cond do
String.starts_with?(string, "Rule") ->
{parse_rule_string_into_maps(string), %{current_zone_name: nil}}
String.starts_with?(string, "Link") ->
{[parse_link_string_into_map(string)], %{current_zone_name: nil}}
String.starts_with?(string, "Zone") ->
zone = parse_zone_string_into_map(string)
{[zone], %{current_zone_name: zone.name}}
true ->
zone = parse_zone_string_into_map(string, current_zone_name)
{[zone], %{current_zone_name: current_zone_name}}
end
maps ++ parse_strings_into_maps(tail, state)
end
defp parse_rule_string_into_maps(rule_string) do
Enum.zip([
[:name, :from_year, :to_year, :_, :month, :day, :time, :local_offset_from_std_time, :letter],
tl(String.split(rule_string, ~r{\s}, trim: true, parts: 10))
|> Enum.map(& String.trim(&1))
])
|> Enum.into(%{})
|> Map.delete(:_)
|> Map.put(:record_type, :rule)
|> transform_rule()
end
defp parse_link_string_into_map(link_string) do
Enum.zip([
[:canonical_zone_name, :link_zone_name],
tl(String.split(link_string, ~r{\s}, trim: true, parts: 3))
|> Enum.map(& String.trim(&1))
])
|> Enum.into(%{})
|> Map.put(:record_type, :link)
end
defp parse_zone_string_into_map(zone_string) do
Enum.zip([
[:name, :std_offset_from_utc_time, :rules, :format_time_zone_abbr, :to],
tl(String.split(zone_string, ~r{\s}, trim: true, parts: 6))
|> Enum.map(& String.trim(&1))
])
|> Enum.into(%{})
|> Map.merge(%{to: :max}, fn _k, v1, _v2 -> v1 end)
|> Map.put(:record_type, :zone)
|> transform_zone()
end
defp parse_zone_string_into_map(zone_string, current_zone_name) do
Enum.zip([
[:name, :std_offset_from_utc_time, :rules, :format_time_zone_abbr, :to],
[
current_zone_name |
String.split(zone_string, ~r{\s}, trim: true, parts: 4)
|> Enum.map(& String.trim(&1))
]
])
|> Enum.into(%{})
|> Map.merge(%{to: :max}, fn _k, v1, _v2 -> v1 end)
|> Map.put(:record_type, :zone)
|> transform_zone()
end
defp transform_zone(%{} = zone) do
rules = String.trim(zone.rules)
rules =
cond do
rules == "-" -> 0
String.match?(rules, ~r/[+-]?[0-9]+/) ->
offset_string_to_seconds(rules)
rules -> rules
end
std_offset = offset_string_to_seconds(zone.std_offset_from_utc_time)
%{
record_type: :zone,
name: zone.name,
rules: rules,
to: parse_to_field_string(zone.to),
std_offset_from_utc_time: std_offset,
format_time_zone_abbr: zone.format_time_zone_abbr
}
end
def transform_rule(%{} = rule) do
{from_year, to_year} = year_strings_to_integers(rule.from_year, rule.to_year)
month = month_string_to_integer(rule.month)
{hour, minute, second, time_modifier} = parse_time_string(rule.time)
{ongoing_switch, to_year} =
if to_year == :max do
{true, from_year}
else
{false, to_year}
end
for year <- Range.new(from_year, to_year) do
parsed_day = parse_day_string(rule.day)
{year, month, day} = parsed_day_to_date(year, month, parsed_day)
naive_date_time = new_naive_date_time(year, month, day, hour, minute, second)
local_offset = offset_string_to_seconds(rule.local_offset_from_std_time)
%{
record_type: :rule,
from: {naive_date_time, time_modifier},
ongoing_switch: ongoing_switch,
name: rule.name,
local_offset_from_std_time: local_offset,
letter: if(rule.letter == "-", do: "", else: rule.letter),
__datetime_data: %{
date: {year, month, parsed_day},
time: {hour, minute, second, time_modifier}
}
}
end
end
def change_rule_year(rule, year, ongoing_switch \\ false)
def change_rule_year(%{to: _} = rule, year, ongoing_switch) do
rule
|> Map.put(:ongoing_switch, ongoing_switch)
|> Map.delete(:to)
|> change_rule_year(year, ongoing_switch)
end
def change_rule_year(%{} = rule, year, ongoing_switch) do
%{
date: {_, month, parsed_day},
time: {hour, minute, second, time_modifier}
} = rule.__datetime_data
{year, month, day} = parsed_day_to_date(year, month, parsed_day)
naive_date_time = new_naive_date_time(year, month, day, hour, minute, second)
%{rule |
from: {naive_date_time, time_modifier},
ongoing_switch: ongoing_switch
}
end
defp new_naive_date_time(year, month, day, 24, minute, second) do
{:ok, naive_date_time} = NaiveDateTime.new(year, month, day, 0, minute, second)
NaiveDateTime.add(naive_date_time, 86400)
end
defp new_naive_date_time(year, month, day, 25, minute, second) do
{:ok, naive_date_time} = NaiveDateTime.new(year, month, day, 1, minute, second)
NaiveDateTime.add(naive_date_time, 86400)
end
defp new_naive_date_time(year, month, day, hour, minute, second) do
{:ok, naive_date_time} = NaiveDateTime.new(year, month, day, hour, minute, second)
naive_date_time
end
defp parse_day_string(day_string) do
cond do
String.contains?(day_string, "last") ->
"last" <> day_of_week_string = day_string
day_of_week = day_of_week_string_to_integer(day_of_week_string)
{:last_dow, day_of_week}
String.contains?(day_string, "<=") ->
[day_of_week_string, on_or_before_day] = String.split(day_string, "<=", trim: true)
day_of_week = day_of_week_string_to_integer(day_of_week_string)
on_or_before_day = String.to_integer(on_or_before_day)
{:dow_equal_or_before_day, day_of_week, on_or_before_day}
String.contains?(day_string, ">=") ->
[day_of_week_string, on_or_after_day] = String.split(day_string, ">=", trim: true)
day_of_week = day_of_week_string_to_integer(day_of_week_string)
on_or_after_day = String.to_integer(on_or_after_day)
{:dow_equal_or_after_day, day_of_week, on_or_after_day}
String.match?(day_string, ~r/[0-9]+/) ->
{:day, String.to_integer(day_string)}
true ->
raise "could not parse day from rule (day to parse is \"#{day_string}\")"
end
end
defp parsed_day_to_date(year, month, parsed_day) do
case parsed_day do
{:last_dow, day_of_week} ->
day = day_at_last_given_day_of_week_of_month(year, month, day_of_week)
{year, month, day}
{:dow_equal_or_before_day, day_of_week, on_or_before_day} ->
day_at_given_day_of_week_of_month(year, month, day_of_week, :on_or_before_day, on_or_before_day)
{:dow_equal_or_after_day, day_of_week, on_or_after_day} ->
day_at_given_day_of_week_of_month(year, month, day_of_week, :on_or_after_day, on_or_after_day)
{:day, day} ->
{year, month, day}
end
end
defp parse_to_field_string(:min), do: :min
defp parse_to_field_string(:max), do: :max
defp parse_to_field_string(to_field_string) do
{year, month, day, hour, minute, second, time_modifier} =
case String.split(to_field_string) do
[year, month, day, time] ->
year = String.to_integer(year)
month = month_string_to_integer(month)
parsed_day = parse_day_string(day)
{year, month, day} = parsed_day_to_date(year, month, parsed_day)
{hour, minute, second, time_modifier} = parse_time_string(time)
{year, month, day, hour, minute, second, time_modifier}
[year, month, day] ->
year = String.to_integer(year)
month = month_string_to_integer(month)
parsed_day = parse_day_string(day)
{year, month, day} = parsed_day_to_date(year, month, parsed_day)
{year, month, day, 0, 0, 0, :wall}
[year, month] ->
year = String.to_integer(year)
month = month_string_to_integer(month)
{year, month, 1, 0, 0, 0, :wall}
[year] ->
year = String.to_integer(year)
{year, 1, 1, 0, 0, 0, :wall}
end
naive_date_time = new_naive_date_time(year, month, day, hour, minute, second)
{naive_date_time, time_modifier}
end
defp day_at_given_day_of_week_of_month(year, month, day_of_week, :on_or_before_day, on_or_before_day) do
{:ok, on_or_before_date} = Date.new(year, month, on_or_before_day)
days_to_subtract = diff_days_of_week(day_of_week, Date.day_of_week(on_or_before_date))
date = Date.add(on_or_before_date, -1 * days_to_subtract)
{date.year, date.month, date.day}
end
defp day_at_given_day_of_week_of_month(year, month, day_of_week, :on_or_after_day, on_or_after_day) do
{:ok, on_or_after_date} = Date.new(year, month, on_or_after_day)
days_to_add = diff_days_of_week(Date.day_of_week(on_or_after_date), day_of_week)
date = Date.add(on_or_after_date, days_to_add)
{date.year, date.month, date.day}
end
defp day_at_last_given_day_of_week_of_month(year, month, day_of_week) do
date_at_end_of_month = date_at_end_of_month(year, month)
days_to_subtract = diff_days_of_week(day_of_week, Date.day_of_week(date_at_end_of_month))
date = Date.add(date_at_end_of_month, -1 * days_to_subtract)
date.day
end
defp diff_days_of_week(from_day_of_week, to_day_of_week) do
rem(7 + (to_day_of_week - from_day_of_week), 7)
end
defp date_at_end_of_month(year, month) do
{:ok, date} = Date.new(year, month, 1)
last_day = Date.days_in_month(date)
{:ok, date} = Date.new(year, month, last_day)
date
end
defp year_strings_to_integers(from_year, "only") do
{String.to_integer(from_year), String.to_integer(from_year)}
end
defp year_strings_to_integers(from_year, "max") do
{String.to_integer(from_year), :max}
end
defp year_strings_to_integers(from_year, to_year) do
{String.to_integer(from_year), String.to_integer(to_year)}
end
defp month_string_to_integer(month_string) do
month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
1 + Enum.find_index(month_names, &month_string == &1)
end
defp day_of_week_string_to_integer(day_of_week_string) do
day_of_week_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
1 + Enum.find_index(day_of_week_names, &day_of_week_string == &1)
end
defp parse_time_string(time_string) do
{hour, minute, second, time_modifier} =
String.split(time_string, ~r{[:gsuz]}, include_captures: true, trim: true)
|> case do
[hour, ":", minute, ":", second, time_modifier] when time_modifier in ["g", "u", "z"] ->
{hour, minute, second, :utc}
[hour, ":", minute, time_modifier] when time_modifier in ["g", "u", "z"] ->
{hour, minute, "0", :utc}
[hour, ":", minute, ":", second, time_modifier] when time_modifier == "s" ->
{hour, minute, second, :standard}
[hour, ":", minute, time_modifier] when time_modifier == "s" ->
{hour, minute, "0", :standard}
[hour, ":", minute, ":", second] ->
{hour, minute, second, :wall}
[hour, ":", minute] ->
{hour, minute, "0", :wall}
_ ->
raise "could not parse time string \"#{time_string}\""
end
hour = String.to_integer(hour)
minute = String.to_integer(minute)
second = String.to_integer(second)
{hour, minute, second, time_modifier}
end
defp offset_string_to_seconds(offset_string) do
{is_negative, hours, minutes, seconds} =
String.split(offset_string, ~r{[:\-]}, include_captures: true, trim: true)
|> case do
["-", hours, ":", minutes, ":", seconds] ->
{true, hours, minutes, seconds}
["-", hours, ":", minutes] ->
{true, hours, minutes, "0"}
[hours, ":", minutes, ":", seconds] ->
{false, hours, minutes, seconds}
[hours, ":", minutes] ->
{false, hours, minutes, "0"}
["-", hours] ->
{true, hours, "0", "0"}
[hours] ->
{false, hours, "0", "0"}
_ ->
raise "could not parse time string \"#{offset_string}\""
end
hours = String.to_integer(hours)
minutes = String.to_integer(minutes)
seconds = String.to_integer(seconds)
total_seconds = hours * 3600 + minutes * 60 + seconds
if(is_negative, do: -1 * total_seconds, else: total_seconds)
end
defp denormalized_zone_data(zone_records) do
zone_records
|> Enum.group_by(& &1.name)
|> (fn zones_by_name ->
Enum.map(zones_by_name, fn {zone_name, zone_lines} -> {zone_name, denormalize_zone_lines(zone_lines)} end)
|> Enum.into(%{})
end).()
end
def denormalized_rule_data(rule_records, build_periods_with_ongoing_dst_changes_until_year \\ 0) do
rule_records
|> Enum.group_by(& &1.name)
|> (fn rules_by_name ->
rules_by_name
|> Enum.map(fn {rule_name, rules} -> {rule_name, denormalize_rules(rules, build_periods_with_ongoing_dst_changes_until_year)} end)
|> Enum.into(%{})
end).()
end
defp denormalize_zone_lines(zone_lines) do
zone_lines
|> Enum.with_index()
|> Enum.map(fn {zone_line, index} ->
Map.put(zone_line, :from,
cond do
index == 0 -> :min
true -> Enum.at(zone_lines, index - 1).to
end)
end)
end
defp denormalize_rules(rules, build_periods_with_ongoing_dst_changes_until_year) do
ongoing_switch_rules = Enum.filter(rules, & &1.ongoing_switch)
rules =
case ongoing_switch_rules do
[] ->
rules
[rule1, rule2] ->
last_year = Enum.max([
build_periods_with_ongoing_dst_changes_until_year,
elem(rule1.from, 0).year,
elem(rule2.from, 0).year
])
Enum.filter(rules, & !&1.ongoing_switch)
++ for year <- Range.new(elem(rule1.from, 0).year, last_year) do
change_rule_year(rule1, year)
end
++ [change_rule_year(rule1, last_year + 1, true)]
++ for year <- Range.new(elem(rule2.from, 0).year, last_year) do
change_rule_year(rule2, year)
end
++ [change_rule_year(rule2, last_year + 1, true)]
_ ->
raise "unexpected number of rules to \"max\", rules: \"#{inspect rules}\""
end
rules =
Enum.sort(rules, fn rule1, rule2 ->
naive_date_time1 = elem(rule1.from, 0)
time_modifier1 = elem(rule1.from, 1)
naive_date_time2 = elem(rule2.from, 0)
time_modifier2 = elem(rule2.from, 1)
diff = NaiveDateTime.diff(naive_date_time1, naive_date_time2)
if (abs(diff) < 86400 && time_modifier1 != time_modifier2) do
raise "unexpected case"
end
diff < 0
end)
rules
|> Enum.with_index()
|> Enum.map(fn {rule, index} ->
rule
|> Map.put(:to,
if rule.ongoing_switch do
:max
else
case Enum.at(rules, index + 1, nil) do
nil -> :max
next_rule -> next_rule.from
end
end)
|> Map.delete(:ongoing_switch)
end)
end
end