cal-invite 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.
@@ -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
- # @option attributes [Time] :end_time The event end time
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 ('UTC') The event 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.each do |key, value|
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] The calendar provider to generate the URL for
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.each do |key, value|
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
- # Generates a cache key for the event and provider combination.
231
- #
232
- # @param provider [Symbol] The calendar provider
233
- # @param method [Symbol] The iCalendar METHOD used to generate the content
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
- "cal_invite:event:#{attributes_hash}"
269
- end
244
+ validate_time_order!(session[:start_time], session[:end_time], "#{label} end_time")
245
+ end
270
246
 
271
- # Retrieves a value from the cache store.
272
- #
273
- # @param key [String] The cache key
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
- # Writes a value to the cache store.
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 key [String] The cache key
283
- # @param value [String] The value to cache
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 write_to_cache(key, value)
286
- return unless key && caching_enabled?
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
- expires_in = CalInvite.configuration&.cache_expires_in
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
- # Invalidates all cached URLs for this event.
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
- # @return [void]
299
- def invalidate_cache
300
- return unless caching_enabled?
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
- if CalInvite.configuration.cache_store.respond_to?(:delete_matched)
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
- # Both entry points fail soft: if `tzid` isn't a TZInfo-recognized identifier (e.g.
13
- # a raw UTC offset string like "+01:00", or 'UTC' itself), they return nil so
14
- # callers can fall back to their previous behavior instead of raising.
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? || tzid.to_s.upcase == 'UTC'
55
+ return nil if utc?(tzid) || tzid.nil? || tzid.to_s.strip.empty?
27
56
 
28
- TZInfo::Timezone.get(tzid.to_s).to_local(utc_time.utc)
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? || tzid.to_s.upcase == 'UTC'
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.period_for(Time.now.utc)
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
- return nil unless period.starts_at
103
+ starts_at = period_starts_at(period)
104
+ return nil unless starts_at
75
105
 
76
- tz.period_for(period.starts_at.to_time - 1)
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
- return nil unless period&.ends_at
111
+ ends_at = period && period_ends_at(period)
112
+ return nil unless ends_at
82
113
 
83
- tz.period_for(period.ends_at.to_time)
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.local_ends_at.to_time
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.offset.observed_utc_offset)}",
94
- "TZOFFSETTO:#{format_offset(to_period.offset.observed_utc_offset)}",
95
- "TZNAME:#{to_period.offset.abbreviation}",
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.offset.observed_utc_offset)}",
107
- "TZOFFSETTO:#{format_offset(period.offset.observed_utc_offset)}",
108
- "TZNAME:#{period.offset.abbreviation}",
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