cal-invite 0.1.2 → 0.1.3

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,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
-
3
2
  # lib/cal_invite/event.rb
3
+ require 'digest'
4
+
4
5
  module CalInvite
5
- # Represents a calendar event with its properties and validation rules
6
6
  class Event
7
7
  attr_accessor :title,
8
8
  :start_time,
@@ -17,36 +17,12 @@ module CalInvite
17
17
  :multi_day_sessions,
18
18
  :all_day
19
19
 
20
- # Initialize a new Event instance
21
- #
22
- # @param attributes [Hash] The attributes to initialize the event with
23
- # @option attributes [String] :title The event title
24
- # @option attributes [Time] :start_time The event start time
25
- # @option attributes [Time] :end_time The event end time
26
- # @option attributes [String] :description The event description
27
- # @option attributes [String] :location The event location
28
- # @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
36
20
  def initialize(attributes = {})
37
21
  @show_attendees = attributes.delete(:show_attendees) || false
38
22
  @timezone = attributes.delete(:timezone) || 'UTC'
39
23
  @multi_day_sessions = attributes.delete(:multi_day_sessions) || []
40
24
  @all_day = attributes.delete(:all_day) || false
41
25
 
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
26
  attributes.each do |key, value|
51
27
  send("#{key}=", value) if respond_to?("#{key}=")
52
28
  end
@@ -54,62 +30,41 @@ module CalInvite
54
30
  validate!
55
31
  end
56
32
 
57
- # Generate a calendar URL for the specified provider
58
- #
59
- # @param provider [Symbol] The calendar provider to generate the URL for
60
- # @return [String] The generated calendar URL
61
- # @raise [ArgumentError] if required attributes are missing
62
- def calendar_url(provider)
33
+ def generate_calendar_url(provider)
63
34
  validate!
35
+
36
+ if caching_enabled?
37
+ cache_key = cache_key_for(provider)
38
+ cached_url = fetch_from_cache(cache_key)
39
+ return cached_url if cached_url
40
+ end
41
+
42
+ # Generate the URL
64
43
  provider_class = CalInvite::Providers.const_get(capitalize_provider(provider.to_s))
65
44
  generator = provider_class.new(self)
66
- generator.generate
67
- end
45
+ url = generator.generate
68
46
 
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)
79
- end
80
-
81
- # Get all event sessions including multi-day sessions
82
- #
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?
47
+ # Cache the result if caching is enabled
48
+ write_to_cache(cache_key, url) if caching_enabled?
86
49
 
87
- multi_day_sessions.map do |session|
88
- [session[:start_time], session[:end_time]]
89
- end
50
+ url
90
51
  end
91
52
 
92
- private
53
+ def update_attributes(new_attributes)
54
+ new_attributes.each do |key, value|
55
+ send("#{key}=", value) if respond_to?("#{key}=")
56
+ end
93
57
 
94
- def ensure_utc(time)
95
- return nil unless time
96
- time.is_a?(Time) ? time.utc : Time.parse(time.to_s).utc
58
+ invalidate_cache if caching_enabled?
59
+ validate!
97
60
  end
98
61
 
99
- def timezone_offset
100
- return '+00:00' if timezone == 'UTC'
101
- timezone # assume timezone is already in offset format
102
- end
62
+ private
103
63
 
104
64
  def capitalize_provider(string)
105
- # Handles both simple capitalization (ical -> Ical)
106
- # and compound names (office365 -> Office365)
107
65
  string.split('_').map(&:capitalize).join
108
66
  end
109
67
 
110
- # Validate the event attributes
111
- #
112
- # @raise [ArgumentError] if required attributes are missing
113
68
  def validate!
114
69
  raise ArgumentError, "Title is required" if title.nil? || title.strip.empty?
115
70
 
@@ -118,5 +73,59 @@ module CalInvite
118
73
  raise ArgumentError, "End time is required for non-all-day events" if end_time.nil?
119
74
  end
120
75
  end
76
+
77
+ def caching_enabled?
78
+ CalInvite.configuration &&
79
+ CalInvite.configuration.respond_to?(:cache_store) &&
80
+ CalInvite.configuration.cache_store
81
+ end
82
+
83
+ def cache_key_for(provider)
84
+ return nil unless caching_enabled?
85
+
86
+ attributes_hash = Digest::MD5.hexdigest(
87
+ [
88
+ title,
89
+ start_time&.to_i,
90
+ end_time&.to_i,
91
+ description,
92
+ location,
93
+ url,
94
+ attendees,
95
+ timezone,
96
+ show_attendees,
97
+ notes,
98
+ multi_day_sessions,
99
+ all_day,
100
+ provider
101
+ ].map(&:to_s).join('|')
102
+ )
103
+
104
+ "cal_invite:event:#{attributes_hash}"
105
+ end
106
+
107
+ def fetch_from_cache(key)
108
+ return nil unless key && caching_enabled?
109
+ CalInvite.configuration.cache_store.read(key)
110
+ end
111
+
112
+ def write_to_cache(key, value)
113
+ return unless key && caching_enabled?
114
+
115
+ expires_in = CalInvite.configuration&.cache_expires_in
116
+ CalInvite.configuration.cache_store.write(
117
+ key,
118
+ value,
119
+ expires_in: expires_in
120
+ )
121
+ end
122
+
123
+ def invalidate_cache
124
+ return unless caching_enabled?
125
+
126
+ if CalInvite.configuration.cache_store.respond_to?(:delete_matched)
127
+ CalInvite.configuration.cache_store.delete_matched("cal_invite:event:*")
128
+ end
129
+ end
121
130
  end
122
131
  end
@@ -1,68 +1,66 @@
1
- # frozen_string_literal: true
1
+ # app/lib/base_provider.rb
2
+ class BaseProvider
3
+ attr_reader :event
2
4
 
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
5
+ def initialize(event)
6
+ @event = event
7
+ end
8
+
9
+ def generate
10
+ raise NotImplementedError, "#{self.class} must implement #generate"
11
+ end
11
12
 
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
13
+ protected
18
14
 
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
15
+ def url_encode(str)
16
+ URI.encode_www_form_component(str.to_s)
17
+ end
18
+
19
+ # Format the event description
20
+ # @return [String, nil] The formatted description or nil if no content
21
+ def format_description
22
+ parts = []
23
+ parts << event.description if event.description
24
+ parts << "Notes: #{event.notes}" if event.notes
25
+ parts.join("\n\n")
26
+ end
27
27
 
28
- protected
28
+ # Get just the physical location
29
+ # @return [String, nil] The location or nil if not present
30
+ def format_location
31
+ event.location
32
+ end
29
33
 
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
34
+ # Get the URL for virtual meetings
35
+ # @return [String, nil] The URL or nil if not present
36
+ def format_url
37
+ event.url
38
+ end
37
39
 
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
40
+ # Format description including URL if present
41
+ # @return [String, nil] The formatted description with URL
42
+ def format_description_with_url
43
+ parts = []
44
+ parts << format_description if format_description
45
+ parts << "Virtual Meeting URL: #{format_url}" if format_url
46
+ parts.join("\n\n")
47
+ end
48
48
 
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
49
+ def add_optional_params(params)
50
+ params[:description] = url_encode(format_description_with_url) if format_description || format_url
51
+ params[:location] = url_encode(format_location) if format_location
58
52
 
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
53
+ if event.show_attendees && event.attendees&.any?
54
+ params[:attendees] = event.attendees.join(',')
66
55
  end
56
+
57
+ params
58
+ end
59
+
60
+ # Get the list of attendees if showing attendees is enabled
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
67
65
  end
68
66
  end
@@ -55,7 +55,11 @@ module CalInvite
55
55
  end
56
56
 
57
57
  def add_optional_params(params)
58
- params[:details] = url_encode(format_description) if format_description
58
+ description_parts = []
59
+ description_parts << format_description if format_description
60
+ description_parts << "Virtual Meeting URL: #{format_url}" if format_url
61
+ params[:details] = url_encode(description_parts.join("\n\n")) if description_parts.any?
62
+
59
63
  params[:location] = url_encode(format_location) if format_location
60
64
  params
61
65
  end
@@ -9,16 +9,19 @@ module CalInvite
9
9
  "BEGIN:VCALENDAR",
10
10
  "VERSION:2.0",
11
11
  "PRODID:-//CalInvite//Ruby//EN",
12
+ "CALSCALE:GREGORIAN",
13
+ "METHOD:PUBLISH",
14
+ generate_timezone,
12
15
  generate_events,
13
16
  "END:VCALENDAR"
14
- ].join("\n")
17
+ ].compact.join("\r\n")
15
18
  end
16
19
 
17
20
  private
18
21
 
19
22
  def generate_events
20
23
  if event.multi_day_sessions.any?
21
- event.multi_day_sessions.map { |session| generate_vevent(session) }.join("\n")
24
+ event.multi_day_sessions.map { |session| generate_vevent(session) }.join("\r\n")
22
25
  else
23
26
  generate_vevent
24
27
  end
@@ -40,38 +43,68 @@ module CalInvite
40
43
  raise ArgumentError, "Start time is required for non-all-day events" unless start_time
41
44
  raise ArgumentError, "End time is required for non-all-day events" unless end_time
42
45
 
43
- lines << "DTSTART:#{format_time(start_time)}"
44
- lines << "DTEND:#{format_time(end_time)}"
46
+ lines << "DTSTART;TZID=#{event.timezone}:#{format_local_time(start_time)}"
47
+ lines << "DTEND;TZID=#{event.timezone}:#{format_local_time(end_time)}"
45
48
  end
46
49
 
50
+ # Required fields
47
51
  lines.concat([
48
- "SUMMARY:#{event.title}",
49
- "UID:#{generate_uid}"
52
+ "SUMMARY:#{escape_text(event.title)}",
53
+ "UID:#{generate_uid}",
54
+ "DTSTAMP:#{format_timestamp(Time.now.utc)}"
50
55
  ])
51
56
 
52
- lines << "DESCRIPTION:#{format_description}" if format_description
53
- lines << "LOCATION:#{format_location}" if format_location
57
+ # Optional fields
58
+ lines << "DESCRIPTION:#{escape_text(format_description)}" if format_description
59
+ lines << "LOCATION:#{escape_text(format_location)}" if format_location
60
+ lines << "URL:#{escape_text(format_url)}" if format_url
54
61
 
62
+ # Attendees
55
63
  if attendees = attendees_list
56
64
  attendees.each do |attendee|
57
- lines << "ATTENDEE:mailto:#{attendee}"
65
+ lines << "ATTENDEE;RSVP=TRUE:mailto:#{attendee}"
58
66
  end
59
67
  end
60
68
 
61
69
  lines << "END:VEVENT"
62
- lines.join("\n")
70
+ lines.join("\r\n")
71
+ end
72
+
73
+ def generate_timezone
74
+ return nil if event.all_day # No timezone needed for all-day events
75
+
76
+ [
77
+ "BEGIN:VTIMEZONE",
78
+ "TZID:#{event.timezone}",
79
+ "END:VTIMEZONE"
80
+ ].join("\r\n")
63
81
  end
64
82
 
65
83
  def generate_uid
66
- SecureRandom.uuid
84
+ "#{Time.now.to_i}-#{SecureRandom.hex(8)}@cal-invite"
67
85
  end
68
86
 
69
87
  def format_date(time)
70
88
  time.strftime("%Y%m%d")
71
89
  end
72
90
 
73
- def format_time(time)
74
- time.utc.strftime("%Y%m%dT%H%M%SZ")
91
+ def format_local_time(time)
92
+ # All times are already in UTC, just format them
93
+ time.strftime("%Y%m%dT%H%M%S")
94
+ end
95
+
96
+ def format_timestamp(time)
97
+ time.strftime("%Y%m%dT%H%M%SZ")
98
+ end
99
+
100
+ def escape_text(text)
101
+ return '' if text.nil?
102
+
103
+ text.to_s
104
+ .gsub('\\', '\\\\')
105
+ .gsub("\n", '\\n')
106
+ .gsub(',', '\\,')
107
+ .gsub(';', '\\;')
75
108
  end
76
109
  end
77
110
  end
@@ -13,8 +13,9 @@ module CalInvite
13
13
  "METHOD:PUBLISH"
14
14
  ]
15
15
 
16
- # Add events (either single or multiple sessions)
17
- if event.multi_day_sessions.any?
16
+ if event.all_day
17
+ calendar_lines.concat(generate_all_day_event)
18
+ elsif event.multi_day_sessions.any?
18
19
  event.multi_day_sessions.each do |session|
19
20
  calendar_lines.concat(generate_vevent(session[:start_time], session[:end_time]))
20
21
  end
@@ -28,47 +29,70 @@ module CalInvite
28
29
 
29
30
  private
30
31
 
32
+ def generate_all_day_event
33
+ vevent = [
34
+ "BEGIN:VEVENT",
35
+ "UID:#{generate_uid}",
36
+ "DTSTAMP:#{format_timestamp(Time.now.utc)}",
37
+ "DTSTART;VALUE=DATE:#{format_date(event.start_time)}",
38
+ "DTEND;VALUE=DATE:#{format_date(event.end_time)}",
39
+ "SUMMARY:#{escape_text(event.title)}"
40
+ ]
41
+
42
+ add_optional_fields(vevent)
43
+ vevent << "END:VEVENT"
44
+ vevent
45
+ end
46
+
31
47
  def generate_vevent(start_time, end_time)
32
48
  vevent = [
33
49
  "BEGIN:VEVENT",
34
50
  "UID:#{generate_uid}",
35
- "DTSTAMP:#{format_timestamp(Time.now)}",
51
+ "DTSTAMP:#{format_timestamp(Time.now.utc)}",
36
52
  "DTSTART;TZID=#{event.timezone}:#{format_local_timestamp(start_time)}",
37
53
  "DTEND;TZID=#{event.timezone}:#{format_local_timestamp(end_time)}",
38
54
  "SUMMARY:#{escape_text(event.title)}"
39
55
  ]
40
56
 
41
- # Add description (including notes if present)
42
- if desc = format_description
43
- vevent << "DESCRIPTION:#{escape_text(desc)}"
57
+ add_optional_fields(vevent)
58
+ vevent << "END:VEVENT"
59
+ vevent
60
+ end
61
+
62
+ def add_optional_fields(vevent)
63
+ description_parts = []
64
+ description_parts << format_description if format_description
65
+
66
+ if description_parts.any?
67
+ vevent << "DESCRIPTION:#{escape_text(description_parts.join('\n\n'))}"
44
68
  end
45
69
 
46
- # Add location and/or URL
47
- if event.location
48
- vevent << "LOCATION:#{escape_text(event.location)}"
70
+ if location = format_location
71
+ vevent << "LOCATION:#{escape_text(location)}"
49
72
  end
50
73
 
51
- if event.url
52
- vevent << "URL:#{escape_text(event.url)}"
74
+ if url = format_url
75
+ vevent << "URL:#{escape_text(url)}"
53
76
  end
54
77
 
55
- # Add attendees if showing is enabled
56
78
  if attendees_list.any?
57
79
  attendees_list.each do |attendee|
58
80
  vevent << "ATTENDEE;RSVP=TRUE:mailto:#{attendee}"
59
81
  end
60
82
  end
61
-
62
- vevent << "END:VEVENT"
63
- vevent
64
83
  end
65
84
 
66
85
  def format_timestamp(time)
67
86
  time.utc.strftime("%Y%m%dT%H%M%SZ")
68
87
  end
69
88
 
89
+ def format_date(time)
90
+ time.strftime("%Y%m%d")
91
+ end
92
+
70
93
  def format_local_timestamp(time)
71
- event.localize_time(time).strftime("%Y%m%dT%H%M%S")
94
+ # All times are already in UTC, just format them
95
+ time.strftime("%Y%m%dT%H%M%S")
72
96
  end
73
97
 
74
98
  def generate_uid
@@ -77,13 +101,12 @@ module CalInvite
77
101
 
78
102
  def escape_text(text)
79
103
  return '' if text.nil?
80
-
81
104
  text.to_s
82
105
  .gsub('\\', '\\\\')
83
106
  .gsub("\n", '\\n')
84
107
  .gsub(',', '\\,')
85
108
  .gsub(';', '\\;')
86
- end
109
+ end
87
110
  end
88
111
  end
89
112
  end
@@ -0,0 +1,111 @@
1
+ # lib/cal_invite/providers/ics_content.rb
2
+ module CalInvite
3
+ module Providers
4
+ class IcsContent < BaseProvider
5
+ def generate
6
+ calendar_lines = [
7
+ "BEGIN:VCALENDAR",
8
+ "VERSION:2.0",
9
+ "PRODID:-//CalInvite//EN",
10
+ "CALSCALE:GREGORIAN",
11
+ "METHOD:PUBLISH"
12
+ ]
13
+
14
+ if event.multi_day_sessions.any?
15
+ event.multi_day_sessions.each do |session|
16
+ calendar_lines.concat(generate_vevent(session[:start_time], session[:end_time]))
17
+ end
18
+ else
19
+ calendar_lines.concat(generate_vevent(event.start_time, event.end_time))
20
+ end
21
+
22
+ calendar_lines << "END:VCALENDAR"
23
+ calendar_lines.join("\r\n")
24
+ end
25
+
26
+ private
27
+
28
+ def generate_vevent(start_time, end_time)
29
+ [
30
+ "BEGIN:VEVENT",
31
+ "UID:#{generate_uid}",
32
+ "DTSTAMP:#{format_timestamp(Time.now.utc)}",
33
+ "DTSTART:#{format_timestamp(start_time)}",
34
+ "DTEND:#{format_timestamp(end_time)}",
35
+ "SUMMARY:#{escape_text(event.title)}",
36
+ description_line,
37
+ location_line,
38
+ url_line,
39
+ attendee_lines,
40
+ "END:VEVENT"
41
+ ].compact
42
+ end
43
+
44
+ def format_timestamp(time)
45
+ time.utc.strftime("%Y%m%dT%H%M%SZ")
46
+ end
47
+
48
+ def generate_uid
49
+ "#{Time.now.to_i}-#{SecureRandom.hex(8)}@cal-invite"
50
+ end
51
+
52
+ def escape_text(text)
53
+ return '' if text.nil?
54
+ text.to_s
55
+ .gsub('\\', '\\\\')
56
+ .gsub("\n", '\\n')
57
+ .gsub(',', '\\,')
58
+ .gsub(';', '\\;')
59
+ end
60
+
61
+ def description_line
62
+ return nil unless event.description
63
+ "DESCRIPTION:#{escape_text(event.description)}"
64
+ end
65
+
66
+ def location_line
67
+ return nil unless event.location
68
+ "LOCATION:#{escape_text(event.location)}"
69
+ end
70
+
71
+ def url_line
72
+ return nil unless event.url
73
+ "URL:#{escape_text(event.url)}"
74
+ end
75
+
76
+ def attendee_lines
77
+ return nil unless event.show_attendees && event.attendees&.any?
78
+ event.attendees.map { |attendee| "ATTENDEE;RSVP=TRUE:mailto:#{attendee}" }
79
+ end
80
+ end
81
+
82
+ # Optional download wrapper
83
+ module IcsDownload
84
+ def self.headers(filename)
85
+ {
86
+ 'Content-Type' => 'text/calendar; charset=UTF-8',
87
+ 'Content-Disposition' => "attachment; filename=#{sanitize_filename(filename)}"
88
+ }
89
+ end
90
+
91
+ def self.sanitize_filename(filename)
92
+ filename.gsub(/[^0-9A-Za-z.\-]/, '_')
93
+ end
94
+
95
+ def self.wrap_for_download(content, title)
96
+ filename = sanitize_filename("#{title.downcase}_#{Time.now.strftime('%Y%m%d')}.ics")
97
+ {
98
+ content: content,
99
+ headers: headers(filename)
100
+ }
101
+ end
102
+ end
103
+
104
+ # For compatibility, keep the original classes but inherit from IcsContent
105
+ class Ics < IcsContent
106
+ end
107
+
108
+ class Ical < IcsContent
109
+ end
110
+ end
111
+ end
@@ -53,17 +53,17 @@ module CalInvite
53
53
  end
54
54
 
55
55
  def format_time(time)
56
- time.utc.strftime('%Y-%m-%dT%H:%M:%S')
56
+ # Always use UTC format, timezone is handled by the calendar
57
+ time.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
57
58
  end
58
59
 
59
60
  def add_optional_params(params)
60
- if description = format_description
61
- params[:body] = url_encode(description)
62
- end
61
+ description_parts = []
62
+ description_parts << format_description if format_description
63
+ description_parts << "Virtual Meeting URL: #{format_url}" if format_url
64
+ params[:body] = url_encode(description_parts.join("\n\n")) if description_parts.any?
63
65
 
64
- if location = format_location
65
- params[:location] = url_encode(location)
66
- end
66
+ params[:location] = url_encode(format_location) if format_location
67
67
 
68
68
  if attendees = attendees_list
69
69
  params[:to] = url_encode(attendees.join(';'))