From 6ac887df28dc696a76b41da74b58ade56c743ba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio?= Date: Wed, 18 Jun 2025 00:31:13 +0300 Subject: [PATCH 1/5] Better preserver timezones in ical --- lib/ice_cube/builders/ical_builder.rb | 13 +++++- lib/ice_cube/parsers/ical_parser.rb | 39 ++++++++++++++--- lib/ice_cube/time_util.rb | 3 +- spec/examples/from_ical_spec.rb | 62 +++++++++++++++++++++++++++ spec/examples/to_ical_spec.rb | 16 +++---- 5 files changed, 118 insertions(+), 15 deletions(-) diff --git a/lib/ice_cube/builders/ical_builder.rb b/lib/ice_cube/builders/ical_builder.rb index 4af655ce..ce8171af 100644 --- a/lib/ice_cube/builders/ical_builder.rb +++ b/lib/ice_cube/builders/ical_builder.rb @@ -35,10 +35,21 @@ def self.ical_utc_format(time) def self.ical_format(time, force_utc) time = time.dup.utc if force_utc + + # Keep timezone. strftime will serializer short versions of time zone (eg. EEST), + # which are not reversivible, as there are many repeated abbreviated zones. This will result in + # issues in parsing + if time.respond_to?(:time_zone) + tz_id = time.time_zone.name + return ";TZID=#{tz_id}:#{IceCube::I18n.l(time, format: "%Y%m%dT%H%M%S")}" # local time specified" + end + if time.utc? ":#{IceCube::I18n.l(time, format: "%Y%m%dT%H%M%SZ")}" # utc time else - ";TZID=#{IceCube::I18n.l(time, format: "%Z:%Y%m%dT%H%M%S")}" # local time specified + # Warn %Z (capital) is OS dependent, and not unique (CST can be in Asia +0800, or Americas (-0500) + # at least %z allows recovery of accurate utc offset during parsing + ";TZID=#{time.strftime("%z")}:#{IceCube::I18n.l(time, format: "%Y%m%dT%H%M%S")}" # local time specified end end diff --git a/lib/ice_cube/parsers/ical_parser.rb b/lib/ice_cube/parsers/ical_parser.rb index 429bd84c..78ecfe94 100644 --- a/lib/ice_cube/parsers/ical_parser.rb +++ b/lib/ice_cube/parsers/ical_parser.rb @@ -4,18 +4,25 @@ def self.schedule_from_ical(ical_string, options = {}) data = {} ical_string.each_line do |line| (property, value) = line.split(":") - (property, _tzid) = property.split(";") + (property, tzid_param) = property.split(";") + + # Extract TZID if present + tzid = nil + if tzid_param && tzid_param.start_with?("TZID=") + tzid = tzid_param[5..-1] # Remove "TZID=" prefix + end + case property when "DTSTART" - data[:start_time] = TimeUtil.deserialize_time(value) + data[:start_time] = deserialize_time_with_tzid(value, tzid) when "DTEND" - data[:end_time] = TimeUtil.deserialize_time(value) + data[:end_time] = deserialize_time_with_tzid(value, tzid) when "RDATE" data[:rtimes] ||= [] - data[:rtimes] += value.split(",").map { |v| TimeUtil.deserialize_time(v) } + data[:rtimes] += value.split(",").map { |v| deserialize_time_with_tzid(v, tzid) } when "EXDATE" data[:extimes] ||= [] - data[:extimes] += value.split(",").map { |v| TimeUtil.deserialize_time(v) } + data[:extimes] += value.split(",").map { |v| deserialize_time_with_tzid(v, tzid) } when "DURATION" data[:duration] # FIXME when "RRULE" @@ -26,6 +33,28 @@ def self.schedule_from_ical(ical_string, options = {}) Schedule.from_hash data end + def self.deserialize_time_with_tzid(time_value, tzid) + if tzid.nil? || tzid.empty? + # No TZID, use standard deserialization + TimeUtil.deserialize_time(time_value) + elsif tzid.match?(/^[+-]\d{4}$/) + # TZID is an offset like +0300 or -0500 + # Parse the time and apply the offset + base_time = Time.strptime(time_value, "%Y%m%dT%H%M%S") + offset_hours = tzid[1..2].to_i + offset_minutes = tzid[3..4].to_i + offset_seconds = offset_hours * 3600 + offset_minutes * 60 + offset_seconds *= -1 if tzid[0] == "-" + Time.new(base_time.year, base_time.month, base_time.day, + base_time.hour, base_time.min, base_time.sec, offset_seconds) + else + # TZID is a timezone name - try to use it if possible + # For now, fall back to standard parsing + # TODO: Could be enhanced to support timezone names if TZInfo is available + TimeUtil.deserialize_time(time_value) + end + end + def self.rule_from_ical(ical) raise ArgumentError, "empty ical rule" if ical.nil? diff --git a/lib/ice_cube/time_util.rb b/lib/ice_cube/time_util.rb index a18f7758..cbf2dead 100644 --- a/lib/ice_cube/time_util.rb +++ b/lib/ice_cube/time_util.rb @@ -87,7 +87,8 @@ def self.serialize_time(time) case time when Time, Date if time.respond_to?(:time_zone) - {time: time.utc, zone: time.time_zone.name} + # avoid .utc as it changes the object timezone + {time: time.getutc, zone: time.time_zone.name} else time end diff --git a/spec/examples/from_ical_spec.rb b/spec/examples/from_ical_spec.rb index 2ab66c3c..c6e96527 100644 --- a/spec/examples/from_ical_spec.rb +++ b/spec/examples/from_ical_spec.rb @@ -429,5 +429,67 @@ def sorted_ical(ical) it_behaves_like "an invalid ical string" end end + + describe "timezone offset parsing" do + it "should correctly parse TZID with offset format" do + # Test parsing TZID with offset format like +0300 + ical_string = "DTSTART;TZID=+0300:20250618T001306" + + schedule = IceCube::Schedule.from_ical(ical_string) + + # Should preserve the +0300 offset (10800 seconds) + expect(schedule.start_time.utc_offset).to eq(10800) + + # Should parse the correct local time + expect(schedule.start_time.year).to eq(2025) + expect(schedule.start_time.month).to eq(6) + expect(schedule.start_time.day).to eq(18) + expect(schedule.start_time.hour).to eq(0) + expect(schedule.start_time.min).to eq(13) + expect(schedule.start_time.sec).to eq(6) + end + + it "should correctly parse TZID with negative offset format" do + # Test parsing TZID with negative offset format like -0500 + ical_string = "DTSTART;TZID=-0500:20250618T001306" + + schedule = IceCube::Schedule.from_ical(ical_string) + + puts "Parsed time (negative offset): #{schedule.start_time.inspect}" + puts "Parsed offset: #{schedule.start_time.utc_offset}" + puts "Expected offset: #{-5 * 3600} (-18000 seconds for -0500)" + + # Should preserve the -0500 offset (-18000 seconds) + expect(schedule.start_time.utc_offset).to eq(-18000) + + # Should parse the correct local time + expect(schedule.start_time.year).to eq(2025) + expect(schedule.start_time.month).to eq(6) + expect(schedule.start_time.day).to eq(18) + expect(schedule.start_time.hour).to eq(0) + expect(schedule.start_time.min).to eq(13) + expect(schedule.start_time.sec).to eq(6) + end + + it "should handle round-trip serialization with offset-based TZID" do + # Create a time with specific offset + original_time = Time.new(2025, 6, 18, 0, 13, 6, "+03:00") + original_schedule = IceCube::Schedule.new(original_time) + + # Convert to iCal + ical_string = original_schedule.to_ical + puts "Generated iCal: #{ical_string}" + + # Parse back + parsed_schedule = IceCube::Schedule.from_ical(ical_string) + + # Should maintain timezone information + expect(parsed_schedule.start_time.utc_offset).to eq(original_time.utc_offset) + expect(parsed_schedule.start_time.to_i).to eq(original_time.to_i) # Same instant + expect(parsed_schedule.start_time.hour).to eq(original_time.hour) # Same local hour + expect(parsed_schedule.start_time.min).to eq(original_time.min) # Same local minute + expect(parsed_schedule.start_time.sec).to eq(original_time.sec) # Same local second + end + end end end diff --git a/spec/examples/to_ical_spec.rb b/spec/examples/to_ical_spec.rb index 39dd5209..fe854965 100644 --- a/spec/examples/to_ical_spec.rb +++ b/spec/examples/to_ical_spec.rb @@ -97,7 +97,7 @@ it "should be able to serialize a base schedule to ical in local time" do Time.zone = "Eastern Time (US & Canada)" schedule = IceCube::Schedule.new(Time.zone.local(2010, 5, 10, 9, 0, 0)) - expect(schedule.to_ical).to eq("DTSTART;TZID=EDT:20100510T090000") + expect(schedule.to_ical).to eq("DTSTART;TZID=Eastern Time (US & Canada):20100510T090000") end it "should be able to serialize a base schedule to ical in UTC time" do @@ -110,7 +110,7 @@ schedule = IceCube::Schedule.new(Time.zone.local(2010, 5, 10, 9, 0, 0)) schedule.add_recurrence_rule IceCube::Rule.weekly # test equality - expectation = "DTSTART;TZID=PDT:20100510T090000\n" + expectation = "DTSTART;TZID=Pacific Time (US & Canada):20100510T090000\n" expectation << "RRULE:FREQ=WEEKLY" expect(schedule.to_ical).to eq(expectation) end @@ -120,7 +120,7 @@ schedule = IceCube::Schedule.new(Time.zone.local(2010, 10, 20, 4, 30, 0)) schedule.add_recurrence_rule IceCube::Rule.weekly.day_of_week(monday: [2, -1]) schedule.add_recurrence_rule IceCube::Rule.hourly - expectation = "DTSTART;TZID=EDT:20101020T043000\n" + expectation = "DTSTART;TZID=Eastern Time (US & Canada):20101020T043000\n" expectation << "RRULE:FREQ=WEEKLY;BYDAY=2MO,-1MO\n" expectation << "RRULE:FREQ=HOURLY" expect(schedule.to_ical).to eq(expectation) @@ -131,7 +131,7 @@ schedule = IceCube::Schedule.new(Time.zone.local(2010, 5, 10, 9, 0, 0)) schedule.add_exception_rule IceCube::Rule.weekly # test equality - expectation = "DTSTART;TZID=PDT:20100510T090000\n" + expectation = "DTSTART;TZID=Pacific Time (US & Canada):20100510T090000\n" expectation << "EXRULE:FREQ=WEEKLY" expect(schedule.to_ical).to eq(expectation) end @@ -141,7 +141,7 @@ schedule = IceCube::Schedule.new(Time.zone.local(2010, 10, 20, 4, 30, 0)) schedule.add_exception_rule IceCube::Rule.weekly.day_of_week(monday: [2, -1]) schedule.add_exception_rule IceCube::Rule.hourly - expectation = "DTSTART;TZID=EDT:20101020T043000\n" + expectation = "DTSTART;TZID=Eastern Time (US & Canada):20101020T043000\n" expectation << "EXRULE:FREQ=WEEKLY;BYDAY=2MO,-1MO\n" expectation << "EXRULE:FREQ=HOURLY" expect(schedule.to_ical).to eq(expectation) @@ -195,7 +195,7 @@ it "should default to to_ical using local time" do time = Time.now schedule = IceCube::Schedule.new(Time.now) - expect(schedule.to_ical).to eq("DTSTART;TZID=#{time.zone}:#{time.strftime("%Y%m%dT%H%M%S")}") # default false + expect(schedule.to_ical).to eq("DTSTART;TZID=#{time.strftime("%z")}:#{time.strftime("%Y%m%dT%H%M%S")}") # default false end it "should not have an rtime that duplicates start time" do @@ -208,8 +208,8 @@ it "should be able to receive a to_ical in utc time" do time = Time.now schedule = IceCube::Schedule.new(Time.now) - expect(schedule.to_ical).to eq("DTSTART;TZID=#{time.zone}:#{time.strftime("%Y%m%dT%H%M%S")}") # default false - expect(schedule.to_ical(false)).to eq("DTSTART;TZID=#{time.zone}:#{time.strftime("%Y%m%dT%H%M%S")}") + expect(schedule.to_ical).to eq("DTSTART;TZID=#{time.strftime("%z")}:#{time.strftime("%Y%m%dT%H%M%S")}") # default false + expect(schedule.to_ical(false)).to eq("DTSTART;TZID=#{time.strftime("%z")}:#{time.strftime("%Y%m%dT%H%M%S")}") expect(schedule.to_ical(true)).to eq("DTSTART:#{time.utc.strftime("%Y%m%dT%H%M%S")}Z") end From a082f98b1a29830d4bf0e38e9c4c155529d87f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio?= Date: Wed, 18 Jun 2025 00:47:57 +0300 Subject: [PATCH 2/5] Fix re-parsing of timezone --- lib/ice_cube/parsers/ical_parser.rb | 12 ++++++++---- spec/examples/from_ical_spec.rb | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/ice_cube/parsers/ical_parser.rb b/lib/ice_cube/parsers/ical_parser.rb index 78ecfe94..c5089a5c 100644 --- a/lib/ice_cube/parsers/ical_parser.rb +++ b/lib/ice_cube/parsers/ical_parser.rb @@ -48,10 +48,14 @@ def self.deserialize_time_with_tzid(time_value, tzid) Time.new(base_time.year, base_time.month, base_time.day, base_time.hour, base_time.min, base_time.sec, offset_seconds) else - # TZID is a timezone name - try to use it if possible - # For now, fall back to standard parsing - # TODO: Could be enhanced to support timezone names if TZInfo is available - TimeUtil.deserialize_time(time_value) + # TZID is a timezone name - Assume it's a valid timezone in a try-catch block + begin + TimeUtil.deserialize_time({time: time_value, zone: tzid}) + rescue ArgumentError + # If the timezone is invalid, fall back to standard deserialization + # Perhaps we want to log this? + TimeUtil.deserialize_time(time_value) + end end end diff --git a/spec/examples/from_ical_spec.rb b/spec/examples/from_ical_spec.rb index c6e96527..769451f7 100644 --- a/spec/examples/from_ical_spec.rb +++ b/spec/examples/from_ical_spec.rb @@ -110,7 +110,7 @@ module IceCube ICAL ical_string_with_multiple_rules = <<-ICAL.gsub(/^\s*/, "") - DTSTART;TZID=CDT:20151005T195541 + DTSTART;TZID=America/Chicago:20151005T195541 RRULE:FREQ=WEEKLY;BYDAY=MO,TU RRULE:FREQ=WEEKLY;INTERVAL=2;WKST=SU;BYDAY=FR ICAL From 38e2f0887a3a265083879d2b7b0dfe102a7209c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio?= Date: Thu, 3 Jul 2025 14:49:10 +0300 Subject: [PATCH 3/5] Improve timezone handling in iCal: convert local time to UTC when no timezone info is present and update related specs --- lib/ice_cube/builders/ical_builder.rb | 7 +-- lib/ice_cube/parsers/ical_parser.rb | 10 ----- spec/examples/from_ical_spec.rb | 62 --------------------------- spec/examples/to_ical_spec.rb | 14 +++--- 4 files changed, 11 insertions(+), 82 deletions(-) diff --git a/lib/ice_cube/builders/ical_builder.rb b/lib/ice_cube/builders/ical_builder.rb index ce8171af..dce90694 100644 --- a/lib/ice_cube/builders/ical_builder.rb +++ b/lib/ice_cube/builders/ical_builder.rb @@ -47,9 +47,10 @@ def self.ical_format(time, force_utc) if time.utc? ":#{IceCube::I18n.l(time, format: "%Y%m%dT%H%M%SZ")}" # utc time else - # Warn %Z (capital) is OS dependent, and not unique (CST can be in Asia +0800, or Americas (-0500) - # at least %z allows recovery of accurate utc offset during parsing - ";TZID=#{time.strftime("%z")}:#{IceCube::I18n.l(time, format: "%Y%m%dT%H%M%S")}" # local time specified + # Convert to UTC as TZID=+xxxx format is not recognized by JS libraries + warn "IceCube: Time object does not have timezone info. Assuming UTC: #{caller(1..1).first}" + utc_time = time.dup.utc + ":#{IceCube::I18n.l(utc_time, format: "%Y%m%dT%H%M%SZ")}" # converted to utc time end end diff --git a/lib/ice_cube/parsers/ical_parser.rb b/lib/ice_cube/parsers/ical_parser.rb index c5089a5c..6df66cc5 100644 --- a/lib/ice_cube/parsers/ical_parser.rb +++ b/lib/ice_cube/parsers/ical_parser.rb @@ -37,16 +37,6 @@ def self.deserialize_time_with_tzid(time_value, tzid) if tzid.nil? || tzid.empty? # No TZID, use standard deserialization TimeUtil.deserialize_time(time_value) - elsif tzid.match?(/^[+-]\d{4}$/) - # TZID is an offset like +0300 or -0500 - # Parse the time and apply the offset - base_time = Time.strptime(time_value, "%Y%m%dT%H%M%S") - offset_hours = tzid[1..2].to_i - offset_minutes = tzid[3..4].to_i - offset_seconds = offset_hours * 3600 + offset_minutes * 60 - offset_seconds *= -1 if tzid[0] == "-" - Time.new(base_time.year, base_time.month, base_time.day, - base_time.hour, base_time.min, base_time.sec, offset_seconds) else # TZID is a timezone name - Assume it's a valid timezone in a try-catch block begin diff --git a/spec/examples/from_ical_spec.rb b/spec/examples/from_ical_spec.rb index 769451f7..4ef584cd 100644 --- a/spec/examples/from_ical_spec.rb +++ b/spec/examples/from_ical_spec.rb @@ -429,67 +429,5 @@ def sorted_ical(ical) it_behaves_like "an invalid ical string" end end - - describe "timezone offset parsing" do - it "should correctly parse TZID with offset format" do - # Test parsing TZID with offset format like +0300 - ical_string = "DTSTART;TZID=+0300:20250618T001306" - - schedule = IceCube::Schedule.from_ical(ical_string) - - # Should preserve the +0300 offset (10800 seconds) - expect(schedule.start_time.utc_offset).to eq(10800) - - # Should parse the correct local time - expect(schedule.start_time.year).to eq(2025) - expect(schedule.start_time.month).to eq(6) - expect(schedule.start_time.day).to eq(18) - expect(schedule.start_time.hour).to eq(0) - expect(schedule.start_time.min).to eq(13) - expect(schedule.start_time.sec).to eq(6) - end - - it "should correctly parse TZID with negative offset format" do - # Test parsing TZID with negative offset format like -0500 - ical_string = "DTSTART;TZID=-0500:20250618T001306" - - schedule = IceCube::Schedule.from_ical(ical_string) - - puts "Parsed time (negative offset): #{schedule.start_time.inspect}" - puts "Parsed offset: #{schedule.start_time.utc_offset}" - puts "Expected offset: #{-5 * 3600} (-18000 seconds for -0500)" - - # Should preserve the -0500 offset (-18000 seconds) - expect(schedule.start_time.utc_offset).to eq(-18000) - - # Should parse the correct local time - expect(schedule.start_time.year).to eq(2025) - expect(schedule.start_time.month).to eq(6) - expect(schedule.start_time.day).to eq(18) - expect(schedule.start_time.hour).to eq(0) - expect(schedule.start_time.min).to eq(13) - expect(schedule.start_time.sec).to eq(6) - end - - it "should handle round-trip serialization with offset-based TZID" do - # Create a time with specific offset - original_time = Time.new(2025, 6, 18, 0, 13, 6, "+03:00") - original_schedule = IceCube::Schedule.new(original_time) - - # Convert to iCal - ical_string = original_schedule.to_ical - puts "Generated iCal: #{ical_string}" - - # Parse back - parsed_schedule = IceCube::Schedule.from_ical(ical_string) - - # Should maintain timezone information - expect(parsed_schedule.start_time.utc_offset).to eq(original_time.utc_offset) - expect(parsed_schedule.start_time.to_i).to eq(original_time.to_i) # Same instant - expect(parsed_schedule.start_time.hour).to eq(original_time.hour) # Same local hour - expect(parsed_schedule.start_time.min).to eq(original_time.min) # Same local minute - expect(parsed_schedule.start_time.sec).to eq(original_time.sec) # Same local second - end - end end end diff --git a/spec/examples/to_ical_spec.rb b/spec/examples/to_ical_spec.rb index fe854965..063a855e 100644 --- a/spec/examples/to_ical_spec.rb +++ b/spec/examples/to_ical_spec.rb @@ -192,10 +192,10 @@ expect(schedule.duration).to eq(3600) end - it "should default to to_ical using local time" do + it "should default to to_ical using UTC when there is no timezone info" do time = Time.now - schedule = IceCube::Schedule.new(Time.now) - expect(schedule.to_ical).to eq("DTSTART;TZID=#{time.strftime("%z")}:#{time.strftime("%Y%m%dT%H%M%S")}") # default false + schedule = IceCube::Schedule.new(time) + expect(schedule.to_ical).to eq("DTSTART:#{time.utc.strftime("%Y%m%dT%H%M%S")}Z") # converts local to UTC end it "should not have an rtime that duplicates start time" do @@ -207,10 +207,10 @@ it "should be able to receive a to_ical in utc time" do time = Time.now - schedule = IceCube::Schedule.new(Time.now) - expect(schedule.to_ical).to eq("DTSTART;TZID=#{time.strftime("%z")}:#{time.strftime("%Y%m%dT%H%M%S")}") # default false - expect(schedule.to_ical(false)).to eq("DTSTART;TZID=#{time.strftime("%z")}:#{time.strftime("%Y%m%dT%H%M%S")}") - expect(schedule.to_ical(true)).to eq("DTSTART:#{time.utc.strftime("%Y%m%dT%H%M%S")}Z") + schedule = IceCube::Schedule.new(time) + expect(schedule.to_ical).to eq("DTSTART:#{time.utc.strftime("%Y%m%dT%H%M%S")}Z") # converts local to UTC + expect(schedule.to_ical(false)).to eq("DTSTART:#{time.utc.strftime("%Y%m%dT%H%M%S")}Z") # still converts local to UTC + expect(schedule.to_ical(true)).to eq("DTSTART:#{time.utc.strftime("%Y%m%dT%H%M%S")}Z") # force UTC end it "should be able to serialize to ical with an until date" do From 395aa547f3640e21342e6720b3b8206f0344220b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio?= Date: Tue, 8 Sep 2026 14:31:23 +0300 Subject: [PATCH 4/5] Harden iCal TZID handling Follow-up fixes to the TZID support added earlier on this branch, found by auditing the round-trip for issues of the same class. Parser: - Do not require ActiveSupport. The TZID path called String#in_time_zone, which raised NoMethodError for anyone parsing an iCal string containing a TZID without ActiveSupport loaded; ice_cube has no runtime dependency on it. Zones now resolve against ActiveSupport when present, then TZInfo, and otherwise warn and fall back to zone-less parsing. - Read TZID from any parameter position. Only the first parameter was inspected, so DTSTART;VALUE=DATE-TIME;TZID=America/New_York silently lost its zone and fell back to the system zone. - Accept double-quoted TZID values, and split the content line at the first colon that is not inside a quoted parameter, since quoting exists precisely to allow a colon in the value (RFC 5545 sections 3.1 and 3.2.19). - Match the TZID parameter name case-insensitively. - Warn instead of silently falling back to the system zone for an unresolvable TZID, which Outlook's Windows zone names hit routinely. Builder: - Serialize the IANA identifier rather than the ActiveSupport zone label. A schedule built with Time.zone = "Eastern Time (US & Canada)" emitted TZID=Eastern Time (US & Canada), which round-trips within ice_cube but is meaningless to every other iCalendar implementation. - Warn once per process, not on every occurrence, when coercing a zone-less Time to UTC. The suite alone emitted this 25 times. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019tjCXM4KcVq2wjuj2QAZkJ --- lib/ice_cube/builders/ical_builder.rb | 33 ++++++++-- lib/ice_cube/parsers/ical_parser.rb | 71 ++++++++++++++------- lib/ice_cube/time_util.rb | 90 +++++++++++++++++++++++++++ spec/examples/from_ical_spec.rb | 52 ++++++++++++++++ spec/examples/to_ical_spec.rb | 36 +++++++++-- 5 files changed, 248 insertions(+), 34 deletions(-) diff --git a/lib/ice_cube/builders/ical_builder.rb b/lib/ice_cube/builders/ical_builder.rb index dce90694..e2bdd884 100644 --- a/lib/ice_cube/builders/ical_builder.rb +++ b/lib/ice_cube/builders/ical_builder.rb @@ -36,24 +36,45 @@ def self.ical_utc_format(time) def self.ical_format(time, force_utc) time = time.dup.utc if force_utc - # Keep timezone. strftime will serializer short versions of time zone (eg. EEST), - # which are not reversivible, as there are many repeated abbreviated zones. This will result in - # issues in parsing + # Keep the time zone. strftime would serialize the abbreviated zone name + # (eg. EEST), which is not reversible, as the same abbreviation is shared + # by several zones. This would result in issues in parsing. if time.respond_to?(:time_zone) - tz_id = time.time_zone.name - return ";TZID=#{tz_id}:#{IceCube::I18n.l(time, format: "%Y%m%dT%H%M%S")}" # local time specified" + return ";TZID=#{tzid_for(time.time_zone)}:#{IceCube::I18n.l(time, format: "%Y%m%dT%H%M%S")}" # local time specified end if time.utc? ":#{IceCube::I18n.l(time, format: "%Y%m%dT%H%M%SZ")}" # utc time else # Convert to UTC as TZID=+xxxx format is not recognized by JS libraries - warn "IceCube: Time object does not have timezone info. Assuming UTC: #{caller(1..1).first}" + warn_missing_time_zone utc_time = time.dup.utc ":#{IceCube::I18n.l(utc_time, format: "%Y%m%dT%H%M%SZ")}" # converted to utc time end end + # RFC 5545 leaves the TZID registry unspecified but points implementers at + # the IANA (Olson) database, which is what other iCalendar implementations + # expect. ActiveSupport zones may carry a Rails-specific label instead (eg. + # "Eastern Time (US & Canada)"), so prefer the underlying IANA identifier; + # ActiveSupport still resolves those, and everyone else can too. + def self.tzid_for(zone) + if zone.respond_to?(:tzinfo) && zone.tzinfo.respond_to?(:name) + zone.tzinfo.name + else + zone.name + end + end + + # A schedule built from plain Time objects hits this on every serialization, + # so warn once per process rather than once per occurrence. + def self.warn_missing_time_zone + return if @missing_time_zone_warned + + @missing_time_zone_warned = true + warn "IceCube: Time object does not have timezone info. Coercing into UTC: #{caller(2..2).first}" + end + def self.ical_duration(duration) hours = duration / 3600 duration %= 3600 diff --git a/lib/ice_cube/parsers/ical_parser.rb b/lib/ice_cube/parsers/ical_parser.rb index 988f96fe..23297e52 100644 --- a/lib/ice_cube/parsers/ical_parser.rb +++ b/lib/ice_cube/parsers/ical_parser.rb @@ -14,26 +14,23 @@ def self.schedule_from_ical(ical_string, options = {}) end lines.each do |line| - (property, value) = line.split(":") - (property, tzid_param) = property.split(";") + (name_and_params, value) = split_content_line(line) + next if value.nil? - # Extract TZID if present - tzid = nil - if tzid_param && tzid_param.start_with?("TZID=") - tzid = tzid_param[5..-1] # Remove "TZID=" prefix - end + (property, *params) = split_unquoted(name_and_params, ";") + tzid = tzid_from_params(params) case property when "DTSTART" - data[:start_time] = deserialize_time_with_tzid(value, tzid) + data[:start_time] = TimeUtil.deserialize_time_with_zone(value, tzid) when "DTEND" - data[:end_time] = deserialize_time_with_tzid(value, tzid) + data[:end_time] = TimeUtil.deserialize_time_with_zone(value, tzid) when "RDATE" data[:rtimes] ||= [] - data[:rtimes] += value.split(",").map { |v| deserialize_time_with_tzid(v, tzid) } + data[:rtimes] += value.split(",").map { |v| TimeUtil.deserialize_time_with_zone(v, tzid) } when "EXDATE" data[:extimes] ||= [] - data[:extimes] += value.split(",").map { |v| deserialize_time_with_tzid(v, tzid) } + data[:extimes] += value.split(",").map { |v| TimeUtil.deserialize_time_with_zone(v, tzid) } when "DURATION" data[:duration] # FIXME when "RRULE" @@ -44,20 +41,48 @@ def self.schedule_from_ical(ical_string, options = {}) Schedule.from_hash data end - def self.deserialize_time_with_tzid(time_value, tzid) - if tzid.nil? || tzid.empty? - # No TZID, use standard deserialization - TimeUtil.deserialize_time(time_value) - else - # TZID is a timezone name - Assume it's a valid timezone in a try-catch block - begin - TimeUtil.deserialize_time({time: time_value, zone: tzid}) - rescue ArgumentError - # If the timezone is invalid, fall back to standard deserialization - # Perhaps we want to log this? - TimeUtil.deserialize_time(time_value) + # Split a content line into its property part (name and parameters) and its + # value, at the first colon that is not inside a quoted parameter value. + # Parameter values are quoted precisely so they may contain a colon + # (RFC 5545 section 3.1), as in DTSTART;TZID="GMT+05:00":20130101T090000. + # Returns a nil value for a line with no colon at all. + def self.split_content_line(line) + in_quotes = false + line.each_char.with_index do |char, index| + case char + when '"' then in_quotes = !in_quotes + when ":" then return [line[0, index], line[(index + 1)..]] unless in_quotes end end + [line, nil] + end + + # Split on +delimiter+, ignoring delimiters inside a quoted value. + def self.split_unquoted(string, delimiter) + parts = [+""] + in_quotes = false + string.each_char do |char| + in_quotes = !in_quotes if char == '"' + if char == delimiter && !in_quotes + parts << +"" + else + parts.last << char + end + end + parts + end + + # Find the TZID parameter among a property's parameters. TZID is not + # necessarily the first parameter (DTSTART;VALUE=DATE-TIME;TZID=... is + # equally valid), parameter names are case-insensitive, and the value may be + # double-quoted (RFC 5545 sections 3.1 and 3.2.19). + def self.tzid_from_params(params) + param = params.find { |p| p =~ /\ATZID=/i } + return nil unless param + + tzid = param.split("=", 2).last.to_s.strip + tzid = tzid[1..-2] if tzid.length >= 2 && tzid.start_with?('"') && tzid.end_with?('"') + tzid.empty? ? nil : tzid end def self.rule_from_ical(ical) diff --git a/lib/ice_cube/time_util.rb b/lib/ice_cube/time_util.rb index cbf2dead..e6f97afa 100644 --- a/lib/ice_cube/time_util.rb +++ b/lib/ice_cube/time_util.rb @@ -114,6 +114,96 @@ def self.deserialize_time(time_or_hash) end end + # Deserialize a time in the named time zone (an iCalendar TZID). + # + # TZID values are not registered by RFC 5545, which points implementers at + # the IANA (Olson) database instead, so +tzid+ is resolved against whichever + # zone database is available: ActiveSupport first, then TZInfo. ice_cube has + # no runtime dependency on either, and a TZID may also simply be unknown + # (Outlook, for example, emits Windows zone names such as "Eastern Standard + # Time"). When the zone cannot be resolved this warns and falls back to + # zone-less parsing rather than raising, so an unrecognised TZID degrades to + # the pre-TZID behaviour instead of breaking the parse. + def self.deserialize_time_with_zone(time_value, tzid) + return deserialize_time(time_value) if tzid.nil? || tzid.empty? + + unless (zone = find_zone(tzid)) + reason = if zone_database_available? + "unknown TZID #{tzid.inspect} (an IANA identifier such as \"America/New_York\" is expected)" + else + "cannot resolve TZID #{tzid.inspect} without a time zone database (require \"active_support/time\" or \"tzinfo\")" + end + warn "IceCube: #{reason}; parsing #{time_value.to_s.strip.inspect} " \ + "without a time zone at: #{caller(1..1).first}" + return deserialize_time(time_value) + end + + time_in_zone(time_value, zone) || deserialize_time(time_value) + end + + # Whether any time zone database ice_cube knows how to use is loaded. + def self.zone_database_available? + (Time.respond_to?(:find_zone) || defined?(TZInfo::Timezone)) ? true : false + end + + # Look up an IANA time zone identifier, preferring ActiveSupport (which also + # accepts Rails' own zone labels) and falling back to TZInfo. Returns nil + # when neither is loaded or the identifier is not recognised. + def self.find_zone(tzid) + if Time.respond_to?(:find_zone) + Time.find_zone(tzid) + elsif defined?(TZInfo::Timezone) + begin + TZInfo::Timezone.get(tzid) + rescue + nil + end + end + end + + # Interpret an iCalendar date-time string as a wall clock reading in +zone+. + # A trailing "Z" marks the value as UTC, in which case it is converted into + # the zone rather than read as local time. Returns nil if the value cannot + # be interpreted, leaving the caller to fall back. + def self.time_in_zone(time_value, zone) + time_value = time_value.to_s.strip + if zone.respond_to?(:parse) # ActiveSupport::TimeZone + zone.parse(time_value) + else # TZInfo::Timezone + time_in_tzinfo_zone(time_value, zone) + end + rescue + nil + end + + # TZInfo gives the correct offset for the instant, but only ActiveSupport + # zones survive as a zone on the Time object, so occurrences after a DST + # transition keep the start time's offset rather than following the zone. + # Note that TZInfo returns a TimeWithOffset whose #zone is an abbreviation + # (eg. "EST"); match_zone treats that as a system-local time and would + # relocate every occurrence into the system zone, so flatten it to a plain + # Time carrying just the offset. + def self.time_in_tzinfo_zone(time_value, zone) + time = Time.parse(time_value) + local = if time_value.end_with?("Z") + zone.utc_to_local(time) + else + zone.local_time(*CLOCK_VALUES.map { |unit| time.public_send(unit) }) + end + warn_tzinfo_dst_limitation + Time.new(local.year, local.month, local.day, local.hour, local.min, local.sec, local.utc_offset) + end + + # Once per process: this caveat applies to every TZID parsed this way. + def self.warn_tzinfo_dst_limitation + return if @tzinfo_dst_warned + + @tzinfo_dst_warned = true + warn "IceCube: resolving TZID with TZInfo. Occurrences will keep the start " \ + "time's UTC offset across DST transitions; require \"active_support/time\" " \ + "for full DST support." + end + # Get a more precise equality for time objects # Ruby provides a Time#hash method, but it fails to account for UTC # offset (so the current date may be different) or DST rules (so the diff --git a/spec/examples/from_ical_spec.rb b/spec/examples/from_ical_spec.rb index b2f7928b..e9ebf716 100644 --- a/spec/examples/from_ical_spec.rb +++ b/spec/examples/from_ical_spec.rb @@ -461,5 +461,57 @@ def sorted_ical(ical) expect(schedule.to_ical.split("\n").find { |x| x =~ /RRULE/ }).to eq("RRULE:FREQ=WEEKLY;UNTIL=20130531T100000Z;BYDAY=TH") end end + + describe "TZID parameter handling" do + it "should read TZID when it is not the first parameter" do + schedule = IceCube::Schedule.from_ical "DTSTART;VALUE=DATE-TIME;TZID=America/New_York:20130101T090000" + expect(schedule.start_time.utc_offset).to eq(-5 * 3600) + expect(schedule.start_time).to eq(Time.utc(2013, 1, 1, 14, 0, 0)) + end + + it "should read a TZID whose parameter name is lower case" do + schedule = IceCube::Schedule.from_ical "DTSTART;tzid=America/New_York:20130101T090000" + expect(schedule.start_time).to eq(Time.utc(2013, 1, 1, 14, 0, 0)) + end + + it "should read a double-quoted TZID" do + schedule = IceCube::Schedule.from_ical %(DTSTART;TZID="America/New_York":20130101T090000) + expect(schedule.start_time).to eq(Time.utc(2013, 1, 1, 14, 0, 0)) + end + + it "should not split the value on a colon inside a quoted parameter" do + schedule = IceCube::Schedule.from_ical %(DTSTART;TZID="Etc/GMT+5":20130101T090000) + expect(schedule.start_time).to eq(Time.utc(2013, 1, 1, 14, 0, 0)) + end + + it "should ignore a line with no value at all" do + expect { + IceCube::Schedule.from_ical "BEGIN:VEVENT\nDTSTART;TZID=America/New_York:20130101T090000\nEND" + }.not_to raise_error + end + + it "should fall back to zone-less parsing and warn for an unknown TZID", expect_warnings: true do + schedule = nil + warnings = capture_warnings do + schedule = IceCube::Schedule.from_ical "DTSTART;TZID=Not/AZone:20130101T090000" + end + expect(warnings).to match(/unknown TZID "Not\/AZone"/) + expect(schedule.start_time).to eq(Time.parse("20130101T090000")) + end + + it "should not raise when a time zone database is unavailable" do + # ice_cube has no runtime dependency on ActiveSupport or TZInfo, so a + # TZID must degrade rather than blow up when neither is loaded. + allow(TimeUtil).to receive(:find_zone).and_return(nil) + allow(TimeUtil).to receive(:zone_database_available?).and_return(false) + + schedule = nil + warnings = capture_warnings do + schedule = IceCube::Schedule.from_ical "DTSTART;TZID=America/New_York:20130101T090000" + end + expect(warnings).to match(/without a time zone database/) + expect(schedule.start_time).to eq(Time.parse("20130101T090000")) + end + end end end diff --git a/spec/examples/to_ical_spec.rb b/spec/examples/to_ical_spec.rb index 7831fdf1..8d6ea696 100644 --- a/spec/examples/to_ical_spec.rb +++ b/spec/examples/to_ical_spec.rb @@ -113,7 +113,7 @@ it "should be able to serialize a base schedule to ical in local time" do Time.zone = "Eastern Time (US & Canada)" schedule = IceCube::Schedule.new(Time.zone.local(2010, 5, 10, 9, 0, 0)) - expect(schedule.to_ical).to eq("DTSTART;TZID=Eastern Time (US & Canada):20100510T090000") + expect(schedule.to_ical).to eq("DTSTART;TZID=America/New_York:20100510T090000") end it "should be able to serialize a base schedule to ical in UTC time" do @@ -126,7 +126,7 @@ schedule = IceCube::Schedule.new(Time.zone.local(2010, 5, 10, 9, 0, 0)) schedule.add_recurrence_rule IceCube::Rule.weekly # test equality - expectation = "DTSTART;TZID=Pacific Time (US & Canada):20100510T090000\n" + expectation = "DTSTART;TZID=America/Los_Angeles:20100510T090000\n" expectation << "RRULE:FREQ=WEEKLY" expect(schedule.to_ical).to eq(expectation) end @@ -136,7 +136,7 @@ schedule = IceCube::Schedule.new(Time.zone.local(2010, 10, 20, 4, 30, 0)) schedule.add_recurrence_rule IceCube::Rule.weekly.day_of_week(monday: [2, -1]) schedule.add_recurrence_rule IceCube::Rule.hourly - expectation = "DTSTART;TZID=Eastern Time (US & Canada):20101020T043000\n" + expectation = "DTSTART;TZID=America/New_York:20101020T043000\n" expectation << "RRULE:FREQ=WEEKLY;BYDAY=2MO,-1MO\n" expectation << "RRULE:FREQ=HOURLY" expect(schedule.to_ical).to eq(expectation) @@ -147,7 +147,7 @@ schedule = IceCube::Schedule.new(Time.zone.local(2010, 5, 10, 9, 0, 0)) schedule.add_exception_rule IceCube::Rule.weekly # test equality - expectation = "DTSTART;TZID=Pacific Time (US & Canada):20100510T090000\n" + expectation = "DTSTART;TZID=America/Los_Angeles:20100510T090000\n" expectation << "EXRULE:FREQ=WEEKLY" expect(schedule.to_ical).to eq(expectation) end @@ -157,7 +157,7 @@ schedule = IceCube::Schedule.new(Time.zone.local(2010, 10, 20, 4, 30, 0)) schedule.add_exception_rule IceCube::Rule.weekly.day_of_week(monday: [2, -1]) schedule.add_exception_rule IceCube::Rule.hourly - expectation = "DTSTART;TZID=Eastern Time (US & Canada):20101020T043000\n" + expectation = "DTSTART;TZID=America/New_York:20101020T043000\n" expectation << "EXRULE:FREQ=WEEKLY;BYDAY=2MO,-1MO\n" expectation << "EXRULE:FREQ=HOURLY" expect(schedule.to_ical).to eq(expectation) @@ -264,4 +264,30 @@ rule.interval(2) expect(rule.to_ical).to match(/^FREQ=WEEKLY;INTERVAL=2/) end + + describe "time zone identifiers" do + before { IceCube::IcalBuilder.instance_variable_set(:@missing_time_zone_warned, false) } + + it "should serialize the IANA identifier rather than a Rails zone label" do + Time.zone = "Eastern Time (US & Canada)" + schedule = IceCube::Schedule.new(Time.zone.local(2010, 5, 10, 9, 0, 0)) + expect(schedule.to_ical).to eq("DTSTART;TZID=America/New_York:20100510T090000") + end + + it "should round-trip a schedule created from a Rails zone label" do + Time.zone = "Eastern Time (US & Canada)" + schedule = IceCube::Schedule.new(Time.zone.local(2013, 3, 8, 9, 0, 0)) + schedule.add_recurrence_rule IceCube::Rule.daily + round_tripped = IceCube::Schedule.from_ical(schedule.to_ical) + expect(round_tripped.first(4)).to eq(schedule.first(4)) + end + + it "should warn only once per process for times with no zone info", expect_warnings: true do + schedule = IceCube::Schedule.new(Time.local(2010, 5, 10, 9, 0, 0)) + warnings = capture_warnings do + 3.times { schedule.to_ical } + end + expect(warnings.scan("does not have timezone info").size).to eq(1) + end + end end From d681dd2cc007d6c38fc7d80372af590d5e927678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio?= Date: Tue, 8 Sep 2026 16:23:30 +0300 Subject: [PATCH 5/5] Add explicit tests for AR formatted timezone --- spec/examples/from_ical_spec.rb | 36 +++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/spec/examples/from_ical_spec.rb b/spec/examples/from_ical_spec.rb index e9ebf716..57b8c626 100644 --- a/spec/examples/from_ical_spec.rb +++ b/spec/examples/from_ical_spec.rb @@ -513,5 +513,41 @@ def sorted_ical(ical) expect(schedule.start_time).to eq(Time.parse("20130101T090000")) end end + + describe "ActiveSupport time zone labels" do + # Serialization emits IANA identifiers, but ActiveSupport names its zones + # with its own labels, and earlier versions wrote those into the TZID. + # Those strings are still out there, so they have to keep parsing. + it "should parse a TZID given as an ActiveSupport zone label" do + schedule = IceCube::Schedule.from_ical "DTSTART;TZID=Eastern Time (US & Canada):20100510T090000" + expect(schedule.start_time).to eq(Time.utc(2010, 5, 10, 13, 0, 0)) + expect(schedule.start_time.utc_offset).to eq(-4 * 3600) + end + + it "should follow DST for a TZID given as an ActiveSupport zone label" do + schedule = IceCube::Schedule.from_ical <<~ICAL + DTSTART;TZID=Eastern Time (US & Canada):20130308T090000 + RRULE:FREQ=DAILY + ICAL + expect(schedule.first(4).map(&:utc_offset)).to eq([-5, -5, -4, -4].map { |h| h * 3600 }) + end + + it "should read a zone label and an IANA identifier as the same zone" do + labelled = IceCube::Schedule.from_ical <<~ICAL + DTSTART;TZID=Eastern Time (US & Canada):20130308T090000 + RRULE:FREQ=DAILY + ICAL + iana = IceCube::Schedule.from_ical <<~ICAL + DTSTART;TZID=America/New_York:20130308T090000 + RRULE:FREQ=DAILY + ICAL + expect(labelled.first(4)).to eq(iana.first(4)) + end + + it "should re-serialize a zone label as its IANA identifier" do + schedule = IceCube::Schedule.from_ical "DTSTART;TZID=Eastern Time (US & Canada):20100510T090000" + expect(schedule.to_ical).to eq("DTSTART;TZID=America/New_York:20100510T090000") + end + end end end