cal-invite 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AGENTS.md +13 -8
- data/CACHING.md +190 -98
- data/CHANGELOG.md +61 -2
- data/CONFIGURATION.md +25 -29
- data/README.md +132 -190
- data/Rakefile +2 -1
- data/SECURITY.md +39 -10
- data/lib/cal_invite/configuration.rb +20 -11
- data/lib/cal_invite/event.rb +87 -114
- data/lib/cal_invite/ical_timezone.rb +64 -18
- data/lib/cal_invite/providers/base_provider.rb +302 -264
- data/lib/cal_invite/providers/ical.rb +7 -152
- data/lib/cal_invite/providers/ics.rb +68 -13
- data/lib/cal_invite/providers/ics_content.rb +7 -129
- data/lib/cal_invite/providers.rb +8 -0
- data/lib/cal_invite/version.rb +1 -1
- data/lib/cal_invite.rb +1 -8
- metadata +4 -9
- data/.DS_Store +0 -0
- data/CLAUDE.md +0 -49
- data/lib/.DS_Store +0 -0
- data/lib/cal_invite/.DS_Store +0 -0
- data/lib/cal_invite/caching.rb +0 -74
- data/lib/tasks/rdoc.rake +0 -13
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
# lib/cal_invite/configuration.rb
|
|
4
4
|
# Configuration class for the CalInvite gem.
|
|
5
|
-
# Handles all configurable options
|
|
5
|
+
# Handles all configurable options: timezone and webhook secret.
|
|
6
6
|
#
|
|
7
|
-
# @attr_reader [ActiveSupport::Cache::Store, nil] cache_store
|
|
8
|
-
# @attr_reader [String] cache_prefix
|
|
9
|
-
# @attr_reader [Integer] cache_expires_in
|
|
7
|
+
# @attr_reader [ActiveSupport::Cache::Store, nil] cache_store Deprecated, unused; removed in 0.4.0
|
|
8
|
+
# @attr_reader [String] cache_prefix Deprecated, unused; removed in 0.4.0
|
|
9
|
+
# @attr_reader [Integer] cache_expires_in Deprecated, unused; removed in 0.4.0
|
|
10
10
|
# @attr_reader [String, nil] webhook_secret The secret key for webhook verification
|
|
11
11
|
# @attr_reader [String] timezone The default timezone for events
|
|
12
12
|
module CalInvite
|
|
@@ -17,19 +17,18 @@ module CalInvite
|
|
|
17
17
|
def initialize
|
|
18
18
|
@cache_store = nil
|
|
19
19
|
@cache_prefix = 'cal_invite'
|
|
20
|
-
@cache_expires_in = 24
|
|
20
|
+
@cache_expires_in = 24 * 60 * 60
|
|
21
21
|
@webhook_secret = nil
|
|
22
22
|
@timezone = 'UTC'
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
#
|
|
25
|
+
# @deprecated CalInvite caches nothing, so this setting has no effect.
|
|
26
|
+
# It warns, and will be removed in 0.4.0.
|
|
26
27
|
#
|
|
27
28
|
# @param store [Symbol, #read, #write, #delete] The cache store to use
|
|
28
29
|
# @raise [ArgumentError] If an unsupported cache store is provided
|
|
29
|
-
#
|
|
30
|
-
# @example Set memory store
|
|
31
|
-
# config.cache_store = :memory_store
|
|
32
30
|
def cache_store=(store)
|
|
31
|
+
warn_deprecated_cache_setting(:cache_store)
|
|
33
32
|
@cache_store = case store
|
|
34
33
|
when :memory_store
|
|
35
34
|
ActiveSupport::Cache::MemoryStore.new
|
|
@@ -46,17 +45,19 @@ module CalInvite
|
|
|
46
45
|
end
|
|
47
46
|
end
|
|
48
47
|
|
|
49
|
-
#
|
|
48
|
+
# @deprecated Has no effect; warns, and will be removed in 0.4.0.
|
|
50
49
|
#
|
|
51
50
|
# @param prefix [#to_s] The prefix to use for cache keys
|
|
52
51
|
def cache_prefix=(prefix)
|
|
52
|
+
warn_deprecated_cache_setting(:cache_prefix)
|
|
53
53
|
@cache_prefix = prefix.to_s
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
#
|
|
56
|
+
# @deprecated Has no effect; warns, and will be removed in 0.4.0.
|
|
57
57
|
#
|
|
58
58
|
# @param duration [#to_i] The duration in seconds
|
|
59
59
|
def cache_expires_in=(duration)
|
|
60
|
+
warn_deprecated_cache_setting(:cache_expires_in)
|
|
60
61
|
@cache_expires_in = duration.to_i
|
|
61
62
|
end
|
|
62
63
|
|
|
@@ -73,5 +74,13 @@ module CalInvite
|
|
|
73
74
|
def timezone=(tz)
|
|
74
75
|
@timezone = tz.to_s
|
|
75
76
|
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def warn_deprecated_cache_setting(name)
|
|
81
|
+
warn "[cal_invite] Configuration##{name}= is deprecated and has no effect: " \
|
|
82
|
+
"CalInvite no longer caches anything. Remove it; it will be removed in 0.4.0.",
|
|
83
|
+
uplevel: 2
|
|
84
|
+
end
|
|
76
85
|
end
|
|
77
86
|
end
|
data/lib/cal_invite/event.rb
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'digest'
|
|
4
|
-
|
|
5
3
|
# lib/cal_invite/event.rb
|
|
6
4
|
# Represents a calendar event with all its attributes and generation capabilities.
|
|
7
5
|
#
|
|
8
6
|
# @attr_accessor [String] title The title of the event
|
|
9
|
-
# @attr_accessor [Time] start_time The start time of the event
|
|
10
|
-
# @attr_accessor [Time] end_time The end time of the event
|
|
7
|
+
# @attr_accessor [Time] start_time The start time of the event (defaults to the earliest multi_day_sessions start)
|
|
8
|
+
# @attr_accessor [Time] end_time The end time of the event (defaults to the latest multi_day_sessions end)
|
|
11
9
|
# @attr_accessor [String] description The description of the event
|
|
12
10
|
# @attr_accessor [String] location The location of the event
|
|
13
11
|
# @attr_accessor [String] url The URL associated with the event
|
|
@@ -19,7 +17,7 @@ require 'digest'
|
|
|
19
17
|
# @attr_accessor [String] notes Additional notes for the event
|
|
20
18
|
# @attr_accessor [Array<Hash>] multi_day_sessions Sessions for multi-day events
|
|
21
19
|
# @attr_accessor [Boolean] all_day Whether this is an all-day event
|
|
22
|
-
# @attr_accessor [Hash] organizer The event organizer, e.g. { name: "Jane Doe", email: "jane@example.com" }
|
|
20
|
+
# @attr_accessor [String, Hash] organizer The event organizer: an email string, or e.g. { name: "Jane Doe", email: "jane@example.com" }
|
|
23
21
|
# @attr_accessor [String] uid Stable RFC 5545 UID identifying this event across its lifecycle
|
|
24
22
|
# @attr_accessor [Integer] sequence RFC 5545 SEQUENCE number; bump on every REQUEST/CANCEL update
|
|
25
23
|
# @attr_accessor [Array<Float>, Hash] geo Location coordinates, e.g. [37.4595, -122.1418] or { lat:, lng: }
|
|
@@ -35,9 +33,8 @@ require 'digest'
|
|
|
35
33
|
# this; other clients don't expose an equivalent control
|
|
36
34
|
module CalInvite
|
|
37
35
|
class Event
|
|
36
|
+
attr_writer :start_time, :end_time
|
|
38
37
|
attr_accessor :title,
|
|
39
|
-
:start_time,
|
|
40
|
-
:end_time,
|
|
41
38
|
:description,
|
|
42
39
|
:location,
|
|
43
40
|
:url,
|
|
@@ -59,22 +56,42 @@ module CalInvite
|
|
|
59
56
|
:importance,
|
|
60
57
|
:allow_counter
|
|
61
58
|
|
|
59
|
+
# The event start. Without an explicit value, a multi-day event spans
|
|
60
|
+
# from its earliest session start, so single-event URL providers (Google,
|
|
61
|
+
# Outlook, Office 365) show the overall range.
|
|
62
|
+
#
|
|
63
|
+
# @return [Time, nil]
|
|
64
|
+
def start_time
|
|
65
|
+
@start_time || multi_day_sessions&.map { |session| session[:start_time] }&.compact&.min
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The event end. Without an explicit value, a multi-day event ends at its
|
|
69
|
+
# latest session end.
|
|
70
|
+
#
|
|
71
|
+
# @return [Time, nil]
|
|
72
|
+
def end_time
|
|
73
|
+
@end_time || multi_day_sessions&.map { |session| session[:end_time] }&.compact&.max
|
|
74
|
+
end
|
|
75
|
+
|
|
62
76
|
# Initializes a new Event instance with the given attributes.
|
|
63
77
|
#
|
|
64
78
|
# @param attributes [Hash] The attributes to initialize the event with
|
|
65
79
|
# @option attributes [String] :title The event title
|
|
66
|
-
# @option attributes [Time] :start_time The event start time
|
|
67
|
-
#
|
|
80
|
+
# @option attributes [Time] :start_time The event start time. Optional with :multi_day_sessions
|
|
81
|
+
# (defaults to the earliest session start).
|
|
82
|
+
# @option attributes [Time] :end_time The event end time. Optional with :multi_day_sessions
|
|
83
|
+
# (defaults to the latest session end).
|
|
68
84
|
# @option attributes [String] :description The event description
|
|
69
85
|
# @option attributes [String] :location The event location
|
|
70
86
|
# @option attributes [String] :url The event URL
|
|
71
87
|
# @option attributes [Array<String>] :attendees The event attendees
|
|
72
|
-
# @option attributes [String] :timezone
|
|
88
|
+
# @option attributes [String] :timezone The event timezone. Defaults to
|
|
89
|
+
# CalInvite.configuration.timezone ('UTC' unless configured).
|
|
73
90
|
# @option attributes [Boolean] :show_attendees (false) Whether to show attendees
|
|
74
91
|
# @option attributes [String] :notes Additional notes
|
|
75
92
|
# @option attributes [Array<Hash>] :multi_day_sessions Multi-day session details
|
|
76
93
|
# @option attributes [Boolean] :all_day (false) Whether it's an all-day event
|
|
77
|
-
# @option attributes [Hash] :organizer The event organizer, e.g. { name: "Jane Doe", email: "jane@example.com" }
|
|
94
|
+
# @option attributes [String, Hash] :organizer The event organizer: an email string, or e.g. { name: "Jane Doe", email: "jane@example.com" }
|
|
78
95
|
# @option attributes [String] :uid A stable identifier for this event. If omitted, a random one
|
|
79
96
|
# is generated and memoized on this instance. To update or cancel a previously sent invite,
|
|
80
97
|
# you MUST pass the same :uid used originally — mail/calendar clients match REQUEST/CANCEL
|
|
@@ -91,10 +108,11 @@ module CalInvite
|
|
|
91
108
|
# @option attributes [Symbol, String] :importance :low, :normal, or :high
|
|
92
109
|
# @option attributes [Boolean] :allow_counter (true) false emits X-MICROSOFT-DISALLOW-COUNTER
|
|
93
110
|
#
|
|
94
|
-
# @raise [ArgumentError] If required attributes are missing
|
|
111
|
+
# @raise [ArgumentError] If required attributes are missing or invalid, or a key isn't an Event attribute
|
|
95
112
|
def initialize(attributes = {})
|
|
113
|
+
attributes = attributes.dup # never mutate the caller's hash
|
|
96
114
|
@show_attendees = attributes.delete(:show_attendees) || false
|
|
97
|
-
@timezone = attributes.delete(:timezone) || 'UTC'
|
|
115
|
+
@timezone = attributes.delete(:timezone) || CalInvite.configuration&.timezone || 'UTC'
|
|
98
116
|
@multi_day_sessions = attributes.delete(:multi_day_sessions) || []
|
|
99
117
|
@all_day = attributes.delete(:all_day) || false
|
|
100
118
|
@uid = attributes.delete(:uid) || generate_uid
|
|
@@ -103,9 +121,7 @@ module CalInvite
|
|
|
103
121
|
@visibility = attributes.delete(:visibility) || :public
|
|
104
122
|
@allow_counter = attributes.key?(:allow_counter) ? attributes.delete(:allow_counter) : true
|
|
105
123
|
|
|
106
|
-
attributes
|
|
107
|
-
send("#{key}=", value) if respond_to?("#{key}=")
|
|
108
|
-
end
|
|
124
|
+
assign_attributes(attributes)
|
|
109
125
|
|
|
110
126
|
validate!
|
|
111
127
|
end
|
|
@@ -113,7 +129,8 @@ module CalInvite
|
|
|
113
129
|
# Generates a calendar URL (or, for the ics/ical/ics_content providers, raw
|
|
114
130
|
# iCalendar content) for the specified provider.
|
|
115
131
|
#
|
|
116
|
-
# @param provider [Symbol]
|
|
132
|
+
# @param provider [Symbol, String] One of {CalInvite::Providers::SUPPORTED_PROVIDERS}
|
|
133
|
+
# (`:ics_content` is also accepted as a legacy alias of `:ics`)
|
|
117
134
|
# @param method [Symbol] The iCalendar METHOD to use (:publish, :request, :cancel,
|
|
118
135
|
# :reply, :counter, or :decline_counter). Only honored by the ics-family providers
|
|
119
136
|
# (ics, ical, ics_content); ignored by URL-based providers.
|
|
@@ -130,7 +147,7 @@ module CalInvite
|
|
|
130
147
|
# "Attendee-proposed reschedules (COUNTER)".
|
|
131
148
|
# - :decline_counter is the organizer rejecting a :counter proposal.
|
|
132
149
|
# @return [String] The generated calendar URL or content
|
|
133
|
-
# @raise [ArgumentError] If required event attributes are missing
|
|
150
|
+
# @raise [ArgumentError] If required event attributes are missing or the provider is unsupported
|
|
134
151
|
#
|
|
135
152
|
# @example Generate a Google Calendar URL
|
|
136
153
|
# event.generate_calendar_url(:google)
|
|
@@ -148,29 +165,14 @@ module CalInvite
|
|
|
148
165
|
# event.generate_calendar_url(:ics, method: :cancel)
|
|
149
166
|
def generate_calendar_url(provider, method: :publish)
|
|
150
167
|
validate!
|
|
151
|
-
|
|
152
|
-
if caching_enabled?
|
|
153
|
-
cache_key = cache_key_for(provider, method)
|
|
154
|
-
cached_url = fetch_from_cache(cache_key)
|
|
155
|
-
return cached_url if cached_url
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# Generate the URL
|
|
159
|
-
provider_class = CalInvite::Providers.const_get(capitalize_provider(provider.to_s))
|
|
160
|
-
generator = provider_class.new(self, method: method)
|
|
161
|
-
url = generator.generate
|
|
162
|
-
|
|
163
|
-
# Cache the result if caching is enabled
|
|
164
|
-
write_to_cache(cache_key, url) if caching_enabled?
|
|
165
|
-
|
|
166
|
-
url
|
|
168
|
+
provider_class_for(provider).new(self, method: method).generate
|
|
167
169
|
end
|
|
168
170
|
|
|
169
171
|
# Updates the event attributes with new values.
|
|
170
172
|
#
|
|
171
173
|
# @param new_attributes [Hash] The new attributes to update
|
|
172
174
|
# @return [void]
|
|
173
|
-
# @raise [ArgumentError] If the updated attributes make the event invalid
|
|
175
|
+
# @raise [ArgumentError] If a key isn't an Event attribute (nothing is changed), or the updated attributes make the event invalid
|
|
174
176
|
#
|
|
175
177
|
# @example Update event title and time
|
|
176
178
|
# event.update_attributes(
|
|
@@ -178,11 +180,7 @@ module CalInvite
|
|
|
178
180
|
# start_time: Time.now + 3600
|
|
179
181
|
# )
|
|
180
182
|
def update_attributes(new_attributes)
|
|
181
|
-
new_attributes
|
|
182
|
-
send("#{key}=", value) if respond_to?("#{key}=")
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
invalidate_cache if caching_enabled?
|
|
183
|
+
assign_attributes(new_attributes)
|
|
186
184
|
validate!
|
|
187
185
|
end
|
|
188
186
|
|
|
@@ -196,6 +194,27 @@ module CalInvite
|
|
|
196
194
|
"#{Time.now.to_i}-#{SecureRandom.hex(8)}@cal-invite"
|
|
197
195
|
end
|
|
198
196
|
|
|
197
|
+
# Legacy provider symbols that still resolve; `:ics_content` has produced the
|
|
198
|
+
# same output as `:ics` since IcsContent became a subclass of Ics.
|
|
199
|
+
PROVIDER_ALIASES = { ics_content: :ics }.freeze
|
|
200
|
+
|
|
201
|
+
# Resolves a provider symbol to its class, accepting only
|
|
202
|
+
# {CalInvite::Providers::SUPPORTED_PROVIDERS} (plus {PROVIDER_ALIASES}).
|
|
203
|
+
#
|
|
204
|
+
# @param provider [Symbol, String] The provider name
|
|
205
|
+
# @return [Class] The provider class
|
|
206
|
+
# @raise [ArgumentError] If the provider isn't supported
|
|
207
|
+
def provider_class_for(provider)
|
|
208
|
+
key = provider.to_s.to_sym
|
|
209
|
+
key = PROVIDER_ALIASES.fetch(key, key)
|
|
210
|
+
unless CalInvite::Providers::SUPPORTED_PROVIDERS.include?(key)
|
|
211
|
+
raise ArgumentError,
|
|
212
|
+
"Unsupported provider #{provider.inspect}; supported: #{CalInvite::Providers::SUPPORTED_PROVIDERS.join(', ')}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
CalInvite::Providers.const_get(capitalize_provider(key.to_s), false)
|
|
216
|
+
end
|
|
217
|
+
|
|
199
218
|
# Capitalizes each part of the provider name.
|
|
200
219
|
#
|
|
201
220
|
# @param string [String] The provider name to capitalize
|
|
@@ -216,92 +235,46 @@ module CalInvite
|
|
|
216
235
|
raise ArgumentError, "Start time is required for non-all-day events" if start_time.nil?
|
|
217
236
|
raise ArgumentError, "End time is required for non-all-day events" if end_time.nil?
|
|
218
237
|
end
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
# Checks if caching is enabled in the configuration.
|
|
222
|
-
#
|
|
223
|
-
# @return [Boolean] true if caching is enabled, false otherwise
|
|
224
|
-
def caching_enabled?
|
|
225
|
-
CalInvite.configuration &&
|
|
226
|
-
CalInvite.configuration.respond_to?(:cache_store) &&
|
|
227
|
-
CalInvite.configuration.cache_store
|
|
228
|
-
end
|
|
229
238
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
# @return [String, nil] The cache key or nil if caching is disabled
|
|
235
|
-
def cache_key_for(provider, method = :publish)
|
|
236
|
-
return nil unless caching_enabled?
|
|
237
|
-
|
|
238
|
-
attributes_hash = Digest::MD5.hexdigest(
|
|
239
|
-
[
|
|
240
|
-
title,
|
|
241
|
-
start_time&.to_i,
|
|
242
|
-
end_time&.to_i,
|
|
243
|
-
description,
|
|
244
|
-
location,
|
|
245
|
-
url,
|
|
246
|
-
attendees,
|
|
247
|
-
timezone,
|
|
248
|
-
show_attendees,
|
|
249
|
-
notes,
|
|
250
|
-
multi_day_sessions,
|
|
251
|
-
all_day,
|
|
252
|
-
organizer,
|
|
253
|
-
uid,
|
|
254
|
-
sequence,
|
|
255
|
-
geo,
|
|
256
|
-
reminders,
|
|
257
|
-
busy,
|
|
258
|
-
visibility,
|
|
259
|
-
rrule,
|
|
260
|
-
calendar_name,
|
|
261
|
-
importance,
|
|
262
|
-
allow_counter,
|
|
263
|
-
provider,
|
|
264
|
-
method
|
|
265
|
-
].map(&:to_s).join('|')
|
|
266
|
-
)
|
|
239
|
+
validate_time_order!(start_time, end_time, "End time")
|
|
240
|
+
multi_day_sessions.each_with_index do |session, index|
|
|
241
|
+
label = "Session #{index + 1}"
|
|
242
|
+
raise ArgumentError, "#{label} requires start_time and end_time" unless session[:start_time] && session[:end_time]
|
|
267
243
|
|
|
268
|
-
|
|
269
|
-
|
|
244
|
+
validate_time_order!(session[:start_time], session[:end_time], "#{label} end_time")
|
|
245
|
+
end
|
|
270
246
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
# @return [String, nil] The cached value or nil if not found
|
|
275
|
-
def fetch_from_cache(key)
|
|
276
|
-
return nil unless key && caching_enabled?
|
|
277
|
-
CalInvite.configuration.cache_store.read(key)
|
|
247
|
+
unless CalInvite::IcalTimezone.valid?(timezone)
|
|
248
|
+
raise ArgumentError, "Unknown timezone #{timezone.inspect}: use 'UTC' or an IANA identifier such as 'America/New_York'"
|
|
249
|
+
end
|
|
278
250
|
end
|
|
279
251
|
|
|
280
|
-
#
|
|
252
|
+
# Assigns attributes through their setters. Checks every key first, so an
|
|
253
|
+
# unknown key (e.g. a typo like `locaton:`) raises before anything changes
|
|
254
|
+
# instead of being silently dropped.
|
|
281
255
|
#
|
|
282
|
-
# @param
|
|
283
|
-
# @
|
|
256
|
+
# @param attributes [Hash] Attribute names (symbols or strings) to values
|
|
257
|
+
# @raise [ArgumentError] If any key isn't an Event attribute
|
|
284
258
|
# @return [void]
|
|
285
|
-
def
|
|
286
|
-
|
|
259
|
+
def assign_attributes(attributes)
|
|
260
|
+
unknown = attributes.keys.reject { |key| respond_to?("#{key}=") }
|
|
261
|
+
raise ArgumentError, "Unknown attribute(s): #{unknown.join(', ')}" if unknown.any?
|
|
287
262
|
|
|
288
|
-
|
|
289
|
-
CalInvite.configuration.cache_store.write(
|
|
290
|
-
key,
|
|
291
|
-
value,
|
|
292
|
-
expires_in: expires_in
|
|
293
|
-
)
|
|
263
|
+
attributes.each { |key, value| send("#{key}=", value) }
|
|
294
264
|
end
|
|
295
265
|
|
|
296
|
-
#
|
|
266
|
+
# Raises unless the end comes after the start. RFC 5545 requires DTEND to be
|
|
267
|
+
# later than DTSTART; all-day events may start and end on the same date.
|
|
297
268
|
#
|
|
298
|
-
# @
|
|
299
|
-
|
|
300
|
-
|
|
269
|
+
# @param start_at [Time, Date, nil] The start
|
|
270
|
+
# @param end_at [Time, Date, nil] The end
|
|
271
|
+
# @param label [String] What to call the end in the error message
|
|
272
|
+
# @raise [ArgumentError] If the end is before (or, for timed events, equal to) the start
|
|
273
|
+
def validate_time_order!(start_at, end_at, label)
|
|
274
|
+
return unless start_at && end_at
|
|
275
|
+
return if end_at > start_at || (all_day && end_at == start_at)
|
|
301
276
|
|
|
302
|
-
|
|
303
|
-
CalInvite.configuration.cache_store.delete_matched("cal_invite:event:*")
|
|
304
|
-
end
|
|
277
|
+
raise ArgumentError, "#{label} must be after start time"
|
|
305
278
|
end
|
|
306
279
|
end
|
|
307
280
|
end
|
|
@@ -9,23 +9,52 @@ module CalInvite
|
|
|
9
9
|
# time for a given IANA/Olson timezone identifier, using TZInfo (already pulled in
|
|
10
10
|
# transitively via activesupport).
|
|
11
11
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
12
|
+
# Only uses TZInfo APIs available in both tzinfo 1.2+ (pinned by Rails 6.0) and
|
|
13
|
+
# 2.x; the few period accessors renamed in 2.x go through {period_starts_at},
|
|
14
|
+
# {period_ends_at} and {period_local_ends_at}.
|
|
15
|
+
#
|
|
16
|
+
# Event validates `timezone` with {valid?}, so by the time a provider runs it's
|
|
17
|
+
# either UTC or a TZInfo-recognized identifier. {local_time} and
|
|
18
|
+
# {vtimezone_lines} still fail soft (return nil) for UTC or unrecognized input.
|
|
15
19
|
#
|
|
16
20
|
# @api private
|
|
17
21
|
module IcalTimezone
|
|
18
22
|
module_function
|
|
19
23
|
|
|
24
|
+
# Whether `tzid` means UTC (case-insensitive 'UTC'), which needs no VTIMEZONE:
|
|
25
|
+
# times are emitted as `Z`-suffixed UTC timestamps instead.
|
|
26
|
+
#
|
|
27
|
+
# @param tzid [String, nil] The timezone identifier
|
|
28
|
+
# @return [Boolean]
|
|
29
|
+
def utc?(tzid)
|
|
30
|
+
tzid.to_s.upcase == 'UTC'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Whether `tzid` is usable as an event timezone: UTC or a TZInfo-recognized
|
|
34
|
+
# IANA identifier (e.g. "America/New_York"). Raw offsets like "+01:00" aren't:
|
|
35
|
+
# they have no VTIMEZONE definition.
|
|
36
|
+
#
|
|
37
|
+
# @param tzid [String, nil] The timezone identifier
|
|
38
|
+
# @return [Boolean]
|
|
39
|
+
def valid?(tzid)
|
|
40
|
+
return true if utc?(tzid)
|
|
41
|
+
return false if tzid.nil? || tzid.to_s.strip.empty?
|
|
42
|
+
|
|
43
|
+
TZInfo::Timezone.get(tzid.to_s)
|
|
44
|
+
true
|
|
45
|
+
rescue TZInfo::InvalidTimezoneIdentifier
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
20
49
|
# Converts a UTC time to local wall-clock time for the given timezone.
|
|
21
50
|
#
|
|
22
51
|
# @param tzid [String] An IANA timezone identifier, e.g. "America/New_York"
|
|
23
52
|
# @param utc_time [Time] The time to convert (interpreted as UTC)
|
|
24
53
|
# @return [Time, nil] The local wall-clock time, or nil if tzid is unrecognized
|
|
25
54
|
def local_time(tzid, utc_time)
|
|
26
|
-
return nil if tzid.nil? || tzid.to_s.strip.empty?
|
|
55
|
+
return nil if utc?(tzid) || tzid.nil? || tzid.to_s.strip.empty?
|
|
27
56
|
|
|
28
|
-
TZInfo::Timezone.get(tzid.to_s).
|
|
57
|
+
TZInfo::Timezone.get(tzid.to_s).utc_to_local(utc_time.utc)
|
|
29
58
|
rescue TZInfo::InvalidTimezoneIdentifier
|
|
30
59
|
nil
|
|
31
60
|
end
|
|
@@ -38,10 +67,10 @@ module CalInvite
|
|
|
38
67
|
# @return [Array<String>, nil] iCalendar lines for the VTIMEZONE component, or
|
|
39
68
|
# nil if tzid is unrecognized, is UTC, or the component can't be built
|
|
40
69
|
def vtimezone_lines(tzid)
|
|
41
|
-
return nil if tzid.nil? || tzid.to_s.strip.empty?
|
|
70
|
+
return nil if utc?(tzid) || tzid.nil? || tzid.to_s.strip.empty?
|
|
42
71
|
|
|
43
72
|
tz = TZInfo::Timezone.get(tzid.to_s)
|
|
44
|
-
current = tz.
|
|
73
|
+
current = tz.period_for_utc(Time.now.utc)
|
|
45
74
|
|
|
46
75
|
if current.dst?
|
|
47
76
|
dst_period = current
|
|
@@ -71,28 +100,45 @@ module CalInvite
|
|
|
71
100
|
|
|
72
101
|
# @api private
|
|
73
102
|
def period_before(tz, period)
|
|
74
|
-
|
|
103
|
+
starts_at = period_starts_at(period)
|
|
104
|
+
return nil unless starts_at
|
|
75
105
|
|
|
76
|
-
tz.
|
|
106
|
+
tz.period_for_utc(starts_at.to_time - 1)
|
|
77
107
|
end
|
|
78
108
|
|
|
79
109
|
# @api private
|
|
80
110
|
def period_after(tz, period)
|
|
81
|
-
|
|
111
|
+
ends_at = period && period_ends_at(period)
|
|
112
|
+
return nil unless ends_at
|
|
82
113
|
|
|
83
|
-
tz.
|
|
114
|
+
tz.period_for_utc(ends_at.to_time)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @api private
|
|
118
|
+
def period_starts_at(period)
|
|
119
|
+
period.respond_to?(:starts_at) ? period.starts_at : period.utc_start
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @api private
|
|
123
|
+
def period_ends_at(period)
|
|
124
|
+
period.respond_to?(:ends_at) ? period.ends_at : period.utc_end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @api private
|
|
128
|
+
def period_local_ends_at(period)
|
|
129
|
+
period.respond_to?(:local_ends_at) ? period.local_ends_at : period.local_end
|
|
84
130
|
end
|
|
85
131
|
|
|
86
132
|
# @api private
|
|
87
133
|
def observance_lines(kind, from_period, to_period)
|
|
88
|
-
transition_time = from_period.
|
|
134
|
+
transition_time = period_local_ends_at(from_period).to_time
|
|
89
135
|
|
|
90
136
|
[
|
|
91
137
|
"BEGIN:#{kind}",
|
|
92
138
|
"DTSTART:#{transition_time.strftime('%Y%m%dT%H%M%S')}",
|
|
93
|
-
"TZOFFSETFROM:#{format_offset(from_period.
|
|
94
|
-
"TZOFFSETTO:#{format_offset(to_period.
|
|
95
|
-
"TZNAME:#{to_period.
|
|
139
|
+
"TZOFFSETFROM:#{format_offset(from_period.utc_total_offset)}",
|
|
140
|
+
"TZOFFSETTO:#{format_offset(to_period.utc_total_offset)}",
|
|
141
|
+
"TZNAME:#{to_period.abbreviation}",
|
|
96
142
|
rrule_line(transition_time),
|
|
97
143
|
"END:#{kind}"
|
|
98
144
|
]
|
|
@@ -103,9 +149,9 @@ module CalInvite
|
|
|
103
149
|
[
|
|
104
150
|
"BEGIN:STANDARD",
|
|
105
151
|
"DTSTART:16010101T000000",
|
|
106
|
-
"TZOFFSETFROM:#{format_offset(period.
|
|
107
|
-
"TZOFFSETTO:#{format_offset(period.
|
|
108
|
-
"TZNAME:#{period.
|
|
152
|
+
"TZOFFSETFROM:#{format_offset(period.utc_total_offset)}",
|
|
153
|
+
"TZOFFSETTO:#{format_offset(period.utc_total_offset)}",
|
|
154
|
+
"TZNAME:#{period.abbreviation}",
|
|
109
155
|
"END:STANDARD"
|
|
110
156
|
]
|
|
111
157
|
end
|