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
data/lib/cal_invite/event.rb
CHANGED
|
@@ -11,12 +11,28 @@ require 'digest'
|
|
|
11
11
|
# @attr_accessor [String] description The description of the event
|
|
12
12
|
# @attr_accessor [String] location The location of the event
|
|
13
13
|
# @attr_accessor [String] url The URL associated with the event
|
|
14
|
-
# @attr_accessor [Array<String>] attendees
|
|
14
|
+
# @attr_accessor [Array<String, Hash>] attendees Attendees as email strings, or hashes like
|
|
15
|
+
# { email:, name:, partstat: } for a display name and/or a specific RSVP status
|
|
16
|
+
# (:accepted, :declined, :tentative, :needs_action, :delegated)
|
|
15
17
|
# @attr_accessor [String] timezone The timezone for the event
|
|
16
18
|
# @attr_accessor [Boolean] show_attendees Whether to include attendees in calendar invites
|
|
17
19
|
# @attr_accessor [String] notes Additional notes for the event
|
|
18
20
|
# @attr_accessor [Array<Hash>] multi_day_sessions Sessions for multi-day events
|
|
19
21
|
# @attr_accessor [Boolean] all_day Whether this is an all-day event
|
|
22
|
+
# @attr_accessor [Hash] organizer The event organizer, e.g. { name: "Jane Doe", email: "jane@example.com" }
|
|
23
|
+
# @attr_accessor [String] uid Stable RFC 5545 UID identifying this event across its lifecycle
|
|
24
|
+
# @attr_accessor [Integer] sequence RFC 5545 SEQUENCE number; bump on every REQUEST/CANCEL update
|
|
25
|
+
# @attr_accessor [Array<Float>, Hash] geo Location coordinates, e.g. [37.4595, -122.1418] or { lat:, lng: }
|
|
26
|
+
# @attr_accessor [Array<Integer>] reminders Minutes-before-start values, one VALARM per entry
|
|
27
|
+
# @attr_accessor [Boolean] busy Whether this event should show as busy (true) or free (false) on free/busy lookups
|
|
28
|
+
# @attr_accessor [Symbol, String] visibility :public, :private, or :confidential
|
|
29
|
+
# @attr_accessor [String] rrule A raw RFC 5545 recurrence rule value, e.g. "FREQ=WEEKLY;COUNT=5"
|
|
30
|
+
# @attr_accessor [String] calendar_name Calendar-level display name (X-WR-CALNAME)
|
|
31
|
+
# @attr_accessor [Symbol, String] importance :low, :normal, or :high — maps to the standard
|
|
32
|
+
# PRIORITY property and, for Outlook specifically, X-MICROSOFT-CDO-IMPORTANCE
|
|
33
|
+
# @attr_accessor [Boolean] allow_counter Whether attendees may propose a new time. When set to
|
|
34
|
+
# false, emits X-MICROSOFT-DISALLOW-COUNTER — the one client-specific lever available for
|
|
35
|
+
# this; other clients don't expose an equivalent control
|
|
20
36
|
module CalInvite
|
|
21
37
|
class Event
|
|
22
38
|
attr_accessor :title,
|
|
@@ -30,7 +46,18 @@ module CalInvite
|
|
|
30
46
|
:show_attendees,
|
|
31
47
|
:notes,
|
|
32
48
|
:multi_day_sessions,
|
|
33
|
-
:all_day
|
|
49
|
+
:all_day,
|
|
50
|
+
:organizer,
|
|
51
|
+
:uid,
|
|
52
|
+
:sequence,
|
|
53
|
+
:geo,
|
|
54
|
+
:reminders,
|
|
55
|
+
:busy,
|
|
56
|
+
:visibility,
|
|
57
|
+
:rrule,
|
|
58
|
+
:calendar_name,
|
|
59
|
+
:importance,
|
|
60
|
+
:allow_counter
|
|
34
61
|
|
|
35
62
|
# Initializes a new Event instance with the given attributes.
|
|
36
63
|
#
|
|
@@ -47,6 +74,22 @@ module CalInvite
|
|
|
47
74
|
# @option attributes [String] :notes Additional notes
|
|
48
75
|
# @option attributes [Array<Hash>] :multi_day_sessions Multi-day session details
|
|
49
76
|
# @option attributes [Boolean] :all_day (false) Whether it's an all-day event
|
|
77
|
+
# @option attributes [Hash] :organizer The event organizer, e.g. { name: "Jane Doe", email: "jane@example.com" }
|
|
78
|
+
# @option attributes [String] :uid A stable identifier for this event. If omitted, a random one
|
|
79
|
+
# is generated and memoized on this instance. To update or cancel a previously sent invite,
|
|
80
|
+
# you MUST pass the same :uid used originally — mail/calendar clients match REQUEST/CANCEL
|
|
81
|
+
# messages to an existing event by UID, not by content.
|
|
82
|
+
# @option attributes [Integer] :sequence (0) RFC 5545 SEQUENCE number. Increment it yourself
|
|
83
|
+
# each time you re-send a REQUEST or a CANCEL for the same :uid.
|
|
84
|
+
# @option attributes [Array<Float>, Hash] :geo Location coordinates, e.g. [37.4595, -122.1418]
|
|
85
|
+
# or { lat:, lng: }
|
|
86
|
+
# @option attributes [Array<Integer>] :reminders Minutes-before-start values; one VALARM per entry
|
|
87
|
+
# @option attributes [Boolean] :busy (true) Whether this event shows as busy on free/busy lookups
|
|
88
|
+
# @option attributes [Symbol, String] :visibility (:public) :public, :private, or :confidential
|
|
89
|
+
# @option attributes [String] :rrule A raw RFC 5545 recurrence rule value, e.g. "FREQ=WEEKLY;COUNT=5"
|
|
90
|
+
# @option attributes [String] :calendar_name Calendar-level display name (X-WR-CALNAME)
|
|
91
|
+
# @option attributes [Symbol, String] :importance :low, :normal, or :high
|
|
92
|
+
# @option attributes [Boolean] :allow_counter (true) false emits X-MICROSOFT-DISALLOW-COUNTER
|
|
50
93
|
#
|
|
51
94
|
# @raise [ArgumentError] If required attributes are missing
|
|
52
95
|
def initialize(attributes = {})
|
|
@@ -54,6 +97,11 @@ module CalInvite
|
|
|
54
97
|
@timezone = attributes.delete(:timezone) || 'UTC'
|
|
55
98
|
@multi_day_sessions = attributes.delete(:multi_day_sessions) || []
|
|
56
99
|
@all_day = attributes.delete(:all_day) || false
|
|
100
|
+
@uid = attributes.delete(:uid) || generate_uid
|
|
101
|
+
@sequence = attributes.delete(:sequence) || 0
|
|
102
|
+
@busy = attributes.key?(:busy) ? attributes.delete(:busy) : true
|
|
103
|
+
@visibility = attributes.delete(:visibility) || :public
|
|
104
|
+
@allow_counter = attributes.key?(:allow_counter) ? attributes.delete(:allow_counter) : true
|
|
57
105
|
|
|
58
106
|
attributes.each do |key, value|
|
|
59
107
|
send("#{key}=", value) if respond_to?("#{key}=")
|
|
@@ -62,10 +110,26 @@ module CalInvite
|
|
|
62
110
|
validate!
|
|
63
111
|
end
|
|
64
112
|
|
|
65
|
-
# Generates a calendar URL for the
|
|
113
|
+
# Generates a calendar URL (or, for the ics/ical/ics_content providers, raw
|
|
114
|
+
# iCalendar content) for the specified provider.
|
|
66
115
|
#
|
|
67
116
|
# @param provider [Symbol] The calendar provider to generate the URL for
|
|
68
|
-
# @
|
|
117
|
+
# @param method [Symbol] The iCalendar METHOD to use (:publish, :request, :cancel,
|
|
118
|
+
# :reply, :counter, or :decline_counter). Only honored by the ics-family providers
|
|
119
|
+
# (ics, ical, ics_content); ignored by URL-based providers.
|
|
120
|
+
# - :request (with an {#organizer} set) produces an invite that mail clients
|
|
121
|
+
# (Gmail, Outlook, Apple Mail) recognize and render with Accept/Decline
|
|
122
|
+
# actions rather than as a plain attachment.
|
|
123
|
+
# - :cancel produces a cancellation (STATUS:CANCELLED) for a previously sent
|
|
124
|
+
# :request. Reuse the same {#uid} and bump {#sequence} so clients match it
|
|
125
|
+
# to the original invite instead of creating a new event.
|
|
126
|
+
# - :reply carries an attendee's own PARTSTAT back to the organizer.
|
|
127
|
+
# - :counter carries an attendee's proposed new {#start_time}/{#end_time} back
|
|
128
|
+
# to the organizer, keeping the original {#uid}/{#sequence}. Client support for
|
|
129
|
+
# rendering this as an actionable UI is inconsistent — see CONFIGURATION.md's
|
|
130
|
+
# "Attendee-proposed reschedules (COUNTER)".
|
|
131
|
+
# - :decline_counter is the organizer rejecting a :counter proposal.
|
|
132
|
+
# @return [String] The generated calendar URL or content
|
|
69
133
|
# @raise [ArgumentError] If required event attributes are missing
|
|
70
134
|
#
|
|
71
135
|
# @example Generate a Google Calendar URL
|
|
@@ -73,18 +137,27 @@ module CalInvite
|
|
|
73
137
|
#
|
|
74
138
|
# @example Generate an Outlook Calendar URL
|
|
75
139
|
# event.generate_calendar_url(:outlook)
|
|
76
|
-
|
|
140
|
+
#
|
|
141
|
+
# @example Generate an RFC 5545 meeting request for emailing as an invite
|
|
142
|
+
# event.organizer = { name: "Jane Doe", email: "jane@example.com" }
|
|
143
|
+
# event.generate_calendar_url(:ics, method: :request)
|
|
144
|
+
#
|
|
145
|
+
# @example Cancel a previously sent invite
|
|
146
|
+
# event.uid = "the-original-uid@cal-invite" # must match the original REQUEST
|
|
147
|
+
# event.sequence = 1 # incremented from the original
|
|
148
|
+
# event.generate_calendar_url(:ics, method: :cancel)
|
|
149
|
+
def generate_calendar_url(provider, method: :publish)
|
|
77
150
|
validate!
|
|
78
151
|
|
|
79
152
|
if caching_enabled?
|
|
80
|
-
cache_key = cache_key_for(provider)
|
|
153
|
+
cache_key = cache_key_for(provider, method)
|
|
81
154
|
cached_url = fetch_from_cache(cache_key)
|
|
82
155
|
return cached_url if cached_url
|
|
83
156
|
end
|
|
84
157
|
|
|
85
158
|
# Generate the URL
|
|
86
159
|
provider_class = CalInvite::Providers.const_get(capitalize_provider(provider.to_s))
|
|
87
|
-
generator = provider_class.new(self)
|
|
160
|
+
generator = provider_class.new(self, method: method)
|
|
88
161
|
url = generator.generate
|
|
89
162
|
|
|
90
163
|
# Cache the result if caching is enabled
|
|
@@ -115,6 +188,14 @@ module CalInvite
|
|
|
115
188
|
|
|
116
189
|
private
|
|
117
190
|
|
|
191
|
+
# Generates a stable unique identifier for this event.
|
|
192
|
+
# Format: timestamp-randomhex@cal-invite
|
|
193
|
+
#
|
|
194
|
+
# @return [String] The generated UID
|
|
195
|
+
def generate_uid
|
|
196
|
+
"#{Time.now.to_i}-#{SecureRandom.hex(8)}@cal-invite"
|
|
197
|
+
end
|
|
198
|
+
|
|
118
199
|
# Capitalizes each part of the provider name.
|
|
119
200
|
#
|
|
120
201
|
# @param string [String] The provider name to capitalize
|
|
@@ -149,8 +230,9 @@ module CalInvite
|
|
|
149
230
|
# Generates a cache key for the event and provider combination.
|
|
150
231
|
#
|
|
151
232
|
# @param provider [Symbol] The calendar provider
|
|
233
|
+
# @param method [Symbol] The iCalendar METHOD used to generate the content
|
|
152
234
|
# @return [String, nil] The cache key or nil if caching is disabled
|
|
153
|
-
def cache_key_for(provider)
|
|
235
|
+
def cache_key_for(provider, method = :publish)
|
|
154
236
|
return nil unless caching_enabled?
|
|
155
237
|
|
|
156
238
|
attributes_hash = Digest::MD5.hexdigest(
|
|
@@ -167,7 +249,19 @@ module CalInvite
|
|
|
167
249
|
notes,
|
|
168
250
|
multi_day_sessions,
|
|
169
251
|
all_day,
|
|
170
|
-
|
|
252
|
+
organizer,
|
|
253
|
+
uid,
|
|
254
|
+
sequence,
|
|
255
|
+
geo,
|
|
256
|
+
reminders,
|
|
257
|
+
busy,
|
|
258
|
+
visibility,
|
|
259
|
+
rrule,
|
|
260
|
+
calendar_name,
|
|
261
|
+
importance,
|
|
262
|
+
allow_counter,
|
|
263
|
+
provider,
|
|
264
|
+
method
|
|
171
265
|
].map(&:to_s).join('|')
|
|
172
266
|
)
|
|
173
267
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tzinfo'
|
|
4
|
+
require 'date'
|
|
5
|
+
|
|
6
|
+
# lib/cal_invite/ical_timezone.rb
|
|
7
|
+
module CalInvite
|
|
8
|
+
# Builds RFC 5545 VTIMEZONE components and converts UTC times to local wall-clock
|
|
9
|
+
# time for a given IANA/Olson timezone identifier, using TZInfo (already pulled in
|
|
10
|
+
# transitively via activesupport).
|
|
11
|
+
#
|
|
12
|
+
# Both entry points fail soft: if `tzid` isn't a TZInfo-recognized identifier (e.g.
|
|
13
|
+
# a raw UTC offset string like "+01:00", or 'UTC' itself), they return nil so
|
|
14
|
+
# callers can fall back to their previous behavior instead of raising.
|
|
15
|
+
#
|
|
16
|
+
# @api private
|
|
17
|
+
module IcalTimezone
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Converts a UTC time to local wall-clock time for the given timezone.
|
|
21
|
+
#
|
|
22
|
+
# @param tzid [String] An IANA timezone identifier, e.g. "America/New_York"
|
|
23
|
+
# @param utc_time [Time] The time to convert (interpreted as UTC)
|
|
24
|
+
# @return [Time, nil] The local wall-clock time, or nil if tzid is unrecognized
|
|
25
|
+
def local_time(tzid, utc_time)
|
|
26
|
+
return nil if tzid.nil? || tzid.to_s.strip.empty? || tzid.to_s.upcase == 'UTC'
|
|
27
|
+
|
|
28
|
+
TZInfo::Timezone.get(tzid.to_s).to_local(utc_time.utc)
|
|
29
|
+
rescue TZInfo::InvalidTimezoneIdentifier
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Builds a complete VTIMEZONE component (STANDARD/DAYLIGHT observances with
|
|
34
|
+
# RRULEs derived from the timezone's actual transition rules) for the given
|
|
35
|
+
# timezone identifier.
|
|
36
|
+
#
|
|
37
|
+
# @param tzid [String] An IANA timezone identifier, e.g. "America/New_York"
|
|
38
|
+
# @return [Array<String>, nil] iCalendar lines for the VTIMEZONE component, or
|
|
39
|
+
# nil if tzid is unrecognized, is UTC, or the component can't be built
|
|
40
|
+
def vtimezone_lines(tzid)
|
|
41
|
+
return nil if tzid.nil? || tzid.to_s.strip.empty? || tzid.to_s.upcase == 'UTC'
|
|
42
|
+
|
|
43
|
+
tz = TZInfo::Timezone.get(tzid.to_s)
|
|
44
|
+
current = tz.period_for(Time.now.utc)
|
|
45
|
+
|
|
46
|
+
if current.dst?
|
|
47
|
+
dst_period = current
|
|
48
|
+
std_period = period_before(tz, dst_period)
|
|
49
|
+
else
|
|
50
|
+
std_period = current
|
|
51
|
+
dst_period = period_after(tz, std_period)
|
|
52
|
+
dst_period = nil unless dst_period&.dst?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
lines = ["BEGIN:VTIMEZONE", "TZID:#{tzid}"]
|
|
56
|
+
|
|
57
|
+
if std_period && dst_period
|
|
58
|
+
lines.concat(observance_lines('STANDARD', dst_period, std_period))
|
|
59
|
+
lines.concat(observance_lines('DAYLIGHT', std_period, dst_period))
|
|
60
|
+
else
|
|
61
|
+
lines.concat(fixed_observance_lines(std_period || dst_period || current))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
lines << "END:VTIMEZONE"
|
|
65
|
+
lines
|
|
66
|
+
rescue StandardError
|
|
67
|
+
# Never let an exotic/edge-case timezone break calendar generation —
|
|
68
|
+
# worst case the VEVENT ends up without a VTIMEZONE definition.
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @api private
|
|
73
|
+
def period_before(tz, period)
|
|
74
|
+
return nil unless period.starts_at
|
|
75
|
+
|
|
76
|
+
tz.period_for(period.starts_at.to_time - 1)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @api private
|
|
80
|
+
def period_after(tz, period)
|
|
81
|
+
return nil unless period&.ends_at
|
|
82
|
+
|
|
83
|
+
tz.period_for(period.ends_at.to_time)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @api private
|
|
87
|
+
def observance_lines(kind, from_period, to_period)
|
|
88
|
+
transition_time = from_period.local_ends_at.to_time
|
|
89
|
+
|
|
90
|
+
[
|
|
91
|
+
"BEGIN:#{kind}",
|
|
92
|
+
"DTSTART:#{transition_time.strftime('%Y%m%dT%H%M%S')}",
|
|
93
|
+
"TZOFFSETFROM:#{format_offset(from_period.offset.observed_utc_offset)}",
|
|
94
|
+
"TZOFFSETTO:#{format_offset(to_period.offset.observed_utc_offset)}",
|
|
95
|
+
"TZNAME:#{to_period.offset.abbreviation}",
|
|
96
|
+
rrule_line(transition_time),
|
|
97
|
+
"END:#{kind}"
|
|
98
|
+
]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @api private
|
|
102
|
+
def fixed_observance_lines(period)
|
|
103
|
+
[
|
|
104
|
+
"BEGIN:STANDARD",
|
|
105
|
+
"DTSTART:16010101T000000",
|
|
106
|
+
"TZOFFSETFROM:#{format_offset(period.offset.observed_utc_offset)}",
|
|
107
|
+
"TZOFFSETTO:#{format_offset(period.offset.observed_utc_offset)}",
|
|
108
|
+
"TZNAME:#{period.offset.abbreviation}",
|
|
109
|
+
"END:STANDARD"
|
|
110
|
+
]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Derives a YEARLY RRULE (e.g. "2nd Sunday in March") from a transition date.
|
|
114
|
+
#
|
|
115
|
+
# @api private
|
|
116
|
+
def rrule_line(time)
|
|
117
|
+
wday_names = %w[SU MO TU WE TH FR SA]
|
|
118
|
+
days_in_month = Date.new(time.year, time.month, -1).day
|
|
119
|
+
nth = (time.day - 1) / 7 + 1
|
|
120
|
+
nth = -1 if time.day + 7 > days_in_month
|
|
121
|
+
|
|
122
|
+
"RRULE:FREQ=YEARLY;BYMONTH=#{time.month};BYDAY=#{nth}#{wday_names[time.wday]}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @api private
|
|
126
|
+
def format_offset(seconds)
|
|
127
|
+
sign = seconds.negative? ? '-' : '+'
|
|
128
|
+
abs = seconds.abs
|
|
129
|
+
format('%<sign>s%<hours>02d%<minutes>02d', sign: sign, hours: abs / 3600, minutes: (abs % 3600) / 60)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
#
|
|
7
7
|
# @abstract Subclass and override {#generate} to implement a calendar provider
|
|
8
8
|
class BaseProvider
|
|
9
|
-
attr_reader :event
|
|
9
|
+
attr_reader :event, :method
|
|
10
10
|
|
|
11
11
|
# Initialize a new calendar provider
|
|
12
12
|
#
|
|
13
13
|
# @param event [CalInvite::Event] The event to generate a calendar URL for
|
|
14
|
-
|
|
14
|
+
# @param method [Symbol] The iCalendar METHOD (:publish or :request). Only
|
|
15
|
+
# meaningful to the ics-family providers; URL-based providers ignore it.
|
|
16
|
+
def initialize(event, method: :publish)
|
|
15
17
|
@event = event
|
|
18
|
+
@method = method
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
# Generate a calendar URL or content for the event.
|
|
@@ -70,16 +73,197 @@ class BaseProvider
|
|
|
70
73
|
params[:location] = url_encode(format_location) if format_location
|
|
71
74
|
|
|
72
75
|
if event.show_attendees && event.attendees&.any?
|
|
73
|
-
params[:attendees] =
|
|
76
|
+
params[:attendees] = attendee_emails.join(',')
|
|
74
77
|
end
|
|
75
78
|
|
|
76
79
|
params
|
|
77
80
|
end
|
|
78
81
|
|
|
79
82
|
# Get the list of attendees if showing attendees is enabled
|
|
80
|
-
# @return [Array<String>] The list of attendees
|
|
83
|
+
# @return [Array<String, Hash>] The list of attendees (email strings or
|
|
84
|
+
# { email:, name:, partstat: } hashes) or empty array if disabled/none present
|
|
81
85
|
def attendees_list
|
|
82
86
|
return [] unless event.show_attendees && event.attendees&.any?
|
|
83
87
|
event.attendees
|
|
84
88
|
end
|
|
89
|
+
|
|
90
|
+
# Plain email addresses for all attendees, regardless of whether they were
|
|
91
|
+
# given as strings or { email:, name:, partstat: } hashes.
|
|
92
|
+
# @return [Array<String>]
|
|
93
|
+
def attendee_emails
|
|
94
|
+
attendees_list.map { |attendee| attendee_email(attendee) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @param attendee [String, Hash] An attendee as given in Event#attendees
|
|
98
|
+
# @return [String] The attendee's email address
|
|
99
|
+
def attendee_email(attendee)
|
|
100
|
+
attendee.is_a?(Hash) ? attendee_hash_value(attendee, :email) : attendee.to_s
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Reads `key` from an attendee hash, trying both symbol and string keys.
|
|
104
|
+
# Unlike `attendee[:key] || attendee["key"]`, this doesn't misread an
|
|
105
|
+
# explicit `false` value (e.g. `rsvp: false`) as "not set".
|
|
106
|
+
#
|
|
107
|
+
# @param attendee [Hash] An attendee hash
|
|
108
|
+
# @param key [Symbol] The key to read
|
|
109
|
+
# @return [Object, nil]
|
|
110
|
+
def attendee_hash_value(attendee, key)
|
|
111
|
+
return nil unless attendee.is_a?(Hash)
|
|
112
|
+
return attendee[key] if attendee.key?(key)
|
|
113
|
+
|
|
114
|
+
attendee[key.to_s]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
NO_RSVP_METHODS = %i[reply counter decline_counter].freeze
|
|
118
|
+
|
|
119
|
+
PARTSTAT_VALUES = {
|
|
120
|
+
accepted: "ACCEPTED",
|
|
121
|
+
declined: "DECLINED",
|
|
122
|
+
tentative: "TENTATIVE",
|
|
123
|
+
needs_action: "NEEDS-ACTION",
|
|
124
|
+
delegated: "DELEGATED"
|
|
125
|
+
}.freeze
|
|
126
|
+
|
|
127
|
+
# Formats a full ATTENDEE property line for iCalendar output.
|
|
128
|
+
#
|
|
129
|
+
# @param attendee [String, Hash] An email string, or a hash like
|
|
130
|
+
# { email:, name:, partstat:, rsvp: } for a display name, specific RSVP
|
|
131
|
+
# status, and/or an explicit RSVP override. `rsvp:` is rarely needed —
|
|
132
|
+
# e.g. a "registration confirmed" invite where the recipient is already
|
|
133
|
+
# `partstat: :accepted` and nothing is actually being requested, so
|
|
134
|
+
# `rsvp: false` suppresses `RSVP=TRUE` even under `method: :request`.
|
|
135
|
+
# @return [String] The formatted ATTENDEE line
|
|
136
|
+
def attendee_line(attendee)
|
|
137
|
+
email = attendee_email(attendee)
|
|
138
|
+
name = attendee_hash_value(attendee, :name)
|
|
139
|
+
partstat_key = attendee_hash_value(attendee, :partstat)
|
|
140
|
+
rsvp_override = attendee_hash_value(attendee, :rsvp)
|
|
141
|
+
|
|
142
|
+
cn = name ? %(;CN="#{name}") : ""
|
|
143
|
+
partstat = PARTSTAT_VALUES[partstat_key&.to_sym] || "NEEDS-ACTION"
|
|
144
|
+
# REPLY/COUNTER/DECLINECOUNTER all flow attendee -> organizer; RSVP=TRUE
|
|
145
|
+
# ("please respond") only makes sense on an organizer -> attendee REQUEST.
|
|
146
|
+
rsvp_default = !NO_RSVP_METHODS.include?(method)
|
|
147
|
+
rsvp = (rsvp_override.nil? ? rsvp_default : rsvp_override) ? ";RSVP=TRUE" : ""
|
|
148
|
+
|
|
149
|
+
"ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=#{partstat}#{rsvp}#{cn}:mailto:#{email}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Format the ORGANIZER property for iCalendar output
|
|
153
|
+
# @return [String, nil] The formatted ORGANIZER line, or nil if no organizer is set
|
|
154
|
+
def organizer_line
|
|
155
|
+
return nil unless event.organizer && event.organizer[:email]
|
|
156
|
+
|
|
157
|
+
name = event.organizer[:name]
|
|
158
|
+
cn = name ? %(;CN="#{name}") : ""
|
|
159
|
+
"ORGANIZER#{cn}:mailto:#{event.organizer[:email]}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The value for the calendar-level METHOD property. RFC 5545 spells this
|
|
163
|
+
# method "DECLINECOUNTER" (one word) despite `:decline_counter` reading more
|
|
164
|
+
# naturally as a Ruby symbol.
|
|
165
|
+
# @return [String] e.g. "REQUEST", "DECLINECOUNTER"
|
|
166
|
+
def method_value
|
|
167
|
+
method == :decline_counter ? "DECLINECOUNTER" : method.to_s.upcase
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The STATUS property, driven by the iCalendar METHOD in use.
|
|
171
|
+
# @return [String] "STATUS:CANCELLED" for :cancel, "STATUS:CONFIRMED" otherwise
|
|
172
|
+
def status_line
|
|
173
|
+
method == :cancel ? "STATUS:CANCELLED" : "STATUS:CONFIRMED"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Converts a time to local wall-clock time for the event's timezone, for use in
|
|
177
|
+
# a `DTSTART;TZID=...`/`DTEND;TZID=...` property. Falls back to the time as given
|
|
178
|
+
# (unconverted) when the timezone isn't a TZInfo-recognized identifier (e.g. 'UTC'
|
|
179
|
+
# or a raw offset string), matching each provider's prior behavior for those cases.
|
|
180
|
+
#
|
|
181
|
+
# @param time [Time] The time to convert (interpreted as UTC)
|
|
182
|
+
# @return [Time] The local wall-clock time
|
|
183
|
+
def local_wall_time(time)
|
|
184
|
+
CalInvite::IcalTimezone.local_time(event.timezone, time) || time
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Builds the VTIMEZONE component lines for the event's timezone, if applicable.
|
|
188
|
+
# @return [Array<String>, nil] iCalendar lines, or nil for UTC/unrecognized timezones
|
|
189
|
+
def vtimezone_lines
|
|
190
|
+
CalInvite::IcalTimezone.vtimezone_lines(event.timezone)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Format the GEO property from Event#geo.
|
|
194
|
+
# @return [String, nil] The formatted GEO line, or nil if no geo is set
|
|
195
|
+
def geo_line
|
|
196
|
+
return nil unless event.geo
|
|
197
|
+
|
|
198
|
+
lat, lng = event.geo.is_a?(Hash) ? [event.geo[:lat] || event.geo["lat"], event.geo[:lng] || event.geo["lng"]] : event.geo
|
|
199
|
+
return nil unless lat && lng
|
|
200
|
+
|
|
201
|
+
"GEO:#{lat};#{lng}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The TRANSP property, from Event#busy (default true).
|
|
205
|
+
# @return [String] "TRANSP:OPAQUE" (busy) or "TRANSP:TRANSPARENT" (free)
|
|
206
|
+
def transp_line
|
|
207
|
+
event.busy == false ? "TRANSP:TRANSPARENT" : "TRANSP:OPAQUE"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The CLASS property, from Event#visibility (default :public).
|
|
211
|
+
# @return [String] e.g. "CLASS:PUBLIC"
|
|
212
|
+
def class_line
|
|
213
|
+
"CLASS:#{(event.visibility || :public).to_s.upcase}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The RRULE property, from Event#rrule, if set.
|
|
217
|
+
# @return [String, nil] e.g. "RRULE:FREQ=WEEKLY;COUNT=5", or nil if no rrule is set
|
|
218
|
+
def rrule_line
|
|
219
|
+
return nil unless event.rrule
|
|
220
|
+
|
|
221
|
+
value = event.rrule.to_s
|
|
222
|
+
value.start_with?("RRULE:") ? value : "RRULE:#{value}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Builds VALARM sub-components from Event#reminders (minutes-before-start values).
|
|
226
|
+
# @return [Array<String>] iCalendar lines, one VALARM block per reminder, or [] if none
|
|
227
|
+
def valarm_lines
|
|
228
|
+
return [] unless event.reminders&.any?
|
|
229
|
+
|
|
230
|
+
event.reminders.flat_map do |minutes|
|
|
231
|
+
["BEGIN:VALARM", "TRIGGER:-PT#{minutes.to_i}M", "ACTION:DISPLAY", "DESCRIPTION:Reminder", "END:VALARM"]
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
IMPORTANCE_VALUES = { low: ["9", "0"], normal: ["5", "1"], high: ["1", "2"] }.freeze
|
|
236
|
+
|
|
237
|
+
# Standard RFC 5545 PRIORITY plus Outlook's non-standard
|
|
238
|
+
# X-MICROSOFT-CDO-IMPORTANCE, from Event#importance (:low/:normal/:high). Safe
|
|
239
|
+
# to always include: RFC 5545 requires unrecognized X- properties be ignored
|
|
240
|
+
# by compliant parsers, so this has no effect outside Outlook.
|
|
241
|
+
# @return [Array<String>] 0, 1, or 2 lines
|
|
242
|
+
def importance_lines
|
|
243
|
+
values = IMPORTANCE_VALUES[event.importance&.to_sym]
|
|
244
|
+
return [] unless values
|
|
245
|
+
|
|
246
|
+
priority, importance = values
|
|
247
|
+
["PRIORITY:#{priority}", "X-MICROSOFT-CDO-IMPORTANCE:#{importance}"]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Outlook's non-standard X-MICROSOFT-CDO-BUSYSTATUS, mirroring Event#busy.
|
|
251
|
+
# Included alongside the standard TRANSP property since some Outlook versions
|
|
252
|
+
# honor this one more reliably.
|
|
253
|
+
# @return [String] e.g. "X-MICROSOFT-CDO-BUSYSTATUS:BUSY"
|
|
254
|
+
def busystatus_line
|
|
255
|
+
"X-MICROSOFT-CDO-BUSYSTATUS:#{event.busy == false ? "FREE" : "BUSY"}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Outlook's non-standard X-MICROSOFT-DISALLOW-COUNTER, from Event#allow_counter
|
|
259
|
+
# (default true). Set Event#allow_counter = false to hide Outlook's "Propose
|
|
260
|
+
# New Time" action — the practical lever for "prevent attendee-initiated
|
|
261
|
+
# reschedules" on Outlook specifically; other clients don't expose an
|
|
262
|
+
# equivalent control and ignore this property.
|
|
263
|
+
# @return [String, nil] "X-MICROSOFT-DISALLOW-COUNTER:TRUE", or nil if counters are allowed
|
|
264
|
+
def disallow_counter_line
|
|
265
|
+
return nil if event.allow_counter != false
|
|
266
|
+
|
|
267
|
+
"X-MICROSOFT-DISALLOW-COUNTER:TRUE"
|
|
268
|
+
end
|
|
85
269
|
end
|
|
@@ -38,7 +38,8 @@ module CalInvite
|
|
|
38
38
|
"VERSION:2.0",
|
|
39
39
|
"PRODID:-//CalInvite//Ruby//EN",
|
|
40
40
|
"CALSCALE:GREGORIAN",
|
|
41
|
-
"METHOD
|
|
41
|
+
"METHOD:#{method_value}",
|
|
42
|
+
(event.calendar_name ? "X-WR-CALNAME:#{escape_text(event.calendar_name)}" : nil),
|
|
42
43
|
generate_timezone,
|
|
43
44
|
generate_events,
|
|
44
45
|
"END:VCALENDAR"
|
|
@@ -85,7 +86,7 @@ module CalInvite
|
|
|
85
86
|
# Required fields
|
|
86
87
|
lines.concat([
|
|
87
88
|
"SUMMARY:#{escape_text(event.title)}",
|
|
88
|
-
"UID:#{
|
|
89
|
+
"UID:#{event.uid}",
|
|
89
90
|
"DTSTAMP:#{format_timestamp(Time.now.utc)}"
|
|
90
91
|
])
|
|
91
92
|
|
|
@@ -93,37 +94,34 @@ module CalInvite
|
|
|
93
94
|
lines << "DESCRIPTION:#{escape_text(format_description)}" if format_description
|
|
94
95
|
lines << "LOCATION:#{escape_text(format_location)}" if format_location
|
|
95
96
|
lines << "URL:#{escape_text(format_url)}" if format_url
|
|
97
|
+
lines << geo_line if geo_line
|
|
98
|
+
lines << organizer_line if organizer_line
|
|
96
99
|
|
|
97
100
|
# Attendees
|
|
98
|
-
|
|
99
|
-
attendees.each do |attendee|
|
|
100
|
-
lines << "ATTENDEE;RSVP=TRUE:mailto:#{attendee}"
|
|
101
|
-
end
|
|
102
|
-
end
|
|
101
|
+
attendees_list.each { |attendee| lines << attendee_line(attendee) }
|
|
103
102
|
|
|
103
|
+
lines << rrule_line if rrule_line
|
|
104
|
+
lines << "SEQUENCE:#{event.sequence}"
|
|
105
|
+
lines << status_line
|
|
106
|
+
lines << transp_line
|
|
107
|
+
lines << busystatus_line
|
|
108
|
+
lines << class_line
|
|
109
|
+
lines.concat(importance_lines)
|
|
110
|
+
lines << disallow_counter_line if disallow_counter_line
|
|
111
|
+
lines.concat(valarm_lines)
|
|
104
112
|
lines << "END:VEVENT"
|
|
105
113
|
lines.join("\r\n")
|
|
106
114
|
end
|
|
107
115
|
|
|
108
|
-
# Generates the timezone block (VTIMEZONE) for the calendar
|
|
109
|
-
#
|
|
116
|
+
# Generates the timezone block (VTIMEZONE) for the calendar, with real
|
|
117
|
+
# STANDARD/DAYLIGHT observances derived from the timezone's transition rules.
|
|
118
|
+
# Only included for non-all-day events with a recognized, non-UTC timezone.
|
|
110
119
|
#
|
|
111
|
-
# @return [String, nil] The formatted timezone block, or nil
|
|
120
|
+
# @return [String, nil] The formatted timezone block, or nil if not applicable
|
|
112
121
|
def generate_timezone
|
|
113
122
|
return nil if event.all_day # No timezone needed for all-day events
|
|
114
|
-
[
|
|
115
|
-
"BEGIN:VTIMEZONE",
|
|
116
|
-
"TZID:#{event.timezone}",
|
|
117
|
-
"END:VTIMEZONE"
|
|
118
|
-
].join("\r\n")
|
|
119
|
-
end
|
|
120
123
|
|
|
121
|
-
|
|
122
|
-
# Format: timestamp-randomhex@cal-invite
|
|
123
|
-
#
|
|
124
|
-
# @return [String] The generated UID
|
|
125
|
-
def generate_uid
|
|
126
|
-
"#{Time.now.to_i}-#{SecureRandom.hex(8)}@cal-invite"
|
|
124
|
+
vtimezone_lines&.join("\r\n")
|
|
127
125
|
end
|
|
128
126
|
|
|
129
127
|
# Formats a time object as a date string in iCalendar format.
|
|
@@ -135,12 +133,13 @@ module CalInvite
|
|
|
135
133
|
end
|
|
136
134
|
|
|
137
135
|
# Formats a time object as a local time string in iCalendar format.
|
|
138
|
-
#
|
|
136
|
+
# Converts from UTC to the event's timezone; times are expected to be in
|
|
137
|
+
# UTC already.
|
|
139
138
|
#
|
|
140
139
|
# @param time [Time] The time to format
|
|
141
140
|
# @return [String] The formatted local time (YYYYMMDDTHHmmSS)
|
|
142
141
|
def format_local_time(time)
|
|
143
|
-
time.strftime("%Y%m%dT%H%M%S")
|
|
142
|
+
local_wall_time(time).strftime("%Y%m%dT%H%M%S")
|
|
144
143
|
end
|
|
145
144
|
|
|
146
145
|
# Formats a time object as an UTC timestamp in iCalendar format.
|