Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 79 additions & 35 deletions lib/ical/deserialize.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <<y::binary-size(4), m::binary-size(2), d::binary-size(2)>> <- 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
Expand All @@ -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 <<y::binary-size(4), m::binary-size(2), d::binary-size(2), ?T, t_h::binary-size(2),
t_m::binary-size(2), t_s::binary-size(2), rest::binary>>
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah right, this is correct per the spec!

If, based on the definition of the referenced time zone, the local
time described occurs more than once (when changing from daylight
to standard time), the DATE-TIME value refers to the first
occurrence of the referenced time.

{: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 <<y::binary-size(4), m::binary-size(2), d::binary-size(2), ?T, t_h::binary-size(2),
t_m::binary-size(2), t_s::binary-size(2)>> <- 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
Expand Down Expand Up @@ -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
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@
"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"},
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"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"},
Expand All @@ -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"},
}
10 changes: 1 addition & 9 deletions test/ical/deserialize_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/test_helper.exs
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,5 @@ defmodule ICal.Test.Helper do
end
end

Calendar.put_time_zone_database(Tz.TimeZoneDatabase)
ExUnit.start()
Loading