icalendar-rrule 0.1.7 → 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 +41 -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 +31 -4
- data/lib/icalendar/schedulable-component.rb +504 -36
- metadata +11 -11
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
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
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
|
+
|
|
13
|
+
## [0.2.0] - 2025-12-24
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Support for tasks (VTODOs) with RRULE expansion
|
|
17
|
+
- New `single_timestamp?` predicate for zero-duration events/deadline-only tasks
|
|
18
|
+
- Comprehensive timezone extraction with multiple fallback strategies
|
|
19
|
+
- Better system timezone detection (ENV['TZ'], /etc/timezone, TZInfo)
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- **BREAKING**: Events without explicit timezone now use system timezone instead of UTC
|
|
23
|
+
- All-day events without DTEND now correctly compute 1-day duration in date-space
|
|
24
|
+
- Relaxed `ice_cube` dependency to >= 0.16 (tested with 0.17.0)
|
|
25
|
+
- `all_day?` now returns false for tasks (only applies to events)
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- Timezone handling for recurring events (RRULE expansion now preserves timezone)
|
|
29
|
+
- Floating time interpretation (DateTime with offset 0 no longer treated as UTC)
|
|
30
|
+
- All-day events no longer experience timezone shift errors
|
|
31
|
+
- Compatibility with icalendar gem 2.12.1 (DowncasedHash handling)
|
|
32
|
+
- Task time handling (zero-duration tasks with only DUE)
|
|
33
|
+
|
|
34
|
+
### Internal
|
|
35
|
+
- Enhanced `_extract_explicit_timezone` for better timezone detection
|
|
36
|
+
- Added `_dtstart_is_all_day?` helper
|
|
37
|
+
- Improved `_guess_system_timezone` with 5 fallback methods
|
|
38
|
+
- Better test coverage (145+ tests, including exotic timezones)
|
|
39
|
+
|
|
40
|
+
## [0.1.7] - 2020-xx-xx
|
|
41
|
+
- Previous stable release
|
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,11 +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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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)
|
|
69
|
+
|
|
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)
|
|
77
|
+
|
|
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)
|
|
82
|
+
|
|
83
|
+
new_oc = Icalendar::Rrule::Occurrence.new(self, base_component, start_tz, end_tz)
|
|
57
84
|
result << new_oc
|
|
58
85
|
end
|
|
59
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
|
##
|
|
@@ -93,8 +97,15 @@ module Icalendar
|
|
|
93
97
|
end
|
|
94
98
|
|
|
95
99
|
##
|
|
96
|
-
#
|
|
97
|
-
#
|
|
100
|
+
# Returns the explicit duration from DURATION property or guessed duration.
|
|
101
|
+
#
|
|
102
|
+
# WARNING: This does NOT compute the actual duration between start_time and end_time!
|
|
103
|
+
# For events with DTEND but no DURATION property, this returns 0 or the guessed duration,
|
|
104
|
+
# even though the actual duration may be longer.
|
|
105
|
+
#
|
|
106
|
+
# To get the actual duration, use: (end_time.to_i - start_time.to_i)
|
|
107
|
+
#
|
|
108
|
+
# @return [Integer] explicit duration in seconds, or 0 if not specified
|
|
98
109
|
# @api private
|
|
99
110
|
def _duration_seconds # rubocop:disable Metrics/AbcSize
|
|
100
111
|
return _guessed_duration unless _duration
|
|
@@ -103,6 +114,25 @@ module Icalendar
|
|
|
103
114
|
d.seconds + (d.minutes * SEC_MIN) + (d.hours * SEC_HOUR) + (d.days * SEC_DAY) + (d.weeks * SEC_WEEK)
|
|
104
115
|
end
|
|
105
116
|
|
|
117
|
+
# Check if dtstart looks like an all-day event (starts at midnight)
|
|
118
|
+
# This is used internally to determine if we should apply the 1-day duration rule
|
|
119
|
+
# @return [Boolean] true if dtstart is a Date or starts at midnight
|
|
120
|
+
# @api private
|
|
121
|
+
def _dtstart_is_all_day?
|
|
122
|
+
# Only apply the 1-day rule to Events, not Tasks
|
|
123
|
+
return false unless self.is_a?(Icalendar::Event)
|
|
124
|
+
|
|
125
|
+
return true if _dtstart.is_a?(Icalendar::Values::Date)
|
|
126
|
+
|
|
127
|
+
# If it's a DateTime, check if it's at midnight (00:00:00)
|
|
128
|
+
if _dtstart.respond_to?(:to_time) || _dtstart.respond_to?(:to_datetime)
|
|
129
|
+
time = _to_time_with_zone(_dtstart)
|
|
130
|
+
return time == time.beginning_of_day
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
false
|
|
134
|
+
end
|
|
135
|
+
|
|
106
136
|
##
|
|
107
137
|
# Make an educated guess how long this event might last according to the following definition from RFC 5545:
|
|
108
138
|
#
|
|
@@ -114,7 +144,7 @@ module Icalendar
|
|
|
114
144
|
# @return [Integer] the number of seconds this task might last.
|
|
115
145
|
# @api private
|
|
116
146
|
def _guessed_duration
|
|
117
|
-
if
|
|
147
|
+
if _dtstart_is_all_day? && _dtend.nil? && _duration.nil? && _due.nil?
|
|
118
148
|
SEC_DAY
|
|
119
149
|
else
|
|
120
150
|
0
|
|
@@ -127,8 +157,11 @@ module Icalendar
|
|
|
127
157
|
def start_time
|
|
128
158
|
if _dtstart
|
|
129
159
|
_to_time_with_zone(_dtstart)
|
|
130
|
-
elsif _due
|
|
160
|
+
elsif _due && _duration_seconds > 0
|
|
131
161
|
_to_time_with_zone(_due.to_i - _duration_seconds)
|
|
162
|
+
elsif _due
|
|
163
|
+
# Task with only DUE, no duration: start == end (zero-duration/deadline-only)
|
|
164
|
+
_to_time_with_zone(_due)
|
|
132
165
|
else
|
|
133
166
|
_to_time_with_zone(NULL_TIME)
|
|
134
167
|
end
|
|
@@ -143,17 +176,51 @@ module Icalendar
|
|
|
143
176
|
elsif _dtend
|
|
144
177
|
_to_time_with_zone(_dtend)
|
|
145
178
|
elsif _dtstart
|
|
146
|
-
|
|
179
|
+
# Special handling for all-day events without explicit end
|
|
180
|
+
if _dtstart_is_all_day? && _dtend.nil?
|
|
181
|
+
# Stay in date space: add days to the date, not seconds to timestamp
|
|
182
|
+
start_date = _dtstart_all_day_event_as_date
|
|
183
|
+
end_date = start_date + (_duration_seconds / SEC_DAY).days
|
|
184
|
+
_date_to_time_with_zone(end_date, component_timezone)
|
|
185
|
+
else
|
|
186
|
+
_to_time_with_zone(start_time.to_i + _duration_seconds)
|
|
187
|
+
end
|
|
147
188
|
else
|
|
148
189
|
_to_time_with_zone(NULL_TIME + _duration_seconds)
|
|
149
190
|
end
|
|
150
191
|
end
|
|
151
192
|
|
|
193
|
+
# Extract the date component from dtstart, assuming it's an all-day event
|
|
194
|
+
# @return [Date]
|
|
195
|
+
# @api private
|
|
196
|
+
def _dtstart_all_day_event_as_date
|
|
197
|
+
raise ArgumentError, "dtstart is not an all-day event" unless _dtstart_is_all_day?
|
|
198
|
+
|
|
199
|
+
if _dtstart.is_a?(Icalendar::Values::Date)
|
|
200
|
+
_dtstart.to_date
|
|
201
|
+
elsif _dtstart.respond_to?(:to_date)
|
|
202
|
+
_dtstart.to_date
|
|
203
|
+
else
|
|
204
|
+
# Fallback: convert via TimeWithZone
|
|
205
|
+
time_with_zone = _to_time_with_zone(_dtstart)
|
|
206
|
+
raise ArgumentError, "Cannot convert dtstart to date" unless time_with_zone.respond_to?(:to_date)
|
|
207
|
+
time_with_zone.to_date
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
152
211
|
##
|
|
153
212
|
# Heuristic to determine whether the event is scheduled
|
|
154
|
-
# for a date without
|
|
155
|
-
#
|
|
213
|
+
# for a date without specifying the exact time of day.
|
|
214
|
+
#
|
|
215
|
+
# Note: This method always returns false for tasks (VTODOs),
|
|
216
|
+
# as the all-day concept only applies to events (VEVENTs).
|
|
217
|
+
#
|
|
218
|
+
# @return [Boolean] true if the component is an Event scheduled for an entire day,
|
|
219
|
+
# false for tasks or timed events
|
|
156
220
|
def all_day?
|
|
221
|
+
# todo: determine timezone purely from input parameters (i.e from _dtstart, _dtend, _due)
|
|
222
|
+
return false unless self.is_a?(Icalendar::Event)
|
|
223
|
+
|
|
157
224
|
_dtstart.is_a?(Icalendar::Values::Date) ||
|
|
158
225
|
(start_time == start_time.beginning_of_day && end_time == end_time.beginning_of_day)
|
|
159
226
|
end
|
|
@@ -165,7 +232,21 @@ module Icalendar
|
|
|
165
232
|
end
|
|
166
233
|
|
|
167
234
|
##
|
|
168
|
-
#
|
|
235
|
+
# Indicates whether this component represents a single point in time
|
|
236
|
+
# rather than a time range. Common for:
|
|
237
|
+
# - Open-ended events (e.g., concert start time without known end)
|
|
238
|
+
# - Tasks with only a deadline (no start time specified)
|
|
239
|
+
#
|
|
240
|
+
# @return [Boolean] true if the component has no duration
|
|
241
|
+
def single_timestamp?
|
|
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 ????
|
|
244
|
+
# Compare at second precision (ignore potential microsecond differences)
|
|
245
|
+
start_time.to_i == end_time.to_i
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
##
|
|
249
|
+
# Make sure that we can always query for a _rrule_ array.
|
|
169
250
|
# @return [array] an array of _ical repeat-rules_ (or an empty array
|
|
170
251
|
# if no repeat-rules are defined for this component).
|
|
171
252
|
# @api private
|
|
@@ -176,9 +257,10 @@ module Icalendar
|
|
|
176
257
|
end
|
|
177
258
|
|
|
178
259
|
##
|
|
179
|
-
# Make sure
|
|
180
|
-
# @return [
|
|
181
|
-
#
|
|
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.
|
|
182
264
|
# @api private
|
|
183
265
|
def _exdates
|
|
184
266
|
Array(exdate).flatten
|
|
@@ -236,7 +318,7 @@ module Icalendar
|
|
|
236
318
|
##
|
|
237
319
|
# Like the for _exdates, also for these dates do not schedule recurrence items.
|
|
238
320
|
#
|
|
239
|
-
# @return [
|
|
321
|
+
# @return [Array<Object>] an array of dates.
|
|
240
322
|
# @api private
|
|
241
323
|
def _overwritten_dates
|
|
242
324
|
result = []
|
|
@@ -250,7 +332,7 @@ module Icalendar
|
|
|
250
332
|
end
|
|
251
333
|
|
|
252
334
|
##
|
|
253
|
-
# Make sure
|
|
335
|
+
# Make sure that we can always query for a rdate(Recurrence Date) array.
|
|
254
336
|
# @return [array] an array of _ical rdates_ (or an empty array
|
|
255
337
|
# if no repeat-rules are defined for this component).
|
|
256
338
|
# @api private
|
|
@@ -264,29 +346,110 @@ module Icalendar
|
|
|
264
346
|
# Creates a schedule for this event
|
|
265
347
|
# @return [IceCube::Schedule]
|
|
266
348
|
def schedule # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
349
|
+
# Calculate the duration of this base event in seconds
|
|
350
|
+
duration_seconds = (end_time.to_i - start_time.to_i) # Integer seconds
|
|
351
|
+
|
|
352
|
+
# Create a schedule with start_time and duration
|
|
353
|
+
# Convert to floating Time for IceCube compatibility
|
|
354
|
+
schedule = IceCube::Schedule.new(_to_floating_time(start_time, _timezone_for_start), duration: duration_seconds)
|
|
355
|
+
|
|
270
356
|
_rrules.each do |rrule|
|
|
271
|
-
|
|
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)
|
|
272
360
|
schedule.add_recurrence_rule(ice_cube_recurrence_rule)
|
|
273
361
|
end
|
|
274
362
|
|
|
275
|
-
_exdates.each do |
|
|
276
|
-
schedule.add_exception_time(
|
|
363
|
+
_exdates.each do |ex_time|
|
|
364
|
+
schedule.add_exception_time(_to_floating_time(ex_time, _timezone_for_start))
|
|
277
365
|
end
|
|
278
366
|
|
|
279
|
-
_overwritten_dates.each do |
|
|
280
|
-
schedule.add_exception_time(
|
|
367
|
+
_overwritten_dates.each do |overwritten_time|
|
|
368
|
+
schedule.add_exception_time(_to_floating_time(overwritten_time, _timezone_for_start))
|
|
281
369
|
end
|
|
282
370
|
|
|
283
371
|
rdates = _rdates
|
|
284
|
-
rdates.each do |
|
|
285
|
-
schedule.add_recurrence_time(
|
|
372
|
+
rdates.each do |recurrence_time|
|
|
373
|
+
schedule.add_recurrence_time(_to_floating_time(recurrence_time, _timezone_for_start))
|
|
286
374
|
end
|
|
287
375
|
schedule
|
|
288
376
|
end
|
|
289
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
|
+
|
|
290
453
|
##
|
|
291
454
|
# Transform the given object into an object of type `ActiveSupport::TimeWithZone`.
|
|
292
455
|
#
|
|
@@ -299,6 +462,9 @@ module Icalendar
|
|
|
299
462
|
#
|
|
300
463
|
# rubocop:disable Metrics/MethodLength,Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
|
|
301
464
|
def _to_time_with_zone(date_time, timezone = nil)
|
|
465
|
+
# Try to extract timezone from the date_time parameter first
|
|
466
|
+
timezone ||= _extract_explicit_timezone(date_time)
|
|
467
|
+
# Fall back to component timezone if no timezone could be extracted
|
|
302
468
|
timezone ||= component_timezone
|
|
303
469
|
|
|
304
470
|
# For Icalendar::Values::DateTime, we can extract the ical value. Which probably is already what we want.
|
|
@@ -317,6 +483,19 @@ module Icalendar
|
|
|
317
483
|
return date_time_value.in_time_zone(timezone)
|
|
318
484
|
|
|
319
485
|
elsif date_time_value.is_a?(DateTime)
|
|
486
|
+
# If DateTime has offset 0, treat it as "floating time" in the target timezone
|
|
487
|
+
# rather than converting from UTC
|
|
488
|
+
if date_time_value.offset.zero?
|
|
489
|
+
return timezone.local(
|
|
490
|
+
date_time_value.year,
|
|
491
|
+
date_time_value.month,
|
|
492
|
+
date_time_value.day,
|
|
493
|
+
date_time_value.hour,
|
|
494
|
+
date_time_value.min,
|
|
495
|
+
date_time_value.sec
|
|
496
|
+
)
|
|
497
|
+
end
|
|
498
|
+
# DateTime with explicit non-zero offset: convert to target timezone
|
|
320
499
|
return date_time_value.in_time_zone(timezone)
|
|
321
500
|
|
|
322
501
|
elsif date_time_value.is_a?(Icalendar::Values::Date)
|
|
@@ -325,6 +504,10 @@ module Icalendar
|
|
|
325
504
|
elsif date_time_value.is_a?(Date)
|
|
326
505
|
return _date_to_time_with_zone(date_time_value, timezone)
|
|
327
506
|
|
|
507
|
+
elsif date_time_value.is_a?(Time)
|
|
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)
|
|
510
|
+
|
|
328
511
|
elsif date_time_value.respond_to?(:to_time)
|
|
329
512
|
return timezone.at(date_time_value.to_time)
|
|
330
513
|
|
|
@@ -336,6 +519,7 @@ module Icalendar
|
|
|
336
519
|
# Oops, the given object is unusable, we'll give back the NULL_DATE
|
|
337
520
|
timezone.at(NULL_TIME)
|
|
338
521
|
end
|
|
522
|
+
|
|
339
523
|
# rubocop:enable Metrics/MethodLength,Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
|
|
340
524
|
|
|
341
525
|
##
|
|
@@ -348,24 +532,190 @@ module Icalendar
|
|
|
348
532
|
timezone.local(d.year, d.month, d.day)
|
|
349
533
|
end
|
|
350
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
|
+
|
|
351
683
|
##
|
|
352
684
|
# Heuristic to determine the best timezone that shall be used in this component.
|
|
353
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.
|
|
354
687
|
def component_timezone
|
|
355
688
|
# let's try sequentially, the first non-nil wins.
|
|
356
|
-
timezone ||=
|
|
357
|
-
timezone ||=
|
|
358
|
-
timezone ||=
|
|
689
|
+
timezone ||= _extract_explicit_timezone(_dtend)
|
|
690
|
+
timezone ||= _extract_explicit_timezone(_dtstart)
|
|
691
|
+
timezone ||= _extract_explicit_timezone(_due)
|
|
359
692
|
timezone ||= _extract_calendar_timezone
|
|
693
|
+
timezone ||= _guess_system_timezone
|
|
360
694
|
|
|
361
695
|
# as a last resort we'll use the Coordinated Universal Time (UTC).
|
|
362
696
|
timezone || ActiveSupport::TimeZone['UTC']
|
|
363
697
|
end
|
|
364
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
|
+
|
|
365
715
|
##
|
|
366
716
|
# Try to determine this components time zone by inspecting the parents calendar.
|
|
367
717
|
# @return[ActiveSupport::TimeZone, nil] the first valid timezone found in the
|
|
368
|
-
# parent
|
|
718
|
+
# parent calendar or nil if none could be found.
|
|
369
719
|
#
|
|
370
720
|
# rubocop:disable Metrics/CyclomaticComplexity
|
|
371
721
|
def _extract_calendar_timezone
|
|
@@ -373,7 +723,6 @@ module Icalendar
|
|
|
373
723
|
return nil unless parent.is_a?(Icalendar::Calendar)
|
|
374
724
|
calendar_timezones = parent.timezones
|
|
375
725
|
calendar_timezones.each do |tz|
|
|
376
|
-
break unless tz.valid?(true)
|
|
377
726
|
ugly_tzid = tz.tzid
|
|
378
727
|
break unless ugly_tzid
|
|
379
728
|
tzid = Array(ugly_tzid).first.to_s.gsub(/^(["'])|(["'])$/, '')
|
|
@@ -384,15 +733,26 @@ module Icalendar
|
|
|
384
733
|
rescue StandardError
|
|
385
734
|
nil
|
|
386
735
|
end
|
|
736
|
+
|
|
387
737
|
# rubocop:enable Metrics/CyclomaticComplexity
|
|
388
738
|
|
|
389
739
|
##
|
|
390
|
-
#
|
|
391
|
-
#
|
|
392
|
-
#
|
|
740
|
+
# Extracts an explicitly set timezone from the given object.
|
|
741
|
+
#
|
|
742
|
+
# This method only returns a timezone if it was explicitly specified through:
|
|
743
|
+
# - An iCalendar TZID parameter (e.g., tzid: 'Europe/Berlin')
|
|
744
|
+
# - An existing ActiveSupport::TimeWithZone object
|
|
745
|
+
# - A wrapped value that is already a TimeWithZone
|
|
746
|
+
#
|
|
747
|
+
# Unlike _guess_timezone_from_offset, this method does NOT guess or infer
|
|
748
|
+
# timezones from UTC offsets. It returns nil if no explicit timezone is found.
|
|
749
|
+
#
|
|
750
|
+
# @param date_time [Object] an object from which to extract the timezone.
|
|
751
|
+
# Typically, an Icalendar::Value, Time, DateTime, or ActiveSupport::TimeWithZone.
|
|
752
|
+
# @return [ActiveSupport::TimeZone, nil] the explicitly set timezone, or nil if none found.
|
|
393
753
|
# @api private
|
|
394
|
-
def
|
|
395
|
-
timezone ||= _extract_ical_time_zone(date_time) # try with ical parameter
|
|
754
|
+
def _extract_explicit_timezone(date_time)
|
|
755
|
+
timezone ||= _extract_ical_time_zone(date_time) # try with ical TZID parameter (most specific)
|
|
396
756
|
timezone ||= _extract_act_sup_timezone(date_time) # is the given value already ActiveSupport::TimeWithZone?
|
|
397
757
|
timezone || _extract_value_time_zone(date_time) # is the ical.value of type ActiveSupport::TimeWithZone?
|
|
398
758
|
end
|
|
@@ -419,6 +779,52 @@ module Icalendar
|
|
|
419
779
|
ical_value.value.time_zone
|
|
420
780
|
end
|
|
421
781
|
|
|
782
|
+
##
|
|
783
|
+
# Guesses the corresponding ActiveSupport timezone from a given time object's UTC offset.
|
|
784
|
+
# This method extracts the UTC offset from objects that respond to :utc_offset
|
|
785
|
+
# (such as Time, DateTime, or their wrapped values in Icalendar::Values)
|
|
786
|
+
# and matches it to an equivalent ActiveSupport::TimeZone.
|
|
787
|
+
#
|
|
788
|
+
# Note: Since multiple timezones can share the same UTC offset (e.g., Berlin,
|
|
789
|
+
# Amsterdam, Paris all use +01:00), this method returns an arbitrary timezone
|
|
790
|
+
# with the matching offset - hence "guess" rather than "extract".
|
|
791
|
+
#
|
|
792
|
+
# If the input does not respond to :utc_offset or an error occurs during processing,
|
|
793
|
+
# the method returns nil.
|
|
794
|
+
#
|
|
795
|
+
# @param date_time [Object] the object to extract the UTC offset from.
|
|
796
|
+
# Should respond to :utc_offset (e.g., Time, DateTime, Icalendar::Values::DateTime).
|
|
797
|
+
# @return [ActiveSupport::TimeZone, nil] an ActiveSupport::TimeZone matching the UTC offset,
|
|
798
|
+
# or nil if no match is found or an error occurs.
|
|
799
|
+
# @deprecated makes little sense and can be avoided.
|
|
800
|
+
# @api private
|
|
801
|
+
def _guess_timezone_from_offset(date_time)
|
|
802
|
+
# Extract value from Icalendar::Values::DateTime if needed
|
|
803
|
+
value = date_time.is_a?(Icalendar::Value) && date_time.respond_to?(:value) ? date_time.value : date_time
|
|
804
|
+
|
|
805
|
+
return nil unless value.respond_to?(:utc_offset)
|
|
806
|
+
|
|
807
|
+
# Get the timezone offset from the Time or DateTime object
|
|
808
|
+
offset_seconds = value.utc_offset
|
|
809
|
+
return nil unless offset_seconds.is_a?(Integer)
|
|
810
|
+
|
|
811
|
+
# First try: check if the system's default timezone matches the offset
|
|
812
|
+
system_tz = _guess_system_timezone
|
|
813
|
+
|
|
814
|
+
# Return `system timezone` if it matches the offset
|
|
815
|
+
return system_tz if system_tz && system_tz.now.utc_offset == offset_seconds
|
|
816
|
+
|
|
817
|
+
# Fallback: find any timezone matching the offset
|
|
818
|
+
# For offset 0, always use UTC to avoid ambiguous timezones with DST
|
|
819
|
+
if offset_seconds.zero?
|
|
820
|
+
ActiveSupport::TimeZone['UTC']
|
|
821
|
+
else
|
|
822
|
+
ActiveSupport::TimeZone[offset_seconds]
|
|
823
|
+
end
|
|
824
|
+
rescue StandardError
|
|
825
|
+
nil
|
|
826
|
+
end
|
|
827
|
+
|
|
422
828
|
##
|
|
423
829
|
# Get the timezone from the given object, assuming it can be extracted from ical params.
|
|
424
830
|
# @param [Icalendar::Value] ical_value an ical value that (probably) supports a time zone identifier.
|
|
@@ -427,11 +833,73 @@ module Icalendar
|
|
|
427
833
|
def _extract_ical_time_zone(ical_value)
|
|
428
834
|
return nil unless ical_value.is_a?(Icalendar::Value)
|
|
429
835
|
return nil unless ical_value.respond_to?(:ical_params)
|
|
430
|
-
|
|
836
|
+
|
|
837
|
+
ical_params = ical_value.ical_params
|
|
838
|
+
return nil unless ical_params
|
|
839
|
+
|
|
840
|
+
ugly_tzid = ical_params['tzid'] || ical_params[:tzid] || ical_params['TZID'] || ical_params[:TZID]
|
|
431
841
|
return nil if ugly_tzid.nil?
|
|
842
|
+
|
|
432
843
|
tzid = Array(ugly_tzid).first.to_s.gsub(/^(["'])|(["'])$/, '')
|
|
844
|
+
return nil if tzid.empty?
|
|
845
|
+
|
|
433
846
|
ActiveSupport::TimeZone[tzid]
|
|
847
|
+
rescue StandardError
|
|
848
|
+
# Uncomment for debugging icalendar gem compatibility issues:
|
|
849
|
+
# warn "[icalendar-rrule] Failed to extract timezone: #{e.message}"
|
|
850
|
+
nil
|
|
434
851
|
end
|
|
852
|
+
|
|
853
|
+
##
|
|
854
|
+
# Attempts to determine the system's timezone.
|
|
855
|
+
# Tries multiple methods in order of reliability.
|
|
856
|
+
#
|
|
857
|
+
# @note see also https://rubygems.org/gems/timezone_local - it does about the same as this.
|
|
858
|
+
#
|
|
859
|
+
# @return [ActiveSupport::TimeZone, nil] the system timezone or nil if it cannot be determined.
|
|
860
|
+
# @api private
|
|
861
|
+
def _guess_system_timezone
|
|
862
|
+
# Method 1: Rails/ActiveSupport Time.zone (most reliable if set)
|
|
863
|
+
return Time.zone if Time.zone.is_a?(ActiveSupport::TimeZone)
|
|
864
|
+
|
|
865
|
+
# Method 2: ENV['TZ'] environment variable
|
|
866
|
+
if ENV['TZ']
|
|
867
|
+
tz = ActiveSupport::TimeZone[ENV['TZ']]
|
|
868
|
+
return tz if tz
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
# Method 3: Try TZInfo if available (optional dependency)
|
|
872
|
+
begin
|
|
873
|
+
require 'tzinfo'
|
|
874
|
+
tz_identifier = TZInfo::Timezone.default_timezone.identifier
|
|
875
|
+
tz = ActiveSupport::TimeZone[tz_identifier]
|
|
876
|
+
return tz if tz
|
|
877
|
+
rescue LoadError, StandardError
|
|
878
|
+
# TZInfo not available or failed, continue
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
# Method 4: Read /etc/timezone on Linux (Debian/Ubuntu style)
|
|
882
|
+
if File.readable?('/etc/timezone')
|
|
883
|
+
tz_name = File.read('/etc/timezone').strip
|
|
884
|
+
tz = ActiveSupport::TimeZone[tz_name]
|
|
885
|
+
return tz if tz
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
# Method 5: Parse /etc/localtime symlink (common on many Unix systems)
|
|
889
|
+
if File.symlink?('/etc/localtime')
|
|
890
|
+
link = File.readlink('/etc/localtime')
|
|
891
|
+
# Extract timezone name from path like /usr/share/zoneinfo/Europe/Berlin
|
|
892
|
+
if link =~ %r{zoneinfo/(.+)$}
|
|
893
|
+
tz = ActiveSupport::TimeZone[$1]
|
|
894
|
+
return tz if tz
|
|
895
|
+
end
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
nil
|
|
899
|
+
rescue StandardError
|
|
900
|
+
nil
|
|
901
|
+
end
|
|
902
|
+
|
|
435
903
|
end
|
|
436
904
|
end
|
|
437
905
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
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
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: activesupport
|
|
@@ -16,14 +15,14 @@ dependencies:
|
|
|
16
15
|
requirements:
|
|
17
16
|
- - ">="
|
|
18
17
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '
|
|
18
|
+
version: '8.0'
|
|
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
|
-
version: '
|
|
25
|
+
version: '8.0'
|
|
27
26
|
- !ruby/object:Gem::Dependency
|
|
28
27
|
name: icalendar
|
|
29
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -44,14 +43,14 @@ dependencies:
|
|
|
44
43
|
requirements:
|
|
45
44
|
- - ">="
|
|
46
45
|
- !ruby/object:Gem::Version
|
|
47
|
-
version: '0.
|
|
46
|
+
version: '0.17'
|
|
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
|
-
version: '0.
|
|
53
|
+
version: '0.17'
|
|
55
54
|
- !ruby/object:Gem::Dependency
|
|
56
55
|
name: bundler
|
|
57
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -144,11 +143,13 @@ executables: []
|
|
|
144
143
|
extensions: []
|
|
145
144
|
extra_rdoc_files: []
|
|
146
145
|
files:
|
|
146
|
+
- ".github/workflows/ruby.yml"
|
|
147
147
|
- ".gitignore"
|
|
148
148
|
- ".rspec"
|
|
149
149
|
- ".rubocop.yml"
|
|
150
150
|
- ".travis.yml"
|
|
151
151
|
- ".yardopts"
|
|
152
|
+
- CHANGELOG.md
|
|
152
153
|
- Gemfile
|
|
153
154
|
- LICENSE.txt
|
|
154
155
|
- README.md
|
|
@@ -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
|
|
@@ -167,7 +169,6 @@ metadata:
|
|
|
167
169
|
homepage_uri: https://github.com/free-creations/icalendar-rrule
|
|
168
170
|
source_code_uri: https://github.com/free-creations/icalendar-rrule
|
|
169
171
|
bug_tracker_uri: https://github.com/free-creations/icalendar-rrule/issues
|
|
170
|
-
post_install_message:
|
|
171
172
|
rdoc_options: []
|
|
172
173
|
require_paths:
|
|
173
174
|
- lib
|
|
@@ -175,15 +176,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
175
176
|
requirements:
|
|
176
177
|
- - ">="
|
|
177
178
|
- !ruby/object:Gem::Version
|
|
178
|
-
version: '2
|
|
179
|
+
version: '3.2'
|
|
179
180
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
180
181
|
requirements:
|
|
181
182
|
- - ">="
|
|
182
183
|
- !ruby/object:Gem::Version
|
|
183
184
|
version: '0'
|
|
184
185
|
requirements: []
|
|
185
|
-
rubygems_version:
|
|
186
|
-
signing_key:
|
|
186
|
+
rubygems_version: 4.0.1
|
|
187
187
|
specification_version: 4
|
|
188
188
|
summary: Helper for ICalendars with recurring events.
|
|
189
189
|
test_files: []
|