cal-invite 0.2.1 → 0.3.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.
@@ -1,269 +1,307 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # app/lib/base_provider.rb
4
- # Base class for calendar providers that implements common functionality
5
- # and defines the interface that all providers must implement.
6
- #
7
- # @abstract Subclass and override {#generate} to implement a calendar provider
8
- class BaseProvider
9
- attr_reader :event, :method
10
-
11
- # Initialize a new calendar provider
12
- #
13
- # @param event [CalInvite::Event] The event to generate a calendar URL for
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)
17
- @event = event
18
- @method = method
19
- end
20
-
21
- # Generate a calendar URL or content for the event.
22
- # This method must be implemented by all provider subclasses.
23
- #
24
- # @abstract
25
- # @return [String] The generated calendar URL or content
26
- # @raise [NotImplementedError] if the provider class doesn't implement this method
27
- def generate
28
- raise NotImplementedError, "#{self.class} must implement #generate"
29
- end
30
-
31
- protected
32
-
33
- # URL encode a string for use in calendar URLs
34
- #
35
- # @param str [#to_s] The string to encode
36
- # @return [String] The URL encoded string
37
- def url_encode(str)
38
- URI.encode_www_form_component(str.to_s)
39
- end
40
-
41
- # Format the event description
42
- # @return [String, nil] The formatted description or nil if no content
43
- def format_description
44
- parts = []
45
- parts << event.description if event.description
46
- parts << "Notes: #{event.notes}" if event.notes
47
- parts.join("\n\n")
48
- end
49
-
50
- # Get just the physical location
51
- # @return [String, nil] The location or nil if not present
52
- def format_location
53
- event.location
54
- end
55
-
56
- # Get the URL for virtual meetings
57
- # @return [String, nil] The URL or nil if not present
58
- def format_url
59
- event.url
60
- end
61
-
62
- # Format description including URL if present
63
- # @return [String, nil] The formatted description with URL
64
- def format_description_with_url
65
- parts = []
66
- parts << format_description if format_description
67
- parts << "Virtual Meeting URL: #{format_url}" if format_url
68
- parts.join("\n\n")
69
- end
70
-
71
- def add_optional_params(params)
72
- params[:description] = url_encode(format_description_with_url) if format_description || format_url
73
- params[:location] = url_encode(format_location) if format_location
74
-
75
- if event.show_attendees && event.attendees&.any?
76
- params[:attendees] = attendee_emails.join(',')
77
- end
78
-
79
- params
80
- end
81
-
82
- # Get the list of attendees if showing attendees is enabled
83
- # @return [Array<String, Hash>] The list of attendees (email strings or
84
- # { email:, name:, partstat: } hashes) or empty array if disabled/none present
85
- def attendees_list
86
- return [] unless event.show_attendees && event.attendees&.any?
87
- event.attendees
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"]
3
+ module CalInvite
4
+ module Providers
5
+ # Base class for calendar providers that implements common functionality
6
+ # and defines the interface that all providers must implement.
7
+ #
8
+ # @abstract Subclass and override {#generate} to implement a calendar provider
9
+ class BaseProvider
10
+ attr_reader :event, :method
11
+
12
+ # Initialize a new calendar provider
13
+ #
14
+ # @param event [CalInvite::Event] The event to generate a calendar URL for
15
+ # @param method [Symbol] The iCalendar METHOD (:publish or :request). Only
16
+ # meaningful to the ics-family providers; URL-based providers ignore it.
17
+ def initialize(event, method: :publish)
18
+ @event = event
19
+ @method = method
20
+ end
21
+
22
+ # Generate a calendar URL or content for the event.
23
+ # This method must be implemented by all provider subclasses.
24
+ #
25
+ # @abstract
26
+ # @return [String] The generated calendar URL or content
27
+ # @raise [NotImplementedError] if the provider class doesn't implement this method
28
+ def generate
29
+ raise NotImplementedError, "#{self.class} must implement #generate"
30
+ end
31
+
32
+ protected
33
+
34
+ # URL encode a string for use in calendar URLs
35
+ #
36
+ # @param str [#to_s] The string to encode
37
+ # @return [String] The URL encoded string
38
+ def url_encode(str)
39
+ URI.encode_www_form_component(str.to_s)
40
+ end
41
+
42
+ # Format the event description
43
+ # @return [String, nil] The formatted description or nil if no content
44
+ def format_description
45
+ parts = []
46
+ parts << event.description if event.description
47
+ parts << "Notes: #{event.notes}" if event.notes
48
+ parts.join("\n\n")
49
+ end
50
+
51
+ # Get just the physical location
52
+ # @return [String, nil] The location or nil if not present
53
+ def format_location
54
+ event.location
55
+ end
56
+
57
+ # Get the URL for virtual meetings
58
+ # @return [String, nil] The URL or nil if not present
59
+ def format_url
60
+ event.url
61
+ end
62
+
63
+ # Format description including URL if present
64
+ # @return [String, nil] The formatted description with URL
65
+ def format_description_with_url
66
+ parts = []
67
+ parts << format_description if format_description
68
+ parts << "Virtual Meeting URL: #{format_url}" if format_url
69
+ parts.join("\n\n")
70
+ end
71
+
72
+ def add_optional_params(params)
73
+ params[:description] = url_encode(format_description_with_url) if format_description || format_url
74
+ params[:location] = url_encode(format_location) if format_location
75
+
76
+ if event.show_attendees && event.attendees&.any?
77
+ params[:attendees] = attendee_emails.join(',')
78
+ end
79
+
80
+ params
81
+ end
82
+
83
+ # Get the list of attendees if showing attendees is enabled
84
+ # @return [Array<String, Hash>] The list of attendees (email strings or
85
+ # { email:, name:, partstat: } hashes) or empty array if disabled/none present
86
+ def attendees_list
87
+ return [] unless event.show_attendees && event.attendees&.any?
88
+ event.attendees
89
+ end
90
+
91
+ # Plain email addresses for all attendees, regardless of whether they were
92
+ # given as strings or { email:, name:, partstat: } hashes.
93
+ # @return [Array<String>]
94
+ def attendee_emails
95
+ attendees_list.map { |attendee| attendee_email(attendee) }
96
+ end
97
+
98
+ # @param attendee [String, Hash] An attendee as given in Event#attendees
99
+ # @return [String] The attendee's email address
100
+ def attendee_email(attendee)
101
+ attendee.is_a?(Hash) ? attendee_hash_value(attendee, :email) : attendee.to_s
102
+ end
103
+
104
+ # Reads `key` from an attendee hash, trying both symbol and string keys.
105
+ # Unlike `attendee[:key] || attendee["key"]`, this doesn't misread an
106
+ # explicit `false` value (e.g. `rsvp: false`) as "not set".
107
+ #
108
+ # @param attendee [Hash] An attendee hash
109
+ # @param key [Symbol] The key to read
110
+ # @return [Object, nil]
111
+ def attendee_hash_value(attendee, key)
112
+ return nil unless attendee.is_a?(Hash)
113
+ return attendee[key] if attendee.key?(key)
114
+
115
+ attendee[key.to_s]
116
+ end
117
+
118
+ NO_RSVP_METHODS = %i[reply counter decline_counter].freeze
119
+
120
+ PARTSTAT_VALUES = {
121
+ accepted: "ACCEPTED",
122
+ declined: "DECLINED",
123
+ tentative: "TENTATIVE",
124
+ needs_action: "NEEDS-ACTION",
125
+ delegated: "DELEGATED"
126
+ }.freeze
127
+
128
+ # Formats a full ATTENDEE property line for iCalendar output.
129
+ #
130
+ # @param attendee [String, Hash] An email string, or a hash like
131
+ # { email:, name:, partstat:, rsvp: } for a display name, specific RSVP
132
+ # status, and/or an explicit RSVP override. `rsvp:` is rarely needed —
133
+ # e.g. a "registration confirmed" invite where the recipient is already
134
+ # `partstat: :accepted` and nothing is actually being requested, so
135
+ # `rsvp: false` suppresses `RSVP=TRUE` even under `method: :request`.
136
+ # @return [String] The formatted ATTENDEE line
137
+ def attendee_line(attendee)
138
+ email = attendee_email(attendee)
139
+ name = attendee_hash_value(attendee, :name)
140
+ partstat_key = attendee_hash_value(attendee, :partstat)
141
+ rsvp_override = attendee_hash_value(attendee, :rsvp)
142
+
143
+ cn = name ? ";CN=#{quoted_param(name)}" : ""
144
+ partstat = PARTSTAT_VALUES[partstat_key&.to_sym] || "NEEDS-ACTION"
145
+ # REPLY/COUNTER/DECLINECOUNTER all flow attendee -> organizer; RSVP=TRUE
146
+ # ("please respond") only makes sense on an organizer -> attendee REQUEST.
147
+ rsvp_default = !NO_RSVP_METHODS.include?(method)
148
+ rsvp = (rsvp_override.nil? ? rsvp_default : rsvp_override) ? ";RSVP=TRUE" : ""
149
+
150
+ "ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=#{partstat}#{rsvp}#{cn}:mailto:#{email}"
151
+ end
152
+
153
+ # Format the ORGANIZER property for iCalendar output. Like attendees, the
154
+ # organizer may be a plain email string or an { email:, name: } hash.
155
+ # @return [String, nil] The formatted ORGANIZER line, or nil if no organizer is set
156
+ def organizer_line
157
+ organizer = event.organizer
158
+ return nil unless organizer
159
+
160
+ email = attendee_email(organizer)
161
+ return nil if email.nil? || email.empty?
162
+
163
+ name = attendee_hash_value(organizer, :name)
164
+ cn = name ? ";CN=#{quoted_param(name)}" : ""
165
+ "ORGANIZER#{cn}:mailto:#{email}"
166
+ end
167
+
168
+ # The value for the calendar-level METHOD property. RFC 5545 spells this
169
+ # method "DECLINECOUNTER" (one word) despite `:decline_counter` reading more
170
+ # naturally as a Ruby symbol.
171
+ # @return [String] e.g. "REQUEST", "DECLINECOUNTER"
172
+ def method_value
173
+ method == :decline_counter ? "DECLINECOUNTER" : method.to_s.upcase
174
+ end
175
+
176
+ # The STATUS property, driven by the iCalendar METHOD in use.
177
+ # @return [String] "STATUS:CANCELLED" for :cancel, "STATUS:CONFIRMED" otherwise
178
+ def status_line
179
+ method == :cancel ? "STATUS:CANCELLED" : "STATUS:CONFIRMED"
180
+ end
181
+
182
+ # Converts a time to local wall-clock time for the event's timezone, for use in
183
+ # a `DTSTART;TZID=...`/`DTEND;TZID=...` property. Falls back to the time as given
184
+ # (unconverted) when the timezone isn't a TZInfo-recognized identifier (e.g. 'UTC'
185
+ # or a raw offset string), matching each provider's prior behavior for those cases.
186
+ #
187
+ # @param time [Time] The time to convert (interpreted as UTC)
188
+ # @return [Time] The local wall-clock time
189
+ def local_wall_time(time)
190
+ CalInvite::IcalTimezone.local_time(event.timezone, time) || time
191
+ end
192
+
193
+ # Builds the VTIMEZONE component lines for the event's timezone, if applicable.
194
+ # @return [Array<String>, nil] iCalendar lines, or nil for UTC/unrecognized timezones
195
+ def vtimezone_lines
196
+ CalInvite::IcalTimezone.vtimezone_lines(event.timezone)
197
+ end
198
+
199
+ # Format the GEO property from Event#geo.
200
+ # @return [String, nil] The formatted GEO line, or nil if no geo is set
201
+ # @raise [ArgumentError] if lat/lng aren't numeric
202
+ def geo_line
203
+ return nil unless event.geo
204
+
205
+ lat, lng = event.geo.is_a?(Hash) ? [event.geo[:lat] || event.geo["lat"], event.geo[:lng] || event.geo["lng"]] : event.geo
206
+ return nil unless lat && lng
207
+
208
+ "GEO:#{Float(lat)};#{Float(lng)}"
209
+ rescue TypeError, ArgumentError
210
+ raise ArgumentError, "geo lat/lng must be numeric (got #{event.geo.inspect})"
211
+ end
212
+
213
+ # The SEQUENCE property, from Event#sequence.
214
+ # @return [String] e.g. "SEQUENCE:0"
215
+ # @raise [ArgumentError] if sequence isn't an integer
216
+ def sequence_line
217
+ "SEQUENCE:#{Integer(event.sequence)}"
218
+ rescue TypeError, ArgumentError
219
+ raise ArgumentError, "sequence must be an integer (got #{event.sequence.inspect})"
220
+ end
221
+
222
+ # The TRANSP property, from Event#busy (default true).
223
+ # @return [String] "TRANSP:OPAQUE" (busy) or "TRANSP:TRANSPARENT" (free)
224
+ def transp_line
225
+ event.busy == false ? "TRANSP:TRANSPARENT" : "TRANSP:OPAQUE"
226
+ end
227
+
228
+ VISIBILITY_VALUES = %w[PUBLIC PRIVATE CONFIDENTIAL].freeze
229
+
230
+ # The CLASS property, from Event#visibility (default :public).
231
+ # @return [String] e.g. "CLASS:PUBLIC"
232
+ # @raise [ArgumentError] if visibility isn't :public, :private, or :confidential
233
+ def class_line
234
+ value = (event.visibility || :public).to_s.upcase
235
+ unless VISIBILITY_VALUES.include?(value)
236
+ raise ArgumentError, "visibility must be one of :public, :private, :confidential (got #{event.visibility.inspect})"
237
+ end
238
+
239
+ "CLASS:#{value}"
240
+ end
241
+
242
+ # Quotes a parameter value (e.g. CN) and escapes it per RFC 6868, so a display
243
+ # name can't close the quoted string and inject parameters, or break the line.
244
+ #
245
+ # @param value [#to_s] The raw parameter value
246
+ # @return [String] The quoted, escaped value
247
+ def quoted_param(value)
248
+ escaped = value.to_s.gsub(/\^|"|\r\n|[\r\n]/) { |match| { '^' => '^^', '"' => "^'" }.fetch(match, '^n') }
249
+ %("#{escaped}")
250
+ end
251
+
252
+ # The RRULE property, from Event#rrule, if set.
253
+ # @return [String, nil] e.g. "RRULE:FREQ=WEEKLY;COUNT=5", or nil if no rrule is set
254
+ def rrule_line
255
+ return nil unless event.rrule
256
+
257
+ value = event.rrule.to_s
258
+ value.start_with?("RRULE:") ? value : "RRULE:#{value}"
259
+ end
260
+
261
+ # Builds VALARM sub-components from Event#reminders (minutes-before-start values).
262
+ # @return [Array<String>] iCalendar lines, one VALARM block per reminder, or [] if none
263
+ def valarm_lines
264
+ return [] unless event.reminders&.any?
265
+
266
+ event.reminders.flat_map do |minutes|
267
+ ["BEGIN:VALARM", "TRIGGER:-PT#{minutes.to_i}M", "ACTION:DISPLAY", "DESCRIPTION:Reminder", "END:VALARM"]
268
+ end
269
+ end
270
+
271
+ IMPORTANCE_VALUES = { low: ["9", "0"], normal: ["5", "1"], high: ["1", "2"] }.freeze
272
+
273
+ # Standard RFC 5545 PRIORITY plus Outlook's non-standard
274
+ # X-MICROSOFT-CDO-IMPORTANCE, from Event#importance (:low/:normal/:high). Safe
275
+ # to always include: RFC 5545 requires unrecognized X- properties be ignored
276
+ # by compliant parsers, so this has no effect outside Outlook.
277
+ # @return [Array<String>] 0, 1, or 2 lines
278
+ def importance_lines
279
+ values = IMPORTANCE_VALUES[event.importance&.to_sym]
280
+ return [] unless values
281
+
282
+ priority, importance = values
283
+ ["PRIORITY:#{priority}", "X-MICROSOFT-CDO-IMPORTANCE:#{importance}"]
284
+ end
285
+
286
+ # Outlook's non-standard X-MICROSOFT-CDO-BUSYSTATUS, mirroring Event#busy.
287
+ # Included alongside the standard TRANSP property since some Outlook versions
288
+ # honor this one more reliably.
289
+ # @return [String] e.g. "X-MICROSOFT-CDO-BUSYSTATUS:BUSY"
290
+ def busystatus_line
291
+ "X-MICROSOFT-CDO-BUSYSTATUS:#{event.busy == false ? "FREE" : "BUSY"}"
292
+ end
293
+
294
+ # Outlook's non-standard X-MICROSOFT-DISALLOW-COUNTER, from Event#allow_counter
295
+ # (default true). Set Event#allow_counter = false to hide Outlook's "Propose
296
+ # New Time" action — the practical lever for "prevent attendee-initiated
297
+ # reschedules" on Outlook specifically; other clients don't expose an
298
+ # equivalent control and ignore this property.
299
+ # @return [String, nil] "X-MICROSOFT-DISALLOW-COUNTER:TRUE", or nil if counters are allowed
300
+ def disallow_counter_line
301
+ return nil if event.allow_counter != false
302
+
303
+ "X-MICROSOFT-DISALLOW-COUNTER:TRUE"
304
+ end
232
305
  end
233
306
  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
269
307
  end