diff --git a/lib/iana_file_parser.ex b/lib/iana_file_parser.ex index 9904484..5d83cc5 100644 --- a/lib/iana_file_parser.ex +++ b/lib/iana_file_parser.ex @@ -146,7 +146,8 @@ defmodule Tz.IanaFileParser do end for year <- Range.new(from_year, to_year) do - {year, month, day} = parse_day_string(year, month, rule.day) + 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) @@ -159,11 +160,38 @@ defmodule Tz.IanaFileParser do name: rule.name, local_offset_from_std_time: local_offset, letter: if(rule.letter == "-", do: "", else: rule.letter), - raw: rule + __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) @@ -179,30 +207,43 @@ defmodule Tz.IanaFileParser do naive_date_time end - defp parse_day_string(year, month, day_string) do + 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) - day = day_at_last_given_day_of_week_of_month(year, month, day_of_week) - {year, month, day} + {: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) - day_at_given_day_of_week_of_month(year, month, day_of_week, :on_or_before_day, 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) - day_at_given_day_of_week_of_month(year, month, day_of_week, :on_or_after_day, on_or_after_day) + {:dow_equal_or_after_day, day_of_week, on_or_after_day} String.match?(day_string, ~r/[0-9]+/) -> - {year, month, String.to_integer(day_string)} + {: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 @@ -211,14 +252,16 @@ defmodule Tz.IanaFileParser do [year, month, day, time] -> year = String.to_integer(year) month = month_string_to_integer(month) - {year, month, day} = parse_day_string(year, month, day) + 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) - {year, month, day} = parse_day_string(year, month, day) + 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] -> @@ -385,11 +428,10 @@ defmodule Tz.IanaFileParser do ongoing_switch_rules = Enum.filter(rules, & &1.ongoing_switch) rules = - case length(ongoing_switch_rules) do - 0 -> + case ongoing_switch_rules do + [] -> rules - 2 -> - [rule1, rule2] = ongoing_switch_rules + [rule1, rule2] -> last_year = Enum.max([ build_periods_with_ongoing_dst_changes_until_year, elem(rule1.from, 0).year, @@ -397,20 +439,14 @@ defmodule Tz.IanaFileParser do ]) Enum.filter(rules, & !&1.ongoing_switch) - ++ (rule1.raw - |> Map.put(:to_year, "#{last_year}") - |> transform_rule()) - ++ (rule1.raw - |> Map.put(:from_year, "#{last_year + 1}") - |> Map.put(:to_year, "max") - |> transform_rule()) - ++ (rule2.raw - |> Map.put(:to_year, "#{last_year}") - |> transform_rule()) - ++ (rule2.raw - |> Map.put(:from_year, "#{last_year + 1}") - |> Map.put(:to_year, "max") - |> transform_rule()) + ++ 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 diff --git a/lib/periods_builder.ex b/lib/periods_builder.ex index 4f5fa63..c957912 100644 --- a/lib/periods_builder.ex +++ b/lib/periods_builder.ex @@ -3,14 +3,14 @@ defmodule Tz.PeriodsBuilder do def build_periods(zone_lines, rule_records, prev_period \\ nil, periods \\ []) - def build_periods([], _rule_records, _prev_period, periods), do: periods + def build_periods([], _rule_records, _prev_period, periods), do: Enum.reverse(periods) def build_periods([zone_line | rest_zone_lines], rule_records, prev_period, periods) do rules = Map.get(rule_records, zone_line.rules, zone_line.rules) new_periods = build_periods_for_zone_line(zone_line, rules, prev_period) - build_periods(rest_zone_lines, rule_records, List.last(new_periods), periods ++ new_periods) + build_periods(rest_zone_lines, rule_records, hd(new_periods), new_periods ++ periods) end defp offset_diff_from_prev_period(_zone_line, _local_offset, nil), do: 0 @@ -20,8 +20,8 @@ defmodule Tz.PeriodsBuilder do total_offset - prev_total_offset end - defp maybe_build_gap_or_overlap_period(_zone_line, _local_offset, %{to: :max}, _period), do: nil - defp maybe_build_gap_or_overlap_period(zone_line, local_offset, prev_period, period) do + defp maybe_build_gap_period(_zone_line, _local_offset, %{to: :max}, _period), do: nil + defp maybe_build_gap_period(zone_line, local_offset, prev_period, period) do offset_diff = offset_diff_from_prev_period(zone_line, local_offset, prev_period) if offset_diff > 0 do @@ -40,11 +40,6 @@ defmodule Tz.PeriodsBuilder do if period.from.utc_gregorian_seconds != prev_period.to.utc_gregorian_seconds do raise "logic error" end - - %{ - from: period.from, - to: prev_period.to - } end end end @@ -74,9 +69,9 @@ defmodule Tz.PeriodsBuilder do zone_abbr: zone_abbr(zone_line, offset) } - maybe_gap_or_overlap_period = maybe_build_gap_or_overlap_period(zone_line, offset, prev_period, period) + maybe_build_gap_period = maybe_build_gap_period(zone_line, offset, prev_period, period) - if maybe_gap_or_overlap_period, do: [maybe_gap_or_overlap_period, period], else: [period] + if maybe_build_gap_period, do: [period, maybe_build_gap_period], else: [period] end defp build_periods_for_zone_line(zone_line, rules, prev_period) when is_list(rules) do @@ -102,7 +97,7 @@ defmodule Tz.PeriodsBuilder do end defp filter_rules_for_zone_line(zone_line, rules, prev_period, prev_local_offset_from_std_time, filtered_rules \\ []) - defp filter_rules_for_zone_line(_zone_line, [], _, _, filtered_rules), do: filtered_rules + defp filter_rules_for_zone_line(_zone_line, [], _, _, filtered_rules), do: Enum.reverse(filtered_rules) defp filter_rules_for_zone_line(zone_line, [rule | rest_rules], prev_period, prev_local_offset_from_std_time, filtered_rules) do is_rule_included = cond do @@ -131,7 +126,7 @@ defmodule Tz.PeriodsBuilder do end if is_rule_included do - filter_rules_for_zone_line(zone_line, rest_rules, prev_period, rule.local_offset_from_std_time, filtered_rules ++ [rule]) + filter_rules_for_zone_line(zone_line, rest_rules, prev_period, rule.local_offset_from_std_time, [rule | filtered_rules]) else filter_rules_for_zone_line(zone_line, rest_rules, prev_period, prev_local_offset_from_std_time, filtered_rules) end @@ -151,8 +146,8 @@ defmodule Tz.PeriodsBuilder do last_rule = List.last(rules) if rule_ends_after_zone_line_range?(zone_line, last_rule) do - rules_without_last = Enum.reverse(rules) |> tl() |> Enum.reverse() - rules_without_last ++ [%{last_rule | to: zone_line.to}] + [%{last_rule | to: zone_line.to} | (Enum.reverse(rules) |> tl())] + |> Enum.reverse() else rules end @@ -246,17 +241,17 @@ defmodule Tz.PeriodsBuilder do period = if period_to == :max do period - |> Map.put(:raw_rule, rule.raw) + |> Map.put(:rule, rule) |> Map.put(:zone_line, zone_line) else period end - maybe_gap_or_overlap_period = maybe_build_gap_or_overlap_period(zone_line, rule.local_offset_from_std_time, prev_period, period) + maybe_build_gap_period = maybe_build_gap_period(zone_line, rule.local_offset_from_std_time, prev_period, period) - periods = if maybe_gap_or_overlap_period, do: periods ++ [maybe_gap_or_overlap_period], else: periods + periods = if maybe_build_gap_period, do: [maybe_build_gap_period | periods], else: periods - periods = periods ++ [period] + periods = [period | periods] do_build_periods_for_zone_line(zone_line, rest_rules, period, periods) end diff --git a/lib/time_zone_database.ex b/lib/time_zone_database.ex index 9622fd6..cfe65ac 100644 --- a/lib/time_zone_database.ex +++ b/lib/time_zone_database.ex @@ -17,11 +17,11 @@ defmodule Tz.TimeZoneDatabase do Map.get(periods_by_year, naive_datetime.year, periods_by_year.minmax) |> find_periods_for_timestamp(utc_gregorian_seconds, :utc_gregorian_seconds) - case Enum.count(found_periods) do - 1 -> - {:ok, List.first(found_periods)} - count -> - raise "#{count} periods found" + case found_periods do + [period] -> + {:ok, period} + _ -> + raise "#{Enum.count(found_periods)} periods found" end end end @@ -35,37 +35,25 @@ defmodule Tz.TimeZoneDatabase do Map.get(periods_by_year, naive_datetime.year, periods_by_year.minmax) |> find_periods_for_timestamp(wall_gregorian_seconds, :wall_gregorian_seconds) - case Enum.count(found_periods) do - 1 -> - period = List.first(found_periods) - case period do - %{zone_abbr: _} -> - {:ok, period} - %{period_before_gap: _} -> - {:gap, {period.period_before_gap, period.from.wall}, {period.period_after_gap, period.to.wall}} - end - 3 -> - {:ambiguous, Enum.at(found_periods, 0), Enum.at(found_periods, 2)} - count -> - raise "#{count} periods found" + case found_periods do + [%{zone_abbr: _} = period] -> + {:ok, period} + [%{period_before_gap: _} = period] -> + {:gap, {period.period_before_gap, period.from.wall}, {period.period_after_gap, period.to.wall}} + [second_period, first_period] -> + {:ambiguous, first_period, second_period} + _ -> + raise "#{Enum.count(found_periods)} periods found" end end end defp generate_dynamic_periods([period1, period2], year) do - rule1 = - period1.raw_rule - |> Map.put(:from_year, "#{year - 1}") - |> Map.put(:to_year, "#{year + 1}") - |> IanaFileParser.transform_rule() - - rule2 = - period2.raw_rule - |> Map.put(:from_year, "#{year - 1}") - |> Map.put(:to_year, "#{year + 1}") - |> IanaFileParser.transform_rule() - - rule_records = IanaFileParser.denormalized_rule_data(rule1 ++ rule2) + rule_records = IanaFileParser.denormalized_rule_data([ + IanaFileParser.change_rule_year(period1.rule, year - 1), + IanaFileParser.change_rule_year(period1.rule, year), + IanaFileParser.change_rule_year(period2.rule, year) + ]) PeriodsBuilder.build_periods([period1.zone_line], rule_records) |> PeriodsBuilder.shrink_and_reverse_periods() @@ -76,52 +64,29 @@ defmodule Tz.TimeZoneDatabase do |> maybe_generate_dynamic_periods(periods, timestamp, time_modifier) end - defp do_find_periods_for_timestamp(periods, timestamp, time_modifier, periods_found \\ []) - - defp do_find_periods_for_timestamp([], _timestamp, _, periods_found), do: periods_found - - defp do_find_periods_for_timestamp([period | rest_periods], timestamp, time_modifier, periods_found) do - period_from = if(period.from == :min, do: :min, else: period.from[time_modifier]) - period_to = if(period.to == :max, do: :max, else: period.to[time_modifier]) - - periods_found = - if is_timestamp_in_range?(timestamp, period_from, period_to) do - [period | periods_found] - else - periods_found - end + defp do_find_periods_for_timestamp(periods, timestamp, time_modifier) do + Enum.filter(periods, fn period -> + period_from = if(period.from == :min, do: :min, else: period.from[time_modifier]) + period_to = if(period.to == :max, do: :max, else: period.to[time_modifier]) - if is_timestamp_after_or_equal_date?(timestamp - 86400, period_from) do - periods_found - else - do_find_periods_for_timestamp(rest_periods, timestamp, time_modifier, periods_found) - end + is_timestamp_in_range?(timestamp, period_from, period_to) + end) end - defp is_timestamp_after_or_equal_date?(_, :min), do: true - defp is_timestamp_after_or_equal_date?(_, :max), do: false - defp is_timestamp_after_or_equal_date?(timestamp, date), do: timestamp >= date - defp is_timestamp_in_range?(_, :min, :max), do: true defp is_timestamp_in_range?(timestamp, :min, date_to), do: timestamp < date_to defp is_timestamp_in_range?(timestamp, date_from, :max), do: timestamp >= date_from defp is_timestamp_in_range?(timestamp, date_from, date_to), do: timestamp >= date_from && timestamp < date_to - defp maybe_generate_dynamic_periods(found_periods, periods, timestamp, time_modifier) do - if Enum.any?(found_periods, & &1.to == :max) do - two_first_periods = Enum.take(periods, 2) - - if Enum.count(two_first_periods, & &1.to == :max) == 2 do - year = NaiveDateTime.add(~N[0000-01-01 00:00:00], timestamp).year - two_first_periods - |> generate_dynamic_periods(year) - |> do_find_periods_for_timestamp(timestamp, time_modifier) - else - found_periods - end - else - found_periods - end + defp maybe_generate_dynamic_periods([%{to: :max} | _], [%{to: :max} = p1, %{to: :max} = p2 | _], timestamp, time_modifier) do + year = NaiveDateTime.add(~N[0000-01-01 00:00:00], timestamp).year + [p1, p2] + |> generate_dynamic_periods(year) + |> do_find_periods_for_timestamp(timestamp, time_modifier) + end + + defp maybe_generate_dynamic_periods(found_periods, _periods, _timestamp, _time_modifier) do + found_periods end defp naive_datetime_from_iso_days(iso_days) do diff --git a/test/time_zone_database_test.exs b/test/time_zone_database_test.exs index 31c9614..108bdb8 100644 --- a/test/time_zone_database_test.exs +++ b/test/time_zone_database_test.exs @@ -79,6 +79,15 @@ defmodule TimeZoneDatabaseTest do assert {:error, :time_zone_not_found} = result end + test "far future date" do + naive_date_time = ~N[2043-12-18 12:30:00] + time_zone = "Europe/Brussels" + + result = DateTime.from_naive(naive_date_time, time_zone, Tz.TimeZoneDatabase) + + assert {:ok, datetime} = result + end + test "version" do assert Regex.match?(~r/^20[0-9]{2}[a-z]$/, Tz.version()) end