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.
- checksums.yaml +4 -4
- data/CACHING.md +148 -0
- data/CHANGELOG.md +22 -3
- data/README.md +159 -19
- data/lib/cal_invite/caching.rb +74 -0
- data/lib/cal_invite/configuration.rb +39 -3
- data/lib/cal_invite/event.rb +96 -14
- data/lib/cal_invite/providers/base_provider.rb +63 -41
- data/lib/cal_invite/providers/google.rb +43 -36
- data/lib/cal_invite/providers/ical.rb +70 -28
- 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 +81 -0
- data/lib/cal_invite/providers/outlook.rb +51 -37
- data/lib/cal_invite/providers/yahoo.rb +40 -12
- data/lib/cal_invite/providers.rb +6 -2
- data/lib/cal_invite/version.rb +1 -1
- data/lib/cal_invite.rb +10 -0
- metadata +24 -7
data/lib/cal_invite/event.rb
CHANGED
@@ -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
|
29
|
-
|
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
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
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
|
-
|
41
|
-
|
42
|
-
return [@start_time, @end_time] if multi_day_sessions.empty?
|
123
|
+
def invalidate_cache
|
124
|
+
return unless caching_enabled?
|
43
125
|
|
44
|
-
|
45
|
-
|
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
|
-
#
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
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.
|
11
|
-
|
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
|
17
|
+
def generate_all_day_event
|
20
18
|
params = {
|
21
|
-
action:
|
22
|
-
text: event.title,
|
23
|
-
dates:
|
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
|
-
|
30
|
-
|
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
|
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:
|
44
|
-
text: event.title,
|
45
|
-
dates:
|
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
|
-
|
52
|
-
|
53
|
-
|
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
|
-
|
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
|
59
|
-
|
60
|
-
|
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
|
-
|
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
|
-
].
|
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
|
24
|
+
event.multi_day_sessions.map { |session| generate_vevent(session) }.join("\r\n")
|
22
25
|
else
|
23
|
-
|
26
|
+
generate_vevent
|
24
27
|
end
|
25
28
|
end
|
26
29
|
|
27
|
-
def generate_vevent(
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
"
|
35
|
-
"
|
36
|
-
|
37
|
-
|
38
|
-
|
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
|
-
|
43
|
-
|
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
|
-
|
47
|
-
|
48
|
-
|
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
|
-
|
53
|
-
|
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
|
61
|
-
|
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
|
-
|
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
|
-
|
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
|