From b1dfa9af90a23f70aca832b4637f0d4d3a03491e Mon Sep 17 00:00:00 2001 From: Girish Gopaul Date: Fri, 3 Jul 2026 18:26:04 +0400 Subject: [PATCH] Respect floating DATE-TIME values during parsing Stop treating the struct fallback timezone as an implicit calendar timezone. Calendars now default to no timezone, so DATE-TIME values without TZID or a trailing Z are preserved as NaiveDateTime unless the calendar declares a default timezone via X-WR-TIMEZONE. This also keeps UTC values explicit, applies declared calendar defaults to floating values, and makes unrecognized TZID values fall back to the calendar default when available. --- lib/ical.ex | 4 +- lib/ical/deserialize.ex | 24 +++++++++--- lib/ical/event.ex | 16 ++++---- lib/ical/journal.ex | 18 ++++----- lib/ical/todo.ex | 18 ++++----- test/ical/deserialize_test.exs | 71 +++++++++++++++++++++++++++++++++- test/ical_test.exs | 31 +++++++++++++-- test/support/fixtures.ex | 2 +- 8 files changed, 144 insertions(+), 40 deletions(-) diff --git a/lib/ical.ex b/lib/ical.ex index 5dc7cdd..00512fc 100644 --- a/lib/ical.ex +++ b/lib/ical.ex @@ -12,7 +12,7 @@ defmodule ICal do todos: [], journals: [], timezones: %{}, - default_timezone: "Etc/UTC", + default_timezone: nil, name: nil, custom_properties: %{}, __other_components: [] @@ -40,7 +40,7 @@ defmodule ICal do todos: [ICal.Todo.t()], journals: [ICal.Journal.t()], timezones: %{String.t() => ICal.Timezone.t()}, - default_timezone: String.t(), + default_timezone: String.t() | nil, name: String.t() | nil, custom_properties: custom_properties } diff --git a/lib/ical/deserialize.ex b/lib/ical/deserialize.ex index 51916f2..dc15245 100644 --- a/lib/ical/deserialize.ex +++ b/lib/ical/deserialize.ex @@ -415,7 +415,7 @@ defmodule ICal.Deserialize do # convert a string to a proper timezone, this includes ones with a / # but also ones like Central Standard Time, so we try our best to normalize those - # all else fails, assume UTC + # all else fails, use the calendar default timezone if one was declared def to_timezone(timezone, default \\ "Etc/UTC") def to_timezone(nil, default), do: default @@ -427,6 +427,9 @@ defmodule ICal.Deserialize do ICal.Deserialize.Timezone.windows_to_olson(timezone) != nil -> ICal.Deserialize.Timezone.windows_to_olson(timezone) + is_nil(default) -> + nil + true -> default end @@ -440,7 +443,8 @@ defmodule ICal.Deserialize do It returns `nil` for ill-formed dates or datetime strings. """ - @spec to_date(String.t() | nil, map, ICal.t()) :: Date.t() | DateTime.t() | nil + @spec to_date(String.t() | nil, map, ICal.t()) :: + Date.t() | DateTime.t() | NaiveDateTime.t() | nil def to_date(nil, _params, _calendar), do: nil def to_date(date_string, %{"VALUE" => "DATE"}, _calendar) do @@ -457,11 +461,21 @@ defmodule ICal.Deserialize do end def to_date(date_string, %{"TZID" => timezone}, %ICal{default_timezone: default_timezone}) do - # Microsoft Outlook calendar .ICS files report times in Greenwich Standard Time (UTC +0) - # so just convert this to UTC timezone = to_timezone(timezone, default_timezone) - to_date_in_timezone(date_string, timezone) + if timezone == nil do + to_local_date(date_string) + else + to_date_in_timezone(date_string, timezone) + end + end + + def to_date(<<_::binary-size(15), "Z">> = date_string, _params, _calendar) do + to_date_in_timezone(date_string, "Etc/UTC") + end + + def to_date(date_string, _params, %ICal{default_timezone: nil}) do + to_local_date(date_string) end def to_date(date_string, _params, %ICal{default_timezone: default_timezone}) do diff --git a/lib/ical/event.ex b/lib/ical/event.ex index d248734..839dc9f 100644 --- a/lib/ical/event.ex +++ b/lib/ical/event.ex @@ -39,14 +39,14 @@ defmodule ICal.Event do @type t :: %__MODULE__{ uid: String.t() | nil, - dtstamp: DateTime.t() | nil, - created: DateTime.t() | nil, - dtstart: Date.t() | DateTime.t() | nil, - dtend: Date.t() | DateTime.t() | nil, - modified: Date.t() | nil, - recurrence_id: Date.t() | nil, - exdates: [Date.t() | DateTime.t()], - rdates: [Date.t() | DateTime.t() | ICal.period()], + dtstamp: DateTime.t() | NaiveDateTime.t() | nil, + created: DateTime.t() | NaiveDateTime.t() | nil, + dtstart: Date.t() | DateTime.t() | NaiveDateTime.t() | nil, + dtend: Date.t() | DateTime.t() | NaiveDateTime.t() | nil, + modified: Date.t() | DateTime.t() | NaiveDateTime.t() | nil, + recurrence_id: Date.t() | DateTime.t() | NaiveDateTime.t() | nil, + exdates: [Date.t() | DateTime.t() | NaiveDateTime.t()], + rdates: [Date.t() | DateTime.t() | NaiveDateTime.t() | ICal.period()], rrule: ICal.Recurrence.t() | nil, class: String.t() | nil, description: String.t() | nil, diff --git a/lib/ical/journal.ex b/lib/ical/journal.ex index e4d46b6..533e4b8 100644 --- a/lib/ical/journal.ex +++ b/lib/ical/journal.ex @@ -40,16 +40,16 @@ defmodule ICal.Journal do @type t :: %__MODULE__{ uid: String.t(), - dtstamp: DateTime.t(), - created: nil | DateTime.t(), - completed: nil | DateTime.t(), - modified: nil | DateTime.t(), - recurrance_id: nil | DateTime.t() | Date.t(), - exdates: [Date.t() | DateTime.t()], - rdates: [Date.t() | DateTime.t() | ICal.period()], + dtstamp: DateTime.t() | NaiveDateTime.t(), + created: nil | DateTime.t() | NaiveDateTime.t(), + completed: nil | DateTime.t() | NaiveDateTime.t(), + modified: nil | DateTime.t() | NaiveDateTime.t(), + recurrance_id: nil | DateTime.t() | NaiveDateTime.t() | Date.t(), + exdates: [Date.t() | DateTime.t() | NaiveDateTime.t()], + rdates: [Date.t() | DateTime.t() | NaiveDateTime.t() | ICal.period()], class: nil | String.t(), description: [String.t()], - dtstart: nil | DateTime.t() | Date.t(), + dtstart: nil | DateTime.t() | NaiveDateTime.t() | Date.t(), organizer: nil | String.t(), priority: non_neg_integer, sequence: non_neg_integer, @@ -57,7 +57,7 @@ defmodule ICal.Journal do summary: nil | String.t(), url: nil | String.t(), rrule: nil | ICal.Recurrence.t(), - due: nil | DateTime.t() | Date.t(), + due: nil | DateTime.t() | NaiveDateTime.t() | Date.t(), duration: nil | ICal.Duration.t(), alarms: [ICal.Alarm.t()], attachments: [ICal.Attachment.t()], diff --git a/lib/ical/todo.ex b/lib/ical/todo.ex index 65bfde6..8eaf85b 100644 --- a/lib/ical/todo.ex +++ b/lib/ical/todo.ex @@ -44,16 +44,16 @@ defmodule ICal.Todo do @type t :: %__MODULE__{ uid: String.t(), - dtstamp: DateTime.t(), - created: nil | DateTime.t(), - completed: nil | DateTime.t(), - modified: nil | DateTime.t(), - recurrance_id: nil | DateTime.t() | Date.t(), - exdates: [Date.t() | DateTime.t()], - rdates: [Date.t() | DateTime.t() | ICal.period()], + dtstamp: DateTime.t() | NaiveDateTime.t(), + created: nil | DateTime.t() | NaiveDateTime.t(), + completed: nil | DateTime.t() | NaiveDateTime.t(), + modified: nil | DateTime.t() | NaiveDateTime.t(), + recurrance_id: nil | DateTime.t() | NaiveDateTime.t() | Date.t(), + exdates: [Date.t() | DateTime.t() | NaiveDateTime.t()], + rdates: [Date.t() | DateTime.t() | NaiveDateTime.t() | ICal.period()], class: nil | String.t(), description: nil | String.t(), - dtstart: nil | DateTime.t() | Date.t(), + dtstart: nil | DateTime.t() | NaiveDateTime.t() | Date.t(), geo: nil | ICal.geo(), location: nil | String.t(), organizer: nil | String.t(), @@ -64,7 +64,7 @@ defmodule ICal.Todo do summary: nil | String.t(), url: nil | String.t(), rrule: nil | ICal.Recurrence.t(), - due: nil | DateTime.t() | Date.t(), + due: nil | DateTime.t() | NaiveDateTime.t() | Date.t(), duration: nil | ICal.Duration.t(), alarms: [ICal.Alarm.t()], attachments: [ICal.Attachment.t()], diff --git a/test/ical/deserialize_test.exs b/test/ical/deserialize_test.exs index 5b6c9ca..955bf8a 100644 --- a/test/ical/deserialize_test.exs +++ b/test/ical/deserialize_test.exs @@ -201,6 +201,50 @@ defmodule ICal.DeserializeTest do assert ~D[1998-01-19] == ICal.Deserialize.to_date("19980119", %{"VALUE" => "DATE"}, %ICal{}) end + test "to_date/3 parses a floating DATE-TIME as a naive datetime" do + assert ~N[1998-01-19 02:00:00] == + ICal.Deserialize.to_date("19980119T020000", %{}, %ICal{}) + end + + test "to_date/3 parses a floating DATE-TIME in the calendar default timezone when set" do + result = + ICal.Deserialize.to_date( + "19980119T020000", + %{}, + %ICal{default_timezone: "America/Chicago"} + ) + + assert %DateTime{ + year: 1998, + month: 1, + day: 19, + hour: 2, + minute: 0, + second: 0, + time_zone: "America/Chicago" + } = result + end + + test "to_date/3 parses a UTC DATE-TIME as a UTC datetime" do + assert ~U[1998-01-19 02:00:00Z] == + ICal.Deserialize.to_date("19980119T020000Z", %{}, %ICal{}) + end + + test "to_date/3 parses a DATE-TIME with TZID in that timezone" do + result = + ICal.Deserialize.to_date("19980119T020000", %{"TZID" => "America/Chicago"}, %ICal{}) + + assert %DateTime{ + year: 1998, + month: 1, + day: 19, + hour: 2, + minute: 0, + second: 0, + time_zone: "America/Chicago" + } = result + end + test "to_date/3 returns nil for an unparseable VALUE=DATE string" do assert nil == ICal.Deserialize.to_date("garbage", %{"VALUE" => "DATE"}, %ICal{}) end @@ -298,8 +342,31 @@ defmodule ICal.DeserializeTest do # olson assert event.dtend.time_zone == "America/Chicago" - # unrecognized tz - assert event.dtstamp.time_zone == "Etc/UTC" + # unrecognized tz without a calendar default timezone + assert event.dtstamp == ~N[2222-12-24 08:30:00] + end + + test "unrecognized TZID falls back to the calendar default timezone when set" do + ics = """ + BEGIN:VCALENDAR + X-WR-TIMEZONE:Europe/Zurich + BEGIN:VEVENT + DTSTAMP;TZID=Garbage:22221224T083000 + END:VEVENT + END:VCALENDAR + """ + + %ICal{events: [event]} = ICal.from_ics(ics) + + assert %DateTime{ + year: 2222, + month: 12, + day: 24, + hour: 8, + minute: 30, + second: 0, + time_zone: "Europe/Zurich" + } = event.dtstamp end test "with CR+LF line endings" do diff --git a/test/ical_test.exs b/test/ical_test.exs index 8c0bc67..42ae641 100644 --- a/test/ical_test.exs +++ b/test/ical_test.exs @@ -63,7 +63,7 @@ defmodule ICalTest do assert calendar.version == "2.0" assert calendar.product_id == "-//Elixir ICal//EN" assert calendar.method == "REQUEST" - assert calendar.default_timezone == "Etc/UTC" + assert calendar.default_timezone == nil assert calendar.custom_properties == %{} end @@ -77,14 +77,37 @@ defmodule ICalTest do assert Fixtures.calendar(:custom_properties) == calendar end - test "ICal with custom tz alter dates" do + test "ICal with custom tz does not alter UTC dates" do dtstamp = ~U[2015-12-24 08:00:00Z] %ICal{events: [%ICal.Event{dtstamp: parsed_date}]} = Helper.test_data("custom_calendar_tz") |> ICal.from_ics() - assert dtstamp != parsed_date - assert parsed_date.time_zone == "Europe/Zurich" + assert dtstamp == parsed_date + end + + test "ICal with custom tz applies default timezone to floating dates" do + ics = """ + BEGIN:VCALENDAR + X-WR-TIMEZONE:Europe/Zurich + BEGIN:VEVENT + UID:1 + DTSTART:20151224T080000 + END:VEVENT + END:VCALENDAR + """ + + %ICal{events: [%ICal.Event{dtstart: parsed_date}]} = ICal.from_ics(ics) + + assert %DateTime{ + year: 2015, + month: 12, + day: 24, + hour: 8, + minute: 0, + second: 0, + time_zone: "Europe/Zurich" + } = parsed_date end test "ICal.to_ics/1 of a calendar with an event, as in README" do diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index d361634..c082353 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -499,7 +499,7 @@ defmodule ICal.Test.Fixtures do } } ], - default_timezone: "Etc/UTC" + default_timezone: nil } end