icalendar-rrule 0.1.6 → 0.2.0

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: b1fe678eb4d4592010ff3793fb53912e1245ef721cc59fd04c8ea10a191f0255
4
- data.tar.gz: a82321cf5ab418d087a17da9902d7c76ec2c55fa49c5b06ed47dd5cf935256c7
3
+ metadata.gz: b891ac836e382f65aa522d6fc6b049483b3296deef1fe9d2bb196ed6bb2de9bc
4
+ data.tar.gz: 5f99a1568a014411c01559505dd71068e630fca5cdbeb3e32fbb246fecd24190
5
5
  SHA512:
6
- metadata.gz: c5906d955ed951f3ca1cd646f255924bc718e3ee416f90ad82f1369200842de484fc0ae3b67a01f7ad41f51cc3d020aba44e4479f759d8da009633a13bda5e91
7
- data.tar.gz: f099253605cfbcc8f0006a982001cb3fd2c961ceb4d0a8704af0866fb6a8b588676917a294f75c6945c339299c4b86dba2464ca515e6ed5c0b2bffc4a4fd61a6
6
+ metadata.gz: ab80482e0f12d0cc47b11e28bbcf7f887bf42e50ed9e6d74dea0f24202b42215c62585347e94e72e8cb5dd38e04bf0e507745520092c7a11af36bc48182ffea1
7
+ data.tar.gz: 6a62fab4d640445562924807ebd7ed8eeadba97d1a5630f2c8bcf399977e6d1ece789efe17dbf21c50e05ee04b289bf81220e81075a3a698ef79531dcba5dbe2
data/.gitignore CHANGED
@@ -1,3 +1,4 @@
1
+ Gemfile.lock
1
2
  *.gem
2
3
  *.rbc
3
4
  /.config
data/CHANGELOG.md ADDED
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.2.0] - 2025-12-24
6
+
7
+ ### Added
8
+ - Support for tasks (VTODOs) with RRULE expansion
9
+ - New `single_timestamp?` predicate for zero-duration events/deadline-only tasks
10
+ - Comprehensive timezone extraction with multiple fallback strategies
11
+ - Better system timezone detection (ENV['TZ'], /etc/timezone, TZInfo)
12
+
13
+ ### Changed
14
+ - **BREAKING**: Events without explicit timezone now use system timezone instead of UTC
15
+ - All-day events without DTEND now correctly compute 1-day duration in date-space
16
+ - Relaxed `ice_cube` dependency to >= 0.16 (tested with 0.17.0)
17
+ - `all_day?` now returns false for tasks (only applies to events)
18
+
19
+ ### Fixed
20
+ - Timezone handling for recurring events (RRULE expansion now preserves timezone)
21
+ - Floating time interpretation (DateTime with offset 0 no longer treated as UTC)
22
+ - All-day events no longer experience timezone shift errors
23
+ - Compatibility with icalendar gem 2.12.1 (DowncasedHash handling)
24
+ - Task time handling (zero-duration tasks with only DUE)
25
+
26
+ ### Internal
27
+ - Enhanced `_extract_explicit_timezone` for better timezone detection
28
+ - Added `_dtstart_is_all_day?` helper
29
+ - Improved `_guess_system_timezone` with 5 fallback methods
30
+ - Better test coverage (145+ tests, including exotic timezones)
31
+
32
+ ## [0.1.7] - 2020-xx-xx
33
+ - Previous stable release
data/README.md CHANGED
@@ -6,7 +6,8 @@ According to the [RFC 5545](https://tools.ietf.org/html/rfc5545) specification,
6
6
  repeating events are represented by one single entry, the repetitions being shown by
7
7
  an attached _repeat rule_. Thus when we iterate through a calendar with, for example,
8
8
  a daily repeating event,
9
- we'll only see one single event where for a month there would be many more events in reality.
9
+ we'll only see one single entry in the Calendar.
10
+ Although, for a whole month there would be 30 or 31 events in reality.
10
11
 
11
12
  The _icalendar-rrule gem_ patches an additional function called `scan` into the _iCalendar Gem_.
12
13
  The _scan_ shows all events by unrolling the _repeat rule_ for a
@@ -32,8 +33,9 @@ To use this gem we'll first have to require it:
32
33
  `require 'icalendar-rrule'`
33
34
 
34
35
  Further we have to declare the use of the "Scannable" namespace.
35
- This is called a "[Refinement](https://ruby-doc.org/core-2.5.0/doc/syntax/refinements_rdoc.html)"
36
- which is a _new Ruby core feature_ since Ruby 2.0.
36
+ This is called a "[Refinement](https://ruby-doc.org/core-2.5.0/doc/syntax/refinements_rdoc.html)",
37
+ a _new Ruby core feature_ since Ruby 2.0, that makes "monkey patching" a bit
38
+ more acceptable.
37
39
 
38
40
  `using Icalendar::Scannable`
39
41
 
@@ -18,6 +18,10 @@ Gem::Specification.new do |gem_spec|
18
18
  gem_spec.homepage = 'https://github.com/free-creations/icalendar-rrule'
19
19
  gem_spec.license = 'MIT'
20
20
 
21
+ gem_spec.metadata['homepage_uri'] = gem_spec.homepage
22
+ gem_spec.metadata['source_code_uri'] = gem_spec.homepage
23
+ gem_spec.metadata['bug_tracker_uri'] = 'https://github.com/free-creations/icalendar-rrule/issues'
24
+
21
25
  gem_spec.files = `git ls-files -z`.split("\x0").reject do |f|
22
26
  f.match(%r{^(test|spec|features)/})
23
27
  end
@@ -26,12 +30,12 @@ Gem::Specification.new do |gem_spec|
26
30
 
27
31
  gem_spec.required_ruby_version = '>= 2.5'
28
32
 
29
- gem_spec.add_dependency 'activesupport', '~> 5.1'
30
- gem_spec.add_dependency 'icalendar', '~> 2.4'
31
- gem_spec.add_dependency 'ice_cube', '~> 0.16'
33
+ gem_spec.add_dependency 'activesupport', '>= 5.1'
34
+ gem_spec.add_dependency 'icalendar', '>= 2.4'
35
+ gem_spec.add_dependency 'ice_cube', '>= 0.16'
32
36
 
33
- gem_spec.add_development_dependency 'bundler', '~> 1.16'
34
- gem_spec.add_development_dependency 'rake', '~> 10.0'
37
+ gem_spec.add_development_dependency 'bundler', '>= 2'
38
+ gem_spec.add_development_dependency 'rake', '>= 12.3.3'
35
39
  gem_spec.add_development_dependency 'rspec', '~> 3.7'
36
40
  gem_spec.add_development_dependency 'rubocop', '~> 0.55.0'
37
41
  gem_spec.add_development_dependency 'rubocop-rspec', '~> 1.24'
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Icalendar
4
4
  module Rrule
5
- VERSION = '0.1.6'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
@@ -52,8 +52,23 @@ module Icalendar
52
52
  result = []
53
53
  components.each do |comp|
54
54
  occurrences = comp.schedule.occurrences_between(begin_time, closing_time)
55
+
56
+ # Get the target timezone from the component
57
+ target_tz = comp.start_time.time_zone
58
+
55
59
  occurrences.each do |oc|
56
- new_oc = Icalendar::Rrule::Occurrence.new(self, comp, oc.start_time, oc.end_time)
60
+ # Interpret the time components AS IF they're already in target timezone
61
+ # (don't convert, just reconstruct in the right zone)
62
+ start_tz = target_tz.local(
63
+ oc.start_time.year, oc.start_time.month, oc.start_time.day,
64
+ oc.start_time.hour, oc.start_time.min, oc.start_time.sec
65
+ )
66
+ end_tz = target_tz.local(
67
+ oc.end_time.year, oc.end_time.month, oc.end_time.day,
68
+ oc.end_time.hour, oc.end_time.min, oc.end_time.sec
69
+ )
70
+
71
+ new_oc = Icalendar::Rrule::Occurrence.new(self, comp, start_tz, end_tz)
57
72
  result << new_oc
58
73
  end
59
74
  end
@@ -93,8 +93,15 @@ module Icalendar
93
93
  end
94
94
 
95
95
  ##
96
- # @return [Integer] the number of seconds this task will last.
97
- # If no duration for this task is specified, this function returns zero.
96
+ # Returns the explicit duration from DURATION property or guessed duration.
97
+ #
98
+ # WARNING: This does NOT compute the actual duration between start_time and end_time!
99
+ # For events with DTEND but no DURATION property, this returns 0 or the guessed duration,
100
+ # even though the actual duration may be longer.
101
+ #
102
+ # To get the actual duration, use: (end_time.to_i - start_time.to_i)
103
+ #
104
+ # @return [Integer] explicit duration in seconds, or 0 if not specified
98
105
  # @api private
99
106
  def _duration_seconds # rubocop:disable Metrics/AbcSize
100
107
  return _guessed_duration unless _duration
@@ -103,6 +110,25 @@ module Icalendar
103
110
  d.seconds + (d.minutes * SEC_MIN) + (d.hours * SEC_HOUR) + (d.days * SEC_DAY) + (d.weeks * SEC_WEEK)
104
111
  end
105
112
 
113
+ # Check if dtstart looks like an all-day event (starts at midnight)
114
+ # This is used internally to determine if we should apply the 1-day duration rule
115
+ # @return [Boolean] true if dtstart is a Date or starts at midnight
116
+ # @api private
117
+ def _dtstart_is_all_day?
118
+ # Only apply the 1-day rule to Events, not Tasks
119
+ return false unless self.is_a?(Icalendar::Event)
120
+
121
+ return true if _dtstart.is_a?(Icalendar::Values::Date)
122
+
123
+ # If it's a DateTime, check if it's at midnight (00:00:00)
124
+ if _dtstart.respond_to?(:to_time) || _dtstart.respond_to?(:to_datetime)
125
+ time = _to_time_with_zone(_dtstart)
126
+ return time == time.beginning_of_day
127
+ end
128
+
129
+ false
130
+ end
131
+
106
132
  ##
107
133
  # Make an educated guess how long this event might last according to the following definition from RFC 5545:
108
134
  #
@@ -114,7 +140,7 @@ module Icalendar
114
140
  # @return [Integer] the number of seconds this task might last.
115
141
  # @api private
116
142
  def _guessed_duration
117
- if _dtstart.is_a?(Icalendar::Values::Date) && _dtend.nil? && _duration.nil? && _due.nil?
143
+ if _dtstart_is_all_day? && _dtend.nil? && _duration.nil? && _due.nil?
118
144
  SEC_DAY
119
145
  else
120
146
  0
@@ -127,8 +153,11 @@ module Icalendar
127
153
  def start_time
128
154
  if _dtstart
129
155
  _to_time_with_zone(_dtstart)
130
- elsif _due
156
+ elsif _due && _duration_seconds > 0
131
157
  _to_time_with_zone(_due.to_i - _duration_seconds)
158
+ elsif _due
159
+ # Task with only DUE, no duration: start == end (zero-duration/deadline-only)
160
+ _to_time_with_zone(_due)
132
161
  else
133
162
  _to_time_with_zone(NULL_TIME)
134
163
  end
@@ -143,17 +172,50 @@ module Icalendar
143
172
  elsif _dtend
144
173
  _to_time_with_zone(_dtend)
145
174
  elsif _dtstart
146
- _to_time_with_zone(_dtstart.to_i + _duration_seconds)
175
+ # Special handling for all-day events without explicit end
176
+ if _dtstart_is_all_day? && _dtend.nil?
177
+ # Stay in date space: add days to the date, not seconds to timestamp
178
+ start_date = _dtstart_all_day_event_as_date
179
+ end_date = start_date + (_duration_seconds / SEC_DAY).days
180
+ _date_to_time_with_zone(end_date, component_timezone)
181
+ else
182
+ _to_time_with_zone(start_time.to_i + _duration_seconds)
183
+ end
147
184
  else
148
185
  _to_time_with_zone(NULL_TIME + _duration_seconds)
149
186
  end
150
187
  end
151
188
 
189
+ # Extract the date component from dtstart, assuming it's an all-day event
190
+ # @return [Date]
191
+ # @api private
192
+ def _dtstart_all_day_event_as_date
193
+ raise ArgumentError, "dtstart is not an all-day event" unless _dtstart_is_all_day?
194
+
195
+ if _dtstart.is_a?(Icalendar::Values::Date)
196
+ _dtstart.to_date
197
+ elsif _dtstart.respond_to?(:to_date)
198
+ _dtstart.to_date
199
+ else
200
+ # Fallback: convert via TimeWithZone
201
+ time_with_zone = _to_time_with_zone(_dtstart)
202
+ raise ArgumentError, "Cannot convert dtstart to date" unless time_with_zone.respond_to?(:to_date)
203
+ time_with_zone.to_date
204
+ end
205
+ end
206
+
152
207
  ##
153
208
  # Heuristic to determine whether the event is scheduled
154
- # for a date without precising the exact time of day.
155
- # @return [Boolean] true if the component is scheduled for a date, false otherwise.
209
+ # for a date without specifying the exact time of day.
210
+ #
211
+ # Note: This method always returns false for tasks (VTODOs),
212
+ # as the all-day concept only applies to events (VEVENTs).
213
+ #
214
+ # @return [Boolean] true if the component is an Event scheduled for an entire day,
215
+ # false for tasks or timed events
156
216
  def all_day?
217
+ return false unless self.is_a?(Icalendar::Event)
218
+
157
219
  _dtstart.is_a?(Icalendar::Values::Date) ||
158
220
  (start_time == start_time.beginning_of_day && end_time == end_time.beginning_of_day)
159
221
  end
@@ -164,6 +226,19 @@ module Icalendar
164
226
  start_time.next_day.beginning_of_day < end_time
165
227
  end
166
228
 
229
+ ##
230
+ # Indicates whether this component represents a single point in time
231
+ # rather than a time range. Common for:
232
+ # - Open-ended events (e.g., concert start time without known end)
233
+ # - Tasks with only a deadline (no start time specified)
234
+ #
235
+ # @return [Boolean] true if the component has no duration
236
+ def single_timestamp?
237
+ return false if start_time.nil? || end_time.nil?
238
+ # Compare at second precision (ignore potential microsecond differences)
239
+ start_time.to_i == end_time.to_i
240
+ end
241
+
167
242
  ##
168
243
  # Make sure, that we can always query for a _rrule_ array.
169
244
  # @return [array] an array of _ical repeat-rules_ (or an empty array
@@ -264,9 +339,13 @@ module Icalendar
264
339
  # Creates a schedule for this event
265
340
  # @return [IceCube::Schedule]
266
341
  def schedule # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
267
- schedule = IceCube::Schedule.new
268
- schedule.start_time = start_time
269
- schedule.end_time = end_time
342
+ # Calculate duration in seconds
343
+ duration_seconds = (end_time.to_i - start_time.to_i) # Integer seconds
344
+
345
+ # Create a schedule with start_time and duration
346
+ # Convert to Ruby Time for IceCube compatibility
347
+ schedule = IceCube::Schedule.new(start_time.to_time, duration: duration_seconds)
348
+
270
349
  _rrules.each do |rrule|
271
350
  ice_cube_recurrence_rule = IceCube::Rule.from_ical(rrule)
272
351
  schedule.add_recurrence_rule(ice_cube_recurrence_rule)
@@ -299,6 +378,9 @@ module Icalendar
299
378
  #
300
379
  # rubocop:disable Metrics/MethodLength,Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
301
380
  def _to_time_with_zone(date_time, timezone = nil)
381
+ # Try to extract timezone from the date_time parameter first
382
+ timezone ||= _extract_explicit_timezone(date_time)
383
+ # Fall back to component timezone if no timezone could be extracted
302
384
  timezone ||= component_timezone
303
385
 
304
386
  # For Icalendar::Values::DateTime, we can extract the ical value. Which probably is already what we want.
@@ -317,6 +399,19 @@ module Icalendar
317
399
  return date_time_value.in_time_zone(timezone)
318
400
 
319
401
  elsif date_time_value.is_a?(DateTime)
402
+ # If DateTime has offset 0, treat it as "floating time" in the target timezone
403
+ # rather than converting from UTC
404
+ if date_time_value.offset.zero?
405
+ return timezone.local(
406
+ date_time_value.year,
407
+ date_time_value.month,
408
+ date_time_value.day,
409
+ date_time_value.hour,
410
+ date_time_value.min,
411
+ date_time_value.sec
412
+ )
413
+ end
414
+ # DateTime with explicit non-zero offset: convert to target timezone
320
415
  return date_time_value.in_time_zone(timezone)
321
416
 
322
417
  elsif date_time_value.is_a?(Icalendar::Values::Date)
@@ -325,6 +420,10 @@ module Icalendar
325
420
  elsif date_time_value.is_a?(Date)
326
421
  return _date_to_time_with_zone(date_time_value, timezone)
327
422
 
423
+ elsif date_time_value.is_a?(Time)
424
+ # Preserve Time's timezone by converting to UTC first, then to target timezone
425
+ return timezone.at(date_time_value.getutc)
426
+
328
427
  elsif date_time_value.respond_to?(:to_time)
329
428
  return timezone.at(date_time_value.to_time)
330
429
 
@@ -353,10 +452,11 @@ module Icalendar
353
452
  # @return [ActiveSupport::TimeZone] the unique timezone used in this component
354
453
  def component_timezone
355
454
  # let's try sequentially, the first non-nil wins.
356
- timezone ||= _extract_timezone(_dtend)
357
- timezone ||= _extract_timezone(_dtstart)
358
- timezone ||= _extract_timezone(_due)
455
+ timezone ||= _extract_explicit_timezone(_dtend)
456
+ timezone ||= _extract_explicit_timezone(_dtstart)
457
+ timezone ||= _extract_explicit_timezone(_due)
359
458
  timezone ||= _extract_calendar_timezone
459
+ timezone ||= _guess_system_timezone
360
460
 
361
461
  # as a last resort we'll use the Coordinated Universal Time (UTC).
362
462
  timezone || ActiveSupport::TimeZone['UTC']
@@ -373,7 +473,6 @@ module Icalendar
373
473
  return nil unless parent.is_a?(Icalendar::Calendar)
374
474
  calendar_timezones = parent.timezones
375
475
  calendar_timezones.each do |tz|
376
- break unless tz.valid?(true)
377
476
  ugly_tzid = tz.tzid
378
477
  break unless ugly_tzid
379
478
  tzid = Array(ugly_tzid).first.to_s.gsub(/^(["'])|(["'])$/, '')
@@ -387,14 +486,24 @@ module Icalendar
387
486
  # rubocop:enable Metrics/CyclomaticComplexity
388
487
 
389
488
  ##
390
- # Get the timezone from the given object trying different methods to find an indication in the object.
391
- # @param [Object] date_time an object from which we shall determine the time zone.
392
- # @return [ActiveSupport::TimeZone, nil] the timezone used by the parameter or nil if no timezone has been set.
489
+ # Extracts an explicitly set timezone from the given object.
490
+ #
491
+ # This method only returns a timezone if it was explicitly specified through:
492
+ # - An iCalendar TZID parameter (e.g., tzid: 'Europe/Berlin')
493
+ # - An existing ActiveSupport::TimeWithZone object
494
+ # - A wrapped value that is already a TimeWithZone
495
+ #
496
+ # Unlike _guess_timezone_from_offset, this method does NOT guess or infer
497
+ # timezones from UTC offsets. It returns nil if no explicit timezone is found.
498
+ #
499
+ # @param date_time [Object] an object from which to extract the timezone.
500
+ # Typically, an Icalendar::Value, Time, DateTime, or ActiveSupport::TimeWithZone.
501
+ # @return [ActiveSupport::TimeZone, nil] the explicitly set timezone, or nil if none found.
393
502
  # @api private
394
- def _extract_timezone(date_time)
395
- timezone ||= _extract_ical_time_zone(date_time) # try with ical parameter
396
- timezone ||= _extract_act_sup_timezone(date_time) # is the given value already ActiveSupport::TimeWithZone?
397
- timezone || _extract_value_time_zone(date_time) # is the ical.value of type ActiveSupport::TimeWithZone?
503
+ def _extract_explicit_timezone(date_time)
504
+ timezone ||= _extract_ical_time_zone(date_time) # try with ical TZID parameter (most specific)
505
+ timezone ||= _extract_act_sup_timezone(date_time) # is the given value already ActiveSupport::TimeWithZone?
506
+ timezone || _extract_value_time_zone(date_time) # is the ical.value of type ActiveSupport::TimeWithZone?
398
507
  end
399
508
 
400
509
  ##
@@ -419,6 +528,51 @@ module Icalendar
419
528
  ical_value.value.time_zone
420
529
  end
421
530
 
531
+ ##
532
+ # Guesses the corresponding ActiveSupport timezone from a given time object's UTC offset.
533
+ # This method extracts the UTC offset from objects that respond to :utc_offset
534
+ # (such as Time, DateTime, or their wrapped values in Icalendar::Values)
535
+ # and matches it to an equivalent ActiveSupport::TimeZone.
536
+ #
537
+ # Note: Since multiple timezones can share the same UTC offset (e.g., Berlin,
538
+ # Amsterdam, Paris all use +01:00), this method returns an arbitrary timezone
539
+ # with the matching offset - hence "guess" rather than "extract".
540
+ #
541
+ # If the input does not respond to :utc_offset or an error occurs during processing,
542
+ # the method returns nil.
543
+ #
544
+ # @param date_time [Object] the object to extract the UTC offset from.
545
+ # Should respond to :utc_offset (e.g., Time, DateTime, Icalendar::Values::DateTime).
546
+ # @return [ActiveSupport::TimeZone, nil] an ActiveSupport::TimeZone matching the UTC offset,
547
+ # or nil if no match is found or an error occurs.
548
+ # @api private
549
+ def _guess_timezone_from_offset(date_time)
550
+ # Extract value from Icalendar::Values::DateTime if needed
551
+ value = date_time.is_a?(Icalendar::Value) && date_time.respond_to?(:value) ? date_time.value : date_time
552
+
553
+ return nil unless value.respond_to?(:utc_offset)
554
+
555
+ # Get the timezone offset from the Time or DateTime object
556
+ offset_seconds = value.utc_offset
557
+ return nil unless offset_seconds.is_a?(Integer)
558
+
559
+ # First try: check if the system's default timezone matches the offset
560
+ system_tz = _guess_system_timezone
561
+
562
+ # Return `system timezone` if it matches the offset
563
+ return system_tz if system_tz && system_tz.now.utc_offset == offset_seconds
564
+
565
+ # Fallback: find any timezone matching the offset
566
+ # For offset 0, always use UTC to avoid ambiguous timezones with DST
567
+ if offset_seconds.zero?
568
+ ActiveSupport::TimeZone['UTC']
569
+ else
570
+ ActiveSupport::TimeZone[offset_seconds]
571
+ end
572
+ rescue StandardError
573
+ nil
574
+ end
575
+
422
576
  ##
423
577
  # Get the timezone from the given object, assuming it can be extracted from ical params.
424
578
  # @param [Icalendar::Value] ical_value an ical value that (probably) supports a time zone identifier.
@@ -427,11 +581,72 @@ module Icalendar
427
581
  def _extract_ical_time_zone(ical_value)
428
582
  return nil unless ical_value.is_a?(Icalendar::Value)
429
583
  return nil unless ical_value.respond_to?(:ical_params)
430
- ugly_tzid = ical_value.ical_params.fetch('tzid', nil)
584
+
585
+ ical_params = ical_value.ical_params
586
+ return nil unless ical_params
587
+
588
+ ugly_tzid = ical_params['tzid'] || ical_params[:tzid] || ical_params['TZID'] || ical_params[:TZID]
431
589
  return nil if ugly_tzid.nil?
590
+
432
591
  tzid = Array(ugly_tzid).first.to_s.gsub(/^(["'])|(["'])$/, '')
592
+ return nil if tzid.empty?
593
+
433
594
  ActiveSupport::TimeZone[tzid]
595
+ rescue StandardError
596
+ # Uncomment for debugging icalendar gem compatibility issues:
597
+ # warn "[icalendar-rrule] Failed to extract timezone: #{e.message}"
598
+ nil
434
599
  end
600
+
601
+ ##
602
+ # Attempts to determine the system's timezone.
603
+ # Tries multiple methods in order of reliability.
604
+ #
605
+ # @return [ActiveSupport::TimeZone, nil] the system timezone or nil if it cannot be determined.
606
+ # @api private
607
+ def _guess_system_timezone
608
+ # Method 1: Rails/ActiveSupport Time.zone (most reliable if set)
609
+ return Time.zone if Time.zone.is_a?(ActiveSupport::TimeZone)
610
+
611
+ # Method 2: ENV['TZ'] environment variable
612
+ if ENV['TZ']
613
+ tz = ActiveSupport::TimeZone[ENV['TZ']]
614
+ return tz if tz
615
+ end
616
+
617
+ # Method 3: Try TZInfo if available (optional dependency)
618
+ begin
619
+ require 'tzinfo'
620
+ tz_identifier = TZInfo::Timezone.default_timezone.identifier
621
+ tz = ActiveSupport::TimeZone[tz_identifier]
622
+ return tz if tz
623
+ rescue LoadError, StandardError
624
+ # TZInfo not available or failed, continue
625
+ end
626
+
627
+ # Method 4: Read /etc/timezone on Linux (Debian/Ubuntu style)
628
+ if File.readable?('/etc/timezone')
629
+ tz_name = File.read('/etc/timezone').strip
630
+ tz = ActiveSupport::TimeZone[tz_name]
631
+ return tz if tz
632
+ end
633
+
634
+ # Method 5: Parse /etc/localtime symlink (common on many Unix systems)
635
+ if File.symlink?('/etc/localtime')
636
+ link = File.readlink('/etc/localtime')
637
+ # Extract timezone name from path like /usr/share/zoneinfo/Europe/Berlin
638
+ if link =~ %r{zoneinfo/(.+)$}
639
+ tz = ActiveSupport::TimeZone[$1]
640
+ return tz if tz
641
+ end
642
+ end
643
+
644
+ nil
645
+ rescue StandardError
646
+ nil
647
+ end
648
+
649
+
435
650
  end
436
651
  end
437
652
  end
metadata CHANGED
@@ -1,85 +1,84 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: icalendar-rrule
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harald Postner
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2018-07-25 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activesupport
15
14
  requirement: !ruby/object:Gem::Requirement
16
15
  requirements:
17
- - - "~>"
16
+ - - ">="
18
17
  - !ruby/object:Gem::Version
19
18
  version: '5.1'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
- - - "~>"
23
+ - - ">="
25
24
  - !ruby/object:Gem::Version
26
25
  version: '5.1'
27
26
  - !ruby/object:Gem::Dependency
28
27
  name: icalendar
29
28
  requirement: !ruby/object:Gem::Requirement
30
29
  requirements:
31
- - - "~>"
30
+ - - ">="
32
31
  - !ruby/object:Gem::Version
33
32
  version: '2.4'
34
33
  type: :runtime
35
34
  prerelease: false
36
35
  version_requirements: !ruby/object:Gem::Requirement
37
36
  requirements:
38
- - - "~>"
37
+ - - ">="
39
38
  - !ruby/object:Gem::Version
40
39
  version: '2.4'
41
40
  - !ruby/object:Gem::Dependency
42
41
  name: ice_cube
43
42
  requirement: !ruby/object:Gem::Requirement
44
43
  requirements:
45
- - - "~>"
44
+ - - ">="
46
45
  - !ruby/object:Gem::Version
47
46
  version: '0.16'
48
47
  type: :runtime
49
48
  prerelease: false
50
49
  version_requirements: !ruby/object:Gem::Requirement
51
50
  requirements:
52
- - - "~>"
51
+ - - ">="
53
52
  - !ruby/object:Gem::Version
54
53
  version: '0.16'
55
54
  - !ruby/object:Gem::Dependency
56
55
  name: bundler
57
56
  requirement: !ruby/object:Gem::Requirement
58
57
  requirements:
59
- - - "~>"
58
+ - - ">="
60
59
  - !ruby/object:Gem::Version
61
- version: '1.16'
60
+ version: '2'
62
61
  type: :development
63
62
  prerelease: false
64
63
  version_requirements: !ruby/object:Gem::Requirement
65
64
  requirements:
66
- - - "~>"
65
+ - - ">="
67
66
  - !ruby/object:Gem::Version
68
- version: '1.16'
67
+ version: '2'
69
68
  - !ruby/object:Gem::Dependency
70
69
  name: rake
71
70
  requirement: !ruby/object:Gem::Requirement
72
71
  requirements:
73
- - - "~>"
72
+ - - ">="
74
73
  - !ruby/object:Gem::Version
75
- version: '10.0'
74
+ version: 12.3.3
76
75
  type: :development
77
76
  prerelease: false
78
77
  version_requirements: !ruby/object:Gem::Requirement
79
78
  requirements:
80
- - - "~>"
79
+ - - ">="
81
80
  - !ruby/object:Gem::Version
82
- version: '10.0'
81
+ version: 12.3.3
83
82
  - !ruby/object:Gem::Dependency
84
83
  name: rspec
85
84
  requirement: !ruby/object:Gem::Requirement
@@ -149,8 +148,8 @@ files:
149
148
  - ".rubocop.yml"
150
149
  - ".travis.yml"
151
150
  - ".yardopts"
151
+ - CHANGELOG.md
152
152
  - Gemfile
153
- - Gemfile.lock
154
153
  - LICENSE.txt
155
154
  - README.md
156
155
  - Rakefile
@@ -164,8 +163,10 @@ files:
164
163
  homepage: https://github.com/free-creations/icalendar-rrule
165
164
  licenses:
166
165
  - MIT
167
- metadata: {}
168
- post_install_message:
166
+ metadata:
167
+ homepage_uri: https://github.com/free-creations/icalendar-rrule
168
+ source_code_uri: https://github.com/free-creations/icalendar-rrule
169
+ bug_tracker_uri: https://github.com/free-creations/icalendar-rrule/issues
169
170
  rdoc_options: []
170
171
  require_paths:
171
172
  - lib
@@ -180,9 +181,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
180
181
  - !ruby/object:Gem::Version
181
182
  version: '0'
182
183
  requirements: []
183
- rubyforge_project:
184
- rubygems_version: 2.7.6
185
- signing_key:
184
+ rubygems_version: 4.0.1
186
185
  specification_version: 4
187
186
  summary: Helper for ICalendars with recurring events.
188
187
  test_files: []
data/Gemfile.lock DELETED
@@ -1,79 +0,0 @@
1
- PATH
2
- remote: .
3
- specs:
4
- icalendar-rrule (0.1.6)
5
- activesupport (~> 5.1)
6
- icalendar (~> 2.4)
7
- ice_cube (~> 0.16)
8
-
9
- GEM
10
- remote: https://rubygems.org/
11
- specs:
12
- activesupport (5.2.0)
13
- concurrent-ruby (~> 1.0, >= 1.0.2)
14
- i18n (>= 0.7, < 2)
15
- minitest (~> 5.1)
16
- tzinfo (~> 1.1)
17
- ast (2.4.0)
18
- concurrent-ruby (1.0.5)
19
- diff-lcs (1.3)
20
- docile (1.3.1)
21
- i18n (1.0.1)
22
- concurrent-ruby (~> 1.0)
23
- icalendar (2.4.1)
24
- ice_cube (0.16.3)
25
- json (2.1.0)
26
- minitest (5.11.3)
27
- parallel (1.12.1)
28
- parser (2.5.1.2)
29
- ast (~> 2.4.0)
30
- powerpack (0.1.2)
31
- rainbow (3.0.0)
32
- rake (10.5.0)
33
- rspec (3.7.0)
34
- rspec-core (~> 3.7.0)
35
- rspec-expectations (~> 3.7.0)
36
- rspec-mocks (~> 3.7.0)
37
- rspec-core (3.7.1)
38
- rspec-support (~> 3.7.0)
39
- rspec-expectations (3.7.0)
40
- diff-lcs (>= 1.2.0, < 2.0)
41
- rspec-support (~> 3.7.0)
42
- rspec-mocks (3.7.0)
43
- diff-lcs (>= 1.2.0, < 2.0)
44
- rspec-support (~> 3.7.0)
45
- rspec-support (3.7.1)
46
- rubocop (0.55.0)
47
- parallel (~> 1.10)
48
- parser (>= 2.5)
49
- powerpack (~> 0.1)
50
- rainbow (>= 2.2.2, < 4.0)
51
- ruby-progressbar (~> 1.7)
52
- unicode-display_width (~> 1.0, >= 1.0.1)
53
- rubocop-rspec (1.26.0)
54
- rubocop (>= 0.53.0)
55
- ruby-progressbar (1.9.0)
56
- simplecov (0.16.1)
57
- docile (~> 1.1)
58
- json (>= 1.8, < 3)
59
- simplecov-html (~> 0.10.0)
60
- simplecov-html (0.10.2)
61
- thread_safe (0.3.6)
62
- tzinfo (1.2.5)
63
- thread_safe (~> 0.1)
64
- unicode-display_width (1.4.0)
65
-
66
- PLATFORMS
67
- ruby
68
-
69
- DEPENDENCIES
70
- bundler (~> 1.16)
71
- icalendar-rrule!
72
- rake (~> 10.0)
73
- rspec (~> 3.7)
74
- rubocop (~> 0.55.0)
75
- rubocop-rspec (~> 1.24)
76
- simplecov (~> 0.16)
77
-
78
- BUNDLED WITH
79
- 1.16.1