diff --git a/lib/ical/deserialize.ex b/lib/ical/deserialize.ex index 33e91e6..d7089f9 100644 --- a/lib/ical/deserialize.ex +++ b/lib/ical/deserialize.ex @@ -432,32 +432,14 @@ defmodule ICal.Deserialize do end @doc """ - This function is designed to parse iCal datetime strings into erlang dates. + This function is designed to parse iCal datetime strings into date/times. - It should be able to handle dates from the past: + It respects TZID and VALUE parameters for properties, which can control the type and + location of the resulting date/time. - iex> {:ok, date} = ICal.Deserialize.to_date("19930407T153022Z") - ...> Timex.to_erl(date) - {{1993, 4, 7}, {15, 30, 22}} - - As well as the future: - - iex> {:ok, date} = ICal.Deserialize.to_date("39930407T153022Z") - ...> Timex.to_erl(date) - {{3993, 4, 7}, {15, 30, 22}} - - And should return error for incorrect dates: - - iex> ICal.Util.Deserialize.to_date("1993/04/07") - {:error, "Expected `2 digit month` at line 1, column 5."} - - It should handle timezones from the Olson Database: - - iex> {:ok, date} = ICal.Deserialize.to_date("19980119T020000", - ...> %{"TZID" => "America/Chicago"}) - ...> [Timex.to_erl(date), date.time_zone] - [{{1998, 1, 19}, {2, 0, 0}}, "America/Chicago"] + It returns `nil` for ill-formed dates or datetime strings. """ + @spec to_date(String.t() | nil, map, ICal.t()) :: Date.t() | DateTime.t() | nil def to_date(nil, _params, _calendar), do: nil def to_date(date_string, %{"TZID" => timezone}, %ICal{default_timezone: default_timezone}) do @@ -468,8 +450,14 @@ defmodule ICal.Deserialize do end def to_date(date_string, %{"VALUE" => "DATE"}, _calendar) do - case Timex.parse(date_string, "{YYYY}{0M}{0D}") do - {:ok, date} -> NaiveDateTime.to_date(date) + # of the form {YYYY}{MM}{DD} + with <> <- date_string, + {year, ""} <- Integer.parse(y), + {month, ""} <- Integer.parse(m), + {day, ""} <- Integer.parse(d), + {:ok, date} <- Date.new(year, month, day) do + date + else _ -> nil end end @@ -479,22 +467,54 @@ defmodule ICal.Deserialize do end def to_date_in_timezone(date_string, timezone) do - with_timezone = - if String.ends_with?(date_string, "Z") do - date_string <> timezone - else - date_string <> "Z" <> timezone + # datetime in the form "{YYYY}{0M}{0D}T{h24}{m}{s}Z{Zname}" + with <> + when rest == "" or rest == "Z" <- date_string, + {year, ""} <- Integer.parse(y), + {month, ""} <- Integer.parse(m), + {day, ""} <- Integer.parse(d), + {hour, ""} <- Integer.parse(t_h), + {minute, ""} <- Integer.parse(t_m), + {second, ""} <- Integer.parse(t_s), + {:ok, date} <- Date.new(year, month, day), + {:ok, time} <- Time.new(hour, minute, second) do + # RFC 5545 §3.3.5 defines how DST edge cases should be handled: + # + # Ambiguous (fall-back, clocks go back — time occurs twice): "the + # DATE-TIME value refers to the first occurrence of the referenced + # time." The first occurrence is the daylight (pre-transition) instant. + # + # Gap (spring-forward, clocks go forward — time never exists): "the + # DATE-TIME value is interpreted using the UTC offset before the gap." + # e.g. 2:30 AM in a spring-forward gap → apply pre-gap offset (EST) to + # get UTC, then express in the post-gap offset (EDT) → 3:30 AM EDT. + case DateTime.new(date, time, timezone) do + {:ok, dt} -> dt + {:ambiguous, first, _second} -> first + {:gap, just_before, just_after} -> adjust_to_gap(time, just_before, just_after) + _ -> nil end - - case Timex.parse(with_timezone, "{YYYY}{0M}{0D}T{h24}{m}{s}Z{Zname}") do - {:ok, date} -> date + else _ -> nil end end + @doc "Parses a local date string as a NaiveDatetime, returning `nil` on failure" + @spec to_local_date(String.t()) :: NaiveDateTime.t() | nil def to_local_date(date_string) do - case Timex.parse(date_string, "{YYYY}{0M}{0D}T{h24}{m}{s}") do - {:ok, date} -> date + # datetime in the form "{YYYY}{0M}{0D}T{h24}{m}{s}" + with <> <- date_string, + {year, ""} <- Integer.parse(y), + {month, ""} <- Integer.parse(m), + {day, ""} <- Integer.parse(d), + {hour, ""} <- Integer.parse(t_h), + {minute, ""} <- Integer.parse(t_m), + {second, ""} <- Integer.parse(t_s), + {:ok, datetime} <- NaiveDateTime.new(year, month, day, hour, minute, second) do + datetime + else _ -> nil end end @@ -522,4 +542,28 @@ defmodule ICal.Deserialize do def status(%ICal.Journal{}, "FINAL"), do: :final def status(_, "CANCELLED"), do: :cancelled def status(_, _), do: nil + + defp adjust_to_gap(original_time, before_gap, after_gap) do + before_gap = + before_gap + |> DateTime.to_time() + |> round_off_micros() + + diff = Time.diff(original_time, before_gap) + + time = Time.shift(DateTime.to_time(after_gap), second: diff) + + DateTime.new!(DateTime.to_date(after_gap), time, after_gap.time_zone) + end + + defp round_off_micros(time) do + # gap times are often 59.9999 seconds, the moment RIGHT before. + # snug those times up to the minute + {ms, precision} = time.microsecond + offset = round(ms / Integer.pow(10, precision)) + + time + |> Time.truncate(:second) + |> Time.shift(second: offset) + end end diff --git a/mix.exs b/mix.exs index b946c09..99d1da2 100644 --- a/mix.exs +++ b/mix.exs @@ -25,7 +25,8 @@ defmodule ICal.Mixfile do defp deps do [ {:timex, "~> 3.4"}, - {:tzdata, "~> 1.1", optional: true}, + {:tz, "~> 0.28", optional: true}, + {:mint, "~>1.7", optional: true}, # saxy is used to generate the windows tz -> olson names code # see priv/generate_win32_tz_mapping.exs diff --git a/mix.lock b/mix.lock index 9d41ea8..7b623df 100644 --- a/mix.lock +++ b/mix.lock @@ -14,6 +14,7 @@ "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, @@ -21,6 +22,7 @@ "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mix_test_watch": {:hex, :mix_test_watch, "1.4.0", "d88bcc4fbe3198871266e9d2f00cd8ae350938efbb11d3fa1da091586345adbb", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "2b4693e17c8ead2ef56d4f48a0329891e8c2d0d73752c0f09272a2b17dc38d1b"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, @@ -29,6 +31,7 @@ "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"}, "timex": {:hex, :timex, "3.7.13", "0688ce11950f5b65e154e42b47bf67b15d3bc0e0c3def62199991b8a8079a1e2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "09588e0522669328e973b8b4fd8741246321b3f0d32735b589f78b136e6d4c54"}, + "tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"}, "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, } diff --git a/test/ical/deserialize_test.exs b/test/ical/deserialize_test.exs index 2925df1..f94e0c2 100644 --- a/test/ical/deserialize_test.exs +++ b/test/ical/deserialize_test.exs @@ -148,10 +148,6 @@ defmodule ICal.DeserializeTest do assert nil == ICal.Deserialize.to_date_in_timezone("garbage", "America/Chicago") end - @tag skip: """ - implementation returns the second (EST) occurrence instead of the first (EDT); - RFC 5545 §3.3.5 requires the first occurrence for ambiguous fall-back times - """ test "to_date_in_timezone/2 handles ambiguous wall clock time during DST fall-back" do # RFC 5545 §3.3.5: when a local time occurs more than once (clocks fall # back), "the DATE-TIME value refers to the first occurrence of the @@ -162,6 +158,7 @@ defmodule ICal.DeserializeTest do # America/New_York falls back on 2023-11-05; 1:30 AM occurs twice. # The first occurrence is EDT (std_offset: 3600, total offset -04:00). result = ICal.Deserialize.to_date_in_timezone("20231105T013000", "America/New_York") + # result = ICal.Deserialize.to_date_in_timezone("20070311T023000", "America/New_York") assert %DateTime{ year: 2023, @@ -176,11 +173,6 @@ defmodule ICal.DeserializeTest do } = result end - @tag skip: """ - implementation returns just_after (3:00 AM EDT) instead of applying the pre-gap - offset (3:30 AM EDT); RFC 5545 §3.3.5 requires interpreting the wall clock time - using the UTC offset before the gap - """ test "to_date_in_timezone/2 handles non-existent wall clock time during DST spring-forward" do # RFC 5545 §3.3.5: when a local time does not occur (clocks spring # forward), "the DATE-TIME value is interpreted using the UTC offset diff --git a/test/test_helper.exs b/test/test_helper.exs index 22476ef..110fcbf 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -52,4 +52,5 @@ defmodule ICal.Test.Helper do end end +Calendar.put_time_zone_database(Tz.TimeZoneDatabase) ExUnit.start()