icalendar-rrule 0.2.0 → 0.3.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 +4 -4
- data/.github/workflows/ruby.yml +31 -0
- data/CHANGELOG.md +8 -0
- data/README.md +66 -0
- data/icalendar-rrule.gemspec +3 -3
- data/lib/icalendar/rrule/time_refinements.rb +123 -0
- data/lib/icalendar/rrule/version.rb +1 -1
- data/lib/icalendar/rrule.rb +24 -0
- data/lib/icalendar/scannable-calendar.rb +28 -16
- data/lib/icalendar/schedulable-component.rb +280 -27
- metadata +8 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 657bfb18be866339dcd5fa55c355864c8d5870ca96d71f83496d0e8065495c43
|
|
4
|
+
data.tar.gz: 37dd92456663dfb2b257db55146ca6868f492549a632a7d39f547896d3f44136
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8e6eeaec9f4174fcf7211688d07955d7a75f972ec0d9fcc5613fb194174df5a7cae03384d220b98505ff12d5ab17d7ad7243a1d778b4fb9be4c7add149da40b6
|
|
7
|
+
data.tar.gz: b2dcc6a4c3c6e8d2dfca0a0b96c81b477a06a3ff2e4d5cac3ca6d44d18fb7a055dba42bd3a9638e74487684ca0cd0dbb0b6c4bb75a010314ee2466d141a54166
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main, master ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ main, master ]
|
|
8
|
+
schedule:
|
|
9
|
+
# Runs at 04:17 on the 13th of every month to avoid the "1st of the month" peak
|
|
10
|
+
- cron: '17 4 13 * *'
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
test:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
strategy:
|
|
16
|
+
fail-fast: false
|
|
17
|
+
matrix:
|
|
18
|
+
# Testing against 3.1, 3.4 and the latest stable release (currently 4.0)
|
|
19
|
+
ruby-version: ['3.2', '3.4', 'ruby']
|
|
20
|
+
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
|
|
24
|
+
- name: Set up Ruby
|
|
25
|
+
uses: ruby/setup-ruby@v1
|
|
26
|
+
with:
|
|
27
|
+
ruby-version: ${{ matrix.ruby-version }}
|
|
28
|
+
bundler-cache: true # Runs 'bundle install' and caches gems automatically
|
|
29
|
+
|
|
30
|
+
- name: Run specs
|
|
31
|
+
run: bundle exec rspec
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.3.0] - 2026-02-06
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- **BREAKING**: Minimum Ruby version increased to 3.2 (required by ActiveSupport 8.1)
|
|
9
|
+
- Updated timezone handling for better DST stability
|
|
10
|
+
- Added GitHub Actions CI pipeline
|
|
11
|
+
|
|
12
|
+
|
|
5
13
|
## [0.2.0] - 2025-12-24
|
|
6
14
|
|
|
7
15
|
### Added
|
data/README.md
CHANGED
|
@@ -84,6 +84,72 @@ Fri. Apr. 27. 8:30-17:00
|
|
|
84
84
|
For a more elaborate example, please have a look at
|
|
85
85
|
<https://github.com/free-creations/sk_calendar>
|
|
86
86
|
|
|
87
|
+
## Configuration
|
|
88
|
+
|
|
89
|
+
### Logging
|
|
90
|
+
|
|
91
|
+
By default, the gem logs nothing. You can enable logging for debugging timezone issues:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
# Enable logging to STDOUT
|
|
95
|
+
Icalendar::Rrule.logger = Logger.new($stdout)
|
|
96
|
+
|
|
97
|
+
# Or use Rails logger
|
|
98
|
+
Icalendar::Rrule.logger = Rails.logger
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Time handling (start/end) and timezones
|
|
102
|
+
|
|
103
|
+
This gem represents both VEVENTs and VTODOs uniformly as *occurrences* with a
|
|
104
|
+
normalised `start_time` and `end_time` (both `ActiveSupport::TimeWithZone`).
|
|
105
|
+
|
|
106
|
+
### Normalising start_time / end_time
|
|
107
|
+
|
|
108
|
+
Input components can specify time using `DTSTART`, `DTEND`, `DUE` and/or `DURATION`.
|
|
109
|
+
In practice these fields are often incomplete or inconsistent, therefore this gem
|
|
110
|
+
derives a sensible time range using the following rules:
|
|
111
|
+
|
|
112
|
+
- `start_time`
|
|
113
|
+
1. If `DTSTART` is present: `start_time = DTSTART`
|
|
114
|
+
2. Else if `DUE` and `DURATION` are present: `start_time = DUE - DURATION`
|
|
115
|
+
3. Else if only `DUE` is present: `start_time = DUE` (deadline-only / zero-duration)
|
|
116
|
+
4. Else: a null fallback is used (Unix epoch)
|
|
117
|
+
|
|
118
|
+
- `end_time`
|
|
119
|
+
1. If `DUE` is present: `end_time = DUE`
|
|
120
|
+
2. Else if `DTEND` is present: `end_time = DTEND`
|
|
121
|
+
3. Else if `DTSTART` is present: `end_time = start_time + duration`
|
|
122
|
+
4. Else: `end_time = epoch + duration`
|
|
123
|
+
|
|
124
|
+
For _all-day VEVENTs_ (`DTSTART` as DATE) without `DTEND`, `DUE` and `DURATION`, the
|
|
125
|
+
duration is assumed to be one day (RFC 5545).
|
|
126
|
+
|
|
127
|
+
### Recurrence semantics (RRULE)
|
|
128
|
+
|
|
129
|
+
Recurrences are expanded in floating (wall-clock) time. The `RRULE` is unrolled
|
|
130
|
+
without applying offsets during expansion; timezone conversion is applied only
|
|
131
|
+
after occurrences have been generated.
|
|
132
|
+
|
|
133
|
+
Rationale:
|
|
134
|
+
|
|
135
|
+
- By keeping recurrence logic in wall-clock time and deferring timezone application,
|
|
136
|
+
this gem avoids DST-related drift, ambiguous instants and retroactive rule changes,
|
|
137
|
+
while remaining predictable and deterministic.
|
|
138
|
+
|
|
139
|
+
### Timezone resolution
|
|
140
|
+
|
|
141
|
+
When mapping values to `ActiveSupport::TimeWithZone`, timezones are resolved with
|
|
142
|
+
the following precedence:
|
|
143
|
+
|
|
144
|
+
1. An explicit timezone on the value itself (`TZID` / `TimeWithZone`)
|
|
145
|
+
2. A timezone defined by the parent calendar (`VTIMEZONE` / calendar settings)
|
|
146
|
+
3. The system timezone
|
|
147
|
+
4. UTC as a final fallback
|
|
148
|
+
|
|
149
|
+
Floating date-times(i.e. `DATE-TIME` values without `TZID` and without `Z`) are interpreted as
|
|
150
|
+
local time and materialised in the _system time zone_.
|
|
151
|
+
|
|
152
|
+
|
|
87
153
|
## Used Libraries
|
|
88
154
|
|
|
89
155
|
- [iCalendar Gem](https://github.com/icalendar/icalendar).
|
data/icalendar-rrule.gemspec
CHANGED
|
@@ -28,11 +28,11 @@ Gem::Specification.new do |gem_spec|
|
|
|
28
28
|
gem_spec.executables = gem_spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
29
29
|
gem_spec.require_paths = ['lib']
|
|
30
30
|
|
|
31
|
-
gem_spec.required_ruby_version = '>= 2
|
|
31
|
+
gem_spec.required_ruby_version = '>= 3.2'
|
|
32
32
|
|
|
33
|
-
gem_spec.add_dependency 'activesupport', '>=
|
|
33
|
+
gem_spec.add_dependency 'activesupport', '>= 8.0'
|
|
34
34
|
gem_spec.add_dependency 'icalendar', '>= 2.4'
|
|
35
|
-
gem_spec.add_dependency 'ice_cube', '>= 0.
|
|
35
|
+
gem_spec.add_dependency 'ice_cube', '>= 0.17'
|
|
36
36
|
|
|
37
37
|
gem_spec.add_development_dependency 'bundler', '>= 2'
|
|
38
38
|
gem_spec.add_development_dependency 'rake', '>= 12.3.3'
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'active_support/time_with_zone'
|
|
5
|
+
require 'active_support/values/time_zone'
|
|
6
|
+
|
|
7
|
+
module Icalendar
|
|
8
|
+
module Rrule
|
|
9
|
+
##
|
|
10
|
+
# Refinements for Ruby's Time class to support iCalendar time concepts.
|
|
11
|
+
#
|
|
12
|
+
# This module provides extensions for handling:
|
|
13
|
+
# - Floating time (wall-clock time without timezone)
|
|
14
|
+
# - Timezone conversions (planned)
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# using Icalendar::Rrule::TimeRefinements
|
|
18
|
+
#
|
|
19
|
+
# time = Time.new(2026, 1, 7, 14, 30, 0, 0)
|
|
20
|
+
# time.floating? # => true
|
|
21
|
+
#
|
|
22
|
+
module TimeRefinements
|
|
23
|
+
refine Time do
|
|
24
|
+
##
|
|
25
|
+
# Checks if this Time object represents "floating time".
|
|
26
|
+
#
|
|
27
|
+
# Floating time is wall-clock time without timezone information,
|
|
28
|
+
# represented as a Time with zone == nil.
|
|
29
|
+
#
|
|
30
|
+
# In iCalendar terms, floating time is a DATETIME without TZID
|
|
31
|
+
# and without the "Z" suffix (e.g., "20260107T143000").
|
|
32
|
+
#
|
|
33
|
+
# This is semantically different from UTC:
|
|
34
|
+
# - UTC is a real timezone (the world reference)
|
|
35
|
+
# - Floating time means "interpret locally" (like a photo of a clock)
|
|
36
|
+
#
|
|
37
|
+
# @return [Boolean] true if this is floating time (zone is nil)
|
|
38
|
+
#
|
|
39
|
+
# @example Floating time
|
|
40
|
+
# Time.new(2026, 1, 1, 12, 0, 0, 0).floating? # => true
|
|
41
|
+
#
|
|
42
|
+
# @example UTC is NOT floating
|
|
43
|
+
# Time.utc(2026, 1, 1, 12, 0, 0).floating? # => false
|
|
44
|
+
#
|
|
45
|
+
# @example Zoned time is NOT floating
|
|
46
|
+
# Time.new(2026, 1, 1, 12, 0, 0, "+01:00").floating? # => false
|
|
47
|
+
#
|
|
48
|
+
def floating?
|
|
49
|
+
zone.nil? && utc_offset.zero?
|
|
50
|
+
rescue StandardError
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
##
|
|
55
|
+
# Converts to floating time. No questions asked.
|
|
56
|
+
# @return [Time] floating time
|
|
57
|
+
def to_floating
|
|
58
|
+
return self if floating?
|
|
59
|
+
Time.new(year, month, day, hour, min, sec, 0)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
##
|
|
63
|
+
# Ensures this is floating-time, warns if conversion needed.
|
|
64
|
+
# @return [Time] floating time
|
|
65
|
+
def ensure_floating
|
|
66
|
+
return self if floating?
|
|
67
|
+
|
|
68
|
+
Icalendar::Rrule.logger.warn do
|
|
69
|
+
"[icalendar-rrule] Time should be floating but has offset: '#{inspect}' - converting"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
to_floating
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
##
|
|
77
|
+
# Embeds floating time into a specific timezone.
|
|
78
|
+
#
|
|
79
|
+
# Interprets the wall-clock components (year, month, day, hour, min, sec)
|
|
80
|
+
# as local time in the given timezone, creating a TimeWithZone.
|
|
81
|
+
#
|
|
82
|
+
# This is the inverse operation to `to_floating`.
|
|
83
|
+
#
|
|
84
|
+
# @param [ActiveSupport::TimeZone, String] timezone the target timezone
|
|
85
|
+
# @return [ActiveSupport::TimeWithZone] the time embedded in the given timezone
|
|
86
|
+
# @raise [ArgumentError] if called on non-floating time (to prevent accidental misuse)
|
|
87
|
+
#
|
|
88
|
+
# @example Embed floating time in Berlin timezone
|
|
89
|
+
# floating = Time.new(2026, 3, 30, 10, 0, 0, 0) # 10:00 floating
|
|
90
|
+
# berlin_time = floating.into_timezone('Europe/Berlin')
|
|
91
|
+
# # => 2026-03-30 10:00:00 +0200 (CEST, because DST active)
|
|
92
|
+
#
|
|
93
|
+
# @example Embed the same floating time in New York
|
|
94
|
+
# ny_time = floating.into_timezone('America/New_York')
|
|
95
|
+
# # => 2026-03-30 10:00:00 -0400 (EDT, because DST active)
|
|
96
|
+
#
|
|
97
|
+
def into_timezone(timezone)
|
|
98
|
+
unless floating?
|
|
99
|
+
raise ArgumentError, "Time::into_timezone can only be called on floating time, got: #{inspect}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Ensure we have an ActiveSupport::TimeZone object
|
|
103
|
+
tz = if timezone.is_a?(ActiveSupport::TimeZone)
|
|
104
|
+
timezone
|
|
105
|
+
else
|
|
106
|
+
ActiveSupport::TimeZone[timezone]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
unless tz
|
|
110
|
+
Icalendar::Rrule.logger.warn do
|
|
111
|
+
"[icalendar-rrule] Invalid timezone '#{timezone.inspect}' in Time::into_timezone - falling back to UTC"
|
|
112
|
+
end
|
|
113
|
+
tz = ActiveSupport::TimeZone['UTC']
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Interpret wall-clock components as local time in target timezone
|
|
117
|
+
tz.local(year, month, day, hour, min, sec)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/icalendar/rrule.rb
CHANGED
|
@@ -6,3 +6,27 @@ require 'icalendar/scannable-calendar'
|
|
|
6
6
|
|
|
7
7
|
require 'icalendar/rrule/version'
|
|
8
8
|
require 'icalendar/rrule/occurrence'
|
|
9
|
+
require 'icalendar/rrule/time_refinements'
|
|
10
|
+
require 'logger'
|
|
11
|
+
|
|
12
|
+
module Icalendar
|
|
13
|
+
module Rrule
|
|
14
|
+
class << self
|
|
15
|
+
# Configurable logger for the icalendar-rrule gem.
|
|
16
|
+
# By default, logs nothing (Logger to /dev/null).
|
|
17
|
+
#
|
|
18
|
+
# @example Enable logging to STDOUT
|
|
19
|
+
# Icalendar::Rrule.logger = Logger.new($stdout)
|
|
20
|
+
#
|
|
21
|
+
# @example Use Rails logger
|
|
22
|
+
# Icalendar::Rrule.logger = Rails.logger
|
|
23
|
+
#
|
|
24
|
+
# @return [Logger]
|
|
25
|
+
attr_writer :logger
|
|
26
|
+
|
|
27
|
+
def logger
|
|
28
|
+
@logger ||= Logger.new(File::NULL) # default: silent
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'icalendar/rrule/time_refinements'
|
|
4
|
+
|
|
3
5
|
module Icalendar
|
|
4
6
|
##
|
|
5
7
|
# Refines the Icalendar::Calendar class by adding
|
|
@@ -14,6 +16,7 @@ module Icalendar
|
|
|
14
16
|
# Icalendar::Calendar class
|
|
15
17
|
refine Icalendar::Calendar do # rubocop:disable Metrics/BlockLength
|
|
16
18
|
using Icalendar::Schedulable
|
|
19
|
+
using Icalendar::Rrule::TimeRefinements
|
|
17
20
|
##
|
|
18
21
|
# @param[date_time] begin_time
|
|
19
22
|
# @param[date_time] closing_time
|
|
@@ -49,26 +52,35 @@ module Icalendar
|
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
private def _occurrences_between(components, begin_time, closing_time)
|
|
55
|
+
# ice_cube does not support timezones well, so we have to work internally with _floating times_.
|
|
52
56
|
result = []
|
|
53
|
-
components.each do |
|
|
54
|
-
|
|
57
|
+
components.each do |base_component|
|
|
58
|
+
start_timezone = base_component._timezone_for_start
|
|
59
|
+
end_timezone = base_component._timezone_for_end
|
|
60
|
+
# we assume that that `start_timezone` and`end_timezone` are also appropriate for start and closing time of the scan.
|
|
61
|
+
# todo: simlify by using base_component._to_floating_time_auto
|
|
62
|
+
zoned_begin_time = base_component._date_to_time_with_zone(begin_time, start_timezone)
|
|
63
|
+
zoned_closing_time = base_component._date_to_time_with_zone(closing_time, end_timezone)
|
|
64
|
+
|
|
65
|
+
floating_begin_time = zoned_begin_time.to_time.to_floating
|
|
66
|
+
floating_closing_time = zoned_closing_time.to_time.to_floating
|
|
67
|
+
|
|
68
|
+
ice_cube_occurrences = base_component.schedule.occurrences_between(floating_begin_time, floating_closing_time)
|
|
55
69
|
|
|
56
|
-
|
|
57
|
-
|
|
70
|
+
ice_cube_occurrences.each do |ice_oc|
|
|
71
|
+
# retrieve start and end for this ice_cube occurrence and convert into the
|
|
72
|
+
# target timezones
|
|
73
|
+
ice_comp_start_time = ice_oc.start_time
|
|
74
|
+
# we assert that ice_comp_start_time is floating here.
|
|
75
|
+
fail "expected floating-time for ice_start_time" unless ice_comp_start_time.floating?
|
|
76
|
+
start_tz = ice_comp_start_time.into_timezone(start_timezone)
|
|
58
77
|
|
|
59
|
-
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
)
|
|
78
|
+
ice_end_time = ice_oc.end_time
|
|
79
|
+
# we assert that ice_end_time is floating here.
|
|
80
|
+
fail "expected floating-time for ice_end_time" unless ice_end_time.floating?
|
|
81
|
+
end_tz = ice_end_time.into_timezone(end_timezone)
|
|
70
82
|
|
|
71
|
-
new_oc = Icalendar::Rrule::Occurrence.new(self,
|
|
83
|
+
new_oc = Icalendar::Rrule::Occurrence.new(self, base_component, start_tz, end_tz)
|
|
72
84
|
result << new_oc
|
|
73
85
|
end
|
|
74
86
|
end
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'ice_cube'
|
|
4
|
+
require 'active_support/time'
|
|
4
5
|
require 'active_support/time_with_zone'
|
|
6
|
+
require 'active_support/values/time_zone'
|
|
7
|
+
require 'date'
|
|
8
|
+
require 'time'
|
|
5
9
|
|
|
6
10
|
module Icalendar
|
|
7
11
|
##
|
|
@@ -44,11 +48,11 @@ module Icalendar
|
|
|
44
48
|
NULL_TIME = 0
|
|
45
49
|
|
|
46
50
|
# the number of seconds in a minute
|
|
47
|
-
SEC_MIN
|
|
51
|
+
SEC_MIN = 60
|
|
48
52
|
# the number of seconds in an hour
|
|
49
53
|
SEC_HOUR = 60 * SEC_MIN
|
|
50
54
|
# the number of seconds in a day
|
|
51
|
-
SEC_DAY
|
|
55
|
+
SEC_DAY = 24 * SEC_HOUR
|
|
52
56
|
# the number of seconds in a week
|
|
53
57
|
SEC_WEEK = 7 * SEC_DAY
|
|
54
58
|
##
|
|
@@ -214,6 +218,7 @@ module Icalendar
|
|
|
214
218
|
# @return [Boolean] true if the component is an Event scheduled for an entire day,
|
|
215
219
|
# false for tasks or timed events
|
|
216
220
|
def all_day?
|
|
221
|
+
# todo: determine timezone purely from input parameters (i.e from _dtstart, _dtend, _due)
|
|
217
222
|
return false unless self.is_a?(Icalendar::Event)
|
|
218
223
|
|
|
219
224
|
_dtstart.is_a?(Icalendar::Values::Date) ||
|
|
@@ -234,13 +239,14 @@ module Icalendar
|
|
|
234
239
|
#
|
|
235
240
|
# @return [Boolean] true if the component has no duration
|
|
236
241
|
def single_timestamp?
|
|
237
|
-
|
|
242
|
+
# todo: determine timezone purely from input parameters (i.e from _dtstart, _dtend, _due)
|
|
243
|
+
return false if start_time.nil? || end_time.nil? # <--- ???? are never nil ????
|
|
238
244
|
# Compare at second precision (ignore potential microsecond differences)
|
|
239
245
|
start_time.to_i == end_time.to_i
|
|
240
246
|
end
|
|
241
247
|
|
|
242
248
|
##
|
|
243
|
-
# Make sure
|
|
249
|
+
# Make sure that we can always query for a _rrule_ array.
|
|
244
250
|
# @return [array] an array of _ical repeat-rules_ (or an empty array
|
|
245
251
|
# if no repeat-rules are defined for this component).
|
|
246
252
|
# @api private
|
|
@@ -251,9 +257,10 @@ module Icalendar
|
|
|
251
257
|
end
|
|
252
258
|
|
|
253
259
|
##
|
|
254
|
-
# Make sure
|
|
255
|
-
# @return [
|
|
256
|
-
#
|
|
260
|
+
# Make sure that we can always query for an `_exdate` array.
|
|
261
|
+
# @return [Array] an array of _ical exdates_ in their original format
|
|
262
|
+
# (may be Icalendar::Values::DateTime, Icalendar::Values::Date, or other date/time types).
|
|
263
|
+
# Returns an empty array if no exdates are defined or an error occurs.
|
|
257
264
|
# @api private
|
|
258
265
|
def _exdates
|
|
259
266
|
Array(exdate).flatten
|
|
@@ -311,7 +318,7 @@ module Icalendar
|
|
|
311
318
|
##
|
|
312
319
|
# Like the for _exdates, also for these dates do not schedule recurrence items.
|
|
313
320
|
#
|
|
314
|
-
# @return [
|
|
321
|
+
# @return [Array<Object>] an array of dates.
|
|
315
322
|
# @api private
|
|
316
323
|
def _overwritten_dates
|
|
317
324
|
result = []
|
|
@@ -325,7 +332,7 @@ module Icalendar
|
|
|
325
332
|
end
|
|
326
333
|
|
|
327
334
|
##
|
|
328
|
-
# Make sure
|
|
335
|
+
# Make sure that we can always query for a rdate(Recurrence Date) array.
|
|
329
336
|
# @return [array] an array of _ical rdates_ (or an empty array
|
|
330
337
|
# if no repeat-rules are defined for this component).
|
|
331
338
|
# @api private
|
|
@@ -339,33 +346,110 @@ module Icalendar
|
|
|
339
346
|
# Creates a schedule for this event
|
|
340
347
|
# @return [IceCube::Schedule]
|
|
341
348
|
def schedule # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
342
|
-
# Calculate duration in seconds
|
|
343
|
-
duration_seconds = (end_time.to_i - start_time.to_i)
|
|
349
|
+
# Calculate the duration of this base event in seconds
|
|
350
|
+
duration_seconds = (end_time.to_i - start_time.to_i) # Integer seconds
|
|
344
351
|
|
|
345
352
|
# Create a schedule with start_time and duration
|
|
346
|
-
# Convert to
|
|
347
|
-
schedule = IceCube::Schedule.new(start_time
|
|
353
|
+
# Convert to floating Time for IceCube compatibility
|
|
354
|
+
schedule = IceCube::Schedule.new(_to_floating_time(start_time, _timezone_for_start), duration: duration_seconds)
|
|
348
355
|
|
|
349
356
|
_rrules.each do |rrule|
|
|
350
|
-
|
|
357
|
+
# Convert RRULE's UNTIL time (if present) to floating time in event's timezone
|
|
358
|
+
normalized_rrule = _normalize_rrule_until(rrule, _timezone_for_start)
|
|
359
|
+
ice_cube_recurrence_rule = IceCube::Rule.from_ical(normalized_rrule)
|
|
351
360
|
schedule.add_recurrence_rule(ice_cube_recurrence_rule)
|
|
352
361
|
end
|
|
353
362
|
|
|
354
|
-
_exdates.each do |
|
|
355
|
-
schedule.add_exception_time(
|
|
363
|
+
_exdates.each do |ex_time|
|
|
364
|
+
schedule.add_exception_time(_to_floating_time(ex_time, _timezone_for_start))
|
|
356
365
|
end
|
|
357
366
|
|
|
358
|
-
_overwritten_dates.each do |
|
|
359
|
-
schedule.add_exception_time(
|
|
367
|
+
_overwritten_dates.each do |overwritten_time|
|
|
368
|
+
schedule.add_exception_time(_to_floating_time(overwritten_time, _timezone_for_start))
|
|
360
369
|
end
|
|
361
370
|
|
|
362
371
|
rdates = _rdates
|
|
363
|
-
rdates.each do |
|
|
364
|
-
schedule.add_recurrence_time(
|
|
372
|
+
rdates.each do |recurrence_time|
|
|
373
|
+
schedule.add_recurrence_time(_to_floating_time(recurrence_time, _timezone_for_start))
|
|
365
374
|
end
|
|
366
375
|
schedule
|
|
367
376
|
end
|
|
368
377
|
|
|
378
|
+
##
|
|
379
|
+
# Normalizes an RRULE string by converting UNTIL times to a format IceCube can handle.
|
|
380
|
+
#
|
|
381
|
+
# ## The IceCube System-Timezone Problem
|
|
382
|
+
#
|
|
383
|
+
# IceCube has a critical flaw: it interprets floating times (without Z suffix) in the
|
|
384
|
+
# **system timezone** at runtime, making PRODUCTION BEHAVIOR dependent on the server's
|
|
385
|
+
# timezone configuration. This isn't just a test problem - the same calendar file would
|
|
386
|
+
# produce different occurrences on different servers!
|
|
387
|
+
#
|
|
388
|
+
# Example with `UNTIL=20180609T190000` (floating, should be 19:00 in event's timezone):
|
|
389
|
+
# - Server in Detroit (UTC-4): IceCube interprets as 19:00 Detroit → 23:00 UTC ❌
|
|
390
|
+
# - Server in Brisbane (UTC+10): IceCube interprets as 19:00 Brisbane → 09:00 UTC ❌
|
|
391
|
+
# - Server in Berlin (UTC+2): IceCube interprets as 19:00 Berlin → 17:00 UTC ✅ (by luck!)
|
|
392
|
+
#
|
|
393
|
+
# This means the SAME iCalendar file would stop repeating at different times depending
|
|
394
|
+
# on WHERE the application is deployed. This is completely unacceptable for a calendar
|
|
395
|
+
# library that's supposed to provide consistent, predictable behavior.
|
|
396
|
+
#
|
|
397
|
+
# ## The Hack: Always append Z suffix
|
|
398
|
+
#
|
|
399
|
+
# IceCube treats times with Z suffix as absolute values and skips timezone conversion:
|
|
400
|
+
# - `UNTIL=20180609T190000Z` → IceCube uses 19:00 directly, no system-TZ applied ✅
|
|
401
|
+
#
|
|
402
|
+
# This is **technically incorrect** (Z means UTC in RFC 5545, not floating time), but it's
|
|
403
|
+
# the only way to get consistent, server-timezone-independent behavior. We convert times
|
|
404
|
+
# to the event's timezone first, then append Z to "lock in" that wall-clock time and
|
|
405
|
+
# prevent IceCube from applying system-TZ conversion.
|
|
406
|
+
#
|
|
407
|
+
# Without this hack, deployments would behave differently based on server timezone,
|
|
408
|
+
# breaking calendar consistency across environments.
|
|
409
|
+
#
|
|
410
|
+
# @param [String] rrule_string the RRULE string (e.g., "FREQ=WEEKLY;UNTIL=20180615T120000Z")
|
|
411
|
+
# @param [ActiveSupport::TimeZone] target_timezone the event's timezone
|
|
412
|
+
# @return [String] the normalized RRULE string with UNTIL time + Z suffix
|
|
413
|
+
# @api private
|
|
414
|
+
#
|
|
415
|
+
# @example RFC 5545 floating time gets Z appended (the hack!)
|
|
416
|
+
# rrule = "FREQ=WEEKLY;UNTIL=20180609T190000"
|
|
417
|
+
# _normalize_rrule_until(rrule, 'Europe/Berlin')
|
|
418
|
+
# # => "FREQ=WEEKLY;UNTIL=20180609T190000Z" (Z added to prevent system-TZ interpretation)
|
|
419
|
+
#
|
|
420
|
+
# @example UTC UNTIL gets converted to event timezone, then Z appended
|
|
421
|
+
# rrule = "FREQ=WEEKLY;UNTIL=20180609T170000Z" # 17:00 UTC
|
|
422
|
+
# _normalize_rrule_until(rrule, 'Europe/Berlin') # Event in Berlin (UTC+2)
|
|
423
|
+
# # => "FREQ=WEEKLY;UNTIL=20180609T190000Z" (17:00 UTC → 19:00 Berlin, Z prevents conversion)
|
|
424
|
+
#
|
|
425
|
+
def _normalize_rrule_until(rrule_string, target_timezone)
|
|
426
|
+
# Match UNTIL with UTC time (Z suffix) or with explicit time
|
|
427
|
+
# Matches: UNTIL=20180615T120000Z or UNTIL=20180615T120000
|
|
428
|
+
rrule_string.gsub(/UNTIL=(\d{8}T\d{6})(Z?)/) do |_match|
|
|
429
|
+
timestamp_str = $1
|
|
430
|
+
is_utc = $2 == 'Z'
|
|
431
|
+
|
|
432
|
+
# RFC 5545 floating time (no Z): pass through unchanged, but add Z to prevent
|
|
433
|
+
# IceCube from interpreting it in system-TZ
|
|
434
|
+
next "UNTIL=#{timestamp_str}Z" unless is_utc
|
|
435
|
+
|
|
436
|
+
# Already UTC with Z - convert to floating in target timezone but add Z to prevent
|
|
437
|
+
# IceCube from interpreting it in system-TZ
|
|
438
|
+
year = timestamp_str[0..3].to_i
|
|
439
|
+
month = timestamp_str[4..5].to_i
|
|
440
|
+
day = timestamp_str[6..7].to_i
|
|
441
|
+
hour = timestamp_str[9..10].to_i
|
|
442
|
+
minute = timestamp_str[11..12].to_i
|
|
443
|
+
second = timestamp_str[13..14].to_i
|
|
444
|
+
|
|
445
|
+
time_obj = Time.utc(year, month, day, hour, minute, second)
|
|
446
|
+
floating_until = _to_floating_time(time_obj, target_timezone)
|
|
447
|
+
|
|
448
|
+
# Format with Z to prevent IceCube system-TZ interpretation
|
|
449
|
+
"UNTIL=#{floating_until.strftime('%Y%m%dT%H%M%S')}Z"
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
369
453
|
##
|
|
370
454
|
# Transform the given object into an object of type `ActiveSupport::TimeWithZone`.
|
|
371
455
|
#
|
|
@@ -421,8 +505,8 @@ module Icalendar
|
|
|
421
505
|
return _date_to_time_with_zone(date_time_value, timezone)
|
|
422
506
|
|
|
423
507
|
elsif date_time_value.is_a?(Time)
|
|
424
|
-
#
|
|
425
|
-
return
|
|
508
|
+
# Treat Ruby Time as quasi-floating: preserve wall-clock time, embed in the target timezone
|
|
509
|
+
return _embed_in_timezone_preserving_wall_clock(date_time_value, timezone)
|
|
426
510
|
|
|
427
511
|
elsif date_time_value.respond_to?(:to_time)
|
|
428
512
|
return timezone.at(date_time_value.to_time)
|
|
@@ -435,6 +519,7 @@ module Icalendar
|
|
|
435
519
|
# Oops, the given object is unusable, we'll give back the NULL_DATE
|
|
436
520
|
timezone.at(NULL_TIME)
|
|
437
521
|
end
|
|
522
|
+
|
|
438
523
|
# rubocop:enable Metrics/MethodLength,Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
|
|
439
524
|
|
|
440
525
|
##
|
|
@@ -447,9 +532,158 @@ module Icalendar
|
|
|
447
532
|
timezone.local(d.year, d.month, d.day)
|
|
448
533
|
end
|
|
449
534
|
|
|
535
|
+
##
|
|
536
|
+
# Converts any time to floating time, CONVERTING to the target timezone first.
|
|
537
|
+
#
|
|
538
|
+
# Use this when you want the wall-clock time in a DIFFERENT timezone.
|
|
539
|
+
# Example: EXDATE:20260330T080000Z (UTC) with event in Berlin → 10:00 floating (Berlin wall-clock)
|
|
540
|
+
#
|
|
541
|
+
# @param [Object] date_or_time any object representing a time (Icalendar::Values::DateTime,
|
|
542
|
+
# ActiveSupport::TimeWithZone, Date, Time, Integer, etc.)
|
|
543
|
+
# @param [ActiveSupport::TimeZone,String] target_tz the timezone to interpret the time in
|
|
544
|
+
# before converting to floating time.
|
|
545
|
+
# @return [Time] a Ruby Time object with UTC offset 0 (floating time)
|
|
546
|
+
# @api private
|
|
547
|
+
#
|
|
548
|
+
# @example Convert a UTC timestamp to floating time in Berlin
|
|
549
|
+
# # UTC: 2018-01-01 15:00 UTC → Berlin: 2018-01-01 16:00 CET
|
|
550
|
+
# floating = _to_floating_time(utc_time, ActiveSupport::TimeZone['Europe/Berlin'])
|
|
551
|
+
# # => 2018-01-01 16:00:00 +0000 (floating)
|
|
552
|
+
def _to_floating_time(date_or_time, target_tz)
|
|
553
|
+
|
|
554
|
+
if date_or_time.is_a?(Date)
|
|
555
|
+
return Time.new(date_or_time.year, date_or_time.month, date_or_time.day, 0, 0, 0, 0)
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
if date_or_time.is_a?(Icalendar::Values::Date)
|
|
559
|
+
return Time.new(date_or_time.year, date_or_time.month, date_or_time.day, 0, 0, 0, 0)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
active_target_tz = _ensure_active_timezone(target_tz)
|
|
563
|
+
|
|
564
|
+
# Convert to TimeWithZone in the target timezone first
|
|
565
|
+
time_with_zone = _embed_in_timezone_preserving_moment(date_or_time, active_target_tz)
|
|
566
|
+
|
|
567
|
+
# Extract wall-clock components and create floating time (offset 0)
|
|
568
|
+
Time.new(
|
|
569
|
+
time_with_zone.year,
|
|
570
|
+
time_with_zone.month,
|
|
571
|
+
time_with_zone.day,
|
|
572
|
+
time_with_zone.hour,
|
|
573
|
+
time_with_zone.min,
|
|
574
|
+
time_with_zone.sec,
|
|
575
|
+
0 # UTC offset 0 = floating time
|
|
576
|
+
)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
##
|
|
580
|
+
# Embeds a Ruby Time object into a timezone, preserving wall-clock time.
|
|
581
|
+
#
|
|
582
|
+
# This treats the Time as "quasi-floating" - we take its wall-clock components
|
|
583
|
+
# (year, month, day, hour, minute, second) and interpret them in the target timezone,
|
|
584
|
+
# ignoring the original offset. The UTC moment will change according to the
|
|
585
|
+
# timezone offset difference.
|
|
586
|
+
#
|
|
587
|
+
# A warning is logged if the Time's original offset doesn't match the target
|
|
588
|
+
# timezone's offset (indicating potential user confusion about timezones).
|
|
589
|
+
#
|
|
590
|
+
# Use this when the Time represents a wall-clock reading (e.g., from user input
|
|
591
|
+
# or an iCalendar floating time) that should be interpreted in a specific timezone.
|
|
592
|
+
#
|
|
593
|
+
# @example
|
|
594
|
+
# local_time = Time.new(2026, 1, 15, 12, 0, 0, '+05:00') # 12:00 somewhere
|
|
595
|
+
# berlin = ActiveSupport::TimeZone['Europe/Berlin']
|
|
596
|
+
# result = _embed_in_timezone_preserving_wall_clock(local_time, berlin)
|
|
597
|
+
# # => 2026-01-15 12:00:00 +0100 (same wall-clock, different moment)
|
|
598
|
+
#
|
|
599
|
+
# @param [Time] time_value a Ruby Time object
|
|
600
|
+
# @param [ActiveSupport::TimeZone] timezone the target timezone
|
|
601
|
+
# @return [ActiveSupport::TimeWithZone] the time embedded in the target timezone
|
|
602
|
+
# @api private
|
|
603
|
+
# @see _embed_in_timezone_preserving_moment for the inverse operation
|
|
604
|
+
def _embed_in_timezone_preserving_wall_clock(time_value, timezone)
|
|
605
|
+
# Create the result in target timezone with same wall-clock time
|
|
606
|
+
result = timezone.local(
|
|
607
|
+
time_value.year,
|
|
608
|
+
time_value.month,
|
|
609
|
+
time_value.day,
|
|
610
|
+
time_value.hour,
|
|
611
|
+
time_value.min,
|
|
612
|
+
time_value.sec
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
# Warn if the original offset doesn't match the target timezone
|
|
616
|
+
if time_value.utc_offset != result.utc_offset
|
|
617
|
+
Icalendar::Rrule.logger.warn do
|
|
618
|
+
"[icalendar-rrule] Time offset (#{time_value.utc_offset}s) differs from target timezone " \
|
|
619
|
+
"#{timezone.name} - forcing to #{result.utc_offset}s"
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
result
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
##
|
|
627
|
+
# Embeds a Ruby Time object into a timezone, preserving the UTC moment.
|
|
628
|
+
#
|
|
629
|
+
# Converts the Time to the target timezone while keeping the same instant in time.
|
|
630
|
+
# The wall-clock time will change according to the timezone offset difference.
|
|
631
|
+
#
|
|
632
|
+
# Use this when the Time represents a specific moment in time (e.g., from a database
|
|
633
|
+
# timestamp or API response) that should be displayed in a different timezone.
|
|
634
|
+
#
|
|
635
|
+
# @example
|
|
636
|
+
# utc_time = Time.utc(2026, 1, 15, 12, 0, 0) # 12:00 UTC
|
|
637
|
+
# berlin = ActiveSupport::TimeZone['Europe/Berlin']
|
|
638
|
+
# result = _embed_in_timezone_preserving_moment(utc_time, berlin)
|
|
639
|
+
# # => 2026-01-15 13:00:00 +0100 (same moment, different wall-clock)
|
|
640
|
+
#
|
|
641
|
+
# @param [Time] time_value a Ruby Time object
|
|
642
|
+
# @param [ActiveSupport::TimeZone] timezone the target timezone
|
|
643
|
+
# @return [ActiveSupport::TimeWithZone] the time in the target timezone
|
|
644
|
+
# @api private
|
|
645
|
+
# @see _embed_time_preserving_wall_clock for the inverse operation
|
|
646
|
+
def _embed_in_timezone_preserving_moment(time_value, timezone)
|
|
647
|
+
if time_value.respond_to?(:to_time)
|
|
648
|
+
timezone.at(time_value.to_time.getutc)
|
|
649
|
+
elsif time_value.is_a?(Integer)
|
|
650
|
+
timezone.at(time_value)
|
|
651
|
+
else
|
|
652
|
+
throw ArgumentError, "Unexpected time value: #{time_value}"
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
##
|
|
657
|
+
# Ensures the given `tz` is either an ActiveSupport::TimeZone object or the name of an existing timezone.
|
|
658
|
+
#
|
|
659
|
+
# If the given timezone name is invalid, logs a warning and returns UTC.
|
|
660
|
+
#
|
|
661
|
+
# @param [ActiveSupport::TimeZone,String] tz a timezone object or timezone name string
|
|
662
|
+
# @return [ActiveSupport::TimeZone] the given timezone object, the timezone with the given name,
|
|
663
|
+
# or UTC if the given timezone-name is invalid
|
|
664
|
+
# @api private
|
|
665
|
+
def _ensure_active_timezone(tz)
|
|
666
|
+
# If already a TimeZone object, return it
|
|
667
|
+
return tz if tz.is_a?(ActiveSupport::TimeZone)
|
|
668
|
+
|
|
669
|
+
# Try to lookup by name/value
|
|
670
|
+
result = ActiveSupport::TimeZone[tz]
|
|
671
|
+
return result if result
|
|
672
|
+
|
|
673
|
+
# Invalid timezone - log warning and return UTC
|
|
674
|
+
Icalendar::Rrule.logger.warn do
|
|
675
|
+
"[icalendar-rrule] Invalid timezone '#{tz.inspect}' - falling back to UTC"
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# Fallback to UTC
|
|
679
|
+
# Use offset 0 as fallback if even 'UTC' lookup fails (should never happen)
|
|
680
|
+
ActiveSupport::TimeZone['UTC'] || ActiveSupport::TimeZone[0]
|
|
681
|
+
end
|
|
682
|
+
|
|
450
683
|
##
|
|
451
684
|
# Heuristic to determine the best timezone that shall be used in this component.
|
|
452
685
|
# @return [ActiveSupport::TimeZone] the unique timezone used in this component
|
|
686
|
+
# @deprecated there is no unique timezone for a component. Use `timezone_for_start` or `timezone_for_end` instead.
|
|
453
687
|
def component_timezone
|
|
454
688
|
# let's try sequentially, the first non-nil wins.
|
|
455
689
|
timezone ||= _extract_explicit_timezone(_dtend)
|
|
@@ -462,10 +696,26 @@ module Icalendar
|
|
|
462
696
|
timezone || ActiveSupport::TimeZone['UTC']
|
|
463
697
|
end
|
|
464
698
|
|
|
699
|
+
##
|
|
700
|
+
# Determine the timezone that shall be used for `start_time` this component
|
|
701
|
+
# @return [ActiveSupport::TimeZone] the unique timezone used for the start_time of this component
|
|
702
|
+
def _timezone_for_start
|
|
703
|
+
# todo: determine timezone purely from input parameters (i.e from _dtstart, _dtend, _due)
|
|
704
|
+
start_time.time_zone
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
##
|
|
708
|
+
# Determine the timezone that shall be used for `end_time` this component
|
|
709
|
+
# @return [ActiveSupport::TimeZone] the unique timezone used for the end_time of this component
|
|
710
|
+
def _timezone_for_end
|
|
711
|
+
# todo: determine timezone purely from input parameters (i.e from _dtstart, _dtend, _due)
|
|
712
|
+
end_time.time_zone
|
|
713
|
+
end
|
|
714
|
+
|
|
465
715
|
##
|
|
466
716
|
# Try to determine this components time zone by inspecting the parents calendar.
|
|
467
717
|
# @return[ActiveSupport::TimeZone, nil] the first valid timezone found in the
|
|
468
|
-
# parent
|
|
718
|
+
# parent calendar or nil if none could be found.
|
|
469
719
|
#
|
|
470
720
|
# rubocop:disable Metrics/CyclomaticComplexity
|
|
471
721
|
def _extract_calendar_timezone
|
|
@@ -483,6 +733,7 @@ module Icalendar
|
|
|
483
733
|
rescue StandardError
|
|
484
734
|
nil
|
|
485
735
|
end
|
|
736
|
+
|
|
486
737
|
# rubocop:enable Metrics/CyclomaticComplexity
|
|
487
738
|
|
|
488
739
|
##
|
|
@@ -501,9 +752,9 @@ module Icalendar
|
|
|
501
752
|
# @return [ActiveSupport::TimeZone, nil] the explicitly set timezone, or nil if none found.
|
|
502
753
|
# @api private
|
|
503
754
|
def _extract_explicit_timezone(date_time)
|
|
504
|
-
timezone ||= _extract_ical_time_zone(date_time)
|
|
505
|
-
timezone ||= _extract_act_sup_timezone(date_time)
|
|
506
|
-
timezone || _extract_value_time_zone(date_time)
|
|
755
|
+
timezone ||= _extract_ical_time_zone(date_time) # try with ical TZID parameter (most specific)
|
|
756
|
+
timezone ||= _extract_act_sup_timezone(date_time) # is the given value already ActiveSupport::TimeWithZone?
|
|
757
|
+
timezone || _extract_value_time_zone(date_time) # is the ical.value of type ActiveSupport::TimeWithZone?
|
|
507
758
|
end
|
|
508
759
|
|
|
509
760
|
##
|
|
@@ -545,6 +796,7 @@ module Icalendar
|
|
|
545
796
|
# Should respond to :utc_offset (e.g., Time, DateTime, Icalendar::Values::DateTime).
|
|
546
797
|
# @return [ActiveSupport::TimeZone, nil] an ActiveSupport::TimeZone matching the UTC offset,
|
|
547
798
|
# or nil if no match is found or an error occurs.
|
|
799
|
+
# @deprecated makes little sense and can be avoided.
|
|
548
800
|
# @api private
|
|
549
801
|
def _guess_timezone_from_offset(date_time)
|
|
550
802
|
# Extract value from Icalendar::Values::DateTime if needed
|
|
@@ -602,6 +854,8 @@ module Icalendar
|
|
|
602
854
|
# Attempts to determine the system's timezone.
|
|
603
855
|
# Tries multiple methods in order of reliability.
|
|
604
856
|
#
|
|
857
|
+
# @note see also https://rubygems.org/gems/timezone_local - it does about the same as this.
|
|
858
|
+
#
|
|
605
859
|
# @return [ActiveSupport::TimeZone, nil] the system timezone or nil if it cannot be determined.
|
|
606
860
|
# @api private
|
|
607
861
|
def _guess_system_timezone
|
|
@@ -646,7 +900,6 @@ module Icalendar
|
|
|
646
900
|
nil
|
|
647
901
|
end
|
|
648
902
|
|
|
649
|
-
|
|
650
903
|
end
|
|
651
904
|
end
|
|
652
905
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: icalendar-rrule
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Harald Postner
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '
|
|
18
|
+
version: '8.0'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '
|
|
25
|
+
version: '8.0'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: icalendar
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -43,14 +43,14 @@ dependencies:
|
|
|
43
43
|
requirements:
|
|
44
44
|
- - ">="
|
|
45
45
|
- !ruby/object:Gem::Version
|
|
46
|
-
version: '0.
|
|
46
|
+
version: '0.17'
|
|
47
47
|
type: :runtime
|
|
48
48
|
prerelease: false
|
|
49
49
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
50
|
requirements:
|
|
51
51
|
- - ">="
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
|
-
version: '0.
|
|
53
|
+
version: '0.17'
|
|
54
54
|
- !ruby/object:Gem::Dependency
|
|
55
55
|
name: bundler
|
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -143,6 +143,7 @@ executables: []
|
|
|
143
143
|
extensions: []
|
|
144
144
|
extra_rdoc_files: []
|
|
145
145
|
files:
|
|
146
|
+
- ".github/workflows/ruby.yml"
|
|
146
147
|
- ".gitignore"
|
|
147
148
|
- ".rspec"
|
|
148
149
|
- ".rubocop.yml"
|
|
@@ -157,6 +158,7 @@ files:
|
|
|
157
158
|
- lib/icalendar-rrule.rb
|
|
158
159
|
- lib/icalendar/rrule.rb
|
|
159
160
|
- lib/icalendar/rrule/occurrence.rb
|
|
161
|
+
- lib/icalendar/rrule/time_refinements.rb
|
|
160
162
|
- lib/icalendar/rrule/version.rb
|
|
161
163
|
- lib/icalendar/scannable-calendar.rb
|
|
162
164
|
- lib/icalendar/schedulable-component.rb
|
|
@@ -174,7 +176,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
174
176
|
requirements:
|
|
175
177
|
- - ">="
|
|
176
178
|
- !ruby/object:Gem::Version
|
|
177
|
-
version: '2
|
|
179
|
+
version: '3.2'
|
|
178
180
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
179
181
|
requirements:
|
|
180
182
|
- - ">="
|