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.
@@ -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 The list of attendee email addresses
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 specified provider.
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
- # @return [String] The generated calendar URL
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
- def generate_calendar_url(provider)
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
- provider
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
- def initialize(event)
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] = event.attendees.join(',')
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 or empty array if disabled/none present
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:PUBLISH",
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:#{generate_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
- if attendees = attendees_list
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
- # Only included for non-all-day events.
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 for all-day events
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
- # Generates a unique identifier for the calendar event.
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
- # Times are assumed to be in the correct timezone already.
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.