cal-invite 0.1.4 → 0.2.0
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/.rdoc_options +5 -1
- data/AGENTS.md +47 -0
- data/CHANGELOG.md +34 -1
- data/CLAUDE.md +49 -0
- data/CONFIGURATION.md +536 -0
- data/README.md +18 -7
- data/SECURITY.md +21 -0
- data/gemfiles/Gemfile.rails6 +3 -0
- data/gemfiles/Gemfile.rails7 +3 -0
- data/gemfiles/Gemfile.rails8 +3 -0
- data/lib/cal_invite/event.rb +103 -9
- data/lib/cal_invite/ical_timezone.rb +132 -0
- data/lib/cal_invite/providers/base_provider.rb +188 -4
- data/lib/cal_invite/providers/ical.rb +23 -24
- data/lib/cal_invite/providers/ics.rb +30 -18
- data/lib/cal_invite/providers/ics_content.rb +33 -22
- data/lib/cal_invite/providers/office365.rb +2 -2
- data/lib/cal_invite/providers/outlook.rb +2 -2
- data/lib/cal_invite/version.rb +1 -1
- data/lib/cal_invite.rb +4 -0
- metadata +69 -8
|
@@ -46,9 +46,15 @@ module CalInvite
|
|
|
46
46
|
"VERSION:2.0",
|
|
47
47
|
"PRODID:-//CalInvite//EN",
|
|
48
48
|
"CALSCALE:GREGORIAN",
|
|
49
|
-
"METHOD
|
|
49
|
+
"METHOD:#{method_value}"
|
|
50
50
|
]
|
|
51
51
|
|
|
52
|
+
calendar_lines << "X-WR-CALNAME:#{escape_text(event.calendar_name)}" if event.calendar_name
|
|
53
|
+
|
|
54
|
+
unless event.all_day
|
|
55
|
+
calendar_lines.concat(vtimezone_lines || [])
|
|
56
|
+
end
|
|
57
|
+
|
|
52
58
|
if event.all_day
|
|
53
59
|
calendar_lines.concat(generate_all_day_event)
|
|
54
60
|
elsif event.multi_day_sessions.any?
|
|
@@ -71,7 +77,7 @@ module CalInvite
|
|
|
71
77
|
def generate_all_day_event
|
|
72
78
|
vevent = [
|
|
73
79
|
"BEGIN:VEVENT",
|
|
74
|
-
"UID:#{
|
|
80
|
+
"UID:#{event.uid}",
|
|
75
81
|
"DTSTAMP:#{format_timestamp(Time.now.utc)}",
|
|
76
82
|
"DTSTART;VALUE=DATE:#{format_date(event.start_time)}",
|
|
77
83
|
"DTEND;VALUE=DATE:#{format_date(event.end_time)}",
|
|
@@ -90,7 +96,7 @@ module CalInvite
|
|
|
90
96
|
def generate_vevent(start_time, end_time)
|
|
91
97
|
vevent = [
|
|
92
98
|
"BEGIN:VEVENT",
|
|
93
|
-
"UID:#{
|
|
99
|
+
"UID:#{event.uid}",
|
|
94
100
|
"DTSTAMP:#{format_timestamp(Time.now.utc)}",
|
|
95
101
|
"DTSTART;TZID=#{event.timezone}:#{format_local_timestamp(start_time)}",
|
|
96
102
|
"DTEND;TZID=#{event.timezone}:#{format_local_timestamp(end_time)}",
|
|
@@ -102,7 +108,8 @@ module CalInvite
|
|
|
102
108
|
end
|
|
103
109
|
|
|
104
110
|
# Adds optional fields to the event component if they exist.
|
|
105
|
-
# Handles description, location, URL,
|
|
111
|
+
# Handles description, location, URL, geo, organizer, attendees, recurrence,
|
|
112
|
+
# visibility/busy status, and reminders.
|
|
106
113
|
#
|
|
107
114
|
# @param vevent [Array<String>] The current event lines array
|
|
108
115
|
# @return [void]
|
|
@@ -121,11 +128,23 @@ module CalInvite
|
|
|
121
128
|
vevent << "URL:#{escape_text(url)}"
|
|
122
129
|
end
|
|
123
130
|
|
|
124
|
-
if
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
131
|
+
vevent << geo_line if geo_line
|
|
132
|
+
|
|
133
|
+
if organizer = organizer_line
|
|
134
|
+
vevent << organizer
|
|
128
135
|
end
|
|
136
|
+
|
|
137
|
+
attendees_list.each { |attendee| vevent << attendee_line(attendee) }
|
|
138
|
+
|
|
139
|
+
vevent << rrule_line if rrule_line
|
|
140
|
+
vevent << "SEQUENCE:#{event.sequence}"
|
|
141
|
+
vevent << status_line
|
|
142
|
+
vevent << transp_line
|
|
143
|
+
vevent << busystatus_line
|
|
144
|
+
vevent << class_line
|
|
145
|
+
vevent.concat(importance_lines)
|
|
146
|
+
vevent << disallow_counter_line if disallow_counter_line
|
|
147
|
+
vevent.concat(valarm_lines)
|
|
129
148
|
end
|
|
130
149
|
|
|
131
150
|
# Formats a time object as an UTC timestamp in iCalendar format.
|
|
@@ -145,20 +164,13 @@ module CalInvite
|
|
|
145
164
|
end
|
|
146
165
|
|
|
147
166
|
# Formats a time object as a local timestamp in iCalendar format.
|
|
148
|
-
#
|
|
167
|
+
# Converts from UTC to the event's timezone; times are expected to be in
|
|
168
|
+
# UTC already.
|
|
149
169
|
#
|
|
150
170
|
# @param time [Time] The time to format
|
|
151
171
|
# @return [String] The formatted local time (YYYYMMDDTHHmmSS)
|
|
152
172
|
def format_local_timestamp(time)
|
|
153
|
-
time.strftime("%Y%m%dT%H%M%S")
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
# Generates a unique identifier for the calendar event.
|
|
157
|
-
# Format: timestamp-randomhex@cal-invite
|
|
158
|
-
#
|
|
159
|
-
# @return [String] The generated UID
|
|
160
|
-
def generate_uid
|
|
161
|
-
"#{Time.now.to_i}-#{SecureRandom.hex(8)}@cal-invite"
|
|
173
|
+
local_wall_time(time).strftime("%Y%m%dT%H%M%S")
|
|
162
174
|
end
|
|
163
175
|
|
|
164
176
|
# Escapes special characters in text according to iCalendar spec.
|
|
@@ -36,9 +36,11 @@ module CalInvite
|
|
|
36
36
|
"VERSION:2.0",
|
|
37
37
|
"PRODID:-//CalInvite//EN",
|
|
38
38
|
"CALSCALE:GREGORIAN",
|
|
39
|
-
"METHOD
|
|
39
|
+
"METHOD:#{method_value}"
|
|
40
40
|
]
|
|
41
41
|
|
|
42
|
+
calendar_lines << "X-WR-CALNAME:#{escape_text(event.calendar_name)}" if event.calendar_name
|
|
43
|
+
|
|
42
44
|
if event.multi_day_sessions.any?
|
|
43
45
|
event.multi_day_sessions.each do |session|
|
|
44
46
|
calendar_lines.concat(generate_vevent(session[:start_time], session[:end_time]))
|
|
@@ -61,7 +63,7 @@ module CalInvite
|
|
|
61
63
|
def generate_vevent(start_time, end_time)
|
|
62
64
|
[
|
|
63
65
|
"BEGIN:VEVENT",
|
|
64
|
-
"UID:#{
|
|
66
|
+
"UID:#{event.uid}",
|
|
65
67
|
"DTSTAMP:#{format_timestamp(Time.now.utc)}",
|
|
66
68
|
"DTSTART:#{format_timestamp(start_time)}",
|
|
67
69
|
"DTEND:#{format_timestamp(end_time)}",
|
|
@@ -69,7 +71,18 @@ module CalInvite
|
|
|
69
71
|
description_line,
|
|
70
72
|
location_line,
|
|
71
73
|
url_line,
|
|
74
|
+
geo_line,
|
|
75
|
+
organizer_line,
|
|
72
76
|
attendee_lines,
|
|
77
|
+
rrule_line,
|
|
78
|
+
"SEQUENCE:#{event.sequence}",
|
|
79
|
+
status_line,
|
|
80
|
+
transp_line,
|
|
81
|
+
busystatus_line,
|
|
82
|
+
class_line,
|
|
83
|
+
importance_lines,
|
|
84
|
+
disallow_counter_line,
|
|
85
|
+
valarm_lines,
|
|
73
86
|
"END:VEVENT"
|
|
74
87
|
].compact
|
|
75
88
|
end
|
|
@@ -82,14 +95,6 @@ module CalInvite
|
|
|
82
95
|
time.utc.strftime("%Y%m%dT%H%M%SZ")
|
|
83
96
|
end
|
|
84
97
|
|
|
85
|
-
# Generates a unique identifier for the calendar event.
|
|
86
|
-
# Format: timestamp-randomhex@cal-invite
|
|
87
|
-
#
|
|
88
|
-
# @return [String] The generated UID
|
|
89
|
-
def generate_uid
|
|
90
|
-
"#{Time.now.to_i}-#{SecureRandom.hex(8)}@cal-invite"
|
|
91
|
-
end
|
|
92
|
-
|
|
93
98
|
# Escapes special characters in text according to iCalendar spec.
|
|
94
99
|
#
|
|
95
100
|
# @param text [String, nil] The text to escape
|
|
@@ -131,8 +136,8 @@ module CalInvite
|
|
|
131
136
|
#
|
|
132
137
|
# @return [Array<String>, nil] Array of ATTENDEE lines, or nil if no attendees or not showing
|
|
133
138
|
def attendee_lines
|
|
134
|
-
return nil
|
|
135
|
-
|
|
139
|
+
return nil if attendees_list.empty?
|
|
140
|
+
attendees_list.map { |attendee| attendee_line(attendee) }
|
|
136
141
|
end
|
|
137
142
|
end
|
|
138
143
|
|
|
@@ -142,10 +147,19 @@ module CalInvite
|
|
|
142
147
|
# Generates appropriate HTTP headers for ICS file download.
|
|
143
148
|
#
|
|
144
149
|
# @param filename [String] The desired filename for the download
|
|
150
|
+
# @param method [Symbol, nil] The iCalendar METHOD used to generate the content
|
|
151
|
+
# (:publish or :request). When given, it's added as a `method` parameter on
|
|
152
|
+
# the Content-Type header — this is what lets mail clients (Gmail, Outlook,
|
|
153
|
+
# Apple Mail) recognize the attachment as a meeting invite and render RSVP
|
|
154
|
+
# actions instead of a plain file attachment. Must match the METHOD in the
|
|
155
|
+
# .ics content itself.
|
|
145
156
|
# @return [Hash] HTTP headers for the ICS file download
|
|
146
|
-
def self.headers(filename)
|
|
157
|
+
def self.headers(filename, method: nil)
|
|
158
|
+
content_type = 'text/calendar; charset=UTF-8'
|
|
159
|
+
content_type += "; method=#{method == :decline_counter ? 'DECLINECOUNTER' : method.to_s.upcase}" if method
|
|
160
|
+
|
|
147
161
|
{
|
|
148
|
-
'Content-Type' =>
|
|
162
|
+
'Content-Type' => content_type,
|
|
149
163
|
'Content-Disposition' => "attachment; filename=#{sanitize_filename(filename)}"
|
|
150
164
|
}
|
|
151
165
|
end
|
|
@@ -162,23 +176,20 @@ module CalInvite
|
|
|
162
176
|
#
|
|
163
177
|
# @param content [String] The ICS calendar content
|
|
164
178
|
# @param title [String] The event title to use in the filename
|
|
179
|
+
# @param method [Symbol, nil] The iCalendar METHOD used to generate `content`
|
|
180
|
+
# (:publish or :request); forwarded to {headers}. Must match the METHOD
|
|
181
|
+
# used when generating `content` itself.
|
|
165
182
|
# @return [Hash] Hash containing content and headers for download
|
|
166
183
|
# @example
|
|
167
184
|
# result = IcsDownload.wrap_for_download(ics_content, "team-meeting")
|
|
168
185
|
# # => { content: "BEGIN:VCALENDAR...", headers: { 'Content-Type' => '...' } }
|
|
169
|
-
def self.wrap_for_download(content, title)
|
|
186
|
+
def self.wrap_for_download(content, title, method: nil)
|
|
170
187
|
filename = sanitize_filename("#{title.downcase}_#{Time.now.strftime('%Y%m%d')}.ics")
|
|
171
188
|
{
|
|
172
189
|
content: content,
|
|
173
|
-
headers: headers(filename)
|
|
190
|
+
headers: headers(filename, method: method)
|
|
174
191
|
}
|
|
175
192
|
end
|
|
176
193
|
end
|
|
177
|
-
|
|
178
|
-
# Compatibility class aliases
|
|
179
|
-
# @api private
|
|
180
|
-
class Ics < IcsContent; end
|
|
181
|
-
# @api private
|
|
182
|
-
class Ical < IcsContent; end
|
|
183
194
|
end
|
|
184
195
|
end
|
|
@@ -115,8 +115,8 @@ module CalInvite
|
|
|
115
115
|
params[:body] = url_encode(description_parts.join("\n\n")) if description_parts.any?
|
|
116
116
|
params[:location] = url_encode(format_location) if format_location
|
|
117
117
|
|
|
118
|
-
if
|
|
119
|
-
params[:to] = url_encode(
|
|
118
|
+
if attendees_list.any?
|
|
119
|
+
params[:to] = url_encode(attendee_emails.join(';'))
|
|
120
120
|
end
|
|
121
121
|
|
|
122
122
|
params
|
|
@@ -120,8 +120,8 @@ module CalInvite
|
|
|
120
120
|
params[:body] = url_encode(description_parts.join("\n\n")) if description_parts.any?
|
|
121
121
|
params[:location] = url_encode(format_location) if format_location
|
|
122
122
|
|
|
123
|
-
if
|
|
124
|
-
params[:to] = url_encode(
|
|
123
|
+
if attendees_list.any?
|
|
124
|
+
params[:to] = url_encode(attendee_emails.join(';'))
|
|
125
125
|
end
|
|
126
126
|
|
|
127
127
|
params
|
data/lib/cal_invite/version.rb
CHANGED
data/lib/cal_invite.rb
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
# lib/cal_invite.rb
|
|
2
|
+
require 'logger'
|
|
3
|
+
require 'mutex_m'
|
|
4
|
+
require 'bigdecimal'
|
|
2
5
|
require 'active_support'
|
|
3
6
|
require 'active_support/core_ext'
|
|
4
7
|
require 'securerandom'
|
|
@@ -8,6 +11,7 @@ require 'uri'
|
|
|
8
11
|
require 'cal_invite/version'
|
|
9
12
|
require 'cal_invite/configuration'
|
|
10
13
|
require 'cal_invite/caching'
|
|
14
|
+
require 'cal_invite/ical_timezone'
|
|
11
15
|
require 'cal_invite/event'
|
|
12
16
|
require 'cal_invite/providers'
|
|
13
17
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cal-invite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stephane Paquet
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-08-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -24,20 +24,76 @@ dependencies:
|
|
|
24
24
|
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: '6.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: tzinfo
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.2'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.2'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: logger
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: mutex_m
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '0'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: bigdecimal
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0'
|
|
76
|
+
type: :runtime
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '0'
|
|
27
83
|
- !ruby/object:Gem::Dependency
|
|
28
84
|
name: rdoc
|
|
29
85
|
requirement: !ruby/object:Gem::Requirement
|
|
30
86
|
requirements:
|
|
31
87
|
- - "~>"
|
|
32
88
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: '
|
|
89
|
+
version: '8.0'
|
|
34
90
|
type: :development
|
|
35
91
|
prerelease: false
|
|
36
92
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
93
|
requirements:
|
|
38
94
|
- - "~>"
|
|
39
95
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: '
|
|
96
|
+
version: '8.0'
|
|
41
97
|
description: CalInvite provides a simple way to generate calendar invite URLs for
|
|
42
98
|
various providers (Google, Outlook, Yahoo) and ICS files
|
|
43
99
|
email:
|
|
@@ -49,12 +105,16 @@ files:
|
|
|
49
105
|
- ".DS_Store"
|
|
50
106
|
- ".rdoc_options"
|
|
51
107
|
- ".rubocop.yml"
|
|
108
|
+
- AGENTS.md
|
|
52
109
|
- CACHING.md
|
|
53
110
|
- CHANGELOG.md
|
|
111
|
+
- CLAUDE.md
|
|
54
112
|
- CODE_OF_CONDUCT.md
|
|
113
|
+
- CONFIGURATION.md
|
|
55
114
|
- LICENSE.txt
|
|
56
115
|
- README.md
|
|
57
116
|
- Rakefile
|
|
117
|
+
- SECURITY.md
|
|
58
118
|
- gemfiles/Gemfile.rails6
|
|
59
119
|
- gemfiles/Gemfile.rails7
|
|
60
120
|
- gemfiles/Gemfile.rails8
|
|
@@ -64,6 +124,7 @@ files:
|
|
|
64
124
|
- lib/cal_invite/caching.rb
|
|
65
125
|
- lib/cal_invite/configuration.rb
|
|
66
126
|
- lib/cal_invite/event.rb
|
|
127
|
+
- lib/cal_invite/ical_timezone.rb
|
|
67
128
|
- lib/cal_invite/providers.rb
|
|
68
129
|
- lib/cal_invite/providers/base_provider.rb
|
|
69
130
|
- lib/cal_invite/providers/google.rb
|
|
@@ -76,13 +137,13 @@ files:
|
|
|
76
137
|
- lib/cal_invite/version.rb
|
|
77
138
|
- lib/tasks/rdoc.rake
|
|
78
139
|
- sig/cal/invite.rbs
|
|
79
|
-
homepage: https://github.com/
|
|
140
|
+
homepage: https://github.com/DashAPI-ai/cal-invite
|
|
80
141
|
licenses:
|
|
81
142
|
- MIT
|
|
82
143
|
metadata:
|
|
83
|
-
homepage_uri: https://github.com/
|
|
84
|
-
source_code_uri: https://github.com/
|
|
85
|
-
changelog_uri: https://github.com/
|
|
144
|
+
homepage_uri: https://github.com/DashAPI-ai/cal-invite
|
|
145
|
+
source_code_uri: https://github.com/DashAPI-ai/cal-invite
|
|
146
|
+
changelog_uri: https://github.com/DashAPI-ai/cal-invite/blob/master/CHANGELOG.md
|
|
86
147
|
post_install_message:
|
|
87
148
|
rdoc_options:
|
|
88
149
|
- "--title"
|