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.
- checksums.yaml +4 -4
- data/CACHING.md +148 -0
- data/CHANGELOG.md +8 -0
- data/README.md +148 -17
- data/lib/cal_invite/caching.rb +74 -0
- data/lib/cal_invite/configuration.rb +39 -3
- data/lib/cal_invite/event.rb +76 -67
- data/lib/cal_invite/providers/base_provider.rb +55 -57
- data/lib/cal_invite/providers/google.rb +5 -1
- data/lib/cal_invite/providers/ical.rb +46 -13
- data/lib/cal_invite/providers/ics.rb +41 -18
- data/lib/cal_invite/providers/ics_content.rb +111 -0
- data/lib/cal_invite/providers/office365.rb +7 -7
- data/lib/cal_invite/providers/outlook.rb +8 -5
- data/lib/cal_invite/providers/yahoo.rb +7 -4
- data/lib/cal_invite/providers.rb +4 -2
- data/lib/cal_invite/version.rb +1 -1
- data/lib/cal_invite.rb +6 -0
- metadata +20 -3
data/lib/cal_invite/event.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
88
|
-
[session[:start_time], session[:end_time]]
|
89
|
-
end
|
50
|
+
url
|
90
51
|
end
|
91
52
|
|
92
|
-
|
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
|
-
|
95
|
-
|
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
|
-
|
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
|
-
#
|
1
|
+
# app/lib/base_provider.rb
|
2
|
+
class BaseProvider
|
3
|
+
attr_reader :event
|
2
4
|
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
class
|
9
|
-
|
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
|
-
|
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
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
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
|
-
|
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
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
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
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
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
|
-
|
50
|
-
|
51
|
-
|
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
|
-
|
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
|
-
|
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:#{
|
44
|
-
lines << "DTEND:#{
|
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
|
-
|
53
|
-
lines << "
|
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.
|
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
|
74
|
-
|
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
|
-
|
17
|
-
|
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
|
-
|
42
|
-
|
43
|
-
|
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
|
-
|
47
|
-
|
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
|
52
|
-
vevent << "URL:#{escape_text(
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
61
|
-
|
62
|
-
|
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
|
-
|
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(';'))
|