cal-invite 0.1.1 → 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,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
-
3
2
  # lib/cal_invite/event.rb
3
+ require 'digest'
4
+
4
5
  module CalInvite
5
6
  class Event
6
7
  attr_accessor :title,
@@ -13,36 +14,117 @@ module CalInvite
13
14
  :timezone,
14
15
  :show_attendees,
15
16
  :notes,
16
- :multi_day_sessions
17
+ :multi_day_sessions,
18
+ :all_day
17
19
 
18
20
  def initialize(attributes = {})
19
21
  @show_attendees = attributes.delete(:show_attendees) || false
20
22
  @timezone = attributes.delete(:timezone) || 'UTC'
21
23
  @multi_day_sessions = attributes.delete(:multi_day_sessions) || []
24
+ @all_day = attributes.delete(:all_day) || false
22
25
 
23
26
  attributes.each do |key, value|
24
27
  send("#{key}=", value) if respond_to?("#{key}=")
25
28
  end
29
+
30
+ validate!
26
31
  end
27
32
 
28
- def calendar_url(provider)
29
- provider_class = CalInvite::Providers.const_get(provider.to_s.camelize)
33
+ def generate_calendar_url(provider)
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
43
+ provider_class = CalInvite::Providers.const_get(capitalize_provider(provider.to_s))
30
44
  generator = provider_class.new(self)
31
- generator.generate
45
+ url = generator.generate
46
+
47
+ # Cache the result if caching is enabled
48
+ write_to_cache(cache_key, url) if caching_enabled?
49
+
50
+ url
32
51
  end
33
52
 
34
- # Convert a UTC time to event's timezone
35
- def localize_time(time)
36
- return time unless time
37
- time.in_time_zone(timezone)
53
+ def update_attributes(new_attributes)
54
+ new_attributes.each do |key, value|
55
+ send("#{key}=", value) if respond_to?("#{key}=")
56
+ end
57
+
58
+ invalidate_cache if caching_enabled?
59
+ validate!
60
+ end
61
+
62
+ private
63
+
64
+ def capitalize_provider(string)
65
+ string.split('_').map(&:capitalize).join
66
+ end
67
+
68
+ def validate!
69
+ raise ArgumentError, "Title is required" if title.nil? || title.strip.empty?
70
+
71
+ unless all_day
72
+ raise ArgumentError, "Start time is required for non-all-day events" if start_time.nil?
73
+ raise ArgumentError, "End time is required for non-all-day events" if end_time.nil?
74
+ end
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
+ )
38
121
  end
39
122
 
40
- # Get all event sessions (including multi-day)
41
- def sessions
42
- return [@start_time, @end_time] if multi_day_sessions.empty?
123
+ def invalidate_cache
124
+ return unless caching_enabled?
43
125
 
44
- multi_day_sessions.map do |session|
45
- [session[:start_time], session[:end_time]]
126
+ if CalInvite.configuration.cache_store.respond_to?(:delete_matched)
127
+ CalInvite.configuration.cache_store.delete_matched("cal_invite:event:*")
46
128
  end
47
129
  end
48
130
  end
@@ -1,44 +1,66 @@
1
- # frozen_string_literal: true
2
-
3
- # lib/cal_invite/providers/base_provider.rb
4
- module CalInvite
5
- module Providers
6
- class BaseProvider
7
- attr_reader :event
8
-
9
- def initialize(event)
10
- @event = event
11
- end
12
-
13
- def generate
14
- raise NotImplementedError, "#{self.class} must implement #generate"
15
- end
16
-
17
- protected
18
-
19
- def url_encode(str)
20
- URI.encode_www_form_component(str.to_s)
21
- end
22
-
23
- def format_description
24
- parts = []
25
- parts << event.description if event.description
26
- parts << "Notes: #{event.notes}" if event.notes
27
- parts << "URL: #{event.url}" if event.url
28
- parts.join("\n\n")
29
- end
30
-
31
- def format_location
32
- return event.url if event.url && !event.location
33
- return event.location if event.location && !event.url
34
- return "#{event.location}\n#{event.url}" if event.location && event.url
35
- nil
36
- end
37
-
38
- def attendees_list
39
- return [] unless event.show_attendees && event.attendees&.any?
40
- event.attendees
41
- end
1
+ # app/lib/base_provider.rb
2
+ class BaseProvider
3
+ attr_reader :event
4
+
5
+ def initialize(event)
6
+ @event = event
7
+ end
8
+
9
+ def generate
10
+ raise NotImplementedError, "#{self.class} must implement #generate"
11
+ end
12
+
13
+ protected
14
+
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
+
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
33
+
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
39
+
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
+
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
52
+
53
+ if event.show_attendees && event.attendees&.any?
54
+ params[:attendees] = event.attendees.join(',')
42
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
43
65
  end
44
66
  end
@@ -4,11 +4,9 @@
4
4
  module CalInvite
5
5
  module Providers
6
6
  class Google < BaseProvider
7
- BASE_URL = "https://calendar.google.com/calendar/render"
8
-
9
7
  def generate
10
- if event.multi_day_sessions.any?
11
- generate_multi_day_event
8
+ if event.all_day
9
+ generate_all_day_event
12
10
  else
13
11
  generate_single_event
14
12
  end
@@ -16,50 +14,59 @@ module CalInvite
16
14
 
17
15
  private
18
16
 
19
- def generate_single_event
17
+ def generate_all_day_event
20
18
  params = {
21
- action: "TEMPLATE",
22
- text: event.title,
23
- dates: format_dates(event.start_time, event.end_time),
24
- details: format_description,
25
- location: format_location,
26
- ctz: event.timezone
19
+ action: 'TEMPLATE',
20
+ text: url_encode(event.title),
21
+ dates: format_all_day_dates
27
22
  }
28
23
 
29
- if attendees_list.any?
30
- params[:add] = attendees_list.join(',')
31
- end
32
-
33
- "#{BASE_URL}?#{URI.encode_www_form(params)}"
24
+ add_optional_params(params)
25
+ build_url(params)
34
26
  end
35
27
 
36
- def generate_multi_day_event
37
- # For multi-day events, Google Calendar supports recurring events
38
- sessions = event.multi_day_sessions.map do |session|
39
- format_dates(session[:start_time], session[:end_time])
40
- end
41
-
28
+ def generate_single_event
42
29
  params = {
43
- action: "TEMPLATE",
44
- text: event.title,
45
- dates: sessions.join(','),
46
- details: format_description,
47
- location: format_location,
48
- ctz: event.timezone
30
+ action: 'TEMPLATE',
31
+ text: url_encode(event.title),
32
+ dates: format_dates
49
33
  }
50
34
 
51
- if attendees_list.any?
52
- params[:add] = attendees_list.join(',')
53
- end
35
+ add_optional_params(params)
36
+ build_url(params)
37
+ end
38
+
39
+ def format_all_day_dates
40
+ # For all-day events, use current date if no start_time specified
41
+ start_date = event.start_time || Time.now
42
+ end_date = event.end_time || (start_date + 86400) # Add one day if no end_time
43
+
44
+ "#{start_date.strftime('%Y%m%d')}/#{end_date.strftime('%Y%m%d')}"
45
+ end
54
46
 
55
- "#{BASE_URL}?#{URI.encode_www_form(params)}"
47
+ def format_dates
48
+ raise ArgumentError, "Start time is required" unless event.start_time
49
+ raise ArgumentError, "End time is required" unless event.end_time
50
+
51
+ start_time = event.start_time
52
+ end_time = event.end_time
53
+
54
+ "#{start_time.utc.strftime('%Y%m%dT%H%M%SZ')}/#{end_time.utc.strftime('%Y%m%dT%H%M%SZ')}"
56
55
  end
57
56
 
58
- def format_dates(start_time, end_time)
59
- start_time = event.localize_time(start_time)
60
- end_time = event.localize_time(end_time)
57
+ def add_optional_params(params)
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
+
63
+ params[:location] = url_encode(format_location) if format_location
64
+ params
65
+ end
61
66
 
62
- "#{start_time.strftime('%Y%m%dT%H%M%S')}/#{end_time.strftime('%Y%m%dT%H%M%S')}"
67
+ def build_url(params)
68
+ query = params.map { |k, v| "#{k}=#{v}" }.join('&')
69
+ "https://calendar.google.com/calendar/render?#{query}"
63
70
  end
64
71
  end
65
72
  end
@@ -8,61 +8,103 @@ module CalInvite
8
8
  [
9
9
  "BEGIN:VCALENDAR",
10
10
  "VERSION:2.0",
11
- "PRODID:-//CalInvite//EN",
11
+ "PRODID:-//CalInvite//Ruby//EN",
12
+ "CALSCALE:GREGORIAN",
13
+ "METHOD:PUBLISH",
14
+ generate_timezone,
12
15
  generate_events,
13
16
  "END:VCALENDAR"
14
- ].flatten.join("\r\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[:start_time], session[:end_time]) }
24
+ event.multi_day_sessions.map { |session| generate_vevent(session) }.join("\r\n")
22
25
  else
23
- [generate_vevent(event.start_time, event.end_time)]
26
+ generate_vevent
24
27
  end
25
28
  end
26
29
 
27
- def generate_vevent(start_time, end_time)
28
- vevent = [
29
- "BEGIN:VEVENT",
30
- "UID:#{generate_uid}",
31
- "DTSTAMP:#{format_time(Time.now)}",
32
- "DTSTART;TZID=#{event.timezone}:#{format_time(start_time)}",
33
- "DTEND;TZID=#{event.timezone}:#{format_time(end_time)}",
34
- "SUMMARY:#{event.title}",
35
- "DESCRIPTION:#{format_description}",
36
- ]
37
-
38
- if format_location
39
- vevent << "LOCATION:#{format_location}"
40
- end
30
+ def generate_vevent(session = nil)
31
+ lines = ["BEGIN:VEVENT"]
32
+
33
+ if event.all_day
34
+ start_date = event.start_time || Time.now
35
+ end_date = event.end_time || (start_date + 86400)
36
+
37
+ lines << "DTSTART;VALUE=DATE:#{format_date(start_date)}"
38
+ lines << "DTEND;VALUE=DATE:#{format_date(end_date)}"
39
+ else
40
+ start_time = session ? session[:start_time] : event.start_time
41
+ end_time = session ? session[:end_time] : event.end_time
41
42
 
42
- if event.url
43
- vevent << "URL:#{event.url}"
43
+ raise ArgumentError, "Start time is required for non-all-day events" unless start_time
44
+ raise ArgumentError, "End time is required for non-all-day events" unless end_time
45
+
46
+ lines << "DTSTART;TZID=#{event.timezone}:#{format_local_time(start_time)}"
47
+ lines << "DTEND;TZID=#{event.timezone}:#{format_local_time(end_time)}"
44
48
  end
45
49
 
46
- if attendees_list.any?
47
- attendees_list.each do |attendee|
48
- vevent << "ATTENDEE;RSVP=TRUE:mailto:#{attendee}"
50
+ # Required fields
51
+ lines.concat([
52
+ "SUMMARY:#{escape_text(event.title)}",
53
+ "UID:#{generate_uid}",
54
+ "DTSTAMP:#{format_timestamp(Time.now.utc)}"
55
+ ])
56
+
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
61
+
62
+ # Attendees
63
+ if attendees = attendees_list
64
+ attendees.each do |attendee|
65
+ lines << "ATTENDEE;RSVP=TRUE:mailto:#{attendee}"
49
66
  end
50
67
  end
51
68
 
52
- vevent << "END:VEVENT"
53
- vevent
69
+ lines << "END:VEVENT"
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")
54
81
  end
55
82
 
56
83
  def generate_uid
57
84
  "#{Time.now.to_i}-#{SecureRandom.hex(8)}@cal-invite"
58
85
  end
59
86
 
60
- def format_time(time)
61
- event.localize_time(time).strftime("%Y%m%dT%H%M%S")
87
+ def format_date(time)
88
+ time.strftime("%Y%m%d")
89
+ end
90
+
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")
62
98
  end
63
99
 
64
100
  def escape_text(text)
65
- text.to_s.gsub(/[,;\\]/) { |match| "\\#{match}" }
101
+ return '' if text.nil?
102
+
103
+ text.to_s
104
+ .gsub('\\', '\\\\')
105
+ .gsub("\n", '\\n')
106
+ .gsub(',', '\\,')
107
+ .gsub(';', '\\;')
66
108
  end
67
109
  end
68
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