icalendar 2.10.3 → 2.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '091658a5c785f876d990794ef88444d4ea39e047aaf533ca3e93bfc8549c2495'
4
- data.tar.gz: 1292b5e139aae74264694245d9a722bc42e501abe6620b341b12d4922ac3cac8
3
+ metadata.gz: c581285bbacae9839202046bea20f80be28315865013f9f59e50851fbbed7a34
4
+ data.tar.gz: 8be871aab3ba233a56a6442783f6a32cbf138ae145fc777bed68fdf4beff0826
5
5
  SHA512:
6
- metadata.gz: 698656b8f89e982eb58c80cce76c4a8512df6ebdbcc37779eeeb2c57daaf3f180fe0226c3a8bdb66155438e2f71afa6df76b537d8a4cb528646211c850a1e70d
7
- data.tar.gz: acd49ceb561f8f5a61be057c3ea00b938a6907a4af9826afd27fe2f484e256d1ff97869d5777ac58e79e55834ed97cc7c56136fb38d6b54f500959dc054c8d22
6
+ metadata.gz: e847fc2a27df55033006bfc9b9d1572563a9a9bafc8cf286959e82e4a47f0c64fcda9a7b7470e37031e635a1e9b086eda769b75108a02e020782c1683fad763e
7
+ data.tar.gz: 3a8f8503ace1467d352d586ede3e4c8c91b7ab5d1fec5271c5add7658014a1cc61fc4b2f2ec32b09d481746ff14139fb83b7d14f63b23526056b183bb9133de6
@@ -21,6 +21,7 @@ jobs:
21
21
  - 3.1
22
22
  - 3.2
23
23
  - 3.3
24
+ - 3.4
24
25
 
25
26
  steps:
26
27
  - uses: actions/checkout@v4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 2.12.1 - 2025-10-19
4
+ - Fix a problem with invalid ics generation for calendars with custom properties that include a `tzid` parameter.
5
+
6
+ ## 2.12.0 - 2025-09-26
7
+ - Support timezone lookup by Windows names - Ronak Gothi
8
+
9
+ ## 2.11.2 - 2025-06-21
10
+ - Deal with more bad value parameter values by falling back to the property default type
11
+
12
+ ## 2.11.1 - 2025-06-06
13
+ - Gracefully deal with malformed ics files that use spaces in the value parameter instead of hyphens
14
+
15
+ ## 2.11.0 - 2025-04-12
16
+ - Require gems for ruby 3.4 that used to be in stdlib - Go Sueyoshi
17
+ - Refactor how timezone offsets are calculated - Pat Allan
18
+ - 'tzid' param is always returned as an array when accessing `ical_params` directly, rather than matching whatever the input parsed. ICS output remains unchanged
19
+
3
20
  ## 2.10.3 - 2024-09-21
4
21
  - Override Icalendar::Value.== so that value objects can be compared to each other.
5
22
  - Correctly load activesupport before activesupport/time
data/icalendar.gemspec CHANGED
@@ -31,7 +31,9 @@ ActiveSupport is required for TimeWithZone support, but not required for general
31
31
 
32
32
  s.required_ruby_version = '>= 2.4.0'
33
33
 
34
+ s.add_dependency 'base64'
34
35
  s.add_dependency 'ice_cube', '~> 0.16'
36
+ s.add_dependency 'logger'
35
37
  s.add_dependency 'ostruct'
36
38
 
37
39
  s.add_development_dependency 'rake', '~> 13.0'
@@ -3,32 +3,65 @@
3
3
  require 'delegate'
4
4
 
5
5
  module Icalendar
6
- class DowncasedHash < ::SimpleDelegator
6
+ class DowncasedHash
7
7
 
8
8
  def initialize(base)
9
- super Hash.new
9
+ @obj = Hash.new
10
10
  base.each do |key, value|
11
11
  self[key] = value
12
12
  end
13
13
  end
14
14
 
15
15
  def []=(key, value)
16
- __getobj__[key.to_s.downcase] = value
16
+ obj[key.to_s.downcase] = value
17
17
  end
18
18
 
19
19
  def [](key)
20
- __getobj__[key.to_s.downcase]
20
+ obj[key.to_s.downcase]
21
21
  end
22
22
 
23
23
  def has_key?(key)
24
- __getobj__.has_key? key.to_s.downcase
24
+ obj.has_key? key.to_s.downcase
25
25
  end
26
26
  alias_method :include?, :has_key?
27
27
  alias_method :member?, :has_key?
28
28
 
29
29
  def delete(key, &block)
30
- __getobj__.delete key.to_s.downcase, &block
30
+ obj.delete key.to_s.downcase, &block
31
31
  end
32
+
33
+ def merge(*other_hashes)
34
+ Icalendar::DowncasedHash.new(obj).merge!(*other_hashes)
35
+ end
36
+
37
+ def merge!(*other_hashes)
38
+ other_hashes.each do |hash|
39
+ hash.each do |key, value|
40
+ self[key] = value
41
+ end
42
+ end
43
+ self
44
+ end
45
+
46
+ def empty?
47
+ obj.empty?
48
+ end
49
+
50
+ def each(&block)
51
+ obj.each &block
52
+ end
53
+
54
+ def map(&block)
55
+ obj.map &block
56
+ end
57
+
58
+ def ==(other)
59
+ obj == Icalendar::DowncasedHash(other).obj
60
+ end
61
+
62
+ protected
63
+
64
+ attr_reader :obj
32
65
  end
33
66
 
34
67
  def self.DowncasedHash(base)
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Icalendar
4
+ class Offset
5
+ class ActiveSupportExact < Icalendar::Offset
6
+ def valid?
7
+ support_classes_defined? && tz
8
+ end
9
+
10
+ def normalized_value
11
+ Icalendar.logger.debug("Plan a - parsing #{value}/#{tzid} as ActiveSupport::TimeWithZone")
12
+ # plan a - use ActiveSupport::TimeWithZone
13
+ Icalendar::Values::Helpers::ActiveSupportTimeWithZoneAdapter.new(nil, tz, value)
14
+ end
15
+
16
+ private
17
+
18
+ def tz
19
+ @tz ||= ActiveSupport::TimeZone[tzid]
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Icalendar
4
+ class Offset
5
+ class ActiveSupportPartial < Offset
6
+ def valid?
7
+ support_classes_defined? && tz
8
+ end
9
+
10
+ def normalized_value
11
+ # plan c - try to find an ActiveSupport::TimeWithZone based on the first word of the tzid
12
+ Icalendar.logger.debug("Plan c - parsing #{value}/#{tz.tzinfo.name} as ActiveSupport::TimeWithZone")
13
+ Icalendar::Values::Helpers::ActiveSupportTimeWithZoneAdapter.new(nil, tz, value)
14
+ end
15
+
16
+ def normalized_tzid
17
+ [tz.tzinfo.name]
18
+ end
19
+
20
+ private
21
+
22
+ def tz
23
+ @tz ||= ActiveSupport::TimeZone[tzid.split.first]
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Icalendar
4
+ class Offset
5
+ class Null < Offset
6
+ def valid?
7
+ true
8
+ end
9
+
10
+ def normalized_value
11
+ # plan d - just ignore the tzid
12
+ Icalendar.logger.info("Ignoring timezone #{tzid} for time #{value}")
13
+ nil
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Icalendar
4
+ class Offset
5
+ class TimeZoneStore < Offset
6
+ def valid?
7
+ timezone_store && tz_info
8
+ end
9
+
10
+ def normalized_value
11
+ # plan b - use definition from provided `VTIMEZONE`
12
+ offset = tz_info.offset_for_local(value).to_s
13
+
14
+ Icalendar.logger.debug("Plan b - parsing #{value} with offset: #{offset}")
15
+ if value.respond_to?(:change)
16
+ value.change offset: offset
17
+ else
18
+ ::Time.new value.year, value.month, value.day, value.hour, value.min, value.sec, offset
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def tz_info
25
+ @tz_info ||= timezone_store.retrieve(tzid)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This module contains mappings from Windows timezone identifiers to Olson timezone identifiers.
4
+ #
5
+ # The data is taken from the unicode consortium [0], the proposal and rationale
6
+ # for this mapping is also available at the unicode consortium [1].
7
+ #
8
+ # [0] https://www.unicode.org/cldr/cldr-aux/charts/29/supplemental/zone_tzid.html
9
+ # [1] https://cldr.unicode.org/development/development-process/design-proposals/extended-windows-olson-zid-mapping
10
+
11
+ module Icalendar
12
+ class Offset
13
+ class WindowsToIana < Offset
14
+ WINDOWS_TO_IANA = {
15
+ 'AUS Central Standard Time' => 'Australia/Darwin',
16
+ 'AUS Eastern Standard Time' => 'Australia/Sydney',
17
+ 'Afghanistan Standard Time' => 'Asia/Kabul',
18
+ 'Alaskan Standard Time' => 'America/Anchorage',
19
+ 'Arab Standard Time' => 'Asia/Riyadh',
20
+ 'Arabian Standard Time' => 'Asia/Dubai',
21
+ 'Arabic Standard Time' => 'Asia/Baghdad',
22
+ 'Argentina Standard Time' => 'America/Argentina/Buenos_Aires',
23
+ 'Atlantic Standard Time' => 'America/Halifax',
24
+ 'Azerbaijan Standard Time' => 'Asia/Baku',
25
+ 'Azores Standard Time' => 'Atlantic/Azores',
26
+ 'Bahia Standard Time' => 'America/Bahia',
27
+ 'Bangladesh Standard Time' => 'Asia/Dhaka',
28
+ 'Belarus Standard Time' => 'Europe/Minsk',
29
+ 'Canada Central Standard Time' => 'America/Regina',
30
+ 'Cape Verde Standard Time' => 'Atlantic/Cape_Verde',
31
+ 'Caucasus Standard Time' => 'Asia/Yerevan',
32
+ 'Cen. Australia Standard Time' => 'Australia/Adelaide',
33
+ 'Central America Standard Time' => 'America/Guatemala',
34
+ 'Central Asia Standard Time' => 'Asia/Almaty',
35
+ 'Central Brazilian Standard Time' => 'America/Cuiaba',
36
+ 'Central Europe Standard Time' => 'Europe/Budapest',
37
+ 'Central European Standard Time' => 'Europe/Warsaw',
38
+ 'Central Pacific Standard Time' => 'Pacific/Guadalcanal',
39
+ 'Central Standard Time' => 'America/Chicago',
40
+ 'Central Standard Time (Mexico)' => 'America/Mexico_City',
41
+ 'China Standard Time' => 'Asia/Shanghai',
42
+ 'Dateline Standard Time' => 'Etc/GMT+12',
43
+ 'E. Africa Standard Time' => 'Africa/Nairobi',
44
+ 'E. Australia Standard Time' => 'Australia/Brisbane',
45
+ 'E. Europe Standard Time' => 'Europe/Chisinau',
46
+ 'E. South America Standard Time' => 'America/Sao_Paulo',
47
+ 'Eastern Standard Time' => 'America/New_York',
48
+ 'Eastern Standard Time (Mexico)' => 'America/Cancun',
49
+ 'Egypt Standard Time' => 'Africa/Cairo',
50
+ 'Ekaterinburg Standard Time' => 'Asia/Yekaterinburg',
51
+ 'FLE Standard Time' => 'Europe/Kyiv',
52
+ 'Fiji Standard Time' => 'Pacific/Fiji',
53
+ 'GMT Standard Time' => 'Europe/London',
54
+ 'GTB Standard Time' => 'Europe/Bucharest',
55
+ 'Georgian Standard Time' => 'Asia/Tbilisi',
56
+ 'Greenland Standard Time' => 'America/Nuuk',
57
+ 'Greenwich Standard Time' => 'Atlantic/Reykjavik',
58
+ 'Hawaiian Standard Time' => 'Pacific/Honolulu',
59
+ 'India Standard Time' => 'Asia/Kolkata',
60
+ 'Iran Standard Time' => 'Asia/Tehran',
61
+ 'Israel Standard Time' => 'Asia/Jerusalem',
62
+ 'Jordan Standard Time' => 'Asia/Amman',
63
+ 'Kaliningrad Standard Time' => 'Europe/Kaliningrad',
64
+ 'Korea Standard Time' => 'Asia/Seoul',
65
+ 'Libya Standard Time' => 'Africa/Tripoli',
66
+ 'Line Islands Standard Time' => 'Pacific/Kiritimati',
67
+ 'Magadan Standard Time' => 'Asia/Magadan',
68
+ 'Mauritius Standard Time' => 'Indian/Mauritius',
69
+ 'Middle East Standard Time' => 'Asia/Beirut',
70
+ 'Montevideo Standard Time' => 'America/Montevideo',
71
+ 'Morocco Standard Time' => 'Africa/Casablanca',
72
+ 'Mountain Standard Time' => 'America/Denver',
73
+ 'Mountain Standard Time (Mexico)' => 'America/Chihuahua',
74
+ 'Myanmar Standard Time' => 'Asia/Yangon',
75
+ 'N. Central Asia Standard Time' => 'Asia/Novosibirsk',
76
+ 'Namibia Standard Time' => 'Africa/Windhoek',
77
+ 'Nepal Standard Time' => 'Asia/Kathmandu',
78
+ 'New Zealand Standard Time' => 'Pacific/Auckland',
79
+ 'Newfoundland Standard Time' => 'America/St_Johns',
80
+ 'North Asia East Standard Time' => 'Asia/Irkutsk',
81
+ 'North Asia Standard Time' => 'Asia/Krasnoyarsk',
82
+ 'North Korea Standard Time' => 'Asia/Pyongyang',
83
+ 'Pacific SA Standard Time' => 'America/Santiago',
84
+ 'Pacific Standard Time' => 'America/Los_Angeles',
85
+ 'Pakistan Standard Time' => 'Asia/Karachi',
86
+ 'Paraguay Standard Time' => 'America/Asuncion',
87
+ 'Romance Standard Time' => 'Europe/Paris',
88
+ 'Russia Time Zone 10' => 'Asia/Srednekolymsk',
89
+ 'Russia Time Zone 11' => 'Asia/Kamchatka',
90
+ 'Russia Time Zone 3' => 'Europe/Samara',
91
+ 'Russian Standard Time' => 'Europe/Moscow',
92
+ 'SA Eastern Standard Time' => 'America/Cayenne',
93
+ 'SA Pacific Standard Time' => 'America/Bogota',
94
+ 'SA Western Standard Time' => 'America/La_Paz',
95
+ 'SE Asia Standard Time' => 'Asia/Bangkok',
96
+ 'Samoa Standard Time' => 'Pacific/Apia',
97
+ 'Singapore Standard Time' => 'Asia/Singapore',
98
+ 'South Africa Standard Time' => 'Africa/Johannesburg',
99
+ 'Sri Lanka Standard Time' => 'Asia/Colombo',
100
+ 'Syria Standard Time' => 'Asia/Damascus',
101
+ 'Taipei Standard Time' => 'Asia/Taipei',
102
+ 'Tasmania Standard Time' => 'Australia/Hobart',
103
+ 'Tokyo Standard Time' => 'Asia/Tokyo',
104
+ 'Tonga Standard Time' => 'Pacific/Tongatapu',
105
+ 'Turkey Standard Time' => 'Europe/Istanbul',
106
+ 'US Eastern Standard Time' => 'America/Indiana/Indianapolis',
107
+ 'US Mountain Standard Time' => 'America/Phoenix',
108
+ 'UTC' => 'Etc/GMT',
109
+ 'UTC+12' => 'Etc/GMT-12',
110
+ 'UTC-02' => 'Etc/GMT+2',
111
+ 'UTC-11' => 'Etc/GMT+11',
112
+ 'Ulaanbaatar Standard Time' => 'Asia/Ulaanbaatar',
113
+ 'Venezuela Standard Time' => 'America/Caracas',
114
+ 'Vladivostok Standard Time' => 'Asia/Vladivostok',
115
+ 'W. Australia Standard Time' => 'Australia/Perth',
116
+ 'W. Central Africa Standard Time' => 'Africa/Lagos',
117
+ 'W. Europe Standard Time' => 'Europe/Berlin',
118
+ 'West Asia Standard Time' => 'Asia/Tashkent',
119
+ 'West Pacific Standard Time' => 'Pacific/Port_Moresby',
120
+ 'Yakutsk Standard Time' => 'Asia/Yakutsk'
121
+ }.freeze
122
+
123
+ def valid?
124
+ support_classes_defined? && tz
125
+ end
126
+
127
+ def normalized_value
128
+ Icalendar.logger.debug("Plan a - parsing #{value}/#{tzid} as ActiveSupport::TimeWithZone")
129
+ # plan a - use ActiveSupport::TimeWithZone
130
+ Icalendar::Values::Helpers::ActiveSupportTimeWithZoneAdapter.new(nil, tz, value)
131
+ end
132
+
133
+ def normalized_tzid
134
+ [WINDOWS_TO_IANA[tzid]].compact
135
+ end
136
+
137
+ private
138
+
139
+ def tz
140
+ @tz ||= ActiveSupport::TimeZone[normalized_tzid.first || '']
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Icalendar
4
+ class Offset
5
+ def self.build(value, params, timezone_store)
6
+ return nil if params.nil? || params['tzid'].nil?
7
+
8
+ tzid = Array(params['tzid']).first
9
+
10
+ [
11
+ Icalendar::Offset::ActiveSupportExact,
12
+ Icalendar::Offset::TimeZoneStore,
13
+ Icalendar::Offset::WindowsToIana,
14
+ Icalendar::Offset::ActiveSupportPartial,
15
+ Icalendar::Offset::Null
16
+ ].lazy.map { |klass| klass.new(tzid, value, timezone_store) }.detect(&:valid?)
17
+ end
18
+
19
+ def initialize(tzid, value, timezone_store)
20
+ @tzid = tzid
21
+ @value = value
22
+ @timezone_store = timezone_store
23
+ end
24
+
25
+ def normalized_tzid
26
+ Array(tzid)
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :tzid, :value, :timezone_store
32
+
33
+ def support_classes_defined?
34
+ defined?(ActiveSupport::TimeZone) &&
35
+ defined?(Icalendar::Values::Helpers::ActiveSupportTimeWithZoneAdapter)
36
+ end
37
+ end
38
+ end
39
+
40
+ require_relative 'offset/active_support_exact'
41
+ require_relative 'offset/active_support_partial'
42
+ require_relative 'offset/null'
43
+ require_relative 'offset/time_zone_store'
44
+ require_relative 'offset/windows_to_iana'
@@ -87,9 +87,10 @@ module Icalendar
87
87
  Icalendar::Values::Helpers::Array.new fields[:value].split(WRAP_PROPERTY_VALUE_SPLIT_REGEX),
88
88
  klass,
89
89
  fields[:params],
90
- delimiter: delimiter
90
+ delimiter: delimiter,
91
+ timezone_store: timezone_store
91
92
  else
92
- klass.new fields[:value], fields[:params]
93
+ klass.new fields[:value], fields[:params], timezone_store: timezone_store
93
94
  end
94
95
  rescue Icalendar::Values::DateTime::FormatError => fe
95
96
  raise fe if strict?
@@ -105,7 +106,7 @@ module Icalendar
105
106
  ((multi_property && value =~ WRAP_IN_ARRAY_REGEX_1) || value =~ WRAP_IN_ARRAY_REGEX_2)
106
107
  end
107
108
 
108
- GET_WRAPPER_CLASS_GSUB_REGEX = /(?:\A|-)(.)/.freeze
109
+ GET_WRAPPER_CLASS_GSUB_REGEX = /(?:\A|-|\s+)(.)/.freeze
109
110
 
110
111
  def get_wrapper_class(component, fields)
111
112
  klass = component.class.default_property_types[fields[:name]]
@@ -113,7 +114,12 @@ module Icalendar
113
114
  klass_name = fields[:params].delete('value').first
114
115
  unless klass_name.upcase == klass.value_type
115
116
  klass_name = "Icalendar::Values::#{klass_name.downcase.gsub(GET_WRAPPER_CLASS_GSUB_REGEX) { |m| m[-1].upcase }}"
116
- klass = Object.const_get klass_name if Object.const_defined?(klass_name)
117
+ begin
118
+ klass = Object.const_get klass_name if Object.const_defined?(klass_name)
119
+ rescue NameError => e
120
+ Icalendar.logger.error "NameError trying to find value type for #{component.name} | #{fields[:name]}: #{e.message}"
121
+ raise e if strict?
122
+ end
117
123
  end
118
124
  end
119
125
  klass
@@ -210,9 +216,6 @@ module Icalendar
210
216
  if param_value.size > 0
211
217
  param_value = param_value.gsub(PVALUE_GSUB_REGEX, '')
212
218
  params[param_name] << param_value
213
- if param_name == 'tzid'
214
- params['x-tz-store'] = timezone_store
215
- end
216
219
  end
217
220
  end
218
221
  end
@@ -8,10 +8,11 @@ module Icalendar
8
8
 
9
9
  class Value < ::SimpleDelegator
10
10
 
11
- attr_accessor :ical_params
11
+ attr_accessor :ical_params, :context
12
12
 
13
- def initialize(value, params = {})
13
+ def initialize(value, params = {}, context = {})
14
14
  @ical_params = Icalendar::DowncasedHash(params)
15
+ @context = Icalendar::DowncasedHash(context)
15
16
  super value
16
17
  end
17
18
 
@@ -5,8 +5,8 @@ module Icalendar
5
5
 
6
6
  class Boolean < Value
7
7
 
8
- def initialize(value, params = {})
9
- super value.to_s.downcase == 'true', params
8
+ def initialize(value, *args)
9
+ super value.to_s.downcase == 'true', *args
10
10
  end
11
11
 
12
12
  def value_ical
@@ -16,4 +16,4 @@ module Icalendar
16
16
  end
17
17
 
18
18
  end
19
- end
19
+ end
@@ -8,9 +8,8 @@ module Icalendar
8
8
  class Date < Value
9
9
  FORMAT = '%Y%m%d'
10
10
 
11
- def initialize(value, params = {})
11
+ def initialize(value, params = {}, *args)
12
12
  params.delete 'tzid'
13
- params.delete 'x-tz-store'
14
13
  if value.is_a? String
15
14
  begin
16
15
  parsed_date = ::Date.strptime(value, FORMAT)
@@ -18,9 +17,9 @@ module Icalendar
18
17
  raise FormatError.new("Failed to parse \"#{value}\" - #{e.message}")
19
18
  end
20
19
 
21
- super parsed_date, params
20
+ super parsed_date, params, *args
22
21
  elsif value.respond_to? :to_date
23
- super value.to_date, params
22
+ super value.to_date, params, *args
24
23
  else
25
24
  super
26
25
  end
@@ -11,7 +11,7 @@ module Icalendar
11
11
 
12
12
  FORMAT = '%Y%m%dT%H%M%S'
13
13
 
14
- def initialize(value, params = {})
14
+ def initialize(value, params = {}, *args)
15
15
  if value.is_a? String
16
16
  params['tzid'] = 'UTC' if value.end_with? 'Z'
17
17
 
@@ -21,9 +21,9 @@ module Icalendar
21
21
  raise FormatError.new("Failed to parse \"#{value}\" - #{e.message}")
22
22
  end
23
23
 
24
- super parsed_date, params
24
+ super parsed_date, params, *args
25
25
  elsif value.respond_to? :to_datetime
26
- super value.to_datetime, params
26
+ super value.to_datetime, params, *args
27
27
  else
28
28
  super
29
29
  end
@@ -7,11 +7,11 @@ module Icalendar
7
7
 
8
8
  class Duration < Value
9
9
 
10
- def initialize(value, params = {})
10
+ def initialize(value, *args)
11
11
  if value.is_a? Icalendar::Values::Duration
12
- super value.value, params
12
+ super value.value, *args
13
13
  else
14
- super OpenStruct.new(parse_fields value), params
14
+ super OpenStruct.new(parse_fields value), *args
15
15
  end
16
16
  end
17
17
 
@@ -5,8 +5,8 @@ module Icalendar
5
5
 
6
6
  class Float < Value
7
7
 
8
- def initialize(value, params = {})
9
- super value.to_f, params
8
+ def initialize(value, *args)
9
+ super value.to_f, *args
10
10
  end
11
11
 
12
12
  def value_ical
@@ -16,4 +16,4 @@ module Icalendar
16
16
  end
17
17
 
18
18
  end
19
- end
19
+ end
@@ -8,22 +8,23 @@ module Icalendar
8
8
 
9
9
  attr_reader :value_delimiter
10
10
 
11
- def initialize(value, klass, params = {}, options = {})
12
- @value_delimiter = options[:delimiter] || ','
11
+ def initialize(value, klass, params = {}, context = {})
12
+ context = Icalendar::DowncasedHash(context)
13
+ @value_delimiter = context['delimiter'] || ','
13
14
  mapped = if value.is_a? ::Array
14
15
  value.map do |v|
15
16
  if v.is_a? Icalendar::Values::Helpers::Array
16
- Icalendar::Values::Helpers::Array.new v.value, klass, v.ical_params, delimiter: v.value_delimiter
17
+ Icalendar::Values::Helpers::Array.new v.value, klass, v.ical_params, context.merge(delimiter: v.value_delimiter)
17
18
  elsif v.is_a? ::Array
18
- Icalendar::Values::Helpers::Array.new v, klass, params, delimiter: value_delimiter
19
+ Icalendar::Values::Helpers::Array.new v, klass, params, context.merge(delimiter: value_delimiter)
19
20
  elsif v.is_a? Icalendar::Value
20
21
  v
21
22
  else
22
- klass.new v, params
23
+ klass.new v, params, context
23
24
  end
24
25
  end
25
26
  else
26
- [klass.new(value, params)]
27
+ [klass.new(value, params, context)]
27
28
  end
28
29
  super mapped
29
30
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "icalendar/offset"
4
+
3
5
  begin
4
6
  require 'active_support'
5
7
  require 'active_support/time'
@@ -17,11 +19,20 @@ module Icalendar
17
19
  module TimeWithZone
18
20
  attr_reader :tz_utc, :timezone_store
19
21
 
20
- def initialize(value, params = {})
22
+ def initialize(value, params = {}, context = {})
21
23
  params = Icalendar::DowncasedHash(params)
24
+ context = Icalendar::DowncasedHash(context)
22
25
  @tz_utc = params['tzid'] == 'UTC'
23
- @timezone_store = params.delete 'x-tz-store'
24
- super (offset_value(value, params) || value), params
26
+ @timezone_store = context['timezone_store']
27
+
28
+ offset = Icalendar::Offset.build(value, params, timezone_store)
29
+
30
+ unless offset.nil?
31
+ @offset_value = offset.normalized_value
32
+ params['tzid'] = offset.normalized_tzid
33
+ end
34
+
35
+ super (@offset_value || value), params, context
25
36
  end
26
37
 
27
38
  def __getobj__
@@ -29,9 +40,9 @@ module Icalendar
29
40
  if set_offset?
30
41
  orig_value
31
42
  else
32
- offset = offset_value(orig_value, ical_params)
33
- __setobj__(offset) unless offset.nil?
34
- offset || orig_value
43
+ new_value = Icalendar::Offset.build(orig_value, ical_params, timezone_store)&.normalized_value
44
+ __setobj__(new_value) unless new_value.nil?
45
+ new_value || orig_value
35
46
  end
36
47
  end
37
48
 
@@ -42,36 +53,6 @@ module Icalendar
42
53
 
43
54
  private
44
55
 
45
- def offset_value(value, params)
46
- @offset_value = unless params.nil? || params['tzid'].nil?
47
- tzid = params['tzid'].is_a?(::Array) ? params['tzid'].first : params['tzid']
48
- support_classes_defined = defined?(ActiveSupport::TimeZone) && defined?(ActiveSupportTimeWithZoneAdapter)
49
- if support_classes_defined && (tz = ActiveSupport::TimeZone[tzid])
50
- Icalendar.logger.debug("Plan a - parsing #{value}/#{tzid} as ActiveSupport::TimeWithZone")
51
- # plan a - use ActiveSupport::TimeWithZone
52
- ActiveSupportTimeWithZoneAdapter.new(nil, tz, value)
53
- elsif !timezone_store.nil? && !(x_tz_info = timezone_store.retrieve(tzid)).nil?
54
- # plan b - use definition from provided `VTIMEZONE`
55
- offset = x_tz_info.offset_for_local(value).to_s
56
- Icalendar.logger.debug("Plan b - parsing #{value} with offset: #{offset}")
57
- if value.respond_to?(:change)
58
- value.change offset: offset
59
- else
60
- ::Time.new value.year, value.month, value.day, value.hour, value.min, value.sec, offset
61
- end
62
- elsif support_classes_defined && (tz = ActiveSupport::TimeZone[tzid.split.first])
63
- # plan c - try to find an ActiveSupport::TimeWithZone based on the first word of the tzid
64
- Icalendar.logger.debug("Plan c - parsing #{value}/#{tz.tzinfo.name} as ActiveSupport::TimeWithZone")
65
- params['tzid'] = [tz.tzinfo.name]
66
- ActiveSupportTimeWithZoneAdapter.new(nil, tz, value)
67
- else
68
- # plan d - just ignore the tzid
69
- Icalendar.logger.info("Ignoring timezone #{tzid} for time #{value}")
70
- nil
71
- end
72
- end
73
- end
74
-
75
56
  def set_offset?
76
57
  !!@offset_value
77
58
  end
@@ -5,8 +5,8 @@ module Icalendar
5
5
 
6
6
  class Integer < Value
7
7
 
8
- def initialize(value, params = {})
9
- super value.to_i, params
8
+ def initialize(value, *args)
9
+ super value.to_i, *args
10
10
  end
11
11
 
12
12
  def value_ical
@@ -16,4 +16,4 @@ module Icalendar
16
16
  end
17
17
 
18
18
  end
19
- end
19
+ end
@@ -7,7 +7,7 @@ module Icalendar
7
7
 
8
8
  PERIOD_LAST_PART_REGEX = /\A[+-]?P.+\z/.freeze
9
9
 
10
- def initialize(value, params = {})
10
+ def initialize(value, *args)
11
11
  parts = value.split '/'
12
12
  period_start = Icalendar::Values::DateTime.new parts.first
13
13
  if parts.last =~ PERIOD_LAST_PART_REGEX
@@ -15,7 +15,7 @@ module Icalendar
15
15
  else
16
16
  period_end = Icalendar::Values::DateTime.new parts.last
17
17
  end
18
- super [period_start, period_end], params
18
+ super [period_start, period_end], *args
19
19
  end
20
20
 
21
21
  def value_ical
@@ -47,4 +47,4 @@ module Icalendar
47
47
  end
48
48
  end
49
49
  end
50
- end
50
+ end
@@ -12,11 +12,11 @@ module Icalendar
12
12
  MONTHDAY = '[+-]?\d{1,2}'
13
13
  YEARDAY = '[+-]?\d{1,3}'
14
14
 
15
- def initialize(value, params = {})
15
+ def initialize(value, *args)
16
16
  if value.is_a? Icalendar::Values::Recur
17
- super value.value, params
17
+ super value.value, *args
18
18
  else
19
- super OpenStruct.new(parse_fields value), params
19
+ super OpenStruct.new(parse_fields value), *args
20
20
  end
21
21
  end
22
22
 
@@ -3,12 +3,12 @@
3
3
  module Icalendar
4
4
  module Values
5
5
  class Text < Value
6
- def initialize(value, params = {})
6
+ def initialize(value, *args)
7
7
  value = value.gsub('\n', "\n")
8
8
  value.gsub!('\,', ',')
9
9
  value.gsub!('\;', ';')
10
10
  value.gsub!('\\\\') { '\\' }
11
- super value, params
11
+ super value, *args
12
12
  end
13
13
 
14
14
  VALUE_ICAL_CARRIAGE_RETURN_GSUB_REGEX = /\r?\n/.freeze
@@ -11,12 +11,12 @@ module Icalendar
11
11
 
12
12
  FORMAT = '%H%M%S'
13
13
 
14
- def initialize(value, params = {})
14
+ def initialize(value, params = {}, *args)
15
15
  if value.is_a? String
16
16
  params['tzid'] = 'UTC' if value.end_with? 'Z'
17
- super ::DateTime.strptime(value, FORMAT).to_time, params
17
+ super ::DateTime.strptime(value, FORMAT).to_time, params, *args
18
18
  elsif value.respond_to? :to_time
19
- super value.to_time, params
19
+ super value.to_time, params, *args
20
20
  else
21
21
  super
22
22
  end
@@ -33,4 +33,4 @@ module Icalendar
33
33
  end
34
34
 
35
35
  end
36
- end
36
+ end
@@ -7,9 +7,9 @@ module Icalendar
7
7
 
8
8
  class Uri < Value
9
9
 
10
- def initialize(value, params = {})
10
+ def initialize(value, *args)
11
11
  parsed = URI.parse(value) rescue value
12
- super parsed, params
12
+ super parsed, *args
13
13
  end
14
14
 
15
15
  def value_ical
@@ -5,13 +5,13 @@ require 'ostruct'
5
5
  module Icalendar
6
6
  module Values
7
7
  class UtcOffset < Value
8
- def initialize(value, params = {})
8
+ def initialize(value, *args)
9
9
  if value.is_a? Icalendar::Values::UtcOffset
10
10
  value = value.value
11
11
  else
12
12
  value = OpenStruct.new parse_fields(value)
13
13
  end
14
- super value, params
14
+ super value, *args
15
15
  end
16
16
 
17
17
  def behind?
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Icalendar
4
4
 
5
- VERSION = '2.10.3'
5
+ VERSION = '2.12.1'
6
6
 
7
7
  end
@@ -0,0 +1,23 @@
1
+ BEGIN:VCALENDAR
2
+ VERSION:2.0
3
+ PRODID:bsprodidfortestabc123
4
+ CALSCALE:GREGORIAN
5
+ BEGIN:VEVENT
6
+ DTSTAMP:20050118T211523Z
7
+ UID:bsuidfortestabc123
8
+ DTSTART;TZID=US-Mountain:20050120T170000
9
+ DTEND;TZID=US-Mountain:20050120T184500
10
+ CLASS:PRIVATE
11
+ GEO:37.386013;-122.0829322
12
+ ORGANIZER;CN="Joe Bob: Magician":mailto:joebob@random.net
13
+ LOCATION;VALUE=1000 MAIN ST EXAMPLE, STATE 12345:1000 Main St Example\, State 12345
14
+ PRIORITY:2
15
+ SUMMARY:This is a really long summary to test the method of unfolding lines
16
+ \, so I'm just going to make it a whole bunch of lines. With a twist: a "
17
+ ö" takes up multiple bytes\, and should be wrapped to the next line.
18
+ ATTACH:http://bush.sucks.org/impeach/him.rhtml
19
+ ATTACH:http://corporations-dominate.existence.net/why.rhtml
20
+ RDATE;TZID=US-Mountain:20050121T170000,20050122T170000
21
+ X-TEST-COMPONENT;QTEST="Hello, World":Shouldn't double double quotes
22
+ END:VEVENT
23
+ END:VCALENDAR
@@ -38,7 +38,7 @@ SEQUENCE:13
38
38
  BEGIN:VALARM
39
39
  X-WR-ALARMUID:6F54CD66-F2A9-491D-8892-7E3209F6A2E4
40
40
  UID:6F54CD66-F2A9-491D-8892-7E3209F6A2E4
41
- TRIGGER;VALUE=DATE-TIME:19760401T005545Z
41
+ TRIGGER;VALUE=DATE TIME:19760401T005545Z
42
42
  ACTION:NONE
43
43
  END:VALARM
44
44
  END:VEVENT
@@ -0,0 +1,30 @@
1
+ BEGIN:VCALENDAR
2
+ METHOD:COUNTER
3
+ PRODID:Microsoft Exchange Server 2010
4
+ VERSION:2.0
5
+ BEGIN:VTIMEZONE
6
+ TZID:Pacific Standard Time
7
+ BEGIN:STANDARD
8
+ DTSTART:16010101T020000
9
+ TZOFFSETFROM:-0700
10
+ TZOFFSETTO:-0800
11
+ RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11
12
+ END:STANDARD
13
+ BEGIN:DAYLIGHT
14
+ DTSTART:16010101T020000
15
+ TZOFFSETFROM:-0800
16
+ TZOFFSETTO:-0700
17
+ RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3
18
+ END:DAYLIGHT
19
+ END:VTIMEZONE
20
+ BEGIN:VEVENT
21
+ DTSTART;TZID=Pacific Standard Time:20251008T103000
22
+ DTEND;TZID=Pacific Standard Time:20251008T113000
23
+ X-MS-OLK-ORIGINALSTART;TZID=Pacific Standard Time:20251008T110000
24
+ X-MS-OLK-ORIGINALEND;TZID=Pacific Standard Time:20251008T120000
25
+ UID:1sqt3u5rn2fprt7g7u75obcgav@google.com
26
+ SEQUENCE:0
27
+ DTSTAMP:20251007T184328Z
28
+ COMMENT;LANGUAGE=en-US:\n
29
+ END:VEVENT
30
+ END:VCALENDAR
data/spec/parser_spec.rb CHANGED
@@ -116,4 +116,28 @@ describe Icalendar::Parser do
116
116
  expect(event.dtstart).to be_kind_of Icalendar::Values::Date
117
117
  end
118
118
  end
119
+
120
+ describe 'completely bad location value' do
121
+ let(:fn) { 'single_event_bad_location.ics' }
122
+
123
+ it 'falls back to string type for location' do
124
+ event = subject.parse.first.events.first
125
+ expect(event.location).to be_kind_of Icalendar::Values::Text
126
+ expect(event.location.value).to eq "1000 Main St Example, State 12345"
127
+ end
128
+ end
129
+
130
+ describe 'custom properties with tzid' do
131
+ let(:fn) { 'tz_store_param_bug.ics' }
132
+
133
+ it 'parses without error' do
134
+ expect(subject.parse.first).to be_a Icalendar::Calendar
135
+ end
136
+
137
+ it 'can be output to ics and re-parsed without error' do
138
+ cal = subject.parse.first
139
+ new_cal = Icalendar::Parser.new(cal.to_ical, false).parse.first
140
+ expect(new_cal).to be_a Icalendar::Calendar
141
+ end
142
+ end
119
143
  end
@@ -17,7 +17,7 @@ describe Icalendar::Values::DateTime do
17
17
  expect(subject.value_ical).to eq value
18
18
  end
19
19
 
20
- context 'local time' do
20
+ context 'tzid in IANA format' do
21
21
  let(:value) { '20140209T160652' }
22
22
  let(:params) { {'tzid' => 'America/Denver'} }
23
23
 
@@ -28,14 +28,15 @@ describe Icalendar::Values::DateTime do
28
28
  end
29
29
  end
30
30
 
31
- context 'nonstandard format tzid local time' do
31
+ context 'tzid with partial match' do
32
32
  let(:value) { '20230901T230404' }
33
- let(:params) { {'tzid' => 'Singapore Standard Time'} }
33
+ let(:params) { { 'tzid' => 'Singapore Time' } }
34
34
 
35
35
  it 'parses the value as local time' do
36
36
  expect(subject.value.hour).to eq 23
37
- expect(subject.value.utc_offset).to eq 28800
37
+ expect(subject.value.utc_offset).to eq 28_800
38
38
  expect(subject.value.utc.hour).to eq 15
39
+ expect(subject.value.utc.minute).to eq 04
39
40
  end
40
41
 
41
42
  it 'updates the tzid' do
@@ -43,8 +44,24 @@ describe Icalendar::Values::DateTime do
43
44
  expect(subject.ical_params['tzid']).to eq ['Asia/Singapore']
44
45
  end
45
46
  end
46
- end
47
47
 
48
+ context 'tzid in windows format' do
49
+ let(:value) { '20230901T230404' }
50
+ let(:params) { { 'tzid' => 'India Standard Time' } }
51
+
52
+ it 'parses the value as local time' do
53
+ expect(subject.value.hour).to eq 23
54
+ expect(subject.value.utc_offset).to eq 19_800
55
+ expect(subject.value.utc.hour).to eq 17
56
+ expect(subject.value.utc.minute).to eq 34
57
+ end
58
+
59
+ it 'updates the tzid' do
60
+ # use an array because that's how output from the parser will end up
61
+ expect(subject.ical_params['tzid']).to eq ['Asia/Kolkata']
62
+ end
63
+ end
64
+ end
48
65
  else
49
66
 
50
67
  context 'without ActiveSupport' do
@@ -63,10 +80,8 @@ describe Icalendar::Values::DateTime do
63
80
  end
64
81
  end
65
82
  end
66
-
67
83
  end
68
84
 
69
-
70
85
  context 'common tests' do
71
86
  it 'does not add any tzid parameter to output' do
72
87
  expect(subject.to_ical described_class).to eq ":#{value}"
metadata CHANGED
@@ -1,15 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: icalendar
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.10.3
4
+ version: 2.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Ahearn
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-09-21 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
13
26
  - !ruby/object:Gem::Dependency
14
27
  name: ice_cube
15
28
  requirement: !ruby/object:Gem::Requirement
@@ -24,6 +37,20 @@ dependencies:
24
37
  - - "~>"
25
38
  - !ruby/object:Gem::Version
26
39
  version: '0.16'
40
+ - !ruby/object:Gem::Dependency
41
+ name: logger
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
27
54
  - !ruby/object:Gem::Dependency
28
55
  name: ostruct
29
56
  requirement: !ruby/object:Gem::Requirement
@@ -195,6 +222,12 @@ files:
195
222
  - lib/icalendar/journal.rb
196
223
  - lib/icalendar/logger.rb
197
224
  - lib/icalendar/marshable.rb
225
+ - lib/icalendar/offset.rb
226
+ - lib/icalendar/offset/active_support_exact.rb
227
+ - lib/icalendar/offset/active_support_partial.rb
228
+ - lib/icalendar/offset/null.rb
229
+ - lib/icalendar/offset/time_zone_store.rb
230
+ - lib/icalendar/offset/windows_to_iana.rb
198
231
  - lib/icalendar/parser.rb
199
232
  - lib/icalendar/timezone.rb
200
233
  - lib/icalendar/timezone_store.rb
@@ -234,6 +267,7 @@ files:
234
267
  - spec/fixtures/single_event.ics
235
268
  - spec/fixtures/single_event_bad_dtstart.ics
236
269
  - spec/fixtures/single_event_bad_line.ics
270
+ - spec/fixtures/single_event_bad_location.ics
237
271
  - spec/fixtures/single_event_bad_organizer.ics
238
272
  - spec/fixtures/single_event_organizer_parsed.ics
239
273
  - spec/fixtures/timezone.ics
@@ -241,6 +275,7 @@ files:
241
275
  - spec/fixtures/two_day_events.ics
242
276
  - spec/fixtures/two_events.ics
243
277
  - spec/fixtures/two_time_events.ics
278
+ - spec/fixtures/tz_store_param_bug.ics
244
279
  - spec/fixtures/tzid_search.ics
245
280
  - spec/freebusy_spec.rb
246
281
  - spec/journal_spec.rb
@@ -283,8 +318,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
283
318
  - !ruby/object:Gem::Version
284
319
  version: '0'
285
320
  requirements: []
286
- rubygems_version: 3.3.27
287
- signing_key:
321
+ rubygems_version: 3.6.9
288
322
  specification_version: 4
289
323
  summary: A ruby implementation of the iCalendar specification (RFC-5545).
290
324
  test_files:
@@ -302,6 +336,7 @@ test_files:
302
336
  - spec/fixtures/single_event.ics
303
337
  - spec/fixtures/single_event_bad_dtstart.ics
304
338
  - spec/fixtures/single_event_bad_line.ics
339
+ - spec/fixtures/single_event_bad_location.ics
305
340
  - spec/fixtures/single_event_bad_organizer.ics
306
341
  - spec/fixtures/single_event_organizer_parsed.ics
307
342
  - spec/fixtures/timezone.ics
@@ -309,6 +344,7 @@ test_files:
309
344
  - spec/fixtures/two_day_events.ics
310
345
  - spec/fixtures/two_events.ics
311
346
  - spec/fixtures/two_time_events.ics
347
+ - spec/fixtures/tz_store_param_bug.ics
312
348
  - spec/fixtures/tzid_search.ics
313
349
  - spec/freebusy_spec.rb
314
350
  - spec/journal_spec.rb