cal-invite 0.1.2 → 0.1.4

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,77 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/cal_invite/configuration.rb
2
- module CalInvites
4
+ # Configuration class for the CalInvite gem.
5
+ # Handles all configurable options including cache settings, timezone, and webhook secrets.
6
+ #
7
+ # @attr_reader [ActiveSupport::Cache::Store, nil] cache_store The cache store to use
8
+ # @attr_reader [String] cache_prefix The prefix to use for cache keys
9
+ # @attr_reader [Integer] cache_expires_in The default cache expiration time in seconds
10
+ # @attr_reader [String, nil] webhook_secret The secret key for webhook verification
11
+ # @attr_reader [String] timezone The default timezone for events
12
+ module CalInvite
3
13
  class Configuration
4
- attr_accessor :cache_store, :cache_prefix, :webhook_secret, :timezone
14
+ attr_reader :cache_store, :cache_prefix, :cache_expires_in, :webhook_secret, :timezone
5
15
 
16
+ # Initializes a new Configuration instance with default values.
6
17
  def initialize
7
- @cache_store = :memory_store
18
+ @cache_store = nil
8
19
  @cache_prefix = 'cal_invite'
20
+ @cache_expires_in = 24.hours # now this will work with active_support loaded
9
21
  @webhook_secret = nil
10
22
  @timezone = 'UTC'
11
23
  end
24
+
25
+ # Sets the cache store to use for caching calendar URLs.
26
+ #
27
+ # @param store [Symbol, #read, #write, #delete] The cache store to use
28
+ # @raise [ArgumentError] If an unsupported cache store is provided
29
+ #
30
+ # @example Set memory store
31
+ # config.cache_store = :memory_store
32
+ def cache_store=(store)
33
+ @cache_store = case store
34
+ when :memory_store
35
+ ActiveSupport::Cache::MemoryStore.new
36
+ when :null_store
37
+ ActiveSupport::Cache::NullStore.new
38
+ when Symbol
39
+ raise ArgumentError, "Unsupported cache store: #{store}"
40
+ else
41
+ # Allow custom cache store objects that respond to read/write/delete
42
+ unless store.respond_to?(:read) && store.respond_to?(:write) && store.respond_to?(:delete)
43
+ raise ArgumentError, "Custom cache store must implement read/write/delete methods"
44
+ end
45
+ store
46
+ end
47
+ end
48
+
49
+ # Sets the prefix used for cache keys.
50
+ #
51
+ # @param prefix [#to_s] The prefix to use for cache keys
52
+ def cache_prefix=(prefix)
53
+ @cache_prefix = prefix.to_s
54
+ end
55
+
56
+ # Sets the default cache expiration time.
57
+ #
58
+ # @param duration [#to_i] The duration in seconds
59
+ def cache_expires_in=(duration)
60
+ @cache_expires_in = duration.to_i
61
+ end
62
+
63
+ # Sets the webhook secret for verification.
64
+ #
65
+ # @param secret [String, nil] The secret key
66
+ def webhook_secret=(secret)
67
+ @webhook_secret = secret
68
+ end
69
+
70
+ # Sets the default timezone for events.
71
+ #
72
+ # @param tz [#to_s] The timezone identifier
73
+ def timezone=(tz)
74
+ @timezone = tz.to_s
75
+ end
12
76
  end
13
77
  end
@@ -1,8 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+
3
5
  # lib/cal_invite/event.rb
6
+ # Represents a calendar event with all its attributes and generation capabilities.
7
+ #
8
+ # @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
11
+ # @attr_accessor [String] description The description of the event
12
+ # @attr_accessor [String] location The location of the event
13
+ # @attr_accessor [String] url The URL associated with the event
14
+ # @attr_accessor [Array<String>] attendees The list of attendee email addresses
15
+ # @attr_accessor [String] timezone The timezone for the event
16
+ # @attr_accessor [Boolean] show_attendees Whether to include attendees in calendar invites
17
+ # @attr_accessor [String] notes Additional notes for the event
18
+ # @attr_accessor [Array<Hash>] multi_day_sessions Sessions for multi-day events
19
+ # @attr_accessor [Boolean] all_day Whether this is an all-day event
4
20
  module CalInvite
5
- # Represents a calendar event with its properties and validation rules
6
21
  class Event
7
22
  attr_accessor :title,
8
23
  :start_time,
@@ -17,7 +32,7 @@ module CalInvite
17
32
  :multi_day_sessions,
18
33
  :all_day
19
34
 
20
- # Initialize a new Event instance
35
+ # Initializes a new Event instance with the given attributes.
21
36
  #
22
37
  # @param attributes [Hash] The attributes to initialize the event with
23
38
  # @option attributes [String] :title The event title
@@ -26,27 +41,20 @@ module CalInvite
26
41
  # @option attributes [String] :description The event description
27
42
  # @option attributes [String] :location The event location
28
43
  # @option attributes [String] :url The event URL
29
- # @option attributes [Array<String>] :attendees List of event attendees
30
- # @option attributes [String] :timezone The event timezone (default: 'UTC')
31
- # @option attributes [Boolean] :show_attendees Whether to show attendees (default: false)
32
- # @option attributes [String] :notes Additional notes for the event
33
- # @option attributes [Array<Hash>] :multi_day_sessions List of sessions for multi-day events
34
- # @option attributes [Boolean] :all_day Whether this is an all-day event (default: false)
35
- # @raise [ArgumentError] if required attributes are missing
44
+ # @option attributes [Array<String>] :attendees The event attendees
45
+ # @option attributes [String] :timezone ('UTC') The event timezone
46
+ # @option attributes [Boolean] :show_attendees (false) Whether to show attendees
47
+ # @option attributes [String] :notes Additional notes
48
+ # @option attributes [Array<Hash>] :multi_day_sessions Multi-day session details
49
+ # @option attributes [Boolean] :all_day (false) Whether it's an all-day event
50
+ #
51
+ # @raise [ArgumentError] If required attributes are missing
36
52
  def initialize(attributes = {})
37
53
  @show_attendees = attributes.delete(:show_attendees) || false
38
54
  @timezone = attributes.delete(:timezone) || 'UTC'
39
55
  @multi_day_sessions = attributes.delete(:multi_day_sessions) || []
40
56
  @all_day = attributes.delete(:all_day) || false
41
57
 
42
- # Convert times to UTC before storing
43
- if attributes[:start_time]
44
- attributes[:start_time] = ensure_utc(attributes[:start_time])
45
- end
46
- if attributes[:end_time]
47
- attributes[:end_time] = ensure_utc(attributes[:end_time])
48
- end
49
-
50
58
  attributes.each do |key, value|
51
59
  send("#{key}=", value) if respond_to?("#{key}=")
52
60
  end
@@ -54,62 +62,72 @@ module CalInvite
54
62
  validate!
55
63
  end
56
64
 
57
- # Generate a calendar URL for the specified provider
65
+ # Generates a calendar URL for the specified provider.
58
66
  #
59
67
  # @param provider [Symbol] The calendar provider to generate the URL for
60
68
  # @return [String] The generated calendar URL
61
- # @raise [ArgumentError] if required attributes are missing
62
- def calendar_url(provider)
69
+ # @raise [ArgumentError] If required event attributes are missing
70
+ #
71
+ # @example Generate a Google Calendar URL
72
+ # event.generate_calendar_url(:google)
73
+ #
74
+ # @example Generate an Outlook Calendar URL
75
+ # event.generate_calendar_url(:outlook)
76
+ def generate_calendar_url(provider)
63
77
  validate!
78
+
79
+ if caching_enabled?
80
+ cache_key = cache_key_for(provider)
81
+ cached_url = fetch_from_cache(cache_key)
82
+ return cached_url if cached_url
83
+ end
84
+
85
+ # Generate the URL
64
86
  provider_class = CalInvite::Providers.const_get(capitalize_provider(provider.to_s))
65
87
  generator = provider_class.new(self)
66
- generator.generate
67
- end
88
+ url = generator.generate
68
89
 
69
- # Convert a UTC time to event's timezone
70
- #
71
- # @param time [Time, nil] The time to convert
72
- # @return [Time, nil] The converted time in the event's timezone
73
- def localize_time(time)
74
- return time unless time
75
- # When timezone is UTC, we should preserve the UTC time
76
- # without any conversion
77
- return time if timezone == 'UTC'
78
- time.getlocal(timezone_offset)
90
+ # Cache the result if caching is enabled
91
+ write_to_cache(cache_key, url) if caching_enabled?
92
+
93
+ url
79
94
  end
80
95
 
81
- # Get all event sessions including multi-day sessions
96
+ # Updates the event attributes with new values.
82
97
  #
83
- # @return [Array<Array<Time>>] Array of start and end time pairs
84
- def sessions
85
- return [@start_time, @end_time] if multi_day_sessions.empty?
86
-
87
- multi_day_sessions.map do |session|
88
- [session[:start_time], session[:end_time]]
98
+ # @param new_attributes [Hash] The new attributes to update
99
+ # @return [void]
100
+ # @raise [ArgumentError] If the updated attributes make the event invalid
101
+ #
102
+ # @example Update event title and time
103
+ # event.update_attributes(
104
+ # title: "Updated Meeting",
105
+ # start_time: Time.now + 3600
106
+ # )
107
+ def update_attributes(new_attributes)
108
+ new_attributes.each do |key, value|
109
+ send("#{key}=", value) if respond_to?("#{key}=")
89
110
  end
90
- end
91
111
 
92
- private
93
-
94
- def ensure_utc(time)
95
- return nil unless time
96
- time.is_a?(Time) ? time.utc : Time.parse(time.to_s).utc
112
+ invalidate_cache if caching_enabled?
113
+ validate!
97
114
  end
98
115
 
99
- def timezone_offset
100
- return '+00:00' if timezone == 'UTC'
101
- timezone # assume timezone is already in offset format
102
- end
116
+ private
103
117
 
118
+ # Capitalizes each part of the provider name.
119
+ #
120
+ # @param string [String] The provider name to capitalize
121
+ # @return [String] The capitalized provider name
122
+ # @example
123
+ # capitalize_provider('google_calendar') # => "GoogleCalendar"
104
124
  def capitalize_provider(string)
105
- # Handles both simple capitalization (ical -> Ical)
106
- # and compound names (office365 -> Office365)
107
125
  string.split('_').map(&:capitalize).join
108
126
  end
109
127
 
110
- # Validate the event attributes
128
+ # Validates the event attributes.
111
129
  #
112
- # @raise [ArgumentError] if required attributes are missing
130
+ # @raise [ArgumentError] If required attributes are missing or invalid
113
131
  def validate!
114
132
  raise ArgumentError, "Title is required" if title.nil? || title.strip.empty?
115
133
 
@@ -118,5 +136,78 @@ module CalInvite
118
136
  raise ArgumentError, "End time is required for non-all-day events" if end_time.nil?
119
137
  end
120
138
  end
139
+
140
+ # Checks if caching is enabled in the configuration.
141
+ #
142
+ # @return [Boolean] true if caching is enabled, false otherwise
143
+ def caching_enabled?
144
+ CalInvite.configuration &&
145
+ CalInvite.configuration.respond_to?(:cache_store) &&
146
+ CalInvite.configuration.cache_store
147
+ end
148
+
149
+ # Generates a cache key for the event and provider combination.
150
+ #
151
+ # @param provider [Symbol] The calendar provider
152
+ # @return [String, nil] The cache key or nil if caching is disabled
153
+ def cache_key_for(provider)
154
+ return nil unless caching_enabled?
155
+
156
+ attributes_hash = Digest::MD5.hexdigest(
157
+ [
158
+ title,
159
+ start_time&.to_i,
160
+ end_time&.to_i,
161
+ description,
162
+ location,
163
+ url,
164
+ attendees,
165
+ timezone,
166
+ show_attendees,
167
+ notes,
168
+ multi_day_sessions,
169
+ all_day,
170
+ provider
171
+ ].map(&:to_s).join('|')
172
+ )
173
+
174
+ "cal_invite:event:#{attributes_hash}"
175
+ end
176
+
177
+ # Retrieves a value from the cache store.
178
+ #
179
+ # @param key [String] The cache key
180
+ # @return [String, nil] The cached value or nil if not found
181
+ def fetch_from_cache(key)
182
+ return nil unless key && caching_enabled?
183
+ CalInvite.configuration.cache_store.read(key)
184
+ end
185
+
186
+ # Writes a value to the cache store.
187
+ #
188
+ # @param key [String] The cache key
189
+ # @param value [String] The value to cache
190
+ # @return [void]
191
+ def write_to_cache(key, value)
192
+ return unless key && caching_enabled?
193
+
194
+ expires_in = CalInvite.configuration&.cache_expires_in
195
+ CalInvite.configuration.cache_store.write(
196
+ key,
197
+ value,
198
+ expires_in: expires_in
199
+ )
200
+ end
201
+
202
+ # Invalidates all cached URLs for this event.
203
+ #
204
+ # @return [void]
205
+ def invalidate_cache
206
+ return unless caching_enabled?
207
+
208
+ if CalInvite.configuration.cache_store.respond_to?(:delete_matched)
209
+ CalInvite.configuration.cache_store.delete_matched("cal_invite:event:*")
210
+ end
211
+ end
121
212
  end
122
213
  end
@@ -1,68 +1,85 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/cal_invite/providers/base_provider.rb
4
- module CalInvite
5
- module Providers
6
- # Base class for calendar providers that implements common functionality
7
- # and defines the interface that all providers must implement
8
- class BaseProvider
9
- # @return [CalInvite::Event] The event being processed
10
- attr_reader :event
3
+ # app/lib/base_provider.rb
4
+ # Base class for calendar providers that implements common functionality
5
+ # and defines the interface that all providers must implement.
6
+ #
7
+ # @abstract Subclass and override {#generate} to implement a calendar provider
8
+ class BaseProvider
9
+ attr_reader :event
11
10
 
12
- # Initialize a new calendar provider
13
- #
14
- # @param event [CalInvite::Event] The event to generate a calendar URL for
15
- def initialize(event)
16
- @event = event
17
- end
11
+ # Initialize a new calendar provider
12
+ #
13
+ # @param event [CalInvite::Event] The event to generate a calendar URL for
14
+ def initialize(event)
15
+ @event = event
16
+ end
18
17
 
19
- # Generate a calendar URL for the event
20
- # This method must be implemented by all provider subclasses
21
- #
22
- # @abstract
23
- # @raise [NotImplementedError] if the provider class doesn't implement this method
24
- def generate
25
- raise NotImplementedError, "#{self.class} must implement #generate"
26
- end
18
+ # Generate a calendar URL or content for the event.
19
+ # This method must be implemented by all provider subclasses.
20
+ #
21
+ # @abstract
22
+ # @return [String] The generated calendar URL or content
23
+ # @raise [NotImplementedError] if the provider class doesn't implement this method
24
+ def generate
25
+ raise NotImplementedError, "#{self.class} must implement #generate"
26
+ end
27
27
 
28
- protected
28
+ protected
29
29
 
30
- # URL encode a string for use in calendar URLs
31
- #
32
- # @param str [#to_s] The string to encode
33
- # @return [String] The URL encoded string
34
- def url_encode(str)
35
- URI.encode_www_form_component(str.to_s)
36
- end
30
+ # URL encode a string for use in calendar URLs
31
+ #
32
+ # @param str [#to_s] The string to encode
33
+ # @return [String] The URL encoded string
34
+ def url_encode(str)
35
+ URI.encode_www_form_component(str.to_s)
36
+ end
37
37
 
38
- # Format the event description including notes and URL if present
39
- #
40
- # @return [String, nil] The formatted description or nil if no content
41
- def format_description
42
- parts = []
43
- parts << event.description if event.description
44
- parts << "Notes: #{event.notes}" if event.notes
45
- parts << "URL: #{event.url}" if event.url
46
- parts.join("\n\n")
47
- end
38
+ # Format the event description
39
+ # @return [String, nil] The formatted description or nil if no content
40
+ def format_description
41
+ parts = []
42
+ parts << event.description if event.description
43
+ parts << "Notes: #{event.notes}" if event.notes
44
+ parts.join("\n\n")
45
+ end
48
46
 
49
- # Format the event location, combining physical location and URL if both present
50
- #
51
- # @return [String, nil] The formatted location or nil if neither location nor URL present
52
- def format_location
53
- return event.url if event.url && !event.location
54
- return event.location if event.location && !event.url
55
- return "#{event.location}\n#{event.url}" if event.location && event.url
56
- nil
57
- end
47
+ # Get just the physical location
48
+ # @return [String, nil] The location or nil if not present
49
+ def format_location
50
+ event.location
51
+ end
52
+
53
+ # Get the URL for virtual meetings
54
+ # @return [String, nil] The URL or nil if not present
55
+ def format_url
56
+ event.url
57
+ end
58
58
 
59
- # Get the list of attendees if showing attendees is enabled
60
- #
61
- # @return [Array<String>] The list of attendees or empty array if disabled/none present
62
- def attendees_list
63
- return [] unless event.show_attendees && event.attendees&.any?
64
- event.attendees
65
- end
59
+ # Format description including URL if present
60
+ # @return [String, nil] The formatted description with URL
61
+ def format_description_with_url
62
+ parts = []
63
+ parts << format_description if format_description
64
+ parts << "Virtual Meeting URL: #{format_url}" if format_url
65
+ parts.join("\n\n")
66
+ end
67
+
68
+ def add_optional_params(params)
69
+ params[:description] = url_encode(format_description_with_url) if format_description || format_url
70
+ params[:location] = url_encode(format_location) if format_location
71
+
72
+ if event.show_attendees && event.attendees&.any?
73
+ params[:attendees] = event.attendees.join(',')
66
74
  end
75
+
76
+ params
77
+ end
78
+
79
+ # Get the list of attendees if showing attendees is enabled
80
+ # @return [Array<String>] The list of attendees or empty array if disabled/none present
81
+ def attendees_list
82
+ return [] unless event.show_attendees && event.attendees&.any?
83
+ event.attendees
67
84
  end
68
85
  end
@@ -3,7 +3,34 @@
3
3
  # lib/cal_invite/providers/google.rb
4
4
  module CalInvite
5
5
  module Providers
6
+ # Google Calendar provider for generating event URLs.
7
+ # This provider generates URLs that open the Google Calendar event creation page
8
+ # with pre-filled event details.
9
+ #
10
+ # @example Creating a regular event URL
11
+ # event = CalInvite::Event.new(
12
+ # title: "Team Meeting",
13
+ # start_time: Time.now,
14
+ # end_time: Time.now + 3600
15
+ # )
16
+ # google = CalInvite::Providers::Google.new(event)
17
+ # url = google.generate
18
+ #
19
+ # @example Creating an all-day event URL
20
+ # event = CalInvite::Event.new(
21
+ # title: "Company Holiday",
22
+ # all_day: true,
23
+ # start_time: Date.today,
24
+ # end_time: Date.today + 1
25
+ # )
26
+ # url = CalInvite::Providers::Google.new(event).generate
6
27
  class Google < BaseProvider
28
+ # Generates a Google Calendar URL for the event.
29
+ # Handles both regular and all-day events appropriately.
30
+ #
31
+ # @return [String] The generated Google Calendar URL
32
+ # @see #generate_all_day_event
33
+ # @see #generate_single_event
7
34
  def generate
8
35
  if event.all_day
9
36
  generate_all_day_event
@@ -14,36 +41,53 @@ module CalInvite
14
41
 
15
42
  private
16
43
 
44
+ # Generates a URL for an all-day event.
45
+ # Uses a simpler date format without time components.
46
+ #
47
+ # @return [String] The Google Calendar URL for an all-day event
48
+ # @see #format_all_day_dates
17
49
  def generate_all_day_event
18
50
  params = {
19
51
  action: 'TEMPLATE',
20
52
  text: url_encode(event.title),
21
53
  dates: format_all_day_dates
22
54
  }
23
-
24
55
  add_optional_params(params)
25
56
  build_url(params)
26
57
  end
27
58
 
59
+ # Generates a URL for a regular (time-specific) event.
60
+ #
61
+ # @return [String] The Google Calendar URL for a regular event
62
+ # @raise [ArgumentError] If start_time or end_time is missing
63
+ # @see #format_dates
28
64
  def generate_single_event
29
65
  params = {
30
66
  action: 'TEMPLATE',
31
67
  text: url_encode(event.title),
32
68
  dates: format_dates
33
69
  }
34
-
35
70
  add_optional_params(params)
36
71
  build_url(params)
37
72
  end
38
73
 
74
+ # Formats dates for an all-day event according to Google Calendar's requirements.
75
+ # If start_time is not specified, uses current date.
76
+ # If end_time is not specified, adds one day to start_time.
77
+ #
78
+ # @return [String] Date range in format 'YYYYMMDD/YYYYMMDD'
39
79
  def format_all_day_dates
40
- # For all-day events, use current date if no start_time specified
41
80
  start_date = event.start_time || Time.now
42
81
  end_date = event.end_time || (start_date + 86400) # Add one day if no end_time
43
82
 
44
83
  "#{start_date.strftime('%Y%m%d')}/#{end_date.strftime('%Y%m%d')}"
45
84
  end
46
85
 
86
+ # Formats dates and times according to Google Calendar's requirements.
87
+ # Times are converted to UTC and formatted appropriately.
88
+ #
89
+ # @return [String] Date/time range in format 'YYYYMMDDTHHmmSSZ/YYYYMMDDTHHmmSSZ'
90
+ # @raise [ArgumentError] If either start_time or end_time is missing
47
91
  def format_dates
48
92
  raise ArgumentError, "Start time is required" unless event.start_time
49
93
  raise ArgumentError, "End time is required" unless event.end_time
@@ -54,12 +98,24 @@ module CalInvite
54
98
  "#{start_time.utc.strftime('%Y%m%dT%H%M%SZ')}/#{end_time.utc.strftime('%Y%m%dT%H%M%SZ')}"
55
99
  end
56
100
 
101
+ # Adds optional parameters to the URL parameters hash.
102
+ # Handles description, virtual meeting URL, and location.
103
+ #
104
+ # @param params [Hash] The parameters hash to update
105
+ # @return [Hash] The updated parameters hash with optional parameters added
57
106
  def add_optional_params(params)
58
- params[:details] = url_encode(format_description) if format_description
107
+ description_parts = []
108
+ description_parts << format_description if format_description
109
+ description_parts << "Virtual Meeting URL: #{format_url}" if format_url
110
+ params[:details] = url_encode(description_parts.join("\n\n")) if description_parts.any?
59
111
  params[:location] = url_encode(format_location) if format_location
60
112
  params
61
113
  end
62
114
 
115
+ # Builds the final Google Calendar URL from the parameters hash.
116
+ #
117
+ # @param params [Hash] The parameters to include in the URL
118
+ # @return [String] The complete Google Calendar URL
63
119
  def build_url(params)
64
120
  query = params.map { |k, v| "#{k}=#{v}" }.join('&')
65
121
  "https://calendar.google.com/calendar/render?#{query}"